From 49237b3b599844ed390ca9eb69b7af51cfba229e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 03:31:09 +0000 Subject: [PATCH 01/17] feat: add command mode with /cmd, /pico, /hipico, /byepico commands Add three interactive modes to the CLI agent: - /cmd: switch to command mode for direct shell command execution - /pico: switch back to AI chat mode - /hipico : invoke AI assistance from within command mode - /byepico: end AI assistance and return to command mode Command mode supports cd with directory tracking, cross-platform shell execution (sh on Unix, PowerShell on Windows), and contextual AI assistance that knows the current working directory. https://claude.ai/code/session_01PoHBfe7eKmhHvVF12W3xZ2 --- cmd/picoclaw/cmd_agent.go | 296 +++++++++++++++++++++++++++++++++++--- 1 file changed, 280 insertions(+), 16 deletions(-) diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/cmd_agent.go index 8658c9d32..b67bfd734 100644 --- a/cmd/picoclaw/cmd_agent.go +++ b/cmd/picoclaw/cmd_agent.go @@ -5,11 +5,14 @@ package main import ( "bufio" + "bytes" "context" "fmt" "io" "os" + "os/exec" "path/filepath" + "runtime" "strings" "github.com/chzyer/readline" @@ -20,6 +23,20 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" ) +// Interactive mode identifiers +const ( + modePico = "pico" // Chat mode (default) - input goes to AI agent + 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. +var cmdWorkingDir string + +func init() { + cmdWorkingDir, _ = os.Getwd() +} + func agentCmd() { message := "" sessionKey := "cli:default" @@ -30,7 +47,7 @@ func agentCmd() { switch args[i] { case "--debug", "-d": logger.SetLevel(logger.DEBUG) - fmt.Println("πŸ” Debug mode enabled") + fmt.Println("Debug mode enabled") case "-m", "--message": if i+1 < len(args) { message = args[i+1] @@ -90,16 +107,25 @@ func agentCmd() { } fmt.Printf("\n%s %s\n", logo, response) } else { - fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo) + fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n", logo) + fmt.Println(" /cmd - switch to command mode") + fmt.Println(" /pico - switch to chat mode") + fmt.Println(" /hipico - AI assistance in command mode") + fmt.Println(" /byepico - end AI assistance") + fmt.Println() interactiveMode(agentLoop, sessionKey) } } func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { - prompt := fmt.Sprintf("%s You: ", logo) + chatPrompt := fmt.Sprintf("%s You: ", logo) + cmdPrompt := "$ " + hipicoPrompt := fmt.Sprintf("%s> ", logo) + + mode := modePico rl, err := readline.NewEx(&readline.Config{ - Prompt: prompt, + Prompt: chatPrompt, HistoryFile: filepath.Join(os.TempDir(), ".picoclaw_history"), HistoryLimit: 100, InterruptPrompt: "^C", @@ -113,6 +139,8 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { } defer rl.Close() + hipicoSessionKey := "cli:hipico" + for { line, err := rl.Readline() if err != nil { @@ -134,21 +162,101 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { return } - ctx := context.Background() - response, err := agentLoop.ProcessDirect(ctx, input, sessionKey) - if err != nil { - fmt.Printf("Error: %v\n", err) - continue - } + switch mode { + case modePico: + if input == "/cmd" { + mode = modeCmd + rl.SetPrompt(cmdPrompt) + fmt.Println("Switched to command mode. Type /pico to return to chat.") + continue + } + + ctx := context.Background() + response, err := agentLoop.ProcessDirect(ctx, input, sessionKey) + if err != nil { + fmt.Printf("Error: %v\n", err) + continue + } + fmt.Printf("\n%s %s\n\n", logo, response) + + case modeCmd: + if input == "/pico" { + mode = modePico + rl.SetPrompt(chatPrompt) + fmt.Println("Switched to chat mode. Type /cmd to return to command mode.") + continue + } + + if strings.HasPrefix(input, "/hipico") { + initialMsg := strings.TrimSpace(strings.TrimPrefix(input, "/hipico")) + if initialMsg == "" { + fmt.Println("Usage: /hipico ") + fmt.Println("Example: /hipico check the log files for error messages") + continue + } + + mode = modeHiPico + rl.SetPrompt(hipicoPrompt) + + 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() + response, err := agentLoop.ProcessDirect(ctx, contextPrefix+initialMsg, hipicoSessionKey) + if err != nil { + fmt.Printf("Error: %v\n", err) + mode = modeCmd + rl.SetPrompt(cmdPrompt) + continue + } + fmt.Printf("%s %s\n\n", logo, response) + continue + } + + executeShellCommand(input) - fmt.Printf("\n%s %s\n\n", logo, response) + 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) + } } } func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { reader := bufio.NewReader(os.Stdin) + mode := modePico + hipicoSessionKey := "cli:hipico" + for { - fmt.Printf("%s You: ", logo) + switch mode { + case modePico: + fmt.Printf("%s You: ", logo) + case modeCmd: + fmt.Print("$ ") + case modeHiPico: + fmt.Printf("%s> ", logo) + } + line, err := reader.ReadString('\n') if err != nil { if err == io.EOF { @@ -169,13 +277,169 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { return } - ctx := context.Background() - response, err := agentLoop.ProcessDirect(ctx, input, sessionKey) + switch mode { + case modePico: + if input == "/cmd" { + mode = modeCmd + fmt.Println("Switched to command mode. Type /pico to return to chat.") + continue + } + + ctx := context.Background() + response, err := agentLoop.ProcessDirect(ctx, input, sessionKey) + if err != nil { + fmt.Printf("Error: %v\n", err) + continue + } + fmt.Printf("\n%s %s\n\n", logo, response) + + case modeCmd: + if input == "/pico" { + mode = modePico + fmt.Println("Switched to chat mode. Type /cmd to return to command mode.") + continue + } + + if strings.HasPrefix(input, "/hipico") { + initialMsg := strings.TrimSpace(strings.TrimPrefix(input, "/hipico")) + if initialMsg == "" { + fmt.Println("Usage: /hipico ") + fmt.Println("Example: /hipico check the log files for error messages") + continue + } + + mode = modeHiPico + 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() + response, err := agentLoop.ProcessDirect(ctx, contextPrefix+initialMsg, hipicoSessionKey) + if err != nil { + fmt.Printf("Error: %v\n", err) + mode = modeCmd + continue + } + fmt.Printf("%s %s\n\n", logo, response) + continue + } + + 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) + } + } +} + +// executeShellCommand runs a shell command in the current working directory +// and prints the output. It also handles the cd command to change directories. +func executeShellCommand(input string) { + // Handle cd command specially to update working directory + if strings.HasPrefix(input, "cd ") || input == "cd" { + handleCd(input) + return + } + + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", input) + } else { + cmd = exec.Command("sh", "-c", input) + } + cmd.Dir = cmdWorkingDir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + + if stdout.Len() > 0 { + fmt.Print(stdout.String()) + if !strings.HasSuffix(stdout.String(), "\n") { + fmt.Println() + } + } + if stderr.Len() > 0 { + fmt.Print(stderr.String()) + if !strings.HasSuffix(stderr.String(), "\n") { + fmt.Println() + } + } + + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + fmt.Printf("Exit code: %d\n", exitErr.ExitCode()) + } else { + fmt.Printf("Error: %v\n", err) + } + } +} + +// handleCd handles the cd command to change the working directory for command mode. +func handleCd(input string) { + parts := strings.Fields(input) + var target string + + if len(parts) < 2 { + home, err := os.UserHomeDir() if err != nil { fmt.Printf("Error: %v\n", err) - continue + return } + target = home + } else { + target = parts[1] + } - fmt.Printf("\n%s %s\n\n", logo, response) + // Handle ~ expansion + if strings.HasPrefix(target, "~") { + home, err := os.UserHomeDir() + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + if target == "~" { + target = home + } else if len(target) > 1 && target[1] == '/' { + target = filepath.Join(home, target[2:]) + } + } + + // Handle relative paths + if !filepath.IsAbs(target) { + target = filepath.Join(cmdWorkingDir, target) } + + target = filepath.Clean(target) + + info, err := os.Stat(target) + if err != nil { + fmt.Printf("cd: %v\n", err) + return + } + if !info.IsDir() { + fmt.Printf("cd: %s: Not a directory\n", target) + return + } + + cmdWorkingDir = target } From cad9d820bf038dfaba8fd4e0ffd0ed91ef8e457b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 04:48:47 +0000 Subject: [PATCH 02/17] feat: add /help command available in all interactive modes Adds a printHelp() function that outputs detailed usage information covering all three modes (chat, command, AI-assisted), available commands, mode switching, and examples. /help is intercepted before the mode-specific switch block so it works in any mode. https://claude.ai/code/session_01PoHBfe7eKmhHvVF12W3xZ2 --- cmd/picoclaw/cmd_agent.go | 61 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/cmd_agent.go index b67bfd734..854d23709 100644 --- a/cmd/picoclaw/cmd_agent.go +++ b/cmd/picoclaw/cmd_agent.go @@ -108,6 +108,7 @@ func agentCmd() { fmt.Printf("\n%s %s\n", logo, response) } else { fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n", logo) + fmt.Println(" /help - show detailed help") fmt.Println(" /cmd - switch to command mode") fmt.Println(" /pico - switch to chat mode") fmt.Println(" /hipico - AI assistance in command mode") @@ -162,6 +163,12 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { return } + // /help works in all modes + if input == "/help" { + printHelp() + continue + } + switch mode { case modePico: if input == "/cmd" { @@ -277,6 +284,12 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { return } + // /help works in all modes + if input == "/help" { + printHelp() + continue + } + switch mode { case modePico: if input == "/cmd" { @@ -349,6 +362,54 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { } } +// printHelp outputs detailed usage information for all interactive modes. +func printHelp() { + fmt.Printf(`%s PicoClaw Interactive Mode Help +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +PicoClaw has three interactive modes: + + 1. Chat Mode (default) + Talk to the AI agent directly. Your input is sent as a message + and the AI responds. + + 2. Command Mode + Execute shell commands directly, like a terminal. Supports cd, + pipes, redirects, and all standard shell features. + + 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): + /help Show this help message + exit Exit PicoClaw + quit Exit PicoClaw + Ctrl+C Exit PicoClaw + +Mode switching: + /cmd Switch to command mode (from chat mode) + /pico Switch to chat mode (from command / AI-assisted mode) + /hipico Start AI-assisted mode (from command mode) + /byepico End AI assistance (from AI-assisted mode) + +Examples: + Chat mode: + %s You: What is the weather today? + + Command mode: + $ ls -al /var/log + $ cd /tmp + $ cat error.log | grep "FATAL" + + AI-assisted mode (enter from command mode): + $ /hipico check the log files for errors + %s> show me more details on line 42 + %s> /byepico + +`, logo, logo, logo, logo) +} + // executeShellCommand runs a shell command in the current working directory // and prints the output. It also handles the cd command to change directories. func executeShellCommand(input string) { From 5f3fc4c133b289dfa1e203d73ada27c0a8e49a3a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Feb 2026 06:03:19 +0000 Subject: [PATCH 03/17] feat: add /usage command to show model info and token usage Adds token usage tracking to AgentInstance using atomic counters (TotalPromptTokens, TotalCompletionTokens, TotalRequests) that accumulate after each LLM call. The /usage command displays current model name, max tokens, temperature, and session token statistics. Available in all interactive modes like /help. https://claude.ai/code/session_01PoHBfe7eKmhHvVF12W3xZ2 --- cmd/picoclaw/cmd_agent.go | 43 +++++++++++++++++++++++++++++++++++++-- pkg/agent/instance.go | 16 +++++++++++++++ pkg/agent/loop.go | 22 ++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/cmd_agent.go index 854d23709..58a63cc99 100644 --- a/cmd/picoclaw/cmd_agent.go +++ b/cmd/picoclaw/cmd_agent.go @@ -109,6 +109,7 @@ func agentCmd() { } else { fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n", logo) fmt.Println(" /help - show detailed help") + fmt.Println(" /usage - show model info and token usage") fmt.Println(" /cmd - switch to command mode") fmt.Println(" /pico - switch to chat mode") fmt.Println(" /hipico - AI assistance in command mode") @@ -163,11 +164,15 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { return } - // /help works in all modes + // /help and /usage work in all modes if input == "/help" { printHelp() continue } + if input == "/usage" { + printUsage(agentLoop) + continue + } switch mode { case modePico: @@ -284,11 +289,15 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { return } - // /help works in all modes + // /help and /usage work in all modes if input == "/help" { printHelp() continue } + if input == "/usage" { + printUsage(agentLoop) + continue + } switch mode { case modePico: @@ -383,6 +392,7 @@ PicoClaw has three interactive modes: Commands (available in all modes): /help Show this help message + /usage Show model info and token usage exit Exit PicoClaw quit Exit PicoClaw Ctrl+C Exit PicoClaw @@ -410,6 +420,35 @@ Examples: `, logo, logo, logo, logo) } +// printUsage displays current model information and accumulated token usage. +func printUsage(agentLoop *agent.AgentLoop) { + info := agentLoop.GetUsageInfo() + if info == nil { + fmt.Println("No usage information available.") + return + } + fmt.Printf(`%s Usage +━━━━━━━━━━━━━━━━━━━━━━ +Model: %s +Max tokens: %d +Temperature: %.1f + +Token usage (this session): + Prompt tokens: %d + Completion tokens:%d + Total tokens: %d + Requests: %d +`, logo, + info["model"], + info["max_tokens"], + info["temperature"], + info["prompt_tokens"], + info["completion_tokens"], + info["total_tokens"], + info["requests"], + ) +} + // executeShellCommand runs a shell command in the current working directory // and prints the output. It also handles the cd command to change directories. func executeShellCommand(input string) { diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index dfbef9fbc..4ce7ad23a 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" @@ -31,6 +32,21 @@ type AgentInstance struct { Subagents *config.SubagentsConfig SkillsFilter []string Candidates []providers.FallbackCandidate + + // Accumulated token usage counters (atomic for concurrent safety) + TotalPromptTokens atomic.Int64 + TotalCompletionTokens atomic.Int64 + TotalRequests atomic.Int64 +} + +// AddUsage accumulates token usage from a single LLM response. +func (a *AgentInstance) AddUsage(usage *providers.UsageInfo) { + if usage == nil { + return + } + a.TotalPromptTokens.Add(int64(usage.PromptTokens)) + a.TotalCompletionTokens.Add(int64(usage.CompletionTokens)) + a.TotalRequests.Add(1) } // NewAgentInstance creates an agent instance from config. diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index bf229ad74..edcc04602 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -594,6 +594,9 @@ func (al *AgentLoop) runLLMIteration( return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err) } + // Accumulate token usage + agent.AddUsage(response.Usage) + // Check if no tool calls - we're done if len(response.ToolCalls) == 0 { finalContent = response.Content @@ -852,6 +855,25 @@ func (al *AgentLoop) GetStartupInfo() map[string]any { return info } +// GetUsageInfo returns accumulated token usage for the default agent. +func (al *AgentLoop) GetUsageInfo() map[string]any { + agent := al.registry.GetDefaultAgent() + if agent == nil { + return nil + } + promptTokens := agent.TotalPromptTokens.Load() + completionTokens := agent.TotalCompletionTokens.Load() + return map[string]any{ + "model": agent.Model, + "max_tokens": agent.MaxTokens, + "temperature": agent.Temperature, + "prompt_tokens": promptTokens, + "completion_tokens": completionTokens, + "total_tokens": promptTokens + completionTokens, + "requests": agent.TotalRequests.Load(), + } +} + // formatMessagesForLog formats messages for logging func formatMessagesForLog(messages []providers.Message) string { if len(messages) == 0 { From f09be8f16ea69d1b305ea87db5962ed8f5f7b05d Mon Sep 17 00:00:00 2001 From: Orlando Chen Date: Tue, 24 Feb 2026 15:08:23 +0900 Subject: [PATCH 04/17] refactor: rename printHelp to printCliHelp and printInteractiveHelp Resolve function name collision between the top-level CLI help and the interactive mode help by giving each a distinct, descriptive name. Co-Authored-By: Claude Opus 4.6 --- cmd/picoclaw/cmd_agent.go | 8 ++++---- cmd/picoclaw/main.go | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/cmd_agent.go index 58a63cc99..bb3a6fed2 100644 --- a/cmd/picoclaw/cmd_agent.go +++ b/cmd/picoclaw/cmd_agent.go @@ -166,7 +166,7 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { // /help and /usage work in all modes if input == "/help" { - printHelp() + printInteractiveHelp() continue } if input == "/usage" { @@ -291,7 +291,7 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { // /help and /usage work in all modes if input == "/help" { - printHelp() + printInteractiveHelp() continue } if input == "/usage" { @@ -371,8 +371,8 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { } } -// printHelp outputs detailed usage information for all interactive modes. -func printHelp() { +// printInteractiveHelp outputs detailed usage information for all interactive modes. +func printInteractiveHelp() { fmt.Printf(`%s PicoClaw Interactive Mode Help ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 25ad701ca..96406858f 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -94,7 +94,7 @@ func copyDirectory(src, dst string) error { func main() { if len(os.Args) < 2 { - printHelp() + printCliHelp() os.Exit(1) } @@ -168,12 +168,12 @@ func main() { printVersion() default: fmt.Printf("Unknown command: %s\n", command) - printHelp() + printCliHelp() os.Exit(1) } } -func printHelp() { +func printCliHelp() { fmt.Printf("%s picoclaw - Personal AI Assistant v%s\n\n", logo, version) fmt.Println("Usage: picoclaw ") fmt.Println() From d9ae60b755dcbe4b7ffa487b01271e9c69eb8e5e Mon Sep 17 00:00:00 2001 From: Orlando Chen Date: Tue, 24 Feb 2026 16:39:34 +0900 Subject: [PATCH 05/17] 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 --- .github/workflows/build.yml | 2 +- .gitignore | 34 +- Dockerfile | 4 +- Makefile | 189 ---------- cmd/picoclaw/cmd_agent.go | 148 +++----- pkg/agent/loop.go | 680 +++++++++++++++++++++++++++++++++++- scripts/build.sh | 89 +++++ scripts/check.sh | 56 +++ scripts/deploy.sh | 67 ++++ scripts/docker.sh | 29 ++ scripts/install.sh | 60 ++++ scripts/setup.sh | 101 ++++++ 12 files changed, 1125 insertions(+), 334 deletions(-) delete mode 100644 Makefile create mode 100755 scripts/build.sh create mode 100755 scripts/check.sh create mode 100755 scripts/deploy.sh create mode 100755 scripts/docker.sh create mode 100755 scripts/install.sh create mode 100755 scripts/setup.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9b89b69ae..68f6e6a27 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,4 +17,4 @@ jobs: go-version-file: go.mod - name: Build - run: make build-all + run: scripts/build.sh --all diff --git a/.gitignore b/.gitignore index ce30d749e..5b4299d13 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ -# Binaries -# Go build artifacts +# Binaries & build artifacts bin/ build/ +dist/ *.exe *.dll *.so @@ -10,37 +10,33 @@ build/ *.out /picoclaw /picoclaw-test -cmd/picoclaw/workspace - -# Picoclaw specific -# PicoClaw +# PicoClaw workspace & config .picoclaw/ config.json sessions/ -build/ - -# Coverage +cmd/picoclaw/workspace -# Secrets & Config (keep templates, ignore actual secrets) +# Secrets .env config/config.json -# Test +# Coverage coverage.txt coverage.html # OS .DS_Store -# Ralph workspace -ralph/ -.ralph/ -tasks/ - -# Editors +# Editors & tools .vscode/ .idea/ +.claude/ -# Added by goreleaser init: -dist/ +# Task tracking +TASKS.md + +# Legacy +ralph/ +.ralph/ +tasks/ diff --git a/Dockerfile b/Dockerfile index 480244127..0ab709ae6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # ============================================================ FROM golang:1.25-alpine AS builder -RUN apk add --no-cache git make +RUN apk add --no-cache git bash WORKDIR /src @@ -13,7 +13,7 @@ RUN go mod download # Copy source and build COPY . . -RUN make build +RUN scripts/build.sh # ============================================================ # Stage 2: Minimal runtime image diff --git a/Makefile b/Makefile deleted file mode 100644 index 29e2fc964..000000000 --- a/Makefile +++ /dev/null @@ -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)" diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/cmd_agent.go index bb3a6fed2..5288a3687 100644 --- a/cmd/picoclaw/cmd_agent.go +++ b/cmd/picoclaw/cmd_agent.go @@ -25,9 +25,8 @@ import ( // Interactive mode identifiers const ( - modePico = "pico" // Chat mode (default) - input goes to AI agent - modeCmd = "cmd" // Command mode - input executed as shell commands - modeHiPico = "hipico" // AI-assisted mode within cmd - multi-turn AI conversation + modePico = "pico" // Chat mode (default) - input goes to AI agent + modeCmd = "cmd" // Command mode - input executed as shell commands ) // cmdWorkingDir tracks the current working directory for command mode. @@ -108,12 +107,11 @@ func agentCmd() { fmt.Printf("\n%s %s\n", logo, response) } else { fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n", logo) - fmt.Println(" /help - show detailed help") - fmt.Println(" /usage - show model info and token usage") - fmt.Println(" /cmd - switch to command mode") - fmt.Println(" /pico - switch to chat mode") - fmt.Println(" /hipico - AI assistance in command mode") - fmt.Println(" /byepico - end AI assistance") + fmt.Println(" :help - show detailed help") + fmt.Println(" :usage - show model info and token usage") + fmt.Println(" :cmd - switch to command mode") + fmt.Println(" :pico - switch to chat mode") + fmt.Println(" :hipico - ask AI for help (from command mode)") fmt.Println() interactiveMode(agentLoop, sessionKey) } @@ -122,7 +120,6 @@ func agentCmd() { func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { chatPrompt := fmt.Sprintf("%s You: ", logo) cmdPrompt := "$ " - hipicoPrompt := fmt.Sprintf("%s> ", logo) mode := modePico @@ -164,22 +161,22 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { return } - // /help and /usage work in all modes - if input == "/help" { + // :help and :usage work in all modes + if input == ":help" { printInteractiveHelp() continue } - if input == "/usage" { + if input == ":usage" { printUsage(agentLoop) continue } switch mode { case modePico: - if input == "/cmd" { + if input == ":cmd" { mode = modeCmd 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 } @@ -192,64 +189,34 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { fmt.Printf("\n%s %s\n\n", logo, response) case modeCmd: - if input == "/pico" { + if input == ":pico" { mode = modePico 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 } - if strings.HasPrefix(input, "/hipico") { - initialMsg := strings.TrimSpace(strings.TrimPrefix(input, "/hipico")) + if strings.HasPrefix(input, ":hipico") { + initialMsg := strings.TrimSpace(strings.TrimPrefix(input, ":hipico")) if initialMsg == "" { - fmt.Println("Usage: /hipico ") - fmt.Println("Example: /hipico check the log files for error messages") + fmt.Println("Usage: :hipico ") + fmt.Println("Example: :hipico check the log files for error messages") continue } - mode = modeHiPico - rl.SetPrompt(hipicoPrompt) - 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() response, err := agentLoop.ProcessDirect(ctx, contextPrefix+initialMsg, hipicoSessionKey) if err != nil { fmt.Printf("Error: %v\n", err) - mode = modeCmd - rl.SetPrompt(cmdPrompt) continue } - fmt.Printf("%s %s\n\n", logo, response) + fmt.Printf("\n%s %s\n\n", logo, response) continue } 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) case modeCmd: fmt.Print("$ ") - case modeHiPico: - fmt.Printf("%s> ", logo) } line, err := reader.ReadString('\n') @@ -289,21 +254,21 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { return } - // /help and /usage work in all modes - if input == "/help" { + // :help and :usage work in all modes + if input == ":help" { printInteractiveHelp() continue } - if input == "/usage" { + if input == ":usage" { printUsage(agentLoop) continue } switch mode { case modePico: - if input == "/cmd" { + if input == ":cmd" { 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 } @@ -316,57 +281,33 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { fmt.Printf("\n%s %s\n\n", logo, response) case modeCmd: - if input == "/pico" { + if input == ":pico" { 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 } - if strings.HasPrefix(input, "/hipico") { - initialMsg := strings.TrimSpace(strings.TrimPrefix(input, "/hipico")) + if strings.HasPrefix(input, ":hipico") { + initialMsg := strings.TrimSpace(strings.TrimPrefix(input, ":hipico")) if initialMsg == "" { - fmt.Println("Usage: /hipico ") - fmt.Println("Example: /hipico check the log files for error messages") + fmt.Println("Usage: :hipico ") + fmt.Println("Example: :hipico check the log files for error messages") continue } - mode = modeHiPico 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() response, err := agentLoop.ProcessDirect(ctx, contextPrefix+initialMsg, hipicoSessionKey) if err != nil { fmt.Printf("Error: %v\n", err) - mode = modeCmd continue } - fmt.Printf("%s %s\n\n", logo, response) + fmt.Printf("\n%s %s\n\n", logo, response) continue } 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 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -PicoClaw has three interactive modes: +PicoClaw has two interactive modes: 1. Chat Mode (default) 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 Execute shell commands directly, like a terminal. Supports cd, pipes, redirects, and all standard shell features. - - 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. + Use :hipico to ask AI for one-shot help within command mode. Commands (available in all modes): - /help Show this help message - /usage Show model info and token usage + :help Show this help message + :usage Show model info and token usage exit Exit PicoClaw quit Exit PicoClaw Ctrl+C Exit PicoClaw Mode switching: - /cmd Switch to command mode (from chat mode) - /pico Switch to chat mode (from command / AI-assisted mode) - /hipico Start AI-assisted mode (from command mode) - /byepico End AI assistance (from AI-assisted mode) + :cmd Switch to command mode (from chat mode) + :pico Switch to chat mode (from command mode) + :hipico Ask AI for help (from command mode, one-shot) Examples: Chat mode: @@ -412,12 +349,11 @@ Examples: $ cd /tmp $ cat error.log | grep "FATAL" - AI-assisted mode (enter from command mode): - $ /hipico check the log files for errors - %s> show me more details on line 42 - %s> /byepico + AI help (one-shot, from command mode): + $ :hipico check the log files for errors + $ :hipico what does this error mean in syslog -`, logo, logo, logo, logo) +`, logo, logo) } // printUsage displays current model information and accumulated token usage. diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index edcc04602..cccc10025 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -10,6 +10,9 @@ import ( "context" "encoding/json" "fmt" + "os" + "path/filepath" + "strconv" "strings" "sync" "sync/atomic" @@ -29,15 +32,25 @@ import ( "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 { - bus *bus.MessageBus - cfg *config.Config - registry *AgentRegistry - state *state.Manager - running atomic.Bool - summarizing sync.Map - fallback *providers.FallbackChain - channelManager *channels.Manager + bus *bus.MessageBus + cfg *config.Config + registry *AgentRegistry + state *state.Manager + running atomic.Bool + summarizing sync.Map + fallback *providers.FallbackChain + 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 @@ -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). func registerSharedTools( cfg *config.Config, @@ -323,15 +358,49 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) "matched_by": route.MatchedBy, }) - 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, - }) + // Handle mode-switching commands (:cmd, :pico, :hipico) + content := strings.TrimSpace(msg.Content) + if strings.HasPrefix(content, ":") { + if response, handled := al.handleModeCommand(content, sessionKey, agent); handled { + return response, nil + } + // :hipico falls through here β€” one-shot LLM call, stays in modeCmd + 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) { @@ -1056,6 +1125,12 @@ func (al *AgentLoop) estimateTokens(messages []providers.Message) int { func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) { 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, "/") { return "", false } @@ -1144,6 +1219,577 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) 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 - Ask AI for help (from command mode, one-shot) +:edit - View/edit files (cmd mode) +/show [model|channel|agents] - Show current configuration +/list [models|channels|agents] - List available options +/switch [model|channel] to - 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 \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 β†’ show file with line numbers +// :edit β†’ replace line N +// :edit + β†’ insert after line N +// :edit - β†’ delete line N +// :edit -m """""" β†’ 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 β€” show file content + if len(parts) == 1 && !strings.Contains(raw, "\n") { + return editShowFile(filename) + } + + // :edit -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 β€” view file\n" + + " :edit β€” replace line N\n" + + " :edit + β€” insert after line N\n" + + " :edit - β€” delete line N\n" + + " :edit -m \"\"\" β€” write content\n" + + " \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 = ` -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 " + } + 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 + " + } + 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 β€” view file\n"+ + ":edit -m \"\"\" β€” write content\n"+ + "\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. func extractPeer(msg bus.InboundMessage) *routing.RoutePeer { peerKind := msg.Metadata["peer_kind"] diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 000000000..f894530c5 --- /dev/null +++ b/scripts/build.sh @@ -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 diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 000000000..af02c11e9 --- /dev/null +++ b/scripts/check.sh @@ -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 diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 000000000..2d01dfd63 --- /dev/null +++ b/scripts/deploy.sh @@ -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 diff --git a/scripts/docker.sh b/scripts/docker.sh new file mode 100755 index 000000000..8fe07e4b6 --- /dev/null +++ b/scripts/docker.sh @@ -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" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 000000000..05b8df5f9 --- /dev/null +++ b/scripts/install.sh @@ -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 diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 000000000..1ccccc36b --- /dev/null +++ b/scripts/setup.sh @@ -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 From a9f1d0eb67122c1f42e77188f8272b66cca5e76b Mon Sep 17 00:00:00 2001 From: Orlando Chen Date: Tue, 24 Feb 2026 17:34:10 +0900 Subject: [PATCH 06/17] feat: inject cmd working directory into system prompt for hipico mode When using :hipico from cmd mode after cd'ing to a subdirectory, the AI now sees the current working directory in the system prompt instead of just a weak user-message prefix. This ensures the AI resolves file paths relative to the cmd working directory, not the workspace root. Co-Authored-By: Claude Opus 4.6 --- cmd/picoclaw/cmd_agent.go | 8 ++------ pkg/agent/loop.go | 27 ++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/cmd_agent.go index 5288a3687..21001e58d 100644 --- a/cmd/picoclaw/cmd_agent.go +++ b/cmd/picoclaw/cmd_agent.go @@ -204,10 +204,8 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { continue } - contextPrefix := fmt.Sprintf("[Command mode context: working directory is %s]\n\n", cmdWorkingDir) - ctx := context.Background() - response, err := agentLoop.ProcessDirect(ctx, contextPrefix+initialMsg, hipicoSessionKey) + response, err := agentLoop.ProcessDirectWithWorkDir(ctx, initialMsg, hipicoSessionKey, cmdWorkingDir) if err != nil { fmt.Printf("Error: %v\n", err) continue @@ -295,10 +293,8 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { continue } - contextPrefix := fmt.Sprintf("[Command mode context: working directory is %s]\n\n", cmdWorkingDir) - ctx := context.Background() - response, err := agentLoop.ProcessDirect(ctx, contextPrefix+initialMsg, hipicoSessionKey) + response, err := agentLoop.ProcessDirectWithWorkDir(ctx, initialMsg, hipicoSessionKey, cmdWorkingDir) if err != nil { fmt.Printf("Error: %v\n", err) continue diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index be4393510..761c4d60c 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -63,6 +63,7 @@ type processOptions struct { EnableSummary bool // Whether to trigger summarization SendResponse bool // Whether to send response via bus NoHistory bool // If true, don't load session history (for heartbeat) + WorkingDir string // Current working directory override (for hipico from cmd mode) } func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop { @@ -273,6 +274,20 @@ func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey stri return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct") } +// ProcessDirectWithWorkDir processes a message with an explicit working directory context. +// The workDir is injected into the system prompt so the AI resolves file paths relative to it. +func (al *AgentLoop) ProcessDirectWithWorkDir(ctx context.Context, content, sessionKey, workDir string) (string, error) { + msg := bus.InboundMessage{ + Channel: "cli", + SenderID: "cron", + ChatID: "direct", + Content: content, + SessionKey: sessionKey, + Metadata: map[string]string{"work_dir": workDir}, + } + return al.processMessage(ctx, msg) +} + func (al *AgentLoop) ProcessDirectWithChannel( ctx context.Context, content, sessionKey, channel, chatID string, @@ -371,7 +386,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) 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, @@ -381,6 +395,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) DefaultResponse: "I've completed processing but have no response to give.", EnableSummary: false, SendResponse: false, + WorkingDir: workDir, }) } } @@ -399,6 +414,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) DefaultResponse: "I've completed processing but have no response to give.", EnableSummary: true, SendResponse: false, + WorkingDir: msg.Metadata["work_dir"], }) } } @@ -491,6 +507,15 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt opts.ChatID, ) + // 2b. Inject current working directory into system prompt if set + if opts.WorkingDir != "" && len(messages) > 0 && messages[0].Role == "system" { + messages[0].Content += fmt.Sprintf( + "\n\n## Current Working Directory\nThe user is currently working in: %s\n"+ + "When the user refers to files or directories, resolve them relative to this path, not the workspace root.", + opts.WorkingDir, + ) + } + // 3. Save user message to session agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) From 18193c69b00957adc48b4178a53e4a75ccbeb625 Mon Sep 17 00:00:00 2001 From: Orlando Chen Date: Wed, 25 Feb 2026 09:24:43 +0900 Subject: [PATCH 07/17] revert: restore Makefile and remove scripts/ directory Revert the build system back to Makefile, removing the shell scripts introduced in d9ae60b. Co-Authored-By: Claude Opus 4.6 --- Makefile | 193 +++++++++++++++++++++++++++++++++++++++++++++ scripts/build.sh | 89 --------------------- scripts/check.sh | 56 ------------- scripts/deploy.sh | 67 ---------------- scripts/docker.sh | 29 ------- scripts/install.sh | 60 -------------- scripts/setup.sh | 101 ------------------------ 7 files changed, 193 insertions(+), 402 deletions(-) create mode 100644 Makefile delete mode 100755 scripts/build.sh delete mode 100755 scripts/check.sh delete mode 100755 scripts/deploy.sh delete mode 100755 scripts/docker.sh delete mode 100755 scripts/install.sh delete mode 100755 scripts/setup.sh diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..576152f40 --- /dev/null +++ b/Makefile @@ -0,0 +1,193 @@ +.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?=CGO_ENABLED=0 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 + +## fix: Fix linting issues +fix: + @$(GOLANGCI_LINT) run --fix + +## 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) | sort | awk -F': ' '{printf " %-16s %s\n", substr($$1, 4), $$2}' + @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)" diff --git a/scripts/build.sh b/scripts/build.sh deleted file mode 100755 index f894530c5..000000000 --- a/scripts/build.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/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 diff --git a/scripts/check.sh b/scripts/check.sh deleted file mode 100755 index af02c11e9..000000000 --- a/scripts/check.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/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 diff --git a/scripts/deploy.sh b/scripts/deploy.sh deleted file mode 100755 index 2d01dfd63..000000000 --- a/scripts/deploy.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/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 diff --git a/scripts/docker.sh b/scripts/docker.sh deleted file mode 100755 index 8fe07e4b6..000000000 --- a/scripts/docker.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/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" diff --git a/scripts/install.sh b/scripts/install.sh deleted file mode 100755 index 05b8df5f9..000000000 --- a/scripts/install.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/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 diff --git a/scripts/setup.sh b/scripts/setup.sh deleted file mode 100755 index 1ccccc36b..000000000 --- a/scripts/setup.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/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 From 30fd9bf82db7839764e22ca407846a60b6fc320f Mon Sep 17 00:00:00 2001 From: Orlando Chen Date: Wed, 25 Feb 2026 09:30:34 +0900 Subject: [PATCH 08/17] fix: restore Makefile usage in build.yml and Dockerfile Update CI and Docker build to use `make` instead of removed scripts/. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build.yml | 2 +- Dockerfile | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 68f6e6a27..9b89b69ae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,4 +17,4 @@ jobs: go-version-file: go.mod - name: Build - run: scripts/build.sh --all + run: make build-all diff --git a/Dockerfile b/Dockerfile index 0ab709ae6..480244127 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # ============================================================ FROM golang:1.25-alpine AS builder -RUN apk add --no-cache git bash +RUN apk add --no-cache git make WORKDIR /src @@ -13,7 +13,7 @@ RUN go mod download # Copy source and build COPY . . -RUN scripts/build.sh +RUN make build # ============================================================ # Stage 2: Minimal runtime image From 3fe492c364e3a5b9c165f2be12bb3b930c428dbc Mon Sep 17 00:00:00 2001 From: Orlando Chen Date: Wed, 25 Feb 2026 12:40:51 +0900 Subject: [PATCH 09/17] fix: harden cmd mode cd redirection and allow ./executable in guard - handleCdCommand: redirect cd, cd ~, cd /, cd /path to workspace directory instead of $HOME or system root for safety - guardCommand: fix path regex false positive that extracted "/exe" from "./exe.sh" and blocked it as absolute path outside workspace Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 19 ++++++++++++------- pkg/tools/shell.go | 11 +++++++++-- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 531c53548..0aac2fc86 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1400,25 +1400,30 @@ func (al *AgentLoop) executeCmdMode(ctx context.Context, agent *AgentInstance, c } // handleCdCommand handles the cd command in command mode, updating per-session working directory. +// Special paths (cd, cd ~, cd /, cd /xxx) are redirected to the workspace directory for safety. func (al *AgentLoop) handleCdCommand(content, sessionKey string, agent *AgentInstance) string { parts := strings.Fields(content) + workspace := agent.Workspace var target string - if len(parts) < 2 || parts[1] == "~" { - home, _ := os.UserHomeDir() - target = home + if len(parts) < 2 || parts[1] == "~" || parts[1] == "/" { + // cd, cd ~, cd / β†’ always go to workspace + target = workspace } else { target = parts[1] - // Expand ~ prefix + // Expand ~ prefix: treat ~ as workspace root (not $HOME) if strings.HasPrefix(target, "~/") { - home, _ := os.UserHomeDir() - target = home + target[1:] + target = workspace + target[1:] + } + // Absolute paths (e.g. cd /etc) β†’ redirect to workspace + if filepath.IsAbs(target) { + target = workspace } // Resolve relative paths if !filepath.IsAbs(target) { currentDir := al.getSessionWorkDir(sessionKey) if currentDir == "" { - currentDir = agent.Workspace + currentDir = workspace } target = filepath.Join(currentDir, target) } diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 6883172cd..c578e571e 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -290,9 +290,16 @@ func (t *ExecTool) guardCommand(command, cwd string) string { } pathPattern := regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`) - matches := pathPattern.FindAllString(cmd, -1) + matchIndices := pathPattern.FindAllStringIndex(cmd, -1) + + for _, loc := range matchIndices { + raw := cmd[loc[0]:loc[1]] + // Skip relative paths like ./executable β€” the regex extracts + // "/executable" from "./executable" but it's not an absolute path. + if loc[0] > 0 && cmd[loc[0]-1] == '.' { + continue + } - for _, raw := range matches { p, err := filepath.Abs(raw) if err != nil { continue From 9bf114eeea90221d8882413acb31b13d9d6c574b Mon Sep 17 00:00:00 2001 From: Orlando Chen Date: Thu, 26 Feb 2026 16:12:07 +0900 Subject: [PATCH 10/17] fix: enforce workspace restriction in :edit command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export validatePath β†’ ValidatePath for cross-package use - Rewrite resolveEditPath to validate paths via tools.ValidatePath, blocking absolute paths, symlink escape, and path traversal - Map ~ to workspace root (not $HOME) for consistency with cd - Return access denied error for paths outside workspace Security: previously :edit /etc/passwd or :edit ~/.ssh/id_rsa could read/write arbitrary files. Now all :edit paths are sandboxed to the agent workspace. Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 27 +++++++++++++++++---------- pkg/tools/filesystem.go | 4 ++-- pkg/tools/shell.go | 2 +- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index e99244d01..af09b0086 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1353,7 +1353,7 @@ func (al *AgentLoop) executeCmdMode(ctx context.Context, agent *AgentInstance, c if workDir == "" { workDir = agent.Workspace } - return al.handleEditCommand(content, workDir), nil + return al.handleEditCommand(content, workDir, agent.Workspace), nil } // Intercept interactive editors @@ -1462,7 +1462,7 @@ func shortenHomePath(path string) string { // :edit + β†’ insert after line N // :edit - β†’ delete line N // :edit -m """""" β†’ write full content (create if needed) -func (al *AgentLoop) handleEditCommand(content, workDir string) string { +func (al *AgentLoop) handleEditCommand(content, workDir, workspace string) string { raw := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(content), ":edit")) if raw == "" { return editUsage() @@ -1479,7 +1479,10 @@ func (al *AgentLoop) handleEditCommand(content, workDir string) string { return editUsage() } - filename := resolveEditPath(parts[0], workDir) + filename, err := resolveEditPath(parts[0], workDir, workspace) + if err != nil { + return fmt.Sprintf("Access denied: %s", err) + } // :edit β€” show file content if len(parts) == 1 && !strings.Contains(raw, "\n") { @@ -1501,15 +1504,19 @@ func (al *AgentLoop) handleEditCommand(content, workDir string) string { return editUsage() } -func resolveEditPath(name, workDir string) string { - if strings.HasPrefix(name, "~/") { - home, _ := os.UserHomeDir() - return home + name[1:] +func resolveEditPath(name, workDir, workspace string) (string, error) { + // Treat ~ as workspace root (not $HOME) + if name == "~" { + name = "." + } else if strings.HasPrefix(name, "~/") { + name = name[2:] } - if filepath.IsAbs(name) { - return name + // Resolve relative paths against workDir + if !filepath.IsAbs(name) { + name = filepath.Join(workDir, name) } - return filepath.Join(workDir, name) + // Validate against workspace (blocks absolute paths outside workspace, symlink escape, traversal) + return tools.ValidatePath(name, workspace, true) } func editUsage() string { diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index 37db8b4ae..76d6f91e9 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -10,8 +10,8 @@ import ( "time" ) -// validatePath ensures the given path is within the workspace if restrict is true. -func validatePath(path, workspace string, restrict bool) (string, error) { +// ValidatePath ensures the given path is within the workspace if restrict is true. +func ValidatePath(path, workspace string, restrict bool) (string, error) { if workspace == "" { return path, fmt.Errorf("workspace is not defined") } diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 9acf95d69..e4e1bef9b 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -143,7 +143,7 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult cwd := t.workingDir if wd, ok := args["working_dir"].(string); ok && wd != "" { if t.restrictToWorkspace && t.workingDir != "" { - resolvedWD, err := validatePath(wd, t.workingDir, true) + resolvedWD, err := ValidatePath(wd, t.workingDir, true) if err != nil { return ErrorResult("Command blocked by safety guard (" + err.Error() + ")") } From e268735f3dd6e6b7c5b31a8b4c6060857a5c56cf Mon Sep 17 00:00:00 2001 From: Orlando Chen Date: Thu, 26 Feb 2026 16:13:03 +0900 Subject: [PATCH 11/17] fix: stop intercepting non-command : prefixed messages Change handleExtensionCommand default case to pass through unrecognized : prefixed messages as normal chat. Previously :) :D :thinking: etc. would return "Unknown command" across all channels (Telegram, Discord). Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index af09b0086..291c2aa5a 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1292,7 +1292,9 @@ Token usage (this session): ), true default: - return fmt.Sprintf("Unknown command: %s\nType :help for available commands.", cmd), true + // Don't intercept unrecognized : prefixed messages (e.g. :) :D :thinking:) + // Let them pass through as normal chat messages + return "", false } } From 64ff8a49d805dd8b9199a8acc73477869f4ae398 Mon Sep 17 00:00:00 2001 From: Orlando Chen Date: Thu, 26 Feb 2026 16:13:48 +0900 Subject: [PATCH 12/17] fix: remove silent ls -l injection, keep emoji for explicit ls -l Remove ensureLsLong which silently injected -l into all ls commands, changing output format unexpectedly. Now ls runs as-is; emoji type indicators only apply when user explicitly uses ls -l. Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 291c2aa5a..1bbd4b34c 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1369,15 +1369,9 @@ func (al *AgentLoop) executeCmdMode(ctx context.Context, agent *AgentInstance, c 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, + "command": content, "working_dir": workDir, }, channel, chatID, nil) @@ -1387,8 +1381,8 @@ func (al *AgentLoop) executeCmdMode(ctx context.Context, agent *AgentInstance, c output = "(no output)" } - // Colorize ls output with emoji type indicators - if isLsCommand(content) { + // Colorize ls output with emoji type indicators (only when user explicitly used ls -l) + if isLsCommand(content) && hasLongFlag(content) { output = formatLsOutput(output) } @@ -1716,20 +1710,14 @@ func isLsCommand(cmd string) bool { 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:] { +// hasLongFlag checks if an ls command already includes the -l flag. +func hasLongFlag(cmd string) bool { + for _, p := range strings.Fields(cmd)[1:] { if strings.HasPrefix(p, "-") && !strings.HasPrefix(p, "--") && strings.ContainsRune(p, 'l') { - return cmd // already has -l + return true } } - // "ls" β†’ "ls -l", "ls -a /tmp" β†’ "ls -l -a /tmp" - if len(parts) == 1 { - return "ls -l" - } - return "ls -l " + strings.Join(parts[1:], " ") + return false } // formatLsOutput adds emoji type indicators to ls -l style output lines. From 217728c10d9a789d7737686aba0a3c55c76302e6 Mon Sep 17 00:00:00 2001 From: Orlando Chen Date: Thu, 26 Feb 2026 16:15:42 +0900 Subject: [PATCH 13/17] test: add unit tests for edit path, cd, guard, ls helpers - resolveEditPath: absolute blocked, ~ maps to workspace, traversal blocked, symlink escape blocked, valid relative works - shortenHomePath: home/subpath/other cases - isLsCommand, hasLongFlag, isPermString: table-driven tests - guardCommand: verify ./executable is not blocked - handleExtensionCommand: verify :) :D :thinking: pass through Co-Authored-By: Claude Opus 4.6 --- pkg/agent/cmd_helpers_test.go | 104 ++++++++++++++++++++++++++++++++++ pkg/agent/loop_test.go | 34 +++++++++++ pkg/agent/ls_helpers_test.go | 64 +++++++++++++++++++++ pkg/tools/shell_test.go | 24 ++++++++ 4 files changed, 226 insertions(+) create mode 100644 pkg/agent/cmd_helpers_test.go create mode 100644 pkg/agent/ls_helpers_test.go diff --git a/pkg/agent/cmd_helpers_test.go b/pkg/agent/cmd_helpers_test.go new file mode 100644 index 000000000..f4ca967e6 --- /dev/null +++ b/pkg/agent/cmd_helpers_test.go @@ -0,0 +1,104 @@ +package agent + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveEditPath_AbsoluteBlocked(t *testing.T) { + workspace := t.TempDir() + _, err := resolveEditPath("/etc/passwd", workspace, workspace) + if err == nil { + t.Fatal("Expected absolute path outside workspace to be blocked") + } +} + +func TestResolveEditPath_TildeIsWorkspace(t *testing.T) { + workspace := t.TempDir() + // Create a file so the path resolves + os.WriteFile(filepath.Join(workspace, "test.txt"), []byte("hi"), 0o644) + + path, err := resolveEditPath("~/test.txt", workspace, workspace) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + expected := filepath.Join(workspace, "test.txt") + if path != expected { + t.Errorf("Expected %s, got %s", expected, path) + } +} + +func TestResolveEditPath_BareTildeIsWorkspace(t *testing.T) { + workspace := t.TempDir() + path, err := resolveEditPath("~", workspace, workspace) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if path != workspace { + t.Errorf("Expected %s, got %s", workspace, path) + } +} + +func TestResolveEditPath_TraversalBlocked(t *testing.T) { + workspace := t.TempDir() + _, err := resolveEditPath("../../etc/passwd", workspace, workspace) + if err == nil { + t.Fatal("Expected path traversal to be blocked") + } +} + +func TestResolveEditPath_SymlinkBlocked(t *testing.T) { + root := t.TempDir() + workspace := filepath.Join(root, "workspace") + os.MkdirAll(workspace, 0o755) + secret := filepath.Join(root, "secret.txt") + os.WriteFile(secret, []byte("secret"), 0o644) + link := filepath.Join(workspace, "link.txt") + if err := os.Symlink(secret, link); err != nil { + t.Skip("symlinks not supported") + } + _, err := resolveEditPath("link.txt", workspace, workspace) + if err == nil { + t.Fatal("Expected symlink escape to be blocked") + } +} + +func TestResolveEditPath_ValidRelative(t *testing.T) { + workspace := t.TempDir() + subdir := filepath.Join(workspace, "subdir") + os.MkdirAll(subdir, 0o755) + testFile := filepath.Join(subdir, "test.txt") + os.WriteFile(testFile, []byte("content"), 0o644) + + path, err := resolveEditPath("subdir/test.txt", workspace, workspace) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if path != testFile { + t.Errorf("Expected %s, got %s", testFile, path) + } +} + +func TestShortenHomePath(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skip("cannot get home dir") + } + + tests := []struct { + input string + expected string + }{ + {home, "~"}, + {filepath.Join(home, "projects"), "~/projects"}, + {"/tmp/other", "/tmp/other"}, + } + + for _, tt := range tests { + result := shortenHomePath(tt.input) + if result != tt.expected { + t.Errorf("shortenHomePath(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 4414398b1..63c0754f2 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -631,3 +631,37 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory)) } } + +// TestHandleExtensionCommand_EmojiPassthrough verifies that emoji-like +// messages starting with : are not intercepted as commands. +func TestHandleExtensionCommand_EmojiPassthrough(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + Model: "test-model", + MaxTokens: 4096, + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + + emojiInputs := []string{":)", ":D", ":heart:", ":thinking:", ":-)", ":100:"} + for _, input := range emojiInputs { + _, handled := al.handleExtensionCommand(input) + if handled { + t.Errorf("Expected %q to pass through (not handled), but it was handled", input) + } + } + + // Known commands should still be handled + knownCommands := []string{":help", ":usage"} + for _, cmd := range knownCommands { + _, handled := al.handleExtensionCommand(cmd) + if !handled { + t.Errorf("Expected %q to be handled, but it was not", cmd) + } + } +} diff --git a/pkg/agent/ls_helpers_test.go b/pkg/agent/ls_helpers_test.go new file mode 100644 index 000000000..fd1b34e5d --- /dev/null +++ b/pkg/agent/ls_helpers_test.go @@ -0,0 +1,64 @@ +package agent + +import "testing" + +func TestIsLsCommand(t *testing.T) { + tests := []struct { + cmd string + want bool + }{ + {"ls", true}, + {"ls -la", true}, + {"ls /tmp", true}, + {"lsof", false}, + {"echo ls", false}, + {"", false}, + } + for _, tt := range tests { + if got := isLsCommand(tt.cmd); got != tt.want { + t.Errorf("isLsCommand(%q) = %v, want %v", tt.cmd, got, tt.want) + } + } +} + +func TestHasLongFlag(t *testing.T) { + tests := []struct { + cmd string + want bool + }{ + {"ls", false}, + {"ls -l", true}, + {"ls -la", true}, + {"ls -al", true}, + {"ls --color", false}, + {"ls -a /tmp", false}, + {"ls -l --color /tmp", true}, + } + for _, tt := range tests { + if got := hasLongFlag(tt.cmd); got != tt.want { + t.Errorf("hasLongFlag(%q) = %v, want %v", tt.cmd, got, tt.want) + } + } +} + +func TestIsPermString(t *testing.T) { + tests := []struct { + s string + want bool + }{ + {"drwxr-xr-x", true}, + {"-rw-r--r--", true}, + {"lrwxrwxrwx", true}, + {"-rwsr-xr-x", true}, // setuid + {"drwxrwxrwt", true}, // sticky + {"hello world", false}, // wrong length/chars + {"----------", true}, + {"xrwxrwxrwx", false}, // invalid first char + {"", false}, + } + for _, tt := range tests { + if got := isPermString(tt.s); got != tt.want { + t.Errorf("isPermString(%q) = %v, want %v", tt.s, got, tt.want) + } + } +} diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index 6d35815e8..a8bd603cc 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -272,3 +272,27 @@ func TestShellTool_RestrictToWorkspace(t *testing.T) { ) } } + +// TestGuardCommand_DotSlashExecutable verifies that ./executable style +// commands are NOT blocked by the path extraction regex in guardCommand. +func TestGuardCommand_DotSlashExecutable(t *testing.T) { + tmpDir := t.TempDir() + tool := NewExecTool(tmpDir, true) + + // Create a test script in the workspace + scriptPath := filepath.Join(tmpDir, "test.sh") + os.WriteFile(scriptPath, []byte("#!/bin/sh\necho ok"), 0o755) + + ctx := context.Background() + result := tool.Execute(ctx, map[string]any{ + "command": "./test.sh", + "working_dir": tmpDir, + }) + + if result.IsError { + t.Errorf("Expected ./test.sh to be allowed, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "ok") { + t.Errorf("Expected output 'ok', got: %s", result.ForLLM) + } +} From ed766429a12cbaa89ea5bb61fcea6dee74ae4777 Mon Sep 17 00:00:00 2001 From: Orlando Chen Date: Thu, 26 Feb 2026 16:30:06 +0900 Subject: [PATCH 14/17] style: fix golangci-lint formatting (gci, golines) Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 17 ++++++++++++++--- pkg/agent/ls_helpers_test.go | 4 ++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 1bbd4b34c..6d78e2e0e 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -274,7 +274,10 @@ func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey stri // ProcessDirectWithWorkDir processes a message with an explicit working directory context. // The workDir is injected into the system prompt so the AI resolves file paths relative to it. -func (al *AgentLoop) ProcessDirectWithWorkDir(ctx context.Context, content, sessionKey, workDir string) (string, error) { +func (al *AgentLoop) ProcessDirectWithWorkDir( + ctx context.Context, + content, sessionKey, workDir string, +) (string, error) { msg := bus.InboundMessage{ Channel: "cli", SenderID: "cron", @@ -1338,7 +1341,11 @@ func (al *AgentLoop) handleModeCommand(content, sessionKey string, agent *AgentI // 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) { +func (al *AgentLoop) executeCmdMode( + ctx context.Context, + agent *AgentInstance, + content, sessionKey, channel, chatID string, +) (string, error) { content = strings.TrimSpace(content) if content == "" { return "", nil @@ -1530,7 +1537,11 @@ 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( + "File not found: %s\nUse :edit %s -m \"\"\" to create it.", + shortenHomePath(path), + filepath.Base(path), + ) } return fmt.Sprintf("Error reading file: %v", err) } diff --git a/pkg/agent/ls_helpers_test.go b/pkg/agent/ls_helpers_test.go index fd1b34e5d..652f1d6de 100644 --- a/pkg/agent/ls_helpers_test.go +++ b/pkg/agent/ls_helpers_test.go @@ -49,8 +49,8 @@ func TestIsPermString(t *testing.T) { {"drwxr-xr-x", true}, {"-rw-r--r--", true}, {"lrwxrwxrwx", true}, - {"-rwsr-xr-x", true}, // setuid - {"drwxrwxrwt", true}, // sticky + {"-rwsr-xr-x", true}, // setuid + {"drwxrwxrwt", true}, // sticky {"hello world", false}, // wrong length/chars {"----------", true}, {"xrwxrwxrwx", false}, // invalid first char From cc7e249efe04e756dcdb4c6a678b6a7d93db8386 Mon Sep 17 00:00:00 2001 From: Orlando Chen Date: Thu, 26 Feb 2026 17:00:07 +0900 Subject: [PATCH 15/17] =?UTF-8?q?refactor:=20unify=20command=20prefix=20st?= =?UTF-8?q?yle=20(/=20=E2=86=92=20:)=20and=20merge=20handlers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge handleCommand() and handleExtensionCommand() into a single function. Convert /show, /list, /switch to :show, :list, :switch. Remove duplicate Telegram-side /show and /list handlers. Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 113 ++++++++++++---------------- pkg/agent/loop_test.go | 37 +++++++++- pkg/channels/telegram.go | 10 +-- pkg/channels/telegram_commands.go | 118 ++---------------------------- 4 files changed, 87 insertions(+), 191 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 6d78e2e0e..0aff9b7f6 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1151,12 +1151,7 @@ func (al *AgentLoop) estimateTokens(messages []providers.Message) int { func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) { 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 } @@ -1169,9 +1164,49 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) args := parts[1:] switch cmd { - case "/show": + 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 - Ask AI for help (from command mode, one-shot) +:edit - View/edit files (cmd mode) +:show [model|channel|agents] - Show current configuration +:list [models|channels|agents] - List available options +:switch [model|channel] to - 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 + + case ":show": if len(args) < 1 { - return "Usage: /show [model|channel|agents]", true + return "Usage: :show [model|channel|agents]", true } switch args[0] { case "model": @@ -1189,9 +1224,9 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) return fmt.Sprintf("Unknown show target: %s", args[0]), true } - case "/list": + case ":list": if len(args) < 1 { - return "Usage: /list [models|channels|agents]", true + return "Usage: :list [models|channels|agents]", true } switch args[0] { case "models": @@ -1212,9 +1247,9 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) return fmt.Sprintf("Unknown list target: %s", args[0]), true } - case "/switch": + case ":switch": if len(args) < 3 || args[1] != "to" { - return "Usage: /switch [model|channel] to ", true + return "Usage: :switch [model|channel] to ", true } target := args[0] value := args[2] @@ -1239,60 +1274,6 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) default: return fmt.Sprintf("Unknown switch target: %s", target), true } - } - - 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 - Ask AI for help (from command mode, one-shot) -:edit - View/edit files (cmd mode) -/show [model|channel|agents] - Show current configuration -/list [models|channels|agents] - List available options -/switch [model|channel] to - 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: // Don't intercept unrecognized : prefixed messages (e.g. :) :D :thinking:) diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 63c0754f2..6042ecf74 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -632,9 +632,9 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { } } -// TestHandleExtensionCommand_EmojiPassthrough verifies that emoji-like +// TestHandleCommand_EmojiPassthrough verifies that emoji-like // messages starting with : are not intercepted as commands. -func TestHandleExtensionCommand_EmojiPassthrough(t *testing.T) { +func TestHandleCommand_EmojiPassthrough(t *testing.T) { cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ @@ -647,10 +647,12 @@ func TestHandleExtensionCommand_EmojiPassthrough(t *testing.T) { msgBus := bus.NewMessageBus() provider := &mockProvider{} al := NewAgentLoop(cfg, msgBus, provider) + ctx := context.Background() emojiInputs := []string{":)", ":D", ":heart:", ":thinking:", ":-)", ":100:"} for _, input := range emojiInputs { - _, handled := al.handleExtensionCommand(input) + msg := bus.InboundMessage{Content: input} + _, handled := al.handleCommand(ctx, msg) if handled { t.Errorf("Expected %q to pass through (not handled), but it was handled", input) } @@ -659,9 +661,36 @@ func TestHandleExtensionCommand_EmojiPassthrough(t *testing.T) { // Known commands should still be handled knownCommands := []string{":help", ":usage"} for _, cmd := range knownCommands { - _, handled := al.handleExtensionCommand(cmd) + msg := bus.InboundMessage{Content: cmd} + _, handled := al.handleCommand(ctx, msg) if !handled { t.Errorf("Expected %q to be handled, but it was not", cmd) } } + + // :show, :list, :switch should be handled + showMsg := bus.InboundMessage{Content: ":show model", Channel: "cli"} + resp, handled := al.handleCommand(ctx, showMsg) + if !handled { + t.Error("Expected :show model to be handled") + } + if resp != "Current model: test-model" { + t.Errorf("Unexpected :show model response: %s", resp) + } + + listMsg := bus.InboundMessage{Content: ":list agents"} + _, handled = al.handleCommand(ctx, listMsg) + if !handled { + t.Error("Expected :list agents to be handled") + } + + // Non-: messages should not be handled + plainInputs := []string{"hello", "/show model", "ls -la"} + for _, input := range plainInputs { + msg := bus.InboundMessage{Content: input} + _, handled := al.handleCommand(ctx, msg) + if handled { + t.Errorf("Expected %q to not be handled, but it was", input) + } + } } diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 524494849..edc1ef2e4 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -76,7 +76,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann return &TelegramChannel{ BaseChannel: base, - commands: NewTelegramCommands(bot, cfg), + commands: NewTelegramCommands(bot), bot: bot, config: cfg, chatIDs: make(map[string]int64), @@ -113,14 +113,6 @@ func (c *TelegramChannel) Start(ctx context.Context) error { return c.commands.Start(ctx, message) }, th.CommandEqual("start")) - bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { - return c.commands.Show(ctx, message) - }, th.CommandEqual("show")) - - bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { - return c.commands.List(ctx, message) - }, th.CommandEqual("list")) - bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { return c.handleMessage(ctx, &message) }, th.AnyMessage()) diff --git a/pkg/channels/telegram_commands.go b/pkg/channels/telegram_commands.go index f28434f46..bc5b095b9 100644 --- a/pkg/channels/telegram_commands.go +++ b/pkg/channels/telegram_commands.go @@ -2,46 +2,31 @@ package channels import ( "context" - "fmt" - "strings" "github.com/mymmrac/telego" - - "github.com/sipeed/picoclaw/pkg/config" ) type TelegramCommander interface { Help(ctx context.Context, message telego.Message) error Start(ctx context.Context, message telego.Message) error - Show(ctx context.Context, message telego.Message) error - List(ctx context.Context, message telego.Message) error } type cmd struct { - bot *telego.Bot - config *config.Config + bot *telego.Bot } -func NewTelegramCommands(bot *telego.Bot, cfg *config.Config) TelegramCommander { +func NewTelegramCommands(bot *telego.Bot) TelegramCommander { return &cmd{ - bot: bot, - config: cfg, - } -} - -func commandArgs(text string) string { - parts := strings.SplitN(text, " ", 2) - if len(parts) < 2 { - return "" + bot: bot, } - return strings.TrimSpace(parts[1]) } func (c *cmd) Help(ctx context.Context, message telego.Message) error { msg := `/start - Start the bot /help - Show this help message -/show [model|channel] - Show current configuration -/list [models|channels] - List available options +:show [model|channel|agents] - Show current configuration +:list [models|channels|agents] - List available options +:switch [model|channel] to - Switch model or channel ` _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ ChatID: telego.ChatID{ID: message.Chat.ID}, @@ -63,94 +48,3 @@ func (c *cmd) Start(ctx context.Context, message telego.Message) error { }) return err } - -func (c *cmd) Show(ctx context.Context, message telego.Message) error { - args := commandArgs(message.Text) - if args == "" { - _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ - ChatID: telego.ChatID{ID: message.Chat.ID}, - Text: "Usage: /show [model|channel]", - ReplyParameters: &telego.ReplyParameters{ - MessageID: message.MessageID, - }, - }) - return err - } - - var response string - switch args { - case "model": - response = fmt.Sprintf("Current Model: %s (Provider: %s)", - c.config.Agents.Defaults.GetModelName(), - c.config.Agents.Defaults.Provider) - case "channel": - response = "Current Channel: telegram" - default: - response = fmt.Sprintf("Unknown parameter: %s. Try 'model' or 'channel'.", args) - } - - _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ - ChatID: telego.ChatID{ID: message.Chat.ID}, - Text: response, - ReplyParameters: &telego.ReplyParameters{ - MessageID: message.MessageID, - }, - }) - return err -} - -func (c *cmd) List(ctx context.Context, message telego.Message) error { - args := commandArgs(message.Text) - if args == "" { - _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ - ChatID: telego.ChatID{ID: message.Chat.ID}, - Text: "Usage: /list [models|channels]", - ReplyParameters: &telego.ReplyParameters{ - MessageID: message.MessageID, - }, - }) - return err - } - - var response string - switch args { - case "models": - provider := c.config.Agents.Defaults.Provider - if provider == "" { - provider = "configured default" - } - response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml", - c.config.Agents.Defaults.GetModelName(), provider) - - case "channels": - var enabled []string - if c.config.Channels.Telegram.Enabled { - enabled = append(enabled, "telegram") - } - if c.config.Channels.WhatsApp.Enabled { - enabled = append(enabled, "whatsapp") - } - if c.config.Channels.Feishu.Enabled { - enabled = append(enabled, "feishu") - } - if c.config.Channels.Discord.Enabled { - enabled = append(enabled, "discord") - } - if c.config.Channels.Slack.Enabled { - enabled = append(enabled, "slack") - } - response = fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- ")) - - default: - response = fmt.Sprintf("Unknown parameter: %s. Try 'models' or 'channels'.", args) - } - - _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ - ChatID: telego.ChatID{ID: message.Chat.ID}, - Text: response, - ReplyParameters: &telego.ReplyParameters{ - MessageID: message.MessageID, - }, - }) - return err -} From f536cdd75e7a253864eeb9b0f6990836a6734d03 Mon Sep 17 00:00:00 2001 From: Orlando Chen Date: Thu, 26 Feb 2026 17:07:04 +0900 Subject: [PATCH 16/17] revert: restore /show /list /switch as slash commands, hide from :help The colon-prefix unification was not practical for these commands. Restore original /show, /list, /switch behavior and Telegram handlers. Remove them from :help output to keep the help text focused on colon commands. Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 110 ++++++++++++++++------------ pkg/agent/loop_test.go | 37 +--------- pkg/channels/telegram.go | 10 ++- pkg/channels/telegram_commands.go | 118 ++++++++++++++++++++++++++++-- 4 files changed, 188 insertions(+), 87 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 0aff9b7f6..497c12285 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1151,7 +1151,12 @@ func (al *AgentLoop) estimateTokens(messages []providers.Message) int { func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) { content := strings.TrimSpace(msg.Content) - if !strings.HasPrefix(content, ":") { + // Handle : prefixed extension commands (work across all channels) + if strings.HasPrefix(content, ":") { + return al.handleExtensionCommand(content) + } + + if !strings.HasPrefix(content, "/") { return "", false } @@ -1164,49 +1169,9 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) args := parts[1:] 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 - Ask AI for help (from command mode, one-shot) -:edit - View/edit files (cmd mode) -:show [model|channel|agents] - Show current configuration -:list [models|channels|agents] - List available options -:switch [model|channel] to - 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 - - case ":show": + case "/show": if len(args) < 1 { - return "Usage: :show [model|channel|agents]", true + return "Usage: /show [model|channel|agents]", true } switch args[0] { case "model": @@ -1224,9 +1189,9 @@ Token usage (this session): return fmt.Sprintf("Unknown show target: %s", args[0]), true } - case ":list": + case "/list": if len(args) < 1 { - return "Usage: :list [models|channels|agents]", true + return "Usage: /list [models|channels|agents]", true } switch args[0] { case "models": @@ -1247,9 +1212,9 @@ Token usage (this session): return fmt.Sprintf("Unknown list target: %s", args[0]), true } - case ":switch": + case "/switch": if len(args) < 3 || args[1] != "to" { - return "Usage: :switch [model|channel] to ", true + return "Usage: /switch [model|channel] to ", true } target := args[0] value := args[2] @@ -1274,6 +1239,57 @@ Token usage (this session): default: return fmt.Sprintf("Unknown switch target: %s", target), true } + } + + 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 - Ask AI for help (from command mode, one-shot) +:edit - View/edit files (cmd mode)`, 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: // Don't intercept unrecognized : prefixed messages (e.g. :) :D :thinking:) diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 6042ecf74..63c0754f2 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -632,9 +632,9 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { } } -// TestHandleCommand_EmojiPassthrough verifies that emoji-like +// TestHandleExtensionCommand_EmojiPassthrough verifies that emoji-like // messages starting with : are not intercepted as commands. -func TestHandleCommand_EmojiPassthrough(t *testing.T) { +func TestHandleExtensionCommand_EmojiPassthrough(t *testing.T) { cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ @@ -647,12 +647,10 @@ func TestHandleCommand_EmojiPassthrough(t *testing.T) { msgBus := bus.NewMessageBus() provider := &mockProvider{} al := NewAgentLoop(cfg, msgBus, provider) - ctx := context.Background() emojiInputs := []string{":)", ":D", ":heart:", ":thinking:", ":-)", ":100:"} for _, input := range emojiInputs { - msg := bus.InboundMessage{Content: input} - _, handled := al.handleCommand(ctx, msg) + _, handled := al.handleExtensionCommand(input) if handled { t.Errorf("Expected %q to pass through (not handled), but it was handled", input) } @@ -661,36 +659,9 @@ func TestHandleCommand_EmojiPassthrough(t *testing.T) { // Known commands should still be handled knownCommands := []string{":help", ":usage"} for _, cmd := range knownCommands { - msg := bus.InboundMessage{Content: cmd} - _, handled := al.handleCommand(ctx, msg) + _, handled := al.handleExtensionCommand(cmd) if !handled { t.Errorf("Expected %q to be handled, but it was not", cmd) } } - - // :show, :list, :switch should be handled - showMsg := bus.InboundMessage{Content: ":show model", Channel: "cli"} - resp, handled := al.handleCommand(ctx, showMsg) - if !handled { - t.Error("Expected :show model to be handled") - } - if resp != "Current model: test-model" { - t.Errorf("Unexpected :show model response: %s", resp) - } - - listMsg := bus.InboundMessage{Content: ":list agents"} - _, handled = al.handleCommand(ctx, listMsg) - if !handled { - t.Error("Expected :list agents to be handled") - } - - // Non-: messages should not be handled - plainInputs := []string{"hello", "/show model", "ls -la"} - for _, input := range plainInputs { - msg := bus.InboundMessage{Content: input} - _, handled := al.handleCommand(ctx, msg) - if handled { - t.Errorf("Expected %q to not be handled, but it was", input) - } - } } diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index edc1ef2e4..524494849 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -76,7 +76,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann return &TelegramChannel{ BaseChannel: base, - commands: NewTelegramCommands(bot), + commands: NewTelegramCommands(bot, cfg), bot: bot, config: cfg, chatIDs: make(map[string]int64), @@ -113,6 +113,14 @@ func (c *TelegramChannel) Start(ctx context.Context) error { return c.commands.Start(ctx, message) }, th.CommandEqual("start")) + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { + return c.commands.Show(ctx, message) + }, th.CommandEqual("show")) + + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { + return c.commands.List(ctx, message) + }, th.CommandEqual("list")) + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { return c.handleMessage(ctx, &message) }, th.AnyMessage()) diff --git a/pkg/channels/telegram_commands.go b/pkg/channels/telegram_commands.go index bc5b095b9..f28434f46 100644 --- a/pkg/channels/telegram_commands.go +++ b/pkg/channels/telegram_commands.go @@ -2,31 +2,46 @@ package channels import ( "context" + "fmt" + "strings" "github.com/mymmrac/telego" + + "github.com/sipeed/picoclaw/pkg/config" ) type TelegramCommander interface { Help(ctx context.Context, message telego.Message) error Start(ctx context.Context, message telego.Message) error + Show(ctx context.Context, message telego.Message) error + List(ctx context.Context, message telego.Message) error } type cmd struct { - bot *telego.Bot + bot *telego.Bot + config *config.Config } -func NewTelegramCommands(bot *telego.Bot) TelegramCommander { +func NewTelegramCommands(bot *telego.Bot, cfg *config.Config) TelegramCommander { return &cmd{ - bot: bot, + bot: bot, + config: cfg, + } +} + +func commandArgs(text string) string { + parts := strings.SplitN(text, " ", 2) + if len(parts) < 2 { + return "" } + return strings.TrimSpace(parts[1]) } func (c *cmd) Help(ctx context.Context, message telego.Message) error { msg := `/start - Start the bot /help - Show this help message -:show [model|channel|agents] - Show current configuration -:list [models|channels|agents] - List available options -:switch [model|channel] to - Switch model or channel +/show [model|channel] - Show current configuration +/list [models|channels] - List available options ` _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ ChatID: telego.ChatID{ID: message.Chat.ID}, @@ -48,3 +63,94 @@ func (c *cmd) Start(ctx context.Context, message telego.Message) error { }) return err } + +func (c *cmd) Show(ctx context.Context, message telego.Message) error { + args := commandArgs(message.Text) + if args == "" { + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: "Usage: /show [model|channel]", + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err + } + + var response string + switch args { + case "model": + response = fmt.Sprintf("Current Model: %s (Provider: %s)", + c.config.Agents.Defaults.GetModelName(), + c.config.Agents.Defaults.Provider) + case "channel": + response = "Current Channel: telegram" + default: + response = fmt.Sprintf("Unknown parameter: %s. Try 'model' or 'channel'.", args) + } + + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: response, + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err +} + +func (c *cmd) List(ctx context.Context, message telego.Message) error { + args := commandArgs(message.Text) + if args == "" { + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: "Usage: /list [models|channels]", + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err + } + + var response string + switch args { + case "models": + provider := c.config.Agents.Defaults.Provider + if provider == "" { + provider = "configured default" + } + response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml", + c.config.Agents.Defaults.GetModelName(), provider) + + case "channels": + var enabled []string + if c.config.Channels.Telegram.Enabled { + enabled = append(enabled, "telegram") + } + if c.config.Channels.WhatsApp.Enabled { + enabled = append(enabled, "whatsapp") + } + if c.config.Channels.Feishu.Enabled { + enabled = append(enabled, "feishu") + } + if c.config.Channels.Discord.Enabled { + enabled = append(enabled, "discord") + } + if c.config.Channels.Slack.Enabled { + enabled = append(enabled, "slack") + } + response = fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- ")) + + default: + response = fmt.Sprintf("Unknown parameter: %s. Try 'models' or 'channels'.", args) + } + + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: response, + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err +} From ba0e9f5f79a2f9bb8c41de1e5acb299c19371d63 Mon Sep 17 00:00:00 2001 From: Orlando Chen Date: Thu, 26 Feb 2026 17:24:12 +0900 Subject: [PATCH 17/17] fix: prevent cd traversal (../../..) from escaping workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After filepath.Clean resolves ../ sequences, verify the resulting path is still within the workspace directory. If not, silently redirect to workspace root β€” same behaviour as cd / and cd ~. Co-Authored-By: Claude Opus 4.6 --- pkg/agent/loop.go | 5 +++++ pkg/agent/loop_test.go | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 497c12285..d525dfebc 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1426,6 +1426,11 @@ func (al *AgentLoop) handleCdCommand(content, sessionKey string, agent *AgentIns target = filepath.Clean(target) + // Prevent traversal outside workspace via ../ + if !strings.HasPrefix(target, workspace) { + target = workspace + } + info, err := os.Stat(target) if err != nil { return fmt.Sprintf("cd: %s: No such file or directory", target) diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 63c0754f2..6dc1ccc7f 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" @@ -632,6 +633,42 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { } } +// TestHandleCdCommand_TraversalBlocked verifies that cd ../../.. cannot escape workspace. +func TestHandleCdCommand_TraversalBlocked(t *testing.T) { + workspace := t.TempDir() + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + Model: "test-model", + MaxTokens: 4096, + }, + }, + } + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + agent := al.registry.GetDefaultAgent() + + // Set working dir to a subdir inside workspace + subdir := filepath.Join(workspace, "a", "b") + os.MkdirAll(subdir, 0o755) + al.setSessionWorkDir("test", subdir) + + // Try to escape via ../../../.. + result := al.handleCdCommand("cd ../../../..", "test", agent) + workDir := al.getSessionWorkDir("test") + + if !strings.HasPrefix(workDir, workspace) { + t.Errorf("cd traversal escaped workspace: workDir=%s, workspace=%s", workDir, workspace) + } + // Should land in workspace, not outside + if workDir != workspace { + t.Errorf("Expected workDir=%s, got %s", workspace, workDir) + } + _ = result +} + // TestHandleExtensionCommand_EmojiPassthrough verifies that emoji-like // messages starting with : are not intercepted as commands. func TestHandleExtensionCommand_EmojiPassthrough(t *testing.T) {