cli-five 0.2.2 → 0.2.4

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.2",
3
+ "version": "0.2.4",
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, readdirSync } from 'node:fs';
4
+ import { resolve, basename, join, extname } 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,30 @@ 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 — validate with retry
51
+ docHints = loadDocs(args.docs, cwd);
52
+ if (docHints.files.length === 0) {
53
+ log.warn('None of the --doc files could be loaded.');
54
+ }
55
+ } else if (args.yes) {
56
+ docHints = loadDocs([], cwd);
57
+ } else {
58
+ docHints = await collectDocFiles(cwd);
59
+ }
60
+
61
+ if (docHints.files.length > 0 && args.docs.length > 0) {
62
+ log.info(`Loaded ${docHints.files.length} doc${docHints.files.length > 1 ? 's' : ''}: ${docHints.files.join(', ')}`);
63
+ if (docHints.projectName) log.dim(` → project name: ${docHints.projectName}`);
64
+ if (docHints.oneLiner) log.dim(` → description: ${docHints.oneLiner}`);
65
+ }
66
+
67
+ // 5. Interview (pre-filled from docs if available, otherwise manual)
68
+ const answers = await interview(detected, args, docHints);
46
69
 
47
70
  // CLI --cost-mode override
48
71
  if (args.costMode && ['premium', 'cheap', 'mixed'].includes(args.costMode)) {
@@ -106,3 +129,239 @@ function printNextSteps(answers) {
106
129
  log.raw(kleur.dim('Edit cost mode anytime by changing `model:` in .github/agents/*.agent.md.'));
107
130
  log.raw('');
108
131
  }
132
+
133
+ // ── Interactive doc file collection ───────────────────────────────────
134
+
135
+ const MANUAL_SENTINEL = '__manual__';
136
+ const SKIP_SENTINEL = '__skip__';
137
+
138
+ async function collectDocFiles(cwd) {
139
+ const { mode } = await prompts({
140
+ type: 'select',
141
+ name: 'mode',
142
+ message: 'How would you like to describe your project?',
143
+ choices: [
144
+ { title: 'Provide document(s)', value: 'docs', description: 'Feed existing files (README, PRD, etc.) — we extract what we can' },
145
+ { title: 'Answer questions', value: 'manual', description: 'Short interactive interview' },
146
+ ],
147
+ initial: 0,
148
+ });
149
+ if (mode === undefined || mode === 'manual') return loadDocs([], cwd);
150
+
151
+ // Retry loop — keep asking until we get valid files or user opts out
152
+ while (true) {
153
+ const selectedPaths = await pickFiles(cwd);
154
+
155
+ // User cancelled or chose skip
156
+ if (selectedPaths === null) return loadDocs([], cwd);
157
+
158
+ const hints = loadDocs(selectedPaths, cwd);
159
+ if (hints.files.length > 0) {
160
+ log.info(`Loaded ${hints.files.length} doc${hints.files.length > 1 ? 's' : ''}: ${hints.files.join(', ')}`);
161
+ if (hints.projectName) log.dim(` → project name: ${hints.projectName}`);
162
+ if (hints.oneLiner) log.dim(` → description: ${hints.oneLiner}`);
163
+ return hints;
164
+ }
165
+
166
+ // Nothing loaded — offer retry
167
+ log.warn('No valid files were loaded.');
168
+ const { next } = await prompts({
169
+ type: 'select',
170
+ name: 'next',
171
+ message: 'What would you like to do?',
172
+ choices: [
173
+ { title: 'Try selecting files again', value: 'retry' },
174
+ { title: 'Answer questions manually instead', value: 'manual' },
175
+ ],
176
+ });
177
+ if (next !== 'retry') return loadDocs([], cwd);
178
+ }
179
+ }
180
+
181
+ async function pickFiles(cwd) {
182
+ const candidates = discoverDocCandidates(cwd);
183
+
184
+ if (candidates.length > 0) {
185
+ const choices = [
186
+ ...candidates.map(f => ({ title: f, value: f })),
187
+ { title: kleur.dim('Type path(s) manually'), value: MANUAL_SENTINEL },
188
+ { title: kleur.dim('Skip — answer questions instead'), value: SKIP_SENTINEL },
189
+ ];
190
+
191
+ const { files } = await prompts({
192
+ type: 'autocompleteMultiselect',
193
+ name: 'files',
194
+ message: 'Select project documents',
195
+ choices,
196
+ hint: 'Type to filter, space to select, enter to confirm',
197
+ suggest: (input, choices) =>
198
+ choices.filter(c =>
199
+ c.value === MANUAL_SENTINEL || c.value === SKIP_SENTINEL ||
200
+ c.title.toLowerCase().includes(input.toLowerCase())
201
+ ),
202
+ });
203
+
204
+ if (!files || files.length === 0) return null;
205
+ if (files.includes(SKIP_SENTINEL)) return null;
206
+ if (!files.includes(MANUAL_SENTINEL)) return files;
207
+ // Fall through to manual entry
208
+ }
209
+
210
+ // Manual entry (also reached when no candidates found)
211
+ return await manualPathEntry(cwd);
212
+ }
213
+
214
+ async function manualPathEntry(cwd) {
215
+ const { raw } = await prompts({
216
+ type: 'list',
217
+ name: 'raw',
218
+ message: 'File paths (comma-separated, relative to project root)',
219
+ separator: ',',
220
+ });
221
+
222
+ const paths = (raw || []).map(p => p.trim()).filter(Boolean);
223
+ if (paths.length === 0) return null;
224
+
225
+ // Validate immediately so user sees which ones failed
226
+ const valid = [];
227
+ const invalid = [];
228
+ for (const p of paths) {
229
+ if (existsSync(resolve(cwd, p))) {
230
+ valid.push(p);
231
+ } else {
232
+ invalid.push(p);
233
+ }
234
+ }
235
+
236
+ if (invalid.length > 0) {
237
+ for (const p of invalid) log.warn(`Not found: ${p}`);
238
+ }
239
+
240
+ return valid.length > 0 ? valid : paths; // return all — loadDocs will warn again, triggers retry
241
+ }
242
+
243
+ function discoverDocCandidates(cwd) {
244
+ const IGNORE = new Set(['node_modules', '.git', '.github', 'dist', 'build', '.next', 'coverage', '.turbo', '.vercel']);
245
+ const DOC_EXTS = new Set(['.md', '.txt', '.rst', '.mdx']);
246
+ const DOC_NAMES = new Set(['package.json']);
247
+ const results = [];
248
+
249
+ function walk(dir, prefix) {
250
+ let entries;
251
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
252
+ for (const entry of entries) {
253
+ if (entry.name.startsWith('.') && entry.name !== '.github') continue;
254
+ if (IGNORE.has(entry.name)) continue;
255
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
256
+ if (entry.isDirectory()) {
257
+ if (rel.split('/').length < 3) walk(join(dir, entry.name), rel);
258
+ } else {
259
+ const ext = extname(entry.name).toLowerCase();
260
+ if (DOC_EXTS.has(ext) || DOC_NAMES.has(entry.name.toLowerCase())) {
261
+ results.push(rel);
262
+ }
263
+ }
264
+ }
265
+ }
266
+
267
+ walk(cwd, '');
268
+ return results.sort();
269
+ }
270
+
271
+ // ── --doc file loading + extraction ───────────────────────────────────
272
+
273
+ function loadDocs(docPaths, cwd) {
274
+ const empty = { files: [], projectName: '', oneLiner: '', goals: '', constraints: '', raw: '' };
275
+ if (!docPaths || docPaths.length === 0) return empty;
276
+
277
+ const sections = [];
278
+ const files = [];
279
+ let projectName = '';
280
+ let oneLiner = '';
281
+ let goals = '';
282
+ let constraints = '';
283
+
284
+ for (const rawPath of docPaths) {
285
+ const abs = resolve(cwd, rawPath);
286
+ if (!existsSync(abs)) {
287
+ log.warn(`--doc: file not found: ${rawPath}`);
288
+ continue;
289
+ }
290
+
291
+ let content;
292
+ try {
293
+ content = readFileSync(abs, 'utf8');
294
+ } catch (err) {
295
+ log.warn(`--doc: cannot read ${rawPath}: ${err.message}`);
296
+ continue;
297
+ }
298
+
299
+ const name = basename(abs);
300
+ files.push(name);
301
+ sections.push(`### ${name}\n\n${content.trim()}`);
302
+
303
+ // ── Heuristic extraction ──────────────────────────────────────
304
+ // Try package.json first (structured data)
305
+ if (name === 'package.json') {
306
+ try {
307
+ const pkg = JSON.parse(content);
308
+ if (pkg.name && !projectName) projectName = pkg.name;
309
+ if (pkg.description && !oneLiner) oneLiner = pkg.description;
310
+ } catch { /* ignore malformed JSON */ }
311
+ continue;
312
+ }
313
+
314
+ // For markdown/text files — extract from headings and first paragraph
315
+ const lines = content.split('\n');
316
+ for (let i = 0; i < lines.length; i++) {
317
+ const line = lines[i].trim();
318
+
319
+ // First H1 → project name hint
320
+ if (!projectName && /^#\s+/.test(line)) {
321
+ projectName = line.replace(/^#+\s*/, '').trim();
322
+ continue;
323
+ }
324
+
325
+ // First non-empty paragraph after H1 → one-liner hint
326
+ if (projectName && !oneLiner && line && !line.startsWith('#') && !line.startsWith('```') && !line.startsWith('- ') && !line.startsWith('|')) {
327
+ oneLiner = line.length > 120 ? line.slice(0, 117) + '...' : line;
328
+ continue;
329
+ }
330
+
331
+ // ## Goal / ## Purpose / ## Overview → goals hint
332
+ if (!goals && /^##\s+(goal|purpose|overview|objective|vision)/i.test(line)) {
333
+ const body = collectSection(lines, i + 1);
334
+ if (body) goals = body;
335
+ continue;
336
+ }
337
+
338
+ // ## Constraints → constraints hint
339
+ if (!constraints && /^##\s+(constraint|requirement|limit|scope)/i.test(line)) {
340
+ const body = collectSection(lines, i + 1);
341
+ if (body) constraints = body;
342
+ continue;
343
+ }
344
+ }
345
+ }
346
+
347
+ return {
348
+ files,
349
+ projectName,
350
+ oneLiner,
351
+ goals,
352
+ constraints,
353
+ raw: sections.join('\n\n---\n\n'),
354
+ };
355
+ }
356
+
357
+ /** Collect text from startIdx until the next heading or EOF. */
358
+ function collectSection(lines, startIdx) {
359
+ const out = [];
360
+ for (let i = startIdx; i < lines.length; i++) {
361
+ if (/^##?\s+/.test(lines[i]) && out.length > 0) break;
362
+ const trimmed = lines[i].trim();
363
+ if (trimmed) out.push(trimmed);
364
+ if (out.length >= 3) break; // Keep it short — just the first few lines
365
+ }
366
+ return out.join(' ');
367
+ }
@@ -78,8 +78,8 @@ const COST_MODES = [
78
78
  { title: 'Mixed', value: 'mixed', description: 'Premium Coder + Reviewer, cheap everything else.' },
79
79
  ];
80
80
 
81
- export async function interview(detected, args) {
82
- if (args.yes) return defaults(detected);
81
+ export async function interview(detected, args, docHints = {}) {
82
+ if (args.yes) return defaults(detected, docHints);
83
83
 
84
84
  const onCancel = () => {
85
85
  throw new Error('Interview cancelled. Nothing was written.');
@@ -92,13 +92,13 @@ export async function interview(detected, args) {
92
92
  type: 'text',
93
93
  name: 'projectName',
94
94
  message: 'Project name',
95
- initial: detected.projectName,
95
+ initial: docHints.projectName || detected.projectName,
96
96
  },
97
97
  {
98
98
  type: 'text',
99
99
  name: 'oneLiner',
100
100
  message: 'One-line description (becomes PROJECT.md vision)',
101
- initial: '',
101
+ initial: docHints.oneLiner || '',
102
102
  },
103
103
  ],
104
104
  { onCancel },
@@ -159,11 +159,13 @@ export async function interview(detected, args) {
159
159
  type: 'text',
160
160
  name: 'goals',
161
161
  message: 'Primary goal of this project (one sentence)',
162
+ initial: docHints.goals || '',
162
163
  },
163
164
  {
164
165
  type: 'text',
165
166
  name: 'constraints',
166
167
  message: 'Hard constraints (perf, deps, deploy, compliance — one sentence, optional)',
168
+ initial: docHints.constraints || '',
167
169
  },
168
170
  {
169
171
  type: 'select',
@@ -183,30 +185,32 @@ export async function interview(detected, args) {
183
185
  );
184
186
 
185
187
  return normalize({
186
- ...defaults(detected),
188
+ ...defaults(detected, docHints),
187
189
  ...basic,
188
190
  ...stackAnswers,
189
191
  ...rest,
190
192
  presetId: preset,
191
193
  quickstart: chosenPreset?.quickstart || '',
194
+ docs: docHints.raw || '',
192
195
  });
193
196
  }
194
197
 
195
198
  /** Default stack is Next.js + TypeScript when nothing detected and --yes. */
196
- function defaults(detected) {
199
+ function defaults(detected, docHints = {}) {
197
200
  const hasDetected = detected.stacks.length > 0;
198
201
  const fallback = STACK_PRESETS[0]; // Next.js (TypeScript)
199
202
  return {
200
- projectName: detected.projectName,
201
- oneLiner: '',
203
+ projectName: docHints.projectName || detected.projectName,
204
+ oneLiner: docHints.oneLiner || '',
202
205
  stack: hasDetected ? detected.stacks.map((s) => s.label) : fallback.stack,
203
206
  frameworks: hasDetected ? [] : fallback.frameworks,
204
- goals: '',
205
- constraints: '',
207
+ goals: docHints.goals || '',
208
+ constraints: docHints.constraints || '',
206
209
  costMode: 'premium',
207
210
  snark: true,
208
211
  presetId: hasDetected ? 'custom' : fallback.value,
209
212
  quickstart: hasDetected ? '' : fallback.quickstart,
213
+ docs: docHints.raw || '',
210
214
  };
211
215
  }
212
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
  };
@@ -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}}._