skills: install/reinstall CLI and refactor into skillsCmd

- Add ParseInstallSpec, InstallFromGitHubEx, fetchTree, fetchDefaultBranch,
  validateSubpath; support repo@branch and optional subpath
- Add reinstall subcommand (force overwrite); install errors with hint when
  skill already exists
- Production install uses GitHub Trees API for full directory; baseURL mode
  for tests uses single SKILL.md
- Keep InstallFromGitHub(spec) as wrapper for backward compatibility
- Add installer_test.go: ParseInstallSpec, reinstall overwrite, already-exists
- Refactor skills CLI from main.go into skillsCmd() in cmd_skills.go
- Add docs/skills-cli.md

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
YS Liu 2026-02-24 16:13:45 +08:00
parent 7cbfa89a96
commit 57b3832e30
6 changed files with 591 additions and 84 deletions

View file

@ -600,6 +600,8 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
└── USER.md # User preferences └── USER.md # User preferences
``` ```
To manage skills (install, list, remove, and more), see the [Skills CLI Reference](docs/skills-cli.md).
### 🔒 Security Sandbox ### 🔒 Security Sandbox
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace. PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.

View file

@ -16,10 +16,63 @@ import (
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
) )
func skillsCmd() {
if len(os.Args) < 3 {
skillsHelp()
return
}
subcommand := os.Args[2]
cfg, err := loadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
os.Exit(1)
}
workspace := cfg.WorkspacePath()
installer := skills.NewSkillInstaller(workspace)
globalDir := filepath.Dir(getConfigPath())
globalSkillsDir := filepath.Join(globalDir, "skills")
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir)
switch subcommand {
case "list":
skillsListCmd(skillsLoader)
case "install":
skillsInstallCmd(installer, cfg, false)
case "reinstall":
skillsInstallCmd(installer, cfg, true)
case "remove", "uninstall":
if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw skills remove <skill-name>")
return
}
skillsRemoveCmd(installer, os.Args[3])
case "install-builtin":
skillsInstallBuiltinCmd(workspace)
case "list-builtin":
skillsListBuiltinCmd()
case "search":
skillsSearchCmd(installer)
case "show":
if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw skills show <skill-name>")
return
}
skillsShowCmd(skillsLoader, os.Args[3])
default:
fmt.Printf("Unknown skills command: %s\n", subcommand)
skillsHelp()
}
}
func skillsHelp() { func skillsHelp() {
fmt.Println("\nSkills commands:") fmt.Println("\nSkills commands:")
fmt.Println(" list List installed skills") fmt.Println(" list List installed skills")
fmt.Println(" install <repo> Install skill from GitHub") fmt.Println(" install <repo> [subpath] Install skill from GitHub (repo: owner/repo or owner/repo@branch; subpath e.g. skills/kanban-ai)")
fmt.Println(" reinstall <repo> [subpath] Overwrite existing skill (same args as install)")
fmt.Println(" install-builtin Install all builtin skills to workspace") fmt.Println(" install-builtin Install all builtin skills to workspace")
fmt.Println(" list-builtin List available builtin skills") fmt.Println(" list-builtin List available builtin skills")
fmt.Println(" remove <name> Remove installed skill") fmt.Println(" remove <name> Remove installed skill")
@ -28,11 +81,13 @@ func skillsHelp() {
fmt.Println() fmt.Println()
fmt.Println("Examples:") fmt.Println("Examples:")
fmt.Println(" picoclaw skills list") fmt.Println(" picoclaw skills list")
fmt.Println(" picoclaw skills install sipeed/picoclaw-skills/weather") fmt.Println(" picoclaw skills install sipeed/picoclaw-skills")
fmt.Println(" picoclaw skills install sipeed/picoclaw-skills@test")
fmt.Println(" picoclaw skills install sipeed/picoclaw-skills weather")
fmt.Println(" picoclaw skills reinstall sipeed/picoclaw-skills k8s-report")
fmt.Println(" picoclaw skills install-builtin") fmt.Println(" picoclaw skills install-builtin")
fmt.Println(" picoclaw skills list-builtin")
fmt.Println(" picoclaw skills remove weather")
fmt.Println(" picoclaw skills install --registry clawhub github") fmt.Println(" picoclaw skills install --registry clawhub github")
fmt.Println(" picoclaw skills remove weather")
} }
func skillsListCmd(loader *skills.SkillsLoader) { func skillsListCmd(loader *skills.SkillsLoader) {
@ -53,10 +108,16 @@ func skillsListCmd(loader *skills.SkillsLoader) {
} }
} }
func skillsInstallCmd(installer *skills.SkillInstaller, cfg *config.Config) { func skillsInstallCmd(installer *skills.SkillInstaller, cfg *config.Config, force bool) {
if len(os.Args) < 4 { if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw skills install <github-repo>") verb := "install"
if force {
verb = "reinstall"
}
fmt.Printf("Usage: picoclaw skills %s <repo> [subpath]\n", verb)
fmt.Println(" picoclaw skills install --registry <name> <slug>") fmt.Println(" picoclaw skills install --registry <name> <slug>")
fmt.Println(" repo: owner/repo or owner/repo@branch (branch defaults to main)")
fmt.Println(" subpath: optional path in repo (e.g. weather or skills/kanban-ai)")
return return
} }
@ -73,19 +134,39 @@ func skillsInstallCmd(installer *skills.SkillInstaller, cfg *config.Config) {
return return
} }
// Default: install from GitHub (backward compatible). // GitHub path: <repo> [subpath]
repo := os.Args[3] spec := os.Args[3]
fmt.Printf("Installing skill from %s...\n", repo) repo, branch, err := skills.ParseInstallSpec(spec)
if err != nil {
fmt.Printf("\u2717 Invalid install spec: %v\n", err)
os.Exit(1)
}
var subpath string
if len(os.Args) >= 5 {
subpath = strings.TrimSpace(os.Args[4])
}
display := spec
if subpath != "" {
display = spec + " " + subpath
}
if force {
fmt.Printf("Reinstalling skill from %s...\n", display)
} else {
fmt.Printf("Installing skill from %s...\n", display)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
if err := installer.InstallFromGitHub(ctx, repo); err != nil { skillName, err := installer.InstallFromGitHubEx(ctx, repo, branch, subpath, force)
if err != nil {
fmt.Printf("\u2717 Failed to install skill: %v\n", err) fmt.Printf("\u2717 Failed to install skill: %v\n", err)
os.Exit(1) os.Exit(1)
} }
fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo)) fmt.Printf("\u2713 Skill '%s' installed successfully!\n", skillName)
} }
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub). // skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).

