diff --git a/internal/cli/lock.go b/internal/cli/lock.go index 6fa7e9b9e0..18769af01d 100644 --- a/internal/cli/lock.go +++ b/internal/cli/lock.go @@ -1137,9 +1137,11 @@ func isTreeLockField(field string) bool { // use forge tree URLs whose deepest path segment is the directory name. // Base-composed entries (see fetchBaseSkill/fetchBasePlugin in // internal/harness/compose.go) record raw.githubusercontent.com URLs pointing -// at the marker file (SKILL.md or plugin.json); only those two names are -// treated as markers and stripped — any other raw URL keeps its last segment -// as the directory name. +// at the marker file (SKILL.md, plugin.json, or .claude-plugin/plugin.json); +// those names are treated as markers and stripped — any other raw URL keeps +// its last segment as the directory name. .claude-plugin/plugin.json is the +// Claude Code plugin spec location; stripping it requires removing two path +// segments so the caller gets the plugin root, not ".claude-plugin". func lockTreeDirName(field, lockURL string) (string, error) { if forgeInfo, err := forge.ParseForgeURL(lockURL); err == nil { if forgeInfo.Path == "" { @@ -1153,6 +1155,15 @@ func lockTreeDirName(field, lockURL string) (string, error) { if dir == "." { return "", fmt.Errorf("%s: URL must point to a marker file inside a directory, not the repo root", field) } + // .claude-plugin/plugin.json is a two-level marker per the + // Claude Code plugin spec; strip the intermediate directory + // so the caller gets the plugin root, not ".claude-plugin". + if last == "plugin.json" && path.Base(dir) == ".claude-plugin" { + dir = path.Dir(dir) + if dir == "." { + return "", fmt.Errorf("%s: URL must point to a marker file inside a directory, not the repo root", field) + } + } return filepath.Base(dir), nil } return path.Base(rawInfo.Path), nil diff --git a/internal/cli/lock_test.go b/internal/cli/lock_test.go index b9224f03a5..c683e6f76e 100644 --- a/internal/cli/lock_test.go +++ b/internal/cli/lock_test.go @@ -2437,6 +2437,18 @@ func TestLockTreeDirName(t *testing.T) { url: "https://raw.githubusercontent.com/org/repo/abc123/plugins/gopls-lsp/plugin.json", want: "gopls-lsp", }, + { + name: "raw .claude-plugin/plugin.json marker stripped", + field: "plugins[0]", + url: "https://raw.githubusercontent.com/org/repo/abc123/plugins/my-plugin/.claude-plugin/plugin.json", + want: "my-plugin", + }, + { + name: "raw .claude-plugin/plugin.json at ref root rejected", + field: "plugins[0]", + url: "https://raw.githubusercontent.com/org/repo/abc123/.claude-plugin/plugin.json", + wantErr: "must point to a marker file inside a directory", + }, { name: "raw marker at ref root rejected", field: "skills[0]",