cli-five 0.2.13 → 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.
@@ -0,0 +1,239 @@
1
+ import kleur from 'kleur';
2
+ import prompts from 'prompts';
3
+ import { existsSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import {
6
+ PLATFORM_COPILOT,
7
+ PLATFORM_OPENCODE,
8
+ PLATFORMS,
9
+ platformLabel,
10
+ } from '../util/platforms.mjs';
11
+ import { log } from '../util/log.mjs';
12
+ import {
13
+ agentNames,
14
+ getDefaultModelMap,
15
+ getModelCatalog,
16
+ normalizeProvider,
17
+ providerForPlatform,
18
+ providerLabel,
19
+ PROVIDER_COPILOT,
20
+ PROVIDER_ZEN,
21
+ PROVIDERS,
22
+ } from '../util/models.mjs';
23
+
24
+ const CUSTOM_SENTINEL = '__custom__';
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
+
38
+ /**
39
+ * Ask the user to choose a target platform.
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.
49
+ */
50
+ export async function choosePlatform(args, { autoDetect = false, askCodegraph = true, codegraphDefault } = {}) {
51
+ if (codegraphDefault === undefined) codegraphDefault = args.codegraph !== false;
52
+
53
+ if (args.target) {
54
+ const t = String(args.target).toLowerCase();
55
+ if (PLATFORMS.includes(t)) {
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 };
65
+ }
66
+ }
67
+
68
+ if (args.yes) {
69
+ return { platform: PLATFORM_COPILOT, codegraph: codegraphDefault };
70
+ }
71
+
72
+ const { platform } = await prompts({
73
+ type: 'select',
74
+ name: 'platform',
75
+ message: 'Target platform',
76
+ choices: [
77
+ {
78
+ title: 'GitHub Copilot',
79
+ value: PLATFORM_COPILOT,
80
+ description: 'VS Code / Copilot Chat agent customization files',
81
+ },
82
+ {
83
+ title: 'OpenCode',
84
+ value: PLATFORM_OPENCODE,
85
+ description: 'OpenCode project agents + opencode.json',
86
+ },
87
+ ],
88
+ initial: 0,
89
+ });
90
+
91
+ if (!platform) {
92
+ throw new Error('Platform selection cancelled. Nothing was written.');
93
+ }
94
+
95
+ if (!askCodegraph) {
96
+ return { platform, codegraph: codegraphDefault };
97
+ }
98
+
99
+ const { codegraph } = await prompts({
100
+ type: 'confirm',
101
+ name: 'codegraph',
102
+ message: `Add CodeGraph MCP + instructions for ${platformLabel(platform)}?`,
103
+ initial: true,
104
+ });
105
+
106
+ return { platform, codegraph: codegraph !== false };
107
+ }
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
+
116
+ /**
117
+ * Ask the user whether to customize models, pick a provider, and optionally
118
+ * override per-agent models.
119
+ */
120
+ export async function chooseModels(platform, args) {
121
+ // Defaults for the platform.
122
+ const defaultProvider = providerForPlatform(platform, args.provider);
123
+ const defaults = getDefaultModelMap(defaultProvider);
124
+
125
+ if (args.yes) {
126
+ return {
127
+ provider: defaultProvider,
128
+ modelMap: defaults,
129
+ customized: false,
130
+ };
131
+ }
132
+
133
+ const { customize } = await prompts({
134
+ type: 'confirm',
135
+ name: 'customize',
136
+ message: 'Customize agent models?',
137
+ initial: false,
138
+ });
139
+
140
+ if (!customize) {
141
+ return {
142
+ provider: defaultProvider,
143
+ modelMap: defaults,
144
+ customized: false,
145
+ };
146
+ }
147
+
148
+ const provider = await chooseProvider(platform, args.provider);
149
+ const baseMap = getDefaultModelMap(provider);
150
+
151
+ // Show defaults and ask which agents to override.
152
+ log.raw(kleur.dim('\n Default models:'));
153
+ for (const agent of agentNames()) {
154
+ log.raw(kleur.dim(` ${agent.padEnd(14)} → ${baseMap[agent]}`));
155
+ }
156
+
157
+ const { overrideAgents } = await prompts({
158
+ type: 'multiselect',
159
+ name: 'overrideAgents',
160
+ message: 'Select agents whose model you want to change',
161
+ choices: agentNames().map((name) => ({ title: name, value: name })),
162
+ hint: 'Space to toggle, Enter to confirm',
163
+ });
164
+
165
+ const modelMap = { ...baseMap };
166
+
167
+ if (overrideAgents && overrideAgents.length > 0) {
168
+ const catalog = getModelCatalog(provider);
169
+ const choices = buildModelChoices(catalog);
170
+
171
+ for (const agent of overrideAgents) {
172
+ const { model } = await prompts({
173
+ type: 'select',
174
+ name: 'model',
175
+ message: `Model for ${agent}`,
176
+ choices,
177
+ initial: 0,
178
+ });
179
+
180
+ if (model === CUSTOM_SENTINEL) {
181
+ const { custom } = await prompts({
182
+ type: 'text',
183
+ name: 'custom',
184
+ message: `Custom model for ${agent}`,
185
+ initial: baseMap[agent],
186
+ });
187
+ if (custom) modelMap[agent] = custom.trim();
188
+ } else if (model) {
189
+ modelMap[agent] = model;
190
+ }
191
+ }
192
+ }
193
+
194
+ return { provider, modelMap, customized: true };
195
+ }
196
+
197
+ async function chooseProvider(platform, cliProvider) {
198
+ if (cliProvider) {
199
+ const normalized = normalizeProvider(cliProvider);
200
+ if (platform === PLATFORM_OPENCODE || normalized === PROVIDER_COPILOT) {
201
+ return normalized;
202
+ }
203
+ }
204
+
205
+ if (platform === PLATFORM_COPILOT) {
206
+ return PROVIDER_COPILOT;
207
+ }
208
+
209
+ const { provider } = await prompts({
210
+ type: 'select',
211
+ name: 'provider',
212
+ message: 'OpenCode model provider',
213
+ choices: [
214
+ {
215
+ title: providerLabel(PROVIDER_ZEN),
216
+ value: PROVIDER_ZEN,
217
+ description: 'Curated, tested models via OpenCode Zen',
218
+ },
219
+ {
220
+ title: 'OpenCode Go',
221
+ value: 'opencode-go',
222
+ description: 'Low-cost open-coding model subscription',
223
+ },
224
+ ],
225
+ initial: 0,
226
+ });
227
+
228
+ if (!provider) {
229
+ throw new Error('Provider selection cancelled. Nothing was written.');
230
+ }
231
+
232
+ return provider;
233
+ }
234
+
235
+ function buildModelChoices(catalog) {
236
+ const choices = catalog.map((id) => ({ title: id, value: id }));
237
+ choices.push({ title: kleur.dim('Type custom model...'), value: CUSTOM_SENTINEL });
238
+ return choices;
239
+ }
@@ -1,20 +1,14 @@
1
1
  import { join } from 'node:path';
