feat: add :edit command, replace Makefile with scripts/, improve cmd mode

- Add :edit command for file viewing and editing in cmd mode
  (:edit file, :edit file N text, :edit file +N text, :edit file -N,
   :edit file -m """...""" for multi-line write with auto-create)
- Intercept vim/nano/vi/emacs with helpful :edit redirect message
- Replace Makefile with scripts/ (build.sh, install.sh, deploy.sh,
  setup.sh, check.sh, docker.sh) for build, test, deploy, and
  environment setup
- Simplify :hipico to one-shot mode, remove :byepico and modeHiPico
- Switch command prefixes from / to : (:cmd, :pico, :hipico, :help)
- Add console-like code block formatting and emoji file type indicators
  for ls output in cmd mode
- Update Dockerfile and CI workflow to use scripts/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
seagochen 2026-02-24 16:39:34 +09:00
parent 36f77eadb0
commit 4f00898f72
12 changed files with 1125 additions and 334 deletions

View file

@ -17,4 +17,4 @@ jobs:
go-version-file: go.mod go-version-file: go.mod
- name: Build - name: Build
run: make build-all run: scripts/build.sh --all

34
.gitignore vendored
View file

@ -1,7 +1,7 @@
# Binaries # Binaries & build artifacts
# Go build artifacts
bin/ bin/
build/ build/
dist/
*.exe *.exe
*.dll *.dll
*.so *.so
@ -10,37 +10,33 @@ build/
*.out *.out
/picoclaw /picoclaw
/picoclaw-test /picoclaw-test
cmd/picoclaw/workspace
# Picoclaw specific # PicoClaw workspace & config
# PicoClaw
.picoclaw/ .picoclaw/
config.json config.json
sessions/ sessions/
build/ cmd/picoclaw/workspace
# Coverage # Secrets
# Secrets & Config (keep templates, ignore actual secrets)
.env .env
config/config.json config/config.json
# Test # Coverage
coverage.txt coverage.txt
coverage.html coverage.html
# OS # OS
.DS_Store .DS_Store
# Ralph workspace # Editors & tools
.vscode/
.idea/
.claude/
# Task tracking
TASKS.md
# Legacy
ralph/ ralph/
.ralph/ .ralph/
tasks/ tasks/
# Editors
.vscode/
.idea/
# Added by goreleaser init:
dist/

View file

@ -3,7 +3,7 @@
# ============================================================ # ============================================================
FROM golang:1.25-alpine AS builder FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git make RUN apk add --no-cache git bash
WORKDIR /src WORKDIR /src
@ -13,7 +13,7 @@ RUN go mod download
# Copy source and build # Copy source and build
COPY . . COPY . .
RUN make build RUN scripts/build.sh
# ============================================================ # ============================================================
# Stage 2: Minimal runtime image # Stage 2: Minimal runtime image

189
Makefile
View file

@ -1,189 +0,0 @@
.PHONY: all build install uninstall clean help test
# Build variables
BINARY_NAME=picoclaw
BUILD_DIR=build
CMD_DIR=cmd/$(BINARY_NAME)
MAIN_GO=$(CMD_DIR)/main.go
# Version
VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
BUILD_TIME=$(shell date +%FT%T%z)
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION) -s -w"
# Go variables
GO?=go
GOFLAGS?=-v -tags stdjson
# Golangci-lint
GOLANGCI_LINT?=golangci-lint
# Installation
INSTALL_PREFIX?=$(HOME)/.local
INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin
INSTALL_MAN_DIR=$(INSTALL_PREFIX)/share/man/man1
INSTALL_TMP_SUFFIX=.new
# Workspace and Skills
PICOCLAW_HOME?=$(HOME)/.picoclaw
WORKSPACE_DIR?=$(PICOCLAW_HOME)/workspace
WORKSPACE_SKILLS_DIR=$(WORKSPACE_DIR)/skills
BUILTIN_SKILLS_DIR=$(CURDIR)/skills
# OS detection
UNAME_S:=$(shell uname -s)
UNAME_M:=$(shell uname -m)
# Platform-specific settings
ifeq ($(UNAME_S),Linux)
PLATFORM=linux
ifeq ($(UNAME_M),x86_64)
ARCH=amd64
else ifeq ($(UNAME_M),aarch64)
ARCH=arm64
else ifeq ($(UNAME_M),loongarch64)
ARCH=loong64
else ifeq ($(UNAME_M),riscv64)
ARCH=riscv64
else
ARCH=$(UNAME_M)
endif
else ifeq ($(UNAME_S),Darwin)
PLATFORM=darwin
ifeq ($(UNAME_M),x86_64)
ARCH=amd64
else ifeq ($(UNAME_M),arm64)
ARCH=arm64
else
ARCH=$(UNAME_M)
endif
else
PLATFORM=$(UNAME_S)
ARCH=$(UNAME_M)
endif
BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)
# Default target
all: build
## generate: Run generate
generate:
@echo "Run generate..."
@rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true
@$(GO) generate ./...
@echo "Run generate complete"
## build: Build the picoclaw binary for current platform
build: generate
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
@mkdir -p $(BUILD_DIR)
@$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
@echo "Build complete: $(BINARY_PATH)"
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
## build-all: Build picoclaw for all platforms
build-all: generate
@echo "Building for multiple platforms..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
@echo "All builds complete"
## install: Install picoclaw to system and copy builtin skills
install: build
@echo "Installing $(BINARY_NAME)..."
@mkdir -p $(INSTALL_BIN_DIR)
# Copy binary with temporary suffix to ensure atomic update
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
@mv -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
@echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
@echo "Installation complete!"
## uninstall: Remove picoclaw from system
uninstall:
@echo "Uninstalling $(BINARY_NAME)..."
@rm -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)
@echo "Removed binary from $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
@echo "Note: Only the executable file has been deleted."
@echo "If you need to delete all configurations (config.json, workspace, etc.), run 'make uninstall-all'"
## uninstall-all: Remove picoclaw and all data
uninstall-all:
@echo "Removing workspace and skills..."
@rm -rf $(PICOCLAW_HOME)
@echo "Removed workspace: $(PICOCLAW_HOME)"
@echo "Complete uninstallation done!"
## clean: Remove build artifacts
clean:
@echo "Cleaning build artifacts..."
@rm -rf $(BUILD_DIR)
@echo "Clean complete"
## vet: Run go vet for static analysis
vet:
@$(GO) vet ./...
## test: Test Go code
test:
@$(GO) test ./...
## fmt: Format Go code
fmt:
@$(GOLANGCI_LINT) fmt
## lint: Run linters
lint:
@$(GOLANGCI_LINT) run
## deps: Download dependencies
deps:
@$(GO) mod download
@$(GO) mod verify
## update-deps: Update dependencies
update-deps:
@$(GO) get -u ./...
@$(GO) mod tidy
## check: Run vet, fmt, and verify dependencies
check: deps fmt vet test
## run: Build and run picoclaw
run: build
@$(BUILD_DIR)/$(BINARY_NAME) $(ARGS)
## help: Show this help message
help:
@echo "picoclaw Makefile"
@echo ""
@echo "Usage:"
@echo " make [target]"
@echo ""
@echo "Targets:"
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## / /'
@echo ""
@echo "Examples:"
@echo " make build # Build for current platform"
@echo " make install # Install to ~/.local/bin"
@echo " make uninstall # Remove from /usr/local/bin"
@echo " make install-skills # Install skills to workspace"
@echo ""
@echo "Environment Variables:"
@echo " INSTALL_PREFIX # Installation prefix (default: ~/.local)"
@echo " WORKSPACE_DIR # Workspace directory (default: ~/.picoclaw/workspace)"
@echo " VERSION # Version string (default: git describe)"
@echo ""
@echo "Current Configuration:"
@echo " Platform: $(PLATFORM)/$(ARCH)"
@echo " Binary: $(BINARY_PATH)"
@echo " Install Prefix: $(INSTALL_PREFIX)"
@echo " Workspace: $(WORKSPACE_DIR)"

