@lovinka/vitrinka 1.8.0 → 1.10.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/CHANGELOG.md CHANGED
@@ -3,6 +3,53 @@
3
3
  `vitrinka update` prints the sections newer than your previous version after
4
4
  updating — keep entries short and user-facing.
5
5
 
6
+ ## 1.10.0 — 2026-07-13
7
+
8
+ ### Added
9
+ - `compose_board` question options accept `cardId` — the board draws a trace
10
+ wire from the option to the card it names, hovering glows it, and picking
11
+ flies the camera there. Pair it with the new `POST /artifact` params
12
+ (`section`, `device`, `anchor`/`side`): mockups land inside their take's
13
+ section frame, sized by device intent, and scale to fit their card
14
+ automatically (server-measured natural size).
15
+
16
+ ## 1.9.0 — 2026-07-12
17
+
18
+ ### Changed
19
+ - MCP registration moved to the remote `/mcp` endpoint (streamable HTTP) —
20
+ no local stdio process per session; `vitrinka install`/`doctor --fix`
21
+ re-register automatically. The old stdio bin is deprecated.
22
+
23
+ ## 1.8.3 — 2026-07-11
24
+
25
+ ### Fixed
26
+ - Interactive prompts (`install`'s operator question, any `promptLine`) strip
27
+ ANSI escape sequences and control characters — arrow-key edits no longer
28
+ persist raw `ESC[D`/`ESC[C` bytes into the operator name (which every fresh
29
+ browser adopted as its board identity). `vitrinka operator <name>` sanitizes
30
+ the same way, and the server now rejects control characters with 422.
31
+
32
+ ## 1.8.2 — 2026-07-11
33
+
34
+ ### Added
35
+ - Desktop-app flag: `~/.config/vitrinka/desktop-app` is the one canonical
36
+ "is Vitrinka.app installed" answer (content = app path). Stamped by
37
+ `apps/desktop make install`, re-synced by `install-claude` and `doctor`.
38
+ - `install-claude` adds a second **⧉ app** footer badge (only when the app is
39
+ installed) whose `vitrinka://` deep link opens the board in Vitrinka.app.
40
+ - `vitrinka open` opens the board in Vitrinka.app when installed;
41
+ `--browser` forces the browser.
42
+
43
+ ## 1.8.1 — 2026-07-11
44
+
45
+ ### Added
46
+ - `install-claude` now also wires a `SessionStart` hook
47
+ (`~/.claude/hooks/vitrinka-board-link.sh`) that puts the repo's live board
48
+ URL into session context at start — the clickable 🧷 footer badge exists
49
+ from message one instead of waiting for a board URL to be mentioned.
50
+ `vitrinka doctor` reports (and `--fix` repairs) the hook; `uninstall`
51
+ removes it.
52
+
6
53
  ## 1.8.0 — 2026-07-11
7
54
 
8
55
  ### Changed
package/README.md CHANGED
@@ -60,11 +60,28 @@ to wire the **Claude Code UI** — re-runnable standalone as `vitrinka install-c
60
60
  `dir │ branch │ 🧷 board │ model` line itself. Needs `jq`; degrades to
61
61
  pass-through without it, and never double-renders a board segment your own
62
62
  statusline already prints.
63
-
64
- Both steps are idempotent re-runs detect the wiring and no-op. To remove:
65
- delete `~/.claude/vitrinka-statusline.sh`, restore `statusLine.command` in
66
- `~/.claude/settings.json` to the `VITRINKA_ORIG` value (or drop the key), and
67
- remove the `footerLinksRegexes` entry whose pattern contains `/boards/`.
63
+ - **Session-start board link** — `~/.claude/hooks/vitrinka-board-link.sh`, a
64
+ `SessionStart` hook that prints the repo's live board URL into session
65
+ context at start, so the clickable footer badge exists from message one
66
+ (the statusline URL isn't clickable in every terminal). Silent no-op in
67
+ repos without a `.screenshots/.vitrinka` descriptor; needs `jq`.
68
+ - **App badge** (only when Vitrinka.app is installed) — a second
69
+ `footerLinksRegexes` entry that renders **⧉ app** next to the board badge;
70
+ its `vitrinka://` deep link opens the same board in the desktop app
71
+ (which registers only the custom scheme — https stays in the browser).
72
+
73
+ **Desktop-app flag** — `~/.config/vitrinka/desktop-app` is the ONE canonical
74
+ "is Vitrinka.app installed here" answer (content = the app path). `apps/desktop`'s
75
+ `make install` stamps it; `install-claude` and `doctor` re-sync it to reality
76
+ (and add/remove the app badge accordingly). Skills and tools should test this
77
+ flag file instead of probing `/Applications` themselves — `vitrinka open` uses
78
+ it to open boards in the app (pass `--browser` to force the browser).
79
+
80
+ All steps are idempotent — re-runs detect the wiring and no-op. To remove:
81
+ delete `~/.claude/vitrinka-statusline.sh` and `~/.claude/hooks/vitrinka-board-link.sh`,
82
+ restore `statusLine.command` in `~/.claude/settings.json` to the `VITRINKA_ORIG`
83
+ value (or drop the key), and remove the `footerLinksRegexes` entry whose pattern
84
+ contains `/boards/` plus the `hooks.SessionStart` entry running `vitrinka-board-link`.
68
85
 
69
86
  For Claude Desktop or any other MCP client, add the equivalent entry by hand:
70
87
 
package/dist/bin-mcp.js CHANGED
@@ -13,14 +13,18 @@ const sub = process.argv[2];
13
13
  if (sub === '--help' || sub === '-h') {
14
14
  process.stdout.write('vitrinka-mcp — Model Context Protocol server for vitrinka\n\n' +
15
15
  'USAGE\n' +
16
- ' vitrinka-mcp Start the MCP server over stdio (default).\n' +
16
+ ' vitrinka-mcp Start the MCP server over stdio (DEPRECATED — see below).\n' +
17
17
  ' vitrinka-mcp work … Run the unattended annotation worker (--repo <dir>).\n\n' +
18
18
  'ENV\n' +
19
19
  ' VITRINKA_BASE_URL default https://vitrinka.lovinka.com\n' +
20
20
  ' VITRINKA_TOKEN bearer token; falls back to ~/.config/vitrinka/token\n' +
21
21
  ' (required only by mutating tools; reads work without it)\n\n' +
22
+ 'DEPRECATED: the stdio server is replaced by the remote streamable-HTTP\n' +
23
+ 'endpoint the Go server hosts at /mcp — same tools, zero local processes.\n\n' +
22
24
  'REGISTER (Claude Code, user scope)\n' +
23
- ' claude mcp add --scope user vitrinka -- npx -y --package=@lovinka/vitrinka vitrinka-mcp\n');
25
+ ' claude mcp add --scope user --transport http vitrinka \\\n' +
26
+ ' https://vitrinka.lovinka.com/mcp \\\n' +
27
+ ' --header "Authorization: Bearer $(cat ~/.config/vitrinka/token)"\n');
24
28
  process.exit(0);
25
29
  }
