diff --git a/README.md b/README.md index 58cdfe323a..2eaa3f77f9 100644 --- a/README.md +++ b/README.md @@ -179,10 +179,16 @@ docker compose -f docker/docker-compose.yml --profile gateway up # 3. Set your API keys vim docker/data/config.json # Set provider API keys, bot tokens, etc. +# Optional: add custom skills for this compose setup +mkdir -p docker/data/workspace/skills/my-skill +# put your SKILL.md at docker/data/workspace/skills/my-skill/SKILL.md + # 4. Start docker compose -f docker/docker-compose.yml --profile gateway up -d ``` +In this compose setup, PicoClaw reads the workspace from `docker/data/workspace` on the host, so local skills must live under `docker/data/workspace/skills`. The repo checkout's `workspace/skills` directory is not mounted into the container. + > [!TIP] > **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`. diff --git a/cmd/picoclaw/internal/skills/helpers.go b/cmd/picoclaw/internal/skills/helpers.go index a59a2013a2..012606bdb6 100644 --- a/cmd/picoclaw/internal/skills/helpers.go +++ b/cmd/picoclaw/internal/skills/helpers.go @@ -22,6 +22,7 @@ func skillsListCmd(loader *skills.SkillsLoader) { if len(allSkills) == 0 { fmt.Println("No skills installed.") + printSkillSearchRoots(loader) return } @@ -35,6 +36,19 @@ func skillsListCmd(loader *skills.SkillsLoader) { } } +func printSkillSearchRoots(loader *skills.SkillsLoader) { + roots := loader.SkillRoots() + if len(roots) == 0 { + return + } + + fmt.Println("Scanned skill roots:") + for i, root := range roots { + fmt.Printf(" %d. %s\n", i+1, root) + } + fmt.Printf("Install local skills under %s//SKILL.md\n", roots[0]) +} + func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error { fmt.Printf("Installing skill from %s...\n", repo) diff --git a/cmd/picoclaw/internal/skills/helpers_test.go b/cmd/picoclaw/internal/skills/helpers_test.go new file mode 100644 index 0000000000..5147623ddf --- /dev/null +++ b/cmd/picoclaw/internal/skills/helpers_test.go @@ -0,0 +1,73 @@ +package skills + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + skillspkg "github.com/sipeed/picoclaw/pkg/skills" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + oldStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stdout = w + + fn() + + require.NoError(t, w.Close()) + os.Stdout = oldStdout + + var buf bytes.Buffer + _, err = io.Copy(&buf, r) + require.NoError(t, err) + require.NoError(t, r.Close()) + return buf.String() +} + +func TestSkillsListCmd_EmptyOutputIncludesSearchRoots(t *testing.T) { + tmp := t.TempDir() + workspace := filepath.Join(tmp, "workspace") + global := filepath.Join(tmp, "global") + builtin := filepath.Join(tmp, "builtin") + loader := skillspkg.NewSkillsLoader(workspace, global, builtin) + + output := captureStdout(t, func() { + skillsListCmd(loader) + }) + + assert.Contains(t, output, "No skills installed.") + assert.Contains(t, output, "Scanned skill roots:") + assert.Contains(t, output, filepath.Join(workspace, "skills")) + assert.Contains(t, output, global) + assert.Contains(t, output, builtin) + assert.Contains(t, output, filepath.Join(workspace, "skills", "", "SKILL.md")) +} + +func TestSkillsListCmd_ShowsInstalledSkills(t *testing.T) { + tmp := t.TempDir() + workspace := filepath.Join(tmp, "workspace") + skillDir := filepath.Join(workspace, "skills", "weather") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + + content := "---\nname: weather\ndescription: Weather lookup\n---\n\n# Weather\n" + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + + loader := skillspkg.NewSkillsLoader(workspace, filepath.Join(tmp, "global"), filepath.Join(tmp, "builtin")) + output := captureStdout(t, func() { + skillsListCmd(loader) + }) + + assert.Contains(t, output, "Installed Skills:") + assert.Contains(t, output, "weather (workspace)") + assert.Contains(t, output, "Weather lookup") + assert.NotContains(t, output, "Scanned skill roots:") +} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index b26cf4199b..b4a48b9c11 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -12,6 +12,9 @@ services: #extra_hosts: # - "host.docker.internal:host-gateway" volumes: + # Persistent PicoClaw home. Custom local skills should be placed under + # ./data/workspace/skills on the host so they appear at + # /root/.picoclaw/workspace/skills inside the container. - ./data:/root/.picoclaw entrypoint: ["picoclaw", "agent"] stdin_open: true @@ -31,6 +34,9 @@ services: #extra_hosts: # - "host.docker.internal:host-gateway" volumes: + # Persistent PicoClaw home. Custom local skills should be placed under + # ./data/workspace/skills on the host so they appear at + # /root/.picoclaw/workspace/skills inside the container. - ./data:/root/.picoclaw # ───────────────────────────────────────────── @@ -49,4 +55,7 @@ services: - "127.0.0.1:18800:18800" - "127.0.0.1:18790:18790" volumes: + # Persistent PicoClaw home. Custom local skills should be placed under + # ./data/workspace/skills on the host so they appear at + # /root/.picoclaw/workspace/skills inside the container. - ./data:/root/.picoclaw