View file

@ -25,9 +25,8 @@ import (
// Interactive mode identifiers // Interactive mode identifiers
const ( const (
modePico = "pico" // Chat mode (default) - input goes to AI agent modePico = "pico" // Chat mode (default) - input goes to AI agent
modeCmd = "cmd" // Command mode - input executed as shell commands modeCmd = "cmd" // Command mode - input executed as shell commands
modeHiPico = "hipico" // AI-assisted mode within cmd - multi-turn AI conversation
) )
// cmdWorkingDir tracks the current working directory for command mode. // cmdWorkingDir tracks the current working directory for command mode.
@ -108,12 +107,11 @@ func agentCmd() {
fmt.Printf("\n%s %s\n", logo, response) fmt.Printf("\n%s %s\n", logo, response)
} else { } else {
fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n", logo) fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n", logo)
fmt.Println(" /help - show detailed help") fmt.Println(" :help - show detailed help")
fmt.Println(" /usage - show model info and token usage") fmt.Println(" :usage - show model info and token usage")
fmt.Println(" /cmd - switch to command mode") fmt.Println(" :cmd - switch to command mode")
fmt.Println(" /pico - switch to chat mode") fmt.Println(" :pico - switch to chat mode")
fmt.Println(" /hipico - AI assistance in command mode") fmt.Println(" :hipico - ask AI for help (from command mode)")
fmt.Println(" /byepico - end AI assistance")
fmt.Println() fmt.Println()
interactiveMode(agentLoop, sessionKey) interactiveMode(agentLoop, sessionKey)
} }
@ -122,7 +120,6 @@ func agentCmd() {
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
chatPrompt := fmt.Sprintf("%s You: ", logo) chatPrompt := fmt.Sprintf("%s You: ", logo)
cmdPrompt := "$ " cmdPrompt := "$ "
hipicoPrompt := fmt.Sprintf("%s> ", logo)
mode := modePico mode := modePico
@ -164,22 +161,22 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
return return
} }
// /help and /usage work in all modes // :help and :usage work in all modes
if input == "/help" { if input == ":help" {
printInteractiveHelp() printInteractiveHelp()
continue continue
} }
if input == "/usage" { if input == ":usage" {
printUsage(agentLoop) printUsage(agentLoop)
continue continue
} }
switch mode { switch mode {
case modePico: case modePico:
if input == "/cmd" { if input == ":cmd" {
mode = modeCmd mode = modeCmd
rl.SetPrompt(cmdPrompt) rl.SetPrompt(cmdPrompt)
fmt.Println("Switched to command mode. Type /pico to return to chat.") fmt.Println("Switched to command mode. Type :pico to return to chat.")
continue continue
} }
@ -192,64 +189,34 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
fmt.Printf("\n%s %s\n\n", logo, response) fmt.Printf("\n%s %s\n\n", logo, response)
case modeCmd: case modeCmd:
if input == "/pico" { if input == ":pico" {
mode = modePico mode = modePico
rl.SetPrompt(chatPrompt) rl.SetPrompt(chatPrompt)
fmt.Println("Switched to chat mode. Type /cmd to return to command mode.") fmt.Println("Switched to chat mode. Type :cmd to return to command mode.")
continue continue
} }
if strings.HasPrefix(input, "/hipico") { if strings.HasPrefix(input, ":hipico") {
initialMsg := strings.TrimSpace(strings.TrimPrefix(input, "/hipico")) initialMsg := strings.TrimSpace(strings.TrimPrefix(input, ":hipico"))
if initialMsg == "" { if initialMsg == "" {
fmt.Println("Usage: /hipico <message>") fmt.Println("Usage: :hipico <message>")
fmt.Println("Example: /hipico check the log files for error messages") fmt.Println("Example: :hipico check the log files for error messages")
continue continue
} }
mode = modeHiPico
rl.SetPrompt(hipicoPrompt)
contextPrefix := fmt.Sprintf("[Command mode context: working directory is %s]\n\n", cmdWorkingDir) contextPrefix := fmt.Sprintf("[Command mode context: working directory is %s]\n\n", cmdWorkingDir)
fmt.Printf("\n%s AI assistance started. Type /byepico to end.\n\n", logo)
ctx := context.Background() ctx := context.Background()
response, err := agentLoop.ProcessDirect(ctx, contextPrefix+initialMsg, hipicoSessionKey) response, err := agentLoop.ProcessDirect(ctx, contextPrefix+initialMsg, hipicoSessionKey)
if err != nil { if err != nil {
fmt.Printf("Error: %v\n", err) fmt.Printf("Error: %v\n", err)
mode = modeCmd
rl.SetPrompt(cmdPrompt)
continue continue
} }
fmt.Printf("%s %s\n\n", logo, response) fmt.Printf("\n%s %s\n\n", logo, response)
continue continue
} }
executeShellCommand(input) executeShellCommand(input)
case modeHiPico:
if input == "/byepico" {
mode = modeCmd
rl.SetPrompt(cmdPrompt)
fmt.Println("AI assistance ended. Back to command mode.")
continue
}
if input == "/pico" {
mode = modePico
rl.SetPrompt(chatPrompt)
fmt.Println("AI assistance ended. Switched to chat mode.")
continue
}
ctx := context.Background()
response, err := agentLoop.ProcessDirect(ctx, input, hipicoSessionKey)
if err != nil {
fmt.Printf("Error: %v\n", err)
continue
}
fmt.Printf("\n%s %s\n\n", logo, response)
} }
} }
} }
@ -265,8 +232,6 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
fmt.Printf("%s You: ", logo) fmt.Printf("%s You: ", logo)
case modeCmd: case modeCmd:
fmt.Print("$ ") fmt.Print("$ ")
case modeHiPico:
fmt.Printf("%s> ", logo)
} }
line, err := reader.ReadString('\n') line, err := reader.ReadString('\n')
@ -289,21 +254,21 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
return return
} }
// /help and /usage work in all modes // :help and :usage work in all modes
if input == "/help" { if input == ":help" {
printInteractiveHelp() printInteractiveHelp()
continue continue
} }
if input == "/usage" { if input == ":usage" {
printUsage(agentLoop) printUsage(agentLoop)
continue continue
} }
switch mode { switch mode {
case modePico: case modePico:
if input == "/cmd" { if input == ":cmd" {
mode = modeCmd mode = modeCmd
fmt.Println("Switched to command mode. Type /pico to return to chat.") fmt.Println("Switched to command mode. Type :pico to return to chat.")
continue continue
} }
@ -316,57 +281,33 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
fmt.Printf("\n%s %s\n\n", logo, response) fmt.Printf("\n%s %s\n\n", logo, response)
case modeCmd: case modeCmd:
if input == "/pico" { if input == ":pico" {
mode = modePico mode = modePico
fmt.Println("Switched to chat mode. Type /cmd to return to command mode.") fmt.Println("Switched to chat mode. Type :cmd to return to command mode.")
continue continue
} }
if strings.HasPrefix(input, "/hipico") { if strings.HasPrefix(input, ":hipico") {
initialMsg := strings.TrimSpace(strings.TrimPrefix(input, "/hipico")) initialMsg := strings.TrimSpace(strings.TrimPrefix(input, ":hipico"))
if initialMsg == "" { if initialMsg == "" {
fmt.Println("Usage: /hipico <message>") fmt.Println("Usage: :hipico <message>")
fmt.Println("Example: /hipico check the log files for error messages") fmt.Println("Example: :hipico check the log files for error messages")
continue continue
} }
mode = modeHiPico
contextPrefix := fmt.Sprintf("[Command mode context: working directory is %s]\n\n", cmdWorkingDir) contextPrefix := fmt.Sprintf("[Command mode context: working directory is %s]\n\n", cmdWorkingDir)
fmt.Printf("\n%s AI assistance started. Type /byepico to end.\n\n", logo)
ctx := context.Background() ctx := context.Background()
response, err := agentLoop.ProcessDirect(ctx, contextPrefix+initialMsg, hipicoSessionKey) response, err := agentLoop.ProcessDirect(ctx, contextPrefix+initialMsg, hipicoSessionKey)
if err != nil { if err != nil {
fmt.Printf("Error: %v\n", err) fmt.Printf("Error: %v\n", err)
mode = modeCmd
continue continue
} }
fmt.Printf("%s %s\n\n", logo, response) fmt.Printf("\n%s %s\n\n", logo, response)
continue continue
} }
executeShellCommand(input) executeShellCommand(input)
case modeHiPico:
if input == "/byepico" {
mode = modeCmd
fmt.Println("AI assistance ended. Back to command mode.")
continue
}
if input == "/pico" {
mode = modePico
fmt.Println("AI assistance ended. Switched to chat mode.")
continue
}
ctx := context.Background()
response, err := agentLoop.ProcessDirect(ctx, input, hipicoSessionKey)
if err != nil {
fmt.Printf("Error: %v\n", err)
continue
}
fmt.Printf("\n%s %s\n\n", logo, response)
} }
} }
} }
@ -376,7 +317,7 @@ func printInteractiveHelp() {
fmt.Printf(`%s PicoClaw Interactive Mode Help fmt.Printf(`%s PicoClaw Interactive Mode Help
PicoClaw has three interactive modes: PicoClaw has two interactive modes:
1. Chat Mode (default) 1. Chat Mode (default)
Talk to the AI agent directly. Your input is sent as a message Talk to the AI agent directly. Your input is sent as a message
@ -385,23 +326,19 @@ PicoClaw has three interactive modes:
2. Command Mode 2. Command Mode
Execute shell commands directly, like a terminal. Supports cd, Execute shell commands directly, like a terminal. Supports cd,
pipes, redirects, and all standard shell features. pipes, redirects, and all standard shell features.
Use :hipico to ask AI for one-shot help within command mode.
3. AI-Assisted Command Mode
A multi-turn AI conversation within command mode. The AI is aware
of your current working directory and can help with system tasks.
Commands (available in all modes): Commands (available in all modes):
/help Show this help message :help Show this help message
/usage Show model info and token usage :usage Show model info and token usage
exit Exit PicoClaw exit Exit PicoClaw
quit Exit PicoClaw quit Exit PicoClaw
Ctrl+C Exit PicoClaw Ctrl+C Exit PicoClaw
Mode switching: Mode switching:
/cmd Switch to command mode (from chat mode) :cmd Switch to command mode (from chat mode)
/pico Switch to chat mode (from command / AI-assisted mode) :pico Switch to chat mode (from command mode)
/hipico <msg> Start AI-assisted mode (from command mode) :hipico <msg> Ask AI for help (from command mode, one-shot)
/byepico End AI assistance (from AI-assisted mode)
Examples: Examples:
Chat mode: Chat mode:
@ -412,12 +349,11 @@ Examples:
$ cd /tmp $ cd /tmp
$ cat error.log | grep "FATAL" $ cat error.log | grep "FATAL"
AI-assisted mode (enter from command mode): AI help (one-shot, from command mode):
$ /hipico check the log files for errors $ :hipico check the log files for errors
%s> show me more details on line 42 $ :hipico what does this error mean in syslog
%s> /byepico
`, logo, logo, logo, logo) `, logo, logo)
} }
// printUsage displays current model information and accumulated token usage. // printUsage displays current model information and accumulated token usage.

