@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/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
- const wrapperP = join(home, '.claude', 'vitrinka-statusline.sh');
1649
- const uiOk = existsSync(wrapperP);
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 ? 'statusline + footer + session hook wired' : 'not wired');
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). Already-wired
1748
- // machines refresh silently (consent was given once); fresh ones are asked.
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 statusline segment + clickable footer badge + session-start board link)? [Y/n] ')) {
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 — restore statusLine from the wrapper's VITRINKA_ORIG,
1866
- // drop the /boards/ footer entry, delete the wrapper.
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
- changed = true;
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 three Claude Code surfaces that link to boards (decisions:
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, unlike statusline OSC 8).
1957
- // 2. statusLine — a wrapper script that pipes the same stdin JSON to the
1958
- // user's ORIGINAL statusline command and appends a "🧷 <board-url>" plain-text
1959
- // segment when the session's capture root has a .screenshots/.vitrinka
1960
- // descriptor. Plain URL on purpose: Claude Code strips OSC 8 from statusline
1961
- // output in real terminals (anthropics/claude-code#21586, #26356 — closed
1962
- // "not planned"), but terminals like Warp/iTerm2/Ghostty cmd+click-linkify
1963
- // visible URLs on their own.
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
- // buildStatuslineWrapper — the ~/.claude/vitrinka-statusline.sh content. The
2032
- // original command is recorded on the VITRINKA_ORIG line (single-quoted), both
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
+ // hasBoardLinkHooka 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 file
2132
- // recorded, so re-runs refresh the wrapper without wrapping it around itself.
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
- // 2. Statusline wrapper.
2205
- const wrapperPath = join(claudeDir, 'vitrinka-statusline.sh');
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 project = sanitizeSeg(strArg(args.project) || basename(mainWorktreeTop(cwd) || cwd), 64);
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 if the wrapper shows prior consent.
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
- const wrapper = join(process.env.HOME || '', '.claude', 'vitrinka-statusline.sh');
2594
- if (existsSync(wrapper)) {
2595
- if (!rerun(['install-claude']))
2596
- console.error('⚠ update: Claude UI refresh failed — re-run: vitrinka install-claude');
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 statusline segment + clickable footer badge',
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 card.
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 res = await fetch(`${base}/api/v1/boards/${encodeURIComponent(board)}/import`, {
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 repoProject = basename(mainWorktreeTop(cwd) || cwd);
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')