Skip to content

Commit 085bfa0

Browse files
author
SqlRush
committed
Validate plugin content files in CLI
1 parent 1c71f75 commit 085bfa0

8 files changed

Lines changed: 254 additions & 6 deletions

File tree

cmd/claude/main.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,17 @@ func runPluginValidateCLI(state *bootstrap.State, args []string, stdout io.Write
506506
return 2
507507
}
508508
writePluginValidationResult(stdout, result)
509-
if !result.Success {
509+
allSuccess := result.Success
510+
if pluginRoot, ok := pluginCLIValidationContentRoot(result); ok {
511+
for _, contentResult := range pluginpkg.ValidatePluginContents(pluginRoot) {
512+
fmt.Fprintln(stdout)
513+
writePluginValidationResult(stdout, contentResult)
514+
if !contentResult.Success {
515+
allSuccess = false
516+
}
517+
}
518+
}
519+
if !allSuccess {
510520
return 1
511521
}
512522
return 0
@@ -887,7 +897,7 @@ func writePluginUninstallResult(stdout io.Writer, result pluginpkg.PluginUninsta
887897

888898
func writePluginValidationResult(stdout io.Writer, result pluginpkg.ManifestValidationResult) {
889899
lines := []string{
890-
fmt.Sprintf("Validating %s manifest: %s", result.FileType, result.FilePath),
900+
pluginCLIValidationHeader(result),
891901
"",
892902
}
893903
if len(result.Errors) > 0 {
@@ -958,6 +968,26 @@ func pluginCLIValidationMessagePath(path string) string {
958968
return path
959969
}
960970

971+
func pluginCLIValidationHeader(result pluginpkg.ManifestValidationResult) string {
972+
switch result.FileType {
973+
case "plugin", "marketplace":
974+
return fmt.Sprintf("Validating %s manifest: %s", result.FileType, result.FilePath)
975+
default:
976+
return fmt.Sprintf("Validating %s: %s", result.FileType, result.FilePath)
977+
}
978+
}
979+
980+
func pluginCLIValidationContentRoot(result pluginpkg.ManifestValidationResult) (string, bool) {
981+
if result.FileType != "plugin" {
982+
return "", false
983+
}
984+
manifestDir := filepath.Dir(result.FilePath)
985+
if !strings.EqualFold(filepath.Base(manifestDir), ".claude-plugin") {
986+
return "", false
987+
}
988+
return filepath.Dir(manifestDir), true
989+
}
990+
961991
func pluginCLISettingsFromFiles(cwd string) (contracts.Settings, error) {
962992
userSettings, err := pluginCLILoadOptionalSettings(config.UserSettingsPath())
963993
if err != nil {

cmd/claude/main_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2202,6 +2202,54 @@ func TestRunPluginValidateCLI(t *testing.T) {
22022202
t.Fatalf("stdout missing %q: %q", want, stdout.String())
22032203
}
22042204
}
2205+
2206+
officialPlugin := filepath.Join(project, "official-plugin")
2207+
officialManifestDir := filepath.Join(officialPlugin, ".claude-plugin")
2208+
if err := os.MkdirAll(officialManifestDir, 0o755); err != nil {
2209+
t.Fatal(err)
2210+
}
2211+
expectedOfficialManifestDir, err := filepath.EvalSymlinks(officialManifestDir)
2212+
if err != nil {
2213+
t.Fatal(err)
2214+
}
2215+
if err := os.MkdirAll(filepath.Join(officialPlugin, "skills", "audit"), 0o755); err != nil {
2216+
t.Fatal(err)
2217+
}
2218+
if err := os.MkdirAll(filepath.Join(officialPlugin, "hooks"), 0o755); err != nil {
2219+
t.Fatal(err)
2220+
}
2221+
if err := os.WriteFile(filepath.Join(officialManifestDir, "plugin.json"), []byte(`{
2222+
"name": "official-plugin",
2223+
"version": "1.0.0",
2224+
"description": "Official layout plugin",
2225+
"author": {"name": "Team"}
2226+
}`), 0o644); err != nil {
2227+
t.Fatal(err)
2228+
}
2229+
if err := os.WriteFile(filepath.Join(officialPlugin, "skills", "audit", "SKILL.md"), []byte("Audit."), 0o644); err != nil {
2230+
t.Fatal(err)
2231+
}
2232+
if err := os.WriteFile(filepath.Join(officialPlugin, "hooks", "hooks.json"), []byte(`{"hooks":`), 0o644); err != nil {
2233+
t.Fatal(err)
2234+
}
2235+
stdout.Reset()
2236+
stderr.Reset()
2237+
code = run([]string{"--cwd", project, "plugin", "validate", "official-plugin"}, strings.NewReader(""), &stdout, &stderr)
2238+
if code != 1 {
2239+
t.Fatalf("exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
2240+
}
2241+
for _, want := range []string{
2242+
"Validating plugin manifest: " + filepath.Join(expectedOfficialManifestDir, "plugin.json"),
2243+
"Validating skill:",
2244+
"frontmatter: No frontmatter block found",
2245+
"Validating hooks:",
2246+
"json: Invalid JSON syntax",
2247+
"Validation failed",
2248+
} {
2249+
if !strings.Contains(stdout.String(), want) {
2250+
t.Fatalf("stdout missing %q: %q", want, stdout.String())
2251+
}
2252+
}
22052253
}
22062254

22072255
func TestRunPluginUninstallCLI(t *testing.T) {

docs/cc-100-roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ 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 路径不请求模型。
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 诊断;CLI 在官方 `.claude-plugin/plugin.json` 布局下还会扫描默认 skills/agents/commands markdown frontmatter 和 hooks/hooks.json;slash 路径不请求模型。
4747

4848
M8 补充:CLI `plugin uninstall|remove|rm [--scope user|project|local] [--keep-data] <plugin>` 现在复用共享安全卸载 API,默认按官方 CLI 使用 user scope,输出 removed path/scope/status,并保留 `--keep-data` 兼容参数。
4949

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -899,7 +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。
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;CLI 在官方 `.claude-plugin/plugin.json` 布局下还会扫描默认 skills/agents/commands markdown frontmatter 和 hooks/hooks.json
903903
- 本轮补充:CLI `plugin uninstall|remove|rm [--scope user|project|local] [--keep-data] <plugin>` 现在复用共享安全卸载 API,默认 user scope,支持 remove/rm alias 和 `--keep-data` 兼容参数。
904904

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

docs/first-second-parity-audit.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +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.
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, marketplace entry summaries, and CLI-side official-layout content scans for skills/agents/commands frontmatter plus hooks/hooks.json.
9393
- CLI `plugin uninstall|remove|rm [--scope user|project|local] [--keep-data] <plugin>` now reuses the same safe uninstall API, defaults to user scope like the official CLI, and reports removed path/scope/status.
9494
- `/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.
9595
- `/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.

internal/conversation/run.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3581,7 +3581,7 @@ func (r Runner) validatePluginSummary(path string) string {
35813581

35823582
func formatPluginValidationResult(result pluginpkg.ManifestValidationResult) string {
35833583
lines := []string{
3584-
fmt.Sprintf("Validating %s manifest: %s", result.FileType, result.FilePath),
3584+
pluginValidationHeader(result),
35853585
"",
35863586
}
35873587
if len(result.Errors) > 0 {
@@ -3636,6 +3636,15 @@ func formatPluginValidationResult(result pluginpkg.ManifestValidationResult) str
36363636
return strings.TrimRight(strings.Join(lines, "\n"), "\n")
36373637
}
36383638

3639+
func pluginValidationHeader(result pluginpkg.ManifestValidationResult) string {
3640+
switch result.FileType {
3641+
case "plugin", "marketplace":
3642+
return fmt.Sprintf("Validating %s manifest: %s", result.FileType, result.FilePath)
3643+
default:
3644+
return fmt.Sprintf("Validating %s: %s", result.FileType, result.FilePath)
3645+
}
3646+
}
3647+
36393648
func pluginValidationPlural(count int, singular string) string {
36403649
if count == 1 {
36413650
return singular

internal/plugins/validate.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"path/filepath"
88
"sort"
99
"strings"
10+
11+
"ccgo/internal/memory"
1012
)
1113

1214
type ManifestValidationMessage struct {
@@ -26,6 +28,32 @@ type ManifestValidationResult struct {
2628
MarketplaceIDs []string
2729
}
2830

31+
func ValidatePluginContents(pluginDir string) []ManifestValidationResult {
32+
pluginDir = cleanAbs(pluginDir)
33+
var results []ManifestValidationResult
34+
for _, item := range []struct {
35+
fileType string
36+
dir string
37+
skills bool
38+
}{
39+
{fileType: "skill", dir: filepath.Join(pluginDir, "skills"), skills: true},
40+
{fileType: "agent", dir: filepath.Join(pluginDir, "agents")},
41+
{fileType: "command", dir: filepath.Join(pluginDir, "commands")},
42+
} {
43+
for _, path := range collectPluginMarkdownFiles(item.dir, item.skills) {
44+
result := validatePluginComponentFile(path, item.fileType)
45+
if len(result.Errors) > 0 || len(result.Warnings) > 0 {
46+
results = append(results, result)
47+
}
48+
}
49+
}
50+
hooksResult, ok := validatePluginHooksJSON(filepath.Join(pluginDir, "hooks", "hooks.json"))
51+
if ok {
52+
results = append(results, hooksResult)
53+
}
54+
return results
55+
}
56+
2957
func ValidateManifestPath(path string, cwd string) (ManifestValidationResult, error) {
3058
path = strings.TrimSpace(path)
3159
if path == "" {
@@ -498,3 +526,100 @@ func stringFromValidationMap(values map[string]any, key string) string {
498526
value, _ := values[key].(string)
499527
return strings.TrimSpace(value)
500528
}
529+
530+
func collectPluginMarkdownFiles(dir string, skills bool) []string {
531+
entries, err := os.ReadDir(dir)
532+
if err != nil {
533+
return nil
534+
}
535+
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
536+
var out []string
537+
if skills {
538+
for _, entry := range entries {
539+
if entry.IsDir() {
540+
path := filepath.Join(dir, entry.Name(), "SKILL.md")
541+
if info, err := os.Stat(path); err == nil && !info.IsDir() {
542+
out = append(out, path)
543+
}
544+
}
545+
}
546+
return out
547+
}
548+
for _, entry := range entries {
549+
path := filepath.Join(dir, entry.Name())
550+
if entry.IsDir() {
551+
out = append(out, collectPluginMarkdownFiles(path, false)...)
552+
continue
553+
}
554+
if entry.Type().IsRegular() && strings.EqualFold(filepath.Ext(entry.Name()), ".md") {
555+
out = append(out, path)
556+
}
557+
}
558+
return out
559+
}
560+
561+
func validatePluginComponentFile(path string, fileType string) ManifestValidationResult {
562+
result := ManifestValidationResult{
563+
Success: true,
564+
FilePath: cleanAbs(path),
565+
FileType: fileType,
566+
}
567+
data, err := os.ReadFile(path)
568+
if err != nil {
569+
result.Success = false
570+
result.Errors = append(result.Errors, ManifestValidationMessage{Path: "file", Message: "Failed to read: " + err.Error()})
571+
return result
572+
}
573+
content := string(data)
574+
if !strings.HasPrefix(strings.TrimLeft(content, "\ufeff"), "---") {
575+
result.Warnings = append(result.Warnings, ManifestValidationMessage{Path: "frontmatter", Message: "No frontmatter block found. Add YAML frontmatter between --- delimiters at the top of the file to set description and other metadata."})
576+
return result
577+
}
578+
frontmatter, body := memory.ParseFrontmatter(content)
579+
if body == "" && len(frontmatter) == 0 {
580+
result.Success = false
581+
result.Errors = append(result.Errors, ManifestValidationMessage{Path: "frontmatter", Message: "Frontmatter block is not closed or could not be parsed."})
582+
return result
583+
}
584+
if strings.TrimSpace(frontmatter["description"]) == "" {
585+
result.Warnings = append(result.Warnings, ManifestValidationMessage{Path: "description", Message: fmt.Sprintf("No description in frontmatter. A description helps users and Claude understand when to use this %s.", fileType)})
586+
}
587+
if shell := strings.TrimSpace(frontmatter["shell"]); shell != "" {
588+
normalized := strings.ToLower(shell)
589+
if normalized != "bash" && normalized != "powershell" {
590+
result.Success = false
591+
result.Errors = append(result.Errors, ManifestValidationMessage{Path: "shell", Message: fmt.Sprintf("shell must be 'bash' or 'powershell', got %q.", shell)})
592+
}
593+
}
594+
result.Success = len(result.Errors) == 0
595+
return result
596+
}
597+
598+
func validatePluginHooksJSON(path string) (ManifestValidationResult, bool) {
599+
result := ManifestValidationResult{
600+
Success: true,
601+
FilePath: cleanAbs(path),
602+
FileType: "hooks",
603+
}
604+
data, err := os.ReadFile(path)
605+
if err != nil {
606+
if os.IsNotExist(err) {
607+
return ManifestValidationResult{}, false
608+
}
609+
result.Success = false
610+
result.Errors = append(result.Errors, ManifestValidationMessage{Path: "file", Message: "Failed to read file: " + err.Error()})
611+
return result, true
612+
}
613+
var raw any
614+
if err := json.Unmarshal(data, &raw); err != nil {
615+
result.Success = false
616+
result.Errors = append(result.Errors, ManifestValidationMessage{Path: "json", Message: "Invalid JSON syntax: " + err.Error() + ". At runtime this breaks the entire plugin load."})
617+
return result, true
618+
}
619+
if hooks := rawHooksFromAny(raw); len(hooks) == 0 {
620+
result.Success = false
621+
result.Errors = append(result.Errors, ManifestValidationMessage{Path: "hooks", Message: "hooks.json must contain a hooks object with at least one hook event."})
622+
return result, true
623+
}
624+
return result, false
625+
}

internal/plugins/validate_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,42 @@ func TestValidateManifestPathReportsWarningsAndTraversal(t *testing.T) {
136136
}
137137
}
138138

139+
func TestValidatePluginContentsReportsComponentWarningsAndHookErrors(t *testing.T) {
140+
root := t.TempDir()
141+
if err := os.MkdirAll(filepath.Join(root, "skills", "audit"), 0o755); err != nil {
142+
t.Fatal(err)
143+
}
144+
if err := os.MkdirAll(filepath.Join(root, "commands"), 0o755); err != nil {
145+
t.Fatal(err)
146+
}
147+
if err := os.MkdirAll(filepath.Join(root, "hooks"), 0o755); err != nil {
148+
t.Fatal(err)
149+
}
150+
if err := os.WriteFile(filepath.Join(root, "skills", "audit", "SKILL.md"), []byte("Audit."), 0o644); err != nil {
151+
t.Fatal(err)
152+
}
153+
if err := os.WriteFile(filepath.Join(root, "commands", "deploy.md"), []byte("---\nshell: fish\n---\nDeploy."), 0o644); err != nil {
154+
t.Fatal(err)
155+
}
156+
if err := os.WriteFile(filepath.Join(root, "hooks", "hooks.json"), []byte(`{"hooks":`), 0o644); err != nil {
157+
t.Fatal(err)
158+
}
159+
160+
results := ValidatePluginContents(root)
161+
if len(results) != 3 {
162+
t.Fatalf("results = %#v", results)
163+
}
164+
if !validationContains(results[0].Warnings, "frontmatter", "No frontmatter") {
165+
t.Fatalf("skill result = %#v", results[0])
166+
}
167+
if !validationContains(results[1].Errors, "shell", "shell must be") {
168+
t.Fatalf("command result = %#v", results[1])
169+
}
170+
if !validationContains(results[2].Errors, "json", "Invalid JSON syntax") {
171+
t.Fatalf("hooks result = %#v", results[2])
172+
}
173+
}
174+
139175
func validationContains(messages []ManifestValidationMessage, path string, text string) bool {
140176
for _, message := range messages {
141177
if message.Path == path && strings.Contains(message.Message, text) {

0 commit comments

Comments
 (0)