@lovinka/vitrinka 1.7.0 → 1.8.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 CHANGED
@@ -3,6 +3,21 @@
3
3
  `vitrinka update` prints the sections newer than your previous version after
4
4
  updating — keep entries short and user-facing.
5
5
 
6
+ ## 1.8.0 — 2026-07-11
7
+
8
+ ### Changed
9
+ - Skills now distribute EXCLUSIVELY as the vitrinka plugin — for Claude Code
10
+ (`claude plugin install vitrinka@lovinka`) AND Codex (`codex plugin add
11
+ vitrinka`), both from the `LEFTEQ/vitrinka` marketplace. `vitrinka install`,
12
+ `doctor --fix` and `update` install/refresh the plugin in every runtime CLI
13
+ they find; the npm package no longer bundles the skills.
14
+ - Skills layout flattened: `listen` is a first-class skill
15
+ (`/vitrinka:listen` in Claude Code, `$listen` in Codex).
16
+
17
+ ### Removed
18
+ - `vitrinka install-skills` — superseded by the plugin. `vitrinka uninstall`
19
+ still clears legacy `~/.claude/skills` copies from pre-plugin machines.
20
+
6
21
  ## 1.6.0 — 2026-07-08
7
22
 
8
23
  ### Added
package/dist/cli.js CHANGED
@@ -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, cpSync, openSync, readSync, closeSync, realpathSync, } from 'node:fs';
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync, copyFileSync, mkdtempSync, chmodSync, appendFileSync, statSync, openSync, readSync, closeSync, realpathSync, } from 'node:fs';
14
14
  import { join, resolve, basename, dirname, extname, relative, sep } from 'node:path';
15
15
  import { tmpdir, hostname } from 'node:os';
16
16
  import { spawnSync, spawn } from 'node:child_process';
