nomarmy 0.1.0-alpha.2 → 0.1.0-alpha.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -480
- package/bin/nomarmy.mjs +1081 -185
- package/docker/Dockerfile +2 -2
- package/docker/Dockerfile.go +6 -4
- package/docker/Dockerfile.rust +17 -2
- package/harnesses/_template/README.md +27 -0
- package/harnesses/_template/harness.yml +26 -0
- package/harnesses/browser-playwright/README.md +35 -0
- package/harnesses/browser-playwright/fixture/package.json +1 -0
- package/harnesses/browser-playwright/fixture/page.html +1 -0
- package/harnesses/browser-playwright/fixture/page.spec.js +5 -0
- package/harnesses/browser-playwright/fixture/playwright.config.js +8 -0
- package/harnesses/browser-playwright/harness.yml +18 -0
- package/harnesses/go/README.md +45 -0
- package/harnesses/go/harness.yml +14 -0
- package/harnesses/mock-oidc/README.md +31 -0
- package/harnesses/mock-oidc/fixture/.nomarmy.yml +4 -0
- package/harnesses/mock-oidc/fixture/discovery.test.mjs +16 -0
- package/harnesses/mock-oidc/harness.yml +19 -0
- package/harnesses/node/README.md +53 -0
- package/harnesses/node/harness.yml +18 -0
- package/harnesses/python/README.md +46 -0
- package/harnesses/python/harness.yml +16 -0
- package/harnesses/rust/README.md +45 -0
- package/harnesses/rust/harness.yml +13 -0
- package/install.sh +29 -9
- package/lib/admission.mjs +178 -30
- package/lib/agents.mjs +8 -6
- package/lib/army.mjs +25 -10
- package/lib/codex-link.mjs +37 -0
- package/lib/config.mjs +15 -0
- package/lib/connect.mjs +232 -19
- package/lib/continue-from.mjs +103 -0
- package/lib/coordinator-instructions.mjs +5 -1
- package/lib/diff-checks.mjs +114 -0
- package/lib/dispatch-schema.mjs +14 -12
- package/lib/doctor.mjs +98 -9
- package/lib/egress-proxy.mjs +116 -0
- package/lib/execute.mjs +241 -33
- package/lib/git-record.mjs +27 -3
- package/lib/harness-schema.mjs +61 -0
- package/lib/harnesses.mjs +99 -0
- package/lib/health.mjs +162 -18
- package/lib/install-freshness.mjs +114 -0
- package/lib/jev-checks.mjs +110 -0
- package/lib/job-format.mjs +54 -0
- package/lib/judge.mjs +130 -0
- package/lib/limits.mjs +77 -0
- package/lib/model-probe.mjs +61 -0
- package/lib/mutation.mjs +159 -0
- package/lib/notify.mjs +30 -3
- package/lib/openclaw-install.mjs +122 -0
- package/lib/openclaw-path.mjs +28 -0
- package/lib/openclaw-run.mjs +74 -12
- package/lib/openclaw-runtime-health.mjs +56 -0
- package/lib/outcome.mjs +21 -2
- package/lib/outcomes.mjs +6 -0
- package/lib/path-utils.mjs +4 -0
- package/lib/podman-health.mjs +41 -0
- package/lib/process.mjs +4 -1
- package/lib/propose.mjs +10 -11
- package/lib/refusal-retry.mjs +16 -0
- package/lib/registry-python.mjs +98 -0
- package/lib/registry-secrets.mjs +140 -0
- package/lib/repo-query.mjs +13 -7
- package/lib/runs.mjs +7 -1
- package/lib/same-path.mjs +14 -0
- package/lib/sandbox-images.mjs +499 -83
- package/lib/sandbox-vm.mjs +32 -0
- package/lib/scan.mjs +5 -1
- package/lib/schema.mjs +20 -11
- package/lib/scout.mjs +21 -3
- package/lib/server-context.mjs +21 -1
- package/lib/setup-steps.mjs +55 -0
- package/lib/share.mjs +82 -0
- package/lib/stale-sessions.mjs +60 -0
- package/lib/stats.mjs +315 -0
- package/lib/statusline.mjs +32 -6
- package/lib/subscription-setup.mjs +13 -0
- package/lib/suggestions.mjs +153 -0
- package/lib/thinking.mjs +23 -0
- package/lib/transcript.mjs +30 -5
- package/lib/usage-limits.mjs +329 -0
- package/lib/user-config.mjs +106 -0
- package/lib/validators.mjs +220 -0
- package/lib/verification-artifacts.mjs +46 -0
- package/lib/verification-flow.mjs +52 -7
- package/lib/verification-network.mjs +66 -0
- package/lib/verify.mjs +338 -85
- package/lib/worker-prompt.mjs +5 -2
- package/lib/wsl-cli.mjs +152 -0
- package/lib/wsl.mjs +230 -0
- package/lib/zod-issues.mjs +15 -0
- package/mcp/server.mjs +165 -34
- package/package.json +7 -5
- package/playbooks/feature.md +8 -5
- package/scripts/configure-openclaw.sh +4 -2
- package/scripts/generate-harness-docs.mjs +42 -0
- package/scripts/install-openclaw.mjs +23 -0
- package/scripts/lib.sh +9 -2
- package/scripts/select-model.mjs +12 -5
- package/scripts/start-inference.sh +2 -2
package/bin/nomarmy.mjs
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// nomArmy CLI. Every command proposes before it writes anything -- init,
|
|
3
3
|
// setup, model and update all show exactly what would change and write only
|
|
4
4
|
// after explicit confirmation ([y/N]) or an explicit non-interactive flag
|
|
5
|
-
// (--write, --json with the required choices given up front).
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// (--write, --json with the required choices given up front). System-level
|
|
6
|
+
// setup (install.sh: OpenClaw, the sandbox, llama.cpp for a local model)
|
|
7
|
+
// runs only when asked: `nomarmy install`, or `nomarmy setup` after it has
|
|
8
|
+
// shown the exact command and the operator said yes.
|
|
9
9
|
import fs from "node:fs";
|
|
10
10
|
import os from "node:os";
|
|
11
11
|
import path from "node:path";
|
|
@@ -18,15 +18,48 @@ import { buildConfigProposal } from "../lib/propose.mjs";
|
|
|
18
18
|
import { detectHardware } from "../lib/hardware.mjs";
|
|
19
19
|
import { readGGUFMetadata, resolveModelPath, totalSplitBytes } from "../lib/gguf.mjs";
|
|
20
20
|
import { recommend, customRecommendation, evaluateConfig, bytesPerKvElementForCacheTypes, MIN_CONTEXT_PER_NOM } from "../lib/sizing.mjs";
|
|
21
|
-
import {
|
|
21
|
+
import { windowsFrontEnd, dropWindowsPath, markWindowsCoordinator, pickDistro, resolveWslNomarmy, mcpBridgeLaunch, writeWindowsSettings } from "../lib/wsl.mjs";
|
|
22
|
+
import { windowsPlan, windowsForward, windowsSetup, windowsDoctor } from "../lib/wsl-cli.mjs";
|
|
23
|
+
import { connectViaWsl, connectClaude, connectCodex, connectCursor, cursorAlreadyConnected, deriveWorkerModelEnv, defaultInstallDir, installMcpCopy, SCOPES, claudeUserScoped, portableServerLaunch } from "../lib/connect.mjs";
|
|
24
|
+
import { compareVersions, readPackageVersion, readInstallVersions, copyIsStale } from "../lib/install-freshness.mjs";
|
|
25
|
+
import { loadJobRecords, computeStats, formatStats, formatStatsSummary, parseSince, resolveRepo, agentLookup } from "../lib/stats.mjs";
|
|
26
|
+
import { requestJobStop } from "../lib/openclaw-run.mjs";
|
|
27
|
+
import { loadValidators, saveJevKey, removeJev, jevSettings, askJev, validatorsPath, JEV_CHECKS, saveJudge, removeJudge, judgeSettings, judgeAgentChoices, chooseJudgeAgent, confirmJudgeHostTools } from "../lib/validators.mjs";
|
|
28
|
+
import { probeModel } from "../lib/model-probe.mjs";
|
|
22
29
|
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, agentRunsToolsOnHost, 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";
|
|
30
|
+
import { loadAgents, readAgentsFile, writeAgentsFile, agentsConfigPath, apiAgentAsPoolEntry, describeAgent as describeAgentLabel, agentRunsToolsOnHost, agentProviderId, AGENT_KINDS, API_PROVIDER_TYPES, RESERVED_AGENT_NAMES, BUILTIN_LOCAL_AGENT } from "../lib/agents.mjs";
|
|
31
|
+
import { loadArmy, mergeArmy, describeArmy, readArmyFile, updateArmyInFile, assignRoleInFile, parseTargetSpec, armyLayerPath, globalConfigDir, DEFAULT_ARMY, ARMY_PHASES, LOCAL_CONFIG_FILENAME } from "../lib/army.mjs";
|
|
25
32
|
import { parseLlamaUrl } from "../lib/execution.mjs";
|
|
33
|
+
import { setupSteps, formatSetupSteps, runSetupPlaybook } from "../lib/setup-steps.mjs";
|
|
34
|
+
import { readUsageSnapshots, refreshStaleOverLimitReadings } from "../lib/usage-limits.mjs";
|
|
35
|
+
import { pickMachine, planResize } from "../lib/sandbox-vm.mjs";
|
|
36
|
+
import { listProcesses, staleSessions, formatStaleSessions } from "../lib/stale-sessions.mjs";
|
|
37
|
+
import { readSetting, writeSetting, writeEnvLine, userCommonPath, userProfilePath, profilePathFor, tildePath } from "../lib/user-config.mjs";
|
|
38
|
+
import { MIN_PODMAN_VM_MB } from "../lib/doctor.mjs";
|
|
39
|
+
import { liveLeases } from "../lib/slots.mjs";
|
|
26
40
|
import { ensureProviderConfig } from "../lib/openclaw-config.mjs";
|
|
27
|
-
import {
|
|
41
|
+
import { linkCodex } from "../lib/codex-link.mjs";
|
|
42
|
+
import { recordProbeSuccess, parseOpenclawAuthProfiles, codexImportRecovery } from "../lib/health.mjs";
|
|
28
43
|
import { pruneJobRuntime } from "../lib/prune.mjs";
|
|
29
|
-
import { SUBSCRIPTION_VENDORS, parseOpenclawVersion, versionAtLeast, parseCatalogModels, parseCliLoginStatus, probeOutcome, parseMuseAuthDescriptor, extractMintedKey } from "../lib/subscription-setup.mjs";
|
|
44
|
+
import { SUBSCRIPTION_VENDORS, parseOpenclawVersion, versionAtLeast, parseCatalogModels, parseCliLoginStatus, probeOutcome, openclawSignInFailure, parseMuseAuthDescriptor, extractMintedKey } from "../lib/subscription-setup.mjs";
|
|
45
|
+
import { PINNED_OPENCLAW_VERSION, repairOpenclaw, verifyOpenclaw, configuredSubscriptionVendors } from "../lib/openclaw-install.mjs";
|
|
46
|
+
import { ensureOpenClawOnPath } from "../lib/openclaw-path.mjs";
|
|
47
|
+
import { THINKING_LEVELS } from "../lib/thinking.mjs";
|
|
48
|
+
import { fileURLToPath } from "node:url";
|
|
49
|
+
// Inside WSL, only the distro's own tools (see lib/wsl.mjs).
|
|
50
|
+
dropWindowsPath();
|
|
51
|
+
// The Windows front end: whatever it forwards knows the coordinators are on Windows.
|
|
52
|
+
if (windowsFrontEnd()) markWindowsCoordinator();
|
|
53
|
+
// OpenClaw in ~/.npm-global/bin (no writable npm prefix) is found without the operator editing PATH.
|
|
54
|
+
ensureOpenClawOnPath();
|
|
55
|
+
// Windows starts the engine as `wsl.exe --exec <node> nomarmy.mjs`, with no
|
|
56
|
+
// login shell, so an nvm Node's directory isn't on PATH. Children that need
|
|
57
|
+
// `node` (install.sh, OpenClaw's `#!/usr/bin/env node`) get the one running.
|
|
58
|
+
{
|
|
59
|
+
const nodeDir = path.dirname(process.execPath);
|
|
60
|
+
const entries = (process.env.PATH ?? "").split(path.delimiter);
|
|
61
|
+
if (!entries.includes(nodeDir)) process.env.PATH = [nodeDir, ...entries.filter(Boolean)].join(path.delimiter);
|
|
62
|
+
}
|
|
30
63
|
|
|
31
64
|
// Add a new coordinator: add its name here, teach commandExists/connectTarget
|
|
32
65
|
// about it below (a JSON-file target like Cursor has no PATH binary to check
|
|
@@ -34,10 +67,10 @@ import { SUBSCRIPTION_VENDORS, parseOpenclawVersion, versionAtLeast, parseCatalo
|
|
|
34
67
|
// "already connected, so resync" detection if it has no CLI to probe.
|
|
35
68
|
const KNOWN_TARGETS = ["claude", "codex", "cursor"];
|
|
36
69
|
|
|
37
|
-
function connectTarget(target, { nomarmyRoot, run }) {
|
|
38
|
-
if (target === "claude") return connectClaude({ nomarmyRoot, run });
|
|
39
|
-
if (target === "codex") return connectCodex({ nomarmyRoot, run });
|
|
40
|
-
if (target === "cursor") return connectCursor({ nomarmyRoot, run });
|
|
70
|
+
function connectTarget(target, { nomarmyRoot, run, scope = "user", projectDir = null }) {
|
|
71
|
+
if (target === "claude") return connectClaude({ nomarmyRoot, run, scope, projectDir });
|
|
72
|
+
if (target === "codex") return connectCodex({ nomarmyRoot, run, scope });
|
|
73
|
+
if (target === "cursor") return connectCursor({ nomarmyRoot, run, scope, projectDir });
|
|
41
74
|
throw new Error(`unknown connect target: ${target}`);
|
|
42
75
|
}
|
|
43
76
|
|
|
@@ -56,7 +89,7 @@ const repoDir = path.resolve(value("repo", process.cwd()));
|
|
|
56
89
|
// through the job's requested reasoning); `--no-thinking` is always false.
|
|
57
90
|
// Returns undefined when none of these flags were passed at all, so the
|
|
58
91
|
// caller can tell "not touched" apart from "explicitly set".
|
|
59
|
-
|
|
92
|
+
// Every level OpenClaw accepts (lib/thinking.mjs).
|
|
60
93
|
function resolveThinkingFlag() {
|
|
61
94
|
const level = value("thinking");
|
|
62
95
|
if (level && THINKING_LEVELS.includes(level)) return level;
|
|
@@ -89,13 +122,16 @@ Usage: nomarmy <command> [options]
|
|
|
89
122
|
and write it after confirmation.
|
|
90
123
|
--force overwrite an existing .nomarmy.yml
|
|
91
124
|
--write with --json, write without prompting (needs a valid proposal)
|
|
92
|
-
setup
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
Prints the install.sh command; never runs it.
|
|
96
|
-
--tier <more|nominal> with --json, skip the prompt
|
|
125
|
+
setup Show setup progress and run the next unfinished step.
|
|
126
|
+
--status [--json] print the checklist only
|
|
127
|
+
--choose choose hosted/local/remote/Bedrock
|
|
97
128
|
--hosted skip hardware/model questions
|
|
98
|
-
--llama-url <url> use a llama-server
|
|
129
|
+
--llama-url <url> use a shared llama-server
|
|
130
|
+
--json --profile-name <name> [--model <model>] [--tier <more|nominal>]
|
|
131
|
+
write a profile non-interactively
|
|
132
|
+
install Run the bundled installer for the chosen setup profile.
|
|
133
|
+
--profile <name> override NOMARMY_SETUP_PROFILE
|
|
134
|
+
--no-claude skip Claude Code registration
|
|
99
135
|
model Change the configured model later, without the rest of
|
|
100
136
|
setup's questions. Offers to also resync the MCP
|
|
101
137
|
registration's worker-routing env vars, and to restart
|
|
@@ -107,8 +143,9 @@ Usage: nomarmy <command> [options]
|
|
|
107
143
|
registration (never done silently)
|
|
108
144
|
--restart-inference with --json, also stop/start local
|
|
109
145
|
inference (never done silently)
|
|
110
|
-
update
|
|
111
|
-
|
|
146
|
+
update Update nomArmy and reconnect your coordinators: installs
|
|
147
|
+
npm's latest alpha, or for a git checkout pulls (fast-forward
|
|
148
|
+
only; refuses on local changes). Then restart open sessions.
|
|
112
149
|
agents <list|add|update|remove>
|
|
113
150
|
Every account a job can run on, in one list:
|
|
114
151
|
~/.config/nomarmy/agents.yml (or NOMARMY_CONFIG_DIR).
|
|
@@ -138,9 +175,14 @@ Usage: nomarmy <command> [options]
|
|
|
138
175
|
--model --auth-env [--base-url]
|
|
139
176
|
[--openclaw-provider --plugin] [--register]
|
|
140
177
|
[--update-mcp] (api); --provider --model
|
|
141
|
-
--owner (subscription)
|
|
178
|
+
--owner (subscription). Codex JSON setup can
|
|
179
|
+
--link-openclaw after CLI login; removing a
|
|
180
|
+
capturing email profile non-interactively needs
|
|
181
|
+
--remove-email-profiles. Interactive Codex setup
|
|
182
|
+
offers removal (default yes), then imports the
|
|
183
|
+
CLI login. For any kind, optionally
|
|
142
184
|
--max-concurrent --context-window
|
|
143
|
-
--thinking [low|medium|high] --no-thinking
|
|
185
|
+
--thinking [minimal|low|medium|high|xhigh|adaptive|max|ultra] --no-thinking
|
|
144
186
|
update <name>
|
|
145
187
|
change the model (picked from what OpenClaw
|
|
146
188
|
lists; a subscription gets a real test call),
|
|
@@ -150,7 +192,7 @@ Usage: nomarmy <command> [options]
|
|
|
150
192
|
--no-model clears the default model, so every
|
|
151
193
|
role or job names its own.
|
|
152
194
|
(--json with the matching flags; --probe to
|
|
153
|
-
test-call a
|
|
195
|
+
test-call the agent; without a name, all agents)
|
|
154
196
|
remove <name>
|
|
155
197
|
remove one agent
|
|
156
198
|
army <show|init|assign|general>
|
|
@@ -185,15 +227,32 @@ Usage: nomarmy <command> [options]
|
|
|
185
227
|
which agent the General is, in --global
|
|
186
228
|
(default) or --local
|
|
187
229
|
config paths where agents.yml and the three army layers live
|
|
188
|
-
jobs [
|
|
230
|
+
config max-jobs [n]
|
|
231
|
+
how many api and subscription jobs run at once, across
|
|
232
|
+
every session (default 4); with n, sets it in limits.yml.
|
|
233
|
+
Warns when the Podman VM is too small for that many.
|
|
234
|
+
jobs [--watch|--events [--until-done] [--run <run-id>] [--repo <path>]|--prune|--wait <jobId> [<jobId> ...]|--stop <jobId> [--reason <text>]] [--interval N] [--older-than DAYS]
|
|
189
235
|
what's running across every session (agent, model, phase,
|
|
190
236
|
last tool call, files changed, heartbeat) and what just
|
|
191
237
|
finished; --watch redraws every N seconds (default 3);
|
|
192
238
|
--events prints one line per start, phase change and
|
|
193
|
-
finish (for
|
|
194
|
-
|
|
239
|
+
finish (--json for JSON lines). It's a stream: read it
|
|
240
|
+
with a monitor that wakes on each line. A background
|
|
241
|
+
command is only reported when it exits, so there use
|
|
242
|
+
--events --until-done --run <run-id> for a whole run,
|
|
243
|
+
or --wait <id> <id> ... for specific jobs. Unscoped
|
|
244
|
+
--until-done watches every job on this machine. The plain
|
|
245
|
+
stream ends on its own after 30 minutes with nothing
|
|
246
|
+
running (--idle-minutes N); --prune removes the bulky runtime data
|
|
195
247
|
from finished jobs older than DAYS (default 2), keeping
|
|
196
|
-
their records, reports and any retained worktree
|
|
248
|
+
their records, reports and any retained worktree;
|
|
249
|
+
--wait <jobId> [<jobId> ...] [--timeout <seconds>]
|
|
250
|
+
blocks until all finish; comma-separated ids also work
|
|
251
|
+
(default timeout 1800; --json is supported);
|
|
252
|
+
--events --repo <path> limits events to one repository;
|
|
253
|
+
--stop <jobId> stops a running job's worker (no report
|
|
254
|
+
recovery, no verification), keeping its worktree for
|
|
255
|
+
continue_from
|
|
197
256
|
health check what's likely to break a run before it does:
|
|
198
257
|
expiring logins, an outdated OpenClaw or plugin, roles
|
|
199
258
|
that can't be dispatched, an unloadable agents.yml,
|
|
@@ -202,10 +261,42 @@ Usage: nomarmy <command> [options]
|
|
|
202
261
|
statusline the one-line summary Claude Code's status line shows
|
|
203
262
|
(installed by \`nomarmy connect claude\` when no status
|
|
204
263
|
line is set); reads the session JSON on stdin
|
|
205
|
-
connect [claude] [codex] [cursor]
|
|
264
|
+
connect [claude] [codex] [cursor] [--scope user|local|project]
|
|
206
265
|
(Re-)register the MCP server with one or more coordinators.
|
|
207
266
|
With no target and not --json, prompts an interactive
|
|
208
|
-
multi-select instead.
|
|
267
|
+
multi-select instead. --scope user (default) registers it
|
|
268
|
+
for every project; local for this repository, only you;
|
|
269
|
+
project for this repository, committed for the team
|
|
270
|
+
(.mcp.json or .cursor/mcp.json, running \`nomarmy mcp\`).
|
|
271
|
+
Codex has only the user scope.
|
|
272
|
+
stats [--since 7d|<date>] [--until <date>] [--role <role>] [--model <model>] [--run <id>] [--details] [--all-suggestions] [--share] [--badge [path]]
|
|
273
|
+
[--repo <path|name>] [--all-repos] [--json]
|
|
274
|
+
What nomArmy's job records show for this repository (or
|
|
275
|
+
all): volume by role and model, code committed, time,
|
|
276
|
+
tokens and spend, how often a "done" report failed
|
|
277
|
+
independent verification, what didn't finish, reviewers,
|
|
278
|
+
and review flags. From verified records, never reports.
|
|
279
|
+
validators <list|add jev|test jev|remove jev|add judge|test judge|remove judge>
|
|
280
|
+
Optional semantic checks from a model you configure with
|
|
281
|
+
your own key. Jev checks focused claims with TypeSafe;
|
|
282
|
+
Judge reviews diffs with one of your configured agents.
|
|
283
|
+
Setup asks for any consent it needs. \`add jev\` asks for the
|
|
284
|
+
key without echoing it (or reads --key-stdin), saves it
|
|
285
|
+
where only you can read it, and makes one test call. Its
|
|
286
|
+
answers only add review flags, and it sends excerpts of
|
|
287
|
+
your code to TypeSafe. \`add judge --agent <name> --model
|
|
288
|
+
<model>\` makes one of your agents a model judge: does the
|
|
289
|
+
diff meet each acceptance criterion, match the report, keep
|
|
290
|
+
its tests as strong? An agent whose tools run on this
|
|
291
|
+
machine needs --host-tools.
|
|
292
|
+
mcp Start nomArmy's MCP server on stdio with this machine's
|
|
293
|
+
settings. What a --scope project registration runs.
|
|
294
|
+
sandbox The Podman VM every sandbox shares (macOS, Windows): its
|
|
295
|
+
memory, disk and images. --memory <GiB> resizes it (stops,
|
|
296
|
+
sets, restarts; refused while jobs run); --prune removes
|
|
297
|
+
images no container uses (nomArmy rebuilds its own on
|
|
298
|
+
demand); --repair restores missing subordinate ID
|
|
299
|
+
ranges (refused while jobs run); --yes skips confirming
|
|
209
300
|
start <profile> Start local inference (wraps scripts/start-inference.sh).
|
|
210
301
|
stop <profile> Stop local inference (wraps scripts/stop-inference.sh).
|
|
211
302
|
uninstall Remove the MCP registration and install directory.
|
|
@@ -272,7 +363,7 @@ function cmdScan() {
|
|
|
272
363
|
const notes = evidence.notes?.items ?? [];
|
|
273
364
|
if (notes.length) {
|
|
274
365
|
console.log("\nNotes:");
|
|
275
|
-
for (const n of notes) console.log(` ${n.message ?? n}`);
|
|
366
|
+
for (const n of notes) console.log(` ${typeof n === "string" ? n : n.message ?? JSON.stringify(n)}`);
|
|
276
367
|
}
|
|
277
368
|
console.log("\nThis is deterministic evidence only - nothing here was executed.");
|
|
278
369
|
console.log("Describe the environment in .nomarmy.yml, then run 'nomarmy validate'.");
|
|
@@ -295,12 +386,12 @@ function scanCheck(evidence) {
|
|
|
295
386
|
process.exit(1);
|
|
296
387
|
}
|
|
297
388
|
console.log(`Comparing ${path.basename(loaded.path)} against repository evidence\n`);
|
|
298
|
-
if (drift.summary) console.log(
|
|
389
|
+
if (drift.summary) console.log(`Drift: ${drift.summary.missingFromConfig} missing from config, ${drift.summary.missingFromRepo} missing from repo (${drift.summary.total} total)\n`);
|
|
299
390
|
for (const section of ["services", "ports", "environment", "commandKinds"]) {
|
|
300
391
|
const d = drift[section];
|
|
301
392
|
if (!d) continue;
|
|
302
|
-
for (const m of d.missingFromConfig ?? []) console.log(` repo has, config omits: ${section}: ${m}`);
|
|
303
|
-
for (const m of d.missingFromRepo ?? []) console.log(` config has, repo lacks: ${section}: ${m}`);
|
|
393
|
+
for (const m of d.missingFromConfig ?? []) console.log(` repo has, config omits: ${section}: ${typeof m === "object" ? JSON.stringify(m) : m}`);
|
|
394
|
+
for (const m of d.missingFromRepo ?? []) console.log(` config has, repo lacks: ${section}: ${typeof m === "object" ? JSON.stringify(m) : m}`);
|
|
304
395
|
}
|
|
305
396
|
process.exit(drift.ok ? 0 : 1);
|
|
306
397
|
}
|
|
@@ -400,7 +491,9 @@ async function cmdInit() {
|
|
|
400
491
|
// This file's own location, not --repo (the target repo being scanned) --
|
|
401
492
|
// setup/model need to find THIS package's config/ and scripts/ as siblings
|
|
402
493
|
// of bin/, the same way select-model.mjs resolves its own root.
|
|
403
|
-
|
|
494
|
+
// fileURLToPath, not URL.pathname: on Windows that gave "/C:/Users/Jason%20Pugh/...",
|
|
495
|
+
// a stray slash and an encoded space, and connect failed copying from C:\\C:\\...
|
|
496
|
+
const nomarmyRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
404
497
|
|
|
405
498
|
/** Read one KEY=VALUE line's value, or null if the file or key doesn't exist. */
|
|
406
499
|
function readEnvValue(filePath, key) {
|
|
@@ -409,15 +502,9 @@ function readEnvValue(filePath, key) {
|
|
|
409
502
|
return m ? m[1].trim() : null;
|
|
410
503
|
}
|
|
411
504
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const line = `${key}=${value}`;
|
|
416
|
-
const updated = new RegExp(`^${key}=.*$`, "m").test(existingText)
|
|
417
|
-
? existingText.replace(new RegExp(`^${key}=.*$`, "m"), line)
|
|
418
|
-
: `${existingText.trimEnd()}\n${line}\n`.replace(/^\n/, "");
|
|
419
|
-
fs.writeFileSync(filePath, updated);
|
|
420
|
-
}
|
|
505
|
+
// Your settings live in ~/.config/nomarmy/, over the package's config/
|
|
506
|
+
// defaults, so an update can't reset them (lib/user-config.mjs).
|
|
507
|
+
const setting = (key) => readSetting(key, { nomarmyRoot });
|
|
421
508
|
|
|
422
509
|
// Each entry's repo/quant/alias is verified against this project's own real
|
|
423
510
|
// usage (downloaded, loaded, dispatched against), not guessed from a model
|
|
@@ -436,6 +523,14 @@ function writeEnvLine(filePath, key, value) {
|
|
|
436
523
|
* not left to fail only at the very end via schema validation with no
|
|
437
524
|
* indication of which of several answers was the problem.
|
|
438
525
|
*/
|
|
526
|
+
/** A numbered choice, asked again until it's one of 1..count (an empty or mistyped answer used to end the whole flow). */
|
|
527
|
+
async function askChoice(rl, count) {
|
|
528
|
+
const answer = await askUntilValid(rl, "Choice: ", { pattern: new RegExp(`^([1-9]|[1-9][0-9])$`), invalidMessage: `Choose a number from 1 to ${count}.` });
|
|
529
|
+
if (Number(answer) >= 1 && Number(answer) <= count) return Number(answer) - 1;
|
|
530
|
+
console.log(c.red(` ✗ Choose a number from 1 to ${count}.`));
|
|
531
|
+
return askChoice(rl, count);
|
|
532
|
+
}
|
|
533
|
+
|
|
439
534
|
async function askUntilValid(rl, prompt, { pattern, invalidMessage, allowEmpty = false, fallback = "" }) {
|
|
440
535
|
for (;;) {
|
|
441
536
|
const answer = (await rl.question(c.bold(prompt))).trim();
|
|
@@ -468,15 +563,15 @@ async function askSecret(rl, prompt) {
|
|
|
468
563
|
|
|
469
564
|
const KNOWN_MODELS = {
|
|
470
565
|
default: {
|
|
471
|
-
label: "Qwen3-Coder-Next
|
|
566
|
+
label: "Qwen3-Coder-Next · 48 GB download · the most reliable in our tests (recommended)",
|
|
472
567
|
repo: "Qwen/Qwen3-Coder-Next-GGUF", quant: "Q4_K_M", alias: "qwen3-coder-next", thinking: false, recommended: true,
|
|
473
568
|
},
|
|
474
569
|
"gpt-oss-20b": {
|
|
475
|
-
label: "gpt-oss-20b
|
|
570
|
+
label: "gpt-oss-20b · 12 GB download · the fastest; keep reasoning at medium",
|
|
476
571
|
repo: "ggml-org/gpt-oss-20b-GGUF", quant: "MXFP4", alias: "gpt-oss-20b", thinking: true,
|
|
477
572
|
},
|
|
478
573
|
"qwen3.6-27b": {
|
|
479
|
-
label: "Qwen3.6-27B
|
|
574
|
+
label: "Qwen3.6-27B · 17 GB download · very reliable, slower; keep reasoning at medium",
|
|
480
575
|
repo: "unsloth/Qwen3.6-27B-GGUF", quant: "Q4_K_M", alias: "qwen3.6-27b", thinking: true,
|
|
481
576
|
},
|
|
482
577
|
};
|
|
@@ -509,35 +604,148 @@ async function chooseModel(rl) {
|
|
|
509
604
|
return { kind: "search", thinking: thinkingAnswer === "y" || thinkingAnswer === "yes" };
|
|
510
605
|
}
|
|
511
606
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
*/
|
|
607
|
+
function setupProjectDir() {
|
|
608
|
+
let project = process.cwd();
|
|
609
|
+
while (!fs.existsSync(path.join(project, ".git"))) {
|
|
610
|
+
const parent = path.dirname(project);
|
|
611
|
+
if (parent === project) { project = null; break; }
|
|
612
|
+
project = parent;
|
|
613
|
+
}
|
|
614
|
+
return project;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** The profile install.sh picks when given none (scripts/lib.sh load_profile). */
|
|
618
|
+
function defaultLocalProfile() {
|
|
619
|
+
if (process.platform === "darwin") return "macbook-pro";
|
|
620
|
+
return spawnSync("nvidia-smi", ["-L"], { stdio: "ignore", timeout: 5000 }).status === 0 ? "nvidia-linux" : "cpu-linux";
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Which profile this machine is set up for, the same way for `setup` and
|
|
624
|
+
// `install`: the one setup recorded, else the install marker's, else the
|
|
625
|
+
// execution mode's, else (a working local install) install.sh's default.
|
|
626
|
+
function setupProfileState() {
|
|
627
|
+
const common = userCommonPath();
|
|
628
|
+
const chosen = setting("NOMARMY_SETUP_PROFILE");
|
|
629
|
+
const probeCommand = (binary, args) => {
|
|
630
|
+
const result = spawnSync(binary, args, { encoding: "utf8", timeout: 10000 });
|
|
631
|
+
return result.status === 0 ? result.stdout.trim() : "";
|
|
632
|
+
};
|
|
633
|
+
const profileFile = profilePathFor(chosen, { nomarmyRoot });
|
|
634
|
+
const root = (process.env.NOMARMY_INSTALL_ROOT || (profileFile && readEnvValue(profileFile, "NOMARMY_INSTALL_ROOT")) || setting("NOMARMY_INSTALL_ROOT") || "$HOME/.local/share/nomarmy-local-agents").replace(/\$HOME|\$\{HOME\}/g, os.homedir());
|
|
635
|
+
let marker = null;
|
|
636
|
+
try { marker = JSON.parse(fs.readFileSync(path.join(root, "install.json"), "utf8")); } catch (error) { if (error.code !== "ENOENT") marker = {}; }
|
|
637
|
+
const version = probeCommand("openclaw", ["--version"]);
|
|
638
|
+
// `mcp list` would connect to every server; `mcp get` only looks this one up.
|
|
639
|
+
const registered = !marker && Boolean(version) && ["claude", "codex"].some((name) => spawnSync(name, ["mcp", "get", "nomarmy-local-worker"], { stdio: "ignore", timeout: 10000 }).status === 0);
|
|
640
|
+
// An install from before setup recorded its profile: the marker's, else the
|
|
641
|
+
// execution mode's, else (a working local install) install.sh's default.
|
|
642
|
+
const execution = setting("NOMARMY_EXECUTION");
|
|
643
|
+
const profile = chosen ?? marker?.profile
|
|
644
|
+
?? (["hosted", "remote", "bedrock"].includes(execution) ? execution : null)
|
|
645
|
+
?? (registered ? defaultLocalProfile() : null);
|
|
646
|
+
return { common, profile, marker, version, registered };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function setupChecklist() {
|
|
650
|
+
const { common, profile, marker, version, registered } = setupProfileState();
|
|
651
|
+
const project = setupProjectDir();
|
|
652
|
+
return setupSteps({
|
|
653
|
+
mode: () => ({ profile, host: setting("NOMARMY_LLAMA_HOST"), port: setting("NOMARMY_LLAMA_PORT") }),
|
|
654
|
+
install: () => ({ marker, version, registered }),
|
|
655
|
+
agents: () => Object.keys(loadAgents(globalConfigDir()).agents),
|
|
656
|
+
army: () => {
|
|
657
|
+
const global = readArmyFile(armyLayerPath("global"), { armyOnly: true });
|
|
658
|
+
const local = project ? readArmyFile(armyLayerPath("project", { projectDir: project })) : null;
|
|
659
|
+
const merged = mergeArmy([{ layer: "global", army: global }, { layer: "project", army: local }]).army;
|
|
660
|
+
// Repair the layer that would otherwise keep overriding a global init.
|
|
661
|
+
const repairLayer = profile === "hosted" && Object.values(local?.roles ?? {}).some((role) => role.agent === "local") ? "project" : null;
|
|
662
|
+
return { ...merged, repairLayer };
|
|
663
|
+
},
|
|
664
|
+
repo: () => ({ inside: Boolean(project), configured: Boolean(project && fs.existsSync(path.join(project, ".nomarmy.yml"))) }),
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function runSetupChild(args) {
|
|
669
|
+
// Work from the repository root even when setup was started in a subdirectory;
|
|
670
|
+
// the child's cwd remains unchanged, as it does for every other step.
|
|
671
|
+
const project = setupProjectDir();
|
|
672
|
+
if (project && ["init", "army"].includes(args[0])) args = [...args, "--repo", project];
|
|
673
|
+
const result = spawnSync(process.execPath, [path.join(nomarmyRoot, "bin", "nomarmy.mjs"), ...args], { stdio: "inherit", cwd: process.cwd() });
|
|
674
|
+
return result.status ?? 1;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function cmdInstall() {
|
|
678
|
+
const profile = value("profile", null) ?? setupProfileState().profile;
|
|
679
|
+
if (!profile) throw new Error("Choose a profile first: nomarmy setup --choose (or install --profile <name>).");
|
|
680
|
+
if (!value("profile", null)) console.log(`Installing for profile ${profile} (from nomarmy setup; pass --profile to choose another).`);
|
|
681
|
+
const result = spawnSync("bash", [path.join(nomarmyRoot, "install.sh"), "--profile", profile, ...(flag("no-claude") ? ["--no-claude"] : [])], { stdio: "inherit", cwd: nomarmyRoot });
|
|
682
|
+
process.exitCode = result.status ?? 1;
|
|
683
|
+
}
|
|
684
|
+
|
|
523
685
|
async function cmdSetup() {
|
|
686
|
+
if (windowsFrontEnd()) {
|
|
687
|
+
process.exitCode = await windowsSetup(argv, { ask: async (prompt) => {
|
|
688
|
+
const rl = createInterface({ input, output });
|
|
689
|
+
try { return await rl.question(prompt); } finally { rl.close(); }
|
|
690
|
+
} });
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (flag("status") || (!flag("choose") && !flag("hosted") && !flag("llama-url") && !json)) {
|
|
694
|
+
if (flag("status") || !process.stdin.isTTY) {
|
|
695
|
+
const steps = setupChecklist();
|
|
696
|
+
return json ? out(steps) : console.log(formatSetupSteps(steps));
|
|
697
|
+
}
|
|
698
|
+
// What's ahead, before the first question: a practice run found setup
|
|
699
|
+
// felt heavy mostly because nothing said how much of it there was.
|
|
700
|
+
if (setupChecklist().find((step) => step.status === "todo")?.id === "mode") {
|
|
701
|
+
console.log(`${c.bold("🍪 nomArmy setup")}\n\nAbout 10 to 20 minutes, one step at a time; stop any time and ${c.cyan("nomarmy setup")} picks up where you left off.`);
|
|
702
|
+
console.log(c.dim(" 1. Where models run: your API and subscription agents, or a model on this machine"));
|
|
703
|
+
console.log(c.dim(" 2. Install: OpenClaw (runs the models) and Podman (the sandbox every job runs in; on macOS a small VM, about a 1 GB download)"));
|
|
704
|
+
console.log(c.dim(" 3. Agents: log in to your subscription or add an API key"));
|
|
705
|
+
console.log(c.dim(" 4. Roles, this repo's checks, and a final health check\n"));
|
|
706
|
+
}
|
|
707
|
+
process.exitCode = await runSetupPlaybook({
|
|
708
|
+
evaluate: setupChecklist, print: console.log, run: runSetupChild,
|
|
709
|
+
ask: async (prompt) => {
|
|
710
|
+
const rl = createInterface({ input, output });
|
|
711
|
+
try { return await rl.question(prompt); } finally { rl.close(); }
|
|
712
|
+
},
|
|
713
|
+
});
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
if (flag("choose")) {
|
|
717
|
+
if (!process.stdin.isTTY) throw new Error("setup --choose needs an interactive terminal.");
|
|
718
|
+
const rl = createInterface({ input, output });
|
|
719
|
+
try {
|
|
720
|
+
console.log("1. hosted (API keys and subscriptions, most people)\n2. a local model on this machine\n3. a shared model server\n4. Bedrock");
|
|
721
|
+
const choice = await askUntilValid(rl, "Choice [1]: ", { pattern: /^[1-4]$/, invalidMessage: "Choose 1, 2, 3 or 4.", allowEmpty: true, fallback: "1" });
|
|
722
|
+
if (choice === "1") argv.push("--hosted");
|
|
723
|
+
if (choice === "3") argv.push("--llama-url", (await rl.question("Server URL: ")).trim());
|
|
724
|
+
if (choice === "4") {
|
|
725
|
+
writeSetting("NOMARMY_EXECUTION", "bedrock");
|
|
726
|
+
writeSetting("NOMARMY_SETUP_PROFILE", "bedrock");
|
|
727
|
+
console.log("Next: nomarmy install");
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
} finally { rl.close(); }
|
|
731
|
+
}
|
|
524
732
|
const hosted = flag("hosted");
|
|
525
733
|
const hasLlamaUrl = flag("llama-url");
|
|
526
734
|
if (hosted && hasLlamaUrl) throw new Error("--hosted and --llama-url cannot be used together.");
|
|
527
735
|
|
|
528
736
|
if (hosted || hasLlamaUrl) {
|
|
529
|
-
const commonPath =
|
|
737
|
+
const commonPath = userCommonPath();
|
|
530
738
|
|
|
531
739
|
if (hosted) {
|
|
532
740
|
const next = [
|
|
533
|
-
|
|
741
|
+
"nomarmy install",
|
|
534
742
|
"nomarmy agents add",
|
|
535
743
|
"nomarmy army init --agent <name>",
|
|
536
744
|
];
|
|
537
|
-
fs.mkdirSync(path.dirname(commonPath), { recursive: true });
|
|
538
745
|
writeEnvLine(commonPath, "NOMARMY_EXECUTION", "hosted");
|
|
746
|
+
writeEnvLine(commonPath, "NOMARMY_SETUP_PROFILE", "hosted");
|
|
539
747
|
if (json) return out({ written: commonPath, execution: "hosted", next });
|
|
540
|
-
console.log(c.green(`✓
|
|
748
|
+
console.log(c.green(`✓ Jobs will run on your API and subscription agents (saved in ${tildePath(commonPath)}).`));
|
|
541
749
|
console.log(c.dim("\nNext:"));
|
|
542
750
|
for (const step of next) console.log(` ${c.bold(step)}`);
|
|
543
751
|
return;
|
|
@@ -557,20 +765,21 @@ async function cmdSetup() {
|
|
|
557
765
|
}
|
|
558
766
|
fs.mkdirSync(path.dirname(commonPath), { recursive: true });
|
|
559
767
|
writeEnvLine(commonPath, "NOMARMY_EXECUTION", "remote");
|
|
768
|
+
writeEnvLine(commonPath, "NOMARMY_SETUP_PROFILE", "remote");
|
|
560
769
|
writeEnvLine(commonPath, "NOMARMY_LLAMA_HOST", llamaHost);
|
|
561
770
|
writeEnvLine(commonPath, "NOMARMY_LLAMA_PORT", llamaPort);
|
|
562
|
-
const next =
|
|
771
|
+
const next = "nomarmy install";
|
|
563
772
|
if (json) return out({ written: commonPath, execution: "remote", llamaHost, llamaPort, reachable, next });
|
|
564
773
|
console.log(reachable
|
|
565
774
|
? c.green(`✓ llama-server is reachable at ${healthUrl}.`)
|
|
566
775
|
: c.yellow(`⚠ llama-server is not reachable at ${healthUrl} right now; configuration was still written.`));
|
|
567
|
-
console.log(c.green(`✓ Wrote the remote llama-server settings to ${
|
|
776
|
+
console.log(c.green(`✓ Wrote the remote llama-server settings to ${tildePath(commonPath)}.`));
|
|
568
777
|
console.log(c.dim("\nNext:"));
|
|
569
778
|
console.log(` ${c.bold(next)}`);
|
|
570
779
|
return;
|
|
571
780
|
}
|
|
572
781
|
|
|
573
|
-
const execution = value("execution", process.env.NOMARMY_EXECUTION || "local");
|
|
782
|
+
const execution = flag("choose") ? "local" : value("execution", process.env.NOMARMY_EXECUTION || "local");
|
|
574
783
|
const isCloud = execution !== "local";
|
|
575
784
|
const hardware = isCloud ? null : await detectHardware();
|
|
576
785
|
const modelPath = isCloud ? null : findModel();
|
|
@@ -593,10 +802,10 @@ async function cmdSetup() {
|
|
|
593
802
|
console.log(isCloud
|
|
594
803
|
? `Execution is '${execution}' -- hosted inference, local hardware does not bound this.\n`
|
|
595
804
|
: `Hardware: ${c.cyan(`${hardware.platform}/${hardware.arch}`)}, ${hardware.cpu?.logicalCores ?? "?"} logical cores, ${(hardware.memory?.totalBytes / 1024 ** 3).toFixed(1)} GiB RAM\n`);
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
805
|
+
// Plain words; the exact settings are shown before anything is written.
|
|
806
|
+
const plain = (env) => { const n = Number(env?.NOMARMY_MAX_WORKERS ?? env?.NOMARMY_LLAMA_PARALLEL ?? 1), ctx = Math.round(Number(env?.NOMARMY_LLAMA_CONTEXT ?? 0) / Math.max(1, Number(env?.NOMARMY_LLAMA_PARALLEL ?? 1)) / 1024); return `${n} local job${n === 1 ? "" : "s"} at a time${ctx ? `, ${ctx}K tokens of context each` : ""}`; };
|
|
807
|
+
console.log(`This machine fits ${c.green(plain(res.env))}${res.confidence === "low" ? c.dim(" (an estimate)") : ""}.`);
|
|
808
|
+
if (res.nominal && !res.nominal.fits) console.log(c.red("Even one local job doesn't fit in this machine's memory: choose hosted instead (nomarmy setup --choose)."));
|
|
600
809
|
}
|
|
601
810
|
|
|
602
811
|
// "More noms" fits as many noms as memory allows; "nominal" is 1 worker
|
|
@@ -614,8 +823,8 @@ async function cmdSetup() {
|
|
|
614
823
|
if (sizingTier !== "more" && sizingTier !== "nominal") throw new Error('--tier must be "more" or "nominal".');
|
|
615
824
|
} else {
|
|
616
825
|
console.log(`\n${c.bold("Which sizing?")}`);
|
|
617
|
-
console.log(` ${c.cyan("1.")}
|
|
618
|
-
console.log(` ${c.cyan("2.")}
|
|
826
|
+
console.log(` ${c.cyan("1.")} As many at once as fit in memory`);
|
|
827
|
+
console.log(` ${c.cyan("2.")} One at a time (lighter on the machine)`);
|
|
619
828
|
const choice = (await rl.question(c.bold("Choice [1]: "))).trim() || "1";
|
|
620
829
|
sizingTier = choice === "2" ? "nominal" : "more";
|
|
621
830
|
}
|
|
@@ -634,8 +843,8 @@ async function cmdSetup() {
|
|
|
634
843
|
}
|
|
635
844
|
|
|
636
845
|
const profileName = nonInteractive ? value("profile-name") : (await rl.question(`\nProfile name [${hardware?.appleSilicon ? "macbook-pro" : "custom"}]: `)).trim() || (hardware?.appleSilicon ? "macbook-pro" : "custom");
|
|
637
|
-
const profilePath =
|
|
638
|
-
const commonPath =
|
|
846
|
+
const profilePath = userProfilePath(profileName);
|
|
847
|
+
const commonPath = userCommonPath();
|
|
639
848
|
|
|
640
849
|
const profileWrites = { ...sizingEnv };
|
|
641
850
|
if (!isCloud) {
|
|
@@ -648,10 +857,10 @@ async function cmdSetup() {
|
|
|
648
857
|
}
|
|
649
858
|
|
|
650
859
|
if (!json) {
|
|
651
|
-
console.log(c.bold(`\nAbout to write ${
|
|
860
|
+
console.log(c.bold(`\nAbout to write ${tildePath(profilePath)}:`));
|
|
652
861
|
for (const [k, v] of Object.entries(profileWrites)) console.log(c.dim(` ${k}=${v}`));
|
|
653
862
|
if (model?.kind === "known") {
|
|
654
|
-
console.log(c.bold(`\nAnd ${
|
|
863
|
+
console.log(c.bold(`\nAnd ${tildePath(commonPath)}:`));
|
|
655
864
|
console.log(c.dim(` NOMARMY_MODEL_REPO=${model.repo}`));
|
|
656
865
|
console.log(c.dim(` NOMARMY_MODEL_QUANT=${model.quant}`));
|
|
657
866
|
console.log(c.dim(` NOMARMY_MODEL_ALIAS=${model.alias}`));
|
|
@@ -659,13 +868,17 @@ async function cmdSetup() {
|
|
|
659
868
|
console.log(c.dim(` NOMARMY_MODEL_THINKING=${model.thinking}`));
|
|
660
869
|
}
|
|
661
870
|
if (!nonInteractive) {
|
|
662
|
-
|
|
663
|
-
|
|
871
|
+
// Yes by default: someone who accepted every suggestion shouldn't lose it all at the last Enter.
|
|
872
|
+
const answer = (await rl.question(c.bold("\nWrite this configuration? [Y/n] "))).trim().toLowerCase();
|
|
873
|
+
if (answer === "n" || answer === "no") { console.log(c.dim("Canceled; nothing written.")); return; }
|
|
664
874
|
}
|
|
665
875
|
}
|
|
666
876
|
|
|
667
877
|
fs.mkdirSync(path.dirname(profilePath), { recursive: true });
|
|
668
878
|
for (const [k, v] of Object.entries(profileWrites)) writeEnvLine(profilePath, k, v);
|
|
879
|
+
writeEnvLine(commonPath, "NOMARMY_EXECUTION", execution);
|
|
880
|
+
if (execution === "local") writeEnvLine(commonPath, "NOMARMY_LLAMA_HOST", "127.0.0.1");
|
|
881
|
+
writeEnvLine(commonPath, "NOMARMY_SETUP_PROFILE", execution === "bedrock" ? "bedrock" : profileName);
|
|
669
882
|
if (model?.kind === "known" && model.repo) {
|
|
670
883
|
writeEnvLine(commonPath, "NOMARMY_MODEL_REPO", model.repo);
|
|
671
884
|
writeEnvLine(commonPath, "NOMARMY_MODEL_QUANT", model.quant);
|
|
@@ -679,16 +892,16 @@ async function cmdSetup() {
|
|
|
679
892
|
writeEnvLine(commonPath, "NOMARMY_WORKER_MODEL", model.alias);
|
|
680
893
|
writeEnvLine(commonPath, "NOMARMY_MODEL_THINKING", String(model.thinking));
|
|
681
894
|
} else if (model?.kind === "search") {
|
|
682
|
-
const searchedAlias =
|
|
895
|
+
const searchedAlias = setting("NOMARMY_MODEL_ALIAS");
|
|
683
896
|
if (searchedAlias) {
|
|
684
897
|
writeEnvLine(commonPath, "NOMARMY_WORKER_MODEL", searchedAlias);
|
|
685
898
|
writeEnvLine(commonPath, "NOMARMY_MODEL_THINKING", String(model.thinking));
|
|
686
899
|
}
|
|
687
900
|
}
|
|
688
901
|
|
|
689
|
-
const installCmd =
|
|
902
|
+
const installCmd = "nomarmy install";
|
|
690
903
|
if (json) return out({ written: { profile: profilePath, common: model?.kind === "known" ? commonPath : null }, env: profileWrites, sizingTier, installCommand: installCmd });
|
|
691
|
-
console.log(c.green(`\n✓ Wrote ${
|
|
904
|
+
console.log(c.green(`\n✓ Wrote ${tildePath(profilePath)}${model?.kind === "known" ? ` and ${tildePath(commonPath)}` : ""}.`));
|
|
692
905
|
console.log(c.dim("\nThis proposes; it does not install. Run:\n"));
|
|
693
906
|
console.log(` ${c.bold(installCmd)}\n`);
|
|
694
907
|
} finally {
|
|
@@ -717,7 +930,7 @@ function restartInference() {
|
|
|
717
930
|
runScript("start-inference.sh", []);
|
|
718
931
|
}
|
|
719
932
|
async function cmdModel() {
|
|
720
|
-
const commonPath =
|
|
933
|
+
const commonPath = userCommonPath();
|
|
721
934
|
if (json) {
|
|
722
935
|
const which = value("model");
|
|
723
936
|
if (!KNOWN_MODELS[which]) throw new Error(`--json requires --model one of ${Object.keys(KNOWN_MODELS).join(", ")} (Hugging Face search is interactive-only).`);
|
|
@@ -741,17 +954,17 @@ async function cmdModel() {
|
|
|
741
954
|
const model = await chooseModel(rl);
|
|
742
955
|
let alias;
|
|
743
956
|
if (model.kind === "search") {
|
|
744
|
-
console.log(c.green("\n✓ Done") +
|
|
745
|
-
alias =
|
|
957
|
+
console.log(c.green("\n✓ Done") + ` -- ${tildePath(commonPath)} was already updated by the search above.`);
|
|
958
|
+
alias = setting("NOMARMY_MODEL_ALIAS");
|
|
746
959
|
} else {
|
|
747
|
-
console.log(c.bold(`\nAbout to write ${
|
|
960
|
+
console.log(c.bold(`\nAbout to write ${tildePath(commonPath)}:`));
|
|
748
961
|
console.log(c.dim(` NOMARMY_MODEL_REPO=${model.repo}\n NOMARMY_MODEL_QUANT=${model.quant}\n NOMARMY_MODEL_ALIAS=${model.alias}`));
|
|
749
962
|
const answer = (await rl.question(c.bold("\nApply this model configuration? [y/N] "))).trim().toLowerCase();
|
|
750
963
|
if (answer !== "y") { console.log(c.dim("Canceled; nothing changed.")); return; }
|
|
751
964
|
writeEnvLine(commonPath, "NOMARMY_MODEL_REPO", model.repo);
|
|
752
965
|
writeEnvLine(commonPath, "NOMARMY_MODEL_QUANT", model.quant);
|
|
753
966
|
writeEnvLine(commonPath, "NOMARMY_MODEL_ALIAS", model.alias);
|
|
754
|
-
console.log(c.green(`✓ Wrote ${
|
|
967
|
+
console.log(c.green(`✓ Wrote ${tildePath(commonPath)}.`));
|
|
755
968
|
alias = model.alias;
|
|
756
969
|
}
|
|
757
970
|
if (alias) {
|
|
@@ -975,23 +1188,24 @@ async function ensureVendorAuth(rl, vendorKey) {
|
|
|
975
1188
|
let status = readLoginStatus(vendorKey);
|
|
976
1189
|
if (!status.loggedIn) {
|
|
977
1190
|
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).`));
|
|
978
|
-
if (!(await confirm(rl, "Log in now?"))) return { ok: false };
|
|
979
|
-
runInteractive(vendor.cli.bin, vendor.cli.loginArgs);
|
|
1191
|
+
if (!(await confirm(rl, "Log in now?"))) { console.log(c.red(`✗ Sign-in canceled. Retry with \`nomarmy agents add subscription ${vendorKey}\`.`)); return { ok: false }; }
|
|
1192
|
+
const loginOk = runInteractive(vendor.cli.bin, vendor.cli.loginArgs);
|
|
1193
|
+
if (!loginOk) { console.log(c.red(`✗ ${vendor.cli.bin} sign-in failed or was canceled. Retry with \`nomarmy agents add subscription ${vendorKey}\`.`)); return { ok: false }; }
|
|
980
1194
|
status = readLoginStatus(vendorKey);
|
|
981
|
-
if (!status.loggedIn) { console.log(c.red(`✗ Still not logged in to ${vendor.cli.bin}
|
|
1195
|
+
if (!status.loggedIn) { console.log(c.red(`✗ Still not logged in to ${vendor.cli.bin}. Retry with \`nomarmy agents add subscription ${vendorKey}\`.`)); return { ok: false }; }
|
|
982
1196
|
}
|
|
983
1197
|
console.log(c.green(`✓ Logged in to ${vendor.cli.bin}${status.email ? ` as ${status.email}` : ""}${status.subscriptionType ? ` (${status.subscriptionType})` : ""}.`));
|
|
984
1198
|
|
|
985
1199
|
if (vendor.plugin) {
|
|
986
1200
|
step("OpenClaw plugin");
|
|
987
1201
|
const version = parseOpenclawVersion(runQuiet(openclawCmd(), ["--version"]).out);
|
|
988
|
-
if (!versionAtLeast(version,
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
}
|
|
1202
|
+
if (!versionAtLeast(version, PINNED_OPENCLAW_VERSION)) {
|
|
1203
|
+
const repaired = await repairOpenclaw({
|
|
1204
|
+
command: openclawCmd(),
|
|
1205
|
+
vendors: configuredSubscriptionVendors(loadAgentsOrExit().agents, [vendorKey]),
|
|
1206
|
+
isTTY: Boolean(input.isTTY), ask: (prompt) => rl.question(prompt),
|
|
1207
|
+
});
|
|
1208
|
+
if (!repaired.ok) return { ok: false };
|
|
995
1209
|
}
|
|
996
1210
|
if (!runQuiet(openclawCmd(), ["plugins", "inspect", vendor.plugin.id]).ok) {
|
|
997
1211
|
console.log(c.dim(`Installing ${vendor.plugin.spec}...`));
|
|
@@ -1001,6 +1215,12 @@ async function ensureVendorAuth(rl, vendorKey) {
|
|
|
1001
1215
|
}
|
|
1002
1216
|
runQuiet(openclawCmd(), ["plugins", "registry", "--refresh"]);
|
|
1003
1217
|
}
|
|
1218
|
+
const checks = verifyOpenclaw({ command: openclawCmd(), vendors: configuredSubscriptionVendors(loadAgentsOrExit().agents, [vendorKey]) });
|
|
1219
|
+
const failed = checks.filter((check) => !check.ok);
|
|
1220
|
+
if (failed.length) {
|
|
1221
|
+
for (const check of failed) console.log(c.red(`✗ ${check.message} Fix: ${check.fix}`));
|
|
1222
|
+
return { ok: false };
|
|
1223
|
+
}
|
|
1004
1224
|
console.log(c.green(`✓ OpenClaw's ${vendor.plugin.id} plugin is ready.`));
|
|
1005
1225
|
}
|
|
1006
1226
|
if (vendor.credential.kind === "minted-key") {
|
|
@@ -1022,13 +1242,27 @@ async function ensureVendorAuth(rl, vendorKey) {
|
|
|
1022
1242
|
return { ok: true, email: status.email };
|
|
1023
1243
|
}
|
|
1024
1244
|
|
|
1245
|
+
function hasUsableOpenclawAuthProfile(provider) {
|
|
1246
|
+
// Same OpenClaw command and JSON shape used by health's login-expiry check.
|
|
1247
|
+
const result = runQuiet(openclawCmd(), ["models", "auth", "list", "--json"]);
|
|
1248
|
+
if (!result.ok) return false;
|
|
1249
|
+
const profiles = parseOpenclawAuthProfiles(result.stdout);
|
|
1250
|
+
return profiles?.some((profile) => {
|
|
1251
|
+
if (profile.provider !== provider) return false;
|
|
1252
|
+
if (profile.expiresAt == null) return true;
|
|
1253
|
+
const expires = Date.parse(profile.expiresAt);
|
|
1254
|
+
return Number.isFinite(expires) && expires > Date.now();
|
|
1255
|
+
}) ?? false;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1025
1258
|
function catalogModelsFor(provider) {
|
|
1026
1259
|
return parseCatalogModels(runQuiet(openclawCmd(), ["models", "list", "--refresh"]).out, provider);
|
|
1027
1260
|
}
|
|
1028
1261
|
|
|
1029
|
-
/** One real, one-token completion through OpenClaw
|
|
1262
|
+
/** One real, one-token completion through OpenClaw to test a model. */
|
|
1030
1263
|
// Why the last probeWorker() call failed, in the vendor's words when it said.
|
|
1031
1264
|
let lastProbeFailure = null;
|
|
1265
|
+
let lastProbeText = "";
|
|
1032
1266
|
function probeWorker(provider, model) {
|
|
1033
1267
|
// The route a job takes: the ambient OpenClaw config and a state dir of
|
|
1034
1268
|
// its own, never --isolated. --isolated skips that config, and with it the
|
|
@@ -1044,7 +1278,10 @@ function probeWorker(provider, model) {
|
|
|
1044
1278
|
"--json", "--cwd", cwd, "--state-dir", stateDir, "--timeout", "90"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], cwd });
|
|
1045
1279
|
// Streams kept apart: OpenClaw logs a "run ... ended" line to stderr
|
|
1046
1280
|
// AFTER the JSON envelope, and the merged text doesn't parse.
|
|
1047
|
-
const
|
|
1281
|
+
const stdout = result.stdout ?? "";
|
|
1282
|
+
const stderr = result.stderr ?? "";
|
|
1283
|
+
const outcome = probeOutcome({ stdout, stderr });
|
|
1284
|
+
lastProbeText = `${stderr}\n${stdout}`;
|
|
1048
1285
|
lastProbeFailure = outcome.ok ? null : outcome.reason;
|
|
1049
1286
|
if (outcome.ok) recordProbeSuccess(agentStateRoot(), `${provider}/${model}`);
|
|
1050
1287
|
return outcome.ok;
|
|
@@ -1076,10 +1313,15 @@ function reapProbeSandbox(stateDir) {
|
|
|
1076
1313
|
/**
|
|
1077
1314
|
* OpenClaw's own provider login, for the vendors whose plugin wants one on
|
|
1078
1315
|
* top of the vendor CLI's login (Claude doesn't: OpenClaw reuses the CLI
|
|
1079
|
-
* session directly).
|
|
1080
|
-
*
|
|
1316
|
+
* session directly). Codex always refreshes its imported copy after CLI
|
|
1317
|
+
* login is confirmed; other providers link when catalog or auth checks fail.
|
|
1081
1318
|
*/
|
|
1082
|
-
function openclawProviderLogin(vendor) {
|
|
1319
|
+
async function openclawProviderLogin(vendor, rl) {
|
|
1320
|
+
if (vendor === SUBSCRIPTION_VENDORS.codex) return linkCodex({
|
|
1321
|
+
run: runQuiet, command: openclawCmd(), isTTY: Boolean(input.isTTY),
|
|
1322
|
+
removeEmailProfiles: flag("remove-email-profiles"),
|
|
1323
|
+
confirm: (prompt, opts) => confirm(rl, prompt, opts), print: (message) => console.log(c.dim(message)),
|
|
1324
|
+
});
|
|
1083
1325
|
const provider = vendor.credential.loginProvider ?? vendor.provider;
|
|
1084
1326
|
console.log(c.dim(`Linking OpenClaw to it (\`openclaw models auth login --provider ${provider}\`) -- follow its prompts:`));
|
|
1085
1327
|
const ok = runInteractive(openclawCmd(), ["models", "auth", "login", "--provider", provider]);
|
|
@@ -1092,8 +1334,9 @@ function openclawProviderLogin(vendor) {
|
|
|
1092
1334
|
// One list in ~/.config/nomarmy/agents.yml (lib/agents.mjs): the local
|
|
1093
1335
|
// model, api keys, and individual subscriptions. `add` walks through what
|
|
1094
1336
|
// each kind needs -- a key registered with OpenClaw, or the vendor's own
|
|
1095
|
-
// login -- and
|
|
1096
|
-
// to
|
|
1337
|
+
// login -- and tests the selected model before saving, with an explicit
|
|
1338
|
+
// opt-in to save after a failed model call. Changes apply to the next job;
|
|
1339
|
+
// the MCP server re-reads the file when it changes.
|
|
1097
1340
|
|
|
1098
1341
|
function loadAgentsOrExit() {
|
|
1099
1342
|
try { return loadAgents(globalConfigDir()); }
|
|
@@ -1205,11 +1448,10 @@ async function cmdAgentsAdd() {
|
|
|
1205
1448
|
let kind = argv[2];
|
|
1206
1449
|
if (!AGENT_KINDS.includes(kind)) {
|
|
1207
1450
|
console.log("\n" + c.bold("What kind of agent?"));
|
|
1208
|
-
console.log(` ${c.cyan("1.")} local ${c.dim("the local model on this machine (
|
|
1451
|
+
console.log(` ${c.cyan("1.")} local ${c.dim("the local model on this machine (no per-token bill, private; slower)")}`);
|
|
1209
1452
|
console.log(` ${c.cyan("2.")} api ${c.dim("a metered API key (xAI, OpenAI, Anthropic, DeepSeek, ...)")}`);
|
|
1210
1453
|
console.log(` ${c.cyan("3.")} subscription ${c.dim("your own Claude, ChatGPT or Muse Code plan (never shared)")}`);
|
|
1211
|
-
kind = AGENT_KINDS[
|
|
1212
|
-
if (!kind) throw new Error("Not a valid choice.");
|
|
1454
|
+
kind = AGENT_KINDS[await askChoice(rl, AGENT_KINDS.length)];
|
|
1213
1455
|
}
|
|
1214
1456
|
if (kind === "local") return await addLocalAgent(rl, agents);
|
|
1215
1457
|
if (kind === "api") return await addApiAgent(rl, agents);
|
|
@@ -1246,8 +1488,7 @@ async function addLocalAgent(rl, agents) {
|
|
|
1246
1488
|
async function addApiAgent(rl, agents) {
|
|
1247
1489
|
console.log("\n" + c.bold("Which provider?"));
|
|
1248
1490
|
API_PROVIDER_TYPES.forEach((t, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${KNOWN_PROVIDERS[t]?.label ?? t}`));
|
|
1249
|
-
const provider = API_PROVIDER_TYPES[
|
|
1250
|
-
if (!provider) throw new Error("Not a valid choice.");
|
|
1491
|
+
const provider = API_PROVIDER_TYPES[await askChoice(rl, API_PROVIDER_TYPES.length)];
|
|
1251
1492
|
const info = KNOWN_PROVIDERS[provider] ?? {};
|
|
1252
1493
|
const agent = { kind: "api", provider };
|
|
1253
1494
|
|
|
@@ -1313,6 +1554,14 @@ async function addApiAgent(rl, agents) {
|
|
|
1313
1554
|
console.log(c.dim(`Use it with \`nomarmy army assign <role> ${name}\` or agent: "${name}" on a job.`));
|
|
1314
1555
|
}
|
|
1315
1556
|
|
|
1557
|
+
const OWNER_EMAIL_RE = /^[^@\s]+@[^@\s]+$/;
|
|
1558
|
+
function validOwnerEmail(email) { return typeof email === "string" && OWNER_EMAIL_RE.test(email); }
|
|
1559
|
+
function gitUserEmail() {
|
|
1560
|
+
const result = spawnSync("git", ["config", "user.email"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
1561
|
+
const email = result.status === 0 ? result.stdout.trim() : "";
|
|
1562
|
+
return validOwnerEmail(email) ? email : "";
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1316
1565
|
const SUBSCRIPTION_AGENT_DEFAULT_NAMES = { claude: "claude", codex: "codex", meta: "muse" };
|
|
1317
1566
|
|
|
1318
1567
|
async function addSubscriptionAgent(rl, agents) {
|
|
@@ -1322,8 +1571,7 @@ async function addSubscriptionAgent(rl, agents) {
|
|
|
1322
1571
|
if (!SUBSCRIPTION_VENDORS[vendorKey]) {
|
|
1323
1572
|
console.log("\n" + c.bold("Which subscription?"));
|
|
1324
1573
|
vendorKeys.forEach((k, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${SUBSCRIPTION_VENDORS[k].label}`));
|
|
1325
|
-
vendorKey = vendorKeys[
|
|
1326
|
-
if (!vendorKey) throw new Error(`Not a valid choice. Supported: ${vendorKeys.join(", ")}. DeepSeek and others without a plan are api agents.`);
|
|
1574
|
+
vendorKey = vendorKeys[await askChoice(rl, vendorKeys.length)];
|
|
1327
1575
|
}
|
|
1328
1576
|
const vendor = SUBSCRIPTION_VENDORS[vendorKey];
|
|
1329
1577
|
|
|
@@ -1337,16 +1585,34 @@ async function addSubscriptionAgent(rl, agents) {
|
|
|
1337
1585
|
}
|
|
1338
1586
|
|
|
1339
1587
|
const auth = await ensureVendorAuth(rl, vendorKey);
|
|
1340
|
-
if (!auth.ok) { console.log(c.dim("\nStopped; nothing was written.")); return; }
|
|
1588
|
+
if (!auth.ok) { console.log(c.dim("\nStopped; nothing was written.")); process.exitCode = 1; return; }
|
|
1341
1589
|
|
|
1342
1590
|
console.log(`\n${c.bold("→")} Models`);
|
|
1343
1591
|
const needsOpenclawLogin = vendor.credential.kind === "openclaw-login";
|
|
1344
1592
|
let linkedOpenclaw = false;
|
|
1593
|
+
if (vendorKey === "codex") {
|
|
1594
|
+
linkedOpenclaw = await openclawProviderLogin(vendor, rl);
|
|
1595
|
+
if (!linkedOpenclaw) {
|
|
1596
|
+
console.log(c.red("✗ Sign-in has no usable auth profile. Retry with `nomarmy agents add subscription codex`."));
|
|
1597
|
+
process.exitCode = 1; return;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1345
1600
|
let models = catalogModelsFor(vendor.provider);
|
|
1346
|
-
if (!models.length && needsOpenclawLogin) {
|
|
1347
|
-
linkedOpenclaw = openclawProviderLogin(vendor);
|
|
1601
|
+
if (!models.length && needsOpenclawLogin && !linkedOpenclaw) {
|
|
1602
|
+
linkedOpenclaw = await openclawProviderLogin(vendor, rl);
|
|
1603
|
+
if (!linkedOpenclaw) { console.log(c.red(`✗ Sign-in failed or was canceled. Retry with \`nomarmy agents add subscription ${vendorKey}\`.`)); process.exitCode = 1; return; }
|
|
1348
1604
|
models = catalogModelsFor(vendor.provider);
|
|
1349
1605
|
}
|
|
1606
|
+
// Catalog entries can be cached, and a particular model can refuse a
|
|
1607
|
+
// working login. Check OpenClaw's auth profile before asking for details.
|
|
1608
|
+
if (needsOpenclawLogin && !hasUsableOpenclawAuthProfile(vendor.provider)) {
|
|
1609
|
+
if (linkedOpenclaw || !(await openclawProviderLogin(vendor, rl)) || !hasUsableOpenclawAuthProfile(vendor.provider)) {
|
|
1610
|
+
console.log(c.red(`✗ Sign-in has no usable auth profile. Retry with \`nomarmy agents add subscription ${vendorKey}\`.`));
|
|
1611
|
+
process.exitCode = 1;
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
linkedOpenclaw = true;
|
|
1615
|
+
}
|
|
1350
1616
|
// The agent is the account; the model is only a default. Roles pick
|
|
1351
1617
|
// their own model (or "auto" for the General to choose per job).
|
|
1352
1618
|
if (models.length) models.forEach((m, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${m}`));
|
|
@@ -1354,27 +1620,38 @@ async function addSubscriptionAgent(rl, agents) {
|
|
|
1354
1620
|
const pick = (await rl.question(c.bold(`Default model, optional${models.length ? " (a number or an id)" : ""}; blank = pick per role: `))).trim();
|
|
1355
1621
|
const model = /^\d+$/.test(pick) && models.length ? models[Number(pick) - 1] : pick || null;
|
|
1356
1622
|
if (pick && !model) throw new Error(`Not a valid choice: "${pick}".`);
|
|
1357
|
-
//
|
|
1623
|
+
// A usable profile is only a fast precondition. The test call is what
|
|
1624
|
+
// proves sign-in: an unexpired Codex import can still answer 401.
|
|
1358
1625
|
const probeModel = model ?? models[0] ?? vendor.defaultModel;
|
|
1359
1626
|
|
|
1360
|
-
const
|
|
1361
|
-
const
|
|
1362
|
-
|
|
1363
|
-
|
|
1627
|
+
const ownerDefault = validOwnerEmail(auth.email) ? auth.email : gitUserEmail();
|
|
1628
|
+
const owner = await askUntilValid(rl, `Whose subscription is this${ownerDefault ? ` [${ownerDefault}]` : ""}: `, {
|
|
1629
|
+
allowEmpty: Boolean(ownerDefault), fallback: ownerDefault, pattern: OWNER_EMAIL_RE,
|
|
1630
|
+
invalidMessage: "enter an email address (one @, no spaces).",
|
|
1631
|
+
});
|
|
1364
1632
|
|
|
1365
1633
|
const name = await askAgentName(rl, agents, SUBSCRIPTION_AGENT_DEFAULT_NAMES[vendorKey] ?? vendorKey);
|
|
1366
1634
|
if (!name) { console.log(c.dim("Stopped; nothing was written.")); return; }
|
|
1367
1635
|
|
|
1368
1636
|
console.log(`\n${c.bold("→")} Test call`);
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
openclawProviderLogin(vendor);
|
|
1372
|
-
works = probeWorker(vendor.provider, probeModel);
|
|
1373
|
-
}
|
|
1637
|
+
const probed = Boolean(probeModel);
|
|
1638
|
+
const works = probed ? probeWorker(vendor.provider, probeModel) : false;
|
|
1374
1639
|
if (works) console.log(c.green(`✓ ${vendor.provider}/${probeModel} answered a real test prompt.`));
|
|
1375
|
-
else {
|
|
1376
|
-
|
|
1377
|
-
|
|
1640
|
+
else if (needsOpenclawLogin && probed && openclawSignInFailure(`${lastProbeFailure ?? ""}\n${lastProbeText}`)) {
|
|
1641
|
+
const why = lastProbeFailure ? `: ${lastProbeFailure}` : "";
|
|
1642
|
+
console.log(c.red(`✗ Sign-in failed${why}.`));
|
|
1643
|
+
console.log(` fix: ${codexImportRecovery()}`);
|
|
1644
|
+
console.log(c.dim("Stopped; nothing was written."));
|
|
1645
|
+
process.exitCode = 1;
|
|
1646
|
+
return;
|
|
1647
|
+
} else {
|
|
1648
|
+
console.log(c.yellow(probeModel ? `⚠ The login works, but ${vendor.provider}/${probeModel} didn't answer a real test prompt.` : "⚠ The login works, but no model is available for a test call."));
|
|
1649
|
+
console.log(c.dim(`You can retry later with \`nomarmy agents update ${name} --probe\`.`));
|
|
1650
|
+
if (!(await confirm(rl, "Save the agent anyway?", { defaultYes: false }))) {
|
|
1651
|
+
console.log(c.dim("Stopped; nothing was written."));
|
|
1652
|
+
process.exitCode = 1;
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1378
1655
|
}
|
|
1379
1656
|
const written = saveAgents({ ...agents, [name]: { kind: "subscription", provider: vendor.provider, owner, ...(model ? { model } : {}) } });
|
|
1380
1657
|
savedAgentMessage(name, written);
|
|
@@ -1387,6 +1664,7 @@ async function cmdAgentsAddJson() {
|
|
|
1387
1664
|
const kind = value("kind") ?? (AGENT_KINDS.includes(argv[2]) ? argv[2] : null);
|
|
1388
1665
|
if (!name || !kind) throw new Error(`--json requires --name <agent> and --kind <${AGENT_KINDS.join("|")}>, plus that kind's fields (see \`nomarmy help\`).`);
|
|
1389
1666
|
if (RESERVED_AGENT_NAMES.includes(name)) throw new Error(`"${name}" is a reserved name.`);
|
|
1667
|
+
if (kind === "subscription" && value("owner") !== null && !validOwnerEmail(value("owner"))) throw new Error("--owner must be an email address (one @, no spaces).");
|
|
1390
1668
|
const agent = { kind };
|
|
1391
1669
|
const num = (flagName) => (value(flagName) !== null ? Number(value(flagName)) : undefined);
|
|
1392
1670
|
if (kind === "local") {
|
|
@@ -1401,6 +1679,15 @@ async function cmdAgentsAddJson() {
|
|
|
1401
1679
|
const thinking = resolveThinkingFlag();
|
|
1402
1680
|
if (thinking !== undefined) agent.thinking = thinking;
|
|
1403
1681
|
}
|
|
1682
|
+
if (kind === "subscription" && agent.provider === "openai" && (flag("link-openclaw") || flag("remove-email-profiles"))) {
|
|
1683
|
+
if (!readLoginStatus("codex").loggedIn) throw new Error("Confirm Codex CLI login first: codex login");
|
|
1684
|
+
const linked = await linkCodex({ run: runQuiet, command: openclawCmd(),
|
|
1685
|
+
removeEmailProfiles: flag("remove-email-profiles"), print: (message) => console.error(message) });
|
|
1686
|
+
if (!linked) throw new Error("Codex import failed; nothing was written.");
|
|
1687
|
+
if (!probeWorker("openai", agent.model ?? SUBSCRIPTION_VENDORS.codex.defaultModel)) {
|
|
1688
|
+
throw new Error(`Codex test call failed; nothing was written. Fix: ${codexImportRecovery()}`);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1404
1691
|
const written = saveAgents({ ...agents, [name]: agent });
|
|
1405
1692
|
const saved = written[name];
|
|
1406
1693
|
let registered = null, mcpUpdated = false;
|
|
@@ -1412,6 +1699,43 @@ async function cmdAgentsAddJson() {
|
|
|
1412
1699
|
return out({ written: agentsConfigPath(globalConfigDir()), name, agent: saved, ...(kind === "api" ? { registered, mcpUpdated } : {}) });
|
|
1413
1700
|
}
|
|
1414
1701
|
|
|
1702
|
+
// A probe-only update checks credentials without changing agents.yml.
|
|
1703
|
+
function probeConfiguredAgent(name, agent) {
|
|
1704
|
+
const provider = agentProviderId(agent);
|
|
1705
|
+
let model = agent.model;
|
|
1706
|
+
let source = "";
|
|
1707
|
+
if (!model && provider) {
|
|
1708
|
+
const role = agentAssignments()[name]?.roles.find((r) => r.model && r.model !== "auto");
|
|
1709
|
+
if (role) { model = role.model; source = `${role.role}'s model`; }
|
|
1710
|
+
if (!model) {
|
|
1711
|
+
const vendor = Object.values(SUBSCRIPTION_VENDORS).find((v) => v.provider === provider);
|
|
1712
|
+
if (vendor?.defaultModel) { model = vendor.defaultModel; source = "vendor default"; }
|
|
1713
|
+
}
|
|
1714
|
+
if (!model) {
|
|
1715
|
+
model = catalogModelsFor(provider)[0];
|
|
1716
|
+
if (model) source = "first catalog model";
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
if (!provider || !model) {
|
|
1720
|
+
console.log(c.red(`✗ ${name} ${provider ?? agent.kind}/${model ?? "no model"} no model available; set one with \`nomarmy agents update ${name} --model <m>\``));
|
|
1721
|
+
process.exitCode = 1;
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
const selected = `${provider}/${model}${source ? ` (${source})` : ""}`;
|
|
1725
|
+
const start = performance.now();
|
|
1726
|
+
const ok = probeWorker(provider, model);
|
|
1727
|
+
const elapsed = ((performance.now() - start) / 1000).toFixed(1);
|
|
1728
|
+
console.log(ok ? c.green(`✓ ${name} ${selected} answered in ${elapsed}s`)
|
|
1729
|
+
: c.red(`✗ ${name} ${selected} failed: ${lastProbeFailure ?? "no answer"}`));
|
|
1730
|
+
if (!ok) process.exitCode = 1;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
function cmdAgentsProbeAll() {
|
|
1734
|
+
const agents = fileAgentsOrExit();
|
|
1735
|
+
if (!Object.keys(agents).length) { console.log("No configured agents to probe."); return; }
|
|
1736
|
+
for (const [name, agent] of Object.entries(agents)) probeConfiguredAgent(name, agent);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1415
1739
|
// --- update ---
|
|
1416
1740
|
|
|
1417
1741
|
// Kind, provider and owner are fixed: changing any of them is a different
|
|
@@ -1420,7 +1744,8 @@ async function cmdAgentsAddJson() {
|
|
|
1420
1744
|
// test call `add` makes (interactive always; --json only with --probe,
|
|
1421
1745
|
// since it spends a real request on the subscription).
|
|
1422
1746
|
async function cmdAgentsUpdate() {
|
|
1423
|
-
const name = argv[2];
|
|
1747
|
+
const name = argv[2]?.startsWith("--") ? null : argv[2];
|
|
1748
|
+
if (!name && flag("probe")) return cmdAgentsProbeAll();
|
|
1424
1749
|
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]");
|
|
1425
1750
|
const agents = fileAgentsOrExit();
|
|
1426
1751
|
const current = Object.prototype.hasOwnProperty.call(agents, name) ? agents[name] : name === "local" ? { ...BUILTIN_LOCAL_AGENT } : undefined;
|
|
@@ -1428,7 +1753,9 @@ async function cmdAgentsUpdate() {
|
|
|
1428
1753
|
|
|
1429
1754
|
const changes = {};
|
|
1430
1755
|
let probe = false;
|
|
1431
|
-
|
|
1756
|
+
// Flags alone are enough; without any, it asks field by field.
|
|
1757
|
+
const flagged = ["model", "no-model", "slot", "auth-env", "base-url", "max-concurrent", "context-window", "thinking", "no-thinking", "probe"].some((f) => flag(f));
|
|
1758
|
+
if (json || flagged) {
|
|
1432
1759
|
const num = (flagName) => (value(flagName) !== null ? Number(value(flagName)) : undefined);
|
|
1433
1760
|
if (value("model") !== null) changes.model = value("model");
|
|
1434
1761
|
// Back to no default model: every role (or job) then names its own.
|
|
@@ -1443,10 +1770,10 @@ async function cmdAgentsUpdate() {
|
|
|
1443
1770
|
if (flag("owner") || value("owner") !== null || value("provider") !== null || value("kind") !== null) {
|
|
1444
1771
|
throw new Error("Kind, provider and owner can't be changed -- that's a different agent. Use `nomarmy agents add`.");
|
|
1445
1772
|
}
|
|
1446
|
-
probe = flag("probe")
|
|
1447
|
-
if (!Object.keys(changes).length) throw new Error("Nothing to update -- pass at least one field flag (see `nomarmy agents update` usage).");
|
|
1773
|
+
probe = flag("probe");
|
|
1774
|
+
if (!Object.keys(changes).length && !probe) throw new Error("Nothing to update -- pass at least one field flag (see `nomarmy agents update` usage).");
|
|
1448
1775
|
} else {
|
|
1449
|
-
if (!process.stdin.isTTY) throw new Error("nomarmy agents update needs an interactive terminal, or
|
|
1776
|
+
if (!process.stdin.isTTY) throw new Error("nomarmy agents update needs an interactive terminal, or the flags to change (e.g. --max-concurrent 3).");
|
|
1450
1777
|
const rl = createInterface({ input, output });
|
|
1451
1778
|
try {
|
|
1452
1779
|
console.log(c.bold("🍪 nomArmy agents update") + c.dim(` (${name}: ${describeAgentLabel(current)})`));
|
|
@@ -1491,7 +1818,8 @@ async function cmdAgentsUpdate() {
|
|
|
1491
1818
|
if (!Object.keys(changes).length) { console.log(c.dim("\nNothing changed.")); return; }
|
|
1492
1819
|
}
|
|
1493
1820
|
|
|
1494
|
-
if (probe && changes.
|
|
1821
|
+
if (probe && !Object.keys(changes).length) { probeConfiguredAgent(name, current); return; }
|
|
1822
|
+
if (probe && changes.model && !probeWorker(agentProviderId(current), changes.model)) {
|
|
1495
1823
|
out({ error: `a real test prompt to ${current.provider}/${changes.model} didn't come back -- nothing was written` });
|
|
1496
1824
|
process.exit(1);
|
|
1497
1825
|
}
|
|
@@ -1529,14 +1857,9 @@ function git(args) {
|
|
|
1529
1857
|
*/
|
|
1530
1858
|
async function cmdUpdate() {
|
|
1531
1859
|
const say = (s) => { if (!json) console.log(s); };
|
|
1532
|
-
// Installed from npm:
|
|
1533
|
-
//
|
|
1534
|
-
if (!fs.existsSync(path.join(nomarmyRoot, ".git")))
|
|
1535
|
-
const how = "npm install -g nomarmy@alpha && nomarmy connect";
|
|
1536
|
-
if (json) return out({ error: "installed from npm, not a git checkout", fix: how });
|
|
1537
|
-
console.log(`This nomArmy was installed from npm, so there's nothing to pull. Update with:\n ${how}`);
|
|
1538
|
-
return;
|
|
1539
|
-
}
|
|
1860
|
+
// Installed from npm: npm updates the package, then connect resyncs the
|
|
1861
|
+
// copy each coordinator runs.
|
|
1862
|
+
if (!fs.existsSync(path.join(nomarmyRoot, ".git"))) return updateFromNpm();
|
|
1540
1863
|
const status = git(["status", "--porcelain"]);
|
|
1541
1864
|
if (status) {
|
|
1542
1865
|
if (json) { out({ error: "working tree is not clean; refusing to pull over local changes", status }); process.exit(1); }
|
|
@@ -1550,8 +1873,19 @@ async function cmdUpdate() {
|
|
|
1550
1873
|
const remote = git(["rev-parse", "@{u}"]);
|
|
1551
1874
|
const base = git(["merge-base", "HEAD", "@{u}"]);
|
|
1552
1875
|
if (local === remote) {
|
|
1553
|
-
|
|
1554
|
-
|
|
1876
|
+
// Nothing to pull, but the copy coordinators run can still be behind
|
|
1877
|
+
// this checkout (commits made or pulled here without a reconnect).
|
|
1878
|
+
if (!copyIsStale(defaultInstallDir(), nomarmyRoot)) {
|
|
1879
|
+
if (json) return out({ updated: false, reason: "already up to date" });
|
|
1880
|
+
console.log(c.green("✓ Already up to date, and your coordinators run this checkout."));
|
|
1881
|
+
printSessionRestarts({ quietWhenNone: true });
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
say(c.bold("🍪 nomArmy update\n"));
|
|
1885
|
+
say("Nothing to pull, but your coordinators run an older copy of this checkout.");
|
|
1886
|
+
const resynced = reconnectCoordinators();
|
|
1887
|
+
if (json) return out({ updated: false, resynced, sha: local });
|
|
1888
|
+
printSessionRestarts();
|
|
1555
1889
|
return;
|
|
1556
1890
|
}
|
|
1557
1891
|
if (base !== local) {
|
|
@@ -1567,28 +1901,81 @@ async function cmdUpdate() {
|
|
|
1567
1901
|
say("\nInstalling dependencies...");
|
|
1568
1902
|
execFileSync("npm", ["install", "--omit=dev", "--no-audit", "--no-fund"], { cwd: nomarmyRoot, stdio: json ? "ignore" : "inherit" });
|
|
1569
1903
|
|
|
1570
|
-
const resynced =
|
|
1571
|
-
|
|
1572
|
-
if (
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1904
|
+
const resynced = reconnectCoordinators();
|
|
1905
|
+
|
|
1906
|
+
if (json) return out({ updated: true, sha: git(["rev-parse", "HEAD"]), resynced });
|
|
1907
|
+
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."));
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// The coordinators nomArmy is registered with. Cursor has no CLI to probe,
|
|
1911
|
+
// so it counts when its own config already lists nomArmy.
|
|
1912
|
+
// Reconnect every connected coordinator through a child process, so it runs
|
|
1913
|
+
// the code now on disk (just pulled or installed) rather than the old code
|
|
1914
|
+
// this process loaded. Returns the targets reconnected.
|
|
1915
|
+
// Each open session keeps the nomArmy it started with: name the ones that
|
|
1916
|
+
// started before the installed copy, rather than a blanket "restart".
|
|
1917
|
+
function printSessionRestarts({ quietWhenNone = false } = {}) {
|
|
1918
|
+
let list = null;
|
|
1919
|
+
try {
|
|
1920
|
+
const installedAt = fs.statSync(path.join(defaultInstallDir(), "source.json")).mtimeMs;
|
|
1921
|
+
const procs = listProcesses();
|
|
1922
|
+
if (procs) list = staleSessions(procs, { installedAt });
|
|
1923
|
+
} catch { /* no installed copy yet, or ps unavailable */ }
|
|
1924
|
+
if (list === null) {
|
|
1925
|
+
if (!quietWhenNone) console.log(c.yellow("\nRestart every open Claude Code, Codex and Cursor session: each keeps the code it started with until then."));
|
|
1926
|
+
return;
|
|
1576
1927
|
}
|
|
1577
|
-
if (
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1928
|
+
if (!list.length) { if (!quietWhenNone) console.log(c.green("\n✓ No open session runs an older nomArmy.")); return; }
|
|
1929
|
+
console.log(c.yellow(`\n${list.length} open session(s) still run an older nomArmy, until each is restarted:`));
|
|
1930
|
+
for (const line of formatStaleSessions(list)) console.log(line);
|
|
1931
|
+
console.log(c.dim("In Claude Code: /exit, then claude --resume (or /mcp → nomarmy-local-worker → Reconnect). Close any you no longer use."));
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
function reconnectCoordinators() {
|
|
1935
|
+
const targets = connectedTargets();
|
|
1936
|
+
// Per-repo registrations run the installed copy (or `nomarmy mcp`), so a
|
|
1937
|
+
// refreshed copy is all they need; re-registering them at user scope would
|
|
1938
|
+
// add nomArmy to every project.
|
|
1939
|
+
if (!targets.length) {
|
|
1940
|
+
execFileSync(process.execPath, [path.join(nomarmyRoot, "bin", "nomarmy.mjs"), "connect", "--copy-only", ...(json ? ["--json"] : [])], { stdio: json ? "ignore" : "inherit" });
|
|
1941
|
+
return [];
|
|
1581
1942
|
}
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
say("\nRe-syncing the Cursor MCP install...");
|
|
1586
|
-
connectCursor({ nomarmyRoot, run: runInherit });
|
|
1587
|
-
resynced.push("cursor");
|
|
1943
|
+
if (targets.length) {
|
|
1944
|
+
if (!json) console.log(`\nReconnecting ${targets.join(", ")}...`);
|
|
1945
|
+
execFileSync(process.execPath, [path.join(nomarmyRoot, "bin", "nomarmy.mjs"), "connect", ...targets, ...(json ? ["--json"] : [])], { stdio: json ? "ignore" : "inherit" });
|
|
1588
1946
|
}
|
|
1947
|
+
return targets;
|
|
1948
|
+
}
|
|
1589
1949
|
|
|
1590
|
-
|
|
1591
|
-
|
|
1950
|
+
function connectedTargets() {
|
|
1951
|
+
return [commandExists("claude") && claudeUserScoped() && "claude", commandExists("codex") && "codex", cursorAlreadyConnected() && "cursor"].filter(Boolean);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
async function updateFromNpm() {
|
|
1955
|
+
const current = readPackageVersion(nomarmyRoot);
|
|
1956
|
+
let latest = null;
|
|
1957
|
+
try { latest = execFileSync("npm", ["view", "nomarmy", "dist-tags.alpha"], { encoding: "utf8", timeout: 20000 }).trim(); } catch { /* offline */ }
|
|
1958
|
+
if (!latest) {
|
|
1959
|
+
const fix = "npm install -g nomarmy@alpha && nomarmy connect claude";
|
|
1960
|
+
if (json) { out({ error: "could not read nomarmy's latest release from npm", fix }); process.exit(1); }
|
|
1961
|
+
console.log(c.red("Couldn't reach npm to find nomArmy's latest release.") + ` Update by hand:\n ${fix}`);
|
|
1962
|
+
process.exit(1);
|
|
1963
|
+
}
|
|
1964
|
+
const { copyVersion } = readInstallVersions(defaultInstallDir());
|
|
1965
|
+
const upgrade = compareVersions(current, latest) < 0;
|
|
1966
|
+
const staleCopy = !copyVersion || compareVersions(copyVersion, upgrade ? latest : current) < 0;
|
|
1967
|
+
if (!upgrade && !staleCopy) {
|
|
1968
|
+
if (json) return out({ updated: false, version: current, reason: "already up to date" });
|
|
1969
|
+
console.log(c.green(`✓ nomArmy ${current} is the latest, and your coordinators run it.`));
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
if (upgrade) {
|
|
1973
|
+
if (!json) console.log(c.bold(`🍪 Updating nomArmy ${current} → ${latest}\n`));
|
|
1974
|
+
execFileSync("npm", ["install", "-g", `nomarmy@${latest}`, "--no-audit", "--no-fund"], { stdio: json ? "ignore" : "inherit" });
|
|
1975
|
+
}
|
|
1976
|
+
const targets = reconnectCoordinators();
|
|
1977
|
+
if (json) return out({ updated: upgrade, from: current, version: upgrade ? latest : current, resynced: targets });
|
|
1978
|
+
printSessionRestarts();
|
|
1592
1979
|
}
|
|
1593
1980
|
|
|
1594
1981
|
function commandExists(cmd) {
|
|
@@ -1632,7 +2019,23 @@ async function cmdConnect() {
|
|
|
1632
2019
|
// scan the whole argv), and `nomarmy connect --json claude` once broke
|
|
1633
2020
|
// that promise by reading argv[1] directly -- --json landed in target's
|
|
1634
2021
|
// slot instead. Multiple bare tokens are now allowed too, for multi-select.
|
|
1635
|
-
const
|
|
2022
|
+
const flagValues = new Set(["--scope", "--repo"].map((name) => argv.indexOf(name)).filter((i) => i >= 0).map((i) => i + 1));
|
|
2023
|
+
const requested = argv.slice(1).filter((a, i) => !a.startsWith("--") && !flagValues.has(i + 1));
|
|
2024
|
+
const scope = value("scope", "user");
|
|
2025
|
+
if (!SCOPES.includes(scope)) throw new Error(`--scope must be one of ${SCOPES.join(", ")}, got "${scope}".`);
|
|
2026
|
+
const nativeWindows = windowsFrontEnd();
|
|
2027
|
+
if (nativeWindows && scope !== "user") throw new Error("per-repo registration on Windows isn't supported yet; use the default --scope user");
|
|
2028
|
+
if (nativeWindows && flag("copy-only")) throw new Error("Windows runs nomArmy inside WSL; run nomarmy connect <target> instead of --copy-only");
|
|
2029
|
+
let projectDir = null;
|
|
2030
|
+
if (scope !== "user") {
|
|
2031
|
+
try { projectDir = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
|
|
2032
|
+
catch { throw new Error(`--scope ${scope} registers nomArmy for one repository, and ${repoDir} isn't inside a git repository. Run it from the repository (or pass --repo <dir>).`); }
|
|
2033
|
+
}
|
|
2034
|
+
if (flag("copy-only")) {
|
|
2035
|
+
// `nomarmy update` with only per-repo registrations: refresh the copy they run.
|
|
2036
|
+
installMcpCopy({ nomarmyRoot, installDir: defaultInstallDir(), run: (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: json ? "ignore" : "inherit", ...opts }) });
|
|
2037
|
+
return json ? out({ refreshed: defaultInstallDir() }) : console.log(c.green(`✓ Refreshed the nomArmy copy in ${defaultInstallDir()}.`));
|
|
2038
|
+
}
|
|
1636
2039
|
let targets;
|
|
1637
2040
|
if (requested.length > 0) {
|
|
1638
2041
|
const unknown = requested.filter((t) => !KNOWN_TARGETS.includes(t));
|
|
@@ -1657,9 +2060,23 @@ async function cmdConnect() {
|
|
|
1657
2060
|
}
|
|
1658
2061
|
try {
|
|
1659
2062
|
if (!json) console.log(c.bold(`\n🍪 Connecting nomArmy to ${target}...`));
|
|
1660
|
-
|
|
2063
|
+
if (nativeWindows) {
|
|
2064
|
+
const capture = (cmd, args, opts = {}) => execFileSync(cmd, args, { stdio: ["ignore", "pipe", "pipe"], ...opts });
|
|
2065
|
+
const distro = pickDistro({ run: capture });
|
|
2066
|
+
const resolved = resolveWslNomarmy({ distro, run: capture });
|
|
2067
|
+
const launch = mcpBridgeLaunch({ distro, ...resolved });
|
|
2068
|
+
const result = connectViaWsl({ target, distro, launch, run, nomarmyRoot });
|
|
2069
|
+
writeWindowsSettings({ distro, ...resolved });
|
|
2070
|
+
results.push({ target, connected: true, ...result });
|
|
2071
|
+
if (!json) console.log(c.green(`✓ Registered nomArmy with ${target}, running inside WSL (${distro})`));
|
|
2072
|
+
continue;
|
|
2073
|
+
}
|
|
2074
|
+
const result = connectTarget(target, { nomarmyRoot, run, scope, projectDir });
|
|
1661
2075
|
results.push({ target, connected: true, ...result });
|
|
1662
|
-
if (!json) console.log(c.green(`✓ Registered nomarmy-local-worker with ${target}.`));
|
|
2076
|
+
if (!json) console.log(c.green(`✓ Registered nomarmy-local-worker with ${target}${scope === "user" ? "." : scope === "local" ? ` for ${projectDir} only (not committed).` : ` in ${path.relative(projectDir, result.configPath ?? path.join(projectDir, ".mcp.json"))}, for everyone who clones this repository.`}`));
|
|
2077
|
+
if (!json && scope === "local" && result?.excluded?.length) console.log(c.dim(` Kept out of git (.git/info/exclude): ${result.excluded.join(", ")}`));
|
|
2078
|
+
if (!json && scope === "project") console.log(c.dim(` Commit it along with the playbook in ${path.relative(projectDir, result.commands.dir)}. Each teammate needs nomArmy installed and set up (npm install -g nomarmy@alpha, then nomarmy setup); the registration runs \`nomarmy mcp\`, which uses their own settings.`));
|
|
2079
|
+
if (!json && scope !== "user" && target === "claude" && result?.userScoped) console.log(c.yellow(` nomArmy is also registered for all your projects (user scope). To use it only where you register it per repository: claude mcp remove nomarmy-local-worker -s user`));
|
|
1663
2080
|
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)`));
|
|
1664
2081
|
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)"));
|
|
1665
2082
|
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`));
|
|
@@ -1681,6 +2098,82 @@ async function cmdConnect() {
|
|
|
1681
2098
|
function runScript(name, args = []) {
|
|
1682
2099
|
execFileSync("bash", [path.join(nomarmyRoot, "scripts", name), ...args], { cwd: nomarmyRoot, stdio: "inherit" });
|
|
1683
2100
|
}
|
|
2101
|
+
// `nomarmy sandbox`: see lib/sandbox-vm.mjs.
|
|
2102
|
+
async function cmdSandbox() {
|
|
2103
|
+
const stateRoot = process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents");
|
|
2104
|
+
const podman = (args, opts = {}) => spawnSync("podman", args, { encoding: "utf8", ...opts });
|
|
2105
|
+
const machine = process.platform === "linux" ? null : pickMachine(podman(["machine", "inspect"]).stdout);
|
|
2106
|
+
let images = null;
|
|
2107
|
+
try {
|
|
2108
|
+
const rows = JSON.parse(podman(["system", "df", "--format", "json"]).stdout || "[]");
|
|
2109
|
+
const row = rows.find((r) => /image/i.test(r.Type ?? ""));
|
|
2110
|
+
if (row) images = { count: row.Total ?? null, size: row.Size ?? null, reclaimable: row.Reclaimable ?? null };
|
|
2111
|
+
} catch { /* podman missing or old */ }
|
|
2112
|
+
const runningJobs = liveLeases(path.join(stateRoot, "leases")).length;
|
|
2113
|
+
const memoryGib = value("memory");
|
|
2114
|
+
|
|
2115
|
+
if (!memoryGib && !flag("prune") && !flag("repair")) {
|
|
2116
|
+
if (json) return out({ platform: process.platform, machine, images, runningJobs, minimumMb: MIN_PODMAN_VM_MB });
|
|
2117
|
+
console.log(c.bold("🍪 nomArmy sandbox"));
|
|
2118
|
+
if (process.platform === "linux") console.log("\nPodman runs natively on Linux: there's no VM to size.");
|
|
2119
|
+
else if (!machine) console.log(c.yellow("\nNo Podman machine found. Run: podman machine init && podman machine start"));
|
|
2120
|
+
else {
|
|
2121
|
+
const low = machine.memoryMb && machine.memoryMb < MIN_PODMAN_VM_MB;
|
|
2122
|
+
console.log(`\nVM ${machine.name} (${machine.state}): ${machine.cpus} CPUs, ${low ? c.red(`${machine.memoryMb / 1024} GiB memory`) : `${machine.memoryMb / 1024} GiB memory`}, ${machine.diskGb} GB disk`);
|
|
2123
|
+
if (low) console.log(c.yellow(` Too small: worker commands get cut off below ${MIN_PODMAN_VM_MB / 1024} GiB. Fix: nomarmy sandbox --memory 8`));
|
|
2124
|
+
const { maxJobs, jobsThatFit, vmGibFor } = await import("../lib/limits.mjs");
|
|
2125
|
+
const limit = maxJobs().value, fit = jobsThatFit(machine.memoryMb);
|
|
2126
|
+
if (fit !== null) console.log(limit > fit
|
|
2127
|
+
? c.yellow(` Fits about ${fit} sandboxes at once, but up to ${limit} api and subscription jobs may run. Fix: nomarmy sandbox --memory ${vmGibFor(limit)}, or nomarmy config max-jobs ${fit}`)
|
|
2128
|
+
: c.dim(` Fits about ${fit} sandboxes at once; up to ${limit} api and subscription jobs may run (nomarmy config max-jobs).`));
|
|
2129
|
+
}
|
|
2130
|
+
if (images) console.log(`Images: ${images.count}, ${images.size}${images.reclaimable ? `, ${images.reclaimable} reclaimable (nomarmy sandbox --prune)` : ""}`);
|
|
2131
|
+
console.log(c.dim(runningJobs ? `${runningJobs} nomArmy job(s) running.` : "No nomArmy jobs running."));
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
const ask = async (question) => {
|
|
2136
|
+
if (flag("yes")) return true;
|
|
2137
|
+
if (!process.stdin.isTTY) throw new Error(`${question} Re-run with --yes to confirm without a terminal.`);
|
|
2138
|
+
const rl = createInterface({ input, output });
|
|
2139
|
+
try { return await confirm(rl, question, { defaultYes: false }); } finally { rl.close(); }
|
|
2140
|
+
};
|
|
2141
|
+
const runPodman = (args) => {
|
|
2142
|
+
console.log(c.dim(`$ podman ${args.join(" ")}`));
|
|
2143
|
+
const r = podman(args, { stdio: "inherit" });
|
|
2144
|
+
if (r.status !== 0) throw new Error(`podman ${args.join(" ")} failed (exit ${r.status ?? "none"}).`);
|
|
2145
|
+
};
|
|
2146
|
+
|
|
2147
|
+
if (flag("repair")) {
|
|
2148
|
+
// Restore the subordinate ID ranges a Podman machine ships with, only
|
|
2149
|
+
// where they're missing, then let Podman re-read them.
|
|
2150
|
+
if (process.platform === "linux") throw new Error("On Linux, add a range for your user to /etc/subuid and /etc/subgid (e.g. `sudo usermod --add-subuids 100000-1099999 --add-subgids 100000-1099999 $USER`), then run `podman system migrate`.");
|
|
2151
|
+
if (!machine) throw new Error("No Podman machine found. Run: podman machine init && podman machine start");
|
|
2152
|
+
// podman system migrate stops every container, a running job's sandbox included.
|
|
2153
|
+
if (runningJobs > 0) throw new Error(`${runningJobs} nomArmy job(s) are running; the repair restarts Podman's containers and would kill their sandboxes. Run it when they're done.`);
|
|
2154
|
+
const script = 'set -e; restored=0; for f in /etc/subuid /etc/subgid; do if ! grep -q "^$(id -un):" "$f" 2>/dev/null; then echo "$(id -un):100000:1000000" | sudo tee -a "$f" >/dev/null; echo "restored $f"; restored=1; else echo "$f already has a range"; fi; done; if [ "$restored" = 1 ]; then podman system migrate; else echo "nothing to repair"; fi';
|
|
2155
|
+
if (!(await ask(`Restore missing subordinate ID ranges in the ${machine.name} VM and run podman system migrate?`))) { console.log(c.dim("Nothing changed.")); return; }
|
|
2156
|
+
const r = spawnSync("podman", ["machine", "ssh", machine.name, script], { stdio: "inherit" });
|
|
2157
|
+
if (r.status !== 0) throw new Error(`the repair failed (exit ${r.status ?? "none"}).`);
|
|
2158
|
+
console.log(c.green("✓ Done. Check with: nomarmy doctor"));
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
if (memoryGib) {
|
|
2163
|
+
const plan = planResize({ gib: memoryGib, machine, hostMemoryMb: os.totalmem() / 1024 / 1024, runningJobs });
|
|
2164
|
+
if (!plan.ok) throw new Error(plan.problems.join("; "));
|
|
2165
|
+
for (const w of plan.warnings) console.log(c.yellow(`⚠ ${w}`));
|
|
2166
|
+
if (!(await ask(`Restart the Podman VM with ${memoryGib} GiB (from ${machine.memoryMb / 1024})?`))) { console.log(c.dim("Nothing changed.")); return; }
|
|
2167
|
+
for (const args of plan.commands) runPodman(args);
|
|
2168
|
+
console.log(c.green(`✓ The Podman VM now has ${memoryGib} GiB.`));
|
|
2169
|
+
}
|
|
2170
|
+
if (flag("prune")) {
|
|
2171
|
+
if (runningJobs > 0) throw new Error(`${runningJobs} nomArmy job(s) are running; prune when they're done.`);
|
|
2172
|
+
if (!(await ask(`Remove every image no container uses${images?.reclaimable ? ` (about ${images.reclaimable})` : ""}? nomArmy rebuilds its own when a job needs them.`))) { console.log(c.dim("Nothing removed.")); return; }
|
|
2173
|
+
runPodman(["image", "prune", "--all", "--force"]);
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
|
|
1684
2177
|
async function cmdStart() { console.log(c.bold("🍪 Starting inference...\n")); runScript("start-inference.sh", argv.slice(1)); }
|
|
1685
2178
|
async function cmdStop() { runScript("stop-inference.sh", argv.slice(1)); }
|
|
1686
2179
|
function safeDu(dir) {
|
|
@@ -1729,7 +2222,7 @@ async function maybeRemoveAgentsDir({ force }) {
|
|
|
1729
2222
|
* needs manual cleanup -- an honest, bounded scope beats guessing at which
|
|
1730
2223
|
* cache entries are "ours". */
|
|
1731
2224
|
function resolveConfiguredModelRepos() {
|
|
1732
|
-
const repo =
|
|
2225
|
+
const repo = setting("NOMARMY_MODEL_REPO");
|
|
1733
2226
|
return repo ? [repo] : [];
|
|
1734
2227
|
}
|
|
1735
2228
|
|
|
@@ -1952,10 +2445,17 @@ function armyLayerFlag(fallback = "global") {
|
|
|
1952
2445
|
return chosen[0] ?? fallback;
|
|
1953
2446
|
}
|
|
1954
2447
|
|
|
1955
|
-
function loadArmyForCli() {
|
|
2448
|
+
function loadArmyForCli({ globalOnly = false, usageRefresh = null } = {}) {
|
|
1956
2449
|
const agents = loadAgentsOrExit().agents;
|
|
1957
|
-
const loaded =
|
|
1958
|
-
|
|
2450
|
+
const loaded = globalOnly
|
|
2451
|
+
? (() => {
|
|
2452
|
+
const filePath = armyLayerPath("global", { projectDir: repoDir });
|
|
2453
|
+
const army = readArmyFile(filePath, { armyOnly: true });
|
|
2454
|
+
return { ...mergeArmy([{ layer: "global", army }]), layers: [{ layer: "global", path: filePath, exists: fs.existsSync(filePath), hasArmy: Boolean(army) }] };
|
|
2455
|
+
})()
|
|
2456
|
+
: loadArmy({ projectDir: repoDir });
|
|
2457
|
+
const usageSnapshots = usageRefresh?.snapshots ?? readUsageSnapshots(process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents"));
|
|
2458
|
+
return { loaded, agents, summary: describeArmy(loaded, { agents, describeAgent: describeAgentLabel, usageSnapshots, agentProviderId, usageRefreshError: usageRefresh?.error ?? null, usageRefreshFailed: usageRefresh?.failedProviders ?? null }) };
|
|
1959
2459
|
}
|
|
1960
2460
|
|
|
1961
2461
|
// Claude Code adds settings.local.json to .gitignore for the same reason:
|
|
@@ -1975,15 +2475,36 @@ function agentCell(name, runsOn, role = null) {
|
|
|
1975
2475
|
return `${name}${model ? ` ${c.bold(model)}` : ""}${runsOn ? c.dim(` ${runsOn}`) : ""}`;
|
|
1976
2476
|
}
|
|
1977
2477
|
|
|
2478
|
+
/** An agent's usage-limit reading (lib/usage-limits.mjs), colored by level. */
|
|
2479
|
+
function usageLine(usage, indent) {
|
|
2480
|
+
const text = `${indent}usage: ${usage.text}`;
|
|
2481
|
+
return usage.level === "over" ? c.red(`${text} (at the limit)`) : usage.level === "high" ? c.yellow(text) : c.dim(text);
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
function repositoryHere(dir) {
|
|
2485
|
+
let current = path.resolve(dir);
|
|
2486
|
+
while (true) {
|
|
2487
|
+
if (fs.existsSync(path.join(current, ".git"))) return true;
|
|
2488
|
+
const parent = path.dirname(current);
|
|
2489
|
+
if (parent === current) return false;
|
|
2490
|
+
current = parent;
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
|
|
1978
2494
|
async function cmdArmyShow() {
|
|
1979
|
-
const
|
|
2495
|
+
const inRepository = repositoryHere(repoDir);
|
|
2496
|
+
const stateRoot = process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents");
|
|
2497
|
+
const usageRefresh = await refreshStaleOverLimitReadings(stateRoot);
|
|
2498
|
+
const { summary } = loadArmyForCli({ globalOnly: !inRepository, usageRefresh });
|
|
1980
2499
|
if (json) return out(summary);
|
|
1981
2500
|
const g = summary.general;
|
|
1982
|
-
console.log(c.bold("🪖 nomArmy") + c.dim(` (${repoDir})`));
|
|
2501
|
+
console.log(c.bold("🪖 nomArmy") + (inRepository ? c.dim(` (${repoDir})`) : ""));
|
|
2502
|
+
if (!inRepository) console.log(c.dim("no repository here: showing global settings"));
|
|
1983
2503
|
console.log(`\n${c.bold("General")} ${g.agent ? agentCell(g.agent, g.agentRunsOn) : ""}${g.setBy ? c.dim(` [${g.setBy}]`) : ""}`);
|
|
1984
2504
|
console.log(c.dim(` ${g.who}`));
|
|
1985
2505
|
for (const line of g.responsibilities) console.log(c.dim(` - ${line}`));
|
|
1986
2506
|
if (g.problem) console.log(c.yellow(` ⚠ ${g.problem}`));
|
|
2507
|
+
if (g.usage) console.log(usageLine(g.usage, " "));
|
|
1987
2508
|
if (summary.workflow) console.log(`\n${c.bold("Workflow")}\n${summary.workflow.split("\n").map((l) => ` ${l}`).join("\n")}`);
|
|
1988
2509
|
const names = Object.keys(summary.roles);
|
|
1989
2510
|
if (!names.length) {
|
|
@@ -2003,11 +2524,12 @@ async function cmdArmyShow() {
|
|
|
2003
2524
|
if (role.description) console.log(c.dim(` ${role.description}`));
|
|
2004
2525
|
if (role.problem && role.agent) console.log(c.red(` ✗ ${role.problem}`));
|
|
2005
2526
|
if (role.overlapsGeneral) console.log(c.yellow(` ⚠ ${role.overlapsGeneral}`));
|
|
2527
|
+
if (role.usage) console.log(usageLine(role.usage, " "));
|
|
2006
2528
|
}
|
|
2007
2529
|
}
|
|
2008
2530
|
}
|
|
2009
2531
|
console.log(`\n${c.bold("Layers")} ${c.dim("(later ones win)")}`);
|
|
2010
|
-
for (const layer of summary.layers) {
|
|
2532
|
+
for (const layer of summary.layers.filter((entry) => inRepository || entry.layer === "global")) {
|
|
2011
2533
|
const state = layer.hasArmy ? c.green("● army section") : layer.exists ? c.dim("○ file exists, no army section") : c.dim("○ no file");
|
|
2012
2534
|
console.log(` ${layer.layer.padEnd(8)} ${state.padEnd(40)} ${c.dim(layer.path)}`);
|
|
2013
2535
|
}
|
|
@@ -2057,14 +2579,31 @@ async function cmdArmyInit() {
|
|
|
2057
2579
|
}
|
|
2058
2580
|
}
|
|
2059
2581
|
|
|
2582
|
+
function armyPositionals() {
|
|
2583
|
+
const result = [];
|
|
2584
|
+
const args = argv.slice(2);
|
|
2585
|
+
for (let i = 0; i < args.length; i++) {
|
|
2586
|
+
const arg = args[i];
|
|
2587
|
+
if (["--repo", "--agent", "--model"].includes(arg)) {
|
|
2588
|
+
if (!args[i + 1] || args[i + 1].startsWith("--")) throw new Error(`${arg} requires a value`);
|
|
2589
|
+
i++;
|
|
2590
|
+
} else if (["--global", "--project", "--local", "--json", "--no-check"].includes(arg)) {
|
|
2591
|
+
continue;
|
|
2592
|
+
} else if (arg.startsWith("--")) {
|
|
2593
|
+
throw new Error(`Unknown army option ${arg}`);
|
|
2594
|
+
} else result.push(arg);
|
|
2595
|
+
}
|
|
2596
|
+
return result;
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2060
2599
|
async function cmdArmyAssign() {
|
|
2061
|
-
|
|
2062
|
-
const
|
|
2063
|
-
|
|
2064
|
-
const [roleName, agentName, model] = firstFlag === -1 ? positional : positional.slice(0, firstFlag);
|
|
2065
|
-
if (!roleName || !agentName) throw new Error("Usage: nomarmy army assign <role> <agent|none> [model|auto] [--global|--project|--local]");
|
|
2600
|
+
const positional = armyPositionals();
|
|
2601
|
+
const [roleName, agentName, model] = positional;
|
|
2602
|
+
if (!roleName || !agentName || positional.length > 3) throw new Error("Usage: nomarmy army assign <role> <agent|none> [model|auto] [--global|--project|--local]");
|
|
2066
2603
|
const layer = armyLayerFlag("global");
|
|
2067
2604
|
const filePath = armyLayerPath(layer, { projectDir: repoDir });
|
|
2605
|
+
const defined = Object.prototype.hasOwnProperty.call(loadArmy({ projectDir: repoDir }).army.roles, roleName);
|
|
2606
|
+
if (!defined) throw new Error(`Role "${roleName}" is not defined in any army roster. Run nomarmy army init first.`);
|
|
2068
2607
|
const target = parseTargetSpec(agentName, model);
|
|
2069
2608
|
const check = flag("no-check") ? { status: "skipped" } : checkRoleModel(agentName, model);
|
|
2070
2609
|
if (check.status === "failed") {
|
|
@@ -2120,8 +2659,9 @@ function checkRoleModel(agentName, model) {
|
|
|
2120
2659
|
// Which agent the General is. Global or local only: it describes the
|
|
2121
2660
|
// person's own coordinator session, which a committed project file can't know.
|
|
2122
2661
|
async function cmdArmyGeneral() {
|
|
2123
|
-
const
|
|
2124
|
-
|
|
2662
|
+
const positional = armyPositionals();
|
|
2663
|
+
const [agentName] = positional;
|
|
2664
|
+
if (!agentName || positional.length !== 1) throw new Error("Usage: nomarmy army general <agent> [--global|--local]");
|
|
2125
2665
|
const layer = armyLayerFlag("global");
|
|
2126
2666
|
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.");
|
|
2127
2667
|
const agents = loadAgentsOrExit().agents;
|
|
@@ -2156,14 +2696,38 @@ async function cmdConfigPaths() {
|
|
|
2156
2696
|
if (json) return out({ globalDir: globalConfigDir(), agents: { path: agentsPath, exists: fs.existsSync(agentsPath) }, army });
|
|
2157
2697
|
console.log(c.bold("nomArmy config") + c.dim(` (global dir: ${globalConfigDir()})`));
|
|
2158
2698
|
console.log(` ${fs.existsSync(agentsPath) ? c.green("●") : c.dim("○")} ${"agents".padEnd(8)} ${c.dim(agentsPath)}`);
|
|
2699
|
+
const { limitsPath } = await import("../lib/limits.mjs");
|
|
2700
|
+
console.log(` ${fs.existsSync(limitsPath()) ? c.green("●") : c.dim("○")} ${"limits".padEnd(8)} ${c.dim(limitsPath())}`);
|
|
2159
2701
|
console.log(c.bold("\nArmy layers"));
|
|
2160
2702
|
for (const a of army) console.log(` ${a.exists ? c.green("●") : c.dim("○")} ${a.layer.padEnd(8)} ${c.dim(a.path)}`);
|
|
2161
2703
|
}
|
|
2162
2704
|
|
|
2705
|
+
// `nomarmy config max-jobs [n]`: api and subscription jobs at once, machine-wide.
|
|
2706
|
+
async function cmdConfigMaxJobs() {
|
|
2707
|
+
const { maxJobs, setMaxJobs, jobsThatFit, vmGibFor } = await import("../lib/limits.mjs");
|
|
2708
|
+
const given = argv[2];
|
|
2709
|
+
if (given !== undefined) {
|
|
2710
|
+
if (!/^\d+$/.test(given)) throw new Error(`max-jobs must be a whole number, got "${given}"`);
|
|
2711
|
+
setMaxJobs(Number(given));
|
|
2712
|
+
}
|
|
2713
|
+
const limit = maxJobs();
|
|
2714
|
+
const machine = process.platform === "linux" ? null : pickMachine(spawnSync("podman", ["machine", "inspect"], { encoding: "utf8" }).stdout);
|
|
2715
|
+
const fit = jobsThatFit(machine?.memoryMb);
|
|
2716
|
+
if (json) return out({ maxJobs: limit.value, source: limit.source, path: limit.path, problem: limit.problem, podmanVmMemoryMb: machine?.memoryMb ?? null, jobsThatFit: fit });
|
|
2717
|
+
const from = { file: `set in ${limit.path}`, env: "from NOMARMY_MAX_POOL_WORKERS in this shell (limits.yml doesn't set it)", default: "the default" }[limit.source];
|
|
2718
|
+
if (limit.problem) console.log(c.yellow(`⚠ ${limit.problem}`));
|
|
2719
|
+
console.log(`${given !== undefined ? c.green("✓ ") : ""}Up to ${c.bold(String(limit.value))} api and subscription jobs at once, across every session (${from}).`);
|
|
2720
|
+
console.log(c.dim("Each agent's max_concurrent in agents.yml also applies, and local-model jobs have their own limit."));
|
|
2721
|
+
if (given !== undefined) console.log(c.dim("Applies to the next job in every session on this version, no restart."));
|
|
2722
|
+
if (fit !== null && limit.value > fit) console.log(c.yellow(`⚠ The Podman VM (${machine.memoryMb / 1024} GiB) fits about ${fit} sandboxes at once; more get refused for memory or cut off. Fix: nomarmy sandbox --memory ${vmGibFor(limit.value)}`));
|
|
2723
|
+
if (given === undefined) console.log(c.dim("Change it with `nomarmy config max-jobs <n>` (1 to 32)."));
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2163
2726
|
async function cmdConfig() {
|
|
2164
2727
|
const sub = argv[1] ?? "paths";
|
|
2165
2728
|
if (sub === "paths") return cmdConfigPaths();
|
|
2166
|
-
|
|
2729
|
+
if (sub === "max-jobs") return cmdConfigMaxJobs();
|
|
2730
|
+
throw new Error(`Unknown config subcommand "${sub}". Use: nomarmy config <paths|max-jobs [n]>`);
|
|
2167
2731
|
}
|
|
2168
2732
|
|
|
2169
2733
|
// --- `nomarmy jobs [--watch]`: what's running, from any session -----------
|
|
@@ -2183,7 +2747,7 @@ function pidIsAlive(pid) {
|
|
|
2183
2747
|
try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; }
|
|
2184
2748
|
}
|
|
2185
2749
|
|
|
2186
|
-
function collectJobs({ recent = 8 } = {}) {
|
|
2750
|
+
function collectJobs({ recent = 8, runId = null, projectDir = null } = {}) {
|
|
2187
2751
|
const root = jobsRootDir();
|
|
2188
2752
|
let names = [];
|
|
2189
2753
|
try { names = fs.readdirSync(root); } catch { return { running: [], recent: [] }; }
|
|
@@ -2191,6 +2755,10 @@ function collectJobs({ recent = 8 } = {}) {
|
|
|
2191
2755
|
const dir = path.join(root, name);
|
|
2192
2756
|
const status = readJsonSafe(path.join(dir, "status.json")) ?? {};
|
|
2193
2757
|
const meta = readJsonSafe(path.join(dir, "metadata.json"));
|
|
2758
|
+
const lease = readJsonSafe(path.join(agentStateRoot(), "leases", `${name}.json`));
|
|
2759
|
+
const jobRunId = meta?.labels?.runId ?? lease?.runId ?? null;
|
|
2760
|
+
const jobProjectDir = meta?.projectDir ?? lease?.repo ?? null;
|
|
2761
|
+
if ((runId && jobRunId !== runId) || (projectDir && (!jobProjectDir || path.resolve(jobProjectDir) !== projectDir))) return null;
|
|
2194
2762
|
const started = Date.parse(status.startedAt ?? meta?.startedAt ?? "") || fs.statSync(dir).mtimeMs;
|
|
2195
2763
|
const running = status.state === "running" && pidIsAlive(status.serverPid);
|
|
2196
2764
|
return {
|
|
@@ -2203,7 +2771,7 @@ function collectJobs({ recent = 8 } = {}) {
|
|
|
2203
2771
|
heartbeatAgeSeconds: status.heartbeatAt ? Math.round((Date.now() - Date.parse(status.heartbeatAt)) / 1000) : null,
|
|
2204
2772
|
started, dir,
|
|
2205
2773
|
};
|
|
2206
|
-
}).sort((a, b) => b.started - a.started);
|
|
2774
|
+
}).filter(Boolean).sort((a, b) => b.started - a.started);
|
|
2207
2775
|
return { running: jobs.filter((j) => j.running), recent: jobs.filter((j) => !j.running).slice(0, recent) };
|
|
2208
2776
|
}
|
|
2209
2777
|
|
|
@@ -2232,6 +2800,19 @@ function renderJobs({ running, recent }) {
|
|
|
2232
2800
|
*/
|
|
2233
2801
|
async function streamJobEvents() {
|
|
2234
2802
|
const interval = Math.max(1, Number(value("interval", "3")) || 3) * 1000;
|
|
2803
|
+
// A stream nobody reads must still end: a General that ran this as a
|
|
2804
|
+
// background command (reported only on exit) was never told jobs had
|
|
2805
|
+
// finished, and eight of these streams were left running for days.
|
|
2806
|
+
const untilDone = flag("until-done");
|
|
2807
|
+
const runId = value("run");
|
|
2808
|
+
const projectDir = flag("repo") ? repoDir : null;
|
|
2809
|
+
if (!runId && !projectDir) {
|
|
2810
|
+
const detail = "watching every job on this machine; use --wait <ids> or --run <id> to scope the watch";
|
|
2811
|
+
if (json) console.log(JSON.stringify({ at: new Date().toISOString(), event: "scope", detail }));
|
|
2812
|
+
else console.log(detail);
|
|
2813
|
+
}
|
|
2814
|
+
const idleLimitMs = Math.max(1, Number(value("idle-minutes", "30")) || 30) * 60000;
|
|
2815
|
+
let idleSinceMs = Date.now(), sawRunning = false;
|
|
2235
2816
|
const seen = new Map();
|
|
2236
2817
|
const emit = (event, job, detail = "") => {
|
|
2237
2818
|
if (json) console.log(JSON.stringify({ at: new Date().toISOString(), event, jobId: job.jobId, agent: job.agent, model: job.model, phase: job.phase, detail }));
|
|
@@ -2240,7 +2821,7 @@ async function streamJobEvents() {
|
|
|
2240
2821
|
process.on("SIGINT", () => process.exit(0));
|
|
2241
2822
|
let first = true;
|
|
2242
2823
|
for (;;) {
|
|
2243
|
-
const { running, recent } = collectJobs({ recent: 20 });
|
|
2824
|
+
const { running, recent } = collectJobs({ recent: 20, runId, projectDir });
|
|
2244
2825
|
const now = new Map([...running, ...recent].map((j) => [j.jobId, j]));
|
|
2245
2826
|
for (const j of running) {
|
|
2246
2827
|
const prev = seen.get(j.jobId);
|
|
@@ -2251,13 +2832,85 @@ async function streamJobEvents() {
|
|
|
2251
2832
|
const j = now.get(id);
|
|
2252
2833
|
if (prev.running && j && !j.running) emit("finished", j, `${j.phase} after ${fmtSeconds(j.elapsedSeconds)}`);
|
|
2253
2834
|
}
|
|
2835
|
+
// A finished record can briefly lack its run label after its lease is removed.
|
|
2836
|
+
// Keep the running snapshot until the stamped record becomes visible.
|
|
2837
|
+
const pending = (runId || projectDir) ? [...seen].filter(([id, j]) => j.running && !now.has(id)) : [];
|
|
2254
2838
|
seen.clear();
|
|
2255
|
-
for (const [id, j] of now) seen.set(id, j);
|
|
2839
|
+
for (const [id, j] of [...now, ...pending]) seen.set(id, j);
|
|
2256
2840
|
first = false;
|
|
2841
|
+
if (running.length || pending.length) { sawRunning = true; idleSinceMs = Date.now(); }
|
|
2842
|
+
else if (untilDone && sawRunning) {
|
|
2843
|
+
if (json) console.log(JSON.stringify({ at: new Date().toISOString(), event: "done", detail: "every job seen running has finished" }));
|
|
2844
|
+
else console.log(`${new Date().toLocaleTimeString()} done every job seen running has finished`);
|
|
2845
|
+
return;
|
|
2846
|
+
} else if (Date.now() - idleSinceMs >= (untilDone ? Math.min(idleLimitMs, 120000) : idleLimitMs)) {
|
|
2847
|
+
const why = untilDone ? "no job was running to wait for" : `nothing has run for ${Math.round(idleLimitMs / 60000)} minutes`;
|
|
2848
|
+
if (json) console.log(JSON.stringify({ at: new Date().toISOString(), event: "idle", detail: why }));
|
|
2849
|
+
else console.log(`${new Date().toLocaleTimeString()} idle ${why}; exiting`);
|
|
2850
|
+
return;
|
|
2851
|
+
}
|
|
2257
2852
|
await new Promise((r) => setTimeout(r, interval));
|
|
2258
2853
|
}
|
|
2259
2854
|
}
|
|
2260
2855
|
|
|
2856
|
+
const commitSha = (commit) => (typeof commit === "string" ? commit : typeof commit?.sha === "string" ? commit.sha : null);
|
|
2857
|
+
|
|
2858
|
+
/** Wait for selected jobs in the shared, cross-session state directory. */
|
|
2859
|
+
async function waitForJobCli() {
|
|
2860
|
+
const waitIndex = argv.indexOf("--wait");
|
|
2861
|
+
const requested = [];
|
|
2862
|
+
for (let i = waitIndex + 1; i < argv.length && !argv[i].startsWith("--"); i++) requested.push(...argv[i].split(","));
|
|
2863
|
+
const jobIds = [...new Set(requested)];
|
|
2864
|
+
const timeoutSeconds = Number(value("timeout", "1800"));
|
|
2865
|
+
const errorOut = (message) => {
|
|
2866
|
+
if (json) out({ error: message });
|
|
2867
|
+
else console.error(`nomarmy jobs: ${message}`);
|
|
2868
|
+
process.exitCode = 2;
|
|
2869
|
+
};
|
|
2870
|
+
if (!jobIds.length) return errorOut("--wait needs a job id");
|
|
2871
|
+
if (jobIds.some((id) => !id || path.basename(id) !== id || id === "." || id === "..")) return errorOut("--wait needs valid job ids");
|
|
2872
|
+
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds < 0) return errorOut("--timeout must be a non-negative number of seconds");
|
|
2873
|
+
for (const jobId of jobIds) {
|
|
2874
|
+
if (!fs.existsSync(path.join(jobsRootDir(), jobId)) && !fs.existsSync(path.join(agentStateRoot(), "leases", `${jobId}.json`))) {
|
|
2875
|
+
return errorOut(`unknown job id: ${jobId}`);
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
2879
|
+
const results = await Promise.all(jobIds.map(async (jobId) => {
|
|
2880
|
+
const jobDir = path.join(jobsRootDir(), jobId);
|
|
2881
|
+
for (;;) {
|
|
2882
|
+
const status = readJsonSafe(path.join(jobDir, "status.json")) ?? {};
|
|
2883
|
+
const meta = readJsonSafe(path.join(jobDir, "metadata.json")) ?? {};
|
|
2884
|
+
if (status.state === "finished" || meta.outcome) {
|
|
2885
|
+
const issues = Array.isArray(meta.issues) ? meta.issues : Array.isArray(status.issues) ? status.issues : [];
|
|
2886
|
+
const result = {
|
|
2887
|
+
jobId,
|
|
2888
|
+
outcome: meta.outcome ?? status.outcome ?? null,
|
|
2889
|
+
coordinatorStatus: meta.coordinatorStatus ?? status.coordinatorStatus ?? null,
|
|
2890
|
+
branch: meta.branch ?? status.branch ?? null,
|
|
2891
|
+
commit: commitSha(meta.commit) ?? commitSha(status.commit),
|
|
2892
|
+
issues,
|
|
2893
|
+
};
|
|
2894
|
+
if (json) out(result);
|
|
2895
|
+
else {
|
|
2896
|
+
const firstIssue = issues[0];
|
|
2897
|
+
const issueText = firstIssue == null ? null : typeof firstIssue === "string" ? firstIssue : firstIssue.message ?? JSON.stringify(firstIssue);
|
|
2898
|
+
console.log([result.jobId, result.outcome ?? "unknown", result.coordinatorStatus ?? "unknown",
|
|
2899
|
+
result.branch ? `branch=${result.branch}` : null, result.commit ? `commit=${result.commit}` : null,
|
|
2900
|
+
issueText ? `issue=${issueText}` : null].filter(Boolean).join(" "));
|
|
2901
|
+
}
|
|
2902
|
+
return result.coordinatorStatus === "complete" ? 0 : 1;
|
|
2903
|
+
}
|
|
2904
|
+
if (Date.now() >= deadline) {
|
|
2905
|
+
errorOut(`timed out waiting for job ${jobId}`);
|
|
2906
|
+
return 2;
|
|
2907
|
+
}
|
|
2908
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(2000, Math.max(1, deadline - Date.now()))));
|
|
2909
|
+
}
|
|
2910
|
+
}));
|
|
2911
|
+
process.exitCode = Math.max(...results);
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2261
2914
|
/**
|
|
2262
2915
|
* `nomarmy jobs --prune [--older-than DAYS]`: remove runtime/ (per-job npm
|
|
2263
2916
|
* cache, harness state such as Codex's, OpenClaw's transcript) from
|
|
@@ -2274,6 +2927,14 @@ function pruneJobRuntimeCli() {
|
|
|
2274
2927
|
}
|
|
2275
2928
|
|
|
2276
2929
|
async function cmdJobs() {
|
|
2930
|
+
if (flag("wait")) return waitForJobCli();
|
|
2931
|
+
if (flag("stop")) {
|
|
2932
|
+
const r = requestJobStop({ jobsRoot: jobsRootDir(), jobId: value("stop"), reason: value("reason") });
|
|
2933
|
+
if (json) return out(r);
|
|
2934
|
+
console.log(r.ok ? c.green(`✓ ${r.message}`) : c.red(`✗ ${r.message}`));
|
|
2935
|
+
if (!r.ok) process.exitCode = 1;
|
|
2936
|
+
return;
|
|
2937
|
+
}
|
|
2277
2938
|
if (flag("events")) return streamJobEvents();
|
|
2278
2939
|
if (flag("prune")) return pruneJobRuntimeCli();
|
|
2279
2940
|
if (json) return out(collectJobs());
|
|
@@ -2301,6 +2962,7 @@ async function cmdHealth() {
|
|
|
2301
2962
|
const { checkAndRecordHealth } = await import("../lib/health.mjs");
|
|
2302
2963
|
const stateRoot = process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents");
|
|
2303
2964
|
const { result } = await checkAndRecordHealth({ projectDir: repoDir, stateRoot, configDir: globalConfigDir(), env: installEnv() });
|
|
2965
|
+
if (result.issues.some((i) => i.severity === "error")) process.exitCode = 1;
|
|
2304
2966
|
if (json) return out(result);
|
|
2305
2967
|
console.log(c.bold("🍪 nomArmy health") + c.dim(` ${new Date(result.checkedAt).toLocaleString()}`));
|
|
2306
2968
|
if (!result.issues.length) { console.log(c.green("\n✓ Nothing to fix.")); return; }
|
|
@@ -2320,15 +2982,249 @@ async function cmdStatusline() {
|
|
|
2320
2982
|
process.stdout.write(`${statusLineText({ session })}\n`);
|
|
2321
2983
|
}
|
|
2322
2984
|
|
|
2323
|
-
|
|
2985
|
+
// `stats --share`: markdown for a PR description or README; `--badge [path]`:
|
|
2986
|
+
// an SVG badge to commit, with the README line for it (lib/share.mjs).
|
|
2987
|
+
async function shareStats(stats) {
|
|
2988
|
+
const { shareMarkdown, badgeSvg, badgeMarkdown } = await import("../lib/share.mjs");
|
|
2989
|
+
const scope = value("run") ? "this feature run" : value("since") ? `since ${value("since")}` : null;
|
|
2990
|
+
if (flag("share")) console.log(shareMarkdown(stats, { scope }));
|
|
2991
|
+
if (flag("badge")) {
|
|
2992
|
+
const given = value("badge");
|
|
2993
|
+
const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
|
2994
|
+
const root = top.status === 0 ? top.stdout.trim() : process.cwd();
|
|
2995
|
+
const file = path.resolve(root, given ?? path.join(".github", "nomarmy-badge.svg"));
|
|
2996
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
2997
|
+
fs.writeFileSync(file, badgeSvg(stats));
|
|
2998
|
+
const rel = path.relative(root, file).split(path.sep).join("/");
|
|
2999
|
+
console.log(`${flag("share") ? "\n" : ""}${c.green("✓")} Wrote ${rel}. Commit it and add this to your README:\n\n ${badgeMarkdown(rel)}\n`);
|
|
3000
|
+
console.log(c.dim("Re-run nomarmy stats --badge after more jobs to refresh the numbers."));
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
function cmdStats() {
|
|
3005
|
+
const stateRoot = process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents");
|
|
3006
|
+
const records = loadJobRecords(path.join(stateRoot, "jobs"));
|
|
3007
|
+
let repo = null;
|
|
3008
|
+
if (value("repo")) repo = resolveRepo(records, value("repo"));
|
|
3009
|
+
else if (!flag("all-repos")) {
|
|
3010
|
+
try { repo = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
|
|
3011
|
+
catch { throw new Error(`${repoDir} isn't inside a git repository; run nomarmy stats from one, or pass --repo <name> or --all-repos`); }
|
|
3012
|
+
}
|
|
3013
|
+
let agentFor = () => null;
|
|
3014
|
+
try { agentFor = agentLookup(loadAgents(globalConfigDir()).agents, agentProviderId); } catch { /* no agents.yml: commands name <agent> */ }
|
|
3015
|
+
const stats = computeStats(records, { repo, sinceMs: parseSince(value("since")), untilMs: parseSince(value("until")), role: value("role"), model: value("model"), runId: value("run"), agentFor, allSuggestions: flag("all-suggestions") });
|
|
3016
|
+
if (json) return out(stats);
|
|
3017
|
+
if (flag("share") || flag("badge")) return shareStats(stats);
|
|
3018
|
+
console.log(flag("details") ? formatStats(stats) : formatStatsSummary(stats, { c }));
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
// Read one line without echoing it: stty -echo around the read, restored
|
|
3022
|
+
// even if the read fails. Windows has no stty, so it says the input shows.
|
|
3023
|
+
async function readHiddenLine(prompt) {
|
|
3024
|
+
const hide = process.stdin.isTTY && process.platform !== "win32";
|
|
3025
|
+
if (!hide) console.log(c.yellow("(your input will be visible as you type)"));
|
|
3026
|
+
const rl = createInterface({ input, output });
|
|
3027
|
+
try {
|
|
3028
|
+
if (hide) spawnSync("stty", ["-echo"], { stdio: ["inherit", "ignore", "ignore"] });
|
|
3029
|
+
return (await rl.question(prompt)).trim();
|
|
3030
|
+
} finally {
|
|
3031
|
+
if (hide) { spawnSync("stty", ["echo"], { stdio: ["inherit", "ignore", "ignore"] }); process.stdout.write("\n"); }
|
|
3032
|
+
rl.close();
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
async function readStdin() {
|
|
3037
|
+
const chunks = [];
|
|
3038
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
3039
|
+
return Buffer.concat(chunks).toString("utf8").trim();
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
// One tiny System One request, to prove the key and the route work.
|
|
3043
|
+
async function testJev(settings) {
|
|
3044
|
+
const { answers } = await askJev({ key: settings.key, model: settings.model, state: { text: "The build finished and all 12 tests passed." },
|
|
3045
|
+
questions: { passed: { type: "noul", instructions: "Does the text say the tests passed?", criteria: { true: "It says the tests passed", false: "It doesn't" } } } });
|
|
3046
|
+
return typeof answers.passed?.noul === "number";
|
|
3047
|
+
}
|
|
3048
|
+
|
|
3049
|
+
async function cmdValidators() {
|
|
3050
|
+
const [sub = "list", name] = argv.slice(1).filter((a) => !a.startsWith("--"));
|
|
3051
|
+
if (sub === "list") {
|
|
3052
|
+
let config = {};
|
|
3053
|
+
try { config = loadValidators(); } catch (error) { if (json) return out({ error: error.message }); console.log(c.red(error.message)); process.exitCode = 1; return; }
|
|
3054
|
+
const judge = config.judge ? { enabled: config.judge.enabled, agent: config.judge.agent, model: config.judge.model, checks: config.judge.checks, hostTools: config.judge.host_tools } : null;
|
|
3055
|
+
const jev = config.jev ? { enabled: config.jev.enabled, checks: config.jev.checks, model: config.jev.model, key: config.jev.key_env ? `env ${config.jev.key_env}` : config.jev.key_file, keyReadable: Boolean(jevSettings()) } : null;
|
|
3056
|
+
if (json) return out({ path: validatorsPath(), jev, judge });
|
|
3057
|
+
if (!jev && !judge) { console.log("No validators configured. Add one with: nomarmy validators add jev, or nomarmy validators add judge --agent <name> --model <model>"); return; }
|
|
3058
|
+
if (jev) console.log(`Jev: ${jev.enabled ? c.green("on") : "off"} (${jev.model}); checks: ${jev.checks.join(", ")}; key: ${jev.key}${jev.keyReadable ? "" : c.red(" (not readable)")}`);
|
|
3059
|
+
if (judge) console.log(`Judge: ${judge.enabled ? c.green("on") : "off"} (${judge.agent}/${judge.model}); checks: ${judge.checks.join(", ")}${judge.hostTools ? c.yellow("; host tools allowed") : ""}`);
|
|
3060
|
+
return;
|
|
3061
|
+
}
|
|
3062
|
+
if (name === "judge") return cmdValidatorsJudge(sub);
|
|
3063
|
+
if (name !== "jev") throw new Error("Usage: nomarmy validators <list|add jev|test jev|remove jev|add judge|test judge|remove judge>");
|
|
3064
|
+
if (sub === "add") {
|
|
3065
|
+
if (!json) {
|
|
3066
|
+
console.log(c.bold("🍪 Jev (TypeSafe) for nomArmy's semantic checks\n"));
|
|
3067
|
+
console.log("It checks that a scout's cited lines support its finding, and that a worker's report matches its diff.");
|
|
3068
|
+
console.log("Its answers only add review flags; they never pass a check or allow a commit.");
|
|
3069
|
+
console.log(c.yellow("It sends excerpts of your code (findings, cited lines, diffs, worker reports) to TypeSafe.\n"));
|
|
3070
|
+
}
|
|
3071
|
+
const key = flag("key-stdin") ? await readStdin() : await readHiddenLine("TypeSafe API key (not shown): ");
|
|
3072
|
+
const saved = saveJevKey(key);
|
|
3073
|
+
let ok = false, why = null;
|
|
3074
|
+
try { ok = await testJev(jevSettings()); } catch (error) { why = error.message; }
|
|
3075
|
+
if (json) return out({ saved: true, keyFile: saved.keyFile, configPath: saved.configPath, test: ok ? "pass" : "fail", reason: why });
|
|
3076
|
+
console.log(c.green(`✓ Saved the key to ${saved.keyFile} (readable only by you) and turned Jev on in ${saved.configPath}.`));
|
|
3077
|
+
console.log(ok ? c.green("✓ Test call answered. New jobs use it; restart open coordinator sessions to pick it up.") : c.red(`✗ Test call failed: ${why ?? "no answer"}. Check the key, then: nomarmy validators test jev`));
|
|
3078
|
+
if (!ok) process.exitCode = 1;
|
|
3079
|
+
return;
|
|
3080
|
+
}
|
|
3081
|
+
if (sub === "test") {
|
|
3082
|
+
const settings = jevSettings();
|
|
3083
|
+
if (!settings) throw new Error("Jev isn't configured, or its key isn't readable. Add it with: nomarmy validators add jev");
|
|
3084
|
+
let ok = false, why = null;
|
|
3085
|
+
try { ok = await testJev(settings); } catch (error) { why = error.message; }
|
|
3086
|
+
if (json) return out({ test: ok ? "pass" : "fail", reason: why });
|
|
3087
|
+
console.log(ok ? c.green("✓ Jev answered.") : c.red(`✗ Jev test call failed: ${why ?? "no answer"}`));
|
|
3088
|
+
if (!ok) process.exitCode = 1;
|
|
3089
|
+
return;
|
|
3090
|
+
}
|
|
3091
|
+
if (sub === "remove") {
|
|
3092
|
+
const result = removeJev();
|
|
3093
|
+
if (json) return out(result);
|
|
3094
|
+
console.log(c.green(`✓ Jev is off${result.removedKey ? ", and its saved key is deleted" : ""}.`));
|
|
3095
|
+
return;
|
|
3096
|
+
}
|
|
3097
|
+
throw new Error("Usage: nomarmy validators <list|add jev|test jev|remove jev>");
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
async function cmdValidatorsJudge(sub) {
|
|
3101
|
+
const agents = loadAgents(globalConfigDir()).agents;
|
|
3102
|
+
const resolve = () => judgeSettings({ agents, providerOf: agentProviderId, runsOnHost: agentRunsToolsOnHost });
|
|
3103
|
+
const probe = async (settings) => probeModel({ provider: settings.provider, model: settings.model, stateRoot: process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents") });
|
|
3104
|
+
if (sub === "add") {
|
|
3105
|
+
let agent = value("agent"), model = value("model"), dominantBuilderVendor = null;
|
|
3106
|
+
let rl = null;
|
|
3107
|
+
const question = async (prompt) => {
|
|
3108
|
+
rl ??= createInterface({ input, output });
|
|
3109
|
+
return rl.question(c.bold(prompt));
|
|
3110
|
+
};
|
|
3111
|
+
if (!agent) {
|
|
3112
|
+
if (!process.stdin.isTTY || json) throw new Error("Usage: nomarmy validators add judge --agent <name> --model <model> [--host-tools]");
|
|
3113
|
+
const roles = loadArmy({ projectDir: repoDir }).army.roles;
|
|
3114
|
+
const guided = judgeAgentChoices({ agents, roles, providerOf: agentProviderId, runsOnHost: agentRunsToolsOnHost });
|
|
3115
|
+
dominantBuilderVendor = guided.dominantBuilderVendor;
|
|
3116
|
+
if (!guided.choices.length) throw new Error("No api or subscription agents are configured in agents.yml.");
|
|
3117
|
+
agent = (await chooseJudgeAgent({ choices: guided.choices, ask: question, write: (line) => console.log(line) })).name;
|
|
3118
|
+
}
|
|
3119
|
+
if (!agents[agent]) throw new Error(`"${agent}" isn't an agent in agents.yml. Agents: ${Object.keys(agents).join(", ") || "(none)"}`);
|
|
3120
|
+
if (!model) {
|
|
3121
|
+
if (!process.stdin.isTTY || json) throw new Error("Usage: nomarmy validators add judge --agent <name> --model <model> [--host-tools]");
|
|
3122
|
+
const listed = catalogModelsFor(agentProviderId(agents[agent]));
|
|
3123
|
+
if (listed.length) {
|
|
3124
|
+
console.log(`Models for ${agent}:`);
|
|
3125
|
+
listed.forEach((item, index) => console.log(` ${index + 1}. ${item}`));
|
|
3126
|
+
}
|
|
3127
|
+
const fallback = agents[agent].model ?? "";
|
|
3128
|
+
const answer = String(await question(`Model id${fallback ? ` [${fallback}]` : ""}: `)).trim();
|
|
3129
|
+
model = /^\d+$/.test(answer) && listed[Number(answer) - 1] ? listed[Number(answer) - 1] : answer || fallback;
|
|
3130
|
+
if (!model) { rl?.close(); throw new Error("A model id is required."); }
|
|
3131
|
+
}
|
|
3132
|
+
let hostTools = flag("host-tools");
|
|
3133
|
+
if (agentRunsToolsOnHost(agents[agent]) && !hostTools) {
|
|
3134
|
+
if (!process.stdin.isTTY || json) {
|
|
3135
|
+
const quote = (arg) => /^[A-Za-z0-9_./:-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", `'\\''`)}'`;
|
|
3136
|
+
const rerun = ["nomarmy", ...argv, "--host-tools"].map(quote).join(" ");
|
|
3137
|
+
throw new Error(`agent "${agent}" runs its tools on this machine, and a judge reads text the worker wrote. Refusing without explicit consent. Re-run: ${rerun}`);
|
|
3138
|
+
}
|
|
3139
|
+
const allowed = await confirmJudgeHostTools({ agent, ask: question, write: (line) => console.log(line) });
|
|
3140
|
+
if (!allowed) { rl?.close(); console.log(c.dim("Canceled; nothing written.")); return; }
|
|
3141
|
+
hostTools = true;
|
|
3142
|
+
}
|
|
3143
|
+
rl?.close();
|
|
3144
|
+
const saved = saveJudge({ agent, model, hostTools });
|
|
3145
|
+
const settings = resolve();
|
|
3146
|
+
if (settings?.problem) throw new Error(settings.problem);
|
|
3147
|
+
const test = await probe(settings);
|
|
3148
|
+
if (json) return out({ saved: true, configPath: saved.configPath, test: test.ok ? "pass" : test.refused ? "refused" : "inconclusive", reason: test.reason });
|
|
3149
|
+
console.log(c.green(`✓ The judge is ${agent}/${model}, in ${saved.configPath}.`));
|
|
3150
|
+
if (dominantBuilderVendor === null) {
|
|
3151
|
+
try {
|
|
3152
|
+
const roles = loadArmy({ projectDir: repoDir }).army.roles;
|
|
3153
|
+
dominantBuilderVendor = judgeAgentChoices({ agents, roles, providerOf: agentProviderId, runsOnHost: agentRunsToolsOnHost }).dominantBuilderVendor;
|
|
3154
|
+
} catch { /* no readable army means there is no builder comparison */ }
|
|
3155
|
+
}
|
|
3156
|
+
if (dominantBuilderVendor && agentProviderId(agents[agent]) === dominantBuilderVendor) console.log(c.yellow("Note: this judge uses the same vendor as most build roles, so its verdicts are not independent of those builders."));
|
|
3157
|
+
console.log(test.ok ? c.green("✓ Test call answered. New implement jobs use it; restart open coordinator sessions to pick it up.") : c.red(`✗ Test call ${test.refused ? "refused" : "didn't answer"}: ${test.reason ?? "no answer"}`));
|
|
3158
|
+
if (!test.ok) process.exitCode = 1;
|
|
3159
|
+
return;
|
|
3160
|
+
}
|
|
3161
|
+
if (sub === "test") {
|
|
3162
|
+
const settings = resolve();
|
|
3163
|
+
if (!settings) throw new Error("No judge configured. Add one with: nomarmy validators add judge --agent <name> --model <model>");
|
|
3164
|
+
if (settings.problem) throw new Error(settings.problem);
|
|
3165
|
+
const test = await probe(settings);
|
|
3166
|
+
if (json) return out({ test: test.ok ? "pass" : "fail", reason: test.reason });
|
|
3167
|
+
console.log(test.ok ? c.green(`✓ ${settings.agent}/${settings.model} answered.`) : c.red(`✗ ${test.reason ?? "no answer"}`));
|
|
3168
|
+
if (!test.ok) process.exitCode = 1;
|
|
3169
|
+
return;
|
|
3170
|
+
}
|
|
3171
|
+
if (sub === "remove") {
|
|
3172
|
+
removeJudge();
|
|
3173
|
+
return json ? out({ removed: true }) : console.log(c.green("✓ The judge is off."));
|
|
3174
|
+
}
|
|
3175
|
+
throw new Error("Usage: nomarmy validators <add judge --agent <name> --model <model> [--host-tools]|test judge|remove judge>");
|
|
3176
|
+
}
|
|
3177
|
+
|
|
3178
|
+
// `nomarmy mcp`: what a --scope project registration runs. Nothing goes to
|
|
3179
|
+
// stdout but the server's own protocol.
|
|
3180
|
+
function cmdMcp() {
|
|
3181
|
+
const { serverPath, env } = portableServerLaunch({ nomarmyRoot });
|
|
3182
|
+
const child = spawn(process.execPath, [serverPath], { stdio: "inherit", env });
|
|
3183
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, () => child.kill(signal));
|
|
3184
|
+
child.on("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exit(code ?? 1); });
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
const commands = { stats: cmdStats, validators: cmdValidators, mcp: cmdMcp, scan: cmdScan, validate: cmdValidate, sizing: cmdSizing, init: cmdInit, setup: cmdSetup, install: cmdInstall, model: cmdModel, agents: cmdAgents, army: cmdArmy, jobs: cmdJobs, statusline: cmdStatusline, health: cmdHealth, config: cmdConfig, update: cmdUpdate, connect: cmdConnect, sandbox: cmdSandbox, start: cmdStart, stop: cmdStop, uninstall: cmdUninstall, help: () => usage(0) };
|
|
2324
3188
|
// doctor command
|
|
2325
3189
|
async function cmdDoctor() {
|
|
3190
|
+
if (windowsFrontEnd()) {
|
|
3191
|
+
process.exitCode = windowsDoctor({ json, argv });
|
|
3192
|
+
return;
|
|
3193
|
+
}
|
|
2326
3194
|
// Import lazily to avoid circular dependencies
|
|
2327
3195
|
const { runDoctor } = await import("../lib/doctor.mjs");
|
|
2328
|
-
|
|
3196
|
+
const agents = loadAgentsOrExit().agents;
|
|
3197
|
+
const vendors = configuredSubscriptionVendors(agents);
|
|
3198
|
+
let armySummary = null;
|
|
3199
|
+
try { armySummary = describeArmy(loadArmy({ projectDir: repoDir }), { agents }); } catch { /* other doctor checks still run */ }
|
|
3200
|
+
let checks;
|
|
3201
|
+
if (flag("fix")) {
|
|
3202
|
+
const repaired = await repairOpenclaw({
|
|
3203
|
+
command: openclawCmd(), vendors, yes: flag("yes"), isTTY: Boolean(input.isTTY),
|
|
3204
|
+
print: json ? console.error : console.log,
|
|
3205
|
+
ask: async (prompt) => {
|
|
3206
|
+
const rl = createInterface({ input, output: json ? process.stderr : output });
|
|
3207
|
+
try { return await rl.question(prompt); } finally { rl.close(); }
|
|
3208
|
+
},
|
|
3209
|
+
});
|
|
3210
|
+
checks = repaired.checks;
|
|
3211
|
+
if (!repaired.ok && !checks.some((check) => !check.ok)) checks.push({ id: "openclaw-repair", ok: false, message: "OpenClaw repair was declined or failed.", fix: "nomarmy doctor --fix --yes" });
|
|
3212
|
+
} else {
|
|
3213
|
+
checks = verifyOpenclaw({ command: openclawCmd(), vendors });
|
|
3214
|
+
}
|
|
3215
|
+
await runDoctor({ json, exit: true, env: installEnv(), additionalChecks: checks,
|
|
3216
|
+
runtime: { agents, armySummary, openclawCmd: openclawCmd() } });
|
|
2329
3217
|
}
|
|
2330
3218
|
commands.doctor = cmdDoctor;
|
|
2331
|
-
if (
|
|
3219
|
+
if (windowsFrontEnd() && windowsPlan(argv) === "FORWARD") {
|
|
3220
|
+
process.exit(windowsForward(argv));
|
|
3221
|
+
}
|
|
3222
|
+
if (!command && !flag("help")) {
|
|
3223
|
+
// New users typed `nomarmy` and got the whole command reference.
|
|
3224
|
+
console.log(`${c.bold("nomArmy")}: bounded coding workers with independently verified results.\n\n New here? ${c.cyan("nomarmy setup")} walks you through it, one step at a time\n All commands: ${c.cyan("nomarmy help")}\n Docs: https://github.com/rayson-tech/nomarmy`);
|
|
3225
|
+
process.exit(0);
|
|
3226
|
+
}
|
|
3227
|
+
if (flag("help") || !commands[command]) usage(command && !commands[command] ? 2 : 0);
|
|
2332
3228
|
|
|
2333
3229
|
try {
|
|
2334
3230
|
await commands[command]();
|