thurview 0.1.4

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 (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/bin/thurview.js +8 -0
  4. package/dist/cli.js +1101 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/diff.js +65 -0
  7. package/dist/diff.js.map +1 -0
  8. package/dist/document/compile.js +418 -0
  9. package/dist/document/compile.js.map +1 -0
  10. package/dist/document/parse.js +139 -0
  11. package/dist/document/parse.js.map +1 -0
  12. package/dist/document/schema.js +129 -0
  13. package/dist/document/schema.js.map +1 -0
  14. package/dist/flags.js +66 -0
  15. package/dist/flags.js.map +1 -0
  16. package/dist/git.js +206 -0
  17. package/dist/git.js.map +1 -0
  18. package/dist/highlight-theme.js +93 -0
  19. package/dist/highlight-theme.js.map +1 -0
  20. package/dist/highlight.js +163 -0
  21. package/dist/highlight.js.map +1 -0
  22. package/dist/main.js +3 -0
  23. package/dist/main.js.map +1 -0
  24. package/dist/server/server.js +387 -0
  25. package/dist/server/server.js.map +1 -0
  26. package/dist/store.js +100 -0
  27. package/dist/store.js.map +1 -0
  28. package/dist/symbols.js +192 -0
  29. package/dist/symbols.js.map +1 -0
  30. package/dist/theme.js +301 -0
  31. package/dist/theme.js.map +1 -0
  32. package/dist/threads.js +87 -0
  33. package/dist/threads.js.map +1 -0
  34. package/dist/ui/app.css +1415 -0
  35. package/dist/ui/app.js +2303 -0
  36. package/dist/ui/app.js.map +7 -0
  37. package/dist/ui/assets/fonts/inter-400.woff2 +0 -0
  38. package/dist/ui/assets/fonts/jetbrains-mono-400.woff2 +0 -0
  39. package/dist/ui/assets/fonts/press-start-2p-400.woff2 +0 -0
  40. package/dist/ui/assets/inter-400.woff2 +0 -0
  41. package/dist/ui/assets/jetbrains-mono-400.woff2 +0 -0
  42. package/dist/ui/assets/press-start-2p-400.woff2 +0 -0
  43. package/dist/ui/index.html +13 -0
  44. package/dist/version.js +6 -0
  45. package/dist/version.js.map +1 -0
  46. package/package.json +70 -0
  47. package/skills/thurview/SKILL.md +219 -0
  48. package/skills/thurview/references/components.md +120 -0
  49. package/skills/thurview/references/document-authoring.md +107 -0
  50. package/skills/thurview/references/lifecycle.md +88 -0
  51. package/skills/thurview/references/software-map.md +42 -0
  52. package/skills/thurview/references/theme.md +90 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1101 @@
1
+ import { runAxiCli, AxiError, installSessionStartHooks, sessionStartHookStatus, uninstallSessionStartHooks, } from "axi-sdk-js";
2
+ import { encode } from "@toon-format/toon";
3
+ import { spawn, execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { cp, mkdir, symlink, lstat, rm } from "node:fs/promises";
6
+ import { existsSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join, dirname, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import open from "open";
11
+ import * as g from "./git.js";
12
+ import { SCHEMA, home, newId, now, readReview, writeReview, listReviews, reviewsFor, reviewDir, revisionDir, readThreads, readText, writeText, writeJson, readJson, serverStateFile, deleteReview, } from "./store.js";
13
+ import { compileDocument, compileMap } from "./document/compile.js";
14
+ import { parseTheme, compileTheme } from "./theme.js";
15
+ import { registerTheme } from "./highlight.js";
16
+ import { replyThread, setThreadStatus, needsAgent } from "./threads.js";
17
+ import { startServer } from "./server/server.js";
18
+ import { parseFlags, helpFor, str, bool } from "./flags.js";
19
+ import { VERSION } from "./version.js";
20
+ const execFileP = promisify(execFile);
21
+ const HERE = dirname(fileURLToPath(import.meta.url));
22
+ const DESCRIPTION = "Guided, evidence-anchored reviews of agent-written code, read and answered in the browser";
23
+ function note(msg) {
24
+ process.stderr.write(msg + "\n");
25
+ }
26
+ function short(id) {
27
+ return id.slice(0, 8);
28
+ }
29
+ function truncate(text, max) {
30
+ return text.length > max
31
+ ? `${text.slice(0, max)}... (truncated, ${text.length} chars total)`
32
+ : text;
33
+ }
34
+ function targetLabel(t) {
35
+ if (t.type === "document")
36
+ return `document${t.quote ? ` "${truncate(t.quote, 40)}"` : ""}`;
37
+ if (t.type === "file") {
38
+ if (!t.line)
39
+ return `${t.path} (file)`;
40
+ const range = t.endLine && t.endLine > t.line ? `${t.line}-${t.endLine}` : String(t.line);
41
+ return `${t.path}:${range}${t.side === "base" ? " (base)" : ""}`;
42
+ }
43
+ if (t.type === "map")
44
+ return `map ${t.node}`;
45
+ return "review";
46
+ }
47
+ function lastMessage(t) {
48
+ const m = t.messages[t.messages.length - 1];
49
+ return m ? `${m.role}: ${truncate(m.body.replace(/\s+/g, " "), 120)}` : "";
50
+ }
51
+ async function worktreeOf(cwd) {
52
+ try {
53
+ return await g.repoRoot(cwd);
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ async function resolveReview(idOpt) {
60
+ if (idOpt) {
61
+ const r = await readReview(idOpt);
62
+ if (r)
63
+ return r;
64
+ const m = (await listReviews()).filter((x) => x.id.startsWith(idOpt));
65
+ if (m.length === 1)
66
+ return m[0];
67
+ throw new AxiError(m.length ? `review id ${idOpt} is ambiguous` : `review ${idOpt} not found`, "NOT_FOUND", ["Run `thurview info --all` to list reviews"]);
68
+ }
69
+ const worktree = await worktreeOf(process.cwd());
70
+ if (!worktree)
71
+ throw new AxiError("not inside a git repository", "VALIDATION_ERROR", [
72
+ "Run inside the source worktree, or pass --review <id>",
73
+ ]);
74
+ const mine = (await reviewsFor(worktree)).filter((r) => !r.dismissed && r.status !== "accepted" && r.status !== "rejected");
75
+ if (mine.length === 1)
76
+ return mine[0];
77
+ if (mine.length)
78
+ throw new AxiError("several active reviews for this worktree", "VALIDATION_ERROR", [
79
+ "Pass --review <id>; run `thurview info` to list them",
80
+ ]);
81
+ throw new AxiError("no active review for this worktree", "NOT_FOUND", [
82
+ "Run `thurview scaffold` to create one",
83
+ ]);
84
+ }
85
+ async function reviewRow(r, fields) {
86
+ const t = await readThreads(r.id);
87
+ const row = {
88
+ id: short(r.id),
89
+ title: r.title,
90
+ status: r.status,
91
+ rev: r.revision,
92
+ open: t.threads.filter((x) => x.status === "open").length,
93
+ needsAgent: t.threads.filter(needsAgent).length,
94
+ };
95
+ if (fields.has("all") || fields.has("binding"))
96
+ row["binding"] = r.binding.kind === "pr" ? `PR #${r.binding.name}` : r.binding.name;
97
+ if (fields.has("all") || fields.has("pins"))
98
+ row["pins"] = `${r.pins.base.slice(0, 12)}..${r.pins.head.slice(0, 12)}`;
99
+ if (fields.has("all") || fields.has("worktree"))
100
+ row["worktree"] = r.worktree;
101
+ if (fields.has("all") || fields.has("inSync"))
102
+ row["inSync"] = await g
103
+ .revParse(r.worktree, "HEAD")
104
+ .then((h) => h === r.pins.head)
105
+ .catch(() => null);
106
+ if (fields.has("all") || fields.has("dismissed"))
107
+ row["dismissed"] = r.dismissed;
108
+ if (fields.has("all") || fields.has("updatedAt"))
109
+ row["updatedAt"] = r.updatedAt;
110
+ if (fields.has("all") || fields.has("uuid"))
111
+ row["uuid"] = r.id;
112
+ return row;
113
+ }
114
+ function publicUrl(port, hosts, dns) {
115
+ const host = dns ? dns.replace(/\.$/, "") : (hosts.find((h) => h !== "127.0.0.1") ?? "localhost");
116
+ return `http://${host}:${port}`;
117
+ }
118
+ async function tailscaleDns() {
119
+ try {
120
+ const { stdout } = await execFileP("tailscale", ["status", "--json"], { timeout: 3000 });
121
+ return JSON.parse(stdout).Self?.DNSName || undefined;
122
+ }
123
+ catch {
124
+ return undefined;
125
+ }
126
+ }
127
+ async function serverAlive() {
128
+ const st = await readJson(serverStateFile());
129
+ if (!st)
130
+ return null;
131
+ try {
132
+ const r = await fetch(`http://127.0.0.1:${st.port}/api/health`, {
133
+ signal: AbortSignal.timeout(1500),
134
+ });
135
+ if (r.ok)
136
+ return st;
137
+ }
138
+ catch {
139
+ /* dead */
140
+ }
141
+ return null;
142
+ }
143
+ async function ensureServer() {
144
+ const alive = await serverAlive();
145
+ if (alive)
146
+ return alive;
147
+ const child = spawn(process.execPath, [join(HERE, "main.js"), "serve"], {
148
+ detached: true,
149
+ stdio: "ignore",
150
+ env: process.env,
151
+ });
152
+ child.unref();
153
+ for (let i = 0; i < 50; i++) {
154
+ await new Promise((r) => setTimeout(r, 200));
155
+ const st = await serverAlive();
156
+ if (st)
157
+ return st;
158
+ }
159
+ throw new AxiError("the review server did not start", "SERVER_ERROR", [
160
+ "Run `thurview serve` in a terminal to see why",
161
+ ]);
162
+ }
163
+ function reviewUrl(base, id, view) {
164
+ return `${base}/review/${id}${view ? `#/${view}` : ""}`;
165
+ }
166
+ async function guidanceFiles(repoRoot) {
167
+ return [join(home(), "THURVIEW.md"), join(repoRoot, "THURVIEW.md")].filter((p) => existsSync(p));
168
+ }
169
+ const TEMPLATE_MD = (title) => `# ${title}
170
+
171
+ **Summary**
172
+
173
+ - what changed, in one line per point
174
+
175
+ **Why**
176
+
177
+ What problem this change solves, in the author's words when available.
178
+
179
+ ## Design
180
+
181
+ Link claims to code: [the entry point](anchor:entry).
182
+
183
+ \`\`\`peek
184
+ entry
185
+ \`\`\`
186
+ `;
187
+ const TEMPLATE_DATA = `# Typed inputs for review.md. See the thurview skill reference for every shape.
188
+ actors: {}
189
+ anchors:
190
+ entry:
191
+ title: Entry point
192
+ peek: { file: README.md, from: 1, to: 1 }
193
+ stores: {}
194
+ `;
195
+ const TEMPLATE_MAP = `# Software map: people, systems, containers, components, code. Empty nodes = no map.
196
+ nodes: []
197
+ edges: []
198
+ `;
199
+ const TEMPLATE_THEME = `# Look of this review, derived from the reviewed project's own design system.
200
+ # Leave this file empty (or delete it) for the default skin. See the thurview skill
201
+ # reference (references/theme.md) for every key. Example:
202
+ #
203
+ # name: acme-web
204
+ # source: tailwind.config.ts, src/styles/tokens.css
205
+ # mode: light
206
+ # colors: { bg: "#ffffff", bg2: "#f6f7f9", fg: "#111827", fg2: "#4b5563", muted: "#9ca3af", line: "#e5e7eb", accent: "#2563eb", link: "#2563eb", ok: "#16a34a", warn: "#d97706", del: "#dc2626" }
207
+ # fonts: { display: "Inter, sans-serif", body: "Inter, sans-serif", mono: "'JetBrains Mono', monospace", stylesheets: ["https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap"] }
208
+ # shape: { radius: 8px, bevel: false, glow: false, scanlines: false, headingTransform: none }
209
+ # code: { keyword: "#7c3aed", string: "#15803d", function: "#b45309", variable: "#0369a1", comment: "#9ca3af" }
210
+ `;
211
+ // ---- commands ----
212
+ const SPECS = {
213
+ scaffold: {
214
+ description: "Create a review pinned to exact base and head commits, or re-pin one",
215
+ flags: {
216
+ pr: { kind: "string", help: "review a GitHub pull request (number or URL, needs gh)" },
217
+ base: { kind: "string", help: "base revision (default: trunk fork point)" },
218
+ head: { kind: "string", help: "head revision (default: current branch)" },
219
+ title: { kind: "string", help: "initial title" },
220
+ new: { kind: "boolean", help: "create another review even if one matches the binding" },
221
+ update: { kind: "boolean", help: "re-pin an existing review from its binding" },
222
+ review: { kind: "string", help: "review to update (id prefix)" },
223
+ },
224
+ examples: [
225
+ "thurview scaffold",
226
+ "thurview scaffold --pr 123",
227
+ "thurview scaffold --base main --head HEAD",
228
+ "thurview scaffold --update --review <id>",
229
+ ],
230
+ },
231
+ info: {
232
+ description: "Reviews bound to this worktree (or every review with --all)",
233
+ flags: {
234
+ all: { kind: "boolean", help: "every review, not only this worktree" },
235
+ fields: {
236
+ kind: "string",
237
+ help: "extra columns: binding,pins,worktree,inSync,dismissed,updatedAt,uuid or all",
238
+ },
239
+ },
240
+ examples: ["thurview info", "thurview info --all --fields pins,inSync"],
241
+ },
242
+ publish: {
243
+ description: "Validate review.md, data.yaml, map.yaml and theme.yaml against the pins and seal a revision",
244
+ flags: {
245
+ review: {
246
+ kind: "string",
247
+ help: "review id prefix (default: the active review for this worktree)",
248
+ },
249
+ view: { kind: "string", help: "tab the reader lands on: review, commits, files, map" },
250
+ open: { kind: "boolean", help: "open the browser after publishing" },
251
+ },
252
+ examples: ["thurview publish", "thurview publish --review <id> --view files --open"],
253
+ },
254
+ open: {
255
+ description: "Start the server if needed and open the review in the browser",
256
+ flags: {
257
+ review: { kind: "string", help: "review id prefix" },
258
+ view: { kind: "string", help: "review, commits, files or map" },
259
+ browser: {
260
+ kind: "boolean",
261
+ help: "launch a browser (use --no-browser to only print the url)",
262
+ },
263
+ },
264
+ examples: ["thurview open", "thurview open --review <id> --view map --no-browser"],
265
+ },
266
+ serve: {
267
+ description: "Run the review server in the foreground",
268
+ flags: {
269
+ port: { kind: "string", help: "port (default: random, recorded in ~/.thurview/server.json)" },
270
+ },
271
+ examples: ["thurview serve", "thurview serve --port 4900"],
272
+ },
273
+ stop: {
274
+ description: "Stop the background review server",
275
+ flags: {},
276
+ examples: ["thurview stop"],
277
+ },
278
+ wait: {
279
+ description: "Block until the reader needs the agent: a question, a submitted review, a decision or dismissal",
280
+ flags: {
281
+ review: { kind: "string", help: "review id prefix" },
282
+ timeout: { kind: "string", help: "seconds before giving up", default: "3600" },
283
+ },
284
+ examples: ["thurview wait", "thurview wait --review <id> --timeout 600"],
285
+ },
286
+ threads: {
287
+ description: "Read, answer and resolve reviewer threads",
288
+ args: "list|get <id>|reply <id> --body <text>|resolve <id>",
289
+ flags: {
290
+ review: { kind: "string", help: "review id prefix" },
291
+ open: { kind: "boolean", help: "list: only open threads" },
292
+ body: { kind: "string", help: "reply: the answer text" },
293
+ full: { kind: "boolean", help: "get: do not truncate message bodies" },
294
+ },
295
+ examples: [
296
+ "thurview threads list --open",
297
+ "thurview threads get <threadId>",
298
+ 'thurview threads reply <threadId> --body "<answer>"',
299
+ "thurview threads resolve <threadId>",
300
+ ],
301
+ },
302
+ delete: {
303
+ description: "Delete a review and everything stored for it (the code is untouched)",
304
+ flags: { review: { kind: "string", help: "review id prefix (required)" } },
305
+ examples: ["thurview delete --review <id>"],
306
+ },
307
+ setup: {
308
+ description: "Install session hooks (ambient context) and the agent skill",
309
+ args: "hooks|skill|status",
310
+ flags: {
311
+ scope: { kind: "string", help: "hooks: user or project", default: "user" },
312
+ remove: { kind: "boolean", help: "hooks: uninstall instead" },
313
+ targets: {
314
+ kind: "string",
315
+ help: "skill: comma list of claude,agents,cursor",
316
+ default: "claude,agents",
317
+ },
318
+ },
319
+ examples: [
320
+ "thurview setup hooks",
321
+ "thurview setup hooks --scope project",
322
+ "thurview setup skill",
323
+ "thurview setup status",
324
+ ],
325
+ },
326
+ skill: {
327
+ description: "Print the path of the bundled skill",
328
+ flags: {},
329
+ examples: ["thurview skill"],
330
+ },
331
+ };
332
+ function spec(name) {
333
+ return SPECS[name];
334
+ }
335
+ async function homeView() {
336
+ const worktree = await worktreeOf(process.cwd());
337
+ if (!worktree)
338
+ return {
339
+ reviews: "0 (not inside a git repository)",
340
+ help: [
341
+ "Run `thurview info --all` to list every review",
342
+ "Run `thurview scaffold` inside a git worktree to start one",
343
+ ],
344
+ };
345
+ const mine = (await reviewsFor(worktree)).filter((r) => !r.dismissed);
346
+ if (!mine.length)
347
+ return {
348
+ reviews: `0 reviews bound to ${worktree}`,
349
+ help: [
350
+ "Run `thurview scaffold` to create a review of the current branch",
351
+ "Run `thurview scaffold --pr <number>` for a pull request",
352
+ ],
353
+ };
354
+ const reviews = [];
355
+ for (const r of mine)
356
+ reviews.push(await reviewRow(r, new Set()));
357
+ const help = [
358
+ "Run `thurview info --fields all` for pins and sync state",
359
+ "Run `thurview threads list --open --review <id>` to read reader threads",
360
+ "Run `thurview open --review <id>` to open the browser",
361
+ ];
362
+ if (mine.some((r) => r.status === "draft"))
363
+ help.unshift("Run `thurview publish --review <id>` once review.md and data.yaml are written");
364
+ if (mine.some((r) => r.status === "awaiting-review"))
365
+ help.unshift("Run `thurview wait --review <id>` to block until the reader responds");
366
+ return { reviews, help };
367
+ }
368
+ const commands = {
369
+ async scaffold(args) {
370
+ const p = parseFlags("scaffold", args, spec("scaffold").flags);
371
+ const cwd = process.cwd();
372
+ const worktree = await worktreeOf(cwd);
373
+ if (!worktree)
374
+ throw new AxiError("not inside a git repository", "VALIDATION_ERROR", [
375
+ "Run `thurview scaffold` inside the source worktree",
376
+ ]);
377
+ const existing = bool(p, "update") || str(p, "review") ? await resolveReview(str(p, "review")) : null;
378
+ let binding;
379
+ let base;
380
+ let head;
381
+ let title = str(p, "title") ?? "";
382
+ const b = existing?.binding;
383
+ const pr = str(p, "pr");
384
+ if (pr || b?.kind === "pr") {
385
+ const ref = pr ?? b.name;
386
+ let info;
387
+ try {
388
+ const { stdout } = await execFileP("gh", ["pr", "view", ref, "--json", "number,title,url,baseRefName,headRefOid"], { cwd: worktree });
389
+ info = JSON.parse(stdout);
390
+ }
391
+ catch (e) {
392
+ throw new AxiError(`could not read pull request ${ref}: ${e.message.split("\n")[0]}`, "PR_ERROR", [
393
+ "Check `gh auth status` and the PR number",
394
+ "Or run `thurview scaffold --base <ref> --head <ref>`",
395
+ ]);
396
+ }
397
+ await g
398
+ .fetch(worktree, "origin", `refs/pull/${info.number}/head`, `refs/heads/${info.baseRefName}`)
399
+ .catch(() => { });
400
+ head = info.headRefOid;
401
+ let baseRef = `origin/${info.baseRefName}`;
402
+ try {
403
+ await g.revParse(worktree, baseRef);
404
+ }
405
+ catch {
406
+ baseRef = info.baseRefName;
407
+ }
408
+ base = await g.mergeBase(worktree, baseRef, head);
409
+ binding = { kind: "pr", name: String(info.number), url: info.url };
410
+ title ||= info.title;
411
+ }
412
+ else if (str(p, "base") || str(p, "head") || b?.kind === "range") {
413
+ const [bb, hh] = b?.kind === "range" && !str(p, "base") && !str(p, "head")
414
+ ? b.name.split("..")
415
+ : [str(p, "base"), str(p, "head")];
416
+ try {
417
+ head = await g.revParse(worktree, hh ?? "HEAD");
418
+ base = bb
419
+ ? await g.revParse(worktree, bb)
420
+ : await g.mergeBase(worktree, await g.trunkRef(worktree), head);
421
+ }
422
+ catch (e) {
423
+ throw new AxiError(e.message, "VALIDATION_ERROR", [
424
+ "Pass resolvable refs: `thurview scaffold --base <ref> --head <ref>`",
425
+ ]);
426
+ }
427
+ binding = { kind: "range", name: `${base.slice(0, 12)}..${head.slice(0, 12)}` };
428
+ }
429
+ else {
430
+ const branch = b?.kind === "branch" ? b.name : await g.currentBranch(worktree);
431
+ if (!branch)
432
+ throw new AxiError("detached HEAD", "VALIDATION_ERROR", [
433
+ "Run `thurview scaffold --head <ref>` or `--base <ref> --head <ref>`",
434
+ ]);
435
+ await g.fetch(worktree).catch(() => note("note: git fetch failed, using local refs"));
436
+ head = await g.revParse(worktree, branch);
437
+ let trunk;
438
+ try {
439
+ trunk = await g.trunkRef(worktree);
440
+ }
441
+ catch (e) {
442
+ throw new AxiError(e.message, "VALIDATION_ERROR", [
443
+ "Run `thurview scaffold --base <ref> --head <ref>`",
444
+ ]);
445
+ }
446
+ base = await g.mergeBase(worktree, trunk, head);
447
+ binding = { kind: "branch", name: branch };
448
+ title ||= branch;
449
+ if (base === head)
450
+ note(`note: ${branch} has no commits past ${trunk}; this is an architecture review of ${head.slice(0, 12)}`);
451
+ }
452
+ let review;
453
+ let reused = false;
454
+ if (existing) {
455
+ review = existing;
456
+ review.pins = { base, head };
457
+ review.binding = binding;
458
+ if (str(p, "title"))
459
+ review.title = str(p, "title");
460
+ await writeReview(review);
461
+ }
462
+ else {
463
+ const match = bool(p, "new")
464
+ ? []
465
+ : (await reviewsFor(worktree)).filter((r) => r.binding.kind === binding.kind &&
466
+ r.binding.name === binding.name &&
467
+ r.status !== "accepted" &&
468
+ r.status !== "rejected");
469
+ if (match.length) {
470
+ review = match[0];
471
+ reused = true;
472
+ review.pins = { base, head };
473
+ await writeReview(review);
474
+ }
475
+ else {
476
+ const id = newId();
477
+ review = {
478
+ schema: SCHEMA,
479
+ id,
480
+ title: title || "Review",
481
+ worktree,
482
+ repoRoot: worktree,
483
+ binding,
484
+ pins: { base, head },
485
+ status: "draft",
486
+ revision: 0,
487
+ dismissed: false,
488
+ createdAt: now(),
489
+ updatedAt: now(),
490
+ };
491
+ await mkdir(reviewDir(id), { recursive: true });
492
+ await writeText(join(reviewDir(id), "review.md"), TEMPLATE_MD(review.title));
493
+ await writeText(join(reviewDir(id), "data.yaml"), TEMPLATE_DATA);
494
+ await writeText(join(reviewDir(id), "map.yaml"), TEMPLATE_MAP);
495
+ await writeText(join(reviewDir(id), "theme.yaml"), TEMPLATE_THEME);
496
+ await writeReview(review);
497
+ }
498
+ }
499
+ const stat = await g.shortStat(worktree, base, head);
500
+ const dir = reviewDir(review.id);
501
+ return {
502
+ review: {
503
+ id: short(review.id),
504
+ uuid: review.id,
505
+ title: review.title,
506
+ status: review.status,
507
+ rev: review.revision,
508
+ binding: binding.kind === "pr" ? `PR #${binding.name}` : binding.name,
509
+ base,
510
+ head,
511
+ worktree,
512
+ reused,
513
+ dir,
514
+ },
515
+ files: {
516
+ document: join(dir, "review.md"),
517
+ data: join(dir, "data.yaml"),
518
+ map: join(dir, "map.yaml"),
519
+ theme: join(dir, "theme.yaml"),
520
+ },
521
+ change: stat,
522
+ guidance: await guidanceFiles(worktree),
523
+ help: [
524
+ `Edit ${join(dir, "review.md")} and data.yaml, then run \`thurview publish --review ${short(review.id)}\``,
525
+ stat.additions + stat.deletions < 300
526
+ ? `Small change: run \`thurview publish --review ${short(review.id)} --view files --open\` now, then write the document`
527
+ : `Run \`git diff ${base.slice(0, 12)} ${head.slice(0, 12)}\` in ${worktree} to study the change`,
528
+ ],
529
+ };
530
+ },
531
+ async info(args) {
532
+ const p = parseFlags("info", args, spec("info").flags);
533
+ const fields = new Set((str(p, "fields") ?? "").split(",").filter(Boolean));
534
+ const worktree = await worktreeOf(process.cwd());
535
+ if (!bool(p, "all") && !worktree)
536
+ throw new AxiError("not inside a git repository", "VALIDATION_ERROR", [
537
+ "Run `thurview info --all`",
538
+ ]);
539
+ const list = bool(p, "all") || !worktree ? await listReviews() : await reviewsFor(worktree);
540
+ if (!list.length)
541
+ return {
542
+ reviews: bool(p, "all") ? "0 reviews stored" : `0 reviews bound to ${worktree}`,
543
+ help: ["Run `thurview scaffold` to create one"],
544
+ };
545
+ const reviews = [];
546
+ for (const r of list)
547
+ reviews.push(await reviewRow(r, fields));
548
+ return {
549
+ count: reviews.length,
550
+ reviews,
551
+ help: [
552
+ "Run `thurview threads list --review <id>` for reader threads",
553
+ "Run `thurview open --review <id>` to open the browser",
554
+ ],
555
+ };
556
+ },
557
+ async publish(args) {
558
+ const p = parseFlags("publish", args, spec("publish").flags);
559
+ const review = await resolveReview(str(p, "review"));
560
+ if (review.status === "accepted" || review.status === "rejected")
561
+ throw new AxiError(`review is ${review.status} and cannot be republished`, "TERMINAL", [
562
+ "Run `thurview scaffold --new` for another review of the same change",
563
+ ]);
564
+ const dir = reviewDir(review.id);
565
+ const [reviewMd, dataYaml, mapYaml, themeYaml] = await Promise.all([
566
+ readText(join(dir, "review.md")),
567
+ readText(join(dir, "data.yaml")),
568
+ readText(join(dir, "map.yaml")),
569
+ readText(join(dir, "theme.yaml")),
570
+ ]);
571
+ if (reviewMd === null)
572
+ throw new AxiError("review.md is missing", "VALIDATION_ERROR", [
573
+ `Write ${join(dir, "review.md")}`,
574
+ ]);
575
+ const threads = await readThreads(review.id);
576
+ if (review.revision > 0) {
577
+ const openC = threads.threads.filter((t) => t.kind === "comment" && t.submitted && t.status === "open");
578
+ if (openC.length) {
579
+ throw new AxiError(`${openC.length} open comment thread${openC.length > 1 ? "s" : ""} block${openC.length > 1 ? "" : "s"} republish: ${openC.map((t) => t.id).join(", ")}`, "THREADS_OPEN", [
580
+ `Run \`thurview threads list --open --review ${short(review.id)}\``,
581
+ `Run \`thurview threads resolve <threadId> --review ${short(review.id)}\` after addressing each`,
582
+ ]);
583
+ }
584
+ }
585
+ const diags = [];
586
+ let theme = null;
587
+ if (themeYaml) {
588
+ const t = parseTheme(themeYaml);
589
+ diags.push(...t.diagnostics);
590
+ if (t.theme) {
591
+ theme = compileTheme(t.theme, (p) => `/api/reviews/${review.id}/blob?path=${encodeURIComponent(p)}`);
592
+ for (const f of t.theme.fonts.files)
593
+ if (!(await g.fileExists(review.worktree, review.pins.head, f.path)))
594
+ diags.push({
595
+ level: "error",
596
+ file: "theme.yaml",
597
+ message: `fonts.files: ${f.path} does not exist at the pinned head commit`,
598
+ });
599
+ }
600
+ }
601
+ const themeName = theme ? await registerTheme(theme.shiki) : undefined;
602
+ const doc = await compileDocument({
603
+ cwd: review.worktree,
604
+ pins: review.pins,
605
+ reviewMd,
606
+ dataYaml: dataYaml ?? "",
607
+ ...(themeName ? { themeName } : {}),
608
+ });
609
+ diags.push(...doc.diagnostics);
610
+ let map = null;
611
+ if (mapYaml && /^\s*nodes:\s*(?!\[\s*\])\S/m.test(mapYaml)) {
612
+ const m = await compileMap({
613
+ cwd: review.worktree,
614
+ pins: review.pins,
615
+ mapYaml,
616
+ anchors: doc.anchors,
617
+ });
618
+ diags.push(...m.diagnostics);
619
+ map = m.map;
620
+ }
621
+ const rows = diags.map((d) => ({
622
+ level: d.level,
623
+ file: d.file,
624
+ line: d.line ?? "",
625
+ message: d.message,
626
+ }));
627
+ const errors = rows.filter((d) => d.level === "error").length;
628
+ if (!doc.document || errors) {
629
+ process.exitCode = 1;
630
+ return {
631
+ error: `publish failed: ${errors} error${errors > 1 ? "s" : ""}, ${rows.length - errors} warning${rows.length - errors === 1 ? "" : "s"}`,
632
+ code: "PUBLISH_FAILED",
633
+ diagnostics: rows,
634
+ help: [
635
+ `Fix each error in ${dir} and run \`thurview publish --review ${short(review.id)}\` again`,
636
+ ],
637
+ };
638
+ }
639
+ const warnings = [];
640
+ if (review.binding.kind === "branch") {
641
+ const tip = await g.revParse(review.worktree, review.binding.name).catch(() => null);
642
+ if (tip && tip !== review.pins.head)
643
+ warnings.push(`branch ${review.binding.name} moved past the pinned head; run \`thurview scaffold --update --review ${short(review.id)}\` to re-pin`);
644
+ }
645
+ const n = review.revision + 1;
646
+ const rdir = revisionDir(review.id, n);
647
+ await mkdir(rdir, { recursive: true });
648
+ await cp(join(dir, "review.md"), join(rdir, "review.md"));
649
+ if (dataYaml !== null)
650
+ await cp(join(dir, "data.yaml"), join(rdir, "data.yaml"));
651
+ if (mapYaml !== null)
652
+ await cp(join(dir, "map.yaml"), join(rdir, "map.yaml"));
653
+ const changes = await g.changedFiles(review.worktree, review.pins.base, review.pins.head);
654
+ await writeJson(join(rdir, "document.json"), doc.document);
655
+ await writeJson(join(rdir, "map.json"), map);
656
+ await writeJson(join(rdir, "changes.json"), changes);
657
+ if (themeYaml !== null)
658
+ await cp(join(dir, "theme.yaml"), join(rdir, "theme.yaml"));
659
+ await writeJson(join(rdir, "theme.json"), theme);
660
+ await writeJson(join(rdir, "meta.json"), {
661
+ revision: n,
662
+ at: now(),
663
+ title: doc.document.title,
664
+ pins: review.pins,
665
+ hasMap: !!map,
666
+ theme: theme?.name ?? "default",
667
+ });
668
+ review.title = doc.document.title;
669
+ review.revision = n;
670
+ review.status = "awaiting-review";
671
+ review.dismissed = false;
672
+ await writeReview(review);
673
+ let st = await serverAlive();
674
+ let url = null;
675
+ if (bool(p, "open"))
676
+ st = await ensureServer();
677
+ if (st)
678
+ url = reviewUrl(publicUrl(st.port, st.hosts, await tailscaleDns()), review.id, str(p, "view"));
679
+ if (bool(p, "open") && url)
680
+ await open(url).catch(() => { });
681
+ const out = {
682
+ published: {
683
+ id: short(review.id),
684
+ rev: n,
685
+ title: review.title,
686
+ status: review.status,
687
+ map: !!map,
688
+ theme: theme?.name ?? "default",
689
+ url: url ?? "(server not running)",
690
+ },
691
+ };
692
+ if (rows.length)
693
+ out["diagnostics"] = rows;
694
+ if (warnings.length)
695
+ out["warnings"] = warnings;
696
+ out["help"] = [
697
+ url
698
+ ? `Give the reader ${url}`
699
+ : `Run \`thurview open --review ${short(review.id)}\` to start the server and get the url`,
700
+ `Run \`thurview wait --review ${short(review.id)}\` to block until the reader responds`,
701
+ ];
702
+ return out;
703
+ },
704
+ async open(args) {
705
+ const p = parseFlags("open", args, spec("open").flags);
706
+ const review = await resolveReview(str(p, "review"));
707
+ const s = await ensureServer();
708
+ const url = reviewUrl(publicUrl(s.port, s.hosts, await tailscaleDns()), review.id, str(p, "view"));
709
+ const launch = p.flags["browser"] !== false;
710
+ if (launch)
711
+ await open(url).catch(() => { });
712
+ return {
713
+ opened: {
714
+ id: short(review.id),
715
+ url,
716
+ local: reviewUrl(`http://127.0.0.1:${s.port}`, review.id, str(p, "view")),
717
+ browser: launch,
718
+ },
719
+ help: [
720
+ `Run \`thurview wait --review ${short(review.id)}\` to block until the reader responds`,
721
+ ],
722
+ };
723
+ },
724
+ async serve(args) {
725
+ const p = parseFlags("serve", args, spec("serve").flags);
726
+ const port = str(p, "port");
727
+ const s = await startServer(port ? { port: Number(port) } : {});
728
+ note(`thurview server on ${s.hosts.map((h) => `http://${h}:${s.port}`).join(" ")}`);
729
+ await new Promise(() => { });
730
+ return {};
731
+ },
732
+ async stop(args) {
733
+ parseFlags("stop", args, {});
734
+ const st = await readJson(serverStateFile());
735
+ if (!st)
736
+ return { server: "not running (no-op)" };
737
+ try {
738
+ process.kill(st.pid, "SIGTERM");
739
+ }
740
+ catch {
741
+ await rm(serverStateFile(), { force: true });
742
+ return { server: "not running (stale state removed, no-op)" };
743
+ }
744
+ await rm(serverStateFile(), { force: true });
745
+ return { server: `stopped pid ${st.pid}` };
746
+ },
747
+ async wait(args) {
748
+ const p = parseFlags("wait", args, spec("wait").flags);
749
+ const review = await resolveReview(str(p, "review"));
750
+ const seconds = Number(str(p, "timeout"));
751
+ if (!Number.isFinite(seconds) || seconds <= 0)
752
+ throw new AxiError("--timeout must be a positive number of seconds", "VALIDATION_ERROR", [
753
+ "thurview wait --timeout 600",
754
+ ]);
755
+ const deadline = Date.now() + seconds * 1000;
756
+ const id = short(review.id);
757
+ const rows = (ts) => ts.map((t) => ({
758
+ id: t.id,
759
+ kind: t.kind,
760
+ target: targetLabel(t.target),
761
+ last: lastMessage(t),
762
+ }));
763
+ while (Date.now() < deadline) {
764
+ const r = await readReview(review.id);
765
+ if (!r)
766
+ return {
767
+ wait: { reason: "review-deleted", id },
768
+ help: ["Stop the loop; the review no longer exists"],
769
+ };
770
+ const t = await readThreads(review.id);
771
+ const last = t.decisions[t.decisions.length - 1];
772
+ if (r.status === "awaiting-agent-updates") {
773
+ const need = t.threads.filter(needsAgent);
774
+ return {
775
+ wait: {
776
+ reason: "awaiting-agent-updates",
777
+ id,
778
+ status: r.status,
779
+ decision: last
780
+ ? `${last.decision}${last.body ? `: ${truncate(last.body, 300)}` : ""}`
781
+ : "",
782
+ },
783
+ count: need.length,
784
+ threads: rows(need),
785
+ help: [
786
+ `Run \`thurview threads get <threadId> --review ${id}\` for the full thread`,
787
+ `Run \`thurview threads resolve <threadId> --review ${id}\` after addressing each`,
788
+ `Run \`thurview publish --review ${id}\` when every open comment is resolved`,
789
+ ],
790
+ };
791
+ }
792
+ if (r.status === "accepted" || r.status === "rejected")
793
+ return {
794
+ wait: {
795
+ reason: r.status,
796
+ id,
797
+ status: r.status,
798
+ decision: last
799
+ ? `${last.decision}${last.body ? `: ${truncate(last.body, 300)}` : ""}`
800
+ : "",
801
+ },
802
+ help: ["The review is complete; report it and stop the loop"],
803
+ };
804
+ if (r.dismissed)
805
+ return {
806
+ wait: { reason: "review-dismissed", id, status: r.status },
807
+ help: ["Stop the loop; the reader dismissed the review"],
808
+ };
809
+ const asks = t.threads.filter((x) => needsAgent(x) && x.mode === "ask");
810
+ if (asks.length)
811
+ return {
812
+ wait: { reason: "question", id, status: r.status },
813
+ count: asks.length,
814
+ threads: rows(asks),
815
+ help: [
816
+ `Run \`thurview threads reply <threadId> --review ${id} --body "<answer>"\``,
817
+ `Run \`thurview wait --review ${id}\` again afterwards`,
818
+ ],
819
+ };
820
+ await new Promise((res) => setTimeout(res, 700));
821
+ }
822
+ throw new AxiError(`no reader activity within ${seconds}s`, "TIMEOUT", [
823
+ `Run \`thurview wait --review ${id}\` again, or report that the reader has not responded`,
824
+ ]);
825
+ },
826
+ async threads(args) {
827
+ const sub = args[0];
828
+ const rest = args.slice(1);
829
+ const s = spec("threads").flags;
830
+ const help = [
831
+ "thurview threads list [--open]",
832
+ "thurview threads get <threadId>",
833
+ 'thurview threads reply <threadId> --body "<text>"',
834
+ "thurview threads resolve <threadId>",
835
+ ];
836
+ if (!sub || !["list", "get", "reply", "resolve"].includes(sub))
837
+ throw new AxiError(`unknown threads command${sub ? ` ${sub}` : ""}`, "VALIDATION_ERROR", help);
838
+ if (sub === "list") {
839
+ const p = parseFlags("threads list", rest, { review: s["review"], open: s["open"] });
840
+ const review = await resolveReview(str(p, "review"));
841
+ const t = await readThreads(review.id);
842
+ const list = t.threads.filter((x) => !bool(p, "open") || x.status === "open");
843
+ const id = short(review.id);
844
+ if (!list.length)
845
+ return {
846
+ threads: `0 ${bool(p, "open") ? "open " : ""}threads on review ${id}`,
847
+ decisions: t.decisions.map((d) => `${d.decision} rev ${d.revision}${d.body ? `: ${truncate(d.body, 200)}` : ""}`),
848
+ };
849
+ return {
850
+ count: `${list.length} of ${t.threads.length} total, ${t.threads.filter(needsAgent).length} need the agent`,
851
+ threads: list.map((x) => ({
852
+ id: x.id,
853
+ kind: x.kind,
854
+ status: x.status,
855
+ needsAgent: needsAgent(x),
856
+ target: targetLabel(x.target),
857
+ last: lastMessage(x),
858
+ })),
859
+ decisions: t.decisions.map((d) => `${d.decision} rev ${d.revision}${d.body ? `: ${truncate(d.body, 200)}` : ""}`),
860
+ help: [
861
+ `Run \`thurview threads get <threadId> --review ${id}\` for the full thread`,
862
+ `Run \`thurview threads reply <threadId> --review ${id} --body "<text>"\``,
863
+ ],
864
+ };
865
+ }
866
+ const p = parseFlags(`threads ${sub}`, rest, sub === "reply"
867
+ ? { review: s["review"], body: s["body"] }
868
+ : sub === "get"
869
+ ? { review: s["review"], full: s["full"] }
870
+ : { review: s["review"] }, 1);
871
+ const threadId = p.positional[0];
872
+ if (!threadId)
873
+ throw new AxiError(`threads ${sub} needs a thread id`, "VALIDATION_ERROR", [
874
+ `thurview threads ${sub} <threadId>`,
875
+ "Run `thurview threads list` to see ids",
876
+ ]);
877
+ const review = await resolveReview(str(p, "review"));
878
+ const id = short(review.id);
879
+ if (sub === "get") {
880
+ const th = (await readThreads(review.id)).threads.find((x) => x.id === threadId);
881
+ if (!th)
882
+ throw new AxiError(`thread ${threadId} not found`, "NOT_FOUND", [
883
+ `Run \`thurview threads list --review ${id}\``,
884
+ ]);
885
+ const full = bool(p, "full");
886
+ const out = {
887
+ thread: {
888
+ id: th.id,
889
+ kind: th.kind,
890
+ mode: th.mode,
891
+ status: th.status,
892
+ submitted: th.submitted,
893
+ needsAgent: needsAgent(th),
894
+ target: targetLabel(th.target),
895
+ rev: th.revision,
896
+ },
897
+ messages: th.messages.map((m) => ({
898
+ role: m.role,
899
+ at: m.at,
900
+ body: full ? m.body : truncate(m.body, 1500),
901
+ })),
902
+ };
903
+ if (!full && th.messages.some((m) => m.body.length > 1500))
904
+ out["help"] = [
905
+ `Run \`thurview threads get ${th.id} --review ${id} --full\` for complete bodies`,
906
+ ];
907
+ return out;
908
+ }
909
+ if (sub === "reply") {
910
+ const body = (str(p, "body") ?? "").trim();
911
+ if (!body)
912
+ throw new AxiError("--body is required", "VALIDATION_ERROR", [
913
+ `thurview threads reply ${threadId} --body "<text>"`,
914
+ ]);
915
+ let th;
916
+ try {
917
+ th = await replyThread(review.id, threadId, "agent", body);
918
+ }
919
+ catch {
920
+ throw new AxiError(`thread ${threadId} not found`, "NOT_FOUND", [
921
+ `Run \`thurview threads list --review ${id}\``,
922
+ ]);
923
+ }
924
+ return {
925
+ thread: { id: th.id, status: th.status, messages: th.messages.length },
926
+ help: [
927
+ th.kind === "comment"
928
+ ? `Run \`thurview threads resolve ${th.id} --review ${id}\` once the change is present`
929
+ : `Run \`thurview wait --review ${id}\` to wait for the next question`,
930
+ ],
931
+ };
932
+ }
933
+ const existing = (await readThreads(review.id)).threads.find((x) => x.id === threadId);
934
+ if (!existing)
935
+ throw new AxiError(`thread ${threadId} not found`, "NOT_FOUND", [
936
+ `Run \`thurview threads list --review ${id}\``,
937
+ ]);
938
+ if (existing.status === "resolved")
939
+ return { thread: `${threadId} already resolved (no-op)` };
940
+ await setThreadStatus(review.id, threadId, "resolved");
941
+ const left = (await readThreads(review.id)).threads.filter((x) => x.kind === "comment" && x.submitted && x.status === "open").length;
942
+ return {
943
+ thread: `${threadId} resolved`,
944
+ openComments: left,
945
+ help: [
946
+ left
947
+ ? `Run \`thurview threads list --open --review ${id}\` for the ${left} left`
948
+ : `Run \`thurview publish --review ${id}\` to seal the next revision`,
949
+ ],
950
+ };
951
+ },
952
+ async delete(args) {
953
+ const p = parseFlags("delete", args, spec("delete").flags);
954
+ const idOpt = str(p, "review");
955
+ if (!idOpt)
956
+ throw new AxiError("--review is required", "VALIDATION_ERROR", [
957
+ "thurview delete --review <id>",
958
+ ]);
959
+ const review = await resolveReview(idOpt);
960
+ await deleteReview(review.id);
961
+ return { deleted: short(review.id) };
962
+ },
963
+ async setup(args) {
964
+ const sub = args[0];
965
+ const s = spec("setup").flags;
966
+ const usage = [
967
+ "thurview setup hooks [--scope user|project] [--remove]",
968
+ "thurview setup skill [--targets claude,agents,cursor]",
969
+ "thurview setup status",
970
+ ];
971
+ if (!sub || !["hooks", "skill", "status"].includes(sub))
972
+ throw new AxiError(`unknown setup command${sub ? ` ${sub}` : ""}`, "VALIDATION_ERROR", usage);
973
+ const identity = { marker: "thurview", binaryNames: ["thurview"] };
974
+ if (sub === "hooks") {
975
+ const p = parseFlags("setup hooks", args.slice(1), {
976
+ scope: s["scope"],
977
+ remove: s["remove"],
978
+ });
979
+ const scope = str(p, "scope") === "project" ? "project" : "user";
980
+ if (bool(p, "remove")) {
981
+ await uninstallSessionStartHooks({ ...identity, scope });
982
+ return {
983
+ hooks: `removed at ${scope} scope`,
984
+ help: ["Run `thurview setup status` to confirm"],
985
+ };
986
+ }
987
+ await installSessionStartHooks({ ...identity, scope });
988
+ const st = sessionStartHookStatus({ ...identity, scope });
989
+ return {
990
+ hooks: {
991
+ scope,
992
+ claude: st.claude.installed ? st.claude.path : "not installed",
993
+ codex: st.codex.installed ? st.codex.path : "not installed",
994
+ opencode: st.opencode.installed ? st.opencode.path : "not installed",
995
+ },
996
+ help: [
997
+ "Each new agent session now starts with `thurview` output for its working directory",
998
+ "Run `thurview setup skill` for on-demand guidance too",
999
+ ],
1000
+ };
1001
+ }
1002
+ if (sub === "skill") {
1003
+ const p = parseFlags("setup skill", args.slice(1), { targets: s["targets"] });
1004
+ const src = resolve(HERE, "..", "skills", "thurview");
1005
+ const dirs = {
1006
+ claude: join(homedir(), ".claude", "skills"),
1007
+ agents: join(homedir(), ".agents", "skills"),
1008
+ cursor: join(homedir(), ".cursor", "skills"),
1009
+ };
1010
+ const installed = {};
1011
+ for (const t of (str(p, "targets") ?? "")
1012
+ .split(",")
1013
+ .map((x) => x.trim())
1014
+ .filter(Boolean)) {
1015
+ const d = dirs[t];
1016
+ if (!d)
1017
+ throw new AxiError(`unknown skill target ${t}`, "VALIDATION_ERROR", [
1018
+ "thurview setup skill --targets claude,agents,cursor",
1019
+ ]);
1020
+ await mkdir(d, { recursive: true });
1021
+ const dst = join(d, "thurview");
1022
+ try {
1023
+ const stt = await lstat(dst);
1024
+ if (stt.isSymbolicLink())
1025
+ await rm(dst);
1026
+ else
1027
+ throw new AxiError(`${dst} exists and is not a symlink`, "CONFLICT", [
1028
+ `Remove ${dst} and run \`thurview setup skill\` again`,
1029
+ ]);
1030
+ }
1031
+ catch (e) {
1032
+ if (e instanceof AxiError)
1033
+ throw e;
1034
+ }
1035
+ await symlink(src, dst, "dir");
1036
+ installed[t] = dst;
1037
+ }
1038
+ return {
1039
+ skill: installed,
1040
+ help: [
1041
+ "Invoke it as /thurview in Claude Code, or by name in other agents",
1042
+ "Run `thurview setup hooks` for ambient context at session start",
1043
+ ],
1044
+ };
1045
+ }
1046
+ parseFlags("setup status", args.slice(1), {});
1047
+ const st = sessionStartHookStatus({ ...identity, scope: "user" });
1048
+ const skill = {};
1049
+ for (const [t, d] of Object.entries({
1050
+ claude: join(homedir(), ".claude", "skills", "thurview"),
1051
+ agents: join(homedir(), ".agents", "skills", "thurview"),
1052
+ }))
1053
+ skill[t] = existsSync(d) ? d : "not installed";
1054
+ return {
1055
+ hooks: {
1056
+ claude: st.claude.installed ? st.claude.path : "not installed",
1057
+ codex: st.codex.installed ? st.codex.path : "not installed",
1058
+ opencode: st.opencode.installed ? st.opencode.path : "not installed",
1059
+ },
1060
+ skill,
1061
+ help: ["Run `thurview setup hooks` or `thurview setup skill` to install what is missing"],
1062
+ };
1063
+ },
1064
+ async skill(args) {
1065
+ parseFlags("skill", args, {});
1066
+ return { skill: resolve(HERE, "..", "skills", "thurview", "SKILL.md") };
1067
+ },
1068
+ };
1069
+ function topLevelHelp() {
1070
+ const cmds = {};
1071
+ for (const [k, v] of Object.entries(SPECS))
1072
+ cmds[k + (v.args ? ` ${v.args}` : "")] = v.description;
1073
+ return (encode({
1074
+ bin: "thurview",
1075
+ description: DESCRIPTION,
1076
+ commands: cmds,
1077
+ examples: [
1078
+ "thurview",
1079
+ "thurview scaffold",
1080
+ "thurview publish --view files --open",
1081
+ "thurview wait",
1082
+ 'thurview threads reply <threadId> --body "<answer>"',
1083
+ ],
1084
+ help: ["Run `thurview <command> --help` for that command's flags"],
1085
+ }) + "\n");
1086
+ }
1087
+ export async function main(argv = process.argv.slice(2)) {
1088
+ await runAxiCli({
1089
+ description: DESCRIPTION,
1090
+ version: VERSION,
1091
+ argv,
1092
+ topLevelHelp: topLevelHelp(),
1093
+ home: homeView,
1094
+ commands,
1095
+ getCommandHelp: (command) => {
1096
+ const s = SPECS[command];
1097
+ return s ? encode(helpFor(command, s.description, s.flags, s.examples, s.args)) + "\n" : null;
1098
+ },
1099
+ });
1100
+ }
1101
+ //# sourceMappingURL=cli.js.map