@gr8ful/spf 0.1.7 → 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 (70) hide show
  1. package/README.md +7 -2
  2. package/assets/skill/cookbooks/authoring_chains.md +96 -84
  3. package/assets/skill/cookbooks/roster.md +3 -1
  4. package/assets/skill/references/config.md +5 -2
  5. package/dist/chains/context.d.ts +2 -0
  6. package/dist/chains/index.d.ts +21 -2
  7. package/dist/chains/index.js +73 -104
  8. package/dist/chains/{adw_simple_sdlc.d.ts → simple_sdlc.d.ts} +7 -1
  9. package/dist/chains/{adw_simple_sdlc.js → simple_sdlc.js} +19 -30
  10. package/dist/chains/steps.d.ts +117 -0
  11. package/dist/chains/steps.js +299 -0
  12. package/dist/cli/ask.d.ts +27 -0
  13. package/dist/cli/ask.js +125 -0
  14. package/dist/cli/commands/doctor.js +2 -24
  15. package/dist/cli/commands/init.d.ts +1 -1
  16. package/dist/cli/commands/init.js +82 -9
  17. package/dist/cli/commands/run.d.ts +1 -1
  18. package/dist/cli/commands/run.js +3 -1
  19. package/dist/cli/commands/watch.js +3 -3
  20. package/dist/cli/env_file.d.ts +18 -0
  21. package/dist/cli/env_file.js +99 -0
  22. package/dist/cli/index.js +2 -2
  23. package/dist/cli/interview.d.ts +24 -0
  24. package/dist/cli/interview.js +330 -0
  25. package/dist/core/prompts.d.ts +2 -0
  26. package/dist/core/prompts.js +2 -0
  27. package/dist/core/providers.d.ts +12 -0
  28. package/dist/core/providers.js +24 -0
  29. package/dist/core/quality.d.ts +9 -0
  30. package/dist/core/quality.js +10 -0
  31. package/dist/core/session.d.ts +6 -1
  32. package/dist/core/session.js +7 -3
  33. package/dist/core/tracer.js +1 -1
  34. package/dist/core/utils.d.ts +6 -2
  35. package/dist/core/utils.js +11 -2
  36. package/dist/test/chains.test.d.ts +12 -0
  37. package/dist/test/chains.test.js +86 -0
  38. package/dist/test/env_file.test.d.ts +1 -0
  39. package/dist/test/env_file.test.js +74 -0
  40. package/dist/test/fake_asker.d.ts +23 -0
  41. package/dist/test/fake_asker.js +30 -0
  42. package/dist/test/init_command.test.d.ts +1 -0
  43. package/dist/test/init_command.test.js +66 -0
  44. package/dist/test/interview.test.d.ts +1 -0
  45. package/dist/test/interview.test.js +179 -0
  46. package/dist/test/ui_server.test.js +1 -1
  47. package/dist/ui/shared/types.d.ts +1 -1
  48. package/package.json +5 -2
  49. package/dist/chains/adw_build.d.ts +0 -12
  50. package/dist/chains/adw_build.js +0 -27
  51. package/dist/chains/adw_build_review.d.ts +0 -21
  52. package/dist/chains/adw_build_review.js +0 -55
  53. package/dist/chains/adw_build_test.d.ts +0 -21
  54. package/dist/chains/adw_build_test.js +0 -67
  55. package/dist/chains/adw_document.d.ts +0 -23
  56. package/dist/chains/adw_document.js +0 -59
  57. package/dist/chains/adw_plan.d.ts +0 -12
  58. package/dist/chains/adw_plan.js +0 -27
  59. package/dist/chains/adw_plan_build.d.ts +0 -12
  60. package/dist/chains/adw_plan_build.js +0 -30
  61. package/dist/chains/adw_plan_build_test.d.ts +0 -16
  62. package/dist/chains/adw_plan_build_test.js +0 -65
  63. package/dist/chains/adw_plan_build_test_quality.d.ts +0 -18
  64. package/dist/chains/adw_plan_build_test_quality.js +0 -66
  65. package/dist/chains/adw_prompt.d.ts +0 -12
  66. package/dist/chains/adw_prompt.js +0 -25
  67. package/dist/chains/adw_quality.d.ts +0 -12
  68. package/dist/chains/adw_quality.js +0 -32
  69. package/dist/chains/adw_scout.d.ts +0 -12
  70. package/dist/chains/adw_scout.js +0 -27
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Zero-dependency interactive prompt primitives for `spf init`'s interview.
3
+ *
4
+ * No prompting library exists anywhere in this repo, and the project keeps a
5
+ * deliberately thin dependency list (`@flue/runtime`, `hono`, `valibot`,
6
+ * `yaml`) — this builds directly on `node:readline/promises` rather than
7
+ * adding one. `Asker` is an interface, not a class, so a test can supply a
8
+ * scripted fake instead of driving a real TTY — the same seam this repo
9
+ * already uses for `IssueProvider`/`CodeHostProvider` (see
10
+ * `src/test/watch.test.ts`'s `FakeProvider`).
11
+ */
12
+ import { createInterface } from "node:readline/promises";
13
+ import { Writable } from "node:stream";
14
+ import { paint } from "../core/console.js";
15
+ /** `stdin.isTTY` is what actually matters (the interview reads it) — `stdout.isTTY` alone, this repo's only prior TTY check (`src/ui/server/serve.ts:88`), would let a piped-in `spf init` hang waiting on input that will never arrive. */
16
+ export function isInteractive() {
17
+ return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY) && !process.env["CI"];
18
+ }
19
+ /** Thrown when the user interrupts (Ctrl-C) or stdin closes (EOF) mid-interview. `initCommand` catches this and exits 130, writing nothing. */
20
+ export class InterviewAborted extends Error {
21
+ constructor() {
22
+ super("interview interrupted");
23
+ }
24
+ }
25
+ class MutableWritable extends Writable {
26
+ muted = false;
27
+ _write(chunk, _encoding, callback) {
28
+ if (!this.muted)
29
+ process.stdout.write(chunk);
30
+ callback();
31
+ }
32
+ }
33
+ export function createAsker() {
34
+ const muteableOut = new MutableWritable();
35
+ const rl = createInterface({ input: process.stdin, output: muteableOut, terminal: true });
36
+ let aborted = false;
37
+ rl.on("SIGINT", () => {
38
+ aborted = true;
39
+ rl.close();
40
+ });
41
+ async function raw(prompt) {
42
+ if (aborted)
43
+ throw new InterviewAborted();
44
+ try {
45
+ const answer = await rl.question(prompt);
46
+ return answer.trim();
47
+ }
48
+ catch {
49
+ // readline rejects `question()` if the interface is closed underneath it (EOF/SIGINT mid-prompt).
50
+ throw new InterviewAborted();
51
+ }
52
+ }
53
+ return {
54
+ async text(label, opts) {
55
+ const suffix = opts?.default ? paint("dim", ` [${opts.default}]`) : "";
56
+ while (true) {
57
+ const answer = await raw(`${label}${suffix}: `);
58
+ const value = answer || opts?.default || "";
59
+ const problem = opts?.validate?.(value);
60
+ if (problem) {
61
+ console.log(paint("red", ` ${problem}`));
62
+ continue;
63
+ }
64
+ return value;
65
+ }
66
+ },
67
+ async select(label, choices, dflt) {
68
+ console.log(label);
69
+ for (const c of choices) {
70
+ const marker = c.value === dflt ? paint("bold", "*") : " ";
71
+ const hint = c.hint ? paint("dim", ` — ${c.hint}`) : "";
72
+ console.log(` ${marker} ${c.label ?? c.value}${hint}`);
73
+ }
74
+ const valid = new Set(choices.map((c) => c.value));
75
+ while (true) {
76
+ const answer = await raw(paint("dim", `choose [${dflt}]: `));
77
+ if (!answer)
78
+ return dflt;
79
+ if (valid.has(answer))
80
+ return answer;
81
+ console.log(paint("red", ` not one of: ${[...valid].join(", ")}`));
82
+ }
83
+ },
84
+ async confirm(label, dflt) {
85
+ const hint = dflt ? "Y/n" : "y/N";
86
+ const answer = (await raw(`${label} ${paint("dim", `[${hint}]`)}: `)).toLowerCase();
87
+ if (!answer)
88
+ return dflt;
89
+ return answer === "y" || answer === "yes";
90
+ },
91
+ async secret(label, opts) {
92
+ const maskedCurrent = opts?.current ? paint("dim", ` [keep current: ${maskForPrompt(opts.current)}]`) : "";
93
+ // The " > " marker is written unmuted, before muting starts — otherwise
94
+ // it vanishes along with the (correctly) suppressed keystroke echo,
95
+ // and an empty line reads as a hang rather than a waiting prompt.
96
+ console.log(`${label}${maskedCurrent}`);
97
+ process.stdout.write(" > ");
98
+ muteableOut.muted = true;
99
+ let answer;
100
+ try {
101
+ answer = await raw("");
102
+ }
103
+ finally {
104
+ muteableOut.muted = false;
105
+ process.stdout.write("\n"); // the newline the muted output swallowed
106
+ }
107
+ return answer;
108
+ },
109
+ note(text) {
110
+ console.log(paint("dim", ` ${text}`));
111
+ },
112
+ heading(text) {
113
+ console.log("");
114
+ console.log(paint("bold cyan", `── ${text} ──`));
115
+ },
116
+ close() {
117
+ rl.close();
118
+ },
119
+ };
120
+ }
121
+ function maskForPrompt(value) {
122
+ if (value.length <= 4)
123
+ return "•".repeat(value.length);
124
+ return `${"•".repeat(Math.max(0, value.length - 4))}${value.slice(-4)}`;
125
+ }
@@ -14,37 +14,15 @@ import * as paths from "../../core/paths.js";
14
14
  import * as permissions from "../../core/permissions.js";
