clawchef 0.1.5 → 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 CHANGED
@@ -8,11 +8,12 @@ Recipe-driven OpenClaw environment orchestrator.
8
8
  - Accepts recipe input from local file/dir/archive and HTTP URL/archive.
9
9
  - Resolves `${var}` parameters from `--var`, environment, and defaults.
10
10
  - Auto-loads environment variables from `.env` in the current working directory.
11
- - Supports loading env vars from a custom `.env` path/URL via `--env-file`.
11
+ - Supports loading env vars from a custom `.env` path/URL via `--dotenv-ref`.
12
12
  - Requires secrets to be injected via `--var` / `CLAWCHEF_VAR_*` (no inline secrets in recipe).
13
13
  - Prepares OpenClaw version (install or reuse).
14
14
  - When installed OpenClaw version mismatches recipe version, prompts: ignore / abort / force reinstall (silent mode auto-picks force reinstall).
15
- - Always runs factory reset first (with confirmation prompt unless `-s/--silent` is used).
15
+ - Supports scoped execution via `--scope full|files|workspace`.
16
+ - `full` scope runs factory reset first (with confirmation prompt unless `-s/--silent` is used).
16
17
  - If `openclaw` is missing, auto-installs the recipe version and skips factory reset.
17
18
  - Starts OpenClaw gateway service after each recipe execution.
18
19
  - Creates workspaces and agents (default workspace path: `~/.openclaw/workspace-<workspace-name>`).
@@ -20,7 +21,6 @@ Recipe-driven OpenClaw environment orchestrator.
20
21
  - Materializes files into target workspaces.
21
22
  - Installs skills.
22
23
  - Supports plugin preinstall via `openclaw.plugins[]` and runtime `--plugin` flags.
23
- - Supports preserving existing OpenClaw state via `--keep-openclaw-state` (skip factory reset).
24
24
  - Configures channels with `openclaw channels add`.
25
25
  - Supports interactive channel login at the end of execution (`channels[].login: true`) for channels that expose login.
26
26
  - Supports remote HTTP orchestration via runtime flags (`--provider remote`) when OpenClaw is reachable via API.
@@ -43,8 +43,8 @@ clawchef cook https://example.com/recipes/sample.yaml --provider remote
43
43
  Run with custom env file (local path or HTTP URL):
44
44
 
45
45
  ```bash
46
- clawchef cook recipes/sample.yaml --env-file ./.env.staging
47
- clawchef cook recipes/sample.yaml --env-file https://example.com/envs/staging.env
46
+ clawchef cook recipes/sample.yaml --dotenv-ref ./.env.staging
47
+ clawchef cook recipes/sample.yaml --dotenv-ref https://example.com/envs/staging.env
48
48
  ```
49
49
 
50
50
  Run recipe from GitHub repository root (`recipe.yaml` at repo root):
@@ -96,13 +96,19 @@ Use it only in CI/non-interactive flows where destructive reset behavior is expe
96
96
  Keep existing OpenClaw state (skip reset and keep current version on mismatch):
97
97
 
98
98
  ```bash
99
- clawchef cook recipes/sample.yaml --keep-openclaw-state
99
+ clawchef cook recipes/sample.yaml --scope files
100
+ ```
101
+
102
+ Update only one workspace:
103
+
104
+ ```bash
105
+ clawchef cook recipes/sample.yaml --scope workspace --workspace workspace-meeting
100
106
  ```
101
107
 
102
108
  From-zero OpenClaw bootstrap (recommended):
103
109
 
104
110
  ```bash
105
- CLAWCHEF_VAR_OPENAI_API_KEY=sk-... clawchef cook recipes/openclaw-from-zero.yaml --verbose
111
+ CLAWCHEF_VAR_LLM_API_KEY=sk-... clawchef cook recipes/openclaw-from-zero.yaml --verbose
106
112
  ```
107
113
 
108
114
  Telegram channel setup only:
@@ -183,7 +189,7 @@ await cook("recipes/sample.yaml", {
183
189
  provider: "command",
184
190
  silent: true,
185
191
  vars: {
186
- openai_api_key: process.env.OPENAI_API_KEY ?? "",
192
+ llm_api_key: process.env.OPENAI_API_KEY ?? "",
187
193
  },
188
194
  });
189
195
 
