feat: add :edit command, replace Makefile with scripts/, improve cmd mode
- Add :edit command for file viewing and editing in cmd mode (:edit file, :edit file N text, :edit file +N text, :edit file -N, :edit file -m """...""" for multi-line write with auto-create) - Intercept vim/nano/vi/emacs with helpful :edit redirect message - Replace Makefile with scripts/ (build.sh, install.sh, deploy.sh, setup.sh, check.sh, docker.sh) for build, test, deploy, and environment setup - Simplify :hipico to one-shot mode, remove :byepico and modeHiPico - Switch command prefixes from / to : (:cmd, :pico, :hipico, :help) - Add console-like code block formatting and emoji file type indicators for ls output in cmd mode - Update Dockerfile and CI workflow to use scripts/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
36f77eadb0
commit
4f00898f72
12 changed files with 1125 additions and 334 deletions
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
|
|
@ -17,4 +17,4 @@ jobs:
|
|||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
run: make build-all
|
||||
run: scripts/build.sh --all
|
||||
|
|
|
|||
34
.gitignore
vendored
34
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
189
Makefile
189
Makefile
|
|
@ -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)"
|
||||
|
|
@ -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 <message>")
|
||||
fmt.Println("Example: /hipico check the log files for error messages")
|
||||
fmt.Println("Usage: :hipico <message>")
|
||||
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 <message>")
|
||||
fmt.Println("Example: /hipico check the log files for error messages")
|
||||
fmt.Println("Usage: :hipico <message>")
|
||||
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 <msg> 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 <msg> 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.
|
||||
|
|
|
|||
|
|
@ -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 <msg> 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 <msg> - Ask AI for help (from command mode, one-shot)
|
||||
:edit <file> - View/edit files (cmd mode)
|
||||
/show [model|channel|agents] - Show current configuration
|
||||
/list [models|channels|agents] - List available options
|
||||
/switch [model|channel] to <name> - Switch model or channel`, true
|
||||
|
||||
case ":usage":
|
||||
agent := al.registry.GetDefaultAgent()
|
||||
if agent == nil {
|
||||
return "No agent available.", true
|
||||
}
|
||||
promptTokens := agent.TotalPromptTokens.Load()
|
||||
completionTokens := agent.TotalCompletionTokens.Load()
|
||||
return fmt.Sprintf(`Model: %s
|
||||
Max tokens: %d
|
||||
Temperature: %.1f
|
||||
|
||||
Token usage (this session):
|
||||
Prompt tokens: %d
|
||||
Completion tokens: %d
|
||||
Total tokens: %d
|
||||
Requests: %d`,
|
||||
agent.Model,
|
||||
agent.MaxTokens,
|
||||
agent.Temperature,
|
||||
promptTokens,
|
||||
completionTokens,
|
||||
promptTokens+completionTokens,
|
||||
agent.TotalRequests.Load(),
|
||||
), true
|
||||
|
||||
default:
|
||||
return fmt.Sprintf("Unknown command: %s\nType :help for available commands.", cmd), true
|
||||
}
|
||||
}
|
||||
|
||||
// handleModeCommand processes mode-switching commands (:cmd, :pico, :hipico).
|
||||
// Returns (response, handled). If handled is true, the caller should return the response directly.
|
||||
// For :hipico with a message, it returns ("", false) so processMessage continues with a one-shot LLM call.
|
||||
func (al *AgentLoop) handleModeCommand(content, sessionKey string, agent *AgentInstance) (string, bool) {
|
||||
parts := strings.Fields(content)
|
||||
if len(parts) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
cmd := parts[0]
|
||||
|
||||
switch cmd {
|
||||
case ":cmd":
|
||||
al.setSessionMode(sessionKey, modeCmd)
|
||||
workDir := al.getSessionWorkDir(sessionKey)
|
||||
if workDir == "" {
|
||||
workDir = agent.Workspace
|
||||
al.setSessionWorkDir(sessionKey, workDir)
|
||||
}
|
||||
displayDir := shortenHomePath(workDir)
|
||||
return fmt.Sprintf("```\n%s$\n```\nType `:pico` to return to chat mode.", displayDir), true
|
||||
|
||||
case ":pico":
|
||||
al.setSessionMode(sessionKey, modePico)
|
||||
return "Switched to chat mode. Type :cmd to enter command mode.", true
|
||||
|
||||
case ":hipico":
|
||||
msg := strings.TrimSpace(strings.TrimPrefix(content, ":hipico"))
|
||||
if msg == "" {
|
||||
return "Usage: :hipico <message>\nExample: :hipico check the log files for errors", true
|
||||
}
|
||||
// Stay in modeCmd, just flag for one-shot LLM call — processMessage handles it
|
||||
return "", false
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// executeCmdMode executes a shell command in command mode via ExecTool.
|
||||
// Output is formatted as a console code block for channel display.
|
||||
func (al *AgentLoop) executeCmdMode(ctx context.Context, agent *AgentInstance, content, sessionKey, channel, chatID string) (string, error) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Handle cd command specially
|
||||
if content == "cd" || strings.HasPrefix(content, "cd ") {
|
||||
return al.handleCdCommand(content, sessionKey, agent), nil
|
||||
}
|
||||
|
||||
// Handle :edit command
|
||||
if content == ":edit" || strings.HasPrefix(content, ":edit ") {
|
||||
workDir := al.getSessionWorkDir(sessionKey)
|
||||
if workDir == "" {
|
||||
workDir = agent.Workspace
|
||||
}
|
||||
return al.handleEditCommand(content, workDir), nil
|
||||
}
|
||||
|
||||
// Intercept interactive editors
|
||||
if msg := interceptEditor(content); msg != "" {
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// Get working directory
|
||||
workDir := al.getSessionWorkDir(sessionKey)
|
||||
if workDir == "" {
|
||||
workDir = agent.Workspace
|
||||
}
|
||||
|
||||
// For ls commands, ensure -l flag so we can parse file types
|
||||
execCmd := content
|
||||
if isLsCommand(content) {
|
||||
execCmd = ensureLsLong(content)
|
||||
}
|
||||
|
||||
// Execute via ExecTool
|
||||
result := agent.Tools.ExecuteWithContext(ctx, "exec", map[string]any{
|
||||
"command": execCmd,
|
||||
"working_dir": workDir,
|
||||
}, channel, chatID, nil)
|
||||
|
||||
displayDir := shortenHomePath(workDir)
|
||||
output := result.ForLLM
|
||||
if output == "" {
|
||||
output = "(no output)"
|
||||
}
|
||||
|
||||
// Colorize ls output with emoji type indicators
|
||||
if isLsCommand(content) {
|
||||
output = formatLsOutput(output)
|
||||
}
|
||||
|
||||
// Format as console code block: prompt line + output (show original command, not modified)
|
||||
return fmt.Sprintf("```\n%s$ %s\n%s\n```", displayDir, content, output), nil
|
||||
}
|
||||
|
||||
// handleCdCommand handles the cd command in command mode, updating per-session working directory.
|
||||
func (al *AgentLoop) handleCdCommand(content, sessionKey string, agent *AgentInstance) string {
|
||||
parts := strings.Fields(content)
|
||||
var target string
|
||||
|
||||
if len(parts) < 2 || parts[1] == "~" {
|
||||
home, _ := os.UserHomeDir()
|
||||
target = home
|
||||
} else {
|
||||
target = parts[1]
|
||||
// Expand ~ prefix
|
||||
if strings.HasPrefix(target, "~/") {
|
||||
home, _ := os.UserHomeDir()
|
||||
target = home + target[1:]
|
||||
}
|
||||
// Resolve relative paths
|
||||
if !filepath.IsAbs(target) {
|
||||
currentDir := al.getSessionWorkDir(sessionKey)
|
||||
if currentDir == "" {
|
||||
currentDir = agent.Workspace
|
||||
}
|
||||
target = filepath.Join(currentDir, target)
|
||||
}
|
||||
}
|
||||
|
||||
target = filepath.Clean(target)
|
||||
|
||||
info, err := os.Stat(target)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("cd: %s: No such file or directory", target)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Sprintf("cd: %s: Not a directory", target)
|
||||
}
|
||||
|
||||
al.setSessionWorkDir(sessionKey, target)
|
||||
return fmt.Sprintf("```\n%s$\n```", shortenHomePath(target))
|
||||
}
|
||||
|
||||
// shortenHomePath replaces the user's home directory prefix with ~ for display.
|
||||
func shortenHomePath(path string) string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return path
|
||||
}
|
||||
if path == home {
|
||||
return "~"
|
||||
}
|
||||
if strings.HasPrefix(path, home+"/") {
|
||||
return "~" + path[len(home):]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// handleEditCommand processes :edit commands for file viewing and editing in cmd mode.
|
||||
// Syntax:
|
||||
//
|
||||
// :edit → show usage
|
||||
// :edit <file> → show file with line numbers
|
||||
// :edit <file> <N> <text> → replace line N
|
||||
// :edit <file> +<N> <text> → insert after line N
|
||||
// :edit <file> -<N> → delete line N
|
||||
// :edit <file> -m """<content>""" → write full content (create if needed)
|
||||
func (al *AgentLoop) handleEditCommand(content, workDir string) string {
|
||||
raw := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(content), ":edit"))
|
||||
if raw == "" {
|
||||
return editUsage()
|
||||
}
|
||||
|
||||
// Split on first newline to get the command line
|
||||
firstLine := raw
|
||||
if idx := strings.Index(raw, "\n"); idx != -1 {
|
||||
firstLine = raw[:idx]
|
||||
}
|
||||
|
||||
parts := strings.Fields(firstLine)
|
||||
if len(parts) == 0 {
|
||||
return editUsage()
|
||||
}
|
||||
|
||||
filename := resolveEditPath(parts[0], workDir)
|
||||
|
||||
// :edit <file> — show file content
|
||||
if len(parts) == 1 && !strings.Contains(raw, "\n") {
|
||||
return editShowFile(filename)
|
||||
}
|
||||
|
||||
// :edit <file> -m """..."""
|
||||
if len(parts) >= 2 && parts[1] == "-m" {
|
||||
return editMultiline(filename, raw)
|
||||
}
|
||||
|
||||
// Line operations: N text, +N text, -N
|
||||
if len(parts) >= 2 {
|
||||
// Get raw text after the line-op token (preserves original spacing)
|
||||
afterFile := strings.TrimSpace(firstLine[len(parts[0]):])
|
||||
return editLineOp(filename, afterFile)
|
||||
}
|
||||
|
||||
return editUsage()
|
||||
}
|
||||
|
||||
func resolveEditPath(name, workDir string) string {
|
||||
if strings.HasPrefix(name, "~/") {
|
||||
home, _ := os.UserHomeDir()
|
||||
return home + name[1:]
|
||||
}
|
||||
if filepath.IsAbs(name) {
|
||||
return name
|
||||
}
|
||||
return filepath.Join(workDir, name)
|
||||
}
|
||||
|
||||
func editUsage() string {
|
||||
return "Usage:\n" +
|
||||
" :edit <file> — view file\n" +
|
||||
" :edit <file> <N> <text> — replace line N\n" +
|
||||
" :edit <file> +<N> <text> — insert after line N\n" +
|
||||
" :edit <file> -<N> — delete line N\n" +
|
||||
" :edit <file> -m \"\"\" — write content\n" +
|
||||
" <content>\n" +
|
||||
" \"\"\""
|
||||
}
|
||||
|
||||
func editShowFile(path string) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Sprintf("File not found: %s\nUse :edit %s -m \"\"\" to create it.", shortenHomePath(path), filepath.Base(path))
|
||||
}
|
||||
return fmt.Sprintf("Error reading file: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
// Remove trailing empty line that Split produces
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
|
||||
const maxLines = 50
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("``` %s (%d lines)\n", filepath.Base(path), len(lines)))
|
||||
if len(lines) <= maxLines {
|
||||
for i, line := range lines {
|
||||
b.WriteString(fmt.Sprintf("%4d│ %s\n", i+1, line))
|
||||
}
|
||||
} else {
|
||||
for i := 0; i < maxLines; i++ {
|
||||
b.WriteString(fmt.Sprintf("%4d│ %s\n", i+1, lines[i]))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" ...│ (%d more lines)\n", len(lines)-maxLines))
|
||||
}
|
||||
b.WriteString("```")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func editMultiline(filename, raw string) string {
|
||||
// raw = `<file> -m """..."""`
|
||||
start := strings.Index(raw, `"""`)
|
||||
if start == -1 {
|
||||
return editUsage()
|
||||
}
|
||||
rest := raw[start+3:]
|
||||
// Trim leading newline after opening """
|
||||
rest = strings.TrimPrefix(rest, "\n")
|
||||
|
||||
// Find closing """
|
||||
end := strings.LastIndex(rest, `"""`)
|
||||
if end == -1 || end == 0 {
|
||||
// No closing triple-quote — use entire rest as content
|
||||
end = len(rest)
|
||||
}
|
||||
content := rest[:end]
|
||||
|
||||
// Ensure trailing newline
|
||||
if content != "" && !strings.HasSuffix(content, "\n") {
|
||||
content += "\n"
|
||||
}
|
||||
|
||||
// Create parent dirs if needed
|
||||
dir := filepath.Dir(filename)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Sprintf("Error creating directory: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filename, []byte(content), 0o644); err != nil {
|
||||
return fmt.Sprintf("Error writing file: %v", err)
|
||||
}
|
||||
|
||||
lineCount := strings.Count(content, "\n")
|
||||
return fmt.Sprintf("```\n✓ Wrote %d lines → %s\n```", lineCount, shortenHomePath(filename))
|
||||
}
|
||||
|
||||
func editLineOp(filename, rawArgs string) string {
|
||||
rawArgs = strings.TrimSpace(rawArgs)
|
||||
// Split into op token and text
|
||||
spaceIdx := strings.IndexByte(rawArgs, ' ')
|
||||
var op, text string
|
||||
if spaceIdx == -1 {
|
||||
op = rawArgs
|
||||
} else {
|
||||
op = rawArgs[:spaceIdx]
|
||||
text = rawArgs[spaceIdx+1:]
|
||||
}
|
||||
|
||||
var lineNum int
|
||||
var action string // "replace", "insert", "delete"
|
||||
var err error
|
||||
|
||||
if strings.HasPrefix(op, "+") {
|
||||
action = "insert"
|
||||
lineNum, err = strconv.Atoi(op[1:])
|
||||
} else if strings.HasPrefix(op, "-") {
|
||||
action = "delete"
|
||||
lineNum, err = strconv.Atoi(op[1:])
|
||||
} else {
|
||||
action = "replace"
|
||||
lineNum, err = strconv.Atoi(op)
|
||||
}
|
||||
if err != nil || lineNum < 1 {
|
||||
return "Invalid line number. Use a positive integer."
|
||||
}
|
||||
|
||||
// Read existing file
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Sprintf("File not found: %s", shortenHomePath(filename))
|
||||
}
|
||||
return fmt.Sprintf("Error reading file: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "delete":
|
||||
if lineNum > len(lines) {
|
||||
return fmt.Sprintf("Line %d out of range (file has %d lines).", lineNum, len(lines))
|
||||
}
|
||||
deleted := lines[lineNum-1]
|
||||
lines = append(lines[:lineNum-1], lines[lineNum:]...)
|
||||
if err := os.WriteFile(filename, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
|
||||
return fmt.Sprintf("Error writing file: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("```\n✓ Deleted line %d: %s\n(%d lines remaining)\n```", lineNum, deleted, len(lines))
|
||||
|
||||
case "replace":
|
||||
if text == "" {
|
||||
return "Usage: :edit <file> <N> <text>"
|
||||
}
|
||||
if lineNum > len(lines) {
|
||||
return fmt.Sprintf("Line %d out of range (file has %d lines).", lineNum, len(lines))
|
||||
}
|
||||
old := lines[lineNum-1]
|
||||
lines[lineNum-1] = text
|
||||
if err := os.WriteFile(filename, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
|
||||
return fmt.Sprintf("Error writing file: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("```\n✓ Line %d replaced\n was: %s\n now: %s\n```", lineNum, old, text)
|
||||
|
||||
case "insert":
|
||||
if text == "" {
|
||||
return "Usage: :edit <file> +<N> <text>"
|
||||
}
|
||||
if lineNum > len(lines) {
|
||||
lineNum = len(lines) // insert at end
|
||||
}
|
||||
newLines := make([]string, 0, len(lines)+1)
|
||||
newLines = append(newLines, lines[:lineNum]...)
|
||||
newLines = append(newLines, text)
|
||||
newLines = append(newLines, lines[lineNum:]...)
|
||||
if err := os.WriteFile(filename, []byte(strings.Join(newLines, "\n")+"\n"), 0o644); err != nil {
|
||||
return fmt.Sprintf("Error writing file: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("```\n✓ Inserted after line %d: %s\n(%d lines total)\n```", lineNum, text, len(newLines))
|
||||
}
|
||||
|
||||
return editUsage()
|
||||
}
|
||||
|
||||
// interceptEditor detects interactive editor commands and returns a helpful redirect message.
|
||||
func interceptEditor(cmd string) string {
|
||||
parts := strings.Fields(cmd)
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
name := parts[0]
|
||||
switch name {
|
||||
case "vim", "vi", "nvim", "nano", "emacs", "pico", "joe", "mcedit":
|
||||
return fmt.Sprintf("⚠ %s requires a terminal and cannot run here.\nUse :edit instead:\n\n"+
|
||||
":edit <file> — view file\n"+
|
||||
":edit <file> -m \"\"\" — write content\n"+
|
||||
"<content>\n"+
|
||||
"\"\"\"\n\n"+
|
||||
"Type :help for all commands.", name)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isLsCommand checks if a shell command is an ls invocation.
|
||||
func isLsCommand(cmd string) bool {
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
return cmd == "ls" || strings.HasPrefix(cmd, "ls ")
|
||||
}
|
||||
|
||||
// ensureLsLong injects -l into an ls command if not already present,
|
||||
// so the output always contains permission strings for type detection.
|
||||
func ensureLsLong(cmd string) string {
|
||||
parts := strings.Fields(cmd)
|
||||
for _, p := range parts[1:] {
|
||||
if strings.HasPrefix(p, "-") && !strings.HasPrefix(p, "--") && strings.ContainsRune(p, 'l') {
|
||||
return cmd // already has -l
|
||||
}
|
||||
}
|
||||
// "ls" → "ls -l", "ls -a /tmp" → "ls -l -a /tmp"
|
||||
if len(parts) == 1 {
|
||||
return "ls -l"
|
||||
}
|
||||
return "ls -l " + strings.Join(parts[1:], " ")
|
||||
}
|
||||
|
||||
// formatLsOutput adds emoji type indicators to ls -l style output lines.
|
||||
func formatLsOutput(output string) string {
|
||||
lines := strings.Split(output, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = formatLsLine(line)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// formatLsLine adds an emoji prefix to a single ls -l output line based on file type.
|
||||
func formatLsLine(line string) string {
|
||||
// Skip empty lines, "total" line, and lines too short to be ls -l
|
||||
if line == "" || strings.HasPrefix(line, "total ") || len(line) < 10 {
|
||||
return line
|
||||
}
|
||||
|
||||
// Check if line starts with a permission string (e.g. drwxr-xr-x)
|
||||
perms := line[:10]
|
||||
if !isPermString(perms) {
|
||||
return line
|
||||
}
|
||||
|
||||
fileType := perms[0]
|
||||
var emoji string
|
||||
switch fileType {
|
||||
case 'd':
|
||||
emoji = "\U0001F4C1" // 📁
|
||||
case 'l':
|
||||
emoji = "\U0001F517" // 🔗
|
||||
case 'b', 'c':
|
||||
emoji = "\U0001F4BE" // 💾
|
||||
case 'p', 's':
|
||||
emoji = "\U0001F50C" // 🔌
|
||||
default:
|
||||
// Regular file: check executable bit (owner/group/other x positions)
|
||||
if perms[3] == 'x' || perms[6] == 'x' || perms[9] == 'x' {
|
||||
emoji = "\u26A1" // ⚡
|
||||
} else {
|
||||
emoji = fileEmojiByExt(line)
|
||||
}
|
||||
}
|
||||
|
||||
return emoji + " " + line
|
||||
}
|
||||
|
||||
// isPermString checks if a 10-char string looks like a Unix permission string.
|
||||
func isPermString(s string) bool {
|
||||
if len(s) != 10 {
|
||||
return false
|
||||
}
|
||||
// First char: file type
|
||||
switch s[0] {
|
||||
case '-', 'd', 'l', 'b', 'c', 'p', 's':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
// Remaining 9 chars: rwx or - (plus s/S/t/T for setuid/setgid/sticky)
|
||||
for _, c := range s[1:] {
|
||||
switch c {
|
||||
case 'r', 'w', 'x', '-', 's', 'S', 't', 'T':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// fileEmojiByExt returns an emoji based on the file extension found in an ls -l line.
|
||||
func fileEmojiByExt(line string) string {
|
||||
// Extract filename: last whitespace-delimited field (for symlinks, take before " -> ")
|
||||
name := line
|
||||
if idx := strings.LastIndex(line, " -> "); idx != -1 {
|
||||
name = line[:idx]
|
||||
}
|
||||
if idx := strings.LastIndex(name, " "); idx != -1 {
|
||||
name = name[idx+1:]
|
||||
}
|
||||
name = strings.ToLower(name)
|
||||
|
||||
ext := filepath.Ext(name)
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp", ".bmp", ".ico", ".tiff":
|
||||
return "\U0001F5BC" // 🖼
|
||||
case ".mp3", ".wav", ".flac", ".aac", ".ogg", ".wma", ".m4a":
|
||||
return "\U0001F3B5" // 🎵
|
||||
case ".mp4", ".avi", ".mkv", ".mov", ".webm", ".flv", ".wmv":
|
||||
return "\U0001F3AC" // 🎬
|
||||
case ".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", ".zst", ".tgz":
|
||||
return "\U0001F4E6" // 📦
|
||||
default:
|
||||
return "\U0001F4C4" // 📄
|
||||
}
|
||||
}
|
||||
|
||||
// extractPeer extracts the routing peer from inbound message metadata.
|
||||
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||
peerKind := msg.Metadata["peer_kind"]
|
||||
|
|
|
|||
89
scripts/build.sh
Executable file
89
scripts/build.sh
Executable file
|
|
@ -0,0 +1,89 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ── project constants ────────────────────────────────────────
|
||||
BINARY_NAME="picoclaw"
|
||||
CMD_DIR="cmd/${BINARY_NAME}"
|
||||
BUILD_DIR="build"
|
||||
|
||||
# ── Go flags ─────────────────────────────────────────────────
|
||||
GO="${GO:-go}"
|
||||
GOFLAGS="${GOFLAGS:--v -tags stdjson}"
|
||||
|
||||
# ── version info (injected via ldflags) ──────────────────────
|
||||
VERSION="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")}"
|
||||
GIT_COMMIT="$(git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")"
|
||||
BUILD_TIME="$(date +%FT%T%z)"
|
||||
GO_VERSION="$($GO version | awk '{print $3}')"
|
||||
LDFLAGS="-X main.version=${VERSION} -X main.gitCommit=${GIT_COMMIT} -X main.buildTime=${BUILD_TIME} -X main.goVersion=${GO_VERSION} -s -w"
|
||||
|
||||
# ── platform detection ───────────────────────────────────────
|
||||
detect_platform() {
|
||||
local os arch
|
||||
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
arch="$(uname -m)"
|
||||
|
||||
case "$arch" in
|
||||
x86_64) arch="amd64" ;;
|
||||
aarch64) arch="arm64" ;;
|
||||
loongarch64) arch="loong64" ;;
|
||||
esac
|
||||
|
||||
echo "${os} ${arch}"
|
||||
}
|
||||
|
||||
# ── generate ─────────────────────────────────────────────────
|
||||
generate() {
|
||||
echo "Run generate..."
|
||||
rm -rf "./${CMD_DIR}/workspace" 2>/dev/null || true
|
||||
$GO generate ./...
|
||||
echo "Run generate complete"
|
||||
}
|
||||
|
||||
# ── build single platform ────────────────────────────────────
|
||||
build_one() {
|
||||
local goos="$1" goarch="$2"
|
||||
local suffix="${BINARY_NAME}-${goos}-${goarch}"
|
||||
[[ "$goos" == "windows" ]] && suffix+=".exe"
|
||||
|
||||
echo "Building ${BINARY_NAME} for ${goos}/${goarch}..."
|
||||
mkdir -p "$BUILD_DIR"
|
||||
GOOS="$goos" GOARCH="$goarch" $GO build $GOFLAGS -ldflags "$LDFLAGS" -o "${BUILD_DIR}/${suffix}" "./${CMD_DIR}"
|
||||
echo "Build complete: ${BUILD_DIR}/${suffix}"
|
||||
}
|
||||
|
||||
# ── build current platform ───────────────────────────────────
|
||||
build_current() {
|
||||
generate
|
||||
read -r os arch <<< "$(detect_platform)"
|
||||
build_one "$os" "$arch"
|
||||
ln -sf "${BINARY_NAME}-${os}-${arch}" "${BUILD_DIR}/${BINARY_NAME}"
|
||||
}
|
||||
|
||||
# ── build all platforms ──────────────────────────────────────
|
||||
build_all() {
|
||||
generate
|
||||
echo "Building for multiple platforms..."
|
||||
mkdir -p "$BUILD_DIR"
|
||||
build_one linux amd64
|
||||
build_one linux arm64
|
||||
build_one linux loong64
|
||||
build_one linux riscv64
|
||||
build_one darwin arm64
|
||||
build_one windows amd64
|
||||
echo "All builds complete"
|
||||
}
|
||||
|
||||
# ── clean ────────────────────────────────────────────────────
|
||||
clean() {
|
||||
echo "Cleaning build artifacts..."
|
||||
rm -rf "$BUILD_DIR"
|
||||
echo "Clean complete"
|
||||
}
|
||||
|
||||
# ── main ─────────────────────────────────────────────────────
|
||||
case "${1:-}" in
|
||||
--all) build_all ;;
|
||||
--clean) clean ;;
|
||||
*) build_current ;;
|
||||
esac
|
||||
56
scripts/check.sh
Executable file
56
scripts/check.sh
Executable file
|
|
@ -0,0 +1,56 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
GO="${GO:-go}"
|
||||
GOLANGCI_LINT="${GOLANGCI_LINT:-golangci-lint}"
|
||||
|
||||
# ── individual checks ────────────────────────────────────────
|
||||
do_deps() {
|
||||
echo "==> Downloading dependencies..."
|
||||
$GO mod download
|
||||
$GO mod verify
|
||||
}
|
||||
|
||||
do_fmt() {
|
||||
echo "==> Formatting..."
|
||||
$GOLANGCI_LINT fmt
|
||||
}
|
||||
|
||||
do_vet() {
|
||||
echo "==> Running vet..."
|
||||
$GO vet ./...
|
||||
}
|
||||
|
||||
do_test() {
|
||||
echo "==> Running tests..."
|
||||
$GO test ./...
|
||||
}
|
||||
|
||||
do_lint() {
|
||||
echo "==> Running linter..."
|
||||
$GOLANGCI_LINT run
|
||||
}
|
||||
|
||||
do_all() {
|
||||
do_deps
|
||||
do_fmt
|
||||
do_vet
|
||||
do_test
|
||||
echo ""
|
||||
echo "All checks passed."
|
||||
}
|
||||
|
||||
# ── main ─────────────────────────────────────────────────────
|
||||
case "${1:-}" in
|
||||
test) do_test ;;
|
||||
lint) do_lint ;;
|
||||
fmt) do_fmt ;;
|
||||
vet) do_vet ;;
|
||||
deps) do_deps ;;
|
||||
"") do_all ;;
|
||||
*)
|
||||
echo "Usage: $(basename "$0") [test|lint|fmt|vet|deps]"
|
||||
echo " (no argument runs all checks)"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
67
scripts/deploy.sh
Executable file
67
scripts/deploy.sh
Executable file
|
|
@ -0,0 +1,67 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BINARY_NAME="picoclaw"
|
||||
BUILD_DIR="build"
|
||||
INSTALL_BIN_DIR="${HOME}/.local/bin"
|
||||
GATEWAY_LOG="/tmp/picoclaw-gateway.log"
|
||||
HEALTH_URL="http://localhost:18790/health"
|
||||
|
||||
# ── step 1: build ────────────────────────────────────────────
|
||||
echo "==> Building..."
|
||||
"${SCRIPT_DIR}/build.sh"
|
||||
|
||||
# ── step 2: stop old gateway ─────────────────────────────────
|
||||
OLD_PID=$(pgrep -f "${BINARY_NAME} gateway" 2>/dev/null || true)
|
||||
|
||||
if [[ -n "$OLD_PID" ]]; then
|
||||
echo "==> Stopping gateway (PID ${OLD_PID})..."
|
||||
kill "$OLD_PID" 2>/dev/null || true
|
||||
|
||||
# Wait up to 5 seconds for graceful shutdown
|
||||
for i in $(seq 1 10); do
|
||||
if ! kill -0 "$OLD_PID" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
# Force kill if still running
|
||||
if kill -0 "$OLD_PID" 2>/dev/null; then
|
||||
echo " Force killing..."
|
||||
kill -9 "$OLD_PID" 2>/dev/null || true
|
||||
sleep 0.5
|
||||
fi
|
||||
echo " Gateway stopped."
|
||||
else
|
||||
echo "==> No running gateway found."
|
||||
fi
|
||||
|
||||
# ── step 3: install new binary ───────────────────────────────
|
||||
echo "==> Installing..."
|
||||
mkdir -p "$INSTALL_BIN_DIR"
|
||||
cp "${BUILD_DIR}/${BINARY_NAME}" "${INSTALL_BIN_DIR}/${BINARY_NAME}.new"
|
||||
chmod +x "${INSTALL_BIN_DIR}/${BINARY_NAME}.new"
|
||||
mv -f "${INSTALL_BIN_DIR}/${BINARY_NAME}.new" "${INSTALL_BIN_DIR}/${BINARY_NAME}"
|
||||
echo " Installed to ${INSTALL_BIN_DIR}/${BINARY_NAME}"
|
||||
|
||||
# ── step 4: start new gateway ────────────────────────────────
|
||||
echo "==> Starting gateway..."
|
||||
nohup "${INSTALL_BIN_DIR}/${BINARY_NAME}" gateway > "$GATEWAY_LOG" 2>&1 &
|
||||
NEW_PID=$!
|
||||
echo " PID: ${NEW_PID}"
|
||||
echo " Log: ${GATEWAY_LOG}"
|
||||
|
||||
# ── step 5: health check ─────────────────────────────────────
|
||||
echo "==> Waiting for health check..."
|
||||
sleep 2
|
||||
|
||||
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
|
||||
echo " Health check passed."
|
||||
echo ""
|
||||
echo "Deploy complete. Gateway running as PID ${NEW_PID}."
|
||||
else
|
||||
echo " Health check failed (gateway may still be starting)."
|
||||
echo " Check logs: tail -f ${GATEWAY_LOG}"
|
||||
fi
|
||||
29
scripts/docker.sh
Executable file
29
scripts/docker.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE_NAME="picoclaw"
|
||||
TAG="${1:-latest}"
|
||||
|
||||
# ── version from git ─────────────────────────────────────────
|
||||
if [[ "$TAG" == "latest" ]]; then
|
||||
GIT_TAG="$(git describe --tags --always --dirty 2>/dev/null || echo "dev")"
|
||||
else
|
||||
GIT_TAG="$TAG"
|
||||
fi
|
||||
|
||||
echo "==> Building Docker image: ${IMAGE_NAME}:${TAG}"
|
||||
echo " Version: ${GIT_TAG}"
|
||||
|
||||
docker build \
|
||||
-t "${IMAGE_NAME}:${TAG}" \
|
||||
-t "${IMAGE_NAME}:${GIT_TAG}" \
|
||||
.
|
||||
|
||||
echo ""
|
||||
echo "Build complete:"
|
||||
echo " ${IMAGE_NAME}:${TAG}"
|
||||
echo " ${IMAGE_NAME}:${GIT_TAG}"
|
||||
echo ""
|
||||
echo "Run with:"
|
||||
echo " docker run --rm ${IMAGE_NAME}:${TAG} version"
|
||||
echo " docker run -v config.json:/home/picoclaw/.picoclaw/config.json:ro ${IMAGE_NAME}:${TAG} gateway"
|
||||
60
scripts/install.sh
Executable file
60
scripts/install.sh
Executable file
|
|
@ -0,0 +1,60 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
BINARY_NAME="picoclaw"
|
||||
BUILD_DIR="build"
|
||||
INSTALL_PREFIX="${HOME}/.local"
|
||||
PICOCLAW_HOME="${HOME}/.picoclaw"
|
||||
|
||||
# ── parse args ───────────────────────────────────────────────
|
||||
ACTION="install"
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--prefix) INSTALL_PREFIX="$2"; shift 2 ;;
|
||||
--uninstall) ACTION="uninstall"; shift ;;
|
||||
--uninstall-all) ACTION="uninstall-all"; shift ;;
|
||||
*) echo "Unknown option: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
INSTALL_BIN_DIR="${INSTALL_PREFIX}/bin"
|
||||
|
||||
# ── install ──────────────────────────────────────────────────
|
||||
do_install() {
|
||||
"${SCRIPT_DIR}/build.sh"
|
||||
|
||||
echo "Installing ${BINARY_NAME}..."
|
||||
mkdir -p "$INSTALL_BIN_DIR"
|
||||
|
||||
# Atomic install: copy to temp, then rename
|
||||
cp "${BUILD_DIR}/${BINARY_NAME}" "${INSTALL_BIN_DIR}/${BINARY_NAME}.new"
|
||||
chmod +x "${INSTALL_BIN_DIR}/${BINARY_NAME}.new"
|
||||
mv -f "${INSTALL_BIN_DIR}/${BINARY_NAME}.new" "${INSTALL_BIN_DIR}/${BINARY_NAME}"
|
||||
|
||||
echo "Installed to ${INSTALL_BIN_DIR}/${BINARY_NAME}"
|
||||
}
|
||||
|
||||
# ── uninstall ────────────────────────────────────────────────
|
||||
do_uninstall() {
|
||||
echo "Uninstalling ${BINARY_NAME}..."
|
||||
rm -f "${INSTALL_BIN_DIR}/${BINARY_NAME}"
|
||||
echo "Removed binary from ${INSTALL_BIN_DIR}/${BINARY_NAME}"
|
||||
echo "Note: config and workspace preserved. Use --uninstall-all to remove everything."
|
||||
}
|
||||
|
||||
# ── uninstall-all ────────────────────────────────────────────
|
||||
do_uninstall_all() {
|
||||
do_uninstall
|
||||
echo "Removing workspace and config..."
|
||||
rm -rf "$PICOCLAW_HOME"
|
||||
echo "Removed ${PICOCLAW_HOME}"
|
||||
echo "Complete uninstallation done!"
|
||||
}
|
||||
|
||||
# ── main ─────────────────────────────────────────────────────
|
||||
case "$ACTION" in
|
||||
install) do_install ;;
|
||||
uninstall) do_uninstall ;;
|
||||
uninstall-all) do_uninstall_all ;;
|
||||
esac
|
||||
101
scripts/setup.sh
Executable file
101
scripts/setup.sh
Executable file
|
|
@ -0,0 +1,101 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REQUIRED_GO_MAJOR=1
|
||||
REQUIRED_GO_MINOR=25
|
||||
PICOCLAW_HOME="${HOME}/.picoclaw"
|
||||
|
||||
passed=0
|
||||
failed=0
|
||||
|
||||
check() {
|
||||
local name="$1" ok="$2" msg="$3"
|
||||
if [[ "$ok" == "true" ]]; then
|
||||
echo " [ok] ${name}: ${msg}"
|
||||
passed=$((passed + 1))
|
||||
else
|
||||
echo " [!!] ${name}: ${msg}"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "PicoClaw Environment Setup"
|
||||
echo "=========================="
|
||||
echo ""
|
||||
|
||||
# ── Go ───────────────────────────────────────────────────────
|
||||
echo "Checking dependencies..."
|
||||
if command -v go &>/dev/null; then
|
||||
go_ver="$(go version | awk '{print $3}' | sed 's/go//')"
|
||||
go_major="${go_ver%%.*}"
|
||||
go_minor="${go_ver#*.}"
|
||||
go_minor="${go_minor%%.*}"
|
||||
|
||||
if [[ "$go_major" -gt "$REQUIRED_GO_MAJOR" ]] || \
|
||||
{ [[ "$go_major" -eq "$REQUIRED_GO_MAJOR" ]] && [[ "$go_minor" -ge "$REQUIRED_GO_MINOR" ]]; }; then
|
||||
check "Go" "true" "go${go_ver}"
|
||||
else
|
||||
check "Go" "false" "go${go_ver} (need >= ${REQUIRED_GO_MAJOR}.${REQUIRED_GO_MINOR})"
|
||||
fi
|
||||
else
|
||||
check "Go" "false" "not installed (https://go.dev/dl/)"
|
||||
fi
|
||||
|
||||
# ── golangci-lint ────────────────────────────────────────────
|
||||
if command -v golangci-lint &>/dev/null; then
|
||||
lint_ver="$(golangci-lint version --format short 2>/dev/null || echo "unknown")"
|
||||
check "golangci-lint" "true" "v${lint_ver}"
|
||||
else
|
||||
check "golangci-lint" "false" "not installed (https://golangci-lint.run/welcome/install/)"
|
||||
fi
|
||||
|
||||
# ── git ──────────────────────────────────────────────────────
|
||||
if command -v git &>/dev/null; then
|
||||
git_ver="$(git --version | awk '{print $3}')"
|
||||
check "git" "true" "${git_ver}"
|
||||
else
|
||||
check "git" "false" "not installed"
|
||||
fi
|
||||
|
||||
# ── curl (for health checks) ────────────────────────────────
|
||||
if command -v curl &>/dev/null; then
|
||||
check "curl" "true" "available"
|
||||
else
|
||||
check "curl" "false" "not installed (needed for deploy health checks)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# ── download dependencies ────────────────────────────────────
|
||||
if command -v go &>/dev/null; then
|
||||
echo "Downloading Go dependencies..."
|
||||
go mod download
|
||||
go mod verify
|
||||
echo " Dependencies ready."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── picoclaw onboard ────────────────────────────────────────
|
||||
if [[ ! -d "$PICOCLAW_HOME" ]]; then
|
||||
if command -v picoclaw &>/dev/null; then
|
||||
echo "Running picoclaw onboard..."
|
||||
picoclaw onboard
|
||||
echo ""
|
||||
else
|
||||
echo "Note: Run 'picoclaw onboard' after first install to initialize workspace."
|
||||
echo ""
|
||||
fi
|
||||
else
|
||||
echo "Workspace: ${PICOCLAW_HOME} (exists)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── summary ──────────────────────────────────────────────────
|
||||
echo "=========================="
|
||||
if [[ "$failed" -eq 0 ]]; then
|
||||
echo "All checks passed (${passed}/${passed})."
|
||||
echo "Ready to build: scripts/build.sh"
|
||||
else
|
||||
echo "${passed} passed, ${failed} failed."
|
||||
echo "Fix the issues above, then re-run this script."
|
||||
fi
|
||||
Loading…
Add table
Reference in a new issue