legion-cc 0.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +269 -0
  3. package/VERSION +1 -0
  4. package/agents/legion-orchestrator.md +95 -0
  5. package/bin/install.js +898 -0
  6. package/bin/legion-tools.cjs +421 -0
  7. package/bin/lib/config.cjs +141 -0
  8. package/bin/lib/core.cjs +216 -0
  9. package/bin/lib/domain.cjs +107 -0
  10. package/bin/lib/init.cjs +184 -0
  11. package/bin/lib/session.cjs +140 -0
  12. package/bin/lib/state.cjs +280 -0
  13. package/commands/legion/devops/architect.md +44 -0
  14. package/commands/legion/devops/build.md +52 -0
  15. package/commands/legion/devops/cycle.md +52 -0
  16. package/commands/legion/devops/execute.md +52 -0
  17. package/commands/legion/devops/plan.md +51 -0
  18. package/commands/legion/devops/quick.md +45 -0
  19. package/commands/legion/devops/review.md +52 -0
  20. package/commands/legion/resume.md +52 -0
  21. package/commands/legion/status.md +53 -0
  22. package/hooks/legion-context-monitor.js +180 -0
  23. package/hooks/legion-statusline.js +191 -0
  24. package/package.json +48 -0
  25. package/references/agent-routing.md +64 -0
  26. package/references/devops/agent-map.md +61 -0
  27. package/references/devops/pipeline-patterns.md +87 -0
  28. package/references/domain-registry.md +63 -0
  29. package/references/ui-brand.md +102 -0
  30. package/templates/config.json +25 -0
  31. package/templates/devops/architect-output.md +28 -0
  32. package/templates/devops/execution-report.md +23 -0
  33. package/templates/devops/plan-output.md +33 -0
  34. package/templates/devops/review-checklist.md +35 -0
  35. package/templates/session.md +17 -0
  36. package/templates/state.md +17 -0
  37. package/templates/task-record.md +19 -0
  38. package/workflows/core/completion.md +70 -0
  39. package/workflows/core/context-load.md +57 -0
  40. package/workflows/core/init.md +52 -0
  41. package/workflows/devops/architect.md +91 -0
  42. package/workflows/devops/build.md +92 -0
  43. package/workflows/devops/cycle.md +237 -0
  44. package/workflows/devops/execute.md +118 -0
  45. package/workflows/devops/plan.md +108 -0
  46. package/workflows/devops/quick.md +107 -0
  47. package/workflows/devops/review.md +112 -0
  48. package/workflows/resume.md +88 -0
  49. package/workflows/status.md +72 -0
