@lovinka/vitrinka 1.1.1 → 1.4.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/README.md CHANGED
@@ -25,6 +25,26 @@ npm i -g @lovinka/vitrinka
25
25
  claude mcp add --scope user vitrinka -- npx -y --package=@lovinka/vitrinka vitrinka-mcp
26
26
  ```
27
27
 
28
+ `vitrinka install` also offers (interactive `[Y/n]`, or `--claude`/`--no-claude`)
29
+ to wire the **Claude Code UI** — re-runnable standalone as `vitrinka install-claude`:
30
+
31
+ - **Footer badge** — a `footerLinksRegexes` entry in `~/.claude/settings.json`
32
+ renders a clickable **🧷 board** badge in Claude Code's footer whenever a
33
+ `/boards/` URL appears in the conversation (clickable in every terminal;
34
+ needs Claude Code ≥ 2.1.176).
35
+ - **Statusline segment** — `~/.claude/vitrinka-statusline.sh` wraps your existing
36
+ statusline command (recorded on its `VITRINKA_ORIG=` line) and appends a
37
+ `🧷 board` OSC 8 link when the session's `.screenshots/.vitrinka` descriptor
38
+ exists; with no statusline configured it renders a minimal
39
+ `dir │ branch │ 🧷 board │ model` line itself. Needs `jq`; degrades to
40
+ pass-through without it, and never double-renders a board segment your own
41
+ statusline already prints.
42
+
43
+ Both steps are idempotent — re-runs detect the wiring and no-op. To remove:
44
+ delete `~/.claude/vitrinka-statusline.sh`, restore `statusLine.command` in
45
+ `~/.claude/settings.json` to the `VITRINKA_ORIG` value (or drop the key), and
46
+ remove the `footerLinksRegexes` entry whose pattern contains `/boards/`.
47
+
28
48
  For Claude Desktop or any other MCP client, add the equivalent entry by hand:
29
49
 
30
50
  ```json
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,118 @@ 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
+ console.log(`${names.length} skill${names.length === 1 ? '' : 's'} installed (${names.sort().join(', ')})`);
1152
+ }
908
1153
  // installCmd writes an executable shim `vitrinka` into ~/.local/bin (so the
909
1154
  // CLI works in interactive shells AND non-interactive AI/tool shells, which a
910
1155
  // zshrc alias would not) and makes sure ~/.local/bin is on PATH via a
@@ -956,8 +1201,195 @@ function installCmd(args) {
956
1201
  console.log(`added ${shellName} completion to ${rc} (open a new shell, or: source ${rc})`);
957
1202
  }
958
1203
  }
1204
+ // Claude Code UI wiring — consent-gated (it edits ~/.claude/settings.json):
1205
+ // --claude / --no-claude decide outright; otherwise ask on a TTY, skip with a
1206
+ // hint when non-interactive (curl|bash, CI).
1207
+ if (args['no-claude'] === true) {
1208
+ console.log('claude wiring: skipped (--no-claude)');
1209
+ }
1210
+ else if (args.claude === true || promptYesNo('Wire Claude Code UI (🧷 board statusline segment + clickable footer badge)? [Y/n] ')) {
1211
+ installClaudeCmd(args);
1212
+ }
1213
+ else if (!process.stdin.isTTY) {
1214
+ console.log('claude wiring: skipped (non-interactive — run `vitrinka install-claude`, or pass --claude)');
1215
+ }
1216
+ else {
1217
+ console.log('claude wiring: skipped (re-run any time: vitrinka install-claude)');
1218
+ }
959
1219
  console.log('done — try: vitrinka --help');
960
1220
  }
