Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions cmd/picoclaw/doctor_binary_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package main

import "testing"

func TestLookPathWithFallbackFindsBinaryOutsidePATH(t *testing.T) {
t.Setenv("PATH", "/tmp/definitely-not-a-real-bin")

p, err := lookPathWithFallback("sh")
if err != nil {
t.Fatalf("lookPathWithFallback(sh) returned error: %v", err)
}
if p == "" {
t.Fatalf("lookPathWithFallback(sh) returned empty path")
}
}

func TestLookPathWithFallbackReturnsErrorForMissingBinary(t *testing.T) {
t.Setenv("PATH", "/tmp/definitely-not-a-real-bin")

if _, err := lookPathWithFallback("sciclaw-definitely-missing-binary-name"); err == nil {
t.Fatalf("expected error for missing binary")
}
}

22 changes: 19 additions & 3 deletions cmd/picoclaw/doctor_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ func runDoctor(opts doctorOptions) doctorReport {
}

// Gateway log quick scan: common Telegram 409 conflict from multiple instances.
add(checkGatewayLog())
add(checkGatewayLog(cfg != nil && cfg.Channels.Telegram.Enabled))
for _, c := range checkServiceStatus(opts) {
add(c)
}
Expand Down Expand Up @@ -379,7 +379,7 @@ func checkBinaryWithHint(name string, args []string, timeout time.Duration, inst
}

func checkBinary(name string, args []string, timeout time.Duration) doctorCheck {
p, err := exec.LookPath(name)
p, err := lookPathWithFallback(name)
if err != nil {
return doctorCheck{Name: name, Status: doctorErr, Message: "not found in PATH"}
}
Expand Down Expand Up @@ -427,6 +427,19 @@ func checkBinary(name string, args []string, timeout time.Duration) doctorCheck
return c
}

func lookPathWithFallback(name string) (string, error) {
if p, err := exec.LookPath(name); err == nil {
return p, nil
}
for _, dir := range []string{"/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"} {
candidate := filepath.Join(dir, name)
if fileExists(candidate) {
return candidate, nil
}
}
return "", exec.ErrNotFound
}

func checkPandocNIHTemplate() doctorCheck {
path := strings.TrimSpace(tools.ResolvePandocNIHTemplatePath())
if path == "" {
Expand Down Expand Up @@ -508,7 +521,7 @@ func checkWorkspacePythonVenv(workspace string, opts doctorOptions) doctorCheck
}
}

