crewhaus 0.1.8 → 0.2.1

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.
Files changed (138) hide show
  1. package/README.md +10 -3
  2. package/dist/advice-apply.d.ts +182 -0
  3. package/dist/advice-apply.js +286 -0
  4. package/dist/advise-rules.d.ts +348 -0
  5. package/dist/advise-rules.js +905 -0
  6. package/dist/alert-sink.d.ts +48 -0
  7. package/dist/alert-sink.js +86 -0
  8. package/dist/approval-gate.d.ts +127 -0
  9. package/dist/approval-gate.js +254 -0
  10. package/dist/audit-verify.d.ts +69 -0
  11. package/dist/audit-verify.js +97 -0
  12. package/dist/autodistill.d.ts +113 -0
  13. package/dist/autodistill.js +256 -0
  14. package/dist/channel-provision.d.ts +360 -0
  15. package/dist/channel-provision.js +881 -0
  16. package/dist/ci-scaffold.d.ts +31 -0
  17. package/dist/ci-scaffold.js +343 -0
  18. package/dist/compile-check.d.ts +90 -0
  19. package/dist/compile-check.js +285 -0
  20. package/dist/compliance-schedule.d.ts +35 -0
  21. package/dist/compliance-schedule.js +36 -0
  22. package/dist/context-pressure.d.ts +80 -0
  23. package/dist/context-pressure.js +166 -0
  24. package/dist/dataset-mine.d.ts +173 -0
  25. package/dist/dataset-mine.js +404 -0
  26. package/dist/datasets.d.ts +124 -0
  27. package/dist/datasets.js +260 -0
  28. package/dist/deploy-canary.d.ts +83 -0
  29. package/dist/deploy-canary.js +87 -0
  30. package/dist/doctor-checks.d.ts +33 -0
  31. package/dist/doctor-checks.js +92 -0
  32. package/dist/doctor-detect.d.ts +108 -0
  33. package/dist/doctor-detect.js +214 -0
  34. package/dist/doctor-fix.d.ts +81 -0
  35. package/dist/doctor-fix.js +164 -0
  36. package/dist/egress-triage.d.ts +121 -0
  37. package/dist/egress-triage.js +261 -0
  38. package/dist/eval-bridge.d.ts +114 -0
  39. package/dist/eval-bridge.js +158 -0
  40. package/dist/eval-coverage.d.ts +140 -0
  41. package/dist/eval-coverage.js +428 -0
  42. package/dist/eval-history.d.ts +48 -0
  43. package/dist/eval-history.js +157 -0
  44. package/dist/eval-matrix.d.ts +80 -0
  45. package/dist/eval-matrix.js +182 -0
  46. package/dist/eval-sentinel.d.ts +65 -0
  47. package/dist/eval-sentinel.js +132 -0
  48. package/dist/faq.d.ts +68 -0
  49. package/dist/faq.js +168 -0
  50. package/dist/feedback.d.ts +9 -2
  51. package/dist/feedback.js +17 -7
  52. package/dist/fewshot.d.ts +83 -0
  53. package/dist/fewshot.js +158 -0
  54. package/dist/fleet.d.ts +207 -0
  55. package/dist/fleet.js +488 -0
  56. package/dist/flywheel.d.ts +193 -0
  57. package/dist/flywheel.js +519 -0
  58. package/dist/graders-suggest.d.ts +186 -0
  59. package/dist/graders-suggest.js +658 -0
  60. package/dist/incident.d.ts +99 -0
  61. package/dist/incident.js +217 -0
  62. package/dist/index.d.ts +9 -1
  63. package/dist/index.js +11664 -963
  64. package/dist/init-interactive.d.ts +105 -0
  65. package/dist/init-interactive.js +208 -0
  66. package/dist/intents.d.ts +105 -0
  67. package/dist/intents.js +292 -0
  68. package/dist/judge-calibrate.d.ts +137 -0
  69. package/dist/judge-calibrate.js +247 -0
  70. package/dist/justification-calibrate.d.ts +150 -0
  71. package/dist/justification-calibrate.js +262 -0
  72. package/dist/justification-gate.d.ts +27 -6
  73. package/dist/justification-gate.js +30 -6
  74. package/dist/knowledge-sync.d.ts +179 -0
  75. package/dist/knowledge-sync.js +551 -0
  76. package/dist/lessons.d.ts +87 -0
  77. package/dist/lessons.js +207 -0
  78. package/dist/lint.d.ts +127 -0
  79. package/dist/lint.js +226 -0
  80. package/dist/loadtest.d.ts +114 -0
  81. package/dist/loadtest.js +196 -0
  82. package/dist/marketplace-cli.d.ts +110 -0
  83. package/dist/marketplace-cli.js +250 -0
  84. package/dist/mcp-doctor.d.ts +121 -0
  85. package/dist/mcp-doctor.js +249 -0
  86. package/dist/model-scan.d.ts +116 -0
  87. package/dist/model-scan.js +226 -0
  88. package/dist/onchain-tune.d.ts +164 -0
  89. package/dist/onchain-tune.js +346 -0
  90. package/dist/permissions-suggest.d.ts +126 -0
  91. package/dist/permissions-suggest.js +333 -0
  92. package/dist/pii-tune.d.ts +107 -0
  93. package/dist/pii-tune.js +122 -0
  94. package/dist/propose.d.ts +117 -0
  95. package/dist/propose.js +184 -0
  96. package/dist/refresh-goldens.d.ts +82 -0
  97. package/dist/refresh-goldens.js +221 -0
  98. package/dist/regression-pin.d.ts +160 -0
  99. package/dist/regression-pin.js +281 -0
  100. package/dist/retention.d.ts +193 -0
  101. package/dist/retention.js +607 -0
  102. package/dist/retire.d.ts +118 -0
  103. package/dist/retire.js +291 -0
  104. package/dist/right-size.d.ts +100 -0
  105. package/dist/right-size.js +123 -0
  106. package/dist/route.d.ts +19 -0
  107. package/dist/route.js +90 -0
  108. package/dist/scaffold-evals.d.ts +138 -0
  109. package/dist/scaffold-evals.js +410 -0
  110. package/dist/scope-audit-drift.d.ts +139 -0
  111. package/dist/scope-audit-drift.js +260 -0
  112. package/dist/security-corpus.d.ts +237 -0
  113. package/dist/security-corpus.js +516 -0
  114. package/dist/security-digest.d.ts +174 -0
  115. package/dist/security-digest.js +656 -0
  116. package/dist/sessions-index.d.ts +27 -0
  117. package/dist/sessions-index.js +51 -0
  118. package/dist/slo-doctor.d.ts +67 -0
  119. package/dist/slo-doctor.js +119 -0
  120. package/dist/slo-sink.d.ts +96 -0
  121. package/dist/slo-sink.js +107 -0
  122. package/dist/spec-changelog.d.ts +102 -0
  123. package/dist/spec-changelog.js +237 -0
  124. package/dist/state-backup.d.ts +223 -0
  125. package/dist/state-backup.js +648 -0
  126. package/dist/tools-cli.d.ts +170 -0
  127. package/dist/tools-cli.js +300 -0
  128. package/dist/triage.d.ts +202 -0
  129. package/dist/triage.js +403 -0
  130. package/dist/upgrade.d.ts +57 -0
  131. package/dist/upgrade.js +113 -0
  132. package/dist/version.d.ts +6 -0
  133. package/dist/version.js +27 -0
  134. package/dist/voice-eval.d.ts +138 -0
  135. package/dist/voice-eval.js +309 -0
  136. package/dist/watch.d.ts +58 -0
  137. package/dist/watch.js +97 -0
  138. package/package.json +90 -65
