catherd-cli 0.2.1 → 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 -26
- 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 -84
- 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,126 @@
|
|
|
1
|
+
import type { Difficulty, Kind } from "./lane.ts";
|
|
2
|
+
import type { Role } from "./roles.ts";
|
|
3
|
+
|
|
4
|
+
export const CLIMB_REASONS = [
|
|
5
|
+
"check-failed-twice",
|
|
6
|
+
"blocker",
|
|
7
|
+
"same-defect",
|
|
8
|
+
"refused",
|
|
9
|
+
"blocked",
|
|
10
|
+
"unchanged",
|
|
11
|
+
] as const;
|
|
12
|
+
export type ClimbReason = (typeof CLIMB_REASONS)[number];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Spec §5.4: where a lane's kind and difficulty came from. `jev-kind`: Jev was sure of the kind only, and the
|
|
16
|
+
* difficulty came from the lane's `Difficulty:` line, else from the role's default rung.
|
|
17
|
+
*/
|
|
18
|
+
export type RouteSource = "jev" | "jev-kind" | "lane" | "default";
|
|
19
|
+
|
|
20
|
+
/** What Jev said about a lane, kept with its route for outcomes.jsonl (spec §5.6). */
|
|
21
|
+
export interface RouteJev {
|
|
22
|
+
pKind: number | null;
|
|
23
|
+
pA: number | null;
|
|
24
|
+
pB: number | null;
|
|
25
|
+
nouls: Record<string, number>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One row of routes.jsonl: a lane's route, or one climb of it. The last row of a lane is its current route. */
|
|
29
|
+
export interface RouteRow {
|
|
30
|
+
at: string;
|
|
31
|
+
lane: string;
|
|
32
|
+
role: Role;
|
|
33
|
+
rung: string;
|
|
34
|
+
ladder: string[];
|
|
35
|
+
source: "route" | "climb";
|
|
36
|
+
decidedBy: RouteSource;
|
|
37
|
+
from: string | null;
|
|
38
|
+
reason: string | null;
|
|
39
|
+
kind: Kind | null;
|
|
40
|
+
difficulty: Difficulty | null;
|
|
41
|
+
/** route rows: the Jev question set asked (null when Jev was not asked) and its probabilities */
|
|
42
|
+
questionSet?: string | null;
|
|
43
|
+
jev?: RouteJev | null;
|
|
44
|
+
/** climb rows: the climb was caused by the environment, not the rung's capability (spec §5.6) */
|
|
45
|
+
env?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function nextRung(ladder: string[], current: string): string | null {
|
|
49
|
+
const i = ladder.indexOf(current);
|
|
50
|
+
return i >= 0 && i < ladder.length - 1 ? (ladder[i + 1] as string) : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const currentRoute = (rows: RouteRow[], lane: string): RouteRow | null =>
|
|
54
|
+
rows.findLast((r) => r.lane === lane) ?? null;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The lane's route in force at `at`: its last row stamped at or before that time; null when the lane
|
|
58
|
+
* had no row by then (or `at` is not a time). Rows without a parseable `at` are skipped.
|
|
59
|
+
*/
|
|
60
|
+
export function routeAt(rows: RouteRow[], lane: string, at: string): RouteRow | null {
|
|
61
|
+
const t = Date.parse(at);
|
|
62
|
+
if (Number.isNaN(t)) return null;
|
|
63
|
+
return rows.findLast((r) => r.lane === lane && Date.parse(r.at) <= t) ?? null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* One outcomes.jsonl row (spec §5.6): how a routed lane ended, for calibrating Jev's route rule.
|
|
68
|
+
* A lane can get several rows (a top-rung failure later landed, a milestone landed again):
|
|
69
|
+
* the last row per lane wins; read them through `latestOutcomes`.
|
|
70
|
+
*/
|
|
71
|
+
export interface OutcomeRow {
|
|
72
|
+
at: string;
|
|
73
|
+
lane: string;
|
|
74
|
+
questionSet: string | null;
|
|
75
|
+
jevProbs: RouteJev | null;
|
|
76
|
+
source: RouteSource;
|
|
77
|
+
startRung: string;
|
|
78
|
+
finalRung: string;
|
|
79
|
+
climbs: { from: string; to: string; reason: string; env: boolean }[];
|
|
80
|
+
/** true when the lane landed, false when it ended open (a climb past its top rung) */
|
|
81
|
+
landed: boolean;
|
|
82
|
+
/** landed with no climb the rung itself caused */
|
|
83
|
+
start_ok: boolean;
|
|
84
|
+
/** the ladder index it landed on; null when it ended open (censored) */
|
|
85
|
+
min_ok_index: number | null;
|
|
86
|
+
/** some climb was the environment's fault: calibration leaves this lane out */
|
|
87
|
+
envCaused: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The lane's outcome from its routes.jsonl rows since its last route; null when it was never routed. */
|
|
91
|
+
export function laneOutcome(rows: RouteRow[], lane: string, landed: boolean, at: string): OutcomeRow | null {
|
|
92
|
+
const mine = rows.filter((r) => r.lane === lane);
|
|
93
|
+
const i = mine.findLastIndex((r) => r.source === "route");
|
|
94
|
+
const start = mine[i];
|
|
95
|
+
if (!start) return null;
|
|
96
|
+
// every climb row counts for start_ok and envCaused, including a no-op past the top rung (from === rung),
|
|
97
|
+
// which only drops out of the listed climbs
|
|
98
|
+
const allClimbs = mine
|
|
99
|
+
.slice(i + 1)
|
|
100
|
+
.filter((r) => r.source === "climb" && r.from !== null)
|
|
101
|
+
.map((r) => ({ from: r.from as string, to: r.rung, reason: r.reason ?? "", env: r.env === true }));
|
|
102
|
+
const climbs = allClimbs.filter((c) => c.from !== c.to);
|
|
103
|
+
const finalRung = mine.at(-1)?.rung ?? start.rung;
|
|
104
|
+
const idx = start.ladder.indexOf(finalRung);
|
|
105
|
+
return {
|
|
106
|
+
at,
|
|
107
|
+
lane,
|
|
108
|
+
questionSet: start.questionSet ?? null,
|
|
109
|
+
jevProbs: start.jev ?? null,
|
|
110
|
+
source: start.decidedBy,
|
|
111
|
+
startRung: start.rung,
|
|
112
|
+
finalRung,
|
|
113
|
+
climbs,
|
|
114
|
+
landed,
|
|
115
|
+
start_ok: landed && allClimbs.every((c) => c.env),
|
|
116
|
+
min_ok_index: landed && idx >= 0 ? idx : null,
|
|
117
|
+
envCaused: allClimbs.some((c) => c.env),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Spec §5.6: one row per lane, the last one written (last row per lane wins), in first-seen lane order. */
|
|
122
|
+
export function latestOutcomes(rows: OutcomeRow[]): OutcomeRow[] {
|
|
123
|
+
const byLane = new Map<string, OutcomeRow>();
|
|
124
|
+
for (const r of rows) byLane.set(r.lane, r);
|
|
125
|
+
return [...byLane.values()];
|
|
126
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Spec §3.1 and D6: catherd runs on Bun 1.4.0 or newer. */
|
|
2
|
+
export const MIN_BUN = "1.4.0";
|
|
3
|
+
|
|
4
|
+
const parts = (v: string) => (/^(\d+)\.(\d+)\.(\d+)/.exec(v)?.slice(1) ?? ["0", "0", "0"]).map(Number);
|
|
5
|
+
|
|
6
|
+
export function bunTooOld(version: string): boolean {
|
|
7
|
+
const a = parts(version);
|
|
8
|
+
const b = parts(MIN_BUN);
|
|
9
|
+
for (let i = 0; i < 3; i++)
|
|
10
|
+
if ((a[i] as number) !== (b[i] as number)) return (a[i] as number) < (b[i] as number);
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The two lines to print, and exit 1 on, when this Bun is too old; null when it is new enough. */
|
|
15
|
+
export function runtimeRefusal(version: string | undefined): string[] | null {
|
|
16
|
+
if (version !== undefined && !bunTooOld(version)) return null;
|
|
17
|
+
return [
|
|
18
|
+
`error E_RUNTIME_TOO_OLD: catherd needs Bun ${MIN_BUN} or newer; this is ${version ? `Bun ${version}` : "not Bun"}`,
|
|
19
|
+
"fix: bun upgrade (or install Bun: curl -fsSL https://bun.sh/install | bash)",
|
|
20
|
+
];
|
|
21
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { replaceSecrets, scrubKeyShapes } from "./secrets.ts";
|
|
2
|
+
|
|
3
|
+
/** What to strip from a captured stream: exact secret values, and paths to replace with a stable name. */
|
|
4
|
+
export interface Scrub {
|
|
5
|
+
secrets: string[];
|
|
6
|
+
paths: { from: string; to: string }[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const EMAIL = /[\w.%+-]+@[\w-]+(?:\.[\w-]+)*\.[A-Za-z]{2,}/g;
|
|
10
|
+
|
|
11
|
+
const longestFirst = <T>(xs: T[], len: (x: T) => number) => [...xs].sort((a, b) => len(b) - len(a));
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Spec §11.8: a capture keeps no secret and no home path. Secrets (8+ chars) and key-shaped strings
|
|
15
|
+
* (domain/secrets.ts) become `<redacted>`, emails `<email>`, and each path its stable name, longest
|
|
16
|
+
* path first so a repo inside the home directory reads `<repo>`, not `~/…`.
|
|
17
|
+
*/
|
|
18
|
+
export function sanitize(text: string, s: Scrub): string {
|
|
19
|
+
// a captured stream is JSONL: a secret with a quote, a backslash or a control character is escaped there
|
|
20
|
+
const forms = s.secrets.flatMap((x) => [x, JSON.stringify(x).slice(1, -1)]);
|
|
21
|
+
let out = scrubKeyShapes(replaceSecrets(text, forms, "<redacted>"), "<redacted>");
|
|
22
|
+
out = out.replace(EMAIL, "<email>");
|
|
23
|
+
for (const p of longestFirst(
|
|
24
|
+
s.paths.filter((x) => x.from.length > 1),
|
|
25
|
+
(x) => x.from.length,
|
|
26
|
+
))
|
|
27
|
+
out = out.split(p.from).join(p.to);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Spec §10.4 and §11.8: the one rule for what is a secret. The log redactor (infra/log.ts), fixture
|
|
2
|
+
// capture (domain/sanitize.ts) and Jev's lane state (domain/jev.ts) all scrub with it; each picks its mark.
|
|
3
|
+
|
|
4
|
+
/** Env names whose values are credentials: the word may sit anywhere (OPENAI_API_KEY_2, GH_TOKEN_FILE). */
|
|
5
|
+
const SECRET_NAME = /KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL/i;
|
|
6
|
+
/** A literal secret shorter than this is never scrubbed: it would blank ordinary words. */
|
|
7
|
+
export const MIN_SECRET = 8;
|
|
8
|
+
|
|
9
|
+
export const isSecretName = (name: string): boolean => SECRET_NAME.test(name);
|
|
10
|
+
|
|
11
|
+
const longestFirst = (xs: string[]): string[] => [...new Set(xs)].sort((a, b) => b.length - a.length);
|
|
12
|
+
|
|
13
|
+
/** The values of env vars whose names say they hold a credential: 8+ characters, once each, longest first. */
|
|
14
|
+
export function secretEnvValues(env: Record<string, string | undefined>): string[] {
|
|
15
|
+
return longestFirst(
|
|
16
|
+
Object.entries(env)
|
|
17
|
+
.filter(([k, v]) => v !== undefined && v.length >= MIN_SECRET && isSecretName(k))
|
|
18
|
+
.map(([, v]) => v as string),
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `text` with each literal secret of 8+ characters replaced by `mark`, the longest first. */
|
|
23
|
+
export function replaceSecrets(text: string, secrets: string[], mark: string): string {
|
|
24
|
+
let out = text;
|
|
25
|
+
for (const s of longestFirst(secrets.filter((x) => x.length >= MIN_SECRET))) out = out.split(s).join(mark);
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Strings shaped like a key wherever they appear, and the part kept before the mark ("Bearer ", a URL's user). */
|
|
30
|
+
const KEY_SHAPES: [RegExp, string][] = [
|
|
31
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, ""],
|
|
32
|
+
[/sk-ant-[\w-]{10,}/g, ""],
|
|
33
|
+
[/\bsk-[\w-]{16,}/g, ""],
|
|
34
|
+
[/\bgh[pousr]_[A-Za-z0-9]{20,}/g, ""],
|
|
35
|
+
[/\bgithub_pat_[A-Za-z0-9_]{20,}/g, ""],
|
|
36
|
+
[/\bAKIA[0-9A-Z]{16}\b/g, ""],
|
|
37
|
+
[/\bxox[abpr]-[A-Za-z0-9-]{10,}/g, ""],
|
|
38
|
+
[/\b(Bearer\s+)[\w.~+/=-]{10,}/gi, "$1"],
|
|
39
|
+
[/(\b[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s:@/]+:)[^\s@/]+(?=@)/g, "$1"],
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/** `text` with every key-shaped string replaced by `mark` (which must not contain `$`). */
|
|
43
|
+
export function scrubKeyShapes(text: string, mark: string): string {
|
|
44
|
+
let out = text;
|
|
45
|
+
for (const [re, keep] of KEY_SHAPES) out = out.replace(re, `${keep}${mark}`);
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `api_key = value` and the like, keeping the name. Jev's state only: on a captured stream or a log row
|
|
51
|
+
* it would also blank counts such as `max_tokens: 100000`.
|
|
52
|
+
*/
|
|
53
|
+
const ASSIGNMENT =
|
|
54
|
+
/\b([A-Za-z0-9_]*(?:api[_-]?key|token|secret|password)[A-Za-z0-9_]*)\s*[:=]\s*["']?[^\s"']{6,}/gi;
|
|
55
|
+
|
|
56
|
+
export const scrubSecretAssignments = (text: string, mark: string): string =>
|
|
57
|
+
text.replace(ASSIGNMENT, `$1=${mark}`);
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Catalog,
|
|
3
|
+
capableFor,
|
|
4
|
+
type Dim,
|
|
5
|
+
effortOffered,
|
|
6
|
+
type RungInfo,
|
|
7
|
+
rungInfo,
|
|
8
|
+
scoresOf,
|
|
9
|
+
} from "./catalog.ts";
|
|
10
|
+
import { type BillingMode, type Cost, compareCost, costOf, DEFAULT_BILLING } from "./cost.ts";
|
|
11
|
+
import { CatherdError } from "./errors.ts";
|
|
12
|
+
import { DIFFICULTIES, type Difficulty, type Kind } from "./lane.ts";
|
|
13
|
+
import type { Role } from "./roles.ts";
|
|
14
|
+
|
|
15
|
+
/** What routing reads from a profile for one role. */
|
|
16
|
+
export interface RoutingProfile {
|
|
17
|
+
objective: "cost" | "speed";
|
|
18
|
+
billing: Partial<Record<string, BillingMode>>;
|
|
19
|
+
role: { enabled: boolean; rungs: string[]; defaultRung?: string };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Candidate {
|
|
23
|
+
rung: string;
|
|
24
|
+
info: RungInfo;
|
|
25
|
+
scores: Partial<Record<Dim, number>>;
|
|
26
|
+
cost: Cost;
|
|
27
|
+
/** median seconds over the user's own runs (≥ 5 samples), else null */
|
|
28
|
+
secs: number | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Pick {
|
|
32
|
+
rung: string;
|
|
33
|
+
ladder: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const byNull = (a: number | null, b: number | null) =>
|
|
37
|
+
a === null || b === null ? (a === null ? 1 : 0) - (b === null ? 1 : 0) : a - b;
|
|
38
|
+
|
|
39
|
+
function secsOf(c: Catalog, canonical: string, kind: Kind | null): number | null {
|
|
40
|
+
return c.secs[`${canonical}|${kind ?? "*"}`] ?? c.secs[`${canonical}|*`] ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The role's enabled rungs that the catalog can place: parseable, offered by their model, listed by
|
|
45
|
+
* the backend when it has a listing, capable for the role, and scored (their own or a treat-like).
|
|
46
|
+
* Ordered by cost (spec §5.3), or by measured speed with cost breaking ties; with fewer than 5
|
|
47
|
+
* samples a rung has no speed, and cost orders it, which within a model is effort order.
|
|
48
|
+
*/
|
|
49
|
+
export function candidates(c: Catalog, p: RoutingProfile, role: Role, kind: Kind | null = null): Candidate[] {
|
|
50
|
+
if (!p.role.enabled) return [];
|
|
51
|
+
const out: Candidate[] = [];
|
|
52
|
+
for (const rung of new Set(p.role.rungs)) {
|
|
53
|
+
let info: RungInfo;
|
|
54
|
+
try {
|
|
55
|
+
info = rungInfo(c, rung);
|
|
56
|
+
} catch {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!effortOffered(info) || info.listed === false || !capableFor(c, info, role)) continue;
|
|
60
|
+
const s = scoresOf(c, info.canonical);
|
|
61
|
+
if (!s) continue;
|
|
62
|
+
const mode = p.billing[info.key] ?? DEFAULT_BILLING[info.key];
|
|
63
|
+
out.push({
|
|
64
|
+
rung,
|
|
65
|
+
info,
|
|
66
|
+
scores: s.values,
|
|
67
|
+
cost: costOf(info.family, info.parsed.effort, mode),
|
|
68
|
+
secs: secsOf(c, info.canonical, kind),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const cost = (a: Candidate, b: Candidate) => compareCost(a.cost, b.cost) || byNull(a.secs, b.secs);
|
|
72
|
+
const speed = (a: Candidate, b: Candidate) => byNull(a.secs, b.secs) || compareCost(a.cost, b.cost);
|
|
73
|
+
return out.sort(p.objective === "speed" ? speed : cost);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function clearsBar(c: Catalog, cand: Candidate, kind: Kind, difficulty: Difficulty): boolean {
|
|
77
|
+
return Object.entries(c.bars[kind][difficulty]).every(
|
|
78
|
+
([dim, min]) => min === undefined || (cand.scores[dim as Dim] ?? Number.NEGATIVE_INFINITY) >= min,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function noRung(role: Role): CatherdError {
|
|
83
|
+
return new CatherdError("E_CONFIG_INVALID", `role "${role}" has no enabled, capable, scored rung`, {
|
|
84
|
+
fix: "run profile_validate, then enable a scored rung or map one with catherd catalog treat-like",
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The role's default rung and every candidate above it (the whole list when it has no default), in cost
|
|
90
|
+
* order under either objective: speed order would put slower, not stronger, rungs above the default.
|
|
91
|
+
*/
|
|
92
|
+
export function defaultLadder(c: Catalog, p: RoutingProfile, role: Role): Pick {
|
|
93
|
+
const all = candidates(c, { ...p, objective: "cost" }, role).map((x) => x.rung);
|
|
94
|
+
if (all.length === 0) throw noRung(role);
|
|
95
|
+
const i = Math.max(0, all.indexOf(p.role.defaultRung ?? ""));
|
|
96
|
+
return { rung: all[i] as string, ladder: all.slice(i) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The role's default difficulty for a kind: the hardest difficulty whose bar the role's default rung clears
|
|
101
|
+
* (`build` when it clears none, or is no candidate: never `copy`, whose start is the cheapest rung, below
|
|
102
|
+
* what the `default` source would pick). A route sure of the kind but not the difficulty starts there.
|
|
103
|
+
*/
|
|
104
|
+
export function defaultDifficulty(c: Catalog, p: RoutingProfile, role: Role, kind: Kind): Difficulty {
|
|
105
|
+
const start = defaultLadder(c, p, role).rung;
|
|
106
|
+
const cand = candidates(c, p, role, kind).find((x) => x.rung === start);
|
|
107
|
+
const cleared = cand ? DIFFICULTIES.filter((d) => clearsBar(c, cand, kind, d)) : [];
|
|
108
|
+
return cleared.at(-1) ?? "build";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Under `objective: "speed"` the objective picks only the start (the fastest bar-clearing rung); the
|
|
113
|
+
* ladder above it is the other bar-clearing rungs at least as strong on the kind's primary dimension,
|
|
114
|
+
* weakest first, cost breaking ties, so a lane never climbs onto a weaker rung. Ported from 0.x.
|
|
115
|
+
*/
|
|
116
|
+
function speedLadder(clearing: Candidate[], kind: Kind): Pick {
|
|
117
|
+
const start = clearing[0] as Candidate;
|
|
118
|
+
const dim: Dim = kind === "terminal" ? "terminal" : "repo_code";
|
|
119
|
+
const strength = (x: Candidate) => x.scores[dim] ?? Number.NEGATIVE_INFINITY;
|
|
120
|
+
const rest = clearing
|
|
121
|
+
.filter((x) => x !== start && strength(x) >= strength(start))
|
|
122
|
+
.sort((a, b) => strength(a) - strength(b) || compareCost(a.cost, b.cost));
|
|
123
|
+
return { rung: start.rung, ladder: [start, ...rest].map((x) => x.rung) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Spec §5.4: the start rung and ladder for a lane of this kind and difficulty (0.x `select`). */
|
|
127
|
+
export function select(c: Catalog, p: RoutingProfile, role: Role, kind: Kind, difficulty: Difficulty): Pick {
|
|
128
|
+
const all = candidates(c, p, role, kind);
|
|
129
|
+
if (all.length === 0) throw noRung(role);
|
|
130
|
+
if (all.length === 1) return { rung: all[0]?.rung as string, ladder: [all[0]?.rung as string] };
|
|
131
|
+
const clearing = all.filter((x) => clearsBar(c, x, kind, difficulty));
|
|
132
|
+
if (clearing.length === 0) return defaultLadder(c, p, role);
|
|
133
|
+
return p.objective === "speed"
|
|
134
|
+
? speedLadder(clearing, kind)
|
|
135
|
+
: { rung: clearing[0]?.rung as string, ladder: clearing.map((x) => x.rung) };
|
|
136
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface StateView {
|
|
2
|
+
title: string;
|
|
3
|
+
head: string;
|
|
4
|
+
dirty: { path: string; owner: string | null }[];
|
|
5
|
+
running: { name: string; rung: string; thread: string | null; since: string; brief: string }[];
|
|
6
|
+
lastCheck: string | null;
|
|
7
|
+
next: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** state.md: enough for a fresh session to resume from alone; the next step is always the last line. */
|
|
11
|
+
export function renderState(s: StateView): string {
|
|
12
|
+
const waiting = s.running.map((r) => r.name);
|
|
13
|
+
return [
|
|
14
|
+
`# ${s.title}`,
|
|
15
|
+
"",
|
|
16
|
+
`HEAD ${s.head}`,
|
|
17
|
+
"",
|
|
18
|
+
"Dirty:",
|
|
19
|
+
...(s.dirty.length ? s.dirty.map((d) => `- ${d.path}${d.owner ? ` (${d.owner})` : ""}`) : ["- none"]),
|
|
20
|
+
"",
|
|
21
|
+
"Running:",
|
|
22
|
+
...(s.running.length
|
|
23
|
+
? s.running.map(
|
|
24
|
+
(r) => `- ${r.name} · ${r.rung} · thread ${r.thread ?? "new"} · since ${r.since} · ${r.brief}`,
|
|
25
|
+
)
|
|
26
|
+
: ["- none"]),
|
|
27
|
+
"",
|
|
28
|
+
`Last check: ${s.lastCheck ?? "none"}`,
|
|
29
|
+
"",
|
|
30
|
+
`Next: ${waiting.length ? `wait for ${waiting.join(", ")}; then ${s.next}` : s.next}`,
|
|
31
|
+
"",
|
|
32
|
+
].join("\n");
|
|
33
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Lower-case letters and digits, every other run of characters one `-`, no `-` at either end. */
|
|
2
|
+
export const slug = (s: string): string =>
|
|
3
|
+
s
|
|
4
|
+
.toLowerCase()
|
|
5
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
6
|
+
.replace(/^-|-$/g, "");
|
|
7
|
+
|
|
8
|
+
/** The middle value, or the mean of the two middle values for an even count; null when there are none. */
|
|
9
|
+
export function median(xs: readonly number[]): number | null {
|
|
10
|
+
if (xs.length === 0) return null;
|
|
11
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
12
|
+
const m = s.length >> 1;
|
|
13
|
+
return s.length % 2 ? (s[m] as number) : ((s[m - 1] as number) + (s[m] as number)) / 2;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A plain object: not null and not an array. */
|
|
17
|
+
export const isPlain = (v: unknown): v is Record<string, unknown> =>
|
|
18
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { defineCommand } from "citty";
|
|
4
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
5
|
+
import { assetPath } from "../infra/assets.ts";
|
|
6
|
+
import { CAPTURE_BACKENDS, type Captured, captureFixtures } from "../services/capture.ts";
|
|
7
|
+
import { exitCodeOf, mark, printError } from "./cli-kit.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Spec §11.8: fixtures go to the catherd checkout's test/fixtures/adapters, next to the contract fixtures
|
|
11
|
+
* they feed, wherever the command runs; null for an installed package, which has no tests to feed.
|
|
12
|
+
*/
|
|
13
|
+
export function defaultOut(): string | null {
|
|
14
|
+
const dir = assetPath("test/fixtures/adapters");
|
|
15
|
+
return existsSync(dir) ? dir : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatCaptured(r: Captured): string {
|
|
19
|
+
return r.status === "captured"
|
|
20
|
+
? `${mark("ok")} ${r.backend} ${r.cliVersion} ${r.name} → ${r.dir}/${r.name}.jsonl (exit ${r.exitCode})`
|
|
21
|
+
: `${mark("skip")} ${r.backend} ${r.name} skipped: ${r.reason}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const captureFixturesCommand = defineCommand({
|
|
25
|
+
meta: {
|
|
26
|
+
name: "capture-fixtures",
|
|
27
|
+
description: "Record one cheap real run per ready backend as sanitized test fixtures",
|
|
28
|
+
},
|
|
29
|
+
args: {
|
|
30
|
+
backend: { type: "string", description: `only this backend (${CAPTURE_BACKENDS.join(", ")})` },
|
|
31
|
+
out: { type: "string", description: "fixture root (default: this checkout's test/fixtures/adapters)" },
|
|
32
|
+
},
|
|
33
|
+
async run({ args }) {
|
|
34
|
+
const refuse = (message: string, fix: string): void => {
|
|
35
|
+
const e = new CatherdError("E_INPUT_INVALID", message, { fix });
|
|
36
|
+
printError(e);
|
|
37
|
+
process.exitCode = exitCodeOf(e);
|
|
38
|
+
};
|
|
39
|
+
const out = args.out ? resolve(args.out) : defaultOut();
|
|
40
|
+
if (!out)
|
|
41
|
+
return refuse(
|
|
42
|
+
"no --out, and this catherd is not a source checkout",
|
|
43
|
+
"catherd capture-fixtures --out <dir>",
|
|
44
|
+
);
|
|
45
|
+
if (args.backend && !CAPTURE_BACKENDS.includes(args.backend))
|
|
46
|
+
return refuse(
|
|
47
|
+
`no capture cases for backend "${args.backend}"`,
|
|
48
|
+
`catherd capture-fixtures --backend ${CAPTURE_BACKENDS.join("|")}`,
|
|
49
|
+
);
|
|
50
|
+
const results = await captureFixtures({
|
|
51
|
+
outDir: out,
|
|
52
|
+
backends: args.backend ? [args.backend] : undefined,
|
|
53
|
+
});
|
|
54
|
+
for (const r of results) console.log(formatCaptured(r));
|
|
55
|
+
process.exitCode = results.some((r) => r.status === "captured") ? 0 : 1;
|
|
56
|
+
},
|
|
57
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { defineCommand } from "citty";
|
|
2
|
+
import { CatherdError, isCatherdError } from "../domain/errors.ts";
|
|
3
|
+
import { ROLES, type Role } from "../domain/roles.ts";
|
|
4
|
+
import { gitToplevel } from "../infra/git.ts";
|
|
5
|
+
import {
|
|
6
|
+
type CatalogModel,
|
|
7
|
+
catalogQuery,
|
|
8
|
+
type Refreshed,
|
|
9
|
+
refreshDiscovery,
|
|
10
|
+
saveTreatLike,
|
|
11
|
+
} from "../services/catalog-service.ts";
|
|
12
|
+
import { exitCodeOf, JSON_ARG, mark, printError } from "./cli-kit.ts";
|
|
13
|
+
|
|
14
|
+
/** One backend's refresh: its model count, else why it listed none, then its fix on a line of its own. */
|
|
15
|
+
export function formatRefreshed(r: Refreshed, plain = false): string {
|
|
16
|
+
if (!r.error) return `${mark("ok", plain)} ${r.backend}: ${r.models} models`;
|
|
17
|
+
return [
|
|
18
|
+
`${mark("warn", plain)} ${r.backend}: ${r.error}${r.fetchedAt ? ` (last listed ${r.fetchedAt})` : ""}`,
|
|
19
|
+
...(r.fix ? [` fix: ${r.fix}`] : []),
|
|
20
|
+
].join("\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatModel(m: CatalogModel): string {
|
|
24
|
+
const scored = m.rungs.filter((r) => r.enabled).length;
|
|
25
|
+
const listed = m.listed === false ? " not offered by this account" : "";
|
|
26
|
+
return `${m.backend}:${m.model} ${scored}/${m.rungs.length} rungs scored roles ${m.roles.join(",") || "none"}${listed}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The repository the command runs in, for per-repository listings; none outside one (global). */
|
|
30
|
+
const hereRepo = async (): Promise<string | undefined> => (await gitToplevel(process.cwd())) ?? undefined;
|
|
31
|
+
|
|
32
|
+
function fail(e: unknown): void {
|
|
33
|
+
if (!isCatherdError(e)) throw e;
|
|
34
|
+
printError(e);
|
|
35
|
+
process.exitCode = exitCodeOf(e);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const refresh = defineCommand({
|
|
39
|
+
meta: { name: "refresh", description: "List every backend's models now" },
|
|
40
|
+
args: JSON_ARG,
|
|
41
|
+
async run({ args }) {
|
|
42
|
+
const r = await refreshDiscovery({ repo: await hereRepo() });
|
|
43
|
+
if (args.json) console.log(JSON.stringify(r, null, 2));
|
|
44
|
+
else for (const x of r) console.log(formatRefreshed(x));
|
|
45
|
+
process.exitCode = r.some((x) => !x.error) ? 0 : 1;
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const list = defineCommand({
|
|
50
|
+
meta: { name: "list", description: "The models catherd can place, with their scored rungs" },
|
|
51
|
+
args: {
|
|
52
|
+
backend: { type: "string", description: "only this backend or billing key" },
|
|
53
|
+
role: { type: "string", description: `only models for this role (${ROLES.join(", ")})` },
|
|
54
|
+
text: { type: "string", description: "only ids containing this" },
|
|
55
|
+
scored: { type: "boolean", description: "only models with a scored rung" },
|
|
56
|
+
...JSON_ARG,
|
|
57
|
+
},
|
|
58
|
+
async run({ args }) {
|
|
59
|
+
if (args.role && !(ROLES as readonly string[]).includes(args.role))
|
|
60
|
+
return fail(
|
|
61
|
+
new CatherdError("E_INPUT_INVALID", `no role "${args.role}"`, {
|
|
62
|
+
fix: `pass --role ${ROLES.join("|")}`,
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
try {
|
|
66
|
+
const r = catalogQuery({
|
|
67
|
+
backend: args.backend,
|
|
68
|
+
role: args.role as Role | undefined,
|
|
69
|
+
text: args.text,
|
|
70
|
+
scoredOnly: args.scored === true,
|
|
71
|
+
limit: 10_000,
|
|
72
|
+
repo: await hereRepo(),
|
|
73
|
+
});
|
|
74
|
+
if (args.json) console.log(JSON.stringify(r, null, 2));
|
|
75
|
+
else for (const m of r.models) console.log(formatModel(m));
|
|
76
|
+
} catch (e) {
|
|
77
|
+
fail(e);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const treatLike = defineCommand({
|
|
83
|
+
meta: { name: "treat-like", description: "Score an unscored rung as a scored one" },
|
|
84
|
+
args: {
|
|
85
|
+
rung: {
|
|
86
|
+
type: "positional",
|
|
87
|
+
required: true,
|
|
88
|
+
description: "the unscored rung, backend:model#effort or model#effort",
|
|
89
|
+
},
|
|
90
|
+
like: { type: "positional", required: true, description: "the scored rung whose scores it borrows" },
|
|
91
|
+
},
|
|
92
|
+
async run({ args }) {
|
|
93
|
+
try {
|
|
94
|
+
const r = await saveTreatLike(args.rung, args.like);
|
|
95
|
+
console.log(`✓ ${r.rung} is treated like ${r.like}`);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
fail(e);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/** Spec §8: `catherd catalog refresh|list|treat-like <rung> <like>`. */
|
|
103
|
+
export const catalogCommand = defineCommand({
|
|
104
|
+
meta: { name: "catalog", description: "The model catalog" },
|
|
105
|
+
subCommands: { refresh, list, "treat-like": treatLike },
|
|
106
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type CatherdError, isCatherdError } from "../domain/errors.ts";
|
|
2
|
+
import { RUN_NOT_FOUND_FIX } from "../services/run-store.ts";
|
|
3
|
+
import { glyph, type State } from "./glyphs.ts";
|
|
4
|
+
|
|
5
|
+
/** Spec §8: 0 ok, 1 error, 2 usage, 3 not ready (doctor), 130 interrupted. */
|
|
6
|
+
export const EXIT = { ok: 0, error: 1, usage: 2, notReady: 3, interrupted: 130 } as const;
|
|
7
|
+
|
|
8
|
+
// oxlint-disable-next-line no-control-regex -- citty colours its messages; the one-line error must not
|
|
9
|
+
const ANSI = /\x1b\[[0-9;]*m/g;
|
|
10
|
+
|
|
11
|
+
/** `text` without colour codes: for output that is piped, or under NO_COLOR. */
|
|
12
|
+
export const stripAnsi = (text: string): string => text.replace(ANSI, "");
|
|
13
|
+
|
|
14
|
+
/** A service's fix worded for the MCP tools, as the CLI says it. */
|
|
15
|
+
const CLI_FIX: Record<string, string> = { [RUN_NOT_FOUND_FIX]: "catherd runs list" };
|
|
16
|
+
|
|
17
|
+
/** Spec §8: one line `error E_CODE: message`, then `fix: …` when there is one. */
|
|
18
|
+
export function printError(e: Pick<CatherdError, "code" | "message" | "fix">): void {
|
|
19
|
+
console.error(`error ${e.code}: ${stripAnsi(e.message).split("\n").join(" ")}`);
|
|
20
|
+
if (e.fix) console.error(`fix: ${CLI_FIX[e.fix] ?? e.fix}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Bad input from the command line is a usage error; any other catherd error is an error. */
|
|
24
|
+
export const exitCodeOf = (e: unknown): number =>
|
|
25
|
+
isCatherdError(e) && e.code === "E_INPUT_INVALID" ? EXIT.usage : EXIT.error;
|
|
26
|
+
|
|
27
|
+
export const printJson = (v: unknown): void => console.log(JSON.stringify(v, null, 2));
|
|
28
|
+
|
|
29
|
+
/** The `--json` flag every read command takes. */
|
|
30
|
+
export const JSON_ARG = { json: { type: "boolean", description: "print JSON" } } as const;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The glyph for a state, as in `✓ ready`: the dashboard's own (spec §9.3), ASCII with `--plain` only;
|
|
34
|
+
* NO_COLOR drops colour, never glyphs. The caller adds the word.
|
|
35
|
+
*/
|
|
36
|
+
export const mark = (state: State, plain = false): string => glyph(state, plain);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { VERSION } from "../infra/version.ts";
|
|
2
|
+
import type { Deps } from "../services/ports.ts";
|
|
3
|
+
import { profileService } from "../services/profile-service.ts";
|
|
4
|
+
import { routingService } from "../services/routing-service.ts";
|
|
5
|
+
|
|
6
|
+
/** The services every entry point (MCP server, CLI commands) runs on. */
|
|
7
|
+
export function defaultDeps(): Deps {
|
|
8
|
+
return {
|
|
9
|
+
profiles: profileService(),
|
|
10
|
+
routing: routingService(),
|
|
11
|
+
version: VERSION,
|
|
12
|
+
pollMs: 250,
|
|
13
|
+
tickMs: Number(process.env.CATHERD_TICK_MS) || 30_000,
|
|
14
|
+
now: Date.now,
|
|
15
|
+
};
|
|
16
|
+
}
|