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,322 @@
1
+ /**
2
+ * Chat CLI subcommands.
3
+ *
4
+ * Provides commands for listing, showing, and searching chat messages
5
+ * across OpenCode sessions.
6
+ */
7
+
8
+ import { Command, type OptionValues } from "commander"
9
+ import { parseGlobalOptions, type GlobalOptions } from "../index"
10
+ import {
11
+ loadSessionChatIndex,
12
+ loadSessionRecords,
13
+ hydrateChatMessageParts,
14
+ searchSessionsChat,
15
+ type ChatMessage,
16
+ } from "../../lib/opencode-data"
17
+ import { copyToClipboard } from "../../lib/clipboard"
18
+ import { resolveSessionId } from "../resolvers"
19
+ import { withErrorHandling, UsageError, NotFoundError } from "../errors"
20
+ import {
21
+ getOutputOptions,
22
+ printChatOutput,
23
+ printChatMessageOutput,
24
+ printChatSearchOutput,
25
+ type IndexedChatMessage,
26
+ type IndexedChatSearchResult,
27
+ type OutputFormat,
28
+ } from "../output"
29
+
30
+ /**
31
+ * Collect all options from a command and its ancestors.
32
+ * Commander stores global options on the root program, not on subcommands.
33
+ */
34
+ function collectOptions(cmd: Command): OptionValues {
35
+ const opts: OptionValues = {}
36
+ let current: Command | null = cmd
37
+ while (current) {
38
+ Object.assign(opts, current.opts())
39
+ current = current.parent
40
+ }
41
+ return opts
42
+ }
43
+
44
+ /**
45
+ * Options specific to the chat list command.
46
+ */
47
+ export interface ChatListOptions {
48
+ /** Session ID to list messages from */
49
+ session: string
50
+ /** Include message parts in output */
51
+ includeParts: boolean
52
+ }
53
+
54
+ /**
55
+ * Options specific to the chat show command.
56
+ */
57
+ export interface ChatShowOptions {
58
+ /** Session ID containing the message */
59
+ session: string
60
+ /** Message ID to show */
61
+ message?: string
62
+ /** Message index (1-based) to show */
63
+ index?: number
64
+ /** Copy message content to clipboard */
65
+ clipboard?: boolean
66
+ }
67
+
68
+ /**
69
+ * Options specific to the chat search command.
70
+ */
71
+ export interface ChatSearchOptions {
72
+ /** Search query */
73
+ query: string
74
+ /** Filter by project ID */
75
+ project?: string
76
+ }
77
+
78
+ /**
79
+ * Register chat subcommands on the given parent command.
80
+ */
81
+ export function registerChatCommands(parent: Command): void {
82
+ const chat = parent
83
+ .command("chat")
84
+ .description("View and search chat messages")
85
+
86
+ chat
87
+ .command("list")
88
+ .description("List messages in a session")
89
+ .requiredOption("--session <sessionId>", "Session ID to list messages from")
90
+ .option("--include-parts", "Include message parts in output", false)
91
+ .action(async function (this: Command) {
92
+ const globalOpts = parseGlobalOptions(collectOptions(this))
93
+ const cmdOpts = this.opts()
94
+ const listOpts: ChatListOptions = {
95
+ session: String(cmdOpts.session),
96
+ includeParts: Boolean(cmdOpts.includeParts),
97
+ }
98
+ await withErrorHandling(handleChatList, globalOpts.format)(
99
+ globalOpts,
100
+ listOpts
101
+ )
102
+ })
103
+
104
+ chat
105
+ .command("show")
106
+ .description("Show a specific message")
107
+ .requiredOption("--session <sessionId>", "Session ID containing the message")
108
+ .option("-m, --message <messageId>", "Message ID to show")
109
+ .option("-i, --index <number>", "Message index (1-based) to show")
110
+ .action(async function (this: Command) {
111
+ const allOpts = collectOptions(this)
112
+ const globalOpts = parseGlobalOptions(allOpts)
113
+ const cmdOpts = this.opts()
114
+ const showOpts: ChatShowOptions = {
115
+ session: String(cmdOpts.session),
116
+ message: cmdOpts.message as string | undefined,
117
+ index: cmdOpts.index ? parseInt(String(cmdOpts.index), 10) : undefined,
118
+ // Use global --clipboard option since it's defined at root level
119
+ clipboard: globalOpts.clipboard,
120
+ }
121
+ await withErrorHandling(handleChatShow, globalOpts.format)(
122
+ globalOpts,
123
+ showOpts
124
+ )
125
+ })
126
+
127
+ chat
128
+ .command("search")
129
+ .description("Search chat content across sessions")
130
+ .requiredOption("-q, --query <query>", "Search query")
131
+ .option("-p, --project <projectId>", "Filter by project ID")
132
+ .action(async function (this: Command) {
133
+ const globalOpts = parseGlobalOptions(collectOptions(this))
134
+ const cmdOpts = this.opts()
135
+ const searchOpts: ChatSearchOptions = {
136
+ query: String(cmdOpts.query),
137
+ project: cmdOpts.project as string | undefined,
138
+ }
139
+ await withErrorHandling(handleChatSearch, globalOpts.format)(
140
+ globalOpts,
141
+ searchOpts
142
+ )
143
+ })
144
+ }
145
+
146
+ /**
147
+ * Handle the chat list command.
148
+ *
149
+ * Lists messages in a session, ordered by createdAt (ascending).
150
+ * Optionally includes message parts for full content.
151
+ */
152
+ async function handleChatList(
153
+ globalOpts: GlobalOptions,
154
+ listOpts: ChatListOptions
155
+ ): Promise<void> {
156
+ // Resolve session ID (with prefix matching)
157
+ const { session } = await resolveSessionId(listOpts.session, {
158
+ root: globalOpts.root,
159
+ allowPrefix: true,
160
+ })
161
+
162
+ // Load message index for the session
163
+ let messages = await loadSessionChatIndex(session.sessionId, globalOpts.root)
164
+
165
+ // Hydrate parts if requested
166
+ if (listOpts.includeParts) {
167
+ messages = await Promise.all(
168
+ messages.map((msg) => hydrateChatMessageParts(msg, globalOpts.root))
169
+ )
170
+ }
171
+
172
+ // Apply limit
173
+ if (globalOpts.limit && messages.length > globalOpts.limit) {
174
+ messages = messages.slice(0, globalOpts.limit)
175
+ }
176
+
177
+ // Add 1-based index for display
178
+ const indexedMessages: IndexedChatMessage[] = messages.map((msg, i) => ({
179
+ ...msg,
180
+ index: i + 1,
181
+ }))
182
+
183
+ // Output using configured format
184
+ const outputOpts = getOutputOptions(globalOpts)
185
+ printChatOutput(indexedMessages, outputOpts)
186
+ }
187
+
188
+ /**
189
+ * Handle the chat show command.
190
+ *
191
+ * Shows a specific message by ID or 1-based index.
192
+ * Optionally copies the message content to clipboard.
193
+ */
194
+ async function handleChatShow(
195
+ globalOpts: GlobalOptions,
196
+ showOpts: ChatShowOptions
197
+ ): Promise<void> {
198
+ // Validate that either --message or --index is provided
199
+ if (!showOpts.message && showOpts.index === undefined) {
200
+ throw new UsageError(
201
+ "Either --message <messageId> or --index <number> is required"
202
+ )
203
+ }
204
+ if (showOpts.message && showOpts.index !== undefined) {
205
+ throw new UsageError(
206
+ "Cannot use both --message and --index. Use one or the other."
207
+ )
208
+ }
209
+
210
+ // Resolve session ID (with prefix matching)
211
+ const { session } = await resolveSessionId(showOpts.session, {
212
+ root: globalOpts.root,
213
+ allowPrefix: true,
214
+ })
215
+
216
+ // Load all messages for the session
217
+ const messages = await loadSessionChatIndex(session.sessionId, globalOpts.root)
218
+
219
+ if (messages.length === 0) {
220
+ throw new NotFoundError(
221
+ `Session "${session.sessionId}" has no messages`,
222
+ "message"
223
+ )
224
+ }
225
+
226
+ let message: ChatMessage | undefined
227
+
228
+ if (showOpts.message) {
229
+ // Find by message ID (exact or prefix match)
230
+ const messageId = showOpts.message
231
+ message = messages.find((m) => m.messageId === messageId)
232
+ if (!message) {
233
+ // Try prefix matching
234
+ const prefixMatches = messages.filter((m) =>
235
+ m.messageId.startsWith(messageId)
236
+ )
237
+ if (prefixMatches.length === 1) {
238
+ message = prefixMatches[0]
239
+ } else if (prefixMatches.length > 1) {
240
+ throw new NotFoundError(
241
+ `Ambiguous message ID prefix "${messageId}" matches ${prefixMatches.length} messages: ${prefixMatches.map((m) => m.messageId).join(", ")}`,
242
+ "message"
243
+ )
244
+ } else {
245
+ throw new NotFoundError(
246
+ `Message "${messageId}" not found in session "${session.sessionId}"`,
247
+ "message"
248
+ )
249
+ }
250
+ }
251
+ } else {
252
+ // Find by index (1-based)
253
+ const index = showOpts.index!
254
+ if (index < 1 || index > messages.length) {
255
+ throw new NotFoundError(
256
+ `Message index ${index} is out of range. Session has ${messages.length} message(s).`,
257
+ "message"
258
+ )
259
+ }
260
+ message = messages[index - 1]
261
+ }
262
+
263
+ // Hydrate message parts to get full content
264
+ const hydratedMessage = await hydrateChatMessageParts(message, globalOpts.root)
265
+
266
+ // Copy to clipboard if requested
267
+ if (showOpts.clipboard) {
268
+ const content = hydratedMessage.parts
269
+ ?.map((p) => p.text)
270
+ .join("\n\n") ?? hydratedMessage.previewText
271
+ try {
272
+ await copyToClipboard(content)
273
+ if (globalOpts.format === "table") {
274
+ console.log("(copied to clipboard)")
275
+ }
276
+ } catch {
277
+ // Clipboard copy failed (e.g., xclip/pbcopy not available)
278
+ // Warn but continue - the user still gets the message output
279
+ if (globalOpts.format === "table") {
280
+ console.error("Warning: Could not copy to clipboard")
281
+ }
282
+ }
283
+ }
284
+
285
+ // Output the message
286
+ printChatMessageOutput(hydratedMessage, globalOpts.format)
287
+ }
288
+
289
+ /**
290
+ * Handle the chat search command.
291
+ *
292
+ * Searches chat content across all sessions (or filtered by project).
293
+ * Returns matching messages with context snippets.
294
+ */
295
+ async function handleChatSearch(
296
+ globalOpts: GlobalOptions,
297
+ searchOpts: ChatSearchOptions
298
+ ): Promise<void> {
299
+ // Load sessions to search
300
+ const sessions = await loadSessionRecords({
301
+ root: globalOpts.root,
302
+ projectId: searchOpts.project,
303
+ })
304
+
305
+ // Search across sessions using the limit from global options
306
+ const results = await searchSessionsChat(
307
+ sessions,
308
+ searchOpts.query,
309
+ globalOpts.root,
310
+ { maxResults: globalOpts.limit }
311
+ )
312
+
313
+ // Add 1-based index for display
314
+ const indexedResults: IndexedChatSearchResult[] = results.map((result, i) => ({
315
+ ...result,
316
+ index: i + 1,
317
+ }))
318
+
319
+ // Output using configured format
320
+ const outputOpts = getOutputOptions(globalOpts)
321
+ printChatSearchOutput(indexedResults, outputOpts)
322
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Projects CLI subcommands.
3
+ *
4
+ * Provides commands for listing and deleting OpenCode projects.
5
+ */
6
+
7
+ import { Command, type OptionValues } from "commander"
8
+ import { parseGlobalOptions, type GlobalOptions } from "../index"
9
+ import {
10
+ loadProjectRecords,
11
+ filterProjectsByState,
12
+ deleteProjectMetadata,
13
+ type ProjectRecord,
14
+ } from "../../lib/opencode-data"
15
+ import {
16
+ getOutputOptions,
17
+ printProjectsOutput,
18
+ printDryRunOutput,
19
+ createDryRunResult,
20
+ printSuccessOutput,
21
+ } from "../output"
22
+ import { tokenizedSearch } from "../../lib/search"
23
+ import { resolveProjectId } from "../resolvers"
24
+ import { requireConfirmation, withErrorHandling, FileOperationError } 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 projects list command.
43
+ */
44
+ export interface ProjectsListOptions {
45
+ /** Only show projects with missing directories */
46
+ missingOnly: boolean
47
+ /** Search query to filter projects */
48
+ search?: string
49
+ }
50
+
51
+ /**
52
+ * Options specific to the projects delete command.
53
+ */
54
+ export interface ProjectsDeleteOptions {
55
+ /** Project ID to delete */
56
+ id: 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
+ * Register projects subcommands on the given parent command.
67
+ */
68
+ export function registerProjectsCommands(parent: Command): void {
69
+ const projects = parent
70
+ .command("projects")
71
+ .description("Manage OpenCode projects")
72
+
73
+ projects
74
+ .command("list")
75
+ .description("List projects")
76
+ .option("--missing-only", "Only show projects with missing directories", false)
77
+ .option("-s, --search <query>", "Search query to filter projects")
78
+ .action(function (this: Command) {
79
+ const globalOpts = parseGlobalOptions(collectOptions(this))
80
+ const cmdOpts = this.opts()
81
+ const listOpts: ProjectsListOptions = {
82
+ missingOnly: Boolean(cmdOpts.missingOnly),
83
+ search: cmdOpts.search as string | undefined,
84
+ }
85
+ handleProjectsList(globalOpts, listOpts)
86
+ })
87
+
88
+ projects
89
+ .command("delete")
90
+ .description("Delete a project's metadata file")
91
+ .requiredOption("--id <projectId>", "Project ID to delete")
92
+ .option("--yes", "Skip confirmation prompt", false)
93
+ .option("--dry-run", "Preview changes without deleting", false)
94
+ .option("--backup-dir <dir>", "Directory to backup files before deletion")
95
+ .action(async function (this: Command) {
96
+ const allOpts = collectOptions(this)
97
+ const globalOpts = parseGlobalOptions(allOpts)
98
+ const cmdOpts = this.opts()
99
+ const deleteOpts: ProjectsDeleteOptions = {
100
+ id: String(cmdOpts.id),
101
+ yes: Boolean(allOpts.yes ?? cmdOpts.yes),
102
+ dryRun: Boolean(allOpts.dryRun ?? cmdOpts.dryRun),
103
+ backupDir: (allOpts.backupDir ?? cmdOpts.backupDir) as string | undefined,
104
+ }
105
+ await withErrorHandling(handleProjectsDelete, getOutputOptions(globalOpts).format)(
106
+ globalOpts,
107
+ deleteOpts
108
+ )
109
+ })
110
+ }
111
+
112
+ /**
113
+ * Handle the projects list command.
114
+ */
115
+ async function handleProjectsList(
116
+ globalOpts: GlobalOptions,
117
+ listOpts: ProjectsListOptions
118
+ ): Promise<void> {
119
+ // Load project records from the data layer
120
+ let projects = await loadProjectRecords({ root: globalOpts.root })
121
+
122
+ // Apply missing-only filter if requested
123
+ if (listOpts.missingOnly) {
124
+ projects = filterProjectsByState(projects, "missing")
125
+ }
126
+
127
+ // Apply tokenized search if query provided (matches TUI semantics)
128
+ if (listOpts.search) {
129
+ projects = tokenizedSearch(
130
+ projects,
131
+ listOpts.search,
132
+ (p) => [p.projectId, p.worktree],
133
+ { limit: globalOpts.limit }
134
+ )
135
+ } else {
136
+ // Apply limit cap even without search (default 200)
137
+ projects = projects.slice(0, globalOpts.limit)
138
+ }
139
+
140
+ // Output the projects using the appropriate formatter
141
+ const outputOpts = getOutputOptions(globalOpts)
142
+ printProjectsOutput(projects, outputOpts)
143
+ }
144
+
145
+ /**
146
+ * Handle the projects delete command.
147
+ *
148
+ * This command deletes a project's metadata file from the OpenCode storage.
149
+ * It does NOT delete the actual project directory on disk.
150
+ *
151
+ * Exit codes:
152
+ * - 0: Success (or dry-run completed)
153
+ * - 2: Usage error (--yes not provided for destructive operation)
154
+ * - 3: Project not found
155
+ * - 4: File operation failure (backup or delete failed)
156
+ */
157
+ async function handleProjectsDelete(
158
+ globalOpts: GlobalOptions,
159
+ deleteOpts: ProjectsDeleteOptions
160
+ ): Promise<void> {
161
+ const outputOpts = getOutputOptions(globalOpts)
162
+
163
+ // Resolve project ID to a project record
164
+ const { project } = await resolveProjectId(deleteOpts.id, {
165
+ root: globalOpts.root,
166
+ allowPrefix: true,
167
+ })
168
+
169
+ const pathsToDelete = [project.filePath]
170
+
171
+ // Handle dry-run mode
172
+ if (deleteOpts.dryRun) {
173
+ const dryRunResult = createDryRunResult(pathsToDelete, "delete", "project")
174
+ printDryRunOutput(dryRunResult, outputOpts.format)
175
+ return
176
+ }
177
+
178
+ // Require confirmation for destructive operation
179
+ requireConfirmation(deleteOpts.yes, "Project deletion")
180
+
181
+ // Backup files if requested
182
+ if (deleteOpts.backupDir) {
183
+ const backupResult = await copyToBackupDir(pathsToDelete, {
184
+ backupDir: deleteOpts.backupDir,
185
+ prefix: "project",
186
+ preserveStructure: true,
187
+ structureRoot: globalOpts.root,
188
+ })
189
+
190
+ if (backupResult.failed.length > 0) {
191
+ throw new FileOperationError(
192
+ `Backup failed for ${backupResult.failed.length} file(s): ${backupResult.failed
193
+ .map((f) => f.path)
194
+ .join(", ")}`,
195
+ "backup"
196
+ )
197
+ }
198
+
199
+ if (!globalOpts.quiet) {
200
+ console.log(formatBackupResult(backupResult))
201
+ }
202
+ }
203
+
204
+ // Perform the deletion
205
+ const deleteResult = await deleteProjectMetadata([project], { dryRun: false })
206
+
207
+ if (deleteResult.failed.length > 0) {
208
+ throw new FileOperationError(
209
+ `Failed to delete ${deleteResult.failed.length} file(s): ${deleteResult.failed
210
+ .map((f) => `${f.path}: ${f.error}`)
211
+ .join(", ")}`,
212
+ "delete"
213
+ )
214
+ }
215
+
216
+ // Output success
217
+ printSuccessOutput(
218
+ `Deleted project: ${project.projectId}`,
219
+ { projectId: project.projectId, deleted: deleteResult.removed },
220
+ outputOpts.format
221
+ )
222
+ }