nomarmy 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +25 -0
- package/README.md +484 -0
- package/bin/nomarmy.mjs +2248 -0
- package/config/agents.yml.example +63 -0
- package/config/common.env +31 -0
- package/config/profiles/bedrock-cheap.env +26 -0
- package/config/profiles/bedrock.env +28 -0
- package/config/profiles/cpu-linux.env +8 -0
- package/config/profiles/dgx-spark.env +12 -0
- package/config/profiles/macbook-pro.env +9 -0
- package/config/profiles/nvidia-linux.env +9 -0
- package/docker/Dockerfile +15 -0
- package/docker/Dockerfile.go +29 -0
- package/docker/Dockerfile.rust +19 -0
- package/e2e.sh +153 -0
- package/install.sh +125 -0
- package/lib/agents.mjs +285 -0
- package/lib/army.mjs +400 -0
- package/lib/budget.mjs +368 -0
- package/lib/claude-transcript.mjs +150 -0
- package/lib/config.mjs +193 -0
- package/lib/connect.mjs +409 -0
- package/lib/coordinator-instructions.mjs +23 -0
- package/lib/decompose.mjs +389 -0
- package/lib/dispatch-config.mjs +164 -0
- package/lib/dispatch-schema.mjs +280 -0
- package/lib/doctor.mjs +443 -0
- package/lib/evidence.mjs +679 -0
- package/lib/gguf.mjs +589 -0
- package/lib/hardware.mjs +476 -0
- package/lib/health.mjs +278 -0
- package/lib/model-catalog.mjs +71 -0
- package/lib/notifier-app.mjs +95 -0
- package/lib/notify.mjs +66 -0
- package/lib/openclaw-config.mjs +65 -0
- package/lib/openclaw-errors.mjs +40 -0
- package/lib/propose.mjs +110 -0
- package/lib/prune.mjs +77 -0
- package/lib/repo-query.mjs +267 -0
- package/lib/runs.mjs +150 -0
- package/lib/sabotage.mjs +128 -0
- package/lib/sandbox-images.mjs +434 -0
- package/lib/scan.mjs +1538 -0
- package/lib/schema.mjs +288 -0
- package/lib/scout.mjs +544 -0
- package/lib/sizing.mjs +1322 -0
- package/lib/slots.mjs +112 -0
- package/lib/statusline.mjs +126 -0
- package/lib/subscription-config.mjs +68 -0
- package/lib/subscription-setup.mjs +217 -0
- package/lib/transcript.mjs +195 -0
- package/lib/verify.mjs +700 -0
- package/mcp/server.mjs +4206 -0
- package/notifier/icon.swift +34 -0
- package/notifier/main.swift +52 -0
- package/notifier/nomarmy-icon.png +0 -0
- package/package.json +67 -0
- package/playbooks/feature.md +43 -0
- package/policies/coder.md +49 -0
- package/policies/orchestrator.md +35 -0
- package/policies/reviewer.md +35 -0
- package/policies/scout.md +65 -0
- package/scripts/configure-openclaw.sh +96 -0
- package/scripts/configure-orchestrator.sh +84 -0
- package/scripts/install-llama-cpp.sh +16 -0
- package/scripts/lib.sh +198 -0
- package/scripts/select-model.mjs +96 -0
- package/scripts/select-model.sh +4 -0
- package/scripts/setup-sandbox.sh +38 -0
- package/scripts/start-inference.sh +46 -0
- package/scripts/stop-inference.sh +5 -0
- package/scripts/uninstall.sh +6 -0
- package/scripts/verify-install.sh +68 -0
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// nomArmy repository configuration loader (v1.3).
|
|
2
|
+
//
|
|
3
|
+
// `.nomarmy.yml` is the environment contract for a job: what services the
|
|
4
|
+
// worker's sandbox gets, how the application is started, which verification
|
|
5
|
+
// profiles exist, and what happens to the environment afterwards.
|
|
6
|
+
//
|
|
7
|
+
// All YAML parsing lives in this file so the parser stays swappable; the shape
|
|
8
|
+
// itself lives in `lib/schema.mjs`.
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import YAML from "yaml";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
collectElevated,
|
|
16
|
+
configSchema,
|
|
17
|
+
emptyElevated,
|
|
18
|
+
formatIssues,
|
|
19
|
+
} from "./schema.mjs";
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
DEFAULT_RETENTION,
|
|
23
|
+
ELEVATED_SERVICE_SOURCES,
|
|
24
|
+
ENVIRONMENT_LEVELS,
|
|
25
|
+
RETENTION_ACTIONS,
|
|
26
|
+
SERVICE_SOURCES,
|
|
27
|
+
} from "./schema.mjs";
|
|
28
|
+
|
|
29
|
+
/** Filenames searched, in order. First hit wins. */
|
|
30
|
+
export const CONFIG_FILENAMES = Object.freeze([".nomarmy.yml", ".nomarmy.yaml"]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Raised when a `.nomarmy.yml` exists but cannot be used. Carries the same
|
|
34
|
+
* readable `path: message` lines that `validateConfig` returns, so a caller
|
|
35
|
+
* never has to re-derive them from the message text.
|
|
36
|
+
*/
|
|
37
|
+
export class ConfigError extends Error {
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} message
|
|
40
|
+
* @param {{ errors?: string[], path?: string|null, cause?: unknown }} [details]
|
|
41
|
+
*/
|
|
42
|
+
constructor(message, details = {}) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "ConfigError";
|
|
45
|
+
this.errors = details.errors || [];
|
|
46
|
+
this.path = details.path || null;
|
|
47
|
+
if (details.cause !== undefined) this.cause = details.cause;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isReadableFile(candidate) {
|
|
52
|
+
try {
|
|
53
|
+
return fs.statSync(candidate).isFile();
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Absolute path of the repository's config file, or null when absent.
|
|
61
|
+
* @param {string} [repoDir]
|
|
62
|
+
* @returns {string|null}
|
|
63
|
+
*/
|
|
64
|
+
export function findConfigFile(repoDir = process.cwd()) {
|
|
65
|
+
const base = path.resolve(repoDir);
|
|
66
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
67
|
+
const candidate = path.join(base, filename);
|
|
68
|
+
if (isReadableFile(candidate)) return candidate;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse YAML text into a plain object. The only YAML entry point in nomArmy.
|
|
75
|
+
* An empty document (or a comments-only file) becomes `{}`.
|
|
76
|
+
* @param {string} text
|
|
77
|
+
* @param {string|null} [sourcePath] used only for the error message
|
|
78
|
+
* @returns {object}
|
|
79
|
+
*/
|
|
80
|
+
export function parseYaml(text, sourcePath = null) {
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = YAML.parse(text, { prettyErrors: true });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
const where = sourcePath ? `${path.basename(sourcePath)}` : "configuration";
|
|
86
|
+
throw new ConfigError(`${where} is not valid YAML: ${error.message}`, {
|
|
87
|
+
path: sourcePath,
|
|
88
|
+
errors: [`config: is not valid YAML: ${error.message}`],
|
|
89
|
+
cause: error,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return parsed === null || parsed === undefined ? {} : parsed;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Serialize a plain object to YAML text. The only YAML *write* entry point,
|
|
97
|
+
* for the same swappable-parser reason parseYaml is the only read one.
|
|
98
|
+
* @param {object} config
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
export function stringifyConfig(config) {
|
|
102
|
+
return YAML.stringify(config);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Validate an already-parsed plain object against the `.nomarmy.yml` schema.
|
|
107
|
+
*
|
|
108
|
+
* `elevated` lists every `shared` and `remote` service found. Those sources
|
|
109
|
+
* reach outside the job sandbox, so they are accepted here but the caller is
|
|
110
|
+
* expected to require explicit policy approval before running the job. It is
|
|
111
|
+
* structured data on purpose, not a warning string.
|
|
112
|
+
*
|
|
113
|
+
* @param {unknown} input
|
|
114
|
+
* @returns {{ valid: boolean, config: object|null, errors: string[], elevated: { shared: string[], remote: string[] } }}
|
|
115
|
+
*/
|
|
116
|
+
export function validateConfig(input) {
|
|
117
|
+
const candidate = input === null || input === undefined ? {} : input;
|
|
118
|
+
|
|
119
|
+
if (typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
120
|
+
return {
|
|
121
|
+
valid: false,
|
|
122
|
+
config: null,
|
|
123
|
+
errors: ["config: must be a mapping of top-level sections"],
|
|
124
|
+
elevated: emptyElevated(),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const result = configSchema.safeParse(candidate);
|
|
129
|
+
if (!result.success) {
|
|
130
|
+
return {
|
|
131
|
+
valid: false,
|
|
132
|
+
config: null,
|
|
133
|
+
errors: formatIssues(result.error),
|
|
134
|
+
elevated: emptyElevated(),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
valid: true,
|
|
140
|
+
config: result.data,
|
|
141
|
+
errors: [],
|
|
142
|
+
elevated: collectElevated(result.data),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Find, parse and validate a repository's `.nomarmy.yml`.
|
|
148
|
+
*
|
|
149
|
+
* A missing file is not an error: the repo simply has no environment contract,
|
|
150
|
+
* and `{ found: false }` comes back cleanly. A file that exists but is broken
|
|
151
|
+
* throws `ConfigError`: an unusable contract must not be mistaken for none.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} [repoDir] repository root to search
|
|
154
|
+
* @returns {{ found: boolean, path: string|null, config: object|null, elevated: { shared: string[], remote: string[] } }}
|
|
155
|
+
*/
|
|
156
|
+
export function loadConfig(repoDir = process.cwd()) {
|
|
157
|
+
const configPath = findConfigFile(repoDir);
|
|
158
|
+
if (!configPath) {
|
|
159
|
+
return { found: false, path: null, config: null, elevated: emptyElevated() };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let text;
|
|
163
|
+
try {
|
|
164
|
+
text = fs.readFileSync(configPath, "utf8");
|
|
165
|
+
} catch (error) {
|
|
166
|
+
throw new ConfigError(
|
|
167
|
+
`${path.basename(configPath)} could not be read: ${error.message}`,
|
|
168
|
+
{
|
|
169
|
+
path: configPath,
|
|
170
|
+
errors: [`config: could not be read: ${error.message}`],
|
|
171
|
+
cause: error,
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const parsed = parseYaml(text, configPath);
|
|
177
|
+
const result = validateConfig(parsed);
|
|
178
|
+
|
|
179
|
+
if (!result.valid) {
|
|
180
|
+
const detail = result.errors.map((line) => ` - ${line}`).join("\n");
|
|
181
|
+
throw new ConfigError(
|
|
182
|
+
`${path.basename(configPath)} is not a valid nomArmy configuration:\n${detail}`,
|
|
183
|
+
{ path: configPath, errors: result.errors },
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
found: true,
|
|
189
|
+
path: configPath,
|
|
190
|
+
config: result.config,
|
|
191
|
+
elevated: result.elevated,
|
|
192
|
+
};
|
|
193
|
+
}
|
package/lib/connect.mjs
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// Registers the nomArmy MCP server with a coordinator (Claude Code, Codex,
|
|
2
|
+
// or Cursor). Originally two separate bash scripts (setup-claude-worker.sh /
|
|
3
|
+
// setup-codex-worker.sh); ported to real JS and now the ONLY implementation
|
|
4
|
+
// -- install.sh calls `nomarmy connect <target>` too, not the old scripts,
|
|
5
|
+
// which is exactly what let a real bug (env vars silently dropped on
|
|
6
|
+
// reinstall) go unnoticed in the bash version for a long time: only one of
|
|
7
|
+
// the two implementations ever got fixed, and the fresh-install path kept
|
|
8
|
+
// calling the other one. Unlike install.sh itself (OS package-manager
|
|
9
|
+
// detection, toolchain installs, curl-piped installers, genuinely risky to
|
|
10
|
+
// reimplement), this registration step is small and
|
|
11
|
+
// purely mechanical: copy files, npm install, then either call one external
|
|
12
|
+
// CLI's own `mcp add` (Claude, Codex) or edit a JSON config file directly
|
|
13
|
+
// (Cursor, which has no CLI for this at all). Nothing here needs bash to
|
|
14
|
+
// exist on the host at all.
|
|
15
|
+
//
|
|
16
|
+
// The registered MCP server is always a COPY under installDir, never the
|
|
17
|
+
// dev checkout directly -- server.mjs resolves "../lib/verify.mjs" relative
|
|
18
|
+
// to its own location, so lib/ ships as installDir's sibling of mcp/.
|
|
19
|
+
//
|
|
20
|
+
// Add a new coordinator here: implement connect<Name>({ nomarmyRoot,
|
|
21
|
+
// installDir, run }) returning at least { installDir, serverPath }, then
|
|
22
|
+
// wire it into KNOWN_TARGETS and the dispatch table in bin/nomarmy.mjs.
|
|
23
|
+
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import os from "node:os";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { execFileSync } from "node:child_process";
|
|
28
|
+
import { loadAgents } from "./agents.mjs";
|
|
29
|
+
import { globalConfigDir } from "./army.mjs";
|
|
30
|
+
import { buildNotifierApp } from "./notifier-app.mjs";
|
|
31
|
+
|
|
32
|
+
export function defaultInstallDir() {
|
|
33
|
+
return process.env.NOMARMY_AGENT_INSTALL_DIR || path.join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".local", "share", "nomarmy-local-worker");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Copy package.json/mcp/lib into installDir and `npm install` there.
|
|
38
|
+
* Shared by both coordinators; has no opinion about which one is registering.
|
|
39
|
+
* @param {{ nomarmyRoot: string, installDir: string, run?: Function }} input
|
|
40
|
+
*/
|
|
41
|
+
// --- The /feature playbook, installed into each coordinator ---------------
|
|
42
|
+
//
|
|
43
|
+
// One body (playbooks/*.md, with a {{REQUEST}} placeholder), rendered in
|
|
44
|
+
// each coordinator's own format. Every installed copy carries a
|
|
45
|
+
// `<!-- nomarmy:... -->` marker; a same-named file WITHOUT it is the
|
|
46
|
+
// operator's own and is never overwritten, only reported as skipped.
|
|
47
|
+
// Claude Code ~/.claude/commands/feature.md -> /feature <request>
|
|
48
|
+
// Codex ~/.codex/skills/nomarmy-feature/SKILL.md (confirmed from
|
|
49
|
+
// codex 0.156's own $CODEX_HOME/skills loader)
|
|
50
|
+
// Cursor ~/.cursor/commands/feature.md (Cursor's documented
|
|
51
|
+
// user-level commands directory; not verified against a
|
|
52
|
+
// real install here)
|
|
53
|
+
|
|
54
|
+
const COMMAND_MARKER = "<!-- nomarmy:";
|
|
55
|
+
const PLAYBOOKS = Object.freeze({
|
|
56
|
+
feature: {
|
|
57
|
+
description: "nomArmy -- build a feature end to end with the army, then come back with a branch ready to merge",
|
|
58
|
+
argumentHint: "<the feature you want> | resume <run-id>",
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export function defaultCommandDirs(env = process.env) {
|
|
63
|
+
const home = os.homedir();
|
|
64
|
+
return {
|
|
65
|
+
claude: env.NOMARMY_CLAUDE_COMMANDS_DIR ? path.resolve(env.NOMARMY_CLAUDE_COMMANDS_DIR) : path.join(home, ".claude", "commands"),
|
|
66
|
+
codex: env.NOMARMY_CODEX_SKILLS_DIR ? path.resolve(env.NOMARMY_CODEX_SKILLS_DIR) : path.join(env.CODEX_HOME || path.join(home, ".codex"), "skills"),
|
|
67
|
+
cursor: env.NOMARMY_CURSOR_COMMANDS_DIR ? path.resolve(env.NOMARMY_CURSOR_COMMANDS_DIR) : path.join(home, ".cursor", "commands"),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One playbook rendered for one coordinator: { relPath, text }. */
|
|
72
|
+
export function renderPlaybook(name, body, target) {
|
|
73
|
+
const meta = PLAYBOOKS[name];
|
|
74
|
+
const marker = `${COMMAND_MARKER}${name} -- installed by \`nomarmy connect ${target}\`; edits here are overwritten on the next connect -->`;
|
|
75
|
+
if (target === "claude") {
|
|
76
|
+
return { relPath: `${name}.md`, text: `---\ndescription: ${meta.description}\nargument-hint: ${meta.argumentHint}\n---\n${marker}\n\n${body.replace("{{REQUEST}}", "$ARGUMENTS")}` };
|
|
77
|
+
}
|
|
78
|
+
if (target === "codex") {
|
|
79
|
+
// A skill is invoked by the model when the request matches its
|
|
80
|
+
// description, so the description says when to use it.
|
|
81
|
+
const description = `${meta.description}. Use when the user asks nomArmy (or "the army") to build a feature end to end, or to resume a nomArmy run by its run-id.`;
|
|
82
|
+
return { relPath: path.join(`nomarmy-${name}`, "SKILL.md"), text: `---\nname: nomarmy-${name}\ndescription: ${description}\n---\n${marker}\n\n${body.replace("{{REQUEST}}", "The feature is the one the user asked for when this skill was invoked (or the run-id they asked to resume).")}` };
|
|
83
|
+
}
|
|
84
|
+
return { relPath: `${name}.md`, text: `${marker}\n\n# nomArmy: ${name}\n\n${body.replace("{{REQUEST}}", `The feature is the text the user wrote after /${name} (or \`resume <run-id>\`).`)}` };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Install every playbook for one coordinator.
|
|
89
|
+
* @returns {{ installed: string[], skipped: string[] }} relative paths
|
|
90
|
+
*/
|
|
91
|
+
export function installPlaybooks({ nomarmyRoot, target, dir = defaultCommandDirs()[target] }) {
|
|
92
|
+
const srcDir = path.join(nomarmyRoot, "playbooks");
|
|
93
|
+
const out = { installed: [], skipped: [], dir };
|
|
94
|
+
if (!fs.existsSync(srcDir)) return out;
|
|
95
|
+
for (const file of fs.readdirSync(srcDir).filter((f) => f.endsWith(".md"))) {
|
|
96
|
+
const name = file.replace(/\.md$/, "");
|
|
97
|
+
if (!PLAYBOOKS[name]) continue;
|
|
98
|
+
const { relPath, text } = renderPlaybook(name, fs.readFileSync(path.join(srcDir, file), "utf8"), target);
|
|
99
|
+
const dest = path.join(dir, relPath);
|
|
100
|
+
if (fs.existsSync(dest) && !fs.readFileSync(dest, "utf8").includes(COMMAND_MARKER)) { out.skipped.push(relPath); continue; }
|
|
101
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
102
|
+
fs.writeFileSync(dest, text);
|
|
103
|
+
out.installed.push(relPath);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const STATUS_LINE_REFRESH_SECONDS = 5;
|
|
109
|
+
|
|
110
|
+
/** Claude Code's user settings file. */
|
|
111
|
+
export function defaultClaudeSettingsPath(env = process.env) {
|
|
112
|
+
return env.NOMARMY_CLAUDE_SETTINGS_PATH ? path.resolve(env.NOMARMY_CLAUDE_SETTINGS_PATH) : path.join(os.homedir(), ".claude", "settings.json");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Point Claude Code's status line at nomArmy's (lib/statusline.mjs in the
|
|
117
|
+
* installed copy) -- only when no status line is configured, or when the
|
|
118
|
+
* configured one is already nomArmy's (refreshed to the current path). An
|
|
119
|
+
* operator's own status line is never replaced; they're told how to add
|
|
120
|
+
* nomArmy's to it instead.
|
|
121
|
+
* @returns {"installed"|"updated"|"kept-yours"|"unchanged"|"skipped"}
|
|
122
|
+
*/
|
|
123
|
+
export function installClaudeStatusLine({ installDir, settingsPath = defaultClaudeSettingsPath() }) {
|
|
124
|
+
let settings = {};
|
|
125
|
+
try { if (fs.existsSync(settingsPath)) settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")); }
|
|
126
|
+
catch { return "skipped"; } // an unparseable settings file is never rewritten
|
|
127
|
+
const command = `node ${JSON.stringify(path.join(installDir, "lib", "statusline.mjs"))}`;
|
|
128
|
+
const current = settings.statusLine;
|
|
129
|
+
if (current && !String(current.command ?? "").includes("statusline.mjs")) return "kept-yours";
|
|
130
|
+
// refreshInterval: Claude Code otherwise re-runs the command only on
|
|
131
|
+
// conversation events, so an idle session's elapsed times froze.
|
|
132
|
+
if (current?.command === command && current?.refreshInterval === STATUS_LINE_REFRESH_SECONDS) return "unchanged";
|
|
133
|
+
settings.statusLine = { type: "command", command, padding: 0, refreshInterval: STATUS_LINE_REFRESH_SECONDS };
|
|
134
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
135
|
+
fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
136
|
+
return current ? "updated" : "installed";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function installMcpCopy({ nomarmyRoot, installDir, run = defaultRun }) {
|
|
140
|
+
fs.mkdirSync(path.join(installDir, "mcp"), { recursive: true });
|
|
141
|
+
fs.copyFileSync(path.join(nomarmyRoot, "package.json"), path.join(installDir, "package.json"));
|
|
142
|
+
fs.copyFileSync(path.join(nomarmyRoot, "mcp", "server.mjs"), path.join(installDir, "mcp", "server.mjs"));
|
|
143
|
+
fs.rmSync(path.join(installDir, "lib"), { recursive: true, force: true });
|
|
144
|
+
fs.cpSync(path.join(nomarmyRoot, "lib"), path.join(installDir, "lib"), { recursive: true });
|
|
145
|
+
// lib/sandbox-images.mjs resolves docker/ relative to its own location
|
|
146
|
+
// (lib/ and docker/ as siblings), so the installed copy needs this too,
|
|
147
|
+
// not just the dev checkout -- otherwise a lazy Go/Rust image build would
|
|
148
|
+
// work from a repo clone but silently fail to find its Dockerfile once
|
|
149
|
+
// installed.
|
|
150
|
+
const dockerSrc = path.join(nomarmyRoot, "docker");
|
|
151
|
+
if (fs.existsSync(dockerSrc)) {
|
|
152
|
+
fs.rmSync(path.join(installDir, "docker"), { recursive: true, force: true });
|
|
153
|
+
fs.cpSync(dockerSrc, path.join(installDir, "docker"), { recursive: true });
|
|
154
|
+
}
|
|
155
|
+
// mcp/server.mjs resolves config/providers.yml relative to its own
|
|
156
|
+
// location (mcp/ and config/ as siblings), the same lib/sandbox-images.mjs
|
|
157
|
+
// pattern above uses for docker/ -- without this, an installed server can
|
|
158
|
+
// never see a dispatch pool config an operator wrote into the dev
|
|
159
|
+
// checkout's config/ directory. This incidentally also copies
|
|
160
|
+
// common.env/profiles/*.env alongside it (the whole directory copies as
|
|
161
|
+
// one unit, same as docker/), but the server never relies on reading
|
|
162
|
+
// those from here: their values only ever reach it through connect-time
|
|
163
|
+
// -e env injection (see deriveWorkerModelEnv below), so a stale copy of
|
|
164
|
+
// either sitting in installDir is inert, never consulted.
|
|
165
|
+
const configSrc = path.join(nomarmyRoot, "config");
|
|
166
|
+
if (fs.existsSync(configSrc)) {
|
|
167
|
+
fs.rmSync(path.join(installDir, "config"), { recursive: true, force: true });
|
|
168
|
+
fs.cpSync(configSrc, path.join(installDir, "config"), { recursive: true });
|
|
169
|
+
}
|
|
170
|
+
run("npm", ["install", "--omit=dev"], { cwd: installDir });
|
|
171
|
+
run(process.execPath, ["--check", "mcp/server.mjs"], { cwd: installDir });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function defaultRun(cmd, args, opts = {}) {
|
|
175
|
+
return execFileSync(cmd, args, { stdio: "inherit", ...opts });
|
|
176
|
+
}
|
|
177
|
+
// Best-effort removal of an old registration: failure (nothing registered
|
|
178
|
+
// under that name yet, likely a fresh install) is expected, not an error.
|
|
179
|
+
// Takes the same injected `run` every other call in this module does, so a
|
|
180
|
+
// test's fake `run` sees every subprocess this module would spawn, never
|
|
181
|
+
// only some of them.
|
|
182
|
+
function quietRun(run, cmd, args, opts = {}) {
|
|
183
|
+
try { return run(cmd, args, opts); } catch { return null; }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Pull the "KEY=value" lines out of `claude mcp get <name>`'s "Environment:"
|
|
188
|
+
* section. Observed live: a registration carrying NOMARMY_WORKER_MODEL (an
|
|
189
|
+
* operator testing a non-default local model) silently lost that variable
|
|
190
|
+
* on the next `nomarmy connect`/`update`, because the re-add below used to
|
|
191
|
+
* pass no -e flags at all -- reinstalling the server code reset which model
|
|
192
|
+
* every dispatch actually used, with no warning. A blank line or a line that
|
|
193
|
+
* is not "KEY=value" ends the block; nothing registered yet is not an error.
|
|
194
|
+
*/
|
|
195
|
+
export function parseClaudeEnv(output) {
|
|
196
|
+
const lines = String(output ?? "").split(/\r?\n/);
|
|
197
|
+
const start = lines.findIndex(l => /^\s*Environment:\s*$/.test(l));
|
|
198
|
+
if (start === -1) return {};
|
|
199
|
+
const env = {};
|
|
200
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
201
|
+
const m = lines[i].match(/^\s{2,}([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
202
|
+
if (!m) break;
|
|
203
|
+
env[m[1]] = m[2];
|
|
204
|
+
}
|
|
205
|
+
return env;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function captureExistingEnv(run, name) {
|
|
209
|
+
try {
|
|
210
|
+
const out = run("claude", ["mcp", "get", name], { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
|
|
211
|
+
return parseClaudeEnv(out);
|
|
212
|
+
} catch {
|
|
213
|
+
return {};
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function readEnvValue(filePath, key) {
|
|
218
|
+
try {
|
|
219
|
+
const m = fs.readFileSync(filePath, "utf8").match(new RegExp(`^${key}=(.*)$`, "m"));
|
|
220
|
+
return m ? m[1].trim() : null;
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* config/common.env's NOMARMY_WORKER_MODEL sat unused for every model choice
|
|
228
|
+
* until now -- nothing ever read it back out to actually route dispatch, so
|
|
229
|
+
* picking a non-default model in `nomarmy setup`/`model` silently had no
|
|
230
|
+
* effect on which model workers were routed to. This makes that file the
|
|
231
|
+
* real source of truth: whatever it says the configured model and its
|
|
232
|
+
* thinking support are, the MCP registration is kept in sync with, every
|
|
233
|
+
* connect. Missing file or keys is not an error -- an older checkout with no
|
|
234
|
+
* such keys yet just contributes nothing here, unchanged from before.
|
|
235
|
+
*/
|
|
236
|
+
export function deriveWorkerModelEnv(nomarmyRoot) {
|
|
237
|
+
const commonPath = path.join(nomarmyRoot, "config", "common.env");
|
|
238
|
+
const model = readEnvValue(commonPath, "NOMARMY_WORKER_MODEL");
|
|
239
|
+
const thinking = readEnvValue(commonPath, "NOMARMY_MODEL_THINKING");
|
|
240
|
+
const env = {};
|
|
241
|
+
if (model) env.NOMARMY_WORKER_MODEL = model;
|
|
242
|
+
if (thinking !== null) env.NOMARMY_WORKER_MODEL_THINKING = thinking;
|
|
243
|
+
return env;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const SERVER_NAME = "nomarmy-local-worker";
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Every distinct auth_env name declared across config/providers.yml's
|
|
250
|
+
* pools, mapped to a placeholder value -- NEVER the real credential, which
|
|
251
|
+
* lives only in OpenClaw's own store from `nomarmy agents add`'s
|
|
252
|
+
* registration step.
|
|
253
|
+
*
|
|
254
|
+
* Why this needs to exist at all: an api agent's auth_env is checked for
|
|
255
|
+
* TRUTHINESS ONLY by the dispatcher (lib/dispatch-config.mjs's
|
|
256
|
+
* availableEntries), inside the MCP SERVER's own process.env -- and
|
|
257
|
+
* "export it in some shell" has no reliable path to that specific process.
|
|
258
|
+
* A GUI-launched coordinator never inherited a later shell export in the
|
|
259
|
+
* first place; a terminal-launched one only did if the export predated
|
|
260
|
+
* that specific launch. Live symptom this closes: the old providers list
|
|
261
|
+
* kept showing an entry as unset even after registration had genuinely
|
|
262
|
+
* succeeded and the variable really was exported -- just never in the
|
|
263
|
+
* shell that mattered.
|
|
264
|
+
*
|
|
265
|
+
* Called on every connect, the same way deriveWorkerModelEnv already is,
|
|
266
|
+
* so a newly added api agent is picked up the next time an operator
|
|
267
|
+
* reconnects for ANY reason (a model swap, an update, a fresh install) --
|
|
268
|
+
* not only if they remember a separate step right after `agents add`.
|
|
269
|
+
* A missing/invalid agents.yml contributes nothing here, silently -- that's
|
|
270
|
+
* `agents list`'s problem to report, not connect's to block on.
|
|
271
|
+
*/
|
|
272
|
+
export function derivePoolAuthEnvPlaceholders(configDir = globalConfigDir()) {
|
|
273
|
+
let loaded;
|
|
274
|
+
try {
|
|
275
|
+
loaded = loadAgents(configDir);
|
|
276
|
+
} catch {
|
|
277
|
+
return {};
|
|
278
|
+
}
|
|
279
|
+
const env = {};
|
|
280
|
+
for (const agent of Object.values(loaded.agents)) {
|
|
281
|
+
if (agent.kind === "api" && agent.auth_env) env[agent.auth_env] = "registered";
|
|
282
|
+
}
|
|
283
|
+
return env;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* @param {{ nomarmyRoot: string, installDir?: string, run?: Function, extraEnv?: Record<string,string>, configDir?: string }} input
|
|
288
|
+
*/
|
|
289
|
+
export function connectClaude({ nomarmyRoot, installDir = defaultInstallDir(), run = defaultRun, extraEnv = {}, configDir = globalConfigDir(), commandsDir = defaultCommandDirs().claude, settingsPath = defaultClaudeSettingsPath() }) {
|
|
290
|
+
installMcpCopy({ nomarmyRoot, installDir, run });
|
|
291
|
+
const commands = installPlaybooks({ nomarmyRoot, target: "claude", dir: commandsDir });
|
|
292
|
+
const notifier = buildNotifierApp({ nomarmyRoot });
|
|
293
|
+
const statusLine = installClaudeStatusLine({ installDir, settingsPath });
|
|
294
|
+
const preservedEnv = captureExistingEnv(run, SERVER_NAME);
|
|
295
|
+
// config/common.env is the source of truth for which model is configured;
|
|
296
|
+
// its worker-routing keys always win over whatever the old registration
|
|
297
|
+
// happened to have, which may be stale (a previous model swap that never
|
|
298
|
+
// made it into the registration, or vice versa). Any OTHER custom env var
|
|
299
|
+
// an operator set some other way is still preserved untouched.
|
|
300
|
+
//
|
|
301
|
+
// Precedence, lowest to highest: a freshly-derived pool placeholder fills
|
|
302
|
+
// in only when nothing already covers that key; whatever's genuinely
|
|
303
|
+
// already registered (preservedEnv) wins over that; extraEnv is an
|
|
304
|
+
// explicit, immediate ask from THIS call (e.g. `nomarmy providers add
|
|
305
|
+
// --update-mcp`, right after writing a brand-new entry, before its
|
|
306
|
+
// placeholder would otherwise show up here on this same call); and
|
|
307
|
+
// config/common.env's derived worker-model keys still win over
|
|
308
|
+
// everything, exactly as before this existed.
|
|
309
|
+
const finalEnv = { ...derivePoolAuthEnvPlaceholders(configDir), ...preservedEnv, ...extraEnv, ...deriveWorkerModelEnv(nomarmyRoot) };
|
|
310
|
+
quietRun(run, "claude", ["mcp", "remove", SERVER_NAME, "--scope", "user"]);
|
|
311
|
+
quietRun(run, "claude", ["mcp", "remove", SERVER_NAME]);
|
|
312
|
+
const serverPath = path.join(installDir, "mcp", "server.mjs");
|
|
313
|
+
// -e is variadic (`-e <env...>`): it greedily swallows every bare token
|
|
314
|
+
// after it, including the server name, until the next recognized flag or
|
|
315
|
+
// `--`. The server name and any --scope must come before -e, not after,
|
|
316
|
+
// or `claude mcp add` rejects the server name itself as a malformed
|
|
317
|
+
// "KEY=value" environment entry.
|
|
318
|
+
const envArgs = Object.entries(finalEnv).flatMap(([k, v]) => ["-e", `${k}=${v}`]);
|
|
319
|
+
try {
|
|
320
|
+
run("claude", ["mcp", "add", "--scope", "user", SERVER_NAME, ...envArgs, "--", "node", serverPath]);
|
|
321
|
+
} catch {
|
|
322
|
+
// Compatibility fallback for Claude Code versions whose MCP command
|
|
323
|
+
// does not support --scope user.
|
|
324
|
+
run("claude", ["mcp", "add", SERVER_NAME, ...envArgs, "--", "node", serverPath]);
|
|
325
|
+
}
|
|
326
|
+
run("claude", ["mcp", "get", SERVER_NAME]);
|
|
327
|
+
return { installDir, serverPath, preservedEnv: finalEnv, commands, statusLine, notifier };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* @param {{ nomarmyRoot: string, installDir?: string, run?: Function }} input
|
|
332
|
+
*/
|
|
333
|
+
export function connectCodex({ nomarmyRoot, installDir = defaultInstallDir(), run = defaultRun, skillsDir = defaultCommandDirs().codex }) {
|
|
334
|
+
installMcpCopy({ nomarmyRoot, installDir, run });
|
|
335
|
+
const commands = installPlaybooks({ nomarmyRoot, target: "codex", dir: skillsDir });
|
|
336
|
+
const notifier = buildNotifierApp({ nomarmyRoot });
|
|
337
|
+
quietRun(run, "codex", ["mcp", "remove", SERVER_NAME]);
|
|
338
|
+
const serverPath = path.join(installDir, "mcp", "server.mjs");
|
|
339
|
+
run("codex", ["mcp", "add", SERVER_NAME, "--", "node", serverPath]);
|
|
340
|
+
run("codex", ["mcp", "list"]);
|
|
341
|
+
return { installDir, serverPath, commands, notifier };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function defaultCursorConfigPath() {
|
|
345
|
+
return process.env.NOMARMY_CURSOR_CONFIG_PATH
|
|
346
|
+
|| path.join(process.env.HOME ?? process.env.USERPROFILE ?? ".", ".cursor", "mcp.json");
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Cursor has no CLI for this: registration is a JSON file
|
|
351
|
+
* (~/.cursor/mcp.json), read-modify-written under { mcpServers: { name: {
|
|
352
|
+
* command, args, env } } }, the same schema Claude Desktop and most other
|
|
353
|
+
* MCP hosts converged on. A file that does not exist yet is fine (fresh
|
|
354
|
+
* install); a file that exists but fails to parse is NEVER silently
|
|
355
|
+
* overwritten -- that would discard whatever else was in it (other
|
|
356
|
+
* configured servers, hand edits) without the operator ever seeing it.
|
|
357
|
+
*/
|
|
358
|
+
export function readCursorConfig(configPath) {
|
|
359
|
+
let raw;
|
|
360
|
+
try {
|
|
361
|
+
raw = fs.readFileSync(configPath, "utf8");
|
|
362
|
+
} catch {
|
|
363
|
+
return {};
|
|
364
|
+
}
|
|
365
|
+
let parsed;
|
|
366
|
+
try {
|
|
367
|
+
parsed = JSON.parse(raw);
|
|
368
|
+
} catch (err) {
|
|
369
|
+
throw new Error(`${configPath} exists but is not valid JSON (${err.message}). Fix or remove it by hand, then re-run -- it is never overwritten blindly.`);
|
|
370
|
+
}
|
|
371
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
372
|
+
throw new Error(`${configPath} exists but its top level is not a JSON object. Fix or remove it by hand, then re-run.`);
|
|
373
|
+
}
|
|
374
|
+
return parsed;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* @param {{ nomarmyRoot: string, installDir?: string, configPath?: string }} input
|
|
379
|
+
*/
|
|
380
|
+
export function connectCursor({ nomarmyRoot, installDir = defaultInstallDir(), configPath = defaultCursorConfigPath(), run = defaultRun, commandsDir = defaultCommandDirs().cursor }) {
|
|
381
|
+
installMcpCopy({ nomarmyRoot, installDir, run });
|
|
382
|
+
const commands = installPlaybooks({ nomarmyRoot, target: "cursor", dir: commandsDir });
|
|
383
|
+
const notifier = buildNotifierApp({ nomarmyRoot });
|
|
384
|
+
const serverPath = path.join(installDir, "mcp", "server.mjs");
|
|
385
|
+
const config = readCursorConfig(configPath);
|
|
386
|
+
if (!config.mcpServers || typeof config.mcpServers !== "object") config.mcpServers = {};
|
|
387
|
+
// Preserve this entry's own existing env vars (the same incident
|
|
388
|
+
// connectClaude's captureExistingEnv guards against: silently dropping an
|
|
389
|
+
// operator-set NOMARMY_WORKER_MODEL on every reinstall) -- every OTHER
|
|
390
|
+
// configured server in the file is left completely untouched.
|
|
391
|
+
const existing = config.mcpServers[SERVER_NAME];
|
|
392
|
+
const preservedEnv = (existing && typeof existing.env === "object" && existing.env) || {};
|
|
393
|
+
config.mcpServers[SERVER_NAME] = { command: "node", args: [serverPath], env: preservedEnv };
|
|
394
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
395
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
396
|
+
return { installDir, serverPath, configPath, preservedEnv, commands, notifier };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** True when Cursor's config file already has a nomArmy entry -- used by
|
|
400
|
+
* `nomarmy update` to decide whether to re-sync it, since Cursor has no
|
|
401
|
+
* `commandExists` equivalent to check the way Claude/Codex do. */
|
|
402
|
+
export function cursorAlreadyConnected(configPath = defaultCursorConfigPath()) {
|
|
403
|
+
try {
|
|
404
|
+
const config = readCursorConfig(configPath);
|
|
405
|
+
return Boolean(config.mcpServers && config.mcpServers[SERVER_NAME]);
|
|
406
|
+
} catch {
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// What every coordinator (Claude Code, Codex, Cursor) is told when it
|
|
2
|
+
// connects to nomArmy's MCP server: the MCP `instructions` field. It used
|
|
3
|
+
// to live only in this repo's CLAUDE.md, which a person had to copy into
|
|
4
|
+
// each project, and which an npm install doesn't even include.
|
|
5
|
+
//
|
|
6
|
+
// Kept short: a coordinator reads it on every session. The detail is in each
|
|
7
|
+
// tool's own description.
|
|
8
|
+
|
|
9
|
+
export const COORDINATOR_INSTRUCTIONS = `nomArmy runs bounded engineering jobs (noms) in isolated git worktrees and sandboxes, on a local model or on the api and subscription agents the operator configured. You are the General: you plan, brief, dispatch, review, integrate and accept. Workers never are.
|
|
10
|
+
|
|
11
|
+
Before dispatching:
|
|
12
|
+
- Answer where-is / who-calls / grep questions with repo_evidence (deterministic, [path:line] on every hit). Use mode: scout only for read-only research that would otherwise pull many files into your own context.
|
|
13
|
+
- Call army to see this repo's roles and which agent each runs on; dispatch by army_role when a role fits. A job on a subscription agent needs on_behalf_of set to that agent's owner.
|
|
14
|
+
- A Claude subscription agent (claude-cli) runs its tools on this machine, outside the sandbox: use it for scout and review work. nomArmy refuses implement jobs on it unless the operator set allow_host_tools; send build work to a sandboxed agent.
|
|
15
|
+
- Brief outcomes, not edits: a task, explicit acceptance criteria, and the tests that prove it. Put facts you've already resolved in evidence.
|
|
16
|
+
- Prefer local_worker_start + local_worker_status for anything longer than a few minutes. For a whole feature, use /feature (run_start keeps a run's jobs, spend and hours bounded).
|
|
17
|
+
|
|
18
|
+
Trust boundary:
|
|
19
|
+
- A worker's four-line report is a claim; nomArmy's verified git record and independent verification are the evidence. A job isn't complete if its report is missing or malformed, its STATUS is partial or blocked, STATUS done lacks VERIFICATION pass, or its changes aren't committed by nomArmy.
|
|
20
|
+
- Read the diff of anything material before integrating it. nomArmy commits on the worker's branch and never merges into yours: integration, conflicts and pushes are yours.
|
|
21
|
+
- Failed and incomplete worktrees are kept for review; clean up with local_worker_cleanup or local_worker_sweep once you've decided.
|
|
22
|
+
|
|
23
|
+
Never delegate deployments, production access, cloud or SSH credentials, secrets, Terraform state or kubectl contexts to a worker.`;
|