@lovinka/vitrinka 1.8.0 → 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/CHANGELOG.md +30 -0
- package/README.md +22 -5
- package/dist/bin-mcp.js +6 -2
- package/dist/bin-mcp.js.map +1 -1
- package/dist/cli.js +204 -24
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +16 -3
- package/dist/mcp.js.map +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,36 @@
|
|
|
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.8.3 — 2026-07-11
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- Interactive prompts (`install`'s operator question, any `promptLine`) strip
|
|
10
|
+
ANSI escape sequences and control characters — arrow-key edits no longer
|
|
11
|
+
persist raw `ESC[D`/`ESC[C` bytes into the operator name (which every fresh
|
|
12
|
+
browser adopted as its board identity). `vitrinka operator <name>` sanitizes
|
|
13
|
+
the same way, and the server now rejects control characters with 422.
|
|
14
|
+
|
|
15
|
+
## 1.8.2 — 2026-07-11
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- Desktop-app flag: `~/.config/vitrinka/desktop-app` is the one canonical
|
|
19
|
+
"is Vitrinka.app installed" answer (content = app path). Stamped by
|
|
20
|
+
`apps/desktop make install`, re-synced by `install-claude` and `doctor`.
|
|
21
|
+
- `install-claude` adds a second **⧉ app** footer badge (only when the app is
|
|
22
|
+
installed) whose `vitrinka://` deep link opens the board in Vitrinka.app.
|
|
23
|
+
- `vitrinka open` opens the board in Vitrinka.app when installed;
|
|
24
|
+
`--browser` forces the browser.
|
|
25
|
+
|
|
26
|
+
## 1.8.1 — 2026-07-11
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- `install-claude` now also wires a `SessionStart` hook
|
|
30
|
+
(`~/.claude/hooks/vitrinka-board-link.sh`) that puts the repo's live board
|
|
31
|
+
URL into session context at start — the clickable 🧷 footer badge exists
|
|
32
|
+
from message one instead of waiting for a board URL to be mentioned.
|
|
33
|
+
`vitrinka doctor` reports (and `--fix` repairs) the hook; `uninstall`
|
|
34
|
+
removes it.
|
|
35
|
+
|
|
6
36
|
## 1.8.0 — 2026-07-11
|
|
7
37
|
|
|
8
38
|
### 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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 (
|
|
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
|
|
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') {
|
package/dist/bin-mcp.js.map
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
1338
|
-
//
|
|
1339
|
-
// the
|
|
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
|
|
1349
|
-
const
|
|
1350
|
-
if (
|
|
1351
|
-
|
|
1352
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
1707
|
-
//
|
|
1708
|
-
//
|
|
1709
|
-
//
|
|
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
|
-
|
|
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
|
{
|