clawchef 0.1.6 → 0.1.8

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"),
@@ -17,6 +17,7 @@ const DEFAULT_COMMANDS = {
17
17
  factory_reset: "${bin} reset --scope full --yes --non-interactive",
18
18
  start_gateway: "${bin} gateway start",
19
19
  enable_plugin: "",
20
+ bind_channel_agent: "",
20
21
  login_channel: "${bin} channels login --channel ${channel_q}${account_arg}",
21
22
  create_agent:
22
23
  "${bin} agents add ${agent} --workspace ${workspace_path} --model ${model} --non-interactive --json",
@@ -31,31 +32,51 @@ interface StagedMessage {
31
32
  content: string;
32
33
  }
33
34
 
35
+ interface BindingItem {
36
+ agentId?: unknown;
37
+ match?: {
38
+ channel?: unknown;
39
+ accountId?: unknown;
40
+ peer?: unknown;
41
+ parentPeer?: unknown;
42
+ guildId?: unknown;
43
+ teamId?: unknown;
44
+ roles?: unknown;
45
+ };
46
+ [key: string]: unknown;
47
+ }
48
+
34
49
  const SECRET_FLAG_RE =
35
50
  /(--[A-Za-z0-9-]*(?:api-key|token|password|secret)[A-Za-z0-9-]*\s+)(?:'[^']*'|"[^"]*"|\S+)/g;
36
51
 
37
52
  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
53
  | "cloudflare_ai_gateway_account_id"
46
54
  | "cloudflare_ai_gateway_gateway_id"
47
55
  | "token"
48
56
  | "token_provider"
49
57
  | "token_profile_id";
50
58
 
59
+ const AUTH_CHOICE_TO_LLM_FLAG: Record<string, string> = {
60
+ "openai-api-key": "--openai-api-key",
61
+ "anthropic-api-key": "--anthropic-api-key",
62
+ "openrouter-api-key": "--openrouter-api-key",
63
+ "xai-api-key": "--xai-api-key",
64
+ "gemini-api-key": "--gemini-api-key",
65
+ "ai-gateway-api-key": "--ai-gateway-api-key",
66
+ "cloudflare-ai-gateway-api-key": "--cloudflare-ai-gateway-api-key",
67
+ };
68
+
69
+ const AUTH_CHOICE_TO_LLM_ENV: Record<string, string> = {
70
+ "openai-api-key": "OPENAI_API_KEY",
71
+ "anthropic-api-key": "ANTHROPIC_API_KEY",
72
+ "openrouter-api-key": "OPENROUTER_API_KEY",
73
+ "xai-api-key": "XAI_API_KEY",
74
+ "gemini-api-key": "GEMINI_API_KEY",
75
+ "ai-gateway-api-key": "AI_GATEWAY_API_KEY",
76
+ "cloudflare-ai-gateway-api-key": "CLOUDFLARE_AI_GATEWAY_API_KEY",
77
+ };
78
+
51
79
  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
80
  ["cloudflare_ai_gateway_account_id", "--cloudflare-ai-gateway-account-id"],
60
81
  ["cloudflare_ai_gateway_gateway_id", "--cloudflare-ai-gateway-gateway-id"],
61
82
  ["token", "--token"],
@@ -239,6 +260,13 @@ function buildBootstrapCommand(bin: string, bootstrap: OpenClawBootstrap | undef
239
260
  flags.push("--no-install-daemon");
240
261
  }
241
262
 
263
+ if (cfg.llm_api_key?.trim()) {
264
+ const llmFlag = AUTH_CHOICE_TO_LLM_FLAG[cfg.auth_choice ?? ""];
265
+ if (llmFlag) {
266
+ flags.push(`${llmFlag} ${shellQuote(cfg.llm_api_key)}`);
267
+ }
268
+ }
269
+
242
270
  for (const [field, flag] of BOOTSTRAP_STRING_FLAGS) {
243
271
  const value = cfg[field];
244
272
  if (value && value.trim()) {
@@ -257,19 +285,49 @@ function bootstrapRuntimeEnv(bootstrap: OpenClawBootstrap | undefined): Record<s
257
285
  }
258
286
  const env: Record<string, string> = {};
259
287
 
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;
288
+ if (bootstrap.llm_api_key?.trim()) {
289
+ const envKey = AUTH_CHOICE_TO_LLM_ENV[bootstrap.auth_choice ?? ""];
290
+ if (envKey) {
291
+ env[envKey] = bootstrap.llm_api_key;
292
+ }
268
293
  }
269
294
 
270
295
  return env;
271
296
  }
272
297
 
298
+ function isAccountLevelBinding(item: BindingItem, channel: string, account: string): boolean {
299
+ const match = item.match;
300
+ if (!match || typeof match !== "object") {
301
+ return false;
302
+ }
303
+ if (match.channel !== channel || match.accountId !== account) {
304
+ return false;
305
+ }
306
+ return (
307
+ match.peer === undefined
308
+ && match.parentPeer === undefined
309
+ && match.guildId === undefined
310
+ && match.teamId === undefined
311
+ && match.roles === undefined
312
+ );
313
+ }
314
+
315
+ function parseBindingsJson(raw: string): BindingItem[] {
316
+ if (!raw.trim()) {
317
+ return [];
318
+ }
319
+ try {
320
+ const parsed = JSON.parse(raw) as unknown;
321
+ if (!Array.isArray(parsed)) {
322
+ throw new ClawChefError("openclaw config bindings is not an array");
323
+ }
324
+ return parsed as BindingItem[];
325
+ } catch (err) {
326
+ const message = err instanceof Error ? err.message : String(err);
327
+ throw new ClawChefError(`Failed to parse openclaw bindings JSON: ${message}`);
328
+ }
329
+ }
330
+
273
331
  export class CommandOpenClawProvider implements OpenClawProvider {
274
332
  private readonly stagedMessages = new Map<string, StagedMessage[]>();
275
333
 
@@ -277,7 +335,7 @@ export class CommandOpenClawProvider implements OpenClawProvider {
277
335
  config: OpenClawSection,
278
336
  dryRun: boolean,
279
337
  silent: boolean,
280
- keepOpenClawState: boolean,
338
+ preserveExistingState: boolean,
281
339
  ): Promise<EnsureVersionResult> {
282
340
  const bin = config.bin ?? "openclaw";
283
341
  const installPolicy = config.install ?? "auto";
@@ -343,7 +401,7 @@ export class CommandOpenClawProvider implements OpenClawProvider {
343
401
  );
344
402
  }
345
403
 
346
- if (keepOpenClawState) {
404
+ if (preserveExistingState) {
347
405
  return { installedThisRun: false };
348
406
  }
349
407
 
@@ -492,6 +550,58 @@ export class CommandOpenClawProvider implements OpenClawProvider {
492
550
  await runShell(cmd, dryRun);
493
551
  }
494
552
 
553
+ async bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void> {
554
+ const account = channel.account?.trim();
555
+ if (!account) {
556
+ throw new ClawChefError(`Channel ${channel.channel} requires account for agent binding`);
557
+ }
558
+
559
+ const bin = config.bin ?? "openclaw";
560
+ const customTemplate = config.commands?.bind_channel_agent;
561
+ if (customTemplate?.trim()) {
562
+ const customCmd = fillTemplate(customTemplate, {
563
+ bin,
564
+ version: config.version,
565
+ channel: channel.channel,
566
+ channel_q: shellQuote(channel.channel),
567
+ account,
568
+ account_q: shellQuote(account),
569
+ agent,
570
+ agent_q: shellQuote(agent),
571
+ });
572
+ if (customCmd.trim()) {
573
+ await runShell(customCmd, dryRun);
574
+ }
575
+ return;
576
+ }
577
+
578
+ if (dryRun) {
579
+ return;
580
+ }
581
+
582
+ const getCmd = `${bin} config get bindings --json 2>/dev/null || printf '[]'`;
583
+ const rawBindings = await runShell(getCmd, false);
584
+ const bindings = parseBindingsJson(rawBindings);
585
+ const nextBinding: BindingItem = {
586
+ agentId: agent,
587
+ match: {
588
+ channel: channel.channel,
589
+ accountId: account,
590
+ },
591
+ };
592
+
593
+ const index = bindings.findIndex((item) => isAccountLevelBinding(item, channel.channel, account));
594
+ if (index >= 0) {
595
+ bindings[index] = nextBinding;
596
+ } else {
597
+ bindings.push(nextBinding);
598
+ }
599
+
600
+ const json = JSON.stringify(bindings);
601
+ const setCmd = `${bin} config set bindings ${shellQuote(json)} --json`;
602
+ await runShell(setCmd, false);
603
+ }
604
+
495
605
  async loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void> {
496
606
  if (!channel.login) {
497
607
  return;
@@ -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);
@@ -69,6 +69,15 @@ export class MockOpenClawProvider implements OpenClawProvider {
69
69
  this.state.channels.add(`${channel.channel}::${channel.account ?? "default"}`);
70
70
  }
71
71
 
72
+ async bindChannelAgent(
73
+ _config: OpenClawSection,
74
+ _channel: ChannelDef,
75
+ _agent: string,
76
+ _dryRun: boolean,
77
+ ): Promise<void> {
78
+ return;
79
+ }
80
+
72
81
  async loginChannel(_config: OpenClawSection, _channel: ChannelDef, _dryRun: boolean): Promise<void> {
73
82
  return;
74
83
  }
@@ -11,13 +11,14 @@ 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>;
18
18
  startGateway(config: OpenClawSection, dryRun: boolean): Promise<void>;
19
19
  createWorkspace(config: OpenClawSection, workspace: ResolvedWorkspaceDef, dryRun: boolean): Promise<void>;
20
20
  configureChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
21
+ bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void>;
21
22
  loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
22
23
  materializeFile?(
23
24
  config: OpenClawSection,
@@ -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,
@@ -188,6 +188,19 @@ export class RemoteOpenClawProvider implements OpenClawProvider {
188
188
  await this.perform(config, "configure_channel", { channel }, dryRun);
189
189
  }
190
190
 
191
+ async bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void> {
192
+ await this.perform(
193
+ config,
194
+ "bind_channel_agent",
195
+ {
196
+ channel: channel.channel,
197
+ account: channel.account,
198
+ agent,
199
+ },
200
+ dryRun,
201
+ );
202
+ }
203
+
191
204
  async loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void> {
192
205
  await this.perform(config, "login_channel", { channel }, dryRun);
193
206
  }
@@ -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);
@@ -240,67 +261,81 @@ export async function runRecipe(
240
261
  }
241
262
 
242
263
  for (const channel of recipe.channels ?? []) {
243
- await provider.configureChannel(recipe.openclaw, channel, options.dryRun);
244
- logger.info(`Channel configured: ${channel.channel}${channel.account ? `/${channel.account}` : ""}`);
264
+ const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
265
+ ? { ...channel, account: channel.agent.trim() }
266
+ : channel;
267
+
268
+ await provider.configureChannel(recipe.openclaw, effectiveChannel, options.dryRun);
269
+ logger.info(`Channel configured: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
270
+ if (effectiveChannel.agent?.trim()) {
271
+ await provider.bindChannelAgent(recipe.openclaw, effectiveChannel, effectiveChannel.agent, options.dryRun);
272
+ logger.info(
273
+ `Channel bound to agent: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""} -> ${effectiveChannel.agent}`,
274
+ );
275
+ }
245
276
  }
246
277
 
247
- for (const file of recipe.files ?? []) {
248
- const wsPath = workspacePaths.get(file.workspace);
278
+ for (const workspace of recipe.workspaces ?? []) {
279
+ const wsPath = workspacePaths.get(workspace.name);
249
280
  if (!wsPath) {
250
- throw new ClawChefError(`File target workspace does not exist: ${file.workspace}`);
281
+ throw new ClawChefError(`Workspace does not exist for files: ${workspace.name}`);
251
282
  }
252
283
 
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}`;
284
+ for (const file of workspace.files ?? []) {
285
+ if (provider.materializeFile) {
286
+ let content = file.content;
287
+ if (content === undefined && file.content_from) {
288
+ if (!options.dryRun) {
289
+ const rawContent = await readTextFromRef(recipeOrigin, file.content_from);
290
+ content = renderTemplateString(rawContent, options.vars, options.allowMissing);
291
+ } else {
292
+ const resolved = resolveFileRef(recipeOrigin, file.content_from);
293
+ content = `__dry_run_content_from__:${resolved.value}`;
294
+ }
261
295
  }
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}`;
296
+ if (content === undefined && file.source) {
297
+ if (!options.dryRun) {
298
+ content = await readTextFromRef(recipeOrigin, file.source);
299
+ } else {
300
+ const resolved = resolveFileRef(recipeOrigin, file.source);
301
+ content = `__dry_run_source__:${resolved.value}`;
302
+ }
303
+ }
304
+ if (content === undefined) {
305
+ throw new ClawChefError(`File ${file.path} requires content, content_from, or source`);
269
306
  }
270
- }
271
- if (content === undefined) {
272
- throw new ClawChefError(`File ${file.path} requires content, content_from, or source`);
273
- }
274
307
 
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
- }
308
+ await provider.materializeFile(recipe.openclaw, workspace.name, file.path, content, file.overwrite, options.dryRun);
309
+ logger.info(`File materialized: ${workspace.name}/${file.path}`);
310
+ continue;
311
+ }
279
312
 
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);
313
+ const target = path.resolve(wsPath, file.path);
314
+ const targetDir = path.dirname(target);
315
+
316
+ if (!options.dryRun) {
317
+ await mkdir(targetDir, { recursive: true });
318
+ const alreadyExists = await exists(target);
319
+ if (alreadyExists && file.overwrite === false) {
320
+ logger.warn(`Skipping existing file: ${target}`);
321
+ } else if (file.content !== undefined) {
322
+ await writeFile(target, file.content, "utf8");
323
+ } else if (file.content_from) {
324
+ const rawContent = await readTextFromRef(recipeOrigin, file.content_from);
325
+ const content = renderTemplateString(rawContent, options.vars, options.allowMissing);
326
+ await writeFile(target, content, "utf8");
327
+ } else if (file.source) {
328
+ const resolved = resolveFileRef(recipeOrigin, file.source);
329
+ if (resolved.kind === "local") {
330
+ await copyFile(resolved.value, target);
331
+ } else {
332
+ const content = await readBinaryFromRef(recipeOrigin, file.source);
333
+ await writeFile(target, content);
334
+ }
300
335
  }
301
336
  }
337
+ logger.info(`File materialized: ${workspace.name}/${file.path}`);
302
338
  }
303
- logger.info(`File materialized: ${file.workspace}/${file.path}`);
304
339
  }
305
340
 
306
341
  for (const agent of recipe.agents ?? []) {
@@ -344,16 +379,19 @@ export async function runRecipe(
344
379
  logger.info("Gateway started");
345
380
 
346
381
  for (const channel of recipe.channels ?? []) {
347
- if (!channel.login) {
382
+ const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
383
+ ? { ...channel, account: channel.agent.trim() }
384
+ : channel;
385
+ if (!effectiveChannel.login) {
348
386
  continue;
349
387
  }
350
388
  if (!options.dryRun && !input.isTTY) {
351
389
  throw new ClawChefError(
352
- `Channel login for ${channel.channel} requires an interactive terminal session`,
390
+ `Channel login for ${effectiveChannel.channel} requires an interactive terminal session`,
353
391
  );
354
392
  }
355
- await provider.loginChannel(recipe.openclaw, channel, options.dryRun);
356
- logger.info(`Channel login completed: ${channel.channel}${channel.account ? `/${channel.account}` : ""}`);
393
+ await provider.loginChannel(recipe.openclaw, effectiveChannel, options.dryRun);
394
+ logger.info(`Channel login completed: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
357
395
  }
358
396
 
359
397
  logger.info("Recipe execution completed");