claude-code: load plugins without wrapper

Persist managed plugins in Claude Code's personal skills directory so strict-parser subcommands do not receive injected --plugin-dir arguments. Retain version-gated legacy behavior for older and unversioned packages.
This commit is contained in:
Austin Horstman 2026-07-09 19:56:39 -05:00
parent 79e6082586
commit 1c029fb0dc
12 changed files with 272 additions and 97 deletions

View file

@ -17,6 +17,17 @@ let
jsonFormat = pkgs.formats.json { };
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 =
@ -222,7 +233,12 @@ in
Each entry is either:
- A path to the plugin directory
- The plugin package, whether a nix package or the output of a fetcher
Plugins are enabled via a `--plugin-dir` argument in the wrapper script.
With Claude Code 2.1.157 or later, plugins are linked into
{option}`programs.claude-code.configDir` and loaded as personal plugins.
Versions 2.1.76 through 2.1.156 fall back to a legacy `--plugin-dir`
wrapper, as do packages without detectable version metadata.
Strict-parser subcommands such as {command}`claude rc` may reject
arguments from that compatibility path.
'';
example = literalExpression ''
[
@ -623,8 +639,124 @@ in
lastUpdated = "1970-01-01T00:00:00Z";
};
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
)
);
mkPluginEntry =
plugin:
let
name = builtins.unsafeDiscardStringContext (
lib.strings.sanitizeDerivationName (baseNameOf (toString plugin))
);
in
{
inherit name;
source = pkgs.symlinkJoin {
name = "claude-code-${name}";
paths = [ plugin ];
postBuild = ''
if [[ ! -e $out/.claude-plugin/plugin.json ]]; then
install -Dm644 ${
jsonFormat.generate "claude-code-${name}.json" {
inherit name;
}
} $out/.claude-plugin/plugin.json
fi
'';
};
};
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 <<EOF
#! ${pkgs.bash}/bin/bash -e
exec -a "\$0" "$out/bin/.claude-wrapped" ${lib.escapeShellArgs legacyWrapperArgs} "\$@"
EOF
chmod +x $out/bin/claude
'';
inherit (cfg.package) meta;
};
pluginNames = map (plugin: plugin.name) pluginEntries;
skillNames =
if builtins.isAttrs cfg.skills then
lib.attrNames cfg.skills
else if lib.hm.strings.isPathLike cfg.skills && lib.pathIsDirectory cfg.skills then
lib.attrNames (builtins.readDir cfg.skills)
else
[ ];
pluginFileEntries = lib.optionalAttrs supportsPersonalPlugins (
lib.listToAttrs (
map (
plugin:
nameValuePair "${cfg.configDir}/skills/${plugin.name}" {
inherit (plugin) source;
recursive = true;
}
) pluginEntries
)
);
in
lib.mkIf cfg.enable {
warnings = 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 = [
@ -641,68 +773,28 @@ in
in
[
{
assertion =
(cfg.mcpServers == { } && cfg.lspServers == { } && !cfg.enableMcpIntegration && cfg.plugins == [ ])
|| cfg.package != null;
message = "`programs.claude-code.package` cannot be null when `mcpServers`, `lspServers`, `enableMcpIntegration`, or `plugins` is configured";
assertion = !hasManagedPlugins || supportsPluginDir;
message = "Managed Claude Code MCP, LSP, and plugins require `programs.claude-code.package` version 2.1.76 or later";
}
{
assertion = !lib.hm.strings.isPathLike 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 =
let
mergedMcpServers =
transformedMcpServers
// lib.mapAttrs (_: server: removeAttrs (lib.hm.mcp.addType server) [ "enabled" ]) cfg.mcpServers;
pluginFiles =
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;
};
pluginDir = 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}") pluginFiles
)
);
allPluginPaths = (if pluginFiles != [ ] then [ pluginDir ] else [ ]) ++ cfg.plugins;
wrapperArgs = lib.flatten (
map (p: [
"--plugin-dir"
"${p}"
]) allPluginPaths
);
in
if allPluginPaths != [ ] then
pkgs.symlinkJoin {
name = "claude-code";
paths = [ cfg.package ];
postBuild = ''
mv $out/bin/claude $out/bin/.claude-wrapped
cat > $out/bin/claude <<EOF
#! ${pkgs.bash}/bin/bash -e
exec -a "\$0" "$out/bin/.claude-wrapped" ${lib.escapeShellArgs wrapperArgs} "\$@"
EOF
chmod +x $out/bin/claude
'';
inherit (cfg.package) meta;
}
else
cfg.package;
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 ];
@ -757,6 +849,7 @@ in
recursive = true;
};
})
pluginFileEntries
(mkHookEntries cfg.hooks)
(lib.optionalAttrs (builtins.isAttrs cfg.skills) (lib.mapAttrs' mkSkillEntry cfg.skills))
(mkMarkdownEntries "output-styles" cfg.outputStyles)

View file

@ -1,7 +1,17 @@
{ config, ... }:
{
programs.claude-code = {
enable = true;
package = null;
package = config.lib.test.mkStubPackage {
name = "claude-code";
version = "2.1.75";
buildScript = ''
mkdir -p $out/bin
touch $out/bin/claude
chmod 755 $out/bin/claude
'';
};
mcpServers = {
filesystem = {
@ -36,7 +46,7 @@
};
test.asserts.assertions.expected = [
"`programs.claude-code.package` cannot be null when `mcpServers`, `lspServers`, `enableMcpIntegration`, or `plugins` is configured"
"Managed Claude Code MCP, LSP, and plugins require `programs.claude-code.package` version 2.1.76 or later"
"Cannot specify both `programs.claude-code.agents` and `programs.claude-code.agentsDir`"
"Cannot specify both `programs.claude-code.commands` and `programs.claude-code.commandsDir`"
"Cannot specify both `programs.claude-code.hooks` and `programs.claude-code.hooksDir`"

View file

@ -26,5 +26,6 @@
claude-code-output-styles = ./output-styles.nix;
claude-code-output-styles-not-set = ./output-styles-not-set.nix;
claude-code-plugins = ./plugins.nix;
claude-code-versionless-package = ./versionless-package.nix;
claude-code-marketplacess = ./marketplaces.nix;
}

View file

@ -1,2 +0,0 @@
#! @bash-interactive@/bin/bash -e
exec -a "$0" "/nix/store/00000000000000000000000000000000-claude-code/bin/.claude-wrapped" --plugin-dir /nix/store/00000000000000000000000000000000-claude-code-hm-plugin "$@"

View file

@ -1,2 +1,2 @@
#! @bash-interactive@/bin/bash -e
exec -a "$0" "/nix/store/00000000000000000000000000000000-claude-code/bin/.claude-wrapped" --plugin-dir /nix/store/00000000000000000000000000000000-claude-code-hm-plugin "$@"
exec -a "$0" "/nix/store/00000000000000000000000000000000-claude-code/bin/.claude-wrapped" --plugin-dir /nix/store/00000000000000000000000000000000-claude-code-hm-plugin --plugin-dir /nix/store/00000000000000000000000000000000-test-plugin "$@"

View file

@ -4,6 +4,7 @@
programs.claude-code = {
package = config.lib.test.mkStubPackage {
name = "claude-code";
version = "2.1.197";
buildScript = ''
mkdir -p $out/bin
touch $out/bin/claude
@ -32,12 +33,9 @@
};
nmt.script = ''
wrapperPath="$TESTED/home-path/bin/claude"
normalizedWrapper=$(normalizeStorePaths "$wrapperPath")
assertFileContent "$normalizedWrapper" ${./expected-lsp-wrapper}
assertPathNotExists "$TESTED/home-path/bin/.claude-wrapped"
pluginDir=$(grep -o -- '--plugin-dir /nix/store/[^ ]*' "$wrapperPath")
pluginDir="''${pluginDir#--plugin-dir }"
pluginDir="$TESTED/home-files/.claude/skills/claude-code-home-manager"
assertFileContent "$pluginDir/.claude-plugin/plugin.json" ${./expected-plugin-manifest.json}
assertFileContent "$pluginDir/.lsp.json" ${./expected-lsp-plugin.json}
assertPathNotExists "$pluginDir/.mcp.json"

View file

@ -1,16 +1,7 @@
{ 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
'';
};
package = null;
enable = true;
enableMcpIntegration = true;
@ -72,12 +63,9 @@
};
nmt.script = ''
wrapperPath="$TESTED/home-path/bin/claude"
normalizedWrapper=$(normalizeStorePaths "$wrapperPath")
assertFileContent "$normalizedWrapper" ${./expected-mcp-wrapper}
assertPathNotExists "$TESTED/home-path/bin/claude"
pluginDir=$(grep -o -- '--plugin-dir /nix/store/[^ ]*' "$wrapperPath")
pluginDir="''${pluginDir#--plugin-dir }"
pluginDir="$TESTED/home-files/.claude/skills/claude-code-home-manager"
assertFileContent "$pluginDir/.claude-plugin/plugin.json" ${./expected-plugin-manifest.json}
assertFileContent "$pluginDir/.mcp.json" ${./expected-mcp-integration-plugin.json}
assertPathNotExists "$pluginDir/.lsp.json"

View file

@ -4,6 +4,7 @@
programs.claude-code = {
package = config.lib.test.mkStubPackage {
name = "claude-code";
version = "2.1.76";
buildScript = ''
mkdir -p $out/bin
touch $out/bin/claude
@ -64,18 +65,32 @@
timeout = 5000;
};
};
plugins = [ ./test-plugin ];
};
test.asserts.warnings.expected = [
''
`programs.claude-code.package` version 2.1.76
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.
''
];
nmt.script = ''
wrapperPath="$TESTED/home-path/bin/claude"
normalizedWrapper=$(normalizeStorePaths "$wrapperPath")
assertFileContent "$normalizedWrapper" ${./expected-mcp-wrapper}
test "$(grep -o -- '--plugin-dir ' "$wrapperPath" | wc -l)" -eq 1
pluginDir=$(grep -o -- '--plugin-dir /nix/store/[^ ]*' "$wrapperPath")
test "$(grep -o -- '--plugin-dir ' "$wrapperPath" | wc -l)" -eq 2
pluginDir=$(grep -o -- '--plugin-dir /nix/store/[^ ]*' "$wrapperPath" | head -1)
pluginDir="''${pluginDir#--plugin-dir }"
assertFileContent "$pluginDir/.claude-plugin/plugin.json" ${./expected-plugin-manifest.json}
assertFileContent "$pluginDir/.mcp.json" ${./expected-mcp-plugin.json}
assertFileContent "$pluginDir/.lsp.json" ${./expected-lsp-plugin.json}
assertPathNotExists "$TESTED/home-files/.claude/skills/claude-code-home-manager"
assertPathNotExists "$TESTED/home-files/.claude/skills/test-plugin"
'';
}

View file

@ -4,6 +4,7 @@
programs.claude-code = {
package = config.lib.test.mkStubPackage {
name = "claude-code";
version = "2.1.157";
buildScript = ''
mkdir -p $out/bin
touch $out/bin/claude
@ -49,12 +50,9 @@
};
nmt.script = ''
wrapperPath="$TESTED/home-path/bin/claude"
normalizedWrapper=$(normalizeStorePaths "$wrapperPath")
assertFileContent "$normalizedWrapper" ${./expected-mcp-wrapper}
assertPathNotExists "$TESTED/home-path/bin/.claude-wrapped"
pluginDir=$(grep -o -- '--plugin-dir /nix/store/[^ ]*' "$wrapperPath")
pluginDir="''${pluginDir#--plugin-dir }"
pluginDir="$TESTED/home-files/.claude/skills/claude-code-home-manager"
assertFileContent "$pluginDir/.claude-plugin/plugin.json" ${./expected-plugin-manifest.json}
assertFileContent "$pluginDir/.mcp.json" ${./expected-mcp-plugin.json}
assertPathNotExists "$pluginDir/.lsp.json"

View file

@ -1,28 +1,66 @@
{ config, ... }:
{ config, pkgs, ... }:
let
mkPackagePlugin =
name:
pkgs.runCommand "source" { } ''
install -Dm644 ${
pkgs.writeText "${name}.json" (
builtins.toJSON {
inherit name;
}
)
} $out/.claude-plugin/plugin.json
'';
manifestlessPlugin = pkgs.runCommand "manifestless-plugin" { } ''
install -Dm644 ${./test-command.md} $out/commands/test.md
'';
packagePluginOne = mkPackagePlugin "package-plugin-one";
packagePluginTwo = mkPackagePlugin "package-plugin-two";
pluginDirName = plugin: baseNameOf (toString plugin);
in
{
programs.claude-code = {
package = config.lib.test.mkStubPackage {
name = "claude-code";
version = "2.1.197";
buildScript = ''
mkdir -p $out/bin
touch $out/bin/claude
chmod 755 $out/bin/claude
cat > $out/bin/claude <<'EOF'
#!${pkgs.runtimeShell}
test "$#" -eq 1
test "$1" = rc
EOF
chmod +x $out/bin/claude
'';
};
enable = true;
plugins = [ ./test-plugin ];
plugins = [
./test-plugin
packagePluginOne
packagePluginTwo
manifestlessPlugin
];
};
nmt.script = ''
wrapperPath="$TESTED/home-path/bin/claude"
normalizedWrapper=$(normalizeStorePaths "$wrapperPath")
assertFileContent $normalizedWrapper ${./expected-plugin-wrapper}
"$TESTED/home-path/bin/claude" rc
assertPathNotExists "$TESTED/home-path/bin/.claude-wrapped"
test "$(grep -o -- '--plugin-dir ' "$wrapperPath" | wc -l)" -eq 1
pluginDir=$(grep -o -- '--plugin-dir /nix/store/[^ ]*' "$wrapperPath")
pluginDir="''${pluginDir#--plugin-dir }"
pluginDir="$TESTED/home-files/.claude/skills/${pluginDirName ./test-plugin}"
assertFileContent "$pluginDir/.claude-plugin/plugin.json" ${./test-plugin/.claude-plugin/plugin.json}
assertFileContains \
"$TESTED/home-files/.claude/skills/${pluginDirName packagePluginOne}/.claude-plugin/plugin.json" \
'"name":"package-plugin-one"'
assertFileContains \
"$TESTED/home-files/.claude/skills/${pluginDirName packagePluginTwo}/.claude-plugin/plugin.json" \
'"name":"package-plugin-two"'
assertFileContains \
"$TESTED/home-files/.claude/skills/${pluginDirName manifestlessPlugin}/.claude-plugin/plugin.json" \
'"name": "${pluginDirName manifestlessPlugin}"'
assertFileContent \
"$TESTED/home-files/.claude/skills/${pluginDirName manifestlessPlugin}/commands/test.md" \
${./test-command.md}
'';
}

View file

@ -2,6 +2,7 @@
programs.claude-code = {
enable = true;
skills = ./skills;
plugins = [ ./test-plugin ];
};
nmt.script = ''
@ -10,5 +11,8 @@
assertFileContent \
home-files/.claude/skills/test-skill/SKILL.md \
${./skills/test-skill/SKILL.md}
assertFileContent \
home-files/.claude/skills/${baseNameOf (toString ./test-plugin)}/.claude-plugin/plugin.json \
${./test-plugin/.claude-plugin/plugin.json}
'';
}

View file

@ -0,0 +1,32 @@
{ config, ... }:
{
programs.claude-code = {
enable = true;
package = config.lib.test.mkStubPackage {
name = "claude-code";
buildScript = ''
mkdir -p $out/bin
touch $out/bin/claude
chmod 755 $out/bin/claude
'';
};
plugins = [ ./test-plugin ];
};
test.asserts.warnings.expected = [
''
`programs.claude-code.package` 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.
''
];
nmt.script = ''
wrapperPath="$TESTED/home-path/bin/claude"
normalizedWrapper=$(normalizeStorePaths "$wrapperPath")
assertFileContent "$normalizedWrapper" ${./expected-plugin-wrapper}
'';
}