26
30
  if (sub === 'work') {
@@ -1 +1 @@
1
- {"version":3,"file":"bin-mcp.js","sourceRoot":"","sources":["../src/bin-mcp.ts"],"names":[],"mappings":";AACA,gFAAgF;AAChF,8CAA8C;AAC9C,EAAE;AACF,8EAA8E;AAC9E,2EAA2E;AAC3E,+EAA+E;AAC/E,EAAE;AACF,gFAAgF;AAChF,0EAA0E;AAC1E,8CAA8C;AAE9C,MAAM,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAE5B,IAAI,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,IAAI,EAAE,CAAC;IACrC,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,+DAA+D;QAC7D,SAAS;QACT,wEAAwE;QACxE,oFAAoF;QACpF,OAAO;QACP,6DAA6D;QAC7D,6EAA6E;QAC7E,mFAAmF;QACnF,sCAAsC;QACtC,6FAA6F,CAChG,CAAC;IACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,IAAI,GAAG,KAAK,MAAM,EAAE,CAAC;IACnB,MAAM,MAAM,CAAC,WAAW,CAAC,CAAC;AAC5B,CAAC;KAAM,CAAC;IACN,MAAM,MAAM,CAAC,UAAU,CAAC,CAAC;AAC3B,CAAC"}
1
+ {"version":3,"file":"bin-mcp.js","sourceRoot":"","sources":["../src/bin-mcp.ts"],"names":[],"mappings":";AACA,gFAAgF;AAChF,8CAA8C;AAC9C,EAAE;AACF,8EAA8E;AAC9E,2EAA2E;AAC3E,+EAA+E;AAC/E,EAAE;AACF,gFAAgF;AAChF,0EAA0E;AAC1E,8CAA8C;AAE9C,MAAM,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAE5B,IAAI,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,IAAI,EAAE,CAAC;IACrC,OAAO,CAAC,MAAM,CAAC,KAAK,CAClB,+DAA+D;QAC7D,SAAS;QACT,uFAAuF;QACvF,oFAAoF;QACpF,OAAO;QACP,6DAA6D;QAC7D,6EAA6E;QAC7E,mFAAmF;QACnF,0EAA0E;QAC1E,8EAA8E;QAC9E,sCAAsC;QACtC,8DAA8D;QAC9D,2CAA2C;QAC3C,wEAAwE,CAC3E,CAAC;IACF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,IAAI,GAAG,KAAK,MAAM,EAAE,CAAC;IACnB,MAAM,MAAM,CAAC,WAAW,CAAC,CAAC;AAC5B,CAAC;KAAM,CAAC;IACN,MAAM,MAAM,CAAC,UAAU,CAAC,CAAC;AAC3B,CAAC"}
package/dist/cli.js CHANGED
@@ -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)'}`);
@@ -1262,9 +1264,13 @@ 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
  }
