diff --git a/doc/cmd_test.go b/doc/cmd_test.go index 0d022c77d..6335f565a 100644 --- a/doc/cmd_test.go +++ b/doc/cmd_test.go @@ -57,6 +57,9 @@ var echoCmd = &cobra.Command{ Short: "Echo anything to the screen", Long: "an utterly useless command for testing", Example: "Just run cobra-test echo", + Annotations: map[string]string{ + "skills:tip:output": "Supports JSON output via -o json.", + }, } var echoSubCmd = &cobra.Command{ diff --git a/doc/skills_docs.go b/doc/skills_docs.go new file mode 100644 index 000000000..55d1229ff --- /dev/null +++ b/doc/skills_docs.go @@ -0,0 +1,340 @@ +// Copyright 2013-2023 The Cobra Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package doc + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/spf13/cobra" +) + +// SkillsConfig holds configuration for generating an agentskills.io-compatible +// SKILL.md file. +type SkillsConfig struct { + // Name is the skill name. Must be 1-64 lowercase alphanumeric characters + // and hyphens. Must not start or end with a hyphen or contain consecutive + // hyphens. If empty, it is derived from the command name. + Name string + + // Description describes what the skill does and when to use it. + // Max 1024 characters. If empty, it is derived from the command's + // Short and Long descriptions. + Description string + + // License is an optional license name or reference. + License string + + // Compatibility optionally indicates environment requirements. + // Max 500 characters. + Compatibility string + + // Metadata holds arbitrary key-value pairs for additional metadata. + Metadata map[string]string + + // AllowedTools is an optional space-delimited list of pre-approved tools. + AllowedTools string + + // DisableModelInvocation prevents agents from automatically loading + // this skill. Set to true for workflows triggered manually. + DisableModelInvocation bool + + // Notes are global notes rendered in SKILL.md body as a "Notes" section. + // Each entry becomes a bullet point. Useful for cross-cutting information + // that applies to multiple commands (e.g., "Most list commands support -o json"). + Notes []string +} + +// toSkillName converts a command name to a valid skill name. +func toSkillName(name string) string { + s := strings.ToLower(name) + s = strings.ReplaceAll(s, " ", "-") + s = strings.ReplaceAll(s, "_", "-") + for strings.Contains(s, "--") { + s = strings.ReplaceAll(s, "--", "-") + } + s = strings.Trim(s, "-") + if len(s) > 64 { + s = s[:64] + s = strings.TrimRight(s, "-") + } + return s +} + +// GenSkills generates an agentskills.io-compatible SKILL.md document +// for the command tree. The output is concise and suitable for use as +// the main SKILL.md. For large command trees, use GenSkillsDir which +// also generates a references/REFERENCE.md with detailed documentation. +func GenSkills(cmd *cobra.Command, w io.Writer, config SkillsConfig) error { + return genSkillsInternal(cmd, w, config, false) +} + +func genSkillsInternal(cmd *cobra.Command, w io.Writer, config SkillsConfig, hasReference bool) error { + cmd.InitDefaultHelpCmd() + cmd.InitDefaultHelpFlag() + + buf := new(bytes.Buffer) + + name := config.Name + if name == "" { + name = toSkillName(cmd.Name()) + } + + description := config.Description + if description == "" { + description = cmd.Short + if len(cmd.Long) > 0 { + description = cmd.Long + } + } + if len(description) > 1024 { + description = description[:1024] + } + + genFrontmatter(buf, name, description, config) + genSkillsBody(buf, cmd, hasReference, config) + + _, err := buf.WriteTo(w) + return err +} + +// GenSkillsDir generates a skill directory following the agentskills.io +// progressive disclosure convention: +// +// //SKILL.md +// //references/.md (one per command) +// +// SKILL.md contains the frontmatter and a concise command overview. +// Each reference file contains detailed documentation for a single +// command including usage, examples, and flags. Agents load only the +// reference file they need, keeping context usage minimal. +func GenSkillsDir(cmd *cobra.Command, dir string, config SkillsConfig) error { + name := config.Name + if name == "" { + name = toSkillName(cmd.Name()) + } + + skillDir := filepath.Join(dir, name) + if err := os.MkdirAll(skillDir, 0o755); err != nil { + return err + } + + skillFile, err := os.Create(filepath.Join(skillDir, "SKILL.md")) + if err != nil { + return err + } + defer skillFile.Close() + + if err := genSkillsInternal(cmd, skillFile, config, true); err != nil { + return err + } + + refDir := filepath.Join(skillDir, "references") + if err := os.MkdirAll(refDir, 0o755); err != nil { + return err + } + + commands := collectCommands(cmd) + for _, c := range commands { + basename := cmdRefFilename(c) + f, err := os.Create(filepath.Join(refDir, basename)) + if err != nil { + return err + } + err = genRefFile(c, f) + f.Close() + if err != nil { + return err + } + } + + return nil +} + +// genFrontmatter writes the YAML frontmatter block. +func genFrontmatter(buf *bytes.Buffer, name, description string, config SkillsConfig) { + buf.WriteString("---\n") + fmt.Fprintf(buf, "name: %s\n", name) + fmt.Fprintf(buf, "description: %s\n", yamlEscapeString(description)) + if config.License != "" { + fmt.Fprintf(buf, "license: %s\n", config.License) + } + if config.Compatibility != "" { + fmt.Fprintf(buf, "compatibility: %s\n", yamlEscapeString(config.Compatibility)) + } + if config.DisableModelInvocation { + buf.WriteString("disable-model-invocation: true\n") + } + if config.AllowedTools != "" { + fmt.Fprintf(buf, "allowed-tools: %s\n", config.AllowedTools) + } + if len(config.Metadata) > 0 { + buf.WriteString("metadata:\n") + keys := make([]string, 0, len(config.Metadata)) + for k := range config.Metadata { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(buf, " %s: %s\n", k, yamlEscapeString(config.Metadata[k])) + } + } + buf.WriteString("---\n\n") +} + +// genSkillsBody writes the concise SKILL.md body with a command summary. +func genSkillsBody(buf *bytes.Buffer, cmd *cobra.Command, hasReference bool, config SkillsConfig) { + commands := collectCommands(cmd) + + buf.WriteString("# " + cmd.Name() + "\n\n") + if len(cmd.Long) > 0 { + buf.WriteString(cmd.Long + "\n\n") + } else { + buf.WriteString(cmd.Short + "\n\n") + } + + if len(config.Notes) > 0 { + buf.WriteString("## Notes\n\n") + for _, note := range config.Notes { + fmt.Fprintf(buf, "- %s\n", note) + } + buf.WriteString("\n") + } + + if cmd.Runnable() { + fmt.Fprintf(buf, "```\n%s\n```\n\n", cmd.UseLine()) + } + + if len(commands) > 1 { + buf.WriteString("## Available Commands\n\n") + for _, c := range commands[1:] { + if hasReference { + fmt.Fprintf(buf, "- [`%s`](references/%s) - %s\n", c.CommandPath(), cmdRefFilename(c), c.Short) + } else { + fmt.Fprintf(buf, "- `%s` - %s\n", c.CommandPath(), c.Short) + } + } + buf.WriteString("\n") + } + + if hasReference { + fmt.Fprintf(buf, "See [references/%s](references/%s) for root command flags.\n\n", cmdRefFilename(cmd), cmdRefFilename(cmd)) + } + + buf.WriteString("Run `" + cmd.Name() + " --help` or `" + cmd.Name() + " --help` for full usage details.\n") +} + +// cmdRefFilename returns the reference filename for a command, +// e.g. "root_echo_times.md". +func cmdRefFilename(cmd *cobra.Command) string { + return strings.ReplaceAll(cmd.CommandPath(), " ", "_") + markdownExtension +} + +// collectTips extracts tips from a command's Annotations. +// Any annotation key starting with "skills:tip" is collected. +// Tips are sorted by annotation key for deterministic output. +func collectTips(cmd *cobra.Command) []string { + if len(cmd.Annotations) == 0 { + return nil + } + var keys []string + for k := range cmd.Annotations { + if strings.HasPrefix(k, "skills:tip") { + keys = append(keys, k) + } + } + if len(keys) == 0 { + return nil + } + sort.Strings(keys) + tips := make([]string, 0, len(keys)) + for _, k := range keys { + tips = append(tips, cmd.Annotations[k]) + } + return tips +} + +// genRefFile writes a detailed reference file for a single command. +func genRefFile(cmd *cobra.Command, w io.Writer) error { + cmd.InitDefaultHelpCmd() + cmd.InitDefaultHelpFlag() + + buf := new(bytes.Buffer) + name := cmd.CommandPath() + + buf.WriteString("# " + name + "\n\n") + buf.WriteString(cmd.Short + "\n\n") + + if len(cmd.Long) > 0 && cmd.Long != cmd.Short { + buf.WriteString(cmd.Long + "\n\n") + } + + if cmd.Runnable() { + fmt.Fprintf(buf, "```\n%s\n```\n\n", cmd.UseLine()) + } + + if len(cmd.Example) > 0 { + buf.WriteString("## Examples\n\n") + fmt.Fprintf(buf, "```\n%s\n```\n\n", cmd.Example) + } + + if tips := collectTips(cmd); len(tips) > 0 { + buf.WriteString("### Tips\n\n") + for _, tip := range tips { + fmt.Fprintf(buf, "- %s\n", tip) + } + buf.WriteString("\n") + } + + if err := printOptions(buf, cmd, name); err != nil { + return err + } + + _, err := buf.WriteTo(w) + return err +} + +// collectCommands returns cmd and all available descendant commands +// in depth-first order. +func collectCommands(cmd *cobra.Command) []*cobra.Command { + var result []*cobra.Command + result = append(result, cmd) + + children := cmd.Commands() + sort.Sort(byName(children)) + + for _, child := range children { + if !child.IsAvailableCommand() || child.IsAdditionalHelpTopicCommand() { + continue + } + result = append(result, collectCommands(child)...) + } + return result +} + +// yamlEscapeString wraps a string in quotes if it contains special +// YAML characters. +func yamlEscapeString(s string) string { + if strings.ContainsAny(s, ":#{}[]|>&*!%@`,\n\"") { + escaped := strings.ReplaceAll(s, `"`, `\"`) + return `"` + escaped + `"` + } + return s +} diff --git a/doc/skills_docs_test.go b/doc/skills_docs_test.go new file mode 100644 index 000000000..8edef3919 --- /dev/null +++ b/doc/skills_docs_test.go @@ -0,0 +1,359 @@ +// Copyright 2013-2023 The Cobra Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package doc + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestGenSkillsDoc(t *testing.T) { + buf := new(bytes.Buffer) + config := SkillsConfig{ + Description: "Root command for testing", + } + if err := GenSkills(rootCmd, buf, config); err != nil { + t.Fatal(err) + } + output := buf.String() + + checkStringContains(t, output, "---\nname: root\n") + checkStringContains(t, output, "description: Root command for testing\n") + checkStringContains(t, output, "---\n") + checkStringContains(t, output, "# root") + checkStringContains(t, output, rootCmd.Long) + checkStringContains(t, output, "## Available Commands") + checkStringContains(t, output, "`root echo` - "+echoCmd.Short) + checkStringContains(t, output, "`root echo times` - "+timesCmd.Short) + checkStringOmits(t, output, deprecatedCmd.Short) + checkStringContains(t, output, "root --help") + checkStringOmits(t, output, "references/REFERENCE.md") +} + +func TestGenSkillsDocDefaultDescription(t *testing.T) { + buf := new(bytes.Buffer) + if err := GenSkills(rootCmd, buf, SkillsConfig{}); err != nil { + t.Fatal(err) + } + output := buf.String() + + checkStringContains(t, output, "description: "+rootCmd.Long) +} + +func TestGenSkillsFrontmatter(t *testing.T) { + buf := new(bytes.Buffer) + config := SkillsConfig{ + Name: "my-cli", + Description: "Manage widgets and gadgets", + License: "Apache-2.0", + Compatibility: "Requires git and docker", + AllowedTools: "Bash(git:*) Read", + DisableModelInvocation: true, + Metadata: map[string]string{ + "author": "test-org", + "version": "1.0", + }, + } + if err := GenSkills(rootCmd, buf, config); err != nil { + t.Fatal(err) + } + output := buf.String() + + checkStringContains(t, output, "name: my-cli\n") + checkStringContains(t, output, "description: Manage widgets and gadgets\n") + checkStringContains(t, output, "license: Apache-2.0\n") + checkStringContains(t, output, "compatibility: Requires git and docker\n") + checkStringContains(t, output, "disable-model-invocation: true\n") + checkStringContains(t, output, "allowed-tools: Bash(git:*) Read\n") + checkStringContains(t, output, "metadata:\n") + checkStringContains(t, output, " author: test-org\n") + checkStringContains(t, output, " version: 1.0\n") +} + +func TestGenSkillsDir(t *testing.T) { + tmpdir, err := os.MkdirTemp("", "test-gen-skills") + if err != nil { + t.Fatalf("Failed to create tmpdir: %v", err) + } + defer os.RemoveAll(tmpdir) + + config := SkillsConfig{ + Name: "my-tool", + Description: "A test tool", + Notes: []string{ + "Most list commands support -o json.", + }, + } + if err := GenSkillsDir(rootCmd, tmpdir, config); err != nil { + t.Fatalf("GenSkillsDir failed: %v", err) + } + + skillFile := filepath.Join(tmpdir, "my-tool", "SKILL.md") + if _, err := os.Stat(skillFile); err != nil { + t.Fatalf("Expected file 'my-tool/SKILL.md' to exist") + } + + skill, err := os.ReadFile(skillFile) + if err != nil { + t.Fatalf("Failed to read SKILL.md: %v", err) + } + skillContent := string(skill) + checkStringContains(t, skillContent, "name: my-tool\n") + checkStringContains(t, skillContent, "# root") + checkStringContains(t, skillContent, "references/root_echo.md") + checkStringContains(t, skillContent, "references/root.md") + checkStringOmits(t, skillContent, "### Examples") + checkStringOmits(t, skillContent, "### Options") + checkStringContains(t, skillContent, "## Notes") + checkStringContains(t, skillContent, "- Most list commands support -o json.") + + refDir := filepath.Join(tmpdir, "my-tool", "references") + expectedRefs := []string{ + "root.md", + "root_echo.md", + "root_echo_echosub.md", + "root_echo_times.md", + } + for _, name := range expectedRefs { + if _, err := os.Stat(filepath.Join(refDir, name)); err != nil { + t.Fatalf("Expected reference file %q to exist", name) + } + } + + echoRef, err := os.ReadFile(filepath.Join(refDir, "root_echo.md")) + if err != nil { + t.Fatalf("Failed to read root_echo.md: %v", err) + } + echoContent := string(echoRef) + checkStringContains(t, echoContent, "# root echo") + checkStringContains(t, echoContent, echoCmd.Long) + checkStringContains(t, echoContent, echoCmd.Example) + checkStringContains(t, echoContent, "boolone") + checkStringContains(t, echoContent, "### Tips") + checkStringContains(t, echoContent, "- Supports JSON output via -o json.") + + timesRef, err := os.ReadFile(filepath.Join(refDir, "root_echo_times.md")) + if err != nil { + t.Fatalf("Failed to read root_echo_times.md: %v", err) + } + timesContent := string(timesRef) + checkStringContains(t, timesContent, "# root echo times") + checkStringContains(t, timesContent, timesCmd.Short) + checkStringContains(t, timesContent, "Options inherited from parent commands") + checkStringOmits(t, timesContent, "### Tips") +} + +func TestGenSkillsDirDefaultName(t *testing.T) { + tmpdir, err := os.MkdirTemp("", "test-gen-skills") + if err != nil { + t.Fatalf("Failed to create tmpdir: %v", err) + } + defer os.RemoveAll(tmpdir) + + if err := GenSkillsDir(rootCmd, tmpdir, SkillsConfig{}); err != nil { + t.Fatalf("GenSkillsDir failed: %v", err) + } + + filename := filepath.Join(tmpdir, "root", "SKILL.md") + if _, err := os.Stat(filename); err != nil { + t.Fatalf("Expected file 'root/SKILL.md' to exist") + } +} + +func TestGenSkillsSingleCommand(t *testing.T) { + cmd := &cobra.Command{ + Use: "solo", + Short: "A standalone command", + Long: "A standalone command with no subcommands", + Run: emptyRun, + } + + buf := new(bytes.Buffer) + if err := GenSkills(cmd, buf, SkillsConfig{}); err != nil { + t.Fatal(err) + } + output := buf.String() + + checkStringContains(t, output, "name: solo\n") + checkStringContains(t, output, "# solo") + checkStringContains(t, output, "A standalone command with no subcommands") + checkStringOmits(t, output, "## Available Commands") +} + +func TestToSkillName(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"MyApp", "myapp"}, + {"my-app", "my-app"}, + {"my_app", "my-app"}, + {"My App", "my-app"}, + {"--my--app--", "my-app"}, + } + for _, tt := range tests { + got := toSkillName(tt.input) + if got != tt.expected { + t.Errorf("toSkillName(%q) = %q, want %q", tt.input, got, tt.expected) + } + } +} + +func TestYamlEscapeString(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"simple", "simple"}, + {"has: colon", `"has: colon"`}, + {"has #comment", `"has #comment"`}, + } + for _, tt := range tests { + got := yamlEscapeString(tt.input) + if got != tt.expected { + t.Errorf("yamlEscapeString(%q) = %q, want %q", tt.input, got, tt.expected) + } + } + + input := `has "quotes"` + got := yamlEscapeString(input) + if !strings.HasPrefix(got, `"`) || !strings.HasSuffix(got, `"`) { + t.Errorf("yamlEscapeString(%q) should be quoted, got %q", input, got) + } +} + +func TestGenSkillsNotes(t *testing.T) { + buf := new(bytes.Buffer) + config := SkillsConfig{ + Notes: []string{ + "Most list commands support `-o json` for machine-readable output.", + "Use `--workspace` to target a specific workspace.", + }, + } + if err := GenSkills(rootCmd, buf, config); err != nil { + t.Fatal(err) + } + output := buf.String() + + checkStringContains(t, output, "## Notes") + checkStringContains(t, output, "- Most list commands support `-o json` for machine-readable output.") + checkStringContains(t, output, "- Use `--workspace` to target a specific workspace.") +} + +func TestGenSkillsNoNotesSection(t *testing.T) { + buf := new(bytes.Buffer) + if err := GenSkills(rootCmd, buf, SkillsConfig{}); err != nil { + t.Fatal(err) + } + output := buf.String() + + checkStringOmits(t, output, "## Notes") +} + +func TestGenRefFileWithTips(t *testing.T) { + cmd := &cobra.Command{ + Use: "list", + Short: "List items", + Long: "List all items in the workspace", + Run: emptyRun, + Annotations: map[string]string{ + "skills:tip:output": "Use `-o json` for machine-readable output.", + "skills:tip:mode": "Use `--mode draft` to see unpublished changes.", + }, + } + + buf := new(bytes.Buffer) + if err := genRefFile(cmd, buf); err != nil { + t.Fatal(err) + } + output := buf.String() + + checkStringContains(t, output, "### Tips") + checkStringContains(t, output, "- Use `--mode draft` to see unpublished changes.") + checkStringContains(t, output, "- Use `-o json` for machine-readable output.") +} + +func TestGenRefFileWithoutTips(t *testing.T) { + cmd := &cobra.Command{ + Use: "pull", + Short: "Pull items", + Run: emptyRun, + } + + buf := new(bytes.Buffer) + if err := genRefFile(cmd, buf); err != nil { + t.Fatal(err) + } + output := buf.String() + + checkStringOmits(t, output, "### Tips") +} + +func TestCollectTips(t *testing.T) { + cmd := &cobra.Command{ + Use: "test", + Short: "Test command", + Run: emptyRun, + Annotations: map[string]string{ + "skills:tip:output": "Use `-o json` for machine-readable output.", + "skills:tip:slug": "Pass slug as positional argument or `--slug` flag.", + "unrelated": "should be ignored", + }, + } + tips := collectTips(cmd) + if len(tips) != 2 { + t.Fatalf("expected 2 tips, got %d", len(tips)) + } + // Should be sorted by key for deterministic output + if tips[0] != "Use `-o json` for machine-readable output." { + t.Errorf("unexpected first tip: %s", tips[0]) + } + if tips[1] != "Pass slug as positional argument or `--slug` flag." { + t.Errorf("unexpected second tip: %s", tips[1]) + } +} + +func TestCollectTipsEmpty(t *testing.T) { + cmd := &cobra.Command{ + Use: "test", + Short: "Test command", + Run: emptyRun, + } + tips := collectTips(cmd) + if len(tips) != 0 { + t.Fatalf("expected 0 tips, got %d", len(tips)) + } +} + +func BenchmarkGenSkillsToFile(b *testing.B) { + file, err := os.CreateTemp("", "") + if err != nil { + b.Fatal(err) + } + defer os.Remove(file.Name()) + defer file.Close() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := GenSkills(rootCmd, file, SkillsConfig{}); err != nil { + b.Fatal(err) + } + } +}