This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

wrapperModules.claude-code: add declarative wrapper module

- wrapperModules/claude-code: declarative options for skills, commands,
agents, mcpServers, hooks, permissions, settings, and claudeMd
- Assembles a plugin directory (--plugin-dir) and settings.json
(--settings) injected into every claude-code invocation
- claudeMd lines are fuzzy-merged into ~/.claude/CLAUDE.md using
Levenshtein distance (only adds, never removes user content)
- 5pkgs/levenshtein: small C tool for edit distance, threshold check,
and line-level file merge
- tools/claude: rewritten to consume the new wrapper module

+507 -30
+1
flake.nix
··· 193 193 ./games/flake-module.nix 194 194 195 195 ./wrapperModules/pi/flake-module.nix 196 + ./wrapperModules/claude-code/flake-module.nix 196 197 { 197 198 options.flake.wrapperModules = nixpkgs.lib.mkOption { 198 199 type = nixpkgs.lib.types.lazyAttrsOf nixpkgs.lib.types.raw;
+157
5pkgs/levenshtein-merge/levenshtein.c
··· 1 + #include <stdio.h> 2 + #include <stdlib.h> 3 + #include <string.h> 4 + 5 + static int min3(int a, int b, int c) { 6 + if (a < b) return a < c ? a : c; 7 + return b < c ? b : c; 8 + } 9 + 10 + static int distance(const char *a, const char *b) { 11 + size_t la = strlen(a), lb = strlen(b); 12 + int *prev = calloc(lb + 1, sizeof(int)); 13 + if (!prev) exit(1); 14 + for (size_t j = 0; j <= lb; j++) prev[j] = (int)j; 15 + for (size_t i = 1; i <= la; i++) { 16 + int corner = prev[0]; 17 + prev[0] = (int)i; 18 + for (size_t j = 1; j <= lb; j++) { 19 + int val = min3(prev[j] + 1, prev[j-1] + 1, 20 + corner + (a[i-1] != b[j-1])); 21 + corner = prev[j]; 22 + prev[j] = val; 23 + } 24 + } 25 + int res = prev[lb]; 26 + free(prev); 27 + return res; 28 + } 29 + 30 + /* Collapse whitespace and trim. Returns malloc'd string. */ 31 + static char *normalize(const char *s) { 32 + while (*s == ' ' || *s == '\t') s++; 33 + size_t len = strlen(s); 34 + char *out = malloc(len + 1); 35 + if (!out) exit(1); 36 + size_t o = 0; 37 + int in_space = 0; 38 + for (size_t i = 0; i < len; i++) { 39 + if (s[i] == ' ' || s[i] == '\t') { 40 + if (!in_space && o > 0) out[o++] = ' '; 41 + in_space = 1; 42 + } else { 43 + out[o++] = s[i]; 44 + in_space = 0; 45 + } 46 + } 47 + if (o > 0 && out[o-1] == ' ') o--; 48 + out[o] = '\0'; 49 + return out; 50 + } 51 + 52 + static int fuzzy_match(const char *a, const char *b, double threshold) { 53 + int dist = distance(a, b); 54 + size_t la = strlen(a), lb = strlen(b); 55 + size_t m = la > lb ? la : lb; 56 + if (m == 0) return 1; 57 + return (double)dist / (double)m <= threshold; 58 + } 59 + 60 + /* Dynamic line array */ 61 + struct lines { char **l; size_t n, cap; }; 62 + 63 + static void lines_init(struct lines *v) { 64 + v->n = 0; v->cap = 64; 65 + v->l = malloc(v->cap * sizeof(char *)); 66 + if (!v->l) exit(1); 67 + } 68 + 69 + static void lines_push(struct lines *v, char *s) { 70 + if (v->n == v->cap) { 71 + v->cap *= 2; 72 + v->l = realloc(v->l, v->cap * sizeof(char *)); 73 + if (!v->l) exit(1); 74 + } 75 + v->l[v->n++] = s; 76 + } 77 + 78 + static void read_lines(const char *path, struct lines *v) { 79 + FILE *f = fopen(path, "r"); 80 + if (!f) return; 81 + char *line = NULL; 82 + size_t cap = 0; 83 + ssize_t len; 84 + while ((len = getline(&line, &cap, f)) != -1) { 85 + if (len > 0 && line[len-1] == '\n') line[len-1] = '\0'; 86 + lines_push(v, strdup(line)); 87 + } 88 + free(line); 89 + fclose(f); 90 + } 91 + 92 + int main(int argc, char **argv) { 93 + if (argc == 3) { 94 + printf("%d\n", distance(argv[1], argv[2])); 95 + return 0; 96 + } 97 + if (argc == 5 && strcmp(argv[1], "-t") == 0) { 98 + double t = atof(argv[2]); 99 + return fuzzy_match(argv[3], argv[4], t) ? 0 : 1; 100 + } 101 + if (argc == 5 && strcmp(argv[1], "merge") == 0) { 102 + double threshold = atof(argv[4]); 103 + 104 + struct lines target, managed, known; 105 + lines_init(&target); 106 + lines_init(&managed); 107 + lines_init(&known); 108 + 109 + read_lines(argv[3], &target); 110 + read_lines(argv[2], &managed); 111 + 112 + /* Build normalized index of target lines */ 113 + for (size_t i = 0; i < target.n; i++) { 114 + char *n = normalize(target.l[i]); 115 + if (n[0]) lines_push(&known, n); 116 + else free(n); 117 + } 118 + 119 + /* Find managed lines missing from target */ 120 + struct lines missing; 121 + lines_init(&missing); 122 + 123 + for (size_t i = 0; i < managed.n; i++) { 124 + char *n = normalize(managed.l[i]); 125 + if (!n[0]) { free(n); continue; } 126 + int found = 0; 127 + for (size_t j = 0; j < known.n; j++) { 128 + if (fuzzy_match(n, known.l[j], threshold)) { 129 + found = 1; 130 + break; 131 + } 132 + } 133 + if (!found) { 134 + lines_push(&missing, managed.l[i]); 135 + lines_push(&known, n); 136 + } else { 137 + free(n); 138 + } 139 + } 140 + 141 + if (missing.n == 0) return 0; 142 + 143 + FILE *f = fopen(argv[3], "a"); 144 + if (!f) { perror(argv[3]); return 1; } 145 + fprintf(f, "\n"); 146 + for (size_t i = 0; i < missing.n; i++) 147 + fprintf(f, "%s\n", missing.l[i]); 148 + fclose(f); 149 + return 0; 150 + } 151 + 152 + fprintf(stderr, 153 + "usage: levenshtein-merge <a> <b>\n" 154 + " levenshtein-merge -t <threshold> <a> <b>\n" 155 + " levenshtein-merge merge <managed> <target> <threshold>\n"); 156 + return 2; 157 + }
+25
5pkgs/levenshtein-merge/package.nix
··· 1 + { 2 + lib, 3 + stdenv, 4 + }: 5 + stdenv.mkDerivation { 6 + pname = "levenshtein-merge"; 7 + version = "0.1.0"; 8 + 9 + src = ./.; 10 + 11 + buildPhase = '' 12 + $CC -O2 -o levenshtein-merge levenshtein.c 13 + ''; 14 + 15 + installPhase = '' 16 + install -Dm755 levenshtein-merge $out/bin/levenshtein-merge 17 + ''; 18 + 19 + meta = { 20 + description = "Fuzzy line-level file merge using Levenshtein distance"; 21 + license = lib.licenses.mit; 22 + mainProgram = "levenshtein-merge"; 23 + platforms = lib.platforms.all; 24 + }; 25 + }
+24 -30
tools/claude/flake-module.nix
··· 3 3 perSystem = 4 4 { pkgs, system, ... }: 5 5 let 6 - # Get paths to MCP servers 7 - gitMcpPath = "${self.packages.${system}.git-mcp}/bin/git-mcp"; 8 - tmuxMcpPath = "${self.packages.${system}.tmux-mcp}/bin/tmux-mcp"; 6 + claude = inputs.wrappers.lib.wrapModule { 7 + imports = [ self.wrapperModules.claude-code ]; 8 + inherit pkgs; 9 9 10 - # Path to CLAUDE.md 11 - claudeMd = ./CLAUDE.md; 10 + claudeMd = ./CLAUDE.md; 11 + 12 + mcpServers = { 13 + git-mcp = { 14 + command = "${self.packages.${system}.git-mcp}/bin/git-mcp"; 15 + }; 16 + tmux-mcp = { 17 + command = "${self.packages.${system}.tmux-mcp}/bin/tmux-mcp"; 18 + }; 19 + }; 20 + 21 + # Seed permission rules from the same set we trust in pi. 22 + permissions = { 23 + allow = [ 24 + "Bash(nix build:*)" 25 + "Bash(nix eval:*)" 26 + "Bash(nix fmt:*)" 27 + ]; 28 + }; 29 + }; 12 30 in 13 31 { 14 - packages.claude = inputs.wrappers.lib.wrapPackage { 15 - pkgs = pkgs; 16 - package = (self.lib.halalify pkgs.claude-code); 17 - wrapper = 18 - { exePath, ... }: 19 - '' 20 - set -efu 21 - set -x 22 - # Configure MCP servers using Claude CLI 23 - if ${exePath} mcp add git-mcp "${gitMcpPath}" 2>/dev/null && \ 24 - ${exePath} mcp add tmux-mcp "${tmuxMcpPath}" 2>/dev/null; then 25 - echo "MCP servers configured successfully" >&2 26 - else 27 - echo "Warning: Could not configure MCP servers" >&2 28 - fi 29 - 30 - # Set up CLAUDE.md if it doesn't exist 31 - CLAUDE_MD_DIR="''${CLAUDE_MD_DIR:-$HOME/.claude}" 32 - mkdir -p "$CLAUDE_MD_DIR" 33 - if [[ ! -f "$CLAUDE_MD_DIR/CLAUDE.md" ]]; then 34 - cp ${claudeMd} "$CLAUDE_MD_DIR/CLAUDE.md" 35 - fi 36 - exec ${exePath} "$@" 37 - ''; 38 - }; 32 + packages.claude = claude.wrapper; 39 33 }; 40 34 }
+300
wrapperModules/claude-code/flake-module.nix
··· 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 + }