mirror of
https://github.com/nix-community/home-manager.git
synced 2026-10-02 19:49:18 +00:00
Claude Code lists a plugin's `agents/` and `commands/` entries with a `readdir` that accepts only regular files, so symlinked directory entries are skipped. Managed plugins were linked recursively, which materializes a real directory holding one symlink per file, and wrapped with `pkgs.symlinkJoin`, which mirrors directories and symlinks the leaf files. Both layers dropped every agent and command a plugin provides. Link each plugin as a single directory symlink, and wrap it with a `runCommand` that links only top-level entries so component directories stay symlinks to the original. Also accept an attribute set for `plugins`, making the personal-plugin directory name user-controlled and stable rather than derived from the source's base name, which yields names such as `bxa1s0m3h4sh-source` that change whenever the plugin is updated. The list form keeps working, with a warning.
85 lines
2.9 KiB
Nix
85 lines
2.9 KiB
Nix
{ pkgs, ... }:
|
|
|
|
let
|
|
# Mimics a fetched repository that holds plugins in subdirectories
|
|
# alongside unrelated repository content. One subdirectory contains
|
|
# whitespace, which must survive the wrapper's glob expansion.
|
|
# Claude Code rejects plugin names containing spaces, so the manifest name
|
|
# stays kebab-case while the directory holding it does not.
|
|
repo = pkgs.runCommand "claude-plugin-repo" { } ''
|
|
mkPlugin() {
|
|
local dir="$1" name="$2"
|
|
mkdir -p "$out/plugins/$dir/.claude-plugin" "$out/plugins/$dir/agents"
|
|
echo "{\"name\":\"$name\",\"version\":\"1.0.0\"}" \
|
|
> "$out/plugins/$dir/.claude-plugin/plugin.json"
|
|
cat > "$out/plugins/$dir/agents/$name-agent.md" <<EOF
|
|
---
|
|
name: $name-agent
|
|
description: Agent provided by a plugin nested in a repository
|
|
---
|
|
|
|
Agent body.
|
|
EOF
|
|
}
|
|
|
|
mkPlugin "nested-plugin" "nested-plugin"
|
|
mkPlugin "spaced plugin" "spaced-plugin"
|
|
echo '# Repository readme' > "$out/README.md"
|
|
'';
|
|
in
|
|
{
|
|
programs.claude-code = {
|
|
enable = true;
|
|
plugins = {
|
|
nested-plugin = "${repo}/plugins/nested-plugin";
|
|
spaced-plugin = "${repo}/plugins/spaced plugin";
|
|
};
|
|
};
|
|
|
|
nmt.script = ''
|
|
assertLinkExists "home-files/.claude/skills/nested-plugin"
|
|
assertLinkExists "home-files/.claude/skills/spaced-plugin"
|
|
|
|
nestedDir="$TESTED/home-files/.claude/skills/nested-plugin"
|
|
assertFileContent \
|
|
"$nestedDir/.claude-plugin/plugin.json" \
|
|
"${repo}/plugins/nested-plugin/.claude-plugin/plugin.json"
|
|
assertFileContent \
|
|
"$nestedDir/agents/nested-plugin-agent.md" \
|
|
"${repo}/plugins/nested-plugin/agents/nested-plugin-agent.md"
|
|
|
|
# A source path containing whitespace must not be split during glob
|
|
# expansion, which would leave dangling links and drop plugin contents.
|
|
spacedDir="$TESTED/home-files/.claude/skills/spaced-plugin"
|
|
assertFileContent \
|
|
"$spacedDir/.claude-plugin/plugin.json" \
|
|
"${repo}/plugins/spaced plugin/.claude-plugin/plugin.json"
|
|
assertFileContent \
|
|
"$spacedDir/agents/spaced-plugin-agent.md" \
|
|
"${repo}/plugins/spaced plugin/agents/spaced-plugin-agent.md"
|
|
|
|
for pluginDir in "$nestedDir" "$spacedDir"; do
|
|
# Only the plugin subdirectory is linked, not the enclosing repository.
|
|
assertPathNotExists "$pluginDir/README.md"
|
|
assertPathNotExists "$pluginDir/plugins"
|
|
|
|
# Every linked entry must resolve; a split path yields dangling links.
|
|
for entry in "$pluginDir"/* "$pluginDir"/.[!.]*; do
|
|
if [[ -e "$entry" ]]; then
|
|
continue
|
|
fi
|
|
echo "Linked plugin entry does not resolve: $entry"
|
|
exit 1
|
|
done
|
|
|
|
# Agents must stay regular files or Claude Code's scanner skips them.
|
|
for agentFile in "$pluginDir"/agents/*.md; do
|
|
if [[ -L "$agentFile" ]]; then
|
|
echo "Plugin component must be a regular file, not a symlink: $agentFile"
|
|
exit 1
|
|
fi
|
|
done
|
|
done
|
|
'';
|
|
}
|