@@ -1334,9 +1340,12 @@ function rcPathOf(home) {
1334
1340
  return join(home, (process.env.SHELL || '').includes('bash') ? '.bashrc' : '.zshrc');
1335
1341
  }
1336
1342
  // claudeCliPresent / mcpRegistered / registerMcp — the `claude mcp` touchpoints.
1337
- // The MCP target mirrors updateCmd's repo-vs-npm detection: a checkout registers
1338
- // the repo's stdio shim (a `git pull` IS the update), an npm install registers
1339
- // 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).
1340
1349
  function claudeCliPresent() {
1341
1350
  return !spawnSync('claude', ['--version'], { stdio: 'ignore' }).error;
1342
1351
  }
@@ -1345,15 +1354,22 @@ function mcpRegistered() {
1345
1354
  return r.status === 0 && /\bvitrinka\b/.test(r.stdout);
1346
1355
  }
1347
1356
  function mcpTarget() {
1348
- const repoTop = git(['rev-parse', '--show-toplevel'], dirname(CLI_PATH));
1349
- const shim = repoTop ? join(repoTop, 'mcp', 'server.ts') : '';
1350
- if (shim && existsSync(shim))
1351
- return ['node', shim];
1352
- 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;
1353
1365
  }
1354
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
+ }
1355
1371
  spawnSync('claude', ['mcp', 'remove', '--scope', scope, 'vitrinka'], { stdio: 'ignore' });
1356
- 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;
1357
1373
  }
1358
1374
  // promptLine — visible one-line read from the controlling TTY ('' when
1359
1375
  // non-interactive, like promptYesNo).
@@ -1366,12 +1382,21 @@ function promptLine(question) {
1366
1382
  const buf = Buffer.alloc(512);
1367
1383
  const n = readSync(fd, buf, 0, 512, null);
1368
1384
  closeSync(fd);
1369
- 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));
1370
1390
  }
