opencode-manager 0.3.1 → 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.
@@ -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
+ }