Skip to content

Commit bc2032b

Browse files
bobakemamianclaude
andcommitted
feat(logs): NAME-first form + failure triage folded into logs
Rolls the failure-triage story into the existing logs command so there's no separate DLQ vocabulary to learn. New forms: buttons BUTTONNAME logs — past runs (JSON default) buttons BUTTONNAME logs --failed — just failures buttons BUTTONNAME logs --limit 10 — how many buttons BUTTONNAME logs --follow — press + stream live (TUI) buttons drawer DRAWERNAME logs — past runs for this drawer buttons logs — recent failures across every button + drawer The verb-first `buttons logs NAME` still works as an alias so existing scripts don't break. Implementation reuses the package flag vars so both forms populate the same variables. NAME-first routing: - `buttons NAME logs ...` is rewritten to `buttons logs NAME ...` in Execute() before Cobra sees it, so Cobra routes to logsCmd with all flags parsed correctly. - `buttons drawer NAME logs` is handled by drawerCmd's existing manual NAME-first dispatch; --failed + --limit registered on drawerCmd so they parse there too. Replaces removed DLQ functionality with: buttons logs --json (workspace failures) buttons NAME logs --failed (per-target failures) No new commands. No new packages. Same agent capability, fewer verbs to remember. Updated CLAUDE.md "Stage 2 plumbing" section to describe the logs-based triage flow. SKILL.md + CLI docs regenerated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b5fae2d commit bc2032b

7 files changed

Lines changed: 261 additions & 56 deletions

File tree

.agents/skills/buttons/SKILL.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,14 @@ buttons delete [flags]
197197
Manage drawer workflows (chains of buttons)
198198

199199
```
200-
buttons drawer
200+
buttons drawer [flags]
201201
```
202202

203+
| Flag | Type | Description |
204+
|------|------|-------------|
205+
| `--failed` | bool | only return runs that failed (for `NAME logs`) |
206+
| `--limit` | int | max runs to return (for `NAME logs`) |
207+
203208
### `buttons history`
204209

205210
Show run history
@@ -234,15 +239,18 @@ buttons list
234239

235240
### `buttons logs`
236241

237-
Press a button and watch its output stream live
242+
View a button's past runs, or press and stream live
238243

239244
```
240245
buttons logs [flags]
241246
```
242247

243248
| Flag | Type | Description |
244249
|------|------|-------------|
245-
| `--arg` | stringArray | argument as key=value (repeatable; validated against the button spec) |
250+
| `--arg` | stringArray | argument as key=value (with --follow, passed through to the press) |
251+
| `--failed` | bool | only return runs that failed |
252+
| `-f, --follow` | bool | press the button and stream live output in a TUI |
253+
| `--limit` | int | max runs to return |
246254

247255
### `buttons press`
248256

cmd/drawer.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,13 +102,22 @@ Usage:
102102
return drawerRemove(name)
103103
case "summary":
104104
return drawerShowSummary(name)
105+
case "logs":
106+
return drawerLogs(name, vargs)
105107
default:
106108
return drawerUnknownVerb(name, verb)
107109
}
108110
},
109111
}
110112

111113
func init() {
114+
// Register the logs flags on drawerCmd as well so
115+
// `buttons drawer NAME logs --failed --limit 50` parses. The
116+
// flag variables are package-level (declared in cmd/logs.go)
117+
// so the drawerLogs handler sees the right values regardless
118+
// of which command registered them.
119+
drawerCmd.Flags().BoolVar(&logsFailed, "failed", false, "only return runs that failed (for `NAME logs`)")
120+
drawerCmd.Flags().IntVar(&logsLimit, "limit", 20, "max runs to return (for `NAME logs`)")
112121
rootCmd.AddCommand(drawerCmd)
113122
}
114123

@@ -384,6 +393,49 @@ func drawerSchema() error {
384393
return err
385394
}
386395

