@gr8ful/spf 0.8.3 → 0.9.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 CHANGED
@@ -7,6 +7,8 @@ A software factory does one thing: it gives you more leverage on your prompt. Ho
7
7
 
8
8
  Everyone can get an agent to write code once. Almost nobody gets the same result twice. This fixes that by moving the control plane out of the prompt and into TypeScript. A chain script owns sequencing, retries, and acceptance. Agents work inside named phases. Typed JSON envelopes carry context across the seams. Every event streams into SQLite while it is still happening. **Agent proposes, code disposes.**
9
9
 
10
+ > **Beta.** SPF is under active development — config schema, chain/CLI surface, and trace/event shapes may change between releases without a deprecation period. Pin a version if you depend on any of these staying stable.
11
+
10
12
  ---
11
13
 
12
14
  ## Install
@@ -124,17 +126,34 @@ Set `coding_agent: claude_code` on any agent (or in `defaults`) to run it on you
124
126
 
125
127
  #### Proxy or wrapper launchers
126
128
 
127
- To route the `claude` command through a wrapper, proxy server, or launcher (e.g., [Ollama](https://ollama.com)), set the `SPF_CLAUDE_CMD` environment variable before running `spf`. Space-separated command chains are supported:
129
+ To route the `claude` command through a wrapper, proxy server, or launcher (e.g., [Ollama](https://ollama.com)), set `SPF_CLAUDE_CMD` as a declarative `env:` entry in `spf.config.yaml`, since it's a launcher choice, not a secret (see [Declarative env vars](#declarative-env-vars) below), or as a plain shell export for a one-off override. Space-separated command chains are supported:
130
+
131
+ ```yaml
132
+ env:
133
+ SPF_CLAUDE_CMD: "ollama launch claude --model {model}"
134
+ ```
128
135
 
129
136
  ```bash
130
- # Route through Ollama's launcher
131
- export SPF_CLAUDE_CMD="ollama launch claude --model granite4.1:8b"
132
137
  spf build "your prompt"
133
138
  ```
134
139
 
135
140
  `ollama launch <cmd>` uses cobra flag parsing, which treats anything typed after it as its own flags unless a literal `--` says otherwise — without one, `claude`'s own flags (`-p`, `--json-schema`, ...) fail with `unknown shorthand flag: 'p' in -p` before `claude` is ever reached. `agent_cc.ts` detects exactly this `ollama launch ...` shape and inserts that `--` automatically, so you never add the separator by hand for this specific launcher.
136
141
 
137
- That `--` alone is not enough to reach `claude`, though: `ollama launch` also needs its OWN `--model <tag>` flag (a tag from `ollama list`), typed BEFORE the auto-inserted `--`, whenever it runs headless — which it always does under SPF, since SPF spawns with piped stdio. Without it, `ollama launch` falls back to an interactive model picker that can never run, and fails one step later than the `--` problem, with `model selection requires an interactive terminal; use --model to run in headless mode`. A `--model` typed after the `--` doesn't help — at that point it belongs to `claude`, not to `ollama launch`. So `SPF_CLAUDE_CMD` must include `ollama launch`'s `--model` yourself, exactly as written above; `spf doctor` hard-fails if it's missing.
142
+ That `--` alone is not enough to reach `claude`, though: `ollama launch` also needs its OWN `--model <tag>` flag, typed BEFORE the auto-inserted `--`, whenever it runs headless — which it always does under SPF, since SPF spawns with piped stdio. Without it, `ollama launch` falls back to an interactive model picker that can never run, and fails one step later than the `--` problem, with `model selection requires an interactive terminal; use --model to run in headless mode`. A `--model` typed after the `--` doesn't help — at that point it belongs to `claude`, not to `ollama launch`. So `SPF_CLAUDE_CMD` must include `ollama launch`'s `--model` yourself; `spf doctor` hard-fails if it's missing.
143
+
144
+ **Per-agent model choice:** a literal `{model}` token in `SPF_CLAUDE_CMD` is substituted with each call's own agent-config `model:` field before spawning. Since `ollama launch`'s `--model` (its OWN flag) is what actually pins the model serving the whole launched process — not `claude`'s own `--model`, appended after the `--` — a FIXED tag in `SPF_CLAUDE_CMD` means every `claude_code` agent runs on that one model regardless of what each agent's `model:` says. Using `{model}` instead makes it genuinely per-agent:
145
+
146
+ ```yaml
147
+ env:
148
+ SPF_CLAUDE_CMD: "ollama launch claude --model {model}"
149
+ agents:
150
+ - name: planner
151
+ coding_agent: claude_code
152
+ model: kimi-k2.7-code:cloud
153
+ - name: builder
154
+ coding_agent: claude_code
155
+ model: qwen3-coder:cloud
156
+ ```
138
157
 
139
158
  ```bash
140
159
  # Or use a custom wrapper script
@@ -144,6 +163,18 @@ spf build "your prompt"
144
163
 
145
164
  The command/launcher must support the full Claude Code CLI interface: `-p` for prompt, `--json-schema`, `--model`, `--session-id`/`--resume`, `--output-format stream-json`, and all other flags `agent_cc` uses. When unset, `SPF_CLAUDE_CMD` defaults to `claude` (resolved from `PATH` normally). A cmdSpec that already contains its own literal `--` is left completely alone — `agent_cc.ts` never inserts a second one.
146
165
 
166
+ #### Declarative env vars
167
+
168
+ `spf.config.yaml` supports an `env:` block for settings like `SPF_CLAUDE_CMD` that are configuration, not secrets — so they can be committed as plain, reviewable text instead of hidden in an out-of-band shell export:
169
+
170
+ ```yaml
171
+ env:
172
+ SPF_CLAUDE_CMD: "ollama launch claude --model {model}"
173
+ SOME_TOKEN: "${SOME_SECRET_FROM_DOTENV}"
174
+ ```
175
+
176
+ Applied to `process.env` before any command runs. A plain value commits as literal text; `${VAR}` interpolates from whatever's already in `process.env` at that point (the real shell, or `.env`, both of which load first) — so a real secret can live in `.env` (gitignored) and be referenced here without ever being written into this file. An already-set `process.env` value always wins over `env:`'s — `spf.config.yaml` supplies the default, a real export still overrides it per machine/session. A `${VAR}` reference to something that's genuinely unset fails loudly at startup, naming the missing variable, rather than silently interpolating to an empty string.
177
+
147
178
  ### flue + local Ollama
148
179
 
149
180
  Point the default `flue` backend at a local Ollama server the same way you'd pick any other Flue provider — the model string's own prefix, `ollama/<tag>` (whatever `ollama list` shows on your machine) instead of `openai/...`/`anthropic/...`:
@@ -99,11 +99,25 @@ SPF-specific plumbing, no `provider:` config section to write.
99
99
 
100
100
  There's a second, different way to reach Ollama through `claude_code`: route
101
101
  the `claude` command itself through `ollama launch claude` via
102
- `SPF_CLAUDE_CMD`, instead of pointing `ANTHROPIC_BASE_URL` at Ollama's
103
- OpenAI-compatible surface. That launcher form needs its own `--model <tag>`
104
- flag (from `ollama list`) in the command string, since SPF always spawns
105
- headless see README.md's "Proxy or wrapper launchers" section for the
106
- full command and why `--model` is mandatory there, not optional.
102
+ `SPF_CLAUDE_CMD` as a declarative `env:` entry in `spf.config.yaml`
103
+ (non-secret; see `config.md`'s "Declarative env vars" section), not a raw
104
+ shell export. That launcher form needs its own `--model <tag>` flag in the
105
+ command string, since SPF always spawns headless use the literal token
106
+ `{model}` there (substituted with each call's own agent `model:` field)
107
+ rather than one fixed tag, or every `claude_code` agent ends up sharing the
108
+ same model regardless of its own `model:`:
109
+
110
+ ```yaml
111
+ env:
112
+ SPF_CLAUDE_CMD: "ollama launch claude --model {model}"
113
+ agents:
114
+ - name: builder
115
+ coding_agent: claude_code
116
+ model: kimi-k2.7-code:cloud
117
+ ```
118
+
119
+ See README.md's "Proxy or wrapper launchers" section for the full command
120
+ and why `--model` is mandatory there, not optional.
107
121
 
108
122
  ## Retune tools
109
123
 
@@ -12,7 +12,7 @@ always shows the resolved, merged result for the repo you're in.
12
12
  1. The packaged built-in default (`assets/defaults/spf.config.yaml` inside
13
13
  the installed CLI).
14
14
  2. `.spf/spf.config.yaml` in the target repo, if present — merged on top,
15
- field by field (`defaults`/`observability`/`quality`/`watch`/`notifications`/`review`/`tiering`
15
+ field by field (`env`/`defaults`/`observability`/`quality`/`watch`/`notifications`/`review`/`tiering`
16
16
  merge key-by-key — `notifications.channels` replaces wholesale, same as
17
17
  `quality.checks` and `tiering.tiers`/`tiering.roles`; `agents` merges by
18
18
  `name`: a matching name patches that entry, a new name appends).
@@ -20,7 +20,9 @@ always shows the resolved, merged result for the repo you're in.
20
20
  underneath it.
21
21
 
22
22
  `spf init` seeds step 2 — on a TTY, via an interview that asks the fields
23
- below and appends whatever secrets they imply to `.env`; non-interactively
23
+ below and appends whatever secrets they imply to `.env` (a non-secret,
24
+ declarative setting like `SPF_CLAUDE_CMD` goes into this file's own `env:`
25
+ block instead — see "Declarative env vars" below); non-interactively
24
26
  (`--yes`, `--template <name>`, or no TTY) it writes a commented starter
25
27
  instead. Omitting `.spf/spf.config.yaml` entirely means running off pure
26
28
  built-ins, which is a fully supported, valid state.
@@ -497,28 +499,60 @@ change made inside that backend's own module, not config — see
497
499
 
498
500
  ## Pointing `claude_code` at Ollama
499
501
 
500
- No config section for this — it's an environment-variable recipe, since
501
- `agent_cc.ts` passes the operator's environment straight through to the
502
- `claude` subprocess, exactly like every other env var. Set
503
- `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` (local or cloud Ollama) before
504
- running `spf`; see `roster.md`'s "Coding agent backends" section for the
505
- exact commands. `spf doctor` probes `ANTHROPIC_BASE_URL` (informational — a
506
- down endpoint or wrong path is reported, never a hard failure) and flags a
507
- base URL that already ends in `/v1` as a likely double-path mistake, since
502
+ Set `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` (local or cloud Ollama)
503
+ before running `spf`; see `roster.md`'s "Coding agent backends" section for
504
+ the exact commands. `spf doctor` probes `ANTHROPIC_BASE_URL` (informational
505
+ a down endpoint or wrong path is reported, never a hard failure) and flags
506
+ a base URL that already ends in `/v1` as a likely double-path mistake, since
508
507
  the `claude` CLI appends `/v1/messages` itself.
509
508
 
510
- Routing through `SPF_CLAUDE_CMD="ollama launch claude --model granite4.1:8b"`
511
- instead needs an explicit `--` before `claude`'s own flags (cobra flag
512
- parsing otherwise consumes them as `ollama launch`'s own) `agent_cc.ts`
513
- detects this exact `ollama launch ...` token shape and inserts that
514
- separator automatically, so you never add it by hand. The `--model` before
515
- that separator is NOT optional, though: it's `ollama launch`'s own flag, and
516
- it's mandatory in headless mode (SPF always pipes stdio, so the interactive
517
- model picker `ollama launch` falls back to without it can never run) — a
518
- `--model` typed after the `--` belongs to `claude`, not to `ollama launch`,
519
- and doesn't help. `spf doctor` hard-fails a `SPF_CLAUDE_CMD` missing it. See
520
- README.md's "Proxy or wrapper launchers" section for the full explanation.
509
+ Routing through `SPF_CLAUDE_CMD` instead e.g.
510
+ `env: { SPF_CLAUDE_CMD: "ollama launch claude --model {model}" }` in
511
+ `spf.config.yaml` (declarative, not a secret see "Declarative env vars"
512
+ below; a plain shell export also works and still wins) — needs an explicit
513
+ `--` before `claude`'s own flags (cobra flag parsing otherwise consumes them
514
+ as `ollama launch`'s own) `agent_cc.ts` detects this exact
515
+ `ollama launch ...` token shape and inserts that separator automatically, so
516
+ you never add it by hand. The `--model` before that separator is NOT
517
+ optional, though: it's `ollama launch`'s own flag, and it's mandatory in
518
+ headless mode (SPF always pipes stdio, so the interactive model picker
519
+ `ollama launch` falls back to without it can never run) — a `--model` typed
520
+ after the `--` belongs to `claude`, not to `ollama launch`, and doesn't help.
521
+ `spf doctor` hard-fails a `SPF_CLAUDE_CMD` missing it. A literal `{model}`
522
+ token anywhere in `SPF_CLAUDE_CMD` is substituted with the calling agent's
523
+ own `model:` field before spawning — since `ollama launch`'s `--model` is
524
+ what actually pins the model for the whole launched process, a FIXED tag
525
+ there means every `claude_code` agent shares one model regardless of its own
526
+ `model:`; `{model}` is what makes per-agent model choice real under this
527
+ launcher. See README.md's "Proxy or wrapper launchers" section for the full
528
+ explanation.
521
529
 
522
530
  For `flue` (the default backend) pointed at Ollama instead of `claude_code`
523
531
  — i.e. `model: ollama/<tag>` — see "Model resolution" above and README.md's
524
532
  "flue + local Ollama" section.
533
+
534
+ ## Declarative env vars
535
+
536
+ A top-level `env:` map in `spf.config.yaml` sets `process.env` defaults for
537
+ settings that are configuration, not secrets — `SPF_CLAUDE_CMD` above is the
538
+ motivating case. Applied once, in `cli/index.ts`'s `main()`, right after
539
+ `.env` loads and before any command runs. Merge is key-by-key (like
540
+ `defaults`/`watch`), so a `.spf/spf.config.yaml` override can add one key
541
+ without repeating the rest.
542
+
543
+ ```yaml
544
+ env:
545
+ SPF_CLAUDE_CMD: "ollama launch claude --model {model}"
546
+ SOME_TOKEN: "${SOME_SECRET_FROM_DOTENV}"
547
+ ```
548
+
549
+ A plain value commits as literal text. `${VAR}` interpolates from whatever's
550
+ already in `process.env` at that point — the real shell, or `.env`, both of
551
+ which load first — so a genuine secret can stay in `.env` (gitignored) and
552
+ be referenced here without ever being written into this (committed) file.
553
+ Precedence: an already-set `process.env` value always wins over `env:`'s —
554
+ this block supplies a default, never forces an override, the same way
555
+ `process.loadEnvFile()` itself never overwrites an already-set var. A
556
+ `${VAR}` reference to something genuinely unset fails loudly at startup,
557
+ naming the missing variable — see `agents.ts`'s `interpolateEnvValue`/
558
+ `applyConfigEnv`.
package/dist/cli/index.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * common case. Everything else is a named subcommand.
6
6
  */
7
7
  import path from "node:path";
8
+ import * as agents from "../core/agents.js";
8
9
  import * as agentCc from "../core/agent_cc.js";
9
10
  import * as agentFlue from "../core/agent_flue.js";
10
11
  import * as notify from "../core/notify/notifier.js";
@@ -57,6 +58,11 @@ function findCwdFlag(argv) {
57
58
  const idx = argv.indexOf("--cwd");
58
59
  return idx !== -1 ? argv[idx + 1] : undefined;
59
60
  }
61
+ /** Same idea, for `--config` — needed before dispatch too, to resolve `cfg.env` early. */
62
+ function findConfigFlag(argv) {
63
+ const idx = argv.indexOf("--config");
64
+ return idx !== -1 ? argv[idx + 1] : undefined;
65
+ }
60
66
  /**
61
67
  * The moment someone types a chain name that doesn't resolve is the highest-
62
68
  * traffic place a broken `.spf/chains/*.yaml` file is ever discovered — and,
@@ -104,6 +110,20 @@ export async function main() {
104
110
  catch {
105
111
  // no .env there — fine, nothing to load
106
112
  }
113
+ // `cfg.env` (spf.config.yaml's declarative env-var defaults — see
114
+ // data_types.ts's SFConfigSchema doc comment) has to land in process.env
115
+ // BEFORE any command reads it (SPF_CLAUDE_CMD, a provider key, ...), and
116
+ // .env/the real shell (just loaded above) must still win over it. Best-
117
+ // effort only: a config that fails to load here (missing, invalid yaml) is
118
+ // reported properly by whatever the actual command's own `agents.loadConfig`
119
+ // call does moments later — this pass exists purely to apply `env`, early.
120
+ try {
121
+ const resolution = paths.resolveConfigPaths(anchor, findConfigFlag(rest));
122
+ agents.applyConfigEnv(agents.loadConfig(resolution.paths).env);
123
+ }
124
+ catch {
125
+ // reported properly by the real command dispatch below, if it matters there
126
+ }
107
127
  // Repo-local chains as DATA: a `.spf/chains/*.yaml` file names existing
108
128
  // step factories, it never imports repo code — SPF's own dispatcher stays
109
129
  // the one thing that ever executes. Registered here, before the command
@@ -79,6 +79,11 @@ export async function runInterview(asker, ctx) {
79
79
  const defaults = {};
80
80
  const agentOverrides = [];
81
81
  const env = {};
82
+ // Declarative, non-secret env vars — written to spf.config.yaml's `env:`
83
+ // (committable) rather than `.env` (gitignored, and read once from the
84
+ // shell rather than declared). `SPF_CLAUDE_CMD` is the one case so far:
85
+ // it names a launcher/wrapper command, not a credential.
86
+ const configEnv = {};
82
87
  const envExampleKeys = [];
83
88
  const notes = [];
84
89
  // ── 1. coding agent ────────────────────────────────────────────────────────
@@ -92,9 +97,9 @@ export async function runInterview(asker, ctx) {
92
97
  if (!ctx.claudeOnPath) {
93
98
  asker.note("warning: `claude` was not found on PATH — install it (or set a launch command below) before running spf.");
94
99
  }
95
- const launchCommand = await asker.text('Launch command for the `claude` CLI — e.g. "ollama launch claude" to route through a wrapper (SPF_CLAUDE_CMD)', { default: "claude" });
100
+ const launchCommand = await asker.text('Launch command for the `claude` CLI — e.g. "ollama launch claude --model {model}" to route through a wrapper, with {model} substituted per-agent (writes env.SPF_CLAUDE_CMD to spf.config.yaml)', { default: "claude" });
96
101
  if (launchCommand !== "claude")
97
- env["SPF_CLAUDE_CMD"] = launchCommand;
102
+ configEnv["SPF_CLAUDE_CMD"] = launchCommand;
98
103
  const model = await asker.select("Model (Claude Code's own vocabulary — not provider/model-id)", [
99
104
  { value: "sonnet", label: "sonnet" },
100
105
  { value: "opus", label: "opus" },
@@ -432,6 +437,8 @@ export async function runInterview(asker, ctx) {
432
437
  const quality = defaults["__quality__"];
433
438
  delete defaults["__quality__"];
434
439
  const config = { defaults };
440
+ if (Object.keys(configEnv).length > 0)
441
+ config.env = configEnv;
435
442
  if (agentOverrides.length > 0)
436
443
  config.agents = agentOverrides;
437
444
  if (quality)
@@ -8,8 +8,11 @@
8
8
  * dependencies, the same trade the pre-Flue `agent_pi.ts` made for `pi`.
9
9
  *
10
10
  * The `claude` command is resolved from `PATH` by default. To route it through
11
- * a wrapper, proxy server, or launcher (e.g., Ollama), set `SPF_CLAUDE_CMD`
12
- * before running spf. Space-separated command chains are supported:
11
+ * a wrapper, proxy server, or launcher (e.g., Ollama), set `SPF_CLAUDE_CMD`
12
+ * ordinarily via `env: { SPF_CLAUDE_CMD: "..." }` in `spf.config.yaml`
13
+ * (see `SFConfigSchema`'s doc comment), since this is a declarative launcher
14
+ * choice, not a secret; a real shell export still works and still wins, for
15
+ * a one-off local override. Space-separated command chains are supported:
13
16
  * - `SPF_CLAUDE_CMD="claude"` (default)
14
17
  * - `SPF_CLAUDE_CMD="ollama launch claude --model <tag>"` (Ollama launcher —
15
18
  * the `--model` is `ollama launch`'s OWN flag, and is mandatory in
@@ -17,6 +20,17 @@
17
20
  * The command/launcher must support the full Claude Code CLI interface.
18
21
  * When unset, defaults to `claude`.
19
22
  *
23
+ * A literal `{model}` token anywhere in `SPF_CLAUDE_CMD` is substituted with
24
+ * THIS call's own `request.model` before spawning — e.g.
25
+ * `SPF_CLAUDE_CMD="ollama launch claude --model {model}"` with one agent's
26
+ * `model: kimi-k2.7-code:cloud` and another's `model: qwen3-coder:cloud`
27
+ * genuinely dispatches each to its own model, since `ollama launch`'s
28
+ * `--model` (its OWN flag, resolved BEFORE `--`, not `claude`'s) is what
29
+ * actually pins which model serves the whole launched process — without
30
+ * `{model}`, that flag would have to be one fixed value for every
31
+ * claude_code agent in the roster, making each agent's own `model:` field
32
+ * inert no matter what it said.
33
+ *
20
34
  * Wrapper contract for the flags this module appends (`-p`, `--json-schema`,
21
35
  * `--model`, ...): a wrapper token chain is spawned as `[...cmdTokens,
22
36
  * ...args]`, so a plain passthrough shim needs nothing special, and a
@@ -101,6 +115,14 @@ export declare class CcToolCallTracker {
101
115
  private finish;
102
116
  }
103
117
  export declare function isKnownToolName(name: string): boolean;
118
+ /**
119
+ * Resolve `SPF_CLAUDE_CMD` for THIS call, substituting a literal `{model}`
120
+ * token with `model` — see the module doc comment for why a fixed tag baked
121
+ * into the wrapper string can't give per-agent model choice under a
122
+ * launcher like `ollama launch`. Exported as its own pure function so this
123
+ * substitution is unit-testable without spawning a real subprocess.
124
+ */
125
+ export declare function resolveClaudeCmdSpec(model: string): string;
104
126
  /** Kill any still-running `claude` children — call once, at process exit. Safe if none are running. */
105
127
  export declare function shutdown(): Promise<void>;
106
128
  /**
@@ -8,8 +8,11 @@
8
8
  * dependencies, the same trade the pre-Flue `agent_pi.ts` made for `pi`.
9
9
  *
10
10
  * The `claude` command is resolved from `PATH` by default. To route it through
11
- * a wrapper, proxy server, or launcher (e.g., Ollama), set `SPF_CLAUDE_CMD`
12
- * before running spf. Space-separated command chains are supported:
11
+ * a wrapper, proxy server, or launcher (e.g., Ollama), set `SPF_CLAUDE_CMD`
12
+ * ordinarily via `env: { SPF_CLAUDE_CMD: "..." }` in `spf.config.yaml`
13
+ * (see `SFConfigSchema`'s doc comment), since this is a declarative launcher
14
+ * choice, not a secret; a real shell export still works and still wins, for
15
+ * a one-off local override. Space-separated command chains are supported:
13
16
  * - `SPF_CLAUDE_CMD="claude"` (default)
14
17
  * - `SPF_CLAUDE_CMD="ollama launch claude --model <tag>"` (Ollama launcher —
15
18
  * the `--model` is `ollama launch`'s OWN flag, and is mandatory in
@@ -17,6 +20,17 @@
17
20
  * The command/launcher must support the full Claude Code CLI interface.
18
21
  * When unset, defaults to `claude`.
19
22
  *
23
+ * A literal `{model}` token anywhere in `SPF_CLAUDE_CMD` is substituted with
24
+ * THIS call's own `request.model` before spawning — e.g.
25
+ * `SPF_CLAUDE_CMD="ollama launch claude --model {model}"` with one agent's
26
+ * `model: kimi-k2.7-code:cloud` and another's `model: qwen3-coder:cloud`
27
+ * genuinely dispatches each to its own model, since `ollama launch`'s
28
+ * `--model` (its OWN flag, resolved BEFORE `--`, not `claude`'s) is what
29
+ * actually pins which model serves the whole launched process — without
30
+ * `{model}`, that flag would have to be one fixed value for every
31
+ * claude_code agent in the roster, making each agent's own `model:` field
32
+ * inert no matter what it said.
33
+ *
20
34
  * Wrapper contract for the flags this module appends (`-p`, `--json-schema`,
21
35
  * `--model`, ...): a wrapper token chain is spawned as `[...cmdTokens,
22
36
  * ...args]`, so a plain passthrough shim needs nothing special, and a
@@ -225,6 +239,16 @@ const EFFORT_MAP = {
225
239
  xhigh: "xhigh",
226
240
  max: "max",
227
241
  };
242
+ /**
243
+ * Resolve `SPF_CLAUDE_CMD` for THIS call, substituting a literal `{model}`
244
+ * token with `model` — see the module doc comment for why a fixed tag baked
245
+ * into the wrapper string can't give per-agent model choice under a
246
+ * launcher like `ollama launch`. Exported as its own pure function so this
247
+ * substitution is unit-testable without spawning a real subprocess.
248
+ */
249
+ export function resolveClaudeCmdSpec(model) {
250
+ return (process.env.SPF_CLAUDE_CMD || "claude").replaceAll("{model}", model);
251
+ }
228
252
  // ── process lifecycle ────────────────────────────────────────────────────────
229
253
  const inFlight = new Set();
230
254
  /** Kill any still-running `claude` children — call once, at process exit. Safe if none are running. */
@@ -278,7 +302,7 @@ export async function run(request, onEvent, onSpawn, onExit) {
278
302
  toolsFlagValue(request.tools),
279
303
  "--strict-mcp-config", // see the module doc comment — required, not optional
280
304
  ];
281
- const cmdSpec = process.env.SPF_CLAUDE_CMD || "claude";
305
+ const cmdSpec = resolveClaudeCmdSpec(request.model);
282
306
  const cmdTokens = cmdSpec.split(/\s+/).filter(Boolean);
283
307
  const [cmd, ...cmdArgs] = cmdTokens;
284
308
  // See the module doc comment for why `ollama launch ...` (and ONLY that
@@ -75,6 +75,26 @@ export declare function assertRunBudget(run: RunBudgetState): void;
75
75
  * and agents.validate() reports that plainly.
76
76
  */
77
77
  export declare function loadConfig(configPaths: string[]): SFConfig;
78
+ /**
79
+ * `${VAR}` substitutes from `process.env` at call time — a real secret can
80
+ * live in `.env` (gitignored) and be referenced from the committed
81
+ * `spf.config.yaml` without ever being written into it. An undefined
82
+ * reference throws rather than silently interpolating to `""`: a value like
83
+ * `SPF_CLAUDE_CMD="ollama launch claude --model ${OLLAMA_TAG}"` silently
84
+ * missing its tag would fail confusingly far downstream (a launcher error,
85
+ * or a wrong-but-plausible model), not here where the actual cause is known.
86
+ */
87
+ export declare function interpolateEnvValue(key: string, raw: string): string;
88
+ /**
89
+ * Apply `cfg.env` to `process.env` — called once, in `cli/index.ts`'s
90
+ * `main()`, right after `.env` loads and before any command runs, so every
91
+ * later `process.env[...]` read (provider keys, `SPF_CLAUDE_CMD`, ...) sees
92
+ * the result. A key already set in `process.env` (a real shell export, or
93
+ * `.env`, both of which load before this) is left untouched — `cfg.env`
94
+ * supplies a DEFAULT, never forces an override, mirroring
95
+ * `process.loadEnvFile()`'s own precedence for `.env` itself.
96
+ */
97
+ export declare function applyConfigEnv(env: Record<string, string>): void;
78
98
  export declare function resolve(cfg: SFConfig, name: string): AgentConfig;
79
99
  /** Fail fast: every required name must resolve to a usable agent. */
80
100
  export declare function validate(cfg: SFConfig, required: string[], requiredSuites?: string[], cwd?: string): void;
@@ -146,6 +146,9 @@ function mergeAgentLists(base, override) {
146
146
  */
147
147
  function mergeRawConfig(base, override) {
148
148
  return {
149
+ // Key-by-key, like `defaults`/`watch` — a repo override adding ONE env
150
+ // var shouldn't have to repeat every built-in one.
151
+ env: { ...(base.env || {}), ...(override.env || {}) },
149
152
  defaults: { ...(base.defaults || {}), ...(override.defaults || {}) },
150
153
  observability: { ...(base.observability || {}), ...(override.observability || {}) },
151
154
  quality: { ...(base.quality || {}), ...(override.quality || {}) },
@@ -220,6 +223,41 @@ export function loadConfig(configPaths) {
220
223
  throw new Error(`invalid config (${configPaths.join(", ")}): ${describeParseError(error)}`);
221
224
  }
222
225
  }
226
+ const ENV_VAR_REF = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
227
+ /**
228
+ * `${VAR}` substitutes from `process.env` at call time — a real secret can
229
+ * live in `.env` (gitignored) and be referenced from the committed
230
+ * `spf.config.yaml` without ever being written into it. An undefined
231
+ * reference throws rather than silently interpolating to `""`: a value like
232
+ * `SPF_CLAUDE_CMD="ollama launch claude --model ${OLLAMA_TAG}"` silently
233
+ * missing its tag would fail confusingly far downstream (a launcher error,
234
+ * or a wrong-but-plausible model), not here where the actual cause is known.
235
+ */
236
+ export function interpolateEnvValue(key, raw) {
237
+ return raw.replace(ENV_VAR_REF, (_match, name) => {
238
+ const value = process.env[name];
239
+ if (value === undefined) {
240
+ throw new Error(`env.${key}: references \${${name}}, which is not set (.env or the shell) — set it before running spf`);
241
+ }
242
+ return value;
243
+ });
244
+ }
245
+ /**
246
+ * Apply `cfg.env` to `process.env` — called once, in `cli/index.ts`'s
247
+ * `main()`, right after `.env` loads and before any command runs, so every
248
+ * later `process.env[...]` read (provider keys, `SPF_CLAUDE_CMD`, ...) sees
249
+ * the result. A key already set in `process.env` (a real shell export, or
250
+ * `.env`, both of which load before this) is left untouched — `cfg.env`
251
+ * supplies a DEFAULT, never forces an override, mirroring
252
+ * `process.loadEnvFile()`'s own precedence for `.env` itself.
253
+ */
254
+ export function applyConfigEnv(env) {
255
+ for (const [key, rawValue] of Object.entries(env)) {
256
+ if (process.env[key] !== undefined)
257
+ continue;
258
+ process.env[key] = interpolateEnvValue(key, rawValue);
259
+ }
260
+ }
223
261
  export function resolve(cfg, name) {
224
262
  const agent = cfg.agents.find((a) => a.name === name);
225
263
  if (!agent) {
@@ -804,6 +804,7 @@ export declare const TieringConfigSchema: v.ObjectSchema<{
804
804
  }, undefined>;
805
805
  export type TieringConfig = v.InferOutput<typeof TieringConfigSchema>;
806
806
  export declare const SFConfigSchema: v.ObjectSchema<{
807
+ readonly env: v.OptionalSchema<v.RecordSchema<v.StringSchema<undefined>, v.StringSchema<undefined>, undefined>, () => {}>;
807
808
  readonly defaults: v.OptionalSchema<v.ObjectSchema<{
808
809
  readonly coding_agent: v.OptionalSchema<v.PicklistSchema<["flue", "claude_code"], undefined>, "flue">;
809
810
  readonly model: v.OptionalSchema<v.StringSchema<undefined>, "google/gemini-3.6-flash">;
@@ -647,6 +647,17 @@ export const TieringConfigSchema = v.object({
647
647
  roles: v.optional(v.record(v.string(), v.string()), () => ({})),
648
648
  });
649
649
  export const SFConfigSchema = v.object({
650
+ // Declarative env vars, applied to process.env before anything else reads
651
+ // it (see cli/index.ts's main()) — for non-secret settings that used to
652
+ // require an out-of-band shell export (e.g. SPF_CLAUDE_CMD) to be
653
+ // committable, plain text config instead of hidden process state. A value
654
+ // already set in process.env (the real shell, or .env, which loads first)
655
+ // always wins — this only supplies a DEFAULT, never forces an override.
656
+ // `${VAR}` inside a value interpolates from process.env at that same
657
+ // point, specifically so a real secret can be pulled in from `.env`
658
+ // without ever being written into this (committed) file — see
659
+ // `applyConfigEnv`/`interpolateEnvValue` in agents.ts.
660
+ env: v.optional(v.record(v.string(), v.string()), () => ({})),
650
661
  defaults: v.optional(ConfigDefaultsSchema, () => v.parse(ConfigDefaultsSchema, {})),
651
662
  observability: v.optional(ObservabilityConfigSchema, () => v.parse(ObservabilityConfigSchema, {})),
652
663
  agents: v.optional(v.array(AgentConfigSchema), () => []),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.8.3",
3
+ "version": "0.9.0",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",