catherd-cli 0.2.0 → 1.0.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/README.md +114 -30
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +1 -1
- package/plugin/commands/catherd.md +1 -1
- package/plugin/skills/catherd/SKILL.md +97 -51
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +144 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +223 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +227 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +316 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +58 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +69 -0
- package/src/domain/profile-rules.ts +205 -0
- package/src/domain/profile.ts +383 -0
- package/src/domain/record.ts +84 -0
- package/src/{profile → domain}/role-prompts.ts +42 -25
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +33 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +16 -0
- package/src/entry/doctor-command.ts +45 -0
- package/src/entry/glyphs.ts +33 -0
- package/src/entry/init-command.ts +142 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/entry/mcp/dispatch-tools.ts +82 -0
- package/src/entry/mcp/handshake.ts +48 -0
- package/src/entry/mcp/lane-tools.ts +83 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +144 -0
- package/src/entry/mcp/server.ts +72 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +320 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +217 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +270 -0
- package/src/entry/tui/fixtures.ts +318 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +419 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +133 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +336 -0
- package/src/entry/tui/stories.ts +75 -0
- package/src/entry/tui/text.ts +118 -0
- package/src/entry/tui/theme.ts +139 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +281 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +206 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +196 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/dispatch-dir.ts +278 -0
- package/src/infra/env.ts +43 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +257 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +277 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +479 -0
- package/src/services/dispatches.ts +145 -0
- package/src/services/doctor-backends.ts +185 -0
- package/src/services/doctor-checks.ts +103 -0
- package/src/services/doctor.ts +358 -0
- package/src/services/finalize.ts +359 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +248 -0
- package/src/services/ports.ts +132 -0
- package/src/services/preflight.ts +207 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/reconcile.ts +101 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +161 -0
- package/src/services/run-store.ts +316 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +104 -0
- package/src/services/summary.ts +227 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -69
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
- /package/src/{mcp/commands.ts → entry/mcp/command.ts} +0 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isDeepStrictEqual } from "node:util";
|
|
4
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
5
|
+
import { parseRung } from "../domain/ids.ts";
|
|
6
|
+
import {
|
|
7
|
+
agentName,
|
|
8
|
+
applyPatch,
|
|
9
|
+
assertProfileName,
|
|
10
|
+
type Change,
|
|
11
|
+
defaultProfileDoc,
|
|
12
|
+
diffProfiles,
|
|
13
|
+
type Profile,
|
|
14
|
+
type ProfileDoc,
|
|
15
|
+
type ProfilePatch,
|
|
16
|
+
resolveProfile,
|
|
17
|
+
} from "../domain/profile.ts";
|
|
18
|
+
import { type Validation, validateProfile } from "../domain/profile-rules.ts";
|
|
19
|
+
import { ROLES } from "../domain/roles.ts";
|
|
20
|
+
import { withFileLockSync } from "../infra/filelock.ts";
|
|
21
|
+
import { configDir } from "../infra/paths.ts";
|
|
22
|
+
import { ensurePrivateDir, writeJsonAtomic, writeTextAtomic } from "../infra/store.ts";
|
|
23
|
+
import { apply, plan, type Synced } from "./agent-links.ts";
|
|
24
|
+
import { loadCatalog } from "./catalog-service.ts";
|
|
25
|
+
import type { ProfilePort, ProfileSaved, ProfileView } from "./ports.ts";
|
|
26
|
+
import {
|
|
27
|
+
activeName,
|
|
28
|
+
configFile,
|
|
29
|
+
getProfile,
|
|
30
|
+
listProfiles,
|
|
31
|
+
profileExists,
|
|
32
|
+
profileFile,
|
|
33
|
+
profileFor,
|
|
34
|
+
projectsFile,
|
|
35
|
+
readConfig,
|
|
36
|
+
readProfileDoc,
|
|
37
|
+
readProjects,
|
|
38
|
+
requireProfile,
|
|
39
|
+
roleEnforcement,
|
|
40
|
+
runnableBackends,
|
|
41
|
+
validateNamed,
|
|
42
|
+
} from "./profile-store.ts";
|
|
43
|
+
|
|
44
|
+
// Spec §7.3, the single writer: one lock over profiles, config.json, projects.json, agent files and links.
|
|
45
|
+
// Reads live in profile-store.ts, the agent files and links in agent-links.ts.
|
|
46
|
+
|
|
47
|
+
/** The profiles lock every writer here takes; `init` moves 0.x files aside under it too. */
|
|
48
|
+
export const withProfilesLock = <T>(fn: () => T): T => {
|
|
49
|
+
ensurePrivateDir(configDir());
|
|
50
|
+
return withFileLockSync(join(configDir(), "profiles"), fn);
|
|
51
|
+
};
|
|
52
|
+
const locked = withProfilesLock;
|
|
53
|
+
|
|
54
|
+
const writeDoc = (name: string, doc: ProfileDoc) => writeJsonAtomic(profileFile(name), { ...doc, name });
|
|
55
|
+
|
|
56
|
+
/** Writes `doc` as profile `name` and relinks; when the relink refuses, puts the profile back as it was. */
|
|
57
|
+
function saveAndLink(name: string, doc: ProfileDoc): Synced {
|
|
58
|
+
const before = existsSync(profileFile(name)) ? readFileSync(profileFile(name), "utf8") : null;
|
|
59
|
+
writeDoc(name, doc);
|
|
60
|
+
try {
|
|
61
|
+
return apply(plan([name]));
|
|
62
|
+
} catch (e) {
|
|
63
|
+
if (before === null) rmSync(profileFile(name), { force: true });
|
|
64
|
+
else writeTextAtomic(profileFile(name), before);
|
|
65
|
+
throw e;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const validate = (doc: ProfileDoc, name: string): Validation =>
|
|
70
|
+
validateProfile(resolveProfile(doc, name), loadCatalog({ timings: false }), runnableBackends(), doc);
|
|
71
|
+
|
|
72
|
+
/** What a save returns: the port's ProfileSaved (one type for the CLI, the TUI and the MCP tools). */
|
|
73
|
+
export type Saved = ProfileSaved;
|
|
74
|
+
|
|
75
|
+
const unsaved = (v: Validation): Saved => ({
|
|
76
|
+
saved: false,
|
|
77
|
+
...v,
|
|
78
|
+
diff: [],
|
|
79
|
+
linked: [],
|
|
80
|
+
pruned: [],
|
|
81
|
+
newSessionNeededFor: [],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/** The issue path of a save refused because the profile is no longer the one its caller was shown. */
|
|
85
|
+
export const CHANGED_ON_DISK = "profile";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Spec §7.3 `patch` (profile_set, `catherd profile set`): validates first and writes nothing when invalid.
|
|
89
|
+
* A profile that does not exist yet starts from the default profile. With `expect` (the profile as a
|
|
90
|
+
* preview read it), it writes nothing when the profile under the lock is no longer that one: the patch
|
|
91
|
+
* would land on values the preview never showed.
|
|
92
|
+
*/
|
|
93
|
+
export function patchProfile(
|
|
94
|
+
name: string | undefined,
|
|
95
|
+
patch: ProfilePatch,
|
|
96
|
+
opts: { expect?: ProfileDoc } = {},
|
|
97
|
+
): Saved {
|
|
98
|
+
return locked(() => {
|
|
99
|
+
const n = assertProfileName(name ?? activeName());
|
|
100
|
+
const before = profileExists(n) ? readProfileDoc(n) : defaultProfileDoc(n);
|
|
101
|
+
if (opts.expect !== undefined && !isDeepStrictEqual(before, opts.expect))
|
|
102
|
+
return unsaved({
|
|
103
|
+
errors: [
|
|
104
|
+
{
|
|
105
|
+
path: CHANGED_ON_DISK,
|
|
106
|
+
message: `profile "${n}" changed on disk since it was shown`,
|
|
107
|
+
fix: "check the changes and save again",
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
warnings: [],
|
|
111
|
+
});
|
|
112
|
+
const after = applyPatch(before, patch);
|
|
113
|
+
const resolved = resolveProfile(after, n);
|
|
114
|
+
const v = validate(after, n);
|
|
115
|
+
if (v.errors.length) return unsaved(v);
|
|
116
|
+
const diff = diffProfiles(resolveProfile(before, n), resolved);
|
|
117
|
+
return { saved: true, ...v, diff, ...saveAndLink(n, after) };
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Spec §7.3 `create` and `copy`: a new profile from `from` (default: the default profile). */
|
|
122
|
+
export function createProfile(name: string, from?: string): Saved {
|
|
123
|
+
return locked(() => {
|
|
124
|
+
// `default` exists without a file: resetProfile is the only way to write it
|
|
125
|
+
if (profileExists(assertProfileName(name)))
|
|
126
|
+
throw new CatherdError("E_INPUT_INVALID", `profile "${name}" already exists`, {
|
|
127
|
+
fix: `pick another name, or edit it with catherd profile set --profile ${name}`,
|
|
128
|
+
});
|
|
129
|
+
if (from !== undefined && !profileExists(assertProfileName(from)))
|
|
130
|
+
throw new CatherdError("E_INPUT_INVALID", `no profile named "${from}" to copy`, {
|
|
131
|
+
fix: "catherd profile list",
|
|
132
|
+
});
|
|
133
|
+
const doc = from === undefined ? defaultProfileDoc(name) : readProfileDoc(from);
|
|
134
|
+
const v = validate(doc, name);
|
|
135
|
+
if (v.errors.length) return unsaved(v);
|
|
136
|
+
return { saved: true, ...v, diff: [], ...saveAndLink(name, doc) };
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Writes the default profile under `name`, replacing what is there (`catherd init` when asked to). */
|
|
141
|
+
export function resetProfile(name: string): Saved {
|
|
142
|
+
return locked(() => {
|
|
143
|
+
assertProfileName(name);
|
|
144
|
+
const doc = defaultProfileDoc(name);
|
|
145
|
+
const v = validate(doc, name);
|
|
146
|
+
if (v.errors.length) return unsaved(v);
|
|
147
|
+
return { saved: true, ...v, diff: [], ...saveAndLink(name, doc) };
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Spec §7.3 `delete`: never the active profile or a repo-bound one; its agent files go with it. A binding
|
|
153
|
+
* whose repo no longer exists does not count: it is pruned with the profile.
|
|
154
|
+
*/
|
|
155
|
+
export function deleteProfile(name: string): Synced {
|
|
156
|
+
return locked(() => {
|
|
157
|
+
assertProfileName(name);
|
|
158
|
+
if (!existsSync(profileFile(name)))
|
|
159
|
+
throw new CatherdError("E_INPUT_INVALID", `profile "${name}" has no file to delete`, {
|
|
160
|
+
fix: "catherd profile list",
|
|
161
|
+
});
|
|
162
|
+
if (name === activeName())
|
|
163
|
+
throw new CatherdError("E_INPUT_INVALID", `"${name}" is the active profile`, {
|
|
164
|
+
fix: "make another profile active first: catherd profile use <name>",
|
|
165
|
+
});
|
|
166
|
+
const projects = readProjects();
|
|
167
|
+
const mine = Object.keys(projects.bindings).filter((repo) => projects.bindings[repo] === name);
|
|
168
|
+
const bound = mine.filter((repo) => existsSync(repo));
|
|
169
|
+
if (bound.length)
|
|
170
|
+
throw new CatherdError("E_INPUT_INVALID", `"${name}" is bound to ${bound.join(", ")}`, {
|
|
171
|
+
fix: "run catherd profile use --repo --clear (or use <name> --repo) inside each of those repos",
|
|
172
|
+
});
|
|
173
|
+
const text = readFileSync(profileFile(name), "utf8");
|
|
174
|
+
rmSync(profileFile(name), { force: true });
|
|
175
|
+
const gone = new Set(mine);
|
|
176
|
+
if (gone.size)
|
|
177
|
+
writeJsonAtomic(projectsFile(), {
|
|
178
|
+
...projects,
|
|
179
|
+
bindings: Object.fromEntries(Object.entries(projects.bindings).filter(([r]) => !gone.has(r))),
|
|
180
|
+
});
|
|
181
|
+
try {
|
|
182
|
+
return apply(plan(), [name]);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
writeTextAtomic(profileFile(name), text);
|
|
185
|
+
if (gone.size) writeJsonAtomic(projectsFile(), projects);
|
|
186
|
+
throw e;
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Spec §7.3 `activate(name, repo?)`: the active profile, or the one bound to a repo; relinks the agents. */
|
|
192
|
+
export function activate(
|
|
193
|
+
name: string,
|
|
194
|
+
repo: string | null = null,
|
|
195
|
+
): Synced & { active: string; repo: string | null } {
|
|
196
|
+
return locked(() => {
|
|
197
|
+
assertProfileName(name);
|
|
198
|
+
if (!profileExists(name))
|
|
199
|
+
throw new CatherdError("E_INPUT_INVALID", `no profile named "${name}"`, {
|
|
200
|
+
fix: "catherd profile list",
|
|
201
|
+
});
|
|
202
|
+
const config = readConfig();
|
|
203
|
+
const projects = readProjects();
|
|
204
|
+
const write = () =>
|
|
205
|
+
repo === null
|
|
206
|
+
? writeJsonAtomic(configFile(), { ...config, schema: 1, activeProfile: name })
|
|
207
|
+
: writeJsonAtomic(projectsFile(), {
|
|
208
|
+
...projects,
|
|
209
|
+
schema: 1,
|
|
210
|
+
bindings: { ...projects.bindings, [repo]: name },
|
|
211
|
+
});
|
|
212
|
+
write();
|
|
213
|
+
try {
|
|
214
|
+
return { active: name, repo, ...apply(plan([name])) };
|
|
215
|
+
} catch (e) {
|
|
216
|
+
if (repo === null) writeJsonAtomic(configFile(), config);
|
|
217
|
+
else writeJsonAtomic(projectsFile(), projects);
|
|
218
|
+
throw e;
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** `catherd profile use --repo --clear`: removes `repo`'s binding, so it runs on the active profile again. */
|
|
224
|
+
export function unbind(repo: string): Synced & { repo: string; was: string } {
|
|
225
|
+
return locked(() => {
|
|
226
|
+
const projects = readProjects();
|
|
227
|
+
const was = projects.bindings[repo];
|
|
228
|
+
if (was === undefined)
|
|
229
|
+
throw new CatherdError("E_INPUT_INVALID", `${repo} is bound to no profile`, {
|
|
230
|
+
fix: "catherd profile list shows the bound repos",
|
|
231
|
+
});
|
|
232
|
+
const bindings = { ...projects.bindings };
|
|
233
|
+
delete bindings[repo];
|
|
234
|
+
writeJsonAtomic(projectsFile(), { ...projects, schema: 1, bindings });
|
|
235
|
+
try {
|
|
236
|
+
return { repo, was, ...apply(plan()) };
|
|
237
|
+
} catch (e) {
|
|
238
|
+
writeJsonAtomic(projectsFile(), projects);
|
|
239
|
+
throw e;
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function diffNamed(a: string, b: string): Change[] {
|
|
245
|
+
return diffProfiles(getProfile(a), getProfile(b));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function viewOf(p: Profile): ProfileView {
|
|
249
|
+
const roles: ProfileView["roles"] = {};
|
|
250
|
+
for (const role of ROLES) roles[role] = { ...p.roles[role], rungs: [...p.roles[role].rungs] };
|
|
251
|
+
return {
|
|
252
|
+
name: p.name,
|
|
253
|
+
objective: p.objective,
|
|
254
|
+
roles,
|
|
255
|
+
billing: p.billing,
|
|
256
|
+
jev: p.jev,
|
|
257
|
+
isolated: Object.fromEntries(Object.entries(p.harness).map(([k, h]) => [k, h.isolated])),
|
|
258
|
+
failover: p.failover,
|
|
259
|
+
budget: p.budget,
|
|
260
|
+
timeouts: p.timeouts,
|
|
261
|
+
preflight: p.preflight,
|
|
262
|
+
heavy: p.lock.heavy,
|
|
263
|
+
notify: p.notify,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The ProfilePort the run engine and the MCP tools use (spec §7.3: the single writer). */
|
|
268
|
+
export function profileService(): ProfilePort {
|
|
269
|
+
// without a name, each tool acts on the profile `repo` runs on (the active one outside a repo)
|
|
270
|
+
return {
|
|
271
|
+
forRepo: (repo) => viewOf(profileFor(repo)),
|
|
272
|
+
get(name, repo = null) {
|
|
273
|
+
const here = activeName(repo);
|
|
274
|
+
const p = getProfile(name === undefined ? here : requireProfile(name));
|
|
275
|
+
return {
|
|
276
|
+
active: activeName(),
|
|
277
|
+
here,
|
|
278
|
+
profiles: listProfiles(),
|
|
279
|
+
profile: viewOf(p),
|
|
280
|
+
enforcement: roleEnforcement(p),
|
|
281
|
+
};
|
|
282
|
+
},
|
|
283
|
+
validate(name, repo = null) {
|
|
284
|
+
const v = validateNamed(name === undefined ? activeName(repo) : requireProfile(name), repo);
|
|
285
|
+
return { valid: v.errors.length === 0, ...v };
|
|
286
|
+
},
|
|
287
|
+
// a name that does not exist yet starts from the default profile
|
|
288
|
+
set: (name, patch, repo = null) => patchProfile(name ?? activeName(repo), patch),
|
|
289
|
+
agentFor: (repo, role, rung) =>
|
|
290
|
+
parseRung(rung).backend === "claude" ? agentName(activeName(repo), role, rung) : null,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { adapterFor } from "../adapters/registry.ts";
|
|
5
|
+
import "../adapters/all.ts";
|
|
6
|
+
import { CatherdError, UPGRADE } from "../domain/errors.ts";
|
|
7
|
+
import { ADAPTER_IDS, tryParseRung } from "../domain/ids.ts";
|
|
8
|
+
import {
|
|
9
|
+
assertProfileName,
|
|
10
|
+
defaultProfileDoc,
|
|
11
|
+
PROFILE_NAME,
|
|
12
|
+
type Profile,
|
|
13
|
+
type ProfileDoc,
|
|
14
|
+
ProfileDocSchema,
|
|
15
|
+
resolveProfile,
|
|
16
|
+
} from "../domain/profile.ts";
|
|
17
|
+
import { type Validation, validateProfile } from "../domain/profile-rules.ts";
|
|
18
|
+
import type { Access } from "../domain/record.ts";
|
|
19
|
+
import { ROLES, type Role } from "../domain/roles.ts";
|
|
20
|
+
import { configDir } from "../infra/paths.ts";
|
|
21
|
+
import { readJsonFile } from "../infra/store.ts";
|
|
22
|
+
import { backendOfKey, loadCatalog } from "./catalog-service.ts";
|
|
23
|
+
|
|
24
|
+
// Spec §7.3, the read side of the profile service: where profiles, config.json and projects.json live,
|
|
25
|
+
// and reading them. Every write goes through profile-service.ts.
|
|
26
|
+
|
|
27
|
+
export const profilesDir = (): string => join(configDir(), "profiles");
|
|
28
|
+
export const configFile = (): string => join(configDir(), "config.json");
|
|
29
|
+
export const projectsFile = (): string => join(configDir(), "projects.json");
|
|
30
|
+
/** `<config>/agents/<profile>/<agent>.md`: catherd's own agent files, which the links point at. */
|
|
31
|
+
export const agentsRoot = (): string => join(configDir(), "agents");
|
|
32
|
+
export const profileFile = (name: string): string => join(profilesDir(), `${name}.json`);
|
|
33
|
+
|
|
34
|
+
const ConfigSchema = z.looseObject({ schema: z.literal(1), activeProfile: z.string().optional() });
|
|
35
|
+
const ProjectsSchema = z.looseObject({
|
|
36
|
+
schema: z.literal(1),
|
|
37
|
+
bindings: z.record(z.string(), z.string()).default({}),
|
|
38
|
+
});
|
|
39
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
40
|
+
export type Projects = z.infer<typeof ProjectsSchema>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reads a schema-1 file. A file without `schema` was written by catherd 0.x, which 1.0 does not read
|
|
44
|
+
* (spec D2): `catherd init` moves those aside.
|
|
45
|
+
*/
|
|
46
|
+
function readV1<T>(file: string, schema: z.ZodType<T>): T {
|
|
47
|
+
const raw = readJsonFile(file);
|
|
48
|
+
const found = (raw as { schema?: unknown } | null)?.schema;
|
|
49
|
+
if (found === undefined)
|
|
50
|
+
throw new CatherdError("E_CONFIG_INVALID", `${file} is from catherd 0.x`, {
|
|
51
|
+
fix: "run catherd init, which moves 0.x files aside and writes 1.0 ones",
|
|
52
|
+
});
|
|
53
|
+
if (typeof found === "number" && found > 1)
|
|
54
|
+
throw new CatherdError(
|
|
55
|
+
"E_CONFIG_NEWER_SCHEMA",
|
|
56
|
+
`${file} has schema ${found}, newer than this catherd (1)`,
|
|
57
|
+
{
|
|
58
|
+
fix: `upgrade catherd: ${UPGRADE}`,
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
const r = schema.safeParse(raw);
|
|
62
|
+
if (!r.success)
|
|
63
|
+
throw new CatherdError("E_CONFIG_INVALID", `${file} is invalid:\n${z.prettifyError(r.error)}`, {
|
|
64
|
+
fix: `fix it with catherd profile set, or delete ${file}`,
|
|
65
|
+
});
|
|
66
|
+
return r.data;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const readConfig = (): Config =>
|
|
70
|
+
existsSync(configFile()) ? readV1(configFile(), ConfigSchema) : { schema: 1 };
|
|
71
|
+
export const readProjects = (): Projects =>
|
|
72
|
+
existsSync(projectsFile()) ? readV1(projectsFile(), ProjectsSchema) : { schema: 1, bindings: {} };
|
|
73
|
+
|
|
74
|
+
/** Every profile name: the files in `profiles/`, and `default`, which exists even before its file does. */
|
|
75
|
+
export function listProfiles(): string[] {
|
|
76
|
+
const onDisk = existsSync(profilesDir())
|
|
77
|
+
? readdirSync(profilesDir())
|
|
78
|
+
.filter((f) => f.endsWith(".json"))
|
|
79
|
+
.map((f) => f.slice(0, -".json".length))
|
|
80
|
+
.filter((n) => PROFILE_NAME.test(n))
|
|
81
|
+
: [];
|
|
82
|
+
return [...new Set(["default", ...onDisk])].sort();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const profileExists = (name: string): boolean => listProfiles().includes(name);
|
|
86
|
+
|
|
87
|
+
/** The stored document; `default` without a file is the built-in default profile. */
|
|
88
|
+
export function readProfileDoc(name: string): ProfileDoc {
|
|
89
|
+
assertProfileName(name);
|
|
90
|
+
if (existsSync(profileFile(name))) return readV1(profileFile(name), ProfileDocSchema);
|
|
91
|
+
if (name === "default") return defaultProfileDoc();
|
|
92
|
+
throw new CatherdError("E_CONFIG_INVALID", `no profile named "${name}"`, { fix: "catherd profile list" });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const getProfile = (name: string): Profile => resolveProfile(readProfileDoc(name), name);
|
|
96
|
+
|
|
97
|
+
/** A profile name the user typed: refused as bad input (exit 2) when no such profile exists. */
|
|
98
|
+
export function requireProfile(name: string): string {
|
|
99
|
+
if (!profileExists(assertProfileName(name)))
|
|
100
|
+
throw new CatherdError("E_INPUT_INVALID", `no profile named "${name}"`, { fix: "catherd profile list" });
|
|
101
|
+
return name;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The profile bound to `repo` (a git toplevel), else the active one, else `default`. */
|
|
105
|
+
export function activeName(repo: string | null = null): string {
|
|
106
|
+
const bound = repo === null ? undefined : readProjects().bindings[repo];
|
|
107
|
+
return bound ?? readConfig().activeProfile ?? "default";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const profileFor = (repo: string | null): Profile => getProfile(activeName(repo));
|
|
111
|
+
|
|
112
|
+
/** The backends catherd can run: every registered adapter, and the native `claude` path. */
|
|
113
|
+
export const runnableBackends = (): string[] => ["claude", ...ADAPTER_IDS.filter((id) => adapterFor(id))];
|
|
114
|
+
|
|
115
|
+
/** Whether a profile's billing or harness key belongs to one of `backends` (default: runnableBackends()). */
|
|
116
|
+
export const keyRunnable = (key: string, backends: string[] = runnableBackends()): boolean =>
|
|
117
|
+
backends.includes(backendOfKey(key));
|
|
118
|
+
|
|
119
|
+
/** `repo`: the git toplevel whose listing route reads (opencode lists its models per repository). */
|
|
120
|
+
export function validateNamed(name?: string, repo: string | null = null): Validation {
|
|
121
|
+
const n = name ?? activeName(repo);
|
|
122
|
+
const doc = readProfileDoc(n);
|
|
123
|
+
const catalog = loadCatalog({ timings: false, ...(repo === null ? {} : { repo }) });
|
|
124
|
+
return validateProfile(resolveProfile(doc, n), catalog, runnableBackends(), doc);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Spec D10: how strongly the backend holds a role to its access mode. */
|
|
128
|
+
export function enforcementOf(rung: string, access: Access): "enforced" | "advisory" {
|
|
129
|
+
const r = tryParseRung(rung);
|
|
130
|
+
return (r && adapterFor(r.backend)?.enforcement[access]) ?? "advisory";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Each role's weakest enforcement over its rungs, for `profile show` and profile_get. */
|
|
134
|
+
export function roleEnforcement(p: Profile): Record<Role, "enforced" | "advisory"> {
|
|
135
|
+
const out = {} as Record<Role, "enforced" | "advisory">;
|
|
136
|
+
for (const role of ROLES) {
|
|
137
|
+
const rc = p.roles[role];
|
|
138
|
+
out[role] = rc.rungs.every((r) => enforcementOf(r, rc.access) === "enforced") ? "enforced" : "advisory";
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { errorMessage } from "../domain/errors.ts";
|
|
3
|
+
import { dispatchPaths } from "../infra/dispatch-dir.ts";
|
|
4
|
+
import { log } from "../infra/log.ts";
|
|
5
|
+
import { writeJsonAtomic } from "../infra/store.ts";
|
|
6
|
+
import { type Dispatch, listDispatches, pendingDispatches } from "./dispatches.ts";
|
|
7
|
+
import { finalizeDispatch, waitForFinish } from "./finalize.ts";
|
|
8
|
+
import type { Deps } from "./ports.ts";
|
|
9
|
+
import { listRuns, type Run } from "./run-store.ts";
|
|
10
|
+
import { refreshState } from "./state.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Builds before the spec.json fix (plan-2 review I1) wrote every env value of the MCP server into
|
|
14
|
+
* spec.json, readable by others. A spec is read once, when its supervisor starts, so such a file loses
|
|
15
|
+
* its env and becomes 0600. Returns how many it scrubbed.
|
|
16
|
+
*/
|
|
17
|
+
function scrubOldSpecs(run: Run): number {
|
|
18
|
+
let n = 0;
|
|
19
|
+
for (const d of listDispatches(run)) {
|
|
20
|
+
const file = dispatchPaths(d.dir).spec;
|
|
21
|
+
try {
|
|
22
|
+
if ((statSync(file).mode & 0o077) === 0) continue;
|
|
23
|
+
const doc = JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
24
|
+
writeJsonAtomic(file, { ...doc, env: {} }, { mode: 0o600 });
|
|
25
|
+
n++;
|
|
26
|
+
} catch {
|
|
27
|
+
// no spec.json, or one that cannot be read: nothing to scrub
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return n;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ReconcileReport {
|
|
34
|
+
finalized: string[];
|
|
35
|
+
watching: string[];
|
|
36
|
+
warnings: string[];
|
|
37
|
+
/** settles once every watched dispatch is finalized */
|
|
38
|
+
done: Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Waits for a live dispatch this process did not start, then finalizes it. A state.md refresh that
|
|
43
|
+
* fails rejects, after the record is written, with a message that says so.
|
|
44
|
+
*/
|
|
45
|
+
async function watchAndFinalize(deps: Deps, run: Run, d: Dispatch): Promise<void> {
|
|
46
|
+
await waitForFinish(d, { pollMs: deps.pollMs, tickMs: Number.POSITIVE_INFINITY, now: deps.now });
|
|
47
|
+
await finalizeDispatch(run, d);
|
|
48
|
+
const { hints } = await refreshState(run);
|
|
49
|
+
if (hints[0]) throw new Error(hints[0]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Spec §4.7: on server start, finalize each finished dispatch that has no record, and watch each live
|
|
54
|
+
* one until it finishes. A run that cannot be read is skipped with a warning; it never stops the server.
|
|
55
|
+
*/
|
|
56
|
+
export async function reconcileAll(deps: Deps): Promise<ReconcileReport> {
|
|
57
|
+
const { runs, corrupt } = listRuns();
|
|
58
|
+
const report: Omit<ReconcileReport, "done"> = {
|
|
59
|
+
finalized: [],
|
|
60
|
+
watching: [],
|
|
61
|
+
warnings: corrupt.map((c) => `skipped run ${c.id}: ${c.reason}`),
|
|
62
|
+
};
|
|
63
|
+
const watchers: Promise<void>[] = [];
|
|
64
|
+
const warn = (run: Run, e: unknown): void => {
|
|
65
|
+
const w = `${run.id}: ${errorMessage(e)}`;
|
|
66
|
+
if (!report.warnings.includes(w)) report.warnings.push(w);
|
|
67
|
+
};
|
|
68
|
+
for (const run of runs) {
|
|
69
|
+
let pending;
|
|
70
|
+
try {
|
|
71
|
+
const scrubbed = scrubOldSpecs(run);
|
|
72
|
+
if (scrubbed) log("warn", "reconcile", { run: run.id, scrubbedSpecs: scrubbed });
|
|
73
|
+
pending = pendingDispatches(run, deps.now());
|
|
74
|
+
} catch (e) {
|
|
75
|
+
warn(run, e);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
for (const d of pending) {
|
|
79
|
+
if (d.state !== "finished") {
|
|
80
|
+
report.watching.push(d.admit.dispatchId);
|
|
81
|
+
watchers.push(watchAndFinalize(deps, run, d).catch((e: unknown) => warn(run, e)));
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
await finalizeDispatch(run, d);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
warn(run, e);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
report.finalized.push(d.admit.dispatchId);
|
|
91
|
+
for (const h of (await refreshState(run)).hints) warn(run, h);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
log("info", "reconcile", {
|
|
95
|
+
runs: runs.length,
|
|
96
|
+
finalized: report.finalized.length,
|
|
97
|
+
watching: report.watching.length,
|
|
98
|
+
warnings: report.warnings,
|
|
99
|
+
});
|
|
100
|
+
return { ...report, done: Promise.all(watchers).then(() => undefined) };
|
|
101
|
+
}
|