@sanurb/ringi 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/cli.mjs +534 -1598
  2. package/dist/cli.mjs.map +1 -1
  3. package/dist/mcp.mjs +4 -4
  4. package/dist/runtime.mjs +2437 -2437
  5. package/dist/runtime.mjs.map +1 -1
  6. package/package.json +2 -1
  7. package/server/nitro.json +1 -1
  8. package/server/public/assets/{_reviewId-DdOpDx4U.js → _reviewId-DszBZzPS.js} +1 -1
  9. package/server/public/assets/{main-FvxVz-kD.js → main-BQVB_Z0Z.js} +2 -2
  10. package/server/public/assets/{new-DOyplRwM.js → new-BpF6zgAP.js} +1 -1
  11. package/server/public/assets/{reviews-CfbuF6ib.js → reviews-Cqy8KE2-.js} +1 -1
  12. package/server/public/assets/reviews-YuW-Er6I.js +1 -0
  13. package/server/public/assets/{routes-DNxq1Fba.js → routes-Bp_N1q52.js} +1 -1
  14. package/server/public/assets/{routes-Dp0ODZ55.js → routes-CdmXLllM.js} +1 -1
  15. package/server/server/_chunks/ssr-renderer.mjs +2 -2
  16. package/server/server/_libs/effect+[...].mjs +396 -193
  17. package/server/server/{_reviewId-AWnOGz5k.mjs → _reviewId-BBo0j3l1.mjs} +2 -2
  18. package/server/server/{_reviewId-p9mhYVwa.mjs → _reviewId-CBLnTeaJ.mjs} +2 -2
  19. package/server/server/{_reviewId-DAhmekJ2.mjs → _reviewId-Ce1qqku3.mjs} +3 -3
  20. package/server/server/_ssr/action-bar-C68xGnWW.mjs +1 -1
  21. package/server/server/_ssr/{api-handler-CstW2n82.mjs → api-handler-yxu7Cbl5.mjs} +35 -3
  22. package/server/server/_ssr/client-runtime-BoPuAEoA.mjs +1 -1
  23. package/server/server/_ssr/domain-rpc-3Ds9DPr0.mjs +1 -1
  24. package/server/server/_ssr/file-tree-CQ5w2GHh.mjs +1 -1
  25. package/server/server/_ssr/load-scoped-diff-NL2XAcdz.mjs +2 -2
  26. package/server/server/_ssr/new-BKl_G2Ks.mjs +1 -1
  27. package/server/server/_ssr/new-DCz5eHkb.mjs +1 -1
  28. package/server/server/_ssr/reviews-C7_NIhY8.mjs +1 -1
  29. package/server/server/_ssr/{router-DLxN8FOm.mjs → router-5hEjszhz.mjs} +4 -4
  30. package/server/server/_ssr/routes-lz0AN75A.mjs +1 -1
  31. package/server/server/_ssr/runtime-D9IbnMlF.mjs +1 -1
  32. package/server/server/_ssr/ssr.mjs +7 -7
  33. package/server/server/_ssr/todo-m_uUvxca.mjs +1 -1
  34. package/server/server/{_tanstack-start-manifest_v-CnL10NRH.mjs → _tanstack-start-manifest_v--jyOFxuR.mjs} +9 -9
  35. package/server/server/index.mjs +905 -905
  36. package/server/public/assets/reviews-CJvVXRLH.js +0 -1
package/dist/cli.mjs CHANGED
@@ -1,16 +1,16 @@
1
1
  #!/usr/bin/env node
2
- import { a as ReviewService, c as ReviewFileRepo, d as parseDiff, f as CommentService, g as ReviewNotFound, i as TodoService, l as serializeHunks, m as TodoNotFound, n as GhService, o as GitService, r as ExportService, s as ReviewRepo, t as CoreLive, u as getDiffSummary } from "./runtime.mjs";
2
+ import { c as getDiffSummary, h as serializeHunks, i as TodoService, l as parseDiff, m as ReviewFileRepo, n as GhService, o as ReviewService, p as ReviewRepo, r as ExportService, s as GitService, t as CoreLive, u as CommentService } from "./runtime.mjs";
3
+ import { NodeRuntime, NodeServices } from "@effect/platform-node";
4
+ import * as Effect from "effect/Effect";
5
+ import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli";
3
6
  import { exec, execFileSync, fork } from "node:child_process";
4
7
  import { existsSync } from "node:fs";
5
- import { resolve } from "node:path";
6
- import * as Schema from "effect/Schema";
7
- import * as Result from "effect/Result";
8
8
  import { writeFile } from "node:fs/promises";
9
- import { ServiceMap } from "effect";
10
- import * as Effect from "effect/Effect";
9
+ import { resolve } from "node:path";
11
10
  import * as Layer from "effect/Layer";
11
+ import { ServiceMap } from "effect";
12
12
  import * as Option from "effect/Option";
13
- import * as ManagedRuntime from "effect/ManagedRuntime";
13
+ import * as Schema from "effect/Schema";
14
14
  import * as ConfigProvider from "effect/ConfigProvider";
15
15
  //#region ../../packages/core/src/services/pr-preflight.ts