@@ -1218,20 +1218,20 @@ async function doctorCmd(args) {
1218
1218
  console.log(`✓ operator ${op}`);
1219
1219
  else
1220
1220
  console.log('✗ operator unset — board writes stay anonymous (fix: vitrinka operator <name>)');
1221
- // Setup surfaces (skills / shim / MCP / Claude UI) — informational rows, and
1221
+ // Setup surfaces (plugin / shim / MCP / Claude UI) — informational rows, and
1222
1222
  // `--fix` repairs everything repairable in place. Token/operator stay manual
1223
1223
  // (they're credentials/identity, not installation).
1224
1224
  const fix = args.fix === true;
1225
1225
  const home = process.env.HOME || '';
1226
- const bundle = bundledSkillsDir(CLI_PATH);
1227
- if (bundle && home) {
1228
- const st = skillsStateOf(bundle, join(home, '.claude', 'skills'));
1229
- if (st === 'current')
1230
- console.log('✓ skills current');
1231
- else if (fix)
1232
- installSkillsCmd(args);
1226
+ for (const rt of pluginRuntimes()) {
1227
+ if (!rt.present)
1228
+ continue; // no runtime, nothing to wire
1229
+ if (rt.installed)
1230
+ console.log(`✓ plugin ${rt.name}: vitrinka installed`);
1231
+ else if (fix && installPluginInto(rt))
1232
+ console.log(`✓ plugin ${rt.name}: vitrinka installed`);
1233
1233
  else
1234
- console.log(`✗ skills ${st} — repair: vitrinka doctor --fix`);
1234
+ console.log(`✗ plugin ${rt.name}: not installed — repair: vitrinka doctor --fix`);
1235
1235
  }
1236
1236
  if (home) {
1237
1237
  const shimP = shimPathOf(home);
@@ -1269,94 +1269,65 @@ async function doctorCmd(args) {
1269
1269
  process.exit(1);
1270
1270
  }
1271
1271
  // ---------- install: PATH shim ----------
1272
- // bundledSkillsDir locates the skills bundle shipped inside the npm package
1273
- // (dist/cli.js → ../skills) or, when running from a repo checkout
1274
- // (pkg/src/cli.ts), the repo-root skills/ directory.
1275
- export function bundledSkillsDir(cliPath) {
1276
- for (const cand of [join(dirname(cliPath), '..', 'skills'), join(dirname(cliPath), '..', '..', 'skills')]) {
1277
- if (existsSync(join(cand, 'vitrinka')))
1278
- return cand;
1279
- }
1280
- return '';
1281
- }
1282
- // installSkillsCmd copies the packaged skills bundle (vitrinka + listen,
1283
- // screenshot, artifact, brainstorming) into the Claude skills directory —
1284
- // ~/.claude/skills by default, ./.claude/skills with --local. `npm i -g
1285
- // @lovinka/vitrinka && vitrinka install-skills` fully equips a machine
1286
- // without a repo checkout.
1287
- function installSkillsCmd(args) {
1288
- const bundle = bundledSkillsDir(CLI_PATH);
1289
- if (!bundle)
1290
- fail('install-skills: bundled skills not found next to the CLI — reinstall the package');
1291
- const home = process.env.HOME;
1292
- const local = args.local === true;
1293
- if (!local && !home)
1294
- fail('install-skills: $HOME is not set (or pass --local)');
1295
- const target = local ? resolve('.claude', 'skills') : join(home, '.claude', 'skills');
1296
- mkdirSync(target, { recursive: true });
1297
- // Whole directories: a skill may nest its SKILL.md (the vitrinka skill
1298
- // keeps it under listen/ next to its CLI + tests).
1299
- const names = readdirSync(bundle).filter((n) => !n.startsWith('.') && statSync(join(bundle, n)).isDirectory());
1300
- if (!names.length)
1301
- fail(`install-skills: no skills found in ${bundle}`);
1302
- for (const n of names) {
1303
- rmSync(join(target, n), { recursive: true, force: true });
1304
- cpSync(join(bundle, n), join(target, n), { recursive: true });
1305
- // Local installs live at ./.claude/skills — keep referenced CLI paths copy-pasteable.
1306
- if (local) {
1307
- for (const doc of [join(target, n, 'SKILL.md'), join(target, n, 'listen', 'SKILL.md')]) {
1308
- if (!existsSync(doc))
1309
- continue;
1310
- writeFileSync(doc, readFileSync(doc, 'utf8')
1311
- .replaceAll('~/.claude/skills/vitrinka/cli.ts', '.claude/skills/vitrinka/cli.ts'));
1312
- }
1272
+ // ---------- plugin distribution (skills ship via the runtimes' plugin systems) ----------
1273
+ // The skills bundle used to be copied into ~/.claude/skills (install-skills,
1274
+ // REMOVED 2026-07-11). Skills now distribute exclusively as the `vitrinka`
1275
+ // plugin from the `lovinka` marketplace (this repo), installable into BOTH
1276
+ // Claude Code and Codex one distribution per machine per runtime.
1277
+ const MARKETPLACE_REPO = 'LEFTEQ/vitrinka';
1278
+ // pluginRuntimes probes which agent CLIs exist and whether the vitrinka
1279
+ // plugin is installed in each. Claude records installs in
1280
+ // ~/.claude/plugins/installed_plugins.json; Codex is asked directly
1281
+ // (`codex plugin list` is fast and authoritative across its versions).
1282
+ function pluginRuntimes() {
1283
+ const home = process.env.HOME || '';
1284
+ const out = [];
1285
+ const claudePresent = claudeCliPresent();
1286
+ let claudeInstalled = false;
1287
+ if (claudePresent && home) {
1288
+ try {
1289
+ claudeInstalled = readFileSync(join(home, '.claude', 'plugins', 'installed_plugins.json'), 'utf8')
1290
+ .includes('vitrinka@lovinka');
1291
+ }
1292
+ catch {
1293
+ claudeInstalled = false; // no plugins file yet — nothing installed
1313
1294
  }
1314
- console.log(`✓ ${n} → ${join(target, n)}`);
1315
1295
  }
1316
- // /vitrinka:listen command stub nested SKILL.md files (skills/vitrinka/listen/)
1317
- // never register as invocable skills; only a stub under commands/vitrinka/ makes
1318
- // the slash command callable.
1319
- const stub = join(bundle, 'vitrinka', 'commands', 'listen.md');
1320
- if (existsSync(stub)) {
1321
- const commandsDir = local ? resolve('.claude', 'commands', 'vitrinka') : join(home, '.claude', 'commands', 'vitrinka');
1322
- mkdirSync(commandsDir, { recursive: true });
1323
- let content = readFileSync(stub, 'utf8');
1324
- if (local)
1325
- content = content.replaceAll('~/.claude/skills/vitrinka/listen/SKILL.md', '.claude/skills/vitrinka/listen/SKILL.md');
1326
- writeFileSync(join(commandsDir, 'listen.md'), content);
1327
- console.log(`✓ /vitrinka:listen command → ${join(commandsDir, 'listen.md')}`);
1296
+ out.push({ name: 'claude', present: claudePresent, installed: claudeInstalled });
1297
+ const codexPresent = !spawnSync('codex', ['--version'], { stdio: 'ignore' }).error;
1298
+ let codexInstalled = false;
1299
+ if (codexPresent) {
1300
+ const r = spawnSync('codex', ['plugin', 'list'], { encoding: 'utf8' });
1301
+ codexInstalled = r.status === 0 && /\bvitrinka\b/.test(r.stdout || '');
1328
1302
  }
1329
- console.log(`${names.length} skill${names.length === 1 ? '' : 's'} installed (${names.sort().join(', ')})`);
1303
+ out.push({ name: 'codex', present: codexPresent, installed: codexInstalled });
1304
+ return out;
1330
1305
  }
1331
- // ---------- setup state probing (shared by install / doctor / uninstall) ----------
1332
- // dirsEqual recursive byte comparison, the node twin of install.sh's `diff -rq`.
1333
- function dirsEqual(a, b) {
1334
- const names = new Set([...readdirSync(a), ...readdirSync(b)]);
1335
- for (const n of names) {
1336
- const pa = join(a, n), pb = join(b, n);
1337
- if (!existsSync(pa) || !existsSync(pb))
1306
+ // installPluginInto registers the lovinka marketplace and installs the
1307
+ // vitrinka plugin for one runtime. Both runtimes' commands are idempotent
1308
+ // enough for our purposes (marketplace add on an existing source errors —
1309
+ // tolerated; install/add on an installed plugin re-snapshots).
1310
+ function installPluginInto(rt) {
1311
+ const cmds = rt.name === 'claude'
1312
+ ? [['claude', 'plugin', 'marketplace', 'add', MARKETPLACE_REPO], ['claude', 'plugin', 'install', 'vitrinka@lovinka']]
1313
+ : [['codex', 'plugin', 'marketplace', 'add', MARKETPLACE_REPO], ['codex', 'plugin', 'add', 'vitrinka@lovinka']];
1314
+ for (const [bin, ...rest] of cmds) {
1315
+ const r = spawnSync(bin, rest, { encoding: 'utf8' });
1316
+ if (r.status !== 0 && !/already|exists/i.test((r.stderr || '') + (r.stdout || ''))) {
1317
+ console.error(`⚠ ${rt.name}: \`${[bin, ...rest].join(' ')}\` failed${r.stderr ? ` — ${r.stderr.trim().split('\n').pop()}` : ''}`);
1338
1318
  return false;
1339
- const da = statSync(pa).isDirectory(), db = statSync(pb).isDirectory();
1340
- if (da !== db)
1341
- return false;
1342
- if (da) {
1343
- if (!dirsEqual(pa, pb))
1344
- return false;
1345
1319
  }
1346
- else if (!readFileSync(pa).equals(readFileSync(pb)))
1347
- return false;
1348
1320
  }
1349
1321
  return true;
1350
1322
  }
1351
- // skillsStateOf the bundled skills' freshness at a target skills dir. Local
1352
- // installs rewrite CLI paths after copy, so a fresh local copy reads "outdated"
1353
- // against the bundle harmless (refresh is idempotent), same as install.sh.
1354
- function skillsStateOf(bundle, target) {
1355
- const names = readdirSync(bundle).filter((n) => !n.startsWith('.') && statSync(join(bundle, n)).isDirectory());
1356
- if (names.some((n) => !existsSync(join(target, n))))
1357
- return 'missing';
1358
- return names.every((n) => dirsEqual(join(bundle, n), join(target, n))) ? 'current' : 'outdated';
1323
+ // updatePluginIn refreshes an installed plugin to the marketplace's latest.
1324
+ function updatePluginIn(rt) {
1325
+ const cmd = rt.name === 'claude'
1326
+ ? ['claude', 'plugin', 'update', 'vitrinka@lovinka']
1327
+ : ['codex', 'plugin', 'marketplace', 'upgrade'];
1328
+ return spawnSync(cmd[0], cmd.slice(1), { stdio: 'inherit' }).status === 0;
1359
1329
  }
1330
+ // ---------- setup state probing (shared by install / doctor / uninstall) ----------
1360
1331
  const SHIM_CONTENT = `#!/bin/sh\nexec node "${CLI_PATH}" "$@"\n`;
1361
1332
  function shimPathOf(home) { return join(home, '.local', 'bin', 'vitrinka'); }
1362
1333
  function rcPathOf(home) {
@@ -1431,9 +1402,7 @@ async function installCmd(args) {
1431
1402
  const base = resolveBaseUrl(strArg(args.base));
1432
1403
  const mcpScope = local ? 'project' : 'user';
1433
1404
  // ---- probe (before touching anything) ----
1434
- const bundle = bundledSkillsDir(CLI_PATH);
1435
- const skillsTarget = local ? resolve('.claude', 'skills') : join(home, '.claude', 'skills');
1436
- const skills = bundle ? skillsStateOf(bundle, skillsTarget) : 'no-bundle';
1405
+ const runtimes = pluginRuntimes();
1437
1406
  const shimP = shimPathOf(home);
1438
1407
  const shimOk = existsSync(shimP) && readFileSync(shimP, 'utf8') === SHIM_CONTENT;
1439
1408
  const rc = rcPathOf(home);
@@ -1448,8 +1417,9 @@ async function installCmd(args) {
1448
1417
  console.log(` ${ok ? '✓' : '✗'} ${label.padEnd(11)}${detail}`);
1449
1418
  };
1450
1419
  console.log(`vitrinka setup — ${base}${local ? ' (local scope)' : ''}`);
1451
- row(skills === 'current', 'skills', skills === 'no-bundle' ? 'bundle not found next to the CLI (reinstall the package)'
1452
- : skills === 'current' ? `current ${skillsTarget}` : `${skills} will refresh`);
1420
+ for (const rt of runtimes) {
1421
+ row(rt.installed, `plugin·${rt.name}`, rt.installed ? 'vitrinka installed' : rt.present ? 'will install (skills ship as the plugin)' : `${rt.name} CLI not found`);
1422
+ }
1453
1423
  if (!local)
1454
1424
  row(shimOk, 'shim', shimOk ? shimP : 'will install');
1455
1425
  if (!local)
@@ -1459,9 +1429,11 @@ async function installCmd(args) {
1459
1429
  row(!!op, 'operator', op || 'unset — board writes stay anonymous');
1460
1430
  row(uiOk, 'claude ui', uiOk ? 'statusline + footer wired' : 'not wired');
1461
1431
  console.log('');
1462
- // ---- silent steps: skills, shim, PATH, completion (reversible, own dirs) ----
1463
- if (bundle && skills !== 'current')
1464
- installSkillsCmd(args);
1432
+ // ---- silent steps: plugins, shim, PATH, completion (reversible, own dirs) ----
1433
+ for (const rt of runtimes) {
1434
+ if (rt.present && !rt.installed && installPluginInto(rt))
1435
+ console.log(`✓ plugin (${rt.name}) → vitrinka@lovinka`);
1436
+ }
1465
1437
  if (!local) {
1466
1438
  if (!shimOk) {
1467
1439
  mkdirSync(dirname(shimP), { recursive: true });
@@ -1587,9 +1559,9 @@ export function stripMarkerBlock(content, marker) {
1587
1559
  }
1588
1560
  return out.join('\n');
1589
1561
  }
1590
- // The bundled skill names — used by uninstall when the bundle isn't next to the
1591
- // CLI anymore (e.g. the package was removed first).
1592
- const BUNDLED_SKILLS = ['artifact', 'brainstorming', 'screenshot', 'vitrinka'];
1562
+ // Legacy ~/.claude/skills copy names — removed by uninstall on machines set
1563
+ // up before the plugin distribution (install-skills, removed 2026-07-11).
1564
+ const LEGACY_SKILLS = ['artifact', 'brainstorming', 'screenshot', 'vitrinka', 'listen'];
1593
1565
  // uninstallCmd removes everything install put on the machine — the exact
1594
1566
  // inverse: bundled skills + the /vitrinka:listen stub, shim, rc marker blocks,
1595
1567
  // statusline wrapper (restoring the original statusLine command), the /boards/
@@ -1600,21 +1572,31 @@ function uninstallCmd(args) {
1600
1572
  if (!home)
1601
1573
  fail('uninstall: $HOME is not set');
1602
1574
  const local = args.local === true;
1603
- if (args.yes !== true && !promptYesNo(`Remove vitrinka from this machine (skills, shim, completion, Claude wiring, MCP${args.purge === true ? ', token+operator' : ''})? [Y/n] `)) {
1575
+ if (args.yes !== true && !promptYesNo(`Remove vitrinka from this machine (plugins, legacy skill copies, shim, completion, Claude wiring, MCP${args.purge === true ? ', token+operator' : ''})? [Y/n] `)) {
1604
1576
  fail('uninstall: aborted (non-interactive runs need --yes)', 2);
1605
1577
  }
1606
- // Skills + the /vitrinka:listen command stub.
1607
- const bundle = bundledSkillsDir(CLI_PATH);
1608
- const names = bundle
1609
- ? readdirSync(bundle).filter((n) => !n.startsWith('.') && statSync(join(bundle, n)).isDirectory())
1610
- : BUNDLED_SKILLS;
1578
+ // Plugins (the current distribution) + legacy ~/.claude/skills copies +
1579
+ // the old /vitrinka:listen command stub, from machines set up pre-plugin.
1580
+ for (const rt of pluginRuntimes()) {
1581
+ if (!rt.installed)
1582
+ continue;
1583
+ const cmd = rt.name === 'claude'
1584
+ ? ['claude', 'plugin', 'uninstall', 'vitrinka@lovinka']
1585
+ : ['codex', 'plugin', 'remove', 'vitrinka@lovinka'];
1586
+ if (spawnSync(cmd[0], cmd.slice(1), { stdio: 'inherit' }).status === 0) {
1587
+ console.log(`✓ removed ${rt.name} plugin`);
1588
+ }
1589
+ else {
1590
+ console.error(`⚠ ${rt.name} plugin removal failed — run: ${cmd.join(' ')}`);
1591
+ }
1592
+ }
1611
1593
  const skillsDir = local ? resolve('.claude', 'skills') : join(home, '.claude', 'skills');
1612
1594
  const commandsDir = local ? resolve('.claude', 'commands', 'vitrinka') : join(home, '.claude', 'commands', 'vitrinka');
1613
- for (const n of names) {
1595
+ for (const n of LEGACY_SKILLS) {
1614
1596
  if (!existsSync(join(skillsDir, n)))
1615
1597
  continue;
1616
1598
  rmSync(join(skillsDir, n), { recursive: true, force: true });
1617
- console.log(`✓ removed skill ${n}`);
1599
+ console.log(`✓ removed legacy skill copy ${n}`);
1618
1600
  }
1619
1601
  if (existsSync(join(commandsDir, 'listen.md'))) {
1620
1602
  rmSync(join(commandsDir, 'listen.md'));
@@ -1977,7 +1959,7 @@ export function cmpSemver(a, b) {
1977
1959
  return 0;
1978
1960
  }
1979
1961
  // Commands that must never carry the notifier/hint noise (setup and meta paths).
1980
- const HINT_SKIP = new Set(['install', 'install-claude', 'install-skills', 'update', 'uninstall',
1962
+ const HINT_SKIP = new Set(['install', 'install-claude', 'update', 'uninstall',
1981
1963
  'doctor', 'help', 'completion', '__complete', 'ai']);
1982
1964
  function updateCheckPath() {
1983
1965
  return join(process.env.HOME || '', '.config', 'vitrinka', 'update-check');
@@ -2070,7 +2052,7 @@ function pkgVersion() {
2070
2052
  // before, the Claude Code UI wiring. It never asks new consent questions —
2071
2053
  // that stays `vitrinka install`'s job. The refresh steps re-exec the (possibly
2072
2054
  // just replaced) CLI file so they run the NEW code, not this stale process.
2073
- async function updateCmd(args) {
2055
+ async function updateCmd(_args) {
2074
2056
  const repoTop = git(['rev-parse', '--show-toplevel'], dirname(CLI_PATH));
2075
2057
  if (repoTop) {
2076
2058
  // Repo checkout — the shim/MCP run the source directly, so a pull IS the update.
@@ -2124,11 +2106,13 @@ async function updateCmd(args) {
2124
2106
  }
2125
2107
  }
2126
2108
  }
2127
- // Refresh installed surfaces from the NEW code — skills always (an install
2128
- // implies them), Claude wiring only if the wrapper shows prior consent.
2109
+ // Refresh installed surfaces from the NEW code — plugins where installed,
2110
+ // Claude wiring only if the wrapper shows prior consent.
2129
2111
  const rerun = (sub) => spawnSync(process.execPath, [CLI_PATH, ...sub], { stdio: 'inherit' }).status === 0;
2130
- if (!rerun(['install-skills', ...(args.local === true ? ['--local'] : [])])) {
2131
- fail('update: skills refresh failed — re-run: vitrinka install-skills');
2112
+ for (const rt of pluginRuntimes()) {
2113
+ if (rt.installed && !updatePluginIn(rt)) {
2114
+ console.error(`⚠ update: ${rt.name} plugin refresh failed — re-run its plugin update manually`);
2115
+ }
2132
2116
  }
2133
2117
  const wrapper = join(process.env.HOME || '', '.claude', 'vitrinka-statusline.sh');
2134
2118
  if (existsSync(wrapper)) {
@@ -2854,12 +2838,7 @@ export const COMMANDS = [
2854
2838
  examples: ['vitrinka install-claude'],
2855
2839
  },
2856
2840
  {
2857
- name: 'install-skills', summary: 'Install the bundled Claude skills (vitrinka, screenshot, artifact, brainstorming)',
2858
- usage: '[--local]', flags: [{ f: '--local', d: 'install into ./.claude/skills of the current project instead of ~/.claude/skills' }],
2859
- examples: ['vitrinka install-skills'],
2860
- },
2861
- {
2862
- name: 'update', summary: 'Update the CLI (git pull / npm -g), show what\'s new, refresh the installed skills + Claude wiring',
2841
+ name: 'update', summary: 'Update the CLI (git pull / npm -g), show what\'s new, refresh the installed plugins + Claude wiring',
2863
2842
  usage: '[--local]',
2864
2843
  flags: [{ f: '--local', d: 'refresh skills into ./.claude/skills of the current project instead of ~/.claude/skills' }],
2865
2844
  examples: ['vitrinka update'],
@@ -3741,8 +3720,6 @@ async function runSub(argv) {
3741
3720
  await installCmd(args);
3742
3721
  else if (cmd === 'install-claude')
3743
3722
  installClaudeCmd(args);
3744
- else if (cmd === 'install-skills')
3745
- installSkillsCmd(args);
3746
3723
  else if (cmd === 'uninstall')
3747
3724
  uninstallCmd(args);
3748
3725
  else if (cmd === 'update')