package/bin/install.js ADDED
@@ -0,0 +1,898 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Legion Installer — bin/install.js
4
+ // Entry point for `npx legion-cc@latest`
5
+ // Copies Legion framework files into ~/.claude/ (or custom config dir)
6
+ //
7
+ // CommonJS, no external dependencies — only Node.js built-ins.
8
+
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const os = require('os');
14
+ const crypto = require('crypto');
15
+
16
+ // ─── Colors ──────────────────────────────────────────────────────────────────
17
+
18
+ const bold = '\x1b[1m';
19
+ const dim = '\x1b[2m';
20
+ const red = '\x1b[31m';
21
+ const green = '\x1b[32m';
22
+ const yellow = '\x1b[33m';
23
+ const cyan = '\x1b[36m';
24
+ const reset = '\x1b[0m';
25
+
26
+ // Disable colors when not a TTY
27
+ const isTTY = process.stdout.isTTY;
28
+ function c(color, text) {
29
+ return isTTY ? `${color}${text}${reset}` : text;
30
+ }
31
+
32
+ // ─── Read version ────────────────────────────────────────────────────────────
33
+
34
+ const srcDir = path.resolve(__dirname, '..');
35
+ const versionFile = path.join(srcDir, 'VERSION');
36
+ let version = '0.0.0';
37
+ try {
38
+ version = fs.readFileSync(versionFile, 'utf8').trim();
39
+ } catch (_) {
40
+ // Try package.json as fallback
41
+ try {
42
+ const pkg = require(path.join(srcDir, 'package.json'));
43
+ version = pkg.version || '0.0.0';
44
+ } catch (_2) {
45
+ // Keep default
46
+ }
47
+ }
48
+
49
+ // ─── Parse CLI flags ─────────────────────────────────────────────────────────
50
+
51
+ const args = process.argv.slice(2);
52
+
53
+ function hasFlag(name) {
54
+ return args.includes(name);
55
+ }
56
+
57
+ function getFlagValue(name) {
58
+ const idx = args.indexOf(name);
59
+ if (idx !== -1 && idx + 1 < args.length) {
60
+ const val = args[idx + 1];
61
+ if (!val.startsWith('-')) return val;
62
+ }
63
+ // Also handle --flag=value
64
+ const eqArg = args.find(a => a.startsWith(name + '='));
65
+ if (eqArg) {
66
+ const val = eqArg.split('=').slice(1).join('=');
67
+ if (val) return val;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ const flagForce = hasFlag('--force');
73
+ const flagDryRun = hasFlag('--dry-run');
74
+ const flagUninstall = hasFlag('--uninstall');
75
+ const flagHelp = hasFlag('--help') || hasFlag('-h');
76
+ const flagConfigDir = getFlagValue('--config-dir');
77
+
78
+ // ─── Help ────────────────────────────────────────────────────────────────────
79
+
80
+ if (flagHelp) {
81
+ console.log(`
82
+ ${c(bold + cyan, 'Legion')} ${c(dim, 'v' + version)} — Installer
83
+
84
+ ${c(yellow, 'Usage:')} npx legion-cc [options]
85
+
86
+ ${c(yellow, 'Options:')}
87
+ ${c(cyan, '--force')} Overwrite existing agent files
88
+ ${c(cyan, '--dry-run')} Show what would happen without doing it
89
+ ${c(cyan, '--uninstall')} Remove Legion from ~/.claude/
90
+ ${c(cyan, '--config-dir <path>')} Custom config directory (default: ~/.claude)
91
+ ${c(cyan, '--help, -h')} Show this help message
92
+
93
+ ${c(yellow, 'Examples:')}
94
+ ${c(dim, '# Install Legion')}
95
+ npx legion-cc
96
+
97
+ ${c(dim, '# Install with force-overwrite of agents')}
98
+ npx legion-cc --force
99
+
100
+ ${c(dim, '# Preview without changes')}
101
+ npx legion-cc --dry-run
102
+
103
+ ${c(dim, '# Uninstall')}
104
+ npx legion-cc --uninstall
105
+
106
+ ${c(yellow, 'Environment:')}
107
+ CLAUDE_CONFIG_DIR Override config directory (same as --config-dir)
108
+ `);
109
+ process.exit(0);
110
+ }
111
+
112
+ // ─── Determine paths ─────────────────────────────────────────────────────────
113
+
114
+ const configDir = process.env.CLAUDE_CONFIG_DIR
115
+ || flagConfigDir
116
+ || path.join(os.homedir(), '.claude');
117
+
118
+ // ─── Banner ──────────────────────────────────────────────────────────────────
119
+
120
+ function printBanner() {
121
+ console.log('');
122
+ console.log(c(bold + cyan, '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
123
+ console.log(c(bold + cyan, ' LEGION') + ' ' + c(dim, 'v' + version) + c(bold + cyan, ' — Installer'));
124
+ console.log(c(bold + cyan, '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
125
+ console.log('');
126
+ }
127
+
128
+ printBanner();
129
+
130
+ if (flagDryRun) {
131
+ console.log(c(yellow, ' [DRY RUN] No files will be written.'));
132
+ console.log('');
133
+ }
134
+
135
+ // ─── Logging helpers ─────────────────────────────────────────────────────────
136
+
137
+ function logStep(msg) {
138
+ console.log(` ${c(bold + cyan, '==>') } ${msg}`);
139
+ }
140
+
141
+ function logOk(msg) {
142
+ console.log(` ${c(green, '+')} ${msg}`);
143
+ }
144
+
145
+ function logSkip(msg) {
146
+ console.log(` ${c(dim, '-')} ${msg} ${c(dim, '(skipped)')}`);
147
+ }
148
+
149
+ function logWarn(msg) {
150
+ console.log(` ${c(yellow, '!')} ${msg}`);
151
+ }
152
+
153
+ function logErr(msg) {
154
+ console.error(` ${c(red, 'ERROR:')} ${msg}`);
155
+ }
156
+
157
+ // ─── Counters ────────────────────────────────────────────────────────────────
158
+
159
+ const counts = {
160
+ commands: 0,
161
+ workflows: 0,
162
+ templates: 0,
163
+ references: 0,
164
+ hooks: 0,
165
+ agents: 0,
166
+ bin: 0,
167
+ other: 0,
168
+ };
169
+ let totalFiles = 0;
170
+ let skippedFiles = 0;
171
+
172
+ // ─── File utility helpers ────────────────────────────────────────────────────
173
+
174
+ /**
175
+ * Recursively count files in a directory.
176
+ */
177
+ function countFilesInDir(dir) {
178
+ let count = 0;
179
+ if (!fs.existsSync(dir)) return 0;
180
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
181
+ for (const entry of entries) {
182
+ const full = path.join(dir, entry.name);
183
+ if (entry.isDirectory()) {
184
+ count += countFilesInDir(full);
185
+ } else if (entry.isFile()) {
186
+ count++;
187
+ }
188
+ }
189
+ return count;
190
+ }
191
+
192
+ /**
193
+ * Compute SHA256 hex digest of a file.
194
+ */
195
+ function sha256File(filePath) {
196
+ const data = fs.readFileSync(filePath);
197
+ return crypto.createHash('sha256').update(data).digest('hex');
198
+ }
199
+
200
+ /**
201
+ * Recursively walk a directory and return all file paths.
202
+ */
203
+ function walkDir(dir, fileList) {
204
+ fileList = fileList || [];
205
+ if (!fs.existsSync(dir)) return fileList;
206
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
207
+ for (const entry of entries) {
208
+ const full = path.join(dir, entry.name);
209
+ if (entry.isDirectory()) {
210
+ walkDir(full, fileList);
211
+ } else if (entry.isFile()) {
212
+ fileList.push(full);
213
+ }
214
+ }
215
+ return fileList;
216
+ }
217
+
218
+ /**
219
+ * Ensure a directory exists (create recursively if needed).
220
+ */
221
+ function ensureDir(dir) {
222
+ if (flagDryRun) return;
223
+ try {
224
+ fs.mkdirSync(dir, { recursive: true });
225
+ } catch (err) {
226
+ if (err.code !== 'EEXIST') {
227
+ logWarn(`Could not create directory ${dir}: ${err.message}`);
228
+ }
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Copy a single file. Creates parent directories as needed.
234
+ */
235
+ function copyFile(src, dest) {
236
+ if (flagDryRun) {
237
+ console.log(` ${c(dim, '[dry-run]')} cp ${src} -> ${dest}`);
238
+ return true;
239
+ }
240
+ try {
241
+ ensureDir(path.dirname(dest));
242
+ fs.copyFileSync(src, dest);
243
+ return true;
244
+ } catch (err) {
245
+ logWarn(`Could not copy ${src} -> ${dest}: ${err.message}`);
246
+ return false;
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Copy a directory recursively using fs.cpSync.
252
+ * Returns the number of files copied.
253
+ */
254
+ function copyDir(src, dest) {
255
+ if (!fs.existsSync(src)) {
256
+ logWarn(`Source directory not found: ${src}`);
257
+ return 0;
258
+ }
259
+
260
+ const fileCount = countFilesInDir(src);
261
+
262
+ if (flagDryRun) {
263
+ console.log(` ${c(dim, '[dry-run]')} cpSync ${src}/ -> ${dest}/ (${fileCount} files)`);
264
+ return fileCount;
265
+ }
266
+
267
+ try {
268
+ ensureDir(dest);
269
+ fs.cpSync(src, dest, { recursive: true, force: true });
270
+ return fileCount;
271
+ } catch (err) {
272
+ logWarn(`Could not copy directory ${src} -> ${dest}: ${err.message}`);
273
+ return 0;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Read and parse a JSON file, returning null on failure.
279
+ */
280
+ function readJSON(filePath) {
281
+ try {
282
+ const raw = fs.readFileSync(filePath, 'utf8');
283
+ return JSON.parse(raw);
284
+ } catch (_) {
285
+ return null;
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Write a JSON file with pretty formatting.
291
+ */
292
+ function writeJSON(filePath, data) {
293
+ if (flagDryRun) {
294
+ console.log(` ${c(dim, '[dry-run]')} write ${filePath}`);
295
+ return;
296
+ }
297
+ try {
298
+ ensureDir(path.dirname(filePath));
299
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
300
+ } catch (err) {
301
+ logWarn(`Could not write ${filePath}: ${err.message}`);
302
+ }
303
+ }
304
+
305
+ // ─── Check prerequisites ────────────────────────────────────────────────────
306
+
307
+ function checkPrerequisites() {
308
+ logStep('Checking prerequisites...');
309
+
310
+ // Node.js >= 18
311
+ const nodeVersion = process.versions.node;
312
+ const major = parseInt(nodeVersion.split('.')[0], 10);
313
+ if (major < 18) {
314
+ logErr(`Node.js >= 18 is required (found v${nodeVersion}).`);
315
+ logErr('Install from https://nodejs.org');
316
+ process.exit(1);
317
+ }
318
+ logOk(`Node.js v${nodeVersion}`);
319
+
320
+ // configDir — create if not exists
321
+ if (!fs.existsSync(configDir)) {
322
+ logWarn(`Config directory ${configDir} does not exist, creating...`);
323
+ ensureDir(configDir);
324
+ if (!flagDryRun && !fs.existsSync(configDir)) {
325
+ logErr(`Could not create config directory: ${configDir}`);
326
+ logErr('Please install Claude Code first: https://claude.ai/download');
327
+ process.exit(1);
328
+ }
329
+ }
330
+ logOk(`Config directory: ${configDir}`);
331
+
332
+ console.log('');
333
+ }
334
+
335
+ // ─── Install logic ──────────────────────────────────────────────────────────
336
+
337
+ function doInstall() {
338
+ checkPrerequisites();
339
+
340
+ // ── Step 1: Copy files ──────────────────────────────────────────────────
341
+
342
+ // commands/legion/ -> commands/legion/
343
+ logStep('Copying commands...');
344
+ const commandsSrc = path.join(srcDir, 'commands', 'legion');
345
+ const commandsDest = path.join(configDir, 'commands', 'legion');
346
+ const commandCount = copyDir(commandsSrc, commandsDest);
347
+ counts.commands = commandCount;
348
+ totalFiles += commandCount;
349
+ logOk(`${commandCount} command files`);
350
+
351
+ // workflows/ -> legion/workflows/
352
+ logStep('Copying workflows...');
353
+ const workflowsSrc = path.join(srcDir, 'workflows');
354
+ const workflowsDest = path.join(configDir, 'legion', 'workflows');
355
+ const workflowCount = copyDir(workflowsSrc, workflowsDest);
356
+ counts.workflows = workflowCount;
357
+ totalFiles += workflowCount;
358
+ logOk(`${workflowCount} workflow files`);
359
+
360
+ // templates/ -> legion/templates/
361
+ logStep('Copying templates...');
362
+ const templatesSrc = path.join(srcDir, 'templates');
363
+ const templatesDest = path.join(configDir, 'legion', 'templates');
364
+ const templateCount = copyDir(templatesSrc, templatesDest);
365
+ counts.templates = templateCount;
366
+ totalFiles += templateCount;
367
+ logOk(`${templateCount} template files`);
368
+
369
+ // references/ -> legion/references/
370
+ logStep('Copying references...');
371
+ const referencesSrc = path.join(srcDir, 'references');
372
+ const referencesDest = path.join(configDir, 'legion', 'references');
373
+ const referenceCount = copyDir(referencesSrc, referencesDest);
374
+ counts.references = referenceCount;
375
+ totalFiles += referenceCount;
376
+ logOk(`${referenceCount} reference files`);
377
+
378
+ // bin/legion-tools.cjs -> legion/bin/legion-tools.cjs
379
+ logStep('Copying bin...');
380
+ const toolsSrc = path.join(srcDir, 'bin', 'legion-tools.cjs');
381
+ const toolsDest = path.join(configDir, 'legion', 'bin', 'legion-tools.cjs');
382
+ if (copyFile(toolsSrc, toolsDest)) {
383
+ counts.bin++;
384
+ totalFiles++;
385
+ }
386
+
387
+ // bin/lib/ -> legion/bin/lib/
388
+ const libSrc = path.join(srcDir, 'bin', 'lib');
389
+ const libDest = path.join(configDir, 'legion', 'bin', 'lib');
390
+ const libCount = copyDir(libSrc, libDest);
391
+ counts.bin += libCount;
392
+ totalFiles += libCount;
393
+ logOk(`${counts.bin} bin files`);
394
+
395
+ // VERSION -> legion/VERSION
396
+ logStep('Copying VERSION...');
397
+ const versionSrc = path.join(srcDir, 'VERSION');
398
+ const versionDest = path.join(configDir, 'legion', 'VERSION');
399
+ if (copyFile(versionSrc, versionDest)) {
400
+ counts.other++;
401
+ totalFiles++;
402
+ }
403
+ logOk('VERSION');
404
+
405
+ // hooks/legion-context-monitor.js -> hooks/legion-context-monitor.js
406
+ // hooks/legion-statusline.js -> hooks/legion-statusline.js
407
+ logStep('Copying hooks...');
408
+ const hookFiles = ['legion-context-monitor.js', 'legion-statusline.js'];
409
+ for (const hookName of hookFiles) {
410
+ const hookSrc = path.join(srcDir, 'hooks', hookName);
411
+ const hookDest = path.join(configDir, 'hooks', hookName);
412
+ if (fs.existsSync(hookSrc)) {
413
+ if (copyFile(hookSrc, hookDest)) {
414
+ counts.hooks++;
415
+ totalFiles++;
416
+ logOk(hookName);
417
+ }
418
+ } else {
419
+ logWarn(`Hook source not found: ${hookSrc}`);
420
+ }
421
+ }
422
+
423
+ // agents/legion-orchestrator.md -> agents/legion-orchestrator.md (skip if exists unless --force)
424
+ logStep('Copying agents...');
425
+ const agentsSrc = path.join(srcDir, 'agents');
426
+ if (fs.existsSync(agentsSrc)) {
427
+ const agentFiles = fs.readdirSync(agentsSrc).filter(f => f.endsWith('.md'));
428
+ for (const agentFile of agentFiles) {
429
+ const src = path.join(agentsSrc, agentFile);
430
+ const dest = path.join(configDir, 'agents', agentFile);
431
+
432
+ if (fs.existsSync(dest) && !flagForce) {
433
+ logSkip(`agents/${agentFile} already exists (use --force to overwrite)`);
434
+ skippedFiles++;
435
+ continue;
436
+ }
437
+
438
+ if (copyFile(src, dest)) {
439
+ counts.agents++;
440
+ totalFiles++;
441
+ logOk(`agents/${agentFile}`);
442
+ }
443
+ }
444
+ }
445
+
446
+ // ── Step 2: Make hooks executable ────────────────────────────────────────
447
+
448
+ logStep('Making hooks executable...');
449
+ for (const hookName of hookFiles) {
450
+ const hookPath = path.join(configDir, 'hooks', hookName);
451
+ if (flagDryRun) {
452
+ console.log(` ${c(dim, '[dry-run]')} chmod 755 ${hookPath}`);
453
+ } else {
454
+ try {
455
+ if (fs.existsSync(hookPath)) {
456
+ fs.chmodSync(hookPath, 0o755);
457
+ logOk(`chmod 755 ${hookName}`);
458
+ }
459
+ } catch (err) {
460
+ logWarn(`Could not chmod ${hookPath}: ${err.message}`);
461
+ }
462
+ }
463
+ }
464
+
465
+ // ── Step 3: Register hooks in settings ──────────────────────────────────
466
+
467
+ logStep('Registering hooks in settings...');
468
+ registerHooks();
469
+
470
+ // ── Step 4: Generate manifest ───────────────────────────────────────────
471
+
472
+ logStep('Generating file manifest...');
473
+ generateManifest();
474
+
475
+ // ── Step 5: Print summary ───────────────────────────────────────────────
476
+
477
+ printSummary();
478
+ }
479
+
480
+ // ─── Register hooks in settings.json ─────────────────────────────────────────
481
+
482
+ function registerHooks() {
483
+ // Determine settings file
484
+ let settingsPath = null;
485
+ const settingsLocalPath = path.join(configDir, 'settings.local.json');
486
+ const settingsJsonPath = path.join(configDir, 'settings.json');
487
+
488
+ if (fs.existsSync(settingsLocalPath)) {
489
+ settingsPath = settingsLocalPath;
490
+ } else if (fs.existsSync(settingsJsonPath)) {
491
+ settingsPath = settingsJsonPath;
492
+ } else {
493
+ // No settings file exists — create settings.local.json
494
+ settingsPath = settingsLocalPath;
495
+ if (!flagDryRun) {
496
+ writeJSON(settingsPath, {});
497
+ }
498
+ logOk(`Created ${settingsPath}`);
499
+ }
500
+
501
+ const settings = readJSON(settingsPath) || {};
502
+
503
+ let changed = false;
504
+
505
+ // ── PostToolUse hook: legion-context-monitor.js ──
506
+
507
+ const contextMonitorCmd = `node "${path.join(configDir, 'hooks', 'legion-context-monitor.js').replace(/\\/g, '/')}"`;
508
+
509
+ if (!settings.hooks) {
510
+ settings.hooks = {};
511
+ }
512
+ if (!Array.isArray(settings.hooks.PostToolUse)) {
513
+ settings.hooks.PostToolUse = [];
514
+ }
515
+
516
+ const alreadyRegistered = settings.hooks.PostToolUse.some(entry => {
517
+ const entryStr = JSON.stringify(entry);
518
+ return entryStr.includes('legion-context-monitor');
519
+ });
520
+
521
+ if (!alreadyRegistered) {
522
+ settings.hooks.PostToolUse.push({
523
+ hooks: [
524
+ {
525
+ type: 'command',
526
+ command: contextMonitorCmd,
527
+ },
528
+ ],
529
+ });
530
+ changed = true;
531
+ logOk('Registered PostToolUse: legion-context-monitor.js');
532
+ } else {
533
+ logSkip('PostToolUse: legion-context-monitor.js already registered');
534
+ }
535
+
536
+ // ── StatusLine: legion-statusline.js ──
537
+
538
+ const statuslineCmd = `node "${path.join(configDir, 'hooks', 'legion-statusline.js').replace(/\\/g, '/')}"`;
539
+
540
+ const currentStatusLine = settings.statusLine || null;
541
+ const currentCmd = (currentStatusLine && typeof currentStatusLine === 'object')
542
+ ? (currentStatusLine.command || '')
543
+ : '';
544
+
545
+ if (!currentStatusLine) {
546
+ // No statusline configured — set Legion's
547
+ settings.statusLine = {
548
+ type: 'command',
549
+ command: statuslineCmd,
550
+ };
551
+ changed = true;
552
+ logOk('Configured statusLine: legion-statusline.js');
553
+ } else if (currentCmd.includes('legion-statusline')) {
554
+ logSkip('statusLine: legion-statusline.js already configured');
555
+ } else if (currentCmd.includes('gsd-statusline')) {
556
+ logWarn('GSD statusline is currently active.');
557
+ logWarn('Only one statusline can be active at a time.');
558
+ logWarn(`To switch to Legion, manually update "statusLine" in ${settingsPath}`);
559
+ } else if (currentCmd) {
560
+ logWarn(`Another statusline is configured: ${currentCmd}`);
561
+ logWarn(`To use Legion statusline, update "statusLine" in ${settingsPath}`);
562
+ }
563
+
564
+ // Write settings if changed
565
+ if (changed) {
566
+ writeJSON(settingsPath, settings);
567
+ logOk(`Updated ${settingsPath}`);
568
+ }
569
+ }
570
+
571
+ // ─── Generate manifest ──────────────────────────────────────────────────────
572
+
573
+ function generateManifest() {
574
+ const manifestPath = path.join(configDir, 'legion-file-manifest.json');
575
+
576
+ const manifest = {
577
+ version: version,
578
+ timestamp: new Date().toISOString(),
579
+ files: {},
580
+ };
581
+
582
+ // Directories to scan for manifest
583
+ const scanDirs = [
584
+ path.join(configDir, 'legion'),
585
+ path.join(configDir, 'commands', 'legion'),
586
+ ];
587
+
588
+ // Individual files to include
589
+ const scanFiles = [
590
+ path.join(configDir, 'hooks', 'legion-context-monitor.js'),
591
+ path.join(configDir, 'hooks', 'legion-statusline.js'),
592
+ ];
593
+
594
+ // Also include agent files we installed
595
+ const agentsSrc = path.join(srcDir, 'agents');
596
+ if (fs.existsSync(agentsSrc)) {
597
+ const agentFiles = fs.readdirSync(agentsSrc).filter(f => f.endsWith('.md'));
598
+ for (const agentFile of agentFiles) {
599
+ scanFiles.push(path.join(configDir, 'agents', agentFile));
600
+ }
601
+ }
602
+
603
+ if (flagDryRun) {
604
+ console.log(` ${c(dim, '[dry-run]')} would write ${manifestPath}`);
605
+ return;
606
+ }
607
+
608
+ // Walk directories
609
+ for (const dir of scanDirs) {
610
+ const files = walkDir(dir);
611
+ for (const filePath of files) {
612
+ const rel = path.relative(configDir, filePath);
613
+ try {
614
+ manifest.files[rel] = sha256File(filePath);
615
+ } catch (err) {
616
+ logWarn(`Could not hash ${filePath}: ${err.message}`);
617
+ }
618
+ }
619
+ }
620
+
621
+ // Hash individual files
622
+ for (const filePath of scanFiles) {
623
+ if (fs.existsSync(filePath)) {
624
+ const rel = path.relative(configDir, filePath);
625
+ try {
626
+ manifest.files[rel] = sha256File(filePath);
627
+ } catch (err) {
628
+ logWarn(`Could not hash ${filePath}: ${err.message}`);
629
+ }
630
+ }
631
+ }
632
+
633
+ // Sort keys
634
+ const sortedFiles = {};
635
+ for (const key of Object.keys(manifest.files).sort()) {
636
+ sortedFiles[key] = manifest.files[key];
637
+ }
638
+ manifest.files = sortedFiles;
639
+
640
+ writeJSON(manifestPath, manifest);
641
+ logOk(`Manifest written: ${manifestPath} (${Object.keys(manifest.files).length} files)`);
642
+ }
643
+
644
+ // ─── Print summary ──────────────────────────────────────────────────────────
645
+
646
+ function printSummary() {
647
+ console.log('');
648
+ console.log(` ${c(bold + green, 'Legion v' + version + ' installed to ' + configDir + '/')}`);
649
+ console.log('');
650
+ console.log(` Commands: ${c(bold, String(counts.commands))} (commands/legion/)`);
651
+ console.log(` Workflows: ${c(bold, String(counts.workflows))} (legion/workflows/)`);
652
+ console.log(` Templates: ${c(bold, String(counts.templates))} (legion/templates/)`);
653
+ console.log(` References: ${c(bold, String(counts.references))} (legion/references/)`);
654
+ console.log(` Hooks: ${c(bold, String(counts.hooks))} (hooks/)`);
655
+ console.log(` Agents: ${c(bold, String(counts.agents))} (agents/)`);
656
+ console.log(` Bin: ${c(bold, String(counts.bin))} (legion/bin/)`);
657
+ console.log('');
658
+ console.log(` Total: ${c(bold, String(totalFiles))} files installed`);
659
+ if (skippedFiles > 0) {
660
+ console.log(` Skipped: ${c(dim, String(skippedFiles))} files`);
661
+ }
662
+ console.log('');
663
+ console.log(` ${c(dim, 'To get started:')}`);
664
+ console.log(` ${c(cyan, '/legion:devops:quick <task>')}`);
665
+ console.log(` ${c(cyan, '/legion:devops:architect <task>')}`);
666
+ console.log(` ${c(cyan, '/legion:status')}`);
667
+ console.log('');
668
+ }
669
+
670
+ // ─── Uninstall logic ────────────────────────────────────────────────────────
671
+
672
+ function doUninstall() {
673
+ checkPrerequisites();
674
+
675
+ logStep('Uninstalling Legion...');
676
+
677
+ const manifestPath = path.join(configDir, 'legion-file-manifest.json');
678
+ const manifest = readJSON(manifestPath);
679
+
680
+ let removedCount = 0;
681
+ let failedCount = 0;
682
+
683
+ // ── Step 1: Remove manifested files ──
684
+
685
+ if (manifest && manifest.files) {
686
+ logStep('Removing manifested files...');
687
+ const files = Object.keys(manifest.files).sort().reverse();
688
+ let agentsSkipped = 0;
689
+ for (const rel of files) {
690
+ // Do NOT remove agent files — they may have been customized by the user
691
+ if (rel.startsWith('agents/') || rel.startsWith('agents\\')) {
692
+ agentsSkipped++;
693
+ continue;
694
+ }
695
+ const filePath = path.join(configDir, rel);
696
+ if (flagDryRun) {
697
+ console.log(` ${c(dim, '[dry-run]')} rm ${filePath}`);
698
+ removedCount++;
699
+ continue;
700
+ }
701
+ try {
702
+ if (fs.existsSync(filePath)) {
703
+ fs.unlinkSync(filePath);
704
+ removedCount++;
705
+ }
706
+ } catch (err) {
707
+ logWarn(`Could not remove ${filePath}: ${err.message}`);
708
+ failedCount++;
709
+ }
710
+ }
711
+ logOk(`Removed ${removedCount} manifested files`);
712
+ if (agentsSkipped > 0) {
713
+ logOk(`Preserved ${agentsSkipped} agent file(s) in agents/`);
714
+ }
715
+ } else {
716
+ logWarn('No manifest found, performing directory-based cleanup...');
717
+ }
718
+
719
+ // ── Step 2: Remove directories ──
720
+
721
+ logStep('Removing directories...');
722
+
723
+ // Remove ~/.claude/legion/
724
+ const legionDir = path.join(configDir, 'legion');
725
+ removeDir(legionDir);
726
+
727
+ // Remove ~/.claude/commands/legion/
728
+ const commandsLegionDir = path.join(configDir, 'commands', 'legion');
729
+ removeDir(commandsLegionDir);
730
+
731
+ // ── Step 3: Remove hooks ──
732
+
733
+ logStep('Removing hooks...');
734
+ const hookFiles = [
735
+ path.join(configDir, 'hooks', 'legion-context-monitor.js'),
736
+ path.join(configDir, 'hooks', 'legion-statusline.js'),
737
+ ];
738
+ for (const hookPath of hookFiles) {
739
+ if (flagDryRun) {
740
+ if (fs.existsSync(hookPath)) {
741
+ console.log(` ${c(dim, '[dry-run]')} rm ${hookPath}`);
742
+ }
743
+ continue;
744
+ }
745
+ try {
746
+ if (fs.existsSync(hookPath)) {
747
+ fs.unlinkSync(hookPath);
748
+ logOk(`Removed ${path.basename(hookPath)}`);
749
+ }
750
+ } catch (err) {
751
+ logWarn(`Could not remove ${hookPath}: ${err.message}`);
752
+ }
753
+ }
754
+
755
+ // ── Step 4: Unregister hooks from settings ──
756
+
757
+ logStep('Unregistering hooks from settings...');
758
+ unregisterHooks();
759
+
760
+ // ── Step 5: Remove manifest ──
761
+
762
+ if (flagDryRun) {
763
+ if (fs.existsSync(manifestPath)) {
764
+ console.log(` ${c(dim, '[dry-run]')} rm ${manifestPath}`);
765
+ }
766
+ } else {
767
+ try {
768
+ if (fs.existsSync(manifestPath)) {
769
+ fs.unlinkSync(manifestPath);
770
+ logOk('Removed legion-file-manifest.json');
771
+ }
772
+ } catch (err) {
773
+ logWarn(`Could not remove manifest: ${err.message}`);
774
+ }
775
+ }
776
+
777
+ // ── Summary ──
778
+
779
+ console.log('');
780
+ console.log(` ${c(bold + green, 'Legion v' + version + ' uninstalled from ' + configDir + '/')}`);
781
+ console.log('');
782
+ console.log(` ${c(dim, 'Removed:')} ${removedCount} files`);
783
+ if (failedCount > 0) {
784
+ console.log(` ${c(yellow, 'Failed:')} ${failedCount} files`);
785
+ }
786
+ console.log('');
787
+ console.log(` ${c(dim, 'Note: Agent files (agents/) and project data (.planning/legion/) were NOT removed.')}`);
788
+ console.log('');
789
+ }
790
+
791
+ /**
792
+ * Recursively remove a directory and its contents.
793
+ */
794
+ function removeDir(dir) {
795
+ if (!fs.existsSync(dir)) return;
796
+
797
+ if (flagDryRun) {
798
+ const count = countFilesInDir(dir);
799
+ console.log(` ${c(dim, '[dry-run]')} rmdir ${dir} (${count} files)`);
800
+ return;
801
+ }
802
+
803
+ try {
804
+ fs.rmSync(dir, { recursive: true, force: true });
805
+ logOk(`Removed ${dir}`);
806
+ } catch (err) {
807
+ logWarn(`Could not remove ${dir}: ${err.message}`);
808
+ }
809
+ }
810
+
811
+ /**
812
+ * Remove Legion hooks from settings.json.
813
+ */
814
+ function unregisterHooks() {
815
+ let settingsPath = null;
816
+ const settingsLocalPath = path.join(configDir, 'settings.local.json');
817
+ const settingsJsonPath = path.join(configDir, 'settings.json');
818
+
819
+ if (fs.existsSync(settingsLocalPath)) {
820
+ settingsPath = settingsLocalPath;
821
+ } else if (fs.existsSync(settingsJsonPath)) {
822
+ settingsPath = settingsJsonPath;
823
+ }
824
+
825
+ if (!settingsPath) {
826
+ logSkip('No settings file found');
827
+ return;
828
+ }
829
+
830
+ const settings = readJSON(settingsPath);
831
+ if (!settings) {
832
+ logWarn(`Could not parse ${settingsPath}`);
833
+ return;
834
+ }
835
+
836
+ let changed = false;
837
+
838
+ // Remove PostToolUse hook entries that reference legion-context-monitor
839
+ if (settings.hooks && Array.isArray(settings.hooks.PostToolUse)) {
840
+ const before = settings.hooks.PostToolUse.length;
841
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(entry => {
842
+ const entryStr = JSON.stringify(entry);
843
+ return !entryStr.includes('legion-context-monitor');
844
+ });
845
+ if (settings.hooks.PostToolUse.length < before) {
846
+ changed = true;
847
+ logOk('Removed PostToolUse: legion-context-monitor.js');
848
+ }
849
+ // Clean up empty arrays/objects
850
+ if (settings.hooks.PostToolUse.length === 0) {
851
+ delete settings.hooks.PostToolUse;
852
+ }
853
+ if (Object.keys(settings.hooks).length === 0) {
854
+ delete settings.hooks;
855
+ }
856
+ }
857
+
858
+ // Remove statusLine if it references legion
859
+ if (settings.statusLine) {
860
+ const cmd = (typeof settings.statusLine === 'object')
861
+ ? (settings.statusLine.command || '')
862
+ : '';
863
+ if (cmd.includes('legion-statusline')) {
864
+ delete settings.statusLine;
865
+ changed = true;
866
+ logOk('Removed statusLine: legion-statusline.js');
867
+ }
868
+ }
869
+
870
+ if (changed) {
871
+ writeJSON(settingsPath, settings);
872
+ logOk(`Updated ${settingsPath}`);
873
+ } else {
874
+ logSkip('No Legion hooks found in settings');
875
+ }
876
+ }
877
+
878
+ // ─── Main ────────────────────────────────────────────────────────────────────
879
+
880
+ function main() {
881
+ try {
882
+ if (flagUninstall) {
883
+ doUninstall();
884
+ } else {
885
+ doInstall();
886
+ }
887
+ process.exit(0);
888
+ } catch (err) {
889
+ console.error('');
890
+ logErr(`Unexpected error: ${err.message}`);
891
+ if (err.stack) {
892
+ console.error(c(dim, err.stack));
893
+ }
894
+ process.exit(1);
895
+ }
896
+ }
897
+
898
+ main();