diff --git a/README.md b/README.md index 84d92115b..677ef2c2a 100644 --- a/README.md +++ b/README.md @@ -600,6 +600,8 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa └── USER.md # User preferences ``` +To manage skills (install, list, remove, and more), see the [Skills CLI Reference](docs/skills-cli.md). + ### 🔒 Security Sandbox PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace. diff --git a/cmd/picoclaw/cmd_skills.go b/cmd/picoclaw/cmd_skills.go index 0814494b3..9a71a944c 100644 --- a/cmd/picoclaw/cmd_skills.go +++ b/cmd/picoclaw/cmd_skills.go @@ -16,10 +16,63 @@ import ( "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 ") + 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 ") + return + } + skillsShowCmd(skillsLoader, os.Args[3]) + default: + fmt.Printf("Unknown skills command: %s\n", subcommand) + skillsHelp() + } +} + func skillsHelp() { fmt.Println("\nSkills commands:") fmt.Println(" list List installed skills") - fmt.Println(" install Install skill from GitHub") + fmt.Println(" install [subpath] Install skill from GitHub (repo: owner/repo or owner/repo@branch; subpath e.g. skills/kanban-ai)") + fmt.Println(" reinstall [subpath] Overwrite existing skill (same args as install)") fmt.Println(" install-builtin Install all builtin skills to workspace") fmt.Println(" list-builtin List available builtin skills") fmt.Println(" remove Remove installed skill") @@ -28,11 +81,13 @@ func skillsHelp() { fmt.Println() fmt.Println("Examples:") 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 list-builtin") - fmt.Println(" picoclaw skills remove weather") fmt.Println(" picoclaw skills install --registry clawhub github") + fmt.Println(" picoclaw skills remove weather") } 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 { - fmt.Println("Usage: picoclaw skills install ") + verb := "install" + if force { + verb = "reinstall" + } + fmt.Printf("Usage: picoclaw skills %s [subpath]\n", verb) fmt.Println(" picoclaw skills install --registry ") + 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 } @@ -73,19 +134,39 @@ func skillsInstallCmd(installer *skills.SkillInstaller, cfg *config.Config) { return } - // Default: install from GitHub (backward compatible). - repo := os.Args[3] - fmt.Printf("Installing skill from %s...\n", repo) + // GitHub path: [subpath] + spec := os.Args[3] + 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) 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) 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). diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 25ad701ca..7e3f11c99 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -14,7 +14,6 @@ import ( "runtime" "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/skills" ) var ( @@ -116,54 +115,7 @@ func main() { case "cron": cronCmd() case "skills": - 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) - // 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 ") - 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 ") - return - } - skillsShowCmd(skillsLoader, os.Args[3]) - default: - fmt.Printf("Unknown skills command: %s\n", subcommand) - skillsHelp() - } + skillsCmd() case "version", "--version", "-v": printVersion() default: diff --git a/docs/skills-cli.md b/docs/skills-cli.md new file mode 100644 index 000000000..d1dacdc59 --- /dev/null +++ b/docs/skills-cli.md @@ -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 [subpath]` or `install --registry ` | Install from GitHub or a registry | +| reinstall | `picoclaw skills reinstall [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 ` | Uninstall a skill by name | +| search | `picoclaw skills search` | Search list of installable skills (e.g. picoclaw-skills) | +| show | `picoclaw skills show ` | Show the content of an installed skill | + +--- + +## Install from GitHub (install / reinstall) + +### Repo format + +- **`owner/repo`** — Uses the GitHub API to resolve the repository’s 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 ` + +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 workspace’s `skills/` directory. +- **list-builtin** — Lists available built-in skill names and descriptions (read-only; does not install). + +--- + +## Other Subcommands + +- **remove** / **uninstall** — Deletes `workspace/skills/`. 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 skill’s `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). diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go index 3210509df..f2a2f04fe 100644 --- a/pkg/skills/installer.go +++ b/pkg/skills/installer.go @@ -8,11 +8,14 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" ) type SkillInstaller struct { workspace string + // baseURL for GitHub raw content; empty means https://raw.githubusercontent.com + baseURL string } type AvailableSkill struct { @@ -24,53 +27,310 @@ type AvailableSkill struct { } func NewSkillInstaller(workspace string) *SkillInstaller { - return &SkillInstaller{ - workspace: workspace, - } + return &SkillInstaller{workspace: workspace} } -func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) error { - skillDir := filepath.Join(si.workspace, "skills", filepath.Base(repo)) +// NewSkillInstallerWithBase returns an installer that uses baseURL for raw content (e.g. httptest.Server.URL). Used for testing. +func NewSkillInstallerWithBase(workspace, baseURL string) *SkillInstaller { + return &SkillInstaller{workspace: workspace, baseURL: baseURL} +} - if _, err := os.Stat(skillDir); err == nil { - return fmt.Errorf("skill '%s' already exists", filepath.Base(repo)) +const defaultBranch = "main" + +// 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} - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) +// githubTreeEntry is one entry from GET /repos/owner/repo/git/trees/branch?recursive=1. +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 { - 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) if err != nil { - return fmt.Errorf("failed to fetch skill: %w", err) + return defaultBranch, nil } defer resp.Body.Close() - if resp.StatusCode != 200 { - return fmt.Errorf("failed to fetch skill: HTTP %d", resp.StatusCode) + return defaultBranch, nil } - body, err := io.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("failed to read response: %w", err) + return defaultBranch, nil } - - if err := os.MkdirAll(skillDir, 0o755); err != nil { - return fmt.Errorf("failed to create skill directory: %w", err) + var v githubRepoResponse + if err := json.Unmarshal(body, &v); err != nil { + return defaultBranch, nil } - - skillPath := filepath.Join(skillDir, "SKILL.md") - if err := os.WriteFile(skillPath, body, 0o644); err != nil { - return fmt.Errorf("failed to write skill file: %w", err) + if v.DefaultBranch == "" { + return defaultBranch, nil } + 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 + } + 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 { skillDir := filepath.Join(si.workspace, "skills", skillName) diff --git a/pkg/skills/installer_test.go b/pkg/skills/installer_test.go new file mode 100644 index 000000000..f129c975d --- /dev/null +++ b/pkg/skills/installer_test.go @@ -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") +}