@@ -1,6 +1,8 @@
1
1
  import { resolveAuth } from "@crewhaus/adapter-anthropic";
2
+ import { lower } from "@crewhaus/compiler";
2
3
  import { parseModelString } from "@crewhaus/model-router";
3
4
  import { parseSpec } from "@crewhaus/spec";
5
+ import { CHANNEL_PLATFORMS, platformSecretRefs } from "./channel-provision";
4
6
  function isSet(env, name) {
5
7
  const v = env[name];
6
8
  return typeof v === "string" && v !== "";
@@ -104,6 +106,21 @@ function statusFor(provider, env) {
104
106
  };
105
107
  }
106
108
  }
109
+ /**
110
+ * Item 13 — online/offline mode selection for `crewhaus scaffold-evals`:
111
+ * true when the provider `model` routes to has visibly satisfied credentials
112
+ * (shares `statusFor` with the doctor checks, so the two never disagree).
113
+ * Unparseable model strings return false — the scaffold then stays on its
114
+ * deterministic template path instead of attempting a doomed call. Note the
115
+ * bedrock check is env-visible only (the AWS SDK's default chain may still
116
+ * work); scaffold prefers the safe offline mode in that ambiguity.
117
+ */
118
+ export function providerCredentialsSatisfied(model, env) {
119
+ const provider = selectedProvider(model);
120
+ if (provider === undefined)
121
+ return false;
122
+ return statusFor(provider, env).satisfied;
123
+ }
107
124
  const PROVIDER_LABEL = {
108
125
  anthropic: "Anthropic credentials",
109
126
  openai: "OpenAI credentials",
@@ -111,6 +128,25 @@ const PROVIDER_LABEL = {
111
128
  bedrock: "Bedrock (AWS) credentials",
112
129
  local: "Local endpoint",
113
130
  };
131
+ /**
132
+ * Item 40 — the canonical env var(s) `doctor --fix` stubs into `.env` for a
133
+ * provider whose credential check is failing. One representative name per
134
+ * provider (the first the status check reads); bedrock/local need no stub
135
+ * (bedrock uses the SDK default chain; local bakes the URL into the model
136
+ * string), so they map to []. Kept next to the status tables so the stub set
137
+ * can't drift from what the checks actually probe.
138
+ */
139
+ const PROVIDER_ENV_STUBS = {
140
+ anthropic: ["ANTHROPIC_API_KEY"],
141
+ openai: ["OPENAI_API_KEY"],
142
+ gemini: ["GEMINI_API_KEY"],
143
+ bedrock: [],
144
+ local: [],
145
+ };
146
+ /** Env var names `doctor --fix` should stub for a selected provider. */
147
+ export function providerEnvStubs(provider) {
148
+ return PROVIDER_ENV_STUBS[provider];
149
+ }
114
150
  /** Providers whose env check is INFORMATIONAL even when selected: the AWS
115
151
  * SDK's default credential chain is authoritative for bedrock (env vars are
116
152
  * only one of its sources), and local/ endpoints need no credentials. */
@@ -176,3 +212,59 @@ export function buildCredentialChecks(specModel, env) {
176
212
  checks.push({ label: "Anthropic credentials", pass: false, reason: anthropic.detail });
177
213
  return checks;
178
214
  }
215
+ /**
216
+ * Item 61 — tolerantly extract the lowered channels block from a
217
+ * crewhaus.yaml text. Returns undefined when the spec doesn't parse/lower or
218
+ * is not a channel target, mirroring {@link extractSpecModel}'s tolerance —
219
+ * doctor must never fail because the cwd spec is a different shape. Lowering
220
+ * (not raw parsing) is deliberate: `lower()` is where `$VAR_NAME` strings
221
+ * become env-refs, so the check shares the compiled daemon's exact env
222
+ * semantics instead of re-implementing the regex.
223
+ */
224
+ export function extractChannelIr(yamlText) {
225
+ try {
226
+ const ir = lower(parseSpec(yamlText));
227
+ return ir.target === "channel" ? ir.channels : undefined;
228
+ }
229
+ catch {
230
+ return undefined;
231
+ }
232
+ }
233
+ /**
234
+ * Item 61 — channel-target doctor checks: one per configured platform
235
+ * (slack/telegram/discord), asserting that every secret env-ref the compiled
236
+ * daemon requires at boot (target-channel-bot's startup check exits 2 on the
237
+ * same set) is actually set. Returns [] for non-channel specs, so the check
238
+ * only appears when the cwd spec is a channel target. Offline by design —
239
+ * live platform probes (granted scopes, webhook state) belong to
240
+ * `crewhaus channel verify`, which the failure reason points at.
241
+ */
242
+ export function buildChannelEnvChecks(yamlText, env) {
243
+ const channels = extractChannelIr(yamlText);
244
+ if (channels === undefined)
245
+ return [];
246
+ const checks = [];
247
+ for (const platform of CHANNEL_PLATFORMS) {
248
+ const refs = platformSecretRefs(platform, channels);
249
+ if (refs.length === 0)
250
+ continue;
251
+ const envNames = refs.flatMap((r) => (r.ref.kind === "env" ? [r.ref.name] : []));
252
+ const missing = envNames.filter((name) => !isSet(env, name)).map((name) => `$${name}`);
253
+ const label = envNames.length > 0
254
+ ? `${PLATFORM_TITLE[platform]} channel env (${envNames.join(", ")})`
255
+ : `${PLATFORM_TITLE[platform]} channel env (inline literals)`;
256
+ checks.push(missing.length === 0
257
+ ? { label, pass: true }
258
+ : {
259
+ label,
260
+ pass: false,
261
+ reason: `unset: ${missing.join(", ")} — the compiled daemon exits at boot without them; run \`crewhaus channel verify\` for live platform probes`,
262
+ });
263
+ }
264
+ return checks;
265
+ }
266
+ const PLATFORM_TITLE = {
267
+ slack: "Slack",
268
+ telegram: "Telegram",
269
+ discord: "Discord",
270
+ };
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Item 40 — `crewhaus doctor --detect`. The read-only INVENTORY half of the
3
+ * "doctor that detects and fixes" pair (the mutating half is `doctor-fix.ts`).
4
+ *
5
+ * Nothing in the CLI reads a local Ollama/vLLM endpoint, a `.mcp.json`, or a
6
+ * Claude Desktop config today — `doctor` only checks whether the cwd spec's
7
+ * provider env is set. `--detect` builds an inventory of what's ACTUALLY
8
+ * available so `init --interactive` (#39) can prefill model/mcp choices and an
9
+ * operator can see, in one command, which providers/models/servers they can
10
+ * reach without editing a spec first.
11
+ *
12
+ * Side-effect-free and directly unit-testable, mirroring `doctor-checks.ts` /
13
+ * `scope-audit.ts`: every ambient dependency (env, fs reads, the localhost
14
+ * HTTP probe) is an INJECTED seam so tests drive the inventory from fixtures
15
+ * with no real network or filesystem.
16
+ */
17
+ /** A reachable model provider inferred purely from visible env vars. */
18
+ export type DetectedProvider = {
19
+ readonly id: "anthropic" | "openai" | "gemini" | "bedrock";
20
+ readonly label: string;
21
+ /** The env var(s) that made this provider look reachable. */
22
+ readonly via: readonly string[];
23
+ };
24
+ /** An MCP server discovered in a `.mcp.json` or Claude Desktop config. */
25
+ export type DetectedMcpServer = {
26
+ readonly name: string;
27
+ readonly source: string;
28
+ readonly transport: "stdio" | "sse" | "unknown";
29
+ /** stdio command (best-effort) or sse url, for the operator's eyes. */
30
+ readonly detail?: string;
31
+ };
32
+ /** A local OpenAI-compatible endpoint (Ollama / vLLM / LM Studio, …). */
33
+ export type DetectedLocalEndpoint = {
34
+ readonly baseUrl: string;
35
+ readonly reachable: boolean;
36
+ /** Model ids the endpoint advertises at `/models`, when reachable. */
37
+ readonly models: readonly string[];
38
+ /** Populated when the probe failed — why it is not reachable. */
39
+ readonly error?: string;
40
+ };
41
+ export type DetectInventory = {
42
+ readonly providers: readonly DetectedProvider[];
43
+ readonly local: DetectedLocalEndpoint;
44
+ readonly mcpServers: readonly DetectedMcpServer[];
45
+ };
46
+ /**
47
+ * Infer which model providers are reachable from visible env vars alone. This
48
+ * shares the credential-name tables with `doctor-checks.ts` but answers a
49
+ * different question: not "does the cwd spec's provider have creds" but "which
50
+ * providers COULD I use right now". Bedrock's default credential chain (IRSA /
51
+ * instance profile / ~/.aws) is not inspected here — only the env surface — so
52
+ * a machine on an instance profile with no AWS env vars reports no bedrock,
53
+ * which is the honest offline answer.
54
+ */
55
+ export declare function detectProviders(env: NodeJS.ProcessEnv): DetectedProvider[];
56
+ /**
57
+ * The base URL the local probe hits: `OPENAI_BASE_URL` when set (a vLLM /
58
+ * LM Studio endpoint the operator already pointed at), else the router's
59
+ * `LOCAL_DEFAULT_BASE_URL` (`http://localhost:11434/v1`, the Ollama default
60
+ * that `local/<m>@<url>` bakes in when no `@url` is given).
61
+ */
62
+ export declare function localBaseUrl(env: NodeJS.ProcessEnv): string;
63
+ /** Injected HTTP seam so the localhost probe is testable without a network. */
64
+ export type FetchLike = (url: string, init?: {
65
+ signal?: AbortSignal;
66
+ }) => Promise<{
67
+ ok: boolean;
68
+ status: number;
69
+ json(): Promise<unknown>;
70
+ }>;
71
+ /**
72
+ * Probe an OpenAI-compatible endpoint's `/models` list. Ollama, vLLM, and
73
+ * LM Studio all serve `GET <base>/models` → `{ data: [{ id }, …] }`. Never
74
+ * throws: an unreachable endpoint (connection refused, timeout, non-200,
75
+ * malformed body) yields `{ reachable: false, models: [], error }` so
76
+ * `--detect` degrades to a one-line "not running" note rather than failing.
77
+ */
78
+ export declare function probeLocalEndpoint(baseUrl: string, fetchImpl: FetchLike): Promise<DetectedLocalEndpoint>;
79
+ /**
80
+ * Parse an MCP server map out of a `.mcp.json` (Claude Code / crewhaus format:
81
+ * `{ mcpServers: { name: { command | url, … } } }`) or a Claude Desktop config
82
+ * (identical `mcpServers` shape). Tolerant: a malformed file, a missing
83
+ * `mcpServers` key, or a non-object entry contributes nothing rather than
84
+ * throwing — `--detect` must never fail because a config file is junk.
85
+ */
86
+ export declare function parseMcpConfig(text: string, source: string): DetectedMcpServer[];
87
+ /**
88
+ * Assemble the full inventory. `readConfig(path)` returns the file text or
89
+ * `undefined` when absent/unreadable (injected fs seam); `configPaths` is the
90
+ * ordered list of MCP config locations to inspect (cwd `.mcp.json` first, then
91
+ * the platform Claude Desktop config). Deterministic and pure given its seams.
92
+ */
93
+ export declare function buildInventory(opts: {
94
+ readonly env: NodeJS.ProcessEnv;
95
+ readonly fetchImpl: FetchLike;
96
+ readonly readConfig: (path: string) => string | undefined;
97
+ readonly configPaths: readonly string[];
98
+ /** Skip the localhost probe (e.g. `--detect --no-probe`); default probes. */
99
+ readonly skipProbe?: boolean;
100
+ }): Promise<DetectInventory>;
101
+ /** Render the inventory as the human-readable `--detect` report block. */
102
+ export declare function formatInventory(inv: DetectInventory): string;
103
+ /**
104
+ * The platform-specific default Claude Desktop config path. macOS and Windows
105
+ * only — Linux has no official Claude Desktop build, so it returns undefined
106
+ * and the inventory just skips that source.
107
+ */
108
+ export declare function claudeDesktopConfigPath(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string | undefined;
@@ -0,0 +1,214 @@
1
+ import { LOCAL_DEFAULT_BASE_URL } from "@crewhaus/model-router";
2
+ function isSet(env, name) {
3
+ const v = env[name];
4
+ return typeof v === "string" && v !== "";
5
+ }
6
+ /**
7
+ * Infer which model providers are reachable from visible env vars alone. This
8
+ * shares the credential-name tables with `doctor-checks.ts` but answers a
9
+ * different question: not "does the cwd spec's provider have creds" but "which
10
+ * providers COULD I use right now". Bedrock's default credential chain (IRSA /
11
+ * instance profile / ~/.aws) is not inspected here — only the env surface — so
12
+ * a machine on an instance profile with no AWS env vars reports no bedrock,
13
+ * which is the honest offline answer.
14
+ */
15
+ export function detectProviders(env) {
16
+ const out = [];
17
+ const anthropicVia = ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"].filter((n) => isSet(env, n));
18
+ if (anthropicVia.length > 0) {
19
+ out.push({ id: "anthropic", label: "Anthropic", via: anthropicVia });
20
+ }
21
+ const openaiVia = ["OPENAI_API_KEY", "OPENAI_BASE_URL"].filter((n) => isSet(env, n));
22
+ if (openaiVia.length > 0) {
23
+ out.push({ id: "openai", label: "OpenAI (or OpenAI-compatible)", via: openaiVia });
24
+ }
25
+ const geminiVia = [
26
+ "GEMINI_API_KEY",
27
+ "GOOGLE_API_KEY",
28
+ "GOOGLE_GENAI_USE_VERTEXAI",
29
+ "GOOGLE_CLOUD_PROJECT",
30
+ ].filter((n) => isSet(env, n));
31
+ if (geminiVia.length > 0) {
32
+ out.push({ id: "gemini", label: "Gemini", via: geminiVia });
33
+ }
34
+ const bedrockVia = [
35
+ "AWS_BEARER_TOKEN_BEDROCK",
36
+ "AWS_ACCESS_KEY_ID",
37
+ "AWS_PROFILE",
38
+ "AWS_REGION",
39
+ ].filter((n) => isSet(env, n));
40
+ if (bedrockVia.length > 0) {
41
+ out.push({ id: "bedrock", label: "Bedrock (AWS)", via: bedrockVia });
42
+ }
43
+ return out;
44
+ }
45
+ /**
46
+ * The base URL the local probe hits: `OPENAI_BASE_URL` when set (a vLLM /
47
+ * LM Studio endpoint the operator already pointed at), else the router's
48
+ * `LOCAL_DEFAULT_BASE_URL` (`http://localhost:11434/v1`, the Ollama default
49
+ * that `local/<m>@<url>` bakes in when no `@url` is given).
50
+ */
51
+ export function localBaseUrl(env) {
52
+ const override = env["OPENAI_BASE_URL"];
53
+ if (typeof override === "string" && override !== "")
54
+ return override;
55
+ return LOCAL_DEFAULT_BASE_URL;
56
+ }
57
+ /**
58
+ * Probe an OpenAI-compatible endpoint's `/models` list. Ollama, vLLM, and
59
+ * LM Studio all serve `GET <base>/models` → `{ data: [{ id }, …] }`. Never
60
+ * throws: an unreachable endpoint (connection refused, timeout, non-200,
61
+ * malformed body) yields `{ reachable: false, models: [], error }` so
62
+ * `--detect` degrades to a one-line "not running" note rather than failing.
63
+ */
64
+ export async function probeLocalEndpoint(baseUrl, fetchImpl) {
65
+ const url = `${baseUrl.replace(/\/+$/, "")}/models`;
66
+ try {
67
+ const res = await fetchImpl(url);
68
+ if (!res.ok) {
69
+ return { baseUrl, reachable: false, models: [], error: `HTTP ${res.status}` };
70
+ }
71
+ const body = (await res.json());
72
+ const models = Array.isArray(body?.data) === true
73
+ ? body.data
74
+ .map((m) => m?.id)
75
+ .filter((id) => typeof id === "string" && id.length > 0)
76
+ : [];
77
+ return { baseUrl, reachable: true, models };
78
+ }
79
+ catch (err) {
80
+ return { baseUrl, reachable: false, models: [], error: err.message };
81
+ }
82
+ }
83
+ /**
84
+ * Parse an MCP server map out of a `.mcp.json` (Claude Code / crewhaus format:
85
+ * `{ mcpServers: { name: { command | url, … } } }`) or a Claude Desktop config
86
+ * (identical `mcpServers` shape). Tolerant: a malformed file, a missing
87
+ * `mcpServers` key, or a non-object entry contributes nothing rather than
88
+ * throwing — `--detect` must never fail because a config file is junk.
89
+ */
90
+ export function parseMcpConfig(text, source) {
91
+ let root;
92
+ try {
93
+ root = JSON.parse(text);
94
+ }
95
+ catch {
96
+ return [];
97
+ }
98
+ if (root === null || typeof root !== "object")
99
+ return [];
100
+ const servers = root.mcpServers;
101
+ if (servers === null || typeof servers !== "object" || Array.isArray(servers))
102
+ return [];
103
+ const out = [];
104
+ for (const [name, raw] of Object.entries(servers)) {
105
+ if (raw === null || typeof raw !== "object") {
106
+ out.push({ name, source, transport: "unknown" });
107
+ continue;
108
+ }
109
+ const cfg = raw;
110
+ if (typeof cfg.url === "string" && cfg.url !== "") {
111
+ out.push({ name, source, transport: "sse", detail: cfg.url });
112
+ }
113
+ else if (typeof cfg.command === "string" && cfg.command !== "") {
114
+ const args = Array.isArray(cfg.args) ? cfg.args.filter((a) => typeof a === "string") : [];
115
+ const detail = [cfg.command, ...args].join(" ");
116
+ out.push({ name, source, transport: "stdio", detail });
117
+ }
118
+ else {
119
+ out.push({ name, source, transport: "unknown" });
120
+ }
121
+ }
122
+ return out;
123
+ }
124
+ /**
125
+ * Assemble the full inventory. `readConfig(path)` returns the file text or
126
+ * `undefined` when absent/unreadable (injected fs seam); `configPaths` is the
127
+ * ordered list of MCP config locations to inspect (cwd `.mcp.json` first, then
128
+ * the platform Claude Desktop config). Deterministic and pure given its seams.
129
+ */
130
+ export async function buildInventory(opts) {
131
+ const providers = detectProviders(opts.env);
132
+ const baseUrl = localBaseUrl(opts.env);
133
+ const local = opts.skipProbe === true
134
+ ? { baseUrl, reachable: false, models: [], error: "probe skipped" }
135
+ : await probeLocalEndpoint(baseUrl, opts.fetchImpl);
136
+ const mcpServers = [];
137
+ const seen = new Set();
138
+ for (const path of opts.configPaths) {
139
+ const text = opts.readConfig(path);
140
+ if (text === undefined)
141
+ continue;
142
+ for (const server of parseMcpConfig(text, path)) {
143
+ // De-dup by (source-independent) name so the same server declared in
144
+ // both .mcp.json and Claude Desktop shows once, first source winning.
145
+ if (seen.has(server.name))
146
+ continue;
147
+ seen.add(server.name);
148
+ mcpServers.push(server);
149
+ }
150
+ }
151
+ return { providers, local, mcpServers };
152
+ }
153
+ /** Render the inventory as the human-readable `--detect` report block. */
154
+ export function formatInventory(inv) {
155
+ const lines = [];
156
+ lines.push("inventory (what's reachable right now):");
157
+ lines.push("");
158
+ lines.push(" model providers (from env):");
159
+ if (inv.providers.length === 0) {
160
+ lines.push(" (none — no provider env vars visible)");
161
+ }
162
+ else {
163
+ for (const p of inv.providers) {
164
+ lines.push(` ✓ ${p.label} — via ${p.via.join(", ")}`);
165
+ }
166
+ }
167
+ lines.push("");
168
+ lines.push(` local endpoint (${inv.local.baseUrl}):`);
169
+ if (inv.local.reachable) {
170
+ if (inv.local.models.length === 0) {
171
+ lines.push(" ✓ reachable, but advertises no models");
172
+ }
173
+ else {
174
+ lines.push(` ✓ reachable — ${inv.local.models.length} model(s):`);
175
+ for (const m of inv.local.models)
176
+ lines.push(` ${m}`);
177
+ }
178
+ }
179
+ else {
180
+ lines.push(` ✗ not reachable (${inv.local.error ?? "unknown"})`);
181
+ }
182
+ lines.push("");
183
+ lines.push(" MCP servers (from .mcp.json / Claude Desktop config):");
184
+ if (inv.mcpServers.length === 0) {
185
+ lines.push(" (none found)");
186
+ }
187
+ else {
188
+ for (const s of inv.mcpServers) {
189
+ const detail = s.detail !== undefined ? ` — ${s.detail}` : "";
190
+ lines.push(` • ${s.name} [${s.transport}]${detail} (${s.source})`);
191
+ }
192
+ }
193
+ return `${lines.join("\n")}\n`;
194
+ }
195
+ /**
196
+ * The platform-specific default Claude Desktop config path. macOS and Windows
197
+ * only — Linux has no official Claude Desktop build, so it returns undefined
198
+ * and the inventory just skips that source.
199
+ */
200
+ export function claudeDesktopConfigPath(platform, env) {
201
+ if (platform === "darwin") {
202
+ const home = env["HOME"];
203
+ if (home === undefined || home === "")
204
+ return undefined;
205
+ return `${home}/Library/Application Support/Claude/claude_desktop_config.json`;
206
+ }
207
+ if (platform === "win32") {
208
+ const appData = env["APPDATA"];
209
+ if (appData === undefined || appData === "")
210
+ return undefined;
211
+ return `${appData}\\Claude\\claude_desktop_config.json`;
212
+ }
213
+ return undefined;
214
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Item 40 — `crewhaus doctor --fix`. The MUTATING half of the "doctor that
3
+ * detects and fixes" pair. Every doctor check today prints a remediation
4
+ * string and exits; `--fix` attaches a mechanical fixer to the checks where a
5
+ * SAFE deterministic fix exists, and leaves the rest advisory.
6
+ *
7
+ * Dry-run is the DEFAULT: without `--fix`, doctor plans the fixes and prints a
8
+ * diff of what it WOULD do; `--fix` opts into applying them. This module is
9
+ * side-effect-free — it computes {@link FixAction}s over injected fs seams and
10
+ * a per-fixer planner; the CLI wrapper in `index.ts` executes the plan (or
11
+ * just prints it) and owns all real I/O. That keeps every fix path
12
+ * unit-testable from fixtures, mirroring `scope-audit.ts` / `doctor-checks.ts`.
13
+ */
14
+ /** One planned change. `apply()` performs the real mutation; the CLI calls it
15
+ * only when `--fix` is passed. `diff` is the human-readable preview printed in
16
+ * both dry-run and applied modes. */
17
+ export type FixAction = {
18
+ /** Stable id of the check this remediates (for grouping/output). */
19
+ readonly check: string;
20
+ /** One-line summary of the change. */
21
+ readonly summary: string;
22
+ /** Multi-line diff preview (created/edited content). */
23
+ readonly diff: string;
24
+ /** Perform the mutation. Throws on failure; the CLI reports it. */
25
+ apply(): void;
26
+ };
27
+ /** Injected filesystem seam — every fixer reads/writes through this so the
28
+ * planner is pure and testable. Mirrors the node:fs subset the fixers need. */
29
+ export type FixFs = {
30
+ exists(path: string): boolean;
31
+ read(path: string): string;
32
+ write(path: string, content: string): void;
33
+ mkdirp(path: string): void;
34
+ };
35
+ /**
36
+ * Fixer 1 — scaffold a missing `crewhaus.yaml`. The check "crewhaus.yaml in
37
+ * cwd" is advisory today (`run \`crewhaus init\``); `--fix` writes the SAME
38
+ * minimal cli spec `runInit` scaffolds, so a fresh directory becomes runnable
39
+ * in one command. `specName` is the cwd basename (matching `init`'s default).
40
+ * Returns undefined when the spec already exists (nothing to fix).
41
+ */
42
+ export declare function planScaffoldSpec(opts: {
43
+ readonly fs: FixFs;
44
+ readonly specPath: string;
45
+ readonly specName: string;
46
+ }): FixAction | undefined;
47
+ /**
48
+ * Fixer 2 — create missing `.crewhaus` state dirs. The runtime resolves the
49
+ * session store and audit/eval/spec sub-stores under `.crewhaus/`; a missing
50
+ * root is harmless (the runtime mkdirs on demand) but pre-creating it is a
51
+ * safe, idempotent convenience the operator can preview. Returns undefined
52
+ * when the dir already exists.
53
+ */
54
+ export declare function planCrewhausDirs(opts: {
55
+ readonly fs: FixFs;
56
+ readonly crewhausDir: string;
57
+ }): FixAction | undefined;
58
+ export declare function planScopeFix(opts: {
59
+ readonly fs: FixFs;
60
+ readonly specPath: string;
61
+ readonly specTarget: string;
62
+ readonly toolName: string;
63
+ }): FixAction | undefined;
64
+ /**
65
+ * Fixer 4 — append commented env stubs to `.env` for the providers a spec
66
+ * needs but the environment lacks. Commented (so nothing is accidentally set
67
+ * to a placeholder) and idempotent: a var already present in `.env` (set OR
68
+ * commented) is skipped. Returns undefined when there is nothing to append.
69
+ *
70
+ * `neededVars` is the ordered list of env var names to stub (the caller derives
71
+ * these from the failing credential checks); each becomes a `# NAME=` line
72
+ * under a generated header, appended to the existing `.env` (created if
73
+ * absent).
74
+ */
75
+ export declare function planEnvStubs(opts: {
76
+ readonly fs: FixFs;
77
+ readonly envPath: string;
78
+ readonly neededVars: readonly string[];
79
+ }): FixAction | undefined;
80
+ /** Format a plan (dry-run or applied) as the `--fix` report block. */
81
+ export declare function formatFixPlan(actions: readonly FixAction[], applied: boolean): string;
@@ -0,0 +1,164 @@
1
+ import { applySpecPatch } from "@crewhaus/spec-patch";
2
+ /** Render an "added file" diff block (all `+` lines). */
3
+ function addedDiff(path, content) {
4
+ const body = content
5
+ .split("\n")
6
+ .map((l) => `+ ${l}`)
7
+ .join("\n");
8
+ return `--- /dev/null\n+++ ${path}\n${body}`;
9
+ }
10
+ /**
11
+ * Fixer 1 — scaffold a missing `crewhaus.yaml`. The check "crewhaus.yaml in
12
+ * cwd" is advisory today (`run \`crewhaus init\``); `--fix` writes the SAME
13
+ * minimal cli spec `runInit` scaffolds, so a fresh directory becomes runnable
14
+ * in one command. `specName` is the cwd basename (matching `init`'s default).
15
+ * Returns undefined when the spec already exists (nothing to fix).
16
+ */
17
+ export function planScaffoldSpec(opts) {
18
+ if (opts.fs.exists(opts.specPath))
19
+ return undefined;
20
+ const content = `name: ${opts.specName}
21
+ target: cli
22
+ agent:
23
+ model: claude-opus-4-7
24
+ instructions: |
25
+ You are a helpful assistant. Replace these instructions with your
26
+ agent's actual behavior, persona, and constraints.
27
+ `;
28
+ return {
29
+ check: "crewhaus.yaml in cwd",
30
+ summary: `create ${opts.specPath} (minimal cli spec)`,
31
+ diff: addedDiff(opts.specPath, content),
32
+ apply: () => opts.fs.write(opts.specPath, content),
33
+ };
34
+ }
35
+ /**
36
+ * Fixer 2 — create missing `.crewhaus` state dirs. The runtime resolves the
37
+ * session store and audit/eval/spec sub-stores under `.crewhaus/`; a missing
38
+ * root is harmless (the runtime mkdirs on demand) but pre-creating it is a
39
+ * safe, idempotent convenience the operator can preview. Returns undefined
40
+ * when the dir already exists.
41
+ */
42
+ export function planCrewhausDirs(opts) {
43
+ if (opts.fs.exists(opts.crewhausDir))
44
+ return undefined;
45
+ return {
46
+ check: ".crewhaus state dir",
47
+ summary: `create ${opts.crewhausDir}/`,
48
+ diff: `--- /dev/null\n+++ ${opts.crewhausDir}/ (directory)`,
49
+ apply: () => opts.fs.mkdirp(opts.crewhausDir),
50
+ };
51
+ }
52
+ /**
53
+ * Fixer 3 — mark an outward-reaching tool `scope: external` via a mechanical
54
+ * CST spec-patch. `auditToolScopes` (surfaced through `doctor
55
+ * --philosophy-alignment` / `compile --strict`) flags outward sinks left at a
56
+ * non-external scope; the safe mechanical fix is to set their scope, which
57
+ * `applySpecPatch` writes back preserving comments + key order.
58
+ *
59
+ * NOTE: the built-in `tools:` list is an array of NAME strings — there is no
60
+ * per-tool `scope` field to patch there (scope is a property of the registered
61
+ * tool, not the spec reference). This fixer therefore targets a `tool_config`
62
+ * entry: it sets `tool_config.<name>.scope: "external"`, the documented place a
63
+ * spec asserts an operator-vetted scope. The patch is validated by
64
+ * `applySpecPatch` (re-parses through `parseSpec`), so an unpatchable finding
65
+ * surfaces as a thrown error the CLI reports rather than a silent no-op.
66
+ *
67
+ * Returns undefined for findings whose tool name is not a plain identifier
68
+ * (e.g. an `mcp__*` dynamic sink) — those need human vetting, not a mechanical
69
+ * scope stamp, so they stay advisory.
70
+ */
71
+ const PLAIN_TOOL_NAME_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
72
+ export function planScopeFix(opts) {
73
+ // `mcp__server__tool` dynamic sinks match the identifier charset but are
74
+ // definitionally external and must be vetted by a human, not stamped
75
+ // mechanically — leave them advisory.
76
+ if (opts.toolName.startsWith("mcp__"))
77
+ return undefined;
78
+ if (!PLAIN_TOOL_NAME_RE.test(opts.toolName))
79
+ return undefined;
80
+ const path = ["tool_config", opts.toolName, "scope"];
81
+ const summary = `set tool_config.${opts.toolName}.scope: "external" in ${opts.specPath}`;
82
+ return {
83
+ check: "tool scope audit",
84
+ summary,
85
+ diff: `~ ${opts.specPath}: + tool_config.${opts.toolName}.scope: "external"`,
86
+ apply: () => {
87
+ const yamlText = opts.fs.read(opts.specPath);
88
+ const result = applySpecPatchAddOrReplace(yamlText, {
89
+ target: opts.specTarget,
90
+ path,
91
+ value: "external",
92
+ rationale: "doctor --fix: mark outward tool external (scope audit)",
93
+ });
94
+ opts.fs.write(opts.specPath, result);
95
+ },
96
+ };
97
+ }
98
+ /** Apply a spec patch as "add", retrying as "replace" when the path exists.
99
+ * Both go through `applySpecPatch`, so the result is always re-validated. */
100
+ function applySpecPatchAddOrReplace(yamlText, patch) {
101
+ try {
102
+ return applySpecPatch(yamlText, { ...patch, op: "add" }).yaml;
103
+ }
104
+ catch {
105
+ return applySpecPatch(yamlText, { ...patch, op: "replace" }).yaml;
106
+ }
107
+ }
108
+ /**
109
+ * Fixer 4 — append commented env stubs to `.env` for the providers a spec
110
+ * needs but the environment lacks. Commented (so nothing is accidentally set
111
+ * to a placeholder) and idempotent: a var already present in `.env` (set OR
112
+ * commented) is skipped. Returns undefined when there is nothing to append.
113
+ *
114
+ * `neededVars` is the ordered list of env var names to stub (the caller derives
115
+ * these from the failing credential checks); each becomes a `# NAME=` line
116
+ * under a generated header, appended to the existing `.env` (created if
117
+ * absent).
118
+ */
119
+ export function planEnvStubs(opts) {
120
+ if (opts.neededVars.length === 0)
121
+ return undefined;
122
+ const existing = opts.fs.exists(opts.envPath) ? opts.fs.read(opts.envPath) : "";
123
+ // A var is "present" if any line names it as a key, set or commented:
124
+ // NAME=... or # NAME=...
125
+ const present = new Set();
126
+ for (const rawLine of existing.split("\n")) {
127
+ const m = /^\s*#?\s*([A-Z_][A-Z0-9_]*)\s*=/.exec(rawLine);
128
+ if (m?.[1] !== undefined)
129
+ present.add(m[1]);
130
+ }
131
+ const toAdd = opts.neededVars.filter((v) => !present.has(v));
132
+ if (toAdd.length === 0)
133
+ return undefined;
134
+ const header = "# added by `crewhaus doctor --fix` — fill in and uncomment";
135
+ const stubLines = [header, ...toAdd.map((v) => `# ${v}=`)];
136
+ const stubBlock = stubLines.join("\n");
137
+ const needsLeadingNewline = existing !== "" && !existing.endsWith("\n");
138
+ const appended = `${existing}${needsLeadingNewline ? "\n" : ""}${stubBlock}\n`;
139
+ return {
140
+ check: "provider credentials",
141
+ summary: `append ${toAdd.length} commented env stub(s) to ${opts.envPath}`,
142
+ diff: [`~ ${opts.envPath}:`, ...stubLines.map((l) => `+ ${l}`)].join("\n"),
143
+ apply: () => opts.fs.write(opts.envPath, appended),
144
+ };
145
+ }
146
+ /** Format a plan (dry-run or applied) as the `--fix` report block. */
147
+ export function formatFixPlan(actions, applied) {
148
+ if (actions.length === 0) {
149
+ return applied
150
+ ? "doctor --fix: nothing to fix — all mechanical checks already clean.\n"
151
+ : "doctor --fix (dry-run): nothing to fix — all mechanical checks already clean.\n";
152
+ }
153
+ const lines = [];
154
+ lines.push(applied
155
+ ? `doctor --fix: applied ${actions.length} fix(es):`
156
+ : `doctor --fix (dry-run): ${actions.length} fix(es) available — re-run with --fix to apply:`);
157
+ for (const a of actions) {
158
+ lines.push("");
159
+ lines.push(` [${a.check}] ${a.summary}`);
160
+ for (const dl of a.diff.split("\n"))
161
+ lines.push(` ${dl}`);
162
+ }
163
+ return `${lines.join("\n")}\n`;
164
+ }