396+
// drawerLogs returns past runs for a specific drawer. Shares the
397+
// same flags as `buttons NAME logs` (--failed, --limit). The TUI
398+
// live-follow mode isn't wired for drawers in stage 2 — drawers
399+
// are sequential orchestration and per-button `buttons NAME logs
400+
// --follow` covers the live-press case at the step level.
401+
func drawerLogs(name string, vargs []string) error {
402+
n := logsLimit
403+
if n <= 0 {
404+
n = 20
405+
}
406+
runs, err := drawer.ListRuns(name, n)
407+
if err != nil {
408+
return handleDrawerError(err)
409+
}
410+
if logsFailed {
411+
kept := runs[:0]
412+
for _, r := range runs {
413+
if r.Status != "ok" {
414+
kept = append(kept, r)
415+
}
416+
}
417+
runs = kept
418+
}
419+
if jsonOutput {
420+
return config.WriteJSON(runs)
421+
}
422+
if len(runs) == 0 {
423+
fmt.Fprintf(os.Stderr, "no runs for drawer %s yet\n", name)
424+
return nil
425+
}
426+
for _, r := range runs {
427+
status := r.Status
428+
if r.ErrorType != "" {
429+
status = r.Status + " · " + r.ErrorType
430+
}
431+
fmt.Printf("%s %s %dms\n", r.StartedAt.Local().Format("2006-01-02 15:04:05"), status, r.DurationMs)
432+
}
433+
// vargs would be used if we later added step-filters; silence
434+
// the unused warning without losing the parameter.
435+
_ = vargs
436+
return nil
437+
}
438+
387439
// drawerShowSummary prints the drawer introspection view — topology,
388440
// inputs, recent runs, validation state. Used by bare
389441
// `buttons drawer NAME` and by the `--summary` flag on mutations.

cmd/logs.go

Lines changed: 150 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,49 +7,52 @@ import (
77

88
"github.com/autonoco/buttons/internal/button"
99
"github.com/autonoco/buttons/internal/config"
10+
"github.com/autonoco/buttons/internal/drawer"
11+
"github.com/autonoco/buttons/internal/history"
1012
"github.com/autonoco/buttons/internal/tui"
1113
)
1214

1315
var logsArgs []string
16+
var logsFailed bool
17+
var logsLimit int
18+
var logsFollow bool
1419

1520
var logsCmd = &cobra.Command{
1621
Use: "logs [name]",
17-
Short: "Press a button and watch its output stream live",
18-
Long: `Press a button in a full-screen viewer that tails every line of
19-
stdout / stderr as the child writes it.
20-
21-
The viewer stays open after the press completes so you can scroll
22-
the output at leisure. Press esc or q to dismiss; ctrl+c cancels an
23-
in-flight press (the child's process group is killed).
24-
25-
Scope is one press. If the button takes required args, pass them with
26-
--arg key=value the same way 'buttons press' does.
27-
28-
Only shell and code buttons stream today. HTTP and prompt buttons
29-
still use 'buttons press' for now — their execution is request /
30-
response, not a long-running process.
31-
32-
Examples:
33-
buttons logs deploy
34-
buttons logs deploy --arg env=staging
35-
buttons logs etl --arg file=/tmp/x.csv`,
36-
Args: exactArgs(1),
22+
Short: "View a button's past runs, or press and stream live",
23+
Long: `View a button's run history. Preferred form is name-first to
24+
match the rest of the CLI:
25+
26+
buttons BUTTONNAME logs — past runs for this button
27+
buttons BUTTONNAME logs --follow — press + stream live
28+
buttons BUTTONNAME logs --failed — just failures
29+
buttons drawer DRAWERNAME logs — past runs for this drawer
30+
31+
The verb-first form (buttons logs NAME) still works as an alias.
32+
buttons logs (no name) dumps recent failures across every button
33+
and drawer — same shape as summary.recent_failures.`,
34+
Args: cobra.MaximumNArgs(1),
3735
RunE: runLogs,
3836
}
3937

