opencode-manager 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROJECT-SUMMARY.md +104 -24
- package/README.md +335 -7
- package/bun.lock +17 -1
- package/manage_opencode_projects.py +71 -66
- package/package.json +6 -3
- package/src/bin/opencode-manager.ts +133 -3
- package/src/cli/backup.ts +324 -0
- package/src/cli/commands/chat.ts +322 -0
- package/src/cli/commands/projects.ts +222 -0
- package/src/cli/commands/sessions.ts +495 -0
- package/src/cli/commands/tokens.ts +168 -0
- package/src/cli/commands/tui.ts +36 -0
- package/src/cli/errors.ts +259 -0
- package/src/cli/formatters/json.ts +184 -0
- package/src/cli/formatters/ndjson.ts +71 -0
- package/src/cli/formatters/table.ts +837 -0
- package/src/cli/index.ts +169 -0
- package/src/cli/output.ts +661 -0
- package/src/cli/resolvers.ts +249 -0
- package/src/lib/clipboard.ts +37 -0
- package/src/lib/opencode-data.ts +380 -1
- package/src/lib/search.ts +170 -0
- package/src/{opencode-tui.tsx → tui/app.tsx} +739 -105
- package/src/tui/args.ts +92 -0
- package/src/tui/index.tsx +46 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sessions CLI subcommands.
|
|
3
|
+
*
|
|
4
|
+
* Provides commands for listing, deleting, renaming, moving, and copying
|
|
5
|
+
* OpenCode sessions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Command, type OptionValues } from "commander"
|
|
9
|
+
import { parseGlobalOptions, type GlobalOptions } from "../index"
|
|
10
|
+
import {
|
|
11
|
+
loadSessionRecords,
|
|
12
|
+
loadProjectRecords,
|
|
13
|
+
deleteSessionMetadata,
|
|
14
|
+
updateSessionTitle,
|
|
15
|
+
moveSession,
|
|
16
|
+
copySession,
|
|
17
|
+
type SessionRecord,
|
|
18
|
+
} from "../../lib/opencode-data"
|
|
19
|
+
import {
|
|
20
|
+
getOutputOptions,
|
|
21
|
+
printSessionsOutput,
|
|
22
|
+
printDryRunOutput,
|
|
23
|
+
createDryRunResult,
|
|
24
|
+
printSuccessOutput,
|
|
25
|
+
} from "../output"
|
|
26
|
+
import { fuzzySearch, type SearchCandidate } from "../../lib/search"
|
|
27
|
+
import { resolveSessionId, resolveProjectId } from "../resolvers"
|
|
28
|
+
import { requireConfirmation, withErrorHandling, FileOperationError, UsageError } from "../errors"
|
|
29
|
+
import { copyToBackupDir, formatBackupResult } from "../backup"
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Collect all options from a command and its ancestors.
|
|
33
|
+
* Commander stores global options on the root program, not on subcommands.
|
|
34
|
+
*/
|
|
35
|
+
function collectOptions(cmd: Command): OptionValues {
|
|
36
|
+
const opts: OptionValues = {}
|
|
37
|
+
let current: Command | null = cmd
|
|
38
|
+
while (current) {
|
|
39
|
+
Object.assign(opts, current.opts())
|
|
40
|
+
current = current.parent
|
|
41
|
+
}
|
|
42
|
+
return opts
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Options specific to the sessions list command.
|
|
47
|
+
*/
|
|
48
|
+
export interface SessionsListOptions {
|
|
49
|
+
/** Filter sessions by project ID */
|
|
50
|
+
project?: string
|
|
51
|
+
/** Search query to filter sessions (fuzzy match) */
|
|
52
|
+
search?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Options specific to the sessions delete command.
|
|
57
|
+
*/
|
|
58
|
+
export interface SessionsDeleteOptions {
|
|
59
|
+
/** Session ID to delete */
|
|
60
|
+
session: string
|
|
61
|
+
/** Skip confirmation prompt */
|
|
62
|
+
yes: boolean
|
|
63
|
+
/** Preview changes without deleting */
|
|
64
|
+
dryRun: boolean
|
|
65
|
+
/** Directory to backup files before deletion */
|
|
66
|
+
backupDir?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Options specific to the sessions rename command.
|
|
71
|
+
*/
|
|
72
|
+
export interface SessionsRenameOptions {
|
|
73
|
+
/** Session ID to rename */
|
|
74
|
+
session: string
|
|
75
|
+
/** New title for the session */
|
|
76
|
+
title: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Options specific to the sessions move command.
|
|
81
|
+
*/
|
|
82
|
+
export interface SessionsMoveOptions {
|
|
83
|
+
/** Session ID to move */
|
|
84
|
+
session: string
|
|
85
|
+
/** Target project ID */
|
|
86
|
+
to: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Options specific to the sessions copy command.
|
|
91
|
+
*/
|
|
92
|
+
export interface SessionsCopyOptions {
|
|
93
|
+
/** Session ID to copy */
|
|
94
|
+
session: string
|
|
95
|
+
/** Target project ID */
|
|
96
|
+
to: string
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Register sessions subcommands on the given parent command.
|
|
101
|
+
*/
|
|
102
|
+
export function registerSessionsCommands(parent: Command): void {
|
|
103
|
+
const sessions = parent
|
|
104
|
+
.command("sessions")
|
|
105
|
+
.description("Manage OpenCode sessions")
|
|
106
|
+
|
|
107
|
+
sessions
|
|
108
|
+
.command("list")
|
|
109
|
+
.description("List sessions")
|
|
110
|
+
.option("-p, --project <projectId>", "Filter by project ID")
|
|
111
|
+
.option("-s, --search <query>", "Search query to filter sessions")
|
|
112
|
+
.action(function (this: Command) {
|
|
113
|
+
const globalOpts = parseGlobalOptions(collectOptions(this))
|
|
114
|
+
const cmdOpts = this.opts()
|
|
115
|
+
const listOpts: SessionsListOptions = {
|
|
116
|
+
project: cmdOpts.project as string | undefined,
|
|
117
|
+
search: cmdOpts.search as string | undefined,
|
|
118
|
+
}
|
|
119
|
+
handleSessionsList(globalOpts, listOpts)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
sessions
|
|
123
|
+
.command("delete")
|
|
124
|
+
.description("Delete a session's metadata file")
|
|
125
|
+
.requiredOption("--session <sessionId>", "Session ID to delete")
|
|
126
|
+
.option("--yes", "Skip confirmation prompt", false)
|
|
127
|
+
.option("--dry-run", "Preview changes without deleting", false)
|
|
128
|
+
.option("--backup-dir <dir>", "Directory to backup files before deletion")
|
|
129
|
+
.action(async function (this: Command) {
|
|
130
|
+
const allOpts = collectOptions(this)
|
|
131
|
+
const globalOpts = parseGlobalOptions(allOpts)
|
|
132
|
+
const cmdOpts = this.opts()
|
|
133
|
+
const deleteOpts: SessionsDeleteOptions = {
|
|
134
|
+
session: String(cmdOpts.session),
|
|
135
|
+
yes: Boolean(allOpts.yes ?? cmdOpts.yes),
|
|
136
|
+
dryRun: Boolean(allOpts.dryRun ?? cmdOpts.dryRun),
|
|
137
|
+
backupDir: (allOpts.backupDir ?? cmdOpts.backupDir) as string | undefined,
|
|
138
|
+
}
|
|
139
|
+
await withErrorHandling(handleSessionsDelete, getOutputOptions(globalOpts).format)(
|
|
140
|
+
globalOpts,
|
|
141
|
+
deleteOpts
|
|
142
|
+
)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
sessions
|
|
146
|
+
.command("rename")
|
|
147
|
+
.description("Rename a session")
|
|
148
|
+
.requiredOption("--session <sessionId>", "Session ID to rename")
|
|
149
|
+
.requiredOption("-t, --title <title>", "New title for the session")
|
|
150
|
+
.action(async function (this: Command) {
|
|
151
|
+
const globalOpts = parseGlobalOptions(collectOptions(this))
|
|
152
|
+
const cmdOpts = this.opts()
|
|
153
|
+
const renameOpts: SessionsRenameOptions = {
|
|
154
|
+
session: String(cmdOpts.session),
|
|
155
|
+
title: String(cmdOpts.title),
|
|
156
|
+
}
|
|
157
|
+
await withErrorHandling(handleSessionsRename, getOutputOptions(globalOpts).format)(
|
|
158
|
+
globalOpts,
|
|
159
|
+
renameOpts
|
|
160
|
+
)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
sessions
|
|
164
|
+
.command("move")
|
|
165
|
+
.description("Move a session to another project")
|
|
166
|
+
.requiredOption("--session <sessionId>", "Session ID to move")
|
|
167
|
+
.requiredOption("--to <projectId>", "Target project ID")
|
|
168
|
+
.action(async function (this: Command) {
|
|
169
|
+
const globalOpts = parseGlobalOptions(collectOptions(this))
|
|
170
|
+
const cmdOpts = this.opts()
|
|
171
|
+
const moveOpts: SessionsMoveOptions = {
|
|
172
|
+
session: String(cmdOpts.session),
|
|
173
|
+
to: String(cmdOpts.to),
|
|
174
|
+
}
|
|
175
|
+
await withErrorHandling(handleSessionsMove, getOutputOptions(globalOpts).format)(
|
|
176
|
+
globalOpts,
|
|
177
|
+
moveOpts
|
|
178
|
+
)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
sessions
|
|
182
|
+
.command("copy")
|
|
183
|
+
.description("Copy a session to another project")
|
|
184
|
+
.requiredOption("--session <sessionId>", "Session ID to copy")
|
|
185
|
+
.requiredOption("--to <projectId>", "Target project ID")
|
|
186
|
+
.action(async function (this: Command) {
|
|
187
|
+
const globalOpts = parseGlobalOptions(collectOptions(this))
|
|
188
|
+
const cmdOpts = this.opts()
|
|
189
|
+
const copyOpts: SessionsCopyOptions = {
|
|
190
|
+
session: String(cmdOpts.session),
|
|
191
|
+
to: String(cmdOpts.to),
|
|
192
|
+
}
|
|
193
|
+
await withErrorHandling(handleSessionsCopy, getOutputOptions(globalOpts).format)(
|
|
194
|
+
globalOpts,
|
|
195
|
+
copyOpts
|
|
196
|
+
)
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Build search text for a session record (matches TUI behavior).
|
|
202
|
+
* Combines title, sessionId, directory, and projectId.
|
|
203
|
+
*/
|
|
204
|
+
function buildSessionSearchText(session: SessionRecord): string {
|
|
205
|
+
return [
|
|
206
|
+
session.title || "",
|
|
207
|
+
session.sessionId,
|
|
208
|
+
session.directory || "",
|
|
209
|
+
session.projectId,
|
|
210
|
+
].join(" ").replace(/\s+/g, " ").trim()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Handle the sessions list command.
|
|
215
|
+
*/
|
|
216
|
+
async function handleSessionsList(
|
|
217
|
+
globalOpts: GlobalOptions,
|
|
218
|
+
listOpts: SessionsListOptions
|
|
219
|
+
): Promise<void> {
|
|
220
|
+
// Load session records from the data layer
|
|
221
|
+
// If a project filter is provided, pass it to the loader
|
|
222
|
+
let sessions = await loadSessionRecords({
|
|
223
|
+
root: globalOpts.root,
|
|
224
|
+
projectId: listOpts.project,
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// Apply fuzzy search if search query is provided
|
|
228
|
+
if (listOpts.search?.trim()) {
|
|
229
|
+
const candidates: SearchCandidate<SessionRecord>[] = sessions.map((s) => ({
|
|
230
|
+
item: s,
|
|
231
|
+
searchText: buildSessionSearchText(s),
|
|
232
|
+
}))
|
|
233
|
+
|
|
234
|
+
const results = fuzzySearch(candidates, listOpts.search, {
|
|
235
|
+
limit: globalOpts.limit,
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
// Sort by score descending, then by sort field descending, then by sessionId
|
|
239
|
+
const sortField = globalOpts.sort // "updated" or "created"
|
|
240
|
+
results.sort((a, b) => {
|
|
241
|
+
if (b.score !== a.score) return b.score - a.score
|
|
242
|
+
const aTime = sortField === "created"
|
|
243
|
+
? (a.item.createdAt?.getTime() ?? 0)
|
|
244
|
+
: ((a.item.updatedAt ?? a.item.createdAt)?.getTime() ?? 0)
|
|
245
|
+
const bTime = sortField === "created"
|
|
246
|
+
? (b.item.createdAt?.getTime() ?? 0)
|
|
247
|
+
: ((b.item.updatedAt ?? b.item.createdAt)?.getTime() ?? 0)
|
|
248
|
+
if (bTime !== aTime) return bTime - aTime
|
|
249
|
+
return a.item.sessionId.localeCompare(b.item.sessionId)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
sessions = results.map((r) => r.item)
|
|
253
|
+
} else {
|
|
254
|
+
// Sort by the specified sort field (descending), then by sessionId
|
|
255
|
+
const sortField = globalOpts.sort // "updated" or "created"
|
|
256
|
+
sessions.sort((a, b) => {
|
|
257
|
+
const aTime = sortField === "created"
|
|
258
|
+
? (a.createdAt?.getTime() ?? 0)
|
|
259
|
+
: ((a.updatedAt ?? a.createdAt)?.getTime() ?? 0)
|
|
260
|
+
const bTime = sortField === "created"
|
|
261
|
+
? (b.createdAt?.getTime() ?? 0)
|
|
262
|
+
: ((b.updatedAt ?? b.createdAt)?.getTime() ?? 0)
|
|
263
|
+
if (bTime !== aTime) return bTime - aTime
|
|
264
|
+
return a.sessionId.localeCompare(b.sessionId)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
// Apply limit cap (default 200) when no search
|
|
268
|
+
sessions = sessions.slice(0, globalOpts.limit)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Output the sessions using the appropriate formatter
|
|
272
|
+
const outputOpts = getOutputOptions(globalOpts)
|
|
273
|
+
printSessionsOutput(sessions, outputOpts)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Handle the sessions delete command.
|
|
278
|
+
*
|
|
279
|
+
* This command deletes a session's metadata file from the OpenCode storage.
|
|
280
|
+
* It does NOT delete associated chat message files (yet).
|
|
281
|
+
*
|
|
282
|
+
* Exit codes:
|
|
283
|
+
* - 0: Success (or dry-run completed)
|
|
284
|
+
* - 2: Usage error (--yes not provided for destructive operation)
|
|
285
|
+
* - 3: Session not found
|
|
286
|
+
* - 4: File operation failure (backup or delete failed)
|
|
287
|
+
*/
|
|
288
|
+
async function handleSessionsDelete(
|
|
289
|
+
globalOpts: GlobalOptions,
|
|
290
|
+
deleteOpts: SessionsDeleteOptions
|
|
291
|
+
): Promise<void> {
|
|
292
|
+
const outputOpts = getOutputOptions(globalOpts)
|
|
293
|
+
|
|
294
|
+
// Resolve session ID to a session record
|
|
295
|
+
const { session } = await resolveSessionId(deleteOpts.session, {
|
|
296
|
+
root: globalOpts.root,
|
|
297
|
+
allowPrefix: true,
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
const pathsToDelete = [session.filePath]
|
|
301
|
+
|
|
302
|
+
// Handle dry-run mode
|
|
303
|
+
if (deleteOpts.dryRun) {
|
|
304
|
+
const dryRunResult = createDryRunResult(pathsToDelete, "delete", "session")
|
|
305
|
+
printDryRunOutput(dryRunResult, outputOpts.format)
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Require confirmation for destructive operation
|
|
310
|
+
requireConfirmation(deleteOpts.yes, "Session deletion")
|
|
311
|
+
|
|
312
|
+
// Backup files if requested
|
|
313
|
+
if (deleteOpts.backupDir) {
|
|
314
|
+
const backupResult = await copyToBackupDir(pathsToDelete, {
|
|
315
|
+
backupDir: deleteOpts.backupDir,
|
|
316
|
+
prefix: "session",
|
|
317
|
+
preserveStructure: true,
|
|
318
|
+
structureRoot: globalOpts.root,
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
if (backupResult.failed.length > 0) {
|
|
322
|
+
throw new FileOperationError(
|
|
323
|
+
`Backup failed for ${backupResult.failed.length} file(s): ${backupResult.failed
|
|
324
|
+
.map((f) => f.path)
|
|
325
|
+
.join(", ")}`,
|
|
326
|
+
"backup"
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!globalOpts.quiet) {
|
|
331
|
+
console.log(formatBackupResult(backupResult))
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Perform the deletion
|
|
336
|
+
const deleteResult = await deleteSessionMetadata([session], { dryRun: false })
|
|
337
|
+
|
|
338
|
+
if (deleteResult.failed.length > 0) {
|
|
339
|
+
throw new FileOperationError(
|
|
340
|
+
`Failed to delete ${deleteResult.failed.length} file(s): ${deleteResult.failed
|
|
341
|
+
.map((f) => `${f.path}: ${f.error}`)
|
|
342
|
+
.join(", ")}`,
|
|
343
|
+
"delete"
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Output success
|
|
348
|
+
printSuccessOutput(
|
|
349
|
+
`Deleted session: ${session.sessionId}`,
|
|
350
|
+
{ sessionId: session.sessionId, deleted: deleteResult.removed },
|
|
351
|
+
outputOpts.format
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Handle the sessions rename command.
|
|
357
|
+
*
|
|
358
|
+
* This command updates a session's title in its metadata file.
|
|
359
|
+
*
|
|
360
|
+
* Exit codes:
|
|
361
|
+
* - 0: Success
|
|
362
|
+
* - 2: Usage error (empty title provided)
|
|
363
|
+
* - 3: Session not found
|
|
364
|
+
*/
|
|
365
|
+
async function handleSessionsRename(
|
|
366
|
+
globalOpts: GlobalOptions,
|
|
367
|
+
renameOpts: SessionsRenameOptions
|
|
368
|
+
): Promise<void> {
|
|
369
|
+
const outputOpts = getOutputOptions(globalOpts)
|
|
370
|
+
|
|
371
|
+
// Validate non-empty title
|
|
372
|
+
const newTitle = renameOpts.title.trim()
|
|
373
|
+
if (!newTitle) {
|
|
374
|
+
throw new UsageError("Title cannot be empty")
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Resolve session ID to a session record
|
|
378
|
+
const { session } = await resolveSessionId(renameOpts.session, {
|
|
379
|
+
root: globalOpts.root,
|
|
380
|
+
allowPrefix: true,
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
// Update the session title
|
|
384
|
+
await updateSessionTitle(session.filePath, newTitle)
|
|
385
|
+
|
|
386
|
+
// Output success
|
|
387
|
+
printSuccessOutput(
|
|
388
|
+
`Renamed session: ${session.sessionId}`,
|
|
389
|
+
{ sessionId: session.sessionId, title: newTitle },
|
|
390
|
+
outputOpts.format
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Handle the sessions move command.
|
|
396
|
+
*
|
|
397
|
+
* This command moves a session to a different project.
|
|
398
|
+
* The session file is moved to the target project's session directory.
|
|
399
|
+
*
|
|
400
|
+
* Exit codes:
|
|
401
|
+
* - 0: Success
|
|
402
|
+
* - 3: Session or target project not found
|
|
403
|
+
* - 4: File operation failure
|
|
404
|
+
*/
|
|
405
|
+
async function handleSessionsMove(
|
|
406
|
+
globalOpts: GlobalOptions,
|
|
407
|
+
moveOpts: SessionsMoveOptions
|
|
408
|
+
): Promise<void> {
|
|
409
|
+
const outputOpts = getOutputOptions(globalOpts)
|
|
410
|
+
|
|
411
|
+
// Resolve session ID to a session record
|
|
412
|
+
const { session } = await resolveSessionId(moveOpts.session, {
|
|
413
|
+
root: globalOpts.root,
|
|
414
|
+
allowPrefix: true,
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
// Validate target project exists
|
|
418
|
+
// Use prefix matching for convenience, but require exactly one match
|
|
419
|
+
await resolveProjectId(moveOpts.to, {
|
|
420
|
+
root: globalOpts.root,
|
|
421
|
+
allowPrefix: true,
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
// Check if session is already in the target project
|
|
425
|
+
if (session.projectId === moveOpts.to) {
|
|
426
|
+
printSuccessOutput(
|
|
427
|
+
`Session ${session.sessionId} is already in project ${moveOpts.to}`,
|
|
428
|
+
{ sessionId: session.sessionId, projectId: moveOpts.to, moved: false },
|
|
429
|
+
outputOpts.format
|
|
430
|
+
)
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Move the session
|
|
435
|
+
const newRecord = await moveSession(session, moveOpts.to, globalOpts.root)
|
|
436
|
+
|
|
437
|
+
// Output success
|
|
438
|
+
printSuccessOutput(
|
|
439
|
+
`Moved session ${session.sessionId} to project ${moveOpts.to}`,
|
|
440
|
+
{
|
|
441
|
+
sessionId: session.sessionId,
|
|
442
|
+
fromProject: session.projectId,
|
|
443
|
+
toProject: moveOpts.to,
|
|
444
|
+
newPath: newRecord.filePath,
|
|
445
|
+
},
|
|
446
|
+
outputOpts.format
|
|
447
|
+
)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Handle the sessions copy command.
|
|
452
|
+
*
|
|
453
|
+
* This command copies a session to a different project.
|
|
454
|
+
* A new session file is created in the target project with a new session ID.
|
|
455
|
+
*
|
|
456
|
+
* Exit codes:
|
|
457
|
+
* - 0: Success
|
|
458
|
+
* - 3: Session or target project not found
|
|
459
|
+
* - 4: File operation failure
|
|
460
|
+
*/
|
|
461
|
+
async function handleSessionsCopy(
|
|
462
|
+
globalOpts: GlobalOptions,
|
|
463
|
+
copyOpts: SessionsCopyOptions
|
|
464
|
+
): Promise<void> {
|
|
465
|
+
const outputOpts = getOutputOptions(globalOpts)
|
|
466
|
+
|
|
467
|
+
// Resolve session ID to a session record
|
|
468
|
+
const { session } = await resolveSessionId(copyOpts.session, {
|
|
469
|
+
root: globalOpts.root,
|
|
470
|
+
allowPrefix: true,
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
// Validate target project exists
|
|
474
|
+
// Use prefix matching for convenience, but require exactly one match
|
|
475
|
+
const { project: targetProject } = await resolveProjectId(copyOpts.to, {
|
|
476
|
+
root: globalOpts.root,
|
|
477
|
+
allowPrefix: true,
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
// Copy the session
|
|
481
|
+
const newRecord = await copySession(session, targetProject.projectId, globalOpts.root)
|
|
482
|
+
|
|
483
|
+
// Output success
|
|
484
|
+
printSuccessOutput(
|
|
485
|
+
`Copied session ${session.sessionId} to project ${targetProject.projectId}`,
|
|
486
|
+
{
|
|
487
|
+
originalSessionId: session.sessionId,
|
|
488
|
+
newSessionId: newRecord.sessionId,
|
|
489
|
+
fromProject: session.projectId,
|
|
490
|
+
toProject: targetProject.projectId,
|
|
491
|
+
newPath: newRecord.filePath,
|
|
492
|
+
},
|
|
493
|
+
outputOpts.format
|
|
494
|
+
)
|
|
495
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tokens CLI subcommands.
|
|
3
|
+
*
|
|
4
|
+
* Provides commands for viewing token usage statistics at session,
|
|
5
|
+
* project, and global levels.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Command, type OptionValues } from "commander"
|
|
9
|
+
import { parseGlobalOptions, type GlobalOptions } from "../index"
|
|
10
|
+
import {
|
|
11
|
+
computeGlobalTokenSummary,
|
|
12
|
+
computeProjectTokenSummary,
|
|
13
|
+
computeSessionTokenSummary,
|
|
14
|
+
loadProjectRecords,
|
|
15
|
+
loadSessionRecords,
|
|
16
|
+
} from "../../lib/opencode-data"
|
|
17
|
+
import { getOutputOptions, printAggregateTokensOutput, printTokensOutput } from "../output"
|
|
18
|
+
import { handleError } from "../errors"
|
|
19
|
+
import { findProjectById, findSessionById } from "../resolvers"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Collect all options from a command and its ancestors.
|
|
23
|
+
* Commander stores global options on the root program, not on subcommands.
|
|
24
|
+
*/
|
|
25
|
+
function collectOptions(cmd: Command): OptionValues {
|
|
26
|
+
const opts: OptionValues = {}
|
|
27
|
+
let current: Command | null = cmd
|
|
28
|
+
while (current) {
|
|
29
|
+
Object.assign(opts, current.opts())
|
|
30
|
+
current = current.parent
|
|
31
|
+
}
|
|
32
|
+
return opts
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Options specific to the tokens session command.
|
|
37
|
+
*/
|
|
38
|
+
export interface TokensSessionOptions {
|
|
39
|
+
/** Session ID to show token usage for */
|
|
40
|
+
session: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Options specific to the tokens project command.
|
|
45
|
+
*/
|
|
46
|
+
export interface TokensProjectOptions {
|
|
47
|
+
/** Project ID to show token usage for */
|
|
48
|
+
project: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Register tokens subcommands on the given parent command.
|
|
53
|
+
*/
|
|
54
|
+
export function registerTokensCommands(parent: Command): void {
|
|
55
|
+
const tokens = parent
|
|
56
|
+
.command("tokens")
|
|
57
|
+
.description("View token usage statistics")
|
|
58
|
+
|
|
59
|
+
tokens
|
|
60
|
+
.command("session")
|
|
61
|
+
.description("Show token usage for a session")
|
|
62
|
+
.requiredOption("--session <sessionId>", "Session ID to show token usage for")
|
|
63
|
+
.action(async function (this: Command) {
|
|
64
|
+
const globalOpts = parseGlobalOptions(collectOptions(this))
|
|
65
|
+
const cmdOpts = this.opts()
|
|
66
|
+
const sessionOpts: TokensSessionOptions = {
|
|
67
|
+
session: String(cmdOpts.session),
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
await handleTokensSession(globalOpts, sessionOpts)
|
|
71
|
+
} catch (error) {
|
|
72
|
+
handleError(error, globalOpts.format)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
tokens
|
|
77
|
+
.command("project")
|
|
78
|
+
.description("Show token usage for a project")
|
|
79
|
+
.requiredOption("--project <projectId>", "Project ID to show token usage for")
|
|
80
|
+
.action(async function (this: Command) {
|
|
81
|
+
const globalOpts = parseGlobalOptions(collectOptions(this))
|
|
82
|
+
const cmdOpts = this.opts()
|
|
83
|
+
const projectOpts: TokensProjectOptions = {
|
|
84
|
+
project: String(cmdOpts.project),
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
await handleTokensProject(globalOpts, projectOpts)
|
|
88
|
+
} catch (error) {
|
|
89
|
+
handleError(error, globalOpts.format)
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
tokens
|
|
94
|
+
.command("global")
|
|
95
|
+
.description("Show global token usage")
|
|
96
|
+
.action(async function (this: Command) {
|
|
97
|
+
const globalOpts = parseGlobalOptions(collectOptions(this))
|
|
98
|
+
try {
|
|
99
|
+
await handleTokensGlobal(globalOpts)
|
|
100
|
+
} catch (error) {
|
|
101
|
+
handleError(error, globalOpts.format)
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Handle the tokens session command.
|
|
108
|
+
*/
|
|
109
|
+
async function handleTokensSession(
|
|
110
|
+
globalOpts: GlobalOptions,
|
|
111
|
+
sessionOpts: TokensSessionOptions
|
|
112
|
+
): Promise<void> {
|
|
113
|
+
// Load all sessions to find the one we want
|
|
114
|
+
const sessions = await loadSessionRecords({ root: globalOpts.root })
|
|
115
|
+
|
|
116
|
+
// Find the session by ID
|
|
117
|
+
const session = findSessionById(sessions, sessionOpts.session)
|
|
118
|
+
|
|
119
|
+
// Compute token summary for the session
|
|
120
|
+
const summary = await computeSessionTokenSummary(session, globalOpts.root)
|
|
121
|
+
|
|
122
|
+
// Output the result
|
|
123
|
+
const outputOpts = getOutputOptions(globalOpts)
|
|
124
|
+
printTokensOutput(summary, outputOpts.format)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Handle the tokens project command.
|
|
129
|
+
*/
|
|
130
|
+
async function handleTokensProject(
|
|
131
|
+
globalOpts: GlobalOptions,
|
|
132
|
+
projectOpts: TokensProjectOptions
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
// Load all projects to validate the project exists
|
|
135
|
+
const projects = await loadProjectRecords({ root: globalOpts.root })
|
|
136
|
+
|
|
137
|
+
// Find the project by ID (throws if not found)
|
|
138
|
+
findProjectById(projects, projectOpts.project)
|
|
139
|
+
|
|
140
|
+
// Load all sessions to compute token summary
|
|
141
|
+
const sessions = await loadSessionRecords({ root: globalOpts.root })
|
|
142
|
+
|
|
143
|
+
// Compute token summary for the project
|
|
144
|
+
const summary = await computeProjectTokenSummary(
|
|
145
|
+
projectOpts.project,
|
|
146
|
+
sessions,
|
|
147
|
+
globalOpts.root
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
// Output the result
|
|
151
|
+
const outputOpts = getOutputOptions(globalOpts)
|
|
152
|
+
printAggregateTokensOutput(summary, outputOpts.format, `Project: ${projectOpts.project}`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Handle the tokens global command.
|
|
157
|
+
*/
|
|
158
|
+
async function handleTokensGlobal(globalOpts: GlobalOptions): Promise<void> {
|
|
159
|
+
// Load all sessions to compute global token summary
|
|
160
|
+
const sessions = await loadSessionRecords({ root: globalOpts.root })
|
|
161
|
+
|
|
162
|
+
// Compute token summary across all sessions
|
|
163
|
+
const summary = await computeGlobalTokenSummary(sessions, globalOpts.root)
|
|
164
|
+
|
|
165
|
+
// Output the result
|
|
166
|
+
const outputOpts = getOutputOptions(globalOpts)
|
|
167
|
+
printAggregateTokensOutput(summary, outputOpts.format, "Global")
|
|
168
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI CLI subcommand.
|
|
3
|
+
*
|
|
4
|
+
* Provides a command to explicitly launch the Terminal UI from the CLI.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Command, type OptionValues } from "commander"
|
|
8
|
+
import { parseGlobalOptions } from "../index"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Collect all options from a command and its ancestors.
|
|
12
|
+
* Commander stores global options on the root program, not on subcommands.
|
|
13
|
+
*/
|
|
14
|
+
function collectOptions(cmd: Command): OptionValues {
|
|
15
|
+
const opts: OptionValues = {}
|
|
16
|
+
let current: Command | null = cmd
|
|
17
|
+
while (current) {
|
|
18
|
+
Object.assign(opts, current.opts())
|
|
19
|
+
current = current.parent
|
|
20
|
+
}
|
|
21
|
+
return opts
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Register the TUI subcommand on the given parent command.
|
|
26
|
+
*/
|
|
27
|
+
export function registerTUICommand(parent: Command): void {
|
|
28
|
+
parent
|
|
29
|
+
.command("tui")
|
|
30
|
+
.description("Launch the Terminal UI for interactive project management")
|
|
31
|
+
.action(async function (this: Command) {
|
|
32
|
+
const globalOpts = parseGlobalOptions(collectOptions(this))
|
|
33
|
+
const { launchTUI } = await import("../../tui/index")
|
|
34
|
+
await launchTUI({ root: globalOpts.root })
|
|
35
|
+
})
|
|
36
|
+
}
|