@lovinka/vitrinka 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,2543 @@
1
+ #!/usr/bin/env node
2
+ // cli.ts — the unified vitrinka CLI. Maintains `.screenshots/manifest.json` + a self-contained,
3
+ // clickable `index.html` gallery, publishes sets to vitrinka (the internal artifacts app), and
4
+ // one-shots capture (`snap`) + artifact scaffolding (`artifact-init`, `artifact-from-set`).
5
+ //
6
+ // Zero dependencies (node: builtins only). Erasable TypeScript only — Node 24 type-stripping
7
+ // and bun both run it directly: `node cli.ts <cmd>`.
8
+ //
9
+ // add / build / meta / remote-init / push are ported verbatim in behavior from
10
+ // skills/screenshot/gallery.mjs (offline marker, COPYFILE_DISABLE, control-file excludes,
11
+ // `--key`, repeatable `--chip`). gallery.mjs is now a passthrough shim to this file.
12
+ // Design: vitrinka/docs/plans/2026-07-03-hand-in-hand-tooling-design.md
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync, copyFileSync, mkdtempSync, chmodSync, appendFileSync, statSync, } from 'node:fs';
14
+ import { join, resolve, basename, dirname, extname, relative, sep } from 'node:path';
15
+ import { tmpdir, hostname } from 'node:os';
16
+ import { spawnSync, spawn } from 'node:child_process';
17
+ import { createHash } from 'node:crypto';
18
+ import { fileURLToPath, pathToFileURL } from 'node:url';
19
+ import { createInterface } from 'node:readline';
20
+ import { readToken } from "./lib/token.js";
21
+ import { operatorPath, readOperator, actorHeaderValue } from "./lib/operator.js";
22
+ import { resolveBaseUrl } from "./lib/base-url.js";
23
+ const CLI_PATH = fileURLToPath(import.meta.url);
24
+ export function parseArgs(argv) {
25
+ const out = {};
26
+ for (let i = 0; i < argv.length; i++) {
27
+ const a = argv[i];
28
+ if (!a.startsWith('--'))
29
+ continue;
30
+ const key = a.slice(2);
31
+ const next = argv[i + 1];
32
+ const val = next === undefined || next.startsWith('--') ? true : argv[++i];
33
+ const cur = out[key];
34
+ if (cur === undefined)
35
+ out[key] = val; // repeated flags (e.g. --chip) accumulate
36
+ else if (Array.isArray(cur))
37
+ cur.push(val);
38
+ else
39
+ out[key] = [cur, val];
40
+ }
41
+ return out;
42
+ }
43
+ // Last-wins string extraction for the new commands (ported commands keep their original
44
+ // inline `args.x && args.x !== true` expressions so behavior stays verbatim).
45
+ function strArg(v) {
46
+ if (v === undefined || v === true)
47
+ return '';
48
+ if (Array.isArray(v)) {
49
+ const last = v[v.length - 1];
50
+ return last === true ? '' : String(last);
51
+ }
52
+ return String(v);
53
+ }
54
+ function numArg(v, dflt, flag) {
55
+ const s = strArg(v);
56
+ if (!s)
57
+ return dflt;
58
+ const n = Number(s);
59
+ if (!Number.isFinite(n) || n <= 0) {
60
+ console.error(`${flag}: expected a positive number, got ${JSON.stringify(s)}`);
61
+ process.exit(2);
62
+ }
63
+ return n;
64
+ }
65
+ function fail(msg, code = 1) {
66
+ console.error(msg);
67
+ process.exit(code);
68
+ }
69
+ // positionals extracts the non-flag arguments, honoring parseArgs's
70
+ // flag-value consumption (a non-"--" token right after a flag is its value).
71
+ export function positionals(argv) {
72
+ const out = [];
73
+ for (let i = 0; i < argv.length; i++) {
74
+ const a = argv[i];
75
+ if (a.startsWith('--')) {
76
+ const next = argv[i + 1];
77
+ if (next !== undefined && !next.startsWith('--'))
78
+ i++;
79
+ continue;
80
+ }
81
+ out.push(a);
82
+ }
83
+ return out;
84
+ }
85
+ function manifestPathOf(root) { return join(root, 'manifest.json'); }
86
+ function htmlPathOf(root) { return join(root, 'index.html'); }
87
+ function loadManifest(root) {
88
+ const manifestPath = manifestPathOf(root);
89
+ if (!existsSync(manifestPath))
90
+ return { version: 1, shots: [] };
91
+ const raw = readFileSync(manifestPath, 'utf8'); // ENOENT handled above; other read errors surface loudly
92
+ let m;
93
+ try {
94
+ m = JSON.parse(raw);
95
+ }
96
+ catch (e) {
97
+ // Corrupt existing manifest is unexpected — never silently overwrite (would lose history).
98
+ throw new Error(`Corrupt manifest ${manifestPath} (${e.message}). Fix or delete it manually; refusing to overwrite.`);
99
+ }
100
+ if (!m || !Array.isArray(m.shots)) {
101
+ throw new Error(`Malformed manifest ${manifestPath}: missing shots[]. Fix or delete it manually.`);
102
+ }
103
+ return m;
104
+ }
105
+ function saveManifest(root, m) {
106
+ mkdirSync(root, { recursive: true });
107
+ ensureRootSelfIgnored(root);
108
+ writeFileSync(manifestPathOf(root), JSON.stringify(m, null, 2) + '\n');
109
+ }
110
+ function buildIndex(root) {
111
+ const m = loadManifest(root);
112
+ const project = basename(dirname(root)) || 'project';
113
+ const htmlPath = htmlPathOf(root);
114
+ writeFileSync(htmlPath, renderHtml(m.shots, project));
115
+ return { count: m.shots.length, htmlPath };
116
+ }
117
+ // shotContext builds the v2 capture-context fields from CLI flags:
118
+ // --src (repeatable or comma-separated), --state, --device <name>,
119
+ // --viewport WxH[@scale]. Empty object stays off the manifest entirely.
120
+ export function shotContext(args) {
121
+ const out = {};
122
+ const rawSrc = args.src === undefined ? [] : Array.isArray(args.src) ? args.src : [args.src];
123
+ const src = rawSrc.filter((v) => typeof v === 'string')
124
+ .flatMap((v) => v.split(',')).map((v) => v.trim()).filter(Boolean);
125
+ if (src.length)
126
+ out.src = src;
127
+ const state = strArg(args.state);
128
+ if (state)
129
+ out.state = state;
130
+ const deviceName = strArg(args.device);
131
+ const vp = strArg(args.viewport);
132
+ if (deviceName || vp) {
133
+ const device = {};
134
+ if (deviceName)
135
+ device.device = deviceName;
136
+ const m = vp ? /^(\d+)x(\d+)(?:@(\d+(?:\.\d+)?))?$/.exec(vp) : null;
137
+ if (vp && !m)
138
+ fail(`invalid --viewport ${JSON.stringify(vp)} — expected WxH or WxH@scale (e.g. 393x852@3)`, 2);
139
+ if (m) {
140
+ device.w = Number(m[1]);
141
+ device.h = Number(m[2]);
142
+ if (m[3])
143
+ device.scale = Number(m[3]);
144
+ }
145
+ out.device = device;
146
+ }
147
+ return out;
148
+ }
149
+ function appendShot(root, shot) {
150
+ const m = loadManifest(root);
151
+ m.shots.push(shot);
152
+ // v2: stamp the session's code identity (last append wins — the freshest
153
+ // commit is the one the newest pixels came from).
154
+ m.version = Math.max(m.version || 1, 2);
155
+ const projectDir = dirname(root);
156
+ const commit = git(['rev-parse', '--short', 'HEAD'], projectDir);
157
+ const branch = git(['branch', '--show-current'], projectDir);
158
+ if (commit)
159
+ m.commit = commit;
160
+ if (branch)
161
+ m.branch = branch;
162
+ saveManifest(root, m);
163
+ const project = basename(dirname(root)) || 'project';
164
+ const htmlPath = htmlPathOf(root);
165
+ writeFileSync(htmlPath, renderHtml(m.shots, project));
166
+ return { count: m.shots.length, htmlPath };
167
+ }
168
+ function addCmd(root, args) {
169
+ const file = args.file;
170
+ if (!file || file === true) {
171
+ console.error('cli.ts add: --file <rel-path> is required');
172
+ process.exit(2);
173
+ }
174
+ const shot = {
175
+ file: String(file),
176
+ surface: args.surface && args.surface !== true ? String(args.surface) : 'other',
177
+ route: args.route && args.route !== true ? String(args.route) : '',
178
+ note: args.note && args.note !== true ? String(args.note) : '',
179
+ // journey fields (vitrinka editorial viewer): stage label + short human title
180
+ label: args.label && args.label !== true ? String(args.label) : undefined,
181
+ title: args.title && args.title !== true ? String(args.title) : undefined,
182
+ action: args.action && args.action !== true ? String(args.action) : undefined,
183
+ ts: new Date().toISOString(),
184
+ ...shotContext(args),
185
+ };
186
+ const { count, htmlPath } = appendShot(root, shot);
187
+ console.log(`+ ${file} (${count} shots) → ${htmlPath}`);
188
+ }
189
+ export function esc(s) {
190
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
191
+ }
192
+ function renderHtml(shots, project) {
193
+ // Data is inlined and rendered client-side via textContent/setAttribute → XSS-safe by construction.
194
+ // Escape </script> so the JSON payload can't break out of the <script> tag.
195
+ const data = JSON.stringify(shots).replace(/</g, '\\u003c');
196
+ return `<!doctype html>
197
+ <html lang="en">
198
+ <head>
199
+ <meta charset="utf-8">
200
+ <meta name="viewport" content="width=device-width, initial-scale=1">
201
+ <title>Screenshots — ${esc(project)}</title>
202
+ <style>
203
+ :root { color-scheme: dark; }
204
+ * { box-sizing: border-box; }
205
+ body { margin: 0; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
206
+ background: #0d1117; color: #e6edf3; }
207
+ header { position: sticky; top: 0; z-index: 5; display: flex; align-items: baseline; gap: 12px;
208
+ padding: 14px 22px; background: #161b22ee; backdrop-filter: blur(8px);
209
+ border-bottom: 1px solid #30363d; }
210
+ header h1 { font-size: 15px; margin: 0; font-weight: 600; }
211
+ header .sub { color: #8b949e; font-size: 12px; }
212
+ main { max-width: 1080px; margin: 0 auto; padding: 8px 22px 80px; }
213
+ .day { margin-top: 28px; }
214
+ .day-h { display: flex; align-items: baseline; gap: 10px; position: sticky; top: 49px;
215
+ background: #0d1117; padding: 8px 0; border-bottom: 1px solid #21262d; z-index: 2; }
216
+ .day-h .date { font-size: 13px; font-weight: 600; color: #58a6ff; }
217
+ .day-h .count { font-size: 11px; color: #8b949e; }
218
+ .card { display: flex; gap: 18px; padding: 18px 0; border-bottom: 1px solid #21262d; }
219
+ .card a.shot { flex: 0 0 auto; }
220
+ .card img { display: block; width: 360px; max-width: 42vw; height: auto; border-radius: 8px;
221
+ border: 1px solid #30363d; background: #010409; }
222
+ .meta { min-width: 0; display: flex; flex-direction: column; gap: 8px; padding-top: 2px; }
223
+ .row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
224
+ .badge { font-size: 10px; letter-spacing: .04em; text-transform: uppercase; font-weight: 700;
225
+ padding: 2px 7px; border-radius: 999px; color: #0d1117; background: #8b949e; }
226
+ .badge.ios { background: #58a6ff; } .badge.android { background: #3fb950; }
227
+ .badge.web { background: #d29922; } .badge.macos { background: #bc8cff; }
228
+ .route { font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; color: #e6edf3;
229
+ background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 2px 7px; }
230
+ .note { margin: 0; color: #c9d1d9; }
231
+ .foot { display: flex; gap: 12px; align-items: center; margin-top: auto; color: #6e7681; font-size: 11px; }
232
+ .foot .file { font-family: ui-monospace, monospace; }
233
+ .empty { color: #8b949e; padding: 40px 0; text-align: center; }
234
+ @media (max-width: 620px) { .card { flex-direction: column; } .card img { width: 100%; max-width: 100%; } }
235
+ </style>
236
+ </head>
237
+ <body>
238
+ <header><h1>📸 ${esc(project)}</h1><span class="sub" id="sub"></span></header>
239
+ <main id="app"></main>
240
+ <script>
241
+ const SHOTS = ${data};
242
+ const app = document.getElementById('app');
243
+ const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
244
+ SHOTS.sort((a, b) => String(a.ts || '').localeCompare(String(b.ts || '')));
245
+ document.getElementById('sub').textContent = SHOTS.length + (SHOTS.length === 1 ? ' shot' : ' shots');
246
+ const dayOf = (s) => {
247
+ const seg = String(s.file || '').split('/')[0];
248
+ if (/^\\d{4}-\\d{2}-\\d{2}$/.test(seg)) return seg;
249
+ return String(s.ts || '').slice(0, 10) || 'undated';
250
+ };
251
+ const groups = {};
252
+ for (const s of SHOTS) (groups[dayOf(s)] ||= []).push(s);
253
+ const days = Object.keys(groups).sort();
254
+ if (!SHOTS.length) app.appendChild(el('p', 'empty', 'No screenshots captured yet.'));
255
+ for (const day of days) {
256
+ const sec = el('section', 'day');
257
+ const h = el('div', 'day-h');
258
+ h.appendChild(el('span', 'date', day));
259
+ h.appendChild(el('span', 'count', groups[day].length + (groups[day].length === 1 ? ' shot' : ' shots')));
260
+ sec.appendChild(h);
261
+ for (const s of groups[day]) {
262
+ const card = el('div', 'card');
263
+ const a = el('a', 'shot'); a.href = s.file; a.target = '_blank'; a.rel = 'noopener';
264
+ const img = el('img'); img.src = s.file; img.loading = 'lazy'; img.alt = s.route || s.file;
265
+ a.appendChild(img); card.appendChild(a);
266
+ const meta = el('div', 'meta');
267
+ const row = el('div', 'row');
268
+ row.appendChild(el('span', 'badge ' + (s.surface || 'other'), s.surface || 'other'));
269
+ if (s.route) row.appendChild(el('code', 'route', s.route));
270
+ meta.appendChild(row);
271
+ if (s.note) meta.appendChild(el('p', 'note', s.note));
272
+ const foot = el('div', 'foot');
273
+ foot.appendChild(el('span', 'file', s.file));
274
+ if (s.ts) { const t = new Date(s.ts); if (!isNaN(t)) foot.appendChild(el('time', 'ts', t.toLocaleString())); }
275
+ meta.appendChild(foot);
276
+ card.appendChild(meta);
277
+ sec.appendChild(card);
278
+ }
279
+ app.appendChild(sec);
280
+ }
281
+ </script>
282
+ </body>
283
+ </html>
284
+ `;
285
+ }
286
+ // Set/update the manifest's journey header (vitrinka editorial viewer): kicker,
287
+ // display title (+ red accent substring), intro paragraph, and meta chips.
288
+ function metaCmd(root, args) {
289
+ const m = loadManifest(root);
290
+ m.version = 2;
291
+ const j = m.journey && typeof m.journey === 'object' ? m.journey : {};
292
+ for (const k of ['kicker', 'title', 'accent', 'intro']) {
293
+ const v = args[k];
294
+ if (v && v !== true)
295
+ j[k] = String(Array.isArray(v) ? v[v.length - 1] : v);
296
+ }
297
+ if (args.chip) {
298
+ const chips = (Array.isArray(args.chip) ? args.chip : [args.chip]).filter((c) => c !== true);
299
+ j.chips = chips.map((c) => {
300
+ const idx = String(c).indexOf('=');
301
+ if (idx < 1) {
302
+ console.error(`meta: --chip expects "Key=Value", got ${JSON.stringify(String(c))}`);
303
+ process.exit(2);
304
+ }
305
+ return { k: String(c).slice(0, idx).trim(), v: String(c).slice(idx + 1).trim() };
306
+ });
307
+ }
308
+ m.journey = j;
309
+ saveManifest(root, m);
310
+ buildIndex(root);
311
+ console.log(`journey meta set (${Object.keys(j).join(', ')}) → ${manifestPathOf(root)}`);
312
+ }
313
+ function vitrinkaCfgPath(root) { return join(root, '.vitrinka'); }
314
+ function offlineMarkerPath(root) { return join(root, '.vitrinka-offline'); }
315
+ // Mirrors vitrinka's server-side slug rules (contract: [a-z0-9._-], collapse '-', trim).
316
+ export function sanitizeSeg(s, max) {
317
+ const out = String(s).toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^[-.]+|[-.]+$/g, '');
318
+ return out.slice(0, max) || 'x';
319
+ }
320
+ // Mirrors the server's validKey (internal/web/validate.go): within GET
321
+ // /{project}/{branch}/{selector} a key shares the path slot with versions, "latest" and
322
+ // "{version}.zip", so the server rejects such keys at PUT time. Fail fast at mint time
323
+ // instead of printing a share URL whose first push can only 400.
324
+ export function validKey(k) {
325
+ if (k === 'latest' || k.endsWith('.zip'))
326
+ return false;
327
+ return /[^0-9]/.test(k); // purely numeric keys resolve as version selectors
328
+ }
329
+ function assertValidKey(key, cmd) {
330
+ if (validKey(key))
331
+ return;
332
+ fail(`${cmd}: invalid set key ${JSON.stringify(key)} — a key must not be purely numeric, "latest", or end in .zip (those collide with the server's version selectors)`, 2);
333
+ }
334
+ function git(gitArgs, cwd) {
335
+ const r = spawnSync('git', gitArgs, { cwd, encoding: 'utf8' });
336
+ return r.status === 0 ? r.stdout.trim() : '';
337
+ }
338
+ // Capture roots must never enter a repo's history — agents sometimes invent
339
+ // non-standard roots (".screenshots4") that repo .gitignore globs don't cover,
340
+ // and a later `git add -A` sweeps the set metadata into a commit. Self-ignore
341
+ // every root via .git/info/exclude (local-only, shared across worktrees, never
342
+ // dirties the repo). Memoized per-root; a no-op outside a git repo.
343
+ const selfIgnoredRoots = new Set();
344
+ function ensureRootSelfIgnored(root) {
345
+ const abs = resolve(root);
346
+ if (selfIgnoredRoots.has(abs))
347
+ return;
348
+ selfIgnoredRoots.add(abs);
349
+ try {
350
+ const top = git(['rev-parse', '--show-toplevel'], abs);
351
+ if (!top)
352
+ return; // not inside a git repo
353
+ const rel = relative(top, abs).split(sep).join('/');
354
+ if (!rel || rel.startsWith('..'))
355
+ return; // never ignore the repo root itself
356
+ const excl = git(['rev-parse', '--path-format=absolute', '--git-path', 'info/exclude'], abs);
357
+ if (!excl)
358
+ return;
359
+ const entry = `/${rel}/`;
360
+ const cur = existsSync(excl) ? readFileSync(excl, 'utf8') : '';
361
+ if (cur.split(/\r?\n/).some((l) => l.trim() === entry))
362
+ return;
363
+ mkdirSync(dirname(excl), { recursive: true });
364
+ writeFileSync(excl, cur + (cur && !cur.endsWith('\n') ? '\n' : '') + entry + '\n');
365
+ }
366
+ catch (e) {
367
+ // Best-effort (read-only .git etc.) — warn, never block a capture.
368
+ console.error(`warn: could not self-ignore ${root}: ${e.message}`);
369
+ }
370
+ }
371
+ // mainWorktreeTop resolves the MAIN worktree's root for a checkout. Linked
372
+ // worktrees are typically named after their branch (repo-branch dirs), so
373
+ // deriving the project from their toplevel forks every branch into its own
374
+ // "project" on vitrinka. The common git dir always lives at <main>/.git.
375
+ export function mainWorktreeTop(cwd) {
376
+ const common = git(['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);
377
+ if (common && basename(common) === '.git')
378
+ return dirname(common);
379
+ return git(['rev-parse', '--show-toplevel'], cwd);
380
+ }
381
+ // originRepo normalizes the origin remote to a schemeless slug, e.g.
382
+ // "github.com/LEFTEQ/fixit". "" outside a repo / without a remote.
383
+ function originRepo(dir) {
384
+ return git(['remote', 'get-url', 'origin'], dir)
385
+ .replace(/^git@([^:]+):/, '$1/').replace(/^[a-z+]+:\/\//, '').replace(/\.git$/, '');
386
+ }
387
+ // Binary/asset blobs the autocomplete should never offer — they only bloat
388
+ // the index (the server caps entries at 50k).
389
+ const INDEX_EXCLUDE = /\.(png|jpe?g|gif|webp|avif|heic|ico|icns|svgz|pdf|zip|tar|gz|tgz|bz2|7z|jar|mp[34]|mov|avi|webm|wav|ogg|flac|ttf|otf|woff2?|eot|keystore|jks|p8|p12|mobileprovision|dylib|so|a|o|node|wasm|pyc|class|min\.js|min\.css|map|lock|lockb)$|(^|\/)(package-lock\.json|bun\.lock|yarn\.lock|pnpm-lock\.yaml|go\.sum)$/i;
390
+ const ROUTE_EXTS = /\.(tsx|jsx|ts|js)$/;
391
+ /** extractRoutes derives route paths from file-based routing conventions.
392
+ * Router roots are found at the repo root AND inside workspace containers
393
+ * (`apps/<name>/`, `packages/<name>/`, each with optional `src/`) — FixIt-style
394
+ * monorepos keep Expo Router at apps/client/app/**. Per ROOT: Next.js
395
+ * app-router mode (any app/**\/page.* under THAT root) makes ONLY page.*
396
+ * files routes; otherwise app/** screens follow Expo Router rules (groups
397
+ * "(tabs)" stripped, trailing "index" collapsed, "_layout"/"+not-found"
398
+ * skipped). pages/** always follows the Next pages-router rules. Route paths
399
+ * stay plain, so two roots CAN produce the same path (e.g. apps/web/app/
400
+ * index.tsx and apps/client/app/index.tsx both yield "/") — the first file
401
+ * in input order keeps the path, later duplicates are dropped. Unrecognized
402
+ * layouts yield [] — the files tier still carries the index. */
403
+ const APP_ROOT_RE = /^(?:(?:apps|packages)\/[^/]+\/)?(?:src\/)?app\/(.+)$/;
404
+ const PAGES_ROOT_RE = /^(?:(?:apps|packages)\/[^/]+\/)?(?:src\/)?pages\/(.+)$/;
405
+ export function extractRoutes(files) {
406
+ const routes = new Map();
407
+ // Next-mode is PER router root, not global: a monorepo can hold a Next app
408
+ // (apps/web) and an Expo app (apps/client) side by side. One exec per file:
409
+ // the root is derived from the match already in hand.
410
+ const rootOfMatch = (f, rel) => f.slice(0, f.length - rel.length);
411
+ const nextRoots = new Set();
412
+ for (const f of files) {
413
+ const m = APP_ROOT_RE.exec(f);
414
+ if (m && /\/page\.(tsx|jsx|ts|js)$/.test(f))
415
+ nextRoots.add(rootOfMatch(f, m[1]));
416
+ }
417
+ const add = (rel, file) => {
418
+ const segs = rel.split('/').filter((s) => s && !(s.startsWith('(') && s.endsWith(')')));
419
+ if (segs[segs.length - 1] === 'index')
420
+ segs.pop();
421
+ const p = '/' + segs.join('/');
422
+ if (!routes.has(p))
423
+ routes.set(p, file);
424
+ };
425
+ for (const f of files) {
426
+ if (!ROUTE_EXTS.test(f))
427
+ continue;
428
+ const app = APP_ROOT_RE.exec(f);
429
+ if (app) {
430
+ const rel = app[1].replace(ROUTE_EXTS, '');
431
+ const base = rel.split('/').pop();
432
+ if (nextRoots.has(rootOfMatch(f, app[1]))) {
433
+ if (base === 'page')
434
+ add(rel.replace(/\/?page$/, ''), f);
435
+ }
436
+ else if (!base.startsWith('_') && !base.startsWith('+')) {
437
+ add(rel, f);
438
+ }
439
+ continue;
440
+ }
441
+ const pages = PAGES_ROOT_RE.exec(f);
442
+ if (pages) {
443
+ const rel = pages[1].replace(ROUTE_EXTS, '');
444
+ const base = rel.split('/').pop();
445
+ if (base.startsWith('_') || rel === 'api' || rel.startsWith('api/'))
446
+ continue;
447
+ add(rel, f);
448
+ }
449
+ }
450
+ return [...routes.entries()].map(([p, f]) => ({ p, f }))
451
+ .sort((a, b) => (a.p < b.p ? -1 : a.p > b.p ? 1 : 0));
452
+ }
453
+ // cmdDescription pulls the one-line `description:` out of a command/skill
454
+ // md frontmatter — the popover hint. "" when absent/unreadable.
455
+ function cmdDescription(mdPath) {
456
+ let head;
457
+ try {
458
+ head = readFileSync(mdPath, 'utf8').slice(0, 2048);
459
+ }
460
+ catch {
461
+ return ''; // vanished/unreadable between readdir and read — hint only, skip
462
+ }
463
+ const m = /^description:\s*["']?(.+?)["']?\s*$/m.exec(head);
464
+ if (!m)
465
+ return '';
466
+ const d = m[1].replace(/\s+/g, ' ').trim();
467
+ return d.length > 80 ? d.slice(0, 79) + '…' : d;
468
+ }
469
+ /** collectCommands indexes the slash-command palette exactly as Claude Code
470
+ * resolves it: `<root>/commands/**\/*.md` → `/ns:name` (dirs join with ":"),
471
+ * `<root>/skills/<ns…>/<name>/SKILL.md` → `/ns:name` — from the PROJECT's
472
+ * .claude first, then the HOME ~/.claude (project wins name collisions).
473
+ * Underscore-prefixed files/dirs (e.g. skills/git/_shared) are internals. */
474
+ export function collectCommands(projectDir, homeClaude = join(process.env.HOME || '', '.claude')) {
475
+ const out = new Map();
476
+ const add = (segs, mdPath) => {
477
+ if (segs.some((s) => !s || s.startsWith('_') || s.startsWith('.')))
478
+ return;
479
+ const c = '/' + segs.join(':');
480
+ if (out.has(c) || out.size >= 1000)
481
+ return;
482
+ const d = cmdDescription(mdPath);
483
+ out.set(c, d ? { c, d } : { c });
484
+ };
485
+ const list = (dir) => {
486
+ try {
487
+ return readdirSync(dir, { recursive: true, encoding: 'utf8' });
488
+ }
489
+ catch {
490
+ return []; // root absent (no .claude here) — normal, not an error
491
+ }
492
+ };
493
+ for (const root of [join(projectDir, '.claude'), homeClaude]) {
494
+ for (const rel of list(join(root, 'commands'))) {
495
+ if (!rel.endsWith('.md'))
496
+ continue;
497
+ add(rel.slice(0, -3).split('/'), join(root, 'commands', rel));
498
+ }
499
+ for (const rel of list(join(root, 'skills'))) {
500
+ if (!rel.endsWith('/SKILL.md'))
501
+ continue;
502
+ add(rel.slice(0, -'/SKILL.md'.length).split('/'), join(root, 'skills', rel));
503
+ }
504
+ }
505
+ return [...out.values()].sort((a, b) => (a.c < b.c ? -1 : a.c > b.c ? 1 : 0));
506
+ }
507
+ /** buildProjectIndex lists the ACTUAL checkout's non-gitignored files
508
+ * (tracked + untracked-unignored — a just-created screen completes before
509
+ * its first commit), derives routes, and indexes the slash-command palette.
510
+ * null outside a git repo. */
511
+ export function buildProjectIndex(projectDir) {
512
+ const top = git(['rev-parse', '--show-toplevel'], projectDir);
513
+ if (!top)
514
+ return null;
515
+ const ls = spawnSync('git', ['ls-files', '--cached', '--others', '--exclude-standard'], { cwd: top, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
516
+ if (ls.status !== 0) {
517
+ console.error(`index: git ls-files failed: ${String(ls.stderr).trim()}`);
518
+ return null;
519
+ }
520
+ let files = ls.stdout.split('\n').filter((f) => f && !INDEX_EXCLUDE.test(f)).sort();
521
+ if (files.length > 50_000) {
522
+ console.error(`index: ${files.length} files exceeds the 50k server cap — keeping the first 50k (tune INDEX_EXCLUDE)`);
523
+ files = files.slice(0, 50_000);
524
+ }
525
+ const routes = extractRoutes(files);
526
+ const cmds = collectCommands(projectDir);
527
+ // Key order mirrors the server's canonical marshal (v, repo, routes, files, cmds).
528
+ const body = JSON.stringify({ v: 1, repo: originRepo(projectDir) || undefined, routes, files, cmds });
529
+ return {
530
+ body, hash: createHash('sha256').update(body).digest('hex'),
531
+ routes: routes.length, files: files.length, cmds: cmds.length,
532
+ };
533
+ }
534
+ // The last successfully pushed index (base+project+hash) is remembered inside
535
+ // the repo's common .git dir — per-repo, never committed, shared by worktrees.
536
+ function indexCachePath(projectDir) {
537
+ return join(git(['rev-parse', '--path-format=absolute', '--git-common-dir'], projectDir), 'vitrinka-index.cache');
538
+ }
539
+ /** pushProjectIndex builds + pushes the index unless nothing changed since
540
+ * the last successful push. Never throws and never exits — every caller is
541
+ * a touchpoint where index freshness must not fail the primary job.
542
+ *
543
+ * `verify` (the explicit `vitrinka index` / daemon path): a matching local
544
+ * stamp alone must not claim success — the server row may have been wiped
545
+ * (DB reset). On a stamp hit it confirms with one conditional GET against
546
+ * the server's own hash (304 = in sync, zero body); a 404 or a diverged
547
+ * ETag invalidates the stamp and falls through to a re-PUT. Piggybacked
548
+ * callers keep verify=false — an unchanged repo costs zero requests there. */
549
+ export async function pushProjectIndex(projectDir, base, project, verify = false) {
550
+ const built = buildProjectIndex(projectDir);
551
+ if (!built)
552
+ return false;
553
+ const cachePath = indexCachePath(projectDir);
554
+ const endpoint = `${base}/api/v1/projects/${encodeURIComponent(project)}/index`;
555
+ const cached = existsSync(cachePath) ? readFileSync(cachePath, 'utf8').trim().split(' ') : [];
556
+ const stampHit = cached[0] === base && cached[1] === project && cached[2] === built.hash;
557
+ let serverHash = cached[3] || ''; // the server's canonical hash from the last PUT's response
558
+ if (stampHit && !verify)
559
+ return true;
560
+ if (stampHit && verify) {
561
+ try {
562
+ const res = await fetch(endpoint, {
563
+ headers: serverHash ? { 'If-None-Match': `"${serverHash}"` } : {},
564
+ signal: AbortSignal.timeout(30_000),
565
+ });
566
+ if (res.status === 304 || (res.ok && !serverHash))
567
+ return true; // in sync / present
568
+ // 404 (row wiped) or a diverged ETag → the stamp lied; re-push below.
569
+ }
570
+ catch (e) {
571
+ console.error(`index: vitrinka unreachable (${e instanceof Error ? e.message : e}) — next touchpoint retries`);
572
+ return false;
573
+ }
574
+ }
575
+ const headers = { 'Content-Type': 'application/json' };
576
+ const token = readToken();
577
+ if (token)
578
+ headers.Authorization = `Bearer ${token}`;
579
+ try {
580
+ const res = await fetch(endpoint, {
581
+ method: 'PUT', headers, body: built.body, signal: AbortSignal.timeout(30_000),
582
+ });
583
+ if (!res.ok && res.status !== 204) {
584
+ console.error(`index: push rejected (HTTP ${res.status}): ${(await res.text()).slice(0, 200)}`);
585
+ return false;
586
+ }
587
+ if (res.status !== 204) {
588
+ try {
589
+ serverHash = String((await res.json()).hash || serverHash);
590
+ }
591
+ catch {
592
+ // 2xx without the JSON hash (older server): keep the previous serverHash.
593
+ }
594
+ }
595
+ }
596
+ catch (e) {
597
+ console.error(`index: vitrinka unreachable (${e instanceof Error ? e.message : e}) — next touchpoint retries`);
598
+ return false;
599
+ }
600
+ writeFileSync(cachePath, `${base} ${project} ${built.hash} ${serverHash}`.trimEnd() + '\n');
601
+ console.log(`index ↑ ${project}: ${built.routes} routes, ${built.files} files, ${built.cmds} commands`);
602
+ return true;
603
+ }
604
+ // indexCmd — the explicit `vitrinka index` touchpoint. Exits non-zero on
605
+ // failure (unlike the piggybacked pushes) so scripts can rely on it; verifies
606
+ // the server actually holds the index rather than trusting the local stamp.
607
+ async function indexCmd(root, args) {
608
+ const projectDir = dirname(root);
609
+ const cfg = existsSync(vitrinkaCfgPath(root)) ? loadVitrinkaCfg(root) : null;
610
+ const base = (strArg(args.base) || cfg?.base || process.env.VITRINKA_URL || 'https://vitrinka.lovinka.com').replace(/\/+$/, '');
611
+ const project = sanitizeSeg(strArg(args.project) || cfg?.project || basename(mainWorktreeTop(projectDir) || projectDir), 64);
612
+ if (!(await pushProjectIndex(projectDir, base, project, true)))
613
+ process.exit(1);
614
+ }
615
+ function loadVitrinkaCfg(root) {
616
+ const raw = readFileSync(vitrinkaCfgPath(root), 'utf8'); // caller checks existsSync
617
+ try {
618
+ return JSON.parse(raw);
619
+ }
620
+ catch (e) {
621
+ throw new Error(`Corrupt ${vitrinkaCfgPath(root)} (${e.message}). Delete it and re-run remote-init.`);
622
+ }
623
+ }
624
+ function shareUrl(cfg) {
625
+ return `${cfg.base}/${cfg.project}/${cfg.slug}/${cfg.key}`;
626
+ }
627
+ function remoteInitCmd(root, args) {
628
+ if (existsSync(vitrinkaCfgPath(root))) {
629
+ // Sticky per session dir — reuse, so re-activation never forks the set.
630
+ const cfg = loadVitrinkaCfg(root);
631
+ console.log(`vitrinka set (existing) → ${shareUrl(cfg)}`);
632
+ return cfg;
633
+ }
634
+ const projectDir = dirname(root);
635
+ const base = (args.base && args.base !== true ? String(args.base) : process.env.VITRINKA_URL || 'https://vitrinka.lovinka.com').replace(/\/+$/, '');
636
+ // Project identity comes from the MAIN worktree (a linked worktree's dir is
637
+ // named after its branch — using it would fork each branch into its own
638
+ // project; the branch already lives in cfg.branch).
639
+ const gitTop = mainWorktreeTop(projectDir);
640
+ // Artifact roots live at <project>/.artifacts/<slug> — outside a git repo the project
641
+ // fallback must be the real project dir, not the '.artifacts' container itself.
642
+ const fallbackDir = basename(projectDir) === '.artifacts' ? dirname(projectDir) : projectDir;
643
+ const project = sanitizeSeg(args.project && args.project !== true ? String(args.project) : basename(gitTop || fallbackDir), 64);
644
+ const branch = (args.branch && args.branch !== true ? String(args.branch) : git(['branch', '--show-current'], projectDir)) || 'main';
645
+ const d = new Date();
646
+ const p2 = (n) => String(n).padStart(2, '0');
647
+ const key = args.key && args.key !== true
648
+ ? String(args.key).replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 64) // meaningful keys, e.g. artifact slugs
649
+ : `s-${d.getFullYear()}${p2(d.getMonth() + 1)}${p2(d.getDate())}-${p2(d.getHours())}${p2(d.getMinutes())}`;
650
+ assertValidKey(key, 'remote-init');
651
+ const cfg = {
652
+ base,
653
+ project,
654
+ branch,
655
+ slug: sanitizeSeg(branch, 100),
656
+ key,
657
+ kind: args.kind && args.kind !== true ? String(args.kind) : 'screenshots',
658
+ issue: args.issue && args.issue !== true ? String(args.issue) : '',
659
+ };
660
+ mkdirSync(root, { recursive: true });
661
+ ensureRootSelfIgnored(root);
662
+ writeFileSync(vitrinkaCfgPath(root), JSON.stringify(cfg, null, 2) + '\n');
663
+ console.log(`vitrinka set → ${shareUrl(cfg)}`);
664
+ return cfg;
665
+ }
666
+ // Token / operator resolution + the X-Board-Actor latin-1 encoder are shared
667
+ // with the MCP server — single-sourced in ./lib (readToken, readOperator,
668
+ // operatorPath, actorHeaderValue are imported at the top).
669
+ // actorFor resolves the X-Board-Actor header value for a board write: an
670
+ // explicit --actor flag wins, else the configured operator name, else ''
671
+ // (anonymous). The CLI uses the truthiness operator semantics (a SET-but-empty
672
+ // $VITRINKA_OPERATOR falls through to the operator file).
673
+ function actorFor(args) {
674
+ return actorHeaderValue((strArg(args.actor) || readOperator(true)).trim());
675
+ }
676
+ function markOffline(root, error) {
677
+ const p = offlineMarkerPath(root);
678
+ // Merge with any existing marker: a repeated push failure inside one offline episode
679
+ // must not clear the `warned` flag snap set (or snap would warn on every shot).
680
+ // A successful push deletes the marker, so a NEW episode still warns exactly once.
681
+ let warned = false;
682
+ if (existsSync(p)) {
683
+ try {
684
+ warned = JSON.parse(readFileSync(p, 'utf8')).warned === true;
685
+ }
686
+ catch {
687
+ // Malformed marker (half-written by a killed push) — treat as not-yet-warned.
688
+ }
689
+ }
690
+ writeFileSync(p, JSON.stringify({ ts: new Date().toISOString(), error, ...(warned ? { warned } : {}) }) + '\n');
691
+ }
692
+ async function pushCmd(root, args) {
693
+ if (!existsSync(vitrinkaCfgPath(root))) {
694
+ console.error(`push: no ${vitrinkaCfgPath(root)} — run \`node ${CLI_PATH} remote-init --root ${root}\` first`);
695
+ process.exit(2);
696
+ }
697
+ const cfg = loadVitrinkaCfg(root);
698
+ const projectDir = dirname(root);
699
+ // Control files are excluded here AND skipped server-side (belt and suspenders).
700
+ // COPYFILE_DISABLE stops macOS bsdtar from emitting AppleDouble (._*) junk entries.
701
+ const tar = spawnSync('tar', [
702
+ '-czf', '-',
703
+ '--exclude', '.vitrinka*', '--exclude', './.vitrinka*',
704
+ '--exclude', '.active', '--exclude', './.active',
705
+ '--exclude', '._*', '--exclude', './._*', '--exclude', '.DS_Store',
706
+ '-C', root, '.',
707
+ ], { maxBuffer: 512 * 1024 * 1024, env: { ...process.env, COPYFILE_DISABLE: '1' } });
708
+ if (tar.status !== 0) {
709
+ console.error(`push: tar failed: ${String(tar.stderr)}`);
710
+ process.exit(1);
711
+ }
712
+ const commit = git(['rev-parse', '--short', 'HEAD'], projectDir);
713
+ const repo = originRepo(projectDir);
714
+ const q = new URLSearchParams();
715
+ q.set('kind', cfg.kind || 'screenshots');
716
+ if (cfg.branch)
717
+ q.set('branch', cfg.branch);
718
+ if (commit)
719
+ q.set('commit', commit);
720
+ if (repo)
721
+ q.set('repo', repo);
722
+ if (cfg.issue)
723
+ q.set('issue', cfg.issue);
724
+ // Source set cross-link: --source flag wins, else the ref recorded in .vitrinka
725
+ // (by artifact-from-set, or persisted from an earlier explicit push). Same for --title.
726
+ // The server clears omitted ?source=/?title= on every re-sync, so both must be re-sent.
727
+ const source = (args.source && args.source !== true ? String(args.source) : '') || cfg.source || '';
728
+ if (source)
729
+ q.set('source', source);
730
+ const title = (args.title && args.title !== true ? String(args.title) : '') || cfg.title || '';
731
+ if (title)
732
+ q.set('title', title);
733
+ const endpoint = `${cfg.base}/api/v1/sets/${encodeURIComponent(cfg.project)}/${encodeURIComponent(cfg.slug)}/${encodeURIComponent(cfg.key)}/content?${q}`;
734
+ const headers = { 'Content-Type': 'application/gzip' };
735
+ const token = readToken();
736
+ if (token)
737
+ headers.Authorization = `Bearer ${token}`;
738
+ let res;
739
+ try {
740
+ res = await fetch(endpoint, { method: 'PUT', headers, body: tar.stdout, signal: AbortSignal.timeout(120_000) });
741
+ }
742
+ catch (e) {
743
+ const msg = e instanceof Error ? e.message : String(e);
744
+ markOffline(root, msg);
745
+ console.error(`push: vitrinka unreachable (${msg}) — shots stay local; any later push backfills the whole set`);
746
+ process.exit(1);
747
+ }
748
+ const text = await res.text();
749
+ if (!res.ok) {
750
+ markOffline(root, `HTTP ${res.status}: ${text.slice(0, 200)}`);
751
+ console.error(`push: server rejected (HTTP ${res.status}): ${text.slice(0, 300)}`);
752
+ // Additive AI hint for a 4xx (client-side) rejection — never blocks the exit.
753
+ if (res.status >= 400 && res.status < 500) {
754
+ await emitHint(`vitrinka push --root ${root}`, `HTTP ${res.status}: ${text.slice(0, 300)}`, renderCmdHelp('push'));
755
+ }
756
+ process.exit(1);
757
+ }
758
+ let out = {};
759
+ try {
760
+ out = JSON.parse(text);
761
+ }
762
+ catch (e) {
763
+ // 2xx with a non-JSON body is unexpected — log it, but the sync itself succeeded.
764
+ console.error(`push: warning — success response was not JSON (${e.message}); using alias URL`);
765
+ }
766
+ if (existsSync(offlineMarkerPath(root)))
767
+ rmSync(offlineMarkerPath(root));
768
+ // Persist explicitly-passed --source/--title (on success only, so a server-rejected
769
+ // value never sticks) — the next plain `push` re-sends them instead of silently
770
+ // clearing the cross-link chip / human title server-side.
771
+ const explicitSource = args.source && args.source !== true ? String(args.source) : '';
772
+ const explicitTitle = args.title && args.title !== true ? String(args.title) : '';
773
+ if ((explicitSource && explicitSource !== cfg.source) || (explicitTitle && explicitTitle !== cfg.title)) {
774
+ if (explicitSource)
775
+ cfg.source = explicitSource;
776
+ if (explicitTitle)
777
+ cfg.title = explicitTitle;
778
+ writeFileSync(vitrinkaCfgPath(root), JSON.stringify(cfg, null, 2) + '\n');
779
+ }
780
+ console.log(`↑ synced ${out.files ?? '?'} files → ${out.url || shareUrl(cfg)}`);
781
+ // Touchpoint refresh of the project's file/route index (autocomplete
782
+ // source) — hash-gated both ends, never fails the set push.
783
+ await pushProjectIndex(projectDir, cfg.base, cfg.project);
784
+ // --name: name the set right after publish (PATCH; names are sticky
785
+ // server-side, so unlike --title it is never re-sent on later pushes).
786
+ // A naming failure warns loudly but exits 0 — the content itself is up.
787
+ const name = strArg(args.name);
788
+ if (name) {
789
+ const nameRes = await patchSetMeta(cfg.base, cfg.project, cfg.slug, cfg.key, { name: name.toLowerCase() });
790
+ if (nameRes.ok)
791
+ console.log(` name → ${cfg.base}/${cfg.project}/${cfg.slug}/${encodeURIComponent(name.toLowerCase())}`);
792
+ else
793
+ console.error(`push: warning — naming failed (${nameRes.error}); content is up, retry with: node ${CLI_PATH} name ${cfg.project}/${cfg.slug}/${cfg.key} ${name}`);
794
+ }
795
+ }
796
+ // ---------- set metadata (name/title) ----------
797
+ // patchSetMeta PATCHes /api/v1/sets/{project}/{slug}/{selector}; never throws.
798
+ async function patchSetMeta(base, project, slug, selector, body) {
799
+ const endpoint = `${base}/api/v1/sets/${encodeURIComponent(project)}/${encodeURIComponent(slug)}/${encodeURIComponent(selector)}`;
800
+ const headers = { 'Content-Type': 'application/json' };
801
+ const token = readToken();
802
+ if (token)
803
+ headers.Authorization = `Bearer ${token}`;
804
+ let res;
805
+ try {
806
+ res = await fetch(endpoint, {
807
+ method: 'PATCH', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000),
808
+ });
809
+ }
810
+ catch (e) {
811
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
812
+ }
813
+ const text = await res.text();
814
+ if (!res.ok)
815
+ return { ok: false, error: `HTTP ${res.status}: ${text.slice(0, 200)}` };
816
+ try {
817
+ return { ok: true, json: JSON.parse(text) };
818
+ }
819
+ catch {
820
+ return { ok: true, json: {} }; // 2xx with non-JSON body — the PATCH itself succeeded
821
+ }
822
+ }
823
+ // nameCmd: `name <project/branch/selector> <new-name>` (or `--clear`) — set or
824
+ // clear the custom, URL-addressable name of an existing set.
825
+ async function nameCmd(rest, args) {
826
+ const pos = positionals(rest).slice(1); // drop the command word itself
827
+ const ref = pos[0] || '';
828
+ const segs = ref.split('/');
829
+ if (segs.length !== 3 || segs.some((s) => !s)) {
830
+ fail(`name: expected <project>/<branchSlug>/<selector> (selector = version, key, or current name), got ${JSON.stringify(ref)}`, 2);
831
+ }
832
+ const clear = args.clear === true;
833
+ const newName = (pos[1] || '').toLowerCase();
834
+ if (!clear && !newName)
835
+ fail('name: expected a new name (or --clear)', 2);
836
+ if (clear && newName)
837
+ fail('name: pass either a new name or --clear, not both', 2);
838
+ const base = (strArg(args.base) || process.env.VITRINKA_URL || 'https://vitrinka.lovinka.com').replace(/\/+$/, '');
839
+ const res = await patchSetMeta(base, segs[0], segs[1], segs[2], { name: clear ? '' : newName });
840
+ if (!res.ok)
841
+ fail(`name: ${res.error}`);
842
+ if (clear)
843
+ console.log(`cleared name of ${segs[0]}/${segs[1]}/${segs[2]} → ${res.json?.url || ''}`);
844
+ else
845
+ console.log(`named ${segs[0]}/${segs[1]}/${segs[2]} → ${base}/${segs[0]}/${segs[1]}/${encodeURIComponent(newName)}`);
846
+ }
847
+ // ---------- operator: the board-attribution persona ----------
848
+ // operatorCmd manages the operator persona name — how vitrinka credits this
849
+ // machine's writes on boards.
850
+ // operator → print the current local + server values
851
+ // operator <name> → write ~/.config/vitrinka/operator AND PUT the server
852
+ // default (needs the write token; server errors are
853
+ // reported but never fail the local write).
854
+ async function operatorCmd(rest, args) {
855
+ const pos = positionals(rest).slice(1); // drop the command word itself
856
+ const base = (strArg(args.base) || process.env.VITRINKA_URL || process.env.VITRINKA_BASE_URL
857
+ || 'https://vitrinka.lovinka.com').replace(/\/+$/, '');
858
+ const name = pos.join(' ').trim();
859
+ if (!name) {
860
+ const local = readOperator(true);
861
+ console.log(`operator (local): ${local || '(unset)'}`);
862
+ try {
863
+ const res = await fetch(`${base}/api/v1/operator`, { signal: AbortSignal.timeout(15_000) });
864
+ if (res.ok) {
865
+ const d = await res.json();
866
+ console.log(`operator (server): ${d.operator || '(unset)'}`);
867
+ }
868
+ else {
869
+ console.log(`operator (server): unavailable (HTTP ${res.status})`);
870
+ }
871
+ }
872
+ catch (e) {
873
+ console.log(`operator (server): unreachable (${e instanceof Error ? e.message : String(e)})`);
874
+ }
875
+ return;
876
+ }
877
+ if (name.length > 64)
878
+ fail('operator: name too long (max 64 chars)', 2);
879
+ // Local write first — it never needs the network.
880
+ const p = operatorPath();
881
+ mkdirSync(dirname(p), { recursive: true });
882
+ writeFileSync(p, name + '\n');
883
+ console.log(`operator (local): ${name} → ${p}`);
884
+ // Then publish the server default (Bearer). Reachability/auth problems are
885
+ // reported but do not undo the local write.
886
+ const token = readToken();
887
+ if (!token) {
888
+ console.error('operator: no write token — server default NOT set (add ~/.config/vitrinka/token or $VITRINKA_TOKEN, then re-run)');
889
+ return;
890
+ }
891
+ try {
892
+ const res = await fetch(`${base}/api/v1/operator`, {
893
+ method: 'PUT',
894
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
895
+ body: JSON.stringify({ operator: name }),
896
+ signal: AbortSignal.timeout(15_000),
897
+ });
898
+ if (res.ok)
899
+ console.log(`operator (server): ${name} → ${base}`);
900
+ else
901
+ console.error(`operator: server rejected (HTTP ${res.status}): ${(await res.text()).slice(0, 200)}`);
902
+ }
903
+ catch (e) {
904
+ console.error(`operator: server unreachable (${e instanceof Error ? e.message : String(e)}) — local name is set; re-run to publish`);
905
+ }
906
+ }
907
+ // ---------- install: PATH shim ----------
908
+ // installCmd writes an executable shim `vitrinka` into ~/.local/bin (so the
909
+ // CLI works in interactive shells AND non-interactive AI/tool shells, which a
910
+ // zshrc alias would not) and makes sure ~/.local/bin is on PATH via a
911
+ // marker-guarded ~/.zshrc block. Idempotent: re-running reports and no-ops.
912
+ function installCmd(args) {
913
+ const home = process.env.HOME;
914
+ if (!home)
915
+ fail('install: $HOME is not set');
916
+ const binDir = join(home, '.local', 'bin');
917
+ const shimPath = join(binDir, 'vitrinka');
918
+ const shim = `#!/bin/sh\nexec node "${CLI_PATH}" "$@"\n`;
919
+ if (existsSync(shimPath) && readFileSync(shimPath, 'utf8') === shim) {
920
+ console.log(`shim already installed: ${shimPath}`);
921
+ }
922
+ else {
923
+ mkdirSync(binDir, { recursive: true });
924
+ writeFileSync(shimPath, shim);
925
+ chmodSync(shimPath, 0o755);
926
+ console.log(`installed shim: ${shimPath} → node ${CLI_PATH}`);
927
+ }
928
+ const marker = '# vitrinka-cli';
929
+ const zshrc = join(home, '.zshrc');
930
+ const zshrcContent = existsSync(zshrc) ? readFileSync(zshrc, 'utf8') : '';
931
+ if (zshrcContent.includes(marker)) {
932
+ console.log(`PATH block already in ${zshrc}`);
933
+ }
934
+ else if ((process.env.PATH || '').split(':').includes(binDir)) {
935
+ console.log(`${binDir} already on PATH — ${zshrc} left untouched`);
936
+ }
937
+ else {
938
+ appendFileSync(zshrc, `\n${marker}\nexport PATH="$HOME/.local/bin:$PATH"\n`);
939
+ console.log(`added PATH block to ${zshrc} (open a new shell, or: source ${zshrc})`);
940
+ }
941
+ // Shell completion — state-aware, opt-out via --no-completion. Appends a
942
+ // single guarded `eval "$(vitrinka completion zsh)"` line (skipped if present).
943
+ if (args['no-completion'] === true) {
944
+ console.log('completion: skipped (--no-completion)');
945
+ }
946
+ else {
947
+ const compMarker = '# vitrinka-completion';
948
+ const shellName = (process.env.SHELL || '').includes('bash') ? 'bash' : 'zsh';
949
+ const rc = join(home, shellName === 'bash' ? '.bashrc' : '.zshrc');
950
+ const rcContent = existsSync(rc) ? readFileSync(rc, 'utf8') : '';
951
+ if (rcContent.includes(compMarker)) {
952
+ console.log(`completion already enabled in ${rc}`);
953
+ }
954
+ else {
955
+ appendFileSync(rc, `\n${compMarker}\neval "$(vitrinka completion ${shellName})"\n`);
956
+ console.log(`added ${shellName} completion to ${rc} (open a new shell, or: source ${rc})`);
957
+ }
958
+ }
959
+ console.log('done — try: vitrinka --help');
960
+ }
961
+ // ---------- snap: one-shot capture → manifest → detached push ----------
962
+ // Filename slug: diacritics folded (Přihlášení → prihlaseni), non-alnum → '-', capped.
963
+ export function kebab(s) {
964
+ const out = String(s)
965
+ .normalize('NFKD')
966
+ .replace(/[\u0300-\u036f]/g, '')
967
+ .toLowerCase()
968
+ .replace(/[^a-z0-9]+/g, '-')
969
+ .replace(/^-+|-+$/g, '')
970
+ .slice(0, 60)
971
+ .replace(/-+$/, '');
972
+ return out || 'shot';
973
+ }
974
+ // NN sequence = max already-used prefix in the dated dir + 1 (ignores index.html etc.).
975
+ // Max-based, not count-based: after a manual shot deletion a count would re-issue a
976
+ // surviving file's number and the next snap would silently overwrite it.
977
+ export function nextSeq(dir) {
978
+ let max = 0;
979
+ if (existsSync(dir)) {
980
+ for (const f of readdirSync(dir)) {
981
+ const m = /^(\d{2,})-/.exec(f);
982
+ if (m)
983
+ max = Math.max(max, Number(m[1]));
984
+ }
985
+ }
986
+ return String(max + 1).padStart(2, '0');
987
+ }
988
+ export function deriveShotName(seq, surface, titleOrRoute, ext = '.png') {
989
+ return `${seq}-${surface}-${kebab(titleOrRoute)}${ext}`;
990
+ }
991
+ const UDID_RE = /^[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}$/;
992
+ // BFS for a UUID-shaped string; keys containing "udid" win over any other UUID-shaped value.
993
+ export function findUdid(v) {
994
+ const queue = [v];
995
+ const preferred = [];
996
+ const fallback = [];
997
+ while (queue.length) {
998
+ const cur = queue.shift();
999
+ if (!cur || typeof cur !== 'object')
1000
+ continue;
1001
+ for (const [k, val] of Object.entries(cur)) {
1002
+ if (typeof val === 'string' && UDID_RE.test(val.trim())) {
1003
+ (/udid/i.test(k) ? preferred : fallback).push(val.trim());
1004
+ }
1005
+ else if (val && typeof val === 'object') {
1006
+ queue.push(val);
1007
+ }
1008
+ }
1009
+ }
1010
+ return preferred[0] || fallback[0] || '';
1011
+ }
1012
+ function udidFromWorktreeFile(projectDir) {
1013
+ const p = join(projectDir, '.fixit-worktree.json');
1014
+ if (!existsSync(p))
1015
+ return '';
1016
+ let parsed;
1017
+ try {
1018
+ parsed = JSON.parse(readFileSync(p, 'utf8'));
1019
+ }
1020
+ catch (e) {
1021
+ // Defensive read by design: a malformed worktree file must not block capture — note it, fall through.
1022
+ console.error(`snap: ignoring malformed ${p} (${e.message})`);
1023
+ return '';
1024
+ }
1025
+ return findUdid(parsed);
1026
+ }
1027
+ function bootedSims() {
1028
+ const r = spawnSync('xcrun', ['simctl', 'list', 'devices', 'booted', '-j'], { encoding: 'utf8' });
1029
+ if (r.error)
1030
+ fail(`snap ios: xcrun unavailable: ${r.error.message}`);
1031
+ if (r.status !== 0)
1032
+ fail(`snap ios: simctl list failed: ${(r.stderr || '').trim()}`);
1033
+ let parsed;
1034
+ try {
1035
+ parsed = JSON.parse(r.stdout);
1036
+ }
1037
+ catch (e) {
1038
+ fail(`snap ios: could not parse simctl list output (${e.message})`);
1039
+ }
1040
+ const out = [];
1041
+ for (const list of Object.values(parsed.devices || {})) {
1042
+ for (const d of list) {
1043
+ if (d.state === 'Booted' && d.udid)
1044
+ out.push({ udid: d.udid, name: d.name || '' });
1045
+ }
1046
+ }
1047
+ return out;
1048
+ }
1049
+ // Also carries the simulator NAME when the resolution path already listed
1050
+ // sims — the v2 device field fills for free, with no extra xcrun call and no
1051
+ // new failure mode on the --udid / worktree-file fast paths.
1052
+ function resolveIosUdid(args, projectDir) {
1053
+ const flag = strArg(args.udid);
1054
+ if (flag)
1055
+ return { udid: flag };
1056
+ const fromFile = udidFromWorktreeFile(projectDir);
1057
+ if (fromFile)
1058
+ return { udid: fromFile };
1059
+ const booted = bootedSims();
1060
+ if (booted.length === 1)
1061
+ return booted[0];
1062
+ if (booted.length === 0)
1063
+ fail('snap ios: no booted simulator (and no --udid / .fixit-worktree.json)');
1064
+ fail(`snap ios: ${booted.length} booted simulators — pass --udid. Booted:\n`
1065
+ + booted.map((b) => ` ${b.udid} ${b.name}`).join('\n'));
1066
+ }
1067
+ // The detached push means push failures are invisible to the caller — so snap surfaces a
1068
+ // pre-existing offline marker instead, exactly once (the marker records that it was shown;
1069
+ // markOffline carries the flag across repeated failures, and a successful push deletes the
1070
+ // marker, re-arming the warning for the next offline episode).
1071
+ function maybeWarnOffline(root) {
1072
+ const p = offlineMarkerPath(root);
1073
+ if (!existsSync(p))
1074
+ return;
1075
+ let marker = {};
1076
+ try {
1077
+ marker = JSON.parse(readFileSync(p, 'utf8'));
1078
+ }
1079
+ catch {
1080
+ // Malformed marker (half-written by a killed push) is recoverable: still warn, then rewrite it.
1081
+ }
1082
+ if (marker.warned)
1083
+ return;
1084
+ console.error(`vitrinka unreachable on a previous push (${marker.error || 'unknown error'}) — shots stay local; any later successful push backfills the whole set`);
1085
+ writeFileSync(p, JSON.stringify({ ...marker, warned: true }) + '\n');
1086
+ }
1087
+ // safeSize returns a file's byte size, or undefined if it can't be stat'd
1088
+ // (missing/racing) — the encoding decision then degrades to "keep original".
1089
+ function safeSize(path) {
1090
+ try {
1091
+ return statSync(path).size;
1092
+ }
1093
+ catch {
1094
+ return undefined;
1095
+ }
1096
+ }
1097
+ // chooseEncoding is the pure decision behind snap's WebP normalization:
1098
+ // - no cwebp → keep the original (PNG/JPEG untouched).
1099
+ // - normal → lossy q85 WebP (small review-page assets).
1100
+ // - --hq → lossless WebP ONLY when it's smaller than the
1101
+ // original (pixel-identical, fewer bytes); else
1102
+ // keep the original PNG. NEVER lossy, NEVER resize.
1103
+ export function chooseEncoding(i) {
1104
+ if (!i.cwebpAvailable)
1105
+ return 'original';
1106
+ if (!i.hq)
1107
+ return 'lossy';
1108
+ if (i.losslessSize !== undefined && i.pngSize !== undefined && i.losslessSize < i.pngSize) {
1109
+ return 'lossless';
1110
+ }
1111
+ return 'original';
1112
+ }
1113
+ async function snapCmd(surface, args) {
1114
+ if (!['ios', 'android', 'macos', 'web'].includes(surface)) {
1115
+ console.error(`snap: surface must be ios|android|macos|web, got ${JSON.stringify(surface)}`);
1116
+ process.exit(2);
1117
+ }
1118
+ const root = resolve(strArg(args.root) || '.screenshots');
1119
+ const projectDir = dirname(root);
1120
+ const route = strArg(args.route);
1121
+ const note = strArg(args.note);
1122
+ if (!route) {
1123
+ console.error('snap: --route is required');
1124
+ process.exit(2);
1125
+ }
1126
+ if (!note) {
1127
+ console.error('snap: --note is required');
1128
+ process.exit(2);
1129
+ }
1130
+ const adopt = strArg(args.file);
1131
+ if (surface === 'web' && !adopt) {
1132
+ console.error('snap web: --file <path> is required (capture via the browser MCP, then adopt the file)');
1133
+ process.exit(2);
1134
+ }
1135
+ // --open + --settle: navigate-and-wait live INSIDE the tool, so a capture is
1136
+ // ONE command — never a shell `sleep N; openurl; sleep M; snap` chain (which
1137
+ // agent harnesses rightly block). --settle defaults to 4 s when --open is
1138
+ // used (deep-link → render time), 0 otherwise; both are overridable.
1139
+ const openUrl = strArg(args.open);
1140
+ if (openUrl && surface === 'web')
1141
+ fail('snap web: --open is not supported (drive the browser via the MCP, then adopt with --file)', 2);
1142
+ if (openUrl && adopt)
1143
+ fail('snap: --open cannot be combined with --file (adopt captures nothing)', 2);
1144
+ const settle = numArg(args.settle, openUrl ? 4 : 0, '--settle');
1145
+ let iosUdid = '';
1146
+ let iosSimName = '';
1147
+ if (surface === 'ios' && !adopt) {
1148
+ const sim = resolveIosUdid(args, projectDir);
1149
+ iosUdid = sim.udid;
1150
+ iosSimName = sim.name || '';
1151
+ }
1152
+ if (openUrl) {
1153
+ if (surface === 'ios') {
1154
+ const r = spawnSync('xcrun', ['simctl', 'openurl', iosUdid, openUrl], { encoding: 'utf8' });
1155
+ if (r.error)
1156
+ fail(`snap ios: xcrun unavailable: ${r.error.message}`);
1157
+ if (r.status !== 0)
1158
+ fail(`snap ios: simctl openurl failed: ${(r.stderr || '').trim()}`);
1159
+ }
1160
+ else if (surface === 'android') {
1161
+ const r = spawnSync('adb', ['shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl], { encoding: 'utf8' });
1162
+ if (r.error)
1163
+ fail(`snap android: adb unavailable: ${r.error.message}`);
1164
+ if (r.status !== 0)
1165
+ fail(`snap android: am start failed: ${(r.stderr || '').trim()}`);
1166
+ }
1167
+ else { // macos
1168
+ const r = spawnSync('open', [openUrl], { encoding: 'utf8' });
1169
+ if (r.error)
1170
+ fail(`snap macos: open unavailable: ${r.error.message}`);
1171
+ if (r.status !== 0)
1172
+ fail(`snap macos: open failed: ${(r.stderr || '').trim()}`);
1173
+ }
1174
+ }
1175
+ if (settle > 0)
1176
+ await new Promise((r) => setTimeout(r, settle * 1000));
1177
+ const d = new Date();
1178
+ const pad = (n) => String(n).padStart(2, '0');
1179
+ const day = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
1180
+ const dir = join(root, day);
1181
+ mkdirSync(dir, { recursive: true });
1182
+ const ext = adopt ? (extname(adopt).toLowerCase() || '.png') : '.png';
1183
+ let name = deriveShotName(nextSeq(dir), surface, strArg(args.title) || route, ext);
1184
+ let dest = join(dir, name);
1185
+ if (adopt) {
1186
+ const src = resolve(adopt);
1187
+ if (!existsSync(src)) {
1188
+ console.error(`snap: --file ${src} does not exist`);
1189
+ process.exit(2);
1190
+ }
1191
+ copyFileSync(src, dest);
1192
+ }
1193
+ else if (surface === 'ios') {
1194
+ const r = spawnSync('xcrun', ['simctl', 'io', iosUdid, 'screenshot', dest], { encoding: 'utf8' });
1195
+ if (r.error)
1196
+ fail(`snap ios: xcrun unavailable: ${r.error.message}`);
1197
+ if (r.status !== 0)
1198
+ fail(`snap ios: simctl screenshot failed: ${(r.stderr || '').trim()}`);
1199
+ }
1200
+ else if (surface === 'android') {
1201
+ const r = spawnSync('adb', ['exec-out', 'screencap', '-p'], { maxBuffer: 64 * 1024 * 1024 });
1202
+ if (r.error)
1203
+ fail(`snap android: adb unavailable: ${r.error.message}`);
1204
+ if (r.status !== 0)
1205
+ fail(`snap android: screencap failed: ${String(r.stderr || '').trim()}`);
1206
+ writeFileSync(dest, r.stdout);
1207
+ }
1208
+ else { // macos
1209
+ const region = strArg(args.region);
1210
+ const scArgs = ['-x', ...(region ? ['-R', region] : []), dest];
1211
+ const r = spawnSync('screencapture', scArgs, { encoding: 'utf8' });
1212
+ if (r.error)
1213
+ fail(`snap macos: screencapture unavailable: ${r.error.message}`);
1214
+ if (r.status !== 0)
1215
+ fail(`snap macos: screencapture failed: ${(r.stderr || '').trim()}`);
1216
+ }
1217
+ // ---------- WebP normalization ----------
1218
+ // Native captures are lossless PNG (a 3x sim frame is 2–5 MB); the review
1219
+ // page doesn't need lossless, and `push` re-uploads the WHOLE set as one
1220
+ // PUT — full-res PNGs blow the proxy body cap once a set spans sessions
1221
+ // (2026-07-03: 317 MB set → HTTP 413, silently, on every detached push).
1222
+ // WebP q85 keeps native resolution at ~5–10% of the PNG bytes. cwebp is a
1223
+ // soft dependency (brew install webp): when missing, keep the original.
1224
+ //
1225
+ // `--hq` (marketing / full-fidelity): NEVER lossy-re-encode, NEVER resize —
1226
+ // keep the original PNG, but if cwebp is present try `-lossless` and keep it
1227
+ // only when it comes out smaller than the PNG (pixel-identical, fewer bytes).
1228
+ const hq = args.hq === true;
1229
+ if (/\.(png|jpe?g)$/i.test(name)) {
1230
+ const webpName = name.replace(/\.(png|jpe?g)$/i, '.webp');
1231
+ const webpDest = join(dir, webpName);
1232
+ const cwebpArgs = hq
1233
+ ? ['-quiet', '-lossless', dest, '-o', webpDest]
1234
+ : ['-quiet', '-q', '85', dest, '-o', webpDest];
1235
+ const w = spawnSync('cwebp', cwebpArgs, { encoding: 'utf8' });
1236
+ const cwebpOk = !w.error && w.status === 0 && existsSync(webpDest);
1237
+ const choice = chooseEncoding({
1238
+ hq,
1239
+ cwebpAvailable: cwebpOk,
1240
+ pngSize: safeSize(dest),
1241
+ losslessSize: hq && cwebpOk ? safeSize(webpDest) : undefined,
1242
+ });
1243
+ if (choice === 'original') {
1244
+ rmSync(webpDest, { force: true });
1245
+ if (!hq && !cwebpOk) {
1246
+ console.error('snap: cwebp unavailable/failed — keeping the original (brew install webp for q85 WebP sets)');
1247
+ }
1248
+ }
1249
+ else {
1250
+ rmSync(dest);
1251
+ name = webpName;
1252
+ dest = webpDest;
1253
+ }
1254
+ }
1255
+ const ctx = shotContext(args);
1256
+ // iOS knows its simulator — auto-fill the device name when the resolution
1257
+ // path already learned it (zero extra xcrun calls).
1258
+ if (surface === 'ios' && iosSimName && !ctx.device?.device) {
1259
+ ctx.device = { ...ctx.device, device: iosSimName };
1260
+ }
1261
+ const { count } = appendShot(root, {
1262
+ file: `${day}/${name}`,
1263
+ surface,
1264
+ route,
1265
+ note,
1266
+ label: strArg(args.label) || undefined,
1267
+ title: strArg(args.title) || undefined,
1268
+ action: strArg(args.action) || undefined,
1269
+ hq: hq || undefined,
1270
+ ts: new Date().toISOString(),
1271
+ ...ctx,
1272
+ });
1273
+ maybeWarnOffline(root);
1274
+ // (shot N) = 1-based manifest position — what embed-shots/artifact-from-set --select
1275
+ // addresses. The filename NN restarts per dated dir; the position never does.
1276
+ console.log(`saved ${dest} (shot ${count})`);
1277
+ if (existsSync(vitrinkaCfgPath(root))) {
1278
+ // Detached, fire-and-forget: the harness gets no background task; the next snap/push backfills.
1279
+ spawn(process.execPath, [CLI_PATH, 'push', '--root', root], { detached: true, stdio: 'ignore' }).unref();
1280
+ console.log(`share ${shareUrl(loadVitrinkaCfg(root))}`);
1281
+ }
1282
+ else {
1283
+ console.log(`(no ${vitrinkaCfgPath(root)} — run remote-init to publish; shot saved locally)`);
1284
+ }
1285
+ console.log('NOW READ THE IMAGE TO VERIFY');
1286
+ }
1287
+ // ---------- embed-shots: manifest shots → resized JPEG data URIs in one data.json ----------
1288
+ // "1,3-5" → [1,3,4,5] — 1-based manifest positions, deduped, ascending.
1289
+ export function parseSelect(spec, count) {
1290
+ const picked = new Set();
1291
+ for (const tok of String(spec).split(',')) {
1292
+ const t = tok.trim();
1293
+ if (!t)
1294
+ continue;
1295
+ const m = t.match(/^(\d+)(?:-(\d+))?$/);
1296
+ if (!m)
1297
+ throw new Error(`--select: bad token ${JSON.stringify(t)} (expected N or A-B)`);
1298
+ const a = Number(m[1]);
1299
+ const b = m[2] === undefined ? a : Number(m[2]);
1300
+ if (a < 1 || b < a)
1301
+ throw new Error(`--select: bad range "${t}"`);
1302
+ if (b > count)
1303
+ throw new Error(`--select: ${b} out of range (set has ${count} shots)`);
1304
+ for (let i = a; i <= b; i++)
1305
+ picked.add(i);
1306
+ }
1307
+ if (!picked.size)
1308
+ throw new Error('--select: empty selection');
1309
+ return [...picked].sort((x, y) => x - y);
1310
+ }
1311
+ export function mimeFor(file) {
1312
+ const e = extname(String(file)).toLowerCase();
1313
+ if (e === '.png')
1314
+ return 'image/png';
1315
+ if (e === '.jpg' || e === '.jpeg')
1316
+ return 'image/jpeg';
1317
+ if (e === '.webp')
1318
+ return 'image/webp';
1319
+ if (e === '.gif')
1320
+ return 'image/gif';
1321
+ return 'application/octet-stream';
1322
+ }
1323
+ function embedShots(root, opts) {
1324
+ const m = loadManifest(root);
1325
+ if (!m.shots.length)
1326
+ fail(`embed-shots: no shots in ${manifestPathOf(root)}`, 2);
1327
+ let indices;
1328
+ try {
1329
+ indices = opts.select ? parseSelect(opts.select, m.shots.length) : m.shots.map((_, i) => i + 1);
1330
+ }
1331
+ catch (e) {
1332
+ fail(`embed-shots: ${e.message}`, 2);
1333
+ }
1334
+ const tmp = mkdtempSync(join(tmpdir(), 'vitrinka-embed-'));
1335
+ let sipsMissing = false;
1336
+ const shots = [];
1337
+ try {
1338
+ for (const i of indices) {
1339
+ const s = m.shots[i - 1];
1340
+ const srcPath = join(root, s.file);
1341
+ if (!existsSync(srcPath))
1342
+ fail(`embed-shots: shot ${i} file missing: ${srcPath} (manifest and folder out of sync)`);
1343
+ let src = '';
1344
+ if (!sipsMissing) {
1345
+ const outPath = join(tmp, `${i}.jpg`);
1346
+ const r = spawnSync('sips', [
1347
+ '-Z', String(opts.size),
1348
+ '-s', 'format', 'jpeg',
1349
+ '-s', 'formatOptions', String(opts.quality),
1350
+ srcPath, '--out', outPath,
1351
+ ], { encoding: 'utf8' });
1352
+ if (r.error && r.error.code === 'ENOENT') {
1353
+ sipsMissing = true; // no sips (non-macOS): embed originals, warn once
1354
+ console.error('embed-shots: sips not found — embedding original files unresized');
1355
+ }
1356
+ else if (r.error) {
1357
+ throw new Error(`embed-shots: sips failed: ${r.error.message}`);
1358
+ }
1359
+ else if (r.status !== 0) {
1360
+ throw new Error(`embed-shots: sips failed on ${srcPath}: ${(r.stderr || '').trim()}`);
1361
+ }
1362
+ else {
1363
+ src = `data:image/jpeg;base64,${readFileSync(outPath).toString('base64')}`;
1364
+ }
1365
+ }
1366
+ if (sipsMissing) {
1367
+ src = `data:${mimeFor(s.file)};base64,${readFileSync(srcPath).toString('base64')}`;
1368
+ }
1369
+ shots.push({ label: s.label, title: s.title, route: s.route, note: s.note, ts: s.ts, src });
1370
+ }
1371
+ }
1372
+ finally {
1373
+ rmSync(tmp, { recursive: true, force: true });
1374
+ }
1375
+ const data = { journey: m.journey && typeof m.journey === 'object' ? m.journey : {}, shots };
1376
+ const outAbs = resolve(opts.out);
1377
+ mkdirSync(dirname(outAbs), { recursive: true });
1378
+ writeFileSync(outAbs, JSON.stringify(data, null, 2) + '\n');
1379
+ return { out: outAbs, count: shots.length, sipsMissing };
1380
+ }
1381
+ function embedShotsCmd(root, args) {
1382
+ const out = strArg(args.out);
1383
+ if (!out) {
1384
+ console.error('embed-shots: --out <dir>/data.json is required');
1385
+ process.exit(2);
1386
+ }
1387
+ const res = embedShots(root, {
1388
+ select: strArg(args.select) || undefined,
1389
+ size: numArg(args.size, 720, '--size'),
1390
+ quality: numArg(args.quality, 85, '--quality'),
1391
+ out,
1392
+ });
1393
+ console.log(`embedded ${res.count} shots → ${res.out}${res.sipsMissing ? ' (originals — sips unavailable)' : ''}`);
1394
+ }
1395
+ // ---------- artifact scaffolding ----------
1396
+ function ensureGitIgnored(projectDir, entry) {
1397
+ const top = git(['rev-parse', '--show-toplevel'], projectDir) || projectDir;
1398
+ const gi = join(top, '.gitignore');
1399
+ const cur = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
1400
+ const bare = entry.replace(/\/$/, '');
1401
+ const has = cur.split(/\r?\n/).some((l) => {
1402
+ const t = l.trim();
1403
+ return t === entry || t === bare || t === `/${entry}` || t === `/${bare}`;
1404
+ });
1405
+ if (!has)
1406
+ writeFileSync(gi, cur + (cur && !cur.endsWith('\n') ? '\n' : '') + entry + '\n');
1407
+ }
1408
+ // Shared <head> + module prologue: pinned import map (kit-1 / zipstore-1 are versioned,
1409
+ // immutable vendor files), tailwind, htm binding. xyflow stays commented until needed.
1410
+ function scaffoldHead(title) {
1411
+ return `<!doctype html>
1412
+ <html lang="en">
1413
+ <head>
1414
+ <meta charset="utf-8">
1415
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1416
+ <title>${esc(title)}</title>
1417
+ <script type="importmap">
1418
+ { "imports": {
1419
+ "react": "/vendor/react.mjs",
1420
+ "react-dom/client": "/vendor/react-dom-client.mjs",
1421
+ "htm": "/vendor/htm.mjs",
1422
+ "kit-1": "/vendor/kit-1.mjs",
1423
+ "zipstore-1": "/vendor/zipstore-1.mjs"
1424
+ } }
1425
+ </script>
1426
+ <!-- Flow diagram? Add inside "imports" above:
1427
+ "@xyflow/react": "/vendor/xyflow-react.mjs"
1428
+ and add to <head>:
1429
+ <link rel="stylesheet" href="/vendor/xyflow-react.css">
1430
+ -->
1431
+ <script src="/vendor/tailwind.js"></script>
1432
+ </head>
1433
+ <body class="bg-[#f6f2ee] text-[#101113] antialiased">
1434
+ <div id="root"></div>
1435
+ <script type="module">
1436
+ import React from 'react';
1437
+ import { createRoot } from 'react-dom/client';
1438
+ import htm from 'htm';
1439
+ import { Hero, Chips, Section, ShotCard, StatRow, DownloadAllButton } from 'kit-1';
1440
+ const html = htm.bind(React.createElement);
1441
+ `;
1442
+ }
1443
+ const DATA_FETCH = `
1444
+ const res = await fetch('./data.json');
1445
+ if (!res.ok) throw new Error('data.json: HTTP ' + res.status);
1446
+ const DATA = await res.json();
1447
+ `;
1448
+ // The authored title is emitted as an htm interpolation (title=\${"…"}), never as a quoted
1449
+ // attribute — htm has no escape for an inner '"', which would truncate the Hero prop.
1450
+ function blankScaffold(title, hasData) {
1451
+ return scaffoldHead(title) + (hasData ? DATA_FETCH : `
1452
+ // TODO: inline your data here (or write a sibling ./data.json and fetch it)
1453
+ const DATA = {};
1454
+ `) + `
1455
+ function App() {
1456
+ return html\`<main class="mx-auto max-w-5xl px-8 py-12">
1457
+ <\${Hero} kicker="TODO" title=\${${JSON.stringify(title)}} accent="TODO accent phrase" intro="TODO 1-2 sentence intro" />
1458
+ <!-- TODO: compose the report from kit-1: Section, ShotCard, StatRow, Chips, DownloadAllButton -->
1459
+ </main>\`;
1460
+ }
1461
+ createRoot(document.getElementById('root')).render(html\`<\${App} />\`);
1462
+ </script>
1463
+ </body>
1464
+ </html>
1465
+ `;
1466
+ }
1467
+ function composedScaffold(title, zipName) {
1468
+ return scaffoldHead(title) + DATA_FETCH + `
1469
+ const kebab = (s) => String(s || 'shot').normalize('NFKD').replace(/[\\u0300-\\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'shot';
1470
+ const extOf = (src) => String(src).startsWith('data:image/png') ? '.png' : '.jpg';
1471
+ const files = DATA.shots.map((s, i) => ({
1472
+ name: String(i + 1).padStart(2, '0') + '-' + kebab(s.title || s.route) + extOf(s.src),
1473
+ dataUri: s.src,
1474
+ }));
1475
+
1476
+ function App() {
1477
+ const j = DATA.journey || {};
1478
+ return html\`<main class="mx-auto max-w-5xl px-8 py-12">
1479
+ <\${Hero} kicker=\${j.kicker} title=\${j.title} accent=\${j.accent} intro=\${j.intro} chips=\${j.chips} />
1480
+
1481
+ <!-- TODO: opening narrative — what this session shipped and why it matters -->
1482
+
1483
+ \${DATA.shots.map((s, i) => html\`
1484
+ <\${Section} key=\${i} label=\${s.label} title=\${s.title} note=\${s.note}>
1485
+ <\${ShotCard} src=\${s.src} alt=\${s.title || s.route || 'screenshot'} caption=\${s.note} />
1486
+ <//>
1487
+ \`)}
1488
+
1489
+ <!-- TODO: closing narrative / findings (StatRow and Chips from kit-1 are available) -->
1490
+
1491
+ <div class="mt-12">
1492
+ <\${DownloadAllButton} files=\${files} zipName=${JSON.stringify(zipName)} />
1493
+ </div>
1494
+ </main>\`;
1495
+ }
1496
+ createRoot(document.getElementById('root')).render(html\`<\${App} />\`);
1497
+ </script>
1498
+ </body>
1499
+ </html>
1500
+ `;
1501
+ }
1502
+ function artifactInitCmd(args) {
1503
+ const slugRaw = strArg(args.slug);
1504
+ if (!slugRaw) {
1505
+ console.error('artifact-init: --slug <slug> is required');
1506
+ process.exit(2);
1507
+ }
1508
+ const slug = sanitizeSeg(slugRaw, 64);
1509
+ assertValidKey(slug, 'artifact-init'); // the slug becomes the set key — fail before any writes
1510
+ const kind = strArg(args.kind) || 'report';
1511
+ const title = strArg(args.title) || slug;
1512
+ const dir = resolve('.artifacts', slug);
1513
+ const indexPath = join(dir, 'index.html');
1514
+ if (existsSync(indexPath)) {
1515
+ console.error(`artifact-init: ${indexPath} already exists — edit it in place (or remove the dir to re-scaffold)`);
1516
+ process.exit(2);
1517
+ }
1518
+ mkdirSync(dir, { recursive: true });
1519
+ writeFileSync(indexPath, blankScaffold(title, existsSync(join(dir, 'data.json'))));
1520
+ ensureGitIgnored(process.cwd(), '.artifacts/');
1521
+ remoteInitCmd(dir, { ...args, key: slug, kind });
1522
+ console.log(`scaffolded ${indexPath}`);
1523
+ console.log(`author the App, then: node ${CLI_PATH} push --root ${dir} --title "<Human title>"`);
1524
+ }
1525
+ function artifactFromSetCmd(args) {
1526
+ const slugRaw = strArg(args.slug);
1527
+ if (!slugRaw) {
1528
+ console.error('artifact-from-set: --slug <slug> is required');
1529
+ process.exit(2);
1530
+ }
1531
+ const slug = sanitizeSeg(slugRaw, 64);
1532
+ assertValidKey(slug, 'artifact-from-set'); // the slug becomes the set key — fail before any writes
1533
+ const from = resolve(strArg(args.from) || '.screenshots');
1534
+ const dir = resolve('.artifacts', slug);
1535
+ const indexPath = join(dir, 'index.html');
1536
+ if (existsSync(indexPath)) {
1537
+ console.error(`artifact-from-set: ${indexPath} already exists — edit it in place (or remove the dir to re-scaffold)`);
1538
+ process.exit(2);
1539
+ }
1540
+ mkdirSync(dir, { recursive: true });
1541
+ const res = embedShots(from, {
1542
+ select: strArg(args.select) || undefined,
1543
+ size: numArg(args.size, 720, '--size'),
1544
+ quality: numArg(args.quality, 85, '--quality'),
1545
+ out: join(dir, 'data.json'),
1546
+ });
1547
+ const fromManifest = loadManifest(from);
1548
+ const title = strArg(args.title) || (fromManifest.journey && fromManifest.journey.title) || slug;
1549
+ writeFileSync(indexPath, composedScaffold(title, `${slug}.zip`));
1550
+ ensureGitIgnored(process.cwd(), '.artifacts/');
1551
+ remoteInitCmd(dir, { ...args, key: slug, kind: strArg(args.kind) || 'report' });
1552
+ // Record the source screenshot set (project/branchSlug/key) so push sends ?source= and
1553
+ // vitrinka can cross-link set ↔ artifact both ways.
1554
+ let source = strArg(args.source);
1555
+ if (!source) {
1556
+ if (existsSync(vitrinkaCfgPath(from))) {
1557
+ const c = loadVitrinkaCfg(from);
1558
+ source = `${c.project}/${c.slug}/${c.key}`;
1559
+ }
1560
+ else {
1561
+ console.error(`artifact-from-set: no ${vitrinkaCfgPath(from)} — source set not recorded (pass --source project/branchSlug/key to cross-link)`);
1562
+ }
1563
+ }
1564
+ if (source) {
1565
+ const cfg = loadVitrinkaCfg(dir);
1566
+ cfg.source = source;
1567
+ writeFileSync(vitrinkaCfgPath(dir), JSON.stringify(cfg, null, 2) + '\n');
1568
+ }
1569
+ console.log(`scaffolded ${indexPath} (${res.count} shots embedded${source ? `, source ${source}` : ''})`);
1570
+ console.log(`write the narrative sections, then \`push\`: node ${CLI_PATH} push --root ${dir} --title "<Human title>"`);
1571
+ }
1572
+ const ROOT_FLAG = { f: '--root', arg: '<dir>', d: 'capture root (default .screenshots)' };
1573
+ const BASE_FLAG = { f: '--base', arg: '<url>', d: 'vitrinka base URL (default $VITRINKA_URL or the mesh host)' };
1574
+ const CTX_FLAGS = [
1575
+ { f: '--label', arg: '<STAGE>', d: 'journey stage label' },
1576
+ { f: '--title', arg: '<t>', d: 'short human title' },
1577
+ { f: '--action', arg: '<a>', d: 'edge label — the action leading to the NEXT shot' },
1578
+ { f: '--src', arg: '<file>', d: "implementing source file(s) (repeatable or comma-list)" },
1579
+ { f: '--state', arg: '<s>', d: 'app/auth state note' },
1580
+ { f: '--device', arg: '<name>', d: 'device name' },
1581
+ { f: '--viewport', arg: 'WxH[@scale]', d: 'viewport, e.g. 393x852@3' },
1582
+ ];
1583
+ export const COMMANDS = [
1584
+ {
1585
+ name: 'add', summary: 'Append a screenshot to the manifest, rebuild index.html',
1586
+ usage: '--root <dir> --file <rel> --surface <ios|android|web|macos> --route <r> --note <n> [ctx flags]',
1587
+ flags: [ROOT_FLAG, { f: '--file', arg: '<rel>', d: 'screenshot path relative to root (required)' },
1588
+ { f: '--surface', arg: '<ios|android|web|macos>', d: 'platform (default other)' },
1589
+ { f: '--route', arg: '<r>', d: 'app route' }, { f: '--note', arg: '<n>', d: 'caption' }, ...CTX_FLAGS],
1590
+ examples: ['vitrinka add --root .screenshots --file 2026-07-06/01-web-home.png --surface web --route /home --note "landing"'],
1591
+ },
1592
+ { name: 'build', summary: 'Rebuild index.html from the manifest', usage: '--root <dir>', flags: [ROOT_FLAG],
1593
+ examples: ['vitrinka build --root .screenshots'] },
1594
+ {
1595
+ name: 'meta', summary: 'Set the journey header (kicker/title/accent/intro/chips)',
1596
+ usage: '--root <dir> [--kicker <k>] [--title <t>] [--accent <substr>] [--intro <p>] [--chip "K=V" ...]',
1597
+ flags: [ROOT_FLAG, { f: '--kicker', arg: '<k>', d: 'eyebrow kicker' }, { f: '--title', arg: '<t>', d: 'display title' },
1598
+ { f: '--accent', arg: '<substr>', d: 'substring of the title to accent' }, { f: '--intro', arg: '<p>', d: 'intro paragraph' },
1599
+ { f: '--chip', arg: '"K=V"', d: 'meta chip (repeatable)' }],
1600
+ examples: ['vitrinka meta --root .screenshots --kicker "FLOW" --title "Payout flow" --accent "Payout"'],
1601
+ },
1602
+ {
1603
+ name: 'remote-init', summary: 'Create a .vitrinka set config for publishing',
1604
+ usage: '--root <dir> [--base <url>] [--project <p>] [--branch <b>] [--kind <k>] [--key <k>] [--issue <ref>]',
1605
+ flags: [ROOT_FLAG, BASE_FLAG, { f: '--project', arg: '<p>', d: 'project slug (default: main worktree name)' },
1606
+ { f: '--branch', arg: '<b>', d: 'branch (default: current)' }, { f: '--kind', arg: '<k>', d: 'set kind (default screenshots)' },
1607
+ { f: '--key', arg: '<k>', d: 'set key (default: timestamped)' }, { f: '--issue', arg: '<ref>', d: 'linked issue ref' }],
1608
+ dynamic: 'projects',
1609
+ examples: ['vitrinka remote-init --root .screenshots --project fixit --branch main'],
1610
+ },
1611
+ {
1612
+ name: 'push', summary: 'Publish the set to vitrinka (tar+PUT), refresh the index',
1613
+ usage: '--root <dir> [--title <t>] [--name <slug>] [--source <project/branchSlug/key>]',
1614
+ flags: [ROOT_FLAG, { f: '--title', arg: '<t>', d: 'human title (re-sent every push)' },
1615
+ { f: '--name', arg: '<slug>', d: 'name the set after publish (sticky)' },
1616
+ { f: '--source', arg: '<ref>', d: 'cross-link source set project/branchSlug/key' }],
1617
+ examples: ['vitrinka push --root .screenshots --title "Payout flow"'],
1618
+ },
1619
+ {
1620
+ name: 'name', summary: "Set or clear a set's custom URL name",
1621
+ usage: '<project/branchSlug/selector> <new-name> [--base <url>] (or --clear)',
1622
+ positional: '<project/branchSlug/selector>', dynamic: 'projects', write: true,
1623
+ flags: [BASE_FLAG, { f: '--clear', d: 'clear the custom name instead of setting one' }],
1624
+ examples: ['vitrinka name fixit/main/2 payout-flow', 'vitrinka name fixit/main/payout-flow --clear'],
1625
+ },
1626
+ {
1627
+ name: 'operator', summary: 'Show or set the board-attribution operator persona',
1628
+ usage: '[<name>] [--base <url>]', positional: '[<name>]', flags: [BASE_FLAG],
1629
+ examples: ['vitrinka operator', 'vitrinka operator "Lukáš"'],
1630
+ },
1631
+ {
1632
+ name: 'install', summary: 'Install the PATH shim (+ optional shell completion)',
1633
+ usage: '[--no-completion]', flags: [{ f: '--no-completion', d: 'skip appending the shell-completion line' }],
1634
+ examples: ['vitrinka install'],
1635
+ },
1636
+ {
1637
+ name: 'snap', summary: 'Capture a screenshot → manifest → detached push',
1638
+ usage: '<ios|android|macos|web> [--root <dir>] [--file <path>] --route <r> --note <n> [ctx flags] [--udid <u>] [--region x,y,w,h] [--open <deeplink>] [--settle <seconds>] [--hq]',
1639
+ positional: '<ios|android|macos|web>',
1640
+ flags: [ROOT_FLAG, { f: '--file', arg: '<path>', d: 'adopt an existing image instead of capturing' },
1641
+ { f: '--route', arg: '<r>', d: 'app route (required)' }, { f: '--note', arg: '<n>', d: 'caption (required)' }, ...CTX_FLAGS,
1642
+ { f: '--udid', arg: '<u>', d: 'iOS simulator UDID' }, { f: '--region', arg: 'x,y,w,h', d: 'macOS capture region' },
1643
+ { f: '--open', arg: '<deeplink>', d: 'open a deep link, then settle before capturing' },
1644
+ { f: '--settle', arg: '<seconds>', d: 'wait after --open (default 4)' },
1645
+ { f: '--hq', d: 'full-fidelity marketing asset: no lossy re-encode, no resize (lossless WebP only if smaller)' }],
1646
+ examples: ['vitrinka snap ios --route /home --note "landing"', 'vitrinka snap web --route / --note "hero" --hq'],
1647
+ },
1648
+ {
1649
+ name: 'board-from-set', summary: 'Turn the set into an annotation flow board',
1650
+ usage: '[--root <dir>] [--slug <board>] [--btitle <t>] [--actor <name>]',
1651
+ flags: [ROOT_FLAG, { f: '--slug', arg: '<board>', d: 'board slug (default: set key)' },
1652
+ { f: '--btitle', arg: '<t>', d: 'board title' }, { f: '--actor', arg: '<name>', d: 'override the operator attribution' }],
1653
+ dynamic: 'boards',
1654
+ examples: ['vitrinka board-from-set --root .screenshots'],
1655
+ },
1656
+ {
1657
+ name: 'watch', summary: 'Monitor the work queue (listen v2); auto-scopes to repo+branch, leases the scope (one listener each), one line per new item',
1658
+ usage: '[--board <slug>|--project <p> --branch <b>|--all] [--takeover] [--quiet-grace <sec>] [--once] [--base <url>]',
1659
+ flags: [{ f: '--board', arg: '<slug>', d: 'lease one board (else the repo project+branch is inferred)' },
1660
+ { f: '--project', arg: '<p>', d: 'override the inferred project scope' },
1661
+ { f: '--branch', arg: '<b>', d: 'override the inferred branch scope (with --project)' },
1662
+ { f: '--all', d: 'firehose: observe every board (leases a conflict-free scope, claims nothing)' },
1663
+ { f: '--takeover', d: 'steal an EXPIRED lease (a dead session); never steals a live one' },
1664
+ { f: '--quiet-grace', arg: '<sec>', d: 'drain poll interval (default 5)' },
1665
+ { f: '--once', d: 'single deterministic poll (exit 0 = work, 3 = idle)' }, BASE_FLAG],
1666
+ dynamic: 'boards',
1667
+ examples: ['vitrinka watch', 'vitrinka watch --board payout-flow', 'vitrinka watch --takeover'],
1668
+ },
1669
+ {
1670
+ name: 'index', summary: 'Build + push the file/route autocomplete index',
1671
+ usage: '[--root <dir>] [--base <url>] [--project <p>]',
1672
+ flags: [ROOT_FLAG, BASE_FLAG, { f: '--project', arg: '<p>', d: 'project slug (default: worktree name)' }],
1673
+ dynamic: 'projects',
1674
+ examples: ['vitrinka index --root .screenshots'],
1675
+ },
1676
+ {
1677
+ name: 'embed-shots', summary: 'Embed manifest shots as resized JPEG data URIs → data.json',
1678
+ usage: '[--root <dir>] [--select 1,3-5] [--size 720] [--quality 85] --out <dir>/data.json',
1679
+ flags: [ROOT_FLAG, { f: '--select', arg: '1,3-5', d: '1-based shot selection' }, { f: '--size', arg: '<px>', d: 'longest edge (default 720)' },
1680
+ { f: '--quality', arg: '<q>', d: 'JPEG quality (default 85)' }, { f: '--out', arg: '<file>', d: 'output data.json (required)' }],
1681
+ examples: ['vitrinka embed-shots --root .screenshots --out .artifacts/x/data.json'],
1682
+ },
1683
+ {
1684
+ name: 'artifact-init', summary: 'Scaffold a blank artifact report + set config',
1685
+ usage: '--slug <s> [--kind report] [--title <t>] [--base <url>]',
1686
+ flags: [{ f: '--slug', arg: '<s>', d: 'artifact slug (= set key, required)' }, { f: '--kind', arg: '<k>', d: 'set kind (default report)' },
1687
+ { f: '--title', arg: '<t>', d: 'artifact title' }, BASE_FLAG],
1688
+ examples: ['vitrinka artifact-init --slug payout-report --title "Payout report"'],
1689
+ },
1690
+ {
1691
+ name: 'artifact-from-set', summary: 'Scaffold an artifact report from a screenshot set',
1692
+ usage: '--slug <s> [--from .screenshots] [--select 1,3-5] [--title <t>] [--kind report] [--source <ref>]',
1693
+ flags: [{ f: '--slug', arg: '<s>', d: 'artifact slug (= set key, required)' }, { f: '--from', arg: '<dir>', d: 'source set root (default .screenshots)' },
1694
+ { f: '--select', arg: '1,3-5', d: '1-based shot selection' }, { f: '--title', arg: '<t>', d: 'artifact title' },
1695
+ { f: '--kind', arg: '<k>', d: 'set kind (default report)' }, { f: '--source', arg: '<ref>', d: 'source set ref for cross-linking' }, BASE_FLAG],
1696
+ examples: ['vitrinka artifact-from-set --slug payout-report --from .screenshots'],
1697
+ },
1698
+ // Meta commands — documented + completable, kept out of the compact usage grid.
1699
+ { name: 'help', summary: 'Show help (all commands, or one command in detail)', usage: '[<command>]', positional: '[<command>]', hidden: true,
1700
+ examples: ['vitrinka help', 'vitrinka help snap'] },
1701
+ { name: 'completion', summary: 'Print a shell-completion script (zsh|bash)', usage: '<zsh|bash>', positional: '<zsh|bash>', hidden: true,
1702
+ examples: ['vitrinka completion zsh >> ~/.zshrc'] },
1703
+ { name: 'ai', summary: 'Ask claude for a vitrinka command in natural language', usage: '"<ask>" [--yes]', positional: '"<ask>"', hidden: true,
1704
+ flags: [{ f: '--yes', d: 'run the proposed command without a confirm prompt' }],
1705
+ examples: ['vitrinka ai "publish the current screenshots as a flow board"'] },
1706
+ ];
1707
+ const COMMAND_MAP = Object.fromEntries(COMMANDS.map((c) => [c.name, c]));
1708
+ export const COMMAND_NAMES = COMMANDS.map((c) => c.name);
1709
+ // ---------- help rendering (man-page-ish; respects NO_COLOR) ----------
1710
+ function useColor() {
1711
+ return !process.env.NO_COLOR && !!process.stdout.isTTY;
1712
+ }
1713
+ function bold(s) { return useColor() ? `\x1b[1m${s}\x1b[0m` : s; }
1714
+ function dim(s) { return useColor() ? `\x1b[2m${s}\x1b[0m` : s; }
1715
+ // renderCompactUsage — the fallback shown on unknown command / bad args.
1716
+ export function renderCompactUsage() {
1717
+ const rows = COMMANDS.filter((c) => !c.hidden).map((c) => ` vitrinka ${c.name} ${c.usage}`);
1718
+ return `Usage:\n${rows.join('\n')}\n\nRun "vitrinka help <command>" for details, or "vitrinka ai \"<ask>\"".`;
1719
+ }
1720
+ // Backwards-compatible binding: dispatch fallback + the gallery.mjs shim test
1721
+ // still reference a `Usage:`-headed string that names every command.
1722
+ const USAGE = renderCompactUsage();
1723
+ export function renderFullHelp() {
1724
+ const lines = [];
1725
+ lines.push(bold('vitrinka') + ' — screenshot sets, artifacts, flow boards, and the work queue.');
1726
+ lines.push('');
1727
+ lines.push(bold('USAGE'));
1728
+ lines.push(' vitrinka <command> [flags]');
1729
+ lines.push('');
1730
+ lines.push(bold('COMMANDS'));
1731
+ const width = Math.max(...COMMANDS.filter((c) => !c.hidden).map((c) => c.name.length));
1732
+ for (const c of COMMANDS.filter((c) => !c.hidden)) {
1733
+ lines.push(` ${c.name.padEnd(width)} ${c.summary}`);
1734
+ }
1735
+ lines.push('');
1736
+ lines.push(bold('MORE'));
1737
+ const meta = COMMANDS.filter((c) => c.hidden);
1738
+ const mw = Math.max(...meta.map((c) => c.name.length));
1739
+ for (const c of meta)
1740
+ lines.push(` ${c.name.padEnd(mw)} ${c.summary}`);
1741
+ lines.push('');
1742
+ lines.push(dim(' Env: VITRINKA_URL, VITRINKA_TOKEN, VITRINKA_OPERATOR, NO_COLOR, VITRINKA_NO_AI=1, CLAUDE_BIN'));
1743
+ return lines.join('\n');
1744
+ }
1745
+ export function renderCmdHelp(name) {
1746
+ const c = COMMAND_MAP[name];
1747
+ if (!c)
1748
+ return renderCompactUsage();
1749
+ const lines = [];
1750
+ lines.push(`${bold('vitrinka ' + c.name)} — ${c.summary}`);
1751
+ lines.push('');
1752
+ lines.push(bold('USAGE'));
1753
+ lines.push(` vitrinka ${c.name} ${c.usage}`);
1754
+ if (c.flags && c.flags.length) {
1755
+ lines.push('');
1756
+ lines.push(bold('FLAGS'));
1757
+ const rows = c.flags.map((f) => ({ left: `${f.f}${f.arg ? ' ' + f.arg : ''}`, d: f.d }));
1758
+ const w = Math.max(...rows.map((r) => r.left.length));
1759
+ for (const r of rows)
1760
+ lines.push(` ${r.left.padEnd(w)} ${r.d}`);
1761
+ }
1762
+ if (c.examples && c.examples.length) {
1763
+ lines.push('');
1764
+ lines.push(bold('EXAMPLES'));
1765
+ for (const e of c.examples)
1766
+ lines.push(` ${e}`);
1767
+ }
1768
+ return lines.join('\n');
1769
+ }
1770
+ // nearestCommand — plain edit-distance "did you mean" (no AI). Returns '' when
1771
+ // nothing is within a small threshold.
1772
+ export function levenshtein(a, b) {
1773
+ const m = a.length, n = b.length;
1774
+ const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
1775
+ for (let j = 0; j <= n; j++)
1776
+ dp[0][j] = j;
1777
+ for (let i = 1; i <= m; i++) {
1778
+ for (let j = 1; j <= n; j++) {
1779
+ dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1]
1780
+ : 1 + Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]);
1781
+ }
1782
+ }
1783
+ return dp[m][n];
1784
+ }
1785
+ export function nearestCommand(input) {
1786
+ let best = '', bestD = Infinity;
1787
+ for (const name of COMMAND_NAMES) {
1788
+ const d = levenshtein(input, name);
1789
+ if (d < bestD) {
1790
+ bestD = d;
1791
+ best = name;
1792
+ }
1793
+ }
1794
+ // Suggest only a genuinely-close miss (≤ a third of the name length, min 2).
1795
+ return bestD <= Math.max(2, Math.ceil(best.length / 3)) ? best : '';
1796
+ }
1797
+ // ---------- shell completion ----------
1798
+ // completionScript embeds the command names + per-command flags at generation
1799
+ // time; slug-shaped positionals shell out to the hidden `vitrinka __complete`.
1800
+ export function completionScript(shell) {
1801
+ const names = COMMANDS.filter((c) => !c.hidden || c.name === 'help').map((c) => c.name).concat(['help', 'completion', 'ai']);
1802
+ const uniqNames = [...new Set(names)];
1803
+ const flagsFor = (c) => (c.flags || []).map((f) => f.f).join(' ');
1804
+ const dynamicCmds = COMMANDS.filter((c) => c.dynamic).map((c) => c.name);
1805
+ if (shell === 'bash') {
1806
+ const cases = COMMANDS.filter((c) => c.flags && c.flags.length)
1807
+ .map((c) => ` ${c.name}) __vitrinka_flags="${flagsFor(c)}" ;;`).join('\n');
1808
+ return `# vitrinka bash completion — add to ~/.bashrc:
1809
+ # eval "$(vitrinka completion bash)"
1810
+ _vitrinka() {
1811
+ local cur prev cmd
1812
+ cur="\${COMP_WORDS[COMP_CWORD]}"
1813
+ local cmds="${uniqNames.join(' ')}"
1814
+ if [ "$COMP_CWORD" -eq 1 ]; then
1815
+ COMPREPLY=( $(compgen -W "$cmds" -- "$cur") ); return
1816
+ fi
1817
+ cmd="\${COMP_WORDS[1]}"
1818
+ if [[ "$cur" == -* ]]; then
1819
+ local __vitrinka_flags=""
1820
+ case "$cmd" in
1821
+ ${cases}
1822
+ esac
1823
+ COMPREPLY=( $(compgen -W "$__vitrinka_flags" -- "$cur") ); return
1824
+ fi
1825
+ case " ${dynamicCmds.join(' ')} " in
1826
+ *" $cmd "*)
1827
+ local dyn; dyn="$(vitrinka __complete "$cmd" "$cur" 2>/dev/null)"
1828
+ COMPREPLY=( $(compgen -W "$dyn" -- "$cur") ) ;;
1829
+ esac
1830
+ }
1831
+ complete -F _vitrinka vitrinka
1832
+ `;
1833
+ }
1834
+ if (shell === 'zsh') {
1835
+ const cases = COMMANDS.filter((c) => c.flags && c.flags.length)
1836
+ .map((c) => ` ${c.name}) flags="${flagsFor(c)}" ;;`).join('\n');
1837
+ return `# vitrinka zsh completion — add to ~/.zshrc:
1838
+ # eval "$(vitrinka completion zsh)"
1839
+ _vitrinka() {
1840
+ local cmds=(${uniqNames.join(' ')})
1841
+ if (( CURRENT == 2 )); then
1842
+ compadd -- $cmds; return
1843
+ fi
1844
+ local cmd="\${words[2]}"
1845
+ local cur="\${words[CURRENT]}"
1846
+ if [[ "$cur" == -* ]]; then
1847
+ local flags=""
1848
+ case "$cmd" in
1849
+ ${cases}
1850
+ esac
1851
+ compadd -- \${=flags}; return
1852
+ fi
1853
+ case " ${dynamicCmds.join(' ')} " in
1854
+ *" $cmd "*)
1855
+ local dyn; dyn=("\${(f)$(vitrinka __complete "$cmd" "$cur" 2>/dev/null)}")
1856
+ compadd -- $dyn ;;
1857
+ esac
1858
+ }
1859
+ compdef _vitrinka vitrinka
1860
+ `;
1861
+ }
1862
+ fail(`completion: unsupported shell ${JSON.stringify(shell)} — expected zsh or bash`, 2);
1863
+ }
1864
+ function completionCmd(rest) {
1865
+ const shell = rest.find((a) => !a.startsWith('--')) || '';
1866
+ if (!shell)
1867
+ fail('completion: expected a shell — zsh or bash', 2);
1868
+ process.stdout.write(completionScript(shell));
1869
+ }
1870
+ function resolveBase(args) {
1871
+ return resolveBaseUrl(strArg(args.base));
1872
+ }
1873
+ // fetchCandidates lists slug candidates of `kind` from the server. Silent [] on
1874
+ // any failure — completion and fuzzy resolution must never hang or error.
1875
+ export async function fetchCandidates(base, kind, timeoutMs = 1500) {
1876
+ const url = kind === 'boards' ? `${base}/api/v1/boards` : `${base}/api/v1/projects`;
1877
+ let data;
1878
+ try {
1879
+ data = await watchFetchJson(url, timeoutMs);
1880
+ }
1881
+ catch {
1882
+ return []; // unreachable / non-2xx — completion stays empty (expected, by design)
1883
+ }
1884
+ const rows = kind === 'boards' ? data?.boards : data?.projects;
1885
+ if (!Array.isArray(rows))
1886
+ return [];
1887
+ return rows.map((r) => String(kind === 'boards' ? r?.slug : r?.project)).filter(Boolean);
1888
+ }
1889
+ // __complete — the hidden dynamic-candidate endpoint the shell scripts call.
1890
+ // Prints newline-separated matches; ALWAYS exits 0 with no output on any
1891
+ // failure so a shell completion never blocks.
1892
+ async function completeDynamicCmd(rest) {
1893
+ const cmd = rest[0] || '';
1894
+ const partial = rest[1] || '';
1895
+ const spec = COMMAND_MAP[cmd];
1896
+ if (!spec?.dynamic)
1897
+ return; // nothing dynamic for this command → silent
1898
+ const cands = await fetchCandidates(resolveBase({}), spec.dynamic);
1899
+ for (const c of cands)
1900
+ if (c.startsWith(partial))
1901
+ process.stdout.write(c + '\n');
1902
+ }
1903
+ // ---------- AI assistance (local claude CLI; degrades gracefully) ----------
1904
+ // aiEnabled — the single kill switch for all three AI paths.
1905
+ function aiEnabled() { return process.env.VITRINKA_NO_AI !== '1'; }
1906
+ // askClaude runs the LOCAL claude binary ($CLAUDE_BIN then PATH) in print mode
1907
+ // with --model haiku, feeding `prompt` on stdin. Returns the trimmed stdout on a
1908
+ // clean exit, or null on ANY failure (disabled, no binary, non-zero, timeout) —
1909
+ // every caller degrades to a single clear line, never a crash.
1910
+ export function askClaude(prompt, opts = {}) {
1911
+ if (!aiEnabled())
1912
+ return Promise.resolve(null);
1913
+ const bin = process.env.CLAUDE_BIN || 'claude';
1914
+ return new Promise((resolvePromise) => {
1915
+ let settled = false;
1916
+ const done = (v) => { if (!settled) {
1917
+ settled = true;
1918
+ resolvePromise(v);
1919
+ } };
1920
+ let child;
1921
+ try {
1922
+ child = spawn(bin, ['-p', '--model', 'haiku'], { stdio: ['pipe', 'pipe', 'ignore'] });
1923
+ }
1924
+ catch {
1925
+ return done(null); // spawn threw synchronously (e.g. bad bin) — degrade
1926
+ }
1927
+ const timer = setTimeout(() => { try {
1928
+ child.kill('SIGKILL');
1929
+ }
1930
+ catch { /* already gone */ } done(null); }, opts.timeoutMs ?? 6000);
1931
+ let out = '';
1932
+ child.stdout?.setEncoding('utf8');
1933
+ child.stdout?.on('data', (d) => { out += d; });
1934
+ child.on('error', () => { clearTimeout(timer); done(null); }); // ENOENT: no claude on PATH
1935
+ child.on('close', (code) => { clearTimeout(timer); done(code === 0 ? out.trim() : null); });
1936
+ child.stdin?.on('error', () => { });
1937
+ child.stdin?.end(prompt);
1938
+ });
1939
+ }
1940
+ // extractCommandLine pulls a single proposed command out of claude's reply:
1941
+ // the first non-empty line of the first fenced block, else the first line that
1942
+ // starts with "vitrinka". A leading shell prompt ("$ ") is stripped.
1943
+ export function extractCommandLine(reply) {
1944
+ const fence = /```[a-zA-Z]*\n([\s\S]*?)```/.exec(reply);
1945
+ const scan = (text) => {
1946
+ for (const raw of text.split('\n')) {
1947
+ const line = raw.trim().replace(/^\$\s+/, '');
1948
+ if (line)
1949
+ return line;
1950
+ }
1951
+ return '';
1952
+ };
1953
+ if (fence) {
1954
+ const inside = scan(fence[1]);
1955
+ if (inside)
1956
+ return inside;
1957
+ }
1958
+ for (const raw of reply.split('\n')) {
1959
+ const line = raw.trim().replace(/^\$\s+/, '');
1960
+ if (/^vitrinka\b/.test(line))
1961
+ return line;
1962
+ }
1963
+ return scan(reply);
1964
+ }
1965
+ // SHELL_META rejects any command line carrying shell control operators — the
1966
+ // proposal must be a plain argv, never a pipeline/chain/substitution.
1967
+ const SHELL_META = /[|;&><`]|\$\(|\$\{|\n/;
1968
+ // tokenizeArgv splits a command line into argv honoring single/double quotes but
1969
+ // interpreting NO shell metacharacters. Returns null on an unbalanced quote.
1970
+ export function tokenizeArgv(line) {
1971
+ const out = [];
1972
+ let cur = '', quote = '', started = false;
1973
+ for (const c of line) {
1974
+ if (quote) {
1975
+ if (c === quote)
1976
+ quote = '';
1977
+ else
1978
+ cur += c;
1979
+ continue;
1980
+ }
1981
+ if (c === '"' || c === "'") {
1982
+ quote = c;
1983
+ started = true;
1984
+ continue;
1985
+ }
1986
+ if (/\s/.test(c)) {
1987
+ if (started || cur) {
1988
+ out.push(cur);
1989
+ cur = '';
1990
+ started = false;
1991
+ }
1992
+ continue;
1993
+ }
1994
+ cur += c;
1995
+ started = true;
1996
+ }
1997
+ if (quote)
1998
+ return null;
1999
+ if (started || cur)
2000
+ out.push(cur);
2001
+ return out;
2002
+ }
2003
+ // validateProposal turns claude's UNTRUSTED command line into a safe, internally
2004
+ // dispatchable argv, or rejects it. The ONLY accepted shape is
2005
+ // `vitrinka <known-subcommand> …` with no shell metacharacters; `ai` is refused
2006
+ // to prevent recursion. The command is never shell-eval'd — the caller dispatches
2007
+ // the returned argv through the same in-process switch.
2008
+ export function validateProposal(line) {
2009
+ if (!line)
2010
+ return { ok: false, reason: 'empty proposal' };
2011
+ if (SHELL_META.test(line))
2012
+ return { ok: false, reason: 'contains shell metacharacters' };
2013
+ const argv = tokenizeArgv(line);
2014
+ if (!argv || argv.length === 0)
2015
+ return { ok: false, reason: 'unparseable command line' };
2016
+ if (argv[0] !== 'vitrinka')
2017
+ return { ok: false, reason: `argv[0] is not "vitrinka" (got ${JSON.stringify(argv[0])})` };
2018
+ const sub = argv[1] || '';
2019
+ if (!COMMAND_NAMES.includes(sub))
2020
+ return { ok: false, reason: `unknown subcommand ${JSON.stringify(sub)}` };
2021
+ if (sub === 'ai')
2022
+ return { ok: false, reason: 'refusing to recurse into `ai`' };
2023
+ // Registry-validate the FLAGS too: a hallucinated flag must be rejected
2024
+ // here, not silently ignored (or misread) by the command at runtime.
2025
+ const spec = COMMANDS.find((c) => c.name === sub);
2026
+ const known = new Set((spec?.flags ?? []).map((fl) => fl.f));
2027
+ for (const tok of argv.slice(2)) {
2028
+ if (!tok.startsWith('--'))
2029
+ continue; // positional / flag value
2030
+ const flag = tok.includes('=') ? tok.slice(0, tok.indexOf('=')) : tok;
2031
+ if (!known.has(flag))
2032
+ return { ok: false, reason: `unknown flag ${JSON.stringify(flag)} for ${sub}` };
2033
+ }
2034
+ return { ok: true, sub, argv };
2035
+ }
2036
+ // confirmYN prompts on stderr and reads one line from stdin. Non-interactive
2037
+ // (no TTY) without an explicit --yes defaults to NO — never auto-runs.
2038
+ async function confirmYN(question) {
2039
+ if (!process.stdin.isTTY)
2040
+ return false;
2041
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
2042
+ const ans = await new Promise((res) => rl.question(`${question} [y/N] `, res));
2043
+ rl.close();
2044
+ return /^y(es)?$/i.test(ans.trim());
2045
+ }
2046
+ // aiCmd: natural-language → ONE proposed vitrinka command → confirm → dispatch
2047
+ // INTERNALLY (never a shell). claude's output is untrusted: parsed, registry-
2048
+ // validated, and only then run through the same in-process switch.
2049
+ async function aiCmd(rest, args) {
2050
+ if (!aiEnabled())
2051
+ fail('ai: disabled (VITRINKA_NO_AI=1)', 1);
2052
+ const ask = positionals(rest).slice(1).join(' ').trim() || strArg(args.ask);
2053
+ if (!ask)
2054
+ fail('ai: expected a natural-language request, e.g. vitrinka ai "publish these as a flow board"', 2);
2055
+ const cwd = process.cwd();
2056
+ const repo = originRepo(cwd) || basename(mainWorktreeTop(cwd) || cwd);
2057
+ const base = resolveBase(args);
2058
+ const operator = readOperator(true) || '(unset)';
2059
+ const prompt = [
2060
+ 'You translate a natural-language request into ONE vitrinka CLI command.',
2061
+ 'Output ONLY the command, on a single line, inside a ```sh fenced block. No prose, no explanation.',
2062
+ 'The command MUST start with "vitrinka " and use only the documented subcommands and flags below.',
2063
+ '',
2064
+ '# Context',
2065
+ `cwd repo: ${repo}`,
2066
+ `base URL: ${base}`,
2067
+ `operator: ${operator}`,
2068
+ '',
2069
+ '# vitrinka help',
2070
+ renderFullHelpPlain(),
2071
+ '',
2072
+ '# Request',
2073
+ ask,
2074
+ ].join('\n');
2075
+ const reply = await askClaude(prompt, { timeoutMs: 20_000 });
2076
+ if (reply == null) {
2077
+ fail('ai: claude unavailable (no `claude` on PATH, or non-zero exit). Set $CLAUDE_BIN, or run the command yourself.', 1);
2078
+ }
2079
+ const line = extractCommandLine(reply);
2080
+ const proposal = validateProposal(line);
2081
+ if (!proposal.ok) {
2082
+ console.error(`ai: rejected claude's suggestion (${proposal.reason}). Raw suggestion:`);
2083
+ console.error(` ${line || '(none)'}`);
2084
+ process.exit(1);
2085
+ }
2086
+ console.log(`proposed: ${proposal.argv.join(' ')}`);
2087
+ if (args.yes !== true) {
2088
+ const ok = await confirmYN('run it?');
2089
+ if (!ok) {
2090
+ console.error('ai: cancelled');
2091
+ process.exit(0);
2092
+ }
2093
+ }
2094
+ // Dispatch the vetted argv through the SAME in-process switch (no shell eval).
2095
+ await runSub(proposal.argv.slice(1));
2096
+ }
2097
+ // renderFullHelpPlain — the full help with color forced off (for the AI prompt).
2098
+ function renderFullHelpPlain() {
2099
+ const rows = COMMANDS.map((c) => ` vitrinka ${c.name} ${c.usage}`);
2100
+ const detail = COMMANDS.filter((c) => c.flags && c.flags.length).map((c) => {
2101
+ const fl = (c.flags || []).map((f) => ` ${f.f}${f.arg ? ' ' + f.arg : ''} ${f.d}`).join('\n');
2102
+ return `${c.name}:\n${fl}`;
2103
+ }).join('\n');
2104
+ return `Commands:\n${rows.join('\n')}\n\nFlags:\n${detail}`;
2105
+ }
2106
+ // ---------- AI fuzzy slug resolution (read: auto; write: confirm) ----------
2107
+ // resolveFuzzy maps a possibly-fuzzy slug to a real one of `kind`. Exact match →
2108
+ // returned as-is. Otherwise the live candidate list is fetched and claude picks
2109
+ // the intended slug; for READ commands the resolution is applied automatically
2110
+ // (announced on stderr), for WRITE commands it is confirmed first (unless --yes).
2111
+ // No candidates / ambiguous / AI unavailable → the top candidates are listed and
2112
+ // the process exits 1.
2113
+ export async function resolveFuzzy(input, kind, opts) {
2114
+ const cands = await fetchCandidates(opts.base, kind);
2115
+ if (cands.includes(input))
2116
+ return input;
2117
+ if (cands.length === 0) {
2118
+ fail(`no matching ${kind.replace(/s$/, '')} for ${JSON.stringify(input)} (server returned no candidates)`, 1);
2119
+ }
2120
+ const prompt = [
2121
+ `From this list of ${kind}, pick the ONE that best matches the user's fuzzy input.`,
2122
+ 'Output ONLY the exact chosen value on a single line, nothing else. If none is a clear match, output NONE.',
2123
+ '', `Input: ${input}`, '', 'Candidates:', ...cands.map((c) => `- ${c}`),
2124
+ ].join('\n');
2125
+ const reply = await askClaude(prompt, { timeoutMs: 8000 });
2126
+ const picked = (reply || '').split('\n').map((s) => s.trim()).find(Boolean) || '';
2127
+ if (!picked || picked === 'NONE' || !cands.includes(picked)) {
2128
+ console.error(`could not confidently resolve ${JSON.stringify(input)} — candidates:`);
2129
+ for (const c of cands.slice(0, 10))
2130
+ console.error(` ${c}`);
2131
+ process.exit(1);
2132
+ }
2133
+ console.error(`resolved "${input}" → ${picked}`);
2134
+ if (opts.write && !opts.yes) {
2135
+ const ok = await confirmYN(`use "${picked}"?`);
2136
+ if (!ok) {
2137
+ console.error('cancelled');
2138
+ process.exit(0);
2139
+ }
2140
+ }
2141
+ return picked;
2142
+ }
2143
+ // emitHint prints ONE claude-authored `hint:` line AFTER a command's own error
2144
+ // output (usage error / 4xx). Capped short; prints nothing on timeout / no
2145
+ // claude / AI disabled — the hint is strictly additive, never blocking.
2146
+ async function emitHint(cmdline, errorText, helpSection) {
2147
+ if (!aiEnabled())
2148
+ return;
2149
+ const prompt = [
2150
+ 'A vitrinka CLI command just failed. In ONE short line, suggest the single most likely fix.',
2151
+ 'Output ONLY that line, prefixed with "hint: ". No other prose.',
2152
+ '', '# Command', cmdline, '', '# Error', errorText, '', '# Relevant help', helpSection,
2153
+ ].join('\n');
2154
+ const reply = await askClaude(prompt, { timeoutMs: 6000 });
2155
+ if (!reply)
2156
+ return;
2157
+ const line = reply.split('\n').map((s) => s.trim()).find(Boolean) || '';
2158
+ if (!line)
2159
+ return;
2160
+ process.stderr.write((line.startsWith('hint:') ? line : `hint: ${line}`) + '\n');
2161
+ }
2162
+ // board-from-set — the flow-visualizer one-shot: turn THIS session's set into a
2163
+ // board where shots are laid out as the user flow (serpentine, action-labeled
2164
+ // arrows). Board slug defaults to the set key; re-running appends the newest
2165
+ // import below the existing content (server behavior).
2166
+ async function boardFromSetCmd(root, args) {
2167
+ if (!existsSync(vitrinkaCfgPath(root))) {
2168
+ console.error(`board-from-set: no ${vitrinkaCfgPath(root)} — run remote-init first`);
2169
+ process.exit(2);
2170
+ }
2171
+ const cfg = loadVitrinkaCfg(root);
2172
+ const slug = (strArg(args.slug) || cfg.key).toLowerCase();
2173
+ const title = strArg(args.btitle) || strArg(args.title) || '';
2174
+ const headers = { 'Content-Type': 'application/json' };
2175
+ const token = readToken();
2176
+ if (token)
2177
+ headers.Authorization = `Bearer ${token}`;
2178
+ // Attribute the board + its imported cards to the operator (or --actor).
2179
+ const actor = actorFor(args);
2180
+ if (actor)
2181
+ headers['X-Board-Actor'] = actor;
2182
+ const create = await fetch(`${cfg.base}/api/v1/boards`, {
2183
+ method: 'POST', headers, body: JSON.stringify({ slug, title }),
2184
+ signal: AbortSignal.timeout(30_000),
2185
+ });
2186
+ if (!create.ok && create.status !== 409) { // 409 = board exists → reuse
2187
+ console.error(`board-from-set: create board failed: ${create.status} ${await create.text()}`);
2188
+ process.exit(1);
2189
+ }
2190
+ const imp = await fetch(`${cfg.base}/api/v1/boards/${encodeURIComponent(slug)}/import-set`, {
2191
+ method: 'POST', headers,
2192
+ body: JSON.stringify({ project: cfg.project, branch: cfg.slug, selector: cfg.key }),
2193
+ signal: AbortSignal.timeout(60_000),
2194
+ });
2195
+ if (!imp.ok) {
2196
+ console.error(`board-from-set: import failed: ${imp.status} ${await imp.text()}`);
2197
+ process.exit(1);
2198
+ }
2199
+ const out = await imp.json();
2200
+ console.log(`flow board: ${cfg.base}/boards/${slug} (${out.cards.length} cards, ${out.edges.length} edges)`);
2201
+ // The board is about to be annotated — make sure its autocomplete index is fresh.
2202
+ await pushProjectIndex(dirname(root), cfg.base, cfg.project);
2203
+ }
2204
+ // announceLine renders the single stdout line for one work item:
2205
+ // №<id> [<intent>] <board>: <prompt, single line, ≤100 chars>
2206
+ export function announceLine(it) {
2207
+ const intent = it.intent === 'other' && it.intentOther ? it.intentOther : (it.intent || 'other');
2208
+ const board = it.board || '?';
2209
+ const prompt = String(it.prompt ?? '').replace(/\s+/g, ' ').trim();
2210
+ const short = prompt.length > 100 ? prompt.slice(0, 100) + '…' : prompt;
2211
+ return `№${it.id} [${intent}] ${board}: ${short}`;
2212
+ }
2213
+ // computeAnnouncements is the watermark/diff core (PURE — unit-tested without
2214
+ // HTTP). Given the previously-announced OPEN items (id → last status/updatedAt)
2215
+ // and the CURRENT open queue, it returns the lines to print for genuinely-new
2216
+ // work plus the next watermark map. An item is "new" when it wasn't announced
2217
+ // before, OR its status/updatedAt changed (re-queue / prompt revision). An item
2218
+ // that LEFT the open queue simply drops from nextMap — so if it reopens later
2219
+ // it is announced again. Feeding the CURRENT open queue every poll makes the
2220
+ // "left then reappeared" case fall out for free (it was absent → present).
2221
+ export function computeAnnouncements(prev, currentQueue) {
2222
+ const nextMap = {};
2223
+ const lines = [];
2224
+ for (const it of currentQueue) {
2225
+ if (it == null || it.id === undefined || it.id === null)
2226
+ continue;
2227
+ const key = String(it.id);
2228
+ const cur = { status: it.status, updatedAt: it.updatedAt };
2229
+ const before = prev[key];
2230
+ if (!before || before.status !== cur.status || before.updatedAt !== cur.updatedAt) {
2231
+ lines.push(announceLine(it));
2232
+ }
2233
+ nextMap[key] = cur;
2234
+ }
2235
+ return { lines, nextMap };
2236
+ }
2237
+ const watchEmit = (line) => { process.stdout.write(line + '\n'); };
2238
+ const watchSleep = (ms) => new Promise((r) => setTimeout(r, ms));
2239
+ function watchBaseUrl(args) {
2240
+ return (strArg(args.base) || process.env.VITRINKA_URL || process.env.VITRINKA_BASE_URL
2241
+ || 'https://vitrinka.lovinka.com').replace(/\/+$/, '');
2242
+ }
2243
+ // deriveWatchScope resolves the listener scope from flags + the repo context
2244
+ // (PURE — unit-tested). Priority: --all (firehose) → --board <slug> → the repo's
2245
+ // project (main-worktree basename, push's derivation) + current git branch.
2246
+ // --project / --branch override the inferred project scope.
2247
+ export function deriveWatchScope(args, ctx) {
2248
+ if (args.all === true)
2249
+ return { kind: 'all', label: 'all boards' };
2250
+ const board = strArg(args.board);
2251
+ if (board)
2252
+ return { kind: 'board', board, label: `board ${board}` };
2253
+ const project = sanitizeSeg(strArg(args.project) || ctx.repoProject || 'project', 64);
2254
+ const branch = sanitizeSeg(strArg(args.branch) || ctx.repoBranch || 'main', 100);
2255
+ return { kind: 'project', project, branch, label: `${project}/${branch}` };
2256
+ }
2257
+ // scopeQuery renders the scope's server query params (shared by the wait lease
2258
+ // and the plain work list).
2259
+ function scopeQuery(scope) {
2260
+ switch (scope.kind) {
2261
+ case 'all': return { all: '1' };
2262
+ case 'board': return { board: scope.board };
2263
+ case 'project': return { project: scope.project, branch: scope.branch };
2264
+ }
2265
+ }
2266
+ // watchFetchResp — a work-API read that returns status + parsed body (does NOT
2267
+ // throw on non-2xx, so the caller can branch on 409/holder). Throws only on a
2268
+ // transport failure so the resilience layer backs off.
2269
+ async function watchFetchResp(url, timeoutMs) {
2270
+ const headers = {};
2271
+ const token = readToken();
2272
+ if (token)
2273
+ headers.Authorization = `Bearer ${token}`;
2274
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) });
2275
+ let body = null;
2276
+ try {
2277
+ body = await res.json();
2278
+ }
2279
+ catch { /* empty/non-JSON */ }
2280
+ return { status: res.status, body };
2281
+ }
2282
+ // watchFetchJson — as above but throws on non-2xx (used by the plain list).
2283
+ async function watchFetchJson(url, timeoutMs) {
2284
+ const { status, body } = await watchFetchResp(url, timeoutMs);
2285
+ if (status < 200 || status >= 300)
2286
+ throw new Error(`HTTP ${status}`);
2287
+ return body;
2288
+ }
2289
+ async function watchCmd(args) {
2290
+ const base = watchBaseUrl(args);
2291
+ const graceSec = numArg(args['quiet-grace'], 5, '--quiet-grace');
2292
+ const once = args.once === true;
2293
+ // Repo context for scope inference (main worktree basename = project; current
2294
+ // branch). '.' cwd is where the session runs (the app repo).
2295
+ const cwd = resolve('.');
2296
+ const repoProject = basename(mainWorktreeTop(cwd) || cwd);
2297
+ const repoBranch = git(['branch', '--show-current'], cwd);
2298
+ const scope = deriveWatchScope(args, { repoProject, repoBranch });
2299
+ const sq = scopeQuery(scope);
2300
+ const listUrl = () => {
2301
+ const q = new URLSearchParams({ status: 'open', ...sq });
2302
+ return `${base}/api/v1/work?${q}`;
2303
+ };
2304
+ // --once: a single deterministic probe (tests). Lease-free (no session), so it
2305
+ // never claims scope. Returns exit 0 with announcements, else 3 (idle).
2306
+ if (once) {
2307
+ const waitUrl = `${base}/api/v1/work/wait?${new URLSearchParams({ timeout: '2', ...sq })}`;
2308
+ let waited;
2309
+ try {
2310
+ waited = await watchFetchJson(waitUrl, 12_000);
2311
+ }
2312
+ catch (e) {
2313
+ console.error(`watch --once: vitrinka unreachable: ${e instanceof Error ? e.message : e}`);
2314
+ process.exit(3);
2315
+ }
2316
+ if (!waited || waited.idle || !Array.isArray(waited.work) || waited.work.length === 0) {
2317
+ process.exit(3);
2318
+ }
2319
+ let list;
2320
+ try {
2321
+ list = await watchFetchJson(listUrl(), 30_000);
2322
+ }
2323
+ catch (e) {
2324
+ console.error(`watch --once: list failed: ${e instanceof Error ? e.message : e}`);
2325
+ process.exit(3);
2326
+ }
2327
+ const items = Array.isArray(list?.work) ? list.work : [];
2328
+ const { lines } = computeAnnouncements({}, items);
2329
+ for (const l of lines)
2330
+ watchEmit(l);
2331
+ process.exit(lines.length > 0 ? 0 : 3);
2332
+ }
2333
+ // Persistent leased loop. Identity: operator persona + host:pid — the process
2334
+ // that must stay alive. The long-poll IS the lease heartbeat; on graceful stop
2335
+ // we DELETE the lease so its unfinished work reverts immediately.
2336
+ const actor = readOperator(true);
2337
+ const session = `${hostname()}:${process.pid}`;
2338
+ const takeover = args.takeover === true;
2339
+ const leasedWaitUrl = (secs) => {
2340
+ const q = new URLSearchParams({ timeout: String(secs), session, ...sq });
2341
+ if (actor)
2342
+ q.set('actor', actor);
2343
+ if (takeover)
2344
+ q.set('takeover', '1');
2345
+ return `${base}/api/v1/work/wait?${q}`;
2346
+ };
2347
+ let stopping = false;
2348
+ let releasing = false;
2349
+ let leaseId = null;
2350
+ const shutdown = async (code) => {
2351
+ if (releasing)
2352
+ return;
2353
+ releasing = true;
2354
+ stopping = true;
2355
+ if (leaseId != null) {
2356
+ try {
2357
+ const headers = {};
2358
+ const token = readToken();
2359
+ if (token)
2360
+ headers.Authorization = `Bearer ${token}`;
2361
+ await fetch(`${base}/api/v1/work/listeners/${leaseId}`, { method: 'DELETE', headers, signal: AbortSignal.timeout(5_000) });
2362
+ }
2363
+ catch { /* best effort — the TTL reaps it anyway */ }
2364
+ }
2365
+ process.exit(code);
2366
+ };
2367
+ process.on('SIGINT', () => { void shutdown(0); });
2368
+ process.on('SIGTERM', () => { void shutdown(0); });
2369
+ let announced = {};
2370
+ let downSince = null;
2371
+ let degradedAt = null;
2372
+ let backoff = 1000;
2373
+ const noteSuccess = () => {
2374
+ if (degradedAt !== null)
2375
+ watchEmit('✓ vitrinka reachable again');
2376
+ downSince = null;
2377
+ degradedAt = null;
2378
+ backoff = 1000;
2379
+ };
2380
+ const noteFailure = async () => {
2381
+ const now = Date.now();
2382
+ if (downSince === null)
2383
+ downSince = now;
2384
+ const downMs = now - downSince;
2385
+ if (downMs >= 120_000 && (degradedAt === null || now - degradedAt >= 600_000)) {
2386
+ watchEmit(`⚠ vitrinka unreachable for ${Math.round(downMs / 1000)}s — listener degraded`);
2387
+ degradedAt = now;
2388
+ }
2389
+ const wait = backoff;
2390
+ backoff = Math.min(backoff * 2, 30_000);
2391
+ await watchSleep(wait);
2392
+ };
2393
+ while (!stopping) {
2394
+ // Leased long-poll: claims/renews the scope, blocks until work exists. The
2395
+ // instant the queue is non-empty it returns immediately (busy) so graceSec
2396
+ // below throttles the diff loop.
2397
+ let resp;
2398
+ try {
2399
+ resp = await watchFetchResp(leasedWaitUrl(55), 70_000);
2400
+ }
2401
+ catch {
2402
+ await noteFailure();
2403
+ continue;
2404
+ }
2405
+ if (resp.status === 409) {
2406
+ // Scope already held by another session. Live → cannot steal; expired →
2407
+ // retry with --takeover. Either way surface it and exit (the skill reads
2408
+ // exit 2 as "another session owns this scope").
2409
+ const h = resp.body?.holder ?? {};
2410
+ const live = resp.body?.live ? 'live' : 'expired';
2411
+ const scopeStr = h.scopeKind === 'project' ? `${h.scope}/${h.branch}` : (h.scope || h.scopeKind || scope.label);
2412
+ const since = h.lastSeen ? ` (since ${h.lastSeen})` : '';
2413
+ watchEmit(`⚠ listener already active (${live}): ${h.actor || '?'}@${h.session || '?'} on ${scopeStr}${since}`);
2414
+ if (!resp.body?.live)
2415
+ watchEmit(' → retry with --takeover to steal the expired lease');
2416
+ process.exit(2);
2417
+ }
2418
+ if (resp.status < 200 || resp.status >= 300) {
2419
+ await noteFailure();
2420
+ continue;
2421
+ }
2422
+ noteSuccess();
2423
+ if (typeof resp.body?.listener === 'number')
2424
+ leaseId = resp.body.listener;
2425
+ const waited = resp.body;
2426
+ if (!waited || waited.idle || !Array.isArray(waited.work) || waited.work.length === 0) {
2427
+ announced = {};
2428
+ continue;
2429
+ }
2430
+ // Queue non-empty → diff the full open list and announce new items. The next
2431
+ // loop iteration re-waits (renewing the lease); graceSec throttles.
2432
+ try {
2433
+ const list = await watchFetchJson(listUrl(), 30_000);
2434
+ const items = Array.isArray(list.work) ? list.work : [];
2435
+ const { lines, nextMap } = computeAnnouncements(announced, items);
2436
+ announced = nextMap;
2437
+ for (const l of lines)
2438
+ watchEmit(l);
2439
+ }
2440
+ catch {
2441
+ await noteFailure();
2442
+ continue;
2443
+ }
2444
+ await watchSleep(graceSec * 1000);
2445
+ }
2446
+ }
2447
+ // runSub — the single command switch, reused by the CLI entrypoint AND the `ai`
2448
+ // command's internal dispatch (no shell eval anywhere). `argv` is [command,
2449
+ // ...rest] exactly as process.argv.slice(2). Every case here MUST have a
2450
+ // COMMANDS registry entry (the registry-completeness test enforces it).
2451
+ async function runSub(argv) {
2452
+ const [cmd, ...rest] = argv;
2453
+ const args = parseArgs(rest);
2454
+ const root = resolve(strArg(args.root) || '.screenshots');
2455
+ if (cmd === 'add')
2456
+ addCmd(root, args);
2457
+ else if (cmd === 'build') {
2458
+ const { count, htmlPath } = buildIndex(root);
2459
+ console.log(`built ${htmlPath} (${count} shots)`);
2460
+ }
2461
+ else if (cmd === 'meta')
2462
+ metaCmd(root, args);
2463
+ else if (cmd === 'remote-init')
2464
+ remoteInitCmd(root, args);
2465
+ else if (cmd === 'push')
2466
+ await pushCmd(root, args);
2467
+ else if (cmd === 'name')
2468
+ await nameCmd(argv, args);
2469
+ else if (cmd === 'operator')
2470
+ await operatorCmd(argv, args);
2471
+ else if (cmd === 'install')
2472
+ installCmd(args);
2473
+ else if (cmd === 'snap')
2474
+ await snapCmd(rest[0] && !rest[0].startsWith('--') ? rest[0] : '', args);
2475
+ else if (cmd === 'board-from-set')
2476
+ await boardFromSetCmd(root, args);
2477
+ else if (cmd === 'watch')
2478
+ await watchCmd(args);
2479
+ else if (cmd === 'index')
2480
+ await indexCmd(root, args);
2481
+ else if (cmd === 'embed-shots')
2482
+ embedShotsCmd(root, args);
2483
+ else if (cmd === 'artifact-init')
2484
+ artifactInitCmd(args);
2485
+ else if (cmd === 'artifact-from-set')
2486
+ artifactFromSetCmd(args);
2487
+ else
2488
+ throw new Error(`runSub: unhandled command ${JSON.stringify(cmd)}`); // guarded by the caller
2489
+ }
2490
+ // IS_MAIN self-run: fires only when THIS file is the process entry (direct
2491
+ // `node cli.ts`, or the dist bin). `main` is a hoisted function declaration, so
2492
+ // calling it here — before its definition below — is fine, and keeping the
2493
+ // entry block adjacent to runSub keeps the help/completion cases out of the
2494
+ // runSub-vs-registry source scan in dx.test.ts.
2495
+ const IS_MAIN = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href === import.meta.url : false;
2496
+ if (IS_MAIN) {
2497
+ await main(process.argv.slice(2));
2498
+ }
2499
+ // main is the CLI entry, exported so the thin skills/vitrinka/cli.ts delegator
2500
+ // (kept for backward-compat with already-armed Monitors + the PATH shim) can
2501
+ // run the unified CLI by importing it.
2502
+ export async function main(argv) {
2503
+ const [cmd, ...rest] = argv;
2504
+ const args = parseArgs(rest);
2505
+ const wantsHelp = args.help === true || rest.includes('-h') || rest.includes('--help');
2506
+ if (cmd === undefined) {
2507
+ // Bare invocation — the compact usage grid (exit 2, like the gallery shim).
2508
+ console.error(USAGE);
2509
+ process.exit(2);
2510
+ }
2511
+ else if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
2512
+ const topic = argv.slice(1).find((a) => !a.startsWith('-')) || '';
2513
+ console.log(topic && COMMAND_MAP[topic] ? renderCmdHelp(topic) : renderFullHelp());
2514
+ process.exit(0);
2515
+ }
2516
+ else if (COMMAND_MAP[cmd] && wantsHelp) {
2517
+ // `vitrinka <cmd> --help` / `-h` → that command's detail page.
2518
+ console.log(renderCmdHelp(cmd));
2519
+ process.exit(0);
2520
+ }
2521
+ else if (cmd === 'completion') {
2522
+ completionCmd(rest);
2523
+ }
2524
+ else if (cmd === '__complete') {
2525
+ await completeDynamicCmd(rest);
2526
+ }
2527
+ else if (cmd === 'ai') {
2528
+ await aiCmd(argv, args);
2529
+ }
2530
+ else if (!COMMAND_MAP[cmd]) {
2531
+ // Unknown command → "did you mean" (plain edit-distance, no AI) + usage,
2532
+ // then one additive AI hint. Exit 2.
2533
+ const near = nearestCommand(cmd);
2534
+ console.error(`unknown command ${JSON.stringify(cmd)}${near ? ` — did you mean "${near}"?` : ''}`);
2535
+ console.error(USAGE);
2536
+ await emitHint(`vitrinka ${argv.join(' ')}`, `unknown command ${JSON.stringify(cmd)}`, USAGE);
2537
+ process.exit(2);
2538
+ }
2539
+ else {
2540
+ await runSub(argv);
2541
+ }
2542
+ }
2543
+ //# sourceMappingURL=cli.js.map