Skip to content
Closed
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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ LDFLAGS=-ldflags "-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit

# Go variables
GO?=CGO_ENABLED=0 go
GOFLAGS?=-v -tags stdjson
GOFLAGS?=-v -tags stdjson -buildvcs=false

# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).
#
Expand Down
8 changes: 8 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 Expand Up @@ -808,6 +814,8 @@ For advanced/test setups, you can override the builtin skills root with:
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
```

For a concrete example of a skill that executes a local script, see [workspace/skills/python-script/SKILL.md](workspace/skills/python-script/SKILL.md). It shows the recommended pattern for calling an existing `python3` script through the `exec` tool and returning structured output.

### Unified Command Execution Policy

- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`.
Expand Down
121 changes: 110 additions & 11 deletions cmd/picoclaw/internal/onboard/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,63 @@ import (

"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/credential"
"golang.org/x/term"
)

func onboard() {
configPath := internal.GetConfigPath()

configExists := false
if _, err := os.Stat(configPath); err == nil {
fmt.Printf("Config already exists at %s\n", configPath)
fmt.Print("Overwrite? (y/n): ")
var response string
fmt.Scanln(&response)
if response != "y" {
fmt.Println("Aborted.")
return
configExists = true
// Only ask for confirmation when *both* config and SSH key already exist,
// indicating a full re-onboard that would reset the config to defaults.
sshKeyPath, _ := credential.DefaultSSHKeyPath()
if _, err := os.Stat(sshKeyPath); err == nil {
// Both exist — confirm a full reset.
fmt.Printf("Config already exists at %s\n", configPath)
fmt.Print("Overwrite config with defaults? (y/n): ")
var response string
fmt.Scanln(&response)
if response != "y" {
fmt.Println("Aborted.")
return
}
configExists = false // user agreed to reset; treat as fresh
}
// Config exists but SSH key is missing — keep existing config, only add SSH key.
}

fmt.Println("\nSet up credential encryption")
fmt.Println("-----------------------------")
passphrase, err := promptPassphrase()
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
// Expose the passphrase to credential.PassphraseProvider (which calls
// os.Getenv by default) so that SaveConfig can encrypt api_keys.
// This process is a one-shot CLI tool; the env var is never exposed outside
// the current process and disappears when it exits.
os.Setenv(credential.PassphraseEnvVar, passphrase)

if err := setupSSHKey(); err != nil {
fmt.Printf("Error generating SSH key: %v\n", err)
os.Exit(1)
}

cfg := config.DefaultConfig()
var cfg *config.Config
if configExists {
// Preserve the existing config; SaveConfig will re-encrypt api_keys with the new passphrase.
cfg, err = config.LoadConfig(configPath)
if err != nil {
fmt.Printf("Error loading existing config: %v\n", err)
os.Exit(1)
}
} else {
cfg = config.DefaultConfig()
}
if err := config.SaveConfig(configPath, cfg); err != nil {
fmt.Printf("Error saving config: %v\n", err)
os.Exit(1)
Expand All @@ -33,17 +73,76 @@ func onboard() {
workspace := cfg.WorkspacePath()
createWorkspaceTemplates(workspace)

fmt.Printf("%s picoclaw is ready!\n", internal.Logo)
fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo)
fmt.Println("\nNext steps:")
fmt.Println(" 1. Add your API key to", configPath)
fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
fmt.Println(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS")
fmt.Println(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd")
fmt.Println("")
fmt.Println(" 2. Add your API key to", configPath)
fmt.Println("")
fmt.Println(" Recommended:")
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
fmt.Println(" - Ollama: https://ollama.com (local, free)")
fmt.Println("")
fmt.Println(" See README.md for 17+ supported providers.")
fmt.Println("")
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
}

// promptPassphrase reads the encryption passphrase twice from the terminal
// (with echo disabled) and returns it. Returns an error if the passphrase is
// empty or if the two inputs do not match.
func promptPassphrase() (string, error) {
fmt.Print("Enter passphrase for credential encryption: ")
p1, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", fmt.Errorf("reading passphrase: %w", err)
}
if len(p1) == 0 {
return "", fmt.Errorf("passphrase must not be empty")
}

fmt.Print("Confirm passphrase: ")
p2, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", fmt.Errorf("reading passphrase confirmation: %w", err)
}

if string(p1) != string(p2) {
return "", fmt.Errorf("passphrases do not match")
}
return string(p1), nil
}

// setupSSHKey generates the picoclaw-specific SSH key at ~/.ssh/picoclaw_ed25519.key.
// If the key already exists the user is warned and asked to confirm overwrite.
// Answering anything other than "y" keeps the existing key (not an error).
func setupSSHKey() error {
keyPath, err := credential.DefaultSSHKeyPath()
if err != nil {
return fmt.Errorf("cannot determine SSH key path: %w", err)
}

if _, err := os.Stat(keyPath); err == nil {
fmt.Printf("\n⚠️ WARNING: %s already exists.\n", keyPath)
fmt.Println(" Overwriting will invalidate any credentials previously encrypted with this key.")
fmt.Print(" Overwrite? (y/n): ")
var response string
fmt.Scanln(&response)
if response != "y" {
fmt.Println("Keeping existing SSH key.")
return nil
}
}

if err := credential.GenerateSSHKey(keyPath); err != nil {
return err
}
fmt.Printf("SSH key generated: %s\n", keyPath)
return nil
}

func createWorkspaceTemplates(workspace string) {
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
Loading