1221
+ // ---------- install-claude: Claude Code UI wiring ----------
1222
+ //
1223
+ // Wires the two Claude Code surfaces that link to boards (decisions:
1224
+ // docs/specs/2026-07-07-claude-install-decisions.md):
1225
+ // 1. footerLinksRegexes in ~/.claude/settings.json — Claude renders its own
1226
+ // clickable "🧷 board" footer badge whenever a /boards/ URL appears in the
1227
+ // conversation (works in every terminal, unlike statusline OSC 8).
1228
+ // 2. statusLine — a wrapper script that pipes the same stdin JSON to the
1229
+ // user's ORIGINAL statusline command and appends a "🧷 board" OSC 8 link
1230
+ // when the session's capture root has a .screenshots/.vitrinka descriptor.
1231
+ // With no original statusline, the wrapper renders a minimal line itself.
1232
+ // Idempotent, never an uninstall: re-runs detect the /boards/ footer pattern and
1233
+ // the wrapper registration and no-op. Removal = delete the wrapper + restore
1234
+ // statusLine.command (recorded in the wrapper's VITRINKA_ORIG line) + drop the
1235
+ // footerLinksRegexes entry (documented in pkg/README.md).
1236
+ export const BOARD_FOOTER_LINK = {
1237
+ type: 'regex',
1238
+ pattern: '(?<url>https?:\\/\\/\\S+\\/boards\\/(?<slug>[\\w.-]+))',
1239
+ url: '{url}',
1240
+ label: 'vitrinka/b/{slug}',
1241
+ };
1242
+ // hasBoardFooterLink — an entry whose pattern targets /boards/ URLs already
1243
+ // exists (ours or hand-written); the marker that makes the settings edit idempotent.
1244
+ export function hasBoardFooterLink(settings) {
1245
+ const arr = settings.footerLinksRegexes;
1246
+ // Strip regex escaping first — patterns write the path as \/boards\/ as often as /boards/.
1247
+ return Array.isArray(arr) && arr.some((e) => typeof e?.pattern === 'string' && e.pattern.replaceAll('\\', '').includes('/boards/'));
1248
+ }
1249
+ // buildStatuslineWrapper — the ~/.claude/vitrinka-statusline.sh content. The
1250
+ // original command is recorded on the VITRINKA_ORIG line (single-quoted), both
1251
+ // to run it and so a re-run can recover it instead of wrapping the wrapper.
1252
+ // Board detection mirrors the CLI's capture-root contract: .screenshots/.vitrinka
1253
+ // with {base, key} → ${base}/boards/${key}. Needs jq (like the statusline
1254
+ // examples in Claude's own docs); without jq it degrades to pass-through.
1255
+ export function buildStatuslineWrapper(origCmd) {
1256
+ const quoted = origCmd.replaceAll("'", `'\\''`);
1257
+ return `#!/bin/bash
1258
+ # vitrinka-statusline — managed by \`vitrinka install-claude\` (re-runs rewrite it).
1259
+ # Wraps the original Claude Code statusline and appends a 🧷 board OSC 8 link when
1260
+ # the session's capture root has a .screenshots/.vitrinka descriptor.
1261
+ VITRINKA_ORIG='${quoted}'
1262
+
1263
+ input=$(cat)
1264
+
1265
+ out=""
1266
+ if [ -n "$VITRINKA_ORIG" ]; then
1267
+ out=$(printf '%s' "$input" | /bin/sh -c "$VITRINKA_ORIG")
1268
+ fi
1269
+
1270
+ # The original already renders a board segment → pass through untouched.
1271
+ case "$out" in *'/boards/'*|*'🧷'*) printf '%s\\n' "$out"; exit 0 ;; esac
1272
+
1273
+ if ! command -v jq >/dev/null 2>&1; then printf '%s\\n' "$out"; exit 0; fi
1274
+
1275
+ DIR=$(printf '%s' "$input" | jq -r '.workspace.current_dir // empty')
1276
+ PROJECT_DIR=$(printf '%s' "$input" | jq -r '.workspace.project_dir // empty')
1277
+
1278
+ BOARD=""
1279
+ for VJSON in "$DIR/.screenshots/.vitrinka" "$PROJECT_DIR/.screenshots/.vitrinka"; do
1280
+ [ -f "$VJSON" ] || continue
1281
+ VBASE=$(jq -r '.base // empty' "$VJSON" 2>/dev/null)
1282
+ VKEY=$(jq -r '.key // empty' "$VJSON" 2>/dev/null)
1283
+ if [ -n "$VBASE" ] && [ -n "$VKEY" ]; then
1284
+ BOARD=$(printf '\\033]8;;%s\\a\\033[35m🧷 board\\033[0m\\033]8;;\\a' "$VBASE/boards/$VKEY")
1285
+ fi
1286
+ break
1287
+ done
1288
+
1289
+ DIM=$(printf '\\033[2m'); RESET=$(printf '\\033[0m')
1290
+
1291
+ if [ -n "$VITRINKA_ORIG" ]; then
1292
+ # Append to the FIRST line only (extra statusline lines stay untouched).
1293
+ first=\${out%%$'\\n'*}; rest=\${out#"$first"}
1294
+ [ -n "$BOARD" ] && first="$first \${DIM}│\${RESET} $BOARD"
1295
+ printf '%s%s\\n' "$first" "$rest"
1296
+ else
1297
+ # No original statusline — minimal line: dir │ branch │ board │ model.
1298
+ MODEL=$(printf '%s' "$input" | jq -r '.model.display_name // empty')
1299
+ BRANCH=$(git -C "\${DIR:-.}" branch --show-current 2>/dev/null)
1300
+ GREEN=$(printf '\\033[32m'); CYAN=$(printf '\\033[36m')
1301
+ line="\${DIR/#$HOME/~}"
1302
+ [ -n "$BRANCH" ] && line="$line \${DIM}│\${RESET} \${GREEN}\${BRANCH}\${RESET}"
1303
+ [ -n "$BOARD" ] && line="$line \${DIM}│\${RESET} $BOARD"
1304
+ [ -n "$MODEL" ] && line="$line \${DIM}│\${RESET} \${CYAN}\${MODEL}\${RESET}"
1305
+ printf '%s\\n' "$line"
1306
+ fi
1307
+ `;
1308
+ }
1309
+ // wrapperOrigCommand — recover the original statusline command a wrapper file
1310
+ // recorded, so re-runs refresh the wrapper without wrapping it around itself.
1311
+ export function wrapperOrigCommand(content) {
1312
+ const line = content.split('\n').find((l) => l.startsWith("VITRINKA_ORIG='"));
1313
+ if (!line || !line.endsWith("'"))
1314
+ return '';
1315
+ return line.slice("VITRINKA_ORIG='".length, -1).replaceAll(`'\\''`, "'");
1316
+ }
1317
+ // promptYesNo — synchronous [Y/n] on the controlling TTY (Enter = yes). False on
1318
+ // any non-interactive stdin or when /dev/tty can't be opened (CI, curl|bash).
1319
+ function promptYesNo(question) {
1320
+ if (!process.stdin.isTTY)
1321
+ return false;
1322
+ process.stdout.write(question);
1323
+ try {
1324
+ const fd = openSync('/dev/tty', 'r');
1325
+ const buf = Buffer.alloc(64);
1326
+ const n = readSync(fd, buf, 0, 64, null);
1327
+ closeSync(fd);
1328
+ const ans = buf.toString('utf8', 0, n).trim().toLowerCase();
1329
+ return ans === '' || ans === 'y' || ans === 'yes';
1330
+ }
1331
+ catch {
1332
+ return false; // no controlling TTY — treat as non-interactive, never block
1333
+ }
1334
+ }
1335
+ export function installClaudeCmd(_args) {
1336
+ const home = process.env.HOME;
1337
+ if (!home)
1338
+ fail('install-claude: $HOME is not set');
1339
+ const claudeDir = join(home, '.claude');
1340
+ if (!existsSync(claudeDir)) {
1341
+ console.log('install-claude: ~/.claude not found — Claude Code is not set up on this machine; skipping');
1342
+ return;
1343
+ }
1344
+ const settingsPath = join(claudeDir, 'settings.json');
1345
+ let settings = {};
1346
+ if (existsSync(settingsPath)) {
1347
+ try {
1348
+ settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
1349
+ }
1350
+ catch (e) {
1351
+ fail(`install-claude: ${settingsPath} is not valid JSON — nothing was changed; fix it first (${e.message})`);
1352
+ }
1353
+ }
1354
+ let changed = false;
1355
+ // 1. Footer badge — clickable in every terminal (requires Claude Code ≥ 2.1.176).
1356
+ if (hasBoardFooterLink(settings)) {
1357
+ console.log('✓ footer badge already configured (footerLinksRegexes /boards/ entry)');
1358
+ }
1359
+ else {
1360
+ settings.footerLinksRegexes = [...(Array.isArray(settings.footerLinksRegexes) ? settings.footerLinksRegexes : []), BOARD_FOOTER_LINK];
1361
+ changed = true;
1362
+ console.log('✓ footer badge → footerLinksRegexes (clickable "🧷 board" whenever a board URL appears in conversation)');
1363
+ }
1364
+ // 2. Statusline wrapper.
1365
+ const wrapperPath = join(claudeDir, 'vitrinka-statusline.sh');
1366
+ const wrapperCmd = '~/.claude/vitrinka-statusline.sh';
1367
+ const sl = settings.statusLine;
1368
+ if (sl && sl.type !== undefined && sl.type !== 'command') {
1369
+ console.error(`⚠ statusLine.type is ${JSON.stringify(sl.type)} — don't know how to wrap it; statusline left untouched (footer badge still works)`);
1370
+ }
1371
+ else {
1372
+ const current = sl?.command ?? '';
1373
+ const isWrapper = current.replace(/^~(?=\/)/, home) === wrapperPath;
1374
+ // Re-runs recover the original from the existing wrapper file — never wrap the wrapper.
1375
+ const orig = isWrapper && existsSync(wrapperPath) ? wrapperOrigCommand(readFileSync(wrapperPath, 'utf8')) : current;
1376
+ writeFileSync(wrapperPath, buildStatuslineWrapper(orig));
1377
+ chmodSync(wrapperPath, 0o755);
1378
+ if (!isWrapper) {
1379
+ settings.statusLine = { type: 'command', command: wrapperCmd };
1380
+ changed = true;
1381
+ }
1382
+ if (isWrapper)
1383
+ console.log(`✓ statusline wrapper refreshed (${wrapperPath}${orig ? `, wraps: ${orig}` : ', minimal line'})`);
1384
+ else if (orig)
1385
+ console.log(`✓ statusline wrapped → ${wrapperPath} (appends 🧷 board to: ${orig})`);
1386
+ else
1387
+ console.log(`✓ statusline installed → ${wrapperPath} (minimal: dir │ branch │ 🧷 board │ model)`);
1388
+ }
1389
+ if (changed)
1390
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
1391
+ console.log(`install-claude done — restart Claude Code sessions to pick up ${settingsPath}`);
1392
+ }
961
1393
  // ---------- snap: one-shot capture → manifest → detached push ----------