16
16
  var PreflightFailure = class extends Schema.TaggedErrorClass()("PreflightFailure", {
@@ -238,13 +238,6 @@ var InvalidPrUrl = class extends Schema.TaggedErrorClass()("InvalidPrUrl", {
238
238
  url: Schema.String
239
239
  }) {};
240
240
  /**
241
- * Quick heuristic: does this string look like a PR URL?
242
- *
243
- * Used by the CLI parser to distinguish `review <url>` from `review <verb>`.
244
- * No ambiguity: no review verb starts with `http`.
245
- */
246
- const looksLikePrUrl = (s) => /^https?:\/\/[^/]+\/[^/]+\/[^/]+\/pull\/\d+/.test(s);
247
- /**
248
241
  * Parses a GitHub PR URL into structured components.
249
242
  *
250
243
  * Supports:
@@ -291,14 +284,13 @@ const parsePrUrl = Effect.fn("parsePrUrl")(function* (raw) {
291
284
  };
292
285
  });
293
286
  //#endregion
294
- //#region src/cli/config.ts
295
- var CliConfig = class extends ServiceMap.Service()("@ringi/CliConfig") {};
287
+ //#region src/cli/cli-errors.ts
296
288
  /**
297
- * Wraps a concrete {@link CliConfigShape} in a layer for the Effect runtime.
289
+ * Typed CLI errors.
290
+ *
291
+ * All CLI errors use `Schema.TaggedErrorClass` so they flow through Effect's
292
+ * typed error channels.
298
293
  */
299
- const CliConfigLive = (config) => Layer.succeed(CliConfig, CliConfig.of(config));
300
- //#endregion
301
- //#region src/cli/contracts.ts
302
294
  const ExitCode = {
303
295
  AuthFailure: 5,
304
296
  ResourceNotFound: 3,
@@ -307,19 +299,6 @@ const ExitCode = {
307
299
  Success: 0,
308
300
  UsageError: 2
309
301
  };
310
- const success = (command, result, nextActions = []) => ({
311
- command,
312
- next_actions: nextActions,
313
- ok: true,
314
- result
315
- });
316
- const failure = (command, error, fix, nextActions = []) => ({
317
- command,
318
- error,
319
- fix,
320
- next_actions: nextActions,
321
- ok: false
322
- });
323
302
  /**
324
303
  * Carries an exit code and optional operator-facing details so callers can
325
304
  * present a short message without losing the underlying reason.
@@ -330,96 +309,99 @@ var CliFailure = class extends Schema.TaggedErrorClass()("CliFailure", {
330
309
  message: Schema.String
331
310
  }) {};
332
311
  //#endregion
333
- //#region src/cli/commands.ts
334
- const formatTable = (headers, rows) => {
335
- const widths = headers.map((header, index) => {
336
- const cellWidths = rows.map((row) => row[index]?.length ?? 0);
337
- return Math.max(header.length, ...cellWidths);
338
- });
339
- const renderRow = (row) => row.map((cell, index) => cell.padEnd(widths.at(index) ?? 0)).join(" ").trimEnd();
340
- return [
341
- renderRow(headers),
342
- renderRow(widths.map((width) => "-".repeat(width))),
343
- ...rows.map(renderRow)
344
- ].join("\n");
345
- };
346
- const renderReviewList = (reviews) => {
347
- if (reviews.length === 0) return "No reviews found.";
348
- return formatTable([
349
- "ID",
350
- "STATUS",
351
- "SOURCE",
352
- "FILES",
353
- "CREATED"
354
- ], reviews.map((review) => [
355
- review.id,
356
- review.status,
357
- review.sourceType,
358
- String(review.fileCount),
359
- review.createdAt
360
- ]));
361
- };
362
- const renderReviewShow = (input) => {
363
- const { comments, review, todos } = input;
364
- const lines = [
365
- `Review ${review.id}`,
366
- `Status: ${review.status}`,
367
- `Source: ${review.sourceType}${review.sourceRef ? ` (${review.sourceRef})` : ""}`,
368
- `Created: ${review.createdAt}`,
369
- `Files: ${review.summary.totalFiles}`,
370
- `Diff: +${review.summary.totalAdditions} / -${review.summary.totalDeletions}`
371
- ];
372
- if (review.files.length > 0) {
373
- lines.push("", "Files:");
374
- for (const file of review.files) lines.push(`- ${file.status.toUpperCase()} ${file.filePath} (+${file.additions} -${file.deletions})`);
375
- }
376
- if (comments && comments.length > 0) {
377
- lines.push("", "Comments:");
378
- for (const comment of comments) {
379
- const location = `${comment.filePath}:${comment.lineNumber ?? "-"}`;
380
- const state = comment.resolved ? "resolved" : "open";
381
- lines.push(`- [${state}] ${location} ${comment.content}`);
382
- }
383
- }
384
- if (todos && todos.length > 0) {
385
- lines.push("", "Todos:");
386
- for (const todo of todos) {
387
- const marker = todo.completed ? "x" : " ";
388
- lines.push(`- [${marker}] (${todo.position + 1}) ${todo.content}`);
389
- }
390
- }
391
- return lines.join("\n");
312
+ //#region src/cli/config.ts
313
+ var CliConfig = class extends ServiceMap.Service()("@ringi/CliConfig") {};
314
+ /**
315
+ * Wraps a concrete {@link CliConfigShape} in a layer for the Effect runtime.
316
+ */
317
+ const CliConfigLive = (config) => Layer.succeed(CliConfig, CliConfig.of(config));
318
+ //#endregion
319
+ //#region src/cli/output.ts
320
+ const successEnvelope = (command, result, nextActions = []) => ({
321
+ command,
322
+ next_actions: nextActions,
323
+ ok: true,
324
+ result
325
+ });
326
+ const writeJson = (payload) => {
327
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
392
328
  };
393
- const renderTodoList = (todos) => {
394
- if (todos.length === 0) return "No todos found.";
395
- return todos.map((todo) => `- [${todo.completed ? "x" : " "}] (${todo.position + 1}) ${todo.content}`).join("\n");
329
+ const writeHuman = (text) => {
330
+ if (text && text.length > 0) process.stdout.write(`${text}\n`);
396
331
  };
397
- const renderSourceList = (input) => {
398
- const lines = [
399
- `Repository: ${input.repo.name}`,
400
- `Path: ${input.repo.path}`,
401
- `Current branch: ${input.repo.branch}`,
402
- `Staged files: ${input.stagedFiles.length}`
403
- ];
404
- if (input.stagedFiles.length > 0) {
405
- lines.push("", "Staged:");
406
- for (const file of input.stagedFiles) lines.push(`- ${file.status} ${file.path}`);
407
- }
408
- if (input.branches.length > 0) {
409
- lines.push("", "Branches:");
410
- for (const branch of input.branches.slice(0, 10)) lines.push(`- ${branch.current ? "*" : " "} ${branch.name}`);
411
- }
412
- if (input.commits.length > 0) {
413
- lines.push("", "Recent commits:");
414
- for (const commit of input.commits.slice(0, 5)) lines.push(`- ${commit.hash.slice(0, 8)} ${commit.message} (${commit.author})`);
332
+ //#endregion
333
+ //#region src/cli/commands.ts
334
+ /**
335
+ * Ringi CLI command definitions using `effect/unstable/cli`.
336
+ *
337
+ * Each command is a typed `Command.make(name, config, handler)` with its flags
338
+ * and arguments declared via `Flag` and `Argument`. The framework handles
339
+ * parsing, help generation, shell completions, and version display.
340
+ *
341
+ * Command handlers produce `CommandOutput<T>` and emit output via the
342
+ * `--json` global flag setting.
343
+ */
344
+ const JsonSetting = GlobalFlag.setting("json")({ flag: Flag.boolean("json").pipe(Flag.withDescription("Emit structured JSON envelope to stdout"), Flag.withDefault(false)) });
345
+ const QuietSetting = GlobalFlag.setting("quiet")({ flag: Flag.boolean("quiet").pipe(Flag.withDescription("Suppress human-readable success output"), Flag.withDefault(false)) });
346
+ const RepoSetting = GlobalFlag.setting("repo")({ flag: Flag.string("repo").pipe(Flag.withDescription("Use a specific Git repository root"), Flag.optional) });
347
+ const DbPathSetting = GlobalFlag.setting("db-path")({ flag: Flag.string("db-path").pipe(Flag.withDescription("Override the SQLite database path"), Flag.optional) });
348
+ const emitOutput = (commandLabel, output) => Effect.gen(function* () {
349
+ const jsonMode = yield* JsonSetting;
350
+ const quietMode = yield* QuietSetting;
351
+ if (jsonMode) writeJson(successEnvelope(commandLabel, output.data, output.nextActions ?? []));
352
+ else if (!quietMode) writeHuman(output.human);
353
+ });
354
+ const resolveRepositoryRoot = (repoOverride) => {
355
+ const cwd = Option.isSome(repoOverride) ? resolve(repoOverride.value) : process.cwd();
356
+ try {
357
+ return Effect.succeed(execFileSync("git", ["rev-parse", "--show-toplevel"], {
358
+ cwd,
359
+ encoding: "utf8"
360
+ }).trim());
361
+ } catch {
362
+ return Effect.fail(new CliFailure({
363
+ exitCode: ExitCode.StateUnavailable,
364
+ message: Option.isSome(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.`
365
+ }));
415
366
  }
416
- return lines.join("\n");
417
367
  };
368
+ const resolveDbPath = (repoRoot, dbPathOverride) => Option.isSome(dbPathOverride) ? resolve(dbPathOverride.value) : resolve(repoRoot, ".ringi/reviews.db");
369
+ const makeCliConfigLayer = Effect.gen(function* () {
370
+ const repoOpt = yield* RepoSetting;
371
+ const dbPathOpt = yield* DbPathSetting;
372
+ const repoRoot = yield* resolveRepositoryRoot(repoOpt);
373
+ const dbPath = resolveDbPath(repoRoot, dbPathOpt);
374
+ const config = {
375
+ color: true,
376
+ cwd: process.cwd(),
377
+ dbPath,
378
+ outputMode: "human",
379
+ quiet: false,
380
+ repoRoot,
381
+ verbose: false
382
+ };
383
+ return Layer.mergeAll(CliConfigLive(config), ConfigProvider.layer(ConfigProvider.fromUnknown({
384
+ DB_PATH: dbPath,
385
+ REPOSITORY_PATH: repoRoot
386
+ })));
387
+ });
388
+ const ensureDatabaseExists = Effect.gen(function* () {
389
+ const cliConfig = yield* CliConfig;
390
+ if (!existsSync(cliConfig.dbPath)) yield* new CliFailure({
391
+ exitCode: ExitCode.StateUnavailable,
392
+ message: `Local state is missing at ${cliConfig.dbPath}. Run 'ringi data migrate' or start 'ringi serve' once to initialize local state.`
393
+ });
394
+ });
418
395
  /**
419
- * Resolves the special "last" selector before show/export handlers ask the
420
- * shared services for a concrete review id.
396
+ * Provides CoreLive + CliConfig to a command's handler.
397
+ * Constructs the layer from the global flags (--repo, --db-path).
421
398
  */
422
- const resolveReviewSelector = Effect.fn("CLI.resolveReviewSelector")(function* resolveReviewSelector(selector) {
399
+ const provideCoreLayer = (self) => Command.provide(self, () => Layer.unwrap(makeCliConfigLayer.pipe(Effect.map((configLayer) => Layer.mergeAll(CoreLive, configLayer).pipe(Layer.provideMerge(configLayer))))));
400
+ /**
401
+ * Provides only GitService + CliConfig (lighter for git-only commands).
402
+ */
403
+ const provideGitLayer = (self) => Command.provide(self, () => Layer.unwrap(makeCliConfigLayer.pipe(Effect.map((configLayer) => Layer.mergeAll(GitService.Default, configLayer).pipe(Layer.provideMerge(configLayer))))));
404
+ const resolveReviewSelector = Effect.fn("CLI.resolveReviewSelector")(function* (selector) {
423
405
  if (selector !== "last") return selector;
424
406
  const cliConfig = yield* CliConfig;
425
407
  const [review] = (yield* (yield* ReviewService).list({
@@ -433,29 +415,47 @@ const resolveReviewSelector = Effect.fn("CLI.resolveReviewSelector")(function* r
433
415
  });
434
416
  return review.id;
435
417
  });
436
- /**
437
- * Mutating CLI commands stay server-backed so they share the same write path as
438
- * the other clients instead of growing a second local-only behavior surface.
439
- */
440
418
  const requireServerMode = (label) => Effect.fail(new CliFailure({
441
419
  details: "Start 'ringi serve' and retry the command.",
442
420
  exitCode: ExitCode.StateUnavailable,
443
421
  message: `${label} requires a running local Ringi server. Standalone local writes are intentionally unsupported.`
444
422
  }));
445
- const diffSourceStrategies = {
446
- branch: (git, command) => git.getBranchDiff(command.branch ?? ""),
447
- commits: (git, command) => git.getCommitDiff((command.commits ?? "").split(",").map((item) => item.trim()).filter(Boolean)),
448
- staged: (git) => git.getStagedDiff
423
+ const formatTable = (headers, rows) => {
424
+ const widths = headers.map((header, index) => {
425
+ const cellWidths = rows.map((row) => row[index]?.length ?? 0);
426
+ return Math.max(header.length, ...cellWidths);
427
+ });
428
+ const renderRow = (row) => row.map((cell, index) => cell.padEnd(widths.at(index) ?? 0)).join(" ").trimEnd();
429
+ return [
430
+ renderRow(headers),
431
+ renderRow(widths.map((width) => "-".repeat(width))),
432
+ ...rows.map(renderRow)
433
+ ].join("\n");
449
434
  };
450
- const runReviewList = Effect.fn("CLI.reviewList")(function* runReviewList(command) {
435
+ const reviewList = Command.make("list", {
436
+ status: Flag.choice("status", [
437
+ "in_progress",
438
+ "approved",
439
+ "changes_requested"
440
+ ]).pipe(Flag.withDescription("Filter by review status"), Flag.optional),
441
+ source: Flag.choice("source", [
442
+ "staged",
443
+ "branch",
444
+ "commits",
445
+ "pull_request"
446
+ ]).pipe(Flag.withDescription("Filter by source type"), Flag.optional),
447
+ limit: Flag.integer("limit").pipe(Flag.withDefault(20), Flag.withDescription("Number of results per page")),
448
+ page: Flag.integer("page").pipe(Flag.withDefault(1), Flag.withDescription("Page number"))
449
+ }, (config) => Effect.gen(function* () {
450
+ yield* ensureDatabaseExists;
451
451
  const reviewService = yield* ReviewService;
452
452
  const cliConfig = yield* CliConfig;
453
453
  const result = yield* reviewService.list({
454
- page: command.page,
455
- pageSize: command.limit,
454
+ page: config.page,
455
+ pageSize: config.limit,
456
456
  repositoryPath: cliConfig.repoRoot,
457
- sourceType: command.source,
458
- status: command.status
457
+ sourceType: Option.getOrUndefined(config.source),
458
+ status: Option.getOrUndefined(config.status)
459
459
  });
460
460
  const nextActions = [];
461
461
  for (const review of result.reviews.slice(0, 3)) nextActions.push({
@@ -463,87 +463,106 @@ const runReviewList = Effect.fn("CLI.reviewList")(function* runReviewList(comman
463
463
  description: `Inspect review ${review.id} (${review.status})`
464
464
  });
465
465
  if (result.reviews.length > 0) nextActions.push({
466
- command: "ringi review show <id> [--comments] [--todos]",
467
- description: "Show full review details",
468
- params: { id: {
469
- description: "Review ID or 'last'",
470
- required: true
471
- } }
466
+ command: "ringi review show <id> --comments --todos",
467
+ description: "Show full review details"
472
468
  });
473
469
  nextActions.push({
474
- command: "ringi review create [--source <source>]",
475
- description: "Create a new review session",
476
- params: { source: {
477
- default: "staged",
478
- enum: [
479
- "staged",
480
- "branch",
481
- "commits"
482
- ]
483
- } }
470
+ command: "ringi review create --source <source>",
471
+ description: "Create a new review session"
484
472
  });
485
- return {
473
+ yield* emitOutput("ringi review list", {
486
474
  data: result,
487
- human: renderReviewList(result.reviews),
475
+ human: result.reviews.length === 0 ? "No reviews found." : formatTable([
476
+ "ID",
477
+ "STATUS",
478
+ "SOURCE",
479
+ "FILES",
480
+ "CREATED"
481
+ ], result.reviews.map((r) => [
482
+ r.id,
483
+ r.status,
484
+ r.sourceType,
485
+ String(r.fileCount),
486
+ r.createdAt
487
+ ])),
488
488
  nextActions
489
- };
490
- });
491
- const runReviewShow = Effect.fn("CLI.reviewShow")(function* runReviewShow(command) {
489
+ });
490
+ })).pipe(Command.withDescription("List review sessions"));
491
+ const reviewShow = Command.make("show", {
492
+ id: Argument.string("id"),
493
+ comments: Flag.boolean("comments").pipe(Flag.withDefault(false), Flag.withDescription("Include comments")),
494
+ todos: Flag.boolean("todos").pipe(Flag.withDefault(false), Flag.withDescription("Include todos"))
495
+ }, (config) => Effect.gen(function* () {
496
+ yield* ensureDatabaseExists;
492
497
  const reviewService = yield* ReviewService;
493
498
  const todoService = yield* TodoService;
494
499
  const commentService = yield* CommentService;
495
- const reviewId = yield* resolveReviewSelector(command.id);
500
+ const reviewId = yield* resolveReviewSelector(config.id);
496
501
  const review = yield* reviewService.getById(reviewId);
502
+ const comments = config.comments ? yield* commentService.getByReview(reviewId) : void 0;
503
+ const todos = config.todos ? (yield* todoService.list({ reviewId })).data : void 0;
497
504
  const data = {
498
- comments: command.comments ? yield* commentService.getByReview(reviewId) : void 0,
505
+ comments,
499
506
  review,
500
- todos: command.todos ? (yield* todoService.list({ reviewId })).data : void 0
507
+ todos
501
508
  };
502
- const nextActions = [
503
- {
504
- command: `ringi review export ${reviewId}`,
505
- description: "Export this review as markdown"
506
- },
507
- {
508
- command: `ringi review show ${reviewId} --comments --todos`,
509
- description: "Show with comments and todos"
510
- },
511
- {
512
- command: "ringi todo list [--review <review-id>] [--status <status>]",
513
- description: "List todos for this review",
514
- params: {
515
- "review-id": { value: reviewId },
516
- status: {
517
- default: "pending",
518
- enum: [
519
- "pending",
520
- "done",
521
- "all"
522
- ]
523
- }
524
- }
525
- },
526
- {
527
- command: "ringi review list",
528
- description: "Back to review list"
529
- }
509
+ const lines = [
510
+ `Review ${review.id}`,
511
+ `Status: ${review.status}`,
512
+ `Source: ${review.sourceType}${review.sourceRef ? ` (${review.sourceRef})` : ""}`,
513
+ `Created: ${review.createdAt}`,
514
+ `Files: ${review.summary.totalFiles}`,
515
+ `Diff: +${review.summary.totalAdditions} / -${review.summary.totalDeletions}`
530
516
  ];
531
- return {
517
+ if (review.files.length > 0) {
518
+ lines.push("", "Files:");
519
+ for (const file of review.files) lines.push(`- ${file.status.toUpperCase()} ${file.filePath} (+${file.additions} -${file.deletions})`);
520
+ }
521
+ if (comments && comments.length > 0) {
522
+ lines.push("", "Comments:");
523
+ for (const comment of comments) {
524
+ const location = `${comment.filePath}:${comment.lineNumber ?? "-"}`;
525
+ const state = comment.resolved ? "resolved" : "open";
526
+ lines.push(`- [${state}] ${location} ${comment.content}`);
527
+ }
528
+ }
529
+ if (todos && todos.length > 0) {
530
+ lines.push("", "Todos:");
531
+ for (const todo of todos) {
532
+ const marker = todo.completed ? "x" : " ";
533
+ lines.push(`- [${marker}] (${todo.position + 1}) ${todo.content}`);
534
+ }
535
+ }
536
+ yield* emitOutput("ringi review show", {
532
537
  data,
533
- human: renderReviewShow(data),
534
- nextActions
535
- };
536
- });
537
- const runReviewExport = Effect.fn("CLI.reviewExport")(function* runReviewExport(command) {
538
- if (command.noResolved || command.noSnippets) yield* new CliFailure({
539
- exitCode: ExitCode.UsageError,
540
- message: "--no-resolved and --no-snippets are documented, but the shared export service does not support adapter-level filtering yet."
538
+ human: lines.join("\n"),
539
+ nextActions: [
540
+ {
541
+ command: `ringi review export ${reviewId}`,
542
+ description: "Export this review as markdown"
543
+ },
544
+ {
545
+ command: `ringi review show ${reviewId} --comments --todos`,
546
+ description: "Show with comments and todos"
547
+ },
548
+ {
549
+ command: "ringi review list",
550
+ description: "Back to review list"
551
+ }
552
+ ]
541
553
  });
554
+ })).pipe(Command.withDescription("Show review details"));
555
+ const reviewExport = Command.make("export", {
556
+ id: Argument.string("id"),
557
+ output: Flag.string("output").pipe(Flag.withDescription("Output file path"), Flag.optional),
558
+ stdout: Flag.boolean("stdout").pipe(Flag.withDefault(false), Flag.withDescription("Print to stdout instead of a file"))
559
+ }, (config) => Effect.gen(function* () {
560
+ yield* ensureDatabaseExists;
542
561
  const exportService = yield* ExportService;
543
562
  const cliConfig = yield* CliConfig;
544
- const reviewId = yield* resolveReviewSelector(command.id);
563
+ const reviewId = yield* resolveReviewSelector(config.id);
545
564
  const markdown = yield* exportService.exportReview(reviewId);
546
- const outputPath = command.outputPath ? resolve(cliConfig.cwd, command.outputPath) : void 0;
565
+ const outputPath = Option.isSome(config.output) ? resolve(cliConfig.cwd, config.output.value) : void 0;
547
566
  if (outputPath) yield* Effect.tryPromise({
548
567
  catch: (error) => new CliFailure({
549
568
  exitCode: ExitCode.RuntimeFailure,
@@ -551,107 +570,49 @@ const runReviewExport = Effect.fn("CLI.reviewExport")(function* runReviewExport(
551
570
  }),
552
571
  try: () => writeFile(outputPath, markdown, "utf8")
553
572
  });
554
- const shouldPrintMarkdown = command.stdout || !outputPath;
555
- const data = {
556
- markdown,
557
- outputPath: outputPath ?? null,
558
- reviewId
559
- };
560
- const nextActions = [{
561
- command: `ringi review show ${reviewId}`,
562
- description: "View the exported review"
563
- }, {
564
- command: "ringi review list",
565
- description: "Back to review list"
566
- }];
567
- return {
568
- data,
573
+ const shouldPrintMarkdown = config.stdout || !outputPath;
574
+ yield* emitOutput("ringi review export", {
575
+ data: {
576
+ markdown,
577
+ outputPath: outputPath ?? null,
578
+ reviewId
579
+ },
569
580
  human: shouldPrintMarkdown ? markdown : `Exported review ${reviewId} to ${outputPath}.`,
570
- nextActions
571
- };
572
- });
573
- const runSourceList = Effect.fn("CLI.sourceList")(function* runSourceList() {
574
- const gitService = yield* GitService;
575
- const repo = yield* gitService.getRepositoryInfo;
576
- const stagedFiles = yield* gitService.getStagedFiles;
577
- const data = {
578
- branches: yield* gitService.getBranches,
579
- commits: (yield* gitService.getCommits({
580
- limit: 10,
581
- offset: 0
582
- })).commits,
583
- repo,
584
- stagedFiles
585
- };
586
- return {
587
- data,
588
- human: renderSourceList(data),
589
- nextActions: [
590
- {
591
- command: "ringi source diff <source> [--stat]",
592
- description: "View diff for a source",
593
- params: { source: { enum: [
594
- "staged",
595
- "branch",
596
- "commits"
597
- ] } }
598
- },
599
- {
600
- command: "ringi review create [--source <source>]",
601
- description: "Create a review from a source",
602
- params: { source: {
603
- default: "staged",
604
- enum: [
605
- "staged",
606
- "branch",
607
- "commits"
608
- ]
609
- } }
610
- },
611
- {
612
- command: "ringi review list",
613
- description: "List existing reviews"
614
- }
615
- ]
616
- };
617
- });
618
- const runSourceDiff = Effect.fn("CLI.sourceDiff")(function* runSourceDiff(command) {
619
- const gitService = yield* GitService;
620
- const strategy = diffSourceStrategies[command.source];
621
- if (!strategy) return yield* new CliFailure({
622
- exitCode: ExitCode.UsageError,
623
- message: "Unsupported review source."
624
- });
625
- const diffText = yield* strategy(gitService, command);
626
- if (!diffText.trim()) yield* new CliFailure({
627
- exitCode: ExitCode.RuntimeFailure,
628
- message: "No diff available for the requested source."
581
+ nextActions: [{
582
+ command: `ringi review show ${reviewId}`,
583
+ description: "View the exported review"
584
+ }, {
585
+ command: "ringi review list",
586
+ description: "Back to review list"
587
+ }]
629
588
  });
630
- const files = parseDiff(diffText);
631
- const data = {
632
- diff: diffText,
633
- source: command.source,
634
- summary: getDiffSummary(files)
635
- };
636
- const nextActions = [{
637
- command: `ringi review create --source ${command.source}`,
638
- description: `Create a review from this ${command.source} diff`
639
- }, {
640
- command: "ringi source list",
641
- description: "List repository sources"
642
- }];
643
- return {
644
- data,
645
- human: command.stat ? [
646
- `Source: ${command.source}`,
647
- `Files: ${data.summary.totalFiles}`,
648
- `Additions: ${data.summary.totalAdditions}`,
649
- `Deletions: ${data.summary.totalDeletions}`
650
- ].join("\n") : diffText,
651
- nextActions
652
- };
653
- });
654
- const runReviewStatus = Effect.fn("CLI.reviewStatus")(function* runReviewStatus(command) {
589
+ })).pipe(Command.withDescription("Export review as markdown"));
590
+ const reviewCreate = Command.make("create", {
591
+ source: Flag.choice("source", [
592
+ "staged",
593
+ "branch",
594
+ "commits",
595
+ "pull_request"
596
+ ]).pipe(Flag.withDefault("staged"), Flag.withDescription("Review source type")),
597
+ branch: Flag.string("branch").pipe(Flag.withDescription("Branch name for branch source"), Flag.optional),
598
+ commits: Flag.string("commits").pipe(Flag.withDescription("Commit range for commits source"), Flag.optional),
599
+ title: Flag.string("title").pipe(Flag.withDescription("Review title"), Flag.optional)
600
+ }, (_config) => requireServerMode("ringi review create")).pipe(Command.withDescription("Create a review session"));
601
+ const reviewResolve = Command.make("resolve", {
602
+ id: Argument.string("id"),
603
+ allComments: Flag.boolean("all-comments").pipe(Flag.withDefault(true), Flag.withDescription("Resolve all comments")),
604
+ yes: Flag.boolean("yes").pipe(Flag.withDefault(false), Flag.withDescription("Skip confirmation prompt"))
605
+ }, (_config) => requireServerMode("ringi review resolve")).pipe(Command.withDescription("Resolve a review session"));
606
+ const reviewStatus = Command.make("status", {
607
+ reviewId: Flag.string("review").pipe(Flag.withDescription("Review ID or 'last'"), Flag.optional),
608
+ source: Flag.choice("source", [
609
+ "staged",
610
+ "branch",
611
+ "commits",
612
+ "pull_request"
613
+ ]).pipe(Flag.withDescription("Filter by source type"), Flag.optional)
614
+ }, (config) => Effect.gen(function* () {
615
+ yield* ensureDatabaseExists;
655
616
  const reviewService = yield* ReviewService;
656
617
  const todoService = yield* TodoService;
657
618
  const commentService = yield* CommentService;
@@ -660,12 +621,12 @@ const runReviewStatus = Effect.fn("CLI.reviewStatus")(function* runReviewStatus(
660
621
  const repo = yield* gitService.getRepositoryInfo;
661
622
  const stagedFiles = yield* gitService.getStagedFiles;
662
623
  let reviewId;
663
- if (command.reviewId) reviewId = yield* resolveReviewSelector(command.reviewId);
624
+ if (Option.isSome(config.reviewId)) reviewId = yield* resolveReviewSelector(config.reviewId.value);
664
625
  const reviews = yield* reviewService.list({
665
626
  page: 1,
666
627
  pageSize: 1,
667
628
  repositoryPath: cliConfig.repoRoot,
668
- sourceType: command.source
629
+ sourceType: Option.getOrUndefined(config.source)
669
630
  });
670
631
  const latestReview = reviewId ? yield* reviewService.getById(reviewId) : reviews.reviews[0];
671
632
  let commentStats;
@@ -709,64 +670,32 @@ const runReviewStatus = Effect.fn("CLI.reviewStatus")(function* runReviewStatus(
709
670
  description: "Export the latest review"
710
671
  });
711
672
  nextActions.push({
712
- command: "ringi review create [--source <source>]",
713
- description: "Create a new review session",
714
- params: { source: {
715
- default: "staged",
716
- enum: [
717
- "staged",
718
- "branch",
719
- "commits"
720
- ]
721
- } }
673
+ command: "ringi review create --source <source>",
674
+ description: "Create a new review session"
722
675
  });
723
- return {
676
+ yield* emitOutput("ringi review status", {
724
677
  data,
725
678
  human: lines.join("\n"),
726
679
  nextActions
727
- };
728
- });
729
- const runTodoList = Effect.fn("CLI.todoList")(function* runTodoList(command) {
730
- const result = yield* (yield* TodoService).list({
731
- completed: command.status === "all" ? void 0 : command.status === "done",
732
- limit: command.limit,
733
- offset: command.offset,
734
- reviewId: command.reviewId
735
- });
736
- const nextActions = [];
737
- if (command.reviewId) nextActions.push({
738
- command: `ringi review show ${command.reviewId}`,
739
- description: "View the associated review"
740
680
  });
741
- nextActions.push({
742
- command: "ringi todo add --text <text> [--review <review-id>]",
743
- description: "Add a new todo",
744
- params: { text: {
745
- description: "Todo text",
746
- required: true
747
- } }
748
- }, {
749
- command: "ringi review list",
750
- description: "List reviews"
751
- });
752
- return {
753
- data: result,
754
- human: renderTodoList(result.data),
755
- nextActions
756
- };
757
- });
758
- const runReviewPr = Effect.fn("CLI.reviewPr")(function* runReviewPr(command) {
759
- const target = yield* parsePrUrl(command.prUrl).pipe(Effect.mapError((e) => new CliFailure({
681
+ })).pipe(Command.withDescription("Show repository and review status"));
682
+ const reviewPr = Command.make("pr", {
683
+ prUrl: Argument.string("pr-url"),
684
+ port: Flag.integer("port").pipe(Flag.withDefault(3e3), Flag.withDescription("Local server port")),
685
+ noOpen: Flag.boolean("no-open").pipe(Flag.withDefault(false), Flag.withDescription("Skip opening the browser")),
686
+ forceRefresh: Flag.boolean("force-refresh").pipe(Flag.withDefault(false), Flag.withDescription("Re-fetch PR data with latest changes"))
687
+ }, (config) => Effect.gen(function* () {
688
+ const target = yield* parsePrUrl(config.prUrl).pipe(Effect.mapError((e) => new CliFailure({
760
689
  exitCode: ExitCode.UsageError,
761
690
  message: e.message
762
691
  })));
763
692
  const preflight = yield* runPreflight(target).pipe(Effect.mapError((e) => new CliFailure({
764
- exitCode: e.exitCode,
693
+ exitCode: e.exitCode ?? ExitCode.RuntimeFailure,
765
694
  message: e.message
766
695
  })));
767
696
  if (preflight.affinityWarning) yield* Effect.logWarning(preflight.affinityWarning);
768
697
  let session;
769
- if (command.forceRefresh) {
698
+ if (config.forceRefresh) {
770
699
  const reviewService = yield* ReviewService;
771
700
  const sourceRef = prSourceRef(target);
772
701
  const cliConfig = yield* CliConfig;
@@ -795,17 +724,9 @@ const runReviewPr = Effect.fn("CLI.reviewPr")(function* runReviewPr(command) {
795
724
  message: e.message
796
725
  })));
797
726
  if (session.staleWarning) yield* Effect.logWarning(session.staleWarning);
798
- const serverUrl = `http://localhost:${command.port}`;
727
+ const serverUrl = `http://localhost:${config.port}`;
799
728
  const reviewUrl = `${serverUrl}/review/${session.reviewId}`;
800
- const data = {
801
- isResumed: session.isResumed,
802
- isStale: session.isStale,
803
- prNumber: target.prNumber,
804
- prUrl: target.url,
805
- reviewId: session.reviewId,
806
- reviewUrl
807
- };
808
- const statusLabel = session.isResumed ? command.forceRefresh ? "(refreshed)" : "(resumed)" : "(new)";
729
+ const statusLabel = session.isResumed ? config.forceRefresh ? "(refreshed)" : "(resumed)" : "(new)";
809
730
  const humanLines = [
810
731
  `PR #${target.prNumber}: ${preflight.metadata.title}`,
811
732
  `Review: ${session.reviewId} ${statusLabel}`,
@@ -816,8 +737,14 @@ const runReviewPr = Effect.fn("CLI.reviewPr")(function* runReviewPr(command) {
816
737
  `Server: ${serverUrl}`,
817
738
  `Review: ${reviewUrl}`
818
739
  ];
819
- if (preflight.metadata.isDraft) humanLines.splice(1, 0, "⚠ Draft PR");
820
- if (preflight.metadata.state === "CLOSED" || preflight.metadata.state === "MERGED") humanLines.splice(1, 0, `⚠ This PR is ${preflight.metadata.state}`);
740
+ const data = {
741
+ isResumed: session.isResumed,
742
+ isStale: session.isStale,
743
+ prNumber: target.prNumber,
744
+ prUrl: target.url,
745
+ reviewId: session.reviewId,
746
+ reviewUrl
747
+ };
821
748
  const nextActions = [{
822
749
  command: `ringi review show ${session.reviewId} --comments --todos`,
823
750
  description: "Inspect review details"
@@ -825,1181 +752,186 @@ const runReviewPr = Effect.fn("CLI.reviewPr")(function* runReviewPr(command) {
825
752
  command: `ringi review export ${session.reviewId}`,
826
753
  description: "Export review as markdown"
827
754
  }];
828
- if (session.isStale) nextActions.unshift({
829
- command: `ringi review ${command.prUrl} --force-refresh`,
830
- description: "Re-fetch PR data with latest changes"
831
- });
832
- return {
755
+ yield* emitOutput("ringi review pr", {
833
756
  data,
834
757
  human: humanLines.join("\n"),
835
758
  nextActions
836
- };
837
- });
838
- /**
839
- * Data-driven command registry. Each command kind maps to its handler.
840
- * Adding a new command means adding one entry — no switch duplication.
841
- */
842
- const COMMAND_HANDLERS = {
843
- "data-migrate": () => requireServerMode("ringi data migrate"),
844
- "data-reset": () => requireServerMode("ringi data reset"),
845
- doctor: () => Effect.succeed({
846
- data: {
847
- checks: [],
848
- ok: true
849
- },
850
- human: "ringi doctor: not yet implemented.",
851
- nextActions: []
852
- }),
853
- events: () => requireServerMode("ringi events"),
854
- mcp: () => Effect.fail(new CliFailure({
855
- exitCode: ExitCode.UsageError,
856
- message: "ringi mcp is a runtime command. Use it directly, not through the command dispatcher."
857
- })),
858
- "review-create": () => requireServerMode("ringi review create"),
859
- "review-export": (c) => runReviewExport(c),
860
- "review-list": (c) => runReviewList(c),
861
- "review-pr": (c) => runReviewPr(c),
862
- "review-resolve": () => requireServerMode("ringi review resolve"),
863
- "review-show": (c) => runReviewShow(c),
864
- "review-status": (c) => runReviewStatus(c),
865
- serve: () => Effect.fail(new CliFailure({
866
- exitCode: ExitCode.UsageError,
867
- message: "ringi serve is a runtime command. Use it directly, not through the command dispatcher."
868
- })),
869
- "source-diff": (c) => runSourceDiff(c),
870
- "source-list": () => runSourceList(),
871
- "todo-add": () => requireServerMode("ringi todo add"),
872
- "todo-clear": () => requireServerMode("ringi todo clear"),
873
- "todo-done": () => requireServerMode("ringi todo done"),
874
- "todo-list": (c) => runTodoList(c),
875
- "todo-move": () => requireServerMode("ringi todo move"),
876
- "todo-remove": () => requireServerMode("ringi todo remove"),
877
- "todo-undone": () => requireServerMode("ringi todo undone")
878
- };
879
- /** Human-readable command label for the JSON envelope `command` field. */
880
- const COMMAND_LABELS = {
881
- "data-migrate": "ringi data migrate",
882
- "data-reset": "ringi data reset",
883
- doctor: "ringi doctor",
884
- events: "ringi events",
885
- mcp: "ringi mcp",
886
- "review-create": "ringi review create",
887
- "review-export": "ringi review export",
888
- "review-list": "ringi review list",
889
- "review-pr": "ringi review <pr-url>",
890
- "review-resolve": "ringi review resolve",
891
- "review-show": "ringi review show",
892
- "review-status": "ringi review status",
893
- serve: "ringi serve",
894
- "source-diff": "ringi source diff",
895
- "source-list": "ringi source list",
896
- "todo-add": "ringi todo add",
897
- "todo-clear": "ringi todo clear",
898
- "todo-done": "ringi todo done",
899
- "todo-list": "ringi todo list",
900
- "todo-move": "ringi todo move",
901
- "todo-remove": "ringi todo remove",
902
- "todo-undone": "ringi todo undone"
903
- };
904
- const commandLabel = (command) => COMMAND_LABELS[command.kind] ?? `ringi ${command.kind}`;
905
- const runCommand = (command) => {
906
- const handler = COMMAND_HANDLERS[command.kind];
907
- if (!handler) return Effect.fail(new CliFailure({
908
- exitCode: ExitCode.UsageError,
909
- message: `No executable handler exists for ${command.kind}.`
910
- }));
911
- return handler(command);
912
- };
913
- //#endregion
914
- //#region src/cli/parser.ts
915
- const REVIEW_SOURCES = new Set([
916
- "branch",
917
- "commits",
918
- "pull_request",
919
- "staged"
920
- ]);
921
- const REVIEW_STATUSES = new Set([
922
- "approved",
923
- "changes_requested",
924
- "in_progress"
925
- ]);
926
- const TODO_STATUSES = new Set([
927
- "all",
928
- "done",
929
- "pending"
930
- ]);
931
- const usageError = (message) => new CliFailure({
932
- exitCode: ExitCode.UsageError,
933
- message
934
- });
935
- /**
936
- * Consumes the next token as a flag value, advancing the cursor by 2.
937
- * Rejects another flag in the value slot so typos fail fast.
938
- */
939
- const requireValue = (state, flag) => {
940
- const value = state.tokens[state.index + 1];
941
- if (!value || value.startsWith("-")) return Option.some(usageError(`Missing value for ${flag}.`));
942
- state.index += 2;
943
- return Option.none();
944
- };
945
- /** Peek at the value that {@link requireValue} would consume. */
946
- const peekValue = (state) => state.tokens[state.index + 1] ?? "";
947
- const decodePositiveInt = (raw, flag) => {
948
- const value = Number.parseInt(raw, 10);
949
- if (!Number.isInteger(value) || value < 0) return Result.fail(usageError(`${flag} must be a non-negative integer.`));
950
- return Result.succeed(value);
951
- };
952
- const decodeEnum = (raw, valid, label) => {
953
- if (!valid.has(raw)) return Result.fail(usageError(`Invalid ${label}: ${raw}.`));
954
- return Result.succeed(raw);
955
- };
956
- /** Boolean flag: sets a key to `true`, advances cursor by 1. */
957
- const boolFlag = (key) => (state, acc) => {
958
- acc[key] = true;
959
- state.index += 1;
960
- return Option.none();
961
- };
962
- /** String flag: consumes next token, assigns to key. */
963
- const stringFlag = (key) => (state, acc) => {
964
- const flag = state.tokens[state.index] ?? "";
965
- const value = peekValue(state);
966
- const error = requireValue(state, flag);
967
- if (Option.isSome(error)) return error;
968
- acc[key] = value;
969
- return Option.none();
970
- };
971
- /** Positive integer flag with an optional minimum (exclusive). */
972
- const positiveIntFlag = (key, opts) => (state, acc) => {
973
- const flag = state.tokens[state.index] ?? "";
974
- const raw = peekValue(state);
975
- const error = requireValue(state, flag);
976
- if (Option.isSome(error)) return error;
977
- const decoded = decodePositiveInt(raw, flag);
978
- if (Result.isFailure(decoded)) return Option.some(decoded.failure);
979
- if (opts?.min !== void 0 && decoded.success <= opts.min) return Option.some(usageError(`${flag} must be greater than ${opts.min}.`));
980
- acc[key] = decoded.success;
981
- return Option.none();
982
- };
983
- /** Enum flag: consumes next token, validates membership, assigns to key. */
984
- const enumFlag = (key, valid, label) => (state, acc) => {
985
- const flag = state.tokens[state.index] ?? "";
986
- const raw = peekValue(state);
987
- const error = requireValue(state, flag);
988
- if (Option.isSome(error)) return error;
989
- const decoded = decodeEnum(raw, valid, label);
990
- if (Result.isFailure(decoded)) return Option.some(decoded.failure);
991
- acc[key] = decoded.success;
992
- return Option.none();
993
- };
994
- const createDefaultOptions = () => ({
995
- color: true,
996
- dbPath: void 0,
997
- help: false,
998
- json: false,
999
- quiet: false,
1000
- repo: void 0,
1001
- verbose: false,
1002
- version: false
1003
- });
1004
- /**
1005
- * Global flags are accepted before or after subcommands because wrappers often
1006
- * prepend them without preserving the CLI's preferred ordering.
1007
- */
1008
- const GLOBAL_FLAG_HANDLERS = {
1009
- "--db-path": stringFlag("dbPath"),
1010
- "--help": boolFlag("help"),
1011
- "--json": boolFlag("json"),
1012
- "--no-color": (state, acc) => {
1013
- acc.color = false;
1014
- state.index += 1;
1015
- return Option.none();
1016
- },
1017
- "--quiet": boolFlag("quiet"),
1018
- "--repo": stringFlag("repo"),
1019
- "--verbose": boolFlag("verbose"),
1020
- "--version": boolFlag("version")
1021
- };
1022
- const maybeParseGlobalFlag = (state) => {
1023
- const token = state.tokens[state.index];
1024
- if (!token) return false;
1025
- const handler = GLOBAL_FLAG_HANDLERS[token];
1026
- if (!handler) return false;
1027
- handler(state, state.options);
1028
- return true;
1029
- };
1030
- /**
1031
- * Consumes all remaining tokens in {@link state} by dispatching to the matching
1032
- * handler in {@link handlers}. Global flags are tried first. Unknown flags
1033
- * produce a usage error naming the {@link commandLabel}.
1034
- */
1035
- const runFlagLoop = (state, acc, handlers, commandLabel) => {
1036
- while (state.index < state.tokens.length) {
1037
- if (maybeParseGlobalFlag(state)) continue;
1038
- const token = state.tokens[state.index] ?? "";
1039
- const handler = handlers[token];
1040
- if (!handler) return Option.some(usageError(`Unknown flag for ${commandLabel}: ${token}.`));
1041
- const error = handler(state, acc);
1042
- if (Option.isSome(error)) return error;
1043
- }
1044
- return Option.none();
1045
- };
1046
- const REVIEW_LIST_FLAGS = {
1047
- "--limit": positiveIntFlag("limit", { min: 0 }),
1048
- "--page": positiveIntFlag("page", { min: 0 }),
1049
- "--source": enumFlag("source", REVIEW_SOURCES, "review source"),
1050
- "--status": enumFlag("status", REVIEW_STATUSES, "review status")
1051
- };
1052
- const parseReviewList = (state) => {
1053
- const acc = {
1054
- limit: 20,
1055
- page: 1,
1056
- source: void 0,
1057
- status: void 0
1058
- };
1059
- const error = runFlagLoop(state, acc, REVIEW_LIST_FLAGS, "review list");
1060
- if (Option.isSome(error)) return Result.fail(error.value);
1061
- return Result.succeed({
1062
- kind: "review-list",
1063
- ...acc
1064
- });
1065
- };
1066
- const REVIEW_SHOW_FLAGS = {
1067
- "--comments": boolFlag("comments"),
1068
- "--todos": boolFlag("todos")
1069
- };
1070
- const parseReviewShow = (state) => {
1071
- const id = state.tokens[state.index];
1072
- if (!id) return Result.fail(usageError("review show requires <id|last>."));
1073
- state.index += 1;
1074
- const acc = {
1075
- comments: false,
1076
- todos: false
1077
- };
1078
- const error = runFlagLoop(state, acc, REVIEW_SHOW_FLAGS, "review show");
1079
- if (Option.isSome(error)) return Result.fail(error.value);
1080
- return Result.succeed({
1081
- id,
1082
- kind: "review-show",
1083
- ...acc
1084
- });
1085
- };
1086
- const REVIEW_EXPORT_FLAGS = {
1087
- "--no-resolved": boolFlag("noResolved"),
1088
- "--no-snippets": boolFlag("noSnippets"),
1089
- "--output": stringFlag("outputPath"),
1090
- "--stdout": boolFlag("stdout")
1091
- };
1092
- const parseReviewExport = (state) => {
1093
- const id = state.tokens[state.index];
1094
- if (!id) return Result.fail(usageError("review export requires <id|last>."));
1095
- state.index += 1;
1096
- const acc = {
1097
- noResolved: false,
1098
- noSnippets: false,
1099
- outputPath: void 0,
1100
- stdout: false
1101
- };
1102
- const error = runFlagLoop(state, acc, REVIEW_EXPORT_FLAGS, "review export");
1103
- if (Option.isSome(error)) return Result.fail(error.value);
1104
- return Result.succeed({
1105
- id,
1106
- kind: "review-export",
1107
- ...acc
1108
- });
1109
- };
1110
- const REVIEW_CREATE_FLAGS = {
1111
- "--branch": stringFlag("branch"),
1112
- "--commits": stringFlag("commits"),
1113
- "--source": enumFlag("source", REVIEW_SOURCES, "review source"),
1114
- "--title": stringFlag("title")
1115
- };
1116
- const validateReviewCreate = (acc) => {
1117
- if (acc.source === "branch" && !acc.branch) return Option.some(usageError("review create --source branch requires --branch."));
1118
- if (acc.source === "commits" && !acc.commits) return Option.some(usageError("review create --source commits requires --commits."));
1119
- if (acc.source === "staged" && (acc.branch || acc.commits)) return Option.some(usageError("review create --source staged does not accept --branch or --commits."));
1120
- return Option.none();
1121
- };
1122
- const parseReviewCreate = (state) => {
1123
- const acc = {
1124
- branch: void 0,
1125
- commits: void 0,
1126
- source: "staged",
1127
- title: void 0
1128
- };
1129
- const error = runFlagLoop(state, acc, REVIEW_CREATE_FLAGS, "review create");
1130
- if (Option.isSome(error)) return Result.fail(error.value);
1131
- const validationError = validateReviewCreate(acc);
1132
- if (Option.isSome(validationError)) return Result.fail(validationError.value);
1133
- return Result.succeed({
1134
- kind: "review-create",
1135
- ...acc
1136
- });
1137
- };
1138
- const SOURCE_DIFF_FLAGS = {
1139
- "--branch": stringFlag("branch"),
1140
- "--commits": stringFlag("commits"),
1141
- "--stat": boolFlag("stat")
1142
- };
1143
- const validateSourceDiff = (source, acc) => {
1144
- if (source === "branch" && !acc.branch) return Option.some(usageError("source diff branch requires --branch."));
1145
- if (source === "commits" && !acc.commits) return Option.some(usageError("source diff commits requires --commits."));
1146
- return Option.none();
1147
- };
1148
- const parseSourceDiff = (state) => {
1149
- const source = state.tokens[state.index];
1150
- if (!source || !REVIEW_SOURCES.has(source)) return Result.fail(usageError("source diff requires <staged|branch|commits>."));
1151
- state.index += 1;
1152
- const acc = {
1153
- branch: void 0,
1154
- commits: void 0,
1155
- stat: false
1156
- };
1157
- const error = runFlagLoop(state, acc, SOURCE_DIFF_FLAGS, "source diff");
1158
- if (Option.isSome(error)) return Result.fail(error.value);
1159
- const validationError = validateSourceDiff(source, acc);
1160
- if (Option.isSome(validationError)) return Result.fail(validationError.value);
1161
- return Result.succeed({
1162
- kind: "source-diff",
1163
- source,
1164
- ...acc
1165
- });
1166
- };
1167
- const TODO_LIST_FLAGS = {
1168
- "--limit": positiveIntFlag("limit"),
1169
- "--offset": positiveIntFlag("offset"),
1170
- "--review": stringFlag("reviewId"),
1171
- "--status": enumFlag("status", TODO_STATUSES, "todo status")
1172
- };
1173
- const parseTodoList = (state) => {
1174
- const acc = {
1175
- limit: void 0,
1176
- offset: 0,
1177
- reviewId: void 0,
1178
- status: "pending"
1179
- };
1180
- const error = runFlagLoop(state, acc, TODO_LIST_FLAGS, "todo list");
1181
- if (Option.isSome(error)) return Result.fail(error.value);
1182
- return Result.succeed({
1183
- kind: "todo-list",
1184
- ...acc
1185
759
  });
1186
- };
1187
- /**
1188
- * Factory for commands that take exactly one positional `<id>` and no
1189
- * command-specific flags (only globals). Avoids duplication for done/undone.
1190
- */
1191
- const positionalIdParser = (kind, label) => (state) => {
1192
- const id = state.tokens[state.index];
1193
- if (!id) return Result.fail(usageError(`${label} requires <id>.`));
1194
- state.index += 1;
1195
- while (state.index < state.tokens.length) if (!maybeParseGlobalFlag(state)) return Result.fail(usageError(`Unknown flag for ${label}: ${state.tokens[state.index]}.`));
1196
- return Result.succeed({
1197
- id,
1198
- kind
1199
- });
1200
- };
1201
- const parseTodoDone = positionalIdParser("todo-done", "todo done");
1202
- const parseTodoUndone = positionalIdParser("todo-undone", "todo undone");
1203
- const TODO_MOVE_FLAGS = { "--position": positiveIntFlag("position") };
1204
- const parseTodoMove = (state) => {
1205
- const id = state.tokens[state.index];
1206
- if (!id) return Result.fail(usageError("todo move requires <id>."));
1207
- state.index += 1;
1208
- const acc = { position: void 0 };
1209
- const error = runFlagLoop(state, acc, TODO_MOVE_FLAGS, "todo move");
1210
- if (Option.isSome(error)) return Result.fail(error.value);
1211
- if (acc.position === void 0) return Result.fail(usageError("todo move requires --position."));
1212
- return Result.succeed({
1213
- id,
1214
- kind: "todo-move",
1215
- position: acc.position
1216
- });
1217
- };
1218
- const TODO_REMOVE_FLAGS = { "--yes": boolFlag("yes") };
1219
- const parseTodoRemove = (state) => {
1220
- const id = state.tokens[state.index];
1221
- if (!id) return Result.fail(usageError("todo remove requires <id>."));
1222
- state.index += 1;
1223
- const acc = { yes: false };
1224
- const error = runFlagLoop(state, acc, TODO_REMOVE_FLAGS, "todo remove");
1225
- if (Option.isSome(error)) return Result.fail(error.value);
1226
- return Result.succeed({
1227
- id,
1228
- kind: "todo-remove",
1229
- ...acc
1230
- });
1231
- };
1232
- const TODO_CLEAR_FLAGS = {
1233
- "--all": boolFlag("all"),
1234
- "--done-only": boolFlag("doneOnly"),
1235
- "--review": stringFlag("reviewId"),
1236
- "--yes": boolFlag("yes")
1237
- };
1238
- const parseTodoClear = (state) => {
1239
- const acc = {
1240
- all: false,
1241
- doneOnly: true,
1242
- reviewId: void 0,
1243
- yes: false
1244
- };
1245
- const error = runFlagLoop(state, acc, TODO_CLEAR_FLAGS, "todo clear");
1246
- if (Option.isSome(error)) return Result.fail(error.value);
1247
- return Result.succeed({
1248
- kind: "todo-clear",
1249
- ...acc
1250
- });
1251
- };
1252
- const REVIEW_STATUS_FLAGS = {
1253
- "--review": stringFlag("reviewId"),
1254
- "--source": enumFlag("source", REVIEW_SOURCES, "review source")
1255
- };
1256
- const parseReviewStatus = (state) => {
1257
- const acc = {
1258
- reviewId: void 0,
1259
- source: void 0
1260
- };
1261
- const error = runFlagLoop(state, acc, REVIEW_STATUS_FLAGS, "review status");
1262
- if (Option.isSome(error)) return Result.fail(error.value);
1263
- return Result.succeed({
1264
- kind: "review-status",
1265
- ...acc
1266
- });
1267
- };
1268
- const REVIEW_RESOLVE_FLAGS = {
1269
- "--all-comments": boolFlag("allComments"),
1270
- "--yes": boolFlag("yes")
1271
- };
1272
- const parseReviewResolve = (state) => {
1273
- const id = state.tokens[state.index];
1274
- if (!id) return Result.fail(usageError("review resolve requires <id|last>."));
1275
- state.index += 1;
1276
- const acc = {
1277
- allComments: true,
1278
- yes: false
1279
- };
1280
- const error = runFlagLoop(state, acc, REVIEW_RESOLVE_FLAGS, "review resolve");
1281
- if (Option.isSome(error)) return Result.fail(error.value);
1282
- return Result.succeed({
1283
- id,
1284
- kind: "review-resolve",
1285
- ...acc
1286
- });
1287
- };
1288
- const TODO_ADD_FLAGS = {
1289
- "--position": positiveIntFlag("position"),
1290
- "--review": stringFlag("reviewId"),
1291
- "--text": stringFlag("text")
1292
- };
1293
- const parseTodoAdd = (state) => {
1294
- const acc = {
1295
- position: void 0,
1296
- reviewId: void 0,
1297
- text: ""
1298
- };
1299
- const error = runFlagLoop(state, acc, TODO_ADD_FLAGS, "todo add");
1300
- if (Option.isSome(error)) return Result.fail(error.value);
1301
- if (!acc.text.trim()) return Result.fail(usageError("todo add requires --text."));
1302
- return Result.succeed({
1303
- kind: "todo-add",
1304
- ...acc
1305
- });
1306
- };
1307
- const SERVE_FLAGS = {
1308
- "--auth": boolFlag("auth"),
1309
- "--cert": stringFlag("cert"),
1310
- "--host": stringFlag("host"),
1311
- "--https": boolFlag("https"),
1312
- "--key": stringFlag("key"),
1313
- "--no-open": boolFlag("noOpen"),
1314
- "--password": stringFlag("password"),
1315
- "--port": positiveIntFlag("port", { min: 0 }),
1316
- "--username": stringFlag("username")
1317
- };
1318
- const parseServe = (state) => {
1319
- const acc = {
1320
- auth: false,
1321
- cert: void 0,
1322
- host: "127.0.0.1",
1323
- https: false,
1324
- key: void 0,
1325
- noOpen: false,
1326
- password: void 0,
1327
- port: 3e3,
1328
- username: void 0
1329
- };
1330
- const error = runFlagLoop(state, acc, SERVE_FLAGS, "serve");
1331
- if (Option.isSome(error)) return Result.fail(error.value);
1332
- return Result.succeed({
1333
- kind: "serve",
1334
- ...acc
1335
- });
1336
- };
1337
- const MCP_LOG_LEVELS = new Set([
1338
- "debug",
1339
- "error",
1340
- "info",
1341
- "silent"
1342
- ]);
1343
- const MCP_FLAGS = {
1344
- "--log-level": enumFlag("logLevel", MCP_LOG_LEVELS, "log level"),
1345
- "--readonly": boolFlag("readonly")
1346
- };
1347
- const parseMcp = (state) => {
1348
- const acc = {
1349
- logLevel: "error",
1350
- readonly: false
1351
- };
1352
- const error = runFlagLoop(state, acc, MCP_FLAGS, "mcp");
1353
- if (Option.isSome(error)) return Result.fail(error.value);
1354
- return Result.succeed({
1355
- kind: "mcp",
1356
- ...acc
1357
- });
1358
- };
1359
- const EVENT_TYPES = new Set([
1360
- "comments",
1361
- "files",
1362
- "reviews",
1363
- "todos"
1364
- ]);
1365
- const EVENTS_FLAGS = {
1366
- "--since": positiveIntFlag("since"),
1367
- "--type": enumFlag("type", EVENT_TYPES, "event type")
1368
- };
1369
- const parseEvents = (state) => {
1370
- const acc = {
1371
- since: void 0,
1372
- type: void 0
1373
- };
1374
- const error = runFlagLoop(state, acc, EVENTS_FLAGS, "events");
1375
- if (Option.isSome(error)) return Result.fail(error.value);
1376
- return Result.succeed({
1377
- kind: "events",
1378
- ...acc
1379
- });
1380
- };
1381
- const DATA_RESET_FLAGS = {
1382
- "--keep-exports": boolFlag("keepExports"),
1383
- "--yes": boolFlag("yes")
1384
- };
1385
- const parseDataReset = (state) => {
1386
- const acc = {
1387
- keepExports: false,
1388
- yes: false
1389
- };
1390
- const error = runFlagLoop(state, acc, DATA_RESET_FLAGS, "data reset");
1391
- if (Option.isSome(error)) return Result.fail(error.value);
1392
- return Result.succeed({
1393
- kind: "data-reset",
1394
- ...acc
1395
- });
1396
- };
1397
- const ensureNoExtraArgs = (state, label) => {
1398
- if (state.index < state.tokens.length) return Option.some(usageError(`Unexpected argument for ${label}: ${state.tokens[state.index]}.`));
1399
- return Option.none();
1400
- };
1401
- /** Review verb parsers keyed by verb name. */
1402
- const REVIEW_VERB_PARSERS = {
1403
- create: parseReviewCreate,
1404
- export: parseReviewExport,
1405
- list: parseReviewList,
1406
- resolve: parseReviewResolve,
1407
- show: parseReviewShow,
1408
- status: parseReviewStatus
1409
- };
1410
- /** Todo verb parsers keyed by verb name. */
1411
- const TODO_VERB_PARSERS = {
1412
- add: parseTodoAdd,
1413
- clear: parseTodoClear,
1414
- done: parseTodoDone,
1415
- list: parseTodoList,
1416
- move: parseTodoMove,
1417
- remove: parseTodoRemove,
1418
- undone: parseTodoUndone
1419
- };
1420
- const REVIEW_PR_FLAGS = {
1421
- "--force-refresh": boolFlag("forceRefresh"),
1422
- "--no-open": boolFlag("noOpen"),
1423
- "--port": positiveIntFlag("port", { min: 0 })
1424
- };
1425
- const parseReviewPr = (state, prUrl) => {
1426
- const acc = {
1427
- forceRefresh: false,
1428
- noOpen: false,
1429
- port: 3e3
760
+ })).pipe(Command.withDescription("Create or resume a PR review"));
761
+ const review = Command.make("review").pipe(Command.withDescription("Review management commands"), Command.withSubcommands([
762
+ reviewList,
763
+ reviewShow,
764
+ reviewExport,
765
+ reviewCreate,
766
+ reviewResolve,
767
+ reviewStatus,
768
+ reviewPr
769
+ ]));
770
+ const sourceList = Command.make("list", {}, () => Effect.gen(function* () {
771
+ const gitService = yield* GitService;
772
+ const repo = yield* gitService.getRepositoryInfo;
773
+ const stagedFiles = yield* gitService.getStagedFiles;
774
+ const data = {
775
+ branches: yield* gitService.getBranches,
776
+ commits: (yield* gitService.getCommits({
777
+ limit: 10,
778
+ offset: 0
779
+ })).commits,
780
+ repo,
781
+ stagedFiles
1430
782
  };
1431
- const error = runFlagLoop(state, acc, REVIEW_PR_FLAGS, "review <pr-url>");
1432
- if (Option.isSome(error)) return Result.fail(error.value);
1433
- return Result.succeed({
1434
- kind: "review-pr",
1435
- prUrl,
1436
- ...acc
1437
- });
1438
- };
1439
- /** Subcommand family parsers keyed by family name. */
1440
- /** Data verb parsers keyed by verb name. */
1441
- const DATA_VERB_PARSERS = {
1442
- migrate: (state) => {
1443
- const error = ensureNoExtraArgs(state, "data migrate");
1444
- if (Option.isSome(error)) return Result.fail(error.value);
1445
- return Result.succeed({ kind: "data-migrate" });
1446
- },
1447
- reset: parseDataReset
1448
- };
1449
- /** Subcommand family parsers keyed by family name. */
1450
- const FAMILY_PARSERS = {
1451
- data: (state) => {
1452
- const verb = state.tokens[state.index];
1453
- if (!verb) return Result.succeed({
1454
- kind: "help",
1455
- topic: ["data"]
1456
- });
1457
- state.index += 1;
1458
- const parser = DATA_VERB_PARSERS[verb];
1459
- if (!parser) return Result.fail(usageError(`Unknown data command: ${verb}.`));
1460
- return parser(state);
1461
- },
1462
- doctor: (state) => {
1463
- const error = ensureNoExtraArgs(state, "doctor");
1464
- if (Option.isSome(error)) return Result.fail(error.value);
1465
- return Result.succeed({ kind: "doctor" });
1466
- },
1467
- events: parseEvents,
1468
- export: parseReviewExport,
1469
- mcp: parseMcp,
1470
- review: (state) => {
1471
- const verb = state.tokens[state.index];
1472
- if (!verb) return Result.succeed({
1473
- kind: "help",
1474
- topic: ["review"]
1475
- });
1476
- if (looksLikePrUrl(verb)) {
1477
- state.index += 1;
1478
- return parseReviewPr(state, verb);
1479
- }
1480
- state.index += 1;
1481
- const parser = REVIEW_VERB_PARSERS[verb];
1482
- if (!parser) return Result.fail(usageError(`Unknown review command: ${verb}.`));
1483
- return parser(state);
1484
- },
1485
- serve: parseServe,
1486
- source: (state) => {
1487
- const verb = state.tokens[state.index];
1488
- if (!verb) return Result.succeed({
1489
- kind: "help",
1490
- topic: ["source"]
1491
- });
1492
- state.index += 1;
1493
- if (verb === "list") {
1494
- const error = ensureNoExtraArgs(state, "source list");
1495
- if (Option.isSome(error)) return Result.fail(error.value);
1496
- return Result.succeed({ kind: "source-list" });
1497
- }
1498
- if (verb === "diff") return parseSourceDiff(state);
1499
- return Result.fail(usageError(`Unknown source command: ${verb}.`));
1500
- },
1501
- todo: (state) => {
1502
- const verb = state.tokens[state.index];
1503
- if (!verb) return Result.succeed({
1504
- kind: "help",
1505
- topic: ["todo"]
1506
- });
1507
- state.index += 1;
1508
- const parser = TODO_VERB_PARSERS[verb];
1509
- if (!parser) return Result.fail(usageError(`Unknown todo command: ${verb}.`));
1510
- return parser(state);
783
+ const lines = [
784
+ `Repository: ${data.repo.name}`,
785
+ `Path: ${data.repo.path}`,
786
+ `Current branch: ${data.repo.branch}`,
787
+ `Staged files: ${data.stagedFiles.length}`
788
+ ];
789
+ if (data.stagedFiles.length > 0) {
790
+ lines.push("", "Staged:");
791
+ for (const file of data.stagedFiles) lines.push(`- ${file.status} ${file.path}`);
1511
792
  }
1512
- };
1513
- /**
1514
- * Strips any leading globals with {@link maybeParseGlobalFlag} before dispatching
1515
- * into a command family so `--help` and `--version` behave consistently.
1516
- */
1517
- const parseWithState = (state) => {
1518
- while (state.index < state.tokens.length && maybeParseGlobalFlag(state));
1519
- if (state.options.version) return Result.succeed({ kind: "version" });
1520
- if (state.index >= state.tokens.length) return Result.succeed({
1521
- kind: "help",
1522
- topic: []
1523
- });
1524
- const first = state.tokens[state.index];
1525
- if (!first) return Result.succeed({
1526
- kind: "help",
1527
- topic: []
1528
- });
1529
- if (first === "help") {
1530
- const topic = state.tokens.slice(state.index + 1);
1531
- state.index = state.tokens.length;
1532
- return Result.succeed({
1533
- kind: "help",
1534
- topic
1535
- });
793
+ if (data.branches.length > 0) {
794
+ lines.push("", "Branches:");
795
+ for (const branch of data.branches.slice(0, 10)) lines.push(`- ${branch.current ? "*" : " "} ${branch.name}`);
1536
796
  }
1537
- if (state.options.help) return Result.succeed({
1538
- kind: "help",
1539
- topic: state.tokens.slice(state.index)
797
+ if (data.commits.length > 0) {
798
+ lines.push("", "Recent commits:");
799
+ for (const commit of data.commits.slice(0, 5)) lines.push(`- ${commit.hash.slice(0, 8)} ${commit.message} (${commit.author})`);
800
+ }
801
+ yield* emitOutput("ringi source list", {
802
+ data,
803
+ human: lines.join("\n"),
804
+ nextActions: [
805
+ {
806
+ command: "ringi source diff <source> --stat",
807
+ description: "View diff for a source"
808
+ },
809
+ {
810
+ command: "ringi review create --source <source>",
811
+ description: "Create a review from a source"
812
+ },
813
+ {
814
+ command: "ringi review list",
815
+ description: "List existing reviews"
816
+ }
817
+ ]
1540
818
  });
1541
- state.index += 1;
1542
- const familyParser = FAMILY_PARSERS[first];
1543
- if (!familyParser) return Result.fail(usageError(`Unknown command: ${first}.`));
1544
- return familyParser(state);
819
+ })).pipe(Command.withDescription("List repository sources"));
820
+ const diffStrategies = {
821
+ branch: (git, branch) => git.getBranchDiff(branch ?? ""),
822
+ commits: (git, _branch, commits) => git.getCommitDiff((commits ?? "").split(",").map((i) => i.trim()).filter(Boolean)),
823
+ staged: (git) => git.getStagedDiff
1545
824
  };
1546
- const parseCliArgs = (argv) => {
1547
- const options = createDefaultOptions();
1548
- const result = parseWithState({
1549
- index: 0,
1550
- options,
1551
- tokens: argv
825
+ const sourceDiff = Command.make("diff", {
826
+ source: Argument.choice("source", [
827
+ "staged",
828
+ "branch",
829
+ "commits"
830
+ ]),
831
+ branch: Flag.string("branch").pipe(Flag.withDescription("Branch name"), Flag.optional),
832
+ commits: Flag.string("commits").pipe(Flag.withDescription("Commit range"), Flag.optional),
833
+ stat: Flag.boolean("stat").pipe(Flag.withDefault(false), Flag.withDescription("Show stats only"))
834
+ }, (config) => Effect.gen(function* () {
835
+ const gitService = yield* GitService;
836
+ const strategy = diffStrategies[config.source];
837
+ if (!strategy) yield* new CliFailure({
838
+ exitCode: ExitCode.UsageError,
839
+ message: "Unsupported review source."
1552
840
  });
1553
- if (Result.isFailure(result)) return Result.fail(result.failure);
1554
- return Result.succeed({
1555
- command: result.success,
1556
- options
841
+ const diffText = yield* strategy(gitService, Option.getOrUndefined(config.branch), Option.getOrUndefined(config.commits));
842
+ if (!diffText.trim()) yield* new CliFailure({
843
+ exitCode: ExitCode.RuntimeFailure,
844
+ message: "No diff available for the requested source."
1557
845
  });
1558
- };
1559
- //#endregion
1560
- //#region src/cli/runtime.ts
1561
- const resolveRepositoryRoot = (repoOverride) => {
1562
- const cwd = repoOverride ? resolve(repoOverride) : process.cwd();
1563
- try {
1564
- return execFileSync("git", ["rev-parse", "--show-toplevel"], {
1565
- cwd,
1566
- encoding: "utf8"
1567
- }).trim();
1568
- } catch {
1569
- return new CliFailure({
1570
- exitCode: ExitCode.StateUnavailable,
1571
- 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.`
1572
- });
1573
- }
1574
- };
1575
- const resolveDbPath = (repoRoot, dbPathOverride) => dbPathOverride ? resolve(dbPathOverride) : resolve(repoRoot, ".ringi/reviews.db");
1576
- const commandNeedsRepository = (command) => command.kind !== "help" && command.kind !== "version" && command.kind !== "mcp" && command.kind !== "serve";
1577
- /**
1578
- * Commands that need the database to already exist. `review-pr` is NOT here
1579
- * because it auto-initializes `.ringi/` (like `serve` does).
1580
- */
1581
- 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";
1582
- const commandUsesCoreRuntime = (command) => command.kind === "review-list" || command.kind === "review-show" || command.kind === "review-export" || command.kind === "review-pr" || command.kind === "review-status" || command.kind === "todo-list" || command.kind === "review-create" || command.kind === "todo-add" || command.kind === "doctor";
1583
- const resolveCliConfig = (args) => {
1584
- const repoRootResult = resolveRepositoryRoot(args.repo);
1585
- if (repoRootResult instanceof CliFailure) return repoRootResult;
1586
- return {
1587
- color: args.color,
1588
- cwd: process.cwd(),
1589
- dbPath: resolveDbPath(repoRootResult, args.dbPath),
1590
- outputMode: "human",
1591
- quiet: args.quiet,
1592
- repoRoot: repoRootResult,
1593
- verbose: args.verbose
846
+ const files = parseDiff(diffText);
847
+ const data = {
848
+ diff: diffText,
849
+ source: config.source,
850
+ summary: getDiffSummary(files)
1594
851
  };
1595
- };
1596
- const ensureLocalStateAvailable = (config) => {
1597
- if (!existsSync(config.dbPath)) return new CliFailure({
1598
- exitCode: ExitCode.StateUnavailable,
1599
- message: `Local state is missing at ${config.dbPath}. Run 'ringi data migrate' or start 'ringi serve' once to initialize local state.`
852
+ yield* emitOutput("ringi source diff", {
853
+ data,
854
+ human: config.stat ? [
855
+ `Source: ${config.source}`,
856
+ `Files: ${data.summary.totalFiles}`,
857
+ `Additions: ${data.summary.totalAdditions}`,
858
+ `Deletions: ${data.summary.totalDeletions}`
859
+ ].join("\n") : diffText,
860
+ nextActions: [{
861
+ command: `ringi review create --source ${config.source}`,
862
+ description: `Create a review from this ${config.source} diff`
863
+ }, {
864
+ command: "ringi source list",
865
+ description: "List repository sources"
866
+ }]
1600
867
  });
1601
- };
1602
- const makeConfigLayer = (config) => ConfigProvider.layer(ConfigProvider.fromUnknown({
1603
- DB_PATH: config.dbPath,
1604
- REPOSITORY_PATH: config.repoRoot
1605
- }));
1606
- const createCoreCliRuntime = (config) => ManagedRuntime.make(Layer.mergeAll(CoreLive, CliConfigLive(config)).pipe(Layer.provideMerge(makeConfigLayer(config))));
1607
- const createGitCliRuntime = (config) => ManagedRuntime.make(Layer.mergeAll(GitService.Default, CliConfigLive(config)).pipe(Layer.provideMerge(makeConfigLayer(config))));
1608
- const createCliRuntimeResources = (command, args) => {
1609
- if (!commandNeedsRepository(command)) return null;
1610
- const configResult = resolveCliConfig(args);
1611
- if (configResult instanceof CliFailure) return configResult;
1612
- if (commandNeedsDatabase(command)) {
1613
- const stateError = ensureLocalStateAvailable(configResult);
1614
- if (stateError) return stateError;
1615
- }
1616
- return {
1617
- config: configResult,
1618
- runtime: commandUsesCoreRuntime(command) ? createCoreCliRuntime(configResult) : createGitCliRuntime(configResult)
1619
- };
1620
- };
1621
- //#endregion
1622
- //#region src/cli/main.ts
1623
- const CLI_VERSION = "0.3.1";
1624
- const COMMAND_TREE = {
1625
- commands: [
1626
- {
1627
- description: "List review sessions",
1628
- name: "review list",
1629
- usage: "ringi review list [--status <status>] [--source <type>] [--limit <n>] [--page <n>]"
1630
- },
1631
- {
1632
- description: "Show review details",
1633
- name: "review show",
1634
- usage: "ringi review show <id|last> [--comments] [--todos]"
1635
- },
1636
- {
1637
- description: "Create a review session",
1638
- name: "review create",
1639
- usage: "ringi review create [--source <staged|branch|commits>] [--branch <name>] [--commits <range>]"
1640
- },
1641
- {
1642
- description: "Export review as markdown",
1643
- name: "review export",
1644
- usage: "ringi review export <id|last> [--output <path>] [--stdout]"
1645
- },
1646
- {
1647
- description: "Resolve a review session",
1648
- name: "review resolve",
1649
- usage: "ringi review resolve <id|last> [--all-comments] [--yes]"
1650
- },
1651
- {
1652
- description: "Show repository and review status",
1653
- name: "review status",
1654
- usage: "ringi review status [--review <id|last>] [--source <type>]"
1655
- },
1656
- {
1657
- description: "List repository sources",
1658
- name: "source list",
1659
- usage: "ringi source list"
1660
- },
1661
- {
1662
- description: "Show diff for a source",
1663
- name: "source diff",
1664
- usage: "ringi source diff <staged|branch|commits> [--branch <name>] [--commits <range>] [--stat]"
1665
- },
1666
- {
1667
- description: "List todo items",
1668
- name: "todo list",
1669
- usage: "ringi todo list [--review <id>] [--status <pending|done|all>] [--limit <n>] [--offset <n>]"
1670
- },
1671
- {
1672
- description: "Add a todo item",
1673
- name: "todo add",
1674
- usage: "ringi todo add --text <text> [--review <id>] [--position <n>]"
1675
- },
1676
- {
1677
- description: "Mark a todo as done",
1678
- name: "todo done",
1679
- usage: "ringi todo done <id>"
1680
- },
1681
- {
1682
- description: "Reopen a completed todo",
1683
- name: "todo undone",
1684
- usage: "ringi todo undone <id>"
1685
- },
1686
- {
1687
- description: "Move a todo to a position",
1688
- name: "todo move",
1689
- usage: "ringi todo move <id> --position <n>"
1690
- },
1691
- {
1692
- description: "Remove a todo",
1693
- name: "todo remove",
1694
- usage: "ringi todo remove <id> [--yes]"
1695
- },
1696
- {
1697
- description: "Clear completed todos",
1698
- name: "todo clear",
1699
- usage: "ringi todo clear [--review <id>] [--done-only] [--all] [--yes]"
1700
- },
1701
- {
1702
- description: "Start the local Ringi server",
1703
- name: "serve",
1704
- usage: "ringi serve [--host <host>] [--port <port>] [--https] [--auth] [--no-open]"
1705
- },
1706
- {
1707
- description: "Start the MCP stdio server",
1708
- name: "mcp",
1709
- usage: "ringi mcp [--readonly] [--log-level <level>]"
1710
- },
1711
- {
1712
- description: "Run local diagnostics",
1713
- name: "doctor",
1714
- usage: "ringi doctor"
1715
- },
1716
- {
1717
- description: "Tail server events",
1718
- name: "events",
1719
- usage: "ringi events [--type <reviews|comments|todos|files>]"
1720
- },
1721
- {
1722
- description: "Run database migrations",
1723
- name: "data migrate",
1724
- usage: "ringi data migrate"
1725
- },
1726
- {
1727
- description: "Reset local data",
1728
- name: "data reset",
1729
- usage: "ringi data reset [--yes] [--keep-exports]"
1730
- }
1731
- ],
1732
- description: "ringi — local-first code review CLI",
1733
- version: CLI_VERSION
1734
- };
1735
- const ROOT_NEXT_ACTIONS = [
1736
- {
1737
- command: "ringi review list [--status <status>] [--source <type>]",
1738
- description: "List review sessions",
1739
- params: {
1740
- source: { enum: [
1741
- "staged",
1742
- "branch",
1743
- "commits"
1744
- ] },
1745
- status: { enum: [
1746
- "in_progress",
1747
- "approved",
1748
- "changes_requested"
1749
- ] }
1750
- }
1751
- },
1752
- {
1753
- command: "ringi source list",
1754
- description: "List repository sources"
1755
- },
1756
- {
1757
- command: "ringi review create [--source <source>]",
1758
- description: "Create a new review session",
1759
- params: { source: {
1760
- default: "staged",
1761
- enum: [
1762
- "staged",
1763
- "branch",
1764
- "commits"
1765
- ]
1766
- } }
1767
- },
1768
- {
1769
- command: "ringi todo list [--status <status>]",
1770
- description: "List todos",
1771
- params: { status: {
1772
- default: "pending",
1773
- enum: [
1774
- "pending",
1775
- "done",
1776
- "all"
1777
- ]
1778
- } }
1779
- },
1780
- {
1781
- command: "ringi review status",
1782
- description: "Show repository and review status"
1783
- }
1784
- ];
1785
- const ROOT_HELP = `ringi — local-first review CLI
1786
-
1787
- Usage:
1788
- ringi [global options] <command>
1789
-
1790
- Global options:
1791
- --json Emit structured JSON envelope to stdout
1792
- --repo <path> Use a specific Git repository root
1793
- --db-path <path> Override the SQLite database path
1794
- --quiet Suppress human-readable success output
1795
- --verbose Include stack traces on failures
1796
- --no-color Disable ANSI color output
1797
- --help Show help
1798
- --version Show version
1799
-
1800
- Commands:
1801
- review list [--status <status>] [--source <type>] [--limit <n>] [--page <n>]
1802
- review show <id|last> [--comments] [--todos]
1803
- review create [--source <staged|branch|commits>] [--branch <name>] [--commits <range>]
1804
- review export <id|last> [--output <path>] [--stdout]
1805
- review resolve <id|last> [--all-comments] [--yes]
1806
- review status [--review <id|last>] [--source <type>]
1807
- source list
1808
- source diff <staged|branch|commits> [--branch <name>] [--commits <range>] [--stat]
1809
- todo list [--review <id>] [--status <pending|done|all>] [--limit <n>] [--offset <n>]
1810
- todo add --text <text> [--review <id>]
1811
- todo done <id>
1812
- todo undone <id>
1813
- todo move <id> --position <n>
1814
- todo remove <id> [--yes]
1815
- todo clear [--review <id>] [--done-only] [--all] [--yes]
1816
- export <id|last> [--output <path>] [--stdout]
1817
- `;
1818
- const HELP_TOPICS = {
1819
- data: `ringi data
1820
-
1821
- Usage:
1822
- ringi data migrate
1823
- ringi data reset [--yes] [--keep-exports]
1824
- `,
1825
- review: `ringi review
1826
-
1827
- Usage:
1828
- ringi review list [--status <status>] [--source <type>] [--limit <n>] [--page <n>]
1829
- ringi review show <id|last> [--comments] [--todos]
1830
- ringi review create [--source <staged|branch|commits>] [--branch <name>] [--commits <range>]
1831
- ringi review export <id|last> [--output <path>] [--stdout]
1832
- ringi review resolve <id|last> [--all-comments] [--yes]
1833
- ringi review status [--review <id|last>] [--source <type>]
1834
- `,
1835
- source: `ringi source
1836
-
1837
- Usage:
1838
- ringi source list
1839
- ringi source diff <staged|branch|commits> [--branch <name>] [--commits <range>] [--stat]
1840
- `,
1841
- todo: `ringi todo
1842
-
1843
- Usage:
1844
- ringi todo list [--review <id>] [--status <pending|done|all>] [--limit <n>] [--offset <n>]
1845
- ringi todo add --text <text> [--review <id>] [--position <n>]
1846
- ringi todo done <id>
1847
- ringi todo undone <id>
1848
- ringi todo move <id> --position <n>
1849
- ringi todo remove <id> [--yes]
1850
- ringi todo clear [--review <id>] [--done-only] [--all] [--yes]
1851
- `
1852
- };
1853
- const renderHelp = (command) => {
1854
- if (command.kind !== "help") return ROOT_HELP;
1855
- const [topic] = command.topic;
1856
- return (topic && HELP_TOPICS[topic]) ?? ROOT_HELP;
1857
- };
1858
- const writeJson = (payload) => {
1859
- process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
1860
- };
1861
- const writeHuman = (text) => {
1862
- if (text && text.length > 0) process.stdout.write(`${text}\n`);
1863
- };
1864
- /** Maps exit codes to error categories and retryable status. */
1865
- const EXIT_CODE_META = {
1866
- [ExitCode.AuthFailure]: {
1867
- category: "auth",
1868
- code: "AUTH_FAILURE",
1869
- retryable: false
1870
- },
1871
- [ExitCode.ResourceNotFound]: {
1872
- category: "not_found",
1873
- code: "RESOURCE_NOT_FOUND",
1874
- retryable: false
1875
- },
1876
- [ExitCode.RuntimeFailure]: {
1877
- category: "server",
1878
- code: "RUNTIME_FAILURE",
1879
- retryable: true
1880
- },
1881
- [ExitCode.StateUnavailable]: {
1882
- category: "config",
1883
- code: "STATE_UNAVAILABLE",
1884
- retryable: false
1885
- },
1886
- [ExitCode.UsageError]: {
1887
- category: "validation",
1888
- code: "USAGE_ERROR",
1889
- retryable: false
1890
- }
1891
- };
1892
- const mapFailure = (error) => {
1893
- if (error instanceof CliFailure) {
1894
- const meta = EXIT_CODE_META[error.exitCode] ?? {
1895
- category: "server",
1896
- code: "UNKNOWN",
1897
- retryable: false
1898
- };
1899
- return {
1900
- category: meta.category,
1901
- code: meta.code,
1902
- exitCode: error.exitCode,
1903
- message: error.message,
1904
- retryable: meta.retryable,
1905
- verbose: error.details
1906
- };
1907
- }
1908
- if (error instanceof ReviewNotFound || error instanceof TodoNotFound) return {
1909
- category: "not_found",
1910
- code: "RESOURCE_NOT_FOUND",
1911
- exitCode: ExitCode.ResourceNotFound,
1912
- message: error.message,
1913
- retryable: false,
1914
- verbose: error.stack
1915
- };
1916
- if (error instanceof Error) return {
1917
- category: "server",
1918
- code: "RUNTIME_FAILURE",
1919
- exitCode: ExitCode.RuntimeFailure,
1920
- message: error.message,
1921
- retryable: true,
1922
- verbose: error.stack
1923
- };
1924
- return {
1925
- category: "server",
1926
- code: "UNKNOWN_FAILURE",
1927
- exitCode: ExitCode.RuntimeFailure,
1928
- message: "Unknown CLI failure.",
1929
- retryable: false
1930
- };
1931
- };
1932
- /** Actionable fix guidance based on error category. */
1933
- const FIX_GUIDANCE = {
1934
- auth: "Check authentication credentials or run 'ringi serve --auth' with valid credentials.",
1935
- config: "Run 'ringi serve' once to initialize local state, or check --repo and --db-path flags.",
1936
- conflict: "Resolve the conflict and retry the operation.",
1937
- connection: "Ensure the Ringi server is running: ringi serve",
1938
- not_found: "Verify the resource ID. Use 'ringi review list' or 'ringi todo list' to find valid IDs.",
1939
- server: "Retry the command. If the error persists, check 'ringi serve' logs.",
1940
- validation: "Check command usage with 'ringi --help'. Verify flag names and values."
1941
- };
1942
- /** Build recovery next_actions based on error category. */
1943
- const errorNextActions = (commandStr, normalized) => {
1944
- const actions = [];
1945
- if (normalized.retryable) actions.push({
1946
- command: commandStr,
1947
- description: "Retry the failed command"
868
+ })).pipe(Command.withDescription("Show diff for a source"));
869
+ const source = Command.make("source").pipe(Command.withDescription("Source management commands"), Command.withSubcommands([sourceList, sourceDiff]));
870
+ const todoList = Command.make("list", {
871
+ reviewId: Flag.string("review").pipe(Flag.withDescription("Filter by review ID"), Flag.optional),
872
+ status: Flag.choice("status", [
873
+ "pending",
874
+ "done",
875
+ "all"
876
+ ]).pipe(Flag.withDefault("pending"), Flag.withDescription("Filter by status")),
877
+ limit: Flag.integer("limit").pipe(Flag.withDescription("Max results"), Flag.optional),
878
+ offset: Flag.integer("offset").pipe(Flag.withDefault(0), Flag.withDescription("Results offset"))
879
+ }, (config) => Effect.gen(function* () {
880
+ yield* ensureDatabaseExists;
881
+ const result = yield* (yield* TodoService).list({
882
+ completed: config.status === "all" ? void 0 : config.status === "done",
883
+ limit: Option.getOrUndefined(config.limit),
884
+ offset: config.offset,
885
+ reviewId: Option.getOrUndefined(config.reviewId)
1948
886
  });
1949
- if (normalized.category === "config" || normalized.category === "connection") actions.push({
1950
- command: "ringi serve",
1951
- description: "Start the local Ringi server"
887
+ const nextActions = [];
888
+ if (Option.isSome(config.reviewId)) nextActions.push({
889
+ command: `ringi review show ${config.reviewId.value}`,
890
+ description: "View the associated review"
1952
891
  });
1953
- if (normalized.category === "not_found") actions.push({
1954
- command: "ringi review list",
1955
- description: "List available reviews"
892
+ nextActions.push({
893
+ command: "ringi todo add --text <text>",
894
+ description: "Add a new todo"
1956
895
  }, {
1957
- command: "ringi todo list",
1958
- description: "List available todos"
896
+ command: "ringi review list",
897
+ description: "List reviews"
1959
898
  });
1960
- if (normalized.category === "validation") actions.push({
1961
- command: `${commandStr.split(" ").slice(0, 3).join(" ")} --help`,
1962
- description: "Show command usage"
899
+ yield* emitOutput("ringi todo list", {
900
+ data: result,
901
+ human: result.data.length === 0 ? "No todos found." : result.data.map((todo) => `- [${todo.completed ? "x" : " "}] (${todo.position + 1}) ${todo.content}`).join("\n"),
902
+ nextActions
1963
903
  });
1964
- return actions;
1965
- };
1966
- /** Build a full error envelope from a normalized failure. */
1967
- const buildErrorEnvelope = (commandStr, normalized) => {
1968
- return failure(commandStr, {
1969
- category: normalized.category,
1970
- code: normalized.code,
1971
- message: normalized.message,
1972
- retryable: normalized.retryable,
1973
- type: `ringi://errors/${normalized.code}`
1974
- }, normalized.verbose ?? FIX_GUIDANCE[normalized.category], errorNextActions(commandStr, normalized));
1975
- };
1976
- const installSignalHandlers = (dispose) => {
1977
- const shutdown = async () => {
1978
- await dispose();
1979
- process.exit(ExitCode.RuntimeFailure);
1980
- };
1981
- process.once("SIGINT", shutdown);
1982
- process.once("SIGTERM", shutdown);
1983
- return () => {
1984
- process.off("SIGINT", shutdown);
1985
- process.off("SIGTERM", shutdown);
1986
- };
1987
- };
1988
- /**
1989
- * Resolves the built Nitro server entry point.
1990
- *
1991
- * Lookup order (first match wins):
1992
- * 1. **Packaged location** — `<pkg-root>/server/server/index.mjs`
1993
- * The published npm package includes `server/` at the package root, which
1994
- * contains `server/index.mjs` (the Nitro entry) and `public/` (static
1995
- * assets). From the bundled CLI at `dist/cli.mjs`, the package root is
1996
- * `import.meta.dirname/..`.
1997
- * 2. **Monorepo development** — `apps/web/.output/server/index.mjs`
1998
- * During local development the web build output lives inside the web
1999
- * workspace. We resolve it relative to the CLI package root.
2000
- * 3. **CWD fallback** — `.output/server/index.mjs` relative to the current
2001
- * working directory, for running the server in a standalone checkout.
2002
- */
904
+ })).pipe(Command.withDescription("List todo items"));
905
+ const todoAdd = Command.make("add", {
906
+ text: Flag.string("text").pipe(Flag.withDescription("Todo text")),
907
+ reviewId: Flag.string("review").pipe(Flag.withDescription("Associate with review"), Flag.optional),
908
+ position: Flag.integer("position").pipe(Flag.withDescription("Insert position"), Flag.optional)
909
+ }, (_config) => requireServerMode("ringi todo add")).pipe(Command.withDescription("Add a todo item"));
910
+ const todoDone = Command.make("done", { id: Argument.string("id") }, (_config) => requireServerMode("ringi todo done")).pipe(Command.withDescription("Mark a todo as done"));
911
+ const todoUndone = Command.make("undone", { id: Argument.string("id") }, (_config) => requireServerMode("ringi todo undone")).pipe(Command.withDescription("Reopen a completed todo"));
912
+ const todoMove = Command.make("move", {
913
+ id: Argument.string("id"),
914
+ position: Flag.integer("position").pipe(Flag.withDescription("Target position"))
915
+ }, (_config) => requireServerMode("ringi todo move")).pipe(Command.withDescription("Move a todo to a position"));
916
+ const todoRemove = Command.make("remove", {
917
+ id: Argument.string("id"),
918
+ yes: Flag.boolean("yes").pipe(Flag.withDefault(false), Flag.withDescription("Skip confirmation"))
919
+ }, (_config) => requireServerMode("ringi todo remove")).pipe(Command.withDescription("Remove a todo"));
920
+ const todoClear = Command.make("clear", {
921
+ reviewId: Flag.string("review").pipe(Flag.withDescription("Scope to review"), Flag.optional),
922
+ doneOnly: Flag.boolean("done-only").pipe(Flag.withDefault(true), Flag.withDescription("Clear only completed")),
923
+ all: Flag.boolean("all").pipe(Flag.withDefault(false), Flag.withDescription("Clear all todos")),
924
+ yes: Flag.boolean("yes").pipe(Flag.withDefault(false), Flag.withDescription("Skip confirmation"))
925
+ }, (_config) => requireServerMode("ringi todo clear")).pipe(Command.withDescription("Clear completed todos"));
926
+ const todo = Command.make("todo").pipe(Command.withDescription("Todo management commands"), Command.withSubcommands([
927
+ todoList,
928
+ todoAdd,
929
+ todoDone,
930
+ todoUndone,
931
+ todoMove,
932
+ todoRemove,
933
+ todoClear
934
+ ]));
2003
935
  const resolveServerEntry = () => {
2004
936
  const candidates = [];
2005
937
  if (import.meta.dirname) {
@@ -2010,122 +942,126 @@ const resolveServerEntry = () => {
2010
942
  candidates.push(resolve(process.cwd(), ".output", "server", "index.mjs"));
2011
943
  return candidates.find((candidate) => existsSync(candidate));
2012
944
  };
2013
- const runServe = (command) => {
945
+ const serve = Command.make("serve", {
946
+ host: Flag.string("host").pipe(Flag.withDefault("127.0.0.1"), Flag.withDescription("Bind host")),
947
+ port: Flag.integer("port").pipe(Flag.withDefault(3e3), Flag.withDescription("Port number")),
948
+ https: Flag.boolean("https").pipe(Flag.withDefault(false), Flag.withDescription("Enable HTTPS")),
949
+ cert: Flag.string("cert").pipe(Flag.withDescription("SSL certificate path"), Flag.optional),
950
+ key: Flag.string("key").pipe(Flag.withDescription("SSL key path"), Flag.optional),
951
+ auth: Flag.boolean("auth").pipe(Flag.withDefault(false), Flag.withDescription("Enable authentication")),
952
+ noOpen: Flag.boolean("no-open").pipe(Flag.withDefault(false), Flag.withDescription("Don't open browser"))
953
+ }, (config) => Effect.callback((resume) => {
2014
954
  const serverEntry = resolveServerEntry();
2015
955
  if (!serverEntry) {
2016
956
  const hint = import.meta.dirname && !import.meta.dirname.includes("apps/cli/") ? "The installed package is missing its server assets. Try reinstalling: npm install -g @sanurb/ringi" : "Run 'pnpm build' at the monorepo root, then 'pnpm --filter @sanurb/ringi build:server' to copy the server assets.";
2017
- process.stderr.write(`No built server found.\n${hint}\n`);
2018
- process.exit(ExitCode.RuntimeFailure);
957
+ resume(Effect.fail(new CliFailure({
958
+ exitCode: ExitCode.RuntimeFailure,
959
+ message: `No built server found.\n${hint}`
960
+ })));
961
+ return;
2019
962
  }
2020
963
  const env = {
2021
964
  ...process.env,
2022
- NITRO_HOST: command.host,
2023
- NITRO_PORT: String(command.port)
965
+ NITRO_HOST: config.host,
966
+ NITRO_PORT: String(config.port)
2024
967
  };
2025
- if (command.https && command.cert && command.key) {
2026
- env.NITRO_SSL_CERT = command.cert;
2027
- env.NITRO_SSL_KEY = command.key;
968
+ if (config.https && Option.isSome(config.cert) && Option.isSome(config.key)) {
969
+ env.NITRO_SSL_CERT = config.cert.value;
970
+ env.NITRO_SSL_KEY = config.key.value;
2028
971
  }
2029
- const url = `${command.https ? "https" : "http"}://${command.host === "0.0.0.0" ? "localhost" : command.host}:${command.port}`;
972
+ const url = `${config.https ? "https" : "http"}://${config.host === "0.0.0.0" ? "localhost" : config.host}:${config.port}`;
2030
973
  process.stderr.write(`ringi server starting on ${url}\n`);
2031
974
  const child = fork(serverEntry, [], {
2032
975
  env,
2033
976
  execArgv: [],
2034
977
  stdio: "inherit"
2035
978
  });
2036
- if (!command.noOpen) setTimeout(() => {
979
+ if (!config.noOpen) setTimeout(() => {
2037
980
  let openCmd = "xdg-open";
2038
981
  if (process.platform === "darwin") openCmd = "open";
2039
982
  else if (process.platform === "win32") openCmd = "start";
2040
983
  exec(`${openCmd} ${url}`, () => {});
2041
984
  }, 1500);
2042
- const shutdown = () => {
2043
- child.kill("SIGTERM");
2044
- };
985
+ const shutdown = () => child.kill("SIGTERM");
2045
986
  process.once("SIGINT", shutdown);
2046
987
  process.once("SIGTERM", shutdown);
2047
988
  child.on("exit", (code) => {
2048
989
  process.off("SIGINT", shutdown);
2049
990
  process.off("SIGTERM", shutdown);
2050
- process.exit(code ?? ExitCode.Success);
2051
- });
2052
- };
2053
- /** Single path for all CLI error exits. */
2054
- const failAndExit = (opts) => {
2055
- const normalized = mapFailure(opts.error);
2056
- if (opts.json) writeJson(buildErrorEnvelope(opts.cmdStr, normalized));
2057
- process.stderr.write(`${normalized.message}\n`);
2058
- if (opts.verbose && normalized.verbose) process.stderr.write(`${normalized.verbose}\n`);
2059
- return process.exit(normalized.exitCode);
2060
- };
2061
- const main = async () => {
2062
- const argv = process.argv.slice(2);
2063
- const parseResult = parseCliArgs(argv);
2064
- if (Result.isFailure(parseResult)) return failAndExit({
2065
- cmdStr: "ringi",
2066
- error: parseResult.failure,
2067
- json: argv.includes("--json"),
2068
- verbose: false
2069
- });
2070
- const { command, options } = parseResult.success;
2071
- if (command.kind === "help") {
2072
- if (options.json) writeJson(success("ringi", COMMAND_TREE, ROOT_NEXT_ACTIONS));
2073
- else writeHuman(renderHelp(command));
2074
- process.exit(ExitCode.Success);
2075
- }
2076
- if (command.kind === "version") {
2077
- if (options.json) writeJson(success("ringi --version", { version: CLI_VERSION }));
2078
- else writeHuman(CLI_VERSION);
2079
- process.exit(ExitCode.Success);
2080
- }
2081
- if (command.kind === "serve") {
2082
- runServe(command);
2083
- return;
2084
- }
2085
- const runtimeResources = createCliRuntimeResources(command, {
2086
- color: options.color,
2087
- dbPath: options.dbPath,
2088
- quiet: options.quiet,
2089
- repo: options.repo,
2090
- verbose: options.verbose
2091
- });
2092
- if (runtimeResources === null) process.exit(ExitCode.Success);
2093
- const cmdStr = commandLabel(command);
2094
- if (runtimeResources instanceof CliFailure) return failAndExit({
2095
- cmdStr,
2096
- error: runtimeResources,
2097
- json: options.json,
2098
- verbose: options.verbose
991
+ if (code === 0) resume(Effect.void);
992
+ else resume(Effect.fail(new CliFailure({
993
+ exitCode: ExitCode.RuntimeFailure,
994
+ message: `Server exited with code ${code}`
995
+ })));
2099
996
  });
2100
- const removeSignalHandlers = installSignalHandlers(() => runtimeResources.runtime.dispose());
2101
- try {
2102
- const output = await runtimeResources.runtime.runPromise(runCommand(command));
2103
- if (options.json) writeJson(success(cmdStr, output.data, output.nextActions ?? []));
2104
- else if (!options.quiet) writeHuman(output.human);
2105
- await runtimeResources.runtime.dispose();
2106
- removeSignalHandlers();
2107
- process.exit(ExitCode.Success);
2108
- } catch (error) {
2109
- await runtimeResources.runtime.dispose();
2110
- removeSignalHandlers();
2111
- failAndExit({
2112
- cmdStr,
2113
- error,
2114
- json: options.json,
2115
- verbose: options.verbose
2116
- });
2117
- }
2118
- };
2119
- try {
2120
- await main();
2121
- } catch (error) {
2122
- failAndExit({
2123
- cmdStr: "ringi",
2124
- error,
2125
- json: process.argv.slice(2).includes("--json"),
2126
- verbose: false
997
+ })).pipe(Command.withDescription("Start the local Ringi server"));
998
+ const mcp = Command.make("mcp", {
999
+ readonly: Flag.boolean("readonly").pipe(Flag.withDefault(false), Flag.withDescription("Read-only mode")),
1000
+ logLevel: Flag.choice("log-level", [
1001
+ "debug",
1002
+ "info",
1003
+ "error",
1004
+ "silent"
1005
+ ]).pipe(Flag.withDefault("error"), Flag.withDescription("MCP log level"))
1006
+ }, (_config) => Effect.fail(new CliFailure({
1007
+ exitCode: ExitCode.UsageError,
1008
+ message: "ringi mcp is a runtime command. Use the MCP server entry point directly."
1009
+ }))).pipe(Command.withDescription("Start the MCP stdio server"));
1010
+ const doctor = Command.make("doctor", {}, () => Effect.gen(function* () {
1011
+ yield* emitOutput("ringi doctor", {
1012
+ data: {
1013
+ checks: [],
1014
+ ok: true
1015
+ },
1016
+ human: "ringi doctor: not yet implemented.",
1017
+ nextActions: []
2127
1018
  });
2128
- }
1019
+ })).pipe(Command.withDescription("Run local diagnostics"));
1020
+ const events = Command.make("events", { type: Flag.choice("type", [
1021
+ "reviews",
1022
+ "comments",
1023
+ "todos",
1024
+ "files"
1025
+ ]).pipe(Flag.withDescription("Filter event type"), Flag.optional) }, (_config) => requireServerMode("ringi events")).pipe(Command.withDescription("Tail server events"));
1026
+ const dataMigrate = Command.make("migrate", {}, () => requireServerMode("ringi data migrate")).pipe(Command.withDescription("Run database migrations"));
1027
+ const dataReset = Command.make("reset", {
1028
+ yes: Flag.boolean("yes").pipe(Flag.withDefault(false), Flag.withDescription("Skip confirmation")),
1029
+ keepExports: Flag.boolean("keep-exports").pipe(Flag.withDefault(false), Flag.withDescription("Preserve export files"))
1030
+ }, (_config) => requireServerMode("ringi data reset")).pipe(Command.withDescription("Reset local data"));
1031
+ const data = Command.make("data").pipe(Command.withDescription("Data management commands"), Command.withSubcommands([dataMigrate, dataReset]));
1032
+ const reviewWithCore = provideCoreLayer(review);
1033
+ const sourceWithGit = provideGitLayer(source);
1034
+ const todoWithCore = provideCoreLayer(todo);
1035
+ const doctorWithCore = provideCoreLayer(doctor);
1036
+ const ringiCommand = Command.make("ringi").pipe(Command.withDescription("ringi — local-first code review CLI"), Command.withGlobalFlags([
1037
+ JsonSetting,
1038
+ QuietSetting,
1039
+ RepoSetting,
1040
+ DbPathSetting
1041
+ ]), Command.withSubcommands([
1042
+ reviewWithCore,
1043
+ sourceWithGit,
1044
+ todoWithCore,
1045
+ serve,
1046
+ mcp,
1047
+ doctorWithCore,
1048
+ events,
1049
+ data
1050
+ ]));
1051
+ //#endregion
1052
+ //#region src/cli/main.ts
1053
+ /**
1054
+ * Ringi CLI entrypoint.
1055
+ *
1056
+ * Uses `effect/unstable/cli` for command parsing, help generation, shell
1057
+ * completions, and version display. Each command is defined as a typed
1058
+ * `Command` with its own flags/arguments and handler.
1059
+ *
1060
+ * The old hand-rolled parser, imperative main loop, and manual help text
1061
+ * are replaced by the Effect CLI framework.
1062
+ */
1063
+ const program = Command.run(ringiCommand, { version: "0.3.3" }).pipe(Effect.provide(NodeServices.layer));
1064
+ NodeRuntime.runMain(program);
2129
1065
  //#endregion
2130
1066
  export {};
2131
1067