@vibe80/vibe80 0.2.0 → 0.2.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.
Files changed (55) hide show
  1. package/README.md +132 -16
  2. package/bin/vibe80.js +1728 -16
  3. package/client/dist/assets/{DiffPanel-BKLnyIAZ.js → DiffPanel-BUJhQj_Q.js} +1 -1
  4. package/client/dist/assets/{ExplorerPanel-D3IbBsXz.js → ExplorerPanel-DugEeaO2.js} +1 -1
  5. package/client/dist/assets/{LogsPanel-BwJAFHRP.js → LogsPanel-BQrGxMu_.js} +1 -1
  6. package/client/dist/assets/{SettingsPanel-BfkchMnR.js → SettingsPanel-Ci2BdIYO.js} +1 -1
  7. package/client/dist/assets/{TerminalPanel-BQfMEm-u.js → TerminalPanel-C-T3t-6T.js} +1 -1
  8. package/client/dist/assets/index-cFi4LM0j.js +711 -0
  9. package/client/dist/assets/index-qNyFxUjK.css +32 -0
  10. package/client/dist/icon_square-512x512.png +0 -0
  11. package/client/dist/icon_square.svg +58 -0
  12. package/client/dist/index.html +3 -2
  13. package/client/dist/sw.js +1 -1
  14. package/client/index.html +1 -0
  15. package/client/public/icon_square-512x512.png +0 -0
  16. package/client/public/icon_square.svg +58 -0
  17. package/client/src/App.jsx +205 -2
  18. package/client/src/assets/vibe80_dark.png +0 -0
  19. package/client/src/assets/vibe80_light.png +0 -0
  20. package/client/src/components/Chat/ChatMessages.jsx +1 -1
  21. package/client/src/components/SessionGate/SessionGate.jsx +295 -91
  22. package/client/src/components/WorktreeTabs.css +11 -0
  23. package/client/src/components/WorktreeTabs.jsx +77 -47
  24. package/client/src/hooks/useChatSocket.js +8 -7
  25. package/client/src/hooks/useRepoBranchesModels.js +12 -6
  26. package/client/src/hooks/useWorktreeCloseConfirm.js +19 -7
  27. package/client/src/hooks/useWorktrees.js +3 -1
  28. package/client/src/index.css +26 -3
  29. package/client/src/locales/en.json +12 -1
  30. package/client/src/locales/fr.json +12 -1
  31. package/docs/api/openapi.json +1 -1
  32. package/package.json +2 -1
  33. package/server/scripts/rotate-workspace-secret.js +1 -1
  34. package/server/src/claudeClient.js +3 -3
  35. package/server/src/codexClient.js +3 -3
  36. package/server/src/config.js +6 -6
  37. package/server/src/index.js +14 -12
  38. package/server/src/middleware/auth.js +7 -7
  39. package/server/src/middleware/debug.js +36 -4
  40. package/server/src/providerLogger.js +2 -2
  41. package/server/src/routes/sessions.js +133 -21
  42. package/server/src/routes/workspaces.js +1 -1
  43. package/server/src/runAs.js +14 -14
  44. package/server/src/services/auth.js +3 -3
  45. package/server/src/services/session.js +182 -14
  46. package/server/src/services/workspace.js +86 -42
  47. package/server/src/storage/index.js +2 -2
  48. package/server/src/storage/redis.js +38 -36
  49. package/server/src/storage/sqlite.js +13 -13
  50. package/server/src/worktreeManager.js +87 -19
  51. package/server/tests/integration/routes/workspaces-routes.test.js +8 -8
  52. package/server/tests/setup/env.js +5 -5
  53. package/server/tests/unit/services/auth.test.js +3 -3
  54. package/client/dist/assets/index-BDQQz6SJ.css +0 -32
  55. package/client/dist/assets/index-D1UJw1oP.js +0 -711
package/bin/vibe80.js CHANGED
@@ -2,26 +2,370 @@
2
2
  "use strict";
3
3
 
4
4
  const { spawn } = require("child_process");
5
+ const { Command } = require("commander");
5
6
  const fs = require("fs");
6
7
  const path = require("path");
7
8
  const os = require("os");
8
9
 
9
10
  const rootDir = path.resolve(__dirname, "..");
10
11
  const homeDir = process.env.HOME || os.homedir();
12
+ const defaultEnv = {
13
+ VIBE80_DEPLOYMENT_MODE: "mono_user",
14
+ VIBE80_DATA_DIRECTORY: path.join(homeDir, ".vibe80"),
15
+ VIBE80_STORAGE_BACKEND: "sqlite",
16
+ };
11
17
  const monoAuthUrlFile = path.join(
12
18
  os.tmpdir(),
13
19
  `vibe80-mono-auth-${process.pid}-${Date.now()}.url`
14
20
  );
