Skip to content

Commit efa295c

Browse files
committed
feat(session): add session list + export commands for AI SRE
Add `flashduty session list` and `flashduty session export <id>`, the CLI surface for the AI SRE session APIs (consumed by the /insight skill). - session list: curated command over Sessions.List. --app (default ai-sre), --limit (default 200), --scope, --status, --team-id, and a client-side --since window (the API has no time filter, so rows are filtered by updated_at after fetching). Output --format jsonl (default, one SessionItem per line for jq) / json / toon. - session export: streams the NDJSON transcript straight to stdout via the SDK's hand-written streaming method, line-by-line, so a large transcript never buffers in memory — redirect to a file. session/export is a streaming op (200 is application/x-ndjson), which the generated typed-response template cannot model. cligen now skips streaming ops (mirroring go-flashduty's own generator) and the curated `safari session-export` leaf keeps it reachable at its path-name alongside the generated safari session-get / session-list. The coverage tests account for streaming ops being curated-only. Pins go-flashduty to the commit that adds the Sessions service.
1 parent e17ab0e commit efa295c

11 files changed

Lines changed: 851 additions & 15 deletions

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/flashcatcloud/flashduty-cli
33
go 1.25.1
44

55
require (
6-
github.com/flashcatcloud/go-flashduty v0.5.3-0.20260602040240-b12fb6a1ddb2
6+
github.com/flashcatcloud/go-flashduty v0.5.4-0.20260602042544-42abd734fee7
77
github.com/mattn/go-runewidth v0.0.23
88
github.com/spf13/cobra v1.10.2
99
github.com/spf13/pflag v1.0.10

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
22
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
33
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
4-
github.com/flashcatcloud/go-flashduty v0.5.3-0.20260602040240-b12fb6a1ddb2 h1:Tr563N4JAbclxnC9dWmwyPC39SCc/bifW0eVvCcnSyk=
5-
github.com/flashcatcloud/go-flashduty v0.5.3-0.20260602040240-b12fb6a1ddb2/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8=
4+
github.com/flashcatcloud/go-flashduty v0.5.4-0.20260602042544-42abd734fee7 h1:ZW8Y7p6JYh+M+saQPq0ScVqRTsxFCrGV59K9TuLxHRA=
5+
github.com/flashcatcloud/go-flashduty v0.5.4-0.20260602042544-42abd734fee7/go.mod h1:aA0RtZEs0AYOwwdNKdtVeD8YMOdnmVY1zAlVD+9Ovx8=
66
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
77
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
88
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=

internal/cli/coverage_test.go

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,20 @@ import (
1111
"github.com/spf13/cobra"
1212
)
1313

14-
// loadSpecPaths reads every GET/POST operation from the openapi spec shipped in
15-
// the linked go-flashduty module — the same spec cligen generates against —
16-
// returning operationId -> path.
17-
func loadSpecPaths(t *testing.T) map[string]string {
14+
// specOpMeta is the slice of an operation the coverage tests reason about.
15+
type specOpMeta struct {
16+
id string
17+
path string
18+
streaming bool // 200 body is not application/json (e.g. application/x-ndjson)
19+
}
20+
21+
// loadSpecOps reads every public GET/POST operation from the openapi spec
22+
// shipped in the linked go-flashduty module — the same spec cligen generates
23+
// against — recording each op's id, path, and whether its 200 response is a
24+
// non-JSON streaming body. Streaming ops are served by curated commands (the
25+
// generated typed-response template cannot model an io.ReadCloser), so the
26+
// generator-coverage check excludes them.
27+
func loadSpecOps(t *testing.T) []specOpMeta {
1828
t.Helper()
1929
out, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "github.com/flashcatcloud/go-flashduty").Output()
2030
if err != nil {
@@ -29,12 +39,15 @@ func loadSpecPaths(t *testing.T) map[string]string {
2939
Paths map[string]map[string]struct {
3040
OperationID string `json:"operationId"`
3141
Tags []string `json:"tags"`
42+
Responses map[string]struct {
43+
Content map[string]json.RawMessage `json:"content"`
44+
} `json:"responses"`
3245
} `json:"paths"`
3346
}
3447
if err := json.Unmarshal(data, &spec); err != nil {
3548
t.Fatalf("parse spec: %v", err)
3649
}
37-
ids := map[string]string{}
50+
var ops []specOpMeta
3851
for path, methods := range spec.Paths {
3952
for verb, op := range methods {
4053
v := strings.ToUpper(verb)
@@ -44,9 +57,25 @@ func loadSpecPaths(t *testing.T) map[string]string {
4457
if op.OperationID == "" || len(op.Tags) == 0 {
4558
continue
4659
}
47-
ids[op.OperationID] = path
60+
streaming := false
61+
if resp, ok := op.Responses["200"]; ok && len(resp.Content) > 0 {
62+
if _, hasJSON := resp.Content["application/json"]; !hasJSON {
63+
streaming = true
64+
}
65+
}
66+
ops = append(ops, specOpMeta{id: op.OperationID, path: path, streaming: streaming})
4867
}
4968
}
69+
return ops
70+
}
71+
72+
// loadSpecPaths returns operationId -> path for every public GET/POST operation.
73+
func loadSpecPaths(t *testing.T) map[string]string {
74+
t.Helper()
75+
ids := map[string]string{}
76+
for _, op := range loadSpecOps(t) {
77+
ids[op.id] = op.path
78+
}
5079
return ids
5180
}
5281

@@ -110,20 +139,38 @@ func TestEveryOperationHasPathCommand(t *testing.T) {
110139
}
111140

112141
// TestGeneratorTargetsFullSpec asserts the generator emitted a command for every
113-
// spec operation (no gaps, no phantom manifest entries from a stale run).
142+
// non-streaming spec operation (no gaps, no phantom manifest entries from a
143+
// stale run). Streaming ops (200 body is not application/json) are deliberately
144+
// excluded from generation — they cannot be modeled by the typed-response
145+
// template and are served by curated commands instead — so the manifest must NOT
146+
// contain them and they are not required to be generated.
114147
func TestGeneratorTargetsFullSpec(t *testing.T) {
115-
specPaths := loadSpecPaths(t)
148+
ops := loadSpecOps(t)
149+
streaming := map[string]bool{}
150+
wantGenerated := map[string]bool{}
151+
for _, op := range ops {
152+
if op.streaming {
153+
streaming[op.id] = true
154+
continue
155+
}
156+
wantGenerated[op.id] = true
157+
}
158+
116159
gen := map[string]bool{}
117160
for _, id := range generatedOpIDs {
118161
gen[id] = true
119-
if _, ok := specPaths[id]; !ok {
162+
if streaming[id] {
163+
t.Errorf("manifest op %q is streaming and must not be generated (curated only)", id)
164+
}
165+
if !wantGenerated[id] && !streaming[id] {
120166
t.Errorf("manifest op %q is not in the current spec (regenerate cligen)", id)
121167
}
122168
}
123-
for id := range specPaths {
169+
for id := range wantGenerated {
124170
if !gen[id] {
125171
t.Errorf("op %q has no generated command (regenerate cligen)", id)
126172
}
127173
}
128-
t.Logf("generator targets %d/%d spec operations", len(gen), len(specPaths))
174+
t.Logf("generator targets %d/%d non-streaming spec operations (%d streaming, curated)",
175+
len(gen), len(wantGenerated), len(streaming))
129176
}

internal/cli/root.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,21 @@ func init() {
100100
rootCmd.AddCommand(newWhoamiCmd())
101101
rootCmd.AddCommand(newUpdateCmd())
102102

103+
// AI agent sessions (list + transcript export).
104+
rootCmd.AddCommand(newSessionCmd())
105+
103106
// Diagnostics entry points (value-add over the raw API).
104107
rootCmd.AddCommand(newMonitQueryCmd())
105108
rootCmd.AddCommand(newMonitAgentCmd())
106109

107110
// Generated commands (full OpenAPI coverage). Registered AFTER curated
108111
// commands so curated leaves win on any name conflict (see genAddLeaf).
109112
registerGenerated(rootCmd)
113+
114+
// session/export is a streaming op excluded from the generated tree; attach
115+
// its path-is-king leaf to the (now-existing) generated `safari` group so the
116+
// operation stays reachable at safari session-export.
117+
attachSafariSessionExport(rootCmd)
110118
}
111119

112120
// Execute runs the root command.

internal/cli/session.go

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
package cli
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"strings"
8+
9+
"github.com/flashcatcloud/go-flashduty"
10+
"github.com/spf13/cobra"
11+
toon "github.com/toon-format/toon-go"
12+
13+
"github.com/flashcatcloud/flashduty-cli/internal/timeutil"
14+
)
15+
16+
func newSessionCmd() *cobra.Command {
17+
cmd := &cobra.Command{
18+
Use: "session",
19+
Short: "Inspect AI agent sessions",
20+
Long: "Inspect AI agent sessions (AI SRE and other Flashduty agents).\n\n" +
21+
"'session list' enumerates sessions visible to the caller; 'session export' streams\n" +
22+
"one session's full event transcript as newline-delimited JSON for offline analysis.",
23+
}
24+
cmd.AddCommand(newSessionListCmd())
25+
cmd.AddCommand(newSessionExportCmd())
26+
return cmd
27+
}
28+
29+
// sessionListFormats are the output shapes 'session list' supports. jsonl (one
30+
// SessionItem JSON object per line) is the default because the rows feed
31+
// line-oriented downstream tooling (the /insight skill streams them through jq);
32+
// json emits the whole SessionListResponse envelope; toon is the compact,
33+
// fewer-tokens encoding.
34+
const (
35+
sessionFormatJSONL = "jsonl"
36+
sessionFormatJSON = "json"
37+
sessionFormatTOON = "toon"
38+
)
39+
40+
func newSessionListCmd() *cobra.Command {
41+
var (
42+
app string
43+
scope string
44+
status string
45+
since string
46+
format string
47+
teamID int64
48+
limit int
49+
page int
50+
)
51+
52+
cmd := &cobra.Command{
53+
Use: "list",
54+
Short: "List agent sessions",
55+
Long: curatedLong(
56+
"List agent sessions visible to the caller, newest first. Reads are scoped to the "+
57+
"person the app_key resolves to within its account.\n\n"+
58+
"--app selects the agent app (default ai-sre). The API has no time-window filter, so "+
59+
"--since (e.g. 30d, 24h, 2026-05-01) is applied CLIENT-SIDE against each session's "+
60+
"updated_at after fetching. --team-id restricts to one team (sets team_ids); --scope "+
61+
"chooses the visibility bucket (all = own + member-teams, the default). Output is "+
62+
"newline-delimited JSON (jsonl) by default so rows pipe straight into jq; use "+
63+
"--format json for the full envelope or --format toon for the compact encoding.",
64+
"Sessions", "List"),
65+
RunE: func(cmd *cobra.Command, args []string) error {
66+
return runCommand(cmd, args, func(ctx *RunContext) error {
67+
format = strings.ToLower(strings.TrimSpace(format))
68+
switch format {
69+
case sessionFormatJSONL, sessionFormatJSON, sessionFormatTOON:
70+
default:
71+
return fmt.Errorf("invalid --format %q (want jsonl, json, or toon)", format)
72+
}
73+
74+
var sinceUnix int64
75+
if since != "" {
76+
ts, err := timeutil.Parse(since)
77+
if err != nil {
78+
return fmt.Errorf("invalid --since: %w", err)
79+
}
80+
sinceUnix = ts
81+
}
82+
83+
req := &flashduty.SessionListRequest{
84+
AppName: app,
85+
Scope: scope,
86+
Status: status,
87+
Orderby: "updated_at",
88+
}
89+
req.Limit = limit
90+
req.Page = page
91+
if teamID > 0 {
92+
req.TeamIDs = []int64{teamID}
93+
}
94+
95+
resp, _, err := ctx.Client.Sessions.List(cmdContext(ctx.Cmd), req)
96+
if err != nil {
97+
return err
98+
}
99+
100+
sessions := resp.Sessions
101+
if sinceUnix > 0 {
102+
sessions = filterSessionsSince(sessions, sinceUnix)
103+
}
104+
105+
return writeSessionList(ctx.Writer, format, sessions, resp.Total)
106+
})
107+
},
108+
}
109+
110+
cmd.Flags().StringVar(&app, "app", "ai-sre", "Agent app to list sessions for")
111+
cmd.Flags().StringVar(&scope, "scope", "", "Visibility scope: all (own + member-teams, default), personal, or team")
112+
registerEnumFlag(cmd, "scope", "all", "personal", "team")
113+
cmd.Flags().StringVar(&status, "status", "", "Archive bucket: active (default), archived, or all")
114+
registerEnumFlag(cmd, "status", "active", "archived", "all")
115+
cmd.Flags().StringVar(&since, "since", "", "Keep only sessions updated within this window (client-side), e.g. 30d, 24h, 2026-05-01")
116+
cmd.Flags().Int64Var(&teamID, "team-id", 0, "Restrict to one team ID")
117+
cmd.Flags().IntVar(&limit, "limit", 200, "Max sessions to fetch (server caps at 100/page)")
118+
cmd.Flags().IntVar(&page, "page", 1, "Page number")
119+
cmd.Flags().StringVar(&format, "format", sessionFormatJSONL, "Output format: jsonl (default), json, or toon")
120+
registerEnumFlag(cmd, "format", sessionFormatJSONL, sessionFormatJSON, sessionFormatTOON)
121+
122+
return cmd
123+
}
124+
125+
// filterSessionsSince keeps sessions whose updated_at is at or after sinceUnix
126+
// (unix seconds). The API exposes no time-window filter, so this is the only
127+
// place a --since window is honored.
128+
func filterSessionsSince(sessions []flashduty.SessionItem, sinceUnix int64) []flashduty.SessionItem {
129+
kept := make([]flashduty.SessionItem, 0, len(sessions))
130+
for _, s := range sessions {
131+
if s.UpdatedAt.Time().Unix() >= sinceUnix {
132+
kept = append(kept, s)
133+
}
134+
}
135+
return kept
136+
}
137+
138+
// writeSessionList renders the session rows in the requested format. jsonl emits
139+
// one SessionItem per line; json emits the whole SessionListResponse envelope;
140+
// toon emits the compact encoding of that envelope.
141+
func writeSessionList(w io.Writer, format string, sessions []flashduty.SessionItem, total int64) error {
142+
switch format {
143+
case sessionFormatJSONL:
144+
enc := json.NewEncoder(w)
145+
for i := range sessions {
146+
if err := enc.Encode(sessions[i]); err != nil {
147+
return fmt.Errorf("failed to encode session: %w", err)
148+
}
149+
}
150+
return nil
151+
default:
152+
envelope := flashduty.SessionListResponse{Sessions: sessions, Total: total}
153+
var (
154+
out []byte
155+
err error
156+
)
157+
if format == sessionFormatTOON {
158+
out, err = toon.Marshal(envelope)
159+
} else {
160+
out, err = json.MarshalIndent(envelope, "", " ")
161+
}
162+
if err != nil {
163+
return fmt.Errorf("failed to marshal sessions: %w", err)
164+
}
165+
_, _ = fmt.Fprintln(w, string(out))
166+
return nil
167+
}
168+
}
169+
170+
// newSessionExportCmd builds the friendly `session export <id>` command.
171+
func newSessionExportCmd() *cobra.Command {
172+
return buildSessionExportCmd("export <session_id>")
173+
}
174+
175+
// newSafariSessionExportCmd builds the path-is-king `safari session-export <id>`
176+
// command. session/export is a streaming op, so it is excluded from the
177+
// generated tree (which cannot model an io.ReadCloser response); this curated
178+
// leaf keeps the operation reachable at its mechanical path-name alongside the
179+
// generated safari session-get / session-list.
180+
func newSafariSessionExportCmd() *cobra.Command {
181+
return buildSessionExportCmd("session-export <session_id>")
182+
}
183+
184+
// buildSessionExportCmd constructs an export command with the given Use line.
185+
// Both the friendly and path-is-king commands share this one implementation so
186+
// the streaming behavior is defined once.
187+
func buildSessionExportCmd(use string) *cobra.Command {
188+
var includeSubagents bool
189+
190+
cmd := &cobra.Command{
191+
Use: use,
192+
Short: "Stream a session's full event transcript as NDJSON",
193+
Long: "Stream one session's full event transcript as newline-delimited JSON (NDJSON) to stdout.\n\n" +
194+
"The first line is always a session_meta envelope; each subsequent line is one event\n" +
195+
"(user_message, llm_call, tool_call, subagent_dispatch, final_answer, agent_text, error).\n" +
196+
"With --include-subagents, each subagent_dispatch line is followed by the child session's\n" +
197+
"own stream. The transcript can be large, so redirect it to a file rather than reading it\n" +
198+
"into a terminal:\n\n" +
199+
" flashduty session export <id> > session.ndjson\n",
200+
Args: requireArgs("session_id"),
201+
RunE: func(cmd *cobra.Command, args []string) error {
202+
return runCommand(cmd, args, func(ctx *RunContext) error {
203+
rc, _, err := ctx.Client.Sessions.Export(cmdContext(ctx.Cmd), &flashduty.SessionExportRequest{
204+
SessionID: ctx.Args[0],
205+
IncludeSubagents: includeSubagents,
206+
})
207+
if err != nil {
208+
return err
209+
}
210+
defer func() { _ = rc.Close() }()
211+
212+
// Stream the NDJSON straight through to the writer without
213+
// buffering the whole transcript: copy line-by-line so a huge
214+
// export never lands in memory or the agent's context.
215+
sc := flashduty.NewExportScanner(rc)
216+
for sc.Scan() {
217+
if _, err := fmt.Fprintln(ctx.Writer, sc.Text()); err != nil {
218+
return err
219+
}
220+
}
221+
return sc.Err()
222+
})
223+
},
224+
}
225+
226+
cmd.Flags().BoolVar(&includeSubagents, "include-subagents", false, "Inline each dispatched subagent's own event stream")
227+
228+
return cmd
229+
}
230+
231+
// attachSafariSessionExport adds the path-is-king `safari session-export` leaf to
232+
// the generated `safari` group. It must run AFTER registerGenerated so the group
233+
// exists; genGroup find-or-creates it and genAddLeaf is a no-op if a same-named
234+
// command is already present.
235+
func attachSafariSessionExport(root *cobra.Command) {
236+
safari := genGroup(root, "safari", "AI SRE API")
237+
genAddLeaf(safari, newSafariSessionExportCmd())
238+
}

0 commit comments

Comments
 (0)