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.
@@ -6,12 +6,13 @@ 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>;
13
13
  createWorkspace(config: OpenClawSection, workspace: ResolvedWorkspaceDef, dryRun: boolean): Promise<void>;
14
14
  configureChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
15
+ bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void>;
15
16
  loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
16
17
  materializeFile?(config: OpenClawSection, workspace: string, filePath: string, content: string, overwrite: boolean | undefined, dryRun: boolean): Promise<void>;
17
18
  createAgent(config: OpenClawSection, agent: AgentDef, workspacePath: string, dryRun: boolean): Promise<void>;
@@ -5,12 +5,13 @@ 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>;
12
12
  createWorkspace(config: OpenClawSection, workspace: ResolvedWorkspaceDef, dryRun: boolean): Promise<void>;
13
13
  configureChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
14
+ bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void>;
14
15
  loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
15
16
  materializeFile(config: OpenClawSection, workspace: string, filePath: string, content: string, overwrite: boolean | undefined, dryRun: boolean): Promise<void>;
16
17
  createAgent(config: OpenClawSection, agent: AgentDef, workspacePath: string, 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);
@@ -119,6 +119,13 @@ export class RemoteOpenClawProvider {
119
119
  async configureChannel(config, channel, dryRun) {
120
120
  await this.perform(config, "configure_channel", { channel }, dryRun);
121
121
  }
122
+ async bindChannelAgent(config, channel, agent, dryRun) {
123
+ await this.perform(config, "bind_channel_agent", {
124
+ channel: channel.channel,
125
+ account: channel.account,
126
+ agent,
127
+ }, dryRun);
128
+ }
122
129
  async loginChannel(config, channel, dryRun) {
123
130
  await this.perform(config, "login_channel", { channel }, dryRun);
124
131
  }
@@ -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 {
@@ -195,68 +215,79 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
195
215
  logger.info(`Agent created: ${agent.workspace}/${agent.name}`);
196
216
  }
197
217
  for (const channel of recipe.channels ?? []) {
198
- await provider.configureChannel(recipe.openclaw, channel, options.dryRun);
199
- logger.info(`Channel configured: ${channel.channel}${channel.account ? `/${channel.account}` : ""}`);
218
+ const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
219
+ ? { ...channel, account: channel.agent.trim() }
220
+ : channel;
221
+ await provider.configureChannel(recipe.openclaw, effectiveChannel, options.dryRun);
222
+ logger.info(`Channel configured: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
223
+ if (effectiveChannel.agent?.trim()) {
224
+ await provider.bindChannelAgent(recipe.openclaw, effectiveChannel, effectiveChannel.agent, options.dryRun);
225
+ logger.info(`Channel bound to agent: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""} -> ${effectiveChannel.agent}`);
226
+ }
200
227
  }
201
- for (const file of recipe.files ?? []) {
202
- const wsPath = workspacePaths.get(file.workspace);
228
+ for (const workspace of recipe.workspaces ?? []) {
229
+ const wsPath = workspacePaths.get(workspace.name);
203
230
  if (!wsPath) {
204
- throw new ClawChefError(`File target workspace does not exist: ${file.workspace}`);
231
+ throw new ClawChefError(`Workspace does not exist for files: ${workspace.name}`);
205
232
  }
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);
233
+ for (const file of workspace.files ?? []) {
234
+ if (provider.materializeFile) {
235
+ let content = file.content;
236
+ if (content === undefined && file.content_from) {
237
+ if (!options.dryRun) {
238
+ const rawContent = await readTextFromRef(recipeOrigin, file.content_from);
239
+ content = renderTemplateString(rawContent, options.vars, options.allowMissing);
240
+ }
241
+ else {
242
+ const resolved = resolveFileRef(recipeOrigin, file.content_from);
243
+ content = `__dry_run_content_from__:${resolved.value}`;
244
+ }
211
245
  }
212
- else {
213
- const resolved = resolveFileRef(recipeOrigin, file.content_from);
214
- content = `__dry_run_content_from__:${resolved.value}`;
246
+ if (content === undefined && file.source) {
247
+ if (!options.dryRun) {
248
+ content = await readTextFromRef(recipeOrigin, file.source);
249
+ }
250
+ else {
251
+ const resolved = resolveFileRef(recipeOrigin, file.source);
252
+ content = `__dry_run_source__:${resolved.value}`;
253
+ }
254
+ }
255
+ if (content === undefined) {
256
+ throw new ClawChefError(`File ${file.path} requires content, content_from, or source`);
215
257
  }
258
+ await provider.materializeFile(recipe.openclaw, workspace.name, file.path, content, file.overwrite, options.dryRun);
259
+ logger.info(`File materialized: ${workspace.name}/${file.path}`);
260
+ continue;
216
261
  }
217
- if (content === undefined && file.source) {
218
- if (!options.dryRun) {
219
- content = await readTextFromRef(recipeOrigin, file.source);
262
+ const target = path.resolve(wsPath, file.path);
263
+ const targetDir = path.dirname(target);
264
+ if (!options.dryRun) {
265
+ await mkdir(targetDir, { recursive: true });
266
+ const alreadyExists = await exists(target);
267
+ if (alreadyExists && file.overwrite === false) {
268
+ logger.warn(`Skipping existing file: ${target}`);
220
269
  }
221
- else {
222
- const resolved = resolveFileRef(recipeOrigin, file.source);
223
- content = `__dry_run_source__:${resolved.value}`;
270
+ else if (file.content !== undefined) {
271
+ await writeFile(target, file.content, "utf8");
224
272
  }
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);
273
+ else if (file.content_from) {
274
+ const rawContent = await readTextFromRef(recipeOrigin, file.content_from);
275
+ const content = renderTemplateString(rawContent, options.vars, options.allowMissing);
276
+ await writeFile(target, content, "utf8");
252
277
  }
253
- else {
254
- const content = await readBinaryFromRef(recipeOrigin, file.source);
255
- await writeFile(target, content);
278
+ else if (file.source) {
279
+ const resolved = resolveFileRef(recipeOrigin, file.source);
280
+ if (resolved.kind === "local") {
281
+ await copyFile(resolved.value, target);
282
+ }
283
+ else {
284
+ const content = await readBinaryFromRef(recipeOrigin, file.source);
285
+ await writeFile(target, content);
286
+ }
256
287
  }
257
288
  }
289
+ logger.info(`File materialized: ${workspace.name}/${file.path}`);
258
290
  }
259
- logger.info(`File materialized: ${file.workspace}/${file.path}`);
260
291
  }
261
292
  for (const agent of recipe.agents ?? []) {
262
293
  for (const skill of agent.skills ?? []) {
@@ -295,14 +326,17 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
295
326
  await provider.startGateway(recipe.openclaw, options.dryRun);
296
327
  logger.info("Gateway started");
297
328
  for (const channel of recipe.channels ?? []) {
298
- if (!channel.login) {
329
+ const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
330
+ ? { ...channel, account: channel.agent.trim() }
331
+ : channel;
332
+ if (!effectiveChannel.login) {
299
333
  continue;
300
334
  }
301
335
  if (!options.dryRun && !input.isTTY) {
302
- throw new ClawChefError(`Channel login for ${channel.channel} requires an interactive terminal session`);
336
+ throw new ClawChefError(`Channel login for ${effectiveChannel.channel} requires an interactive terminal session`);
303
337
  }
304
- await provider.loginChannel(recipe.openclaw, channel, options.dryRun);
305
- logger.info(`Channel login completed: ${channel.channel}${channel.account ? `/${channel.account}` : ""}`);
338
+ await provider.loginChannel(recipe.openclaw, effectiveChannel, options.dryRun);
339
+ logger.info(`Channel login completed: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
306
340
  }
307
341
  logger.info("Recipe execution completed");
308
342
  }
