opencode-manager 0.4.0 → 0.4.2

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/README.md CHANGED
@@ -8,15 +8,27 @@ Terminal UI for inspecting, filtering, and pruning OpenCode metadata stored on d
8
8
  ## Screenshots
9
9
 
10
10
  <p align="center">
11
- <img src="home-screen.png" alt="OpenCode Metadata Manager home screen showing projects and sessions" width="85%" />
11
+ <img src="oc-manager.png" alt="OpenCode Metadata Manager home screen showing projects and sessions" width="85%" />
12
12
  <br />
13
13
  <em>Main workspace with Projects (left) and Sessions (right) panels.</em>
14
14
  </p>
15
15
 
16
16
  <p align="center">
17
- <img src="help-screen.png" alt="OpenCode Metadata Manager help overlay" width="85%" />
17
+ <img src="oc-manager-home.png" alt="OpenCode Metadata Manager home screen alternate view" width="85%" />
18
18
  <br />
19
- <em>Contextual help overlay with key bindings and tips.</em>
19
+ <em>Alternate home view with project/session context.</em>
20
+ </p>
21
+
22
+ <p align="center">
23
+ <img src="oc-manager-search.png" alt="OpenCode Metadata Manager search view" width="85%" />
24
+ <br />
25
+ <em>Fuzzy search across sessions with ranked results.</em>
26
+ </p>
27
+
28
+ <p align="center">
29
+ <img src="oc-manager-cli.png" alt="OpenCode Metadata Manager CLI output" width="85%" />
30
+ <br />
31
+ <em>Scriptable CLI output for listing projects and sessions.</em>
20
32
  </p>
21
33
 
22
34
  ## Features
@@ -32,6 +44,7 @@ Terminal UI for inspecting, filtering, and pruning OpenCode metadata stored on d
32
44
  - Rich help overlay with live key hints (`?` or `H`).
33
45
  - Zero-install via `bunx` so even CI shells can run it without cloning.
34
46
  - **Token counting**: View token usage per session, per project, and globally.
47
+ - **Experimental SQLite backend**: Faster queries for large stores via `--experimental-sqlite`.
35
48
 
36
49
  ## Token Counting
37
50
 
@@ -117,6 +130,43 @@ The CLI provides scriptable access to all management operations. Use subcommands
117
130
  | `-q, --quiet` | `false` | Suppress non-essential output |
118
131
  | `-c, --clipboard` | `false` | Copy output to clipboard |
119
132
  | `--backup-dir <path>` | — | Directory for backup copies before deletion |
133
+ | `--experimental-sqlite` | `false` | Use SQLite database instead of JSONL files (experimental) |
134
+ | `--db <path>` | `~/.local/share/opencode/opencode.db` | Path to SQLite database (implies `--experimental-sqlite`) |
135
+ | `--sqlite-strict` | `false` | Fail on any SQLite warning or malformed data |
136
+ | `--force-write` | `false` | Wait for SQLite write locks to clear before failing |
137
+
138
+ #### Experimental SQLite Support
139
+
140
+ OpenCode can store metadata in SQLite databases. The CLI supports this mode with `--experimental-sqlite` or by pointing directly at a database with `--db <path>`.
141
+
142
+ Key behaviors:
143
+ - Read operations open SQLite in readonly mode by default.
144
+ - Write operations may fail if OpenCode has the database locked. Use `--force-write` to wait for the lock to clear.
145
+ - When malformed rows are encountered, the CLI logs a warning and returns partial results. Use `--sqlite-strict` to fail fast.
146
+
147
+ When to use SQLite vs JSONL:
148
+ - Use SQLite when your OpenCode installation no longer writes JSONL files or when you want faster list/search operations on large stores.
149
+ - Use JSONL when you need maximal compatibility with older OpenCode versions or when you want to avoid SQLite locking.
150
+
151
+ Known limitations and differences:
152
+ - Schema changes in OpenCode may break compatibility. The CLI validates required tables/columns and warns if the schema is incomplete.
153
+ - SQLite records use virtual file paths (e.g., `sqlite:project:proj_123`) instead of JSON file paths.
154
+ - Results may differ slightly from JSONL when extra SQLite-only rows exist.
155
+
156
+ Examples:
157
+ ```bash
158
+ # List projects using the default SQLite database
159
+ opencode-manager projects list --experimental-sqlite
160
+
161
+ # List sessions using an explicit SQLite database path
162
+ opencode-manager sessions list --db ~/.local/share/opencode/opencode.db
163
+
164
+ # Fail fast on malformed SQLite data
165
+ opencode-manager projects list --db ~/.local/share/opencode/opencode.db --sqlite-strict
166
+
167
+ # Wait for locks before destructive operations
168
+ opencode-manager projects delete --id proj_missing --db ~/.local/share/opencode/opencode.db --yes --force-write
169
+ ```
120
170
 
