crewhaus 0.1.3 → 0.1.5

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 CHANGED
@@ -11,7 +11,7 @@ CrewHaus compiles one spec into the shape each situation calls for — a CLI age
11
11
  The fastest path is the self-contained binary — one file, no Bun/Node required:
12
12
 
13
13
  ```bash
14
- brew install crewhaus/tap/crewhaus # macOS / Linux (Homebrew)
14
+ brew tap crewhaus/tap && brew install crewhaus # macOS / Linux (Homebrew)
15
15
  scoop install crewhaus # Windows (Scoop; see repo for the bucket)
16
16
  winget install CrewHaus.CLI # Windows (winget)
17
17
  # Debian / Ubuntu (apt): signed repo at https://crewhaus.github.io/apt
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Model-aware credential checks for `crewhaus doctor`, factored out of the
3
+ * entry file `index.ts` (which runs a top-level argv switch and so cannot be
4
+ * imported by a test without executing the CLI). Side-effect-free and
5
+ * directly unit-testable, mirroring `scope-audit.ts` / `justification-gate.ts`.
6
+ *
7
+ * The old doctor checked ONLY Anthropic credentials and exited 1 when they
8
+ * were absent — for every spec, including openai/gemini/bedrock/local ones
9
+ * that never touch Anthropic. The fix: parse the cwd spec's `agent.model`
10
+ * with the model-router grammar and check the matching provider's env,
11
+ * reporting the others informationally instead of failing on them.
12
+ */
13
+ /** Doctor-level provider id: the router's four providers plus "local"
14
+ * (`local/<m>@<url>` parses to openai WITH a baseUrl — credential-free). */
15
+ export type DoctorProviderId = "anthropic" | "openai" | "gemini" | "bedrock" | "local";
16
+ export type DoctorCredentialCheck = {
17
+ readonly label: string;
18
+ readonly pass: boolean;
19
+ /** warn+pass renders as "~" — informational, never fails doctor. */
20
+ readonly warn?: boolean;
21
+ readonly reason?: string;
22
+ };
23
+ /**
24
+ * Tolerantly extract `agent.model` from a crewhaus.yaml text. Returns
25
+ * undefined when the spec doesn't parse or carries no agent.model (e.g.
26
+ * workflow shapes with per-step models) — the caller then falls back to
27
+ * the legacy Anthropic-first behaviour.
28
+ */
29
+ export declare function extractSpecModel(yamlText: string): string | undefined;
30
+ /**
31
+ * Map a model string to the doctor-level provider whose credentials the
32
+ * run will need. Returns undefined for unparseable strings.
33
+ */
34
+ export declare function selectedProvider(model: string): DoctorProviderId | undefined;
35
+ /**
36
+ * Build the credential section of `crewhaus doctor`.
37
+ *
38
+ * With a parseable spec model: the matching provider gets a real pass/fail
39
+ * check (informational for bedrock/local), and any OTHER provider with env
40
+ * visibly set gets a "~" informational line. Without one (no spec file, or
41
+ * no agent.model): legacy Anthropic-first behaviour, EXCEPT that a missing
42
+ * Anthropic credential downgrades to informational when another provider's
43
+ * env is clearly set (the operator is plainly not on Anthropic).
44
+ */
45
+ export declare function buildCredentialChecks(specModel: string | undefined, env: NodeJS.ProcessEnv): DoctorCredentialCheck[];
@@ -0,0 +1,178 @@
1
+ import { resolveAuth } from "@crewhaus/adapter-anthropic";
2
+ import { parseModelString } from "@crewhaus/model-router";
3
+ import { parseSpec } from "@crewhaus/spec";
4
+ function isSet(env, name) {
5
+ const v = env[name];
6
+ return typeof v === "string" && v !== "";
7
+ }
8
+ /**
9
+ * Tolerantly extract `agent.model` from a crewhaus.yaml text. Returns
10
+ * undefined when the spec doesn't parse or carries no agent.model (e.g.
11
+ * workflow shapes with per-step models) — the caller then falls back to
12
+ * the legacy Anthropic-first behaviour.
13
+ */
14
+ export function extractSpecModel(yamlText) {
15
+ try {
16
+ const spec = parseSpec(yamlText);
17
+ const m = spec.agent?.model;
18
+ return typeof m === "string" && m.length > 0 ? m : undefined;
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ /**
25
+ * Map a model string to the doctor-level provider whose credentials the
26
+ * run will need. Returns undefined for unparseable strings.
27
+ */
28
+ export function selectedProvider(model) {
29
+ try {
30
+ const parsed = parseModelString(model);
31
+ if (parsed.providerId === "openai" && parsed.baseUrl !== undefined)
32
+ return "local";
33
+ return parsed.providerId;
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ function anthropicStatus(env) {
40
+ const auth = resolveAuth(env);
41
+ return auth.mode !== "none"
42
+ ? { satisfied: true, detail: `${auth.mode} credentials found` }
43
+ : {
44
+ satisfied: false,
45
+ detail: "set ANTHROPIC_AUTH_TOKEN (Claude subscription) or ANTHROPIC_API_KEY",
46
+ };
47
+ }
48
+ function openaiStatus(env) {
49
+ if (isSet(env, "OPENAI_API_KEY"))
50
+ return { satisfied: true, detail: "OPENAI_API_KEY set" };
51
+ if (isSet(env, "OPENAI_BASE_URL")) {
52
+ return { satisfied: true, detail: "OPENAI_BASE_URL set (OpenAI-compatible endpoint)" };
53
+ }
54
+ return {
55
+ satisfied: false,
56
+ detail: "set OPENAI_API_KEY (or OPENAI_BASE_URL for an OpenAI-compatible endpoint)",
57
+ };
58
+ }
59
+ function geminiStatus(env) {
60
+ if (isSet(env, "GEMINI_API_KEY"))
61
+ return { satisfied: true, detail: "GEMINI_API_KEY set" };
62
+ if (isSet(env, "GOOGLE_API_KEY"))
63
+ return { satisfied: true, detail: "GOOGLE_API_KEY set" };
64
+ if (isSet(env, "GOOGLE_GENAI_USE_VERTEXAI") || isSet(env, "GOOGLE_CLOUD_PROJECT")) {
65
+ return {
66
+ satisfied: true,
67
+ detail: "Vertex AI env set (GOOGLE_GENAI_USE_VERTEXAI/GOOGLE_CLOUD_PROJECT)",
68
+ };
69
+ }
70
+ return {
71
+ satisfied: false,
72
+ detail: "set GEMINI_API_KEY (or GOOGLE_API_KEY; Vertex users set GOOGLE_GENAI_USE_VERTEXAI + GOOGLE_CLOUD_PROJECT)",
73
+ };
74
+ }
75
+ const BEDROCK_ENV_NAMES = [
76
+ "AWS_BEARER_TOKEN_BEDROCK",
77
+ "AWS_ACCESS_KEY_ID",
78
+ "AWS_PROFILE",
79
+ "AWS_REGION",
80
+ ];
81
+ function bedrockStatus(env) {
82
+ const found = BEDROCK_ENV_NAMES.filter((n) => isSet(env, n));
83
+ return found.length > 0
84
+ ? { satisfied: true, detail: `${found.join(", ")} set` }
85
+ : {
86
+ satisfied: false,
87
+ detail: "no AWS env vars visible (AWS_BEARER_TOKEN_BEDROCK/AWS_ACCESS_KEY_ID/AWS_PROFILE/AWS_REGION) — the SDK default credential chain (IRSA, instance profile, ~/.aws) may still apply",
88
+ };
89
+ }
90
+ function statusFor(provider, env) {
91
+ switch (provider) {
92
+ case "anthropic":
93
+ return anthropicStatus(env);
94
+ case "openai":
95
+ return openaiStatus(env);
96
+ case "gemini":
97
+ return geminiStatus(env);
98
+ case "bedrock":
99
+ return bedrockStatus(env);
100
+ case "local":
101
+ return {
102
+ satisfied: true,
103
+ detail: "local endpoint baked into the model string — no credentials needed",
104
+ };
105
+ }
106
+ }
107
+ const PROVIDER_LABEL = {
108
+ anthropic: "Anthropic credentials",
109
+ openai: "OpenAI credentials",
110
+ gemini: "Gemini credentials",
111
+ bedrock: "Bedrock (AWS) credentials",
112
+ local: "Local endpoint",
113
+ };
114
+ /** Providers whose env check is INFORMATIONAL even when selected: the AWS
115
+ * SDK's default credential chain is authoritative for bedrock (env vars are
116
+ * only one of its sources), and local/ endpoints need no credentials. */
117
+ const INFORMATIONAL_WHEN_SELECTED = new Set(["bedrock", "local"]);
118
+ /** Non-selected providers whose env is set get an informational line so the
119
+ * operator sees what else is configured. */
120
+ const ALL_PROVIDERS = ["anthropic", "openai", "gemini", "bedrock"];
121
+ /**
122
+ * Build the credential section of `crewhaus doctor`.
123
+ *
124
+ * With a parseable spec model: the matching provider gets a real pass/fail
125
+ * check (informational for bedrock/local), and any OTHER provider with env
126
+ * visibly set gets a "~" informational line. Without one (no spec file, or
127
+ * no agent.model): legacy Anthropic-first behaviour, EXCEPT that a missing
128
+ * Anthropic credential downgrades to informational when another provider's
129
+ * env is clearly set (the operator is plainly not on Anthropic).
130
+ */
131
+ export function buildCredentialChecks(specModel, env) {
132
+ const provider = specModel !== undefined ? selectedProvider(specModel) : undefined;
133
+ const checks = [];
134
+ if (provider !== undefined && specModel !== undefined) {
135
+ const status = statusFor(provider, env);
136
+ const informational = INFORMATIONAL_WHEN_SELECTED.has(provider);
137
+ checks.push({
138
+ label: `${PROVIDER_LABEL[provider]} (model ${specModel})`,
139
+ pass: informational ? true : status.satisfied,
140
+ ...(informational && !status.satisfied ? { warn: true } : {}),
141
+ ...(status.satisfied && !informational ? {} : { reason: status.detail }),
142
+ });
143
+ for (const other of ALL_PROVIDERS) {
144
+ if (other === provider)
145
+ continue;
146
+ const otherStatus = statusFor(other, env);
147
+ if (otherStatus.satisfied) {
148
+ checks.push({
149
+ label: `${PROVIDER_LABEL[other]} (not required for this spec)`,
150
+ pass: true,
151
+ warn: true,
152
+ reason: otherStatus.detail,
153
+ });
154
+ }
155
+ }
156
+ return checks;
157
+ }
158
+ // No spec model — legacy Anthropic-first behaviour with one softening:
159
+ // when Anthropic creds are absent but another provider's env is clearly
160
+ // set, report informationally instead of hard-failing.
161
+ const anthropic = anthropicStatus(env);
162
+ if (anthropic.satisfied) {
163
+ checks.push({ label: "Anthropic credentials", pass: true });
164
+ return checks;
165
+ }
166
+ const others = ALL_PROVIDERS.filter((p) => p !== "anthropic").filter((p) => statusFor(p, env).satisfied);
167
+ if (others.length > 0) {
168
+ checks.push({
169
+ label: "Anthropic credentials",
170
+ pass: true,
171
+ warn: true,
172
+ reason: `not set, but ${others.map((p) => PROVIDER_LABEL[p]).join(" + ")} detected — claude-* models will not run until ANTHROPIC_AUTH_TOKEN/ANTHROPIC_API_KEY is set`,
173
+ });
174
+ return checks;
175
+ }
176
+ checks.push({ label: "Anthropic credentials", pass: false, reason: anthropic.detail });
177
+ return checks;
178
+ }
@@ -1,5 +1,4 @@
1
1
  import type { EgressMatcher } from "@crewhaus/egress-classifier";
2
-
3
2
  /**
4
3
  * FR-006 — Pillar 3 sink-side egress-matcher selection for `crewhaus run`,
5
4
  * factored out of the entry file `index.ts` (which runs a top-level argv
@@ -28,11 +27,7 @@ import type { EgressMatcher } from "@crewhaus/egress-classifier";
28
27
  * claude judge. The default path (`"substring"` / unset) returns `undefined`
29
28
  * and never touches the semantic package, so it pulls in no embedding code.
30
29
  */
31
-
32
30
  export type EgressMatcherChoice = "substring" | "semantic";
33
-
34
- const VALID_EGRESS_MATCHER_CHOICES: ReadonlyArray<EgressMatcherChoice> = ["substring", "semantic"];
35
-
36
31
  /**
37
32
  * Default embedder model for the semantic matcher when neither
38
33
  * `--egress-embedder` nor `CREWHAUS_EGRESS_EMBEDDER` is supplied. A real
@@ -40,24 +35,15 @@ const VALID_EGRESS_MATCHER_CHOICES: ReadonlyArray<EgressMatcherChoice> = ["subst
40
35
  * throws and `SemanticEgressMatcher` degrades to the substring tripwire
41
36
  * (its built-in safe fallback) rather than dropping the egress check.
42
37
  */
43
- export const DEFAULT_EGRESS_EMBEDDER_MODEL = "openai/text-embedding-3-small";
44
-
38
+ export declare const DEFAULT_EGRESS_EMBEDDER_MODEL = "openai/text-embedding-3-small";
45
39
  /** Thrown by `resolveEgressMatcherChoice` on an unrecognised choice. The CLI
46
40
  * entry file catches it and routes the message through `die()`; tests assert
47
41
  * on `.choice` / `.message` without the process exiting. */
48
- export class InvalidEgressMatcherChoiceError extends Error {
49
- override readonly name = "InvalidEgressMatcherChoiceError";
50
- constructor(readonly choice: string) {
51
- super(
52
- `invalid --egress-matcher "${choice}" — allowed: ${VALID_EGRESS_MATCHER_CHOICES.join(", ")}`,
53
- );
54
- }
42
+ export declare class InvalidEgressMatcherChoiceError extends Error {
43
+ readonly choice: string;
44
+ readonly name = "InvalidEgressMatcherChoiceError";
45
+ constructor(choice: string);
55
46
  }
56
-
57
- function isEgressMatcherChoice(s: string): s is EgressMatcherChoice {
58
- return (VALID_EGRESS_MATCHER_CHOICES as ReadonlyArray<string>).includes(s);
59
- }
60
-
61
47
  /**
62
48
  * Pure resolution of the matcher choice from the flag value + the lowered IR
63
49
  * selector. `flagValue` is the raw `--egress-matcher` value (or undefined when
@@ -65,28 +51,19 @@ function isEgressMatcherChoice(s: string): s is EgressMatcherChoice {
65
51
  * Throws `InvalidEgressMatcherChoiceError` for any value outside the allowed
66
52
  * set so the caller can `die()` with a friendly message.
67
53
  */
68
- export function resolveEgressMatcherChoice(
69
- flagValue: string | undefined,
70
- irEgressMatcher: EgressMatcherChoice | undefined,
71
- ): EgressMatcherChoice {
72
- const raw = flagValue ?? irEgressMatcher ?? "substring";
73
- if (!isEgressMatcherChoice(raw)) throw new InvalidEgressMatcherChoiceError(raw);
74
- return raw;
75
- }
76
-
54
+ export declare function resolveEgressMatcherChoice(flagValue: string | undefined, irEgressMatcher: EgressMatcherChoice | undefined): EgressMatcherChoice;
77
55
  export type CreateEgressMatcherOptions = {
78
- /**
79
- * Embedder model string for the `"semantic"` choice — the
80
- * `@crewhaus/embedder` prefix grammar (`openai/…`, `voyage/…`, `mock/…`).
81
- * Resolution precedence at the call site: `--egress-embedder` flag >
82
- * `CREWHAUS_EGRESS_EMBEDDER` env > `DEFAULT_EGRESS_EMBEDDER_MODEL`. Tests
83
- * pass `mock/…` for a deterministic, network-free embedder.
84
- */
85
- readonly embedderModel: string;
86
- /** Optional cosine threshold passed through to `SemanticEgressMatcher`. */
87
- readonly threshold?: number;
56
+ /**
57
+ * Embedder model string for the `"semantic"` choice — the
58
+ * `@crewhaus/embedder` prefix grammar (`openai/…`, `voyage/…`, `mock/…`).
59
+ * Resolution precedence at the call site: `--egress-embedder` flag >
60
+ * `CREWHAUS_EGRESS_EMBEDDER` env > `DEFAULT_EGRESS_EMBEDDER_MODEL`. Tests
61
+ * pass `mock/…` for a deterministic, network-free embedder.
62
+ */
63
+ readonly embedderModel: string;
64
+ /** Optional cosine threshold passed through to `SemanticEgressMatcher`. */
65
+ readonly threshold?: number;
88
66
  };
89
-
90
67
  /**
91
68
  * Build the `EgressMatcher` for a resolved choice. Returns `undefined` for
92
69
  * `"substring"` so the caller omits `egressMatcher` from `runChatLoop`
@@ -97,16 +74,4 @@ export type CreateEgressMatcherOptions = {
97
74
  * with the injected embedder, so the model-backed code only loads when
98
75
  * actually selected.
99
76
  */
100
- export async function createEgressMatcher(
101
- choice: EgressMatcherChoice,
102
- opts: CreateEgressMatcherOptions,
103
- ): Promise<EgressMatcher | undefined> {
104
- if (choice === "substring") return undefined;
105
- const { createEmbedder } = await import("@crewhaus/embedder");
106
- const { createSemanticEgressMatcher } = await import("@crewhaus/egress-matcher-semantic");
107
- const embedder = createEmbedder({ model: opts.embedderModel });
108
- return createSemanticEgressMatcher({
109
- embedder,
110
- ...(opts.threshold !== undefined ? { threshold: opts.threshold } : {}),
111
- });
112
- }
77
+ export declare function createEgressMatcher(choice: EgressMatcherChoice, opts: CreateEgressMatcherOptions): Promise<EgressMatcher | undefined>;
@@ -0,0 +1,57 @@
1
+ const VALID_EGRESS_MATCHER_CHOICES = ["substring", "semantic"];
2
+ /**
3
+ * Default embedder model for the semantic matcher when neither
4
+ * `--egress-embedder` nor `CREWHAUS_EGRESS_EMBEDDER` is supplied. A real
5
+ * provider is the production intent; if its API key is unset the embedder
6
+ * throws and `SemanticEgressMatcher` degrades to the substring tripwire
7
+ * (its built-in safe fallback) rather than dropping the egress check.
8
+ */
9
+ export const DEFAULT_EGRESS_EMBEDDER_MODEL = "openai/text-embedding-3-small";
10
+ /** Thrown by `resolveEgressMatcherChoice` on an unrecognised choice. The CLI
11
+ * entry file catches it and routes the message through `die()`; tests assert
12
+ * on `.choice` / `.message` without the process exiting. */
13
+ export class InvalidEgressMatcherChoiceError extends Error {
14
+ choice;
15
+ name = "InvalidEgressMatcherChoiceError";
16
+ constructor(choice) {
17
+ super(`invalid --egress-matcher "${choice}" — allowed: ${VALID_EGRESS_MATCHER_CHOICES.join(", ")}`);
18
+ this.choice = choice;
19
+ }
20
+ }
21
+ function isEgressMatcherChoice(s) {
22
+ return VALID_EGRESS_MATCHER_CHOICES.includes(s);
23
+ }
24
+ /**
25
+ * Pure resolution of the matcher choice from the flag value + the lowered IR
26
+ * selector. `flagValue` is the raw `--egress-matcher` value (or undefined when
27
+ * the flag is absent); `irEgressMatcher` is `ir.security?.egressMatcher`.
28
+ * Throws `InvalidEgressMatcherChoiceError` for any value outside the allowed
29
+ * set so the caller can `die()` with a friendly message.
30
+ */
31
+ export function resolveEgressMatcherChoice(flagValue, irEgressMatcher) {
32
+ const raw = flagValue ?? irEgressMatcher ?? "substring";
33
+ if (!isEgressMatcherChoice(raw))
34
+ throw new InvalidEgressMatcherChoiceError(raw);
35
+ return raw;
36
+ }
37
+ /**
38
+ * Build the `EgressMatcher` for a resolved choice. Returns `undefined` for
39
+ * `"substring"` so the caller omits `egressMatcher` from `runChatLoop`
40
+ * (runtime-core then uses the built-in `substringMatcher`, the
41
+ * behavior-preserving default — the default egress path pulls in no embedding
42
+ * dependency). For `"semantic"` it lazily imports `@crewhaus/embedder` and
43
+ * `@crewhaus/egress-matcher-semantic` and constructs a `SemanticEgressMatcher`
44
+ * with the injected embedder, so the model-backed code only loads when
45
+ * actually selected.
46
+ */
47
+ export async function createEgressMatcher(choice, opts) {
48
+ if (choice === "substring")
49
+ return undefined;
50
+ const { createEmbedder } = await import("@crewhaus/embedder");
51
+ const { createSemanticEgressMatcher } = await import("@crewhaus/egress-matcher-semantic");
52
+ const embedder = createEmbedder({ model: opts.embedderModel });
53
+ return createSemanticEgressMatcher({
54
+ embedder,
55
+ ...(opts.threshold !== undefined ? { threshold: opts.threshold } : {}),
56
+ });
57
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ export {};