opencode-hub 1.0.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.
Files changed (4) hide show
  1. package/README.md +119 -0
  2. package/oc-tui.js +1108 -0
  3. package/package.json +29 -0
  4. package/plugin.js +130 -0
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # opencode-hub
2
+
3
+ TUI launcher and `oc` shell command for [OpenCode](https://github.com/sst/opencode).
4
+
5
+ When loaded as an OpenCode plugin, it installs the `oc` command into your shell. Running `oc` opens an interactive TUI for switching between projects and managing plugins.
6
+
7
+ ## Features
8
+
9
+ - **Project list** — shows recent OpenCode projects sorted by last used, with session counts
10
+ - **Pin / Hide / Unhide** — organize your project list
11
+ - **Custom path** — open any directory directly
12
+ - **Change path** — reassociate sessions when a project moves
13
+ - **Plugin manager** — view plugin status, toggle auto-update, force rebuild, downgrade to specific commits
14
+ - **Auto-update OpenCode** — checks for new `opencode-ai` npm versions once per day
15
+ - **Centralized config** — stores config in `config/oc-config.json`, plugins in `config/plugins.json`
16
+ - **`<creator>/<repo>` layout** — plugin repos stored under `repos/<github-user>/<repo-name>` to prevent collisions
17
+
18
+ ## Requirements
19
+
20
+ - [Bun](https://bun.sh/) runtime (uses `bun:sqlite` for reading the OpenCode session database)
21
+
22
+ ## Installation
23
+
24
+ ### Option A — Via plugin-updater (recommended)
25
+
26
+ If you have [opencode-plugin-updater](https://github.com/intisy/opencode-plugin-updater) installed, add this entry to `~/.config/opencode/config/plugins.json`:
27
+
28
+ ```json
29
+ {
30
+ "name": "opencode-hub",
31
+ "url": "https://github.com/intisy/opencode-hub.git",
32
+ "install": null,
33
+ "build": null,
34
+ "bundle": null,
35
+ "output": "plugin.js",
36
+ "pluginFile": "oc-launcher.js",
37
+ "autoUpdate": true
38
+ }
39
+ ```
40
+
41
+ Restart OpenCode. The updater will clone the repo and deploy the plugin automatically.
42
+
43
+ ### Option B — npm
44
+
45
+ Add the package to your `~/.config/opencode/opencode.json`:
46
+
47
+ ```jsonc
48
+ {
49
+ "plugins": ["opencode-hub@latest"]
50
+ }
51
+ ```
52
+
53
+ Restart OpenCode.
54
+
55
+ ### Option C — Manual
56
+
57
+ ```bash
58
+ mkdir -p ~/.config/opencode/repos/intisy/opencode-hub
59
+ git clone https://github.com/intisy/opencode-hub.git ~/.config/opencode/repos/intisy/opencode-hub
60
+ cp ~/.config/opencode/repos/intisy/opencode-hub/plugin.js ~/.config/opencode/plugins/oc-launcher.js
61
+ ```
62
+
63
+ Register the plugin in `~/.config/opencode/opencode.json`:
64
+
65
+ ```jsonc
66
+ {
67
+ "plugins": {
68
+ "oc-launcher": "./plugins/oc-launcher.js"
69
+ }
70
+ }
71
+ ```
72
+
73
+ ## How It Works
74
+
75
+ 1. **On OpenCode startup** — the plugin installs `oc` (or `oc.cmd` on Windows) into `~/.local/bin/`
76
+ 2. **When you run `oc`** — the TUI launcher opens, showing your projects and plugins
77
+ 3. **Select a project** — the launcher `cd`s into the directory and starts `opencode`
78
+
79
+ The plugin also provides an `oc_remove` tool to uninstall the shell command.
80
+
81
+ ## Usage
82
+
83
+ ```bash
84
+ oc # Launch TUI
85
+ oc 3 # Open project #3 directly
86
+ oc myproject # Open first project matching "myproject"
87
+ ```
88
+
89
+ ### Keyboard shortcuts
90
+
91
+ #### Projects tab
92
+
93
+ | Key | Action |
94
+ |-----|--------|
95
+ | ↑↓ / W S | Navigate |
96
+ | Enter | Open action menu |
97
+ | O | Open project |
98
+ | P | Pin/Unpin |
99
+ | H | Hide |
100
+ | U | Unhide all |
101
+ | C | Custom path |
102
+ | ← → | Switch tabs |
103
+ | Q | Quit |
104
+
105
+ #### Plugins tab
106
+
107
+ | Key | Action |
108
+ |-----|--------|
109
+ | ↑↓ / W S | Navigate |
110
+ | Enter | Open action menu |
111
+ | F | Fetch remote updates |
112
+ | A | Toggle auto-update |
113
+ | U | Update plugin |
114
+ | Q | Quit |
115
+
116
+ ## License
117
+
118
+ MIT
119
+
package/oc-tui.js ADDED
@@ -0,0 +1,1108 @@
1
+ #!/usr/bin/env bun
2
+ import { Database } from "bun:sqlite";
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from "fs";
4
+ import { execSync } from "child_process";
5
+ import { join, dirname } from "path";
6
+ import { homedir } from "os";
7
+
8
+ var HOME = homedir();
9
+ var CONFIG_DIR = join(HOME, ".config", "opencode");
10
+ var DB_PATH = join(HOME, ".local", "share", "opencode", "opencode.db");
11
+ var CONFIG_FOLDER = join(CONFIG_DIR, "config");
12
+ var CONFIG_PATH = join(CONFIG_FOLDER, "oc-config.json");
13
+ var UPDATE_CHECK_PATH = join(CONFIG_DIR, "oc-last-update-check");
14
+ var PLUGINS_JSON = join(CONFIG_FOLDER, "plugins.json");
15
+ var REPOS_DIR = join(CONFIG_DIR, "repos");
16
+ var PLUGINS_DIR = join(CONFIG_DIR, "plugins");
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Folder name helper: <creator>/<repo-name> to avoid collisions
20
+ // ---------------------------------------------------------------------------
21
+
22
+ function getFolderName(plugin) {
23
+ var match = (plugin.url || "").match(/github\.com\/([^\/]+)\/([^\/\.]+)/);
24
+ if (match) return match[1] + "/" + plugin.name;
25
+ return plugin.name;
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Migration: move legacy config files into config/
30
+ // ---------------------------------------------------------------------------
31
+
32
+ function migrateConfigs() {
33
+ if (!existsSync(CONFIG_FOLDER)) try { mkdirSync(CONFIG_FOLDER, { recursive: true }); } catch {}
34
+ var legacyConfig = join(CONFIG_DIR, "oc-config.json");
35
+ if (existsSync(legacyConfig) && !existsSync(CONFIG_PATH)) {
36
+ try { copyFileSync(legacyConfig, CONFIG_PATH); } catch {}
37
+ }
38
+ var legacyPlugins = join(CONFIG_DIR, "plugins.json");
39
+ if (existsSync(legacyPlugins) && !existsSync(PLUGINS_JSON)) {
40
+ try { copyFileSync(legacyPlugins, PLUGINS_JSON); } catch {}
41
+ }
42
+ }
43
+
44
+ migrateConfigs();
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Auto-update OpenCode itself
48
+ // ---------------------------------------------------------------------------
49
+
50
+ function checkForUpdates() {
51
+ try {
52
+ if (existsSync(UPDATE_CHECK_PATH)) {
53
+ var lastCheck = parseInt(readFileSync(UPDATE_CHECK_PATH, "utf-8").trim(), 10);
54
+ if (Date.now() - lastCheck < 86400000) return;
55
+ }
56
+
57
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
58
+ writeFileSync(UPDATE_CHECK_PATH, String(Date.now()));
59
+
60
+ var installed = execSync("opencode --version", { encoding: "utf-8", timeout: 10000 }).trim();
61
+ var latest = execSync("npm view opencode-ai version", { encoding: "utf-8", timeout: 15000 }).trim();
62
+
63
+ if (!latest || !installed || latest === installed) return;
64
+
65
+ process.stderr.write("\x1b[33m > Updating OpenCode: " + installed + " -> " + latest + "\x1b[0m\n");
66
+ execSync("npm install -g opencode-ai@latest", { stdio: "inherit", timeout: 120000 });
67
+ process.stderr.write("\x1b[32m > Updated to " + latest + "\x1b[0m\n\n");
68
+ } catch (e) {}
69
+ }
70
+
71
+ checkForUpdates();
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Project launcher data
75
+ // ---------------------------------------------------------------------------
76
+
77
+ function loadConfig() {
78
+ try { if (existsSync(CONFIG_PATH)) return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); } catch {}
79
+ // Legacy fallback
80
+ var legacy = join(CONFIG_DIR, "oc-config.json");
81
+ try { if (existsSync(legacy)) return JSON.parse(readFileSync(legacy, "utf-8")); } catch {}
82
+ return { pinned: [], hidden: [] };
83
+ }
84
+ function saveConfig(cfg) {
85
+ try {
86
+ if (!existsSync(CONFIG_FOLDER)) mkdirSync(CONFIG_FOLDER, { recursive: true });
87
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
88
+ } catch {}
89
+ }
90
+
91
+ function queryProjects() {
92
+ if (!existsSync(DB_PATH)) return [];
93
+ try {
94
+ var db = new Database(DB_PATH, { readonly: true });
95
+ var rows = db.query(
96
+ "SELECT directory, MAX(time_updated) as last_used, COUNT(*) as sessions " +
97
+ "FROM session WHERE parent_id IS NULL GROUP BY directory ORDER BY last_used DESC LIMIT 30"
98
+ ).all();
99
+ db.close();
100
+ return rows;
101
+ } catch { return []; }
102
+ }
103
+
104
+ function timeAgo(ts) {
105
+ if (!ts) return "--";
106
+ var d = Date.now() - ts;
107
+ if (d < 60000) return "now";
108
+ if (d < 3600000) return Math.floor(d / 60000) + "m ago";
109
+ if (d < 86400000) return Math.floor(d / 3600000) + "h ago";
110
+ return Math.floor(d / 86400000) + "d ago";
111
+ }
112
+
113
+ function shortPath(dir) {
114
+ var h = HOME.replace(/\\/g, "/");
115
+ var d = dir.replace(/\\/g, "/");
116
+ if (d.startsWith(h)) d = "~" + d.substring(h.length);
117
+ return d;
118
+ }
119
+
120
+ function pad(s, len) {
121
+ s = String(s || "");
122
+ while (s.length < len) s += " ";
123
+ return s.substring(0, len);
124
+ }
125
+
126
+ function trunc(s, max) {
127
+ if (!s) return "";
128
+ if (s.length <= max) return s;
129
+ return s.substring(0, max - 1) + "…";
130
+ }
131
+
132
+ function buildList() {
133
+ var cfg = loadConfig();
134
+ var rows = queryProjects();
135
+ var list = [];
136
+
137
+ var pinnedItems = [];
138
+ for (var dir of cfg.pinned) {
139
+ var row = rows.find(function(r) { return r.directory === dir; });
140
+ if (cfg.hidden.indexOf(dir) !== -1) continue;
141
+ pinnedItems.push({
142
+ dir: dir,
143
+ name: dir.split(/[\\/]/).pop() || dir,
144
+ sessions: row ? row.sessions : 0,
145
+ lastUsed: row ? row.last_used : 0,
146
+ pinned: true
147
+ });
148
+ }
149
+ pinnedItems.sort(function(a, b) { return (b.lastUsed || 0) - (a.lastUsed || 0); });
150
+ for (var pi = 0; pi < pinnedItems.length; pi++) { list.push(pinnedItems[pi]); }
151
+
152
+ for (var i = 0; i < rows.length; i++) {
153
+ var r = rows[i];
154
+ if (cfg.pinned.indexOf(r.directory) !== -1) continue;
155
+ if (cfg.hidden.indexOf(r.directory) !== -1) continue;
156
+ list.push({
157
+ dir: r.directory,
158
+ name: r.directory.split(/[\\/]/).pop() || r.directory,
159
+ sessions: r.sessions,
160
+ lastUsed: r.last_used,
161
+ pinned: false
162
+ });
163
+ }
164
+ return list;
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Plugin data
169
+ // ---------------------------------------------------------------------------
170
+
171
+ function loadPlugins() {
172
+ try { if (existsSync(PLUGINS_JSON)) return JSON.parse(readFileSync(PLUGINS_JSON, "utf-8")); } catch {}
173
+ // Legacy fallback
174
+ var legacy = join(CONFIG_DIR, "plugins.json");
175
+ try { if (existsSync(legacy)) return JSON.parse(readFileSync(legacy, "utf-8")); } catch {}
176
+ return [];
177
+ }
178
+
179
+ function savePlugins(plugins) {
180
+ if (!existsSync(CONFIG_FOLDER)) try { mkdirSync(CONFIG_FOLDER, { recursive: true }); } catch {}
181
+ writeFileSync(PLUGINS_JSON, JSON.stringify(plugins, null, 2), "utf-8");
182
+ // Dual-write for backward compat
183
+ try { writeFileSync(join(CONFIG_DIR, "plugins.json"), JSON.stringify(plugins, null, 2), "utf-8"); } catch {}
184
+ }
185
+
186
+ function gitText(args, cwd) {
187
+ try {
188
+ var out = execSync(args.join(" "), { cwd: cwd, encoding: "utf-8", timeout: 15000, stdio: ["ignore", "pipe", "ignore"] });
189
+ return out.trim();
190
+ } catch { return ""; }
191
+ }
192
+
193
+ function buildPluginList() {
194
+ var plugins = loadPlugins();
195
+ var list = [];
196
+ for (var p of plugins) {
197
+ var folderName = getFolderName(p);
198
+ var dir = join(REPOS_DIR, folderName);
199
+ var installed = existsSync(dir);
200
+ var deployed = existsSync(join(PLUGINS_DIR, p.pluginFile));
201
+ var localHead = "";
202
+ var remoteHead = "";
203
+ var subject = "";
204
+ var updateAvail = false;
205
+
206
+ if (installed) {
207
+ localHead = gitText(["git", "rev-parse", "HEAD"], dir);
208
+ subject = gitText(["git", "log", "-1", "--format=%s"], dir);
209
+ }
210
+
211
+ list.push({
212
+ name: p.name,
213
+ folderName: folderName,
214
+ url: p.url,
215
+ autoUpdate: p.autoUpdate !== false,
216
+ installed: installed,
217
+ deployed: deployed,
218
+ localHead: localHead,
219
+ remoteHead: remoteHead,
220
+ subject: subject,
221
+ updateAvail: updateAvail,
222
+ hasBuild: !!(p.build || p.bundle),
223
+ pluginFile: p.pluginFile,
224
+ _raw: p
225
+ });
226
+ }
227
+ return list;
228
+ }
229
+
230
+ function fetchPluginRemotes(pluginItems) {
231
+ for (var p of pluginItems) {
232
+ if (!p.installed) continue;
233
+ var dir = join(REPOS_DIR, p.folderName);
234
+ gitText(["git", "fetch", "origin"], dir);
235
+ for (var ref of ["origin/HEAD", "origin/main", "origin/master"]) {
236
+ var h = gitText(["git", "rev-parse", ref], dir);
237
+ if (h) { p.remoteHead = h; break; }
238
+ }
239
+ p.updateAvail = !!(p.localHead && p.remoteHead && p.localHead !== p.remoteHead);
240
+ }
241
+ }
242
+
243
+ function runPluginUpdate(pluginItem) {
244
+ var plugins = loadPlugins();
245
+ var repo = plugins.find(function(r) { return r.name === pluginItem.name; });
246
+ if (!repo) return "Plugin not found in plugins.json";
247
+
248
+ var folderName = getFolderName(repo);
249
+ var dir = join(REPOS_DIR, folderName);
250
+
251
+ if (!existsSync(dir)) {
252
+ var parentDir = dirname(dir);
253
+ if (!existsSync(parentDir)) try { mkdirSync(parentDir, { recursive: true }); } catch {}
254
+ try {
255
+ execSync("git clone " + repo.url + " " + folderName, { cwd: REPOS_DIR, timeout: 60000, stdio: "ignore" });
256
+ } catch (e) { return "Clone failed: " + (e.message || e); }
257
+ }
258
+
259
+ try { execSync("git pull --ff-only", { cwd: dir, timeout: 30000, stdio: "ignore" }); } catch {}
260
+
261
+ if (repo.install) {
262
+ try { execSync(repo.install.join(" "), { cwd: dir, timeout: 120000, stdio: "ignore" }); }
263
+ catch (e) { return "Install failed"; }
264
+ }
265
+ if (repo.build) {
266
+ try { execSync(repo.build.join(" "), { cwd: dir, timeout: 120000, stdio: "ignore" }); }
267
+ catch (e) { return "Build failed"; }
268
+ }
269
+ if (repo.bundle) {
270
+ try { execSync(repo.bundle.join(" "), { cwd: dir, timeout: 120000, stdio: "ignore" }); }
271
+ catch (e) { return "Bundle failed"; }
272
+ }
273
+
274
+ var outputPath = join(dir, repo.output);
275
+ var destPath = join(PLUGINS_DIR, repo.pluginFile);
276
+
277
+ if (!existsSync(PLUGINS_DIR)) try { mkdirSync(PLUGINS_DIR, { recursive: true }); } catch {}
278
+
279
+ if (existsSync(outputPath)) {
280
+ try { copyFileSync(outputPath, destPath); }
281
+ catch (e) { return "Copy failed"; }
282
+ } else {
283
+ return "Build output not found: " + repo.output;
284
+ }
285
+
286
+ return null; // success
287
+ }
288
+
289
+ // ---------------------------------------------------------------------------
290
+ // ANSI
291
+ // ---------------------------------------------------------------------------
292
+
293
+ var E = "\x1b[";
294
+ var RST = E + "0m";
295
+ var BOLD = E + "1m";
296
+ var DIM = E + "2m";
297
+ var GRAY = E + "90m";
298
+ var WHITE = E + "37m";
299
+ var YELLOW = E + "33m";
300
+ var GREEN = E + "32m";
301
+ var CYAN = E + "36m";
302
+ var RED = E + "31m";
303
+ var BLUE = E + "34m";
304
+ var MAGENTA = E + "35m";
305
+ var BG_SEL = E + "48;5;236m";
306
+ var CLR = E + "K";
307
+
308
+ var _buf = "";
309
+ function b(s) { _buf += s; }
310
+ function flush() { process.stderr.write(_buf); _buf = ""; }
311
+ function hideCur() { process.stderr.write(E + "?25l"); }
312
+ function showCur() { process.stderr.write(E + "?25h"); }
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // State
316
+ // ---------------------------------------------------------------------------
317
+
318
+ var items = buildList();
319
+ var pluginItems = buildPluginList();
320
+ var cursor = 0;
321
+ var pcursor = 0; // plugin page cursor
322
+ var mode = "list"; // "list" | "actions" | "input" | "pactions"
323
+ var page = "projects"; // "projects" | "plugins"
324
+ var acursor = 0;
325
+ var pacursor = 0; // plugin action cursor
326
+ var message = "";
327
+ var msgTimeout = null;
328
+ var scrollOff = 0;
329
+ var pscrollOff = 0;
330
+ var inputBuf = "";
331
+ var chpathDir = "";
332
+ var pluginFetched = false;
333
+ var pluginUpdating = "";
334
+ var commitItems = [];
335
+ var ccursor = 0;
336
+ var cscrollOff = 0;
337
+
338
+ function flash(msg) {
339
+ message = msg;
340
+ if (msgTimeout) clearTimeout(msgTimeout);
341
+ msgTimeout = setTimeout(function() { message = ""; render(); }, 2500);
342
+ }
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // Project actions
346
+ // ---------------------------------------------------------------------------
347
+
348
+ function getActions(item) {
349
+ var a = [
350
+ { key: "open", label: "Open in OpenCode", icon: ">" },
351
+ ];
352
+ if (item.pinned) {
353
+ a.push({ key: "unpin", label: "Unpin from favorites", icon: "x" });
354
+ } else {
355
+ a.push({ key: "pin", label: "Pin to favorites", icon: "*" });
356
+ }
357
+ a.push({ key: "hide", label: "Hide from list", icon: "-" });
358
+ a.push({ key: "chpath", label: "Change path", icon: "~" });
359
+ a.push({ key: "unhide", label: "Show hidden projects", icon: "+" });
360
+ a.push({ key: "cancel", label: "Cancel", icon: "<" });
361
+ return a;
362
+ }
363
+
364
+ function getPluginActions(pitem) {
365
+ var a = [];
366
+ if (pitem.updateAvail || !pitem.deployed) {
367
+ a.push({ key: "update", label: "Update now" });
368
+ }
369
+ if (pitem.autoUpdate) {
370
+ a.push({ key: "disable-auto", label: "Disable auto-update" });
371
+ } else {
372
+ a.push({ key: "enable-auto", label: "Enable auto-update" });
373
+ }
374
+ a.push({ key: "force-update", label: "Force rebuild & deploy" });
375
+ a.push({ key: "commits", label: "Select specific commit (Downgrade)" });
376
+ a.push({ key: "cancel", label: "Cancel" });
377
+ return a;
378
+ }
379
+
380
+ function outputDir(dir) {
381
+ var outFile = process.env.OC_OUTPUT;
382
+ if (outFile) {
383
+ writeFileSync(outFile, dir, "utf-8");
384
+ } else {
385
+ process.stdout.write(dir);
386
+ }
387
+ }
388
+
389
+ function openProject(item) {
390
+ cleanup();
391
+ outputDir(item.dir);
392
+ process.exit(0);
393
+ }
394
+
395
+ function togglePin(idx) {
396
+ var item = items[idx];
397
+ var cfg = loadConfig();
398
+ if (item.pinned) {
399
+ cfg.pinned = cfg.pinned.filter(function(d) { return d !== item.dir; });
400
+ flash("Unpinned: " + item.name);
401
+ } else {
402
+ cfg.pinned.push(item.dir);
403
+ flash("Pinned: " + item.name);
404
+ }
405
+ saveConfig(cfg);
406
+ items = buildList();
407
+ if (cursor >= items.length) cursor = Math.max(0, items.length - 1);
408
+ }
409
+
410
+ function hideItem(idx) {
411
+ var item = items[idx];
412
+ var cfg = loadConfig();
413
+ if (cfg.hidden.indexOf(item.dir) === -1) cfg.hidden.push(item.dir);
414
+ cfg.pinned = cfg.pinned.filter(function(d) { return d !== item.dir; });
415
+ saveConfig(cfg);
416
+ flash("Hidden: " + item.name);
417
+ items = buildList();
418
+ if (cursor >= items.length) cursor = Math.max(0, items.length - 1);
419
+ }
420
+
421
+ function unhideAll() {
422
+ var cfg = loadConfig();
423
+ var count = cfg.hidden.length;
424
+ cfg.hidden = [];
425
+ saveConfig(cfg);
426
+ flash("Restored " + count + " hidden project(s)");
427
+ items = buildList();
428
+ if (cursor >= items.length) cursor = Math.max(0, items.length - 1);
429
+ }
430
+
431
+ function getProjectId(dir) {
432
+ try {
433
+ var root = execSync("git rev-list --max-parents=0 HEAD", { cwd: dir, encoding: "utf-8", timeout: 5000 });
434
+ var lines = root.trim().split("\n").filter(Boolean).map(function(x) { return x.trim(); }).sort();
435
+ return lines[0] || null;
436
+ } catch (e) { return null; }
437
+ }
438
+
439
+ function changeProjectPath(oldDir, newDir) {
440
+ if (!existsSync(DB_PATH)) { flash("DB not found"); return; }
441
+ try {
442
+ var db = new Database(DB_PATH);
443
+ var count = db.query("SELECT COUNT(*) as c FROM session WHERE directory = ?").get(oldDir);
444
+ if (!count || count.c === 0) { db.close(); flash("No sessions at old path"); return; }
445
+
446
+ var oldSess = db.query("SELECT project_id FROM session WHERE directory = ? LIMIT 1").get(oldDir);
447
+ var oldPid = oldSess.project_id;
448
+ var newPid = getProjectId(newDir);
449
+
450
+ if (newPid) {
451
+ var existing = db.query("SELECT id FROM project WHERE id = ?").get(newPid);
452
+ if (existing) {
453
+ db.run("UPDATE session SET project_id = ?, directory = ? WHERE directory = ?", [newPid, newDir, oldDir]);
454
+ } else if (oldPid !== "global") {
455
+ db.run("UPDATE project SET id = ?, worktree = ? WHERE id = ?", [newPid, newDir, oldPid]);
456
+ db.run("UPDATE session SET project_id = ?, directory = ? WHERE directory = ?", [newPid, newDir, oldDir]);
457
+ } else {
458
+ var now = Date.now();
459
+ db.run("INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES (?, ?, ?, ?, '[]')", [newPid, newDir, now, now]);
460
+ db.run("UPDATE session SET project_id = ?, directory = ? WHERE directory = ?", [newPid, newDir, oldDir]);
461
+ }
462
+ try {
463
+ var gitDir = join(newDir, ".git");
464
+ if (existsSync(gitDir)) writeFileSync(join(gitDir, "opencode"), newPid);
465
+ } catch (e) {}
466
+ } else {
467
+ db.run("UPDATE session SET project_id = 'global', directory = ? WHERE directory = ?", [newDir, oldDir]);
468
+ }
469
+
470
+ if (oldPid !== "global" && oldPid !== newPid) {
471
+ var rem = db.query("SELECT COUNT(*) as c FROM session WHERE project_id = ?").get(oldPid);
472
+ if (!rem || rem.c === 0) db.run("DELETE FROM project WHERE id = ?", [oldPid]);
473
+ }
474
+
475
+ db.close();
476
+ var cfg = loadConfig();
477
+ var pidx = cfg.pinned.indexOf(oldDir);
478
+ if (pidx !== -1) cfg.pinned[pidx] = newDir;
479
+ var hidx = cfg.hidden.indexOf(oldDir);
480
+ if (hidx !== -1) cfg.hidden[hidx] = newDir;
481
+ saveConfig(cfg);
482
+ flash("Moved " + count.c + " sessions to new path");
483
+ items = buildList();
484
+ if (cursor >= items.length) cursor = Math.max(0, items.length - 1);
485
+ } catch (e) {
486
+ flash("Error: " + (e.message || e));
487
+ }
488
+ }
489
+
490
+ // ---------------------------------------------------------------------------
491
+ // Render: projects page
492
+ // ---------------------------------------------------------------------------
493
+
494
+ function buildProjectItem(pushBody, i, item, nameW, cols, isSelected) {
495
+ var sel = i === cursor;
496
+ var arrow = sel ? (YELLOW + " > " + RST) : " ";
497
+ var bg = sel ? BG_SEL : "";
498
+ var nameStyle = sel ? (BOLD + WHITE) : DIM;
499
+ var sessStr = GRAY + pad(item.sessions + " sess", 8) + RST;
500
+ var timeStr = GRAY + pad(timeAgo(item.lastUsed), 9) + RST;
501
+ var pinMark = item.pinned ? (YELLOW + " *" + RST) : "";
502
+
503
+ pushBody(" " + bg + arrow + nameStyle + pad(trunc(item.name, nameW), nameW) + RST + bg + sessStr + timeStr + pinMark + RST, isSelected);
504
+
505
+ if (sel && (mode === "list" || mode === "actions")) {
506
+ pushBody(" " + GRAY + " " + trunc(shortPath(item.dir), cols - 10) + RST, isSelected);
507
+ }
508
+
509
+ if (sel && mode === "actions") {
510
+ pushBody("", isSelected);
511
+ var acts = getActions(item);
512
+ for (var j = 0; j < acts.length; j++) {
513
+ var a = acts[j];
514
+ var aSel = j === acursor;
515
+ var lbl = trunc(a.label, cols - 12);
516
+ if (aSel) {
517
+ pushBody(" " + GREEN + " > " + BOLD + lbl + RST, isSelected);
518
+ } else {
519
+ pushBody(" " + GRAY + " " + lbl + RST, isSelected);
520
+ }
521
+ }
522
+ pushBody("", isSelected);
523
+ }
524
+ }
525
+
526
+ function buildProjects(pushBody, pushFoot, cols, barW) {
527
+ var nameW = Math.min(28, Math.max(16, cols - 36));
528
+
529
+ if (items.length === 0) {
530
+ pushBody(" " + GRAY + "No projects found." + RST, false);
531
+ pushBody(" " + GRAY + "Use OpenCode in a directory first, then come back." + RST, false);
532
+ pushBody("", false);
533
+
534
+ pushFoot(" " + GRAY + "-".repeat(barW) + RST);
535
+ pushFoot(" " + GRAY + "Q" + RST + " Quit " + GRAY + "U" + RST + " Unhide all");
536
+ return;
537
+ }
538
+
539
+ var pinnedCount = 0;
540
+ for (var i = 0; i < items.length; i++) { if (items[i].pinned) pinnedCount++; }
541
+ var recentCount = items.length - pinnedCount;
542
+
543
+ if (pinnedCount > 0) {
544
+ pushBody(" " + YELLOW + "*" + GRAY + " Pinned" + RST, false);
545
+ for (var i = 0; i < pinnedCount; i++) {
546
+ buildProjectItem(pushBody, i, items[i], nameW, cols, i === cursor);
547
+ }
548
+ }
549
+
550
+ if (pinnedCount > 0 && recentCount > 0) pushBody("", false);
551
+
552
+ if (recentCount > 0) {
553
+ var countLabel = recentCount > 0 ? " (" + recentCount + ")" : "";
554
+ pushBody(" " + BLUE + "~" + GRAY + " Recent" + countLabel + RST, false);
555
+ for (var i = pinnedCount; i < items.length; i++) {
556
+ buildProjectItem(pushBody, i, items[i], nameW, cols, i === cursor);
557
+ }
558
+ }
559
+
560
+ pushBody("", false);
561
+
562
+ if (message) {
563
+ pushFoot(" " + GREEN + " " + trunc(message, cols - 5) + RST);
564
+ }
565
+ pushFoot(" " + GRAY + "-".repeat(barW) + RST);
566
+
567
+ if (mode === "input") {
568
+ var inputLabel = chpathDir ? "New path: " : "Path: ";
569
+ var maxInput = Math.max(10, cols - 15 - inputLabel.length);
570
+ var displayInput = inputBuf.length > maxInput ? "…" + inputBuf.substring(inputBuf.length - maxInput + 1) : inputBuf;
571
+ pushFoot(" " + CYAN + inputLabel + RST + displayInput + BOLD + "|" + RST);
572
+ pushFoot(" " + DIM + "Enter" + RST + " Confirm " + DIM + "Esc" + RST + " Cancel" + RST);
573
+ } else if (mode === "list") {
574
+ pushFoot(" " + DIM + "^v" + RST + "/" + DIM + "WS" + RST + " Move " +
575
+ DIM + "Enter" + RST + " Select " +
576
+ DIM + "P" + RST + " Pin " +
577
+ DIM + "H" + RST + " Hide " +
578
+ DIM + "O" + RST + " Open " +
579
+ DIM + "C" + RST + " Custom " +
580
+ DIM + "Q" + RST + " Quit" + RST);
581
+ } else {
582
+ pushFoot(" " + DIM + "^v" + RST + "/" + DIM + "WS" + RST + " Move " +
583
+ DIM + "Enter" + RST + " Confirm " +
584
+ DIM + "Esc" + RST + " Back" + RST);
585
+ }
586
+ }
587
+
588
+ // ---------------------------------------------------------------------------
589
+ // Render: plugins page
590
+ // ---------------------------------------------------------------------------
591
+
592
+ function buildPluginItem(pushBody, i, pitem, nameW, cols, isSelected) {
593
+ var sel = i === pcursor;
594
+ var arrow = sel ? (YELLOW + " > " + RST) : " ";
595
+ var bg = sel ? BG_SEL : "";
596
+ var nameStyle = sel ? (BOLD + WHITE) : DIM;
597
+
598
+ var statusParts = [];
599
+ if (pitem.autoUpdate) {
600
+ statusParts.push(GREEN + "auto" + RST);
601
+ } else {
602
+ statusParts.push(YELLOW + "manual" + RST);
603
+ }
604
+ if (pitem.updateAvail) {
605
+ statusParts.push(CYAN + "UPDATE" + RST);
606
+ } else if (pitem.deployed) {
607
+ statusParts.push(GRAY + "ok" + RST);
608
+ } else {
609
+ statusParts.push(RED + "missing" + RST);
610
+ }
611
+
612
+ var statusStr = statusParts.join(GRAY + " | " + RST);
613
+ var commitStr = pitem.localHead ? (GRAY + pitem.localHead.substring(0, 7) + RST) : (GRAY + "---" + RST);
614
+
615
+ pushBody(" " + bg + arrow + nameStyle + pad(trunc(pitem.name, nameW), nameW) + RST + bg + " " + statusStr + " " + commitStr + RST, isSelected);
616
+
617
+ if (sel) {
618
+ var subInfo = GRAY + " " + trunc(pitem.subject || pitem.url, cols - 10) + RST;
619
+ pushBody(" " + subInfo, isSelected);
620
+ }
621
+
622
+ if (sel && mode === "pactions") {
623
+ pushBody("", isSelected);
624
+ var acts = getPluginActions(pitem);
625
+ for (var j = 0; j < acts.length; j++) {
626
+ var a = acts[j];
627
+ var aSel = j === pacursor;
628
+ if (aSel) {
629
+ pushBody(" " + GREEN + " > " + BOLD + a.label + RST, isSelected);
630
+ } else {
631
+ pushBody(" " + GRAY + " " + a.label + RST, isSelected);
632
+ }
633
+ }
634
+ pushBody("", isSelected);
635
+ }
636
+ }
637
+
638
+ function buildPlugins(pushBody, pushFoot, cols, barW) {
639
+ var nameW = Math.min(32, Math.max(20, cols - 44));
640
+
641
+ if (mode === "pcommits") {
642
+ pushBody(" " + MAGENTA + "#" + GRAY + " Select commit for " + pluginItems[pcursor].name + RST, false);
643
+ for (var i = 0; i < commitItems.length; i++) {
644
+ var c = commitItems[i];
645
+ var sel = i === ccursor;
646
+ var arrow = sel ? (YELLOW + " > " + RST) : " ";
647
+ var bg = sel ? BG_SEL : "";
648
+ var nameStyle = sel ? (BOLD + WHITE) : DIM;
649
+ pushBody(" " + bg + arrow + nameStyle + c.hash + RST + bg + " " + pad(c.time, 12) + " " + trunc(c.subject, Math.max(10, cols - 30)) + RST, sel);
650
+ }
651
+ pushBody("", false);
652
+
653
+ if (message) {
654
+ pushFoot(" " + GREEN + " " + trunc(message, cols - 5) + RST);
655
+ }
656
+ pushFoot(" " + GRAY + "-".repeat(barW) + RST);
657
+ pushFoot(" " + DIM + "^v" + RST + "/" + DIM + "WS" + RST + " Move " +
658
+ DIM + "Enter" + RST + " Checkout " +
659
+ DIM + "Esc" + RST + " Cancel" + RST);
660
+ return;
661
+ }
662
+
663
+ if (pluginItems.length === 0) {
664
+ pushBody(" " + GRAY + "No plugins configured." + RST, false);
665
+ pushBody(" " + GRAY + "Add plugins to ~/.config/opencode/config/plugins.json" + RST, false);
666
+ pushBody("", false);
667
+
668
+ pushFoot(" " + GRAY + "-".repeat(barW) + RST);
669
+ pushFoot(" " + GRAY + "Q" + RST + " Quit");
670
+ return;
671
+ }
672
+
673
+ var autoCount = 0, manualCount = 0, updateCount = 0;
674
+ for (var p of pluginItems) {
675
+ if (p.autoUpdate) autoCount++; else manualCount++;
676
+ if (p.updateAvail) updateCount++;
677
+ }
678
+
679
+ pushBody(" " + MAGENTA + "#" + GRAY + " Plugins " +
680
+ DIM + "(" + autoCount + " auto, " + manualCount + " manual" +
681
+ (updateCount > 0 ? ", " + CYAN + updateCount + " updates" + DIM : "") +
682
+ ")" + RST, false);
683
+
684
+ if (!pluginFetched) {
685
+ pushBody(" " + GRAY + " Press " + RST + "F" + GRAY + " to check for updates" + RST, false);
686
+ }
687
+
688
+ for (var i = 0; i < pluginItems.length; i++) {
689
+ buildPluginItem(pushBody, i, pluginItems[i], nameW, cols, i === pcursor);
690
+ }
691
+
692
+ pushBody("", false);
693
+
694
+ if (message) {
695
+ pushFoot(" " + GREEN + " " + trunc(message, cols - 5) + RST);
696
+ }
697
+ pushFoot(" " + GRAY + "-".repeat(barW) + RST);
698
+
699
+ if (mode === "pactions") {
700
+ pushFoot(" " + DIM + "^v" + RST + "/" + DIM + "WS" + RST + " Move " +
701
+ DIM + "Enter" + RST + " Confirm " +
702
+ DIM + "Esc" + RST + " Back" + RST);
703
+ } else {
704
+ pushFoot(" " + DIM + "^v" + RST + "/" + DIM + "WS" + RST + " Move " +
705
+ DIM + "Enter" + RST + " Select " +
706
+ DIM + "F" + RST + " Fetch " +
707
+ DIM + "A" + RST + " Toggle auto " +
708
+ DIM + "U" + RST + " Update " +
709
+ DIM + "Q" + RST + " Quit" + RST);
710
+ }
711
+ }
712
+
713
+ // ---------------------------------------------------------------------------
714
+ // Main render
715
+ // ---------------------------------------------------------------------------
716
+
717
+ function render() {
718
+ var cols = process.stderr.columns || 80;
719
+ var totalRows = (process.stderr.rows || 24) - 1;
720
+ var barW = Math.min(56, cols - 4);
721
+
722
+ var headLines = [];
723
+ var bodyLines = [];
724
+ var footLines = [];
725
+ var selStart = 0;
726
+ var selEnd = 0;
727
+
728
+ function pushHead(s) { headLines.push(s); }
729
+ function pushBody(s, isSelLine) {
730
+ if (isSelLine && selStart === 0) selStart = bodyLines.length;
731
+ bodyLines.push(s);
732
+ if (isSelLine) selEnd = bodyLines.length;
733
+ }
734
+ function pushFoot(s) { footLines.push(s); }
735
+
736
+ // 1. Build Header
737
+ pushHead("");
738
+ pushHead(" " + BOLD + CYAN + " OpenCode" + RST + GRAY + " Launcher" + RST);
739
+ pushHead(" " + GRAY + "-".repeat(barW) + RST);
740
+ var showPluginsTab = pluginItems.length > 0;
741
+ var projTab = page === "projects" ? (BOLD + WHITE + BG_SEL + " Projects " + RST) : (GRAY + " Projects " + RST);
742
+ var plugTab = showPluginsTab ? (page === "plugins" ? (BOLD + WHITE + BG_SEL + " Plugins " + RST) : (GRAY + " Plugins " + RST)) : "";
743
+ pushHead(" " + projTab + (showPluginsTab ? " " + plugTab + " " + DIM + "<- ->" + RST : ""));
744
+ pushHead("");
745
+
746
+ // 2. Build Body & Footer
747
+ if (page === "projects") {
748
+ buildProjects(pushBody, pushFoot, cols, barW);
749
+ } else {
750
+ buildPlugins(pushBody, pushFoot, cols, barW);
751
+ }
752
+
753
+ // 3. Viewport calculation
754
+ var maxBody = Math.max(2, totalRows - headLines.length - footLines.length);
755
+
756
+ var activeScroll = 0;
757
+ if (page === "projects") activeScroll = scrollOff;
758
+ else if (mode === "pcommits") activeScroll = cscrollOff;
759
+ else activeScroll = pscrollOff;
760
+
761
+ if (bodyLines.length > maxBody) {
762
+ if (selStart < activeScroll) activeScroll = selStart;
763
+ if (selEnd > activeScroll + maxBody) activeScroll = selEnd - maxBody;
764
+ if (activeScroll > bodyLines.length - maxBody) activeScroll = bodyLines.length - maxBody;
765
+ if (activeScroll < 0) activeScroll = 0;
766
+
767
+ if (page === "projects") scrollOff = activeScroll;
768
+ else if (mode === "pcommits") cscrollOff = activeScroll;
769
+ else pscrollOff = activeScroll;
770
+
771
+ var origLen = bodyLines.length;
772
+
773
+ var hasAbove = activeScroll > 0;
774
+ var hasBelow = activeScroll + maxBody < origLen;
775
+
776
+ var sliceLen = maxBody;
777
+ if (hasAbove) sliceLen--;
778
+ if (hasBelow) sliceLen--;
779
+
780
+ hasBelow = activeScroll + sliceLen < origLen;
781
+ if (hasBelow && !hasAbove && activeScroll > 0) {
782
+ // Re-evaluate in case reducing sliceLen triggered hasAbove
783
+ hasAbove = true;
784
+ sliceLen--;
785
+ hasBelow = activeScroll + sliceLen < origLen;
786
+ }
787
+
788
+ var visibleBody = bodyLines.slice(activeScroll, activeScroll + sliceLen);
789
+
790
+ if (hasAbove) {
791
+ visibleBody.unshift(" " + GRAY + " ^ " + activeScroll + " more" + RST);
792
+ }
793
+ if (hasBelow) {
794
+ visibleBody.push(" " + GRAY + " v " + (origLen - (activeScroll + sliceLen)) + " more" + RST);
795
+ }
796
+
797
+ bodyLines = visibleBody;
798
+ }
799
+
800
+ // 4. Render to screen
801
+ _buf = E + "H";
802
+ for (var h of headLines) _buf += h + CLR + "\n";
803
+ for (var b of bodyLines) _buf += b + CLR + "\n";
804
+ for (var f of footLines) _buf += f + CLR + "\n";
805
+ _buf += E + "J";
806
+
807
+ process.stderr.write(_buf);
808
+ _buf = "";
809
+ }
810
+
811
+ // ---------------------------------------------------------------------------
812
+ // Key handling
813
+ // ---------------------------------------------------------------------------
814
+ function handleKey(key) {
815
+ // Page switching with left/right (only in list mode, not in actions/input)
816
+ if ((mode === "list") && (key === "left" || key === "right")) {
817
+ var showPluginsTab = pluginItems.length > 0;
818
+ if (key === "left" && page === "plugins") { page = "projects"; mode = "list"; render(); return; }
819
+ if (key === "right" && page === "projects" && showPluginsTab) { page = "plugins"; mode = "list"; render(); return; }
820
+ return;
821
+ }
822
+
823
+ if (page === "projects") {
824
+ handleProjectKey(key);
825
+ } else {
826
+ handlePluginKey(key);
827
+ }
828
+ }
829
+
830
+ function handleProjectKey(key) {
831
+ if (mode === "list") {
832
+ if (key === "up" || key === "w") { cursor = Math.max(0, cursor - 1); }
833
+ else if (key === "down" || key === "s") { cursor = Math.min(items.length - 1, cursor + 1); }
834
+ else if (key === "enter" || key === "space") {
835
+ if (items.length > 0) { mode = "actions"; acursor = 0; }
836
+ }
837
+ else if (key === "o") { if (items.length > 0) openProject(items[cursor]); }
838
+ else if (key === "p") { if (items.length > 0) togglePin(cursor); }
839
+ else if (key === "h") { if (items.length > 0) hideItem(cursor); }
840
+ else if (key === "u") { unhideAll(); }
841
+ else if (key === "c") { mode = "input"; inputBuf = ""; }
842
+ else if (key === "q" || key === "escape") { cleanup(); process.exit(1); }
843
+ } else if (mode === "actions") {
844
+ var acts = getActions(items[cursor]);
845
+ if (key === "up" || key === "w") { acursor = Math.max(0, acursor - 1); }
846
+ else if (key === "down" || key === "s") { acursor = Math.min(acts.length - 1, acursor + 1); }
847
+ else if (key === "enter" || key === "space") {
848
+ var action = acts[acursor].key;
849
+ if (action === "open") { openProject(items[cursor]); }
850
+ else if (action === "pin" || action === "unpin") { togglePin(cursor); mode = "list"; }
851
+ else if (action === "hide") { hideItem(cursor); mode = "list"; }
852
+ else if (action === "chpath") { mode = "input"; chpathDir = items[cursor].dir; inputBuf = items[cursor].dir; }
853
+ else if (action === "unhide") { unhideAll(); mode = "list"; }
854
+ else { mode = "list"; }
855
+ }
856
+ else if (key === "escape" || key === "q" || key === "left") { mode = "list"; }
857
+ }
858
+ }
859
+
860
+ function handlePluginKey(key) {
861
+ if (mode === "list") {
862
+ if (key === "up" || key === "w") { pcursor = Math.max(0, pcursor - 1); }
863
+ else if (key === "down" || key === "s") { pcursor = Math.min(pluginItems.length - 1, pcursor + 1); }
864
+ else if (key === "enter" || key === "space") {
865
+ if (pluginItems.length > 0) { mode = "pactions"; pacursor = 0; }
866
+ }
867
+ else if (key === "f") {
868
+ flash("Fetching remotes...");
869
+ render();
870
+ fetchPluginRemotes(pluginItems);
871
+ pluginFetched = true;
872
+ var updateCount = 0;
873
+ for (var p of pluginItems) { if (p.updateAvail) updateCount++; }
874
+ flash(updateCount > 0 ? updateCount + " update(s) available" : "All plugins up to date");
875
+ }
876
+ else if (key === "a") {
877
+ if (pluginItems.length > 0) {
878
+ var p = pluginItems[pcursor];
879
+ p.autoUpdate = !p.autoUpdate;
880
+ var plugins = loadPlugins();
881
+ var match = plugins.find(function(r) { return r.name === p.name; });
882
+ if (match) { match.autoUpdate = p.autoUpdate; savePlugins(plugins); }
883
+ flash(p.name + ": auto-update " + (p.autoUpdate ? "ON" : "OFF"));
884
+ }
885
+ }
886
+ else if (key === "u") {
887
+ if (pluginItems.length > 0) {
888
+ var p = pluginItems[pcursor];
889
+ flash("Updating " + p.name + "...");
890
+ render();
891
+ var err = runPluginUpdate(p);
892
+ pluginItems = buildPluginList();
893
+ if (pcursor >= pluginItems.length) pcursor = Math.max(0, pluginItems.length - 1);
894
+ flash(err ? p.name + ": " + err : p.name + " updated. Restart OpenCode to apply.");
895
+ }
896
+ }
897
+ else if (key === "q" || key === "escape") { cleanup(); process.exit(1); }
898
+ } else if (mode === "pactions") {
899
+ var pitem = pluginItems[pcursor];
900
+ var acts = getPluginActions(pitem);
901
+ if (key === "up" || key === "w") { pacursor = Math.max(0, pacursor - 1); }
902
+ else if (key === "down" || key === "s") { pacursor = Math.min(acts.length - 1, pacursor + 1); }
903
+ else if (key === "enter" || key === "space") {
904
+ var action = acts[pacursor].key;
905
+ if (action === "update" || action === "force-update") {
906
+ flash("Updating " + pitem.name + "...");
907
+ render();
908
+ var err = runPluginUpdate(pitem);
909
+ pluginItems = buildPluginList();
910
+ if (pcursor >= pluginItems.length) pcursor = Math.max(0, pluginItems.length - 1);
911
+ flash(err ? pitem.name + ": " + err : pitem.name + " updated. Restart OpenCode to apply.");
912
+ mode = "list";
913
+ }
914
+ else if (action === "enable-auto" || action === "disable-auto") {
915
+ var newVal = action === "enable-auto";
916
+ pitem.autoUpdate = newVal;
917
+ var plugins = loadPlugins();
918
+ var match = plugins.find(function(r) { return r.name === pitem.name; });
919
+ if (match) { match.autoUpdate = newVal; savePlugins(plugins); }
920
+ flash(pitem.name + ": auto-update " + (newVal ? "ON" : "OFF"));
921
+ mode = "list";
922
+ }
923
+ else if (action === "commits") {
924
+ var dir = join(REPOS_DIR, pitem.folderName);
925
+ if (!existsSync(dir)) { flash("Not installed locally yet"); mode = "list"; return; }
926
+ try {
927
+ var log = execSync('git log -20 --format="%h|%s|%ar"', { cwd: dir, encoding: "utf-8", timeout: 5000 });
928
+ var lines = log.trim().split("\n");
929
+ commitItems = [];
930
+ for (var i = 0; i < lines.length; i++) {
931
+ if (!lines[i]) continue;
932
+ var parts = lines[i].split("|");
933
+ if (parts.length >= 3) {
934
+ commitItems.push({ hash: parts[0], subject: parts.slice(1, -1).join("|"), time: parts[parts.length-1] });
935
+ }
936
+ }
937
+ if (commitItems.length > 0) {
938
+ ccursor = 0; cscrollOff = 0; mode = "pcommits";
939
+ } else {
940
+ flash("No commits found"); mode = "list";
941
+ }
942
+ } catch (e) {
943
+ flash("Failed to fetch commits"); mode = "list";
944
+ }
945
+ }
946
+ else { mode = "list"; }
947
+ }
948
+ else if (key === "escape" || key === "q" || key === "left") { mode = "list"; }
949
+ } else if (mode === "pcommits") {
950
+ if (key === "up" || key === "w") { ccursor = Math.max(0, ccursor - 1); }
951
+ else if (key === "down" || key === "s") { ccursor = Math.min(commitItems.length - 1, ccursor + 1); }
952
+ else if (key === "escape" || key === "q" || key === "left") { mode = "list"; }
953
+ else if (key === "enter" || key === "space") {
954
+ var pitem = pluginItems[pcursor];
955
+ var citem = commitItems[ccursor];
956
+ flash("Downgrading " + pitem.name + " to " + citem.hash + "...");
957
+ render();
958
+
959
+ var dir = join(REPOS_DIR, pitem.folderName);
960
+ try {
961
+ execSync("git reset --hard", { cwd: dir, timeout: 15000, stdio: "ignore" });
962
+ execSync("git checkout " + citem.hash, { cwd: dir, timeout: 15000, stdio: "ignore" });
963
+ } catch (e) {
964
+ flash("Checkout failed"); mode = "list"; return;
965
+ }
966
+
967
+ var plugins = loadPlugins();
968
+ var repo = plugins.find(function(r) { return r.name === pitem.name; });
969
+ var err = null;
970
+ if (repo) {
971
+ if (repo.install) {
972
+ try { execSync(repo.install.join(" "), { cwd: dir, timeout: 120000, stdio: "ignore" }); } catch(e) { err="Install failed"; }
973
+ }
974
+ if (!err && repo.build) {
975
+ try { execSync(repo.build.join(" "), { cwd: dir, timeout: 120000, stdio: "ignore" }); } catch(e) { err="Build failed"; }
976
+ }
977
+ if (!err && repo.bundle) {
978
+ try { execSync(repo.bundle.join(" "), { cwd: dir, timeout: 120000, stdio: "ignore" }); } catch(e) { err="Bundle failed"; }
979
+ }
980
+ var outputPath = join(dir, repo.output);
981
+ var destPath = join(PLUGINS_DIR, repo.pluginFile);
982
+ if (!err && existsSync(outputPath)) {
983
+ try { copyFileSync(outputPath, destPath); } catch(e) { err="Copy failed"; }
984
+ } else if (!err) {
985
+ err = "Build output not found";
986
+ }
987
+ }
988
+ pluginItems = buildPluginList();
989
+ if (err) flash("Error: " + err);
990
+ else flash("Downgraded to " + citem.hash.substring(0,7));
991
+ mode = "list";
992
+ }
993
+ }
994
+ }
995
+
996
+ function handleInputData(buf) {
997
+ if (buf[0] === 27) { mode = "list"; chpathDir = ""; return; }
998
+ if (buf[0] === 3) { cleanup(); process.exit(1); }
999
+ if (buf[0] === 13 || buf[0] === 10) {
1000
+ var p = inputBuf.trim();
1001
+ if (p) {
1002
+ if (p.charAt(0) === "~") p = HOME + p.substring(1);
1003
+ p = p.replace(/\//g, "\\");
1004
+ if (chpathDir) {
1005
+ if (p === chpathDir) { flash("Same path, nothing changed"); mode = "list"; chpathDir = ""; return; }
1006
+ if (existsSync(p)) {
1007
+ changeProjectPath(chpathDir, p);
1008
+ } else {
1009
+ flash("Path not found: " + p);
1010
+ }
1011
+ mode = "list"; chpathDir = "";
1012
+ } else {
1013
+ if (existsSync(p)) {
1014
+ cleanup();
1015
+ outputDir(p);
1016
+ process.exit(0);
1017
+ } else {
1018
+ flash("Path not found: " + p);
1019
+ mode = "list";
1020
+ }
1021
+ }
1022
+ } else {
1023
+ mode = "list"; chpathDir = "";
1024
+ }
1025
+ return;
1026
+ }
1027
+ if (buf[0] === 127 || buf[0] === 8) {
1028
+ inputBuf = inputBuf.substring(0, inputBuf.length - 1);
1029
+ return;
1030
+ }
1031
+ if (buf[0] >= 32 && buf[0] < 127) {
1032
+ inputBuf += String.fromCharCode(buf[0]);
1033
+ return;
1034
+ }
1035
+ var s = buf.toString("utf-8");
1036
+ if (s.length > 0) {
1037
+ for (var i = 0; i < s.length; i++) {
1038
+ var c = s.charCodeAt(i);
1039
+ if (c >= 32) inputBuf += s.charAt(i);
1040
+ }
1041
+ }
1042
+ }
1043
+
1044
+ function parseKey(buf) {
1045
+ if (buf[0] === 27) {
1046
+ if (buf.length === 1) return "escape";
1047
+ if (buf[1] === 91) {
1048
+ if (buf[2] === 65) return "up";
1049
+ if (buf[2] === 66) return "down";
1050
+ if (buf[2] === 67) return "right";
1051
+ if (buf[2] === 68) return "left";
1052
+ }
1053
+ return null;
1054
+ }
1055
+ if (buf[0] === 13 || buf[0] === 10) return "enter";
1056
+ if (buf[0] === 32) return "space";
1057
+ if (buf[0] === 3) { cleanup(); process.exit(1); }
1058
+ var ch = String.fromCharCode(buf[0]).toLowerCase();
1059
+ if ("wsadqpchouf".indexOf(ch) !== -1) return ch;
1060
+ return null;
1061
+ }
1062
+
1063
+ // ---------------------------------------------------------------------------
1064
+ // Cleanup & startup
1065
+ // ---------------------------------------------------------------------------
1066
+
1067
+ function cleanup() {
1068
+ showCur();
1069
+ process.stderr.write(E + "H" + E + "2J");
1070
+ try { process.stdin.setRawMode(false); } catch {}
1071
+ }
1072
+
1073
+ process.on("exit", function() { showCur(); });
1074
+ process.on("SIGINT", function() { cleanup(); process.exit(1); });
1075
+ process.on("SIGTERM", function() { cleanup(); process.exit(1); });
1076
+ try { process.stderr.on("resize", function() { render(); }); } catch(e) {}
1077
+
1078
+ // Direct argument handling (skip TUI)
1079
+ var arg = process.argv[2];
1080
+ if (arg) {
1081
+ if (/^\d+$/.test(arg)) {
1082
+ var idx = parseInt(arg) - 1;
1083
+ if (idx >= 0 && idx < items.length) {
1084
+ outputDir(items[idx].dir);
1085
+ process.exit(0);
1086
+ }
1087
+ process.stderr.write("Invalid number: " + arg + "\n");
1088
+ process.exit(1);
1089
+ }
1090
+ var match = items.find(function(it) { return it.name.toLowerCase().indexOf(arg.toLowerCase()) !== -1; });
1091
+ if (!match) match = items.find(function(it) { return it.dir.toLowerCase().indexOf(arg.toLowerCase()) !== -1; });
1092
+ if (match) {
1093
+ outputDir(match.dir);
1094
+ process.exit(0);
1095
+ }
1096
+ process.stderr.write("No match for: " + arg + "\n");
1097
+ process.exit(1);
1098
+ }
1099
+
1100
+ hideCur();
1101
+ render();
1102
+ process.stdin.setRawMode(true);
1103
+ process.stdin.resume();
1104
+ process.stdin.on("data", function(buf) {
1105
+ if (mode === "input") { handleInputData(buf); render(); return; }
1106
+ var key = parseKey(buf);
1107
+ if (key) { handleKey(key); render(); }
1108
+ });
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "opencode-hub",
3
+ "version": "1.0.1",
4
+ "description": "TUI launcher for OpenCode - project switcher and plugin manager with oc command",
5
+ "main": "plugin.js",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "author": "intisy",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/intisy/opencode-launcher.git"
12
+ },
13
+ "homepage": "https://github.com/intisy/opencode-launcher#readme",
14
+ "keywords": [
15
+ "opencode",
16
+ "launcher",
17
+ "tui",
18
+ "project-switcher",
19
+ "plugin"
20
+ ],
21
+ "engines": {
22
+ "node": "\u003e=20.0.0"
23
+ },
24
+ "files": [
25
+ "plugin.js",
26
+ "oc-tui.js",
27
+ "README.md"
28
+ ]
29
+ }
package/plugin.js ADDED
@@ -0,0 +1,130 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { existsSync, writeFileSync, mkdirSync, unlinkSync, readFileSync } from "fs";
3
+ import { join, dirname } from "path";
4
+ import { homedir } from "os";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Find oc-tui.js — works for both npm and plugin-updater installs
8
+ // ---------------------------------------------------------------------------
9
+
10
+ function findTuiScript() {
11
+ // 1. Same directory as this plugin file (npm install case)
12
+ var sameDirPath = join(import.meta.dir, "oc-tui.js");
13
+ if (existsSync(sameDirPath)) return sameDirPath;
14
+
15
+ // 2. Find config dir, then check repos/intisy/opencode-launcher/ (updater case)
16
+ var configDir = findConfigDir(import.meta.dir);
17
+ if (configDir) {
18
+ var repoPath = join(configDir, "repos", "intisy", "opencode-launcher", "oc-tui.js");
19
+ if (existsSync(repoPath)) return repoPath;
20
+ }
21
+
22
+ return null;
23
+ }
24
+
25
+ function findConfigDir(start) {
26
+ var dir = start;
27
+ for (var i = 0; i < 8; i++) {
28
+ if (existsSync(join(dir, "opencode.json"))) return dir;
29
+ if (existsSync(join(dir, "config", "plugins.json"))) return dir;
30
+ var parent = dirname(dir);
31
+ if (parent === dir) break;
32
+ dir = parent;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Install / remove the `oc` shell command
39
+ // ---------------------------------------------------------------------------
40
+
41
+ function getBinDir() {
42
+ if (process.platform === "win32") {
43
+ return join(homedir(), ".local", "bin");
44
+ }
45
+ return join(homedir(), ".local", "bin");
46
+ }
47
+
48
+ function installOcCommand() {
49
+ var tuiPath = findTuiScript();
50
+ if (!tuiPath) return;
51
+
52
+ var binDir = getBinDir();
53
+ if (!existsSync(binDir)) try { mkdirSync(binDir, { recursive: true }); } catch {}
54
+
55
+ var tuiPathEscaped = tuiPath.replace(/\\/g, "\\\\");
56
+
57
+ if (process.platform === "win32") {
58
+ // oc.cmd for Windows
59
+ var cmdPath = join(binDir, "oc.cmd");
60
+ var cmdContent = '@echo off\r\n'
61
+ + 'set "tmp=%TEMP%\\oc-output-%RANDOM%.tmp"\r\n'
62
+ + 'set "OC_OUTPUT=%tmp%"\r\n'
63
+ + 'bun "' + tuiPath + '" %*\r\n'
64
+ + 'set /p dir=<"%tmp%" 2>nul\r\n'
65
+ + 'del "%tmp%" 2>nul\r\n'
66
+ + 'if defined dir (\r\n'
67
+ + ' cd /d "%dir%" && opencode\r\n'
68
+ + ')\r\n';
69
+ try { writeFileSync(cmdPath, cmdContent, "utf-8"); } catch {}
70
+ } else {
71
+ // oc for Unix
72
+ var shPath = join(binDir, "oc");
73
+ var shContent = '#!/bin/sh\n'
74
+ + 'tmp=$(mktemp)\n'
75
+ + 'OC_OUTPUT="$tmp" bun "' + tuiPathEscaped + '" "$@"\n'
76
+ + 'dir=$(cat "$tmp" 2>/dev/null)\n'
77
+ + 'rm -f "$tmp"\n'
78
+ + 'if [ -n "$dir" ]; then\n'
79
+ + ' cd "$dir" && opencode\n'
80
+ + 'fi\n';
81
+ try {
82
+ writeFileSync(shPath, shContent, { mode: 0o755 });
83
+ } catch {}
84
+ }
85
+ }
86
+
87
+ function removeOcCommand() {
88
+ var binDir = getBinDir();
89
+ var files = ["oc", "oc.cmd"];
90
+ var removed = [];
91
+ for (var f of files) {
92
+ var p = join(binDir, f);
93
+ if (existsSync(p)) {
94
+ try { unlinkSync(p); removed.push(f); } catch {}
95
+ }
96
+ }
97
+ return removed;
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Install on load (with guard against dual execution)
102
+ // ---------------------------------------------------------------------------
103
+
104
+ if (!globalThis.__ocLauncherInstalled) {
105
+ globalThis.__ocLauncherInstalled = true;
106
+ installOcCommand();
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Plugin export
111
+ // ---------------------------------------------------------------------------
112
+
113
+ export default async function OpenCodeLauncher(ctx) {
114
+ return {
115
+ tool: {
116
+ oc_remove: tool({
117
+ description:
118
+ "Remove the oc launcher command. Deletes oc, oc.cmd from ~/.local/bin. The launcher will be reinstalled on next opencode start if the plugin is still active.",
119
+ args: {
120
+ _placeholder: tool.schema.boolean().describe("Placeholder. Always pass true."),
121
+ },
122
+ async execute() {
123
+ var removed = removeOcCommand();
124
+ if (removed.length === 0) return "No oc commands found to remove.";
125
+ return "Removed: " + removed.join(", ") + " from " + getBinDir();
126
+ },
127
+ }),
128
+ },
129
+ };
130
+ }