View file

@ -14,7 +14,6 @@ import (
"runtime" "runtime"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/skills"
) )
var ( var (
@ -116,54 +115,7 @@ func main() {
case "cron": case "cron":
cronCmd() cronCmd()
case "skills": case "skills":
if len(os.Args) < 3 { skillsCmd()
skillsHelp()
return
}
subcommand := os.Args[2]
cfg, err := loadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
os.Exit(1)
}
workspace := cfg.WorkspacePath()
installer := skills.NewSkillInstaller(workspace)
// get global config directory and builtin skills directory
globalDir := filepath.Dir(getConfigPath())
globalSkillsDir := filepath.Join(globalDir, "skills")
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir)
switch subcommand {
case "list":
skillsListCmd(skillsLoader)
case "install":
skillsInstallCmd(installer, cfg)
case "remove", "uninstall":
if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw skills remove <skill-name>")
return
}
skillsRemoveCmd(installer, os.Args[3])
case "install-builtin":
skillsInstallBuiltinCmd(workspace)
case "list-builtin":
skillsListBuiltinCmd()
case "search":
skillsSearchCmd(installer)
case "show":
if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw skills show <skill-name>")
return
}
skillsShowCmd(skillsLoader, os.Args[3])
default:
fmt.Printf("Unknown skills command: %s\n", subcommand)
skillsHelp()
}
case "version", "--version", "-v": case "version", "--version", "-v":
printVersion() printVersion()
default: default:

115
docs/skills-cli.md Normal file
View file