962
1394
  // Filename slug: diacritics folded (Přihlášení → prihlaseni), non-alnum → '-', capped.
963
1395
  export function kebab(s) {
@@ -1258,6 +1690,13 @@ async function snapCmd(surface, args) {
1258
1690
  if (surface === 'ios' && iosSimName && !ctx.device?.device) {
1259
1691
  ctx.device = { ...ctx.device, device: iosSimName };
1260
1692
  }
1693
+ // Capture provenance: the mechanism that produced the pixels, the URL/deep
1694
+ // link the screen was opened at (--url wins over --open), and the app build.
1695
+ const tool = adopt ? 'adopted'
1696
+ : surface === 'ios' ? 'simctl'
1697
+ : surface === 'android' ? 'adb-screencap'
1698
+ : 'screencapture';
1699
+ const url = strArg(args.url) || openUrl;
1261
1700
  const { count } = appendShot(root, {
1262
1701
  file: `${day}/${name}`,
1263
1702
  surface,
@@ -1268,6 +1707,9 @@ async function snapCmd(surface, args) {
1268
1707
  action: strArg(args.action) || undefined,
1269
1708
  hq: hq || undefined,
1270
1709
  ts: new Date().toISOString(),
1710
+ tool,
1711
+ url: url || undefined,
1712
+ appVersion: appVersionOf(projectDir) || undefined,
1271
1713
  ...ctx,
1272
1714
  });
1273
1715
  maybeWarnOffline(root);
@@ -1629,10 +2071,28 @@ export const COMMANDS = [
1629
2071
  examples: ['vitrinka operator', 'vitrinka operator "Lukáš"'],
1630
2072
  },
1631
2073
  {
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' }],
2074
+ name: 'doctor', summary: 'Check this machine\'s setup: server reachable, write token valid, operator set',
2075
+ usage: '[--base <url>]', flags: [BASE_FLAG],
2076
+ examples: ['vitrinka doctor'],
2077
+ },
2078
+ {
2079
+ name: 'install', summary: 'Install the PATH shim (+ optional shell completion + Claude Code UI wiring)',
2080
+ usage: '[--no-completion] [--claude|--no-claude]',
2081
+ flags: [{ f: '--no-completion', d: 'skip appending the shell-completion line' },
2082
+ { f: '--claude', d: 'wire the Claude Code UI without asking' },
2083
+ { f: '--no-claude', d: 'skip the Claude Code UI wiring (default when non-interactive)' }],
1634
2084
  examples: ['vitrinka install'],
1635
2085
  },
2086
+ {
2087
+ name: 'install-claude', summary: 'Wire the Claude Code UI: 🧷 board statusline segment + clickable footer badge',
2088
+ usage: '', flags: [],
2089
+ examples: ['vitrinka install-claude'],
2090
+ },
2091
+ {
2092
+ name: 'install-skills', summary: 'Install the bundled Claude skills (vitrinka, screenshot, artifact, brainstorming)',
2093
+ usage: '[--local]', flags: [{ f: '--local', d: 'install into ./.claude/skills of the current project instead of ~/.claude/skills' }],
2094
+ examples: ['vitrinka install-skills'],
2095
+ },
1636
2096
  {
1637
2097
  name: 'snap', summary: 'Capture a screenshot → manifest → detached push',
1638
2098
  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 +2102,7 @@ export const COMMANDS = [
1642
2102
  { f: '--udid', arg: '<u>', d: 'iOS simulator UDID' }, { f: '--region', arg: 'x,y,w,h', d: 'macOS capture region' },
1643
2103
  { f: '--open', arg: '<deeplink>', d: 'open a deep link, then settle before capturing' },
1644
2104
  { f: '--settle', arg: '<seconds>', d: 'wait after --open (default 4)' },
2105
+ { f: '--url', arg: '<url>', d: 'exact URL the shot was taken at (provenance; defaults to --open)' },
1645
2106
  { f: '--hq', d: 'full-fidelity marketing asset: no lossy re-encode, no resize (lossless WebP only if smaller)' }],
1646
2107
  examples: ['vitrinka snap ios --route /home --note "landing"', 'vitrinka snap web --route / --note "hero" --hq'],
1647
2108
  },
@@ -1673,6 +2134,20 @@ export const COMMANDS = [
1673
2134
  dynamic: 'projects',
1674
2135
  examples: ['vitrinka index --root .screenshots'],
1675
2136
  },
2137
+ {
2138
+ name: 'index-components', summary: 'Scan a UI library for exported components + push the component index',
2139
+ usage: '--library <kit> [--src <dirs>] [--project <p>] [--from <file.json>] [--dry] [--base <url>]',
2140
+ flags: [
2141
+ { f: '--library', arg: '<kit>', d: 'UI kit name, one per library in a monorepo (e.g. "web", "expo") — required' },
2142
+ { f: '--src', arg: '<dirs>', d: 'comma-separated dirs to scan (default "src/components,components,src")' },
2143
+ { f: '--project', arg: '<p>', d: 'project slug (default: worktree name)' },
2144
+ { f: '--from', arg: '<file>', d: 'push a prepared components JSON (the AI pre-index output) instead of scanning' },
2145
+ { f: '--dry', d: 'print the scan as JSON, push nothing (feed this to the AI pre-index pass)' },
2146
+ BASE_FLAG
2147
+ ],
2148
+ examples: ['vitrinka index-components --library web --dry > components.json',
2149
+ 'vitrinka index-components --library web --from components.json'],
2150
+ },
1676
2151
  {
1677
2152
  name: 'embed-shots', summary: 'Embed manifest shots as resized JPEG data URIs → data.json',
1678
2153
  usage: '[--root <dir>] [--select 1,3-5] [--size 720] [--quality 85] --out <dir>/data.json',
@@ -2180,7 +2655,7 @@ async function boardFromSetCmd(root, args) {
2180
2655
  if (actor)
2181
2656
  headers['X-Board-Actor'] = actor;
2182
2657
  const create = await fetch(`${cfg.base}/api/v1/boards`, {
2183
- method: 'POST', headers, body: JSON.stringify({ slug, title }),
2658
+ method: 'POST', headers, body: JSON.stringify({ slug, title, project: cfg.project }),
2184
2659
  signal: AbortSignal.timeout(30_000),
2185
2660
  });
2186
2661
  if (!create.ok && create.status !== 409) { // 409 = board exists → reuse
@@ -2468,8 +2943,14 @@ async function runSub(argv) {
2468
2943
  await nameCmd(argv, args);
2469
2944
  else if (cmd === 'operator')
2470
2945
  await operatorCmd(argv, args);
2946
+ else if (cmd === 'doctor')
2947
+ await doctorCmd(args);
2471
2948
  else if (cmd === 'install')
2472
2949
  installCmd(args);
2950
+ else if (cmd === 'install-claude')
2951
+ installClaudeCmd(args);
2952
+ else if (cmd === 'install-skills')
2953
+ installSkillsCmd(args);
2473
2954
  else if (cmd === 'snap')
2474
2955
  await snapCmd(rest[0] && !rest[0].startsWith('--') ? rest[0] : '', args);
2475
2956
  else if (cmd === 'board-from-set')
@@ -2478,6 +2959,8 @@ async function runSub(argv) {
2478
2959
  await watchCmd(args);
2479
2960
  else if (cmd === 'index')
2480
2961
  await indexCmd(root, args);
2962
+ else if (cmd === 'index-components')
2963
+ await indexComponentsCmd(root, args);
2481
2964
  else if (cmd === 'embed-shots')
2482
2965
  embedShotsCmd(root, args);
2483
2966
  else if (cmd === 'artifact-init')