121
171
  #### Commands Overview
122
172
 
@@ -424,13 +474,24 @@ Delete commands (`projects delete`, `sessions delete`) remove **metadata files o
424
474
  ### Project Structure
425
475
  ```
426
476
  src/
427
- bin/opencode-manager.ts # Bun-native CLI shim exposed as the bin entry
477
+ bin/opencode-manager.ts # Bun-native CLI shim exposed as the bin entry
478
+ cli/
479
+ index.ts # Commander program with global options
480
+ commands/ # Subcommand implementations (projects, sessions, chat, tokens)
481
+ resolvers.ts # ID prefix resolution helpers
482
+ lib/
483
+ opencode-data.ts # JSONL file-based data access
484
+ opencode-data-sqlite.ts # SQLite backend (experimental)
485
+ opencode-data-provider.ts # Unified DataProvider abstraction
428
486
  tui/
429
- app.tsx # Main TUI implementation (panels, search, help)
430
- index.tsx # TUI entrypoint with launchTUI(), parseArgs(), bootstrap()
431
- manage_opencode_projects.py # Legacy Python launcher for backwards compatibility
432
- opencode-gen.sh # Spec snapshot helper script
433
- PROJECT-SUMMARY.md # Extended design notes & roadmap
487
+ app.tsx # Main TUI implementation (panels, search, help)
488
+ index.tsx # TUI entrypoint with launchTUI(), parseArgs(), bootstrap()
489
+ tests/
490
+ fixtures/ # Test data (JSONL and SQLite fixtures)
491
+ lib/ # Unit tests for data modules
492
+ cli/ # CLI integration tests
493
+ manage_opencode_projects.py # Legacy Python launcher for backwards compatibility
494
+ PROJECT-SUMMARY.md # Extended design notes & roadmap
434
495
  ```
435
496
 
436
497
  ## Packaging & Publish
package/bun.lock CHANGED
@@ -12,6 +12,7 @@
12
12
  "react": "^19.0.0",
13
13
  },
14
14
  "devDependencies": {
15
+ "@types/bun": "^1.3.6",
15
16
  "@types/node": "^22.8.5",
16
17
  "@types/react": "^19.0.0",
17
18
  "typescript": "^5.6.3",
@@ -95,6 +96,8 @@
95
96
 
96
97
  "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
97
98
 
99
+ "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
100
+
98
101
  "@types/node": ["@types/node@22.19.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA=="],
99
102
 
100
103
  "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
@@ -115,6 +118,8 @@
115
118
 
116
119
  "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
117
120
 
121
+ "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
122
+
118
123
  "bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
119
124
 
120
125
  "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eDgLN9teKTfmvrCqgwwmWNsNszxYs7IZdCqk0S1DCarvMhr4wcajoSBlA/nQA0/owwLduPTS8xxCnQp4/N/gDg=="],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-manager",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Terminal UI for inspecting OpenCode metadata stores.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -51,6 +51,7 @@
51
51
  "react": "^19.0.0"
52
52
  },
53
53
  "devDependencies": {
54
+ "@types/bun": "^1.3.6",
54
55
  "@types/node": "^22.8.5",
55
56
  "@types/react": "^19.0.0",
56
57
  "typescript": "^5.6.3"
@@ -7,13 +7,8 @@
7
7
 
8
8
  import { Command, type OptionValues } from "commander"
9
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"
10
+ import { type ChatMessage, type ChatSearchResult } from "../../lib/opencode-data"
11
+ import { createProviderFromGlobalOptions } from "../../lib/opencode-data-provider"
17
12
  import { copyToClipboard } from "../../lib/clipboard"
18
13
  import { resolveSessionId } from "../resolvers"
19
14
  import { withErrorHandling, UsageError, NotFoundError } from "../errors"
@@ -141,6 +136,16 @@ export function registerChatCommands(parent: Command): void {
141
136
  searchOpts
142
137
  )
143
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
+ )
144
149
  }
