{ config, lib, pkgs, ... }: let inherit (lib) mkChangedOptionModule; 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) derivePluginName mkHookEntries mkInstalledMarketplaceEntry mkMarkdownEntries mkMarketplaceEntry mkPluginEntry mkRecursiveDirAttrs mkSkillEntry ; # Manifest name of the synthesized plugin that carries generated MCP and # LSP config. Claude Code derives the MCP tool namespace from this name # (`mcp__plugin____`), so it is kept short to reduce # token overhead. The personal-plugin directory entry keeps the longer, # stable `claude-code-home-manager` name to avoid colliding with user # plugins or skills. See issue #9446. generatedPluginName = "hm"; 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 = generatedPluginName; } } $out/.claude-plugin/plugin.json '' + lib.concatLines ( map (pluginFile: "install -Dm644 ${pluginFile.path} $out/${pluginFile.name}") generatedPluginFiles ) ); # The list form predates named plugins and is kept working, with a # warning, so existing configurations keep evaluating. pluginsAreList = builtins.isList cfg.plugins; pluginSources = if pluginsAreList then cfg.plugins else lib.attrValues cfg.plugins; managedPluginEntries = if pluginsAreList then map (plugin: mkPluginEntry (derivePluginName plugin) plugin) cfg.plugins else lib.mapAttrsToList mkPluginEntry cfg.plugins; pluginEntries = lib.optional (generatedPluginFiles != [ ]) { # Keep the directory name as the long, stable `claude-code-home-manager` # rather than the short `generatedPluginName`: this entry becomes a # personal-plugin directory under `skills/`, and a short name like `hm` # would collide with valid `programs.claude-code.plugins.hm` or # `skills.hm`, failing the uniqueness assertions. The MCP tool prefix # already uses the short manifest name above. name = "claude-code-home-manager"; source = generatedPlugin; } ++ managedPluginEntries; legacyPluginPaths = lib.optional (generatedPluginFiles != [ ]) generatedPlugin ++ pluginSources; hasManagedPlugins = legacyPluginPaths != [ ]; useLegacyPluginWrapper = hasManagedPlugins && !supportsPersonalPlugins; legacyWrapperArgs = lib.concatMap (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 <&2 exit 1 fi ${lib.optionalString supportsPersonalPlugins ( lib.concatMapStringsSep "\n" (name: '' if [[ -e "$source"/${lib.escapeShellArg name} || -L "$source"/${lib.escapeShellArg name} ]]; then echo "programs.claude-code.skills and managed plugins must have unique directory names" >&2 exit 1 fi '') pluginNames )} ln -s "$source" "$out" ''; # Each plugin is linked as a single directory symlink rather than # recursively. Claude Code discovers a plugin's `agents/` and `commands/` # entries with a `readdir` that only accepts regular files, so recursive # linking - which materializes a real directory holding one symlink per # file - silently drops every agent and command the plugin provides. # Symlinked directories are followed, so a whole-plugin link keeps them. pluginFileEntries = lib.optionalAttrs supportsPersonalPlugins ( lib.listToAttrs ( map ( plugin: lib.nameValuePair "${cfg.configDir}/skills/${plugin.name}" { inherit (plugin) source; } ) pluginEntries ) ); in lib.mkIf cfg.enable { warnings = lib.optional (pluginsAreList && cfg.plugins != [ ]) '' `programs.claude-code.plugins` is set to a list. Names are then derived from each entry's base name, which for store paths yields unstable directory names such as `bxa1s0m3h4sh-source`. Use an attribute set instead so plugin directory names stay stable and readable: plugins.my-plugin = ./my-plugin; '' ++ lib.optional (useLegacyPluginWrapper && supportsPluginDir) '' `programs.claude-code.package` ${ if hasPackageVersion then "version ${packageVersion}" else "has no detectable version and" } uses the legacy `--plugin-dir` wrapper. Strict-parser subcommands such as `claude rc` may reject managed MCP, LSP, or plugin arguments. Upgrade Claude Code to version 2.1.157 or later to use persistent personal plugins instead. ''; assertions = let exclusiveInlineDirNames = [ "rules" "agents" "commands" "hooks" ]; mkExclusiveAssertion = inline: { assertion = !(cfg.${inline} != { } && cfg.${inline + "Dir"} != null); message = "Cannot specify both `programs.claude-code.${inline}` and `programs.claude-code.${inline}Dir`"; }; in [ { assertion = !hasManagedPlugins || supportsPluginDir; message = "Managed Claude Code MCP, LSP, and plugins require `programs.claude-code.package` version 2.1.76 or later"; } { assertion = !lib.isPath cfg.skills || lib.pathIsDirectory cfg.skills; message = "`programs.claude-code.skills` must be a directory when set to a path"; } { assertion = !supportsPersonalPlugins || lib.length pluginNames == lib.length (lib.unique pluginNames); message = "`programs.claude-code.plugins` entries must resolve to unique personal-plugin directory names"; } { assertion = !supportsPersonalPlugins || lib.intersectLists skillNames pluginNames == [ ]; message = "`programs.claude-code.skills` and managed plugins must have unique directory names"; } ] ++ map mkExclusiveAssertion exclusiveInlineDirNames; programs.claude-code.finalPackage = lib.mkIf (cfg.package != null) ( if useLegacyPluginWrapper then legacyFinalPackage else cfg.package ); 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 != { } || disabledMcpServerNames != [ ]) { "${cfg.configDir}/settings.json".source = let settingsFile = jsonFormat.generate "claude-code-settings.json" ( cfg.settings // { "$schema" = "https://json.schemastore.org/claude-code-settings.json"; } // lib.optionalAttrs (cfg.marketplaces != { }) { extraKnownMarketplaces = lib.mapAttrs mkMarketplaceEntry cfg.marketplaces; } // lib.optionalAttrs (disabledMcpServerNames != [ ]) { disabledMcpjsonServers = lib.unique ( (cfg.settings.disabledMcpjsonServers or [ ]) ++ disabledMcpServerNames ); } ); settingsDirectory = pkgs.runCommand "claude-code-settings-directory" { } '' install -Dm444 ${settingsFile} "$out/settings.json" ''; in "${settingsDirectory}/settings.json"; }) ( if lib.isPath cfg.context then { "${cfg.configDir}/CLAUDE.md".source = cfg.context; } else (lib.mkIf (cfg.context != "") { "${cfg.configDir}/CLAUDE.md".text = cfg.context; }) ) (lib.mkIf (cfg.marketplaces != { }) { "${cfg.configDir}/plugins/known_marketplaces.json".source = jsonFormat.generate "claude-code-known-marketplaces.json" ( lib.mapAttrs mkInstalledMarketplaceEntry cfg.marketplaces ); }) (mkMarkdownEntries "agents" cfg.agents) (mkMarkdownEntries "commands" cfg.commands) (mkMarkdownEntries "rules" cfg.rules) (mkRecursiveDirAttrs "agents" cfg.agentsDir) (mkRecursiveDirAttrs "commands" cfg.commandsDir) (mkRecursiveDirAttrs "hooks" cfg.hooksDir) (mkRecursiveDirAttrs "rules" cfg.rulesDir) (lib.mkIf skillsArePath { "${cfg.configDir}/skills" = { source = if lib.isPath cfg.skills then cfg.skills else checkedSkillsDirectory; recursive = true; }; }) pluginFileEntries (mkHookEntries cfg.hooks) (lib.optionalAttrs skillsAreAttrs (lib.mapAttrs' mkSkillEntry cfg.skills)) (mkMarkdownEntries "output-styles" cfg.outputStyles) ]; }; }; }