clawchef 0.1.11 → 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();
@@ -76,6 +100,14 @@ function isHttpUrl(value) {
76
100
  return false;
77
101
  }
78
102
  }
103
+ function shouldAutoDisableTelegramChannel(channel) {
104
+ if (channel.channel !== "telegram") {
105
+ return false;
106
+ }
107
+ const emptyToken = channel.token !== undefined && channel.token.trim().length === 0;
108
+ const emptyBotToken = channel.bot_token !== undefined && channel.bot_token.trim().length === 0;
109
+ return emptyToken || emptyBotToken;
110
+ }
79
111
  function isNotFoundError(err) {
80
112
  return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
81
113
  }
@@ -154,30 +186,37 @@ async function confirmFactoryReset(options) {
154
186
  export async function runRecipe(recipe, recipeOrigin, options, logger) {
155
187
  const provider = createProvider(options);
156
188
  const remoteMode = options.provider === "remote";
189
+ const filesOnlyScope = options.scope === "files";
190
+ const fileFilterEnabled = filesOnlyScope && options.filePatterns.length > 0;
157
191
  const workspacePaths = new Map();
158
192
  const preserveExistingState = options.scope !== "full";
159
193
  logger.info(`Running recipe: ${recipe.name}`);
160
- const versionResult = await provider.ensureVersion(recipe.openclaw, options.dryRun, options.silent, preserveExistingState);
161
- logger.info(`OpenClaw version ready: ${recipe.openclaw.version}`);
162
- if (versionResult.installedThisRun) {
163
- logger.info("OpenClaw was installed in this run; skipping factory reset");
164
- }
165
- else if (preserveExistingState) {
166
- logger.info("Keeping existing OpenClaw state; skipping factory reset");
167
- }
168
- else {
169
- const confirmed = await confirmFactoryReset(options);
170
- if (!confirmed) {
171
- 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}`);
172
216
  }
173
- await provider.factoryReset(recipe.openclaw, options.dryRun);
174
- logger.info("Factory reset completed");
175
217
  }
176
- const pluginSpecs = Array.from(new Set([...(recipe.openclaw.plugins ?? []), ...options.plugins].map((v) => v.trim())))
177
- .filter((v) => v.length > 0);
178
- for (const pluginSpec of pluginSpecs) {
179
- await provider.installPlugin(recipe.openclaw, pluginSpec, options.dryRun);
180
- logger.info(`Plugin preinstalled: ${pluginSpec}`);
218
+ else {
219
+ logger.info("Scope files: only syncing root/workspace assets and files");
181
220
  }
182
221
  const root = recipe.openclaw.root;
183
222
  if (root && (root.assets?.trim() || (root.files?.length ?? 0) > 0)) {
@@ -216,6 +255,11 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
216
255
  else {
217
256
  const assetFiles = await collectLocalAssetFiles(resolvedAssets.value);
218
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
+ }
219
263
  const target = path.resolve(openclawRootPath, assetFile.relativePath);
220
264
  if (!options.dryRun) {
221
265
  await mkdir(path.dirname(target), { recursive: true });
@@ -226,6 +270,11 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
226
270
  }
227
271
  }
228
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
+ }
229
278
  const target = path.resolve(openclawRootPath, file.path);
230
279
  const targetDir = path.dirname(target);
231
280
  if (!options.dryRun) {
@@ -262,8 +311,10 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
262
311
  if (!options.dryRun && !remoteMode) {
263
312
  await mkdir(absPath, { recursive: true });
264
313
  }
265
- await provider.createWorkspace(recipe.openclaw, { ...ws, path: absPath }, options.dryRun);
266
- 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
+ }
267
318
  if (!ws.assets?.trim()) {
268
319
  continue;
269
320
  }
@@ -288,6 +339,15 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
288
339
  }
289
340
  const assetFiles = await collectLocalAssetFiles(resolvedAssets.value);
290
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
+ }
291
351
  if (provider.materializeFile) {
292
352
  const content = await readFile(assetFile.absolutePath, "utf8");
293
353
  await provider.materializeFile(recipe.openclaw, ws.name, assetFile.relativePath, content, true, options.dryRun);
@@ -302,31 +362,64 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
302
362
  logger.info(`Workspace asset copied: ${ws.name}/${assetFile.relativePath}`);
303
363
  }
304
364
  }
305
- for (const agent of recipe.agents ?? []) {
306
- const workspacePath = workspacePaths.get(agent.workspace);
307
- if (!workspacePath) {
308
- 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}`);
309
374
  }
310
- await provider.createAgent(recipe.openclaw, agent, workspacePath, options.dryRun);
311
- logger.info(`Agent created: ${agent.workspace}/${agent.name}`);
312
- }
313
- for (const channel of recipe.channels ?? []) {
314
- const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
315
- ? { ...channel, account: channel.agent.trim() }
316
- : channel;
317
- await provider.configureChannel(recipe.openclaw, effectiveChannel, options.dryRun);
318
- logger.info(`Channel configured: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
319
- if (effectiveChannel.agent?.trim()) {
320
- await provider.bindChannelAgent(recipe.openclaw, effectiveChannel, effectiveChannel.agent, options.dryRun);
321
- logger.info(`Channel bound to agent: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""} -> ${effectiveChannel.agent}`);
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
+ }
389
+ }
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
+ }
322
402
  }
323
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
+ }
324
408
  for (const workspace of recipe.workspaces ?? []) {
325
409
  const wsPath = workspacePaths.get(workspace.name);
326
410
  if (!wsPath) {
327
411
  throw new ClawChefError(`Workspace does not exist for files: ${workspace.name}`);
328
412
  }
329
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
+ }
330
423
  if (provider.materializeFile) {
331
424
  let content = file.content;
332
425
  if (content === undefined && file.content_from) {
@@ -385,59 +478,61 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
385
478
  logger.info(`File materialized: ${workspace.name}/${file.path}`);
386
479
  }
387
480
  }
388
- for (const agent of recipe.agents ?? []) {
389
- for (const skill of agent.skills ?? []) {
390
- await provider.installSkill(recipe.openclaw, agent.workspace, agent.name, skill, options.dryRun);
391
- 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
+ }
392
487
  }
393
- }
394
- for (const conv of recipe.conversations ?? []) {
395
- for (const msg of conv.messages) {
396
- await provider.sendMessage(recipe.openclaw, conv, msg.content, options.dryRun);
397
- const shouldRun = conv.run ?? Boolean(msg.expect);
398
- if (shouldRun) {
399
- if (options.dryRun) {
400
- logger.info(`dry-run: skipping execution and output assertions: ${conv.workspace}/${conv.agent}`);
401
- continue;
402
- }
403
- const reply = await provider.runAgent(recipe.openclaw, conv, options.dryRun);
404
- if (msg.expect) {
405
- try {
406
- validateReply(reply, msg.expect);
407
- 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;
408
496
  }
409
- catch (err) {
410
- logger.warn(`Assertion failed reply (truncated): ${truncateForLog(reply)}`);
411
- 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
+ }
412
507
  }
508
+ else {
509
+ logger.info(`Agent executed: ${conv.workspace}/${conv.agent}`);
510
+ }
511
+ logger.debug(`Agent output: ${reply}`);
413
512
  }
414
- else {
415
- logger.info(`Agent executed: ${conv.workspace}/${conv.agent}`);
416
- }
417
- logger.debug(`Agent output: ${reply}`);
418
513
  }
514
+ logger.info(`Preset messages sent: ${conv.workspace}/${conv.agent}`);
419
515
  }
420
- logger.info(`Preset messages sent: ${conv.workspace}/${conv.agent}`);
421
- }
422
- await provider.startGateway(recipe.openclaw, options.gatewayMode, options.dryRun);
423
- if (options.gatewayMode === "none") {
424
- logger.info("Gateway start skipped by gateway mode: none");
425
- }
426
- else {
427
- logger.info(`Gateway started (${options.gatewayMode})`);
428
- }
429
- for (const channel of recipe.channels ?? []) {
430
- const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
431
- ? { ...channel, account: channel.agent.trim() }
432
- : channel;
433
- if (!effectiveChannel.login) {
434
- 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})`);
435
522
  }
