From afd5430600f122747aefddec423d149a47e9404f Mon Sep 17 00:00:00 2001 From: Ernie Pedapati Date: Sun, 22 Feb 2026 16:33:45 -0500 Subject: [PATCH 01/13] fix(tui): split tab bar into two rows for narrow terminals 12+ tabs overflow on constrained screens. Split evenly into two rows: primary workflow tabs on top, admin/config tabs below. Co-Authored-By: Claude Opus 4.6 --- cmd/picoclaw/tui/model.go | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/cmd/picoclaw/tui/model.go b/cmd/picoclaw/tui/model.go index 9161800..83cfb9c 100644 --- a/cmd/picoclaw/tui/model.go +++ b/cmd/picoclaw/tui/model.go @@ -447,16 +447,29 @@ func (m Model) View() string { } func (m Model) renderTabBar() string { - var tabs []string - for i, t := range m.tabs { - if i == m.activeTab { - tabs = append(tabs, styleTabActive.Render(t.name)) - } else { - tabs = append(tabs, styleTabInactive.Render(t.name)) + // Split tabs into two rows to fit narrow terminals. + // Row 1: primary workflow tabs, Row 2: admin/config tabs. + mid := len(m.tabs) / 2 + if mid < 1 { + mid = len(m.tabs) + } + + renderRow := func(entries []tabEntry, startIdx int) string { + var cells []string + for i, t := range entries { + if startIdx+i == m.activeTab { + cells = append(cells, styleTabActive.Render(t.name)) + } else { + cells = append(cells, styleTabInactive.Render(t.name)) + } } + return lipgloss.JoinHorizontal(lipgloss.Top, cells...) } - row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) - return styleTabBar.Width(m.width).Render(row) + + row1 := renderRow(m.tabs[:mid], 0) + row2 := renderRow(m.tabs[mid:], mid) + both := lipgloss.JoinVertical(lipgloss.Left, row1, row2) + return styleTabBar.Width(m.width).Render(both) } func (m Model) renderStatusBar() string { From c5c49a3edb7719a54e2877d6c15de1ba22b9d35f Mon Sep 17 00:00:00 2001 From: Ernie Pedapati Date: Sun, 22 Feb 2026 16:45:07 -0500 Subject: [PATCH 02/13] fix(tui): expand tilde workspace paths in routing browser --- cmd/picoclaw/tui/tab_routing.go | 32 +++++-- cmd/picoclaw/tui/tab_routing_test.go | 119 +++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 cmd/picoclaw/tui/tab_routing_test.go diff --git a/cmd/picoclaw/tui/tab_routing.go b/cmd/picoclaw/tui/tab_routing.go index 0374e22..b483989 100644 --- a/cmd/picoclaw/tui/tab_routing.go +++ b/cmd/picoclaw/tui/tab_routing.go @@ -568,7 +568,7 @@ func (m RoutingModel) updateAddWizard(msg tea.KeyMsg, snap *VMSnapshot) (Routing m.wizardInput.SetValue("") m.wizardInput.Placeholder = "/absolute/path/to/workspace" if snap != nil && snap.WorkspacePath != "" { - m.wizardInput.SetValue(snap.WorkspacePath) + m.wizardInput.SetValue(expandHomeForExecPath(snap.WorkspacePath, m.exec.HomePath())) } return m, nil } @@ -583,7 +583,7 @@ func (m RoutingModel) updateAddWizard(msg tea.KeyMsg, snap *VMSnapshot) (Routing if val == "" { return m, nil } - m.wizardPath = val + m.wizardPath = expandHomeForExecPath(val, m.exec.HomePath()) m.wizardStep = addStepAllow m.wizardInput.SetValue("") m.wizardInput.Placeholder = "sender_id1,sender_id2" @@ -638,11 +638,12 @@ func (m RoutingModel) updateAddWizard(msg tea.KeyMsg, snap *VMSnapshot) (Routing func (m *RoutingModel) startBrowse(snap *VMSnapshot) tea.Cmd { m.mode = routingBrowseFolder - startPath := strings.TrimSpace(m.wizardInput.Value()) - if startPath == "" || !strings.HasPrefix(startPath, "/") { + startPath := expandHomeForExecPath(strings.TrimSpace(m.wizardInput.Value()), m.exec.HomePath()) + if startPath == "" || !filepath.IsAbs(startPath) { if snap != nil && snap.WorkspacePath != "" { - startPath = snap.WorkspacePath - } else { + startPath = expandHomeForExecPath(snap.WorkspacePath, m.exec.HomePath()) + } + if startPath == "" || !filepath.IsAbs(startPath) { startPath = m.exec.HomePath() } } @@ -1096,6 +1097,7 @@ func routingReloadCmd(exec Executor) tea.Cmd { func routingAddMappingCmd(exec Executor, channel, chatID, workspace, allowCSV, label string) tea.Cmd { return func() tea.Msg { + workspace = expandHomeForExecPath(workspace, exec.HomePath()) cmd := fmt.Sprintf("HOME=%s sciclaw routing add --channel %s --chat-id %s --workspace %s --allow %s", exec.HomePath(), shellEscape(channel), @@ -1127,10 +1129,11 @@ func routingSetUsersCmd(exec Executor, channel, chatID, allowCSV string) tea.Cmd func fetchDirListCmd(exec Executor, dirPath string) tea.Cmd { return func() tea.Msg { - cmd := fmt.Sprintf("ls -1pF %s 2>/dev/null", shellEscape(dirPath)) + resolvedPath := expandHomeForExecPath(dirPath, exec.HomePath()) + cmd := fmt.Sprintf("ls -1pF %s 2>/dev/null", shellEscape(resolvedPath)) out, err := exec.ExecShell(5*time.Second, cmd) if err != nil { - return routingDirListMsg{path: dirPath, err: "Cannot read directory"} + return routingDirListMsg{path: resolvedPath, err: "Cannot read directory"} } var dirs []string for _, line := range strings.Split(out, "\n") { @@ -1142,8 +1145,19 @@ func fetchDirListCmd(exec Executor, dirPath string) tea.Cmd { dirs = append(dirs, strings.TrimSuffix(line, "/")) } } - return routingDirListMsg{path: dirPath, dirs: dirs} + return routingDirListMsg{path: resolvedPath, dirs: dirs} + } +} + +func expandHomeForExecPath(path, home string) string { + path = strings.TrimSpace(path) + if path == "~" { + return home + } + if strings.HasPrefix(path, "~/") { + return filepath.Join(home, path[2:]) } + return path } func fetchDiscordRoomsCmd(exec Executor) tea.Cmd { diff --git a/cmd/picoclaw/tui/tab_routing_test.go b/cmd/picoclaw/tui/tab_routing_test.go new file mode 100644 index 0000000..39bbfa9 --- /dev/null +++ b/cmd/picoclaw/tui/tab_routing_test.go @@ -0,0 +1,119 @@ +package tui + +import ( + "os" + "os/exec" + "strings" + "testing" + "time" +) + +type routingTestExec struct { + home string + shellOut string + shellErr error + lastShell string +} + +func (e *routingTestExec) Mode() Mode { return ModeLocal } + +func (e *routingTestExec) ExecShell(_ time.Duration, shellCmd string) (string, error) { + e.lastShell = shellCmd + return e.shellOut, e.shellErr +} + +func (e *routingTestExec) ExecCommand(_ time.Duration, _ ...string) (string, error) { return "", nil } + +func (e *routingTestExec) ReadFile(_ string) (string, error) { return "", os.ErrNotExist } + +func (e *routingTestExec) WriteFile(_ string, _ []byte, _ os.FileMode) error { return nil } + +func (e *routingTestExec) ConfigPath() string { return "/tmp/config.json" } + +func (e *routingTestExec) AuthPath() string { return "/tmp/auth.json" } + +func (e *routingTestExec) HomePath() string { return e.home } + +func (e *routingTestExec) AgentVersion() string { return "vtest" } + +func (e *routingTestExec) ServiceInstalled() bool { return false } + +func (e *routingTestExec) ServiceActive() bool { return false } + +func (e *routingTestExec) InteractiveProcess(_ ...string) *exec.Cmd { return exec.Command("true") } + +func TestExpandHomeForExecPath(t *testing.T) { + home := "/Users/tester" + tests := []struct { + in string + want string + }{ + {in: "~", want: "/Users/tester"}, + {in: "~/sciclaw", want: "/Users/tester/sciclaw"}, + {in: " ~/sciclaw/workspace ", want: "/Users/tester/sciclaw/workspace"}, + {in: "/tmp/workspace", want: "/tmp/workspace"}, + {in: "relative/path", want: "relative/path"}, + } + for _, tt := range tests { + if got := expandHomeForExecPath(tt.in, home); got != tt.want { + t.Fatalf("expandHomeForExecPath(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestFetchDirListCmd_ExpandsHomePath(t *testing.T) { + execStub := &routingTestExec{ + home: "/Users/tester", + shellOut: "alpha/\nbeta/\nnotes.txt\n", + } + cmd := fetchDirListCmd(execStub, "~/sciclaw") + msg := cmd().(routingDirListMsg) + + if msg.err != "" { + t.Fatalf("unexpected err: %q", msg.err) + } + if msg.path != "/Users/tester/sciclaw" { + t.Fatalf("msg.path = %q, want %q", msg.path, "/Users/tester/sciclaw") + } + if got, want := strings.Join(msg.dirs, ","), "alpha,beta"; got != want { + t.Fatalf("dirs = %q, want %q", got, want) + } + if !strings.Contains(execStub.lastShell, "/Users/tester/sciclaw") { + t.Fatalf("shell cmd did not use expanded path: %q", execStub.lastShell) + } + if strings.Contains(execStub.lastShell, "~/sciclaw") { + t.Fatalf("shell cmd still contains tilde path: %q", execStub.lastShell) + } +} + +func TestRoutingAddMappingCmd_ExpandsWorkspacePath(t *testing.T) { + execStub := &routingTestExec{ + home: "/Users/tester", + shellOut: "ok", + } + cmd := routingAddMappingCmd(execStub, "discord", "123", "~/sciclaw/workspace", "u1", "") + _ = cmd().(actionDoneMsg) + + if !strings.Contains(execStub.lastShell, "--workspace '/Users/tester/sciclaw/workspace'") { + t.Fatalf("routing add command missing expanded workspace: %q", execStub.lastShell) + } +} + +func TestStartBrowse_UsesExpandedWorkspacePath(t *testing.T) { + execStub := &routingTestExec{ + home: "/Users/tester", + shellOut: "project/\n", + } + m := NewRoutingModel(execStub) + m.wizardInput.SetValue("~/picoclaw/workspace") + + cmd := m.startBrowse(nil) + if m.browserPath != "/Users/tester/picoclaw/workspace" { + t.Fatalf("browserPath = %q, want %q", m.browserPath, "/Users/tester/picoclaw/workspace") + } + + msg := cmd().(routingDirListMsg) + if msg.path != "/Users/tester/picoclaw/workspace" { + t.Fatalf("dir-list path = %q, want %q", msg.path, "/Users/tester/picoclaw/workspace") + } +} From 6eb021df4ac03dbce0fb9d88caaaba9edffe22e2 Mon Sep 17 00:00:00 2001 From: Ernie Pedapati Date: Sun, 22 Feb 2026 16:51:16 -0500 Subject: [PATCH 03/13] fix(tui): improve gateway status parsing and action feedback --- cmd/picoclaw/tui/executor_local.go | 34 +++++++++++++++++++++++-- cmd/picoclaw/tui/executor_local_test.go | 34 +++++++++++++++++++++++++ cmd/picoclaw/tui/model.go | 5 ++++ cmd/picoclaw/tui/tab_agent.go | 34 ++++++++++++++++++++++--- cmd/picoclaw/tui/tab_agent_test.go | 20 +++++++++++++++ 5 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 cmd/picoclaw/tui/executor_local_test.go create mode 100644 cmd/picoclaw/tui/tab_agent_test.go diff --git a/cmd/picoclaw/tui/executor_local.go b/cmd/picoclaw/tui/executor_local.go index d85c92a..4640144 100644 --- a/cmd/picoclaw/tui/executor_local.go +++ b/cmd/picoclaw/tui/executor_local.go @@ -62,7 +62,10 @@ 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 { @@ -70,7 +73,10 @@ func (e *LocalExecutor) ServiceActive() bool { 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 { @@ -104,3 +110,27 @@ func runLocalCmd(timeout time.Duration, name string, args ...string) (string, er return "", exec.ErrNotFound } } + +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 +} diff --git a/cmd/picoclaw/tui/executor_local_test.go b/cmd/picoclaw/tui/executor_local_test.go new file mode 100644 index 0000000..b6dfce8 --- /dev/null +++ b/cmd/picoclaw/tui/executor_local_test.go @@ -0,0 +1,34 @@ +package tui + +import "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) + } +} + diff --git a/cmd/picoclaw/tui/model.go b/cmd/picoclaw/tui/model.go index 83cfb9c..1b36ee4 100644 --- a/cmd/picoclaw/tui/model.go +++ b/cmd/picoclaw/tui/model.go @@ -318,6 +318,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.agent.HandleLogsMsg(msg) return m, nil + case serviceActionMsg: + m.agent.HandleServiceAction(msg) + m.loading = true + return m, tea.Batch(m.spinner.Tick, fetchSnapshotCmd(m.exec)) + case modelsStatusMsg: m.models.HandleStatus(msg) return m, nil diff --git a/cmd/picoclaw/tui/tab_agent.go b/cmd/picoclaw/tui/tab_agent.go index e9f4eaa..a98f7be 100644 --- a/cmd/picoclaw/tui/tab_agent.go +++ b/cmd/picoclaw/tui/tab_agent.go @@ -11,6 +11,11 @@ import ( ) type logsMsg struct{ content string } +type serviceActionMsg struct { + action string + ok bool + output string +} // AgentModel handles the Agent Service tab. type AgentModel struct { @@ -54,6 +59,20 @@ func (m *AgentModel) HandleLogsMsg(msg logsMsg) { m.logsViewport.SetContent(msg.content) } +func (m *AgentModel) HandleServiceAction(msg serviceActionMsg) { + header := fmt.Sprintf("Service %s completed.", msg.action) + if !msg.ok { + header = fmt.Sprintf("Service %s failed.", msg.action) + } + content := header + if strings.TrimSpace(msg.output) != "" { + content += "\n\n" + strings.TrimSpace(msg.output) + } + m.logsContent = content + m.logsLoaded = true + m.logsViewport.SetContent(content) +} + func (m *AgentModel) HandleResize(width, height int) { w := width - 8 if w < 40 { @@ -156,9 +175,18 @@ func serviceBackendLabel(mode Mode) string { func serviceAction(exec Executor, action string) tea.Cmd { return func() tea.Msg { - cmd := "HOME=" + exec.HomePath() + " sciclaw service " + action - _, _ = exec.ExecShell(10*time.Second, cmd) - return actionDoneMsg{output: "Service " + action + " completed."} + cmd := "HOME=" + exec.HomePath() + " sciclaw service " + action + " 2>&1" + out, err := exec.ExecShell(15*time.Second, cmd) + if err != nil { + if strings.TrimSpace(out) == "" { + out = err.Error() + } + return serviceActionMsg{action: action, ok: false, output: strings.TrimSpace(out)} + } + if strings.TrimSpace(out) == "" { + out = "No output." + } + return serviceActionMsg{action: action, ok: true, output: strings.TrimSpace(out)} } } diff --git a/cmd/picoclaw/tui/tab_agent_test.go b/cmd/picoclaw/tui/tab_agent_test.go new file mode 100644 index 0000000..5efc5eb --- /dev/null +++ b/cmd/picoclaw/tui/tab_agent_test.go @@ -0,0 +1,20 @@ +package tui + +import "testing" + +func TestAgentModel_HandleServiceAction(t *testing.T) { + m := NewAgentModel(&routingTestExec{home: "/Users/tester"}) + m.HandleServiceAction(serviceActionMsg{ + action: "start", + ok: true, + output: "service started", + }) + + if !m.logsLoaded { + t.Fatal("logsLoaded = false, want true") + } + if got := m.logsViewport.View(); got == "" { + t.Fatal("logs viewport content is empty") + } +} + From 48b51d5cc845d0ac51ddbd4f2afcec967e096588 Mon Sep 17 00:00:00 2001 From: Ernie Pedapati Date: Sun, 22 Feb 2026 16:56:57 -0500 Subject: [PATCH 04/13] feat(tui): add richer gateway action feedback and momentum --- cmd/picoclaw/tui/tab_agent.go | 247 +++++++++++++++++++++++++++-- cmd/picoclaw/tui/tab_agent_test.go | 92 ++++++++++- 2 files changed, 318 insertions(+), 21 deletions(-) diff --git a/cmd/picoclaw/tui/tab_agent.go b/cmd/picoclaw/tui/tab_agent.go index a98f7be..2463553 100644 --- a/cmd/picoclaw/tui/tab_agent.go +++ b/cmd/picoclaw/tui/tab_agent.go @@ -12,9 +12,14 @@ import ( type logsMsg struct{ content string } type serviceActionMsg struct { - action string - ok bool - output string + action string + ok bool + normalized bool + output string + duration time.Duration + statusKnown bool + installed bool + running bool } // AgentModel handles the Agent Service tab. @@ -23,6 +28,12 @@ type AgentModel struct { logsViewport viewport.Model logsContent string logsLoaded bool + actionBusy bool + actionName string + actionStart time.Time + lastAction serviceActionMsg + lastActionAt time.Time + successStreak int } func NewAgentModel(exec Executor) AgentModel { @@ -32,17 +43,29 @@ func NewAgentModel(exec Executor) AgentModel { } func (m AgentModel) Update(msg tea.KeyMsg, snap *VMSnapshot) (AgentModel, tea.Cmd) { + startAction := func(action string) (AgentModel, tea.Cmd) { + if m.actionBusy { + return m, nil + } + m.actionBusy = true + m.actionName = action + m.actionStart = time.Now() + return m, serviceAction(m.exec, action) + } + switch msg.String() { case "s": - return m, serviceAction(m.exec, "start") + return startAction("start") case "t": - return m, serviceAction(m.exec, "stop") + return startAction("stop") case "r": - return m, serviceAction(m.exec, "restart") + return startAction("restart") case "i": - return m, serviceAction(m.exec, "install") + return startAction("install") + case "f": + return startAction("refresh") case "u": - return m, serviceAction(m.exec, "uninstall") + return startAction("uninstall") case "l": return m, fetchLogs(m.exec) } @@ -60,9 +83,31 @@ func (m *AgentModel) HandleLogsMsg(msg logsMsg) { } func (m *AgentModel) HandleServiceAction(msg serviceActionMsg) { + m.actionBusy = false + m.actionName = "" + m.actionStart = time.Time{} + + now := time.Now() + if msg.ok { + if !m.lastActionAt.IsZero() && now.Sub(m.lastActionAt) <= 90*time.Second { + m.successStreak++ + if m.successStreak < 1 { + m.successStreak = 1 + } + } else { + m.successStreak = 1 + } + } else { + m.successStreak = 0 + } + m.lastAction = msg + m.lastActionAt = now + header := fmt.Sprintf("Service %s completed.", msg.action) if !msg.ok { header = fmt.Sprintf("Service %s failed.", msg.action) + } else if msg.normalized { + header = fmt.Sprintf("Service %s completed (state verified).", msg.action) } content := header if strings.TrimSpace(msg.output) != "" { @@ -126,14 +171,42 @@ func (m AgentModel) renderServicePanel(snap *VMSnapshot, w int) string { styleDim.Render(serviceBackendLabel(m.exec.Mode())), )) + if m.actionBusy { + lines = append(lines, fmt.Sprintf(" %s %s %s", + styleLabel.Render("Action:"), + styleWarn.Render(strings.ToUpper(m.actionName)), + styleDim.Render(renderServiceActionProgress(m.actionName, m.actionStart)), + )) + } + if !m.lastActionAt.IsZero() { + lastStyle := styleOK + if !m.lastAction.ok { + lastStyle = styleErr + } else if m.lastAction.normalized { + lastStyle = styleWarn + } + lines = append(lines, fmt.Sprintf(" %s %s (%s)", + styleLabel.Render("Last:"), + lastStyle.Render(fmt.Sprintf("%s %s", strings.ToUpper(m.lastAction.action), map[bool]string{true: "OK", false: "FAIL"}[m.lastAction.ok])), + styleDim.Render(formatDurationCompact(m.lastAction.duration)), + )) + if m.successStreak > 1 { + lines = append(lines, fmt.Sprintf(" %s %s", + styleLabel.Render("Momentum:"), + styleValue.Render(fmt.Sprintf("x%d %s", m.successStreak, serviceStreakTitle(m.successStreak))), + )) + } + } + lines = append(lines, "") lines = append(lines, fmt.Sprintf(" %s Start %s Stop %s Restart", styleKey.Render("[s]"), styleKey.Render("[t]"), styleKey.Render("[r]"), )) - lines = append(lines, fmt.Sprintf(" %s Install/Reinstall %s Uninstall", + lines = append(lines, fmt.Sprintf(" %s Install/Reinstall %s Refresh %s Uninstall", styleKey.Render("[i]"), + styleKey.Render("[f]"), styleKey.Render("[u]"), )) lines = append(lines, fmt.Sprintf(" %s Fetch latest logs", @@ -175,19 +248,159 @@ func serviceBackendLabel(mode Mode) string { func serviceAction(exec Executor, action string) tea.Cmd { return func() tea.Msg { + started := time.Now() cmd := "HOME=" + exec.HomePath() + " sciclaw service " + action + " 2>&1" - out, err := exec.ExecShell(15*time.Second, cmd) - if err != nil { - if strings.TrimSpace(out) == "" { - out = err.Error() - } - return serviceActionMsg{action: action, ok: false, output: strings.TrimSpace(out)} - } + out, err := exec.ExecShell(20*time.Second, cmd) if strings.TrimSpace(out) == "" { out = "No output." } - return serviceActionMsg{action: action, ok: true, output: strings.TrimSpace(out)} + + installed, running, statusKnown, statusOut := serviceStatusSnapshot(exec) + normalized := false + ok := err == nil + if !ok && statusKnown && inferServiceActionSuccess(action, installed, running) { + ok = true + normalized = true + } + + statusLine := "" + if statusKnown { + statusLine = fmt.Sprintf("Observed status: installed=%s running=%s", yesNoPlain(installed), yesNoPlain(running)) + } else if strings.TrimSpace(statusOut) != "" { + statusLine = "Observed status:\n" + strings.TrimSpace(statusOut) + } + if statusLine != "" { + out = strings.TrimSpace(out) + "\n\n" + statusLine + } + + return serviceActionMsg{ + action: action, + ok: ok, + normalized: normalized, + output: strings.TrimSpace(out), + duration: time.Since(started), + statusKnown: statusKnown, + installed: installed, + running: running, + } + } +} + +func serviceStatusSnapshot(exec Executor) (installed, running, statusKnown bool, raw string) { + cmd := "HOME=" + exec.HomePath() + " sciclaw service status 2>&1" + out, err := exec.ExecShell(8*time.Second, cmd) + if err != nil { + return false, false, false, out + } + installed, okInstalled := parseServiceStatusFlagFromOutput(out, "installed") + running, okRunning := parseServiceStatusFlagFromOutput(out, "running") + return installed, running, okInstalled && okRunning, out +} + +func parseServiceStatusFlagFromOutput(out, key string) (bool, bool) { + prefix := strings.ToLower(strings.TrimSpace(key)) + ":" + for _, line := range strings.Split(out, "\n") { + l := strings.ToLower(strings.TrimSpace(line)) + if !strings.HasPrefix(l, prefix) { + continue + } + val := strings.TrimSpace(strings.TrimPrefix(l, prefix)) + switch { + case strings.HasPrefix(val, "yes"): + return true, true + case strings.HasPrefix(val, "no"): + return false, true + } + return false, false + } + return false, false +} + +func inferServiceActionSuccess(action string, installed, running bool) bool { + switch strings.ToLower(strings.TrimSpace(action)) { + case "start", "restart": + return running + case "stop": + return !running + case "install": + return installed + case "uninstall": + return !installed + case "refresh": + return installed && running + default: + return false + } +} + +func renderServiceActionProgress(action string, started time.Time) string { + if started.IsZero() { + return "queued" + } + elapsed := time.Since(started) + target := 8 * time.Second + switch action { + case "stop": + target = 5 * time.Second + case "restart": + target = 10 * time.Second + case "install", "uninstall", "refresh": + target = 12 * time.Second + } + pct := float64(elapsed) / float64(target) + if pct > 1 { + pct = 1 + } + width := 12 + filled := int(pct * float64(width)) + if filled > width { + filled = width + } + bar := "[" + strings.Repeat("=", filled) + strings.Repeat(".", width-filled) + "]" + phase := "processing" + switch action { + case "start": + phase = "booting" + case "stop": + phase = "draining" + case "restart": + phase = "cycling" + case "install": + phase = "provisioning" + case "uninstall": + phase = "tearing-down" + case "refresh": + phase = "syncing" + } + return fmt.Sprintf("%s %s %s", bar, phase, formatDurationCompact(elapsed)) +} + +func formatDurationCompact(d time.Duration) string { + if d < 0 { + d = 0 + } + if d < time.Second { + return fmt.Sprintf("%dms", d.Milliseconds()) + } + return fmt.Sprintf("%.1fs", d.Seconds()) +} + +func serviceStreakTitle(streak int) string { + switch { + case streak >= 6: + return "(SRE mode)" + case streak >= 4: + return "(operator)" + default: + return "(warming up)" + } +} + +func yesNoPlain(v bool) string { + if v { + return "yes" } + return "no" } func fetchLogs(exec Executor) tea.Cmd { diff --git a/cmd/picoclaw/tui/tab_agent_test.go b/cmd/picoclaw/tui/tab_agent_test.go index 5efc5eb..836f1fc 100644 --- a/cmd/picoclaw/tui/tab_agent_test.go +++ b/cmd/picoclaw/tui/tab_agent_test.go @@ -1,13 +1,60 @@ package tui -import "testing" +import ( + "errors" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +type serviceTestExec struct { + home string + actionOut string + actionErr error + statusOut string + statusErr error + calls []string +} + +func (e *serviceTestExec) Mode() Mode { return ModeLocal } + +func (e *serviceTestExec) ExecShell(_ time.Duration, shellCmd string) (string, error) { + e.calls = append(e.calls, shellCmd) + if strings.Contains(shellCmd, "service status") { + return e.statusOut, e.statusErr + } + return e.actionOut, e.actionErr +} + +func (e *serviceTestExec) ExecCommand(_ time.Duration, _ ...string) (string, error) { return "", nil } + +func (e *serviceTestExec) ReadFile(_ string) (string, error) { return "", os.ErrNotExist } + +func (e *serviceTestExec) WriteFile(_ string, _ []byte, _ os.FileMode) error { return nil } + +func (e *serviceTestExec) ConfigPath() string { return "/tmp/config.json" } + +func (e *serviceTestExec) AuthPath() string { return "/tmp/auth.json" } + +func (e *serviceTestExec) HomePath() string { return e.home } + +func (e *serviceTestExec) AgentVersion() string { return "vtest" } + +func (e *serviceTestExec) ServiceInstalled() bool { return true } + +func (e *serviceTestExec) ServiceActive() bool { return true } + +func (e *serviceTestExec) InteractiveProcess(_ ...string) *exec.Cmd { return exec.Command("true") } func TestAgentModel_HandleServiceAction(t *testing.T) { m := NewAgentModel(&routingTestExec{home: "/Users/tester"}) m.HandleServiceAction(serviceActionMsg{ - action: "start", - ok: true, - output: "service started", + action: "start", + ok: true, + output: "service started", + duration: 1500 * time.Millisecond, }) if !m.logsLoaded { @@ -18,3 +65,40 @@ func TestAgentModel_HandleServiceAction(t *testing.T) { } } +func TestServiceAction_NormalizesStatusWhenLaunchdReturnsError(t *testing.T) { + execStub := &serviceTestExec{ + home: "/Users/tester", + actionOut: "Service restart failed: kickstart failed:", + actionErr: errors.New("exit status 1"), + statusOut: "Gateway service status:\n Installed: yes\n Running: yes\n", + } + + msg := serviceAction(execStub, "restart")().(serviceActionMsg) + if !msg.ok { + t.Fatalf("msg.ok = false, want true; output=%q", msg.output) + } + if !msg.normalized { + t.Fatal("msg.normalized = false, want true") + } + if !msg.statusKnown || !msg.running || !msg.installed { + t.Fatalf("unexpected status parse: known=%v installed=%v running=%v", msg.statusKnown, msg.installed, msg.running) + } +} + +func TestInferServiceActionSuccess(t *testing.T) { + if !inferServiceActionSuccess("start", true, true) { + t.Fatal("start should be successful when running=true") + } + if inferServiceActionSuccess("stop", true, true) { + t.Fatal("stop should not be successful when running=true") + } + if !inferServiceActionSuccess("stop", true, false) { + t.Fatal("stop should be successful when running=false") + } + if !inferServiceActionSuccess("install", true, false) { + t.Fatal("install should be successful when installed=true") + } + if !inferServiceActionSuccess("uninstall", false, false) { + t.Fatal("uninstall should be successful when installed=false") + } +} From 98ed6d12b0372e6a6d0ae12a7cc2104080946aa8 Mon Sep 17 00:00:00 2001 From: Ernie Pedapati Date: Sun, 22 Feb 2026 18:05:06 -0500 Subject: [PATCH 05/13] tui: refresh routing after actions and add folder edit/detach UX --- cmd/picoclaw/tui/model.go | 8 +- cmd/picoclaw/tui/tab_routing.go | 252 ++++++++++++++++++++++----- cmd/picoclaw/tui/tab_routing_test.go | 65 ++++++- 3 files changed, 277 insertions(+), 48 deletions(-) diff --git a/cmd/picoclaw/tui/model.go b/cmd/picoclaw/tui/model.go index 1b36ee4..8aaec49 100644 --- a/cmd/picoclaw/tui/model.go +++ b/cmd/picoclaw/tui/model.go @@ -23,8 +23,8 @@ const ( tabModels = 8 tabSkills = 9 tabCron = 10 - tabRouting = 11 - tabSettings = 12 + tabRouting = 11 + tabSettings = 12 ) // tabEntry maps a visible tab position to its logical ID and display name. @@ -302,6 +302,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.spinner, cmd = m.spinner.Update(msg) return m, cmd + case routingActionMsg: + m.routing.HandleAction(msg) + return m, tea.Batch(fetchRoutingStatus(m.exec), fetchRoutingListCmd(m.exec)) + case actionDoneMsg: m.loading = true return m, tea.Batch(m.spinner.Tick, fetchSnapshotCmd(m.exec)) diff --git a/cmd/picoclaw/tui/tab_routing.go b/cmd/picoclaw/tui/tab_routing.go index b483989..4511f6d 100644 --- a/cmd/picoclaw/tui/tab_routing.go +++ b/cmd/picoclaw/tui/tab_routing.go @@ -20,20 +20,28 @@ const ( routingConfirmRemove routingAddWizard routingEditUsers + routingEditWorkspace routingBrowseFolder routingPickRoom routingPairTelegram ) +type routingBrowseTarget int + +const ( + browseTargetAddWizard routingBrowseTarget = iota + browseTargetEditWorkspace +) + // Wizard steps for adding a mapping. const ( - addStepChannel = 0 - addStepChatID = 1 // Discord: auto-picker; Telegram: choice screen - addStepWorkspace = 2 - addStepAllow = 3 - addStepLabel = 4 - addStepConfirm = 5 - addStepChatIDManual = 6 // manual text input fallback (both channels) + addStepChannel = 0 + addStepChatID = 1 // Discord: auto-picker; Telegram: choice screen + addStepWorkspace = 2 + addStepAllow = 3 + addStepLabel = 4 + addStepConfirm = 5 + addStepChatIDManual = 6 // manual text input fallback (both channels) ) // Messages for async routing operations. @@ -41,6 +49,11 @@ type routingStatusMsg struct{ output string } type routingListMsg struct{ output string } type routingValidateMsg struct{ output string } type routingReloadMsg struct{ output string } +type routingActionMsg struct { + action string + output string + ok bool +} type routingDirListMsg struct { path string dirs []string @@ -106,8 +119,9 @@ type RoutingModel struct { wizardLabel string wizardInput textinput.Model - // Edit-users state - editUsersInput textinput.Model + // Edit-users/workspace state + editUsersInput textinput.Model + editWorkspaceInput textinput.Model // Folder browser state browserPath string @@ -115,6 +129,7 @@ type RoutingModel struct { browserCursor int browserLoading bool browserErr string + browserTarget routingBrowseTarget // Discord room picker state discordRooms []discordRoom @@ -144,13 +159,17 @@ func NewRoutingModel(exec Executor) RoutingModel { ei := textinput.New() ei.CharLimit = 200 ei.Width = 50 + ew := textinput.New() + ew.CharLimit = 300 + ew.Width = 50 return RoutingModel{ - exec: exec, - listVP: listVP, - detailVP: detailVP, - wizardInput: wi, - editUsersInput: ei, + exec: exec, + listVP: listVP, + detailVP: detailVP, + wizardInput: wi, + editUsersInput: ei, + editWorkspaceInput: ew, } } @@ -195,6 +214,36 @@ func (m *RoutingModel) HandleReload(msg routingReloadMsg) { m.flashUntil = time.Now().Add(5 * time.Second) } +func (m *RoutingModel) HandleAction(msg routingActionMsg) { + defaultLabel := "Routing action" + switch msg.action { + case "add": + defaultLabel = "Mapping saved" + case "remove": + defaultLabel = "Mapping detached" + case "set-users": + defaultLabel = "Allowed users updated" + case "enable": + defaultLabel = "Routing enabled" + case "disable": + defaultLabel = "Routing disabled" + } + + out := strings.TrimSpace(msg.output) + if msg.ok { + if out == "" { + out = defaultLabel + } + m.flashMsg = styleOK.Render("✓") + " " + out + } else { + if out == "" { + out = defaultLabel + " failed" + } + m.flashMsg = styleErr.Render("✗") + " " + out + } + m.flashUntil = time.Now().Add(6 * time.Second) +} + func (m *RoutingModel) HandleDirList(msg routingDirListMsg) { m.browserLoading = false if msg.path != m.browserPath { @@ -441,6 +490,8 @@ func (m RoutingModel) Update(msg tea.KeyMsg, snap *VMSnapshot) (RoutingModel, te return m.updateAddWizard(msg, snap) case routingEditUsers: return m.updateEditUsers(msg) + case routingEditWorkspace: + return m.updateEditWorkspace(msg, snap) case routingBrowseFolder: return m.updateBrowseFolder(msg, snap) case routingPickRoom: @@ -481,11 +532,13 @@ func (m RoutingModel) updateNormal(msg tea.KeyMsg, snap *VMSnapshot) (RoutingMod } case "t": return m, routingToggleCmd(m.exec, !m.status.Enabled) - case "d", "backspace", "delete": + case "d", "backspace", "delete", "x": if m.selectedRow < len(m.mappings) { m.removeMapping = m.mappings[m.selectedRow] m.mode = routingConfirmRemove } + case "e": + return m.startEditWorkspace() case "v": return m, routingValidateCmd(m.exec) case "R": @@ -637,10 +690,18 @@ func (m RoutingModel) updateAddWizard(msg tea.KeyMsg, snap *VMSnapshot) (Routing // --- Folder Browser --- func (m *RoutingModel) startBrowse(snap *VMSnapshot) tea.Cmd { + return m.startBrowseFromInput(strings.TrimSpace(m.wizardInput.Value()), snap, browseTargetAddWizard) +} + +func (m *RoutingModel) startBrowseFromInput(rawPath string, snap *VMSnapshot, target routingBrowseTarget) tea.Cmd { m.mode = routingBrowseFolder - startPath := expandHomeForExecPath(strings.TrimSpace(m.wizardInput.Value()), m.exec.HomePath()) + m.browserTarget = target + startPath := expandHomeForExecPath(rawPath, m.exec.HomePath()) if startPath == "" || !filepath.IsAbs(startPath) { - if snap != nil && snap.WorkspacePath != "" { + if target == browseTargetEditWorkspace && m.selectedRow < len(m.mappings) { + startPath = expandHomeForExecPath(m.mappings[m.selectedRow].Workspace, m.exec.HomePath()) + } + if (startPath == "" || !filepath.IsAbs(startPath)) && snap != nil && snap.WorkspacePath != "" { startPath = expandHomeForExecPath(snap.WorkspacePath, m.exec.HomePath()) } if startPath == "" || !filepath.IsAbs(startPath) { @@ -652,20 +713,36 @@ func (m *RoutingModel) startBrowse(snap *VMSnapshot) tea.Cmd { m.browserLoading = true m.browserErr = "" m.browserEntries = nil - m.wizardInput.Blur() + if target == browseTargetEditWorkspace { + m.editWorkspaceInput.Blur() + } else { + m.wizardInput.Blur() + } return fetchDirListCmd(m.exec, startPath) } func (m RoutingModel) updateBrowseFolder(msg tea.KeyMsg, snap *VMSnapshot) (RoutingModel, tea.Cmd) { key := msg.String() + restoreFromBrowse := func(path string) (RoutingModel, tea.Cmd) { + switch m.browserTarget { + case browseTargetEditWorkspace: + m.mode = routingEditWorkspace + m.editWorkspaceInput.SetValue(path) + m.editWorkspaceInput.Focus() + return m, nil + default: + m.mode = routingAddWizard + m.wizardStep = addStepWorkspace + m.wizardInput.SetValue(path) + m.wizardInput.Focus() + return m, nil + } + } + switch key { case "esc": - m.mode = routingAddWizard - m.wizardStep = addStepWorkspace - m.wizardInput.SetValue(m.browserPath) - m.wizardInput.Focus() - return m, nil + return restoreFromBrowse(m.browserPath) case "up", "k": if m.browserCursor > 0 { @@ -694,11 +771,7 @@ func (m RoutingModel) updateBrowseFolder(msg tea.KeyMsg, snap *VMSnapshot) (Rout return m, fetchDirListCmd(m.exec, parent) } else if m.browserCursor == selectIdx { // Select current folder - m.mode = routingAddWizard - m.wizardStep = addStepWorkspace - m.wizardInput.SetValue(m.browserPath) - m.wizardInput.Focus() - return m, nil + return restoreFromBrowse(m.browserPath) } else { // Descend into directory dirName := m.browserEntries[m.browserCursor-1] @@ -711,11 +784,7 @@ func (m RoutingModel) updateBrowseFolder(msg tea.KeyMsg, snap *VMSnapshot) (Rout case " ": // Space selects current folder - m.mode = routingAddWizard - m.wizardStep = addStepWorkspace - m.wizardInput.SetValue(m.browserPath) - m.wizardInput.Focus() - return m, nil + return restoreFromBrowse(m.browserPath) } return m, nil @@ -758,6 +827,47 @@ func (m RoutingModel) updateEditUsers(msg tea.KeyMsg) (RoutingModel, tea.Cmd) { return m, cmd } +func (m RoutingModel) startEditWorkspace() (RoutingModel, tea.Cmd) { + if m.selectedRow >= len(m.mappings) { + return m, nil + } + row := m.mappings[m.selectedRow] + m.mode = routingEditWorkspace + m.editWorkspaceInput.SetValue(row.Workspace) + m.editWorkspaceInput.Placeholder = "/absolute/path/to/workspace" + m.editWorkspaceInput.Focus() + return m, nil +} + +func (m RoutingModel) updateEditWorkspace(msg tea.KeyMsg, snap *VMSnapshot) (RoutingModel, tea.Cmd) { + switch msg.String() { + case "esc": + m.mode = routingNormal + m.editWorkspaceInput.Blur() + return m, nil + case "ctrl+b": + return m, m.startBrowseFromInput(strings.TrimSpace(m.editWorkspaceInput.Value()), snap, browseTargetEditWorkspace) + case "enter": + if m.selectedRow >= len(m.mappings) { + m.mode = routingNormal + m.editWorkspaceInput.Blur() + return m, nil + } + val := strings.TrimSpace(m.editWorkspaceInput.Value()) + if val == "" { + return m, nil + } + row := m.mappings[m.selectedRow] + workspace := expandHomeForExecPath(val, m.exec.HomePath()) + m.mode = routingNormal + m.editWorkspaceInput.Blur() + return m, routingAddMappingCmd(m.exec, row.Channel, row.ChatID, workspace, row.AllowedSenders, row.Label) + } + var cmd tea.Cmd + m.editWorkspaceInput, cmd = m.editWorkspaceInput.Update(msg) + return m, cmd +} + // --- Discord Room Picker --- func (m RoutingModel) startDiscordPicker() (RoutingModel, tea.Cmd) { @@ -814,7 +924,7 @@ func (m RoutingModel) updatePickRoom(msg tea.KeyMsg, snap *VMSnapshot) (RoutingM m.wizardInput.SetValue("") m.wizardInput.Placeholder = "/absolute/path/to/workspace" if snap != nil && snap.WorkspacePath != "" { - m.wizardInput.SetValue(snap.WorkspacePath) + m.wizardInput.SetValue(expandHomeForExecPath(snap.WorkspacePath, m.exec.HomePath())) } m.wizardInput.Focus() } @@ -953,11 +1063,12 @@ func (m RoutingModel) View(snap *VMSnapshot, width int) string { b.WriteString(placePanelTitle(detailPanel, detailTitle)) // Keybindings. - b.WriteString(fmt.Sprintf(" %s Add %s Edit Users %s Toggle %s Remove %s Check %s Apply %s Refresh\n", + b.WriteString(fmt.Sprintf(" %s Add %s Edit Folder %s Edit Users %s Toggle %s Detach %s Check %s Apply %s Refresh\n", styleKey.Render("[a]"), + styleKey.Render("[e]"), styleKey.Render("[u]"), styleKey.Render("[t]"), - styleKey.Render("[d]"), + styleKey.Render("[x]"), styleKey.Render("[v]"), styleKey.Render("[R]"), styleKey.Render("[l]"), @@ -971,7 +1082,7 @@ func (m RoutingModel) View(snap *VMSnapshot, width int) string { // Overlay: remove confirmation. if m.mode == routingConfirmRemove { b.WriteString("\n") - b.WriteString(fmt.Sprintf(" Remove mapping %s? %s / %s\n", + b.WriteString(fmt.Sprintf(" Detach mapping %s? %s / %s\n", styleBold.Render(m.removeMapping.Channel+":"+m.removeMapping.ChatID), styleKey.Render("[y]es"), styleKey.Render("[n]o"), @@ -990,6 +1101,12 @@ func (m RoutingModel) View(snap *VMSnapshot, width int) string { b.WriteString(m.renderEditUsersOverlay()) } + // Overlay: edit workspace. + if m.mode == routingEditWorkspace { + b.WriteString("\n") + b.WriteString(m.renderEditWorkspaceOverlay()) + } + // Overlay: folder browser. if m.mode == routingBrowseFolder { b.WriteString("\n") @@ -1065,8 +1182,18 @@ func routingToggleCmd(exec Executor, enable bool) tea.Cmd { action = "enable" } cmd := "HOME=" + exec.HomePath() + " sciclaw routing " + action + " 2>&1" - _, _ = exec.ExecShell(10*time.Second, cmd) - return actionDoneMsg{output: "Routing " + action + "d"} + out, err := exec.ExecShell(10*time.Second, cmd) + out = strings.TrimSpace(out) + if err != nil { + if out == "" { + out = err.Error() + } + return routingActionMsg{action: action, output: out, ok: false} + } + if out == "" { + out = "Routing " + action + "d" + } + return routingActionMsg{action: action, output: out, ok: true} } } @@ -1074,8 +1201,18 @@ func routingRemoveCmd(exec Executor, channel, chatID string) tea.Cmd { return func() tea.Msg { cmd := "HOME=" + exec.HomePath() + " sciclaw routing remove --channel " + shellEscape(channel) + " --chat-id " + shellEscape(chatID) + " 2>&1" - _, _ = exec.ExecShell(10*time.Second, cmd) - return actionDoneMsg{output: "Removed mapping " + channel + ":" + chatID} + out, err := exec.ExecShell(10*time.Second, cmd) + out = strings.TrimSpace(out) + if err != nil { + if out == "" { + out = err.Error() + } + return routingActionMsg{action: "remove", output: out, ok: false} + } + if out == "" { + out = "Removed mapping " + channel + ":" + chatID + } + return routingActionMsg{action: "remove", output: out, ok: true} } } @@ -1109,8 +1246,15 @@ func routingAddMappingCmd(exec Executor, channel, chatID, workspace, allowCSV, l cmd += " --label " + shellEscape(label) } cmd += " 2>&1" - out, _ := exec.ExecShell(10*time.Second, cmd) - return actionDoneMsg{output: strings.TrimSpace(out)} + out, err := exec.ExecShell(10*time.Second, cmd) + out = strings.TrimSpace(out) + if err != nil { + if out == "" { + out = err.Error() + } + return routingActionMsg{action: "add", output: out, ok: false} + } + return routingActionMsg{action: "add", output: out, ok: true} } } @@ -1122,8 +1266,15 @@ func routingSetUsersCmd(exec Executor, channel, chatID, allowCSV string) tea.Cmd shellEscape(chatID), shellEscape(allowCSV), ) - out, _ := exec.ExecShell(10*time.Second, cmd) - return actionDoneMsg{output: strings.TrimSpace(out)} + out, err := exec.ExecShell(10*time.Second, cmd) + out = strings.TrimSpace(out) + if err != nil { + if out == "" { + out = err.Error() + } + return routingActionMsg{action: "set-users", output: out, ok: false} + } + return routingActionMsg{action: "set-users", output: out, ok: true} } } @@ -1302,6 +1453,17 @@ func (m RoutingModel) renderEditUsersOverlay() string { return strings.Join(lines, "\n") } +func (m RoutingModel) renderEditWorkspaceOverlay() string { + row := m.mappings[m.selectedRow] + var lines []string + lines = append(lines, styleBold.Render(fmt.Sprintf(" Edit folder for %s:%s", row.Channel, row.ChatID))) + lines = append(lines, fmt.Sprintf(" Workspace path: %s", m.editWorkspaceInput.View())) + lines = append(lines, styleHint.Render(" Enter a new project folder for this room mapping.")) + lines = append(lines, fmt.Sprintf(" %s to browse folders", styleKey.Render("Ctrl+B"))) + lines = append(lines, styleDim.Render(" Enter to save, Esc to cancel")) + return strings.Join(lines, "\n") +} + func (m RoutingModel) renderFolderBrowser() string { var lines []string lines = append(lines, styleBold.Render(" Browse Folders")) diff --git a/cmd/picoclaw/tui/tab_routing_test.go b/cmd/picoclaw/tui/tab_routing_test.go index 39bbfa9..c8093ed 100644 --- a/cmd/picoclaw/tui/tab_routing_test.go +++ b/cmd/picoclaw/tui/tab_routing_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" "time" + + tea "github.com/charmbracelet/bubbletea" ) type routingTestExec struct { @@ -92,7 +94,10 @@ func TestRoutingAddMappingCmd_ExpandsWorkspacePath(t *testing.T) { shellOut: "ok", } cmd := routingAddMappingCmd(execStub, "discord", "123", "~/sciclaw/workspace", "u1", "") - _ = cmd().(actionDoneMsg) + msg := cmd().(routingActionMsg) + if !msg.ok { + t.Fatalf("expected ok routing action, got: %#v", msg) + } if !strings.Contains(execStub.lastShell, "--workspace '/Users/tester/sciclaw/workspace'") { t.Fatalf("routing add command missing expanded workspace: %q", execStub.lastShell) @@ -117,3 +122,61 @@ func TestStartBrowse_UsesExpandedWorkspacePath(t *testing.T) { t.Fatalf("dir-list path = %q, want %q", msg.path, "/Users/tester/picoclaw/workspace") } } + +func TestEditWorkspace_BrowseRoundTrip(t *testing.T) { + execStub := &routingTestExec{ + home: "/Users/tester", + shellOut: "project/\n", + } + m := NewRoutingModel(execStub) + m.mappings = []routingRow{ + {Channel: "discord", ChatID: "123", Workspace: "~/picoclaw/workspace"}, + } + m.selectedRow = 0 + + edited, _ := m.updateNormal(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("e")}, nil) + if edited.mode != routingEditWorkspace { + t.Fatalf("mode = %v, want %v", edited.mode, routingEditWorkspace) + } + + browsing, cmd := edited.updateEditWorkspace(tea.KeyMsg{Type: tea.KeyCtrlB}, nil) + if browsing.mode != routingBrowseFolder { + t.Fatalf("mode = %v, want %v", browsing.mode, routingBrowseFolder) + } + if browsing.browserTarget != browseTargetEditWorkspace { + t.Fatalf("browser target = %v, want %v", browsing.browserTarget, browseTargetEditWorkspace) + } + if browsing.browserPath != "/Users/tester/picoclaw/workspace" { + t.Fatalf("browserPath = %q, want %q", browsing.browserPath, "/Users/tester/picoclaw/workspace") + } + _ = cmd().(routingDirListMsg) + + restored, _ := browsing.updateBrowseFolder(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(" ")}, nil) + if restored.mode != routingEditWorkspace { + t.Fatalf("mode = %v, want %v", restored.mode, routingEditWorkspace) + } + if restored.editWorkspaceInput.Value() != "/Users/tester/picoclaw/workspace" { + t.Fatalf("editWorkspaceInput = %q, want %q", restored.editWorkspaceInput.Value(), "/Users/tester/picoclaw/workspace") + } +} + +func TestPickRoom_UsesExpandedWorkspaceFromSnapshot(t *testing.T) { + execStub := &routingTestExec{home: "/Users/tester"} + m := NewRoutingModel(execStub) + m.mode = routingPickRoom + m.discordRooms = []discordRoom{ + {ChannelID: "123", GuildName: "Guild", ChannelName: "general"}, + } + m.roomCursor = 0 + + next, _ := m.updatePickRoom(tea.KeyMsg{Type: tea.KeyEnter}, &VMSnapshot{WorkspacePath: "~/picoclaw/workspace"}) + if next.mode != routingAddWizard { + t.Fatalf("mode = %v, want %v", next.mode, routingAddWizard) + } + if next.wizardStep != addStepWorkspace { + t.Fatalf("wizardStep = %d, want %d", next.wizardStep, addStepWorkspace) + } + if next.wizardInput.Value() != "/Users/tester/picoclaw/workspace" { + t.Fatalf("workspace input = %q, want %q", next.wizardInput.Value(), "/Users/tester/picoclaw/workspace") + } +} From 03f852d146e0741ec8ae481fc9b87925b1367ac9 Mon Sep 17 00:00:00 2001 From: Ernie Pedapati Date: Sun, 22 Feb 2026 18:22:20 -0500 Subject: [PATCH 06/13] feat(models): add discover endpoint-backed catalog and TUI model picker --- cmd/picoclaw/main.go | 18 ++- cmd/picoclaw/tui/model.go | 4 + cmd/picoclaw/tui/tab_models.go | 212 ++++++++++++++++++++++++++++++++- pkg/models/models.go | 193 ++++++++++++++++++++++++++++++ pkg/models/models_test.go | 77 ++++++++++++ 5 files changed, 497 insertions(+), 7 deletions(-) create mode 100644 pkg/models/models_test.go diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 1620a99..fbcb654 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -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 \n", commandName) @@ -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) } } diff --git a/cmd/picoclaw/tui/model.go b/cmd/picoclaw/tui/model.go index 8aaec49..0e01826 100644 --- a/cmd/picoclaw/tui/model.go +++ b/cmd/picoclaw/tui/model.go @@ -331,6 +331,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.models.HandleStatus(msg) return m, nil + case modelsCatalogMsg: + m.models.HandleCatalog(msg) + return m, nil + case skillsListMsg: m.skills.HandleList(msg) return m, nil diff --git a/cmd/picoclaw/tui/tab_models.go b/cmd/picoclaw/tui/tab_models.go index 0b6b02c..5296ad1 100644 --- a/cmd/picoclaw/tui/tab_models.go +++ b/cmd/picoclaw/tui/tab_models.go @@ -1,6 +1,7 @@ package tui import ( + "encoding/json" "fmt" "strings" "time" @@ -13,11 +14,19 @@ type modelsMode int const ( modelsNormal modelsMode = iota + modelsSelectModel modelsSetModel modelsSetEffort ) type modelsStatusMsg struct{ output string } +type modelsCatalogMsg struct { + provider string + source string + models []string + warning string + err string +} var effortLevels = []string{"none", "minimal", "low", "medium", "high", "xhigh"} @@ -33,9 +42,18 @@ type ModelsModel struct { authMethod string reasoningEffort string - // Set model text input + // Set model text input (manual mode) input textinput.Model + // Discovered model options + modelOptions []string + modelOptionsIdx int + modelsProvider string + modelsSource string + modelsWarning string + modelsLoading bool + modelsErr string + // Effort selection effortIdx int } @@ -50,7 +68,7 @@ func NewModelsModel(exec Executor) ModelsModel { func (m *ModelsModel) AutoRun() tea.Cmd { if !m.loaded { - return fetchModelsStatus(m.exec) + return tea.Batch(fetchModelsStatus(m.exec), fetchModelsCatalog(m.exec)) } return nil } @@ -60,6 +78,18 @@ func (m *ModelsModel) HandleStatus(msg modelsStatusMsg) { m.parseStatus(msg.output) } +func (m *ModelsModel) HandleCatalog(msg modelsCatalogMsg) { + m.modelsLoading = false + m.modelsProvider = msg.provider + m.modelsSource = msg.source + m.modelsWarning = msg.warning + m.modelsErr = msg.err + m.modelOptions = append([]string(nil), msg.models...) + if m.modelOptionsIdx >= len(m.modelOptions) { + m.modelOptionsIdx = 0 + } +} + func (m *ModelsModel) parseStatus(output string) { for _, line := range strings.Split(output, "\n") { parts := strings.SplitN(line, ":", 2) @@ -84,6 +114,40 @@ func (m *ModelsModel) parseStatus(output string) { func (m ModelsModel) Update(msg tea.KeyMsg, snap *VMSnapshot) (ModelsModel, tea.Cmd) { key := msg.String() + if m.mode == modelsSelectModel { + switch key { + case "esc": + m.mode = modelsNormal + return m, nil + case "r": + m.modelsLoading = true + return m, fetchModelsCatalog(m.exec) + case "m": + m.mode = modelsSetModel + m.input.SetValue("") + m.input.Focus() + return m, nil + case "up", "k": + if !m.modelsLoading && m.modelOptionsIdx > 0 { + m.modelOptionsIdx-- + } + return m, nil + case "down", "j": + if !m.modelsLoading && m.modelOptionsIdx < len(m.modelOptions)-1 { + m.modelOptionsIdx++ + } + return m, nil + case "enter": + if m.modelsLoading || len(m.modelOptions) == 0 { + return m, nil + } + selected := m.modelOptions[m.modelOptionsIdx] + m.mode = modelsNormal + return m, setModelCmd(m.exec, selected) + } + return m, nil + } + if m.mode == modelsSetModel { switch key { case "esc": @@ -130,6 +194,13 @@ func (m ModelsModel) Update(msg tea.KeyMsg, snap *VMSnapshot) (ModelsModel, tea. // Normal mode. switch key { case "s": + m.mode = modelsSelectModel + if len(m.modelOptions) == 0 && !m.modelsLoading { + m.modelsLoading = true + return m, fetchModelsCatalog(m.exec) + } + return m, nil + case "m": m.mode = modelsSetModel m.input.SetValue("") m.input.Focus() @@ -144,8 +215,8 @@ func (m ModelsModel) Update(msg tea.KeyMsg, snap *VMSnapshot) (ModelsModel, tea. } return m, nil case "l": - m.loaded = false - return m, fetchModelsStatus(m.exec) + m.modelsLoading = true + return m, tea.Batch(fetchModelsStatus(m.exec), fetchModelsCatalog(m.exec)) } return m, nil } @@ -172,15 +243,63 @@ func (m ModelsModel) View(snap *VMSnapshot, width int) string { styleLabel.Render("Effort:"), styleValue.Render(m.reasoningEffort))) lines = append(lines, "") - lines = append(lines, fmt.Sprintf(" %s Set model %s Set reasoning effort %s Refresh", + lines = append(lines, fmt.Sprintf(" %s Select model %s Manual model %s Set reasoning effort %s Refresh", styleKey.Render("[s]"), + styleKey.Render("[m]"), styleKey.Render("[e]"), styleKey.Render("[l]"), )) + if m.mode == modelsSelectModel { + lines = append(lines, "") + lines = append(lines, styleBold.Render(" Pick a model:")) + if m.modelsProvider != "" { + source := m.modelsSource + if source == "" { + source = "unknown" + } + lines = append(lines, fmt.Sprintf(" %s %s (%s)", + styleLabel.Render("Catalog:"), + styleValue.Render(m.modelsProvider), + source, + )) + } + + if m.modelsLoading { + lines = append(lines, " "+styleDim.Render("Loading model catalog...")) + } else if m.modelsErr != "" { + lines = append(lines, " "+styleErr.Render(m.modelsErr)) + } + + if !m.modelsLoading && len(m.modelOptions) > 0 { + maxVisible := 10 + start := 0 + if m.modelOptionsIdx > maxVisible-3 { + start = m.modelOptionsIdx - maxVisible + 3 + } + end := start + maxVisible + if end > len(m.modelOptions) { + end = len(m.modelOptions) + } + + for i := start; i < end; i++ { + prefix := " " + if i == m.modelOptionsIdx { + prefix = styleBold.Render("▸ ") + } + lines = append(lines, fmt.Sprintf(" %s%s", prefix, m.modelOptions[i])) + } + } + + if m.modelsWarning != "" { + lines = append(lines, " "+styleDim.Render(m.modelsWarning)) + } + lines = append(lines, styleDim.Render(" ↑/↓ to pick, Enter to apply, [r] refresh, [m] manual, Esc cancel")) + } + if m.mode == modelsSetModel { lines = append(lines, "") - lines = append(lines, fmt.Sprintf(" Model name: %s", m.input.View())) + lines = append(lines, fmt.Sprintf(" Model id: %s", m.input.View())) lines = append(lines, styleDim.Render(" Enter to confirm, Esc to cancel")) } @@ -212,6 +331,49 @@ func fetchModelsStatus(exec Executor) tea.Cmd { } } +func fetchModelsCatalog(exec Executor) tea.Cmd { + type discoverPayload struct { + Provider string `json:"provider"` + Source string `json:"source"` + Models []string `json:"models"` + Warning string `json:"warning"` + } + + return func() tea.Msg { + cmd := "HOME=" + exec.HomePath() + " sciclaw models discover --json 2>&1" + out, err := exec.ExecShell(20*time.Second, cmd) + trimmed := strings.TrimSpace(out) + + var payload discoverPayload + if json.Unmarshal([]byte(trimmed), &payload) == nil { + return modelsCatalogMsg{ + provider: payload.Provider, + source: payload.Source, + models: dedupeModelIDs(payload.Models), + warning: payload.Warning, + } + } + + // Fallback parser for plain text output. + models := parseModelListOutput(trimmed) + if len(models) > 0 { + return modelsCatalogMsg{ + models: dedupeModelIDs(models), + source: "plain", + } + } + + msg := firstNonEmptyLine(trimmed) + if msg == "" && err != nil { + msg = err.Error() + } + if msg == "" { + msg = "No model catalog returned" + } + return modelsCatalogMsg{err: msg} + } +} + func setModelCmd(exec Executor, model string) tea.Cmd { return func() tea.Msg { cmd := "HOME=" + exec.HomePath() + " sciclaw models set " + shellEscape(model) + " 2>&1" @@ -227,3 +389,41 @@ func setEffortCmd(exec Executor, level string) tea.Cmd { return actionDoneMsg{output: "Reasoning effort set to " + level} } } + +func parseModelListOutput(output string) []string { + var models []string + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "- ") { + models = append(models, strings.TrimSpace(strings.TrimPrefix(line, "- "))) + } + } + return models +} + +func dedupeModelIDs(in []string) []string { + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, v := range in { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} + +func firstNonEmptyLine(s string) string { + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if line != "" { + return line + } + } + return "" +} diff --git a/pkg/models/models.go b/pkg/models/models.go index 1a015b1..7778027 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -1,15 +1,28 @@ package models import ( + "context" "fmt" "os" "path/filepath" "strings" + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" ) +const anthropicOAuthBetaHeader = "oauth-2025-04-20" + +// DiscoverResult is returned by model discovery. +type DiscoverResult struct { + Provider string `json:"provider"` + Source string `json:"source"` + Models []string `json:"models"` + Warning string `json:"warning,omitempty"` +} + // ProviderInfo describes a configured provider and its auth status. type ProviderInfo struct { Name string @@ -145,6 +158,7 @@ func PrintList(cfg *config.Config) { // SetModel validates and persists a new default model. func SetModel(cfg *config.Config, configPath string, newModel string) error { oldModel := cfg.Agents.Defaults.Model + oldProvider := cfg.Agents.Defaults.Provider provider := ResolveProvider(newModel, cfg) if provider == "unknown" { @@ -153,6 +167,9 @@ func SetModel(cfg *config.Config, configPath string, newModel string) error { } cfg.Agents.Defaults.Model = newModel + if provider != "unknown" { + cfg.Agents.Defaults.Provider = provider + } if err := config.SaveConfig(configPath, cfg); err != nil { return fmt.Errorf("saving config: %w", err) @@ -160,6 +177,13 @@ func SetModel(cfg *config.Config, configPath string, newModel string) error { fmt.Printf("Model changed: %s → %s\n", oldModel, newModel) fmt.Printf("Provider: %s\n", provider) + if provider != "unknown" && oldProvider != cfg.Agents.Defaults.Provider { + old := oldProvider + if strings.TrimSpace(old) == "" { + old = "(auto)" + } + fmt.Printf("Pinned provider: %s → %s\n", old, cfg.Agents.Defaults.Provider) + } return nil } @@ -248,3 +272,172 @@ func resolveAuthMethod(provider string, cfg *config.Config) string { return "not configured" } + +// Discover returns selectable model IDs for the active provider. +// It attempts provider endpoint discovery first, then falls back to known built-ins. +func Discover(cfg *config.Config) DiscoverResult { + provider := resolveDiscoveryProvider(cfg) + result := DiscoverResult{ + Provider: provider, + Source: "builtin", + Models: knownModelsForProvider(provider), + } + + if provider == "anthropic" { + models, err := discoverAnthropicModels(cfg) + if err == nil && len(models) > 0 { + result.Source = "endpoint" + result.Models = models + return result + } + if err != nil { + result.Warning = err.Error() + } + } + + // If provider-specific builtins are empty, include known models from configured providers. + if len(result.Models) == 0 { + for _, p := range ListProviders(cfg) { + result.Models = append(result.Models, p.Models...) + } + result.Models = dedupeNonEmpty(result.Models) + } + + if len(result.Models) == 0 { + current := strings.TrimSpace(cfg.Agents.Defaults.Model) + if current != "" { + result.Models = []string{current} + } + } + return result +} + +func PrintDiscover(result DiscoverResult) { + fmt.Printf("Provider: %s\n", result.Provider) + fmt.Printf("Source: %s\n", result.Source) + if strings.TrimSpace(result.Warning) != "" { + fmt.Printf("Warning: %s\n", result.Warning) + } + fmt.Println("Models:") + for _, m := range result.Models { + fmt.Printf("- %s\n", m) + } +} + +func resolveDiscoveryProvider(cfg *config.Config) string { + byModel := strings.ToLower(strings.TrimSpace(ResolveProvider(cfg.Agents.Defaults.Model, cfg))) + if byModel != "" && byModel != "unknown" { + return byModel + } + + pinned := strings.ToLower(strings.TrimSpace(cfg.Agents.Defaults.Provider)) + if pinned != "" && pinned != "unknown" { + return pinned + } + + if cred, err := auth.GetCredential("anthropic"); err == nil && cred != nil && strings.TrimSpace(cred.AccessToken) != "" { + return "anthropic" + } + if cred, err := auth.GetCredential("openai"); err == nil && cred != nil && strings.TrimSpace(cred.AccessToken) != "" { + return "openai" + } + return "unknown" +} + +func knownModelsForProvider(provider string) []string { + switch provider { + case "anthropic": + return []string{"claude-opus-4-6", "claude-sonnet-4-5-20250929", "claude-haiku-4-5-20251001"} + case "openai": + return []string{"gpt-5.3-codex", "gpt-5.3-codex-spark", "gpt-5.2-codex", "gpt-5.2"} + case "gemini": + return []string{"gemini-2.5-pro", "gemini-2.5-flash"} + case "openrouter": + return []string{"openrouter/"} + case "deepseek": + return []string{"deepseek-chat", "deepseek-reasoner"} + case "zhipu": + return []string{"glm-4.7"} + case "groq": + return []string{"groq/llama-3.3-70b"} + default: + return nil + } +} + +func discoverAnthropicModels(cfg *config.Config) ([]string, error) { + token := "" + if cred, err := auth.GetCredential("anthropic"); err == nil && cred != nil { + token = strings.TrimSpace(cred.AccessToken) + } + if token == "" { + token = strings.TrimSpace(cfg.Providers.Anthropic.APIKey) + } + if token == "" { + return nil, fmt.Errorf("anthropic credentials not configured") + } + + baseURL := strings.TrimSpace(cfg.Providers.Anthropic.APIBase) + if baseURL == "" { + baseURL = "https://api.anthropic.com" + } + baseURL = strings.TrimRight(baseURL, "/") + baseURL = strings.TrimSuffix(baseURL, "/v1") + + opts := []option.RequestOption{ + option.WithAuthToken(token), + option.WithBaseURL(baseURL), + } + if isAnthropicOAuthToken(token) { + opts = append(opts, option.WithHeader("anthropic-beta", anthropicOAuthBetaHeader)) + } + + client := anthropic.NewClient(opts...) + params := anthropic.ModelListParams{ + Limit: anthropic.Int(1000), + } + if isAnthropicOAuthToken(token) { + params.Betas = []anthropic.AnthropicBeta{ + anthropic.AnthropicBeta(anthropicOAuthBetaHeader), + } + } + + pager := client.Models.ListAutoPaging(context.Background(), params) + var models []string + for pager.Next() { + modelID := strings.TrimSpace(pager.Current().ID) + if modelID != "" { + models = append(models, modelID) + } + } + if err := pager.Err(); err != nil { + return nil, err + } + + models = dedupeNonEmpty(models) + if len(models) == 0 { + return nil, fmt.Errorf("anthropic endpoint returned zero models") + } + return models, nil +} + +func isAnthropicOAuthToken(token string) bool { + return strings.HasPrefix(strings.TrimSpace(token), "sk-ant-oat") +} + +func dedupeNonEmpty(in []string) []string { + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, v := range in { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} diff --git a/pkg/models/models_test.go b/pkg/models/models_test.go new file mode 100644 index 0000000..d84b234 --- /dev/null +++ b/pkg/models/models_test.go @@ -0,0 +1,77 @@ +package models + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestSetModelSyncsProvider(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Model = "gpt-5.2" + cfg.Agents.Defaults.Provider = "openai" + + configPath := filepath.Join(t.TempDir(), "config.json") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("seed config: %v", err) + } + + if err := SetModel(cfg, configPath, "claude-opus-4-6"); err != nil { + t.Fatalf("SetModel: %v", err) + } + + if got, want := cfg.Agents.Defaults.Provider, "anthropic"; got != want { + t.Fatalf("provider = %q, want %q", got, want) + } + + raw, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read config: %v", err) + } + text := string(raw) + if !strings.Contains(text, `"provider": "anthropic"`) { + t.Fatalf("saved config missing provider sync: %s", text) + } +} + +func TestResolveDiscoveryProviderPrefersModel(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Model = "claude-sonnet-4-5-20250929" + cfg.Agents.Defaults.Provider = "openai" + + if got, want := resolveDiscoveryProvider(cfg), "anthropic"; got != want { + t.Fatalf("resolveDiscoveryProvider() = %q, want %q", got, want) + } +} + +func TestDiscoverFallsBackToBuiltinModels(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Model = "claude-opus-4-6" + cfg.Agents.Defaults.Provider = "" + cfg.Providers.Anthropic.APIKey = "" + cfg.Providers.Anthropic.AuthMethod = "" + + result := Discover(cfg) + if got, want := result.Provider, "anthropic"; got != want { + t.Fatalf("provider = %q, want %q", got, want) + } + if result.Source != "builtin" && result.Source != "endpoint" { + t.Fatalf("unexpected source %q", result.Source) + } + if len(result.Models) == 0 { + t.Fatalf("expected non-empty model list") + } + found := false + for _, m := range result.Models { + if m == "claude-opus-4-6" { + found = true + break + } + } + if !found { + t.Fatalf("builtin anthropic models missing expected id; got %v", result.Models) + } +} From eda10f3d205fb03186073cd43ad8f7e6ac392c98 Mon Sep 17 00:00:00 2001 From: Ernie Pedapati Date: Sun, 22 Feb 2026 18:25:37 -0500 Subject: [PATCH 07/13] fix(tui): prefer current binary path for local subprocess commands --- cmd/picoclaw/tui/executor_local.go | 60 +++++++++++++++++++++++++ cmd/picoclaw/tui/executor_local_test.go | 30 ++++++++++++- 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/cmd/picoclaw/tui/executor_local.go b/cmd/picoclaw/tui/executor_local.go index 4640144..b424e87 100644 --- a/cmd/picoclaw/tui/executor_local.go +++ b/cmd/picoclaw/tui/executor_local.go @@ -89,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 @@ -111,6 +112,65 @@ func runLocalCmd(timeout time.Duration, name string, args ...string) (string, er } } +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") { diff --git a/cmd/picoclaw/tui/executor_local_test.go b/cmd/picoclaw/tui/executor_local_test.go index b6dfce8..a7e68a4 100644 --- a/cmd/picoclaw/tui/executor_local_test.go +++ b/cmd/picoclaw/tui/executor_local_test.go @@ -1,6 +1,9 @@ package tui -import "testing" +import ( + "strings" + "testing" +) func TestParseServiceStatusFlag(t *testing.T) { out := ` @@ -32,3 +35,28 @@ Gateway service status: } } +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) + } +} From 02bc504729549c1813c121600c0e907744160278 Mon Sep 17 00:00:00 2001 From: Ernie Pedapati Date: Sun, 22 Feb 2026 18:33:15 -0500 Subject: [PATCH 08/13] fix(models): include configured secondary provider catalogs in discover --- pkg/models/models.go | 35 ++++++++++++++++++++++++++++++++++- pkg/models/models_test.go | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/pkg/models/models.go b/pkg/models/models.go index 7778027..d5e66a6 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -288,13 +288,23 @@ func Discover(cfg *config.Config) DiscoverResult { if err == nil && len(models) > 0 { result.Source = "endpoint" result.Models = models - return result } if err != nil { result.Warning = err.Error() } } + // Also include models from other configured providers so users can switch + // providers from a single selector without having to manually type IDs. + secondary := discoverSecondaryProviders(cfg, provider) + for _, name := range secondary { + result.Models = append(result.Models, knownModelsForProvider(name)...) + } + result.Models = dedupeNonEmpty(result.Models) + if len(secondary) > 0 && result.Source == "endpoint" { + result.Source = "endpoint+builtin" + } + // If provider-specific builtins are empty, include known models from configured providers. if len(result.Models) == 0 { for _, p := range ListProviders(cfg) { @@ -344,6 +354,29 @@ func resolveDiscoveryProvider(cfg *config.Config) string { return "unknown" } +func discoverSecondaryProviders(cfg *config.Config, primary string) []string { + candidates := []string{ + "anthropic", + "openai", + "openrouter", + "gemini", + "groq", + "deepseek", + "zhipu", + } + var out []string + for _, name := range candidates { + if name == primary { + continue + } + if resolveAuthMethod(name, cfg) == "not configured" { + continue + } + out = append(out, name) + } + return out +} + func knownModelsForProvider(provider string) []string { switch provider { case "anthropic": diff --git a/pkg/models/models_test.go b/pkg/models/models_test.go index d84b234..a41c25d 100644 --- a/pkg/models/models_test.go +++ b/pkg/models/models_test.go @@ -58,7 +58,7 @@ func TestDiscoverFallsBackToBuiltinModels(t *testing.T) { if got, want := result.Provider, "anthropic"; got != want { t.Fatalf("provider = %q, want %q", got, want) } - if result.Source != "builtin" && result.Source != "endpoint" { + if result.Source != "builtin" && result.Source != "endpoint" && result.Source != "endpoint+builtin" { t.Fatalf("unexpected source %q", result.Source) } if len(result.Models) == 0 { @@ -75,3 +75,34 @@ func TestDiscoverFallsBackToBuiltinModels(t *testing.T) { t.Fatalf("builtin anthropic models missing expected id; got %v", result.Models) } } + +func TestDiscoverIncludesSecondaryConfiguredProviders(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Model = "claude-opus-4-6" + cfg.Agents.Defaults.Provider = "anthropic" + cfg.Providers.Anthropic.AuthMethod = "token" + cfg.Providers.OpenAI.AuthMethod = "oauth" + + result := Discover(cfg) + if len(result.Models) == 0 { + t.Fatalf("expected non-empty model list") + } + + hasClaude := false + hasGPT := false + for _, m := range result.Models { + if m == "claude-opus-4-6" { + hasClaude = true + } + if m == "gpt-5.2" { + hasGPT = true + } + } + + if !hasClaude { + t.Fatalf("expected anthro model in list, got %v", result.Models) + } + if !hasGPT { + t.Fatalf("expected openai model in list, got %v", result.Models) + } +} From 7cf1459bb727efdc877b7edc64a2ace658d27b83 Mon Sep 17 00:00:00 2001 From: Ernie Pedapati Date: Sun, 22 Feb 2026 18:45:23 -0500 Subject: [PATCH 09/13] fix(service): normalize launchd kickstart failures and doctor log checks --- cmd/picoclaw/doctor_cmd.go | 7 ++- cmd/picoclaw/doctor_gateway_log_test.go | 52 ++++++++++++++++++++++ pkg/service/launchd_darwin.go | 20 ++++++++- pkg/service/launchd_darwin_test.go | 57 +++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 cmd/picoclaw/doctor_gateway_log_test.go create mode 100644 pkg/service/launchd_darwin_test.go diff --git a/cmd/picoclaw/doctor_cmd.go b/cmd/picoclaw/doctor_cmd.go index 3872c72..f83e2d8 100644 --- a/cmd/picoclaw/doctor_cmd.go +++ b/cmd/picoclaw/doctor_cmd.go @@ -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) } @@ -508,7 +508,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"} @@ -517,6 +517,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 { diff --git a/cmd/picoclaw/doctor_gateway_log_test.go b/cmd/picoclaw/doctor_gateway_log_test.go new file mode 100644 index 0000000..a459e37 --- /dev/null +++ b/cmd/picoclaw/doctor_gateway_log_test.go @@ -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) + } +} + diff --git a/pkg/service/launchd_darwin.go b/pkg/service/launchd_darwin.go index fdd88cb..1be8af2 100644 --- a/pkg/service/launchd_darwin.go +++ b/pkg/service/launchd_darwin.go @@ -90,8 +90,16 @@ func (m *launchdManager) Start() error { } } } + if out, err := runCommand(m.runner, 5*time.Second, "launchctl", "enable", m.serviceTarget); err != nil { + return fmt.Errorf("enable failed: %s", commandErrorDetail(err, out)) + } if out, err := runCommand(m.runner, 10*time.Second, "launchctl", "kickstart", "-k", m.serviceTarget); err != nil { - return fmt.Errorf("kickstart failed: %s", oneLine(string(out))) + // launchctl may report a kickstart failure even after successfully loading + // and running the service; treat verified running state as success. + if st, stErr := m.Status(); stErr == nil && st.Running { + return nil + } + return fmt.Errorf("kickstart failed: %s", commandErrorDetail(err, out)) } return nil } @@ -149,3 +157,13 @@ func (m *launchdManager) Logs(lines int) (string, error) { } return combined, nil } + +func commandErrorDetail(err error, out []byte) string { + if msg := oneLine(string(out)); msg != "" { + return msg + } + if err != nil { + return err.Error() + } + return "" +} diff --git a/pkg/service/launchd_darwin_test.go b/pkg/service/launchd_darwin_test.go new file mode 100644 index 0000000..2a8db97 --- /dev/null +++ b/pkg/service/launchd_darwin_test.go @@ -0,0 +1,57 @@ +//go:build darwin + +package service + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" +) + +type launchdStartTestRunner struct { + printCalls int +} + +func (r *launchdStartTestRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) { + if name != "launchctl" || len(args) == 0 { + return nil, nil + } + + switch args[0] { + case "print": + r.printCalls++ + if r.printCalls == 1 { + return nil, errors.New("service not loaded") + } + return []byte("state = running\npid = 4242\n"), nil + case "bootstrap", "enable": + return nil, nil + case "kickstart": + return nil, errors.New("exit status 1") + default: + return nil, nil + } +} + +func TestLaunchdStartNormalizesKickstartFailureWhenServiceRunning(t *testing.T) { + tmp := t.TempDir() + plistPath := filepath.Join(tmp, "io.sciclaw.gateway.plist") + if err := os.WriteFile(plistPath, []byte(""), 0644); err != nil { + t.Fatalf("seed plist: %v", err) + } + + runner := &launchdStartTestRunner{} + mgr := &launchdManager{ + runner: runner, + domainTarget: "gui/501", + serviceTarget: "gui/501/io.sciclaw.gateway", + plistPath: plistPath, + } + + if err := mgr.Start(); err != nil { + t.Fatalf("Start() = %v, want success", err) + } +} + From 921703b6475c17c6122e70910f2261bc5d1c2935 Mon Sep 17 00:00:00 2001 From: Ernie Pedapati Date: Sun, 22 Feb 2026 19:34:31 -0500 Subject: [PATCH 10/13] fix(routing): restore persona/skills context and launchd tool path --- cmd/picoclaw/doctor_binary_test.go | 24 +++++++++++++ cmd/picoclaw/doctor_cmd.go | 15 +++++++- pkg/agent/context.go | 58 ++++++++++++++++++++++++++---- pkg/agent/context_test.go | 43 ++++++++++++++++++++++ pkg/service/launchd_darwin.go | 15 +++++++- pkg/service/manager_test.go | 5 ++- pkg/service/templates.go | 10 ++++-- 7 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 cmd/picoclaw/doctor_binary_test.go diff --git a/cmd/picoclaw/doctor_binary_test.go b/cmd/picoclaw/doctor_binary_test.go new file mode 100644 index 0000000..00ac484 --- /dev/null +++ b/cmd/picoclaw/doctor_binary_test.go @@ -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") + } +} + diff --git a/cmd/picoclaw/doctor_cmd.go b/cmd/picoclaw/doctor_cmd.go index f83e2d8..dccc8ee 100644 --- a/cmd/picoclaw/doctor_cmd.go +++ b/cmd/picoclaw/doctor_cmd.go @@ -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"} } @@ -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 == "" { diff --git a/pkg/agent/context.go b/pkg/agent/context.go index d01cd32..9bc65fb 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -30,12 +30,48 @@ func getGlobalConfigDir() string { return filepath.Join(home, ".picoclaw") } +func resolveGlobalSkillsDir() string { + base := strings.TrimSpace(getGlobalConfigDir()) + if base == "" { + return "" + } + workspaceSkills := filepath.Join(base, "workspace", "skills") + legacySkills := filepath.Join(base, "skills") + + // Prefer the workspace baseline skills path used by onboarded installs. + if hasSkillsDir(workspaceSkills) { + return workspaceSkills + } + // Fall back to legacy global skills location if present. + if hasSkillsDir(legacySkills) { + return legacySkills + } + // Default to workspace-based path so future installs land in one place. + return workspaceSkills +} + +func hasSkillsDir(path string) bool { + entries, err := os.ReadDir(path) + if err != nil { + return false + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if _, err := os.Stat(filepath.Join(path, entry.Name(), "SKILL.md")); err == nil { + return true + } + } + return false +} + func NewContextBuilder(workspace string) *ContextBuilder { // builtin skills: skills directory in current project // Use the skills/ directory under the current working directory wd, _ := os.Getwd() builtinSkillsDir := filepath.Join(wd, "skills") - globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills") + globalSkillsDir := resolveGlobalSkillsDir() return &ContextBuilder{ workspace: workspace, @@ -159,15 +195,25 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string { "TOOLS.md", } - var result string + primaryWorkspace := filepath.Clean(cb.workspace) + fallbackWorkspace := filepath.Clean(filepath.Join(getGlobalConfigDir(), "workspace")) + + var result strings.Builder for _, filename := range bootstrapFiles { - filePath := filepath.Join(cb.workspace, filename) - if data, err := os.ReadFile(filePath); err == nil { - result += fmt.Sprintf("## %s\n\n%s\n\n", filename, string(data)) + candidates := []string{filepath.Join(primaryWorkspace, filename)} + if fallbackWorkspace != "" && fallbackWorkspace != "." && fallbackWorkspace != primaryWorkspace { + candidates = append(candidates, filepath.Join(fallbackWorkspace, filename)) + } + + for _, filePath := range candidates { + if data, err := os.ReadFile(filePath); err == nil { + result.WriteString(fmt.Sprintf("## %s\n\n%s\n\n", filename, string(data))) + break + } } } - return result + return result.String() } func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID string) []providers.Message { diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go index 11f7704..7179373 100644 --- a/pkg/agent/context_test.go +++ b/pkg/agent/context_test.go @@ -49,3 +49,46 @@ func TestLoadBootstrapFilesIncludesTools(t *testing.T) { t.Fatalf("bootstrap content missing TOOLS.md body") } } + +func TestLoadBootstrapFilesFallsBackToGlobalWorkspace(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + globalWorkspace := filepath.Join(home, ".picoclaw", "workspace") + if err := os.MkdirAll(globalWorkspace, 0755); err != nil { + t.Fatalf("mkdir global workspace: %v", err) + } + if err := os.WriteFile(filepath.Join(globalWorkspace, "AGENTS.md"), []byte("# Global Agents\n"), 0644); err != nil { + t.Fatalf("write global AGENTS.md: %v", err) + } + + routedWorkspace := t.TempDir() + cb := NewContextBuilder(routedWorkspace) + bootstrap := cb.LoadBootstrapFiles() + + if !strings.Contains(bootstrap, "# Global Agents") { + t.Fatalf("expected fallback AGENTS.md from global workspace, got: %q", bootstrap) + } +} + +func TestContextBuilderLoadsGlobalWorkspaceSkillsForRoutedWorkspace(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + skillDir := filepath.Join(home, ".picoclaw", "workspace", "skills", "baseline") + if err := os.MkdirAll(skillDir, 0755); err != nil { + t.Fatalf("mkdir skill dir: %v", err) + } + if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Baseline\n"), 0644); err != nil { + t.Fatalf("write SKILL.md: %v", err) + } + + routedWorkspace := t.TempDir() + cb := NewContextBuilder(routedWorkspace) + info := cb.GetSkillsInfo() + + total, _ := info["total"].(int) + if total < 1 { + t.Fatalf("expected at least one skill from global workspace, got %v", info) + } +} diff --git a/pkg/service/launchd_darwin.go b/pkg/service/launchd_darwin.go index 1be8af2..0bc2a67 100644 --- a/pkg/service/launchd_darwin.go +++ b/pkg/service/launchd_darwin.go @@ -5,6 +5,7 @@ package service import ( "fmt" "os" + "os/exec" "path/filepath" "strings" "time" @@ -48,7 +49,8 @@ func (m *launchdManager) Install() error { return err } - plist := renderLaunchdPlist(m.label, m.exePath, m.stdoutPath, m.stderrPath) + pathEnv := buildSystemdPath(os.Getenv("PATH"), m.detectBrewPrefix()) + plist := renderLaunchdPlist(m.label, m.exePath, m.stdoutPath, m.stderrPath, pathEnv) if err := writeFileIfChanged(m.plistPath, []byte(plist), 0644); err != nil { return err } @@ -167,3 +169,14 @@ func commandErrorDetail(err error, out []byte) string { } return "" } + +func (m *launchdManager) detectBrewPrefix() string { + if _, err := exec.LookPath("brew"); err != nil { + return "" + } + out, err := runCommand(m.runner, 4*time.Second, "brew", "--prefix") + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} diff --git a/pkg/service/manager_test.go b/pkg/service/manager_test.go index 1ee01f6..344b306 100644 --- a/pkg/service/manager_test.go +++ b/pkg/service/manager_test.go @@ -92,11 +92,14 @@ func TestBuildSystemdPath_NoBrewPrefix(t *testing.T) { } func TestRenderLaunchdPlist(t *testing.T) { - plist := renderLaunchdPlist("io.sciclaw.gateway", "/opt/homebrew/bin/sciclaw", "/tmp/out.log", "/tmp/err.log") + plist := renderLaunchdPlist("io.sciclaw.gateway", "/opt/homebrew/bin/sciclaw", "/tmp/out.log", "/tmp/err.log", "/opt/homebrew/bin:/usr/bin:/bin") mustContain(t, plist, "io.sciclaw.gateway") mustContain(t, plist, "/opt/homebrew/bin/sciclaw") mustContain(t, plist, "gateway") mustContain(t, plist, "/tmp/out.log") + mustContain(t, plist, "EnvironmentVariables") + mustContain(t, plist, "PATH") + mustContain(t, plist, "/opt/homebrew/bin:/usr/bin:/bin") } func mustContain(t *testing.T, s, needle string) { diff --git a/pkg/service/templates.go b/pkg/service/templates.go index c72a7b5..6e4a177 100644 --- a/pkg/service/templates.go +++ b/pkg/service/templates.go @@ -22,7 +22,7 @@ WantedBy=default.target `, exePath, pathEnv) } -func renderLaunchdPlist(label, exePath, stdoutPath, stderrPath string) string { +func renderLaunchdPlist(label, exePath, stdoutPath, stderrPath, pathEnv string) string { return fmt.Sprintf(` @@ -47,7 +47,13 @@ func renderLaunchdPlist(label, exePath, stdoutPath, stderrPath string) string { StandardErrorPath %s + + EnvironmentVariables + + PATH + %s + -`, label, exePath, stdoutPath, stderrPath) +`, label, exePath, stdoutPath, stderrPath, pathEnv) } From 84b7010f9ccbf7420c3ac1411277d9513bef8f22 Mon Sep 17 00:00:00 2001 From: Ernie Pedapati Date: Sun, 22 Feb 2026 19:42:20 -0500 Subject: [PATCH 11/13] fix(exec): normalize PATH so brew tools resolve in all run modes --- pkg/tools/shell.go | 61 +++++++++++++++++++++++++++++++++++++++-- pkg/tools/shell_test.go | 19 +++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index e3eb30b..27b1f3b 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -148,9 +148,12 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *To for k, v := range t.extraEnv { envOverrides[k] = v } - if len(envOverrides) > 0 { - cmd.Env = mergeEnv(os.Environ(), envOverrides) + pathBase := envOverrides["PATH"] + if strings.TrimSpace(pathBase) == "" { + pathBase = os.Getenv("PATH") } + envOverrides["PATH"] = mergedExecPATH(pathBase) + cmd.Env = mergeEnv(os.Environ(), envOverrides) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout @@ -224,6 +227,60 @@ func mergeEnv(base []string, overrides map[string]string) []string { return out } +func mergedExecPATH(current string) string { + entries := splitAndCleanPath(current) + home, _ := os.UserHomeDir() + extras := []string{ + filepath.Join(home, ".local", "bin"), + "/opt/homebrew/bin", + "/opt/homebrew/sbin", + "/usr/local/bin", + "/usr/local/sbin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + } + return mergePathEntries(entries, extras) +} + +func splitAndCleanPath(pathValue string) []string { + var out []string + for _, p := range strings.Split(pathValue, string(os.PathListSeparator)) { + p = strings.TrimSpace(p) + if p == "" { + continue + } + out = append(out, p) + } + return out +} + +func mergePathEntries(base []string, extras []string) string { + seen := map[string]struct{}{} + var ordered []string + appendIfNew := func(path string) { + path = strings.TrimSpace(path) + if path == "" { + return + } + if _, exists := seen[path]; exists { + return + } + seen[path] = struct{}{} + ordered = append(ordered, path) + } + + for _, p := range base { + appendIfNew(p) + } + for _, p := range extras { + appendIfNew(p) + } + + return strings.Join(ordered, string(os.PathListSeparator)) +} + func (t *ExecTool) commandWithPandocDefaults(command string) (string, error) { if !shouldApplyNIHPandocTemplate(command) { return command, nil diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index 09f5ad2..f5b33e9 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -471,3 +471,22 @@ func TestShellTool_ExecuteDoesNotInjectPandocDefaultsForNonDocx(t *testing.T) { t.Fatalf("expected no --defaults injection for non-docx command, got %q", strings.TrimSpace(result.ForLLM)) } } + +func TestMergedExecPATHIncludesHomebrewAndSystemBins(t *testing.T) { + got := mergedExecPATH("/usr/bin:/bin") + for _, want := range []string{"/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"} { + if !strings.Contains(got, want) { + t.Fatalf("mergedExecPATH missing %q in %q", want, got) + } + } +} + +func TestMergePathEntriesDedupesAndKeepsBaseOrder(t *testing.T) { + got := mergePathEntries( + []string{"/alpha/bin", "/beta/bin", "/alpha/bin"}, + []string{"/beta/bin", "/gamma/bin"}, + ) + if got != "/alpha/bin:/beta/bin:/gamma/bin" { + t.Fatalf("unexpected merged path: %q", got) + } +} From 1a301b3865061cc39f8ff6699ea6b7db139a2185 Mon Sep 17 00:00:00 2001 From: Ernie Pedapati Date: Sun, 22 Feb 2026 21:07:12 -0500 Subject: [PATCH 12/13] fix(routing): avoid blocking workspace readdir during config validation --- pkg/config/config.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 326e360..02226bc 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -518,9 +518,10 @@ func ValidateRoutingConfig(r RoutingConfig) error { if !info.IsDir() { return fmt.Errorf("routing.mappings[%d].workspace must be a directory", i) } - if _, err := os.ReadDir(workspace); err != nil { - return fmt.Errorf("routing.mappings[%d].workspace is not readable: %w", i, err) - } + // Avoid eager directory enumeration here. Cloud-backed folders (e.g., + // Dropbox/iCloud File Provider paths) can block for long periods when + // read in a background service context, which can stall gateway startup. + // Runtime tool execution will still surface permission/readability errors. if len(m.AllowedSenders) == 0 { return fmt.Errorf("routing.mappings[%d].allowed_senders must contain at least one sender", i) From f3ceca0427fff862ded29130c95fbabc1ee914c3 Mon Sep 17 00:00:00 2001 From: Ernie Pedapati Date: Sun, 22 Feb 2026 21:07:19 -0500 Subject: [PATCH 13/13] docs(ops): add brew-only deployment contract for stable gateway ops --- docs/ops/brew-only-deployment-contract.md | 100 ++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/ops/brew-only-deployment-contract.md diff --git a/docs/ops/brew-only-deployment-contract.md b/docs/ops/brew-only-deployment-contract.md new file mode 100644 index 0000000..195dc31 --- /dev/null +++ b/docs/ops/brew-only-deployment-contract.md @@ -0,0 +1,100 @@ +# Brew-Only Deployment Contract (macOS) + +This contract keeps sciClaw deployments stable and prevents path drift. + +## Non-Negotiable Rules + +1. Install and upgrade only through Homebrew. +2. Never copy `sciclaw` binaries into `~/.local/bin` for production services. +3. Never symlink `/opt/homebrew/bin/sciclaw` to a non-Homebrew path. +4. Always (re)install the service using the currently active Brew binary. +5. Treat launchd `Running: yes` as necessary but not sufficient; verify channel/socket readiness too. + +## Golden Commands (Fresh Host) + +```bash +brew tap drpedapati/tap +brew install sciclaw +which -a sciclaw +sciclaw --version +sciclaw service install +sciclaw service start +``` + +## Service Binding Verification (Required) + +```bash +# 1) Binary provenance +which -a sciclaw +ls -l "$(command -v sciclaw)" + +# 2) launchd program path must match active sciclaw +launchctl print gui/"$(id -u)"/io.sciclaw.gateway | egrep "program =|path =|state =" + +# 3) Runtime sanity +sciclaw service status +sciclaw doctor +``` + +Pass criteria: +- `command -v sciclaw` resolves to Homebrew-managed path. +- launchd `program =` points to that same binary. +- `sciclaw doctor` reports no blocking gateway/tool errors. + +## Upgrade Procedure (No Drift) + +```bash +brew update +brew upgrade sciclaw +sciclaw service install # refresh launchd plist to current binary/PATH +sciclaw service restart +sciclaw service status +``` + +## Operational Readiness Check (Gateway) + +After any install/upgrade/restart: + +```bash +# confirm bot connection in logs +sciclaw service logs --lines 120 | egrep -i "Discord bot connected|Telegram bot connected|Starting channel" + +# optional: confirm process has outbound sockets +pid=$(pgrep -f "sciclaw gateway" | head -n1) +lsof -n -P -a -p "$pid" -i +``` + +## Forbidden Patterns + +Do **not** do any of these in production: + +- `cp ./sciclaw ~/.local/bin/sciclaw` +- `ln -sf ~/.local/bin/sciclaw /opt/homebrew/bin/sciclaw` +- Running mixed binaries (`sciclaw` from Brew, service using another path) +- Manual launchd plist edits that bypass `sciclaw service install` + +## Recovery Playbook (If Drift Is Suspected) + +```bash +# stop/uninstall current service binding +sciclaw service stop || true +sciclaw service uninstall || true + +# reinstall from Brew source of truth +brew reinstall sciclaw + +# rebind service and start +sciclaw service install +sciclaw service start +sciclaw service status +``` + +If `sciclaw` is missing after cleanup, install via Brew first, then run the playbook. + +## Data1 Cleanup Rule (Requested) + +When asked to "clean up sciclaw except config": +- Preserve only `~/.picoclaw/config.json`. +- Remove service/plists/logs/workspace/auth/cache/binaries. +- Reinstall from Brew and rebind service afterward. +