Skip to content

Commit 748cb36

Browse files
aksOpsclaude
andcommitted
feat: V10 bash-only feed · V20 doctor panel · V21 log disk usage
Three independent v0.2 items landed together from parallel subagent work — batched because they share edits to server.go, SessionDetail, and cmd/serve.go. V10 — Bash-only command log: - FeedStream gains a bashOnly prop; when true filters tool_call events to tool==Bash and swaps to <BashOnlyRow>. - SessionDetail.FeedTab subcomponent owns an All|Bash segmented toggle, persists the choice in sessionStorage keyed by session name. - BashOnlyRow: compact mono command + ok/err chip, click to expand showing full command + first 8 ANSI-stripped output lines. - ToolCallRow type gains optional exit_code (forward-compat — the Go hub doesn't emit it today; derivation falls back to is_error). V20 — ctm doctor panel: - New internal/doctor/ package factored out of cmd/doctor.go so both CLI and serve call the same Run(ctx, cfg) []Check entry. CLI behaviour preserved byte-for-byte. - GET /api/doctor returns {checks:[{name,status,message,remediation}]} with a 5 s ctx cap. - New /doctor route; DoctorPanel lists checks with coloured dots, expandable remediation, and a Re-run button (refetch). - cmd/serve.go now threads the loaded Config into serve.Options so the doctor handler doesn't re-read config from disk per request. V21 — Log disk usage: - GET /api/logs/usage walks ~/.config/ctm/logs/ and returns {dir,total_bytes,files:[{uuid,session,bytes,mtime}]}. 10 000-file safety bound (returns 507 over limit). Sorted by bytes desc. - logsUUIDResolver in server.go mirrors the claudeDirToName reverse- lookup used in orphan UUID adoption. - format.ts gains humanBytes (binary, 1 KB = 1024 B — devs read du -h). - LogDiskUsage card rendered below MetaList in the Meta tab. - Read-only — no delete endpoint (that'd be V23 mutation territory). Tests (all landed, all green under make regression): - Go: 22 packages ok, race-clean on internal/serve/..., govulncheck clean - vitest: 7 files, 32 tests (was 18) - playwright: 15 specs (was 13) - pnpm audit: 0 vulnerabilities Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e37504c commit 748cb36

27 files changed

Lines changed: 2467 additions & 13 deletions

cmd/doctor.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
package cmd
22

33
import (
4+
"context"
45
"fmt"
56
"os"
6-
"os/exec"
77
"strings"
88

99
"github.com/spf13/cobra"
1010
"github.com/RandomCodeSpace/ctm/internal/config"
11+
"github.com/RandomCodeSpace/ctm/internal/doctor"
1112
"github.com/RandomCodeSpace/ctm/internal/output"
1213
"github.com/RandomCodeSpace/ctm/internal/serve/auth"
1314
"github.com/RandomCodeSpace/ctm/internal/session"
@@ -25,6 +26,10 @@ var doctorCmd = &cobra.Command{
2526
RunE: runDoctor,
2627
}
2728

29+
// runDoctor is a thin CLI formatter over internal/doctor's shared
30+
// probe primitives. The grouped-section output is preserved byte-for-
31+
// byte from earlier releases (the V20 API endpoint uses the flat
32+
// doctor.Run() slice form instead — same probes, different rendering).
2833
func runDoctor(cmd *cobra.Command, args []string) error {
2934
out := output.Stdout()
3035

@@ -41,7 +46,7 @@ func runDoctor(cmd *cobra.Command, args []string) error {
4146
// --- Dependencies ---
4247
out.Bold("Dependencies:")
4348
for _, dep := range []string{"tmux", "claude", "git"} {
44-
if path, err := exec.LookPath(dep); err == nil {
49+
if path, ok := doctor.LookupBinary(dep); ok {
4550
out.Success(" [OK] %-10s %s", dep, path)
4651
} else {
4752
out.Warn(" [MISSING] %s — not found in PATH", dep)
@@ -50,8 +55,8 @@ func runDoctor(cmd *cobra.Command, args []string) error {
5055
fmt.Println()
5156

5257
// --- Tmux version ---
53-
if out2, err := exec.Command("tmux", "-V").Output(); err == nil {
54-
out.Info("tmux version: %s", strings.TrimSpace(string(out2)))
58+
if v, ok := doctor.TmuxVersion(context.Background()); ok {
59+
out.Info("tmux version: %s", v)
5560
} else {
5661
out.Warn("tmux version: unavailable")
5762
}

cmd/serve.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ the port, it exits non-zero without disturbing the foreign listener.`,
5252
WebhookURL: cfg.Serve.WebhookURL,
5353
WebhookAuth: cfg.Serve.WebhookAuth,
5454
AttentionThresholds: attentionThresholdsFrom(cfg.Serve.Attention),
55+
// Thread the loaded config through so /api/doctor can
56+
// surface required_env / required_in_path without re-
57+
// reading from disk inside the handler.
58+
Config: cfg,
5559
}
5660
// Let the config override the dump dir when set; otherwise
5761
// serve.New falls back to /tmp/ctm-statusline.

internal/doctor/doctor.go

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
// Package doctor implements the diagnostic check runner shared by the
2+
// `ctm doctor` CLI and the `GET /api/doctor` HTTP endpoint.
3+
//
4+
// The CLI formats [ok/warn/err] Check slices as coloured lines; the
5+
// HTTP endpoint JSON-encodes the same slice. Adding a new check means
6+
// appending one function here — both surfaces pick it up without edits.
7+
package doctor
8+
9+
import (
10+
"context"
11+
"fmt"
12+
"os"
13+
"os/exec"
14+
"strings"
15+
16+
"github.com/RandomCodeSpace/ctm/internal/config"
17+
"github.com/RandomCodeSpace/ctm/internal/serve/auth"
18+
"github.com/RandomCodeSpace/ctm/internal/session"
19+
"github.com/RandomCodeSpace/ctm/internal/tmux"
20+
)
21+
22+
// Status enumerates the three check outcomes surfaced to both CLI and UI.
23+
// Values are the wire form — do not rename without bumping the API.
24+
const (
25+
StatusOK = "ok"
26+
StatusWarn = "warn"
27+
StatusErr = "err"
28+
)
29+
30+
// Check is the single result row. JSON tags are the /api/doctor wire
31+
// contract; Remediation is optional and omitted when empty.
32+
type Check struct {
33+
Name string `json:"name"`
34+
Status string `json:"status"`
35+
Message string `json:"message,omitempty"`
36+
Remediation string `json:"remediation,omitempty"`
37+
}
38+
39+
// LookupBinary resolves a named binary on PATH. Exposed so the CLI
40+
// formatter (cmd/doctor.go) can share the exact same probe logic
41+
// rather than shelling out a second time.
42+
func LookupBinary(name string) (path string, ok bool) {
43+
p, err := exec.LookPath(name)
44+
if err != nil {
45+
return "", false
46+
}
47+
return p, true
48+
}
49+
50+
// TmuxVersion runs `tmux -V` under ctx and returns the trimmed output.
51+
// ok=false means tmux is either missing or refused to print a version.
52+
func TmuxVersion(ctx context.Context) (version string, ok bool) {
53+
if _, err := exec.LookPath("tmux"); err != nil {
54+
return "", false
55+
}
56+
b, err := exec.CommandContext(ctx, "tmux", "-V").Output()
57+
if err != nil {
58+
return "", false
59+
}
60+
return strings.TrimSpace(string(b)), true
61+
}
62+
63+
// Run executes every diagnostic check and returns the results in
64+
// display order. All checks respect ctx cancellation; Run returns
65+
// immediately with whatever it has accumulated if ctx expires.
66+
//
67+
// cfg is the already-loaded user config. A zero-valued Config is
68+
// legal: checks that depend on user-configurable lists (required_env,
69+
// required_in_path) simply report "not configured".
70+
func Run(ctx context.Context, cfg config.Config) []Check {
71+
checks := make([]Check, 0, 16)
72+
73+
// Each checker is free to shell out; we gate the slow ones on
74+
// ctx.Err() so a caller with a 5-s deadline can still return a
75+
// partial list rather than blocking the HTTP response.
76+
for _, fn := range []func(context.Context, config.Config) []Check{
77+
checkDependencies,
78+
checkTmuxVersion,
79+
checkRequiredEnv,
80+
checkRequiredInPath,
81+
checkServeToken,
82+
checkConfig,
83+
checkSessions,
84+
} {
85+
if ctx.Err() != nil {
86+
break
87+
}
88+
checks = append(checks, fn(ctx, cfg)...)
89+
}
90+
return checks
91+
}
92+
93+
func checkDependencies(_ context.Context, _ config.Config) []Check {
94+
deps := []string{"tmux", "claude", "git"}
95+
out := make([]Check, 0, len(deps))
96+
for _, dep := range deps {
97+
c := Check{Name: "dep:" + dep}
98+
if path, err := exec.LookPath(dep); err == nil {
99+
c.Status = StatusOK
100+
c.Message = path
101+
} else {
102+
c.Status = StatusWarn
103+
c.Message = fmt.Sprintf("%s not found in PATH", dep)
104+
c.Remediation = fmt.Sprintf(
105+
"install %s and ensure it is on PATH (e.g. `apt install %s` on Debian)",
106+
dep, dep)
107+
}
108+
out = append(out, c)
109+
}
110+
return out
111+
}
112+
113+
func checkTmuxVersion(ctx context.Context, _ config.Config) []Check {
114+
c := Check{Name: "tmux:version"}
115+
// Fail fast if tmux isn't even on PATH — the dep check already
116+
// reported the warning, no need to double-log.
117+
if _, err := exec.LookPath("tmux"); err != nil {
118+
c.Status = StatusWarn
119+
c.Message = "tmux not installed"
120+
c.Remediation = "install tmux first (see dep:tmux)"
121+
return []Check{c}
122+
}
123+
cmd := exec.CommandContext(ctx, "tmux", "-V")
124+
b, err := cmd.Output()
125+
if err != nil {
126+
c.Status = StatusWarn
127+
c.Message = fmt.Sprintf("could not read tmux -V: %v", err)
128+
c.Remediation = "verify tmux works: `tmux -V` in a shell"
129+
return []Check{c}
130+
}
131+
c.Status = StatusOK
132+
c.Message = strings.TrimSpace(string(b))
133+
return []Check{c}
134+
}
135+
136+
func checkRequiredEnv(_ context.Context, cfg config.Config) []Check {
137+
if len(cfg.RequiredEnv) == 0 {
138+
return nil
139+
}
140+
out := make([]Check, 0, len(cfg.RequiredEnv))
141+
for _, name := range cfg.RequiredEnv {
142+
c := Check{Name: "env:" + name}
143+
if v, ok := os.LookupEnv(name); ok && v != "" {
144+
c.Status = StatusOK
145+
c.Message = "set"
146+
} else {
147+
c.Status = StatusErr
148+
c.Message = fmt.Sprintf("%s is not set", name)
149+
c.Remediation = fmt.Sprintf("export %s=... in your shell rc or the session's env file", name)
150+
}
151+
out = append(out, c)
152+
}
153+
return out
154+
}
155+
156+
func checkRequiredInPath(_ context.Context, cfg config.Config) []Check {
157+
if len(cfg.RequiredInPath) == 0 {
158+
return nil
159+
}
160+
out := make([]Check, 0, len(cfg.RequiredInPath))
161+
for _, bin := range cfg.RequiredInPath {
162+
c := Check{Name: "path:" + bin}
163+
if path, err := exec.LookPath(bin); err == nil {
164+
c.Status = StatusOK
165+
c.Message = path
166+
} else {
167+
c.Status = StatusWarn
168+
c.Message = fmt.Sprintf("%s not found on PATH", bin)
169+
c.Remediation = fmt.Sprintf("install %s or add its directory to PATH", bin)
170+
}
171+
out = append(out, c)
172+
}
173+
return out
174+
}
175+
176+
func checkServeToken(_ context.Context, _ config.Config) []Check {
177+
c := Check{Name: "serve:token"}
178+
tokenPath := auth.TokenPath()
179+
info, err := os.Stat(tokenPath)
180+
if err != nil {
181+
c.Status = StatusErr
182+
c.Message = fmt.Sprintf("missing at %s", tokenPath)
183+
c.Remediation = "run `ctm doctor` once (on CLI) to seed serve.token, or `ctm install`"
184+
return []Check{c}
185+
}
186+
mode := info.Mode().Perm()
187+
if mode != 0o600 {
188+
c.Status = StatusWarn
189+
c.Message = fmt.Sprintf("present but mode is %o (want 0600)", mode)
190+
c.Remediation = fmt.Sprintf("run: chmod 600 %s", tokenPath)
191+
return []Check{c}
192+
}
193+
c.Status = StatusOK
194+
c.Message = fmt.Sprintf("present (mode 0600, %d bytes)", info.Size())
195+
return []Check{c}
196+
}
197+
198+
func checkConfig(_ context.Context, cfg config.Config) []Check {
199+
c := Check{Name: "config:load"}
200+
cfgPath := config.ConfigPath()
201+
if _, err := os.Stat(cfgPath); err != nil {
202+
c.Status = StatusWarn
203+
c.Message = fmt.Sprintf("%s not present", cfgPath)
204+
c.Remediation = "run any `ctm` command once to seed defaults"
205+
return []Check{c}
206+
}
207+
// cfg is passed in already loaded; we check that it has *something*
208+
// populated to distinguish "zero value" (caller forgot to load)
209+
// from "file exists and parsed".
210+
if cfg.DefaultMode == "" && len(cfg.RequiredEnv) == 0 && cfg.ScrollbackLines == 0 {
211+
c.Status = StatusWarn
212+
c.Message = fmt.Sprintf("%s is present but appears empty", cfgPath)
213+
c.Remediation = "delete the file and re-run any `ctm` command to re-seed defaults"
214+
return []Check{c}
215+
}
216+
c.Status = StatusOK
217+
c.Message = fmt.Sprintf("loaded (default_mode=%s, scrollback=%d)",
218+
cfg.DefaultMode, cfg.ScrollbackLines)
219+
return []Check{c}
220+
}
221+
222+
func checkSessions(_ context.Context, _ config.Config) []Check {
223+
c := Check{Name: "sessions:store"}
224+
path := config.SessionsPath()
225+
store := session.NewStore(path)
226+
sessions, err := store.List()
227+
if err != nil {
228+
c.Status = StatusWarn
229+
c.Message = fmt.Sprintf("could not list sessions: %v", err)
230+
c.Remediation = fmt.Sprintf("inspect %s; if corrupt, delete it (sessions will be rebuilt from tmux on next attach)", path)
231+
return []Check{c}
232+
}
233+
// Count tmux liveness so the panel tells users whether the store
234+
// agrees with reality without them opening another panel.
235+
tc := tmux.NewClient(config.TmuxConfPath())
236+
alive := 0
237+
for _, s := range sessions {
238+
if tc.HasSession(s.Name) {
239+
alive++
240+
}
241+
}
242+
c.Status = StatusOK
243+
if len(sessions) == 0 {
244+
c.Message = "no sessions on record"
245+
} else {
246+
c.Message = fmt.Sprintf("%d session(s), %d tmux-alive", len(sessions), alive)
247+
}
248+
return []Check{c}
249+
}

0 commit comments

Comments
 (0)