patchrelay 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -1,59 +1,14 @@
1
- import { spawn } from "node:child_process";
2
- import { setTimeout as delay } from "node:timers/promises";
3
1
  import { loadConfig } from "../config.js";
4
- import { initializePatchRelayHome, installUserServiceUnits, upsertProjectInConfig } from "../install.js";
5
2
  import { runPreflight } from "../preflight.js";
6
- import { getDefaultConfigPath, getDefaultRuntimeEnvPath, getDefaultServiceEnvPath, getSystemdUserPathUnitPath, getSystemdUserReloadUnitPath, getSystemdUserUnitPath, } from "../runtime-paths.js";
3
+ import { parseArgs, resolveCommand } from "./args.js";
4
+ import { handleConnectCommand, handleInstallationsCommand } from "./commands/connect.js";
5
+ import { handleEventsCommand, handleInspectCommand, handleListCommand, handleLiveCommand, handleOpenCommand, handleReportCommand, handleRetryCommand, handleWorktreeCommand, } from "./commands/issues.js";
6
+ import { handleProjectCommand } from "./commands/project.js";
7
+ import { handleInitCommand, handleInstallServiceCommand, handleRestartServiceCommand } from "./commands/setup.js";
7
8
  import { CliDataAccess } from "./data.js";
8
9
  import { formatJson } from "./formatters/json.js";
