@kiroku-solutions/kiroku-ai 0.1.2

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 ADDED
@@ -0,0 +1,157 @@
1
+ # kiroku-ai CLI
2
+
3
+ Interactive CLI that bootstraps **Kiroku AI Standards** ("AI Context as Code")
4
+ into any project. It asks a few questions about your stack, downloads only the
5
+ relevant skill files from the central [`Kiroku-Solutions/kiroku-ai-standards`](https://github.com/Kiroku-Solutions/kiroku-ai-standards)
6
+ repository into a local `.ai-skills/` folder, and wires up the two-way sync
7
+ GitHub Action.
8
+
9
+ ## Usage
10
+
11
+ ```bash
12
+ # Pre-requisite: ensure you have configured your .npmrc with your GitHub token.
13
+ # (See .npmrc.example in the root directory for instructions).
14
+
15
+ # From the root of the project you want to set up:
16
+ pnpm dlx @kiroku-solutions/kiroku-ai init
17
+
18
+ # Re-sync skills later (picks up new files, removes stale ones):
19
+ pnpm dlx @kiroku-solutions/kiroku-ai update
20
+
21
+ # Switch which AI tool this project targets:
22
+ pnpm dlx @kiroku-solutions/kiroku-ai env # interactive picker
23
+ pnpm dlx @kiroku-solutions/kiroku-ai env cursor # non-interactive
24
+
25
+ # or, inside this monorepo for local development:
26
+ pnpm --dir CLI start init
27
+ ```
28
+
29
+ ### Commands
30
+
31
+ | Command | Description |
32
+ | ---------------- | --------------------------------------------------------------------------- |
33
+ | `init` | Guided stack builder; downloads skills and scaffolds `.ai-skills/`. |
34
+ | `update` | Re-resolves the recorded stack, re-downloads, and prunes removed skills. |
35
+ | `env [tool]` | Switches the AI tool and (re)generates its pointer file. Updates lockfile. |
36
+
37
+ ### AI environments
38
+
39
+ During `init` you pick the AI tool the project targets. The CLI generates the
40
+ matching "pointer" file so the tool auto-loads `.ai-skills/`:
41
+
42
+ | Tool | Generated file |
43
+ | ------------- | ---------------------------------- |
44
+ | Claude Code | `CLAUDE.md` |
45
+ | Cursor | `.cursor/rules/kiroku-ai.mdc` |
46
+ | GitHub Copilot| `.github/copilot-instructions.md` |
47
+ | OpenCode | `AGENTS.md` |
48
+ | Antigravity | `AGENTS.md` |
49
+ | Windsurf | `.windsurf/rules/kiroku-ai.md` |
50
+
51
+ Pointer files carry a `kiroku-ai:managed` marker — the CLI never overwrites or
52
+ deletes a hand-written file you already had. Switch tools anytime with
53
+ `kiroku-ai env`.
54
+
55
+ ### Options
56
+
57
+ | Option | Description |
58
+ | -------------- | ------------------------------------------------------------------ |
59
+ | `--dir <path>` | Target project directory (default: current directory). |
60
+ | `--ref <ref>` | Git ref of the SSOT to pull from (default: `main`). |
61
+ | `--force` | Re-run even if a lockfile exists (re-installs / overwrites skills). |
62
+ | `--dry-run` | Resolve the stack and list files without writing anything. |
63
+ | `-h, --help` | Show help. |
64
+ | `-v, --version`| Show version. |
65
+
66
+ ### Environment variables
67
+
68
+ | Variable | Purpose |
69
+ | ------------------ | ----------------------------------------------------------- |
70
+ | `KIROKU_AI_ORG` | Override the GitHub org (default: `Kiroku-Solutions`). |
71
+ | `KIROKU_AI_REPO` | Override the repo (default: `kiroku-ai-standards`). |
72
+ | `KIROKU_AI_REF` | Override the default git ref. |
73
+ | `KIROKU_AI_TOKEN` | Read token — **required if the SSOT repo is private**. |
74
+ | `KIROKU_AI_SOURCE` | Local path to an SSOT checkout — reads from disk instead of GitHub (for testing). |
75
+
76
+ ## What it generates
77
+
78
+ ```text
79
+ your-project/
80
+ ├── .ai-skills/
81
+ │ ├── global/ frontend/ backend/ infra-and-db/ # downloaded skills (do not edit)
82
+ │ ├── proposals/ # propose skills company-wide
83
+ │ ├── local/ # project-only AI rules (never synced)
84
+ │ ├── README.md
85
+ │ └── lockfile.json # prevents duplicate installs
86
+ └── .github/workflows/kiroku-ai-sync.yml # two-way sync action
87
+ ```
88
+
89
+ ## How the stack maps to skills
90
+
91
+ The compatibility matrix lives in [`src/resolver.mjs`](./src/resolver.mjs). In short:
92
+
93
+ - `global/` is **always** installed.
94
+ - A frontend scope pulls `frontend/shared/` plus the chosen framework folder.
95
+ Astro additionally pulls UI-integration folders (React → `frontend/react`).
96
+ - A backend scope pulls `backend/shared/` plus the chosen tech folder. Choosing
97
+ **None (Supabase BaaS)** pulls `infra-and-db/supabase.md` instead.
98
+ - Databases, deployment target and DevOps tools pull their matching `infra-and-db/`
99
+ / `backend/shared/` documents.
100
+
101
+ ## Architecture
102
+
103
+ ```text
104
+ bin/kiroku-ai.mjs # executable shim
105
+ src/
106
+ cli.mjs # arg parsing + init orchestration
107
+ config.mjs # org/repo/ref constants (+ env overrides)
108
+ prompts.mjs # @clack/prompts guided flow & compatibility constraints
109
+ resolver.mjs # stack -> skill selectors -> repo blob paths
110
+ source.mjs # dispatch: GitHub or local filesystem (KIROKU_AI_SOURCE)
111
+ github.mjs # tree listing + raw file download (public/private)
112
+ local-source.mjs # read skills/agents from a local SSOT checkout (testing)
113
+ scaffold.mjs # write .ai-skills/, governance folders, inject workflow, prune
114
+ integrations.mjs # AI environment pointer files (CLAUDE.md, AGENTS.md, ...)
115
+ lockfile.mjs # read/write/guard lockfile.json
116
+ templates/
117
+ kiroku-ai-sync.yml # workflow injected into consumer projects
118
+ test/ # node:test suite (run: pnpm test)
119
+ ```
120
+
121
+ ## Development
122
+
123
+ ```bash
124
+ pnpm install
125
+ pnpm test
126
+ node bin/kiroku-ai.mjs --help
127
+ ```
128
+
129
+ ## Testing from another repo (offline, no GitHub)
130
+
131
+ You can verify the CLI downloads the right files **without pushing** the SSOT to
132
+ GitHub by pointing it at this repo on disk with `KIROKU_AI_SOURCE`:
133
+
134
+ ```bash
135
+ # 1. Create a throwaway target project somewhere else
136
+ mkdir /tmp/demo-app && cd /tmp/demo-app
137
+
138
+ # 2. Run init against the local SSOT checkout (PowerShell shown below)
139
+ # bash:
140
+ KIROKU_AI_SOURCE=/path/to/kiroku-ai-standards \
141
+ node /path/to/kiroku-ai-standards/CLI/bin/kiroku-ai.mjs init
142
+
143
+ # 3. Inspect what came down
144
+ ls -R .ai-skills
145
+ cat .ai-skills/lockfile.json
146
+ ```
147
+
148
+ PowerShell:
149
+
150
+ ```powershell
151
+ $env:KIROKU_AI_SOURCE = "T:\Kiroku\kiroku-ai-standards"
152
+ node "T:\Kiroku\kiroku-ai-standards\CLI\bin\kiroku-ai.mjs" init
153
+ ```
154
+
155
+ When `KIROKU_AI_SOURCE` is set, the resolver reads from disk, so each folder's
156
+ `README.md` (and the `infra-and-db/*.md` topic files) is what gets installed —
157
+ confirming the stack → files mapping without any network access.
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/cli.mjs';
3
+
4
+ run(process.argv.slice(2)).catch((err) => {
5
+ // Defensive top-level guard. Normal control flow handles its own errors.
6
+ console.error(err?.stack || String(err));
7
+ process.exit(1);
8
+ });
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@kiroku-solutions/kiroku-ai",
3
+ "version": "0.1.2",
4
+ "description": "Interactive CLI to bootstrap Kiroku AI Standards (AI Context as Code) into any project.",
5
+ "type": "module",
6
+ "bin": {
7
+ "kiroku-ai": "./bin/kiroku-ai.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "templates"
13
+ ],
14
+ "scripts": {
15
+ "start": "node ./bin/kiroku-ai.mjs",
16
+ "init": "node ./bin/kiroku-ai.mjs init",
17
+ "test": "node --test"
18
+ },
19
+ "engines": {
20
+ "node": ">=18.17.0"
21
+ },
22
+ "keywords": [
23
+ "kiroku",
24
+ "ai",
25
+ "skills",
26
+ "prompts",
27
+ "cli",
28
+ "ai-context-as-code"
29
+ ],
30
+ "author": "Kiroku Solutions",
31
+ "license": "UNLICENSED",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/Kiroku-Solutions/kiroku-ai-standards.git",
35
+ "directory": "CLI"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "registry": "https://registry.npmjs.org/"
40
+ },
41
+ "dependencies": {
42
+ "@clack/prompts": "^0.7.0",
43
+ "picocolors": "^1.0.1"
44
+ }
45
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,420 @@
1
+ /**
2
+ * CLI entrypoint and command router for `kiroku-ai`.
3
+ *
4
+ * kiroku-ai init [--dir <path>] [--ref <git-ref>] [--force] [--dry-run]
5
+ * kiroku-ai update [--dir <path>] [--ref <git-ref>]
6
+ * kiroku-ai env [tool] [--dir <path>]
7
+ * kiroku-ai --help | --version
8
+ */
9
+
10
+ import { readFile } from 'node:fs/promises';
11
+ import { dirname, resolve, join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { spinner, log, intro, outro, select, isCancel, cancel } from '@clack/prompts';
14
+ import pc from 'picocolors';
15
+
16
+ import { REF, SKILLS_ROOT, LOCAL_DIR, ORG, REPO, TOKEN, setToken, saveTokenToNpmrc, SOURCE } from './config.mjs';
17
+ import { fetchTree, downloadFile, sourceLabel } from './source.mjs';
18
+ import { resolveSkillSelectors, expandSelectors, collectAgentBlobs } from './resolver.mjs';
19
+ import {
20
+ showIntro,
21
+ showOutro,
22
+ collectStack,
23
+ summarizeStack,
24
+ confirmInstall,
25
+ promptForToken,
26
+ } from './prompts.mjs';
27
+ import { lockfileExists, readLockfile, writeLockfile } from './lockfile.mjs';
28
+ import {
29
+ writeSkills,
30
+ scaffoldGovernanceFolders,
31
+ injectSyncWorkflow,
32
+ pruneStaleSkills,
33
+ } from './scaffold.mjs';
34
+ import {
35
+ ENVIRONMENTS,
36
+ isValidEnvironment,
37
+ environmentChoices,
38
+ writePointer,
39
+ removeManagedPointer,
40
+ } from './integrations.mjs';
41
+
42
+ const __dirname = dirname(fileURLToPath(import.meta.url));
43
+
44
+ function parseArgs(argv) {
45
+ const args = { _: [], ref: REF, dir: process.cwd(), force: false, dryRun: false };
46
+ for (let i = 0; i < argv.length; i++) {
47
+ const a = argv[i];
48
+ switch (a) {
49
+ case '-h':
50
+ case '--help':
51
+ args.help = true;
52
+ break;
53
+ case '-v':
54
+ case '--version':
55
+ args.version = true;
56
+ break;
57
+ case '--force':
58
+ args.force = true;
59
+ break;
60
+ case '--dry-run':
61
+ args.dryRun = true;
62
+ break;
63
+ case '--dir':
64
+ args.dir = resolve(argv[++i] ?? '.');
65
+ break;
66
+ case '--ref':
67
+ args.ref = argv[++i] ?? REF;
68
+ args.refExplicit = true;
69
+ break;
70
+ default:
71
+ if (a.startsWith('--dir=')) args.dir = resolve(a.slice(6));
72
+ else if (a.startsWith('--ref=')) {
73
+ args.ref = a.slice(6);
74
+ args.refExplicit = true;
75
+ } else args._.push(a);
76
+ }
77
+ }
78
+ return args;
79
+ }
80
+
81
+ async function readVersion() {
82
+ try {
83
+ const pkg = JSON.parse(await readFile(join(__dirname, '..', 'package.json'), 'utf8'));
84
+ return pkg.version ?? '0.0.0';
85
+ } catch {
86
+ return '0.0.0';
87
+ }
88
+ }
89
+
90
+ function printHelp() {
91
+ console.log(`
92
+ ${pc.bold('kiroku-ai')} — Bootstrap Kiroku AI Standards into a project
93
+
94
+ ${pc.bold('Usage')}
95
+ kiroku-ai init [options] Set up .ai-skills/ from a guided stack builder
96
+ kiroku-ai update [options] Re-sync skills for the recorded stack (prunes stale)
97
+ kiroku-ai env [tool] [options] Switch the AI tool / regenerate its pointer file
98
+
99
+ ${pc.bold('Environments')} (for ${pc.cyan('env')})
100
+ ${Object.entries(ENVIRONMENTS)
101
+ .map(([k, v]) => `${k} (${v.label})`)
102
+ .join(', ')}
103
+
104
+ ${pc.bold('Options')}
105
+ --dir <path> Target project directory (default: current directory)
106
+ --ref <ref> Git ref of the SSOT to pull from (default: ${REF})
107
+ --force Re-run even if a lockfile already exists (overwrites skills)
108
+ --dry-run Resolve the stack and list files without writing anything
109
+ -h, --help Show this help
110
+ -v, --version Show version
111
+
112
+ ${pc.bold('Source')}
113
+ ${ORG}/${REPO} (override with KIROKU_AI_ORG / KIROKU_AI_REPO / KIROKU_AI_REF)
114
+ Private repos: set KIROKU_AI_TOKEN with a read token.
115
+ `);
116
+ }
117
+
118
+ async function runInit(args) {
119
+ showIntro();
120
+
121
+ // Guard: refuse to clobber an existing install unless forced.
122
+ if (lockfileExists(args.dir) && !args.force && !args.dryRun) {
123
+ log.warn(
124
+ `A ${pc.cyan(`${LOCAL_DIR}/lockfile.json`)} already exists in this project.\n` +
125
+ ` Run with ${pc.cyan('--force')} to re-install, or delete it manually.`,
126
+ );
127
+ showOutro(pc.yellow('Nothing to do.'));
128
+ return 0;
129
+ }
130
+
131
+ // 1. Collect the stack interactively.
132
+ const stack = await collectStack();
133
+
134
+ // 1.5 Ask for token if missing (and not using local source)
135
+ if (!TOKEN && !SOURCE) {
136
+ const newToken = await promptForToken();
137
+ setToken(newToken);
138
+ saveTokenToNpmrc(newToken);
139
+ log.success('GitHub token saved securely to ~/.npmrc');
140
+ }
141
+
142
+ // 2. Resolve selectors and fetch the repository tree.
143
+ const selectors = resolveSkillSelectors(stack);
144
+
145
+ const s = spinner();
146
+ s.start(`Resolving skills from ${sourceLabel()} (${ORG}/${REPO}@${args.ref})`);
147
+ let blobPaths;
148
+ try {
149
+ const tree = await fetchTree(args.ref);
150
+ blobPaths = expandSelectors(selectors, tree, SKILLS_ROOT);
151
+ if (stack.agents) blobPaths = [...blobPaths, ...collectAgentBlobs(tree)];
152
+ s.stop(`Resolved ${blobPaths.length} file(s).`);
153
+ } catch (err) {
154
+ s.stop(pc.red('Failed to resolve skills.'));
155
+ log.error(err.message);
156
+ showOutro(pc.red('Aborted.'));
157
+ return 1;
158
+ }
159
+
160
+ if (blobPaths.length === 0) {
161
+ log.warn('No skill files matched your stack on the remote yet.');
162
+ }
163
+
164
+ summarizeStack(stack, blobPaths.length);
165
+
166
+ // Dry-run stops here.
167
+ if (args.dryRun) {
168
+ log.info('Dry run — files that would be installed:');
169
+ for (const p of blobPaths) console.log(' ' + pc.dim(p.replace(/^skills\//, '')));
170
+ showOutro(pc.cyan('Dry run complete. Nothing was written.'));
171
+ return 0;
172
+ }
173
+
174
+ if (!(await confirmInstall())) {
175
+ showOutro(pc.yellow('Cancelled. Nothing was written.'));
176
+ return 0;
177
+ }
178
+
179
+ // 3. Download the matched files.
180
+ const dl = spinner();
181
+ dl.start('Downloading skills');
182
+ const files = [];
183
+ try {
184
+ for (const path of blobPaths) {
185
+ const content = await downloadFile(path, args.ref);
186
+ files.push({ path, content });
187
+ }
188
+ dl.stop(`Downloaded ${files.length} file(s).`);
189
+ } catch (err) {
190
+ dl.stop(pc.red('Download failed.'));
191
+ log.error(err.message);
192
+ showOutro(pc.red('Aborted — no partial install was committed to the lockfile.'));
193
+ return 1;
194
+ }
195
+
196
+ // 4. Scaffold the local structure and inject the workflow.
197
+ const write = spinner();
198
+ write.start('Writing .ai-skills/ and injecting sync workflow');
199
+ try {
200
+ await writeSkills(args.dir, files);
201
+ await scaffoldGovernanceFolders(args.dir);
202
+ const wf = await injectSyncWorkflow(args.dir);
203
+ if (stack.environment) {
204
+ const pointer = await writePointer(args.dir, stack.environment);
205
+ if (pointer.skipped) {
206
+ log.warn(
207
+ `Kept your existing ${pc.cyan(pointer.file)} (not generated by kiroku-ai). ` +
208
+ `Add a reference to ${LOCAL_DIR}/ manually.`,
209
+ );
210
+ }
211
+ }
212
+ await writeLockfile(args.dir, { ref: args.ref, stack, files: blobPaths });
213
+ write.stop('Project scaffolded.');
214
+ if (wf.existed) {
215
+ log.warn(`Overwrote existing ${wf.path}.`);
216
+ }
217
+ } catch (err) {
218
+ write.stop(pc.red('Scaffolding failed.'));
219
+ log.error(err.message);
220
+ showOutro(pc.red('Aborted.'));
221
+ return 1;
222
+ }
223
+
224
+ // 5. Next steps.
225
+ log.success('Kiroku AI context installed.');
226
+ log.message(
227
+ [
228
+ `${pc.bold('Next steps')}`,
229
+ ` 1. Reference ${pc.cyan(LOCAL_DIR + '/')} from your AI tool (Cursor / Copilot / Claude).`,
230
+ ` 2. Propose skills by adding files to ${pc.cyan(LOCAL_DIR + '/proposals/')} and pushing to main.`,
231
+ ` 3. Add the ${pc.cyan('KIROKU_AI_SYNC_TOKEN')} org secret so the sync workflow can open PRs.`,
232
+ ].join('\n'),
233
+ );
234
+ showOutro(pc.green('Done.'));
235
+ return 0;
236
+ }
237
+
238
+ async function runUpdate(args) {
239
+ intro(pc.bgMagenta(pc.black(' kiroku-ai update ')));
240
+
241
+ const lock = await readLockfile(args.dir);
242
+ if (!lock) {
243
+ log.warn(`No ${pc.cyan(`${LOCAL_DIR}/lockfile.json`)} found. Run ${pc.cyan('kiroku-ai init')} first.`);
244
+ outro(pc.yellow('Nothing to do.'));
245
+ return 0;
246
+ }
247
+
248
+ const stack = lock.stack;
249
+ const ref = args.refExplicit ? args.ref : lock.source?.ref ?? args.ref;
250
+ const selectors = resolveSkillSelectors(stack);
251
+
252
+ if (!TOKEN && !SOURCE) {
253
+ const newToken = await promptForToken();
254
+ setToken(newToken);
255
+ saveTokenToNpmrc(newToken);
256
+ log.success('GitHub token saved securely to ~/.npmrc');
257
+ }
258
+
259
+ const s = spinner();
260
+ s.start(`Re-resolving skills from ${sourceLabel()} (${ORG}/${REPO}@${ref})`);
261
+ let blobPaths;
262
+ try {
263
+ const tree = await fetchTree(ref);
264
+ blobPaths = expandSelectors(selectors, tree, SKILLS_ROOT);
265
+ if (stack.agents) blobPaths = [...blobPaths, ...collectAgentBlobs(tree)];
266
+ s.stop(`Resolved ${blobPaths.length} file(s).`);
267
+ } catch (err) {
268
+ s.stop(pc.red('Failed to resolve skills.'));
269
+ log.error(err.message);
270
+ outro(pc.red('Aborted — existing install untouched.'));
271
+ return 1;
272
+ }
273
+
274
+ // Download the fresh set.
275
+ const dl = spinner();
276
+ dl.start('Downloading latest skills');
277
+ const files = [];
278
+ try {
279
+ for (const path of blobPaths) {
280
+ files.push({ path, content: await downloadFile(path, ref) });
281
+ }
282
+ dl.stop(`Downloaded ${files.length} file(s).`);
283
+ } catch (err) {
284
+ dl.stop(pc.red('Download failed.'));
285
+ log.error(err.message);
286
+ outro(pc.red('Aborted — existing install untouched.'));
287
+ return 1;
288
+ }
289
+
290
+ // Compute and prune files that left the resolved set.
291
+ const newLocal = new Set(blobPaths.map((p) => p.replace(/^skills\//, '')));
292
+ const stale = (lock.files ?? []).filter((p) => !newLocal.has(p));
293
+
294
+ const w = spinner();
295
+ w.start('Applying update');
296
+ try {
297
+ await writeSkills(args.dir, files);
298
+ const removed = await pruneStaleSkills(args.dir, stale);
299
+ // Refresh the AI pointer file for the recorded environment.
300
+ if (stack.environment && isValidEnvironment(stack.environment)) {
301
+ await writePointer(args.dir, stack.environment);
302
+ }
303
+ await writeLockfile(args.dir, { ref, stack, files: blobPaths });
304
+ w.stop(`Updated ${files.length} file(s)${removed ? `, removed ${removed} stale` : ''}.`);
305
+ } catch (err) {
306
+ w.stop(pc.red('Update failed.'));
307
+ log.error(err.message);
308
+ outro(pc.red('Aborted.'));
309
+ return 1;
310
+ }
311
+
312
+ log.success('Skills are up to date.');
313
+ outro(pc.green('Done.'));
314
+ return 0;
315
+ }
316
+
317
+ async function runEnv(args) {
318
+ intro(pc.bgMagenta(pc.black(' kiroku-ai env ')));
319
+
320
+ const lock = await readLockfile(args.dir);
321
+ if (!lock) {
322
+ log.warn(`No ${pc.cyan(`${LOCAL_DIR}/lockfile.json`)} found. Run ${pc.cyan('kiroku-ai init')} first.`);
323
+ outro(pc.yellow('Nothing to do.'));
324
+ return 0;
325
+ }
326
+
327
+ const current = lock.stack?.environment ?? null;
328
+
329
+ // Allow a non-interactive form: `kiroku-ai env <tool>`.
330
+ let target = args._[1];
331
+ if (target && !isValidEnvironment(target)) {
332
+ log.error(`Unknown environment "${target}". Valid: ${Object.keys(ENVIRONMENTS).join(', ')}`);
333
+ outro(pc.red('Aborted.'));
334
+ return 1;
335
+ }
336
+
337
+ if (!target) {
338
+ const picked = await select({
339
+ message: 'Select the AI tool for this project',
340
+ initialValue: current ?? undefined,
341
+ options: environmentChoices().map((c) => ({
342
+ ...c,
343
+ hint: c.value === current ? 'current' : undefined,
344
+ })),
345
+ });
346
+ if (isCancel(picked)) {
347
+ cancel('No changes made.');
348
+ return 0;
349
+ }
350
+ target = picked;
351
+ }
352
+
353
+ if (target === current) {
354
+ // Still (re)generate in case the pointer file was deleted.
355
+ await writePointer(args.dir, target);
356
+ log.info(`Already using ${pc.cyan(ENVIRONMENTS[target].label)}. Pointer file refreshed.`);
357
+ outro(pc.green('Done.'));
358
+ return 0;
359
+ }
360
+
361
+ // Swap: remove the previous managed pointer, write the new one.
362
+ if (current && isValidEnvironment(current)) {
363
+ const removed = await removeManagedPointer(args.dir, current);
364
+ if (removed) log.message(`Removed ${pc.dim(ENVIRONMENTS[current].file)}`);
365
+ }
366
+ const pointer = await writePointer(args.dir, target);
367
+ if (pointer.skipped) {
368
+ log.warn(`Kept your existing ${pc.cyan(pointer.file)} (not generated by kiroku-ai).`);
369
+ } else {
370
+ log.success(`Switched AI tool to ${pc.cyan(ENVIRONMENTS[target].label)} → ${pointer.file}`);
371
+ }
372
+
373
+ // Persist the new environment in the lockfile.
374
+ const stack = { ...lock.stack, environment: target };
375
+ await writeLockfile(args.dir, {
376
+ ref: lock.source?.ref ?? REF,
377
+ stack,
378
+ files: (lock.files ?? []).map((p) => `skills/${p}`),
379
+ });
380
+
381
+ outro(pc.green('Done.'));
382
+ return 0;
383
+ }
384
+
385
+ export async function run(argv) {
386
+ const args = parseArgs(argv);
387
+
388
+ if (args.version) {
389
+ console.log(await readVersion());
390
+ return 0;
391
+ }
392
+ if (args.help) {
393
+ printHelp();
394
+ return 0;
395
+ }
396
+
397
+ const command = args._[0] ?? 'init';
398
+ switch (command) {
399
+ case 'init': {
400
+ const code = await runInit(args);
401
+ process.exitCode = code;
402
+ return code;
403
+ }
404
+ case 'update': {
405
+ const code = await runUpdate(args);
406
+ process.exitCode = code;
407
+ return code;
408
+ }
409
+ case 'env': {
410
+ const code = await runEnv(args);
411
+ process.exitCode = code;
412
+ return code;
413
+ }
414
+ default:
415
+ console.error(pc.red(`Unknown command: ${command}`));
416
+ printHelp();
417
+ process.exitCode = 1;
418
+ return 1;
419
+ }
420
+ }