This repository has no description
12 kB
333 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 claudeMdMergeThreshold = lib.mkOption {
272 type = lib.types.float;
273 default = 0.15;
274 description = ''
275 Levenshtein distance ratio (distance / max-length) below which
276 two normalized lines are considered the same during
277 `claudeMd` merging. 0.0 requires exact match; higher values
278 tolerate more typos. Default 0.15 allows roughly one typo per
279 seven characters.
280 '';
281 };
282
283 pluginDir = lib.mkOption {
284 type = lib.types.package;
285 readOnly = true;
286 default = pluginDir;
287 defaultText = "<assembled plugin directory>";
288 description = "The assembled plugin directory passed via `--plugin-dir`.";
289 };
290
291 settingsFile = lib.mkOption {
292 type = lib.types.package;
293 readOnly = true;
294 default = settingsFile;
295 defaultText = "<assembled settings.json>";
296 description = "The assembled settings.json passed via `--settings`.";
297 };
298 };
299
300 config.package = lib.mkDefault (self.lib.halalify pkgs.claude-code);
301
302 # Always inject our plugin dir + settings file into every invocation.
303 # `--plugin-dir` is repeatable so users can still pass more on the CLI.
304 config.flags."--plugin-dir" = {
305 value = [ "${pluginDir}" ] ++ map toString config.plugins;
306 order = 100;
307 };
308 config.flags."--settings" = {
309 value = "${settingsFile}";
310 order = 101;
311 };
312
313 config.preHook = lib.optionalString (config.claudeMd != null) ''
314 CLAUDE_MD_DIR="''${CLAUDE_MD_DIR:-$HOME/.claude}"
315 mkdir -p "$CLAUDE_MD_DIR"
316 touch "$CLAUDE_MD_DIR/CLAUDE.md"
317 ${levenshtein}/bin/levenshtein-merge merge \
318 ${config.claudeMd} \
319 "$CLAUDE_MD_DIR/CLAUDE.md" \
320 ${toString config.claudeMdMergeThreshold} \
321 || echo "levenshtein merge failed; leaving CLAUDE.md untouched" >&2
322 '';
323
324 config.meta.maintainers = [
325 {
326 name = "lassulus";
327 github = "lassulus";
328 githubId = 621375;
329 }
330 ];
331 config.meta.platforms = lib.platforms.all;
332 };
333}