cli-five 0.2.16 → 0.2.18
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 +43 -3
- package/package.json +1 -1
- package/src/addons/codegraph-command.mjs +47 -0
- package/src/addons/codegraph.mjs +124 -0
- package/src/addons/jev.mjs +98 -0
- package/src/addons/registry.mjs +38 -8
- package/src/commands/add.mjs +1 -1
- package/src/commands/init.mjs +28 -4
- package/src/commands/list-addons.mjs +16 -7
- package/src/steps/platform.mjs +27 -15
- package/src/steps/scaffold.mjs +11 -46
- package/src/util/auth.mjs +85 -0
- package/templates/AGENTS.md.tmpl +0 -1
- package/templates/opencode/plugin/jev-tier-router/index.js +187 -0
- package/templates/opencode/plugin/jev-tier-router/package.json +9 -0
package/README.md
CHANGED
|
@@ -156,13 +156,51 @@ Optional integrations live outside the default scaffold and are installed with `
|
|
|
156
156
|
|
|
157
157
|
```bash
|
|
158
158
|
npx cli-five add # list known targets
|
|
159
|
-
npx cli-five add codegraph #
|
|
159
|
+
npx cli-five add codegraph # CodeGraph MCP registration + AGENTS.md instructions
|
|
160
|
+
npx cli-five add jev # tier-routing tool for the Planner (OpenCode only)
|
|
160
161
|
npx cli-five list-addons # installed vs. available status
|
|
161
162
|
```
|
|
162
163
|
|
|
163
|
-
|
|
164
|
+
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. Re-running an add-on is idempotent — no duplicate MCP entries or AGENTS.md sections.
|
|
165
|
+
|
|
166
|
+
### CodeGraph (`add codegraph`)
|
|
167
|
+
|
|
168
|
+
`add codegraph` registers the CodeGraph MCP server and adds the CodeGraph section to `AGENTS.md`. It works on **both platforms**:
|
|
169
|
+
|
|
170
|
+
| Target | MCP registration | Instructions |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| Copilot | `.vscode/mcp.json` → `servers.codegraph` | `AGENTS.md` block |
|
|
173
|
+
| OpenCode | `opencode.json` → `mcp.codegraph` | `AGENTS.md` block |
|
|
174
|
+
|
|
175
|
+
**The `init` flags are now thin wrappers.** `init --codegraph` and `init --no-codegraph` still behave exactly as before, but they call the *same* underlying `add codegraph` logic — one implementation, two entry points. CodeGraph remains off by default in minimal `init` and on with `--full-interview`; pass `--codegraph` to force it on, `--no-codegraph` to force it off.
|
|
176
|
+
|
|
177
|
+
cli-five still does **not** run `codegraph init` itself — it only registers the server and reminds you to index the project:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
npm i -g @colbymchenry/codegraph
|
|
181
|
+
codegraph init
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Jev (`add jev`) — tier-routing only, OpenCode only
|
|
185
|
+
|
|
186
|
+
`add jev` scaffolds an OpenCode plugin that adds a `local_tier_heuristic` tool. The Planner calls it once per task to classify the work as `trivial` / `minor` / `major`, then scales planning depth accordingly.
|
|
187
|
+
|
|
188
|
+
**Two things it deliberately does *not* do, stated plainly:**
|
|
189
|
+
|
|
190
|
+
1. **It does not call Jev.** `jev-harness` 0.2.0's `route` subcommand exposes no custom-criteria interface — it emits its own fixed tier vocabulary (`deterministic` / `lightweight_system2` / `heavy_system2`) and returns a constant confidence (`0.88`) under its offline/mock engine, so it cannot be thresholded on. The shipped tool is therefore a **local heuristic**, truthfully named `local_tier_heuristic`. The swap point for real Jev wiring is marked in `templates/opencode/plugin/jev-tier-router/index.js` (`JEVR_SWAP_POINT`).
|
|
191
|
+
2. **The test-gate is parked.** Gating Reviewer spawns via plugin interception (`tool.execute.before` / `permission.ask`) does not work: OpenCode plugin hooks do not fire under OpenChamber's embedded-server routing. Do not expect `add jev` to gate anything.
|
|
192
|
+
|
|
193
|
+
`list-addons` reports each add-on's honest capability rather than a bare "installed":
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
codegraph installed (MCP registration + AGENTS.md instructions) available MCP registration + AGENTS.md instructions
|
|
197
|
+
jev installed (tier-routing only) available local heuristic — …; test-gate parked — <issue link>
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
**Fail-open is non-negotiable.** If the classifier is unavailable, errors, or returns malformed output, the tool returns `available: false` with tier `major` (the expensive tier) and never throws. The Planner falls back to its own judgment. cli-five and the scaffolded agents behave identically whether the plugin works, is missing, or is broken.
|
|
201
|
+
|
|
202
|
+
**Copilot has no equivalent.** `add jev` refuses cleanly on a Copilot target (exit 1, no files written) — there is no `tools.add`-style surface there.
|
|
164
203
|
|
|
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.
|
|
166
204
|
|
|
167
205
|
## Model providers
|
|
168
206
|
|
|
@@ -174,6 +212,8 @@ During `init --full-interview` you can choose the model provider and optionally
|
|
|
174
212
|
| OpenCode Zen | OpenCode | `opencode/gpt-5.3-codex` |
|
|
175
213
|
| OpenCode Go | OpenCode | `opencode-go/qwen3.8-max` |
|
|
176
214
|
|
|
215
|
+
**On OpenCode, the default provider is auth-aware.** cli-five checks which provider you are actually authenticated for (`opencode auth list`) and uses it instead of blindly defaulting to OpenCode Zen. If you are authenticated for Go only, `init --target opencode` writes Go models automatically — no `--provider` flag needed. If nothing is detected, or the selected provider does not match your auth, `init` prints a warning telling you how to fix it, so you do not end up with agent files whose models silently fail.
|
|
216
|
+
|
|
177
217
|
`--yes` uses the provider's defaults. `--provider opencode-go --yes` skips the provider prompt.
|
|
178
218
|
|
|
179
219
|
### Copilot cost modes
|
package/package.json
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { log } from '../util/log.mjs';
|
|
5
|
+
import { PLATFORM_COPILOT, PLATFORM_OPENCODE, platformLabel } from '../util/platforms.mjs';
|
|
6
|
+
import { addCodegraphTo, CODEGRAPH_INIT_REMINDER, mcpTargetFor } from './codegraph.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `add codegraph` — register the CodeGraph MCP server + AGENTS.md section.
|
|
10
|
+
*
|
|
11
|
+
* Works on both platforms. Same content and behavior as the previous inline
|
|
12
|
+
* init implementation; only the invocation path and write mechanism changed
|
|
13
|
+
* (mergeBlock instead of blunt overwrite).
|
|
14
|
+
*/
|
|
15
|
+
export async function runCodegraph({ cwd, args = {} }) {
|
|
16
|
+
const platform = detectPlatform(cwd);
|
|
17
|
+
|
|
18
|
+
if (platform === 'unknown') {
|
|
19
|
+
log.err('No cli-five scaffold detected (neither .opencode/agents nor .github/agents).');
|
|
20
|
+
log.dim('Run `npx cli-five init` first, then `npx cli-five add codegraph`.');
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const dryRun = Boolean(args.dryRun);
|
|
26
|
+
const touched = addCodegraphTo({ cwd, platform, dryRun, track: true });
|
|
27
|
+
|
|
28
|
+
for (const t of touched) {
|
|
29
|
+
const rel = t.path.replace(cwd + '/', '');
|
|
30
|
+
const symbol = t.action === 'created' || t.action === 'updated' ? '+' : '~';
|
|
31
|
+
log.raw(` ${symbol} ${rel} ${kleur.dim(`(${t.action})`)}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
log.ok(`CodeGraph registered for ${platformLabel(platform)}`);
|
|
35
|
+
log.dim(`MCP: ${mcpTargetFor(platform)} · instructions: AGENTS.md`);
|
|
36
|
+
log.dim(CODEGRAPH_INIT_REMINDER.replace('CodeGraph is configured. ', ''));
|
|
37
|
+
|
|
38
|
+
return touched;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function detectPlatform(cwd) {
|
|
42
|
+
if (existsSync(join(cwd, '.opencode', 'agents'))) return PLATFORM_OPENCODE;
|
|
43
|
+
if (existsSync(join(cwd, '.github', 'agents', 'orchestrator.agent.md'))) return PLATFORM_COPILOT;
|
|
44
|
+
return 'unknown';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const __testables = { detectPlatform };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { mergeBlock } from '../util/merge.mjs';
|
|
4
|
+
import { PLATFORM_COPILOT, PLATFORM_OPENCODE } from '../util/platforms.mjs';
|
|
5
|
+
|
|
6
|
+
export const CODEGRAPH_STATUS = 'MCP registration + AGENTS.md instructions';
|
|
7
|
+
export const CODEGRAPH_BLOCK_NAME = 'codegraph';
|
|
8
|
+
export const CODEGRAPH_INIT_REMINDER =
|
|
9
|
+
'CodeGraph is configured. Remember to run `codegraph init` before asking agents to explore the codebase.';
|
|
10
|
+
|
|
11
|
+
// The AGENTS.md section is byte-identical to the pre-migration inline constant
|
|
12
|
+
// in scaffold.mjs. Do not change its content — only the write path changed.
|
|
13
|
+
export const CODEGRAPH_BLOCK = `## CodeGraph
|
|
14
|
+
|
|
15
|
+
This project is configured to use [CodeGraph](https://codegraph.ru) for graph-backed codebase context.
|
|
16
|
+
When you need to understand relationships, call paths, or impacts, use:
|
|
17
|
+
|
|
18
|
+
\`\`\`
|
|
19
|
+
codegraph explore "<your question>"
|
|
20
|
+
\`\`\`
|
|
21
|
+
|
|
22
|
+
The CodeGraph MCP server is registered in the project config. Run \`codegraph init\` in this directory
|
|
23
|
+
if the project has not been indexed yet.`;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The CodeGraph MCP server entries, per platform. Unchanged from the previous
|
|
27
|
+
* inline implementation — same server, same command, same shape.
|
|
28
|
+
*/
|
|
29
|
+
export function codegraphOpencodeConfig() {
|
|
30
|
+
return {
|
|
31
|
+
codegraph: {
|
|
32
|
+
type: 'local',
|
|
33
|
+
command: ['codegraph', 'serve', '--mcp'],
|
|
34
|
+
enabled: true,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function codegraphCopilotMcpJson() {
|
|
40
|
+
return {
|
|
41
|
+
inputs: [],
|
|
42
|
+
servers: {
|
|
43
|
+
codegraph: {
|
|
44
|
+
command: 'codegraph',
|
|
45
|
+
args: ['serve', '--mcp'],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Which MCP file a given platform owns. Preserves the old per-platform split. */
|
|
52
|
+
export function mcpTargetFor(platform) {
|
|
53
|
+
return platform === PLATFORM_OPENCODE ? 'opencode.json' : join('.vscode', 'mcp.json');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Register CodeGraph in a workspace, using mergeBlock() for every write.
|
|
58
|
+
*
|
|
59
|
+
* Shared by `add codegraph` and init's legacy --codegraph flag — one
|
|
60
|
+
* implementation, two entry points.
|
|
61
|
+
*
|
|
62
|
+
* @returns {Array<{path:string, action:string}>} the files touched.
|
|
63
|
+
*/
|
|
64
|
+
export function addCodegraphTo({ cwd, platform, dryRun = false, track = false }) {
|
|
65
|
+
const touched = [];
|
|
66
|
+
const isOpenCode = platform === PLATFORM_OPENCODE;
|
|
67
|
+
|
|
68
|
+
// 1. MCP registration.
|
|
69
|
+
if (isOpenCode) {
|
|
70
|
+
const opencodePath = join(cwd, 'opencode.json');
|
|
71
|
+
// Preserve a pre-existing `plugins[]` (e.g. jev): deepMerge replaces arrays,
|
|
72
|
+
// so carry the existing value through untouched at the root.
|
|
73
|
+
const existing = readJson(opencodePath) || {};
|
|
74
|
+
const patch = { mcp: codegraphOpencodeConfig() };
|
|
75
|
+
if (Array.isArray(existing.plugins)) patch.plugins = existing.plugins;
|
|
76
|
+
touched.push(mergeBlock(opencodePath, 'codegraph', patch, { dryRun, track }));
|
|
77
|
+
} else {
|
|
78
|
+
const mcpPath = join(cwd, '.vscode', 'mcp.json');
|
|
79
|
+
const existing = readJson(mcpPath) || {};
|
|
80
|
+
// Preserve the old key order (`inputs` first) so output matches the
|
|
81
|
+
// pre-migration inline implementation byte-for-byte. `inputs` is an array;
|
|
82
|
+
// carry it through so deepMerge's array-replace is a no-op.
|
|
83
|
+
const patch = { inputs: Array.isArray(existing.inputs) ? existing.inputs : [] };
|
|
84
|
+
patch.servers = { codegraph: codegraphCopilotMcpJson().servers.codegraph };
|
|
85
|
+
touched.push(mergeBlock(mcpPath, 'codegraph', patch, { dryRun, track }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 2. AGENTS.md section (fenced block; same marker name as the old template).
|
|
89
|
+
const agentsPath = join(cwd, 'AGENTS.md');
|
|
90
|
+
touched.push(mergeBlock(agentsPath, CODEGRAPH_BLOCK_NAME, CODEGRAPH_BLOCK, { dryRun }));
|
|
91
|
+
|
|
92
|
+
return touched;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Idempotent remove-of-duplicates is intentionally NOT implemented: mergeBlock
|
|
97
|
+
* already replaces an existing fenced block in place and deep-merges JSON, so
|
|
98
|
+
* re-running add codegraph does not duplicate anything.
|
|
99
|
+
*/
|
|
100
|
+
export function isCodegraphPresent(cwd, platform) {
|
|
101
|
+
const mcpPath = join(cwd, mcpTargetFor(platform));
|
|
102
|
+
const cfg = readJson(mcpPath);
|
|
103
|
+
if (platform === PLATFORM_OPENCODE) return Boolean(cfg?.mcp?.codegraph);
|
|
104
|
+
return Boolean(cfg?.servers?.codegraph);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readJson(filePath) {
|
|
108
|
+
if (!existsSync(filePath)) return null;
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Ensure a file exists with given content (used for a created-not-merged file). */
|
|
117
|
+
export function ensureFile(filePath, contents) {
|
|
118
|
+
if (existsSync(filePath)) return false;
|
|
119
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
120
|
+
writeFileSync(filePath, contents);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const __testables = { readJson, mcpTargetFor };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import kleur from 'kleur';
|
|
2
|
+
import { existsSync, readFileSync, cpSync, mkdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { log } from '../util/log.mjs';
|
|
5
|
+
import { mergeBlock } from '../util/merge.mjs';
|
|
6
|
+
import { templatePath } from '../util/fs.mjs';
|
|
7
|
+
|
|
8
|
+
export const JEVR_STATUS =
|
|
9
|
+
'local heuristic — jev-harness lacks a custom-criteria interface as of 2026-09-26';
|
|
10
|
+
export const JEVR_TOOL = 'local_tier_heuristic';
|
|
11
|
+
export const TEST_GATE_ISSUE = 'https://github.com/idusortus/cli-five/issues';
|
|
12
|
+
|
|
13
|
+
const PLUGIN_REL = join('.opencode', 'plugin', 'jev-tier-router');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `add jev` — tier-routing only.
|
|
17
|
+
*
|
|
18
|
+
* Ships a local tier-classification tool for the Planner. Does NOT wire the
|
|
19
|
+
* test-gate (parked: OpenCode plugin hooks don't fire under OpenChamber's
|
|
20
|
+
* embedded-server routing) and does NOT call Jev (no custom-criteria
|
|
21
|
+
* interface in jev-harness as of this release).
|
|
22
|
+
*/
|
|
23
|
+
export async function runJev({ cwd, args }) {
|
|
24
|
+
const platform = detectPlatform(cwd);
|
|
25
|
+
|
|
26
|
+
if (platform !== 'opencode') {
|
|
27
|
+
log.err('jev is OpenCode-only. No tools.add-equivalent surface exists for Copilot.');
|
|
28
|
+
log.dim('Re-run init with --target opencode, then `npx cli-five add jev`.');
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const written = [];
|
|
34
|
+
|
|
35
|
+
// 1. Plugin directory (package.json + index.js), confirmed convention.
|
|
36
|
+
const pluginDir = join(cwd, PLUGIN_REL);
|
|
37
|
+
mkdirSync(pluginDir, { recursive: true });
|
|
38
|
+
for (const file of ['package.json', 'index.js']) {
|
|
39
|
+
const src = templatePath('opencode', 'plugin', 'jev-tier-router', file);
|
|
40
|
+
const dest = join(pluginDir, file);
|
|
41
|
+
cpSync(src, dest);
|
|
42
|
+
written.push({ path: dest, written: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 2. Register the plugin path in opencode.json's `plugins` array.
|
|
46
|
+
// mergeBlock deep-merges JSON but replaces arrays, so read the existing
|
|
47
|
+
// array, union it, and hand the unioned value to mergeBlock (single
|
|
48
|
+
// write path — no second merge approach).
|
|
49
|
+
const opencodePath = join(cwd, 'opencode.json');
|
|
50
|
+
const plugins = readPlugins(opencodePath);
|
|
51
|
+
const pluginRef = toPosix(PLUGIN_REL);
|
|
52
|
+
if (!plugins.includes(pluginRef)) plugins.push(pluginRef);
|
|
53
|
+
mergeBlock(opencodePath, 'jev', { plugins }, { track: true, metaKey: '$cliFive' });
|
|
54
|
+
|
|
55
|
+
// 3. Planner instruction in AGENTS.md (fenced block, idempotent).
|
|
56
|
+
const agentsPath = join(cwd, 'AGENTS.md');
|
|
57
|
+
mergeBlock(agentsPath, 'jev-tier-routing', plannerInstruction());
|
|
58
|
+
|
|
59
|
+
log.ok(`Plugin written to ${kleur.bold(toPosix(PLUGIN_REL))}`);
|
|
60
|
+
log.ok(`Registered in opencode.json (plugins[])`);
|
|
61
|
+
log.ok('Planner instruction added to AGENTS.md');
|
|
62
|
+
log.raw('');
|
|
63
|
+
log.info(`Tool: ${kleur.bold(JEVR_TOOL)} (tier-routing only)`);
|
|
64
|
+
log.warn('Status: ' + JEVR_STATUS);
|
|
65
|
+
log.dim(`Test-gate is parked — see ${TEST_GATE_ISSUE}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function plannerInstruction() {
|
|
69
|
+
return `## Tier routing (local heuristic)
|
|
70
|
+
|
|
71
|
+
Before planning, call the \`${JEVR_TOOL}\` tool once with the task description.
|
|
72
|
+
|
|
73
|
+
- If it returns \`confidence\` >= 0.6, use its \`tier\` (trivial | minor | major) as your planning depth.
|
|
74
|
+
- If \`confidence\` < 0.6, or the tool is unavailable, use your own judgment and default to \`major\`.
|
|
75
|
+
- This is a local heuristic, not a Jev call. Never block or fail a turn because the tool is unavailable.`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readPlugins(opencodePath) {
|
|
79
|
+
if (!existsSync(opencodePath)) return [];
|
|
80
|
+
try {
|
|
81
|
+
const cfg = JSON.parse(readFileSync(opencodePath, 'utf8'));
|
|
82
|
+
return Array.isArray(cfg.plugins) ? [...cfg.plugins] : [];
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function detectPlatform(cwd) {
|
|
89
|
+
if (existsSync(join(cwd, '.opencode', 'agents'))) return 'opencode';
|
|
90
|
+
if (existsSync(join(cwd, '.github', 'agents', 'orchestrator.agent.md'))) return 'copilot';
|
|
91
|
+
return 'unknown';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function toPosix(p) {
|
|
95
|
+
return p.split('\\').join('/');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const __testables = { plannerInstruction, readPlugins, detectPlatform };
|
package/src/addons/registry.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { runJev, JEVR_STATUS, TEST_GATE_ISSUE } from './jev.mjs';
|
|
4
|
+
import { runCodegraph } from './codegraph-command.mjs';
|
|
5
|
+
import { CODEGRAPH_STATUS } from './codegraph.mjs';
|
|
3
6
|
|
|
4
7
|
/**
|
|
5
8
|
* Add-on registry for `cli-five add <name>`.
|
|
@@ -16,19 +19,27 @@ export const ADDONS = {
|
|
|
16
19
|
name: 'codegraph',
|
|
17
20
|
label: 'CodeGraph',
|
|
18
21
|
description: 'Graph-backed codebase context MCP server and agent instructions.',
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
// Matches jev's status-object pattern: explicit capability + status string,
|
|
23
|
+
// not a bare boolean.
|
|
24
|
+
available: true,
|
|
25
|
+
capability: 'MCP registration + AGENTS.md instructions',
|
|
26
|
+
status: CODEGRAPH_STATUS,
|
|
27
|
+
platforms: ['copilot', 'opencode'],
|
|
21
28
|
detect: detectCodeGraph,
|
|
22
|
-
run:
|
|
29
|
+
run: runCodegraph,
|
|
23
30
|
},
|
|
24
31
|
jev: {
|
|
25
32
|
name: 'jev',
|
|
26
33
|
label: 'Jev',
|
|
27
|
-
description: '
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
description: 'Tier-routing tool for the Planner (local heuristic; test-gate parked).',
|
|
35
|
+
// Not a bare boolean: jev ships tier-routing ONLY. The test-gate half is
|
|
36
|
+
// parked because OpenCode plugin hooks don't fire under OpenChamber routing.
|
|
37
|
+
available: true,
|
|
38
|
+
capability: 'tier-routing only',
|
|
39
|
+
status: `${JEVR_STATUS}; test-gate parked — ${TEST_GATE_ISSUE}`,
|
|
40
|
+
platforms: ['opencode'],
|
|
41
|
+
detect: detectJev,
|
|
42
|
+
run: runJev,
|
|
32
43
|
},
|
|
33
44
|
};
|
|
34
45
|
|
|
@@ -94,3 +105,22 @@ function readJson(filePath) {
|
|
|
94
105
|
return null;
|
|
95
106
|
}
|
|
96
107
|
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Detect the jev tier-router plugin: its directory on disk and/or its entry in
|
|
111
|
+
* opencode.json's `plugins` array. Read-only.
|
|
112
|
+
*/
|
|
113
|
+
export function detectJev(cwd) {
|
|
114
|
+
const signals = [];
|
|
115
|
+
|
|
116
|
+
if (existsSync(join(cwd, '.opencode', 'plugin', 'jev-tier-router', 'index.js'))) {
|
|
117
|
+
signals.push('.opencode/plugin/jev-tier-router');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const cfg = readJson(join(cwd, 'opencode.json'));
|
|
121
|
+
if (Array.isArray(cfg?.plugins) && cfg.plugins.some((p) => String(p).includes('jev-tier-router'))) {
|
|
122
|
+
signals.push('opencode.json plugins[]');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return signals;
|
|
126
|
+
}
|
package/src/commands/add.mjs
CHANGED
|
@@ -28,7 +28,7 @@ export async function add(args) {
|
|
|
28
28
|
if (typeof addon.run !== 'function') {
|
|
29
29
|
log.warn(`${addon.label} is registered but not implemented yet.`);
|
|
30
30
|
log.info('The add dispatcher works — this target is reserved for a future release.');
|
|
31
|
-
if (addon.note) log.dim(addon.note);
|
|
31
|
+
if (addon.note || addon.status) log.dim(addon.note || addon.status);
|
|
32
32
|
log.dim('Nothing was written to your project.');
|
|
33
33
|
return;
|
|
34
34
|
}
|
package/src/commands/init.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { choosePlatform, chooseModels, resolveCodegraphDefault } from '../steps/
|
|
|
13
13
|
import { isGitRepo, gitInit } from '../util/git.mjs';
|
|
14
14
|
import { platformLabel } from '../util/platforms.mjs';
|
|
15
15
|
import { autoProjectInfo } from '../util/project.mjs';
|
|
16
|
+
import { detectAuthenticatedProviders } from '../util/auth.mjs';
|
|
16
17
|
|
|
17
18
|
export async function init(args) {
|
|
18
19
|
const cwd = args.cwd;
|
|
@@ -100,7 +101,7 @@ export async function init(args) {
|
|
|
100
101
|
} else {
|
|
101
102
|
log.step('4/8 Project info');
|
|
102
103
|
docHints = autoProjectInfo(cwd);
|
|
103
|
-
logAutoProjectInfo(docHints);
|
|
104
|
+
logAutoProjectInfo(docHints, { yes: args.yes });
|
|
104
105
|
|
|
105
106
|
log.step('5/8 Model configuration');
|
|
106
107
|
// Minimal path uses provider defaults without prompting (still honours --provider).
|
|
@@ -169,20 +170,30 @@ export async function init(args) {
|
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
/** Log which project fields were auto-pulled from the workspace. */
|
|
172
|
-
function logAutoProjectInfo(info) {
|
|
173
|
+
function logAutoProjectInfo(info, { yes = false } = {}) {
|
|
173
174
|
const name = info?.name || {};
|
|
174
175
|
const oneLiner = info?.oneLiner || {};
|
|
175
176
|
|
|
176
177
|
if (name.value && !name.ambiguous) {
|
|
177
178
|
log.info(`Name: ${kleur.bold(name.value)} ${kleur.gray(`(${name.sources[0].source})`)}`);
|
|
178
179
|
} else if (name.ambiguous) {
|
|
179
|
-
|
|
180
|
+
const picks = name.sources.map((s) => `${s.source}="${s.value}"`).join(', ');
|
|
181
|
+
if (yes) {
|
|
182
|
+
log.warn(`Multiple project names found (${picks}); --yes picked "${name.value}".`);
|
|
183
|
+
} else {
|
|
184
|
+
log.warn(`Multiple project names found (${picks}) — asking.`);
|
|
185
|
+
}
|
|
180
186
|
}
|
|
181
187
|
|
|
182
188
|
if (oneLiner.value && !oneLiner.ambiguous) {
|
|
183
189
|
log.info(`Tagline: ${oneLiner.value} ${kleur.gray(`(${oneLiner.sources[0].source})`)}`);
|
|
184
190
|
} else if (oneLiner.ambiguous) {
|
|
185
|
-
|
|
191
|
+
const picks = oneLiner.sources.map((s) => `${s.source}="${s.value}"`).join(', ');
|
|
192
|
+
if (yes) {
|
|
193
|
+
log.warn(`Multiple descriptions found (${picks}); --yes picked ${oneLiner.sources[0].source}.`);
|
|
194
|
+
} else {
|
|
195
|
+
log.warn(`Multiple descriptions found (${picks}) — asking.`);
|
|
196
|
+
}
|
|
186
197
|
}
|
|
187
198
|
}
|
|
188
199
|
|
|
@@ -208,6 +219,19 @@ function printNextSteps(answers, { generatedInstructions = false } = {}) {
|
|
|
208
219
|
log.raw(` 3. Run OpenCode from this directory:`);
|
|
209
220
|
log.raw(kleur.gray(` opencode`));
|
|
210
221
|
log.raw(` 4. Select ${kleur.bold('Orchestrator')} and describe what you want built.`);
|
|
222
|
+
|
|
223
|
+
// A provider the user is not authenticated for produces agent files whose
|
|
224
|
+
// models silently fail. Surface that here rather than at first agent use.
|
|
225
|
+
const authed = detectAuthenticatedProviders();
|
|
226
|
+
const provider = answers.provider;
|
|
227
|
+
if (authed.length > 0 && !authed.includes(provider)) {
|
|
228
|
+
log.raw('');
|
|
229
|
+
log.warn(`Models use "${provider}", but you are authenticated for: ${authed.join(', ')}.`);
|
|
230
|
+
log.dim(` Authenticate with \`opencode auth login\`, or re-run with --provider ${authed[0]}.`);
|
|
231
|
+
} else if (authed.length === 0) {
|
|
232
|
+
log.raw('');
|
|
233
|
+
log.dim(` Make sure you are authenticated (\`opencode auth login\`) for provider "${provider}".`);
|
|
234
|
+
}
|
|
211
235
|
} else {
|
|
212
236
|
log.raw(` 1. Open this folder in VS Code Insiders.`);
|
|
213
237
|
log.raw(` 2. Enable Copilot subagent invocations (settings.json):`);
|
|
@@ -5,24 +5,33 @@ import { detectAddon, listAddons } from '../addons/registry.mjs';
|
|
|
5
5
|
/**
|
|
6
6
|
* `cli-five list-addons` — show what is installed vs. available.
|
|
7
7
|
*
|
|
8
|
-
* "Installed" is detected read-only from workspace artifacts.
|
|
9
|
-
*
|
|
8
|
+
* "Installed" is detected read-only from workspace artifacts. Status text is
|
|
9
|
+
* deliberately honest: an add-on that ships partial capability (e.g. jev's
|
|
10
|
+
* tier-routing without the parked test-gate) must not read as fully installed.
|
|
10
11
|
*/
|
|
11
12
|
export function listAddonsCommand(args) {
|
|
12
13
|
const cwd = args.cwd;
|
|
13
14
|
log.raw(kleur.bold().magenta('\ncli-five list-addons') + kleur.gray(` ${cwd}`));
|
|
14
15
|
log.raw('');
|
|
15
16
|
|
|
16
|
-
log.raw(` ${kleur.gray(pad('ADD-ON', 12))} ${kleur.gray(pad('STATUS',
|
|
17
|
-
log.raw(` ${'─'.repeat(12)} ${'─'.repeat(
|
|
17
|
+
log.raw(` ${kleur.gray(pad('ADD-ON', 12))} ${kleur.gray(pad('STATUS', 14))} ${kleur.gray(pad('ADD', 10))} ${kleur.gray('DETAIL')}`);
|
|
18
|
+
log.raw(` ${'─'.repeat(12)} ${'─'.repeat(14)} ${'─'.repeat(10)} ${'─'.repeat(30)}`);
|
|
18
19
|
|
|
19
20
|
for (const addon of listAddons()) {
|
|
20
21
|
const signals = detectAddon(addon, cwd);
|
|
21
22
|
const installed = signals.length > 0;
|
|
22
23
|
const addable = typeof addon.run === 'function';
|
|
23
|
-
const detail = installed ? signals.join(', ') : addon.note || '';
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// An installed add-on with a `capability` note is partial — say so.
|
|
26
|
+
const statusLabel = installed
|
|
27
|
+
? (addon.capability ? `installed (${addon.capability})` : 'installed')
|
|
28
|
+
: 'not found';
|
|
29
|
+
|
|
30
|
+
const detail = installed
|
|
31
|
+
? [addon.status || signals.join(', ')].filter(Boolean).join(' — ')
|
|
32
|
+
: addon.note || addon.status || '';
|
|
33
|
+
|
|
34
|
+
const statusText = pad(statusLabel, 14);
|
|
26
35
|
const status = installed ? kleur.green(statusText) : kleur.gray(statusText);
|
|
27
36
|
const addableText = pad(addable ? 'available' : 'planned', 10);
|
|
28
37
|
const addableColored = addable ? kleur.green(addableText) : kleur.yellow(addableText);
|
|
@@ -31,7 +40,7 @@ export function listAddonsCommand(args) {
|
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
log.raw('');
|
|
34
|
-
log.dim('Install with `npx cli-five add <name
|
|
43
|
+
log.dim('Install with `npx cli-five add <name>`.');
|
|
35
44
|
log.raw('');
|
|
36
45
|
}
|
|
37
46
|
|
package/src/steps/platform.mjs
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
platformLabel,
|
|
10
10
|
} from '../util/platforms.mjs';
|
|
11
11
|
import { log } from '../util/log.mjs';
|
|
12
|
+
import { detectAuthenticatedProviders, preferredProviderForAuth } from '../util/auth.mjs';
|
|
12
13
|
import {
|
|
13
14
|
agentNames,
|
|
14
15
|
getDefaultModelMap,
|
|
@@ -118,10 +119,17 @@ export function detectExistingPlatform(cwd) {
|
|
|
118
119
|
* override per-agent models.
|
|
119
120
|
*/
|
|
120
121
|
export async function chooseModels(platform, args) {
|
|
121
|
-
// Defaults for the platform.
|
|
122
|
-
const
|
|
122
|
+
// Defaults for the platform, refined by what the user is actually authed for.
|
|
123
|
+
const authedProvider = platform === PLATFORM_OPENCODE && !args.provider
|
|
124
|
+
? preferredProviderForAuth(platform)
|
|
125
|
+
: null;
|
|
126
|
+
const defaultProvider = providerForPlatform(platform, args.provider || authedProvider);
|
|
123
127
|
const defaults = getDefaultModelMap(defaultProvider);
|
|
124
128
|
|
|
129
|
+
if (authedProvider && authedProvider !== PROVIDER_ZEN) {
|
|
130
|
+
log.dim(`Detected authenticated OpenCode provider: ${providerLabel(authedProvider)} (using it instead of the ${providerLabel(PROVIDER_ZEN)} default).`);
|
|
131
|
+
}
|
|
132
|
+
|
|
125
133
|
if (args.yes) {
|
|
126
134
|
return {
|
|
127
135
|
provider: defaultProvider,
|
|
@@ -206,23 +214,27 @@ async function chooseProvider(platform, cliProvider) {
|
|
|
206
214
|
return PROVIDER_COPILOT;
|
|
207
215
|
}
|
|
208
216
|
|
|
217
|
+
const authed = detectAuthenticatedProviders();
|
|
218
|
+
const isAuthed = (p) => authed.includes(p);
|
|
219
|
+
const annotate = (p, label, description) => ({
|
|
220
|
+
title: isAuthed(p) ? `${label} ${kleur.green('(authenticated)')}` : label,
|
|
221
|
+
value: p,
|
|
222
|
+
description,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const choices = [
|
|
226
|
+
annotate(PROVIDER_ZEN, providerLabel(PROVIDER_ZEN), 'Curated, tested models via OpenCode Zen'),
|
|
227
|
+
annotate(PROVIDER_GO, 'OpenCode Go', 'Low-cost open-coding model subscription'),
|
|
228
|
+
];
|
|
229
|
+
// Put an authenticated provider first so the default is one that works.
|
|
230
|
+
const initial = Math.max(0, choices.findIndex((c) => isAuthed(c.value)));
|
|
231
|
+
|
|
209
232
|
const { provider } = await prompts({
|
|
210
233
|
type: 'select',
|
|
211
234
|
name: 'provider',
|
|
212
235
|
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,
|
|
236
|
+
choices,
|
|
237
|
+
initial,
|
|
226
238
|
});
|
|
227
239
|
|
|
228
240
|
if (!provider) {
|
package/src/steps/scaffold.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { log } from '../util/log.mjs';
|
|
|
3
3
|
import { readTemplate, render, writeFile, listFilesRecursive, relTo, templatePath } from '../util/fs.mjs';
|
|
4
4
|
import { readFileSync } from 'node:fs';
|
|
5
5
|
import { PLATFORM_COPILOT, PLATFORM_OPENCODE, agentDirFor, agentFileFor } from '../util/platforms.mjs';
|
|
6
|
+
import { addCodegraphTo } from '../addons/codegraph.mjs';
|
|
6
7
|
|
|
7
8
|
const AGENT_NAMES = ['orchestrator', 'planner', 'coder', 'designer', 'reviewer'];
|
|
8
9
|
const HISTORY_FILES = ['orchestrator.md', 'planner.md', 'coder.md', 'designer.md', 'reviewer.md'];
|
|
@@ -44,6 +45,9 @@ export function scaffold({ cwd, answers, args }) {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
// Shared memory primitives
|
|
48
|
+
// NOTE: AGENTS.md is written WITHOUT the CodeGraph block here. When CodeGraph
|
|
49
|
+
// is enabled, the block is merged in afterward by addCodegraphTo() — the same
|
|
50
|
+
// code path `add codegraph` uses (one implementation, two entry points).
|
|
47
51
|
for (const tmpl of [
|
|
48
52
|
'AGENTS.md.tmpl',
|
|
49
53
|
'PROJECT.md.tmpl',
|
|
@@ -61,6 +65,13 @@ export function scaffold({ cwd, answers, args }) {
|
|
|
61
65
|
written.push(writeFile(join(cwd, 'histories', file), readTemplate('histories', file), args));
|
|
62
66
|
}
|
|
63
67
|
|
|
68
|
+
// CodeGraph — delegated to the shared add codegraph implementation.
|
|
69
|
+
if (answers.codegraph) {
|
|
70
|
+
for (const t of addCodegraphTo({ cwd, platform, dryRun: args.dryRun })) {
|
|
71
|
+
written.push({ path: t.path, written: !args.dryRun && t.action !== 'unchanged' });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
64
75
|
return written;
|
|
65
76
|
}
|
|
66
77
|
|
|
@@ -93,11 +104,6 @@ function scaffoldCopilot({ cwd, answers, args, vars }) {
|
|
|
93
104
|
writeFile(join(cwd, '.github', 'skills', 'README.md'), readTemplate('.github', 'skills', 'README.md'), args),
|
|
94
105
|
);
|
|
95
106
|
|
|
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));
|
|
99
|
-
}
|
|
100
|
-
|
|
101
107
|
return written;
|
|
102
108
|
}
|
|
103
109
|
|
|
@@ -144,34 +150,10 @@ function buildOpencodeConfig({ answers, orchestratorModel }) {
|
|
|
144
150
|
subagent_depth: 2,
|
|
145
151
|
};
|
|
146
152
|
|
|
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
153
|
return config;
|
|
158
154
|
}
|
|
159
155
|
|
|
160
|
-
function codegraphMcpJson() {
|
|
161
|
-
return {
|
|
162
|
-
inputs: [],
|
|
163
|
-
servers: {
|
|
164
|
-
codegraph: {
|
|
165
|
-
command: 'codegraph',
|
|
166
|
-
args: ['serve', '--mcp'],
|
|
167
|
-
},
|
|
168
|
-
},
|
|
169
|
-
};
|
|
170
|
-
}
|
|
171
|
-
|
|
172
156
|
function buildVars(a) {
|
|
173
|
-
const codegraphBlock = a.codegraph ? CODEGRAPH_BLOCK : '';
|
|
174
|
-
|
|
175
157
|
return {
|
|
176
158
|
PROJECT_NAME: a.projectName,
|
|
177
159
|
ONE_LINER: a.oneLiner || 'TODO — write a one-line vision statement.',
|
|
@@ -190,7 +172,6 @@ ${a.docs}
|
|
|
190
172
|
` : '',
|
|
191
173
|
DATE: new Date().toISOString().slice(0, 10),
|
|
192
174
|
PERSONA_BLOCK: a.snark ? PERSONA_BLOCK : '',
|
|
193
|
-
CODEGRAPH_BLOCK: codegraphBlock,
|
|
194
175
|
};
|
|
195
176
|
}
|
|
196
177
|
|
|
@@ -221,22 +202,6 @@ const PERSONA_BLOCK = `# Persona
|
|
|
221
202
|
|
|
222
203
|
`;
|
|
223
204
|
|
|
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
|
-
|
|
240
205
|
export function summarize(written, cwd) {
|
|
241
206
|
const lines = [];
|
|
242
207
|
for (const w of written) {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { PROVIDER_ZEN, PROVIDER_GO } from './models.mjs';
|
|
6
|
+
|
|
7
|
+
// OpenCode auth provider id -> cli-five provider id.
|
|
8
|
+
// Zen authenticates as `opencode`; Go as `opencode-go`.
|
|
9
|
+
const AUTH_ID_TO_PROVIDER = {
|
|
10
|
+
opencode: PROVIDER_ZEN,
|
|
11
|
+
'opencode-go': PROVIDER_GO,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Detect which OpenCode model providers the user is actually authenticated for.
|
|
16
|
+
*
|
|
17
|
+
* Best-effort and side-effect free: shells out to `opencode auth list --format json`
|
|
18
|
+
* and falls back to reading OpenCode's auth.json. Returns an array of cli-five
|
|
19
|
+
* provider ids (e.g. ['opencode-go']); empty when nothing is detected.
|
|
20
|
+
*
|
|
21
|
+
* Never throws — a missing OpenCode CLI or unreadable auth must not break init.
|
|
22
|
+
*/
|
|
23
|
+
export function detectAuthenticatedProviders() {
|
|
24
|
+
for (const ids of [authFromCli(), authFromFile()]) {
|
|
25
|
+
if (ids && ids.length > 0) return ids;
|
|
26
|
+
}
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function authFromCli() {
|
|
31
|
+
try {
|
|
32
|
+
const out = execFileSync('opencode', ['auth', 'list', '--format', 'json'], {
|
|
33
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
timeout: 5000,
|
|
36
|
+
});
|
|
37
|
+
const parsed = JSON.parse(out);
|
|
38
|
+
if (!Array.isArray(parsed)) return null;
|
|
39
|
+
return parsed
|
|
40
|
+
.map((entry) => AUTH_ID_TO_PROVIDER[entry?.id])
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function authFromFile() {
|
|
48
|
+
const candidates = [
|
|
49
|
+
process.env.OPENCODE_AUTH_FILE,
|
|
50
|
+
join(homedir(), '.local', 'share', 'opencode', 'auth.json'),
|
|
51
|
+
join(process.env.XDG_DATA_HOME || '', 'opencode', 'auth.json'),
|
|
52
|
+
].filter(Boolean);
|
|
53
|
+
|
|
54
|
+
for (const path of candidates) {
|
|
55
|
+
if (!existsSync(path)) continue;
|
|
56
|
+
try {
|
|
57
|
+
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
58
|
+
return Object.keys(data)
|
|
59
|
+
.map((id) => AUTH_ID_TO_PROVIDER[id])
|
|
60
|
+
.filter(Boolean);
|
|
61
|
+
} catch {
|
|
62
|
+
/* try next */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Pick the best provider for a platform given what the user is authenticated for.
|
|
70
|
+
*
|
|
71
|
+
* For OpenCode: prefer the authenticated provider when there is one; when the
|
|
72
|
+
* user has only Go (or only Zen), use it instead of the blind Zen default.
|
|
73
|
+
* Returns null when no preference can be inferred (caller keeps its default).
|
|
74
|
+
*/
|
|
75
|
+
export function preferredProviderForAuth(platform) {
|
|
76
|
+
if (platform !== 'opencode') return null;
|
|
77
|
+
const authed = detectAuthenticatedProviders();
|
|
78
|
+
if (authed.length === 0) return null;
|
|
79
|
+
// Prefer Go when both are present only if Zen is absent — otherwise Zen.
|
|
80
|
+
if (authed.includes(PROVIDER_ZEN)) return PROVIDER_ZEN;
|
|
81
|
+
if (authed.includes(PROVIDER_GO)) return PROVIDER_GO;
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const __testables = { authFromCli, authFromFile, AUTH_ID_TO_PROVIDER };
|
package/templates/AGENTS.md.tmpl
CHANGED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// cli-five jev-tier-router plugin (OpenCode).
|
|
2
|
+
//
|
|
3
|
+
// Ships a `local_tier_heuristic` tool that classifies a task description into
|
|
4
|
+
// cli-five's tier vocabulary: trivial | minor | major.
|
|
5
|
+
//
|
|
6
|
+
// TRUTHFUL NAMING: this is a LOCAL heuristic. It does NOT call Jev. As of
|
|
7
|
+
// 2026-09-26, jev-harness's `route` subcommand exposes no custom-criteria
|
|
8
|
+
// interface (it emits its own fixed tier vocabulary: deterministic /
|
|
9
|
+
// lightweight_system2 / heavy_system2) and returns a constant confidence
|
|
10
|
+
// (0.88) under its offline/mock engine, so it cannot be thresholded on.
|
|
11
|
+
// The name `local_tier_heuristic` is deliberate — do not revive `jev_tier_route`
|
|
12
|
+
// unless/until real Jev wiring is verified. See JEVR_SWAP_POINT below.
|
|
13
|
+
//
|
|
14
|
+
// Fail-open: if anything goes wrong the tool reports `available: false` and
|
|
15
|
+
// tier "major" (the expensive tier), so the Planner falls back to its own
|
|
16
|
+
// judgment. It never throws in a way that would break the Planner's turn.
|
|
17
|
+
|
|
18
|
+
import { appendFileSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
const CONFIDENCE_CUTOFF = 0.6;
|
|
22
|
+
const FALLBACK_TIER = 'major';
|
|
23
|
+
|
|
24
|
+
const TIERS = ['trivial', 'minor', 'major'];
|
|
25
|
+
|
|
26
|
+
// Weighted signals. `strong` matches dominate; `moderate` accumulate.
|
|
27
|
+
const SIGNALS = [
|
|
28
|
+
// trivial — mechanical, single-token, no reasoning
|
|
29
|
+
{ tier: 'trivial', weight: 3, re: /\b(typo|typos|whitespace|lint|linting|format|formatting|rename|renaming|comment|comments|docstring|spelling|indent(ation)?)\b/i },
|
|
30
|
+
{ tier: 'trivial', weight: 2, re: /\b(README|changelog|CHANGELOG|\.md\b|docs?)\b/i },
|
|
31
|
+
{ tier: 'trivial', weight: 2, re: /\b(one[- ]?line|single[- ]?(file|line)|small tweak|quick fix|minor tweak)\b/i },
|
|
32
|
+
{ tier: 'trivial', weight: 2, re: /\b(delete|remove)\b[\s\w]{0,20}\b(console\.log|stray|unused|dead code|tmp|temp file)\b/i },
|
|
33
|
+
|
|
34
|
+
// major — architectural, cross-cutting, ambiguous scope
|
|
35
|
+
{ tier: 'major', weight: 3, re: /\b(architect(ure|ural)?|redesign|rearchitect|rewrite|overhaul|migrat(e|ion)|replatform|distributed|scalab(le|ility)|multi[- ]?(tenant|region|service))\b/i },
|
|
36
|
+
{ tier: 'major', weight: 3, re: /\b(entire|whole|across (the )?(codebase|repo(sitory)?|project)|end[- ]to[- ]end|system[- ]wide)\b/i },
|
|
37
|
+
{ tier: 'major', weight: 2, re: /\b(concurren(cy|t)|race condition|deadlock|transaction(al)?|consistency|eventual consistency|saga|retry (architecture|strategy)|queue|scheduler|orchestrat(e|ion))\b/i },
|
|
38
|
+
{ tier: 'major', weight: 2, re: /\b(performance|latency|throughput|optimi[sz]e|profil(e|ing)|security|auth(entication|orization)?|encryption|compliance|HIPAA|SOC ?2|GDPR)\b/i },
|
|
39
|
+
{ tier: 'major', weight: 1, re: /\b(design|feature|implement|build|add support for|new (module|service|system))\b/i },
|
|
40
|
+
|
|
41
|
+
// minor — bounded, local, incremental
|
|
42
|
+
{ tier: 'minor', weight: 3, re: /\b(validation|validate|error handling|error message|edge case|bug ?fix|fix (a |the )?bug|patch|handle null|guard clause)\b/i },
|
|
43
|
+
{ tier: 'minor', weight: 2, re: /\b(component|function|method|handler|endpoint|form|button|modal|tooltip|dropdown)\b/i },
|
|
44
|
+
{ tier: 'minor', weight: 2, re: /\b(refactor|extract|rename (the )?(function|method|class|module)|tidy|clean ?up)\b/i },
|
|
45
|
+
{ tier: 'minor', weight: 1, re: /\b(add|update|adjust|tweak|improve|tidy)\b/i },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Classify a task description locally.
|
|
50
|
+
*
|
|
51
|
+
* Returns { tier, confidence, rationale, available, source }.
|
|
52
|
+
* Ambiguity fails toward the expensive tier (major), never the cheap one.
|
|
53
|
+
*/
|
|
54
|
+
export function classifyTask(description) {
|
|
55
|
+
const text = String(description ?? '').trim();
|
|
56
|
+
if (!text) {
|
|
57
|
+
return {
|
|
58
|
+
tier: FALLBACK_TIER,
|
|
59
|
+
confidence: 0,
|
|
60
|
+
rationale: 'Empty task description; defaulting to the expensive tier.',
|
|
61
|
+
available: true,
|
|
62
|
+
source: 'local_heuristic',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const scores = { trivial: 0, minor: 0, major: 0 };
|
|
67
|
+
const hits = { trivial: [], minor: [], major: [] };
|
|
68
|
+
|
|
69
|
+
for (const signal of SIGNALS) {
|
|
70
|
+
if (signal.re.test(text)) {
|
|
71
|
+
scores[signal.tier] += signal.weight;
|
|
72
|
+
hits[signal.tier].push(signal.re.source.slice(0, 40));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Length is a weak major signal: long, multi-clause prompts rarely stay local.
|
|
77
|
+
const words = text.split(/\s+/).filter(Boolean).length;
|
|
78
|
+
if (words > 25) scores.major += 1;
|
|
79
|
+
if (words > 60) scores.major += 1;
|
|
80
|
+
|
|
81
|
+
const ranked = TIERS.map((tier) => ({ tier, score: scores[tier] })).sort((a, b) => b.score - a.score);
|
|
82
|
+
const [top, second] = ranked;
|
|
83
|
+
|
|
84
|
+
if (top.score === 0) {
|
|
85
|
+
// Nothing matched — ambiguous. Fail toward the expensive tier.
|
|
86
|
+
return {
|
|
87
|
+
tier: FALLBACK_TIER,
|
|
88
|
+
confidence: 0.3,
|
|
89
|
+
rationale: 'No tier signals matched; ambiguous, so defaulting to the expensive tier.',
|
|
90
|
+
available: true,
|
|
91
|
+
source: 'local_heuristic',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const total = TIERS.reduce((sum, tier) => sum + scores[tier], 0);
|
|
96
|
+
const separation = (top.score - (second?.score ?? 0)) / top.score;
|
|
97
|
+
const share = top.score / total;
|
|
98
|
+
let confidence = 0.5 * share + 0.5 * separation;
|
|
99
|
+
|
|
100
|
+
// A single weak hit with no corroboration is not a confident call.
|
|
101
|
+
if (top.score <= 1) confidence = Math.min(confidence, 0.5);
|
|
102
|
+
|
|
103
|
+
confidence = Math.round(confidence * 100) / 100;
|
|
104
|
+
|
|
105
|
+
if (confidence < CONFIDENCE_CUTOFF) {
|
|
106
|
+
return {
|
|
107
|
+
tier: FALLBACK_TIER,
|
|
108
|
+
confidence,
|
|
109
|
+
rationale: `Low confidence (${confidence} < ${CONFIDENCE_CUTOFF}) between ${top.tier} and ${second?.tier ?? 'n/a'}; defaulting to the expensive tier.`,
|
|
110
|
+
available: true,
|
|
111
|
+
source: 'local_heuristic',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
tier: top.tier,
|
|
117
|
+
confidence,
|
|
118
|
+
rationale: `Matched ${hits[top.tier].length} ${top.tier} signal(s).`,
|
|
119
|
+
available: true,
|
|
120
|
+
source: 'local_heuristic',
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── JEVR_SWAP_POINT ───────────────────────────────────────────────────
|
|
125
|
+
// Real-Jev wiring would replace classifyTask() above with a shell-out to
|
|
126
|
+
// jev-harness route --json --task "<description>"
|
|
127
|
+
// and map the returned `selected_tier` onto cli-five's tiers, thresholding on
|
|
128
|
+
// `confidence`. It is NOT wired because, as of 2026-09-26, `route` exposes no
|
|
129
|
+
// custom-criteria interface, emits a fixed vocabulary, and returns a constant
|
|
130
|
+
// confidence under the offline engine. Re-verify before swapping.
|
|
131
|
+
// Exact call site: the `execute` handler below that calls classifyTask().
|
|
132
|
+
// ──────────────────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
export const __testables = { classifyTask, CONFIDENCE_CUTOFF, FALLBACK_TIER };
|
|
135
|
+
|
|
136
|
+
export default {
|
|
137
|
+
id: 'cli-five-jev-tier-router',
|
|
138
|
+
setup: async (ctx) => {
|
|
139
|
+
if (!ctx || !ctx.tool || typeof ctx.tool.transform !== 'function') return;
|
|
140
|
+
|
|
141
|
+
await ctx.tool.transform((tools) => {
|
|
142
|
+
tools.add({
|
|
143
|
+
name: 'local_tier_heuristic',
|
|
144
|
+
description:
|
|
145
|
+
'Classify a task description into cli-five\'s tier vocabulary (trivial | minor | major) using a local heuristic. ' +
|
|
146
|
+
'Call this once per task, before planning. Trust the returned tier when confidence >= 0.6; otherwise fall back to "major". ' +
|
|
147
|
+
'This is a local heuristic, not a Jev call.',
|
|
148
|
+
input: {
|
|
149
|
+
type: 'object',
|
|
150
|
+
properties: {
|
|
151
|
+
description: {
|
|
152
|
+
type: 'string',
|
|
153
|
+
description: 'The task to classify, verbatim (the user request or planning prompt).',
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
required: ['description'],
|
|
157
|
+
additionalProperties: false,
|
|
158
|
+
},
|
|
159
|
+
async execute(input) {
|
|
160
|
+
try {
|
|
161
|
+
const result = classifyTask(input?.description);
|
|
162
|
+
return { content: JSON.stringify(result) };
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// Fail-open: never break the caller's turn.
|
|
165
|
+
try {
|
|
166
|
+
appendFileSync(
|
|
167
|
+
process.env.CLI_FIVE_LOGFILE || join(process.cwd(), '.opencode', 'journals', 'jev-tier-router.log'),
|
|
168
|
+
`${new Date().toISOString()} local_tier_heuristic fail-open: ${err?.message || err}\n`,
|
|
169
|
+
);
|
|
170
|
+
} catch {
|
|
171
|
+
/* logging is best-effort */
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
content: JSON.stringify({
|
|
175
|
+
tier: FALLBACK_TIER,
|
|
176
|
+
confidence: 0,
|
|
177
|
+
rationale: 'Tier classifier unavailable; defaulting to the expensive tier.',
|
|
178
|
+
available: false,
|
|
179
|
+
source: 'local_heuristic',
|
|
180
|
+
}),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
};
|