claude-code: Add support for installing plugins (#8934)
Allow for configuring marketplaces or individual plugins. Both are provided as a list of either paths or packages. The lone plugins are enabled by adding a --plugin-dir argument to the wrapper script. Marketplaces are saved to the nix store and enabled by adding to the claude settings and known_marketplaces files. With the marketplace "installed", the plugin can just be enabled via the enabledPlugins setting.
This commit is contained in:
@@ -50,6 +50,7 @@ let
|
||||
default = null;
|
||||
inherit description example;
|
||||
};
|
||||
|
||||
in
|
||||
{
|
||||
meta.maintainers = [ lib.maintainers.khaneliman ];
|
||||
@@ -136,6 +137,51 @@ in
|
||||
description = "JSON configuration for Claude Code settings.json";
|
||||
};
|
||||
|
||||
plugins = lib.mkOption {
|
||||
type = with lib.types; listOf (either package path);
|
||||
default = [ ];
|
||||
description = ''
|
||||
List of plugins to use when running Claude Code.
|
||||
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.
|
||||
'';
|
||||
example = literalExpression ''
|
||||
[
|
||||
./my-local-plugin
|
||||
fetchFromGithub {
|
||||
owner = "some-github-org";
|
||||
repo = "claude-plugin";
|
||||
rev = "779a68ebc2a75e4a184d2c87e5a43a758e6458a1";
|
||||
sha256 = "228fdd7e5908ea1d2f65218ecd9c71e1eefa0834d200d55fbb8bf8b5563acec0";
|
||||
}
|
||||
]
|
||||
'';
|
||||
};
|
||||
|
||||
marketplaces = lib.mkOption {
|
||||
type = with lib.types; attrsOf (either package path);
|
||||
default = { };
|
||||
description = ''
|
||||
Custom marketplaces for Claude Code plugins.
|
||||
The attribute name becomes the marketplace name, and the value is either:
|
||||
- A path to the marketplace directory
|
||||
- The marketplace package, whether a nix package or the output of a fetcher
|
||||
'';
|
||||
example = literalExpression ''
|
||||
{
|
||||
local-marketplace = ./my-local-marketplace;
|
||||
gh-marketplace = fetchFromGithub {
|
||||
owner = "some-github-org";
|
||||
repo = "claude-marketplace";
|
||||
rev = "8a873a220b8427b25b03ce1a821593a24e098c34";
|
||||
sha256 = "5c2dce95122b5bb73fa547edabbb6c3061c2d193d11e51faecd4d22659e67279";
|
||||
};
|
||||
}
|
||||
'';
|
||||
};
|
||||
|
||||
agents = mkContentOption {
|
||||
description = ''
|
||||
Custom agents for Claude Code.
|
||||
@@ -476,6 +522,22 @@ in
|
||||
}
|
||||
else
|
||||
nameValuePair ".claude/skills/${name}/SKILL.md" (mkSourceEntry content);
|
||||
|
||||
mkMarketplaceEntry = name: content: {
|
||||
source = {
|
||||
source = "directory";
|
||||
path = content;
|
||||
};
|
||||
};
|
||||
|
||||
mkInstalledMarketplaceEntry =
|
||||
name: content:
|
||||
(mkMarketplaceEntry name content)
|
||||
// {
|
||||
installLocation = content;
|
||||
lastUpdated = "1970-01-01T00:00:00Z";
|
||||
};
|
||||
|
||||
in
|
||||
lib.mkIf cfg.enable {
|
||||
assertions =
|
||||
@@ -496,9 +558,9 @@ in
|
||||
[
|
||||
{
|
||||
assertion =
|
||||
(cfg.mcpServers == { } && cfg.lspServers == { } && !cfg.enableMcpIntegration)
|
||||
(cfg.mcpServers == { } && cfg.lspServers == { } && !cfg.enableMcpIntegration && cfg.plugins == [ ])
|
||||
|| cfg.package != null;
|
||||
message = "`programs.claude-code.package` cannot be null when `mcpServers`, `lspServers`, or `enableMcpIntegration` is configured";
|
||||
message = "`programs.claude-code.package` cannot be null when `mcpServers`, `lspServers`, `enableMcpIntegration`, or `plugins` is configured";
|
||||
}
|
||||
{
|
||||
assertion = !(cfg.memory.text != null && cfg.memory.source != null);
|
||||
@@ -531,8 +593,15 @@ in
|
||||
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 pluginFiles != [ ] then
|
||||
if allPluginPaths != [ ] then
|
||||
pkgs.symlinkJoin {
|
||||
name = "claude-code";
|
||||
paths = [ cfg.package ];
|
||||
@@ -540,7 +609,7 @@ in
|
||||
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" --plugin-dir ${pluginDir} "\$@"
|
||||
exec -a "\$0" "$out/bin/.claude-wrapped" ${lib.escapeShellArgs wrapperArgs} "\$@"
|
||||
EOF
|
||||
chmod +x $out/bin/claude
|
||||
'';
|
||||
@@ -553,12 +622,15 @@ in
|
||||
packages = lib.mkIf (cfg.package != null) [ cfg.finalPackage ];
|
||||
|
||||
file = lib.mkMerge [
|
||||
(lib.mkIf (cfg.settings != { }) {
|
||||
(lib.mkIf (cfg.settings != { } || cfg.marketplaces != { }) {
|
||||
".claude/settings.json".source = jsonFormat.generate "claude-code-settings.json" (
|
||||
cfg.settings
|
||||
// {
|
||||
"$schema" = "https://json.schemastore.org/claude-code-settings.json";
|
||||
}
|
||||
// optionalAttrs (cfg.marketplaces != { }) {
|
||||
extraKnownMarketplaces = lib.mapAttrs mkMarketplaceEntry cfg.marketplaces;
|
||||
}
|
||||
);
|
||||
})
|
||||
(lib.mkIf (cfg.memory.text != null) {
|
||||
@@ -567,6 +639,12 @@ in
|
||||
(lib.mkIf (cfg.memory.source != null) {
|
||||
".claude/CLAUDE.md".source = cfg.memory.source;
|
||||
})
|
||||
(lib.mkIf (cfg.marketplaces != { }) {
|
||||
".claude/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)
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
};
|
||||
|
||||
test.asserts.assertions.expected = [
|
||||
"`programs.claude-code.package` cannot be null when `mcpServers`, `lspServers`, or `enableMcpIntegration` is configured"
|
||||
"`programs.claude-code.package` cannot be null when `mcpServers`, `lspServers`, `enableMcpIntegration`, or `plugins` is configured"
|
||||
"Cannot specify both `programs.claude-code.memory.text` and `programs.claude-code.memory.source`"
|
||||
"Cannot specify both `programs.claude-code.agents` and `programs.claude-code.agentsDir`"
|
||||
"Cannot specify both `programs.claude-code.commands` and `programs.claude-code.commandsDir`"
|
||||
|
||||
@@ -21,4 +21,6 @@
|
||||
claude-code-mixed-content = ./mixed-content.nix;
|
||||
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-marketplacess = ./marketplaces.nix;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"test-duplicate": {
|
||||
"installLocation": "/nix/store/00000000000000000000000000000000-test-marketplace",
|
||||
"lastUpdated": "1970-01-01T00:00:00Z",
|
||||
"source": {
|
||||
"path": "/nix/store/00000000000000000000000000000000-test-marketplace",
|
||||
"source": "directory"
|
||||
}
|
||||
},
|
||||
"test-market": {
|
||||
"installLocation": "/nix/store/00000000000000000000000000000000-test-marketplace",
|
||||
"lastUpdated": "1970-01-01T00:00:00Z",
|
||||
"source": {
|
||||
"path": "/nix/store/00000000000000000000000000000000-test-marketplace",
|
||||
"source": "directory"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
#! @bash-interactive@/bin/bash -e
|
||||
exec -a "$0" "/nix/store/00000000000000000000000000000000-claude-code/bin/.claude-wrapped" --plugin-dir /nix/store/00000000000000000000000000000000-test-plugin "$@"
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"extraKnownMarketplaces": {
|
||||
"test-duplicate": {
|
||||
"source": {
|
||||
"path": "/nix/store/00000000000000000000000000000000-test-marketplace",
|
||||
"source": "directory"
|
||||
}
|
||||
},
|
||||
"test-market": {
|
||||
"source": {
|
||||
"path": "/nix/store/00000000000000000000000000000000-test-marketplace",
|
||||
"source": "directory"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{ 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;
|
||||
|
||||
marketplaces = {
|
||||
test-market = ./test-marketplace;
|
||||
test-duplicate = ./test-marketplace;
|
||||
};
|
||||
};
|
||||
|
||||
nmt.script = ''
|
||||
assertFileExists home-files/.claude/plugins/known_marketplaces.json
|
||||
assertLinkExists home-files/.claude/plugins/known_marketplaces.json
|
||||
normalizedFile=$(normalizeStorePaths home-files/.claude/plugins/known_marketplaces.json)
|
||||
assertFileContent "$normalizedFile" ${./expected-known-marketplaces.json}
|
||||
normalizedFile=$(normalizeStorePaths home-files/.claude/settings.json)
|
||||
assertFileContent "$normalizedFile" ${./expected-settings-marketplaces.json}
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{ 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;
|
||||
|
||||
plugins = [ ./test-plugin ];
|
||||
};
|
||||
|
||||
nmt.script = ''
|
||||
wrapperPath="$TESTED/home-path/bin/claude"
|
||||
normalizedWrapper=$(normalizeStorePaths "$wrapperPath")
|
||||
assertFileContent $normalizedWrapper ${./expected-plugin-wrapper}
|
||||
|
||||
test "$(grep -o -- '--plugin-dir ' "$wrapperPath" | wc -l)" -eq 1
|
||||
pluginDir=$(grep -o -- '--plugin-dir /nix/store/[^ ]*' "$wrapperPath")
|
||||
pluginDir="''${pluginDir#--plugin-dir }"
|
||||
assertFileContent "$pluginDir/.claude-plugin/plugin.json" ${./test-plugin/.claude-plugin/plugin.json}
|
||||
'';
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"name": "test-tool",
|
||||
"source": "./plugins/test-tool"
|
||||
}
|
||||
]
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "test-tool",
|
||||
"description": "A test tool plugin"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "test-plugin",
|
||||
"description": "A test plugin for home-manager tests"
|
||||
}
|
||||
Reference in New Issue
Block a user