@lovinka/vitrinka 1.1.1 → 1.5.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
@@ -10,14 +10,14 @@
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, } from 'node:fs';
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync, copyFileSync, mkdtempSync, chmodSync, appendFileSync, statSync, cpSync, openSync, readSync, closeSync, } from 'node:fs';
14
14
  import { join, resolve, basename, dirname, extname, relative, sep } from 'node:path';
15
15
  import { tmpdir, hostname } from 'node:os';
16
16
  import { spawnSync, spawn } from 'node:child_process';
17
17
  import { createHash } from 'node:crypto';
18
18
  import { fileURLToPath, pathToFileURL } from 'node:url';
19
19
  import { createInterface } from 'node:readline';
20
- import { readToken } from "./lib/token.js";
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
23
  const CLI_PATH = fileURLToPath(import.meta.url);
@@ -146,6 +146,26 @@ export function shotContext(args) {
146
146
  }
147
147
  return out;
148
148
  }
149
+ // appVersionOf reads the app's version string for capture provenance:
150
+ // app.json (Expo: expo.version, or bare version) first, then package.json.
151
+ // Best-effort — a missing/malformed file just leaves the field off.
152
+ export function appVersionOf(projectDir) {
153
+ for (const f of ['app.json', 'package.json']) {
154
+ const p = join(projectDir, f);
155
+ if (!existsSync(p))
156
+ continue;
157
+ try {
158
+ const j = JSON.parse(readFileSync(p, 'utf8'));
159
+ const v = j?.expo?.version ?? j?.version;
160
+ if (typeof v === 'string' && v)
161
+ return v;
162
+ }
163
+ catch {
164
+ // Malformed JSON is not snap's problem — provenance is best-effort.
165
+ }
166
+ }
167
+ return '';
168
+ }
149
169
  function appendShot(root, shot) {
150
170
  const m = loadManifest(root);
151
171
  m.shots.push(shot);
@@ -470,17 +490,23 @@ function cmdDescription(mdPath) {
470
490
  * resolves it: `<root>/commands/**\/*.md` → `/ns:name` (dirs join with ":"),
471
491
  * `<root>/skills/<ns…>/<name>/SKILL.md` → `/ns:name` — from the PROJECT's
472
492
  * .claude first, then the HOME ~/.claude (project wins name collisions).
493
+ * Home entries are tagged `s:"home"` so the board can tier + mark them.
473
494
  * Underscore-prefixed files/dirs (e.g. skills/git/_shared) are internals. */
474
495
  export function collectCommands(projectDir, homeClaude = join(process.env.HOME || '', '.claude')) {
475
496
  const out = new Map();
476
- const add = (segs, mdPath) => {
497
+ const add = (segs, mdPath, home) => {
477
498
  if (segs.some((s) => !s || s.startsWith('_') || s.startsWith('.')))
478
499
  return;
479
500
  const c = '/' + segs.join(':');
480
501
  if (out.has(c) || out.size >= 1000)
481
502
  return;
482
503
  const d = cmdDescription(mdPath);
483
- out.set(c, d ? { c, d } : { c });
504
+ const e = { c };
505
+ if (d)
506
+ e.d = d;
507
+ if (home)
508
+ e.s = 'home';
509
+ out.set(c, e);
484
510
  };
485
511
  const list = (dir) => {
486
512
  try {
@@ -491,15 +517,16 @@ export function collectCommands(projectDir, homeClaude = join(process.env.HOME |
491
517
  }
492
518
  };
493
519
  for (const root of [join(projectDir, '.claude'), homeClaude]) {
520
+ const home = root === homeClaude;
494
521
  for (const rel of list(join(root, 'commands'))) {
495
522
  if (!rel.endsWith('.md'))
496
523
  continue;
497
- add(rel.slice(0, -3).split('/'), join(root, 'commands', rel));
524
+ add(rel.slice(0, -3).split('/'), join(root, 'commands', rel), home);
498
525
  }
499
526
  for (const rel of list(join(root, 'skills'))) {
500
527
  if (!rel.endsWith('/SKILL.md'))
501
528
  continue;
502
- add(rel.slice(0, -'/SKILL.md'.length).split('/'), join(root, 'skills', rel));
529
+ add(rel.slice(0, -'/SKILL.md'.length).split('/'), join(root, 'skills', rel), home);
503
530
  }
504
531
  }
505
532
  return [...out.values()].sort((a, b) => (a.c < b.c ? -1 : a.c > b.c ? 1 : 0));
@@ -523,7 +550,9 @@ export function buildProjectIndex(projectDir) {
523
550
  files = files.slice(0, 50_000);
524
551
  }
525
552
  const routes = extractRoutes(files);
526
- const cmds = collectCommands(projectDir);
553
+ // The palette lives at the repo root's .claude — resolve from `top`, not the
554
+ // raw cwd, or a push from a subdir silently indexes only ~/.claude.
555
+ const cmds = collectCommands(top);
527
556
  // Key order mirrors the server's canonical marshal (v, repo, routes, files, cmds).
528
557
  const body = JSON.stringify({ v: 1, repo: originRepo(projectDir) || undefined, routes, files, cmds });
529
558
  return {
@@ -612,6 +641,111 @@ async function indexCmd(root, args) {
612
641
  if (!(await pushProjectIndex(projectDir, base, project, true)))
613
642
  process.exit(1);
614
643
  }
644
+ const COMPONENT_SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.next', '.expo', 'coverage', '.git', '__tests__', '__mocks__']);
645
+ function scanComponentFiles(dir, out) {
646
+ let entries;
647
+ try {
648
+ entries = readdirSync(dir, { withFileTypes: true });
649
+ }
650
+ catch {
651
+ return; // a listed default dir simply doesn't exist in this repo
652
+ }
653
+ for (const e of entries) {
654
+ if (e.isDirectory()) {
655
+ if (!COMPONENT_SKIP_DIRS.has(e.name) && !e.name.startsWith('.'))
656
+ scanComponentFiles(join(dir, e.name), out);
657
+ }
658
+ else if (/\.(tsx|jsx)$/.test(e.name) && !/\.(test|spec|stories)\./.test(e.name)) {
659
+ out.push(join(dir, e.name));
660
+ }
661
+ }
662
+ }
663
+ function extractProps(src, comp) {
664
+ const m = src.match(new RegExp(`(?:interface\\s+${comp}Props\\s*(?:extends [^{]+)?|type\\s+${comp}Props\\s*=\\s*)\\{`));
665
+ if (!m || m.index === undefined)
666
+ return { props: [], variants: [] };
667
+ // Balance braces from the block open so nested object types don't truncate.
668
+ let depth = 0, i = src.indexOf('{', m.index), start = i;
669
+ for (; i < src.length; i++) {
670
+ if (src[i] === '{')
671
+ depth++;
672
+ else if (src[i] === '}' && --depth === 0)
673
+ break;
674
+ }
675
+ const block = src.slice(start + 1, i);
676
+ const props = [];
677
+ const variants = [];
678
+ for (const line of block.split('\n')) {
679
+ const pm = line.match(/^\s*(\w+)(\?)?\s*:\s*([^;]+)/);
680
+ if (!pm)
681
+ continue;
682
+ const type = pm[3].trim().replace(/\s+/g, ' ').slice(0, 120);
683
+ props.push({ name: pm[1], type, required: !pm[2] });
684
+ const lits = type.match(/'[^']+'/g);
685
+ if (lits && lits.length > 1)
686
+ variants.push(`${pm[1]}=${lits.map((l) => l.slice(1, -1)).join('|')}`);
687
+ }
688
+ return { props, variants };
689
+ }
690
+ function scanComponents(projectDir, srcDirs) {
691
+ const files = [];
692
+ for (const d of srcDirs)
693
+ scanComponentFiles(resolve(projectDir, d), files);
694
+ const seen = new Map();
695
+ for (const file of files) {
696
+ const src = readFileSync(file, 'utf8');
697
+ const names = new Set();
698
+ for (const m of src.matchAll(/export\s+(?:default\s+)?function\s+([A-Z]\w+)\s*[(<]/g))
699
+ names.add(m[1]);
700
+ for (const m of src.matchAll(/export\s+const\s+([A-Z]\w+)\s*(?::[^=]+)?=\s*(?:React\.)?(?:memo|forwardRef)?\s*[(<]/g))
701
+ names.add(m[1]);
702
+ for (const name of names) {
703
+ if (seen.has(name))
704
+ continue; // first definition wins; dupes are re-exports
705
+ const { props, variants } = extractProps(src, name);
706
+ seen.set(name, { name, kind: 'component', sourcePath: relative(projectDir, file), props, variants });
707
+ }
708
+ }
709
+ return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
710
+ }
711
+ async function indexComponentsCmd(root, args) {
712
+ const projectDir = dirname(root);
713
+ const library = strArg(args.library);
714
+ if (!library)
715
+ throw new Error('index-components: --library <kit> is required (one per UI library, e.g. "web" or "expo")');
716
+ const base = (strArg(args.base) || process.env.VITRINKA_URL || 'https://vitrinka.lovinka.com').replace(/\/+$/, '');
717
+ const project = sanitizeSeg(strArg(args.project) || basename(mainWorktreeTop(projectDir) || projectDir), 64);
718
+ let components;
719
+ if (strArg(args.from)) {
720
+ const raw = JSON.parse(readFileSync(String(args.from), 'utf8'));
721
+ components = Array.isArray(raw) ? raw : raw.components;
722
+ if (!Array.isArray(components))
723
+ throw new Error('index-components: --from file must be a components array or {components:[…]}');
724
+ }
725
+ else {
726
+ const dirs = (strArg(args.src) || 'src/components,components,src').split(',').map((d) => d.trim()).filter(Boolean);
727
+ components = scanComponents(projectDir, dirs);
728
+ if (!components.length) {
729
+ throw new Error(`index-components: no exported components under ${dirs.join(', ')} — pass --src or check the library root`);
730
+ }
731
+ }
732
+ if (args.dry) {
733
+ console.log(JSON.stringify({ project, library, components }, null, 2));
734
+ return;
735
+ }
736
+ const token = readToken();
737
+ const headers = { 'Content-Type': 'application/json' };
738
+ if (token)
739
+ headers.Authorization = `Bearer ${token}`;
740
+ const res = await fetch(`${base}/api/v1/projects/${encodeURIComponent(project)}/components`, {
741
+ method: 'PUT', headers,
742
+ body: JSON.stringify({ library, components }),
743
+ signal: AbortSignal.timeout(30_000),
744
+ });
745
+ if (!res.ok)
746
+ throw new Error(`index-components: push rejected (HTTP ${res.status}): ${(await res.text()).slice(0, 200)}`);
747
+ console.log(`components ↑ ${project}/${library}: ${components.length} indexed`);
748
+ }
615
749
  function loadVitrinkaCfg(root) {
616
750
  const raw = readFileSync(vitrinkaCfgPath(root), 'utf8'); // caller checks existsSync
617
751
  try {
@@ -904,7 +1038,131 @@ async function operatorCmd(rest, args) {
904
1038
  console.error(`operator: server unreachable (${e instanceof Error ? e.message : String(e)}) — local name is set; re-run to publish`);
905
1039
  }
906
1040
  }
1041
+ // ---------- doctor: setup health check ----------
1042
+ // doctorCmd checks this machine's vitrinka setup: server reachable, write
1043
+ // token present AND accepted by the server, operator persona set. The token
1044
+ // probe is `PUT /api/v1/operator` with an empty JSON body — auth runs before
1045
+ // body validation, so 401 = bad token and 422 = token accepted, with nothing
1046
+ // ever written. Exit 1 when the server is unreachable or the token is
1047
+ // missing/rejected (reads still work; writes would 401).
1048
+ async function doctorCmd(args) {
1049
+ const base = resolveBaseUrl(strArg(args.base));
1050
+ let failed = false;
1051
+ let reachable = false;
1052
+ try {
1053
+ const res = await fetch(`${base}/healthz`, { signal: AbortSignal.timeout(10_000) });
1054
+ reachable = res.ok;
1055
+ console.log(res.ok ? `✓ server ${base}` : `✗ server ${base} (HTTP ${res.status})`);
1056
+ if (!res.ok)
1057
+ failed = true;
1058
+ }
1059
+ catch (e) {
1060
+ console.log(`✗ server ${base} unreachable (${e instanceof Error ? e.message : String(e)}) — vitrinka is WireGuard-mesh-only; bring the VPN up`);
1061
+ failed = true;
1062
+ }
1063
+ const src = tokenSource();
1064
+ const token = readToken();
1065
+ if (!token) {
1066
+ console.log(`✗ token missing ($VITRINKA_TOKEN unset, no ${tokenPath()}) — writes will 401`);
1067
+ console.log('');
1068
+ console.log(tokenHelp());
1069
+ console.log('');
1070
+ failed = true;
1071
+ }
1072
+ else if (reachable) {
1073
+ try {
1074
+ const res = await fetch(`${base}/api/v1/operator`, {
1075
+ method: 'PUT',
1076
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
1077
+ body: '{}',
1078
+ signal: AbortSignal.timeout(10_000),
1079
+ });
1080
+ if (res.status === 401) {
1081
+ console.log(`✗ token REJECTED by ${base} (from ${src === 'env' ? '$VITRINKA_TOKEN' : tokenPath()}) — stale or mistyped`);
1082
+ console.log('');
1083
+ console.log(tokenHelp());
1084
+ console.log('');
1085
+ failed = true;
1086
+ }
1087
+ else {
1088
+ console.log(`✓ token valid (from ${src === 'env' ? '$VITRINKA_TOKEN' : tokenPath()})`);
1089
+ }
1090
+ }
1091
+ catch (e) {
1092
+ console.log(`? token present (from ${src === 'env' ? '$VITRINKA_TOKEN' : tokenPath()}) — validity check failed (${e instanceof Error ? e.message : String(e)})`);
1093
+ }
1094
+ }
1095
+ else {
1096
+ console.log(`? token present (from ${src === 'env' ? '$VITRINKA_TOKEN' : tokenPath()}) — server unreachable, validity unknown`);
1097
+ }
1098
+ const op = readOperator(true);
1099
+ if (op)
1100
+ console.log(`✓ operator ${op}`);
1101
+ else
1102
+ console.log('✗ operator unset — board writes stay anonymous (fix: vitrinka operator <name>)');
1103
+ if (failed)
1104
+ process.exit(1);
1105
+ }
907
1106
  // ---------- install: PATH shim ----------
1107
+ // bundledSkillsDir locates the skills bundle shipped inside the npm package
1108
+ // (dist/cli.js → ../skills) or, when running from a repo checkout
1109
+ // (pkg/src/cli.ts), the repo-root skills/ directory.
1110
+ export function bundledSkillsDir(cliPath) {
1111
+ for (const cand of [join(dirname(cliPath), '..', 'skills'), join(dirname(cliPath), '..', '..', 'skills')]) {
1112
+ if (existsSync(join(cand, 'vitrinka')))
1113
+ return cand;
1114
+ }
1115
+ return '';
1116
+ }
1117
+ // installSkillsCmd copies the packaged skills bundle (vitrinka + listen,
1118
+ // screenshot, artifact, brainstorming) into the Claude skills directory —
1119
+ // ~/.claude/skills by default, ./.claude/skills with --local. This is the
1120
+ // npm-package twin of install.sh's skills step, so `npm i -g @lovinka/vitrinka
1121
+ // && vitrinka install-skills` fully equips a machine without a repo checkout.
1122
+ function installSkillsCmd(args) {
1123
+ const bundle = bundledSkillsDir(CLI_PATH);
1124
+ if (!bundle)
1125
+ fail('install-skills: bundled skills not found next to the CLI — reinstall the package');
1126
+ const home = process.env.HOME;
1127
+ const local = args.local === true;
1128
+ if (!local && !home)
1129
+ fail('install-skills: $HOME is not set (or pass --local)');
1130
+ const target = local ? resolve('.claude', 'skills') : join(home, '.claude', 'skills');
1131
+ mkdirSync(target, { recursive: true });
1132
+ // Whole directories, like install.sh: a skill may nest its SKILL.md (the
1133
+ // vitrinka skill keeps it under listen/ next to its CLI + tests).
1134
+ const names = readdirSync(bundle).filter((n) => !n.startsWith('.') && statSync(join(bundle, n)).isDirectory());
1135
+ if (!names.length)
1136
+ fail(`install-skills: no skills found in ${bundle}`);
1137
+ for (const n of names) {
1138
+ rmSync(join(target, n), { recursive: true, force: true });
1139
+ cpSync(join(bundle, n), join(target, n), { recursive: true });
1140
+ // Local installs live at ./.claude/skills — keep referenced CLI paths copy-pasteable.
1141
+ if (local) {
1142
+ for (const doc of [join(target, n, 'SKILL.md'), join(target, n, 'listen', 'SKILL.md')]) {
1143
+ if (!existsSync(doc))
1144
+ continue;
1145
+ writeFileSync(doc, readFileSync(doc, 'utf8')
1146
+ .replaceAll('~/.claude/skills/vitrinka/cli.ts', '.claude/skills/vitrinka/cli.ts'));
1147
+ }
1148
+ }
1149
+ console.log(`✓ ${n} → ${join(target, n)}`);
1150
+ }
1151
+ // /vitrinka:listen command stub — nested SKILL.md files (skills/vitrinka/listen/)
1152
+ // never register as invocable skills; only a stub under commands/vitrinka/ makes
1153
+ // the slash command callable.
1154
+ const stub = join(bundle, 'vitrinka', 'commands', 'listen.md');
1155
+ if (existsSync(stub)) {
1156
+ const commandsDir = local ? resolve('.claude', 'commands', 'vitrinka') : join(home, '.claude', 'commands', 'vitrinka');
1157
+ mkdirSync(commandsDir, { recursive: true });
1158
+ let content = readFileSync(stub, 'utf8');
1159
+ if (local)
1160
+ content = content.replaceAll('~/.claude/skills/vitrinka/listen/SKILL.md', '.claude/skills/vitrinka/listen/SKILL.md');
1161
+ writeFileSync(join(commandsDir, 'listen.md'), content);
1162
+ console.log(`✓ /vitrinka:listen command → ${join(commandsDir, 'listen.md')}`);
1163
+ }
1164
+ console.log(`${names.length} skill${names.length === 1 ? '' : 's'} installed (${names.sort().join(', ')})`);
1165
+ }
908
1166
  // installCmd writes an executable shim `vitrinka` into ~/.local/bin (so the
909
1167
  // CLI works in interactive shells AND non-interactive AI/tool shells, which a
910
1168
  // zshrc alias would not) and makes sure ~/.local/bin is on PATH via a
@@ -913,6 +1171,13 @@ function installCmd(args) {
913
1171
  const home = process.env.HOME;
914
1172
  if (!home)
915
1173
  fail('install: $HOME is not set');
1174
+ // Skills first — `npm i -g @lovinka/vitrinka && vitrinka install` must fully
1175
+ // equip a machine on its own (there is deliberately no npm postinstall side
1176
+ // effect). Idempotent: re-runs refresh the bundle copies.
1177
+ if (bundledSkillsDir(CLI_PATH))
1178
+ installSkillsCmd(args);
1179
+ else
1180
+ console.log('skills: bundle not found next to the CLI — skipped (reinstall the package, or run install.sh from a checkout)');
916
1181
  const binDir = join(home, '.local', 'bin');
917
1182
  const shimPath = join(binDir, 'vitrinka');
918
1183
  const shim = `#!/bin/sh\nexec node "${CLI_PATH}" "$@"\n`;
@@ -956,8 +1221,206 @@ function installCmd(args) {
956
1221
  console.log(`added ${shellName} completion to ${rc} (open a new shell, or: source ${rc})`);
957
1222
  }
958
1223
  }
1224
+ // Claude Code UI wiring — consent-gated (it edits ~/.claude/settings.json):
1225
+ // --claude / --no-claude decide outright; otherwise ask on a TTY, skip with a
1226
+ // hint when non-interactive (curl|bash, CI).
1227
+ if (args['no-claude'] === true) {
1228
+ console.log('claude wiring: skipped (--no-claude)');
1229
+ }
1230
+ else if (args.claude === true || promptYesNo('Wire Claude Code UI (🧷 board statusline segment + clickable footer badge)? [Y/n] ')) {
1231
+ installClaudeCmd(args);
1232
+ }
1233
+ else if (!process.stdin.isTTY) {
1234
+ console.log('claude wiring: skipped (non-interactive — run `vitrinka install-claude`, or pass --claude)');
1235
+ }
1236
+ else {
1237
+ console.log('claude wiring: skipped (re-run any time: vitrinka install-claude)');
1238
+ }
959
1239
  console.log('done — try: vitrinka --help');
960
1240
  }
1241
+ // ---------- install-claude: Claude Code UI wiring ----------
1242
+ //
1243
+ // Wires the two Claude Code surfaces that link to boards (decisions:
1244
+ // docs/specs/2026-07-07-claude-install-decisions.md):
1245
+ // 1. footerLinksRegexes in ~/.claude/settings.json — Claude renders its own
1246
+ // clickable "🧷 board" footer badge whenever a /boards/ URL appears in the
1247
+ // conversation (works in every terminal, unlike statusline OSC 8).
1248
+ // 2. statusLine — a wrapper script that pipes the same stdin JSON to the
1249
+ // user's ORIGINAL statusline command and appends a "🧷 <board-url>" plain-text
1250
+ // segment when the session's capture root has a .screenshots/.vitrinka
1251
+ // descriptor. Plain URL on purpose: Claude Code strips OSC 8 from statusline
1252
+ // output in real terminals (anthropics/claude-code#21586, #26356 — closed
1253
+ // "not planned"), but terminals like Warp/iTerm2/Ghostty cmd+click-linkify
1254
+ // visible URLs on their own.
1255
+ // With no original statusline, the wrapper renders a minimal line itself.
1256
+ // Idempotent, never an uninstall: re-runs detect the /boards/ footer pattern and
1257
+ // the wrapper registration and no-op. Removal = delete the wrapper + restore
1258
+ // statusLine.command (recorded in the wrapper's VITRINKA_ORIG line) + drop the
1259
+ // footerLinksRegexes entry (documented in pkg/README.md).
1260
+ export const BOARD_FOOTER_LINK = {
1261
+ type: 'regex',
1262
+ pattern: '(?<url>https?:\\/\\/\\S+\\/boards\\/(?<slug>[\\w.-]+))',
1263
+ url: '{url}',
1264
+ label: 'vitrinka/b/{slug}',
1265
+ };
1266
+ // hasBoardFooterLink — an entry whose pattern targets /boards/ URLs already
1267
+ // exists (ours or hand-written); the marker that makes the settings edit idempotent.
1268
+ export function hasBoardFooterLink(settings) {
1269
+ const arr = settings.footerLinksRegexes;
1270
+ // Strip regex escaping first — patterns write the path as \/boards\/ as often as /boards/.
1271
+ return Array.isArray(arr) && arr.some((e) => typeof e?.pattern === 'string' && e.pattern.replaceAll('\\', '').includes('/boards/'));
1272
+ }
1273
+ // buildStatuslineWrapper — the ~/.claude/vitrinka-statusline.sh content. The
1274
+ // original command is recorded on the VITRINKA_ORIG line (single-quoted), both
1275
+ // to run it and so a re-run can recover it instead of wrapping the wrapper.
1276
+ // Board detection mirrors the CLI's capture-root contract: .screenshots/.vitrinka
1277
+ // with {base, key} → ${base}/boards/${key}. Needs jq (like the statusline
1278
+ // examples in Claude's own docs); without jq it degrades to pass-through.
1279
+ export function buildStatuslineWrapper(origCmd) {
1280
+ const quoted = origCmd.replaceAll("'", `'\\''`);
1281
+ return `#!/bin/bash
1282
+ # vitrinka-statusline — managed by \`vitrinka install-claude\` (re-runs rewrite it).
1283
+ # Wraps the original Claude Code statusline and appends a 🧷 board-URL segment when
1284
+ # the session's capture root has a .screenshots/.vitrinka descriptor. Plain URL,
1285
+ # not OSC 8 — Claude Code strips OSC 8 from statusline output; terminals
1286
+ # cmd+click-linkify visible URLs themselves.
1287
+ VITRINKA_ORIG='${quoted}'
1288
+
1289
+ input=$(cat)
1290
+
1291
+ out=""
1292
+ if [ -n "$VITRINKA_ORIG" ]; then
1293
+ out=$(printf '%s' "$input" | /bin/sh -c "$VITRINKA_ORIG")
1294
+ fi
1295
+
1296
+ # The original already renders a board segment → pass through untouched.
1297
+ case "$out" in *'/boards/'*|*'🧷'*) printf '%s\\n' "$out"; exit 0 ;; esac
1298
+
1299
+ if ! command -v jq >/dev/null 2>&1; then printf '%s\\n' "$out"; exit 0; fi
1300
+
1301
+ DIR=$(printf '%s' "$input" | jq -r '.workspace.current_dir // empty')
1302
+ PROJECT_DIR=$(printf '%s' "$input" | jq -r '.workspace.project_dir // empty')
1303
+
1304
+ BOARD=""
1305
+ for VJSON in "$DIR/.screenshots/.vitrinka" "$PROJECT_DIR/.screenshots/.vitrinka"; do
1306
+ [ -f "$VJSON" ] || continue
1307
+ VBASE=$(jq -r '.base // empty' "$VJSON" 2>/dev/null)
1308
+ VKEY=$(jq -r '.key // empty' "$VJSON" 2>/dev/null)
1309
+ if [ -n "$VBASE" ] && [ -n "$VKEY" ]; then
1310
+ BOARD=$(printf '\\033[35m🧷\\033[0m \\033[2m%s\\033[0m' "$VBASE/boards/$VKEY")
1311
+ fi
1312
+ break
1313
+ done
1314
+
1315
+ DIM=$(printf '\\033[2m'); RESET=$(printf '\\033[0m')
1316
+
1317
+ if [ -n "$VITRINKA_ORIG" ]; then
1318
+ # Append to the FIRST line only (extra statusline lines stay untouched).
1319
+ first=\${out%%$'\\n'*}; rest=\${out#"$first"}
1320
+ [ -n "$BOARD" ] && first="$first \${DIM}│\${RESET} $BOARD"
1321
+ printf '%s%s\\n' "$first" "$rest"
1322
+ else
1323
+ # No original statusline — minimal line: dir │ branch │ board │ model.
1324
+ MODEL=$(printf '%s' "$input" | jq -r '.model.display_name // empty')
1325
+ BRANCH=$(git -C "\${DIR:-.}" branch --show-current 2>/dev/null)
1326
+ GREEN=$(printf '\\033[32m'); CYAN=$(printf '\\033[36m')
1327
+ line="\${DIR/#$HOME/~}"
1328
+ [ -n "$BRANCH" ] && line="$line \${DIM}│\${RESET} \${GREEN}\${BRANCH}\${RESET}"
1329
+ [ -n "$BOARD" ] && line="$line \${DIM}│\${RESET} $BOARD"
1330
+ [ -n "$MODEL" ] && line="$line \${DIM}│\${RESET} \${CYAN}\${MODEL}\${RESET}"
1331
+ printf '%s\\n' "$line"
1332
+ fi
1333
+ `;
1334
+ }
1335
+ // wrapperOrigCommand — recover the original statusline command a wrapper file
1336
+ // recorded, so re-runs refresh the wrapper without wrapping it around itself.
1337
+ export function wrapperOrigCommand(content) {
1338
+ const line = content.split('\n').find((l) => l.startsWith("VITRINKA_ORIG='"));
1339
+ if (!line || !line.endsWith("'"))
1340
+ return '';
1341
+ return line.slice("VITRINKA_ORIG='".length, -1).replaceAll(`'\\''`, "'");
1342
+ }
1343
+ // promptYesNo — synchronous [Y/n] on the controlling TTY (Enter = yes). False on
1344
+ // any non-interactive stdin or when /dev/tty can't be opened (CI, curl|bash).
1345
+ function promptYesNo(question) {
1346
+ if (!process.stdin.isTTY)
1347
+ return false;
1348
+ process.stdout.write(question);
1349
+ try {
1350
+ const fd = openSync('/dev/tty', 'r');
1351
+ const buf = Buffer.alloc(64);
1352
+ const n = readSync(fd, buf, 0, 64, null);
1353
+ closeSync(fd);
1354
+ const ans = buf.toString('utf8', 0, n).trim().toLowerCase();
1355
+ return ans === '' || ans === 'y' || ans === 'yes';
1356
+ }
1357
+ catch {
1358
+ return false; // no controlling TTY — treat as non-interactive, never block
1359
+ }
1360
+ }
1361
+ export function installClaudeCmd(_args) {
1362
+ const home = process.env.HOME;
1363
+ if (!home)
1364
+ fail('install-claude: $HOME is not set');
1365
+ const claudeDir = join(home, '.claude');
1366
+ if (!existsSync(claudeDir)) {
1367
+ console.log('install-claude: ~/.claude not found — Claude Code is not set up on this machine; skipping');
1368
+ return;
1369
+ }
1370
+ const settingsPath = join(claudeDir, 'settings.json');
1371
+ let settings = {};
1372
+ if (existsSync(settingsPath)) {
1373
+ try {
1374
+ settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
1375
+ }
1376
+ catch (e) {
1377
+ fail(`install-claude: ${settingsPath} is not valid JSON — nothing was changed; fix it first (${e.message})`);
1378
+ }
1379
+ }
1380
+ let changed = false;
1381
+ // 1. Footer badge — clickable in every terminal (requires Claude Code ≥ 2.1.176).
1382
+ if (hasBoardFooterLink(settings)) {
1383
+ console.log('✓ footer badge already configured (footerLinksRegexes /boards/ entry)');
1384
+ }
1385
+ else {
1386
+ settings.footerLinksRegexes = [...(Array.isArray(settings.footerLinksRegexes) ? settings.footerLinksRegexes : []), BOARD_FOOTER_LINK];
1387
+ changed = true;
1388
+ console.log('✓ footer badge → footerLinksRegexes (clickable "🧷 board" whenever a board URL appears in conversation)');
1389
+ }
1390
+ // 2. Statusline wrapper.
1391
+ const wrapperPath = join(claudeDir, 'vitrinka-statusline.sh');
1392
+ const wrapperCmd = '~/.claude/vitrinka-statusline.sh';
1393
+ const sl = settings.statusLine;
1394
+ if (sl && sl.type !== undefined && sl.type !== 'command') {
1395
+ console.error(`⚠ statusLine.type is ${JSON.stringify(sl.type)} — don't know how to wrap it; statusline left untouched (footer badge still works)`);
1396
+ }
1397
+ else {
1398
+ const current = sl?.command ?? '';
1399
+ const isWrapper = current.replace(/^~(?=\/)/, home) === wrapperPath;
1400
+ // Re-runs recover the original from the existing wrapper file — never wrap the wrapper.
1401
+ const orig = isWrapper && existsSync(wrapperPath) ? wrapperOrigCommand(readFileSync(wrapperPath, 'utf8')) : current;
1402
+ writeFileSync(wrapperPath, buildStatuslineWrapper(orig));
1403
+ chmodSync(wrapperPath, 0o755);
1404
+ if (!isWrapper) {
1405
+ settings.statusLine = { type: 'command', command: wrapperCmd };
1406
+ changed = true;
1407
+ }
1408
+ if (isWrapper)
1409
+ console.log(`✓ statusline wrapper refreshed (${wrapperPath}${orig ? `, wraps: ${orig}` : ', minimal line'})`);
1410
+ else if (orig)
1411
+ console.log(`✓ statusline wrapped → ${wrapperPath} (appends 🧷 board to: ${orig})`);
1412
+ else
1413
+ console.log(`✓ statusline installed → ${wrapperPath} (minimal: dir │ branch │ 🧷 board │ model)`);
1414
+ }
1415
+ if (changed)
1416
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
1417
+ // The wrapper reads Claude's stdin JSON with jq; without it the board segment
1418
+ // silently degrades to pass-through — surface that at install time.
1419
+ if (spawnSync('jq', ['--version'], { stdio: 'ignore' }).error) {
1420
+ console.error('⚠ jq not found — the statusline 🧷 board segment needs it (brew install jq); footer badge works regardless');
1421
+ }
1422
+ console.log(`install-claude done — restart Claude Code sessions to pick up ${settingsPath}`);
1423
+ }
961
1424
  // ---------- snap: one-shot capture → manifest → detached push ----------
962
1425
  // Filename slug: diacritics folded (Přihlášení → prihlaseni), non-alnum → '-', capped.
963
1426
  export function kebab(s) {
@@ -1258,6 +1721,13 @@ async function snapCmd(surface, args) {
1258
1721
  if (surface === 'ios' && iosSimName && !ctx.device?.device) {
1259
1722
  ctx.device = { ...ctx.device, device: iosSimName };
1260
1723
  }
1724
+ // Capture provenance: the mechanism that produced the pixels, the URL/deep
1725
+ // link the screen was opened at (--url wins over --open), and the app build.
1726
+ const tool = adopt ? 'adopted'
1727
+ : surface === 'ios' ? 'simctl'
1728
+ : surface === 'android' ? 'adb-screencap'
1729
+ : 'screencapture';
1730
+ const url = strArg(args.url) || openUrl;
1261
1731
  const { count } = appendShot(root, {
1262
1732
  file: `${day}/${name}`,
1263
1733
  surface,
@@ -1268,6 +1738,9 @@ async function snapCmd(surface, args) {
1268
1738
  action: strArg(args.action) || undefined,
1269
1739
  hq: hq || undefined,
1270
1740
  ts: new Date().toISOString(),
1741
+ tool,
1742
+ url: url || undefined,
1743
+ appVersion: appVersionOf(projectDir) || undefined,
1271
1744
  ...ctx,
1272
1745
  });
1273
1746
  maybeWarnOffline(root);
@@ -1629,10 +2102,28 @@ export const COMMANDS = [
1629
2102
  examples: ['vitrinka operator', 'vitrinka operator "Lukáš"'],
1630
2103
  },
1631
2104
  {
1632
- name: 'install', summary: 'Install the PATH shim (+ optional shell completion)',
1633
- usage: '[--no-completion]', flags: [{ f: '--no-completion', d: 'skip appending the shell-completion line' }],
2105
+ name: 'doctor', summary: 'Check this machine\'s setup: server reachable, write token valid, operator set',
2106
+ usage: '[--base <url>]', flags: [BASE_FLAG],
2107
+ examples: ['vitrinka doctor'],
2108
+ },
2109
+ {
2110
+ name: 'install', summary: 'Install the PATH shim (+ optional shell completion + Claude Code UI wiring)',
2111
+ usage: '[--no-completion] [--claude|--no-claude]',
2112
+ flags: [{ f: '--no-completion', d: 'skip appending the shell-completion line' },
2113
+ { f: '--claude', d: 'wire the Claude Code UI without asking' },
2114
+ { f: '--no-claude', d: 'skip the Claude Code UI wiring (default when non-interactive)' }],
1634
2115
  examples: ['vitrinka install'],
1635
2116
  },
2117
+ {
2118
+ name: 'install-claude', summary: 'Wire the Claude Code UI: 🧷 board statusline segment + clickable footer badge',
2119
+ usage: '', flags: [],
2120
+ examples: ['vitrinka install-claude'],
2121
+ },
2122
+ {
2123
+ name: 'install-skills', summary: 'Install the bundled Claude skills (vitrinka, screenshot, artifact, brainstorming)',
2124
+ usage: '[--local]', flags: [{ f: '--local', d: 'install into ./.claude/skills of the current project instead of ~/.claude/skills' }],
2125
+ examples: ['vitrinka install-skills'],
2126
+ },
1636
2127
  {
1637
2128
  name: 'snap', summary: 'Capture a screenshot → manifest → detached push',
1638
2129
  usage: '<ios|android|macos|web> [--root <dir>] [--file <path>] --route <r> --note <n> [ctx flags] [--udid <u>] [--region x,y,w,h] [--open <deeplink>] [--settle <seconds>] [--hq]',
@@ -1642,6 +2133,7 @@ export const COMMANDS = [
1642
2133
  { f: '--udid', arg: '<u>', d: 'iOS simulator UDID' }, { f: '--region', arg: 'x,y,w,h', d: 'macOS capture region' },
1643
2134
  { f: '--open', arg: '<deeplink>', d: 'open a deep link, then settle before capturing' },
1644
2135
  { f: '--settle', arg: '<seconds>', d: 'wait after --open (default 4)' },
2136
+ { f: '--url', arg: '<url>', d: 'exact URL the shot was taken at (provenance; defaults to --open)' },
1645
2137
  { f: '--hq', d: 'full-fidelity marketing asset: no lossy re-encode, no resize (lossless WebP only if smaller)' }],
1646
2138
  examples: ['vitrinka snap ios --route /home --note "landing"', 'vitrinka snap web --route / --note "hero" --hq'],
1647
2139
  },
@@ -1673,6 +2165,20 @@ export const COMMANDS = [
1673
2165
  dynamic: 'projects',
1674
2166
  examples: ['vitrinka index --root .screenshots'],
1675
2167
  },
2168
+ {
2169
+ name: 'index-components', summary: 'Scan a UI library for exported components + push the component index',
2170
+ usage: '--library <kit> [--src <dirs>] [--project <p>] [--from <file.json>] [--dry] [--base <url>]',
2171
+ flags: [
2172
+ { f: '--library', arg: '<kit>', d: 'UI kit name, one per library in a monorepo (e.g. "web", "expo") — required' },
2173
+ { f: '--src', arg: '<dirs>', d: 'comma-separated dirs to scan (default "src/components,components,src")' },
2174
+ { f: '--project', arg: '<p>', d: 'project slug (default: worktree name)' },
2175
+ { f: '--from', arg: '<file>', d: 'push a prepared components JSON (the AI pre-index output) instead of scanning' },
2176
+ { f: '--dry', d: 'print the scan as JSON, push nothing (feed this to the AI pre-index pass)' },
2177
+ BASE_FLAG
2178
+ ],
2179
+ examples: ['vitrinka index-components --library web --dry > components.json',
2180
+ 'vitrinka index-components --library web --from components.json'],
2181
+ },
1676
2182
  {
1677
2183
  name: 'embed-shots', summary: 'Embed manifest shots as resized JPEG data URIs → data.json',
1678
2184
  usage: '[--root <dir>] [--select 1,3-5] [--size 720] [--quality 85] --out <dir>/data.json',
@@ -2180,7 +2686,7 @@ async function boardFromSetCmd(root, args) {
2180
2686
  if (actor)
2181
2687
  headers['X-Board-Actor'] = actor;
2182
2688
  const create = await fetch(`${cfg.base}/api/v1/boards`, {
2183
- method: 'POST', headers, body: JSON.stringify({ slug, title }),
2689
+ method: 'POST', headers, body: JSON.stringify({ slug, title, project: cfg.project }),
2184
2690
  signal: AbortSignal.timeout(30_000),
2185
2691
  });
2186
2692
  if (!create.ok && create.status !== 409) { // 409 = board exists → reuse
@@ -2468,8 +2974,14 @@ async function runSub(argv) {
2468
2974
  await nameCmd(argv, args);
2469
2975
  else if (cmd === 'operator')
2470
2976
  await operatorCmd(argv, args);
2977
+ else if (cmd === 'doctor')
2978
+ await doctorCmd(args);
2471
2979
  else if (cmd === 'install')
2472
2980
  installCmd(args);
2981
+ else if (cmd === 'install-claude')
2982
+ installClaudeCmd(args);
2983
+ else if (cmd === 'install-skills')
2984
+ installSkillsCmd(args);
2473
2985
  else if (cmd === 'snap')
2474
2986
  await snapCmd(rest[0] && !rest[0].startsWith('--') ? rest[0] : '', args);
2475
2987
  else if (cmd === 'board-from-set')
@@ -2478,6 +2990,8 @@ async function runSub(argv) {
2478
2990
  await watchCmd(args);
2479
2991
  else if (cmd === 'index')
2480
2992
  await indexCmd(root, args);
2993
+ else if (cmd === 'index-components')
2994
+ await indexComponentsCmd(root, args);
2481
2995
  else if (cmd === 'embed-shots')
2482
2996
  embedShotsCmd(root, args);
2483
2997
  else if (cmd === 'artifact-init')