@lovinka/vitrinka 1.18.0 → 1.21.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,7 +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 } from "./lib/registry.js";
23
+ import { recordProject, registeredPath } from "./lib/registry.js";
24
+ import * as tui from "./lib/tui.js";
24
25
  const CLI_PATH = fileURLToPath(import.meta.url);
25
26
  // Cap on the gzipped tarball push streams to the server. spawnSync kills tar (SIGTERM,
26
27
  // empty stderr) if its stdout exceeds this, so pushCmd surfaces the size in its error.
@@ -162,7 +163,7 @@ function saveManifest(root, m) {
162
163
  }
163
164
  function buildIndex(root) {
164
165
  const m = loadManifest(root);
165
- const project = basename(dirname(root)) || 'project';
166
+ const project = basename(projectDirOf(root)) || 'project';
166
167
  const htmlPath = htmlPathOf(root);
167
168
  // A fresh/nonexistent root builds an empty gallery — mirror saveManifest's
168
169
  // dir creation instead of crashing with a raw ENOENT.
@@ -229,7 +230,7 @@ function appendShot(root, shot) {
229
230
  // v2: stamp the session's code identity (last append wins — the freshest
230
231
  // commit is the one the newest pixels came from).
231
232
  m.version = Math.max(m.version || 1, 2);
232
- const projectDir = dirname(root);
233
+ const projectDir = projectDirOf(root);
233
234
  const commit = git(['rev-parse', '--short', 'HEAD'], projectDir);
234
235
  const branch = git(['branch', '--show-current'], projectDir);
235
236
  if (commit)
@@ -237,7 +238,7 @@ function appendShot(root, shot) {
237
238
  if (branch)
238
239
  m.branch = branch;
239
240
  saveManifest(root, m);
240
- const project = basename(dirname(root)) || 'project';
241
+ const project = basename(projectDirOf(root)) || 'project';
241
242
  const htmlPath = htmlPathOf(root);
242
243
  writeFileSync(htmlPath, renderHtml(m.shots, project));
243
244
  return { count: m.shots.length, htmlPath };
@@ -513,6 +514,41 @@ export function mainWorktreeTop(cwd) {
513
514
  return dirname(common);
514
515
  return git(['rev-parse', '--show-toplevel'], cwd);
515
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
+ }
516
552
  // originRepo normalizes the origin remote to a schemeless slug, e.g.
517
553
  // "github.com/LEFTEQ/fixit". "" outside a repo / without a remote.
518
554
  function originRepo(dir) {
@@ -680,6 +716,21 @@ export function buildProjectIndex(projectDir) {
680
716
  function indexCachePath(projectDir) {
681
717
  return join(git(['rev-parse', '--path-format=absolute', '--git-common-dir'], projectDir), 'vitrinka-index.cache');
682
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
+ }
683
734
  /** pushProjectIndex builds + pushes the index unless nothing changed since
684
735
  * the last successful push. Never throws and never exits — every caller is
685
736
  * a touchpoint where index freshness must not fail the primary job.
@@ -749,7 +800,7 @@ export async function pushProjectIndex(projectDir, base, project, verify = false
749
800
  // failure (unlike the piggybacked pushes) so scripts can rely on it; verifies
750
801
  // the server actually holds the index rather than trusting the local stamp.
751
802
  async function indexCmd(root, args) {
752
- const projectDir = dirname(root);
803
+ const projectDir = projectDirOf(root);
753
804
  const cfg = existsSync(vitrinkaCfgPath(root)) ? loadVitrinkaCfg(root) : null;
754
805
  const base = (strArg(args.base) || cfg?.base || process.env.VITRINKA_URL || 'https://vitrinka.in').replace(/\/+$/, '');
755
806
  const project = sanitizeSeg(strArg(args.project) || cfg?.project || basename(mainWorktreeTop(projectDir) || projectDir), 64);
@@ -824,7 +875,7 @@ function scanComponents(projectDir, srcDirs) {
824
875
  return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
825
876
  }
826
877
  async function indexComponentsCmd(root, args) {
827
- const projectDir = dirname(root);
878
+ const projectDir = projectDirOf(root);
828
879
  const library = strArg(args.library);
829
880
  if (!library)
830
881
  throw new Error('index-components: --library <kit> is required (one per UI library, e.g. "web" or "expo")');
@@ -848,6 +899,10 @@ async function indexComponentsCmd(root, args) {
848
899
  console.log(JSON.stringify({ project, library, components }, null, 2));
849
900
  return;
850
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) {
851
906
  const token = readToken();
852
907
  const headers = { 'Content-Type': 'application/json' };
853
908
  if (token)
@@ -861,6 +916,42 @@ async function indexComponentsCmd(root, args) {
861
916
  throw new Error(`index-components: push rejected (HTTP ${res.status}): ${(await res.text()).slice(0, 200)}`);
862
917
  console.log(`components ↑ ${project}/${library}: ${components.length} indexed`);
863
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
+ }
864
955
  function loadVitrinkaCfg(root) {
865
956
  const raw = readFileSync(vitrinkaCfgPath(root), 'utf8'); // caller checks existsSync
866
957
  try {
@@ -913,16 +1004,15 @@ async function remoteInitCmd(root, args) {
913
1004
  console.log(`vitrinka set (existing) → ${shareUrl(cfg)}`);
914
1005
  return cfg;
915
1006
  }
916
- const projectDir = dirname(root);
1007
+ const projectDir = projectDirOf(root);
917
1008
  const base = (args.base && args.base !== true ? String(args.base) : process.env.VITRINKA_URL || 'https://vitrinka.in').replace(/\/+$/, '');
918
1009
  // Project identity comes from the MAIN worktree (a linked worktree's dir is
919
1010
  // named after its branch — using it would fork each branch into its own
920
1011
  // project; the branch already lives in cfg.branch).
921
1012
  const gitTop = mainWorktreeTop(projectDir);
922
- // Artifact roots live at <project>/.artifacts/<slug> outside a git repo the project
923
- // fallback must be the real project dir, not the '.artifacts' container itself.
924
- const fallbackDir = basename(projectDir) === '.artifacts' ? dirname(projectDir) : projectDir;
925
- 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);
926
1016
  const branch = (args.branch && args.branch !== true ? String(args.branch) : git(['branch', '--show-current'], projectDir)) || 'main';
927
1017
  const d = new Date();
928
1018
  const p2 = (n) => String(n).padStart(2, '0');
@@ -1017,7 +1107,7 @@ async function pushCmd(root, args) {
1017
1107
  process.exit(2);
1018
1108
  }
1019
1109
  const cfg = loadVitrinkaCfg(root);
1020
- const projectDir = dirname(root);
1110
+ const projectDir = projectDirOf(root);
1021
1111
  // Control files are excluded here AND skipped server-side (belt and suspenders).
1022
1112
  // COPYFILE_DISABLE stops macOS bsdtar from emitting AppleDouble (._*) junk entries.
1023
1113
  const tar = spawnSync('tar', [
@@ -1324,6 +1414,85 @@ async function operatorCmd(rest, args) {
1324
1414
  }
1325
1415
  }
1326
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
+ }
1327
1496
  // doctorCmd checks this machine's vitrinka setup: server reachable, write
1328
1497
  // token present AND accepted by the server, operator persona set. The token
1329
1498
  // probe is `PUT /api/v1/operator` with an empty JSON body — auth runs before
@@ -1626,6 +1795,9 @@ async function installCmd(args) {
1626
1795
  if (!home)
1627
1796
  fail('install: $HOME is not set');
1628
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;
1629
1801
  const base = resolveBaseUrl(strArg(args.base));
1630
1802
  const mcpScope = local ? 'project' : 'user';
1631
1803
  // ---- probe (before touching anything) ----
@@ -1701,7 +1873,7 @@ async function installCmd(args) {
1701
1873
  else if (!mcpOk) {
1702
1874
  if (args['no-mcp'] === true)
1703
1875
  console.log('mcp: skipped (--no-mcp)');
1704
- 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] `))) {
1705
1877
  if (registerMcp(mcpScope))
1706
1878
  console.log(`✓ mcp registered (--scope ${mcpScope})`);
1707
1879
  else
@@ -1713,7 +1885,7 @@ async function installCmd(args) {
1713
1885
  }
1714
1886
  // 2. Write token (reads work without one; pushes/board writes 401).
1715
1887
  if (tokenSrc === 'missing') {
1716
- if (process.stdin.isTTY) {
1888
+ if (!noPrompt) {
1717
1889
  console.log('No write token yet — reads work without one, writes 401. It is the');
1718
1890
  console.log('server\'s VITRINKA_TOKEN (one shared secret — copy it from a configured');
1719
1891
  console.log('machine or the server admin; details: vitrinka doctor).');
@@ -1739,7 +1911,7 @@ async function installCmd(args) {
1739
1911
  await operatorCmd(['operator', opArg], args);
1740
1912
  }
1741
1913
  else if (!op) {
1742
- 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): ');
1743
1915
  if (name)
1744
1916
  await operatorCmd(['operator', name], args);
1745
1917
  else
@@ -1754,10 +1926,10 @@ async function installCmd(args) {
1754
1926
  console.log('claude ui: skipped (--no-claude)');
1755
1927
  }
1756
1928
  else if (uiOk || args.claude === true
1757
- || promptYesNo('Wire Claude Code UI (clickable 🧷 board footer badge)? [Y/n] ')) {
1929
+ || (!noPrompt && promptYesNo('Wire Claude Code UI (clickable 🧷 board footer badge)? [Y/n] '))) {
1758
1930
  installClaudeCmd(args);
1759
1931
  }
1760
- else if (!process.stdin.isTTY) {
1932
+ else if (noPrompt) {
1761
1933
  console.log('claude ui: skipped (non-interactive — run `vitrinka install-claude`, or pass --claude)');
1762
1934
  }
1763
1935
  else {
@@ -2190,8 +2362,11 @@ function editCmd(rest, args) {
2190
2362
  // discovers this checkout. Same project derivation as statusCmd; the stored
2191
2363
  // path is the MAIN worktree top (the app enumerates worktrees itself).
2192
2364
  const top = mainWorktreeTop(absPath);
2193
- if (top)
2194
- recordProject(sanitizeSeg(strArg(args.project) || basename(top), 64), top);
2365
+ if (top) {
2366
+ const project = sanitizeSeg(strArg(args.project) || basename(top), 64);
2367
+ recordProject(project, top);
2368
+ setupNudge(absPath, project, top);
2369
+ }
2195
2370
  const url = buildEditUrl(absPath);
2196
2371
  if (args.print === true) {
2197
2372
  console.log(url);
@@ -2208,6 +2383,189 @@ function editCmd(rest, args) {
2208
2383
  if (r.error || r.status !== 0)
2209
2384
  console.error(`edit: could not launch open — URL: ${url}`);
2210
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
+ }
2211
2569
  // ---------- status: the session dashboard ----------
2212
2570
  // statusCmd — one glance at this repo+branch's vitrinka world: the live board,
2213
2571
  // the open work queue (same scope derivation as `watch`), and who holds the
@@ -2218,8 +2576,10 @@ async function statusCmd(root, args) {
2218
2576
  const top = mainWorktreeTop(cwd);
2219
2577
  const project = sanitizeSeg(strArg(args.project) || basename(top || cwd), 64);
2220
2578
  const branch = sanitizeSeg(strArg(args.branch) || git(['branch', '--show-current'], cwd) || 'main', 100);
2221
- if (top)
2579
+ if (top) {
2222
2580
  recordProject(project, top); // feed the desktop app's project registry
2581
+ setupNudge(cwd, project, top);
2582
+ }
2223
2583
  console.log(`vitrinka status — ${project}/${branch}`);
2224
2584
  const board = boardUrlOf(root);
2225
2585
  console.log(board ? ` board ${board}` : ' board none yet (publish one: vitrinka board-from-set)');
@@ -2682,8 +3042,8 @@ async function snapCmd(surface, args, rawArgv = []) {
2682
3042
  console.error(`snap: surface must be ios|android|macos|web, got ${JSON.stringify(surface)}`);
2683
3043
  process.exit(2);
2684
3044
  }
2685
- const root = resolve(strArg(args.root) || '.screenshots');
2686
- const projectDir = dirname(root);
3045
+ const root = strArg(args.root) ? resolve(strArg(args.root)) : homeRoot('screenshots');
3046
+ const projectDir = projectDirOf(root);
2687
3047
  const route = strArg(args.route);
2688
3048
  const note = strArg(args.note);
2689
3049
  if (!route) {
@@ -3001,6 +3361,7 @@ function scaffoldHead(title) {
3001
3361
  "react-dom/client": "/vendor/react-dom-client.mjs",
3002
3362
  "htm": "/vendor/htm.mjs",
3003
3363
  "kit-1": "/vendor/kit-1.mjs",
3364
+ "kit-2": "/vendor/kit-2.mjs",
3004
3365
  "zipstore-1": "/vendor/zipstore-1.mjs"
3005
3366
  } }
3006
3367
  </script>
@@ -3008,7 +3369,26 @@ function scaffoldHead(title) {
3008
3369
  "@xyflow/react": "/vendor/xyflow-react.mjs"
3009
3370
  and add to <head>:
3010
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"
3011
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. -->
3012
3392
  <script src="/vendor/tailwind.js"></script>
3013
3393
  </head>
3014
3394
  <body class="bg-[#f6f2ee] text-[#101113] antialiased">
@@ -3090,7 +3470,7 @@ async function artifactInitCmd(args) {
3090
3470
  assertValidKey(slug, 'artifact-init'); // the slug becomes the set key — fail before any writes
3091
3471
  const kind = strArg(args.kind) || 'report';
3092
3472
  const title = strArg(args.title) || slug;
3093
- const dir = resolve('.artifacts', slug);
3473
+ const dir = join(homeRoot('artifacts'), slug);
3094
3474
  const indexPath = join(dir, 'index.html');
3095
3475
  if (existsSync(indexPath)) {
3096
3476
  console.error(`artifact-init: ${indexPath} already exists — edit it in place (or remove the dir to re-scaffold)`);
@@ -3098,7 +3478,7 @@ async function artifactInitCmd(args) {
3098
3478
  }
3099
3479
  mkdirSync(dir, { recursive: true });
3100
3480
  writeFileSync(indexPath, blankScaffold(title, existsSync(join(dir, 'data.json'))));
3101
- ensureGitIgnored(process.cwd(), '.artifacts/');
3481
+ ensureGitIgnored(process.cwd(), `${VITRINKA_HOME}/`);
3102
3482
  await remoteInitCmd(dir, { ...args, key: slug, kind });
3103
3483
  console.log(`scaffolded ${indexPath}`);
3104
3484
  console.log(`author the App, then: node ${CLI_PATH} push --root ${dir} --title "<Human title>"`);
@@ -3111,8 +3491,8 @@ async function artifactFromSetCmd(args) {
3111
3491
  }
3112
3492
  const slug = sanitizeSeg(slugRaw, 64);
3113
3493
  assertValidKey(slug, 'artifact-from-set'); // the slug becomes the set key — fail before any writes
3114
- const from = resolve(strArg(args.from) || '.screenshots');
3115
- const dir = resolve('.artifacts', slug);
3494
+ const from = strArg(args.from) ? resolve(strArg(args.from)) : homeRoot('screenshots');
3495
+ const dir = join(homeRoot('artifacts'), slug);
3116
3496
  const indexPath = join(dir, 'index.html');
3117
3497
  if (existsSync(indexPath)) {
3118
3498
  console.error(`artifact-from-set: ${indexPath} already exists — edit it in place (or remove the dir to re-scaffold)`);
@@ -3128,7 +3508,7 @@ async function artifactFromSetCmd(args) {
3128
3508
  const fromManifest = loadManifest(from);
3129
3509
  const title = strArg(args.title) || (fromManifest.journey && fromManifest.journey.title) || slug;
3130
3510
  writeFileSync(indexPath, composedScaffold(title, `${slug}.zip`));
3131
- ensureGitIgnored(process.cwd(), '.artifacts/');
3511
+ ensureGitIgnored(process.cwd(), `${VITRINKA_HOME}/`);
3132
3512
  await remoteInitCmd(dir, { ...args, key: slug, kind: strArg(args.kind) || 'report' });
3133
3513
  // Record the source screenshot set (project/branchSlug/key) so push sends ?source= and
3134
3514
  // vitrinka can cross-link set ↔ artifact both ways.
@@ -3150,7 +3530,7 @@ async function artifactFromSetCmd(args) {
3150
3530
  console.log(`scaffolded ${indexPath} (${res.count} shots embedded${source ? `, source ${source}` : ''})`);
3151
3531
  console.log(`write the narrative sections, then \`push\`: node ${CLI_PATH} push --root ${dir} --title "<Human title>"`);
3152
3532
  }
3153
- 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)' };
3154
3534
  const BASE_FLAG = { f: '--base', arg: '<url>', d: 'vitrinka base URL (default $VITRINKA_URL or the mesh host)' };
3155
3535
  const CTX_FLAGS = [
3156
3536
  { f: '--label', arg: '<STAGE>', d: 'journey stage label' },
@@ -3170,17 +3550,17 @@ export const COMMANDS = [
3170
3550
  flags: [ROOT_FLAG, { f: '--file', arg: '<rel>', d: 'screenshot path relative to root (required)' },
3171
3551
  { f: '--surface', arg: '<ios|android|web|macos>', d: 'platform (default other)' },
3172
3552
  { f: '--route', arg: '<r>', d: 'app route' }, { f: '--note', arg: '<n>', d: 'caption' }, ...CTX_FLAGS],
3173
- 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"'],
3174
3554
  },
3175
3555
  { name: 'build', summary: 'Rebuild index.html from the manifest', usage: '--root <dir>', flags: [ROOT_FLAG],
3176
- examples: ['vitrinka build --root .screenshots'] },
3556
+ examples: ['vitrinka build --root .vitrinka/screenshots'] },
3177
3557
  {
3178
3558
  name: 'meta', summary: 'Set the journey header (kicker/title/accent/intro/chips)',
3179
3559
  usage: '--root <dir> [--kicker <k>] [--title <t>] [--accent <substr>] [--intro <p>] [--chip "K=V" ...]',
3180
3560
  flags: [ROOT_FLAG, { f: '--kicker', arg: '<k>', d: 'eyebrow kicker' }, { f: '--title', arg: '<t>', d: 'display title' },
3181
3561
  { f: '--accent', arg: '<substr>', d: 'substring of the title to accent' }, { f: '--intro', arg: '<p>', d: 'intro paragraph' },
3182
3562
  { f: '--chip', arg: '"K=V"', d: 'meta chip (repeatable)' }],
3183
- 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"'],
3184
3564
  },
3185
3565
  {
3186
3566
  name: 'remote-init', summary: 'Create a .vitrinka set config for publishing',
@@ -3189,7 +3569,7 @@ export const COMMANDS = [
3189
3569
  { f: '--branch', arg: '<b>', d: 'branch (default: current)' }, { f: '--kind', arg: '<k>', d: 'set kind (default screenshots)' },
3190
3570
  { f: '--key', arg: '<k>', d: 'set key (default: timestamped)' }, { f: '--issue', arg: '<ref>', d: 'linked issue ref' }],
3191
3571
  dynamic: 'projects',
3192
- examples: ['vitrinka remote-init --root .screenshots --project fixit --branch main'],
3572
+ examples: ['vitrinka remote-init --root .vitrinka/screenshots --project fixit --branch main'],
3193
3573
  },
3194
3574
  {
3195
3575
  name: 'push', summary: 'Publish the set to vitrinka (tar+PUT), refresh the index',
@@ -3197,7 +3577,7 @@ export const COMMANDS = [
3197
3577
  flags: [ROOT_FLAG, { f: '--title', arg: '<t>', d: 'human title (re-sent every push)' },
3198
3578
  { f: '--name', arg: '<slug>', d: 'name the set after publish (sticky)' },
3199
3579
  { f: '--source', arg: '<ref>', d: 'cross-link source set project/branchSlug/key' }],
3200
- examples: ['vitrinka push --root .screenshots --title "Payout flow"'],
3580
+ examples: ['vitrinka push --root .vitrinka/screenshots --title "Payout flow"'],
3201
3581
  },
3202
3582
  {
3203
3583
  name: 'name', summary: "Set or clear a set's custom URL name",
@@ -3222,6 +3602,13 @@ export const COMMANDS = [
3222
3602
  flags: [{ f: '--fix', d: 'repair everything repairable: refresh skills, rewrite the shim, re-register the MCP, refresh the Claude UI wiring' }, BASE_FLAG],
3223
3603
  examples: ['vitrinka doctor', 'vitrinka doctor --fix'],
3224
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
+ },
3225
3612
  {
3226
3613
  name: 'install', summary: 'Onboard this machine: status table, then skills + shim + completion + MCP + token + operator + Claude UI',
3227
3614
  usage: '[--local] [--operator <name>] [--mcp|--no-mcp] [--claude|--no-claude] [--no-completion]',
@@ -3231,9 +3618,19 @@ export const COMMANDS = [
3231
3618
  { f: '--no-mcp', d: 'skip the MCP registration' },
3232
3619
  { f: '--claude', d: 'wire the Claude Code UI without asking' },
3233
3620
  { f: '--no-claude', d: 'skip the Claude Code UI wiring' },
3234
- { 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)' }],
3235
3623
  examples: ['vitrinka install', 'vitrinka install --local', 'vitrinka install --operator "Lukáš" --mcp --claude'],
3236
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
+ },
3237
3634
  {
3238
3635
  name: 'uninstall', summary: 'Remove everything install put here (skills, shim, completion, Claude wiring, MCP); token/operator survive',
3239
3636
  usage: '[--purge] [--yes] [--local]',
@@ -3339,7 +3736,7 @@ export const COMMANDS = [
3339
3736
  flags: [ROOT_FLAG, { f: '--slug', arg: '<board>', d: 'board slug (default: set key)' },
3340
3737
  { f: '--btitle', arg: '<t>', d: 'board title' }, { f: '--actor', arg: '<name>', d: 'override the operator attribution' }],
3341
3738
  dynamic: 'boards',
3342
- examples: ['vitrinka board-from-set --root .screenshots'],
3739
+ examples: ['vitrinka board-from-set --root .vitrinka/screenshots'],
3343
3740
  },
3344
3741
  {
3345
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)',
@@ -3347,7 +3744,7 @@ export const COMMANDS = [
3347
3744
  flags: [ROOT_FLAG, { f: '--slug', arg: '<board>', d: 'board slug (default: set key)' },
3348
3745
  { f: '--btitle', arg: '<t>', d: 'board title' }, { f: '--actor', arg: '<name>', d: 'override the operator attribution' }],
3349
3746
  dynamic: 'boards',
3350
- examples: ['vitrinka journey-from-set --root .screenshots'],
3747
+ examples: ['vitrinka journey-from-set --root .vitrinka/screenshots'],
3351
3748
  },
3352
3749
  {
3353
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',
@@ -3368,7 +3765,7 @@ export const COMMANDS = [
3368
3765
  usage: '[--root <dir>] [--base <url>] [--project <p>]',
3369
3766
  flags: [ROOT_FLAG, BASE_FLAG, { f: '--project', arg: '<p>', d: 'project slug (default: worktree name)' }],
3370
3767
  dynamic: 'projects',
3371
- examples: ['vitrinka index --root .screenshots'],
3768
+ examples: ['vitrinka index --root .vitrinka/screenshots'],
3372
3769
  },
3373
3770
  {
3374
3771
  name: 'index-components', summary: 'Scan a UI library for exported components + push the component index',
@@ -3389,7 +3786,7 @@ export const COMMANDS = [
3389
3786
  usage: '[--root <dir>] [--select 1,3-5] [--size 720] [--quality 85] --out <dir>/data.json',
3390
3787
  flags: [ROOT_FLAG, { f: '--select', arg: '1,3-5', d: '1-based shot selection' }, { f: '--size', arg: '<px>', d: 'longest edge (default 720)' },
3391
3788
  { f: '--quality', arg: '<q>', d: 'JPEG quality (default 85)' }, { f: '--out', arg: '<file>', d: 'output data.json (required)' }],
3392
- 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'],
3393
3790
  },
3394
3791
  {
3395
3792
  name: 'artifact-init', summary: 'Scaffold a blank artifact report + set config',
@@ -3400,11 +3797,11 @@ export const COMMANDS = [
3400
3797
  },
3401
3798
  {
3402
3799
  name: 'artifact-from-set', summary: 'Scaffold an artifact report from a screenshot set',
3403
- usage: '--slug <s> [--from .screenshots] [--select 1,3-5] [--title <t>] [--kind report] [--source <ref>]',
3404
- 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)' },
3405
3802
  { f: '--select', arg: '1,3-5', d: '1-based shot selection' }, { f: '--title', arg: '<t>', d: 'artifact title' },
3406
3803
  { f: '--kind', arg: '<k>', d: 'set kind (default report)' }, { f: '--source', arg: '<ref>', d: 'source set ref for cross-linking' }, BASE_FLAG],
3407
- examples: ['vitrinka artifact-from-set --slug payout-report --from .screenshots'],
3804
+ examples: ['vitrinka artifact-from-set --slug payout-report --from .vitrinka/screenshots'],
3408
3805
  },
3409
3806
  // Meta commands — documented + completable, kept out of the compact usage grid.
3410
3807
  { name: 'help', summary: 'Show help (all commands, or one command in detail)', usage: '[<command>]', positional: '[<command>]', hidden: true,
@@ -3885,7 +4282,7 @@ async function boardFromSetCmd(root, args, journey = false) {
3885
4282
  const cfg = loadVitrinkaCfg(root);
3886
4283
  // The .vitrinka descriptor names the project — record its main worktree top so
3887
4284
  // the desktop app can discover this checkout (instant-editor decision #6).
3888
- const projTop = mainWorktreeTop(dirname(root));
4285
+ const projTop = mainWorktreeTop(projectDirOf(root));
3889
4286
  if (cfg.project && projTop)
3890
4287
  recordProject(sanitizeSeg(cfg.project, 64), projTop);
3891
4288
  const slug = (strArg(args.slug) || cfg.key).toLowerCase();
@@ -3932,7 +4329,7 @@ async function boardFromSetCmd(root, args, journey = false) {
3932
4329
  const boardUrl = out.board?.url || `${cfg.base}${ws}/boards/${slug}`;
3933
4330
  console.log(`flow board: ${boardUrl} (${out.cards.length} cards, ${out.edges.length} edges)`);
3934
4331
  // The board is about to be annotated — make sure its autocomplete index is fresh.
3935
- await pushProjectIndex(dirname(root), cfg.base, cfg.project);
4332
+ await pushProjectIndex(projectDirOf(root), cfg.base, cfg.project);
3936
4333
  }
3937
4334
  // ---------- upload: files → board cards (mcp-file-upload 2026-07-15) ----------
3938
4335
  //
@@ -4535,7 +4932,12 @@ async function watchCmd(args) {
4535
4932
  async function runSub(argv) {
4536
4933
  const [cmd, ...rest] = argv;
4537
4934
  const args = parseArgs(rest);
4538
- 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') : '';
4539
4941
  if (cmd === 'add')
4540
4942
  addCmd(root, args, rest);
4541
4943
  else if (cmd === 'build') {
@@ -4556,8 +4958,12 @@ async function runSub(argv) {
4556
4958
  await loginCmd(args);
4557
4959
  else if (cmd === 'doctor')
4558
4960
  await doctorCmd(args);
4961
+ else if (cmd === 'tidy')
4962
+ tidyCmd(args);
4559
4963
  else if (cmd === 'install')
4560
4964
  await installCmd(args);
4965
+ else if (cmd === 'setup-project')
4966
+ await setupProjectCmd(args);
4561
4967
  else if (cmd === 'install-claude')
4562
4968
  installClaudeCmd(args);
4563
4969
  else if (cmd === 'uninstall')