@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.
- package/README.md +7 -2
- package/assets/skill/cookbooks/authoring_chains.md +96 -84
- package/assets/skill/cookbooks/roster.md +3 -1
- package/assets/skill/references/config.md +5 -2
- package/dist/chains/context.d.ts +2 -0
- package/dist/chains/index.d.ts +21 -2
- package/dist/chains/index.js +73 -104
- package/dist/chains/{adw_simple_sdlc.d.ts → simple_sdlc.d.ts} +7 -1
- package/dist/chains/{adw_simple_sdlc.js → simple_sdlc.js} +19 -30
- package/dist/chains/steps.d.ts +117 -0
- package/dist/chains/steps.js +299 -0
- package/dist/cli/ask.d.ts +27 -0
- package/dist/cli/ask.js +125 -0
- package/dist/cli/commands/doctor.js +2 -24
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.js +82 -9
- package/dist/cli/commands/run.d.ts +1 -1
- package/dist/cli/commands/run.js +3 -1
- package/dist/cli/commands/watch.js +3 -3
- package/dist/cli/env_file.d.ts +18 -0
- package/dist/cli/env_file.js +99 -0
- package/dist/cli/index.js +2 -2
- package/dist/cli/interview.d.ts +24 -0
- package/dist/cli/interview.js +330 -0
- package/dist/core/prompts.d.ts +2 -0
- package/dist/core/prompts.js +2 -0
- package/dist/core/providers.d.ts +12 -0
- package/dist/core/providers.js +24 -0
- package/dist/core/quality.d.ts +9 -0
- package/dist/core/quality.js +10 -0
- package/dist/core/session.d.ts +6 -1
- package/dist/core/session.js +7 -3
- package/dist/core/tracer.js +1 -1
- package/dist/core/utils.d.ts +6 -2
- package/dist/core/utils.js +11 -2
- package/dist/test/chains.test.d.ts +12 -0
- package/dist/test/chains.test.js +86 -0
- package/dist/test/env_file.test.d.ts +1 -0
- package/dist/test/env_file.test.js +74 -0
- package/dist/test/fake_asker.d.ts +23 -0
- package/dist/test/fake_asker.js +30 -0
- package/dist/test/init_command.test.d.ts +1 -0
- package/dist/test/init_command.test.js +66 -0
- package/dist/test/interview.test.d.ts +1 -0
- package/dist/test/interview.test.js +179 -0
- package/dist/test/ui_server.test.js +1 -1
- package/dist/ui/shared/types.d.ts +1 -1
- package/package.json +5 -2
- package/dist/chains/adw_build.d.ts +0 -12
- package/dist/chains/adw_build.js +0 -27
- package/dist/chains/adw_build_review.d.ts +0 -21
- package/dist/chains/adw_build_review.js +0 -55
- package/dist/chains/adw_build_test.d.ts +0 -21
- package/dist/chains/adw_build_test.js +0 -67
- package/dist/chains/adw_document.d.ts +0 -23
- package/dist/chains/adw_document.js +0 -59
- package/dist/chains/adw_plan.d.ts +0 -12
- package/dist/chains/adw_plan.js +0 -27
- package/dist/chains/adw_plan_build.d.ts +0 -12
- package/dist/chains/adw_plan_build.js +0 -30
- package/dist/chains/adw_plan_build_test.d.ts +0 -16
- package/dist/chains/adw_plan_build_test.js +0 -65
- package/dist/chains/adw_plan_build_test_quality.d.ts +0 -18
- package/dist/chains/adw_plan_build_test_quality.js +0 -66
- package/dist/chains/adw_prompt.d.ts +0 -12
- package/dist/chains/adw_prompt.js +0 -25
- package/dist/chains/adw_quality.d.ts +0 -12
- package/dist/chains/adw_quality.js +0 -32
- package/dist/chains/adw_scout.d.ts +0 -12
- package/dist/chains/adw_scout.js +0 -27
package/dist/cli/ask.js
ADDED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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>;
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -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
|
|
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
|
|
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>]
|
|
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>;
|