diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 368a0f06b..5ff7a8f25 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -11,6 +11,8 @@ builds: - id: picoclaw env: - CGO_ENABLED=0 + ldflags: + - -s -w goos: - linux - windows @@ -28,6 +30,19 @@ builds: - goos: windows goarch: arm + # Android/Termux requires PIE executables (ET_DYN) built with GOOS=android. + # Standard Linux binaries (ET_EXEC) are rejected by Android's kernel. + - id: picoclaw-android + env: + - CGO_ENABLED=0 + ldflags: + - -s -w + goos: + - android + goarch: + - arm64 + main: ./cmd/picoclaw + dockers_v2: - id: picoclaw dockerfile: Dockerfile.goreleaser @@ -45,7 +60,10 @@ dockers_v2: - linux/riscv64 archives: - - formats: [tar.gz] + - ids: + - picoclaw + - picoclaw-android + formats: [tar.gz] # this name template makes the OS and Arch compatible with the results of `uname`. name_template: >- {{ .ProjectName }}_ diff --git a/assets/scripts/picoclaw-manager.sh b/assets/scripts/picoclaw-manager.sh index 18b7ac5d8..71dc35e9a 100644 --- a/assets/scripts/picoclaw-manager.sh +++ b/assets/scripts/picoclaw-manager.sh @@ -9,8 +9,10 @@ BLUE='\033[0;34m' YELLOW='\033[1;33m' NC='\033[0m' # No Color +GITHUB_REPO="sipeed/picoclaw" CONFIG_FILE="$HOME/.picoclaw/config.json" -REPO_DIR="$HOME/.picoclaw-repo" +DATA_DIR="$HOME/.picoclaw" +LOG_FILE="$DATA_DIR/picoclaw.log" BIN_PATH="$PREFIX/bin/picoclaw" show_header() { @@ -29,9 +31,15 @@ EOF echo -e "==========================================${NC}" } +is_android() { + [[ "$(uname -o 2>/dev/null)" == "Android" ]] || [ -n "$ANDROID_ROOT" ] || [ -d "/data/data/com.termux" ] +} + check_dependencies() { echo -e "${YELLOW}[*] Checking dependencies...${NC}" - deps=("golang" "git" "make" "jq" "tmux") + + deps=("curl" "jq" "tmux" "tar") + to_install=() for dep in "${deps[@]}"; do if ! command -v "$dep" &> /dev/null; then @@ -47,34 +55,100 @@ check_dependencies() { fi } -install_picoclaw() { - check_dependencies - - echo -e "${YELLOW}[*] Cloning repository...${NC}" - if [ -d "$REPO_DIR" ]; then - cd "$REPO_DIR" && git pull - else - git clone https://github.com/sipeed/picoclaw.git "$REPO_DIR" - cd "$REPO_DIR" +detect_arch() { + local arch + arch=$(uname -m) + case "$arch" in + aarch64|arm64) echo "arm64" ;; + armv7l|armv6l) echo "armv6" ;; + x86_64|amd64) echo "x86_64" ;; + *) + echo -e "${RED}[!] Unsupported architecture: $arch${NC}" >&2 + return 1 + ;; + esac +} + +get_latest_version() { + curl -s "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" | jq -r '.tag_name' +} + +# Download prebuilt binary from GitHub Releases +install_from_release() { + local version="$1" + local arch="$2" + + # Android/Termux uses a dedicated Android binary (GOOS=android, PIE). + # Standard Linux uses the regular Linux binary. + local os_name="Linux" + if is_android; then + os_name="Android" fi - echo -e "${YELLOW}[*] Building PicoClaw...${NC}" - # Adjust Go version - GO_VERSION=$(go version | awk '{print $3}' | sed 's/go//' | cut -d. -f1,2,3) - sed -i "s/go 1.25.7/go $GO_VERSION/" go.mod - - export CGO_ENABLED=0 - make deps - make build + local filename="picoclaw_${os_name}_${arch}.tar.gz" + local url="https://github.com/${GITHUB_REPO}/releases/download/${version}/${filename}" + local tmpdir + tmpdir=$(mktemp -d -p "${TMPDIR:-/tmp}") - echo -e "${YELLOW}[*] Installing to bin...${NC}" - cp build/picoclaw-linux-arm64 "$BIN_PATH" + echo -e "${YELLOW}[*] Downloading ${filename}...${NC}" + if ! curl -fSL --progress-bar "$url" -o "${tmpdir}/${filename}"; then + echo -e "${RED}[!] Download failed. URL: ${url}${NC}" + rm -rf "$tmpdir" + return 1 + fi + + echo -e "${YELLOW}[*] Extracting binary...${NC}" + tar -xzf "${tmpdir}/${filename}" -C "$tmpdir" + + if [ ! -f "${tmpdir}/picoclaw" ]; then + echo -e "${RED}[!] Binary not found in archive.${NC}" + rm -rf "$tmpdir" + return 1 + fi + + mkdir -p "$(dirname "$BIN_PATH")" + cp "${tmpdir}/picoclaw" "$BIN_PATH" chmod +x "$BIN_PATH" - + rm -rf "$tmpdir" + return 0 +} + +install_picoclaw() { + check_dependencies + + echo -e "${YELLOW}[*] Detecting architecture...${NC}" + local arch + arch=$(detect_arch) + if [ $? -ne 0 ] || [ -z "$arch" ]; then + echo -e "${RED}[!] Could not detect architecture. Aborting.${NC}" + read -p "Press Enter to return to menu..." + return + fi + echo -e "${GREEN}[✓] Architecture: $arch${NC}" + + echo -e "${YELLOW}[*] Fetching latest release...${NC}" + local version + version=$(get_latest_version) + if [ -z "$version" ] || [ "$version" = "null" ]; then + echo -e "${RED}[!] Could not fetch latest version. Check your internet connection.${NC}" + read -p "Press Enter to return to menu..." + return + fi + echo -e "${GREEN}[✓] Latest version: $version${NC}" + + install_from_release "$version" "$arch" + + if [ $? -ne 0 ]; then + echo -e "${RED}[!] Installation failed.${NC}" + read -p "Press Enter to return to menu..." + return + fi + if ! command -v picoclaw &> /dev/null; then - echo -e "${RED}[!] Installation failed. Bin not in PATH?${NC}" + echo -e "${RED}[!] Installation failed. Is $PREFIX/bin in your PATH?${NC}" else - echo -e "${GREEN}[✓] PicoClaw installed successfully!${NC}" + echo -e "${GREEN}[✓] PicoClaw ${version} installed successfully!${NC}" + echo -e "${YELLOW}[*] Running initial setup...${NC}" picoclaw onboard fi read -p "Press Enter to return to menu..." @@ -121,7 +195,8 @@ manage_service() { if tmux has-session -t picoclaw 2>/dev/null; then echo -e "${YELLOW}[!] Session 'picoclaw' already exists.${NC}" else - tmux new-session -d -s picoclaw "picoclaw gateway | tee $REPO_DIR/picoclaw.log" + mkdir -p "$DATA_DIR" + tmux new-session -d -s picoclaw "picoclaw gateway 2>&1 | tee $LOG_FILE" echo -e "${GREEN}[✓] Gateway started in tmux session 'picoclaw'.${NC}" fi ;; @@ -134,11 +209,16 @@ manage_service() { pkill -9 picoclaw tmux kill-session -t picoclaw 2>/dev/null sleep 1 - tmux new-session -d -s picoclaw "picoclaw gateway | tee $REPO_DIR/picoclaw.log" + mkdir -p "$DATA_DIR" + tmux new-session -d -s picoclaw "picoclaw gateway 2>&1 | tee $LOG_FILE" echo -e "${GREEN}[✓] Gateway restarted.${NC}" ;; 4) - tail -f "$REPO_DIR/picoclaw.log" + if [ -f "$LOG_FILE" ]; then + tail -f "$LOG_FILE" + else + echo -e "${YELLOW}[!] No log file found. Start the gateway first.${NC}" + fi ;; *) return ;; esac @@ -150,12 +230,12 @@ uninstall_picoclaw() { if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then pkill -9 picoclaw tmux kill-session -t picoclaw 2>/dev/null - rm "$BIN_PATH" - rm -rf "$REPO_DIR" - rm -rf "$HOME/.picoclaw" + rm -f "$BIN_PATH" + rm -rf "$DATA_DIR" echo -e "${GREEN}[✓] PicoClaw uninstalled.${NC}" fi read -p "Press Enter to return to menu..." +} network_diagnostics() { echo -e "${BLUE}--- Network Diagnostics ---${NC}" diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 03c136189..9ec0371cf 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -120,6 +120,17 @@ func copyDirectory(src, dst string) error { } func main() { + // Android's Bionic linker duplicates argv[0] when loading PIE executables, + // inserting the full binary path as argv[1] and shifting real arguments. + // For example: argv = ["picoclaw", "/data/.../bin/picoclaw", "onboard"] + // Detect this by checking if argv[1] is a path whose base name matches + // argv[0] (the program name). + if len(os.Args) >= 2 && runtime.GOOS == "android" { + if filepath.Base(os.Args[1]) == filepath.Base(os.Args[0]) && strings.Contains(os.Args[1], "/") { + os.Args = append(os.Args[:1], os.Args[2:]...) + } + } + if len(os.Args) < 2 { printHelp() os.Exit(1) diff --git a/docs/TERMUX_INSTALL.md b/docs/TERMUX_INSTALL.md index 6c6556274..df800db30 100644 --- a/docs/TERMUX_INSTALL.md +++ b/docs/TERMUX_INSTALL.md @@ -26,31 +26,25 @@ If you prefer to set up everything manually, follow these steps: Open Termux and install the necessary packages: ```bash pkg update && pkg upgrade -pkg install -y golang git make jq tmux +pkg install -y curl jq tmux ``` -## 2. Clone and Prepare PicoClaw -```bash -git clone https://github.com/sipeed/picoclaw.git ~/.picoclaw-repo -cd ~/.picoclaw-repo -``` +## 2. Download Pre-compiled Binary +PicoClaw provides pre-compiled Android binaries for every release — no build tools needed. -### Fix Go Version Requirement -Termux might have a slightly older version of Go than required by `go.mod`. Use this command to automatically adjust the requirement to match your installed Go version: ```bash -sed -i "s/go 1.25.7/go $(go version | awk '{print $3}' | sed 's/go//' | cut -d. -f1,2,3)/" go.mod -``` +# Fetch latest version tag +VERSION=$(curl -s https://api.github.com/repos/sipeed/picoclaw/releases/latest | jq -r '.tag_name') +echo "Installing PicoClaw $VERSION..." -### Build and Install PicoClaw -Build the project with CGO disabled for maximum compatibility across different Android architectures, then move it to your system PATH: -```bash -export CGO_ENABLED=0 -make deps -make build +# Download the Android arm64 binary (built with GOOS=android for Termux compatibility) +curl -fSL "https://github.com/sipeed/picoclaw/releases/download/${VERSION}/picoclaw_Android_arm64.tar.gz" -o /tmp/picoclaw.tar.gz +tar -xzf /tmp/picoclaw.tar.gz -C /tmp picoclaw # Install to Termux bin directory -cp build/picoclaw-linux-arm64 $PREFIX/bin/picoclaw +cp /tmp/picoclaw $PREFIX/bin/picoclaw chmod +x $PREFIX/bin/picoclaw +rm /tmp/picoclaw.tar.gz /tmp/picoclaw ``` Now you can run `picoclaw` from anywhere! diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 0bd38abf4..724ad8af3 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -51,7 +51,7 @@ func TestRecordLastChannel(t *testing.T) { // Create agent loop msgBus := bus.NewMessageBus() provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, msgBus, provider, "") // Test RecordLastChannel testChannel := "test-channel" @@ -67,7 +67,7 @@ func TestRecordLastChannel(t *testing.T) { } // Verify persistence by creating a new agent loop - al2 := NewAgentLoop(cfg, msgBus, provider) + al2 := NewAgentLoop(cfg, msgBus, provider, "") if al2.state.GetLastChannel() != testChannel { t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, al2.state.GetLastChannel()) } @@ -96,7 +96,7 @@ func TestRecordLastChatID(t *testing.T) { // Create agent loop msgBus := bus.NewMessageBus() provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, msgBus, provider, "") // Test RecordLastChatID testChatID := "test-chat-id-123" @@ -112,7 +112,7 @@ func TestRecordLastChatID(t *testing.T) { } // Verify persistence by creating a new agent loop - al2 := NewAgentLoop(cfg, msgBus, provider) + al2 := NewAgentLoop(cfg, msgBus, provider, "") if al2.state.GetLastChatID() != testChatID { t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, al2.state.GetLastChatID()) } @@ -141,7 +141,7 @@ func TestNewAgentLoop_StateInitialized(t *testing.T) { // Create agent loop msgBus := bus.NewMessageBus() provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, msgBus, provider, "") // Verify state manager is initialized if al.state == nil { @@ -176,7 +176,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) { msgBus := bus.NewMessageBus() provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, msgBus, provider, "") // Register a custom tool customTool := &mockCustomTool{} @@ -222,7 +222,7 @@ func TestToolContext_Updates(t *testing.T) { msgBus := bus.NewMessageBus() provider := &simpleMockProvider{response: "OK"} - _ = NewAgentLoop(cfg, msgBus, provider) + _ = NewAgentLoop(cfg, msgBus, provider, "") // Verify that ContextualTool interface is defined and can be implemented // This test validates the interface contract exists @@ -253,7 +253,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) { msgBus := bus.NewMessageBus() provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, msgBus, provider, "") // Register a test tool and verify it shows up in startup info testTool := &mockCustomTool{} @@ -297,7 +297,7 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) { msgBus := bus.NewMessageBus() provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, msgBus, provider, "") info := al.GetStartupInfo() @@ -344,7 +344,7 @@ func TestAgentLoop_Stop(t *testing.T) { msgBus := bus.NewMessageBus() provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, msgBus, provider, "") // Note: running is only set to true when Run() is called // We can't test that without starting the event loop @@ -466,7 +466,7 @@ func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) { msgBus := bus.NewMessageBus() provider := &simpleMockProvider{response: "File operation complete"} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, msgBus, provider, "") helper := testHelper{al: al} // ReadFileTool returns SilentResult, which should not send user message @@ -508,7 +508,7 @@ func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) { msgBus := bus.NewMessageBus() provider := &simpleMockProvider{response: "Command output: hello world"} - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, msgBus, provider, "") helper := testHelper{al: al} // ExecTool returns UserResult, which should send user message @@ -581,7 +581,7 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { successResp: "Recovered from context error", } - al := NewAgentLoop(cfg, msgBus, provider) + al := NewAgentLoop(cfg, msgBus, provider, "") // Inject some history to simulate a full context sessionKey := "test-session-context"