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:
parent
7cbfa89a96
commit
57b3832e30
6 changed files with 591 additions and 84 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 <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() {
|
||||
fmt.Println("\nSkills commands:")
|
||||
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(" list-builtin List available builtin skills")
|
||||
fmt.Println(" remove <name> 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 <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(" 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: <repo> [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).
|
||||
|
|
|
|||
|
|
@ -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 <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()
|
||||
}
|
||||
skillsCmd()
|
||||
case "version", "--version", "-v":
|
||||
printVersion()
|
||||
default:
|
||||
|
|
|
|||
115
docs/skills-cli.md
Normal file
115
docs/skills-cli.md
Normal 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 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 <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 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/<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 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).
|
||||
|
|
@ -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,52 +27,309 @@ 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))
|
||||
|
||||
if _, err := os.Stat(skillDir); err == nil {
|
||||
return fmt.Errorf("skill '%s' already exists", 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}
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://raw.githubusercontent.com/%s/main/SKILL.md", repo)
|
||||
const defaultBranch = "main"
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
// 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
|
||||
}
|
||||
|
||||
// githubRepoResponse is the minimal GitHub API response for GET /repos/owner/repo.
|
||||
type githubRepoResponse struct {
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
var v githubRepoResponse
|
||||
if err := json.Unmarshal(body, &v); err != nil {
|
||||
return defaultBranch, nil
|
||||
}
|
||||
if v.DefaultBranch == "" {
|
||||
return defaultBranch, nil
|
||||
}
|
||||
return v.DefaultBranch, nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create skill directory: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
// 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)
|
||||
|
|
|
|||
97
pkg/skills/installer_test.go
Normal file
97
pkg/skills/installer_test.go
Normal 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")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue