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