This repository has no description
13 kB
371 lines
1{ self, ... }:
2{
3 flake.wrapperModules.claude-code =
4 {
5 config,
6 lib,
7 ...
8 }:
9 let
10 pkgs = config.pkgs;
11 jsonFormat = pkgs.formats.json { };
12
13 # Assemble a single Claude Code plugin directory containing skills,
14 # commands, and agents. Claude Code discovers a directory as a plugin
15 # when it contains `.claude-plugin/plugin.json`.
16 pluginDir =
17 pkgs.runCommand "claude-plugin-${config.pluginName}"
18 {
19 passthru = { inherit (config) pluginManifest; };
20 }
21 ''
22 mkdir -p $out/.claude-plugin
23 cp ${jsonFormat.generate "plugin.json" config.pluginManifest} \
24 $out/.claude-plugin/plugin.json
25
26 ${lib.optionalString (config.skills != { }) ''
27 mkdir -p $out/skills
28 ${lib.concatStringsSep "\n" (
29 lib.mapAttrsToList (name: src: "ln -s ${src} $out/skills/${name}") config.skills
30 )}
31 ''}
32
33 ${lib.optionalString (config.commands != { }) ''
34 mkdir -p $out/commands
35 ${lib.concatStringsSep "\n" (
36 lib.mapAttrsToList (name: src: "ln -s ${src} $out/commands/${name}") config.commands
37 )}
38 ''}
39
40 ${lib.optionalString (config.agents != { }) ''
41 mkdir -p $out/agents
42 ${lib.concatStringsSep "\n" (
43 lib.mapAttrsToList (name: src: "ln -s ${src} $out/agents/${name}") config.agents
44 )}
45 ''}
46
47 ${lib.optionalString (config.pluginExtraFiles != { }) ''
48 ${lib.concatStringsSep "\n" (
49 lib.mapAttrsToList (path: src: ''
50 mkdir -p "$out/$(dirname ${path})"
51 ln -s ${src} "$out/${path}"
52 '') config.pluginExtraFiles
53 )}
54 ''}
55 '';
56
57 # Compose a settings.json from the typed options + freeform overrides.
58 # Order matters: typed options take precedence over freeform `settings`
59 # for the keys they own, but freeform can still set anything else.
60 mergedSettings = lib.foldl' lib.recursiveUpdate config.settings (
61 lib.optional (config.mcpServers != { }) { mcpServers = config.mcpServers; }
62 ++ lib.optional (config.hooks != { }) { hooks = config.hooks; }
63 ++ lib.optional (config.permissions != { }) { permissions = config.permissions; }
64 );
65
66 settingsFile = jsonFormat.generate "claude-settings.json" mergedSettings;
67
68 levenshtein = self.packages.${pkgs.system}.levenshtein-merge;
69 in
70 {
71 _class = "wrapper";
72
73 options = {
74 pluginName = lib.mkOption {
75 type = lib.types.str;
76 default = "superconfig";
77 description = "Name of the bundled plugin directory.";
78 };
79
80 pluginManifest = lib.mkOption {
81 type = jsonFormat.type;
82 default = {
83 name = config.pluginName;
84 version = "0.0.0";
85 description = "Nix-managed Claude Code plugin bundle.";
86 };
87 defaultText = lib.literalExpression ''
88 { name = config.pluginName; version = "0.0.0"; description = "..."; }
89 '';
90 description = "Contents of `.claude-plugin/plugin.json` for the bundled plugin.";
91 };
92
93 skills = lib.mkOption {
94 type = lib.types.attrsOf lib.types.path;
95 default = { };
96 example = lib.literalExpression ''
97 {
98 websearch = ./skills/websearch;
99 }
100 '';
101 description = ''
102 Skills to bundle. Each entry is a directory containing `SKILL.md`
103 and any helper scripts/resources. Symlinked into
104 `<plugin>/skills/<name>` and discovered by Claude Code automatically.
105 '';
106 };
107
108 commands = lib.mkOption {
109 type = lib.types.attrsOf lib.types.path;
110 default = { };
111 description = ''
112 Slash commands. Each entry is a markdown file (or directory)
113 symlinked into `<plugin>/commands/<name>`.
114 '';
115 };
116
117 agents = lib.mkOption {
118 type = lib.types.attrsOf lib.types.path;
119 default = { };
120 description = ''
121 Sub-agents. Each entry is a markdown file (or directory) symlinked
122 into `<plugin>/agents/<name>`.
123 '';
124 };
125
126 pluginExtraFiles = lib.mkOption {
127 type = lib.types.attrsOf lib.types.path;
128 default = { };
129 example = lib.literalExpression ''
130 {
131 "hooks/format.sh" = ./hooks/format.sh;
132 }
133 '';
134 description = ''
135 Additional files to drop into the plugin directory. Keys are
136 relative paths inside the plugin directory; values are paths to
137 symlink in. Use this for hook scripts or anything else not covered
138 by the typed options above.
139 '';
140 };
141
142 plugins = lib.mkOption {
143 type = lib.types.listOf lib.types.path;
144 default = [ ];
145 example = lib.literalExpression ''
146 [
147 # A fetched plugin from GitHub:
148 (pkgs.fetchFromGitHub {
149 owner = "Myr-Aya";
150 repo = "GouvernAI-claude-code-plugin";
151 rev = "main";
152 hash = "sha256-AAAA...";
153 # The plugin directory is a subdirectory of the repo:
154 } + "/gouvernai")
155
156 # A local plugin directory (must contain .claude-plugin/plugin.json):
157 ./my-local-plugin
158
159 # A plugin built by a Nix derivation:
160 my-plugin-package
161 ]
162 '';
163 description = ''
164 Additional Claude Code plugin directories to load. Each entry
165 must be a path to a directory containing `.claude-plugin/plugin.json`.
166 Passed as extra `--plugin-dir` flags alongside the bundled plugin.
167
168 Plugins can provide skills, commands, agents, and hooks. Use this
169 to declaratively install third-party plugins (e.g. from GitHub)
170 or locally developed ones without going through the Claude Code
171 marketplace.
172 '';
173 };
174
175 mcpServers = lib.mkOption {
176 type = jsonFormat.type;
177 default = { };
178 example = lib.literalExpression ''
179 {
180 git-mcp = {
181 command = "''${pkgs.git-mcp}/bin/git-mcp";
182 };
183 tmux-mcp = {
184 type = "stdio";
185 command = "''${pkgs.tmux-mcp}/bin/tmux-mcp";
186 args = [ ];
187 env = { };
188 };
189 }
190 '';
191 description = ''
192 MCP servers to register. Merged into `settings.json` under
193 `mcpServers`. Each entry follows Claude Code's MCP server schema
194 (stdio: `{ command, args, env }`; http/sse: `{ type, url, headers }`).
195 '';
196 };
197
198 hooks = lib.mkOption {
199 type = jsonFormat.type;
200 default = { };
201 example = lib.literalExpression ''
202 {
203 PreToolUse = [
204 {
205 matcher = "*";
206 hooks = [
207 {
208 type = "command";
209 command = "''${pkgs.my-permission-bridge}/bin/permission-forward";
210 }
211 ];
212 }
213 ];
214 }
215 '';
216 description = ''
217 Hook definitions. Shape matches Claude Code's `settings.json` hooks
218 field. Supported events include `PreToolUse`, `PostToolUse`,
219 `UserPromptSubmit`, `Stop`, `SessionStart`, `Notification`, and
220 `PreCompact`.
221 '';
222 };
223
224 permissions = lib.mkOption {
225 type = jsonFormat.type;
226 default = { };
227 example = lib.literalExpression ''
228 {
229 defaultMode = "acceptEdits";
230 allow = [
231 "Bash(nix build:*)"
232 "Bash(nix eval:*)"
233 "Bash(nix fmt:*)"
234 "Bash(git status:*)"
235 "Bash(git diff:*)"
236 ];
237 deny = [ ];
238 ask = [ ];
239 }
240 '';
241 description = ''
242 Permission rules. Shape matches Claude Code's `settings.json`
243 `permissions` field. Use `allow`/`deny`/`ask` pattern lists to
244 short-circuit the permission flow before any hook fires.
245 '';
246 };
247
248 settings = lib.mkOption {
249 type = jsonFormat.type;
250 default = { };
251 description = ''
252 Freeform additional `settings.json` content. Merged with the
253 typed options above (which take precedence on conflict). Use this
254 for anything not covered by a dedicated option.
255 '';
256 };
257
258 claudeMd = lib.mkOption {
259 type = lib.types.nullOr lib.types.path;
260 default = null;
261 description = ''
262 Optional `CLAUDE.md` whose lines are fuzzy-merged into
263 `$CLAUDE_MD_DIR/CLAUDE.md` (default `~/.claude/CLAUDE.md`) on
264 every invocation. Only adds lines, never removes user content.
265 Each non-blank managed line is skipped when a line within
266 `claudeMdMergeThreshold` (Levenshtein ratio) already exists in
267 the target file.
268 '';
269 };
270
271 projectStateWipeKeys = lib.mkOption {
272 type = lib.types.listOf lib.types.str;
273 default = [ "mcpServers" ];
274 description = ''
275 Keys to delete from every `projects.<dir>` entry of
276 `~/.claude.json` on each invocation. Use this to purge
277 unmanaged runtime drift that the wrapper otherwise can't
278 override — most notably stale `mcpServers` registrations
279 created by `claude mcp add`, which pin GC-able `/nix/store`
280 paths and shadow the canonical set the wrapper writes via
281 `--settings`.
282
283 Anything not listed here is left alone, so new fields
284 claude-code may add in future versions are preserved by
285 default. Set to `[ ]` to disable the cleanup step.
286 '';
287 };
288
289 claudeMdMergeThreshold = lib.mkOption {
290 type = lib.types.float;
291 default = 0.15;
292 description = ''
293 Levenshtein distance ratio (distance / max-length) below which
294 two normalized lines are considered the same during
295 `claudeMd` merging. 0.0 requires exact match; higher values
296 tolerate more typos. Default 0.15 allows roughly one typo per
297 seven characters.
298 '';
299 };
300
301 pluginDir = lib.mkOption {
302 type = lib.types.package;
303 readOnly = true;
304 default = pluginDir;
305 defaultText = "<assembled plugin directory>";
306 description = "The assembled plugin directory passed via `--plugin-dir`.";
307 };
308
309 settingsFile = lib.mkOption {
310 type = lib.types.package;
311 readOnly = true;
312 default = settingsFile;
313 defaultText = "<assembled settings.json>";
314 description = "The assembled settings.json passed via `--settings`.";
315 };
316 };
317
318 config.package = lib.mkDefault (self.lib.halalify pkgs.claude-code);
319
320 # Always inject our plugin dir + settings file into every invocation.
321 # `--plugin-dir` is repeatable so users can still pass more on the CLI.
322 config.flags."--plugin-dir" = {
323 value = [ "${pluginDir}" ] ++ map toString config.plugins;
324 order = 100;
325 };
326 config.flags."--settings" = {
327 value = "${settingsFile}";
328 order = 101;
329 };
330
331 config.preHook =
332 lib.optionalString (config.claudeMd != null) ''
333 CLAUDE_MD_DIR="''${CLAUDE_MD_DIR:-$HOME/.claude}"
334 mkdir -p "$CLAUDE_MD_DIR"
335 touch "$CLAUDE_MD_DIR/CLAUDE.md"
336 ${levenshtein}/bin/levenshtein-merge merge \
337 ${config.claudeMd} \
338 "$CLAUDE_MD_DIR/CLAUDE.md" \
339 ${toString config.claudeMdMergeThreshold} \
340 || echo "levenshtein merge failed; leaving CLAUDE.md untouched" >&2
341 ''
342 + lib.optionalString (config.projectStateWipeKeys != [ ]) ''
343 CLAUDE_JSON="$HOME/.claude.json"
344 if [ -f "$CLAUDE_JSON" ]; then
345 CLAUDE_JSON_TMP=$(mktemp "$CLAUDE_JSON.XXXXXX")
346 if ${pkgs.jq}/bin/jq \
347 --argjson wipe ${lib.escapeShellArg (builtins.toJSON config.projectStateWipeKeys)} '
348 if .projects then
349 .projects |= with_entries(
350 .value |= reduce $wipe[] as $k (.; del(.[$k]))
351 )
352 else . end
353 ' "$CLAUDE_JSON" > "$CLAUDE_JSON_TMP"; then
354 mv "$CLAUDE_JSON_TMP" "$CLAUDE_JSON"
355 else
356 rm -f "$CLAUDE_JSON_TMP"
357 echo "claude.json projects-state cleanup failed; leaving file untouched" >&2
358 fi
359 fi
360 '';
361
362 config.meta.maintainers = [
363 {
364 name = "lassulus";
365 github = "lassulus";
366 githubId = 621375;
367 }
368 ];
369 config.meta.platforms = lib.platforms.all;
370 };
371}