436
- if (!options.dryRun && !input.isTTY) {
437
- 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}` : ""}`);
438
535
  }
439
- await provider.loginChannel(recipe.openclaw, effectiveChannel, options.dryRun);
440
- logger.info(`Channel login completed: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
441
536
  }
442
537
  logger.info("Recipe execution completed");
443
538
  }
package/dist/recipe.js CHANGED
@@ -44,6 +44,35 @@ 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
+ }
68
+ function hasExplicitEmptyTelegramToken(channel) {
69
+ if (channel.channel !== "telegram") {
70
+ return false;
71
+ }
72
+ const emptyToken = channel.token !== undefined && channel.token.trim().length === 0;
73
+ const emptyBotToken = channel.bot_token !== undefined && channel.bot_token.trim().length === 0;
74
+ return emptyToken || emptyBotToken;
75
+ }
47
76
  function assertNoInlineSecrets(recipe) {
48
77
  const bootstrap = recipe.openclaw.bootstrap;
49
78
  if (bootstrap) {
@@ -57,6 +86,9 @@ function assertNoInlineSecrets(recipe) {
57
86
  }
58
87
  }
59
88
  }
89
+ if (recipe.openclaw.config_patch) {
90
+ assertNoInlineSecretsInObject(recipe.openclaw.config_patch, "openclaw.config_patch");
91
+ }
60
92
  for (const channel of recipe.channels ?? []) {
61
93
  for (const field of CHANNEL_SECRET_FIELDS) {
62
94
  const value = channel[field];
@@ -118,6 +150,20 @@ function collectVars(recipe, cliVars, requiredKeys) {
118
150
  return vars;
119
151
  }
120
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
+ }
121
167
  if (options.scope !== "workspace") {
122
168
  return recipe;
123
169
  }
@@ -222,6 +268,9 @@ function semanticValidate(recipe) {
222
268
  }
223
269
  return /(token|password|secret|api[_-]?key|webhook)/i.test(key) && String(value).trim().length > 0;
224
270
  });
271
+ if (hasExplicitEmptyTelegramToken(channel)) {
272
+ continue;
273
+ }
225
274
  if (!hasAuth) {
226
275
  throw new ClawChefError(`channels[] entry for ${channel.channel} requires at least one auth input (for example token/bot_token/access_token/token_file/use_env)`);
227
276
  }
@@ -662,7 +711,7 @@ export async function loadRecipe(recipePath, options) {
662
711
  }
663
712
  const projected = projectRecipeForScope(firstParse.data, options);
664
713
  assertNoInlineSecrets(projected);
665
- const requiredKeys = options.scope === "workspace" ? new Set() : undefined;
714
+ const requiredKeys = options.scope === "workspace" || options.scope === "files" ? new Set() : undefined;
666
715
  const vars = collectVars(projected, options.vars, requiredKeys);
667
716
  const rendered = deepResolveTemplates(projected, vars, options.allowMissing);
668
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
  })
@@ -107,10 +108,10 @@ const channelSchema = z
107
108
  login_mode: z.enum(["interactive"]).optional(),
108
109
  login_account: z.string().min(1).optional(),
109
110
  name: z.string().min(1).optional(),
110
- token: z.string().min(1).optional(),
111
+ token: z.string().optional(),
111
112
  token_file: z.string().min(1).optional(),
112
113
  use_env: z.boolean().optional(),
113
- bot_token: z.string().min(1).optional(),
114
+ bot_token: z.string().optional(),
114
115
  access_token: z.string().min(1).optional(),
115
116
  app_token: z.string().min(1).optional(),
116
117
  webhook_url: z.string().min(1).optional(),
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.11",
3
+ "version": "0.1.13",
4
4
  "description": "Recipe-driven OpenClaw environment orchestrator",
5
5
  "homepage": "https://renorzr.github.io/clawchef",
6
6
  "repository": {