Skip to content

Commit 4650d3f

Browse files
teovlclaude
andcommitted
feat(pilotctl): agent-first CLI overhaul — bounded output, filters, styling
Inbox was unusable for agents (23 MB --json dumps, oldest-first, 80-char mid-token truncation) and several commands shared the same disease. This makes every high-traffic command bounded, filterable, non-interactive, and visually scannable, without breaking --json consumers. - inbox: newest-first, default --limit 10, --latest/--from/--since/ --full/read <id>/--clear --before; --json bounded (23 MB -> 3 KB) - received: same flag surface ported (mtime-ordered; sender metadata unavailable in dataexchange filenames) - peers: summary + exceptions-only view (surfaces unencrypted peers), colorized --all, --limit/--search - trust: newest-first, --limit 20, --search, one-way trust flagged - daemon status: fix contradiction (stale PID file reported "stopped" while live socket data printed); socket is now the source of truth - info: grouped identity/network/traffic/skills layout - ping: 5s default timeout (was 30s), relay-convergence hint on failure - send-message/ping --json: add "to" resolved-address field - send-message --wait, ping, bench, traceroute: animated elapsed line on stderr (TTY-only, erased on completion, no-op for pipes/--json) - config/skills status/updates/network list: aligned key-value, per-tool status dots, word-boundary wrap, dead MEMBERS column dropped - handshake/approve/untrust: next-step hints - context <command>: single-command spec (18 KB -> ~440 B) New style layer (cmd/pilotctl/style.go): semantic ANSI helpers gated on TTY + NO_COLOR/PILOT_NO_COLOR/TERM=dumb; tests pipe stdout so assertions stay plain-text. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 388805b commit 4650d3f

11 files changed

Lines changed: 1535 additions & 401 deletions

cmd/pilotctl/main.go

Lines changed: 881 additions & 328 deletions
Large diffs are not rendered by default.

cmd/pilotctl/skills.go

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,11 @@ func runTick() (*skillinject.Report, error) {
5858
}
5959