4038
func runLogs(cmd *cobra.Command, args []string) error {
41-
if jsonOutput {
42-
_ = config.WriteJSONError("NOT_APPLICABLE", "logs is an interactive TUI; --json is not supported")
43-
return errSilent
39+
// Workspace-level: no name → recent failures across everything.
40+
if len(args) == 0 {
41+
return logsWorkspaceFailures()
4442
}
45-
if config.IsNonTTY() {
46-
// Piped / CI — the TUI can't render. Tell the user to use
47-
// 'buttons press --json' instead of dropping into a broken state.
48-
fmt.Fprintln(cmd.ErrOrStderr(), "logs requires a TTY. For programmatic output, use: buttons press --json")
49-
return errSilent
43+
44+
// Per-button: if --follow AND TTY AND not --json, drop into the
45+
// live-stream TUI. Otherwise return the structured past-runs
46+
// view — this is the agent path and also the default non-TTY
47+
// path. Failures live in history, so `buttons NAME logs --failed`
48+
// is the triage call.
49+
if logsFollow && !jsonOutput && !config.IsNonTTY() {
50+
return runLogsTUI(cmd, args[0])
5051
}
52+
return logsButtonJSON(args[0])
53+
}
5154

52-
name := args[0]
55+
func runLogsTUI(cmd *cobra.Command, name string) error {
5356
svc := button.NewService()
5457

5558
btn, err := svc.Get(name)
@@ -109,7 +112,124 @@ func runtimeLabel(btn *button.Button) string {
109112
return btn.Runtime
110113
}
111114

115+
// logsButtonJSON prints past runs for one button. Honors --failed
116+
// (filter to non-ok) and --limit (default 20). Used by agents for
117+
// triage — a single call shows the last N runs with full structured
118+
// errors, args, and stderr, so drill-in doesn't need a second command.
119+
func logsButtonJSON(name string) error {
120+
n := logsLimit
121+
if n <= 0 {
122+
n = 20
123+
}
124+
runs, err := history.List(name, n)
125+
if err != nil {
126+
return handleServiceError(err)
127+
}
128+
if logsFailed {
129+
kept := runs[:0]
130+
for _, r := range runs {
131+
if r.Status != "ok" {
132+
kept = append(kept, r)
133+
}
134+
}
135+
runs = kept
136+
}
137+
return config.WriteJSON(runs)
138+
}
139+
140+
// logsWorkspaceFailures aggregates recent failures across every
141+
// button + drawer so agents can triage in one tool call. Same bucket
142+
// that `buttons summary --json` surfaces under recent_failures, but
143+
// this command is the "show me what broke" direct path.
144+
func logsWorkspaceFailures() error {
145+
n := logsLimit
146+
if n <= 0 {
147+
n = 20
148+
}
149+
150+
type failure struct {
151+
Target string `json:"target"`
152+
RunID string `json:"run_id,omitempty"`
153+
StartedAt any `json:"started_at"`
154+
Status string `json:"status"`
155+
ExitCode int `json:"exit_code,omitempty"`
156+
ErrorType string `json:"error_type,omitempty"`
157+
Stderr string `json:"stderr,omitempty"`
158+
FailedStep string `json:"failed_step,omitempty"`
159+
}
160+
161+
out := []failure{}
162+
163+
// Button runs.
164+
allButtonRuns, _ := history.ListAll(n * 4) // overfetch; filter below
165+
for _, r := range allButtonRuns {
166+
if r.Status == "ok" {
167+
continue
168+
}
169+
out = append(out, failure{
170+
Target: "button/" + r.ButtonName,
171+
StartedAt: r.StartedAt,
172+
Status: r.Status,
173+
ExitCode: r.ExitCode,
174+
ErrorType: r.ErrorType,
175+
Stderr: truncateForSummary(r.Stderr, 400),
176+
})
177+
if len(out) >= n {
178+
break
179+
}
180+
}
181+
182+
// Drawer runs — scan each drawer's history.
183+
if len(out) < n {
184+
dsvc := drawer.NewService()
185+
drawers, _ := dsvc.List()
186+
for _, d := range drawers {
187+
runs, _ := drawer.ListRuns(d.Name, n)
188+
for _, r := range runs {
189+
if r.Status == "ok" {
190+
continue
191+
}
192+
out = append(out, failure{
193+
Target: "drawer/" + d.Name,
194+
RunID: r.RunID,
195+
StartedAt: r.StartedAt,
196+
Status: r.Status,
197+
ErrorType: r.ErrorType,
198+
FailedStep: lastFailedStep(r),
199+
})
200+
if len(out) >= n {
201+
break
202+
}
203+
}
204+
if len(out) >= n {
205+
break
206+
}
207+
}
208+
}
209+
210+
return config.WriteJSON(out)
211+
}
212+
213+
func lastFailedStep(r drawer.Run) string {
214+
for i := len(r.Steps) - 1; i >= 0; i-- {
215+
if r.Steps[i].Status != "ok" {
216+
return r.Steps[i].ID
217+
}
218+
}
219+
return ""
220+
}
221+
222+
func truncateForSummary(s string, n int) string {
223+
if len(s) <= n {
224+
return s
225+
}
226+
return s[:n] + "…"
227+
}
228+
112229
func init() {
113-
logsCmd.Flags().StringArrayVar(&logsArgs, "arg", nil, "argument as key=value (repeatable; validated against the button spec)")
230+
logsCmd.Flags().StringArrayVar(&logsArgs, "arg", nil, "argument as key=value (with --follow, passed through to the press)")
231+
logsCmd.Flags().BoolVar(&logsFailed, "failed", false, "only return runs that failed")
232+
logsCmd.Flags().IntVar(&logsLimit, "limit", 20, "max runs to return")
233+
logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", false, "press the button and stream live output in a TUI")
114234
rootCmd.AddCommand(logsCmd)
115235
}

cmd/root.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"fmt"
66
"os"
7+
"strings"
78

89
"github.com/autonoco/buttons/internal/button"
910
"github.com/autonoco/buttons/internal/config"
@@ -35,6 +36,15 @@ var rootCmd = &cobra.Command{
3536
return config.EnsureDataDir()
3637
},
3738
RunE: func(cmd *cobra.Command, args []string) error {
39+
// NAME-first verb form matching `buttons drawer NAME add/...`
40+
//
41+
// buttons BUTTONNAME logs [--follow] [--failed] [--limit N]
42+
//
43+
// Routes through the existing logs command so every flag
44+
// keeps working.
45+
if len(args) >= 2 && args[1] == "logs" {
46+
return runLogs(cmd, []string{args[0]})
47+
}
3848
// If a positional arg was passed and it isn't a subcommand,
3949
// fall back to per-button detail — preserves existing
4050
// `buttons <name>` shorthand.
@@ -51,6 +61,26 @@ var rootCmd = &cobra.Command{
5161
func Root() *cobra.Command { return rootCmd }
5262

5363
func Execute() {
64+
// NAME-first rewriting. Cobra routes by subcommand name in arg
65+
// position 1, so `buttons BUTTONNAME logs ...` doesn't reach
66+
// logsCmd naturally. We rewrite it to `buttons logs BUTTONNAME
67+
// ...` before Cobra sees it. Same pattern intentionally NOT
68+
// applied to `buttons drawer NAME logs` because drawerCmd
69+
// already does its own NAME-first dispatch internally.
70+
if len(os.Args) >= 3 && os.Args[2] == "logs" && !strings.HasPrefix(os.Args[1], "-") {
71+
switch os.Args[1] {
72+
case "drawer", "create", "press", "list", "delete", "rm", "remove",
73+
"batteries", "board", "config", "history", "init", "logs",
74+
"smash", "store", "summary", "tail", "update", "version":
75+
// Already a subcommand; don't rewrite.
76+
default:
77+
rewritten := make([]string, 0, len(os.Args))
78+
rewritten = append(rewritten, os.Args[0], "logs", os.Args[1])
79+
rewritten = append(rewritten, os.Args[3:]...)
80+
os.Args = rewritten
81+
}
82+
}
83+
5484
if err := rootCmd.Execute(); err != nil {
5585
if !errors.Is(err, errSilent) {
5686
fmt.Fprintln(os.Stderr, err)

docs/cli/buttons.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ buttons [flags]
3535
* [buttons history](buttons_history.md) - Show run history
3636
* [buttons init](buttons_init.md) - Initialize a project-local .buttons directory
3737
* [buttons list](buttons_list.md) - List all buttons
38-
* [buttons logs](buttons_logs.md) - Press a button and watch its output stream live
38+
* [buttons logs](buttons_logs.md) - View a button's past runs, or press and stream live
3939
* [buttons press](buttons_press.md) - Run a button
4040
* [buttons smash](buttons_smash.md) - Run multiple buttons in parallel
4141
* [buttons store](buttons_store.md) - Marketplace (search/install/import/publish)

docs/cli/buttons_drawer.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ buttons drawer [flags]
3030
### Options
3131

3232
```
33-
-h, --help help for drawer
33+
--failed NAME logs only return runs that failed (for NAME logs)
34+
-h, --help help for drawer
35+
--limit NAME logs max runs to return (for NAME logs) (default 20)
3436
```
3537

3638
### Options inherited from parent commands

0 commit comments

Comments
 (0)