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.
@@ -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
+ }