This repository has no description
10 kB
300 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 mcpServers = lib.mkOption {
143 type = jsonFormat.type;
144 default = { };
145 example = lib.literalExpression ''
146 {
147 git-mcp = {
148 command = "''${pkgs.git-mcp}/bin/git-mcp";
149 };
150 tmux-mcp = {
151 type = "stdio";
152 command = "''${pkgs.tmux-mcp}/bin/tmux-mcp";
153 args = [ ];
154 env = { };
155 };
156 }
157 '';
158 description = ''
159 MCP servers to register. Merged into `settings.json` under
160 `mcpServers`. Each entry follows Claude Code's MCP server schema
161 (stdio: `{ command, args, env }`; http/sse: `{ type, url, headers }`).
162 '';
163 };
164
165 hooks = lib.mkOption {
166 type = jsonFormat.type;
167 default = { };
168 example = lib.literalExpression ''
169 {
170 PreToolUse = [
171 {
172 matcher = "*";
173 hooks = [
174 {
175 type = "command";
176 command = "''${pkgs.my-permission-bridge}/bin/permission-forward";
177 }
178 ];
179 }
180 ];
181 }
182 '';
183 description = ''
184 Hook definitions. Shape matches Claude Code's `settings.json` hooks
185 field. Supported events include `PreToolUse`, `PostToolUse`,
186 `UserPromptSubmit`, `Stop`, `SessionStart`, `Notification`, and
187 `PreCompact`.
188 '';
189 };
190
191 permissions = lib.mkOption {
192 type = jsonFormat.type;
193 default = { };
194 example = lib.literalExpression ''
195 {
196 defaultMode = "acceptEdits";
197 allow = [
198 "Bash(nix build:*)"
199 "Bash(nix eval:*)"
200 "Bash(nix fmt:*)"
201 "Bash(git status:*)"
202 "Bash(git diff:*)"
203 ];
204 deny = [ ];
205 ask = [ ];
206 }
207 '';
208 description = ''
209 Permission rules. Shape matches Claude Code's `settings.json`
210 `permissions` field. Use `allow`/`deny`/`ask` pattern lists to
211 short-circuit the permission flow before any hook fires.
212 '';
213 };
214
215 settings = lib.mkOption {
216 type = jsonFormat.type;
217 default = { };
218 description = ''
219 Freeform additional `settings.json` content. Merged with the
220 typed options above (which take precedence on conflict). Use this
221 for anything not covered by a dedicated option.
222 '';
223 };
224
225 claudeMd = lib.mkOption {
226 type = lib.types.nullOr lib.types.path;
227 default = null;
228 description = ''
229 Optional `CLAUDE.md` whose lines are fuzzy-merged into
230 `$CLAUDE_MD_DIR/CLAUDE.md` (default `~/.claude/CLAUDE.md`) on
231 every invocation. Only adds lines, never removes user content.
232 Each non-blank managed line is skipped when a line within
233 `claudeMdMergeThreshold` (Levenshtein ratio) already exists in
234 the target file.
235 '';
236 };
237
238 claudeMdMergeThreshold = lib.mkOption {
239 type = lib.types.float;
240 default = 0.15;
241 description = ''
242 Levenshtein distance ratio (distance / max-length) below which
243 two normalized lines are considered the same during
244 `claudeMd` merging. 0.0 requires exact match; higher values
245 tolerate more typos. Default 0.15 allows roughly one typo per
246 seven characters.
247 '';
248 };
249
250 pluginDir = lib.mkOption {
251 type = lib.types.package;
252 readOnly = true;
253 default = pluginDir;
254 defaultText = "<assembled plugin directory>";
255 description = "The assembled plugin directory passed via `--plugin-dir`.";
256 };
257
258 settingsFile = lib.mkOption {
259 type = lib.types.package;
260 readOnly = true;
261 default = settingsFile;
262 defaultText = "<assembled settings.json>";
263 description = "The assembled settings.json passed via `--settings`.";
264 };
265 };
266
267 config.package = lib.mkDefault (self.lib.halalify pkgs.claude-code);
268
269 # Always inject our plugin dir + settings file into every invocation.
270 # `--plugin-dir` is repeatable so users can still pass more on the CLI.
271 config.flags."--plugin-dir" = {
272 value = [ "${pluginDir}" ];
273 order = 100;
274 };
275 config.flags."--settings" = {
276 value = "${settingsFile}";
277 order = 101;
278 };
279
280 config.preHook = lib.optionalString (config.claudeMd != null) ''
281 CLAUDE_MD_DIR="''${CLAUDE_MD_DIR:-$HOME/.claude}"
282 mkdir -p "$CLAUDE_MD_DIR"
283 touch "$CLAUDE_MD_DIR/CLAUDE.md"
284 ${levenshtein}/bin/levenshtein-merge merge \
285 ${config.claudeMd} \
286 "$CLAUDE_MD_DIR/CLAUDE.md" \
287 ${toString config.claudeMdMergeThreshold} \
288 || echo "levenshtein merge failed; leaving CLAUDE.md untouched" >&2
289 '';
290
291 config.meta.maintainers = [
292 {
293 name = "lassulus";
294 github = "lassulus";
295 githubId = 621375;
296 }
297 ];
298 config.meta.platforms = lib.platforms.all;
299 };
300}