/ internal / skills / api.go
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  }