15
15
  import * as agentCc from "../../core/agent_cc.js";
16
16
  import { isKnownToolName as isKnownFlueToolName, resolveModel } from "../../core/agent_flue.js";
17
- import { parseCli } from "../../core/utils.js";
17
+ import { binaryOnPath, parseCli } from "../../core/utils.js";
18
+ import { PROVIDER_ENV_KEYS } from "../../core/providers.js";
18
19
  import { isRepoAt } from "../../core/git_helper.js";
19
20
  import { findChain } from "../../chains/index.js";
20
- // Common providers' env var conventions — public knowledge (pi-ai's own
21
- // resolution table is internal, unexported, and not something to reach into
22
- // for this). Missing from this table just means "unknown provider, skipped
23
- // the key check" — never a false failure.
24
- const PROVIDER_ENV_KEYS = {
25
- anthropic: ["ANTHROPIC_API_KEY"],
26
- openai: ["OPENAI_API_KEY"],
27
- google: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
28
- openrouter: ["OPENROUTER_API_KEY"],
29
- fireworks: ["FIREWORKS_API_KEY"],
30
- groq: ["GROQ_API_KEY"],
31
- mistral: ["MISTRAL_API_KEY"],
32
- xai: ["XAI_API_KEY"],
33
- deepseek: ["DEEPSEEK_API_KEY"],
34
- together: ["TOGETHER_API_KEY"],
35
- cerebras: ["CEREBRAS_API_KEY"],
36
- };
37
21
  function check(report, name, ok, detail) {
38
22
  report.checks.push({ name, ok, detail });
39
23
  if (!ok)
40
24
  report.ok = false;
41
25
  }
