nomarmy 0.1.0-alpha.0
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/LICENSE +202 -0
- package/NOTICE +25 -0
- package/README.md +484 -0
- package/bin/nomarmy.mjs +2248 -0
- package/config/agents.yml.example +63 -0
- package/config/common.env +31 -0
- package/config/profiles/bedrock-cheap.env +26 -0
- package/config/profiles/bedrock.env +28 -0
- package/config/profiles/cpu-linux.env +8 -0
- package/config/profiles/dgx-spark.env +12 -0
- package/config/profiles/macbook-pro.env +9 -0
- package/config/profiles/nvidia-linux.env +9 -0
- package/docker/Dockerfile +15 -0
- package/docker/Dockerfile.go +29 -0
- package/docker/Dockerfile.rust +19 -0
- package/e2e.sh +153 -0
- package/install.sh +125 -0
- package/lib/agents.mjs +285 -0
- package/lib/army.mjs +400 -0
- package/lib/budget.mjs +368 -0
- package/lib/claude-transcript.mjs +150 -0
- package/lib/config.mjs +193 -0
- package/lib/connect.mjs +409 -0
- package/lib/coordinator-instructions.mjs +23 -0
- package/lib/decompose.mjs +389 -0
- package/lib/dispatch-config.mjs +164 -0
- package/lib/dispatch-schema.mjs +280 -0
- package/lib/doctor.mjs +443 -0
- package/lib/evidence.mjs +679 -0
- package/lib/gguf.mjs +589 -0
- package/lib/hardware.mjs +476 -0
- package/lib/health.mjs +278 -0
- package/lib/model-catalog.mjs +71 -0
- package/lib/notifier-app.mjs +95 -0
- package/lib/notify.mjs +66 -0
- package/lib/openclaw-config.mjs +65 -0
- package/lib/openclaw-errors.mjs +40 -0
- package/lib/propose.mjs +110 -0
- package/lib/prune.mjs +77 -0
- package/lib/repo-query.mjs +267 -0
- package/lib/runs.mjs +150 -0
- package/lib/sabotage.mjs +128 -0
- package/lib/sandbox-images.mjs +434 -0
- package/lib/scan.mjs +1538 -0
- package/lib/schema.mjs +288 -0
- package/lib/scout.mjs +544 -0
- package/lib/sizing.mjs +1322 -0
- package/lib/slots.mjs +112 -0
- package/lib/statusline.mjs +126 -0
- package/lib/subscription-config.mjs +68 -0
- package/lib/subscription-setup.mjs +217 -0
- package/lib/transcript.mjs +195 -0
- package/lib/verify.mjs +700 -0
- package/mcp/server.mjs +4206 -0
- package/notifier/icon.swift +34 -0
- package/notifier/main.swift +52 -0
- package/notifier/nomarmy-icon.png +0 -0
- package/package.json +67 -0
- package/playbooks/feature.md +43 -0
- package/policies/coder.md +49 -0
- package/policies/orchestrator.md +35 -0
- package/policies/reviewer.md +35 -0
- package/policies/scout.md +65 -0
- package/scripts/configure-openclaw.sh +96 -0
- package/scripts/configure-orchestrator.sh +84 -0
- package/scripts/install-llama-cpp.sh +16 -0
- package/scripts/lib.sh +198 -0
- package/scripts/select-model.mjs +96 -0
- package/scripts/select-model.sh +4 -0
- package/scripts/setup-sandbox.sh +38 -0
- package/scripts/start-inference.sh +46 -0
- package/scripts/stop-inference.sh +5 -0
- package/scripts/uninstall.sh +6 -0
- package/scripts/verify-install.sh +68 -0
package/bin/nomarmy.mjs
ADDED
|
@@ -0,0 +1,2248 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// nomArmy CLI. Every command proposes before it writes anything -- init,
|
|
3
|
+
// setup, model and update all show exactly what would change and write only
|
|
4
|
+
// after explicit confirmation ([y/N]) or an explicit non-interactive flag
|
|
5
|
+
// (--write, --json with the required choices given up front). Nothing here
|
|
6
|
+
// provisions SYSTEM-level infrastructure on its own: install.sh (builds
|
|
7
|
+
// llama.cpp, installs OpenClaw, configures the sandbox) stays a separate,
|
|
8
|
+
// manual step in every case, printed but never run.
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { spawn, spawnSync, execFileSync } from "node:child_process";
|
|
13
|
+
import { createInterface } from "node:readline/promises";
|
|
14
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
15
|
+
import { loadConfig, validateConfig, stringifyConfig, findConfigFile, parseYaml, CONFIG_FILENAMES } from "../lib/config.mjs";
|
|
16
|
+
import { scanRepository, compareEvidence } from "../lib/scan.mjs";
|
|
17
|
+
import { buildConfigProposal } from "../lib/propose.mjs";
|
|
18
|
+
import { detectHardware } from "../lib/hardware.mjs";
|
|
19
|
+
import { readGGUFMetadata, resolveModelPath, totalSplitBytes } from "../lib/gguf.mjs";
|
|
20
|
+
import { recommend, customRecommendation, evaluateConfig, bytesPerKvElementForCacheTypes, MIN_CONTEXT_PER_NOM } from "../lib/sizing.mjs";
|
|
21
|
+
import { connectClaude, connectCodex, connectCursor, cursorAlreadyConnected } from "../lib/connect.mjs";
|
|
22
|
+
import { ID_RE, AUTH_ENV_NAME_RE, OPENCLAW_PROVIDER_ID_RE, openclawProviderId, isNativeProviderType } from "../lib/dispatch-schema.mjs";
|
|
23
|
+
import { loadAgents, readAgentsFile, writeAgentsFile, agentsConfigPath, apiAgentAsPoolEntry, describeAgent as describeAgentLabel, AGENT_KINDS, API_PROVIDER_TYPES, RESERVED_AGENT_NAMES, BUILTIN_LOCAL_AGENT } from "../lib/agents.mjs";
|
|
24
|
+
import { loadArmy, describeArmy, readArmyFile, updateArmyInFile, assignRoleInFile, parseTargetSpec, armyLayerPath, globalConfigDir, DEFAULT_ARMY, ARMY_PHASES, LOCAL_CONFIG_FILENAME } from "../lib/army.mjs";
|
|
25
|
+
import { ensureProviderConfig } from "../lib/openclaw-config.mjs";
|
|
26
|
+
import { recordProbeSuccess } from "../lib/health.mjs";
|
|
27
|
+
import { pruneJobRuntime } from "../lib/prune.mjs";
|
|
28
|
+
import { SUBSCRIPTION_VENDORS, parseOpenclawVersion, versionAtLeast, parseCatalogModels, parseCliLoginStatus, probeOutcome, parseMuseAuthDescriptor, extractMintedKey } from "../lib/subscription-setup.mjs";
|
|
29
|
+
|
|
30
|
+
// Add a new coordinator: add its name here, teach commandExists/connectTarget
|
|
31
|
+
// about it below (a JSON-file target like Cursor has no PATH binary to check
|
|
32
|
+
// and should short-circuit commandExists to true), and give cmdUpdate its own
|
|
33
|
+
// "already connected, so resync" detection if it has no CLI to probe.
|
|
34
|
+
const KNOWN_TARGETS = ["claude", "codex", "cursor"];
|
|
35
|
+
|
|
36
|
+
function connectTarget(target, { nomarmyRoot, run }) {
|
|
37
|
+
if (target === "claude") return connectClaude({ nomarmyRoot, run });
|
|
38
|
+
if (target === "codex") return connectCodex({ nomarmyRoot, run });
|
|
39
|
+
if (target === "cursor") return connectCursor({ nomarmyRoot, run });
|
|
40
|
+
throw new Error(`unknown connect target: ${target}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const argv = process.argv.slice(2);
|
|
44
|
+
const command = argv[0];
|
|
45
|
+
const flag = (name) => argv.includes(`--${name}`);
|
|
46
|
+
const value = (name, fallback = null) => {
|
|
47
|
+
const i = argv.indexOf(`--${name}`);
|
|
48
|
+
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : fallback;
|
|
49
|
+
};
|
|
50
|
+
const repoDir = path.resolve(value("repo", process.cwd()));
|
|
51
|
+
// --json shape for `agents add`/`agents update`'s thinking field:
|
|
52
|
+
// `--thinking low|medium|high` sets that entry's own fixed reasoning floor
|
|
53
|
+
// (see thinkingSchema's doc comment); a bare `--thinking` (no value, or a
|
|
54
|
+
// value that isn't a real level) keeps the original boolean meaning (pass
|
|
55
|
+
// through the job's requested reasoning); `--no-thinking` is always false.
|
|
56
|
+
// Returns undefined when none of these flags were passed at all, so the
|
|
57
|
+
// caller can tell "not touched" apart from "explicitly set".
|
|
58
|
+
const THINKING_LEVELS = ["low", "medium", "high"];
|
|
59
|
+
function resolveThinkingFlag() {
|
|
60
|
+
const level = value("thinking");
|
|
61
|
+
if (level && THINKING_LEVELS.includes(level)) return level;
|
|
62
|
+
if (flag("no-thinking")) return false;
|
|
63
|
+
if (flag("thinking")) return true;
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
const json = flag("json");
|
|
67
|
+
const out = (obj) => console.log(JSON.stringify(obj, null, 2));
|
|
68
|
+
const gib = (n) => (typeof n === "number" ? `${(n / 1024 ** 3).toFixed(1)} GiB` : "unknown");
|
|
69
|
+
const K = (n) => (typeof n === "number" ? `${Math.round(n / 1024)}K` : "?");
|
|
70
|
+
|
|
71
|
+
// A plain wall of text doesn't match this project's own tone (a mascot, a
|
|
72
|
+
// tagline). Used only by the newer interactive commands (init/setup/model);
|
|
73
|
+
// the rest of the CLI's output is untouched, on purpose, to keep this change
|
|
74
|
+
// scoped. Off under --json and when stdout isn't a real terminal (piped,
|
|
75
|
+
// redirected) so color codes never leak into something meant to be parsed.
|
|
76
|
+
const useColor = process.stdout.isTTY && !json;
|
|
77
|
+
const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
78
|
+
const c = { bold: paint(1), dim: paint(2), red: paint(31), green: paint(32), yellow: paint(33), cyan: paint(36) };
|
|
79
|
+
|
|
80
|
+
function usage(code = 0) {
|
|
81
|
+
console.log(`nomArmy - bounded coding workers with independently verified results
|
|
82
|
+
|
|
83
|
+
Usage: nomarmy <command> [options]
|
|
84
|
+
|
|
85
|
+
scan Inspect this repository and report its execution environment.
|
|
86
|
+
--check compare the evidence against a committed .nomarmy.yml
|
|
87
|
+
init Propose a .nomarmy.yml from this repository's scan evidence
|
|
88
|
+
and write it after confirmation.
|
|
89
|
+
--force overwrite an existing .nomarmy.yml
|
|
90
|
+
--write with --json, write without prompting (needs a valid proposal)
|
|
91
|
+
setup Detect this machine, recommend a profile (offering "more
|
|
92
|
+
noms" vs "nominal" when they differ), choose a model, and
|
|
93
|
+
write config/profiles/<name>.env (+ config/common.env).
|
|
94
|
+
Prints the install.sh command; never runs it.
|
|
95
|
+
--tier <more|nominal> with --json, skip the prompt
|
|
96
|
+
model Change the configured model later, without the rest of
|
|
97
|
+
setup's questions. Offers to also resync the MCP
|
|
98
|
+
registration's worker-routing env vars, and to restart
|
|
99
|
+
local inference so the running llama-server actually
|
|
100
|
+
loads the new model (writing the config alone leaves
|
|
101
|
+
the running process serving whatever it loaded at its
|
|
102
|
+
own last start).
|
|
103
|
+
--update-mcp with --json, also resync the MCP
|
|
104
|
+
registration (never done silently)
|
|
105
|
+
--restart-inference with --json, also stop/start local
|
|
106
|
+
inference (never done silently)
|
|
107
|
+
update Pull the latest nomArmy code and re-sync the installed
|
|
108
|
+
MCP copy (fast-forward only; refuses on local changes).
|
|
109
|
+
agents <list|add|update|remove>
|
|
110
|
+
Every account a job can run on, in one list:
|
|
111
|
+
~/.config/nomarmy/agents.yml (or NOMARMY_CONFIG_DIR).
|
|
112
|
+
local the local model (built in as \`local\`)
|
|
113
|
+
api a metered API key: xai, openai,
|
|
114
|
+
anthropic, deepinfra, bedrock, azure,
|
|
115
|
+
any OpenClaw provider by id ("openclaw",
|
|
116
|
+
e.g. DeepSeek), or an OpenAI-compatible URL
|
|
117
|
+
subscription ONE person's own Claude, ChatGPT or Muse
|
|
118
|
+
Code plan; never pooled, and every job
|
|
119
|
+
must name its owner in on_behalf_of
|
|
120
|
+
Changes apply to the next job, no restart.
|
|
121
|
+
list show your agents
|
|
122
|
+
An api or subscription agent is an account; its
|
|
123
|
+
model is only an optional default, since each role
|
|
124
|
+
(or the General, per job) picks the model.
|
|
125
|
+
add [local|api|subscription] [claude|codex|meta]
|
|
126
|
+
walks through what that kind needs: an API
|
|
127
|
+
key registered with OpenClaw (over stdin,
|
|
128
|
+
never saved to a file), or the vendor's own
|
|
129
|
+
CLI install and login, OpenClaw's plugin and
|
|
130
|
+
login, and for Meta the key Muse Code's login
|
|
131
|
+
left in the macOS keychain. Then a real test
|
|
132
|
+
call before saving. Interactive, or --json
|
|
133
|
+
--name <n> --kind <kind> plus that kind's
|
|
134
|
+
fields: --slot coder|gpt (local); --provider
|
|
135
|
+
--model --auth-env [--base-url]
|
|
136
|
+
[--openclaw-provider --plugin] [--register]
|
|
137
|
+
[--update-mcp] (api); --provider --model
|
|
138
|
+
--owner (subscription); and optionally
|
|
139
|
+
--max-concurrent --context-window
|
|
140
|
+
--thinking [low|medium|high] --no-thinking
|
|
141
|
+
update <name>
|
|
142
|
+
change the model (picked from what OpenClaw
|
|
143
|
+
lists; a subscription gets a real test call),
|
|
144
|
+
slot, auth_env, base_url, max_concurrent,
|
|
145
|
+
context_window or thinking. Kind, provider and
|
|
146
|
+
owner stay fixed: that's a different agent.
|
|
147
|
+
--no-model clears the default model, so every
|
|
148
|
+
role or job names its own.
|
|
149
|
+
(--json with the matching flags; --probe to
|
|
150
|
+
test-call a subscription's new model first)
|
|
151
|
+
remove <name>
|
|
152
|
+
remove one agent
|
|
153
|
+
army <show|init|assign|general>
|
|
154
|
+
Who does what. The General is your coordinator session:
|
|
155
|
+
its charter is fixed by nomArmy, and you define which
|
|
156
|
+
agent it is. Each other role has a description, a phase
|
|
157
|
+
(build, review, acceptance) and one agent. Layers merge
|
|
158
|
+
like Claude Code's settings, later wins: global
|
|
159
|
+
~/.config/nomarmy/config.yml, the repo's .nomarmy.yml,
|
|
160
|
+
then its gitignored .nomarmy.local.yml. Army sections
|
|
161
|
+
only pick agents by name; they can never hold a
|
|
162
|
+
credential or endpoint. Jobs dispatch with
|
|
163
|
+
\`army_role\`; edits apply to the next job, no restart.
|
|
164
|
+
show the General, the roster, which layer set what,
|
|
165
|
+
and roles that share the General's model or
|
|
166
|
+
usage (--json is what the \`army\` tool returns)
|
|
167
|
+
init write the default roster (Sr Dev, Jr Dev,
|
|
168
|
+
UI/UX, data architect, security analyst, PM,
|
|
169
|
+
PO, stakeholder, all on \`local\`) to --global
|
|
170
|
+
(default), --project or --local; --force
|
|
171
|
+
replaces an existing one
|
|
172
|
+
assign <role> <agent|none> [model|auto]
|
|
173
|
+
give a role an agent, and optionally the model
|
|
174
|
+
to run on it ("auto" lets the General pick per
|
|
175
|
+
job; omitted, the agent's default), in --global
|
|
176
|
+
(default), --project or --local. A named model
|
|
177
|
+
gets one real test call first (a listed model
|
|
178
|
+
isn't proof it runs); --no-check skips that
|
|
179
|
+
general <agent>
|
|
180
|
+
which agent the General is, in --global
|
|
181
|
+
(default) or --local
|
|
182
|
+
config paths where agents.yml and the three army layers live
|
|
183
|
+
jobs [--watch|--events|--prune] [--interval N] [--older-than DAYS]
|
|
184
|
+
what's running across every session (agent, model, phase,
|
|
185
|
+
last tool call, files changed, heartbeat) and what just
|
|
186
|
+
finished; --watch redraws every N seconds (default 3);
|
|
187
|
+
--events prints one line per start, phase change and
|
|
188
|
+
finish (for Claude Code's background monitor; --json for
|
|
189
|
+
JSON lines); --prune removes the bulky runtime data
|
|
190
|
+
from finished jobs older than DAYS (default 2), keeping
|
|
191
|
+
their records, reports and any retained worktree
|
|
192
|
+
health check what's likely to break a run before it does:
|
|
193
|
+
expiring logins, an outdated OpenClaw or plugin, roles
|
|
194
|
+
that can't be dispatched, an unloadable agents.yml,
|
|
195
|
+
piled-up job storage. The MCP server also runs this
|
|
196
|
+
every 6 hours and notifies once per new warning
|
|
197
|
+
statusline the one-line summary Claude Code's status line shows
|
|
198
|
+
(installed by \`nomarmy connect claude\` when no status
|
|
199
|
+
line is set); reads the session JSON on stdin
|
|
200
|
+
connect [claude] [codex] [cursor]
|
|
201
|
+
(Re-)register the MCP server with one or more coordinators.
|
|
202
|
+
With no target and not --json, prompts an interactive
|
|
203
|
+
multi-select instead.
|
|
204
|
+
start <profile> Start local inference (wraps scripts/start-inference.sh).
|
|
205
|
+
stop <profile> Stop local inference (wraps scripts/stop-inference.sh).
|
|
206
|
+
uninstall Remove the MCP registration and install directory.
|
|
207
|
+
--clear-agents also remove job records, logs and the
|
|
208
|
+
built llama.cpp binary (rebuilt on next
|
|
209
|
+
install)
|
|
210
|
+
--clear-models also remove the model repo config/
|
|
211
|
+
common.env references from the local
|
|
212
|
+
Hugging Face cache
|
|
213
|
+
--all both of the above
|
|
214
|
+
--force skip the confirmation prompt for either
|
|
215
|
+
(required alongside --json)
|
|
216
|
+
validate Validate .nomarmy.yml against the schema.
|
|
217
|
+
sizing Recommend context and nom count for this machine.
|
|
218
|
+
--check evaluate the loaded profile instead of recommending
|
|
219
|
+
--noms <N> size for exactly N workers instead of the max
|
|
220
|
+
that fits ("more noms") or the fixed
|
|
221
|
+
shipped-profile default ("nominal"); also
|
|
222
|
+
offered as an interactive prompt at the end
|
|
223
|
+
of the plain (non --json) report
|
|
224
|
+
doctor Check this host is ready to run nomArmy, with a fix for
|
|
225
|
+
anything missing.
|
|
226
|
+
help
|
|
227
|
+
|
|
228
|
+
Options:
|
|
229
|
+
--repo <dir> repository to inspect (default: cwd)
|
|
230
|
+
--json machine-readable output; disables interactive prompts
|
|
231
|
+
--model <path> GGUF file to size against (default: auto-discover)
|
|
232
|
+
--execution <m> local | bedrock (default: $NOMARMY_EXECUTION or local)
|
|
233
|
+
`);
|
|
234
|
+
process.exit(code);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Model discovery and split-shard size summing live in lib/gguf.mjs, tested
|
|
238
|
+
// there; this is a thin wrapper binding the CLI's own --model flag.
|
|
239
|
+
function findModel() {
|
|
240
|
+
return resolveModelPath({ explicit: value("model"), env: process.env });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function printWarnings(warnings = []) {
|
|
244
|
+
if (!warnings.length) return;
|
|
245
|
+
console.log("");
|
|
246
|
+
for (const w of warnings) console.log(` [${w.severity}] ${w.code}: ${w.message}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function cmdScan() {
|
|
250
|
+
const evidence = scanRepository(repoDir);
|
|
251
|
+
if (flag("check")) return scanCheck(evidence);
|
|
252
|
+
if (json) return out(evidence);
|
|
253
|
+
|
|
254
|
+
console.log(`Repository evidence for ${evidence.repoName}\n`);
|
|
255
|
+
for (const [name, count] of Object.entries(evidence.counts)) {
|
|
256
|
+
if (count > 0) {
|
|
257
|
+
console.log(` ${String(name).padEnd(14)} ${count}${evidence[name]?.truncated ? " (truncated)" : ""}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const services = evidence.services?.items ?? [];
|
|
261
|
+
if (services.length) {
|
|
262
|
+
console.log("\nServices:");
|
|
263
|
+
for (const s of services) {
|
|
264
|
+
console.log(` ${s.name}${s.image ? ` ${s.image}` : ""}${s.source ? ` (${s.source})` : ""}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const notes = evidence.notes?.items ?? [];
|
|
268
|
+
if (notes.length) {
|
|
269
|
+
console.log("\nNotes:");
|
|
270
|
+
for (const n of notes) console.log(` ${n.message ?? n}`);
|
|
271
|
+
}
|
|
272
|
+
console.log("\nThis is deterministic evidence only - nothing here was executed.");
|
|
273
|
+
console.log("Describe the environment in .nomarmy.yml, then run 'nomarmy validate'.");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function scanCheck(evidence) {
|
|
277
|
+
let loaded;
|
|
278
|
+
try {
|
|
279
|
+
loaded = loadConfig(repoDir);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
if (json) return out({ error: err.message, errors: err.errors ?? [] });
|
|
282
|
+
console.error(`Cannot compare: ${err.message}`);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
const drift = compareEvidence(evidence, loaded.found ? loaded.config : null);
|
|
286
|
+
if (json) return out({ evidence, drift });
|
|
287
|
+
if (!loaded.found) {
|
|
288
|
+
console.log(`No ${CONFIG_FILENAMES.join(" or ")} found in ${evidence.repoName}.`);
|
|
289
|
+
console.log("Run 'nomarmy scan' to see what this repository appears to need.");
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
console.log(`Comparing ${path.basename(loaded.path)} against repository evidence\n`);
|
|
293
|
+
if (drift.summary) console.log(`${drift.summary}\n`);
|
|
294
|
+
for (const section of ["services", "ports", "environment", "commandKinds"]) {
|
|
295
|
+
const d = drift[section];
|
|
296
|
+
if (!d) continue;
|
|
297
|
+
for (const m of d.missingFromConfig ?? []) console.log(` repo has, config omits: ${section}: ${m}`);
|
|
298
|
+
for (const m of d.missingFromRepo ?? []) console.log(` config has, repo lacks: ${section}: ${m}`);
|
|
299
|
+
}
|
|
300
|
+
process.exit(drift.ok ? 0 : 1);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function cmdValidate() {
|
|
304
|
+
let loaded;
|
|
305
|
+
try {
|
|
306
|
+
loaded = loadConfig(repoDir);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
if (json) return out({ valid: false, errors: err.errors ?? [err.message], path: err.path ?? null });
|
|
309
|
+
console.error(`Invalid ${err.path ? path.basename(err.path) : "config"}:`);
|
|
310
|
+
for (const e of err.errors ?? [err.message]) console.error(` ${e}`);
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
if (!loaded.found) {
|
|
314
|
+
if (json) return out({ found: false });
|
|
315
|
+
console.log(`No ${CONFIG_FILENAMES.join(" or ")} in ${repoDir}. Nothing to validate.`);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const res = validateConfig(loaded.config);
|
|
319
|
+
if (json) {
|
|
320
|
+
return out({ found: true, path: loaded.path, valid: res.valid, errors: res.errors, elevated: loaded.elevated });
|
|
321
|
+
}
|
|
322
|
+
console.log(`${path.basename(loaded.path)} is valid.`);
|
|
323
|
+
const { shared = [], remote = [] } = loaded.elevated ?? {};
|
|
324
|
+
if (shared.length || remote.length) {
|
|
325
|
+
console.log("\nElevated services requiring explicit policy approval:");
|
|
326
|
+
for (const s of shared) console.log(` shared: ${s} (mutable state shared across jobs)`);
|
|
327
|
+
for (const r of remote) console.log(` remote: ${r} (traffic leaves this machine)`);
|
|
328
|
+
console.log("\nThese are accepted by the schema but are not enabled by default.");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* `nomarmy init`: scan the repository, propose a `.nomarmy.yml` from the
|
|
334
|
+
* evidence, and write it only after explicit confirmation (interactive) or
|
|
335
|
+
* an explicit `--write` flag (non-interactive, `--json`). Never overwrites an
|
|
336
|
+
* existing file without `--force` -- a human-authored config is never
|
|
337
|
+
* silently clobbered by a guess.
|
|
338
|
+
*/
|
|
339
|
+
async function cmdInit() {
|
|
340
|
+
const existing = findConfigFile(repoDir);
|
|
341
|
+
if (existing && !flag("force")) {
|
|
342
|
+
if (json) { out({ error: `${path.basename(existing)} already exists`, path: existing }); process.exit(1); }
|
|
343
|
+
console.log(c.yellow(`${path.basename(existing)} already exists at ${existing}.`));
|
|
344
|
+
console.log("Not overwriting a config someone already wrote. Re-run with --force to replace it.");
|
|
345
|
+
process.exit(1);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const evidence = scanRepository(repoDir);
|
|
349
|
+
const { proposal, valid, errors, excludedFixturePaths, notes } = buildConfigProposal(evidence);
|
|
350
|
+
// --force regenerates what the scan can see; the army section is a human
|
|
351
|
+
// decision the scan knows nothing about, so it carries over untouched.
|
|
352
|
+
if (existing) {
|
|
353
|
+
try {
|
|
354
|
+
const previousArmy = parseYaml(fs.readFileSync(existing, "utf8"), existing)?.army;
|
|
355
|
+
if (previousArmy) proposal.army = previousArmy;
|
|
356
|
+
} catch { /* an unparseable old file has nothing safe to carry over */ }
|
|
357
|
+
}
|
|
358
|
+
const targetPath = path.join(repoDir, existing ? path.basename(existing) : CONFIG_FILENAMES[0]);
|
|
359
|
+
|
|
360
|
+
if (json) {
|
|
361
|
+
if (!flag("write")) return out({ proposal, valid, errors, excludedFixturePaths, notes, wouldWriteTo: targetPath });
|
|
362
|
+
if (!valid) { out({ error: "proposal does not validate; refusing to write", errors }); process.exit(1); }
|
|
363
|
+
fs.writeFileSync(targetPath, stringifyConfig(proposal));
|
|
364
|
+
return out({ written: targetPath, proposal });
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
console.log(c.bold(`🍪 Proposed ${path.basename(targetPath)}`) + c.dim(`, built from ${evidence.repoName}'s scan evidence:`) + "\n");
|
|
368
|
+
console.log(stringifyConfig(proposal));
|
|
369
|
+
if (excludedFixturePaths.length) {
|
|
370
|
+
console.log(c.yellow("Excluded as likely test fixtures (review by hand if any of these is real):"));
|
|
371
|
+
for (const p of excludedFixturePaths) console.log(c.dim(` ${p}`));
|
|
372
|
+
console.log("");
|
|
373
|
+
}
|
|
374
|
+
if (notes.length) { for (const n of notes) console.log(c.dim(`Note: ${n}`)); console.log(""); }
|
|
375
|
+
|
|
376
|
+
if (!valid) {
|
|
377
|
+
console.log(c.red("This proposal does not validate against the schema:"));
|
|
378
|
+
for (const e of errors) console.log(c.red(` ${e}`));
|
|
379
|
+
console.log("\nNot offering to write an invalid config. Fix the evidence or write .nomarmy.yml by hand.");
|
|
380
|
+
process.exit(1);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (!process.stdin.isTTY) throw new Error("nomarmy init needs an interactive terminal to confirm the write, or --json --write for a non-interactive one.");
|
|
384
|
+
const rl = createInterface({ input, output });
|
|
385
|
+
try {
|
|
386
|
+
const answer = (await rl.question(c.bold(`Write this to ${path.basename(targetPath)}? [y/N] `))).trim().toLowerCase();
|
|
387
|
+
if (answer !== "y") { console.log(c.dim("Canceled; nothing written.")); return; }
|
|
388
|
+
fs.writeFileSync(targetPath, stringifyConfig(proposal));
|
|
389
|
+
console.log(c.green(`✓ Wrote ${targetPath}.`) + " Run 'nomarmy validate' any time to re-check it.");
|
|
390
|
+
} finally {
|
|
391
|
+
rl.close();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// This file's own location, not --repo (the target repo being scanned) --
|
|
396
|
+
// setup/model need to find THIS package's config/ and scripts/ as siblings
|
|
397
|
+
// of bin/, the same way select-model.mjs resolves its own root.
|
|
398
|
+
const nomarmyRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
|
399
|
+
|
|
400
|
+
/** Read one KEY=VALUE line's value, or null if the file or key doesn't exist. */
|
|
401
|
+
function readEnvValue(filePath, key) {
|
|
402
|
+
if (!fs.existsSync(filePath)) return null;
|
|
403
|
+
const m = fs.readFileSync(filePath, "utf8").match(new RegExp(`^${key}=(.*)$`, "m"));
|
|
404
|
+
return m ? m[1].trim() : null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Read-modify-write one KEY=VALUE line, replacing it if present, appending if not -- the exact pattern scripts/select-model.mjs already uses for config/common.env. */
|
|
408
|
+
function writeEnvLine(filePath, key, value) {
|
|
409
|
+
const existingText = fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
|
|
410
|
+
const line = `${key}=${value}`;
|
|
411
|
+
const updated = new RegExp(`^${key}=.*$`, "m").test(existingText)
|
|
412
|
+
? existingText.replace(new RegExp(`^${key}=.*$`, "m"), line)
|
|
413
|
+
: `${existingText.trimEnd()}\n${line}\n`.replace(/^\n/, "");
|
|
414
|
+
fs.writeFileSync(filePath, updated);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Each entry's repo/quant/alias is verified against this project's own real
|
|
418
|
+
// usage (downloaded, loaded, dispatched against), not guessed from a model
|
|
419
|
+
// card. `thinking` records whether the model has a reasoning mode at all --
|
|
420
|
+
// Coder-Next does not, the other two do, at reasoning: medium specifically
|
|
421
|
+
// (see docs/experiments/2026-09-20-model-bakeoff-and-economics.md): high
|
|
422
|
+
// reasoning was a strict downgrade on every model tested, from a full
|
|
423
|
+
// timeout (Qwen3.6-27B on an open-ended task) to a 5x slowdown with real
|
|
424
|
+
// failures (gpt-oss-20b: 318s/4 failures at high vs 62s/0 failures at
|
|
425
|
+
// medium on an identical ticket). `recommended` is this project's own
|
|
426
|
+
// honest opinion given everything measured so far, not a formula -- it
|
|
427
|
+
// prints as a note, never a hidden default no one chose.
|
|
428
|
+
/**
|
|
429
|
+
* Ask a question and keep re-asking until the answer matches `pattern` (or
|
|
430
|
+
* is blank and `allowEmpty`, returning `fallback`) -- validated ON THE SPOT,
|
|
431
|
+
* not left to fail only at the very end via schema validation with no
|
|
432
|
+
* indication of which of several answers was the problem.
|
|
433
|
+
*/
|
|
434
|
+
async function askUntilValid(rl, prompt, { pattern, invalidMessage, allowEmpty = false, fallback = "" }) {
|
|
435
|
+
for (;;) {
|
|
436
|
+
const answer = (await rl.question(c.bold(prompt))).trim();
|
|
437
|
+
if (!answer && allowEmpty) return fallback;
|
|
438
|
+
if (pattern.test(answer)) return answer;
|
|
439
|
+
console.log(c.red(` ✗ ${invalidMessage}`));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Like rl.question, for a real secret typed directly into the terminal
|
|
445
|
+
* rather than exported as an env var first. NOT masked: the classic
|
|
446
|
+
* "monkey-patch readline's private write hook" trick relies on
|
|
447
|
+
* `_writeToOutput`, which the callback-based `readline.Interface` exposes
|
|
448
|
+
* but this project's promises-based one (`node:readline/promises`,
|
|
449
|
+
* confirmed directly against this Node version) does not -- and a
|
|
450
|
+
* hand-rolled raw-mode character reader is exactly the kind of thing that's
|
|
451
|
+
* unsafe to ship without testing against a real interactive terminal, which
|
|
452
|
+
* this environment cannot do. So this echoes plainly, like every other
|
|
453
|
+
* question in this file, and says so up front rather than silently doing
|
|
454
|
+
* something fragile. The value is still never written to config/
|
|
455
|
+
* agents.yml or any other file -- it's held in memory for the one
|
|
456
|
+
* immediate registration call and discarded.
|
|
457
|
+
*/
|
|
458
|
+
async function askSecret(rl, prompt) {
|
|
459
|
+
console.log(c.yellow("(this will be visible as you type it -- not masked, not sent anywhere, not saved to a file)"));
|
|
460
|
+
const answer = await rl.question(c.bold(prompt));
|
|
461
|
+
return answer.trim();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const KNOWN_MODELS = {
|
|
465
|
+
default: {
|
|
466
|
+
label: "Qwen3-Coder-Next (shipped default; no thinking mode -- 0 failures across every case tested tonight)",
|
|
467
|
+
repo: "Qwen/Qwen3-Coder-Next-GGUF", quant: "Q4_K_M", alias: "qwen3-coder-next", thinking: false, recommended: true,
|
|
468
|
+
},
|
|
469
|
+
"gpt-oss-20b": {
|
|
470
|
+
label: "gpt-oss-20b (thinking, use reasoning: medium -- fastest of every model tested on the hardest case: 62s/0 failures; reasoning: high on the SAME ticket was the worst result measured: 318s/4 failures)",
|
|
471
|
+
repo: "ggml-org/gpt-oss-20b-GGUF", quant: "MXFP4", alias: "gpt-oss-20b", thinking: true,
|
|
472
|
+
},
|
|
473
|
+
"qwen3.6-27b": {
|
|
474
|
+
label: "Qwen3.6-27B (thinking, use reasoning: medium -- best measured reliability, noticeably slower per-token; reasoning: high caused a full timeout on an open-ended task)",
|
|
475
|
+
repo: "unsloth/Qwen3.6-27B-GGUF", quant: "Q4_K_M", alias: "qwen3.6-27b", thinking: true,
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The one model-choice menu both `setup` and `model` show. Returns
|
|
481
|
+
* `{ kind: "known", repo, quant, alias, thinking }` for a curated entry, or
|
|
482
|
+
* `{ kind: "search" }` once scripts/select-model.mjs (spawned as a child
|
|
483
|
+
* process, not reimplemented -- it already owns the Hugging Face search,
|
|
484
|
+
* confirm and write flow) has finished. A searched model's `thinking`
|
|
485
|
+
* support isn't knowable from a repo/quant alone, so it's asked directly.
|
|
486
|
+
*/
|
|
487
|
+
async function chooseModel(rl) {
|
|
488
|
+
const entries = Object.entries(KNOWN_MODELS);
|
|
489
|
+
console.log("\n" + c.bold("Which model?"));
|
|
490
|
+
entries.forEach(([, m], i) => console.log(` ${c.cyan(`${i + 1}.`)} ${m.label}`));
|
|
491
|
+
console.log(` ${c.cyan(`${entries.length + 1}.`)} Search Hugging Face for something else`);
|
|
492
|
+
const defaultChoice = String(entries.findIndex(([, m]) => m.recommended) + 1 || 1);
|
|
493
|
+
const choice = (await rl.question(c.bold(`Choice [${defaultChoice}]: `))).trim() || defaultChoice;
|
|
494
|
+
const picked = entries[Number(choice) - 1];
|
|
495
|
+
if (picked) return { kind: "known", ...picked[1] };
|
|
496
|
+
const term = (await rl.question("Search term (or owner/model-GGUF repo): ")).trim();
|
|
497
|
+
if (!term) throw new Error("A search term or repo is required for the Hugging Face search path.");
|
|
498
|
+
await new Promise((resolve, reject) => {
|
|
499
|
+
const child = spawn(process.execPath, [path.join(nomarmyRoot, "scripts", "select-model.mjs"), term], { stdio: "inherit" });
|
|
500
|
+
child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`select-model.mjs exited ${code}`))));
|
|
501
|
+
child.on("error", reject);
|
|
502
|
+
});
|
|
503
|
+
const thinkingAnswer = (await rl.question(c.bold("Does this model have a thinking/reasoning mode? [y/N] "))).trim().toLowerCase();
|
|
504
|
+
return { kind: "search", thinking: thinkingAnswer === "y" || thinkingAnswer === "yes" };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* `nomarmy setup`: detect hardware, recommend a profile the same way
|
|
509
|
+
* `nomarmy sizing` already does, let the user pick a model, then write the
|
|
510
|
+
* result to config/profiles/<name>.env and (for the two curated model
|
|
511
|
+
* choices) config/common.env. Stops there -- prints the exact `install.sh`
|
|
512
|
+
* command rather than running it. install.sh builds llama.cpp, curl-pipes an
|
|
513
|
+
* installer and touches sandbox/provider config; that is not a proportionate
|
|
514
|
+
* thing for an opt-in flag on a CLI whose whole brand is "reports or
|
|
515
|
+
* proposes" to cross, unlike the cheap, reversible, single-file writes this
|
|
516
|
+
* command itself does.
|
|
517
|
+
*/
|
|
518
|
+
async function cmdSetup() {
|
|
519
|
+
const execution = value("execution", process.env.NOMARMY_EXECUTION || "local");
|
|
520
|
+
const isCloud = execution !== "local";
|
|
521
|
+
const hardware = isCloud ? null : await detectHardware();
|
|
522
|
+
const modelPath = isCloud ? null : findModel();
|
|
523
|
+
const gguf = modelPath ? await readGGUFMetadata(modelPath) : { found: false };
|
|
524
|
+
const res = recommend({ hardware, gguf, execution });
|
|
525
|
+
|
|
526
|
+
const nonInteractive = json;
|
|
527
|
+
if (nonInteractive && !flag("profile-name")) throw new Error("--json requires --profile-name <name>.");
|
|
528
|
+
if (nonInteractive && !isCloud && !flag("model")) throw new Error(`--json requires --model <${Object.keys(KNOWN_MODELS).join("|")}> for a local profile (Hugging Face search is interactive-only).`);
|
|
529
|
+
|
|
530
|
+
let rl = null;
|
|
531
|
+
if (!nonInteractive) {
|
|
532
|
+
if (!process.stdin.isTTY) throw new Error("nomarmy setup needs an interactive terminal, or --json with --profile-name (and --model for a local profile).");
|
|
533
|
+
rl = createInterface({ input, output });
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
try {
|
|
537
|
+
if (!json) {
|
|
538
|
+
console.log(c.bold("🍪 nomArmy setup\n"));
|
|
539
|
+
console.log(isCloud
|
|
540
|
+
? `Execution is '${execution}' -- hosted inference, local hardware does not bound this.\n`
|
|
541
|
+
: `Hardware: ${c.cyan(`${hardware.platform}/${hardware.arch}`)}, ${hardware.cpu?.logicalCores ?? "?"} logical cores, ${(hardware.memory?.totalBytes / 1024 ** 3).toFixed(1)} GiB RAM\n`);
|
|
542
|
+
console.log(`More noms ${c.dim(`(confidence: ${res.confidence})`)}: ${c.green(res.summary ?? JSON.stringify(res.env))}`);
|
|
543
|
+
if (res.nominal && !res.nominal.sameAsRecommended) {
|
|
544
|
+
console.log(`Nominal: ${res.nominal.fits ? c.dim(res.nominal.summary) : c.red(`${res.nominal.summary} DOES NOT FIT either -- nothing on this machine does.`)}`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// "More noms" fits as many noms as memory allows; "nominal" is 1 worker
|
|
549
|
+
// at the same context, matching every profile actually shipped in
|
|
550
|
+
// config/profiles/*.env. No genuinely distinct third "fast" tier is
|
|
551
|
+
// offered: worker count is the only speed-relevant lever this project
|
|
552
|
+
// has real (measured, README-documented) data for, and a smaller
|
|
553
|
+
// context per nom has no established speed relationship in this
|
|
554
|
+
// codebase, only a memory one -- inventing one would be a guess
|
|
555
|
+
// presented as a measurement.
|
|
556
|
+
let sizingTier = "more";
|
|
557
|
+
if (res.nominal && !res.nominal.sameAsRecommended) {
|
|
558
|
+
if (nonInteractive) {
|
|
559
|
+
sizingTier = value("tier", "more");
|
|
560
|
+
if (sizingTier !== "more" && sizingTier !== "nominal") throw new Error('--tier must be "more" or "nominal".');
|
|
561
|
+
} else {
|
|
562
|
+
console.log(`\n${c.bold("Which sizing?")}`);
|
|
563
|
+
console.log(` ${c.cyan("1.")} More noms -- as many as fit in memory`);
|
|
564
|
+
console.log(` ${c.cyan("2.")} Nominal -- 1 nom, matching this project's own shipped profiles`);
|
|
565
|
+
const choice = (await rl.question(c.bold("Choice [1]: "))).trim() || "1";
|
|
566
|
+
sizingTier = choice === "2" ? "nominal" : "more";
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const sizingEnv = sizingTier === "nominal" ? res.nominal.env : res.env;
|
|
570
|
+
|
|
571
|
+
let model = null;
|
|
572
|
+
if (!isCloud) {
|
|
573
|
+
if (nonInteractive) {
|
|
574
|
+
const which = value("model");
|
|
575
|
+
if (!KNOWN_MODELS[which]) throw new Error(`--model must be one of ${Object.keys(KNOWN_MODELS).join(", ")} under --json, got "${which}".`);
|
|
576
|
+
model = { kind: "known", ...KNOWN_MODELS[which] };
|
|
577
|
+
} else {
|
|
578
|
+
model = await chooseModel(rl);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const profileName = nonInteractive ? value("profile-name") : (await rl.question(`\nProfile name [${hardware?.appleSilicon ? "macbook-pro" : "custom"}]: `)).trim() || (hardware?.appleSilicon ? "macbook-pro" : "custom");
|
|
583
|
+
const profilePath = path.join(nomarmyRoot, "config", "profiles", `${profileName}.env`);
|
|
584
|
+
const commonPath = path.join(nomarmyRoot, "config", "common.env");
|
|
585
|
+
|
|
586
|
+
const profileWrites = { ...sizingEnv };
|
|
587
|
+
if (!isCloud) {
|
|
588
|
+
// recommend() only returns context/parallel/worker counts -- every
|
|
589
|
+
// hand-authored profile also sets these two, and start-inference.sh
|
|
590
|
+
// references both unconditionally under `set -euo pipefail`, so a
|
|
591
|
+
// profile missing them fails outright on first use, not gracefully.
|
|
592
|
+
profileWrites.NOMARMY_LLAMA_GPU_LAYERS = (hardware.gpu?.count > 0 || hardware.platform === "darwin") ? 999 : 0;
|
|
593
|
+
profileWrites.NOMARMY_LLAMA_THREADS = hardware.cpu?.physicalCores ?? hardware.cpu?.logicalCores ?? 4;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (!json) {
|
|
597
|
+
console.log(c.bold(`\nAbout to write ${path.relative(nomarmyRoot, profilePath)}:`));
|
|
598
|
+
for (const [k, v] of Object.entries(profileWrites)) console.log(c.dim(` ${k}=${v}`));
|
|
599
|
+
if (model?.kind === "known") {
|
|
600
|
+
console.log(c.bold(`\nAnd ${path.relative(nomarmyRoot, commonPath)}:`));
|
|
601
|
+
console.log(c.dim(` NOMARMY_MODEL_REPO=${model.repo}`));
|
|
602
|
+
console.log(c.dim(` NOMARMY_MODEL_QUANT=${model.quant}`));
|
|
603
|
+
console.log(c.dim(` NOMARMY_MODEL_ALIAS=${model.alias}`));
|
|
604
|
+
console.log(c.dim(` NOMARMY_WORKER_MODEL=${model.alias}`));
|
|
605
|
+
console.log(c.dim(` NOMARMY_MODEL_THINKING=${model.thinking}`));
|
|
606
|
+
}
|
|
607
|
+
if (!nonInteractive) {
|
|
608
|
+
const answer = (await rl.question(c.bold("\nWrite this configuration? [y/N] "))).trim().toLowerCase();
|
|
609
|
+
if (answer !== "y") { console.log(c.dim("Canceled; nothing written.")); return; }
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
fs.mkdirSync(path.dirname(profilePath), { recursive: true });
|
|
614
|
+
for (const [k, v] of Object.entries(profileWrites)) writeEnvLine(profilePath, k, v);
|
|
615
|
+
if (model?.kind === "known" && model.repo) {
|
|
616
|
+
writeEnvLine(commonPath, "NOMARMY_MODEL_REPO", model.repo);
|
|
617
|
+
writeEnvLine(commonPath, "NOMARMY_MODEL_QUANT", model.quant);
|
|
618
|
+
}
|
|
619
|
+
if (model?.kind === "known") {
|
|
620
|
+
writeEnvLine(commonPath, "NOMARMY_MODEL_ALIAS", model.alias);
|
|
621
|
+
// The keys `nomarmy connect` actually reads to route worker dispatch --
|
|
622
|
+
// writing NOMARMY_MODEL_ALIAS alone (the old behavior) left the MCP
|
|
623
|
+
// registration permanently pointed at whatever it last had, regardless
|
|
624
|
+
// of what was chosen here.
|
|
625
|
+
writeEnvLine(commonPath, "NOMARMY_WORKER_MODEL", model.alias);
|
|
626
|
+
writeEnvLine(commonPath, "NOMARMY_MODEL_THINKING", String(model.thinking));
|
|
627
|
+
} else if (model?.kind === "search") {
|
|
628
|
+
const searchedAlias = readEnvValue(commonPath, "NOMARMY_MODEL_ALIAS");
|
|
629
|
+
if (searchedAlias) {
|
|
630
|
+
writeEnvLine(commonPath, "NOMARMY_WORKER_MODEL", searchedAlias);
|
|
631
|
+
writeEnvLine(commonPath, "NOMARMY_MODEL_THINKING", String(model.thinking));
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const installCmd = `./install.sh --profile ${profileName}${isCloud ? "" : ""}`;
|
|
636
|
+
if (json) return out({ written: { profile: profilePath, common: model?.kind === "known" ? commonPath : null }, env: profileWrites, sizingTier, installCommand: installCmd });
|
|
637
|
+
console.log(c.green(`\n✓ Wrote ${path.relative(nomarmyRoot, profilePath)}${model?.kind === "known" ? ` and ${path.relative(nomarmyRoot, commonPath)}` : ""}.`));
|
|
638
|
+
console.log(c.dim("\nThis proposes; it does not install. Run:\n"));
|
|
639
|
+
console.log(` ${c.bold(installCmd)}\n`);
|
|
640
|
+
} finally {
|
|
641
|
+
rl?.close();
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* `nomarmy model`: swap the configured model later without re-running the
|
|
647
|
+
* whole `setup` wizard. Same menu `setup` offers; never restarts inference
|
|
648
|
+
* itself, matching every other "propose a config change" command in this
|
|
649
|
+
* CLI.
|
|
650
|
+
*/
|
|
651
|
+
// The third layer this closes, alongside NOMARMY_WORKER_MODEL/--update-mcp
|
|
652
|
+
// above: writing config/common.env and resyncing the MCP registration still
|
|
653
|
+
// leaves the ACTUAL RUNNING llama-server serving whatever model it loaded
|
|
654
|
+
// at its own last start -- a real, confirmed incident (config said
|
|
655
|
+
// Qwen3.6-27B, the live process was still gpt-oss-20b 11 minutes later,
|
|
656
|
+
// and a delegated worker correctly refused to guess a launch command or
|
|
657
|
+
// kill a 13.7GB process without authorization rather than silently doing
|
|
658
|
+
// nothing). stop/start-inference.sh already no-op harmlessly on a cloud
|
|
659
|
+
// profile and auto-detect the profile from the OS when none is given
|
|
660
|
+
// (see lib.sh's load_profile), so this needs no profile argument itself.
|
|
661
|
+
function restartInference() {
|
|
662
|
+
runScript("stop-inference.sh", []);
|
|
663
|
+
runScript("start-inference.sh", []);
|
|
664
|
+
}
|
|
665
|
+
async function cmdModel() {
|
|
666
|
+
const commonPath = path.join(nomarmyRoot, "config", "common.env");
|
|
667
|
+
if (json) {
|
|
668
|
+
const which = value("model");
|
|
669
|
+
if (!KNOWN_MODELS[which]) throw new Error(`--json requires --model one of ${Object.keys(KNOWN_MODELS).join(", ")} (Hugging Face search is interactive-only).`);
|
|
670
|
+
const m = KNOWN_MODELS[which];
|
|
671
|
+
if (m.repo) { writeEnvLine(commonPath, "NOMARMY_MODEL_REPO", m.repo); writeEnvLine(commonPath, "NOMARMY_MODEL_QUANT", m.quant); }
|
|
672
|
+
writeEnvLine(commonPath, "NOMARMY_MODEL_ALIAS", m.alias);
|
|
673
|
+
writeEnvLine(commonPath, "NOMARMY_WORKER_MODEL", m.alias);
|
|
674
|
+
writeEnvLine(commonPath, "NOMARMY_MODEL_THINKING", String(m.thinking));
|
|
675
|
+
// Same destructive-action-needs-explicit-opt-in-under-json rule as
|
|
676
|
+
// uninstall's --clear-*: this runs claude mcp remove/add for real.
|
|
677
|
+
if (flag("update-mcp")) connectClaude({ nomarmyRoot, run: (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: "ignore", ...opts }) });
|
|
678
|
+
// Also real and destructive (kills the running llama-server, however
|
|
679
|
+
// briefly) -- same opt-in-under-json rule.
|
|
680
|
+
if (flag("restart-inference")) restartInference();
|
|
681
|
+
return out({ written: commonPath, model: m, mcpUpdated: flag("update-mcp"), inferenceRestarted: flag("restart-inference") });
|
|
682
|
+
}
|
|
683
|
+
if (!process.stdin.isTTY) throw new Error(`nomarmy model needs an interactive terminal, or --json --model <${Object.keys(KNOWN_MODELS).join("|")}> (add --update-mcp to also resync the MCP registration, --restart-inference to also reload the running local model).`);
|
|
684
|
+
const rl = createInterface({ input, output });
|
|
685
|
+
try {
|
|
686
|
+
console.log(c.bold("🍪 nomArmy model"));
|
|
687
|
+
const model = await chooseModel(rl);
|
|
688
|
+
let alias;
|
|
689
|
+
if (model.kind === "search") {
|
|
690
|
+
console.log(c.green("\n✓ Done") + " -- config/common.env was already updated by the search above.");
|
|
691
|
+
alias = readEnvValue(commonPath, "NOMARMY_MODEL_ALIAS");
|
|
692
|
+
} else {
|
|
693
|
+
console.log(c.bold(`\nAbout to write ${path.relative(nomarmyRoot, commonPath)}:`));
|
|
694
|
+
console.log(c.dim(` NOMARMY_MODEL_REPO=${model.repo}\n NOMARMY_MODEL_QUANT=${model.quant}\n NOMARMY_MODEL_ALIAS=${model.alias}`));
|
|
695
|
+
const answer = (await rl.question(c.bold("\nApply this model configuration? [y/N] "))).trim().toLowerCase();
|
|
696
|
+
if (answer !== "y") { console.log(c.dim("Canceled; nothing changed.")); return; }
|
|
697
|
+
writeEnvLine(commonPath, "NOMARMY_MODEL_REPO", model.repo);
|
|
698
|
+
writeEnvLine(commonPath, "NOMARMY_MODEL_QUANT", model.quant);
|
|
699
|
+
writeEnvLine(commonPath, "NOMARMY_MODEL_ALIAS", model.alias);
|
|
700
|
+
console.log(c.green(`✓ Wrote ${path.relative(nomarmyRoot, commonPath)}.`));
|
|
701
|
+
alias = model.alias;
|
|
702
|
+
}
|
|
703
|
+
if (alias) {
|
|
704
|
+
writeEnvLine(commonPath, "NOMARMY_WORKER_MODEL", alias);
|
|
705
|
+
writeEnvLine(commonPath, "NOMARMY_MODEL_THINKING", String(model.thinking));
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// The gap this closes: NOMARMY_WORKER_MODEL above was, until now, never
|
|
709
|
+
// read back by anything -- picking a model here had no effect on which
|
|
710
|
+
// model workers actually dispatched to until someone separately, and
|
|
711
|
+
// manually, re-ran the MCP registration by hand.
|
|
712
|
+
if (alias && commandExists("claude")) {
|
|
713
|
+
const answer = (await rl.question(c.bold(`\nAlso update the Claude Code MCP registration to use "${alias}" now? [y/N] `))).trim().toLowerCase();
|
|
714
|
+
if (answer === "y" || answer === "yes") {
|
|
715
|
+
const run = (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: "inherit", ...opts });
|
|
716
|
+
connectClaude({ nomarmyRoot, run });
|
|
717
|
+
console.log(c.green("✓ MCP registration updated.") + " Restart your Claude Code session to pick this up (the MCP server is a per-session child process).");
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// The third layer: config/common.env and the MCP registration can both
|
|
722
|
+
// now say the right model while the actually-running llama-server keeps
|
|
723
|
+
// serving whatever it loaded at its own last start, unnoticed until
|
|
724
|
+
// something fails against the wrong model.
|
|
725
|
+
if (alias) {
|
|
726
|
+
const answer = (await rl.question(c.bold(`\nAlso restart local inference now to load "${alias}"? [y/N] `))).trim().toLowerCase();
|
|
727
|
+
if (answer === "y" || answer === "yes") {
|
|
728
|
+
restartInference();
|
|
729
|
+
} else {
|
|
730
|
+
console.log(c.dim("Skipped -- restart inference yourself when ready:\n nomarmy stop\n nomarmy start"));
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
} finally {
|
|
734
|
+
rl.close();
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// One label/default per api provider type the `nomarmy agents add api` menu shows.
|
|
739
|
+
// `native: true` types register through OpenClaw's own dedicated onboarding
|
|
740
|
+
// flag (--anthropic-api-key etc, verified via `openclaw onboard --help`);
|
|
741
|
+
// the rest register as a custom endpoint (the same mechanism
|
|
742
|
+
// scripts/configure-openclaw.sh already uses for Bedrock) and need base_url.
|
|
743
|
+
const KNOWN_PROVIDERS = {
|
|
744
|
+
anthropic: { label: "Anthropic (Claude)", defaultModel: "claude-sonnet-4-6", authEnvSuggestion: "NOMARMY_ANTHROPIC_API_KEY", native: true },
|
|
745
|
+
openai: { label: "OpenAI", defaultModel: "gpt-5.6-terra", authEnvSuggestion: "NOMARMY_OPENAI_API_KEY", native: true },
|
|
746
|
+
xai: { label: "xAI (Grok)", defaultModel: "grok-build-0.1", authEnvSuggestion: "NOMARMY_XAI_API_KEY", native: true },
|
|
747
|
+
deepinfra: { label: "DeepInfra", defaultModel: "meta-llama/Llama-3.3-70B-Instruct-Turbo", authEnvSuggestion: "NOMARMY_DEEPINFRA_API_KEY", native: true },
|
|
748
|
+
openclaw: { label: "Any other OpenClaw provider (DeepSeek, Mistral, Groq, ... -- by its OpenClaw id)", native: true },
|
|
749
|
+
bedrock: { label: "AWS Bedrock (custom OpenAI-compatible endpoint)", defaultModel: "amazon.nova-micro-v1:0", authEnvSuggestion: "NOMARMY_BEDROCK_API_KEY", native: false, baseUrlHint: "https://bedrock-runtime.<region>.amazonaws.com/openai/v1" },
|
|
750
|
+
"azure-openai": { label: "Azure OpenAI", defaultModel: "gpt-4o-mini", authEnvSuggestion: "NOMARMY_AZURE_OPENAI_API_KEY", native: false, baseUrlHint: "https://YOUR-RESOURCE.openai.azure.com" },
|
|
751
|
+
"openai-compatible": { label: "Custom OpenAI-compatible endpoint", defaultModel: "", authEnvSuggestion: "NOMARMY_CUSTOM_API_KEY", native: false, baseUrlHint: "https://example.com/v1" },
|
|
752
|
+
"llama-cpp": { label: "Local llama.cpp server (already configured -- adds it to a pool alongside remote providers)", native: false },
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Registers one provider entry with OpenClaw. The credential is ALWAYS
|
|
757
|
+
* piped via stdin, never passed as a CLI argument -- a bare argv value is
|
|
758
|
+
* visible to any other process on this machine for the child's lifetime
|
|
759
|
+
* (`ps aux`/`/proc/<pid>/cmdline`), which an earlier version of this
|
|
760
|
+
* function got wrong for native providers specifically (--anthropic-api-key
|
|
761
|
+
* <key> as a literal argument), a real regression against the discipline
|
|
762
|
+
* scripts/configure-openclaw.sh already established for Bedrock/local.
|
|
763
|
+
* `openclaw models auth paste-api-key --provider <id>` is that same
|
|
764
|
+
* stdin-piped primitive, used uniformly here for every provider type.
|
|
765
|
+
* Custom endpoints (bedrock/azure-openai/openai-compatible) additionally
|
|
766
|
+
* need `openclaw onboard --custom-*` first to define the provider's shape
|
|
767
|
+
* (base URL, model id) before a credential can attach to it; native
|
|
768
|
+
* providers (anthropic/openai/xai/deepinfra, or any id via the generic
|
|
769
|
+
* `openclaw` type) are already known to OpenClaw, or become known once the
|
|
770
|
+
* entry's `plugin` is installed, and skip straight to the credential step. On failure, the raw exec error
|
|
771
|
+
* is deliberately never printed -- Node's own error message embeds the
|
|
772
|
+
* full child command line, which for a failed `paste-api-key` call would
|
|
773
|
+
* otherwise still be safe (the key was on stdin, not argv) but is not worth
|
|
774
|
+
* trusting blindly across every future code path this function might grow.
|
|
775
|
+
*/
|
|
776
|
+
// Takes a pool entry in its REAL, on-disk shape (snake_case auth_env/
|
|
777
|
+
// base_url, exactly what config/providers.yml and the schema use) rather
|
|
778
|
+
// than a translated camelCase copy -- a prior version of this function
|
|
779
|
+
// destructured `authEnv`/`baseUrl` while every real entry object actually
|
|
780
|
+
// carries `auth_env`/`base_url`, so both were silently always undefined at
|
|
781
|
+
// every call site and the piped credential was the literal string "null".
|
|
782
|
+
function registerProviderWithOpenClaw({ id, provider, model, auth_env: authEnv, base_url: baseUrl, openclaw_provider: genericId, plugin, apiKeyOverride }) {
|
|
783
|
+
// apiKeyOverride is the value from askSecret's "enter it now instead"
|
|
784
|
+
// path -- checked first so a key typed directly into the wizard is used
|
|
785
|
+
// immediately, without also requiring it be exported first.
|
|
786
|
+
const apiKey = apiKeyOverride || (authEnv ? process.env[authEnv] : null);
|
|
787
|
+
if (!apiKey) {
|
|
788
|
+
console.log(c.red(authEnv
|
|
789
|
+
? `✗ ${authEnv} is not set in this shell -- export it, then run this registration again.`
|
|
790
|
+
: "✗ No credential available to register (no auth_env on this entry and none entered)."));
|
|
791
|
+
return false;
|
|
792
|
+
}
|
|
793
|
+
const isNative = isNativeProviderType(provider);
|
|
794
|
+
const authProviderId = isNative ? openclawProviderId({ provider, openclaw_provider: genericId }) : id;
|
|
795
|
+
const skipFlags = ["--skip-daemon", "--skip-channels", "--skip-skills", "--skip-search", "--skip-hooks", "--skip-ui"];
|
|
796
|
+
// Override point for tests (and for an operator pointing at a specific
|
|
797
|
+
// openclaw binary/path rather than relying on PATH resolution).
|
|
798
|
+
const openclawCmd = process.env.NOMARMY_OPENCLAW_CMD || "openclaw";
|
|
799
|
+
try {
|
|
800
|
+
// Official plugins register under their provider's id (meta, codex,
|
|
801
|
+
// deepseek -- confirmed live), so inspecting by that id is the
|
|
802
|
+
// already-installed check; installing an installed plugin errors out.
|
|
803
|
+
if (plugin && spawnSync(openclawCmd, ["plugins", "inspect", authProviderId], { stdio: "ignore" }).status !== 0) {
|
|
804
|
+
console.log(c.dim(`Installing OpenClaw plugin ${plugin}...`));
|
|
805
|
+
execFileSync(openclawCmd, ["plugins", "install", plugin], { stdio: "inherit" });
|
|
806
|
+
spawnSync(openclawCmd, ["plugins", "registry", "--refresh"], { stdio: "ignore" });
|
|
807
|
+
}
|
|
808
|
+
if (!isNative) {
|
|
809
|
+
execFileSync(openclawCmd, ["onboard", "--non-interactive", "--accept-risk",
|
|
810
|
+
"--custom-base-url", baseUrl, "--custom-model-id", model, "--custom-provider-id", id, "--custom-compatibility", "openai", ...skipFlags], { stdio: "ignore" });
|
|
811
|
+
}
|
|
812
|
+
execFileSync(openclawCmd, ["models", "auth", "paste-api-key", "--provider", authProviderId, "--profile-id", `${authProviderId}:nomarmy`],
|
|
813
|
+
{ input: `${apiKey}\n`, stdio: ["pipe", "ignore", "ignore"] });
|
|
814
|
+
console.log(c.green(`✓ Registered "${id}" with OpenClaw.`));
|
|
815
|
+
console.log(c.yellow(`This project has not run a real job against this specific provider type yet -- run \`openclaw models list --provider ${authProviderId}\` to confirm it registered as expected, then dispatch one real job against this pool before trusting it in production.`));
|
|
816
|
+
return true;
|
|
817
|
+
} catch (error) {
|
|
818
|
+
console.log(c.red(`✗ Registration failed (exit ${error.status ?? "?"}). Run the equivalent \`openclaw onboard\` / \`openclaw models auth paste-api-key --provider ${authProviderId}\` commands by hand to see the real error -- it is not repeated here, since a raw exec error can embed a full child command line and this one is not worth trusting blindly not to.`));
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// --- subscription setup (`agents add subscription <vendor>`): wrap every OpenClaw step
|
|
824
|
+
//
|
|
825
|
+
// The operator shouldn't need to know OpenClaw exists for the common case.
|
|
826
|
+
// Each helper below runs one real command, reports what it found in plain
|
|
827
|
+
// terms, and only prompts when something actually needs doing. Logins use
|
|
828
|
+
// stdio: "inherit" deliberately: they are device-auth/OAuth flows a human
|
|
829
|
+
// completes in a browser (`openclaw models auth login` refuses outright
|
|
830
|
+
// without a TTY, confirmed live) -- nomArmy starts the flow, the vendor CLI
|
|
831
|
+
// and OpenClaw do the authenticating, and nomArmy never sees a token.
|
|
832
|
+
|
|
833
|
+
function openclawCmd() { return process.env.NOMARMY_OPENCLAW_CMD || "openclaw"; }
|
|
834
|
+
|
|
835
|
+
// stdout AND stderr, on success too: `codex login status` prints its
|
|
836
|
+
// "Logged in using ChatGPT" line to stderr (confirmed live), and an earlier
|
|
837
|
+
// version that kept only stdout on a zero exit read that as "not logged
|
|
838
|
+
// in" -- then told a user who had just logged in successfully that they
|
|
839
|
+
// still weren't.
|
|
840
|
+
function runQuiet(cmd, args) {
|
|
841
|
+
const result = spawnSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
842
|
+
const out = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
843
|
+
return { ok: !result.error && result.status === 0, out, stdout: result.stdout ?? "", status: result.status };
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function runInteractive(cmd, args) {
|
|
847
|
+
try { execFileSync(cmd, args, { stdio: "inherit" }); return true; }
|
|
848
|
+
catch { return false; }
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
async function confirm(rl, question, { defaultYes = true } = {}) {
|
|
852
|
+
const answer = (await rl.question(c.bold(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `))).trim().toLowerCase();
|
|
853
|
+
if (!answer) return defaultYes;
|
|
854
|
+
return answer === "y" || answer === "yes";
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** The vendor CLI's own login state: its status command, or (Muse Code, which has none) its non-secret descriptor file. */
|
|
858
|
+
function readLoginStatus(vendorKey) {
|
|
859
|
+
const cli = SUBSCRIPTION_VENDORS[vendorKey].cli;
|
|
860
|
+
if (cli.statusArgs) return parseCliLoginStatus(vendorKey, runQuiet(cli.bin, cli.statusArgs).out);
|
|
861
|
+
let text = "";
|
|
862
|
+
try { text = fs.readFileSync(cli.statusFile.replace(/^~(?=\/)/, os.homedir()), "utf8"); } catch { /* missing = not logged in */ }
|
|
863
|
+
return parseMuseAuthDescriptor(text);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* credential.kind "minted-key": copies the key the vendor CLI's own login
|
|
868
|
+
* minted into the OS keychain over to OpenClaw, keychain -> this process ->
|
|
869
|
+
* `paste-api-key` stdin. Never printed, never on argv, never written to a
|
|
870
|
+
* file nomArmy owns; child stdio is discarded so no error path can echo it.
|
|
871
|
+
* Run on every setup, not just the first: the vendor may rotate the key,
|
|
872
|
+
* and a stale copy in OpenClaw fails exactly like a missing one.
|
|
873
|
+
*/
|
|
874
|
+
function syncMintedKey(vendor) {
|
|
875
|
+
const { keychain, profileId } = vendor.credential;
|
|
876
|
+
if (process.platform !== "darwin") {
|
|
877
|
+
console.log(c.red(`✗ Reading ${vendor.cli.bin}'s stored key is only wired up for the macOS keychain so far. On this OS, link it yourself: \`openclaw models auth paste-api-key --provider ${vendor.provider} --profile-id ${profileId}\` and paste the key it minted.`));
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
880
|
+
// macOS may show its own keychain prompt here, asking whether to let
|
|
881
|
+
// `security` read this item -- that's the OS asking the operator, as it should.
|
|
882
|
+
const read = spawnSync("security", ["find-generic-password", "-s", keychain.service, "-a", keychain.account, "-w"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
883
|
+
const key = read.status === 0 ? extractMintedKey(read.stdout.trim(), keychain.field) : null;
|
|
884
|
+
if (!key) {
|
|
885
|
+
console.log(c.red(`✗ Couldn't find a usable ${vendor.cli.bin} key in the keychain (or access was declined). Run \`${vendor.cli.bin} ${vendor.cli.loginArgs.join(" ")}\` again, then re-run this.`));
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
888
|
+
const pasted = spawnSync(openclawCmd(), ["models", "auth", "paste-api-key", "--provider", vendor.provider, "--profile-id", profileId], { input: `${key}\n`, stdio: ["pipe", "ignore", "ignore"] });
|
|
889
|
+
if (pasted.status !== 0) {
|
|
890
|
+
console.log(c.red(`✗ OpenClaw didn't accept the key (exit ${pasted.status ?? "?"}). Run \`openclaw models auth paste-api-key --provider ${vendor.provider} --profile-id ${profileId}\` by hand to see why.`));
|
|
891
|
+
return false;
|
|
892
|
+
}
|
|
893
|
+
runQuiet(openclawCmd(), ["plugins", "registry", "--refresh"]);
|
|
894
|
+
return true;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Makes one vendor's credential usable by OpenClaw, installing/updating/
|
|
899
|
+
* logging in only what's actually missing. Returns { ok, email } -- email is
|
|
900
|
+
* the vendor CLI's own logged-in account when it reports one, used to
|
|
901
|
+
* default the worker's owner so the operator never retypes who they are.
|
|
902
|
+
*/
|
|
903
|
+
async function ensureVendorAuth(rl, vendorKey) {
|
|
904
|
+
const vendor = SUBSCRIPTION_VENDORS[vendorKey];
|
|
905
|
+
const step = (text) => console.log(`\n${c.bold("→")} ${text}`);
|
|
906
|
+
|
|
907
|
+
step(`${vendor.cli.bin} CLI`);
|
|
908
|
+
if (!runQuiet(vendor.cli.bin, ["--version"]).ok) {
|
|
909
|
+
if (!vendor.cli.npmPackage) {
|
|
910
|
+
console.log(c.red(`✗ \`${vendor.cli.bin}\` isn't installed. ${vendor.cli.installHint}, then run this again.`));
|
|
911
|
+
return { ok: false };
|
|
912
|
+
}
|
|
913
|
+
if (!(await confirm(rl, `\`${vendor.cli.bin}\` isn't installed. Install ${vendor.cli.npmPackage} now?`))) return { ok: false };
|
|
914
|
+
if (!runInteractive("npm", ["install", "-g", vendor.cli.npmPackage])) {
|
|
915
|
+
console.log(c.red(`✗ Install failed. Run \`${vendor.cli.installHint}\` yourself to see why.`));
|
|
916
|
+
return { ok: false };
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
console.log(c.green(`✓ ${vendor.cli.bin} is installed.`));
|
|
920
|
+
|
|
921
|
+
let status = readLoginStatus(vendorKey);
|
|
922
|
+
if (!status.loggedIn) {
|
|
923
|
+
console.log(c.yellow(`You're not logged in to ${vendor.cli.bin} yet -- this opens its own login (a browser or a device code).`));
|
|
924
|
+
if (!(await confirm(rl, "Log in now?"))) return { ok: false };
|
|
925
|
+
runInteractive(vendor.cli.bin, vendor.cli.loginArgs);
|
|
926
|
+
status = readLoginStatus(vendorKey);
|
|
927
|
+
if (!status.loggedIn) { console.log(c.red(`✗ Still not logged in to ${vendor.cli.bin}.`)); return { ok: false }; }
|
|
928
|
+
}
|
|
929
|
+
console.log(c.green(`✓ Logged in to ${vendor.cli.bin}${status.email ? ` as ${status.email}` : ""}${status.subscriptionType ? ` (${status.subscriptionType})` : ""}.`));
|
|
930
|
+
|
|
931
|
+
if (vendor.plugin) {
|
|
932
|
+
step("OpenClaw plugin");
|
|
933
|
+
const version = parseOpenclawVersion(runQuiet(openclawCmd(), ["--version"]).out);
|
|
934
|
+
if (!versionAtLeast(version, vendor.plugin.minOpenclaw)) {
|
|
935
|
+
console.log(c.yellow(`OpenClaw ${version ? version.join(".") : "(unknown version)"} is older than the ${vendor.plugin.minOpenclaw} this vendor's plugin needs.`));
|
|
936
|
+
if (!(await confirm(rl, "Update OpenClaw now (npm update -g openclaw)?"))) return { ok: false };
|
|
937
|
+
if (!runInteractive("npm", ["update", "-g", "openclaw"])) {
|
|
938
|
+
console.log(c.red("✗ Update failed. If npm reports EACCES, your global npm directory has root-owned files from an old sudo install: `sudo chown -R $(whoami) ~/.npm ~/.npm-global` fixes it."));
|
|
939
|
+
return { ok: false };
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
if (!runQuiet(openclawCmd(), ["plugins", "inspect", vendor.plugin.id]).ok) {
|
|
943
|
+
console.log(c.dim(`Installing ${vendor.plugin.spec}...`));
|
|
944
|
+
if (!runInteractive(openclawCmd(), ["plugins", "install", vendor.plugin.spec])) {
|
|
945
|
+
console.log(c.red(`✗ Plugin install failed. Run \`openclaw plugins install ${vendor.plugin.spec}\` yourself to see why.`));
|
|
946
|
+
return { ok: false };
|
|
947
|
+
}
|
|
948
|
+
runQuiet(openclawCmd(), ["plugins", "registry", "--refresh"]);
|
|
949
|
+
}
|
|
950
|
+
console.log(c.green(`✓ OpenClaw's ${vendor.plugin.id} plugin is ready.`));
|
|
951
|
+
}
|
|
952
|
+
if (vendor.credential.kind === "minted-key") {
|
|
953
|
+
step(`Linking your ${vendor.cli.bin} login to OpenClaw`);
|
|
954
|
+
console.log(c.dim(`Copies the key ${vendor.cli.bin}'s own login stored in your keychain into OpenClaw (over stdin, never shown). macOS may ask you to allow it.`));
|
|
955
|
+
if (!syncMintedKey(vendor)) return { ok: false };
|
|
956
|
+
console.log(c.green(`✓ OpenClaw is using your ${vendor.cli.bin} subscription key (profile ${vendor.credential.profileId}).`));
|
|
957
|
+
}
|
|
958
|
+
if (vendor.plugin?.providerConfig) {
|
|
959
|
+
// paste-api-key saves the key but not the provider's config entry; the
|
|
960
|
+
// plugin's own onboarding step writes that (lib/openclaw-config.mjs).
|
|
961
|
+
const applied = await ensureProviderConfig({ provider: vendor.provider, pluginId: vendor.plugin.id, ...vendor.plugin.providerConfig });
|
|
962
|
+
if (applied.error) {
|
|
963
|
+
console.log(c.red(`✗ OpenClaw has no ${vendor.provider} provider entry, and adding it failed: ${applied.error}. Run \`openclaw onboard\` and pick ${vendor.label} to add it.`));
|
|
964
|
+
return { ok: false };
|
|
965
|
+
}
|
|
966
|
+
if (applied.changed) console.log(c.green(`✓ Added the ${vendor.provider} provider to OpenClaw's config with the plugin's own setup step (backup: ${applied.backup}).`));
|
|
967
|
+
}
|
|
968
|
+
return { ok: true, email: status.email };
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function catalogModelsFor(provider) {
|
|
972
|
+
return parseCatalogModels(runQuiet(openclawCmd(), ["models", "list", "--refresh"]).out, provider);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** One real, one-token completion through OpenClaw -- the only proof a credential actually works. */
|
|
976
|
+
// Why the last probeWorker() call failed, in the vendor's words when it said.
|
|
977
|
+
let lastProbeFailure = null;
|
|
978
|
+
function probeWorker(provider, model) {
|
|
979
|
+
// The route a job takes: the ambient OpenClaw config and a state dir of
|
|
980
|
+
// its own, never --isolated. --isolated skips that config, and with it the
|
|
981
|
+
// Codex runtime ChatGPT-plan jobs run through: gpt-6-sol answered "ok"
|
|
982
|
+
// under --isolated while every job on it failed "not supported when using
|
|
983
|
+
// Codex with a ChatGPT account" (a real Senti run). Under the home
|
|
984
|
+
// directory, since the Podman sandbox only binds paths there.
|
|
985
|
+
const dir = fs.mkdtempSync(path.join(agentStateRoot(), "probe-"));
|
|
986
|
+
const stateDir = path.join(dir, "state"), cwd = path.join(dir, "ws");
|
|
987
|
+
fs.mkdirSync(stateDir); fs.mkdirSync(cwd);
|
|
988
|
+
try {
|
|
989
|
+
const result = spawnSync(openclawCmd(), ["agent", "exec", "Reply with exactly: ok", "--model", `${provider}/${model}`, "--no-auth-env-only",
|
|
990
|
+
"--json", "--cwd", cwd, "--state-dir", stateDir, "--timeout", "90"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], cwd });
|
|
991
|
+
// Streams kept apart: OpenClaw logs a "run ... ended" line to stderr
|
|
992
|
+
// AFTER the JSON envelope, and the merged text doesn't parse.
|
|
993
|
+
const outcome = probeOutcome({ stdout: result.stdout ?? "", stderr: result.stderr ?? "" });
|
|
994
|
+
lastProbeFailure = outcome.ok ? null : outcome.reason;
|
|
995
|
+
if (outcome.ok) recordProbeSuccess(agentStateRoot(), `${provider}/${model}`);
|
|
996
|
+
return outcome.ok;
|
|
997
|
+
} finally {
|
|
998
|
+
reapProbeSandbox(stateDir);
|
|
999
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
function agentStateRoot() {
|
|
1003
|
+
const root = process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents");
|
|
1004
|
+
fs.mkdirSync(root, { recursive: true });
|
|
1005
|
+
return root;
|
|
1006
|
+
}
|
|
1007
|
+
// The sandbox container OpenClaw starts for the call is never stopped when
|
|
1008
|
+
// it returns (mcp/server.mjs reaps each job's the same way, by the hash in
|
|
1009
|
+
// its state dir).
|
|
1010
|
+
function reapProbeSandbox(stateDir) {
|
|
1011
|
+
let hashes = [];
|
|
1012
|
+
try {
|
|
1013
|
+
hashes = fs.readdirSync(path.join(stateDir, "sandbox", "skills-workspaces"), { withFileTypes: true })
|
|
1014
|
+
.filter((d) => d.isDirectory() && /^workspace-[0-9a-f]{16,}$/.test(d.name)).map((d) => d.name.slice("workspace-".length));
|
|
1015
|
+
} catch { return; }
|
|
1016
|
+
for (const hash of hashes) {
|
|
1017
|
+
const names = runQuiet("podman", ["ps", "-a", "--filter", `name=${hash}`, "--format", "{{.Names}}"]).stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
1018
|
+
for (const name of names) runQuiet("podman", ["rm", "-f", "-v", name]);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* OpenClaw's own provider login, for the vendors whose plugin wants one on
|
|
1024
|
+
* top of the vendor CLI's login (Claude doesn't: OpenClaw reuses the CLI
|
|
1025
|
+
* session directly). Only ever run when a probe or catalog lookup has
|
|
1026
|
+
* already shown it's needed -- never preemptively.
|
|
1027
|
+
*/
|
|
1028
|
+
function openclawProviderLogin(vendor) {
|
|
1029
|
+
const provider = vendor.credential.loginProvider ?? vendor.provider;
|
|
1030
|
+
console.log(c.dim(`Linking OpenClaw to it (\`openclaw models auth login --provider ${provider}\`) -- follow its prompts:`));
|
|
1031
|
+
const ok = runInteractive(openclawCmd(), ["models", "auth", "login", "--provider", provider]);
|
|
1032
|
+
runQuiet(openclawCmd(), ["plugins", "registry", "--refresh"]);
|
|
1033
|
+
return ok;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// --- `nomarmy agents`: every model a job can run on -------------------------
|
|
1037
|
+
//
|
|
1038
|
+
// One list in ~/.config/nomarmy/agents.yml (lib/agents.mjs): the local
|
|
1039
|
+
// model, api keys, and individual subscriptions. `add` walks through what
|
|
1040
|
+
// each kind needs -- a key registered with OpenClaw, or the vendor's own
|
|
1041
|
+
// login -- and proves it with a real test call before saving. Changes apply
|
|
1042
|
+
// to the next job; the MCP server re-reads the file when it changes.
|
|
1043
|
+
|
|
1044
|
+
function loadAgentsOrExit() {
|
|
1045
|
+
try { return loadAgents(globalConfigDir()); }
|
|
1046
|
+
catch (error) { failAgents(error); }
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function fileAgentsOrExit() {
|
|
1050
|
+
try { return readAgentsFile(globalConfigDir()); }
|
|
1051
|
+
catch (error) { failAgents(error); }
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function failAgents(error) {
|
|
1055
|
+
if (json) { out({ error: error.message, errors: error.errors ?? [], path: error.path ?? null }); process.exit(1); }
|
|
1056
|
+
console.error(c.red(error.errors?.length ? "That isn't a valid agents.yml:" : error.message));
|
|
1057
|
+
for (const line of error.errors ?? []) console.error(` - ${line}`);
|
|
1058
|
+
process.exit(1);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function saveAgents(agents) {
|
|
1062
|
+
try { return writeAgentsFile(globalConfigDir(), agents); }
|
|
1063
|
+
catch (error) { failAgents(error); }
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/** Which roles, in any army layer this repo sees, point at `name`. */
|
|
1067
|
+
function rolesUsingAgent(name) {
|
|
1068
|
+
try {
|
|
1069
|
+
const { army } = loadArmy({ projectDir: repoDir });
|
|
1070
|
+
return Object.entries(army.roles).filter(([, role]) => role.agent === name).map(([role]) => role);
|
|
1071
|
+
} catch {
|
|
1072
|
+
return [];
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function parseThinkingAnswer(answer, fallback) {
|
|
1077
|
+
const a = answer.trim().toLowerCase();
|
|
1078
|
+
if (!a) return fallback;
|
|
1079
|
+
if (THINKING_LEVELS.includes(a)) return a;
|
|
1080
|
+
return !(a === "n" || a === "no");
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* For each agent: the roles pointing at it in this repo's merged army
|
|
1085
|
+
* (with each role's model), and whether it's the General. Empty when the
|
|
1086
|
+
* army doesn't load -- `army show` reports why.
|
|
1087
|
+
*/
|
|
1088
|
+
function agentAssignments() {
|
|
1089
|
+
const byAgent = {};
|
|
1090
|
+
try {
|
|
1091
|
+
const { army } = loadArmy({ projectDir: repoDir });
|
|
1092
|
+
if (army.general) (byAgent[army.general] ??= { general: true, roles: [] }).general = true;
|
|
1093
|
+
for (const [role, r] of Object.entries(army.roles)) {
|
|
1094
|
+
if (!r.agent) continue;
|
|
1095
|
+
(byAgent[r.agent] ??= { general: false, roles: [] }).roles.push({ role, model: r.model ?? null });
|
|
1096
|
+
}
|
|
1097
|
+
} catch { /* army problems are army show's to report */ }
|
|
1098
|
+
return byAgent;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
async function cmdAgentsList() {
|
|
1102
|
+
const loaded = loadAgentsOrExit();
|
|
1103
|
+
const assigned = agentAssignments();
|
|
1104
|
+
if (json) return out({ found: loaded.found, path: loaded.path, agents: loaded.agents, assignments: assigned });
|
|
1105
|
+
console.log(c.bold("🍪 nomArmy agents") + c.dim(` (${loaded.found ? loaded.path : `no agents.yml yet; it will live at ${loaded.path}`})`));
|
|
1106
|
+
const width = Math.max(...Object.keys(loaded.agents).map((n) => n.length), 5) + 2;
|
|
1107
|
+
const inFile = fileAgentsOrExit();
|
|
1108
|
+
for (const [name, agent] of Object.entries(loaded.agents)) {
|
|
1109
|
+
const extra = agent.kind === "api" ? c.dim(` key: ${agent.auth_env}`) : !Object.prototype.hasOwnProperty.call(inFile, name) ? c.dim(" built in") : "";
|
|
1110
|
+
console.log(` ${c.cyan(name.padEnd(width))} ${describeAgentLabel(agent)}${extra}`);
|
|
1111
|
+
const a = assigned[name];
|
|
1112
|
+
const uses = [
|
|
1113
|
+
...(a?.general ? [c.bold("the General")] : []),
|
|
1114
|
+
...(a?.roles ?? []).map((r) => `${r.role}${r.model ? ` (${r.model === "auto" ? "auto" : r.model})` : agent.model ? ` (${agent.model})` : ""}`),
|
|
1115
|
+
];
|
|
1116
|
+
console.log(c.dim(` ${" ".repeat(width)} ${uses.length ? `used by: ${uses.join(", ")}` : "not used by any role in this repo"}`));
|
|
1117
|
+
}
|
|
1118
|
+
console.log(c.dim(`\nAdd one with \`nomarmy agents add\`. Give roles an agent with \`nomarmy army assign <role> <agent>\`, or dispatch with agent: "<name>".`));
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
async function cmdAgentsRemove() {
|
|
1122
|
+
const name = argv[2];
|
|
1123
|
+
if (!name) throw new Error("Usage: nomarmy agents remove <name>");
|
|
1124
|
+
const agents = fileAgentsOrExit();
|
|
1125
|
+
if (!Object.prototype.hasOwnProperty.call(agents, name)) {
|
|
1126
|
+
throw new Error(name === "local" ? "`local` is built in and can't be removed." : `Unknown agent "${name}". Your agents: ${Object.keys(loadAgentsOrExit().agents).join(", ")}`);
|
|
1127
|
+
}
|
|
1128
|
+
const usedBy = rolesUsingAgent(name);
|
|
1129
|
+
if (!json) {
|
|
1130
|
+
if (usedBy.length) console.log(c.yellow(`Roles using "${name}" in this repo: ${usedBy.join(", ")}. They'll be refused until you reassign them.`));
|
|
1131
|
+
const rl = createInterface({ input, output });
|
|
1132
|
+
try { if (!(await confirm(rl, `Remove agent "${name}"?`, { defaultYes: false }))) { console.log(c.dim("Canceled; nothing changed.")); return; } }
|
|
1133
|
+
finally { rl.close(); }
|
|
1134
|
+
}
|
|
1135
|
+
const next = { ...agents };
|
|
1136
|
+
delete next[name];
|
|
1137
|
+
saveAgents(next);
|
|
1138
|
+
if (json) return out({ removed: true, name, rolesStillUsingIt: usedBy });
|
|
1139
|
+
console.log(c.green(`✓ Removed agent "${name}".`));
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// --- add ---
|
|
1143
|
+
|
|
1144
|
+
async function cmdAgentsAdd() {
|
|
1145
|
+
if (json) return cmdAgentsAddJson();
|
|
1146
|
+
if (!process.stdin.isTTY) throw new Error("nomarmy agents add needs an interactive terminal (subscription logins open a browser), or --json with explicit flags (see `nomarmy help`).");
|
|
1147
|
+
const agents = fileAgentsOrExit();
|
|
1148
|
+
const rl = createInterface({ input, output });
|
|
1149
|
+
try {
|
|
1150
|
+
console.log(c.bold("🍪 nomArmy agents add"));
|
|
1151
|
+
let kind = argv[2];
|
|
1152
|
+
if (!AGENT_KINDS.includes(kind)) {
|
|
1153
|
+
console.log("\n" + c.bold("What kind of agent?"));
|
|
1154
|
+
console.log(` ${c.cyan("1.")} local ${c.dim("the local model on this machine (free, private)")}`);
|
|
1155
|
+
console.log(` ${c.cyan("2.")} api ${c.dim("a metered API key (xAI, OpenAI, Anthropic, DeepSeek, ...)")}`);
|
|
1156
|
+
console.log(` ${c.cyan("3.")} subscription ${c.dim("your own Claude, ChatGPT or Muse Code plan (never shared)")}`);
|
|
1157
|
+
kind = AGENT_KINDS[Number((await rl.question(c.bold("Choice: "))).trim()) - 1];
|
|
1158
|
+
if (!kind) throw new Error("Not a valid choice.");
|
|
1159
|
+
}
|
|
1160
|
+
if (kind === "local") return await addLocalAgent(rl, agents);
|
|
1161
|
+
if (kind === "api") return await addApiAgent(rl, agents);
|
|
1162
|
+
return await addSubscriptionAgent(rl, agents);
|
|
1163
|
+
} finally {
|
|
1164
|
+
rl.close();
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
async function askAgentName(rl, agents, fallback) {
|
|
1169
|
+
const name = await askUntilValid(rl, `Agent name [${fallback}]: `, {
|
|
1170
|
+
allowEmpty: true, fallback, pattern: ID_RE,
|
|
1171
|
+
invalidMessage: "must be 1-64 characters of letters, numbers, dot, underscore or hyphen.",
|
|
1172
|
+
});
|
|
1173
|
+
if (RESERVED_AGENT_NAMES.includes(name)) throw new Error(`"${name}" is a reserved name.`);
|
|
1174
|
+
if (Object.prototype.hasOwnProperty.call(agents, name) && !(await confirm(rl, `"${name}" already exists. Replace it?`, { defaultYes: false }))) return null;
|
|
1175
|
+
return name;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function savedAgentMessage(name, written) {
|
|
1179
|
+
console.log(c.green(`\n✓ Saved agent "${name}": ${describeAgentLabel(written[name])}.`));
|
|
1180
|
+
console.log(c.dim(`Use it with \`nomarmy army assign <role> ${name}\` or agent: "${name}" on a job. It applies to the next job, no restart.`));
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
async function addLocalAgent(rl, agents) {
|
|
1184
|
+
console.log(c.dim("`local` (the coder slot) is built in. Add another only to name the gpt slot (NOMARMY_WORKER_MODEL_FALLBACK)."));
|
|
1185
|
+
const slot = (await rl.question(c.bold("Slot, coder or gpt [gpt]: "))).trim() || "gpt";
|
|
1186
|
+
if (!["coder", "gpt"].includes(slot)) throw new Error("Slot must be coder or gpt.");
|
|
1187
|
+
const name = await askAgentName(rl, agents, slot === "gpt" ? "local-gpt" : "local");
|
|
1188
|
+
if (!name) { console.log(c.dim("Stopped; nothing was written.")); return; }
|
|
1189
|
+
savedAgentMessage(name, saveAgents({ ...agents, [name]: { kind: "local", slot } }));
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
async function addApiAgent(rl, agents) {
|
|
1193
|
+
console.log("\n" + c.bold("Which provider?"));
|
|
1194
|
+
API_PROVIDER_TYPES.forEach((t, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${KNOWN_PROVIDERS[t]?.label ?? t}`));
|
|
1195
|
+
const provider = API_PROVIDER_TYPES[Number((await rl.question(c.bold("Choice: "))).trim()) - 1];
|
|
1196
|
+
if (!provider) throw new Error("Not a valid choice.");
|
|
1197
|
+
const info = KNOWN_PROVIDERS[provider] ?? {};
|
|
1198
|
+
const agent = { kind: "api", provider };
|
|
1199
|
+
|
|
1200
|
+
if (provider === "openclaw") {
|
|
1201
|
+
console.log(c.dim("Any provider OpenClaw can talk to. Find its id with `openclaw models list --all`, or `openclaw plugins search <name>` if it needs a plugin."));
|
|
1202
|
+
agent.openclaw_provider = await askUntilValid(rl, "OpenClaw provider id (e.g. deepseek, mistral, groq): ", {
|
|
1203
|
+
pattern: OPENCLAW_PROVIDER_ID_RE, invalidMessage: "must be an OpenClaw provider id (lowercase letters, digits, dot, underscore, hyphen).",
|
|
1204
|
+
});
|
|
1205
|
+
const plugin = (await rl.question(c.bold("Plugin to install first, if it isn't built in (e.g. clawhub:@openclaw/deepseek-provider; blank = none): "))).trim();
|
|
1206
|
+
if (plugin) agent.plugin = plugin;
|
|
1207
|
+
}
|
|
1208
|
+
const name = await askAgentName(rl, agents, agent.openclaw_provider ?? (provider === "xai" ? "grok" : provider));
|
|
1209
|
+
if (!name) { console.log(c.dim("Stopped; nothing was written.")); return; }
|
|
1210
|
+
|
|
1211
|
+
const apiModel = (await rl.question(c.bold(`Default model, optional${info.defaultModel ? ` (e.g. ${info.defaultModel})` : ""}; blank = pick per role: `))).trim();
|
|
1212
|
+
if (apiModel) agent.model = apiModel;
|
|
1213
|
+
const envSuggestion = info.authEnvSuggestion ?? `NOMARMY_${(agent.openclaw_provider ?? name).toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
|
|
1214
|
+
// The variable's NAME, never the key: the key itself goes to OpenClaw's
|
|
1215
|
+
// own store over stdin (registerProviderWithOpenClaw) and never into
|
|
1216
|
+
// agents.yml or any other file nomArmy writes.
|
|
1217
|
+
agent.auth_env = await askUntilValid(rl, `Environment variable NAME for the API key, not the key itself [${envSuggestion}]: `, {
|
|
1218
|
+
allowEmpty: true, fallback: envSuggestion, pattern: AUTH_ENV_NAME_RE,
|
|
1219
|
+
invalidMessage: "must look like an ENVIRONMENT VARIABLE NAME (uppercase letters, digits, underscores) -- not the key itself.",
|
|
1220
|
+
});
|
|
1221
|
+
if (["bedrock", "azure-openai", "openai-compatible"].includes(provider)) {
|
|
1222
|
+
agent.base_url = (await rl.question(c.bold(`Base URL${info.baseUrlHint ? ` (e.g. ${info.baseUrlHint})` : ""}: `))).trim();
|
|
1223
|
+
if (!agent.base_url) throw new Error("A base URL is required for this provider.");
|
|
1224
|
+
}
|
|
1225
|
+
agent.thinking = parseThinkingAnswer(await rl.question(c.bold("Thinking: Y = follow the job's level (default), n = off, or low/medium/high to always use that: ")), true);
|
|
1226
|
+
const cw = (await rl.question(c.bold("Context window in tokens (blank = look it up from OpenClaw's catalog): "))).trim();
|
|
1227
|
+
if (cw) agent.context_window = Number(cw);
|
|
1228
|
+
|
|
1229
|
+
let apiKey = null;
|
|
1230
|
+
if (!process.env[agent.auth_env]) {
|
|
1231
|
+
console.log(c.yellow(`\n${agent.auth_env} isn't set in this shell.`));
|
|
1232
|
+
if (await confirm(rl, "Enter the key now instead? It's used once to register with OpenClaw and never saved to a file.", { defaultYes: true })) {
|
|
1233
|
+
apiKey = await askSecret(rl, c.bold(`${agent.auth_env}: `));
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
const written = saveAgents({ ...agents, [name]: agent });
|
|
1238
|
+
const saved = written[name];
|
|
1239
|
+
console.log(c.green(`\n✓ Saved agent "${name}": ${describeAgentLabel(saved)}.`));
|
|
1240
|
+
|
|
1241
|
+
console.log(`\n${c.bold("→")} Registering the key with OpenClaw`);
|
|
1242
|
+
const registered = registerProviderWithOpenClaw({ ...apiAgentAsPoolEntry(name, saved), apiKeyOverride: apiKey });
|
|
1243
|
+
if (registered && saved.model) {
|
|
1244
|
+
console.log(`\n${c.bold("→")} Test call`);
|
|
1245
|
+
const provId = saved.provider === "openclaw" ? saved.openclaw_provider : ["bedrock", "azure-openai", "openai-compatible"].includes(saved.provider) ? name : saved.provider;
|
|
1246
|
+
if (probeWorker(provId, saved.model)) console.log(c.green(`✓ ${provId}/${saved.model} answered a real test prompt.`));
|
|
1247
|
+
else console.log(c.yellow(`⚠ A real test prompt to ${provId}/${saved.model} didn't come back. Check the model id with \`openclaw models list --provider ${provId}\`.`));
|
|
1248
|
+
}
|
|
1249
|
+
// Dispatch only treats an api agent as usable when its auth_env is set
|
|
1250
|
+
// in the MCP server's own environment, which comes from its registration
|
|
1251
|
+
// (see derivePoolAuthEnvPlaceholders in lib/connect.mjs), so a new api
|
|
1252
|
+
// agent needs one reconnect. Every later edit applies without one.
|
|
1253
|
+
if (commandExists("claude") && await confirm(rl, `Reconnect the MCP server so it can use "${name}" (needed once for a new api agent)?`, { defaultYes: true })) {
|
|
1254
|
+
connectClaude({ nomarmyRoot, run: (cmd, args2, opts = {}) => execFileSync(cmd, args2, { stdio: "ignore", ...opts }), extraEnv: { [saved.auth_env]: "registered" } });
|
|
1255
|
+
console.log(c.green("✓ Reconnected.") + c.dim(" Restart your coordinator session once so it picks up the new registration."));
|
|
1256
|
+
} else {
|
|
1257
|
+
console.log(c.dim(`Run \`nomarmy connect claude\` before dispatching to "${name}".`));
|
|
1258
|
+
}
|
|
1259
|
+
console.log(c.dim(`Use it with \`nomarmy army assign <role> ${name}\` or agent: "${name}" on a job.`));
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const SUBSCRIPTION_AGENT_DEFAULT_NAMES = { claude: "claude", codex: "codex", meta: "muse" };
|
|
1263
|
+
|
|
1264
|
+
async function addSubscriptionAgent(rl, agents) {
|
|
1265
|
+
console.log(c.dim("Connects ONE person's own subscription. Never pooled, never shared."));
|
|
1266
|
+
const vendorKeys = Object.keys(SUBSCRIPTION_VENDORS);
|
|
1267
|
+
let vendorKey = argv[3];
|
|
1268
|
+
if (!SUBSCRIPTION_VENDORS[vendorKey]) {
|
|
1269
|
+
console.log("\n" + c.bold("Which subscription?"));
|
|
1270
|
+
vendorKeys.forEach((k, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${SUBSCRIPTION_VENDORS[k].label}`));
|
|
1271
|
+
vendorKey = vendorKeys[Number((await rl.question(c.bold("Choice: "))).trim()) - 1];
|
|
1272
|
+
if (!vendorKey) throw new Error(`Not a valid choice. Supported: ${vendorKeys.join(", ")}. DeepSeek and others without a plan are api agents.`);
|
|
1273
|
+
}
|
|
1274
|
+
const vendor = SUBSCRIPTION_VENDORS[vendorKey];
|
|
1275
|
+
|
|
1276
|
+
// Checked before any login: an api agent on the same OpenClaw provider id
|
|
1277
|
+
// would share the one credential slot, and the save would be refused
|
|
1278
|
+
// anyway -- don't walk the operator through a browser flow first.
|
|
1279
|
+
const clash = Object.entries(agents).find(([, a]) => a.kind === "api" && (a.provider === "openclaw" ? a.openclaw_provider : a.provider) === vendor.provider);
|
|
1280
|
+
if (clash) {
|
|
1281
|
+
console.log(c.red(`\n✗ Api agent "${clash[0]}" already uses OpenClaw provider "${vendor.provider}", and OpenClaw holds one credential per provider. Remove it first (\`nomarmy agents remove ${clash[0]}\`), or keep using it instead.`));
|
|
1282
|
+
return;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const auth = await ensureVendorAuth(rl, vendorKey);
|
|
1286
|
+
if (!auth.ok) { console.log(c.dim("\nStopped; nothing was written.")); return; }
|
|
1287
|
+
|
|
1288
|
+
console.log(`\n${c.bold("→")} Models`);
|
|
1289
|
+
const needsOpenclawLogin = vendor.credential.kind === "openclaw-login";
|
|
1290
|
+
let linkedOpenclaw = false;
|
|
1291
|
+
let models = catalogModelsFor(vendor.provider);
|
|
1292
|
+
if (!models.length && needsOpenclawLogin) {
|
|
1293
|
+
linkedOpenclaw = openclawProviderLogin(vendor);
|
|
1294
|
+
models = catalogModelsFor(vendor.provider);
|
|
1295
|
+
}
|
|
1296
|
+
// The agent is the account; the model is only a default. Roles pick
|
|
1297
|
+
// their own model (or "auto" for the General to choose per job).
|
|
1298
|
+
if (models.length) models.forEach((m, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${m}`));
|
|
1299
|
+
else console.log(c.dim(`OpenClaw isn't listing ${vendor.provider} models yet.`));
|
|
1300
|
+
const pick = (await rl.question(c.bold(`Default model, optional${models.length ? " (a number or an id)" : ""}; blank = pick per role: `))).trim();
|
|
1301
|
+
const model = /^\d+$/.test(pick) && models.length ? models[Number(pick) - 1] : pick || null;
|
|
1302
|
+
if (pick && !model) throw new Error(`Not a valid choice: "${pick}".`);
|
|
1303
|
+
// The test call needs some model; it proves the login, not the choice.
|
|
1304
|
+
const probeModel = model ?? models[0] ?? vendor.defaultModel;
|
|
1305
|
+
|
|
1306
|
+
const knownOwners = [...new Set(Object.values(agents).filter((a) => a.kind === "subscription").map((a) => a.owner))];
|
|
1307
|
+
const ownerDefault = auth.email ?? (knownOwners.length === 1 ? knownOwners[0] : "");
|
|
1308
|
+
const owner = (await rl.question(c.bold(`Whose subscription is this${ownerDefault ? ` [${ownerDefault}]` : ""}: `))).trim() || ownerDefault;
|
|
1309
|
+
if (!owner) throw new Error("An owner is required -- every job on this agent must name them in on_behalf_of.");
|
|
1310
|
+
|
|
1311
|
+
const name = await askAgentName(rl, agents, SUBSCRIPTION_AGENT_DEFAULT_NAMES[vendorKey] ?? vendorKey);
|
|
1312
|
+
if (!name) { console.log(c.dim("Stopped; nothing was written.")); return; }
|
|
1313
|
+
|
|
1314
|
+
console.log(`\n${c.bold("→")} Test call`);
|
|
1315
|
+
let works = probeModel ? probeWorker(vendor.provider, probeModel) : false;
|
|
1316
|
+
if (!works && needsOpenclawLogin && !linkedOpenclaw && probeModel) {
|
|
1317
|
+
openclawProviderLogin(vendor);
|
|
1318
|
+
works = probeWorker(vendor.provider, probeModel);
|
|
1319
|
+
}
|
|
1320
|
+
if (works) console.log(c.green(`✓ ${vendor.provider}/${probeModel} answered a real test prompt.`));
|
|
1321
|
+
else {
|
|
1322
|
+
console.log(c.red(probeModel ? `✗ A real test prompt to ${vendor.provider}/${probeModel} didn't come back.` : "✗ No model to make a test call with."));
|
|
1323
|
+
if (!(await confirm(rl, "Save the agent anyway?", { defaultYes: false }))) { console.log(c.dim("Stopped; nothing was written.")); return; }
|
|
1324
|
+
}
|
|
1325
|
+
const written = saveAgents({ ...agents, [name]: { kind: "subscription", provider: vendor.provider, owner, ...(model ? { model } : {}) } });
|
|
1326
|
+
savedAgentMessage(name, written);
|
|
1327
|
+
console.log(c.dim(`Jobs on it need on_behalf_of: "${owner}".`));
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
async function cmdAgentsAddJson() {
|
|
1331
|
+
const agents = fileAgentsOrExit();
|
|
1332
|
+
const name = value("name");
|
|
1333
|
+
const kind = value("kind") ?? (AGENT_KINDS.includes(argv[2]) ? argv[2] : null);
|
|
1334
|
+
if (!name || !kind) throw new Error(`--json requires --name <agent> and --kind <${AGENT_KINDS.join("|")}>, plus that kind's fields (see \`nomarmy help\`).`);
|
|
1335
|
+
if (RESERVED_AGENT_NAMES.includes(name)) throw new Error(`"${name}" is a reserved name.`);
|
|
1336
|
+
const agent = { kind };
|
|
1337
|
+
const num = (flagName) => (value(flagName) !== null ? Number(value(flagName)) : undefined);
|
|
1338
|
+
if (kind === "local") {
|
|
1339
|
+
agent.slot = value("slot") ?? "coder";
|
|
1340
|
+
} else {
|
|
1341
|
+
for (const [field, flagName] of [["provider", "provider"], ["model", "model"], ["owner", "owner"], ["auth_env", "auth-env"], ["base_url", "base-url"], ["openclaw_provider", "openclaw-provider"], ["plugin", "plugin"]]) {
|
|
1342
|
+
if (value(flagName) !== null) agent[field] = value(flagName);
|
|
1343
|
+
}
|
|
1344
|
+
for (const [field, flagName] of [["max_concurrent", "max-concurrent"], ["context_window", "context-window"]]) {
|
|
1345
|
+
if (num(flagName) !== undefined) agent[field] = num(flagName);
|
|
1346
|
+
}
|
|
1347
|
+
const thinking = resolveThinkingFlag();
|
|
1348
|
+
if (thinking !== undefined) agent.thinking = thinking;
|
|
1349
|
+
}
|
|
1350
|
+
const written = saveAgents({ ...agents, [name]: agent });
|
|
1351
|
+
const saved = written[name];
|
|
1352
|
+
let registered = null, mcpUpdated = false;
|
|
1353
|
+
if (kind === "api" && flag("register")) registered = registerProviderWithOpenClaw(apiAgentAsPoolEntry(name, saved));
|
|
1354
|
+
if (kind === "api" && flag("update-mcp")) {
|
|
1355
|
+
connectClaude({ nomarmyRoot, run: (cmd, args2, opts = {}) => execFileSync(cmd, args2, { stdio: "ignore", ...opts }), extraEnv: { [saved.auth_env]: "registered" } });
|
|
1356
|
+
mcpUpdated = true;
|
|
1357
|
+
}
|
|
1358
|
+
return out({ written: agentsConfigPath(globalConfigDir()), name, agent: saved, ...(kind === "api" ? { registered, mcpUpdated } : {}) });
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// --- update ---
|
|
1362
|
+
|
|
1363
|
+
// Kind, provider and owner are fixed: changing any of them is a different
|
|
1364
|
+
// agent (a different model family, vendor, or person's login), so that's
|
|
1365
|
+
// `agents add`, not an edit. A subscription model change gets the same real
|
|
1366
|
+
// test call `add` makes (interactive always; --json only with --probe,
|
|
1367
|
+
// since it spends a real request on the subscription).
|
|
1368
|
+
async function cmdAgentsUpdate() {
|
|
1369
|
+
const name = argv[2];
|
|
1370
|
+
if (!name) throw new Error("Usage: nomarmy agents update <name> [--model <m>|--no-model] [--slot coder|gpt] [--auth-env <NAME>] [--base-url <url>] [--max-concurrent <n>] [--context-window <tokens>] [--thinking [low|medium|high]|--no-thinking] [--probe]");
|
|
1371
|
+
const agents = fileAgentsOrExit();
|
|
1372
|
+
const current = Object.prototype.hasOwnProperty.call(agents, name) ? agents[name] : name === "local" ? { ...BUILTIN_LOCAL_AGENT } : undefined;
|
|
1373
|
+
if (!current) throw new Error(`Unknown agent "${name}". Your agents: ${Object.keys(loadAgentsOrExit().agents).join(", ")}`);
|
|
1374
|
+
|
|
1375
|
+
const changes = {};
|
|
1376
|
+
let probe = false;
|
|
1377
|
+
if (json) {
|
|
1378
|
+
const num = (flagName) => (value(flagName) !== null ? Number(value(flagName)) : undefined);
|
|
1379
|
+
if (value("model") !== null) changes.model = value("model");
|
|
1380
|
+
// Back to no default model: every role (or job) then names its own.
|
|
1381
|
+
if (flag("no-model")) { if (value("model") !== null) throw new Error("Pass --model or --no-model, not both."); changes.model = undefined; }
|
|
1382
|
+
if (value("slot") !== null) changes.slot = value("slot");
|
|
1383
|
+
if (value("auth-env") !== null) changes.auth_env = value("auth-env");
|
|
1384
|
+
if (value("base-url") !== null) changes.base_url = value("base-url");
|
|
1385
|
+
if (num("max-concurrent") !== undefined) changes.max_concurrent = num("max-concurrent");
|
|
1386
|
+
if (num("context-window") !== undefined) changes.context_window = num("context-window");
|
|
1387
|
+
const thinking = resolveThinkingFlag();
|
|
1388
|
+
if (thinking !== undefined) changes.thinking = thinking;
|
|
1389
|
+
if (flag("owner") || value("owner") !== null || value("provider") !== null || value("kind") !== null) {
|
|
1390
|
+
throw new Error("Kind, provider and owner can't be changed -- that's a different agent. Use `nomarmy agents add`.");
|
|
1391
|
+
}
|
|
1392
|
+
probe = flag("probe") && current.kind === "subscription";
|
|
1393
|
+
if (!Object.keys(changes).length) throw new Error("Nothing to update -- pass at least one field flag (see `nomarmy agents update` usage).");
|
|
1394
|
+
} else {
|
|
1395
|
+
if (!process.stdin.isTTY) throw new Error("nomarmy agents update needs an interactive terminal, or --json with explicit flags.");
|
|
1396
|
+
const rl = createInterface({ input, output });
|
|
1397
|
+
try {
|
|
1398
|
+
console.log(c.bold("🍪 nomArmy agents update") + c.dim(` (${name}: ${describeAgentLabel(current)})`));
|
|
1399
|
+
console.log(c.dim("Blank keeps the current value.\n"));
|
|
1400
|
+
if (current.kind === "local") {
|
|
1401
|
+
const slot = (await rl.question(c.bold(`Slot, coder or gpt [${current.slot}]: `))).trim();
|
|
1402
|
+
if (slot && slot !== current.slot) changes.slot = slot;
|
|
1403
|
+
} else {
|
|
1404
|
+
const provId = current.kind === "subscription" ? current.provider : current.provider === "openclaw" ? current.openclaw_provider : current.provider;
|
|
1405
|
+
const models = catalogModelsFor(provId);
|
|
1406
|
+
if (models.length) models.forEach((m, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${m}${m === current.model ? c.dim(" (current)") : ""}`));
|
|
1407
|
+
const pick = (await rl.question(c.bold(`Default model${models.length ? " -- a number, or type an id" : ""} [${current.model ?? "none: each role picks"}] ("-" clears it): `))).trim();
|
|
1408
|
+
if (pick === "-") { if (current.model) changes.model = undefined; }
|
|
1409
|
+
else {
|
|
1410
|
+
const chosen = /^\d+$/.test(pick) && models.length ? models[Number(pick) - 1] : pick;
|
|
1411
|
+
if (pick && !chosen) throw new Error(`Not a valid choice: "${pick}".`);
|
|
1412
|
+
if (chosen && chosen !== current.model) changes.model = chosen;
|
|
1413
|
+
}
|
|
1414
|
+
if (current.kind === "api") {
|
|
1415
|
+
const env = await askUntilValid(rl, `API key environment variable NAME [${current.auth_env}]: `, {
|
|
1416
|
+
allowEmpty: true, fallback: current.auth_env, pattern: AUTH_ENV_NAME_RE, invalidMessage: "must look like an ENVIRONMENT VARIABLE NAME, not the key itself.",
|
|
1417
|
+
});
|
|
1418
|
+
if (env !== current.auth_env) changes.auth_env = env;
|
|
1419
|
+
}
|
|
1420
|
+
const mc = (await rl.question(c.bold(`max_concurrent [${current.max_concurrent}]: `))).trim();
|
|
1421
|
+
if (mc) changes.max_concurrent = Number(mc);
|
|
1422
|
+
const label = current.thinking === false ? "off" : current.thinking === true ? "on, follows the job" : `fixed at "${current.thinking}"`;
|
|
1423
|
+
const thinking = parseThinkingAnswer(await rl.question(c.bold(`Thinking: y = follow the job, n = off, or low/medium/high [current: ${label}]: `)), current.thinking);
|
|
1424
|
+
if (thinking !== current.thinking) changes.thinking = thinking;
|
|
1425
|
+
if (changes.model && current.kind === "subscription") {
|
|
1426
|
+
console.log(`\n${c.bold("→")} Test call`);
|
|
1427
|
+
if (probeWorker(current.provider, changes.model)) console.log(c.green(`✓ ${current.provider}/${changes.model} answered a real test prompt.`));
|
|
1428
|
+
else {
|
|
1429
|
+
console.log(c.red(`✗ A real test prompt to ${current.provider}/${changes.model} didn't come back.`));
|
|
1430
|
+
if (!(await confirm(rl, "Save the change anyway?", { defaultYes: false }))) { console.log(c.dim("Stopped; nothing was written.")); return; }
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
} finally {
|
|
1435
|
+
rl.close();
|
|
1436
|
+
}
|
|
1437
|
+
if (!Object.keys(changes).length) { console.log(c.dim("\nNothing changed.")); return; }
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
if (probe && changes.model && !probeWorker(current.provider, changes.model)) {
|
|
1441
|
+
out({ error: `a real test prompt to ${current.provider}/${changes.model} didn't come back -- nothing was written` });
|
|
1442
|
+
process.exit(1);
|
|
1443
|
+
}
|
|
1444
|
+
const next = { ...current, ...changes };
|
|
1445
|
+
for (const [k, v] of Object.entries(next)) if (v === undefined) delete next[k];
|
|
1446
|
+
const written = saveAgents({ ...agents, [name]: next });
|
|
1447
|
+
if (json) return out({ updated: true, name, agent: written[name], changed: Object.keys(changes) });
|
|
1448
|
+
console.log(c.green(`\n✓ Updated "${name}": ${describeAgentLabel(written[name])}.`));
|
|
1449
|
+
if (changes.auth_env) console.log(c.yellow(`The key's variable changed to ${changes.auth_env}: run \`nomarmy connect claude\` so the MCP server sees it.`));
|
|
1450
|
+
else console.log(c.dim("Applies to the next job, no restart."));
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
async function cmdAgents() {
|
|
1454
|
+
const sub = argv[1] ?? "list";
|
|
1455
|
+
if (sub === "list") return cmdAgentsList();
|
|
1456
|
+
if (sub === "add") return cmdAgentsAdd();
|
|
1457
|
+
if (sub === "update") return cmdAgentsUpdate();
|
|
1458
|
+
if (sub === "remove") return cmdAgentsRemove();
|
|
1459
|
+
throw new Error(`Unknown agents subcommand "${sub}". Use: nomarmy agents <list|add|update|remove>`);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function git(args) {
|
|
1463
|
+
try { return execFileSync("git", args, { cwd: nomarmyRoot, encoding: "utf8" }).trim(); }
|
|
1464
|
+
catch (error) { throw new Error(`git ${args.join(" ")} failed: ${error.stderr ? String(error.stderr).trim() : error.message}`); }
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* `nomarmy update`: pull and apply the latest nomArmy code -- NOT a model
|
|
1469
|
+
* swap, see `nomarmy model` for that. Real motivation: the MCP server Claude
|
|
1470
|
+
* Code actually runs is a COPY (installMcpCopy, in lib/connect.mjs, copies
|
|
1471
|
+
* mcp/server.mjs + lib/ + package.json into
|
|
1472
|
+
* ~/.local/share/nomarmy-local-worker and registers that path), not this
|
|
1473
|
+
* checkout -- a bare `git pull` here changes nothing Claude Code is running
|
|
1474
|
+
* until that copy step reruns.
|
|
1475
|
+
*/
|
|
1476
|
+
async function cmdUpdate() {
|
|
1477
|
+
const say = (s) => { if (!json) console.log(s); };
|
|
1478
|
+
// Installed from npm: there's no checkout to pull. npm updates the
|
|
1479
|
+
// package; connect resyncs the copy each coordinator runs.
|
|
1480
|
+
if (!fs.existsSync(path.join(nomarmyRoot, ".git"))) {
|
|
1481
|
+
const how = "npm install -g nomarmy@alpha && nomarmy connect";
|
|
1482
|
+
if (json) return out({ error: "installed from npm, not a git checkout", fix: how });
|
|
1483
|
+
console.log(`This nomArmy was installed from npm, so there's nothing to pull. Update with:\n ${how}`);
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
const status = git(["status", "--porcelain"]);
|
|
1487
|
+
if (status) {
|
|
1488
|
+
if (json) { out({ error: "working tree is not clean; refusing to pull over local changes", status }); process.exit(1); }
|
|
1489
|
+
console.log(c.red("Working tree is not clean -- refusing to pull over local changes:"));
|
|
1490
|
+
console.log(status);
|
|
1491
|
+
process.exit(1);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
git(["fetch"]);
|
|
1495
|
+
const local = git(["rev-parse", "HEAD"]);
|
|
1496
|
+
const remote = git(["rev-parse", "@{u}"]);
|
|
1497
|
+
const base = git(["merge-base", "HEAD", "@{u}"]);
|
|
1498
|
+
if (local === remote) {
|
|
1499
|
+
if (json) return out({ updated: false, reason: "already up to date" });
|
|
1500
|
+
console.log(c.green("✓ Already up to date."));
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
if (base !== local) {
|
|
1504
|
+
if (json) { out({ error: "local branch has diverged from upstream; not a clean fast-forward", local, remote, base }); process.exit(1); }
|
|
1505
|
+
console.log(c.red("Local branch has diverged from upstream -- not a clean fast-forward. Resolve by hand (rebase or merge), then rerun.")); process.exit(1);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
say(c.bold("🍪 nomArmy update\n"));
|
|
1509
|
+
say("Pulling...");
|
|
1510
|
+
git(["merge", "--ff-only", "@{u}"]);
|
|
1511
|
+
say(c.green(`✓ Pulled to ${git(["rev-parse", "--short", "HEAD"])}.`));
|
|
1512
|
+
|
|
1513
|
+
say("\nInstalling dependencies...");
|
|
1514
|
+
execFileSync("npm", ["install", "--omit=dev", "--no-audit", "--no-fund"], { cwd: nomarmyRoot, stdio: json ? "ignore" : "inherit" });
|
|
1515
|
+
|
|
1516
|
+
const resynced = [];
|
|
1517
|
+
const runInherit = (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: json ? "ignore" : "inherit", ...opts });
|
|
1518
|
+
if (commandExists("claude")) {
|
|
1519
|
+
say("\nRe-syncing the Claude Code MCP install...");
|
|
1520
|
+
connectClaude({ nomarmyRoot, run: runInherit });
|
|
1521
|
+
resynced.push("claude");
|
|
1522
|
+
}
|
|
1523
|
+
if (commandExists("codex")) {
|
|
1524
|
+
say("\nRe-syncing the Codex MCP install...");
|
|
1525
|
+
connectCodex({ nomarmyRoot, run: runInherit });
|
|
1526
|
+
resynced.push("codex");
|
|
1527
|
+
}
|
|
1528
|
+
// Cursor has no CLI/PATH binary to probe with commandExists -- "already
|
|
1529
|
+
// connected" is read from its own config file instead.
|
|
1530
|
+
if (cursorAlreadyConnected()) {
|
|
1531
|
+
say("\nRe-syncing the Cursor MCP install...");
|
|
1532
|
+
connectCursor({ nomarmyRoot, run: runInherit });
|
|
1533
|
+
resynced.push("cursor");
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
if (json) return out({ updated: true, sha: git(["rev-parse", "HEAD"]), resynced });
|
|
1537
|
+
console.log(c.yellow("\nThe MCP server is a per-session child process: every open Claude Code / Codex / Cursor session needs a restart to pick this up, not just this one."));
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
function commandExists(cmd) {
|
|
1541
|
+
try { execFileSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore" }); return true; }
|
|
1542
|
+
catch { return false; }
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/** A dependency-free multi-select: numbered checklist, comma-separated
|
|
1546
|
+
* answer, matching the plain-readline style already used elsewhere in this
|
|
1547
|
+
* CLI (setup's numbered "Choice [1]:" prompts) rather than pulling in an
|
|
1548
|
+
* arrow-key TUI library for one prompt. */
|
|
1549
|
+
async function promptMultiSelect(options, question) {
|
|
1550
|
+
const rl = createInterface({ input, output });
|
|
1551
|
+
try {
|
|
1552
|
+
console.log(c.bold(question));
|
|
1553
|
+
options.forEach((opt, i) => console.log(` ${i + 1}. ${opt}`));
|
|
1554
|
+
const answer = (await rl.question(c.bold('Choice(s) [comma-separated numbers, or "all"]: '))).trim().toLowerCase();
|
|
1555
|
+
if (!answer) return [];
|
|
1556
|
+
if (answer === "all") return [...options];
|
|
1557
|
+
const picked = new Set();
|
|
1558
|
+
for (const token of answer.split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
1559
|
+
const idx = Number(token);
|
|
1560
|
+
if (Number.isInteger(idx) && idx >= 1 && idx <= options.length) picked.add(options[idx - 1]);
|
|
1561
|
+
}
|
|
1562
|
+
return [...picked];
|
|
1563
|
+
} finally {
|
|
1564
|
+
rl.close();
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/**
|
|
1569
|
+
* `nomarmy connect [claude] [codex] [cursor]`: (re-)register the MCP server
|
|
1570
|
+
* with one or more coordinators on its own, without a full `update`. Useful
|
|
1571
|
+
* standalone -- e.g. a coordinator installed *after* nomArmy already was --
|
|
1572
|
+
* not only as an update step. With no target named and not --json, prompts
|
|
1573
|
+
* an interactive multi-select instead of requiring one call per target.
|
|
1574
|
+
*/
|
|
1575
|
+
async function cmdConnect() {
|
|
1576
|
+
// Positional, but never a fixed argv index: every other command in this
|
|
1577
|
+
// CLI is position-independent with respect to global flags (flag()/value()
|
|
1578
|
+
// scan the whole argv), and `nomarmy connect --json claude` once broke
|
|
1579
|
+
// that promise by reading argv[1] directly -- --json landed in target's
|
|
1580
|
+
// slot instead. Multiple bare tokens are now allowed too, for multi-select.
|
|
1581
|
+
const requested = argv.slice(1).filter((a) => !a.startsWith("--"));
|
|
1582
|
+
let targets;
|
|
1583
|
+
if (requested.length > 0) {
|
|
1584
|
+
const unknown = requested.filter((t) => !KNOWN_TARGETS.includes(t));
|
|
1585
|
+
if (unknown.length) throw new Error(`unknown target(s) ${unknown.join(", ")}. Known: ${KNOWN_TARGETS.join(", ")}.`);
|
|
1586
|
+
targets = [...new Set(requested)];
|
|
1587
|
+
} else if (json) {
|
|
1588
|
+
throw new Error(`nomarmy connect --json needs at least one target: ${KNOWN_TARGETS.join(", ")}.`);
|
|
1589
|
+
} else {
|
|
1590
|
+
targets = await promptMultiSelect(KNOWN_TARGETS, "🍪 Which coordinator(s) should nomArmy register with?");
|
|
1591
|
+
if (targets.length === 0) { console.log("Nothing selected."); return; }
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
const run = (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: json ? "ignore" : "inherit", ...opts });
|
|
1595
|
+
const results = [];
|
|
1596
|
+
for (const target of targets) {
|
|
1597
|
+
// Cursor is a JSON file, not a CLI on PATH -- nothing to probe there.
|
|
1598
|
+
if (target !== "cursor" && !commandExists(target)) {
|
|
1599
|
+
const error = `${target} was not found on PATH.`;
|
|
1600
|
+
results.push({ target, connected: false, error });
|
|
1601
|
+
if (!json) console.log(c.red(`✗ ${target}: ${error}`));
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1604
|
+
try {
|
|
1605
|
+
if (!json) console.log(c.bold(`\n🍪 Connecting nomArmy to ${target}...`));
|
|
1606
|
+
const result = connectTarget(target, { nomarmyRoot, run });
|
|
1607
|
+
results.push({ target, connected: true, ...result });
|
|
1608
|
+
if (!json) console.log(c.green(`✓ Registered nomarmy-local-worker with ${target}.`));
|
|
1609
|
+
if (!json && result?.commands?.installed?.length) console.log(c.green(`✓ Playbooks: ${result.commands.installed.join(", ")}`) + c.dim(` in ${result.commands.dir} (restart ${target} to pick up a new one)`));
|
|
1610
|
+
if (!json && result?.notifier?.status === "built") console.log(c.green("✓ Notifications: nomArmy.app, with nomArmy's icon") + c.dim(" (macOS asks once whether to allow it)"));
|
|
1611
|
+
if (!json && result?.notifier?.status === "failed") console.log(c.yellow(`⚠ Couldn't build nomArmy.app (${result.notifier.reason}); notifications still work, with Script Editor's icon. Xcode's command-line tools provide swiftc: xcode-select --install`));
|
|
1612
|
+
if (!json && result?.statusLine === "installed") console.log(c.green("✓ Claude Code status line: nomArmy's") + c.dim(" (shows running jobs and the active run; restart Claude Code to see it)"));
|
|
1613
|
+
if (!json && result?.statusLine === "kept-yours") console.log(c.dim("Kept your own Claude Code status line. To add nomArmy's to it, have your command also run `nomarmy statusline`."));
|
|
1614
|
+
if (!json && result?.commands?.skipped?.length) console.log(c.yellow(`⚠ Left your own ${result.commands.skipped.join(", ")} in ${result.commands.dir} alone (not nomArmy's); nomArmy's version is in playbooks/.`));
|
|
1615
|
+
} catch (error) {
|
|
1616
|
+
results.push({ target, connected: false, error: error.message });
|
|
1617
|
+
if (!json) console.log(c.red(`✗ ${target}: ${error.message}`));
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
const failed = results.filter((r) => !r.connected);
|
|
1622
|
+
if (failed.length) process.exitCode = 1;
|
|
1623
|
+
if (json) return out({ results });
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
/** Thin, mechanical wrappers around already-working scripts -- unlike connect's port to JS, these are real bash process/PID management with no awkward Node-calling-Node seam to fix, so spawning them is the right amount of wrapping, not under- or over-engineering it. */
|
|
1627
|
+
function runScript(name, args = []) {
|
|
1628
|
+
execFileSync("bash", [path.join(nomarmyRoot, "scripts", name), ...args], { cwd: nomarmyRoot, stdio: "inherit" });
|
|
1629
|
+
}
|
|
1630
|
+
async function cmdStart() { console.log(c.bold("🍪 Starting inference...\n")); runScript("start-inference.sh", argv.slice(1)); }
|
|
1631
|
+
async function cmdStop() { runScript("stop-inference.sh", argv.slice(1)); }
|
|
1632
|
+
function safeDu(dir) {
|
|
1633
|
+
try { return execFileSync("du", ["-sh", dir], { encoding: "utf8" }).trim().split(/\s+/)[0]; }
|
|
1634
|
+
catch { return "unknown size"; }
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/** The llama.cpp build + job/log records live here, separate from the small
|
|
1638
|
+
* MCP install dir uninstall.sh already removes -- see install-llama-cpp.sh
|
|
1639
|
+
* and start-inference.sh, which both default to this same path. */
|
|
1640
|
+
function agentsDirDefault() {
|
|
1641
|
+
return process.env.NOMARMY_INSTALL_ROOT
|
|
1642
|
+
|| path.join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".local", "share", "nomarmy-local-agents");
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
async function confirmDestructive(question, force) {
|
|
1646
|
+
if (force) return true;
|
|
1647
|
+
const rl = createInterface({ input, output });
|
|
1648
|
+
try {
|
|
1649
|
+
const answer = (await rl.question(c.bold(question))).trim().toLowerCase();
|
|
1650
|
+
return answer === "y" || answer === "yes";
|
|
1651
|
+
} finally {
|
|
1652
|
+
rl.close();
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
async function maybeRemoveAgentsDir({ force }) {
|
|
1657
|
+
const dir = agentsDirDefault();
|
|
1658
|
+
if (!fs.existsSync(dir)) return null;
|
|
1659
|
+
const size = safeDu(dir);
|
|
1660
|
+
const ok = await confirmDestructive(
|
|
1661
|
+
`\nAlso remove ${dir} (${size}) -- job records, logs, and the built llama.cpp binary? A fresh install will need to rebuild it. [y/N] `,
|
|
1662
|
+
force,
|
|
1663
|
+
);
|
|
1664
|
+
if (!ok) { console.log(" Skipped -- left in place."); return false; }
|
|
1665
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1666
|
+
console.log(c.green(` Removed ${dir} (${size}).`));
|
|
1667
|
+
return true;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
/** Only the repo(s) nomArmy's OWN config declares -- never a blanket sweep
|
|
1671
|
+
* of ~/.cache/huggingface/hub, which is shared with any other tool using
|
|
1672
|
+
* huggingface_hub's standard cache. A model tested via a one-off
|
|
1673
|
+
* NOMARMY_MODEL_REPO override (never written to config/common.env, the way
|
|
1674
|
+
* most of tonight's model comparisons were run) is not tracked here and
|
|
1675
|
+
* needs manual cleanup -- an honest, bounded scope beats guessing at which
|
|
1676
|
+
* cache entries are "ours". */
|
|
1677
|
+
function resolveConfiguredModelRepos() {
|
|
1678
|
+
const repo = readEnvValue(path.join(nomarmyRoot, "config", "common.env"), "NOMARMY_MODEL_REPO");
|
|
1679
|
+
return repo ? [repo] : [];
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
function hfCacheDirFor(repo) {
|
|
1683
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? ".";
|
|
1684
|
+
return path.join(home, ".cache", "huggingface", "hub", `models--${repo.replace(/\//g, "--")}`);
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
async function maybeRemoveModelCaches({ force }) {
|
|
1688
|
+
const removed = [];
|
|
1689
|
+
for (const repo of resolveConfiguredModelRepos()) {
|
|
1690
|
+
const dir = hfCacheDirFor(repo);
|
|
1691
|
+
if (!fs.existsSync(dir)) continue;
|
|
1692
|
+
const size = safeDu(dir);
|
|
1693
|
+
const ok = await confirmDestructive(`Also remove cached model ${repo} (${size}) from ~/.cache/huggingface/hub? [y/N] `, force);
|
|
1694
|
+
if (!ok) { console.log(` Skipped ${repo} -- left in place.`); continue; }
|
|
1695
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
1696
|
+
console.log(c.green(` Removed cached model ${repo} (${size}).`));
|
|
1697
|
+
removed.push(repo);
|
|
1698
|
+
}
|
|
1699
|
+
if (!removed.length) {
|
|
1700
|
+
console.log(" No configured model repo found cached locally, or it was skipped above. Note: models tested via a manual NOMARMY_MODEL_REPO override, never saved to config/common.env, are not tracked here and need manual cleanup.");
|
|
1701
|
+
}
|
|
1702
|
+
return removed;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
async function cmdUninstall() {
|
|
1706
|
+
const clearModels = flag("clear-models") || flag("all");
|
|
1707
|
+
const clearAgents = flag("clear-agents") || flag("all");
|
|
1708
|
+
const force = flag("force");
|
|
1709
|
+
|
|
1710
|
+
if ((clearModels || clearAgents) && json && !force) {
|
|
1711
|
+
throw new Error("--clear-models/--clear-agents with --json needs --force -- these delete real, possibly multi-GB local state, and nothing is removed without explicit confirmation.");
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
if (!json) console.log(c.yellow("Removing the nomArmy local worker MCP installation...\n"));
|
|
1715
|
+
runScript("uninstall.sh");
|
|
1716
|
+
|
|
1717
|
+
const result = { uninstalled: true, agentsRemoved: null, modelsRemoved: [] };
|
|
1718
|
+
if (clearAgents) result.agentsRemoved = await maybeRemoveAgentsDir({ force });
|
|
1719
|
+
if (clearModels) result.modelsRemoved = await maybeRemoveModelCaches({ force });
|
|
1720
|
+
|
|
1721
|
+
if (json) return out(result);
|
|
1722
|
+
console.log(c.green("\n✓ Removed."));
|
|
1723
|
+
if (!clearAgents) console.log(" Job records, logs, and the built llama.cpp binary under ~/.local/share/nomarmy-local-agents were kept -- pass --clear-agents to also remove them.");
|
|
1724
|
+
if (!clearModels) console.log(" Downloaded model files under ~/.cache/huggingface/hub were kept -- pass --clear-models to also remove the one(s) nomArmy's config references.");
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
async function cmdSizing() {
|
|
1728
|
+
const execution = value("execution", process.env.NOMARMY_EXECUTION || "local");
|
|
1729
|
+
const hardware = await detectHardware();
|
|
1730
|
+
const modelPath = findModel();
|
|
1731
|
+
const gguf = modelPath ? await readGGUFMetadata(modelPath) : { found: false };
|
|
1732
|
+
if (gguf.found) gguf.fileSizeBytes = totalSplitBytes(modelPath);
|
|
1733
|
+
|
|
1734
|
+
if (flag("check")) return sizingCheck(hardware, gguf);
|
|
1735
|
+
|
|
1736
|
+
// Optional: if NOMARMY_LLAMA_CACHE_TYPE_K/V are set (quantizing the KV
|
|
1737
|
+
// cache to fit more context), reflect that in the estimate instead of
|
|
1738
|
+
// silently assuming fp16. Unset by default -- nothing changes for anyone
|
|
1739
|
+
// who hasn't touched these.
|
|
1740
|
+
const bytesPerKvElement = bytesPerKvElementForCacheTypes(
|
|
1741
|
+
process.env.NOMARMY_LLAMA_CACHE_TYPE_K, process.env.NOMARMY_LLAMA_CACHE_TYPE_V);
|
|
1742
|
+
|
|
1743
|
+
// --noms N: size for an EXACT worker count instead of "more noms" (max
|
|
1744
|
+
// that fits) or "nominal" (fixed at 1). Bypasses the rest of the report
|
|
1745
|
+
// entirely -- scriptable, and works in --json too.
|
|
1746
|
+
const nomsFlag = value("noms");
|
|
1747
|
+
if (nomsFlag !== null) {
|
|
1748
|
+
const noms = Number(nomsFlag);
|
|
1749
|
+
if (!Number.isFinite(noms) || noms < 1) throw new Error(`--noms must be a positive number, got "${nomsFlag}".`);
|
|
1750
|
+
const custom = customRecommendation({ hardware, gguf, execution, noms, bytesPerKvElement });
|
|
1751
|
+
if (json) return out({ hardware, gguf: { found: gguf.found, path: gguf.path ?? null }, recommendation: custom });
|
|
1752
|
+
printHardwareAndModel(hardware, gguf);
|
|
1753
|
+
printCustomResult(custom);
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
const res = recommend({ hardware, gguf, execution, bytesPerKvElement });
|
|
1758
|
+
if (json) {
|
|
1759
|
+
return out({ hardware, gguf: { found: gguf.found, path: gguf.path ?? null }, recommendation: res });
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
if (res.kind === "cloud") {
|
|
1763
|
+
console.log(`Execution is '${execution}' - hosted inference, so local hardware does not bound this.\n`);
|
|
1764
|
+
console.log(` NOMARMY_MAX_WORKERS=${res.maxWorkers}`);
|
|
1765
|
+
console.log(`\nBounded by ${res.limitedBy}, not by this machine.`);
|
|
1766
|
+
printWarnings(res.warnings);
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
printHardwareAndModel(hardware, gguf);
|
|
1771
|
+
|
|
1772
|
+
// Never present a configuration as "recommended" when the arithmetic says it
|
|
1773
|
+
// does not fit. The fallback is a floor to start from, not an endorsement.
|
|
1774
|
+
// fits is a tri-state (true/false/null): null means hardware was totally
|
|
1775
|
+
// unmeasurable, not that it fits -- `null !== false` must not fall through
|
|
1776
|
+
// to the "measured, fits" message, or an unmeasured machine gets the exact
|
|
1777
|
+
// same confident framing as a real recommendation.
|
|
1778
|
+
const doesNotFit = res.memory && res.memory.fits === false;
|
|
1779
|
+
const unmeasured = res.memory && res.memory.fits === null;
|
|
1780
|
+
console.log(doesNotFit
|
|
1781
|
+
? `\nNOTHING FITS on this machine. Closest fallback (confidence: ${res.confidence}):\n`
|
|
1782
|
+
: unmeasured
|
|
1783
|
+
? `\nHARDWARE COULD NOT BE MEASURED. This is an unverified floor, not a recommendation (confidence: ${res.confidence}):\n`
|
|
1784
|
+
: `\nMore noms (confidence: ${res.confidence}) -- as many as fit in memory:\n`);
|
|
1785
|
+
for (const [k, v] of Object.entries(res.env ?? {})) console.log(` ${k}=${v}`);
|
|
1786
|
+
console.log(`\n ${res.maxWorkers} nom(s) at ${K(res.contextPerNom)} each`
|
|
1787
|
+
+ (res.contextTotal ? ` (${res.contextTotal} total across ${res.llamaParallel} slot(s))` : ""));
|
|
1788
|
+
if (res.limitedBy) console.log(` limited by: ${res.limitedBy}`);
|
|
1789
|
+
|
|
1790
|
+
// "More noms" answers what fits in memory; it has no model of inference
|
|
1791
|
+
// speed or worker contention at all. Every profile actually shipped in
|
|
1792
|
+
// config/profiles/*.env uses 1-2 workers regardless of how much more
|
|
1793
|
+
// would fit -- "nominal" makes that convention explicit rather than
|
|
1794
|
+
// leaving it as something you only learn by reading the README's
|
|
1795
|
+
// benchmarks. No third "fast" tier: worker count is the only
|
|
1796
|
+
// speed-relevant lever this project has real (measured) data for, and it
|
|
1797
|
+
// collapses to the same thing as nominal.
|
|
1798
|
+
if (res.nominal && !res.nominal.sameAsRecommended) {
|
|
1799
|
+
console.log(`\nNominal -- 1 nom, matching this project's own shipped profiles${res.nominal.fits ? "" : " (DOES NOT FIT either -- nothing on this machine does)"}:\n`);
|
|
1800
|
+
for (const [k, v] of Object.entries(res.nominal.env)) console.log(` ${k}=${v}`);
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
if (res.alternatives?.length) {
|
|
1804
|
+
console.log("\nAlternatives:");
|
|
1805
|
+
for (const a of res.alternatives) {
|
|
1806
|
+
const label = a.label ?? `${a.maxWorkers} nom(s) @ ${K(a.contextPerNom)}`;
|
|
1807
|
+
console.log(` ${String(label).padEnd(26)}${a.fits === false ? "does not fit" : "fits"}`);
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
printWarnings(res.warnings);
|
|
1811
|
+
if (res.assumptions?.length) {
|
|
1812
|
+
console.log("\nAssumptions:");
|
|
1813
|
+
for (const a of res.assumptions) console.log(` ${a}`);
|
|
1814
|
+
}
|
|
1815
|
+
// Context bounds text, so show what a nom at this context can be asked and
|
|
1816
|
+
// how much it can say back. Memory pressure is checked at job admission by
|
|
1817
|
+
// the MCP server, not here.
|
|
1818
|
+
const { deriveBudgets, describeBudgets } = await import("../lib/budget.mjs");
|
|
1819
|
+
console.log("\nWorker budgets at this context:");
|
|
1820
|
+
for (const line of describeBudgets(deriveBudgets({ contextPerNom: res.contextPerNom, source: "this recommendation" }))) console.log(` ${line}`);
|
|
1821
|
+
console.log("\nThis is a recommendation. Apply it by editing config/profiles/<profile>.env.");
|
|
1822
|
+
|
|
1823
|
+
// "More noms"/"nominal" are both already fully printed above; the one
|
|
1824
|
+
// thing this command couldn't answer without a re-run was "what about N
|
|
1825
|
+
// workers specifically". Skipped entirely for cloud (no local slots to
|
|
1826
|
+
// size) and whenever stdin isn't interactive -- readline resolves an
|
|
1827
|
+
// unanswerable question with "" on EOF, so this degrades safely under a
|
|
1828
|
+
// pipe or in CI rather than hanging.
|
|
1829
|
+
if (res.kind === "local") {
|
|
1830
|
+
const rl = createInterface({ input, output });
|
|
1831
|
+
let answer;
|
|
1832
|
+
try {
|
|
1833
|
+
answer = (await rl.question(c.bold("\nSize for a specific worker count instead? Enter a number, or press Enter to skip: "))).trim();
|
|
1834
|
+
} finally {
|
|
1835
|
+
rl.close();
|
|
1836
|
+
}
|
|
1837
|
+
if (answer) {
|
|
1838
|
+
const noms = Number(answer);
|
|
1839
|
+
if (!Number.isFinite(noms) || noms < 1) console.log(`Not a positive number: "${answer}". Skipped.`);
|
|
1840
|
+
else printCustomResult(customRecommendation({ hardware, gguf, execution, noms, bytesPerKvElement }));
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
function printHardwareAndModel(hardware, gguf) {
|
|
1846
|
+
console.log(`Hardware: ${hardware.platform}/${hardware.arch}`
|
|
1847
|
+
+ `, ${hardware.cpu?.logicalCores ?? "?"} logical cores`
|
|
1848
|
+
+ `, ${gib(hardware.memory?.totalBytes)} RAM`
|
|
1849
|
+
+ (hardware.gpu?.count ? `, ${hardware.gpu.count} GPU` : ", no NVIDIA GPU"));
|
|
1850
|
+
console.log(`Model: ${gguf.found
|
|
1851
|
+
? `${path.basename(gguf.path)} (${gib(gguf.fileSizeBytes)})`
|
|
1852
|
+
: "not found - using assumed architecture"}`);
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
function printCustomResult(res) {
|
|
1856
|
+
if (res.kind === "cloud") {
|
|
1857
|
+
console.log(`\nCustom -- ${res.requestedNoms} worker(s), hosted execution (no local memory ceiling):\n`);
|
|
1858
|
+
for (const [k, v] of Object.entries(res.env)) console.log(` ${k}=${v}`);
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
if (!res.fits) {
|
|
1862
|
+
console.log(`\nCustom -- ${res.requestedNoms} worker(s) DOES NOT FIT on this machine, even at the minimum context (${K(MIN_CONTEXT_PER_NOM)}).`);
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
console.log(`\nCustom -- ${res.requestedNoms} worker(s) as requested`
|
|
1866
|
+
+ (res.steppedDownFrom ? `, context stepped down from ${K(res.steppedDownFrom)} to fit` : "") + ":\n");
|
|
1867
|
+
for (const [k, v] of Object.entries(res.env)) console.log(` ${k}=${v}`);
|
|
1868
|
+
console.log(`\n ${res.requestedNoms} nom(s) at ${K(res.contextPerNom)} each (${res.contextTotal} total across ${res.llamaParallel} slot(s))`);
|
|
1869
|
+
if (res.limitedBy) console.log(` limited by: ${res.limitedBy}`);
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
function sizingCheck(hardware, gguf) {
|
|
1873
|
+
const contextTotal = Number(process.env.NOMARMY_LLAMA_CONTEXT) || null;
|
|
1874
|
+
const llamaParallel = Number(process.env.NOMARMY_LLAMA_PARALLEL) || null;
|
|
1875
|
+
const maxWorkers = Number(process.env.NOMARMY_MAX_WORKERS) || null;
|
|
1876
|
+
if (!contextTotal || !llamaParallel) {
|
|
1877
|
+
console.error("No profile is loaded. Source one first, for example:");
|
|
1878
|
+
console.error(" source scripts/lib.sh && load_profile macbook-pro && nomarmy sizing --check");
|
|
1879
|
+
process.exit(2);
|
|
1880
|
+
}
|
|
1881
|
+
const res = evaluateConfig({ hardware, gguf, contextTotal, llamaParallel, maxWorkers });
|
|
1882
|
+
if (json) return out(res);
|
|
1883
|
+
console.log(`Current profile: ${contextTotal} total context / ${llamaParallel} slot(s) / ${maxWorkers} worker(s)\n`);
|
|
1884
|
+
console.log(` context per nom: ${K(res.contextPerNom)}`);
|
|
1885
|
+
printWarnings(res.warnings);
|
|
1886
|
+
process.exit((res.warnings ?? []).some((w) => w.severity === "error") ? 1 : 0);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// --- `nomarmy army` / `nomarmy config` ------------------------------------
|
|
1890
|
+
//
|
|
1891
|
+
// The army layers (see lib/army.mjs) are global config.yml, the repo's
|
|
1892
|
+
// .nomarmy.yml and its gitignored .nomarmy.local.yml. `--repo` picks the
|
|
1893
|
+
// repo, same as every other command here; it defaults to the cwd.
|
|
1894
|
+
|
|
1895
|
+
function armyLayerFlag(fallback = "global") {
|
|
1896
|
+
const chosen = ["global", "project", "local"].filter((l) => flag(l));
|
|
1897
|
+
if (chosen.length > 1) throw new Error(`Pick one of --global, --project or --local, not ${chosen.map((l) => `--${l}`).join(" and ")}.`);
|
|
1898
|
+
return chosen[0] ?? fallback;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
function loadArmyForCli() {
|
|
1902
|
+
const agents = loadAgentsOrExit().agents;
|
|
1903
|
+
const loaded = loadArmy({ projectDir: repoDir });
|
|
1904
|
+
return { loaded, agents, summary: describeArmy(loaded, { agents, describeAgent: describeAgentLabel }) };
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// Claude Code adds settings.local.json to .gitignore for the same reason:
|
|
1908
|
+
// the local layer is personal, and a teammate's checkout must never pick it up.
|
|
1909
|
+
function ensureLocalLayerIgnored() {
|
|
1910
|
+
if (!fs.existsSync(path.join(repoDir, ".git"))) return;
|
|
1911
|
+
const ignorePath = path.join(repoDir, ".gitignore");
|
|
1912
|
+
const text = fs.existsSync(ignorePath) ? fs.readFileSync(ignorePath, "utf8") : "";
|
|
1913
|
+
if (text.split(/\r?\n/).some((line) => line.trim() === LOCAL_CONFIG_FILENAME || line.trim() === `/${LOCAL_CONFIG_FILENAME}`)) return;
|
|
1914
|
+
fs.writeFileSync(ignorePath, `${text}${text && !text.endsWith("\n") ? "\n" : ""}${LOCAL_CONFIG_FILENAME}\n`);
|
|
1915
|
+
if (!json) console.log(c.dim(`Added ${LOCAL_CONFIG_FILENAME} to .gitignore.`));
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
function agentCell(name, runsOn, role = null) {
|
|
1919
|
+
if (!name) return c.yellow("(no agent)");
|
|
1920
|
+
const model = role?.modelIsAuto ? "auto (the General picks)" : role?.model;
|
|
1921
|
+
return `${name}${model ? ` ${c.bold(model)}` : ""}${runsOn ? c.dim(` ${runsOn}`) : ""}`;
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
async function cmdArmyShow() {
|
|
1925
|
+
const { summary } = loadArmyForCli();
|
|
1926
|
+
if (json) return out(summary);
|
|
1927
|
+
const g = summary.general;
|
|
1928
|
+
console.log(c.bold("🪖 nomArmy") + c.dim(` (${repoDir})`));
|
|
1929
|
+
console.log(`\n${c.bold("General")} ${g.agent ? agentCell(g.agent, g.agentRunsOn) : ""}${g.setBy ? c.dim(` [${g.setBy}]`) : ""}`);
|
|
1930
|
+
console.log(c.dim(` ${g.who}`));
|
|
1931
|
+
for (const line of g.responsibilities) console.log(c.dim(` - ${line}`));
|
|
1932
|
+
if (g.problem) console.log(c.yellow(` ⚠ ${g.problem}`));
|
|
1933
|
+
if (summary.workflow) console.log(`\n${c.bold("Workflow")}\n${summary.workflow.split("\n").map((l) => ` ${l}`).join("\n")}`);
|
|
1934
|
+
const names = Object.keys(summary.roles);
|
|
1935
|
+
if (!names.length) {
|
|
1936
|
+
console.log(c.dim(`\nNo roles yet. ${summary.howToDispatch}`));
|
|
1937
|
+
} else {
|
|
1938
|
+
const byPhase = new Map();
|
|
1939
|
+
for (const name of names) {
|
|
1940
|
+
const phase = summary.roles[name].phase ?? "unphased";
|
|
1941
|
+
if (!byPhase.has(phase)) byPhase.set(phase, []);
|
|
1942
|
+
byPhase.get(phase).push(name);
|
|
1943
|
+
}
|
|
1944
|
+
for (const phase of [...ARMY_PHASES, "unphased"].filter((p) => byPhase.has(p))) {
|
|
1945
|
+
console.log(`\n${c.bold(phase[0].toUpperCase() + phase.slice(1))}`);
|
|
1946
|
+
for (const name of byPhase.get(phase)) {
|
|
1947
|
+
const role = summary.roles[name];
|
|
1948
|
+
console.log(` ${c.cyan(name.padEnd(18))} ${agentCell(role.agent, role.agentRunsOn, role)}${role.setBy.agent ? c.dim(` [${role.setBy.agent}]`) : ""}`);
|
|
1949
|
+
if (role.description) console.log(c.dim(` ${role.description}`));
|
|
1950
|
+
if (role.problem && role.agent) console.log(c.red(` ✗ ${role.problem}`));
|
|
1951
|
+
if (role.overlapsGeneral) console.log(c.yellow(` ⚠ ${role.overlapsGeneral}`));
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
console.log(`\n${c.bold("Layers")} ${c.dim("(later ones win)")}`);
|
|
1956
|
+
for (const layer of summary.layers) {
|
|
1957
|
+
const state = layer.hasArmy ? c.green("● army section") : layer.exists ? c.dim("○ file exists, no army section") : c.dim("○ no file");
|
|
1958
|
+
console.log(` ${layer.layer.padEnd(8)} ${state.padEnd(40)} ${c.dim(layer.path)}`);
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
async function cmdArmyInit() {
|
|
1963
|
+
const layer = armyLayerFlag("global");
|
|
1964
|
+
const filePath = armyLayerPath(layer, { projectDir: repoDir });
|
|
1965
|
+
const existing = readArmyFile(filePath, { armyOnly: layer !== "project" });
|
|
1966
|
+
if (existing?.roles && Object.keys(existing.roles).length && !flag("force")) {
|
|
1967
|
+
throw new Error(`${filePath} already defines an army (${Object.keys(existing.roles).join(", ")}). Re-run with --force to replace it.`);
|
|
1968
|
+
}
|
|
1969
|
+
// Keep a General already defined in this layer; the roster is what init resets.
|
|
1970
|
+
updateArmyInFile(filePath, (army) => ({ ...structuredClone(DEFAULT_ARMY), ...(army.general ? { general: army.general } : {}) }));
|
|
1971
|
+
if (layer === "local") ensureLocalLayerIgnored();
|
|
1972
|
+
if (json) return out({ written: filePath, layer, roles: Object.keys(DEFAULT_ARMY.roles) });
|
|
1973
|
+
console.log(c.green(`✓ Wrote the default army to ${filePath} (${layer}).`));
|
|
1974
|
+
console.log(c.dim("Every role starts on the local model. Next: `nomarmy army general <agent>` (the agent your coordinator session runs on), then `nomarmy army assign <role> <agent>` for any role you want elsewhere."));
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
async function cmdArmyAssign() {
|
|
1978
|
+
// Positionals only: argv also holds flags, and `--json` must never be read as a model.
|
|
1979
|
+
const positional = argv.slice(2);
|
|
1980
|
+
const firstFlag = positional.findIndex((a) => a.startsWith("--"));
|
|
1981
|
+
const [roleName, agentName, model] = firstFlag === -1 ? positional : positional.slice(0, firstFlag);
|
|
1982
|
+
if (!roleName || !agentName) throw new Error("Usage: nomarmy army assign <role> <agent|none> [model|auto] [--global|--project|--local]");
|
|
1983
|
+
const layer = armyLayerFlag("global");
|
|
1984
|
+
const filePath = armyLayerPath(layer, { projectDir: repoDir });
|
|
1985
|
+
const target = parseTargetSpec(agentName, model);
|
|
1986
|
+
const check = flag("no-check") ? { status: "skipped" } : checkRoleModel(agentName, model);
|
|
1987
|
+
if (check.status === "failed") {
|
|
1988
|
+
const msg = `${agentName}/${model} ${check.detail} -- nothing was written. Pick a model from: ${check.listed.join(", ") || "(OpenClaw lists none for this agent)"}, or pass --no-check if you're sure.`;
|
|
1989
|
+
if (json) { out({ error: msg, check }); process.exit(1); }
|
|
1990
|
+
throw new Error(msg);
|
|
1991
|
+
}
|
|
1992
|
+
assignRoleInFile(filePath, roleName, target);
|
|
1993
|
+
if (layer === "local") ensureLocalLayerIgnored();
|
|
1994
|
+
const { summary } = loadArmyForCli();
|
|
1995
|
+
const role = summary.roles[roleName];
|
|
1996
|
+
if (json) return out({ written: filePath, layer, role: roleName, effective: role ?? null, modelCheck: check });
|
|
1997
|
+
if (check.status === "listed") console.log(c.dim(`${model} is in OpenClaw's catalog for ${agentName}, and a real test call worked.`));
|
|
1998
|
+
else if (check.status === "probed") console.log(c.dim(`${model} isn't in OpenClaw's catalog yet, but a real test call to it worked.`));
|
|
1999
|
+
else if (check.status === "unchecked") console.log(c.yellow(`⚠ Couldn't check ${model} (${check.detail}); the first job on this role will find out.`));
|
|
2000
|
+
console.log(c.green(`✓ ${roleName} → ${agentName}${model ? ` (${model === "auto" ? "model: the General picks per job" : model})` : ""} in ${filePath} (${layer}).`));
|
|
2001
|
+
if (role) {
|
|
2002
|
+
if (role.setBy.agent && role.setBy.agent !== layer) console.log(c.yellow(`Note: the ${role.setBy.agent} layer overrides this, so ${roleName} still runs on ${role.agent}.`));
|
|
2003
|
+
if (role.problem && role.agent) console.log(c.yellow(`⚠ ${role.problem}.`));
|
|
2004
|
+
if (role.overlapsGeneral) console.log(c.yellow(`⚠ ${roleName} ${role.overlapsGeneral}.`));
|
|
2005
|
+
if (!role.description) console.log(c.dim(`${roleName} has no description in any layer; the General will only see its name.`));
|
|
2006
|
+
}
|
|
2007
|
+
if (layer === "project") console.log(c.dim("This is committed with the repo; teammates need an agent with that same name in their own agents.yml."));
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
/**
|
|
2011
|
+
* Whether `model` really runs on `agentName`, before a role is pointed at
|
|
2012
|
+
* it: listed in OpenClaw's freshly refreshed catalog, or -- since that
|
|
2013
|
+
* catalog lags new models (xai/grok-4.7 worked while unlisted) -- answering
|
|
2014
|
+
* one real test call. Caught live: gpt-6-sol is in the Codex CLI's own
|
|
2015
|
+
* model list but OpenClaw's openai provider answers "Unknown model".
|
|
2016
|
+
* Nothing to check for "auto", no model, or a local or unknown agent.
|
|
2017
|
+
*/
|
|
2018
|
+
function checkRoleModel(agentName, model) {
|
|
2019
|
+
if (!model || model === "auto") return { status: "none" };
|
|
2020
|
+
let agent;
|
|
2021
|
+
try { agent = loadAgents(globalConfigDir()).agents[agentName]; } catch { return { status: "unchecked", detail: "agents.yml didn't load" }; }
|
|
2022
|
+
if (!agent || agent.kind === "local") return { status: "none" };
|
|
2023
|
+
const provider = agent.kind === "api" ? openclawProviderId(agent) : agent.provider;
|
|
2024
|
+
if (!json) console.log(c.dim(`Checking ${model} against OpenClaw's ${provider} models (a catalog refresh takes a few seconds)...`));
|
|
2025
|
+
const listing = runQuiet(openclawCmd(), ["models", "list", "--all", "--refresh"]);
|
|
2026
|
+
if (!listing.ok && !listing.out.trim()) return { status: "unchecked", detail: "openclaw isn't reachable" };
|
|
2027
|
+
const listed = parseCatalogModels(listing.out, provider);
|
|
2028
|
+
// Always one real call: a listed model isn't proof it runs. Muse was
|
|
2029
|
+
// listed (catalog refresh lists meta/muse-spark-1.3) while every job on
|
|
2030
|
+
// it failed "Unknown model", and an assignment checked only against the
|
|
2031
|
+
// list let a Senti review fail 10 seconds in.
|
|
2032
|
+
if (probeWorker(provider, model)) return { status: listed.includes(model) ? "listed" : "probed", listed };
|
|
2033
|
+
const why = lastProbeFailure ? ` (${lastProbeFailure})` : "";
|
|
2034
|
+
return { status: "failed", detail: `${listed.includes(model) ? "is listed in OpenClaw's catalog, but a real test call to it failed" : "isn't in OpenClaw's catalog and a real test call to it failed"}${why}`, listed };
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
// Which agent the General is. Global or local only: it describes the
|
|
2038
|
+
// person's own coordinator session, which a committed project file can't know.
|
|
2039
|
+
async function cmdArmyGeneral() {
|
|
2040
|
+
const agentName = argv[2];
|
|
2041
|
+
if (!agentName) throw new Error("Usage: nomarmy army general <agent> [--global|--local]");
|
|
2042
|
+
const layer = armyLayerFlag("global");
|
|
2043
|
+
if (layer === "project") throw new Error("The General is your own coordinator session, so it's set in --global or --local, never in a committed project file.");
|
|
2044
|
+
const agents = loadAgentsOrExit().agents;
|
|
2045
|
+
if (!Object.prototype.hasOwnProperty.call(agents, agentName)) {
|
|
2046
|
+
throw new Error(`Unknown agent "${agentName}". Define it first with \`nomarmy agents add\` (the General's agent is only described, never dispatched to), or pick one of: ${Object.keys(agents).join(", ")}`);
|
|
2047
|
+
}
|
|
2048
|
+
const filePath = armyLayerPath(layer, { projectDir: repoDir });
|
|
2049
|
+
updateArmyInFile(filePath, (army) => ({ ...army, general: agentName }));
|
|
2050
|
+
if (layer === "local") ensureLocalLayerIgnored();
|
|
2051
|
+
const { summary } = loadArmyForCli();
|
|
2052
|
+
const overlaps = Object.entries(summary.roles).filter(([, r]) => r.overlapsGeneral);
|
|
2053
|
+
if (json) return out({ written: filePath, layer, general: agentName, overlaps: Object.fromEntries(overlaps.map(([n, r]) => [n, r.overlapsGeneral])) });
|
|
2054
|
+
console.log(c.green(`✓ The General is "${agentName}" (${describeAgentLabel(agents[agentName])}), in ${filePath} (${layer}).`));
|
|
2055
|
+
for (const [name, role] of overlaps) console.log(c.yellow(`⚠ ${name} ${role.overlapsGeneral}.`));
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
async function cmdArmy() {
|
|
2059
|
+
const sub = argv[1] ?? "show";
|
|
2060
|
+
if (sub === "show") return cmdArmyShow();
|
|
2061
|
+
if (sub === "init") return cmdArmyInit();
|
|
2062
|
+
if (sub === "assign") return cmdArmyAssign();
|
|
2063
|
+
if (sub === "general") return cmdArmyGeneral();
|
|
2064
|
+
throw new Error(`Unknown army subcommand "${sub}". Use: nomarmy army <show|init|assign|general>`);
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
async function cmdConfigPaths() {
|
|
2068
|
+
const agentsPath = agentsConfigPath(globalConfigDir());
|
|
2069
|
+
const army = ["global", "project", "local"].map((layer) => {
|
|
2070
|
+
const p = armyLayerPath(layer, { projectDir: repoDir });
|
|
2071
|
+
return { layer, path: p, exists: fs.existsSync(p) };
|
|
2072
|
+
});
|
|
2073
|
+
if (json) return out({ globalDir: globalConfigDir(), agents: { path: agentsPath, exists: fs.existsSync(agentsPath) }, army });
|
|
2074
|
+
console.log(c.bold("nomArmy config") + c.dim(` (global dir: ${globalConfigDir()})`));
|
|
2075
|
+
console.log(` ${fs.existsSync(agentsPath) ? c.green("●") : c.dim("○")} ${"agents".padEnd(8)} ${c.dim(agentsPath)}`);
|
|
2076
|
+
console.log(c.bold("\nArmy layers"));
|
|
2077
|
+
for (const a of army) console.log(` ${a.exists ? c.green("●") : c.dim("○")} ${a.layer.padEnd(8)} ${c.dim(a.path)}`);
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
async function cmdConfig() {
|
|
2081
|
+
const sub = argv[1] ?? "paths";
|
|
2082
|
+
if (sub === "paths") return cmdConfigPaths();
|
|
2083
|
+
throw new Error(`Unknown config subcommand "${sub}". Use: nomarmy config paths`);
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
// --- `nomarmy jobs [--watch]`: what's running, from any session -----------
|
|
2087
|
+
//
|
|
2088
|
+
// Reads the shared job directory, so it shows every session's jobs. A job
|
|
2089
|
+
// is running when its status says so AND its server process is alive (a
|
|
2090
|
+
// server that died mid-job leaves a stale "running" status behind).
|
|
2091
|
+
|
|
2092
|
+
function jobsRootDir() {
|
|
2093
|
+
return path.join(process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents"), "jobs");
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
function readJsonSafe(file) { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } }
|
|
2097
|
+
|
|
2098
|
+
function pidIsAlive(pid) {
|
|
2099
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
2100
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; }
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
function collectJobs({ recent = 8 } = {}) {
|
|
2104
|
+
const root = jobsRootDir();
|
|
2105
|
+
let names = [];
|
|
2106
|
+
try { names = fs.readdirSync(root); } catch { return { running: [], recent: [] }; }
|
|
2107
|
+
const jobs = names.map((name) => {
|
|
2108
|
+
const dir = path.join(root, name);
|
|
2109
|
+
const status = readJsonSafe(path.join(dir, "status.json")) ?? {};
|
|
2110
|
+
const meta = readJsonSafe(path.join(dir, "metadata.json"));
|
|
2111
|
+
const started = Date.parse(status.startedAt ?? meta?.startedAt ?? "") || fs.statSync(dir).mtimeMs;
|
|
2112
|
+
const running = status.state === "running" && pidIsAlive(status.serverPid);
|
|
2113
|
+
return {
|
|
2114
|
+
jobId: status.jobId ?? name, mode: status.mode ?? meta?.mode ?? null, running,
|
|
2115
|
+
phase: running ? status.phase : (meta?.outcome ?? (status.state === "running" ? "orphaned" : status.phase ?? "?")),
|
|
2116
|
+
agent: status.agent ?? meta?.worker?.provider ?? null, model: status.model ?? meta?.metrics?.worker_model ?? null,
|
|
2117
|
+
elapsedSeconds: Math.round(((running ? Date.now() : Date.parse(meta?.finishedAt ?? status.updatedAt ?? "") || Date.now()) - started) / 1000),
|
|
2118
|
+
lastTool: status.lastTool ? `${status.lastTool.tool}${status.lastTool.target ? ` ${String(status.lastTool.target).slice(0, 40)}` : ""}` : null,
|
|
2119
|
+
filesChanged: status.filesChangedLive ?? meta?.git?.filesChanged?.length ?? null,
|
|
2120
|
+
heartbeatAgeSeconds: status.heartbeatAt ? Math.round((Date.now() - Date.parse(status.heartbeatAt)) / 1000) : null,
|
|
2121
|
+
started, dir,
|
|
2122
|
+
};
|
|
2123
|
+
}).sort((a, b) => b.started - a.started);
|
|
2124
|
+
return { running: jobs.filter((j) => j.running), recent: jobs.filter((j) => !j.running).slice(0, recent) };
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
const fmtSeconds = (n) => (n == null ? "-" : n < 60 ? `${n}s` : n < 3600 ? `${Math.floor(n / 60)}m${String(n % 60).padStart(2, "0")}s` : `${Math.floor(n / 3600)}h${String(Math.floor((n % 3600) / 60)).padStart(2, "0")}m`);
|
|
2128
|
+
|
|
2129
|
+
function renderJobs({ running, recent }) {
|
|
2130
|
+
const lines = [c.bold(`🍪 nomArmy jobs`) + c.dim(` ${new Date().toLocaleTimeString()} (${jobsRootDir()})`), ""];
|
|
2131
|
+
lines.push(c.bold(`Running (${running.length})`));
|
|
2132
|
+
if (!running.length) lines.push(c.dim(" nothing running"));
|
|
2133
|
+
for (const j of running) {
|
|
2134
|
+
const beat = j.heartbeatAgeSeconds == null ? c.dim("no heartbeat yet") : j.heartbeatAgeSeconds > 60 ? c.yellow(`heartbeat ${fmtSeconds(j.heartbeatAgeSeconds)} ago`) : c.dim(`heartbeat ${fmtSeconds(j.heartbeatAgeSeconds)} ago`);
|
|
2135
|
+
lines.push(` ${c.cyan(j.jobId)} ${j.agent ?? "local"}${j.model ? `/${j.model}` : ""} ${j.phase} ${fmtSeconds(j.elapsedSeconds)} ${beat}`);
|
|
2136
|
+
lines.push(c.dim(` last tool: ${j.lastTool ?? "-"} files changed: ${j.filesChanged ?? "-"} log: tail -f ${path.join(j.dir, "openclaw.stderr.log")}`));
|
|
2137
|
+
}
|
|
2138
|
+
lines.push("", c.bold("Recent"));
|
|
2139
|
+
for (const j of recent) lines.push(` ${j.jobId.padEnd(40)} ${String(j.phase).padEnd(20)} ${fmtSeconds(j.elapsedSeconds).padStart(7)} ${c.dim(`${j.agent ?? ""}${j.model ? `/${j.model}` : ""}`)}`);
|
|
2140
|
+
return lines.join("\n");
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
/**
|
|
2144
|
+
* `nomarmy jobs --events`: one line per change -- a job started, changed
|
|
2145
|
+
* phase, or finished -- and nothing in between. Made for Claude Code's
|
|
2146
|
+
* background monitor: the General watches this stream and is woken on
|
|
2147
|
+
* each line, instead of polling local_worker_status (each poll costs its
|
|
2148
|
+
* seat usage). With --json, one JSON object per line.
|
|
2149
|
+
*/
|
|
2150
|
+
async function streamJobEvents() {
|
|
2151
|
+
const interval = Math.max(1, Number(value("interval", "3")) || 3) * 1000;
|
|
2152
|
+
const seen = new Map();
|
|
2153
|
+
const emit = (event, job, detail = "") => {
|
|
2154
|
+
if (json) console.log(JSON.stringify({ at: new Date().toISOString(), event, jobId: job.jobId, agent: job.agent, model: job.model, phase: job.phase, detail }));
|
|
2155
|
+
else console.log(`${new Date().toLocaleTimeString()} ${event.padEnd(9)} ${job.jobId} ${job.agent ?? "local"}${job.model ? `/${job.model}` : ""}${detail ? ` ${detail}` : ""}`);
|
|
2156
|
+
};
|
|
2157
|
+
process.on("SIGINT", () => process.exit(0));
|
|
2158
|
+
let first = true;
|
|
2159
|
+
for (;;) {
|
|
2160
|
+
const { running, recent } = collectJobs({ recent: 20 });
|
|
2161
|
+
const now = new Map([...running, ...recent].map((j) => [j.jobId, j]));
|
|
2162
|
+
for (const j of running) {
|
|
2163
|
+
const prev = seen.get(j.jobId);
|
|
2164
|
+
if (!prev) { if (!first) emit("started", j, j.phase); else emit("running", j, j.phase); }
|
|
2165
|
+
else if (prev.phase !== j.phase) emit("phase", j, `${prev.phase} -> ${j.phase}`);
|
|
2166
|
+
}
|
|
2167
|
+
for (const [id, prev] of seen) {
|
|
2168
|
+
const j = now.get(id);
|
|
2169
|
+
if (prev.running && j && !j.running) emit("finished", j, `${j.phase} after ${fmtSeconds(j.elapsedSeconds)}`);
|
|
2170
|
+
}
|
|
2171
|
+
seen.clear();
|
|
2172
|
+
for (const [id, j] of now) seen.set(id, j);
|
|
2173
|
+
first = false;
|
|
2174
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
/**
|
|
2179
|
+
* `nomarmy jobs --prune [--older-than DAYS]`: remove runtime/ (per-job npm
|
|
2180
|
+
* cache, harness state such as Codex's, OpenClaw's transcript) from
|
|
2181
|
+
* finished jobs older than DAYS (default 2). Each job's record, logs,
|
|
2182
|
+
* report and any retained worktree stay. Job storage reached 1.9 GB and
|
|
2183
|
+
* then 2.4 GB again within a day of real runs, mostly this.
|
|
2184
|
+
*/
|
|
2185
|
+
function pruneJobRuntimeCli() {
|
|
2186
|
+
const days = Math.max(0, Number(value("older-than", "2")) || 0);
|
|
2187
|
+
const { pruned, scratchCleared, freedBytes: bytes } = pruneJobRuntime({ stateRoot: agentStateRoot(), olderThanMs: days * 86400000 });
|
|
2188
|
+
if (json) return out({ pruned, scratchCleared, freedBytes: bytes, olderThanDays: days });
|
|
2189
|
+
const parts = [pruned ? `runtime data from ${pruned} finished job(s) older than ${days} day(s)` : null, scratchCleared ? `OpenClaw scratch files from ${scratchCleared} more recent one(s)` : null].filter(Boolean);
|
|
2190
|
+
console.log(parts.length ? c.green(`✓ Removed ${parts.join(" and ")}, freeing ${(bytes / 1024 ** 3).toFixed(2)} GB. Records and reports are kept.`) : c.dim(`Nothing to prune: no finished job older than ${days} day(s) still has runtime data.`));
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
async function cmdJobs() {
|
|
2194
|
+
if (flag("events")) return streamJobEvents();
|
|
2195
|
+
if (flag("prune")) return pruneJobRuntimeCli();
|
|
2196
|
+
if (json) return out(collectJobs());
|
|
2197
|
+
if (!flag("watch")) return console.log(renderJobs(collectJobs()));
|
|
2198
|
+
// No `watch` on macOS, and a shell loop can't run through Claude Code's `!`.
|
|
2199
|
+
const interval = Math.max(1, Number(value("interval", "3")) || 3) * 1000;
|
|
2200
|
+
process.on("SIGINT", () => { process.stdout.write("\n"); process.exit(0); });
|
|
2201
|
+
for (;;) {
|
|
2202
|
+
process.stdout.write("\u001b[2J\u001b[H" + renderJobs(collectJobs()) + c.dim("\n\nCtrl-C to stop.") + "\n");
|
|
2203
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
// `nomarmy health`: run the checks now (the MCP server also runs them every
|
|
2208
|
+
// 6 hours) and record them, which also refreshes the status line's warning.
|
|
2209
|
+
async function cmdHealth() {
|
|
2210
|
+
const { checkAndRecordHealth } = await import("../lib/health.mjs");
|
|
2211
|
+
const stateRoot = process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents");
|
|
2212
|
+
const { result } = await checkAndRecordHealth({ projectDir: repoDir, stateRoot, configDir: globalConfigDir() });
|
|
2213
|
+
if (json) return out(result);
|
|
2214
|
+
console.log(c.bold("🍪 nomArmy health") + c.dim(` ${new Date(result.checkedAt).toLocaleString()}`));
|
|
2215
|
+
if (!result.issues.length) { console.log(c.green("\n✓ Nothing to fix.")); return; }
|
|
2216
|
+
const mark = { error: c.red("✗"), warn: c.yellow("⚠"), info: c.dim("·") };
|
|
2217
|
+
for (const i of result.issues) {
|
|
2218
|
+
console.log(`\n${mark[i.severity]} ${c.bold(i.title)}`);
|
|
2219
|
+
console.log(c.dim(` ${i.detail}`));
|
|
2220
|
+
if (i.fix) console.log(` fix: ${i.fix}`);
|
|
2221
|
+
}
|
|
2222
|
+
if (result.issues.some((i) => i.severity === "error")) process.exitCode = 1;
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
async function cmdStatusline() {
|
|
2226
|
+
const { statusLineText } = await import("../lib/statusline.mjs");
|
|
2227
|
+
let session = {};
|
|
2228
|
+
if (!process.stdin.isTTY) { try { session = JSON.parse(fs.readFileSync(0, "utf8") || "{}"); } catch { /* no session JSON */ } }
|
|
2229
|
+
process.stdout.write(`${statusLineText({ session })}\n`);
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
const commands = { scan: cmdScan, validate: cmdValidate, sizing: cmdSizing, init: cmdInit, setup: cmdSetup, model: cmdModel, agents: cmdAgents, army: cmdArmy, jobs: cmdJobs, statusline: cmdStatusline, health: cmdHealth, config: cmdConfig, update: cmdUpdate, connect: cmdConnect, start: cmdStart, stop: cmdStop, uninstall: cmdUninstall, help: () => usage(0) };
|
|
2233
|
+
// doctor command
|
|
2234
|
+
async function cmdDoctor() {
|
|
2235
|
+
// Import lazily to avoid circular dependencies
|
|
2236
|
+
const { runDoctor } = await import("../lib/doctor.mjs");
|
|
2237
|
+
await runDoctor({ json, exit: true });
|
|
2238
|
+
}
|
|
2239
|
+
commands.doctor = cmdDoctor;
|
|
2240
|
+
if (!command || flag("help") || !commands[command]) usage(command && !commands[command] ? 2 : 0);
|
|
2241
|
+
|
|
2242
|
+
try {
|
|
2243
|
+
await commands[command]();
|
|
2244
|
+
} catch (err) {
|
|
2245
|
+
if (json) out({ error: err.message });
|
|
2246
|
+
else console.error(`nomarmy ${command}: ${err.message}`);
|
|
2247
|
+
process.exit(1);
|
|
2248
|
+
}
|