Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
14 changes: 14 additions & 0 deletions cmd/picoclaw/internal/skills/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ func skillsListCmd(loader *skills.SkillsLoader) {

if len(allSkills) == 0 {
fmt.Println("No skills installed.")
printSkillSearchRoots(loader)
return
}

Expand All @@ -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-name>/SKILL.md\n", roots[0])
}

func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error {
fmt.Printf("Installing skill from %s...\n", repo)

Expand Down
73 changes: 73 additions & 0 deletions cmd/picoclaw/internal/skills/helpers_test.go
Original file line number Diff line number Diff line change
@@ -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-name>", "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:")
}
9 changes: 9 additions & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

# ─────────────────────────────────────────────
Expand All @@ -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