clawchef 0.1.12 → 0.1.13

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.
@@ -5,6 +5,10 @@ export type ResolvedWorkspaceDef = WorkspaceDef & {
5
5
  export interface EnsureVersionResult {
6
6
  installedThisRun: boolean;
7
7
  }
8
+ export interface ChannelAgentBinding {
9
+ channel: ChannelDef;
10
+ agent: string;
11
+ }
8
12
  export interface OpenClawProvider {
9
13
  ensureVersion(config: OpenClawSection, dryRun: boolean, silent: boolean, preserveExistingState: boolean): Promise<EnsureVersionResult>;
10
14
  installPlugin(config: OpenClawSection, pluginSpec: string, dryRun: boolean): Promise<void>;
@@ -12,6 +16,8 @@ export interface OpenClawProvider {
12
16
  startGateway(config: OpenClawSection, mode: GatewayMode, dryRun: boolean): Promise<void>;
13
17
  createWorkspace(config: OpenClawSection, workspace: ResolvedWorkspaceDef, dryRun: boolean): Promise<void>;
14
18
  configureChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
19
+ applyConfigPatch(config: OpenClawSection, patch: Record<string, unknown>, dryRun: boolean): Promise<void>;
20
+ bindChannelAgents?(config: OpenClawSection, bindings: ChannelAgentBinding[], dryRun: boolean): Promise<void>;
15
21
  bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void>;
16
22
  loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
17
23
  materializeFile?(config: OpenClawSection, workspace: string, filePath: string, content: string, overwrite: boolean | undefined, dryRun: boolean): Promise<void>;
@@ -3,7 +3,9 @@ import type { EnsureVersionResult, OpenClawProvider, ResolvedWorkspaceDef } from
3
3
  export declare class RemoteOpenClawProvider implements OpenClawProvider {
4
4
  private readonly stagedMessages;
5
5
  private readonly remoteConfig;
6
- constructor(remoteConfig: Partial<OpenClawRemoteConfig>);
6
+ private readonly verboseEnabled;
7
+ constructor(remoteConfig: Partial<OpenClawRemoteConfig>, verboseEnabled?: boolean);
8
+ private debug;
7
9
  private perform;
8
10
  ensureVersion(config: OpenClawSection, dryRun: boolean, _silent: boolean, _preserveExistingState: boolean): Promise<EnsureVersionResult>;
9
11
  installPlugin(config: OpenClawSection, pluginSpec: string, dryRun: boolean): Promise<void>;
@@ -11,6 +13,7 @@ export declare class RemoteOpenClawProvider implements OpenClawProvider {
11
13
  startGateway(config: OpenClawSection, mode: GatewayMode, dryRun: boolean): Promise<void>;
12
14
  createWorkspace(config: OpenClawSection, workspace: ResolvedWorkspaceDef, dryRun: boolean): Promise<void>;
13
15
  configureChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
16
+ applyConfigPatch(config: OpenClawSection, patch: Record<string, unknown>, dryRun: boolean): Promise<void>;
14
17
  bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void>;
15
18
  loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
16
19
  materializeFile(config: OpenClawSection, workspace: string, filePath: string, content: string, overwrite: boolean | undefined, dryRun: boolean): Promise<void>;
@@ -1,6 +1,16 @@
1
1
  import { ClawChefError } from "../errors.js";
2
2
  const DEFAULT_TIMEOUT_MS = 60_000;
3
3
  const DEFAULT_OPERATION_PATH = "/v1/clawchef/operation";
4
+ function timestamp() {
5
+ const now = new Date();
6
+ const year = now.getFullYear();
7
+ const month = String(now.getMonth() + 1).padStart(2, "0");
8
+ const day = String(now.getDate()).padStart(2, "0");
9
+ const hours = String(now.getHours()).padStart(2, "0");
10
+ const minutes = String(now.getMinutes()).padStart(2, "0");
11
+ const seconds = String(now.getSeconds()).padStart(2, "0");
12
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
13
+ }
4
14
  function parseResponseBody(raw) {
5
15
  if (!raw.trim()) {
6
16
  return {};
@@ -50,11 +60,20 @@ function assertRemoteConfig(remote) {
50
60
  export class RemoteOpenClawProvider {
51
61
  stagedMessages = new Map();
52
62
  remoteConfig;
53
- constructor(remoteConfig) {
63
+ verboseEnabled;
64
+ constructor(remoteConfig, verboseEnabled = false) {
54
65
  this.remoteConfig = remoteConfig;
66
+ this.verboseEnabled = verboseEnabled;
67
+ }
68
+ debug(message) {
69
+ if (!this.verboseEnabled) {
70
+ return;
71
+ }
72
+ process.stdout.write(`[${timestamp()}] [DEBUG] ${message}\n`);
55
73
  }
56
74
  async perform(config, operation, payload, dryRun) {
57
75
  if (dryRun) {
76
+ this.debug(`REMOTE DRY-RUN op=${operation}`);
58
77
  return { ok: true };
59
78
  }
60
79
  const remote = assertRemoteConfig(this.remoteConfig);
@@ -66,6 +85,8 @@ export class RemoteOpenClawProvider {
66
85
  recipe_version: config.version,
67
86
  payload,
68
87
  };
88
+ const startedAt = Date.now();
89
+ this.debug(`REMOTE START op=${operation} url=${operationUrl(remote)}`);
69
90
  try {
70
91
  const response = await fetch(operationUrl(remote), {
71
92
  method: "POST",
@@ -81,9 +102,11 @@ export class RemoteOpenClawProvider {
81
102
  if (parsed.ok === false) {
82
103
  throw new ClawChefError(`Remote operation failed for ${operation}: ${parsed.message ?? "unknown error"}`);
83
104
  }
105
+ this.debug(`REMOTE DONE op=${operation} status=${response.status} (${Date.now() - startedAt}ms)`);
84
106
  return parsed;
85
107
  }
86
108
  catch (err) {
109
+ this.debug(`REMOTE FAIL op=${operation} (${Date.now() - startedAt}ms)`);
87
110
  if (err instanceof ClawChefError) {
88
111
  throw err;
89
112
  }
@@ -122,6 +145,9 @@ export class RemoteOpenClawProvider {
122
145
  async configureChannel(config, channel, dryRun) {
123
146
  await this.perform(config, "configure_channel", { channel }, dryRun);
124
147
  }
148
+ async applyConfigPatch(config, patch, dryRun) {
149
+ await this.perform(config, "apply_config_patch", { patch }, dryRun);
150
+ }
125
151
  async bindChannelAgent(config, channel, agent, dryRun) {
126
152
  await this.perform(config, "bind_channel_agent", {
127
153
  channel: channel.channel,
@@ -22,6 +22,30 @@ function truncateForLog(text, maxLength = 500) {
22
22
  }
23
23
  return `${text.slice(0, maxLength)}... [truncated ${text.length - maxLength} chars]`;
24
24
  }
25
+ function toPosixPath(value) {
26
+ return value.replaceAll("\\", "/");
27
+ }
28
+ function wildcardToRegExp(pattern) {
29
+ const escaped = toPosixPath(pattern)
30
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
31
+ .replace(/\*\*/g, "___DOUBLE_STAR___")
32
+ .replace(/\*/g, "[^/]*")
33
+ .replace(/___DOUBLE_STAR___/g, ".*");
34
+ return new RegExp(`^${escaped}$`);
35
+ }
36
+ function matchesFilePatterns(patterns, relativePath) {
37
+ if (patterns.length === 0) {
38
+ return true;
39
+ }
40
+ const normalized = toPosixPath(relativePath);
41
+ return patterns.some((pattern) => wildcardToRegExp(pattern).test(normalized));
42
+ }
43
+ function matchesAnyFilePattern(patterns, candidates) {
44
+ if (patterns.length === 0) {
45
+ return true;
46
+ }
47
+ return candidates.some((candidate) => matchesFilePatterns(patterns, candidate));
48
+ }
25
49
  function renderTemplateString(input, vars, allowMissing) {
26
50
  return input.replace(/\$\{([^}]+)\}/g, (_match, rawKey) => {
27
51
  const key = String(rawKey).trim();
@@ -162,30 +186,37 @@ async function confirmFactoryReset(options) {
162
186
  export async function runRecipe(recipe, recipeOrigin, options, logger) {
163
187
  const provider = createProvider(options);
164
188
  const remoteMode = options.provider === "remote";
189
+ const filesOnlyScope = options.scope === "files";
190
+ const fileFilterEnabled = filesOnlyScope && options.filePatterns.length > 0;
165
191
  const workspacePaths = new Map();
166
192
  const preserveExistingState = options.scope !== "full";
167
193
  logger.info(`Running recipe: ${recipe.name}`);
168
- const versionResult = await provider.ensureVersion(recipe.openclaw, options.dryRun, options.silent, preserveExistingState);
169
- logger.info(`OpenClaw version ready: ${recipe.openclaw.version}`);
170
- if (versionResult.installedThisRun) {
171
- logger.info("OpenClaw was installed in this run; skipping factory reset");
172
- }
173
- else if (preserveExistingState) {
174
- logger.info("Keeping existing OpenClaw state; skipping factory reset");
175
- }
176
- else {
177
- const confirmed = await confirmFactoryReset(options);
178
- if (!confirmed) {
179
- throw new ClawChefError("Aborted by user before factory reset");
194
+ if (!filesOnlyScope) {
195
+ const versionResult = await provider.ensureVersion(recipe.openclaw, options.dryRun, options.silent, preserveExistingState);
196
+ logger.info(`OpenClaw version ready: ${recipe.openclaw.version}`);
197
+ if (versionResult.installedThisRun) {
198
+ logger.info("OpenClaw was installed in this run; skipping factory reset");
199
+ }
200
+ else if (preserveExistingState) {
201
+ logger.info("Keeping existing OpenClaw state; skipping factory reset");
202
+ }
203
+ else {
204
+ const confirmed = await confirmFactoryReset(options);
205
+ if (!confirmed) {
206
+ throw new ClawChefError("Aborted by user before factory reset");
207
+ }
208
+ await provider.factoryReset(recipe.openclaw, options.dryRun);
209
+ logger.info("Factory reset completed");
210
+ }
211
+ const pluginSpecs = Array.from(new Set([...(recipe.openclaw.plugins ?? []), ...options.plugins].map((v) => v.trim())))
212
+ .filter((v) => v.length > 0);
213
+ for (const pluginSpec of pluginSpecs) {
214
+ await provider.installPlugin(recipe.openclaw, pluginSpec, options.dryRun);
215
+ logger.info(`Plugin preinstalled: ${pluginSpec}`);
180
216
  }
181
- await provider.factoryReset(recipe.openclaw, options.dryRun);
182
- logger.info("Factory reset completed");
183
217
  }
184
- const pluginSpecs = Array.from(new Set([...(recipe.openclaw.plugins ?? []), ...options.plugins].map((v) => v.trim())))
185
- .filter((v) => v.length > 0);
186
- for (const pluginSpec of pluginSpecs) {
187
- await provider.installPlugin(recipe.openclaw, pluginSpec, options.dryRun);
188
- logger.info(`Plugin preinstalled: ${pluginSpec}`);
218
+ else {
219
+ logger.info("Scope files: only syncing root/workspace assets and files");
189
220
  }
190
221
  const root = recipe.openclaw.root;
191
222
  if (root && (root.assets?.trim() || (root.files?.length ?? 0) > 0)) {
@@ -224,6 +255,11 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
224
255
  else {
225
256
  const assetFiles = await collectLocalAssetFiles(resolvedAssets.value);
226
257
  for (const assetFile of assetFiles) {
258
+ const patternCandidates = [assetFile.relativePath, `root/${assetFile.relativePath}`];
259
+ if (fileFilterEnabled && !matchesAnyFilePattern(options.filePatterns, patternCandidates)) {
260
+ logger.debug(`Filtered root asset: ${assetFile.relativePath}`);
261
+ continue;
262
+ }
227
263
  const target = path.resolve(openclawRootPath, assetFile.relativePath);
228
264
  if (!options.dryRun) {
229
265
  await mkdir(path.dirname(target), { recursive: true });
@@ -234,6 +270,11 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
234
270
  }
235
271
  }
236
272
  for (const file of root.files ?? []) {
273
+ const patternCandidates = [file.path, `root/${file.path}`];
274
+ if (fileFilterEnabled && !matchesAnyFilePattern(options.filePatterns, patternCandidates)) {
275
+ logger.debug(`Filtered root file: ${file.path}`);
276
+ continue;
277
+ }
237
278
  const target = path.resolve(openclawRootPath, file.path);
238
279
  const targetDir = path.dirname(target);
239
280
  if (!options.dryRun) {
@@ -270,8 +311,10 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
270
311
  if (!options.dryRun && !remoteMode) {
271
312
  await mkdir(absPath, { recursive: true });
272
313
  }
273
- await provider.createWorkspace(recipe.openclaw, { ...ws, path: absPath }, options.dryRun);
274
- logger.info(`Workspace created: ${ws.name}`);
314
+ if (!filesOnlyScope) {
315
+ await provider.createWorkspace(recipe.openclaw, { ...ws, path: absPath }, options.dryRun);
316
+ logger.info(`Workspace created: ${ws.name}`);
317
+ }
275
318
  if (!ws.assets?.trim()) {
276
319
  continue;
277
320
  }
@@ -296,6 +339,15 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
296
339
  }
297
340
  const assetFiles = await collectLocalAssetFiles(resolvedAssets.value);
298
341
  for (const assetFile of assetFiles) {
342
+ const patternCandidates = [
343
+ assetFile.relativePath,
344
+ `${ws.name}/${assetFile.relativePath}`,
345
+ `workspace-${ws.name}/${assetFile.relativePath}`,
346
+ ];
347
+ if (fileFilterEnabled && !matchesAnyFilePattern(options.filePatterns, patternCandidates)) {
348
+ logger.debug(`Filtered workspace asset: ${ws.name}/${assetFile.relativePath}`);
349
+ continue;
350
+ }
299
351
  if (provider.materializeFile) {
300
352
  const content = await readFile(assetFile.absolutePath, "utf8");
301
353
  await provider.materializeFile(recipe.openclaw, ws.name, assetFile.relativePath, content, true, options.dryRun);
@@ -310,36 +362,64 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
310
362
  logger.info(`Workspace asset copied: ${ws.name}/${assetFile.relativePath}`);
311
363
  }
312
364
  }
313
- for (const agent of recipe.agents ?? []) {
314
- const workspacePath = workspacePaths.get(agent.workspace);
315
- if (!workspacePath) {
316
- throw new ClawChefError(`Agent references missing workspace: ${agent.workspace}`);
365
+ if (!filesOnlyScope) {
366
+ const pendingChannelBindings = [];
367
+ for (const agent of recipe.agents ?? []) {
368
+ const workspacePath = workspacePaths.get(agent.workspace);
369
+ if (!workspacePath) {
370
+ throw new ClawChefError(`Agent references missing workspace: ${agent.workspace}`);
371
+ }
372
+ await provider.createAgent(recipe.openclaw, agent, workspacePath, options.dryRun);
373
+ logger.info(`Agent created: ${agent.workspace}/${agent.name}`);
317
374
  }
318
- await provider.createAgent(recipe.openclaw, agent, workspacePath, options.dryRun);
319
- logger.info(`Agent created: ${agent.workspace}/${agent.name}`);
320
- }
321
- for (const channel of recipe.channels ?? []) {
322
- const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
323
- ? { ...channel, account: channel.agent.trim() }
324
- : channel;
325
- const autoDisabledTelegram = shouldAutoDisableTelegramChannel(effectiveChannel);
326
- await provider.configureChannel(recipe.openclaw, effectiveChannel, options.dryRun);
327
- if (autoDisabledTelegram) {
328
- logger.info(`Telegram channel disabled due to empty bot token: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
329
- continue;
375
+ for (const channel of recipe.channels ?? []) {
376
+ const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
377
+ ? { ...channel, account: channel.agent.trim() }
378
+ : channel;
379
+ const autoDisabledTelegram = shouldAutoDisableTelegramChannel(effectiveChannel);
380
+ await provider.configureChannel(recipe.openclaw, effectiveChannel, options.dryRun);
381
+ if (autoDisabledTelegram) {
382
+ logger.info(`Telegram channel disabled due to empty bot token: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
383
+ continue;
384
+ }
385
+ logger.info(`Channel configured: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
386
+ if (effectiveChannel.agent?.trim()) {
387
+ pendingChannelBindings.push({ channel: effectiveChannel, agent: effectiveChannel.agent });
388
+ }
330
389
  }
331
- logger.info(`Channel configured: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
332
- if (effectiveChannel.agent?.trim()) {
333
- await provider.bindChannelAgent(recipe.openclaw, effectiveChannel, effectiveChannel.agent, options.dryRun);
334
- logger.info(`Channel bound to agent: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""} -> ${effectiveChannel.agent}`);
390
+ if (pendingChannelBindings.length > 0) {
391
+ if (provider.bindChannelAgents) {
392
+ await provider.bindChannelAgents(recipe.openclaw, pendingChannelBindings, options.dryRun);
393
+ }
394
+ else {
395
+ for (const binding of pendingChannelBindings) {
396
+ await provider.bindChannelAgent(recipe.openclaw, binding.channel, binding.agent, options.dryRun);
397
+ }
398
+ }
399
+ for (const binding of pendingChannelBindings) {
400
+ logger.info(`Channel bound to agent: ${binding.channel.channel}${binding.channel.account ? `/${binding.channel.account}` : ""} -> ${binding.agent}`);
401
+ }
335
402
  }
336
403
  }
404
+ if (!filesOnlyScope && recipe.openclaw.config_patch) {
405
+ await provider.applyConfigPatch(recipe.openclaw, recipe.openclaw.config_patch, options.dryRun);
406
+ logger.info("OpenClaw config patch applied");
407
+ }
337
408
  for (const workspace of recipe.workspaces ?? []) {
338
409
  const wsPath = workspacePaths.get(workspace.name);
339
410
  if (!wsPath) {
340
411
  throw new ClawChefError(`Workspace does not exist for files: ${workspace.name}`);
341
412
  }
342
413
  for (const file of workspace.files ?? []) {
414
+ const patternCandidates = [
415
+ file.path,
416
+ `${workspace.name}/${file.path}`,
417
+ `workspace-${workspace.name}/${file.path}`,
418
+ ];
419
+ if (fileFilterEnabled && !matchesAnyFilePattern(options.filePatterns, patternCandidates)) {
420
+ logger.debug(`Filtered workspace file: ${workspace.name}/${file.path}`);
421
+ continue;
422
+ }
343
423
  if (provider.materializeFile) {
344
424
  let content = file.content;
345
425
  if (content === undefined && file.content_from) {
@@ -398,59 +478,61 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
398
478
  logger.info(`File materialized: ${workspace.name}/${file.path}`);
399
479
  }
400
480
  }
401
- for (const agent of recipe.agents ?? []) {
402
- for (const skill of agent.skills ?? []) {
403
- await provider.installSkill(recipe.openclaw, agent.workspace, agent.name, skill, options.dryRun);
404
- logger.info(`Skill installed: ${agent.workspace}/${agent.name} -> ${skill}`);
481
+ if (!filesOnlyScope) {
482
+ for (const agent of recipe.agents ?? []) {
483
+ for (const skill of agent.skills ?? []) {
484
+ await provider.installSkill(recipe.openclaw, agent.workspace, agent.name, skill, options.dryRun);
485
+ logger.info(`Skill installed: ${agent.workspace}/${agent.name} -> ${skill}`);
486
+ }
405
487
  }
406
- }
407
- for (const conv of recipe.conversations ?? []) {
408
- for (const msg of conv.messages) {
409
- await provider.sendMessage(recipe.openclaw, conv, msg.content, options.dryRun);
410
- const shouldRun = conv.run ?? Boolean(msg.expect);
411
- if (shouldRun) {
412
- if (options.dryRun) {
413
- logger.info(`dry-run: skipping execution and output assertions: ${conv.workspace}/${conv.agent}`);
414
- continue;
415
- }
416
- const reply = await provider.runAgent(recipe.openclaw, conv, options.dryRun);
417
- if (msg.expect) {
418
- try {
419
- validateReply(reply, msg.expect);
420
- logger.info(`Output assertions passed: ${conv.workspace}/${conv.agent}`);
488
+ for (const conv of recipe.conversations ?? []) {
489
+ for (const msg of conv.messages) {
490
+ await provider.sendMessage(recipe.openclaw, conv, msg.content, options.dryRun);
491
+ const shouldRun = conv.run ?? Boolean(msg.expect);
492
+ if (shouldRun) {
493
+ if (options.dryRun) {
494
+ logger.info(`dry-run: skipping execution and output assertions: ${conv.workspace}/${conv.agent}`);
495
+ continue;
421
496
  }
422
- catch (err) {
423
- logger.warn(`Assertion failed reply (truncated): ${truncateForLog(reply)}`);
424
- throw err;
497
+ const reply = await provider.runAgent(recipe.openclaw, conv, options.dryRun);
498
+ if (msg.expect) {
499
+ try {
500
+ validateReply(reply, msg.expect);
501
+ logger.info(`Output assertions passed: ${conv.workspace}/${conv.agent}`);
502
+ }
503
+ catch (err) {
504
+ logger.warn(`Assertion failed reply (truncated): ${truncateForLog(reply)}`);
505
+ throw err;
506
+ }
425
507
  }
508
+ else {
509
+ logger.info(`Agent executed: ${conv.workspace}/${conv.agent}`);
510
+ }
511
+ logger.debug(`Agent output: ${reply}`);
426
512
  }
427
- else {
428
- logger.info(`Agent executed: ${conv.workspace}/${conv.agent}`);
429
- }
430
- logger.debug(`Agent output: ${reply}`);
431
513
  }
514
+ logger.info(`Preset messages sent: ${conv.workspace}/${conv.agent}`);
432
515
  }
433
- logger.info(`Preset messages sent: ${conv.workspace}/${conv.agent}`);
434
- }
435
- await provider.startGateway(recipe.openclaw, options.gatewayMode, options.dryRun);
436
- if (options.gatewayMode === "none") {
437
- logger.info("Gateway start skipped by gateway mode: none");
438
- }
439
- else {
440
- logger.info(`Gateway started (${options.gatewayMode})`);
441
- }
442
- for (const channel of recipe.channels ?? []) {
443
- const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
444
- ? { ...channel, account: channel.agent.trim() }
445
- : channel;
446
- if (!effectiveChannel.login) {
447
- continue;
516
+ await provider.startGateway(recipe.openclaw, options.gatewayMode, options.dryRun);
517
+ if (options.gatewayMode === "none") {
518
+ logger.info("Gateway start skipped by gateway mode: none");
519
+ }
520
+ else {
521
+ logger.info(`Gateway started (${options.gatewayMode})`);
448
522
  }
449
- if (!options.dryRun && !input.isTTY) {
450
- throw new ClawChefError(`Channel login for ${effectiveChannel.channel} requires an interactive terminal session`);
523
+ for (const channel of recipe.channels ?? []) {
524
+ const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
525
+ ? { ...channel, account: channel.agent.trim() }
526
+ : channel;
527
+ if (!effectiveChannel.login) {
528
+ continue;
529
+ }
530
+ if (!options.dryRun && !input.isTTY) {
531
+ throw new ClawChefError(`Channel login for ${effectiveChannel.channel} requires an interactive terminal session`);
532
+ }
533
+ await provider.loginChannel(recipe.openclaw, effectiveChannel, options.dryRun);
534
+ logger.info(`Channel login completed: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
451
535
  }
452
- await provider.loginChannel(recipe.openclaw, effectiveChannel, options.dryRun);
453
- logger.info(`Channel login completed: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
454
536
  }
455
537
  logger.info("Recipe execution completed");
456
538
  }
package/dist/recipe.js CHANGED
@@ -44,6 +44,27 @@ const ALLOWED_CHANNELS = new Set([
44
44
  ]);
45
45
  const CHANNEL_SECRET_FIELDS = ["token", "bot_token", "access_token", "app_token", "password"];
46
46
  const TEMPLATE_TOKEN_RE = /\$\{[A-Za-z_][A-Za-z0-9_]*\}/;
47
+ function assertNoInlineSecretsInObject(value, pathLabel) {
48
+ if (Array.isArray(value)) {
49
+ for (let i = 0; i < value.length; i += 1) {
50
+ assertNoInlineSecretsInObject(value[i], `${pathLabel}[${i}]`);
51
+ }
52
+ return;
53
+ }
54
+ if (!value || typeof value !== "object") {
55
+ return;
56
+ }
57
+ for (const [key, nestedValue] of Object.entries(value)) {
58
+ const nextPath = `${pathLabel}.${key}`;
59
+ if (typeof nestedValue === "string" &&
60
+ /(token|password|secret|api[_-]?key|webhook)/i.test(key) &&
61
+ nestedValue.trim().length > 0 &&
62
+ !TEMPLATE_TOKEN_RE.test(nestedValue)) {
63
+ throw new ClawChefError(`Inline secret in ${nextPath} is not allowed. Use \${var} and pass it via --var or CLAWCHEF_VAR_*`);
64
+ }
65
+ assertNoInlineSecretsInObject(nestedValue, nextPath);
66
+ }
67
+ }
47
68
  function hasExplicitEmptyTelegramToken(channel) {
48
69
  if (channel.channel !== "telegram") {
49
70
  return false;
@@ -65,6 +86,9 @@ function assertNoInlineSecrets(recipe) {
65
86
  }
66
87
  }
67
88
  }
89
+ if (recipe.openclaw.config_patch) {
90
+ assertNoInlineSecretsInObject(recipe.openclaw.config_patch, "openclaw.config_patch");
91
+ }
68
92
  for (const channel of recipe.channels ?? []) {
69
93
  for (const field of CHANNEL_SECRET_FIELDS) {
70
94
  const value = channel[field];
@@ -126,6 +150,20 @@ function collectVars(recipe, cliVars, requiredKeys) {
126
150
  return vars;
127
151
  }
128
152
  function projectRecipeForScope(recipe, options) {
153
+ if (options.scope === "files") {
154
+ return {
155
+ ...recipe,
156
+ openclaw: {
157
+ ...recipe.openclaw,
158
+ version: "0.0.0",
159
+ bootstrap: undefined,
160
+ plugins: [],
161
+ },
162
+ channels: [],
163
+ agents: [],
164
+ conversations: [],
165
+ };
166
+ }
129
167
  if (options.scope !== "workspace") {
130
168
  return recipe;
131
169
  }
@@ -673,7 +711,7 @@ export async function loadRecipe(recipePath, options) {
673
711
  }
674
712
  const projected = projectRecipeForScope(firstParse.data, options);
675
713
  assertNoInlineSecrets(projected);
676
- const requiredKeys = options.scope === "workspace" ? new Set() : undefined;
714
+ const requiredKeys = options.scope === "workspace" || options.scope === "files" ? new Set() : undefined;
677
715
  const vars = collectVars(projected, options.vars, requiredKeys);
678
716
  const rendered = deepResolveTemplates(projected, vars, options.allowMissing);
679
717
  const secondParse = recipeSchema.safeParse(rendered);
package/dist/schema.d.ts CHANGED
@@ -75,6 +75,7 @@ export declare const recipeSchema: z.ZodObject<{
75
75
  }[] | undefined;
76
76
  assets?: string | undefined;
77
77
  }>>;
78
+ config_patch: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
78
79
  bootstrap: z.ZodOptional<z.ZodObject<{
79
80
  non_interactive: z.ZodOptional<z.ZodBoolean>;
80
81
  accept_risk: z.ZodOptional<z.ZodBoolean>;
@@ -201,6 +202,7 @@ export declare const recipeSchema: z.ZodObject<{
201
202
  }[] | undefined;
202
203
  assets?: string | undefined;
203
204
  } | undefined;
205
+ config_patch?: Record<string, unknown> | undefined;
204
206
  bootstrap?: {
205
207
  workspace?: string | undefined;
206
208
  cloudflare_ai_gateway_account_id?: string | undefined;
@@ -255,6 +257,7 @@ export declare const recipeSchema: z.ZodObject<{
255
257
  }[] | undefined;
256
258
  assets?: string | undefined;
257
259
  } | undefined;
260
+ config_patch?: Record<string, unknown> | undefined;
258
261
  bootstrap?: {
259
262
  workspace?: string | undefined;
260
263
  cloudflare_ai_gateway_account_id?: string | undefined;
@@ -512,6 +515,7 @@ export declare const recipeSchema: z.ZodObject<{
512
515
  }[] | undefined;
513
516
  assets?: string | undefined;
514
517
  } | undefined;
518
+ config_patch?: Record<string, unknown> | undefined;
515
519
  bootstrap?: {
516
520
  workspace?: string | undefined;
517
521
  cloudflare_ai_gateway_account_id?: string | undefined;
@@ -628,6 +632,7 @@ export declare const recipeSchema: z.ZodObject<{
628
632
  }[] | undefined;
629
633
  assets?: string | undefined;
630
634
  } | undefined;
635
+ config_patch?: Record<string, unknown> | undefined;
631
636
  bootstrap?: {
632
637
  workspace?: string | undefined;
633
638
  cloudflare_ai_gateway_account_id?: string | undefined;
package/dist/schema.js CHANGED
@@ -72,6 +72,7 @@ const openClawSchema = z
72
72
  install: z.enum(["auto", "always", "never"]).optional(),
73
73
  plugins: z.array(z.string().min(1)).optional(),
74
74
  root: openClawRootSchema.optional(),
75
+ config_patch: z.record(z.unknown()).optional(),
75
76
  bootstrap: openClawBootstrapSchema.optional(),
76
77
  commands: openClawCommandsSchema.optional(),
77
78
  })
package/dist/types.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type InstallPolicy = "auto" | "always" | "never";
2
2
  export type OpenClawProvider = "command" | "mock" | "remote";
3
- export type RunScope = "full" | "files" | "workspace";
3
+ export type RunScope = "full" | "stateful" | "files" | "workspace";
4
4
  export type GatewayMode = "service" | "run" | "none";
5
5
  export interface OpenClawRemoteConfig {
6
6
  base_url: string;
@@ -59,6 +59,7 @@ export interface OpenClawSection {
59
59
  install?: InstallPolicy;
60
60
  plugins?: string[];
61
61
  root?: OpenClawRootDef;
62
+ config_patch?: Record<string, unknown>;
62
63
  bootstrap?: OpenClawBootstrap;
63
64
  commands?: OpenClawCommandOverrides;
64
65
  }
@@ -136,6 +137,7 @@ export interface Recipe {
136
137
  export interface RunOptions {
137
138
  vars: Record<string, string>;
138
139
  plugins: string[];
140
+ filePatterns: string[];
139
141
  scope: RunScope;
140
142
  workspaceName?: string;
141
143
  gatewayMode: GatewayMode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawchef",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "Recipe-driven OpenClaw environment orchestrator",
5
5
  "homepage": "https://renorzr.github.io/clawchef",
6
6
  "repository": {
package/src/api.ts CHANGED
@@ -12,6 +12,7 @@ import type { ScaffoldOptions, ScaffoldResult } from "./scaffold.js";
12
12
  export interface CookOptions {
13
13
  vars?: Record<string, string>;
14
14
  plugins?: string[];
15
+ filePatterns?: string[];
15
16
  dryRun?: boolean;
16
17
  allowMissing?: boolean;
17
18
  verbose?: boolean;
@@ -27,6 +28,9 @@ export interface CookOptions {
27
28
 
28
29
  function normalizeCookOptions(options: CookOptions): RunOptions {
29
30
  const plugins = Array.from(new Set((options.plugins ?? []).map((value) => value.trim()).filter((value) => value.length > 0)));
31
+ const filePatterns = Array.from(
32
+ new Set((options.filePatterns ?? []).map((value) => value.trim()).filter((value) => value.length > 0)),
33
+ );
30
34
  const scope = options.scope ?? "full";
31
35
  const workspaceName = options.workspaceName?.trim() || undefined;
32
36
  if (scope === "workspace" && !workspaceName) {
@@ -35,9 +39,13 @@ function normalizeCookOptions(options: CookOptions): RunOptions {
35
39
  if (scope !== "workspace" && workspaceName) {
36
40
  throw new ClawChefError("workspaceName is only allowed when scope=workspace");
37
41
  }
42
+ if (scope !== "files" && filePatterns.length > 0) {
43
+ throw new ClawChefError("filePatterns is only allowed when scope=files");
44
+ }
38
45
  return {
39
46
  vars: options.vars ?? {},
40
47
  plugins,
48
+ filePatterns,
41
49
  scope,
42
50
  workspaceName,
43
51
  gatewayMode: options.gatewayMode ?? "service",