@draig/lexis-two 1.0.9 → 1.1.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.
@@ -0,0 +1,1041 @@
1
+ #!/usr/bin/env node
2
+ // lexis-two — interactive installer (phase A1–A4: rules, OpenCode, uninstall, hints)
3
+
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const readline = require('readline');
8
+ const { spawnSync } = require('child_process');
9
+
10
+ const PACKAGE_ROOT = path.resolve(__dirname, '..');
11
+ const OPENCODE_PLUGIN_ENTRY = '@draig/lexis-two';
12
+ const REPO_URL = 'https://github.com/nitdraig/lexis-two';
13
+
14
+ /** @type {Record<string, RuleHost>} */
15
+ const RULE_HOSTS = {
16
+ cursor: {
17
+ id: 'cursor',
18
+ label: 'Cursor',
19
+ src: '.cursor/rules/lexis-two.mdc',
20
+ projectDest: '.cursor/rules/lexis-two.mdc',
21
+ globalDest: (home) => path.join(home, '.cursor', 'rules', 'lexis-two.mdc'),
22
+ supportsGlobal: true,
23
+ detect(ctx) {
24
+ return (
25
+ dirExists(path.join(ctx.projectDir, '.cursor')) ||
26
+ dirExists(path.join(ctx.home, '.cursor')) ||
27
+ commandExists('cursor')
28
+ );
29
+ },
30
+ },
31
+ windsurf: {
32
+ id: 'windsurf',
33
+ label: 'Windsurf',
34
+ src: '.windsurf/rules/lexis-two.md',
35
+ projectDest: '.windsurf/rules/lexis-two.md',
36
+ supportsGlobal: false,
37
+ detect(ctx) {
38
+ return dirExists(path.join(ctx.projectDir, '.windsurf'));
39
+ },
40
+ },
41
+ cline: {
42
+ id: 'cline',
43
+ label: 'Cline',
44
+ src: '.clinerules/lexis-two.md',
45
+ projectDest: '.clinerules/lexis-two.md',
46
+ supportsGlobal: false,
47
+ detect(ctx) {
48
+ return dirExists(path.join(ctx.projectDir, '.clinerules'));
49
+ },
50
+ },
51
+ kiro: {
52
+ id: 'kiro',
53
+ label: 'Kiro',
54
+ src: '.kiro/steering/lexis-two.md',
55
+ projectDest: '.kiro/steering/lexis-two.md',
56
+ supportsGlobal: false,
57
+ detect(ctx) {
58
+ return dirExists(path.join(ctx.projectDir, '.kiro'));
59
+ },
60
+ },
61
+ agents: {
62
+ id: 'agents',
63
+ label: 'AGENTS.md (generic agents)',
64
+ src: 'AGENTS.md',
65
+ projectDest: 'AGENTS.md',
66
+ supportsGlobal: false,
67
+ neverOverwriteWithoutForce: true,
68
+ detect() {
69
+ return true;
70
+ },
71
+ },
72
+ 'copilot-repo': {
73
+ id: 'copilot-repo',
74
+ label: 'GitHub Copilot (repo instructions)',
75
+ src: '.github/copilot-instructions.md',
76
+ projectDest: '.github/copilot-instructions.md',
77
+ supportsGlobal: false,
78
+ neverOverwriteWithoutForce: true,
79
+ detect(ctx) {
80
+ return dirExists(path.join(ctx.projectDir, '.github'));
81
+ },
82
+ },
83
+ };
84
+
85
+ /** @type {Record<string, PluginHost>} */
86
+ const PLUGIN_HOSTS = {
87
+ opencode: {
88
+ id: 'opencode',
89
+ label: 'OpenCode',
90
+ supportsGlobal: true,
91
+ detect(ctx) {
92
+ return (
93
+ fileExists(path.join(ctx.projectDir, 'opencode.json')) ||
94
+ dirExists(getOpencodeConfigDir(ctx.home)) ||
95
+ commandExists('opencode')
96
+ );
97
+ },
98
+ },
99
+ };
100
+
101
+ /** @type {Record<string, HintHost>} */
102
+ const HINT_HOSTS = {
103
+ claude: {
104
+ id: 'claude',
105
+ label: 'Claude Code (plugin)',
106
+ detect(ctx) {
107
+ return (
108
+ dirExists(path.join(ctx.home, '.claude')) ||
109
+ typeof process.env.CLAUDE_CONFIG_DIR === 'string' ||
110
+ commandExists('claude')
111
+ );
112
+ },
113
+ getHints() {
114
+ return [
115
+ 'Claude Code installs via marketplace or a local plugin folder.',
116
+ `Package path: ${displayPath(PACKAGE_ROOT)}`,
117
+ `Clone: git clone ${REPO_URL}.git ~/lexis-two`,
118
+ 'Point Claude at .claude-plugin/plugin.json (hooks, commands, skills).',
119
+ 'After install: restart Claude Code and run /lexis status.',
120
+ `Docs: ${REPO_URL}/blob/main/docs/setup.md#claude-code`,
121
+ ];
122
+ },
123
+ },
124
+ copilot: {
125
+ id: 'copilot',
126
+ label: 'GitHub Copilot (IDE extension)',
127
+ detect(ctx) {
128
+ return (
129
+ dirExists(path.join(ctx.projectDir, '.github')) ||
130
+ commandExists('code')
131
+ );
132
+ },
133
+ getHints() {
134
+ return [
135
+ 'Copilot IDE extensions publish through the GitHub Copilot Extension program.',
136
+ `Manifest: ${displayPath(path.join(PACKAGE_ROOT, '.github/plugin/plugin.json'))}`,
137
+ 'Repo-level instructions (automated): --host copilot-repo',
138
+ `Docs: ${REPO_URL}/blob/main/docs/setup.md#github-copilot`,
139
+ ];
140
+ },
141
+ },
142
+ gemini: {
143
+ id: 'gemini',
144
+ label: 'Gemini CLI (extension)',
145
+ detect(ctx) {
146
+ return (
147
+ fileExists(path.join(ctx.projectDir, 'gemini-extension.json')) ||
148
+ commandExists('gemini')
149
+ );
150
+ },
151
+ getHints() {
152
+ return [
153
+ 'Gemini CLI extensions install from a directory with gemini-extension.json.',
154
+ `Run: cd "${PACKAGE_ROOT}" && gemini extensions install .`,
155
+ `Clone: git clone ${REPO_URL}.git ~/lexis-two && cd ~/lexis-two`,
156
+ 'Loads AGENTS.md, commands/, and skills/ from the extension manifest.',
157
+ `Docs: ${REPO_URL}/blob/main/docs/setup.md#gemini-cli`,
158
+ ];
159
+ },
160
+ },
161
+ pi: {
162
+ id: 'pi',
163
+ label: 'pi (extension)',
164
+ detect() {
165
+ return commandExists('pi');
166
+ },
167
+ getHints() {
168
+ return [
169
+ 'pi reads extensions from package.json "pi" field.',
170
+ 'Install: npm install -g @draig/lexis-two',
171
+ 'Or from clone: npm install -g . at the repo root',
172
+ 'After install: run /lexis status and /specxis status in pi.',
173
+ `Docs: ${REPO_URL}/blob/main/docs/setup.md#pi`,
174
+ ];
175
+ },
176
+ },
177
+ };
178
+
179
+ const ALL_HOST_IDS = [
180
+ ...Object.keys(RULE_HOSTS),
181
+ ...Object.keys(PLUGIN_HOSTS),
182
+ ...Object.keys(HINT_HOSTS),
183
+ ];
184
+
185
+ /**
186
+ * @typedef {object} RuleHost
187
+ * @property {string} id
188
+ * @property {string} label
189
+ * @property {string} src
190
+ * @property {string} projectDest
191
+ * @property {(home: string) => string} [globalDest]
192
+ * @property {boolean} supportsGlobal
193
+ * @property {boolean} [neverOverwriteWithoutForce]
194
+ * @property {(ctx: InstallContext) => boolean} detect
195
+ */
196
+
197
+ /**
198
+ * @typedef {object} InstallContext
199
+ * @property {string} projectDir
200
+ * @property {string} home
201
+ * @property {string} packageRoot
202
+ */
203
+
204
+ /**
205
+ * @typedef {object} InstallOptions
206
+ * @property {string[]} hosts
207
+ * @property {'project' | 'global' | 'both'} scope
208
+ * @property {boolean} yes
209
+ * @property {boolean} dryRun
210
+ * @property {boolean} force
211
+ * @property {boolean} uninstall
212
+ * @property {boolean} help
213
+ * @property {boolean} interactive
214
+ * @property {string} projectDir
215
+ */
216
+
217
+ /**
218
+ * @typedef {object} PluginHost
219
+ * @property {string} id
220
+ * @property {string} label
221
+ * @property {boolean} supportsGlobal
222
+ * @property {(ctx: InstallContext) => boolean} detect
223
+ */
224
+
225
+ /**
226
+ * @typedef {object} InstallAction
227
+ * @property {'copy' | 'merge' | 'remove' | 'hint' | 'skip'} type
228
+ * @property {string} host
229
+ * @property {'project' | 'global'} scope
230
+ * @property {string} to
231
+ * @property {string} [from]
232
+ * @property {string} [content]
233
+ * @property {string[]} [lines]
234
+ * @property {string} [reason]
235
+ * @property {string} [backup]
236
+ */
237
+
238
+ /**
239
+ * @typedef {object} HintHost
240
+ * @property {string} id
241
+ * @property {string} label
242
+ * @property {(ctx: InstallContext) => boolean} detect
243
+ * @property {(ctx: InstallContext) => string[]} getHints
244
+ */
245
+
246
+ function fileExists(target) {
247
+ try {
248
+ return fs.statSync(target).isFile();
249
+ } catch {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ function getOpencodeConfigDir(home) {
255
+ if (process.platform === 'win32') {
256
+ const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
257
+ return path.join(appData, 'opencode');
258
+ }
259
+ const xdg = process.env.XDG_CONFIG_HOME;
260
+ if (xdg) {
261
+ return path.join(xdg, 'opencode');
262
+ }
263
+ return path.join(home, '.config', 'opencode');
264
+ }
265
+
266
+ function isLexisPluginEntry(entry) {
267
+ if (typeof entry !== 'string') {
268
+ return false;
269
+ }
270
+ return (
271
+ entry === OPENCODE_PLUGIN_ENTRY ||
272
+ entry.includes('lexis-two') && entry.includes('plugin')
273
+ );
274
+ }
275
+
276
+ function listOpencodeCommandFiles() {
277
+ const dir = path.join(PACKAGE_ROOT, '.opencode', 'commands');
278
+ return fs
279
+ .readdirSync(dir)
280
+ .filter(
281
+ (name) =>
282
+ name.endsWith('.md') &&
283
+ (name.startsWith('lexis') || name.startsWith('specxis')),
284
+ )
285
+ .map((name) => ({
286
+ name,
287
+ src: path.join(dir, name),
288
+ }));
289
+ }
290
+
291
+ function readJsonConfig(configPath) {
292
+ if (!fs.existsSync(configPath)) {
293
+ return {};
294
+ }
295
+ const raw = fs.readFileSync(configPath, 'utf8');
296
+ try {
297
+ return JSON.parse(raw);
298
+ } catch {
299
+ throw new Error(
300
+ `Invalid JSON in ${configPath}. Fix it manually before running the installer.`,
301
+ );
302
+ }
303
+ }
304
+
305
+ function dirExists(target) {
306
+ try {
307
+ return fs.statSync(target).isDirectory();
308
+ } catch {
309
+ return false;
310
+ }
311
+ }
312
+
313
+ function commandExists(cmd) {
314
+ try {
315
+ const checker = process.platform === 'win32' ? 'where' : 'which';
316
+ const result = spawnSync(checker, [cmd], { stdio: 'ignore' });
317
+ return result.status === 0;
318
+ } catch {
319
+ return false;
320
+ }
321
+ }
322
+
323
+ function backupPath(targetPath) {
324
+ return `${targetPath}.bak`;
325
+ }
326
+
327
+ function displayPath(absPath) {
328
+ const home = os.homedir();
329
+ if (absPath.startsWith(home)) {
330
+ return `~${absPath.slice(home.length).replace(/\\/g, '/')}`;
331
+ }
332
+ return absPath.replace(/\\/g, '/');
333
+ }
334
+
335
+ function isHintHost(hostId) {
336
+ return Object.prototype.hasOwnProperty.call(HINT_HOSTS, hostId);
337
+ }
338
+
339
+ function readPackageFile(relPath) {
340
+ const abs = path.join(PACKAGE_ROOT, relPath);
341
+ if (!fs.existsSync(abs)) {
342
+ throw new Error(`Missing package file: ${relPath}`);
343
+ }
344
+ return fs.readFileSync(abs, 'utf8');
345
+ }
346
+
347
+ function parseArgs(argv) {
348
+ /** @type {InstallOptions} */
349
+ const options = {
350
+ hosts: [],
351
+ scope: 'project',
352
+ yes: false,
353
+ dryRun: false,
354
+ force: false,
355
+ uninstall: false,
356
+ help: false,
357
+ interactive: process.stdin.isTTY === true,
358
+ projectDir: process.cwd(),
359
+ };
360
+
361
+ for (let i = 0; i < argv.length; i += 1) {
362
+ const arg = argv[i];
363
+
364
+ if (arg === 'install') {
365
+ continue;
366
+ }
367
+ if (arg === '--help' || arg === '-h') {
368
+ options.help = true;
369
+ continue;
370
+ }
371
+ if (arg === '--yes' || arg === '-y') {
372
+ options.yes = true;
373
+ continue;
374
+ }
375
+ if (arg === '--dry-run') {
376
+ options.dryRun = true;
377
+ continue;
378
+ }
379
+ if (arg === '--force') {
380
+ options.force = true;
381
+ continue;
382
+ }
383
+ if (arg === '--uninstall') {
384
+ options.uninstall = true;
385
+ continue;
386
+ }
387
+ if (arg === '--non-interactive') {
388
+ options.interactive = false;
389
+ continue;
390
+ }
391
+ if (arg.startsWith('--host=')) {
392
+ options.hosts.push(...splitList(arg.slice('--host='.length)));
393
+ continue;
394
+ }
395
+ if (arg === '--host') {
396
+ const next = argv[i + 1];
397
+ if (!next) throw new Error('Missing value for --host');
398
+ options.hosts.push(...splitList(next));
399
+ i += 1;
400
+ continue;
401
+ }
402
+ if (arg.startsWith('--scope=')) {
403
+ options.scope = parseScope(arg.slice('--scope='.length));
404
+ continue;
405
+ }
406
+ if (arg === '--scope') {
407
+ const next = argv[i + 1];
408
+ if (!next) throw new Error('Missing value for --scope');
409
+ options.scope = parseScope(next);
410
+ i += 1;
411
+ continue;
412
+ }
413
+ if (arg.startsWith('--project-dir=')) {
414
+ options.projectDir = path.resolve(arg.slice('--project-dir='.length));
415
+ continue;
416
+ }
417
+ if (arg === '--project-dir') {
418
+ const next = argv[i + 1];
419
+ if (!next) throw new Error('Missing value for --project-dir');
420
+ options.projectDir = path.resolve(next);
421
+ i += 1;
422
+ continue;
423
+ }
424
+
425
+ throw new Error(`Unknown argument: ${arg}`);
426
+ }
427
+
428
+ options.hosts = [...new Set(options.hosts.map((host) => host.trim()).filter(Boolean))];
429
+ return options;
430
+ }
431
+
432
+ function splitList(value) {
433
+ return value.split(',').map((item) => item.trim()).filter(Boolean);
434
+ }
435
+
436
+ function parseScope(value) {
437
+ if (value === 'project' || value === 'global' || value === 'both') {
438
+ return value;
439
+ }
440
+ throw new Error(`Invalid scope: ${value}. Use project, global, or both.`);
441
+ }
442
+
443
+ function createContext(projectDir) {
444
+ return {
445
+ projectDir: path.resolve(projectDir),
446
+ home: os.homedir(),
447
+ packageRoot: PACKAGE_ROOT,
448
+ };
449
+ }
450
+
451
+ function detectHosts(ctx) {
452
+ const copyHosts = Object.values(RULE_HOSTS)
453
+ .filter((host) => host.detect(ctx))
454
+ .map((host) => host.id);
455
+ const pluginHosts = Object.values(PLUGIN_HOSTS)
456
+ .filter((host) => host.detect(ctx))
457
+ .map((host) => host.id);
458
+ const hintHosts = Object.values(HINT_HOSTS)
459
+ .filter((host) => host.detect(ctx))
460
+ .map((host) => host.id);
461
+ return [...new Set([...copyHosts, ...pluginHosts, ...hintHosts])];
462
+ }
463
+
464
+ function validateHostIds(hostIds) {
465
+ for (const hostId of hostIds) {
466
+ if (!ALL_HOST_IDS.includes(hostId)) {
467
+ throw new Error(`Unknown host: ${hostId}. Valid: ${ALL_HOST_IDS.join(', ')}`);
468
+ }
469
+ }
470
+ }
471
+
472
+ function resolveScopes(scope, host) {
473
+ if (scope === 'both') {
474
+ return host.supportsGlobal ? ['project', 'global'] : ['project'];
475
+ }
476
+ if (scope === 'global' && !host.supportsGlobal) {
477
+ return [];
478
+ }
479
+ return [scope];
480
+ }
481
+
482
+ function planFileCopy(hostId, installScope, from, to, options, neverOverwriteWithoutForce) {
483
+ const incoming = fs.readFileSync(from, 'utf8');
484
+
485
+ /** @type {InstallAction} */
486
+ const action = {
487
+ type: 'copy',
488
+ host: hostId,
489
+ scope: installScope,
490
+ from,
491
+ to,
492
+ };
493
+
494
+ if (!fs.existsSync(to)) {
495
+ return action;
496
+ }
497
+
498
+ const existing = fs.readFileSync(to, 'utf8');
499
+ if (existing === incoming) {
500
+ action.type = 'skip';
501
+ action.reason = 'identical';
502
+ return action;
503
+ }
504
+
505
+ if (neverOverwriteWithoutForce && !options.force) {
506
+ action.type = 'skip';
507
+ action.reason = 'exists';
508
+ return action;
509
+ }
510
+
511
+ if (!options.force) {
512
+ action.type = 'skip';
513
+ action.reason = 'exists';
514
+ return action;
515
+ }
516
+
517
+ action.backup = backupPath(to);
518
+ return action;
519
+ }
520
+
521
+ function planFileRemove(hostId, installScope, targetPath, packageSrcAbs) {
522
+ /** @type {InstallAction} */
523
+ const action = {
524
+ type: 'remove',
525
+ host: hostId,
526
+ scope: installScope,
527
+ to: targetPath,
528
+ };
529
+
530
+ if (!fs.existsSync(targetPath)) {
531
+ action.type = 'skip';
532
+ action.reason = 'missing';
533
+ return action;
534
+ }
535
+
536
+ const installed = fs.readFileSync(targetPath, 'utf8');
537
+ const expected = fs.readFileSync(packageSrcAbs, 'utf8');
538
+ if (installed !== expected) {
539
+ action.type = 'skip';
540
+ action.reason = 'modified';
541
+ return action;
542
+ }
543
+
544
+ action.backup = backupPath(targetPath);
545
+ return action;
546
+ }
547
+
548
+ function planCopyAction(host, installScope, destPath, options) {
549
+ const from = path.join(PACKAGE_ROOT, host.src);
550
+ return planFileCopy(
551
+ host.id,
552
+ installScope,
553
+ from,
554
+ destPath,
555
+ options,
556
+ host.neverOverwriteWithoutForce === true,
557
+ );
558
+ }
559
+
560
+ function planOpencodeConfigMerge(configPath, installScope, options) {
561
+ const config = readJsonConfig(configPath);
562
+ const plugins = Array.isArray(config.plugin) ? [...config.plugin] : [];
563
+
564
+ /** @type {InstallAction} */
565
+ const action = {
566
+ type: 'merge',
567
+ host: 'opencode',
568
+ scope: installScope,
569
+ to: configPath,
570
+ content: '',
571
+ };
572
+
573
+ if (plugins.some(isLexisPluginEntry)) {
574
+ action.type = 'skip';
575
+ action.reason = 'already-configured';
576
+ return action;
577
+ }
578
+
579
+ plugins.push(OPENCODE_PLUGIN_ENTRY);
580
+ const nextConfig = { ...config, plugin: plugins };
581
+ action.content = `${JSON.stringify(nextConfig, null, 2)}\n`;
582
+
583
+ if (fs.existsSync(configPath) && fs.readFileSync(configPath, 'utf8') === action.content) {
584
+ action.type = 'skip';
585
+ action.reason = 'identical';
586
+ return action;
587
+ }
588
+
589
+ if (fs.existsSync(configPath)) {
590
+ action.backup = backupPath(configPath);
591
+ }
592
+
593
+ return action;
594
+ }
595
+
596
+ function planOpencodeConfigUninstall(configPath, installScope) {
597
+ if (!fs.existsSync(configPath)) {
598
+ return {
599
+ type: 'skip',
600
+ host: 'opencode',
601
+ scope: installScope,
602
+ to: configPath,
603
+ reason: 'missing',
604
+ };
605
+ }
606
+
607
+ const config = readJsonConfig(configPath);
608
+ const plugins = Array.isArray(config.plugin) ? [...config.plugin] : [];
609
+ const filtered = plugins.filter((entry) => !isLexisPluginEntry(entry));
610
+
611
+ /** @type {InstallAction} */
612
+ const action = {
613
+ type: 'merge',
614
+ host: 'opencode',
615
+ scope: installScope,
616
+ to: configPath,
617
+ content: '',
618
+ };
619
+
620
+ if (filtered.length === plugins.length) {
621
+ action.type = 'skip';
622
+ action.reason = 'not-configured';
623
+ return action;
624
+ }
625
+
626
+ const nextConfig = { ...config };
627
+ if (filtered.length > 0) {
628
+ nextConfig.plugin = filtered;
629
+ } else {
630
+ delete nextConfig.plugin;
631
+ }
632
+
633
+ action.content = `${JSON.stringify(nextConfig, null, 2)}\n`;
634
+
635
+ if (fs.readFileSync(configPath, 'utf8') === action.content) {
636
+ action.type = 'skip';
637
+ action.reason = 'identical';
638
+ return action;
639
+ }
640
+
641
+ action.backup = backupPath(configPath);
642
+ return action;
643
+ }
644
+
645
+ function planOpencodeActions(options, ctx) {
646
+ const host = PLUGIN_HOSTS.opencode;
647
+ const scopes = resolveScopes(options.scope, host);
648
+ /** @type {InstallAction[]} */
649
+ const actions = [];
650
+
651
+ for (const installScope of scopes) {
652
+ const configPath =
653
+ installScope === 'project'
654
+ ? path.join(ctx.projectDir, 'opencode.json')
655
+ : path.join(getOpencodeConfigDir(ctx.home), 'opencode.json');
656
+
657
+ actions.push(planOpencodeConfigMerge(configPath, installScope, options));
658
+
659
+ const commandDir =
660
+ installScope === 'project'
661
+ ? path.join(ctx.projectDir, '.opencode', 'commands')
662
+ : path.join(getOpencodeConfigDir(ctx.home), 'commands');
663
+
664
+ for (const file of listOpencodeCommandFiles()) {
665
+ actions.push(
666
+ planFileCopy(
667
+ 'opencode',
668
+ installScope,
669
+ file.src,
670
+ path.join(commandDir, file.name),
671
+ options,
672
+ false,
673
+ ),
674
+ );
675
+ }
676
+ }
677
+
678
+ return actions;
679
+ }
680
+
681
+ function planOpencodeUninstallActions(options, ctx) {
682
+ const host = PLUGIN_HOSTS.opencode;
683
+ const scopes = resolveScopes(options.scope, host);
684
+ /** @type {InstallAction[]} */
685
+ const actions = [];
686
+
687
+ for (const installScope of scopes) {
688
+ const configPath =
689
+ installScope === 'project'
690
+ ? path.join(ctx.projectDir, 'opencode.json')
691
+ : path.join(getOpencodeConfigDir(ctx.home), 'opencode.json');
692
+
693
+ actions.push(planOpencodeConfigUninstall(configPath, installScope));
694
+
695
+ const commandDir =
696
+ installScope === 'project'
697
+ ? path.join(ctx.projectDir, '.opencode', 'commands')
698
+ : path.join(getOpencodeConfigDir(ctx.home), 'commands');
699
+
700
+ for (const file of listOpencodeCommandFiles()) {
701
+ actions.push(
702
+ planFileRemove(
703
+ 'opencode',
704
+ installScope,
705
+ path.join(commandDir, file.name),
706
+ file.src,
707
+ ),
708
+ );
709
+ }
710
+ }
711
+
712
+ return actions;
713
+ }
714
+
715
+ function buildHintActions(hostIds, ctx) {
716
+ return hostIds.filter(isHintHost).map((hostId) => {
717
+ const host = HINT_HOSTS[hostId];
718
+ return {
719
+ type: 'hint',
720
+ host: hostId,
721
+ scope: 'project',
722
+ to: host.label,
723
+ lines: host.getHints(ctx),
724
+ };
725
+ });
726
+ }
727
+
728
+ function buildUninstallPlan(hostIds, options, ctx) {
729
+ validateHostIds(hostIds);
730
+
731
+ /** @type {InstallAction[]} */
732
+ const actions = [];
733
+
734
+ for (const hostId of hostIds) {
735
+ if (hostId === 'opencode') {
736
+ actions.push(...planOpencodeUninstallActions(options, ctx));
737
+ continue;
738
+ }
739
+
740
+ const host = RULE_HOSTS[hostId];
741
+ const scopes = resolveScopes(options.scope, host);
742
+ const packageSrc = path.join(PACKAGE_ROOT, host.src);
743
+
744
+ for (const installScope of scopes) {
745
+ const targetPath =
746
+ installScope === 'project'
747
+ ? path.join(ctx.projectDir, host.projectDest)
748
+ : host.globalDest(ctx.home);
749
+
750
+ actions.push(planFileRemove(host.id, installScope, targetPath, packageSrc));
751
+ }
752
+ }
753
+
754
+ return actions;
755
+ }
756
+
757
+ function buildPlan(hostIds, options, ctx) {
758
+ validateHostIds(hostIds);
759
+
760
+ /** @type {InstallAction[]} */
761
+ const actions = [];
762
+
763
+ for (const hostId of hostIds) {
764
+ if (hostId === 'opencode') {
765
+ actions.push(...planOpencodeActions(options, ctx));
766
+ continue;
767
+ }
768
+
769
+ const host = RULE_HOSTS[hostId];
770
+ const scopes = resolveScopes(options.scope, host);
771
+
772
+ for (const installScope of scopes) {
773
+ const destPath =
774
+ installScope === 'project'
775
+ ? path.join(ctx.projectDir, host.projectDest)
776
+ : host.globalDest(ctx.home);
777
+
778
+ actions.push(planCopyAction(host, installScope, destPath, options));
779
+ }
780
+ }
781
+
782
+ return actions;
783
+ }
784
+
785
+ function formatAction(action) {
786
+ if (action.type === 'skip') {
787
+ return `skip ${action.host} (${action.scope}) → ${action.to} [${action.reason}]`;
788
+ }
789
+ if (action.type === 'hint') {
790
+ return `hint ${action.host} → ${action.to}`;
791
+ }
792
+ const backup = action.backup ? ` (backup → ${action.backup})` : '';
793
+ if (action.type === 'merge') {
794
+ return `merge ${action.host} (${action.scope}) → ${action.to}${backup}`;
795
+ }
796
+ if (action.type === 'remove') {
797
+ return `remove ${action.host} (${action.scope}) → ${action.to}${backup}`;
798
+ }
799
+ return `copy ${action.host} (${action.scope}) → ${action.to}${backup}`;
800
+ }
801
+
802
+ function printPlan(actions, options) {
803
+ if (actions.length === 0) {
804
+ console.log(options.uninstall ? 'No uninstall actions planned.' : 'No install actions planned.');
805
+ return;
806
+ }
807
+
808
+ const mode = options.uninstall ? 'uninstall' : 'install';
809
+ const prefix = options.dryRun ? `Dry run — planned ${mode} actions:` : `Planned ${mode} actions:`;
810
+ console.log(prefix);
811
+ for (const action of actions) {
812
+ console.log(` ${formatAction(action)}`);
813
+ if (action.type === 'hint' && action.lines) {
814
+ for (const line of action.lines) {
815
+ console.log(` ${line}`);
816
+ }
817
+ }
818
+ }
819
+ }
820
+
821
+ function executePlan(actions) {
822
+ let applied = 0;
823
+ let skipped = 0;
824
+
825
+ for (const action of actions) {
826
+ if (action.type === 'skip' || action.type === 'hint') {
827
+ skipped += 1;
828
+ continue;
829
+ }
830
+
831
+ fs.mkdirSync(path.dirname(action.to), { recursive: true });
832
+
833
+ if (action.backup && fs.existsSync(action.to)) {
834
+ fs.copyFileSync(action.to, action.backup);
835
+ }
836
+
837
+ if (action.type === 'remove') {
838
+ fs.unlinkSync(action.to);
839
+ } else if (action.type === 'merge') {
840
+ fs.writeFileSync(action.to, action.content, 'utf8');
841
+ } else {
842
+ fs.copyFileSync(action.from, action.to);
843
+ }
844
+
845
+ applied += 1;
846
+ }
847
+
848
+ return { applied, skipped };
849
+ }
850
+
851
+ function printHelp() {
852
+ console.log(`lexis-two install — copy Lexis-Two rules, merge OpenCode config, or uninstall
853
+
854
+ Usage:
855
+ npx @draig/lexis-two install [options]
856
+ lexis-two install [options]
857
+
858
+ Options:
859
+ --host <id[,id]> Hosts: cursor, windsurf, cline, kiro, agents, opencode,
860
+ copilot-repo, claude, copilot, gemini, pi
861
+ --scope <scope> project | global | both (default: project)
862
+ --project-dir <path> Target project directory (default: cwd)
863
+ --uninstall Remove Lexis-Two files installed by this tool
864
+ --dry-run Print actions without writing files
865
+ --yes, -y Skip confirmation prompt
866
+ --force Overwrite existing files (AGENTS.md requires this)
867
+ --non-interactive Do not prompt; requires --host and --yes
868
+ --help, -h Show this help
869
+
870
+ Safety:
871
+ Uninstall only removes files identical to the package (skips modified files).
872
+ Overwrites and uninstalls create a .bak backup first when the target exists.
873
+
874
+ Examples:
875
+ npx @draig/lexis-two install --host cursor,agents --scope project --yes
876
+ npx @draig/lexis-two install --host opencode --scope project --yes
877
+ npx @draig/lexis-two install --host claude,gemini --yes
878
+ npx @draig/lexis-two install --uninstall --host cursor --scope project --yes
879
+ `);
880
+ }
881
+
882
+ function question(prompt) {
883
+ const rl = readline.createInterface({
884
+ input: process.stdin,
885
+ output: process.stdout,
886
+ });
887
+
888
+ return new Promise((resolve) => {
889
+ rl.question(prompt, (answer) => {
890
+ rl.close();
891
+ resolve(answer.trim());
892
+ });
893
+ });
894
+ }
895
+
896
+ async function resolveHosts(options, ctx) {
897
+ if (options.hosts.length > 0) {
898
+ return options.hosts;
899
+ }
900
+
901
+ const detected = detectHosts(ctx);
902
+ if (detected.length === 0) {
903
+ const verb = options.uninstall ? 'Uninstall' : 'Install';
904
+ console.log(`No hosts detected. Use --host cursor,agents to ${verb.toLowerCase()} manually.`);
905
+ return [];
906
+ }
907
+
908
+ if (!options.interactive) {
909
+ console.log(`Detected hosts: ${detected.join(', ')}`);
910
+ console.log('Re-run with --host <ids> --yes to install without prompts.');
911
+ return [];
912
+ }
913
+
914
+ const promptVerb = options.uninstall ? 'Uninstall' : 'Install';
915
+ const answer = await question(
916
+ `${promptVerb} for [${detected.join(', ')}]? (Y/n or comma list): `,
917
+ );
918
+
919
+ if (!answer || answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
920
+ return detected;
921
+ }
922
+ if (answer.toLowerCase() === 'n' || answer.toLowerCase() === 'no') {
923
+ return [];
924
+ }
925
+
926
+ return [...new Set(splitList(answer))];
927
+ }
928
+
929
+ async function confirmPlan(options) {
930
+ if (options.yes || options.dryRun || !options.interactive) {
931
+ return true;
932
+ }
933
+
934
+ const answer = await question(
935
+ options.uninstall ? 'Proceed with uninstall? (Y/n): ' : 'Proceed? (Y/n): ',
936
+ );
937
+ return !answer || answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes';
938
+ }
939
+
940
+ function printNextSteps(hostIds) {
941
+ const lines = ['Done. Next steps:'];
942
+
943
+ if (hostIds.includes('cursor')) {
944
+ lines.push(' • Cursor: open your project — lexis-two.mdc should be active.');
945
+ }
946
+ if (hostIds.includes('agents')) {
947
+ lines.push(' • Generic agents: load AGENTS.md from your project root.');
948
+ }
949
+ if (hostIds.includes('opencode')) {
950
+ lines.push(' • OpenCode: restart the TUI, then run /lexis status.');
951
+ }
952
+ if (hostIds.some(isHintHost)) {
953
+ lines.push(' • Plugin hosts: follow the setup hints printed above.');
954
+ }
955
+ if (hostIds.some((id) => !['agents', 'opencode', 'cursor'].includes(id) && !isHintHost(id))) {
956
+ lines.push(' • More hosts: see docs/setup.md');
957
+ }
958
+
959
+ console.log(lines.join('\n'));
960
+ }
961
+
962
+ async function runInstall(rawArgv) {
963
+ const options = parseArgs(rawArgv);
964
+
965
+ if (options.help) {
966
+ printHelp();
967
+ return 0;
968
+ }
969
+
970
+ const ctx = createContext(options.projectDir);
971
+ const hostIds = await resolveHosts(options, ctx);
972
+
973
+ if (hostIds.length === 0) {
974
+ return 0;
975
+ }
976
+
977
+ const hintHostIds = hostIds.filter(isHintHost);
978
+ const actionHostIds = hostIds.filter((hostId) => !isHintHost(hostId));
979
+
980
+ const actions = options.uninstall
981
+ ? buildUninstallPlan(actionHostIds, options, ctx)
982
+ : [
983
+ ...buildPlan(actionHostIds, options, ctx),
984
+ ...buildHintActions(hintHostIds, ctx),
985
+ ];
986
+ printPlan(actions, options);
987
+
988
+ const confirmed = await confirmPlan(options);
989
+ if (!confirmed) {
990
+ console.log(options.uninstall ? 'Uninstall cancelled.' : 'Install cancelled.');
991
+ return 0;
992
+ }
993
+
994
+ if (options.dryRun) {
995
+ return 0;
996
+ }
997
+
998
+ const result = executePlan(actions);
999
+ const verb = options.uninstall ? 'Uninstalled' : 'Installed';
1000
+ console.log(`${verb}: ${result.applied} file(s), skipped: ${result.skipped}.`);
1001
+
1002
+ if (!options.uninstall) {
1003
+ printNextSteps(hostIds);
1004
+ }
1005
+ return 0;
1006
+ }
1007
+
1008
+ async function main() {
1009
+ try {
1010
+ const code = await runInstall(process.argv.slice(2));
1011
+ process.exit(code);
1012
+ } catch (error) {
1013
+ const message = error instanceof Error ? error.message : String(error);
1014
+ console.error(`lexis-two install failed: ${message}`);
1015
+ process.exit(1);
1016
+ }
1017
+ }
1018
+
1019
+ if (require.main === module) {
1020
+ main();
1021
+ }
1022
+
1023
+ module.exports = {
1024
+ RULE_HOSTS,
1025
+ PLUGIN_HOSTS,
1026
+ HINT_HOSTS,
1027
+ parseArgs,
1028
+ detectHosts,
1029
+ buildPlan,
1030
+ buildUninstallPlan,
1031
+ buildHintActions,
1032
+ executePlan,
1033
+ createContext,
1034
+ runInstall,
1035
+ getOpencodeConfigDir,
1036
+ isLexisPluginEntry,
1037
+ isHintHost,
1038
+ planOpencodeConfigMerge,
1039
+ planOpencodeConfigUninstall,
1040
+ backupPath,
1041
+ };