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/); + }); +});