Repository navigation
Expand file tree
/
Copy pathcli_recordings_export.go
More file actions
273 lines (257 loc) · 10.3 KB
/
Copy pathcli_recordings_export.go
File metadata and controls
273 lines (257 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"github.com/jmwri/flockdeck/internal/agent"
"github.com/jmwri/flockdeck/internal/appwindow"
"github.com/jmwri/flockdeck/internal/record"
"github.com/jmwri/flockdeck/internal/session/transcript"
"github.com/jmwri/flockdeck/internal/store"
)
// exportEnv is what `flockdeck recordings export` reads, so that a test can
// hand it a state directory, a catalog and saved layouts of its own.
type exportEnv struct {
stateDir func() (string, error)
// catalog is the agents Flockdeck knows, and the id of the default one.
catalog func() ([]agent.Spec, string)
// pane finds a pane in the saved layouts by its id.
pane func(id string) (savedPane, bool)
// reveal shows a file in the desktop's file manager.
reveal func(path string) error
}
// savedPane is a pane as the saved layouts hold it.
type savedPane struct {
store.Pane
// Root is the project the layout belongs to.
Root string
}
func realExportEnv() exportEnv {
return exportEnv{
stateDir: store.Dir,
catalog: func() ([]agent.Spec, string) {
specs, def, _ := agentCatalog()
return specs, def
},
pane: findSavedPane,
reveal: appwindow.RevealFile,
}
}
// findSavedPane looks for a pane in the saved layout of every project Flockdeck
// knows. It reads them and changes nothing.
func findSavedPane(id string) (savedPane, bool) {
projects, err := store.Recents()
if err != nil {
return savedPane{}, false
}
for _, p := range projects {
st, err := store.Peek(p.Root)
if err != nil || st == nil {
continue
}
for _, tab := range st.Tabs {
if sp, ok := paneIn(tab.Root, id); ok {
return savedPane{Pane: sp, Root: p.Root}, true
}
}
}
return savedPane{}, false
}
func paneIn(n *store.Node, id string) (store.Pane, bool) {
if n == nil {
return store.Pane{}, false
}
if n.Pane != nil && n.Pane.ID == id {
return *n.Pane, true
}
for _, c := range n.Children {
if p, ok := paneIn(c, id); ok {
return p, true
}
}
return store.Pane{}, false
}
// exportFlags are the flags of `flockdeck recordings export`.
type exportFlags struct {
path string
reveal bool
}
func exportFlagSet(f *exportFlags) *flag.FlagSet {
fs := flag.NewFlagSet("recordings export", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
fs.StringVar(&f.path, "o", "", "write the transcript to this `file`, which must not exist and must not be inside the project or a git repository; by default it goes in the project's exports folder under the recordings folder")
fs.BoolVar(&f.reveal, "reveal", false, "show the exported file in the file manager, selected, when it has been written")
return fs
}
// exportRecording implements `flockdeck recordings export`: it writes the
// conversation an agent has stored as a transcript in the recording format. It
// works with no instance running, and for a pane that was never recorded.
func exportRecording(args []string, out io.Writer, env exportEnv) error {
var f exportFlags
fs := exportFlagSet(&f)
fs.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: flockdeck recordings export [-o file] [-reveal] <pane-id | conversation-id>\n\n")
fmt.Fprintf(os.Stderr, "Writes the whole conversation an agent has stored as a transcript, in the format\n")
fmt.Fprintf(os.Stderr, "recordings use, whether or not the pane was ever recorded. A transcript is made\n")
fmt.Fprintf(os.Stderr, "from the agent's own stored conversation, so a recording of it and an export of\n")
fmt.Fprintf(os.Stderr, "it are the same lines. Claude Code is the agent whose conversations Flockdeck can\n")
fmt.Fprintf(os.Stderr, "read; for any other, nothing is exported and nothing is written.\n\n")
fmt.Fprintf(os.Stderr, "The id is a pane's, as the saved layouts hold it, or a Claude Code\n")
fmt.Fprintf(os.Stderr, "conversation's (its file's name under ~/.claude/projects).\n\n")
fmt.Fprintf(os.Stderr, "The transcript can contain secrets: common ones are removed and very long\n")
fmt.Fprintf(os.Stderr, "output is clipped, but that is best effort. It is written 0600 on Linux and\n")
fmt.Fprintf(os.Stderr, "macOS (on Windows, with your profile's permissions), never into the project\n")
fmt.Fprintf(os.Stderr, "or a git repository. A transcript stops at %d MiB, and an entry of the stored\n", record.MaxFileBytes>>20)
fmt.Fprintf(os.Stderr, "conversation that cannot be read, such as one over 8 MiB, is left out; the\n")
fmt.Fprintf(os.Stderr, "command says how many were.\n\n")
fmt.Fprintf(os.Stderr, "If an earlier export of the conversation has events this one would lack (the\n")
fmt.Fprintf(os.Stderr, "stored conversation was cut or changed), it is kept, nothing new is written,\n")
fmt.Fprintf(os.Stderr, "and the command exits with an error; delete that file to have a fresh one.\n\n")
fmt.Fprintf(os.Stderr, "The format is documented in docs/recording-format.md in the Flockdeck\n")
fmt.Fprintf(os.Stderr, "repository.\n\nFlags:\n")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return errReported
}
rest := fs.Args()
if len(rest) > 1 {
// Flags after the id: `export <id> -o file`.
if err := fs.Parse(rest[1:]); err != nil {
return errReported
}
rest = append(rest[:1], fs.Args()...)
}
if len(rest) != 1 {
fs.Usage()
return errors.New("export takes one pane or conversation id")
}
id := rest[0]
meta, spec, ex, layoutRoot, err := resolveExport(id, env)
if err != nil {
return err
}
path := f.path
if path != "" {
if path, err = filepath.Abs(path); err != nil {
return err
}
for _, project := range []string{meta.ProjectRoot, layoutRoot} {
if err := record.CheckExportPath(path, project); err != nil {
return err
}
}
}
res, err := record.Export(env.stateDir, meta, ex.Follow(spec, meta.Conversation), record.ExportOptions{Path: path})
switch {
case errors.Is(err, transcript.ErrNoTranscript):
return fmt.Errorf("%s has no stored conversation for %s on this machine, so nothing was exported", agentName(spec), meta.Conversation)
case errors.Is(err, record.ErrNothingToExport):
return fmt.Errorf("the stored conversation %s has nothing in it to export", meta.Conversation)
case err != nil:
return err
}
if res.Kept {
return fmt.Errorf("not exported again: the earlier export at %s has events this one would lack (the stored conversation was cut or changed since), so it was kept as it was and is not up to date; delete it to have a fresh one", res.Path)
}
fmt.Fprintf(out, "exported %d lines (%d prompts, %d messages, %d tool calls) to %s\n", res.Lines, res.Prompts, res.Messages, res.ToolCalls, res.Path)
if res.Replaced {
fmt.Fprintln(out, "replaced the earlier export of this conversation, which had nothing this one lacks")
}
if res.Full {
fmt.Fprintf(out, "the conversation is longer than a transcript can be, so it was cut at its %d MiB cap\n", record.MaxFileBytes>>20)
}
if res.Skipped > 0 {
fmt.Fprintf(out, "%d entries of the stored conversation could not be read (not JSON or over 8 MiB, for example) and are left out\n", res.Skipped)
}
fmt.Fprintln(out, "it may contain secrets: common ones are removed, but that is best effort")
if f.reveal {
// The file just written, which is where this process put it. One in the
// recordings folder is checked to be a regular file in it, as the
// window's reveal does; one at a path the user chose is the user's own.
show := res.Path
if path == "" {
if show, err = record.InRecordings(env.stateDir, res.Path); err != nil {
return fmt.Errorf("exported, but not showing it: %w", err)
}
}
if err := env.reveal(show); err != nil {
return fmt.Errorf("exported, but could not show it in the file manager: %w", err)
}
}
return nil
}
// resolveExport works out whose conversation id names, and who stores it. The
// last value is the project directory the saved layout gives a pane, which is
// kept out of by a path of the user's own as the conversation's is, and is not
// part of the transcript.
func resolveExport(id string, env exportEnv) (record.Meta, agent.Spec, transcript.Exporter, string, error) {
specs, defaultID := env.catalog()
find := func(agentID string) (agent.Spec, bool) {
if agentID == "" {
agentID = defaultID
}
for _, s := range specs {
if s.ID == agentID {
return s, true
}
}
return agent.Spec{}, false
}
if sp, ok := env.pane(id); ok {
spec, ok := find(sp.Agent)
if !ok {
return record.Meta{}, agent.Spec{}, nil, "", fmt.Errorf("pane %s runs %q, which is not an agent in this machine's catalog, so its conversation cannot be read", id, sp.Agent)
}
ex, ok := transcript.ExporterFor(spec)
if !ok {
return record.Meta{}, agent.Spec{}, nil, "", fmt.Errorf("%s stores no conversation Flockdeck can read, so there is nothing to export", agentName(spec))
}
conv := sp.Conversation
if conv == "" {
conv = sp.ID
}
// Whose lines they are is the conversation's, as it is for a recording
// and for the window's export, and not the saved layout's, which can
// have a name or a model the pane no longer has.
root := sp.Root
if sp.Pane.Root != "" {
root = sp.Pane.Root
}
return record.MetaFor(spec, ex, conv), spec, ex, root, nil
}
// Not a pane: a conversation, which is found by looking where each agent
// keeps them.
seen := map[string]bool{}
for _, spec := range specs {
ex, ok := transcript.ExporterFor(spec)
if !ok {
continue
}
// Two entries that keep their conversations in one place are one place.
home := transcript.ClaudeHomeFor(spec)
if seen[home] {
continue
}
seen[home] = true
if ex.Cwd(spec, id) == "" && !stored(ex, spec, id) {
continue
}
return record.MetaFor(spec, ex, id), spec, ex, "", nil
}
return record.Meta{}, agent.Spec{}, nil, "", fmt.Errorf("no pane or stored conversation %q was found. A pane's id is in the saved layout and a conversation's is its file's name under ~/.claude/projects; "+
"agents other than Claude Code store nothing Flockdeck can read, so nothing can be exported for them", id)
}
// stored reports whether the agent has a conversation stored under id that says
// anything, which it learns from the first event and no more.
func stored(ex transcript.Exporter, spec agent.Spec, id string) bool {
_, err := ex.Follow(spec, id).Poll(func(transcript.ExportEvent) error { return errFound })
return err == nil || errors.Is(err, errFound)
}
var errFound = errors.New("found")