@lovinka/vitrinka 1.5.0 → 1.7.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,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, cpSync, openSync, readSync, closeSync, } from 'node:fs';
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync, copyFileSync, mkdtempSync, chmodSync, appendFileSync, statSync, cpSync, openSync, readSync, closeSync, realpathSync, } 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';
@@ -111,6 +111,10 @@ function buildIndex(root) {
111
111
  const m = loadManifest(root);
112
112
  const project = basename(dirname(root)) || 'project';
113
113
  const htmlPath = htmlPathOf(root);
114
+ // A fresh/nonexistent root builds an empty gallery — mirror saveManifest's
115
+ // dir creation instead of crashing with a raw ENOENT.
116
+ mkdirSync(root, { recursive: true });
117
+ ensureRootSelfIgnored(root);
114
118
  writeFileSync(htmlPath, renderHtml(m.shots, project));
115
119
  return { count: m.shots.length, htmlPath };
116
120
  }
@@ -756,12 +760,45 @@ function loadVitrinkaCfg(root) {
756
760
  }
757
761
  }
758
762
  function shareUrl(cfg) {
759
- return `${cfg.base}/${cfg.project}/${cfg.slug}/${cfg.key}`;
763
+ const ws = cfg.workspace ? `/w/${cfg.workspace}` : '';
764
+ return `${cfg.base}${ws}/${cfg.project}/${cfg.slug}/${cfg.key}`;
765
+ }
766
+ // resolveWorkspace resolves the multitenant workspace slug for DISPLAY URLs.
767
+ // Precedence: --workspace flag > $VITRINKA_WORKSPACE > GET {base}/api/v1/whoami
768
+ // (token-routed, the token IS the workspace) > "" (single-tenant). whoami is a
769
+ // pure display-URL nicety — any failure (offline, older server, non-JSON) yields
770
+ // "" and must never fail the command that called it.
771
+ async function resolveWorkspace(args, base) {
772
+ const flag = strArg(args.workspace);
773
+ if (flag)
774
+ return flag;
775
+ if (process.env.VITRINKA_WORKSPACE)
776
+ return process.env.VITRINKA_WORKSPACE;
777
+ try {
778
+ const headers = {};
779
+ const token = readToken();
780
+ if (token)
781
+ headers.Authorization = `Bearer ${token}`;
782
+ const res = await fetch(`${base}/api/v1/whoami`, { headers, signal: AbortSignal.timeout(15_000) });
783
+ if (!res.ok)
784
+ return '';
785
+ return String((await res.json()).workspace || '');
786
+ }
787
+ catch {
788
+ // Best-effort: unreachable/older server (no whoami) ⇒ single-tenant display URLs.
789
+ return '';
790
+ }
760
791
  }
