This commit is contained in:
OpenClaw-User 2026-03-14 16:57:05 +08:00
commit dd82d8e32a
4 changed files with 102 additions and 0 deletions

View file

@ -179,10 +179,16 @@ docker compose -f docker/docker-compose.yml --profile gateway up
# 3. Set your API keys # 3. Set your API keys
vim docker/data/config.json # Set provider API keys, bot tokens, etc. 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 # 4. Start
docker compose -f docker/docker-compose.yml --profile gateway up -d 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] > [!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`. > **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`.

View file

@ -22,6 +22,7 @@ func skillsListCmd(loader *skills.SkillsLoader) {
if len(allSkills) == 0 { if len(allSkills) == 0 {
fmt.Println("No skills installed.") fmt.Println("No skills installed.")
printSkillSearchRoots(loader)
return 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-name>/SKILL.md\n", roots[0])
}
func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error { func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error {
fmt.Printf("Installing skill from %s...\n", repo) fmt.Printf("Installing skill from %s...\n", repo)

View file

@ -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:")
}

View file

@ -12,6 +12,9 @@ services:
#extra_hosts: #extra_hosts:
# - "host.docker.internal:host-gateway" # - "host.docker.internal:host-gateway"
volumes: 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 - ./data:/root/.picoclaw
entrypoint: ["picoclaw", "agent"] entrypoint: ["picoclaw", "agent"]
stdin_open: true stdin_open: true
@ -31,6 +34,9 @@ services:
#extra_hosts: #extra_hosts:
# - "host.docker.internal:host-gateway" # - "host.docker.internal:host-gateway"
volumes: 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 - ./data:/root/.picoclaw
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
@ -49,4 +55,7 @@ services:
- "127.0.0.1:18800:18800" - "127.0.0.1:18800:18800"
- "127.0.0.1:18790:18790" - "127.0.0.1:18790:18790"
volumes: 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 - ./data:/root/.picoclaw