model-orchestrator 0.1.35 → 1.0.1
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/AGENTS.md +31 -21
- package/CHANGELOG.md +58 -1
- package/README.md +129 -110
- package/SECURITY.md +7 -3
- package/bin/README.md +57 -6
- package/bin/aunx.js +7 -0
- package/bin/cli-run.mjs +21 -15
- package/bin/cli.js +376 -257
- package/docs/README.md +15 -18
- package/docs/catalog.md +236 -44
- package/docs/companions.md +28 -10
- package/docs/guarantees.md +21 -12
- package/docs/how-it-routes.md +49 -42
- package/docs/install.md +141 -33
- package/docs/part-1-beginner.md +37 -45
- package/docs/part-2-intermediate.md +34 -52
- package/docs/part-3-advanced.md +36 -26
- package/docs/security-review-history.md +39 -0
- package/llms.txt +24 -25
- package/package.json +15 -8
- package/proof/README.md +100 -0
- package/proof/gate-demo.cast +9 -0
- package/proof/gate-demo.gif +0 -0
- package/proof/results.json +198 -0
- package/proof/scripts/check-gate.js +26 -0
- package/proof/scripts/install-time.js +16 -0
- package/proof/scripts/lib.js +73 -0
- package/proof/scripts/measure.js +15 -0
- package/proof/scripts/missing-results.js +30 -0
- package/proof/scripts/record-gate.js +38 -0
- package/proof/scripts/render.js +18 -0
- package/proof/scripts/runner-overhead.js +21 -0
- package/src/README.md +10 -3
- package/src/activation-ownership.js +19 -0
- package/src/apply-companions.js +104 -0
- package/src/apply-snippets.js +60 -28
- package/src/aunx.js +272 -0
- package/src/bounded-file.js +31 -0
- package/src/catalog.js +257 -121
- package/src/install.js +483 -212
- package/src/plugin.js +13 -4
- package/src/postinstall.js +57 -0
- package/src/roles.js +184 -0
- package/src/uninstall.js +128 -10
- package/templates/README.md +19 -2
- package/templates/advanced/README.md +2 -2
- package/templates/advanced/vm/ENVIRONMENT.md +8 -0
- package/templates/advanced/vm/PRIVACY_GATES.md +17 -19
- package/templates/advanced/vm/README.md +25 -20
- package/templates/advanced/vm/box-CLAUDE.md +19 -18
- package/templates/advanced/vm/docker-compose.yml +2 -1
- package/templates/advanced/vm/jobs/README.md +31 -2
- package/templates/advanced/vm/jobs/weekly-audit.service +7 -2
- package/templates/advanced/vm/jobs/weekly-audit.sh +24 -17
- package/templates/advanced/vm/setup-vm.sh +49 -2
- package/templates/agents/README.md +2 -2
- package/templates/agents/agy/README.md +20 -3
- package/templates/agents/agy/builder.md +11 -7
- package/templates/agents/agy/bulk-worker.md +9 -7
- package/templates/agents/agy/code-reviewer.md +13 -7
- package/templates/agents/agy/deep-planner.md +10 -7
- package/templates/agents/agy/done-verifier.md +13 -22
- package/templates/agents/agy/finding-verifier.md +14 -22
- package/templates/agents/agy/live-researcher.md +10 -7
- package/templates/agents/agy/reader.md +10 -12
- package/templates/agents/claude-code/README.md +18 -14
- package/templates/agents/claude-code/builder.md +10 -15
- package/templates/agents/claude-code/bulk-worker.md +8 -10
- package/templates/agents/claude-code/code-reviewer.md +11 -17
- package/templates/agents/claude-code/deep-planner.md +9 -11
- package/templates/agents/claude-code/done-verifier.md +12 -33
- package/templates/agents/claude-code/finding-verifier.md +13 -39
- package/templates/agents/claude-code/live-researcher.md +9 -11
- package/templates/agents/claude-code/reader.md +9 -18
- package/templates/agents/snippets/chat.md +9 -10
- package/templates/agents/snippets/claude-code.md +17 -18
- package/templates/agents/snippets/generic.md +9 -11
- package/templates/agents/snippets/route-gate.mjs +2 -2
- package/templates/agents/snippets/route-metrics.mjs +1 -1
- package/templates/agents/snippets/subagent-context.mjs +4 -4
- package/templates/beginner/ORCHESTRATOR.md +31 -36
- package/templates/beginner/README.md +1 -1
- package/templates/common/ACCEPTANCE_CHECKS.json +12 -0
- package/templates/common/CONTEXT.md +37 -0
- package/templates/common/DECISIONS.md +11 -0
- package/templates/common/README.md +24 -11
- package/templates/common/TASK_BRIEF.md +84 -0
- package/templates/common/protocols/README.md +14 -11
- package/templates/common/protocols/acceptance-checks.md +15 -0
- package/templates/common/protocols/build-protocol.md +91 -106
- package/templates/common/protocols/context-file.md +10 -0
- package/templates/common/protocols/decision-log.md +9 -0
- package/templates/common/protocols/deep-research.md +20 -34
- package/templates/common/protocols/docs-then-prove.md +13 -18
- package/templates/common/protocols/gap-analysis.md +15 -21
- package/templates/common/protocols/memory-and-record.md +21 -20
- package/templates/common/protocols/numbers-and-logic.md +20 -26
- package/templates/common/protocols/propagate.md +18 -27
- package/templates/intermediate/CLI-RUN.md +83 -113
- package/templates/intermediate/DELEGATION_MATRIX.md +9 -3
- package/templates/intermediate/README.md +3 -3
- package/templates/intermediate/RESEARCH_TRIAGE.md +23 -15
- package/templates/intermediate/ROUTING.md +54 -51
- package/templates/intermediate/TIERS.md +37 -76
- package/templates/tools/README.md +1 -1
- package/templates/tools/codecalc/CODECALC.md +4 -4
- package/templates/tools/codecalc/mcp/agy.mcp_config.json +1 -1
- package/templates/tools/codecalc/mcp/codex.config.toml +1 -1
- package/templates/tools/codecalc/mcp/mcpServers.json +1 -1
- package/templates/tools/codecalc/mcp/vscode.mcp.json +1 -1
- package/templates/tools/codecalc/mcp/zed.settings.json +1 -1
- package/templates/tools/context7/CONTEXT7.md +6 -10
- package/templates/tools/obsidian-tc/OBSIDIAN-TC.md +3 -3
- package/templates/tools/obsidian-tc/mcp/obsidian-tc.agy.mcp_config.json +1 -1
- package/templates/tools/obsidian-tc/mcp/obsidian-tc.codex.config.toml +1 -1
- package/templates/tools/obsidian-tc/mcp/obsidian-tc.mcpServers.json +1 -1
- package/templates/tools/obsidian-tc/mcp/obsidian-tc.vscode.mcp.json +1 -1
- package/templates/tools/obsidian-tc/mcp/obsidian-tc.zed.settings.json +1 -1
- package/docs/audit-brief.md +0 -148
- package/scripts/README.md +0 -7
- package/scripts/gen-catalog.js +0 -81
- package/scripts/gen-plugin.js +0 -16
- package/scripts/record-demo.sh +0 -45
- package/templates/common/TASK_BUNDLE.md +0 -56
package/bin/cli.js
CHANGED
|
@@ -1,31 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// model-orchestrator installer.
|
|
3
|
-
//
|
|
4
|
-
// matching files into a folder. It never writes a secret
|
|
3
|
+
// Detects available AIs and previews a stack-specific setup for confirmation.
|
|
4
|
+
// Writes the matching files into a folder. It never writes a secret or runs a vendor
|
|
5
5
|
// shell script. Existing documents are preserved unless an update is requested.
|
|
6
6
|
|
|
7
7
|
import { stdin, stdout } from 'node:process';
|
|
8
8
|
import { makeAsker } from '../src/prompt.js';
|
|
9
|
-
import { spawnSync } from 'node:child_process';
|
|
10
9
|
import { resolve, join } from 'node:path';
|
|
11
10
|
import { which } from '../src/detect.js';
|
|
12
|
-
import { AIS, LEVELS, TOOLS, PROVIDERS, aisForLevel, agentCandidates, byId, npmSpec } from '../src/catalog.js';
|
|
13
|
-
import { planFiles, writeFiles, resolveSelection, resolveTools, resolveApis, dirProblems, readManifest, activationSteps, MACHINE_OWNED, RUNTIME, toPosixRel, GENERATOR_VERSION } from '../src/install.js';
|
|
14
|
-
// npm resolves to npm.cmd on Windows; spawning that bare name with no shell
|
|
15
|
-
// hits the same EINVAL bin/cli-run.mjs's lanes did (Node's fix for
|
|
16
|
-
// CVE-2024-27980). windowsSpawnPlan is the same fix reused here rather than
|
|
17
|
-
// duplicated: resolve npm's own cmd-shim and run node on it directly, no
|
|
18
|
-
// shell, or fall back to the escaped cmd.exe path it also provides.
|
|
19
|
-
import { windowsSpawnPlan } from './cli-run.mjs';
|
|
11
|
+
import { AIS, LEVELS, TOOLS, PROVIDERS, aisForLevel, agentCandidates, byId, npmSpec, summaryWithEvidence } from '../src/catalog.js';
|
|
12
|
+
import { planFiles, writeFiles, writePreflightProblems, resolveSelection, resolveTools, resolveApis, dirProblems, readManifest, activationSteps, MACHINE_OWNED, RUNTIME, toPosixRel, GENERATOR_VERSION } from '../src/install.js';
|
|
20
13
|
import { uninstallFiles } from '../src/uninstall.js';
|
|
21
14
|
import { assertSnippetPrimary, planSnippetApplication } from '../src/apply-snippets.js';
|
|
15
|
+
import { assignRoles, inferPrimary, roleTable } from '../src/roles.js';
|
|
16
|
+
import { installHealthCheck, signInStatus } from '../src/postinstall.js';
|
|
17
|
+
import { planCompanionApplication } from '../src/apply-companions.js';
|
|
22
18
|
|
|
23
19
|
// One strict parse. Unknown flags, missing values and duplicates are usage
|
|
24
20
|
// errors (exit 2) before anything is planned, so a typo like --dryy can never
|
|
25
21
|
// turn a dry run into a real one.
|
|
26
22
|
const SPEC = {
|
|
27
23
|
level: 'value', ais: 'value', primary: 'value', dir: 'value', project: 'value', tools: 'value', apis: 'value', plans: 'value',
|
|
28
|
-
'apply-snippets': 'bool', yes: 'bool', force: 'bool', dry: 'bool', 'dry-run': 'bool', uninstall: 'bool', 'no-install': 'bool', 'no-tools': 'bool', 'no-apis': 'bool', 'effort-auto': 'bool', 'upgrade-runtime': 'bool', 'update-docs': 'bool', list: 'bool', help: 'bool', h: 'bool', version: 'bool', v: 'bool'
|
|
24
|
+
'apply-snippets': 'bool', 'no-apply': 'bool', yes: 'bool', force: 'bool', dry: 'bool', 'dry-run': 'bool', uninstall: 'bool', 'no-install': 'bool', 'no-tools': 'bool', 'no-apis': 'bool', 'effort-auto': 'bool', 'upgrade-runtime': 'bool', 'update-docs': 'bool', list: 'bool', help: 'bool', h: 'bool', version: 'bool', v: 'bool'
|
|
29
25
|
};
|
|
30
26
|
export function parseArgs(argv) {
|
|
31
27
|
const out = {};
|
|
@@ -91,7 +87,7 @@ if (flag('version') || flag('v')) {
|
|
|
91
87
|
}
|
|
92
88
|
|
|
93
89
|
if (flag('help') || flag('h')) {
|
|
94
|
-
console.log(`model-orchestrator: set up a model
|
|
90
|
+
console.log(`model-orchestrator: set up a model router for the AIs you actually have.
|
|
95
91
|
|
|
96
92
|
Usage
|
|
97
93
|
npx model-orchestrator interactive
|
|
@@ -102,18 +98,19 @@ Usage
|
|
|
102
98
|
Flags
|
|
103
99
|
--level 1|2|3 1 beginner (one agent), 2 intermediate (many CLIs), 3 advanced (plus a VM)
|
|
104
100
|
--ais a,b,c catalog ids you have access to (see --list)
|
|
105
|
-
--primary id the agent that runs the system and receives the subagents (any level). When several qualify
|
|
101
|
+
--primary id the main agent that runs the system and receives the subagents (any level). When several qualify
|
|
106
102
|
and --yes is set, the run picks one and says so in the plan; pass this to decide it yourself.
|
|
107
|
-
--tools a,b companion
|
|
103
|
+
--tools a,b companion guides and supported project registration, all optional (default: none)
|
|
108
104
|
--apis a,b level 3 only: metered API keys you HOLD (anthropic,openai,google,xai,openrouter); --no-apis for none.
|
|
109
|
-
|
|
105
|
+
Select these with --apis or the edit screen; a subscription is not an API key.
|
|
110
106
|
--plans a=plan,b=plan stated subscription plans for guidance; --plans none clears prior stated plans
|
|
111
107
|
--effort-auto consent to write auto effort defaults for selected high or max plan cli-run lanes
|
|
112
108
|
--dir path where to write the docs and protocols (default ./ai-orchestrator)
|
|
113
|
-
--project path the project root your agent runs from
|
|
114
|
-
|
|
115
|
-
--yes skip confirmations
|
|
116
|
-
--apply-snippets apply
|
|
109
|
+
--project path the project root your agent runs from (default: current directory);
|
|
110
|
+
choose a project folder: global agent configuration is outside the installer scope
|
|
111
|
+
--yes skip confirmations; requires --level and --ais for a reproducible install
|
|
112
|
+
--apply-snippets apply main-agent rules and supported project config with backups; enables activation with --yes
|
|
113
|
+
--no-apply leave project rules and settings for manual activation (interactive default: apply)
|
|
117
114
|
--force overwrite every file that already exists, documents included
|
|
118
115
|
--upgrade-runtime replace the runtime files (cli-run, the audit job, compose, gateway config, setup script) even
|
|
119
116
|
when they cannot be verified as untouched; documents are still kept
|
|
@@ -122,7 +119,8 @@ Flags
|
|
|
122
119
|
and reported, and nothing happens without a manifest
|
|
123
120
|
--uninstall remove unedited managed files recorded in MANIFEST.json; keep and list edited files
|
|
124
121
|
--dry, --dry-run print the plan, write nothing
|
|
125
|
-
--no-install
|
|
122
|
+
--no-install accepted for compatibility; the installer never runs third-party installations
|
|
123
|
+
--no-tools accepted for compatibility; companions default to none
|
|
126
124
|
--list print the catalog
|
|
127
125
|
--version, -v print the version and exit
|
|
128
126
|
--help, -h print this and exit
|
|
@@ -135,9 +133,10 @@ if (flag('list')) {
|
|
|
135
133
|
console.log('');
|
|
136
134
|
for (const a of AIS) {
|
|
137
135
|
const here = a.bin ? (which(a.bin) ? 'installed' : 'not on PATH') : 'app';
|
|
138
|
-
|
|
136
|
+
const billing = { subscription: 'subscription lane', 'pay-per-token': 'pay-per-token lane', free: 'free lane', local: 'local lane' }[a.facts.billing];
|
|
137
|
+
console.log(`${a.id.padEnd(13)} ${a.name}\n${''.padEnd(13)} level ${a.minLevel}+ · ${billing} · ${here}\n${''.padEnd(13)} ${summaryWithEvidence(a)}`);
|
|
139
138
|
const install = a.install.npm
|
|
140
|
-
?
|
|
139
|
+
? 'npm install -g ' + npmSpec(a)
|
|
141
140
|
: a.install.script
|
|
142
141
|
? `curl -fsSL ${a.install.script} -o /tmp/${a.id}-install.sh && less /tmp/${a.id}-install.sh && bash /tmp/${a.id}-install.sh`
|
|
143
142
|
: a.install.url + (a.install.brew ? ` (or: brew install ${a.install.brew})` : '');
|
|
@@ -152,12 +151,19 @@ if (flag('list')) {
|
|
|
152
151
|
}
|
|
153
152
|
|
|
154
153
|
const yes = flag('yes');
|
|
155
|
-
|
|
154
|
+
let rl = null;
|
|
156
155
|
const ask = (q, fallback) => (rl ? rl.ask(q, fallback) : Promise.resolve(fallback));
|
|
157
156
|
|
|
157
|
+
// Throws instead of exiting directly. Every call site outside the interactive
|
|
158
|
+
// edit screen has no handler in between, so the error rides the promise chain
|
|
159
|
+
// up to main().catch() below, which prints the same message and exits 2: flag
|
|
160
|
+
// validation and --yes keep their hard exit. Inside editSetup() a catch block
|
|
161
|
+
// takes the USAGE code as a signal to re-ask the same field instead of
|
|
162
|
+
// letting the process die on a typo (H1).
|
|
158
163
|
function bad(msg) {
|
|
159
|
-
|
|
160
|
-
|
|
164
|
+
const err = new Error(msg);
|
|
165
|
+
err.code = 'USAGE';
|
|
166
|
+
throw err;
|
|
161
167
|
}
|
|
162
168
|
|
|
163
169
|
function plansFromIds(raw, selected) {
|
|
@@ -180,7 +186,194 @@ function plansFromIds(raw, selected) {
|
|
|
180
186
|
function plansFromManifest(manifest, selected) {
|
|
181
187
|
const raw = manifest && manifest.plans;
|
|
182
188
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
|
183
|
-
|
|
189
|
+
const pairs = Object.entries(raw).filter(([id]) => selected.some((ai) => ai.id === id));
|
|
190
|
+
return pairs.length ? plansFromIds(pairs.map(([id, plan]) => `${id}=${plan}`).join(','), selected) : {};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function inferLevel(selected) {
|
|
194
|
+
return Math.min(2, Math.max(1, selected.length >= 2 && selected.some((ai) => ai.facts.cliRun) ? 2 : 1, ...selected.map((ai) => ai.minLevel)));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 0.1.35's --primary auto-pick (git show 20408a8:bin/cli.js), kept verbatim
|
|
198
|
+
// for --yes so a reproducible flag-only run never changes its main agent
|
|
199
|
+
// across a version bump (K1): claude-code first, else the first candidate
|
|
200
|
+
// that can load project agent definitions, else the first candidate in
|
|
201
|
+
// selection order. `inferPrimary`'s capability ranking is for the
|
|
202
|
+
// interactive default only.
|
|
203
|
+
export function legacyPrimary(candidates) {
|
|
204
|
+
if (!candidates.length) return null;
|
|
205
|
+
return candidates.find((ai) => ai.id === 'claude-code') || candidates.find((ai) => ai.facts.agentDefinitions) || candidates[0];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Detection reads filesystem metadata only. A caller can supply the detector
|
|
209
|
+
// for deterministic tests; no detected vendor program is executed here.
|
|
210
|
+
export function inferSetup({ selected, detect = which } = {}) {
|
|
211
|
+
const detected = new Set(AIS.filter((ai) => ai.bin && detect(ai.bin)).map((ai) => ai.id));
|
|
212
|
+
const picks = selected || AIS.filter((ai) => detected.has(ai.id));
|
|
213
|
+
return { detected, selected: picks, level: inferLevel(picks), primary: inferPrimary(agentCandidates(picks)) || null, tools: [], apis: [], plans: {}, effortAuto: [] };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function selectedFromIds(raw) {
|
|
217
|
+
const { selected, unknown } = resolveSelection(raw.split(',').map((s) => s.trim()).filter(Boolean));
|
|
218
|
+
if (unknown.length) bad('unknown AI id(s): ' + unknown.join(', ') + ' (see --list)');
|
|
219
|
+
if (!selected.length) bad('pick at least one AI');
|
|
220
|
+
return selected;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function numberedPicks(answer, available, noun) {
|
|
224
|
+
if (answer.toLowerCase() === 'none') return [];
|
|
225
|
+
const picked = answer.split(',').map((s) => s.trim()).filter(Boolean).map((number) => {
|
|
226
|
+
const index = Number(number);
|
|
227
|
+
const item = Number.isInteger(index) && index > 0 ? available[index - 1] : null;
|
|
228
|
+
if (!item) bad(`no ${noun} numbered ${number}`);
|
|
229
|
+
return item;
|
|
230
|
+
});
|
|
231
|
+
return [...new Set(picked)];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function askAis(available, detected, selected = []) {
|
|
235
|
+
console.log('\nWhich AIs do you have access to? (numbers, comma-separated; detected ones are marked)');
|
|
236
|
+
available.forEach((ai, i) => console.log(` ${String(i + 1).padStart(2)} ${detected.has(ai.id) ? '*' : ' '} ${ai.name}`));
|
|
237
|
+
const fallback = available.map((ai, i) => selected.includes(ai) ? i + 1 : null).filter(Boolean).join(',');
|
|
238
|
+
return numberedPicks(await ask(`\nYour picks${fallback ? ' [' + fallback + ']' : ''}: `, fallback), available, 'AI');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function eligibleEffort(selected, plans) {
|
|
242
|
+
return selected.filter((ai) => ai.facts.cliRun && plans[ai.id] && ['high', 'max'].includes(plans[ai.id].headroom)).map((ai) => ai.id).sort();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function validateSetup(state) {
|
|
246
|
+
if (![1, 2, 3].includes(state.level)) bad('level must be 1, 2 or 3');
|
|
247
|
+
if (!state.selected.length) bad('pick at least one AI');
|
|
248
|
+
const tooHigh = state.selected.filter((ai) => ai.minLevel > state.level);
|
|
249
|
+
if (tooHigh.length) bad(`${tooHigh.map((ai) => ai.id).join(', ')} need level ${Math.max(...tooHigh.map((ai) => ai.minLevel))} or higher`);
|
|
250
|
+
const candidates = agentCandidates(state.selected);
|
|
251
|
+
if (!candidates.length) bad('pick at least one agent or chat app to be the orchestrator; a local model runtime on its own cannot run the system');
|
|
252
|
+
if (!candidates.includes(state.primary)) bad('--primary must be one of: ' + candidates.map((ai) => ai.id).join(', '));
|
|
253
|
+
if (state.level < 3 && state.apis.length) bad('--apis only applies at level 3 (the gateway)');
|
|
254
|
+
const dirBad = dirProblems(state.dir);
|
|
255
|
+
if (dirBad.length) bad(dirBad.join('; '));
|
|
256
|
+
const projectBad = dirProblems(state.project);
|
|
257
|
+
if (projectBad.length) bad('--project: ' + projectBad.join('; '));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// A typo here must never cost the user the whole session (H1): every field
|
|
261
|
+
// below re-asks itself on a bad answer instead of exiting, and every earlier
|
|
262
|
+
// answer stays put. `bad()`'s hard exit still applies to flags and --yes;
|
|
263
|
+
// only a USAGE error raised while THIS function is running is caught locally.
|
|
264
|
+
async function editSetup(state, sources) {
|
|
265
|
+
while (true) {
|
|
266
|
+
console.log('\nChange what?\n 1 level (1, 2 or 3; 3 needs an always-on Linux machine)\n 2 which AIs\n 3 main agent\n 4 companion tools\n 5 where the files go (folder and project root)\n 6 subscription plans' + (state.level === 3 ? '\n 7 level 3 API keys' : '') + `\n 8 automatic activation (${state.applySnippets ? 'on; select to turn off' : 'off; select to turn on'})` + '\n 0 nothing, back to the summary');
|
|
267
|
+
const choice = await ask('\nNumber [0]: ', '0');
|
|
268
|
+
if (choice === '0') return;
|
|
269
|
+
if (choice === '1') {
|
|
270
|
+
LEVELS.forEach((l) => console.log(` ${l.id} ${l.name}: ${l.tagline}`));
|
|
271
|
+
while (true) {
|
|
272
|
+
const answer = Number(await ask(`Level [${state.level}]: `, String(state.level)));
|
|
273
|
+
if ([1, 2, 3].includes(answer)) { state.level = answer; break; }
|
|
274
|
+
console.log('Pick 1, 2 or 3.');
|
|
275
|
+
}
|
|
276
|
+
state.levelChosen = true;
|
|
277
|
+
sources.delete('level');
|
|
278
|
+
} else if (choice === '2') {
|
|
279
|
+
while (true) {
|
|
280
|
+
try { state.selected = await askAis(AIS, state.detected, state.selected); break; }
|
|
281
|
+
catch (e) { if (!e || e.code !== 'USAGE') throw e; console.log(e.message); }
|
|
282
|
+
}
|
|
283
|
+
sources.delete('ais');
|
|
284
|
+
if (!state.levelChosen) state.level = inferLevel(state.selected);
|
|
285
|
+
if (!state.primaryChosen || !agentCandidates(state.selected).includes(state.primary)) {
|
|
286
|
+
state.primary = inferPrimary(agentCandidates(state.selected)) || null;
|
|
287
|
+
state.primaryChosen = false;
|
|
288
|
+
sources.delete('primary');
|
|
289
|
+
}
|
|
290
|
+
state.plans = Object.fromEntries(Object.entries(state.plans).filter(([id]) => state.selected.some((ai) => ai.id === id)));
|
|
291
|
+
state.effortAuto = state.effortAuto.filter((id) => state.selected.some((ai) => ai.id === id && ai.facts.cliRun));
|
|
292
|
+
} else if (choice === '3') {
|
|
293
|
+
const candidates = agentCandidates(state.selected);
|
|
294
|
+
candidates.forEach((ai, i) => console.log(` ${i + 1} ${ai.name}`));
|
|
295
|
+
const fallback = String(candidates.indexOf(state.primary) + 1);
|
|
296
|
+
while (true) {
|
|
297
|
+
const answer = Number(await ask(`Main agent [${fallback}]: `, fallback));
|
|
298
|
+
if (Number.isInteger(answer) && answer >= 1 && answer <= candidates.length) { state.primary = candidates[answer - 1]; break; }
|
|
299
|
+
console.log(`Pick 1-${candidates.length}.`);
|
|
300
|
+
}
|
|
301
|
+
state.primaryChosen = true;
|
|
302
|
+
sources.delete('primary');
|
|
303
|
+
} else if (choice === '4') {
|
|
304
|
+
console.log('Companion tools (optional): selecting one writes guides and config snippets, and registers supported project config when activation is on. Install prerequisites yourself.');
|
|
305
|
+
TOOLS.forEach((tool, i) => console.log(` ${i + 1} ${tool.name}: ${tool.role}\n ${tool.requires}\n ${tool.optionalNote}`));
|
|
306
|
+
const fallback = TOOLS.map((tool, i) => state.tools.includes(tool) ? i + 1 : null).filter(Boolean).join(',') || 'none';
|
|
307
|
+
while (true) {
|
|
308
|
+
try { state.tools = numberedPicks(await ask(`Companions [${fallback}]: `, fallback), TOOLS, 'tool'); break; }
|
|
309
|
+
catch (e) { if (!e || e.code !== 'USAGE') throw e; console.log(e.message); }
|
|
310
|
+
}
|
|
311
|
+
sources.delete('tools');
|
|
312
|
+
sources.delete('no-tools');
|
|
313
|
+
} else if (choice === '5') {
|
|
314
|
+
while (true) {
|
|
315
|
+
const dir = resolve(await ask(`Docs folder [${state.dir}]: `, state.dir));
|
|
316
|
+
const problems = dirProblems(dir);
|
|
317
|
+
if (!problems.length) { state.dir = dir; break; }
|
|
318
|
+
console.log(problems.join('; '));
|
|
319
|
+
}
|
|
320
|
+
while (true) {
|
|
321
|
+
const project = resolve(await ask(`Project root [${state.project}]: `, state.project));
|
|
322
|
+
const problems = dirProblems(project);
|
|
323
|
+
if (!problems.length) { state.project = project; break; }
|
|
324
|
+
console.log(problems.join('; '));
|
|
325
|
+
}
|
|
326
|
+
sources.delete('dir');
|
|
327
|
+
sources.delete('project');
|
|
328
|
+
} else if (choice === '6') {
|
|
329
|
+
const plans = {};
|
|
330
|
+
for (const ai of state.selected.filter((candidate) => candidate.plans)) {
|
|
331
|
+
const first = ai.plans[0];
|
|
332
|
+
console.log(`\nWhich ${ai.vendor} plan? (checked ${first.checked}, source ${first.source})`);
|
|
333
|
+
ai.plans.forEach((plan, i) => console.log(` ${i + 1} ${plan.name} (${plan.headroom} headroom)`));
|
|
334
|
+
console.log(` ${ai.plans.length + 1} not sure`);
|
|
335
|
+
const fallback = String(state.plans[ai.id] ? ai.plans.indexOf(state.plans[ai.id]) + 1 : ai.plans.length + 1);
|
|
336
|
+
while (true) {
|
|
337
|
+
const answer = Number(await ask(`Plan [${fallback}]: `, fallback));
|
|
338
|
+
if (Number.isInteger(answer) && answer >= 1 && answer <= ai.plans.length + 1) {
|
|
339
|
+
if (answer <= ai.plans.length) plans[ai.id] = ai.plans[answer - 1];
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
console.log(`Pick 1-${ai.plans.length + 1}.`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
state.plans = plans;
|
|
346
|
+
state.plansChosen = true;
|
|
347
|
+
state.effortChosen = true;
|
|
348
|
+
const eligible = eligibleEffort(state.selected, plans);
|
|
349
|
+
state.effortAuto = eligible.length && /^y/i.test(await ask(`Size reasoning effort per task automatically on ${eligible.join(', ')}? [y/N] `, 'n')) ? eligible : [];
|
|
350
|
+
sources.delete('plans');
|
|
351
|
+
sources.delete('effort-auto');
|
|
352
|
+
} else if (choice === '7' && state.level === 3) {
|
|
353
|
+
console.log('Which metered API keys do you HOLD? A subscription is not an API key. Only variable names are written.');
|
|
354
|
+
PROVIDERS.forEach((provider, i) => console.log(` ${i + 1} ${provider.name} (${provider.envName})`));
|
|
355
|
+
const fallback = PROVIDERS.map((provider, i) => state.apis.includes(provider) ? i + 1 : null).filter(Boolean).join(',') || 'none';
|
|
356
|
+
while (true) {
|
|
357
|
+
try { state.apis = numberedPicks(await ask(`Your keys [${fallback}]: `, fallback), PROVIDERS, 'provider'); break; }
|
|
358
|
+
catch (e) { if (!e || e.code !== 'USAGE') throw e; console.log(e.message); }
|
|
359
|
+
}
|
|
360
|
+
sources.delete('apis');
|
|
361
|
+
sources.delete('no-apis');
|
|
362
|
+
} else if (choice === '8') {
|
|
363
|
+
state.applySnippets = !state.applySnippets;
|
|
364
|
+
sources.delete('apply-snippets');
|
|
365
|
+
sources.delete('no-apply');
|
|
366
|
+
} else {
|
|
367
|
+
console.log('Pick a listed edit number.');
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
// A single field can pass its own check and still leave the whole setup
|
|
371
|
+
// inconsistent (e.g. a level dropped below what a still-selected AI
|
|
372
|
+
// needs). Catch that here too: report it and go back to the menu instead
|
|
373
|
+
// of exiting, so the user can fix whichever field caused it.
|
|
374
|
+
try { validateSetup(state); return; }
|
|
375
|
+
catch (e) { if (!e || e.code !== 'USAGE') throw e; console.log(e.message); }
|
|
376
|
+
}
|
|
184
377
|
}
|
|
185
378
|
|
|
186
379
|
async function main() {
|
|
@@ -194,218 +387,157 @@ async function main() {
|
|
|
194
387
|
const actions = uninstallFiles({ dir, project, dry });
|
|
195
388
|
console.log(dry ? 'Uninstall preview (--dry): nothing changed.' : 'Uninstall complete.');
|
|
196
389
|
for (const action of actions) console.log(action);
|
|
197
|
-
console.log('\
|
|
198
|
-
console.log(`Applied rules use <!-- model-orchestrator:start --> and <!-- model-orchestrator:end -->. Backups stay beside the originals: ${join(project, 'CLAUDE.md.bak-YYYYMMDDTHHMMSS')} and ${join(project, '.claude', 'settings.json.bak-YYYYMMDDTHHMMSS')}. Review backups before restoring them; later edits may need to be kept.`);
|
|
199
|
-
console.log('For another primary agent, remove its pasted activation block from its rules file.');
|
|
390
|
+
console.log('\nRecorded activation entries are removed when unchanged. User edits and backups are kept. Remove any instructions you pasted by hand from their app or rules file.');
|
|
200
391
|
return;
|
|
201
392
|
}
|
|
202
393
|
// Says what this generates, not what it guarantees. The old line promised
|
|
203
394
|
// routing this package does not perform: lane choice is an instruction an
|
|
204
395
|
// agent follows, never something enforced here (#11).
|
|
205
|
-
console.log('\nmodel-orchestrator\
|
|
396
|
+
console.log('\nmodel-orchestrator\nModel router for AI coding agents: installs routing rules, 8 subagents, hooks and a CLI runner so your AI picks model and effort per task and saves tokens\n');
|
|
397
|
+
if (flag('no-install')) console.log('--no-install is no longer needed: the installer never runs a third-party install.');
|
|
206
398
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (!
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
for (const l of LEVELS) console.log(` ${l.id} ${l.name}: ${l.tagline}`);
|
|
213
|
-
level = Number(await ask('\nLevel [1]: ', '1'));
|
|
214
|
-
if (![1, 2, 3].includes(level)) bad('level must be 1, 2 or 3');
|
|
215
|
-
}
|
|
399
|
+
if (!yes) rl = makeAsker({ input: stdin, output: stdout });
|
|
400
|
+
if (yes && ![1, 2, 3].includes(Number(opt('level')))) bad('--level must be 1, 2 or 3 when --yes is set');
|
|
401
|
+
if (yes && !opt('ais')) bad('--ais is required with --yes (comma-separated ids, see --list)');
|
|
402
|
+
if (flag('no-apis') && opt('apis')) bad('--no-apis and --apis contradict each other');
|
|
403
|
+
if (flag('no-apply') && flag('apply-snippets')) bad('--no-apply and --apply-snippets contradict each other');
|
|
216
404
|
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
if (
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (yes) bad('--ais is required with --yes (comma-separated ids, see --list)');
|
|
224
|
-
console.log('\nWhich AIs do you have access to? (numbers, comma-separated; detected ones are marked)');
|
|
225
|
-
available.forEach((a, i) => {
|
|
226
|
-
const mark = a.bin && which(a.bin) ? '*' : ' ';
|
|
227
|
-
console.log(` ${String(i + 1).padStart(2)} ${mark} ${a.name}`);
|
|
228
|
-
});
|
|
229
|
-
const detected = available.map((a, i) => (a.bin && which(a.bin) ? i + 1 : null)).filter(Boolean);
|
|
230
|
-
const fallback = detected.join(',');
|
|
231
|
-
const answer = await ask(`\nYour picks${fallback ? ' [' + fallback + ']' : ''}: `, fallback);
|
|
232
|
-
ids = answer
|
|
233
|
-
.split(',')
|
|
234
|
-
.map((s) => s.trim())
|
|
235
|
-
.filter(Boolean)
|
|
236
|
-
.map((n) => {
|
|
237
|
-
const a = available[Number(n) - 1];
|
|
238
|
-
if (!a) bad(`no AI numbered ${n}`);
|
|
239
|
-
return a.id;
|
|
240
|
-
});
|
|
405
|
+
console.log('Looking for AI tools on your PATH...');
|
|
406
|
+
const setup = inferSetup({ selected: opt('ais') ? selectedFromIds(opt('ais')) : undefined });
|
|
407
|
+
console.log(' found: ' + (AIS.filter((ai) => setup.detected.has(ai.id)).map((ai) => `${ai.bin} (${ai.name})`).join(', ') || 'none'));
|
|
408
|
+
if (!setup.selected.length && !yes) {
|
|
409
|
+
const available = opt('level') ? aisForLevel(Number(opt('level'))) : AIS;
|
|
410
|
+
setup.selected = await askAis(available, setup.detected);
|
|
241
411
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (opt('
|
|
253
|
-
|
|
254
|
-
if (
|
|
255
|
-
|
|
256
|
-
primary = candidates[0];
|
|
257
|
-
} else if (candidates.length === 0) {
|
|
258
|
-
bad('pick at least one agent or chat app to be the orchestrator; a local model runtime on its own cannot run the system');
|
|
259
|
-
} else if (candidates.length > 1) {
|
|
260
|
-
// --yes picks for the user: claude-code if present, else the first agent that can load subagent
|
|
261
|
-
// definitions (it gets five files written for it), else the first candidate. #19: codex listed
|
|
262
|
-
// before agy used to win and nothing was written to the project root.
|
|
263
|
-
if (yes) {
|
|
264
|
-
primary = candidates.find((a) => a.id === 'claude-code') || candidates.find((a) => a.agentsDir) || candidates[0];
|
|
265
|
-
primaryAutoPicked = true;
|
|
266
|
-
}
|
|
267
|
-
else {
|
|
268
|
-
console.log('\nWhich one is your primary agent (the one that runs the system)?');
|
|
269
|
-
candidates.forEach((a, i) => console.log(` ${i + 1} ${a.name}`));
|
|
270
|
-
const n = Number(await ask('\nPrimary [1]: ', '1'));
|
|
271
|
-
primary = candidates[n - 1];
|
|
272
|
-
if (!primary) bad('pick a listed number');
|
|
273
|
-
}
|
|
412
|
+
setup.level = opt('level') ? Number(opt('level')) : inferLevel(setup.selected);
|
|
413
|
+
setup.primary = opt('primary') ? byId[opt('primary')]
|
|
414
|
+
: (yes ? legacyPrimary(agentCandidates(setup.selected)) : inferPrimary(agentCandidates(setup.selected))) || null;
|
|
415
|
+
setup.levelChosen = opt('level') !== null;
|
|
416
|
+
setup.primaryChosen = opt('primary') !== null;
|
|
417
|
+
setup.plansChosen = opt('plans') !== null;
|
|
418
|
+
setup.effortChosen = flag('effort-auto');
|
|
419
|
+
setup.dir = resolve(opt('dir') || './ai-orchestrator');
|
|
420
|
+
setup.project = resolve(opt('project') || '.');
|
|
421
|
+
setup.applySnippets = flag('apply-snippets') || (!yes && !flag('no-apply'));
|
|
422
|
+
if (opt('tools')) {
|
|
423
|
+
const result = resolveTools(opt('tools').split(',').map((value) => value.trim()).filter(Boolean));
|
|
424
|
+
if (result.unknown.length) bad('unknown tool id(s): ' + result.unknown.join(', ') + ' (see --list)');
|
|
425
|
+
setup.tools = result.tools;
|
|
274
426
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
if (opt('tools')) {
|
|
281
|
-
const r = resolveTools(opt('tools').split(',').map((s) => s.trim()).filter(Boolean));
|
|
282
|
-
if (r.unknown.length) bad('unknown tool id(s): ' + r.unknown.join(', ') + ' (see --list)');
|
|
283
|
-
tools = r.tools;
|
|
284
|
-
} else if (yes) {
|
|
285
|
-
tools = TOOLS.filter((t) => t.recommended);
|
|
286
|
-
} else {
|
|
287
|
-
console.log('\nCompanion tools (all optional): things your agents call. Selecting one writes docs and config snippets; it installs nothing.');
|
|
288
|
-
for (const t of TOOLS) {
|
|
289
|
-
console.log(`\n ${t.id}: ${t.role}\n ${t.repo}\n needs: ${t.requires}\n ${t.optionalNote}`);
|
|
290
|
-
const def = t.recommended ? 'y' : 'n';
|
|
291
|
-
const a = await ask(` Set up ${t.id}? [${t.recommended ? 'Y/n' : 'y/N'}]: `, def);
|
|
292
|
-
if (/^y/i.test(a)) tools.push(t);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
427
|
+
if (opt('apis')) {
|
|
428
|
+
const result = resolveApis(opt('apis').split(',').map((value) => value.trim()).filter(Boolean));
|
|
429
|
+
if (result.unknown.length) bad('unknown provider id(s): ' + result.unknown.join(', ') + ' (see --list)');
|
|
430
|
+
setup.apis = result.apis;
|
|
431
|
+
if (setup.level < 3) bad('--apis only applies at level 3 (the gateway)');
|
|
295
432
|
}
|
|
433
|
+
validateSetup(setup);
|
|
296
434
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
let
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
435
|
+
let prev = readManifest(setup.dir);
|
|
436
|
+
setup.plans = opt('plans') !== null ? plansFromIds(opt('plans'), setup.selected) : plansFromManifest(prev, setup.selected);
|
|
437
|
+
let plansKept = opt('plans') === null && Object.keys(setup.plans).length > 0;
|
|
438
|
+
setup.effortAuto = flag('effort-auto') ? eligibleEffort(setup.selected, setup.plans)
|
|
439
|
+
: Array.isArray(prev?.effortAuto) ? prev.effortAuto.filter((id) => setup.selected.some((ai) => ai.id === id && ai.facts.cliRun)) : [];
|
|
440
|
+
const sources = new Set(Object.keys(parsed.out));
|
|
441
|
+
let files;
|
|
442
|
+
let plannedManifest;
|
|
443
|
+
let registrations = [];
|
|
444
|
+
while (true) {
|
|
445
|
+
validateSetup(setup);
|
|
446
|
+
const { level, selected, primary, dir, project, tools, apis, plans, effortAuto, detected, applySnippets } = setup;
|
|
447
|
+
if (applySnippets) assertSnippetPrimary(primary);
|
|
448
|
+
files = planFiles({ level, selected, primary, dir, project, tools, apis, plans, effortAuto, detected, applySnippets });
|
|
449
|
+
plannedManifest = JSON.parse(files.find((file) => file.rel === 'MANIFEST.json').content);
|
|
450
|
+
if (applySnippets) files.push(...planSnippetApplication({ primary, project, files }));
|
|
451
|
+
registrations = applySnippets ? planCompanionApplication({ primary, tools, project, files }) : [];
|
|
452
|
+
files.push(...registrations);
|
|
453
|
+
const lvl = LEVELS.find((entry) => entry.id === level);
|
|
454
|
+
const candidates = agentCandidates(selected);
|
|
455
|
+
const agentFiles = files.filter((file) => file.root === 'project');
|
|
456
|
+
const projectKinds = [
|
|
457
|
+
[agentFiles.filter((file) => primary?.facts.agentDefinitions && toPosixRel(file.rel).startsWith(primary.facts.agentDefinitions + '/')).length, 'subagents'],
|
|
458
|
+
[agentFiles.filter((file) => toPosixRel(file.rel).startsWith('.claude/hooks/')).length, 'hooks']
|
|
459
|
+
].filter(([count]) => count).map(([count, kind]) => `${count} ${kind}`).join(' + ');
|
|
460
|
+
const from = (key) => sources.has(key) ? ` (from --${key})` : '';
|
|
461
|
+
const autoPrimary = !setup.primaryChosen && candidates.length > 1
|
|
462
|
+
? ` (chosen for you from ${candidates.map((ai) => ai.id).join(', ')}; pass --primary to decide it yourself)` : '';
|
|
463
|
+
const detectedAccess = !sources.has('ais') && selected.every((ai) => detected.has(ai.id)) ? ' (detected on your PATH)' : '';
|
|
464
|
+
const statedPlans = Object.entries(plans).map(([id, plan]) => `${id}=${plan.id}`).join(', ');
|
|
465
|
+
console.log(`\nPlan\n level ${lvl.id} ${lvl.name}: ${lvl.tagline}${from('level')}\n access ${selected.map((ai) => ai.id).join(', ')}${from('ais')}${detectedAccess}\n primary ${primary.id}${from('primary')}${autoPrimary}\n tools ${tools.map((tool) => tool.id).join(', ') || 'none (companions are opt-in)'}${from('tools')}${from('no-tools')}\n plans ${statedPlans || 'not stated (your tools select the model)'}${from('plans')}`
|
|
466
|
+
+ (level >= 3 ? `\n api keys ${apis.map((provider) => provider.id).join(', ') || 'none'}${from('apis')}${from('no-apis')}` : '')
|
|
467
|
+
+ (effortAuto.length || sources.has('effort-auto') ? `\n auto effort ${effortAuto.join(', ') || 'none eligible'}${from('effort-auto')}` : '')
|
|
468
|
+
+ `\n folder ${dir}${from('dir')}\n project ${project}${projectKinds ? ' (' + projectKinds + ' go here)' : ''}${from('project')}\n files ${files.length}`);
|
|
469
|
+
if (plansKept) console.log(' plans kept from the previous run');
|
|
470
|
+
// Q4: automatic activation only ever means a rules file gets written for
|
|
471
|
+
// the primary; a primary with none (a chat app, or a CLI the catalog has
|
|
472
|
+
// no rules file for, such as Grok or Hermes) always keeps a manual step,
|
|
473
|
+
// whatever --apply-snippets or the edit screen say.
|
|
474
|
+
console.log(` activation ${applySnippets && assertSnippetPrimary(primary) ? 'automatic' : 'manual'}${from('apply-snippets')}${from('no-apply')}`);
|
|
475
|
+
for (const file of agentFiles) {
|
|
476
|
+
const path = join(project, file.rel);
|
|
477
|
+
// Q8: a rules file that does not exist yet is created, not updated, and
|
|
478
|
+
// there is nothing to back up. file.original is null exactly when this
|
|
479
|
+
// run is the first to write it.
|
|
480
|
+
const rulesCreated = file.applySnippet && file.rel === primary.rulesFile && file.original === null;
|
|
481
|
+
const action = rulesCreated ? 'create'
|
|
482
|
+
: file.applySnippet ? file.rel === primary.rulesFile ? 'update' : file.rel === join('.claude', 'settings.json') ? 'merge hooks into' : 'merge config into'
|
|
483
|
+
: 'write';
|
|
484
|
+
const note = rulesCreated ? 'new file, marked block'
|
|
485
|
+
: `${file.applySnippet && file.rel === primary.rulesFile ? 'marked block, ' : ''}backup kept if changed`;
|
|
486
|
+
console.log(` ${action} ${path} (${note})`);
|
|
320
487
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if (opt('plans') !== null) {
|
|
339
|
-
plans = plansFromIds(opt('plans'), selected);
|
|
340
|
-
} else if (prev) {
|
|
341
|
-
plans = plansFromManifest(prev, selected);
|
|
342
|
-
plansKept = Object.keys(plans).length > 0;
|
|
343
|
-
} else if (!yes) {
|
|
344
|
-
plans = {};
|
|
345
|
-
for (const ai of selected.filter((a) => a.plans)) {
|
|
346
|
-
const first = ai.plans[0];
|
|
347
|
-
console.log(`\nWhich ${ai.vendor} plan? (checked ${first.checked}, source ${first.source})`);
|
|
348
|
-
ai.plans.forEach((p, i) => console.log(` ${i + 1} ${p.name} (${p.headroom} headroom)`));
|
|
349
|
-
console.log(` ${ai.plans.length + 1} not sure`);
|
|
350
|
-
const answer = Number(await ask(`Plan [${ai.plans.length + 1}]: `, String(ai.plans.length + 1)));
|
|
351
|
-
if (!Number.isInteger(answer) || answer < 1 || answer > ai.plans.length + 1) bad('pick a listed plan number');
|
|
352
|
-
if (answer <= ai.plans.length) plans[ai.id] = ai.plans[answer - 1];
|
|
488
|
+
if (primary.facts.agentDefinitions) console.log(` subagent location: ${join(project, primary.facts.agentDefinitions)}; launch ${primary.bin} from ${project}`);
|
|
489
|
+
if (agentFiles.length && !sources.has('project')) console.log(`\nNote: --project defaults to the current directory, so the ${projectKinds} go to the current directory (${project}). Pass --project to put them somewhere else.`);
|
|
490
|
+
const assignment = assignRoles({ selected, primary, detected, plans });
|
|
491
|
+
const agents = Object.fromEntries(Object.entries(plannedManifest.roles || {}).filter(([, role]) => role.agent).map(([id, role]) => [id, role.agent]));
|
|
492
|
+
console.log('\n' + roleTable(assignment, { selected, primary, detected, agents }));
|
|
493
|
+
if (level >= 2 && !selected.some((ai) => ai.facts.cliRun)) console.log('\nWarning: no executable lanes selected; delegation is inactive. Use level 1 for a single-agent setup, or add a supported CLI. Doctor will exit 13 until a lane is enabled.');
|
|
494
|
+
if (flag('dry') || flag('dry-run')) {
|
|
495
|
+
for (const file of files) console.log(' - ' + (file.root === 'project' ? '[project] ' : '') + file.rel);
|
|
496
|
+
if (applySnippets) {
|
|
497
|
+
const preview = writeFiles(files, { dir, project, dry: true, force: flag('force'), upgradeRuntime: flag('upgrade-runtime'), updateDocs: flag('update-docs'), prevManifest: prev, backupExisting: true });
|
|
498
|
+
for (const path of preview.written) console.log(' would write ' + path);
|
|
499
|
+
for (const path of [...preview.skipped, ...preview.conflicts, ...preview.unverifiable, ...preview.docsConflict, ...preview.docsUnverifiable]) console.log(' would keep ' + path);
|
|
500
|
+
for (const path of preview.backups) console.log(' would back up ' + path);
|
|
501
|
+
}
|
|
502
|
+
console.log('\n--dry: nothing written.');
|
|
503
|
+
rl && rl.close();
|
|
504
|
+
return;
|
|
353
505
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
// 5. Plan
|
|
372
|
-
const applySnippets = flag('apply-snippets');
|
|
373
|
-
if (applySnippets) assertSnippetPrimary(primary);
|
|
374
|
-
const files = planFiles({ level, selected, primary, dir, project, tools, apis, plans, effortAuto, applySnippets });
|
|
375
|
-
if (applySnippets) files.push(...planSnippetApplication({ primary, project, files }));
|
|
376
|
-
const lvl = LEVELS.find((l) => l.id === level);
|
|
377
|
-
const agentFiles = files.filter((f) => f.root === 'project');
|
|
378
|
-
const projectKinds = [
|
|
379
|
-
[agentFiles.filter((f) => primary?.agentsDir && toPosixRel(f.rel).startsWith(primary.agentsDir + '/')).length, 'subagents'],
|
|
380
|
-
[agentFiles.filter((f) => toPosixRel(f.rel).startsWith('.claude/hooks/')).length, 'hooks']
|
|
381
|
-
].filter(([count]) => count).map(([count, kind]) => `${count} ${kind}`).join(' + ');
|
|
382
|
-
const statedPlans = Object.entries(plans).map(([id, p]) => `${id}=${p.id}`).join(', ');
|
|
383
|
-
console.log(`\nPlan\n level ${lvl.id} ${lvl.name}\n access ${selected.map((a) => a.id).join(', ')}\n primary ${primary ? primary.id : 'none'}${primaryAutoPicked ? ` (chosen for you from ${candidates.map((a) => a.id).join(', ')}; pass --primary to decide it yourself)` : ''}\n tools ${tools.map((t) => t.id).join(', ') || 'none'}\n plans ${statedPlans || 'none stated'}` + (level >= 3 ? `\n api keys ${apis.map((p) => p.id).join(', ') || 'none'}` : '') + `\n folder ${dir}\n project ${project}${projectKinds ? ' (' + projectKinds + ' go here)' : ''}\n files ${files.length}`);
|
|
384
|
-
if (plansKept) console.log(' plans kept from the previous run');
|
|
385
|
-
if (agentFiles.length && !opt('project')) {
|
|
386
|
-
console.log(`\nNote: --project defaults to the current directory, so the ${projectKinds} go to the current directory (${project}). Pass --project to put them somewhere else.`);
|
|
387
|
-
}
|
|
388
|
-
if (level >= 2 && !selected.some((a) => a.cliRun)) {
|
|
389
|
-
console.log('\nWarning: no executable lanes selected; delegation is inactive. Use level 1 for a single-agent setup, or add a supported CLI. Doctor will exit 13 until a lane is enabled.');
|
|
390
|
-
}
|
|
391
|
-
if (flag('dry') || flag('dry-run')) {
|
|
392
|
-
for (const f of files) console.log(' - ' + (f.root === 'project' ? '[project] ' : '') + f.rel);
|
|
393
|
-
if (applySnippets) {
|
|
394
|
-
const preview = writeFiles(files, { dir, project, dry: true, force: flag('force'), upgradeRuntime: flag('upgrade-runtime'), updateDocs: flag('update-docs'), prevManifest: prev, backupExisting: true });
|
|
395
|
-
for (const path of preview.written) console.log(' would write ' + path);
|
|
396
|
-
for (const path of [...preview.skipped, ...preview.conflicts, ...preview.unverifiable, ...preview.docsConflict, ...preview.docsUnverifiable]) console.log(' would keep ' + path);
|
|
397
|
-
for (const path of preview.backups) console.log(' would back up ' + path);
|
|
506
|
+
if (yes) break;
|
|
507
|
+
const go = (await ask('\nWrite these files?\n[Y/n/e] (e to change anything above): ', 'y')).toLowerCase();
|
|
508
|
+
if (go === 'y' || go === 'yes') break;
|
|
509
|
+
if (go === 'e') {
|
|
510
|
+
const oldDir = setup.dir;
|
|
511
|
+
const oldPlans = setup.plans;
|
|
512
|
+
await editSetup(setup, sources);
|
|
513
|
+
if (setup.plans !== oldPlans) plansKept = false;
|
|
514
|
+
if (oldDir !== setup.dir) {
|
|
515
|
+
prev = readManifest(setup.dir);
|
|
516
|
+
if (!setup.plansChosen) {
|
|
517
|
+
setup.plans = plansFromManifest(prev, setup.selected);
|
|
518
|
+
plansKept = Object.keys(setup.plans).length > 0;
|
|
519
|
+
}
|
|
520
|
+
if (!setup.effortChosen) setup.effortAuto = Array.isArray(prev?.effortAuto) ? prev.effortAuto.filter((id) => setup.selected.some((ai) => ai.id === id && ai.facts.cliRun)) : [];
|
|
521
|
+
}
|
|
522
|
+
continue;
|
|
398
523
|
}
|
|
399
|
-
|
|
400
|
-
rl && rl.close();
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
const go = yes ? 'y' : await ask('\nWrite these files? [Y/n]: ', 'y');
|
|
404
|
-
if (!/^y/i.test(go)) {
|
|
524
|
+
if (go !== 'n' && go !== 'no') bad('answer y, n or e');
|
|
405
525
|
console.log('Nothing written.');
|
|
406
526
|
rl && rl.close();
|
|
407
527
|
return;
|
|
408
528
|
}
|
|
529
|
+
const { level, selected, primary, dir, project, tools, apis, plans, effortAuto, applySnippets } = setup;
|
|
530
|
+
// Q1 safety: a doomed write (global agent config, path escape) must never
|
|
531
|
+
// first run a real vendor status command such as `claude auth status`.
|
|
532
|
+
// Check the same path safety writeFiles() will check, before any subprocess.
|
|
533
|
+
const earlyProblems = writePreflightProblems(files, { dir, project });
|
|
534
|
+
if (earlyProblems.length) bad('refusing to write:\n ' + earlyProblems.join('\n '));
|
|
535
|
+
const authStatuses = signInStatus(selected);
|
|
536
|
+
// Final remaining steps include the observed sign-in and MCP merge results.
|
|
537
|
+
// Render before writing so README ownership and preservation stay unchanged.
|
|
538
|
+
const applications = files.filter(file => file.applySnippet);
|
|
539
|
+
files = [...planFiles({ ...setup, authStatuses, registrations }), ...applications];
|
|
540
|
+
plannedManifest = JSON.parse(files.find(file => file.rel === 'MANIFEST.json').content);
|
|
409
541
|
|
|
410
542
|
// Reconfiguration: compare what a previous run recorded with what was asked now.
|
|
411
543
|
const changed = prev
|
|
@@ -413,11 +545,12 @@ async function main() {
|
|
|
413
545
|
.concat(['ais', 'tools', 'apis'].filter((k) => JSON.stringify(prev[k] || []) !== JSON.stringify({ ais: selected, tools, apis }[k].map((x) => x.id))))
|
|
414
546
|
.concat(JSON.stringify(prev.plans || {}) !== JSON.stringify(Object.fromEntries(Object.entries(plans).map(([id, p]) => [id, p.id]))) ? ['plans'] : [])
|
|
415
547
|
.concat(JSON.stringify((prev.effortAuto || []).slice().sort()) !== JSON.stringify(effortAuto.slice().sort()) ? ['effortAuto'] : [])
|
|
548
|
+
.concat(JSON.stringify(prev.roles || {}) !== JSON.stringify(plannedManifest.roles || {}) ? ['roles'] : [])
|
|
416
549
|
: [];
|
|
417
550
|
|
|
418
|
-
let written, skipped, upgraded, conflicts, unverifiable, docsUpdated, docsConflict, docsUnverifiable;
|
|
551
|
+
let written, skipped, upgraded, conflicts, unverifiable, docsUpdated, docsConflict, docsUnverifiable, docsRenamed;
|
|
419
552
|
try {
|
|
420
|
-
({ written, skipped, upgraded, conflicts, unverifiable, docsUpdated, docsConflict, docsUnverifiable } = writeFiles(files, { dir, project, force: flag('force'), upgradeRuntime: flag('upgrade-runtime'), updateDocs: flag('update-docs'), prevManifest: prev, backupExisting: applySnippets, onBackup: (path) => console.log(' backup ' + path) }));
|
|
553
|
+
({ written, skipped, upgraded, conflicts, unverifiable, docsUpdated, docsConflict, docsUnverifiable, docsRenamed } = writeFiles(files, { dir, project, force: flag('force'), upgradeRuntime: flag('upgrade-runtime'), updateDocs: flag('update-docs'), prevManifest: prev, backupExisting: applySnippets, onBackup: (path) => console.log(' backup ' + path) }));
|
|
421
554
|
} catch (e) {
|
|
422
555
|
if (e && e.code === 'PREFLIGHT') bad(e.message);
|
|
423
556
|
throw e;
|
|
@@ -432,13 +565,14 @@ async function main() {
|
|
|
432
565
|
if (changed.length) {
|
|
433
566
|
console.log(` selection changed: ${changed.join(', ')}`);
|
|
434
567
|
console.log(` applied: ${ownedWritten.join(', ') || 'nothing'} (machine-owned files are always rewritten, so the new lanes are live)`);
|
|
568
|
+
if (changed.includes('roles')) console.log(' the role assignment changed; MANIFEST.json and aunx route are current. Any kept documents may still carry the previous assignment.');
|
|
435
569
|
} else console.log(' selection identical.');
|
|
436
570
|
}
|
|
437
571
|
if (upgraded.length) console.log(` runtime upgraded: ${upgraded.join(', ')} ${flag('upgrade-runtime') ? '(--upgrade-runtime: replaced whether or not you had edited them)' : '(each installed copy matched the hash of a previous run, so nobody had edited it)'}`);
|
|
438
572
|
if (conflicts.length) {
|
|
439
573
|
console.log(` runtime CONFLICT, kept: ${conflicts.join(', ')}`);
|
|
440
574
|
console.log(' these differ from what a previous run generated, so you edited them. The fixes in this release were NOT applied to them.');
|
|
441
|
-
console.log(' Options: move your copy aside and re-run; or --upgrade-runtime to replace runtime files only; or --force to replace
|
|
575
|
+
console.log(' Options: move your copy aside and re-run; or --upgrade-runtime to replace runtime files only; or --force to replace current generated files.');
|
|
442
576
|
}
|
|
443
577
|
if (unverifiable.length) {
|
|
444
578
|
console.log(` runtime kept, UNVERIFIABLE: ${unverifiable.join(', ')}`);
|
|
@@ -446,54 +580,39 @@ async function main() {
|
|
|
446
580
|
console.log(' Re-run with --upgrade-runtime to replace runtime files only (documents stay), or --force to replace everything.');
|
|
447
581
|
}
|
|
448
582
|
if (docsUpdated.length) console.log(` documents updated: ${docsUpdated.join(', ')} (each matched the hash of a previous run, so nobody had edited them)`);
|
|
583
|
+
if (docsRenamed.length) console.log(` documents renamed: ${docsRenamed.join(', ')} (the old copy matched its installed hash)`);
|
|
449
584
|
if (docsConflict.length) {
|
|
450
585
|
console.log(` document CONFLICT, kept: ${docsConflict.join(', ')}`);
|
|
451
|
-
console.log(' these differ from what a previous run generated, so you edited them. Edit them by hand
|
|
586
|
+
console.log(' these differ from what a previous run generated, so you edited them. Edit them by hand; --force replaces current generated paths. Edited legacy briefs stay.');
|
|
452
587
|
}
|
|
453
588
|
if (docsUnverifiable.length) {
|
|
454
589
|
console.log(` documents kept, UNVERIFIABLE: ${docsUnverifiable.join(', ')}`);
|
|
455
|
-
console.log(' the previous install left no manifest, so --update-docs cannot tell your edits from generated text. --force replaces
|
|
590
|
+
console.log(' the previous install left no manifest, so --update-docs cannot tell your edits from generated text. --force replaces current generated paths. Unverifiable legacy briefs stay.');
|
|
456
591
|
}
|
|
457
592
|
if (skipped.length && prev && changed.length && !flag('update-docs')) console.log(' documents kept: they may describe the old selection. --update-docs regenerates the ones you have not edited; --force regenerates all of them (this overwrites your edits).');
|
|
458
593
|
}
|
|
459
594
|
|
|
460
|
-
// 6.
|
|
595
|
+
// 6. Print manual installation commands for missing CLIs.
|
|
461
596
|
const missing = selected.filter((a) => a.bin && !which(a.bin));
|
|
462
597
|
if (missing.length) {
|
|
463
|
-
console.log('\
|
|
598
|
+
console.log('\nInstall these yourself (binary presence checked; versions need your verification):');
|
|
464
599
|
for (const a of missing) {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
const plan = windowsSpawnPlan([which('npm') || 'npm', 'install', '-g', spec], process.platform, { allowCmdFallback: true });
|
|
472
|
-
const r = spawnSync(plan.command, plan.args, { stdio: 'inherit', ...plan.options });
|
|
473
|
-
console.log(r.status === 0 ? ` installed ${spec}` : ` npm exited ${r.status}; install it by hand`);
|
|
474
|
-
} else {
|
|
475
|
-
console.log(` ${a.name}: npm install -g ${spec} (pinned to the version this installer was released with)`);
|
|
476
|
-
}
|
|
477
|
-
} else if (a.install.script) {
|
|
478
|
-
console.log(` ${a.name}: the vendor installer is a shell script. Download it, read it, then run it:\n curl -fsSL ${a.install.script} -o /tmp/${a.id}-install.sh && less /tmp/${a.id}-install.sh && bash /tmp/${a.id}-install.sh`);
|
|
479
|
-
} else {
|
|
480
|
-
console.log(` ${a.name}: ${a.install.url}` + (a.install.brew ? ` (or: brew install ${a.install.brew})` : ''));
|
|
481
|
-
}
|
|
482
|
-
console.log(` sign in: ${a.auth}`);
|
|
600
|
+
const command = a.install.npm
|
|
601
|
+
? 'npm install -g ' + npmSpec(a)
|
|
602
|
+
: a.install.script
|
|
603
|
+
? `curl -fsSL ${a.install.script} -o /tmp/${a.id}-install.sh && less /tmp/${a.id}-install.sh && bash /tmp/${a.id}-install.sh`
|
|
604
|
+
: a.install.brew ? `brew install ${a.install.brew}` : 'Follow the vendor setup guide';
|
|
605
|
+
console.log(` ${a.name}: ${command}\n official guide: ${a.install.url || a.install.script}`);
|
|
483
606
|
}
|
|
484
607
|
}
|
|
485
608
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// 7. Activation summary: writing the folder is half the job. Say exactly what
|
|
492
|
-
// turns it on, in order, with one command that proves it. The generated
|
|
493
|
-
// README renders this same array, so the two surfaces cannot disagree (#20).
|
|
494
|
-
const steps = activationSteps({ level, selected, primary, tools, dir, project, applySnippets });
|
|
495
|
-
console.log('\nTo activate, in order:');
|
|
609
|
+
// 7. Run the presence check, then print only the remaining actions. The
|
|
610
|
+
// generated README renders the same array for a fresh installation (#20).
|
|
611
|
+
await installHealthCheck({ level, selected, primary, dir });
|
|
612
|
+
const steps = activationSteps({ level, selected, primary, tools, dir, project, applySnippets, authStatuses, registrations });
|
|
613
|
+
console.log("\nWhat's left for you:");
|
|
496
614
|
steps.forEach((st, i) => console.log(` ${i + 1}. ${st}`));
|
|
615
|
+
if (!steps.length) console.log(' Nothing left to do.');
|
|
497
616
|
console.log(`\nStart here: ${join(dir, 'README.md')} (written for level ${level} and the AIs you picked).`);
|
|
498
617
|
console.log('\nIf this saved you time, a star helps people find it: https://github.com/aunysillyme/model-orchestrator\n');
|
|
499
618
|
rl && rl.close();
|
|
@@ -510,5 +629,5 @@ function isEntryPoint() {
|
|
|
510
629
|
}
|
|
511
630
|
if (isEntryPoint()) main().catch((e) => {
|
|
512
631
|
console.error('model-orchestrator: ' + (e && e.message ? e.message : e));
|
|
513
|
-
process.exit(e && ['EOF', 'UNINSTALL', 'PREFLIGHT'].includes(e.code) ? 2 : 1);
|
|
632
|
+
process.exit(e && ['EOF', 'UNINSTALL', 'PREFLIGHT', 'USAGE'].includes(e.code) ? 2 : 1);
|
|
514
633
|
});
|