package/dist/recipe.js CHANGED
@@ -8,23 +8,17 @@ import { recipeSchema } from "./schema.js";
8
8
  import { ClawChefError } from "./errors.js";
9
9
  import { deepResolveTemplates } from "./template.js";
10
10
  const AUTH_CHOICE_TO_FIELD = {
11
- "openai-api-key": "openai_api_key",
12
- "anthropic-api-key": "anthropic_api_key",
13
- "openrouter-api-key": "openrouter_api_key",
14
- "xai-api-key": "xai_api_key",
15
- "gemini-api-key": "gemini_api_key",
16
- "ai-gateway-api-key": "ai_gateway_api_key",
17
- "cloudflare-ai-gateway-api-key": "cloudflare_ai_gateway_api_key",
11
+ "openai-api-key": "llm_api_key",
12
+ "anthropic-api-key": "llm_api_key",
13
+ "openrouter-api-key": "llm_api_key",
14
+ "xai-api-key": "llm_api_key",
15
+ "gemini-api-key": "llm_api_key",
16
+ "ai-gateway-api-key": "llm_api_key",
17
+ "cloudflare-ai-gateway-api-key": "llm_api_key",
18
18
  token: "token",
19
19
  };
20
20
  const SECRET_BOOTSTRAP_FIELDS = [
21
- "openai_api_key",
22
- "anthropic_api_key",
23
- "openrouter_api_key",
24
- "xai_api_key",
25
- "gemini_api_key",
26
- "ai_gateway_api_key",
27
- "cloudflare_ai_gateway_api_key",
21
+ "llm_api_key",
28
22
  "token",
29
23
  ];
