diff --git a/modules/misc/news/2025/08/2025-08-15_15-51-02.nix b/modules/misc/news/2025/08/2025-08-15_15-51-02.nix new file mode 100644 index 000000000..b4e62d8f9 --- /dev/null +++ b/modules/misc/news/2025/08/2025-08-15_15-51-02.nix @@ -0,0 +1,17 @@ +{ + time = "2025-08-15T20:51:02+00:00"; + condition = true; + message = '' + A new module is available: 'programs.claude-code'. + + Claude Code is Anthropic's official CLI for Claude, providing an + interactive command-line interface for AI-assisted development. + + The module supports: + - Configuration through 'programs.claude-code.settings' + - Custom agents via 'programs.claude-code.agents' + - Custom commands via 'programs.claude-code.commands' + - MCP (Model Context Protocol) servers via 'programs.claude-code.mcpServers' + - Package installation control via 'programs.claude-code.package' + ''; +} diff --git a/modules/programs/claude-code.nix b/modules/programs/claude-code.nix new file mode 100644 index 000000000..e86443944 --- /dev/null +++ b/modules/programs/claude-code.nix @@ -0,0 +1,261 @@ +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.programs.claude-code; + jsonFormat = pkgs.formats.json { }; +in +{ + meta.maintainers = [ lib.maintainers.khaneliman ]; + + options.programs.claude-code = { + enable = lib.mkEnableOption "Claude Code, Anthropic's official CLI"; + + package = lib.mkPackageOption pkgs "claude-code" { nullable = true; }; + + finalPackage = lib.mkOption { + type = lib.types.package; + readOnly = true; + internal = true; + description = "Resulting customized claude-code package."; + }; + + settings = lib.mkOption { + inherit (jsonFormat) type; + default = { }; + example = { + theme = "dark"; + permissions = { + allow = [ + "Bash(git diff:*)" + "Edit" + ]; + ask = [ "Bash(git push:*)" ]; + deny = [ + "WebFetch" + "Bash(curl:*)" + "Read(./.env)" + "Read(./secrets/**)" + ]; + additionalDirectories = [ "../docs/" ]; + defaultMode = "acceptEdits"; + disableBypassPermissionsMode = "disable"; + }; + model = "claude-3-5-sonnet-20241022"; + hooks = { + PreToolUse = [ + { + matcher = "Bash"; + hooks = [ + { + type = "command"; + command = "echo 'Running command: $CLAUDE_TOOL_INPUT'"; + } + ]; + } + ]; + PostToolUse = [ + { + matcher = "Edit|MultiEdit|Write"; + hooks = [ + { + type = "command"; + command = "nix fmt $(jq -r '.tool_input.file_path' <<< '$CLAUDE_TOOL_INPUT')"; + } + ]; + } + ]; + }; + statusLine = { + type = "command"; + command = "input=$(cat); echo \"[$(echo \"$input\" | jq -r '.model.display_name')] 📁 $(basename \"$(echo \"$input\" | jq -r '.workspace.current_dir')\")\""; + padding = 0; + }; + includeCoAuthoredBy = false; + }; + description = "JSON configuration for Claude Code settings.json"; + }; + + agents = lib.mkOption { + type = lib.types.attrsOf lib.types.lines; + default = { }; + description = '' + Custom agents for Claude Code. + The attribute name becomes the agent filename, and the value is the file content with frontmatter. + Agents are stored in .claude/agents/ directory. + ''; + example = { + code-reviewer = '' + --- + name: code-reviewer + description: Specialized code review agent + tools: Read, Edit, Grep + --- + + You are a senior software engineer specializing in code reviews. + Focus on code quality, security, and maintainability. + ''; + documentation = '' + --- + name: documentation + description: Documentation writing assistant + model: claude-3-5-sonnet-20241022 + tools: Read, Write, Edit + --- + + You are a technical writer who creates clear, comprehensive documentation. + Focus on user-friendly explanations and examples. + ''; + }; + }; + + commands = lib.mkOption { + type = lib.types.attrsOf lib.types.lines; + default = { }; + description = '' + Custom commands for Claude Code. + The attribute name becomes the command filename, and the value is the file content. + Commands are stored in .claude/commands/ directory. + ''; + example = { + changelog = '' + --- + allowed-tools: Bash(git log:*), Bash(git diff:*) + argument-hint: [version] [change-type] [message] + description: Update CHANGELOG.md with new entry + --- + Parse the version, change type, and message from the input + and update the CHANGELOG.md file accordingly. + ''; + fix-issue = '' + --- + allowed-tools: Bash(git status:*), Read + argument-hint: [issue-number] + description: Fix GitHub issue following coding standards + --- + Fix issue #$ARGUMENTS following our coding standards and best practices. + ''; + commit = '' + --- + allowed-tools: Bash(git add:*), Bash(git status:*), Bash(git commit:*) + description: Create a git commit with proper message + --- + ## Context + + - Current git status: !`git status` + - Current git diff: !`git diff HEAD` + - Recent commits: !`git log --oneline -5` + + ## Task + + Based on the changes above, create a single atomic git commit with a descriptive message. + ''; + }; + }; + + mcpServers = lib.mkOption { + type = lib.types.attrsOf jsonFormat.type; + default = { }; + description = "MCP (Model Context Protocol) servers configuration"; + example = { + github = { + type = "http"; + url = "https://api.githubcopilot.com/mcp/"; + }; + filesystem = { + type = "stdio"; + command = "npx"; + args = [ + "-y" + "@modelcontextprotocol/server-filesystem" + "/tmp" + ]; + }; + database = { + type = "stdio"; + command = "npx"; + args = [ + "-y" + "@bytebase/dbhub" + "--dsn" + "postgresql://user:pass@localhost:5432/db" + ]; + env = { + DATABASE_URL = "postgresql://user:pass@localhost:5432/db"; + }; + }; + customTransport = { + type = "websocket"; + url = "wss://example.com/mcp"; + customOption = "value"; + timeout = 5000; + }; + }; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.mcpServers == { } || cfg.package != null; + message = "`programs.claude-code.package` cannot be null when `mcpServers` is configured"; + } + ]; + + programs.claude-code.finalPackage = + let + makeWrapperArgs = lib.flatten ( + lib.filter (x: x != [ ]) [ + (lib.optional (cfg.mcpServers != { }) [ + "--add-flags" + "--mcp-config ${jsonFormat.generate "claude-code-mcp-config.json" { inherit (cfg) mcpServers; }}" + ]) + ] + ); + + hasWrapperArgs = makeWrapperArgs != [ ]; + in + if hasWrapperArgs then + pkgs.symlinkJoin { + name = "claude-code"; + paths = [ cfg.package ]; + nativeBuildInputs = [ pkgs.makeWrapper ]; + postBuild = '' + wrapProgram $out/bin/claude ${lib.escapeShellArgs makeWrapperArgs} + ''; + inherit (cfg.package) meta; + } + else + cfg.package; + + home = { + packages = lib.mkIf (cfg.package != null) [ cfg.finalPackage ]; + + file = { + ".claude/settings.json" = lib.mkIf (cfg.settings != { }) { + source = jsonFormat.generate "claude-code-settings.json" ( + cfg.settings + // { + "$schema" = "https://json.schemastore.org/claude-code-settings.json"; + } + ); + }; + } + // lib.mapAttrs' ( + name: content: + lib.nameValuePair ".claude/agents/${name}.md" { + text = content; + } + ) cfg.agents + // lib.mapAttrs' ( + name: content: + lib.nameValuePair ".claude/commands/${name}.md" { + text = content; + } + ) cfg.commands; + }; + }; +} diff --git a/tests/darwinScrublist.nix b/tests/darwinScrublist.nix index eb061dd24..eb4c4f86e 100644 --- a/tests/darwinScrublist.nix +++ b/tests/darwinScrublist.nix @@ -27,6 +27,7 @@ let "btop" "carapace" "cava" + "claude-code" "clock-rs" "cmus" "codex" diff --git a/tests/modules/programs/claude-code/assertion.nix b/tests/modules/programs/claude-code/assertion.nix new file mode 100644 index 000000000..b1d5f0427 --- /dev/null +++ b/tests/modules/programs/claude-code/assertion.nix @@ -0,0 +1,22 @@ +{ + programs.claude-code = { + enable = true; + package = null; + + mcpServers = { + filesystem = { + type = "stdio"; + command = "npx"; + args = [ + "-y" + "@modelcontextprotocol/server-filesystem" + "/tmp" + ]; + }; + }; + }; + + test.asserts.assertions.expected = [ + "`programs.claude-code.package` cannot be null when `mcpServers` is configured" + ]; +} diff --git a/tests/modules/programs/claude-code/basic.nix b/tests/modules/programs/claude-code/basic.nix new file mode 100644 index 000000000..62a4998eb --- /dev/null +++ b/tests/modules/programs/claude-code/basic.nix @@ -0,0 +1,7 @@ +{ + programs.claude-code.enable = true; + + nmt.script = '' + assertPathNotExists home-files/.claude + ''; +} diff --git a/tests/modules/programs/claude-code/default.nix b/tests/modules/programs/claude-code/default.nix new file mode 100644 index 000000000..3c98cad83 --- /dev/null +++ b/tests/modules/programs/claude-code/default.nix @@ -0,0 +1,6 @@ +{ + claude-code-basic = ./basic.nix; + claude-code-full-config = ./full-config.nix; + claude-code-mcp = ./mcp.nix; + claude-code-assertion = ./assertion.nix; +} diff --git a/tests/modules/programs/claude-code/expected-changelog b/tests/modules/programs/claude-code/expected-changelog new file mode 100644 index 000000000..d09919f98 --- /dev/null +++ b/tests/modules/programs/claude-code/expected-changelog @@ -0,0 +1,7 @@ +--- +allowed-tools: Bash(git log:*), Bash(git diff:*) +argument-hint: [version] [change-type] [message] +description: Update CHANGELOG.md with new entry +--- +Parse the version, change type, and message from the input +and update the CHANGELOG.md file accordingly. diff --git a/tests/modules/programs/claude-code/expected-code-reviewer.md b/tests/modules/programs/claude-code/expected-code-reviewer.md new file mode 100644 index 000000000..87edb80ad --- /dev/null +++ b/tests/modules/programs/claude-code/expected-code-reviewer.md @@ -0,0 +1,8 @@ +--- +name: code-reviewer +description: Specialized code review agent +tools: Read, Edit, Grep +--- + +You are a senior software engineer specializing in code reviews. +Focus on code quality, security, and maintainability. diff --git a/tests/modules/programs/claude-code/expected-commit b/tests/modules/programs/claude-code/expected-commit new file mode 100644 index 000000000..91fe6f0b2 --- /dev/null +++ b/tests/modules/programs/claude-code/expected-commit @@ -0,0 +1,13 @@ +--- +allowed-tools: Bash(git add:*), Bash(git status:*), Bash(git commit:*) +description: Create a git commit with proper message +--- +## Context + +- Current git status: !`git status` +- Current git diff: !`git diff HEAD` +- Recent commits: !`git log --oneline -5` + +## Task + +Based on the changes above, create a single atomic git commit with a descriptive message. diff --git a/tests/modules/programs/claude-code/expected-documentation.md b/tests/modules/programs/claude-code/expected-documentation.md new file mode 100644 index 000000000..ba13554b6 --- /dev/null +++ b/tests/modules/programs/claude-code/expected-documentation.md @@ -0,0 +1,9 @@ +--- +name: documentation +description: Documentation writing assistant +model: claude-3-5-sonnet-20241022 +tools: Read, Write, Edit +--- + +You are a technical writer who creates clear, comprehensive documentation. +Focus on user-friendly explanations and examples. diff --git a/tests/modules/programs/claude-code/expected-mcp-wrapper b/tests/modules/programs/claude-code/expected-mcp-wrapper new file mode 100644 index 000000000..636f0ff9a --- /dev/null +++ b/tests/modules/programs/claude-code/expected-mcp-wrapper @@ -0,0 +1,2 @@ +#! /nix/store/00000000000000000000000000000000-bash/bin/bash -e +exec -a "$0" "/nix/store/00000000000000000000000000000000-claude-code/bin/.claude-wrapped" --mcp-config /nix/store/00000000000000000000000000000000-claude-code-mcp-config.json "$@" diff --git a/tests/modules/programs/claude-code/expected-settings.json b/tests/modules/programs/claude-code/expected-settings.json new file mode 100644 index 000000000..edb2e5898 --- /dev/null +++ b/tests/modules/programs/claude-code/expected-settings.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "command": "echo 'Running bash command: $CLAUDE_TOOL_INPUT'", + "type": "command" + } + ], + "matcher": "Bash" + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "command": "echo 'User submitted: $CLAUDE_USER_PROMPT'", + "type": "command" + } + ], + "matcher": "" + } + ] + }, + "includeCoAuthoredBy": false, + "model": "claude-3-5-sonnet-20241022", + "permissions": { + "additionalDirectories": [ + "../docs/" + ], + "allow": [ + "Bash(git diff:*)", + "Edit" + ], + "ask": [ + "Bash(git push:*)" + ], + "defaultMode": "acceptEdits", + "deny": [ + "WebFetch", + "Bash(curl:*)", + "Read(./.env)", + "Read(./secrets/**)" + ], + "disableBypassPermissionsMode": "disable" + }, + "statusLine": { + "command": "input=$(cat); echo \"[$(echo \"$input\" | jq -r '.model.display_name')] 📁 $(basename \"$(echo \"$input\" | jq -r '.workspace.current_dir')\")\"", + "padding": 0, + "type": "command" + }, + "theme": "dark" +} diff --git a/tests/modules/programs/claude-code/full-config.nix b/tests/modules/programs/claude-code/full-config.nix new file mode 100644 index 000000000..69dd8e136 --- /dev/null +++ b/tests/modules/programs/claude-code/full-config.nix @@ -0,0 +1,126 @@ +{ + programs.claude-code = { + enable = true; + + settings = { + theme = "dark"; + permissions = { + allow = [ + "Bash(git diff:*)" + "Edit" + ]; + ask = [ "Bash(git push:*)" ]; + deny = [ + "WebFetch" + "Bash(curl:*)" + "Read(./.env)" + "Read(./secrets/**)" + ]; + additionalDirectories = [ "../docs/" ]; + defaultMode = "acceptEdits"; + disableBypassPermissionsMode = "disable"; + }; + model = "claude-3-5-sonnet-20241022"; + hooks = { + UserPromptSubmit = [ + { + matcher = ""; + hooks = [ + { + type = "command"; + command = "echo 'User submitted: $CLAUDE_USER_PROMPT'"; + } + ]; + } + ]; + PreToolUse = [ + { + matcher = "Bash"; + hooks = [ + { + type = "command"; + command = "echo 'Running bash command: $CLAUDE_TOOL_INPUT'"; + } + ]; + } + ]; + }; + statusLine = { + type = "command"; + command = "input=$(cat); echo \"[$(echo \"$input\" | jq -r '.model.display_name')] 📁 $(basename \"$(echo \"$input\" | jq -r '.workspace.current_dir')\")\""; + padding = 0; + }; + includeCoAuthoredBy = false; + }; + + commands = { + changelog = '' + --- + allowed-tools: Bash(git log:*), Bash(git diff:*) + argument-hint: [version] [change-type] [message] + description: Update CHANGELOG.md with new entry + --- + Parse the version, change type, and message from the input + and update the CHANGELOG.md file accordingly. + ''; + commit = '' + --- + allowed-tools: Bash(git add:*), Bash(git status:*), Bash(git commit:*) + description: Create a git commit with proper message + --- + ## Context + + - Current git status: !`git status` + - Current git diff: !`git diff HEAD` + - Recent commits: !`git log --oneline -5` + + ## Task + + Based on the changes above, create a single atomic git commit with a descriptive message. + ''; + }; + + agents = { + code-reviewer = '' + --- + name: code-reviewer + description: Specialized code review agent + tools: Read, Edit, Grep + --- + + You are a senior software engineer specializing in code reviews. + Focus on code quality, security, and maintainability. + ''; + + documentation = '' + --- + name: documentation + description: Documentation writing assistant + model: claude-3-5-sonnet-20241022 + tools: Read, Write, Edit + --- + + You are a technical writer who creates clear, comprehensive documentation. + Focus on user-friendly explanations and examples. + ''; + }; + }; + + nmt.script = '' + assertFileExists home-files/.claude/settings.json + assertFileContent home-files/.claude/settings.json ${./expected-settings.json} + + assertFileExists home-files/.claude/agents/code-reviewer.md + assertFileContent home-files/.claude/agents/code-reviewer.md ${./expected-code-reviewer.md} + + assertFileExists home-files/.claude/agents/documentation.md + assertFileContent home-files/.claude/agents/documentation.md ${./expected-documentation.md} + + + assertFileExists home-files/.claude/commands/changelog.md + assertFileContent home-files/.claude/commands/changelog.md ${./expected-changelog} + + assertFileExists home-files/.claude/commands/commit.md + assertFileContent home-files/.claude/commands/commit.md ${./expected-commit} + ''; +} diff --git a/tests/modules/programs/claude-code/mcp.nix b/tests/modules/programs/claude-code/mcp.nix new file mode 100644 index 000000000..c95103c8d --- /dev/null +++ b/tests/modules/programs/claude-code/mcp.nix @@ -0,0 +1,55 @@ +{ config, ... }: + +{ + programs.claude-code = { + package = config.lib.test.mkStubPackage { + name = "claude-code"; + buildScript = '' + mkdir -p $out/bin + touch $out/bin/claude + chmod 755 $out/bin/claude + ''; + }; + enable = true; + + mcpServers = { + github = { + type = "http"; + url = "https://api.githubcopilot.com/mcp/"; + }; + filesystem = { + type = "stdio"; + command = "npx"; + args = [ + "-y" + "@modelcontextprotocol/server-filesystem" + "/tmp" + ]; + }; + database = { + type = "stdio"; + command = "npx"; + args = [ + "-y" + "@bytebase/dbhub" + "--dsn" + "postgresql://user:pass@localhost:5432/db" + ]; + env = { + DATABASE_URL = "postgresql://user:pass@localhost:5432/db"; + }; + }; + customTransport = { + type = "websocket"; + url = "wss://example.com/mcp"; + customOption = "value"; + timeout = 5000; + }; + }; + }; + + nmt.script = '' + normalizedWrapper=$(normalizeStorePaths home-path/bin/claude) + assertFileContent $normalizedWrapper ${./expected-mcp-wrapper} + ''; +}