15
- const defaultEnv = {
16
- DEPLOYMENT_MODE: "mono_user",
17
- VIBE80_DATA_DIRECTORY: path.join(homeDir, ".vibe80"),
18
- STORAGE_BACKEND: "sqlite",
21
+ const defaultBaseUrl = process.env.VIBE80_BASE_URL || "http://localhost:5179";
22
+
23
+ const resolveCliStatePath = () => {
24
+ const dataDir = process.env.VIBE80_DATA_DIRECTORY || defaultEnv.VIBE80_DATA_DIRECTORY;
25
+ return path.join(dataDir, "cli", "state.json");
26
+ };
27
+
28
+ const loadCliState = () => {
29
+ const statePath = resolveCliStatePath();
30
+ if (!fs.existsSync(statePath)) {
31
+ return {
32
+ version: 1,
33
+ currentWorkspaceId: null,
34
+ workspaces: {},
35
+ currentSessionByWorkspace: {},
36
+ sessionsByWorkspace: {},
37
+ currentWorktreeBySession: {},
38
+ };
39
+ }
40
+ try {
41
+ const parsed = JSON.parse(fs.readFileSync(statePath, "utf8"));
42
+ if (!parsed || typeof parsed !== "object") {
43
+ return {
44
+ version: 1,
45
+ currentWorkspaceId: null,
46
+ workspaces: {},
47
+ currentSessionByWorkspace: {},
48
+ sessionsByWorkspace: {},
49
+ currentWorktreeBySession: {},
50
+ };
51
+ }
52
+ return {
53
+ version: 1,
54
+ currentWorkspaceId:
55
+ typeof parsed.currentWorkspaceId === "string" && parsed.currentWorkspaceId
56
+ ? parsed.currentWorkspaceId
57
+ : null,
58
+ workspaces:
59
+ parsed.workspaces && typeof parsed.workspaces === "object" ? parsed.workspaces : {},
60
+ currentSessionByWorkspace:
61
+ parsed.currentSessionByWorkspace && typeof parsed.currentSessionByWorkspace === "object"
62
+ ? parsed.currentSessionByWorkspace
63
+ : {},
64
+ sessionsByWorkspace:
65
+ parsed.sessionsByWorkspace && typeof parsed.sessionsByWorkspace === "object"
66
+ ? parsed.sessionsByWorkspace
67
+ : {},
68
+ currentWorktreeBySession:
69
+ parsed.currentWorktreeBySession && typeof parsed.currentWorktreeBySession === "object"
70
+ ? parsed.currentWorktreeBySession
71
+ : {},
72
+ };
73
+ } catch {
74
+ return {
75
+ version: 1,
76
+ currentWorkspaceId: null,
77
+ workspaces: {},
78
+ currentSessionByWorkspace: {},
79
+ sessionsByWorkspace: {},
80
+ currentWorktreeBySession: {},
81
+ };
82
+ }
83
+ };
84
+
85
+ const saveCliState = (state) => {
86
+ const statePath = resolveCliStatePath();
87
+ fs.mkdirSync(path.dirname(statePath), { recursive: true, mode: 0o700 });
88
+ fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
89
+ };
90
+
91
+ const normalizeBaseUrl = (baseUrl) => String(baseUrl || defaultBaseUrl).replace(/\/+$/, "");
92
+
93
+ const toIsoStringOrNull = (value) => {
94
+ if (!value) return null;
95
+ const date = new Date(value);
96
+ if (Number.isNaN(date.getTime())) return null;
97
+ return date.toISOString();
98
+ };
99
+
100
+ const maskToken = (value) => {
101
+ if (!value || typeof value !== "string") return "";
102
+ if (value.length <= 12) return `${value.slice(0, 3)}...${value.slice(-3)}`;
103
+ return `${value.slice(0, 6)}...${value.slice(-4)}`;
104
+ };
105
+
106
+ const ensureWorkspaceEntry = (state, workspaceId) => {
107
+ const id = String(workspaceId || "").trim();
108
+ if (!id) {
109
+ throw new Error("workspaceId is required.");
110
+ }
111
+ if (!state.workspaces[id] || typeof state.workspaces[id] !== "object") {
112
+ state.workspaces[id] = { workspaceId: id, baseUrl: normalizeBaseUrl(defaultBaseUrl) };
113
+ }
114
+ if (!state.workspaces[id].workspaceId) {
115
+ state.workspaces[id].workspaceId = id;
116
+ }
117
+ if (!state.workspaces[id].baseUrl) {
118
+ state.workspaces[id].baseUrl = normalizeBaseUrl(defaultBaseUrl);
119
+ }
120
+ return state.workspaces[id];
121
+ };
122
+
123
+ const ensureSessionWorkspaceMap = (state, workspaceId) => {
124
+ const id = String(workspaceId || "").trim();
125
+ if (!id) {
126
+ throw new Error("workspaceId is required.");
127
+ }
128
+ if (!state.sessionsByWorkspace || typeof state.sessionsByWorkspace !== "object") {
129
+ state.sessionsByWorkspace = {};
130
+ }
131
+ if (
132
+ !state.sessionsByWorkspace[id]
133
+ || typeof state.sessionsByWorkspace[id] !== "object"
134
+ || Array.isArray(state.sessionsByWorkspace[id])
135
+ ) {
136
+ state.sessionsByWorkspace[id] = {};
137
+ }
138
+ if (!state.currentSessionByWorkspace || typeof state.currentSessionByWorkspace !== "object") {
139
+ state.currentSessionByWorkspace = {};
140
+ }
141
+ return state.sessionsByWorkspace[id];
142
+ };
143
+
144
+ const setCurrentSessionForWorkspace = (state, workspaceId, sessionId) => {
145
+ ensureSessionWorkspaceMap(state, workspaceId);
146
+ if (!sessionId) {
147
+ delete state.currentSessionByWorkspace[workspaceId];
148
+ return;
149
+ }
150
+ state.currentSessionByWorkspace[workspaceId] = sessionId;
151
+ };
152
+
153
+ const getCurrentSessionForWorkspace = (state, workspaceId) =>
154
+ state.currentSessionByWorkspace?.[workspaceId] || null;
155
+
156
+ const upsertKnownSession = (state, workspaceId, session) => {
157
+ const map = ensureSessionWorkspaceMap(state, workspaceId);
158
+ const sessionId = String(session?.sessionId || "").trim();
159
+ if (!sessionId) {
160
+ return;
161
+ }
162
+ map[sessionId] = {
163
+ sessionId,
164
+ name: session.name || "",
165
+ repoUrl: session.repoUrl || "",
166
+ createdAt: session.createdAt || null,
167
+ lastActivityAt: session.lastActivityAt || null,
168
+ defaultProvider: session.defaultProvider || session.activeProvider || null,
169
+ providers: Array.isArray(session.providers) ? session.providers : [],
170
+ };
171
+ };
172
+
173
+ const getSessionKey = (workspaceId, sessionId) => `${workspaceId}/${sessionId}`;
174
+
175
+ const setCurrentWorktreeForSession = (state, workspaceId, sessionId, worktreeId) => {
176
+ if (!state.currentWorktreeBySession || typeof state.currentWorktreeBySession !== "object") {
177
+ state.currentWorktreeBySession = {};
178
+ }
179
+ const key = getSessionKey(workspaceId, sessionId);
180
+ if (!worktreeId) {
181
+ delete state.currentWorktreeBySession[key];
182
+ return;
183
+ }
184
+ state.currentWorktreeBySession[key] = worktreeId;
185
+ };
186
+
187
+ const getCurrentWorktreeForSession = (state, workspaceId, sessionId) =>
188
+ state.currentWorktreeBySession?.[getSessionKey(workspaceId, sessionId)] || null;
189
+
190
+ const parseListOption = (value, previous = []) => {
191
+ const parts = String(value || "")
192
+ .split(",")
193
+ .map((item) => item.trim())
194
+ .filter(Boolean);
195
+ return [...previous, ...parts];
196
+ };
197
+
198
+ const parseRepeatOption = (value, previous = []) => {
199
+ const trimmed = String(value || "").trim();
200
+ if (!trimmed) {
201
+ return previous;
202
+ }
203
+ return [...previous, trimmed];
204
+ };
205
+
206
+ const parseProviderName = (value) => {
207
+ const provider = String(value || "").trim().toLowerCase();
208
+ if (provider !== "codex" && provider !== "claude") {
209
+ throw new Error(`Unknown provider "${value}". Use codex or claude.`);
210
+ }
211
+ return provider;
212
+ };
213
+
214
+ const buildProvidersPatch = (options) => {
215
+ const patch = {};
216
+ for (const providerName of options.enable || []) {
217
+ const provider = parseProviderName(providerName);
218
+ patch[provider] = { ...(patch[provider] || {}), enabled: true };
219
+ }
220
+ for (const providerName of options.disable || []) {
221
+ const provider = parseProviderName(providerName);
222
+ patch[provider] = { ...(patch[provider] || {}), enabled: false };
223
+ }
224
+
225
+ if (options.codexAuthType || options.codexAuthValue) {
226
+ patch.codex = {
227
+ ...(patch.codex || {}),
228
+ auth: {
229
+ type: options.codexAuthType || "api_key",
230
+ value: options.codexAuthValue || "",
231
+ },
232
+ };
233
+ }
234
+ if (options.claudeAuthType || options.claudeAuthValue) {
235
+ patch.claude = {
236
+ ...(patch.claude || {}),
237
+ auth: {
238
+ type: options.claudeAuthType || "api_key",
239
+ value: options.claudeAuthValue || "",
240
+ },
241
+ };
242
+ }
243
+ return patch;
244
+ };
245
+
246
+ const apiRequest = async ({ baseUrl, pathname, method = "GET", body, workspaceToken }) => {
247
+ const url = `${normalizeBaseUrl(baseUrl)}${pathname}`;
248
+ const headers = {};
249
+ if (workspaceToken) {
250
+ headers.authorization = `Bearer ${workspaceToken}`;
251
+ }
252
+ if (body != null) {
253
+ headers["content-type"] = "application/json";
254
+ }
255
+ const response = await fetch(url, {
256
+ method,
257
+ headers,
258
+ body: body != null ? JSON.stringify(body) : undefined,
259
+ });
260
+ const raw = await response.text();
261
+ let payload = null;
262
+ if (raw) {
263
+ try {
264
+ payload = JSON.parse(raw);
265
+ } catch {
266
+ payload = { raw };
267
+ }
268
+ }
269
+ if (!response.ok) {
270
+ const message =
271
+ payload?.error || payload?.message || payload?.code || `Request failed (${response.status}).`;
272
+ const error = new Error(message);
273
+ error.status = response.status;
274
+ error.payload = payload;
275
+ error.url = url;
276
+ throw error;
277
+ }
278
+ return payload || {};
279
+ };
280
+
281
+ const isAccessTokenFresh = (entry, skewMs = 30 * 1000) => {
282
+ const token = typeof entry?.workspaceToken === "string" ? entry.workspaceToken : "";
283
+ if (!token) {
284
+ return false;
285
+ }
286
+ const expiresAt = Date.parse(entry?.expiresAt || "");
287
+ if (!Number.isFinite(expiresAt)) {
288
+ return true;
289
+ }
290
+ return Date.now() + skewMs < expiresAt;
291
+ };
292
+
293
+ const refreshWorkspaceAccessToken = async ({
294
+ state,
295
+ workspaceId,
296
+ entry,
297
+ baseUrl,
298
+ }) => {
299
+ if (!entry?.refreshToken) {
300
+ throw new Error(`No refresh token saved for workspace "${workspaceId}". Run workspace login first.`);
301
+ }
302
+ const payload = await apiRequest({
303
+ baseUrl,
304
+ pathname: "/api/v1/workspaces/refresh",
305
+ method: "POST",
306
+ body: { refreshToken: entry.refreshToken },
307
+ });
308
+ upsertWorkspaceFromTokens(state, payload.workspaceId || workspaceId, baseUrl, payload, null);
309
+ const updatedId = payload.workspaceId || workspaceId;
310
+ const updatedEntry = ensureWorkspaceEntry(state, updatedId);
311
+ saveCliState(state);
312
+ return { workspaceId: updatedId, entry: updatedEntry };
313
+ };
314
+
315
+ const ensureWorkspaceAccessToken = async ({
316
+ state,
317
+ workspaceId,
318
+ entry,
319
+ baseUrl,
320
+ }) => {
321
+ if (isAccessTokenFresh(entry)) {
322
+ return { workspaceId, entry };
323
+ }
324
+ if (!entry?.refreshToken) {
325
+ if (entry?.workspaceToken) {
326
+ return { workspaceId, entry };
327
+ }
328
+ throw new Error(`No workspace token/refresh token for "${workspaceId}". Run workspace login first.`);
329
+ }
330
+ return refreshWorkspaceAccessToken({ state, workspaceId, entry, baseUrl });
331
+ };
332
+
333
+ const authedApiRequest = async ({
334
+ state,
335
+ workspaceId,
336
+ entry,
337
+ baseUrl,
338
+ retryOnUnauthorized = true,
339
+ ...request
340
+ }) => {
341
+ const ensured = await ensureWorkspaceAccessToken({ state, workspaceId, entry, baseUrl });
342
+ let activeWorkspaceId = ensured.workspaceId;
343
+ let activeEntry = ensured.entry;
344
+ try {
345
+ return await apiRequest({
346
+ baseUrl,
347
+ workspaceToken: activeEntry.workspaceToken,
348
+ ...request,
349
+ });
350
+ } catch (error) {
351
+ if (!retryOnUnauthorized || error?.status !== 401) {
352
+ throw error;
353
+ }
354
+ const refreshed = await refreshWorkspaceAccessToken({
355
+ state,
356
+ workspaceId: activeWorkspaceId,
357
+ entry: activeEntry,
358
+ baseUrl,
359
+ });
360
+ activeWorkspaceId = refreshed.workspaceId;
361
+ activeEntry = refreshed.entry;
362
+ return apiRequest({
363
+ baseUrl,
364
+ workspaceToken: activeEntry.workspaceToken,
365
+ ...request,
366
+ });
367
+ }
19
368
  };
20
- const deploymentMode = process.env.DEPLOYMENT_MODE || defaultEnv.DEPLOYMENT_MODE;
21
- const serverPort = process.env.PORT || "5179";
22
- const cliArgs = process.argv.slice(2);
23
- const enableCodexFromCli = cliArgs.includes("--codex");
24
- const enableClaudeFromCli = cliArgs.includes("--claude");
25
369
 
26
370
  const spawnProcess = (cmd, args, label, extraEnv = {}) => {
27
371
  const child = spawn(cmd, args, {
@@ -42,7 +386,6 @@ const spawnProcess = (cmd, args, label, extraEnv = {}) => {
42
386
  };
43
387
 
44
388
  let server = null;
45
-
46
389
  let shuttingDown = false;
47
390
 
48
391
  const unlinkMonoAuthUrlFile = () => {
@@ -59,7 +402,7 @@ const tryOpenUrl = (url) =>
59
402
  resolve(false);
60
403
  return;
61
404
  }
62
- const command = process.platform === "darwin"
405
+ const openCommand = process.platform === "darwin"
63
406
  ? "open"
64
407
  : process.platform === "win32"
65
408
  ? "cmd"
@@ -69,7 +412,7 @@ const tryOpenUrl = (url) =>
69
412
  : process.platform === "win32"
70
413
  ? ["/c", "start", "", url]
71
414
  : [url];
72
- const opener = spawn(command, args, {
415
+ const opener = spawn(openCommand, args, {
73
416
  stdio: "ignore",
74
417
  detached: true,
75
418
  });
@@ -103,7 +446,8 @@ const waitForMonoAuthUrl = (timeoutMs = 15000) =>
103
446
  poll();
104
447
  });
105
448
 
106
- const maybeOpenMonoAuthUrl = async () => {
449
+ const maybeOpenMonoAuthUrl = async (serverPort) => {
450
+ const deploymentMode = process.env.VIBE80_DEPLOYMENT_MODE || defaultEnv.VIBE80_DEPLOYMENT_MODE;
107
451
  if (deploymentMode !== "mono_user") {
108
452
  return;
109
453
  }
@@ -128,7 +472,12 @@ const shutdown = (code = 0) => {
128
472
  process.exit(code);
129
473
  };
130
474
 
131
- const startServer = () => {
475
+ const startServer = (options = {}) => {
476
+ const enableCodexFromCli = Boolean(options.codex);
477
+ const enableClaudeFromCli = Boolean(options.claude);
478
+ const shouldOpenBrowser = options.open !== false;
479
+ const serverPort = options.port || process.env.VIBE80_PORT || "5179";
480
+
132
481
  unlinkMonoAuthUrlFile();
133
482
  const monoProviderEnv = {};
134
483
  if (enableCodexFromCli) {
@@ -137,6 +486,14 @@ const startServer = () => {
137
486
  if (enableClaudeFromCli) {
138
487
  monoProviderEnv.VIBE80_MONO_ENABLE_CLAUDE = "true";
139
488
  }
489
+ if (options.dataDir) {
490
+ monoProviderEnv.VIBE80_DATA_DIRECTORY = path.resolve(options.dataDir);
491
+ }
492
+ if (options.storageBackend) {
493
+ monoProviderEnv.VIBE80_STORAGE_BACKEND = options.storageBackend;
494
+ }
495
+ monoProviderEnv.VIBE80_PORT = String(serverPort);
496
+
140
497
  server = spawnProcess(
141
498
  process.execPath,
142
499
  ["server/src/index.js"],
@@ -146,7 +503,9 @@ const startServer = () => {
146
503
  ...monoProviderEnv,
147
504
  }
148
505
  );
149
- void maybeOpenMonoAuthUrl();
506
+ if (shouldOpenBrowser) {
507
+ void maybeOpenMonoAuthUrl(serverPort);
508
+ }
150
509
 
151
510
  server.on("exit", (code, signal) => {
152
511
  if (shuttingDown) return;
@@ -162,7 +521,1360 @@ const startServer = () => {
162
521
  });
163
522
  };
164
523
 
165
- startServer();
524
+ const resolveWorkspaceForCommand = (state, workspaceIdArg) => {
525
+ const workspaceId = workspaceIdArg || state.currentWorkspaceId;
526
+ if (!workspaceId) {
527
+ throw new Error("No workspace selected. Use `vibe80 workspace use <workspaceId>`.");
528
+ }
529
+ const entry = state.workspaces[workspaceId];
530
+ if (!entry) {
531
+ throw new Error(`Unknown workspace "${workspaceId}".`);
532
+ }
533
+ return { workspaceId, entry };
534
+ };
535
+
536
+ const upsertWorkspaceFromTokens = (state, workspaceId, baseUrl, payload, workspaceSecret) => {
537
+ const entry = ensureWorkspaceEntry(state, workspaceId);
538
+ entry.baseUrl = normalizeBaseUrl(baseUrl || entry.baseUrl || defaultBaseUrl);
539
+ if (workspaceSecret) {
540
+ entry.workspaceSecret = workspaceSecret;
541
+ }
542
+ if (payload.workspaceToken) {
543
+ entry.workspaceToken = payload.workspaceToken;
544
+ }
545
+ if (payload.refreshToken) {
546
+ entry.refreshToken = payload.refreshToken;
547
+ }
548
+ if (payload.expiresIn) {
549
+ entry.expiresAt = new Date(Date.now() + Number(payload.expiresIn) * 1000).toISOString();
550
+ }
551
+ if (payload.refreshExpiresIn) {
552
+ entry.refreshExpiresAt = new Date(
553
+ Date.now() + Number(payload.refreshExpiresIn) * 1000
554
+ ).toISOString();
555
+ }
556
+ entry.lastLoginAt = new Date().toISOString();
557
+ state.currentWorkspaceId = workspaceId;
558
+ };
559
+
560
+ const program = new Command();
561
+
562
+ program
563
+ .name("vibe80")
564
+ .description("Vibe80 CLI")
565
+ .showHelpAfterError()
566
+ .showSuggestionAfterError(true);
567
+
568
+ program
569
+ .command("run")
570
+ .description("Run the Vibe80 server (mono_user by default)")
571
+ .option("--codex", "Enable Codex provider in mono_user mode")
572
+ .option("--claude", "Enable Claude provider in mono_user mode")
573
+ .option("--port <port>", "Server port (default: 5179)")
574
+ .option("--data-dir <path>", "Override VIBE80_DATA_DIRECTORY")
575
+ .option("--storage-backend <backend>", "Override VIBE80_STORAGE_BACKEND (default: sqlite)")
576
+ .option("--no-open", "Do not auto-open authentication URL in a browser")
577
+ .action((options) => {
578
+ if (!options.codex && !options.claude) {
579
+ throw new Error("`vibe80 run` requires at least one provider flag: --codex or --claude.");
580
+ }
581
+ startServer(options);
582
+ });
583
+
584
+ const workspaceCommand = program
585
+ .command("workspace")
586
+ .alias("ws")
587
+ .description("Manage workspace context and authentication");
588
+
589
+ workspaceCommand
590
+ .command("ls")
591
+ .description("List known local workspaces")
592
+ .option("--json", "Output JSON")
593
+ .action((options) => {
594
+ const state = loadCliState();
595
+ const rows = Object.values(state.workspaces).map((entry) => ({
596
+ workspaceId: entry.workspaceId,
597
+ current: state.currentWorkspaceId === entry.workspaceId,
598
+ baseUrl: entry.baseUrl || normalizeBaseUrl(defaultBaseUrl),
599
+ hasToken: Boolean(entry.workspaceToken),
600
+ hasRefreshToken: Boolean(entry.refreshToken),
601
+ lastLoginAt: toIsoStringOrNull(entry.lastLoginAt),
602
+ }));
603
+ rows.sort((a, b) => a.workspaceId.localeCompare(b.workspaceId));
604
+ if (options.json) {
605
+ console.log(JSON.stringify({ currentWorkspaceId: state.currentWorkspaceId, workspaces: rows }, null, 2));
606
+ return;
607
+ }
608
+ if (!rows.length) {
609
+ console.log("No workspace saved locally.");
610
+ return;
611
+ }
612
+ for (const row of rows) {
613
+ const currentLabel = row.current ? "*" : " ";
614
+ const tokenLabel = row.hasToken ? "token" : "no-token";
615
+ console.log(
616
+ `${currentLabel} ${row.workspaceId} (${tokenLabel}) ${row.baseUrl}${row.lastLoginAt ? ` lastLogin=${row.lastLoginAt}` : ""}`
617
+ );
618
+ }
619
+ });
620
+
621
+ workspaceCommand
622
+ .command("current")
623
+ .description("Show current workspace")
624
+ .option("--json", "Output JSON")
625
+ .action((options) => {
626
+ const state = loadCliState();
627
+ const workspaceId = state.currentWorkspaceId;
628
+ if (!workspaceId) {
629
+ console.log("No current workspace selected.");
630
+ return;
631
+ }
632
+ const entry = state.workspaces[workspaceId] || { workspaceId };
633
+ const payload = {
634
+ workspaceId,
635
+ baseUrl: entry.baseUrl || normalizeBaseUrl(defaultBaseUrl),
636
+ hasToken: Boolean(entry.workspaceToken),
637
+ hasRefreshToken: Boolean(entry.refreshToken),
638
+ lastLoginAt: toIsoStringOrNull(entry.lastLoginAt),
639
+ };
640
+ if (options.json) {
641
+ console.log(JSON.stringify(payload, null, 2));
642
+ return;
643
+ }
644
+ console.log(payload.workspaceId);
645
+ });
646
+
647
+ workspaceCommand
648
+ .command("use <workspaceId>")
649
+ .description("Set current workspace")
650
+ .option("--base-url <url>", "Default API base URL for this workspace")
651
+ .action((workspaceId, options) => {
652
+ const state = loadCliState();
653
+ const entry = ensureWorkspaceEntry(state, workspaceId);
654
+ if (options.baseUrl) {
655
+ entry.baseUrl = normalizeBaseUrl(options.baseUrl);
656
+ }
657
+ state.currentWorkspaceId = workspaceId;
658
+ saveCliState(state);
659
+ console.log(`Current workspace: ${workspaceId}`);
660
+ });
661
+
662
+ workspaceCommand
663
+ .command("show [workspaceId]")
664
+ .description("Show workspace details (local + remote when possible)")
665
+ .option("--base-url <url>", "Override API base URL for remote call")
666
+ .option("--json", "Output JSON")
667
+ .action(async (workspaceIdArg, options) => {
668
+ const state = loadCliState();
669
+ const { workspaceId, entry } = resolveWorkspaceForCommand(state, workspaceIdArg);
670
+ const baseUrl = normalizeBaseUrl(options.baseUrl || entry.baseUrl || defaultBaseUrl);
671
+ const localPayload = {
672
+ workspaceId,
673
+ baseUrl,
674
+ workspaceSecretSaved: Boolean(entry.workspaceSecret),
675
+ workspaceToken: entry.workspaceToken ? maskToken(entry.workspaceToken) : null,
676
+ refreshToken: entry.refreshToken ? maskToken(entry.refreshToken) : null,
677
+ expiresAt: toIsoStringOrNull(entry.expiresAt),
678
+ refreshExpiresAt: toIsoStringOrNull(entry.refreshExpiresAt),
679
+ lastLoginAt: toIsoStringOrNull(entry.lastLoginAt),
680
+ };
681
+ let remotePayload = null;
682
+ let remoteError = null;
683
+ if (entry.workspaceToken || entry.refreshToken) {
684
+ try {
685
+ remotePayload = await authedApiRequest({
686
+ state,
687
+ workspaceId,
688
+ entry,
689
+ baseUrl,
690
+ pathname: `/api/v1/workspaces/${workspaceId}`,
691
+ });
692
+ } catch (error) {
693
+ remoteError = error.message || String(error);
694
+ }
695
+ }
696
+ const payload = { local: localPayload, remote: remotePayload, remoteError };
697
+ if (options.json) {
698
+ console.log(JSON.stringify(payload, null, 2));
699
+ return;
700
+ }
701
+ console.log(`Workspace: ${workspaceId}`);
702
+ console.log(`Base URL: ${baseUrl}`);
703
+ if (remotePayload) {
704
+ console.log("Remote providers:");
705
+ console.log(JSON.stringify(remotePayload.providers || {}, null, 2));
706
+ } else if (remoteError) {
707
+ console.log(`Remote check failed: ${remoteError}`);
708
+ } else {
709
+ console.log("Remote check skipped (no workspace token saved).");
710
+ }
711
+ });
712
+
713
+ workspaceCommand
714
+ .command("login")
715
+ .description("Login workspace and persist tokens locally")
716
+ .option("--workspace-id <id>", "Workspace ID")
717
+ .option("--workspace-secret <secret>", "Workspace secret (multi_user)")
718
+ .option("--mono-auth-token <token>", "One-shot mono auth token (mono_user)")
719
+ .option("--base-url <url>", "API base URL (default: http://localhost:5179)")
720
+ .option("--json", "Output JSON")
721
+ .action(async (options) => {
722
+ const state = loadCliState();
723
+ const baseUrl = normalizeBaseUrl(options.baseUrl || defaultBaseUrl);
724
+ const payload = options.monoAuthToken
725
+ ? {
726
+ grantType: "mono_auth_token",
727
+ monoAuthToken: String(options.monoAuthToken),
728
+ }
729
+ : {
730
+ workspaceId: options.workspaceId,
731
+ workspaceSecret: options.workspaceSecret,
732
+ };
733
+ const response = await apiRequest({
734
+ baseUrl,
735
+ pathname: "/api/v1/workspaces/login",
736
+ method: "POST",
737
+ body: payload,
738
+ });
739
+ const workspaceId =
740
+ response.workspaceId || options.workspaceId || state.currentWorkspaceId || "default";
741
+ upsertWorkspaceFromTokens(
742
+ state,
743
+ workspaceId,
744
+ baseUrl,
745
+ response,
746
+ options.workspaceSecret || null
747
+ );
748
+ saveCliState(state);
749
+ if (options.json) {
750
+ console.log(
751
+ JSON.stringify(
752
+ {
753
+ workspaceId,
754
+ expiresAt: state.workspaces[workspaceId]?.expiresAt || null,
755
+ refreshExpiresAt: state.workspaces[workspaceId]?.refreshExpiresAt || null,
756
+ },
757
+ null,
758
+ 2
759
+ )
760
+ );
761
+ return;
762
+ }
763
+ console.log(`Workspace login success: ${workspaceId}`);
764
+ });
765
+
766
+ workspaceCommand
767
+ .command("refresh")
768
+ .description("Refresh workspace token with saved refresh token")
769
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
770
+ .option("--base-url <url>", "API base URL")
771
+ .option("--json", "Output JSON")
772
+ .action(async (options) => {
773
+ const state = loadCliState();
774
+ const { workspaceId, entry } = resolveWorkspaceForCommand(state, options.workspaceId);
775
+ if (!entry.refreshToken) {
776
+ throw new Error(`No refresh token saved for workspace "${workspaceId}".`);
777
+ }
778
+ const baseUrl = normalizeBaseUrl(options.baseUrl || entry.baseUrl || defaultBaseUrl);
779
+ const response = await apiRequest({
780
+ baseUrl,
781
+ pathname: "/api/v1/workspaces/refresh",
782
+ method: "POST",
783
+ body: { refreshToken: entry.refreshToken },
784
+ });
785
+ upsertWorkspaceFromTokens(state, response.workspaceId || workspaceId, baseUrl, response, null);
786
+ saveCliState(state);
787
+ if (options.json) {
788
+ console.log(JSON.stringify(response, null, 2));
789
+ return;
790
+ }
791
+ console.log(`Workspace token refreshed: ${response.workspaceId || workspaceId}`);
792
+ });
793
+
794
+ workspaceCommand
795
+ .command("logout")
796
+ .description("Delete saved tokens for a workspace")
797
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
798
+ .action((options) => {
799
+ const state = loadCliState();
800
+ const { workspaceId, entry } = resolveWorkspaceForCommand(state, options.workspaceId);
801
+ delete entry.workspaceToken;
802
+ delete entry.refreshToken;
803
+ delete entry.expiresAt;
804
+ delete entry.refreshExpiresAt;
805
+ saveCliState(state);
806
+ console.log(`Logged out: ${workspaceId}`);
807
+ });
808
+
809
+ workspaceCommand
810
+ .command("create")
811
+ .description("Create a workspace")
812
+ .option("--base-url <url>", "API base URL")
813
+ .option("--enable <provider>", "Enable provider (repeatable, supports comma-separated)", parseListOption, [])
814
+ .option("--codex-auth-type <type>", "Codex auth type (api_key|auth_json_b64)")
815
+ .option("--codex-auth-value <value>", "Codex auth value")
816
+ .option("--claude-auth-type <type>", "Claude auth type (api_key|setup_token)")
817
+ .option("--claude-auth-value <value>", "Claude auth value")
818
+ .option("--json", "Output JSON")
819
+ .action(async (options) => {
820
+ const patch = buildProvidersPatch(options);
821
+ const baseUrl = normalizeBaseUrl(options.baseUrl || defaultBaseUrl);
822
+ const response = await apiRequest({
823
+ baseUrl,
824
+ pathname: "/api/v1/workspaces",
825
+ method: "POST",
826
+ body: { providers: patch },
827
+ });
828
+ const state = loadCliState();
829
+ const entry = ensureWorkspaceEntry(state, response.workspaceId);
830
+ entry.baseUrl = baseUrl;
831
+ entry.workspaceSecret = response.workspaceSecret || null;
832
+ state.currentWorkspaceId = response.workspaceId;
833
+ saveCliState(state);
834
+ if (options.json) {
835
+ console.log(JSON.stringify(response, null, 2));
836
+ return;
837
+ }
838
+ console.log(`Workspace created: ${response.workspaceId}`);
839
+ if (response.workspaceSecret) {
840
+ console.log(`Workspace secret: ${response.workspaceSecret}`);
841
+ }
842
+ });
843
+
844
+ workspaceCommand
845
+ .command("update <workspaceId>")
846
+ .description("Update workspace providers/auth config")
847
+ .option("--base-url <url>", "API base URL")
848
+ .option("--enable <provider>", "Enable provider (repeatable, supports comma-separated)", parseListOption, [])
849
+ .option("--disable <provider>", "Disable provider (repeatable, supports comma-separated)", parseListOption, [])
850
+ .option("--codex-auth-type <type>", "Codex auth type (api_key|auth_json_b64)")
851
+ .option("--codex-auth-value <value>", "Codex auth value")
852
+ .option("--claude-auth-type <type>", "Claude auth type (api_key|setup_token)")
853
+ .option("--claude-auth-value <value>", "Claude auth value")
854
+ .option("--json", "Output JSON")
855
+ .action(async (workspaceId, options) => {
856
+ const state = loadCliState();
857
+ const entry = ensureWorkspaceEntry(state, workspaceId);
858
+ const baseUrl = normalizeBaseUrl(options.baseUrl || entry.baseUrl || defaultBaseUrl);
859
+ const patch = buildProvidersPatch(options);
860
+ const response = await authedApiRequest({
861
+ state,
862
+ workspaceId,
863
+ entry,
864
+ baseUrl,
865
+ pathname: `/api/v1/workspaces/${workspaceId}`,
866
+ method: "PATCH",
867
+ body: { providers: patch },
868
+ });
869
+ entry.baseUrl = baseUrl;
870
+ saveCliState(state);
871
+ if (options.json) {
872
+ console.log(JSON.stringify(response, null, 2));
873
+ return;
874
+ }
875
+ console.log(`Workspace updated: ${workspaceId}`);
876
+ });
877
+
878
+ workspaceCommand
879
+ .command("rm <workspaceId>")
880
+ .description("Delete workspace (server policy may refuse)")
881
+ .option("--base-url <url>", "API base URL")
882
+ .option("--yes", "Confirm deletion")
883
+ .action(async (workspaceId, options) => {
884
+ if (!options.yes) {
885
+ throw new Error("Refusing to delete without --yes.");
886
+ }
887
+ const state = loadCliState();
888
+ const entry = ensureWorkspaceEntry(state, workspaceId);
889
+ const baseUrl = normalizeBaseUrl(options.baseUrl || entry.baseUrl || defaultBaseUrl);
890
+ await authedApiRequest({
891
+ state,
892
+ workspaceId,
893
+ entry,
894
+ baseUrl,
895
+ pathname: `/api/v1/workspaces/${workspaceId}`,
896
+ method: "DELETE",
897
+ });
898
+ delete state.workspaces[workspaceId];
899
+ if (state.currentWorkspaceId === workspaceId) {
900
+ state.currentWorkspaceId = null;
901
+ }
902
+ saveCliState(state);
903
+ console.log(`Workspace deleted: ${workspaceId}`);
904
+ });
905
+
906
+ const resolveWorkspaceAuthContext = async (state, options = {}) => {
907
+ const { workspaceId, entry } = resolveWorkspaceForCommand(state, options.workspaceId);
908
+ const baseUrl = normalizeBaseUrl(options.baseUrl || entry.baseUrl || defaultBaseUrl);
909
+ const ensured = await ensureWorkspaceAccessToken({
910
+ state,
911
+ workspaceId,
912
+ entry,
913
+ baseUrl,
914
+ });
915
+ return {
916
+ workspaceId: ensured.workspaceId,
917
+ entry: ensured.entry,
918
+ baseUrl,
919
+ };
920
+ };
921
+
922
+ const resolveSessionForCommand = (state, workspaceId, sessionIdArg) => {
923
+ const sessionId = sessionIdArg || getCurrentSessionForWorkspace(state, workspaceId);
924
+ if (!sessionId) {
925
+ throw new Error("No session selected. Use `vibe80 session use <sessionId>`.");
926
+ }
927
+ return sessionId;
928
+ };
929
+
930
+ const sessionCommand = program
931
+ .command("session")
932
+ .alias("s")
933
+ .description("Manage sessions for the current workspace");
934
+
935
+ sessionCommand
936
+ .command("ls")
937
+ .description("List sessions from API for the selected workspace")
938
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
939
+ .option("--base-url <url>", "API base URL")
940
+ .option("--json", "Output JSON")
941
+ .action(async (options) => {
942
+ const state = loadCliState();
943
+ const { workspaceId, baseUrl, entry } = await resolveWorkspaceAuthContext(state, options);
944
+ const response = await authedApiRequest({
945
+ state,
946
+ workspaceId,
947
+ entry,
948
+ baseUrl,
949
+ pathname: "/api/v1/sessions",
950
+ });
951
+ const sessions = Array.isArray(response.sessions) ? response.sessions : [];
952
+ for (const session of sessions) {
953
+ upsertKnownSession(state, workspaceId, session);
954
+ }
955
+ saveCliState(state);
956
+ const currentSessionId = getCurrentSessionForWorkspace(state, workspaceId);
957
+ if (options.json) {
958
+ console.log(JSON.stringify({ workspaceId, currentSessionId, sessions }, null, 2));
959
+ return;
960
+ }
961
+ if (!sessions.length) {
962
+ console.log("No session found.");
963
+ return;
964
+ }
965
+ for (const session of sessions) {
966
+ const marker = currentSessionId === session.sessionId ? "*" : " ";
967
+ const name = session.name ? ` name="${session.name}"` : "";
968
+ const repo = session.repoUrl ? ` repo=${session.repoUrl}` : "";
969
+ console.log(`${marker} ${session.sessionId}${name}${repo}`);
970
+ }
971
+ });
972
+
973
+ sessionCommand
974
+ .command("current")
975
+ .description("Show current session in selected workspace")
976
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
977
+ .option("--json", "Output JSON")
978
+ .action((options) => {
979
+ const state = loadCliState();
980
+ const { workspaceId } = resolveWorkspaceForCommand(state, options.workspaceId);
981
+ const currentSessionId = getCurrentSessionForWorkspace(state, workspaceId);
982
+ if (options.json) {
983
+ console.log(JSON.stringify({ workspaceId, sessionId: currentSessionId }, null, 2));
984
+ return;
985
+ }
986
+ if (!currentSessionId) {
987
+ console.log("No current session selected.");
988
+ return;
989
+ }
990
+ console.log(currentSessionId);
991
+ });
992
+
993
+ sessionCommand
994
+ .command("use <sessionId>")
995
+ .description("Set current session for current workspace")
996
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
997
+ .action((sessionId, options) => {
998
+ const state = loadCliState();
999
+ const { workspaceId } = resolveWorkspaceForCommand(state, options.workspaceId);
1000
+ ensureSessionWorkspaceMap(state, workspaceId);
1001
+ setCurrentSessionForWorkspace(state, workspaceId, sessionId);
1002
+ saveCliState(state);
1003
+ console.log(`Current session (${workspaceId}): ${sessionId}`);
1004
+ });
1005
+
1006
+ sessionCommand
1007
+ .command("show [sessionId]")
1008
+ .description("Show session details")
1009
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
1010
+ .option("--base-url <url>", "API base URL")
1011
+ .option("--json", "Output JSON")
1012
+ .action(async (sessionIdArg, options) => {
1013
+ const state = loadCliState();
1014
+ const { workspaceId, baseUrl, entry } = await resolveWorkspaceAuthContext(state, options);
1015
+ const sessionId = resolveSessionForCommand(state, workspaceId, sessionIdArg);
1016
+ const response = await authedApiRequest({
1017
+ state,
1018
+ workspaceId,
1019
+ entry,
1020
+ baseUrl,
1021
+ pathname: `/api/v1/sessions/${sessionId}`,
1022
+ });
1023
+ upsertKnownSession(state, workspaceId, response);
1024
+ saveCliState(state);
1025
+ if (options.json) {
1026
+ console.log(JSON.stringify(response, null, 2));
1027
+ return;
1028
+ }
1029
+ console.log(`Session: ${response.sessionId}`);
1030
+ console.log(`Name: ${response.name || "-"}`);
1031
+ console.log(`Repo: ${response.repoUrl || "-"}`);
1032
+ console.log(`Provider: ${response.defaultProvider || "-"}`);
1033
+ });
1034
+
1035
+ sessionCommand
1036
+ .command("create")
1037
+ .description("Create a new session")
1038
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
1039
+ .option("--base-url <url>", "API base URL")
1040
+ .requiredOption("--repo-url <url>", "Repository URL")
1041
+ .option("--name <name>", "Session display name")
1042
+ .option("--default-internet-access <bool>", "true|false")
1043
+ .option("--default-deny-git-credentials-access <bool>", "true|false")
1044
+ .option("--json", "Output JSON")
1045
+ .action(async (options) => {
1046
+ const state = loadCliState();
1047
+ const { workspaceId, baseUrl, entry } = await resolveWorkspaceAuthContext(state, options);
1048
+ const body = {
1049
+ repoUrl: options.repoUrl,
1050
+ name: options.name,
1051
+ };
1052
+ if (typeof options.defaultInternetAccess === "string") {
1053
+ body.defaultInternetAccess = options.defaultInternetAccess === "true";
1054
+ }
1055
+ if (typeof options.defaultDenyGitCredentialsAccess === "string") {
1056
+ body.defaultDenyGitCredentialsAccess =
1057
+ options.defaultDenyGitCredentialsAccess === "true";
1058
+ }
1059
+ const response = await authedApiRequest({
1060
+ state,
1061
+ workspaceId,
1062
+ entry,
1063
+ baseUrl,
1064
+ pathname: "/api/v1/sessions",
1065
+ method: "POST",
1066
+ body,
1067
+ });
1068
+ upsertKnownSession(state, workspaceId, response);
1069
+ setCurrentSessionForWorkspace(state, workspaceId, response.sessionId);
1070
+ saveCliState(state);
1071
+ if (options.json) {
1072
+ console.log(JSON.stringify(response, null, 2));
1073
+ return;
1074
+ }
1075
+ console.log(`Session created: ${response.sessionId}`);
1076
+ });
1077
+
1078
+ sessionCommand
1079
+ .command("rm [sessionId]")
1080
+ .description("Delete a session")
1081
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
1082
+ .option("--base-url <url>", "API base URL")
1083
+ .option("--yes", "Confirm deletion")
1084
+ .action(async (sessionIdArg, options) => {
1085
+ if (!options.yes) {
1086
+ throw new Error("Refusing to delete without --yes.");
1087
+ }
1088
+ const state = loadCliState();
1089
+ const { workspaceId, baseUrl, entry } = await resolveWorkspaceAuthContext(state, options);
1090
+ const sessionId = resolveSessionForCommand(state, workspaceId, sessionIdArg);
1091
+ const response = await authedApiRequest({
1092
+ state,
1093
+ workspaceId,
1094
+ entry,
1095
+ baseUrl,
1096
+ pathname: `/api/v1/sessions/${sessionId}`,
1097
+ method: "DELETE",
1098
+ });
1099
+ const map = ensureSessionWorkspaceMap(state, workspaceId);
1100
+ delete map[sessionId];
1101
+ setCurrentWorktreeForSession(state, workspaceId, sessionId, null);
1102
+ if (getCurrentSessionForWorkspace(state, workspaceId) === sessionId) {
1103
+ setCurrentSessionForWorkspace(state, workspaceId, null);
1104
+ }
1105
+ saveCliState(state);
1106
+ console.log(`Session deleted: ${response.sessionId || sessionId}`);
1107
+ });
1108
+
1109
+ sessionCommand
1110
+ .command("health [sessionId]")
1111
+ .description("Get session health")
1112
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
1113
+ .option("--base-url <url>", "API base URL")
1114
+ .option("--json", "Output JSON")
1115
+ .action(async (sessionIdArg, options) => {
1116
+ const state = loadCliState();
1117
+ const { workspaceId, baseUrl, entry } = await resolveWorkspaceAuthContext(state, options);
1118
+ const sessionId = resolveSessionForCommand(state, workspaceId, sessionIdArg);
1119
+ const response = await authedApiRequest({
1120
+ state,
1121
+ workspaceId,
1122
+ entry,
1123
+ baseUrl,
1124
+ pathname: `/api/v1/sessions/${sessionId}/health`,
1125
+ });
1126
+ if (options.json) {
1127
+ console.log(JSON.stringify(response, null, 2));
1128
+ return;
1129
+ }
1130
+ console.log(
1131
+ `${sessionId}: ok=${Boolean(response.ok)} ready=${Boolean(response.ready)} provider=${response.provider || "-"}`
1132
+ );
1133
+ });
1134
+
1135
+ const handoffCommand = sessionCommand
1136
+ .command("handoff")
1137
+ .description("Create or consume session handoff tokens");
1138
+
1139
+ handoffCommand
1140
+ .command("create [sessionId]")
1141
+ .description("Create handoff token for a session")
1142
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
1143
+ .option("--base-url <url>", "API base URL")
1144
+ .option("--json", "Output JSON")
1145
+ .action(async (sessionIdArg, options) => {
1146
+ const state = loadCliState();
1147
+ const { workspaceId, baseUrl, entry } = await resolveWorkspaceAuthContext(state, options);
1148
+ const sessionId = resolveSessionForCommand(state, workspaceId, sessionIdArg);
1149
+ const response = await authedApiRequest({
1150
+ state,
1151
+ workspaceId,
1152
+ entry,
1153
+ baseUrl,
1154
+ pathname: "/api/v1/sessions/handoff",
1155
+ method: "POST",
1156
+ body: { sessionId },
1157
+ });
1158
+ if (options.json) {
1159
+ console.log(JSON.stringify(response, null, 2));
1160
+ return;
1161
+ }
1162
+ console.log(`handoffToken=${response.handoffToken}`);
1163
+ if (response.expiresAt) {
1164
+ console.log(`expiresAt=${response.expiresAt}`);
1165
+ }
1166
+ });
1167
+
1168
+ handoffCommand
1169
+ .command("consume")
1170
+ .description("Consume handoff token and save returned workspace/session context")
1171
+ .requiredOption("--token <handoffToken>", "Handoff token")
1172
+ .option("--base-url <url>", "API base URL")
1173
+ .option("--json", "Output JSON")
1174
+ .action(async (options) => {
1175
+ const baseUrl = normalizeBaseUrl(options.baseUrl || defaultBaseUrl);
1176
+ const response = await apiRequest({
1177
+ baseUrl,
1178
+ pathname: "/api/v1/sessions/handoff/consume",
1179
+ method: "POST",
1180
+ body: { handoffToken: options.token },
1181
+ });
1182
+ const state = loadCliState();
1183
+ upsertWorkspaceFromTokens(state, response.workspaceId, baseUrl, response, null);
1184
+ if (response.sessionId) {
1185
+ upsertKnownSession(state, response.workspaceId, { sessionId: response.sessionId });
1186
+ setCurrentSessionForWorkspace(state, response.workspaceId, response.sessionId);
1187
+ }
1188
+ saveCliState(state);
1189
+ if (options.json) {
1190
+ console.log(JSON.stringify(response, null, 2));
1191
+ return;
1192
+ }
1193
+ console.log(
1194
+ `Handoff consumed: workspace=${response.workspaceId}${response.sessionId ? ` session=${response.sessionId}` : ""}`
1195
+ );
1196
+ });
1197
+
1198
+ const resolveSessionAuthContext = async (state, options = {}, sessionIdArg = null) => {
1199
+ const { workspaceId, entry, baseUrl } = await resolveWorkspaceAuthContext(state, options);
1200
+ const sessionId = resolveSessionForCommand(state, workspaceId, sessionIdArg);
1201
+ return { workspaceId, entry, baseUrl, sessionId };
1202
+ };
1203
+
1204
+ const resolveWorktreeForCommand = (state, workspaceId, sessionId, worktreeIdArg) => {
1205
+ const worktreeId = worktreeIdArg || getCurrentWorktreeForSession(state, workspaceId, sessionId);
1206
+ if (!worktreeId) {
1207
+ throw new Error("No worktree selected. Use `vibe80 worktree use <worktreeId>`.");
1208
+ }
1209
+ return worktreeId;
1210
+ };
1211
+
1212
+ const uploadAttachmentFiles = async ({
1213
+ state,
1214
+ workspaceId,
1215
+ entry,
1216
+ baseUrl,
1217
+ sessionId,
1218
+ files,
1219
+ }) => {
1220
+ if (!Array.isArray(files) || !files.length) {
1221
+ return [];
1222
+ }
1223
+ const doUpload = async (workspaceToken) => {
1224
+ const formData = new FormData();
1225
+ for (const filePath of files) {
1226
+ const absPath = path.resolve(filePath);
1227
+ const filename = path.basename(absPath);
1228
+ const buffer = fs.readFileSync(absPath);
1229
+ const blob = new Blob([buffer]);
1230
+ formData.append("files", blob, filename);
1231
+ }
1232
+ const response = await fetch(
1233
+ `${normalizeBaseUrl(baseUrl)}/api/v1/sessions/${encodeURIComponent(sessionId)}/attachments/upload`,
1234
+ {
1235
+ method: "POST",
1236
+ headers: {
1237
+ authorization: `Bearer ${workspaceToken}`,
1238
+ },
1239
+ body: formData,
1240
+ }
1241
+ );
1242
+ const raw = await response.text();
1243
+ let payload = {};
1244
+ if (raw) {
1245
+ try {
1246
+ payload = JSON.parse(raw);
1247
+ } catch {
1248
+ payload = { raw };
1249
+ }
1250
+ }
1251
+ if (!response.ok) {
1252
+ const message =
1253
+ payload?.error || payload?.message || `Attachment upload failed (${response.status}).`;
1254
+ const error = new Error(message);
1255
+ error.status = response.status;
1256
+ error.payload = payload;
1257
+ throw error;
1258
+ }
1259
+ return payload;
1260
+ };
1261
+
1262
+ const ensured = await ensureWorkspaceAccessToken({
1263
+ state,
1264
+ workspaceId,
1265
+ entry,
1266
+ baseUrl,
1267
+ });
1268
+ let activeWorkspaceId = ensured.workspaceId;
1269
+ let activeEntry = ensured.entry;
1270
+ try {
1271
+ const payload = await doUpload(activeEntry.workspaceToken);
1272
+ return Array.isArray(payload?.files) ? payload.files : [];
1273
+ } catch (error) {
1274
+ if (error?.status !== 401) {
1275
+ throw error;
1276
+ }
1277
+ const refreshed = await refreshWorkspaceAccessToken({
1278
+ state,
1279
+ workspaceId: activeWorkspaceId,
1280
+ entry: activeEntry,
1281
+ baseUrl,
1282
+ });
1283
+ activeWorkspaceId = refreshed.workspaceId;
1284
+ activeEntry = refreshed.entry;
1285
+ const payload = await doUpload(activeEntry.workspaceToken);
1286
+ return Array.isArray(payload?.files) ? payload.files : [];
1287
+ }
1288
+ };
1289
+
1290
+ const worktreeCommand = program
1291
+ .command("worktree")
1292
+ .alias("wt")
1293
+ .description("Manage worktrees for the current session");
1294
+
1295
+ worktreeCommand
1296
+ .command("ls")
1297
+ .description("List worktrees for a session")
1298
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
1299
+ .option("--session-id <id>", "Session ID (default: current for workspace)")
1300
+ .option("--base-url <url>", "API base URL")
1301
+ .option("--json", "Output JSON")
1302
+ .action(async (options) => {
1303
+ const state = loadCliState();
1304
+ const { workspaceId, baseUrl, entry, sessionId } = await resolveSessionAuthContext(
1305
+ state,
1306
+ options,
1307
+ options.sessionId
1308
+ );
1309
+ const response = await authedApiRequest({
1310
+ state,
1311
+ workspaceId,
1312
+ entry,
1313
+ baseUrl,
1314
+ pathname: `/api/v1/sessions/${sessionId}/worktrees`,
1315
+ });
1316
+ const worktrees = Array.isArray(response.worktrees) ? response.worktrees : [];
1317
+ const currentWorktreeId = getCurrentWorktreeForSession(state, workspaceId, sessionId);
1318
+ if (options.json) {
1319
+ console.log(JSON.stringify({ workspaceId, sessionId, currentWorktreeId, worktrees }, null, 2));
1320
+ return;
1321
+ }
1322
+ if (!worktrees.length) {
1323
+ console.log("No worktree found.");
1324
+ return;
1325
+ }
1326
+ for (const wt of worktrees) {
1327
+ const marker = currentWorktreeId === wt.id ? "*" : " ";
1328
+ console.log(
1329
+ `${marker} ${wt.id} name="${wt.name || "-"}" branch=${wt.branchName || "-"} provider=${wt.provider || "-"} status=${wt.status || "-"}`
1330
+ );
1331
+ }
1332
+ });
1333
+
1334
+ worktreeCommand
1335
+ .command("current")
1336
+ .description("Show current worktree in selected session")
1337
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
1338
+ .option("--session-id <id>", "Session ID (default: current for workspace)")
1339
+ .option("--json", "Output JSON")
1340
+ .action((options) => {
1341
+ const state = loadCliState();
1342
+ const { workspaceId } = resolveWorkspaceForCommand(state, options.workspaceId);
1343
+ const sessionId = resolveSessionForCommand(state, workspaceId, options.sessionId);
1344
+ const worktreeId = getCurrentWorktreeForSession(state, workspaceId, sessionId);
1345
+ if (options.json) {
1346
+ console.log(JSON.stringify({ workspaceId, sessionId, worktreeId }, null, 2));
1347
+ return;
1348
+ }
1349
+ if (!worktreeId) {
1350
+ console.log("No current worktree selected.");
1351
+ return;
1352
+ }
1353
+ console.log(worktreeId);
1354
+ });
1355
+
1356
+ worktreeCommand
1357
+ .command("use <worktreeId>")
1358
+ .description("Set current worktree for selected session")
1359
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
1360
+ .option("--session-id <id>", "Session ID (default: current for workspace)")
1361
+ .action((worktreeId, options) => {
1362
+ const state = loadCliState();
1363
+ const { workspaceId } = resolveWorkspaceForCommand(state, options.workspaceId);
1364
+ const sessionId = resolveSessionForCommand(state, workspaceId, options.sessionId);
1365
+ setCurrentWorktreeForSession(state, workspaceId, sessionId, worktreeId);
1366
+ saveCliState(state);
1367
+ console.log(`Current worktree (${workspaceId}/${sessionId}): ${worktreeId}`);
1368
+ });
1369
+
1370
+ worktreeCommand
1371
+ .command("show [worktreeId]")
1372
+ .description("Show worktree details")
1373
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
1374
+ .option("--session-id <id>", "Session ID (default: current for workspace)")
1375
+ .option("--base-url <url>", "API base URL")
1376
+ .option("--json", "Output JSON")
1377
+ .action(async (worktreeIdArg, options) => {
1378
+ const state = loadCliState();
1379
+ const { workspaceId, baseUrl, entry, sessionId } = await resolveSessionAuthContext(
1380
+ state,
1381
+ options,
1382
+ options.sessionId
1383
+ );
1384
+ const worktreeId = resolveWorktreeForCommand(state, workspaceId, sessionId, worktreeIdArg);
1385
+ const response = await authedApiRequest({
1386
+ state,
1387
+ workspaceId,
1388
+ entry,
1389
+ baseUrl,
1390
+ pathname: `/api/v1/sessions/${sessionId}/worktrees/${worktreeId}`,
1391
+ });
1392
+ if (options.json) {
1393
+ console.log(JSON.stringify(response, null, 2));
1394
+ return;
1395
+ }
1396
+ console.log(`Worktree: ${response.id}`);
1397
+ console.log(`Name: ${response.name || "-"}`);
1398
+ console.log(`Branch: ${response.branchName || "-"}`);
1399
+ console.log(`Provider: ${response.provider || "-"}`);
1400
+ console.log(`Status: ${response.status || "-"}`);
1401
+ });
1402
+
1403
+ worktreeCommand
1404
+ .command("create")
1405
+ .description("Create a new worktree (context=new)")
1406
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
1407
+ .option("--session-id <id>", "Session ID (default: current for workspace)")
1408
+ .option("--base-url <url>", "API base URL")
1409
+ .requiredOption("--provider <provider>", "codex|claude")
1410
+ .option("--name <name>", "Worktree display name")
1411
+ .option("--json", "Output JSON")
1412
+ .action(async (options) => {
1413
+ const provider = parseProviderName(options.provider);
1414
+ const state = loadCliState();
1415
+ const { workspaceId, baseUrl, entry, sessionId } = await resolveSessionAuthContext(
1416
+ state,
1417
+ options,
1418
+ options.sessionId
1419
+ );
1420
+ const response = await authedApiRequest({
1421
+ state,
1422
+ workspaceId,
1423
+ entry,
1424
+ baseUrl,
1425
+ pathname: `/api/v1/sessions/${sessionId}/worktrees`,
1426
+ method: "POST",
1427
+ body: {
1428
+ context: "new",
1429
+ provider,
1430
+ name: options.name || null,
1431
+ },
1432
+ });
1433
+ setCurrentWorktreeForSession(state, workspaceId, sessionId, response.worktreeId);
1434
+ saveCliState(state);
1435
+ if (options.json) {
1436
+ console.log(JSON.stringify(response, null, 2));
1437
+ return;
1438
+ }
1439
+ console.log(`Worktree created: ${response.worktreeId}`);
1440
+ });
1441
+
1442
+ worktreeCommand
1443
+ .command("fork")
1444
+ .description("Fork a worktree (context=fork)")
1445
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
1446
+ .option("--session-id <id>", "Session ID (default: current for workspace)")
1447
+ .option("--base-url <url>", "API base URL")
1448
+ .requiredOption("--from <worktreeId>", "Source worktree id")
1449
+ .option("--name <name>", "Worktree display name")
1450
+ .option("--json", "Output JSON")
1451
+ .action(async (options) => {
1452
+ const state = loadCliState();
1453
+ const { workspaceId, baseUrl, entry, sessionId } = await resolveSessionAuthContext(
1454
+ state,
1455
+ options,
1456
+ options.sessionId
1457
+ );
1458
+ const response = await authedApiRequest({
1459
+ state,
1460
+ workspaceId,
1461
+ entry,
1462
+ baseUrl,
1463
+ pathname: `/api/v1/sessions/${sessionId}/worktrees`,
1464
+ method: "POST",
1465
+ body: {
1466
+ context: "fork",
1467
+ sourceWorktree: options.from,
1468
+ name: options.name || null,
1469
+ },
1470
+ });
1471
+ setCurrentWorktreeForSession(state, workspaceId, sessionId, response.worktreeId);
1472
+ saveCliState(state);
1473
+ if (options.json) {
1474
+ console.log(JSON.stringify(response, null, 2));
1475
+ return;
1476
+ }
1477
+ console.log(`Worktree forked: ${response.worktreeId} (from ${options.from})`);
1478
+ });
1479
+
1480
+ worktreeCommand
1481
+ .command("rm [worktreeId]")
1482
+ .description("Delete a worktree")
1483
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
1484
+ .option("--session-id <id>", "Session ID (default: current for workspace)")
1485
+ .option("--base-url <url>", "API base URL")
1486
+ .option("--yes", "Confirm deletion")
1487
+ .action(async (worktreeIdArg, options) => {
1488
+ if (!options.yes) {
1489
+ throw new Error("Refusing to delete without --yes.");
1490
+ }
1491
+ const state = loadCliState();
1492
+ const { workspaceId, baseUrl, entry, sessionId } = await resolveSessionAuthContext(
1493
+ state,
1494
+ options,
1495
+ options.sessionId
1496
+ );
1497
+ const worktreeId = resolveWorktreeForCommand(state, workspaceId, sessionId, worktreeIdArg);
1498
+ await authedApiRequest({
1499
+ state,
1500
+ workspaceId,
1501
+ entry,
1502
+ baseUrl,
1503
+ pathname: `/api/v1/sessions/${sessionId}/worktrees/${worktreeId}`,
1504
+ method: "DELETE",
1505
+ });
1506
+ if (getCurrentWorktreeForSession(state, workspaceId, sessionId) === worktreeId) {
1507
+ setCurrentWorktreeForSession(state, workspaceId, sessionId, null);
1508
+ }
1509
+ saveCliState(state);
1510
+ console.log(`Worktree deleted: ${worktreeId}`);
1511
+ });
1512
+
1513
+ worktreeCommand
1514
+ .command("rename [worktreeId]")
1515
+ .description("Rename a worktree")
1516
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
1517
+ .option("--session-id <id>", "Session ID (default: current for workspace)")
1518
+ .option("--base-url <url>", "API base URL")
1519
+ .requiredOption("--name <name>", "New worktree name")
1520
+ .option("--json", "Output JSON")
1521
+ .action(async (worktreeIdArg, options) => {
1522
+ const state = loadCliState();
1523
+ const { workspaceId, baseUrl, entry, sessionId } = await resolveSessionAuthContext(
1524
+ state,
1525
+ options,
1526
+ options.sessionId
1527
+ );
1528
+ const worktreeId = resolveWorktreeForCommand(state, workspaceId, sessionId, worktreeIdArg);
1529
+ const response = await authedApiRequest({
1530
+ state,
1531
+ workspaceId,
1532
+ entry,
1533
+ baseUrl,
1534
+ pathname: `/api/v1/sessions/${sessionId}/worktrees/${worktreeId}`,
1535
+ method: "PATCH",
1536
+ body: { name: options.name },
1537
+ });
1538
+ if (options.json) {
1539
+ console.log(JSON.stringify(response, null, 2));
1540
+ return;
1541
+ }
1542
+ console.log(`Worktree renamed: ${worktreeId} -> ${options.name}`);
1543
+ });
1544
+
1545
+ worktreeCommand
1546
+ .command("wakeup [worktreeId]")
1547
+ .description("Wake provider for a worktree")
1548
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
1549
+ .option("--session-id <id>", "Session ID (default: current for workspace)")
1550
+ .option("--base-url <url>", "API base URL")
1551
+ .option("--timeout-ms <ms>", "Wakeup timeout in milliseconds")
1552
+ .option("--json", "Output JSON")
1553
+ .action(async (worktreeIdArg, options) => {
1554
+ const state = loadCliState();
1555
+ const { workspaceId, baseUrl, entry, sessionId } = await resolveSessionAuthContext(
1556
+ state,
1557
+ options,
1558
+ options.sessionId
1559
+ );
1560
+ const worktreeId = resolveWorktreeForCommand(state, workspaceId, sessionId, worktreeIdArg);
1561
+ const body = {};
1562
+ if (options.timeoutMs != null) {
1563
+ body.timeoutMs = Number.parseInt(options.timeoutMs, 10);
1564
+ }
1565
+ const response = await authedApiRequest({
1566
+ state,
1567
+ workspaceId,
1568
+ entry,
1569
+ baseUrl,
1570
+ pathname: `/api/v1/sessions/${sessionId}/worktrees/${worktreeId}/wakeup`,
1571
+ method: "POST",
1572
+ body,
1573
+ });
1574
+ if (options.json) {
1575
+ console.log(JSON.stringify(response, null, 2));
1576
+ return;
1577
+ }
1578
+ console.log(
1579
+ `${response.worktreeId || worktreeId}: status=${response.status || "ready"} provider=${response.provider || "-"}`
1580
+ );
1581
+ });
1582
+
1583
+ worktreeCommand
1584
+ .command("status [worktreeId]")
1585
+ .description("Show git status entries for a worktree")
1586
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
1587
+ .option("--session-id <id>", "Session ID (default: current for workspace)")
1588
+ .option("--base-url <url>", "API base URL")
1589
+ .option("--json", "Output JSON")
1590
+ .action(async (worktreeIdArg, options) => {
1591
+ const state = loadCliState();
1592
+ const { workspaceId, baseUrl, entry, sessionId } = await resolveSessionAuthContext(
1593
+ state,
1594
+ options,
1595
+ options.sessionId
1596
+ );
1597
+ const worktreeId = resolveWorktreeForCommand(state, workspaceId, sessionId, worktreeIdArg);
1598
+ const response = await authedApiRequest({
1599
+ state,
1600
+ workspaceId,
1601
+ entry,
1602
+ baseUrl,
1603
+ pathname: `/api/v1/sessions/${sessionId}/worktrees/${worktreeId}/status`,
1604
+ });
1605
+ const entries = Array.isArray(response.entries) ? response.entries : [];
1606
+ if (options.json) {
1607
+ console.log(JSON.stringify({ worktreeId, entries }, null, 2));
1608
+ return;
1609
+ }
1610
+ if (!entries.length) {
1611
+ console.log("Clean worktree.");
1612
+ return;
1613
+ }
1614
+ for (const item of entries) {
1615
+ console.log(`${item.type || "modified"}\t${item.path || ""}`);
1616
+ }
1617
+ });
1618
+
1619
+ worktreeCommand
1620
+ .command("diff [worktreeId]")
1621
+ .description("Show worktree diff")
1622
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
1623
+ .option("--session-id <id>", "Session ID (default: current for workspace)")
1624
+ .option("--base-url <url>", "API base URL")
1625
+ .option("--json", "Output JSON")
1626
+ .action(async (worktreeIdArg, options) => {
1627
+ const state = loadCliState();
1628
+ const { workspaceId, baseUrl, entry, sessionId } = await resolveSessionAuthContext(
1629
+ state,
1630
+ options,
1631
+ options.sessionId
1632
+ );
1633
+ const worktreeId = resolveWorktreeForCommand(state, workspaceId, sessionId, worktreeIdArg);
1634
+ const response = await authedApiRequest({
1635
+ state,
1636
+ workspaceId,
1637
+ entry,
1638
+ baseUrl,
1639
+ pathname: `/api/v1/sessions/${sessionId}/worktrees/${worktreeId}/diff`,
1640
+ });
1641
+ if (options.json) {
1642
+ console.log(JSON.stringify(response, null, 2));
1643
+ return;
1644
+ }
1645
+ if (typeof response.diff === "string") {
1646
+ console.log(response.diff);
1647
+ return;
1648
+ }
1649
+ console.log(JSON.stringify(response, null, 2));
1650
+ });
1651
+
1652
+ worktreeCommand
1653
+ .command("commits [worktreeId]")
1654
+ .description("List recent commits for a worktree")
1655
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
1656
+ .option("--session-id <id>", "Session ID (default: current for workspace)")
1657
+ .option("--base-url <url>", "API base URL")
1658
+ .option("--limit <n>", "Number of commits (default: 20)")
1659
+ .option("--json", "Output JSON")
1660
+ .action(async (worktreeIdArg, options) => {
1661
+ const state = loadCliState();
1662
+ const { workspaceId, baseUrl, entry, sessionId } = await resolveSessionAuthContext(
1663
+ state,
1664
+ options,
1665
+ options.sessionId
1666
+ );
1667
+ const worktreeId = resolveWorktreeForCommand(state, workspaceId, sessionId, worktreeIdArg);
1668
+ const qs = options.limit ? `?limit=${encodeURIComponent(String(options.limit))}` : "";
1669
+ const response = await authedApiRequest({
1670
+ state,
1671
+ workspaceId,
1672
+ entry,
1673
+ baseUrl,
1674
+ pathname: `/api/v1/sessions/${sessionId}/worktrees/${worktreeId}/commits${qs}`,
1675
+ });
1676
+ const commits = Array.isArray(response.commits) ? response.commits : [];
1677
+ if (options.json) {
1678
+ console.log(JSON.stringify({ worktreeId, commits }, null, 2));
1679
+ return;
1680
+ }
1681
+ if (!commits.length) {
1682
+ console.log("No commit found.");
1683
+ return;
1684
+ }
1685
+ for (const commit of commits) {
1686
+ console.log(`${commit.sha || "-"} ${commit.date || ""} ${commit.message || ""}`);
1687
+ }
1688
+ });
1689
+
1690
+ const messageCommand = program
1691
+ .command("message")
1692
+ .alias("msg")
1693
+ .description("Send and inspect worktree messages");
1694
+
1695
+ messageCommand
1696
+ .command("send")
1697
+ .description("Send a user message to a worktree (supports attachments)")
1698
+ .requiredOption("--text <text>", "Message text")
1699
+ .option("--file <path>", "Attachment path (repeatable)", parseRepeatOption, [])
1700
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
1701
+ .option("--session-id <id>", "Session ID (default: current for workspace)")
1702
+ .option("--worktree-id <id>", "Worktree ID (default: current for session)")
1703
+ .option("--base-url <url>", "API base URL")
1704
+ .option("--json", "Output JSON")
1705
+ .action(async (options) => {
1706
+ const state = loadCliState();
1707
+ const { workspaceId, baseUrl, entry, sessionId } = await resolveSessionAuthContext(
1708
+ state,
1709
+ options,
1710
+ options.sessionId
1711
+ );
1712
+ const worktreeId = resolveWorktreeForCommand(
1713
+ state,
1714
+ workspaceId,
1715
+ sessionId,
1716
+ options.worktreeId
1717
+ );
1718
+ const uploaded = await uploadAttachmentFiles({
1719
+ state,
1720
+ workspaceId,
1721
+ entry,
1722
+ baseUrl,
1723
+ sessionId,
1724
+ files: options.file || [],
1725
+ });
1726
+ const response = await authedApiRequest({
1727
+ state,
1728
+ workspaceId,
1729
+ entry,
1730
+ baseUrl,
1731
+ pathname: `/api/v1/sessions/${sessionId}/worktrees/${worktreeId}/messages`,
1732
+ method: "POST",
1733
+ body: {
1734
+ role: "user",
1735
+ text: options.text,
1736
+ attachments: uploaded,
1737
+ },
1738
+ });
1739
+ if (options.json) {
1740
+ console.log(JSON.stringify({ ...response, attachments: uploaded }, null, 2));
1741
+ return;
1742
+ }
1743
+ console.log(
1744
+ `Message sent: worktree=${worktreeId} messageId=${response.messageId || "-"} turnId=${response.turnId || "-"}`
1745
+ );
1746
+ if (uploaded.length) {
1747
+ console.log(`Attachments uploaded: ${uploaded.map((item) => item.name || item.path).join(", ")}`);
1748
+ }
1749
+ });
1750
+
1751
+ messageCommand
1752
+ .command("ls")
1753
+ .description("List messages for a worktree")
1754
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
1755
+ .option("--session-id <id>", "Session ID (default: current for workspace)")
1756
+ .option("--worktree-id <id>", "Worktree ID (default: current for session)")
1757
+ .option("--base-url <url>", "API base URL")
1758
+ .option("--limit <n>", "Number of messages (default: 50)")
1759
+ .option("--before-message-id <id>", "Pagination cursor")
1760
+ .option("--json", "Output JSON")
1761
+ .action(async (options) => {
1762
+ const state = loadCliState();
1763
+ const { workspaceId, baseUrl, entry, sessionId } = await resolveSessionAuthContext(
1764
+ state,
1765
+ options,
1766
+ options.sessionId
1767
+ );
1768
+ const worktreeId = resolveWorktreeForCommand(
1769
+ state,
1770
+ workspaceId,
1771
+ sessionId,
1772
+ options.worktreeId
1773
+ );
1774
+ const qs = new URLSearchParams();
1775
+ if (options.limit) qs.set("limit", String(options.limit));
1776
+ if (options.beforeMessageId) qs.set("beforeMessageId", String(options.beforeMessageId));
1777
+ const response = await authedApiRequest({
1778
+ state,
1779
+ workspaceId,
1780
+ entry,
1781
+ baseUrl,
1782
+ pathname: `/api/v1/sessions/${sessionId}/worktrees/${worktreeId}/messages${qs.size ? `?${qs.toString()}` : ""}`,
1783
+ });
1784
+ const messages = Array.isArray(response.messages) ? response.messages : [];
1785
+ if (options.json) {
1786
+ console.log(JSON.stringify({ ...response, worktreeId, messages }, null, 2));
1787
+ return;
1788
+ }
1789
+ if (!messages.length) {
1790
+ console.log("No message found.");
1791
+ return;
1792
+ }
1793
+ for (const msg of messages) {
1794
+ const role = msg.role || "unknown";
1795
+ const text = String(msg.text || "").replace(/\s+/g, " ").trim();
1796
+ const attachments = Array.isArray(msg.attachments) ? msg.attachments : [];
1797
+ const suffix = attachments.length
1798
+ ? ` [attachments: ${attachments.map((a) => a?.name || a?.path).filter(Boolean).join(", ")}]`
1799
+ : "";
1800
+ console.log(`${msg.id || "-"} ${role}: ${text}${suffix}`);
1801
+ }
1802
+ });
1803
+
1804
+ messageCommand
1805
+ .command("tail")
1806
+ .description("Poll and display new messages for a worktree")
1807
+ .option("--workspace-id <id>", "Workspace ID (default: current)")
1808
+ .option("--session-id <id>", "Session ID (default: current for workspace)")
1809
+ .option("--worktree-id <id>", "Worktree ID (default: current for session)")
1810
+ .option("--base-url <url>", "API base URL")
1811
+ .option("--limit <n>", "Initial number of messages (default: 50)")
1812
+ .option("--interval-ms <ms>", "Polling interval in milliseconds (default: 2000)")
1813
+ .action(async (options) => {
1814
+ const state = loadCliState();
1815
+ const { workspaceId, baseUrl, entry, sessionId } = await resolveSessionAuthContext(
1816
+ state,
1817
+ options,
1818
+ options.sessionId
1819
+ );
1820
+ const worktreeId = resolveWorktreeForCommand(
1821
+ state,
1822
+ workspaceId,
1823
+ sessionId,
1824
+ options.worktreeId
1825
+ );
1826
+ const intervalMs = Number.parseInt(options.intervalMs, 10) || 2000;
1827
+ const initialLimit = Number.parseInt(options.limit, 10) || 50;
1828
+ const seen = new Set();
1829
+ console.log(`Tailing messages for ${workspaceId}/${sessionId}/${worktreeId} (Ctrl+C to stop)...`);
1830
+ while (true) {
1831
+ const qs = new URLSearchParams();
1832
+ qs.set("limit", String(initialLimit));
1833
+ const response = await authedApiRequest({
1834
+ state,
1835
+ workspaceId,
1836
+ entry,
1837
+ baseUrl,
1838
+ pathname: `/api/v1/sessions/${sessionId}/worktrees/${worktreeId}/messages?${qs.toString()}`,
1839
+ });
1840
+ const messages = Array.isArray(response.messages) ? response.messages : [];
1841
+ for (const msg of messages) {
1842
+ const id = msg.id || `${msg.role}-${msg.createdAt || ""}-${msg.text || ""}`;
1843
+ if (seen.has(id)) {
1844
+ continue;
1845
+ }
1846
+ seen.add(id);
1847
+ const role = msg.role || "unknown";
1848
+ const text = String(msg.text || "").replace(/\s+/g, " ").trim();
1849
+ const attachments = Array.isArray(msg.attachments) ? msg.attachments : [];
1850
+ const suffix = attachments.length
1851
+ ? ` [attachments: ${attachments.map((a) => a?.name || a?.path).filter(Boolean).join(", ")}]`
1852
+ : "";
1853
+ console.log(`${msg.id || "-"} ${role}: ${text}${suffix}`);
1854
+ }
1855
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
1856
+ }
1857
+ });
1858
+
1859
+ program.command("help").description("Show help").action(() => {
1860
+ program.outputHelp();
1861
+ });
1862
+
1863
+ if (!process.argv.slice(2).length) {
1864
+ console.error(
1865
+ "[vibe80] Missing command. Use `vibe80 run --codex`, `vibe80 workspace --help`, `vibe80 session --help`, or `vibe80 worktree --help`."
1866
+ );
1867
+ program.outputHelp();
1868
+ process.exit(1);
1869
+ }
1870
+
1871
+ program.parseAsync(process.argv).catch((error) => {
1872
+ console.error(`[vibe80] ${error?.message || error}`);
1873
+ if (error?.payload) {
1874
+ console.error(JSON.stringify(error.payload, null, 2));
1875
+ }
1876
+ process.exit(1);
1877
+ });
166
1878
 
167
1879
  process.on("SIGINT", () => shutdown(0));
168
1880
  process.on("SIGTERM", () => shutdown(0));