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 +57 -21
- package/package.json +1 -1
- package/src/addons/registry.mjs +96 -0
- package/src/cli.mjs +30 -5
- package/src/commands/add.mjs +55 -0
- package/src/commands/init.mjs +105 -41
- package/src/commands/list-addons.mjs +40 -0
- package/src/steps/interview.mjs +59 -0
- package/src/steps/platform.mjs +46 -3
- package/src/util/merge.mjs +170 -0
- package/src/util/project.mjs +140 -0
package/README.md
CHANGED
|
@@ -10,7 +10,14 @@
|
|
|
10
10
|
npx cli-five init
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
|
|
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 #
|
|
48
|
-
│ ├── instructions/ #
|
|
49
|
-
│ └── skills/ #
|
|
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/ #
|
|
72
|
-
├── .github/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 #
|
|
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
|
|
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
|
-
| `--
|
|
111
|
-
| `--no-
|
|
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** —
|
|
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
|
|
125
|
-
6. **Model configuration** —
|
|
126
|
-
7. **Scaffold** — writes
|
|
127
|
-
8. **
|
|
128
|
-
|
|
129
|
-
|
|
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
|
@@ -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
|
|
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
|
-
--
|
|
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
|
-
|
|
72
|
-
|
|
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
|
+
}
|
package/src/commands/init.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
97
|
+
|
|
98
|
+
log.step('5/8 Model configuration');
|
|
99
|
+
modelConfig = await chooseModels(platform, args);
|
|
66
100
|
} else {
|
|
67
|
-
|
|
68
|
-
|
|
101
|
+
log.step('4/8 Project info');
|
|
102
|
+
docHints = autoProjectInfo(cwd);
|
|
103
|
+
logAutoProjectInfo(docHints);
|
|
69
104
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
144
|
+
// 8. Skill discovery
|
|
112
145
|
log.step('7/8 Skill discovery');
|
|
113
|
-
|
|
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
|
-
//
|
|
152
|
+
// 9. Custom instructions
|
|
116
153
|
log.step('8/8 Custom instructions');
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
163
|
+
} else {
|
|
164
|
+
log.dim('Skipped (minimal init). Enable with --instructions or --full-interview.');
|
|
124
165
|
}
|
|
125
166
|
|
|
126
|
-
//
|
|
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
|
-
|
|
169
|
-
|
|
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
|
+
}
|
package/src/steps/interview.mjs
CHANGED
|
@@ -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;
|
package/src/steps/platform.mjs
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
+
}
|