View file

@ -10,6 +10,9 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
@ -29,15 +32,25 @@ import (
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
) )
// Session mode constants
type sessionMode int
const (
modePico sessionMode = iota // Default: messages → LLM
modeCmd // Command mode: messages → shell
)
type AgentLoop struct { type AgentLoop struct {
bus *bus.MessageBus bus *bus.MessageBus
cfg *config.Config cfg *config.Config
registry *AgentRegistry registry *AgentRegistry
state *state.Manager state *state.Manager
running atomic.Bool running atomic.Bool
summarizing sync.Map summarizing sync.Map
fallback *providers.FallbackChain fallback *providers.FallbackChain
channelManager *channels.Manager channelManager *channels.Manager
sessionModes sync.Map // per-session mode: sessionKey -> sessionMode
sessionWorkDirs sync.Map // per-session working dir: sessionKey -> string
} }
// processOptions configures how a message is processed // processOptions configures how a message is processed
@ -79,6 +92,28 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
} }
} }
func (al *AgentLoop) getSessionMode(sessionKey string) sessionMode {
if v, ok := al.sessionModes.Load(sessionKey); ok {
return v.(sessionMode)
}
return modePico
}
func (al *AgentLoop) setSessionMode(sessionKey string, mode sessionMode) {
al.sessionModes.Store(sessionKey, mode)
}
func (al *AgentLoop) getSessionWorkDir(sessionKey string) string {
if v, ok := al.sessionWorkDirs.Load(sessionKey); ok {
return v.(string)
}
return ""
}
func (al *AgentLoop) setSessionWorkDir(sessionKey string, dir string) {
al.sessionWorkDirs.Store(sessionKey, dir)
}
// registerSharedTools registers tools that are shared across all agents (web, message, spawn). // registerSharedTools registers tools that are shared across all agents (web, message, spawn).
func registerSharedTools( func registerSharedTools(
cfg *config.Config, cfg *config.Config,
@ -323,15 +358,49 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
"matched_by": route.MatchedBy, "matched_by": route.MatchedBy,
}) })
return al.runAgentLoop(ctx, agent, processOptions{ // Handle mode-switching commands (:cmd, :pico, :hipico)
SessionKey: sessionKey, content := strings.TrimSpace(msg.Content)
Channel: msg.Channel, if strings.HasPrefix(content, ":") {
ChatID: msg.ChatID, if response, handled := al.handleModeCommand(content, sessionKey, agent); handled {
UserMessage: msg.Content, return response, nil
DefaultResponse: "I've completed processing but have no response to give.", }
EnableSummary: true, // :hipico <msg> falls through here — one-shot LLM call, stays in modeCmd
SendResponse: false, if strings.HasPrefix(content, ":hipico") {
}) userMessage := strings.TrimSpace(strings.TrimPrefix(content, ":hipico"))
workDir := al.getSessionWorkDir(sessionKey)
if workDir == "" {
workDir = agent.Workspace
}
userMessage = fmt.Sprintf("[Command mode context: working directory is %s]\n\n%s", workDir, userMessage)
hipicoSessionKey := sessionKey + ":hipico"
return al.runAgentLoop(ctx, agent, processOptions{
SessionKey: hipicoSessionKey,
Channel: msg.Channel,
ChatID: msg.ChatID,
UserMessage: userMessage,
DefaultResponse: "I've completed processing but have no response to give.",
EnableSummary: false,
SendResponse: false,
})
}
}
// Dispatch based on current session mode
switch al.getSessionMode(sessionKey) {
case modeCmd:
return al.executeCmdMode(ctx, agent, content, sessionKey, msg.Channel, msg.ChatID)
default: // modePico
return al.runAgentLoop(ctx, agent, processOptions{
SessionKey: sessionKey,
Channel: msg.Channel,
ChatID: msg.ChatID,
UserMessage: msg.Content,
DefaultResponse: "I've completed processing but have no response to give.",
EnableSummary: true,
SendResponse: false,
})
}
} }
func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
@ -1056,6 +1125,12 @@ func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) { func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) {
content := strings.TrimSpace(msg.Content) content := strings.TrimSpace(msg.Content)
// Handle : prefixed extension commands (work across all channels)
if strings.HasPrefix(content, ":") {
return al.handleExtensionCommand(content)
}
if !strings.HasPrefix(content, "/") { if !strings.HasPrefix(content, "/") {
return "", false return "", false
} }
@ -1144,6 +1219,577 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
return "", false return "", false
} }
// handleExtensionCommand handles : prefixed commands that work across all channels.
func (al *AgentLoop) handleExtensionCommand(content string) (string, bool) {
parts := strings.Fields(content)
if len(parts) == 0 {
return "", false
}
cmd := parts[0]
switch cmd {
case ":cmd", ":pico", ":hipico", ":edit":
// Pass through to processMessage for mode handling (needs sessionKey from routing)
return "", false
case ":help":
return `:help - Show this help message
:usage - Show model info and token usage
:cmd - Switch to command mode (execute shell commands)
:pico - Switch to chat mode (default, AI conversation)
:hipico <msg> - Ask AI for help (from command mode, one-shot)
:edit <file> - View/edit files (cmd mode)
/show [model|channel|agents] - Show current configuration
/list [models|channels|agents] - List available options
/switch [model|channel] to <name> - Switch model or channel`, true
case ":usage":
agent := al.registry.GetDefaultAgent()
if agent == nil {
return "No agent available.", true
}
promptTokens := agent.TotalPromptTokens.Load()
completionTokens := agent.TotalCompletionTokens.Load()
return fmt.Sprintf(`Model: %s
Max tokens: %d
Temperature: %.1f
Token usage (this session):
Prompt tokens: %d
Completion tokens: %d
Total tokens: %d
Requests: %d`,
agent.Model,
agent.MaxTokens,
agent.Temperature,
promptTokens,
completionTokens,
promptTokens+completionTokens,
agent.TotalRequests.Load(),
), true
default:
return fmt.Sprintf("Unknown command: %s\nType :help for available commands.", cmd), true
}
}
// handleModeCommand processes mode-switching commands (:cmd, :pico, :hipico).
// Returns (response, handled). If handled is true, the caller should return the response directly.
// For :hipico with a message, it returns ("", false) so processMessage continues with a one-shot LLM call.
func (al *AgentLoop) handleModeCommand(content, sessionKey string, agent *AgentInstance) (string, bool) {
parts := strings.Fields(content)
if len(parts) == 0 {
return "", false
}
cmd := parts[0]
switch cmd {
case ":cmd":
al.setSessionMode(sessionKey, modeCmd)
workDir := al.getSessionWorkDir(sessionKey)
if workDir == "" {
workDir = agent.Workspace
al.setSessionWorkDir(sessionKey, workDir)
}
displayDir := shortenHomePath(workDir)
return fmt.Sprintf("```\n%s$\n```\nType `:pico` to return to chat mode.", displayDir), true
case ":pico":
al.setSessionMode(sessionKey, modePico)
return "Switched to chat mode. Type :cmd to enter command mode.", true
case ":hipico":
msg := strings.TrimSpace(strings.TrimPrefix(content, ":hipico"))
if msg == "" {
return "Usage: :hipico <message>\nExample: :hipico check the log files for errors", true
}
// Stay in modeCmd, just flag for one-shot LLM call — processMessage handles it
return "", false
}
return "", false
}
// executeCmdMode executes a shell command in command mode via ExecTool.
// Output is formatted as a console code block for channel display.
func (al *AgentLoop) executeCmdMode(ctx context.Context, agent *AgentInstance, content, sessionKey, channel, chatID string) (string, error) {
content = strings.TrimSpace(content)
if content == "" {
return "", nil
}
// Handle cd command specially
if content == "cd" || strings.HasPrefix(content, "cd ") {
return al.handleCdCommand(content, sessionKey, agent), nil
}
// Handle :edit command
if content == ":edit" || strings.HasPrefix(content, ":edit ") {
workDir := al.getSessionWorkDir(sessionKey)
if workDir == "" {
workDir = agent.Workspace
}
return al.handleEditCommand(content, workDir), nil
}
// Intercept interactive editors
if msg := interceptEditor(content); msg != "" {
return msg, nil
}
// Get working directory
workDir := al.getSessionWorkDir(sessionKey)
if workDir == "" {
workDir = agent.Workspace
}
// For ls commands, ensure -l flag so we can parse file types
execCmd := content
if isLsCommand(content) {
execCmd = ensureLsLong(content)
}
// Execute via ExecTool
result := agent.Tools.ExecuteWithContext(ctx, "exec", map[string]any{
"command": execCmd,
"working_dir": workDir,
}, channel, chatID, nil)
displayDir := shortenHomePath(workDir)
output := result.ForLLM
if output == "" {
output = "(no output)"
}
// Colorize ls output with emoji type indicators
if isLsCommand(content) {
output = formatLsOutput(output)
}
// Format as console code block: prompt line + output (show original command, not modified)
return fmt.Sprintf("```\n%s$ %s\n%s\n```", displayDir, content, output), nil
}
// handleCdCommand handles the cd command in command mode, updating per-session working directory.
func (al *AgentLoop) handleCdCommand(content, sessionKey string, agent *AgentInstance) string {
parts := strings.Fields(content)
var target string
if len(parts) < 2 || parts[1] == "~" {
home, _ := os.UserHomeDir()
target = home
} else {
target = parts[1]
// Expand ~ prefix
if strings.HasPrefix(target, "~/") {
home, _ := os.UserHomeDir()
target = home + target[1:]
}
// Resolve relative paths
if !filepath.IsAbs(target) {
currentDir := al.getSessionWorkDir(sessionKey)
if currentDir == "" {
currentDir = agent.Workspace
}
target = filepath.Join(currentDir, target)
}
}
target = filepath.Clean(target)
info, err := os.Stat(target)
if err != nil {
return fmt.Sprintf("cd: %s: No such file or directory", target)
}
if !info.IsDir() {
return fmt.Sprintf("cd: %s: Not a directory", target)
}
al.setSessionWorkDir(sessionKey, target)
return fmt.Sprintf("```\n%s$\n```", shortenHomePath(target))
}
// shortenHomePath replaces the user's home directory prefix with ~ for display.
func shortenHomePath(path string) string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return path
}
if path == home {
return "~"
}
if strings.HasPrefix(path, home+"/") {
return "~" + path[len(home):]
}
return path
}
// handleEditCommand processes :edit commands for file viewing and editing in cmd mode.
// Syntax:
//
// :edit → show usage
// :edit <file> → show file with line numbers
// :edit <file> <N> <text> → replace line N
// :edit <file> +<N> <text> → insert after line N
// :edit <file> -<N> → delete line N
// :edit <file> -m """<content>""" → write full content (create if needed)
func (al *AgentLoop) handleEditCommand(content, workDir string) string {
raw := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(content), ":edit"))
if raw == "" {
return editUsage()
}
// Split on first newline to get the command line
firstLine := raw
if idx := strings.Index(raw, "\n"); idx != -1 {
firstLine = raw[:idx]
}
parts := strings.Fields(firstLine)
if len(parts) == 0 {
return editUsage()
}
filename := resolveEditPath(parts[0], workDir)
// :edit <file> — show file content
if len(parts) == 1 && !strings.Contains(raw, "\n") {
return editShowFile(filename)
}
// :edit <file> -m """..."""
if len(parts) >= 2 && parts[1] == "-m" {
return editMultiline(filename, raw)
}
// Line operations: N text, +N text, -N
if len(parts) >= 2 {
// Get raw text after the line-op token (preserves original spacing)
afterFile := strings.TrimSpace(firstLine[len(parts[0]):])
return editLineOp(filename, afterFile)
}
return editUsage()
}
func resolveEditPath(name, workDir string) string {
if strings.HasPrefix(name, "~/") {
home, _ := os.UserHomeDir()
return home + name[1:]
}
if filepath.IsAbs(name) {
return name
}
return filepath.Join(workDir, name)
}
func editUsage() string {
return "Usage:\n" +
" :edit <file> — view file\n" +
" :edit <file> <N> <text> — replace line N\n" +
" :edit <file> +<N> <text> — insert after line N\n" +
" :edit <file> -<N> — delete line N\n" +
" :edit <file> -m \"\"\" — write content\n" +
" <content>\n" +
" \"\"\""
}
func editShowFile(path string) string {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return fmt.Sprintf("File not found: %s\nUse :edit %s -m \"\"\" to create it.", shortenHomePath(path), filepath.Base(path))
}
return fmt.Sprintf("Error reading file: %v", err)
}
lines := strings.Split(string(data), "\n")
// Remove trailing empty line that Split produces
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
const maxLines = 50
var b strings.Builder
b.WriteString(fmt.Sprintf("``` %s (%d lines)\n", filepath.Base(path), len(lines)))
if len(lines) <= maxLines {
for i, line := range lines {
b.WriteString(fmt.Sprintf("%4d│ %s\n", i+1, line))
}
} else {
for i := 0; i < maxLines; i++ {
b.WriteString(fmt.Sprintf("%4d│ %s\n", i+1, lines[i]))
}
b.WriteString(fmt.Sprintf(" ...│ (%d more lines)\n", len(lines)-maxLines))
}
b.WriteString("```")
return b.String()
}
func editMultiline(filename, raw string) string {
// raw = `<file> -m """..."""`
start := strings.Index(raw, `"""`)
if start == -1 {
return editUsage()
}
rest := raw[start+3:]
// Trim leading newline after opening """
rest = strings.TrimPrefix(rest, "\n")
// Find closing """
end := strings.LastIndex(rest, `"""`)
if end == -1 || end == 0 {
// No closing triple-quote — use entire rest as content
end = len(rest)
}
content := rest[:end]
// Ensure trailing newline
if content != "" && !strings.HasSuffix(content, "\n") {
content += "\n"
}
// Create parent dirs if needed
dir := filepath.Dir(filename)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Sprintf("Error creating directory: %v", err)
}
if err := os.WriteFile(filename, []byte(content), 0o644); err != nil {
return fmt.Sprintf("Error writing file: %v", err)
}
lineCount := strings.Count(content, "\n")
return fmt.Sprintf("```\n✓ Wrote %d lines → %s\n```", lineCount, shortenHomePath(filename))
}
func editLineOp(filename, rawArgs string) string {
rawArgs = strings.TrimSpace(rawArgs)
// Split into op token and text
spaceIdx := strings.IndexByte(rawArgs, ' ')
var op, text string
if spaceIdx == -1 {
op = rawArgs
} else {
op = rawArgs[:spaceIdx]
text = rawArgs[spaceIdx+1:]
}
var lineNum int
var action string // "replace", "insert", "delete"
var err error
if strings.HasPrefix(op, "+") {
action = "insert"
lineNum, err = strconv.Atoi(op[1:])
} else if strings.HasPrefix(op, "-") {
action = "delete"
lineNum, err = strconv.Atoi(op[1:])
} else {
action = "replace"
lineNum, err = strconv.Atoi(op)
}
if err != nil || lineNum < 1 {
return "Invalid line number. Use a positive integer."
}
// Read existing file
data, err := os.ReadFile(filename)
if err != nil {
if os.IsNotExist(err) {
return fmt.Sprintf("File not found: %s", shortenHomePath(filename))
}
return fmt.Sprintf("Error reading file: %v", err)
}
lines := strings.Split(string(data), "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
switch action {
case "delete":
if lineNum > len(lines) {
return fmt.Sprintf("Line %d out of range (file has %d lines).", lineNum, len(lines))
}
deleted := lines[lineNum-1]
lines = append(lines[:lineNum-1], lines[lineNum:]...)
if err := os.WriteFile(filename, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
return fmt.Sprintf("Error writing file: %v", err)
}
return fmt.Sprintf("```\n✓ Deleted line %d: %s\n(%d lines remaining)\n```", lineNum, deleted, len(lines))
case "replace":
if text == "" {
return "Usage: :edit <file> <N> <text>"
}
if lineNum > len(lines) {
return fmt.Sprintf("Line %d out of range (file has %d lines).", lineNum, len(lines))
}
old := lines[lineNum-1]
lines[lineNum-1] = text
if err := os.WriteFile(filename, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
return fmt.Sprintf("Error writing file: %v", err)
}
return fmt.Sprintf("```\n✓ Line %d replaced\n was: %s\n now: %s\n```", lineNum, old, text)
case "insert":
if text == "" {
return "Usage: :edit <file> +<N> <text>"
}
if lineNum > len(lines) {
lineNum = len(lines) // insert at end
}
newLines := make([]string, 0, len(lines)+1)
newLines = append(newLines, lines[:lineNum]...)
newLines = append(newLines, text)
newLines = append(newLines, lines[lineNum:]...)
if err := os.WriteFile(filename, []byte(strings.Join(newLines, "\n")+"\n"), 0o644); err != nil {
return fmt.Sprintf("Error writing file: %v", err)
}
return fmt.Sprintf("```\n✓ Inserted after line %d: %s\n(%d lines total)\n```", lineNum, text, len(newLines))
}
return editUsage()
}
// interceptEditor detects interactive editor commands and returns a helpful redirect message.
func interceptEditor(cmd string) string {
parts := strings.Fields(cmd)
if len(parts) == 0 {
return ""
}
name := parts[0]
switch name {
case "vim", "vi", "nvim", "nano", "emacs", "pico", "joe", "mcedit":
return fmt.Sprintf("⚠ %s requires a terminal and cannot run here.\nUse :edit instead:\n\n"+
":edit <file> — view file\n"+
":edit <file> -m \"\"\" — write content\n"+
"<content>\n"+
"\"\"\"\n\n"+
"Type :help for all commands.", name)
}
return ""
}
// isLsCommand checks if a shell command is an ls invocation.
func isLsCommand(cmd string) bool {
cmd = strings.TrimSpace(cmd)
return cmd == "ls" || strings.HasPrefix(cmd, "ls ")
}
// ensureLsLong injects -l into an ls command if not already present,
// so the output always contains permission strings for type detection.
func ensureLsLong(cmd string) string {
parts := strings.Fields(cmd)
for _, p := range parts[1:] {
if strings.HasPrefix(p, "-") && !strings.HasPrefix(p, "--") && strings.ContainsRune(p, 'l') {
return cmd // already has -l
}
}
// "ls" → "ls -l", "ls -a /tmp" → "ls -l -a /tmp"
if len(parts) == 1 {
return "ls -l"
}
return "ls -l " + strings.Join(parts[1:], " ")
}
// formatLsOutput adds emoji type indicators to ls -l style output lines.
func formatLsOutput(output string) string {
lines := strings.Split(output, "\n")
for i, line := range lines {
lines[i] = formatLsLine(line)
}
return strings.Join(lines, "\n")
}
// formatLsLine adds an emoji prefix to a single ls -l output line based on file type.
func formatLsLine(line string) string {
// Skip empty lines, "total" line, and lines too short to be ls -l
if line == "" || strings.HasPrefix(line, "total ") || len(line) < 10 {
return line
}
// Check if line starts with a permission string (e.g. drwxr-xr-x)
perms := line[:10]
if !isPermString(perms) {
return line
}
fileType := perms[0]
var emoji string
switch fileType {
case 'd':
emoji = "\U0001F4C1" // 📁
case 'l':
emoji = "\U0001F517" // 🔗
case 'b', 'c':
emoji = "\U0001F4BE" // 💾
case 'p', 's':
emoji = "\U0001F50C" // 🔌
default:
// Regular file: check executable bit (owner/group/other x positions)
if perms[3] == 'x' || perms[6] == 'x' || perms[9] == 'x' {
emoji = "\u26A1" // ⚡
} else {
emoji = fileEmojiByExt(line)
}
}
return emoji + " " + line
}
// isPermString checks if a 10-char string looks like a Unix permission string.
func isPermString(s string) bool {
if len(s) != 10 {
return false
}
// First char: file type
switch s[0] {
case '-', 'd', 'l', 'b', 'c', 'p', 's':
default:
return false
}
// Remaining 9 chars: rwx or - (plus s/S/t/T for setuid/setgid/sticky)
for _, c := range s[1:] {
switch c {
case 'r', 'w', 'x', '-', 's', 'S', 't', 'T':
default:
return false
}
}
return true
}
// fileEmojiByExt returns an emoji based on the file extension found in an ls -l line.
func fileEmojiByExt(line string) string {
// Extract filename: last whitespace-delimited field (for symlinks, take before " -> ")
name := line
if idx := strings.LastIndex(line, " -> "); idx != -1 {
name = line[:idx]
}
if idx := strings.LastIndex(name, " "); idx != -1 {
name = name[idx+1:]
}
name = strings.ToLower(name)
ext := filepath.Ext(name)
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp", ".bmp", ".ico", ".tiff":
return "\U0001F5BC" // 🖼
case ".mp3", ".wav", ".flac", ".aac", ".ogg", ".wma", ".m4a":
return "\U0001F3B5" // 🎵
case ".mp4", ".avi", ".mkv", ".mov", ".webm", ".flv", ".wmv":
return "\U0001F3AC" // 🎬
case ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", ".zst", ".tgz":
return "\U0001F4E6" // 📦
default:
return "\U0001F4C4" // 📄
}
}
// extractPeer extracts the routing peer from inbound message metadata. // extractPeer extracts the routing peer from inbound message metadata.
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer { func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
peerKind := msg.Metadata["peer_kind"] peerKind := msg.Metadata["peer_kind"]

89
scripts/build.sh Executable file
View file

@ -0,0 +1,89 @@
#!/usr/bin/env bash
set -euo pipefail
# ── project constants ────────────────────────────────────────
BINARY_NAME="picoclaw"
CMD_DIR="cmd/${BINARY_NAME}"
BUILD_DIR="build"
# ── Go flags ─────────────────────────────────────────────────
GO="${GO:-go}"
GOFLAGS="${GOFLAGS:--v -tags stdjson}"
# ── version info (injected via ldflags) ──────────────────────
VERSION="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")}"
GIT_COMMIT="$(git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")"
BUILD_TIME="$(date +%FT%T%z)"
GO_VERSION="$($GO version | awk '{print $3}')"
LDFLAGS="-X main.version=${VERSION} -X main.gitCommit=${GIT_COMMIT} -X main.buildTime=${BUILD_TIME} -X main.goVersion=${GO_VERSION} -s -w"
# ── platform detection ───────────────────────────────────────
detect_platform() {
local os arch
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
arch="$(uname -m)"
case "$arch" in
x86_64) arch="amd64" ;;
aarch64) arch="arm64" ;;
loongarch64) arch="loong64" ;;
esac
echo "${os} ${arch}"
}
# ── generate ─────────────────────────────────────────────────
generate() {
echo "Run generate..."
rm -rf "./${CMD_DIR}/workspace" 2>/dev/null || true
$GO generate ./...
echo "Run generate complete"
}
# ── build single platform ────────────────────────────────────
build_one() {
local goos="$1" goarch="$2"
local suffix="${BINARY_NAME}-${goos}-${goarch}"
[[ "$goos" == "windows" ]] && suffix+=".exe"
echo "Building ${BINARY_NAME} for ${goos}/${goarch}..."
mkdir -p "$BUILD_DIR"
GOOS="$goos" GOARCH="$goarch" $GO build $GOFLAGS -ldflags "$LDFLAGS" -o "${BUILD_DIR}/${suffix}" "./${CMD_DIR}"
echo "Build complete: ${BUILD_DIR}/${suffix}"
}
# ── build current platform ───────────────────────────────────
build_current() {
generate
read -r os arch <<< "$(detect_platform)"
build_one "$os" "$arch"
ln -sf "${BINARY_NAME}-${os}-${arch}" "${BUILD_DIR}/${BINARY_NAME}"
}
# ── build all platforms ──────────────────────────────────────
build_all() {
generate
echo "Building for multiple platforms..."
mkdir -p "$BUILD_DIR"
build_one linux amd64
build_one linux arm64
build_one linux loong64
build_one linux riscv64
build_one darwin arm64
build_one windows amd64
echo "All builds complete"
}
# ── clean ────────────────────────────────────────────────────
clean() {
echo "Cleaning build artifacts..."
rm -rf "$BUILD_DIR"
echo "Clean complete"
}
# ── main ─────────────────────────────────────────────────────
case "${1:-}" in
--all) build_all ;;
--clean) clean ;;
*) build_current ;;
esac