2
2
  import { log } from '../util/log.mjs';
3
- import { readTemplate, render, writeFile, listFilesRecursive, relTo } from '../util/fs.mjs';
4
- import { templatePath } from '../util/fs.mjs';
3
+ import { readTemplate, render, writeFile, listFilesRecursive, relTo, templatePath } from '../util/fs.mjs';
5
4
  import { readFileSync } from 'node:fs';
5
+ import { PLATFORM_COPILOT, PLATFORM_OPENCODE, agentDirFor, agentFileFor } from '../util/platforms.mjs';
6
6
 
7
- const AGENT_FILES = [
8
- 'orchestrator.agent.md',
9
- 'planner.agent.md',
10
- 'coder.agent.md',
11
- 'designer.agent.md',
12
- 'reviewer.agent.md',
13
- ];
14
-
7
+ const AGENT_NAMES = ['orchestrator', 'planner', 'coder', 'designer', 'reviewer'];
15
8
  const HISTORY_FILES = ['orchestrator.md', 'planner.md', 'coder.md', 'designer.md', 'reviewer.md'];
16
9
 
17
- const MODEL_MAP = {
10
+ // Legacy cost-mode maps for GitHub Copilot (used when models are not customized).
11
+ const COPILOT_COST_MODE_MAP = {
18
12
  premium: {
19
13
  Orchestrator: 'Claude Sonnet 4.6 (copilot)',
20
14
  Planner: 'Claude Opus 4.6 (copilot)',
@@ -39,21 +33,55 @@ const MODEL_MAP = {
39
33
  };
40
34
 
41
35
  export function scaffold({ cwd, answers, args }) {
36
+ const platform = answers.platform || PLATFORM_COPILOT;
42
37
  const vars = buildVars(answers);
43
38
  const written = [];
44
39
 
45
- // Agents (with model swap per cost mode)
46
- for (const file of AGENT_FILES) {
40
+ if (platform === PLATFORM_OPENCODE) {
41
+ written.push(...scaffoldOpenCode({ cwd, answers, args, vars }));
42
+ } else {
43
+ written.push(...scaffoldCopilot({ cwd, answers, args, vars }));
44
+ }
45
+
46
+ // Shared memory primitives
47
+ for (const tmpl of [
48
+ 'AGENTS.md.tmpl',
49
+ 'PROJECT.md.tmpl',
50
+ 'STATE.md.tmpl',
51
+ 'decisions.md.tmpl',
52
+ 'agent-diary.md.tmpl',
53
+ ]) {
54
+ const out = render(readTemplate(tmpl), vars);
55
+ const target = tmpl.replace(/\.tmpl$/, '');
56
+ written.push(writeFile(join(cwd, target), out, args));
57
+ }
58
+
59
+ // Per-agent histories
60
+ for (const file of HISTORY_FILES) {
61
+ written.push(writeFile(join(cwd, 'histories', file), readTemplate('histories', file), args));
62
+ }
63
+
64
+ return written;
65
+ }
66
+
67
+ function scaffoldCopilot({ cwd, answers, args, vars }) {
68
+ const written = [];
69
+ const modelByAgent = answers.customizedModels
70
+ ? answers.modelMap
71
+ : COPILOT_COST_MODE_MAP[answers.costMode] || COPILOT_COST_MODE_MAP.premium;
72
+
73
+ // Agents
74
+ for (const file of AGENT_NAMES.map((n) => `${n}.agent.md`)) {
47
75
  const src = readFileSync(templatePath('.github', 'agents', file), 'utf8');
48
- const swapped = swapModel(src, MODEL_MAP[answers.costMode]);
76
+ const swapped = swapModel(src, modelByAgent);
49
77
  written.push(writeFile(join(cwd, '.github', 'agents', file), swapped, args));
50
78
  }
51
79
 
52
- // copilot-instructions.md (templated)
80
+ // copilot-instructions.md
53
81
  const ci = render(readTemplate('.github', 'copilot-instructions.md.tmpl'), vars);
54
82
  written.push(writeFile(join(cwd, '.github', 'copilot-instructions.md'), ci, args));
55
83
 
56
- // Empty containers ready for /agent-customization
84
+ // Empty containers
57
85
  written.push(
58
86
  writeFile(
59
87
  join(cwd, '.github', 'instructions', 'README.md'),
@@ -65,28 +93,85 @@ export function scaffold({ cwd, answers, args }) {
65
93
  writeFile(join(cwd, '.github', 'skills', 'README.md'), readTemplate('.github', 'skills', 'README.md'), args),
66
94
  );
67
95
 
68
- // Project root memory primitives (GSD-inspired)
69
- for (const tmpl of [
70
- 'AGENTS.md.tmpl',
71
- 'PROJECT.md.tmpl',
72
- 'STATE.md.tmpl',
73
- 'decisions.md.tmpl',
74
- 'agent-diary.md.tmpl',
75
- ]) {
76
- const out = render(readTemplate(tmpl), vars);
77
- const target = tmpl.replace(/\.tmpl$/, '');
78
- written.push(writeFile(join(cwd, target), out, args));
96
+ // CodeGraph MCP for VS Code Copilot
97
+ if (answers.codegraph) {
98
+ written.push(writeFile(join(cwd, '.vscode', 'mcp.json'), JSON.stringify(codegraphMcpJson(), null, 2), args));
79
99
  }
80
100
 
81
- // Per-agent histories
82
- for (const file of HISTORY_FILES) {
83
- written.push(writeFile(join(cwd, 'histories', file), readTemplate('histories', file), args));
101
+ return written;
102
+ }
103
+
104
+ function scaffoldOpenCode({ cwd, answers, args, vars }) {
105
+ const written = [];
106
+ const modelByAgent = answers.modelMap || {};
107
+ const orchestratorModel = modelByAgent.Orchestrator || 'opencode/gpt-5.3-codex';
108
+
109
+ // Agents
110
+ for (const name of AGENT_NAMES) {
111
+ const src = readFileSync(templatePath('opencode', 'agents', `${name}.md`), 'utf8');
112
+ const swapped = swapModel(src, modelByAgent);
113
+ written.push(writeFile(join(cwd, '.opencode', 'agents', `${name}.md`), swapped, args));
84
114
  }
85
115
 
116
+ // opencode.json
117
+ const opencodeConfig = buildOpencodeConfig({ answers, orchestratorModel });
118
+ written.push(writeFile(join(cwd, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2) + '\n', args));
119
+
120
+ // Empty containers (still useful for OpenCode agents)
121
+ written.push(
122
+ writeFile(
123
+ join(cwd, '.github', 'instructions', 'README.md'),
124
+ readTemplate('.github', 'instructions', 'README.md'),
125
+ args,
126
+ ),
127
+ );
128
+ written.push(
129
+ writeFile(join(cwd, '.github', 'skills', 'README.md'), readTemplate('.github', 'skills', 'README.md'), args),
130
+ );
131
+
86
132
  return written;
87
133
  }
88
134
 
135
+ function buildOpencodeConfig({ answers, orchestratorModel }) {
136
+ const smallModel = answers.provider === 'opencode-go'
137
+ ? 'opencode-go/qwen3.8-flash'
138
+ : 'opencode/gpt-5-nano';
139
+
140
+ const config = {
141
+ $schema: 'https://opencode.ai/config.json',
142
+ model: orchestratorModel,
143
+ small_model: smallModel,
144
+ subagent_depth: 2,
145
+ };
146
+
147
+ if (answers.codegraph) {
148
+ config.mcp = {
149
+ codegraph: {
150
+ type: 'local',
151
+ command: ['codegraph', 'serve', '--mcp'],
152
+ enabled: true,
153
+ },
154
+ };
155
+ }
156
+
157
+ return config;
158
+ }
159
+
160
+ function codegraphMcpJson() {
161
+ return {
162
+ inputs: [],
163
+ servers: {
164
+ codegraph: {
165
+ command: 'codegraph',
166
+ args: ['serve', '--mcp'],
167
+ },
168
+ },
169
+ };
170
+ }
171
+
89
172
  function buildVars(a) {
173
+ const codegraphBlock = a.codegraph ? CODEGRAPH_BLOCK : '';
174
+
90
175
  return {
91
176
  PROJECT_NAME: a.projectName,
92
177
  ONE_LINER: a.oneLiner || 'TODO — write a one-line vision statement.',
@@ -105,17 +190,17 @@ ${a.docs}
105
190
  ` : '',
106
191
  DATE: new Date().toISOString().slice(0, 10),
107
192
  PERSONA_BLOCK: a.snark ? PERSONA_BLOCK : '',
193
+ CODEGRAPH_BLOCK: codegraphBlock,
108
194
  };
109
195
  }
110
196
 
111
197
  function swapModel(src, modelByAgent) {
112
- // Replace the YAML `model:` line based on the `name:` immediately above/around it.
113
198
  const lines = src.split('\n');
114
199
  let agentName = null;
115
200
  for (let i = 0; i < lines.length; i++) {
116
- const m = /^name:\s*(.+?)\s*$/.exec(lines[i]);
117
- if (m) {
118
- agentName = m[1];
201
+ const nameMatch = /^name:\s*(.+?)\s*$/.exec(lines[i]);
202
+ if (nameMatch) {
203
+ agentName = nameMatch[1];
119
204
  continue;
120
205
  }
121
206
  if (agentName && /^model:\s*/.test(lines[i])) {
@@ -136,6 +221,22 @@ const PERSONA_BLOCK = `# Persona
136
221
 
137
222
  `;
138
223
 
224
+ const CODEGRAPH_BLOCK = `
225
+ <!-- CODEGRAPH_START -->
226
+ ## CodeGraph
227
+
228
+ This project is configured to use [CodeGraph](https://codegraph.ru) for graph-backed codebase context.
229
+ When you need to understand relationships, call paths, or impacts, use:
230
+
231
+ \`\`\`
232
+ codegraph explore "<your question>"
233
+ \`\`\`
234
+
235
+ The CodeGraph MCP server is registered in the project config. Run \`codegraph init\` in this directory
236
+ if the project has not been indexed yet.
237
+ <!-- CODEGRAPH_END -->
238
+ `;
239
+
139
240
  export function summarize(written, cwd) {
140
241
  const lines = [];
141
242
  for (const w of written) {
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ import YAML from 'yaml';
2
3
 
3
4
  export const AGENT_FILES = [
4
5
  'orchestrator.agent.md',
@@ -8,6 +9,14 @@ export const AGENT_FILES = [
8
9
  'reviewer.agent.md',
9
10
  ];
10
11
 
12
+ export const OPENCODE_AGENT_FILES = [
13
+ 'orchestrator.md',
14
+ 'planner.md',
15
+ 'coder.md',
16
+ 'designer.md',
17
+ 'reviewer.md',
18
+ ];
19
+
11
20
  export const EXPECTED_AGENT_NAMES = {
12
21
  'orchestrator.agent.md': 'Orchestrator',
13
22
  'planner.agent.md': 'Planner',
@@ -89,6 +98,44 @@ export function validateAgentSource(source, fileName, { requiredMarkers = [] } =
89
98
  return errors;
90
99
  }
91
100
 
101
+ export function validateOpenCodeAgentSource(source, fileName) {
102
+ const errors = [];
103
+ const { frontmatter, body } = parseAgentSource(source);
104
+
105
+ if (!frontmatter) {
106
+ return ['missing YAML frontmatter'];
107
+ }
108
+
109
+ for (const key of ['description', 'mode', 'model']) {
110
+ if (!(key in frontmatter)) {
111
+ errors.push(`missing frontmatter key: ${key}`);
112
+ }
113
+ }
114
+
115
+ const mode = String(frontmatter.mode || '').toLowerCase();
116
+ if (mode && !['primary', 'subagent', 'all'].includes(mode)) {
117
+ errors.push(`invalid mode: ${frontmatter.mode}`);
118
+ }
119
+
120
+ const expectedMode = fileName === 'orchestrator.md' ? 'primary' : 'subagent';
121
+ if (mode && mode !== expectedMode && mode !== 'all') {
122
+ errors.push(`expected mode ${expectedMode}, found ${frontmatter.mode}`);
123
+ }
124
+
125
+ if (fileName === 'orchestrator.md') {
126
+ const task = frontmatter.permission?.task || frontmatter.permissions?.task;
127
+ if (!task) {
128
+ errors.push('orchestrator must declare permission.task for subagents');
129
+ }
130
+ }
131
+
132
+ if (!body.trim()) {
133
+ errors.push('agent body must not be empty');
134
+ }
135
+
136
+ return errors;
137
+ }
138
+
92
139
  function requiredFrontmatterKeys(fileName) {
93
140
  return fileName === 'orchestrator.agent.md'
94
141
  ? ['name', 'description', 'model', 'tools', 'agents']
@@ -96,16 +143,21 @@ function requiredFrontmatterKeys(fileName) {
96
143
  }
97
144
 
98
145
  function parseFrontmatter(block) {
99
- const out = {};
100
- for (const rawLine of block.split('\n')) {
101
- const line = rawLine.trim();
102
- if (!line) continue;
103
- const match = /^([A-Za-z-]+):\s*(.+)$/.exec(line);
104
- if (!match) continue;
105
- const [, key, rawValue] = match;
106
- out[key] = parseFrontmatterValue(rawValue.trim());
107
- }
108
- return out;
146
+ try {
147
+ return YAML.parse(block, { strict: false });
148
+ } catch {
149
+ // Fall back to the legacy line parser for simple Copilot frontmatter.
150
+ const out = {};
151
+ for (const rawLine of block.split('\n')) {
152
+ const line = rawLine.trim();
153
+ if (!line) continue;
154
+ const match = /^([A-Za-z-]+):\s*(.+)$/.exec(line);
155
+ if (!match) continue;
156
+ const [, key, rawValue] = match;
157
+ out[key] = parseFrontmatterValue(rawValue.trim());
158
+ }
159
+ return out;
160
+ }
109
161
  }
110
162
 
111
163
  function parseFrontmatterValue(rawValue) {
@@ -134,4 +186,4 @@ function parseArrayValue(rawValue) {
134
186
  }
135
187
  return part;
136
188
  });
137
- }
189
+ }