cli-five 0.2.15 → 0.2.16

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/README.md CHANGED
@@ -10,7 +10,14 @@
10
10
  npx cli-five init
11
11
  ```
12
12
 
13
- Interviews you, lets you pick a target platform and model provider, scaffolds agents + memory files + stack-specific instructions, discovers skills, and tells you what to do next.
13
+ By default `init` is **minimal**: it scaffolds the 5-agent team and the tooling they need, asking only for the target platform and (when it can't find them) your project name and one-liner. Name and description are auto-pulled from `package.json` and `README.md`. Optional integrations — including CodeGraph, skills, and instruction files — are **not** installed unless you ask.
14
+
15
+ ```bash
16
+ npx cli-five init --full-interview # legacy: docs, goals, persona, model picker, CodeGraph, skills, instructions
17
+ npx cli-five init --codegraph # minimal, but add the CodeGraph MCP + AGENTS.md block
18
+ ```
19
+
20
+ `--full-interview` (or passing `--doc`) restores the complete guided setup: stack presets, goals, constraints, persona toggle, per-agent model overrides, Copilot cost modes, CodeGraph, skill discovery, and stack-specific instruction generation. Individual pieces can also be toggled with `--codegraph`, `--skills`, `--instructions`, and `--persona`.
14
21
 
15
22
  ### Quick plugin install (personal use, Copilot only)
16
23
 
@@ -44,9 +51,9 @@ your-repo/
44
51
  │ │ ├── coder.agent.md
45
52
  │ │ ├── designer.agent.md
46
53
  │ │ └── reviewer.agent.md
47
- │ ├── copilot-instructions.md # Persona + project mandates from interview
48
- │ ├── instructions/ # Stack-specific coding guidelines
49
- │ └── skills/ # Installed skills from awesome-copilot + skills.sh
54
+ │ ├── copilot-instructions.md # Project mandates (persona is opt-in)
55
+ │ ├── instructions/ # Container; stack guidelines via --instructions
56
+ │ └── skills/ # Container; skills via --skills
50
57
  ├── .vscode/mcp.json # CodeGraph MCP server (optional)
51
58
  ├── AGENTS.md # Tool-agnostic project context (agents.md standard)
52
59
  ├── PROJECT.md # Long-form vision (rarely changes)
@@ -68,8 +75,8 @@ your-repo/
68
75
  │ ├── designer.md
69
76
  │ └── reviewer.md
70
77
  ├── opencode.json # Project config, model defaults, CodeGraph MCP (optional)
71
- ├── .github/instructions/ # Stack-specific coding guidelines
72
- ├── .github/skills/ # Installed skills
78
+ ├── .github/instructions/ # Container; stack guidelines via --instructions
79
+ ├── .github/skills/ # Container; skills via --skills
73
80
  ├── AGENTS.md # Project rules + optional CodeGraph block
74
81
  ├── PROJECT.md
75
82
  ├── STATE.md
@@ -94,7 +101,10 @@ The plugin agents and scaffolded `.github/agents/*.agent.md` templates are inten
94
101
  ## Commands
95
102
 
96
103
  ```bash
97
- npx cli-five init # interview + scaffold + skill discovery
104
+ npx cli-five init # minimal scaffold (5 agents + required tooling)
105
+ npx cli-five init --full-interview # full guided setup (interview, models, skills, instructions)
106
+ npx cli-five add <name> # install an optional add-on (dispatcher; targets land later)
107
+ npx cli-five list-addons # show installed vs. available add-ons
98
108
  npx cli-five doctor # validate an existing cli-five setup
99
109
  npx cli-five list-stacks # show detectable tech stacks
100
110
  npx cli-five help
@@ -104,33 +114,59 @@ npx cli-five help
104
114
 
105
115
  | Flag | Effect |
106
116
  |---|---|
107
- | `--yes`, `-y` | Accept interview defaults (overwrite gate still active) |
117
+ | `--yes`, `-y` | Accept defaults (overwrite gate still active) |
108
118
  | `--force` | Overwrite without confirmation (use with `--yes`) |
109
119
  | `--dry-run` | Print actions, write nothing |
110
- | `--no-skills` | Skip skills.sh discovery step |
111
- | `--no-codegraph` | Skip CodeGraph MCP + instructions |
120
+ | `--full-interview`, `--full` | Run the full legacy interview (docs, goals/constraints/persona, model picker). Also enables CodeGraph, skills + instructions |
121
+ | `--skills` / `--no-skills` | Force skill discovery on/off |
122
+ | `--instructions` / `--no-instructions` | Force stack-specific instruction generation on/off |
123
+ | `--persona` / `--no-persona` | Include / omit the snarky persona block |
124
+ | `--codegraph` / `--no-codegraph` | Force CodeGraph MCP + instructions on/off (default: on with `--full-interview`, off otherwise) |
112
125
  | `--target <t>` | Target platform: `copilot` or `opencode` (default: `copilot`) |
113
126
  | `--provider <p>` | Model provider: `copilot`, `opencode`, `opencode-go` (default: platform default) |
114
127
  | `--cost-mode <m>` | Set cost mode for Copilot: `premium`, `cheap`, or `mixed` |
115
- | `--doc <file>` | Read project docs to pre-fill interview (repeatable) |
128
+ | `--doc <file>` | Read project docs to pre-fill interview (repeatable; implies `--full-interview`) |
116
129
  | `--cwd <path>` | Run against a different directory |
117
130
 
118
131
  ## What `init` does
119
132
 
133
+ Default (`npx cli-five init`):
134
+
120
135
  1. **Detect** — fingerprints stack (Node/TS, Python, .NET, Kotlin, Rust, Go, etc.). Brownfield-aware.
121
- 2. **Choose platform** — GitHub Copilot or OpenCode, with optional CodeGraph pairing.
136
+ 2. **Choose platform** — explicit `--target`, auto-detected from an existing scaffold, or prompted.
122
137
  3. **git init** — if needed. Asks first.
123
138
  4. **Overwrite gate** — double-confirms ("Proceed?" then "R U Sure?"). Only `--force --yes` bypasses.
124
- 5. **Project info** — name, one-liner, stack, frameworks, goals, constraints, persona toggle.
125
- 6. **Model configuration** — pick a provider and optionally override each agent's model.
126
- 7. **Scaffold** — writes platform-specific files. Swaps `model:` per provider/selection.
127
- 8. **Skill discovery** — multi-source discovery from **awesome-copilot** and **skills.sh**.
128
- 9. **Custom instructions** — generates stack-specific `.instructions.md` files for detected languages.
129
- 10. **Next steps** — platform-specific instructions. No button-clicking required.
139
+ 5. **Project info** — name and one-liner auto-pulled from `package.json` / `README.md`; asks only when missing or ambiguous.
140
+ 6. **Model configuration** — platform/provider defaults, no prompt.
141
+ 7. **Scaffold** — writes the 5 agents + required project memory/tooling files. Swaps `model:` per provider defaults.
142
+ 8. **Done** — prints platform-specific next steps.
143
+
144
+ `--full-interview` additionally runs:
145
+
146
+ - **Docs / manual interview** — project docs, stack preset, goals, constraints.
147
+ - **Model customization** — provider picker + per-agent model overrides.
148
+ - **Persona toggle** — snarky persona block.
149
+ - **CodeGraph** — MCP registration + `AGENTS.md` block (also available standalone via `--codegraph`).
150
+ - **Skill discovery** — multi-source discovery from **awesome-copilot** and **skills.sh**.
151
+ - **Custom instructions** — stack-specific `.instructions.md` files for detected languages.
152
+
153
+ ## Add-ons
154
+
155
+ Optional integrations live outside the default scaffold and are installed with `cli-five add <name>`.
156
+
157
+ ```bash
158
+ npx cli-five add # list known targets
159
+ npx cli-five add codegraph # stub in this release
160
+ npx cli-five list-addons # installed vs. available status
161
+ ```
162
+
163
+ This release ships the **dispatcher and merge utility** only — no integration is wired in yet. Jev and the CodeGraph migration are future commits.
164
+
165
+ Add-ons use `mergeBlock(file, markerFence, content)` to layer a fenced block into an existing JSON or Markdown file without touching the rest of it (distinct from init's blunt overwrite gate). Markdown gets `<!-- NAME_START -->` / `<!-- NAME_END -->` fences; JSON is deep-merged with existing keys preserved.
130
166
 
131
167
  ## Model providers
132
168
 
133
- During `init` you can choose the model provider and optionally customize each agent's model.
169
+ During `init --full-interview` you can choose the model provider and optionally customize each agent's model. The minimal `init` path uses provider defaults (honouring `--provider` and `--cost-mode`).
134
170
 
135
171
  | Provider | Platform | Example model |
136
172
  |---|---|---|
@@ -155,7 +191,7 @@ Change anytime by editing the `model:` line in the agent files.
155
191
 
156
192
  ## CodeGraph
157
193
 
158
- CodeGraph adds a local, graph-backed codebase context server. When enabled, cli-five:
194
+ CodeGraph adds a local, graph-backed codebase context server. It is **off by default** in the minimal scaffold; enable it with `--codegraph` or `--full-interview`. When enabled, cli-five:
159
195
 
160
196
  - Registers the CodeGraph MCP server in `opencode.json` (OpenCode) or `.vscode/mcp.json` (Copilot).
161
197
  - Adds a marker-fenced CodeGraph section to `AGENTS.md`.
@@ -184,7 +220,7 @@ No extra settings required. Project agents live in `.opencode/agents/` and the p
184
220
 
185
221
  ## Skill discovery
186
222
 
187
- cli-five searches **two sources** for skills matching your detected stack:
223
+ Skill discovery runs during `init --full-interview` (or when you pass `--skills`). cli-five searches **two sources** for skills matching your detected stack:
188
224
 
189
225
  | Source | What it has | Stars |
190
226
  |---|---|---|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cli-five",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "description": "Code Like I'm Five — scaffold a 5-agent AI team (GitHub Copilot or OpenCode) into any repo.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,96 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Add-on registry for `cli-five add <name>`.
6
+ *
7
+ * An add-on describes an optional integration that can be layered onto an
8
+ * already-scaffolded repo. Entries without a `run` function are reserved
9
+ * (dispatcher/stub) targets — the mechanism is live, the integration is not.
10
+ *
11
+ * To wire a real target later: give it a `run({ cwd, args })` function (the
12
+ * `add` command will invoke it) and update `available`/`note` accordingly.
13
+ */
14
+ export const ADDONS = {
15
+ codegraph: {
16
+ name: 'codegraph',
17
+ label: 'CodeGraph',
18
+ description: 'Graph-backed codebase context MCP server and agent instructions.',
19
+ available: false,
20
+ note: 'Currently installed by init (on by default). `add` migration is a future commit.',
21
+ detect: detectCodeGraph,
22
+ run: null,
23
+ },
24
+ jev: {
25
+ name: 'jev',
26
+ label: 'Jev',
27
+ description: 'Optional Jev integration (purpose to be defined in a future commit).',
28
+ available: false,
29
+ note: 'Reserved target. No integration wired in this release.',
30
+ detect: () => [],
31
+ run: null,
32
+ },
33
+ };
34
+
35
+ export const ADDON_NAMES = Object.keys(ADDONS);
36
+
37
+ export function listAddons() {
38
+ return Object.values(ADDONS);
39
+ }
40
+
41
+ export function getAddon(name) {
42
+ if (!name) return undefined;
43
+ return ADDONS[String(name).toLowerCase()];
44
+ }
45
+
46
+ /**
47
+ * Inspect a workspace for evidence that an add-on is already present.
48
+ * Returns a list of human-readable signals (empty when not detected).
49
+ */
50
+ export function detectAddon(addon, cwd) {
51
+ if (!addon || typeof addon.detect !== 'function') return [];
52
+ try {
53
+ return addon.detect(cwd) || [];
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+
59
+ // ── Detectors ─────────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Detect CodeGraph artifacts written by init today. Read-only: this inspects
63
+ * existing registration without modifying it.
64
+ */
65
+ export function detectCodeGraph(cwd) {
66
+ const signals = [];
67
+
68
+ const opencodePath = join(cwd, 'opencode.json');
69
+ const opencodeCfg = readJson(opencodePath);
70
+ if (opencodeCfg?.mcp?.codegraph) signals.push('opencode.json mcp.codegraph');
71
+
72
+ const mcpPath = join(cwd, '.vscode', 'mcp.json');
73
+ const mcpCfg = readJson(mcpPath);
74
+ if (mcpCfg?.servers?.codegraph) signals.push('.vscode/mcp.json servers.codegraph');
75
+
76
+ const agentsPath = join(cwd, 'AGENTS.md');
77
+ if (existsSync(agentsPath)) {
78
+ try {
79
+ const body = readFileSync(agentsPath, 'utf8');
80
+ if (body.includes('<!-- CODEGRAPH_START -->')) signals.push('AGENTS.md CodeGraph section');
81
+ } catch {
82
+ /* ignore */
83
+ }
84
+ }
85
+
86
+ return signals;
87
+ }
88
+
89
+ function readJson(filePath) {
90
+ if (!existsSync(filePath)) return null;
91
+ try {
92
+ return JSON.parse(readFileSync(filePath, 'utf8'));
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
package/src/cli.mjs CHANGED
@@ -5,6 +5,8 @@ import { join } from 'node:path';
5
5
  import { init } from './commands/init.mjs';
6
6
  import { doctor } from './commands/doctor.mjs';
7
7
  import { listStacks } from './commands/list-stacks.mjs';
8
+ import { add } from './commands/add.mjs';
9
+ import { listAddonsCommand } from './commands/list-addons.mjs';
8
10
  import { STACK_SIGNATURES } from './steps/detect.mjs';
9
11
 
10
12
  const HELP = `${kleur.bold('cli-five')} ${kleur.gray('— Code Like I\'m Five')}
@@ -14,7 +16,9 @@ ${kleur.bold('Usage')}
14
16
  npx cli-five <command> [options]
15
17
 
16
18
  ${kleur.bold('Commands')}
17
- init Interview + scaffold agents + project memory files
19
+ init Scaffold the 5 agents + required tooling (minimal by default)
20
+ add <name> Install an optional add-on (dispatcher; targets land later)
21
+ list-addons Show installed vs. available add-ons
18
22
  doctor Validate an existing cli-five setup
19
23
  list-stacks Show detectable tech stacks
20
24
  help Show this message
@@ -23,12 +27,19 @@ ${kleur.bold('Flags')}
23
27
  --yes, -y Accept defaults and skip confirmations (still gated on overwrite)
24
28
  --force Overwrite without confirmation. Dangerous. Use with --yes.
25
29
  --dry-run Print actions without writing files
30
+ --full-interview Run the full legacy interview (docs, goals, persona, models). Enables CodeGraph by default
31
+ --skills Force skill discovery (default: full interview only)
26
32
  --no-skills Skip the skills.sh discovery step
27
- --doc <file> Read project docs to pre-fill interview (repeatable)
33
+ --instructions Force stack-specific instruction generation
34
+ --no-instructions Skip instruction generation
35
+ --persona Include the snarky persona block
36
+ --no-persona Omit the snarky persona block
37
+ --codegraph Add CodeGraph MCP + instructions (default: only with --full-interview)
38
+ --no-codegraph Skip CodeGraph MCP + instructions
39
+ --doc <file> Read project docs to pre-fill interview (repeatable; implies --full-interview)
28
40
  --cost-mode <m> Override cost mode (premium, cheap, mixed) — skips interview question
29
41
  --target <t> Target platform: copilot or opencode (default: copilot)
30
42
  --provider <p> Model provider: copilot, opencode, opencode-go (default: platform default)
31
- --no-codegraph Skip CodeGraph MCP + instructions
32
43
  --cwd <path> Run against a directory other than the current one
33
44
  --version, -v Print version and exit
34
45
  `;
@@ -40,6 +51,10 @@ export async function run(argv) {
40
51
  switch (cmd) {
41
52
  case 'init':
42
53
  return init(args);
54
+ case 'add':
55
+ return add(args);
56
+ case 'list-addons':
57
+ return listAddonsCommand(args);
43
58
  case 'doctor':
44
59
  return doctor(args);
45
60
  case 'list-stacks':
@@ -68,8 +83,11 @@ function parse(argv) {
68
83
  yes: false,
69
84
  force: false,
70
85
  dryRun: false,
71
- skills: true,
72
- codegraph: true,
86
+ fullInterview: false,
87
+ skills: null,
88
+ instructions: null,
89
+ persona: null,
90
+ codegraph: null,
73
91
  docs: [],
74
92
  costMode: null,
75
93
  target: null,
@@ -81,7 +99,14 @@ function parse(argv) {
81
99
  if (a === '--yes' || a === '-y') out.yes = true;
82
100
  else if (a === '--force') out.force = true;
83
101
  else if (a === '--dry-run') out.dryRun = true;
102
+ else if (a === '--full-interview' || a === '--full') out.fullInterview = true;
103
+ else if (a === '--skills') out.skills = true;
84
104
  else if (a === '--no-skills') out.skills = false;
105
+ else if (a === '--instructions') out.instructions = true;
106
+ else if (a === '--no-instructions') out.instructions = false;
107
+ else if (a === '--persona') out.persona = true;
108
+ else if (a === '--no-persona') out.persona = false;
109
+ else if (a === '--codegraph') out.codegraph = true;
85
110
  else if (a === '--no-codegraph') out.codegraph = false;
86
111
  else if (a === '--doc') out.docs.push(argv[++i]);
87
112
  else if (a === '--cost-mode') out.costMode = argv[++i];
@@ -0,0 +1,55 @@
1
+ import kleur from 'kleur';
2
+ import { log } from '../util/log.mjs';
3
+ import { ADDON_NAMES, getAddon, listAddons } from '../addons/registry.mjs';
4
+
5
+ /**
6
+ * `cli-five add <name>` — dispatch to a registered add-on.
7
+ *
8
+ * This pass ships the dispatcher only. Registered targets without a `run`
9
+ * function respond with a clear stub so the mechanism can be exercised
10
+ * end-to-end without pretending an integration exists.
11
+ */
12
+ export async function add(args) {
13
+ const name = args._[1];
14
+
15
+ if (!name || name === '--help' || name === '-h' || name === 'help') {
16
+ printAddHelp();
17
+ return;
18
+ }
19
+
20
+ const addon = getAddon(name);
21
+ if (!addon) {
22
+ log.err(`Unknown add-on: ${name}`);
23
+ printAvailable(addon => addon.name);
24
+ process.exitCode = 2;
25
+ return;
26
+ }
27
+
28
+ if (typeof addon.run !== 'function') {
29
+ log.warn(`${addon.label} is registered but not implemented yet.`);
30
+ log.info('The add dispatcher works — this target is reserved for a future release.');
31
+ if (addon.note) log.dim(addon.note);
32
+ log.dim('Nothing was written to your project.');
33
+ return;
34
+ }
35
+
36
+ log.step(`add ${addon.name}`);
37
+ await addon.run({ cwd: args.cwd, args });
38
+ }
39
+
40
+ function printAddHelp() {
41
+ process.stdout.write(`${kleur.bold('cli-five add')} — install an optional add-on\n\n`);
42
+ process.stdout.write(`${kleur.bold('Usage')}\n npx cli-five add <name>\n\n`);
43
+ printAvailable();
44
+ }
45
+
46
+ function printAvailable() {
47
+ process.stdout.write(`${kleur.bold('Available add-ons')}\n`);
48
+ for (const addon of listAddons()) {
49
+ const addable = typeof addon.run === 'function';
50
+ const statusText = (addable ? 'available' : 'planned').padEnd(10);
51
+ const status = addable ? kleur.green(statusText) : kleur.yellow(statusText);
52
+ process.stdout.write(` ${addon.name.padEnd(12)} ${status} ${kleur.dim(addon.description)}\n`);
53
+ }
54
+ process.stdout.write(`\n${kleur.dim(`Known names: ${ADDON_NAMES.join(', ')}`)}\n`);
55
+ }
@@ -5,18 +5,30 @@ import { resolve, basename, join, extname } from 'node:path';
5
5
  import { log } from '../util/log.mjs';
6
6
  import { detect } from '../steps/detect.mjs';
7
7
  import { confirmOverwriteIfNeeded } from '../steps/confirm.mjs';
8
- import { interview } from '../steps/interview.mjs';
8
+ import { interview, minimalInterview } from '../steps/interview.mjs';
9
9
  import { scaffold, summarize } from '../steps/scaffold.mjs';
10
10
  import { skillDiscovery } from '../steps/skills.mjs';
11
11
  import { instructionGeneration } from '../steps/instructions.mjs';
12
- import { choosePlatform, chooseModels } from '../steps/platform.mjs';
12
+ import { choosePlatform, chooseModels, resolveCodegraphDefault } from '../steps/platform.mjs';
13
13
  import { isGitRepo, gitInit } from '../util/git.mjs';
14
14
  import { platformLabel } from '../util/platforms.mjs';
15
+ import { autoProjectInfo } from '../util/project.mjs';
15
16
 
16
17
  export async function init(args) {
17
18
  const cwd = args.cwd;
18
19
  log.raw(kleur.bold().magenta('\ncli-five init') + kleur.gray(` ${cwd}`));
19
20
 
21
+ // ── Mode ───────────────────────────────────────────────────────────
22
+ // Default init is minimal: the 5 agents + required tooling, asking only for
23
+ // platform and (when needed) name/one-liner. The legacy interview — docs,
24
+ // goals/constraints/persona, model customization, skills, instructions — is
25
+ // opt-in via --full-interview (or --doc). Per-feature flags can also force
26
+ // skills/instructions/persona without the whole interview.
27
+ const docs = Array.isArray(args.docs) ? args.docs : [];
28
+ const fullInterview = Boolean(args.fullInterview) || docs.length > 0;
29
+ const runSkills = args.skills !== null ? Boolean(args.skills) : fullInterview;
30
+ const runInstructions = args.instructions !== null ? Boolean(args.instructions) : fullInterview;
31
+
20
32
  // 1. Detect
21
33
  log.step('1/8 Detect workspace');
22
34
  const detected = detect(cwd);
@@ -27,10 +39,17 @@ export async function init(args) {
27
39
 
28
40
  // 2. Platform + CodeGraph
29
41
  log.step('2/8 Choose platform');
30
- const { platform, codegraph } = await choosePlatform(args);
42
+ const codegraphDefault = resolveCodegraphDefault(args, fullInterview);
43
+ const { platform, codegraph } = await choosePlatform(args, {
44
+ autoDetect: !fullInterview,
45
+ askCodegraph: fullInterview && args.codegraph === null,
46
+ codegraphDefault,
47
+ });
31
48
  log.info(`Target: ${kleur.bold(platformLabel(platform))}`);
32
49
  if (codegraph) log.info(`CodeGraph: ${kleur.green('enabled')}`);
33
- else log.info('CodeGraph: disabled');
50
+ else if (fullInterview || args.codegraph === false) log.info('CodeGraph: disabled');
51
+ else log.info(`CodeGraph: ${kleur.gray('disabled')} ${kleur.dim('(enable with --codegraph or --full-interview)')}`);
52
+ if (!fullInterview) log.dim('Minimal init. Full interview: npx cli-five init --full-interview');
34
53
 
35
54
  // 3. git init if needed
36
55
  if (!detected.hasGit) {
@@ -51,43 +70,57 @@ export async function init(args) {
51
70
  }
52
71
  if (!detected.hasAgents && !detected.hasCopilotInstructions) log.dim('No collisions.');
53
72
 
54
- // 5. Input mode — docs or manual interview
55
- log.step('4/8 Project info');
73
+ // 5. Project info + model configuration
74
+ args.__platform = platform;
56
75
  let docHints;
76
+ let modelConfig;
77
+
78
+ if (fullInterview) {
79
+ log.step('4/8 Project info');
80
+ if (docs.length > 0) {
81
+ // --doc was passed on the CLI — validate with retry
82
+ docHints = loadDocs(docs, cwd);
83
+ if (docHints.files.length === 0) {
84
+ log.warn('None of the --doc files could be loaded.');
85
+ }
86
+ } else if (args.yes) {
87
+ docHints = loadDocs([], cwd);
88
+ } else {
89
+ docHints = await collectDocFiles(cwd);
90
+ }
57
91
 
58
- if (args.docs.length > 0) {
59
- // --doc was passed on the CLI — validate with retry
60
- docHints = loadDocs(args.docs, cwd);
61
- if (docHints.files.length === 0) {
62
- log.warn('None of the --doc files could be loaded.');
92
+ if (docHints.files.length > 0 && docs.length > 0) {
93
+ log.info(`Loaded ${docHints.files.length} doc${docHints.files.length > 1 ? 's' : ''}: ${docHints.files.join(', ')}`);
94
+ if (docHints.projectName) log.dim(` → project name: ${docHints.projectName}`);
95
+ if (docHints.oneLiner) log.dim(` → description: ${docHints.oneLiner}`);
63
96
  }
64
- } else if (args.yes) {
65
- docHints = loadDocs([], cwd);
97
+
98
+ log.step('5/8 Model configuration');
99
+ modelConfig = await chooseModels(platform, args);
66
100
  } else {
67
- docHints = await collectDocFiles(cwd);
68
- }
101
+ log.step('4/8 Project info');
102
+ docHints = autoProjectInfo(cwd);
103
+ logAutoProjectInfo(docHints);
69
104
 
70
- if (docHints.files.length > 0 && args.docs.length > 0) {
71
- log.info(`Loaded ${docHints.files.length} doc${docHints.files.length > 1 ? 's' : ''}: ${docHints.files.join(', ')}`);
72
- if (docHints.projectName) log.dim(` → project name: ${docHints.projectName}`);
73
- if (docHints.oneLiner) log.dim(` → description: ${docHints.oneLiner}`);
105
+ log.step('5/8 Model configuration');
106
+ // Minimal path uses provider defaults without prompting (still honours --provider).
107
+ modelConfig = await chooseModels(platform, { ...args, yes: true });
74
108
  }
75
109
 
76
- // 6. Model configuration
77
- log.step('5/8 Model configuration');
78
- const modelConfig = await chooseModels(platform, args);
79
110
  log.info(`Provider: ${kleur.bold(modelConfig.provider)}`);
80
111
  if (modelConfig.customized) log.info('Models: customized');
81
112
  else log.info('Models: defaults');
82
113
 
83
- // 7. Interview (pre-filled from docs if available, otherwise manual)
84
- args.__platform = platform;
85
- const answers = await interview(detected, args, docHints);
114
+ // 6. Interview (minimal by default, full behind --full-interview)
115
+ const answers = fullInterview
116
+ ? await interview(detected, args, docHints)
117
+ : await minimalInterview(detected, args, docHints);
86
118
 
87
- // CLI --cost-mode override
119
+ // CLI overrides — apply to both paths.
88
120
  if (args.costMode && ['premium', 'cheap', 'mixed'].includes(args.costMode)) {
89
121
  answers.costMode = args.costMode;
90
122
  }
123
+ if (args.persona !== null) answers.snark = Boolean(args.persona);
91
124
 
92
125
  // Attach platform/model choices to answers so scaffold can use them.
93
126
  answers.platform = platform;
@@ -101,30 +134,56 @@ export async function init(args) {
101
134
  }
102
135
  if (answers.frameworks.length) log.info(`Stack: ${answers.stack.join(', ')} + ${answers.frameworks.join(', ')}`);
103
136
 
104
- // 8. Scaffold
137
+ // 7. Scaffold
105
138
  log.step('6/8 Scaffold');
106
139
  const written = scaffold({ cwd, answers, args });
107
140
  if (args.dryRun) log.warn('--dry-run: no files written. Plan:');
108
141
  log.raw(summarize(written, cwd));
109
142
  if (!args.dryRun) log.ok(`Wrote ${written.length} files.`);
110
143
 
111
- // 9. Skill discovery
144
+ // 8. Skill discovery
112
145
  log.step('7/8 Skill discovery');
113
- await skillDiscovery({ cwd, answers, args });
146
+ if (runSkills) {
147
+ await skillDiscovery({ cwd, answers, args: { ...args, skills: true } });
148
+ } else {
149
+ log.dim('Skipped (minimal init). Enable with --skills or --full-interview.');
150
+ }
114
151
 
115
- // 10. Custom instructions
152
+ // 9. Custom instructions
116
153
  log.step('8/8 Custom instructions');
117
- const instrWritten = await instructionGeneration({ cwd, answers, args });
118
- if (instrWritten && instrWritten.length > 0) {
119
- if (args.dryRun) log.warn('--dry-run: instruction plan:');
120
- for (const w of instrWritten) {
121
- log.raw(` ${w.written ? '+' : '~'} ${w.path.replace(cwd + '/', '')}`);
154
+ if (runInstructions) {
155
+ const instrWritten = await instructionGeneration({ cwd, answers, args });
156
+ if (instrWritten && instrWritten.length > 0) {
157
+ if (args.dryRun) log.warn('--dry-run: instruction plan:');
158
+ for (const w of instrWritten) {
159
+ log.raw(` ${w.written ? '+' : '~'} ${w.path.replace(cwd + '/', '')}`);
160
+ }
161
+ if (!args.dryRun) log.ok(`Wrote ${instrWritten.length} instruction file${instrWritten.length > 1 ? 's' : ''}.`);
122
162
  }
123
- if (!args.dryRun) log.ok(`Wrote ${instrWritten.length} instruction file${instrWritten.length > 1 ? 's' : ''}.`);
163
+ } else {
164
+ log.dim('Skipped (minimal init). Enable with --instructions or --full-interview.');
124
165
  }
125
166
 
126
- // 11. Next steps
127
- printNextSteps(answers);
167
+ // 10. Next steps
168
+ printNextSteps(answers, { generatedInstructions: runInstructions });
169
+ }
170
+
171
+ /** Log which project fields were auto-pulled from the workspace. */
172
+ function logAutoProjectInfo(info) {
173
+ const name = info?.name || {};
174
+ const oneLiner = info?.oneLiner || {};
175
+
176
+ if (name.value && !name.ambiguous) {
177
+ log.info(`Name: ${kleur.bold(name.value)} ${kleur.gray(`(${name.sources[0].source})`)}`);
178
+ } else if (name.ambiguous) {
179
+ log.warn(`Multiple project names found (${name.sources.map((s) => s.source).join(', ')}) — asking.`);
180
+ }
181
+
182
+ if (oneLiner.value && !oneLiner.ambiguous) {
183
+ log.info(`Tagline: ${oneLiner.value} ${kleur.gray(`(${oneLiner.sources[0].source})`)}`);
184
+ } else if (oneLiner.ambiguous) {
185
+ log.warn(`Multiple descriptions found (${oneLiner.sources.map((s) => s.source).join(', ')}) — asking.`);
186
+ }
128
187
  }
129
188
 
130
189
  async function ask(message, initial = false) {
@@ -132,7 +191,7 @@ async function ask(message, initial = false) {
132
191
  return Boolean(v);
133
192
  }
134
193
 
135
- function printNextSteps(answers) {
194
+ function printNextSteps(answers, { generatedInstructions = false } = {}) {
136
195
  const hasDocs = answers.docFiles?.length > 0;
137
196
  const platform = answers.platform || 'copilot';
138
197
  const codegraph = answers.codegraph;
@@ -165,8 +224,10 @@ function printNextSteps(answers) {
165
224
  } else {
166
225
  log.raw(kleur.gray(` read PROJECT.md and implement Phase 1.`));
167
226
  }
168
- log.raw(` 6. Review generated instruction files in .github/instructions/.`);
169
- log.raw(kleur.gray(` Edit applyTo globs and guidelines to fit your project.`));
227
+ if (generatedInstructions) {
228
+ log.raw(` 6. Review generated instruction files in .github/instructions/.`);
229
+ log.raw(kleur.gray(` Edit applyTo globs and guidelines to fit your project.`));
230
+ }
170
231
 
171
232
  if (codegraph) {
172
233
  log.raw('');
@@ -183,6 +244,9 @@ function printNextSteps(answers) {
183
244
  log.raw('');
184
245
  log.raw(kleur.dim('Edit agent models anytime by changing `model:` in .opencode/agents/*.md.'));
185
246
  }
247
+
248
+ log.raw('');
249
+ log.raw(kleur.dim('Optional integrations: npx cli-five list-addons'));
186
250
  log.raw('');
187
251
  }
188
252
 
@@ -0,0 +1,40 @@
1
+ import kleur from 'kleur';
2
+ import { log } from '../util/log.mjs';
3
+ import { detectAddon, listAddons } from '../addons/registry.mjs';
4
+
5
+ /**
6
+ * `cli-five list-addons` — show what is installed vs. available.
7
+ *
8
+ * "Installed" is detected read-only from workspace artifacts. CodeGraph is
9
+ * listed honestly even though its `add` plumbing has not moved yet.
10
+ */
11
+ export function listAddonsCommand(args) {
12
+ const cwd = args.cwd;
13
+ log.raw(kleur.bold().magenta('\ncli-five list-addons') + kleur.gray(` ${cwd}`));
14
+ log.raw('');
15
+
16
+ log.raw(` ${kleur.gray(pad('ADD-ON', 12))} ${kleur.gray(pad('STATUS', 12))} ${kleur.gray(pad('ADD', 10))} ${kleur.gray('DETAIL')}`);
17
+ log.raw(` ${'─'.repeat(12)} ${'─'.repeat(12)} ${'─'.repeat(10)} ${'─'.repeat(30)}`);
18
+
19
+ for (const addon of listAddons()) {
20
+ const signals = detectAddon(addon, cwd);
21
+ const installed = signals.length > 0;
22
+ const addable = typeof addon.run === 'function';
23
+ const detail = installed ? signals.join(', ') : addon.note || '';
24
+
25
+ const statusText = pad(installed ? 'installed' : 'not found', 12);
26
+ const status = installed ? kleur.green(statusText) : kleur.gray(statusText);
27
+ const addableText = pad(addable ? 'available' : 'planned', 10);
28
+ const addableColored = addable ? kleur.green(addableText) : kleur.yellow(addableText);
29
+
30
+ log.raw(` ${pad(addon.name, 12)} ${status} ${addableColored} ${kleur.dim(detail)}`);
31
+ }
32
+
33
+ log.raw('');
34
+ log.dim('Install with `npx cli-five add <name>` once a target is available.');
35
+ log.raw('');
36
+ }
37
+
38
+ function pad(value, width) {
39
+ return String(value).padEnd(width);
40
+ }
@@ -195,6 +195,65 @@ export async function interview(detected, args, docHints = {}) {
195
195
  });
196
196
  }
197
197
 
198
+ /**
199
+ * Minimal interview — the default `init` path.
200
+ *
201
+ * Asks for the project name and one-liner only, and only when `projectInfo`
202
+ * could not confidently supply them. Everything else (stack, goals,
203
+ * constraints, persona, cost mode) falls back to `defaults`.
204
+ *
205
+ * `projectInfo` is the shape returned by `autoProjectInfo(cwd)`.
206
+ */
207
+ export async function minimalInterview(detected, args, projectInfo = {}) {
208
+ const platform = args.__platform || 'copilot';
209
+ const nameInfo = projectInfo.name || {};
210
+ const oneLinerInfo = projectInfo.oneLiner || {};
211
+
212
+ let projectName = nameInfo.value || '';
213
+ let oneLiner = oneLinerInfo.value || '';
214
+
215
+ if (!args.yes) {
216
+ const questions = [];
217
+
218
+ if (!projectName || nameInfo.ambiguous) {
219
+ questions.push({
220
+ type: 'text',
221
+ name: 'projectName',
222
+ message: 'Project name',
223
+ initial: projectName || detected.projectName,
224
+ });
225
+ }
226
+
227
+ if (!oneLiner || oneLinerInfo.ambiguous) {
228
+ questions.push({
229
+ type: 'text',
230
+ name: 'oneLiner',
231
+ message: 'One-line description (becomes PROJECT.md vision)',
232
+ initial: oneLiner || '',
233
+ });
234
+ }
235
+
236
+ if (questions.length > 0) {
237
+ const answers = await prompts(questions, {
238
+ onCancel: () => {
239
+ throw new Error('Interview cancelled. Nothing was written.');
240
+ },
241
+ });
242
+ if (answers.projectName !== undefined) projectName = answers.projectName;
243
+ if (answers.oneLiner !== undefined) oneLiner = answers.oneLiner;
244
+ }
245
+ }
246
+
247
+ const base = defaults(detected, { projectName, oneLiner }, platform);
248
+ return normalize({
249
+ ...base,
250
+ projectName: (projectName || detected.projectName || '').trim(),
251
+ oneLiner: (oneLiner || '').trim(),
252
+ // Persona is opt-in on the minimal path (--persona / --full-interview).
253
+ snark: args.persona === true,
254
+ });
255
+ }
256
+
198
257
  /** Default stack is first preset when nothing is detected and --yes is used. */
199
258
  function defaults(detected, docHints = {}, platform = 'copilot') {
200
259
  const hasDetected = detected.stacks.length > 0;
@@ -1,5 +1,7 @@
1
1
  import kleur from 'kleur';
2
2
  import prompts from 'prompts';
3
+ import { existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
3
5
  import {
4
6
  PLATFORM_COPILOT,
5
7
  PLATFORM_OPENCODE,
@@ -21,20 +23,50 @@ import {
21
23
 
22
24
  const CUSTOM_SENTINEL = '__custom__';
23
25
 
26
+ /**
27
+ * Resolve whether CodeGraph should be enabled when no explicit `--codegraph`
28
+ * / `--no-codegraph` value was passed.
29
+ *
30
+ * The registration/opt-out mechanism is unchanged — this only decides the
31
+ * default: on for the full interview, off for minimal init.
32
+ */
33
+ export function resolveCodegraphDefault(args, fullInterview) {
34
+ if (args.codegraph === true || args.codegraph === false) return args.codegraph;
35
+ return Boolean(fullInterview);
36
+ }
37
+
24
38
  /**
25
39
  * Ask the user to choose a target platform.
26
40
  * If args.target is a valid platform, skip the prompt.
41
+ *
42
+ * Options:
43
+ * autoDetect — infer the platform from an existing scaffold before prompting
44
+ * askCodegraph — whether to ask the CodeGraph opt-out question (full interview)
45
+ * codegraphDefault — resolved default when no explicit flag was passed
46
+ *
47
+ * Note: CodeGraph registration and the `--no-codegraph` opt-out are unchanged;
48
+ * this only controls whether the question is asked / what the default is.
27
49
  */
28
- export async function choosePlatform(args) {
50
+ export async function choosePlatform(args, { autoDetect = false, askCodegraph = true, codegraphDefault } = {}) {
51
+ if (codegraphDefault === undefined) codegraphDefault = args.codegraph !== false;
52
+
29
53
  if (args.target) {
30
54
  const t = String(args.target).toLowerCase();
31
55
  if (PLATFORMS.includes(t)) {
32
- return { platform: t, codegraph: args.codegraph !== false };
56
+ return { platform: t, codegraph: codegraphDefault };
57
+ }
58
+ }
59
+
60
+ if (autoDetect) {
61
+ const existing = detectExistingPlatform(args.cwd);
62
+ if (existing) {
63
+ log.dim(`Detected existing ${platformLabel(existing)} scaffold.`);
64
+ return { platform: existing, codegraph: codegraphDefault };
33
65
  }
34
66
  }
35
67
 
36
68
  if (args.yes) {
37
- return { platform: PLATFORM_COPILOT, codegraph: args.codegraph !== false };
69
+ return { platform: PLATFORM_COPILOT, codegraph: codegraphDefault };
38
70
  }
39
71
 
40
72
  const { platform } = await prompts({
@@ -60,6 +92,10 @@ export async function choosePlatform(args) {
60
92
  throw new Error('Platform selection cancelled. Nothing was written.');
61
93
  }
62
94
 
95
+ if (!askCodegraph) {
96
+ return { platform, codegraph: codegraphDefault };
97
+ }
98
+
63
99
  const { codegraph } = await prompts({
64
100
  type: 'confirm',
65
101
  name: 'codegraph',
@@ -70,6 +106,13 @@ export async function choosePlatform(args) {
70
106
  return { platform, codegraph: codegraph !== false };
71
107
  }
72
108
 
109
+ /** Infer an existing scaffold's platform, or null when there is no scaffold. */
110
+ export function detectExistingPlatform(cwd) {
111
+ if (existsSync(join(cwd, '.opencode', 'agents'))) return PLATFORM_OPENCODE;
112
+ if (existsSync(join(cwd, '.github', 'agents', 'orchestrator.agent.md'))) return PLATFORM_COPILOT;
113
+ return null;
114
+ }
115
+
73
116
  /**
74
117
  * Ask the user whether to customize models, pick a provider, and optionally
75
118
  * override per-agent models.
@@ -0,0 +1,170 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname, extname } from 'node:path';
3
+
4
+ const JSON_EXTENSIONS = new Set(['.json']);
5
+
6
+ /**
7
+ * Surgically merge a named block into an existing file **without touching the
8
+ * rest of it**. Unlike init's blunt overwrite gate (which replaces whole files),
9
+ * `mergeBlock` is designed for `cli-five add` against repos that are already
10
+ * scaffolded.
11
+ *
12
+ * Two strategies, chosen by file extension:
13
+ *
14
+ * JSON (.json)
15
+ * `content` (a plain object or a JSON string) is deep-merged into the file.
16
+ * Existing keys are preserved; overlapping scalar/array keys are replaced.
17
+ * Passing `fenceKey` nests the patch under that top-level key instead of
18
+ * merging at the root (e.g. `{ fenceKey: 'mcp' }` for `opencode.json`).
19
+ *
20
+ * Markdown / other text
21
+ * `content` is wrapped in HTML-comment fences derived from `markerFence`:
22
+ * <!-- NAME_START -->
23
+ * ...content...
24
+ * <!-- NAME_END -->
25
+ * If the fences already exist the body between them is replaced in place;
26
+ * otherwise the block is appended. Re-running is idempotent.
27
+ *
28
+ * @param {string} filePath Absolute path to the target file.
29
+ * @param {string|{name?:string,start?:string,end?:string}} markerFence
30
+ * Block name (e.g. "codegraph"), or explicit `{ start, end }` markers.
31
+ * @param {string|object} content Markdown body, or object / JSON string.
32
+ * @param {object} [options]
33
+ * @param {boolean} [options.dryRun] Compute but do not write.
34
+ * @param {string|null} [options.fenceKey] JSON only — nest the merge under this key.
35
+ * @param {boolean} [options.track] JSON only — record the block name under `$cliFive`.
36
+ * @param {string} [options.metaKey] JSON only — metadata key (default `$cliFive`).
37
+ * @returns {{path:string, block:string, action:'created'|'updated'|'unchanged', dryRun:boolean}}
38
+ */
39
+ export function mergeBlock(filePath, markerFence, content, options = {}) {
40
+ const { dryRun = false, fenceKey = null, track = false, metaKey = '$cliFive' } = options;
41
+ const block = fenceName(markerFence);
42
+
43
+ const ext = extname(filePath).toLowerCase();
44
+ const result = JSON_EXTENSIONS.has(ext)
45
+ ? mergeJson(filePath, block, content, { fenceKey, track, metaKey })
46
+ : mergeText(filePath, markerFence, content);
47
+
48
+ if (!dryRun && (result.action === 'created' || result.action === 'updated')) {
49
+ mkdirSync(dirname(filePath), { recursive: true });
50
+ writeFileSync(filePath, result.contents);
51
+ }
52
+
53
+ return { path: filePath, block, action: result.action, dryRun };
54
+ }
55
+
56
+ // ── Markdown / text ───────────────────────────────────────────────────
57
+
58
+ function mergeText(filePath, markerFence, content) {
59
+ const { start, end } = fenceMarkers(markerFence);
60
+ const body = String(content ?? '').replace(/\s+$/, '');
61
+ const core = `${start}\n${body}\n${end}`;
62
+
63
+ if (!existsSync(filePath)) {
64
+ return { contents: `${core}\n`, action: 'created' };
65
+ }
66
+
67
+ const existing = readFileSync(filePath, 'utf8');
68
+ if (existing.trim() === '') {
69
+ return { contents: `${core}\n`, action: 'created' };
70
+ }
71
+
72
+ const pattern = new RegExp(`${escapeRegExp(start)}[\\s\\S]*?${escapeRegExp(end)}`);
73
+ if (pattern.test(existing)) {
74
+ const next = existing.replace(pattern, core);
75
+ return { contents: next, action: next === existing ? 'unchanged' : 'updated' };
76
+ }
77
+
78
+ const next = `${existing.replace(/\s+$/, '')}\n\n${core}\n`;
79
+ return { contents: next, action: 'updated' };
80
+ }
81
+
82
+ function fenceMarkers(markerFence) {
83
+ if (isPlainObject(markerFence) && markerFence.start && markerFence.end) {
84
+ return { start: markerFence.start, end: markerFence.end };
85
+ }
86
+ const name = fenceName(markerFence).toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '');
87
+ return { start: `<!-- ${name}_START -->`, end: `<!-- ${name}_END -->` };
88
+ }
89
+
90
+ // ── JSON ──────────────────────────────────────────────────────────────
91
+
92
+ function mergeJson(filePath, block, content, { fenceKey, track, metaKey }) {
93
+ let existing = {};
94
+ if (existsSync(filePath)) {
95
+ const raw = readFileSync(filePath, 'utf8').trim();
96
+ if (raw) {
97
+ try {
98
+ existing = JSON.parse(raw);
99
+ } catch (err) {
100
+ throw new Error(`mergeBlock: ${filePath} is not valid JSON: ${err.message}`);
101
+ }
102
+ }
103
+ }
104
+
105
+ if (!isPlainObject(existing)) {
106
+ throw new Error(`mergeBlock: ${filePath} must contain a JSON object at the root`);
107
+ }
108
+
109
+ let patch = content;
110
+ if (typeof patch === 'string') {
111
+ try {
112
+ patch = JSON.parse(patch);
113
+ } catch (err) {
114
+ throw new Error(`mergeBlock: content for ${filePath} is not valid JSON: ${err.message}`);
115
+ }
116
+ }
117
+ if (!isPlainObject(patch)) {
118
+ throw new Error(`mergeBlock: content for ${filePath} must be a JSON object`);
119
+ }
120
+
121
+ const before = JSON.stringify(existing);
122
+
123
+ const target = fenceKey
124
+ ? (isPlainObject(existing[fenceKey]) ? existing[fenceKey] : (existing[fenceKey] = {}))
125
+ : existing;
126
+ deepMerge(target, patch);
127
+
128
+ if (track) {
129
+ const meta = isPlainObject(existing[metaKey]) ? existing[metaKey] : (existing[metaKey] = {});
130
+ const blocks = Array.isArray(meta.blocks) ? meta.blocks : (meta.blocks = []);
131
+ if (!blocks.includes(block)) blocks.push(block);
132
+ }
133
+
134
+ const contents = `${JSON.stringify(existing, null, 2)}\n`;
135
+ const action = before === JSON.stringify(existing) && existsSync(filePath) ? 'unchanged' : (existsSync(filePath) ? 'updated' : 'created');
136
+ return { contents, action };
137
+ }
138
+
139
+ function deepMerge(target, patch) {
140
+ for (const [key, value] of Object.entries(patch)) {
141
+ if (isPlainObject(value) && isPlainObject(target[key])) {
142
+ deepMerge(target[key], value);
143
+ } else if (isPlainObject(value)) {
144
+ target[key] = deepMerge({}, value);
145
+ } else if (Array.isArray(value)) {
146
+ target[key] = [...value];
147
+ } else {
148
+ target[key] = value;
149
+ }
150
+ }
151
+ return target;
152
+ }
153
+
154
+ // ── Helpers ───────────────────────────────────────────────────────────
155
+
156
+ function fenceName(markerFence) {
157
+ if (typeof markerFence === 'string' && markerFence.trim()) return markerFence.trim();
158
+ if (isPlainObject(markerFence) && typeof markerFence.name === 'string' && markerFence.name.trim()) {
159
+ return markerFence.name.trim();
160
+ }
161
+ throw new Error('mergeBlock: markerFence must be a non-empty string or { name }');
162
+ }
163
+
164
+ function isPlainObject(value) {
165
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
166
+ }
167
+
168
+ function escapeRegExp(value) {
169
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
170
+ }
@@ -0,0 +1,140 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ // README variants checked in order. First match wins.
5
+ const README_CANDIDATES = [
6
+ 'README.md',
7
+ 'readme.md',
8
+ 'Readme.md',
9
+ 'README.MD',
10
+ 'README.markdown',
11
+ 'README.txt',
12
+ 'README',
13
+ ];
14
+
15
+ /**
16
+ * Best-effort auto-extraction of a project name and one-liner from the
17
+ * workspace itself (package.json and/or README), used by the minimal init
18
+ * interview so it only has to ask when the answer is genuinely missing or
19
+ * ambiguous.
20
+ *
21
+ * Returns:
22
+ * {
23
+ * name: { value, ambiguous, sources: [{ source, value }] },
24
+ * oneLiner: { value, ambiguous, sources: [{ source, value }] },
25
+ * }
26
+ *
27
+ * `value` is the first candidate (a safe fallback), `ambiguous` is true when
28
+ * two or more distinct candidates were found. Callers should ask the user
29
+ * whenever `ambiguous` is true or `value` is empty.
30
+ */
31
+ export function autoProjectInfo(cwd) {
32
+ const nameSources = [];
33
+ const oneLinerSources = [];
34
+
35
+ const pkgPath = join(cwd, 'package.json');
36
+ if (existsSync(pkgPath)) {
37
+ try {
38
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
39
+ if (isNonEmptyString(pkg?.name)) {
40
+ nameSources.push({ source: 'package.json', value: pkg.name.trim() });
41
+ }
42
+ if (isNonEmptyString(pkg?.description)) {
43
+ oneLinerSources.push({ source: 'package.json', value: pkg.description.trim() });
44
+ }
45
+ } catch {
46
+ /* malformed package.json — ignore */
47
+ }
48
+ }
49
+
50
+ for (const file of README_CANDIDATES) {
51
+ const filePath = join(cwd, file);
52
+ if (!existsSync(filePath)) continue;
53
+
54
+ let content;
55
+ try {
56
+ content = readFileSync(filePath, 'utf8');
57
+ } catch {
58
+ continue;
59
+ }
60
+
61
+ const hints = extractReadmeHints(content);
62
+ if (hints.name) nameSources.push({ source: file, value: hints.name });
63
+ if (hints.oneLiner) oneLinerSources.push({ source: file, value: hints.oneLiner });
64
+
65
+ break; // first README found wins — don't blend multiple README variants
66
+ }
67
+
68
+ return {
69
+ name: summarize(nameSources),
70
+ oneLiner: summarize(oneLinerSources),
71
+ };
72
+ }
73
+
74
+ /** Pull a name (first H1) and one-liner (first prose line) out of a README. */
75
+ export function extractReadmeHints(content) {
76
+ const lines = String(content || '').split('\n');
77
+ let name = '';
78
+ let oneLiner = '';
79
+
80
+ for (let i = 0; i < lines.length; i++) {
81
+ const line = lines[i].trim();
82
+ if (!line) continue;
83
+
84
+ if (!name) {
85
+ const h1 = /^#\s+(.+?)\s*$/.exec(line);
86
+ if (h1) {
87
+ name = stripInlineMarkdown(h1[1]);
88
+ continue;
89
+ }
90
+ }
91
+
92
+ // Wait for the first H1 before reading prose — otherwise the README may
93
+ // start with a logo/badge that is not a name.
94
+ if (!name || oneLiner) continue;
95
+
96
+ if (isProseLine(line)) {
97
+ oneLiner = line.length > 120 ? `${line.slice(0, 117)}...` : line;
98
+ }
99
+ }
100
+
101
+ return { name, oneLiner };
102
+ }
103
+
104
+ function summarize(sources) {
105
+ if (sources.length === 0) {
106
+ return { value: '', ambiguous: false, sources: [] };
107
+ }
108
+
109
+ const distinct = [];
110
+ for (const entry of sources) {
111
+ if (!distinct.includes(entry.value)) distinct.push(entry.value);
112
+ }
113
+
114
+ return {
115
+ value: sources[0].value,
116
+ ambiguous: distinct.length > 1,
117
+ sources,
118
+ };
119
+ }
120
+
121
+ function isProseLine(line) {
122
+ // Skip headings, badges/images, code fences, lists, tables, blockquotes, HTML.
123
+ if (/^[#>|`*\-_]/.test(line)) return false;
124
+ if (/^\[!\[/.test(line)) return false;
125
+ if (/^!\[/.test(line)) return false;
126
+ if (/^<[a-zA-Z!/]/.test(line)) return false;
127
+ if (/^\|/.test(line)) return false;
128
+ return true;
129
+ }
130
+
131
+ function stripInlineMarkdown(value) {
132
+ return String(value)
133
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') // [text](url) → text
134
+ .replace(/[*_`]/g, '')
135
+ .trim();
136
+ }
137
+
138
+ function isNonEmptyString(value) {
139
+ return typeof value === 'string' && value.trim().length > 0;
140
+ }