func checkGatewayLog() doctorCheck {
func checkGatewayLog(telegramEnabled bool) doctorCheck {
home, err := os.UserHomeDir()
if err != nil {
return doctorCheck{Name: "gateway.log", Status: doctorSkip, Message: "home directory unavailable"}
Expand All @@ -517,6 +530,9 @@ func checkGatewayLog() doctorCheck {
if !fileExists(p) {
return doctorCheck{Name: "gateway.log", Status: doctorSkip, Message: "not found"}
}
if !telegramEnabled {
return doctorCheck{Name: "gateway.log", Status: doctorSkip, Message: "telegram disabled; skipped 409 conflict scan"}
}

tail, err := readTail(p, 128*1024)
if err != nil {
Expand Down
52 changes: 52 additions & 0 deletions cmd/picoclaw/doctor_gateway_log_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package main

import (
"os"
"path/filepath"
"testing"
)

func TestCheckGatewayLogSkipsTelegramConflictWhenDisabled(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
logPath := filepath.Join(home, ".picoclaw", "gateway.log")
if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}

content := `[Sat Feb 21 22:16:27 EST 2026] ERROR Getting updates: telego: getUpdates: api: 409 "Conflict: terminated by other getUpdates request; make sure that only one bot instance is running"`
if err := os.WriteFile(logPath, []byte(content), 0644); err != nil {
t.Fatalf("write log: %v", err)
}

got := checkGatewayLog(false)
if got.Status != doctorSkip {
t.Fatalf("status = %q, want %q", got.Status, doctorSkip)
}
if got.Name != "gateway.log" {
t.Fatalf("name = %q, want gateway.log", got.Name)
}
}

func TestCheckGatewayLogFlagsConflictWhenTelegramEnabled(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
logPath := filepath.Join(home, ".picoclaw", "gateway.log")
if err := os.MkdirAll(filepath.Dir(logPath), 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}

content := `[Sat Feb 21 22:16:27 EST 2026] ERROR Getting updates: telego: getUpdates: api: 409 "Conflict: terminated by other getUpdates request; make sure that only one bot instance is running"`
if err := os.WriteFile(logPath, []byte(content), 0644); err != nil {
t.Fatalf("write log: %v", err)
}

got := checkGatewayLog(true)
if got.Status != doctorErr {
t.Fatalf("status = %q, want %q", got.Status, doctorErr)
}
if got.Name != "gateway.telegram" {
t.Fatalf("name = %q, want gateway.telegram", got.Name)
}
}

18 changes: 17 additions & 1 deletion cmd/picoclaw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,22 @@ func modelsCmd() {
switch os.Args[2] {
case "list":
models.PrintList(cfg)
case "discover":
jsonOut := false
if len(os.Args) >= 4 && os.Args[3] == "--json" {
jsonOut = true
}
result := models.Discover(cfg)
if jsonOut {
data, err := json.Marshal(result)
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
fmt.Println(string(data))
return
}
models.PrintDiscover(result)
case "set":
if len(os.Args) < 4 {
fmt.Printf("Usage: %s models set <model>\n", commandName)
Expand All @@ -1311,7 +1327,7 @@ func modelsCmd() {
models.PrintStatus(cfg)
default:
fmt.Printf("Unknown models command: %s\n", os.Args[2])
fmt.Printf("Usage: %s models [list|set|effort|status]\n", commandName)
fmt.Printf("Usage: %s models [list|discover|set|effort|status]\n", commandName)
}
}

Expand Down
94 changes: 92 additions & 2 deletions cmd/picoclaw/tui/executor_local.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,21 @@ func (e *LocalExecutor) ServiceInstalled() bool {
if err != nil {
return false
}
return !strings.Contains(out, "not installed")
if installed, ok := parseServiceStatusFlag(out, "installed"); ok {
return installed
}
return !strings.Contains(strings.ToLower(out), "not installed")
}

func (e *LocalExecutor) ServiceActive() bool {
out, err := runLocalCmd(5*time.Second, "sciclaw", "service", "status")
if err != nil {
return false
}
return strings.Contains(out, "Running: yes") || strings.Contains(out, "running: yes")
if running, ok := parseServiceStatusFlag(out, "running"); ok {
return running
}
return strings.Contains(strings.ToLower(out), "running: yes")
}

func (e *LocalExecutor) InteractiveProcess(args ...string) *exec.Cmd {
Expand All @@ -83,6 +89,7 @@ func (e *LocalExecutor) InteractiveProcess(args ...string) *exec.Cmd {

func runLocalCmd(timeout time.Duration, name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
cmd.Env = localCommandEnv()
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
Expand All @@ -104,3 +111,86 @@ func runLocalCmd(timeout time.Duration, name string, args ...string) (string, er
return "", exec.ErrNotFound
}
}

func localCommandEnv() []string {
env := append([]string(nil), os.Environ()...)

currentPath := ""
pathIdx := -1
for i, kv := range env {
if strings.HasPrefix(kv, "PATH=") {
pathIdx = i
currentPath = strings.TrimPrefix(kv, "PATH=")
break
}
}

var preferred []string
if exe, err := os.Executable(); err == nil {
if dir := strings.TrimSpace(filepath.Dir(exe)); dir != "" {
preferred = append(preferred, dir)
}
}
if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
preferred = append(preferred, filepath.Join(home, ".local", "bin"))
}
preferred = append(preferred, "/opt/homebrew/bin", "/usr/local/bin")

mergedPath := mergePathList(preferred, currentPath)
pathKV := "PATH=" + mergedPath
if pathIdx >= 0 {
env[pathIdx] = pathKV
return env
}
return append(env, pathKV)
}

func mergePathList(preferred []string, current string) string {
seen := map[string]struct{}{}
var ordered []string

appendPart := func(p string) {
p = strings.TrimSpace(p)
if p == "" {
return
}
if _, ok := seen[p]; ok {
return
}
seen[p] = struct{}{}
ordered = append(ordered, p)
}

for _, p := range preferred {
appendPart(p)
}
for _, p := range strings.Split(current, string(os.PathListSeparator)) {
appendPart(p)
}

return strings.Join(ordered, string(os.PathListSeparator))
}

func parseServiceStatusFlag(out, key string) (bool, bool) {
keyLower := strings.ToLower(strings.TrimSpace(key))
for _, raw := range strings.Split(out, "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
lineLower := strings.ToLower(line)
prefix := keyLower + ":"
if !strings.HasPrefix(lineLower, prefix) {
continue
}
val := strings.TrimSpace(lineLower[len(prefix):])
switch {
case strings.HasPrefix(val, "yes"):
return true, true
case strings.HasPrefix(val, "no"):
return false, true
}
return false, false
}
return false, false
}
62 changes: 62 additions & 0 deletions cmd/picoclaw/tui/executor_local_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package tui

import (
"strings"
"testing"
)

func TestParseServiceStatusFlag(t *testing.T) {
out := `
Gateway service status:
Backend: launchd
Installed: yes
Running: yes
Enabled: yes
`
if v, ok := parseServiceStatusFlag(out, "installed"); !ok || !v {
t.Fatalf("installed parse = (%v,%v), want (true,true)", v, ok)
}
if v, ok := parseServiceStatusFlag(out, "running"); !ok || !v {
t.Fatalf("running parse = (%v,%v), want (true,true)", v, ok)
}
}

func TestParseServiceStatusFlag_No(t *testing.T) {
out := `
Gateway service status:
Installed: no
Running: no
`
if v, ok := parseServiceStatusFlag(out, "installed"); !ok || v {
t.Fatalf("installed parse = (%v,%v), want (false,true)", v, ok)
}
if v, ok := parseServiceStatusFlag(out, "running"); !ok || v {
t.Fatalf("running parse = (%v,%v), want (false,true)", v, ok)
}
}

func TestMergePathList_PrefersFrontAndDedupes(t *testing.T) {
got := mergePathList(
[]string{"/Users/tester/.local/bin", "/opt/homebrew/bin", "/usr/local/bin"},
"/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin",
)
parts := strings.Split(got, ":")
if len(parts) < 5 {
t.Fatalf("merged path too short: %q", got)
}
if parts[0] != "/Users/tester/.local/bin" {
t.Fatalf("first path = %q, want %q", parts[0], "/Users/tester/.local/bin")
}
if parts[1] != "/opt/homebrew/bin" {
t.Fatalf("second path = %q, want %q", parts[1], "/opt/homebrew/bin")
}
if parts[2] != "/usr/local/bin" {
t.Fatalf("third path = %q, want %q", parts[2], "/usr/local/bin")
}
if strings.Count(got, "/opt/homebrew/bin") != 1 {
t.Fatalf("expected deduped /opt/homebrew/bin in %q", got)
}
if strings.Count(got, "/usr/local/bin") != 1 {
t.Fatalf("expected deduped /usr/local/bin in %q", got)
}
}
Loading
Loading