lilflow 0.1.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/AGENTS.md +140 -0
- package/README.md +112 -0
- package/package.json +50 -0
- package/src/AGENTS.md +27 -0
- package/src/agents/claude-code.js +352 -0
- package/src/agents/index.js +228 -0
- package/src/agents/ndjson.js +67 -0
- package/src/agents/opencode.js +290 -0
- package/src/agents/prompt.js +91 -0
- package/src/agents/session-store.js +91 -0
- package/src/cli.js +204 -0
- package/src/config/AGENTS.md +23 -0
- package/src/config.js +776 -0
- package/src/init-project.js +573 -0
- package/src/run-workflow.js +6274 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
import { access, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import yaml from "js-yaml";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
9
|
+
log: {
|
|
10
|
+
level: "info"
|
|
11
|
+
},
|
|
12
|
+
parallelism: 4,
|
|
13
|
+
timeout: {
|
|
14
|
+
default: "5m"
|
|
15
|
+
},
|
|
16
|
+
agent: Object.freeze({
|
|
17
|
+
opencode: Object.freeze({ bin: null }),
|
|
18
|
+
claude: Object.freeze({ bin: null })
|
|
19
|
+
})
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const LOG_LEVELS = ["debug", "info", "warn", "error"];
|
|
23
|
+
const CONFIG_KEYS = [
|
|
24
|
+
"log.level",
|
|
25
|
+
"parallelism",
|
|
26
|
+
"timeout.default",
|
|
27
|
+
"agent.opencode.bin",
|
|
28
|
+
"agent.claude.bin"
|
|
29
|
+
];
|
|
30
|
+
const CONFIG_TEMPLATE = `# Flow configuration
|
|
31
|
+
# log.level controls console verbosity.
|
|
32
|
+
log:
|
|
33
|
+
level: info
|
|
34
|
+
|
|
35
|
+
# parallelism limits concurrent work.
|
|
36
|
+
parallelism: 4
|
|
37
|
+
|
|
38
|
+
timeout:
|
|
39
|
+
# timeout.default is the fallback timeout for steps.
|
|
40
|
+
default: 5m
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
export class ConfigError extends Error {
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} message - Human-readable validation or parsing error.
|
|
46
|
+
*/
|
|
47
|
+
constructor(message) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "ConfigError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Return the filesystem path for the global config file.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} [homeDir=os.homedir()] - Home directory override.
|
|
57
|
+
* @returns {string} Absolute global config path.
|
|
58
|
+
*/
|
|
59
|
+
export function getGlobalConfigPath(homeDir = os.homedir()) {
|
|
60
|
+
return path.join(homeDir, ".flow", "config.yaml");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Return the filesystem path for the project config file.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} [cwd=process.cwd()] - Project directory override.
|
|
67
|
+
* @returns {string} Absolute project config path.
|
|
68
|
+
*/
|
|
69
|
+
export function getProjectConfigPath(cwd = process.cwd()) {
|
|
70
|
+
return path.join(cwd, ".flow", "config.yaml");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Return the adjacent local override path for a workflow file.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} workflowPath - Absolute or relative workflow path.
|
|
77
|
+
* @returns {string} Workflow-local override path.
|
|
78
|
+
*/
|
|
79
|
+
export function getLocalWorkflowConfigPath(workflowPath) {
|
|
80
|
+
const extension = path.extname(workflowPath);
|
|
81
|
+
|
|
82
|
+
if (extension === "") {
|
|
83
|
+
return `${workflowPath}.local`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return path.join(
|
|
87
|
+
path.dirname(workflowPath),
|
|
88
|
+
`${path.basename(workflowPath, extension)}.local${extension}`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Return the config template used for new files.
|
|
94
|
+
*
|
|
95
|
+
* @returns {string} Documented YAML template.
|
|
96
|
+
*/
|
|
97
|
+
export function getConfigTemplate() {
|
|
98
|
+
return CONFIG_TEMPLATE;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Return the config-specific help text.
|
|
103
|
+
*
|
|
104
|
+
* @returns {string} Help text shown for `flow config --help`.
|
|
105
|
+
*/
|
|
106
|
+
export function getConfigHelpText() {
|
|
107
|
+
return `flow config
|
|
108
|
+
|
|
109
|
+
Usage:
|
|
110
|
+
flow config
|
|
111
|
+
flow config --show
|
|
112
|
+
flow config --defaults
|
|
113
|
+
flow config --global
|
|
114
|
+
flow config --project
|
|
115
|
+
flow config [--log-level=<level>] [--parallelism=<count>] [--timeout-default=<duration>]
|
|
116
|
+
flow config --help
|
|
117
|
+
|
|
118
|
+
Flags:
|
|
119
|
+
--global Create or show ~/.flow/config.yaml
|
|
120
|
+
--project Create or show .flow/config.yaml in the current directory
|
|
121
|
+
--show Show the merged config with a source annotation per value
|
|
122
|
+
--defaults Show built-in default values
|
|
123
|
+
--log-level=<level> Override log.level for this command
|
|
124
|
+
--parallelism=<count> Override parallelism for this command
|
|
125
|
+
--timeout-default=<dur> Override timeout.default for this command
|
|
126
|
+
--help Show this help text
|
|
127
|
+
|
|
128
|
+
Supported keys:
|
|
129
|
+
log.level debug | info | warn | error
|
|
130
|
+
parallelism integer from 1 to 16
|
|
131
|
+
timeout.default duration like 30s, 5m, 1h
|
|
132
|
+
|
|
133
|
+
Examples:
|
|
134
|
+
flow config --global
|
|
135
|
+
FLOW_LOG_LEVEL=debug flow config
|
|
136
|
+
flow config --show --log-level=error`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Parse config overrides and command flags from CLI args.
|
|
141
|
+
*
|
|
142
|
+
* @param {string[]} args - CLI args after `config`.
|
|
143
|
+
* @returns {{commandFlags: object, overrideFlags: object}} Parsed flags.
|
|
144
|
+
*/
|
|
145
|
+
export function parseConfigCommandArgs(args) {
|
|
146
|
+
const commandFlags = {
|
|
147
|
+
defaults: false,
|
|
148
|
+
global: false,
|
|
149
|
+
help: false,
|
|
150
|
+
project: false,
|
|
151
|
+
show: false
|
|
152
|
+
};
|
|
153
|
+
const overrideFlags = {};
|
|
154
|
+
|
|
155
|
+
for (const arg of args) {
|
|
156
|
+
if (arg === "--help" || arg === "-h") {
|
|
157
|
+
commandFlags.help = true;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (arg === "--show") {
|
|
162
|
+
commandFlags.show = true;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (arg === "--defaults") {
|
|
167
|
+
commandFlags.defaults = true;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (arg === "--global") {
|
|
172
|
+
commandFlags.global = true;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (arg === "--project") {
|
|
177
|
+
commandFlags.project = true;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (arg.startsWith("--log-level=")) {
|
|
182
|
+
overrideFlags["log.level"] = arg.slice("--log-level=".length);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (arg.startsWith("--parallelism=")) {
|
|
187
|
+
const value = Number.parseInt(arg.slice("--parallelism=".length), 10);
|
|
188
|
+
overrideFlags.parallelism = value;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (arg.startsWith("--timeout-default=")) {
|
|
193
|
+
overrideFlags["timeout.default"] = arg.slice("--timeout-default=".length);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
throw new ConfigError(`Unknown config flag: ${arg}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { commandFlags, overrideFlags };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Parse YAML configuration content into a plain object.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} content - YAML file content.
|
|
207
|
+
* @param {string} sourceLabel - Source name used in error messages.
|
|
208
|
+
* @returns {object} Parsed config object.
|
|
209
|
+
*/
|
|
210
|
+
export function parseConfigContent(content, sourceLabel) {
|
|
211
|
+
try {
|
|
212
|
+
const parsed = yaml.load(content);
|
|
213
|
+
|
|
214
|
+
if (parsed == null) {
|
|
215
|
+
return {};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
219
|
+
throw new ConfigError(`${sourceLabel} must contain a YAML object.`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return parsed;
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (error instanceof ConfigError) {
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
throw new ConfigError(`Failed to parse ${sourceLabel}: ${error.message}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Ensure a config file exists, preserving any current content.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} filePath - Target config path.
|
|
236
|
+
* @returns {Promise<boolean>} True when the file was created.
|
|
237
|
+
*/
|
|
238
|
+
export async function ensureConfigFile(filePath) {
|
|
239
|
+
const directoryPath = path.dirname(filePath);
|
|
240
|
+
await mkdir(directoryPath, { recursive: true });
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
await access(filePath, constants.F_OK);
|
|
244
|
+
return false;
|
|
245
|
+
} catch {
|
|
246
|
+
const temporaryPath = `${filePath}.${randomUUID()}.tmp`;
|
|
247
|
+
await writeFile(temporaryPath, CONFIG_TEMPLATE, "utf8");
|
|
248
|
+
await rename(temporaryPath, filePath);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Load, merge, and validate flow configuration.
|
|
255
|
+
*
|
|
256
|
+
* @param {object} [options={}] - Load options.
|
|
257
|
+
* @param {string} [options.cwd=process.cwd()] - Working directory override.
|
|
258
|
+
* @param {object} [options.env=process.env] - Environment override.
|
|
259
|
+
* @param {object} [options.flags={}] - CLI flag overrides.
|
|
260
|
+
* @param {string} [options.homeDir=os.homedir()] - Home directory override.
|
|
261
|
+
* @param {string} [options.workflowPath] - Workflow file whose local config should participate in merging.
|
|
262
|
+
* @returns {Promise<{config: object, sources: object}>} Merged config and sources.
|
|
263
|
+
*/
|
|
264
|
+
export async function loadConfig(options = {}) {
|
|
265
|
+
const {
|
|
266
|
+
cwd = process.cwd(),
|
|
267
|
+
env = process.env,
|
|
268
|
+
flags = {},
|
|
269
|
+
homeDir = os.homedir(),
|
|
270
|
+
workflowPath = path.join(cwd, "workflow.yaml")
|
|
271
|
+
} = options;
|
|
272
|
+
|
|
273
|
+
const config = cloneConfig(DEFAULT_CONFIG);
|
|
274
|
+
const sources = createDefaultSources();
|
|
275
|
+
|
|
276
|
+
const globalFilePath = getGlobalConfigPath(homeDir);
|
|
277
|
+
const projectFilePath = getProjectConfigPath(cwd);
|
|
278
|
+
|
|
279
|
+
const globalConfig = await readConfigFile(globalFilePath, "global config");
|
|
280
|
+
validateConfigFragment(globalConfig);
|
|
281
|
+
applyConfigLayer(config, sources, globalConfig, "global");
|
|
282
|
+
|
|
283
|
+
const projectConfig = await readConfigFile(projectFilePath, "project config");
|
|
284
|
+
validateConfigFragment(projectConfig);
|
|
285
|
+
applyConfigLayer(config, sources, projectConfig, "project");
|
|
286
|
+
|
|
287
|
+
const workflowConfig = await readWorkflowConfigFile(workflowPath, "workflow config");
|
|
288
|
+
validateConfigFragment(workflowConfig);
|
|
289
|
+
applyConfigLayer(config, sources, workflowConfig, "workflow");
|
|
290
|
+
|
|
291
|
+
const workflowLocalConfig = await readWorkflowConfigFile(
|
|
292
|
+
getLocalWorkflowConfigPath(workflowPath),
|
|
293
|
+
"workflow local config",
|
|
294
|
+
true
|
|
295
|
+
);
|
|
296
|
+
validateConfigFragment(workflowLocalConfig);
|
|
297
|
+
applyConfigLayer(config, sources, workflowLocalConfig, "workflow-local");
|
|
298
|
+
|
|
299
|
+
const envConfig = readEnvConfig(env);
|
|
300
|
+
validateConfigFragment(envConfig);
|
|
301
|
+
applyConfigLayer(config, sources, envConfig, "env");
|
|
302
|
+
|
|
303
|
+
const flagConfig = readFlagConfig(flags);
|
|
304
|
+
validateConfigFragment(flagConfig);
|
|
305
|
+
applyConfigLayer(config, sources, flagConfig, "flag");
|
|
306
|
+
|
|
307
|
+
validateResolvedConfig(config);
|
|
308
|
+
|
|
309
|
+
return { config, sources };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Render config as YAML.
|
|
314
|
+
*
|
|
315
|
+
* @param {object} config - Config to serialize.
|
|
316
|
+
* @returns {string} YAML output.
|
|
317
|
+
*/
|
|
318
|
+
export function renderConfig(config) {
|
|
319
|
+
return yaml.dump(config, {
|
|
320
|
+
lineWidth: -1,
|
|
321
|
+
noRefs: true,
|
|
322
|
+
sortKeys: false
|
|
323
|
+
}).trimEnd();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Render config with per-key source annotations.
|
|
328
|
+
*
|
|
329
|
+
* @param {object} config - Merged config values.
|
|
330
|
+
* @param {object} sources - Per-key source labels.
|
|
331
|
+
* @returns {string} YAML-like output with comments.
|
|
332
|
+
*/
|
|
333
|
+
export function renderConfigWithSources(config, sources) {
|
|
334
|
+
return [
|
|
335
|
+
"log:",
|
|
336
|
+
` level: ${config.log.level} # source: ${sources["log.level"]}`,
|
|
337
|
+
`parallelism: ${config.parallelism} # source: ${sources.parallelism}`,
|
|
338
|
+
"timeout:",
|
|
339
|
+
` default: ${config.timeout.default} # source: ${sources["timeout.default"]}`,
|
|
340
|
+
"agent:",
|
|
341
|
+
" opencode:",
|
|
342
|
+
` bin: ${formatConfigValue(config.agent.opencode.bin)} # source: ${sources["agent.opencode.bin"]}`,
|
|
343
|
+
" claude:",
|
|
344
|
+
` bin: ${formatConfigValue(config.agent.claude.bin)} # source: ${sources["agent.claude.bin"]}`
|
|
345
|
+
].join("\n");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* @param {unknown} value - Config value to format for `--show` output.
|
|
350
|
+
* @returns {string} Display form ("null" for null/undefined).
|
|
351
|
+
*/
|
|
352
|
+
function formatConfigValue(value) {
|
|
353
|
+
if (value === null || value === undefined) {
|
|
354
|
+
return "null";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return String(value);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Validate a config fragment or resolved config object.
|
|
362
|
+
*
|
|
363
|
+
* @param {object} config - Config value to validate.
|
|
364
|
+
*/
|
|
365
|
+
export function validateConfig(config) {
|
|
366
|
+
validateConfigFragment(config);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* @returns {object} Fresh copy of the default config.
|
|
371
|
+
*/
|
|
372
|
+
function cloneConfig() {
|
|
373
|
+
return {
|
|
374
|
+
log: {
|
|
375
|
+
level: DEFAULT_CONFIG.log.level
|
|
376
|
+
},
|
|
377
|
+
parallelism: DEFAULT_CONFIG.parallelism,
|
|
378
|
+
timeout: {
|
|
379
|
+
default: DEFAULT_CONFIG.timeout.default
|
|
380
|
+
},
|
|
381
|
+
agent: {
|
|
382
|
+
opencode: { bin: DEFAULT_CONFIG.agent.opencode.bin },
|
|
383
|
+
claude: { bin: DEFAULT_CONFIG.agent.claude.bin }
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* @returns {object} Default source map.
|
|
390
|
+
*/
|
|
391
|
+
function createDefaultSources() {
|
|
392
|
+
return {
|
|
393
|
+
"log.level": "default",
|
|
394
|
+
parallelism: "default",
|
|
395
|
+
"timeout.default": "default",
|
|
396
|
+
"agent.opencode.bin": "default",
|
|
397
|
+
"agent.claude.bin": "default"
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* @param {string} filePath - Path to inspect.
|
|
403
|
+
* @param {string} sourceLabel - Error label.
|
|
404
|
+
* @returns {Promise<object>} Parsed config or empty object if missing.
|
|
405
|
+
*/
|
|
406
|
+
async function readConfigFile(filePath, sourceLabel) {
|
|
407
|
+
try {
|
|
408
|
+
const content = await readFile(filePath, "utf8");
|
|
409
|
+
return parseConfigContent(content, sourceLabel);
|
|
410
|
+
} catch (error) {
|
|
411
|
+
if (error && error.code === "ENOENT") {
|
|
412
|
+
return {};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
throw error;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* @param {string} filePath - Workflow or workflow-local config path.
|
|
421
|
+
* @param {string} sourceLabel - Error label.
|
|
422
|
+
* @param {boolean} [allowBareConfigFragment=false] - Whether the full file can be treated as a config fragment.
|
|
423
|
+
* @returns {Promise<object>} Extracted workflow-scoped config fragment.
|
|
424
|
+
*/
|
|
425
|
+
async function readWorkflowConfigFile(filePath, sourceLabel, allowBareConfigFragment = false) {
|
|
426
|
+
try {
|
|
427
|
+
const content = await readFile(filePath, "utf8");
|
|
428
|
+
return parseWorkflowConfigContent(content, sourceLabel, allowBareConfigFragment);
|
|
429
|
+
} catch (error) {
|
|
430
|
+
if (error && error.code === "ENOENT") {
|
|
431
|
+
return {};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
throw error;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* @param {string} content - Workflow YAML content.
|
|
440
|
+
* @param {string} sourceLabel - Error label.
|
|
441
|
+
* @param {boolean} [allowBareConfigFragment=false] - Whether the file can be parsed directly as a config fragment.
|
|
442
|
+
* @returns {object} Extracted workflow-scoped config.
|
|
443
|
+
*/
|
|
444
|
+
function parseWorkflowConfigContent(content, sourceLabel, allowBareConfigFragment = false) {
|
|
445
|
+
const parsed = parseConfigContent(content, sourceLabel);
|
|
446
|
+
return extractWorkflowConfigFragment(parsed, sourceLabel, allowBareConfigFragment);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* @param {object} parsed - Parsed workflow document.
|
|
451
|
+
* @param {string} sourceLabel - Error label.
|
|
452
|
+
* @param {boolean} allowBareConfigFragment - Whether the full document is a config fragment.
|
|
453
|
+
* @returns {object} Extracted workflow config fragment.
|
|
454
|
+
*/
|
|
455
|
+
function extractWorkflowConfigFragment(parsed, sourceLabel, allowBareConfigFragment) {
|
|
456
|
+
if (Object.hasOwn(parsed, "config")) {
|
|
457
|
+
if (parsed.config == null || typeof parsed.config !== "object" || Array.isArray(parsed.config)) {
|
|
458
|
+
throw new ConfigError(`${sourceLabel} config must be a YAML object.`);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return parsed.config;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (allowBareConfigFragment) {
|
|
465
|
+
return parsed;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return pickKnownConfigKeys(parsed);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* @param {object} value - Parsed workflow document.
|
|
473
|
+
* @returns {object} Top-level supported config keys copied from the workflow document.
|
|
474
|
+
*/
|
|
475
|
+
function pickKnownConfigKeys(value) {
|
|
476
|
+
const config = {};
|
|
477
|
+
|
|
478
|
+
if (value.log !== undefined) {
|
|
479
|
+
config.log = value.log;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (value.parallelism !== undefined) {
|
|
483
|
+
config.parallelism = value.parallelism;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (value.timeout !== undefined) {
|
|
487
|
+
config.timeout = value.timeout;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (value.agent !== undefined) {
|
|
491
|
+
config.agent = value.agent;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return config;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* @param {object} env - Process environment.
|
|
499
|
+
* @returns {object} Partial config from env vars.
|
|
500
|
+
*/
|
|
501
|
+
function readEnvConfig(env) {
|
|
502
|
+
const config = {};
|
|
503
|
+
|
|
504
|
+
if (env.FLOW_LOG_LEVEL !== undefined) {
|
|
505
|
+
config.log = {
|
|
506
|
+
level: env.FLOW_LOG_LEVEL
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (env.FLOW_PARALLELISM !== undefined) {
|
|
511
|
+
config.parallelism = Number.parseInt(env.FLOW_PARALLELISM, 10);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (env.FLOW_TIMEOUT_DEFAULT !== undefined) {
|
|
515
|
+
config.timeout = {
|
|
516
|
+
default: env.FLOW_TIMEOUT_DEFAULT
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (env.FLOW_AGENT_OPENCODE_BIN !== undefined || env.FLOW_AGENT_CLAUDE_BIN !== undefined) {
|
|
521
|
+
config.agent = {};
|
|
522
|
+
|
|
523
|
+
if (env.FLOW_AGENT_OPENCODE_BIN !== undefined) {
|
|
524
|
+
config.agent.opencode = { bin: env.FLOW_AGENT_OPENCODE_BIN };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (env.FLOW_AGENT_CLAUDE_BIN !== undefined) {
|
|
528
|
+
config.agent.claude = { bin: env.FLOW_AGENT_CLAUDE_BIN };
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return config;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* @param {object} flags - Dot-path and flat flag overrides.
|
|
537
|
+
* @returns {object} Partial config from CLI flags.
|
|
538
|
+
*/
|
|
539
|
+
function readFlagConfig(flags) {
|
|
540
|
+
const config = {};
|
|
541
|
+
|
|
542
|
+
if (Object.hasOwn(flags, "log.level")) {
|
|
543
|
+
config.log = {
|
|
544
|
+
level: flags["log.level"]
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (Object.hasOwn(flags, "parallelism")) {
|
|
549
|
+
config.parallelism = flags.parallelism;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (Object.hasOwn(flags, "timeout.default")) {
|
|
553
|
+
config.timeout = {
|
|
554
|
+
default: flags["timeout.default"]
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (Object.hasOwn(flags, "agent.opencode.bin") || Object.hasOwn(flags, "agent.claude.bin")) {
|
|
559
|
+
config.agent = {};
|
|
560
|
+
|
|
561
|
+
if (Object.hasOwn(flags, "agent.opencode.bin")) {
|
|
562
|
+
config.agent.opencode = { bin: flags["agent.opencode.bin"] };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (Object.hasOwn(flags, "agent.claude.bin")) {
|
|
566
|
+
config.agent.claude = { bin: flags["agent.claude.bin"] };
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return config;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* @param {object} target - Current merged config.
|
|
575
|
+
* @param {object} sources - Current source map.
|
|
576
|
+
* @param {object} layer - Partial config to merge.
|
|
577
|
+
* @param {string} sourceName - Label for changed keys.
|
|
578
|
+
*/
|
|
579
|
+
function applyConfigLayer(target, sources, layer, sourceName) {
|
|
580
|
+
if (layer.log && Object.hasOwn(layer.log, "level")) {
|
|
581
|
+
target.log.level = layer.log.level;
|
|
582
|
+
sources["log.level"] = sourceName;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (Object.hasOwn(layer, "parallelism")) {
|
|
586
|
+
target.parallelism = layer.parallelism;
|
|
587
|
+
sources.parallelism = sourceName;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (layer.timeout && Object.hasOwn(layer.timeout, "default")) {
|
|
591
|
+
target.timeout.default = layer.timeout.default;
|
|
592
|
+
sources["timeout.default"] = sourceName;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (layer.agent) {
|
|
596
|
+
if (layer.agent.opencode && Object.hasOwn(layer.agent.opencode, "bin")) {
|
|
597
|
+
target.agent.opencode.bin = layer.agent.opencode.bin;
|
|
598
|
+
sources["agent.opencode.bin"] = sourceName;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (layer.agent.claude && Object.hasOwn(layer.agent.claude, "bin")) {
|
|
602
|
+
target.agent.claude.bin = layer.agent.claude.bin;
|
|
603
|
+
sources["agent.claude.bin"] = sourceName;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* @param {object} config - Partial config to validate.
|
|
610
|
+
*/
|
|
611
|
+
function validateConfigFragment(config) {
|
|
612
|
+
validateKnownKeys(config);
|
|
613
|
+
|
|
614
|
+
if (config.log && Object.hasOwn(config.log, "level")) {
|
|
615
|
+
validateLogLevel(config.log.level, "log.level");
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (Object.hasOwn(config, "parallelism")) {
|
|
619
|
+
validateParallelism(config.parallelism, "parallelism");
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (config.timeout && Object.hasOwn(config.timeout, "default")) {
|
|
623
|
+
validateDuration(config.timeout.default, "timeout.default");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (config.agent) {
|
|
627
|
+
if (config.agent.opencode && Object.hasOwn(config.agent.opencode, "bin")) {
|
|
628
|
+
validateAgentBin(config.agent.opencode.bin, "agent.opencode.bin");
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (config.agent.claude && Object.hasOwn(config.agent.claude, "bin")) {
|
|
632
|
+
validateAgentBin(config.agent.claude.bin, "agent.claude.bin");
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* @param {object} config - Resolved config.
|
|
639
|
+
*/
|
|
640
|
+
function validateResolvedConfig(config) {
|
|
641
|
+
validateConfigFragment(config);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* @param {object} value - Partial config value.
|
|
646
|
+
*/
|
|
647
|
+
function validateKnownKeys(value) {
|
|
648
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
|
649
|
+
throw new ConfigError("Config must be a YAML object.");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
for (const key of Object.keys(value)) {
|
|
653
|
+
if (key === "log") {
|
|
654
|
+
validateKnownNestedKeys(value.log, "log", ["level"]);
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if (key === "timeout") {
|
|
659
|
+
validateKnownNestedKeys(value.timeout, "timeout", ["default"]);
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (key === "parallelism") {
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (key === "agent") {
|
|
668
|
+
validateAgentSection(value.agent);
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
throw createUnknownKeyError(key);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* @param {object} agent - Candidate agent section.
|
|
678
|
+
*/
|
|
679
|
+
function validateAgentSection(agent) {
|
|
680
|
+
if (agent == null || typeof agent !== "object" || Array.isArray(agent)) {
|
|
681
|
+
throw new ConfigError("agent must be a YAML object.");
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
for (const key of Object.keys(agent)) {
|
|
685
|
+
if (key === "opencode") {
|
|
686
|
+
validateKnownNestedKeys(agent.opencode, "agent.opencode", ["bin"]);
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (key === "claude") {
|
|
691
|
+
validateKnownNestedKeys(agent.claude, "agent.claude", ["bin"]);
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
throw createUnknownKeyError(`agent.${key}`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* @param {object} value - Nested config object.
|
|
701
|
+
* @param {string} parentKey - Prefix for errors.
|
|
702
|
+
* @param {string[]} validKeys - Allowed nested keys.
|
|
703
|
+
*/
|
|
704
|
+
function validateKnownNestedKeys(value, parentKey, validKeys) {
|
|
705
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
|
706
|
+
throw new ConfigError(`${parentKey} must be a YAML object.`);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
for (const key of Object.keys(value)) {
|
|
710
|
+
if (!validKeys.includes(key)) {
|
|
711
|
+
throw createUnknownKeyError(`${parentKey}.${key}`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* @param {string} level - Candidate log level.
|
|
718
|
+
* @param {string} key - Config key name.
|
|
719
|
+
*/
|
|
720
|
+
function validateLogLevel(level, key) {
|
|
721
|
+
if (typeof level !== "string" || !LOG_LEVELS.includes(level)) {
|
|
722
|
+
throw new ConfigError(
|
|
723
|
+
`Invalid value for ${key}: ${String(level)}. Valid options: ${LOG_LEVELS.join(", ")}. Run 'flow config --help' for guidance.`
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* @param {number} parallelism - Candidate parallelism.
|
|
730
|
+
* @param {string} key - Config key name.
|
|
731
|
+
*/
|
|
732
|
+
function validateParallelism(parallelism, key) {
|
|
733
|
+
if (!Number.isInteger(parallelism) || parallelism < 1 || parallelism > 16) {
|
|
734
|
+
throw new ConfigError(
|
|
735
|
+
`Invalid value for ${key}: ${String(parallelism)}. Valid range: 1-16. Run 'flow config --help' for guidance.`
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* @param {string} duration - Candidate duration string.
|
|
742
|
+
* @param {string} key - Config key name.
|
|
743
|
+
*/
|
|
744
|
+
function validateDuration(duration, key) {
|
|
745
|
+
if (typeof duration !== "string" || !/^\d+(ms|s|m|h)$/.test(duration)) {
|
|
746
|
+
throw new ConfigError(
|
|
747
|
+
`Invalid value for ${key}: ${String(duration)}. Valid format: <number><ms|s|m|h>. Run 'flow config --help' for guidance.`
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* @param {unknown} value - Candidate binary path override.
|
|
754
|
+
* @param {string} key - Config key name.
|
|
755
|
+
*/
|
|
756
|
+
function validateAgentBin(value, key) {
|
|
757
|
+
if (value === null) {
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
762
|
+
throw new ConfigError(
|
|
763
|
+
`Invalid value for ${key}: ${String(value)}. Must be a non-empty string or null. Run 'flow config --help' for guidance.`
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* @param {string} key - Unknown key path.
|
|
770
|
+
* @returns {ConfigError} Error describing allowed keys.
|
|
771
|
+
*/
|
|
772
|
+
function createUnknownKeyError(key) {
|
|
773
|
+
return new ConfigError(
|
|
774
|
+
`Invalid configuration key: ${key}. Supported keys: ${CONFIG_KEYS.join(", ")}. Run 'flow config --help' for guidance.`
|
|
775
|
+
);
|
|
776
|
+
}
|