@lovinka/vitrinka 1.17.0 → 1.18.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 +28 -0
- package/README.md +16 -17
- package/dist/cli.js +180 -231
- package/dist/cli.js.map +1 -1
- package/dist/lib/registry.js +51 -0
- package/dist/lib/registry.js.map +1 -0
- package/dist/mcp.js +13 -15
- package/dist/mcp.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ import { createInterface } from 'node:readline';
|
|
|
20
20
|
import { readToken, tokenSource, tokenPath, tokenHelp } from "./lib/token.js";
|
|
21
21
|
import { operatorPath, readOperator, actorHeaderValue } from "./lib/operator.js";
|
|
22
22
|
import { resolveBaseUrl } from "./lib/base-url.js";
|
|
23
|
+
import { recordProject } from "./lib/registry.js";
|
|
23
24
|
const CLI_PATH = fileURLToPath(import.meta.url);
|
|
24
25
|
// Cap on the gzipped tarball push streams to the server. spawnSync kills tar (SIGTERM,
|
|
25
26
|
// empty stderr) if its stdout exceeds this, so pushCmd surfaces the size in its error.
|
|
@@ -1425,14 +1426,6 @@ async function doctorCmd(args) {
|
|
|
1425
1426
|
else
|
|
1426
1427
|
console.log('✗ mcp not registered — repair: vitrinka doctor --fix (or vitrinka install)');
|
|
1427
1428
|
}
|
|
1428
|
-
if (home && existsSync(join(home, '.claude', 'vitrinka-statusline.sh'))) {
|
|
1429
|
-
if (fix)
|
|
1430
|
-
installClaudeCmd(args);
|
|
1431
|
-
else if (existsSync(join(home, '.claude', 'hooks', 'vitrinka-board-link.sh')))
|
|
1432
|
-
console.log('✓ claude ui wired (refresh: vitrinka doctor --fix)');
|
|
1433
|
-
else
|
|
1434
|
-
console.log('✗ claude ui session-start hook missing — repair: vitrinka doctor --fix');
|
|
1435
|
-
}
|
|
1436
1429
|
const appPath = refreshDesktopAppFlag();
|
|
1437
1430
|
console.log(appPath ? `✓ app ${appPath} (flag: ${desktopAppFlagPath()})` : `- app Vitrinka.app not installed (no flag)`);
|
|
1438
1431
|
if (failed)
|
|
@@ -1645,8 +1638,16 @@ async function installCmd(args) {
|
|
|
1645
1638
|
const mcpOk = haveClaude ? mcpRegistered() : false;
|
|
1646
1639
|
const tokenSrc = tokenSource();
|
|
1647
1640
|
const op = readOperator(true);
|
|
1648
|
-
|
|
1649
|
-
|
|
1641
|
+
// Prior consent to the Claude UI wiring == the footer badge is already in
|
|
1642
|
+
// settings (the retired statusline wrapper is no longer a signal).
|
|
1643
|
+
const claudeSettingsP = join(home, '.claude', 'settings.json');
|
|
1644
|
+
let uiOk = false;
|
|
1645
|
+
if (existsSync(claudeSettingsP)) {
|
|
1646
|
+
try {
|
|
1647
|
+
uiOk = hasBoardFooterLink(JSON.parse(readFileSync(claudeSettingsP, 'utf8')));
|
|
1648
|
+
}
|
|
1649
|
+
catch { /* malformed settings.json — treat as not-wired; install-claude reports it */ }
|
|
1650
|
+
}
|
|
1650
1651
|
const row = (ok, label, detail) => {
|
|
1651
1652
|
console.log(` ${ok ? '✓' : '✗'} ${label.padEnd(11)}${detail}`);
|
|
1652
1653
|
};
|
|
@@ -1661,7 +1662,7 @@ async function installCmd(args) {
|
|
|
1661
1662
|
row(mcpOk, 'mcp', mcpOk ? 'registered' : haveClaude ? `not registered (scope ${mcpScope})` : 'claude CLI not found');
|
|
1662
1663
|
row(tokenSrc !== 'missing', 'token', tokenSrc === 'missing' ? 'missing — writes will 401' : `present (${tokenSrc === 'env' ? '$VITRINKA_TOKEN' : tokenPath()})`);
|
|
1663
1664
|
row(!!op, 'operator', op || 'unset — board writes stay anonymous');
|
|
1664
|
-
row(uiOk, 'claude ui', uiOk ? '
|
|
1665
|
+
row(uiOk, 'claude ui', uiOk ? 'footer badge wired' : 'not wired');
|
|
1665
1666
|
console.log('');
|
|
1666
1667
|
// ---- silent steps: plugins, shim, PATH, completion (reversible, own dirs) ----
|
|
1667
1668
|
for (const rt of runtimes) {
|
|
@@ -1744,13 +1745,16 @@ async function installCmd(args) {
|
|
|
1744
1745
|
else
|
|
1745
1746
|
console.error('⚠ no operator — board writes stay anonymous (set later: vitrinka operator <name>)');
|
|
1746
1747
|
}
|
|
1747
|
-
// 4. Claude Code UI wiring (edits ~/.claude/settings.json).
|
|
1748
|
-
//
|
|
1748
|
+
// 4. Claude Code UI wiring (edits ~/.claude/settings.json). The retired
|
|
1749
|
+
// statusline extension is cleaned up on every run (silent when absent); the
|
|
1750
|
+
// footer badge is (re-)wired — already-wired machines refresh silently
|
|
1751
|
+
// (consent was given once), fresh ones are asked.
|
|
1752
|
+
cleanupLegacyStatusline(home);
|
|
1749
1753
|
if (args['no-claude'] === true) {
|
|
1750
1754
|
console.log('claude ui: skipped (--no-claude)');
|
|
1751
1755
|
}
|
|
1752
1756
|
else if (uiOk || args.claude === true
|
|
1753
|
-
|| promptYesNo('Wire Claude Code UI (🧷 board
|
|
1757
|
+
|| promptYesNo('Wire Claude Code UI (clickable 🧷 board footer badge)? [Y/n] ')) {
|
|
1754
1758
|
installClaudeCmd(args);
|
|
1755
1759
|
}
|
|
1756
1760
|
else if (!process.stdin.isTTY) {
|
|
@@ -1760,9 +1764,6 @@ async function installCmd(args) {
|
|
|
1760
1764
|
console.log('claude ui: skipped (re-run any time: vitrinka install-claude)');
|
|
1761
1765
|
}
|
|
1762
1766
|
// ---- environment checks (non-fatal) ----
|
|
1763
|
-
if (spawnSync('jq', ['--version'], { stdio: 'ignore' }).error) {
|
|
1764
|
-
console.error('⚠ jq missing (brew install jq) — the statusline 🧷 board segment won\'t render without it');
|
|
1765
|
-
}
|
|
1766
1767
|
if (spawnSync('cwebp', ['-version'], { stdio: 'ignore' }).error) {
|
|
1767
1768
|
console.error('⚠ cwebp missing (brew install webp) — snap falls back to PNG');
|
|
1768
1769
|
}
|
|
@@ -1798,6 +1799,66 @@ export function stripMarkerBlock(content, marker) {
|
|
|
1798
1799
|
// Legacy ~/.claude/skills copy names — removed by uninstall on machines set
|
|
1799
1800
|
// up before the plugin distribution (install-skills, removed 2026-07-11).
|
|
1800
1801
|
const LEGACY_SKILLS = ['artifact', 'brainstorming', 'screenshot', 'vitrinka', 'listen'];
|
|
1802
|
+
// cleanupLegacyStatusline removes the retired Claude Code status-panel extension
|
|
1803
|
+
// (docs/specs/2026-07-20-menubar-tray-decisions.md #5): the
|
|
1804
|
+
// ~/.claude/vitrinka-statusline.sh wrapper — restoring the user's original
|
|
1805
|
+
// statusLine command recorded on its VITRINKA_ORIG line — and the
|
|
1806
|
+
// vitrinka-board-link SessionStart hook + its settings entry. The /boards/
|
|
1807
|
+
// footer badge is a separate surface and is NOT touched here. Silent when
|
|
1808
|
+
// nothing is there; prints one short notice when it removed something. Shared
|
|
1809
|
+
// by `install`/`update` (clean existing installs) and `uninstall`.
|
|
1810
|
+
export function cleanupLegacyStatusline(home) {
|
|
1811
|
+
const wrapperP = join(home, '.claude', 'vitrinka-statusline.sh');
|
|
1812
|
+
const hookP = join(home, '.claude', 'hooks', 'vitrinka-board-link.sh');
|
|
1813
|
+
const settingsPath = join(home, '.claude', 'settings.json');
|
|
1814
|
+
let removed = false;
|
|
1815
|
+
if (existsSync(settingsPath)) {
|
|
1816
|
+
let settings;
|
|
1817
|
+
try {
|
|
1818
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
1819
|
+
}
|
|
1820
|
+
catch (e) {
|
|
1821
|
+
fail(`cleanup: ${settingsPath} is not valid JSON — nothing was changed there; fix it first (${e.message})`);
|
|
1822
|
+
}
|
|
1823
|
+
let changed = false;
|
|
1824
|
+
const sl = settings.statusLine;
|
|
1825
|
+
if (sl?.command && sl.command.replace(/^~(?=\/)/, home) === wrapperP) {
|
|
1826
|
+
const orig = existsSync(wrapperP) ? wrapperOrigCommand(readFileSync(wrapperP, 'utf8')) : '';
|
|
1827
|
+
if (orig)
|
|
1828
|
+
settings.statusLine = { type: 'command', command: orig };
|
|
1829
|
+
else
|
|
1830
|
+
delete settings.statusLine;
|
|
1831
|
+
changed = true;
|
|
1832
|
+
}
|
|
1833
|
+
if (hasBoardLinkHook(settings)) {
|
|
1834
|
+
const hooks = settings.hooks;
|
|
1835
|
+
const ss = hooks.SessionStart
|
|
1836
|
+
.map((m) => (Array.isArray(m?.hooks)
|
|
1837
|
+
? { ...m, hooks: m.hooks.filter((h) => !(typeof h?.command === 'string' && h.command.includes('vitrinka-board-link'))) }
|
|
1838
|
+
: m))
|
|
1839
|
+
.filter((m) => !Array.isArray(m?.hooks) || m.hooks.length > 0);
|
|
1840
|
+
if (ss.length)
|
|
1841
|
+
hooks.SessionStart = ss;
|
|
1842
|
+
else
|
|
1843
|
+
delete hooks.SessionStart;
|
|
1844
|
+
changed = true;
|
|
1845
|
+
}
|
|
1846
|
+
if (changed) {
|
|
1847
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
1848
|
+
removed = true;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
if (existsSync(wrapperP)) {
|
|
1852
|
+
rmSync(wrapperP);
|
|
1853
|
+
removed = true;
|
|
1854
|
+
}
|
|
1855
|
+
if (existsSync(hookP)) {
|
|
1856
|
+
rmSync(hookP);
|
|
1857
|
+
removed = true;
|
|
1858
|
+
}
|
|
1859
|
+
if (removed)
|
|
1860
|
+
console.log('✓ removed legacy statusline extension');
|
|
1861
|
+
}
|
|
1801
1862
|
// uninstallCmd removes everything install put on the machine — the exact
|
|
1802
1863
|
// inverse: bundled skills + the /vitrinka:listen stub, shim, rc marker blocks,
|
|
1803
1864
|
// statusline wrapper (restoring the original statusLine command), the /boards/
|
|
@@ -1862,10 +1923,10 @@ function uninstallCmd(args) {
|
|
|
1862
1923
|
console.log(`✓ removed ${marker} block from ${file}`);
|
|
1863
1924
|
}
|
|
1864
1925
|
}
|
|
1865
|
-
// Claude UI wiring —
|
|
1866
|
-
//
|
|
1926
|
+
// Claude UI wiring — drop the /boards/ footer entry, then run the shared
|
|
1927
|
+
// cleanup that removes the retired statusline wrapper + board-link hook and
|
|
1928
|
+
// restores the user's original statusLine command.
|
|
1867
1929
|
const settingsPath = join(home, '.claude', 'settings.json');
|
|
1868
|
-
const wrapperP = join(home, '.claude', 'vitrinka-statusline.sh');
|
|
1869
1930
|
if (existsSync(settingsPath)) {
|
|
1870
1931
|
let settings;
|
|
1871
1932
|
try {
|
|
@@ -1874,17 +1935,6 @@ function uninstallCmd(args) {
|
|
|
1874
1935
|
catch (e) {
|
|
1875
1936
|
fail(`uninstall: ${settingsPath} is not valid JSON — nothing was changed there; fix it first (${e.message})`);
|
|
1876
1937
|
}
|
|
1877
|
-
let changed = false;
|
|
1878
|
-
const sl = settings.statusLine;
|
|
1879
|
-
if (sl?.command && sl.command.replace(/^~(?=\/)/, home) === wrapperP) {
|
|
1880
|
-
const orig = existsSync(wrapperP) ? wrapperOrigCommand(readFileSync(wrapperP, 'utf8')) : '';
|
|
1881
|
-
if (orig)
|
|
1882
|
-
settings.statusLine = { type: 'command', command: orig };
|
|
1883
|
-
else
|
|
1884
|
-
delete settings.statusLine;
|
|
1885
|
-
changed = true;
|
|
1886
|
-
console.log(`✓ statusline restored${orig ? ` → ${orig}` : ' (removed — none was configured before)'}`);
|
|
1887
|
-
}
|
|
1888
1938
|
const arr = settings.footerLinksRegexes;
|
|
1889
1939
|
if (Array.isArray(arr)) {
|
|
1890
1940
|
const kept = arr.filter((e) => !(typeof e?.pattern === 'string' && e.pattern.replaceAll('\\', '').includes('/boards/')));
|
|
@@ -1893,36 +1943,12 @@ function uninstallCmd(args) {
|
|
|
1893
1943
|
settings.footerLinksRegexes = kept;
|
|
1894
1944
|
else
|
|
1895
1945
|
delete settings.footerLinksRegexes;
|
|
1896
|
-
|
|
1946
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
1897
1947
|
console.log('✓ footer badge entry removed');
|
|
1898
1948
|
}
|
|
1899
1949
|
}
|
|
1900
|
-
if (hasBoardLinkHook(settings)) {
|
|
1901
|
-
const hooks = settings.hooks;
|
|
1902
|
-
const ss = hooks.SessionStart
|
|
1903
|
-
.map((m) => (Array.isArray(m?.hooks)
|
|
1904
|
-
? { ...m, hooks: m.hooks.filter((h) => !(typeof h?.command === 'string' && h.command.includes('vitrinka-board-link'))) }
|
|
1905
|
-
: m))
|
|
1906
|
-
.filter((m) => !Array.isArray(m?.hooks) || m.hooks.length > 0);
|
|
1907
|
-
if (ss.length)
|
|
1908
|
-
hooks.SessionStart = ss;
|
|
1909
|
-
else
|
|
1910
|
-
delete hooks.SessionStart;
|
|
1911
|
-
changed = true;
|
|
1912
|
-
console.log('✓ session-start hook entry removed');
|
|
1913
|
-
}
|
|
1914
|
-
if (changed)
|
|
1915
|
-
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
1916
|
-
}
|
|
1917
|
-
if (existsSync(wrapperP)) {
|
|
1918
|
-
rmSync(wrapperP);
|
|
1919
|
-
console.log(`✓ removed ${wrapperP}`);
|
|
1920
|
-
}
|
|
1921
|
-
const hookP = join(home, '.claude', 'hooks', 'vitrinka-board-link.sh');
|
|
1922
|
-
if (existsSync(hookP)) {
|
|
1923
|
-
rmSync(hookP);
|
|
1924
|
-
console.log(`✓ removed ${hookP}`);
|
|
1925
1950
|
}
|
|
1951
|
+
cleanupLegacyStatusline(home);
|
|
1926
1952
|
if (existsSync(desktopAppFlagPath())) {
|
|
1927
1953
|
rmSync(desktopAppFlagPath());
|
|
1928
1954
|
console.log(`✓ removed ${desktopAppFlagPath()}`);
|
|
@@ -1949,27 +1975,18 @@ function uninstallCmd(args) {
|
|
|
1949
1975
|
}
|
|
1950
1976
|
// ---------- install-claude: Claude Code UI wiring ----------
|
|
1951
1977
|
//
|
|
1952
|
-
// Wires the
|
|
1978
|
+
// Wires the Claude Code footer badges that link to boards (decisions:
|
|
1953
1979
|
// docs/specs/2026-07-07-claude-install-decisions.md):
|
|
1954
1980
|
// 1. footerLinksRegexes in ~/.claude/settings.json — Claude renders its own
|
|
1955
1981
|
// clickable "🧷 board" footer badge whenever a /boards/ URL appears in the
|
|
1956
|
-
// conversation (works in every terminal
|
|
1957
|
-
// 2.
|
|
1958
|
-
//
|
|
1959
|
-
//
|
|
1960
|
-
//
|
|
1961
|
-
//
|
|
1962
|
-
//
|
|
1963
|
-
//
|
|
1964
|
-
// With no original statusline, the wrapper renders a minimal line itself.
|
|
1965
|
-
// 3. SessionStart hook — ~/.claude/hooks/vitrinka-board-link.sh prints the
|
|
1966
|
-
// capture root's board URL into session context at start, so the footer
|
|
1967
|
-
// badge is clickable from message one (the statusline URL isn't).
|
|
1968
|
-
// Idempotent, never an uninstall: re-runs detect the /boards/ footer pattern,
|
|
1969
|
-
// the wrapper registration, and the SessionStart entry and no-op. Removal =
|
|
1970
|
-
// delete the wrapper + hook + restore statusLine.command (recorded in the
|
|
1971
|
-
// wrapper's VITRINKA_ORIG line) + drop the footerLinksRegexes and SessionStart
|
|
1972
|
-
// entries (documented in pkg/README.md).
|
|
1982
|
+
// conversation (works in every terminal).
|
|
1983
|
+
// 2. App badge — a second footer entry whose vitrinka:// deep link opens the
|
|
1984
|
+
// same board in Vitrinka.app, added only where the desktop app is present.
|
|
1985
|
+
// The old statusline segment + SessionStart board-link hook were removed for
|
|
1986
|
+
// good (docs/specs/2026-07-20-menubar-tray-decisions.md #5): active-board
|
|
1987
|
+
// visibility now lives in the desktop app's menu bar tray. Idempotent, never an
|
|
1988
|
+
// uninstall: re-runs detect the /boards/ footer pattern and no-op. Removal = drop
|
|
1989
|
+
// the footerLinksRegexes entries (documented in pkg/README.md).
|
|
1973
1990
|
export const BOARD_FOOTER_LINK = {
|
|
1974
1991
|
type: 'regex',
|
|
1975
1992
|
pattern: '(?<url>https?:\\/\\/\\S+\\/boards\\/(?<slug>[\\w.-]+))',
|
|
@@ -2028,108 +2045,15 @@ export function refreshDesktopAppFlag() {
|
|
|
2028
2045
|
}
|
|
2029
2046
|
return app;
|
|
2030
2047
|
}
|
|
2031
|
-
//
|
|
2032
|
-
//
|
|
2033
|
-
// to run it and so a re-run can recover it instead of wrapping the wrapper.
|
|
2034
|
-
// Board detection mirrors the CLI's capture-root contract: .screenshots/.vitrinka
|
|
2035
|
-
// with {base, key[, workspace]} → ${base}[/w/${workspace}]/boards/${key}. Needs jq
|
|
2036
|
-
// (like the statusline examples in Claude's own docs); without jq it degrades to
|
|
2037
|
-
// pass-through.
|
|
2038
|
-
export function buildStatuslineWrapper(origCmd) {
|
|
2039
|
-
const quoted = origCmd.replaceAll("'", `'\\''`);
|
|
2040
|
-
return `#!/bin/bash
|
|
2041
|
-
# vitrinka-statusline — managed by \`vitrinka install-claude\` (re-runs rewrite it).
|
|
2042
|
-
# Wraps the original Claude Code statusline and appends a 🧷 board-URL segment when
|
|
2043
|
-
# the session's capture root has a .screenshots/.vitrinka descriptor. Plain URL,
|
|
2044
|
-
# not OSC 8 — Claude Code strips OSC 8 from statusline output; terminals
|
|
2045
|
-
# cmd+click-linkify visible URLs themselves.
|
|
2046
|
-
VITRINKA_ORIG='${quoted}'
|
|
2047
|
-
|
|
2048
|
-
input=$(cat)
|
|
2049
|
-
|
|
2050
|
-
out=""
|
|
2051
|
-
if [ -n "$VITRINKA_ORIG" ]; then
|
|
2052
|
-
out=$(printf '%s' "$input" | /bin/sh -c "$VITRINKA_ORIG")
|
|
2053
|
-
fi
|
|
2054
|
-
|
|
2055
|
-
# The original already renders a board segment → pass through untouched.
|
|
2056
|
-
case "$out" in *'/boards/'*|*'🧷'*) printf '%s\\n' "$out"; exit 0 ;; esac
|
|
2057
|
-
|
|
2058
|
-
if ! command -v jq >/dev/null 2>&1; then printf '%s\\n' "$out"; exit 0; fi
|
|
2059
|
-
|
|
2060
|
-
DIR=$(printf '%s' "$input" | jq -r '.workspace.current_dir // empty')
|
|
2061
|
-
PROJECT_DIR=$(printf '%s' "$input" | jq -r '.workspace.project_dir // empty')
|
|
2062
|
-
|
|
2063
|
-
BOARD=""
|
|
2064
|
-
for VJSON in "$DIR/.screenshots/.vitrinka" "$PROJECT_DIR/.screenshots/.vitrinka"; do
|
|
2065
|
-
[ -f "$VJSON" ] || continue
|
|
2066
|
-
VBASE=$(jq -r '.base // empty' "$VJSON" 2>/dev/null)
|
|
2067
|
-
VKEY=$(jq -r '.key // empty' "$VJSON" 2>/dev/null)
|
|
2068
|
-
VWS=$(jq -r '.workspace // empty' "$VJSON" 2>/dev/null)
|
|
2069
|
-
if [ -n "$VBASE" ] && [ -n "$VKEY" ]; then
|
|
2070
|
-
[ -n "$VWS" ] && VBASE="$VBASE/w/$VWS"
|
|
2071
|
-
BOARD=$(printf '\\033[35m🧷\\033[0m \\033[2m%s\\033[0m' "$VBASE/boards/$VKEY")
|
|
2072
|
-
fi
|
|
2073
|
-
break
|
|
2074
|
-
done
|
|
2075
|
-
|
|
2076
|
-
DIM=$(printf '\\033[2m'); RESET=$(printf '\\033[0m')
|
|
2077
|
-
|
|
2078
|
-
if [ -n "$VITRINKA_ORIG" ]; then
|
|
2079
|
-
# Append to the FIRST line only (extra statusline lines stay untouched).
|
|
2080
|
-
first=\${out%%$'\\n'*}; rest=\${out#"$first"}
|
|
2081
|
-
[ -n "$BOARD" ] && first="$first \${DIM}│\${RESET} $BOARD"
|
|
2082
|
-
printf '%s%s\\n' "$first" "$rest"
|
|
2083
|
-
else
|
|
2084
|
-
# No original statusline — minimal line: dir │ branch │ board │ model.
|
|
2085
|
-
MODEL=$(printf '%s' "$input" | jq -r '.model.display_name // empty')
|
|
2086
|
-
BRANCH=$(git -C "\${DIR:-.}" branch --show-current 2>/dev/null)
|
|
2087
|
-
GREEN=$(printf '\\033[32m'); CYAN=$(printf '\\033[36m')
|
|
2088
|
-
line="\${DIR/#$HOME/~}"
|
|
2089
|
-
[ -n "$BRANCH" ] && line="$line \${DIM}│\${RESET} \${GREEN}\${BRANCH}\${RESET}"
|
|
2090
|
-
[ -n "$BOARD" ] && line="$line \${DIM}│\${RESET} $BOARD"
|
|
2091
|
-
[ -n "$MODEL" ] && line="$line \${DIM}│\${RESET} \${CYAN}\${MODEL}\${RESET}"
|
|
2092
|
-
printf '%s\\n' "$line"
|
|
2093
|
-
fi
|
|
2094
|
-
`;
|
|
2095
|
-
}
|
|
2096
|
-
// SessionStart hook — the third Claude UI surface. Statusline URLs aren't
|
|
2097
|
-
// clickable in every terminal (and never OSC 8), so this puts the board URL
|
|
2098
|
-
// into the session context at start: the footer badge renders from message
|
|
2099
|
-
// one and Claude knows the live board without asking. Silent no-op when the
|
|
2100
|
-
// repo has no .screenshots/.vitrinka descriptor.
|
|
2101
|
-
export const BOARD_LINK_HOOK_CMD = '~/.claude/hooks/vitrinka-board-link.sh';
|
|
2102
|
-
export const BOARD_LINK_HOOK_SCRIPT = `#!/bin/bash
|
|
2103
|
-
# vitrinka-board-link — SessionStart hook, managed by \`vitrinka install-claude\`
|
|
2104
|
-
# (re-runs rewrite it). Emits the repo's live board URL into session context so
|
|
2105
|
-
# the clickable footer badge (footerLinksRegexes /boards/ entry) exists from
|
|
2106
|
-
# message one. Silent no-op without a .screenshots/.vitrinka descriptor.
|
|
2107
|
-
command -v jq >/dev/null 2>&1 || exit 0
|
|
2108
|
-
input=$(cat)
|
|
2109
|
-
dir=$(printf '%s' "$input" | jq -r '.cwd // ""')
|
|
2110
|
-
dir="\${dir:-$PWD}"
|
|
2111
|
-
root=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null)
|
|
2112
|
-
vjson=""
|
|
2113
|
-
for c in "$dir/.screenshots/.vitrinka" \${root:+"$root/.screenshots/.vitrinka"}; do
|
|
2114
|
-
[ -f "$c" ] && { vjson=$c; break; }
|
|
2115
|
-
done
|
|
2116
|
-
[ -n "$vjson" ] || exit 0
|
|
2117
|
-
base=$(jq -r '.base // empty' "$vjson" 2>/dev/null)
|
|
2118
|
-
key=$(jq -r '.key // empty' "$vjson" 2>/dev/null)
|
|
2119
|
-
ws=$(jq -r '.workspace // empty' "$vjson" 2>/dev/null)
|
|
2120
|
-
[ -n "$base" ] && [ -n "$key" ] || exit 0
|
|
2121
|
-
[ -n "$ws" ] && base="$base/w/$ws"
|
|
2122
|
-
printf 'Live vitrinka board for this repo: %s/boards/%s\\n' "$base" "$key"
|
|
2123
|
-
`;
|
|
2124
|
-
// hasBoardLinkHook — a SessionStart entry running vitrinka-board-link already
|
|
2125
|
-
// exists; the marker that makes the settings edit idempotent.
|
|
2048
|
+
// hasBoardLinkHook — a SessionStart entry running the retired vitrinka-board-link
|
|
2049
|
+
// hook still exists; used by cleanupLegacyStatusline to find and drop it.
|
|
2126
2050
|
export function hasBoardLinkHook(settings) {
|
|
2127
2051
|
const arr = settings.hooks?.SessionStart;
|
|
2128
2052
|
return Array.isArray(arr) && arr.some((m) => Array.isArray(m?.hooks)
|
|
2129
2053
|
&& m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('vitrinka-board-link')));
|
|
2130
2054
|
}
|
|
2131
|
-
// wrapperOrigCommand — recover the original statusline command a wrapper
|
|
2132
|
-
// recorded, so
|
|
2055
|
+
// wrapperOrigCommand — recover the original statusline command a legacy wrapper
|
|
2056
|
+
// file recorded, so cleanup can restore it when removing the retired extension.
|
|
2133
2057
|
export function wrapperOrigCommand(content) {
|
|
2134
2058
|
const line = content.split('\n').find((l) => l.startsWith("VITRINKA_ORIG='"));
|
|
2135
2059
|
if (!line || !line.endsWith("'"))
|
|
@@ -2201,56 +2125,10 @@ export function installClaudeCmd(_args) {
|
|
|
2201
2125
|
changed = true;
|
|
2202
2126
|
console.log('✓ app badge removed (Vitrinka.app not installed on this machine)');
|
|
2203
2127
|
}
|
|
2204
|
-
//
|
|
2205
|
-
|
|
2206
|
-
const wrapperCmd = '~/.claude/vitrinka-statusline.sh';
|
|
2207
|
-
const sl = settings.statusLine;
|
|
2208
|
-
if (sl && sl.type !== undefined && sl.type !== 'command') {
|
|
2209
|
-
console.error(`⚠ statusLine.type is ${JSON.stringify(sl.type)} — don't know how to wrap it; statusline left untouched (footer badge still works)`);
|
|
2210
|
-
}
|
|
2211
|
-
else {
|
|
2212
|
-
const current = sl?.command ?? '';
|
|
2213
|
-
const isWrapper = current.replace(/^~(?=\/)/, home) === wrapperPath;
|
|
2214
|
-
// Re-runs recover the original from the existing wrapper file — never wrap the wrapper.
|
|
2215
|
-
const orig = isWrapper && existsSync(wrapperPath) ? wrapperOrigCommand(readFileSync(wrapperPath, 'utf8')) : current;
|
|
2216
|
-
writeFileSync(wrapperPath, buildStatuslineWrapper(orig));
|
|
2217
|
-
chmodSync(wrapperPath, 0o755);
|
|
2218
|
-
if (!isWrapper) {
|
|
2219
|
-
settings.statusLine = { type: 'command', command: wrapperCmd };
|
|
2220
|
-
changed = true;
|
|
2221
|
-
}
|
|
2222
|
-
if (isWrapper)
|
|
2223
|
-
console.log(`✓ statusline wrapper refreshed (${wrapperPath}${orig ? `, wraps: ${orig}` : ', minimal line'})`);
|
|
2224
|
-
else if (orig)
|
|
2225
|
-
console.log(`✓ statusline wrapped → ${wrapperPath} (appends 🧷 board to: ${orig})`);
|
|
2226
|
-
else
|
|
2227
|
-
console.log(`✓ statusline installed → ${wrapperPath} (minimal: dir │ branch │ 🧷 board │ model)`);
|
|
2228
|
-
}
|
|
2229
|
-
// 3. SessionStart hook — board URL into context at session start, so the
|
|
2230
|
-
// footer badge is clickable from message one.
|
|
2231
|
-
const hooksDir = join(claudeDir, 'hooks');
|
|
2232
|
-
const hookPath = join(hooksDir, 'vitrinka-board-link.sh');
|
|
2233
|
-
mkdirSync(hooksDir, { recursive: true });
|
|
2234
|
-
writeFileSync(hookPath, BOARD_LINK_HOOK_SCRIPT);
|
|
2235
|
-
chmodSync(hookPath, 0o755);
|
|
2236
|
-
if (hasBoardLinkHook(settings)) {
|
|
2237
|
-
console.log(`✓ session-start hook refreshed (${hookPath})`);
|
|
2238
|
-
}
|
|
2239
|
-
else {
|
|
2240
|
-
const hooks = (settings.hooks && typeof settings.hooks === 'object' ? settings.hooks : {});
|
|
2241
|
-
const arr = Array.isArray(hooks.SessionStart) ? hooks.SessionStart : [];
|
|
2242
|
-
hooks.SessionStart = [...arr, { hooks: [{ type: 'command', command: BOARD_LINK_HOOK_CMD, timeout: 10 }] }];
|
|
2243
|
-
settings.hooks = hooks;
|
|
2244
|
-
changed = true;
|
|
2245
|
-
console.log(`✓ session-start hook → ${hookPath} (board URL in context from message one)`);
|
|
2246
|
-
}
|
|
2128
|
+
// The retired statusline segment + board-link hook are cleaned up (not wired)
|
|
2129
|
+
// by install/update/uninstall via cleanupLegacyStatusline; nothing to do here.
|
|
2247
2130
|
if (changed)
|
|
2248
2131
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
2249
|
-
// The wrapper reads Claude's stdin JSON with jq; without it the board segment
|
|
2250
|
-
// silently degrades to pass-through — surface that at install time.
|
|
2251
|
-
if (spawnSync('jq', ['--version'], { stdio: 'ignore' }).error) {
|
|
2252
|
-
console.error('⚠ jq not found — the statusline 🧷 board segment needs it (brew install jq); footer badge works regardless');
|
|
2253
|
-
}
|
|
2254
2132
|
console.log(`install-claude done — restart Claude Code sessions to pick up ${settingsPath}`);
|
|
2255
2133
|
}
|
|
2256
2134
|
// ---------- open: jump to the live board ----------
|
|
@@ -2293,6 +2171,43 @@ function openCmd(root, args) {
|
|
|
2293
2171
|
if (r.error || r.status !== 0)
|
|
2294
2172
|
console.error(`open: could not launch ${opener} — URL printed above`);
|
|
2295
2173
|
}
|
|
2174
|
+
// ---------- edit: open a file/dir in the resident editor ----------
|
|
2175
|
+
// buildEditUrl — the vitrinka:// deep link the desktop app routes to its editor
|
|
2176
|
+
// page. PURE (unit-tested): the path is percent-encoded so spaces and unicode
|
|
2177
|
+
// survive the URL round-trip.
|
|
2178
|
+
export function buildEditUrl(absPath) {
|
|
2179
|
+
return `vitrinka://edit?path=${encodeURIComponent(absPath)}`;
|
|
2180
|
+
}
|
|
2181
|
+
// editCmd — `vitrinka edit [path]`: focus the resident Vitrinka.app on a file or
|
|
2182
|
+
// project (instant-editor decision #4). Records the repo in the project registry
|
|
2183
|
+
// as a side effect (#6) so the app's project list stays current, then opens the
|
|
2184
|
+
// vitrinka://edit deep link. --print emits only the URL; when the app isn't
|
|
2185
|
+
// installed we print a one-line install hint plus the URL (nothing to launch).
|
|
2186
|
+
function editCmd(rest, args) {
|
|
2187
|
+
const positional = rest.find((a) => !a.startsWith('--')) || '.';
|
|
2188
|
+
const absPath = resolve(positional);
|
|
2189
|
+
// Inside a git repo → record {project → main worktree top} so the desktop app
|
|
2190
|
+
// discovers this checkout. Same project derivation as statusCmd; the stored
|
|
2191
|
+
// path is the MAIN worktree top (the app enumerates worktrees itself).
|
|
2192
|
+
const top = mainWorktreeTop(absPath);
|
|
2193
|
+
if (top)
|
|
2194
|
+
recordProject(sanitizeSeg(strArg(args.project) || basename(top), 64), top);
|
|
2195
|
+
const url = buildEditUrl(absPath);
|
|
2196
|
+
if (args.print === true) {
|
|
2197
|
+
console.log(url);
|
|
2198
|
+
return;
|
|
2199
|
+
}
|
|
2200
|
+
// Only the resident macOS app can handle vitrinka://edit. When it's absent,
|
|
2201
|
+
// there's nothing to launch — surface the install hint alongside the URL.
|
|
2202
|
+
if (process.platform !== 'darwin' || !existsSync(desktopAppFlagPath())) {
|
|
2203
|
+
console.log(url);
|
|
2204
|
+
console.error('edit: Vitrinka.app not installed — install it with: cd apps/desktop && make install');
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
const r = spawnSync('open', [url], { stdio: 'ignore' });
|
|
2208
|
+
if (r.error || r.status !== 0)
|
|
2209
|
+
console.error(`edit: could not launch open — URL: ${url}`);
|
|
2210
|
+
}
|
|
2296
2211
|
// ---------- status: the session dashboard ----------
|
|
2297
2212
|
// statusCmd — one glance at this repo+branch's vitrinka world: the live board,
|
|
2298
2213
|
// the open work queue (same scope derivation as `watch`), and who holds the
|
|
@@ -2300,8 +2215,11 @@ function openCmd(root, args) {
|
|
|
2300
2215
|
async function statusCmd(root, args) {
|
|
2301
2216
|
const base = resolveBaseUrl(strArg(args.base));
|
|
2302
2217
|
const cwd = resolve('.');
|
|
2303
|
-
const
|
|
2218
|
+
const top = mainWorktreeTop(cwd);
|
|
2219
|
+
const project = sanitizeSeg(strArg(args.project) || basename(top || cwd), 64);
|
|
2304
2220
|
const branch = sanitizeSeg(strArg(args.branch) || git(['branch', '--show-current'], cwd) || 'main', 100);
|
|
2221
|
+
if (top)
|
|
2222
|
+
recordProject(project, top); // feed the desktop app's project registry
|
|
2305
2223
|
console.log(`vitrinka status — ${project}/${branch}`);
|
|
2306
2224
|
const board = boardUrlOf(root);
|
|
2307
2225
|
console.log(board ? ` board ${board}` : ' board none yet (publish one: vitrinka board-from-set)');
|
|
@@ -2583,17 +2501,27 @@ async function updateCmd(_args) {
|
|
|
2583
2501
|
}
|
|
2584
2502
|
}
|
|
2585
2503
|
// Refresh installed surfaces from the NEW code — plugins where installed,
|
|
2586
|
-
// Claude wiring only
|
|
2504
|
+
// Claude wiring only where the footer badge shows prior consent.
|
|
2587
2505
|
const rerun = (sub) => spawnSync(process.execPath, [CLI_PATH, ...sub], { stdio: 'inherit' }).status === 0;
|
|
2588
2506
|
for (const rt of pluginRuntimes()) {
|
|
2589
2507
|
if (rt.installed && !updatePluginIn(rt)) {
|
|
2590
2508
|
console.error(`⚠ update: ${rt.name} plugin refresh failed — re-run its plugin update manually`);
|
|
2591
2509
|
}
|
|
2592
2510
|
}
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2511
|
+
// Remove the retired statusline extension from existing installs, then refresh
|
|
2512
|
+
// the footer badge where it was already wired.
|
|
2513
|
+
const home = process.env.HOME || '';
|
|
2514
|
+
cleanupLegacyStatusline(home);
|
|
2515
|
+
const settingsP = join(home, '.claude', 'settings.json');
|
|
2516
|
+
let consented = false;
|
|
2517
|
+
if (existsSync(settingsP)) {
|
|
2518
|
+
try {
|
|
2519
|
+
consented = hasBoardFooterLink(JSON.parse(readFileSync(settingsP, 'utf8')));
|
|
2520
|
+
}
|
|
2521
|
+
catch { /* malformed settings.json — treat as not-wired; install-claude reports it */ }
|
|
2522
|
+
}
|
|
2523
|
+
if (consented && !rerun(['install-claude'])) {
|
|
2524
|
+
console.error('⚠ update: Claude UI refresh failed — re-run: vitrinka install-claude');
|
|
2597
2525
|
}
|
|
2598
2526
|
console.log('update done — try: vitrinka --help');
|
|
2599
2527
|
}
|
|
@@ -3315,7 +3243,7 @@ export const COMMANDS = [
|
|
|
3315
3243
|
examples: ['vitrinka uninstall', 'vitrinka uninstall --purge --yes'],
|
|
3316
3244
|
},
|
|
3317
3245
|
{
|
|
3318
|
-
name: 'install-claude', summary: 'Wire the Claude Code UI: 🧷 board
|
|
3246
|
+
name: 'install-claude', summary: 'Wire the Claude Code UI: clickable 🧷 board footer badge',
|
|
3319
3247
|
usage: '', flags: [],
|
|
3320
3248
|
examples: ['vitrinka install-claude'],
|
|
3321
3249
|
},
|
|
@@ -3338,6 +3266,14 @@ export const COMMANDS = [
|
|
|
3338
3266
|
{ f: '--browser', d: 'force the browser even when Vitrinka.app is installed' }],
|
|
3339
3267
|
examples: ['vitrinka open', 'vitrinka open --qr'],
|
|
3340
3268
|
},
|
|
3269
|
+
{
|
|
3270
|
+
name: 'edit', summary: 'Open a file or project in the resident Vitrinka.app editor (records the repo for the app\'s project list)',
|
|
3271
|
+
usage: '[<path>] [--print] [--project <p>]',
|
|
3272
|
+
positional: '[<path>]',
|
|
3273
|
+
flags: [{ f: '--print', d: 'only print the vitrinka://edit URL' },
|
|
3274
|
+
{ f: '--project', arg: '<p>', d: 'override the recorded project name' }],
|
|
3275
|
+
examples: ['vitrinka edit', 'vitrinka edit src/cli.ts', 'vitrinka edit --print'],
|
|
3276
|
+
},
|
|
3341
3277
|
{
|
|
3342
3278
|
name: 'status', summary: 'Session dashboard: live board, open work queue, and listener lease for this repo+branch',
|
|
3343
3279
|
usage: '[--root <dir>] [--project <p>] [--branch <b>] [--base <url>]',
|
|
@@ -3947,6 +3883,11 @@ async function boardFromSetCmd(root, args, journey = false) {
|
|
|
3947
3883
|
process.exit(2);
|
|
3948
3884
|
}
|
|
3949
3885
|
const cfg = loadVitrinkaCfg(root);
|
|
3886
|
+
// The .vitrinka descriptor names the project — record its main worktree top so
|
|
3887
|
+
// the desktop app can discover this checkout (instant-editor decision #6).
|
|
3888
|
+
const projTop = mainWorktreeTop(dirname(root));
|
|
3889
|
+
if (cfg.project && projTop)
|
|
3890
|
+
recordProject(sanitizeSeg(cfg.project, 64), projTop);
|
|
3950
3891
|
const slug = (strArg(args.slug) || cfg.key).toLowerCase();
|
|
3951
3892
|
const title = strArg(args.btitle) || strArg(args.title) || '';
|
|
3952
3893
|
const headers = { 'Content-Type': 'application/json' };
|
|
@@ -4096,7 +4037,9 @@ export function detectImportKind(explicit, file, content) {
|
|
|
4096
4037
|
return 'openapi';
|
|
4097
4038
|
return '';
|
|
4098
4039
|
}
|
|
4099
|
-
// postImport sends a raw source to the board import endpoint and prints the
|
|
4040
|
+
// postImport sends a raw source to the board import endpoint and prints the
|
|
4041
|
+
// server's summary ({id, elemNo, title, counts…}) — or the full card JSON with
|
|
4042
|
+
// --verbose (mcp-token-economy #3: the geo boxes are noise by default).
|
|
4100
4043
|
async function postImport(board, body, args) {
|
|
4101
4044
|
const base = resolveBaseUrl(strArg(args.base));
|
|
4102
4045
|
const headers = { 'Content-Type': 'application/json' };
|
|
@@ -4106,7 +4049,8 @@ async function postImport(board, body, args) {
|
|
|
4106
4049
|
const actor = actorFor(args);
|
|
4107
4050
|
if (actor)
|
|
4108
4051
|
headers['X-Board-Actor'] = actor;
|
|
4109
|
-
const
|
|
4052
|
+
const verbose = args.verbose === true ? '?verbose=1' : '';
|
|
4053
|
+
const res = await fetch(`${base}/api/v1/boards/${encodeURIComponent(board)}/import${verbose}`, {
|
|
4110
4054
|
method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(120_000),
|
|
4111
4055
|
});
|
|
4112
4056
|
const text = await res.text();
|
|
@@ -4120,7 +4064,7 @@ async function importCmd(rest, args) {
|
|
|
4120
4064
|
const file = rest.find((a) => !a.startsWith('--'));
|
|
4121
4065
|
const board = strArg(args.board);
|
|
4122
4066
|
if (!file || !board) {
|
|
4123
|
-
console.error('usage: vitrinka import <file> --board <slug> [--kind auto|openapi|sqlddl|compose] [--title "…"]');
|
|
4067
|
+
console.error('usage: vitrinka import <file> --board <slug> [--kind auto|openapi|sqlddl|compose] [--title "…"] [--verbose]');
|
|
4124
4068
|
process.exit(2);
|
|
4125
4069
|
}
|
|
4126
4070
|
if (!existsSync(file)) {
|
|
@@ -4385,8 +4329,11 @@ async function watchCmd(args) {
|
|
|
4385
4329
|
// Repo context for scope inference (main worktree basename = project; current
|
|
4386
4330
|
// branch). '.' cwd is where the session runs (the app repo).
|
|
4387
4331
|
const cwd = resolve('.');
|
|
4388
|
-
const
|
|
4332
|
+
const repoTop = mainWorktreeTop(cwd);
|
|
4333
|
+
const repoProject = basename(repoTop || cwd);
|
|
4389
4334
|
const repoBranch = git(['branch', '--show-current'], cwd);
|
|
4335
|
+
if (repoTop)
|
|
4336
|
+
recordProject(sanitizeSeg(repoProject, 64), repoTop); // register the repo for the desktop app
|
|
4390
4337
|
const scope = deriveWatchScope(args, { repoProject, repoBranch });
|
|
4391
4338
|
const sq = scopeQuery(scope);
|
|
4392
4339
|
const listUrl = () => {
|
|
@@ -4621,6 +4568,8 @@ async function runSub(argv) {
|
|
|
4621
4568
|
versionCmd(false);
|
|
4622
4569
|
else if (cmd === 'open')
|
|
4623
4570
|
openCmd(root, args);
|
|
4571
|
+
else if (cmd === 'edit')
|
|
4572
|
+
editCmd(rest, args);
|
|
4624
4573
|
else if (cmd === 'status')
|
|
4625
4574
|
await statusCmd(root, args);
|
|
4626
4575
|
else if (cmd === 'snap')
|