@lovinka/vitrinka 1.17.0 → 1.20.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 +58 -0
- package/README.md +16 -17
- package/dist/cli.js +626 -271
- package/dist/cli.js.map +1 -1
- package/dist/lib/registry.js +64 -0
- package/dist/lib/registry.js.map +1 -0
- package/dist/lib/tui.js +79 -0
- package/dist/lib/tui.js.map +1 -0
- package/dist/mcp.js +13 -15
- package/dist/mcp.js.map +1 -1
- package/dist/work.js +4 -1
- package/dist/work.js.map +1 -1
- package/package.json +5 -1
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// cli.ts — the unified vitrinka CLI. Maintains `.screenshots/manifest.json` + a self-contained,
|
|
2
|
+
// cli.ts — the unified vitrinka CLI. Maintains `.vitrinka/screenshots/manifest.json` + a self-contained,
|
|
3
3
|
// clickable `index.html` gallery, publishes sets to vitrinka (the internal artifacts app), and
|
|
4
4
|
// one-shots capture (`snap`) + artifact scaffolding (`artifact-init`, `artifact-from-set`).
|
|
5
5
|
//
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// skills/screenshot/gallery.mjs (offline marker, COPYFILE_DISABLE, control-file excludes,
|
|
11
11
|
// `--key`, repeatable `--chip`). gallery.mjs is now a passthrough shim to this file.
|
|
12
12
|
// Design: vitrinka/docs/plans/2026-07-03-hand-in-hand-tooling-design.md
|
|
13
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync, copyFileSync, mkdtempSync, chmodSync, appendFileSync, statSync, openSync, readSync, closeSync, realpathSync, openAsBlob, lstatSync, constants as fsConstants, } from 'node:fs';
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, renameSync, readdirSync, copyFileSync, mkdtempSync, chmodSync, appendFileSync, statSync, openSync, readSync, closeSync, realpathSync, openAsBlob, lstatSync, constants as fsConstants, } from 'node:fs';
|
|
14
14
|
import { join, resolve, basename, dirname, extname, relative, sep, isAbsolute } from 'node:path';
|
|
15
15
|
import { tmpdir, hostname } from 'node:os';
|
|
16
16
|
import { spawnSync, spawn } from 'node:child_process';
|
|
@@ -20,6 +20,8 @@ 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, registeredPath } from "./lib/registry.js";
|
|
24
|
+
import * as tui from "./lib/tui.js";
|
|
23
25
|
const CLI_PATH = fileURLToPath(import.meta.url);
|
|
24
26
|
// Cap on the gzipped tarball push streams to the server. spawnSync kills tar (SIGTERM,
|
|
25
27
|
// empty stderr) if its stdout exceeds this, so pushCmd surfaces the size in its error.
|
|
@@ -161,7 +163,7 @@ function saveManifest(root, m) {
|
|
|
161
163
|
}
|
|
162
164
|
function buildIndex(root) {
|
|
163
165
|
const m = loadManifest(root);
|
|
164
|
-
const project = basename(
|
|
166
|
+
const project = basename(projectDirOf(root)) || 'project';
|
|
165
167
|
const htmlPath = htmlPathOf(root);
|
|
166
168
|
// A fresh/nonexistent root builds an empty gallery — mirror saveManifest's
|
|
167
169
|
// dir creation instead of crashing with a raw ENOENT.
|
|
@@ -228,7 +230,7 @@ function appendShot(root, shot) {
|
|
|
228
230
|
// v2: stamp the session's code identity (last append wins — the freshest
|
|
229
231
|
// commit is the one the newest pixels came from).
|
|
230
232
|
m.version = Math.max(m.version || 1, 2);
|
|
231
|
-
const projectDir =
|
|
233
|
+
const projectDir = projectDirOf(root);
|
|
232
234
|
const commit = git(['rev-parse', '--short', 'HEAD'], projectDir);
|
|
233
235
|
const branch = git(['branch', '--show-current'], projectDir);
|
|
234
236
|
if (commit)
|
|
@@ -236,7 +238,7 @@ function appendShot(root, shot) {
|
|
|
236
238
|
if (branch)
|
|
237
239
|
m.branch = branch;
|
|
238
240
|
saveManifest(root, m);
|
|
239
|
-
const project = basename(
|
|
241
|
+
const project = basename(projectDirOf(root)) || 'project';
|
|
240
242
|
const htmlPath = htmlPathOf(root);
|
|
241
243
|
writeFileSync(htmlPath, renderHtml(m.shots, project));
|
|
242
244
|
return { count: m.shots.length, htmlPath };
|
|
@@ -512,6 +514,41 @@ export function mainWorktreeTop(cwd) {
|
|
|
512
514
|
return dirname(common);
|
|
513
515
|
return git(['rev-parse', '--show-toplevel'], cwd);
|
|
514
516
|
}
|
|
517
|
+
// ── .vitrinka/ home (decisions 2026-07-23) ────────────────────────────────
|
|
518
|
+
// Everything vitrinka writes into a target project lives under ONE dir:
|
|
519
|
+
// .vitrinka/screenshots/ (was ./.screenshots)
|
|
520
|
+
// .vitrinka/artifacts/<slug>/ (was ./.artifacts/<slug>)
|
|
521
|
+
// .vitrinka/scratch/<topic>/ (sanctioned home for ad-hoc session output)
|
|
522
|
+
// Legacy roots auto-migrate on first touch (rename + one notice); the home
|
|
523
|
+
// gets a single `.vitrinka/` .gitignore line on top of the per-root
|
|
524
|
+
// info/exclude self-ignore.
|
|
525
|
+
const VITRINKA_HOME = '.vitrinka';
|
|
526
|
+
function homeRoot(kind, cwd = process.cwd()) {
|
|
527
|
+
const home = resolve(cwd, VITRINKA_HOME);
|
|
528
|
+
const next = join(home, kind);
|
|
529
|
+
const legacy = kind === 'scratch' ? '' : resolve(cwd, kind === 'screenshots' ? '.screenshots' : '.artifacts');
|
|
530
|
+
if (legacy && existsSync(legacy) && !existsSync(next)) {
|
|
531
|
+
mkdirSync(home, { recursive: true });
|
|
532
|
+
renameSync(legacy, next);
|
|
533
|
+
console.error(`vitrinka: migrated ${relative(cwd, legacy)} → ${relative(cwd, next)}`);
|
|
534
|
+
}
|
|
535
|
+
if (existsSync(home))
|
|
536
|
+
ensureGitIgnored(cwd, `${VITRINKA_HOME}/`);
|
|
537
|
+
return next;
|
|
538
|
+
}
|
|
539
|
+
// The project dir for a root under the home (<p>/.vitrinka/screenshots) is
|
|
540
|
+
// <p>, NOT the home dir — deriving the project name from dirname(root) would
|
|
541
|
+
// call every project ".vitrinka". Legacy layouts keep their old parents.
|
|
542
|
+
function projectDirOf(root) {
|
|
543
|
+
let d = dirname(root);
|
|
544
|
+
if (basename(d) === '.artifacts')
|
|
545
|
+
d = dirname(d); // legacy artifact container
|
|
546
|
+
if (['screenshots', 'artifacts', 'scratch'].includes(basename(d)) && basename(dirname(d)) === VITRINKA_HOME)
|
|
547
|
+
d = dirname(d);
|
|
548
|
+
if (basename(d) === VITRINKA_HOME)
|
|
549
|
+
d = dirname(d);
|
|
550
|
+
return d;
|
|
551
|
+
}
|
|
515
552
|
// originRepo normalizes the origin remote to a schemeless slug, e.g.
|
|
516
553
|
// "github.com/LEFTEQ/fixit". "" outside a repo / without a remote.
|
|
517
554
|
function originRepo(dir) {
|
|
@@ -679,6 +716,21 @@ export function buildProjectIndex(projectDir) {
|
|
|
679
716
|
function indexCachePath(projectDir) {
|
|
680
717
|
return join(git(['rev-parse', '--path-format=absolute', '--git-common-dir'], projectDir), 'vitrinka-index.cache');
|
|
681
718
|
}
|
|
719
|
+
// setupNudge — one dim stderr line when the repo isn't fully set up (setup-project
|
|
720
|
+
// decision #6: registry entry AND index stamp both present = healthy): shown
|
|
721
|
+
// exactly where the pain shows (edit/status), silent on healthy repos. The
|
|
722
|
+
// registry check catches a best-effort recordProject that failed to write.
|
|
723
|
+
// Callers gate on being inside a git repo.
|
|
724
|
+
function setupNudge(projectDir, project, top) {
|
|
725
|
+
try {
|
|
726
|
+
if (registeredPath(project) !== top || !existsSync(indexCachePath(projectDir))) {
|
|
727
|
+
console.error(tui.pc.dim('hint: repo not set up for vitrinka — run: vitrinka setup-project'));
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
// best-effort — a nudge must never break the command that hosts it
|
|
732
|
+
}
|
|
733
|
+
}
|
|
682
734
|
/** pushProjectIndex builds + pushes the index unless nothing changed since
|
|
683
735
|
* the last successful push. Never throws and never exits — every caller is
|
|
684
736
|
* a touchpoint where index freshness must not fail the primary job.
|
|
@@ -748,7 +800,7 @@ export async function pushProjectIndex(projectDir, base, project, verify = false
|
|
|
748
800
|
// failure (unlike the piggybacked pushes) so scripts can rely on it; verifies
|
|
749
801
|
// the server actually holds the index rather than trusting the local stamp.
|
|
750
802
|
async function indexCmd(root, args) {
|
|
751
|
-
const projectDir =
|
|
803
|
+
const projectDir = projectDirOf(root);
|
|
752
804
|
const cfg = existsSync(vitrinkaCfgPath(root)) ? loadVitrinkaCfg(root) : null;
|
|
753
805
|
const base = (strArg(args.base) || cfg?.base || process.env.VITRINKA_URL || 'https://vitrinka.in').replace(/\/+$/, '');
|
|
754
806
|
const project = sanitizeSeg(strArg(args.project) || cfg?.project || basename(mainWorktreeTop(projectDir) || projectDir), 64);
|
|
@@ -823,7 +875,7 @@ function scanComponents(projectDir, srcDirs) {
|
|
|
823
875
|
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
824
876
|
}
|
|
825
877
|
async function indexComponentsCmd(root, args) {
|
|
826
|
-
const projectDir =
|
|
878
|
+
const projectDir = projectDirOf(root);
|
|
827
879
|
const library = strArg(args.library);
|
|
828
880
|
if (!library)
|
|
829
881
|
throw new Error('index-components: --library <kit> is required (one per UI library, e.g. "web" or "expo")');
|
|
@@ -847,6 +899,10 @@ async function indexComponentsCmd(root, args) {
|
|
|
847
899
|
console.log(JSON.stringify({ project, library, components }, null, 2));
|
|
848
900
|
return;
|
|
849
901
|
}
|
|
902
|
+
await pushComponents(base, project, library, components);
|
|
903
|
+
}
|
|
904
|
+
// pushComponents — the PUT shared by index-components and setup-project.
|
|
905
|
+
async function pushComponents(base, project, library, components) {
|
|
850
906
|
const token = readToken();
|
|
851
907
|
const headers = { 'Content-Type': 'application/json' };
|
|
852
908
|
if (token)
|
|
@@ -860,6 +916,42 @@ async function indexComponentsCmd(root, args) {
|
|
|
860
916
|
throw new Error(`index-components: push rejected (HTTP ${res.status}): ${(await res.text()).slice(0, 200)}`);
|
|
861
917
|
console.log(`components ↑ ${project}/${library}: ${components.length} indexed`);
|
|
862
918
|
}
|
|
919
|
+
// detectComponentDirs — the setup-project heuristic: well-known component roots
|
|
920
|
+
// (plus one level of apps/* and packages/* for monorepos) that actually contain
|
|
921
|
+
// a meaningful number of exported components, best first. Deliberately cheap —
|
|
922
|
+
// it reuses the index-components scanner and never walks the whole repo.
|
|
923
|
+
export function detectComponentDirs(top) {
|
|
924
|
+
const roots = new Set(['src/components', 'components', 'src/ui', 'ui', 'lib/components']);
|
|
925
|
+
for (const container of ['apps', 'packages']) {
|
|
926
|
+
let entries;
|
|
927
|
+
try {
|
|
928
|
+
entries = readdirSync(join(top, container), { withFileTypes: true });
|
|
929
|
+
}
|
|
930
|
+
catch {
|
|
931
|
+
continue; // not a monorepo container — normal
|
|
932
|
+
}
|
|
933
|
+
for (const e of entries) {
|
|
934
|
+
if (!e.isDirectory() || e.name.startsWith('.'))
|
|
935
|
+
continue;
|
|
936
|
+
roots.add(`${container}/${e.name}/src/components`);
|
|
937
|
+
roots.add(`${container}/${e.name}/components`);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
const out = [];
|
|
941
|
+
for (const dir of roots) {
|
|
942
|
+
if (!existsSync(join(top, dir)))
|
|
943
|
+
continue;
|
|
944
|
+
const count = scanComponents(top, [dir]).length;
|
|
945
|
+
if (count >= 3)
|
|
946
|
+
out.push({ dir, count }); // fewer reads as noise, not a library
|
|
947
|
+
}
|
|
948
|
+
return out.sort((a, b) => b.count - a.count).slice(0, 6);
|
|
949
|
+
}
|
|
950
|
+
// guessLibraryName — "apps/web/src/components" → "web"; flat repos → "web".
|
|
951
|
+
export function guessLibraryName(dir) {
|
|
952
|
+
const m = dir.match(/^(?:apps|packages)\/([^/]+)\//);
|
|
953
|
+
return m ? m[1] : 'web';
|
|
954
|
+
}
|
|
863
955
|
function loadVitrinkaCfg(root) {
|
|
864
956
|
const raw = readFileSync(vitrinkaCfgPath(root), 'utf8'); // caller checks existsSync
|
|
865
957
|
try {
|
|
@@ -912,16 +1004,15 @@ async function remoteInitCmd(root, args) {
|
|
|
912
1004
|
console.log(`vitrinka set (existing) → ${shareUrl(cfg)}`);
|
|
913
1005
|
return cfg;
|
|
914
1006
|
}
|
|
915
|
-
const projectDir =
|
|
1007
|
+
const projectDir = projectDirOf(root);
|
|
916
1008
|
const base = (args.base && args.base !== true ? String(args.base) : process.env.VITRINKA_URL || 'https://vitrinka.in').replace(/\/+$/, '');
|
|
917
1009
|
// Project identity comes from the MAIN worktree (a linked worktree's dir is
|
|
918
1010
|
// named after its branch — using it would fork each branch into its own
|
|
919
1011
|
// project; the branch already lives in cfg.branch).
|
|
920
1012
|
const gitTop = mainWorktreeTop(projectDir);
|
|
921
|
-
//
|
|
922
|
-
//
|
|
923
|
-
const
|
|
924
|
-
const project = sanitizeSeg(args.project && args.project !== true ? String(args.project) : basename(gitTop || fallbackDir), 64);
|
|
1013
|
+
// Outside a git repo the fallback is the real project dir — projectDirOf
|
|
1014
|
+
// already stepped out of the .vitrinka/ home and legacy containers.
|
|
1015
|
+
const project = sanitizeSeg(args.project && args.project !== true ? String(args.project) : basename(gitTop || projectDir), 64);
|
|
925
1016
|
const branch = (args.branch && args.branch !== true ? String(args.branch) : git(['branch', '--show-current'], projectDir)) || 'main';
|
|
926
1017
|
const d = new Date();
|
|
927
1018
|
const p2 = (n) => String(n).padStart(2, '0');
|
|
@@ -1016,7 +1107,7 @@ async function pushCmd(root, args) {
|
|
|
1016
1107
|
process.exit(2);
|
|
1017
1108
|
}
|
|
1018
1109
|
const cfg = loadVitrinkaCfg(root);
|
|
1019
|
-
const projectDir =
|
|
1110
|
+
const projectDir = projectDirOf(root);
|
|
1020
1111
|
// Control files are excluded here AND skipped server-side (belt and suspenders).
|
|
1021
1112
|
// COPYFILE_DISABLE stops macOS bsdtar from emitting AppleDouble (._*) junk entries.
|
|
1022
1113
|
const tar = spawnSync('tar', [
|
|
@@ -1323,6 +1414,85 @@ async function operatorCmd(rest, args) {
|
|
|
1323
1414
|
}
|
|
1324
1415
|
}
|
|
1325
1416
|
// ---------- doctor: setup health check ----------
|
|
1417
|
+
// tidyCmd sweeps legacy vitrinka output and shot-shaped session litter into
|
|
1418
|
+
// the .vitrinka/ home. Report-first: prints the plan and exits unless --yes.
|
|
1419
|
+
// Conservative by design — only known patterns move by default (.screenshots/
|
|
1420
|
+
// + .artifacts/ legacy roots, .screenshots-<topic> session dirs, loose
|
|
1421
|
+
// shot-*.png); other loose root images and .aud_*/.audit_* text litter are
|
|
1422
|
+
// reported and move only under --all. Git-TRACKED paths never move.
|
|
1423
|
+
function tidyCmd(args) {
|
|
1424
|
+
const cwd = process.cwd();
|
|
1425
|
+
const top = git(['rev-parse', '--show-toplevel'], cwd) || cwd;
|
|
1426
|
+
const all = args.all === true;
|
|
1427
|
+
const tracked = new Set((git(['ls-files'], top) || '').split('\n').filter(Boolean));
|
|
1428
|
+
const trackedUnder = (rel) => {
|
|
1429
|
+
if (tracked.has(rel))
|
|
1430
|
+
return true;
|
|
1431
|
+
const prefix = rel + '/';
|
|
1432
|
+
for (const t of tracked)
|
|
1433
|
+
if (t.startsWith(prefix))
|
|
1434
|
+
return true;
|
|
1435
|
+
return false;
|
|
1436
|
+
};
|
|
1437
|
+
const moves = [];
|
|
1438
|
+
const skipped = [];
|
|
1439
|
+
const plan = (from, to) => {
|
|
1440
|
+
if (trackedUnder(from)) {
|
|
1441
|
+
skipped.push(`${from} (git-tracked — not touching)`);
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
if (existsSync(join(top, to))) {
|
|
1445
|
+
skipped.push(`${from} (target ${to} already exists)`);
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
moves.push({ from, to });
|
|
1449
|
+
};
|
|
1450
|
+
if (existsSync(join(top, '.screenshots')))
|
|
1451
|
+
plan('.screenshots', `${VITRINKA_HOME}/screenshots`);
|
|
1452
|
+
if (existsSync(join(top, '.artifacts')))
|
|
1453
|
+
plan('.artifacts', `${VITRINKA_HOME}/artifacts`);
|
|
1454
|
+
for (const e of readdirSync(top, { withFileTypes: true })) {
|
|
1455
|
+
if (e.name === VITRINKA_HOME || e.name === '.git')
|
|
1456
|
+
continue;
|
|
1457
|
+
if (e.isDirectory()) {
|
|
1458
|
+
const m = e.name.match(/^\.screenshots[-_](.+)$/);
|
|
1459
|
+
if (m)
|
|
1460
|
+
plan(e.name, `${VITRINKA_HOME}/scratch/${m[1]}`);
|
|
1461
|
+
}
|
|
1462
|
+
else if (e.isFile()) {
|
|
1463
|
+
if (trackedUnder(e.name))
|
|
1464
|
+
continue; // committed files are repo content, not litter
|
|
1465
|
+
const isShot = /^shot-.*\.(png|jpe?g|webp)$/i.test(e.name);
|
|
1466
|
+
const isLooseImage = /\.(png|jpe?g|webp)$/i.test(e.name);
|
|
1467
|
+
const isAudit = /^\.aud(it)?_/.test(e.name);
|
|
1468
|
+
if (isShot || (all && (isLooseImage || isAudit)))
|
|
1469
|
+
plan(e.name, `${VITRINKA_HOME}/scratch/loose/${e.name}`);
|
|
1470
|
+
else if (isLooseImage || isAudit)
|
|
1471
|
+
skipped.push(`${e.name} (loose litter — rerun with --all to move)`);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
if (!moves.length && !skipped.length) {
|
|
1475
|
+
console.log('tidy: nothing to do — repo root is clean');
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
for (const mv of moves)
|
|
1479
|
+
console.log(` ${mv.from} → ${mv.to}`);
|
|
1480
|
+
for (const s of skipped)
|
|
1481
|
+
console.log(` skip ${s}`);
|
|
1482
|
+
if (!moves.length)
|
|
1483
|
+
return;
|
|
1484
|
+
if (args.yes !== true) {
|
|
1485
|
+
console.log(`\ntidy: ${moves.length} move(s) planned — rerun with --yes to apply`);
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
for (const mv of moves) {
|
|
1489
|
+
const dest = join(top, mv.to);
|
|
1490
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
1491
|
+
renameSync(join(top, mv.from), dest);
|
|
1492
|
+
}
|
|
1493
|
+
ensureGitIgnored(top, `${VITRINKA_HOME}/`);
|
|
1494
|
+
console.log(`tidy: moved ${moves.length} item(s) into ${VITRINKA_HOME}/`);
|
|
1495
|
+
}
|
|
1326
1496
|
// doctorCmd checks this machine's vitrinka setup: server reachable, write
|
|
1327
1497
|
// token present AND accepted by the server, operator persona set. The token
|
|
1328
1498
|
// probe is `PUT /api/v1/operator` with an empty JSON body — auth runs before
|
|
@@ -1425,14 +1595,6 @@ async function doctorCmd(args) {
|
|
|
1425
1595
|
else
|
|
1426
1596
|
console.log('✗ mcp not registered — repair: vitrinka doctor --fix (or vitrinka install)');
|
|
1427
1597
|
}
|
|
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
1598
|
const appPath = refreshDesktopAppFlag();
|
|
1437
1599
|
console.log(appPath ? `✓ app ${appPath} (flag: ${desktopAppFlagPath()})` : `- app Vitrinka.app not installed (no flag)`);
|
|
1438
1600
|
if (failed)
|
|
@@ -1633,6 +1795,9 @@ async function installCmd(args) {
|
|
|
1633
1795
|
if (!home)
|
|
1634
1796
|
fail('install: $HOME is not set');
|
|
1635
1797
|
const local = args.local === true;
|
|
1798
|
+
// --yes (setup-project's inline delegate, or direct use): behave exactly like a
|
|
1799
|
+
// non-TTY run — skip every prompt, config-touchers print their manual command.
|
|
1800
|
+
const noPrompt = args.yes === true || !process.stdin.isTTY;
|
|
1636
1801
|
const base = resolveBaseUrl(strArg(args.base));
|
|
1637
1802
|
const mcpScope = local ? 'project' : 'user';
|
|
1638
1803
|
// ---- probe (before touching anything) ----
|
|
@@ -1645,8 +1810,16 @@ async function installCmd(args) {
|
|
|
1645
1810
|
const mcpOk = haveClaude ? mcpRegistered() : false;
|
|
1646
1811
|
const tokenSrc = tokenSource();
|
|
1647
1812
|
const op = readOperator(true);
|
|
1648
|
-
|
|
1649
|
-
|
|
1813
|
+
// Prior consent to the Claude UI wiring == the footer badge is already in
|
|
1814
|
+
// settings (the retired statusline wrapper is no longer a signal).
|
|
1815
|
+
const claudeSettingsP = join(home, '.claude', 'settings.json');
|
|
1816
|
+
let uiOk = false;
|
|
1817
|
+
if (existsSync(claudeSettingsP)) {
|
|
1818
|
+
try {
|
|
1819
|
+
uiOk = hasBoardFooterLink(JSON.parse(readFileSync(claudeSettingsP, 'utf8')));
|
|
1820
|
+
}
|
|
1821
|
+
catch { /* malformed settings.json — treat as not-wired; install-claude reports it */ }
|
|
1822
|
+
}
|
|
1650
1823
|
const row = (ok, label, detail) => {
|
|
1651
1824
|
console.log(` ${ok ? '✓' : '✗'} ${label.padEnd(11)}${detail}`);
|
|
1652
1825
|
};
|
|
@@ -1661,7 +1834,7 @@ async function installCmd(args) {
|
|
|
1661
1834
|
row(mcpOk, 'mcp', mcpOk ? 'registered' : haveClaude ? `not registered (scope ${mcpScope})` : 'claude CLI not found');
|
|
1662
1835
|
row(tokenSrc !== 'missing', 'token', tokenSrc === 'missing' ? 'missing — writes will 401' : `present (${tokenSrc === 'env' ? '$VITRINKA_TOKEN' : tokenPath()})`);
|
|
1663
1836
|
row(!!op, 'operator', op || 'unset — board writes stay anonymous');
|
|
1664
|
-
row(uiOk, 'claude ui', uiOk ? '
|
|
1837
|
+
row(uiOk, 'claude ui', uiOk ? 'footer badge wired' : 'not wired');
|
|
1665
1838
|
console.log('');
|
|
1666
1839
|
// ---- silent steps: plugins, shim, PATH, completion (reversible, own dirs) ----
|
|
1667
1840
|
for (const rt of runtimes) {
|
|
@@ -1700,7 +1873,7 @@ async function installCmd(args) {
|
|
|
1700
1873
|
else if (!mcpOk) {
|
|
1701
1874
|
if (args['no-mcp'] === true)
|
|
1702
1875
|
console.log('mcp: skipped (--no-mcp)');
|
|
1703
|
-
else if (args.mcp === true || promptYesNo(`Register the vitrinka MCP for Claude Code (scope ${mcpScope})? [Y/n] `)) {
|
|
1876
|
+
else if (args.mcp === true || (!noPrompt && promptYesNo(`Register the vitrinka MCP for Claude Code (scope ${mcpScope})? [Y/n] `))) {
|
|
1704
1877
|
if (registerMcp(mcpScope))
|
|
1705
1878
|
console.log(`✓ mcp registered (--scope ${mcpScope})`);
|
|
1706
1879
|
else
|
|
@@ -1712,7 +1885,7 @@ async function installCmd(args) {
|
|
|
1712
1885
|
}
|
|
1713
1886
|
// 2. Write token (reads work without one; pushes/board writes 401).
|
|
1714
1887
|
if (tokenSrc === 'missing') {
|
|
1715
|
-
if (
|
|
1888
|
+
if (!noPrompt) {
|
|
1716
1889
|
console.log('No write token yet — reads work without one, writes 401. It is the');
|
|
1717
1890
|
console.log('server\'s VITRINKA_TOKEN (one shared secret — copy it from a configured');
|
|
1718
1891
|
console.log('machine or the server admin; details: vitrinka doctor).');
|
|
@@ -1738,31 +1911,31 @@ async function installCmd(args) {
|
|
|
1738
1911
|
await operatorCmd(['operator', opArg], args);
|
|
1739
1912
|
}
|
|
1740
1913
|
else if (!op) {
|
|
1741
|
-
const name = promptLine('Operator name — how should vitrinka credit you on boards? (Enter to skip): ');
|
|
1914
|
+
const name = noPrompt ? '' : promptLine('Operator name — how should vitrinka credit you on boards? (Enter to skip): ');
|
|
1742
1915
|
if (name)
|
|
1743
1916
|
await operatorCmd(['operator', name], args);
|
|
1744
1917
|
else
|
|
1745
1918
|
console.error('⚠ no operator — board writes stay anonymous (set later: vitrinka operator <name>)');
|
|
1746
1919
|
}
|
|
1747
|
-
// 4. Claude Code UI wiring (edits ~/.claude/settings.json).
|
|
1748
|
-
//
|
|
1920
|
+
// 4. Claude Code UI wiring (edits ~/.claude/settings.json). The retired
|
|
1921
|
+
// statusline extension is cleaned up on every run (silent when absent); the
|
|
1922
|
+
// footer badge is (re-)wired — already-wired machines refresh silently
|
|
1923
|
+
// (consent was given once), fresh ones are asked.
|
|
1924
|
+
cleanupLegacyStatusline(home);
|
|
1749
1925
|
if (args['no-claude'] === true) {
|
|
1750
1926
|
console.log('claude ui: skipped (--no-claude)');
|
|
1751
1927
|
}
|
|
1752
1928
|
else if (uiOk || args.claude === true
|
|
1753
|
-
|| promptYesNo('Wire Claude Code UI (🧷 board
|
|
1929
|
+
|| (!noPrompt && promptYesNo('Wire Claude Code UI (clickable 🧷 board footer badge)? [Y/n] '))) {
|
|
1754
1930
|
installClaudeCmd(args);
|
|
1755
1931
|
}
|
|
1756
|
-
else if (
|
|
1932
|
+
else if (noPrompt) {
|
|
1757
1933
|
console.log('claude ui: skipped (non-interactive — run `vitrinka install-claude`, or pass --claude)');
|
|
1758
1934
|
}
|
|
1759
1935
|
else {
|
|
1760
1936
|
console.log('claude ui: skipped (re-run any time: vitrinka install-claude)');
|
|
1761
1937
|
}
|
|
1762
1938
|
// ---- 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
1939
|
if (spawnSync('cwebp', ['-version'], { stdio: 'ignore' }).error) {
|
|
1767
1940
|
console.error('⚠ cwebp missing (brew install webp) — snap falls back to PNG');
|
|
1768
1941
|
}
|
|
@@ -1798,6 +1971,66 @@ export function stripMarkerBlock(content, marker) {
|
|
|
1798
1971
|
// Legacy ~/.claude/skills copy names — removed by uninstall on machines set
|
|
1799
1972
|
// up before the plugin distribution (install-skills, removed 2026-07-11).
|
|
1800
1973
|
const LEGACY_SKILLS = ['artifact', 'brainstorming', 'screenshot', 'vitrinka', 'listen'];
|
|
1974
|
+
// cleanupLegacyStatusline removes the retired Claude Code status-panel extension
|
|
1975
|
+
// (docs/specs/2026-07-20-menubar-tray-decisions.md #5): the
|
|
1976
|
+
// ~/.claude/vitrinka-statusline.sh wrapper — restoring the user's original
|
|
1977
|
+
// statusLine command recorded on its VITRINKA_ORIG line — and the
|
|
1978
|
+
// vitrinka-board-link SessionStart hook + its settings entry. The /boards/
|
|
1979
|
+
// footer badge is a separate surface and is NOT touched here. Silent when
|
|
1980
|
+
// nothing is there; prints one short notice when it removed something. Shared
|
|
1981
|
+
// by `install`/`update` (clean existing installs) and `uninstall`.
|
|
1982
|
+
export function cleanupLegacyStatusline(home) {
|
|
1983
|
+
const wrapperP = join(home, '.claude', 'vitrinka-statusline.sh');
|
|
1984
|
+
const hookP = join(home, '.claude', 'hooks', 'vitrinka-board-link.sh');
|
|
1985
|
+
const settingsPath = join(home, '.claude', 'settings.json');
|
|
1986
|
+
let removed = false;
|
|
1987
|
+
if (existsSync(settingsPath)) {
|
|
1988
|
+
let settings;
|
|
1989
|
+
try {
|
|
1990
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
1991
|
+
}
|
|
1992
|
+
catch (e) {
|
|
1993
|
+
fail(`cleanup: ${settingsPath} is not valid JSON — nothing was changed there; fix it first (${e.message})`);
|
|
1994
|
+
}
|
|
1995
|
+
let changed = false;
|
|
1996
|
+
const sl = settings.statusLine;
|
|
1997
|
+
if (sl?.command && sl.command.replace(/^~(?=\/)/, home) === wrapperP) {
|
|
1998
|
+
const orig = existsSync(wrapperP) ? wrapperOrigCommand(readFileSync(wrapperP, 'utf8')) : '';
|
|
1999
|
+
if (orig)
|
|
2000
|
+
settings.statusLine = { type: 'command', command: orig };
|
|
2001
|
+
else
|
|
2002
|
+
delete settings.statusLine;
|
|
2003
|
+
changed = true;
|
|
2004
|
+
}
|
|
2005
|
+
if (hasBoardLinkHook(settings)) {
|
|
2006
|
+
const hooks = settings.hooks;
|
|
2007
|
+
const ss = hooks.SessionStart
|
|
2008
|
+
.map((m) => (Array.isArray(m?.hooks)
|
|
2009
|
+
? { ...m, hooks: m.hooks.filter((h) => !(typeof h?.command === 'string' && h.command.includes('vitrinka-board-link'))) }
|
|
2010
|
+
: m))
|
|
2011
|
+
.filter((m) => !Array.isArray(m?.hooks) || m.hooks.length > 0);
|
|
2012
|
+
if (ss.length)
|
|
2013
|
+
hooks.SessionStart = ss;
|
|
2014
|
+
else
|
|
2015
|
+
delete hooks.SessionStart;
|
|
2016
|
+
changed = true;
|
|
2017
|
+
}
|
|
2018
|
+
if (changed) {
|
|
2019
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
2020
|
+
removed = true;
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
if (existsSync(wrapperP)) {
|
|
2024
|
+
rmSync(wrapperP);
|
|
2025
|
+
removed = true;
|
|
2026
|
+
}
|
|
2027
|
+
if (existsSync(hookP)) {
|
|
2028
|
+
rmSync(hookP);
|
|
2029
|
+
removed = true;
|
|
2030
|
+
}
|
|
2031
|
+
if (removed)
|
|
2032
|
+
console.log('✓ removed legacy statusline extension');
|
|
2033
|
+
}
|
|
1801
2034
|
// uninstallCmd removes everything install put on the machine — the exact
|
|
1802
2035
|
// inverse: bundled skills + the /vitrinka:listen stub, shim, rc marker blocks,
|
|
1803
2036
|
// statusline wrapper (restoring the original statusLine command), the /boards/
|
|
@@ -1862,10 +2095,10 @@ function uninstallCmd(args) {
|
|
|
1862
2095
|
console.log(`✓ removed ${marker} block from ${file}`);
|
|
1863
2096
|
}
|
|
1864
2097
|
}
|
|
1865
|
-
// Claude UI wiring —
|
|
1866
|
-
//
|
|
2098
|
+
// Claude UI wiring — drop the /boards/ footer entry, then run the shared
|
|
2099
|
+
// cleanup that removes the retired statusline wrapper + board-link hook and
|
|
2100
|
+
// restores the user's original statusLine command.
|
|
1867
2101
|
const settingsPath = join(home, '.claude', 'settings.json');
|
|
1868
|
-
const wrapperP = join(home, '.claude', 'vitrinka-statusline.sh');
|
|
1869
2102
|
if (existsSync(settingsPath)) {
|
|
1870
2103
|
let settings;
|
|
1871
2104
|
try {
|
|
@@ -1874,17 +2107,6 @@ function uninstallCmd(args) {
|
|
|
1874
2107
|
catch (e) {
|
|
1875
2108
|
fail(`uninstall: ${settingsPath} is not valid JSON — nothing was changed there; fix it first (${e.message})`);
|
|
1876
2109
|
}
|
|
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
2110
|
const arr = settings.footerLinksRegexes;
|
|
1889
2111
|
if (Array.isArray(arr)) {
|
|
1890
2112
|
const kept = arr.filter((e) => !(typeof e?.pattern === 'string' && e.pattern.replaceAll('\\', '').includes('/boards/')));
|
|
@@ -1893,36 +2115,12 @@ function uninstallCmd(args) {
|
|
|
1893
2115
|
settings.footerLinksRegexes = kept;
|
|
1894
2116
|
else
|
|
1895
2117
|
delete settings.footerLinksRegexes;
|
|
1896
|
-
|
|
2118
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
1897
2119
|
console.log('✓ footer badge entry removed');
|
|
1898
2120
|
}
|
|
1899
2121
|
}
|
|
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
2122
|
}
|
|
2123
|
+
cleanupLegacyStatusline(home);
|
|
1926
2124
|
if (existsSync(desktopAppFlagPath())) {
|
|
1927
2125
|
rmSync(desktopAppFlagPath());
|
|
1928
2126
|
console.log(`✓ removed ${desktopAppFlagPath()}`);
|
|
@@ -1949,27 +2147,18 @@ function uninstallCmd(args) {
|
|
|
1949
2147
|
}
|
|
1950
2148
|
// ---------- install-claude: Claude Code UI wiring ----------
|
|
1951
2149
|
//
|
|
1952
|
-
// Wires the
|
|
2150
|
+
// Wires the Claude Code footer badges that link to boards (decisions:
|
|
1953
2151
|
// docs/specs/2026-07-07-claude-install-decisions.md):
|
|
1954
2152
|
// 1. footerLinksRegexes in ~/.claude/settings.json — Claude renders its own
|
|
1955
2153
|
// 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).
|
|
2154
|
+
// conversation (works in every terminal).
|
|
2155
|
+
// 2. App badge — a second footer entry whose vitrinka:// deep link opens the
|
|
2156
|
+
// same board in Vitrinka.app, added only where the desktop app is present.
|
|
2157
|
+
// The old statusline segment + SessionStart board-link hook were removed for
|
|
2158
|
+
// good (docs/specs/2026-07-20-menubar-tray-decisions.md #5): active-board
|
|
2159
|
+
// visibility now lives in the desktop app's menu bar tray. Idempotent, never an
|
|
2160
|
+
// uninstall: re-runs detect the /boards/ footer pattern and no-op. Removal = drop
|
|
2161
|
+
// the footerLinksRegexes entries (documented in pkg/README.md).
|
|
1973
2162
|
export const BOARD_FOOTER_LINK = {
|
|
1974
2163
|
type: 'regex',
|
|
1975
2164
|
pattern: '(?<url>https?:\\/\\/\\S+\\/boards\\/(?<slug>[\\w.-]+))',
|
|
@@ -2028,108 +2217,15 @@ export function refreshDesktopAppFlag() {
|
|
|
2028
2217
|
}
|
|
2029
2218
|
return app;
|
|
2030
2219
|
}
|
|
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.
|
|
2220
|
+
// hasBoardLinkHook — a SessionStart entry running the retired vitrinka-board-link
|
|
2221
|
+
// hook still exists; used by cleanupLegacyStatusline to find and drop it.
|
|
2126
2222
|
export function hasBoardLinkHook(settings) {
|
|
2127
2223
|
const arr = settings.hooks?.SessionStart;
|
|
2128
2224
|
return Array.isArray(arr) && arr.some((m) => Array.isArray(m?.hooks)
|
|
2129
2225
|
&& m.hooks.some((h) => typeof h?.command === 'string' && h.command.includes('vitrinka-board-link')));
|
|
2130
2226
|
}
|
|
2131
|
-
// wrapperOrigCommand — recover the original statusline command a wrapper
|
|
2132
|
-
// recorded, so
|
|
2227
|
+
// wrapperOrigCommand — recover the original statusline command a legacy wrapper
|
|
2228
|
+
// file recorded, so cleanup can restore it when removing the retired extension.
|
|
2133
2229
|
export function wrapperOrigCommand(content) {
|
|
2134
2230
|
const line = content.split('\n').find((l) => l.startsWith("VITRINKA_ORIG='"));
|
|
2135
2231
|
if (!line || !line.endsWith("'"))
|
|
@@ -2201,56 +2297,10 @@ export function installClaudeCmd(_args) {
|
|
|
2201
2297
|
changed = true;
|
|
2202
2298
|
console.log('✓ app badge removed (Vitrinka.app not installed on this machine)');
|
|
2203
2299
|
}
|
|
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
|
-
}
|
|
2300
|
+
// The retired statusline segment + board-link hook are cleaned up (not wired)
|
|
2301
|
+
// by install/update/uninstall via cleanupLegacyStatusline; nothing to do here.
|
|
2247
2302
|
if (changed)
|
|
2248
2303
|
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
2304
|
console.log(`install-claude done — restart Claude Code sessions to pick up ${settingsPath}`);
|
|
2255
2305
|
}
|
|
2256
2306
|
// ---------- open: jump to the live board ----------
|
|
@@ -2293,6 +2343,229 @@ function openCmd(root, args) {
|
|
|
2293
2343
|
if (r.error || r.status !== 0)
|
|
2294
2344
|
console.error(`open: could not launch ${opener} — URL printed above`);
|
|
2295
2345
|
}
|
|
2346
|
+
// ---------- edit: open a file/dir in the resident editor ----------
|
|
2347
|
+
// buildEditUrl — the vitrinka:// deep link the desktop app routes to its editor
|
|
2348
|
+
// page. PURE (unit-tested): the path is percent-encoded so spaces and unicode
|
|
2349
|
+
// survive the URL round-trip.
|
|
2350
|
+
export function buildEditUrl(absPath) {
|
|
2351
|
+
return `vitrinka://edit?path=${encodeURIComponent(absPath)}`;
|
|
2352
|
+
}
|
|
2353
|
+
// editCmd — `vitrinka edit [path]`: focus the resident Vitrinka.app on a file or
|
|
2354
|
+
// project (instant-editor decision #4). Records the repo in the project registry
|
|
2355
|
+
// as a side effect (#6) so the app's project list stays current, then opens the
|
|
2356
|
+
// vitrinka://edit deep link. --print emits only the URL; when the app isn't
|
|
2357
|
+
// installed we print a one-line install hint plus the URL (nothing to launch).
|
|
2358
|
+
function editCmd(rest, args) {
|
|
2359
|
+
const positional = rest.find((a) => !a.startsWith('--')) || '.';
|
|
2360
|
+
const absPath = resolve(positional);
|
|
2361
|
+
// Inside a git repo → record {project → main worktree top} so the desktop app
|
|
2362
|
+
// discovers this checkout. Same project derivation as statusCmd; the stored
|
|
2363
|
+
// path is the MAIN worktree top (the app enumerates worktrees itself).
|
|
2364
|
+
const top = mainWorktreeTop(absPath);
|
|
2365
|
+
if (top) {
|
|
2366
|
+
const project = sanitizeSeg(strArg(args.project) || basename(top), 64);
|
|
2367
|
+
recordProject(project, top);
|
|
2368
|
+
setupNudge(absPath, project, top);
|
|
2369
|
+
}
|
|
2370
|
+
const url = buildEditUrl(absPath);
|
|
2371
|
+
if (args.print === true) {
|
|
2372
|
+
console.log(url);
|
|
2373
|
+
return;
|
|
2374
|
+
}
|
|
2375
|
+
// Only the resident macOS app can handle vitrinka://edit. When it's absent,
|
|
2376
|
+
// there's nothing to launch — surface the install hint alongside the URL.
|
|
2377
|
+
if (process.platform !== 'darwin' || !existsSync(desktopAppFlagPath())) {
|
|
2378
|
+
console.log(url);
|
|
2379
|
+
console.error('edit: Vitrinka.app not installed — install it with: cd apps/desktop && make install');
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
2382
|
+
const r = spawnSync('open', [url], { stdio: 'ignore' });
|
|
2383
|
+
if (r.error || r.status !== 0)
|
|
2384
|
+
console.error(`edit: could not launch open — URL: ${url}`);
|
|
2385
|
+
}
|
|
2386
|
+
// ---------- setup-project: the repo onboarding wizard ----------
|
|
2387
|
+
//
|
|
2388
|
+
// One front door for everything a REPO needs before the editor and the board
|
|
2389
|
+
// @ autocomplete work (docs/specs/2026-07-23-setup-project-decisions.md):
|
|
2390
|
+
// register the repo in the desktop app's project registry, push the file/route
|
|
2391
|
+
// index, offer the component index, check the desktop app, and optionally
|
|
2392
|
+
// create a hello board. Status-first like install: re-runs refresh the cheap
|
|
2393
|
+
// idempotent bits and only prompt for genuinely missing config; --yes and
|
|
2394
|
+
// non-TTY runs take every default.
|
|
2395
|
+
async function setupProjectCmd(args) {
|
|
2396
|
+
const auto = args.yes === true || !tui.interactive();
|
|
2397
|
+
const base = resolveBaseUrl(strArg(args.base));
|
|
2398
|
+
const cwd = resolve('.');
|
|
2399
|
+
const top = mainWorktreeTop(cwd);
|
|
2400
|
+
if (!top)
|
|
2401
|
+
fail('setup-project: not inside a git repository — run it from the repo you want to onboard');
|
|
2402
|
+
tui.intro(`vitrinka setup-project — ${base}`);
|
|
2403
|
+
// ---- machine gate: offer install inline instead of a dead end (decision #5) ----
|
|
2404
|
+
const missing = [];
|
|
2405
|
+
if (tokenSource() === 'missing')
|
|
2406
|
+
missing.push('token');
|
|
2407
|
+
for (const rt of pluginRuntimes())
|
|
2408
|
+
if (rt.present && !rt.installed)
|
|
2409
|
+
missing.push(`plugin·${rt.name}`);
|
|
2410
|
+
if (claudeCliPresent() && !mcpRegistered())
|
|
2411
|
+
missing.push('mcp');
|
|
2412
|
+
if (missing.length) {
|
|
2413
|
+
tui.note(`missing: ${missing.join(', ')}`, 'machine not fully onboarded');
|
|
2414
|
+
if (await tui.confirm('Run the machine install now?', true, auto))
|
|
2415
|
+
await installCmd(args);
|
|
2416
|
+
else
|
|
2417
|
+
tui.step('skipped — run it later: vitrinka install');
|
|
2418
|
+
}
|
|
2419
|
+
// ---- project identity + registry (the name is asked only while THIS repo is
|
|
2420
|
+
// unregistered — an entry for the same name but another path is a basename
|
|
2421
|
+
// collision with a different checkout, so the prompt is the escape hatch,
|
|
2422
|
+
// never a silent overwrite of the other project's mapping) ----
|
|
2423
|
+
const defaultName = sanitizeSeg(strArg(args.project) || basename(top), 64);
|
|
2424
|
+
let project = defaultName;
|
|
2425
|
+
if (registeredPath(defaultName) !== top) {
|
|
2426
|
+
project = sanitizeSeg(await tui.text('Project name (how boards + indexes file this repo)', defaultName, auto), 64) || defaultName;
|
|
2427
|
+
}
|
|
2428
|
+
recordProject(project, top);
|
|
2429
|
+
tui.step(`registry ✓ ${project} → ${top}`);
|
|
2430
|
+
// ---- file/route index — verify=true so a wiped server row re-pushes even on
|
|
2431
|
+
// a local stamp hit (pushProjectIndex prints its own ↑ line on a real push) ----
|
|
2432
|
+
const sp = tui.spinner();
|
|
2433
|
+
sp.start('pushing the file/route index…');
|
|
2434
|
+
const idxOk = await pushProjectIndex(cwd, base, project, true);
|
|
2435
|
+
sp.stop(idxOk ? 'index ✓ (@ autocomplete + editor file map)' : 'index ✗ — server unreachable? retry: vitrinka index');
|
|
2436
|
+
// ---- component index (decision #3: auto-detect, offer, never mandatory) ----
|
|
2437
|
+
await setupComponentsStep(base, project, top, auto);
|
|
2438
|
+
// ---- desktop app ----
|
|
2439
|
+
const appPath = refreshDesktopAppFlag();
|
|
2440
|
+
if (appPath)
|
|
2441
|
+
tui.step(`app ✓ ${appPath}`);
|
|
2442
|
+
else if (process.platform === 'darwin') {
|
|
2443
|
+
tui.step('app ✗ Vitrinka.app not installed — editor links open in the browser (from the vitrinka repo: cd apps/desktop && make install)');
|
|
2444
|
+
}
|
|
2445
|
+
// ---- hello board (decision #3: a closing "want to try it?" — offered only
|
|
2446
|
+
// while the project has no board at all; default no, so --yes never spams) ----
|
|
2447
|
+
await setupBoardStep(base, project, args, auto);
|
|
2448
|
+
// The index is the core deliverable (editor file map + @ autocomplete) — a
|
|
2449
|
+
// failed push means the repo is NOT ready, and scripts must see a non-zero exit.
|
|
2450
|
+
if (!idxOk) {
|
|
2451
|
+
tui.outro('repo NOT ready — the index push failed (see above); retry once the server is reachable: vitrinka setup-project');
|
|
2452
|
+
process.exit(1);
|
|
2453
|
+
}
|
|
2454
|
+
tui.outro('repo ready — open the editor: vitrinka edit . dashboard: vitrinka status');
|
|
2455
|
+
}
|
|
2456
|
+
// setupComponentsStep — the component-index leg of setup-project. Skips loudly
|
|
2457
|
+
// but harmlessly whenever the server or the scan has nothing to offer.
|
|
2458
|
+
async function setupComponentsStep(base, project, top, auto) {
|
|
2459
|
+
try {
|
|
2460
|
+
// Unauthenticated compact read — already indexed means a ✓ row, no prompt.
|
|
2461
|
+
const res = await fetch(`${base}/api/v1/projects/${encodeURIComponent(project)}/components`, { signal: AbortSignal.timeout(15_000) });
|
|
2462
|
+
if (res.ok) {
|
|
2463
|
+
const d = await res.json();
|
|
2464
|
+
const comps = Array.isArray(d.components) ? d.components : [];
|
|
2465
|
+
if (comps.length) {
|
|
2466
|
+
const libs = [...new Set(comps.map((c) => c.library).filter(Boolean))];
|
|
2467
|
+
tui.step(`components ✓ ${comps.length} indexed (${libs.join(', ')})`);
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
catch (e) {
|
|
2473
|
+
tui.step(`components ? server unreachable (${e instanceof Error ? e.message : e}) — index later: vitrinka index-components`);
|
|
2474
|
+
return;
|
|
2475
|
+
}
|
|
2476
|
+
const cands = detectComponentDirs(top);
|
|
2477
|
+
if (!cands.length) {
|
|
2478
|
+
tui.step('components — no component dirs detected; index later: vitrinka index-components --library <kit> --src <dirs>');
|
|
2479
|
+
return;
|
|
2480
|
+
}
|
|
2481
|
+
const dir = await tui.select('Component library to index (powers @ component autocomplete on boards)', [
|
|
2482
|
+
...cands.map((c) => ({ value: c.dir, label: c.dir, hint: `${c.count} exported components` })),
|
|
2483
|
+
{ value: '', label: 'skip', hint: 'index later: vitrinka index-components' },
|
|
2484
|
+
], auto);
|
|
2485
|
+
if (!dir)
|
|
2486
|
+
return;
|
|
2487
|
+
const library = sanitizeSeg(await tui.text('Library name (one per UI kit, e.g. "web" or "expo")', guessLibraryName(dir), auto), 32) || 'web';
|
|
2488
|
+
try {
|
|
2489
|
+
await pushComponents(base, project, library, scanComponents(top, [dir]));
|
|
2490
|
+
}
|
|
2491
|
+
catch (e) {
|
|
2492
|
+
tui.step(`components ✗ ${e.message}`);
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
// helloBoardSlug — the hello board's slug, kept within the server's 64-char
|
|
2496
|
+
// boardSlugRe even for a maximal 64-char project name (57 + "-hello" = 63);
|
|
2497
|
+
// the truncation never leaves a dangling separator before the suffix.
|
|
2498
|
+
export function helloBoardSlug(project) {
|
|
2499
|
+
return `${project.slice(0, 57).replace(/[-.]+$/, '') || 'x'}-hello`;
|
|
2500
|
+
}
|
|
2501
|
+
// setupBoardStep — the closing hello-board offer. Every failure path is a quiet
|
|
2502
|
+
// row, never an exit: the board is a nicety on top of an already-onboarded repo.
|
|
2503
|
+
async function setupBoardStep(base, project, args, auto) {
|
|
2504
|
+
let boards;
|
|
2505
|
+
try {
|
|
2506
|
+
const res = await fetch(`${base}/api/v1/boards`, { signal: AbortSignal.timeout(15_000) });
|
|
2507
|
+
if (!res.ok)
|
|
2508
|
+
return; // older server — the offer is a nicety, not a step
|
|
2509
|
+
boards = (await res.json()).boards || [];
|
|
2510
|
+
}
|
|
2511
|
+
catch {
|
|
2512
|
+
return; // unreachable — the index step already surfaced that loudly
|
|
2513
|
+
}
|
|
2514
|
+
const mine = boards.filter((b) => b.project === project);
|
|
2515
|
+
if (mine.length) {
|
|
2516
|
+
tui.step(`boards ✓ ${mine.length} (latest: ${mine[0].url || mine[0].slug})`);
|
|
2517
|
+
return;
|
|
2518
|
+
}
|
|
2519
|
+
if (!await tui.confirm('Create a hello board to try the round-trip (annotate → work queue)?', false, auto))
|
|
2520
|
+
return;
|
|
2521
|
+
const slug = helloBoardSlug(project);
|
|
2522
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
2523
|
+
const token = readToken();
|
|
2524
|
+
if (token)
|
|
2525
|
+
headers.Authorization = `Bearer ${token}`;
|
|
2526
|
+
const actor = actorFor(args);
|
|
2527
|
+
if (actor)
|
|
2528
|
+
headers['X-Board-Actor'] = actor;
|
|
2529
|
+
try {
|
|
2530
|
+
const res = await fetch(`${base}/api/v1/boards`, {
|
|
2531
|
+
method: 'POST', headers,
|
|
2532
|
+
body: JSON.stringify({ slug, title: `${project} — hello`, project, subgroup: 'testing' }),
|
|
2533
|
+
signal: AbortSignal.timeout(30_000),
|
|
2534
|
+
});
|
|
2535
|
+
if (res.ok) {
|
|
2536
|
+
const url = String((await res.json()).url || `${base}/boards/${slug}`);
|
|
2537
|
+
tui.step(`board ✓ ${url}`);
|
|
2538
|
+
if (process.platform === 'darwin' && tui.interactive())
|
|
2539
|
+
spawnSync('open', [url], { stdio: 'ignore' });
|
|
2540
|
+
}
|
|
2541
|
+
else if (res.status === 409) {
|
|
2542
|
+
// 409 = the slug exists. Re-list and reuse it when it's OURS (a race or a
|
|
2543
|
+
// half-finished earlier run); a different project's board is a genuine
|
|
2544
|
+
// collision — never silently adopt it.
|
|
2545
|
+
let ours;
|
|
2546
|
+
try {
|
|
2547
|
+
const list = await fetch(`${base}/api/v1/boards`, { signal: AbortSignal.timeout(15_000) });
|
|
2548
|
+
if (list.ok) {
|
|
2549
|
+
ours = ((await list.json()).boards || [])
|
|
2550
|
+
.find((b) => b.slug === slug && b.project === project);
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
catch {
|
|
2554
|
+
// unreachable mid-step — fall through to the collision line
|
|
2555
|
+
}
|
|
2556
|
+
if (ours)
|
|
2557
|
+
tui.step(`board ✓ ${ours.url || slug} (reused)`);
|
|
2558
|
+
else
|
|
2559
|
+
tui.step(`board — slug ${slug} already exists (another project's?) — create one later: /vitrinka:board`);
|
|
2560
|
+
}
|
|
2561
|
+
else {
|
|
2562
|
+
tui.step(`board ✗ create failed (HTTP ${res.status}): ${(await res.text()).slice(0, 200)}`);
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
catch (e) {
|
|
2566
|
+
tui.step(`board ✗ ${e.message}`);
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2296
2569
|
// ---------- status: the session dashboard ----------
|
|
2297
2570
|
// statusCmd — one glance at this repo+branch's vitrinka world: the live board,
|
|
2298
2571
|
// the open work queue (same scope derivation as `watch`), and who holds the
|
|
@@ -2300,8 +2573,13 @@ function openCmd(root, args) {
|
|
|
2300
2573
|
async function statusCmd(root, args) {
|
|
2301
2574
|
const base = resolveBaseUrl(strArg(args.base));
|
|
2302
2575
|
const cwd = resolve('.');
|
|
2303
|
-
const
|
|
2576
|
+
const top = mainWorktreeTop(cwd);
|
|
2577
|
+
const project = sanitizeSeg(strArg(args.project) || basename(top || cwd), 64);
|
|
2304
2578
|
const branch = sanitizeSeg(strArg(args.branch) || git(['branch', '--show-current'], cwd) || 'main', 100);
|
|
2579
|
+
if (top) {
|
|
2580
|
+
recordProject(project, top); // feed the desktop app's project registry
|
|
2581
|
+
setupNudge(cwd, project, top);
|
|
2582
|
+
}
|
|
2305
2583
|
console.log(`vitrinka status — ${project}/${branch}`);
|
|
2306
2584
|
const board = boardUrlOf(root);
|
|
2307
2585
|
console.log(board ? ` board ${board}` : ' board none yet (publish one: vitrinka board-from-set)');
|
|
@@ -2583,17 +2861,27 @@ async function updateCmd(_args) {
|
|
|
2583
2861
|
}
|
|
2584
2862
|
}
|
|
2585
2863
|
// Refresh installed surfaces from the NEW code — plugins where installed,
|
|
2586
|
-
// Claude wiring only
|
|
2864
|
+
// Claude wiring only where the footer badge shows prior consent.
|
|
2587
2865
|
const rerun = (sub) => spawnSync(process.execPath, [CLI_PATH, ...sub], { stdio: 'inherit' }).status === 0;
|
|
2588
2866
|
for (const rt of pluginRuntimes()) {
|
|
2589
2867
|
if (rt.installed && !updatePluginIn(rt)) {
|
|
2590
2868
|
console.error(`⚠ update: ${rt.name} plugin refresh failed — re-run its plugin update manually`);
|
|
2591
2869
|
}
|
|
2592
2870
|
}
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2871
|
+
// Remove the retired statusline extension from existing installs, then refresh
|
|
2872
|
+
// the footer badge where it was already wired.
|
|
2873
|
+
const home = process.env.HOME || '';
|
|
2874
|
+
cleanupLegacyStatusline(home);
|
|
2875
|
+
const settingsP = join(home, '.claude', 'settings.json');
|
|
2876
|
+
let consented = false;
|
|
2877
|
+
if (existsSync(settingsP)) {
|
|
2878
|
+
try {
|
|
2879
|
+
consented = hasBoardFooterLink(JSON.parse(readFileSync(settingsP, 'utf8')));
|
|
2880
|
+
}
|
|
2881
|
+
catch { /* malformed settings.json — treat as not-wired; install-claude reports it */ }
|
|
2882
|
+
}
|
|
2883
|
+
if (consented && !rerun(['install-claude'])) {
|
|
2884
|
+
console.error('⚠ update: Claude UI refresh failed — re-run: vitrinka install-claude');
|
|
2597
2885
|
}
|
|
2598
2886
|
console.log('update done — try: vitrinka --help');
|
|
2599
2887
|
}
|
|
@@ -2754,8 +3042,8 @@ async function snapCmd(surface, args, rawArgv = []) {
|
|
|
2754
3042
|
console.error(`snap: surface must be ios|android|macos|web, got ${JSON.stringify(surface)}`);
|
|
2755
3043
|
process.exit(2);
|
|
2756
3044
|
}
|
|
2757
|
-
const root = resolve(strArg(args.root)
|
|
2758
|
-
const projectDir =
|
|
3045
|
+
const root = strArg(args.root) ? resolve(strArg(args.root)) : homeRoot('screenshots');
|
|
3046
|
+
const projectDir = projectDirOf(root);
|
|
2759
3047
|
const route = strArg(args.route);
|
|
2760
3048
|
const note = strArg(args.note);
|
|
2761
3049
|
if (!route) {
|
|
@@ -3073,6 +3361,7 @@ function scaffoldHead(title) {
|
|
|
3073
3361
|
"react-dom/client": "/vendor/react-dom-client.mjs",
|
|
3074
3362
|
"htm": "/vendor/htm.mjs",
|
|
3075
3363
|
"kit-1": "/vendor/kit-1.mjs",
|
|
3364
|
+
"kit-2": "/vendor/kit-2.mjs",
|
|
3076
3365
|
"zipstore-1": "/vendor/zipstore-1.mjs"
|
|
3077
3366
|
} }
|
|
3078
3367
|
</script>
|
|
@@ -3080,7 +3369,26 @@ function scaffoldHead(title) {
|
|
|
3080
3369
|
"@xyflow/react": "/vendor/xyflow-react.mjs"
|
|
3081
3370
|
and add to <head>:
|
|
3082
3371
|
<link rel="stylesheet" href="/vendor/xyflow-react.css">
|
|
3372
|
+
shadcn ui components resolve via the map above: import { Button, Card, Tabs, … } from "kit-2"
|
|
3373
|
+
More built-ins (relative-import bundles — no map entry needed;
|
|
3374
|
+
GET /api/v1/runtime lists what the server has):
|
|
3375
|
+
recharts: import { LineChart, Line, XAxis, … } from "/vendor/recharts.mjs"
|
|
3376
|
+
tables: import { useReactTable, … } from "/vendor/tanstack-table.mjs"
|
|
3377
|
+
motion: import { motion, AnimatePresence } from "/vendor/motion.mjs"
|
|
3378
|
+
highlight: import hljs from "/vendor/hljs.mjs" + <link rel="stylesheet" href="/vendor/hljs.css">
|
|
3379
|
+
math: import katex from "/vendor/katex.mjs" + <link rel="stylesheet" href="/vendor/katex.css">
|
|
3380
|
+
api ref: import { ApiRefBody, parseSpec } from "/vendor/apiref-1.mjs"
|
|
3083
3381
|
-->
|
|
3382
|
+
<link rel="modulepreload" href="/vendor/react.mjs">
|
|
3383
|
+
<link rel="modulepreload" href="/vendor/scheduler.mjs">
|
|
3384
|
+
<link rel="modulepreload" href="/vendor/react-dom.mjs">
|
|
3385
|
+
<link rel="modulepreload" href="/vendor/react-dom-client.mjs">
|
|
3386
|
+
<link rel="modulepreload" href="/vendor/htm.mjs">
|
|
3387
|
+
<link rel="modulepreload" href="/vendor/kit-1.mjs">
|
|
3388
|
+
<link rel="modulepreload" href="/vendor/zipstore-1.mjs">
|
|
3389
|
+
<!-- importing shelf libs (kit-2, recharts, …)? add a matching modulepreload —
|
|
3390
|
+
it flattens the import waterfall into one parallel fetch round. Only
|
|
3391
|
+
preload what the App actually imports; the scaffold body uses kit-1. -->
|
|
3084
3392
|
<script src="/vendor/tailwind.js"></script>
|
|
3085
3393
|
</head>
|
|
3086
3394
|
<body class="bg-[#f6f2ee] text-[#101113] antialiased">
|
|
@@ -3162,7 +3470,7 @@ async function artifactInitCmd(args) {
|
|
|
3162
3470
|
assertValidKey(slug, 'artifact-init'); // the slug becomes the set key — fail before any writes
|
|
3163
3471
|
const kind = strArg(args.kind) || 'report';
|
|
3164
3472
|
const title = strArg(args.title) || slug;
|
|
3165
|
-
const dir =
|
|
3473
|
+
const dir = join(homeRoot('artifacts'), slug);
|
|
3166
3474
|
const indexPath = join(dir, 'index.html');
|
|
3167
3475
|
if (existsSync(indexPath)) {
|
|
3168
3476
|
console.error(`artifact-init: ${indexPath} already exists — edit it in place (or remove the dir to re-scaffold)`);
|
|
@@ -3170,7 +3478,7 @@ async function artifactInitCmd(args) {
|
|
|
3170
3478
|
}
|
|
3171
3479
|
mkdirSync(dir, { recursive: true });
|
|
3172
3480
|
writeFileSync(indexPath, blankScaffold(title, existsSync(join(dir, 'data.json'))));
|
|
3173
|
-
ensureGitIgnored(process.cwd(),
|
|
3481
|
+
ensureGitIgnored(process.cwd(), `${VITRINKA_HOME}/`);
|
|
3174
3482
|
await remoteInitCmd(dir, { ...args, key: slug, kind });
|
|
3175
3483
|
console.log(`scaffolded ${indexPath}`);
|
|
3176
3484
|
console.log(`author the App, then: node ${CLI_PATH} push --root ${dir} --title "<Human title>"`);
|
|
@@ -3183,8 +3491,8 @@ async function artifactFromSetCmd(args) {
|
|
|
3183
3491
|
}
|
|
3184
3492
|
const slug = sanitizeSeg(slugRaw, 64);
|
|
3185
3493
|
assertValidKey(slug, 'artifact-from-set'); // the slug becomes the set key — fail before any writes
|
|
3186
|
-
const from = resolve(strArg(args.from)
|
|
3187
|
-
const dir =
|
|
3494
|
+
const from = strArg(args.from) ? resolve(strArg(args.from)) : homeRoot('screenshots');
|
|
3495
|
+
const dir = join(homeRoot('artifacts'), slug);
|
|
3188
3496
|
const indexPath = join(dir, 'index.html');
|
|
3189
3497
|
if (existsSync(indexPath)) {
|
|
3190
3498
|
console.error(`artifact-from-set: ${indexPath} already exists — edit it in place (or remove the dir to re-scaffold)`);
|
|
@@ -3200,7 +3508,7 @@ async function artifactFromSetCmd(args) {
|
|
|
3200
3508
|
const fromManifest = loadManifest(from);
|
|
3201
3509
|
const title = strArg(args.title) || (fromManifest.journey && fromManifest.journey.title) || slug;
|
|
3202
3510
|
writeFileSync(indexPath, composedScaffold(title, `${slug}.zip`));
|
|
3203
|
-
ensureGitIgnored(process.cwd(),
|
|
3511
|
+
ensureGitIgnored(process.cwd(), `${VITRINKA_HOME}/`);
|
|
3204
3512
|
await remoteInitCmd(dir, { ...args, key: slug, kind: strArg(args.kind) || 'report' });
|
|
3205
3513
|
// Record the source screenshot set (project/branchSlug/key) so push sends ?source= and
|
|
3206
3514
|
// vitrinka can cross-link set ↔ artifact both ways.
|
|
@@ -3222,7 +3530,7 @@ async function artifactFromSetCmd(args) {
|
|
|
3222
3530
|
console.log(`scaffolded ${indexPath} (${res.count} shots embedded${source ? `, source ${source}` : ''})`);
|
|
3223
3531
|
console.log(`write the narrative sections, then \`push\`: node ${CLI_PATH} push --root ${dir} --title "<Human title>"`);
|
|
3224
3532
|
}
|
|
3225
|
-
const ROOT_FLAG = { f: '--root', arg: '<dir>', d: 'capture root (default .screenshots)' };
|
|
3533
|
+
const ROOT_FLAG = { f: '--root', arg: '<dir>', d: 'capture root (default .vitrinka/screenshots)' };
|
|
3226
3534
|
const BASE_FLAG = { f: '--base', arg: '<url>', d: 'vitrinka base URL (default $VITRINKA_URL or the mesh host)' };
|
|
3227
3535
|
const CTX_FLAGS = [
|
|
3228
3536
|
{ f: '--label', arg: '<STAGE>', d: 'journey stage label' },
|
|
@@ -3242,17 +3550,17 @@ export const COMMANDS = [
|
|
|
3242
3550
|
flags: [ROOT_FLAG, { f: '--file', arg: '<rel>', d: 'screenshot path relative to root (required)' },
|
|
3243
3551
|
{ f: '--surface', arg: '<ios|android|web|macos>', d: 'platform (default other)' },
|
|
3244
3552
|
{ f: '--route', arg: '<r>', d: 'app route' }, { f: '--note', arg: '<n>', d: 'caption' }, ...CTX_FLAGS],
|
|
3245
|
-
examples: ['vitrinka add --root .screenshots --file 2026-07-06/01-web-home.png --surface web --route /home --note "landing"'],
|
|
3553
|
+
examples: ['vitrinka add --root .vitrinka/screenshots --file 2026-07-06/01-web-home.png --surface web --route /home --note "landing"'],
|
|
3246
3554
|
},
|
|
3247
3555
|
{ name: 'build', summary: 'Rebuild index.html from the manifest', usage: '--root <dir>', flags: [ROOT_FLAG],
|
|
3248
|
-
examples: ['vitrinka build --root .screenshots'] },
|
|
3556
|
+
examples: ['vitrinka build --root .vitrinka/screenshots'] },
|
|
3249
3557
|
{
|
|
3250
3558
|
name: 'meta', summary: 'Set the journey header (kicker/title/accent/intro/chips)',
|
|
3251
3559
|
usage: '--root <dir> [--kicker <k>] [--title <t>] [--accent <substr>] [--intro <p>] [--chip "K=V" ...]',
|
|
3252
3560
|
flags: [ROOT_FLAG, { f: '--kicker', arg: '<k>', d: 'eyebrow kicker' }, { f: '--title', arg: '<t>', d: 'display title' },
|
|
3253
3561
|
{ f: '--accent', arg: '<substr>', d: 'substring of the title to accent' }, { f: '--intro', arg: '<p>', d: 'intro paragraph' },
|
|
3254
3562
|
{ f: '--chip', arg: '"K=V"', d: 'meta chip (repeatable)' }],
|
|
3255
|
-
examples: ['vitrinka meta --root .screenshots --kicker "FLOW" --title "Payout flow" --accent "Payout"'],
|
|
3563
|
+
examples: ['vitrinka meta --root .vitrinka/screenshots --kicker "FLOW" --title "Payout flow" --accent "Payout"'],
|
|
3256
3564
|
},
|
|
3257
3565
|
{
|
|
3258
3566
|
name: 'remote-init', summary: 'Create a .vitrinka set config for publishing',
|
|
@@ -3261,7 +3569,7 @@ export const COMMANDS = [
|
|
|
3261
3569
|
{ f: '--branch', arg: '<b>', d: 'branch (default: current)' }, { f: '--kind', arg: '<k>', d: 'set kind (default screenshots)' },
|
|
3262
3570
|
{ f: '--key', arg: '<k>', d: 'set key (default: timestamped)' }, { f: '--issue', arg: '<ref>', d: 'linked issue ref' }],
|
|
3263
3571
|
dynamic: 'projects',
|
|
3264
|
-
examples: ['vitrinka remote-init --root .screenshots --project fixit --branch main'],
|
|
3572
|
+
examples: ['vitrinka remote-init --root .vitrinka/screenshots --project fixit --branch main'],
|
|
3265
3573
|
},
|
|
3266
3574
|
{
|
|
3267
3575
|
name: 'push', summary: 'Publish the set to vitrinka (tar+PUT), refresh the index',
|
|
@@ -3269,7 +3577,7 @@ export const COMMANDS = [
|
|
|
3269
3577
|
flags: [ROOT_FLAG, { f: '--title', arg: '<t>', d: 'human title (re-sent every push)' },
|
|
3270
3578
|
{ f: '--name', arg: '<slug>', d: 'name the set after publish (sticky)' },
|
|
3271
3579
|
{ f: '--source', arg: '<ref>', d: 'cross-link source set project/branchSlug/key' }],
|
|
3272
|
-
examples: ['vitrinka push --root .screenshots --title "Payout flow"'],
|
|
3580
|
+
examples: ['vitrinka push --root .vitrinka/screenshots --title "Payout flow"'],
|
|
3273
3581
|
},
|
|
3274
3582
|
{
|
|
3275
3583
|
name: 'name', summary: "Set or clear a set's custom URL name",
|
|
@@ -3294,6 +3602,13 @@ export const COMMANDS = [
|
|
|
3294
3602
|
flags: [{ f: '--fix', d: 'repair everything repairable: refresh skills, rewrite the shim, re-register the MCP, refresh the Claude UI wiring' }, BASE_FLAG],
|
|
3295
3603
|
examples: ['vitrinka doctor', 'vitrinka doctor --fix'],
|
|
3296
3604
|
},
|
|
3605
|
+
{
|
|
3606
|
+
name: 'tidy', summary: 'Sweep legacy roots + shot-shaped litter into .vitrinka/ (report-first)',
|
|
3607
|
+
usage: '[--yes] [--all]',
|
|
3608
|
+
flags: [{ f: '--yes', d: 'apply the planned moves (default: report only)' },
|
|
3609
|
+
{ f: '--all', d: 'also move loose root images and .aud_*/.audit_* text litter (default: report them)' }],
|
|
3610
|
+
examples: ['vitrinka tidy', 'vitrinka tidy --yes', 'vitrinka tidy --yes --all'],
|
|
3611
|
+
},
|
|
3297
3612
|
{
|
|
3298
3613
|
name: 'install', summary: 'Onboard this machine: status table, then skills + shim + completion + MCP + token + operator + Claude UI',
|
|
3299
3614
|
usage: '[--local] [--operator <name>] [--mcp|--no-mcp] [--claude|--no-claude] [--no-completion]',
|
|
@@ -3303,9 +3618,19 @@ export const COMMANDS = [
|
|
|
3303
3618
|
{ f: '--no-mcp', d: 'skip the MCP registration' },
|
|
3304
3619
|
{ f: '--claude', d: 'wire the Claude Code UI without asking' },
|
|
3305
3620
|
{ f: '--no-claude', d: 'skip the Claude Code UI wiring' },
|
|
3306
|
-
{ f: '--no-completion', d: 'skip appending the shell-completion line' }
|
|
3621
|
+
{ f: '--no-completion', d: 'skip appending the shell-completion line' },
|
|
3622
|
+
{ f: '--yes', d: 'non-interactive: skip every prompt (config-touchers print their manual command)' }],
|
|
3307
3623
|
examples: ['vitrinka install', 'vitrinka install --local', 'vitrinka install --operator "Lukáš" --mcp --claude'],
|
|
3308
3624
|
},
|
|
3625
|
+
{
|
|
3626
|
+
name: 'setup-project', summary: 'Onboard THIS repo: registry + file/route index + component index + editor check (interactive, idempotent)',
|
|
3627
|
+
usage: '[--yes] [--base <url>] [--project <p>]',
|
|
3628
|
+
flags: [BASE_FLAG,
|
|
3629
|
+
{ f: '--project', arg: '<p>', d: 'project name (default: repo dir name)' },
|
|
3630
|
+
{ f: '--yes', d: 'non-interactive: take every default, prompt for nothing' }],
|
|
3631
|
+
examples: ['vitrinka setup-project', 'vitrinka setup-project --yes'],
|
|
3632
|
+
write: true,
|
|
3633
|
+
},
|
|
3309
3634
|
{
|
|
3310
3635
|
name: 'uninstall', summary: 'Remove everything install put here (skills, shim, completion, Claude wiring, MCP); token/operator survive',
|
|
3311
3636
|
usage: '[--purge] [--yes] [--local]',
|
|
@@ -3315,7 +3640,7 @@ export const COMMANDS = [
|
|
|
3315
3640
|
examples: ['vitrinka uninstall', 'vitrinka uninstall --purge --yes'],
|
|
3316
3641
|
},
|
|
3317
3642
|
{
|
|
3318
|
-
name: 'install-claude', summary: 'Wire the Claude Code UI: 🧷 board
|
|
3643
|
+
name: 'install-claude', summary: 'Wire the Claude Code UI: clickable 🧷 board footer badge',
|
|
3319
3644
|
usage: '', flags: [],
|
|
3320
3645
|
examples: ['vitrinka install-claude'],
|
|
3321
3646
|
},
|
|
@@ -3338,6 +3663,14 @@ export const COMMANDS = [
|
|
|
3338
3663
|
{ f: '--browser', d: 'force the browser even when Vitrinka.app is installed' }],
|
|
3339
3664
|
examples: ['vitrinka open', 'vitrinka open --qr'],
|
|
3340
3665
|
},
|
|
3666
|
+
{
|
|
3667
|
+
name: 'edit', summary: 'Open a file or project in the resident Vitrinka.app editor (records the repo for the app\'s project list)',
|
|
3668
|
+
usage: '[<path>] [--print] [--project <p>]',
|
|
3669
|
+
positional: '[<path>]',
|
|
3670
|
+
flags: [{ f: '--print', d: 'only print the vitrinka://edit URL' },
|
|
3671
|
+
{ f: '--project', arg: '<p>', d: 'override the recorded project name' }],
|
|
3672
|
+
examples: ['vitrinka edit', 'vitrinka edit src/cli.ts', 'vitrinka edit --print'],
|
|
3673
|
+
},
|
|
3341
3674
|
{
|
|
3342
3675
|
name: 'status', summary: 'Session dashboard: live board, open work queue, and listener lease for this repo+branch',
|
|
3343
3676
|
usage: '[--root <dir>] [--project <p>] [--branch <b>] [--base <url>]',
|
|
@@ -3403,7 +3736,7 @@ export const COMMANDS = [
|
|
|
3403
3736
|
flags: [ROOT_FLAG, { f: '--slug', arg: '<board>', d: 'board slug (default: set key)' },
|
|
3404
3737
|
{ f: '--btitle', arg: '<t>', d: 'board title' }, { f: '--actor', arg: '<name>', d: 'override the operator attribution' }],
|
|
3405
3738
|
dynamic: 'boards',
|
|
3406
|
-
examples: ['vitrinka board-from-set --root .screenshots'],
|
|
3739
|
+
examples: ['vitrinka board-from-set --root .vitrinka/screenshots'],
|
|
3407
3740
|
},
|
|
3408
3741
|
{
|
|
3409
3742
|
name: 'journey-from-set', summary: 'Turn the set into a journey TREE board — signpost screens fan their branches, wires leave from the clicked element (snap --next/--target)',
|
|
@@ -3411,7 +3744,7 @@ export const COMMANDS = [
|
|
|
3411
3744
|
flags: [ROOT_FLAG, { f: '--slug', arg: '<board>', d: 'board slug (default: set key)' },
|
|
3412
3745
|
{ f: '--btitle', arg: '<t>', d: 'board title' }, { f: '--actor', arg: '<name>', d: 'override the operator attribution' }],
|
|
3413
3746
|
dynamic: 'boards',
|
|
3414
|
-
examples: ['vitrinka journey-from-set --root .screenshots'],
|
|
3747
|
+
examples: ['vitrinka journey-from-set --root .vitrinka/screenshots'],
|
|
3415
3748
|
},
|
|
3416
3749
|
{
|
|
3417
3750
|
name: 'watch', summary: 'Monitor the work queue (listen v2); auto-scopes to repo+branch, leases the scope (one listener each), one line per new item',
|
|
@@ -3432,7 +3765,7 @@ export const COMMANDS = [
|
|
|
3432
3765
|
usage: '[--root <dir>] [--base <url>] [--project <p>]',
|
|
3433
3766
|
flags: [ROOT_FLAG, BASE_FLAG, { f: '--project', arg: '<p>', d: 'project slug (default: worktree name)' }],
|
|
3434
3767
|
dynamic: 'projects',
|
|
3435
|
-
examples: ['vitrinka index --root .screenshots'],
|
|
3768
|
+
examples: ['vitrinka index --root .vitrinka/screenshots'],
|
|
3436
3769
|
},
|
|
3437
3770
|
{
|
|
3438
3771
|
name: 'index-components', summary: 'Scan a UI library for exported components + push the component index',
|
|
@@ -3453,7 +3786,7 @@ export const COMMANDS = [
|
|
|
3453
3786
|
usage: '[--root <dir>] [--select 1,3-5] [--size 720] [--quality 85] --out <dir>/data.json',
|
|
3454
3787
|
flags: [ROOT_FLAG, { f: '--select', arg: '1,3-5', d: '1-based shot selection' }, { f: '--size', arg: '<px>', d: 'longest edge (default 720)' },
|
|
3455
3788
|
{ f: '--quality', arg: '<q>', d: 'JPEG quality (default 85)' }, { f: '--out', arg: '<file>', d: 'output data.json (required)' }],
|
|
3456
|
-
examples: ['vitrinka embed-shots --root .screenshots --out .artifacts/x/data.json'],
|
|
3789
|
+
examples: ['vitrinka embed-shots --root .vitrinka/screenshots --out .vitrinka/artifacts/x/data.json'],
|
|
3457
3790
|
},
|
|
3458
3791
|
{
|
|
3459
3792
|
name: 'artifact-init', summary: 'Scaffold a blank artifact report + set config',
|
|
@@ -3464,11 +3797,11 @@ export const COMMANDS = [
|
|
|
3464
3797
|
},
|
|
3465
3798
|
{
|
|
3466
3799
|
name: 'artifact-from-set', summary: 'Scaffold an artifact report from a screenshot set',
|
|
3467
|
-
usage: '--slug <s> [--from .screenshots] [--select 1,3-5] [--title <t>] [--kind report] [--source <ref>]',
|
|
3468
|
-
flags: [{ f: '--slug', arg: '<s>', d: 'artifact slug (= set key, required)' }, { f: '--from', arg: '<dir>', d: 'source set root (default .screenshots)' },
|
|
3800
|
+
usage: '--slug <s> [--from .vitrinka/screenshots] [--select 1,3-5] [--title <t>] [--kind report] [--source <ref>]',
|
|
3801
|
+
flags: [{ f: '--slug', arg: '<s>', d: 'artifact slug (= set key, required)' }, { f: '--from', arg: '<dir>', d: 'source set root (default .vitrinka/screenshots)' },
|
|
3469
3802
|
{ f: '--select', arg: '1,3-5', d: '1-based shot selection' }, { f: '--title', arg: '<t>', d: 'artifact title' },
|
|
3470
3803
|
{ f: '--kind', arg: '<k>', d: 'set kind (default report)' }, { f: '--source', arg: '<ref>', d: 'source set ref for cross-linking' }, BASE_FLAG],
|
|
3471
|
-
examples: ['vitrinka artifact-from-set --slug payout-report --from .screenshots'],
|
|
3804
|
+
examples: ['vitrinka artifact-from-set --slug payout-report --from .vitrinka/screenshots'],
|
|
3472
3805
|
},
|
|
3473
3806
|
// Meta commands — documented + completable, kept out of the compact usage grid.
|
|
3474
3807
|
{ name: 'help', summary: 'Show help (all commands, or one command in detail)', usage: '[<command>]', positional: '[<command>]', hidden: true,
|
|
@@ -3947,6 +4280,11 @@ async function boardFromSetCmd(root, args, journey = false) {
|
|
|
3947
4280
|
process.exit(2);
|
|
3948
4281
|
}
|
|
3949
4282
|
const cfg = loadVitrinkaCfg(root);
|
|
4283
|
+
// The .vitrinka descriptor names the project — record its main worktree top so
|
|
4284
|
+
// the desktop app can discover this checkout (instant-editor decision #6).
|
|
4285
|
+
const projTop = mainWorktreeTop(projectDirOf(root));
|
|
4286
|
+
if (cfg.project && projTop)
|
|
4287
|
+
recordProject(sanitizeSeg(cfg.project, 64), projTop);
|
|
3950
4288
|
const slug = (strArg(args.slug) || cfg.key).toLowerCase();
|
|
3951
4289
|
const title = strArg(args.btitle) || strArg(args.title) || '';
|
|
3952
4290
|
const headers = { 'Content-Type': 'application/json' };
|
|
@@ -3991,7 +4329,7 @@ async function boardFromSetCmd(root, args, journey = false) {
|
|
|
3991
4329
|
const boardUrl = out.board?.url || `${cfg.base}${ws}/boards/${slug}`;
|
|
3992
4330
|
console.log(`flow board: ${boardUrl} (${out.cards.length} cards, ${out.edges.length} edges)`);
|
|
3993
4331
|
// The board is about to be annotated — make sure its autocomplete index is fresh.
|
|
3994
|
-
await pushProjectIndex(
|
|
4332
|
+
await pushProjectIndex(projectDirOf(root), cfg.base, cfg.project);
|
|
3995
4333
|
}
|
|
3996
4334
|
// ---------- upload: files → board cards (mcp-file-upload 2026-07-15) ----------
|
|
3997
4335
|
//
|
|
@@ -4096,7 +4434,9 @@ export function detectImportKind(explicit, file, content) {
|
|
|
4096
4434
|
return 'openapi';
|
|
4097
4435
|
return '';
|
|
4098
4436
|
}
|
|
4099
|
-
// postImport sends a raw source to the board import endpoint and prints the
|
|
4437
|
+
// postImport sends a raw source to the board import endpoint and prints the
|
|
4438
|
+
// server's summary ({id, elemNo, title, counts…}) — or the full card JSON with
|
|
4439
|
+
// --verbose (mcp-token-economy #3: the geo boxes are noise by default).
|
|
4100
4440
|
async function postImport(board, body, args) {
|
|
4101
4441
|
const base = resolveBaseUrl(strArg(args.base));
|
|
4102
4442
|
const headers = { 'Content-Type': 'application/json' };
|
|
@@ -4106,7 +4446,8 @@ async function postImport(board, body, args) {
|
|
|
4106
4446
|
const actor = actorFor(args);
|
|
4107
4447
|
if (actor)
|
|
4108
4448
|
headers['X-Board-Actor'] = actor;
|
|
4109
|
-
const
|
|
4449
|
+
const verbose = args.verbose === true ? '?verbose=1' : '';
|
|
4450
|
+
const res = await fetch(`${base}/api/v1/boards/${encodeURIComponent(board)}/import${verbose}`, {
|
|
4110
4451
|
method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(120_000),
|
|
4111
4452
|
});
|
|
4112
4453
|
const text = await res.text();
|
|
@@ -4120,7 +4461,7 @@ async function importCmd(rest, args) {
|
|
|
4120
4461
|
const file = rest.find((a) => !a.startsWith('--'));
|
|
4121
4462
|
const board = strArg(args.board);
|
|
4122
4463
|
if (!file || !board) {
|
|
4123
|
-
console.error('usage: vitrinka import <file> --board <slug> [--kind auto|openapi|sqlddl|compose] [--title "…"]');
|
|
4464
|
+
console.error('usage: vitrinka import <file> --board <slug> [--kind auto|openapi|sqlddl|compose] [--title "…"] [--verbose]');
|
|
4124
4465
|
process.exit(2);
|
|
4125
4466
|
}
|
|
4126
4467
|
if (!existsSync(file)) {
|
|
@@ -4385,8 +4726,11 @@ async function watchCmd(args) {
|
|
|
4385
4726
|
// Repo context for scope inference (main worktree basename = project; current
|
|
4386
4727
|
// branch). '.' cwd is where the session runs (the app repo).
|
|
4387
4728
|
const cwd = resolve('.');
|
|
4388
|
-
const
|
|
4729
|
+
const repoTop = mainWorktreeTop(cwd);
|
|
4730
|
+
const repoProject = basename(repoTop || cwd);
|
|
4389
4731
|
const repoBranch = git(['branch', '--show-current'], cwd);
|
|
4732
|
+
if (repoTop)
|
|
4733
|
+
recordProject(sanitizeSeg(repoProject, 64), repoTop); // register the repo for the desktop app
|
|
4390
4734
|
const scope = deriveWatchScope(args, { repoProject, repoBranch });
|
|
4391
4735
|
const sq = scopeQuery(scope);
|
|
4392
4736
|
const listUrl = () => {
|
|
@@ -4588,7 +4932,12 @@ async function watchCmd(args) {
|
|
|
4588
4932
|
async function runSub(argv) {
|
|
4589
4933
|
const [cmd, ...rest] = argv;
|
|
4590
4934
|
const args = parseArgs(rest);
|
|
4591
|
-
|
|
4935
|
+
// The default root may auto-migrate a legacy .screenshots/ — compute it ONLY
|
|
4936
|
+
// for commands that actually take --root. Anything else (tidy, which owns
|
|
4937
|
+
// migration report-first; artifact-from-set, whose --from must keep seeing
|
|
4938
|
+
// the legacy path) must not move dirs as a side effect of dispatch.
|
|
4939
|
+
const takesRoot = COMMAND_MAP[cmd]?.flags?.some((f) => f.f === '--root') ?? false;
|
|
4940
|
+
const root = strArg(args.root) ? resolve(strArg(args.root)) : takesRoot ? homeRoot('screenshots') : '';
|
|
4592
4941
|
if (cmd === 'add')
|
|
4593
4942
|
addCmd(root, args, rest);
|
|
4594
4943
|
else if (cmd === 'build') {
|
|
@@ -4609,8 +4958,12 @@ async function runSub(argv) {
|
|
|
4609
4958
|
await loginCmd(args);
|
|
4610
4959
|
else if (cmd === 'doctor')
|
|
4611
4960
|
await doctorCmd(args);
|
|
4961
|
+
else if (cmd === 'tidy')
|
|
4962
|
+
tidyCmd(args);
|
|
4612
4963
|
else if (cmd === 'install')
|
|
4613
4964
|
await installCmd(args);
|
|
4965
|
+
else if (cmd === 'setup-project')
|
|
4966
|
+
await setupProjectCmd(args);
|
|
4614
4967
|
else if (cmd === 'install-claude')
|
|
4615
4968
|
installClaudeCmd(args);
|
|
4616
4969
|
else if (cmd === 'uninstall')
|
|
@@ -4621,6 +4974,8 @@ async function runSub(argv) {
|
|
|
4621
4974
|
versionCmd(false);
|
|
4622
4975
|
else if (cmd === 'open')
|
|
4623
4976
|
openCmd(root, args);
|
|
4977
|
+
else if (cmd === 'edit')
|
|
4978
|
+
editCmd(rest, args);
|
|
4624
4979
|
else if (cmd === 'status')
|
|
4625
4980
|
await statusCmd(root, args);
|
|
4626
4981
|
else if (cmd === 'snap')
|