cli-five 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-five",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Code Like I'm Five — scaffold a 5-agent VS Code Copilot team into any repo.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -24,6 +24,7 @@ ${kleur.bold('Flags')}
24
24
  --force Overwrite without confirmation. Dangerous. Use with --yes.
25
25
  --dry-run Print actions without writing files
26
26
  --no-skills Skip the skills.sh discovery step
27
+ --doc <file> Read project docs to pre-fill interview (repeatable)
27
28
  --cost-mode <m> Override cost mode (premium, cheap, mixed) — skips interview question
28
29
  --cwd <path> Run against a directory other than the current one
29
30
  --version, -v Print version and exit
@@ -59,13 +60,14 @@ export async function run(argv) {
59
60
  }
60
61
 
61
62
  function parse(argv) {
62
- const out = { _: [], yes: false, force: false, dryRun: false, skills: true, costMode: null, cwd: process.cwd() };
63
+ const out = { _: [], yes: false, force: false, dryRun: false, skills: true, docs: [], costMode: null, cwd: process.cwd() };
63
64
  for (let i = 0; i < argv.length; i++) {
64
65
  const a = argv[i];
65
66
  if (a === '--yes' || a === '-y') out.yes = true;
66
67
  else if (a === '--force') out.force = true;
67
68
  else if (a === '--dry-run') out.dryRun = true;
68
69
  else if (a === '--no-skills') out.skills = false;
70
+ else if (a === '--doc') out.docs.push(argv[++i]);
69
71
  else if (a === '--cost-mode') out.costMode = argv[++i];
70
72
  else if (a === '--cwd') out.cwd = argv[++i];
71
73
  else if (a === '--help' || a === '-h') out._.push('help');
@@ -1,5 +1,7 @@
1
1
  import kleur from 'kleur';
2
2
  import prompts from 'prompts';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { resolve, basename } from 'node:path';
3
5
  import { log } from '../util/log.mjs';
4
6
  import { detect } from '../steps/detect.mjs';
5
7
  import { confirmOverwriteIfNeeded } from '../steps/confirm.mjs';
@@ -40,9 +42,51 @@ export async function init(args) {
40
42
  }
41
43
  if (!detected.hasAgents && !detected.hasCopilotInstructions) log.dim('No collisions.');
42
44
 
43
- // 4. Interview
44
- log.step('3/6 Interview');
45
- const answers = await interview(detected, args);
45
+ // 4. Input mode — docs or manual interview
46
+ log.step('3/6 Project info');
47
+ let docHints;
48
+
49
+ if (args.docs.length > 0) {
50
+ // --doc was passed on the CLI — use those files directly
51
+ docHints = loadDocs(args.docs, cwd);
52
+ } else if (args.yes) {
53
+ // --yes skips the choice — go straight to defaults
54
+ docHints = loadDocs([], cwd);
55
+ } else {
56
+ const { mode } = await prompts({
57
+ type: 'select',
58
+ name: 'mode',
59
+ message: 'How would you like to describe your project?',
60
+ choices: [
61
+ { title: 'Provide document(s)', value: 'docs', description: 'Feed existing files (README, PRD, etc.) — we extract what we can' },
62
+ { title: 'Answer questions', value: 'manual', description: 'Short interactive interview' },
63
+ ],
64
+ initial: 0,
65
+ });
66
+ if (mode === undefined) { log.warn('Cancelled.'); return; }
67
+
68
+ if (mode === 'docs') {
69
+ const { paths } = await prompts({
70
+ type: 'list',
71
+ name: 'paths',
72
+ message: 'File paths (comma-separated, relative to project root)',
73
+ separator: ',',
74
+ });
75
+ if (!paths || paths.length === 0) { log.warn('No files provided. Falling back to interview.'); }
76
+ docHints = loadDocs((paths || []).map(p => p.trim()).filter(Boolean), cwd);
77
+ } else {
78
+ docHints = loadDocs([], cwd);
79
+ }
80
+ }
81
+
82
+ if (docHints.files.length > 0) {
83
+ log.info(`Loaded ${docHints.files.length} doc${docHints.files.length > 1 ? 's' : ''}: ${docHints.files.join(', ')}`);
84
+ if (docHints.projectName) log.dim(` → project name: ${docHints.projectName}`);
85
+ if (docHints.oneLiner) log.dim(` → description: ${docHints.oneLiner}`);
86
+ }
87
+
88
+ // 5. Interview (pre-filled from docs if available, otherwise manual)
89
+ const answers = await interview(detected, args, docHints);
46
90
 
47
91
  // CLI --cost-mode override
48
92
  if (args.costMode && ['premium', 'cheap', 'mixed'].includes(args.costMode)) {
@@ -106,3 +150,101 @@ function printNextSteps(answers) {
106
150
  log.raw(kleur.dim('Edit cost mode anytime by changing `model:` in .github/agents/*.agent.md.'));
107
151
  log.raw('');
108
152
  }
153
+
154
+ // ── --doc file loading + extraction ───────────────────────────────────
155
+
156
+ function loadDocs(docPaths, cwd) {
157
+ const empty = { files: [], projectName: '', oneLiner: '', goals: '', constraints: '', raw: '' };
158
+ if (!docPaths || docPaths.length === 0) return empty;
159
+
160
+ const sections = [];
161
+ const files = [];
162
+ let projectName = '';
163
+ let oneLiner = '';
164
+ let goals = '';
165
+ let constraints = '';
166
+
167
+ for (const rawPath of docPaths) {
168
+ const abs = resolve(cwd, rawPath);
169
+ if (!existsSync(abs)) {
170
+ log.warn(`--doc: file not found: ${rawPath}`);
171
+ continue;
172
+ }
173
+
174
+ let content;
175
+ try {
176
+ content = readFileSync(abs, 'utf8');
177
+ } catch (err) {
178
+ log.warn(`--doc: cannot read ${rawPath}: ${err.message}`);
179
+ continue;
180
+ }
181
+
182
+ const name = basename(abs);
183
+ files.push(name);
184
+ sections.push(`### ${name}\n\n${content.trim()}`);
185
+
186
+ // ── Heuristic extraction ──────────────────────────────────────
187
+ // Try package.json first (structured data)
188
+ if (name === 'package.json') {
189
+ try {
190
+ const pkg = JSON.parse(content);
191
+ if (pkg.name && !projectName) projectName = pkg.name;
192
+ if (pkg.description && !oneLiner) oneLiner = pkg.description;
193
+ } catch { /* ignore malformed JSON */ }
194
+ continue;
195
+ }
196
+
197
+ // For markdown/text files — extract from headings and first paragraph
198
+ const lines = content.split('\n');
199
+ for (let i = 0; i < lines.length; i++) {
200
+ const line = lines[i].trim();
201
+
202
+ // First H1 → project name hint
203
+ if (!projectName && /^#\s+/.test(line)) {
204
+ projectName = line.replace(/^#+\s*/, '').trim();
205
+ continue;
206
+ }
207
+
208
+ // First non-empty paragraph after H1 → one-liner hint
209
+ if (projectName && !oneLiner && line && !line.startsWith('#') && !line.startsWith('```') && !line.startsWith('- ') && !line.startsWith('|')) {
210
+ oneLiner = line.length > 120 ? line.slice(0, 117) + '...' : line;
211
+ continue;
212
+ }
213
+
214
+ // ## Goal / ## Purpose / ## Overview → goals hint
215
+ if (!goals && /^##\s+(goal|purpose|overview|objective|vision)/i.test(line)) {
216
+ const body = collectSection(lines, i + 1);
217
+ if (body) goals = body;
218
+ continue;
219
+ }
220
+
221
+ // ## Constraints → constraints hint
222
+ if (!constraints && /^##\s+(constraint|requirement|limit|scope)/i.test(line)) {
223
+ const body = collectSection(lines, i + 1);
224
+ if (body) constraints = body;
225
+ continue;
226
+ }
227
+ }
228
+ }
229
+
230
+ return {
231
+ files,
232
+ projectName,
233
+ oneLiner,
234
+ goals,
235
+ constraints,
236
+ raw: sections.join('\n\n---\n\n'),
237
+ };
238
+ }
239
+
240
+ /** Collect text from startIdx until the next heading or EOF. */
241
+ function collectSection(lines, startIdx) {
242
+ const out = [];
243
+ for (let i = startIdx; i < lines.length; i++) {
244
+ if (/^##?\s+/.test(lines[i]) && out.length > 0) break;
245
+ const trimmed = lines[i].trim();
246
+ if (trimmed) out.push(trimmed);
247
+ if (out.length >= 3) break; // Keep it short — just the first few lines
248
+ }
249
+ return out.join(' ');
250
+ }
@@ -6,6 +6,14 @@ import prompts from 'prompts';
6
6
  // Research via Context7: Next.js, Vite+React, Express, Hono all score
7
7
  // 80+ benchmark with high snippet counts and well-indexed docs.
8
8
  const STACK_PRESETS = [
9
+ {
10
+ title: 'Next.js + Supabase PWA (TypeScript)',
11
+ value: 'nextjs-supabase',
12
+ description: 'Next.js 15 static export, React 19, Tailwind v4, Supabase, SWR, Vercel hosting, PWA.',
13
+ stack: ['Node + TypeScript'],
14
+ frameworks: ['Next.js', 'React', 'Tailwind CSS', 'Supabase', 'SWR'],
15
+ quickstart: 'npx create-next-app@latest . --yes && npm i @supabase/supabase-js swr date-fns clsx && npm run dev',
16
+ },
9
17
  {
10
18
  title: 'Next.js (TypeScript)',
11
19
  value: 'nextjs',
@@ -70,8 +78,8 @@ const COST_MODES = [
70
78
  { title: 'Mixed', value: 'mixed', description: 'Premium Coder + Reviewer, cheap everything else.' },
71
79
  ];
72
80
 
73
- export async function interview(detected, args) {
74
- if (args.yes) return defaults(detected);
81
+ export async function interview(detected, args, docHints = {}) {
82
+ if (args.yes) return defaults(detected, docHints);
75
83
 
76
84
  const onCancel = () => {
77
85
  throw new Error('Interview cancelled. Nothing was written.');
@@ -84,13 +92,13 @@ export async function interview(detected, args) {
84
92
  type: 'text',
85
93
  name: 'projectName',
86
94
  message: 'Project name',
87
- initial: detected.projectName,
95
+ initial: docHints.projectName || detected.projectName,
88
96
  },
89
97
  {
90
98
  type: 'text',
91
99
  name: 'oneLiner',
92
100
  message: 'One-line description (becomes PROJECT.md vision)',
93
- initial: '',
101
+ initial: docHints.oneLiner || '',
94
102
  },
95
103
  ],
96
104
  { onCancel },
@@ -151,11 +159,13 @@ export async function interview(detected, args) {
151
159
  type: 'text',
152
160
  name: 'goals',
153
161
  message: 'Primary goal of this project (one sentence)',
162
+ initial: docHints.goals || '',
154
163
  },
155
164
  {
156
165
  type: 'text',
157
166
  name: 'constraints',
158
167
  message: 'Hard constraints (perf, deps, deploy, compliance — one sentence, optional)',
168
+ initial: docHints.constraints || '',
159
169
  },
160
170
  {
161
171
  type: 'select',
@@ -175,30 +185,32 @@ export async function interview(detected, args) {
175
185
  );
176
186
 
177
187
  return normalize({
178
- ...defaults(detected),
188
+ ...defaults(detected, docHints),
179
189
  ...basic,
180
190
  ...stackAnswers,
181
191
  ...rest,
182
192
  presetId: preset,
183
193
  quickstart: chosenPreset?.quickstart || '',
194
+ docs: docHints.raw || '',
184
195
  });
185
196
  }
186
197
 
187
198
  /** Default stack is Next.js + TypeScript when nothing detected and --yes. */
188
- function defaults(detected) {
199
+ function defaults(detected, docHints = {}) {
189
200
  const hasDetected = detected.stacks.length > 0;
190
201
  const fallback = STACK_PRESETS[0]; // Next.js (TypeScript)
191
202
  return {
192
- projectName: detected.projectName,
193
- oneLiner: '',
203
+ projectName: docHints.projectName || detected.projectName,
204
+ oneLiner: docHints.oneLiner || '',
194
205
  stack: hasDetected ? detected.stacks.map((s) => s.label) : fallback.stack,
195
206
  frameworks: hasDetected ? [] : fallback.frameworks,
196
- goals: '',
197
- constraints: '',
207
+ goals: docHints.goals || '',
208
+ constraints: docHints.constraints || '',
198
209
  costMode: 'premium',
199
210
  snark: true,
200
211
  presetId: hasDetected ? 'custom' : fallback.value,
201
212
  quickstart: hasDetected ? '' : fallback.quickstart,
213
+ docs: docHints.raw || '',
202
214
  };
203
215
  }
204
216
 
@@ -96,6 +96,13 @@ function buildVars(a) {
96
96
  CONSTRAINTS: a.constraints || 'None declared.',
97
97
  QUICKSTART: a.quickstart || 'TODO — add install + run commands here.',
98
98
  COST_MODE: a.costMode,
99
+ DOCS_SECTION: a.docs ? `
100
+ ## Source Documents
101
+
102
+ The following documents were provided via \`--doc\` at project init time.
103
+
104
+ ${a.docs}
105
+ ` : '',
99
106
  DATE: new Date().toISOString().slice(0, 10),
100
107
  PERSONA_BLOCK: a.snark ? PERSONA_BLOCK : '',
101
108
  };
@@ -6,36 +6,9 @@ import { join, dirname } from 'node:path';
6
6
  import { createRequire } from 'node:module';
7
7
  import { log } from '../util/log.mjs';
8
8
 
9
- // ── Awesome-copilot catalog (github/awesome-copilot) ──────────────────
10
- // Curated subset mapped by stack term → { name, description, type }
11
- const AWESOME_CATALOG = [
12
- // Universal
13
- { terms: ['*'], name: 'frontend-design', desc: 'Production-grade UI design', type: 'skill', source: 'awesome-copilot' },
14
- { terms: ['*'], name: 'conventional-commit', desc: 'Commit message standards', type: 'skill', source: 'awesome-copilot' },
15
- { terms: ['*'], name: 'documentation-writer', desc: 'Generate project docs', type: 'skill', source: 'awesome-copilot' },
16
- { terms: ['*'], name: 'mermaid-diagrams', desc: 'Create software diagrams', type: 'skill', source: 'awesome-copilot' },
17
- // JavaScript / TypeScript / React / Next
18
- { terms: ['node', 'typescript', 'javascript'], name: 'typescript', desc: 'TypeScript best practices', type: 'instruction', source: 'awesome-copilot' },
19
- { terms: ['react', 'next'], name: 'reactjs', desc: 'React patterns & conventions', type: 'instruction', source: 'awesome-copilot' },
20
- { terms: ['next'], name: 'nextjs', desc: 'Next.js patterns', type: 'instruction', source: 'awesome-copilot' },
21
- { terms: ['node', 'typescript', 'react', 'next'], name: 'playwright-tester', desc: 'E2E testing with Playwright', type: 'skill', source: 'awesome-copilot' },
22
- // Python
23
- { terms: ['python', 'django', 'flask', 'fastapi'], name: 'python', desc: 'Python best practices', type: 'instruction', source: 'awesome-copilot' },
24
- { terms: ['django'], name: 'django', desc: 'Django conventions', type: 'instruction', source: 'awesome-copilot' },
25
- { terms: ['fastapi'], name: 'fastapi', desc: 'FastAPI patterns', type: 'instruction', source: 'awesome-copilot' },
26
- // .NET
27
- { terms: ['dotnet', '.net', 'csharp', 'c#'], name: 'dotnet', desc: '.NET conventions', type: 'instruction', source: 'awesome-copilot' },
28
- // Rust
29
- { terms: ['rust'], name: 'rust', desc: 'Rust best practices', type: 'instruction', source: 'awesome-copilot' },
30
- // Go
31
- { terms: ['go', 'golang'], name: 'go', desc: 'Go conventions', type: 'instruction', source: 'awesome-copilot' },
32
- // Mobile
33
- { terms: ['swift', 'ios'], name: 'swift', desc: 'Swift/iOS patterns', type: 'instruction', source: 'awesome-copilot' },
34
- { terms: ['kotlin', 'android'], name: 'kotlin', desc: 'Kotlin/Android patterns', type: 'instruction', source: 'awesome-copilot' },
35
- // DevOps / Infra
36
- { terms: ['docker', 'kubernetes', 'devops'], name: 'docker', desc: 'Docker best practices', type: 'instruction', source: 'awesome-copilot' },
37
- { terms: ['terraform'], name: 'terraform', desc: 'Terraform conventions', type: 'instruction', source: 'awesome-copilot' },
38
- ];
9
+ // ── Known-good skill repos (verified working) ────────────────────────
10
+ // awesome-copilot skills are discovered dynamically via `skills find`.
11
+ const AWESOME_COPILOT_REPO = 'github/awesome-copilot';
39
12
 
40
13
  // Well-known skill repos matched by stack term → repo + suggested skill names (skills.sh)
41
14
  const SKILL_CATALOG = [
@@ -141,8 +114,7 @@ export async function skillDiscovery({ cwd, answers, args }) {
141
114
  if (!env) {
142
115
  log.warn('Cannot run skills CLI: no bundled binary, pnpm, or npx found.');
143
116
  log.warn('Install Node.js (includes npx) or pnpm, then re-run.');
144
- // Still show awesome-copilot recs (they don't need skills CLI)
145
- await showAwesomeCopilotOnly(answers);
117
+ printBreadcrumbs();
146
118
  return;
147
119
  }
148
120
 
@@ -165,77 +137,86 @@ export async function skillDiscovery({ cwd, answers, args }) {
165
137
  }
166
138
 
167
139
  log.ok(`Running skills via ${env.label}`);
168
- log.raw('');
169
-
170
- // ── Build recommendations from BOTH sources ─────────────────────────
171
- const awesomeRecs = buildAwesomeRecommendations(answers);
172
- const skillsShRecs = buildRecommendations(answers);
173
-
174
- // ── Display hero section ────────────────────────────────────────────
175
- displayHeroRecommendations(awesomeRecs, skillsShRecs, answers);
176
140
 
177
- // ── Combined picker ─────────────────────────────────────────────────
141
+ // ── 1. Auto-search all stack terms silently ─────────────────────────
178
142
  const terms = suggestSearches(answers);
143
+ const searchResults = [];
179
144
 
180
- // Search skills.sh for additional results
181
- const allFound = [];
182
145
  if (terms.length > 0) {
183
- log.dim(`Suggested searches: ${terms.map((t) => `"${t}"`).join(', ')}`);
184
146
  log.raw('');
185
-
147
+ log.info(`Searching skills.sh for: ${terms.map((t) => kleur.bold(t)).join(', ')} ...`);
186
148
  for (const term of terms) {
187
- const { go } = await prompts({
188
- type: 'confirm',
189
- name: 'go',
190
- message: `Search skills.sh for "${term}"?`,
191
- initial: true,
192
- });
193
- if (go) {
194
- const output = await runSkillsCapture(env, ['find', term], cwd);
195
- allFound.push(...parseSkillRefs(output));
196
- }
149
+ const output = await runSkillsSilent(env, ['find', term], cwd);
150
+ searchResults.push(...parseSkillRefs(output));
151
+ }
152
+ if (searchResults.length > 0) {
153
+ log.ok(`Found ${searchResults.length} skill${searchResults.length > 1 ? 's' : ''} from search.`);
154
+ } else {
155
+ log.dim('No additional skills found via search.');
197
156
  }
198
157
  }
199
158
 
200
- // Build combined skill picker — awesome recs + catalog recs + search results
159
+ // ── 2. Build static catalog recommendations ─────────────────────────
160
+ const catalogRecs = buildRecommendations(answers);
161
+
162
+ // ── 3. Categorize search results by source ──────────────────────────
163
+ const awesomeResults = [];
164
+ const otherResults = [];
165
+ for (const r of searchResults) {
166
+ const parsed = parseRef(r.ref);
167
+ if (parsed && parsed.repo === AWESOME_COPILOT_REPO) {
168
+ awesomeResults.push({ ...r, ...parsed });
169
+ } else {
170
+ otherResults.push(r);
171
+ }
172
+ }
173
+
174
+ // ── 4. Display hero section ─────────────────────────────────────────
175
+ displayHeroRecommendations(awesomeResults, catalogRecs, answers);
176
+
177
+ // ── 5. Build unified picker ─────────────────────────────────────────
201
178
  const seen = new Set();
202
179
  const choices = [];
203
180
 
204
- // Awesome-copilot recs first (hero placement)
205
- for (const r of awesomeRecs) {
206
- const key = `awesome:${r.name}`;
181
+ // awesome-copilot results from search (dynamic, real names)
182
+ for (const r of awesomeResults) {
183
+ const key = r.ref;
207
184
  if (seen.has(key)) continue;
208
185
  seen.add(key);
209
186
  choices.push({
210
- title: `${kleur.cyan('⬡')} ${r.name} ${kleur.dim(`(${r.desc})`)} ${kleur.cyan('← awesome-copilot')}`,
211
- value: { source: 'awesome', name: r.name, type: r.type },
187
+ title: `${kleur.cyan('⬡')} ${r.skill} ${kleur.dim(`(${r.installs})`)} ${kleur.cyan('← awesome-copilot')}`,
188
+ value: { source: 'awesome', repo: r.repo, skill: r.skill, ref: r.ref },
212
189
  selected: true,
213
190
  });
214
191
  }
215
192
 
216
- // skills.sh catalog recs
217
- for (const r of skillsShRecs) {
193
+ // Static catalog recs (verified repos)
194
+ for (const r of catalogRecs) {
218
195
  const ref = `${r.repo}@${r.skill}`;
219
196
  if (seen.has(ref)) continue;
220
197
  seen.add(ref);
221
198
  choices.push({
222
199
  title: `${kleur.yellow('◆')} ${r.skill} ${kleur.dim(`(${r.repo})`)} ${kleur.yellow('← skills.sh')}`,
223
- value: { source: 'skillssh', ref, repo: r.repo, skill: r.skill },
200
+ value: { source: 'catalog', ref, repo: r.repo, skill: r.skill },
224
201
  selected: true,
225
202
  });
226
203
  }
227
204
 
228
- // skills.sh search results
229
- for (const r of allFound) {
205
+ // Other search results (non-awesome-copilot)
206
+ for (const r of otherResults) {
230
207
  if (seen.has(r.ref)) continue;
231
208
  seen.add(r.ref);
209
+ const parsed = parseRef(r.ref);
232
210
  choices.push({
233
- title: `${kleur.yellow('◆')} ${r.ref} ${kleur.dim(`— ${r.installs}`)} ${kleur.yellow('← skills.sh')}`,
234
- value: { source: 'skillssh', ref: r.ref },
211
+ title: `${kleur.yellow('◆')} ${parsed ? parsed.skill : r.ref} ${kleur.dim(`(${parsed ? parsed.repo : ''} — ${r.installs})`)} ${kleur.yellow('← skills.sh')}`,
212
+ value: { source: 'search', ref: r.ref, repo: parsed?.repo, skill: parsed?.skill },
235
213
  selected: false,
236
214
  });
237
215
  }
238
216
 
217
+ // ── 6. Prompt user to select skills ─────────────────────────────────
218
+ const installResults = [];
219
+
239
220
  if (choices.length > 0) {
240
221
  log.raw('');
241
222
  const { toInstall } = await prompts({
@@ -247,51 +228,47 @@ export async function skillDiscovery({ cwd, answers, args }) {
247
228
  });
248
229
 
249
230
  if (toInstall && toInstall.length > 0) {
250
- // Separate awesome-copilot entries from skills.sh entries
251
- const awesomeItems = toInstall.filter((i) => i.source === 'awesome');
252
- const skillsShItems = toInstall.filter((i) => i.source === 'skillssh');
253
-
254
- // Install awesome-copilot skills/instructions via npx skills (they're in the registry too)
255
- if (awesomeItems.length > 0) {
256
- log.raw('');
257
- log.info(`${kleur.cyan('awesome-copilot')} resources selected: ${awesomeItems.map((i) => i.name).join(', ')}`);
258
- log.dim('These will be installed via skills CLI from the awesome-copilot registry.');
259
- for (const item of awesomeItems) {
260
- log.info(`Installing ${item.name} (${item.type})...`);
261
- await runSkills(env, ['add', `awesome-copilot@${item.name}`, '-a', 'github-copilot'], cwd);
231
+ log.raw('');
232
+
233
+ // Group by repo for batch install
234
+ const byRepo = new Map();
235
+ const standalone = [];
236
+
237
+ for (const item of toInstall) {
238
+ if (item.repo && item.skill) {
239
+ if (!byRepo.has(item.repo)) byRepo.set(item.repo, []);
240
+ byRepo.get(item.repo).push(item.skill);
241
+ } else if (item.ref) {
242
+ standalone.push(item.ref);
262
243
  }
263
244
  }
264
245
 
265
- // Install skills.sh entries
266
- if (skillsShItems.length > 0) {
267
- const byRepo = new Map();
268
- const standalone = [];
269
-
270
- for (const item of skillsShItems) {
271
- if (item.repo) {
272
- if (!byRepo.has(item.repo)) byRepo.set(item.repo, []);
273
- byRepo.get(item.repo).push(item.skill);
274
- } else {
275
- standalone.push(item.ref);
276
- }
277
- }
278
-
279
- for (const [repo, skills] of byRepo) {
280
- const skillArgs = skills.flatMap((s) => ['--skill', s]);
281
- log.info(`Installing from ${repo}: ${skills.join(', ')}`);
282
- await runSkills(env, ['add', repo, ...skillArgs, '-a', 'github-copilot'], cwd);
246
+ // Batch install per repo
247
+ for (const [repo, skills] of byRepo) {
248
+ const skillArgs = skills.flatMap((s) => ['--skill', s]);
249
+ log.info(`Installing from ${kleur.bold(repo)}: ${skills.join(', ')}`);
250
+ const result = await runSkillsTracked(env, ['add', repo, ...skillArgs, '-a', 'github-copilot'], cwd);
251
+ for (const s of skills) {
252
+ installResults.push({ name: `${repo}@${s}`, ...result });
283
253
  }
254
+ }
284
255
 
285
- for (const ref of standalone) {
286
- log.info(`Installing ${ref}...`);
287
- await runSkills(env, ['add', ref, '-a', 'github-copilot'], cwd);
288
- }
256
+ // Standalone refs
257
+ for (const ref of standalone) {
258
+ log.info(`Installing ${ref}...`);
259
+ const result = await runSkillsTracked(env, ['add', ref, '-a', 'github-copilot'], cwd);
260
+ installResults.push({ name: ref, ...result });
289
261
  }
290
262
 
291
263
  await relocateSkills(cwd);
292
264
  }
293
265
  }
294
266
 
267
+ // ── 7. Install summary ──────────────────────────────────────────────
268
+ if (installResults.length > 0) {
269
+ printInstallSummary(installResults);
270
+ }
271
+
295
272
  // Offer freeform catch-all
296
273
  const { freeform } = await prompts({
297
274
  type: 'confirm',
@@ -310,7 +287,7 @@ export async function skillDiscovery({ cwd, answers, args }) {
310
287
 
311
288
  // ── Hero display ──────────────────────────────────────────────────────
312
289
 
313
- function displayHeroRecommendations(awesomeRecs, skillsShRecs, answers) {
290
+ function displayHeroRecommendations(awesomeResults, catalogRecs, answers) {
314
291
  const stackLabel = [...(answers.stack || []), ...(answers.frameworks || [])].join(', ') || 'general';
315
292
 
316
293
  log.raw('');
@@ -318,42 +295,50 @@ function displayHeroRecommendations(awesomeRecs, skillsShRecs, answers) {
318
295
  log.raw(kleur.bold().cyan(' ║') + kleur.bold(' 📦 RECOMMENDED FOR YOUR STACK: ') + kleur.bold().white(stackLabel) + pad('', Math.max(0, 30 - stackLabel.length)) + kleur.bold().cyan(' ║'));
319
296
  log.raw(kleur.bold().cyan(' ╚══════════════════════════════════════════════════════════════════╝'));
320
297
 
321
- if (awesomeRecs.length > 0) {
298
+ if (awesomeResults.length > 0) {
322
299
  log.raw('');
323
300
  log.raw(kleur.cyan(' ⬡ Source: awesome-copilot') + kleur.dim(' (github/awesome-copilot · 30k+ ★)'));
324
301
  log.raw(kleur.dim(' ─────────────────────────────────────────────────────────'));
325
- for (const r of awesomeRecs) {
326
- const typeTag = kleur.dim(`[${r.type}]`);
327
- log.raw(` ${kleur.green('✓')} ${pad(r.name, 28)} ${pad(r.desc, 32)} ${typeTag}`);
302
+ for (const r of awesomeResults) {
303
+ log.raw(` ${kleur.green('✓')} ${pad(r.skill, 38)} ${kleur.dim(r.installs)}`);
328
304
  }
329
305
  }
330
306
 
331
- if (skillsShRecs.length > 0) {
307
+ if (catalogRecs.length > 0) {
332
308
  log.raw('');
333
- log.raw(kleur.yellow(' ◆ Source: skills.sh') + kleur.dim(' (skills.sh registry)'));
309
+ log.raw(kleur.yellow(' ◆ Source: skills.sh') + kleur.dim(' (verified repos)'));
334
310
  log.raw(kleur.dim(' ─────────────────────────────────────────────────────────'));
335
- for (const r of skillsShRecs) {
336
- log.raw(` ${kleur.green('✓')} ${pad(r.skill, 28)} ${kleur.dim(r.repo)}`);
311
+ for (const r of catalogRecs) {
312
+ log.raw(` ${kleur.green('✓')} ${pad(r.skill, 38)} ${kleur.dim(r.repo)}`);
337
313
  }
338
314
  }
339
315
 
340
316
  log.raw('');
341
317
  }
342
318
 
343
- /** Fallback when skills CLI is unavailable — show awesome-copilot recs as copy-paste commands */
344
- async function showAwesomeCopilotOnly(answers) {
345
- const recs = buildAwesomeRecommendations(answers);
346
- if (recs.length === 0) return;
319
+ // ── Install summary ───────────────────────────────────────────────────
320
+
321
+ function printInstallSummary(results) {
322
+ const succeeded = results.filter((r) => r.ok);
323
+ const failed = results.filter((r) => !r.ok);
347
324
 
348
325
  log.raw('');
349
- log.info('Cannot install skills automatically, but here are recommendations:');
350
- displayHeroRecommendations(recs, [], answers);
351
- log.raw(kleur.dim(' Install manually in VS Code Chat:'));
352
- for (const r of recs) {
353
- log.raw(` ${kleur.white(`copilot plugin install awesome-copilot@${r.name}`)}`);
326
+ if (succeeded.length > 0) {
327
+ log.ok(`Installed ${succeeded.length} skill${succeeded.length > 1 ? 's' : ''} successfully.`);
328
+ }
329
+
330
+ if (failed.length > 0) {
331
+ log.raw('');
332
+ log.warn(`${failed.length} skill${failed.length > 1 ? 's' : ''} failed to install:`);
333
+ for (const f of failed) {
334
+ log.raw(` ${kleur.red('✗')} ${f.name}`);
335
+ if (f.reason) {
336
+ log.raw(` ${kleur.dim(f.reason)}`);
337
+ }
338
+ }
339
+ log.raw('');
340
+ log.dim('Install failed skills manually: npx skills add <owner/repo> --skill <name>');
354
341
  }
355
- log.raw('');
356
- printBreadcrumbs();
357
342
  }
358
343
 
359
344
  // ── Post-install breadcrumbs ──────────────────────────────────────────
@@ -364,9 +349,6 @@ function printBreadcrumbs() {
364
349
  log.raw(kleur.bold().green(' │') + kleur.bold(' 🎯 KEEP DISCOVERING — paste into VS Code / Copilot Chat: ') + kleur.bold().green('│'));
365
350
  log.raw(kleur.bold().green(' ├──────────────────────────────────────────────────────────────────┤'));
366
351
  log.raw(kleur.bold().green(' │') + ' ' + kleur.bold().green('│'));
367
- log.raw(kleur.bold().green(' │') + ' Install the awesome-copilot suggestion skill: ' + kleur.bold().green('│'));
368
- log.raw(kleur.bold().green(' │') + kleur.white(' copilot plugin install awesome-copilot@suggest ') + kleur.bold().green('│'));
369
- log.raw(kleur.bold().green(' │') + ' ' + kleur.bold().green('│'));
370
352
  log.raw(kleur.bold().green(' │') + ' Add awesome-copilot MCP for ongoing search: ' + kleur.bold().green('│'));
371
353
  log.raw(kleur.bold().green(' │') + kleur.white(' Add to .vscode/mcp.json: ') + kleur.bold().green('│'));
372
354
  log.raw(kleur.bold().green(' │') + kleur.dim(' "awesome-copilot": { ') + kleur.bold().green('│'));
@@ -381,26 +363,61 @@ function printBreadcrumbs() {
381
363
  log.raw('');
382
364
  }
383
365
 
384
- // ── Awesome-copilot recommendation builder ────────────────────────────
366
+ // ── Runner ─────────────────────────────────────────────────────────────
385
367
 
386
- function buildAwesomeRecommendations(a) {
387
- const stackLower = (a.stack || []).map((s) => s.toLowerCase());
388
- const fwLower = (a.frameworks || []).map((f) => f.toLowerCase());
389
- const all = [...stackLower, ...fwLower];
390
- const seen = new Set();
391
- const out = [];
368
+ /** Run skills CLI with stdio: inherit (visible to user). */
369
+ function runSkills(env, skillsArgs, cwd) {
370
+ const { cmd, args } = env.runner(skillsArgs);
371
+ return runInteractive(cmd, args, cwd);
372
+ }
392
373
 
393
- for (const entry of AWESOME_CATALOG) {
394
- const matches = entry.terms.includes('*') || entry.terms.some((t) => all.some((s) => s.includes(t)));
395
- if (!matches) continue;
396
- if (seen.has(entry.name)) continue;
397
- seen.add(entry.name);
398
- out.push({ name: entry.name, desc: entry.desc, type: entry.type, source: entry.source });
399
- }
400
- return out;
374
+ /** Run skills CLI, capture output, track success/failure. Returns { ok, reason }. */
375
+ function runSkillsTracked(env, skillsArgs, cwd) {
376
+ const { cmd, args } = env.runner(skillsArgs);
377
+ return new Promise((resolve) => {
378
+ let output = '';
379
+ const proc = spawn(cmd, args, { cwd, stdio: ['inherit', 'pipe', 'pipe'] });
380
+ proc.stdout.on('data', (chunk) => {
381
+ const text = chunk.toString();
382
+ process.stdout.write(text);
383
+ output += text;
384
+ });
385
+ proc.stderr.on('data', (chunk) => {
386
+ const text = chunk.toString();
387
+ process.stderr.write(text);
388
+ output += text;
389
+ });
390
+ proc.on('exit', (code) => {
391
+ const clean = stripAnsi(output);
392
+ if (code !== 0 || /failed to clone|installation failed|canceled/i.test(clean)) {
393
+ const reason = extractFailureReason(clean);
394
+ resolve({ ok: false, reason });
395
+ } else {
396
+ resolve({ ok: true, reason: null });
397
+ }
398
+ });
399
+ proc.on('error', (err) => {
400
+ resolve({ ok: false, reason: err.message });
401
+ });
402
+ });
401
403
  }
402
404
 
403
- // ── Relocation ─────────────────────────────────────────────────────────
405
+ /** Run skills CLI silently — capture stdout/stderr without echoing. */
406
+ function runSkillsSilent(env, skillsArgs, cwd) {
407
+ const { cmd, args } = env.runner(skillsArgs);
408
+ return new Promise((resolve) => {
409
+ let output = '';
410
+ const proc = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
411
+ proc.stdout.on('data', (chunk) => {
412
+ output += chunk.toString();
413
+ });
414
+ proc.stderr.on('data', (chunk) => {
415
+ output += chunk.toString();
416
+ });
417
+ proc.on('exit', () => resolve(output));
418
+ proc.on('error', () => resolve(''));
419
+ });
420
+ }
404
421
 
405
422
  async function relocateSkills(cwd) {
406
423
  const src = join(cwd, '.agents', 'skills');
@@ -458,12 +475,7 @@ async function relocateSkills(cwd) {
458
475
  }
459
476
  }
460
477
 
461
- // ── Runner ─────────────────────────────────────────────────────────────
462
-
463
- function runSkills(env, skillsArgs, cwd) {
464
- const { cmd, args } = env.runner(skillsArgs);
465
- return runInteractive(cmd, args, cwd);
466
- }
478
+ // ── Helpers ────────────────────────────────────────────────────────────
467
479
 
468
480
  function buildRecommendations(a) {
469
481
  const stackLower = (a.stack || []).map((s) => s.toLowerCase());
@@ -503,6 +515,24 @@ function suggestSearches(a) {
503
515
  return [...out];
504
516
  }
505
517
 
518
+ /** Parse owner/repo@skill ref into { repo, skill }. */
519
+ function parseRef(ref) {
520
+ const match = /^(.+?\/.+?)@(.+)$/.exec(ref);
521
+ if (!match) return null;
522
+ return { repo: match[1], skill: match[2] };
523
+ }
524
+
525
+ /** Extract a concise failure reason from skills CLI output. */
526
+ function extractFailureReason(output) {
527
+ const cloneMatch = /Failed to clone[^:]*:\s*(.+)/i.exec(output);
528
+ if (cloneMatch) return cloneMatch[1].trim();
529
+
530
+ const errorMatch = /(?:error|failed)[:\s]+(.+)/im.exec(output);
531
+ if (errorMatch) return errorMatch[1].trim();
532
+
533
+ return 'Unknown error (check output above)';
534
+ }
535
+
506
536
  function pad(s, n) {
507
537
  return (s || '').padEnd(n);
508
538
  }
@@ -519,30 +549,6 @@ function runInteractive(cmd, args, cwd) {
519
549
  });
520
550
  }
521
551
 
522
- /** Run skills CLI, capture stdout/stderr while echoing to the terminal. */
523
- function runSkillsCapture(env, skillsArgs, cwd) {
524
- const { cmd, args } = env.runner(skillsArgs);
525
- return new Promise((resolve) => {
526
- let output = '';
527
- const proc = spawn(cmd, args, { cwd, stdio: ['inherit', 'pipe', 'pipe'] });
528
- proc.stdout.on('data', (chunk) => {
529
- const text = chunk.toString();
530
- process.stdout.write(text);
531
- output += text;
532
- });
533
- proc.stderr.on('data', (chunk) => {
534
- const text = chunk.toString();
535
- process.stderr.write(text);
536
- output += text;
537
- });
538
- proc.on('exit', () => resolve(output));
539
- proc.on('error', (err) => {
540
- log.warn(`Could not launch \`${cmd} ${args.join(' ')}\`: ${err.message}`);
541
- resolve('');
542
- });
543
- });
544
- }
545
-
546
552
  function stripAnsi(str) {
547
553
  // eslint-disable-next-line no-control-regex
548
554
  return str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
@@ -27,6 +27,6 @@ _(populate as you discover things this project will NOT do)_
27
27
  _(populate with measurable outcomes — what does "done" look like?)_
28
28
 
29
29
  ---
30
-
30
+ {{DOCS_SECTION}}
31
31
  _This file is the durable vision. It changes rarely. Day-to-day status lives in `STATE.md`._
32
32
  _Generated by `npx cli-five` on {{DATE}}._