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.
@@ -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"
@@ -1,3 +1,5 @@
1
- # content-from-demo
1
+ # ${project_name}
2
2
 
3
3
  This content is loaded from a referenced file.
4
+
5
+ Assigned agent: ${agent_name}
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("0.1.6");
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("--keep-openclaw-state", "Preserve existing OpenClaw state (skip factory reset)", false)
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.openai_api_key) env.OPENAI_API_KEY = bootstrap.openai_api_key;
261
- if (bootstrap.anthropic_api_key) env.ANTHROPIC_API_KEY = bootstrap.anthropic_api_key;
262
- if (bootstrap.openrouter_api_key) env.OPENROUTER_API_KEY = bootstrap.openrouter_api_key;
263
- if (bootstrap.xai_api_key) env.XAI_API_KEY = bootstrap.xai_api_key;
264
- if (bootstrap.gemini_api_key) env.GEMINI_API_KEY = bootstrap.gemini_api_key;
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
- keepOpenClawState: boolean,
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 (keepOpenClawState) {
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
- _keepOpenClawState: boolean,
28
+ _preserveExistingState: boolean,
29
29
  ): Promise<EnsureVersionResult> {
30
30
  const policy = config.install ?? "auto";
31
31
  const installed = this.state.installedVersions.has(config.version);
@@ -11,7 +11,7 @@ export interface OpenClawProvider {
11
11
  config: OpenClawSection,
12
12
  dryRun: boolean,
13
13
  silent: boolean,
14
- keepOpenClawState: boolean,
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
- _keepOpenClawState: boolean,
148
+ _preserveExistingState: boolean,
149
149
  ): Promise<EnsureVersionResult> {
150
150
  const result = await this.perform(
151
151
  config,
@@ -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
- options.keepOpenClawState,
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 (options.keepOpenClawState) {
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 file of recipe.files ?? []) {
248
- const wsPath = workspacePaths.get(file.workspace);
268
+ for (const workspace of recipe.workspaces ?? []) {
269
+ const wsPath = workspacePaths.get(workspace.name);
249
270
  if (!wsPath) {
250
- throw new ClawChefError(`File target workspace does not exist: ${file.workspace}`);
271
+ throw new ClawChefError(`Workspace does not exist for files: ${workspace.name}`);
251
272
  }
252
273
 
253
- if (provider.materializeFile) {
254
- let content = file.content;
255
- if (content === undefined && file.content_from) {
256
- if (!options.dryRun) {
257
- content = await readTextFromRef(recipeOrigin, file.content_from);
258
- } else {
259
- const resolved = resolveFileRef(recipeOrigin, file.content_from);
260
- content = `__dry_run_content_from__:${resolved.value}`;
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
- if (content === undefined && file.source) {
264
- if (!options.dryRun) {
265
- content = await readTextFromRef(recipeOrigin, file.source);
266
- } else {
267
- const resolved = resolveFileRef(recipeOrigin, file.source);
268
- content = `__dry_run_source__:${resolved.value}`;
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
- await provider.materializeFile(recipe.openclaw, file.workspace, file.path, content, file.overwrite, options.dryRun);
276
- logger.info(`File materialized: ${file.workspace}/${file.path}`);
277
- continue;
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
- const target = path.resolve(wsPath, file.path);
281
- const targetDir = path.dirname(target);
282
-
283
- if (!options.dryRun) {
284
- await mkdir(targetDir, { recursive: true });
285
- const alreadyExists = await exists(target);
286
- if (alreadyExists && file.overwrite === false) {
287
- logger.warn(`Skipping existing file: ${target}`);
288
- } else if (file.content !== undefined) {
289
- await writeFile(target, file.content, "utf8");
290
- } else if (file.content_from) {
291
- const content = await readTextFromRef(recipeOrigin, file.content_from);
292
- await writeFile(target, content, "utf8");
293
- } else if (file.source) {
294
- const resolved = resolveFileRef(recipeOrigin, file.source);
295
- if (resolved.kind === "local") {
296
- await copyFile(resolved.value, target);
297
- } else {
298
- const content = await readBinaryFromRef(recipeOrigin, file.source);
299
- await writeFile(target, content);
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": "openai_api_key",
36
- "anthropic-api-key": "anthropic_api_key",
37
- "openrouter-api-key": "openrouter_api_key",
38
- "xai-api-key": "xai_api_key",
39
- "gemini-api-key": "gemini_api_key",
40
- "ai-gateway-api-key": "ai_gateway_api_key",
41
- "cloudflare-ai-gateway-api-key": "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
- "openai_api_key",
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 file of recipe.files ?? []) {
183
- if (!ws.has(file.workspace)) {
184
- throw new ClawChefError(`File ${file.path} references missing workspace: ${file.workspace}`);
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
- assertNoInlineSecrets(firstParse.data);
781
+ const projected = projectRecipeForScope(firstParse.data, options);
756
782
 
757
- const vars = collectVars(firstParse.data, options.vars);
758
- const rendered = deepResolveTemplates(firstParse.data, vars, options.allowMissing);
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
- semanticValidate(secondParse.data);
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: secondParse.data,
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
- openai_api_key: z.string().optional(),
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();