761
- function remoteInitCmd(root, args) {
792
+ async function remoteInitCmd(root, args) {
762
793
  if (existsSync(vitrinkaCfgPath(root))) {
763
794
  // Sticky per session dir — reuse, so re-activation never forks the set.
764
795
  const cfg = loadVitrinkaCfg(root);
796
+ // Older descriptors predate the workspace field — resolve + persist once so
797
+ // display URLs carry /w/<ws> without a whoami call on every re-activation.
798
+ if (cfg.workspace === undefined) {
799
+ cfg.workspace = await resolveWorkspace(args, cfg.base);
800
+ writeFileSync(vitrinkaCfgPath(root), JSON.stringify(cfg, null, 2) + '\n');
801
+ }
765
802
  console.log(`vitrinka set (existing) → ${shareUrl(cfg)}`);
766
803
  return cfg;
767
804
  }
@@ -791,6 +828,7 @@ function remoteInitCmd(root, args) {
791
828
  kind: args.kind && args.kind !== true ? String(args.kind) : 'screenshots',
792
829
  issue: args.issue && args.issue !== true ? String(args.issue) : '',
793
830
  };
831
+ cfg.workspace = await resolveWorkspace(args, base);
794
832
  mkdirSync(root, { recursive: true });
795
833
  ensureRootSelfIgnored(root);
796
834
  writeFileSync(vitrinkaCfgPath(root), JSON.stringify(cfg, null, 2) + '\n');
@@ -978,6 +1016,86 @@ async function nameCmd(rest, args) {
978
1016
  else
979
1017
  console.log(`named ${segs[0]}/${segs[1]}/${segs[2]} → ${base}/${segs[0]}/${segs[1]}/${encodeURIComponent(newName)}`);
980
1018
  }
1019
+ // ---------- login: the CLI device dance ----------
1020
+ // loginCmd — `vitrinka login`: the multitenant sign-in. Starts a device-code
1021
+ // request, opens the browser approval page (the signed-in user confirms the
1022
+ // code and picks a workspace), polls until the server parks the minted PAT,
1023
+ // and saves it to ~/.config/vitrinka/token. On single-tenant/mesh installs
1024
+ // (no /api/v1/cli/auth route) it explains the shared-token path instead.
1025
+ async function loginCmd(args) {
1026
+ const base = (strArg(args.base) || process.env.VITRINKA_URL || process.env.VITRINKA_BASE_URL
1027
+ || 'https://vitrinka.lovinka.com').replace(/\/+$/, '');
1028
+ let start;
1029
+ try {
1030
+ start = await fetch(`${base}/api/v1/cli/auth`, { method: 'POST', signal: AbortSignal.timeout(15_000) });
1031
+ }
1032
+ catch (e) {
1033
+ fail(`login: ${base} unreachable (${e instanceof Error ? e.message : String(e)})`);
1034
+ return;
1035
+ }
1036
+ if (start.status === 404) {
1037
+ fail(`login: ${base} has no CLI sign-in (single-tenant install?) — use the shared token instead:\n\n${tokenHelp()}`);
1038
+ return;
1039
+ }
1040
+ if (!start.ok)
1041
+ fail(`login: ${base} refused (${start.status})`);
1042
+ const s = (await start.json());
1043
+ const verifyUrl = `${base}${s.verify_path}`;
1044
+ console.log('');
1045
+ console.log(` Confirm this code in your browser: ${s.user_code}`);
1046
+ console.log(` ${verifyUrl}`);
1047
+ console.log('');
1048
+ // Best-effort browser launch; the printed URL is the real contract.
1049
+ const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
1050
+ spawnSync(opener, [verifyUrl], { stdio: 'ignore' });
1051
+ const intervalMs = Math.max(1, s.interval ?? 2) * 1000;
1052
+ const deadline = Date.now() + (s.expires_in ?? 600) * 1000;
1053
+ process.stdout.write(' waiting for approval …');
1054
+ while (Date.now() < deadline) {
1055
+ await new Promise((f) => setTimeout(f, intervalMs));
1056
+ let res;
1057
+ try {
1058
+ res = await fetch(`${base}/api/v1/cli/auth/claim`, {
1059
+ method: 'POST',
1060
+ headers: { 'Content-Type': 'application/json' },
1061
+ body: JSON.stringify({ device_code: s.device_code }),
1062
+ signal: AbortSignal.timeout(15_000),
1063
+ });
1064
+ }
1065
+ catch {
1066
+ process.stdout.write('.'); // transient network blip — keep polling
1067
+ continue;
1068
+ }
1069
+ if (res.status === 202) {
1070
+ process.stdout.write('.');
1071
+ continue;
1072
+ }
1073
+ if (!res.ok) {
1074
+ console.log('');
1075
+ fail(`login: ${res.status === 404 ? 'code expired — run vitrinka login again' : `claim failed (${res.status})`}`);
1076
+ }
1077
+ const { token } = (await res.json());
1078
+ const p = tokenPath();
1079
+ mkdirSync(dirname(p), { recursive: true });
1080
+ writeFileSync(p, token + '\n', { mode: 0o600 });
1081
+ console.log('\n');
1082
+ // The token IS the workspace — show which one this machine now acts in.
1083
+ try {
1084
+ const who = await fetch(`${base}/api/v1/whoami`, {
1085
+ headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(15_000),
1086
+ });
1087
+ const w = (await who.json());
1088
+ console.log(` ✓ signed in — token saved to ${p}${w.workspace ? ` (workspace: ${w.workspace})` : ''}`);
1089
+ }
1090
+ catch {
1091
+ console.log(` ✓ signed in — token saved to ${p}`);
1092
+ }
1093
+ console.log(' next: vitrinka install (skills + MCP + shim, if not done yet)');
1094
+ return;
1095
+ }
1096
+ console.log('');
1097
+ fail('login: approval timed out (10 min) — run vitrinka login again');
1098
+ }
981
1099
  // ---------- operator: the board-attribution persona ----------
982
1100
  // operatorCmd manages the operator persona name — how vitrinka credits this
983
1101
  // machine's writes on boards.
@@ -1100,6 +1218,53 @@ async function doctorCmd(args) {
1100
1218
  console.log(`✓ operator ${op}`);
1101
1219
  else
1102
1220
  console.log('✗ operator unset — board writes stay anonymous (fix: vitrinka operator <name>)');
1221
+ // Setup surfaces (skills / shim / MCP / Claude UI) — informational rows, and
1222
+ // `--fix` repairs everything repairable in place. Token/operator stay manual
1223
+ // (they're credentials/identity, not installation).
1224
+ const fix = args.fix === true;
1225
+ const home = process.env.HOME || '';
1226
+ const bundle = bundledSkillsDir(CLI_PATH);
1227
+ if (bundle && home) {
1228
+ const st = skillsStateOf(bundle, join(home, '.claude', 'skills'));
1229
+ if (st === 'current')
1230
+ console.log('✓ skills current');
1231
+ else if (fix)
1232
+ installSkillsCmd(args);
1233
+ else
1234
+ console.log(`✗ skills ${st} — repair: vitrinka doctor --fix`);
1235
+ }
1236
+ if (home) {
1237
+ const shimP = shimPathOf(home);
1238
+ const shimOk = existsSync(shimP) && readFileSync(shimP, 'utf8') === SHIM_CONTENT;
1239
+ if (shimOk)
1240
+ console.log(`✓ shim ${shimP}`);
1241
+ else if (fix) {
1242
+ mkdirSync(dirname(shimP), { recursive: true });
1243
+ writeFileSync(shimP, SHIM_CONTENT);
1244
+ chmodSync(shimP, 0o755);
1245
+ console.log(`✓ shim rewritten → ${shimP}`);
1246
+ }
1247
+ else
1248
+ console.log(`✗ shim ${existsSync(shimP) ? 'stale (points at another CLI copy)' : 'missing'} — repair: vitrinka doctor --fix`);
1249
+ }
1250
+ if (claudeCliPresent()) {
1251
+ if (mcpRegistered())
1252
+ console.log('✓ mcp registered');
1253
+ else if (fix) {
1254
+ if (registerMcp('user'))
1255
+ console.log('✓ mcp re-registered (--scope user)');
1256
+ else
1257
+ console.log('✗ mcp re-registration failed — see output above');
1258
+ }
1259
+ else
1260
+ console.log('✗ mcp not registered — repair: vitrinka doctor --fix (or vitrinka install)');
1261
+ }
1262
+ if (home && existsSync(join(home, '.claude', 'vitrinka-statusline.sh'))) {
1263
+ if (fix)
1264
+ installClaudeCmd(args);
1265
+ else
1266
+ console.log('✓ claude ui wired (refresh: vitrinka doctor --fix)');
1267
+ }
1103
1268
  if (failed)
1104
1269
  process.exit(1);
1105
1270
  }
@@ -1116,9 +1281,9 @@ export function bundledSkillsDir(cliPath) {
1116
1281
  }
1117
1282
  // installSkillsCmd copies the packaged skills bundle (vitrinka + listen,
1118
1283
  // 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.
1284
+ // ~/.claude/skills by default, ./.claude/skills with --local. `npm i -g
1285
+ // @lovinka/vitrinka && vitrinka install-skills` fully equips a machine
1286
+ // without a repo checkout.
1122
1287
  function installSkillsCmd(args) {
1123
1288
  const bundle = bundledSkillsDir(CLI_PATH);
1124
1289
  if (!bundle)
@@ -1129,8 +1294,8 @@ function installSkillsCmd(args) {
1129
1294
  fail('install-skills: $HOME is not set (or pass --local)');
1130
1295
  const target = local ? resolve('.claude', 'skills') : join(home, '.claude', 'skills');
1131
1296
  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).
1297
+ // Whole directories: a skill may nest its SKILL.md (the vitrinka skill
1298
+ // keeps it under listen/ next to its CLI + tests).
1134
1299
  const names = readdirSync(bundle).filter((n) => !n.startsWith('.') && statSync(join(bundle, n)).isDirectory());
1135
1300
  if (!names.length)
1136
1301
  fail(`install-skills: no skills found in ${bundle}`);
@@ -1163,80 +1328,383 @@ function installSkillsCmd(args) {
1163
1328
  }
1164
1329
  console.log(`${names.length} skill${names.length === 1 ? '' : 's'} installed (${names.sort().join(', ')})`);
1165
1330
  }
1166
- // installCmd writes an executable shim `vitrinka` into ~/.local/bin (so the
1167
- // CLI works in interactive shells AND non-interactive AI/tool shells, which a
1168
- // zshrc alias would not) and makes sure ~/.local/bin is on PATH via a
1169
- // marker-guarded ~/.zshrc block. Idempotent: re-running reports and no-ops.
1170
- function installCmd(args) {
1331
+ // ---------- setup state probing (shared by install / doctor / uninstall) ----------
1332
+ // dirsEqual recursive byte comparison, the node twin of install.sh's `diff -rq`.
1333
+ function dirsEqual(a, b) {
1334
+ const names = new Set([...readdirSync(a), ...readdirSync(b)]);
1335
+ for (const n of names) {
1336
+ const pa = join(a, n), pb = join(b, n);
1337
+ if (!existsSync(pa) || !existsSync(pb))
1338
+ return false;
1339
+ const da = statSync(pa).isDirectory(), db = statSync(pb).isDirectory();
1340
+ if (da !== db)
1341
+ return false;
1342
+ if (da) {
1343
+ if (!dirsEqual(pa, pb))
1344
+ return false;
1345
+ }
1346
+ else if (!readFileSync(pa).equals(readFileSync(pb)))
1347
+ return false;
1348
+ }
1349
+ return true;
1350
+ }
1351
+ // skillsStateOf — the bundled skills' freshness at a target skills dir. Local
1352
+ // installs rewrite CLI paths after copy, so a fresh local copy reads "outdated"
1353
+ // against the bundle — harmless (refresh is idempotent), same as install.sh.
1354
+ function skillsStateOf(bundle, target) {
1355
+ const names = readdirSync(bundle).filter((n) => !n.startsWith('.') && statSync(join(bundle, n)).isDirectory());
1356
+ if (names.some((n) => !existsSync(join(target, n))))
1357
+ return 'missing';
1358
+ return names.every((n) => dirsEqual(join(bundle, n), join(target, n))) ? 'current' : 'outdated';
1359
+ }
1360
+ const SHIM_CONTENT = `#!/bin/sh\nexec node "${CLI_PATH}" "$@"\n`;
1361
+ function shimPathOf(home) { return join(home, '.local', 'bin', 'vitrinka'); }
1362
+ function rcPathOf(home) {
1363
+ return join(home, (process.env.SHELL || '').includes('bash') ? '.bashrc' : '.zshrc');
1364
+ }
1365
+ // claudeCliPresent / mcpRegistered / registerMcp — the `claude mcp` touchpoints.
1366
+ // The MCP target mirrors updateCmd's repo-vs-npm detection: a checkout registers
1367
+ // the repo's stdio shim (a `git pull` IS the update), an npm install registers
1368
+ // the published package via npx.
1369
+ function claudeCliPresent() {
1370
+ return !spawnSync('claude', ['--version'], { stdio: 'ignore' }).error;
1371
+ }
1372
+ function mcpRegistered() {
1373
+ const r = spawnSync('claude', ['mcp', 'list'], { encoding: 'utf8' });
1374
+ return r.status === 0 && /\bvitrinka\b/.test(r.stdout);
1375
+ }
1376
+ function mcpTarget() {
1377
+ const repoTop = git(['rev-parse', '--show-toplevel'], dirname(CLI_PATH));
1378
+ const shim = repoTop ? join(repoTop, 'mcp', 'server.ts') : '';
1379
+ if (shim && existsSync(shim))
1380
+ return ['node', shim];
1381
+ return ['npx', '-y', '--package=@lovinka/vitrinka', 'vitrinka-mcp'];
1382
+ }
1383
+ function registerMcp(scope) {
1384
+ spawnSync('claude', ['mcp', 'remove', '--scope', scope, 'vitrinka'], { stdio: 'ignore' });
1385
+ return spawnSync('claude', ['mcp', 'add', '--scope', scope, 'vitrinka', '--', ...mcpTarget()], { stdio: 'inherit' }).status === 0;
1386
+ }
1387
+ // promptLine — visible one-line read from the controlling TTY ('' when
1388
+ // non-interactive, like promptYesNo).
1389
+ function promptLine(question) {
1390
+ if (!process.stdin.isTTY)
1391
+ return '';
1392
+ process.stdout.write(question);
1393
+ try {
1394
+ const fd = openSync('/dev/tty', 'r');
1395
+ const buf = Buffer.alloc(512);
1396
+ const n = readSync(fd, buf, 0, 512, null);
1397
+ closeSync(fd);
1398
+ return buf.toString('utf8', 0, n).trim();
1399
+ }
1400
+ catch {
1401
+ return ''; // no controlling TTY — treat as non-interactive, never block
1402
+ }
1403
+ }
1404
+ // promptSecret — promptLine with terminal echo off (token input, the old
1405
+ // install.sh's `read -rs`). Degrades to a visible read if stty is unavailable.
1406
+ function promptSecret(question) {
1407
+ if (!process.stdin.isTTY)
1408
+ return '';
1409
+ const echoOff = spawnSync('stty', ['-echo'], { stdio: 'inherit' }).status === 0;
1410
+ const val = promptLine(question);
1411
+ if (echoOff) {
1412
+ spawnSync('stty', ['echo'], { stdio: 'inherit' });
1413
+ process.stdout.write('\n'); // the suppressed Enter
1414
+ }
1415
+ return val;
1416
+ }
1417
+ // ---------- install: status-first onboarding ----------
1418
+ //
1419
+ // The one-command machine setup (decisions: docs/specs/2026-07-08-cli-onboarding-
1420
+ // decisions.md — this replaced the deleted install.sh). Status-first: print a
1421
+ // ✓/✗ table of every component, silently install the harmless ones (skills,
1422
+ // shim, PATH, completion — own dirs, fully reversible), then ask ONLY for the
1423
+ // missing config-touchers (MCP registration, write token, operator persona,
1424
+ // Claude UI wiring). Re-runs are near-silent. Non-interactive runs (CI,
1425
+ // curl|bash) skip every prompt and print the manual command instead.
1426
+ async function installCmd(args) {
1171
1427
  const home = process.env.HOME;
1172
1428
  if (!home)
1173
1429
  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))
1430
+ const local = args.local === true;
1431
+ const base = resolveBaseUrl(strArg(args.base));
1432
+ const mcpScope = local ? 'project' : 'user';
1433
+ // ---- probe (before touching anything) ----
1434
+ const bundle = bundledSkillsDir(CLI_PATH);
1435
+ const skillsTarget = local ? resolve('.claude', 'skills') : join(home, '.claude', 'skills');
1436
+ const skills = bundle ? skillsStateOf(bundle, skillsTarget) : 'no-bundle';
1437
+ const shimP = shimPathOf(home);
1438
+ const shimOk = existsSync(shimP) && readFileSync(shimP, 'utf8') === SHIM_CONTENT;
1439
+ const rc = rcPathOf(home);
1440
+ const completionOk = existsSync(rc) && readFileSync(rc, 'utf8').includes('# vitrinka-completion');
1441
+ const haveClaude = claudeCliPresent();
1442
+ const mcpOk = haveClaude ? mcpRegistered() : false;
1443
+ const tokenSrc = tokenSource();
1444
+ const op = readOperator(true);
1445
+ const wrapperP = join(home, '.claude', 'vitrinka-statusline.sh');
1446
+ const uiOk = existsSync(wrapperP);
1447
+ const row = (ok, label, detail) => {
1448
+ console.log(` ${ok ? '✓' : '✗'} ${label.padEnd(11)}${detail}`);
1449
+ };
1450
+ console.log(`vitrinka setup — ${base}${local ? ' (local scope)' : ''}`);
1451
+ row(skills === 'current', 'skills', skills === 'no-bundle' ? 'bundle not found next to the CLI (reinstall the package)'
1452
+ : skills === 'current' ? `current → ${skillsTarget}` : `${skills} → will refresh`);
1453
+ if (!local)
1454
+ row(shimOk, 'shim', shimOk ? shimP : 'will install');
1455
+ if (!local)
1456
+ row(completionOk, 'completion', completionOk ? rc : args['no-completion'] === true ? 'skipped (--no-completion)' : 'will enable');
1457
+ row(mcpOk, 'mcp', mcpOk ? 'registered' : haveClaude ? `not registered (scope ${mcpScope})` : 'claude CLI not found');
1458
+ row(tokenSrc !== 'missing', 'token', tokenSrc === 'missing' ? 'missing — writes will 401' : `present (${tokenSrc === 'env' ? '$VITRINKA_TOKEN' : tokenPath()})`);
1459
+ row(!!op, 'operator', op || 'unset — board writes stay anonymous');
1460
+ row(uiOk, 'claude ui', uiOk ? 'statusline + footer wired' : 'not wired');
1461
+ console.log('');
1462
+ // ---- silent steps: skills, shim, PATH, completion (reversible, own dirs) ----
1463
+ if (bundle && skills !== 'current')
1178
1464
  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)');
1181
- const binDir = join(home, '.local', 'bin');
1182
- const shimPath = join(binDir, 'vitrinka');
1183
- const shim = `#!/bin/sh\nexec node "${CLI_PATH}" "$@"\n`;
1184
- if (existsSync(shimPath) && readFileSync(shimPath, 'utf8') === shim) {
1185
- console.log(`shim already installed: ${shimPath}`);
1465
+ if (!local) {
1466
+ if (!shimOk) {
1467
+ mkdirSync(dirname(shimP), { recursive: true });
1468
+ writeFileSync(shimP, SHIM_CONTENT);
1469
+ chmodSync(shimP, 0o755);
1470
+ console.log(`✓ shim ${shimP}`);
1471
+ }
1472
+ const binDir = dirname(shimP);
1473
+ const marker = '# vitrinka-cli';
1474
+ const zshrc = join(home, '.zshrc');
1475
+ const zshrcContent = existsSync(zshrc) ? readFileSync(zshrc, 'utf8') : '';
1476
+ if (!zshrcContent.includes(marker) && !(process.env.PATH || '').split(':').includes(binDir)) {
1477
+ appendFileSync(zshrc, `\n${marker}\nexport PATH="$HOME/.local/bin:$PATH"\n`);
1478
+ console.log(`✓ PATH block → ${zshrc} (open a new shell, or: source ${zshrc})`);
1479
+ }
1480
+ if (args['no-completion'] !== true && !completionOk) {
1481
+ const shellName = rc.endsWith('.bashrc') ? 'bash' : 'zsh';
1482
+ appendFileSync(rc, `\n# vitrinka-completion\neval "$(vitrinka completion ${shellName})"\n`);
1483
+ console.log(`✓ ${shellName} completion → ${rc}`);
1484
+ }
1485
+ }
1486
+ // ---- asked steps: the config-touchers ----
1487
+ // 1. MCP registration (claude's registry).
1488
+ const mcpCmdHint = `claude mcp add --scope ${mcpScope} vitrinka -- ${mcpTarget().join(' ')}`;
1489
+ if (!haveClaude) {
1490
+ console.error(`⚠ claude CLI not found — register the MCP later with:\n ${mcpCmdHint}`);
1491
+ }
1492
+ else if (!mcpOk) {
1493
+ if (args['no-mcp'] === true)
1494
+ console.log('mcp: skipped (--no-mcp)');
1495
+ else if (args.mcp === true || promptYesNo(`Register the vitrinka MCP for Claude Code (scope ${mcpScope})? [Y/n] `)) {
1496
+ if (registerMcp(mcpScope))
1497
+ console.log(`✓ mcp registered (--scope ${mcpScope})`);
1498
+ else
1499
+ console.error(`⚠ mcp registration failed — retry with:\n ${mcpCmdHint}`);
1500
+ }
1501
+ else {
1502
+ console.log(`mcp: skipped — register later with:\n ${mcpCmdHint}`);
1503
+ }
1504
+ }
1505
+ // 2. Write token (reads work without one; pushes/board writes 401).
1506
+ if (tokenSrc === 'missing') {
1507
+ if (process.stdin.isTTY) {
1508
+ console.log('No write token yet — reads work without one, writes 401. It is the');
1509
+ console.log('server\'s VITRINKA_TOKEN (one shared secret — copy it from a configured');
1510
+ console.log('machine or the server admin; details: vitrinka doctor).');
1511
+ const tok = promptSecret('Write token (Enter to skip): ');
1512
+ if (tok) {
1513
+ mkdirSync(dirname(tokenPath()), { recursive: true });
1514
+ writeFileSync(tokenPath(), tok + '\n');
1515
+ chmodSync(tokenPath(), 0o600);
1516
+ console.log(`✓ token → ${tokenPath()}`);
1517
+ }
1518
+ else {
1519
+ console.error(`⚠ no token — writes will 401 until you add one (${tokenPath()})`);
1520
+ }
1521
+ }
1522
+ else {
1523
+ console.error('⚠ no token and non-interactive — writes will 401 until you add one:');
1524
+ console.error(tokenHelp());
1525
+ }
1186
1526
  }
1187
- else {
1188
- mkdirSync(binDir, { recursive: true });
1189
- writeFileSync(shimPath, shim);
1190
- chmodSync(shimPath, 0o755);
1191
- console.log(`installed shim: ${shimPath} → node ${CLI_PATH}`);
1527
+ // 3. Operator persona (board attribution) — same engine as `vitrinka operator`.
1528
+ const opArg = strArg(args.operator);
1529
+ if (opArg) {
1530
+ await operatorCmd(['operator', opArg], args);
1192
1531
  }
1193
- const marker = '# vitrinka-cli';
1194
- const zshrc = join(home, '.zshrc');
1195
- const zshrcContent = existsSync(zshrc) ? readFileSync(zshrc, 'utf8') : '';
1196
- if (zshrcContent.includes(marker)) {
1197
- console.log(`PATH block already in ${zshrc}`);
1532
+ else if (!op) {
1533
+ const name = promptLine('Operator name — how should vitrinka credit you on boards? (Enter to skip): ');
1534
+ if (name)
1535
+ await operatorCmd(['operator', name], args);
1536
+ else
1537
+ console.error('⚠ no operator — board writes stay anonymous (set later: vitrinka operator <name>)');
1198
1538
  }
1199
- else if ((process.env.PATH || '').split(':').includes(binDir)) {
1200
- console.log(`${binDir} already on PATH ${zshrc} left untouched`);
1539
+ // 4. Claude Code UI wiring (edits ~/.claude/settings.json). Already-wired
1540
+ // machines refresh silently (consent was given once); fresh ones are asked.
1541
+ if (args['no-claude'] === true) {
1542
+ console.log('claude ui: skipped (--no-claude)');
1201
1543
  }
1202
- else {
1203
- appendFileSync(zshrc, `\n${marker}\nexport PATH="$HOME/.local/bin:$PATH"\n`);
1204
- console.log(`added PATH block to ${zshrc} (open a new shell, or: source ${zshrc})`);
1544
+ else if (uiOk || args.claude === true
1545
+ || promptYesNo('Wire Claude Code UI (🧷 board statusline segment + clickable footer badge)? [Y/n] ')) {
1546
+ installClaudeCmd(args);
1205
1547
  }
1206
- // Shell completion — state-aware, opt-out via --no-completion. Appends a
1207
- // single guarded `eval "$(vitrinka completion zsh)"` line (skipped if present).
1208
- if (args['no-completion'] === true) {
1209
- console.log('completion: skipped (--no-completion)');
1548
+ else if (!process.stdin.isTTY) {
1549
+ console.log('claude ui: skipped (non-interactive run `vitrinka install-claude`, or pass --claude)');
1210
1550
  }
1211
1551
  else {
1212
- const compMarker = '# vitrinka-completion';
1213
- const shellName = (process.env.SHELL || '').includes('bash') ? 'bash' : 'zsh';
1214
- const rc = join(home, shellName === 'bash' ? '.bashrc' : '.zshrc');
1215
- const rcContent = existsSync(rc) ? readFileSync(rc, 'utf8') : '';
1216
- if (rcContent.includes(compMarker)) {
1217
- console.log(`completion already enabled in ${rc}`);
1552
+ console.log('claude ui: skipped (re-run any time: vitrinka install-claude)');
1553
+ }
1554
+ // ---- environment checks (non-fatal) ----
1555
+ if (spawnSync('jq', ['--version'], { stdio: 'ignore' }).error) {
1556
+ console.error('⚠ jq missing (brew install jq) — the statusline 🧷 board segment won\'t render without it');
1557
+ }
1558
+ if (spawnSync('cwebp', ['-version'], { stdio: 'ignore' }).error) {
1559
+ console.error('⚠ cwebp missing (brew install webp) — snap falls back to PNG');
1560
+ }
1561
+ try {
1562
+ const res = await fetch(`${base}/healthz`, { signal: AbortSignal.timeout(5_000) });
1563
+ if (!res.ok)
1564
+ console.error(`⚠ ${base} answered HTTP ${res.status}`);
1565
+ }
1566
+ catch {
1567
+ console.error(`⚠ ${base} unreachable — vitrinka is WireGuard-mesh-only; bring the VPN up`);
1568
+ }
1569
+ console.log('');
1570
+ console.log('done — health check any time with: vitrinka doctor');
1571
+ }
1572
+ // ---------- uninstall: clean offboarding ----------
1573
+ // stripMarkerBlock removes a marker comment line AND the single payload line
1574
+ // after it (the exact shape install writes), plus the blank separator install
1575
+ // prepended. PURE — unit-tested.
1576
+ export function stripMarkerBlock(content, marker) {
1577
+ const lines = content.split('\n');
1578
+ const out = [];
1579
+ for (let i = 0; i < lines.length; i++) {
1580
+ if (lines[i].trim() === marker) {
1581
+ if (out.length && out[out.length - 1] === '')
1582
+ out.pop(); // the blank install added
1583
+ i++; // skip the payload line too
1584
+ continue;
1218
1585
  }
1219
- else {
1220
- appendFileSync(rc, `\n${compMarker}\neval "$(vitrinka completion ${shellName})"\n`);
1221
- console.log(`added ${shellName} completion to ${rc} (open a new shell, or: source ${rc})`);
1586
+ out.push(lines[i]);
1587
+ }
1588
+ return out.join('\n');
1589
+ }
1590
+ // The bundled skill names — used by uninstall when the bundle isn't next to the
1591
+ // CLI anymore (e.g. the package was removed first).
1592
+ const BUNDLED_SKILLS = ['artifact', 'brainstorming', 'screenshot', 'vitrinka'];
1593
+ // uninstallCmd removes everything install put on the machine — the exact
1594
+ // inverse: bundled skills + the /vitrinka:listen stub, shim, rc marker blocks,
1595
+ // statusline wrapper (restoring the original statusLine command), the /boards/
1596
+ // footer badge entry, and the MCP registration. ~/.config/vitrinka (token +
1597
+ // operator) survives by default; --purge removes it too.
1598
+ function uninstallCmd(args) {
1599
+ const home = process.env.HOME;
1600
+ if (!home)
1601
+ fail('uninstall: $HOME is not set');
1602
+ const local = args.local === true;
1603
+ if (args.yes !== true && !promptYesNo(`Remove vitrinka from this machine (skills, shim, completion, Claude wiring, MCP${args.purge === true ? ', token+operator' : ''})? [Y/n] `)) {
1604
+ fail('uninstall: aborted (non-interactive runs need --yes)', 2);
1605
+ }
1606
+ // Skills + the /vitrinka:listen command stub.
1607
+ const bundle = bundledSkillsDir(CLI_PATH);
1608
+ const names = bundle
1609
+ ? readdirSync(bundle).filter((n) => !n.startsWith('.') && statSync(join(bundle, n)).isDirectory())
1610
+ : BUNDLED_SKILLS;
1611
+ const skillsDir = local ? resolve('.claude', 'skills') : join(home, '.claude', 'skills');
1612
+ const commandsDir = local ? resolve('.claude', 'commands', 'vitrinka') : join(home, '.claude', 'commands', 'vitrinka');
1613
+ for (const n of names) {
1614
+ if (!existsSync(join(skillsDir, n)))
1615
+ continue;
1616
+ rmSync(join(skillsDir, n), { recursive: true, force: true });
1617
+ console.log(`✓ removed skill ${n}`);
1618
+ }
1619
+ if (existsSync(join(commandsDir, 'listen.md'))) {
1620
+ rmSync(join(commandsDir, 'listen.md'));
1621
+ try {
1622
+ rmSync(commandsDir, { recursive: false });
1623
+ }
1624
+ catch { /* not empty — other files live there */ }
1625
+ console.log('✓ removed /vitrinka:listen command stub');
1626
+ }
1627
+ // Shim + rc marker blocks (global installs only — local never wrote them).
1628
+ const shimP = shimPathOf(home);
1629
+ if (existsSync(shimP)) {
1630
+ rmSync(shimP);
1631
+ console.log(`✓ removed shim ${shimP}`);
1632
+ }
1633
+ for (const [file, marker] of [
1634
+ [join(home, '.zshrc'), '# vitrinka-cli'],
1635
+ [join(home, '.zshrc'), '# vitrinka-completion'],
1636
+ [join(home, '.bashrc'), '# vitrinka-completion'],
1637
+ ]) {
1638
+ if (!existsSync(file))
1639
+ continue;
1640
+ const cur = readFileSync(file, 'utf8');
1641
+ const next = stripMarkerBlock(cur, marker);
1642
+ if (next !== cur) {
1643
+ writeFileSync(file, next);
1644
+ console.log(`✓ removed ${marker} block from ${file}`);
1222
1645
  }
1223
1646
  }
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)');
1647
+ // Claude UI wiring — restore statusLine from the wrapper's VITRINKA_ORIG,
1648
+ // drop the /boards/ footer entry, delete the wrapper.
1649
+ const settingsPath = join(home, '.claude', 'settings.json');
1650
+ const wrapperP = join(home, '.claude', 'vitrinka-statusline.sh');
1651
+ if (existsSync(settingsPath)) {
1652
+ let settings;
1653
+ try {
1654
+ settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
1655
+ }
1656
+ catch (e) {
1657
+ fail(`uninstall: ${settingsPath} is not valid JSON — nothing was changed there; fix it first (${e.message})`);
1658
+ }
1659
+ let changed = false;
1660
+ const sl = settings.statusLine;
1661
+ if (sl?.command && sl.command.replace(/^~(?=\/)/, home) === wrapperP) {
1662
+ const orig = existsSync(wrapperP) ? wrapperOrigCommand(readFileSync(wrapperP, 'utf8')) : '';
1663
+ if (orig)
1664
+ settings.statusLine = { type: 'command', command: orig };
1665
+ else
1666
+ delete settings.statusLine;
1667
+ changed = true;
1668
+ console.log(`✓ statusline restored${orig ? ` → ${orig}` : ' (removed — none was configured before)'}`);
1669
+ }
1670
+ const arr = settings.footerLinksRegexes;
1671
+ if (Array.isArray(arr)) {
1672
+ const kept = arr.filter((e) => !(typeof e?.pattern === 'string' && e.pattern.replaceAll('\\', '').includes('/boards/')));
1673
+ if (kept.length !== arr.length) {
1674
+ if (kept.length)
1675
+ settings.footerLinksRegexes = kept;
1676
+ else
1677
+ delete settings.footerLinksRegexes;
1678
+ changed = true;
1679
+ console.log('✓ footer badge entry removed');
1680
+ }
1681
+ }
1682
+ if (changed)
1683
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
1229
1684
  }
1230
- else if (args.claude === true || promptYesNo('Wire Claude Code UI (🧷 board statusline segment + clickable footer badge)? [Y/n] ')) {
1231
- installClaudeCmd(args);
1685
+ if (existsSync(wrapperP)) {
1686
+ rmSync(wrapperP);
1687
+ console.log(`✓ removed ${wrapperP}`);
1232
1688
  }
1233
- else if (!process.stdin.isTTY) {
1234
- console.log('claude wiring: skipped (non-interactive — run `vitrinka install-claude`, or pass --claude)');
1689
+ // MCP registration (both scopes — cheap, and remove is idempotent).
1690
+ if (claudeCliPresent()) {
1691
+ for (const scope of local ? ['project', 'user'] : ['user', 'project']) {
1692
+ spawnSync('claude', ['mcp', 'remove', '--scope', scope, 'vitrinka'], { stdio: 'ignore' });
1693
+ }
1694
+ console.log('✓ MCP registration removed');
1235
1695
  }
1236
- else {
1237
- console.log('claude wiring: skipped (re-run any time: vitrinka install-claude)');
1696
+ // Token + operator only with --purge — they're credentials, not installation.
1697
+ const cfgDir = join(home, '.config', 'vitrinka');
1698
+ if (args.purge === true) {
1699
+ if (existsSync(cfgDir)) {
1700
+ rmSync(cfgDir, { recursive: true, force: true });
1701
+ console.log(`✓ purged ${cfgDir}`);
1702
+ }
1703
+ }
1704
+ else if (existsSync(cfgDir)) {
1705
+ console.log(`token/operator kept at ${cfgDir} (remove with --purge)`);
1238
1706
  }
1239
- console.log('done — try: vitrinka --help');
1707
+ console.log('uninstall done');
1240
1708
  }
1241
1709
  // ---------- install-claude: Claude Code UI wiring ----------
1242
1710
  //
@@ -1274,8 +1742,9 @@ export function hasBoardFooterLink(settings) {
1274
1742
  // original command is recorded on the VITRINKA_ORIG line (single-quoted), both
1275
1743
  // to run it and so a re-run can recover it instead of wrapping the wrapper.
1276
1744
  // 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.
1745
+ // with {base, key[, workspace]} → ${base}[/w/${workspace}]/boards/${key}. Needs jq
1746
+ // (like the statusline examples in Claude's own docs); without jq it degrades to
1747
+ // pass-through.
1279
1748
  export function buildStatuslineWrapper(origCmd) {
1280
1749
  const quoted = origCmd.replaceAll("'", `'\\''`);
1281
1750
  return `#!/bin/bash
@@ -1306,7 +1775,9 @@ for VJSON in "$DIR/.screenshots/.vitrinka" "$PROJECT_DIR/.screenshots/.vitrinka"
1306
1775
  [ -f "$VJSON" ] || continue
1307
1776
  VBASE=$(jq -r '.base // empty' "$VJSON" 2>/dev/null)
1308
1777
  VKEY=$(jq -r '.key // empty' "$VJSON" 2>/dev/null)
1778
+ VWS=$(jq -r '.workspace // empty' "$VJSON" 2>/dev/null)
1309
1779
  if [ -n "$VBASE" ] && [ -n "$VKEY" ]; then
1780
+ [ -n "$VWS" ] && VBASE="$VBASE/w/$VWS"
1310
1781
  BOARD=$(printf '\\033[35m🧷\\033[0m \\033[2m%s\\033[0m' "$VBASE/boards/$VKEY")
1311
1782
  fi
1312
1783
  break
@@ -1421,6 +1892,251 @@ export function installClaudeCmd(_args) {
1421
1892
  }
1422
1893
  console.log(`install-claude done — restart Claude Code sessions to pick up ${settingsPath}`);
1423
1894
  }
1895
+ // ---------- open: jump to the live board ----------
1896
+ // boardUrlOf — the current capture root's live board URL ('' when the root has
1897
+ // no .vitrinka descriptor yet). Board slug == set key (board-from-set contract).
1898
+ function boardUrlOf(root) {
1899
+ if (!existsSync(vitrinkaCfgPath(root)))
1900
+ return '';
1901
+ const cfg = loadVitrinkaCfg(root);
1902
+ const ws = cfg.workspace ? `/w/${cfg.workspace}` : '';
1903
+ return `${cfg.base}${ws}/boards/${cfg.key}`;
1904
+ }
1905
+ // openCmd opens the thing you're working on: the capture root's live board when
1906
+ // one exists, else the vitrinka home. --qr renders the URL as a terminal QR
1907
+ // (scan with the iPad → annotate with the Pencil) and skips the local browser;
1908
+ // --print only prints.
1909
+ function openCmd(root, args) {
1910
+ const url = boardUrlOf(root) || resolveBaseUrl(strArg(args.base));
1911
+ console.log(url);
1912
+ if (args.qr === true) {
1913
+ // Zero-dep rule: we shell to qrencode rather than hand-roll a QR encoder.
1914
+ const r = spawnSync('qrencode', ['-t', 'ANSIUTF8', url], { stdio: 'inherit' });
1915
+ if (r.error)
1916
+ console.error('open: qrencode not found (brew install qrencode) — URL printed above');
1917
+ return; // QR targets another device — don't also grab the local browser
1918
+ }
1919
+ if (args.print === true)
1920
+ return;
1921
+ const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
1922
+ const r = spawnSync(opener, [url], { stdio: 'ignore' });
1923
+ if (r.error || r.status !== 0)
1924
+ console.error(`open: could not launch ${opener} — URL printed above`);
1925
+ }
1926
+ // ---------- status: the session dashboard ----------
1927
+ // statusCmd — one glance at this repo+branch's vitrinka world: the live board,
1928
+ // the open work queue (same scope derivation as `watch`), and who holds the
1929
+ // listener lease. Degrades row-by-row when the server is unreachable.
1930
+ async function statusCmd(root, args) {
1931
+ const base = resolveBaseUrl(strArg(args.base));
1932
+ const cwd = resolve('.');
1933
+ const project = sanitizeSeg(strArg(args.project) || basename(mainWorktreeTop(cwd) || cwd), 64);
1934
+ const branch = sanitizeSeg(strArg(args.branch) || git(['branch', '--show-current'], cwd) || 'main', 100);
1935
+ console.log(`vitrinka status — ${project}/${branch}`);
1936
+ const board = boardUrlOf(root);
1937
+ console.log(board ? ` board ${board}` : ' board none yet (publish one: vitrinka board-from-set)');
1938
+ try {
1939
+ const q = new URLSearchParams({ status: 'open', project, branch });
1940
+ const d = await watchFetchJson(`${base}/api/v1/work?${q}`, 15_000);
1941
+ const items = Array.isArray(d?.work) ? d.work : [];
1942
+ console.log(` queue ${items.length} open item${items.length === 1 ? '' : 's'}`);
1943
+ for (const it of items.slice(0, 10))
1944
+ console.log(` ${announceLine(it)}`);
1945
+ if (items.length > 10)
1946
+ console.log(` … ${items.length - 10} more`);
1947
+ }
1948
+ catch (e) {
1949
+ console.log(` queue unreachable (${e instanceof Error ? e.message : e})`);
1950
+ }
1951
+ try {
1952
+ const d = await watchFetchJson(`${base}/api/v1/work/listeners`, 15_000);
1953
+ const ls = Array.isArray(d?.listeners) ? d.listeners : [];
1954
+ const boardSlug = board ? board.slice(board.lastIndexOf('/') + 1) : '';
1955
+ const mine = ls.filter((l) => l.scopeKind === 'all'
1956
+ || (l.scopeKind === 'project' && l.scope === project && (!l.branch || l.branch === branch))
1957
+ || (l.scopeKind === 'board' && !!boardSlug && l.scope === boardSlug));
1958
+ if (!mine.length)
1959
+ console.log(' listener none — arm one with /vitrinka:listen (or: vitrinka watch)');
1960
+ for (const l of mine) {
1961
+ console.log(` listener ${l.actor || 'anonymous'} @ ${l.session || '?'} (${l.scopeKind}${l.scope ? ` ${l.scope}` : ''}${l.branch ? `/${l.branch}` : ''})`);
1962
+ }
1963
+ }
1964
+ catch (e) {
1965
+ console.log(` listener unreachable (${e instanceof Error ? e.message : e})`);
1966
+ }
1967
+ }
1968
+ // ---------- passive update notifier + first-run hint ----------
1969
+ // cmpSemver — numeric dotted-version compare (>0: a newer). PURE — unit-tested.
1970
+ export function cmpSemver(a, b) {
1971
+ const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
1972
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
1973
+ const d = (pa[i] || 0) - (pb[i] || 0);
1974
+ if (d)
1975
+ return d;
1976
+ }
1977
+ return 0;
1978
+ }
1979
+ // Commands that must never carry the notifier/hint noise (setup and meta paths).
1980
+ const HINT_SKIP = new Set(['install', 'install-claude', 'install-skills', 'update', 'uninstall',
1981
+ 'doctor', 'help', 'completion', '__complete', 'ai']);
1982
+ function updateCheckPath() {
1983
+ return join(process.env.HOME || '', '.config', 'vitrinka', 'update-check');
1984
+ }
1985
+ // maybeNotifyUpdate — one dim stderr line when a newer published version is
1986
+ // known, npm installs only (a repo checkout is a dev seat that pulls itself).
1987
+ // The registry check runs DETACHED at most once a day and writes a cache file;
1988
+ // the hot path only ever reads it — a command never waits on the network.
1989
+ function maybeNotifyUpdate(cmd) {
1990
+ if (HINT_SKIP.has(cmd) || !process.stderr.isTTY || !process.env.HOME)
1991
+ return;
1992
+ if (git(['rev-parse', '--show-toplevel'], dirname(CLI_PATH)))
1993
+ return;
1994
+ const p = updateCheckPath();
1995
+ let cache = {};
1996
+ try {
1997
+ cache = JSON.parse(readFileSync(p, 'utf8'));
1998
+ }
1999
+ catch {
2000
+ // Absent or corrupt cache — the background refresh below rewrites it.
2001
+ }
2002
+ const current = pkgVersion();
2003
+ if (cache.latest && current && cmpSemver(cache.latest, current) > 0) {
2004
+ console.error(`\x1b[2mupdate available ${current} → ${cache.latest} · run: vitrinka update\x1b[0m`);
2005
+ }
2006
+ if (Date.now() - (cache.ts || 0) < 24 * 3600 * 1000)
2007
+ return;
2008
+ const script = `const fs=require('fs'),path=require('path'),f=${JSON.stringify(p)};`
2009
+ + `fetch('https://registry.npmjs.org/@lovinka/vitrinka/latest',{signal:AbortSignal.timeout(10000)})`
2010
+ + `.then(r=>r.json()).then(d=>{fs.mkdirSync(path.dirname(f),{recursive:true});`
2011
+ + `fs.writeFileSync(f,JSON.stringify({ts:Date.now(),latest:String(d.version||'')}))}).catch(()=>{})`;
2012
+ try {
2013
+ spawn(process.execPath, ['-e', script], { detached: true, stdio: 'ignore' }).unref();
2014
+ }
2015
+ catch {
2016
+ // A notifier must never break the actual command.
2017
+ }
2018
+ }
2019
+ // maybeHintFirstRun — one stderr line on a machine with zero vitrinka setup
2020
+ // (no skills, no token, no shim). All three must be absent, so a configured
2021
+ // machine (or a local-scope one with a token) is never nagged.
2022
+ function maybeHintFirstRun(cmd) {
2023
+ if (HINT_SKIP.has(cmd))
2024
+ return;
2025
+ const home = process.env.HOME;
2026
+ if (!home)
2027
+ return;
2028
+ if (existsSync(join(home, '.claude', 'skills', 'vitrinka')))
2029
+ return;
2030
+ if (readToken())
2031
+ return;
2032
+ if (existsSync(shimPathOf(home)))
2033
+ return;
2034
+ console.error('\x1b[2mhint: this machine has no vitrinka setup yet — run: vitrinka install\x1b[0m');
2035
+ }
2036
+ // ---------- what's new (changelog) ----------
2037
+ // changelogSince — the `## x.y.z` sections newer than `since`, verbatim.
2038
+ // '' when nothing is newer or the file has no versioned headings. PURE.
2039
+ export function changelogSince(md, since) {
2040
+ const out = [];
2041
+ let taking = false;
2042
+ for (const line of md.split('\n')) {
2043
+ const m = /^##\s+\[?(\d+(?:\.\d+)*)\]?/.exec(line);
2044
+ if (m) {
2045
+ if (since && cmpSemver(m[1], since) <= 0)
2046
+ break;
2047
+ taking = true;
2048
+ }
2049
+ if (taking)
2050
+ out.push(line);
2051
+ }
2052
+ return out.join('\n').trim();
2053
+ }
2054
+ // ---------- update: bring the CLI + installed surfaces to the latest ----------
2055
+ // pkgVersion reads the version of the package the running CLI belongs to.
2056
+ // dirname(CLI_PATH) is pkg/src (repo checkout) or <pkg>/dist (npm install) —
2057
+ // package.json sits one level up in both layouts.
2058
+ function pkgVersion() {
2059
+ try {
2060
+ return String(JSON.parse(readFileSync(join(dirname(CLI_PATH), '..', 'package.json'), 'utf8')).version || '');
2061
+ }
2062
+ catch {
2063
+ return ''; // no package.json next to the CLI (odd layout) — version is cosmetic here
2064
+ }
2065
+ }
2066
+ // updateCmd updates the CLI source (git pull in a repo checkout, `npm i -g` for
2067
+ // a global npm install — detected from where the running file lives), then
2068
+ // refreshes every surface a previous install put on this machine: the skills
2069
+ // bundle (+ /vitrinka:listen command stub) and, ONLY where it was consented to
2070
+ // before, the Claude Code UI wiring. It never asks new consent questions —
2071
+ // that stays `vitrinka install`'s job. The refresh steps re-exec the (possibly
2072
+ // just replaced) CLI file so they run the NEW code, not this stale process.
2073
+ async function updateCmd(args) {
2074
+ const repoTop = git(['rev-parse', '--show-toplevel'], dirname(CLI_PATH));
2075
+ if (repoTop) {
2076
+ // Repo checkout — the shim/MCP run the source directly, so a pull IS the update.
2077
+ const before = git(['rev-parse', '--short', 'HEAD'], repoTop);
2078
+ console.log(`repo checkout ${repoTop} (${before || '?'}) — git pull --ff-only…`);
2079
+ const pull = spawnSync('git', ['-C', repoTop, 'pull', '--ff-only'], { stdio: 'inherit' });
2080
+ if (pull.status !== 0)
2081
+ fail('update: git pull --ff-only failed — fix the repo state (dirty tree / diverged branch) and re-run');
2082
+ const after = git(['rev-parse', '--short', 'HEAD'], repoTop);
2083
+ if (after === before)
2084
+ console.log(`✓ already up to date (${after})`);
2085
+ else {
2086
+ console.log(`✓ updated ${before} → ${after}`);
2087
+ // What's new — the pulled commits, capped so a long-idle machine doesn't scroll.
2088
+ const log = git(['log', '--oneline', '--no-decorate', `${before}..${after}`], repoTop);
2089
+ if (log) {
2090
+ const lines = log.split('\n');
2091
+ for (const l of lines.slice(0, 30))
2092
+ console.log(` ${l}`);
2093
+ if (lines.length > 30)
2094
+ console.log(` … ${lines.length - 30} more`);
2095
+ }
2096
+ }
2097
+ }
2098
+ else {
2099
+ // Global npm install — compare against the registry, update when behind.
2100
+ const current = pkgVersion();
2101
+ const view = spawnSync('npm', ['view', '@lovinka/vitrinka', 'version'], { encoding: 'utf8' });
2102
+ const latest = view.status === 0 ? view.stdout.trim() : '';
2103
+ if (!latest) {
2104
+ fail(`update: could not read the registry version (npm view @lovinka/vitrinka version): ${String(view.stderr || view.error || '').trim().slice(0, 200) || 'unknown error'}`);
2105
+ }
2106
+ if (latest === current) {
2107
+ console.log(`✓ already at the latest version (${current})`);
2108
+ }
2109
+ else {
2110
+ console.log(`updating @lovinka/vitrinka ${current || '?'} → ${latest}…`);
2111
+ const inst = spawnSync('npm', ['install', '-g', '@lovinka/vitrinka@latest'], { stdio: 'inherit' });
2112
+ if (inst.status !== 0)
2113
+ fail('update: npm install -g @lovinka/vitrinka@latest failed (see npm output above)');
2114
+ console.log(`✓ @lovinka/vitrinka ${latest}`);
2115
+ // What's new — npm just replaced the package files in place, so the
2116
+ // CHANGELOG next to the CLI is the NEW one; print its sections > current.
2117
+ try {
2118
+ const news = changelogSince(readFileSync(join(dirname(CLI_PATH), '..', 'CHANGELOG.md'), 'utf8'), current);
2119
+ if (news)
2120
+ console.log(`\n${news}\n`);
2121
+ }
2122
+ catch {
2123
+ // Older packages ship no CHANGELOG.md — nothing to show.
2124
+ }
2125
+ }
2126
+ }
2127
+ // Refresh installed surfaces from the NEW code — skills always (an install
2128
+ // implies them), Claude wiring only if the wrapper shows prior consent.
2129
+ const rerun = (sub) => spawnSync(process.execPath, [CLI_PATH, ...sub], { stdio: 'inherit' }).status === 0;
2130
+ if (!rerun(['install-skills', ...(args.local === true ? ['--local'] : [])])) {
2131
+ fail('update: skills refresh failed — re-run: vitrinka install-skills');
2132
+ }
2133
+ const wrapper = join(process.env.HOME || '', '.claude', 'vitrinka-statusline.sh');
2134
+ if (existsSync(wrapper)) {
2135
+ if (!rerun(['install-claude']))
2136
+ console.error('⚠ update: Claude UI refresh failed — re-run: vitrinka install-claude');
2137
+ }
2138
+ console.log('update done — try: vitrinka --help');
2139
+ }
1424
2140
  // ---------- snap: one-shot capture → manifest → detached push ----------
1425
2141
  // Filename slug: diacritics folded (Přihlášení → prihlaseni), non-alnum → '-', capped.
1426
2142
  export function kebab(s) {
@@ -1972,7 +2688,7 @@ createRoot(document.getElementById('root')).render(html\`<\${App} />\`);
1972
2688
  </html>
1973
2689
  `;
1974
2690
  }
1975
- function artifactInitCmd(args) {
2691
+ async function artifactInitCmd(args) {
1976
2692
  const slugRaw = strArg(args.slug);
1977
2693
  if (!slugRaw) {
1978
2694
  console.error('artifact-init: --slug <slug> is required');
@@ -1991,11 +2707,11 @@ function artifactInitCmd(args) {
1991
2707
  mkdirSync(dir, { recursive: true });
1992
2708
  writeFileSync(indexPath, blankScaffold(title, existsSync(join(dir, 'data.json'))));
1993
2709
  ensureGitIgnored(process.cwd(), '.artifacts/');
1994
- remoteInitCmd(dir, { ...args, key: slug, kind });
2710
+ await remoteInitCmd(dir, { ...args, key: slug, kind });
1995
2711
  console.log(`scaffolded ${indexPath}`);
1996
2712
  console.log(`author the App, then: node ${CLI_PATH} push --root ${dir} --title "<Human title>"`);
1997
2713
  }
1998
- function artifactFromSetCmd(args) {
2714
+ async function artifactFromSetCmd(args) {
1999
2715
  const slugRaw = strArg(args.slug);
2000
2716
  if (!slugRaw) {
2001
2717
  console.error('artifact-from-set: --slug <slug> is required');
@@ -2021,7 +2737,7 @@ function artifactFromSetCmd(args) {
2021
2737
  const title = strArg(args.title) || (fromManifest.journey && fromManifest.journey.title) || slug;
2022
2738
  writeFileSync(indexPath, composedScaffold(title, `${slug}.zip`));
2023
2739
  ensureGitIgnored(process.cwd(), '.artifacts/');
2024
- remoteInitCmd(dir, { ...args, key: slug, kind: strArg(args.kind) || 'report' });
2740
+ await remoteInitCmd(dir, { ...args, key: slug, kind: strArg(args.kind) || 'report' });
2025
2741
  // Record the source screenshot set (project/branchSlug/key) so push sends ?source= and
2026
2742
  // vitrinka can cross-link set ↔ artifact both ways.
2027
2743
  let source = strArg(args.source);
@@ -2102,17 +2818,35 @@ export const COMMANDS = [
2102
2818
  examples: ['vitrinka operator', 'vitrinka operator "Lukáš"'],
2103
2819
  },
2104
2820
  {
2105
- name: 'doctor', summary: 'Check this machine\'s setup: server reachable, write token valid, operator set',
2821
+ name: 'login', summary: 'Sign this machine in: browser approval mints a personal, workspace-scoped token',
2106
2822
  usage: '[--base <url>]', flags: [BASE_FLAG],
2107
- examples: ['vitrinka doctor'],
2823
+ examples: ['vitrinka login', 'vitrinka login --base https://vitrinka.lovinka.com'],
2108
2824
  },
2109
2825
  {
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' },
2826
+ name: 'doctor', summary: 'Check this machine\'s setup (server, token, operator, skills, shim, MCP); --fix repairs it',
2827
+ usage: '[--fix] [--base <url>]',
2828
+ flags: [{ f: '--fix', d: 'repair everything repairable: refresh skills, rewrite the shim, re-register the MCP, refresh the Claude UI wiring' }, BASE_FLAG],
2829
+ examples: ['vitrinka doctor', 'vitrinka doctor --fix'],
2830
+ },
2831
+ {
2832
+ name: 'install', summary: 'Onboard this machine: status table, then skills + shim + completion + MCP + token + operator + Claude UI',
2833
+ usage: '[--local] [--operator <name>] [--mcp|--no-mcp] [--claude|--no-claude] [--no-completion]',
2834
+ flags: [{ f: '--local', d: 'project scope: skills → ./.claude, MCP --scope project; skips shim/completion' },
2835
+ { f: '--operator', arg: '<name>', d: 'set the board-attribution persona without prompting' },
2836
+ { f: '--mcp', d: 'register the MCP without asking' },
2837
+ { f: '--no-mcp', d: 'skip the MCP registration' },
2113
2838
  { 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)' }],
2115
- examples: ['vitrinka install'],
2839
+ { f: '--no-claude', d: 'skip the Claude Code UI wiring' },
2840
+ { f: '--no-completion', d: 'skip appending the shell-completion line' }],
2841
+ examples: ['vitrinka install', 'vitrinka install --local', 'vitrinka install --operator "Lukáš" --mcp --claude'],
2842
+ },
2843
+ {
2844
+ name: 'uninstall', summary: 'Remove everything install put here (skills, shim, completion, Claude wiring, MCP); token/operator survive',
2845
+ usage: '[--purge] [--yes] [--local]',
2846
+ flags: [{ f: '--purge', d: 'also remove ~/.config/vitrinka (token + operator)' },
2847
+ { f: '--yes', d: 'skip the confirmation (required when non-interactive)' },
2848
+ { f: '--local', d: 'remove the project-local install (./.claude) instead' }],
2849
+ examples: ['vitrinka uninstall', 'vitrinka uninstall --purge --yes'],
2116
2850
  },
2117
2851
  {
2118
2852
  name: 'install-claude', summary: 'Wire the Claude Code UI: 🧷 board statusline segment + clickable footer badge',
@@ -2124,6 +2858,27 @@ export const COMMANDS = [
2124
2858
  usage: '[--local]', flags: [{ f: '--local', d: 'install into ./.claude/skills of the current project instead of ~/.claude/skills' }],
2125
2859
  examples: ['vitrinka install-skills'],
2126
2860
  },
2861
+ {
2862
+ name: 'update', summary: 'Update the CLI (git pull / npm -g), show what\'s new, refresh the installed skills + Claude wiring',
2863
+ usage: '[--local]',
2864
+ flags: [{ f: '--local', d: 'refresh skills into ./.claude/skills of the current project instead of ~/.claude/skills' }],
2865
+ examples: ['vitrinka update'],
2866
+ },
2867
+ {
2868
+ name: 'open', summary: 'Open the capture root\'s live board in the browser (--qr renders a terminal QR for the iPad)',
2869
+ usage: '[--root <dir>] [--qr] [--print]',
2870
+ flags: [ROOT_FLAG, { f: '--qr', d: 'print an ANSI QR code of the URL instead of opening the browser (needs qrencode)' },
2871
+ { f: '--print', d: 'only print the URL' }],
2872
+ examples: ['vitrinka open', 'vitrinka open --qr'],
2873
+ },
2874
+ {
2875
+ name: 'status', summary: 'Session dashboard: live board, open work queue, and listener lease for this repo+branch',
2876
+ usage: '[--root <dir>] [--project <p>] [--branch <b>] [--base <url>]',
2877
+ flags: [ROOT_FLAG, { f: '--project', arg: '<p>', d: 'override the inferred project scope' },
2878
+ { f: '--branch', arg: '<b>', d: 'override the inferred branch scope' }, BASE_FLAG],
2879
+ dynamic: 'projects',
2880
+ examples: ['vitrinka status'],
2881
+ },
2127
2882
  {
2128
2883
  name: 'snap', summary: 'Capture a screenshot → manifest → detached push',
2129
2884
  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]',
@@ -2703,7 +3458,11 @@ async function boardFromSetCmd(root, args) {
2703
3458
  process.exit(1);
2704
3459
  }
2705
3460
  const out = await imp.json();
2706
- console.log(`flow board: ${cfg.base}/boards/${slug} (${out.cards.length} cards, ${out.edges.length} edges)`);
3461
+ // Prefer the server-authoritative board url (carries /w/<workspace>); compose
3462
+ // the workspace-prefixed path only as a fallback for older servers.
3463
+ const ws = cfg.workspace ? `/w/${cfg.workspace}` : '';
3464
+ const boardUrl = out.board?.url || `${cfg.base}${ws}/boards/${slug}`;
3465
+ console.log(`flow board: ${boardUrl} (${out.cards.length} cards, ${out.edges.length} edges)`);
2707
3466
  // The board is about to be annotated — make sure its autocomplete index is fresh.
2708
3467
  await pushProjectIndex(dirname(root), cfg.base, cfg.project);
2709
3468
  }
@@ -2967,21 +3726,31 @@ async function runSub(argv) {
2967
3726
  else if (cmd === 'meta')
2968
3727
  metaCmd(root, args);
2969
3728
  else if (cmd === 'remote-init')
2970
- remoteInitCmd(root, args);
3729
+ await remoteInitCmd(root, args);
2971
3730
  else if (cmd === 'push')
2972
3731
  await pushCmd(root, args);
2973
3732
  else if (cmd === 'name')
2974
3733
  await nameCmd(argv, args);
2975
3734
  else if (cmd === 'operator')
2976
3735
  await operatorCmd(argv, args);
3736
+ else if (cmd === 'login')
3737
+ await loginCmd(args);
2977
3738
  else if (cmd === 'doctor')
2978
3739
  await doctorCmd(args);
2979
3740
  else if (cmd === 'install')
2980
- installCmd(args);
3741
+ await installCmd(args);
2981
3742
  else if (cmd === 'install-claude')
2982
3743
  installClaudeCmd(args);
2983
3744
  else if (cmd === 'install-skills')
2984
3745
  installSkillsCmd(args);
3746
+ else if (cmd === 'uninstall')
3747
+ uninstallCmd(args);
3748
+ else if (cmd === 'update')
3749
+ await updateCmd(args);
3750
+ else if (cmd === 'open')
3751
+ openCmd(root, args);
3752
+ else if (cmd === 'status')
3753
+ await statusCmd(root, args);
2985
3754
  else if (cmd === 'snap')
2986
3755
  await snapCmd(rest[0] && !rest[0].startsWith('--') ? rest[0] : '', args);
2987
3756
  else if (cmd === 'board-from-set')
@@ -2995,9 +3764,9 @@ async function runSub(argv) {
2995
3764
  else if (cmd === 'embed-shots')
2996
3765
  embedShotsCmd(root, args);
2997
3766
  else if (cmd === 'artifact-init')
2998
- artifactInitCmd(args);
3767
+ await artifactInitCmd(args);
2999
3768
  else if (cmd === 'artifact-from-set')
3000
- artifactFromSetCmd(args);
3769
+ await artifactFromSetCmd(args);
3001
3770
  else
3002
3771
  throw new Error(`runSub: unhandled command ${JSON.stringify(cmd)}`); // guarded by the caller
3003
3772
  }
@@ -3006,7 +3775,20 @@ async function runSub(argv) {
3006
3775
  // calling it here — before its definition below — is fine, and keeping the
3007
3776
  // entry block adjacent to runSub keeps the help/completion cases out of the
3008
3777
  // runSub-vs-registry source scan in dx.test.ts.
3009
- const IS_MAIN = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href === import.meta.url : false;
3778
+ // realpathSync resolves the PATH symlink (e.g. ~/.bun/bin/vitrinka
3779
+ // .../dist/cli.js) so the entry path matches import.meta.url — resolve() alone
3780
+ // doesn't follow symlinks, so the bin shim would otherwise never be IS_MAIN and
3781
+ // the CLI would silently exit 0. Fall back to the raw resolve() if the entry
3782
+ // somehow can't be realpath'd (never on a real run, but keep it non-fatal).
3783
+ function entryHref(argvPath) {
3784
+ try {
3785
+ return pathToFileURL(realpathSync(resolve(argvPath))).href;
3786
+ }
3787
+ catch {
3788
+ return pathToFileURL(resolve(argvPath)).href;
3789
+ }
3790
+ }
3791
+ const IS_MAIN = process.argv[1] ? entryHref(process.argv[1]) === import.meta.url : false;
3010
3792
  if (IS_MAIN) {
3011
3793
  await main(process.argv.slice(2));
3012
3794
  }
@@ -3044,6 +3826,7 @@ export async function main(argv) {
3044
3826
  else if (!COMMAND_MAP[cmd]) {
3045
3827
  // Unknown command → "did you mean" (plain edit-distance, no AI) + usage,
3046
3828
  // then one additive AI hint. Exit 2.
3829
+ maybeHintFirstRun(cmd);
3047
3830
  const near = nearestCommand(cmd);
3048
3831
  console.error(`unknown command ${JSON.stringify(cmd)}${near ? ` — did you mean "${near}"?` : ''}`);
3049
3832
  console.error(USAGE);
@@ -3051,6 +3834,9 @@ export async function main(argv) {
3051
3834
  process.exit(2);
3052
3835
  }
3053
3836
  else {
3837
+ // Passive DX layer — one dim stderr line each, never blocking, never fatal.
3838
+ maybeHintFirstRun(cmd);
3839
+ maybeNotifyUpdate(cmd);
3054
3840
  await runSub(argv);
3055
3841
  }
3056
3842
  }