1371
1391
  catch {
1372
1392
  return ''; // no controlling TTY — treat as non-interactive, never block
1373
1393
  }
1374
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
+ }
1375
1400
  // promptSecret — promptLine with terminal echo off (token input, the old
1376
1401
  // install.sh's `read -rs`). Degrades to a visible read if stty is unavailable.
1377
1402
  function promptSecret(question) {
@@ -1427,7 +1452,7 @@ async function installCmd(args) {
1427
1452
  row(mcpOk, 'mcp', mcpOk ? 'registered' : haveClaude ? `not registered (scope ${mcpScope})` : 'claude CLI not found');
1428
1453
  row(tokenSrc !== 'missing', 'token', tokenSrc === 'missing' ? 'missing — writes will 401' : `present (${tokenSrc === 'env' ? '$VITRINKA_TOKEN' : tokenPath()})`);
1429
1454
  row(!!op, 'operator', op || 'unset — board writes stay anonymous');
1430
- row(uiOk, 'claude ui', uiOk ? 'statusline + footer wired' : 'not wired');
1455
+ row(uiOk, 'claude ui', uiOk ? 'statusline + footer + session hook wired' : 'not wired');
1431
1456
  console.log('');
1432
1457
  // ---- silent steps: plugins, shim, PATH, completion (reversible, own dirs) ----
1433
1458
  for (const rt of runtimes) {
@@ -1457,7 +1482,9 @@ async function installCmd(args) {
1457
1482
  }
1458
1483
  // ---- asked steps: the config-touchers ----
1459
1484
  // 1. MCP registration (claude's registry).
1460
- 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()})"`;
1461
1488
  if (!haveClaude) {
1462
1489
  console.error(`⚠ claude CLI not found — register the MCP later with:\n ${mcpCmdHint}`);
1463
1490
  }
@@ -1514,7 +1541,7 @@ async function installCmd(args) {
1514
1541
  console.log('claude ui: skipped (--no-claude)');
1515
1542
  }
1516
1543
  else if (uiOk || args.claude === true
1517
- || 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] ')) {
1518
1545
  installClaudeCmd(args);
1519
1546
  }
1520
1547
  else if (!process.stdin.isTTY) {
@@ -1661,6 +1688,20 @@ function uninstallCmd(args) {
1661
1688
  console.log('✓ footer badge entry removed');
1662
1689
  }
1663
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
+ }
1664
1705
  if (changed)
1665
1706
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
1666
1707
  }
@@ -1668,6 +1709,15 @@ function uninstallCmd(args) {
1668
1709
  rmSync(wrapperP);
1669
1710
  console.log(`✓ removed ${wrapperP}`);
1670
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
+ }
1671
1721
  // MCP registration (both scopes — cheap, and remove is idempotent).