56
scripts/check.sh Executable file
View file

@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
GO="${GO:-go}"
GOLANGCI_LINT="${GOLANGCI_LINT:-golangci-lint}"
# ── individual checks ────────────────────────────────────────
do_deps() {
echo "==> Downloading dependencies..."
$GO mod download
$GO mod verify
}
do_fmt() {
echo "==> Formatting..."
$GOLANGCI_LINT fmt
}
do_vet() {
echo "==> Running vet..."
$GO vet ./...
}
do_test() {
echo "==> Running tests..."
$GO test ./...
}
do_lint() {
echo "==> Running linter..."
$GOLANGCI_LINT run
}
do_all() {
do_deps
do_fmt
do_vet
do_test
echo ""
echo "All checks passed."
}
# ── main ─────────────────────────────────────────────────────
case "${1:-}" in
test) do_test ;;
lint) do_lint ;;
fmt) do_fmt ;;
vet) do_vet ;;
deps) do_deps ;;
"") do_all ;;
*)
echo "Usage: $(basename "$0") [test|lint|fmt|vet|deps]"
echo " (no argument runs all checks)"
exit 1
;;
esac

67
scripts/deploy.sh Executable file
View file

@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BINARY_NAME="picoclaw"
BUILD_DIR="build"
INSTALL_BIN_DIR="${HOME}/.local/bin"
GATEWAY_LOG="/tmp/picoclaw-gateway.log"
HEALTH_URL="http://localhost:18790/health"
# ── step 1: build ────────────────────────────────────────────
echo "==> Building..."
"${SCRIPT_DIR}/build.sh"
# ── step 2: stop old gateway ─────────────────────────────────
OLD_PID=$(pgrep -f "${BINARY_NAME} gateway" 2>/dev/null || true)
if [[ -n "$OLD_PID" ]]; then
echo "==> Stopping gateway (PID ${OLD_PID})..."
kill "$OLD_PID" 2>/dev/null || true
# Wait up to 5 seconds for graceful shutdown
for i in $(seq 1 10); do
if ! kill -0 "$OLD_PID" 2>/dev/null; then
break
fi
sleep 0.5
done
# Force kill if still running
if kill -0 "$OLD_PID" 2>/dev/null; then
echo " Force killing..."
kill -9 "$OLD_PID" 2>/dev/null || true
sleep 0.5
fi
echo " Gateway stopped."
else
echo "==> No running gateway found."
fi
# ── step 3: install new binary ───────────────────────────────
echo "==> Installing..."
mkdir -p "$INSTALL_BIN_DIR"
cp "${BUILD_DIR}/${BINARY_NAME}" "${INSTALL_BIN_DIR}/${BINARY_NAME}.new"
chmod +x "${INSTALL_BIN_DIR}/${BINARY_NAME}.new"
mv -f "${INSTALL_BIN_DIR}/${BINARY_NAME}.new" "${INSTALL_BIN_DIR}/${BINARY_NAME}"
echo " Installed to ${INSTALL_BIN_DIR}/${BINARY_NAME}"
# ── step 4: start new gateway ────────────────────────────────
echo "==> Starting gateway..."
nohup "${INSTALL_BIN_DIR}/${BINARY_NAME}" gateway > "$GATEWAY_LOG" 2>&1 &
NEW_PID=$!
echo " PID: ${NEW_PID}"
echo " Log: ${GATEWAY_LOG}"
# ── step 5: health check ─────────────────────────────────────
echo "==> Waiting for health check..."
sleep 2
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
echo " Health check passed."
echo ""
echo "Deploy complete. Gateway running as PID ${NEW_PID}."
else
echo " Health check failed (gateway may still be starting)."
echo " Check logs: tail -f ${GATEWAY_LOG}"
fi

