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,324 @@
1
+ /**
2
+ * CLI backup utilities module.
3
+ *
4
+ * Provides helpers for backing up files and directories before
5
+ * destructive operations like delete.
6
+ */
7
+
8
+ import { promises as fs } from "node:fs"
9
+ import { basename, dirname, join, relative, resolve } from "node:path"
10
+ import { FileOperationError } from "./errors"
11
+
12
+ // ========================
13
+ // Types
14
+ // ========================
15
+
16
+ /**
17
+ * Options for backup operations.
18
+ */
19
+ export interface BackupOptions {
20
+ /** Base directory for backups. Files are copied here with structure preserved. */
21
+ backupDir: string
22
+ /** Optional prefix for backup directory name (defaults to timestamp). */
23
+ prefix?: string
24
+ /** Whether to preserve the original directory structure relative to a root. */
25
+ preserveStructure?: boolean
26
+ /** Root directory for preserving structure (paths are relative to this). */
27
+ structureRoot?: string
28
+ }
29
+
30
+ /**
31
+ * Result of a backup operation.
32
+ */
33
+ export interface BackupResult {
34
+ /** Source paths that were backed up. */
35
+ sources: string[]
36
+ /** Destination paths where backups were created. */
37
+ destinations: string[]
38
+ /** The backup directory used (may include timestamp subdirectory). */
39
+ backupDir: string
40
+ /** Any paths that failed to backup. */
41
+ failed: Array<{ path: string; error: string }>
42
+ }
43
+
44
+ // ========================
45
+ // Helpers
46
+ // ========================
47
+
48
+ /**
49
+ * Generate a timestamp string for backup directory names.
50
+ *
51
+ * @returns ISO-like timestamp without colons (filesystem safe)
52
+ */
53
+ export function generateBackupTimestamp(): string {
54
+ const now = new Date()
55
+ // Format: YYYY-MM-DD_HH-MM-SS
56
+ const year = now.getFullYear()
57
+ const month = String(now.getMonth() + 1).padStart(2, "0")
58
+ const day = String(now.getDate()).padStart(2, "0")
59
+ const hours = String(now.getHours()).padStart(2, "0")
60
+ const minutes = String(now.getMinutes()).padStart(2, "0")
61
+ const seconds = String(now.getSeconds()).padStart(2, "0")
62
+ return `${year}-${month}-${day}_${hours}-${minutes}-${seconds}`
63
+ }
64
+
65
+ /**
66
+ * Ensure a directory exists, creating it if necessary.
67
+ *
68
+ * @param dir - Directory path to ensure
69
+ */
70
+ async function ensureDir(dir: string): Promise<void> {
71
+ await fs.mkdir(dir, { recursive: true })
72
+ }
73
+
74
+ /**
75
+ * Check if a path exists.
76
+ *
77
+ * @param path - Path to check
78
+ * @returns true if exists, false otherwise
79
+ */
80
+ async function pathExists(path: string): Promise<boolean> {
81
+ try {
82
+ await fs.access(path)
83
+ return true
84
+ } catch {
85
+ return false
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Check if a path is a directory.
91
+ *
92
+ * @param path - Path to check
93
+ * @returns true if directory, false otherwise
94
+ */
95
+ async function isDirectory(path: string): Promise<boolean> {
96
+ try {
97
+ const stat = await fs.stat(path)
98
+ return stat.isDirectory()
99
+ } catch {
100
+ return false
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Recursively copy a directory.
106
+ *
107
+ * @param src - Source directory
108
+ * @param dest - Destination directory
109
+ */
110
+ async function copyDir(src: string, dest: string): Promise<void> {
111
+ await ensureDir(dest)
112
+ const entries = await fs.readdir(src, { withFileTypes: true })
113
+
114
+ for (const entry of entries) {
115
+ const srcPath = join(src, entry.name)
116
+ const destPath = join(dest, entry.name)
117
+
118
+ if (entry.isDirectory()) {
119
+ await copyDir(srcPath, destPath)
120
+ } else {
121
+ await fs.copyFile(srcPath, destPath)
122
+ }
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Copy a file or directory to a destination.
128
+ *
129
+ * @param src - Source path (file or directory)
130
+ * @param dest - Destination path
131
+ */
132
+ async function copyPath(src: string, dest: string): Promise<void> {
133
+ if (await isDirectory(src)) {
134
+ await copyDir(src, dest)
135
+ } else {
136
+ // Ensure parent directory exists
137
+ await ensureDir(dirname(dest))
138
+ await fs.copyFile(src, dest)
139
+ }
140
+ }
141
+
142
+ // ========================
143
+ // Main Backup Functions
144
+ // ========================
145
+
146
+ /**
147
+ * Copy files to a backup directory before deletion.
148
+ *
149
+ * Creates a timestamped subdirectory within backupDir to store the backups.
150
+ * Preserves directory structure relative to structureRoot if specified.
151
+ *
152
+ * @param paths - Array of file/directory paths to backup
153
+ * @param options - Backup options
154
+ * @returns BackupResult with details of the operation
155
+ *
156
+ * @example
157
+ * ```ts
158
+ * // Simple backup
159
+ * const result = await copyToBackupDir(
160
+ * ["/path/to/project.json", "/path/to/session/data"],
161
+ * { backupDir: "/backups" }
162
+ * )
163
+ * // Files are copied to /backups/2024-01-15_12-30-45/...
164
+ *
165
+ * // Preserve structure
166
+ * const result = await copyToBackupDir(
167
+ * ["/data/storage/project/abc.json"],
168
+ * {
169
+ * backupDir: "/backups",
170
+ * preserveStructure: true,
171
+ * structureRoot: "/data"
172
+ * }
173
+ * )
174
+ * // File is copied to /backups/2024-01-15_12-30-45/storage/project/abc.json
175
+ * ```
176
+ */
177
+ export async function copyToBackupDir(
178
+ paths: string[],
179
+ options: BackupOptions
180
+ ): Promise<BackupResult> {
181
+ const { backupDir, prefix, preserveStructure, structureRoot } = options
182
+
183
+ // Validate backup directory
184
+ const resolvedBackupDir = resolve(backupDir)
185
+
186
+ // Create timestamped subdirectory
187
+ const timestamp = generateBackupTimestamp()
188
+ const backupSubdir = prefix
189
+ ? `${prefix}_${timestamp}`
190
+ : timestamp
191
+ const targetBackupDir = join(resolvedBackupDir, backupSubdir)
192
+
193
+ const result: BackupResult = {
194
+ sources: [],
195
+ destinations: [],
196
+ backupDir: targetBackupDir,
197
+ failed: [],
198
+ }
199
+
200
+ // If no paths, return early
201
+ if (paths.length === 0) {
202
+ return result
203
+ }
204
+
205
+ // Ensure backup directory exists
206
+ try {
207
+ await ensureDir(targetBackupDir)
208
+ } catch (error) {
209
+ throw new FileOperationError(
210
+ `Failed to create backup directory: ${targetBackupDir}`,
211
+ "backup"
212
+ )
213
+ }
214
+
215
+ // Copy each path
216
+ for (const srcPath of paths) {
217
+ const resolvedSrc = resolve(srcPath)
218
+
219
+ // Check if source exists
220
+ if (!(await pathExists(resolvedSrc))) {
221
+ result.failed.push({
222
+ path: resolvedSrc,
223
+ error: "Source path does not exist",
224
+ })
225
+ continue
226
+ }
227
+
228
+ // Determine destination path
229
+ let destPath: string
230
+ if (preserveStructure && structureRoot) {
231
+ // Preserve directory structure relative to root
232
+ const relativePath = relative(resolve(structureRoot), resolvedSrc)
233
+ if (relativePath.startsWith("..")) {
234
+ // Path is outside structureRoot, use basename
235
+ destPath = join(targetBackupDir, basename(resolvedSrc))
236
+ } else {
237
+ destPath = join(targetBackupDir, relativePath)
238
+ }
239
+ } else {
240
+ // Just use the basename
241
+ destPath = join(targetBackupDir, basename(resolvedSrc))
242
+ }
243
+
244
+ // Copy the file/directory
245
+ try {
246
+ await copyPath(resolvedSrc, destPath)
247
+ result.sources.push(resolvedSrc)
248
+ result.destinations.push(destPath)
249
+ } catch (error) {
250
+ const errorMessage = error instanceof Error ? error.message : String(error)
251
+ result.failed.push({
252
+ path: resolvedSrc,
253
+ error: errorMessage,
254
+ })
255
+ }
256
+ }
257
+
258
+ return result
259
+ }
260
+
261
+ /**
262
+ * Get the paths that would be backed up (for dry-run display).
263
+ *
264
+ * @param paths - Source paths to backup
265
+ * @param options - Backup options
266
+ * @returns Object with source and computed destination paths
267
+ */
268
+ export function previewBackupPaths(
269
+ paths: string[],
270
+ options: BackupOptions
271
+ ): { sources: string[]; destinations: string[]; backupDir: string } {
272
+ const { backupDir, prefix, preserveStructure, structureRoot } = options
273
+
274
+ const resolvedBackupDir = resolve(backupDir)
275
+ const timestamp = generateBackupTimestamp()
276
+ const backupSubdir = prefix ? `${prefix}_${timestamp}` : timestamp
277
+ const targetBackupDir = join(resolvedBackupDir, backupSubdir)
278
+
279
+ const sources: string[] = []
280
+ const destinations: string[] = []
281
+
282
+ for (const srcPath of paths) {
283
+ const resolvedSrc = resolve(srcPath)
284
+ sources.push(resolvedSrc)
285
+
286
+ let destPath: string
287
+ if (preserveStructure && structureRoot) {
288
+ const relativePath = relative(resolve(structureRoot), resolvedSrc)
289
+ if (relativePath.startsWith("..")) {
290
+ destPath = join(targetBackupDir, basename(resolvedSrc))
291
+ } else {
292
+ destPath = join(targetBackupDir, relativePath)
293
+ }
294
+ } else {
295
+ destPath = join(targetBackupDir, basename(resolvedSrc))
296
+ }
297
+ destinations.push(destPath)
298
+ }
299
+
300
+ return { sources, destinations, backupDir: targetBackupDir }
301
+ }
302
+
303
+ /**
304
+ * Format backup result for display.
305
+ *
306
+ * @param result - Backup result to format
307
+ * @returns Human-readable summary string
308
+ */
309
+ export function formatBackupResult(result: BackupResult): string {
310
+ const lines: string[] = []
311
+
312
+ if (result.sources.length > 0) {
313
+ lines.push(`Backed up ${result.sources.length} item(s) to: ${result.backupDir}`)
314
+ }
315
+
316
+ if (result.failed.length > 0) {
317
+ lines.push(`Failed to backup ${result.failed.length} item(s):`)
318
+ for (const { path, error } of result.failed) {
319
+ lines.push(` ${path}: ${error}`)
320
+ }
321
+ }
322
+
323
+ return lines.join("\n")
324
+ }
@@ -0,0 +1,336 @@
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 { type ChatMessage, type ChatSearchResult } from "../../lib/opencode-data"
11
+ import { createProviderFromGlobalOptions } from "../../lib/opencode-data-provider"
12
+ import { copyToClipboard } from "../../lib/clipboard"
13
+ import { resolveSessionId } from "../resolvers"
14
+ import { withErrorHandling, UsageError, NotFoundError } from "../errors"
15
+ import {
16
+ getOutputOptions,
17
+ printChatOutput,
18
+ printChatMessageOutput,
19
+ printChatSearchOutput,
20
+ type IndexedChatMessage,
21
+ type IndexedChatSearchResult,
22
+ type OutputFormat,
23
+ } from "../output"
24
+
25
+ /**
26
+ * Collect all options from a command and its ancestors.
27
+ * Commander stores global options on the root program, not on subcommands.
28
+ */
29
+ function collectOptions(cmd: Command): OptionValues {
30
+ const opts: OptionValues = {}
31
+ let current: Command | null = cmd
32
+ while (current) {
33
+ Object.assign(opts, current.opts())
34
+ current = current.parent
35
+ }
36
+ return opts
37
+ }
38
+
39
+ /**
40
+ * Options specific to the chat list command.
41
+ */
42
+ export interface ChatListOptions {
43
+ /** Session ID to list messages from */
44
+ session: string
45
+ /** Include message parts in output */
46
+ includeParts: boolean
47
+ }
48
+
49
+ /**
50
+ * Options specific to the chat show command.
51
+ */
52
+ export interface ChatShowOptions {
53
+ /** Session ID containing the message */
54
+ session: string
55
+ /** Message ID to show */
56
+ message?: string
57
+ /** Message index (1-based) to show */
58
+ index?: number
59
+ /** Copy message content to clipboard */
60
+ clipboard?: boolean
61
+ }
62
+
63
+ /**
64
+ * Options specific to the chat search command.
65
+ */
66
+ export interface ChatSearchOptions {
67
+ /** Search query */
68
+ query: string
69
+ /** Filter by project ID */
70
+ project?: string
71
+ }
72
+
73
+ /**
74
+ * Register chat subcommands on the given parent command.
75
+ */
76
+ export function registerChatCommands(parent: Command): void {
77
+ const chat = parent
78
+ .command("chat")
79
+ .description("View and search chat messages")
80
+
81
+ chat
82
+ .command("list")
83
+ .description("List messages in a session")
84
+ .requiredOption("--session <sessionId>", "Session ID to list messages from")
85
+ .option("--include-parts", "Include message parts in output", false)
86
+ .action(async function (this: Command) {
87
+ const globalOpts = parseGlobalOptions(collectOptions(this))
88
+ const cmdOpts = this.opts()
89
+ const listOpts: ChatListOptions = {
90
+ session: String(cmdOpts.session),
91
+ includeParts: Boolean(cmdOpts.includeParts),
92
+ }
93
+ await withErrorHandling(handleChatList, globalOpts.format)(
94
+ globalOpts,
95
+ listOpts
96
+ )
97
+ })
98
+
99
+ chat
100
+ .command("show")
101
+ .description("Show a specific message")
102
+ .requiredOption("--session <sessionId>", "Session ID containing the message")
103
+ .option("-m, --message <messageId>", "Message ID to show")
104
+ .option("-i, --index <number>", "Message index (1-based) to show")
105
+ .action(async function (this: Command) {
106
+ const allOpts = collectOptions(this)
107
+ const globalOpts = parseGlobalOptions(allOpts)
108
+ const cmdOpts = this.opts()
109
+ const showOpts: ChatShowOptions = {
110
+ session: String(cmdOpts.session),
111
+ message: cmdOpts.message as string | undefined,
112
+ index: cmdOpts.index ? parseInt(String(cmdOpts.index), 10) : undefined,
113
+ // Use global --clipboard option since it's defined at root level
114
+ clipboard: globalOpts.clipboard,
115
+ }
116
+ await withErrorHandling(handleChatShow, globalOpts.format)(
117
+ globalOpts,
118
+ showOpts
119
+ )
120
+ })
121
+
122
+ chat
123
+ .command("search")
124
+ .description("Search chat content across sessions")
125
+ .requiredOption("-q, --query <query>", "Search query")
126
+ .option("-p, --project <projectId>", "Filter by project ID")
127
+ .action(async function (this: Command) {
128
+ const globalOpts = parseGlobalOptions(collectOptions(this))
129
+ const cmdOpts = this.opts()
130
+ const searchOpts: ChatSearchOptions = {
131
+ query: String(cmdOpts.query),
132
+ project: cmdOpts.project as string | undefined,
133
+ }
134
+ await withErrorHandling(handleChatSearch, globalOpts.format)(
135
+ globalOpts,
136
+ searchOpts
137
+ )
138
+ })
139
+
140
+ chat.addHelpText(
141
+ "after",
142
+ [
143
+ "",
144
+ "Examples:",
145
+ " opencode-manager chat list --session <id> --experimental-sqlite",
146
+ " opencode-manager chat list --session <id> --db ~/.local/share/opencode/opencode.db",
147
+ ].join("\n")
148
+ )
149
+ }
150
+
151
+ /**
152
+ * Handle the chat list command.
153
+ *
154
+ * Lists messages in a session, ordered by createdAt (ascending).
155
+ * Optionally includes message parts for full content.
156
+ */
157
+ async function handleChatList(
158
+ globalOpts: GlobalOptions,
159
+ listOpts: ChatListOptions
160
+ ): Promise<void> {
161
+ // Create provider from global options (JSONL or SQLite)
162
+ const provider = createProviderFromGlobalOptions(globalOpts)
163
+
164
+ // Resolve session ID (with prefix matching) using the provider
165
+ const { session } = await resolveSessionId(listOpts.session, {
166
+ root: globalOpts.root,
167
+ allowPrefix: true,
168
+ provider,
169
+ })
170
+
171
+ // Load message index for the session
172
+ let messages = await provider.loadSessionChatIndex(session.sessionId)
173
+
174
+ // Hydrate parts if requested
175
+ if (listOpts.includeParts) {
176
+ messages = await Promise.all(
177
+ messages.map((msg: ChatMessage) => provider.hydrateChatMessageParts(msg))
178
+ )
179
+ }
180
+
181
+ // Apply limit
182
+ if (globalOpts.limit && messages.length > globalOpts.limit) {
183
+ messages = messages.slice(0, globalOpts.limit)
184
+ }
185
+
186
+ // Add 1-based index for display
187
+ const indexedMessages: IndexedChatMessage[] = messages.map((msg: ChatMessage, i: number) => ({
188
+ ...msg,
189
+ index: i + 1,
190
+ }))
191
+
192
+ // Output using configured format
193
+ const outputOpts = getOutputOptions(globalOpts)
194
+ printChatOutput(indexedMessages, outputOpts)
195
+ }
196
+
197
+ /**
198
+ * Handle the chat show command.
199
+ *
200
+ * Shows a specific message by ID or 1-based index.
201
+ * Optionally copies the message content to clipboard.
202
+ */
203
+ async function handleChatShow(
204
+ globalOpts: GlobalOptions,
205
+ showOpts: ChatShowOptions
206
+ ): Promise<void> {
207
+ // Validate that either --message or --index is provided
208
+ if (!showOpts.message && showOpts.index === undefined) {
209
+ throw new UsageError(
210
+ "Either --message <messageId> or --index <number> is required"
211
+ )
212
+ }
213
+ if (showOpts.message && showOpts.index !== undefined) {
214
+ throw new UsageError(
215
+ "Cannot use both --message and --index. Use one or the other."
216
+ )
217
+ }
218
+
219
+ // Create provider from global options (JSONL or SQLite)
220
+ const provider = createProviderFromGlobalOptions(globalOpts)
221
+
222
+ // Resolve session ID (with prefix matching) using the provider
223
+ const { session } = await resolveSessionId(showOpts.session, {
224
+ root: globalOpts.root,
225
+ allowPrefix: true,
226
+ provider,
227
+ })
228
+
229
+ // Load all messages for the session
230
+ const messages = await provider.loadSessionChatIndex(session.sessionId)
231
+
232
+ if (messages.length === 0) {
233
+ throw new NotFoundError(
234
+ `Session "${session.sessionId}" has no messages`,
235
+ "message"
236
+ )
237
+ }
238
+
239
+ let message: ChatMessage | undefined
240
+
241
+ if (showOpts.message) {
242
+ // Find by message ID (exact or prefix match)
243
+ const messageId = showOpts.message
244
+ message = messages.find((m: ChatMessage) => m.messageId === messageId)
245
+ if (!message) {
246
+ // Try prefix matching
247
+ const prefixMatches = messages.filter((m: ChatMessage) =>
248
+ m.messageId.startsWith(messageId)
249
+ )
250
+ if (prefixMatches.length === 1) {
251
+ message = prefixMatches[0]
252
+ } else if (prefixMatches.length > 1) {
253
+ throw new NotFoundError(
254
+ `Ambiguous message ID prefix "${messageId}" matches ${prefixMatches.length} messages: ${prefixMatches.map((m: ChatMessage) => m.messageId).join(", ")}`,
255
+ "message"
256
+ )
257
+ } else {
258
+ throw new NotFoundError(
259
+ `Message "${messageId}" not found in session "${session.sessionId}"`,
260
+ "message"
261
+ )
262
+ }
263
+ }
264
+ } else {
265
+ // Find by index (1-based)
266
+ const index = showOpts.index!
267
+ if (index < 1 || index > messages.length) {
268
+ throw new NotFoundError(
269
+ `Message index ${index} is out of range. Session has ${messages.length} message(s).`,
270
+ "message"
271
+ )
272
+ }
273
+ message = messages[index - 1]
274
+ }
275
+
276
+ // Hydrate message parts to get full content
277
+ const hydratedMessage = await provider.hydrateChatMessageParts(message)
278
+
279
+ // Copy to clipboard if requested
280
+ if (showOpts.clipboard) {
281
+ const content = hydratedMessage.parts
282
+ ?.map((p: { text: string }) => p.text)
283
+ .join("\n\n") ?? hydratedMessage.previewText
284
+ try {
285
+ await copyToClipboard(content)
286
+ if (globalOpts.format === "table") {
287
+ console.log("(copied to clipboard)")
288
+ }
289
+ } catch {
290
+ // Clipboard copy failed (e.g., xclip/pbcopy not available)
291
+ // Warn but continue - the user still gets the message output
292
+ if (globalOpts.format === "table") {
293
+ console.error("Warning: Could not copy to clipboard")
294
+ }
295
+ }
296
+ }
297
+
298
+ // Output the message
299
+ printChatMessageOutput(hydratedMessage, globalOpts.format)
300
+ }
301
+
302
+ /**
303
+ * Handle the chat search command.
304
+ *
305
+ * Searches chat content across all sessions (or filtered by project).
306
+ * Returns matching messages with context snippets.
307
+ */
308
+ async function handleChatSearch(
309
+ globalOpts: GlobalOptions,
310
+ searchOpts: ChatSearchOptions
311
+ ): Promise<void> {
312
+ // Create provider from global options (JSONL or SQLite)
313
+ const provider = createProviderFromGlobalOptions(globalOpts)
314
+
315
+ // Load sessions to search
316
+ const sessions = await provider.loadSessionRecords({
317
+ projectId: searchOpts.project,
318
+ })
319
+
320
+ // Search across sessions using the limit from global options
321
+ const results = await provider.searchSessionsChat(
322
+ sessions,
323
+ searchOpts.query,
324
+ { maxResults: globalOpts.limit }
325
+ )
326
+
327
+ // Add 1-based index for display
328
+ const indexedResults: IndexedChatSearchResult[] = results.map((result: ChatSearchResult, i: number) => ({
329
+ ...result,
330
+ index: i + 1,
331
+ }))
332
+
333
+ // Output using configured format
334
+ const outputOpts = getOutputOptions(globalOpts)
335
+ printChatSearchOutput(indexedResults, outputOpts)
336
+ }