Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ or miss one that does. A test fails the build when it drifts.
| `moshcode shorten` <br>`short` `link` | hosting | mint a short link on the pit β€” /f/<code> follows to your url |
| `moshcode games` <br>`game` `arcade` | arcade | the moshcode arcade β€” twenty-two games, no menus |
| `moshcode pwd` <br>`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` <br>`advisor` | tools | equity research from advis0r.com |
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions bin/moshcode.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}
Expand Down
20 changes: 17 additions & 3 deletions src/cli-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -548,10 +548,24 @@
{
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] [<engine>|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 <engine>` 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",
Expand Down
304 changes: 304 additions & 0 deletions src/engine-settings.mjs
Original file line number Diff line number Diff line change
@@ -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 <engine>` 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 <engine>` 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] [<engine>|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;
}
Loading
Loading