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
|
└── 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.
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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
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"
|
"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 {
|
||||||
|
|
|
||||||
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