@lovinka/vitrinka 1.17.0 → 1.20.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 CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // cli.ts — the unified vitrinka CLI. Maintains `.screenshots/manifest.json` + a self-contained,
2
+ // cli.ts — the unified vitrinka CLI. Maintains `.vitrinka/screenshots/manifest.json` + a self-contained,
3
3
  // clickable `index.html` gallery, publishes sets to vitrinka (the internal artifacts app), and
4
4
  // one-shots capture (`snap`) + artifact scaffolding (`artifact-init`, `artifact-from-set`).
5
5
  //
@@ -10,7 +10,7 @@
10
10
  // skills/screenshot/gallery.mjs (offline marker, COPYFILE_DISABLE, control-file excludes,
11
11
  // `--key`, repeatable `--chip`). gallery.mjs is now a passthrough shim to this file.
12
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, openSync, readSync, closeSync, realpathSync, openAsBlob, lstatSync, constants as fsConstants, } from 'node:fs';
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, renameSync, readdirSync, copyFileSync, mkdtempSync, chmodSync, appendFileSync, statSync, openSync, readSync, closeSync, realpathSync, openAsBlob, lstatSync, constants as fsConstants, } from 'node:fs';
14
14
  import { join, resolve, basename, dirname, extname, relative, sep, isAbsolute } from 'node:path';
15
15
  import { tmpdir, hostname } from 'node:os';
16
16
  import { spawnSync, spawn } from 'node:child_process';
@@ -20,6 +20,8 @@ import { createInterface } from 'node:readline';
20
20
  import { readToken, tokenSource, tokenPath, tokenHelp } from "./lib/token.js";
21
21
  import { operatorPath, readOperator, actorHeaderValue } from "./lib/operator.js";
22
22
  import { resolveBaseUrl } from "./lib/base-url.js";
23
+ import { recordProject, registeredPath } from "./lib/registry.js";
24
+ import * as tui from "./lib/tui.js";
23
25
  const CLI_PATH = fileURLToPath(import.meta.url);
24
26
  // Cap on the gzipped tarball push streams to the server. spawnSync kills tar (SIGTERM,
25
27
  // empty stderr) if its stdout exceeds this, so pushCmd surfaces the size in its error.
@@ -161,7 +163,7 @@ function saveManifest(root, m) {
161
163
  }
