diff --git a/README.md b/README.md
index 33137cac..b8320b69 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,7 @@ or miss one that does. A test fails the build when it drifts.
| `moshcode shorten`
`short` `link` | hosting | mint a short link on the pit — /f/ follows to your url |
| `moshcode games`
`game` `arcade` | arcade | the moshcode arcade — twenty-two games, no menus |
| `moshcode pwd`
`where` | system | show the current directory and git context |
-| `moshcode engines` | engines | list engines and installation status |
+| `moshcode engines` | engines | list engines and installation status, or apply an engine's settings defaults |
| `moshcode tools` | tools | list workflow tools and installation status |
| `moshcode trade` | tools | look up markets and trade through Alpaca |
| `moshcode stocks`
`advisor` | tools | equity research from advis0r.com |
@@ -491,6 +491,24 @@ hand, and the screen rules stay as the fallback for everything else.
`moshcode herd doctor` says what is installed, what has drifted, and — for the
first time — what is wrong with your `rules.json` instead of ignoring it.
+### The engine's settings, the way a herd wants them
+
+An engine can also say how it would like to be configured. Claude Code's
+defaults are **ultracode on** (every substantive prompt runs as a workflow of
+agents), **small workflows** (Claude's own advisory tier, fewer than 5 agents
+each) and a **hard cap of 4 agents at once**, so one session cannot eat the box
+the rest of the herd is running on. `moshcode install claude` applies them;
+by hand:
+
+```sh
+moshcode engines defaults apply claude
+✓ claude — 3 defaults applied (ultracode on by default, small workflows (under 5 agents), 4 agents at once, hard cap)
+```
+
+Same rule as the hooks: the file is merged, never clobbered. A key you already
+set — to anything — stays yours, `defaults` shows which ones those are, and
+`defaults remove` takes out only a value that is still the one moshcode wrote.
+
### What happened while you slept
Every prompt through the herd mints a **task**: an id, its state transitions
diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs
index d5be47d3..bcc604f1 100755
--- a/bin/moshcode.mjs
+++ b/bin/moshcode.mjs
@@ -309,6 +309,13 @@ async function main() {
}
if (cmd === "engines") {
+ // `engines defaults` — the engine's own settings, as moshcode wants them
+ // (src/engine-settings.mjs). Everything else is still the roster.
+ if (rest[0] === "defaults") {
+ const { enginesDefaults } = await import("../src/engine-settings.mjs");
+ process.exitCode = await enginesDefaults(rest.slice(1));
+ return;
+ }
printEngineStatus(rest.includes("--json"));
return;
}
@@ -521,6 +528,12 @@ async function main() {
// for everything that offers none, and a name already in the file is
// reported by the adopter rather than replaced.
for (const line of adoptAliasLines(target, entry, { isReserved })) console.log(line);
+ // And an engine that ships settings defaults gets them now, into holes
+ // only — see src/engine-settings.mjs. Silent when there is nothing to do.
+ if (Object.hasOwn(ENGINES, target)) {
+ const { applyAfterInstall } = await import("../src/engine-settings.mjs");
+ applyAfterInstall(target);
+ }
}
return backToPit(`install ${target}`, result.code);
}
diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs
index 03fae00e..8d42146e 100644
--- a/src/cli-schema.mjs
+++ b/src/cli-schema.mjs
@@ -548,10 +548,24 @@ export const CORE_CLI_COMMANDS = [
{
name: "engines",
group: "engines",
- description: "list engines and installation status",
- synopsis: [["moshcode engines [--json]", ""]],
- flags: [["--json", "machine-readable", ""]],
+ description: "list engines and installation status, or apply an engine's settings defaults",
+ synopsis: [
+ ["moshcode engines [--json]", ""],
+ ["moshcode engines defaults [status|apply|remove] [|all]", ""],
+ ],
+ flags: [
+ ["--json", "machine-readable", ""],
+ ["--dry-run", "defaults apply/remove: print the change to the engine's settings file and write nothing", ""],
+ ],
+ examples: [
+ ["moshcode engines", "who is installed"],
+ ["moshcode engines defaults", "per engine: which of its defaults are set, missing, or yours"],
+ ["moshcode engines defaults apply claude", "ultracode on, small workflows, 4 agents at once"],
+ ["moshcode engines defaults remove claude", "take them back out"],
+ ],
seeAlso: ["agents", "install"],
+ note: "`moshcode install ` applies its defaults for you. the file is merged, never clobbered: "
+ + "a key you already set — to anything — stays yours, and remove takes out only a value that is still ours.",
},
{
name: "tools",
diff --git a/src/engine-settings.mjs b/src/engine-settings.mjs
new file mode 100644
index 00000000..c66570cc
--- /dev/null
+++ b/src/engine-settings.mjs
@@ -0,0 +1,304 @@
+// Engine settings defaults — the engine's own config, as moshcode would like
+// to find it (the settings half of "the engine speaks for itself", PRD 0011).
+//
+// An engine's `settings` spec in engines.mjs names the file it reads and the
+// keys moshcode wants in it. `moshcode install ` applies them, and
+// `moshcode engines defaults` shows, applies or removes them by hand. The
+// first one written is Claude Code's ultracode: every substantive prompt
+// becomes a workflow, workflows stay small, and no more than four agents run
+// at once — the shape a herd wants from each of its sessions.
+//
+// THE SAME THREE RULES as src/herd-hooks.mjs, because it is the same file:
+//
+// MERGE, NEVER CLOBBER. A key the operator set — to anything, including the
+// opposite of what we want — stays exactly as it is. Apply only ever fills a
+// hole. That makes these a floor under a fresh install, not a policy over an
+// old one, and it is why `status` reports "theirs" as a state and not a fault.
+//
+// REMOVE ONLY WHAT IS OURS. A key whose value is still the one we wrote is
+// taken out; a key the operator has since changed is theirs now and stays.
+//
+// REFUSE A FILE WE CANNOT PARSE. Overwriting it would take every hook, MCP
+// server and preference in it along with the mistake.
+//
+// Nested defaults (`env: { X: "4" }`) merge one leaf at a time: the operator's
+// other env vars are untouched, and an `env` object we created is removed
+// again only once it is empty.
+import { ENGINES, resolveEngine } from "./engines.mjs";
+import { existingMode, hookDiff, readJsonFile, writeJsonFile } from "./herd-hooks.mjs";
+import { acid, amber, ash, err, info, ok } from "./ui.mjs";
+
+const FILE_MODE = 0o600;
+
+/** Engines that ship a settings spec, in table order. */
+export function defaultableEngines() {
+ return Object.entries(ENGINES).filter(([, engine]) => engine.settings?.defaults).map(([key]) => key);
+}
+
+/** Where this engine's settings live, resolved now (specs hold a function). */
+export function settingsFile(engine) {
+ const spec = ENGINES[engine]?.settings;
+ if (!spec) return null;
+ return typeof spec.file === "function" ? spec.file() : spec.file;
+}
+
+function isPlainObject(value) {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+/**
+ * The spec's defaults as a flat list of leaves — `{ env: { X: "4" } }` is one
+ * entry at path ["env", "X"], key "env.X". Merging leaf by leaf is what keeps
+ * the operator's other env vars alone.
+ */
+export function defaultEntries(engine) {
+ const spec = ENGINES[engine]?.settings;
+ if (!spec?.defaults) return [];
+ const out = [];
+ const walk = (value, path) => {
+ if (isPlainObject(value) && Object.keys(value).length) {
+ for (const [k, v] of Object.entries(value)) walk(v, [...path, k]);
+ return;
+ }
+ const key = path.join(".");
+ out.push({ path, key, value, label: spec.labels?.[key] || key });
+ };
+ walk(spec.defaults, []);
+ return out;
+}
+
+function getAt(object, path) {
+ let cursor = object;
+ for (const step of path) {
+ if (!isPlainObject(cursor) || !Object.hasOwn(cursor, step)) return { present: false };
+ cursor = cursor[step];
+ }
+ return { present: true, value: cursor };
+}
+
+function setAt(object, path, value) {
+ let cursor = object;
+ for (const step of path.slice(0, -1)) {
+ if (!isPlainObject(cursor[step])) cursor[step] = {};
+ cursor = cursor[step];
+ }
+ cursor[path[path.length - 1]] = value;
+}
+
+/** Delete a leaf, then any parent object the deletion left empty. */
+function deleteAt(object, path) {
+ const parents = [];
+ let cursor = object;
+ for (const step of path.slice(0, -1)) {
+ if (!isPlainObject(cursor[step])) return;
+ parents.push([cursor, step]);
+ cursor = cursor[step];
+ }
+ delete cursor[path[path.length - 1]];
+ for (let i = parents.length - 1; i >= 0; i--) {
+ const [parent, step] = parents[i];
+ if (isPlainObject(parent[step]) && !Object.keys(parent[step]).length) delete parent[step];
+ else break;
+ }
+}
+
+function same(a, b) {
+ return JSON.stringify(a) === JSON.stringify(b);
+}
+
+/**
+ * What the file says right now, one row per default: `set` (ours), `theirs`
+ * (present, some other value — the operator's, and respected), or `missing`.
+ */
+export function settingsStatus(engine, { file = settingsFile(engine) } = {}) {
+ const spec = ENGINES[engine]?.settings;
+ if (!spec?.defaults) return { engine, supported: false, file: null, entries: [] };
+ const read = readJsonFile(file);
+ if (!read.ok) {
+ return { engine, supported: true, file, readable: false, error: String(read.error?.message || read.error), entries: [] };
+ }
+ const settings = read.data || {};
+ const entries = defaultEntries(engine).map((entry) => {
+ const have = getAt(settings, entry.path);
+ const state = !have.present ? "missing" : same(have.value, entry.value) ? "set" : "theirs";
+ return { key: entry.key, label: entry.label, want: entry.value, have: have.present ? have.value : undefined, state };
+ });
+ return {
+ engine,
+ supported: true,
+ file,
+ readable: true,
+ present: read.present,
+ // "Applied" means nothing is missing. A key the operator overrode counts:
+ // they have an answer, and it is not our place to have a different one.
+ applied: entries.every((e) => e.state !== "missing"),
+ entries,
+ };
+}
+
+/**
+ * Fill the holes. `dryRun` computes everything and writes nothing, returning
+ * the file as it would have been so the caller can show a diff.
+ */
+export function applyEngineSettings(engine, { file = settingsFile(engine), dryRun = false } = {}) {
+ const spec = ENGINES[engine]?.settings;
+ if (!spec?.defaults) {
+ return { ok: false, engine, supported: false, error: new Error(`${engine} ships no settings defaults`) };
+ }
+ const read = readJsonFile(file);
+ if (!read.ok) return { ok: false, engine, supported: true, file, error: read.error };
+
+ const settings = read.data ?? {};
+ const before = JSON.stringify(settings, null, 2);
+ const changes = defaultEntries(engine).map((entry) => {
+ const have = getAt(settings, entry.path);
+ if (!have.present) { setAt(settings, entry.path, entry.value); return { key: entry.key, label: entry.label, change: "added" }; }
+ return { key: entry.key, label: entry.label, change: same(have.value, entry.value) ? "unchanged" : "kept" };
+ });
+ const after = JSON.stringify(settings, null, 2);
+
+ if (!dryRun && changes.some((c) => c.change === "added")) {
+ try { writeJsonFile(file, settings, { mode: read.present ? existingMode(file) : FILE_MODE }); }
+ catch (error) { return { ok: false, engine, supported: true, file, error }; }
+ }
+ return {
+ ok: true, engine, supported: true, file, dryRun,
+ changes,
+ written: changes.filter((c) => c.change === "added").length,
+ before, after,
+ };
+}
+
+/** Take ours back out. A value the operator changed since is theirs and stays. */
+export function removeEngineSettings(engine, { file = settingsFile(engine), dryRun = false } = {}) {
+ const spec = ENGINES[engine]?.settings;
+ if (!spec?.defaults) return { ok: false, engine, supported: false, error: new Error(`${engine} ships no settings defaults`) };
+ const read = readJsonFile(file);
+ if (!read.ok) return { ok: false, engine, supported: true, file, error: read.error };
+ if (!read.present) return { ok: true, engine, supported: true, file, removed: 0, dryRun };
+
+ const settings = read.data ?? {};
+ const before = JSON.stringify(settings, null, 2);
+ let removed = 0;
+ for (const entry of defaultEntries(engine)) {
+ const have = getAt(settings, entry.path);
+ if (have.present && same(have.value, entry.value)) { deleteAt(settings, entry.path); removed++; }
+ }
+ const after = JSON.stringify(settings, null, 2);
+
+ if (!dryRun && removed) {
+ try { writeJsonFile(file, settings, { mode: existingMode(file) }); }
+ catch (error) { return { ok: false, engine, supported: true, file, error }; }
+ }
+ return { ok: true, engine, supported: true, file, removed, dryRun, before, after };
+}
+
+/**
+ * The one-liner `moshcode install ` prints after a successful install:
+ * what was applied, or nothing at all for an engine with no spec. Quiet on
+ * purpose — an install that changed nothing about the engine's config reads
+ * exactly as it did before.
+ */
+export function applyAfterInstall(engine, { write = console.log } = {}) {
+ if (!defaultableEngines().includes(engine)) return null;
+ const result = applyEngineSettings(engine);
+ if (!result.ok) {
+ write(` ${engine} defaults not applied: ${result.error?.message || result.error} — moshcode engines defaults apply ${engine}`);
+ return result;
+ }
+ const added = result.changes.filter((c) => c.change === "added");
+ if (added.length) write(` ${engine} defaults: ${added.map((c) => c.label).join(" · ")} (${result.file})`);
+ return result;
+}
+
+const USAGE = "usage: moshcode engines defaults [status|apply|remove] [|all] [--dry-run] [--json]";
+
+/**
+ * `moshcode engines defaults …` — status is the default verb, `all` the
+ * default target. Shared by the CLI and the pit; `write` is console.log or the
+ * pit's indenting wrapper.
+ */
+export async function enginesDefaults(argv = [], { write = console.log } = {}) {
+ const positional = argv.filter((a) => !a.startsWith("-"));
+ const dryRun = argv.includes("--dry-run");
+ const json = argv.includes("--json");
+ const supported = defaultableEngines();
+
+ // `moshcode engines defaults claude` reads as status for claude; the verb
+ // is optional and an engine name in its place should not be a usage error.
+ let [verb = "status", target] = positional;
+ if (!["status", "apply", "remove"].includes(verb)) {
+ if (resolveEngine(verb) && !target) { target = verb; verb = "status"; }
+ else { write(err(USAGE)); return 1; }
+ }
+
+ const targets = (() => {
+ if (!target || target === "all") return supported;
+ const resolved = resolveEngine(target);
+ return resolved ? [resolved[0]] : [];
+ })();
+
+ if (!targets.length) {
+ write(err(target ? `no engine named ${JSON.stringify(target)}` : "no engine in this release ships settings defaults"));
+ if (target) write(info(`engines with defaults: ${supported.join(", ") || "none yet"}`));
+ return 1;
+ }
+
+ if (verb === "status") {
+ const rows = targets.map((engine) => settingsStatus(engine));
+ if (json) { write(JSON.stringify(rows, null, 2)); return 0; }
+ for (const row of rows) {
+ if (!row.readable) { write(err(`${row.engine} — ${row.error}`)); continue; }
+ const missing = row.entries.filter((e) => e.state === "missing").length;
+ write(row.applied
+ ? ok(`${row.engine} — defaults in place`)
+ : info(`${row.engine} — ${missing} of ${row.entries.length} not set · moshcode engines defaults apply ${row.engine}`));
+ write(ash(` ${row.file}`));
+ for (const e of row.entries) {
+ const mark = e.state === "set" ? acid("✓") : e.state === "theirs" ? amber("~") : ash("·");
+ const detail = e.state === "theirs" ? ash(`yours: ${JSON.stringify(e.have)}`) : ash(`→ ${JSON.stringify(e.want)}`);
+ write(` ${mark} ${e.label.padEnd(34)} ${detail}`);
+ }
+ }
+ const unsupported = Object.keys(ENGINES).filter((k) => !supported.includes(k));
+ if (unsupported.length && !target) write(info(`no defaults yet: ${unsupported.join(", ")}`));
+ return 0;
+ }
+
+ const results = targets.map((engine) => (verb === "apply"
+ ? applyEngineSettings(engine, { dryRun })
+ : removeEngineSettings(engine, { dryRun })));
+
+ if (json) {
+ write(JSON.stringify(results.map((r) => ({
+ engine: r.engine, ok: r.ok, file: r.file ?? settingsFile(r.engine), dryRun: Boolean(r.dryRun),
+ ...(r.changes ? { changes: r.changes } : {}), ...(r.removed !== undefined ? { removed: r.removed } : {}),
+ ...(r.error ? { error: String(r.error.message || r.error) } : {}),
+ })), null, 2));
+ return results.every((r) => r.ok) ? 0 : 1;
+ }
+
+ for (const result of results) {
+ if (!result.ok) { write(err(`${result.engine} — ${result.error?.message || result.error}`)); continue; }
+ if (dryRun) {
+ const diff = hookDiff(result.before, result.after);
+ write(info(`${result.engine} — ${result.file} (dry run)`));
+ write(diff.split("\n").some((l) => l.startsWith("+") || l.startsWith("-")) ? diff : ash(" nothing would change"));
+ continue;
+ }
+ if (verb === "apply") {
+ const added = result.changes.filter((c) => c.change === "added");
+ const kept = result.changes.filter((c) => c.change === "kept");
+ write(added.length
+ ? ok(`${result.engine} — ${added.length} default${added.length === 1 ? "" : "s"} applied (${added.map((c) => c.label).join(", ")})`)
+ : ok(`${result.engine} — already in place`));
+ if (kept.length) write(info(`left as you set them: ${kept.map((c) => c.key).join(", ")}`));
+ if (added.length) write(ash(` ${result.file} — takes effect on the engine's next start`));
+ } else {
+ write(result.removed
+ ? ok(`${result.engine} — ${result.removed} default${result.removed === 1 ? "" : "s"} removed`)
+ : info(`${result.engine} — nothing of ours was in there.`));
+ }
+ }
+ return results.every((r) => r.ok) ? 0 : 1;
+}
diff --git a/src/engines.mjs b/src/engines.mjs
index 1d03ec6e..80d3974b 100644
--- a/src/engines.mjs
+++ b/src/engines.mjs
@@ -116,6 +116,31 @@ export const ENGINES = {
{ event: "UserPromptSubmit", state: "working", label: "prompt-submit" },
],
},
+ // The engine's settings as moshcode would like to find them (PRD 0011's
+ // sibling: the engine speaks for itself here too). `moshcode install
+ // claude` and `moshcode engines defaults apply` merge these into the file
+ // above — a key the operator already set is never touched, so this is a
+ // floor under a fresh install and not a policy over an old one.
+ //
+ // Why these three: a coding session that fans out to a workflow by default
+ // is what a herd of agents is for, and the two caps keep one session from
+ // eating the box the rest of the herd is running on. "small" is Claude's
+ // own advisory tier (fewer than 5 agents per workflow); the env var is the
+ // hard gate on how many run at once, and 4 leaves room for the other three.
+ settings: {
+ format: "claude-settings",
+ file: () => path.join(homedir(), ".claude", "settings.json"),
+ defaults: {
+ ultracode: true,
+ workflowSizeGuideline: "small",
+ env: { CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS: "4" },
+ },
+ labels: {
+ "ultracode": "ultracode on by default",
+ "workflowSizeGuideline": "small workflows (under 5 agents)",
+ "env.CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS": "4 agents at once, hard cap",
+ },
+ },
state: {
// The permission dialog's own heading, and the selector on its first
// option — the generic numbered-menu pattern would catch the second only
diff --git a/src/herd-hooks.mjs b/src/herd-hooks.mjs
index e26cf54b..952c3d37 100644
--- a/src/herd-hooks.mjs
+++ b/src/herd-hooks.mjs
@@ -68,7 +68,7 @@ export function isOurs(entry) {
const HOOK_FILE_MODE = 0o600;
-function readJsonFile(file) {
+export function readJsonFile(file) {
let text;
try { text = fs.readFileSync(file, "utf8"); }
catch (error) {
@@ -85,7 +85,7 @@ function readJsonFile(file) {
}
}
-function writeJsonFile(file, data, { mode = HOOK_FILE_MODE } = {}) {
+export function writeJsonFile(file, data, { mode = HOOK_FILE_MODE } = {}) {
const body = `${JSON.stringify(data, null, 2)}\n`;
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
// Write-then-rename: a crash mid-write on the engine's own settings file
@@ -97,7 +97,7 @@ function writeJsonFile(file, data, { mode = HOOK_FILE_MODE } = {}) {
}
/** The mode an existing file already has, so an install does not tighten it. */
-function existingMode(file) {
+export function existingMode(file) {
try { return fs.statSync(file).mode & 0o777; }
catch { return HOOK_FILE_MODE; }
}
diff --git a/src/tui.mjs b/src/tui.mjs
index cb447e05..f571eb5e 100644
--- a/src/tui.mjs
+++ b/src/tui.mjs
@@ -826,6 +826,15 @@ function installTarget(key) {
// exactly as it did before. Names you bound yourself are never touched.
const added = Object.hasOwn(TOOLS, key) ? adoptToolAliases(key, TOOLS[key], { quiet: true }) : 0;
if (added) console.log(ash(` ${added} alias${added === 1 ? "" : "es"} from ${key} · /alias list for all of them`));
+ // An engine with settings defaults gets them too — holes only, and quiet
+ // when there are none (src/engine-settings.mjs).
+ if (Object.hasOwn(ENGINES, key)) {
+ import("./engine-settings.mjs")
+ .then(({ applyAfterInstall }) => applyAfterInstall(key, { write: (l) => console.log(ash(l)) }))
+ .catch((e) => console.log(err(`defaults not applied: ${String(e.message || e)}`)))
+ .finally(resolve);
+ return;
+ }
resolve();
});
});
@@ -1144,6 +1153,11 @@ export async function tui() {
printEngines(rest[0] === "--json");
continue;
}
+ if (cmd === "engines" && rest[0] === "defaults") {
+ const { enginesDefaults } = await import("./engine-settings.mjs");
+ await enginesDefaults(rest.slice(1), { write: (l) => console.log(` ${l}`) });
+ continue;
+ }
const resolved = resolveEngine(rest[0]);
if (!resolved) { console.log(err(`unknown engine "${rest[0]}". try: ${Object.keys(ENGINES).join(", ")}`)); continue; }
const [key, engine] = resolved;
diff --git a/test/engine-settings.test.mjs b/test/engine-settings.test.mjs
new file mode 100644
index 00000000..9d2608ec
--- /dev/null
+++ b/test/engine-settings.test.mjs
@@ -0,0 +1,257 @@
+// Engine settings defaults: the merge that only ever fills a hole, the remove
+// that only takes back what is still ours, and the file it refuses to touch.
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+import {
+ applyAfterInstall, applyEngineSettings, defaultEntries, defaultableEngines, enginesDefaults,
+ removeEngineSettings, settingsStatus,
+} from "../src/engine-settings.mjs";
+import { ENGINES } from "../src/engines.mjs";
+
+function withSettings(initial, fn) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-defaults-test-"));
+ const file = path.join(dir, "settings.json");
+ if (initial !== undefined) fs.writeFileSync(file, typeof initial === "string" ? initial : JSON.stringify(initial, null, 2));
+ const cleanup = () => fs.rmSync(dir, { recursive: true, force: true });
+ // The command tests are async: clean up after the promise, not before it.
+ let result;
+ try { result = fn(file); }
+ catch (error) { cleanup(); throw error; }
+ if (result && typeof result.then === "function") return result.finally(cleanup);
+ cleanup();
+ return result;
+}
+
+const read = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
+
+/* ---------------------------------------------------------------- the spec */
+
+test("claude ships ultracode on, small workflows, and a hard cap of four agents", () => {
+ // The three values this feature exists to carry. Change them here and in
+ // the README together — the numbers in the prose are these.
+ assert.deepEqual(ENGINES.claude.settings.defaults, {
+ ultracode: true,
+ workflowSizeGuideline: "small",
+ env: { CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS: "4" },
+ });
+ assert.deepEqual(defaultableEngines(), ["claude"]);
+});
+
+test("the spec points at Claude Code's own settings file", () => {
+ assert.equal(ENGINES.claude.settings.file(), path.join(os.homedir(), ".claude", "settings.json"));
+ // Same file the hooks write, so both installers are guests in one config.
+ assert.equal(ENGINES.claude.settings.file(), ENGINES.claude.hooks.file());
+});
+
+test("nested defaults flatten to one leaf per key", () => {
+ const keys = defaultEntries("claude").map((e) => e.key);
+ assert.deepEqual(keys, ["ultracode", "workflowSizeGuideline", "env.CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS"]);
+ assert.ok(defaultEntries("claude").every((e) => e.label && e.label !== e.key), "every default carries a human label");
+ assert.deepEqual(defaultEntries("codex"), []);
+});
+
+/* ------------------------------------------------------------- apply/merge */
+
+test("applying fills holes and leaves everything else alone", () => {
+ withSettings({
+ model: "opus",
+ env: { FOO: "bar" },
+ hooks: { Stop: [{ hooks: [{ type: "command", command: "echo theirs" }] }] },
+ }, (file) => {
+ const result = applyEngineSettings("claude", { file });
+ assert.equal(result.ok, true);
+ assert.equal(result.written, 3);
+ const after = read(file);
+ assert.equal(after.model, "opus", "an unrelated setting was lost");
+ assert.equal(after.env.FOO, "bar", "a sibling env var was lost");
+ assert.equal(after.env.CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS, "4");
+ assert.equal(after.ultracode, true);
+ assert.equal(after.workflowSizeGuideline, "small");
+ assert.equal(after.hooks.Stop[0].hooks[0].command, "echo theirs", "the user's hook was clobbered");
+ });
+});
+
+test("a key the operator set is never touched, even to the opposite value", () => {
+ // The whole reason "theirs" is a state and not a fault: a floor under a
+ // fresh install, not a policy over an old one.
+ withSettings({ ultracode: false, workflowSizeGuideline: "large" }, (file) => {
+ const result = applyEngineSettings("claude", { file });
+ assert.equal(result.ok, true);
+ assert.deepEqual(result.changes.map((c) => c.change), ["kept", "kept", "added"]);
+ const after = read(file);
+ assert.equal(after.ultracode, false);
+ assert.equal(after.workflowSizeGuideline, "large");
+ assert.equal(after.env.CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS, "4");
+ });
+});
+
+test("applying twice changes nothing the second time", () => {
+ withSettings({}, (file) => {
+ applyEngineSettings("claude", { file });
+ const second = applyEngineSettings("claude", { file });
+ assert.equal(second.written, 0);
+ assert.ok(second.changes.every((c) => c.change === "unchanged"));
+ });
+});
+
+test("applying creates the file when the engine has never been configured", () => {
+ withSettings(undefined, (file) => {
+ assert.equal(applyEngineSettings("claude", { file }).ok, true);
+ assert.deepEqual(read(file), ENGINES.claude.settings.defaults);
+ assert.equal(fs.statSync(file).mode & 0o777, 0o600, "a fresh settings file should be private");
+ });
+});
+
+test("a settings file that cannot be parsed is refused, not overwritten", () => {
+ withSettings("{ not json", (file) => {
+ const result = applyEngineSettings("claude", { file });
+ assert.equal(result.ok, false);
+ assert.match(String(result.error.message), /not valid JSON/);
+ assert.equal(fs.readFileSync(file, "utf8"), "{ not json");
+ });
+});
+
+test("--dry-run writes nothing and can still show the change", () => {
+ withSettings({ model: "opus" }, (file) => {
+ const result = applyEngineSettings("claude", { file, dryRun: true });
+ assert.equal(result.ok, true);
+ assert.deepEqual(read(file), { model: "opus" }, "a dry run touched the file");
+ assert.match(result.after, /"ultracode": true/);
+ });
+});
+
+test("an engine without a spec is refused with a reason", () => {
+ const result = applyEngineSettings("codex", { file: "/nonexistent/never-written.json" });
+ assert.equal(result.ok, false);
+ assert.equal(result.supported, false);
+ assert.ok(!fs.existsSync("/nonexistent/never-written.json"));
+});
+
+/* ------------------------------------------------------------------ status */
+
+test("status tells set from missing from theirs", () => {
+ withSettings({ ultracode: true, workflowSizeGuideline: "large" }, (file) => {
+ const status = settingsStatus("claude", { file });
+ assert.equal(status.readable, true);
+ assert.deepEqual(status.entries.map((e) => e.state), ["set", "theirs", "missing"]);
+ assert.equal(status.entries[1].have, "large");
+ assert.equal(status.applied, false, "one is still missing");
+ applyEngineSettings("claude", { file });
+ const after = settingsStatus("claude", { file });
+ assert.deepEqual(after.entries.map((e) => e.state), ["set", "theirs", "set"]);
+ assert.equal(after.applied, true, "an override is an answer, not a hole");
+ });
+});
+
+test("status on a missing file is every default missing, not an error", () => {
+ withSettings(undefined, (file) => {
+ const status = settingsStatus("claude", { file });
+ assert.equal(status.readable, true);
+ assert.equal(status.present, false);
+ assert.ok(status.entries.every((e) => e.state === "missing"));
+ });
+});
+
+/* ------------------------------------------------------------------ remove */
+
+test("remove takes out only what is still ours", () => {
+ withSettings({ model: "opus", env: { FOO: "bar" } }, (file) => {
+ applyEngineSettings("claude", { file });
+ // The operator has since changed one of them: it is theirs now.
+ const edited = read(file);
+ edited.workflowSizeGuideline = "large";
+ fs.writeFileSync(file, JSON.stringify(edited, null, 2));
+
+ const result = removeEngineSettings("claude", { file });
+ assert.equal(result.ok, true);
+ assert.equal(result.removed, 2);
+ const after = read(file);
+ assert.equal(after.model, "opus");
+ assert.equal(after.env.FOO, "bar", "a sibling env var went with ours");
+ assert.equal("ultracode" in after, false);
+ assert.equal("CLAUDE_CODE_WORKFLOW_MAX_CONCURRENT_AGENTS" in after.env, false);
+ assert.equal(after.workflowSizeGuideline, "large", "the operator's edit was removed");
+ });
+});
+
+test("remove drops an env object it emptied and keeps one it did not", () => {
+ withSettings({}, (file) => {
+ applyEngineSettings("claude", { file });
+ removeEngineSettings("claude", { file });
+ assert.deepEqual(read(file), {}, "an empty env we created should go with our key");
+ });
+ withSettings({ env: { FOO: "bar" } }, (file) => {
+ applyEngineSettings("claude", { file });
+ removeEngineSettings("claude", { file });
+ assert.deepEqual(read(file), { env: { FOO: "bar" } });
+ });
+});
+
+test("remove on a file that was never there is a no-op", () => {
+ withSettings(undefined, (file) => {
+ const result = removeEngineSettings("claude", { file });
+ assert.equal(result.ok, true);
+ assert.equal(result.removed, 0);
+ assert.ok(!fs.existsSync(file));
+ });
+});
+
+/* --------------------------------------------------------- after an install */
+
+test("after an install, an engine with no spec says nothing at all", () => {
+ const lines = [];
+ assert.equal(applyAfterInstall("codex", { write: (l) => lines.push(l) }), null);
+ assert.deepEqual(lines, []);
+});
+
+/* -------------------------------------------------------------- the command */
+
+async function run(argv, file) {
+ const lines = [];
+ // The command resolves the file from the spec; point it at the temp file.
+ const spec = ENGINES.claude.settings;
+ const original = spec.file;
+ spec.file = () => file;
+ try { return { code: await enginesDefaults(argv, { write: (l) => lines.push(l) }), lines }; }
+ finally { spec.file = original; }
+}
+
+test("`engines defaults claude` is status for claude, not a usage error", async () => {
+ await withSettings({}, async (file) => {
+ const { code, lines } = await run(["claude"], file);
+ assert.equal(code, 0);
+ assert.match(lines.join("\n"), /3 of 3 not set/);
+ });
+});
+
+test("`engines defaults apply claude --json` is machine-readable and reports every change", async () => {
+ await withSettings({ ultracode: false }, async (file) => {
+ const { code, lines } = await run(["apply", "claude", "--json"], file);
+ assert.equal(code, 0);
+ const [result] = JSON.parse(lines.join("\n"));
+ assert.equal(result.engine, "claude");
+ assert.deepEqual(result.changes.map((c) => c.change), ["kept", "added", "added"]);
+ assert.equal(read(file).ultracode, false);
+ });
+});
+
+test("a verb nobody knows is a usage error", async () => {
+ await withSettings({}, async (file) => {
+ const { code, lines } = await run(["frobnicate", "claude"], file);
+ assert.equal(code, 1);
+ assert.match(lines.join("\n"), /usage: moshcode engines defaults/);
+ });
+});
+
+test("an engine nobody knows names the ones that have defaults", async () => {
+ await withSettings({}, async (file) => {
+ const { code, lines } = await run(["apply", "nope"], file);
+ assert.equal(code, 1);
+ assert.match(lines.join("\n"), /no engine named "nope"/);
+ assert.match(lines.join("\n"), /engines with defaults: claude/);
+ });
+});