clawchef 0.1.6 → 0.1.7
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 +20 -12
- package/dist/api.d.ts +3 -1
- package/dist/api.js +10 -1
- package/dist/cli.js +34 -3
- package/dist/openclaw/command-provider.d.ts +1 -1
- package/dist/openclaw/command-provider.js +31 -23
- package/dist/openclaw/mock-provider.d.ts +1 -1
- package/dist/openclaw/mock-provider.js +1 -1
- package/dist/openclaw/provider.d.ts +1 -1
- package/dist/openclaw/remote-provider.d.ts +1 -1
- package/dist/openclaw/remote-provider.js +1 -1
- package/dist/orchestrator.js +74 -50
- package/dist/recipe.js +57 -24
- package/dist/schema.d.ts +72 -107
- package/dist/schema.js +15 -21
- package/dist/types.d.ts +6 -11
- package/package.json +1 -1
- package/recipes/content-from-sample.yaml +13 -9
- package/recipes/openclaw-from-zero.yaml +2 -2
- package/recipes/openclaw-local.yaml +8 -10
- package/recipes/openclaw-remote-http.yaml +6 -8
- package/recipes/sample.yaml +6 -8
- package/recipes/snippets/readme-template.md +3 -1
- package/src/api.ts +13 -2
- package/src/cli.ts +36 -4
- package/src/openclaw/command-provider.ts +34 -24
- package/src/openclaw/mock-provider.ts +1 -1
- package/src/openclaw/provider.ts +1 -1
- package/src/openclaw/remote-provider.ts +1 -1
- package/src/orchestrator.ts +74 -49
- package/src/recipe.ts +63 -24
- package/src/schema.ts +17 -22
- package/src/types.ts +6 -11
package/recipes/sample.yaml
CHANGED
|
@@ -15,6 +15,12 @@ openclaw:
|
|
|
15
15
|
|
|
16
16
|
workspaces:
|
|
17
17
|
- name: "${project_name}"
|
|
18
|
+
files:
|
|
19
|
+
- path: "README.md"
|
|
20
|
+
overwrite: true
|
|
21
|
+
content: |
|
|
22
|
+
# ${project_name}
|
|
23
|
+
Generated by clawchef.
|
|
18
24
|
|
|
19
25
|
channels:
|
|
20
26
|
- channel: "telegram"
|
|
@@ -27,14 +33,6 @@ agents:
|
|
|
27
33
|
model: "gpt-5.3-codex"
|
|
28
34
|
skills: ["repo-explore", "code-review"]
|
|
29
35
|
|
|
30
|
-
files:
|
|
31
|
-
- workspace: "${project_name}"
|
|
32
|
-
path: "README.md"
|
|
33
|
-
overwrite: true
|
|
34
|
-
content: |
|
|
35
|
-
# ${project_name}
|
|
36
|
-
Generated by clawchef.
|
|
37
|
-
|
|
38
36
|
conversations:
|
|
39
37
|
- workspace: "${project_name}"
|
|
40
38
|
agent: "planner"
|
package/src/api.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { runRecipe } from "./orchestrator.js";
|
|
|
6
6
|
import { loadRecipe, loadRecipeText } from "./recipe.js";
|
|
7
7
|
import { scaffoldProject } from "./scaffold.js";
|
|
8
8
|
import { recipeSchema } from "./schema.js";
|
|
9
|
-
import type { OpenClawProvider, OpenClawRemoteConfig, RunOptions } from "./types.js";
|
|
9
|
+
import type { OpenClawProvider, OpenClawRemoteConfig, RunOptions, RunScope } from "./types.js";
|
|
10
10
|
import type { ScaffoldOptions, ScaffoldResult } from "./scaffold.js";
|
|
11
11
|
|
|
12
12
|
export interface CookOptions {
|
|
@@ -16,6 +16,8 @@ export interface CookOptions {
|
|
|
16
16
|
allowMissing?: boolean;
|
|
17
17
|
verbose?: boolean;
|
|
18
18
|
silent?: boolean;
|
|
19
|
+
scope?: RunScope;
|
|
20
|
+
workspaceName?: string;
|
|
19
21
|
provider?: OpenClawProvider;
|
|
20
22
|
remote?: Partial<OpenClawRemoteConfig>;
|
|
21
23
|
envFile?: string;
|
|
@@ -24,14 +26,23 @@ export interface CookOptions {
|
|
|
24
26
|
|
|
25
27
|
function normalizeCookOptions(options: CookOptions): RunOptions {
|
|
26
28
|
const plugins = Array.from(new Set((options.plugins ?? []).map((value) => value.trim()).filter((value) => value.length > 0)));
|
|
29
|
+
const scope = options.scope ?? "full";
|
|
30
|
+
const workspaceName = options.workspaceName?.trim() || undefined;
|
|
31
|
+
if (scope === "workspace" && !workspaceName) {
|
|
32
|
+
throw new ClawChefError("scope=workspace requires workspaceName");
|
|
33
|
+
}
|
|
34
|
+
if (scope !== "workspace" && workspaceName) {
|
|
35
|
+
throw new ClawChefError("workspaceName is only allowed when scope=workspace");
|
|
36
|
+
}
|
|
27
37
|
return {
|
|
28
38
|
vars: options.vars ?? {},
|
|
29
39
|
plugins,
|
|
40
|
+
scope,
|
|
41
|
+
workspaceName,
|
|
30
42
|
dryRun: Boolean(options.dryRun),
|
|
31
43
|
allowMissing: Boolean(options.allowMissing),
|
|
32
44
|
verbose: Boolean(options.verbose),
|
|
33
45
|
silent: options.silent ?? true,
|
|
34
|
-
keepOpenClawState: false,
|
|
35
46
|
provider: options.provider ?? "command",
|
|
36
47
|
remote: options.remote ?? {},
|
|
37
48
|
};
|
package/src/cli.ts
CHANGED
|
@@ -6,12 +6,27 @@ import { runRecipe } from "./orchestrator.js";
|
|
|
6
6
|
import { loadRecipe, loadRecipeText } from "./recipe.js";
|
|
7
7
|
import { recipeSchema } from "./schema.js";
|
|
8
8
|
import { scaffoldProject } from "./scaffold.js";
|
|
9
|
-
import type { RunOptions } from "./types.js";
|
|
9
|
+
import type { RunOptions, RunScope } from "./types.js";
|
|
10
10
|
import YAML from "js-yaml";
|
|
11
11
|
import path from "node:path";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
12
13
|
import { createInterface } from "node:readline/promises";
|
|
13
14
|
import { stdin as input, stdout as output } from "node:process";
|
|
14
15
|
|
|
16
|
+
function readPackageVersion(): string {
|
|
17
|
+
try {
|
|
18
|
+
const pkgPath = new URL("../package.json", import.meta.url);
|
|
19
|
+
const content = readFileSync(pkgPath, "utf8");
|
|
20
|
+
const parsed = JSON.parse(content) as { version?: string };
|
|
21
|
+
if (parsed.version?.trim()) {
|
|
22
|
+
return parsed.version;
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// ignore and use fallback
|
|
26
|
+
}
|
|
27
|
+
return "0.0.0";
|
|
28
|
+
}
|
|
29
|
+
|
|
15
30
|
function parseVarFlags(values: string[]): Record<string, string> {
|
|
16
31
|
const out: Record<string, string> = {};
|
|
17
32
|
for (const item of values) {
|
|
@@ -46,6 +61,13 @@ function parseProvider(value: string): "command" | "mock" | "remote" {
|
|
|
46
61
|
throw new ClawChefError(`Invalid --provider value: ${value}. Expected command, remote, or mock`);
|
|
47
62
|
}
|
|
48
63
|
|
|
64
|
+
function parseScope(value: string): RunScope {
|
|
65
|
+
if (value === "full" || value === "files" || value === "workspace") {
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
throw new ClawChefError(`Invalid --scope value: ${value}. Expected full, files, or workspace`);
|
|
69
|
+
}
|
|
70
|
+
|
|
49
71
|
function parseOptionalInt(value: string | undefined, fieldName: string): number | undefined {
|
|
50
72
|
if (value === undefined) {
|
|
51
73
|
return undefined;
|
|
@@ -78,7 +100,7 @@ export function buildCli(): Command {
|
|
|
78
100
|
program
|
|
79
101
|
.name("clawchef")
|
|
80
102
|
.description("Run OpenClaw environment recipes")
|
|
81
|
-
.version(
|
|
103
|
+
.version(readPackageVersion());
|
|
82
104
|
|
|
83
105
|
program
|
|
84
106
|
.command("cook")
|
|
@@ -88,7 +110,8 @@ export function buildCli(): Command {
|
|
|
88
110
|
.option("--allow-missing", "Allow unresolved template variables", false)
|
|
89
111
|
.option("--verbose", "Verbose logging", false)
|
|
90
112
|
.option("-s, --silent", "Skip reset confirmation prompt", false)
|
|
91
|
-
.option("--
|
|
113
|
+
.option("--scope <scope>", "Run scope: full | files | workspace", "full")
|
|
114
|
+
.option("--workspace <name>", "Workspace name (required when --scope workspace)")
|
|
92
115
|
.option("--dotenv-ref <path-or-url>", "Load env vars from local file or HTTP URL")
|
|
93
116
|
.option("--provider <provider>", "Execution provider: command | remote | mock")
|
|
94
117
|
.option("--plugin <npm-spec>", "Preinstall plugin package (repeatable)", (v, p: string[]) => p.concat([v]), [])
|
|
@@ -106,14 +129,23 @@ export function buildCli(): Command {
|
|
|
106
129
|
}
|
|
107
130
|
|
|
108
131
|
const provider = parseProvider(opts.provider ?? readEnv("CLAWCHEF_PROVIDER") ?? "command");
|
|
132
|
+
const scope = parseScope(String(opts.scope ?? "full"));
|
|
133
|
+
const workspaceName = opts.workspace?.trim() ? String(opts.workspace).trim() : undefined;
|
|
134
|
+
if (scope === "workspace" && !workspaceName) {
|
|
135
|
+
throw new ClawChefError("--scope workspace requires --workspace <name>");
|
|
136
|
+
}
|
|
137
|
+
if (scope !== "workspace" && workspaceName) {
|
|
138
|
+
throw new ClawChefError("--workspace is only allowed when --scope workspace");
|
|
139
|
+
}
|
|
109
140
|
const options: RunOptions = {
|
|
110
141
|
vars: parseVarFlags(opts.var),
|
|
111
142
|
plugins: parsePluginFlags(opts.plugin),
|
|
143
|
+
scope,
|
|
144
|
+
workspaceName,
|
|
112
145
|
dryRun: Boolean(opts.dryRun),
|
|
113
146
|
allowMissing: Boolean(opts.allowMissing),
|
|
114
147
|
verbose: Boolean(opts.verbose),
|
|
115
148
|
silent: Boolean(opts.silent),
|
|
116
|
-
keepOpenClawState: Boolean(opts.keepOpenclawState),
|
|
117
149
|
provider,
|
|
118
150
|
remote: {
|
|
119
151
|
base_url: opts.remoteBaseUrl ?? readEnv("CLAWCHEF_REMOTE_BASE_URL"),
|
|
@@ -35,27 +35,33 @@ const SECRET_FLAG_RE =
|
|
|
35
35
|
/(--[A-Za-z0-9-]*(?:api-key|token|password|secret)[A-Za-z0-9-]*\s+)(?:'[^']*'|"[^"]*"|\S+)/g;
|
|
36
36
|
|
|
37
37
|
type BootstrapStringField =
|
|
38
|
-
| "openai_api_key"
|
|
39
|
-
| "anthropic_api_key"
|
|
40
|
-
| "openrouter_api_key"
|
|
41
|
-
| "xai_api_key"
|
|
42
|
-
| "gemini_api_key"
|
|
43
|
-
| "ai_gateway_api_key"
|
|
44
|
-
| "cloudflare_ai_gateway_api_key"
|
|
45
38
|
| "cloudflare_ai_gateway_account_id"
|
|
46
39
|
| "cloudflare_ai_gateway_gateway_id"
|
|
47
40
|
| "token"
|
|
48
41
|
| "token_provider"
|
|
49
42
|
| "token_profile_id";
|
|
50
43
|
|
|
44
|
+
const AUTH_CHOICE_TO_LLM_FLAG: Record<string, string> = {
|
|
45
|
+
"openai-api-key": "--openai-api-key",
|
|
46
|
+
"anthropic-api-key": "--anthropic-api-key",
|
|
47
|
+
"openrouter-api-key": "--openrouter-api-key",
|
|
48
|
+
"xai-api-key": "--xai-api-key",
|
|
49
|
+
"gemini-api-key": "--gemini-api-key",
|
|
50
|
+
"ai-gateway-api-key": "--ai-gateway-api-key",
|
|
51
|
+
"cloudflare-ai-gateway-api-key": "--cloudflare-ai-gateway-api-key",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const AUTH_CHOICE_TO_LLM_ENV: Record<string, string> = {
|
|
55
|
+
"openai-api-key": "OPENAI_API_KEY",
|
|
56
|
+
"anthropic-api-key": "ANTHROPIC_API_KEY",
|
|
57
|
+
"openrouter-api-key": "OPENROUTER_API_KEY",
|
|
58
|
+
"xai-api-key": "XAI_API_KEY",
|
|
59
|
+
"gemini-api-key": "GEMINI_API_KEY",
|
|
60
|
+
"ai-gateway-api-key": "AI_GATEWAY_API_KEY",
|
|
61
|
+
"cloudflare-ai-gateway-api-key": "CLOUDFLARE_AI_GATEWAY_API_KEY",
|
|
62
|
+
};
|
|
63
|
+
|
|
51
64
|
const BOOTSTRAP_STRING_FLAGS: Array<[BootstrapStringField, string]> = [
|
|
52
|
-
["openai_api_key", "--openai-api-key"],
|
|
53
|
-
["anthropic_api_key", "--anthropic-api-key"],
|
|
54
|
-
["openrouter_api_key", "--openrouter-api-key"],
|
|
55
|
-
["xai_api_key", "--xai-api-key"],
|
|
56
|
-
["gemini_api_key", "--gemini-api-key"],
|
|
57
|
-
["ai_gateway_api_key", "--ai-gateway-api-key"],
|
|
58
|
-
["cloudflare_ai_gateway_api_key", "--cloudflare-ai-gateway-api-key"],
|
|
59
65
|
["cloudflare_ai_gateway_account_id", "--cloudflare-ai-gateway-account-id"],
|
|
60
66
|
["cloudflare_ai_gateway_gateway_id", "--cloudflare-ai-gateway-gateway-id"],
|
|
61
67
|
["token", "--token"],
|
|
@@ -239,6 +245,13 @@ function buildBootstrapCommand(bin: string, bootstrap: OpenClawBootstrap | undef
|
|
|
239
245
|
flags.push("--no-install-daemon");
|
|
240
246
|
}
|
|
241
247
|
|
|
248
|
+
if (cfg.llm_api_key?.trim()) {
|
|
249
|
+
const llmFlag = AUTH_CHOICE_TO_LLM_FLAG[cfg.auth_choice ?? ""];
|
|
250
|
+
if (llmFlag) {
|
|
251
|
+
flags.push(`${llmFlag} ${shellQuote(cfg.llm_api_key)}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
242
255
|
for (const [field, flag] of BOOTSTRAP_STRING_FLAGS) {
|
|
243
256
|
const value = cfg[field];
|
|
244
257
|
if (value && value.trim()) {
|
|
@@ -257,14 +270,11 @@ function bootstrapRuntimeEnv(bootstrap: OpenClawBootstrap | undefined): Record<s
|
|
|
257
270
|
}
|
|
258
271
|
const env: Record<string, string> = {};
|
|
259
272
|
|
|
260
|
-
if (bootstrap.
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (bootstrap.ai_gateway_api_key) env.AI_GATEWAY_API_KEY = bootstrap.ai_gateway_api_key;
|
|
266
|
-
if (bootstrap.cloudflare_ai_gateway_api_key) {
|
|
267
|
-
env.CLOUDFLARE_AI_GATEWAY_API_KEY = bootstrap.cloudflare_ai_gateway_api_key;
|
|
273
|
+
if (bootstrap.llm_api_key?.trim()) {
|
|
274
|
+
const envKey = AUTH_CHOICE_TO_LLM_ENV[bootstrap.auth_choice ?? ""];
|
|
275
|
+
if (envKey) {
|
|
276
|
+
env[envKey] = bootstrap.llm_api_key;
|
|
277
|
+
}
|
|
268
278
|
}
|
|
269
279
|
|
|
270
280
|
return env;
|
|
@@ -277,7 +287,7 @@ export class CommandOpenClawProvider implements OpenClawProvider {
|
|
|
277
287
|
config: OpenClawSection,
|
|
278
288
|
dryRun: boolean,
|
|
279
289
|
silent: boolean,
|
|
280
|
-
|
|
290
|
+
preserveExistingState: boolean,
|
|
281
291
|
): Promise<EnsureVersionResult> {
|
|
282
292
|
const bin = config.bin ?? "openclaw";
|
|
283
293
|
const installPolicy = config.install ?? "auto";
|
|
@@ -343,7 +353,7 @@ export class CommandOpenClawProvider implements OpenClawProvider {
|
|
|
343
353
|
);
|
|
344
354
|
}
|
|
345
355
|
|
|
346
|
-
if (
|
|
356
|
+
if (preserveExistingState) {
|
|
347
357
|
return { installedThisRun: false };
|
|
348
358
|
}
|
|
349
359
|
|
|
@@ -25,7 +25,7 @@ export class MockOpenClawProvider implements OpenClawProvider {
|
|
|
25
25
|
config: OpenClawSection,
|
|
26
26
|
_dryRun: boolean,
|
|
27
27
|
_silent: boolean,
|
|
28
|
-
|
|
28
|
+
_preserveExistingState: boolean,
|
|
29
29
|
): Promise<EnsureVersionResult> {
|
|
30
30
|
const policy = config.install ?? "auto";
|
|
31
31
|
const installed = this.state.installedVersions.has(config.version);
|
package/src/openclaw/provider.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface OpenClawProvider {
|
|
|
11
11
|
config: OpenClawSection,
|
|
12
12
|
dryRun: boolean,
|
|
13
13
|
silent: boolean,
|
|
14
|
-
|
|
14
|
+
preserveExistingState: boolean,
|
|
15
15
|
): Promise<EnsureVersionResult>;
|
|
16
16
|
installPlugin(config: OpenClawSection, pluginSpec: string, dryRun: boolean): Promise<void>;
|
|
17
17
|
factoryReset(config: OpenClawSection, dryRun: boolean): Promise<void>;
|
|
@@ -145,7 +145,7 @@ export class RemoteOpenClawProvider implements OpenClawProvider {
|
|
|
145
145
|
config: OpenClawSection,
|
|
146
146
|
dryRun: boolean,
|
|
147
147
|
_silent: boolean,
|
|
148
|
-
|
|
148
|
+
_preserveExistingState: boolean,
|
|
149
149
|
): Promise<EnsureVersionResult> {
|
|
150
150
|
const result = await this.perform(
|
|
151
151
|
config,
|
package/src/orchestrator.ts
CHANGED
|
@@ -27,6 +27,26 @@ function truncateForLog(text: string, maxLength = 500): string {
|
|
|
27
27
|
return `${text.slice(0, maxLength)}... [truncated ${text.length - maxLength} chars]`;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
function renderTemplateString(input: string, vars: Record<string, string>, allowMissing: boolean): string {
|
|
31
|
+
return input.replace(/\$\{([^}]+)\}/g, (_match, rawKey: string) => {
|
|
32
|
+
const key = String(rawKey).trim();
|
|
33
|
+
if (!key) {
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
if (Object.prototype.hasOwnProperty.call(vars, key)) {
|
|
37
|
+
return vars[key] ?? "";
|
|
38
|
+
}
|
|
39
|
+
const lowerKey = key.toLowerCase();
|
|
40
|
+
if (Object.prototype.hasOwnProperty.call(vars, lowerKey)) {
|
|
41
|
+
return vars[lowerKey] ?? "";
|
|
42
|
+
}
|
|
43
|
+
if (allowMissing) {
|
|
44
|
+
return `\${${key}}`;
|
|
45
|
+
}
|
|
46
|
+
throw new ClawChefError(`Missing template variable in file content: ${key}`);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
30
50
|
function resolveWorkspacePath(recipeOrigin: RecipeOrigin, name: string, configuredPath?: string): string {
|
|
31
51
|
if (configuredPath?.trim()) {
|
|
32
52
|
if (path.isAbsolute(configuredPath)) {
|
|
@@ -146,19 +166,20 @@ export async function runRecipe(
|
|
|
146
166
|
const provider = createProvider(options);
|
|
147
167
|
const remoteMode = options.provider === "remote";
|
|
148
168
|
const workspacePaths = new Map<string, string>();
|
|
169
|
+
const preserveExistingState = options.scope !== "full";
|
|
149
170
|
|
|
150
171
|
logger.info(`Running recipe: ${recipe.name}`);
|
|
151
172
|
const versionResult = await provider.ensureVersion(
|
|
152
173
|
recipe.openclaw,
|
|
153
174
|
options.dryRun,
|
|
154
175
|
options.silent,
|
|
155
|
-
|
|
176
|
+
preserveExistingState,
|
|
156
177
|
);
|
|
157
178
|
logger.info(`OpenClaw version ready: ${recipe.openclaw.version}`);
|
|
158
179
|
|
|
159
180
|
if (versionResult.installedThisRun) {
|
|
160
181
|
logger.info("OpenClaw was installed in this run; skipping factory reset");
|
|
161
|
-
} else if (
|
|
182
|
+
} else if (preserveExistingState) {
|
|
162
183
|
logger.info("Keeping existing OpenClaw state; skipping factory reset");
|
|
163
184
|
} else {
|
|
164
185
|
const confirmed = await confirmFactoryReset(options);
|
|
@@ -244,63 +265,67 @@ export async function runRecipe(
|
|
|
244
265
|
logger.info(`Channel configured: ${channel.channel}${channel.account ? `/${channel.account}` : ""}`);
|
|
245
266
|
}
|
|
246
267
|
|
|
247
|
-
for (const
|
|
248
|
-
const wsPath = workspacePaths.get(
|
|
268
|
+
for (const workspace of recipe.workspaces ?? []) {
|
|
269
|
+
const wsPath = workspacePaths.get(workspace.name);
|
|
249
270
|
if (!wsPath) {
|
|
250
|
-
throw new ClawChefError(`
|
|
271
|
+
throw new ClawChefError(`Workspace does not exist for files: ${workspace.name}`);
|
|
251
272
|
}
|
|
252
273
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
274
|
+
for (const file of workspace.files ?? []) {
|
|
275
|
+
if (provider.materializeFile) {
|
|
276
|
+
let content = file.content;
|
|
277
|
+
if (content === undefined && file.content_from) {
|
|
278
|
+
if (!options.dryRun) {
|
|
279
|
+
const rawContent = await readTextFromRef(recipeOrigin, file.content_from);
|
|
280
|
+
content = renderTemplateString(rawContent, options.vars, options.allowMissing);
|
|
281
|
+
} else {
|
|
282
|
+
const resolved = resolveFileRef(recipeOrigin, file.content_from);
|
|
283
|
+
content = `__dry_run_content_from__:${resolved.value}`;
|
|
284
|
+
}
|
|
261
285
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
286
|
+
if (content === undefined && file.source) {
|
|
287
|
+
if (!options.dryRun) {
|
|
288
|
+
content = await readTextFromRef(recipeOrigin, file.source);
|
|
289
|
+
} else {
|
|
290
|
+
const resolved = resolveFileRef(recipeOrigin, file.source);
|
|
291
|
+
content = `__dry_run_source__:${resolved.value}`;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (content === undefined) {
|
|
295
|
+
throw new ClawChefError(`File ${file.path} requires content, content_from, or source`);
|
|
269
296
|
}
|
|
270
|
-
}
|
|
271
|
-
if (content === undefined) {
|
|
272
|
-
throw new ClawChefError(`File ${file.path} requires content, content_from, or source`);
|
|
273
|
-
}
|
|
274
297
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
298
|
+
await provider.materializeFile(recipe.openclaw, workspace.name, file.path, content, file.overwrite, options.dryRun);
|
|
299
|
+
logger.info(`File materialized: ${workspace.name}/${file.path}`);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
279
302
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
303
|
+
const target = path.resolve(wsPath, file.path);
|
|
304
|
+
const targetDir = path.dirname(target);
|
|
305
|
+
|
|
306
|
+
if (!options.dryRun) {
|
|
307
|
+
await mkdir(targetDir, { recursive: true });
|
|
308
|
+
const alreadyExists = await exists(target);
|
|
309
|
+
if (alreadyExists && file.overwrite === false) {
|
|
310
|
+
logger.warn(`Skipping existing file: ${target}`);
|
|
311
|
+
} else if (file.content !== undefined) {
|
|
312
|
+
await writeFile(target, file.content, "utf8");
|
|
313
|
+
} else if (file.content_from) {
|
|
314
|
+
const rawContent = await readTextFromRef(recipeOrigin, file.content_from);
|
|
315
|
+
const content = renderTemplateString(rawContent, options.vars, options.allowMissing);
|
|
316
|
+
await writeFile(target, content, "utf8");
|
|
317
|
+
} else if (file.source) {
|
|
318
|
+
const resolved = resolveFileRef(recipeOrigin, file.source);
|
|
319
|
+
if (resolved.kind === "local") {
|
|
320
|
+
await copyFile(resolved.value, target);
|
|
321
|
+
} else {
|
|
322
|
+
const content = await readBinaryFromRef(recipeOrigin, file.source);
|
|
323
|
+
await writeFile(target, content);
|
|
324
|
+
}
|
|
300
325
|
}
|
|
301
326
|
}
|
|
327
|
+
logger.info(`File materialized: ${workspace.name}/${file.path}`);
|
|
302
328
|
}
|
|
303
|
-
logger.info(`File materialized: ${file.workspace}/${file.path}`);
|
|
304
329
|
}
|
|
305
330
|
|
|
306
331
|
for (const agent of recipe.agents ?? []) {
|
package/src/recipe.ts
CHANGED
|
@@ -32,24 +32,18 @@ export interface LoadedRecipeText {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
const AUTH_CHOICE_TO_FIELD: Record<string, string> = {
|
|
35
|
-
"openai-api-key": "
|
|
36
|
-
"anthropic-api-key": "
|
|
37
|
-
"openrouter-api-key": "
|
|
38
|
-
"xai-api-key": "
|
|
39
|
-
"gemini-api-key": "
|
|
40
|
-
"ai-gateway-api-key": "
|
|
41
|
-
"cloudflare-ai-gateway-api-key": "
|
|
35
|
+
"openai-api-key": "llm_api_key",
|
|
36
|
+
"anthropic-api-key": "llm_api_key",
|
|
37
|
+
"openrouter-api-key": "llm_api_key",
|
|
38
|
+
"xai-api-key": "llm_api_key",
|
|
39
|
+
"gemini-api-key": "llm_api_key",
|
|
40
|
+
"ai-gateway-api-key": "llm_api_key",
|
|
41
|
+
"cloudflare-ai-gateway-api-key": "llm_api_key",
|
|
42
42
|
token: "token",
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
const SECRET_BOOTSTRAP_FIELDS = [
|
|
46
|
-
"
|
|
47
|
-
"anthropic_api_key",
|
|
48
|
-
"openrouter_api_key",
|
|
49
|
-
"xai_api_key",
|
|
50
|
-
"gemini_api_key",
|
|
51
|
-
"ai_gateway_api_key",
|
|
52
|
-
"cloudflare_ai_gateway_api_key",
|
|
46
|
+
"llm_api_key",
|
|
53
47
|
"token",
|
|
54
48
|
] as const;
|
|
55
49
|
|
|
@@ -124,7 +118,7 @@ function assertNoInlineSecrets(recipe: Recipe): void {
|
|
|
124
118
|
}
|
|
125
119
|
}
|
|
126
120
|
|
|
127
|
-
function collectVars(recipe: Recipe, cliVars: Record<string, string>): Record<string, string> {
|
|
121
|
+
function collectVars(recipe: Recipe, cliVars: Record<string, string>, requiredKeys?: Set<string>): Record<string, string> {
|
|
128
122
|
const vars: Record<string, string> = {};
|
|
129
123
|
const params = recipe.params ?? {};
|
|
130
124
|
|
|
@@ -155,7 +149,7 @@ function collectVars(recipe: Recipe, cliVars: Record<string, string>): Record<st
|
|
|
155
149
|
vars[key] = def.default;
|
|
156
150
|
continue;
|
|
157
151
|
}
|
|
158
|
-
if (def.required) {
|
|
152
|
+
if (def.required && (requiredKeys === undefined || requiredKeys.has(key))) {
|
|
159
153
|
throw new ClawChefError(`Parameter ${key} is required but was not provided via --var or environment`);
|
|
160
154
|
}
|
|
161
155
|
}
|
|
@@ -167,6 +161,36 @@ function collectVars(recipe: Recipe, cliVars: Record<string, string>): Record<st
|
|
|
167
161
|
return vars;
|
|
168
162
|
}
|
|
169
163
|
|
|
164
|
+
function projectRecipeForScope(recipe: Recipe, options: RunOptions): Recipe {
|
|
165
|
+
if (options.scope !== "workspace") {
|
|
166
|
+
return recipe;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
...recipe,
|
|
171
|
+
openclaw: {
|
|
172
|
+
...recipe.openclaw,
|
|
173
|
+
bootstrap: undefined,
|
|
174
|
+
},
|
|
175
|
+
channels: [],
|
|
176
|
+
conversations: [],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function filterRecipeByWorkspaceName(recipe: Recipe, workspaceName: string): Recipe {
|
|
181
|
+
const workspace = (recipe.workspaces ?? []).find((ws) => ws.name === workspaceName);
|
|
182
|
+
if (!workspace) {
|
|
183
|
+
throw new ClawChefError(`Workspace not found in recipe: ${workspaceName}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
...recipe,
|
|
188
|
+
workspaces: [workspace],
|
|
189
|
+
agents: (recipe.agents ?? []).filter((agent) => agent.workspace === workspaceName),
|
|
190
|
+
conversations: (recipe.conversations ?? []).filter((conv) => conv.workspace === workspaceName),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
170
194
|
function semanticValidate(recipe: Recipe): void {
|
|
171
195
|
const ws = new Set((recipe.workspaces ?? []).map((w) => w.name));
|
|
172
196
|
for (const workspace of recipe.workspaces ?? []) {
|
|
@@ -179,9 +203,11 @@ function semanticValidate(recipe: Recipe): void {
|
|
|
179
203
|
throw new ClawChefError(`Agent ${agent.name} references missing workspace: ${agent.workspace}`);
|
|
180
204
|
}
|
|
181
205
|
}
|
|
182
|
-
for (const
|
|
183
|
-
|
|
184
|
-
|
|
206
|
+
for (const workspace of recipe.workspaces ?? []) {
|
|
207
|
+
for (const file of workspace.files ?? []) {
|
|
208
|
+
if (!file.path.trim()) {
|
|
209
|
+
throw new ClawChefError(`Workspace ${workspace.name} has file with empty path`);
|
|
210
|
+
}
|
|
185
211
|
}
|
|
186
212
|
}
|
|
187
213
|
const agents = new Set((recipe.agents ?? []).map((a) => `${a.workspace}::${a.name}`));
|
|
@@ -752,18 +778,31 @@ export async function loadRecipe(recipePath: string, options: RunOptions): Promi
|
|
|
752
778
|
throw new ClawChefError(`Recipe format is invalid: ${firstParse.error.message}`);
|
|
753
779
|
}
|
|
754
780
|
|
|
755
|
-
|
|
781
|
+
const projected = projectRecipeForScope(firstParse.data, options);
|
|
756
782
|
|
|
757
|
-
|
|
758
|
-
|
|
783
|
+
assertNoInlineSecrets(projected);
|
|
784
|
+
|
|
785
|
+
const requiredKeys = options.scope === "workspace" ? new Set<string>() : undefined;
|
|
786
|
+
const vars = collectVars(projected, options.vars, requiredKeys);
|
|
787
|
+
const rendered = deepResolveTemplates(projected, vars, options.allowMissing);
|
|
759
788
|
const secondParse = recipeSchema.safeParse(rendered);
|
|
760
789
|
if (!secondParse.success) {
|
|
761
790
|
throw new ClawChefError(`Recipe is invalid after parameter resolution: ${secondParse.error.message}`);
|
|
762
791
|
}
|
|
763
792
|
|
|
764
|
-
|
|
793
|
+
const scopedRecipe = (() => {
|
|
794
|
+
if (options.scope !== "workspace") {
|
|
795
|
+
return secondParse.data;
|
|
796
|
+
}
|
|
797
|
+
if (!options.workspaceName) {
|
|
798
|
+
throw new ClawChefError("scope=workspace requires a workspace name");
|
|
799
|
+
}
|
|
800
|
+
return filterRecipeByWorkspaceName(secondParse.data, options.workspaceName);
|
|
801
|
+
})();
|
|
802
|
+
|
|
803
|
+
semanticValidate(scopedRecipe);
|
|
765
804
|
return {
|
|
766
|
-
recipe:
|
|
805
|
+
recipe: scopedRecipe,
|
|
767
806
|
origin: recipeRef.origin,
|
|
768
807
|
};
|
|
769
808
|
});
|
package/src/schema.ts
CHANGED
|
@@ -39,13 +39,7 @@ const openClawBootstrapSchema = z
|
|
|
39
39
|
skip_ui: z.boolean().optional(),
|
|
40
40
|
skip_daemon: z.boolean().optional(),
|
|
41
41
|
install_daemon: z.boolean().optional(),
|
|
42
|
-
|
|
43
|
-
anthropic_api_key: z.string().optional(),
|
|
44
|
-
openrouter_api_key: z.string().optional(),
|
|
45
|
-
xai_api_key: z.string().optional(),
|
|
46
|
-
gemini_api_key: z.string().optional(),
|
|
47
|
-
ai_gateway_api_key: z.string().optional(),
|
|
48
|
-
cloudflare_ai_gateway_api_key: z.string().optional(),
|
|
42
|
+
llm_api_key: z.string().optional(),
|
|
49
43
|
cloudflare_ai_gateway_account_id: z.string().optional(),
|
|
50
44
|
cloudflare_ai_gateway_gateway_id: z.string().optional(),
|
|
51
45
|
token: z.string().optional(),
|
|
@@ -70,6 +64,22 @@ const workspaceSchema = z
|
|
|
70
64
|
name: z.string().min(1),
|
|
71
65
|
path: z.string().min(1).optional(),
|
|
72
66
|
assets: z.string().min(1).optional(),
|
|
67
|
+
files: z
|
|
68
|
+
.array(
|
|
69
|
+
z
|
|
70
|
+
.object({
|
|
71
|
+
path: z.string().min(1),
|
|
72
|
+
content: z.string().optional(),
|
|
73
|
+
content_from: z.string().min(1).optional(),
|
|
74
|
+
source: z.string().optional(),
|
|
75
|
+
overwrite: z.boolean().optional(),
|
|
76
|
+
})
|
|
77
|
+
.strict()
|
|
78
|
+
.refine((v) => [v.content, v.content_from, v.source].filter((item) => item !== undefined).length === 1, {
|
|
79
|
+
message: "workspaces[].files[] requires exactly one of content, content_from, or source",
|
|
80
|
+
}),
|
|
81
|
+
)
|
|
82
|
+
.optional(),
|
|
73
83
|
})
|
|
74
84
|
.strict();
|
|
75
85
|
|
|
@@ -104,20 +114,6 @@ const agentSchema = z
|
|
|
104
114
|
})
|
|
105
115
|
.strict();
|
|
106
116
|
|
|
107
|
-
const fileSchema = z
|
|
108
|
-
.object({
|
|
109
|
-
workspace: z.string().min(1),
|
|
110
|
-
path: z.string().min(1),
|
|
111
|
-
content: z.string().optional(),
|
|
112
|
-
content_from: z.string().min(1).optional(),
|
|
113
|
-
source: z.string().optional(),
|
|
114
|
-
overwrite: z.boolean().optional(),
|
|
115
|
-
})
|
|
116
|
-
.strict()
|
|
117
|
-
.refine((v) => [v.content, v.content_from, v.source].filter((item) => item !== undefined).length === 1, {
|
|
118
|
-
message: "files[] requires exactly one of content, content_from, or source",
|
|
119
|
-
});
|
|
120
|
-
|
|
121
117
|
const conversationExpectSchema = z
|
|
122
118
|
.object({
|
|
123
119
|
contains: z.array(z.string()).optional(),
|
|
@@ -152,7 +148,6 @@ export const recipeSchema = z
|
|
|
152
148
|
workspaces: z.array(workspaceSchema).optional(),
|
|
153
149
|
channels: z.array(channelSchema).optional(),
|
|
154
150
|
agents: z.array(agentSchema).optional(),
|
|
155
|
-
files: z.array(fileSchema).optional(),
|
|
156
151
|
conversations: z.array(conversationSchema).optional(),
|
|
157
152
|
})
|
|
158
153
|
.strict();
|