Skip to content

Commit 4c409c6

Browse files
author
SqlRush
committed
List configured plugin marketplaces
1 parent 783f285 commit 4c409c6

5 files changed

Lines changed: 267 additions & 3 deletions

File tree

cmd/claude/main.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,16 @@ type pluginCLIAvailableList struct {
316316
Available []pluginCLIAvailableEntry `json:"available"`
317317
}
318318

319+
type pluginCLIMarketplaceEntry struct {
320+
Name string `json:"name"`
321+
Source string `json:"source,omitempty"`
322+
Repo string `json:"repo,omitempty"`
323+
URL string `json:"url,omitempty"`
324+
Path string `json:"path,omitempty"`
325+
Package string `json:"package,omitempty"`
326+
InstallLocation string `json:"installLocation,omitempty"`
327+
}
328+
319329
func runPluginCLI(ctx context.Context, state *bootstrap.State, args []string, stdout io.Writer, stderr io.Writer) int {
320330
_ = ctx
321331
if len(args) == 0 {
@@ -325,6 +335,8 @@ func runPluginCLI(ctx context.Context, state *bootstrap.State, args []string, st
325335
switch strings.ToLower(strings.TrimSpace(args[0])) {
326336
case "list", "ls":
327337
return runPluginListCLI(state, args[1:], stdout, stderr)
338+
case "marketplace", "marketplaces":
339+
return runPluginMarketplaceCLI(state, args[1:], stdout, stderr)
328340
default:
329341
fmt.Fprintf(stderr, "ccgo plugin: unsupported subcommand %s\n", args[0])
330342
return 2
@@ -393,6 +405,66 @@ func runPluginListCLI(state *bootstrap.State, args []string, stdout io.Writer, s
393405
return 0
394406
}
395407

408+
func runPluginMarketplaceCLI(state *bootstrap.State, args []string, stdout io.Writer, stderr io.Writer) int {
409+
if len(args) == 0 {
410+
fmt.Fprintln(stderr, "ccgo plugin marketplace: missing subcommand")
411+
return 2
412+
}
413+
switch strings.ToLower(strings.TrimSpace(args[0])) {
414+
case "list", "ls":
415+
return runPluginMarketplaceListCLI(state, args[1:], stdout, stderr)
416+
default:
417+
fmt.Fprintf(stderr, "ccgo plugin marketplace: unsupported subcommand %s\n", args[0])
418+
return 2
419+
}
420+
}
421+
422+
func runPluginMarketplaceListCLI(state *bootstrap.State, args []string, stdout io.Writer, stderr io.Writer) int {
423+
flags := flag.NewFlagSet("claude plugin marketplace list", flag.ContinueOnError)
424+
flags.SetOutput(stderr)
425+
jsonOutput := flags.Bool("json", false, "output JSON")
426+
if err := flags.Parse(args); err != nil {
427+
if err == flag.ErrHelp {
428+
return 0
429+
}
430+
return 2
431+
}
432+
if flags.NArg() > 0 {
433+
fmt.Fprintf(stderr, "ccgo plugin marketplace list: unexpected argument %s\n", flags.Arg(0))
434+
return 2
435+
}
436+
runner, err := state.ConversationRunner()
437+
if err != nil {
438+
fmt.Fprintf(stderr, "ccgo plugin marketplace list: %v\n", err)
439+
return 1
440+
}
441+
marketplaces := pluginCLIMarketplaceEntries(runnerMergedSettings(runner))
442+
if *jsonOutput {
443+
encoder := json.NewEncoder(stdout)
444+
encoder.SetIndent("", " ")
445+
if err := encoder.Encode(marketplaces); err != nil {
446+
fmt.Fprintf(stderr, "ccgo plugin marketplace list: %v\n", err)
447+
return 1
448+
}
449+
return 0
450+
}
451+
if len(marketplaces) == 0 {
452+
fmt.Fprintln(stdout, "No marketplaces configured")
453+
return 0
454+
}
455+
fmt.Fprintln(stdout, "Configured marketplaces:")
456+
for _, marketplace := range marketplaces {
457+
fmt.Fprintf(stdout, "- %s\n", marketplace.Name)
458+
if source := pluginCLIMarketplaceSourceText(marketplace); source != "" {
459+
fmt.Fprintf(stdout, " Source: %s\n", source)
460+
}
461+
if marketplace.InstallLocation != "" {
462+
fmt.Fprintf(stdout, " Install location: %s\n", marketplace.InstallLocation)
463+
}
464+
}
465+
return 0
466+
}
467+
396468
func pluginCLIInstalledEntries(plugins []pluginpkg.LoadedPlugin, settings contracts.Settings) []pluginCLIListEntry {
397469
out := make([]pluginCLIListEntry, 0, len(plugins))
398470
for _, plugin := range plugins {
@@ -444,6 +516,81 @@ func pluginCLIAvailableEntries(marketplacePlugins []pluginpkg.LoadedPlugin, inst
444516
return out
445517
}
446518

519+
func pluginCLIMarketplaceEntries(settings contracts.Settings) []pluginCLIMarketplaceEntry {
520+
names := make([]string, 0, len(settings.ExtraKnownMarketplaces))
521+
for name := range settings.ExtraKnownMarketplaces {
522+
if strings.TrimSpace(name) != "" {
523+
names = append(names, strings.TrimSpace(name))
524+
}
525+
}
526+
sort.Strings(names)
527+
out := make([]pluginCLIMarketplaceEntry, 0, len(names))
528+
for _, name := range names {
529+
rawEntry, _ := settings.ExtraKnownMarketplaces[name].(map[string]any)
530+
source := pluginCLIMarketplaceSource(rawEntry)
531+
sourceType := strings.TrimSpace(pluginCLIStringFromMap(source, "source"))
532+
entry := pluginCLIMarketplaceEntry{
533+
Name: name,
534+
Source: sourceType,
535+
InstallLocation: strings.TrimSpace(pluginCLIStringFromMap(rawEntry, "installLocation")),
536+
}
537+
switch sourceType {
538+
case "github":
539+
entry.Repo = pluginCLIStringFromMap(source, "repo")
540+
case "git", "url":
541+
entry.URL = pluginCLIStringFromMap(source, "url")
542+
case "directory", "file":
543+
entry.Path = pluginCLIStringFromMap(source, "path")
544+
case "npm":
545+
entry.Package = pluginCLIStringFromMap(source, "package")
546+
}
547+
out = append(out, entry)
548+
}
549+
return out
550+
}
551+
552+
func pluginCLIMarketplaceSource(rawEntry map[string]any) map[string]any {
553+
if len(rawEntry) == 0 {
554+
return nil
555+
}
556+
if source, ok := rawEntry["source"].(map[string]any); ok {
557+
return source
558+
}
559+
return rawEntry
560+
}
561+
562+
func pluginCLIMarketplaceSourceText(marketplace pluginCLIMarketplaceEntry) string {
563+
switch marketplace.Source {
564+
case "github":
565+
if marketplace.Repo != "" {
566+
return "GitHub (" + marketplace.Repo + ")"
567+
}
568+
case "git":
569+
if marketplace.URL != "" {
570+
return "Git (" + marketplace.URL + ")"
571+
}
572+
case "url":
573+
if marketplace.URL != "" {
574+
return "URL (" + marketplace.URL + ")"
575+
}
576+
case "directory":
577+
if marketplace.Path != "" {
578+
return "Directory (" + marketplace.Path + ")"
579+
}
580+
case "file":
581+
if marketplace.Path != "" {
582+
return "File (" + marketplace.Path + ")"
583+
}
584+
case "npm":
585+
if marketplace.Package != "" {
586+
return "NPM (" + marketplace.Package + ")"
587+
}
588+
case "settings":
589+
return "Settings"
590+
}
591+
return strings.TrimSpace(marketplace.Source)
592+
}
593+
447594
func pluginCLIID(plugin pluginpkg.LoadedPlugin) string {
448595
name := strings.TrimSpace(plugin.Name)
449596
if name == "" {
@@ -467,6 +614,14 @@ func pluginCLINameKey(name string) string {
467614
return strings.ToLower(strings.TrimSpace(name))
468615
}
469616

617+
func pluginCLIStringFromMap(values map[string]any, key string) string {
618+
if len(values) == 0 {
619+
return ""
620+
}
621+
value, _ := values[key].(string)
622+
return strings.TrimSpace(value)
623+
}
624+
470625
func pluginCLIMCPServerNames(servers map[string]contracts.MCPServer) []string {
471626
names := make([]string, 0, len(servers))
472627
for name := range servers {

cmd/claude/main_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2149,6 +2149,113 @@ func TestRunPluginListJSONAvailable(t *testing.T) {
21492149
}
21502150
}
21512151

2152+
func TestRunPluginMarketplaceListCLI(t *testing.T) {
2153+
configHome := t.TempDir()
2154+
t.Setenv("CLAUDE_CONFIG_DIR", configHome)
2155+
project := t.TempDir()
2156+
if err := os.MkdirAll(filepath.Join(project, ".git"), 0o755); err != nil {
2157+
t.Fatal(err)
2158+
}
2159+
directoryMarket := filepath.Join(t.TempDir(), "team-market")
2160+
fileMarket := filepath.Join(t.TempDir(), "catalog.json")
2161+
settings := fmt.Sprintf(`{
2162+
"extraKnownMarketplaces": {
2163+
"team": {
2164+
"source": {"source": "directory", "path": %q},
2165+
"installLocation": "project"
2166+
},
2167+
"remote": {"source": {"source": "url", "url": "https://example.com/catalog.json"}},
2168+
"github": {"source": {"source": "github", "repo": "owner/repo"}},
2169+
"npm-tools": {"source": {"source": "npm", "package": "@example/tools"}},
2170+
"file": {"source": {"source": "file", "path": %q}}
2171+
}
2172+
}`, directoryMarket, fileMarket)
2173+
if err := os.WriteFile(filepath.Join(configHome, "settings.json"), []byte(settings), 0o644); err != nil {
2174+
t.Fatal(err)
2175+
}
2176+
2177+
var stdout, stderr bytes.Buffer
2178+
code := run([]string{"--cwd", project, "plugin", "marketplace", "list", "--json"}, strings.NewReader(""), &stdout, &stderr)
2179+
if code != 0 {
2180+
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
2181+
}
2182+
var payload []map[string]any
2183+
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
2184+
t.Fatalf("invalid json stdout %q: %v", stdout.String(), err)
2185+
}
2186+
if len(payload) != 5 {
2187+
t.Fatalf("marketplaces = %#v", payload)
2188+
}
2189+
byName := map[string]map[string]any{}
2190+
for _, marketplace := range payload {
2191+
name, _ := marketplace["name"].(string)
2192+
byName[name] = marketplace
2193+
}
2194+
if byName["team"]["source"] != "directory" || byName["team"]["path"] != directoryMarket || byName["team"]["installLocation"] != "project" {
2195+
t.Fatalf("team marketplace = %#v", byName["team"])
2196+
}
2197+
if byName["remote"]["source"] != "url" || byName["remote"]["url"] != "https://example.com/catalog.json" {
2198+
t.Fatalf("remote marketplace = %#v", byName["remote"])
2199+
}
2200+
if byName["github"]["source"] != "github" || byName["github"]["repo"] != "owner/repo" {
2201+
t.Fatalf("github marketplace = %#v", byName["github"])
2202+
}
2203+
if byName["npm-tools"]["source"] != "npm" || byName["npm-tools"]["package"] != "@example/tools" {
2204+
t.Fatalf("npm marketplace = %#v", byName["npm-tools"])
2205+
}
2206+
if byName["file"]["source"] != "file" || byName["file"]["path"] != fileMarket {
2207+
t.Fatalf("file marketplace = %#v", byName["file"])
2208+
}
2209+
2210+
stdout.Reset()
2211+
stderr.Reset()
2212+
code = run([]string{"--cwd", project, "plugin", "marketplace", "list"}, strings.NewReader(""), &stdout, &stderr)
2213+
if code != 0 {
2214+
t.Fatalf("text exit = %d stderr=%s", code, stderr.String())
2215+
}
2216+
for _, want := range []string{
2217+
"Configured marketplaces:",
2218+
"- team",
2219+
"Source: Directory (" + directoryMarket + ")",
2220+
"Install location: project",
2221+
"- remote",
2222+
"Source: URL (https://example.com/catalog.json)",
2223+
"- github",
2224+
"Source: GitHub (owner/repo)",
2225+
"- npm-tools",
2226+
"Source: NPM (@example/tools)",
2227+
} {
2228+
if !strings.Contains(stdout.String(), want) {
2229+
t.Fatalf("text output missing %q: %q", want, stdout.String())
2230+
}
2231+
}
2232+
}
2233+
2234+
func TestRunPluginMarketplaceListEmpty(t *testing.T) {
2235+
configHome := t.TempDir()
2236+
t.Setenv("CLAUDE_CONFIG_DIR", configHome)
2237+
project := t.TempDir()
2238+
if err := os.MkdirAll(filepath.Join(project, ".git"), 0o755); err != nil {
2239+
t.Fatal(err)
2240+
}
2241+
2242+
var stdout, stderr bytes.Buffer
2243+
code := run([]string{"--cwd", project, "plugin", "marketplace", "list"}, strings.NewReader(""), &stdout, &stderr)
2244+
if code != 0 {
2245+
t.Fatalf("exit = %d stderr=%s", code, stderr.String())
2246+
}
2247+
if got := strings.TrimSpace(stdout.String()); got != "No marketplaces configured" {
2248+
t.Fatalf("stdout = %q", stdout.String())
2249+
}
2250+
2251+
stdout.Reset()
2252+
stderr.Reset()
2253+
code = run([]string{"--cwd", project, "plugin", "marketplace", "refresh"}, strings.NewReader(""), &stdout, &stderr)
2254+
if code != 2 || !strings.Contains(stderr.String(), "unsupported subcommand refresh") {
2255+
t.Fatalf("unsupported exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
2256+
}
2257+
}
2258+
21522259
func TestRunPrintStreamJSONIncludesToolProgress(t *testing.T) {
21532260
var requestBody map[string]any
21542261
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

docs/cc-100-roadmap.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ M4 补充:tool executor 会围绕 `PreToolUse`、`PostToolUse`、`PermissionDe
3737

3838
M10 补充:plugin command/agent 的 allowed tool frontmatter 解析现在只在顶层逗号或空白处分隔,保留括号、方括号和引号内的逗号/空白,避免 `Bash(git commit -m "x,y")` 这类 tool pattern 被误拆。
3939

40+
M8 补充:CLI `plugin marketplace list` 现在可列出 settings 中已配置的 marketplace;`--json` 输出按名称排序的 source/repo/url/path/package/installLocation 结构,普通文本输出与无配置提示也已覆盖。
41+
4042
M10 补充:新增 `TaskOutput`/`AgentOutputTool``KillTask`/`TaskStop` 内置工具;`TaskOutput` 可列出当前 session 的 sidechain task,或按 task/sidechain ID 读取状态、summary、tail 输出和 agent metadata,`KillTask` 会通过 sidechain manager 写入 cancelled lifecycle summary。完整 AgentTool 执行循环、progress event streaming、resume command UI 和 worktree isolation 仍未完成。
4143

4244
M10 补充:新增 `ResumeTask`/`TaskResume` 只读工具,复用 sidechain resume context builder 返回 `can_resume`、截断状态、message limit、agent metadata 和恢复用 tail message 摘要;plugin agent prompt 被 tail 截断时会自动以前置 system meta message 恢复。完整恢复后的 agent 执行循环和 UI picker 仍未完成。

docs/claude-code-go-rewrite-plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ test/parity/ # golden tests against TS/official behavior
143143
- 本轮补充:`strictPluginOnlyCustomization` 已开始按 policy settings 生效,当前覆盖已实现的 `skills``mcp``hooks` 和 agent-metadata surfaces:锁定后 user/project/local skills 与 legacy commands 不加载,manual MCP server sources 不加载但 plugin/admin policy 仍保留,settings hooks 只保留 policy hooks 且 plugin hooks 继续运行;`agents` 锁定时非 plugin/bundled/admin prompt command 的 `agent``context: fork``effort` frontmatter metadata 会被清除。完整 forked agent runtime/UI edge cases 仍按缺口追踪。
144144
- 本轮补充:settings 文件读取现在有 path-keyed cache,按 size/mode/mtime 指纹复用内容并提供 `ResetSettingsCache`;新增 settings change detector,可对 settings 文件快照区分 created/modified/deleted,并在检测到变化时清空 settings 文件缓存;Runner turn-start 会在检测到 user/project/local settings 文件变化时重载 merged settings 并同步刷新 plugin MCP server app-state。完整后台 watcher 和 remote managed 非 daemon 后台 refresh 生命周期仍按缺口追踪。
145145
- 本轮补充:新增 settings JSON Schema generation,`SettingsJSONSchema()` 会从 `contracts.Settings` 反射生成 draft 2020-12 schema,补强 `$schema` const、permission mode enum、`strictPluginOnlyCustomization` union 和 login method enum;`/config show schema` 现在可展示 schema ID、draft、字段数和生成大小,完整 Zod message parity 仍按缺口追踪。
146-
- 本轮补充:settings validation 现在覆盖官方 marketplace source union 的基础语义,适用于 `extraKnownMarketplaces[].source`、`strictKnownMarketplaces[]` 和 `blockedMarketplaces[]`:校验 source discriminator、各 source 类型必填字段、URL source 绝对 URL、headers string record、github/git sparsePaths string array、settings-source plugins array/item path shape、settings-source name 安全/保留名,并继续覆盖 inline marketplace key/name 一致性;`internal/plugins` 新增 marketplace policy resolver,blocked settings 优先、strict allowlist 非空时拒绝未列入来源,`/plugin marketplaces` 会展示 allow/block decision;本地 plugin manifest 可声明 `marketplace`/`marketplaceName`/`marketplace_name`/`source.name`,带 settings 的 plugin loader 会对这些来源执行 marketplace policy,`extraKnownMarketplaces.<name>.source.source=settings` 的 `plugins` 本地 root、`source=directory` 的本地 marketplace 目录、`source=file` catalog、`source=url` 远端 JSON catalog、`source=git` clone cache、`source=github` repo clone cache 以及 `source=npm` package cache 的 plugin roots 都会进入 plugin load path 并继承 marketplace 名称;URL catalog 支持配置 headers、大小限制、JSON 校验、写入 `marketplace-cache/catalogs` 并在后续请求失败时缓存回退;git/github source 会按 URL clone 到 `marketplace-cache/git`,后续加载 fetch/pull,支持 `ref` checkout 和 repo 内 `path` 指向单插件、插件目录或 catalog JSON,github `repo: owner/name` 会规范化为 GitHub clone URL;npm source 会用 `npm pack` 解包到 `marketplace-cache/npm`,失败时可从已有缓存回退,且支持 package 内 `path` 指向单插件、插件目录或 catalog JSON;`/plugin show/search` 会标注 blocked reason;headless `/plugin available [query]`、`/plugin marketplace plugins|search|show` 会浏览 configured marketplace 插件并标注 available/installed/update 状态;CLI `plugin list --json --available` 会输出 installed/available marketplace JSON;headless `/plugin install <name>` 会从这些已配置 marketplace 来源复制插件到项目 `.claude/plugins/<safe-name>`,不覆盖冲突路径,并刷新 plugin MCP server app-state;headless `/plugin update [name]` 会刷新配置的 marketplace cache,并把已安装的同名插件替换为最新 marketplace 副本后刷新 plugin MCP server app-state。完整 marketplace TUI/UI、后台自动更新和策略 lifecycle 仍按缺口追踪。
146+
- 本轮补充:settings validation 现在覆盖官方 marketplace source union 的基础语义,适用于 `extraKnownMarketplaces[].source`、`strictKnownMarketplaces[]` 和 `blockedMarketplaces[]`:校验 source discriminator、各 source 类型必填字段、URL source 绝对 URL、headers string record、github/git sparsePaths string array、settings-source plugins array/item path shape、settings-source name 安全/保留名,并继续覆盖 inline marketplace key/name 一致性;`internal/plugins` 新增 marketplace policy resolver,blocked settings 优先、strict allowlist 非空时拒绝未列入来源,`/plugin marketplaces` 会展示 allow/block decision;本地 plugin manifest 可声明 `marketplace`/`marketplaceName`/`marketplace_name`/`source.name`,带 settings 的 plugin loader 会对这些来源执行 marketplace policy,`extraKnownMarketplaces.<name>.source.source=settings` 的 `plugins` 本地 root、`source=directory` 的本地 marketplace 目录、`source=file` catalog、`source=url` 远端 JSON catalog、`source=git` clone cache、`source=github` repo clone cache 以及 `source=npm` package cache 的 plugin roots 都会进入 plugin load path 并继承 marketplace 名称;URL catalog 支持配置 headers、大小限制、JSON 校验、写入 `marketplace-cache/catalogs` 并在后续请求失败时缓存回退;git/github source 会按 URL clone 到 `marketplace-cache/git`,后续加载 fetch/pull,支持 `ref` checkout 和 repo 内 `path` 指向单插件、插件目录或 catalog JSON,github `repo: owner/name` 会规范化为 GitHub clone URL;npm source 会用 `npm pack` 解包到 `marketplace-cache/npm`,失败时可从已有缓存回退,且支持 package 内 `path` 指向单插件、插件目录或 catalog JSON;`/plugin show/search` 会标注 blocked reason;headless `/plugin available [query]`、`/plugin marketplace plugins|search|show` 会浏览 configured marketplace 插件并标注 available/installed/update 状态;CLI `plugin list --json --available` 会输出 installed/available marketplace JSON,CLI `plugin marketplace list --json` 会列出已配置 marketplace 的 source/repo/url/path/package/installLocation 投影;headless `/plugin install <name>` 会从这些已配置 marketplace 来源复制插件到项目 `.claude/plugins/<safe-name>`,不覆盖冲突路径,并刷新 plugin MCP server app-state;headless `/plugin update [name]` 会刷新配置的 marketplace cache,并把已安装的同名插件替换为最新 marketplace 副本后刷新 plugin MCP server app-state。完整 marketplace TUI/UI、后台自动更新和策略 lifecycle 仍按缺口追踪。
147147

148148
### M4: Tool framework、permissions、sandbox
149149

0 commit comments

Comments
 (0)