nomarmy 0.1.0-alpha.16 → 0.1.0-alpha.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/bin/nomarmy.mjs +115 -59
- package/install.sh +6 -1
- package/lib/connect.mjs +9 -7
- package/lib/doctor.mjs +2 -2
- package/lib/health.mjs +2 -2
- package/lib/openclaw-path.mjs +28 -0
- package/lib/share.mjs +82 -0
- package/lib/stale-sessions.mjs +60 -0
- package/lib/stats.mjs +79 -5
- package/lib/suggestions.mjs +33 -13
- package/lib/user-config.mjs +106 -0
- package/mcp/server.mjs +12 -4
- package/package.json +1 -1
- package/playbooks/feature.md +1 -1
- package/scripts/configure-openclaw.sh +4 -2
- package/scripts/lib.sh +9 -2
- package/scripts/select-model.mjs +8 -4
package/README.md
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
<a href="https://github.com/rayson-tech/nomarmy/actions/workflows/ci.yml"><img src="https://github.com/rayson-tech/nomarmy/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
5
5
|
<a href="https://www.npmjs.com/package/nomarmy"><img src="https://img.shields.io/npm/v/nomarmy/alpha?label=npm%40alpha" alt="npm"></a>
|
|
6
6
|
<a href="https://github.com/rayson-tech/nomarmy/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="License: Apache 2.0"></a>
|
|
7
|
+
<a href="https://github.com/rayson-tech/nomarmy/blob/main/docs/feature-runs.md"><img src="https://raw.githubusercontent.com/rayson-tech/nomarmy/main/.github/nomarmy-badge.svg" alt="nomArmy: AI worker claims checked on this repository"></a>
|
|
7
8
|
</p>
|
|
8
9
|
|
|
9
10
|
<p align="center"><em>Every byte verified.</em> 🍪</p>
|
|
@@ -50,6 +51,8 @@ Stuck? `nomarmy doctor` checks the machine and `nomarmy health` checks everythin
|
|
|
50
51
|
|
|
51
52
|
Failing verification stays failed, unconditionally. A malformed report isn't automatically a failure: if the repository changed, nomArmy verifies independently and may recover the work. And the checks aren't the General's to waive: a repo's `.nomarmy.yml` policy (on by default for new repos) makes verification and the revert check mandatory for every job.
|
|
52
53
|
|
|
54
|
+
**Showing what was checked.** When a `/feature` run finishes, the General gets a "Verified by nomArmy" block for the pull request: how many "done, tests pass" claims held up, how many nomArmy caught and why, and the new tests shown to fail without their change. `nomarmy stats --share` prints the same for any period, and `nomarmy stats --badge` writes a README badge like `nomArmy | 78 claims checked · 11 caught`. Every number comes from the verified records.
|
|
55
|
+
|
|
53
56
|
**Checking without building** costs nothing: `mode: verify` runs a verification profile against any branch, with no worker and no model tokens.
|
|
54
57
|
|
|
55
58
|
**Want deeper checks?** Three optional [validators](https://github.com/rayson-tech/nomarmy/blob/main/docs/validators.md) go further, each only adding review flags: mutation testing (do the tests pin down the changed lines?), Jev (do a scout's citations support its findings, does a report match its diff?) and a model judge (acceptance criteria, weakened tests).
|
package/bin/nomarmy.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { readGGUFMetadata, resolveModelPath, totalSplitBytes } from "../lib/gguf
|
|
|
20
20
|
import { recommend, customRecommendation, evaluateConfig, bytesPerKvElementForCacheTypes, MIN_CONTEXT_PER_NOM } from "../lib/sizing.mjs";
|
|
21
21
|
import { connectClaude, connectCodex, connectCursor, cursorAlreadyConnected, deriveWorkerModelEnv, defaultInstallDir, installMcpCopy, SCOPES, claudeUserScoped, portableServerLaunch } from "../lib/connect.mjs";
|
|
22
22
|
import { compareVersions, readPackageVersion, readInstallVersions, copyIsStale } from "../lib/install-freshness.mjs";
|
|
23
|
-
import { loadJobRecords, computeStats, formatStats, parseSince, resolveRepo, agentLookup } from "../lib/stats.mjs";
|
|
23
|
+
import { loadJobRecords, computeStats, formatStats, formatStatsSummary, parseSince, resolveRepo, agentLookup } from "../lib/stats.mjs";
|
|
24
24
|
import { requestJobStop } from "../lib/openclaw-run.mjs";
|
|
25
25
|
import { loadValidators, saveJevKey, removeJev, jevSettings, askJev, validatorsPath, JEV_CHECKS, saveJudge, removeJudge, judgeSettings } from "../lib/validators.mjs";
|
|
26
26
|
import { probeModel } from "../lib/model-probe.mjs";
|
|
@@ -31,12 +31,17 @@ import { parseLlamaUrl } from "../lib/execution.mjs";
|
|
|
31
31
|
import { setupSteps, formatSetupSteps, runSetupPlaybook } from "../lib/setup-steps.mjs";
|
|
32
32
|
import { readUsageSnapshots } from "../lib/usage-limits.mjs";
|
|
33
33
|
import { pickMachine, planResize } from "../lib/sandbox-vm.mjs";
|
|
34
|
+
import { listProcesses, staleSessions, formatStaleSessions } from "../lib/stale-sessions.mjs";
|
|
35
|
+
import { readSetting, writeSetting, writeEnvLine, userCommonPath, userProfilePath, profilePathFor, tildePath } from "../lib/user-config.mjs";
|
|
34
36
|
import { MIN_PODMAN_VM_MB } from "../lib/doctor.mjs";
|
|
35
37
|
import { liveLeases } from "../lib/slots.mjs";
|
|
36
38
|
import { ensureProviderConfig } from "../lib/openclaw-config.mjs";
|
|
37
39
|
import { recordProbeSuccess } from "../lib/health.mjs";
|
|
38
40
|
import { pruneJobRuntime } from "../lib/prune.mjs";
|
|
39
41
|
import { SUBSCRIPTION_VENDORS, parseOpenclawVersion, versionAtLeast, parseCatalogModels, parseCliLoginStatus, probeOutcome, parseMuseAuthDescriptor, extractMintedKey } from "../lib/subscription-setup.mjs";
|
|
42
|
+
import { ensureOpenClawOnPath } from "../lib/openclaw-path.mjs";
|
|
43
|
+
// OpenClaw in ~/.npm-global/bin (no writable npm prefix) is found without the operator editing PATH.
|
|
44
|
+
ensureOpenClawOnPath();
|
|
40
45
|
|
|
41
46
|
// Add a new coordinator: add its name here, teach commandExists/connectTarget
|
|
42
47
|
// about it below (a JSON-file target like Cursor has no PATH binary to check
|
|
@@ -238,7 +243,7 @@ Usage: nomarmy <command> [options]
|
|
|
238
243
|
project for this repository, committed for the team
|
|
239
244
|
(.mcp.json or .cursor/mcp.json, running \`nomarmy mcp\`).
|
|
240
245
|
Codex has only the user scope.
|
|
241
|
-
stats [--since 7d|<date>] [--until <date>] [--role <role>] [--model <model>]
|
|
246
|
+
stats [--since 7d|<date>] [--until <date>] [--role <role>] [--model <model>] [--run <id>] [--details] [--all-suggestions] [--share] [--badge [path]]
|
|
242
247
|
[--repo <path|name>] [--all-repos] [--json]
|
|
243
248
|
What nomArmy's job records show for this repository (or
|
|
244
249
|
all): volume by role and model, code committed, time,
|
|
@@ -467,15 +472,9 @@ function readEnvValue(filePath, key) {
|
|
|
467
472
|
return m ? m[1].trim() : null;
|
|
468
473
|
}
|
|
469
474
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
const line = `${key}=${value}`;
|
|
474
|
-
const updated = new RegExp(`^${key}=.*$`, "m").test(existingText)
|
|
475
|
-
? existingText.replace(new RegExp(`^${key}=.*$`, "m"), line)
|
|
476
|
-
: `${existingText.trimEnd()}\n${line}\n`.replace(/^\n/, "");
|
|
477
|
-
fs.writeFileSync(filePath, updated);
|
|
478
|
-
}
|
|
475
|
+
// Your settings live in ~/.config/nomarmy/, over the package's config/
|
|
476
|
+
// defaults, so an update can't reset them (lib/user-config.mjs).
|
|
477
|
+
const setting = (key) => readSetting(key, { nomarmyRoot });
|
|
479
478
|
|
|
480
479
|
// Each entry's repo/quant/alias is verified against this project's own real
|
|
481
480
|
// usage (downloaded, loaded, dispatched against), not guessed from a model
|
|
@@ -494,6 +493,14 @@ function writeEnvLine(filePath, key, value) {
|
|
|
494
493
|
* not left to fail only at the very end via schema validation with no
|
|
495
494
|
* indication of which of several answers was the problem.
|
|
496
495
|
*/
|
|
496
|
+
/** A numbered choice, asked again until it's one of 1..count (an empty or mistyped answer used to end the whole flow). */
|
|
497
|
+
async function askChoice(rl, count) {
|
|
498
|
+
const answer = await askUntilValid(rl, "Choice: ", { pattern: new RegExp(`^([1-9]|[1-9][0-9])$`), invalidMessage: `Choose a number from 1 to ${count}.` });
|
|
499
|
+
if (Number(answer) >= 1 && Number(answer) <= count) return Number(answer) - 1;
|
|
500
|
+
console.log(c.red(` ✗ Choose a number from 1 to ${count}.`));
|
|
501
|
+
return askChoice(rl, count);
|
|
502
|
+
}
|
|
503
|
+
|
|
497
504
|
async function askUntilValid(rl, prompt, { pattern, invalidMessage, allowEmpty = false, fallback = "" }) {
|
|
498
505
|
for (;;) {
|
|
499
506
|
const answer = (await rl.question(c.bold(prompt))).trim();
|
|
@@ -526,15 +533,15 @@ async function askSecret(rl, prompt) {
|
|
|
526
533
|
|
|
527
534
|
const KNOWN_MODELS = {
|
|
528
535
|
default: {
|
|
529
|
-
label: "Qwen3-Coder-Next
|
|
536
|
+
label: "Qwen3-Coder-Next · 48 GB download · the most reliable in our tests (recommended)",
|
|
530
537
|
repo: "Qwen/Qwen3-Coder-Next-GGUF", quant: "Q4_K_M", alias: "qwen3-coder-next", thinking: false, recommended: true,
|
|
531
538
|
},
|
|
532
539
|
"gpt-oss-20b": {
|
|
533
|
-
label: "gpt-oss-20b
|
|
540
|
+
label: "gpt-oss-20b · 12 GB download · the fastest; keep reasoning at medium",
|
|
534
541
|
repo: "ggml-org/gpt-oss-20b-GGUF", quant: "MXFP4", alias: "gpt-oss-20b", thinking: true,
|
|
535
542
|
},
|
|
536
543
|
"qwen3.6-27b": {
|
|
537
|
-
label: "Qwen3.6-27B
|
|
544
|
+
label: "Qwen3.6-27B · 17 GB download · very reliable, slower; keep reasoning at medium",
|
|
538
545
|
repo: "unsloth/Qwen3.6-27B-GGUF", quant: "Q4_K_M", alias: "qwen3.6-27b", thinking: true,
|
|
539
546
|
},
|
|
540
547
|
};
|
|
@@ -587,14 +594,14 @@ function defaultLocalProfile() {
|
|
|
587
594
|
// `install`: the one setup recorded, else the install marker's, else the
|
|
588
595
|
// execution mode's, else (a working local install) install.sh's default.
|
|
589
596
|
function setupProfileState() {
|
|
590
|
-
const common =
|
|
591
|
-
const chosen =
|
|
597
|
+
const common = userCommonPath();
|
|
598
|
+
const chosen = setting("NOMARMY_SETUP_PROFILE");
|
|
592
599
|
const probeCommand = (binary, args) => {
|
|
593
600
|
const result = spawnSync(binary, args, { encoding: "utf8", timeout: 10000 });
|
|
594
601
|
return result.status === 0 ? result.stdout.trim() : "";
|
|
595
602
|
};
|
|
596
|
-
const profileFile = chosen
|
|
597
|
-
const root = (process.env.NOMARMY_INSTALL_ROOT || (profileFile && readEnvValue(profileFile, "NOMARMY_INSTALL_ROOT")) ||
|
|
603
|
+
const profileFile = profilePathFor(chosen, { nomarmyRoot });
|
|
604
|
+
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());
|
|
598
605
|
let marker = null;
|
|
599
606
|
try { marker = JSON.parse(fs.readFileSync(path.join(root, "install.json"), "utf8")); } catch (error) { if (error.code !== "ENOENT") marker = {}; }
|
|
600
607
|
const version = probeCommand("openclaw", ["--version"]);
|
|
@@ -602,7 +609,7 @@ function setupProfileState() {
|
|
|
602
609
|
const registered = !marker && Boolean(version) && ["claude", "codex"].some((name) => spawnSync(name, ["mcp", "get", "nomarmy-local-worker"], { stdio: "ignore", timeout: 10000 }).status === 0);
|
|
603
610
|
// An install from before setup recorded its profile: the marker's, else the
|
|
604
611
|
// execution mode's, else (a working local install) install.sh's default.
|
|
605
|
-
const execution =
|
|
612
|
+
const execution = setting("NOMARMY_EXECUTION");
|
|
606
613
|
const profile = chosen ?? marker?.profile
|
|
607
614
|
?? (["hosted", "remote", "bedrock"].includes(execution) ? execution : null)
|
|
608
615
|
?? (registered ? defaultLocalProfile() : null);
|
|
@@ -613,7 +620,7 @@ function setupChecklist() {
|
|
|
613
620
|
const { common, profile, marker, version, registered } = setupProfileState();
|
|
614
621
|
const project = setupProjectDir();
|
|
615
622
|
return setupSteps({
|
|
616
|
-
mode: () => ({ profile, host:
|
|
623
|
+
mode: () => ({ profile, host: setting("NOMARMY_LLAMA_HOST"), port: setting("NOMARMY_LLAMA_PORT") }),
|
|
617
624
|
install: () => ({ marker, version, registered }),
|
|
618
625
|
agents: () => Object.keys(loadAgents(globalConfigDir()).agents),
|
|
619
626
|
army: () => {
|
|
@@ -651,6 +658,15 @@ async function cmdSetup() {
|
|
|
651
658
|
const steps = setupChecklist();
|
|
652
659
|
return json ? out(steps) : console.log(formatSetupSteps(steps));
|
|
653
660
|
}
|
|
661
|
+
// What's ahead, before the first question: a practice run found setup
|
|
662
|
+
// felt heavy mostly because nothing said how much of it there was.
|
|
663
|
+
if (setupChecklist().find((step) => step.status === "todo")?.id === "mode") {
|
|
664
|
+
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.`);
|
|
665
|
+
console.log(c.dim(" 1. Where models run: your API and subscription agents, or a model on this machine"));
|
|
666
|
+
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)"));
|
|
667
|
+
console.log(c.dim(" 3. Agents: log in to your subscription or add an API key"));
|
|
668
|
+
console.log(c.dim(" 4. Roles, this repo's checks, and a final health check\n"));
|
|
669
|
+
}
|
|
654
670
|
process.exitCode = await runSetupPlaybook({
|
|
655
671
|
evaluate: setupChecklist, print: console.log, run: runSetupChild,
|
|
656
672
|
ask: async (prompt) => {
|
|
@@ -669,10 +685,8 @@ async function cmdSetup() {
|
|
|
669
685
|
if (choice === "1") argv.push("--hosted");
|
|
670
686
|
if (choice === "3") argv.push("--llama-url", (await rl.question("Server URL: ")).trim());
|
|
671
687
|
if (choice === "4") {
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
writeEnvLine(common, "NOMARMY_EXECUTION", "bedrock");
|
|
675
|
-
writeEnvLine(common, "NOMARMY_SETUP_PROFILE", "bedrock");
|
|
688
|
+
writeSetting("NOMARMY_EXECUTION", "bedrock");
|
|
689
|
+
writeSetting("NOMARMY_SETUP_PROFILE", "bedrock");
|
|
676
690
|
console.log("Next: nomarmy install");
|
|
677
691
|
return;
|
|
678
692
|
}
|
|
@@ -683,7 +697,7 @@ async function cmdSetup() {
|
|
|
683
697
|
if (hosted && hasLlamaUrl) throw new Error("--hosted and --llama-url cannot be used together.");
|
|
684
698
|
|
|
685
699
|
if (hosted || hasLlamaUrl) {
|
|
686
|
-
const commonPath =
|
|
700
|
+
const commonPath = userCommonPath();
|
|
687
701
|
|
|
688
702
|
if (hosted) {
|
|
689
703
|
const next = [
|
|
@@ -691,11 +705,10 @@ async function cmdSetup() {
|
|
|
691
705
|
"nomarmy agents add",
|
|
692
706
|
"nomarmy army init --agent <name>",
|
|
693
707
|
];
|
|
694
|
-
fs.mkdirSync(path.dirname(commonPath), { recursive: true });
|
|
695
708
|
writeEnvLine(commonPath, "NOMARMY_EXECUTION", "hosted");
|
|
696
709
|
writeEnvLine(commonPath, "NOMARMY_SETUP_PROFILE", "hosted");
|
|
697
710
|
if (json) return out({ written: commonPath, execution: "hosted", next });
|
|
698
|
-
console.log(c.green(`✓
|
|
711
|
+
console.log(c.green(`✓ Jobs will run on your API and subscription agents (saved in ${tildePath(commonPath)}).`));
|
|
699
712
|
console.log(c.dim("\nNext:"));
|
|
700
713
|
for (const step of next) console.log(` ${c.bold(step)}`);
|
|
701
714
|
return;
|
|
@@ -723,7 +736,7 @@ async function cmdSetup() {
|
|
|
723
736
|
console.log(reachable
|
|
724
737
|
? c.green(`✓ llama-server is reachable at ${healthUrl}.`)
|
|
725
738
|
: c.yellow(`⚠ llama-server is not reachable at ${healthUrl} right now; configuration was still written.`));
|
|
726
|
-
console.log(c.green(`✓ Wrote the remote llama-server settings to ${
|
|
739
|
+
console.log(c.green(`✓ Wrote the remote llama-server settings to ${tildePath(commonPath)}.`));
|
|
727
740
|
console.log(c.dim("\nNext:"));
|
|
728
741
|
console.log(` ${c.bold(next)}`);
|
|
729
742
|
return;
|
|
@@ -752,10 +765,10 @@ async function cmdSetup() {
|
|
|
752
765
|
console.log(isCloud
|
|
753
766
|
? `Execution is '${execution}' -- hosted inference, local hardware does not bound this.\n`
|
|
754
767
|
: `Hardware: ${c.cyan(`${hardware.platform}/${hardware.arch}`)}, ${hardware.cpu?.logicalCores ?? "?"} logical cores, ${(hardware.memory?.totalBytes / 1024 ** 3).toFixed(1)} GiB RAM\n`);
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
768
|
+
// Plain words; the exact settings are shown before anything is written.
|
|
769
|
+
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` : ""}`; };
|
|
770
|
+
console.log(`This machine fits ${c.green(plain(res.env))}${res.confidence === "low" ? c.dim(" (an estimate)") : ""}.`);
|
|
771
|
+
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)."));
|
|
759
772
|
}
|
|
760
773
|
|
|
761
774
|
// "More noms" fits as many noms as memory allows; "nominal" is 1 worker
|
|
@@ -773,8 +786,8 @@ async function cmdSetup() {
|
|
|
773
786
|
if (sizingTier !== "more" && sizingTier !== "nominal") throw new Error('--tier must be "more" or "nominal".');
|
|
774
787
|
} else {
|
|
775
788
|
console.log(`\n${c.bold("Which sizing?")}`);
|
|
776
|
-
console.log(` ${c.cyan("1.")}
|
|
777
|
-
console.log(` ${c.cyan("2.")}
|
|
789
|
+
console.log(` ${c.cyan("1.")} As many at once as fit in memory`);
|
|
790
|
+
console.log(` ${c.cyan("2.")} One at a time (lighter on the machine)`);
|
|
778
791
|
const choice = (await rl.question(c.bold("Choice [1]: "))).trim() || "1";
|
|
779
792
|
sizingTier = choice === "2" ? "nominal" : "more";
|
|
780
793
|
}
|
|
@@ -793,8 +806,8 @@ async function cmdSetup() {
|
|
|
793
806
|
}
|
|
794
807
|
|
|
795
808
|
const profileName = nonInteractive ? value("profile-name") : (await rl.question(`\nProfile name [${hardware?.appleSilicon ? "macbook-pro" : "custom"}]: `)).trim() || (hardware?.appleSilicon ? "macbook-pro" : "custom");
|
|
796
|
-
const profilePath =
|
|
797
|
-
const commonPath =
|
|
809
|
+
const profilePath = userProfilePath(profileName);
|
|
810
|
+
const commonPath = userCommonPath();
|
|
798
811
|
|
|
799
812
|
const profileWrites = { ...sizingEnv };
|
|
800
813
|
if (!isCloud) {
|
|
@@ -807,10 +820,10 @@ async function cmdSetup() {
|
|
|
807
820
|
}
|
|
808
821
|
|
|
809
822
|
if (!json) {
|
|
810
|
-
console.log(c.bold(`\nAbout to write ${
|
|
823
|
+
console.log(c.bold(`\nAbout to write ${tildePath(profilePath)}:`));
|
|
811
824
|
for (const [k, v] of Object.entries(profileWrites)) console.log(c.dim(` ${k}=${v}`));
|
|
812
825
|
if (model?.kind === "known") {
|
|
813
|
-
console.log(c.bold(`\nAnd ${
|
|
826
|
+
console.log(c.bold(`\nAnd ${tildePath(commonPath)}:`));
|
|
814
827
|
console.log(c.dim(` NOMARMY_MODEL_REPO=${model.repo}`));
|
|
815
828
|
console.log(c.dim(` NOMARMY_MODEL_QUANT=${model.quant}`));
|
|
816
829
|
console.log(c.dim(` NOMARMY_MODEL_ALIAS=${model.alias}`));
|
|
@@ -818,8 +831,9 @@ async function cmdSetup() {
|
|
|
818
831
|
console.log(c.dim(` NOMARMY_MODEL_THINKING=${model.thinking}`));
|
|
819
832
|
}
|
|
820
833
|
if (!nonInteractive) {
|
|
821
|
-
|
|
822
|
-
|
|
834
|
+
// Yes by default: someone who accepted every suggestion shouldn't lose it all at the last Enter.
|
|
835
|
+
const answer = (await rl.question(c.bold("\nWrite this configuration? [Y/n] "))).trim().toLowerCase();
|
|
836
|
+
if (answer === "n" || answer === "no") { console.log(c.dim("Canceled; nothing written.")); return; }
|
|
823
837
|
}
|
|
824
838
|
}
|
|
825
839
|
|
|
@@ -841,7 +855,7 @@ async function cmdSetup() {
|
|
|
841
855
|
writeEnvLine(commonPath, "NOMARMY_WORKER_MODEL", model.alias);
|
|
842
856
|
writeEnvLine(commonPath, "NOMARMY_MODEL_THINKING", String(model.thinking));
|
|
843
857
|
} else if (model?.kind === "search") {
|
|
844
|
-
const searchedAlias =
|
|
858
|
+
const searchedAlias = setting("NOMARMY_MODEL_ALIAS");
|
|
845
859
|
if (searchedAlias) {
|
|
846
860
|
writeEnvLine(commonPath, "NOMARMY_WORKER_MODEL", searchedAlias);
|
|
847
861
|
writeEnvLine(commonPath, "NOMARMY_MODEL_THINKING", String(model.thinking));
|
|
@@ -850,7 +864,7 @@ async function cmdSetup() {
|
|
|
850
864
|
|
|
851
865
|
const installCmd = "nomarmy install";
|
|
852
866
|
if (json) return out({ written: { profile: profilePath, common: model?.kind === "known" ? commonPath : null }, env: profileWrites, sizingTier, installCommand: installCmd });
|
|
853
|
-
console.log(c.green(`\n✓ Wrote ${
|
|
867
|
+
console.log(c.green(`\n✓ Wrote ${tildePath(profilePath)}${model?.kind === "known" ? ` and ${tildePath(commonPath)}` : ""}.`));
|
|
854
868
|
console.log(c.dim("\nThis proposes; it does not install. Run:\n"));
|
|
855
869
|
console.log(` ${c.bold(installCmd)}\n`);
|
|
856
870
|
} finally {
|
|
@@ -879,7 +893,7 @@ function restartInference() {
|
|
|
879
893
|
runScript("start-inference.sh", []);
|
|
880
894
|
}
|
|
881
895
|
async function cmdModel() {
|
|
882
|
-
const commonPath =
|
|
896
|
+
const commonPath = userCommonPath();
|
|
883
897
|
if (json) {
|
|
884
898
|
const which = value("model");
|
|
885
899
|
if (!KNOWN_MODELS[which]) throw new Error(`--json requires --model one of ${Object.keys(KNOWN_MODELS).join(", ")} (Hugging Face search is interactive-only).`);
|
|
@@ -903,17 +917,17 @@ async function cmdModel() {
|
|
|
903
917
|
const model = await chooseModel(rl);
|
|
904
918
|
let alias;
|
|
905
919
|
if (model.kind === "search") {
|
|
906
|
-
console.log(c.green("\n✓ Done") +
|
|
907
|
-
alias =
|
|
920
|
+
console.log(c.green("\n✓ Done") + ` -- ${tildePath(commonPath)} was already updated by the search above.`);
|
|
921
|
+
alias = setting("NOMARMY_MODEL_ALIAS");
|
|
908
922
|
} else {
|
|
909
|
-
console.log(c.bold(`\nAbout to write ${
|
|
923
|
+
console.log(c.bold(`\nAbout to write ${tildePath(commonPath)}:`));
|
|
910
924
|
console.log(c.dim(` NOMARMY_MODEL_REPO=${model.repo}\n NOMARMY_MODEL_QUANT=${model.quant}\n NOMARMY_MODEL_ALIAS=${model.alias}`));
|
|
911
925
|
const answer = (await rl.question(c.bold("\nApply this model configuration? [y/N] "))).trim().toLowerCase();
|
|
912
926
|
if (answer !== "y") { console.log(c.dim("Canceled; nothing changed.")); return; }
|
|
913
927
|
writeEnvLine(commonPath, "NOMARMY_MODEL_REPO", model.repo);
|
|
914
928
|
writeEnvLine(commonPath, "NOMARMY_MODEL_QUANT", model.quant);
|
|
915
929
|
writeEnvLine(commonPath, "NOMARMY_MODEL_ALIAS", model.alias);
|
|
916
|
-
console.log(c.green(`✓ Wrote ${
|
|
930
|
+
console.log(c.green(`✓ Wrote ${tildePath(commonPath)}.`));
|
|
917
931
|
alias = model.alias;
|
|
918
932
|
}
|
|
919
933
|
if (alias) {
|
|
@@ -1370,8 +1384,7 @@ async function cmdAgentsAdd() {
|
|
|
1370
1384
|
console.log(` ${c.cyan("1.")} local ${c.dim("the local model on this machine (no per-token bill, private; slower)")}`);
|
|
1371
1385
|
console.log(` ${c.cyan("2.")} api ${c.dim("a metered API key (xAI, OpenAI, Anthropic, DeepSeek, ...)")}`);
|
|
1372
1386
|
console.log(` ${c.cyan("3.")} subscription ${c.dim("your own Claude, ChatGPT or Muse Code plan (never shared)")}`);
|
|
1373
|
-
kind = AGENT_KINDS[
|
|
1374
|
-
if (!kind) throw new Error("Not a valid choice.");
|
|
1387
|
+
kind = AGENT_KINDS[await askChoice(rl, AGENT_KINDS.length)];
|
|
1375
1388
|
}
|
|
1376
1389
|
if (kind === "local") return await addLocalAgent(rl, agents);
|
|
1377
1390
|
if (kind === "api") return await addApiAgent(rl, agents);
|
|
@@ -1408,8 +1421,7 @@ async function addLocalAgent(rl, agents) {
|
|
|
1408
1421
|
async function addApiAgent(rl, agents) {
|
|
1409
1422
|
console.log("\n" + c.bold("Which provider?"));
|
|
1410
1423
|
API_PROVIDER_TYPES.forEach((t, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${KNOWN_PROVIDERS[t]?.label ?? t}`));
|
|
1411
|
-
const provider = API_PROVIDER_TYPES[
|
|
1412
|
-
if (!provider) throw new Error("Not a valid choice.");
|
|
1424
|
+
const provider = API_PROVIDER_TYPES[await askChoice(rl, API_PROVIDER_TYPES.length)];
|
|
1413
1425
|
const info = KNOWN_PROVIDERS[provider] ?? {};
|
|
1414
1426
|
const agent = { kind: "api", provider };
|
|
1415
1427
|
|
|
@@ -1484,8 +1496,7 @@ async function addSubscriptionAgent(rl, agents) {
|
|
|
1484
1496
|
if (!SUBSCRIPTION_VENDORS[vendorKey]) {
|
|
1485
1497
|
console.log("\n" + c.bold("Which subscription?"));
|
|
1486
1498
|
vendorKeys.forEach((k, i) => console.log(` ${c.cyan(`${i + 1}.`)} ${SUBSCRIPTION_VENDORS[k].label}`));
|
|
1487
|
-
vendorKey = vendorKeys[
|
|
1488
|
-
if (!vendorKey) throw new Error(`Not a valid choice. Supported: ${vendorKeys.join(", ")}. DeepSeek and others without a plan are api agents.`);
|
|
1499
|
+
vendorKey = vendorKeys[await askChoice(rl, vendorKeys.length)];
|
|
1489
1500
|
}
|
|
1490
1501
|
const vendor = SUBSCRIPTION_VENDORS[vendorKey];
|
|
1491
1502
|
|
|
@@ -1714,13 +1725,14 @@ async function cmdUpdate() {
|
|
|
1714
1725
|
if (!copyIsStale(defaultInstallDir(), nomarmyRoot)) {
|
|
1715
1726
|
if (json) return out({ updated: false, reason: "already up to date" });
|
|
1716
1727
|
console.log(c.green("✓ Already up to date, and your coordinators run this checkout."));
|
|
1728
|
+
printSessionRestarts({ quietWhenNone: true });
|
|
1717
1729
|
return;
|
|
1718
1730
|
}
|
|
1719
1731
|
say(c.bold("🍪 nomArmy update\n"));
|
|
1720
1732
|
say("Nothing to pull, but your coordinators run an older copy of this checkout.");
|
|
1721
1733
|
const resynced = reconnectCoordinators();
|
|
1722
1734
|
if (json) return out({ updated: false, resynced, sha: local });
|
|
1723
|
-
|
|
1735
|
+
printSessionRestarts();
|
|
1724
1736
|
return;
|
|
1725
1737
|
}
|
|
1726
1738
|
if (base !== local) {
|
|
@@ -1747,6 +1759,25 @@ async function cmdUpdate() {
|
|
|
1747
1759
|
// Reconnect every connected coordinator through a child process, so it runs
|
|
1748
1760
|
// the code now on disk (just pulled or installed) rather than the old code
|
|
1749
1761
|
// this process loaded. Returns the targets reconnected.
|
|
1762
|
+
// Each open session keeps the nomArmy it started with: name the ones that
|
|
1763
|
+
// started before the installed copy, rather than a blanket "restart".
|
|
1764
|
+
function printSessionRestarts({ quietWhenNone = false } = {}) {
|
|
1765
|
+
let list = null;
|
|
1766
|
+
try {
|
|
1767
|
+
const installedAt = fs.statSync(path.join(defaultInstallDir(), "source.json")).mtimeMs;
|
|
1768
|
+
const procs = listProcesses();
|
|
1769
|
+
if (procs) list = staleSessions(procs, { installedAt });
|
|
1770
|
+
} catch { /* no installed copy yet, or ps unavailable */ }
|
|
1771
|
+
if (list === null) {
|
|
1772
|
+
if (!quietWhenNone) console.log(c.yellow("\nRestart every open Claude Code, Codex and Cursor session: each keeps the code it started with until then."));
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
if (!list.length) { if (!quietWhenNone) console.log(c.green("\n✓ No open session runs an older nomArmy.")); return; }
|
|
1776
|
+
console.log(c.yellow(`\n${list.length} open session(s) still run an older nomArmy, until each is restarted:`));
|
|
1777
|
+
for (const line of formatStaleSessions(list)) console.log(line);
|
|
1778
|
+
console.log(c.dim("In Claude Code: /exit, then claude --resume (or /mcp → nomarmy-local-worker → Reconnect). Close any you no longer use."));
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1750
1781
|
function reconnectCoordinators() {
|
|
1751
1782
|
const targets = connectedTargets();
|
|
1752
1783
|
// Per-repo registrations run the installed copy (or `nomarmy mcp`), so a
|
|
@@ -1791,7 +1822,7 @@ async function updateFromNpm() {
|
|
|
1791
1822
|
}
|
|
1792
1823
|
const targets = reconnectCoordinators();
|
|
1793
1824
|
if (json) return out({ updated: upgrade, from: current, version: upgrade ? latest : current, resynced: targets });
|
|
1794
|
-
|
|
1825
|
+
printSessionRestarts();
|
|
1795
1826
|
}
|
|
1796
1827
|
|
|
1797
1828
|
function commandExists(cmd) {
|
|
@@ -2024,7 +2055,7 @@ async function maybeRemoveAgentsDir({ force }) {
|
|
|
2024
2055
|
* needs manual cleanup -- an honest, bounded scope beats guessing at which
|
|
2025
2056
|
* cache entries are "ours". */
|
|
2026
2057
|
function resolveConfiguredModelRepos() {
|
|
2027
|
-
const repo =
|
|
2058
|
+
const repo = setting("NOMARMY_MODEL_REPO");
|
|
2028
2059
|
return repo ? [repo] : [];
|
|
2029
2060
|
}
|
|
2030
2061
|
|
|
@@ -2736,6 +2767,25 @@ async function cmdStatusline() {
|
|
|
2736
2767
|
process.stdout.write(`${statusLineText({ session })}\n`);
|
|
2737
2768
|
}
|
|
2738
2769
|
|
|
2770
|
+
// `stats --share`: markdown for a PR description or README; `--badge [path]`:
|
|
2771
|
+
// an SVG badge to commit, with the README line for it (lib/share.mjs).
|
|
2772
|
+
async function shareStats(stats) {
|
|
2773
|
+
const { shareMarkdown, badgeSvg, badgeMarkdown } = await import("../lib/share.mjs");
|
|
2774
|
+
const scope = value("run") ? "this feature run" : value("since") ? `since ${value("since")}` : null;
|
|
2775
|
+
if (flag("share")) console.log(shareMarkdown(stats, { scope }));
|
|
2776
|
+
if (flag("badge")) {
|
|
2777
|
+
const given = value("badge");
|
|
2778
|
+
const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
|
2779
|
+
const root = top.status === 0 ? top.stdout.trim() : process.cwd();
|
|
2780
|
+
const file = path.resolve(root, given ?? path.join(".github", "nomarmy-badge.svg"));
|
|
2781
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
2782
|
+
fs.writeFileSync(file, badgeSvg(stats));
|
|
2783
|
+
const rel = path.relative(root, file).split(path.sep).join("/");
|
|
2784
|
+
console.log(`${flag("share") ? "\n" : ""}${c.green("✓")} Wrote ${rel}. Commit it and add this to your README:\n\n ${badgeMarkdown(rel)}\n`);
|
|
2785
|
+
console.log(c.dim("Re-run nomarmy stats --badge after more jobs to refresh the numbers."));
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2739
2789
|
function cmdStats() {
|
|
2740
2790
|
const stateRoot = process.env.NOMARMY_AGENT_STATE || path.join(os.homedir(), ".local", "share", "nomarmy-local-agents");
|
|
2741
2791
|
const records = loadJobRecords(path.join(stateRoot, "jobs"));
|
|
@@ -2747,9 +2797,10 @@ function cmdStats() {
|
|
|
2747
2797
|
}
|
|
2748
2798
|
let agentFor = () => null;
|
|
2749
2799
|
try { agentFor = agentLookup(loadAgents(globalConfigDir()).agents, agentProviderId); } catch { /* no agents.yml: commands name <agent> */ }
|
|
2750
|
-
const stats = computeStats(records, { repo, sinceMs: parseSince(value("since")), untilMs: parseSince(value("until")), role: value("role"), model: value("model"), agentFor });
|
|
2800
|
+
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") });
|
|
2751
2801
|
if (json) return out(stats);
|
|
2752
|
-
|
|
2802
|
+
if (flag("share") || flag("badge")) return shareStats(stats);
|
|
2803
|
+
console.log(flag("details") ? formatStats(stats) : formatStatsSummary(stats, { c }));
|
|
2753
2804
|
}
|
|
2754
2805
|
|
|
2755
2806
|
// Read one line without echoing it: stty -echo around the read, restored
|
|
@@ -2884,7 +2935,12 @@ async function cmdDoctor() {
|
|
|
2884
2935
|
await runDoctor({ json, exit: true, env: installEnv() });
|
|
2885
2936
|
}
|
|
2886
2937
|
commands.doctor = cmdDoctor;
|
|
2887
|
-
if (!command
|
|
2938
|
+
if (!command && !flag("help")) {
|
|
2939
|
+
// New users typed `nomarmy` and got the whole command reference.
|
|
2940
|
+
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`);
|
|
2941
|
+
process.exit(0);
|
|
2942
|
+
}
|
|
2943
|
+
if (flag("help") || !commands[command]) usage(command && !commands[command] ? 2 : 0);
|
|
2888
2944
|
|
|
2889
2945
|
try {
|
|
2890
2946
|
await commands[command]();
|
package/install.sh
CHANGED
|
@@ -87,7 +87,9 @@ echo '==> Installing nomArmy dependencies'
|
|
|
87
87
|
|
|
88
88
|
# Put `nomarmy` on PATH. Non-fatal by design: linking needs a writable npm
|
|
89
89
|
# global prefix, and the CLI is equally usable as `node bin/nomarmy.mjs`.
|
|
90
|
-
if
|
|
90
|
+
if [[ "$ROOT" == */node_modules/* ]]; then
|
|
91
|
+
: # Installed from npm: `nomarmy` is already on PATH.
|
|
92
|
+
elif (cd "$ROOT" && npm link >/dev/null 2>&1); then
|
|
91
93
|
echo '==> Linked the nomarmy CLI onto PATH'
|
|
92
94
|
else
|
|
93
95
|
echo 'NOTE: could not link the nomarmy CLI (npm global prefix not writable).'
|
|
@@ -115,6 +117,9 @@ if ! command -v openclaw >/dev/null 2>&1; then
|
|
|
115
117
|
else
|
|
116
118
|
# No writable global prefix: install for this user, as OpenClaw's own installer does.
|
|
117
119
|
npm install -g --prefix "$HOME/.npm-global" "openclaw@$OPENCLAW_VERSION" --no-audit --no-fund
|
|
120
|
+
# nomArmy finds it there on its own (lib/openclaw-path.mjs); your shell won't until PATH has it.
|
|
121
|
+
echo "NOTE: OpenClaw is in ~/.npm-global/bin. nomArmy finds it there; to run openclaw yourself, add it to PATH:"
|
|
122
|
+
echo ' echo '"'"'export PATH="$HOME/.npm-global/bin:$PATH"'"'"' >> ~/.profile'
|
|
118
123
|
fi
|
|
119
124
|
export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH"
|
|
120
125
|
fi
|
package/lib/connect.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { loadAgents } from "./agents.mjs";
|
|
|
29
29
|
import { globalConfigDir } from "./army.mjs";
|
|
30
30
|
import { buildNotifierApp } from "./notifier-app.mjs";
|
|
31
31
|
import { recordCopySource } from "./install-freshness.mjs";
|
|
32
|
+
import { readSettings, migrateUserConfig } from "./user-config.mjs";
|
|
32
33
|
|
|
33
34
|
export function defaultInstallDir() {
|
|
34
35
|
return process.env.NOMARMY_AGENT_INSTALL_DIR || path.join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".local", "share", "nomarmy-local-worker");
|
|
@@ -140,6 +141,9 @@ export function installClaudeStatusLine({ installDir, settingsPath = defaultClau
|
|
|
140
141
|
}
|
|
141
142
|
|
|
142
143
|
export function installMcpCopy({ nomarmyRoot, installDir, run = defaultRun }) {
|
|
144
|
+
// Settings from before they moved to ~/.config/nomarmy/ survive in the old
|
|
145
|
+
// copy's config/ until this call replaces it: rescue them first.
|
|
146
|
+
try { migrateUserConfig({ oldConfigDir: path.join(installDir, "config"), nomarmyRoot }); } catch { /* never block a connect */ }
|
|
143
147
|
fs.mkdirSync(path.join(installDir, "mcp"), { recursive: true });
|
|
144
148
|
fs.copyFileSync(path.join(nomarmyRoot, "package.json"), path.join(installDir, "package.json"));
|
|
145
149
|
fs.copyFileSync(path.join(nomarmyRoot, "mcp", "server.mjs"), path.join(installDir, "mcp", "server.mjs"));
|
|
@@ -246,15 +250,13 @@ function readEnvValue(filePath, key) {
|
|
|
246
250
|
* such keys yet just contributes nothing here, unchanged from before.
|
|
247
251
|
*/
|
|
248
252
|
export function deriveWorkerModelEnv(nomarmyRoot) {
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
const thinking = readEnvValue(commonPath, "NOMARMY_MODEL_THINKING");
|
|
253
|
+
// Your settings over the package defaults (lib/user-config.mjs).
|
|
254
|
+
const settings = readSettings({ nomarmyRoot });
|
|
252
255
|
const env = {};
|
|
253
|
-
if (
|
|
254
|
-
if (
|
|
256
|
+
if (settings.NOMARMY_WORKER_MODEL) env.NOMARMY_WORKER_MODEL = settings.NOMARMY_WORKER_MODEL;
|
|
257
|
+
if (settings.NOMARMY_MODEL_THINKING !== undefined) env.NOMARMY_WORKER_MODEL_THINKING = settings.NOMARMY_MODEL_THINKING;
|
|
255
258
|
for (const key of ["NOMARMY_EXECUTION", "NOMARMY_LLAMA_HOST", "NOMARMY_LLAMA_PORT"]) {
|
|
256
|
-
|
|
257
|
-
if (value !== null) env[key] = value;
|
|
259
|
+
if (settings[key] !== undefined) env[key] = settings[key];
|
|
258
260
|
}
|
|
259
261
|
return env;
|
|
260
262
|
}
|
package/lib/doctor.mjs
CHANGED
|
@@ -252,7 +252,7 @@ export function checkEndpoint(facts) {
|
|
|
252
252
|
return {
|
|
253
253
|
ok: false,
|
|
254
254
|
message: `NOMARMY_EXECUTION '${execution}' is not recognized.`,
|
|
255
|
-
fix: "Set NOMARMY_EXECUTION to 'local', 'remote', 'hosted', or 'bedrock' (
|
|
255
|
+
fix: "Set NOMARMY_EXECUTION to 'local', 'remote', 'hosted', or 'bedrock' (nomarmy setup --choose, or ~/.config/nomarmy/common.env).",
|
|
256
256
|
};
|
|
257
257
|
}
|
|
258
258
|
|
|
@@ -282,7 +282,7 @@ function checkBedrockEndpoint(endpoint) {
|
|
|
282
282
|
return {
|
|
283
283
|
ok: false,
|
|
284
284
|
message: "NOMARMY_BEDROCK_REGION is not set.",
|
|
285
|
-
fix: "Set NOMARMY_BEDROCK_REGION, e.g. export NOMARMY_BEDROCK_REGION=eu-west-2 (
|
|
285
|
+
fix: "Set NOMARMY_BEDROCK_REGION, e.g. export NOMARMY_BEDROCK_REGION=eu-west-2 (or set it in ~/.config/nomarmy/common.env).",
|
|
286
286
|
};
|
|
287
287
|
}
|
|
288
288
|
if (!regionValid) {
|
package/lib/health.mjs
CHANGED
|
@@ -337,8 +337,8 @@ export async function checkAndRecordHealth({ projectDir, stateRoot, configDir, n
|
|
|
337
337
|
const { loadJobRecords, agentLookup } = await import("./stats.mjs");
|
|
338
338
|
const { recentSuggestions } = await import("./suggestions.mjs");
|
|
339
339
|
for (const s of recentSuggestions(loadJobRecords(path.join(stateRoot, "jobs")), { projectDir, agentFor: agents ? agentLookup(agents, agentProviderId) : () => null, now })) {
|
|
340
|
-
if (s.level !== "warn") continue;
|
|
341
|
-
result.issues.push({ id: `suggestion:${s.key}`, severity: "warn", title: s.title, detail: s.evidence, fix: s.command ?? "nomarmy stats (routing suggestions)", short: "routing tip" });
|
|
340
|
+
if (s.level !== "warn" && s.level !== "act") continue;
|
|
341
|
+
result.issues.push({ id: `suggestion:${s.key}`, severity: "warn", title: s.title, detail: s.evidence, fix: s.command ?? "nomarmy stats (routing suggestions)", short: s.level === "act" ? "review needed" : "routing tip" });
|
|
342
342
|
}
|
|
343
343
|
} catch { /* suggestions never break a health check */ }
|
|
344
344
|
const toNotify = recordHealth(path.join(stateRoot, "health.json"), result, { now });
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Find OpenClaw where install.sh may have put it. With no writable npm global
|
|
2
|
+
// folder (common on Linux), it installs into ~/.npm-global/bin, which isn't on
|
|
3
|
+
// most PATHs: a fresh-install practice run ended "OpenClaw not found", and
|
|
4
|
+
// every job would then fail. The CLI and the MCP server call this first, so
|
|
5
|
+
// they and everything they spawn find it.
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
const isExecutable = (file) => { try { fs.accessSync(file, fs.constants.X_OK); return fs.statSync(file).isFile(); } catch { return false; } };
|
|
12
|
+
|
|
13
|
+
export function openclawFallbackDirs(home = os.homedir()) {
|
|
14
|
+
return [path.join(home, ".npm-global", "bin"), path.join(home, ".local", "bin")];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Adds the folder holding openclaw to env.PATH when it isn't already reachable. Returns the folder added, or null. */
|
|
18
|
+
export function ensureOpenClawOnPath(env = process.env, { home = os.homedir(), platform = process.platform } = {}) {
|
|
19
|
+
if (env.NOMARMY_OPENCLAW_CMD) return null;
|
|
20
|
+
const sep = platform === "win32" ? ";" : ":";
|
|
21
|
+
const names = platform === "win32" ? ["openclaw.cmd", "openclaw.exe", "openclaw"] : ["openclaw"];
|
|
22
|
+
const dirs = String(env.PATH ?? "").split(sep).filter(Boolean);
|
|
23
|
+
if (dirs.some((d) => names.some((n) => isExecutable(path.join(d, n))))) return null;
|
|
24
|
+
const found = openclawFallbackDirs(home).find((d) => names.some((n) => isExecutable(path.join(d, n))));
|
|
25
|
+
if (!found) return null;
|
|
26
|
+
env.PATH = [found, ...dirs].join(sep);
|
|
27
|
+
return found;
|
|
28
|
+
}
|