@lovinka/vitrinka 1.1.0 → 1.4.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/README.md +20 -0
- package/dist/cli.js +493 -10
- package/dist/cli.js.map +1 -1
- package/dist/lib/token.js +26 -0
- package/dist/lib/token.js.map +1 -1
- package/dist/mcp.js +411 -16
- package/dist/mcp.js.map +1 -1
- package/package.json +7 -5
- package/skills/artifact/SKILL.md +82 -0
- package/skills/brainstorming/SKILL.md +107 -0
- package/skills/screenshot/SKILL.md +70 -0
- package/skills/screenshot/gallery.mjs +6 -0
- package/skills/vitrinka/cli.ts +39 -0
- package/skills/vitrinka/listen/SKILL.md +193 -0
- package/skills/vitrinka/tests/_helpers.ts +108 -0
- package/skills/vitrinka/tests/derive.test.ts +118 -0
- package/skills/vitrinka/tests/dx.test.ts +264 -0
- package/skills/vitrinka/tests/embed.test.ts +96 -0
- package/skills/vitrinka/tests/index.test.ts +250 -0
- package/skills/vitrinka/tests/install-name.test.ts +111 -0
- package/skills/vitrinka/tests/manifest.test.ts +98 -0
- package/skills/vitrinka/tests/operator.test.ts +119 -0
- package/skills/vitrinka/tests/push.test.ts +115 -0
- package/skills/vitrinka/tests/scaffold.test.ts +173 -0
- package/skills/vitrinka/tests/shim.test.ts +28 -0
- package/skills/vitrinka/tests/snap.test.ts +175 -0
- package/skills/vitrinka/tests/watch.test.ts +204 -0
package/README.md
CHANGED
|
@@ -25,6 +25,26 @@ npm i -g @lovinka/vitrinka
|
|
|
25
25
|
claude mcp add --scope user vitrinka -- npx -y --package=@lovinka/vitrinka vitrinka-mcp
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
+
`vitrinka install` also offers (interactive `[Y/n]`, or `--claude`/`--no-claude`)
|
|
29
|
+
to wire the **Claude Code UI** — re-runnable standalone as `vitrinka install-claude`:
|
|
30
|
+
|
|
31
|
+
- **Footer badge** — a `footerLinksRegexes` entry in `~/.claude/settings.json`
|
|
32
|
+
renders a clickable **🧷 board** badge in Claude Code's footer whenever a
|
|
33
|
+
`/boards/` URL appears in the conversation (clickable in every terminal;
|
|
34
|
+
needs Claude Code ≥ 2.1.176).
|
|
35
|
+
- **Statusline segment** — `~/.claude/vitrinka-statusline.sh` wraps your existing
|
|
36
|
+
statusline command (recorded on its `VITRINKA_ORIG=` line) and appends a
|
|
37
|
+
`🧷 board` OSC 8 link when the session's `.screenshots/.vitrinka` descriptor
|
|
38
|
+
exists; with no statusline configured it renders a minimal
|
|
39
|
+
`dir │ branch │ 🧷 board │ model` line itself. Needs `jq`; degrades to
|
|
40
|
+
pass-through without it, and never double-renders a board segment your own
|
|
41
|
+
statusline already prints.
|
|
42
|
+
|
|
43
|
+
Both steps are idempotent — re-runs detect the wiring and no-op. To remove:
|
|
44
|
+
delete `~/.claude/vitrinka-statusline.sh`, restore `statusLine.command` in
|
|
45
|
+
`~/.claude/settings.json` to the `VITRINKA_ORIG` value (or drop the key), and
|
|
46
|
+
remove the `footerLinksRegexes` entry whose pattern contains `/boards/`.
|
|
47
|
+
|
|
28
48
|
For Claude Desktop or any other MCP client, add the equivalent entry by hand:
|
|
29
49
|
|
|
30
50
|
```json
|
package/dist/cli.js
CHANGED
|
@@ -10,14 +10,14 @@
|
|
|
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, } from 'node:fs';
|
|
13
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, readdirSync, copyFileSync, mkdtempSync, chmodSync, appendFileSync, statSync, cpSync, openSync, readSync, closeSync, } 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';
|
|
17
17
|
import { createHash } from 'node:crypto';
|
|
18
18
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
19
19
|
import { createInterface } from 'node:readline';
|
|
20
|
-
import { readToken } from "./lib/token.js";
|
|
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
23
|
const CLI_PATH = fileURLToPath(import.meta.url);
|
|
@@ -146,6 +146,26 @@ export function shotContext(args) {
|
|
|
146
146
|
}
|
|
147
147
|
return out;
|
|
148
148
|
}
|
|
149
|
+
// appVersionOf reads the app's version string for capture provenance:
|
|
150
|
+
// app.json (Expo: expo.version, or bare version) first, then package.json.
|
|
151
|
+
// Best-effort — a missing/malformed file just leaves the field off.
|
|
152
|
+
export function appVersionOf(projectDir) {
|
|
153
|
+
for (const f of ['app.json', 'package.json']) {
|
|
154
|
+
const p = join(projectDir, f);
|
|
155
|
+
if (!existsSync(p))
|
|
156
|
+
continue;
|
|
157
|
+
try {
|
|
158
|
+
const j = JSON.parse(readFileSync(p, 'utf8'));
|
|
159
|
+
const v = j?.expo?.version ?? j?.version;
|
|
160
|
+
if (typeof v === 'string' && v)
|
|
161
|
+
return v;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Malformed JSON is not snap's problem — provenance is best-effort.
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return '';
|
|
168
|
+
}
|
|
149
169
|
function appendShot(root, shot) {
|
|
150
170
|
const m = loadManifest(root);
|
|
151
171
|
m.shots.push(shot);
|
|
@@ -470,17 +490,23 @@ function cmdDescription(mdPath) {
|
|
|
470
490
|
* resolves it: `<root>/commands/**\/*.md` → `/ns:name` (dirs join with ":"),
|
|
471
491
|
* `<root>/skills/<ns…>/<name>/SKILL.md` → `/ns:name` — from the PROJECT's
|
|
472
492
|
* .claude first, then the HOME ~/.claude (project wins name collisions).
|
|
493
|
+
* Home entries are tagged `s:"home"` so the board can tier + mark them.
|
|
473
494
|
* Underscore-prefixed files/dirs (e.g. skills/git/_shared) are internals. */
|
|
474
495
|
export function collectCommands(projectDir, homeClaude = join(process.env.HOME || '', '.claude')) {
|
|
475
496
|
const out = new Map();
|
|
476
|
-
const add = (segs, mdPath) => {
|
|
497
|
+
const add = (segs, mdPath, home) => {
|
|
477
498
|
if (segs.some((s) => !s || s.startsWith('_') || s.startsWith('.')))
|
|
478
499
|
return;
|
|
479
500
|
const c = '/' + segs.join(':');
|
|
480
501
|
if (out.has(c) || out.size >= 1000)
|
|
481
502
|
return;
|
|
482
503
|
const d = cmdDescription(mdPath);
|
|
483
|
-
|
|
504
|
+
const e = { c };
|
|
505
|
+
if (d)
|
|
506
|
+
e.d = d;
|
|
507
|
+
if (home)
|
|
508
|
+
e.s = 'home';
|
|
509
|
+
out.set(c, e);
|
|
484
510
|
};
|
|
485
511
|
const list = (dir) => {
|
|
486
512
|
try {
|
|
@@ -491,15 +517,16 @@ export function collectCommands(projectDir, homeClaude = join(process.env.HOME |
|
|
|
491
517
|
}
|
|
492
518
|
};
|
|
493
519
|
for (const root of [join(projectDir, '.claude'), homeClaude]) {
|
|
520
|
+
const home = root === homeClaude;
|
|
494
521
|
for (const rel of list(join(root, 'commands'))) {
|
|
495
522
|
if (!rel.endsWith('.md'))
|
|
496
523
|
continue;
|
|
497
|
-
add(rel.slice(0, -3).split('/'), join(root, 'commands', rel));
|
|
524
|
+
add(rel.slice(0, -3).split('/'), join(root, 'commands', rel), home);
|
|
498
525
|
}
|
|
499
526
|
for (const rel of list(join(root, 'skills'))) {
|
|
500
527
|
if (!rel.endsWith('/SKILL.md'))
|
|
501
528
|
continue;
|
|
502
|
-
add(rel.slice(0, -'/SKILL.md'.length).split('/'), join(root, 'skills', rel));
|
|
529
|
+
add(rel.slice(0, -'/SKILL.md'.length).split('/'), join(root, 'skills', rel), home);
|
|
503
530
|
}
|
|
504
531
|
}
|
|
505
532
|
return [...out.values()].sort((a, b) => (a.c < b.c ? -1 : a.c > b.c ? 1 : 0));
|
|
@@ -523,7 +550,9 @@ export function buildProjectIndex(projectDir) {
|
|
|
523
550
|
files = files.slice(0, 50_000);
|
|
524
551
|
}
|
|
525
552
|
const routes = extractRoutes(files);
|
|
526
|
-
|
|
553
|
+
// The palette lives at the repo root's .claude — resolve from `top`, not the
|
|
554
|
+
// raw cwd, or a push from a subdir silently indexes only ~/.claude.
|
|
555
|
+
const cmds = collectCommands(top);
|
|
527
556
|
// Key order mirrors the server's canonical marshal (v, repo, routes, files, cmds).
|
|
528
557
|
const body = JSON.stringify({ v: 1, repo: originRepo(projectDir) || undefined, routes, files, cmds });
|
|
529
558
|
return {
|
|
@@ -612,6 +641,111 @@ async function indexCmd(root, args) {
|
|
|
612
641
|
if (!(await pushProjectIndex(projectDir, base, project, true)))
|
|
613
642
|
process.exit(1);
|
|
614
643
|
}
|
|
644
|
+
const COMPONENT_SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.next', '.expo', 'coverage', '.git', '__tests__', '__mocks__']);
|
|
645
|
+
function scanComponentFiles(dir, out) {
|
|
646
|
+
let entries;
|
|
647
|
+
try {
|
|
648
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
return; // a listed default dir simply doesn't exist in this repo
|
|
652
|
+
}
|
|
653
|
+
for (const e of entries) {
|
|
654
|
+
if (e.isDirectory()) {
|
|
655
|
+
if (!COMPONENT_SKIP_DIRS.has(e.name) && !e.name.startsWith('.'))
|
|
656
|
+
scanComponentFiles(join(dir, e.name), out);
|
|
657
|
+
}
|
|
658
|
+
else if (/\.(tsx|jsx)$/.test(e.name) && !/\.(test|spec|stories)\./.test(e.name)) {
|
|
659
|
+
out.push(join(dir, e.name));
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
function extractProps(src, comp) {
|
|
664
|
+
const m = src.match(new RegExp(`(?:interface\\s+${comp}Props\\s*(?:extends [^{]+)?|type\\s+${comp}Props\\s*=\\s*)\\{`));
|
|
665
|
+
if (!m || m.index === undefined)
|
|
666
|
+
return { props: [], variants: [] };
|
|
667
|
+
// Balance braces from the block open so nested object types don't truncate.
|
|
668
|
+
let depth = 0, i = src.indexOf('{', m.index), start = i;
|
|
669
|
+
for (; i < src.length; i++) {
|
|
670
|
+
if (src[i] === '{')
|
|
671
|
+
depth++;
|
|
672
|
+
else if (src[i] === '}' && --depth === 0)
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
675
|
+
const block = src.slice(start + 1, i);
|
|
676
|
+
const props = [];
|
|
677
|
+
const variants = [];
|
|
678
|
+
for (const line of block.split('\n')) {
|
|
679
|
+
const pm = line.match(/^\s*(\w+)(\?)?\s*:\s*([^;]+)/);
|
|
680
|
+
if (!pm)
|
|
681
|
+
continue;
|
|
682
|
+
const type = pm[3].trim().replace(/\s+/g, ' ').slice(0, 120);
|
|
683
|
+
props.push({ name: pm[1], type, required: !pm[2] });
|
|
684
|
+
const lits = type.match(/'[^']+'/g);
|
|
685
|
+
if (lits && lits.length > 1)
|
|
686
|
+
variants.push(`${pm[1]}=${lits.map((l) => l.slice(1, -1)).join('|')}`);
|
|
687
|
+
}
|
|
688
|
+
return { props, variants };
|
|
689
|
+
}
|
|
690
|
+
function scanComponents(projectDir, srcDirs) {
|
|
691
|
+
const files = [];
|
|
692
|
+
for (const d of srcDirs)
|
|
693
|
+
scanComponentFiles(resolve(projectDir, d), files);
|
|
694
|
+
const seen = new Map();
|
|
695
|
+
for (const file of files) {
|
|
696
|
+
const src = readFileSync(file, 'utf8');
|
|
697
|
+
const names = new Set();
|
|
698
|
+
for (const m of src.matchAll(/export\s+(?:default\s+)?function\s+([A-Z]\w+)\s*[(<]/g))
|
|
699
|
+
names.add(m[1]);
|
|
700
|
+
for (const m of src.matchAll(/export\s+const\s+([A-Z]\w+)\s*(?::[^=]+)?=\s*(?:React\.)?(?:memo|forwardRef)?\s*[(<]/g))
|
|
701
|
+
names.add(m[1]);
|
|
702
|
+
for (const name of names) {
|
|
703
|
+
if (seen.has(name))
|
|
704
|
+
continue; // first definition wins; dupes are re-exports
|
|
705
|
+
const { props, variants } = extractProps(src, name);
|
|
706
|
+
seen.set(name, { name, kind: 'component', sourcePath: relative(projectDir, file), props, variants });
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
710
|
+
}
|
|
711
|
+
async function indexComponentsCmd(root, args) {
|
|
712
|
+
const projectDir = dirname(root);
|
|
713
|
+
const library = strArg(args.library);
|
|
714
|
+
if (!library)
|
|
715
|
+
throw new Error('index-components: --library <kit> is required (one per UI library, e.g. "web" or "expo")');
|
|
716
|
+
const base = (strArg(args.base) || process.env.VITRINKA_URL || 'https://vitrinka.lovinka.com').replace(/\/+$/, '');
|
|
717
|
+
const project = sanitizeSeg(strArg(args.project) || basename(mainWorktreeTop(projectDir) || projectDir), 64);
|
|
718
|
+
let components;
|
|
719
|
+
if (strArg(args.from)) {
|
|
720
|
+
const raw = JSON.parse(readFileSync(String(args.from), 'utf8'));
|
|
721
|
+
components = Array.isArray(raw) ? raw : raw.components;
|
|
722
|
+
if (!Array.isArray(components))
|
|
723
|
+
throw new Error('index-components: --from file must be a components array or {components:[…]}');
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
const dirs = (strArg(args.src) || 'src/components,components,src').split(',').map((d) => d.trim()).filter(Boolean);
|
|
727
|
+
components = scanComponents(projectDir, dirs);
|
|
728
|
+
if (!components.length) {
|
|
729
|
+
throw new Error(`index-components: no exported components under ${dirs.join(', ')} — pass --src or check the library root`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
if (args.dry) {
|
|
733
|
+
console.log(JSON.stringify({ project, library, components }, null, 2));
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
const token = readToken();
|
|
737
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
738
|
+
if (token)
|
|
739
|
+
headers.Authorization = `Bearer ${token}`;
|
|
740
|
+
const res = await fetch(`${base}/api/v1/projects/${encodeURIComponent(project)}/components`, {
|
|
741
|
+
method: 'PUT', headers,
|
|
742
|
+
body: JSON.stringify({ library, components }),
|
|
743
|
+
signal: AbortSignal.timeout(30_000),
|
|
744
|
+
});
|
|
745
|
+
if (!res.ok)
|
|
746
|
+
throw new Error(`index-components: push rejected (HTTP ${res.status}): ${(await res.text()).slice(0, 200)}`);
|
|
747
|
+
console.log(`components ↑ ${project}/${library}: ${components.length} indexed`);
|
|
748
|
+
}
|
|
615
749
|
function loadVitrinkaCfg(root) {
|
|
616
750
|
const raw = readFileSync(vitrinkaCfgPath(root), 'utf8'); // caller checks existsSync
|
|
617
751
|
try {
|
|
@@ -904,7 +1038,118 @@ async function operatorCmd(rest, args) {
|
|
|
904
1038
|
console.error(`operator: server unreachable (${e instanceof Error ? e.message : String(e)}) — local name is set; re-run to publish`);
|
|
905
1039
|
}
|
|
906
1040
|
}
|
|
1041
|
+
// ---------- doctor: setup health check ----------
|
|
1042
|
+
// doctorCmd checks this machine's vitrinka setup: server reachable, write
|
|
1043
|
+
// token present AND accepted by the server, operator persona set. The token
|
|
1044
|
+
// probe is `PUT /api/v1/operator` with an empty JSON body — auth runs before
|
|
1045
|
+
// body validation, so 401 = bad token and 422 = token accepted, with nothing
|
|
1046
|
+
// ever written. Exit 1 when the server is unreachable or the token is
|
|
1047
|
+
// missing/rejected (reads still work; writes would 401).
|
|
1048
|
+
async function doctorCmd(args) {
|
|
1049
|
+
const base = resolveBaseUrl(strArg(args.base));
|
|
1050
|
+
let failed = false;
|
|
1051
|
+
let reachable = false;
|
|
1052
|
+
try {
|
|
1053
|
+
const res = await fetch(`${base}/healthz`, { signal: AbortSignal.timeout(10_000) });
|
|
1054
|
+
reachable = res.ok;
|
|
1055
|
+
console.log(res.ok ? `✓ server ${base}` : `✗ server ${base} (HTTP ${res.status})`);
|
|
1056
|
+
if (!res.ok)
|
|
1057
|
+
failed = true;
|
|
1058
|
+
}
|
|
1059
|
+
catch (e) {
|
|
1060
|
+
console.log(`✗ server ${base} unreachable (${e instanceof Error ? e.message : String(e)}) — vitrinka is WireGuard-mesh-only; bring the VPN up`);
|
|
1061
|
+
failed = true;
|
|
1062
|
+
}
|
|
1063
|
+
const src = tokenSource();
|
|
1064
|
+
const token = readToken();
|
|
1065
|
+
if (!token) {
|
|
1066
|
+
console.log(`✗ token missing ($VITRINKA_TOKEN unset, no ${tokenPath()}) — writes will 401`);
|
|
1067
|
+
console.log('');
|
|
1068
|
+
console.log(tokenHelp());
|
|
1069
|
+
console.log('');
|
|
1070
|
+
failed = true;
|
|
1071
|
+
}
|
|
1072
|
+
else if (reachable) {
|
|
1073
|
+
try {
|
|
1074
|
+
const res = await fetch(`${base}/api/v1/operator`, {
|
|
1075
|
+
method: 'PUT',
|
|
1076
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
1077
|
+
body: '{}',
|
|
1078
|
+
signal: AbortSignal.timeout(10_000),
|
|
1079
|
+
});
|
|
1080
|
+
if (res.status === 401) {
|
|
1081
|
+
console.log(`✗ token REJECTED by ${base} (from ${src === 'env' ? '$VITRINKA_TOKEN' : tokenPath()}) — stale or mistyped`);
|
|
1082
|
+
console.log('');
|
|
1083
|
+
console.log(tokenHelp());
|
|
1084
|
+
console.log('');
|
|
1085
|
+
failed = true;
|
|
1086
|
+
}
|
|
1087
|
+
else {
|
|
1088
|
+
console.log(`✓ token valid (from ${src === 'env' ? '$VITRINKA_TOKEN' : tokenPath()})`);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
catch (e) {
|
|
1092
|
+
console.log(`? token present (from ${src === 'env' ? '$VITRINKA_TOKEN' : tokenPath()}) — validity check failed (${e instanceof Error ? e.message : String(e)})`);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
else {
|
|
1096
|
+
console.log(`? token present (from ${src === 'env' ? '$VITRINKA_TOKEN' : tokenPath()}) — server unreachable, validity unknown`);
|
|
1097
|
+
}
|
|
1098
|
+
const op = readOperator(true);
|
|
1099
|
+
if (op)
|
|
1100
|
+
console.log(`✓ operator ${op}`);
|
|
1101
|
+
else
|
|
1102
|
+
console.log('✗ operator unset — board writes stay anonymous (fix: vitrinka operator <name>)');
|
|
1103
|
+
if (failed)
|
|
1104
|
+
process.exit(1);
|
|
1105
|
+
}
|
|
907
1106
|
// ---------- install: PATH shim ----------
|
|
1107
|
+
// bundledSkillsDir locates the skills bundle shipped inside the npm package
|
|
1108
|
+
// (dist/cli.js → ../skills) or, when running from a repo checkout
|
|
1109
|
+
// (pkg/src/cli.ts), the repo-root skills/ directory.
|
|
1110
|
+
export function bundledSkillsDir(cliPath) {
|
|
1111
|
+
for (const cand of [join(dirname(cliPath), '..', 'skills'), join(dirname(cliPath), '..', '..', 'skills')]) {
|
|
1112
|
+
if (existsSync(join(cand, 'vitrinka')))
|
|
1113
|
+
return cand;
|
|
1114
|
+
}
|
|
1115
|
+
return '';
|
|
1116
|
+
}
|
|
1117
|
+
// installSkillsCmd copies the packaged skills bundle (vitrinka + listen,
|
|
1118
|
+
// screenshot, artifact, brainstorming) into the Claude skills directory —
|
|
1119
|
+
// ~/.claude/skills by default, ./.claude/skills with --local. This is the
|
|
1120
|
+
// npm-package twin of install.sh's skills step, so `npm i -g @lovinka/vitrinka
|
|
1121
|
+
// && vitrinka install-skills` fully equips a machine without a repo checkout.
|
|
1122
|
+
function installSkillsCmd(args) {
|
|
1123
|
+
const bundle = bundledSkillsDir(CLI_PATH);
|
|
1124
|
+
if (!bundle)
|
|
1125
|
+
fail('install-skills: bundled skills not found next to the CLI — reinstall the package');
|
|
1126
|
+
const home = process.env.HOME;
|
|
1127
|
+
const local = args.local === true;
|
|
1128
|
+
if (!local && !home)
|
|
1129
|
+
fail('install-skills: $HOME is not set (or pass --local)');
|
|
1130
|
+
const target = local ? resolve('.claude', 'skills') : join(home, '.claude', 'skills');
|
|
1131
|
+
mkdirSync(target, { recursive: true });
|
|
1132
|
+
// Whole directories, like install.sh: a skill may nest its SKILL.md (the
|
|
1133
|
+
// vitrinka skill keeps it under listen/ next to its CLI + tests).
|
|
1134
|
+
const names = readdirSync(bundle).filter((n) => !n.startsWith('.') && statSync(join(bundle, n)).isDirectory());
|
|
1135
|
+
if (!names.length)
|
|
1136
|
+
fail(`install-skills: no skills found in ${bundle}`);
|
|
1137
|
+
for (const n of names) {
|
|
1138
|
+
rmSync(join(target, n), { recursive: true, force: true });
|
|
1139
|
+
cpSync(join(bundle, n), join(target, n), { recursive: true });
|
|
1140
|
+
// Local installs live at ./.claude/skills — keep referenced CLI paths copy-pasteable.
|
|
1141
|
+
if (local) {
|
|
1142
|
+
for (const doc of [join(target, n, 'SKILL.md'), join(target, n, 'listen', 'SKILL.md')]) {
|
|
1143
|
+
if (!existsSync(doc))
|
|
1144
|
+
continue;
|
|
1145
|
+
writeFileSync(doc, readFileSync(doc, 'utf8')
|
|
1146
|
+
.replaceAll('~/.claude/skills/vitrinka/cli.ts', '.claude/skills/vitrinka/cli.ts'));
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
console.log(`✓ ${n} → ${join(target, n)}`);
|
|
1150
|
+
}
|
|
1151
|
+
console.log(`${names.length} skill${names.length === 1 ? '' : 's'} installed (${names.sort().join(', ')})`);
|
|
1152
|
+
}
|
|
908
1153
|
// installCmd writes an executable shim `vitrinka` into ~/.local/bin (so the
|
|
909
1154
|
// CLI works in interactive shells AND non-interactive AI/tool shells, which a
|
|
910
1155
|
// zshrc alias would not) and makes sure ~/.local/bin is on PATH via a
|
|
@@ -956,8 +1201,195 @@ function installCmd(args) {
|
|
|
956
1201
|
console.log(`added ${shellName} completion to ${rc} (open a new shell, or: source ${rc})`);
|
|
957
1202
|
}
|
|
958
1203
|
}
|
|
1204
|
+
// Claude Code UI wiring — consent-gated (it edits ~/.claude/settings.json):
|
|
1205
|
+
// --claude / --no-claude decide outright; otherwise ask on a TTY, skip with a
|
|
1206
|
+
// hint when non-interactive (curl|bash, CI).
|
|
1207
|
+
if (args['no-claude'] === true) {
|
|
1208
|
+
console.log('claude wiring: skipped (--no-claude)');
|
|
1209
|
+
}
|
|
1210
|
+
else if (args.claude === true || promptYesNo('Wire Claude Code UI (🧷 board statusline segment + clickable footer badge)? [Y/n] ')) {
|
|
1211
|
+
installClaudeCmd(args);
|
|
1212
|
+
}
|
|
1213
|
+
else if (!process.stdin.isTTY) {
|
|
1214
|
+
console.log('claude wiring: skipped (non-interactive — run `vitrinka install-claude`, or pass --claude)');
|
|
1215
|
+
}
|
|
1216
|
+
else {
|
|
1217
|
+
console.log('claude wiring: skipped (re-run any time: vitrinka install-claude)');
|
|
1218
|
+
}
|
|
959
1219
|
console.log('done — try: vitrinka --help');
|
|
960
1220
|
}
|
|
1221
|
+
// ---------- install-claude: Claude Code UI wiring ----------
|
|
1222
|
+
//
|
|
1223
|
+
// Wires the two Claude Code surfaces that link to boards (decisions:
|
|
1224
|
+
// docs/specs/2026-07-07-claude-install-decisions.md):
|
|
1225
|
+
// 1. footerLinksRegexes in ~/.claude/settings.json — Claude renders its own
|
|
1226
|
+
// clickable "🧷 board" footer badge whenever a /boards/ URL appears in the
|
|
1227
|
+
// conversation (works in every terminal, unlike statusline OSC 8).
|
|
1228
|
+
// 2. statusLine — a wrapper script that pipes the same stdin JSON to the
|
|
1229
|
+
// user's ORIGINAL statusline command and appends a "🧷 board" OSC 8 link
|
|
1230
|
+
// when the session's capture root has a .screenshots/.vitrinka descriptor.
|
|
1231
|
+
// With no original statusline, the wrapper renders a minimal line itself.
|
|
1232
|
+
// Idempotent, never an uninstall: re-runs detect the /boards/ footer pattern and
|
|
1233
|
+
// the wrapper registration and no-op. Removal = delete the wrapper + restore
|
|
1234
|
+
// statusLine.command (recorded in the wrapper's VITRINKA_ORIG line) + drop the
|
|
1235
|
+
// footerLinksRegexes entry (documented in pkg/README.md).
|
|
1236
|
+
export const BOARD_FOOTER_LINK = {
|
|
1237
|
+
type: 'regex',
|
|
1238
|
+
pattern: '(?<url>https?:\\/\\/\\S+\\/boards\\/(?<slug>[\\w.-]+))',
|
|
1239
|
+
url: '{url}',
|
|
1240
|
+
label: 'vitrinka/b/{slug}',
|
|
1241
|
+
};
|
|
1242
|
+
// hasBoardFooterLink — an entry whose pattern targets /boards/ URLs already
|
|
1243
|
+
// exists (ours or hand-written); the marker that makes the settings edit idempotent.
|
|
1244
|
+
export function hasBoardFooterLink(settings) {
|
|
1245
|
+
const arr = settings.footerLinksRegexes;
|
|
1246
|
+
// Strip regex escaping first — patterns write the path as \/boards\/ as often as /boards/.
|
|
1247
|
+
return Array.isArray(arr) && arr.some((e) => typeof e?.pattern === 'string' && e.pattern.replaceAll('\\', '').includes('/boards/'));
|
|
1248
|
+
}
|
|
1249
|
+
// buildStatuslineWrapper — the ~/.claude/vitrinka-statusline.sh content. The
|
|
1250
|
+
// original command is recorded on the VITRINKA_ORIG line (single-quoted), both
|
|
1251
|
+
// to run it and so a re-run can recover it instead of wrapping the wrapper.
|
|
1252
|
+
// Board detection mirrors the CLI's capture-root contract: .screenshots/.vitrinka
|
|
1253
|
+
// with {base, key} → ${base}/boards/${key}. Needs jq (like the statusline
|
|
1254
|
+
// examples in Claude's own docs); without jq it degrades to pass-through.
|
|
1255
|
+
export function buildStatuslineWrapper(origCmd) {
|
|
1256
|
+
const quoted = origCmd.replaceAll("'", `'\\''`);
|
|
1257
|
+
return `#!/bin/bash
|
|
1258
|
+
# vitrinka-statusline — managed by \`vitrinka install-claude\` (re-runs rewrite it).
|
|
1259
|
+
# Wraps the original Claude Code statusline and appends a 🧷 board OSC 8 link when
|
|
1260
|
+
# the session's capture root has a .screenshots/.vitrinka descriptor.
|
|
1261
|
+
VITRINKA_ORIG='${quoted}'
|
|
1262
|
+
|
|
1263
|
+
input=$(cat)
|
|
1264
|
+
|
|
1265
|
+
out=""
|
|
1266
|
+
if [ -n "$VITRINKA_ORIG" ]; then
|
|
1267
|
+
out=$(printf '%s' "$input" | /bin/sh -c "$VITRINKA_ORIG")
|
|
1268
|
+
fi
|
|
1269
|
+
|
|
1270
|
+
# The original already renders a board segment → pass through untouched.
|
|
1271
|
+
case "$out" in *'/boards/'*|*'🧷'*) printf '%s\\n' "$out"; exit 0 ;; esac
|
|
1272
|
+
|
|
1273
|
+
if ! command -v jq >/dev/null 2>&1; then printf '%s\\n' "$out"; exit 0; fi
|
|
1274
|
+
|
|
1275
|
+
DIR=$(printf '%s' "$input" | jq -r '.workspace.current_dir // empty')
|
|
1276
|
+
PROJECT_DIR=$(printf '%s' "$input" | jq -r '.workspace.project_dir // empty')
|
|
1277
|
+
|
|
1278
|
+
BOARD=""
|
|
1279
|
+
for VJSON in "$DIR/.screenshots/.vitrinka" "$PROJECT_DIR/.screenshots/.vitrinka"; do
|
|
1280
|
+
[ -f "$VJSON" ] || continue
|
|
1281
|
+
VBASE=$(jq -r '.base // empty' "$VJSON" 2>/dev/null)
|
|
1282
|
+
VKEY=$(jq -r '.key // empty' "$VJSON" 2>/dev/null)
|
|
1283
|
+
if [ -n "$VBASE" ] && [ -n "$VKEY" ]; then
|
|
1284
|
+
BOARD=$(printf '\\033]8;;%s\\a\\033[35m🧷 board\\033[0m\\033]8;;\\a' "$VBASE/boards/$VKEY")
|
|
1285
|
+
fi
|
|
1286
|
+
break
|
|
1287
|
+
done
|
|
1288
|
+
|
|
1289
|
+
DIM=$(printf '\\033[2m'); RESET=$(printf '\\033[0m')
|
|
1290
|
+
|
|
1291
|
+
if [ -n "$VITRINKA_ORIG" ]; then
|
|
1292
|
+
# Append to the FIRST line only (extra statusline lines stay untouched).
|
|
1293
|
+
first=\${out%%$'\\n'*}; rest=\${out#"$first"}
|
|
1294
|
+
[ -n "$BOARD" ] && first="$first \${DIM}│\${RESET} $BOARD"
|
|
1295
|
+
printf '%s%s\\n' "$first" "$rest"
|
|
1296
|
+
else
|
|
1297
|
+
# No original statusline — minimal line: dir │ branch │ board │ model.
|
|
1298
|
+
MODEL=$(printf '%s' "$input" | jq -r '.model.display_name // empty')
|
|
1299
|
+
BRANCH=$(git -C "\${DIR:-.}" branch --show-current 2>/dev/null)
|
|
1300
|
+
GREEN=$(printf '\\033[32m'); CYAN=$(printf '\\033[36m')
|
|
1301
|
+
line="\${DIR/#$HOME/~}"
|
|
1302
|
+
[ -n "$BRANCH" ] && line="$line \${DIM}│\${RESET} \${GREEN}\${BRANCH}\${RESET}"
|
|
1303
|
+
[ -n "$BOARD" ] && line="$line \${DIM}│\${RESET} $BOARD"
|
|
1304
|
+
[ -n "$MODEL" ] && line="$line \${DIM}│\${RESET} \${CYAN}\${MODEL}\${RESET}"
|
|
1305
|
+
printf '%s\\n' "$line"
|
|
1306
|
+
fi
|
|
1307
|
+
`;
|
|
1308
|
+
}
|
|
1309
|
+
// wrapperOrigCommand — recover the original statusline command a wrapper file
|
|
1310
|
+
// recorded, so re-runs refresh the wrapper without wrapping it around itself.
|
|
1311
|
+
export function wrapperOrigCommand(content) {
|
|
1312
|
+
const line = content.split('\n').find((l) => l.startsWith("VITRINKA_ORIG='"));
|
|
1313
|
+
if (!line || !line.endsWith("'"))
|
|
1314
|
+
return '';
|
|
1315
|
+
return line.slice("VITRINKA_ORIG='".length, -1).replaceAll(`'\\''`, "'");
|
|
1316
|
+
}
|
|
1317
|
+
// promptYesNo — synchronous [Y/n] on the controlling TTY (Enter = yes). False on
|
|
1318
|
+
// any non-interactive stdin or when /dev/tty can't be opened (CI, curl|bash).
|
|
1319
|
+
function promptYesNo(question) {
|
|
1320
|
+
if (!process.stdin.isTTY)
|
|
1321
|
+
return false;
|
|
1322
|
+
process.stdout.write(question);
|
|
1323
|
+
try {
|
|
1324
|
+
const fd = openSync('/dev/tty', 'r');
|
|
1325
|
+
const buf = Buffer.alloc(64);
|
|
1326
|
+
const n = readSync(fd, buf, 0, 64, null);
|
|
1327
|
+
closeSync(fd);
|
|
1328
|
+
const ans = buf.toString('utf8', 0, n).trim().toLowerCase();
|
|
1329
|
+
return ans === '' || ans === 'y' || ans === 'yes';
|
|
1330
|
+
}
|
|
1331
|
+
catch {
|
|
1332
|
+
return false; // no controlling TTY — treat as non-interactive, never block
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
export function installClaudeCmd(_args) {
|
|
1336
|
+
const home = process.env.HOME;
|
|
1337
|
+
if (!home)
|
|
1338
|
+
fail('install-claude: $HOME is not set');
|
|
1339
|
+
const claudeDir = join(home, '.claude');
|
|
1340
|
+
if (!existsSync(claudeDir)) {
|
|
1341
|
+
console.log('install-claude: ~/.claude not found — Claude Code is not set up on this machine; skipping');
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
const settingsPath = join(claudeDir, 'settings.json');
|
|
1345
|
+
let settings = {};
|
|
1346
|
+
if (existsSync(settingsPath)) {
|
|
1347
|
+
try {
|
|
1348
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
1349
|
+
}
|
|
1350
|
+
catch (e) {
|
|
1351
|
+
fail(`install-claude: ${settingsPath} is not valid JSON — nothing was changed; fix it first (${e.message})`);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
let changed = false;
|
|
1355
|
+
// 1. Footer badge — clickable in every terminal (requires Claude Code ≥ 2.1.176).
|
|
1356
|
+
if (hasBoardFooterLink(settings)) {
|
|
1357
|
+
console.log('✓ footer badge already configured (footerLinksRegexes /boards/ entry)');
|
|
1358
|
+
}
|
|
1359
|
+
else {
|
|
1360
|
+
settings.footerLinksRegexes = [...(Array.isArray(settings.footerLinksRegexes) ? settings.footerLinksRegexes : []), BOARD_FOOTER_LINK];
|
|
1361
|
+
changed = true;
|
|
1362
|
+
console.log('✓ footer badge → footerLinksRegexes (clickable "🧷 board" whenever a board URL appears in conversation)');
|
|
1363
|
+
}
|
|
1364
|
+
// 2. Statusline wrapper.
|
|
1365
|
+
const wrapperPath = join(claudeDir, 'vitrinka-statusline.sh');
|
|
1366
|
+
const wrapperCmd = '~/.claude/vitrinka-statusline.sh';
|
|
1367
|
+
const sl = settings.statusLine;
|
|
1368
|
+
if (sl && sl.type !== undefined && sl.type !== 'command') {
|
|
1369
|
+
console.error(`⚠ statusLine.type is ${JSON.stringify(sl.type)} — don't know how to wrap it; statusline left untouched (footer badge still works)`);
|
|
1370
|
+
}
|
|
1371
|
+
else {
|
|
1372
|
+
const current = sl?.command ?? '';
|
|
1373
|
+
const isWrapper = current.replace(/^~(?=\/)/, home) === wrapperPath;
|
|
1374
|
+
// Re-runs recover the original from the existing wrapper file — never wrap the wrapper.
|
|
1375
|
+
const orig = isWrapper && existsSync(wrapperPath) ? wrapperOrigCommand(readFileSync(wrapperPath, 'utf8')) : current;
|
|
1376
|
+
writeFileSync(wrapperPath, buildStatuslineWrapper(orig));
|
|
1377
|
+
chmodSync(wrapperPath, 0o755);
|
|
1378
|
+
if (!isWrapper) {
|
|
1379
|
+
settings.statusLine = { type: 'command', command: wrapperCmd };
|
|
1380
|
+
changed = true;
|
|
1381
|
+
}
|
|
1382
|
+
if (isWrapper)
|
|
1383
|
+
console.log(`✓ statusline wrapper refreshed (${wrapperPath}${orig ? `, wraps: ${orig}` : ', minimal line'})`);
|
|
1384
|
+
else if (orig)
|
|
1385
|
+
console.log(`✓ statusline wrapped → ${wrapperPath} (appends 🧷 board to: ${orig})`);
|
|
1386
|
+
else
|
|
1387
|
+
console.log(`✓ statusline installed → ${wrapperPath} (minimal: dir │ branch │ 🧷 board │ model)`);
|
|
1388
|
+
}
|
|
1389
|
+
if (changed)
|
|
1390
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
1391
|
+
console.log(`install-claude done — restart Claude Code sessions to pick up ${settingsPath}`);
|
|
1392
|
+
}
|
|
961
1393
|
// ---------- snap: one-shot capture → manifest → detached push ----------
|
|
962
1394
|
// Filename slug: diacritics folded (Přihlášení → prihlaseni), non-alnum → '-', capped.
|
|
963
1395
|
export function kebab(s) {
|
|
@@ -1258,6 +1690,13 @@ async function snapCmd(surface, args) {
|
|
|
1258
1690
|
if (surface === 'ios' && iosSimName && !ctx.device?.device) {
|
|
1259
1691
|
ctx.device = { ...ctx.device, device: iosSimName };
|
|
1260
1692
|
}
|
|
1693
|
+
// Capture provenance: the mechanism that produced the pixels, the URL/deep
|
|
1694
|
+
// link the screen was opened at (--url wins over --open), and the app build.
|
|
1695
|
+
const tool = adopt ? 'adopted'
|
|
1696
|
+
: surface === 'ios' ? 'simctl'
|
|
1697
|
+
: surface === 'android' ? 'adb-screencap'
|
|
1698
|
+
: 'screencapture';
|
|
1699
|
+
const url = strArg(args.url) || openUrl;
|
|
1261
1700
|
const { count } = appendShot(root, {
|
|
1262
1701
|
file: `${day}/${name}`,
|
|
1263
1702
|
surface,
|
|
@@ -1268,6 +1707,9 @@ async function snapCmd(surface, args) {
|
|
|
1268
1707
|
action: strArg(args.action) || undefined,
|
|
1269
1708
|
hq: hq || undefined,
|
|
1270
1709
|
ts: new Date().toISOString(),
|
|
1710
|
+
tool,
|
|
1711
|
+
url: url || undefined,
|
|
1712
|
+
appVersion: appVersionOf(projectDir) || undefined,
|
|
1271
1713
|
...ctx,
|
|
1272
1714
|
});
|
|
1273
1715
|
maybeWarnOffline(root);
|
|
@@ -1629,10 +2071,28 @@ export const COMMANDS = [
|
|
|
1629
2071
|
examples: ['vitrinka operator', 'vitrinka operator "Lukáš"'],
|
|
1630
2072
|
},
|
|
1631
2073
|
{
|
|
1632
|
-
name: '
|
|
1633
|
-
usage: '[--
|
|
2074
|
+
name: 'doctor', summary: 'Check this machine\'s setup: server reachable, write token valid, operator set',
|
|
2075
|
+
usage: '[--base <url>]', flags: [BASE_FLAG],
|
|
2076
|
+
examples: ['vitrinka doctor'],
|
|
2077
|
+
},
|
|
2078
|
+
{
|
|
2079
|
+
name: 'install', summary: 'Install the PATH shim (+ optional shell completion + Claude Code UI wiring)',
|
|
2080
|
+
usage: '[--no-completion] [--claude|--no-claude]',
|
|
2081
|
+
flags: [{ f: '--no-completion', d: 'skip appending the shell-completion line' },
|
|
2082
|
+
{ f: '--claude', d: 'wire the Claude Code UI without asking' },
|
|
2083
|
+
{ f: '--no-claude', d: 'skip the Claude Code UI wiring (default when non-interactive)' }],
|
|
1634
2084
|
examples: ['vitrinka install'],
|
|
1635
2085
|
},
|
|
2086
|
+
{
|
|
2087
|
+
name: 'install-claude', summary: 'Wire the Claude Code UI: 🧷 board statusline segment + clickable footer badge',
|
|
2088
|
+
usage: '', flags: [],
|
|
2089
|
+
examples: ['vitrinka install-claude'],
|
|
2090
|
+
},
|
|
2091
|
+
{
|
|
2092
|
+
name: 'install-skills', summary: 'Install the bundled Claude skills (vitrinka, screenshot, artifact, brainstorming)',
|
|
2093
|
+
usage: '[--local]', flags: [{ f: '--local', d: 'install into ./.claude/skills of the current project instead of ~/.claude/skills' }],
|
|
2094
|
+
examples: ['vitrinka install-skills'],
|
|
2095
|
+
},
|
|
1636
2096
|
{
|
|
1637
2097
|
name: 'snap', summary: 'Capture a screenshot → manifest → detached push',
|
|
1638
2098
|
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]',
|
|
@@ -1642,6 +2102,7 @@ export const COMMANDS = [
|
|
|
1642
2102
|
{ f: '--udid', arg: '<u>', d: 'iOS simulator UDID' }, { f: '--region', arg: 'x,y,w,h', d: 'macOS capture region' },
|
|
1643
2103
|
{ f: '--open', arg: '<deeplink>', d: 'open a deep link, then settle before capturing' },
|
|
1644
2104
|
{ f: '--settle', arg: '<seconds>', d: 'wait after --open (default 4)' },
|
|
2105
|
+
{ f: '--url', arg: '<url>', d: 'exact URL the shot was taken at (provenance; defaults to --open)' },
|
|
1645
2106
|
{ f: '--hq', d: 'full-fidelity marketing asset: no lossy re-encode, no resize (lossless WebP only if smaller)' }],
|
|
1646
2107
|
examples: ['vitrinka snap ios --route /home --note "landing"', 'vitrinka snap web --route / --note "hero" --hq'],
|
|
1647
2108
|
},
|
|
@@ -1673,6 +2134,20 @@ export const COMMANDS = [
|
|
|
1673
2134
|
dynamic: 'projects',
|
|
1674
2135
|
examples: ['vitrinka index --root .screenshots'],
|
|
1675
2136
|
},
|
|
2137
|
+
{
|
|
2138
|
+
name: 'index-components', summary: 'Scan a UI library for exported components + push the component index',
|
|
2139
|
+
usage: '--library <kit> [--src <dirs>] [--project <p>] [--from <file.json>] [--dry] [--base <url>]',
|
|
2140
|
+
flags: [
|
|
2141
|
+
{ f: '--library', arg: '<kit>', d: 'UI kit name, one per library in a monorepo (e.g. "web", "expo") — required' },
|
|
2142
|
+
{ f: '--src', arg: '<dirs>', d: 'comma-separated dirs to scan (default "src/components,components,src")' },
|
|
2143
|
+
{ f: '--project', arg: '<p>', d: 'project slug (default: worktree name)' },
|
|
2144
|
+
{ f: '--from', arg: '<file>', d: 'push a prepared components JSON (the AI pre-index output) instead of scanning' },
|
|
2145
|
+
{ f: '--dry', d: 'print the scan as JSON, push nothing (feed this to the AI pre-index pass)' },
|
|
2146
|
+
BASE_FLAG
|
|
2147
|
+
],
|
|
2148
|
+
examples: ['vitrinka index-components --library web --dry > components.json',
|
|
2149
|
+
'vitrinka index-components --library web --from components.json'],
|
|
2150
|
+
},
|
|
1676
2151
|
{
|
|
1677
2152
|
name: 'embed-shots', summary: 'Embed manifest shots as resized JPEG data URIs → data.json',
|
|
1678
2153
|
usage: '[--root <dir>] [--select 1,3-5] [--size 720] [--quality 85] --out <dir>/data.json',
|
|
@@ -2180,7 +2655,7 @@ async function boardFromSetCmd(root, args) {
|
|
|
2180
2655
|
if (actor)
|
|
2181
2656
|
headers['X-Board-Actor'] = actor;
|
|
2182
2657
|
const create = await fetch(`${cfg.base}/api/v1/boards`, {
|
|
2183
|
-
method: 'POST', headers, body: JSON.stringify({ slug, title }),
|
|
2658
|
+
method: 'POST', headers, body: JSON.stringify({ slug, title, project: cfg.project }),
|
|
2184
2659
|
signal: AbortSignal.timeout(30_000),
|
|
2185
2660
|
});
|
|
2186
2661
|
if (!create.ok && create.status !== 409) { // 409 = board exists → reuse
|
|
@@ -2468,8 +2943,14 @@ async function runSub(argv) {
|
|
|
2468
2943
|
await nameCmd(argv, args);
|
|
2469
2944
|
else if (cmd === 'operator')
|
|
2470
2945
|
await operatorCmd(argv, args);
|
|
2946
|
+
else if (cmd === 'doctor')
|
|
2947
|
+
await doctorCmd(args);
|
|
2471
2948
|
else if (cmd === 'install')
|
|
2472
2949
|
installCmd(args);
|
|
2950
|
+
else if (cmd === 'install-claude')
|
|
2951
|
+
installClaudeCmd(args);
|
|
2952
|
+
else if (cmd === 'install-skills')
|
|
2953
|
+
installSkillsCmd(args);
|
|
2473
2954
|
else if (cmd === 'snap')
|
|
2474
2955
|
await snapCmd(rest[0] && !rest[0].startsWith('--') ? rest[0] : '', args);
|
|
2475
2956
|
else if (cmd === 'board-from-set')
|
|
@@ -2478,6 +2959,8 @@ async function runSub(argv) {
|
|
|
2478
2959
|
await watchCmd(args);
|
|
2479
2960
|
else if (cmd === 'index')
|
|
2480
2961
|
await indexCmd(root, args);
|
|
2962
|
+
else if (cmd === 'index-components')
|
|
2963
|
+
await indexComponentsCmd(root, args);
|
|
2481
2964
|
else if (cmd === 'embed-shots')
|
|
2482
2965
|
embedShotsCmd(root, args);
|
|
2483
2966
|
else if (cmd === 'artifact-init')
|