145
150
 
146
151
  /**
@@ -153,19 +158,23 @@ async function handleChatList(
153
158
  globalOpts: GlobalOptions,
154
159
  listOpts: ChatListOptions
155
160
  ): Promise<void> {
156
- // Resolve session ID (with prefix matching)
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
157
165
  const { session } = await resolveSessionId(listOpts.session, {
158
166
  root: globalOpts.root,
159
167
  allowPrefix: true,
168
+ provider,
160
169
  })
161
170
 
162
171
  // Load message index for the session
163
- let messages = await loadSessionChatIndex(session.sessionId, globalOpts.root)
172
+ let messages = await provider.loadSessionChatIndex(session.sessionId)
164
173
 
165
174
  // Hydrate parts if requested
166
175
  if (listOpts.includeParts) {
167
176
  messages = await Promise.all(
168
- messages.map((msg) => hydrateChatMessageParts(msg, globalOpts.root))
177
+ messages.map((msg: ChatMessage) => provider.hydrateChatMessageParts(msg))
169
178
  )
170
179
  }
171
180
 
@@ -175,7 +184,7 @@ async function handleChatList(
175
184
  }
176
185
 
177
186
  // Add 1-based index for display
178
- const indexedMessages: IndexedChatMessage[] = messages.map((msg, i) => ({
187
+ const indexedMessages: IndexedChatMessage[] = messages.map((msg: ChatMessage, i: number) => ({
179
188
  ...msg,
180
189
  index: i + 1,
181
190
  }))
@@ -207,14 +216,18 @@ async function handleChatShow(
207
216
  )
208
217
  }
209
218
 
210
- // Resolve session ID (with prefix matching)
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
211
223
  const { session } = await resolveSessionId(showOpts.session, {
212
224
  root: globalOpts.root,
213
225
  allowPrefix: true,
226
+ provider,
214
227
  })
215
228
 
216
229
  // Load all messages for the session
217
- const messages = await loadSessionChatIndex(session.sessionId, globalOpts.root)
230
+ const messages = await provider.loadSessionChatIndex(session.sessionId)
218
231
 
219
232
  if (messages.length === 0) {
220
233
  throw new NotFoundError(
@@ -228,17 +241,17 @@ async function handleChatShow(
228
241
  if (showOpts.message) {
229
242
  // Find by message ID (exact or prefix match)
230
243
  const messageId = showOpts.message
231
- message = messages.find((m) => m.messageId === messageId)
244
+ message = messages.find((m: ChatMessage) => m.messageId === messageId)
232
245
  if (!message) {
233
246
  // Try prefix matching
234
- const prefixMatches = messages.filter((m) =>
247
+ const prefixMatches = messages.filter((m: ChatMessage) =>
235
248
  m.messageId.startsWith(messageId)
236
249
  )
237
250
  if (prefixMatches.length === 1) {
238
251
  message = prefixMatches[0]
239
252
  } else if (prefixMatches.length > 1) {
240
253
  throw new NotFoundError(
241
- `Ambiguous message ID prefix "${messageId}" matches ${prefixMatches.length} messages: ${prefixMatches.map((m) => m.messageId).join(", ")}`,
254
+ `Ambiguous message ID prefix "${messageId}" matches ${prefixMatches.length} messages: ${prefixMatches.map((m: ChatMessage) => m.messageId).join(", ")}`,
242
255
  "message"
243
256
  )
244
257
  } else {
@@ -261,12 +274,12 @@ async function handleChatShow(
261
274
  }
262
275
 
263
276
  // Hydrate message parts to get full content
264
- const hydratedMessage = await hydrateChatMessageParts(message, globalOpts.root)
277
+ const hydratedMessage = await provider.hydrateChatMessageParts(message)
265
278
 
266
279
  // Copy to clipboard if requested
267
280
  if (showOpts.clipboard) {
268
281
  const content = hydratedMessage.parts
269
- ?.map((p) => p.text)
282
+ ?.map((p: { text: string }) => p.text)
270
283
  .join("\n\n") ?? hydratedMessage.previewText
271
284
  try {
272
285
  await copyToClipboard(content)
@@ -296,22 +309,23 @@ async function handleChatSearch(
296
309
  globalOpts: GlobalOptions,
297
310
  searchOpts: ChatSearchOptions
298
311
  ): Promise<void> {
312
+ // Create provider from global options (JSONL or SQLite)
313
+ const provider = createProviderFromGlobalOptions(globalOpts)
314
+
299
315
  // Load sessions to search
300
- const sessions = await loadSessionRecords({
301
- root: globalOpts.root,
316
+ const sessions = await provider.loadSessionRecords({
302
317
  projectId: searchOpts.project,
303
318
  })
304
319
 
305
320
  // Search across sessions using the limit from global options
306
- const results = await searchSessionsChat(
321
+ const results = await provider.searchSessionsChat(
307
322
  sessions,
308
323
  searchOpts.query,
309
- globalOpts.root,
310
324
  { maxResults: globalOpts.limit }
311
325
  )
312
326
 
313
327
  // Add 1-based index for display
314
- const indexedResults: IndexedChatSearchResult[] = results.map((result, i) => ({
328
+ const indexedResults: IndexedChatSearchResult[] = results.map((result: ChatSearchResult, i: number) => ({
315
329
  ...result,
316
330
  index: i + 1,
317
331
  }))
@@ -7,11 +7,10 @@
7
7
  import { Command, type OptionValues } from "commander"
8
8
  import { parseGlobalOptions, type GlobalOptions } from "../index"
9
9
  import {
10
- loadProjectRecords,
11
10
  filterProjectsByState,
12
- deleteProjectMetadata,
13
11
  type ProjectRecord,
14
12
  } from "../../lib/opencode-data"
13
+ import { createProviderFromGlobalOptions } from "../../lib/opencode-data-provider"
15
14
  import {
16
15
  getOutputOptions,
17
16
  printProjectsOutput,
@@ -107,6 +106,16 @@ export function registerProjectsCommands(parent: Command): void {
107
106
  deleteOpts
108
107
  )
109
108
  })
109
+
110
+ projects.addHelpText(
111
+ "after",
112
+ [
113
+ "",
114
+ "Examples:",
115
+ " opencode-manager projects list --experimental-sqlite",
116
+ " opencode-manager projects list --db ~/.local/share/opencode/opencode.db",
117
+ ].join("\n")
118
+ )
110
119
  }
111
120
 
112
121
  /**
@@ -116,8 +125,11 @@ async function handleProjectsList(
116
125
  globalOpts: GlobalOptions,
117
126
  listOpts: ProjectsListOptions
118
127
  ): Promise<void> {
128
+ // Create data provider based on global options (JSONL or SQLite backend)
129
+ const provider = createProviderFromGlobalOptions(globalOpts)
130
+
119
131
  // Load project records from the data layer
120
- let projects = await loadProjectRecords({ root: globalOpts.root })
132
+ let projects = await provider.loadProjectRecords()
121
133
 
122
134
  // Apply missing-only filter if requested
123
135
  if (listOpts.missingOnly) {
@@ -160,10 +172,14 @@ async function handleProjectsDelete(
160
172
  ): Promise<void> {
161
173
  const outputOpts = getOutputOptions(globalOpts)
162
174
 
163
- // Resolve project ID to a project record
175
+ // Create data provider based on global options (JSONL or SQLite backend)
176
+ const provider = createProviderFromGlobalOptions(globalOpts)
177
+
178
+ // Resolve project ID to a project record (use provider for backend-agnostic resolution)
164
179
  const { project } = await resolveProjectId(deleteOpts.id, {
165
180
  root: globalOpts.root,
166
181
  allowPrefix: true,
182
+ provider,
167
183
  })
168
184
 
169
185
  const pathsToDelete = [project.filePath]
@@ -178,8 +194,8 @@ async function handleProjectsDelete(
178
194
  // Require confirmation for destructive operation
179
195
  requireConfirmation(deleteOpts.yes, "Project deletion")
180
196
 
181
- // Backup files if requested
182
- if (deleteOpts.backupDir) {
197
+ // Backup files if requested (only applies to JSONL backend - SQLite has no files to backup)
198
+ if (deleteOpts.backupDir && provider.backend === "jsonl") {
183
199
  const backupResult = await copyToBackupDir(pathsToDelete, {
184
200
  backupDir: deleteOpts.backupDir,
185
201
  prefix: "project",
@@ -201,13 +217,13 @@ async function handleProjectsDelete(
201
217
  }
202
218
  }
203
219
 
204
- // Perform the deletion
205
- const deleteResult = await deleteProjectMetadata([project], { dryRun: false })
220
+ // Perform the deletion using the provider (handles both JSONL and SQLite)
221
+ const deleteResult = await provider.deleteProjectMetadata([project], { dryRun: false })
206
222
 
207
223
  if (deleteResult.failed.length > 0) {
208
224
  throw new FileOperationError(
209
225
  `Failed to delete ${deleteResult.failed.length} file(s): ${deleteResult.failed
210
- .map((f) => `${f.path}: ${f.error}`)
226
+ .map((f: { path: string; error?: string }) => `${f.path}: ${f.error || "unknown error"}`)
211
227
  .join(", ")}`,
212
228
  "delete"
213
229
  )
@@ -8,14 +8,10 @@
8
8
  import { Command, type OptionValues } from "commander"
9
9
  import { parseGlobalOptions, type GlobalOptions } from "../index"
10
10
  import {
11
- loadSessionRecords,
12
- loadProjectRecords,
13
- deleteSessionMetadata,
14
- updateSessionTitle,
15
- moveSession,
16
11
  copySession,
17
12
  type SessionRecord,
18
13
  } from "../../lib/opencode-data"
14
+ import { createProviderFromGlobalOptions } from "../../lib/opencode-data-provider"
19
15
  import {
20
16
  getOutputOptions,
21
17
  printSessionsOutput,
@@ -195,6 +191,16 @@ export function registerSessionsCommands(parent: Command): void {
195
191
  copyOpts
196
192
  )
197
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
+ )
198
204
  }
199
205
 
200
206
  /**
@@ -217,10 +223,12 @@ async function handleSessionsList(
217
223
  globalOpts: GlobalOptions,
218
224
  listOpts: SessionsListOptions
219
225
  ): Promise<void> {
226
+ // Create data provider based on global options (JSONL or SQLite backend)
227
+ const provider = createProviderFromGlobalOptions(globalOpts)
228
+
220
229
  // Load session records from the data layer
221
230
  // If a project filter is provided, pass it to the loader
222
- let sessions = await loadSessionRecords({
223
- root: globalOpts.root,
231
+ let sessions = await provider.loadSessionRecords({
224
232
  projectId: listOpts.project,
225
233
  })
226
234
 
@@ -277,7 +285,7 @@ async function handleSessionsList(
277
285
  * Handle the sessions delete command.
278
286
  *
279
287
  * This command deletes a session's metadata file from the OpenCode storage.
280
- * It does NOT delete associated chat message files (yet).
288
+ * For SQLite backend, it deletes session, messages, and parts in a transaction.
281
289
  *
282
290
  * Exit codes:
283
291
  * - 0: Success (or dry-run completed)
@@ -291,10 +299,14 @@ async function handleSessionsDelete(
291
299
  ): Promise<void> {
292
300
  const outputOpts = getOutputOptions(globalOpts)
293
301
 
294
- // Resolve session ID to a session record
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)
295
306
  const { session } = await resolveSessionId(deleteOpts.session, {
296
307
  root: globalOpts.root,
297
308
  allowPrefix: true,
309
+ provider,
298
310
  })
299
311
 
300
312
  const pathsToDelete = [session.filePath]
@@ -309,8 +321,8 @@ async function handleSessionsDelete(
309
321
  // Require confirmation for destructive operation
310
322
  requireConfirmation(deleteOpts.yes, "Session deletion")
311
323
 
312
- // Backup files if requested
313
- if (deleteOpts.backupDir) {
324
+ // Backup files if requested (only applies to JSONL backend - SQLite has no files to backup)
325
+ if (deleteOpts.backupDir && provider.backend === "jsonl") {
314
326
  const backupResult = await copyToBackupDir(pathsToDelete, {
315
327
  backupDir: deleteOpts.backupDir,
316
328
  prefix: "session",
@@ -332,13 +344,13 @@ async function handleSessionsDelete(
332
344
  }
333
345
  }
334
346
 
335
- // Perform the deletion
336
- const deleteResult = await deleteSessionMetadata([session], { dryRun: false })
347
+ // Perform the deletion using the provider (handles both JSONL and SQLite)
348
+ const deleteResult = await provider.deleteSessionMetadata([session], { dryRun: false })
337
349
 
338
350
  if (deleteResult.failed.length > 0) {
339
351
  throw new FileOperationError(
340
352
  `Failed to delete ${deleteResult.failed.length} file(s): ${deleteResult.failed
341
- .map((f) => `${f.path}: ${f.error}`)
353
+ .map((f) => `${f.path}: ${f.error || "unknown error"}`)
342
354
  .join(", ")}`,
343
355
  "delete"
344
356
  )
@@ -356,6 +368,7 @@ async function handleSessionsDelete(
356
368
  * Handle the sessions rename command.
357
369
  *
358
370
  * This command updates a session's title in its metadata file.
371
+ * For SQLite backend, it updates the title in the database.
359
372
  *
360
373
  * Exit codes:
361
374
  * - 0: Success
@@ -374,14 +387,18 @@ async function handleSessionsRename(
374
387
  throw new UsageError("Title cannot be empty")
375
388
  }
376
389
 
377
- // Resolve session ID to a session record
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)
378
394
  const { session } = await resolveSessionId(renameOpts.session, {
379
395
  root: globalOpts.root,
380
396
  allowPrefix: true,
397
+ provider,
381
398
  })
382
399
 
383
- // Update the session title
384
- await updateSessionTitle(session.filePath, newTitle)
400
+ // Update the session title using the provider
401
+ await provider.updateSessionTitle(session, newTitle)
385
402
 
386
403
  // Output success
387
404
  printSuccessOutput(
@@ -395,7 +412,8 @@ async function handleSessionsRename(
395
412
  * Handle the sessions move command.
396
413
  *
397
414
  * This command moves a session to a different project.
398
- * The session file is moved to the target project's session directory.
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.
399
417
  *
400
418
  * Exit codes:
401
419
  * - 0: Success
@@ -408,39 +426,46 @@ async function handleSessionsMove(
408
426
  ): Promise<void> {
409
427
  const outputOpts = getOutputOptions(globalOpts)
410
428
 
411
- // Resolve session ID to a session record
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)
412
433
  const { session } = await resolveSessionId(moveOpts.session, {
413
434
  root: globalOpts.root,
414
435
  allowPrefix: true,
436
+ provider,
415
437
  })
416
438
 
417
439
  // Validate target project exists
418
440
  // Use prefix matching for convenience, but require exactly one match
419
- await resolveProjectId(moveOpts.to, {
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, {
420
444
  root: globalOpts.root,
421
445
  allowPrefix: true,
446
+ provider,
422
447
  })
423
448
 
424
449
  // Check if session is already in the target project
425
- if (session.projectId === moveOpts.to) {
450
+ if (session.projectId === targetProject.projectId) {
426
451
  printSuccessOutput(
427
- `Session ${session.sessionId} is already in project ${moveOpts.to}`,
428
- { sessionId: session.sessionId, projectId: moveOpts.to, moved: false },
452
+ `Session ${session.sessionId} is already in project ${targetProject.projectId}`,
453
+ { sessionId: session.sessionId, projectId: targetProject.projectId, moved: false },
429
454
  outputOpts.format
430
455
  )
431
456
  return
432
457
  }
433
458
 
434
- // Move the session
435
- const newRecord = await moveSession(session, moveOpts.to, globalOpts.root)
459
+ // Move the session using the provider
460
+ const newRecord = await provider.moveSession(session, targetProject.projectId)
436
461
 
437
462
  // Output success
438
463
  printSuccessOutput(
439
- `Moved session ${session.sessionId} to project ${moveOpts.to}`,
464
+ `Moved session ${session.sessionId} to project ${targetProject.projectId}`,
440
465
  {
441
466
  sessionId: session.sessionId,
442
467
  fromProject: session.projectId,
443
- toProject: moveOpts.to,
468
+ toProject: targetProject.projectId,
444
469
  newPath: newRecord.filePath,
445
470
  },
446
471
  outputOpts.format