Skip to content

Commit 2c8ba9f

Browse files
author
SqlRush
committed
Add plugin validate command
1 parent 1188fd5 commit 2c8ba9f

9 files changed

Lines changed: 992 additions & 0 deletions

File tree

cmd/claude/main.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,8 @@ func runPluginCLI(ctx context.Context, state *bootstrap.State, args []string, st
339339
return runPluginInstallCLI(state, args[1:], stdout, stderr)
340340
case "update":
341341
return runPluginUpdateCLI(state, args[1:], stdout, stderr)
342+
case "validate":
343+
return runPluginValidateCLI(state, args[1:], stdout, stderr)
342344
case "enable":
343345
return runPluginSetEnabledCLI(state, "enable", args[1:], stdout, stderr)
344346
case "disable":
@@ -483,6 +485,31 @@ func runPluginUpdateCLI(state *bootstrap.State, args []string, stdout io.Writer,
483485
return 0
484486
}
485487

488+
func runPluginValidateCLI(state *bootstrap.State, args []string, stdout io.Writer, stderr io.Writer) int {
489+
flags := flag.NewFlagSet("claude plugin validate", flag.ContinueOnError)
490+
flags.SetOutput(stderr)
491+
if err := flags.Parse(args); err != nil {
492+
if err == flag.ErrHelp {
493+
return 0
494+
}
495+
return 2
496+
}
497+
if flags.NArg() != 1 {
498+
fmt.Fprintln(stderr, "ccgo plugin validate: usage: claude plugin validate <path>")
499+
return 2
500+
}
501+
result, err := pluginpkg.ValidateManifestPath(flags.Arg(0), state.CWD())
502+
if err != nil {
503+
fmt.Fprintf(stderr, "ccgo plugin validate: %v\n", err)
504+
return 2
505+
}
506+
writePluginValidationResult(stdout, result)
507+
if !result.Success {
508+
return 1
509+
}
510+
return 0
511+
}
512+
486513
func runPluginSetEnabledCLI(state *bootstrap.State, action string, args []string, stdout io.Writer, stderr io.Writer) int {
487514
flags := flag.NewFlagSet("claude plugin "+action, flag.ContinueOnError)
488515
flags.SetOutput(stderr)
@@ -807,6 +834,79 @@ func writePluginUpdateResult(stdout io.Writer, result pluginpkg.PluginUpdateResu
807834
fmt.Fprintln(stdout, strings.Join(lines, "\n"))
808835
}
809836

837+
func writePluginValidationResult(stdout io.Writer, result pluginpkg.ManifestValidationResult) {
838+
lines := []string{
839+
fmt.Sprintf("Validating %s manifest: %s", result.FileType, result.FilePath),
840+
"",
841+
}
842+
if len(result.Errors) > 0 {
843+
lines = append(lines, fmt.Sprintf("Found %d %s:", len(result.Errors), pluralWord(len(result.Errors), "error", "errors")))
844+
lines = append(lines, "")
845+
for _, item := range result.Errors {
846+
lines = append(lines, fmt.Sprintf("- %s: %s", pluginCLIValidationMessagePath(item.Path), item.Message))
847+
}
848+
lines = append(lines, "")
849+
}
850+
if len(result.Warnings) > 0 {
851+
lines = append(lines, fmt.Sprintf("Found %d %s:", len(result.Warnings), pluralWord(len(result.Warnings), "warning", "warnings")))
852+
lines = append(lines, "")
853+
for _, item := range result.Warnings {
854+
lines = append(lines, fmt.Sprintf("- %s: %s", pluginCLIValidationMessagePath(item.Path), item.Message))
855+
}
856+
lines = append(lines, "")
857+
}
858+
if result.Success && result.FileType == "plugin" {
859+
lines = append(lines, "Plugin: "+result.Plugin.Name)
860+
if result.Plugin.Version != "" {
861+
lines = append(lines, "Version: "+result.Plugin.Version)
862+
}
863+
lines = append(lines,
864+
fmt.Sprintf("Commands: %d", len(result.Plugin.Commands)+len(result.Plugin.PromptTemplates)),
865+
fmt.Sprintf("Skills: %d", len(result.Plugin.SkillCommands)),
866+
fmt.Sprintf("Agents: %d", len(result.Plugin.Agents)),
867+
fmt.Sprintf("MCP servers: %d", len(result.Plugin.MCPServers)),
868+
fmt.Sprintf("Output styles: %d", len(result.Plugin.OutputStyles)),
869+
fmt.Sprintf("Hooks: %d", len(result.Plugin.HookEvents)),
870+
"",
871+
)
872+
}
873+
if result.Success && result.FileType == "marketplace" {
874+
lines = append(lines, fmt.Sprintf("Marketplace plugins: %d", result.PluginCount))
875+
for _, name := range firstPluginCLIStrings(result.MarketplaceIDs, 10) {
876+
lines = append(lines, "- "+name)
877+
}
878+
if len(result.MarketplaceIDs) > 10 {
879+
lines = append(lines, fmt.Sprintf("Showing 10 of %d marketplace plugins.", len(result.MarketplaceIDs)))
880+
}
881+
lines = append(lines, "")
882+
}
883+
if result.Success {
884+
if len(result.Warnings) > 0 {
885+
lines = append(lines, "Validation passed with warnings")
886+
} else {
887+
lines = append(lines, "Validation passed")
888+
}
889+
} else {
890+
lines = append(lines, "Validation failed")
891+
}
892+
fmt.Fprintln(stdout, strings.TrimRight(strings.Join(lines, "\n"), "\n"))
893+
}
894+
895+
func firstPluginCLIStrings(values []string, limit int) []string {
896+
if len(values) <= limit {
897+
return values
898+
}
899+
return values[:limit]
900+
}
901+
902+
func pluginCLIValidationMessagePath(path string) string {
903+
path = strings.TrimSpace(path)
904+
if path == "" {
905+
return "root"
906+
}
907+
return path
908+
}
909+
810910
func pluginCLISettingsFromFiles(cwd string) (contracts.Settings, error) {
811911
userSettings, err := pluginCLILoadOptionalSettings(config.UserSettingsPath())
812912
if err != nil {

cmd/claude/main_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2145,6 +2145,65 @@ func TestRunPluginListJSONAvailable(t *testing.T) {
21452145
}
21462146
}
21472147

2148+
func TestRunPluginValidateCLI(t *testing.T) {
2149+
configHome := t.TempDir()
2150+
t.Setenv("CLAUDE_CONFIG_DIR", configHome)
2151+
project := t.TempDir()
2152+
pluginDir := filepath.Join(project, "demo-plugin")
2153+
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
2154+
t.Fatal(err)
2155+
}
2156+
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(`{
2157+
"name": "demo-plugin",
2158+
"version": "1.0.0",
2159+
"description": "Demo plugin",
2160+
"author": {"name": "Team"}
2161+
}`), 0o644); err != nil {
2162+
t.Fatal(err)
2163+
}
2164+
expectedPluginDir, err := filepath.EvalSymlinks(pluginDir)
2165+
if err != nil {
2166+
t.Fatal(err)
2167+
}
2168+
var stdout, stderr bytes.Buffer
2169+
code := run([]string{"--cwd", project, "plugin", "validate", "demo-plugin"}, strings.NewReader(""), &stdout, &stderr)
2170+
if code != 0 {
2171+
t.Fatalf("exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
2172+
}
2173+
for _, want := range []string{
2174+
"Validating plugin manifest: " + filepath.Join(expectedPluginDir, "plugin.json"),
2175+
"Plugin: demo-plugin",
2176+
"Validation passed",
2177+
} {
2178+
if !strings.Contains(stdout.String(), want) {
2179+
t.Fatalf("stdout missing %q: %q", want, stdout.String())
2180+
}
2181+
}
2182+
2183+
badDir := filepath.Join(project, "bad-plugin")
2184+
if err := os.MkdirAll(badDir, 0o755); err != nil {
2185+
t.Fatal(err)
2186+
}
2187+
if err := os.WriteFile(filepath.Join(badDir, "plugin.json"), []byte(`{"name":`), 0o644); err != nil {
2188+
t.Fatal(err)
2189+
}
2190+
stdout.Reset()
2191+
stderr.Reset()
2192+
code = run([]string{"--cwd", project, "plugin", "validate", "bad-plugin"}, strings.NewReader(""), &stdout, &stderr)
2193+
if code != 1 {
2194+
t.Fatalf("exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
2195+
}
2196+
for _, want := range []string{
2197+
"Found 1 error:",
2198+
"json: Invalid JSON syntax",
2199+
"Validation failed",
2200+
} {
2201+
if !strings.Contains(stdout.String(), want) {
2202+
t.Fatalf("stdout missing %q: %q", want, stdout.String())
2203+
}
2204+
}
2205+
}
2206+
21482207
func TestRunPluginInstallCLI(t *testing.T) {
21492208
configHome := t.TempDir()
21502209
t.Setenv("CLAUDE_CONFIG_DIR", configHome)

docs/cc-100-roadmap.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ M8 补充:`/plugin help|--help|-h` 已输出官方插件命令用法,`/plugi
4343

4444
M8 补充:`/plugin uninstall|remove|rm [--scope project|user|local] <plugin>` 已接入 no-query 安全卸载路径,只允许删除 installed plugin directory 枚举出的插件 root,输出 removed path/scope/status,并刷新 plugin MCP server app-state。
4545

46+
M8 补充:`/plugin validate <path>` 与 CLI `plugin validate <path>` 已接入共享 manifest 验证 API,支持官方 `.claude-plugin/marketplace.json` 优先目录检测、`.claude-plugin/plugin.json`、当前 Go root `marketplace.json`/`plugin.json` 兼容、未知文件内容推断、JSON/类型/unknown-key/path-traversal 诊断、plugin author/version/description/kebab-case warning 和 marketplace plugin count/duplicate/source 诊断;slash 路径不请求模型。
47+
4648
M10 补充:plugin command/agent 的 allowed tool frontmatter 解析现在只在顶层逗号或空白处分隔,保留括号、方括号和引号内的逗号/空白,避免 `Bash(git commit -m "x,y")` 这类 tool pattern 被误拆。
4749

4850
M8 补充:CLI `plugin marketplace list` 现在可列出 settings 中已配置的 marketplace;`--json` 输出按名称排序的 source/repo/url/path/package/installLocation 结构,普通文本输出与无配置提示也已覆盖。CLI `plugin marketplace add [--scope user|project|local] [--type ...] <name> <source>` 和 `plugin marketplace remove [--scope user|project|local] <name>` 现在复用 `internal/config` settings 文件写入 helper,可按目标 scope 写入/删除 `extraKnownMarketplaces`,并在写入前复用 marketplace source validation,且 `installLocation` 会校验为 `user|project|local`。CLI `plugin marketplace update [name]` 现在可按全部或指定 marketplace 触发现有 URL/git/github/npm/settings cache 加载刷新路径,命名 update 会用轻量 settings 读取避免启动时提前刷新全部 marketplace。运行时插件发现现在会合并项目链 `.claude/plugins` 和用户级 `${CLAUDE_CONFIG_DIR}/plugins`,项目同名插件优先。CLI `plugin install --scope project|user|local <plugin>` 与 headless `/plugin install [--scope project|user|local] <plugin>` 现在复用 `internal/plugins` 共享安装 API,把 marketplace 插件复制到目标 scope 的 `plugins/<safe-name>`,未显式传 scope 时会尊重 marketplace `installLocation` 默认值,并保留冲突检测、重复安装识别和 symlink/non-regular file 拒绝。CLI `plugin update --scope project|user|local|all <plugin>` 与 headless `/plugin update [--scope project|user|local|all] [plugin]` 现在复用 `internal/plugins` 共享更新 API,把目标 scope 已安装同名插件替换为最新 marketplace 副本,未显式传 scope 的命名更新同样会尊重 marketplace `installLocation`。CLI `plugin enable|disable [--scope user|project|local] <plugin>` 与 `plugin disable --all --scope ...` 现在复用 `internal/config` settings 文件写入 helper,按目标 scope 更新 `enabledPlugins` 并覆盖基本参数冲突。

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,6 +899,7 @@ test/parity/ # golden tests against TS/official behavior
899899

900900
- 本轮补充:`/plugin help|--help|-h` 现在输出官方 Plugin Command Usage 文案,`/plugin manage` 复用已安装 plugin summary,`/plugin i` 作为 install alias 接入同一安装路径,builtin command registry 同步声明官方 `/plugins``/marketplace` 顶层别名。
901901
- 本轮补充:`/plugin uninstall|remove|rm [--scope project|user|local] <plugin>` 现在有 no-query 安全卸载路径,只删除 installed plugin directory 枚举出的插件 root,输出 removed path/scope/status,并刷新 plugin MCP server app-state。
902+
- 本轮补充:`/plugin validate <path>` 现在走 no-query 本地 manifest 验证路径,并与 CLI `plugin validate <path>` 共享 `internal/plugins` 验证 API;目录输入会按官方优先级检测 `.claude-plugin/marketplace.json``.claude-plugin/plugin.json`,同时兼容当前 Go root `marketplace.json`/`plugin.json` 布局,输出 JSON/类型/unknown-key/path-traversal 诊断、plugin warning 和 marketplace plugin summary。
902903

903904
- 本轮补充:bridge-safe 内置本地命令 `/summary``/release-notes``/files` 现在已注册并接入 no-query runner 路径;`/summary` 输出确定性的会话/历史消息计数、工具使用计数、估算 token 和最近用户/助手预览,`/files` 只读列出当前工作目录第一层条目而不读取文件内容,`/release-notes` 明确报告当前 Go runtime 未打包 release notes。完整 local-jsx UI surface 和其它本地命令 parity 仍需继续补。
904905

docs/first-second-parity-audit.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ Anthropic API 和 conversation:
8989
- `/mcp <server-name>` now takes the no-query local server-detail path used by `/mcp show <server-name>` when the name matches a configured settings or plugin MCP server, while unknown one-word subcommands still report unsupported.
9090
- `/plugin help|--help|-h` now returns the official plugin command usage text without querying the model, `/plugin manage` reuses the installed-plugin summary path, `/plugin i` aliases install, and the built-in command registry now exposes the official `/plugins` and `/marketplace` aliases.
9191
- `/plugin uninstall|remove|rm [--scope project|user|local] <plugin>` now takes a no-query safe uninstall path that only removes plugin roots discovered from installed plugin directories, reports removed path/scope, and refreshes plugin MCP server app-state.
92+
- `/plugin validate <path>` now takes a no-query local validation path backed by a shared plugin manifest validator also used by CLI `plugin validate <path>`, covering official `.claude-plugin` marketplace/plugin directory detection, current Go root manifest layouts, JSON/type/unknown-key/path-traversal diagnostics, common plugin warnings, and marketplace entry summaries.
9293
- `/plugin <plugin-name>` now takes the no-query local plugin-detail path used by `/plugin show <plugin-name>` when the name matches a local plugin manifest, while unknown one-word subcommands still report unsupported.
9394
- `/model show`, `/model info`, and `/model current` now take the no-query current-model path instead of treating those words as custom model names and mutating the runner model.
9495
- A basic `Skill` tool wrapper is now registered with the default built-in tool set. It can invoke local project and plugin-directory prompt skills through the command registry, returns the official-style `Launching skill: ...` result plus structured command metadata, preserves command source/display/root/frontmatter metadata for plugin skills, and passes expanded meta user messages through `ToolResult.NewMessages`; the conversation runner now appends those new messages to transcripts and subsequent model requests.

internal/conversation/run.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3390,6 +3390,8 @@ func (r Runner) formatPluginSummary(raw string) string {
33903390
return r.formatPluginConfig(args)
33913391
case "install", "i":
33923392
return r.installPluginSummary(subcommandRemainder(raw, args[0]))
3393+
case "validate":
3394+
return r.validatePluginSummary(subcommandRemainder(raw, args[0]))
33933395
case "uninstall", "remove", "rm":
33943396
return r.uninstallPluginSummary(subcommandRemainder(raw, args[0]))
33953397
case "update":
@@ -3546,6 +3548,109 @@ func pluginCommandHelp() string {
35463548
}, "\n")
35473549
}
35483550

3551+
func pluginValidateUsage() string {
3552+
return strings.Join([]string{
3553+
"Usage: /plugin validate <path>",
3554+
"",
3555+
"Validate a plugin or marketplace manifest file or directory.",
3556+
"",
3557+
"Examples:",
3558+
" /plugin validate .claude-plugin/plugin.json",
3559+
" /plugin validate /path/to/plugin-directory",
3560+
" /plugin validate .",
3561+
"",
3562+
"When given a directory, automatically validates .claude-plugin/marketplace.json",
3563+
"or .claude-plugin/plugin.json (prefers marketplace if both exist).",
3564+
"",
3565+
"Or from the command line:",
3566+
" claude plugin validate <path>",
3567+
}, "\n")
3568+
}
3569+
3570+
func (r Runner) validatePluginSummary(path string) string {
3571+
path = strings.TrimSpace(path)
3572+
if path == "" {
3573+
return pluginValidateUsage()
3574+
}
3575+
result, err := pluginpkg.ValidateManifestPath(path, r.WorkingDirectory)
3576+
if err != nil {
3577+
return "Unexpected error during validation: " + err.Error()
3578+
}
3579+
return formatPluginValidationResult(result)
3580+
}
3581+
3582+
func formatPluginValidationResult(result pluginpkg.ManifestValidationResult) string {
3583+
lines := []string{
3584+
fmt.Sprintf("Validating %s manifest: %s", result.FileType, result.FilePath),
3585+
"",
3586+
}
3587+
if len(result.Errors) > 0 {
3588+
lines = append(lines, fmt.Sprintf("Found %d %s:", len(result.Errors), pluginValidationPlural(len(result.Errors), "error")))
3589+
lines = append(lines, "")
3590+
for _, item := range result.Errors {
3591+
lines = append(lines, fmt.Sprintf("- %s: %s", pluginValidationMessagePath(item.Path), item.Message))
3592+
}
3593+
lines = append(lines, "")
3594+
}
3595+
if len(result.Warnings) > 0 {
3596+
lines = append(lines, fmt.Sprintf("Found %d %s:", len(result.Warnings), pluginValidationPlural(len(result.Warnings), "warning")))
3597+
lines = append(lines, "")
3598+
for _, item := range result.Warnings {
3599+
lines = append(lines, fmt.Sprintf("- %s: %s", pluginValidationMessagePath(item.Path), item.Message))
3600+
}
3601+
lines = append(lines, "")
3602+
}
3603+
if result.Success && result.FileType == "plugin" {
3604+
if result.Plugin.Name != "" {
3605+
lines = append(lines, "Plugin: "+result.Plugin.Name)
3606+
}
3607+
lines = append(lines,
3608+
fmt.Sprintf("Commands: %d", len(loadedPluginCommandNames(result.Plugin))),
3609+
fmt.Sprintf("Skills: %d", len(result.Plugin.SkillCommands)),
3610+
fmt.Sprintf("Agents: %d", len(result.Plugin.Agents)),
3611+
fmt.Sprintf("MCP servers: %d", len(result.Plugin.MCPServers)),
3612+
fmt.Sprintf("Output styles: %d", len(result.Plugin.OutputStyles)),
3613+
fmt.Sprintf("Hooks: %d", pluginHookCount([]pluginpkg.LoadedPlugin{result.Plugin})),
3614+
"",
3615+
)
3616+
}
3617+
if result.Success && result.FileType == "marketplace" {
3618+
lines = append(lines, fmt.Sprintf("Marketplace plugins: %d", result.PluginCount))
3619+
for _, name := range firstStrings(result.MarketplaceIDs, 10) {
3620+
lines = append(lines, "- "+name)
3621+
}
3622+
if len(result.MarketplaceIDs) > 10 {
3623+
lines = append(lines, fmt.Sprintf("Showing 10 of %d marketplace plugins.", len(result.MarketplaceIDs)))
3624+
}
3625+
lines = append(lines, "")
3626+
}
3627+
if result.Success {
3628+
if len(result.Warnings) > 0 {
3629+
lines = append(lines, "Validation passed with warnings")
3630+
} else {
3631+
lines = append(lines, "Validation passed")
3632+
}
3633+
} else {
3634+
lines = append(lines, "Validation failed")
3635+
}
3636+
return strings.TrimRight(strings.Join(lines, "\n"), "\n")
3637+
}
3638+
3639+
func pluginValidationPlural(count int, singular string) string {
3640+
if count == 1 {
3641+
return singular
3642+
}
3643+
return singular + "s"
3644+
}
3645+
3646+
func pluginValidationMessagePath(path string) string {
3647+
path = strings.TrimSpace(path)
3648+
if path == "" {
3649+
return "root"
3650+
}
3651+
return path
3652+
}
3653+
35493654
func (r Runner) formatPluginShow(args []string) string {
35503655
if len(args) < 2 || strings.TrimSpace(args[1]) == "" {
35513656
return "Usage: /plugin " + args[0] + " <plugin-name>"

0 commit comments

Comments
 (0)