From d9ae60b755dcbe4b7ffa487b01271e9c69eb8e5e Mon Sep 17 00:00:00 2001 From: Orlando Chen Date: Tue, 24 Feb 2026 16:39:34 +0900 Subject: [PATCH] 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/ +cmd/picoclaw/workspace -# Coverage - -# Secrets & Config (keep templates, ignore actual secrets) +# Secrets .env config/config.json -# Test +# Coverage coverage.txt coverage.html # OS .DS_Store -# Ralph workspace +# Editors & tools +.vscode/ +.idea/ +.claude/ + +# Task tracking +TASKS.md + +# Legacy ralph/ .ralph/ tasks/ - -# Editors -.vscode/ -.idea/ - -# Added by goreleaser init: -dist/ 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