diff --git a/.github/labeler.yml b/.github/labeler.yml index 7750a323d..96b926929 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -7,7 +7,7 @@ - modules/programs/aiac.nix - modules/programs/aichat.nix - modules/programs/aider-chat.nix - - modules/programs/claude-code.nix + - modules/programs/claude-code/**/* - modules/programs/codex.nix - modules/programs/fabric-ai.nix - modules/programs/antigravity-cli.nix diff --git a/modules/programs/claude-code/default.nix b/modules/programs/claude-code/default.nix new file mode 100644 index 000000000..bf364cad0 --- /dev/null +++ b/modules/programs/claude-code/default.nix @@ -0,0 +1,291 @@ +{ + config, + lib, + pkgs, + ... +}: +let + inherit (lib) + mkChangedOptionModule + nameValuePair + optionalAttrs + ; + + cfg = config.programs.claude-code; + + jsonFormat = pkgs.formats.json { }; + claudeCodeLib = import ./lib.nix { inherit lib pkgs; }; + + packageVersion = if cfg.package == null then null else lib.getVersion cfg.package; + hasPackageVersion = packageVersion != null && packageVersion != ""; + + # A null package has no detectable version, so assume the latest Claude Code + # and enable version-gated behavior by default. Preserve the legacy wrapper + # for non-null packages without version metadata. + atLeast = + version: cfg.package == null || (hasPackageVersion && lib.versionAtLeast packageVersion version); + supportsPluginDir = !hasPackageVersion || atLeast "2.1.76"; + supportsPersonalPlugins = atLeast "2.1.157"; + + upstreamConfigDir = "${config.home.homeDirectory}/.claude"; + + isMcpServerEnabled = + server: + let + enabled = server.enabled or null; + disabled = (server.disabled or false) == true; + in + enabled != false && !disabled; + + transformMcpServer = + name: server: + lib.hm.mcp.transformMcpServer { + inherit server; + exclude = [ "enabled" ]; + extraTransforms = [ + lib.hm.mcp.addType + (lib.hm.mcp.wrapEnvFilesCommand { inherit pkgs name; }) # envFiles currently still need wrapping https://github.com/anthropics/claude-code/issues/28942 + ]; + }; + + # Shared MCP servers participate only when both integrations are enabled. + sharedMcpServers = lib.optionalAttrs ( + cfg.enableMcpIntegration && config.programs.mcp.enable + ) config.programs.mcp.servers; + + transformedMcpServers = lib.mapAttrs transformMcpServer sharedMcpServers; + + disabledMcpServerNames = lib.attrNames ( + lib.filterAttrs (_: server: !(isMcpServerEnabled server)) (sharedMcpServers // cfg.mcpServers) + ); + +in +{ + meta.maintainers = [ lib.maintainers.khaneliman ]; + + imports = [ + (mkChangedOptionModule + [ "programs" "claude-code" "memory" "text" ] + [ "programs" "claude-code" "context" ] + (config: lib.getAttrFromPath [ "programs" "claude-code" "memory" "text" ] config) + ) + (mkChangedOptionModule + [ "programs" "claude-code" "memory" "source" ] + [ "programs" "claude-code" "context" ] + (config: lib.getAttrFromPath [ "programs" "claude-code" "memory" "source" ] config) + ) + (mkChangedOptionModule + [ "programs" "claude-code" "skillsDir" ] + [ "programs" "claude-code" "skills" ] + (config: lib.getAttrFromPath [ "programs" "claude-code" "skillsDir" ] config) + ) + ./options.nix + ]; + + config = + let + helpers = claudeCodeLib.mkHelpers { inherit (cfg) configDir; }; + inherit (helpers) + mkHookEntries + mkInstalledMarketplaceEntry + mkMarkdownEntries + mkMarketplaceEntry + mkPluginEntry + mkRecursiveDirAttrs + mkSkillEntry + ; + + mergedMcpServers = + transformedMcpServers + // lib.mapAttrs (_: server: removeAttrs (lib.hm.mcp.addType server) [ "enabled" ]) cfg.mcpServers; + + generatedPluginFiles = + lib.optional (mergedMcpServers != { }) { + name = ".mcp.json"; + path = jsonFormat.generate "claude-code-mcp.json" { mcpServers = mergedMcpServers; }; + } + ++ lib.optional (cfg.lspServers != { }) { + name = ".lsp.json"; + path = jsonFormat.generate "claude-code-lsp.json" cfg.lspServers; + }; + + generatedPlugin = pkgs.runCommand "claude-code-hm-plugin" { } ( + '' + install -Dm644 ${ + jsonFormat.generate "claude-code-plugin.json" { + name = "claude-code-home-manager"; + } + } $out/.claude-plugin/plugin.json + '' + + lib.concatLines ( + map (pluginFile: "install -Dm644 ${pluginFile.path} $out/${pluginFile.name}") generatedPluginFiles + ) + ); + + pluginEntries = + lib.optional (generatedPluginFiles != [ ]) { + name = "claude-code-home-manager"; + source = generatedPlugin; + } + ++ map mkPluginEntry cfg.plugins; + + legacyPluginPaths = lib.optional (generatedPluginFiles != [ ]) generatedPlugin ++ cfg.plugins; + + hasManagedPlugins = legacyPluginPaths != [ ]; + useLegacyPluginWrapper = hasManagedPlugins && !supportsPersonalPlugins; + + legacyWrapperArgs = lib.flatten ( + map (plugin: [ + "--plugin-dir" + "${plugin}" + ]) legacyPluginPaths + ); + + legacyFinalPackage = pkgs.symlinkJoin { + name = "claude-code"; + paths = [ cfg.package ]; + postBuild = '' + mv $out/bin/claude $out/bin/.claude-wrapped + cat > $out/bin/claude < $out/bin/claude <