29
scripts/docker.sh Executable file
View file

@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE_NAME="picoclaw"
TAG="${1:-latest}"
# ── version from git ─────────────────────────────────────────
if [[ "$TAG" == "latest" ]]; then
GIT_TAG="$(git describe --tags --always --dirty 2>/dev/null || echo "dev")"
else
GIT_TAG="$TAG"
fi
echo "==> Building Docker image: ${IMAGE_NAME}:${TAG}"
echo " Version: ${GIT_TAG}"
docker build \
-t "${IMAGE_NAME}:${TAG}" \
-t "${IMAGE_NAME}:${GIT_TAG}" \
.
echo ""
echo "Build complete:"
echo " ${IMAGE_NAME}:${TAG}"
echo " ${IMAGE_NAME}:${GIT_TAG}"
echo ""
echo "Run with:"
echo " docker run --rm ${IMAGE_NAME}:${TAG} version"
echo " docker run -v config.json:/home/picoclaw/.picoclaw/config.json:ro ${IMAGE_NAME}:${TAG} gateway"

60
scripts/install.sh Executable file
View file

@ -0,0 +1,60 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BINARY_NAME="picoclaw"
BUILD_DIR="build"
INSTALL_PREFIX="${HOME}/.local"
PICOCLAW_HOME="${HOME}/.picoclaw"
# ── parse args ───────────────────────────────────────────────
ACTION="install"
while [[ $# -gt 0 ]]; do
case "$1" in
--prefix) INSTALL_PREFIX="$2"; shift 2 ;;
--uninstall) ACTION="uninstall"; shift ;;
--uninstall-all) ACTION="uninstall-all"; shift ;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
INSTALL_BIN_DIR="${INSTALL_PREFIX}/bin"
# ── install ──────────────────────────────────────────────────
do_install() {
"${SCRIPT_DIR}/build.sh"
echo "Installing ${BINARY_NAME}..."
mkdir -p "$INSTALL_BIN_DIR"
# Atomic install: copy to temp, then rename
cp "${BUILD_DIR}/${BINARY_NAME}" "${INSTALL_BIN_DIR}/${BINARY_NAME}.new"
chmod +x "${INSTALL_BIN_DIR}/${BINARY_NAME}.new"
mv -f "${INSTALL_BIN_DIR}/${BINARY_NAME}.new" "${INSTALL_BIN_DIR}/${BINARY_NAME}"
echo "Installed to ${INSTALL_BIN_DIR}/${BINARY_NAME}"
}
# ── uninstall ────────────────────────────────────────────────
do_uninstall() {
echo "Uninstalling ${BINARY_NAME}..."
rm -f "${INSTALL_BIN_DIR}/${BINARY_NAME}"
echo "Removed binary from ${INSTALL_BIN_DIR}/${BINARY_NAME}"
echo "Note: config and workspace preserved. Use --uninstall-all to remove everything."
}
# ── uninstall-all ────────────────────────────────────────────
do_uninstall_all() {
do_uninstall
echo "Removing workspace and config..."
rm -rf "$PICOCLAW_HOME"
echo "Removed ${PICOCLAW_HOME}"
echo "Complete uninstallation done!"
}
# ── main ─────────────────────────────────────────────────────
case "$ACTION" in
install) do_install ;;
uninstall) do_uninstall ;;
uninstall-all) do_uninstall_all ;;
esac

