@lovinka/vitrinka 1.7.1 → 1.9.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, realpathSync, } from 'node:fs';
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync, copyFileSync, mkdtempSync, chmodSync, appendFileSync, statSync, 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';
@@ -1107,7 +1107,9 @@ async function operatorCmd(rest, args) {
1107
1107
  const pos = positionals(rest).slice(1); // drop the command word itself
1108
1108
  const base = (strArg(args.base) || process.env.VITRINKA_URL || process.env.VITRINKA_BASE_URL
1109
1109
  || 'https://vitrinka.lovinka.com').replace(/\/+$/, '');
1110
- const name = pos.join(' ').trim();
1110
+ // stripControl mirrors the server's 422 on control characters — an
1111
+ // arrow-edited or paste-polluted name must never reach the settings key.
1112
+ const name = stripControl(pos.join(' '));
1111
1113
  if (!name) {
1112
1114
  const local = readOperator(true);
1113
1115
  console.log(`operator (local): ${local || '(unset)'}`);
@@ -1218,20 +1220,20 @@ async function doctorCmd(args) {
1218
1220
  console.log(`✓ operator ${op}`);
1219
1221
  else
1220
1222
  console.log('✗ operator unset — board writes stay anonymous (fix: vitrinka operator <name>)');
1221
- // Setup surfaces (skills / shim / MCP / Claude UI) — informational rows, and
1223
+ // Setup surfaces (plugin / shim / MCP / Claude UI) — informational rows, and
1222
1224
  // `--fix` repairs everything repairable in place. Token/operator stay manual
1223
1225
  // (they're credentials/identity, not installation).
1224
1226
  const fix = args.fix === true;
1225
1227
  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);
1228
+ for (const rt of pluginRuntimes()) {
1229
+ if (!rt.present)
1230
+ continue; // no runtime, nothing to wire
1231
+ if (rt.installed)
1232
+ console.log(`✓ plugin ${rt.name}: vitrinka installed`);
1233
+ else if (fix && installPluginInto(rt))
1234
+ console.log(`✓ plugin ${rt.name}: vitrinka installed`);
1233
1235
  else
1234
- console.log(`✗ skills ${st} — repair: vitrinka doctor --fix`);
1236
+ console.log(`✗ plugin ${rt.name}: not installed — repair: vitrinka doctor --fix`);
1235
1237
  }
1236
1238
  if (home) {
1237
1239
  const shimP = shimPathOf(home);
@@ -1262,110 +1264,88 @@ async function doctorCmd(args) {
1262
1264
  if (home && existsSync(join(home, '.claude', 'vitrinka-statusline.sh'))) {
1263
1265
  if (fix)
1264
1266
  installClaudeCmd(args);
1265
- else
1267
+ else if (existsSync(join(home, '.claude', 'hooks', 'vitrinka-board-link.sh')))
1266
1268
  console.log('✓ claude ui wired (refresh: vitrinka doctor --fix)');
1269
+ else
1270
+ console.log('✗ claude ui session-start hook missing — repair: vitrinka doctor --fix');
1267
1271
  }
1272
+ const appPath = refreshDesktopAppFlag();
1273
+ console.log(appPath ? `✓ app ${appPath} (flag: ${desktopAppFlagPath()})` : `- app Vitrinka.app not installed (no flag)`);
1268
1274
  if (failed)
1269
1275
  process.exit(1);
1270
1276
  }
1271
1277
  // ---------- install: PATH shim ----------
1272
- // bundledSkillsDir locates the skills bundle shipped inside the npm package
1273
- // (dist/cli.js → ../skills) or, when running from a repo checkout
1274
- // (pkg/src/cli.ts), the repo-root skills/ directory.
1275
- export function bundledSkillsDir(cliPath) {
1276
- for (const cand of [join(dirname(cliPath), '..', 'skills'), join(dirname(cliPath), '..', '..', 'skills')]) {
1277
- if (existsSync(join(cand, 'vitrinka')))
1278
- return cand;
1279
- }
1280
- return '';
1281
- }
1282
- // installSkillsCmd copies the packaged skills bundle (vitrinka + listen,
1283
- // screenshot, artifact, brainstorming) into the Claude skills directory —
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.
1287
- function installSkillsCmd(args) {
1288
- const bundle = bundledSkillsDir(CLI_PATH);
1289
- if (!bundle)
1290
- fail('install-skills: bundled skills not found next to the CLI — reinstall the package');
1291
- const home = process.env.HOME;
1292
- const local = args.local === true;
1293
- if (!local && !home)
1294
- fail('install-skills: $HOME is not set (or pass --local)');
1295
- const target = local ? resolve('.claude', 'skills') : join(home, '.claude', 'skills');
1296
- mkdirSync(target, { recursive: true });
1297
- // Whole directories: a skill may nest its SKILL.md (the vitrinka skill
1298
- // keeps it under listen/ next to its CLI + tests).
1299
- const names = readdirSync(bundle).filter((n) => !n.startsWith('.') && statSync(join(bundle, n)).isDirectory());
1300
- if (!names.length)
1301
- fail(`install-skills: no skills found in ${bundle}`);
1302
- for (const n of names) {
1303
- rmSync(join(target, n), { recursive: true, force: true });
1304
- cpSync(join(bundle, n), join(target, n), { recursive: true });
1305
- // Local installs live at ./.claude/skills — keep referenced CLI paths copy-pasteable.
1306
- if (local) {
1307
- for (const doc of [join(target, n, 'SKILL.md'), join(target, n, 'listen', 'SKILL.md')]) {
1308
- if (!existsSync(doc))
1309
- continue;
1310
- writeFileSync(doc, readFileSync(doc, 'utf8')
1311
- .replaceAll('~/.claude/skills/vitrinka/cli.ts', '.claude/skills/vitrinka/cli.ts'));
1312
- }
1278
+ // ---------- plugin distribution (skills ship via the runtimes' plugin systems) ----------
1279
+ // The skills bundle used to be copied into ~/.claude/skills (install-skills,
1280
+ // REMOVED 2026-07-11). Skills now distribute exclusively as the `vitrinka`
1281
+ // plugin from the `lovinka` marketplace (this repo), installable into BOTH
1282
+ // Claude Code and Codex one distribution per machine per runtime.
1283
+ const MARKETPLACE_REPO = 'LEFTEQ/vitrinka';
1284
+ // pluginRuntimes probes which agent CLIs exist and whether the vitrinka
1285
+ // plugin is installed in each. Claude records installs in
1286
+ // ~/.claude/plugins/installed_plugins.json; Codex is asked directly
1287
+ // (`codex plugin list` is fast and authoritative across its versions).
1288
+ function pluginRuntimes() {
1289
+ const home = process.env.HOME || '';
1290
+ const out = [];
1291
+ const claudePresent = claudeCliPresent();
1292
+ let claudeInstalled = false;
1293
+ if (claudePresent && home) {
1294
+ try {
1295
+ claudeInstalled = readFileSync(join(home, '.claude', 'plugins', 'installed_plugins.json'), 'utf8')
1296
+ .includes('vitrinka@lovinka');
1297
+ }
1298
+ catch {
1299
+ claudeInstalled = false; // no plugins file yet — nothing installed
1313
1300
  }
1314
- console.log(`✓ ${n} → ${join(target, n)}`);
1315
1301
  }
1316
- // /vitrinka:listen command stub nested SKILL.md files (skills/vitrinka/listen/)
1317
- // never register as invocable skills; only a stub under commands/vitrinka/ makes
1318
- // the slash command callable.
1319
- const stub = join(bundle, 'vitrinka', 'commands', 'listen.md');
1320
- if (existsSync(stub)) {
1321
- const commandsDir = local ? resolve('.claude', 'commands', 'vitrinka') : join(home, '.claude', 'commands', 'vitrinka');
1322
- mkdirSync(commandsDir, { recursive: true });
1323
- let content = readFileSync(stub, 'utf8');
1324
- if (local)
1325
- content = content.replaceAll('~/.claude/skills/vitrinka/listen/SKILL.md', '.claude/skills/vitrinka/listen/SKILL.md');
1326
- writeFileSync(join(commandsDir, 'listen.md'), content);
1327
- console.log(`✓ /vitrinka:listen command → ${join(commandsDir, 'listen.md')}`);
1302
+ out.push({ name: 'claude', present: claudePresent, installed: claudeInstalled });
1303
+ const codexPresent = !spawnSync('codex', ['--version'], { stdio: 'ignore' }).error;
1304
+ let codexInstalled = false;
1305
+ if (codexPresent) {
1306
+ const r = spawnSync('codex', ['plugin', 'list'], { encoding: 'utf8' });
1307
+ codexInstalled = r.status === 0 && /\bvitrinka\b/.test(r.stdout || '');
1328
1308
  }
1329
- console.log(`${names.length} skill${names.length === 1 ? '' : 's'} installed (${names.sort().join(', ')})`);
1309
+ out.push({ name: 'codex', present: codexPresent, installed: codexInstalled });
1310
+ return out;
1330
1311
  }
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)
1312
+ // installPluginInto registers the lovinka marketplace and installs the
1313
+ // vitrinka plugin for one runtime. Both runtimes' commands are idempotent
1314
+ // enough for our purposes (marketplace add on an existing source errors —
1315
+ // tolerated; install/add on an installed plugin re-snapshots).
1316
+ function installPluginInto(rt) {
1317
+ const cmds = rt.name === 'claude'
1318
+ ? [['claude', 'plugin', 'marketplace', 'add', MARKETPLACE_REPO], ['claude', 'plugin', 'install', 'vitrinka@lovinka']]
1319
+ : [['codex', 'plugin', 'marketplace', 'add', MARKETPLACE_REPO], ['codex', 'plugin', 'add', 'vitrinka@lovinka']];
1320
+ for (const [bin, ...rest] of cmds) {
1321
+ const r = spawnSync(bin, rest, { encoding: 'utf8' });
1322
+ if (r.status !== 0 && !/already|exists/i.test((r.stderr || '') + (r.stdout || ''))) {
1323
+ console.error(`⚠ ${rt.name}: \`${[bin, ...rest].join(' ')}\` failed${r.stderr ? ` — ${r.stderr.trim().split('\n').pop()}` : ''}`);
1341
1324
  return false;
1342
- if (da) {
1343
- if (!dirsEqual(pa, pb))
1344
- return false;
1345
1325
  }
1346
- else if (!readFileSync(pa).equals(readFileSync(pb)))
1347
- return false;
1348
1326
  }
1349
1327
  return true;
1350
1328
  }
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';
1329
+ // updatePluginIn refreshes an installed plugin to the marketplace's latest.
1330
+ function updatePluginIn(rt) {
1331
+ const cmd = rt.name === 'claude'
1332
+ ? ['claude', 'plugin', 'update', 'vitrinka@lovinka']
1333
+ : ['codex', 'plugin', 'marketplace', 'upgrade'];
1334
+ return spawnSync(cmd[0], cmd.slice(1), { stdio: 'inherit' }).status === 0;
1359
1335
  }
1336
+ // ---------- setup state probing (shared by install / doctor / uninstall) ----------
1360
1337
  const SHIM_CONTENT = `#!/bin/sh\nexec node "${CLI_PATH}" "$@"\n`;
1361
1338
  function shimPathOf(home) { return join(home, '.local', 'bin', 'vitrinka'); }
1362
1339
  function rcPathOf(home) {
1363
1340
  return join(home, (process.env.SHELL || '').includes('bash') ? '.bashrc' : '.zshrc');
1364
1341
  }
1365
1342
  // 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.
1343
+ // The registration is the REMOTE streamable-HTTP endpoint the Go server hosts
1344
+ // at {base}/mcp (remote-mcp decisions 2026-07-12) no local process per
1345
+ // session; the old stdio bin is deprecated. The bearer token is required on
1346
+ // every /mcp request, so registration without a token is refused with the
1347
+ // token help. X-Board-Actor rides along when an operator persona exists
1348
+ // (UTF-8 bytes latin-1-encoded — header values are ByteStrings).
1369
1349
  function claudeCliPresent() {
1370
1350
  return !spawnSync('claude', ['--version'], { stdio: 'ignore' }).error;
1371
1351
  }
@@ -1374,15 +1354,22 @@ function mcpRegistered() {
1374
1354
  return r.status === 0 && /\bvitrinka\b/.test(r.stdout);
1375
1355
  }
1376
1356
  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'];
1357
+ const args = ['--transport', 'http', 'vitrinka', `${resolveBaseUrl()}/mcp`];
1358
+ const token = readToken();
1359
+ if (token)
1360
+ args.push('--header', `Authorization: Bearer ${token}`);
1361
+ const actor = actorHeaderValue(readOperator(true));
1362
+ if (actor)
1363
+ args.push('--header', `X-Board-Actor: ${actor}`);
1364
+ return args;
1382
1365
  }
1383
1366
  function registerMcp(scope) {
1367
+ if (!readToken()) {
1368
+ console.log(`✗ mcp not registered — /mcp requires a bearer token on every request.\n${tokenHelp()}`);
1369
+ return false;
1370
+ }
1384
1371
  spawnSync('claude', ['mcp', 'remove', '--scope', scope, 'vitrinka'], { stdio: 'ignore' });
1385
- return spawnSync('claude', ['mcp', 'add', '--scope', scope, 'vitrinka', '--', ...mcpTarget()], { stdio: 'inherit' }).status === 0;
1372
+ return spawnSync('claude', ['mcp', 'add', '--scope', scope, ...mcpTarget()], { stdio: 'inherit' }).status === 0;
1386
1373
  }
1387
1374
  // promptLine — visible one-line read from the controlling TTY ('' when
1388
1375
  // non-interactive, like promptYesNo).
@@ -1395,12 +1382,21 @@ function promptLine(question) {
1395
1382
  const buf = Buffer.alloc(512);
1396
1383
  const n = readSync(fd, buf, 0, 512, null);
1397
1384
  closeSync(fd);
1398
- return buf.toString('utf8', 0, n).trim();
1385
+ // A cooked-mode tty read line-edits backspace but NOT arrow keys — those
1386
+ // arrive as raw ESC[D/ESC[C sequences that LOOK like cursor movement while
1387
+ // typing. Strip ANSI sequences and control chars so an arrow-edited answer
1388
+ // never persists escape bytes (the ␛[D␛[D␛[C operator-name incident).
1389
+ return stripControl(buf.toString('utf8', 0, n));
1399
1390
  }
1400
1391
  catch {
1401
1392
  return ''; // no controlling TTY — treat as non-interactive, never block
1402
1393
  }
1403
1394
  }
1395
+ // stripControl — remove ANSI escape sequences and control characters from a
1396
+ // line of user input, then trim.
1397
+ export function stripControl(s) {
1398
+ return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x1f\x7f]/g, '').trim();
1399
+ }
1404
1400
  // promptSecret — promptLine with terminal echo off (token input, the old
1405
1401
  // install.sh's `read -rs`). Degrades to a visible read if stty is unavailable.
1406
1402
  function promptSecret(question) {
@@ -1431,9 +1427,7 @@ async function installCmd(args) {
1431
1427
  const base = resolveBaseUrl(strArg(args.base));
1432
1428
  const mcpScope = local ? 'project' : 'user';
1433
1429
  // ---- 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';
1430
+ const runtimes = pluginRuntimes();
1437
1431
  const shimP = shimPathOf(home);
1438
1432
  const shimOk = existsSync(shimP) && readFileSync(shimP, 'utf8') === SHIM_CONTENT;
1439
1433
  const rc = rcPathOf(home);
@@ -1448,8 +1442,9 @@ async function installCmd(args) {
1448
1442
  console.log(` ${ok ? '✓' : '✗'} ${label.padEnd(11)}${detail}`);
1449
1443
  };
1450
1444
  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`);
1445
+ for (const rt of runtimes) {
1446
+ row(rt.installed, `plugin·${rt.name}`, rt.installed ? 'vitrinka installed' : rt.present ? 'will install (skills ship as the plugin)' : `${rt.name} CLI not found`);
1447
+ }
1453
1448
  if (!local)
1454
1449
  row(shimOk, 'shim', shimOk ? shimP : 'will install');
1455
1450
  if (!local)
@@ -1457,11 +1452,13 @@ async function installCmd(args) {
1457
1452
  row(mcpOk, 'mcp', mcpOk ? 'registered' : haveClaude ? `not registered (scope ${mcpScope})` : 'claude CLI not found');
1458
1453
  row(tokenSrc !== 'missing', 'token', tokenSrc === 'missing' ? 'missing — writes will 401' : `present (${tokenSrc === 'env' ? '$VITRINKA_TOKEN' : tokenPath()})`);
1459
1454
  row(!!op, 'operator', op || 'unset — board writes stay anonymous');
1460
- row(uiOk, 'claude ui', uiOk ? 'statusline + footer wired' : 'not wired');
1455
+ row(uiOk, 'claude ui', uiOk ? 'statusline + footer + session hook wired' : 'not wired');
1461
1456
  console.log('');
1462
- // ---- silent steps: skills, shim, PATH, completion (reversible, own dirs) ----
1463
- if (bundle && skills !== 'current')
1464
- installSkillsCmd(args);
1457
+ // ---- silent steps: plugins, shim, PATH, completion (reversible, own dirs) ----
1458
+ for (const rt of runtimes) {
1459
+ if (rt.present && !rt.installed && installPluginInto(rt))
1460
+ console.log(`✓ plugin (${rt.name}) → vitrinka@lovinka`);
1461
+ }
1465
1462
  if (!local) {
1466
1463
  if (!shimOk) {
1467
1464
  mkdirSync(dirname(shimP), { recursive: true });
@@ -1485,7 +1482,9 @@ async function installCmd(args) {
1485
1482
  }
1486
1483
  // ---- asked steps: the config-touchers ----
1487
1484
  // 1. MCP registration (claude's registry).
1488
- const mcpCmdHint = `claude mcp add --scope ${mcpScope} vitrinka -- ${mcpTarget().join(' ')}`;
1485
+ // Hint never inlines the token the shell substitution reads it at run time.
1486
+ const mcpCmdHint = `claude mcp add --scope ${mcpScope} --transport http vitrinka ${resolveBaseUrl()}/mcp `
1487
+ + `--header "Authorization: Bearer $(cat ${tokenPath()})"`;
1489
1488
  if (!haveClaude) {
1490
1489
  console.error(`⚠ claude CLI not found — register the MCP later with:\n ${mcpCmdHint}`);
1491
1490
  }
@@ -1542,7 +1541,7 @@ async function installCmd(args) {
1542
1541
  console.log('claude ui: skipped (--no-claude)');
1543
1542
  }
1544
1543
  else if (uiOk || args.claude === true
1545
- || promptYesNo('Wire Claude Code UI (🧷 board statusline segment + clickable footer badge)? [Y/n] ')) {
1544
+ || promptYesNo('Wire Claude Code UI (🧷 board statusline segment + clickable footer badge + session-start board link)? [Y/n] ')) {
1546
1545
  installClaudeCmd(args);
1547
1546
  }
1548
1547
  else if (!process.stdin.isTTY) {
@@ -1587,9 +1586,9 @@ export function stripMarkerBlock(content, marker) {
1587
1586
  }
1588
1587
  return out.join('\n');
1589
1588
  }
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'];
1589
+ // Legacy ~/.claude/skills copy names — removed by uninstall on machines set
1590
+ // up before the plugin distribution (install-skills, removed 2026-07-11).
1591
+ const LEGACY_SKILLS = ['artifact', 'brainstorming', 'screenshot', 'vitrinka', 'listen'];
1593
1592
  // uninstallCmd removes everything install put on the machine — the exact
1594
1593
  // inverse: bundled skills + the /vitrinka:listen stub, shim, rc marker blocks,
1595
1594
  // statusline wrapper (restoring the original statusLine command), the /boards/
@@ -1600,21 +1599,31 @@ function uninstallCmd(args) {
1600
1599
  if (!home)
1601
1600
  fail('uninstall: $HOME is not set');
1602
1601
  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] `)) {
1602
+ if (args.yes !== true && !promptYesNo(`Remove vitrinka from this machine (plugins, legacy skill copies, shim, completion, Claude wiring, MCP${args.purge === true ? ', token+operator' : ''})? [Y/n] `)) {
1604
1603
  fail('uninstall: aborted (non-interactive runs need --yes)', 2);
1605
1604
  }
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;
1605
+ // Plugins (the current distribution) + legacy ~/.claude/skills copies +
1606
+ // the old /vitrinka:listen command stub, from machines set up pre-plugin.
1607
+ for (const rt of pluginRuntimes()) {
1608
+ if (!rt.installed)
1609
+ continue;
1610
+ const cmd = rt.name === 'claude'
1611
+ ? ['claude', 'plugin', 'uninstall', 'vitrinka@lovinka']
1612
+ : ['codex', 'plugin', 'remove', 'vitrinka@lovinka'];
1613
+ if (spawnSync(cmd[0], cmd.slice(1), { stdio: 'inherit' }).status === 0) {
1614
+ console.log(`✓ removed ${rt.name} plugin`);
1615
+ }
1616
+ else {
1617
+ console.error(`⚠ ${rt.name} plugin removal failed — run: ${cmd.join(' ')}`);
1618
+ }
1619
+ }
1611
1620
  const skillsDir = local ? resolve('.claude', 'skills') : join(home, '.claude', 'skills');
1612
1621
  const commandsDir = local ? resolve('.claude', 'commands', 'vitrinka') : join(home, '.claude', 'commands', 'vitrinka');
1613
- for (const n of names) {
1622
+ for (const n of LEGACY_SKILLS) {
1614
1623
  if (!existsSync(join(skillsDir, n)))
1615
1624
  continue;
1616
1625
  rmSync(join(skillsDir, n), { recursive: true, force: true });
1617
- console.log(`✓ removed skill ${n}`);
1626
+ console.log(`✓ removed legacy skill copy ${n}`);
1618
1627
  }
1619
1628
  if (existsSync(join(commandsDir, 'listen.md'))) {
1620
1629
  rmSync(join(commandsDir, 'listen.md'));
@@ -1679,6 +1688,20 @@ function uninstallCmd(args) {
1679
1688
  console.log('✓ footer badge entry removed');
1680
1689
  }
1681
1690
  }
1691
+ if (hasBoardLinkHook(settings)) {
1692
+ const hooks = settings.hooks;
1693
+ const ss = hooks.SessionStart
1694
+ .map((m) => (Array.isArray(m?.hooks)
1695
+ ? { ...m, hooks: m.hooks.filter((h) => !(typeof h?.command === 'string' && h.command.includes('vitrinka-board-link'))) }
1696
+ : m))
1697
+ .filter((m) => !Array.isArray(m?.hooks) || m.hooks.length > 0);
1698
+ if (ss.length)
1699
+ hooks.SessionStart = ss;
1700
+ else
1701
+ delete hooks.SessionStart;
1702
+ changed = true;
1703
+ console.log('✓ session-start hook entry removed');
1704
+ }
1682
1705
  if (changed)
1683
1706
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
1684
1707
  }
@@ -1686,6 +1709,15 @@ function uninstallCmd(args) {
1686
1709
  rmSync(wrapperP);
1687
1710
  console.log(`✓ removed ${wrapperP}`);
1688
1711
  }
1712
+ const hookP = join(home, '.claude', 'hooks', 'vitrinka-board-link.sh');
1713
+ if (existsSync(hookP)) {
1714
+ rmSync(hookP);
1715
+ console.log(`✓ removed ${hookP}`);
1716
+ }
1717
+ if (existsSync(desktopAppFlagPath())) {
1718
+ rmSync(desktopAppFlagPath());
1719
+ console.log(`✓ removed ${desktopAppFlagPath()}`);
1720
+ }
1689
1721
  // MCP registration (both scopes — cheap, and remove is idempotent).
1690
1722
  if (claudeCliPresent()) {
1691
1723
  for (const scope of local ? ['project', 'user'] : ['user', 'project']) {
@@ -1708,7 +1740,7 @@ function uninstallCmd(args) {
1708
1740
  }
1709
1741
  // ---------- install-claude: Claude Code UI wiring ----------
1710
1742
  //
1711
- // Wires the two Claude Code surfaces that link to boards (decisions:
1743
+ // Wires the three Claude Code surfaces that link to boards (decisions:
1712
1744
  // docs/specs/2026-07-07-claude-install-decisions.md):
1713
1745
  // 1. footerLinksRegexes in ~/.claude/settings.json — Claude renders its own
1714
1746
  // clickable "🧷 board" footer badge whenever a /boards/ URL appears in the
@@ -1721,16 +1753,30 @@ function uninstallCmd(args) {
1721
1753
  // "not planned"), but terminals like Warp/iTerm2/Ghostty cmd+click-linkify
1722
1754
  // visible URLs on their own.
1723
1755
  // With no original statusline, the wrapper renders a minimal line itself.
1724
- // Idempotent, never an uninstall: re-runs detect the /boards/ footer pattern and
1725
- // the wrapper registration and no-op. Removal = delete the wrapper + restore
1726
- // statusLine.command (recorded in the wrapper's VITRINKA_ORIG line) + drop the
1727
- // footerLinksRegexes entry (documented in pkg/README.md).
1756
+ // 3. SessionStart hook ~/.claude/hooks/vitrinka-board-link.sh prints the
1757
+ // capture root's board URL into session context at start, so the footer
1758
+ // badge is clickable from message one (the statusline URL isn't).
1759
+ // Idempotent, never an uninstall: re-runs detect the /boards/ footer pattern,
1760
+ // the wrapper registration, and the SessionStart entry and no-op. Removal =
1761
+ // delete the wrapper + hook + restore statusLine.command (recorded in the
1762
+ // wrapper's VITRINKA_ORIG line) + drop the footerLinksRegexes and SessionStart
1763
+ // entries (documented in pkg/README.md).
1728
1764
  export const BOARD_FOOTER_LINK = {
1729
1765
  type: 'regex',
1730
1766
  pattern: '(?<url>https?:\\/\\/\\S+\\/boards\\/(?<slug>[\\w.-]+))',
1731
1767
  url: '{url}',
1732
1768
  label: 'vitrinka/b/{slug}',
1733
1769
  };
1770
+ // Second badge on the same board URLs: a vitrinka:// deep link that opens the
1771
+ // Tauri desktop app (which registers ONLY the custom scheme — https stays in
1772
+ // the browser by design, see apps/desktop). Captures the path after the host
1773
+ // so `vitrinka://w/<ws>/boards/<slug>` lands on the same board in-app.
1774
+ export const BOARD_APP_FOOTER_LINK = {
1775
+ type: 'regex',
1776
+ pattern: 'https?:\\/\\/[^\\/\\s]+\\/(?<path>(?:w\\/[\\w.-]+\\/)?boards\\/[\\w.-]+)',
1777
+ url: 'vitrinka://{path}',
1778
+ label: '⧉ app',
1779
+ };
1734
1780
  // hasBoardFooterLink — an entry whose pattern targets /boards/ URLs already
1735
1781
  // exists (ours or hand-written); the marker that makes the settings edit idempotent.
1736
1782
  export function hasBoardFooterLink(settings) {
@@ -1738,6 +1784,41 @@ export function hasBoardFooterLink(settings) {
1738
1784
  // Strip regex escaping first — patterns write the path as \/boards\/ as often as /boards/.
1739
1785
  return Array.isArray(arr) && arr.some((e) => typeof e?.pattern === 'string' && e.pattern.replaceAll('\\', '').includes('/boards/'));
1740
1786
  }
1787
+ // hasBoardAppFooterLink — the vitrinka:// deep-link badge already exists.
1788
+ export function hasBoardAppFooterLink(settings) {
1789
+ const arr = settings.footerLinksRegexes;
1790
+ return Array.isArray(arr) && arr.some((e) => typeof e?.url === 'string' && e.url.startsWith('vitrinka://'));
1791
+ }
1792
+ // ---------- desktop-app flag ----------
1793
+ // ONE canonical "is Vitrinka.app installed here" answer, so skills (and the
1794
+ // Claude wiring) never probe /Applications themselves: install-claude and
1795
+ // doctor refresh ~/.config/vitrinka/desktop-app to hold the app path, or
1796
+ // delete it when the app is gone. Skills just test the file.
1797
+ export function desktopAppFlagPath() {
1798
+ return join(process.env.HOME || '', '.config', 'vitrinka', 'desktop-app');
1799
+ }
1800
+ // desktopAppPath — where Vitrinka.app actually is right now ('' when absent).
1801
+ export function desktopAppPath() {
1802
+ for (const p of [join(process.env.HOME || '', 'Applications', 'Vitrinka.app'), '/Applications/Vitrinka.app']) {
1803
+ if (existsSync(p))
1804
+ return p;
1805
+ }
1806
+ return '';
1807
+ }
1808
+ // refreshDesktopAppFlag — sync the flag file to reality; returns the app path
1809
+ // ('' when absent). Called from install-claude and doctor.
1810
+ export function refreshDesktopAppFlag() {
1811
+ const app = desktopAppPath();
1812
+ const flag = desktopAppFlagPath();
1813
+ if (app) {
1814
+ mkdirSync(dirname(flag), { recursive: true });
1815
+ writeFileSync(flag, app + '\n');
1816
+ }
1817
+ else if (existsSync(flag)) {
1818
+ rmSync(flag);
1819
+ }
1820
+ return app;
1821
+ }
1741
1822
  // buildStatuslineWrapper — the ~/.claude/vitrinka-statusline.sh content. The
1742
1823
  // original command is recorded on the VITRINKA_ORIG line (single-quoted), both
1743
1824
  // to run it and so a re-run can recover it instead of wrapping the wrapper.
@@ -1803,6 +1884,41 @@ else
1803
1884
  fi
1804
1885
  `;
1805
1886
  }
1887
+ // SessionStart hook — the third Claude UI surface. Statusline URLs aren't
1888
+ // clickable in every terminal (and never OSC 8), so this puts the board URL
1889
+ // into the session context at start: the footer badge renders from message
1890
+ // one and Claude knows the live board without asking. Silent no-op when the
1891
+ // repo has no .screenshots/.vitrinka descriptor.
1892
+ export const BOARD_LINK_HOOK_CMD = '~/.claude/hooks/vitrinka-board-link.sh';
1893
+ export const BOARD_LINK_HOOK_SCRIPT = `#!/bin/bash
1894
+ # vitrinka-board-link — SessionStart hook, managed by \`vitrinka install-claude\`
1895
+ # (re-runs rewrite it). Emits the repo's live board URL into session context so
1896
+ # the clickable footer badge (footerLinksRegexes /boards/ entry) exists from
1897
+ # message one. Silent no-op without a .screenshots/.vitrinka descriptor.
1898
+ command -v jq >/dev/null 2>&1 || exit 0
1899
+ input=$(cat)
1900
+ dir=$(printf '%s' "$input" | jq -r '.cwd // ""')
1901
+ dir="\${dir:-$PWD}"
1902
+ root=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null)
1903
+ vjson=""
1904
+ for c in "$dir/.screenshots/.vitrinka" \${root:+"$root/.screenshots/.vitrinka"}; do
1905
+ [ -f "$c" ] && { vjson=$c; break; }
1906
+ done
1907
+ [ -n "$vjson" ] || exit 0
1908
+ base=$(jq -r '.base // empty' "$vjson" 2>/dev/null)
1909
+ key=$(jq -r '.key // empty' "$vjson" 2>/dev/null)
1910
+ ws=$(jq -r '.workspace // empty' "$vjson" 2>/dev/null)
1911
+ [ -n "$base" ] && [ -n "$key" ] || exit 0
1912
+ [ -n "$ws" ] && base="$base/w/$ws"
1913
+ printf 'Live vitrinka board for this repo: %s/boards/%s\\n' "$base" "$key"
1914
+ `;
1915
+ // hasBoardLinkHook — a SessionStart entry running vitrinka-board-link already
1916
+ // exists; the marker that makes the settings edit idempotent.
1917
+ export function hasBoardLinkHook(settings) {
1918
+ const arr = settings.hooks?.SessionStart;
1919
+ return Array.isArray(arr) && arr.some((m) => Array.isArray(m?.hooks)
1920
+ && m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('vitrinka-board-link')));
1921
+ }
1806
1922
  // wrapperOrigCommand — recover the original statusline command a wrapper file
1807
1923
  // recorded, so re-runs refresh the wrapper without wrapping it around itself.
1808
1924
  export function wrapperOrigCommand(content) {
@@ -1858,6 +1974,24 @@ export function installClaudeCmd(_args) {
1858
1974
  changed = true;
1859
1975
  console.log('✓ footer badge → footerLinksRegexes (clickable "🧷 board" whenever a board URL appears in conversation)');
1860
1976
  }
1977
+ // 1b. App badge — only on machines with the desktop app: a second badge per
1978
+ // board URL whose vitrinka:// deep link opens the same board in Vitrinka.app.
1979
+ // The flag is refreshed here (see desktopAppPath); skills read the flag file,
1980
+ // never probe /Applications themselves.
1981
+ const appPath = refreshDesktopAppFlag();
1982
+ if (appPath && !hasBoardAppFooterLink(settings)) {
1983
+ settings.footerLinksRegexes = [...settings.footerLinksRegexes, BOARD_APP_FOOTER_LINK];
1984
+ changed = true;
1985
+ console.log(`✓ app badge → footerLinksRegexes ("⧉ app" opens boards in ${appPath})`);
1986
+ }
1987
+ else if (appPath) {
1988
+ console.log('✓ app badge already configured (vitrinka:// footer entry)');
1989
+ }
1990
+ else if (hasBoardAppFooterLink(settings)) {
1991
+ settings.footerLinksRegexes = settings.footerLinksRegexes.filter((e) => !(typeof e?.url === 'string' && e.url.startsWith('vitrinka://')));
1992
+ changed = true;
1993
+ console.log('✓ app badge removed (Vitrinka.app not installed on this machine)');
1994
+ }
1861
1995
  // 2. Statusline wrapper.
1862
1996
  const wrapperPath = join(claudeDir, 'vitrinka-statusline.sh');
1863
1997
  const wrapperCmd = '~/.claude/vitrinka-statusline.sh';
@@ -1883,6 +2017,24 @@ export function installClaudeCmd(_args) {
1883
2017
  else
1884
2018
  console.log(`✓ statusline installed → ${wrapperPath} (minimal: dir │ branch │ 🧷 board │ model)`);
1885
2019
  }
2020
+ // 3. SessionStart hook — board URL into context at session start, so the
2021
+ // footer badge is clickable from message one.
2022
+ const hooksDir = join(claudeDir, 'hooks');
2023
+ const hookPath = join(hooksDir, 'vitrinka-board-link.sh');
2024
+ mkdirSync(hooksDir, { recursive: true });
2025
+ writeFileSync(hookPath, BOARD_LINK_HOOK_SCRIPT);
2026
+ chmodSync(hookPath, 0o755);
2027
+ if (hasBoardLinkHook(settings)) {
2028
+ console.log(`✓ session-start hook refreshed (${hookPath})`);
2029
+ }
2030
+ else {
2031
+ const hooks = (settings.hooks && typeof settings.hooks === 'object' ? settings.hooks : {});
2032
+ const arr = Array.isArray(hooks.SessionStart) ? hooks.SessionStart : [];
2033
+ hooks.SessionStart = [...arr, { hooks: [{ type: 'command', command: BOARD_LINK_HOOK_CMD, timeout: 10 }] }];
2034
+ settings.hooks = hooks;
2035
+ changed = true;
2036
+ console.log(`✓ session-start hook → ${hookPath} (board URL in context from message one)`);
2037
+ }
1886
2038
  if (changed)
1887
2039
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
1888
2040
  // The wrapper reads Claude's stdin JSON with jq; without it the board segment
@@ -1919,7 +2071,16 @@ function openCmd(root, args) {
1919
2071
  if (args.print === true)
1920
2072
  return;
1921
2073
  const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
1922
- const r = spawnSync(opener, [url], { stdio: 'ignore' });
2074
+ // Desktop app installed (the ~/.config/vitrinka/desktop-app flag) open the
2075
+ // board in Vitrinka.app via its vitrinka:// scheme instead of the browser.
2076
+ // --browser forces the https URL.
2077
+ let target = url;
2078
+ if (args.browser !== true && process.platform === 'darwin' && existsSync(desktopAppFlagPath())) {
2079
+ target = url.replace(/^https?:\/\/[^/]+\//, 'vitrinka://');
2080
+ if (target === url)
2081
+ target = 'vitrinka://'; // bare base URL — just focus the app
2082
+ }
2083
+ const r = spawnSync(opener, [target], { stdio: 'ignore' });
1923
2084
  if (r.error || r.status !== 0)
1924
2085
  console.error(`open: could not launch ${opener} — URL printed above`);
1925
2086
  }
@@ -1977,7 +2138,7 @@ export function cmpSemver(a, b) {
1977
2138
  return 0;
1978
2139
  }
1979
2140
  // 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',
2141
+ const HINT_SKIP = new Set(['install', 'install-claude', 'update', 'uninstall',
1981
2142
  'doctor', 'help', 'completion', '__complete', 'ai']);
1982
2143
  function updateCheckPath() {
1983
2144
  return join(process.env.HOME || '', '.config', 'vitrinka', 'update-check');
@@ -2070,7 +2231,7 @@ function pkgVersion() {
2070
2231
  // before, the Claude Code UI wiring. It never asks new consent questions —
2071
2232
  // that stays `vitrinka install`'s job. The refresh steps re-exec the (possibly
2072
2233
  // just replaced) CLI file so they run the NEW code, not this stale process.
2073
- async function updateCmd(args) {
2234
+ async function updateCmd(_args) {
2074
2235
  const repoTop = git(['rev-parse', '--show-toplevel'], dirname(CLI_PATH));
2075
2236
  if (repoTop) {
2076
2237
  // Repo checkout — the shim/MCP run the source directly, so a pull IS the update.
@@ -2124,11 +2285,13 @@ async function updateCmd(args) {
2124
2285
  }
2125
2286
  }
2126
2287
  }
2127
- // Refresh installed surfaces from the NEW code — skills always (an install
2128
- // implies them), Claude wiring only if the wrapper shows prior consent.
2288
+ // Refresh installed surfaces from the NEW code — plugins where installed,
2289
+ // Claude wiring only if the wrapper shows prior consent.
2129
2290
  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');
2291
+ for (const rt of pluginRuntimes()) {
2292
+ if (rt.installed && !updatePluginIn(rt)) {
2293
+ console.error(`⚠ update: ${rt.name} plugin refresh failed — re-run its plugin update manually`);
2294
+ }
2132
2295
  }
2133
2296
  const wrapper = join(process.env.HOME || '', '.claude', 'vitrinka-statusline.sh');
2134
2297
  if (existsSync(wrapper)) {
@@ -2854,21 +3017,17 @@ export const COMMANDS = [
2854
3017
  examples: ['vitrinka install-claude'],
2855
3018
  },
2856
3019
  {
2857
- name: 'install-skills', summary: 'Install the bundled Claude skills (vitrinka, screenshot, artifact, brainstorming)',
2858
- usage: '[--local]', flags: [{ f: '--local', d: 'install into ./.claude/skills of the current project instead of ~/.claude/skills' }],
2859
- examples: ['vitrinka install-skills'],
2860
- },
2861
- {
2862
- name: 'update', summary: 'Update the CLI (git pull / npm -g), show what\'s new, refresh the installed skills + Claude wiring',
3020
+ name: 'update', summary: 'Update the CLI (git pull / npm -g), show what\'s new, refresh the installed plugins + Claude wiring',
2863
3021
  usage: '[--local]',
2864
3022
  flags: [{ f: '--local', d: 'refresh skills into ./.claude/skills of the current project instead of ~/.claude/skills' }],
2865
3023
  examples: ['vitrinka update'],
2866
3024
  },
2867
3025
  {
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]',
3026
+ name: 'open', summary: 'Open the capture root\'s live board in Vitrinka.app when installed, else the browser (--qr renders a terminal QR for the iPad)',
3027
+ usage: '[--root <dir>] [--qr] [--print] [--browser]',
2870
3028
  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' }],
3029
+ { f: '--print', d: 'only print the URL' },
3030
+ { f: '--browser', d: 'force the browser even when Vitrinka.app is installed' }],
2872
3031
  examples: ['vitrinka open', 'vitrinka open --qr'],
2873
3032
  },
2874
3033
  {
@@ -3741,8 +3900,6 @@ async function runSub(argv) {
3741
3900
  await installCmd(args);
3742
3901
  else if (cmd === 'install-claude')
3743
3902
  installClaudeCmd(args);
3744
- else if (cmd === 'install-skills')
3745
- installSkillsCmd(args);
3746
3903
  else if (cmd === 'uninstall')
3747
3904
  uninstallCmd(args);
3748
3905
  else if (cmd === 'update')