1672
1722
  if (claudeCliPresent()) {
1673
1723
  for (const scope of local ? ['project', 'user'] : ['user', 'project']) {
@@ -1690,7 +1740,7 @@ function uninstallCmd(args) {
1690
1740
  }
1691
1741
  // ---------- install-claude: Claude Code UI wiring ----------
1692
1742
  //
1693
- // Wires the two Claude Code surfaces that link to boards (decisions:
1743
+ // Wires the three Claude Code surfaces that link to boards (decisions:
1694
1744
  // docs/specs/2026-07-07-claude-install-decisions.md):
1695
1745
  // 1. footerLinksRegexes in ~/.claude/settings.json — Claude renders its own
1696
1746
  // clickable "🧷 board" footer badge whenever a /boards/ URL appears in the
@@ -1703,16 +1753,30 @@ function uninstallCmd(args) {
1703
1753
  // "not planned"), but terminals like Warp/iTerm2/Ghostty cmd+click-linkify
1704
1754
  // visible URLs on their own.
1705
1755
  // With no original statusline, the wrapper renders a minimal line itself.
1706
- // Idempotent, never an uninstall: re-runs detect the /boards/ footer pattern and
1707
- // the wrapper registration and no-op. Removal = delete the wrapper + restore
1708
- // statusLine.command (recorded in the wrapper's VITRINKA_ORIG line) + drop the
1709
- // 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).
1710
1764
  export const BOARD_FOOTER_LINK = {
1711
1765
  type: 'regex',
1712
1766
  pattern: '(?<url>https?:\\/\\/\\S+\\/boards\\/(?<slug>[\\w.-]+))',
1713
1767
  url: '{url}',
1714
1768
  label: 'vitrinka/b/{slug}',
1715
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
+ };
1716
1780
  // hasBoardFooterLink — an entry whose pattern targets /boards/ URLs already
1717
1781
  // exists (ours or hand-written); the marker that makes the settings edit idempotent.
1718
1782
  export function hasBoardFooterLink(settings) {
@@ -1720,6 +1784,41 @@ export function hasBoardFooterLink(settings) {
1720
1784
  // Strip regex escaping first — patterns write the path as \/boards\/ as often as /boards/.
1721
1785
  return Array.isArray(arr) && arr.some((e) => typeof e?.pattern === 'string' && e.pattern.replaceAll('\\', '').includes('/boards/'));
1722
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
+ }
1723
1822
  // buildStatuslineWrapper — the ~/.claude/vitrinka-statusline.sh content. The
1724
1823
  // original command is recorded on the VITRINKA_ORIG line (single-quoted), both
1725
1824
  // to run it and so a re-run can recover it instead of wrapping the wrapper.
@@ -1785,6 +1884,41 @@ else
1785
1884
  fi
1786
1885
  `;
1787
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
+ }
1788
1922
  // wrapperOrigCommand — recover the original statusline command a wrapper file
1789
1923
  // recorded, so re-runs refresh the wrapper without wrapping it around itself.
1790
1924
  export function wrapperOrigCommand(content) {
@@ -1840,6 +1974,24 @@ export function installClaudeCmd(_args) {
1840
1974
  changed = true;
1841
1975
  console.log('✓ footer badge → footerLinksRegexes (clickable "🧷 board" whenever a board URL appears in conversation)');
1842
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
+ }
1843
1995
  // 2. Statusline wrapper.
1844
1996
  const wrapperPath = join(claudeDir, 'vitrinka-statusline.sh');
1845
1997
  const wrapperCmd = '~/.claude/vitrinka-statusline.sh';
@@ -1865,6 +2017,24 @@ export function installClaudeCmd(_args) {
1865
2017
  else
1866
2018
  console.log(`✓ statusline installed → ${wrapperPath} (minimal: dir │ branch │ 🧷 board │ model)`);
1867
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
+ }
1868
2038
  if (changed)
1869
2039
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
1870
2040
  // The wrapper reads Claude's stdin JSON with jq; without it the board segment
@@ -1901,7 +2071,16 @@ function openCmd(root, args) {
1901
2071
  if (args.print === true)
1902
2072
  return;
1903
2073
  const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
1904
- 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' });
1905
2084
  if (r.error || r.status !== 0)
1906
2085
  console.error(`open: could not launch ${opener} — URL printed above`);
1907
2086
  }
@@ -2844,10 +3023,11 @@ export const COMMANDS = [
2844
3023
  examples: ['vitrinka update'],
2845
3024
  },
2846
3025
  {
2847
- name: 'open', summary: 'Open the capture root\'s live board in the browser (--qr renders a terminal QR for the iPad)',
2848
- 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]',
2849
3028
  flags: [ROOT_FLAG, { f: '--qr', d: 'print an ANSI QR code of the URL instead of opening the browser (needs qrencode)' },
2850
- { 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' }],
2851
3031
  examples: ['vitrinka open', 'vitrinka open --qr'],
2852
3032
  },
2853
3033
  {