9
- import { formatEvents, formatInspect, formatList, formatLive, formatOpen, formatReport, formatRetry, formatWorktree } from "./formatters/text.js";
10
- const KNOWN_COMMANDS = new Set([
11
- "serve",
12
- "inspect",
13
- "live",
14
- "report",
15
- "events",
16
- "worktree",
17
- "open",
18
- "retry",
19
- "list",
20
- "doctor",
21
- "init",
22
- "project",
23
- "connect",
24
- "installations",
25
- "install-service",
26
- "restart-service",
27
- "help",
28
- ]);
29
- function parseArgs(argv) {
30
- const positionals = [];
31
- const flags = new Map();
32
- for (let index = 0; index < argv.length; index += 1) {
33
- const value = argv[index];
34
- if (!value.startsWith("--")) {
35
- positionals.push(value);
36
- continue;
37
- }
38
- const trimmed = value.slice(2);
39
- const [name, inline] = trimmed.split("=", 2);
40
- if (!name) {
41
- continue;
42
- }
43
- if (inline !== undefined) {
44
- flags.set(name, inline);
45
- continue;
46
- }
47
- const next = argv[index + 1];
48
- if (next && !next.startsWith("--")) {
49
- flags.set(name, next);
50
- index += 1;
51
- continue;
52
- }
53
- flags.set(name, true);
54
- }
55
- return { positionals, flags };
56
- }
10
+ import { runInteractiveCommand } from "./interactive.js";
11
+ import { formatDoctor, writeOutput } from "./output.js";
57
12
  function helpText() {
58
13
  return [
59
14
  "PatchRelay",
@@ -107,167 +62,6 @@ function helpText() {
107
62
  " List tracked issues",
108
63
  ].join("\n");
109
64
  }
110
- function normalizePublicBaseUrl(value) {
111
- const candidate = /^[a-zA-Z][a-zA-Z\d+.-]*:\/\//.test(value) ? value : `https://${value}`;
112
- const url = new URL(candidate);
113
- return url.origin;
114
- }
115
- function getStageFlag(value) {
116
- if (typeof value !== "string") {
117
- return undefined;
118
- }
119
- const trimmed = value.trim();
120
- return trimmed || undefined;
121
- }
122
- function parseCsvFlag(value) {
123
- if (typeof value !== "string") {
124
- return [];
125
- }
126
- return value
127
- .split(",")
128
- .map((entry) => entry.trim())
129
- .filter(Boolean);
130
- }
131
- function writeOutput(stream, text) {
132
- stream.write(text);
133
- }
134
- function formatDoctor(report) {
135
- const lines = ["PatchRelay doctor", ""];
136
- for (const check of report.checks) {
137
- const marker = check.status === "pass" ? "PASS" : check.status === "warn" ? "WARN" : "FAIL";
138
- lines.push(`${marker} [${check.scope}] ${check.message}`);
139
- }
140
- lines.push("");
141
- lines.push(report.ok ? "Doctor result: ready" : "Doctor result: not ready");
142
- return `${lines.join("\n")}\n`;
143
- }
144
- function buildOpenCommand(config, worktreePath, resumeThreadId) {
145
- const args = ["--dangerously-bypass-approvals-and-sandbox"];
146
- if (resumeThreadId) {
147
- args.push("resume", "-C", worktreePath, resumeThreadId);
148
- }
149
- else {
150
- args.push("-C", worktreePath);
151
- }
152
- return {
153
- command: config.runner.codex.bin,
154
- args,
155
- };
156
- }
157
- async function runInteractiveCommand(command, args) {
158
- return await new Promise((resolve, reject) => {
159
- const child = spawn(command, args, {
160
- stdio: "inherit",
161
- });
162
- child.on("error", reject);
163
- child.on("exit", (code, signal) => {
164
- if (signal) {
165
- resolve(1);
166
- return;
167
- }
168
- resolve(code ?? 0);
169
- });
170
- });
171
- }
172
- async function openExternalUrl(url) {
173
- const candidates = process.platform === "darwin"
174
- ? [{ command: "open", args: [url] }]
175
- : process.platform === "win32"
176
- ? [{ command: "cmd", args: ["/c", "start", "", url] }]
177
- : [{ command: "xdg-open", args: [url] }];
178
- for (const candidate of candidates) {
179
- try {
180
- const exitCode = await runInteractiveCommand(candidate.command, candidate.args);
181
- if (exitCode === 0) {
182
- return true;
183
- }
184
- }
185
- catch {
186
- // Try the next opener.
187
- }
188
- }
189
- return false;
190
- }
191
- async function runServiceCommands(runner, commands) {
192
- for (const entry of commands) {
193
- const exitCode = await runner(entry.command, entry.args);
194
- if (exitCode !== 0) {
195
- throw new Error(`Command failed with exit code ${exitCode}: ${entry.command} ${entry.args.join(" ")}`);
196
- }
197
- }
198
- }
199
- function parseTimeoutSeconds(value, command) {
200
- const timeoutSeconds = typeof value === "string" ? Number(value) : 180;
201
- if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
202
- throw new Error(`${command} --timeout must be a positive number of seconds.`);
203
- }
204
- return timeoutSeconds;
205
- }
206
- async function runConnectFlow(params) {
207
- const result = await params.data.connect(params.projectId);
208
- if (params.json) {
209
- writeOutput(params.stdout, formatJson(result));
210
- return 0;
211
- }
212
- if ("completed" in result && result.completed) {
213
- const label = result.installation.workspaceName ?? result.installation.actorName ?? `installation #${result.installation.id}`;
214
- writeOutput(params.stdout, `Linked project ${result.projectId} to existing Linear installation ${result.installation.id} (${label}). No new OAuth approval was needed.\n`);
215
- return 0;
216
- }
217
- if ("completed" in result) {
218
- throw new Error("Unexpected completed connect result.");
219
- }
220
- const opener = params.openExternal ?? openExternalUrl;
221
- const opened = params.noOpen ? false : await opener(result.authorizeUrl);
222
- writeOutput(params.stdout, `${result.projectId ? `Project: ${result.projectId}\n` : ""}${opened ? "Opened browser for Linear OAuth.\n" : "Open this URL in a browser:\n"}${opened ? result.authorizeUrl : `${result.authorizeUrl}\n`}Waiting for OAuth approval...\n`);
223
- const deadline = Date.now() + (params.timeoutSeconds ?? 180) * 1000;
224
- const pollIntervalMs = params.connectPollIntervalMs ?? 1000;
225
- do {
226
- const status = await params.data.connectStatus(result.state);
227
- if (status.status === "completed") {
228
- const label = status.installation?.workspaceName ?? status.installation?.actorName ?? `installation #${status.installation?.id ?? "unknown"}`;
229
- writeOutput(params.stdout, [
230
- `Connected ${label}${status.projectId ? ` for project ${status.projectId}` : ""}.${status.installation?.id ? ` Installation ${status.installation.id}.` : ""}`,
231
- params.config.linear.oauth.actor === "app"
232
- ? "If your Linear OAuth app webhook settings are configured, Linear has now provisioned the workspace webhook automatically."
233
- : undefined,
234
- ]
235
- .filter(Boolean)
236
- .join("\n") + "\n");
237
- return 0;
238
- }
239
- if (status.status === "failed") {
240
- throw new Error(status.errorMessage ?? "Linear OAuth failed.");
241
- }
242
- if (Date.now() >= deadline) {
243
- throw new Error(`Timed out waiting for Linear OAuth after ${params.timeoutSeconds ?? 180} seconds.`);
244
- }
245
- await delay(pollIntervalMs);
246
- } while (true);
247
- }
248
- async function tryManageService(runner, commands) {
249
- try {
250
- await runServiceCommands(runner, commands);
251
- return { ok: true };
252
- }
253
- catch (error) {
254
- return { ok: false, error: error instanceof Error ? error.message : String(error) };
255
- }
256
- }
257
- function installServiceCommands() {
258
- return [
259
- { command: "systemctl", args: ["--user", "daemon-reload"] },
260
- { command: "systemctl", args: ["--user", "enable", "--now", "patchrelay.path"] },
261
- { command: "systemctl", args: ["--user", "enable", "patchrelay.service"] },
262
- { command: "systemctl", args: ["--user", "reload-or-restart", "patchrelay.service"] },
263
- ];
264
- }
265
- function restartServiceCommands() {
266
- return [
267
- { command: "systemctl", args: ["--user", "daemon-reload"] },
268
- { command: "systemctl", args: ["--user", "reload-or-restart", "patchrelay.service"] },
269
- ];
270
- }
271
65
  function getCommandConfigProfile(command) {
272
66
  switch (command) {
273
67
  case "doctor":
@@ -293,13 +87,7 @@ export async function runCli(argv, options) {
293
87
  const stdout = options?.stdout ?? process.stdout;
294
88
  const stderr = options?.stderr ?? process.stderr;
295
89
  const parsed = parseArgs(argv);
296
- const requestedCommand = parsed.positionals[0];
297
- const command = !requestedCommand
298
- ? "help"
299
- : KNOWN_COMMANDS.has(requestedCommand)
300
- ? requestedCommand
301
- : "inspect";
302
- const commandArgs = command === requestedCommand ? parsed.positionals.slice(1) : parsed.positionals;
90
+ const { command, commandArgs } = resolveCommand(parsed);
303
91
  if (command === "help") {
304
92
  writeOutput(stdout, `${helpText()}\n`);
305
93
  return 0;
@@ -310,265 +98,45 @@ export async function runCli(argv, options) {
310
98
  const runInteractive = options?.runInteractive ?? runInteractiveCommand;
311
99
  const json = parsed.flags.get("json") === true;
312
100
  if (command === "init") {
313
- try {
314
- const requestedPublicBaseUrl = typeof parsed.flags.get("public-base-url") === "string"
315
- ? String(parsed.flags.get("public-base-url"))
316
- : commandArgs[0];
317
- if (!requestedPublicBaseUrl) {
318
- throw new Error([
319
- "patchrelay init requires <public-base-url>.",
320
- "PatchRelay must know the public HTTPS origin that Linear will call for the webhook and OAuth callback.",
321
- "Example: patchrelay init https://patchrelay.example.com",
322
- ].join("\n"));
323
- }
324
- const publicBaseUrl = normalizePublicBaseUrl(requestedPublicBaseUrl);
325
- const result = await initializePatchRelayHome({
326
- force: parsed.flags.get("force") === true,
327
- publicBaseUrl,
328
- });
329
- const serviceUnits = await installUserServiceUnits({ force: parsed.flags.get("force") === true });
330
- const serviceState = await tryManageService(runInteractive, installServiceCommands());
331
- writeOutput(stdout, json
332
- ? formatJson({ ...result, serviceUnits, serviceState })
333
- : [
334
- `Config directory: ${result.configDir}`,
335
- `Runtime env: ${result.runtimeEnvPath} (${result.runtimeEnvStatus})`,
336
- `Service env: ${result.serviceEnvPath} (${result.serviceEnvStatus})`,
337
- `Config file: ${result.configPath} (${result.configStatus})`,
338
- `State directory: ${result.stateDir}`,
339
- `Data directory: ${result.dataDir}`,
340
- `Service unit: ${serviceUnits.unitPath} (${serviceUnits.serviceStatus})`,
341
- `Reload unit: ${serviceUnits.reloadUnitPath} (${serviceUnits.reloadStatus})`,
342
- `Watcher unit: ${serviceUnits.pathUnitPath} (${serviceUnits.pathStatus})`,
343
- "",
344
- "PatchRelay public URLs:",
345
- `- Public base URL: ${result.publicBaseUrl}`,
346
- `- Webhook URL: ${result.webhookUrl}`,
347
- `- OAuth callback: ${result.oauthCallbackUrl}`,
348
- "",
349
- "Created with defaults:",
350
- `- Config file contains only machine-level essentials such as server.public_base_url`,
351
- `- Database, logs, bind address, and worktree roots use built-in defaults`,
352
- `- The user service and config watcher are installed for you`,
353
- "",
354
- "Register the app in Linear:",
355
- "- Open Linear Settings > API > Applications",
356
- "- Create an OAuth app for PatchRelay",
357
- "- Choose actor `app`",
358
- "- Choose scopes `read`, `write`, `app:assignable`, `app:mentionable`",
359
- `- Add redirect URI ${result.oauthCallbackUrl}`,
360
- `- Add webhook URL ${result.webhookUrl}`,
361
- "- Enable webhook categories for issue events, comment events, agent session events, permission changes, and inbox/app-user notifications",
362
- "",
363
- result.configStatus === "skipped"
364
- ? `Config file was skipped, so make sure ${result.configPath} still has server.public_base_url: ${result.publicBaseUrl}`
365
- : `Config file already includes server.public_base_url: ${result.publicBaseUrl}`,
366
- "",
367
- "Service status:",
368
- serviceState.ok
369
- ? "PatchRelay service and config watcher are installed and reload-or-restart has been requested."
370
- : `PatchRelay service units were installed, but the service could not be started yet: ${serviceState.error}`,
371
- !serviceState.ok
372
- ? "This is expected until the required env vars and at least one valid project workflow are in place. The watcher will retry when config or env files change."
373
- : undefined,
374
- "",
375
- "Next steps:",
376
- `1. Edit ${result.serviceEnvPath}`,
377
- "2. Paste your Linear OAuth client id and client secret into service.env and keep the generated webhook secret and token encryption key",
378
- "3. Paste LINEAR_WEBHOOK_SECRET from service.env into the Linear OAuth app webhook signing secret",
379
- "4. Run `patchrelay project apply <id> <repo-path>`",
380
- "5. Edit the generated project workflows if you want custom state names or workflow files, then add those workflow files to the repo",
381
- "6. Run `patchrelay doctor`",
382
- ]
383
- .filter(Boolean)
384
- .join("\n") + "\n");
385
- return 0;
386
- }
387
- catch (error) {
388
- writeOutput(stderr, `${error instanceof Error ? error.message : String(error)}\n`);
389
- return 1;
390
- }
101
+ return await handleInitCommand({
102
+ commandArgs,
103
+ parsed,
104
+ json,
105
+ stdout,
106
+ stderr,
107
+ runInteractive,
108
+ });
391
109
  }
392
110
  if (command === "install-service") {
393
- try {
394
- const result = await installUserServiceUnits({ force: parsed.flags.get("force") === true });
395
- const writeOnly = parsed.flags.get("write-only") === true;
396
- if (!writeOnly) {
397
- await runServiceCommands(runInteractive, installServiceCommands());
398
- }
399
- writeOutput(stdout, json
400
- ? formatJson({ ...result, writeOnly })
401
- : [
402
- `Service unit: ${result.unitPath} (${result.serviceStatus})`,
403
- `Reload unit: ${result.reloadUnitPath} (${result.reloadStatus})`,
404
- `Watcher unit: ${result.pathUnitPath} (${result.pathStatus})`,
405
- `Runtime env: ${result.runtimeEnvPath}`,
406
- `Service env: ${result.serviceEnvPath}`,
407
- `Config file: ${result.configPath}`,
408
- writeOnly
409
- ? "Service units written. Start them with: systemctl --user daemon-reload && systemctl --user enable --now patchrelay.path && systemctl --user enable patchrelay.service && systemctl --user reload-or-restart patchrelay.service"
410
- : "PatchRelay user service and config watcher are installed and running.",
411
- "After package updates, run: patchrelay restart-service",
412
- ].join("\n") + "\n");
413
- return 0;
414
- }
415
- catch (error) {
416
- writeOutput(stderr, `${error instanceof Error ? error.message : String(error)}\n`);
417
- return 1;
418
- }
111
+ return await handleInstallServiceCommand({
112
+ commandArgs,
113
+ parsed,
114
+ json,
115
+ stdout,
116
+ stderr,
117
+ runInteractive,
118
+ });
419
119
  }
420
120
  if (command === "restart-service") {
421
- try {
422
- await runServiceCommands(runInteractive, restartServiceCommands());
423
- writeOutput(stdout, json
424
- ? formatJson({
425
- service: "patchrelay",
426
- unitPath: getSystemdUserUnitPath(),
427
- reloadUnitPath: getSystemdUserReloadUnitPath(),
428
- pathUnitPath: getSystemdUserPathUnitPath(),
429
- runtimeEnvPath: getDefaultRuntimeEnvPath(),
430
- serviceEnvPath: getDefaultServiceEnvPath(),
431
- configPath: getDefaultConfigPath(),
432
- restarted: true,
433
- })
434
- : "Reloaded systemd user units and reload-or-restart was requested for PatchRelay.\n");
435
- return 0;
436
- }
437
- catch (error) {
438
- writeOutput(stderr, `${error instanceof Error ? error.message : String(error)}\n`);
439
- return 1;
440
- }
121
+ return await handleRestartServiceCommand({
122
+ commandArgs,
123
+ parsed,
124
+ json,
125
+ stdout,
126
+ stderr,
127
+ runInteractive,
128
+ });
441
129
  }
442
130
  if (command === "project") {
443
- try {
444
- const subcommand = commandArgs[0];
445
- if (subcommand !== "apply") {
446
- throw new Error("Usage: patchrelay project apply <id> <repo-path> [--issue-prefix <prefixes>] [--team-id <ids>] [--no-connect] [--timeout <seconds>]");
447
- }
448
- const projectId = commandArgs[1];
449
- const repoPath = commandArgs[2];
450
- if (!projectId || !repoPath) {
451
- throw new Error("Usage: patchrelay project apply <id> <repo-path> [--issue-prefix <prefixes>] [--team-id <ids>] [--no-connect] [--timeout <seconds>]");
452
- }
453
- const result = await upsertProjectInConfig({
454
- id: projectId,
455
- repoPath,
456
- issueKeyPrefixes: parseCsvFlag(parsed.flags.get("issue-prefix")),
457
- linearTeamIds: parseCsvFlag(parsed.flags.get("team-id")),
458
- });
459
- const serviceUnits = await installUserServiceUnits();
460
- const noConnect = parsed.flags.get("no-connect") === true;
461
- const lines = [
462
- `Config file: ${result.configPath}`,
463
- `${result.status === "created" ? "Created" : result.status === "updated" ? "Updated" : "Verified"} project ${result.project.id} for ${result.project.repoPath}`,
464
- result.project.issueKeyPrefixes.length > 0 ? `Issue key prefixes: ${result.project.issueKeyPrefixes.join(", ")}` : undefined,
465
- result.project.linearTeamIds.length > 0 ? `Linear team ids: ${result.project.linearTeamIds.join(", ")}` : undefined,
466
- `Service unit: ${serviceUnits.unitPath} (${serviceUnits.serviceStatus})`,
467
- `Watcher unit: ${serviceUnits.pathUnitPath} (${serviceUnits.pathStatus})`,
468
- ].filter(Boolean);
469
- let fullConfig;
470
- try {
471
- fullConfig = loadConfig(undefined, { profile: "doctor" });
472
- }
473
- catch (error) {
474
- if (json) {
475
- writeOutput(stdout, formatJson({
476
- ...result,
477
- serviceUnits,
478
- readiness: {
479
- ok: false,
480
- error: error instanceof Error ? error.message : String(error),
481
- },
482
- connect: {
483
- attempted: false,
484
- skipped: "missing_env",
485
- },
486
- }));
487
- return 0;
488
- }
489
- lines.push(`Linear connect was skipped: ${error instanceof Error ? error.message : String(error)}`);
490
- lines.push("Finish the required env vars and rerun `patchrelay project apply`.");
491
- writeOutput(stdout, `${lines.join("\n")}\n`);
492
- return 0;
493
- }
494
- const report = await runPreflight(fullConfig);
495
- const failedChecks = report.checks.filter((check) => check.status === "fail");
496
- if (failedChecks.length > 0) {
497
- if (json) {
498
- writeOutput(stdout, formatJson({
499
- ...result,
500
- serviceUnits,
501
- readiness: report,
502
- connect: {
503
- attempted: false,
504
- skipped: "preflight_failed",
505
- },
506
- }));
507
- return 0;
508
- }
509
- lines.push("Linear connect was skipped because PatchRelay is not ready yet:");
510
- lines.push(...failedChecks.map((check) => `- [${check.scope}] ${check.message}`));
511
- lines.push("Fix the failures above and rerun `patchrelay project apply`.");
512
- writeOutput(stdout, `${lines.join("\n")}\n`);
513
- return 0;
514
- }
515
- const serviceState = await tryManageService(runInteractive, installServiceCommands());
516
- if (!serviceState.ok) {
517
- throw new Error(`Project was saved, but PatchRelay could not be reloaded: ${serviceState.error}`);
518
- }
519
- const cliData = options?.data ?? new CliDataAccess(fullConfig);
520
- try {
521
- if (json) {
522
- const connectResult = noConnect ? undefined : await cliData.connect(projectId);
523
- writeOutput(stdout, formatJson({
524
- ...result,
525
- serviceUnits,
526
- readiness: report,
527
- serviceReloaded: true,
528
- ...(noConnect
529
- ? {
530
- connect: {
531
- attempted: false,
532
- skipped: "no_connect",
533
- },
534
- }
535
- : {
536
- connect: {
537
- attempted: true,
538
- result: connectResult,
539
- },
540
- }),
541
- }));
542
- return 0;
543
- }
544
- if (noConnect) {
545
- lines.push("Project saved and PatchRelay was reloaded.");
546
- lines.push(`Next: patchrelay connect --project ${result.project.id}`);
547
- writeOutput(stdout, `${lines.join("\n")}\n`);
548
- return 0;
549
- }
550
- writeOutput(stdout, `${lines.join("\n")}\n`);
551
- return await runConnectFlow({
552
- config: fullConfig,
553
- data: cliData,
554
- stdout,
555
- noOpen: parsed.flags.get("no-open") === true,
556
- timeoutSeconds: parseTimeoutSeconds(parsed.flags.get("timeout"), "project apply"),
557
- projectId,
558
- ...(options?.openExternal ? { openExternal: options.openExternal } : {}),
559
- ...(options?.connectPollIntervalMs !== undefined ? { connectPollIntervalMs: options.connectPollIntervalMs } : {}),
560
- });
561
- }
562
- finally {
563
- if (!options?.data) {
564
- cliData.close();
565
- }
566
- }
567
- }
568
- catch (error) {
569
- writeOutput(stderr, `${error instanceof Error ? error.message : String(error)}\n`);
570
- return 1;
571
- }
131
+ return await handleProjectCommand({
132
+ commandArgs,
133
+ parsed,
134
+ json,
135
+ stdout,
136
+ stderr,
137
+ runInteractive,
138
+ ...(options ? { options } : {}),
139
+ });
572
140
  }
573
141
  const config = options?.config ??
574
142
  loadConfig(undefined, {
@@ -583,169 +151,45 @@ export async function runCli(argv, options) {
583
151
  }
584
152
  data ??= new CliDataAccess(config);
585
153
  if (command === "inspect") {
586
- const issueKey = commandArgs[0];
587
- if (!issueKey) {
588
- throw new Error("inspect requires <issueKey>.");
589
- }
590
- const result = await data.inspect(issueKey);
591
- if (!result) {
592
- throw new Error(`Issue not found: ${issueKey}`);
593
- }
594
- writeOutput(stdout, json ? formatJson(result) : formatInspect(result));
595
- return 0;
154
+ return await handleInspectCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
596
155
  }
597
156
  if (command === "live") {
598
- const issueKey = commandArgs[0];
599
- if (!issueKey) {
600
- throw new Error("live requires <issueKey>.");
601
- }
602
- const watch = parsed.flags.get("watch") === true;
603
- do {
604
- const result = await data.live(issueKey);
605
- if (!result) {
606
- throw new Error(`No active stage found for ${issueKey}`);
607
- }
608
- writeOutput(stdout, json ? formatJson(result) : formatLive(result));
609
- if (!watch || result.stageRun.status !== "running") {
610
- break;
611
- }
612
- await delay(2000);
613
- } while (true);
614
- return 0;
157
+ return await handleLiveCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
615
158
  }
616
159
  if (command === "report") {
617
- const issueKey = commandArgs[0];
618
- if (!issueKey) {
619
- throw new Error("report requires <issueKey>.");
620
- }
621
- const reportOptions = {};
622
- const stage = getStageFlag(parsed.flags.get("stage"));
623
- if (stage) {
624
- reportOptions.stage = stage;
625
- }
626
- if (typeof parsed.flags.get("stage-run") === "string") {
627
- reportOptions.stageRunId = Number(parsed.flags.get("stage-run"));
628
- }
629
- const result = data.report(issueKey, reportOptions);
630
- if (!result) {
631
- throw new Error(`Issue not found: ${issueKey}`);
632
- }
633
- writeOutput(stdout, json ? formatJson(result) : formatReport(result));
634
- return 0;
160
+ return await handleReportCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
635
161
  }
636
162
  if (command === "events") {
637
- const issueKey = commandArgs[0];
638
- if (!issueKey) {
639
- throw new Error("events requires <issueKey>.");
640
- }
641
- const follow = parsed.flags.get("follow") === true;
642
- let afterId;
643
- let stageRunId = typeof parsed.flags.get("stage-run") === "string" ? Number(parsed.flags.get("stage-run")) : undefined;
644
- do {
645
- const result = data.events(issueKey, {
646
- ...(stageRunId !== undefined ? { stageRunId } : {}),
647
- ...(typeof parsed.flags.get("method") === "string" ? { method: String(parsed.flags.get("method")) } : {}),
648
- ...(afterId !== undefined ? { afterId } : {}),
649
- });
650
- if (!result) {
651
- throw new Error(`Stage run not found for ${issueKey}`);
652
- }
653
- stageRunId = result.stageRun.id;
654
- if (result.events.length > 0) {
655
- writeOutput(stdout, json ? formatJson(result) : formatEvents(result));
656
- afterId = result.events.at(-1)?.id;
657
- }
658
- if (!follow || result.stageRun.status !== "running") {
659
- break;
660
- }
661
- await delay(2000);
662
- } while (true);
663
- return 0;
163
+ return await handleEventsCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
664
164
  }
665
165
  if (command === "worktree") {
666
- const issueKey = commandArgs[0];
667
- if (!issueKey) {
668
- throw new Error("worktree requires <issueKey>.");
669
- }
670
- const result = data.worktree(issueKey);
671
- if (!result) {
672
- throw new Error(`Workspace not found for ${issueKey}`);
673
- }
674
- writeOutput(stdout, json ? formatJson(result) : formatWorktree(result, parsed.flags.get("cd") === true));
675
- return 0;
166
+ return await handleWorktreeCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
676
167
  }
677
168
  if (command === "open") {
678
- const issueKey = commandArgs[0];
679
- if (!issueKey) {
680
- throw new Error("open requires <issueKey>.");
681
- }
682
- const result = data.open(issueKey);
683
- if (!result) {
684
- throw new Error(`Workspace not found for ${issueKey}`);
685
- }
686
- if (json) {
687
- writeOutput(stdout, formatJson(result));
688
- return 0;
689
- }
690
- if (parsed.flags.get("print") === true) {
691
- writeOutput(stdout, formatOpen(result));
692
- return 0;
693
- }
694
- const openCommand = buildOpenCommand(config, result.workspace.worktreePath, result.resumeThreadId);
695
- return await runInteractive(openCommand.command, openCommand.args);
169
+ return await handleOpenCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
696
170
  }
697
171
  if (command === "connect") {
698
- return await runConnectFlow({
172
+ return await handleConnectCommand({
173
+ parsed,
174
+ json,
175
+ stdout,
699
176
  config,
700
177
  data,
701
- stdout,
702
- noOpen: parsed.flags.get("no-open") === true,
703
- timeoutSeconds: parseTimeoutSeconds(parsed.flags.get("timeout"), "connect"),
704
- json,
705
- ...(options?.openExternal ? { openExternal: options.openExternal } : {}),
706
- ...(options?.connectPollIntervalMs !== undefined ? { connectPollIntervalMs: options.connectPollIntervalMs } : {}),
707
- ...(typeof parsed.flags.get("project") === "string" ? { projectId: String(parsed.flags.get("project")) } : {}),
178
+ ...(options ? { options } : {}),
708
179
  });
709
180
  }
710
181
  if (command === "installations") {
711
- const result = await data.listInstallations();
712
- if (json) {
713
- writeOutput(stdout, formatJson(result));
714
- return 0;
715
- }
716
- writeOutput(stdout, `${(result.installations.length > 0
717
- ? result.installations.map((item) => `${item.installation.id} ${item.installation.workspaceName ?? item.installation.actorName ?? "-"} projects=${item.linkedProjects.join(",") || "-"}`)
718
- : ["No installations found."]).join("\n")}\n`);
719
- return 0;
182
+ return await handleInstallationsCommand({
183
+ json,
184
+ stdout,
185
+ data,
186
+ });
720
187
  }
721
188
  if (command === "retry") {
722
- const issueKey = commandArgs[0];
723
- if (!issueKey) {
724
- throw new Error("retry requires <issueKey>.");
725
- }
726
- const retryOptions = {};
727
- const stage = getStageFlag(parsed.flags.get("stage"));
728
- if (stage) {
729
- retryOptions.stage = stage;
730
- }
731
- if (typeof parsed.flags.get("reason") === "string") {
732
- retryOptions.reason = String(parsed.flags.get("reason"));
733
- }
734
- const result = data.retry(issueKey, retryOptions);
735
- if (!result) {
736
- throw new Error(`Issue not found: ${issueKey}`);
737
- }
738
- writeOutput(stdout, json ? formatJson(result) : formatRetry(result));
739
- return 0;
189
+ return await handleRetryCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
740
190
  }
741
191
  if (command === "list") {
742
- const result = data.list({
743
- active: parsed.flags.get("active") === true,
744
- failed: parsed.flags.get("failed") === true,
745
- ...(typeof parsed.flags.get("project") === "string" ? { project: String(parsed.flags.get("project")) } : {}),
746
- });
747
- writeOutput(stdout, json ? formatJson(result) : formatList(result));
748
- return 0;
192
+ return await handleListCommand({ commandArgs, parsed, json, stdout, data, config, runInteractive });
749
193
  }
750
194
  throw new Error(`Unknown command: ${command}`);
751
195
  }