30
24
  const ALLOWED_CHANNELS = new Set([
@@ -86,7 +80,7 @@ function assertNoInlineSecrets(recipe) {
86
80
  }
87
81
  }
88
82
  }
89
- function collectVars(recipe, cliVars) {
83
+ function collectVars(recipe, cliVars, requiredKeys) {
90
84
  const vars = {};
91
85
  const params = recipe.params ?? {};
92
86
  for (const [envKey, envValue] of Object.entries(process.env)) {
@@ -114,7 +108,7 @@ function collectVars(recipe, cliVars) {
114
108
  vars[key] = def.default;
115
109
  continue;
116
110
  }
117
- if (def.required) {
111
+ if (def.required && (requiredKeys === undefined || requiredKeys.has(key))) {
118
112
  throw new ClawChefError(`Parameter ${key} is required but was not provided via --var or environment`);
119
113
  }
120
114
  }
@@ -123,21 +117,51 @@ function collectVars(recipe, cliVars) {
123
117
  }
124
118
  return vars;
125
119
  }
120
+ function projectRecipeForScope(recipe, options) {
121
+ if (options.scope !== "workspace") {
122
+ return recipe;
123
+ }
124
+ return {
125
+ ...recipe,
126
+ openclaw: {
127
+ ...recipe.openclaw,
128
+ bootstrap: undefined,
129
+ },
130
+ channels: [],
131
+ conversations: [],
132
+ };
133
+ }
134
+ function filterRecipeByWorkspaceName(recipe, workspaceName) {
135
+ const workspace = (recipe.workspaces ?? []).find((ws) => ws.name === workspaceName);
136
+ if (!workspace) {
137
+ throw new ClawChefError(`Workspace not found in recipe: ${workspaceName}`);
138
+ }
139
+ return {
140
+ ...recipe,
141
+ workspaces: [workspace],
142
+ agents: (recipe.agents ?? []).filter((agent) => agent.workspace === workspaceName),
143
+ conversations: (recipe.conversations ?? []).filter((conv) => conv.workspace === workspaceName),
144
+ };
145
+ }
126
146
  function semanticValidate(recipe) {
127
147
  const ws = new Set((recipe.workspaces ?? []).map((w) => w.name));
148
+ const agentNameCounts = new Map();
128
149
  for (const workspace of recipe.workspaces ?? []) {
129
150
  if (workspace.assets !== undefined && !workspace.assets.trim()) {
130
151
  throw new ClawChefError(`Workspace ${workspace.name} has empty assets path`);
131
152
  }
132
153
  }
133
154
  for (const agent of recipe.agents ?? []) {
155
+ agentNameCounts.set(agent.name, (agentNameCounts.get(agent.name) ?? 0) + 1);
134
156
  if (!ws.has(agent.workspace)) {
135
157
  throw new ClawChefError(`Agent ${agent.name} references missing workspace: ${agent.workspace}`);
136
158
  }
137
159
  }
138
- for (const file of recipe.files ?? []) {
139
- if (!ws.has(file.workspace)) {
140
- throw new ClawChefError(`File ${file.path} references missing workspace: ${file.workspace}`);
160
+ for (const workspace of recipe.workspaces ?? []) {
161
+ for (const file of workspace.files ?? []) {
162
+ if (!file.path.trim()) {
163
+ throw new ClawChefError(`Workspace ${workspace.name} has file with empty path`);
164
+ }
141
165
  }
142
166
  }
143
167
  const agents = new Set((recipe.agents ?? []).map((a) => `${a.workspace}::${a.name}`));
@@ -157,6 +181,18 @@ function semanticValidate(recipe) {
157
181
  (channel.login || channel.login_mode !== undefined || channel.login_account !== undefined)) {
158
182
  throw new ClawChefError("channels[] entry for telegram does not support login/login_mode/login_account. Configure token (or use_env/token_file), then start gateway.");
159
183
  }
184
+ if (channel.agent?.trim()) {
185
+ if (channel.channel !== "telegram") {
186
+ throw new ClawChefError(`channels[] entry for ${channel.channel} does not support agent binding. Use channel: telegram with agent.`);
187
+ }
188
+ const matched = agentNameCounts.get(channel.agent) ?? 0;
189
+ if (matched === 0) {
190
+ throw new ClawChefError(`channels[] entry references missing agent by name: ${channel.agent}`);
191
+ }
192
+ if (matched > 1) {
193
+ throw new ClawChefError(`channels[] entry references duplicate agent name: ${channel.agent}. Agent names must be unique for channel binding.`);
194
+ }
195
+ }
160
196
  const hasAuth = Boolean(channel.use_env) ||
161
197
  Boolean(channel.token?.trim()) ||
162
198
  Boolean(channel.token_file?.trim()) ||
@@ -609,16 +645,27 @@ export async function loadRecipe(recipePath, options) {
609
645
  if (!firstParse.success) {
610
646
  throw new ClawChefError(`Recipe format is invalid: ${firstParse.error.message}`);
611
647
  }
612
- assertNoInlineSecrets(firstParse.data);
613
- const vars = collectVars(firstParse.data, options.vars);
614
- const rendered = deepResolveTemplates(firstParse.data, vars, options.allowMissing);
648
+ const projected = projectRecipeForScope(firstParse.data, options);
649
+ assertNoInlineSecrets(projected);
650
+ const requiredKeys = options.scope === "workspace" ? new Set() : undefined;
651
+ const vars = collectVars(projected, options.vars, requiredKeys);
652
+ const rendered = deepResolveTemplates(projected, vars, options.allowMissing);
615
653
  const secondParse = recipeSchema.safeParse(rendered);
616
654
  if (!secondParse.success) {
617
655
  throw new ClawChefError(`Recipe is invalid after parameter resolution: ${secondParse.error.message}`);
618
656
  }
619
- semanticValidate(secondParse.data);
657
+ const scopedRecipe = (() => {
658
+ if (options.scope !== "workspace") {
659
+ return secondParse.data;
660
+ }
661
+ if (!options.workspaceName) {
662
+ throw new ClawChefError("scope=workspace requires a workspace name");
663
+ }
664
+ return filterRecipeByWorkspaceName(secondParse.data, options.workspaceName);
665
+ })();
666
+ semanticValidate(scopedRecipe);
620
667
  return {
621
- recipe: secondParse.data,
668
+ recipe: scopedRecipe,
622
669
  origin: recipeRef.origin,
623
670
  };
624
671
  });