awesome-agv 3.0.0 → 3.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.
@@ -9,10 +9,9 @@ const path = require('path');
9
9
  const { createInterface } = require('readline');
10
10
  const { execSync } = require('child_process');
11
11
  const os = require('os');
12
- const { createGunzip } = require('zlib');
13
12
 
14
13
  // ── ANSI Colors (zero-dependency) ──────────────────────────────────────────────
15
- const color = {
14
+ const c = {
16
15
  reset: '\x1b[0m',
17
16
  bold: '\x1b[1m',
18
17
  dim: '\x1b[2m',
@@ -21,19 +20,22 @@ const color = {
21
20
  red: '\x1b[31m',
22
21
  cyan: '\x1b[36m',
23
22
  magenta: '\x1b[35m',
23
+ white: '\x1b[37m',
24
24
  };
25
25
 
26
- const icons = {
27
- check: `${color.green}✓${color.reset}`,
28
- warn: `${color.yellow}⚠${color.reset}`,
29
- error: `${color.red}✗${color.reset}`,
30
- arrow: `${color.cyan}→${color.reset}`,
26
+ const icon = {
27
+ check: `${c.green}✓${c.reset}`,
28
+ warn: `${c.yellow}⚠${c.reset}`,
29
+ error: `${c.red}✗${c.reset}`,
30
+ arrow: `${c.cyan}→${c.reset}`,
31
31
  rocket: '🚀',
32
32
  shield: '🛡️',
33
33
  gear: '⚙️',
34
34
  book: '📏',
35
35
  tool: '🛠️',
36
36
  cycle: '🔄',
37
+ target: '🎯',
38
+ bolt: '⚡',
37
39
  };
38
40
 
39
41
  // ── Constants ──────────────────────────────────────────────────────────────────
@@ -42,6 +44,7 @@ const REPO_NAME = 'awesome-agv';
42
44
  const BRANCH = 'main';
43
45
  const TARBALL_URL = `https://github.com/${REPO_OWNER}/${REPO_NAME}/archive/refs/heads/${BRANCH}.tar.gz`;
44
46
  const AGENT_DIR = '.agents';
47
+ const CONFIG_FILE = '.agvrc';
45
48
 
46
49
  // ── CLI Argument Parsing ───────────────────────────────────────────────────────
47
50
  function parseArgs(argv) {
@@ -50,13 +53,29 @@ function parseArgs(argv) {
50
53
  force: false,
51
54
  targetDir: process.cwd(),
52
55
  help: false,
56
+ all: false,
57
+ stacks: null,
53
58
  };
54
59
 
55
- for (const arg of args) {
60
+ for (let i = 0; i < args.length; i++) {
61
+ const arg = args[i];
56
62
  if (arg === '--force' || arg === '-f') {
57
63
  options.force = true;
58
64
  } else if (arg === '--help' || arg === '-h') {
59
65
  options.help = true;
66
+ } else if (arg === '--all') {
67
+ options.all = true;
68
+ } else if (arg === '--stacks') {
69
+ const next = args[i + 1];
70
+ if (next && !next.startsWith('-')) {
71
+ options.stacks = next.split(',').map((s) => s.trim().toLowerCase());
72
+ i++;
73
+ }
74
+ } else if (arg.startsWith('--stacks=')) {
75
+ options.stacks = arg
76
+ .slice('--stacks='.length)
77
+ .split(',')
78
+ .map((s) => s.trim().toLowerCase());
60
79
  } else if (!arg.startsWith('-')) {
61
80
  options.targetDir = path.resolve(arg);
62
81
  }
@@ -65,48 +84,81 @@ function parseArgs(argv) {
65
84
  return options;
66
85
  }
67
86
 
87
+ // ── Banner ─────────────────────────────────────────────────────────────────────
88
+ function printBanner() {
89
+ const { version } = require('../package.json');
90
+
91
+ // Gradient: magenta → cyan → white
92
+ const m = c.magenta + c.bold;
93
+ const cy = c.cyan + c.bold;
94
+ const w = c.white + c.bold;
95
+
96
+ console.log(`
97
+ ${m} █████╗ ██╗ ██╗███████╗███████╗ ██████╗ ███╗ ███╗███████╗${c.reset}
98
+ ${m} ██╔══██╗ ██║ ██║██╔════╝██╔════╝██╔═══██╗████╗ ████║██╔════╝${c.reset}
99
+ ${cy} ███████║ ██║ █╗ ██║█████╗ ███████╗██║ ██║██╔████╔██║█████╗${c.reset}
100
+ ${cy} ██╔══██║ ██║███╗██║██╔══╝ ╚════██║██║ ██║██║╚██╔╝██║██╔══╝${c.reset}
101
+ ${w} ██║ ██║ ╚███╔███╔╝███████╗███████║╚██████╔╝██║ ╚═╝ ██║███████╗${c.reset}
102
+ ${w} ╚═╝ ╚═╝ ╚══╝╚══╝╚══════╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝${c.reset}
103
+ ${cy} █████╗ ██████╗ ██╗ ██╗${c.reset}
104
+ ${w} ██╔══██╗██╔════╝ ██║ ██║${c.reset}
105
+ ${w} ███████║██║ ███╗██║ ██║${c.reset}
106
+ ${cy} ██╔══██║██║ ██║╚██╗ ██╔╝${c.reset}
107
+ ${m} ██║ ██║╚██████╔╝ ╚████╔╝${c.reset}
108
+ ${m} ╚═╝ ╚═╝ ╚═════╝ ╚═══╝${c.reset} ${c.dim}v${version}${c.reset}
109
+
110
+ ${icon.bolt} ${c.bold}Rugged AI Agent Configuration Suite${c.reset}
111
+ ${c.dim}─────────────────────────────────────────────────────${c.reset}
112
+ `);
113
+ }
114
+
68
115
  // ── Help Text ──────────────────────────────────────────────────────────────────
69
116
  function printHelp() {
117
+ const { version } = require('../package.json');
70
118
  console.log(`
71
- ${color.bold}awesome-agv${color.reset} — Install the Awesome AGV AI Agent configuration suite
119
+ ${c.bold}awesome-agv${c.reset} ${c.dim}v${version}${c.reset} — Install the Awesome AGV AI Agent configuration suite
72
120
 
73
- ${color.bold}USAGE${color.reset}
121
+ ${c.bold}USAGE${c.reset}
74
122
  npx awesome-agv [target-dir] [options]
75
123
 
76
- ${color.bold}ARGUMENTS${color.reset}
77
- target-dir Directory to install into (default: current directory)
124
+ ${c.bold}ARGUMENTS${c.reset}
125
+ target-dir Directory to install into (default: current directory)
78
126
 
79
- ${color.bold}OPTIONS${color.reset}
80
- -f, --force Overwrite existing .agents directory without prompting
81
- -h, --help Show this help message
127
+ ${c.bold}OPTIONS${c.reset}
128
+ --stacks <list> Comma-separated stacks to include (additive with existing .agvrc)
129
+ --all Install everything (all stacks, same as Default mode)
130
+ -f, --force Replace existing .agents/ — re-prompts from scratch
131
+ -h, --help Show this help message
82
132
 
83
- ${color.bold}EXAMPLES${color.reset}
84
- ${color.dim}# Install into current directory${color.reset}
133
+ ${c.bold}INSTALL MODES${c.reset}
134
+ ${icon.rocket} ${c.bold}Default${c.reset} Install everything. The full arsenal.
135
+ ${icon.target} ${c.bold}Curated${c.reset} Pick languages & frameworks. Core always included.
136
+ ${icon.gear} ${c.bold}Advanced${c.reset} Full control. Pick rules, skills, agents, everything.
137
+
138
+ ${c.bold}EXAMPLES${c.reset}
139
+ ${c.dim}# Interactive — choose your mode${c.reset}
85
140
  npx awesome-agv
86
141
 
87
- ${color.dim}# Install into a specific project${color.reset}
88
- npx awesome-agv ./my-project
142
+ ${c.dim}# Non-interactive: specific stacks, core always included${c.reset}
143
+ npx awesome-agv --stacks go,python
144
+
145
+ ${c.dim}# Add React to existing installation (additive)${c.reset}
146
+ npx awesome-agv --stacks react
89
147
 
90
- ${color.dim}# Overwrite existing installation${color.reset}
91
- npx awesome-agv --force
148
+ ${c.dim}# Full install, no prompts${c.reset}
149
+ npx awesome-agv --all --force
92
150
 
93
- ${color.bold}WHAT GETS INSTALLED${color.reset}
94
- ${icons.book} 42 Rules — Security, architecture, language idioms, DevOps standards
95
- ${icons.tool} 43 Skills — Debugging, design, code review, language idioms, and more
96
- ${icons.cycle} 12 Workflows — End-to-end development processes
97
- 🤖 15 Agents — Specialized personas for multi-agent orchestration
151
+ ${c.dim}# CI/CD: specific stacks, no prompts${c.reset}
152
+ npx awesome-agv --stacks typescript,vue --force
98
153
 
99
- ${color.dim}https://github.com/${REPO_OWNER}/${REPO_NAME}${color.reset}
154
+ ${c.dim}https://github.com/${REPO_OWNER}/${REPO_NAME}${c.reset}
100
155
  `);
101
156
  }
102
157
 
103
- // ── User Prompt ────────────────────────────────────────────────────────────────
158
+ // ── User Prompt (single-line question) ─────────────────────────────────────────
104
159
  function promptUser(question) {
105
160
  return new Promise((resolve) => {
106
- const rl = createInterface({
107
- input: process.stdin,
108
- output: process.stdout,
109
- });
161
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
110
162
  rl.question(question, (answer) => {
111
163
  rl.close();
112
164
  resolve(answer.trim().toLowerCase());
@@ -114,6 +166,364 @@ function promptUser(question) {
114
166
  });
115
167
  }
116
168
 
169
+ // ── Single-Select Menu (arrow keys + enter) ────────────────────────────────────
170
+ function promptSelect(title, options) {
171
+ return new Promise((resolve) => {
172
+ let selected = 0;
173
+
174
+ function render() {
175
+ // Move cursor up to clear previous render (except first render)
176
+ const totalLines = options.length + 2;
177
+ process.stdout.write(`\x1b[${totalLines}A\x1b[J`);
178
+ console.log(` ${c.bold}${title}${c.reset}\n`);
179
+ for (let i = 0; i < options.length; i++) {
180
+ const pointer = i === selected ? `${c.cyan}❯${c.reset}` : ' ';
181
+ const label =
182
+ i === selected
183
+ ? `${c.bold}${options[i].label}${c.reset}`
184
+ : `${c.dim}${options[i].label}${c.reset}`;
185
+ console.log(` ${pointer} ${label}`);
186
+ }
187
+ }
188
+
189
+ // Print initial blank lines so the first render can overwrite
190
+ console.log(` ${c.bold}${title}${c.reset}\n`);
191
+ for (const opt of options) {
192
+ console.log(` ${c.dim}${opt.label}${c.reset}`);
193
+ }
194
+ render();
195
+
196
+ process.stdin.setRawMode(true);
197
+ process.stdin.resume();
198
+ process.stdin.setEncoding('utf8');
199
+
200
+ const onData = (key) => {
201
+ if (key === '\x1b[A') {
202
+ // Up arrow
203
+ selected = (selected - 1 + options.length) % options.length;
204
+ render();
205
+ } else if (key === '\x1b[B') {
206
+ // Down arrow
207
+ selected = (selected + 1) % options.length;
208
+ render();
209
+ } else if (key === '\r' || key === '\n') {
210
+ // Enter
211
+ process.stdin.setRawMode(false);
212
+ process.stdin.pause();
213
+ process.stdin.removeListener('data', onData);
214
+ console.log('');
215
+ resolve(options[selected].value);
216
+ } else if (key === '\x03') {
217
+ // Ctrl+C
218
+ process.stdin.setRawMode(false);
219
+ process.exit(0);
220
+ }
221
+ };
222
+
223
+ process.stdin.on('data', onData);
224
+ });
225
+ }
226
+
227
+ // ── Multi-Select Menu (arrow keys, space, enter) ───────────────────────────────
228
+ function promptMultiSelect(title, items, footerFn) {
229
+ // items: [{ key, label, indent, checked, disabled, warning }]
230
+ return new Promise((resolve) => {
231
+ let cursor = 0;
232
+ // Skip to first non-disabled item
233
+ while (cursor < items.length && items[cursor].disabled) cursor++;
234
+
235
+ function render() {
236
+ const totalLines = items.length + 4; // title + blank + items + footer + blank
237
+ process.stdout.write(`\x1b[${totalLines}A\x1b[J`);
238
+ console.log(` ${c.bold}${title}${c.reset}`);
239
+ console.log('');
240
+ for (let i = 0; i < items.length; i++) {
241
+ const item = items[i];
242
+ const pointer = i === cursor ? `${c.cyan}❯${c.reset}` : ' ';
243
+ const indent = ' '.repeat(item.indent || 0);
244
+ let checkbox;
245
+ if (item.separator) {
246
+ console.log(` ${c.dim}${item.label}${c.reset}`);
247
+ continue;
248
+ }
249
+ if (item.disabled) {
250
+ checkbox = `${c.dim}◉${c.reset}`;
251
+ } else {
252
+ checkbox = item.checked
253
+ ? `${c.green}◉${c.reset}`
254
+ : `${c.dim}◯${c.reset}`;
255
+ }
256
+ const label =
257
+ i === cursor
258
+ ? `${c.bold}${item.label}${c.reset}`
259
+ : item.checked
260
+ ? `${item.label}`
261
+ : `${c.dim}${item.label}${c.reset}`;
262
+
263
+ console.log(` ${pointer} ${indent}${checkbox} ${label}`);
264
+ }
265
+ console.log('');
266
+ if (footerFn) {
267
+ console.log(` ${footerFn(items)}`);
268
+ } else {
269
+ console.log(` ${c.dim}(space to toggle, enter to confirm)${c.reset}`);
270
+ }
271
+ }
272
+
273
+ // Initial blank lines
274
+ console.log(` ${c.bold}${title}${c.reset}`);
275
+ console.log('');
276
+ for (const item of items) {
277
+ console.log('');
278
+ }
279
+ console.log('');
280
+ console.log('');
281
+ render();
282
+
283
+ process.stdin.setRawMode(true);
284
+ process.stdin.resume();
285
+ process.stdin.setEncoding('utf8');
286
+
287
+ const onData = (key) => {
288
+ if (key === '\x1b[A') {
289
+ // Up — skip separators and disabled
290
+ let next = (cursor - 1 + items.length) % items.length;
291
+ let attempts = 0;
292
+ while ((items[next].separator || items[next].disabled) && attempts < items.length) {
293
+ next = (next - 1 + items.length) % items.length;
294
+ attempts++;
295
+ }
296
+ cursor = next;
297
+ render();
298
+ } else if (key === '\x1b[B') {
299
+ // Down — skip separators and disabled
300
+ let next = (cursor + 1) % items.length;
301
+ let attempts = 0;
302
+ while ((items[next].separator || items[next].disabled) && attempts < items.length) {
303
+ next = (next + 1) % items.length;
304
+ attempts++;
305
+ }
306
+ cursor = next;
307
+ render();
308
+ } else if (key === ' ') {
309
+ // Space — toggle
310
+ const item = items[cursor];
311
+ if (!item.separator && !item.disabled) {
312
+ item.checked = !item.checked;
313
+ // Handle requires: selecting a child auto-selects parent
314
+ if (item.checked && item.requires) {
315
+ for (const reqKey of item.requires) {
316
+ const parent = items.find((i) => i.key === reqKey);
317
+ if (parent && !parent.checked) {
318
+ parent.checked = true;
319
+ }
320
+ }
321
+ }
322
+ // Handle requires: deselecting a parent auto-deselects all transitive children
323
+ if (!item.checked) {
324
+ let cascadeChanged = true;
325
+ while (cascadeChanged) {
326
+ cascadeChanged = false;
327
+ for (const child of items) {
328
+ if (child.checked && child.requires) {
329
+ // If any required parent is unchecked, deselect this child
330
+ const allReqsMet = child.requires.every((reqKey) => {
331
+ const parent = items.find((i) => i.key === reqKey);
332
+ return parent && parent.checked;
333
+ });
334
+ if (!allReqsMet) {
335
+ child.checked = false;
336
+ cascadeChanged = true;
337
+ }
338
+ }
339
+ }
340
+ }
341
+ }
342
+ render();
343
+ }
344
+ } else if (key === '\r' || key === '\n') {
345
+ // Enter — confirm
346
+ process.stdin.setRawMode(false);
347
+ process.stdin.pause();
348
+ process.stdin.removeListener('data', onData);
349
+ console.log('');
350
+ resolve(items.filter((i) => i.checked && !i.separator));
351
+ } else if (key === '\x03') {
352
+ process.stdin.setRawMode(false);
353
+ process.exit(0);
354
+ }
355
+ };
356
+
357
+ process.stdin.on('data', onData);
358
+ });
359
+ }
360
+
361
+ // ── Auto-Detection ─────────────────────────────────────────────────────────────
362
+ function detectStacks(targetDir, manifest) {
363
+ const detected = new Set();
364
+ const stacks = manifest.skills.stacks;
365
+
366
+ // Gather all detection patterns
367
+ const detectionMap = {};
368
+ for (const [key, stack] of Object.entries(stacks)) {
369
+ if (!stack.detect || stack.detect.length === 0) continue;
370
+ for (const pattern of stack.detect) {
371
+ if (!pattern.includes('*')) {
372
+ // Exact filename match
373
+ if (!detectionMap[pattern]) detectionMap[pattern] = [];
374
+ detectionMap[pattern].push(key);
375
+ }
376
+ }
377
+ }
378
+
379
+ // Scan root directory
380
+ const scanDirs = [targetDir];
381
+ const subDirs = ['apps', 'src', 'packages', 'services'];
382
+ for (const sub of subDirs) {
383
+ const subPath = path.join(targetDir, sub);
384
+ if (fs.existsSync(subPath) && fs.statSync(subPath).isDirectory()) {
385
+ scanDirs.push(subPath);
386
+ // One more level for monorepo detection
387
+ try {
388
+ for (const entry of fs.readdirSync(subPath, { withFileTypes: true })) {
389
+ if (entry.isDirectory()) {
390
+ scanDirs.push(path.join(subPath, entry.name));
391
+ }
392
+ }
393
+ } catch {
394
+ // Permission error, skip
395
+ }
396
+ }
397
+ }
398
+
399
+ for (const dir of scanDirs) {
400
+ let entries;
401
+ try {
402
+ entries = fs.readdirSync(dir);
403
+ } catch {
404
+ continue;
405
+ }
406
+ for (const entry of entries) {
407
+ // Check exact filename match
408
+ if (detectionMap[entry]) {
409
+ for (const stackKey of detectionMap[entry]) {
410
+ detected.add(stackKey);
411
+ }
412
+ }
413
+ // Check glob patterns (*.ext)
414
+ for (const [key, stack] of Object.entries(stacks)) {
415
+ if (!stack.detect) continue;
416
+ for (const pattern of stack.detect) {
417
+ if (pattern.startsWith('*.')) {
418
+ const ext = pattern.slice(1); // e.g. ".vue"
419
+ if (entry.endsWith(ext)) {
420
+ detected.add(key);
421
+ }
422
+ }
423
+ }
424
+ }
425
+ }
426
+ }
427
+
428
+ return detected;
429
+ }
430
+
431
+ // ── Dependency Resolution ──────────────────────────────────────────────────────
432
+ function resolveRequires(selectedKeys, manifest) {
433
+ const stacks = manifest.skills.stacks;
434
+ const resolved = new Set(selectedKeys);
435
+ let changed = true;
436
+ while (changed) {
437
+ changed = false;
438
+ for (const key of [...resolved]) {
439
+ const stack = stacks[key];
440
+ if (stack && stack.requires) {
441
+ for (const req of stack.requires) {
442
+ if (!resolved.has(req)) {
443
+ resolved.add(req);
444
+ changed = true;
445
+ }
446
+ }
447
+ }
448
+ }
449
+ }
450
+ return resolved;
451
+ }
452
+
453
+ // ── Build Skills Set ───────────────────────────────────────────────────────────
454
+ function buildSkillsSet(selectedStackKeys, manifest) {
455
+ const skills = new Set(manifest.skills.core);
456
+ const stacks = manifest.skills.stacks;
457
+ for (const key of selectedStackKeys) {
458
+ const stack = stacks[key];
459
+ if (stack && stack.skills) {
460
+ for (const skill of stack.skills) {
461
+ skills.add(skill);
462
+ }
463
+ }
464
+ }
465
+ return skills;
466
+ }
467
+
468
+ // ── Build Stack Tree for Display ───────────────────────────────────────────────
469
+ function buildStackTree(manifest) {
470
+ const stacks = manifest.skills.stacks;
471
+ const tree = [];
472
+
473
+ // Separate languages (no requires) from frameworks (has requires)
474
+ const languages = {};
475
+ const frameworks = {};
476
+
477
+ for (const [key, stack] of Object.entries(stacks)) {
478
+ if (stack.requires && stack.requires.length > 0) {
479
+ frameworks[key] = stack;
480
+ } else {
481
+ languages[key] = stack;
482
+ }
483
+ }
484
+
485
+ // Group: core languages first, then community
486
+ const coreLanguages = Object.entries(languages).filter(([, s]) => s.group === 'core');
487
+ const communityLanguages = Object.entries(languages).filter(([, s]) => s.group === 'community');
488
+
489
+ // Find direct children of a language
490
+ function getChildren(parentKey) {
491
+ const children = [];
492
+ for (const [key, fw] of Object.entries(frameworks)) {
493
+ if (fw.requires.includes(parentKey)) {
494
+ children.push([key, fw]);
495
+ }
496
+ }
497
+ return children;
498
+ }
499
+
500
+
501
+ // Add a language and its framework children recursively
502
+ function addWithChildren(key, stack, indent) {
503
+ tree.push({ key, indent, label: `${stack.label.padEnd(14)}— ${stack.description}`, requires: stack.requires || null });
504
+ const children = getChildren(key);
505
+ for (const [childKey, childStack] of children) {
506
+ addWithChildren(childKey, childStack, indent + 1);
507
+ }
508
+ }
509
+
510
+ // Core stacks section
511
+ tree.push({ separator: true, label: '── Core Stacks ──────────────────────────────────────' });
512
+ for (const [key, stack] of coreLanguages) {
513
+ addWithChildren(key, stack, 0);
514
+ }
515
+
516
+ // Community languages section
517
+ if (communityLanguages.length > 0) {
518
+ tree.push({ separator: true, label: '── Community Languages ──────────────────────────────' });
519
+ for (const [key, stack] of communityLanguages) {
520
+ addWithChildren(key, stack, 0);
521
+ }
522
+ }
523
+
524
+ return tree;
525
+ }
526
+
117
527
  // ── HTTP Download with Redirect Following ──────────────────────────────────────
118
528
  function downloadToFile(url, destPath, maxRedirects = 5) {
119
529
  return new Promise((resolve, reject) => {
@@ -125,9 +535,8 @@ function downloadToFile(url, destPath, maxRedirects = 5) {
125
535
  const client = url.startsWith('https') ? https : http;
126
536
  client
127
537
  .get(url, (res) => {
128
- // Follow redirects (GitHub returns 302)
129
538
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
130
- res.resume(); // Consume response to free up memory
539
+ res.resume();
131
540
  downloadToFile(res.headers.location, destPath, maxRedirects - 1)
132
541
  .then(resolve)
133
542
  .catch(reject);
@@ -154,66 +563,113 @@ function downloadToFile(url, destPath, maxRedirects = 5) {
154
563
  });
155
564
  }
156
565
 
157
- // ── Extract .agents directory from tarball ──────────────────────────────────────
158
- function extractAgentDir(tarballPath, targetDir) {
159
- // The tarball from GitHub has a root directory like: awesome-agv-main/
160
- // We need to extract awesome-agv-main/.agents/ targetDir/.agents/
161
- const stripPrefix = `${REPO_NAME}-${BRANCH}/${AGENT_DIR}`;
162
-
163
- const agentTargetDir = path.join(targetDir, AGENT_DIR);
164
-
165
- // Ensure target exists
166
- fs.mkdirSync(agentTargetDir, { recursive: true });
167
-
168
- try {
169
- // Use system tar (available on macOS and Linux)
170
- execSync(
171
- `tar -xzf "${tarballPath}" --strip-components=2 -C "${agentTargetDir}" "${stripPrefix}"`,
172
- { stdio: 'pipe' }
173
- );
174
- } catch {
175
- // Fallback: manual extraction using Node.js streams
176
- extractManually(tarballPath, targetDir, stripPrefix);
566
+ // ── Recursive directory copy ───────────────────────────────────────────────────
567
+ function copyDirRecursive(src, dest) {
568
+ fs.mkdirSync(dest, { recursive: true });
569
+ const entries = fs.readdirSync(src, { withFileTypes: true });
570
+ for (const entry of entries) {
571
+ const srcPath = path.join(src, entry.name);
572
+ const destPath = path.join(dest, entry.name);
573
+ if (entry.isDirectory()) {
574
+ copyDirRecursive(srcPath, destPath);
575
+ } else {
576
+ fs.copyFileSync(srcPath, destPath);
577
+ }
177
578
  }
178
579
  }
179
580
 
180
- // ── Manual tar extraction fallback (for Windows or missing tar) ────────────────
181
- function extractManually(tarballPath, targetDir, stripPrefix) {
182
- // Read the gzipped tarball
183
- const gzipData = fs.readFileSync(tarballPath);
581
+ // ── Count files recursively ────────────────────────────────────────────────────
582
+ function countFiles(dirPath) {
583
+ let count = 0;
584
+ if (!fs.existsSync(dirPath)) return count;
585
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
586
+ for (const entry of entries) {
587
+ const fullPath = path.join(dirPath, entry.name);
588
+ if (entry.isDirectory()) {
589
+ count += countFiles(fullPath);
590
+ } else {
591
+ count++;
592
+ }
593
+ }
594
+ return count;
595
+ }
184
596
 
185
- // Decompress using zlib
186
- const { execFileSync } = require('child_process');
597
+ // ── Extract with filtering ─────────────────────────────────────────────────────
598
+ // Returns the number of skill directories actually copied from the archive.
599
+ function extractFiltered(tarballPath, targetDir, selectedSkills, config) {
187
600
  const tmpExtractDir = path.join(os.tmpdir(), `awesome-agv-extract-${Date.now()}`);
188
601
  fs.mkdirSync(tmpExtractDir, { recursive: true });
602
+ let skillsCopied = 0;
189
603
 
190
604
  try {
191
- // Try using tar with different flags (Windows Git Bash compatibility)
192
- execFileSync('tar', ['-xzf', tarballPath, '-C', tmpExtractDir], { stdio: 'pipe' });
193
-
194
- // Find the extracted .agent directory
195
- const extractedRoot = path.join(tmpExtractDir, `${REPO_NAME}-${BRANCH}`, AGENT_DIR);
605
+ // Extract full tarball to temp
606
+ try {
607
+ execSync(`tar -xzf "${tarballPath}" -C "${tmpExtractDir}"`, { stdio: 'pipe' });
608
+ } catch {
609
+ const { execFileSync } = require('child_process');
610
+ execFileSync('tar', ['-xzf', tarballPath, '-C', tmpExtractDir], { stdio: 'pipe' });
611
+ }
196
612
 
197
- if (fs.existsSync(extractedRoot)) {
198
- copyDirRecursive(extractedRoot, path.join(targetDir, AGENT_DIR));
199
- } else {
613
+ const extractedAgents = path.join(tmpExtractDir, `${REPO_NAME}-${BRANCH}`, AGENT_DIR);
614
+ if (!fs.existsSync(extractedAgents)) {
200
615
  throw new Error(`Could not find ${AGENT_DIR} in downloaded archive`);
201
616
  }
617
+
618
+ const destAgents = path.join(targetDir, AGENT_DIR);
619
+ fs.mkdirSync(destAgents, { recursive: true });
620
+
621
+ // Copy core paths (rules/, workflows/, agents/)
622
+ for (const corePath of config.core.paths) {
623
+ const src = path.join(extractedAgents, corePath);
624
+ const dest = path.join(destAgents, corePath);
625
+ if (fs.existsSync(src)) {
626
+ // If advanced mode deselected items, filter them
627
+ if (config._deselectedRules && corePath === 'rules/') {
628
+ copyDirFiltered(src, dest, config._deselectedRules);
629
+ } else if (config._deselectedAgents && corePath === 'agents/') {
630
+ copyDirFiltered(src, dest, config._deselectedAgents);
631
+ } else if (config._deselectedWorkflows && corePath === 'workflows/') {
632
+ copyDirFiltered(src, dest, config._deselectedWorkflows);
633
+ } else {
634
+ copyDirRecursive(src, dest);
635
+ }
636
+ }
637
+ }
638
+
639
+ // Copy selected skills only
640
+ const skillsSrc = path.join(extractedAgents, 'skills');
641
+ const skillsDest = path.join(destAgents, 'skills');
642
+ fs.mkdirSync(skillsDest, { recursive: true });
643
+
644
+ if (fs.existsSync(skillsSrc)) {
645
+ const allSkillDirs = fs.readdirSync(skillsSrc, { withFileTypes: true });
646
+ for (const entry of allSkillDirs) {
647
+ if (entry.isDirectory() && selectedSkills.has(entry.name)) {
648
+ copyDirRecursive(
649
+ path.join(skillsSrc, entry.name),
650
+ path.join(skillsDest, entry.name)
651
+ );
652
+ skillsCopied++;
653
+ }
654
+ }
655
+ }
202
656
  } finally {
203
- // Clean up temp extraction dir
204
657
  fs.rmSync(tmpExtractDir, { recursive: true, force: true });
205
658
  }
659
+
660
+ return skillsCopied;
206
661
  }
207
662
 
208
- // ── Recursive directory copy ───────────────────────────────────────────────────
209
- function copyDirRecursive(src, dest) {
663
+ // ── Copy directory with exclusion filter ───────────────────────────────────────
664
+ function copyDirFiltered(src, dest, excludeSet) {
210
665
  fs.mkdirSync(dest, { recursive: true });
211
-
212
666
  const entries = fs.readdirSync(src, { withFileTypes: true });
213
667
  for (const entry of entries) {
668
+ const baseName = entry.name.replace(/\.md$/, '');
669
+ if (excludeSet.has(baseName)) continue;
670
+
214
671
  const srcPath = path.join(src, entry.name);
215
672
  const destPath = path.join(dest, entry.name);
216
-
217
673
  if (entry.isDirectory()) {
218
674
  copyDirRecursive(srcPath, destPath);
219
675
  } else {
@@ -222,65 +678,89 @@ function copyDirRecursive(src, dest) {
222
678
  }
223
679
  }
224
680
 
225
- // ── Count files recursively ────────────────────────────────────────────────────
226
- function countFiles(dirPath) {
227
- let count = 0;
228
- if (!fs.existsSync(dirPath)) return count;
681
+ // ── Write .agvrc ───────────────────────────────────────────────────────────────
682
+ function writeAgvrc(targetDir, mode, selectedStacks, deselected) {
683
+ const rcPath = path.join(targetDir, AGENT_DIR, CONFIG_FILE);
684
+ const config = {
685
+ version: require('../package.json').version,
686
+ mode,
687
+ stacks: [...selectedStacks].sort(),
688
+ deselected_rules: deselected.rules ? [...deselected.rules].sort() : [],
689
+ deselected_core_skills: deselected.coreSkills ? [...deselected.coreSkills].sort() : [],
690
+ deselected_agents: deselected.agents ? [...deselected.agents].sort() : [],
691
+ deselected_workflows: deselected.workflows ? [...deselected.workflows].sort() : [],
692
+ installed_at: new Date().toISOString(),
693
+ };
694
+ fs.writeFileSync(rcPath, JSON.stringify(config, null, 2) + '\n');
695
+ }
229
696
 
230
- const entries = fs.readdirSync(dirPath, { withFileTypes: true });
231
- for (const entry of entries) {
232
- const fullPath = path.join(dirPath, entry.name);
233
- if (entry.isDirectory()) {
234
- count += countFiles(fullPath);
235
- } else {
236
- count++;
237
- }
697
+ // ── Read existing .agvrc ───────────────────────────────────────────────────────
698
+ function readAgvrc(targetDir) {
699
+ const rcPath = path.join(targetDir, AGENT_DIR, CONFIG_FILE);
700
+ if (!fs.existsSync(rcPath)) return null;
701
+ try {
702
+ return JSON.parse(fs.readFileSync(rcPath, 'utf8'));
703
+ } catch {
704
+ return null;
238
705
  }
239
- return count;
240
706
  }
241
707
 
242
- // ── Print Banner ───────────────────────────────────────────────────────────────
243
- function printBanner() {
244
- // Load version from package.json
245
- const { version } = require('../package.json');
708
+ // ── Read manifest from extracted archive ───────────────────────────────────────
709
+ function readManifest(tarballPath) {
710
+ const tmpExtractDir = path.join(os.tmpdir(), `awesome-agv-manifest-${Date.now()}`);
711
+ fs.mkdirSync(tmpExtractDir, { recursive: true });
246
712
 
247
- const boxWidth = 44; // inner width between ║ and ║
248
- const border = '═'.repeat(boxWidth);
713
+ try {
714
+ // Extract only the manifest file
715
+ try {
716
+ execSync(
717
+ `tar -xzf "${tarballPath}" --strip-components=1 -C "${tmpExtractDir}" "${REPO_NAME}-${BRANCH}/agv.config.json"`,
718
+ { stdio: 'pipe' }
719
+ );
720
+ } catch {
721
+ // Fallback: extract everything and find it
722
+ try {
723
+ execSync(`tar -xzf "${tarballPath}" -C "${tmpExtractDir}"`, { stdio: 'pipe' });
724
+ } catch {
725
+ // Ignore extraction failures — we'll fall back to bundled
726
+ }
727
+ }
249
728
 
250
- const titleText = `awesome-agv v${version}`;
251
- const subtitleText = 'Awesome AGV AI Agent Configuration';
729
+ // Try both locations in the extracted archive
730
+ let manifestPath = path.join(tmpExtractDir, 'agv.config.json');
731
+ if (!fs.existsSync(manifestPath)) {
732
+ manifestPath = path.join(tmpExtractDir, `${REPO_NAME}-${BRANCH}`, 'agv.config.json');
733
+ }
252
734
 
253
- // Center-pad a visible string inside the box
254
- function padCenter(text, width) {
255
- const pad = width - text.length;
256
- const left = Math.floor(pad / 2);
257
- const right = pad - left;
258
- return ' '.repeat(left) + text + ' '.repeat(right);
259
- }
735
+ // Fallback: bundled copy shipped with the CLI package
736
+ if (!fs.existsSync(manifestPath)) {
737
+ // When installed via npm, layout is: cli/bin/awesome-agv.js + cli/agv.config.json
738
+ const bundledPath = path.resolve(__dirname, '..', 'agv.config.json');
739
+ if (fs.existsSync(bundledPath)) {
740
+ manifestPath = bundledPath;
741
+ }
742
+ }
260
743
 
261
- const titlePadded = padCenter(titleText, boxWidth);
262
- const subtitlePadded = padCenter(subtitleText, boxWidth);
744
+ // Fallback: repo root (when running from source checkout)
745
+ if (!fs.existsSync(manifestPath)) {
746
+ const repoRootPath = path.resolve(__dirname, '..', '..', 'agv.config.json');
747
+ if (fs.existsSync(repoRootPath)) {
748
+ manifestPath = repoRootPath;
749
+ }
750
+ }
263
751
 
264
- // Build colored title: split at the version part for coloring
265
- const titleColored = titlePadded.replace(
266
- titleText,
267
- `${color.magenta}awesome-agv${color.cyan}${color.bold} ${color.dim}v${version}${color.cyan}${color.bold}`
268
- );
269
- const subtitleColored = subtitlePadded.replace(
270
- subtitleText,
271
- `${color.reset}${color.dim}${subtitleText}${color.cyan}${color.bold}`
272
- );
752
+ if (!fs.existsSync(manifestPath)) {
753
+ throw new Error('agv.config.json not found in archive or CLI bundle');
754
+ }
273
755
 
274
- console.log(`
275
- ${color.bold}${color.cyan} ╔${border}╗
276
- ║${titleColored}
277
- ║${subtitleColored}
278
- ╚${border}╝${color.reset}
279
- `);
756
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
757
+ } finally {
758
+ fs.rmSync(tmpExtractDir, { recursive: true, force: true });
759
+ }
280
760
  }
281
761
 
282
762
  // ── Print Success Summary ──────────────────────────────────────────────────────
283
- function printSuccess(targetDir) {
763
+ function printSuccess(targetDir, mode, selectedStacks) {
284
764
  const agentDir = path.join(targetDir, AGENT_DIR);
285
765
  const rulesDir = path.join(agentDir, 'rules');
286
766
  const skillsDir = path.join(agentDir, 'skills');
@@ -301,27 +781,40 @@ function printSuccess(targetDir) {
301
781
  : 0;
302
782
  const totalFiles = countFiles(agentDir);
303
783
 
784
+ const modeLabel =
785
+ mode === 'default'
786
+ ? `${icon.rocket} Default`
787
+ : mode === 'curated'
788
+ ? `${icon.target} Curated`
789
+ : `${icon.gear} Advanced`;
790
+
304
791
  console.log(`
305
- ${color.green}${color.bold} Installation complete! ${icons.rocket}${color.reset}
792
+ ${c.green}${c.bold} Installation complete! ${icon.rocket}${c.reset}
306
793
 
307
- ${icons.check} ${color.bold}${totalFiles} files${color.reset} installed to ${color.cyan}${path.relative(process.cwd(), agentDir) || AGENT_DIR}/${color.reset}
794
+ ${icon.check} ${c.bold}${totalFiles} files${c.reset} installed to ${c.cyan}${path.relative(process.cwd(), agentDir) || AGENT_DIR}/${c.reset}
795
+ ${c.dim}Mode: ${modeLabel}${c.reset}
308
796
 
309
- ${icons.book} ${color.bold}${rulesCount}${color.reset} Rules ${color.dim}Security, architecture, testing standards${color.reset}
310
- ${icons.tool} ${color.bold}${skillsCount}${color.reset} Skills ${color.dim}Debugging, design, code review, language idioms${color.reset}
311
- ${icons.cycle} ${color.bold}${workflowsCount}${color.reset} Workflows ${color.dim}End-to-end dev processes${color.reset}
312
- 🤖 ${color.bold}${agentsCount}${color.reset} Agents ${color.dim}Specialized multi-agent personas${color.reset}
797
+ ${icon.book} ${c.bold}${rulesCount}${c.reset} Rules ${c.dim}Security, architecture, testing standards${c.reset}
798
+ ${icon.tool} ${c.bold}${skillsCount}${c.reset} Skills ${c.dim}Debugging, design, code review, language idioms${c.reset}
799
+ ${icon.cycle} ${c.bold}${workflowsCount}${c.reset} Workflows ${c.dim}End-to-end dev processes${c.reset}
800
+ 🤖 ${c.bold}${agentsCount}${c.reset} Agents ${c.dim}Specialized multi-agent personas${c.reset}
801
+ `);
802
+
803
+ if (selectedStacks && selectedStacks.size > 0) {
804
+ const stackLabels = [...selectedStacks].sort().join(', ');
805
+ console.log(` ${icon.arrow} ${c.dim}Stacks: ${stackLabels}${c.reset}`);
806
+ }
313
807
 
314
- ${icons.arrow} ${color.dim}Your AI agent will automatically pick up the${color.reset}
315
- ${color.dim}${AGENT_DIR}/ directory. No additional configuration needed.${color.reset}
808
+ console.log(`
809
+ ${icon.arrow} ${c.dim}Your AI agent will automatically pick up the${c.reset}
810
+ ${c.dim}${AGENT_DIR}/ directory. No additional configuration needed.${c.reset}
316
811
 
317
- 💡 ${color.bold}Quick start${color.reset} — open a chat with your agent and try:
318
- ${color.cyan}/workflow-solo${color.reset} ${color.dim}Build a feature end-to-end (single agent)${color.reset}
319
- ${color.cyan}/workflow-team${color.reset} ${color.dim}Build with multi-agent orchestration${color.reset}
320
- ${color.cyan}/audit${color.reset} ${color.dim}Review code quality${color.reset}
321
- ${color.cyan}/bugfix${color.reset} ${color.dim}Fix a bug fast${color.reset}
322
- ${color.dim}Rules and skills are modular — use with or without workflows.${color.reset}
812
+ 💡 ${c.bold}Quick start${c.reset} — open a chat with your agent and try:
813
+ ${c.cyan}/workflow-solo${c.reset} ${c.dim}Build a feature end-to-end (single agent)${c.reset}
814
+ ${c.cyan}/workflow-team${c.reset} ${c.dim}Build with multi-agent orchestration${c.reset}
815
+ ${c.cyan}/audit${c.reset} ${c.dim}Review code quality${c.reset}
323
816
 
324
- ${icons.shield} ${color.dim}Learn more: ${color.cyan}https://github.com/${REPO_OWNER}/${REPO_NAME}${color.reset}
817
+ ${icon.shield} ${c.dim}Learn more: ${c.cyan}https://github.com/${REPO_OWNER}/${REPO_NAME}${c.reset}
325
818
  `);
326
819
  }
327
820
 
@@ -338,52 +831,191 @@ async function main() {
338
831
 
339
832
  const targetAgentDir = path.join(options.targetDir, AGENT_DIR);
340
833
 
341
- // Check if .agent already exists
342
- if (fs.existsSync(targetAgentDir)) {
343
- if (!options.force) {
344
- console.log(
345
- ` ${icons.warn} ${color.yellow}An existing ${AGENT_DIR}/ directory was found at:${color.reset}`
346
- );
347
- console.log(` ${color.dim}${targetAgentDir}${color.reset}\n`);
834
+ // ── Check existing .agvrc for additive stacks ──
835
+ const existingRc = readAgvrc(options.targetDir);
348
836
 
349
- const answer = await promptUser(
350
- ` ${color.bold}Overwrite? ${color.reset}${color.dim}(y/N)${color.reset} `
351
- );
837
+ // ── Handle additive --stacks with existing config ──
838
+ if (options.stacks && existingRc && !options.force) {
839
+ console.log(` ${icon.arrow} Found existing config (${existingRc.mode}: ${existingRc.stacks.join(', ')})`);
840
+ console.log(` ${icon.arrow} Adding stacks: ${options.stacks.join(', ')}`);
841
+ const mergedStacks = new Set([...existingRc.stacks, ...options.stacks]);
842
+ options.stacks = [...mergedStacks];
843
+ options._additive = true;
844
+ }
352
845
 
353
- if (answer !== 'y' && answer !== 'yes') {
354
- console.log(`\n ${icons.arrow} ${color.dim}Installation cancelled.${color.reset}\n`);
355
- process.exit(0);
846
+ // ── Handle existing .agents directory ──
847
+ if (fs.existsSync(targetAgentDir)) {
848
+ if (!options.force && !options._additive) {
849
+ // Non-TTY environments can't prompt — auto-overwrite
850
+ if (!process.stdin.isTTY) {
851
+ console.log(` ${icon.arrow} ${c.dim}Existing ${AGENT_DIR}/ found — overwriting (non-interactive mode).${c.reset}`);
852
+ } else if (existingRc && !options.stacks && !options.all) {
853
+ // Offer to re-use existing config
854
+ const stackList = existingRc.stacks.join(', ');
855
+ console.log(
856
+ ` ${icon.arrow} ${c.dim}Found existing config${c.reset} ${c.bold}(${existingRc.mode}: ${stackList})${c.reset}`
857
+ );
858
+ const answer = await promptUser(
859
+ ` ${c.bold}Re-install with same config?${c.reset} ${c.dim}(Y/n)${c.reset} `
860
+ );
861
+
862
+ if (answer === '' || answer === 'y' || answer === 'yes') {
863
+ options.stacks = existingRc.stacks;
864
+ options._mode = existingRc.mode;
865
+ }
866
+ }
867
+
868
+ if (process.stdin.isTTY && !options.stacks && !options.all) {
869
+ console.log(
870
+ ` ${icon.warn} ${c.yellow}An existing ${AGENT_DIR}/ directory was found at:${c.reset}`
871
+ );
872
+ console.log(` ${c.dim}${targetAgentDir}${c.reset}\n`);
873
+ const answer = await promptUser(
874
+ ` ${c.bold}Overwrite? ${c.reset}${c.dim}(y/N)${c.reset} `
875
+ );
876
+ if (answer !== 'y' && answer !== 'yes') {
877
+ console.log(`\n ${icon.arrow} ${c.dim}Installation cancelled.${c.reset}\n`);
878
+ process.exit(0);
879
+ }
356
880
  }
357
881
  }
358
882
 
359
- // Remove existing .agent directory
360
- console.log(` ${icons.arrow} Removing existing ${AGENT_DIR}/ directory...`);
883
+ console.log(` ${icon.arrow} Removing existing ${AGENT_DIR}/ directory...`);
361
884
  fs.rmSync(targetAgentDir, { recursive: true, force: true });
362
885
  }
363
886
 
364
- // Download tarball to a temp file
887
+ // ── Download ──
365
888
  const tmpDir = os.tmpdir();
366
889
  const tarballPath = path.join(tmpDir, `awesome-agv-${Date.now()}.tar.gz`);
367
890
 
368
891
  try {
369
- console.log(` ${icons.arrow} Fetching latest configuration from GitHub...`);
892
+ console.log(` ${icon.arrow} Fetching latest configuration from GitHub...`);
370
893
  await downloadToFile(TARBALL_URL, tarballPath);
371
894
 
372
- console.log(` ${icons.arrow} Extracting ${AGENT_DIR}/ directory...`);
373
- extractAgentDir(tarballPath, options.targetDir);
895
+ // ── Read manifest ──
896
+ console.log(` ${icon.arrow} Reading manifest...`);
897
+ const manifest = readManifest(tarballPath);
898
+
899
+ // ── Determine what to install ──
900
+ let mode = options._mode || 'default';
901
+ let selectedStackKeys;
902
+ let deselected = { rules: null, coreSkills: null, agents: null, workflows: null };
903
+ let extractConfig = { ...manifest, _deselectedRules: null, _deselectedAgents: null, _deselectedWorkflows: null };
904
+
905
+ if (options.all) {
906
+ // ── Default: install everything ──
907
+ mode = 'default';
908
+ selectedStackKeys = new Set(Object.keys(manifest.skills.stacks));
909
+ } else if (options.stacks) {
910
+ // ── Non-interactive stacks ──
911
+ mode = options._mode || 'curated';
912
+ // Validate stack names against manifest
913
+ const validStackKeys = new Set(Object.keys(manifest.skills.stacks));
914
+ const unknownStacks = options.stacks.filter((s) => !validStackKeys.has(s));
915
+ if (unknownStacks.length > 0) {
916
+ console.log(` ${icon.warn} ${c.yellow}Unknown stack(s): ${unknownStacks.join(', ')}${c.reset}`);
917
+ console.log(` ${c.dim} Available: ${[...validStackKeys].sort().join(', ')}${c.reset}`);
918
+ console.log('');
919
+ }
920
+ const validStacks = options.stacks.filter((s) => validStackKeys.has(s));
921
+ const resolvedStacks = resolveRequires(new Set(validStacks), manifest);
922
+ selectedStackKeys = resolvedStacks;
923
+ } else {
924
+ // ── Interactive mode selection (requires TTY) ──
925
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
926
+ // Non-interactive environment (CI, piped input) — default to full install
927
+ console.log(` ${icon.arrow} ${c.dim}Non-interactive environment detected — defaulting to full install.${c.reset}`);
928
+ console.log(` ${c.dim} Use --stacks <list> or --all to control installation non-interactively.${c.reset}`);
929
+ console.log('');
930
+ mode = 'default';
931
+ selectedStackKeys = new Set(Object.keys(manifest.skills.stacks));
932
+ } else {
933
+ mode = await promptSelect('How would you like to install?', [
934
+ { value: 'default', label: `${icon.rocket} ${c.bold}Default${c.reset} — Install everything. The full arsenal. ${c.dim}(recommended)${c.reset}` },
935
+ { value: 'curated', label: `${icon.target} ${c.bold}Curated${c.reset} — Pick your languages & frameworks. Core always included.` },
936
+ { value: 'advanced', label: `${icon.gear} ${c.bold}Advanced${c.reset} — Full control. Pick rules, skills, agents, everything.` },
937
+ ]);
938
+
939
+ if (mode === 'default') {
940
+ selectedStackKeys = new Set(Object.keys(manifest.skills.stacks));
941
+ } else {
942
+ // ── Auto-detect stacks ──
943
+ const detected = detectStacks(options.targetDir, manifest);
944
+ if (detected.size > 0) {
945
+ console.log(` ${c.dim}Auto-detected from project files:${c.reset}`);
946
+ for (const key of detected) {
947
+ const stack = manifest.skills.stacks[key];
948
+ const detectedFile = stack.detect?.[0] || '';
949
+ console.log(` ${icon.check} ${c.bold}${stack.label}${c.reset} ${c.dim}(found ${detectedFile})${c.reset}`);
950
+ }
951
+ console.log('');
952
+ }
953
+
954
+ // ── Build stack selection tree ──
955
+ const treeItems = buildStackTree(manifest);
956
+ const selectItems = treeItems.map((item) => {
957
+ if (item.separator) return item;
958
+ return {
959
+ ...item,
960
+ checked: detected.has(item.key),
961
+ disabled: false,
962
+ };
963
+ });
964
+
965
+ const selectedItems = await promptMultiSelect(
966
+ mode === 'curated'
967
+ ? `${icon.target} Curated — Pick your stacks. Core is always included.`
968
+ : `${icon.gear} Advanced — Select stacks:`,
969
+ selectItems,
970
+ (items) => {
971
+ const count = items.filter((i) => i.checked && !i.separator).length;
972
+ return `${c.dim}${count} stack(s) selected · (space to toggle, enter to confirm)${c.reset}`;
973
+ }
974
+ );
975
+
976
+ const rawKeys = new Set(selectedItems.map((i) => i.key));
977
+ selectedStackKeys = resolveRequires(rawKeys, manifest);
978
+
979
+ // ── Advanced mode: granular selection (future) ──
980
+ if (mode === 'advanced') {
981
+ console.log(` ${c.dim}Advanced mode: all rules, agents, and workflows are included.${c.reset}`);
982
+ console.log(` ${c.dim}To customize further, edit ${AGENT_DIR}/ after installation.${c.reset}`);
983
+ console.log('');
984
+ }
985
+ }
986
+ } // end TTY block
987
+ }
988
+
989
+
990
+ // ── Build final skill set ──
991
+ const selectedSkills = buildSkillsSet(selectedStackKeys, manifest);
992
+
993
+ // Handle advanced mode deselected core skills
994
+ if (deselected.coreSkills && deselected.coreSkills.size > 0) {
995
+ for (const skill of deselected.coreSkills) {
996
+ selectedSkills.delete(skill);
997
+ }
998
+ }
999
+
1000
+ // ── Extract ──
1001
+ console.log(` ${icon.arrow} Extracting ${AGENT_DIR}/ directory...`);
1002
+ const actualSkillsCopied = extractFiltered(tarballPath, options.targetDir, selectedSkills, extractConfig);
1003
+ console.log(` ${c.dim} ${actualSkillsCopied} skills installed${c.reset}`);
1004
+
1005
+ // ── Write .agvrc ──
1006
+ writeAgvrc(options.targetDir, mode, selectedStackKeys, deselected);
374
1007
 
375
- printSuccess(options.targetDir);
1008
+ // ── Success ──
1009
+ printSuccess(options.targetDir, mode, selectedStackKeys);
376
1010
  } catch (err) {
377
- console.error(`\n ${icons.error} ${color.red}Installation failed:${color.reset} ${err.message}\n`);
1011
+ console.error(`\n ${icon.error} ${c.red}Installation failed:${c.reset} ${err.message}\n`);
378
1012
 
379
- // Clean up partial installation
380
1013
  if (fs.existsSync(targetAgentDir)) {
381
1014
  fs.rmSync(targetAgentDir, { recursive: true, force: true });
382
1015
  }
383
1016
 
384
1017
  process.exit(1);
385
1018
  } finally {
386
- // Clean up tarball
387
1019
  if (fs.existsSync(tarballPath)) {
388
1020
  fs.unlinkSync(tarballPath);
389
1021
  }