42
- function binaryOnPath(bin) {
43
- if (path.isAbsolute(bin) || bin.includes("/"))
44
- return existsSync(bin);
45
- const result = spawnSync(process.platform === "win32" ? "where" : "which", [bin], { encoding: "utf-8" });
46
- return result.status === 0;
47
- }
48
26
  export function doctorCommand(argv) {
49
27
  const { options, flags } = parseCli(argv, ["cwd", "config"], ["json"]);
50
28
  const report = { ok: true, checks: [] };
@@ -1 +1 @@
1
- export declare function initCommand(argv: string[]): number;
1
+ export declare function initCommand(argv: string[]): Promise<number>;
@@ -1,10 +1,27 @@
1
- /** `spf init` — seed a `.spf/` override directory. Everything else is inherited from the packaged defaults. */
1
+ /**
2
+ * `spf init` — seed a `.spf/` override directory. Everything else is
3
+ * inherited from the packaged defaults.
4
+ *
5
+ * On a TTY (and without `--template`/`--yes`), this runs an interview
6
+ * instead of writing the all-comments starter file: it asks which coding
7
+ * agent, model/provider, quality checks, and (if wanted) `spf watch`
8
+ * tracker/host to use, collects the secrets those answers imply, and
9
+ * appends them to `.env` (already auto-loaded by every command — see
10
+ * `src/cli/index.ts`). Piped input, `--yes`, or `--template <name>` all
11
+ * fall through to the original non-interactive behavior unchanged — a
12
+ * scripted `spf init` must never hang waiting on stdin.
13
+ */
2
14
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
15
  import path from "node:path";
16
+ import { stringify } from "yaml";
4
17
  import * as paths from "../../core/paths.js";
18
+ import * as agents from "../../core/agents.js";
5
19
  import { ensureGitignore } from "../gitignore.js";
6
20
  import { parseCli } from "../../core/utils.js";
7
21
  import { paint } from "../../core/console.js";
22
+ import { createAsker, isInteractive, InterviewAborted } from "../ask.js";
23
+ import { gatherContext, runInterview } from "../interview.js";
24
+ import { readEnvFile, upsertEnvFile, writeEnvExample } from "../env_file.js";
8
25
  const TEMPLATE_SUFFIX = ".spf.config.yaml";
9
26
  /** Every template's short name (e.g. "ts-cc"), derived from disk rather than hand-maintained — never drifts from what's actually packaged. */
10
27
  function listTemplates() {
@@ -75,20 +92,76 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
75
92
  // content is ignored: session traces (data/), a hand-editable engine copy
76
93
  // (engine/, from `spf eject`), and secrets (.env).
77
94
  const GITIGNORE_ENTRIES = [".spf/data/", ".spf/engine/", ".env"];
78
- export function initCommand(argv) {
79
- const { options, flags } = parseCli(argv, ["cwd", "template"], ["force"]);
95
+ const GENERATED_HEADER = `# .spf/spf.config.yaml — written by \`spf init\`'s interview, merged ON TOP of
96
+ # spf's packaged built-in defaults. Only what you changed is here; run
97
+ # \`spf doctor\` any time to see what's actually in effect for this repo, and
98
+ # where each value came from. Secrets this config implies live in .env
99
+ # (gitignored) — .env.example lists the key names only.
100
+ `;
101
+ export async function initCommand(argv) {
102
+ const { options, flags } = parseCli(argv, ["cwd", "template"], ["force", "yes"]);
80
103
  const anchor = paths.resolveAnchor(options["cwd"]);
81
104
  const sfDir = path.join(anchor.repo_root, ".spf");
82
105
  mkdirSync(sfDir, { recursive: true });
83
106
  const configPath = path.join(sfDir, "spf.config.yaml");
84
- if (existsSync(configPath) && !flags["force"]) {
85
- console.log(`${configPath} already exists leaving it alone (--force to overwrite)`);
107
+ const templateName = options["template"];
108
+ const interactive = !templateName && !flags["yes"] && isInteractive();
109
+ if (!interactive) {
110
+ if (existsSync(configPath) && !flags["force"]) {
111
+ console.log(`${configPath} already exists — leaving it alone (--force to overwrite)`);
112
+ }
113
+ else {
114
+ const content = templateName ? loadTemplate(templateName) : STARTER_CONFIG;
115
+ writeFileSync(configPath, content);
116
+ console.log(`wrote ${configPath}${templateName ? ` (from template "${templateName}")` : ""}`);
117
+ }
86
118
  }
87
119
  else {
88
- const templateName = options["template"];
89
- const content = templateName ? loadTemplate(templateName) : STARTER_CONFIG;
90
- writeFileSync(configPath, content);
91
- console.log(`wrote ${configPath}${templateName ? ` (from template "${templateName}")` : ""}`);
120
+ const asker = createAsker();
121
+ try {
122
+ if (existsSync(configPath) && !flags["force"]) {
123
+ const overwrite = await asker.confirm(`${configPath} already exists overwrite it?`, false);
124
+ if (!overwrite) {
125
+ console.log("leaving the existing config alone (--force to skip this prompt)");
126
+ asker.close();
127
+ ensureGitignore(anchor.repo_root, GITIGNORE_ENTRIES);
128
+ return 0;
129
+ }
130
+ }
131
+ const envPath = path.join(anchor.repo_root, ".env");
132
+ const ctx = gatherContext(anchor.repo_root, readEnvFile(envPath));
133
+ const result = await runInterview(asker, ctx);
134
+ asker.close();
135
+ if (!result) {
136
+ console.log("init cancelled — nothing written");
137
+ return 1;
138
+ }
139
+ writeFileSync(configPath, GENERATED_HEADER + stringify(result.config));
140
+ console.log(`wrote ${configPath}`);
141
+ if (Object.keys(result.env).length > 0)
142
+ upsertEnvFile(anchor.repo_root, result.env);
143
+ writeEnvExample(anchor.repo_root, result.envExampleKeys);
144
+ // The same merge-then-validate pipeline `spf doctor` runs — catches a
145
+ // bad answer (e.g. a suite naming an unconfigured check) right after
146
+ // writing, not at the user's first real chain run. Non-fatal: the
147
+ // config is already written either way, and `spf doctor` gives the
148
+ // full picture.
149
+ try {
150
+ const cfg = agents.loadConfig([paths.BUILTIN_CONFIG_PATH, configPath]);
151
+ agents.validate(cfg, cfg.agents.map((a) => a.name), Object.keys(cfg.quality.suites), anchor.cwd);
152
+ }
153
+ catch (error) {
154
+ console.log(paint("yellow", `warning: ${error.message}\nrun \`spf doctor\` for the full picture.`));
155
+ }
156
+ }
157
+ catch (error) {
158
+ asker.close();
159
+ if (error instanceof InterviewAborted) {
160
+ console.log("\ninit interrupted — nothing written");
161
+ return 130;
162
+ }
163
+ throw error;
164
+ }
92
165
  }
93
166
  ensureGitignore(anchor.repo_root, GITIGNORE_ENTRIES);
94
167
  const templates = listTemplates();
@@ -1,3 +1,3 @@
1
- import type { ChainDefinition } from "../../chains/index.ts";
1
+ import { type ChainDefinition } from "../../chains/index.ts";
2
2
  export declare function usageFor(chain: ChainDefinition): string;
3
3
  export declare function dispatchChain(chain: ChainDefinition, argv: string[]): Promise<number>;
@@ -1,6 +1,7 @@
1
1
  /** Shared by both `spf <chain> "..."` and `spf run <chain> "..."` — same dispatch. */
2
2
  import * as paths from "../../core/paths.js";
3
3
  import { parseCli, resolvePrompt } from "../../core/utils.js";
4
+ import { runChain } from "../../chains/index.js";
4
5
  const KNOWN_OPTIONS = ["config", "adw-id", "cwd", "agent", "base"];
5
6
  export function usageFor(chain) {
6
7
  return `usage: spf ${chain.name} "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id <id>] [--cwd <dir>]`;
@@ -17,11 +18,12 @@ export async function dispatchChain(chain, argv) {
17
18
  config_paths: paths.resolveConfigPaths(anchor, options["config"]).paths,
18
19
  adw_id: options["adw-id"] ?? null,
19
20
  cwd: anchor.cwd,
21
+ chain_name: chain.name,
20
22
  };
21
23
  const chainOptions = {};
22
24
  if (options["agent"] !== undefined)
23
25
  chainOptions["agent"] = options["agent"];
24
26
  if (options["base"] !== undefined)
25
27
  chainOptions["base"] = options["base"];
26
- return chain.run(ctx, chainOptions);
28
+ return runChain(chain, ctx, chainOptions);
27
29
  }
@@ -15,7 +15,7 @@ import { GitHubProvider } from "../../core/issues/github_provider.js";
15
15
  import { JiraProvider } from "../../core/issues/jira_provider.js";
16
16
  import { BitbucketProvider } from "../../core/issues/bitbucket_provider.js";
17
17
  import { createWatchState, tick } from "../../core/watch.js";
18
- import { findChain } from "../../chains/index.js";
18
+ import { findChain, runChain as runChainDef } from "../../chains/index.js";
19
19
  import { SfDb } from "../../ui/server/db.js";
20
20
  import { parseCli } from "../../core/utils.js";
21
21
  /**
@@ -184,8 +184,8 @@ export async function watchCommand(argv) {
184
184
  }
185
185
  const runChain = async (opts) => {
186
186
  const chainDef = findChain(cfg.watch.chain); // checked above
187
- const ctx = { prompt: opts.prompt, config_paths: configPaths, adw_id: opts.adwId, cwd: opts.cwd };
188
- const code = await chainDef.run(ctx);
187
+ const ctx = { prompt: opts.prompt, config_paths: configPaths, adw_id: opts.adwId, cwd: opts.cwd, chain_name: chainDef.name };
188
+ const code = await runChainDef(chainDef, ctx);
189
189
  if (code === 0)
190
190
  return { accepted: true, adwId: opts.adwId, detail: "" };
191
191
  let detail = `Chain "${cfg.watch.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`;
@@ -0,0 +1,18 @@
1
+ /** Parses simple `KEY=VALUE` lines; blank lines and `#` comments are ignored (and preserved verbatim on write). */
2
+ export declare function readEnvFile(p: string): Map<string, string>;
3
+ /** Redacts a secret for display: keeps the last 4 characters, masks the rest. Short values are fully masked. */
4
+ export declare function maskSecret(value: string): string;
5
+ export interface EnvUpsertResult {
6
+ added: string[];
7
+ updated: string[];
8
+ unchanged: string[];
9
+ }
10
+ /**
11
+ * Upsert `entries` into `<repoRoot>/.env`, preserving every existing line —
12
+ * comments, ordering, unrelated keys — verbatim. An existing key is
13
+ * rewritten in place (never duplicated); a new key is appended under a
14
+ * `# spf` header, matching `ensureGitignore`'s idempotent-append shape.
15
+ */
16
+ export declare function upsertEnvFile(repoRoot: string, entries: Record<string, string>): EnvUpsertResult;
17
+ /** Fills the `!.env.example` slot `.gitignore` already reserves — key names only, empty values, safe to commit. */
18
+ export declare function writeEnvExample(repoRoot: string, keys: string[]): void;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Idempotent `.env` upsert for `spf init`'s interview — the write-side
3
+ * counterpart of `process.loadEnvFile()` in `src/cli/index.ts:66`, which
4
+ * already loads `<repo_root>/.env` on every command. Modeled directly on
5
+ * `ensureGitignore` (`./gitignore.ts`): read, compute only what's missing or
6
+ * changed, log a one-line summary of key NAMES only — a secret value must
7
+ * never reach stdout, a log line, or a trace event.
8
+ */
9
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
10
+ import path from "node:path";
11
+ const KEY_LINE = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
12
+ function unquote(raw) {
13
+ const trimmed = raw.trim();
14
+ if (trimmed.length >= 2 && ((trimmed[0] === '"' && trimmed.endsWith('"')) || (trimmed[0] === "'" && trimmed.endsWith("'")))) {
15
+ return trimmed.slice(1, -1);
16
+ }
17
+ return trimmed;
18
+ }
19
+ function quoteIfNeeded(value) {
20
+ if (/[\s#"]/.test(value))
21
+ return `"${value.replace(/"/g, '\\"')}"`;
22
+ return value;
23
+ }
24
+ /** Parses simple `KEY=VALUE` lines; blank lines and `#` comments are ignored (and preserved verbatim on write). */
25
+ export function readEnvFile(p) {
26
+ const values = new Map();
27
+ if (!existsSync(p))
28
+ return values;
29
+ for (const line of readFileSync(p, "utf-8").split("\n")) {
30
+ const match = KEY_LINE.exec(line.trim());
31
+ if (match)
32
+ values.set(match[1], unquote(match[2]));
33
+ }
34
+ return values;
35
+ }
36
+ /** Redacts a secret for display: keeps the last 4 characters, masks the rest. Short values are fully masked. */
37
+ export function maskSecret(value) {
38
+ if (value.length <= 4)
39
+ return "•".repeat(Math.max(value.length, 1));
40
+ return `${"•".repeat(value.length - 4)}${value.slice(-4)}`;
41
+ }
42
+ /**
43
+ * Upsert `entries` into `<repoRoot>/.env`, preserving every existing line —
44
+ * comments, ordering, unrelated keys — verbatim. An existing key is
45
+ * rewritten in place (never duplicated); a new key is appended under a
46
+ * `# spf` header, matching `ensureGitignore`'s idempotent-append shape.
47
+ */
48
+ export function upsertEnvFile(repoRoot, entries) {
49
+ const envPath = path.join(repoRoot, ".env");
50
+ const hadFile = existsSync(envPath);
51
+ const lines = hadFile ? readFileSync(envPath, "utf-8").split("\n") : [];
52
+ const result = { added: [], updated: [], unchanged: [] };
53
+ const remaining = new Map(Object.entries(entries));
54
+ const rewritten = lines.map((line) => {
55
+ const match = KEY_LINE.exec(line.trim());
56
+ if (!match || !remaining.has(match[1]))
57
+ return line;
58
+ const key = match[1];
59
+ const newValue = remaining.get(key);
60
+ remaining.delete(key);
61
+ if (unquote(match[2]) === newValue) {
62
+ result.unchanged.push(key);
63
+ return line;
64
+ }
65
+ result.updated.push(key);
66
+ return `${key}=${quoteIfNeeded(newValue)}`;
67
+ });
68
+ const newKeys = [...remaining.keys()];
69
+ if (newKeys.length > 0) {
70
+ result.added.push(...newKeys);
71
+ const body = hadFile ? rewritten.join("\n").replace(/\n*$/, "\n") + "\n" : "";
72
+ const appended = newKeys.map((key) => `${key}=${quoteIfNeeded(remaining.get(key))}`);
73
+ writeFileSync(envPath, `${body}# spf\n${appended.join("\n")}\n`);
74
+ }
75
+ else if (result.updated.length > 0) {
76
+ writeFileSync(envPath, rewritten.join("\n"));
77
+ }
78
+ if (!hadFile) {
79
+ try {
80
+ chmodSync(envPath, 0o600);
81
+ }
82
+ catch {
83
+ // best-effort — some filesystems (or Windows) don't support unix perms
84
+ }
85
+ }
86
+ if (result.added.length > 0 || result.updated.length > 0) {
87
+ console.log(`updated ${envPath} (added: ${result.added.join(", ") || "none"}; updated: ${result.updated.join(", ") || "none"})`);
88
+ }
89
+ return result;
90
+ }
91
+ /** Fills the `!.env.example` slot `.gitignore` already reserves — key names only, empty values, safe to commit. */
92
+ export function writeEnvExample(repoRoot, keys) {
93
+ if (keys.length === 0)
94
+ return;
95
+ const examplePath = path.join(repoRoot, ".env.example");
96
+ const unique = [...new Set(keys)].sort();
97
+ writeFileSync(examplePath, `# spf — keys required by this repo's spf.config.yaml; fill in real values in .env (gitignored)\n${unique.map((k) => `${k}=`).join("\n")}\n`);
98
+ console.log(`wrote ${examplePath}`);
99
+ }
package/dist/cli/index.js CHANGED
@@ -27,7 +27,7 @@ const HELP = `spf — repeatable agents-plus-code workflows (ADWs)
27
27
 
28
28
  spf list the chain registry — names, phases, what each needs
29
29
  spf <chain> "<prompt>" [options] run a chain (spf run <chain> ... works identically)
30
- spf init [--force] [--template <name>] seed .spf/spf.config.yaml (from a packaged template, if named)
30
+ spf init [--force] [--yes] [--template <name>] interview to seed .spf/spf.config.yaml + .env (--yes/--template skip the interview)
31
31
  spf install-skill [--user] [--force] install the Claude Code skill (repo-local by default)
32
32
  spf migrate [--apply] [--force] move an old stamped adws/ tree onto .spf/ (dry run by default)
33
33
  spf eject [--target <dir>] [--force] copy the installed engine out for reference/hand-editing
@@ -90,7 +90,7 @@ export async function main() {
90
90
  process.exitCode = listCommand();
91
91
  return;
92
92
  case "init":
93
- process.exitCode = initCommand(rest);
93
+ process.exitCode = await initCommand(rest);
94
94
  return;
95
95
  case "install-skill":
96
96
  process.exitCode = installSkillCommand(rest);
@@ -0,0 +1,24 @@
1
+ import type { Asker } from "./ask.ts";
2
+ export interface DetectedContext {
3
+ repoSlug?: string;
4
+ currentBranch: string;
5
+ gitEmail?: string;
6
+ gitName?: string;
7
+ scripts: Record<string, string>;
8
+ claudeOnPath: boolean;
9
+ /** Whatever's already in `.env` — shown masked so a re-run can offer "keep current" instead of asking blind. */
10
+ existingEnv: Map<string, string>;
11
+ }
12
+ export declare function gatherContext(repoRoot: string, existingEnv?: Map<string, string>): DetectedContext;
13
+ export interface InterviewResult {
14
+ config: Record<string, unknown>;
15
+ env: Record<string, string>;
16
+ /** Key names only (no values) — written to a committable `.env.example`. */
17
+ envExampleKeys: string[];
18
+ }
19
+ /**
20
+ * Runs the interview and returns the config/env to write, or `null` if the
21
+ * user declines the final confirmation. Throws `InterviewAborted` (from
22
+ * `./ask.ts`) on Ctrl-C/EOF — the caller decides the exit code for that.
23
+ */
24
+ export declare function runInterview(asker: Asker, ctx: DetectedContext): Promise<InterviewResult | null>;