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.
@@ -1,93 +1,98 @@
1
1
  #!/usr/bin/env python3
2
- """Launch the OpenCode metadata TUI built with OpenTUI.
2
+ """Launch the OpenCode metadata manager (TUI or CLI).
3
3
 
4
- This wrapper keeps the previous entry point name but simply shells out to the
5
- new Bun-powered React TUI located under ``src/opencode-tui.tsx``. Use
6
- ``manage_opencode_projects.py -- --help`` to see the TUI's runtime help text.
4
+ This wrapper keeps the previous entry point name but shells out to the
5
+ Bun-powered entrypoint at ``src/bin/opencode-manager.ts``. The routing logic
6
+ detects CLI subcommands (projects, sessions, chat, tokens) and passes them
7
+ directly, otherwise defaults to the TUI.
8
+
9
+ Usage:
10
+ manage_opencode_projects.py # Launch TUI (default)
11
+ manage_opencode_projects.py projects list # CLI: list projects
12
+ manage_opencode_projects.py sessions list # CLI: list sessions
13
+ manage_opencode_projects.py -- --help # Show TUI help
7
14
  """
8
15
 
9
16
  from __future__ import annotations
10
17
 
11
- import argparse
12
18
  import shutil
13
19
  import subprocess
14
20
  import sys
15
21
  from pathlib import Path
16
22
  from typing import Sequence
17
23
 
18
- DEFAULT_ROOT = Path.home() / ".local" / "share" / "opencode"
24
+ # CLI subcommands that route to the CLI module instead of TUI
25
+ CLI_SUBCOMMANDS = frozenset({"projects", "sessions", "chat", "tokens"})
26
+
19
27
  PROJECT_DIR = Path(__file__).resolve().parent
20
28
 
21
29
 
22
- def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
23
- parser = argparse.ArgumentParser(
24
- description="Open the interactive OpenCode metadata manager TUI",
25
- epilog=(
26
- "Examples:\n"
27
- " manage_opencode_projects.py\n"
28
- " Launch the TUI using the default metadata root.\n\n"
29
- " manage_opencode_projects.py --root /tmp/opencode\n"
30
- " Launch the TUI against a different storage directory.\n\n"
31
- " manage_opencode_projects.py -- --help\n"
32
- " Show the TUI's built-in CLI help output.\n"
33
- ),
34
- )
35
- parser.add_argument(
36
- "--root",
37
- type=Path,
38
- default=DEFAULT_ROOT,
39
- help="Metadata root to inspect (defaults to ~/.local/share/opencode)",
40
- )
41
- parser.add_argument(
42
- "--bun",
43
- type=Path,
44
- default=None,
45
- help="Optional path to the bun executable if it's not on PATH",
46
- )
47
- parser.add_argument(
48
- "tui_args",
49
- nargs=argparse.REMAINDER,
50
- help=(
51
- "Additional arguments forwarded to the TUI after '--'. For example: "
52
- "manage_opencode_projects.py -- --help"
53
- ),
54
- )
55
- return parser.parse_args(argv)
56
-
57
-
58
- def find_bun(explicit: Path | None) -> str:
59
- if explicit:
60
- return str(explicit)
30
+ def find_bun(explicit_path: str | None = None) -> str:
31
+ """Locate bun executable, preferring explicit path if provided."""
32
+ if explicit_path:
33
+ return explicit_path
61
34
  bun_path = shutil.which("bun")
62
35
  if bun_path:
63
36
  return bun_path
64
- raise SystemExit("bun executable not found. Please install Bun to run the TUI.")
65
-
66
-
67
- def launch_tui(root: Path, bun_exe: str, extra_args: Sequence[str]) -> int:
68
- # Normalize passthrough args: drop leading "--" if present
69
- if extra_args and len(extra_args) > 0 and extra_args[0] == "--":
70
- extra_args = extra_args[1:]
71
- cmd = [
72
- bun_exe,
73
- "run",
74
- "tui",
75
- "--",
76
- "--root",
77
- str(root.expanduser()),
78
- ]
79
- if extra_args:
80
- cmd.extend(extra_args)
37
+ raise SystemExit("bun executable not found. Please install Bun.")
38
+
39
+
40
+ def is_cli_subcommand(args: Sequence[str]) -> bool:
41
+ """Check if the first non-flag argument is a CLI subcommand."""
42
+ for arg in args:
43
+ if arg.startswith("-"):
44
+ # Skip flags like --bun, --root, etc.
45
+ continue
46
+ # First positional argument determines routing
47
+ return arg in CLI_SUBCOMMANDS
48
+ return False
49
+
81
50
 
51
+ def run_entrypoint(bun_exe: str, args: Sequence[str]) -> int:
52
+ """Run the main entrypoint with given arguments.
53
+
54
+ The TypeScript entrypoint handles all routing internally:
55
+ - CLI subcommands (projects, sessions, chat, tokens) → CLI module
56
+ - Everything else → TUI
57
+ """
58
+ # Normalize: drop leading "--" if present (legacy passthrough syntax)
59
+ args_list = list(args)
60
+ if args_list and args_list[0] == "--":
61
+ args_list = args_list[1:]
62
+
63
+ cmd = [bun_exe, "src/bin/opencode-manager.ts"] + args_list
82
64
  process = subprocess.run(cmd, cwd=PROJECT_DIR)
83
65
  return process.returncode
84
66
 
85
67
 
86
68
  def main(argv: Sequence[str] | None = None) -> int:
87
- args = parse_args(argv)
88
- bun_exe = find_bun(args.bun)
89
- extra_args = list(args.tui_args or [])
90
- return launch_tui(args.root, bun_exe, extra_args)
69
+ """Main entry point.
70
+
71
+ Parses minimal wrapper-level options (--bun) and forwards everything
72
+ else to the TypeScript entrypoint which handles TUI/CLI routing.
73
+ """
74
+ if argv is None:
75
+ argv = sys.argv[1:]
76
+
77
+ args = list(argv)
78
+ bun_exe_path: str | None = None
79
+
80
+ # Extract --bun option if present (wrapper-level option only)
81
+ filtered_args: list[str] = []
82
+ i = 0
83
+ while i < len(args):
84
+ if args[i] == "--bun" and i + 1 < len(args):
85
+ bun_exe_path = args[i + 1]
86
+ i += 2
87
+ elif args[i].startswith("--bun="):
88
+ bun_exe_path = args[i].split("=", 1)[1]
89
+ i += 1
90
+ else:
91
+ filtered_args.append(args[i])
92
+ i += 1
93
+
94
+ bun_exe = find_bun(bun_exe_path)
95
+ return run_entrypoint(bun_exe, filtered_args)
91
96
 
92
97
 
93
98
  if __name__ == "__main__":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-manager",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Terminal UI for inspecting OpenCode metadata stores.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,8 +20,9 @@
20
20
  "LICENSE"
21
21
  ],
22
22
  "scripts": {
23
- "tui": "bun src/opencode-tui.tsx",
24
- "dev": "bun --watch src/opencode-tui.tsx",
23
+ "tui": "bun src/tui/index.tsx",
24
+ "dev": "bun --watch src/tui/index.tsx",
25
+ "test": "bun test",
25
26
  "typecheck": "bunx tsc --noEmit",
26
27
  "prepublishOnly": "bun run typecheck"
27
28
  },
@@ -45,6 +46,8 @@
45
46
  "dependencies": {
46
47
  "@opentui/core": "^0.1.44",
47
48
  "@opentui/react": "^0.1.44",
49
+ "commander": "^12.0.0",
50
+ "fast-fuzzy": "^1.12.0",
48
51
  "react": "^19.0.0"
49
52
  },
50
53
  "devDependencies": {
@@ -1,4 +1,134 @@
1
1
  #!/usr/bin/env bun
2
- // Bun-native CLI entry that simply boots the TUI module.
3
- // Keeping this file tiny lets `bun x opencode-manager` launch instantly without extra bundling.
4
- import "../opencode-tui"
2
+ /**
3
+ * Main CLI entrypoint for opencode-manager.
4
+ *
5
+ * Routes between TUI and CLI modes based on provided subcommands:
6
+ * - No subcommand → shows help
7
+ * - "tui" subcommand → launches TUI
8
+ * - CLI subcommands (projects, sessions, chat, tokens) → launches CLI
9
+ *
10
+ * Uses dynamic imports to keep initial load fast and avoid loading
11
+ * unused modules.
12
+ */
13
+
14
+ // Known CLI subcommands that should route to the CLI module
15
+ const CLI_SUBCOMMANDS = new Set([
16
+ "projects",
17
+ "sessions",
18
+ "chat",
19
+ "tokens",
20
+ ])
21
+
22
+ // Subcommands that explicitly request TUI
23
+ const TUI_SUBCOMMANDS = new Set(["tui"])
24
+
25
+ // Version from package.json
26
+ const VERSION = "0.4.0"
27
+
28
+ function printHelp(): void {
29
+ console.log(`opencode-manager v${VERSION}
30
+
31
+ Inspect and manage OpenCode metadata stores via TUI or CLI.
32
+
33
+ USAGE:
34
+ opencode-manager [command] [options]
35
+
36
+ MODES:
37
+ tui Launch interactive TUI (terminal user interface)
38
+ <command> Run CLI command (see below)
39
+
40
+ CLI COMMANDS:
41
+ projects list List all projects
42
+ projects delete Delete a project's metadata
43
+
44
+ sessions list List sessions (optionally filter by project)
45
+ sessions delete Delete a session's metadata
46
+ sessions rename Rename a session
47
+ sessions move Move a session to another project
48
+ sessions copy Copy a session to another project
49
+
50
+ chat list List messages in a session
51
+ chat show Show a specific message
52
+ chat search Search chat content across sessions
53
+
54
+ tokens session Show token usage for a session
55
+ tokens project Show token usage for a project
56
+ tokens global Show global token usage
57
+
58
+ OPTIONS:
59
+ --help, -h Show this help
60
+ --version, -v Show version
61
+
62
+ EXAMPLES:
63
+ opencode-manager tui # Launch TUI
64
+ opencode-manager projects list --format json # List projects as JSON
65
+ opencode-manager sessions list --project X # List sessions for project
66
+ opencode-manager chat search --query "error" # Search chat content
67
+
68
+ For detailed help on any command:
69
+ opencode-manager <command> --help
70
+ opencode-manager <command> <subcommand> --help
71
+ `)
72
+ }
73
+
74
+ function printVersion(): void {
75
+ console.log(VERSION)
76
+ }
77
+
78
+ async function main(): Promise<void> {
79
+ const args = process.argv.slice(2)
80
+ const firstArg = args[0]
81
+
82
+ // Handle no args, --help, or -h → show help
83
+ if (!firstArg || firstArg === "--help" || firstArg === "-h") {
84
+ printHelp()
85
+ return
86
+ }
87
+
88
+ // Handle --version or -v
89
+ if (firstArg === "--version" || firstArg === "-v") {
90
+ printVersion()
91
+ return
92
+ }
93
+
94
+ // Determine routing based on first argument
95
+ const isCliSubcommand = CLI_SUBCOMMANDS.has(firstArg)
96
+ const isTuiSubcommand = TUI_SUBCOMMANDS.has(firstArg)
97
+
98
+ if (isCliSubcommand) {
99
+ // Route to CLI module (dynamically imported)
100
+ // Using string path to avoid TypeScript errors before CLI module exists
101
+ const cliModulePath = "../cli/index"
102
+ try {
103
+ const cliModule = await import(cliModulePath)
104
+ if (typeof cliModule.runCLI !== "function") {
105
+ throw new Error("CLI module missing runCLI export")
106
+ }
107
+ await cliModule.runCLI(args)
108
+ } catch (error) {
109
+ const errCode = (error as NodeJS.ErrnoException).code
110
+ const errMessage = (error as Error).message
111
+ if (errCode === "ERR_MODULE_NOT_FOUND" || errMessage.includes("Cannot find module")) {
112
+ console.error(`CLI module not yet implemented. Subcommand: ${firstArg}`)
113
+ console.error("Run without subcommand to launch TUI, or use --help for usage.")
114
+ process.exit(1)
115
+ }
116
+ throw error
117
+ }
118
+ } else if (isTuiSubcommand) {
119
+ // Explicit TUI request - strip "tui" subcommand so TUI args parser doesn't see it
120
+ const tuiArgs = args.slice(1)
121
+ const { bootstrap } = await import("../tui/index")
122
+ await bootstrap(tuiArgs)
123
+ } else {
124
+ // Unknown command
125
+ console.error(`Unknown command: ${firstArg}\n`)
126
+ printHelp()
127
+ process.exit(1)
128
+ }
129
+ }
130
+
131
+ main().catch((error) => {
132
+ console.error(error)
133
+ process.exit(1)
134
+ })
@@ -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
+ }