101
scripts/setup.sh Executable file
View file

@ -0,0 +1,101 @@
#!/usr/bin/env bash
set -euo pipefail
REQUIRED_GO_MAJOR=1
REQUIRED_GO_MINOR=25
PICOCLAW_HOME="${HOME}/.picoclaw"
passed=0
failed=0
check() {
local name="$1" ok="$2" msg="$3"
if [[ "$ok" == "true" ]]; then
echo " [ok] ${name}: ${msg}"
passed=$((passed + 1))
else
echo " [!!] ${name}: ${msg}"
failed=$((failed + 1))
fi
}
echo "PicoClaw Environment Setup"
echo "=========================="
echo ""
# ── Go ───────────────────────────────────────────────────────
echo "Checking dependencies..."
if command -v go &>/dev/null; then
go_ver="$(go version | awk '{print $3}' | sed 's/go//')"
go_major="${go_ver%%.*}"
go_minor="${go_ver#*.}"
go_minor="${go_minor%%.*}"
if [[ "$go_major" -gt "$REQUIRED_GO_MAJOR" ]] || \
{ [[ "$go_major" -eq "$REQUIRED_GO_MAJOR" ]] && [[ "$go_minor" -ge "$REQUIRED_GO_MINOR" ]]; }; then
check "Go" "true" "go${go_ver}"
else
check "Go" "false" "go${go_ver} (need >= ${REQUIRED_GO_MAJOR}.${REQUIRED_GO_MINOR})"
fi
else
check "Go" "false" "not installed (https://go.dev/dl/)"
fi
# ── golangci-lint ────────────────────────────────────────────
if command -v golangci-lint &>/dev/null; then
lint_ver="$(golangci-lint version --format short 2>/dev/null || echo "unknown")"
check "golangci-lint" "true" "v${lint_ver}"
else
check "golangci-lint" "false" "not installed (https://golangci-lint.run/welcome/install/)"
fi
# ── git ──────────────────────────────────────────────────────
if command -v git &>/dev/null; then
git_ver="$(git --version | awk '{print $3}')"
check "git" "true" "${git_ver}"
else
check "git" "false" "not installed"
fi
# ── curl (for health checks) ────────────────────────────────
if command -v curl &>/dev/null; then
check "curl" "true" "available"
else
check "curl" "false" "not installed (needed for deploy health checks)"
fi
echo ""
# ── download dependencies ────────────────────────────────────
if command -v go &>/dev/null; then
echo "Downloading Go dependencies..."
go mod download
go mod verify
echo " Dependencies ready."
echo ""
fi
# ── picoclaw onboard ────────────────────────────────────────
if [[ ! -d "$PICOCLAW_HOME" ]]; then
if command -v picoclaw &>/dev/null; then
echo "Running picoclaw onboard..."
picoclaw onboard
echo ""
else
echo "Note: Run 'picoclaw onboard' after first install to initialize workspace."
echo ""
fi
else
echo "Workspace: ${PICOCLAW_HOME} (exists)"
echo ""
fi
# ── summary ──────────────────────────────────────────────────
echo "=========================="
if [[ "$failed" -eq 0 ]]; then
echo "All checks passed (${passed}/${passed})."
echo "Ready to build: scripts/build.sh"
else
echo "${passed} passed, ${failed} failed."
echo "Fix the issues above, then re-run this script."
fi