api.go
1 package skills 2 3 import ( 4 "fmt" 5 "os" 6 "os/exec" 7 "path/filepath" 8 "strings" 9 10 "gopkg.in/yaml.v3" 11 ) 12 13 // SkillDetail is the API response type for GET /skills/{name}. 14 // Includes prompt body and source, unlike SkillMeta (metadata only) 15 // or Skill (which hides Source/Dir via json:"-" tags). 16 type SkillDetail struct { 17 Name string `json:"name"` 18 Slug string `json:"slug"` 19 Description string `json:"description"` 20 Prompt string `json:"prompt"` 21 Source string `json:"source"` 22 InstallSource string `json:"install_source"` 23 MarketplaceSlug string `json:"marketplace_slug,omitempty"` 24 License string `json:"license,omitempty"` 25 Compatibility string `json:"compatibility,omitempty"` 26 Metadata map[string]any `json:"metadata,omitempty"` 27 AllowedTools []string `json:"allowed_tools,omitempty"` 28 StickyInstructions bool `json:"sticky_instructions,omitempty"` 29 Hidden bool `json:"hidden,omitempty"` 30 StickySnippet string `json:"sticky_snippet,omitempty"` 31 RequiredSecrets []SecretSpec `json:"required_secrets,omitempty"` 32 ConfiguredSecrets []string `json:"configured_secrets,omitempty"` 33 } 34 35 // WriteGlobalSkill writes a skill to the global skills directory 36 // (~/.shannon/skills/<slug>/SKILL.md). Same atomic write pattern 37 // as agents.WriteAgentSkill but different path root. 38 // 39 // Directory is keyed by Slug (the URL/on-disk identifier); Name is the 40 // frontmatter display label and may contain uppercase / CJK / spaces, 41 // neither of which is safe for a filesystem path. Falls back to Name 42 // for skills created before the Name/Slug split where Slug is unset. 43 func WriteGlobalSkill(shannonDir string, skill *Skill) error { 44 dirKey := skill.Slug 45 if dirKey == "" { 46 dirKey = skill.Name 47 } 48 dir := filepath.Join(shannonDir, "skills", dirKey) 49 if err := os.MkdirAll(dir, 0700); err != nil { 50 return err 51 } 52 53 fm := skillFrontmatter{ 54 Name: skill.Name, 55 Description: skill.Description, 56 License: skill.License, 57 Compatibility: skill.Compatibility, 58 Metadata: skill.Metadata, 59 StickyInstructions: skill.StickyInstructions, 60 Hidden: skill.Hidden, 61 } 62 if len(skill.AllowedTools) > 0 { 63 fm.AllowedTools = strings.Join(skill.AllowedTools, " ") 64 } 65 // Only marshal the sticky-snippet when the author explicitly pinned one 66 // (via StickySnippetOverride). The resolved StickySnippet may come from 67 // the heuristic extractor; serializing that would freeze a heuristic 68 // choice into the file and, on the next reload, skip Pass-1 entirely. 69 if override := strings.TrimSpace(skill.StickySnippetOverride); override != "" { 70 fm.StickySnippet = override 71 } 72 73 fmBytes, err := yaml.Marshal(fm) 74 if err != nil { 75 return fmt.Errorf("marshal frontmatter: %w", err) 76 } 77 78 var buf strings.Builder 79 buf.WriteString("---\n") 80 buf.Write(fmBytes) 81 buf.WriteString("---\n\n") 82 buf.WriteString(skill.Prompt) 83 if !strings.HasSuffix(skill.Prompt, "\n") { 84 buf.WriteString("\n") 85 } 86 87 if err := atomicWrite(filepath.Join(dir, "SKILL.md"), []byte(buf.String())); err != nil { 88 return err 89 } 90 return clearMarketplaceProvenance(dir) 91 } 92 93 // DeleteGlobalSkill removes a global skill directory. 94 func DeleteGlobalSkill(shannonDir, name string) error { 95 if err := ValidateSkillName(name); err != nil { 96 return err 97 } 98 return os.RemoveAll(filepath.Join(shannonDir, "skills", name)) 99 } 100 101 // DownloadableSkill describes a skill available for download from Anthropic's repo. 102 type DownloadableSkill struct { 103 Name string `json:"name"` 104 Description string `json:"description"` 105 Installed bool `json:"installed"` 106 } 107 108 // DownloadableSkills is the registry of skills available for on-demand installation. 109 // Includes both formerly-bundled skills (copied from embedded binary) and 110 // proprietary skills (fetched from Anthropic's repo). 111 var DownloadableSkills = []struct { 112 Name string 113 Description string 114 }{ 115 // Formerly bundled — installed from embedded binary 116 {"pdf-reader", "Analyze PDF files using file_read's built-in PDF rendering and vision"}, 117 {"algorithmic-art", "Create algorithmic art using p5.js with seeded randomness"}, 118 {"brand-guidelines", "Apply brand colors and typography to artifacts"}, 119 {"canvas-design", "Create visual art in PNG and PDF using design philosophy"}, 120 {"claude-api", "Build apps with the Claude API or Anthropic SDK"}, 121 {"doc-coauthoring", "Structured workflow for co-authoring documentation"}, 122 {"frontend-design", "Create production-grade frontend interfaces with high design quality"}, 123 {"heatmap-analyze", "End-to-end Ptengine heatmap analysis with AI-powered CRO insights"}, 124 {"internal-comms", "Write internal communications using company formats"}, 125 {"mcp-builder", "Create MCP servers for LLM-to-service integration"}, 126 {"skill-creator", "Create, modify, and measure skill performance"}, 127 {"slack-gif-creator", "Create animated GIFs optimized for Slack"}, 128 {"theme-factory", "Style artifacts with pre-set or custom themes"}, 129 {"web-artifacts-builder", "Create multi-component HTML artifacts with React and Tailwind"}, 130 {"webapp-testing", "Test local web applications using Playwright"}, 131 // Proprietary — installed from Anthropic's repo 132 {"docx", "Document creation, editing, and analysis with tracked changes and comments"}, 133 {"pdf", "PDF extraction, creation, merging, splitting, and form filling"}, 134 {"pptx", "Presentation creation, editing, and analysis"}, 135 {"xlsx", "Spreadsheet creation, editing, analysis with formulas and formatting"}, 136 } 137 138 // IsDownloadable returns true if the skill name is in the downloadable registry. 139 func IsDownloadable(name string) bool { 140 for _, s := range DownloadableSkills { 141 if s.Name == name { 142 return true 143 } 144 } 145 return false 146 } 147 148 // builtinSkills are skills that are auto-installed on startup. 149 // Unlike other bundled skills (which require manual installation), 150 // these are always available without user action. 151 var builtinSkills = []string{"kocoro"} 152 153 // EnsureBuiltinSkills auto-installs builtin skills from the embedded binary 154 // to the global skills directory. Idempotent — skips if already installed. 155 // Called at daemon/TUI startup alongside agents.EnsureBuiltins. 156 func EnsureBuiltinSkills(shannonDir string) error { 157 for _, name := range builtinSkills { 158 destDir := filepath.Join(shannonDir, "skills", name) 159 if _, err := os.Stat(filepath.Join(destDir, "SKILL.md")); err == nil { 160 continue // already installed 161 } 162 if err := installFromBundled(shannonDir, name, destDir); err != nil { 163 return fmt.Errorf("install builtin skill %s: %w", name, err) 164 } 165 } 166 return nil 167 } 168 169 // InstallSkill installs a downloadable skill to the global skills directory 170 // (~/.shannon/skills/<name>/). First checks if the skill is available in the 171 // embedded bundled directory (fast, no network). Falls back to fetching from 172 // Anthropic's skills repo via git sparse checkout. 173 func InstallSkill(shannonDir, name string) error { 174 if err := ValidateSkillName(name); err != nil { 175 return err 176 } 177 if !IsDownloadable(name) { 178 return fmt.Errorf("skill %q is not available for download", name) 179 } 180 181 destDir := filepath.Join(shannonDir, "skills", name) 182 if _, err := os.Stat(filepath.Join(destDir, "SKILL.md")); err == nil { 183 return fmt.Errorf("skill %q is already installed", name) 184 } 185 186 // Try bundled source first (no network required) 187 if err := installFromBundled(shannonDir, name, destDir); err == nil { 188 return nil 189 } 190 191 // Fall back to Anthropic's repo 192 return installFromRepo(shannonDir, name, destDir) 193 } 194 195 // installFromBundled copies a skill from the embedded bundled directory to global. 196 func installFromBundled(shannonDir, name, destDir string) error { 197 bundledSrc, err := BundledSkillSource(shannonDir) 198 if err != nil { 199 return err 200 } 201 srcDir := filepath.Join(bundledSrc.Dir, name) 202 skillMD := filepath.Join(srcDir, "SKILL.md") 203 if _, err := os.Stat(skillMD); err != nil { 204 return fmt.Errorf("skill %q not in bundled dir", name) 205 } 206 207 if err := os.MkdirAll(filepath.Dir(destDir), 0700); err != nil { 208 return err 209 } 210 211 // Copy directory contents (bundled dir is read-only, can't rename) 212 return copyDir(srcDir, destDir) 213 } 214 215 // installFromRepo downloads a skill from Anthropic's skills repo via git sparse checkout. 216 func installFromRepo(shannonDir, name, destDir string) error { 217 tmpDir, err := os.MkdirTemp(shannonDir, "skill-install-*") 218 if err != nil { 219 return fmt.Errorf("create temp dir: %w", err) 220 } 221 defer os.RemoveAll(tmpDir) 222 223 if err := runGit(tmpDir, "clone", "--depth=1", "--filter=blob:none", "--sparse", 224 "https://github.com/anthropics/skills.git", "."); err != nil { 225 return fmt.Errorf("git clone: %w", err) 226 } 227 if err := runGit(tmpDir, "sparse-checkout", "set", "skills/"+name); err != nil { 228 return fmt.Errorf("git sparse-checkout: %w", err) 229 } 230 231 srcDir := filepath.Join(tmpDir, "skills", name) 232 if _, err := os.Stat(filepath.Join(srcDir, "SKILL.md")); err != nil { 233 return fmt.Errorf("skill %q not found in Anthropic repo", name) 234 } 235 236 if err := os.MkdirAll(filepath.Dir(destDir), 0700); err != nil { 237 return err 238 } 239 return os.Rename(srcDir, destDir) 240 } 241 242 // copyDir recursively copies a directory tree. 243 func copyDir(src, dst string) error { 244 return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { 245 if err != nil { 246 return err 247 } 248 rel, err := filepath.Rel(src, path) 249 if err != nil { 250 return err 251 } 252 destPath := filepath.Join(dst, rel) 253 if d.IsDir() { 254 return os.MkdirAll(destPath, 0700) 255 } 256 content, err := os.ReadFile(path) 257 if err != nil { 258 return err 259 } 260 return os.WriteFile(destPath, content, 0644) 261 }) 262 } 263 264 // InstallSkillFromRepo is a backwards-compatible alias for InstallSkill. 265 // Deprecated: use InstallSkill instead. 266 func InstallSkillFromRepo(shannonDir, name string) error { 267 return InstallSkill(shannonDir, name) 268 } 269 270 func runGit(dir string, args ...string) error { 271 cmd := exec.Command("git", args...) 272 cmd.Dir = dir 273 out, err := cmd.CombinedOutput() 274 if err != nil { 275 return fmt.Errorf("%s: %s", err, strings.TrimSpace(string(out))) 276 } 277 return nil 278 } 279 280 // atomicWrite writes data to a temp file then renames to path. 281 func atomicWrite(path string, data []byte) error { 282 tmp := path + ".tmp" 283 if err := os.WriteFile(tmp, data, 0600); err != nil { 284 return err 285 } 286 return os.Rename(tmp, path) 287 }