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.
@@ -27,6 +27,34 @@ 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 toPosixPath(value: string): string {
31
+ return value.replaceAll("\\", "/");
32
+ }
33
+
34
+ function wildcardToRegExp(pattern: string): RegExp {
35
+ const escaped = toPosixPath(pattern)
36
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
37
+ .replace(/\*\*/g, "___DOUBLE_STAR___")
38
+ .replace(/\*/g, "[^/]*")
39
+ .replace(/___DOUBLE_STAR___/g, ".*");
40
+ return new RegExp(`^${escaped}$`);
41
+ }
42
+
43
+ function matchesFilePatterns(patterns: string[], relativePath: string): boolean {
44
+ if (patterns.length === 0) {
45
+ return true;
46
+ }
47
+ const normalized = toPosixPath(relativePath);
48
+ return patterns.some((pattern) => wildcardToRegExp(pattern).test(normalized));
49
+ }
50
+
51
+ function matchesAnyFilePattern(patterns: string[], candidates: string[]): boolean {
52
+ if (patterns.length === 0) {
53
+ return true;
54
+ }
55
+ return candidates.some((candidate) => matchesFilePatterns(patterns, candidate));
56
+ }
57
+
30
58
  function renderTemplateString(input: string, vars: Record<string, string>, allowMissing: boolean): string {
31
59
  return input.replace(/\$\{([^}]+)\}/g, (_match, rawKey: string) => {
32
60
  const key = String(rawKey).trim();
@@ -191,36 +219,42 @@ export async function runRecipe(
191
219
  ): Promise<void> {
192
220
  const provider = createProvider(options);
193
221
  const remoteMode = options.provider === "remote";
222
+ const filesOnlyScope = options.scope === "files";
223
+ const fileFilterEnabled = filesOnlyScope && options.filePatterns.length > 0;
194
224
  const workspacePaths = new Map<string, string>();
195
225
  const preserveExistingState = options.scope !== "full";
196
226
 
197
227
  logger.info(`Running recipe: ${recipe.name}`);
198
- const versionResult = await provider.ensureVersion(
199
- recipe.openclaw,
200
- options.dryRun,
201
- options.silent,
202
- preserveExistingState,
203
- );
204
- logger.info(`OpenClaw version ready: ${recipe.openclaw.version}`);
205
-
206
- if (versionResult.installedThisRun) {
207
- logger.info("OpenClaw was installed in this run; skipping factory reset");
208
- } else if (preserveExistingState) {
209
- logger.info("Keeping existing OpenClaw state; skipping factory reset");
210
- } else {
211
- const confirmed = await confirmFactoryReset(options);
212
- if (!confirmed) {
213
- throw new ClawChefError("Aborted by user before factory reset");
228
+ if (!filesOnlyScope) {
229
+ const versionResult = await provider.ensureVersion(
230
+ recipe.openclaw,
231
+ options.dryRun,
232
+ options.silent,
233
+ preserveExistingState,
234
+ );
235
+ logger.info(`OpenClaw version ready: ${recipe.openclaw.version}`);
236
+
237
+ if (versionResult.installedThisRun) {
238
+ logger.info("OpenClaw was installed in this run; skipping factory reset");
239
+ } else if (preserveExistingState) {
240
+ logger.info("Keeping existing OpenClaw state; skipping factory reset");
241
+ } else {
242
+ const confirmed = await confirmFactoryReset(options);
243
+ if (!confirmed) {
244
+ throw new ClawChefError("Aborted by user before factory reset");
245
+ }
246
+ await provider.factoryReset(recipe.openclaw, options.dryRun);
247
+ logger.info("Factory reset completed");
214
248
  }
215
- await provider.factoryReset(recipe.openclaw, options.dryRun);
216
- logger.info("Factory reset completed");
217
- }
218
249
 
219
- const pluginSpecs = Array.from(new Set([...(recipe.openclaw.plugins ?? []), ...options.plugins].map((v) => v.trim())))
220
- .filter((v) => v.length > 0);
221
- for (const pluginSpec of pluginSpecs) {
222
- await provider.installPlugin(recipe.openclaw, pluginSpec, options.dryRun);
223
- logger.info(`Plugin preinstalled: ${pluginSpec}`);
250
+ const pluginSpecs = Array.from(new Set([...(recipe.openclaw.plugins ?? []), ...options.plugins].map((v) => v.trim())))
251
+ .filter((v) => v.length > 0);
252
+ for (const pluginSpec of pluginSpecs) {
253
+ await provider.installPlugin(recipe.openclaw, pluginSpec, options.dryRun);
254
+ logger.info(`Plugin preinstalled: ${pluginSpec}`);
255
+ }
256
+ } else {
257
+ logger.info("Scope files: only syncing root/workspace assets and files");
224
258
  }
225
259
 
226
260
  const root = recipe.openclaw.root;
@@ -261,6 +295,11 @@ export async function runRecipe(
261
295
  } else {
262
296
  const assetFiles = await collectLocalAssetFiles(resolvedAssets.value);
263
297
  for (const assetFile of assetFiles) {
298
+ const patternCandidates = [assetFile.relativePath, `root/${assetFile.relativePath}`];
299
+ if (fileFilterEnabled && !matchesAnyFilePattern(options.filePatterns, patternCandidates)) {
300
+ logger.debug(`Filtered root asset: ${assetFile.relativePath}`);
301
+ continue;
302
+ }
264
303
  const target = path.resolve(openclawRootPath, assetFile.relativePath);
265
304
  if (!options.dryRun) {
266
305
  await mkdir(path.dirname(target), { recursive: true });
@@ -272,6 +311,11 @@ export async function runRecipe(
272
311
  }
273
312
 
274
313
  for (const file of root.files ?? []) {
314
+ const patternCandidates = [file.path, `root/${file.path}`];
315
+ if (fileFilterEnabled && !matchesAnyFilePattern(options.filePatterns, patternCandidates)) {
316
+ logger.debug(`Filtered root file: ${file.path}`);
317
+ continue;
318
+ }
275
319
  const target = path.resolve(openclawRootPath, file.path);
276
320
  const targetDir = path.dirname(target);
277
321
 
@@ -306,8 +350,10 @@ export async function runRecipe(
306
350
  if (!options.dryRun && !remoteMode) {
307
351
  await mkdir(absPath, { recursive: true });
308
352
  }
309
- await provider.createWorkspace(recipe.openclaw, { ...ws, path: absPath }, options.dryRun);
310
- logger.info(`Workspace created: ${ws.name}`);
353
+ if (!filesOnlyScope) {
354
+ await provider.createWorkspace(recipe.openclaw, { ...ws, path: absPath }, options.dryRun);
355
+ logger.info(`Workspace created: ${ws.name}`);
356
+ }
311
357
 
312
358
  if (!ws.assets?.trim()) {
313
359
  continue;
@@ -337,6 +383,15 @@ export async function runRecipe(
337
383
 
338
384
  const assetFiles = await collectLocalAssetFiles(resolvedAssets.value);
339
385
  for (const assetFile of assetFiles) {
386
+ const patternCandidates = [
387
+ assetFile.relativePath,
388
+ `${ws.name}/${assetFile.relativePath}`,
389
+ `workspace-${ws.name}/${assetFile.relativePath}`,
390
+ ];
391
+ if (fileFilterEnabled && !matchesAnyFilePattern(options.filePatterns, patternCandidates)) {
392
+ logger.debug(`Filtered workspace asset: ${ws.name}/${assetFile.relativePath}`);
393
+ continue;
394
+ }
340
395
  if (provider.materializeFile) {
341
396
  const content = await readFile(assetFile.absolutePath, "utf8");
342
397
  await provider.materializeFile(
@@ -358,38 +413,60 @@ export async function runRecipe(
358
413
  }
359
414
  }
360
415
 
361
- for (const agent of recipe.agents ?? []) {
362
- const workspacePath = workspacePaths.get(agent.workspace);
363
- if (!workspacePath) {
364
- throw new ClawChefError(`Agent references missing workspace: ${agent.workspace}`);
416
+ if (!filesOnlyScope) {
417
+ const pendingChannelBindings: Array<{ channel: ChannelDef; agent: string }> = [];
418
+
419
+ for (const agent of recipe.agents ?? []) {
420
+ const workspacePath = workspacePaths.get(agent.workspace);
421
+ if (!workspacePath) {
422
+ throw new ClawChefError(`Agent references missing workspace: ${agent.workspace}`);
423
+ }
424
+ await provider.createAgent(recipe.openclaw, agent, workspacePath, options.dryRun);
425
+ logger.info(`Agent created: ${agent.workspace}/${agent.name}`);
365
426
  }
366
- await provider.createAgent(recipe.openclaw, agent, workspacePath, options.dryRun);
367
- logger.info(`Agent created: ${agent.workspace}/${agent.name}`);
368
- }
369
427
 
370
- for (const channel of recipe.channels ?? []) {
371
- const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
372
- ? { ...channel, account: channel.agent.trim() }
373
- : channel;
374
- const autoDisabledTelegram = shouldAutoDisableTelegramChannel(effectiveChannel);
428
+ for (const channel of recipe.channels ?? []) {
429
+ const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
430
+ ? { ...channel, account: channel.agent.trim() }
431
+ : channel;
432
+ const autoDisabledTelegram = shouldAutoDisableTelegramChannel(effectiveChannel);
375
433
 
376
- await provider.configureChannel(recipe.openclaw, effectiveChannel, options.dryRun);
377
- if (autoDisabledTelegram) {
378
- logger.info(
379
- `Telegram channel disabled due to empty bot token: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`,
380
- );
381
- continue;
434
+ await provider.configureChannel(recipe.openclaw, effectiveChannel, options.dryRun);
435
+ if (autoDisabledTelegram) {
436
+ logger.info(
437
+ `Telegram channel disabled due to empty bot token: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`,
438
+ );
439
+ continue;
440
+ }
441
+
442
+ logger.info(`Channel configured: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
443
+ if (effectiveChannel.agent?.trim()) {
444
+ pendingChannelBindings.push({ channel: effectiveChannel, agent: effectiveChannel.agent });
445
+ }
382
446
  }
383
447
 
384
- logger.info(`Channel configured: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
385
- if (effectiveChannel.agent?.trim()) {
386
- await provider.bindChannelAgent(recipe.openclaw, effectiveChannel, effectiveChannel.agent, options.dryRun);
387
- logger.info(
388
- `Channel bound to agent: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""} -> ${effectiveChannel.agent}`,
389
- );
448
+ if (pendingChannelBindings.length > 0) {
449
+ if (provider.bindChannelAgents) {
450
+ await provider.bindChannelAgents(recipe.openclaw, pendingChannelBindings, options.dryRun);
451
+ } else {
452
+ for (const binding of pendingChannelBindings) {
453
+ await provider.bindChannelAgent(recipe.openclaw, binding.channel, binding.agent, options.dryRun);
454
+ }
455
+ }
456
+
457
+ for (const binding of pendingChannelBindings) {
458
+ logger.info(
459
+ `Channel bound to agent: ${binding.channel.channel}${binding.channel.account ? `/${binding.channel.account}` : ""} -> ${binding.agent}`,
460
+ );
461
+ }
390
462
  }
391
463
  }
392
464
 
465
+ if (!filesOnlyScope && recipe.openclaw.config_patch) {
466
+ await provider.applyConfigPatch(recipe.openclaw, recipe.openclaw.config_patch, options.dryRun);
467
+ logger.info("OpenClaw config patch applied");
468
+ }
469
+
393
470
  for (const workspace of recipe.workspaces ?? []) {
394
471
  const wsPath = workspacePaths.get(workspace.name);
395
472
  if (!wsPath) {
@@ -397,6 +474,15 @@ export async function runRecipe(
397
474
  }
398
475
 
399
476
  for (const file of workspace.files ?? []) {
477
+ const patternCandidates = [
478
+ file.path,
479
+ `${workspace.name}/${file.path}`,
480
+ `workspace-${workspace.name}/${file.path}`,
481
+ ];
482
+ if (fileFilterEnabled && !matchesAnyFilePattern(options.filePatterns, patternCandidates)) {
483
+ logger.debug(`Filtered workspace file: ${workspace.name}/${file.path}`);
484
+ continue;
485
+ }
400
486
  if (provider.materializeFile) {
401
487
  let content = file.content;
402
488
  if (content === undefined && file.content_from) {
@@ -453,64 +539,66 @@ export async function runRecipe(
453
539
  }
454
540
  }
455
541
 
456
- for (const agent of recipe.agents ?? []) {
457
- for (const skill of agent.skills ?? []) {
458
- await provider.installSkill(recipe.openclaw, agent.workspace, agent.name, skill, options.dryRun);
459
- logger.info(`Skill installed: ${agent.workspace}/${agent.name} -> ${skill}`);
542
+ if (!filesOnlyScope) {
543
+ for (const agent of recipe.agents ?? []) {
544
+ for (const skill of agent.skills ?? []) {
545
+ await provider.installSkill(recipe.openclaw, agent.workspace, agent.name, skill, options.dryRun);
546
+ logger.info(`Skill installed: ${agent.workspace}/${agent.name} -> ${skill}`);
547
+ }
460
548
  }
461
- }
462
549
 
463
- for (const conv of recipe.conversations ?? []) {
464
- for (const msg of conv.messages) {
465
- await provider.sendMessage(recipe.openclaw, conv, msg.content, options.dryRun);
550
+ for (const conv of recipe.conversations ?? []) {
551
+ for (const msg of conv.messages) {
552
+ await provider.sendMessage(recipe.openclaw, conv, msg.content, options.dryRun);
466
553
 
467
- const shouldRun = conv.run ?? Boolean(msg.expect);
468
- if (shouldRun) {
469
- if (options.dryRun) {
470
- logger.info(`dry-run: skipping execution and output assertions: ${conv.workspace}/${conv.agent}`);
471
- continue;
472
- }
473
- const reply = await provider.runAgent(recipe.openclaw, conv, options.dryRun);
474
- if (msg.expect) {
475
- try {
476
- validateReply(reply, msg.expect);
477
- logger.info(`Output assertions passed: ${conv.workspace}/${conv.agent}`);
478
- } catch (err) {
479
- logger.warn(
480
- `Assertion failed reply (truncated): ${truncateForLog(reply)}`,
481
- );
482
- throw err;
554
+ const shouldRun = conv.run ?? Boolean(msg.expect);
555
+ if (shouldRun) {
556
+ if (options.dryRun) {
557
+ logger.info(`dry-run: skipping execution and output assertions: ${conv.workspace}/${conv.agent}`);
558
+ continue;
483
559
  }
484
- } else {
485
- logger.info(`Agent executed: ${conv.workspace}/${conv.agent}`);
560
+ const reply = await provider.runAgent(recipe.openclaw, conv, options.dryRun);
561
+ if (msg.expect) {
562
+ try {
563
+ validateReply(reply, msg.expect);
564
+ logger.info(`Output assertions passed: ${conv.workspace}/${conv.agent}`);
565
+ } catch (err) {
566
+ logger.warn(
567
+ `Assertion failed reply (truncated): ${truncateForLog(reply)}`,
568
+ );
569
+ throw err;
570
+ }
571
+ } else {
572
+ logger.info(`Agent executed: ${conv.workspace}/${conv.agent}`);
573
+ }
574
+ logger.debug(`Agent output: ${reply}`);
486
575
  }
487
- logger.debug(`Agent output: ${reply}`);
488
576
  }
577
+ logger.info(`Preset messages sent: ${conv.workspace}/${conv.agent}`);
489
578
  }
490
- logger.info(`Preset messages sent: ${conv.workspace}/${conv.agent}`);
491
- }
492
579
 
493
- await provider.startGateway(recipe.openclaw, options.gatewayMode, options.dryRun);
494
- if (options.gatewayMode === "none") {
495
- logger.info("Gateway start skipped by gateway mode: none");
496
- } else {
497
- logger.info(`Gateway started (${options.gatewayMode})`);
498
- }
499
-
500
- for (const channel of recipe.channels ?? []) {
501
- const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
502
- ? { ...channel, account: channel.agent.trim() }
503
- : channel;
504
- if (!effectiveChannel.login) {
505
- continue;
580
+ await provider.startGateway(recipe.openclaw, options.gatewayMode, options.dryRun);
581
+ if (options.gatewayMode === "none") {
582
+ logger.info("Gateway start skipped by gateway mode: none");
583
+ } else {
584
+ logger.info(`Gateway started (${options.gatewayMode})`);
506
585
  }
507
- if (!options.dryRun && !input.isTTY) {
508
- throw new ClawChefError(
509
- `Channel login for ${effectiveChannel.channel} requires an interactive terminal session`,
510
- );
586
+
587
+ for (const channel of recipe.channels ?? []) {
588
+ const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
589
+ ? { ...channel, account: channel.agent.trim() }
590
+ : channel;
591
+ if (!effectiveChannel.login) {
592
+ continue;
593
+ }
594
+ if (!options.dryRun && !input.isTTY) {
595
+ throw new ClawChefError(
596
+ `Channel login for ${effectiveChannel.channel} requires an interactive terminal session`,
597
+ );
598
+ }
599
+ await provider.loginChannel(recipe.openclaw, effectiveChannel, options.dryRun);
600
+ logger.info(`Channel login completed: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
511
601
  }
512
- await provider.loginChannel(recipe.openclaw, effectiveChannel, options.dryRun);
513
- logger.info(`Channel login completed: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
514
602
  }
515
603
 
516
604
  logger.info("Recipe execution completed");
package/src/recipe.ts CHANGED
@@ -73,6 +73,33 @@ const CHANNEL_SECRET_FIELDS = ["token", "bot_token", "access_token", "app_token"
73
73
 
74
74
  const TEMPLATE_TOKEN_RE = /\$\{[A-Za-z_][A-Za-z0-9_]*\}/;
75
75
 
76
+ function assertNoInlineSecretsInObject(value: unknown, pathLabel: string): void {
77
+ if (Array.isArray(value)) {
78
+ for (let i = 0; i < value.length; i += 1) {
79
+ assertNoInlineSecretsInObject(value[i], `${pathLabel}[${i}]`);
80
+ }
81
+ return;
82
+ }
83
+ if (!value || typeof value !== "object") {
84
+ return;
85
+ }
86
+
87
+ for (const [key, nestedValue] of Object.entries(value)) {
88
+ const nextPath = `${pathLabel}.${key}`;
89
+ if (
90
+ typeof nestedValue === "string" &&
91
+ /(token|password|secret|api[_-]?key|webhook)/i.test(key) &&
92
+ nestedValue.trim().length > 0 &&
93
+ !TEMPLATE_TOKEN_RE.test(nestedValue)
94
+ ) {
95
+ throw new ClawChefError(
96
+ `Inline secret in ${nextPath} is not allowed. Use \${var} and pass it via --var or CLAWCHEF_VAR_*`,
97
+ );
98
+ }
99
+ assertNoInlineSecretsInObject(nestedValue, nextPath);
100
+ }
101
+ }
102
+
76
103
  function hasExplicitEmptyTelegramToken(channel: ChannelDef): boolean {
77
104
  if (channel.channel !== "telegram") {
78
105
  return false;
@@ -98,6 +125,10 @@ function assertNoInlineSecrets(recipe: Recipe): void {
98
125
  }
99
126
  }
100
127
 
128
+ if (recipe.openclaw.config_patch) {
129
+ assertNoInlineSecretsInObject(recipe.openclaw.config_patch, "openclaw.config_patch");
130
+ }
131
+
101
132
  for (const channel of recipe.channels ?? []) {
102
133
  for (const field of CHANNEL_SECRET_FIELDS) {
103
134
  const value = channel[field];
@@ -171,6 +202,21 @@ function collectVars(recipe: Recipe, cliVars: Record<string, string>, requiredKe
171
202
  }
172
203
 
173
204
  function projectRecipeForScope(recipe: Recipe, options: RunOptions): Recipe {
205
+ if (options.scope === "files") {
206
+ return {
207
+ ...recipe,
208
+ openclaw: {
209
+ ...recipe.openclaw,
210
+ version: "0.0.0",
211
+ bootstrap: undefined,
212
+ plugins: [],
213
+ },
214
+ channels: [],
215
+ agents: [],
216
+ conversations: [],
217
+ };
218
+ }
219
+
174
220
  if (options.scope !== "workspace") {
175
221
  return recipe;
176
222
  }
@@ -832,7 +878,7 @@ export async function loadRecipe(recipePath: string, options: RunOptions): Promi
832
878
 
833
879
  assertNoInlineSecrets(projected);
834
880
 
835
- const requiredKeys = options.scope === "workspace" ? new Set<string>() : undefined;
881
+ const requiredKeys = options.scope === "workspace" || options.scope === "files" ? new Set<string>() : undefined;
836
882
  const vars = collectVars(projected, options.vars, requiredKeys);
837
883
  const rendered = deepResolveTemplates(projected, vars, options.allowMissing);
838
884
  const secondParse = recipeSchema.safeParse(rendered);
package/src/schema.ts CHANGED
@@ -78,6 +78,7 @@ const openClawSchema = z
78
78
  install: z.enum(["auto", "always", "never"]).optional(),
79
79
  plugins: z.array(z.string().min(1)).optional(),
80
80
  root: openClawRootSchema.optional(),
81
+ config_patch: z.record(z.unknown()).optional(),
81
82
  bootstrap: openClawBootstrapSchema.optional(),
82
83
  commands: openClawCommandsSchema.optional(),
83
84
  })
package/src/types.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
 
6
6
  export interface OpenClawRemoteConfig {
@@ -64,6 +64,7 @@ export interface OpenClawSection {
64
64
  install?: InstallPolicy;
65
65
  plugins?: string[];
66
66
  root?: OpenClawRootDef;
67
+ config_patch?: Record<string, unknown>;
67
68
  bootstrap?: OpenClawBootstrap;
68
69
  commands?: OpenClawCommandOverrides;
69
70
  }
@@ -151,6 +152,7 @@ export interface Recipe {
151
152
  export interface RunOptions {
152
153
  vars: Record<string, string>;
153
154
  plugins: string[];
155
+ filePatterns: string[];
154
156
  scope: RunScope;
155
157
  workspaceName?: string;
156
158
  gatewayMode: GatewayMode;