@ -0,0 +1,115 @@
# Skills CLI Reference
The `picoclaw skills` command manages local skills: install, list, remove, search, and view. Installed skills are written under your **workspace** at `skills/{skillName}/`. At runtime, the [SkillsLoader](pkg/skills/loader.go) discovers skills by scanning these directories; a directory is treated as a valid skill only if it contains a `SKILL.md` file.
Some features (e.g. `reinstall`, `repo@branch`, optional `subpath`) may require a recent PicoClaw version. If a command or option is not recognized, upgrade to the latest release.
---
## Subcommands Overview
| Subcommand | Usage | Description |
|------------|--------|-------------|
| list | `picoclaw skills list` | List installed skills |
| install | `picoclaw skills install <repo> [subpath]` or `install --registry <name> <slug>` | Install from GitHub or a registry |
| reinstall | `picoclaw skills reinstall <repo> [subpath]` | Overwrite install (remove then install) |
| install-builtin | `picoclaw skills install-builtin` | Copy built-in skills into the workspace |
| list-builtin | `picoclaw skills list-builtin` | List available built-in skills |
| remove | `picoclaw skills remove <name>` | Uninstall a skill by name |
| search | `picoclaw skills search` | Search list of installable skills (e.g. picoclaw-skills) |
| show | `picoclaw skills show <name>` | Show the content of an installed skill |
---
## Install from GitHub (install / reinstall)
### Repo format
- **`owner/repo`** — Uses the GitHub API to resolve the repositorys default branch; if that fails, falls back to `main`.
- **`owner/repo@branch`** — Use a specific branch (e.g. `owner/repo@v1`).
### Optional subpath
For a monorepo, you can pass a **subpath** (e.g. `skills/k8s-report`). The **skill name** is the last segment of `subpath` if given; otherwise it is the repo name.
### Behavior
- **install** — If `workspace/skills/{skillName}` already exists, the command fails and suggests using `reinstall`.
- **reinstall** — Removes the existing skill directory, then performs the same download and write as `install` (overwrite update).
### What gets installed (production)
The installer uses the GitHub Trees API to list all blobs under the given branch (and optionally under `subpath`). The tree must include `SKILL.md` (at the repo root or under `subpath`). Files are downloaded from `https://raw.githubusercontent.com/{repo}/{branch}/{path}` and written under `workspace/skills/{skillName}/` with the correct relative paths (subpath prefix stripped).
### Examples
```bash
# Install from repo root (default branch)
picoclaw skills install sipeed/picoclaw-skills
# Install from a subpath (skill name = k8s-report)
picoclaw skills install sipeed/picoclaw-skills k8s-report
# Install from a specific branch
picoclaw skills install owner/repo@v1
# Overwrite an existing install
picoclaw skills reinstall sipeed/picoclaw-skills k8s-report
```
---
## Install from Registry
Use a configured registry (e.g. ClawHub) to install by slug.
**Usage:** `picoclaw skills install --registry <registry_name> <slug>`
The registry must be enabled under `tools.skills` in your config. See [Tools Configuration Skills Tool](#related-configuration) for registry settings. If the skill is already installed, the command fails. Currently only the GitHub path supports overwriting via `reinstall`; registry install does not have a reinstall/force option yet.
**Example:**
```bash
picoclaw skills install --registry clawhub github
```
---
## Built-in Skills (install-builtin / list-builtin)
- **install-builtin** — Copies a predefined set of built-in skills from the PicoClaw install directory into the current workspaces `skills/` directory.
- **list-builtin** — Lists available built-in skill names and descriptions (read-only; does not install).
---
## Other Subcommands
- **remove** / **uninstall** — Deletes `workspace/skills/<name>`. Fails if the skill is not installed.
- **search** — Fetches the remote skills list (e.g. from sipeed/picoclaw-skills `skills.json`) and prints name, description, repository, author, and tags.
- **show** — Reads and prints the installed skills `SKILL.md` content (as used by the SkillsLoader).
---
## Install Directory and Discovery
- **Install directory:** `{workspace}/skills/{skillName}/`
- For GitHub: `skillName` is the last segment of `subpath` if provided, otherwise the repo name.
- For registry: `skillName` is the install slug.
- **Discovery:** The SkillsLoader scans workspace, global, and built-in skills directories. A directory is considered a valid skill only if it contains `SKILL.md`.
---
## Errors and Tips
| Situation | What to do |
|-----------|------------|
| Skill already exists | Use `reinstall` to overwrite (GitHub path only). |
| No `SKILL.md` (GitHub install) | Ensure the repo (and subpath, if used) contains a `SKILL.md` file. |
| Registry not found or disabled | Check `tools.skills.registries` in your config and see [Tools Configuration](tools_configuration.md#skills-tool). |
| Skill not found for `remove` / `show` | Confirm the skill name (e.g. from `picoclaw skills list`) and that it is installed in the current workspace. |
---
## Related Configuration
Registry enablement and API paths (e.g. ClawHub base URL, skills path, download path) are configured under the Skills Tool. See [Tools Configuration Skills Tool](tools_configuration.md#skills-tool).

View file

@ -8,11 +8,14 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
) )
type SkillInstaller struct { type SkillInstaller struct {
workspace string workspace string
// baseURL for GitHub raw content; empty means https://raw.githubusercontent.com
baseURL string
} }
type AvailableSkill struct { type AvailableSkill struct {
@ -24,51 +27,308 @@ type AvailableSkill struct {
} }
func NewSkillInstaller(workspace string) *SkillInstaller { func NewSkillInstaller(workspace string) *SkillInstaller {
return &SkillInstaller{ return &SkillInstaller{workspace: workspace}
workspace: workspace,
}
} }
func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) error { // NewSkillInstallerWithBase returns an installer that uses baseURL for raw content (e.g. httptest.Server.URL). Used for testing.
skillDir := filepath.Join(si.workspace, "skills", filepath.Base(repo)) func NewSkillInstallerWithBase(workspace, baseURL string) *SkillInstaller {
return &SkillInstaller{workspace: workspace, baseURL: baseURL}
}
if _, err := os.Stat(skillDir); err == nil { const defaultBranch = "main"
return fmt.Errorf("skill '%s' already exists", filepath.Base(repo))
// ParseInstallSpec parses "owner/repo" or "owner/repo@branch" into repo and branch.
// When @ is absent, branch is "" meaning "use repo default branch" (fetched from GitHub API).
// Repo must contain at least one "/".
func ParseInstallSpec(spec string) (repo, branch string, err error) {
spec = strings.TrimSpace(spec)
if spec == "" {
return "", "", fmt.Errorf("empty install spec")
} }
idx := strings.LastIndex(spec, "@")
if idx >= 0 {
repo = strings.TrimSpace(spec[:idx])
branch = strings.TrimSpace(spec[idx+1:])
if branch == "" {
return "", "", fmt.Errorf("branch name after @ is empty")
}
} else {
repo = spec
branch = "" // empty = resolve default branch via API
}
if repo == "" || !strings.Contains(repo, "/") {
return "", "", fmt.Errorf("repo must be owner/repo (got %q)", spec)
}
return repo, branch, nil
}
url := fmt.Sprintf("https://raw.githubusercontent.com/%s/main/SKILL.md", repo) // githubRepoResponse is the minimal GitHub API response for GET /repos/owner/repo.
type githubRepoResponse struct {
DefaultBranch string `json:"default_branch"`
}
client := &http.Client{Timeout: 15 * time.Second} // githubTreeEntry is one entry from GET /repos/owner/repo/git/trees/branch?recursive=1.
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) type githubTreeEntry struct {
Path string `json:"path"`
Mode string `json:"mode"`
Type string `json:"type"`
SHA string `json:"sha"`
}
// githubTreeResponse is the response for Git Trees API.
type githubTreeResponse struct {
SHA string `json:"sha"`
Tree []githubTreeEntry `json:"tree"`
Truncated bool `json:"truncated"`
}
// fetchDefaultBranch returns the default branch for repo "owner/repo" via GitHub API.
// On failure (network, 404, rate limit) returns defaultBranch "main" and nil error so install can still be tried.
func fetchDefaultBranch(ctx context.Context, repo string) (string, error) {
apiURL := "https://api.github.com/repos/" + repo
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil { if err != nil {
return fmt.Errorf("failed to create request: %w", err) return defaultBranch, nil
} }
req.Header.Set("Accept", "application/vnd.github.v3+json")
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("failed to fetch skill: %w", err) return defaultBranch, nil
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return fmt.Errorf("failed to fetch skill: HTTP %d", resp.StatusCode) return defaultBranch, nil
} }
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return fmt.Errorf("failed to read response: %w", err) return defaultBranch, nil
} }
var v githubRepoResponse
if err := os.MkdirAll(skillDir, 0o755); err != nil { if err := json.Unmarshal(body, &v); err != nil {
return fmt.Errorf("failed to create skill directory: %w", err) return defaultBranch, nil
} }
if v.DefaultBranch == "" {
skillPath := filepath.Join(skillDir, "SKILL.md") return defaultBranch, nil
if err := os.WriteFile(skillPath, body, 0o644); err != nil {
return fmt.Errorf("failed to write skill file: %w", err)
} }
return v.DefaultBranch, nil
}
// fetchTree returns blob paths under the given path prefix. prefix "" means repo root (all files).
// Branch must be resolved (e.g. main or from fetchDefaultBranch).
func fetchTree(ctx context.Context, repo, branch, pathPrefix string) ([]string, error) {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/git/trees/%s?recursive=1", repo, branch)
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github.v3+json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("failed to fetch tree: HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var tr githubTreeResponse
if err := json.Unmarshal(body, &tr); err != nil {
return nil, err
}
if tr.Truncated {
return nil, fmt.Errorf("repository tree is too large (truncated)")
}
var paths []string
for _, e := range tr.Tree {
if e.Type != "blob" {
continue
}
if pathPrefix == "" {
paths = append(paths, e.Path)
continue
}
prefix := pathPrefix + "/"
if e.Path == pathPrefix || strings.HasPrefix(e.Path, prefix) {
paths = append(paths, e.Path)
}
}
return paths, nil
}
// validateSubpath ensures subpath is safe (no ".." or absolute path).
// Multi-segment paths like "skills/kanban-ai" are allowed.
func validateSubpath(subpath string) error {
subpath = strings.TrimSpace(subpath)
if subpath == "" {
return nil return nil
}
if strings.Contains(subpath, "..") {
return fmt.Errorf("subpath must not contain ..")
}
cleaned := filepath.Clean(subpath)
if cleaned != subpath || filepath.IsAbs(cleaned) || strings.HasPrefix(cleaned, "..") {
return fmt.Errorf("invalid subpath %q", subpath)
}
return nil
}
// InstallFromGitHubEx installs a skill from GitHub. repo is "owner/repo". When branch is empty,
// the repo's default branch is fetched from GitHub API; subpath is optional (e.g. "skills/kanban-ai").
// If force is true, an existing skill directory is removed before install. Returns the installed skill name.
func (si *SkillInstaller) InstallFromGitHubEx(ctx context.Context, repo, branch, subpath string, force bool) (skillName string, err error) {
repo = strings.TrimSpace(repo)
branch = strings.TrimSpace(branch)
if branch == "" {
if si.baseURL == "" {
branch, _ = fetchDefaultBranch(ctx, repo)
}
if branch == "" {
branch = defaultBranch
}
}
if err := validateSubpath(subpath); err != nil {
return "", err
}
if subpath != "" {
skillName = filepath.Base(filepath.Clean(subpath))
} else {
skillName = filepath.Base(repo)
}
skillDir := filepath.Join(si.workspace, "skills", skillName)
if _, err := os.Stat(skillDir); err == nil {
if !force {
return "", fmt.Errorf("skill '%s' already exists (use reinstall to overwrite)", skillName)
}
if err := os.RemoveAll(skillDir); err != nil {
return "", fmt.Errorf("failed to remove existing skill: %w", err)
}
}
base := "https://raw.githubusercontent.com/" + repo + "/" + branch
if si.baseURL != "" {
base = strings.TrimSuffix(si.baseURL, "/") + "/" + repo + "/" + branch
}
// Test mode (baseURL set): single-file install (SKILL.md only) for backward-compatible tests.
if si.baseURL != "" {
return si.installSingleFile(ctx, base, repo, branch, subpath, skillDir, skillName, force)
}
// Production: install entire directory via GitHub Trees API.
paths, err := fetchTree(ctx, repo, branch, subpath)
if err != nil {
return "", err
}
skillMD := "SKILL.md"
if subpath != "" {
skillMD = subpath + "/SKILL.md"
}
hasSKILL := false
for _, p := range paths {
if p == skillMD {
hasSKILL = true
break
}
}
if !hasSKILL {
return "", fmt.Errorf("SKILL.md not found at %s (check branch and path)", base+"/"+skillMD)
}
if err := os.MkdirAll(skillDir, 0755); err != nil {
return "", fmt.Errorf("failed to create skill directory: %w", err)
}
client := &http.Client{Timeout: 15 * time.Second}
for _, p := range paths {
rawURL := base + "/" + p
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request for %s: %w", p, err)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch %s: %w", p, err)
}
if resp.StatusCode != 200 {
resp.Body.Close()
return "", fmt.Errorf("failed to fetch %s: HTTP %d", p, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return "", fmt.Errorf("failed to read %s: %w", p, err)
}
localPath := p
if subpath != "" {
localPath = p[len(subpath)+1:]
}
dest := filepath.Join(skillDir, filepath.FromSlash(localPath))
if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil {
return "", fmt.Errorf("failed to create directory for %s: %w", p, err)
}
if err := os.WriteFile(dest, body, 0644); err != nil {
return "", fmt.Errorf("failed to write %s: %w", p, err)
}
}
return skillName, nil
}
// installSingleFile installs only SKILL.md (used when baseURL is set, e.g. tests).
func (si *SkillInstaller) installSingleFile(ctx context.Context, base, repo, branch, subpath, skillDir, skillName string, force bool) (string, error) {
var rawURL string
if subpath != "" {
rawURL = base + "/" + subpath + "/SKILL.md"
} else {
rawURL = base + "/SKILL.md"
}
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to fetch skill: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
if resp.StatusCode == 404 {
return "", fmt.Errorf("SKILL.md not found at %s (check branch and path)", rawURL)
}
return "", fmt.Errorf("failed to fetch skill: HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
if err := os.MkdirAll(skillDir, 0755); err != nil {
return "", fmt.Errorf("failed to create skill directory: %w", err)
}
skillPath := filepath.Join(skillDir, "SKILL.md")
if err := os.WriteFile(skillPath, body, 0644); err != nil {
return "", fmt.Errorf("failed to write skill file: %w", err)
}
return skillName, nil
}
// InstallFromGitHub installs a skill from GitHub using a single spec string.
// Spec can be "owner/repo" or "owner/repo@branch". Branch defaults to "main".
// For monorepo subpath, use InstallFromGitHubEx.
func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, spec string) error {
repo, branch, err := ParseInstallSpec(spec)
if err != nil {
return err
}
_, err = si.InstallFromGitHubEx(ctx, repo, branch, "", false)
return err
} }
func (si *SkillInstaller) Uninstall(skillName string) error { func (si *SkillInstaller) Uninstall(skillName string) error {

View file

@ -0,0 +1,97 @@
package skills
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseInstallSpec(t *testing.T) {
tests := []struct {
spec string
wantRepo string
wantBranch string
wantErr bool
}{
{"owner/repo", "owner/repo", "", false},
{" owner/repo ", "owner/repo", "", false},
{"owner/repo@main", "owner/repo", "main", false},
{"owner/repo@test", "owner/repo", "test", false},
{"owner/repo@", "", "", true},
{"", "", "", true},
{"n slash", "", "", true},
{"a/b@branch", "a/b", "branch", false},
}
for _, tt := range tests {
t.Run(tt.spec, func(t *testing.T) {
repo, branch, err := ParseInstallSpec(tt.spec)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantRepo, repo)
assert.Equal(t, tt.wantBranch, branch)
})
}
}
func TestInstallFromGitHubEx_reinstall_overwrites(t *testing.T) {
content1 := []byte("# Skill v1")
content2 := []byte("# Skill v2")
var reqCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqCount++
w.WriteHeader(200)
if reqCount == 1 {
_, _ = w.Write(content1)
} else {
_, _ = w.Write(content2)
}
}))
defer server.Close()
dir := t.TempDir()
si := NewSkillInstallerWithBase(dir, server.URL)
ctx := context.Background()
skillName, err := si.InstallFromGitHubEx(ctx, "owner/repo", "main", "", false)
require.NoError(t, err)
assert.Equal(t, "repo", skillName)
path := filepath.Join(dir, "skills", "repo", "SKILL.md")
data, _ := os.ReadFile(path)
assert.Equal(t, content1, data)
// Reinstall (force overwrite)
skillName2, err := si.InstallFromGitHubEx(ctx, "owner/repo", "main", "", true)
require.NoError(t, err)
assert.Equal(t, "repo", skillName2)
data2, _ := os.ReadFile(path)
assert.Equal(t, content2, data2)
}
func TestInstallFromGitHubEx_already_exists(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
_, _ = w.Write([]byte("# Skill"))
}))
defer server.Close()
dir := t.TempDir()
si := NewSkillInstallerWithBase(dir, server.URL)
ctx := context.Background()
_, err := si.InstallFromGitHubEx(ctx, "sipeed/picoclaw-skills", "main", "", false)
require.NoError(t, err)
_, err = si.InstallFromGitHubEx(ctx, "sipeed/picoclaw-skills", "main", "", false)
require.Error(t, err)
assert.Contains(t, err.Error(), "already exists")
assert.Contains(t, err.Error(), "reinstall")
}