@@ -196,6 +202,8 @@ await scaffold("./my-recipe-project", {
196
202
 
197
203
  - `vars`: template variables (`Record<string, string>`)
198
204
  - `plugins`: plugin npm specs to preinstall for this run (`string[]`)
205
+ - `scope`: `full | files | workspace` (default: `full`)
206
+ - `workspaceName`: required when `scope: "workspace"`
199
207
  - `provider`: `command | remote | mock`
200
208
  - `remote`: remote provider config (same fields as CLI remote flags)
201
209
  - `envFile`: custom env file path/URL; when set, default cwd `.env` loading is skipped
@@ -221,7 +229,7 @@ Notes:
221
229
  If `params.<key>.required: true` and no value is found, run fails.
222
230
 
223
231
  If `.env` exists in the directory where `clawchef` is executed, it is loaded before recipe parsing.
224
- If `--env-file` (or Node API `envFile`) is provided, only that env source is loaded.
232
+ If `--dotenv-ref` (or Node API `envFile`) is provided, only that env source is loaded.
225
233
 
226
234
  ## Recipe reference formats
227
235
 
@@ -318,7 +326,7 @@ Supported fields include:
318
326
 
319
327
  - onboarding: `mode`, `flow`, `non_interactive`, `accept_risk`, `reset`
320
328
  - setup toggles: `skip_channels`, `skip_skills`, `skip_health`, `skip_ui`, `skip_daemon`, `install_daemon`
321
- - auth/provider: `auth_choice`, `openai_api_key`, `anthropic_api_key`, `openrouter_api_key`, `xai_api_key`, `gemini_api_key`, `ai_gateway_api_key`, `cloudflare_ai_gateway_api_key`, `token`, `token_provider`, `token_profile_id`
329
+ - auth/provider: `auth_choice`, `llm_api_key`, `token`, `token_provider`, `token_profile_id`
322
330
 
323
331
  ### Plugin preinstall
324
332
 
@@ -334,7 +342,7 @@ openclaw:
334
342
  - "@scope/custom-channel-plugin@1.2.3"
335
343
  ```
336
344
 
337
- When `openclaw.bootstrap` contains provider keys, `clawchef` also injects them into runtime env for `openclaw agent --local`.
345
+ When `openclaw.bootstrap` contains `llm_api_key`, clawchef maps it to the provider-specific runtime env by `auth_choice` for `openclaw agent --local`.
338
346
 
339
347
  For `command` provider, default command templates are:
340
348
 
@@ -394,7 +402,7 @@ Supported common fields:
394
402
  - `workspaces[].assets` is optional.
395
403
  - If `assets` is set, clawchef recursively copies files from that directory into the workspace root.
396
404
  - `assets` is resolved relative to the recipe file path (unless absolute path is given).
397
- - `files[]` runs after assets copy, so `files[]` can override copied asset files.
405
+ - `workspaces[].files[]` runs after assets copy, so explicit file entries can override copied asset files.
398
406
  - Direct URL recipes do not support `workspaces[].assets` (assets must resolve to a local directory).
399
407
  - If provided, relative paths are resolved from the recipe file directory.
400
408
  - For direct URL recipe files, relative workspace paths are resolved from the current working directory.
@@ -410,10 +418,10 @@ workspaces:
410
418
 
411
419
  ## File content references
412
420
 
413
- In `files[]`, set exactly one of:
421
+ In `workspaces[].files[]`, set exactly one of:
414
422
 
415
423
  - `content`: inline text in recipe
416
- - `content_from`: load text from another file/URL
424
+ - `content_from`: load text from another file/URL (loaded content supports `${var}` template rendering)
417
425
  - `source`: copy raw file bytes from another file/URL
418
426
 
419
427
  `content_from` and `source` accept:
@@ -433,8 +441,8 @@ Useful placeholders when overriding commands:
433
441
 
434
442
  - Do not put plaintext API keys/tokens in recipe files.
435
443
  - Use `${var}` placeholders in recipe and pass values via:
436
- - `--var openai_api_key=...`
437
- - `CLAWCHEF_VAR_OPENAI_API_KEY=...`
444
+ - `--var llm_api_key=...`
445
+ - `CLAWCHEF_VAR_LLM_API_KEY=...`
438
446
  - Inline secrets in `openclaw.bootstrap.*` are rejected by validation.
439
447
 
440
448
  ## Conversation message format
package/dist/api.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { OpenClawProvider, OpenClawRemoteConfig } from "./types.js";
1
+ import type { OpenClawProvider, OpenClawRemoteConfig, RunScope } from "./types.js";
2
2
  import type { ScaffoldOptions, ScaffoldResult } from "./scaffold.js";
3
3
  export interface CookOptions {
4
4
  vars?: Record<string, string>;
@@ -7,6 +7,8 @@ export interface CookOptions {
7
7
  allowMissing?: boolean;
8
8
  verbose?: boolean;
9
9
  silent?: boolean;
10
+ scope?: RunScope;
11
+ workspaceName?: string;
10
12
  provider?: OpenClawProvider;
11
13
  remote?: Partial<OpenClawRemoteConfig>;
12
14
  envFile?: string;
package/dist/api.js CHANGED
@@ -8,14 +8,23 @@ import { scaffoldProject } from "./scaffold.js";
8
8
  import { recipeSchema } from "./schema.js";
9
9
  function normalizeCookOptions(options) {
10
10
  const plugins = Array.from(new Set((options.plugins ?? []).map((value) => value.trim()).filter((value) => value.length > 0)));
11
+ const scope = options.scope ?? "full";
12
+ const workspaceName = options.workspaceName?.trim() || undefined;
13
+ if (scope === "workspace" && !workspaceName) {
14
+ throw new ClawChefError("scope=workspace requires workspaceName");
15
+ }
16
+ if (scope !== "workspace" && workspaceName) {
17
+ throw new ClawChefError("workspaceName is only allowed when scope=workspace");
18
+ }
11
19
  return {
12
20
  vars: options.vars ?? {},
13
21
  plugins,
22
+ scope,
23
+ workspaceName,
14
24
  dryRun: Boolean(options.dryRun),
15
25
  allowMissing: Boolean(options.allowMissing),
16
26
  verbose: Boolean(options.verbose),
17
27
  silent: options.silent ?? true,
18
- keepOpenClawState: false,
19
28
  provider: options.provider ?? "command",
20
29
  remote: options.remote ?? {},
21
30
  };
package/dist/cli.js CHANGED
@@ -8,8 +8,23 @@ import { recipeSchema } from "./schema.js";
8
8
  import { scaffoldProject } from "./scaffold.js";
9
9
  import YAML from "js-yaml";
10
10
  import path from "node:path";
11
+ import { readFileSync } from "node:fs";
11
12
  import { createInterface } from "node:readline/promises";
12
13
  import { stdin as input, stdout as output } from "node:process";
14
+ function readPackageVersion() {
15
+ try {
16
+ const pkgPath = new URL("../package.json", import.meta.url);
17
+ const content = readFileSync(pkgPath, "utf8");
18
+ const parsed = JSON.parse(content);
19
+ if (parsed.version?.trim()) {
20
+ return parsed.version;
21
+ }
22
+ }
23
+ catch {
24
+ // ignore and use fallback
25
+ }
26
+ return "0.0.0";
27
+ }
13
28
  function parseVarFlags(values) {
14
29
  const out = {};
15
30
  for (const item of values) {
@@ -40,6 +55,12 @@ function parseProvider(value) {
40
55
  }
41
56
  throw new ClawChefError(`Invalid --provider value: ${value}. Expected command, remote, or mock`);
42
57
  }
58
+ function parseScope(value) {
59
+ if (value === "full" || value === "files" || value === "workspace") {
60
+ return value;
61
+ }
62
+ throw new ClawChefError(`Invalid --scope value: ${value}. Expected full, files, or workspace`);
63
+ }
43
64
  function parseOptionalInt(value, fieldName) {
44
65
  if (value === undefined) {
45
66
  return undefined;
@@ -69,7 +90,7 @@ export function buildCli() {
69
90
  program
70
91
  .name("clawchef")
71
92
  .description("Run OpenClaw environment recipes")
72
- .version("0.1.0");
93
+ .version(readPackageVersion());
73
94
  program
74
95
  .command("cook")
75
96
  .argument("<recipe>", "Recipe path/URL/dir/archive[:file]")
@@ -78,8 +99,9 @@ export function buildCli() {
78
99
  .option("--allow-missing", "Allow unresolved template variables", false)
79
100
  .option("--verbose", "Verbose logging", false)
80
101
  .option("-s, --silent", "Skip reset confirmation prompt", false)
81
- .option("--keep-openclaw-state", "Preserve existing OpenClaw state (skip factory reset)", false)
82
- .option("--env-file <path-or-url>", "Load env vars from local file or HTTP URL")
102
+ .option("--scope <scope>", "Run scope: full | files | workspace", "full")
103
+ .option("--workspace <name>", "Workspace name (required when --scope workspace)")
104
+ .option("--dotenv-ref <path-or-url>", "Load env vars from local file or HTTP URL")
83
105
  .option("--provider <provider>", "Execution provider: command | remote | mock")
84
106
  .option("--plugin <npm-spec>", "Preinstall plugin package (repeatable)", (v, p) => p.concat([v]), [])
85
107
  .option("--remote-base-url <url>", "Remote OpenClaw API base URL")
@@ -89,21 +111,30 @@ export function buildCli() {
89
111
  .option("--remote-timeout-ms <ms>", "Remote operation timeout in milliseconds")
90
112
  .option("--remote-operation-path <path>", "Remote operation endpoint path")
91
113
  .action(async (recipeRef, opts) => {
92
- if (opts.envFile) {
93
- await importDotEnvFromRef(String(opts.envFile));
114
+ if (opts.dotenvRef) {
115
+ await importDotEnvFromRef(String(opts.dotenvRef));
94
116
  }
95
117
  else {
96
118
  importDotEnvFromCwd();
97
119
  }
98
120
  const provider = parseProvider(opts.provider ?? readEnv("CLAWCHEF_PROVIDER") ?? "command");
121
+ const scope = parseScope(String(opts.scope ?? "full"));
122
+ const workspaceName = opts.workspace?.trim() ? String(opts.workspace).trim() : undefined;
123
+ if (scope === "workspace" && !workspaceName) {
124
+ throw new ClawChefError("--scope workspace requires --workspace <name>");
125
+ }
126
+ if (scope !== "workspace" && workspaceName) {
127
+ throw new ClawChefError("--workspace is only allowed when --scope workspace");
128
+ }
99
129
  const options = {
100
130
  vars: parseVarFlags(opts.var),
101
131
  plugins: parsePluginFlags(opts.plugin),
132
+ scope,
133
+ workspaceName,
102
134
  dryRun: Boolean(opts.dryRun),
103
135
  allowMissing: Boolean(opts.allowMissing),
104
136
  verbose: Boolean(opts.verbose),
105
137
  silent: Boolean(opts.silent),
106
- keepOpenClawState: Boolean(opts.keepOpenclawState),
107
138
  provider,
108
139
  remote: {
109
140
  base_url: opts.remoteBaseUrl ?? readEnv("CLAWCHEF_REMOTE_BASE_URL"),
package/dist/env.js CHANGED
@@ -32,7 +32,7 @@ function applyEnv(entries) {
32
32
  export async function importDotEnvFromRef(ref) {
33
33
  const trimmed = ref.trim();
34
34
  if (!trimmed) {
35
- throw new ClawChefError("--env-file cannot be empty");
35
+ throw new ClawChefError("dotenv ref cannot be empty");
36
36
  }
37
37
  if (isHttpUrl(trimmed)) {
38
38
  let response;
@@ -2,7 +2,7 @@ import type { AgentDef, ChannelDef, ConversationDef, OpenClawSection } from "../
2
2
  import type { EnsureVersionResult, OpenClawProvider, ResolvedWorkspaceDef } from "./provider.js";
3
3
  export declare class CommandOpenClawProvider implements OpenClawProvider {
4
4
  private readonly stagedMessages;
5
- ensureVersion(config: OpenClawSection, dryRun: boolean, silent: boolean, keepOpenClawState: boolean): Promise<EnsureVersionResult>;
5
+ ensureVersion(config: OpenClawSection, dryRun: boolean, silent: boolean, preserveExistingState: boolean): Promise<EnsureVersionResult>;
6
6
  factoryReset(config: OpenClawSection, dryRun: boolean): Promise<void>;
7
7
  installPlugin(config: OpenClawSection, pluginSpec: string, dryRun: boolean): Promise<void>;
8
8
  startGateway(config: OpenClawSection, dryRun: boolean): Promise<void>;
@@ -21,14 +21,25 @@ const DEFAULT_COMMANDS = {
21
21
  run_agent: "${bin} agent --local --agent ${agent} --message ${prompt_q} --json",
22
22
  };
23
23
  const SECRET_FLAG_RE = /(--[A-Za-z0-9-]*(?:api-key|token|password|secret)[A-Za-z0-9-]*\s+)(?:'[^']*'|"[^"]*"|\S+)/g;
24
+ const AUTH_CHOICE_TO_LLM_FLAG = {
25
+ "openai-api-key": "--openai-api-key",
26
+ "anthropic-api-key": "--anthropic-api-key",
27
+ "openrouter-api-key": "--openrouter-api-key",
28
+ "xai-api-key": "--xai-api-key",
29
+ "gemini-api-key": "--gemini-api-key",
30
+ "ai-gateway-api-key": "--ai-gateway-api-key",
31
+ "cloudflare-ai-gateway-api-key": "--cloudflare-ai-gateway-api-key",
32
+ };
33
+ const AUTH_CHOICE_TO_LLM_ENV = {
34
+ "openai-api-key": "OPENAI_API_KEY",
35
+ "anthropic-api-key": "ANTHROPIC_API_KEY",
36
+ "openrouter-api-key": "OPENROUTER_API_KEY",
37
+ "xai-api-key": "XAI_API_KEY",
38
+ "gemini-api-key": "GEMINI_API_KEY",
39
+ "ai-gateway-api-key": "AI_GATEWAY_API_KEY",
40
+ "cloudflare-ai-gateway-api-key": "CLOUDFLARE_AI_GATEWAY_API_KEY",
41
+ };
24
42
  const BOOTSTRAP_STRING_FLAGS = [
25
- ["openai_api_key", "--openai-api-key"],
26
- ["anthropic_api_key", "--anthropic-api-key"],
27
- ["openrouter_api_key", "--openrouter-api-key"],
28
- ["xai_api_key", "--xai-api-key"],
29
- ["gemini_api_key", "--gemini-api-key"],
30
- ["ai_gateway_api_key", "--ai-gateway-api-key"],
31
- ["cloudflare_ai_gateway_api_key", "--cloudflare-ai-gateway-api-key"],
32
43
  ["cloudflare_ai_gateway_account_id", "--cloudflare-ai-gateway-account-id"],
33
44
  ["cloudflare_ai_gateway_gateway_id", "--cloudflare-ai-gateway-gateway-id"],
34
45
  ["token", "--token"],
@@ -187,6 +198,12 @@ function buildBootstrapCommand(bin, bootstrap, workspacePath) {
187
198
  else if (cfg.install_daemon === false) {
188
199
  flags.push("--no-install-daemon");
189
200
  }
201
+ if (cfg.llm_api_key?.trim()) {
202
+ const llmFlag = AUTH_CHOICE_TO_LLM_FLAG[cfg.auth_choice ?? ""];
203
+ if (llmFlag) {
204
+ flags.push(`${llmFlag} ${shellQuote(cfg.llm_api_key)}`);
205
+ }
206
+ }
190
207
  for (const [field, flag] of BOOTSTRAP_STRING_FLAGS) {
191
208
  const value = cfg[field];
192
209
  if (value && value.trim()) {
@@ -202,26 +219,17 @@ function bootstrapRuntimeEnv(bootstrap) {
202
219
  return {};
203
220
  }
204
221
  const env = {};
205
- if (bootstrap.openai_api_key)
206
- env.OPENAI_API_KEY = bootstrap.openai_api_key;
207
- if (bootstrap.anthropic_api_key)
208
- env.ANTHROPIC_API_KEY = bootstrap.anthropic_api_key;
209
- if (bootstrap.openrouter_api_key)
210
- env.OPENROUTER_API_KEY = bootstrap.openrouter_api_key;
211
- if (bootstrap.xai_api_key)
212
- env.XAI_API_KEY = bootstrap.xai_api_key;
213
- if (bootstrap.gemini_api_key)
214
- env.GEMINI_API_KEY = bootstrap.gemini_api_key;
215
- if (bootstrap.ai_gateway_api_key)
216
- env.AI_GATEWAY_API_KEY = bootstrap.ai_gateway_api_key;
217
- if (bootstrap.cloudflare_ai_gateway_api_key) {
218
- env.CLOUDFLARE_AI_GATEWAY_API_KEY = bootstrap.cloudflare_ai_gateway_api_key;
222
+ if (bootstrap.llm_api_key?.trim()) {
223
+ const envKey = AUTH_CHOICE_TO_LLM_ENV[bootstrap.auth_choice ?? ""];
224
+ if (envKey) {
225
+ env[envKey] = bootstrap.llm_api_key;
226
+ }
219
227
  }
220
228
  return env;
221
229
  }
222
230
  export class CommandOpenClawProvider {
223
231
  stagedMessages = new Map();
224
- async ensureVersion(config, dryRun, silent, keepOpenClawState) {
232
+ async ensureVersion(config, dryRun, silent, preserveExistingState) {
225
233
  const bin = config.bin ?? "openclaw";
226
234
  const installPolicy = config.install ?? "auto";
227
235
  const useCmd = commandFor(config, "use_version", { bin, version: config.version });
@@ -272,7 +280,7 @@ export class CommandOpenClawProvider {
272
280
  if (installedThisRun) {
273
281
  throw new ClawChefError(`OpenClaw version mismatch after install: current ${currentVersion}, expected ${config.version}`);
274
282
  }
275
- if (keepOpenClawState) {
283
+ if (preserveExistingState) {
276
284
  return { installedThisRun: false };
277
285
  }
278
286
  const choice = await chooseVersionMismatchAction(currentVersion, config.version, silent);
@@ -2,7 +2,7 @@ import type { AgentDef, ChannelDef, ConversationDef, OpenClawSection } from "../
2
2
  import type { EnsureVersionResult, OpenClawProvider, ResolvedWorkspaceDef } from "./provider.js";
3
3
  export declare class MockOpenClawProvider implements OpenClawProvider {
4
4
  private state;
5
- ensureVersion(config: OpenClawSection, _dryRun: boolean, _silent: boolean, _keepOpenClawState: boolean): Promise<EnsureVersionResult>;
5
+ ensureVersion(config: OpenClawSection, _dryRun: boolean, _silent: boolean, _preserveExistingState: boolean): Promise<EnsureVersionResult>;
6
6
  installPlugin(_config: OpenClawSection, _pluginSpec: string, _dryRun: boolean): Promise<void>;
7
7
  factoryReset(_config: OpenClawSection, _dryRun: boolean): Promise<void>;
8
8
  startGateway(_config: OpenClawSection, _dryRun: boolean): Promise<void>;
@@ -7,7 +7,7 @@ export class MockOpenClawProvider {
7
7
  skills: new Set(),
8
8
  messages: new Map(),
9
9
  };
10
- async ensureVersion(config, _dryRun, _silent, _keepOpenClawState) {
10
+ async ensureVersion(config, _dryRun, _silent, _preserveExistingState) {
11
11
  const policy = config.install ?? "auto";
12
12
  const installed = this.state.installedVersions.has(config.version);
13
13
  let installedThisRun = false;
@@ -6,7 +6,7 @@ export interface EnsureVersionResult {
6
6
  installedThisRun: boolean;
7
7
  }
8
8
  export interface OpenClawProvider {
9
- ensureVersion(config: OpenClawSection, dryRun: boolean, silent: boolean, keepOpenClawState: boolean): Promise<EnsureVersionResult>;
9
+ ensureVersion(config: OpenClawSection, dryRun: boolean, silent: boolean, preserveExistingState: boolean): Promise<EnsureVersionResult>;
10
10
  installPlugin(config: OpenClawSection, pluginSpec: string, dryRun: boolean): Promise<void>;
11
11
  factoryReset(config: OpenClawSection, dryRun: boolean): Promise<void>;
12
12
  startGateway(config: OpenClawSection, dryRun: boolean): Promise<void>;
@@ -5,7 +5,7 @@ export declare class RemoteOpenClawProvider implements OpenClawProvider {
5
5
  private readonly remoteConfig;
6
6
  constructor(remoteConfig: Partial<OpenClawRemoteConfig>);
7
7
  private perform;
8
- ensureVersion(config: OpenClawSection, dryRun: boolean, _silent: boolean, _keepOpenClawState: boolean): Promise<EnsureVersionResult>;
8
+ ensureVersion(config: OpenClawSection, dryRun: boolean, _silent: boolean, _preserveExistingState: boolean): Promise<EnsureVersionResult>;
9
9
  installPlugin(config: OpenClawSection, pluginSpec: string, dryRun: boolean): Promise<void>;
10
10
  factoryReset(config: OpenClawSection, dryRun: boolean): Promise<void>;
11
11
  startGateway(config: OpenClawSection, dryRun: boolean): Promise<void>;
@@ -94,7 +94,7 @@ export class RemoteOpenClawProvider {
94
94
  clearTimeout(timeout);
95
95
  }
96
96
  }
97
- async ensureVersion(config, dryRun, _silent, _keepOpenClawState) {
97
+ async ensureVersion(config, dryRun, _silent, _preserveExistingState) {
98
98
  const result = await this.perform(config, "ensure_version", {
99
99
  install: config.install,
100
100
  }, dryRun);
@@ -22,6 +22,25 @@ function truncateForLog(text, maxLength = 500) {
22
22
  }
23
23
  return `${text.slice(0, maxLength)}... [truncated ${text.length - maxLength} chars]`;
24
24
  }
25
+ function renderTemplateString(input, vars, allowMissing) {
26
+ return input.replace(/\$\{([^}]+)\}/g, (_match, rawKey) => {
27
+ const key = String(rawKey).trim();
28
+ if (!key) {
29
+ return "";
30
+ }
31
+ if (Object.prototype.hasOwnProperty.call(vars, key)) {
32
+ return vars[key] ?? "";
33
+ }
34
+ const lowerKey = key.toLowerCase();
35
+ if (Object.prototype.hasOwnProperty.call(vars, lowerKey)) {
36
+ return vars[lowerKey] ?? "";
37
+ }
38
+ if (allowMissing) {
39
+ return `\${${key}}`;
40
+ }
41
+ throw new ClawChefError(`Missing template variable in file content: ${key}`);
42
+ });
43
+ }
25
44
  function resolveWorkspacePath(recipeOrigin, name, configuredPath) {
26
45
  if (configuredPath?.trim()) {
27
46
  if (path.isAbsolute(configuredPath)) {
@@ -121,13 +140,14 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
121
140
  const provider = createProvider(options);
122
141
  const remoteMode = options.provider === "remote";
123
142
  const workspacePaths = new Map();
143
+ const preserveExistingState = options.scope !== "full";
124
144
  logger.info(`Running recipe: ${recipe.name}`);
125
- const versionResult = await provider.ensureVersion(recipe.openclaw, options.dryRun, options.silent, options.keepOpenClawState);
145
+ const versionResult = await provider.ensureVersion(recipe.openclaw, options.dryRun, options.silent, preserveExistingState);
126
146
  logger.info(`OpenClaw version ready: ${recipe.openclaw.version}`);
127
147
  if (versionResult.installedThisRun) {
128
148
  logger.info("OpenClaw was installed in this run; skipping factory reset");
129
149
  }
130
- else if (options.keepOpenClawState) {
150
+ else if (preserveExistingState) {
131
151
  logger.info("Keeping existing OpenClaw state; skipping factory reset");
132
152
  }
133
153
  else {
@@ -198,65 +218,69 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
198
218
  await provider.configureChannel(recipe.openclaw, channel, options.dryRun);
199
219
  logger.info(`Channel configured: ${channel.channel}${channel.account ? `/${channel.account}` : ""}`);
200
220
  }
201
- for (const file of recipe.files ?? []) {
202
- const wsPath = workspacePaths.get(file.workspace);
221
+ for (const workspace of recipe.workspaces ?? []) {
222
+ const wsPath = workspacePaths.get(workspace.name);
203
223
  if (!wsPath) {
204
- throw new ClawChefError(`File target workspace does not exist: ${file.workspace}`);
224
+ throw new ClawChefError(`Workspace does not exist for files: ${workspace.name}`);
205
225
  }
206
- if (provider.materializeFile) {
207
- let content = file.content;
208
- if (content === undefined && file.content_from) {
209
- if (!options.dryRun) {
210
- content = await readTextFromRef(recipeOrigin, file.content_from);
226
+ for (const file of workspace.files ?? []) {
227
+ if (provider.materializeFile) {
228
+ let content = file.content;
229
+ if (content === undefined && file.content_from) {
230
+ if (!options.dryRun) {
231
+ const rawContent = await readTextFromRef(recipeOrigin, file.content_from);
232
+ content = renderTemplateString(rawContent, options.vars, options.allowMissing);
233
+ }
234
+ else {
235
+ const resolved = resolveFileRef(recipeOrigin, file.content_from);
236
+ content = `__dry_run_content_from__:${resolved.value}`;
237
+ }
211
238
  }
212
- else {
213
- const resolved = resolveFileRef(recipeOrigin, file.content_from);
214
- content = `__dry_run_content_from__:${resolved.value}`;
239
+ if (content === undefined && file.source) {
240
+ if (!options.dryRun) {
241
+ content = await readTextFromRef(recipeOrigin, file.source);
242
+ }
243
+ else {
244
+ const resolved = resolveFileRef(recipeOrigin, file.source);
245
+ content = `__dry_run_source__:${resolved.value}`;
246
+ }
247
+ }
248
+ if (content === undefined) {
249
+ throw new ClawChefError(`File ${file.path} requires content, content_from, or source`);
215
250
  }
251
+ await provider.materializeFile(recipe.openclaw, workspace.name, file.path, content, file.overwrite, options.dryRun);
252
+ logger.info(`File materialized: ${workspace.name}/${file.path}`);
253
+ continue;
216
254
  }
217
- if (content === undefined && file.source) {
218
- if (!options.dryRun) {
219
- content = await readTextFromRef(recipeOrigin, file.source);
255
+ const target = path.resolve(wsPath, file.path);
256
+ const targetDir = path.dirname(target);
257
+ if (!options.dryRun) {
258
+ await mkdir(targetDir, { recursive: true });
259
+ const alreadyExists = await exists(target);
260
+ if (alreadyExists && file.overwrite === false) {
261
+ logger.warn(`Skipping existing file: ${target}`);
220
262
  }
221
- else {
222
- const resolved = resolveFileRef(recipeOrigin, file.source);
223
- content = `__dry_run_source__:${resolved.value}`;
263
+ else if (file.content !== undefined) {
264
+ await writeFile(target, file.content, "utf8");
224
265
  }
225
- }
226
- if (content === undefined) {
227
- throw new ClawChefError(`File ${file.path} requires content, content_from, or source`);
228
- }
229
- await provider.materializeFile(recipe.openclaw, file.workspace, file.path, content, file.overwrite, options.dryRun);
230
- logger.info(`File materialized: ${file.workspace}/${file.path}`);
231
- continue;
232
- }
233
- const target = path.resolve(wsPath, file.path);
234
- const targetDir = path.dirname(target);
235
- if (!options.dryRun) {
236
- await mkdir(targetDir, { recursive: true });
237
- const alreadyExists = await exists(target);
238
- if (alreadyExists && file.overwrite === false) {
239
- logger.warn(`Skipping existing file: ${target}`);
240
- }
241
- else if (file.content !== undefined) {
242
- await writeFile(target, file.content, "utf8");
243
- }
244
- else if (file.content_from) {
245
- const content = await readTextFromRef(recipeOrigin, file.content_from);
246
- await writeFile(target, content, "utf8");
247
- }
248
- else if (file.source) {
249
- const resolved = resolveFileRef(recipeOrigin, file.source);
250
- if (resolved.kind === "local") {
251
- await copyFile(resolved.value, target);
266
+ else if (file.content_from) {
267
+ const rawContent = await readTextFromRef(recipeOrigin, file.content_from);
268
+ const content = renderTemplateString(rawContent, options.vars, options.allowMissing);
269
+ await writeFile(target, content, "utf8");
252
270
  }
253
- else {
254
- const content = await readBinaryFromRef(recipeOrigin, file.source);
255
- await writeFile(target, content);
271
+ else if (file.source) {
272
+ const resolved = resolveFileRef(recipeOrigin, file.source);
273
+ if (resolved.kind === "local") {
274
+ await copyFile(resolved.value, target);
275
+ }
276
+ else {
277
+ const content = await readBinaryFromRef(recipeOrigin, file.source);
278
+ await writeFile(target, content);
279
+ }
256
280
  }
257
281
  }
282
+ logger.info(`File materialized: ${workspace.name}/${file.path}`);
258
283
  }
259
- logger.info(`File materialized: ${file.workspace}/${file.path}`);
260
284
  }
261
285
  for (const agent of recipe.agents ?? []) {
262
286
  for (const skill of agent.skills ?? []) {