@sanurb/ringi 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs ADDED
@@ -0,0 +1,1741 @@
1
+ #!/usr/bin/env node
2
+ import { a as GitService, c as CommentService, f as ReviewNotFound, i as ReviewService, n as ExportService, o as getDiffSummary, r as TodoService, s as parseDiff, t as CoreLive, u as TodoNotFound } from "./runtime.mjs";
3
+ import { exec, execFileSync, fork } from "node:child_process";
4
+ import { existsSync } from "node:fs";
5
+ import { resolve } from "node:path";
6
+ import * as Schema from "effect/Schema";
7
+ import * as Either from "effect/Either";
8
+ import { writeFile } from "node:fs/promises";
9
+ import * as Effect from "effect/Effect";
10
+ import * as Option from "effect/Option";
11
+ import * as Context from "effect/Context";
12
+ import * as Layer from "effect/Layer";
13
+ import * as ManagedRuntime from "effect/ManagedRuntime";
14
+ import * as ConfigProvider from "effect/ConfigProvider";
15
+ //#region src/cli/config.ts
16
+ var CliConfig = class extends Context.Tag("@ringi/CliConfig")() {};
17
+ /**
18
+ * Wraps a concrete {@link CliConfigShape} in a layer for the Effect runtime.
19
+ */
20
+ const CliConfigLive = (config) => Layer.succeed(CliConfig, config);
21
+ //#endregion
22
+ //#region src/cli/contracts.ts
23
+ const ExitCode = {
24
+ AuthFailure: 5,
25
+ ResourceNotFound: 3,
26
+ RuntimeFailure: 1,
27
+ StateUnavailable: 4,
28
+ Success: 0,
29
+ UsageError: 2
30
+ };
31
+ const success = (command, result, nextActions = []) => ({
32
+ command,
33
+ next_actions: nextActions,
34
+ ok: true,
35
+ result
36
+ });
37
+ const failure = (command, error, fix, nextActions = []) => ({
38
+ command,
39
+ error,
40
+ fix,
41
+ next_actions: nextActions,
42
+ ok: false
43
+ });
44
+ /**
45
+ * Carries an exit code and optional operator-facing details so callers can
46
+ * present a short message without losing the underlying reason.
47
+ */
48
+ var CliFailure = class extends Schema.TaggedError()("CliFailure", {
49
+ details: Schema.optional(Schema.String),
50
+ exitCode: Schema.Number,
51
+ message: Schema.String
52
+ }) {};
53
+ //#endregion
54
+ //#region src/cli/commands.ts
55
+ const formatTable = (headers, rows) => {
56
+ const widths = headers.map((header, index) => {
57
+ const cellWidths = rows.map((row) => row[index]?.length ?? 0);
58
+ return Math.max(header.length, ...cellWidths);
59
+ });
60
+ const renderRow = (row) => row.map((cell, index) => cell.padEnd(widths.at(index) ?? 0)).join(" ").trimEnd();
61
+ return [
62
+ renderRow(headers),
63
+ renderRow(widths.map((width) => "-".repeat(width))),
64
+ ...rows.map(renderRow)
65
+ ].join("\n");
66
+ };
67
+ const renderReviewList = (reviews) => {
68
+ if (reviews.length === 0) return "No reviews found.";
69
+ return formatTable([
70
+ "ID",
71
+ "STATUS",
72
+ "SOURCE",
73
+ "FILES",
74
+ "CREATED"
75
+ ], reviews.map((review) => [
76
+ review.id,
77
+ review.status,
78
+ review.sourceType,
79
+ String(review.fileCount),
80
+ review.createdAt
81
+ ]));
82
+ };
83
+ const renderReviewShow = (input) => {
84
+ const { comments, review, todos } = input;
85
+ const lines = [
86
+ `Review ${review.id}`,
87
+ `Status: ${review.status}`,
88
+ `Source: ${review.sourceType}${review.sourceRef ? ` (${review.sourceRef})` : ""}`,
89
+ `Created: ${review.createdAt}`,
90
+ `Files: ${review.summary.totalFiles}`,
91
+ `Diff: +${review.summary.totalAdditions} / -${review.summary.totalDeletions}`
92
+ ];
93
+ if (review.files.length > 0) {
94
+ lines.push("", "Files:");
95
+ for (const file of review.files) lines.push(`- ${file.status.toUpperCase()} ${file.filePath} (+${file.additions} -${file.deletions})`);
96
+ }
97
+ if (comments && comments.length > 0) {
98
+ lines.push("", "Comments:");
99
+ for (const comment of comments) {
100
+ const location = `${comment.filePath}:${comment.lineNumber ?? "-"}`;
101
+ const state = comment.resolved ? "resolved" : "open";
102
+ lines.push(`- [${state}] ${location} ${comment.content}`);
103
+ }
104
+ }
105
+ if (todos && todos.length > 0) {
106
+ lines.push("", "Todos:");
107
+ for (const todo of todos) {
108
+ const marker = todo.completed ? "x" : " ";
109
+ lines.push(`- [${marker}] (${todo.position + 1}) ${todo.content}`);
110
+ }
111
+ }
112
+ return lines.join("\n");
113
+ };
114
+ const renderTodoList = (todos) => {
115
+ if (todos.length === 0) return "No todos found.";
116
+ return todos.map((todo) => `- [${todo.completed ? "x" : " "}] (${todo.position + 1}) ${todo.content}`).join("\n");
117
+ };
118
+ const renderSourceList = (input) => {
119
+ const lines = [
120
+ `Repository: ${input.repo.name}`,
121
+ `Path: ${input.repo.path}`,
122
+ `Current branch: ${input.repo.branch}`,
123
+ `Staged files: ${input.stagedFiles.length}`
124
+ ];
125
+ if (input.stagedFiles.length > 0) {
126
+ lines.push("", "Staged:");
127
+ for (const file of input.stagedFiles) lines.push(`- ${file.status} ${file.path}`);
128
+ }
129
+ if (input.branches.length > 0) {
130
+ lines.push("", "Branches:");
131
+ for (const branch of input.branches.slice(0, 10)) lines.push(`- ${branch.current ? "*" : " "} ${branch.name}`);
132
+ }
133
+ if (input.commits.length > 0) {
134
+ lines.push("", "Recent commits:");
135
+ for (const commit of input.commits.slice(0, 5)) lines.push(`- ${commit.hash.slice(0, 8)} ${commit.message} (${commit.author})`);
136
+ }
137
+ return lines.join("\n");
138
+ };
139
+ /**
140
+ * Resolves the special "last" selector before show/export handlers ask the
141
+ * shared services for a concrete review id.
142
+ */
143
+ const resolveReviewSelector = Effect.fn("CLI.resolveReviewSelector")(function* resolveReviewSelector(selector) {
144
+ if (selector !== "last") return selector;
145
+ const cliConfig = yield* CliConfig;
146
+ const [review] = (yield* (yield* ReviewService).list({
147
+ page: 1,
148
+ pageSize: 1,
149
+ repositoryPath: cliConfig.repoRoot
150
+ })).reviews;
151
+ if (!review) return yield* new CliFailure({
152
+ exitCode: ExitCode.ResourceNotFound,
153
+ message: "No review sessions exist for this repository yet."
154
+ });
155
+ return review.id;
156
+ });
157
+ /**
158
+ * Mutating CLI commands stay server-backed so they share the same write path as
159
+ * the other clients instead of growing a second local-only behavior surface.
160
+ */
161
+ const requireServerMode = (label) => Effect.fail(new CliFailure({
162
+ details: "Start 'ringi serve' and retry the command.",
163
+ exitCode: ExitCode.StateUnavailable,
164
+ message: `${label} requires a running local Ringi server. Standalone local writes are intentionally unsupported.`
165
+ }));
166
+ const diffSourceStrategies = {
167
+ branch: (git, command) => git.getBranchDiff(command.branch ?? ""),
168
+ commits: (git, command) => git.getCommitDiff((command.commits ?? "").split(",").map((item) => item.trim()).filter(Boolean)),
169
+ staged: (git) => git.getStagedDiff
170
+ };
171
+ const runReviewList = Effect.fn("CLI.reviewList")(function* runReviewList(command) {
172
+ const reviewService = yield* ReviewService;
173
+ const cliConfig = yield* CliConfig;
174
+ const result = yield* reviewService.list({
175
+ page: command.page,
176
+ pageSize: command.limit,
177
+ repositoryPath: cliConfig.repoRoot,
178
+ sourceType: command.source,
179
+ status: command.status
180
+ });
181
+ const nextActions = [];
182
+ for (const review of result.reviews.slice(0, 3)) nextActions.push({
183
+ command: `ringi review show ${review.id} --comments --todos`,
184
+ description: `Inspect review ${review.id} (${review.status})`
185
+ });
186
+ if (result.reviews.length > 0) nextActions.push({
187
+ command: "ringi review show <id> [--comments] [--todos]",
188
+ description: "Show full review details",
189
+ params: { id: {
190
+ description: "Review ID or 'last'",
191
+ required: true
192
+ } }
193
+ });
194
+ nextActions.push({
195
+ command: "ringi review create [--source <source>]",
196
+ description: "Create a new review session",
197
+ params: { source: {
198
+ default: "staged",
199
+ enum: [
200
+ "staged",
201
+ "branch",
202
+ "commits"
203
+ ]
204
+ } }
205
+ });
206
+ return {
207
+ data: result,
208
+ human: renderReviewList(result.reviews),
209
+ nextActions
210
+ };
211
+ });
212
+ const runReviewShow = Effect.fn("CLI.reviewShow")(function* runReviewShow(command) {
213
+ const reviewService = yield* ReviewService;
214
+ const todoService = yield* TodoService;
215
+ const commentService = yield* CommentService;
216
+ const reviewId = yield* resolveReviewSelector(command.id);
217
+ const review = yield* reviewService.getById(reviewId);
218
+ const data = {
219
+ comments: command.comments ? yield* commentService.getByReview(reviewId) : void 0,
220
+ review,
221
+ todos: command.todos ? (yield* todoService.list({ reviewId })).data : void 0
222
+ };
223
+ const nextActions = [
224
+ {
225
+ command: `ringi review export ${reviewId}`,
226
+ description: "Export this review as markdown"
227
+ },
228
+ {
229
+ command: `ringi review show ${reviewId} --comments --todos`,
230
+ description: "Show with comments and todos"
231
+ },
232
+ {
233
+ command: "ringi todo list [--review <review-id>] [--status <status>]",
234
+ description: "List todos for this review",
235
+ params: {
236
+ "review-id": { value: reviewId },
237
+ status: {
238
+ default: "pending",
239
+ enum: [
240
+ "pending",
241
+ "done",
242
+ "all"
243
+ ]
244
+ }
245
+ }
246
+ },
247
+ {
248
+ command: "ringi review list",
249
+ description: "Back to review list"
250
+ }
251
+ ];
252
+ return {
253
+ data,
254
+ human: renderReviewShow(data),
255
+ nextActions
256
+ };
257
+ });
258
+ const runReviewExport = Effect.fn("CLI.reviewExport")(function* runReviewExport(command) {
259
+ if (command.noResolved || command.noSnippets) yield* new CliFailure({
260
+ exitCode: ExitCode.UsageError,
261
+ message: "--no-resolved and --no-snippets are documented, but the shared export service does not support adapter-level filtering yet."
262
+ });
263
+ const exportService = yield* ExportService;
264
+ const cliConfig = yield* CliConfig;
265
+ const reviewId = yield* resolveReviewSelector(command.id);
266
+ const markdown = yield* exportService.exportReview(reviewId);
267
+ const outputPath = command.outputPath ? resolve(cliConfig.cwd, command.outputPath) : void 0;
268
+ if (outputPath) yield* Effect.tryPromise({
269
+ catch: (error) => new CliFailure({
270
+ exitCode: ExitCode.RuntimeFailure,
271
+ message: `Failed to write export to ${outputPath}: ${String(error)}`
272
+ }),
273
+ try: () => writeFile(outputPath, markdown, "utf8")
274
+ });
275
+ const shouldPrintMarkdown = command.stdout || !outputPath;
276
+ const data = {
277
+ markdown,
278
+ outputPath: outputPath ?? null,
279
+ reviewId
280
+ };
281
+ const nextActions = [{
282
+ command: `ringi review show ${reviewId}`,
283
+ description: "View the exported review"
284
+ }, {
285
+ command: "ringi review list",
286
+ description: "Back to review list"
287
+ }];
288
+ return {
289
+ data,
290
+ human: shouldPrintMarkdown ? markdown : `Exported review ${reviewId} to ${outputPath}.`,
291
+ nextActions
292
+ };
293
+ });
294
+ const runSourceList = Effect.fn("CLI.sourceList")(function* runSourceList() {
295
+ const gitService = yield* GitService;
296
+ const repo = yield* gitService.getRepositoryInfo;
297
+ const stagedFiles = yield* gitService.getStagedFiles;
298
+ const data = {
299
+ branches: yield* gitService.getBranches,
300
+ commits: (yield* gitService.getCommits({
301
+ limit: 10,
302
+ offset: 0
303
+ })).commits,
304
+ repo,
305
+ stagedFiles
306
+ };
307
+ return {
308
+ data,
309
+ human: renderSourceList(data),
310
+ nextActions: [
311
+ {
312
+ command: "ringi source diff <source> [--stat]",
313
+ description: "View diff for a source",
314
+ params: { source: { enum: [
315
+ "staged",
316
+ "branch",
317
+ "commits"
318
+ ] } }
319
+ },
320
+ {
321
+ command: "ringi review create [--source <source>]",
322
+ description: "Create a review from a source",
323
+ params: { source: {
324
+ default: "staged",
325
+ enum: [
326
+ "staged",
327
+ "branch",
328
+ "commits"
329
+ ]
330
+ } }
331
+ },
332
+ {
333
+ command: "ringi review list",
334
+ description: "List existing reviews"
335
+ }
336
+ ]
337
+ };
338
+ });
339
+ const runSourceDiff = Effect.fn("CLI.sourceDiff")(function* runSourceDiff(command) {
340
+ const gitService = yield* GitService;
341
+ const strategy = diffSourceStrategies[command.source];
342
+ if (!strategy) return yield* new CliFailure({
343
+ exitCode: ExitCode.UsageError,
344
+ message: "Unsupported review source."
345
+ });
346
+ const diffText = yield* strategy(gitService, command);
347
+ if (!diffText.trim()) yield* new CliFailure({
348
+ exitCode: ExitCode.RuntimeFailure,
349
+ message: "No diff available for the requested source."
350
+ });
351
+ const files = parseDiff(diffText);
352
+ const data = {
353
+ diff: diffText,
354
+ source: command.source,
355
+ summary: getDiffSummary(files)
356
+ };
357
+ const nextActions = [{
358
+ command: `ringi review create --source ${command.source}`,
359
+ description: `Create a review from this ${command.source} diff`
360
+ }, {
361
+ command: "ringi source list",
362
+ description: "List repository sources"
363
+ }];
364
+ return {
365
+ data,
366
+ human: command.stat ? [
367
+ `Source: ${command.source}`,
368
+ `Files: ${data.summary.totalFiles}`,
369
+ `Additions: ${data.summary.totalAdditions}`,
370
+ `Deletions: ${data.summary.totalDeletions}`
371
+ ].join("\n") : diffText,
372
+ nextActions
373
+ };
374
+ });
375
+ const runReviewStatus = Effect.fn("CLI.reviewStatus")(function* runReviewStatus(command) {
376
+ const reviewService = yield* ReviewService;
377
+ const todoService = yield* TodoService;
378
+ const commentService = yield* CommentService;
379
+ const gitService = yield* GitService;
380
+ const cliConfig = yield* CliConfig;
381
+ const repo = yield* gitService.getRepositoryInfo;
382
+ const stagedFiles = yield* gitService.getStagedFiles;
383
+ let reviewId;
384
+ if (command.reviewId) reviewId = yield* resolveReviewSelector(command.reviewId);
385
+ const reviews = yield* reviewService.list({
386
+ page: 1,
387
+ pageSize: 1,
388
+ repositoryPath: cliConfig.repoRoot,
389
+ sourceType: command.source
390
+ });
391
+ const latestReview = reviewId ? yield* reviewService.getById(reviewId) : reviews.reviews[0];
392
+ let commentStats;
393
+ let todoStats;
394
+ if (latestReview) {
395
+ commentStats = yield* commentService.getStats(latestReview.id);
396
+ todoStats = yield* todoService.getStats();
397
+ }
398
+ const data = {
399
+ commentStats: commentStats ?? null,
400
+ repository: {
401
+ branch: repo.branch,
402
+ name: repo.name,
403
+ path: repo.path,
404
+ stagedFileCount: stagedFiles.length
405
+ },
406
+ review: latestReview ? {
407
+ createdAt: latestReview.createdAt,
408
+ id: latestReview.id,
409
+ sourceType: latestReview.sourceType,
410
+ status: latestReview.status
411
+ } : null,
412
+ todoStats: todoStats ?? null
413
+ };
414
+ const lines = [
415
+ `Repository: ${repo.name}`,
416
+ `Branch: ${repo.branch}`,
417
+ `Staged files: ${stagedFiles.length}`
418
+ ];
419
+ if (latestReview) {
420
+ lines.push("", `Review: ${latestReview.id}`, `Status: ${latestReview.status}`, `Source: ${latestReview.sourceType}`);
421
+ if (commentStats) lines.push(`Comments: ${commentStats.unresolved ?? 0} unresolved / ${commentStats.total} total`);
422
+ if (todoStats) lines.push(`Todos: ${todoStats.pending} pending / ${todoStats.total} total`);
423
+ } else lines.push("", "No review sessions found.");
424
+ const nextActions = [];
425
+ if (latestReview) nextActions.push({
426
+ command: `ringi review show ${latestReview.id} --comments --todos`,
427
+ description: "Inspect the latest review"
428
+ }, {
429
+ command: `ringi review export ${latestReview.id}`,
430
+ description: "Export the latest review"
431
+ });
432
+ nextActions.push({
433
+ command: "ringi review create [--source <source>]",
434
+ description: "Create a new review session",
435
+ params: { source: {
436
+ default: "staged",
437
+ enum: [
438
+ "staged",
439
+ "branch",
440
+ "commits"
441
+ ]
442
+ } }
443
+ });
444
+ return {
445
+ data,
446
+ human: lines.join("\n"),
447
+ nextActions
448
+ };
449
+ });
450
+ const runTodoList = Effect.fn("CLI.todoList")(function* runTodoList(command) {
451
+ const result = yield* (yield* TodoService).list({
452
+ completed: command.status === "all" ? void 0 : command.status === "done",
453
+ limit: command.limit,
454
+ offset: command.offset,
455
+ reviewId: command.reviewId
456
+ });
457
+ const nextActions = [];
458
+ if (command.reviewId) nextActions.push({
459
+ command: `ringi review show ${command.reviewId}`,
460
+ description: "View the associated review"
461
+ });
462
+ nextActions.push({
463
+ command: "ringi todo add --text <text> [--review <review-id>]",
464
+ description: "Add a new todo",
465
+ params: { text: {
466
+ description: "Todo text",
467
+ required: true
468
+ } }
469
+ }, {
470
+ command: "ringi review list",
471
+ description: "List reviews"
472
+ });
473
+ return {
474
+ data: result,
475
+ human: renderTodoList(result.data),
476
+ nextActions
477
+ };
478
+ });
479
+ /**
480
+ * Data-driven command registry. Each command kind maps to its handler.
481
+ * Adding a new command means adding one entry — no switch duplication.
482
+ */
483
+ const COMMAND_HANDLERS = {
484
+ "data-migrate": () => requireServerMode("ringi data migrate"),
485
+ "data-reset": () => requireServerMode("ringi data reset"),
486
+ doctor: () => Effect.succeed({
487
+ data: {
488
+ checks: [],
489
+ ok: true
490
+ },
491
+ human: "ringi doctor: not yet implemented.",
492
+ nextActions: []
493
+ }),
494
+ events: () => requireServerMode("ringi events"),
495
+ mcp: () => Effect.fail(new CliFailure({
496
+ exitCode: ExitCode.UsageError,
497
+ message: "ringi mcp is a runtime command. Use it directly, not through the command dispatcher."
498
+ })),
499
+ "review-create": () => requireServerMode("ringi review create"),
500
+ "review-export": (c) => runReviewExport(c),
501
+ "review-list": (c) => runReviewList(c),
502
+ "review-resolve": () => requireServerMode("ringi review resolve"),
503
+ "review-show": (c) => runReviewShow(c),
504
+ "review-status": (c) => runReviewStatus(c),
505
+ serve: () => Effect.fail(new CliFailure({
506
+ exitCode: ExitCode.UsageError,
507
+ message: "ringi serve is a runtime command. Use it directly, not through the command dispatcher."
508
+ })),
509
+ "source-diff": (c) => runSourceDiff(c),
510
+ "source-list": () => runSourceList(),
511
+ "todo-add": () => requireServerMode("ringi todo add"),
512
+ "todo-clear": () => requireServerMode("ringi todo clear"),
513
+ "todo-done": () => requireServerMode("ringi todo done"),
514
+ "todo-list": (c) => runTodoList(c),
515
+ "todo-move": () => requireServerMode("ringi todo move"),
516
+ "todo-remove": () => requireServerMode("ringi todo remove"),
517
+ "todo-undone": () => requireServerMode("ringi todo undone")
518
+ };
519
+ /** Human-readable command label for the JSON envelope `command` field. */
520
+ const COMMAND_LABELS = {
521
+ "data-migrate": "ringi data migrate",
522
+ "data-reset": "ringi data reset",
523
+ doctor: "ringi doctor",
524
+ events: "ringi events",
525
+ mcp: "ringi mcp",
526
+ "review-create": "ringi review create",
527
+ "review-export": "ringi review export",
528
+ "review-list": "ringi review list",
529
+ "review-resolve": "ringi review resolve",
530
+ "review-show": "ringi review show",
531
+ "review-status": "ringi review status",
532
+ serve: "ringi serve",
533
+ "source-diff": "ringi source diff",
534
+ "source-list": "ringi source list",
535
+ "todo-add": "ringi todo add",
536
+ "todo-clear": "ringi todo clear",
537
+ "todo-done": "ringi todo done",
538
+ "todo-list": "ringi todo list",
539
+ "todo-move": "ringi todo move",
540
+ "todo-remove": "ringi todo remove",
541
+ "todo-undone": "ringi todo undone"
542
+ };
543
+ const commandLabel = (command) => COMMAND_LABELS[command.kind] ?? `ringi ${command.kind}`;
544
+ const runCommand = (command) => {
545
+ const handler = COMMAND_HANDLERS[command.kind];
546
+ if (!handler) return Effect.fail(new CliFailure({
547
+ exitCode: ExitCode.UsageError,
548
+ message: `No executable handler exists for ${command.kind}.`
549
+ }));
550
+ return handler(command);
551
+ };
552
+ //#endregion
553
+ //#region src/cli/parser.ts
554
+ const REVIEW_SOURCES = new Set([
555
+ "branch",
556
+ "commits",
557
+ "staged"
558
+ ]);
559
+ const REVIEW_STATUSES = new Set([
560
+ "approved",
561
+ "changes_requested",
562
+ "in_progress"
563
+ ]);
564
+ const TODO_STATUSES = new Set([
565
+ "all",
566
+ "done",
567
+ "pending"
568
+ ]);
569
+ const usageError = (message) => new CliFailure({
570
+ exitCode: ExitCode.UsageError,
571
+ message
572
+ });
573
+ /**
574
+ * Consumes the next token as a flag value, advancing the cursor by 2.
575
+ * Rejects another flag in the value slot so typos fail fast.
576
+ */
577
+ const requireValue = (state, flag) => {
578
+ const value = state.tokens[state.index + 1];
579
+ if (!value || value.startsWith("-")) return Option.some(usageError(`Missing value for ${flag}.`));
580
+ state.index += 2;
581
+ return Option.none();
582
+ };
583
+ /** Peek at the value that {@link requireValue} would consume. */
584
+ const peekValue = (state) => state.tokens[state.index + 1] ?? "";
585
+ const decodePositiveInt = (raw, flag) => {
586
+ const value = Number.parseInt(raw, 10);
587
+ if (!Number.isInteger(value) || value < 0) return Either.left(usageError(`${flag} must be a non-negative integer.`));
588
+ return Either.right(value);
589
+ };
590
+ const decodeEnum = (raw, valid, label) => {
591
+ if (!valid.has(raw)) return Either.left(usageError(`Invalid ${label}: ${raw}.`));
592
+ return Either.right(raw);
593
+ };
594
+ /** Boolean flag: sets a key to `true`, advances cursor by 1. */
595
+ const boolFlag = (key) => (state, acc) => {
596
+ acc[key] = true;
597
+ state.index += 1;
598
+ return Option.none();
599
+ };
600
+ /** String flag: consumes next token, assigns to key. */
601
+ const stringFlag = (key) => (state, acc) => {
602
+ const flag = state.tokens[state.index] ?? "";
603
+ const value = peekValue(state);
604
+ const error = requireValue(state, flag);
605
+ if (Option.isSome(error)) return error;
606
+ acc[key] = value;
607
+ return Option.none();
608
+ };
609
+ /** Positive integer flag with an optional minimum (exclusive). */
610
+ const positiveIntFlag = (key, opts) => (state, acc) => {
611
+ const flag = state.tokens[state.index] ?? "";
612
+ const raw = peekValue(state);
613
+ const error = requireValue(state, flag);
614
+ if (Option.isSome(error)) return error;
615
+ const decoded = decodePositiveInt(raw, flag);
616
+ if (Either.isLeft(decoded)) return Option.some(decoded.left);
617
+ if (opts?.min !== void 0 && decoded.right <= opts.min) return Option.some(usageError(`${flag} must be greater than ${opts.min}.`));
618
+ acc[key] = decoded.right;
619
+ return Option.none();
620
+ };
621
+ /** Enum flag: consumes next token, validates membership, assigns to key. */
622
+ const enumFlag = (key, valid, label) => (state, acc) => {
623
+ const flag = state.tokens[state.index] ?? "";
624
+ const raw = peekValue(state);
625
+ const error = requireValue(state, flag);
626
+ if (Option.isSome(error)) return error;
627
+ const decoded = decodeEnum(raw, valid, label);
628
+ if (Either.isLeft(decoded)) return Option.some(decoded.left);
629
+ acc[key] = decoded.right;
630
+ return Option.none();
631
+ };
632
+ const createDefaultOptions = () => ({
633
+ color: true,
634
+ dbPath: void 0,
635
+ help: false,
636
+ json: false,
637
+ quiet: false,
638
+ repo: void 0,
639
+ verbose: false,
640
+ version: false
641
+ });
642
+ /**
643
+ * Global flags are accepted before or after subcommands because wrappers often
644
+ * prepend them without preserving the CLI's preferred ordering.
645
+ */
646
+ const GLOBAL_FLAG_HANDLERS = {
647
+ "--db-path": stringFlag("dbPath"),
648
+ "--help": boolFlag("help"),
649
+ "--json": boolFlag("json"),
650
+ "--no-color": (state, acc) => {
651
+ acc.color = false;
652
+ state.index += 1;
653
+ return Option.none();
654
+ },
655
+ "--quiet": boolFlag("quiet"),
656
+ "--repo": stringFlag("repo"),
657
+ "--verbose": boolFlag("verbose"),
658
+ "--version": boolFlag("version")
659
+ };
660
+ const maybeParseGlobalFlag = (state) => {
661
+ const token = state.tokens[state.index];
662
+ if (!token) return false;
663
+ const handler = GLOBAL_FLAG_HANDLERS[token];
664
+ if (!handler) return false;
665
+ handler(state, state.options);
666
+ return true;
667
+ };
668
+ /**
669
+ * Consumes all remaining tokens in {@link state} by dispatching to the matching
670
+ * handler in {@link handlers}. Global flags are tried first. Unknown flags
671
+ * produce a usage error naming the {@link commandLabel}.
672
+ */
673
+ const runFlagLoop = (state, acc, handlers, commandLabel) => {
674
+ while (state.index < state.tokens.length) {
675
+ if (maybeParseGlobalFlag(state)) continue;
676
+ const token = state.tokens[state.index] ?? "";
677
+ const handler = handlers[token];
678
+ if (!handler) return Option.some(usageError(`Unknown flag for ${commandLabel}: ${token}.`));
679
+ const error = handler(state, acc);
680
+ if (Option.isSome(error)) return error;
681
+ }
682
+ return Option.none();
683
+ };
684
+ const REVIEW_LIST_FLAGS = {
685
+ "--limit": positiveIntFlag("limit", { min: 0 }),
686
+ "--page": positiveIntFlag("page", { min: 0 }),
687
+ "--source": enumFlag("source", REVIEW_SOURCES, "review source"),
688
+ "--status": enumFlag("status", REVIEW_STATUSES, "review status")
689
+ };
690
+ const parseReviewList = (state) => {
691
+ const acc = {
692
+ limit: 20,
693
+ page: 1,
694
+ source: void 0,
695
+ status: void 0
696
+ };
697
+ const error = runFlagLoop(state, acc, REVIEW_LIST_FLAGS, "review list");
698
+ if (Option.isSome(error)) return Either.left(error.value);
699
+ return Either.right({
700
+ kind: "review-list",
701
+ ...acc
702
+ });
703
+ };
704
+ const REVIEW_SHOW_FLAGS = {
705
+ "--comments": boolFlag("comments"),
706
+ "--todos": boolFlag("todos")
707
+ };
708
+ const parseReviewShow = (state) => {
709
+ const id = state.tokens[state.index];
710
+ if (!id) return Either.left(usageError("review show requires <id|last>."));
711
+ state.index += 1;
712
+ const acc = {
713
+ comments: false,
714
+ todos: false
715
+ };
716
+ const error = runFlagLoop(state, acc, REVIEW_SHOW_FLAGS, "review show");
717
+ if (Option.isSome(error)) return Either.left(error.value);
718
+ return Either.right({
719
+ id,
720
+ kind: "review-show",
721
+ ...acc
722
+ });
723
+ };
724
+ const REVIEW_EXPORT_FLAGS = {
725
+ "--no-resolved": boolFlag("noResolved"),
726
+ "--no-snippets": boolFlag("noSnippets"),
727
+ "--output": stringFlag("outputPath"),
728
+ "--stdout": boolFlag("stdout")
729
+ };
730
+ const parseReviewExport = (state) => {
731
+ const id = state.tokens[state.index];
732
+ if (!id) return Either.left(usageError("review export requires <id|last>."));
733
+ state.index += 1;
734
+ const acc = {
735
+ noResolved: false,
736
+ noSnippets: false,
737
+ outputPath: void 0,
738
+ stdout: false
739
+ };
740
+ const error = runFlagLoop(state, acc, REVIEW_EXPORT_FLAGS, "review export");
741
+ if (Option.isSome(error)) return Either.left(error.value);
742
+ return Either.right({
743
+ id,
744
+ kind: "review-export",
745
+ ...acc
746
+ });
747
+ };
748
+ const REVIEW_CREATE_FLAGS = {
749
+ "--branch": stringFlag("branch"),
750
+ "--commits": stringFlag("commits"),
751
+ "--source": enumFlag("source", REVIEW_SOURCES, "review source"),
752
+ "--title": stringFlag("title")
753
+ };
754
+ const validateReviewCreate = (acc) => {
755
+ if (acc.source === "branch" && !acc.branch) return Option.some(usageError("review create --source branch requires --branch."));
756
+ if (acc.source === "commits" && !acc.commits) return Option.some(usageError("review create --source commits requires --commits."));
757
+ if (acc.source === "staged" && (acc.branch || acc.commits)) return Option.some(usageError("review create --source staged does not accept --branch or --commits."));
758
+ return Option.none();
759
+ };
760
+ const parseReviewCreate = (state) => {
761
+ const acc = {
762
+ branch: void 0,
763
+ commits: void 0,
764
+ source: "staged",
765
+ title: void 0
766
+ };
767
+ const error = runFlagLoop(state, acc, REVIEW_CREATE_FLAGS, "review create");
768
+ if (Option.isSome(error)) return Either.left(error.value);
769
+ const validationError = validateReviewCreate(acc);
770
+ if (Option.isSome(validationError)) return Either.left(validationError.value);
771
+ return Either.right({
772
+ kind: "review-create",
773
+ ...acc
774
+ });
775
+ };
776
+ const SOURCE_DIFF_FLAGS = {
777
+ "--branch": stringFlag("branch"),
778
+ "--commits": stringFlag("commits"),
779
+ "--stat": boolFlag("stat")
780
+ };
781
+ const validateSourceDiff = (source, acc) => {
782
+ if (source === "branch" && !acc.branch) return Option.some(usageError("source diff branch requires --branch."));
783
+ if (source === "commits" && !acc.commits) return Option.some(usageError("source diff commits requires --commits."));
784
+ return Option.none();
785
+ };
786
+ const parseSourceDiff = (state) => {
787
+ const source = state.tokens[state.index];
788
+ if (!source || !REVIEW_SOURCES.has(source)) return Either.left(usageError("source diff requires <staged|branch|commits>."));
789
+ state.index += 1;
790
+ const acc = {
791
+ branch: void 0,
792
+ commits: void 0,
793
+ stat: false
794
+ };
795
+ const error = runFlagLoop(state, acc, SOURCE_DIFF_FLAGS, "source diff");
796
+ if (Option.isSome(error)) return Either.left(error.value);
797
+ const validationError = validateSourceDiff(source, acc);
798
+ if (Option.isSome(validationError)) return Either.left(validationError.value);
799
+ return Either.right({
800
+ kind: "source-diff",
801
+ source,
802
+ ...acc
803
+ });
804
+ };
805
+ const TODO_LIST_FLAGS = {
806
+ "--limit": positiveIntFlag("limit"),
807
+ "--offset": positiveIntFlag("offset"),
808
+ "--review": stringFlag("reviewId"),
809
+ "--status": enumFlag("status", TODO_STATUSES, "todo status")
810
+ };
811
+ const parseTodoList = (state) => {
812
+ const acc = {
813
+ limit: void 0,
814
+ offset: 0,
815
+ reviewId: void 0,
816
+ status: "pending"
817
+ };
818
+ const error = runFlagLoop(state, acc, TODO_LIST_FLAGS, "todo list");
819
+ if (Option.isSome(error)) return Either.left(error.value);
820
+ return Either.right({
821
+ kind: "todo-list",
822
+ ...acc
823
+ });
824
+ };
825
+ /**
826
+ * Factory for commands that take exactly one positional `<id>` and no
827
+ * command-specific flags (only globals). Avoids duplication for done/undone.
828
+ */
829
+ const positionalIdParser = (kind, label) => (state) => {
830
+ const id = state.tokens[state.index];
831
+ if (!id) return Either.left(usageError(`${label} requires <id>.`));
832
+ state.index += 1;
833
+ while (state.index < state.tokens.length) if (!maybeParseGlobalFlag(state)) return Either.left(usageError(`Unknown flag for ${label}: ${state.tokens[state.index]}.`));
834
+ return Either.right({
835
+ id,
836
+ kind
837
+ });
838
+ };
839
+ const parseTodoDone = positionalIdParser("todo-done", "todo done");
840
+ const parseTodoUndone = positionalIdParser("todo-undone", "todo undone");
841
+ const TODO_MOVE_FLAGS = { "--position": positiveIntFlag("position") };
842
+ const parseTodoMove = (state) => {
843
+ const id = state.tokens[state.index];
844
+ if (!id) return Either.left(usageError("todo move requires <id>."));
845
+ state.index += 1;
846
+ const acc = { position: void 0 };
847
+ const error = runFlagLoop(state, acc, TODO_MOVE_FLAGS, "todo move");
848
+ if (Option.isSome(error)) return Either.left(error.value);
849
+ if (acc.position === void 0) return Either.left(usageError("todo move requires --position."));
850
+ return Either.right({
851
+ id,
852
+ kind: "todo-move",
853
+ position: acc.position
854
+ });
855
+ };
856
+ const TODO_REMOVE_FLAGS = { "--yes": boolFlag("yes") };
857
+ const parseTodoRemove = (state) => {
858
+ const id = state.tokens[state.index];
859
+ if (!id) return Either.left(usageError("todo remove requires <id>."));
860
+ state.index += 1;
861
+ const acc = { yes: false };
862
+ const error = runFlagLoop(state, acc, TODO_REMOVE_FLAGS, "todo remove");
863
+ if (Option.isSome(error)) return Either.left(error.value);
864
+ return Either.right({
865
+ id,
866
+ kind: "todo-remove",
867
+ ...acc
868
+ });
869
+ };
870
+ const TODO_CLEAR_FLAGS = {
871
+ "--all": boolFlag("all"),
872
+ "--done-only": boolFlag("doneOnly"),
873
+ "--review": stringFlag("reviewId"),
874
+ "--yes": boolFlag("yes")
875
+ };
876
+ const parseTodoClear = (state) => {
877
+ const acc = {
878
+ all: false,
879
+ doneOnly: true,
880
+ reviewId: void 0,
881
+ yes: false
882
+ };
883
+ const error = runFlagLoop(state, acc, TODO_CLEAR_FLAGS, "todo clear");
884
+ if (Option.isSome(error)) return Either.left(error.value);
885
+ return Either.right({
886
+ kind: "todo-clear",
887
+ ...acc
888
+ });
889
+ };
890
+ const REVIEW_STATUS_FLAGS = {
891
+ "--review": stringFlag("reviewId"),
892
+ "--source": enumFlag("source", REVIEW_SOURCES, "review source")
893
+ };
894
+ const parseReviewStatus = (state) => {
895
+ const acc = {
896
+ reviewId: void 0,
897
+ source: void 0
898
+ };
899
+ const error = runFlagLoop(state, acc, REVIEW_STATUS_FLAGS, "review status");
900
+ if (Option.isSome(error)) return Either.left(error.value);
901
+ return Either.right({
902
+ kind: "review-status",
903
+ ...acc
904
+ });
905
+ };
906
+ const REVIEW_RESOLVE_FLAGS = {
907
+ "--all-comments": boolFlag("allComments"),
908
+ "--yes": boolFlag("yes")
909
+ };
910
+ const parseReviewResolve = (state) => {
911
+ const id = state.tokens[state.index];
912
+ if (!id) return Either.left(usageError("review resolve requires <id|last>."));
913
+ state.index += 1;
914
+ const acc = {
915
+ allComments: true,
916
+ yes: false
917
+ };
918
+ const error = runFlagLoop(state, acc, REVIEW_RESOLVE_FLAGS, "review resolve");
919
+ if (Option.isSome(error)) return Either.left(error.value);
920
+ return Either.right({
921
+ id,
922
+ kind: "review-resolve",
923
+ ...acc
924
+ });
925
+ };
926
+ const TODO_ADD_FLAGS = {
927
+ "--position": positiveIntFlag("position"),
928
+ "--review": stringFlag("reviewId"),
929
+ "--text": stringFlag("text")
930
+ };
931
+ const parseTodoAdd = (state) => {
932
+ const acc = {
933
+ position: void 0,
934
+ reviewId: void 0,
935
+ text: ""
936
+ };
937
+ const error = runFlagLoop(state, acc, TODO_ADD_FLAGS, "todo add");
938
+ if (Option.isSome(error)) return Either.left(error.value);
939
+ if (!acc.text.trim()) return Either.left(usageError("todo add requires --text."));
940
+ return Either.right({
941
+ kind: "todo-add",
942
+ ...acc
943
+ });
944
+ };
945
+ const SERVE_FLAGS = {
946
+ "--auth": boolFlag("auth"),
947
+ "--cert": stringFlag("cert"),
948
+ "--host": stringFlag("host"),
949
+ "--https": boolFlag("https"),
950
+ "--key": stringFlag("key"),
951
+ "--no-open": boolFlag("noOpen"),
952
+ "--password": stringFlag("password"),
953
+ "--port": positiveIntFlag("port", { min: 0 }),
954
+ "--username": stringFlag("username")
955
+ };
956
+ const parseServe = (state) => {
957
+ const acc = {
958
+ auth: false,
959
+ cert: void 0,
960
+ host: "127.0.0.1",
961
+ https: false,
962
+ key: void 0,
963
+ noOpen: false,
964
+ password: void 0,
965
+ port: 3e3,
966
+ username: void 0
967
+ };
968
+ const error = runFlagLoop(state, acc, SERVE_FLAGS, "serve");
969
+ if (Option.isSome(error)) return Either.left(error.value);
970
+ return Either.right({
971
+ kind: "serve",
972
+ ...acc
973
+ });
974
+ };
975
+ const MCP_LOG_LEVELS = new Set([
976
+ "debug",
977
+ "error",
978
+ "info",
979
+ "silent"
980
+ ]);
981
+ const MCP_FLAGS = {
982
+ "--log-level": enumFlag("logLevel", MCP_LOG_LEVELS, "log level"),
983
+ "--readonly": boolFlag("readonly")
984
+ };
985
+ const parseMcp = (state) => {
986
+ const acc = {
987
+ logLevel: "error",
988
+ readonly: false
989
+ };
990
+ const error = runFlagLoop(state, acc, MCP_FLAGS, "mcp");
991
+ if (Option.isSome(error)) return Either.left(error.value);
992
+ return Either.right({
993
+ kind: "mcp",
994
+ ...acc
995
+ });
996
+ };
997
+ const EVENT_TYPES = new Set([
998
+ "comments",
999
+ "files",
1000
+ "reviews",
1001
+ "todos"
1002
+ ]);
1003
+ const EVENTS_FLAGS = {
1004
+ "--since": positiveIntFlag("since"),
1005
+ "--type": enumFlag("type", EVENT_TYPES, "event type")
1006
+ };
1007
+ const parseEvents = (state) => {
1008
+ const acc = {
1009
+ since: void 0,
1010
+ type: void 0
1011
+ };
1012
+ const error = runFlagLoop(state, acc, EVENTS_FLAGS, "events");
1013
+ if (Option.isSome(error)) return Either.left(error.value);
1014
+ return Either.right({
1015
+ kind: "events",
1016
+ ...acc
1017
+ });
1018
+ };
1019
+ const DATA_RESET_FLAGS = {
1020
+ "--keep-exports": boolFlag("keepExports"),
1021
+ "--yes": boolFlag("yes")
1022
+ };
1023
+ const parseDataReset = (state) => {
1024
+ const acc = {
1025
+ keepExports: false,
1026
+ yes: false
1027
+ };
1028
+ const error = runFlagLoop(state, acc, DATA_RESET_FLAGS, "data reset");
1029
+ if (Option.isSome(error)) return Either.left(error.value);
1030
+ return Either.right({
1031
+ kind: "data-reset",
1032
+ ...acc
1033
+ });
1034
+ };
1035
+ const ensureNoExtraArgs = (state, label) => {
1036
+ if (state.index < state.tokens.length) return Option.some(usageError(`Unexpected argument for ${label}: ${state.tokens[state.index]}.`));
1037
+ return Option.none();
1038
+ };
1039
+ /** Review verb parsers keyed by verb name. */
1040
+ const REVIEW_VERB_PARSERS = {
1041
+ create: parseReviewCreate,
1042
+ export: parseReviewExport,
1043
+ list: parseReviewList,
1044
+ resolve: parseReviewResolve,
1045
+ show: parseReviewShow,
1046
+ status: parseReviewStatus
1047
+ };
1048
+ /** Todo verb parsers keyed by verb name. */
1049
+ const TODO_VERB_PARSERS = {
1050
+ add: parseTodoAdd,
1051
+ clear: parseTodoClear,
1052
+ done: parseTodoDone,
1053
+ list: parseTodoList,
1054
+ move: parseTodoMove,
1055
+ remove: parseTodoRemove,
1056
+ undone: parseTodoUndone
1057
+ };
1058
+ /** Subcommand family parsers keyed by family name. */
1059
+ /** Data verb parsers keyed by verb name. */
1060
+ const DATA_VERB_PARSERS = {
1061
+ migrate: (state) => {
1062
+ const error = ensureNoExtraArgs(state, "data migrate");
1063
+ if (Option.isSome(error)) return Either.left(error.value);
1064
+ return Either.right({ kind: "data-migrate" });
1065
+ },
1066
+ reset: parseDataReset
1067
+ };
1068
+ /** Subcommand family parsers keyed by family name. */
1069
+ const FAMILY_PARSERS = {
1070
+ data: (state) => {
1071
+ const verb = state.tokens[state.index];
1072
+ if (!verb) return Either.right({
1073
+ kind: "help",
1074
+ topic: ["data"]
1075
+ });
1076
+ state.index += 1;
1077
+ const parser = DATA_VERB_PARSERS[verb];
1078
+ if (!parser) return Either.left(usageError(`Unknown data command: ${verb}.`));
1079
+ return parser(state);
1080
+ },
1081
+ doctor: (state) => {
1082
+ const error = ensureNoExtraArgs(state, "doctor");
1083
+ if (Option.isSome(error)) return Either.left(error.value);
1084
+ return Either.right({ kind: "doctor" });
1085
+ },
1086
+ events: parseEvents,
1087
+ export: parseReviewExport,
1088
+ mcp: parseMcp,
1089
+ review: (state) => {
1090
+ const verb = state.tokens[state.index];
1091
+ if (!verb) return Either.right({
1092
+ kind: "help",
1093
+ topic: ["review"]
1094
+ });
1095
+ state.index += 1;
1096
+ const parser = REVIEW_VERB_PARSERS[verb];
1097
+ if (!parser) return Either.left(usageError(`Unknown review command: ${verb}.`));
1098
+ return parser(state);
1099
+ },
1100
+ serve: parseServe,
1101
+ source: (state) => {
1102
+ const verb = state.tokens[state.index];
1103
+ if (!verb) return Either.right({
1104
+ kind: "help",
1105
+ topic: ["source"]
1106
+ });
1107
+ state.index += 1;
1108
+ if (verb === "list") {
1109
+ const error = ensureNoExtraArgs(state, "source list");
1110
+ if (Option.isSome(error)) return Either.left(error.value);
1111
+ return Either.right({ kind: "source-list" });
1112
+ }
1113
+ if (verb === "diff") return parseSourceDiff(state);
1114
+ return Either.left(usageError(`Unknown source command: ${verb}.`));
1115
+ },
1116
+ todo: (state) => {
1117
+ const verb = state.tokens[state.index];
1118
+ if (!verb) return Either.right({
1119
+ kind: "help",
1120
+ topic: ["todo"]
1121
+ });
1122
+ state.index += 1;
1123
+ const parser = TODO_VERB_PARSERS[verb];
1124
+ if (!parser) return Either.left(usageError(`Unknown todo command: ${verb}.`));
1125
+ return parser(state);
1126
+ }
1127
+ };
1128
+ /**
1129
+ * Strips any leading globals with {@link maybeParseGlobalFlag} before dispatching
1130
+ * into a command family so `--help` and `--version` behave consistently.
1131
+ */
1132
+ const parseWithState = (state) => {
1133
+ while (state.index < state.tokens.length && maybeParseGlobalFlag(state));
1134
+ if (state.options.version) return Either.right({ kind: "version" });
1135
+ if (state.index >= state.tokens.length) return Either.right({
1136
+ kind: "help",
1137
+ topic: []
1138
+ });
1139
+ const first = state.tokens[state.index];
1140
+ if (!first) return Either.right({
1141
+ kind: "help",
1142
+ topic: []
1143
+ });
1144
+ if (first === "help") {
1145
+ const topic = state.tokens.slice(state.index + 1);
1146
+ state.index = state.tokens.length;
1147
+ return Either.right({
1148
+ kind: "help",
1149
+ topic
1150
+ });
1151
+ }
1152
+ if (state.options.help) return Either.right({
1153
+ kind: "help",
1154
+ topic: state.tokens.slice(state.index)
1155
+ });
1156
+ state.index += 1;
1157
+ const familyParser = FAMILY_PARSERS[first];
1158
+ if (!familyParser) return Either.left(usageError(`Unknown command: ${first}.`));
1159
+ return familyParser(state);
1160
+ };
1161
+ const parseCliArgs = (argv) => {
1162
+ const options = createDefaultOptions();
1163
+ const result = parseWithState({
1164
+ index: 0,
1165
+ options,
1166
+ tokens: argv
1167
+ });
1168
+ if (Either.isLeft(result)) return Either.left(result.left);
1169
+ return Either.right({
1170
+ command: result.right,
1171
+ options
1172
+ });
1173
+ };
1174
+ //#endregion
1175
+ //#region src/cli/runtime.ts
1176
+ /**
1177
+ * Resolves the repository root up front so every later git and database path is
1178
+ * anchored to the same directory, even when the user invoked the CLI elsewhere.
1179
+ *
1180
+ * NOTE: This uses execFileSync intentionally — it runs once at CLI startup
1181
+ * before the Effect runtime is constructed, so there is no fiber to block.
1182
+ * Moving this into the Effect runtime would create a circular dependency
1183
+ * (config → git → config).
1184
+ */
1185
+ const resolveRepositoryRoot = (repoOverride) => {
1186
+ const cwd = repoOverride ? resolve(repoOverride) : process.cwd();
1187
+ try {
1188
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
1189
+ cwd,
1190
+ encoding: "utf8"
1191
+ }).trim();
1192
+ } catch {
1193
+ return new CliFailure({
1194
+ exitCode: ExitCode.StateUnavailable,
1195
+ message: repoOverride ? `Path ${cwd} is not a Git repository. Use --repo <path> with a valid repository root.` : `Could not resolve a Git repository from ${cwd}. Use --repo <path> with a valid repository root.`
1196
+ });
1197
+ }
1198
+ };
1199
+ const resolveDbPath = (repoRoot, dbPathOverride) => dbPathOverride ? resolve(dbPathOverride) : resolve(repoRoot, ".ringi/reviews.db");
1200
+ const commandNeedsRepository = (command) => command.kind !== "help" && command.kind !== "version" && command.kind !== "mcp" && command.kind !== "serve";
1201
+ const commandNeedsDatabase = (command) => command.kind === "review-list" || command.kind === "review-show" || command.kind === "review-export" || command.kind === "review-status" || command.kind === "todo-list" || command.kind === "doctor";
1202
+ const commandUsesCoreRuntime = (command) => command.kind === "review-list" || command.kind === "review-show" || command.kind === "review-export" || command.kind === "review-status" || command.kind === "todo-list" || command.kind === "review-create" || command.kind === "todo-add" || command.kind === "doctor";
1203
+ const resolveCliConfig = (args) => {
1204
+ const repoRootResult = resolveRepositoryRoot(args.repo);
1205
+ if (repoRootResult instanceof CliFailure) return repoRootResult;
1206
+ return {
1207
+ color: args.color,
1208
+ cwd: process.cwd(),
1209
+ dbPath: resolveDbPath(repoRootResult, args.dbPath),
1210
+ outputMode: "human",
1211
+ quiet: args.quiet,
1212
+ repoRoot: repoRootResult,
1213
+ verbose: args.verbose
1214
+ };
1215
+ };
1216
+ /**
1217
+ * Read-only commands still depend on initialized local state because the shared
1218
+ * services use the same database-backed storage as the other clients.
1219
+ */
1220
+ const ensureLocalStateAvailable = (config) => {
1221
+ if (!existsSync(config.dbPath)) return new CliFailure({
1222
+ exitCode: ExitCode.StateUnavailable,
1223
+ message: `Local state is missing at ${config.dbPath}. Run 'ringi data migrate' or start 'ringi serve' once to initialize local state.`
1224
+ });
1225
+ };
1226
+ const makeConfigLayer = (config) => Layer.setConfigProvider(ConfigProvider.fromMap(new Map([["DB_PATH", config.dbPath], ["REPOSITORY_PATH", config.repoRoot]])));
1227
+ const createCoreCliRuntime = (config) => ManagedRuntime.make(Layer.mergeAll(CoreLive, CliConfigLive(config)).pipe(Layer.provideMerge(makeConfigLayer(config))));
1228
+ const createGitCliRuntime = (config) => ManagedRuntime.make(Layer.mergeAll(GitService.Default, CliConfigLive(config)).pipe(Layer.provideMerge(makeConfigLayer(config))));
1229
+ /**
1230
+ * Centralizes runtime selection. Returns either valid resources or a CliFailure.
1231
+ * Returns null for help/version commands that don't need a runtime.
1232
+ */
1233
+ const createCliRuntimeResources = (command, args) => {
1234
+ if (!commandNeedsRepository(command)) return null;
1235
+ const configResult = resolveCliConfig(args);
1236
+ if (configResult instanceof CliFailure) return configResult;
1237
+ if (commandNeedsDatabase(command)) {
1238
+ const stateError = ensureLocalStateAvailable(configResult);
1239
+ if (stateError) return stateError;
1240
+ }
1241
+ return {
1242
+ config: configResult,
1243
+ runtime: commandUsesCoreRuntime(command) ? createCoreCliRuntime(configResult) : createGitCliRuntime(configResult)
1244
+ };
1245
+ };
1246
+ //#endregion
1247
+ //#region src/cli/main.ts
1248
+ const CLI_VERSION = "0.2.1";
1249
+ const COMMAND_TREE = {
1250
+ commands: [
1251
+ {
1252
+ description: "List review sessions",
1253
+ name: "review list",
1254
+ usage: "ringi review list [--status <status>] [--source <type>] [--limit <n>] [--page <n>]"
1255
+ },
1256
+ {
1257
+ description: "Show review details",
1258
+ name: "review show",
1259
+ usage: "ringi review show <id|last> [--comments] [--todos]"
1260
+ },
1261
+ {
1262
+ description: "Create a review session",
1263
+ name: "review create",
1264
+ usage: "ringi review create [--source <staged|branch|commits>] [--branch <name>] [--commits <range>]"
1265
+ },
1266
+ {
1267
+ description: "Export review as markdown",
1268
+ name: "review export",
1269
+ usage: "ringi review export <id|last> [--output <path>] [--stdout]"
1270
+ },
1271
+ {
1272
+ description: "Resolve a review session",
1273
+ name: "review resolve",
1274
+ usage: "ringi review resolve <id|last> [--all-comments] [--yes]"
1275
+ },
1276
+ {
1277
+ description: "Show repository and review status",
1278
+ name: "review status",
1279
+ usage: "ringi review status [--review <id|last>] [--source <type>]"
1280
+ },
1281
+ {
1282
+ description: "List repository sources",
1283
+ name: "source list",
1284
+ usage: "ringi source list"
1285
+ },
1286
+ {
1287
+ description: "Show diff for a source",
1288
+ name: "source diff",
1289
+ usage: "ringi source diff <staged|branch|commits> [--branch <name>] [--commits <range>] [--stat]"
1290
+ },
1291
+ {
1292
+ description: "List todo items",
1293
+ name: "todo list",
1294
+ usage: "ringi todo list [--review <id>] [--status <pending|done|all>] [--limit <n>] [--offset <n>]"
1295
+ },
1296
+ {
1297
+ description: "Add a todo item",
1298
+ name: "todo add",
1299
+ usage: "ringi todo add --text <text> [--review <id>] [--position <n>]"
1300
+ },
1301
+ {
1302
+ description: "Mark a todo as done",
1303
+ name: "todo done",
1304
+ usage: "ringi todo done <id>"
1305
+ },
1306
+ {
1307
+ description: "Reopen a completed todo",
1308
+ name: "todo undone",
1309
+ usage: "ringi todo undone <id>"
1310
+ },
1311
+ {
1312
+ description: "Move a todo to a position",
1313
+ name: "todo move",
1314
+ usage: "ringi todo move <id> --position <n>"
1315
+ },
1316
+ {
1317
+ description: "Remove a todo",
1318
+ name: "todo remove",
1319
+ usage: "ringi todo remove <id> [--yes]"
1320
+ },
1321
+ {
1322
+ description: "Clear completed todos",
1323
+ name: "todo clear",
1324
+ usage: "ringi todo clear [--review <id>] [--done-only] [--all] [--yes]"
1325
+ },
1326
+ {
1327
+ description: "Start the local Ringi server",
1328
+ name: "serve",
1329
+ usage: "ringi serve [--host <host>] [--port <port>] [--https] [--auth] [--no-open]"
1330
+ },
1331
+ {
1332
+ description: "Start the MCP stdio server",
1333
+ name: "mcp",
1334
+ usage: "ringi mcp [--readonly] [--log-level <level>]"
1335
+ },
1336
+ {
1337
+ description: "Run local diagnostics",
1338
+ name: "doctor",
1339
+ usage: "ringi doctor"
1340
+ },
1341
+ {
1342
+ description: "Tail server events",
1343
+ name: "events",
1344
+ usage: "ringi events [--type <reviews|comments|todos|files>]"
1345
+ },
1346
+ {
1347
+ description: "Run database migrations",
1348
+ name: "data migrate",
1349
+ usage: "ringi data migrate"
1350
+ },
1351
+ {
1352
+ description: "Reset local data",
1353
+ name: "data reset",
1354
+ usage: "ringi data reset [--yes] [--keep-exports]"
1355
+ }
1356
+ ],
1357
+ description: "ringi — local-first code review CLI",
1358
+ version: CLI_VERSION
1359
+ };
1360
+ const ROOT_NEXT_ACTIONS = [
1361
+ {
1362
+ command: "ringi review list [--status <status>] [--source <type>]",
1363
+ description: "List review sessions",
1364
+ params: {
1365
+ source: { enum: [
1366
+ "staged",
1367
+ "branch",
1368
+ "commits"
1369
+ ] },
1370
+ status: { enum: [
1371
+ "in_progress",
1372
+ "approved",
1373
+ "changes_requested"
1374
+ ] }
1375
+ }
1376
+ },
1377
+ {
1378
+ command: "ringi source list",
1379
+ description: "List repository sources"
1380
+ },
1381
+ {
1382
+ command: "ringi review create [--source <source>]",
1383
+ description: "Create a new review session",
1384
+ params: { source: {
1385
+ default: "staged",
1386
+ enum: [
1387
+ "staged",
1388
+ "branch",
1389
+ "commits"
1390
+ ]
1391
+ } }
1392
+ },
1393
+ {
1394
+ command: "ringi todo list [--status <status>]",
1395
+ description: "List todos",
1396
+ params: { status: {
1397
+ default: "pending",
1398
+ enum: [
1399
+ "pending",
1400
+ "done",
1401
+ "all"
1402
+ ]
1403
+ } }
1404
+ },
1405
+ {
1406
+ command: "ringi review status",
1407
+ description: "Show repository and review status"
1408
+ }
1409
+ ];
1410
+ const ROOT_HELP = `ringi — local-first review CLI
1411
+
1412
+ Usage:
1413
+ ringi [global options] <command>
1414
+
1415
+ Global options:
1416
+ --json Emit structured JSON envelope to stdout
1417
+ --repo <path> Use a specific Git repository root
1418
+ --db-path <path> Override the SQLite database path
1419
+ --quiet Suppress human-readable success output
1420
+ --verbose Include stack traces on failures
1421
+ --no-color Disable ANSI color output
1422
+ --help Show help
1423
+ --version Show version
1424
+
1425
+ Commands:
1426
+ review list [--status <status>] [--source <type>] [--limit <n>] [--page <n>]
1427
+ review show <id|last> [--comments] [--todos]
1428
+ review create [--source <staged|branch|commits>] [--branch <name>] [--commits <range>]
1429
+ review export <id|last> [--output <path>] [--stdout]
1430
+ review resolve <id|last> [--all-comments] [--yes]
1431
+ review status [--review <id|last>] [--source <type>]
1432
+ source list
1433
+ source diff <staged|branch|commits> [--branch <name>] [--commits <range>] [--stat]
1434
+ todo list [--review <id>] [--status <pending|done|all>] [--limit <n>] [--offset <n>]
1435
+ todo add --text <text> [--review <id>]
1436
+ todo done <id>
1437
+ todo undone <id>
1438
+ todo move <id> --position <n>
1439
+ todo remove <id> [--yes]
1440
+ todo clear [--review <id>] [--done-only] [--all] [--yes]
1441
+ export <id|last> [--output <path>] [--stdout]
1442
+ `;
1443
+ const HELP_TOPICS = {
1444
+ data: `ringi data
1445
+
1446
+ Usage:
1447
+ ringi data migrate
1448
+ ringi data reset [--yes] [--keep-exports]
1449
+ `,
1450
+ review: `ringi review
1451
+
1452
+ Usage:
1453
+ ringi review list [--status <status>] [--source <type>] [--limit <n>] [--page <n>]
1454
+ ringi review show <id|last> [--comments] [--todos]
1455
+ ringi review create [--source <staged|branch|commits>] [--branch <name>] [--commits <range>]
1456
+ ringi review export <id|last> [--output <path>] [--stdout]
1457
+ ringi review resolve <id|last> [--all-comments] [--yes]
1458
+ ringi review status [--review <id|last>] [--source <type>]
1459
+ `,
1460
+ source: `ringi source
1461
+
1462
+ Usage:
1463
+ ringi source list
1464
+ ringi source diff <staged|branch|commits> [--branch <name>] [--commits <range>] [--stat]
1465
+ `,
1466
+ todo: `ringi todo
1467
+
1468
+ Usage:
1469
+ ringi todo list [--review <id>] [--status <pending|done|all>] [--limit <n>] [--offset <n>]
1470
+ ringi todo add --text <text> [--review <id>] [--position <n>]
1471
+ ringi todo done <id>
1472
+ ringi todo undone <id>
1473
+ ringi todo move <id> --position <n>
1474
+ ringi todo remove <id> [--yes]
1475
+ ringi todo clear [--review <id>] [--done-only] [--all] [--yes]
1476
+ `
1477
+ };
1478
+ const renderHelp = (command) => {
1479
+ if (command.kind !== "help") return ROOT_HELP;
1480
+ const [topic] = command.topic;
1481
+ return (topic && HELP_TOPICS[topic]) ?? ROOT_HELP;
1482
+ };
1483
+ const writeJson = (payload) => {
1484
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
1485
+ };
1486
+ const writeHuman = (text) => {
1487
+ if (text && text.length > 0) process.stdout.write(`${text}\n`);
1488
+ };
1489
+ /** Maps exit codes to error categories and retryable status. */
1490
+ const EXIT_CODE_META = {
1491
+ [ExitCode.AuthFailure]: {
1492
+ category: "auth",
1493
+ code: "AUTH_FAILURE",
1494
+ retryable: false
1495
+ },
1496
+ [ExitCode.ResourceNotFound]: {
1497
+ category: "not_found",
1498
+ code: "RESOURCE_NOT_FOUND",
1499
+ retryable: false
1500
+ },
1501
+ [ExitCode.RuntimeFailure]: {
1502
+ category: "server",
1503
+ code: "RUNTIME_FAILURE",
1504
+ retryable: true
1505
+ },
1506
+ [ExitCode.StateUnavailable]: {
1507
+ category: "config",
1508
+ code: "STATE_UNAVAILABLE",
1509
+ retryable: false
1510
+ },
1511
+ [ExitCode.UsageError]: {
1512
+ category: "validation",
1513
+ code: "USAGE_ERROR",
1514
+ retryable: false
1515
+ }
1516
+ };
1517
+ const mapFailure = (error) => {
1518
+ if (error instanceof CliFailure) {
1519
+ const meta = EXIT_CODE_META[error.exitCode] ?? {
1520
+ category: "server",
1521
+ code: "UNKNOWN",
1522
+ retryable: false
1523
+ };
1524
+ return {
1525
+ category: meta.category,
1526
+ code: meta.code,
1527
+ exitCode: error.exitCode,
1528
+ message: error.message,
1529
+ retryable: meta.retryable,
1530
+ verbose: error.details
1531
+ };
1532
+ }
1533
+ if (error instanceof ReviewNotFound || error instanceof TodoNotFound) return {
1534
+ category: "not_found",
1535
+ code: "RESOURCE_NOT_FOUND",
1536
+ exitCode: ExitCode.ResourceNotFound,
1537
+ message: error.message,
1538
+ retryable: false,
1539
+ verbose: error.stack
1540
+ };
1541
+ if (error instanceof Error) return {
1542
+ category: "server",
1543
+ code: "RUNTIME_FAILURE",
1544
+ exitCode: ExitCode.RuntimeFailure,
1545
+ message: error.message,
1546
+ retryable: true,
1547
+ verbose: error.stack
1548
+ };
1549
+ return {
1550
+ category: "server",
1551
+ code: "UNKNOWN_FAILURE",
1552
+ exitCode: ExitCode.RuntimeFailure,
1553
+ message: "Unknown CLI failure.",
1554
+ retryable: false
1555
+ };
1556
+ };
1557
+ /** Actionable fix guidance based on error category. */
1558
+ const FIX_GUIDANCE = {
1559
+ auth: "Check authentication credentials or run 'ringi serve --auth' with valid credentials.",
1560
+ config: "Run 'ringi serve' once to initialize local state, or check --repo and --db-path flags.",
1561
+ conflict: "Resolve the conflict and retry the operation.",
1562
+ connection: "Ensure the Ringi server is running: ringi serve",
1563
+ not_found: "Verify the resource ID. Use 'ringi review list' or 'ringi todo list' to find valid IDs.",
1564
+ server: "Retry the command. If the error persists, check 'ringi serve' logs.",
1565
+ validation: "Check command usage with 'ringi --help'. Verify flag names and values."
1566
+ };
1567
+ /** Build recovery next_actions based on error category. */
1568
+ const errorNextActions = (commandStr, normalized) => {
1569
+ const actions = [];
1570
+ if (normalized.retryable) actions.push({
1571
+ command: commandStr,
1572
+ description: "Retry the failed command"
1573
+ });
1574
+ if (normalized.category === "config" || normalized.category === "connection") actions.push({
1575
+ command: "ringi serve",
1576
+ description: "Start the local Ringi server"
1577
+ });
1578
+ if (normalized.category === "not_found") actions.push({
1579
+ command: "ringi review list",
1580
+ description: "List available reviews"
1581
+ }, {
1582
+ command: "ringi todo list",
1583
+ description: "List available todos"
1584
+ });
1585
+ if (normalized.category === "validation") actions.push({
1586
+ command: `${commandStr.split(" ").slice(0, 3).join(" ")} --help`,
1587
+ description: "Show command usage"
1588
+ });
1589
+ return actions;
1590
+ };
1591
+ /** Build a full error envelope from a normalized failure. */
1592
+ const buildErrorEnvelope = (commandStr, normalized) => {
1593
+ return failure(commandStr, {
1594
+ category: normalized.category,
1595
+ code: normalized.code,
1596
+ message: normalized.message,
1597
+ retryable: normalized.retryable,
1598
+ type: `ringi://errors/${normalized.code}`
1599
+ }, normalized.verbose ?? FIX_GUIDANCE[normalized.category], errorNextActions(commandStr, normalized));
1600
+ };
1601
+ const installSignalHandlers = (dispose) => {
1602
+ const shutdown = async () => {
1603
+ await dispose();
1604
+ process.exit(ExitCode.RuntimeFailure);
1605
+ };
1606
+ process.once("SIGINT", shutdown);
1607
+ process.once("SIGTERM", shutdown);
1608
+ return () => {
1609
+ process.off("SIGINT", shutdown);
1610
+ process.off("SIGTERM", shutdown);
1611
+ };
1612
+ };
1613
+ /**
1614
+ * Resolves the built Nitro server entry point. Returns the absolute path to
1615
+ * `.output/server/index.mjs` relative to the package root (the directory
1616
+ * containing this CLI entry point after bundling).
1617
+ */
1618
+ const resolveServerEntry = () => {
1619
+ const candidates = [resolve(process.cwd(), ".output", "server", "index.mjs")];
1620
+ if (import.meta.dirname) candidates.push(resolve(import.meta.dirname, "..", ".output", "server", "index.mjs"));
1621
+ return candidates.find((candidate) => existsSync(candidate));
1622
+ };
1623
+ const runServe = (command) => {
1624
+ const serverEntry = resolveServerEntry();
1625
+ if (!serverEntry) {
1626
+ process.stderr.write("No built server found. Run 'pnpm build' first, then retry 'ringi serve'.\n");
1627
+ process.exit(ExitCode.RuntimeFailure);
1628
+ }
1629
+ const env = {
1630
+ ...process.env,
1631
+ NITRO_HOST: command.host,
1632
+ NITRO_PORT: String(command.port)
1633
+ };
1634
+ if (command.https && command.cert && command.key) {
1635
+ env.NITRO_SSL_CERT = command.cert;
1636
+ env.NITRO_SSL_KEY = command.key;
1637
+ }
1638
+ const url = `${command.https ? "https" : "http"}://${command.host === "0.0.0.0" ? "localhost" : command.host}:${command.port}`;
1639
+ process.stderr.write(`ringi server starting on ${url}\n`);
1640
+ const child = fork(serverEntry, [], {
1641
+ env,
1642
+ execArgv: [],
1643
+ stdio: "inherit"
1644
+ });
1645
+ if (!command.noOpen) setTimeout(() => {
1646
+ let openCmd = "xdg-open";
1647
+ if (process.platform === "darwin") openCmd = "open";
1648
+ else if (process.platform === "win32") openCmd = "start";
1649
+ exec(`${openCmd} ${url}`, () => {});
1650
+ }, 1500);
1651
+ const shutdown = () => {
1652
+ child.kill("SIGTERM");
1653
+ };
1654
+ process.once("SIGINT", shutdown);
1655
+ process.once("SIGTERM", shutdown);
1656
+ child.on("exit", (code) => {
1657
+ process.off("SIGINT", shutdown);
1658
+ process.off("SIGTERM", shutdown);
1659
+ process.exit(code ?? ExitCode.Success);
1660
+ });
1661
+ };
1662
+ /** Single path for all CLI error exits. */
1663
+ const failAndExit = (opts) => {
1664
+ const normalized = mapFailure(opts.error);
1665
+ if (opts.json) writeJson(buildErrorEnvelope(opts.cmdStr, normalized));
1666
+ process.stderr.write(`${normalized.message}\n`);
1667
+ if (opts.verbose && normalized.verbose) process.stderr.write(`${normalized.verbose}\n`);
1668
+ return process.exit(normalized.exitCode);
1669
+ };
1670
+ const main = async () => {
1671
+ const argv = process.argv.slice(2);
1672
+ const parseResult = parseCliArgs(argv);
1673
+ if (Either.isLeft(parseResult)) return failAndExit({
1674
+ cmdStr: "ringi",
1675
+ error: parseResult.left,
1676
+ json: argv.includes("--json"),
1677
+ verbose: false
1678
+ });
1679
+ const { command, options } = parseResult.right;
1680
+ if (command.kind === "help") {
1681
+ if (options.json) writeJson(success("ringi", COMMAND_TREE, ROOT_NEXT_ACTIONS));
1682
+ else writeHuman(renderHelp(command));
1683
+ process.exit(ExitCode.Success);
1684
+ }
1685
+ if (command.kind === "version") {
1686
+ if (options.json) writeJson(success("ringi --version", { version: CLI_VERSION }));
1687
+ else writeHuman(CLI_VERSION);
1688
+ process.exit(ExitCode.Success);
1689
+ }
1690
+ if (command.kind === "serve") {
1691
+ runServe(command);
1692
+ return;
1693
+ }
1694
+ const runtimeResources = createCliRuntimeResources(command, {
1695
+ color: options.color,
1696
+ dbPath: options.dbPath,
1697
+ quiet: options.quiet,
1698
+ repo: options.repo,
1699
+ verbose: options.verbose
1700
+ });
1701
+ if (runtimeResources === null) process.exit(ExitCode.Success);
1702
+ const cmdStr = commandLabel(command);
1703
+ if (runtimeResources instanceof CliFailure) return failAndExit({
1704
+ cmdStr,
1705
+ error: runtimeResources,
1706
+ json: options.json,
1707
+ verbose: options.verbose
1708
+ });
1709
+ const removeSignalHandlers = installSignalHandlers(() => runtimeResources.runtime.dispose());
1710
+ try {
1711
+ const output = await runtimeResources.runtime.runPromise(runCommand(command));
1712
+ if (options.json) writeJson(success(cmdStr, output.data, output.nextActions ?? []));
1713
+ else if (!options.quiet) writeHuman(output.human);
1714
+ await runtimeResources.runtime.dispose();
1715
+ removeSignalHandlers();
1716
+ process.exit(ExitCode.Success);
1717
+ } catch (error) {
1718
+ await runtimeResources.runtime.dispose();
1719
+ removeSignalHandlers();
1720
+ failAndExit({
1721
+ cmdStr,
1722
+ error,
1723
+ json: options.json,
1724
+ verbose: options.verbose
1725
+ });
1726
+ }
1727
+ };
1728
+ try {
1729
+ await main();
1730
+ } catch (error) {
1731
+ failAndExit({
1732
+ cmdStr: "ringi",
1733
+ error,
1734
+ json: process.argv.slice(2).includes("--json"),
1735
+ verbose: false
1736
+ });
1737
+ }
1738
+ //#endregion
1739
+ export {};
1740
+
1741
+ //# sourceMappingURL=cli.mjs.map