@lovinka/vitrinka 1.4.0 → 1.6.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 +32 -0
- package/README.md +26 -5
- package/dist/cli.js +744 -69
- package/dist/cli.js.map +1 -1
- package/dist/lib/token.js +2 -2
- package/dist/lib/token.js.map +1 -1
- package/dist/mcp.js +46 -9
- package/dist/mcp.js.map +1 -1
- package/package.json +4 -3
- package/skills/screenshot/SKILL.md +1 -1
- package/skills/vitrinka/cli.ts +1 -1
- package/skills/vitrinka/commands/listen.md +10 -0
- package/skills/vitrinka/listen/SKILL.md +1 -1
- package/skills/vitrinka/tests/index.test.ts +2 -2
- package/skills/vitrinka/tests/install-name.test.ts +10 -4
- package/skills/vitrinka/tests/snap.test.ts +1 -1
package/dist/cli.js
CHANGED
|
@@ -111,6 +111,10 @@ function buildIndex(root) {
|
|
|
111
111
|
const m = loadManifest(root);
|
|
112
112
|
const project = basename(dirname(root)) || 'project';
|
|
113
113
|
const htmlPath = htmlPathOf(root);
|
|
114
|
+
// A fresh/nonexistent root builds an empty gallery — mirror saveManifest's
|
|
115
|
+
// dir creation instead of crashing with a raw ENOENT.
|
|
116
|
+
mkdirSync(root, { recursive: true });
|
|
117
|
+
ensureRootSelfIgnored(root);
|
|
114
118
|
writeFileSync(htmlPath, renderHtml(m.shots, project));
|
|
115
119
|
return { count: m.shots.length, htmlPath };
|
|
116
120
|
}
|
|
@@ -1100,6 +1104,53 @@ async function doctorCmd(args) {
|
|
|
1100
1104
|
console.log(`✓ operator ${op}`);
|
|
1101
1105
|
else
|
|
1102
1106
|
console.log('✗ operator unset — board writes stay anonymous (fix: vitrinka operator <name>)');
|
|
1107
|
+
// Setup surfaces (skills / shim / MCP / Claude UI) — informational rows, and
|
|
1108
|
+
// `--fix` repairs everything repairable in place. Token/operator stay manual
|
|
1109
|
+
// (they're credentials/identity, not installation).
|
|
1110
|
+
const fix = args.fix === true;
|
|
1111
|
+
const home = process.env.HOME || '';
|
|
1112
|
+
const bundle = bundledSkillsDir(CLI_PATH);
|
|
1113
|
+
if (bundle && home) {
|
|
1114
|
+
const st = skillsStateOf(bundle, join(home, '.claude', 'skills'));
|
|
1115
|
+
if (st === 'current')
|
|
1116
|
+
console.log('✓ skills current');
|
|
1117
|
+
else if (fix)
|
|
1118
|
+
installSkillsCmd(args);
|
|
1119
|
+
else
|
|
1120
|
+
console.log(`✗ skills ${st} — repair: vitrinka doctor --fix`);
|
|
1121
|
+
}
|
|
1122
|
+
if (home) {
|
|
1123
|
+
const shimP = shimPathOf(home);
|
|
1124
|
+
const shimOk = existsSync(shimP) && readFileSync(shimP, 'utf8') === SHIM_CONTENT;
|
|
1125
|
+
if (shimOk)
|
|
1126
|
+
console.log(`✓ shim ${shimP}`);
|
|
1127
|
+
else if (fix) {
|
|
1128
|
+
mkdirSync(dirname(shimP), { recursive: true });
|
|
1129
|
+
writeFileSync(shimP, SHIM_CONTENT);
|
|
1130
|
+
chmodSync(shimP, 0o755);
|
|
1131
|
+
console.log(`✓ shim rewritten → ${shimP}`);
|
|
1132
|
+
}
|
|
1133
|
+
else
|
|
1134
|
+
console.log(`✗ shim ${existsSync(shimP) ? 'stale (points at another CLI copy)' : 'missing'} — repair: vitrinka doctor --fix`);
|
|
1135
|
+
}
|
|
1136
|
+
if (claudeCliPresent()) {
|
|
1137
|
+
if (mcpRegistered())
|
|
1138
|
+
console.log('✓ mcp registered');
|
|
1139
|
+
else if (fix) {
|
|
1140
|
+
if (registerMcp('user'))
|
|
1141
|
+
console.log('✓ mcp re-registered (--scope user)');
|
|
1142
|
+
else
|
|
1143
|
+
console.log('✗ mcp re-registration failed — see output above');
|
|
1144
|
+
}
|
|
1145
|
+
else
|
|
1146
|
+
console.log('✗ mcp not registered — repair: vitrinka doctor --fix (or vitrinka install)');
|
|
1147
|
+
}
|
|
1148
|
+
if (home && existsSync(join(home, '.claude', 'vitrinka-statusline.sh'))) {
|
|
1149
|
+
if (fix)
|
|
1150
|
+
installClaudeCmd(args);
|
|
1151
|
+
else
|
|
1152
|
+
console.log('✓ claude ui wired (refresh: vitrinka doctor --fix)');
|
|
1153
|
+
}
|
|
1103
1154
|
if (failed)
|
|
1104
1155
|
process.exit(1);
|
|
1105
1156
|
}
|
|
@@ -1116,9 +1167,9 @@ export function bundledSkillsDir(cliPath) {
|
|
|
1116
1167
|
}
|
|
1117
1168
|
// installSkillsCmd copies the packaged skills bundle (vitrinka + listen,
|
|
1118
1169
|
// screenshot, artifact, brainstorming) into the Claude skills directory —
|
|
1119
|
-
// ~/.claude/skills by default, ./.claude/skills with --local.
|
|
1120
|
-
//
|
|
1121
|
-
//
|
|
1170
|
+
// ~/.claude/skills by default, ./.claude/skills with --local. `npm i -g
|
|
1171
|
+
// @lovinka/vitrinka && vitrinka install-skills` fully equips a machine
|
|
1172
|
+
// without a repo checkout.
|
|
1122
1173
|
function installSkillsCmd(args) {
|
|
1123
1174
|
const bundle = bundledSkillsDir(CLI_PATH);
|
|
1124
1175
|
if (!bundle)
|
|
@@ -1129,8 +1180,8 @@ function installSkillsCmd(args) {
|
|
|
1129
1180
|
fail('install-skills: $HOME is not set (or pass --local)');
|
|
1130
1181
|
const target = local ? resolve('.claude', 'skills') : join(home, '.claude', 'skills');
|
|
1131
1182
|
mkdirSync(target, { recursive: true });
|
|
1132
|
-
// Whole directories
|
|
1133
|
-
//
|
|
1183
|
+
// Whole directories: a skill may nest its SKILL.md (the vitrinka skill
|
|
1184
|
+
// keeps it under listen/ next to its CLI + tests).
|
|
1134
1185
|
const names = readdirSync(bundle).filter((n) => !n.startsWith('.') && statSync(join(bundle, n)).isDirectory());
|
|
1135
1186
|
if (!names.length)
|
|
1136
1187
|
fail(`install-skills: no skills found in ${bundle}`);
|
|
@@ -1148,75 +1199,398 @@ function installSkillsCmd(args) {
|
|
|
1148
1199
|
}
|
|
1149
1200
|
console.log(`✓ ${n} → ${join(target, n)}`);
|
|
1150
1201
|
}
|
|
1202
|
+
// /vitrinka:listen command stub — nested SKILL.md files (skills/vitrinka/listen/)
|
|
1203
|
+
// never register as invocable skills; only a stub under commands/vitrinka/ makes
|
|
1204
|
+
// the slash command callable.
|
|
1205
|
+
const stub = join(bundle, 'vitrinka', 'commands', 'listen.md');
|
|
1206
|
+
if (existsSync(stub)) {
|
|
1207
|
+
const commandsDir = local ? resolve('.claude', 'commands', 'vitrinka') : join(home, '.claude', 'commands', 'vitrinka');
|
|
1208
|
+
mkdirSync(commandsDir, { recursive: true });
|
|
1209
|
+
let content = readFileSync(stub, 'utf8');
|
|
1210
|
+
if (local)
|
|
1211
|
+
content = content.replaceAll('~/.claude/skills/vitrinka/listen/SKILL.md', '.claude/skills/vitrinka/listen/SKILL.md');
|
|
1212
|
+
writeFileSync(join(commandsDir, 'listen.md'), content);
|
|
1213
|
+
console.log(`✓ /vitrinka:listen command → ${join(commandsDir, 'listen.md')}`);
|
|
1214
|
+
}
|
|
1151
1215
|
console.log(`${names.length} skill${names.length === 1 ? '' : 's'} installed (${names.sort().join(', ')})`);
|
|
1152
1216
|
}
|
|
1153
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1217
|
+
// ---------- setup state probing (shared by install / doctor / uninstall) ----------
|
|
1218
|
+
// dirsEqual — recursive byte comparison, the node twin of install.sh's `diff -rq`.
|
|
1219
|
+
function dirsEqual(a, b) {
|
|
1220
|
+
const names = new Set([...readdirSync(a), ...readdirSync(b)]);
|
|
1221
|
+
for (const n of names) {
|
|
1222
|
+
const pa = join(a, n), pb = join(b, n);
|
|
1223
|
+
if (!existsSync(pa) || !existsSync(pb))
|
|
1224
|
+
return false;
|
|
1225
|
+
const da = statSync(pa).isDirectory(), db = statSync(pb).isDirectory();
|
|
1226
|
+
if (da !== db)
|
|
1227
|
+
return false;
|
|
1228
|
+
if (da) {
|
|
1229
|
+
if (!dirsEqual(pa, pb))
|
|
1230
|
+
return false;
|
|
1231
|
+
}
|
|
1232
|
+
else if (!readFileSync(pa).equals(readFileSync(pb)))
|
|
1233
|
+
return false;
|
|
1234
|
+
}
|
|
1235
|
+
return true;
|
|
1236
|
+
}
|
|
1237
|
+
// skillsStateOf — the bundled skills' freshness at a target skills dir. Local
|
|
1238
|
+
// installs rewrite CLI paths after copy, so a fresh local copy reads "outdated"
|
|
1239
|
+
// against the bundle — harmless (refresh is idempotent), same as install.sh.
|
|
1240
|
+
function skillsStateOf(bundle, target) {
|
|
1241
|
+
const names = readdirSync(bundle).filter((n) => !n.startsWith('.') && statSync(join(bundle, n)).isDirectory());
|
|
1242
|
+
if (names.some((n) => !existsSync(join(target, n))))
|
|
1243
|
+
return 'missing';
|
|
1244
|
+
return names.every((n) => dirsEqual(join(bundle, n), join(target, n))) ? 'current' : 'outdated';
|
|
1245
|
+
}
|
|
1246
|
+
const SHIM_CONTENT = `#!/bin/sh\nexec node "${CLI_PATH}" "$@"\n`;
|
|
1247
|
+
function shimPathOf(home) { return join(home, '.local', 'bin', 'vitrinka'); }
|
|
1248
|
+
function rcPathOf(home) {
|
|
1249
|
+
return join(home, (process.env.SHELL || '').includes('bash') ? '.bashrc' : '.zshrc');
|
|
1250
|
+
}
|
|
1251
|
+
// claudeCliPresent / mcpRegistered / registerMcp — the `claude mcp` touchpoints.
|
|
1252
|
+
// The MCP target mirrors updateCmd's repo-vs-npm detection: a checkout registers
|
|
1253
|
+
// the repo's stdio shim (a `git pull` IS the update), an npm install registers
|
|
1254
|
+
// the published package via npx.
|
|
1255
|
+
function claudeCliPresent() {
|
|
1256
|
+
return !spawnSync('claude', ['--version'], { stdio: 'ignore' }).error;
|
|
1257
|
+
}
|
|
1258
|
+
function mcpRegistered() {
|
|
1259
|
+
const r = spawnSync('claude', ['mcp', 'list'], { encoding: 'utf8' });
|
|
1260
|
+
return r.status === 0 && /\bvitrinka\b/.test(r.stdout);
|
|
1261
|
+
}
|
|
1262
|
+
function mcpTarget() {
|
|
1263
|
+
const repoTop = git(['rev-parse', '--show-toplevel'], dirname(CLI_PATH));
|
|
1264
|
+
const shim = repoTop ? join(repoTop, 'mcp', 'server.ts') : '';
|
|
1265
|
+
if (shim && existsSync(shim))
|
|
1266
|
+
return ['node', shim];
|
|
1267
|
+
return ['npx', '-y', '--package=@lovinka/vitrinka', 'vitrinka-mcp'];
|
|
1268
|
+
}
|
|
1269
|
+
function registerMcp(scope) {
|
|
1270
|
+
spawnSync('claude', ['mcp', 'remove', '--scope', scope, 'vitrinka'], { stdio: 'ignore' });
|
|
1271
|
+
return spawnSync('claude', ['mcp', 'add', '--scope', scope, 'vitrinka', '--', ...mcpTarget()], { stdio: 'inherit' }).status === 0;
|
|
1272
|
+
}
|
|
1273
|
+
// promptLine — visible one-line read from the controlling TTY ('' when
|
|
1274
|
+
// non-interactive, like promptYesNo).
|
|
1275
|
+
function promptLine(question) {
|
|
1276
|
+
if (!process.stdin.isTTY)
|
|
1277
|
+
return '';
|
|
1278
|
+
process.stdout.write(question);
|
|
1279
|
+
try {
|
|
1280
|
+
const fd = openSync('/dev/tty', 'r');
|
|
1281
|
+
const buf = Buffer.alloc(512);
|
|
1282
|
+
const n = readSync(fd, buf, 0, 512, null);
|
|
1283
|
+
closeSync(fd);
|
|
1284
|
+
return buf.toString('utf8', 0, n).trim();
|
|
1285
|
+
}
|
|
1286
|
+
catch {
|
|
1287
|
+
return ''; // no controlling TTY — treat as non-interactive, never block
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
// promptSecret — promptLine with terminal echo off (token input, the old
|
|
1291
|
+
// install.sh's `read -rs`). Degrades to a visible read if stty is unavailable.
|
|
1292
|
+
function promptSecret(question) {
|
|
1293
|
+
if (!process.stdin.isTTY)
|
|
1294
|
+
return '';
|
|
1295
|
+
const echoOff = spawnSync('stty', ['-echo'], { stdio: 'inherit' }).status === 0;
|
|
1296
|
+
const val = promptLine(question);
|
|
1297
|
+
if (echoOff) {
|
|
1298
|
+
spawnSync('stty', ['echo'], { stdio: 'inherit' });
|
|
1299
|
+
process.stdout.write('\n'); // the suppressed Enter
|
|
1300
|
+
}
|
|
1301
|
+
return val;
|
|
1302
|
+
}
|
|
1303
|
+
// ---------- install: status-first onboarding ----------
|
|
1304
|
+
//
|
|
1305
|
+
// The one-command machine setup (decisions: docs/specs/2026-07-08-cli-onboarding-
|
|
1306
|
+
// decisions.md — this replaced the deleted install.sh). Status-first: print a
|
|
1307
|
+
// ✓/✗ table of every component, silently install the harmless ones (skills,
|
|
1308
|
+
// shim, PATH, completion — own dirs, fully reversible), then ask ONLY for the
|
|
1309
|
+
// missing config-touchers (MCP registration, write token, operator persona,
|
|
1310
|
+
// Claude UI wiring). Re-runs are near-silent. Non-interactive runs (CI,
|
|
1311
|
+
// curl|bash) skip every prompt and print the manual command instead.
|
|
1312
|
+
async function installCmd(args) {
|
|
1158
1313
|
const home = process.env.HOME;
|
|
1159
1314
|
if (!home)
|
|
1160
1315
|
fail('install: $HOME is not set');
|
|
1161
|
-
const
|
|
1162
|
-
const
|
|
1163
|
-
const
|
|
1164
|
-
|
|
1165
|
-
|
|
1316
|
+
const local = args.local === true;
|
|
1317
|
+
const base = resolveBaseUrl(strArg(args.base));
|
|
1318
|
+
const mcpScope = local ? 'project' : 'user';
|
|
1319
|
+
// ---- probe (before touching anything) ----
|
|
1320
|
+
const bundle = bundledSkillsDir(CLI_PATH);
|
|
1321
|
+
const skillsTarget = local ? resolve('.claude', 'skills') : join(home, '.claude', 'skills');
|
|
1322
|
+
const skills = bundle ? skillsStateOf(bundle, skillsTarget) : 'no-bundle';
|
|
1323
|
+
const shimP = shimPathOf(home);
|
|
1324
|
+
const shimOk = existsSync(shimP) && readFileSync(shimP, 'utf8') === SHIM_CONTENT;
|
|
1325
|
+
const rc = rcPathOf(home);
|
|
1326
|
+
const completionOk = existsSync(rc) && readFileSync(rc, 'utf8').includes('# vitrinka-completion');
|
|
1327
|
+
const haveClaude = claudeCliPresent();
|
|
1328
|
+
const mcpOk = haveClaude ? mcpRegistered() : false;
|
|
1329
|
+
const tokenSrc = tokenSource();
|
|
1330
|
+
const op = readOperator(true);
|
|
1331
|
+
const wrapperP = join(home, '.claude', 'vitrinka-statusline.sh');
|
|
1332
|
+
const uiOk = existsSync(wrapperP);
|
|
1333
|
+
const row = (ok, label, detail) => {
|
|
1334
|
+
console.log(` ${ok ? '✓' : '✗'} ${label.padEnd(11)}${detail}`);
|
|
1335
|
+
};
|
|
1336
|
+
console.log(`vitrinka setup — ${base}${local ? ' (local scope)' : ''}`);
|
|
1337
|
+
row(skills === 'current', 'skills', skills === 'no-bundle' ? 'bundle not found next to the CLI (reinstall the package)'
|
|
1338
|
+
: skills === 'current' ? `current → ${skillsTarget}` : `${skills} → will refresh`);
|
|
1339
|
+
if (!local)
|
|
1340
|
+
row(shimOk, 'shim', shimOk ? shimP : 'will install');
|
|
1341
|
+
if (!local)
|
|
1342
|
+
row(completionOk, 'completion', completionOk ? rc : args['no-completion'] === true ? 'skipped (--no-completion)' : 'will enable');
|
|
1343
|
+
row(mcpOk, 'mcp', mcpOk ? 'registered' : haveClaude ? `not registered (scope ${mcpScope})` : 'claude CLI not found');
|
|
1344
|
+
row(tokenSrc !== 'missing', 'token', tokenSrc === 'missing' ? 'missing — writes will 401' : `present (${tokenSrc === 'env' ? '$VITRINKA_TOKEN' : tokenPath()})`);
|
|
1345
|
+
row(!!op, 'operator', op || 'unset — board writes stay anonymous');
|
|
1346
|
+
row(uiOk, 'claude ui', uiOk ? 'statusline + footer wired' : 'not wired');
|
|
1347
|
+
console.log('');
|
|
1348
|
+
// ---- silent steps: skills, shim, PATH, completion (reversible, own dirs) ----
|
|
1349
|
+
if (bundle && skills !== 'current')
|
|
1350
|
+
installSkillsCmd(args);
|
|
1351
|
+
if (!local) {
|
|
1352
|
+
if (!shimOk) {
|
|
1353
|
+
mkdirSync(dirname(shimP), { recursive: true });
|
|
1354
|
+
writeFileSync(shimP, SHIM_CONTENT);
|
|
1355
|
+
chmodSync(shimP, 0o755);
|
|
1356
|
+
console.log(`✓ shim → ${shimP}`);
|
|
1357
|
+
}
|
|
1358
|
+
const binDir = dirname(shimP);
|
|
1359
|
+
const marker = '# vitrinka-cli';
|
|
1360
|
+
const zshrc = join(home, '.zshrc');
|
|
1361
|
+
const zshrcContent = existsSync(zshrc) ? readFileSync(zshrc, 'utf8') : '';
|
|
1362
|
+
if (!zshrcContent.includes(marker) && !(process.env.PATH || '').split(':').includes(binDir)) {
|
|
1363
|
+
appendFileSync(zshrc, `\n${marker}\nexport PATH="$HOME/.local/bin:$PATH"\n`);
|
|
1364
|
+
console.log(`✓ PATH block → ${zshrc} (open a new shell, or: source ${zshrc})`);
|
|
1365
|
+
}
|
|
1366
|
+
if (args['no-completion'] !== true && !completionOk) {
|
|
1367
|
+
const shellName = rc.endsWith('.bashrc') ? 'bash' : 'zsh';
|
|
1368
|
+
appendFileSync(rc, `\n# vitrinka-completion\neval "$(vitrinka completion ${shellName})"\n`);
|
|
1369
|
+
console.log(`✓ ${shellName} completion → ${rc}`);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
// ---- asked steps: the config-touchers ----
|
|
1373
|
+
// 1. MCP registration (claude's registry).
|
|
1374
|
+
const mcpCmdHint = `claude mcp add --scope ${mcpScope} vitrinka -- ${mcpTarget().join(' ')}`;
|
|
1375
|
+
if (!haveClaude) {
|
|
1376
|
+
console.error(`⚠ claude CLI not found — register the MCP later with:\n ${mcpCmdHint}`);
|
|
1377
|
+
}
|
|
1378
|
+
else if (!mcpOk) {
|
|
1379
|
+
if (args['no-mcp'] === true)
|
|
1380
|
+
console.log('mcp: skipped (--no-mcp)');
|
|
1381
|
+
else if (args.mcp === true || promptYesNo(`Register the vitrinka MCP for Claude Code (scope ${mcpScope})? [Y/n] `)) {
|
|
1382
|
+
if (registerMcp(mcpScope))
|
|
1383
|
+
console.log(`✓ mcp registered (--scope ${mcpScope})`);
|
|
1384
|
+
else
|
|
1385
|
+
console.error(`⚠ mcp registration failed — retry with:\n ${mcpCmdHint}`);
|
|
1386
|
+
}
|
|
1387
|
+
else {
|
|
1388
|
+
console.log(`mcp: skipped — register later with:\n ${mcpCmdHint}`);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
// 2. Write token (reads work without one; pushes/board writes 401).
|
|
1392
|
+
if (tokenSrc === 'missing') {
|
|
1393
|
+
if (process.stdin.isTTY) {
|
|
1394
|
+
console.log('No write token yet — reads work without one, writes 401. It is the');
|
|
1395
|
+
console.log('server\'s VITRINKA_TOKEN (one shared secret — copy it from a configured');
|
|
1396
|
+
console.log('machine or the server admin; details: vitrinka doctor).');
|
|
1397
|
+
const tok = promptSecret('Write token (Enter to skip): ');
|
|
1398
|
+
if (tok) {
|
|
1399
|
+
mkdirSync(dirname(tokenPath()), { recursive: true });
|
|
1400
|
+
writeFileSync(tokenPath(), tok + '\n');
|
|
1401
|
+
chmodSync(tokenPath(), 0o600);
|
|
1402
|
+
console.log(`✓ token → ${tokenPath()}`);
|
|
1403
|
+
}
|
|
1404
|
+
else {
|
|
1405
|
+
console.error(`⚠ no token — writes will 401 until you add one (${tokenPath()})`);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
else {
|
|
1409
|
+
console.error('⚠ no token and non-interactive — writes will 401 until you add one:');
|
|
1410
|
+
console.error(tokenHelp());
|
|
1411
|
+
}
|
|
1166
1412
|
}
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1413
|
+
// 3. Operator persona (board attribution) — same engine as `vitrinka operator`.
|
|
1414
|
+
const opArg = strArg(args.operator);
|
|
1415
|
+
if (opArg) {
|
|
1416
|
+
await operatorCmd(['operator', opArg], args);
|
|
1417
|
+
}
|
|
1418
|
+
else if (!op) {
|
|
1419
|
+
const name = promptLine('Operator name — how should vitrinka credit you on boards? (Enter to skip): ');
|
|
1420
|
+
if (name)
|
|
1421
|
+
await operatorCmd(['operator', name], args);
|
|
1422
|
+
else
|
|
1423
|
+
console.error('⚠ no operator — board writes stay anonymous (set later: vitrinka operator <name>)');
|
|
1424
|
+
}
|
|
1425
|
+
// 4. Claude Code UI wiring (edits ~/.claude/settings.json). Already-wired
|
|
1426
|
+
// machines refresh silently (consent was given once); fresh ones are asked.
|
|
1427
|
+
if (args['no-claude'] === true) {
|
|
1428
|
+
console.log('claude ui: skipped (--no-claude)');
|
|
1172
1429
|
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
if (zshrcContent.includes(marker)) {
|
|
1177
|
-
console.log(`PATH block already in ${zshrc}`);
|
|
1430
|
+
else if (uiOk || args.claude === true
|
|
1431
|
+
|| promptYesNo('Wire Claude Code UI (🧷 board statusline segment + clickable footer badge)? [Y/n] ')) {
|
|
1432
|
+
installClaudeCmd(args);
|
|
1178
1433
|
}
|
|
1179
|
-
else if (
|
|
1180
|
-
console.log(
|
|
1434
|
+
else if (!process.stdin.isTTY) {
|
|
1435
|
+
console.log('claude ui: skipped (non-interactive — run `vitrinka install-claude`, or pass --claude)');
|
|
1181
1436
|
}
|
|
1182
1437
|
else {
|
|
1183
|
-
|
|
1184
|
-
console.log(`added PATH block to ${zshrc} (open a new shell, or: source ${zshrc})`);
|
|
1438
|
+
console.log('claude ui: skipped (re-run any time: vitrinka install-claude)');
|
|
1185
1439
|
}
|
|
1186
|
-
//
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
console.log('completion: skipped (--no-completion)');
|
|
1440
|
+
// ---- environment checks (non-fatal) ----
|
|
1441
|
+
if (spawnSync('jq', ['--version'], { stdio: 'ignore' }).error) {
|
|
1442
|
+
console.error('⚠ jq missing (brew install jq) — the statusline 🧷 board segment won\'t render without it');
|
|
1190
1443
|
}
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
const
|
|
1196
|
-
if (
|
|
1197
|
-
console.
|
|
1444
|
+
if (spawnSync('cwebp', ['-version'], { stdio: 'ignore' }).error) {
|
|
1445
|
+
console.error('⚠ cwebp missing (brew install webp) — snap falls back to PNG');
|
|
1446
|
+
}
|
|
1447
|
+
try {
|
|
1448
|
+
const res = await fetch(`${base}/healthz`, { signal: AbortSignal.timeout(5_000) });
|
|
1449
|
+
if (!res.ok)
|
|
1450
|
+
console.error(`⚠ ${base} answered HTTP ${res.status}`);
|
|
1451
|
+
}
|
|
1452
|
+
catch {
|
|
1453
|
+
console.error(`⚠ ${base} unreachable — vitrinka is WireGuard-mesh-only; bring the VPN up`);
|
|
1454
|
+
}
|
|
1455
|
+
console.log('');
|
|
1456
|
+
console.log('done — health check any time with: vitrinka doctor');
|
|
1457
|
+
}
|
|
1458
|
+
// ---------- uninstall: clean offboarding ----------
|
|
1459
|
+
// stripMarkerBlock removes a marker comment line AND the single payload line
|
|
1460
|
+
// after it (the exact shape install writes), plus the blank separator install
|
|
1461
|
+
// prepended. PURE — unit-tested.
|
|
1462
|
+
export function stripMarkerBlock(content, marker) {
|
|
1463
|
+
const lines = content.split('\n');
|
|
1464
|
+
const out = [];
|
|
1465
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1466
|
+
if (lines[i].trim() === marker) {
|
|
1467
|
+
if (out.length && out[out.length - 1] === '')
|
|
1468
|
+
out.pop(); // the blank install added
|
|
1469
|
+
i++; // skip the payload line too
|
|
1470
|
+
continue;
|
|
1198
1471
|
}
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1472
|
+
out.push(lines[i]);
|
|
1473
|
+
}
|
|
1474
|
+
return out.join('\n');
|
|
1475
|
+
}
|
|
1476
|
+
// The bundled skill names — used by uninstall when the bundle isn't next to the
|
|
1477
|
+
// CLI anymore (e.g. the package was removed first).
|
|
1478
|
+
const BUNDLED_SKILLS = ['artifact', 'brainstorming', 'screenshot', 'vitrinka'];
|
|
1479
|
+
// uninstallCmd removes everything install put on the machine — the exact
|
|
1480
|
+
// inverse: bundled skills + the /vitrinka:listen stub, shim, rc marker blocks,
|
|
1481
|
+
// statusline wrapper (restoring the original statusLine command), the /boards/
|
|
1482
|
+
// footer badge entry, and the MCP registration. ~/.config/vitrinka (token +
|
|
1483
|
+
// operator) survives by default; --purge removes it too.
|
|
1484
|
+
function uninstallCmd(args) {
|
|
1485
|
+
const home = process.env.HOME;
|
|
1486
|
+
if (!home)
|
|
1487
|
+
fail('uninstall: $HOME is not set');
|
|
1488
|
+
const local = args.local === true;
|
|
1489
|
+
if (args.yes !== true && !promptYesNo(`Remove vitrinka from this machine (skills, shim, completion, Claude wiring, MCP${args.purge === true ? ', token+operator' : ''})? [Y/n] `)) {
|
|
1490
|
+
fail('uninstall: aborted (non-interactive runs need --yes)', 2);
|
|
1491
|
+
}
|
|
1492
|
+
// Skills + the /vitrinka:listen command stub.
|
|
1493
|
+
const bundle = bundledSkillsDir(CLI_PATH);
|
|
1494
|
+
const names = bundle
|
|
1495
|
+
? readdirSync(bundle).filter((n) => !n.startsWith('.') && statSync(join(bundle, n)).isDirectory())
|
|
1496
|
+
: BUNDLED_SKILLS;
|
|
1497
|
+
const skillsDir = local ? resolve('.claude', 'skills') : join(home, '.claude', 'skills');
|
|
1498
|
+
const commandsDir = local ? resolve('.claude', 'commands', 'vitrinka') : join(home, '.claude', 'commands', 'vitrinka');
|
|
1499
|
+
for (const n of names) {
|
|
1500
|
+
if (!existsSync(join(skillsDir, n)))
|
|
1501
|
+
continue;
|
|
1502
|
+
rmSync(join(skillsDir, n), { recursive: true, force: true });
|
|
1503
|
+
console.log(`✓ removed skill ${n}`);
|
|
1504
|
+
}
|
|
1505
|
+
if (existsSync(join(commandsDir, 'listen.md'))) {
|
|
1506
|
+
rmSync(join(commandsDir, 'listen.md'));
|
|
1507
|
+
try {
|
|
1508
|
+
rmSync(commandsDir, { recursive: false });
|
|
1509
|
+
}
|
|
1510
|
+
catch { /* not empty — other files live there */ }
|
|
1511
|
+
console.log('✓ removed /vitrinka:listen command stub');
|
|
1512
|
+
}
|
|
1513
|
+
// Shim + rc marker blocks (global installs only — local never wrote them).
|
|
1514
|
+
const shimP = shimPathOf(home);
|
|
1515
|
+
if (existsSync(shimP)) {
|
|
1516
|
+
rmSync(shimP);
|
|
1517
|
+
console.log(`✓ removed shim ${shimP}`);
|
|
1518
|
+
}
|
|
1519
|
+
for (const [file, marker] of [
|
|
1520
|
+
[join(home, '.zshrc'), '# vitrinka-cli'],
|
|
1521
|
+
[join(home, '.zshrc'), '# vitrinka-completion'],
|
|
1522
|
+
[join(home, '.bashrc'), '# vitrinka-completion'],
|
|
1523
|
+
]) {
|
|
1524
|
+
if (!existsSync(file))
|
|
1525
|
+
continue;
|
|
1526
|
+
const cur = readFileSync(file, 'utf8');
|
|
1527
|
+
const next = stripMarkerBlock(cur, marker);
|
|
1528
|
+
if (next !== cur) {
|
|
1529
|
+
writeFileSync(file, next);
|
|
1530
|
+
console.log(`✓ removed ${marker} block from ${file}`);
|
|
1202
1531
|
}
|
|
1203
1532
|
}
|
|
1204
|
-
// Claude
|
|
1205
|
-
//
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1533
|
+
// Claude UI wiring — restore statusLine from the wrapper's VITRINKA_ORIG,
|
|
1534
|
+
// drop the /boards/ footer entry, delete the wrapper.
|
|
1535
|
+
const settingsPath = join(home, '.claude', 'settings.json');
|
|
1536
|
+
const wrapperP = join(home, '.claude', 'vitrinka-statusline.sh');
|
|
1537
|
+
if (existsSync(settingsPath)) {
|
|
1538
|
+
let settings;
|
|
1539
|
+
try {
|
|
1540
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
1541
|
+
}
|
|
1542
|
+
catch (e) {
|
|
1543
|
+
fail(`uninstall: ${settingsPath} is not valid JSON — nothing was changed there; fix it first (${e.message})`);
|
|
1544
|
+
}
|
|
1545
|
+
let changed = false;
|
|
1546
|
+
const sl = settings.statusLine;
|
|
1547
|
+
if (sl?.command && sl.command.replace(/^~(?=\/)/, home) === wrapperP) {
|
|
1548
|
+
const orig = existsSync(wrapperP) ? wrapperOrigCommand(readFileSync(wrapperP, 'utf8')) : '';
|
|
1549
|
+
if (orig)
|
|
1550
|
+
settings.statusLine = { type: 'command', command: orig };
|
|
1551
|
+
else
|
|
1552
|
+
delete settings.statusLine;
|
|
1553
|
+
changed = true;
|
|
1554
|
+
console.log(`✓ statusline restored${orig ? ` → ${orig}` : ' (removed — none was configured before)'}`);
|
|
1555
|
+
}
|
|
1556
|
+
const arr = settings.footerLinksRegexes;
|
|
1557
|
+
if (Array.isArray(arr)) {
|
|
1558
|
+
const kept = arr.filter((e) => !(typeof e?.pattern === 'string' && e.pattern.replaceAll('\\', '').includes('/boards/')));
|
|
1559
|
+
if (kept.length !== arr.length) {
|
|
1560
|
+
if (kept.length)
|
|
1561
|
+
settings.footerLinksRegexes = kept;
|
|
1562
|
+
else
|
|
1563
|
+
delete settings.footerLinksRegexes;
|
|
1564
|
+
changed = true;
|
|
1565
|
+
console.log('✓ footer badge entry removed');
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
if (changed)
|
|
1569
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
1209
1570
|
}
|
|
1210
|
-
|
|
1211
|
-
|
|
1571
|
+
if (existsSync(wrapperP)) {
|
|
1572
|
+
rmSync(wrapperP);
|
|
1573
|
+
console.log(`✓ removed ${wrapperP}`);
|
|
1212
1574
|
}
|
|
1213
|
-
|
|
1214
|
-
|
|
1575
|
+
// MCP registration (both scopes — cheap, and remove is idempotent).
|
|
1576
|
+
if (claudeCliPresent()) {
|
|
1577
|
+
for (const scope of local ? ['project', 'user'] : ['user', 'project']) {
|
|
1578
|
+
spawnSync('claude', ['mcp', 'remove', '--scope', scope, 'vitrinka'], { stdio: 'ignore' });
|
|
1579
|
+
}
|
|
1580
|
+
console.log('✓ MCP registration removed');
|
|
1215
1581
|
}
|
|
1216
|
-
|
|
1217
|
-
|
|
1582
|
+
// Token + operator only with --purge — they're credentials, not installation.
|
|
1583
|
+
const cfgDir = join(home, '.config', 'vitrinka');
|
|
1584
|
+
if (args.purge === true) {
|
|
1585
|
+
if (existsSync(cfgDir)) {
|
|
1586
|
+
rmSync(cfgDir, { recursive: true, force: true });
|
|
1587
|
+
console.log(`✓ purged ${cfgDir}`);
|
|
1588
|
+
}
|
|
1218
1589
|
}
|
|
1219
|
-
|
|
1590
|
+
else if (existsSync(cfgDir)) {
|
|
1591
|
+
console.log(`token/operator kept at ${cfgDir} (remove with --purge)`);
|
|
1592
|
+
}
|
|
1593
|
+
console.log('uninstall done');
|
|
1220
1594
|
}
|
|
1221
1595
|
// ---------- install-claude: Claude Code UI wiring ----------
|
|
1222
1596
|
//
|
|
@@ -1226,8 +1600,12 @@ function installCmd(args) {
|
|
|
1226
1600
|
// clickable "🧷 board" footer badge whenever a /boards/ URL appears in the
|
|
1227
1601
|
// conversation (works in every terminal, unlike statusline OSC 8).
|
|
1228
1602
|
// 2. statusLine — a wrapper script that pipes the same stdin JSON to the
|
|
1229
|
-
// user's ORIGINAL statusline command and appends a "🧷 board"
|
|
1230
|
-
// when the session's capture root has a .screenshots/.vitrinka
|
|
1603
|
+
// user's ORIGINAL statusline command and appends a "🧷 <board-url>" plain-text
|
|
1604
|
+
// segment when the session's capture root has a .screenshots/.vitrinka
|
|
1605
|
+
// descriptor. Plain URL on purpose: Claude Code strips OSC 8 from statusline
|
|
1606
|
+
// output in real terminals (anthropics/claude-code#21586, #26356 — closed
|
|
1607
|
+
// "not planned"), but terminals like Warp/iTerm2/Ghostty cmd+click-linkify
|
|
1608
|
+
// visible URLs on their own.
|
|
1231
1609
|
// With no original statusline, the wrapper renders a minimal line itself.
|
|
1232
1610
|
// Idempotent, never an uninstall: re-runs detect the /boards/ footer pattern and
|
|
1233
1611
|
// the wrapper registration and no-op. Removal = delete the wrapper + restore
|
|
@@ -1256,8 +1634,10 @@ export function buildStatuslineWrapper(origCmd) {
|
|
|
1256
1634
|
const quoted = origCmd.replaceAll("'", `'\\''`);
|
|
1257
1635
|
return `#!/bin/bash
|
|
1258
1636
|
# vitrinka-statusline — managed by \`vitrinka install-claude\` (re-runs rewrite it).
|
|
1259
|
-
# Wraps the original Claude Code statusline and appends a 🧷 board
|
|
1260
|
-
# the session's capture root has a .screenshots/.vitrinka descriptor.
|
|
1637
|
+
# Wraps the original Claude Code statusline and appends a 🧷 board-URL segment when
|
|
1638
|
+
# the session's capture root has a .screenshots/.vitrinka descriptor. Plain URL,
|
|
1639
|
+
# not OSC 8 — Claude Code strips OSC 8 from statusline output; terminals
|
|
1640
|
+
# cmd+click-linkify visible URLs themselves.
|
|
1261
1641
|
VITRINKA_ORIG='${quoted}'
|
|
1262
1642
|
|
|
1263
1643
|
input=$(cat)
|
|
@@ -1281,7 +1661,7 @@ for VJSON in "$DIR/.screenshots/.vitrinka" "$PROJECT_DIR/.screenshots/.vitrinka"
|
|
|
1281
1661
|
VBASE=$(jq -r '.base // empty' "$VJSON" 2>/dev/null)
|
|
1282
1662
|
VKEY=$(jq -r '.key // empty' "$VJSON" 2>/dev/null)
|
|
1283
1663
|
if [ -n "$VBASE" ] && [ -n "$VKEY" ]; then
|
|
1284
|
-
BOARD=$(printf '\\033
|
|
1664
|
+
BOARD=$(printf '\\033[35m🧷\\033[0m \\033[2m%s\\033[0m' "$VBASE/boards/$VKEY")
|
|
1285
1665
|
fi
|
|
1286
1666
|
break
|
|
1287
1667
|
done
|
|
@@ -1388,8 +1768,257 @@ export function installClaudeCmd(_args) {
|
|
|
1388
1768
|
}
|
|
1389
1769
|
if (changed)
|
|
1390
1770
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
1771
|
+
// The wrapper reads Claude's stdin JSON with jq; without it the board segment
|
|
1772
|
+
// silently degrades to pass-through — surface that at install time.
|
|
1773
|
+
if (spawnSync('jq', ['--version'], { stdio: 'ignore' }).error) {
|
|
1774
|
+
console.error('⚠ jq not found — the statusline 🧷 board segment needs it (brew install jq); footer badge works regardless');
|
|
1775
|
+
}
|
|
1391
1776
|
console.log(`install-claude done — restart Claude Code sessions to pick up ${settingsPath}`);
|
|
1392
1777
|
}
|
|
1778
|
+
// ---------- open: jump to the live board ----------
|
|
1779
|
+
// boardUrlOf — the current capture root's live board URL ('' when the root has
|
|
1780
|
+
// no .vitrinka descriptor yet). Board slug == set key (board-from-set contract).
|
|
1781
|
+
function boardUrlOf(root) {
|
|
1782
|
+
if (!existsSync(vitrinkaCfgPath(root)))
|
|
1783
|
+
return '';
|
|
1784
|
+
const cfg = loadVitrinkaCfg(root);
|
|
1785
|
+
return `${cfg.base}/boards/${cfg.key}`;
|
|
1786
|
+
}
|
|
1787
|
+
// openCmd opens the thing you're working on: the capture root's live board when
|
|
1788
|
+
// one exists, else the vitrinka home. --qr renders the URL as a terminal QR
|
|
1789
|
+
// (scan with the iPad → annotate with the Pencil) and skips the local browser;
|
|
1790
|
+
// --print only prints.
|
|
1791
|
+
function openCmd(root, args) {
|
|
1792
|
+
const url = boardUrlOf(root) || resolveBaseUrl(strArg(args.base));
|
|
1793
|
+
console.log(url);
|
|
1794
|
+
if (args.qr === true) {
|
|
1795
|
+
// Zero-dep rule: we shell to qrencode rather than hand-roll a QR encoder.
|
|
1796
|
+
const r = spawnSync('qrencode', ['-t', 'ANSIUTF8', url], { stdio: 'inherit' });
|
|
1797
|
+
if (r.error)
|
|
1798
|
+
console.error('open: qrencode not found (brew install qrencode) — URL printed above');
|
|
1799
|
+
return; // QR targets another device — don't also grab the local browser
|
|
1800
|
+
}
|
|
1801
|
+
if (args.print === true)
|
|
1802
|
+
return;
|
|
1803
|
+
const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
1804
|
+
const r = spawnSync(opener, [url], { stdio: 'ignore' });
|
|
1805
|
+
if (r.error || r.status !== 0)
|
|
1806
|
+
console.error(`open: could not launch ${opener} — URL printed above`);
|
|
1807
|
+
}
|
|
1808
|
+
// ---------- status: the session dashboard ----------
|
|
1809
|
+
// statusCmd — one glance at this repo+branch's vitrinka world: the live board,
|
|
1810
|
+
// the open work queue (same scope derivation as `watch`), and who holds the
|
|
1811
|
+
// listener lease. Degrades row-by-row when the server is unreachable.
|
|
1812
|
+
async function statusCmd(root, args) {
|
|
1813
|
+
const base = resolveBaseUrl(strArg(args.base));
|
|
1814
|
+
const cwd = resolve('.');
|
|
1815
|
+
const project = sanitizeSeg(strArg(args.project) || basename(mainWorktreeTop(cwd) || cwd), 64);
|
|
1816
|
+
const branch = sanitizeSeg(strArg(args.branch) || git(['branch', '--show-current'], cwd) || 'main', 100);
|
|
1817
|
+
console.log(`vitrinka status — ${project}/${branch}`);
|
|
1818
|
+
const board = boardUrlOf(root);
|
|
1819
|
+
console.log(board ? ` board ${board}` : ' board none yet (publish one: vitrinka board-from-set)');
|
|
1820
|
+
try {
|
|
1821
|
+
const q = new URLSearchParams({ status: 'open', project, branch });
|
|
1822
|
+
const d = await watchFetchJson(`${base}/api/v1/work?${q}`, 15_000);
|
|
1823
|
+
const items = Array.isArray(d?.work) ? d.work : [];
|
|
1824
|
+
console.log(` queue ${items.length} open item${items.length === 1 ? '' : 's'}`);
|
|
1825
|
+
for (const it of items.slice(0, 10))
|
|
1826
|
+
console.log(` ${announceLine(it)}`);
|
|
1827
|
+
if (items.length > 10)
|
|
1828
|
+
console.log(` … ${items.length - 10} more`);
|
|
1829
|
+
}
|
|
1830
|
+
catch (e) {
|
|
1831
|
+
console.log(` queue unreachable (${e instanceof Error ? e.message : e})`);
|
|
1832
|
+
}
|
|
1833
|
+
try {
|
|
1834
|
+
const d = await watchFetchJson(`${base}/api/v1/work/listeners`, 15_000);
|
|
1835
|
+
const ls = Array.isArray(d?.listeners) ? d.listeners : [];
|
|
1836
|
+
const boardSlug = board ? board.slice(board.lastIndexOf('/') + 1) : '';
|
|
1837
|
+
const mine = ls.filter((l) => l.scopeKind === 'all'
|
|
1838
|
+
|| (l.scopeKind === 'project' && l.scope === project && (!l.branch || l.branch === branch))
|
|
1839
|
+
|| (l.scopeKind === 'board' && !!boardSlug && l.scope === boardSlug));
|
|
1840
|
+
if (!mine.length)
|
|
1841
|
+
console.log(' listener none — arm one with /vitrinka:listen (or: vitrinka watch)');
|
|
1842
|
+
for (const l of mine) {
|
|
1843
|
+
console.log(` listener ${l.actor || 'anonymous'} @ ${l.session || '?'} (${l.scopeKind}${l.scope ? ` ${l.scope}` : ''}${l.branch ? `/${l.branch}` : ''})`);
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
catch (e) {
|
|
1847
|
+
console.log(` listener unreachable (${e instanceof Error ? e.message : e})`);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
// ---------- passive update notifier + first-run hint ----------
|
|
1851
|
+
// cmpSemver — numeric dotted-version compare (>0: a newer). PURE — unit-tested.
|
|
1852
|
+
export function cmpSemver(a, b) {
|
|
1853
|
+
const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
|
|
1854
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
1855
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
1856
|
+
if (d)
|
|
1857
|
+
return d;
|
|
1858
|
+
}
|
|
1859
|
+
return 0;
|
|
1860
|
+
}
|
|
1861
|
+
// Commands that must never carry the notifier/hint noise (setup and meta paths).
|
|
1862
|
+
const HINT_SKIP = new Set(['install', 'install-claude', 'install-skills', 'update', 'uninstall',
|
|
1863
|
+
'doctor', 'help', 'completion', '__complete', 'ai']);
|
|
1864
|
+
function updateCheckPath() {
|
|
1865
|
+
return join(process.env.HOME || '', '.config', 'vitrinka', 'update-check');
|
|
1866
|
+
}
|
|
1867
|
+
// maybeNotifyUpdate — one dim stderr line when a newer published version is
|
|
1868
|
+
// known, npm installs only (a repo checkout is a dev seat that pulls itself).
|
|
1869
|
+
// The registry check runs DETACHED at most once a day and writes a cache file;
|
|
1870
|
+
// the hot path only ever reads it — a command never waits on the network.
|
|
1871
|
+
function maybeNotifyUpdate(cmd) {
|
|
1872
|
+
if (HINT_SKIP.has(cmd) || !process.stderr.isTTY || !process.env.HOME)
|
|
1873
|
+
return;
|
|
1874
|
+
if (git(['rev-parse', '--show-toplevel'], dirname(CLI_PATH)))
|
|
1875
|
+
return;
|
|
1876
|
+
const p = updateCheckPath();
|
|
1877
|
+
let cache = {};
|
|
1878
|
+
try {
|
|
1879
|
+
cache = JSON.parse(readFileSync(p, 'utf8'));
|
|
1880
|
+
}
|
|
1881
|
+
catch {
|
|
1882
|
+
// Absent or corrupt cache — the background refresh below rewrites it.
|
|
1883
|
+
}
|
|
1884
|
+
const current = pkgVersion();
|
|
1885
|
+
if (cache.latest && current && cmpSemver(cache.latest, current) > 0) {
|
|
1886
|
+
console.error(`\x1b[2mupdate available ${current} → ${cache.latest} · run: vitrinka update\x1b[0m`);
|
|
1887
|
+
}
|
|
1888
|
+
if (Date.now() - (cache.ts || 0) < 24 * 3600 * 1000)
|
|
1889
|
+
return;
|
|
1890
|
+
const script = `const fs=require('fs'),path=require('path'),f=${JSON.stringify(p)};`
|
|
1891
|
+
+ `fetch('https://registry.npmjs.org/@lovinka/vitrinka/latest',{signal:AbortSignal.timeout(10000)})`
|
|
1892
|
+
+ `.then(r=>r.json()).then(d=>{fs.mkdirSync(path.dirname(f),{recursive:true});`
|
|
1893
|
+
+ `fs.writeFileSync(f,JSON.stringify({ts:Date.now(),latest:String(d.version||'')}))}).catch(()=>{})`;
|
|
1894
|
+
try {
|
|
1895
|
+
spawn(process.execPath, ['-e', script], { detached: true, stdio: 'ignore' }).unref();
|
|
1896
|
+
}
|
|
1897
|
+
catch {
|
|
1898
|
+
// A notifier must never break the actual command.
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
// maybeHintFirstRun — one stderr line on a machine with zero vitrinka setup
|
|
1902
|
+
// (no skills, no token, no shim). All three must be absent, so a configured
|
|
1903
|
+
// machine (or a local-scope one with a token) is never nagged.
|
|
1904
|
+
function maybeHintFirstRun(cmd) {
|
|
1905
|
+
if (HINT_SKIP.has(cmd))
|
|
1906
|
+
return;
|
|
1907
|
+
const home = process.env.HOME;
|
|
1908
|
+
if (!home)
|
|
1909
|
+
return;
|
|
1910
|
+
if (existsSync(join(home, '.claude', 'skills', 'vitrinka')))
|
|
1911
|
+
return;
|
|
1912
|
+
if (readToken())
|
|
1913
|
+
return;
|
|
1914
|
+
if (existsSync(shimPathOf(home)))
|
|
1915
|
+
return;
|
|
1916
|
+
console.error('\x1b[2mhint: this machine has no vitrinka setup yet — run: vitrinka install\x1b[0m');
|
|
1917
|
+
}
|
|
1918
|
+
// ---------- what's new (changelog) ----------
|
|
1919
|
+
// changelogSince — the `## x.y.z` sections newer than `since`, verbatim.
|
|
1920
|
+
// '' when nothing is newer or the file has no versioned headings. PURE.
|
|
1921
|
+
export function changelogSince(md, since) {
|
|
1922
|
+
const out = [];
|
|
1923
|
+
let taking = false;
|
|
1924
|
+
for (const line of md.split('\n')) {
|
|
1925
|
+
const m = /^##\s+\[?(\d+(?:\.\d+)*)\]?/.exec(line);
|
|
1926
|
+
if (m) {
|
|
1927
|
+
if (since && cmpSemver(m[1], since) <= 0)
|
|
1928
|
+
break;
|
|
1929
|
+
taking = true;
|
|
1930
|
+
}
|
|
1931
|
+
if (taking)
|
|
1932
|
+
out.push(line);
|
|
1933
|
+
}
|
|
1934
|
+
return out.join('\n').trim();
|
|
1935
|
+
}
|
|
1936
|
+
// ---------- update: bring the CLI + installed surfaces to the latest ----------
|
|
1937
|
+
// pkgVersion reads the version of the package the running CLI belongs to.
|
|
1938
|
+
// dirname(CLI_PATH) is pkg/src (repo checkout) or <pkg>/dist (npm install) —
|
|
1939
|
+
// package.json sits one level up in both layouts.
|
|
1940
|
+
function pkgVersion() {
|
|
1941
|
+
try {
|
|
1942
|
+
return String(JSON.parse(readFileSync(join(dirname(CLI_PATH), '..', 'package.json'), 'utf8')).version || '');
|
|
1943
|
+
}
|
|
1944
|
+
catch {
|
|
1945
|
+
return ''; // no package.json next to the CLI (odd layout) — version is cosmetic here
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
// updateCmd updates the CLI source (git pull in a repo checkout, `npm i -g` for
|
|
1949
|
+
// a global npm install — detected from where the running file lives), then
|
|
1950
|
+
// refreshes every surface a previous install put on this machine: the skills
|
|
1951
|
+
// bundle (+ /vitrinka:listen command stub) and, ONLY where it was consented to
|
|
1952
|
+
// before, the Claude Code UI wiring. It never asks new consent questions —
|
|
1953
|
+
// that stays `vitrinka install`'s job. The refresh steps re-exec the (possibly
|
|
1954
|
+
// just replaced) CLI file so they run the NEW code, not this stale process.
|
|
1955
|
+
async function updateCmd(args) {
|
|
1956
|
+
const repoTop = git(['rev-parse', '--show-toplevel'], dirname(CLI_PATH));
|
|
1957
|
+
if (repoTop) {
|
|
1958
|
+
// Repo checkout — the shim/MCP run the source directly, so a pull IS the update.
|
|
1959
|
+
const before = git(['rev-parse', '--short', 'HEAD'], repoTop);
|
|
1960
|
+
console.log(`repo checkout ${repoTop} (${before || '?'}) — git pull --ff-only…`);
|
|
1961
|
+
const pull = spawnSync('git', ['-C', repoTop, 'pull', '--ff-only'], { stdio: 'inherit' });
|
|
1962
|
+
if (pull.status !== 0)
|
|
1963
|
+
fail('update: git pull --ff-only failed — fix the repo state (dirty tree / diverged branch) and re-run');
|
|
1964
|
+
const after = git(['rev-parse', '--short', 'HEAD'], repoTop);
|
|
1965
|
+
if (after === before)
|
|
1966
|
+
console.log(`✓ already up to date (${after})`);
|
|
1967
|
+
else {
|
|
1968
|
+
console.log(`✓ updated ${before} → ${after}`);
|
|
1969
|
+
// What's new — the pulled commits, capped so a long-idle machine doesn't scroll.
|
|
1970
|
+
const log = git(['log', '--oneline', '--no-decorate', `${before}..${after}`], repoTop);
|
|
1971
|
+
if (log) {
|
|
1972
|
+
const lines = log.split('\n');
|
|
1973
|
+
for (const l of lines.slice(0, 30))
|
|
1974
|
+
console.log(` ${l}`);
|
|
1975
|
+
if (lines.length > 30)
|
|
1976
|
+
console.log(` … ${lines.length - 30} more`);
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
}
|
|
1980
|
+
else {
|
|
1981
|
+
// Global npm install — compare against the registry, update when behind.
|
|
1982
|
+
const current = pkgVersion();
|
|
1983
|
+
const view = spawnSync('npm', ['view', '@lovinka/vitrinka', 'version'], { encoding: 'utf8' });
|
|
1984
|
+
const latest = view.status === 0 ? view.stdout.trim() : '';
|
|
1985
|
+
if (!latest) {
|
|
1986
|
+
fail(`update: could not read the registry version (npm view @lovinka/vitrinka version): ${String(view.stderr || view.error || '').trim().slice(0, 200) || 'unknown error'}`);
|
|
1987
|
+
}
|
|
1988
|
+
if (latest === current) {
|
|
1989
|
+
console.log(`✓ already at the latest version (${current})`);
|
|
1990
|
+
}
|
|
1991
|
+
else {
|
|
1992
|
+
console.log(`updating @lovinka/vitrinka ${current || '?'} → ${latest}…`);
|
|
1993
|
+
const inst = spawnSync('npm', ['install', '-g', '@lovinka/vitrinka@latest'], { stdio: 'inherit' });
|
|
1994
|
+
if (inst.status !== 0)
|
|
1995
|
+
fail('update: npm install -g @lovinka/vitrinka@latest failed (see npm output above)');
|
|
1996
|
+
console.log(`✓ @lovinka/vitrinka ${latest}`);
|
|
1997
|
+
// What's new — npm just replaced the package files in place, so the
|
|
1998
|
+
// CHANGELOG next to the CLI is the NEW one; print its sections > current.
|
|
1999
|
+
try {
|
|
2000
|
+
const news = changelogSince(readFileSync(join(dirname(CLI_PATH), '..', 'CHANGELOG.md'), 'utf8'), current);
|
|
2001
|
+
if (news)
|
|
2002
|
+
console.log(`\n${news}\n`);
|
|
2003
|
+
}
|
|
2004
|
+
catch {
|
|
2005
|
+
// Older packages ship no CHANGELOG.md — nothing to show.
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
// Refresh installed surfaces from the NEW code — skills always (an install
|
|
2010
|
+
// implies them), Claude wiring only if the wrapper shows prior consent.
|
|
2011
|
+
const rerun = (sub) => spawnSync(process.execPath, [CLI_PATH, ...sub], { stdio: 'inherit' }).status === 0;
|
|
2012
|
+
if (!rerun(['install-skills', ...(args.local === true ? ['--local'] : [])])) {
|
|
2013
|
+
fail('update: skills refresh failed — re-run: vitrinka install-skills');
|
|
2014
|
+
}
|
|
2015
|
+
const wrapper = join(process.env.HOME || '', '.claude', 'vitrinka-statusline.sh');
|
|
2016
|
+
if (existsSync(wrapper)) {
|
|
2017
|
+
if (!rerun(['install-claude']))
|
|
2018
|
+
console.error('⚠ update: Claude UI refresh failed — re-run: vitrinka install-claude');
|
|
2019
|
+
}
|
|
2020
|
+
console.log('update done — try: vitrinka --help');
|
|
2021
|
+
}
|
|
1393
2022
|
// ---------- snap: one-shot capture → manifest → detached push ----------
|
|
1394
2023
|
// Filename slug: diacritics folded (Přihlášení → prihlaseni), non-alnum → '-', capped.
|
|
1395
2024
|
export function kebab(s) {
|
|
@@ -2071,17 +2700,30 @@ export const COMMANDS = [
|
|
|
2071
2700
|
examples: ['vitrinka operator', 'vitrinka operator "Lukáš"'],
|
|
2072
2701
|
},
|
|
2073
2702
|
{
|
|
2074
|
-
name: 'doctor', summary: 'Check this machine\'s setup
|
|
2075
|
-
usage: '[--base <url>]',
|
|
2076
|
-
|
|
2703
|
+
name: 'doctor', summary: 'Check this machine\'s setup (server, token, operator, skills, shim, MCP); --fix repairs it',
|
|
2704
|
+
usage: '[--fix] [--base <url>]',
|
|
2705
|
+
flags: [{ f: '--fix', d: 'repair everything repairable: refresh skills, rewrite the shim, re-register the MCP, refresh the Claude UI wiring' }, BASE_FLAG],
|
|
2706
|
+
examples: ['vitrinka doctor', 'vitrinka doctor --fix'],
|
|
2077
2707
|
},
|
|
2078
2708
|
{
|
|
2079
|
-
name: 'install', summary: '
|
|
2080
|
-
usage: '[--no-
|
|
2081
|
-
flags: [{ f: '--
|
|
2709
|
+
name: 'install', summary: 'Onboard this machine: status table, then skills + shim + completion + MCP + token + operator + Claude UI',
|
|
2710
|
+
usage: '[--local] [--operator <name>] [--mcp|--no-mcp] [--claude|--no-claude] [--no-completion]',
|
|
2711
|
+
flags: [{ f: '--local', d: 'project scope: skills → ./.claude, MCP --scope project; skips shim/completion' },
|
|
2712
|
+
{ f: '--operator', arg: '<name>', d: 'set the board-attribution persona without prompting' },
|
|
2713
|
+
{ f: '--mcp', d: 'register the MCP without asking' },
|
|
2714
|
+
{ f: '--no-mcp', d: 'skip the MCP registration' },
|
|
2082
2715
|
{ f: '--claude', d: 'wire the Claude Code UI without asking' },
|
|
2083
|
-
{ f: '--no-claude', d: 'skip the Claude Code UI wiring
|
|
2084
|
-
|
|
2716
|
+
{ f: '--no-claude', d: 'skip the Claude Code UI wiring' },
|
|
2717
|
+
{ f: '--no-completion', d: 'skip appending the shell-completion line' }],
|
|
2718
|
+
examples: ['vitrinka install', 'vitrinka install --local', 'vitrinka install --operator "Lukáš" --mcp --claude'],
|
|
2719
|
+
},
|
|
2720
|
+
{
|
|
2721
|
+
name: 'uninstall', summary: 'Remove everything install put here (skills, shim, completion, Claude wiring, MCP); token/operator survive',
|
|
2722
|
+
usage: '[--purge] [--yes] [--local]',
|
|
2723
|
+
flags: [{ f: '--purge', d: 'also remove ~/.config/vitrinka (token + operator)' },
|
|
2724
|
+
{ f: '--yes', d: 'skip the confirmation (required when non-interactive)' },
|
|
2725
|
+
{ f: '--local', d: 'remove the project-local install (./.claude) instead' }],
|
|
2726
|
+
examples: ['vitrinka uninstall', 'vitrinka uninstall --purge --yes'],
|
|
2085
2727
|
},
|
|
2086
2728
|
{
|
|
2087
2729
|
name: 'install-claude', summary: 'Wire the Claude Code UI: 🧷 board statusline segment + clickable footer badge',
|
|
@@ -2093,6 +2735,27 @@ export const COMMANDS = [
|
|
|
2093
2735
|
usage: '[--local]', flags: [{ f: '--local', d: 'install into ./.claude/skills of the current project instead of ~/.claude/skills' }],
|
|
2094
2736
|
examples: ['vitrinka install-skills'],
|
|
2095
2737
|
},
|
|
2738
|
+
{
|
|
2739
|
+
name: 'update', summary: 'Update the CLI (git pull / npm -g), show what\'s new, refresh the installed skills + Claude wiring',
|
|
2740
|
+
usage: '[--local]',
|
|
2741
|
+
flags: [{ f: '--local', d: 'refresh skills into ./.claude/skills of the current project instead of ~/.claude/skills' }],
|
|
2742
|
+
examples: ['vitrinka update'],
|
|
2743
|
+
},
|
|
2744
|
+
{
|
|
2745
|
+
name: 'open', summary: 'Open the capture root\'s live board in the browser (--qr renders a terminal QR for the iPad)',
|
|
2746
|
+
usage: '[--root <dir>] [--qr] [--print]',
|
|
2747
|
+
flags: [ROOT_FLAG, { f: '--qr', d: 'print an ANSI QR code of the URL instead of opening the browser (needs qrencode)' },
|
|
2748
|
+
{ f: '--print', d: 'only print the URL' }],
|
|
2749
|
+
examples: ['vitrinka open', 'vitrinka open --qr'],
|
|
2750
|
+
},
|
|
2751
|
+
{
|
|
2752
|
+
name: 'status', summary: 'Session dashboard: live board, open work queue, and listener lease for this repo+branch',
|
|
2753
|
+
usage: '[--root <dir>] [--project <p>] [--branch <b>] [--base <url>]',
|
|
2754
|
+
flags: [ROOT_FLAG, { f: '--project', arg: '<p>', d: 'override the inferred project scope' },
|
|
2755
|
+
{ f: '--branch', arg: '<b>', d: 'override the inferred branch scope' }, BASE_FLAG],
|
|
2756
|
+
dynamic: 'projects',
|
|
2757
|
+
examples: ['vitrinka status'],
|
|
2758
|
+
},
|
|
2096
2759
|
{
|
|
2097
2760
|
name: 'snap', summary: 'Capture a screenshot → manifest → detached push',
|
|
2098
2761
|
usage: '<ios|android|macos|web> [--root <dir>] [--file <path>] --route <r> --note <n> [ctx flags] [--udid <u>] [--region x,y,w,h] [--open <deeplink>] [--settle <seconds>] [--hq]',
|
|
@@ -2946,11 +3609,19 @@ async function runSub(argv) {
|
|
|
2946
3609
|
else if (cmd === 'doctor')
|
|
2947
3610
|
await doctorCmd(args);
|
|
2948
3611
|
else if (cmd === 'install')
|
|
2949
|
-
installCmd(args);
|
|
3612
|
+
await installCmd(args);
|
|
2950
3613
|
else if (cmd === 'install-claude')
|
|
2951
3614
|
installClaudeCmd(args);
|
|
2952
3615
|
else if (cmd === 'install-skills')
|
|
2953
3616
|
installSkillsCmd(args);
|
|
3617
|
+
else if (cmd === 'uninstall')
|
|
3618
|
+
uninstallCmd(args);
|
|
3619
|
+
else if (cmd === 'update')
|
|
3620
|
+
await updateCmd(args);
|
|
3621
|
+
else if (cmd === 'open')
|
|
3622
|
+
openCmd(root, args);
|
|
3623
|
+
else if (cmd === 'status')
|
|
3624
|
+
await statusCmd(root, args);
|
|
2954
3625
|
else if (cmd === 'snap')
|
|
2955
3626
|
await snapCmd(rest[0] && !rest[0].startsWith('--') ? rest[0] : '', args);
|
|
2956
3627
|
else if (cmd === 'board-from-set')
|
|
@@ -3013,6 +3684,7 @@ export async function main(argv) {
|
|
|
3013
3684
|
else if (!COMMAND_MAP[cmd]) {
|
|
3014
3685
|
// Unknown command → "did you mean" (plain edit-distance, no AI) + usage,
|
|
3015
3686
|
// then one additive AI hint. Exit 2.
|
|
3687
|
+
maybeHintFirstRun(cmd);
|
|
3016
3688
|
const near = nearestCommand(cmd);
|
|
3017
3689
|
console.error(`unknown command ${JSON.stringify(cmd)}${near ? ` — did you mean "${near}"?` : ''}`);
|
|
3018
3690
|
console.error(USAGE);
|
|
@@ -3020,6 +3692,9 @@ export async function main(argv) {
|
|
|
3020
3692
|
process.exit(2);
|
|
3021
3693
|
}
|
|
3022
3694
|
else {
|
|
3695
|
+
// Passive DX layer — one dim stderr line each, never blocking, never fatal.
|
|
3696
|
+
maybeHintFirstRun(cmd);
|
|
3697
|
+
maybeNotifyUpdate(cmd);
|
|
3023
3698
|
await runSub(argv);
|
|
3024
3699
|
}
|
|
3025
3700
|
}
|