diff --git a/modules/programs/claude-code.nix b/modules/programs/claude-code.nix index 30c3d81ae..91a8ee7ed 100644 --- a/modules/programs/claude-code.nix +++ b/modules/programs/claude-code.nix @@ -17,6 +17,8 @@ let jsonFormat = pkgs.formats.json { }; + upstreamConfigDir = "${config.home.homeDirectory}/.claude"; + mkMcpServer = server: (removeAttrs server [ "disabled" ]) @@ -100,6 +102,22 @@ in ''; }; + configDir = mkOption { + type = lib.types.str; + default = upstreamConfigDir; + defaultText = literalExpression ''"''${config.home.homeDirectory}/.claude"''; + example = literalExpression ''"''${config.xdg.configHome}/claude"''; + description = '' + Directory holding Claude Code's configuration files. + + Defaults to {file}`~/.claude`, matching the upstream + {command}`claude` CLI default. The {env}`CLAUDE_CONFIG_DIR` + environment variable is exported automatically whenever the + directory differs from this default so the CLI reads + configuration from the same location. + ''; + }; + settings = mkOption { inherit (jsonFormat) type; default = { }; @@ -167,7 +185,8 @@ in - A path to a file containing the content The configured content is written to - {file}`~/.claude/CLAUDE.md`. + {file}`CLAUDE.md` inside {option}`programs.claude-code.configDir` + (default {file}`~/.claude/CLAUDE.md`). ''; example = literalExpression "./claude-memory.md"; }; @@ -223,7 +242,8 @@ in The attribute name becomes the agent filename, and the value is either: - Inline content as a string with frontmatter - A path to a file containing the agent content with frontmatter - Agents are stored in .claude/agents/ directory. + Agents are stored in the {file}`agents/` subdirectory of + {option}`programs.claude-code.configDir`. ''; example = literalExpression '' { @@ -248,7 +268,8 @@ in The attribute name becomes the command filename, and the value is either: - Inline content as a string - A path to a file containing the command content - Commands are stored in .claude/commands/ directory. + Commands are stored in the {file}`commands/` subdirectory of + {option}`programs.claude-code.configDir`. ''; example = literalExpression '' { @@ -287,7 +308,8 @@ in description = '' Custom hooks for Claude Code. The attribute name becomes the hook filename, and the value is the hook script content. - Hooks are stored in .claude/hooks/ directory. + Hooks are stored in the {file}`hooks/` subdirectory of + {option}`programs.claude-code.configDir`. ''; example = { pre-edit = '' @@ -307,8 +329,9 @@ in The attribute name becomes the rule filename, and the value is either: - Inline content as a string - A path to a file containing the rule content - Rules are stored in .claude/rules/ directory. - All markdown files in .claude/rules/ are automatically loaded as project memory. + Rules are stored in the {file}`rules/` subdirectory of + {option}`programs.claude-code.configDir`. All markdown files in + that directory are automatically loaded as project memory. ''; example = literalExpression '' { @@ -332,8 +355,10 @@ in rulesDir = mkDirOption { description = '' Path to a directory containing rule files for Claude Code. - Rule files from this directory will be symlinked to .claude/rules/. - All markdown files in this directory are automatically loaded as project memory. + Rule files from this directory will be symlinked into the + {file}`rules/` subdirectory of + {option}`programs.claude-code.configDir`. All markdown files in + this directory are automatically loaded as project memory. ''; example = literalExpression "./rules"; }; @@ -341,7 +366,9 @@ in agentsDir = mkDirOption { description = '' Path to a directory containing agent files for Claude Code. - Agent files from this directory will be symlinked to .claude/agents/. + Agent files from this directory will be symlinked into the + {file}`agents/` subdirectory of + {option}`programs.claude-code.configDir`. ''; example = literalExpression "./agents"; }; @@ -349,7 +376,9 @@ in commandsDir = mkDirOption { description = '' Path to a directory containing command files for Claude Code. - Command files from this directory will be symlinked to .claude/commands/. + Command files from this directory will be symlinked into the + {file}`commands/` subdirectory of + {option}`programs.claude-code.configDir`. ''; example = literalExpression "./commands"; }; @@ -357,7 +386,9 @@ in hooksDir = mkDirOption { description = '' Path to a directory containing hook files for Claude Code. - Hook files from this directory will be symlinked to .claude/hooks/. + Hook files from this directory will be symlinked into the + {file}`hooks/` subdirectory of + {option}`programs.claude-code.configDir`. ''; example = literalExpression "./hooks"; }; @@ -369,7 +400,9 @@ in The value is either: - Inline content as a string - A path to a file - In both cases, the contents will be written to .claude/output-styles/.md + In both cases, the contents will be written to + {file}`output-styles/.md` inside + {option}`programs.claude-code.configDir`. ''; example = literalExpression '' { @@ -401,16 +434,17 @@ in If an attribute set is used, the attribute name becomes the skill directory name, and the value is either: - - Inline content as a string (creates .claude/skills//SKILL.md) - - A path to a file (creates .claude/skills//SKILL.md) - - A path to a directory (creates .claude/skills// with all files) + - Inline content as a string (creates {file}`skills//SKILL.md`) + - A path to a file (creates {file}`skills//SKILL.md`) + - A path to a directory (creates {file}`skills//` with all files) This also accepts Nix store paths, for example a skill directory from a package. If a path is used, it is expected to contain one folder per skill name, each containing a {file}`SKILL.md`. The directory is - symlinked to {file}`~/.claude/skills/`. + symlinked into the {file}`skills/` subdirectory of + {option}`programs.claude-code.configDir`. ''; example = literalExpression '' { @@ -521,17 +555,19 @@ in mkMarkdownEntries = subdir: attrs: lib.mapAttrs' ( - name: content: nameValuePair ".claude/${subdir}/${name}.md" (mkSourceEntry content) + name: content: nameValuePair "${cfg.configDir}/${subdir}/${name}.md" (mkSourceEntry content) ) attrs; mkTextEntries = subdir: attrs: - lib.mapAttrs' (name: content: nameValuePair ".claude/${subdir}/${name}" { text = content; }) attrs; + lib.mapAttrs' ( + name: content: nameValuePair "${cfg.configDir}/${subdir}/${name}" { text = content; } + ) attrs; mkRecursiveDirAttrs = subdir: dir: optionalAttrs (dir != null) { - ".claude/${subdir}" = { + "${cfg.configDir}/${subdir}" = { source = dir; recursive = true; }; @@ -540,12 +576,12 @@ in mkSkillEntry = name: content: if isPathLikeContent content && lib.pathIsDirectory content then - nameValuePair ".claude/skills/${name}" { + nameValuePair "${cfg.configDir}/skills/${name}" { source = content; recursive = true; } else - nameValuePair ".claude/skills/${name}/SKILL.md" ( + nameValuePair "${cfg.configDir}/skills/${name}/SKILL.md" ( if isPathLikeContent content then { source = content; } else { text = content; } ); @@ -646,9 +682,13 @@ in home = { packages = lib.mkIf (cfg.package != null) [ cfg.finalPackage ]; + sessionVariables = lib.mkIf (cfg.configDir != upstreamConfigDir) { + CLAUDE_CONFIG_DIR = cfg.configDir; + }; + file = lib.mkMerge [ (lib.mkIf (cfg.settings != { } || cfg.marketplaces != { }) { - ".claude/settings.json".source = jsonFormat.generate "claude-code-settings.json" ( + "${cfg.configDir}/settings.json".source = jsonFormat.generate "claude-code-settings.json" ( cfg.settings // { "$schema" = "https://json.schemastore.org/claude-code-settings.json"; @@ -661,15 +701,15 @@ in ( if lib.isPath cfg.context then { - ".claude/CLAUDE.md".source = cfg.context; + "${cfg.configDir}/CLAUDE.md".source = cfg.context; } else (lib.mkIf (cfg.context != "") { - ".claude/CLAUDE.md".text = cfg.context; + "${cfg.configDir}/CLAUDE.md".text = cfg.context; }) ) (lib.mkIf (cfg.marketplaces != { }) { - ".claude/plugins/known_marketplaces.json".source = + "${cfg.configDir}/plugins/known_marketplaces.json".source = jsonFormat.generate "claude-code-known-marketplaces.json" ( lib.mapAttrs mkInstalledMarketplaceEntry cfg.marketplaces ); @@ -682,7 +722,7 @@ in (mkRecursiveDirAttrs "hooks" cfg.hooksDir) (mkRecursiveDirAttrs "rules" cfg.rulesDir) (lib.mkIf (isPathLikeContent cfg.skills) { - ".claude/skills" = { + "${cfg.configDir}/skills" = { source = cfg.skills; recursive = true; }; diff --git a/tests/modules/programs/claude-code/config-dir.nix b/tests/modules/programs/claude-code/config-dir.nix new file mode 100644 index 000000000..50692f881 --- /dev/null +++ b/tests/modules/programs/claude-code/config-dir.nix @@ -0,0 +1,76 @@ +{ config, ... }: +{ + programs.claude-code = { + enable = true; + configDir = "${config.xdg.configHome}/claude"; + + settings = { + theme = "dark"; + }; + + context = '' + # Custom context + ''; + + agents = { + reviewer = '' + --- + name: reviewer + description: code reviewer + --- + body + ''; + }; + + commands = { + hello = '' + --- + description: hello command + --- + body + ''; + }; + + rules = { + style = "rule body"; + }; + + hooks = { + pre-edit = '' + #!/usr/bin/env bash + echo hi + ''; + }; + + outputStyles = { + concise = "concise body"; + }; + + skills = { + pdf = '' + --- + name: pdf + description: pdf skill + --- + body + ''; + }; + }; + + nmt.script = '' + assertPathNotExists home-files/.claude + + assertFileExists home-files/.config/claude/settings.json + assertFileExists home-files/.config/claude/CLAUDE.md + assertFileExists home-files/.config/claude/agents/reviewer.md + assertFileExists home-files/.config/claude/commands/hello.md + assertFileExists home-files/.config/claude/rules/style.md + assertFileExists home-files/.config/claude/hooks/pre-edit + assertFileExists home-files/.config/claude/output-styles/concise.md + assertFileExists home-files/.config/claude/skills/pdf/SKILL.md + + assertFileExists home-path/etc/profile.d/hm-session-vars.sh + assertFileRegex home-path/etc/profile.d/hm-session-vars.sh \ + 'export CLAUDE_CONFIG_DIR="/home/hm-user/.config/claude"' + ''; +} diff --git a/tests/modules/programs/claude-code/default.nix b/tests/modules/programs/claude-code/default.nix index 19836bb5f..faedcb2b5 100644 --- a/tests/modules/programs/claude-code/default.nix +++ b/tests/modules/programs/claude-code/default.nix @@ -1,5 +1,6 @@ { claude-code-basic = ./basic.nix; + claude-code-config-dir = ./config-dir.nix; claude-code-full-config = ./full-config.nix; claude-code-lsp = ./lsp.nix; claude-code-mcp = ./mcp.nix;