162
164
  function buildIndex(root) {
163
165
  const m = loadManifest(root);
164
- const project = basename(dirname(root)) || 'project';
166
+ const project = basename(projectDirOf(root)) || 'project';
165
167
  const htmlPath = htmlPathOf(root);
166
168
  // A fresh/nonexistent root builds an empty gallery — mirror saveManifest's
167
169
  // dir creation instead of crashing with a raw ENOENT.
@@ -228,7 +230,7 @@ function appendShot(root, shot) {
228
230
  // v2: stamp the session's code identity (last append wins — the freshest
229
231
  // commit is the one the newest pixels came from).
230
232
  m.version = Math.max(m.version || 1, 2);
231
- const projectDir = dirname(root);
233
+ const projectDir = projectDirOf(root);
232
234
  const commit = git(['rev-parse', '--short', 'HEAD'], projectDir);
233
235
  const branch = git(['branch', '--show-current'], projectDir);
234
236
  if (commit)
@@ -236,7 +238,7 @@ function appendShot(root, shot) {
236
238
  if (branch)
237
239
  m.branch = branch;
238
240
  saveManifest(root, m);
239
- const project = basename(dirname(root)) || 'project';
241
+ const project = basename(projectDirOf(root)) || 'project';
240
242
  const htmlPath = htmlPathOf(root);
241
243
  writeFileSync(htmlPath, renderHtml(m.shots, project));
242
244
  return { count: m.shots.length, htmlPath };
@@ -512,6 +514,41 @@ export function mainWorktreeTop(cwd) {
512
514
  return dirname(common);
513
515
  return git(['rev-parse', '--show-toplevel'], cwd);
514
516
  }
517
+ // ── .vitrinka/ home (decisions 2026-07-23) ────────────────────────────────
518
+ // Everything vitrinka writes into a target project lives under ONE dir:
519
+ // .vitrinka/screenshots/ (was ./.screenshots)
520
+ // .vitrinka/artifacts/<slug>/ (was ./.artifacts/<slug>)
521
+ // .vitrinka/scratch/<topic>/ (sanctioned home for ad-hoc session output)
522
+ // Legacy roots auto-migrate on first touch (rename + one notice); the home
523
+ // gets a single `.vitrinka/` .gitignore line on top of the per-root
524
+ // info/exclude self-ignore.
525
+ const VITRINKA_HOME = '.vitrinka';
526
+ function homeRoot(kind, cwd = process.cwd()) {
527
+ const home = resolve(cwd, VITRINKA_HOME);
528
+ const next = join(home, kind);
529
+ const legacy = kind === 'scratch' ? '' : resolve(cwd, kind === 'screenshots' ? '.screenshots' : '.artifacts');
530
+ if (legacy && existsSync(legacy) && !existsSync(next)) {
531
+ mkdirSync(home, { recursive: true });
532
+ renameSync(legacy, next);
533
+ console.error(`vitrinka: migrated ${relative(cwd, legacy)} → ${relative(cwd, next)}`);
534
+ }
535
+ if (existsSync(home))
536
+ ensureGitIgnored(cwd, `${VITRINKA_HOME}/`);
537
+ return next;
538
+ }
539
+ // The project dir for a root under the home (<p>/.vitrinka/screenshots) is
540
+ // <p>, NOT the home dir — deriving the project name from dirname(root) would
541
+ // call every project ".vitrinka". Legacy layouts keep their old parents.
542
+ function projectDirOf(root) {
543
+ let d = dirname(root);
544
+ if (basename(d) === '.artifacts')
545
+ d = dirname(d); // legacy artifact container
546
+ if (['screenshots', 'artifacts', 'scratch'].includes(basename(d)) && basename(dirname(d)) === VITRINKA_HOME)
547
+ d = dirname(d);
548
+ if (basename(d) === VITRINKA_HOME)
549
+ d = dirname(d);
550
+ return d;
551
+ }
515
552
  // originRepo normalizes the origin remote to a schemeless slug, e.g.
516
553
  // "github.com/LEFTEQ/fixit". "" outside a repo / without a remote.
517
554
  function originRepo(dir) {
@@ -679,6 +716,21 @@ export function buildProjectIndex(projectDir) {
679
716
  function indexCachePath(projectDir) {
680
717
  return join(git(['rev-parse', '--path-format=absolute', '--git-common-dir'], projectDir), 'vitrinka-index.cache');
681
718
  }
719
+ // setupNudge — one dim stderr line when the repo isn't fully set up (setup-project
720
+ // decision #6: registry entry AND index stamp both present = healthy): shown
721
+ // exactly where the pain shows (edit/status), silent on healthy repos. The
722
+ // registry check catches a best-effort recordProject that failed to write.
723
+ // Callers gate on being inside a git repo.
724
+ function setupNudge(projectDir, project, top) {
725
+ try {
726
+ if (registeredPath(project) !== top || !existsSync(indexCachePath(projectDir))) {
727
+ console.error(tui.pc.dim('hint: repo not set up for vitrinka — run: vitrinka setup-project'));
728
+ }
729
+ }
730
+ catch {
731
+ // best-effort — a nudge must never break the command that hosts it
732
+ }
733
+ }
682
734
  /** pushProjectIndex builds + pushes the index unless nothing changed since
683
735
  * the last successful push. Never throws and never exits — every caller is
684
736
  * a touchpoint where index freshness must not fail the primary job.
@@ -748,7 +800,7 @@ export async function pushProjectIndex(projectDir, base, project, verify = false
748
800
  // failure (unlike the piggybacked pushes) so scripts can rely on it; verifies
749
801
  // the server actually holds the index rather than trusting the local stamp.
750
802
  async function indexCmd(root, args) {
751
- const projectDir = dirname(root);
803
+ const projectDir = projectDirOf(root);
752
804
  const cfg = existsSync(vitrinkaCfgPath(root)) ? loadVitrinkaCfg(root) : null;
753
805
  const base = (strArg(args.base) || cfg?.base || process.env.VITRINKA_URL || 'https://vitrinka.in').replace(/\/+$/, '');
754
806
  const project = sanitizeSeg(strArg(args.project) || cfg?.project || basename(mainWorktreeTop(projectDir) || projectDir), 64);
@@ -823,7 +875,7 @@ function scanComponents(projectDir, srcDirs) {
823
875
  return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
824
876
  }
825
877
  async function indexComponentsCmd(root, args) {
826
- const projectDir = dirname(root);
878
+ const projectDir = projectDirOf(root);
827
879
  const library = strArg(args.library);
828
880
  if (!library)
829
881
  throw new Error('index-components: --library <kit> is required (one per UI library, e.g. "web" or "expo")');
@@ -847,6 +899,10 @@ async function indexComponentsCmd(root, args) {
847
899
  console.log(JSON.stringify({ project, library, components }, null, 2));
848
900
  return;
849
901
  }
902
+ await pushComponents(base, project, library, components);
903
+ }
904
+ // pushComponents — the PUT shared by index-components and setup-project.
905
+ async function pushComponents(base, project, library, components) {
850
906
  const token = readToken();
851
907
  const headers = { 'Content-Type': 'application/json' };
852
908
  if (token)
@@ -860,6 +916,42 @@ async function indexComponentsCmd(root, args) {
860
916
  throw new Error(`index-components: push rejected (HTTP ${res.status}): ${(await res.text()).slice(0, 200)}`);
861
917
  console.log(`components ↑ ${project}/${library}: ${components.length} indexed`);
862
918
  }
919
+ // detectComponentDirs — the setup-project heuristic: well-known component roots
920
+ // (plus one level of apps/* and packages/* for monorepos) that actually contain
921
+ // a meaningful number of exported components, best first. Deliberately cheap —
922
+ // it reuses the index-components scanner and never walks the whole repo.
923
+ export function detectComponentDirs(top) {
924
+ const roots = new Set(['src/components', 'components', 'src/ui', 'ui', 'lib/components']);
925
+ for (const container of ['apps', 'packages']) {
926
+ let entries;
927
+ try {
928
+ entries = readdirSync(join(top, container), { withFileTypes: true });
929
+ }
930
+ catch {
931
+ continue; // not a monorepo container — normal
932
+ }
933
+ for (const e of entries) {
934
+ if (!e.isDirectory() || e.name.startsWith('.'))
935
+ continue;
936
+ roots.add(`${container}/${e.name}/src/components`);
937
+ roots.add(`${container}/${e.name}/components`);
938
+ }
939
+ }
940
+ const out = [];
941
+ for (const dir of roots) {
942
+ if (!existsSync(join(top, dir)))
943
+ continue;
944
+ const count = scanComponents(top, [dir]).length;
945
+ if (count >= 3)
946
+ out.push({ dir, count }); // fewer reads as noise, not a library
947
+ }
948
+ return out.sort((a, b) => b.count - a.count).slice(0, 6);
949
+ }
950
+ // guessLibraryName — "apps/web/src/components" → "web"; flat repos → "web".
951
+ export function guessLibraryName(dir) {
952
+ const m = dir.match(/^(?:apps|packages)\/([^/]+)\//);
953
+ return m ? m[1] : 'web';
954
+ }
863
955
  function loadVitrinkaCfg(root) {
864
956
  const raw = readFileSync(vitrinkaCfgPath(root), 'utf8'); // caller checks existsSync
865
957
  try {
@@ -912,16 +1004,15 @@ async function remoteInitCmd(root, args) {
912
1004
  console.log(`vitrinka set (existing) → ${shareUrl(cfg)}`);
913
1005
  return cfg;
914
1006
  }
915
- const projectDir = dirname(root);
1007
+ const projectDir = projectDirOf(root);
916
1008
  const base = (args.base && args.base !== true ? String(args.base) : process.env.VITRINKA_URL || 'https://vitrinka.in').replace(/\/+$/, '');
917
1009
  // Project identity comes from the MAIN worktree (a linked worktree's dir is
918
1010
  // named after its branch — using it would fork each branch into its own
919
1011
  // project; the branch already lives in cfg.branch).
920
1012
  const gitTop = mainWorktreeTop(projectDir);
921
- // Artifact roots live at <project>/.artifacts/<slug> outside a git repo the project
922
- // fallback must be the real project dir, not the '.artifacts' container itself.
923
- const fallbackDir = basename(projectDir) === '.artifacts' ? dirname(projectDir) : projectDir;
924
- const project = sanitizeSeg(args.project && args.project !== true ? String(args.project) : basename(gitTop || fallbackDir), 64);
1013
+ // Outside a git repo the fallback is the real project dir — projectDirOf
1014
+ // already stepped out of the .vitrinka/ home and legacy containers.
1015
+ const project = sanitizeSeg(args.project && args.project !== true ? String(args.project) : basename(gitTop || projectDir), 64);
925
1016
  const branch = (args.branch && args.branch !== true ? String(args.branch) : git(['branch', '--show-current'], projectDir)) || 'main';
926
1017
  const d = new Date();
927
1018
  const p2 = (n) => String(n).padStart(2, '0');
@@ -1016,7 +1107,7 @@ async function pushCmd(root, args) {
1016
1107
  process.exit(2);
1017
1108
  }
1018
1109
  const cfg = loadVitrinkaCfg(root);
1019
- const projectDir = dirname(root);
1110
+ const projectDir = projectDirOf(root);
1020
1111
  // Control files are excluded here AND skipped server-side (belt and suspenders).
1021
1112
  // COPYFILE_DISABLE stops macOS bsdtar from emitting AppleDouble (._*) junk entries.
1022
1113
  const tar = spawnSync('tar', [
@@ -1323,6 +1414,85 @@ async function operatorCmd(rest, args) {
1323
1414
  }
1324
1415
  }
1325
1416
  // ---------- doctor: setup health check ----------
1417
+ // tidyCmd sweeps legacy vitrinka output and shot-shaped session litter into
1418
+ // the .vitrinka/ home. Report-first: prints the plan and exits unless --yes.
1419
+ // Conservative by design — only known patterns move by default (.screenshots/
1420
+ // + .artifacts/ legacy roots, .screenshots-<topic> session dirs, loose
1421
+ // shot-*.png); other loose root images and .aud_*/.audit_* text litter are
1422
+ // reported and move only under --all. Git-TRACKED paths never move.
1423
+ function tidyCmd(args) {
1424
+ const cwd = process.cwd();
1425
+ const top = git(['rev-parse', '--show-toplevel'], cwd) || cwd;
1426
+ const all = args.all === true;
1427
+ const tracked = new Set((git(['ls-files'], top) || '').split('\n').filter(Boolean));
1428
+ const trackedUnder = (rel) => {
1429
+ if (tracked.has(rel))
1430
+ return true;
1431
+ const prefix = rel + '/';
1432
+ for (const t of tracked)
1433
+ if (t.startsWith(prefix))
1434
+ return true;
1435
+ return false;
1436
+ };
1437
+ const moves = [];
1438
+ const skipped = [];
1439
+ const plan = (from, to) => {
1440
+ if (trackedUnder(from)) {
1441
+ skipped.push(`${from} (git-tracked — not touching)`);
1442
+ return;
1443
+ }
1444
+ if (existsSync(join(top, to))) {
1445
+ skipped.push(`${from} (target ${to} already exists)`);
1446
+ return;
1447
+ }
1448
+ moves.push({ from, to });
1449
+ };
1450
+ if (existsSync(join(top, '.screenshots')))
1451
+ plan('.screenshots', `${VITRINKA_HOME}/screenshots`);
1452
+ if (existsSync(join(top, '.artifacts')))
1453
+ plan('.artifacts', `${VITRINKA_HOME}/artifacts`);
1454
+ for (const e of readdirSync(top, { withFileTypes: true })) {
1455
+ if (e.name === VITRINKA_HOME || e.name === '.git')
1456
+ continue;
1457
+ if (e.isDirectory()) {
1458
+ const m = e.name.match(/^\.screenshots[-_](.+)$/);
1459
+ if (m)
1460
+ plan(e.name, `${VITRINKA_HOME}/scratch/${m[1]}`);
1461
+ }
1462
+ else if (e.isFile()) {
1463
+ if (trackedUnder(e.name))
1464
+ continue; // committed files are repo content, not litter
1465
+ const isShot = /^shot-.*\.(png|jpe?g|webp)$/i.test(e.name);
1466
+ const isLooseImage = /\.(png|jpe?g|webp)$/i.test(e.name);
1467
+ const isAudit = /^\.aud(it)?_/.test(e.name);
1468
+ if (isShot || (all && (isLooseImage || isAudit)))
1469
+ plan(e.name, `${VITRINKA_HOME}/scratch/loose/${e.name}`);
1470
+ else if (isLooseImage || isAudit)
1471
+ skipped.push(`${e.name} (loose litter — rerun with --all to move)`);
1472
+ }
1473
+ }
1474
+ if (!moves.length && !skipped.length) {
1475
+ console.log('tidy: nothing to do — repo root is clean');
1476
+ return;
1477
+ }
1478
+ for (const mv of moves)
1479
+ console.log(` ${mv.from} → ${mv.to}`);
1480
+ for (const s of skipped)
1481
+ console.log(` skip ${s}`);
1482
+ if (!moves.length)
1483
+ return;
1484
+ if (args.yes !== true) {
1485
+ console.log(`\ntidy: ${moves.length} move(s) planned — rerun with --yes to apply`);
1486
+ return;
1487
+ }
1488
+ for (const mv of moves) {
1489
+ const dest = join(top, mv.to);
1490
+ mkdirSync(dirname(dest), { recursive: true });
1491
+ renameSync(join(top, mv.from), dest);
1492
+ }
1493
+ ensureGitIgnored(top, `${VITRINKA_HOME}/`);
1494
+ console.log(`tidy: moved ${moves.length} item(s) into ${VITRINKA_HOME}/`);
1495
+ }
1326
1496
  // doctorCmd checks this machine's vitrinka setup: server reachable, write
1327
1497
  // token present AND accepted by the server, operator persona set. The token
1328
1498
  // probe is `PUT /api/v1/operator` with an empty JSON body — auth runs before
@@ -1425,14 +1595,6 @@ async function doctorCmd(args) {
1425
1595
  else
1426
1596
  console.log('✗ mcp not registered — repair: vitrinka doctor --fix (or vitrinka install)');
1427
1597
  }
1428
- if (home && existsSync(join(home, '.claude', 'vitrinka-statusline.sh'))) {
1429
- if (fix)
1430
- installClaudeCmd(args);
1431
- else if (existsSync(join(home, '.claude', 'hooks', 'vitrinka-board-link.sh')))
1432
- console.log('✓ claude ui wired (refresh: vitrinka doctor --fix)');
1433
- else
1434
- console.log('✗ claude ui session-start hook missing — repair: vitrinka doctor --fix');
1435
- }
1436
1598
  const appPath = refreshDesktopAppFlag();
1437
1599
  console.log(appPath ? `✓ app ${appPath} (flag: ${desktopAppFlagPath()})` : `- app Vitrinka.app not installed (no flag)`);
1438
1600
  if (failed)
@@ -1633,6 +1795,9 @@ async function installCmd(args) {
1633
1795
  if (!home)
1634
1796
  fail('install: $HOME is not set');
1635
1797
  const local = args.local === true;
1798
+ // --yes (setup-project's inline delegate, or direct use): behave exactly like a
1799
+ // non-TTY run — skip every prompt, config-touchers print their manual command.
1800
+ const noPrompt = args.yes === true || !process.stdin.isTTY;
1636
1801
  const base = resolveBaseUrl(strArg(args.base));
1637
1802
  const mcpScope = local ? 'project' : 'user';
1638
1803
  // ---- probe (before touching anything) ----
@@ -1645,8 +1810,16 @@ async function installCmd(args) {
1645
1810
  const mcpOk = haveClaude ? mcpRegistered() : false;
1646
1811
  const tokenSrc = tokenSource();
1647
1812
  const op = readOperator(true);
1648
- const wrapperP = join(home, '.claude', 'vitrinka-statusline.sh');
1649
- const uiOk = existsSync(wrapperP);
1813
+ // Prior consent to the Claude UI wiring == the footer badge is already in
1814
+ // settings (the retired statusline wrapper is no longer a signal).
1815
+ const claudeSettingsP = join(home, '.claude', 'settings.json');
1816
+ let uiOk = false;
1817
+ if (existsSync(claudeSettingsP)) {
1818
+ try {
1819
+ uiOk = hasBoardFooterLink(JSON.parse(readFileSync(claudeSettingsP, 'utf8')));
1820
+ }
1821
+ catch { /* malformed settings.json — treat as not-wired; install-claude reports it */ }
1822
+ }
1650
1823
  const row = (ok, label, detail) => {
1651
1824
  console.log(` ${ok ? '✓' : '✗'} ${label.padEnd(11)}${detail}`);
1652
1825
  };
@@ -1661,7 +1834,7 @@ async function installCmd(args) {
1661
1834
  row(mcpOk, 'mcp', mcpOk ? 'registered' : haveClaude ? `not registered (scope ${mcpScope})` : 'claude CLI not found');
1662
1835
  row(tokenSrc !== 'missing', 'token', tokenSrc === 'missing' ? 'missing — writes will 401' : `present (${tokenSrc === 'env' ? '$VITRINKA_TOKEN' : tokenPath()})`);
1663
1836
  row(!!op, 'operator', op || 'unset — board writes stay anonymous');
1664
- row(uiOk, 'claude ui', uiOk ? 'statusline + footer + session hook wired' : 'not wired');
1837
+ row(uiOk, 'claude ui', uiOk ? 'footer badge wired' : 'not wired');
1665
1838
  console.log('');
1666
1839
  // ---- silent steps: plugins, shim, PATH, completion (reversible, own dirs) ----
1667
1840
  for (const rt of runtimes) {
@@ -1700,7 +1873,7 @@ async function installCmd(args) {
1700
1873
  else if (!mcpOk) {
1701
1874
  if (args['no-mcp'] === true)
1702
1875
  console.log('mcp: skipped (--no-mcp)');
1703
- else if (args.mcp === true || promptYesNo(`Register the vitrinka MCP for Claude Code (scope ${mcpScope})? [Y/n] `)) {
1876
+ else if (args.mcp === true || (!noPrompt && promptYesNo(`Register the vitrinka MCP for Claude Code (scope ${mcpScope})? [Y/n] `))) {
1704
1877
  if (registerMcp(mcpScope))
1705
1878
  console.log(`✓ mcp registered (--scope ${mcpScope})`);
1706
1879
  else
@@ -1712,7 +1885,7 @@ async function installCmd(args) {
1712
1885
  }
1713
1886
  // 2. Write token (reads work without one; pushes/board writes 401).
1714
1887
  if (tokenSrc === 'missing') {
1715
- if (process.stdin.isTTY) {
1888
+ if (!noPrompt) {
1716
1889
  console.log('No write token yet — reads work without one, writes 401. It is the');
1717
1890
  console.log('server\'s VITRINKA_TOKEN (one shared secret — copy it from a configured');
1718
1891
  console.log('machine or the server admin; details: vitrinka doctor).');
@@ -1738,31 +1911,31 @@ async function installCmd(args) {
1738
1911
  await operatorCmd(['operator', opArg], args);
1739
1912
  }
1740
1913
  else if (!op) {
1741
- const name = promptLine('Operator name — how should vitrinka credit you on boards? (Enter to skip): ');
1914
+ const name = noPrompt ? '' : promptLine('Operator name — how should vitrinka credit you on boards? (Enter to skip): ');
1742
1915
  if (name)
1743
1916
  await operatorCmd(['operator', name], args);
1744
1917
  else
1745
1918
  console.error('⚠ no operator — board writes stay anonymous (set later: vitrinka operator <name>)');
1746
1919
  }
1747
- // 4. Claude Code UI wiring (edits ~/.claude/settings.json). Already-wired
1748
- // machines refresh silently (consent was given once); fresh ones are asked.
1920
+ // 4. Claude Code UI wiring (edits ~/.claude/settings.json). The retired
1921
+ // statusline extension is cleaned up on every run (silent when absent); the
1922
+ // footer badge is (re-)wired — already-wired machines refresh silently
1923
+ // (consent was given once), fresh ones are asked.
1924
+ cleanupLegacyStatusline(home);
1749
1925
  if (args['no-claude'] === true) {
1750
1926
  console.log('claude ui: skipped (--no-claude)');
1751
1927
  }
1752
1928
  else if (uiOk || args.claude === true
1753
- || promptYesNo('Wire Claude Code UI (🧷 board statusline segment + clickable footer badge + session-start board link)? [Y/n] ')) {
1929
+ || (!noPrompt && promptYesNo('Wire Claude Code UI (clickable 🧷 board footer badge)? [Y/n] '))) {
1754
1930
  installClaudeCmd(args);
1755
1931
  }
1756
- else if (!process.stdin.isTTY) {
1932
+ else if (noPrompt) {
1757
1933
  console.log('claude ui: skipped (non-interactive — run `vitrinka install-claude`, or pass --claude)');
1758
1934
  }
1759
1935
  else {
1760
1936
  console.log('claude ui: skipped (re-run any time: vitrinka install-claude)');
1761
1937
  }
1762
1938
  // ---- environment checks (non-fatal) ----
1763
- if (spawnSync('jq', ['--version'], { stdio: 'ignore' }).error) {
1764
- console.error('⚠ jq missing (brew install jq) — the statusline 🧷 board segment won\'t render without it');
1765
- }
1766
1939
  if (spawnSync('cwebp', ['-version'], { stdio: 'ignore' }).error) {
1767
1940
  console.error('⚠ cwebp missing (brew install webp) — snap falls back to PNG');
1768
1941
  }
@@ -1798,6 +1971,66 @@ export function stripMarkerBlock(content, marker) {
1798
1971
  // Legacy ~/.claude/skills copy names — removed by uninstall on machines set
1799
1972
  // up before the plugin distribution (install-skills, removed 2026-07-11).
1800
1973
  const LEGACY_SKILLS = ['artifact', 'brainstorming', 'screenshot', 'vitrinka', 'listen'];
1974
+ // cleanupLegacyStatusline removes the retired Claude Code status-panel extension
1975
+ // (docs/specs/2026-07-20-menubar-tray-decisions.md #5): the
1976
+ // ~/.claude/vitrinka-statusline.sh wrapper — restoring the user's original
1977
+ // statusLine command recorded on its VITRINKA_ORIG line — and the
1978
+ // vitrinka-board-link SessionStart hook + its settings entry. The /boards/
1979
+ // footer badge is a separate surface and is NOT touched here. Silent when
1980
+ // nothing is there; prints one short notice when it removed something. Shared
1981
+ // by `install`/`update` (clean existing installs) and `uninstall`.
1982
+ export function cleanupLegacyStatusline(home) {
1983
+ const wrapperP = join(home, '.claude', 'vitrinka-statusline.sh');
1984
+ const hookP = join(home, '.claude', 'hooks', 'vitrinka-board-link.sh');
1985
+ const settingsPath = join(home, '.claude', 'settings.json');
1986
+ let removed = false;
1987
+ if (existsSync(settingsPath)) {
1988
+ let settings;
1989
+ try {
1990
+ settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
1991
+ }
1992
+ catch (e) {
1993
+ fail(`cleanup: ${settingsPath} is not valid JSON — nothing was changed there; fix it first (${e.message})`);
1994
+ }
1995
+ let changed = false;
1996
+ const sl = settings.statusLine;
1997
+ if (sl?.command && sl.command.replace(/^~(?=\/)/, home) === wrapperP) {
1998
+ const orig = existsSync(wrapperP) ? wrapperOrigCommand(readFileSync(wrapperP, 'utf8')) : '';
1999
+ if (orig)
2000
+ settings.statusLine = { type: 'command', command: orig };
2001
+ else
2002
+ delete settings.statusLine;
2003
+ changed = true;
2004
+ }
2005
+ if (hasBoardLinkHook(settings)) {
2006
+ const hooks = settings.hooks;
2007
+ const ss = hooks.SessionStart
2008
+ .map((m) => (Array.isArray(m?.hooks)
2009
+ ? { ...m, hooks: m.hooks.filter((h) => !(typeof h?.command === 'string' && h.command.includes('vitrinka-board-link'))) }
2010
+ : m))
2011
+ .filter((m) => !Array.isArray(m?.hooks) || m.hooks.length > 0);
2012
+ if (ss.length)
2013
+ hooks.SessionStart = ss;
2014
+ else
2015
+ delete hooks.SessionStart;
2016
+ changed = true;
2017
+ }
2018
+ if (changed) {
2019
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
2020
+ removed = true;
2021
+ }
2022
+ }
2023
+ if (existsSync(wrapperP)) {
2024
+ rmSync(wrapperP);
2025
+ removed = true;
2026
+ }
2027
+ if (existsSync(hookP)) {
2028
+ rmSync(hookP);
2029
+ removed = true;
2030
+ }
2031
+ if (removed)
2032
+ console.log('✓ removed legacy statusline extension');
2033
+ }
1801
2034
  // uninstallCmd removes everything install put on the machine — the exact
1802
2035
  // inverse: bundled skills + the /vitrinka:listen stub, shim, rc marker blocks,
1803
2036
  // statusline wrapper (restoring the original statusLine command), the /boards/
@@ -1862,10 +2095,10 @@ function uninstallCmd(args) {
1862
2095
  console.log(`✓ removed ${marker} block from ${file}`);
1863
2096
  }
1864
2097
  }
1865
- // Claude UI wiring — restore statusLine from the wrapper's VITRINKA_ORIG,
1866
- // drop the /boards/ footer entry, delete the wrapper.
2098
+ // Claude UI wiring — drop the /boards/ footer entry, then run the shared
2099
+ // cleanup that removes the retired statusline wrapper + board-link hook and
2100
+ // restores the user's original statusLine command.
1867
2101
  const settingsPath = join(home, '.claude', 'settings.json');
1868
- const wrapperP = join(home, '.claude', 'vitrinka-statusline.sh');
1869
2102
  if (existsSync(settingsPath)) {
1870
2103
  let settings;
1871
2104
  try {
@@ -1874,17 +2107,6 @@ function uninstallCmd(args) {
1874
2107
  catch (e) {
1875
2108
  fail(`uninstall: ${settingsPath} is not valid JSON — nothing was changed there; fix it first (${e.message})`);
1876
2109
  }
1877
- let changed = false;
1878
- const sl = settings.statusLine;
1879
- if (sl?.command && sl.command.replace(/^~(?=\/)/, home) === wrapperP) {
1880
- const orig = existsSync(wrapperP) ? wrapperOrigCommand(readFileSync(wrapperP, 'utf8')) : '';
1881
- if (orig)
1882
- settings.statusLine = { type: 'command', command: orig };
1883
- else
1884
- delete settings.statusLine;
1885
- changed = true;
1886
- console.log(`✓ statusline restored${orig ? ` → ${orig}` : ' (removed — none was configured before)'}`);
1887
- }
1888
2110
  const arr = settings.footerLinksRegexes;
1889
2111
  if (Array.isArray(arr)) {
1890
2112
  const kept = arr.filter((e) => !(typeof e?.pattern === 'string' && e.pattern.replaceAll('\\', '').includes('/boards/')));
@@ -1893,36 +2115,12 @@ function uninstallCmd(args) {
1893
2115
  settings.footerLinksRegexes = kept;
1894
2116
  else
1895
2117
  delete settings.footerLinksRegexes;
1896
- changed = true;
2118
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
1897
2119
  console.log('✓ footer badge entry removed');
1898
2120
  }
1899
2121
  }
1900
- if (hasBoardLinkHook(settings)) {
1901
- const hooks = settings.hooks;
1902
- const ss = hooks.SessionStart
1903
- .map((m) => (Array.isArray(m?.hooks)
1904
- ? { ...m, hooks: m.hooks.filter((h) => !(typeof h?.command === 'string' && h.command.includes('vitrinka-board-link'))) }
1905
- : m))
1906
- .filter((m) => !Array.isArray(m?.hooks) || m.hooks.length > 0);
1907
- if (ss.length)
1908
- hooks.SessionStart = ss;
1909
- else
1910
- delete hooks.SessionStart;
1911
- changed = true;
1912
- console.log('✓ session-start hook entry removed');
1913
- }
1914
- if (changed)
1915
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
1916
- }
1917
- if (existsSync(wrapperP)) {
1918
- rmSync(wrapperP);
1919
- console.log(`✓ removed ${wrapperP}`);
1920
- }
1921
- const hookP = join(home, '.claude', 'hooks', 'vitrinka-board-link.sh');
1922
- if (existsSync(hookP)) {
1923
- rmSync(hookP);
1924
- console.log(`✓ removed ${hookP}`);
1925
2122
  }
2123
+ cleanupLegacyStatusline(home);
1926
2124
  if (existsSync(desktopAppFlagPath())) {
1927
2125
  rmSync(desktopAppFlagPath());
1928
2126
  console.log(`✓ removed ${desktopAppFlagPath()}`);
@@ -1949,27 +2147,18 @@ function uninstallCmd(args) {
1949
2147
  }
1950
2148
  // ---------- install-claude: Claude Code UI wiring ----------
1951
2149
  //
1952
- // Wires the three Claude Code surfaces that link to boards (decisions:
2150
+ // Wires the Claude Code footer badges that link to boards (decisions:
1953
2151
  // docs/specs/2026-07-07-claude-install-decisions.md):
1954
2152
  // 1. footerLinksRegexes in ~/.claude/settings.json — Claude renders its own
1955
2153
  // clickable "🧷 board" footer badge whenever a /boards/ URL appears in the
1956
- // conversation (works in every terminal, unlike statusline OSC 8).
1957
- // 2. statusLine — a wrapper script that pipes the same stdin JSON to the
1958
- // user's ORIGINAL statusline command and appends a "🧷 <board-url>" plain-text
1959
- // segment when the session's capture root has a .screenshots/.vitrinka
1960
- // descriptor. Plain URL on purpose: Claude Code strips OSC 8 from statusline
1961
- // output in real terminals (anthropics/claude-code#21586, #26356 — closed
1962
- // "not planned"), but terminals like Warp/iTerm2/Ghostty cmd+click-linkify
1963
- // visible URLs on their own.
1964
- // With no original statusline, the wrapper renders a minimal line itself.
1965
- // 3. SessionStart hook — ~/.claude/hooks/vitrinka-board-link.sh prints the
1966
- // capture root's board URL into session context at start, so the footer
1967
- // badge is clickable from message one (the statusline URL isn't).
1968
- // Idempotent, never an uninstall: re-runs detect the /boards/ footer pattern,
1969
- // the wrapper registration, and the SessionStart entry and no-op. Removal =
1970
- // delete the wrapper + hook + restore statusLine.command (recorded in the
1971
- // wrapper's VITRINKA_ORIG line) + drop the footerLinksRegexes and SessionStart
1972
- // entries (documented in pkg/README.md).
2154
+ // conversation (works in every terminal).
2155
+ // 2. App badge — a second footer entry whose vitrinka:// deep link opens the
2156
+ // same board in Vitrinka.app, added only where the desktop app is present.
2157
+ // The old statusline segment + SessionStart board-link hook were removed for
2158
+ // good (docs/specs/2026-07-20-menubar-tray-decisions.md #5): active-board
2159
+ // visibility now lives in the desktop app's menu bar tray. Idempotent, never an
2160
+ // uninstall: re-runs detect the /boards/ footer pattern and no-op. Removal = drop
2161
+ // the footerLinksRegexes entries (documented in pkg/README.md).
1973
2162
  export const BOARD_FOOTER_LINK = {
1974
2163
  type: 'regex',
1975
2164
  pattern: '(?<url>https?:\\/\\/\\S+\\/boards\\/(?<slug>[\\w.-]+))',
@@ -2028,108 +2217,15 @@ export function refreshDesktopAppFlag() {
2028
2217
  }
2029
2218
  return app;
2030
2219
  }
2031
- // buildStatuslineWrapper — the ~/.claude/vitrinka-statusline.sh content. The
2032
- // original command is recorded on the VITRINKA_ORIG line (single-quoted), both
2033
- // to run it and so a re-run can recover it instead of wrapping the wrapper.
2034
- // Board detection mirrors the CLI's capture-root contract: .screenshots/.vitrinka
2035
- // with {base, key[, workspace]} → ${base}[/w/${workspace}]/boards/${key}. Needs jq
2036
- // (like the statusline examples in Claude's own docs); without jq it degrades to
2037
- // pass-through.
2038
- export function buildStatuslineWrapper(origCmd) {
2039
- const quoted = origCmd.replaceAll("'", `'\\''`);
2040
- return `#!/bin/bash
2041
- # vitrinka-statusline — managed by \`vitrinka install-claude\` (re-runs rewrite it).
2042
- # Wraps the original Claude Code statusline and appends a 🧷 board-URL segment when
2043
- # the session's capture root has a .screenshots/.vitrinka descriptor. Plain URL,
2044
- # not OSC 8 — Claude Code strips OSC 8 from statusline output; terminals
2045
- # cmd+click-linkify visible URLs themselves.
2046
- VITRINKA_ORIG='${quoted}'
2047
-
2048
- input=$(cat)
2049
-
2050
- out=""
2051
- if [ -n "$VITRINKA_ORIG" ]; then
2052
- out=$(printf '%s' "$input" | /bin/sh -c "$VITRINKA_ORIG")
2053
- fi
2054
-
2055
- # The original already renders a board segment → pass through untouched.
2056
- case "$out" in *'/boards/'*|*'🧷'*) printf '%s\\n' "$out"; exit 0 ;; esac
2057
-
2058
- if ! command -v jq >/dev/null 2>&1; then printf '%s\\n' "$out"; exit 0; fi
2059
-
2060
- DIR=$(printf '%s' "$input" | jq -r '.workspace.current_dir // empty')
2061
- PROJECT_DIR=$(printf '%s' "$input" | jq -r '.workspace.project_dir // empty')
2062
-
2063
- BOARD=""
2064
- for VJSON in "$DIR/.screenshots/.vitrinka" "$PROJECT_DIR/.screenshots/.vitrinka"; do
2065
- [ -f "$VJSON" ] || continue
2066
- VBASE=$(jq -r '.base // empty' "$VJSON" 2>/dev/null)
2067
- VKEY=$(jq -r '.key // empty' "$VJSON" 2>/dev/null)
2068
- VWS=$(jq -r '.workspace // empty' "$VJSON" 2>/dev/null)
2069
- if [ -n "$VBASE" ] && [ -n "$VKEY" ]; then
2070
- [ -n "$VWS" ] && VBASE="$VBASE/w/$VWS"
2071
- BOARD=$(printf '\\033[35m🧷\\033[0m \\033[2m%s\\033[0m' "$VBASE/boards/$VKEY")
2072
- fi
2073
- break
2074
- done
2075
-
2076
- DIM=$(printf '\\033[2m'); RESET=$(printf '\\033[0m')
2077
-
2078
- if [ -n "$VITRINKA_ORIG" ]; then
2079
- # Append to the FIRST line only (extra statusline lines stay untouched).
2080
- first=\${out%%$'\\n'*}; rest=\${out#"$first"}
2081
- [ -n "$BOARD" ] && first="$first \${DIM}│\${RESET} $BOARD"
2082
- printf '%s%s\\n' "$first" "$rest"
2083
- else
2084
- # No original statusline — minimal line: dir │ branch │ board │ model.
2085
- MODEL=$(printf '%s' "$input" | jq -r '.model.display_name // empty')
2086
- BRANCH=$(git -C "\${DIR:-.}" branch --show-current 2>/dev/null)
2087
- GREEN=$(printf '\\033[32m'); CYAN=$(printf '\\033[36m')
2088
- line="\${DIR/#$HOME/~}"
2089
- [ -n "$BRANCH" ] && line="$line \${DIM}│\${RESET} \${GREEN}\${BRANCH}\${RESET}"
2090
- [ -n "$BOARD" ] && line="$line \${DIM}│\${RESET} $BOARD"
2091
- [ -n "$MODEL" ] && line="$line \${DIM}│\${RESET} \${CYAN}\${MODEL}\${RESET}"
2092
- printf '%s\\n' "$line"
2093
- fi
2094
- `;
2095
- }
2096
- // SessionStart hook — the third Claude UI surface. Statusline URLs aren't
2097
- // clickable in every terminal (and never OSC 8), so this puts the board URL
2098
- // into the session context at start: the footer badge renders from message
2099
- // one and Claude knows the live board without asking. Silent no-op when the
2100
- // repo has no .screenshots/.vitrinka descriptor.
2101
- export const BOARD_LINK_HOOK_CMD = '~/.claude/hooks/vitrinka-board-link.sh';
2102
- export const BOARD_LINK_HOOK_SCRIPT = `#!/bin/bash
2103
- # vitrinka-board-link — SessionStart hook, managed by \`vitrinka install-claude\`
2104
- # (re-runs rewrite it). Emits the repo's live board URL into session context so
2105
- # the clickable footer badge (footerLinksRegexes /boards/ entry) exists from
2106
- # message one. Silent no-op without a .screenshots/.vitrinka descriptor.
2107
- command -v jq >/dev/null 2>&1 || exit 0
2108
- input=$(cat)
2109
- dir=$(printf '%s' "$input" | jq -r '.cwd // ""')
2110
- dir="\${dir:-$PWD}"
2111
- root=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null)
2112
- vjson=""
2113
- for c in "$dir/.screenshots/.vitrinka" \${root:+"$root/.screenshots/.vitrinka"}; do
2114
- [ -f "$c" ] && { vjson=$c; break; }
2115
- done
2116
- [ -n "$vjson" ] || exit 0
2117
- base=$(jq -r '.base // empty' "$vjson" 2>/dev/null)
2118
- key=$(jq -r '.key // empty' "$vjson" 2>/dev/null)
2119
- ws=$(jq -r '.workspace // empty' "$vjson" 2>/dev/null)
2120
- [ -n "$base" ] && [ -n "$key" ] || exit 0
2121
- [ -n "$ws" ] && base="$base/w/$ws"
2122
- printf 'Live vitrinka board for this repo: %s/boards/%s\\n' "$base" "$key"
2123
- `;
2124
- // hasBoardLinkHook — a SessionStart entry running vitrinka-board-link already
2125
- // exists; the marker that makes the settings edit idempotent.
2220
+ // hasBoardLinkHooka SessionStart entry running the retired vitrinka-board-link
2221
+ // hook still exists; used by cleanupLegacyStatusline to find and drop it.
2126
2222
  export function hasBoardLinkHook(settings) {
2127
2223
  const arr = settings.hooks?.SessionStart;
2128
2224
  return Array.isArray(arr) && arr.some((m) => Array.isArray(m?.hooks)
2129
2225
  && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('vitrinka-board-link')));
2130
2226
  }
2131
- // wrapperOrigCommand — recover the original statusline command a wrapper file
2132
- // recorded, so re-runs refresh the wrapper without wrapping it around itself.
2227
+ // wrapperOrigCommand — recover the original statusline command a legacy wrapper
2228
+ // file recorded, so cleanup can restore it when removing the retired extension.
2133
2229
  export function wrapperOrigCommand(content) {
2134
2230
  const line = content.split('\n').find((l) => l.startsWith("VITRINKA_ORIG='"));
2135
2231
  if (!line || !line.endsWith("'"))
@@ -2201,56 +2297,10 @@ export function installClaudeCmd(_args) {
2201
2297
  changed = true;
2202
2298
  console.log('✓ app badge removed (Vitrinka.app not installed on this machine)');
2203
2299
  }
2204
- // 2. Statusline wrapper.
2205
- const wrapperPath = join(claudeDir, 'vitrinka-statusline.sh');
2206
- const wrapperCmd = '~/.claude/vitrinka-statusline.sh';
2207
- const sl = settings.statusLine;
2208
- if (sl && sl.type !== undefined && sl.type !== 'command') {
2209
- console.error(`⚠ statusLine.type is ${JSON.stringify(sl.type)} — don't know how to wrap it; statusline left untouched (footer badge still works)`);
2210
- }
2211
- else {
2212
- const current = sl?.command ?? '';
2213
- const isWrapper = current.replace(/^~(?=\/)/, home) === wrapperPath;
2214
- // Re-runs recover the original from the existing wrapper file — never wrap the wrapper.
2215
- const orig = isWrapper && existsSync(wrapperPath) ? wrapperOrigCommand(readFileSync(wrapperPath, 'utf8')) : current;
2216
- writeFileSync(wrapperPath, buildStatuslineWrapper(orig));
2217
- chmodSync(wrapperPath, 0o755);
2218
- if (!isWrapper) {
2219
- settings.statusLine = { type: 'command', command: wrapperCmd };
2220
- changed = true;
2221
- }
2222
- if (isWrapper)
2223
- console.log(`✓ statusline wrapper refreshed (${wrapperPath}${orig ? `, wraps: ${orig}` : ', minimal line'})`);
2224
- else if (orig)
2225
- console.log(`✓ statusline wrapped → ${wrapperPath} (appends 🧷 board to: ${orig})`);
2226
- else
2227
- console.log(`✓ statusline installed → ${wrapperPath} (minimal: dir │ branch │ 🧷 board │ model)`);
2228
- }
2229
- // 3. SessionStart hook — board URL into context at session start, so the
2230
- // footer badge is clickable from message one.
2231
- const hooksDir = join(claudeDir, 'hooks');
2232
- const hookPath = join(hooksDir, 'vitrinka-board-link.sh');
2233
- mkdirSync(hooksDir, { recursive: true });
2234
- writeFileSync(hookPath, BOARD_LINK_HOOK_SCRIPT);
2235
- chmodSync(hookPath, 0o755);
2236
- if (hasBoardLinkHook(settings)) {
2237
- console.log(`✓ session-start hook refreshed (${hookPath})`);
2238
- }
2239
- else {
2240
- const hooks = (settings.hooks && typeof settings.hooks === 'object' ? settings.hooks : {});
2241
- const arr = Array.isArray(hooks.SessionStart) ? hooks.SessionStart : [];
2242
- hooks.SessionStart = [...arr, { hooks: [{ type: 'command', command: BOARD_LINK_HOOK_CMD, timeout: 10 }] }];
2243
- settings.hooks = hooks;
2244
- changed = true;
2245
- console.log(`✓ session-start hook → ${hookPath} (board URL in context from message one)`);
2246
- }
2300
+ // The retired statusline segment + board-link hook are cleaned up (not wired)
2301
+ // by install/update/uninstall via cleanupLegacyStatusline; nothing to do here.
2247
2302
  if (changed)
2248
2303
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
2249
- // The wrapper reads Claude's stdin JSON with jq; without it the board segment
2250
- // silently degrades to pass-through — surface that at install time.
2251
- if (spawnSync('jq', ['--version'], { stdio: 'ignore' }).error) {
2252
- console.error('⚠ jq not found — the statusline 🧷 board segment needs it (brew install jq); footer badge works regardless');
2253
- }
2254
2304
  console.log(`install-claude done — restart Claude Code sessions to pick up ${settingsPath}`);
2255
2305
  }
2256
2306
  // ---------- open: jump to the live board ----------
@@ -2293,6 +2343,229 @@ function openCmd(root, args) {
2293
2343
  if (r.error || r.status !== 0)
2294
2344
  console.error(`open: could not launch ${opener} — URL printed above`);
2295
2345
  }
2346
+ // ---------- edit: open a file/dir in the resident editor ----------
2347
+ // buildEditUrl — the vitrinka:// deep link the desktop app routes to its editor
2348
+ // page. PURE (unit-tested): the path is percent-encoded so spaces and unicode
2349
+ // survive the URL round-trip.
2350
+ export function buildEditUrl(absPath) {
2351
+ return `vitrinka://edit?path=${encodeURIComponent(absPath)}`;
2352
+ }
2353
+ // editCmd — `vitrinka edit [path]`: focus the resident Vitrinka.app on a file or
2354
+ // project (instant-editor decision #4). Records the repo in the project registry
2355
+ // as a side effect (#6) so the app's project list stays current, then opens the
2356
+ // vitrinka://edit deep link. --print emits only the URL; when the app isn't
2357
+ // installed we print a one-line install hint plus the URL (nothing to launch).
2358
+ function editCmd(rest, args) {
2359
+ const positional = rest.find((a) => !a.startsWith('--')) || '.';
2360
+ const absPath = resolve(positional);
2361
+ // Inside a git repo → record {project → main worktree top} so the desktop app
2362
+ // discovers this checkout. Same project derivation as statusCmd; the stored
2363
+ // path is the MAIN worktree top (the app enumerates worktrees itself).
2364
+ const top = mainWorktreeTop(absPath);
2365
+ if (top) {
2366
+ const project = sanitizeSeg(strArg(args.project) || basename(top), 64);
2367
+ recordProject(project, top);
2368
+ setupNudge(absPath, project, top);
2369
+ }
2370
+ const url = buildEditUrl(absPath);
2371
+ if (args.print === true) {
2372
+ console.log(url);
2373
+ return;
2374
+ }
2375
+ // Only the resident macOS app can handle vitrinka://edit. When it's absent,
2376
+ // there's nothing to launch — surface the install hint alongside the URL.
2377
+ if (process.platform !== 'darwin' || !existsSync(desktopAppFlagPath())) {
2378
+ console.log(url);
2379
+ console.error('edit: Vitrinka.app not installed — install it with: cd apps/desktop && make install');
2380
+ return;
2381
+ }
2382
+ const r = spawnSync('open', [url], { stdio: 'ignore' });
2383
+ if (r.error || r.status !== 0)
2384
+ console.error(`edit: could not launch open — URL: ${url}`);
2385
+ }
2386
+ // ---------- setup-project: the repo onboarding wizard ----------
2387
+ //
2388
+ // One front door for everything a REPO needs before the editor and the board
2389
+ // @ autocomplete work (docs/specs/2026-07-23-setup-project-decisions.md):
2390
+ // register the repo in the desktop app's project registry, push the file/route
2391
+ // index, offer the component index, check the desktop app, and optionally
2392
+ // create a hello board. Status-first like install: re-runs refresh the cheap
2393
+ // idempotent bits and only prompt for genuinely missing config; --yes and
2394
+ // non-TTY runs take every default.
2395
+ async function setupProjectCmd(args) {
2396
+ const auto = args.yes === true || !tui.interactive();
2397
+ const base = resolveBaseUrl(strArg(args.base));
2398
+ const cwd = resolve('.');
2399
+ const top = mainWorktreeTop(cwd);
2400
+ if (!top)
2401
+ fail('setup-project: not inside a git repository — run it from the repo you want to onboard');
2402
+ tui.intro(`vitrinka setup-project — ${base}`);
2403
+ // ---- machine gate: offer install inline instead of a dead end (decision #5) ----
2404
+ const missing = [];
2405
+ if (tokenSource() === 'missing')
2406
+ missing.push('token');
2407
+ for (const rt of pluginRuntimes())
2408
+ if (rt.present && !rt.installed)
2409
+ missing.push(`plugin·${rt.name}`);
2410
+ if (claudeCliPresent() && !mcpRegistered())
2411
+ missing.push('mcp');
2412
+ if (missing.length) {
2413
+ tui.note(`missing: ${missing.join(', ')}`, 'machine not fully onboarded');
2414
+ if (await tui.confirm('Run the machine install now?', true, auto))
2415
+ await installCmd(args);
2416
+ else
2417
+ tui.step('skipped — run it later: vitrinka install');
2418
+ }
2419
+ // ---- project identity + registry (the name is asked only while THIS repo is
2420
+ // unregistered — an entry for the same name but another path is a basename
2421
+ // collision with a different checkout, so the prompt is the escape hatch,
2422
+ // never a silent overwrite of the other project's mapping) ----
2423
+ const defaultName = sanitizeSeg(strArg(args.project) || basename(top), 64);
2424
+ let project = defaultName;
2425
+ if (registeredPath(defaultName) !== top) {
2426
+ project = sanitizeSeg(await tui.text('Project name (how boards + indexes file this repo)', defaultName, auto), 64) || defaultName;
2427
+ }
2428
+ recordProject(project, top);
2429
+ tui.step(`registry ✓ ${project} → ${top}`);
2430
+ // ---- file/route index — verify=true so a wiped server row re-pushes even on
2431
+ // a local stamp hit (pushProjectIndex prints its own ↑ line on a real push) ----
2432
+ const sp = tui.spinner();
2433
+ sp.start('pushing the file/route index…');
2434
+ const idxOk = await pushProjectIndex(cwd, base, project, true);
2435
+ sp.stop(idxOk ? 'index ✓ (@ autocomplete + editor file map)' : 'index ✗ — server unreachable? retry: vitrinka index');
2436
+ // ---- component index (decision #3: auto-detect, offer, never mandatory) ----
2437
+ await setupComponentsStep(base, project, top, auto);
2438
+ // ---- desktop app ----
2439
+ const appPath = refreshDesktopAppFlag();
2440
+ if (appPath)
2441
+ tui.step(`app ✓ ${appPath}`);
2442
+ else if (process.platform === 'darwin') {
2443
+ tui.step('app ✗ Vitrinka.app not installed — editor links open in the browser (from the vitrinka repo: cd apps/desktop && make install)');
2444
+ }
2445
+ // ---- hello board (decision #3: a closing "want to try it?" — offered only
2446
+ // while the project has no board at all; default no, so --yes never spams) ----
2447
+ await setupBoardStep(base, project, args, auto);
2448
+ // The index is the core deliverable (editor file map + @ autocomplete) — a
2449
+ // failed push means the repo is NOT ready, and scripts must see a non-zero exit.
2450
+ if (!idxOk) {
2451
+ tui.outro('repo NOT ready — the index push failed (see above); retry once the server is reachable: vitrinka setup-project');
2452
+ process.exit(1);
2453
+ }
2454
+ tui.outro('repo ready — open the editor: vitrinka edit . dashboard: vitrinka status');
2455
+ }
2456
+ // setupComponentsStep — the component-index leg of setup-project. Skips loudly
2457
+ // but harmlessly whenever the server or the scan has nothing to offer.
2458
+ async function setupComponentsStep(base, project, top, auto) {
2459
+ try {
2460
+ // Unauthenticated compact read — already indexed means a ✓ row, no prompt.
2461
+ const res = await fetch(`${base}/api/v1/projects/${encodeURIComponent(project)}/components`, { signal: AbortSignal.timeout(15_000) });
2462
+ if (res.ok) {
2463
+ const d = await res.json();
2464
+ const comps = Array.isArray(d.components) ? d.components : [];
2465
+ if (comps.length) {
2466
+ const libs = [...new Set(comps.map((c) => c.library).filter(Boolean))];
2467
+ tui.step(`components ✓ ${comps.length} indexed (${libs.join(', ')})`);
2468
+ return;
2469
+ }
2470
+ }
2471
+ }
2472
+ catch (e) {
2473
+ tui.step(`components ? server unreachable (${e instanceof Error ? e.message : e}) — index later: vitrinka index-components`);
2474
+ return;
2475
+ }
2476
+ const cands = detectComponentDirs(top);
2477
+ if (!cands.length) {
2478
+ tui.step('components — no component dirs detected; index later: vitrinka index-components --library <kit> --src <dirs>');
2479
+ return;
2480
+ }
2481
+ const dir = await tui.select('Component library to index (powers @ component autocomplete on boards)', [
2482
+ ...cands.map((c) => ({ value: c.dir, label: c.dir, hint: `${c.count} exported components` })),
2483
+ { value: '', label: 'skip', hint: 'index later: vitrinka index-components' },
2484
+ ], auto);
2485
+ if (!dir)
2486
+ return;
2487
+ const library = sanitizeSeg(await tui.text('Library name (one per UI kit, e.g. "web" or "expo")', guessLibraryName(dir), auto), 32) || 'web';
2488
+ try {
2489
+ await pushComponents(base, project, library, scanComponents(top, [dir]));
2490
+ }
2491
+ catch (e) {
2492
+ tui.step(`components ✗ ${e.message}`);
2493
+ }
2494
+ }
2495
+ // helloBoardSlug — the hello board's slug, kept within the server's 64-char
2496
+ // boardSlugRe even for a maximal 64-char project name (57 + "-hello" = 63);
2497
+ // the truncation never leaves a dangling separator before the suffix.
2498
+ export function helloBoardSlug(project) {
2499
+ return `${project.slice(0, 57).replace(/[-.]+$/, '') || 'x'}-hello`;
2500
+ }
2501
+ // setupBoardStep — the closing hello-board offer. Every failure path is a quiet
2502
+ // row, never an exit: the board is a nicety on top of an already-onboarded repo.
2503
+ async function setupBoardStep(base, project, args, auto) {
2504
+ let boards;
2505
+ try {
2506
+ const res = await fetch(`${base}/api/v1/boards`, { signal: AbortSignal.timeout(15_000) });
2507
+ if (!res.ok)
2508
+ return; // older server — the offer is a nicety, not a step
2509
+ boards = (await res.json()).boards || [];
2510
+ }
2511
+ catch {
2512
+ return; // unreachable — the index step already surfaced that loudly
2513
+ }
2514
+ const mine = boards.filter((b) => b.project === project);
2515
+ if (mine.length) {
2516
+ tui.step(`boards ✓ ${mine.length} (latest: ${mine[0].url || mine[0].slug})`);
2517
+ return;
2518
+ }
2519
+ if (!await tui.confirm('Create a hello board to try the round-trip (annotate → work queue)?', false, auto))
2520
+ return;
2521
+ const slug = helloBoardSlug(project);
2522
+ const headers = { 'Content-Type': 'application/json' };
2523
+ const token = readToken();
2524
+ if (token)
2525
+ headers.Authorization = `Bearer ${token}`;
2526
+ const actor = actorFor(args);
2527
+ if (actor)
2528
+ headers['X-Board-Actor'] = actor;
2529
+ try {
2530
+ const res = await fetch(`${base}/api/v1/boards`, {
2531
+ method: 'POST', headers,
2532
+ body: JSON.stringify({ slug, title: `${project} — hello`, project, subgroup: 'testing' }),
2533
+ signal: AbortSignal.timeout(30_000),
2534
+ });
2535
+ if (res.ok) {
2536
+ const url = String((await res.json()).url || `${base}/boards/${slug}`);
2537
+ tui.step(`board ✓ ${url}`);
2538
+ if (process.platform === 'darwin' && tui.interactive())
2539
+ spawnSync('open', [url], { stdio: 'ignore' });
2540
+ }
2541
+ else if (res.status === 409) {
2542
+ // 409 = the slug exists. Re-list and reuse it when it's OURS (a race or a
2543
+ // half-finished earlier run); a different project's board is a genuine
2544
+ // collision — never silently adopt it.
2545
+ let ours;
2546
+ try {
2547
+ const list = await fetch(`${base}/api/v1/boards`, { signal: AbortSignal.timeout(15_000) });
2548
+ if (list.ok) {
2549
+ ours = ((await list.json()).boards || [])
2550
+ .find((b) => b.slug === slug && b.project === project);
2551
+ }
2552
+ }
2553
+ catch {
2554
+ // unreachable mid-step — fall through to the collision line
2555
+ }
2556
+ if (ours)
2557
+ tui.step(`board ✓ ${ours.url || slug} (reused)`);
2558
+ else
2559
+ tui.step(`board — slug ${slug} already exists (another project's?) — create one later: /vitrinka:board`);
2560
+ }
2561
+ else {
2562
+ tui.step(`board ✗ create failed (HTTP ${res.status}): ${(await res.text()).slice(0, 200)}`);
2563
+ }
2564
+ }
2565
+ catch (e) {
2566
+ tui.step(`board ✗ ${e.message}`);
2567
+ }
2568
+ }
2296
2569
  // ---------- status: the session dashboard ----------
2297
2570
  // statusCmd — one glance at this repo+branch's vitrinka world: the live board,
2298
2571
  // the open work queue (same scope derivation as `watch`), and who holds the
@@ -2300,8 +2573,13 @@ function openCmd(root, args) {
2300
2573
  async function statusCmd(root, args) {
2301
2574
  const base = resolveBaseUrl(strArg(args.base));
2302
2575
  const cwd = resolve('.');
2303
- const project = sanitizeSeg(strArg(args.project) || basename(mainWorktreeTop(cwd) || cwd), 64);
2576
+ const top = mainWorktreeTop(cwd);
2577
+ const project = sanitizeSeg(strArg(args.project) || basename(top || cwd), 64);
2304
2578
  const branch = sanitizeSeg(strArg(args.branch) || git(['branch', '--show-current'], cwd) || 'main', 100);
2579
+ if (top) {
2580
+ recordProject(project, top); // feed the desktop app's project registry
2581
+ setupNudge(cwd, project, top);
2582
+ }
2305
2583
  console.log(`vitrinka status — ${project}/${branch}`);
2306
2584
  const board = boardUrlOf(root);
2307
2585
  console.log(board ? ` board ${board}` : ' board none yet (publish one: vitrinka board-from-set)');
@@ -2583,17 +2861,27 @@ async function updateCmd(_args) {
2583
2861
  }
2584
2862
  }
2585
2863
  // Refresh installed surfaces from the NEW code — plugins where installed,
2586
- // Claude wiring only if the wrapper shows prior consent.
2864
+ // Claude wiring only where the footer badge shows prior consent.
2587
2865
  const rerun = (sub) => spawnSync(process.execPath, [CLI_PATH, ...sub], { stdio: 'inherit' }).status === 0;
2588
2866
  for (const rt of pluginRuntimes()) {
2589
2867
  if (rt.installed && !updatePluginIn(rt)) {
2590
2868
  console.error(`⚠ update: ${rt.name} plugin refresh failed — re-run its plugin update manually`);
2591
2869
  }
2592
2870
  }
2593
- const wrapper = join(process.env.HOME || '', '.claude', 'vitrinka-statusline.sh');
2594
- if (existsSync(wrapper)) {
2595
- if (!rerun(['install-claude']))
2596
- console.error('⚠ update: Claude UI refresh failed — re-run: vitrinka install-claude');
2871
+ // Remove the retired statusline extension from existing installs, then refresh
2872
+ // the footer badge where it was already wired.
2873
+ const home = process.env.HOME || '';
2874
+ cleanupLegacyStatusline(home);
2875
+ const settingsP = join(home, '.claude', 'settings.json');
2876
+ let consented = false;
2877
+ if (existsSync(settingsP)) {
2878
+ try {
2879
+ consented = hasBoardFooterLink(JSON.parse(readFileSync(settingsP, 'utf8')));
2880
+ }
2881
+ catch { /* malformed settings.json — treat as not-wired; install-claude reports it */ }
2882
+ }
2883
+ if (consented && !rerun(['install-claude'])) {
2884
+ console.error('⚠ update: Claude UI refresh failed — re-run: vitrinka install-claude');
2597
2885
  }
2598
2886
  console.log('update done — try: vitrinka --help');
2599
2887
  }
@@ -2754,8 +3042,8 @@ async function snapCmd(surface, args, rawArgv = []) {
2754
3042
  console.error(`snap: surface must be ios|android|macos|web, got ${JSON.stringify(surface)}`);
2755
3043
  process.exit(2);
2756
3044
  }
2757
- const root = resolve(strArg(args.root) || '.screenshots');
2758
- const projectDir = dirname(root);
3045
+ const root = strArg(args.root) ? resolve(strArg(args.root)) : homeRoot('screenshots');
3046
+ const projectDir = projectDirOf(root);
2759
3047
  const route = strArg(args.route);
2760
3048
  const note = strArg(args.note);
2761
3049
  if (!route) {
@@ -3073,6 +3361,7 @@ function scaffoldHead(title) {
3073
3361
  "react-dom/client": "/vendor/react-dom-client.mjs",
3074
3362
  "htm": "/vendor/htm.mjs",
3075
3363
  "kit-1": "/vendor/kit-1.mjs",
3364
+ "kit-2": "/vendor/kit-2.mjs",
3076
3365
  "zipstore-1": "/vendor/zipstore-1.mjs"
3077
3366
  } }
3078
3367
  </script>
@@ -3080,7 +3369,26 @@ function scaffoldHead(title) {
3080
3369
  "@xyflow/react": "/vendor/xyflow-react.mjs"
3081
3370
  and add to <head>:
3082
3371
  <link rel="stylesheet" href="/vendor/xyflow-react.css">
3372
+ shadcn ui components resolve via the map above: import { Button, Card, Tabs, … } from "kit-2"
3373
+ More built-ins (relative-import bundles — no map entry needed;
3374
+ GET /api/v1/runtime lists what the server has):
3375
+ recharts: import { LineChart, Line, XAxis, … } from "/vendor/recharts.mjs"
3376
+ tables: import { useReactTable, … } from "/vendor/tanstack-table.mjs"
3377
+ motion: import { motion, AnimatePresence } from "/vendor/motion.mjs"
3378
+ highlight: import hljs from "/vendor/hljs.mjs" + <link rel="stylesheet" href="/vendor/hljs.css">
3379
+ math: import katex from "/vendor/katex.mjs" + <link rel="stylesheet" href="/vendor/katex.css">
3380
+ api ref: import { ApiRefBody, parseSpec } from "/vendor/apiref-1.mjs"
3083
3381
  -->
3382
+ <link rel="modulepreload" href="/vendor/react.mjs">
3383
+ <link rel="modulepreload" href="/vendor/scheduler.mjs">
3384
+ <link rel="modulepreload" href="/vendor/react-dom.mjs">
3385
+ <link rel="modulepreload" href="/vendor/react-dom-client.mjs">
3386
+ <link rel="modulepreload" href="/vendor/htm.mjs">
3387
+ <link rel="modulepreload" href="/vendor/kit-1.mjs">
3388
+ <link rel="modulepreload" href="/vendor/zipstore-1.mjs">
3389
+ <!-- importing shelf libs (kit-2, recharts, …)? add a matching modulepreload —
3390
+ it flattens the import waterfall into one parallel fetch round. Only
3391
+ preload what the App actually imports; the scaffold body uses kit-1. -->
3084
3392
  <script src="/vendor/tailwind.js"></script>
3085
3393
  </head>
3086
3394
  <body class="bg-[#f6f2ee] text-[#101113] antialiased">
@@ -3162,7 +3470,7 @@ async function artifactInitCmd(args) {
3162
3470
  assertValidKey(slug, 'artifact-init'); // the slug becomes the set key — fail before any writes
3163
3471
  const kind = strArg(args.kind) || 'report';
3164
3472
  const title = strArg(args.title) || slug;
3165
- const dir = resolve('.artifacts', slug);
3473
+ const dir = join(homeRoot('artifacts'), slug);
3166
3474
  const indexPath = join(dir, 'index.html');
3167
3475
  if (existsSync(indexPath)) {
3168
3476
  console.error(`artifact-init: ${indexPath} already exists — edit it in place (or remove the dir to re-scaffold)`);
@@ -3170,7 +3478,7 @@ async function artifactInitCmd(args) {
3170
3478
  }
3171
3479
  mkdirSync(dir, { recursive: true });
3172
3480
  writeFileSync(indexPath, blankScaffold(title, existsSync(join(dir, 'data.json'))));
3173
- ensureGitIgnored(process.cwd(), '.artifacts/');
3481
+ ensureGitIgnored(process.cwd(), `${VITRINKA_HOME}/`);
3174
3482
  await remoteInitCmd(dir, { ...args, key: slug, kind });
3175
3483
  console.log(`scaffolded ${indexPath}`);
3176
3484
  console.log(`author the App, then: node ${CLI_PATH} push --root ${dir} --title "<Human title>"`);
@@ -3183,8 +3491,8 @@ async function artifactFromSetCmd(args) {
3183
3491
  }
3184
3492
  const slug = sanitizeSeg(slugRaw, 64);
3185
3493
  assertValidKey(slug, 'artifact-from-set'); // the slug becomes the set key — fail before any writes
3186
- const from = resolve(strArg(args.from) || '.screenshots');
3187
- const dir = resolve('.artifacts', slug);
3494
+ const from = strArg(args.from) ? resolve(strArg(args.from)) : homeRoot('screenshots');
3495
+ const dir = join(homeRoot('artifacts'), slug);
3188
3496
  const indexPath = join(dir, 'index.html');
3189
3497
  if (existsSync(indexPath)) {
3190
3498
  console.error(`artifact-from-set: ${indexPath} already exists — edit it in place (or remove the dir to re-scaffold)`);
@@ -3200,7 +3508,7 @@ async function artifactFromSetCmd(args) {
3200
3508
  const fromManifest = loadManifest(from);
3201
3509
  const title = strArg(args.title) || (fromManifest.journey && fromManifest.journey.title) || slug;
3202
3510
  writeFileSync(indexPath, composedScaffold(title, `${slug}.zip`));
3203
- ensureGitIgnored(process.cwd(), '.artifacts/');
3511
+ ensureGitIgnored(process.cwd(), `${VITRINKA_HOME}/`);
3204
3512
  await remoteInitCmd(dir, { ...args, key: slug, kind: strArg(args.kind) || 'report' });
3205
3513
  // Record the source screenshot set (project/branchSlug/key) so push sends ?source= and
3206
3514
  // vitrinka can cross-link set ↔ artifact both ways.
@@ -3222,7 +3530,7 @@ async function artifactFromSetCmd(args) {
3222
3530
  console.log(`scaffolded ${indexPath} (${res.count} shots embedded${source ? `, source ${source}` : ''})`);
3223
3531
  console.log(`write the narrative sections, then \`push\`: node ${CLI_PATH} push --root ${dir} --title "<Human title>"`);
3224
3532
  }
3225
- const ROOT_FLAG = { f: '--root', arg: '<dir>', d: 'capture root (default .screenshots)' };
3533
+ const ROOT_FLAG = { f: '--root', arg: '<dir>', d: 'capture root (default .vitrinka/screenshots)' };
3226
3534
  const BASE_FLAG = { f: '--base', arg: '<url>', d: 'vitrinka base URL (default $VITRINKA_URL or the mesh host)' };
3227
3535
  const CTX_FLAGS = [
3228
3536
  { f: '--label', arg: '<STAGE>', d: 'journey stage label' },
@@ -3242,17 +3550,17 @@ export const COMMANDS = [
3242
3550
  flags: [ROOT_FLAG, { f: '--file', arg: '<rel>', d: 'screenshot path relative to root (required)' },
3243
3551
  { f: '--surface', arg: '<ios|android|web|macos>', d: 'platform (default other)' },
3244
3552
  { f: '--route', arg: '<r>', d: 'app route' }, { f: '--note', arg: '<n>', d: 'caption' }, ...CTX_FLAGS],
3245
- examples: ['vitrinka add --root .screenshots --file 2026-07-06/01-web-home.png --surface web --route /home --note "landing"'],
3553
+ examples: ['vitrinka add --root .vitrinka/screenshots --file 2026-07-06/01-web-home.png --surface web --route /home --note "landing"'],
3246
3554
  },
3247
3555
  { name: 'build', summary: 'Rebuild index.html from the manifest', usage: '--root <dir>', flags: [ROOT_FLAG],
3248
- examples: ['vitrinka build --root .screenshots'] },
3556
+ examples: ['vitrinka build --root .vitrinka/screenshots'] },
3249
3557
  {
3250
3558
  name: 'meta', summary: 'Set the journey header (kicker/title/accent/intro/chips)',
3251
3559
  usage: '--root <dir> [--kicker <k>] [--title <t>] [--accent <substr>] [--intro <p>] [--chip "K=V" ...]',
3252
3560
  flags: [ROOT_FLAG, { f: '--kicker', arg: '<k>', d: 'eyebrow kicker' }, { f: '--title', arg: '<t>', d: 'display title' },
3253
3561
  { f: '--accent', arg: '<substr>', d: 'substring of the title to accent' }, { f: '--intro', arg: '<p>', d: 'intro paragraph' },
3254
3562
  { f: '--chip', arg: '"K=V"', d: 'meta chip (repeatable)' }],
3255
- examples: ['vitrinka meta --root .screenshots --kicker "FLOW" --title "Payout flow" --accent "Payout"'],
3563
+ examples: ['vitrinka meta --root .vitrinka/screenshots --kicker "FLOW" --title "Payout flow" --accent "Payout"'],
3256
3564
  },
3257
3565
  {
3258
3566
  name: 'remote-init', summary: 'Create a .vitrinka set config for publishing',
@@ -3261,7 +3569,7 @@ export const COMMANDS = [
3261
3569
  { f: '--branch', arg: '<b>', d: 'branch (default: current)' }, { f: '--kind', arg: '<k>', d: 'set kind (default screenshots)' },
3262
3570
  { f: '--key', arg: '<k>', d: 'set key (default: timestamped)' }, { f: '--issue', arg: '<ref>', d: 'linked issue ref' }],
3263
3571
  dynamic: 'projects',
3264
- examples: ['vitrinka remote-init --root .screenshots --project fixit --branch main'],
3572
+ examples: ['vitrinka remote-init --root .vitrinka/screenshots --project fixit --branch main'],
3265
3573
  },
3266
3574
  {
3267
3575
  name: 'push', summary: 'Publish the set to vitrinka (tar+PUT), refresh the index',
@@ -3269,7 +3577,7 @@ export const COMMANDS = [
3269
3577
  flags: [ROOT_FLAG, { f: '--title', arg: '<t>', d: 'human title (re-sent every push)' },
3270
3578
  { f: '--name', arg: '<slug>', d: 'name the set after publish (sticky)' },
3271
3579
  { f: '--source', arg: '<ref>', d: 'cross-link source set project/branchSlug/key' }],
3272
- examples: ['vitrinka push --root .screenshots --title "Payout flow"'],
3580
+ examples: ['vitrinka push --root .vitrinka/screenshots --title "Payout flow"'],
3273
3581
  },
3274
3582
  {
3275
3583
  name: 'name', summary: "Set or clear a set's custom URL name",
@@ -3294,6 +3602,13 @@ export const COMMANDS = [
3294
3602
  flags: [{ f: '--fix', d: 'repair everything repairable: refresh skills, rewrite the shim, re-register the MCP, refresh the Claude UI wiring' }, BASE_FLAG],
3295
3603
  examples: ['vitrinka doctor', 'vitrinka doctor --fix'],
3296
3604
  },
3605
+ {
3606
+ name: 'tidy', summary: 'Sweep legacy roots + shot-shaped litter into .vitrinka/ (report-first)',
3607
+ usage: '[--yes] [--all]',
3608
+ flags: [{ f: '--yes', d: 'apply the planned moves (default: report only)' },
3609
+ { f: '--all', d: 'also move loose root images and .aud_*/.audit_* text litter (default: report them)' }],
3610
+ examples: ['vitrinka tidy', 'vitrinka tidy --yes', 'vitrinka tidy --yes --all'],
3611
+ },
3297
3612
  {
3298
3613
  name: 'install', summary: 'Onboard this machine: status table, then skills + shim + completion + MCP + token + operator + Claude UI',
3299
3614
  usage: '[--local] [--operator <name>] [--mcp|--no-mcp] [--claude|--no-claude] [--no-completion]',
@@ -3303,9 +3618,19 @@ export const COMMANDS = [
3303
3618
  { f: '--no-mcp', d: 'skip the MCP registration' },
3304
3619
  { f: '--claude', d: 'wire the Claude Code UI without asking' },
3305
3620
  { f: '--no-claude', d: 'skip the Claude Code UI wiring' },
3306
- { f: '--no-completion', d: 'skip appending the shell-completion line' }],
3621
+ { f: '--no-completion', d: 'skip appending the shell-completion line' },
3622
+ { f: '--yes', d: 'non-interactive: skip every prompt (config-touchers print their manual command)' }],
3307
3623
  examples: ['vitrinka install', 'vitrinka install --local', 'vitrinka install --operator "Lukáš" --mcp --claude'],
3308
3624
  },
3625
+ {
3626
+ name: 'setup-project', summary: 'Onboard THIS repo: registry + file/route index + component index + editor check (interactive, idempotent)',
3627
+ usage: '[--yes] [--base <url>] [--project <p>]',
3628
+ flags: [BASE_FLAG,
3629
+ { f: '--project', arg: '<p>', d: 'project name (default: repo dir name)' },
3630
+ { f: '--yes', d: 'non-interactive: take every default, prompt for nothing' }],
3631
+ examples: ['vitrinka setup-project', 'vitrinka setup-project --yes'],
3632
+ write: true,
3633
+ },
3309
3634
  {
3310
3635
  name: 'uninstall', summary: 'Remove everything install put here (skills, shim, completion, Claude wiring, MCP); token/operator survive',
3311
3636
  usage: '[--purge] [--yes] [--local]',
@@ -3315,7 +3640,7 @@ export const COMMANDS = [
3315
3640
  examples: ['vitrinka uninstall', 'vitrinka uninstall --purge --yes'],
3316
3641
  },
3317
3642
  {
3318
- name: 'install-claude', summary: 'Wire the Claude Code UI: 🧷 board statusline segment + clickable footer badge',
3643
+ name: 'install-claude', summary: 'Wire the Claude Code UI: clickable 🧷 board footer badge',
3319
3644
  usage: '', flags: [],
3320
3645
  examples: ['vitrinka install-claude'],
3321
3646
  },
@@ -3338,6 +3663,14 @@ export const COMMANDS = [
3338
3663
  { f: '--browser', d: 'force the browser even when Vitrinka.app is installed' }],
3339
3664
  examples: ['vitrinka open', 'vitrinka open --qr'],
3340
3665
  },
3666
+ {
3667
+ name: 'edit', summary: 'Open a file or project in the resident Vitrinka.app editor (records the repo for the app\'s project list)',
3668
+ usage: '[<path>] [--print] [--project <p>]',
3669
+ positional: '[<path>]',
3670
+ flags: [{ f: '--print', d: 'only print the vitrinka://edit URL' },
3671
+ { f: '--project', arg: '<p>', d: 'override the recorded project name' }],
3672
+ examples: ['vitrinka edit', 'vitrinka edit src/cli.ts', 'vitrinka edit --print'],
3673
+ },
3341
3674
  {
3342
3675
  name: 'status', summary: 'Session dashboard: live board, open work queue, and listener lease for this repo+branch',
3343
3676
  usage: '[--root <dir>] [--project <p>] [--branch <b>] [--base <url>]',
@@ -3403,7 +3736,7 @@ export const COMMANDS = [
3403
3736
  flags: [ROOT_FLAG, { f: '--slug', arg: '<board>', d: 'board slug (default: set key)' },
3404
3737
  { f: '--btitle', arg: '<t>', d: 'board title' }, { f: '--actor', arg: '<name>', d: 'override the operator attribution' }],
3405
3738
  dynamic: 'boards',
3406
- examples: ['vitrinka board-from-set --root .screenshots'],
3739
+ examples: ['vitrinka board-from-set --root .vitrinka/screenshots'],
3407
3740
  },
3408
3741
  {
3409
3742
  name: 'journey-from-set', summary: 'Turn the set into a journey TREE board — signpost screens fan their branches, wires leave from the clicked element (snap --next/--target)',
@@ -3411,7 +3744,7 @@ export const COMMANDS = [
3411
3744
  flags: [ROOT_FLAG, { f: '--slug', arg: '<board>', d: 'board slug (default: set key)' },
3412
3745
  { f: '--btitle', arg: '<t>', d: 'board title' }, { f: '--actor', arg: '<name>', d: 'override the operator attribution' }],
3413
3746
  dynamic: 'boards',
3414
- examples: ['vitrinka journey-from-set --root .screenshots'],
3747
+ examples: ['vitrinka journey-from-set --root .vitrinka/screenshots'],
3415
3748
  },
3416
3749
  {
3417
3750
  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',
@@ -3432,7 +3765,7 @@ export const COMMANDS = [
3432
3765
  usage: '[--root <dir>] [--base <url>] [--project <p>]',
3433
3766
  flags: [ROOT_FLAG, BASE_FLAG, { f: '--project', arg: '<p>', d: 'project slug (default: worktree name)' }],
3434
3767
  dynamic: 'projects',
3435
- examples: ['vitrinka index --root .screenshots'],
3768
+ examples: ['vitrinka index --root .vitrinka/screenshots'],
3436
3769
  },
3437
3770
  {
3438
3771
  name: 'index-components', summary: 'Scan a UI library for exported components + push the component index',
@@ -3453,7 +3786,7 @@ export const COMMANDS = [
3453
3786
  usage: '[--root <dir>] [--select 1,3-5] [--size 720] [--quality 85] --out <dir>/data.json',
3454
3787
  flags: [ROOT_FLAG, { f: '--select', arg: '1,3-5', d: '1-based shot selection' }, { f: '--size', arg: '<px>', d: 'longest edge (default 720)' },
3455
3788
  { f: '--quality', arg: '<q>', d: 'JPEG quality (default 85)' }, { f: '--out', arg: '<file>', d: 'output data.json (required)' }],
3456
- examples: ['vitrinka embed-shots --root .screenshots --out .artifacts/x/data.json'],
3789
+ examples: ['vitrinka embed-shots --root .vitrinka/screenshots --out .vitrinka/artifacts/x/data.json'],
3457
3790
  },
3458
3791
  {
3459
3792
  name: 'artifact-init', summary: 'Scaffold a blank artifact report + set config',
@@ -3464,11 +3797,11 @@ export const COMMANDS = [
3464
3797
  },
3465
3798
  {
3466
3799
  name: 'artifact-from-set', summary: 'Scaffold an artifact report from a screenshot set',
3467
- usage: '--slug <s> [--from .screenshots] [--select 1,3-5] [--title <t>] [--kind report] [--source <ref>]',
3468
- flags: [{ f: '--slug', arg: '<s>', d: 'artifact slug (= set key, required)' }, { f: '--from', arg: '<dir>', d: 'source set root (default .screenshots)' },
3800
+ usage: '--slug <s> [--from .vitrinka/screenshots] [--select 1,3-5] [--title <t>] [--kind report] [--source <ref>]',
3801
+ flags: [{ f: '--slug', arg: '<s>', d: 'artifact slug (= set key, required)' }, { f: '--from', arg: '<dir>', d: 'source set root (default .vitrinka/screenshots)' },
3469
3802
  { f: '--select', arg: '1,3-5', d: '1-based shot selection' }, { f: '--title', arg: '<t>', d: 'artifact title' },
3470
3803
  { f: '--kind', arg: '<k>', d: 'set kind (default report)' }, { f: '--source', arg: '<ref>', d: 'source set ref for cross-linking' }, BASE_FLAG],
3471
- examples: ['vitrinka artifact-from-set --slug payout-report --from .screenshots'],
3804
+ examples: ['vitrinka artifact-from-set --slug payout-report --from .vitrinka/screenshots'],
3472
3805
  },
3473
3806
  // Meta commands — documented + completable, kept out of the compact usage grid.
3474
3807
  { name: 'help', summary: 'Show help (all commands, or one command in detail)', usage: '[<command>]', positional: '[<command>]', hidden: true,
@@ -3947,6 +4280,11 @@ async function boardFromSetCmd(root, args, journey = false) {
3947
4280
  process.exit(2);
3948
4281
  }
3949
4282
  const cfg = loadVitrinkaCfg(root);
4283
+ // The .vitrinka descriptor names the project — record its main worktree top so
4284
+ // the desktop app can discover this checkout (instant-editor decision #6).
4285
+ const projTop = mainWorktreeTop(projectDirOf(root));
4286
+ if (cfg.project && projTop)
4287
+ recordProject(sanitizeSeg(cfg.project, 64), projTop);
3950
4288
  const slug = (strArg(args.slug) || cfg.key).toLowerCase();
3951
4289
  const title = strArg(args.btitle) || strArg(args.title) || '';
3952
4290
  const headers = { 'Content-Type': 'application/json' };
@@ -3991,7 +4329,7 @@ async function boardFromSetCmd(root, args, journey = false) {
3991
4329
  const boardUrl = out.board?.url || `${cfg.base}${ws}/boards/${slug}`;
3992
4330
  console.log(`flow board: ${boardUrl} (${out.cards.length} cards, ${out.edges.length} edges)`);
3993
4331
  // The board is about to be annotated — make sure its autocomplete index is fresh.
3994
- await pushProjectIndex(dirname(root), cfg.base, cfg.project);
4332
+ await pushProjectIndex(projectDirOf(root), cfg.base, cfg.project);
3995
4333
  }
3996
4334
  // ---------- upload: files → board cards (mcp-file-upload 2026-07-15) ----------
3997
4335
  //
@@ -4096,7 +4434,9 @@ export function detectImportKind(explicit, file, content) {
4096
4434
  return 'openapi';
4097
4435
  return '';
4098
4436
  }
4099
- // postImport sends a raw source to the board import endpoint and prints the card.
4437
+ // postImport sends a raw source to the board import endpoint and prints the
4438
+ // server's summary ({id, elemNo, title, counts…}) — or the full card JSON with
4439
+ // --verbose (mcp-token-economy #3: the geo boxes are noise by default).
4100
4440
  async function postImport(board, body, args) {
4101
4441
  const base = resolveBaseUrl(strArg(args.base));
4102
4442
  const headers = { 'Content-Type': 'application/json' };
@@ -4106,7 +4446,8 @@ async function postImport(board, body, args) {
4106
4446
  const actor = actorFor(args);
4107
4447
  if (actor)
4108
4448
  headers['X-Board-Actor'] = actor;
4109
- const res = await fetch(`${base}/api/v1/boards/${encodeURIComponent(board)}/import`, {
4449
+ const verbose = args.verbose === true ? '?verbose=1' : '';
4450
+ const res = await fetch(`${base}/api/v1/boards/${encodeURIComponent(board)}/import${verbose}`, {
4110
4451
  method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(120_000),
4111
4452
  });
4112
4453
  const text = await res.text();
@@ -4120,7 +4461,7 @@ async function importCmd(rest, args) {
4120
4461
  const file = rest.find((a) => !a.startsWith('--'));
4121
4462
  const board = strArg(args.board);
4122
4463
  if (!file || !board) {
4123
- console.error('usage: vitrinka import <file> --board <slug> [--kind auto|openapi|sqlddl|compose] [--title "…"]');
4464
+ console.error('usage: vitrinka import <file> --board <slug> [--kind auto|openapi|sqlddl|compose] [--title "…"] [--verbose]');
4124
4465
  process.exit(2);
4125
4466
  }
4126
4467
  if (!existsSync(file)) {
@@ -4385,8 +4726,11 @@ async function watchCmd(args) {
4385
4726
  // Repo context for scope inference (main worktree basename = project; current
4386
4727
  // branch). '.' cwd is where the session runs (the app repo).
4387
4728
  const cwd = resolve('.');
4388
- const repoProject = basename(mainWorktreeTop(cwd) || cwd);
4729
+ const repoTop = mainWorktreeTop(cwd);
4730
+ const repoProject = basename(repoTop || cwd);
4389
4731
  const repoBranch = git(['branch', '--show-current'], cwd);
4732
+ if (repoTop)
4733
+ recordProject(sanitizeSeg(repoProject, 64), repoTop); // register the repo for the desktop app
4390
4734
  const scope = deriveWatchScope(args, { repoProject, repoBranch });
4391
4735
  const sq = scopeQuery(scope);
4392
4736
  const listUrl = () => {
@@ -4588,7 +4932,12 @@ async function watchCmd(args) {
4588
4932
  async function runSub(argv) {
4589
4933
  const [cmd, ...rest] = argv;
4590
4934
  const args = parseArgs(rest);
4591
- const root = resolve(strArg(args.root) || '.screenshots');
4935
+ // The default root may auto-migrate a legacy .screenshots/ — compute it ONLY
4936
+ // for commands that actually take --root. Anything else (tidy, which owns
4937
+ // migration report-first; artifact-from-set, whose --from must keep seeing
4938
+ // the legacy path) must not move dirs as a side effect of dispatch.
4939
+ const takesRoot = COMMAND_MAP[cmd]?.flags?.some((f) => f.f === '--root') ?? false;
4940
+ const root = strArg(args.root) ? resolve(strArg(args.root)) : takesRoot ? homeRoot('screenshots') : '';
4592
4941
  if (cmd === 'add')
4593
4942
  addCmd(root, args, rest);
4594
4943
  else if (cmd === 'build') {
@@ -4609,8 +4958,12 @@ async function runSub(argv) {
4609
4958
  await loginCmd(args);
4610
4959
  else if (cmd === 'doctor')
4611
4960
  await doctorCmd(args);
4961
+ else if (cmd === 'tidy')
4962
+ tidyCmd(args);
4612
4963
  else if (cmd === 'install')
4613
4964
  await installCmd(args);
4965
+ else if (cmd === 'setup-project')
4966
+ await setupProjectCmd(args);
4614
4967
  else if (cmd === 'install-claude')
4615
4968
  installClaudeCmd(args);
4616
4969
  else if (cmd === 'uninstall')
@@ -4621,6 +4974,8 @@ async function runSub(argv) {
4621
4974
  versionCmd(false);
4622
4975
  else if (cmd === 'open')
4623
4976
  openCmd(root, args);
4977
+ else if (cmd === 'edit')
4978
+ editCmd(rest, args);
4624
4979
  else if (cmd === 'status')
4625
4980
  await statusCmd(root, args);
4626
4981
  else if (cmd === 'snap')