6060
// cmdSkillsStatus runs one tick (fetching the manifest + entrypoint over
61-
// HTTPS) and prints what state each managed file is in and what action
62-
// the daemon's next live tick would take.
63-
func cmdSkillsStatus(_ []string) {
61+
// HTTPS) and prints a per-tool summary line (statusDot + what, if anything,
62+
// the next tick would change). Per-file detail lines are behind --verbose.
63+
func cmdSkillsStatus(args []string) {
64+
flags, _ := parseFlags(args)
65+
verbose := flagBool(flags, "verbose")
6466
report, err := runTick()
6567
if err != nil {
6668
fatalCode("internal", "skills tick: %v", err)
@@ -87,10 +89,8 @@ func cmdSkillsStatus(_ []string) {
8789
return
8890
}
8991

90-
fmt.Println("Pilot Protocol skill — install status")
91-
fmt.Println("=====================================")
92-
fmt.Printf("Reconcile cadence: every %s (default), plus once on daemon startup.\n", skillinject.DefaultInterval)
93-
fmt.Println("All paths below are auto-managed by the daemon — edits are reverted on next tick.")
92+
fmt.Println(sBold("Pilot Protocol skill — install status"))
93+
fmt.Println(sDim(fmt.Sprintf("Reconcile cadence: every %s (default), plus once on daemon startup · paths are auto-managed — manual edits revert on next tick", skillinject.DefaultInterval)))
9494
fmt.Println()
9595

9696
if len(report.Outcomes) == 0 {
@@ -116,8 +116,28 @@ func cmdSkillsStatus(_ []string) {
116116
sort.Strings(tools)
117117

118118
for _, tool := range tools {
119-
fmt.Printf("[%s]\n", tool)
120-
for _, o := range byTool[tool] {
119+
outs := byTool[tool]
120+
// One summary line per tool: ok when every managed file is
121+
// identical; otherwise warn naming the first offending file and
122+
// the action the daemon's next tick will take; err on tick errors.
123+
dot, summary := "ok", "skill+heartbeat ok"
124+
for _, o := range outs {
125+
if o.Err != "" {
126+
dot = "err"
127+
summary = fmt.Sprintf("%s — %s", filepath.Base(o.Path), o.Err)
128+
break
129+
}
130+
if o.State != skillinject.StateIdentical && dot == "ok" {
131+
dot = "warn"
132+
summary = fmt.Sprintf("%s %s — next: %s", filepath.Base(o.Path), o.State, o.Action)
133+
}
134+
}
135+
fmt.Printf("%s %s %s\n", statusDot(dot), sBold(tool), sDim(summary))
136+
137+
if !verbose {
138+
continue
139+
}
140+
for _, o := range outs {
121141
label := "skill copy: "
122142
switch o.Kind {
123143
case skillinject.KindMarker:
@@ -138,6 +158,9 @@ func cmdSkillsStatus(_ []string) {
138158
fmt.Println()
139159
}
140160

161+
if !verbose {
162+
fmt.Printf("\n%s\n", sDim("per-file detail: --verbose · paths only: pilotctl skills paths · force a pass: pilotctl skills check"))
163+
}
141164
if len(report.Skipped) > 0 {
142165
fmt.Printf("Not installed (skipped): %s\n", strings.Join(report.Skipped, ", "))
143166
}
@@ -361,18 +384,37 @@ func cmdSkillsEnable(args []string) {
361384
}
362385
}
363386

364-
// printSkillInstallSummary is called from cmdInfo to surface the agent
365-
// skill install paths in the standard daemon diagnostic. Quiet (no header)
366-
// when no agent tools are detected on the host.
387+
// skillInstallTools returns the agent tools that have the pilot skill
388+
// installed, in detection order. Empty when no agent tools are present
389+
// on the host. Same data source as `pilotctl skills`, collapsed to one
390+
// entry per tool.
391+
func skillInstallTools() []string {
392+
report, err := runTick()
393+
if err != nil || report == nil || len(report.Outcomes) == 0 {
394+
return nil
395+
}
396+
seen := map[string]bool{}
397+
var order []string
398+
for _, o := range report.Outcomes {
399+
if o.Kind != skillinject.KindSkill {
400+
continue
401+
}
402+
if !seen[o.Tool] {
403+
seen[o.Tool] = true
404+
order = append(order, o.Tool)
405+
}
406+
}
407+
return order
408+
}
409+
410+
// printSkillInstallSummary surfaces the agent skill install paths.
411+
// Quiet (no header) when no agent tools are detected on the host.
367412
func printSkillInstallSummary() {
368413
report, err := runTick()
369414
if err != nil || report == nil || len(report.Outcomes) == 0 {
370415
return
371416
}
372417
// Collapse to one path per tool: prefer the skill copy over the marker.
373-
type entry struct {
374-
Tool, Path string
375-
}
376418
seen := map[string]string{}
377419
order := []string{}
378420
for _, o := range report.Outcomes {

cmd/pilotctl/style.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
// style.go — ANSI styling for human-facing output.
4+
//
5+
// Rules:
6+
// - Color only when stdout is a TTY and NO_COLOR / TERM=dumb are unset.
7+
// - --json output is NEVER styled (agents parse it byte-for-byte).
8+
// - Piped text output stays plain so grep/awk/cut keep working.
9+
//
10+
// The palette is semantic, not decorative: ok=green, warn=yellow,
11+
// err=red, accent=cyan (ids, hostnames), dim=metadata. Use sparingly —
12+
// a screen that is all color is as unreadable as one with none.
13+
14+
package main
15+
16+
import (
17+
"fmt"
18+
"os"
19+
"sync"
20+
"time"
21+
)
22+
23+
// colorEnabled is computed once at startup. Tests capture stdout via a
24+
// pipe, so styling is automatically off there and assertions on plain
25+
// text stay stable.
26+
var colorEnabled = func() bool {
27+
if os.Getenv("NO_COLOR") != "" || os.Getenv("PILOT_NO_COLOR") != "" {
28+
return false
29+
}
30+
if os.Getenv("TERM") == "dumb" {
31+
return false
32+
}
33+
fi, err := os.Stdout.Stat()
34+
if err != nil {
35+
return false
36+
}
37+
return fi.Mode()&os.ModeCharDevice != 0
38+
}()
39+
40+
func sc(code, s string) string {
41+
if !colorEnabled {
42+
return s
43+
}
44+
return "\x1b[" + code + "m" + s + "\x1b[0m"
45+
}
46+
47+
func sBold(s string) string { return sc("1", s) }
48+
func sDim(s string) string { return sc("2", s) }
49+
func sOK(s string) string { return sc("32", s) }
50+
func sWarn(s string) string { return sc("33", s) }
51+
func sErr(s string) string { return sc("31", s) }
52+
func sAccent(s string) string { return sc("36", s) }
53+
54+
// stderrIsTTY mirrors colorEnabled but for os.Stderr. It gates the
55+
// animated wait-progress line: animation (cursor rewrites) only makes
56+
// sense on an interactive terminal, and NO_COLOR / TERM=dumb users have
57+
// asked for plain output, so they get silence instead of a spinner.
58+
var stderrIsTTY = func() bool {
59+
if os.Getenv("NO_COLOR") != "" || os.Getenv("PILOT_NO_COLOR") != "" {
60+
return false
61+
}
62+
if os.Getenv("TERM") == "dumb" {
63+
return false
64+
}
65+
fi, err := os.Stderr.Stat()
66+
if err != nil {
67+
return false
68+
}
69+
return fi.Mode()&os.ModeCharDevice != 0
70+
}()
71+
72+
// startWaitProgress shows a single self-rewriting stderr line
73+
// ("label… Ns") while a blocking wait is in flight, so commands like
74+
// `send-message --wait` don't sit in 30 s of dead silence. Returns a
75+
// stop func that erases the line and terminates the ticker goroutine.
76+
//
77+
// Safe by construction:
78+
// - no-op when stderr is not a TTY or --json is set (agents and pipes
79+
// never see control sequences);
80+
// - stop() is idempotent (sync.Once), so it can be called on every
81+
// exit path of a wait without bookkeeping;
82+
// - stop() blocks until the goroutine has erased the line, so output
83+
// printed right after stop() never interleaves with the animation.
84+
//
85+
// First draw happens at the first 500 ms tick — sub-500 ms waits finish
86+
// without any flicker.
87+
func startWaitProgress(label string) (stop func()) {
88+
if !stderrIsTTY || jsonOutput {
89+
return func() {}
90+
}
91+
done := make(chan struct{})
92+
finished := make(chan struct{})
93+
start := time.Now()
94+
go func() {
95+
defer close(finished)
96+
ticker := time.NewTicker(500 * time.Millisecond)
97+
defer ticker.Stop()
98+
for {
99+
select {
100+
case <-done:
101+
fmt.Fprint(os.Stderr, "\r\x1b[2K")
102+
return
103+
case <-ticker.C:
104+
elapsed := int(time.Since(start).Seconds())
105+
fmt.Fprintf(os.Stderr, "\r\x1b[2K%s",
106+
sDim(fmt.Sprintf("%s… %ds", label, elapsed)))
107+
}
108+
}
109+
}()
110+
var once sync.Once
111+
return func() {
112+
once.Do(func() {
113+
close(done)
114+
<-finished
115+
})
116+
}
117+
}
118+
119+
// statusDot renders a colored ● for state lines: ok=green, warn=yellow,
120+
// err=red. Falls back to plain symbols when color is off so piped output
121+
// still distinguishes states.
122+
func statusDot(state string) string {
123+
switch state {
124+
case "ok":
125+
if colorEnabled {
126+
return sOK("●")
127+
}
128+
return "●"
129+
case "warn":
130+
if colorEnabled {
131+
return sWarn("●")
132+
}
133+
return "◐"
134+
default:
135+
if colorEnabled {
136+
return sErr("●")
137+
}
138+
return "○"
139+
}
140+
}

cmd/pilotctl/updates.go

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,24 +114,53 @@ func cmdUpdates(args []string) {
114114
date = t.Format("2006-01-02")
115115
}
116116
title := strings.TrimSpace(it.Title)
117-
fmt.Printf("• %s %s\n", date, title)
117+
fmt.Printf("• %s %s\n", sAccent(date), title)
118118
if len(it.Categories) > 0 {
119-
fmt.Printf(" [%s]\n", strings.Join(it.Categories, ", "))
119+
fmt.Printf(" %s\n", sDim("["+strings.Join(it.Categories, ", ")+"]"))
120120
}
121121
if d := strings.TrimSpace(it.Description); d != "" {
122-
d = collapseWhitespace(d)
123-
if len(d) > 200 {
124-
d = d[:197] + "..."
125-
}
126-
fmt.Printf(" %s\n", d)
122+
fmt.Printf(" %s\n", wrapText(collapseWhitespace(d), 100, 4))
127123
}
128124
if l := strings.TrimSpace(it.Link); l != "" {
129-
fmt.Printf(" %s\n", l)
125+
fmt.Printf(" %s\n", sDim(l))
130126
}
131127
fmt.Println()
132128
}
133129
}
134130

131+
// wrapText word-wraps s at word boundaries so no line exceeds width
132+
// columns (counting the indent), with a hanging indent: continuation
133+
// lines are prefixed with `indent` spaces to line up under a first line
134+
// the caller has already indented by the same amount. Words longer than
135+
// a full line are emitted unbroken — never split mid-word.
136+
func wrapText(s string, width, indent int) string {
137+
words := strings.Fields(s)
138+
if len(words) == 0 {
139+
return ""
140+
}
141+
pad := strings.Repeat(" ", indent)
142+
var b strings.Builder
143+
lineLen := indent // caller prints the first line's indent
144+
for i, w := range words {
145+
wl := len([]rune(w))
146+
switch {
147+
case i == 0:
148+
b.WriteString(w)
149+
lineLen += wl
150+
case lineLen+1+wl > width:
151+
b.WriteString("\n")
152+
b.WriteString(pad)
153+
b.WriteString(w)
154+
lineLen = indent + wl
155+
default:
156+
b.WriteString(" ")
157+
b.WriteString(w)
158+
lineLen += 1 + wl
159+
}
160+
}
161+
return b.String()
162+
}
163+
135164
// filterAndTruncate applies the --scope category filter (case-insensitive,
136165
// match-any-category) and the --count cap to a list of feed items. Both
137166
// arguments are inert when zero/empty, so callers can pass scope="" or

0 commit comments

Comments
 (0)