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,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
|
+
}
|