papilot 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -2
  3. package/README.zh-CN.md +127 -0
  4. package/bin/papilot.mjs +113 -0
  5. package/dist/apple-touch-icon.png +0 -0
  6. package/dist/assets/Inconsolatazi4-Regular-Dbtq7t_r.otf +0 -0
  7. package/dist/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
  8. package/dist/assets/TeXGyrePagellaX-Italic-CSuYIstn.otf +0 -0
  9. package/dist/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
  10. package/dist/assets/cursor-editorTextHighlight-kG3uUOwL.svg +8 -0
  11. package/dist/assets/index-BhQFCMTx.js +131 -0
  12. package/dist/assets/index-CAV3-87U.css +1 -0
  13. package/dist/assets/pdf-view-DJo0X38w.js +598 -0
  14. package/dist/assets/pdf.worker.min-CjEcRF4W.mjs +28 -0
  15. package/dist/favicon.png +0 -0
  16. package/dist/index.html +4 -0
  17. package/examples/papilot-paper/.latex-review.json +4 -0
  18. package/examples/papilot-paper/appendix/proofs.tex +60 -0
  19. package/examples/papilot-paper/appendix/reference.tex +54 -0
  20. package/examples/papilot-paper/main.tex +57 -0
  21. package/examples/papilot-paper/preamble.tex +53 -0
  22. package/examples/papilot-paper/references.bib +158 -0
  23. package/examples/papilot-paper/sections/01-introduction.tex +66 -0
  24. package/examples/papilot-paper/sections/02-using.tex +134 -0
  25. package/examples/papilot-paper/sections/03-background.tex +97 -0
  26. package/examples/papilot-paper/sections/04-model.tex +185 -0
  27. package/examples/papilot-paper/sections/05-conflicts.tex +99 -0
  28. package/examples/papilot-paper/sections/06-building.tex +70 -0
  29. package/examples/papilot-paper/sections/07-geometry.tex +193 -0
  30. package/examples/papilot-paper/sections/08-notes.tex +104 -0
  31. package/examples/papilot-paper/sections/09-agents.tex +106 -0
  32. package/examples/papilot-paper/sections/10-usage.tex +56 -0
  33. package/examples/papilot-paper/sections/11-git.tex +62 -0
  34. package/examples/papilot-paper/sections/12-security.tex +41 -0
  35. package/examples/papilot-paper/sections/13-evaluation.tex +106 -0
  36. package/examples/papilot-paper/sections/14-related.tex +38 -0
  37. package/examples/papilot-paper/sections/15-conclusion.tex +27 -0
  38. package/package.json +85 -4
  39. package/scripts/demo.ts +21 -0
  40. package/scripts/service.ts +35 -0
  41. package/src/notes.ts +40 -0
  42. package/src/server/agents.ts +343 -0
  43. package/src/server/codex.ts +63 -0
  44. package/src/server/frontend.ts +15 -0
  45. package/src/server/main.ts +202 -0
  46. package/src/server/manuscript.ts +337 -0
  47. package/src/server/paths.ts +11 -0
  48. package/src/server/pdf.ts +27 -0
  49. package/src/server/review.ts +53 -0
  50. package/src/server/security.ts +15 -0
  51. package/src/server/store.ts +60 -0
  52. package/src/server/workspace.ts +530 -0
  53. package/src/shared.ts +28 -0
  54. package/vendor/latex-review/LICENSE +21 -0
  55. package/vendor/latex-review/dist/web/assets/TeXGyrePagellaX-Bold-CEtYdOXj.otf +0 -0
  56. package/vendor/latex-review/dist/web/assets/TeXGyrePagellaX-Regular-DQJjZE_L.otf +0 -0
  57. package/vendor/latex-review/dist/web/assets/index-DyzYnIGk.css +1 -0
  58. package/vendor/latex-review/dist/web/assets/index-HxBK-dls.js +19 -0
  59. package/vendor/latex-review/dist/web/index.html +18 -0
  60. package/vendor/latex-review/package-lock.json +3114 -0
  61. package/vendor/latex-review/package.json +49 -0
  62. package/vendor/latex-review/src/cli.ts +494 -0
  63. package/vendor/latex-review/src/client.ts +198 -0
  64. package/vendor/latex-review/src/core/build.ts +296 -0
  65. package/vendor/latex-review/src/core/changes.ts +126 -0
  66. package/vendor/latex-review/src/core/config.ts +270 -0
  67. package/vendor/latex-review/src/core/diff.ts +54 -0
  68. package/vendor/latex-review/src/core/files.ts +96 -0
  69. package/vendor/latex-review/src/core/git.ts +317 -0
  70. package/vendor/latex-review/src/core/outline.ts +314 -0
  71. package/vendor/latex-review/src/core/process.ts +111 -0
  72. package/vendor/latex-review/src/core/publish.ts +339 -0
  73. package/vendor/latex-review/src/core/review.ts +734 -0
  74. package/vendor/latex-review/src/core/store.ts +66 -0
  75. package/vendor/latex-review/src/core/tasks.ts +237 -0
  76. package/vendor/latex-review/src/render/inline.ts +805 -0
  77. package/vendor/latex-review/src/render/locate.ts +481 -0
  78. package/vendor/latex-review/src/render/pdf.ts +281 -0
  79. package/vendor/latex-review/src/render/pdfdiff.ts +272 -0
  80. package/vendor/latex-review/src/render/round.ts +236 -0
  81. package/vendor/latex-review/src/render/worker.ts +10 -0
  82. package/vendor/latex-review/src/runtime.ts +23 -0
  83. package/vendor/latex-review/src/server.ts +401 -0
  84. package/vendor/latex-review/src/types.ts +283 -0
  85. package/vendor/latex-review/src/web/ChangeMap.tsx +212 -0
  86. package/vendor/latex-review/src/web/PdfStrip.tsx +77 -0
  87. package/vendor/latex-review/src/web/fonts/GUST-FONT-LICENSE.txt +29 -0
  88. package/vendor/latex-review/src/web/fonts/LPPL.txt +415 -0
  89. package/vendor/latex-review/src/web/fonts/MANIFEST-newpx.txt +20 -0
  90. package/vendor/latex-review/src/web/fonts/README-newpx.txt +473 -0
  91. package/vendor/latex-review/src/web/fonts/README.md +12 -0
  92. package/vendor/latex-review/src/web/fonts/TeXGyrePagellaX-Bold.otf +0 -0
  93. package/vendor/latex-review/src/web/fonts/TeXGyrePagellaX-Regular.otf +0 -0
  94. package/vendor/latex-review/src/web/index.html +17 -0
  95. package/vendor/latex-review/src/web/main.tsx +10 -0
  96. package/vendor/latex-review/src/web/model.ts +153 -0
  97. package/vendor/latex-review/src/web/overview.tsx +146 -0
  98. package/vendor/latex-review/src/web/review.tsx +991 -0
  99. package/vendor/latex-review/src/web/style.css +1249 -0
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "latex-review-skill",
3
+ "version": "0.2.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.22.0"
8
+ },
9
+ "bin": {
10
+ "latex-review": "scripts/review.mjs"
11
+ },
12
+ "scripts": {
13
+ "review": "tsx src/cli.ts",
14
+ "dev": "vite --host 127.0.0.1",
15
+ "build": "tsc --noEmit && vite build",
16
+ "typecheck": "tsc --noEmit",
17
+ "test": "vitest run",
18
+ "check": "npm run typecheck && npm test && npm run build",
19
+ "demo": "tsx examples/demo.ts",
20
+ "test:browser": "playwright test",
21
+ "test:corpus": "tsx scripts/validate-corpus.ts",
22
+ "fixture:prepare": "tsx scripts/prepare-fixture.ts",
23
+ "format": "prettier --write src tests scripts examples/demo.ts *.ts *.json"
24
+ },
25
+ "dependencies": {
26
+ "@fastify/compress": "^9.2.2",
27
+ "@fastify/static": "^10.1.7",
28
+ "commander": "^15.0.0",
29
+ "diff": "^9.0.0",
30
+ "fastify": "^5.12.7",
31
+ "mupdf": "^1.28.1",
32
+ "proper-lockfile": "^4.1.2",
33
+ "react": "^19.3.1",
34
+ "react-dom": "^19.3.1",
35
+ "tsx": "^4.23.15"
36
+ },
37
+ "devDependencies": {
38
+ "@playwright/test": "^1.64.1",
39
+ "@types/node": "^22.20.5",
40
+ "@types/proper-lockfile": "^4.1.4",
41
+ "@types/react": "^19.3.0",
42
+ "@types/react-dom": "^19.3.0",
43
+ "@vitejs/plugin-react": "^6.1.2",
44
+ "prettier": "^3.9.10",
45
+ "typescript": "^7.0.2",
46
+ "vite": "^8.3.5",
47
+ "vitest": "^5.0.5"
48
+ }
49
+ }
@@ -0,0 +1,494 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { spawn } from "node:child_process";
5
+ import { Command, CommanderError } from "commander";
6
+ import { ROOT } from "./runtime.ts";
7
+ import {
8
+ Client,
9
+ connect,
10
+ serverHome,
11
+ stop,
12
+ type ServerInfo,
13
+ } from "./client.ts";
14
+ import { atomicWrite, sleep } from "./core/files.ts";
15
+ import { run, which } from "./core/process.ts";
16
+ import { taskMarker } from "./core/tasks.ts";
17
+ import { ENGINES } from "./core/config.ts";
18
+ import type { Feedback, JobResult, Summary, PageState } from "./types.ts";
19
+ interface Info extends Summary {
20
+ worktree: string;
21
+ url: string;
22
+ viewing: boolean;
23
+ }
24
+ class Failure extends Error {
25
+ constructor(public payload: { error: string; [key: string]: unknown }) {
26
+ super(payload.error);
27
+ }
28
+ }
29
+ export const emit = (value: unknown) => console.log(JSON.stringify(value));
30
+ function openBrowser(url: string) {
31
+ const child = spawn(
32
+ process.platform === "darwin" ? "open" : "xdg-open",
33
+ [url],
34
+ { detached: true, stdio: "ignore" },
35
+ );
36
+ child.on("error", () => {});
37
+ child.unref();
38
+ }
39
+ export async function doctor() {
40
+ const texHint =
41
+ process.platform === "darwin"
42
+ ? "Install MacTeX, or BasicTeX with latexmk."
43
+ : "Install TeX Live, e.g. apt install latexmk texlive-latex-recommended.";
44
+ const checks: { name: string; ok: boolean; detail: string; hint?: string }[] =
45
+ [];
46
+ const check = (name: string, ok: boolean, detail: string, hint: string) =>
47
+ checks.push({ name, ok, detail, ...(ok ? {} : { hint }) });
48
+ check(
49
+ "platform",
50
+ process.platform !== "win32",
51
+ process.platform,
52
+ "Use macOS, Linux or WSL.",
53
+ );
54
+ check(
55
+ "node",
56
+ Number(process.versions.node.split(".")[0]) > 22 ||
57
+ (Number(process.versions.node.split(".")[0]) === 22 &&
58
+ Number(process.versions.node.split(".")[1]) >= 22),
59
+ process.version,
60
+ "Install Node.js 22.22 or newer.",
61
+ );
62
+ for (const tool of ["git", "latexmk", "synctex"]) {
63
+ const binary = which(tool);
64
+ check(
65
+ tool,
66
+ Boolean(binary),
67
+ binary
68
+ ? run(binary, ["--version"], { check: false })
69
+ .stdout.toString()
70
+ .split("\n")[0]
71
+ .slice(0, 80)
72
+ : "not found",
73
+ tool === "git" ? "Install Git." : texHint,
74
+ );
75
+ }
76
+ const engines = ENGINES.filter((e) => which(e));
77
+ check(
78
+ "tex engines",
79
+ Boolean(engines.length),
80
+ engines.join(", ") || "none found",
81
+ texHint,
82
+ );
83
+ try {
84
+ const mupdf = await import("mupdf");
85
+ const doc = new mupdf.PDFDocument();
86
+ doc.destroy();
87
+ check("pdf renderer", true, "MuPDF WebAssembly", "");
88
+ } catch (error) {
89
+ check(
90
+ "pdf renderer",
91
+ false,
92
+ (error as Error).message,
93
+ "Run npm ci in the skill folder.",
94
+ );
95
+ }
96
+ check(
97
+ "review UI",
98
+ fs.existsSync(path.join(ROOT, "dist/web/index.html")),
99
+ fs.existsSync(path.join(ROOT, "dist/web/index.html"))
100
+ ? "React production build ready"
101
+ : "not built",
102
+ "Run npm run build in the skill folder.",
103
+ );
104
+ const ok = checks.every((c) => c.ok);
105
+ return {
106
+ ok,
107
+ checks,
108
+ next: ok
109
+ ? `Ready. Try the example with npm --prefix ${ROOT} run demo -- --no-open.`
110
+ : "Fix the failed checks, then run doctor again.",
111
+ };
112
+ }
113
+ async function context(command: Command, requireTask = false) {
114
+ const options = command.optsWithGlobals<{ repo: string; task?: string }>(),
115
+ [root, marker] = taskMarker(options.repo);
116
+ if (options.task && marker && options.task !== marker)
117
+ throw new Error(
118
+ `This worktree belongs to task ${marker}; run other tasks from the main repository.`,
119
+ );
120
+ const task = options.task || marker;
121
+ if (requireTask && !task)
122
+ throw new Error("Run this inside a task worktree, or pass --task NAME.");
123
+ return { root, task };
124
+ }
125
+ export async function feedback(
126
+ client: Client,
127
+ root: string,
128
+ task: string,
129
+ wait: number | null,
130
+ all: boolean,
131
+ output = emit,
132
+ ) {
133
+ const file = path.join(
134
+ path.dirname(serverHome(root)),
135
+ "agents",
136
+ task + ".cursor",
137
+ ),
138
+ after = all
139
+ ? 0
140
+ : Number(
141
+ fs.existsSync(file) ? fs.readFileSync(file, "utf8").trim() || 0 : 0,
142
+ ),
143
+ deadline = wait === null ? Infinity : Date.now() + wait * 1000;
144
+ let value: Feedback;
145
+ for (;;) {
146
+ try {
147
+ value = await client.get<Feedback>(
148
+ `/api/tasks/${encodeURIComponent(task)}/feedback?after=${after}`,
149
+ );
150
+ } catch (error) {
151
+ if (
152
+ error instanceof TypeError ||
153
+ (error as Error).name === "TimeoutError"
154
+ ) {
155
+ await sleep(500);
156
+ client = await connect(root);
157
+ continue;
158
+ }
159
+ throw error;
160
+ }
161
+ if (
162
+ value.feedback.some((i) => i.kind === "note") ||
163
+ !["reviewing", "building"].includes(value.state) ||
164
+ Date.now() >= deadline
165
+ )
166
+ break;
167
+ await sleep(Math.min(1000, deadline - Date.now()));
168
+ }
169
+ const info = await client.get<Info>(`/api/tasks/${encodeURIComponent(task)}`);
170
+ output({
171
+ ...value,
172
+ url: info.url,
173
+ ...(wait && Date.now() >= deadline ? { waited: wait } : {}),
174
+ });
175
+ if (!all) atomicWrite(file, String(value.cursor));
176
+ }
177
+ export async function main(argv = process.argv) {
178
+ const program = new Command()
179
+ .name("latex-review")
180
+ .description("Review LaTeX edits as selectable PDF diffs.")
181
+ .option(
182
+ "--repo <path>",
183
+ "Document repository or task worktree",
184
+ process.cwd(),
185
+ )
186
+ .option("--task <name>", "Task outside its worktree")
187
+ .exitOverride();
188
+ const collect = (value: string, previous: string[]) => [...previous, value];
189
+ program.command("doctor").action(async () => {
190
+ const result = await doctor();
191
+ emit(result);
192
+ if (!result.ok) process.exitCode = 1;
193
+ });
194
+ program
195
+ .command("start [name]")
196
+ .option(
197
+ "--section <selector>",
198
+ "Section number, title or label",
199
+ collect,
200
+ [],
201
+ )
202
+ .option("--file <path>", "Scoped file or folder", collect, [])
203
+ .option("--title <text>")
204
+ .option("--open")
205
+ .action(
206
+ async (
207
+ name: string | undefined,
208
+ options: {
209
+ section: string[];
210
+ file: string[];
211
+ title?: string;
212
+ open?: boolean;
213
+ },
214
+ command: Command,
215
+ ) => {
216
+ const { root, task } = await context(command);
217
+ if (task && name && name !== task)
218
+ throw new Error(
219
+ `This worktree already belongs to task ${task}. Start new tasks from the main repository.`,
220
+ );
221
+ const client = await connect(root),
222
+ info = task
223
+ ? await client.get<Info>(`/api/tasks/${encodeURIComponent(task)}`)
224
+ : await client.post<Info>("/api/tasks", {
225
+ ...(name ? { name } : {}),
226
+ ...(options.title ? { title: options.title } : {}),
227
+ sections: options.section,
228
+ files: options.file,
229
+ });
230
+ if (info.state === "editing")
231
+ info.next = `Edit only inside ${info.worktree}, then run \`publish\` from there.`;
232
+ if (options.open) openBrowser(info.url);
233
+ emit(info);
234
+ },
235
+ );
236
+ program
237
+ .command("publish")
238
+ .option("--no-wait")
239
+ .option("--timeout <seconds>", "Seconds to wait", "900")
240
+ .option("--open")
241
+ .action(
242
+ async (
243
+ options: { wait: boolean; timeout: string; open?: boolean },
244
+ command: Command,
245
+ ) => {
246
+ const { root, task } = await context(command, true),
247
+ client = await connect(root),
248
+ name = encodeURIComponent(task!),
249
+ timeout = Number(options.timeout);
250
+ if (!Number.isFinite(timeout) || timeout < 0)
251
+ throw new Error("--timeout must be nonnegative.");
252
+ const job = await client.post<{ job: string; status: string }>(
253
+ `/api/tasks/${name}/publish`,
254
+ );
255
+ if (!options.wait) {
256
+ emit({
257
+ task,
258
+ ...job,
259
+ next: "The round is building. Run `status` or `publish` again later.",
260
+ });
261
+ return;
262
+ }
263
+ const deadline = Date.now() + timeout * 1000;
264
+ let result: JobResult & { status: string; phase: string };
265
+ for (;;) {
266
+ result = await client.get(`/api/jobs/${job.job}`);
267
+ if (["done", "failed"].includes(result.status)) break;
268
+ if (Date.now() > deadline) {
269
+ emit({
270
+ task,
271
+ job: job.job,
272
+ status: "building",
273
+ phase: result.phase,
274
+ next: "Still building. Run `publish` again to keep waiting.",
275
+ });
276
+ return;
277
+ }
278
+ await sleep(500);
279
+ }
280
+ const info = await client.get<Info>(`/api/tasks/${name}`);
281
+ if (result.status === "failed")
282
+ throw new Failure({
283
+ error: result.error || "Publish failed.",
284
+ details: result.details ?? [],
285
+ log: result.log,
286
+ next: result.next,
287
+ });
288
+ emit({
289
+ task,
290
+ round: result.round,
291
+ changes: result.changes,
292
+ url: info.url,
293
+ viewing: info.viewing,
294
+ ...(result.outcome === "published"
295
+ ? {
296
+ kinds: result.kinds,
297
+ warnings: result.warnings,
298
+ next: `Share round ${result.round} with the user: ${info.url}. Keep this turn open and run \`feedback --wait\`; continue automatically when it returns.`,
299
+ }
300
+ : {
301
+ message:
302
+ result.outcome === "unchanged"
303
+ ? `Nothing new since round ${result.round}.`
304
+ : "The worktree matches the accepted text; there is nothing to review.",
305
+ next: info.next,
306
+ }),
307
+ });
308
+ if (options.open && !info.viewing) openBrowser(info.url);
309
+ },
310
+ );
311
+ program
312
+ .command("feedback")
313
+ .option(
314
+ "--wait [seconds]",
315
+ "Wait for completion or a message; optional timeout",
316
+ "0",
317
+ )
318
+ .option("--all")
319
+ .action(
320
+ async (
321
+ options: { wait: string | true; all?: boolean },
322
+ command: Command,
323
+ ) => {
324
+ const { root, task } = await context(command, true),
325
+ wait = options.wait === true ? null : Number(options.wait);
326
+ if (
327
+ wait !== null &&
328
+ (!Number.isFinite(wait) || wait < 0 || wait > 3600)
329
+ )
330
+ throw new Error("--wait must be between 0 and 3600 seconds.");
331
+ await feedback(
332
+ await connect(root),
333
+ root,
334
+ task!,
335
+ wait,
336
+ Boolean(options.all),
337
+ );
338
+ },
339
+ );
340
+ program
341
+ .command("accept [changes...]")
342
+ .description(
343
+ "Accept explicitly delegated changes without opening the browser",
344
+ )
345
+ .option("--all", "Accept every pending change in the latest round")
346
+ .option("--round <number>", "Require this exact round")
347
+ .action(
348
+ async (
349
+ changes: string[],
350
+ options: { all?: boolean; round?: string },
351
+ command: Command,
352
+ ) => {
353
+ if (options.all && changes.length)
354
+ throw new Error("Use --all or a list of change IDs.");
355
+ if (!options.all && !changes.length)
356
+ throw new Error("Specify change IDs or --all.");
357
+ const { root, task } = await context(command, true),
358
+ client = await connect(root),
359
+ route = `/api/tasks/${encodeURIComponent(task!)}`;
360
+ const state = await client.get<PageState>(route + "/state");
361
+ if (state.closed || !state.latest)
362
+ throw new Error("There is no open published round to accept.");
363
+ if (
364
+ options.round !== undefined &&
365
+ Number(options.round) !== state.latest
366
+ )
367
+ throw new Error(
368
+ "A different round is current. Read status before accepting it.",
369
+ );
370
+ const chosen = options.all
371
+ ? Object.keys(state.decisions).filter(
372
+ (id) => state.decisions[id] === "pending",
373
+ )
374
+ : changes;
375
+ const result = chosen.length
376
+ ? await client.post<PageState>(route + "/decide", {
377
+ action: "accept",
378
+ round: state.latest,
379
+ changes: chosen,
380
+ })
381
+ : state;
382
+ emit({
383
+ ...(await client.get<Info>(route)),
384
+ accepted: chosen,
385
+ changes: chosen.length,
386
+ undo: result.undo,
387
+ ...(chosen.length
388
+ ? {}
389
+ : { message: "No pending changes to accept." }),
390
+ });
391
+ },
392
+ );
393
+ program
394
+ .command("undo")
395
+ .description("Undo the latest decision, including a batch acceptance")
396
+ .action(async (_options, command: Command) => {
397
+ const { root, task } = await context(command, true),
398
+ client = await connect(root),
399
+ route = `/api/tasks/${encodeURIComponent(task!)}`;
400
+ const state = await client.get<PageState>(route + "/state");
401
+ if (!state.undo) throw new Error("There is no current decision to undo.");
402
+ const result = await client.post<PageState & { restored: string[] }>(
403
+ route + "/undo",
404
+ { id: state.undo.id },
405
+ );
406
+ emit({
407
+ ...(await client.get<Info>(route)),
408
+ restored: result.restored,
409
+ undo: result.undo,
410
+ });
411
+ });
412
+ program.command("status").action(async (_options, command: Command) => {
413
+ const { root, task } = await context(command, true);
414
+ emit(
415
+ await (
416
+ await connect(root)
417
+ ).get(`/api/tasks/${encodeURIComponent(task!)}`),
418
+ );
419
+ });
420
+ program.command("list").action(async (_options, command: Command) => {
421
+ const { root } = await context(command);
422
+ const result = await (
423
+ await connect(root)
424
+ ).get<{ tasks: unknown[] }>("/api/tasks");
425
+ emit({ tasks: result.tasks });
426
+ });
427
+ program
428
+ .command("finish")
429
+ .option("--force")
430
+ .action(async (options: { force?: boolean }, command: Command) => {
431
+ const { root, task } = await context(command, true),
432
+ result = await (
433
+ await connect(root)
434
+ ).post<{ changed_files: string[] }>(
435
+ `/api/tasks/${encodeURIComponent(task!)}/finish`,
436
+ { force: Boolean(options.force) },
437
+ );
438
+ emit({
439
+ ...result,
440
+ next: result.changed_files.length
441
+ ? `Accepted changes remain in ${root}, uncommitted. The task worktree was removed.`
442
+ : "The task worktree was removed. No changes were accepted.",
443
+ });
444
+ });
445
+ program
446
+ .command("server <action>")
447
+ .option("--force")
448
+ .action(
449
+ async (
450
+ action: string,
451
+ options: { force?: boolean },
452
+ command: Command,
453
+ ) => {
454
+ const { root } = await context(command);
455
+ if (action === "stop") {
456
+ const stopped = await stop(root, Boolean(options.force));
457
+ emit({ running: false, ...(stopped ? { stopped } : {}) });
458
+ } else if (action === "status") {
459
+ const client = await connect(root, false);
460
+ if (!client) emit({ running: false });
461
+ else {
462
+ const { csrf: _token, ...info } =
463
+ await client.get<ServerInfo>("/api/info");
464
+ emit({ running: true, ...info });
465
+ }
466
+ } else throw new Error("Use server status or server stop.");
467
+ },
468
+ );
469
+ program
470
+ .command("serve", { hidden: true })
471
+ .requiredOption("--root <path>")
472
+ .option("--port <number>", "", "0")
473
+ .option("--idle <seconds>", "", "43200")
474
+ .action(async (options: { root: string; port: string; idle: string }) => {
475
+ const { serve } = await import("./server.ts");
476
+ await serve(options.root, Number(options.port), Number(options.idle));
477
+ });
478
+ try {
479
+ await program.parseAsync(argv);
480
+ } catch (error) {
481
+ if (error instanceof CommanderError && error.exitCode === 0) return;
482
+ emit(
483
+ error instanceof Failure
484
+ ? error.payload
485
+ : { error: (error as Error).message },
486
+ );
487
+ process.exitCode = 1;
488
+ }
489
+ }
490
+ if (
491
+ process.argv[1] &&
492
+ import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
493
+ )
494
+ await main();