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.
package/README.md CHANGED
@@ -8,11 +8,12 @@ Recipe-driven OpenClaw environment orchestrator.
8
8
  - Accepts recipe input from local file/dir/archive and HTTP URL/archive.
9
9
  - Resolves `${var}` parameters from `--var`, environment, and defaults.
10
10
  - Auto-loads environment variables from `.env` in the current working directory.
11
+ - `--verbose` prints step-level debug logs including executed commands and operation timing.
11
12
  - Supports loading env vars from a custom `.env` path/URL via `--dotenv-ref`.
12
13
  - Requires secrets to be injected via `--var` / `CLAWCHEF_VAR_*` (no inline secrets in recipe).
13
14
  - Prepares OpenClaw version (install or reuse).
14
15
  - When installed OpenClaw version mismatches recipe version, prompts: ignore / abort / force reinstall (silent mode auto-picks force reinstall).
15
- - Supports scoped execution via `--scope full|files|workspace`.
16
+ - Supports scoped execution via `--scope full|stateful|files|workspace`.
16
17
  - `full` scope runs factory reset first (with confirmation prompt unless `-s/--silent` is used).
17
18
  - Factory reset includes removing local `~/.openclaw` directory.
18
19
  - If `openclaw` is missing, auto-installs the recipe version and skips factory reset.
@@ -105,10 +106,31 @@ Use it only in CI/non-interactive flows where destructive reset behavior is expe
105
106
 
106
107
  Keep existing OpenClaw state (skip reset and keep current version on mismatch):
107
108
 
109
+ ```bash
110
+ clawchef cook recipes/sample.yaml --scope stateful
111
+ ```
112
+
113
+ Sync only files and assets (`openclaw.root.assets/files` + `workspaces[].assets/files`) without touching version/reset/workspace/agent/channel/skills/conversations/gateway:
114
+
108
115
  ```bash
109
116
  clawchef cook recipes/sample.yaml --scope files
110
117
  ```
111
118
 
119
+ Limit files-scope changes to relative paths only:
120
+
121
+ ```bash
122
+ clawchef cook recipes/sample.yaml --scope files --file "README.md"
123
+ clawchef cook recipes/sample.yaml --scope files --file "src/**" --file "docs/*.md"
124
+ clawchef cook recipes/sample.yaml --scope files --file "workspace-product-designer/**"
125
+ ```
126
+
127
+ `--file` is repeatable and only works with `--scope files`. Patterns can match any of:
128
+
129
+ - file relative path (for example `README.md`, `src/**`)
130
+ - workspace-prefixed path (for example `product-designer/**`)
131
+ - default workspace-dir-prefixed path (for example `workspace-product-designer/**`)
132
+ - root-prefixed path (`root/**`) for `openclaw.root.assets/files`
133
+
112
134
  Update only one workspace:
113
135
 
114
136
  ```bash
@@ -422,6 +444,36 @@ If `agent` is set and `account` is omitted, clawchef defaults `account` to the s
422
444
  `channels[].group_policy` currently supports `channel: "telegram"` only and is applied after `channels add` via `openclaw config set` so it is not overwritten by add-flow writes.
423
445
  If `channel: "telegram"` has `token: ""` or `bot_token: ""`, clawchef auto-disables that telegram account (`enabled=false`) and skips channel add/bind.
424
446
 
447
+ ## OpenClaw config patch
448
+
449
+ Use `openclaw.config_patch` to apply a deep config patch to active OpenClaw config (same target used by `openclaw config set`).
450
+
451
+ Merge behavior:
452
+
453
+ - objects: merged recursively by setting nested keys
454
+ - arrays: replaced as a whole
455
+ - scalars: replaced
456
+
457
+ Applied during `cook` after channel setup and before gateway start. Not applied in `--scope files`.
458
+
459
+ ```yaml
460
+ openclaw:
461
+ version: "2026.3.2"
462
+ config_patch:
463
+ channels:
464
+ telegram:
465
+ accounts:
466
+ frontend-dev:
467
+ capabilities:
468
+ inlineButtons: "all"
469
+ groups:
470
+ "*":
471
+ requireMention: false
472
+ enabled: true
473
+ actions:
474
+ sendMessage: true
475
+ ```
476
+
425
477
  ## Workspace path behavior
426
478
 
427
479
  - `workspaces[].path` is optional.
package/dist/api.d.ts CHANGED
@@ -3,6 +3,7 @@ import type { ScaffoldOptions, ScaffoldResult } from "./scaffold.js";
3
3
  export interface CookOptions {
4
4
  vars?: Record<string, string>;
5
5
  plugins?: string[];
6
+ filePatterns?: string[];
6
7
  dryRun?: boolean;
7
8
  allowMissing?: boolean;
8
9
  verbose?: boolean;
package/dist/api.js CHANGED
@@ -8,6 +8,7 @@ import { scaffoldProject } from "./scaffold.js";
8
8
  import { recipeSchema } from "./schema.js";
9
9
  function normalizeCookOptions(options) {
10
10
  const plugins = Array.from(new Set((options.plugins ?? []).map((value) => value.trim()).filter((value) => value.length > 0)));
11
+ const filePatterns = Array.from(new Set((options.filePatterns ?? []).map((value) => value.trim()).filter((value) => value.length > 0)));
11
12
  const scope = options.scope ?? "full";
12
13
  const workspaceName = options.workspaceName?.trim() || undefined;
13
14
  if (scope === "workspace" && !workspaceName) {
@@ -16,9 +17,13 @@ function normalizeCookOptions(options) {
16
17
  if (scope !== "workspace" && workspaceName) {
17
18
  throw new ClawChefError("workspaceName is only allowed when scope=workspace");
18
19
  }
20
+ if (scope !== "files" && filePatterns.length > 0) {
21
+ throw new ClawChefError("filePatterns is only allowed when scope=files");
22
+ }
19
23
  return {
20
24
  vars: options.vars ?? {},
21
25
  plugins,
26
+ filePatterns,
22
27
  scope,
23
28
  workspaceName,
24
29
  gatewayMode: options.gatewayMode ?? "service",
package/dist/cli.js CHANGED
@@ -41,6 +41,9 @@ function parseVarFlags(values) {
41
41
  function parsePluginFlags(values) {
42
42
  return Array.from(new Set(values.map((value) => value.trim()).filter((value) => value.length > 0)));
43
43
  }
44
+ function parseFileFlags(values) {
45
+ return Array.from(new Set(values.map((value) => value.trim()).filter((value) => value.length > 0)));
46
+ }
44
47
  function readEnv(name) {
45
48
  const value = process.env[name];
46
49
  if (value === undefined) {
@@ -56,10 +59,10 @@ function parseProvider(value) {
56
59
  throw new ClawChefError(`Invalid --provider value: ${value}. Expected command, remote, or mock`);
57
60
  }
58
61
  function parseScope(value) {
59
- if (value === "full" || value === "files" || value === "workspace") {
62
+ if (value === "full" || value === "stateful" || value === "files" || value === "workspace") {
60
63
  return value;
61
64
  }
62
- throw new ClawChefError(`Invalid --scope value: ${value}. Expected full, files, or workspace`);
65
+ throw new ClawChefError(`Invalid --scope value: ${value}. Expected full, stateful, files, or workspace`);
63
66
  }
64
67
  function parseGatewayMode(value) {
65
68
  if (value === "service" || value === "run" || value === "none") {
@@ -105,7 +108,8 @@ export function buildCli() {
105
108
  .option("--allow-missing", "Allow unresolved template variables", false)
106
109
  .option("--verbose", "Verbose logging", false)
107
110
  .option("-s, --silent", "Skip reset confirmation prompt", false)
108
- .option("--scope <scope>", "Run scope: full | files | workspace", "full")
111
+ .option("--scope <scope>", "Run scope: full | stateful | files | workspace", "full")
112
+ .option("--file <pattern>", "File pattern filter (only with --scope files, repeatable)", (v, p) => p.concat([v]), [])
109
113
  .option("--workspace <name>", "Workspace name (required when --scope workspace)")
110
114
  .option("--gateway-mode <mode>", "Gateway mode: service | run | none", "service")
111
115
  .option("--dotenv-ref <path-or-url>", "Load env vars from local file or HTTP URL")
@@ -127,6 +131,7 @@ export function buildCli() {
127
131
  const provider = parseProvider(opts.provider ?? readEnv("CLAWCHEF_PROVIDER") ?? "command");
128
132
  const scope = parseScope(String(opts.scope ?? "full"));
129
133
  const gatewayMode = parseGatewayMode(String(opts.gatewayMode ?? "service"));
134
+ const filePatterns = parseFileFlags(opts.file);
130
135
  const workspaceName = opts.workspace?.trim() ? String(opts.workspace).trim() : undefined;
131
136
  if (scope === "workspace" && !workspaceName) {
132
137
  throw new ClawChefError("--scope workspace requires --workspace <name>");
@@ -134,9 +139,13 @@ export function buildCli() {
134
139
  if (scope !== "workspace" && workspaceName) {
135
140
  throw new ClawChefError("--workspace is only allowed when --scope workspace");
136
141
  }
142
+ if (scope !== "files" && filePatterns.length > 0) {
143
+ throw new ClawChefError("--file is only allowed when --scope files");
144
+ }
137
145
  const options = {
138
146
  vars: parseVarFlags(opts.var),
139
147
  plugins: parsePluginFlags(opts.plugin),
148
+ filePatterns,
140
149
  scope,
141
150
  workspaceName,
142
151
  gatewayMode,
package/dist/logger.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export declare class Logger {
2
2
  private readonly verboseEnabled;
3
3
  constructor(verboseEnabled: boolean);
4
+ private timestamp;
4
5
  info(message: string): void;
5
6
  warn(message: string): void;
6
7
  debug(message: string): void;
package/dist/logger.js CHANGED
@@ -3,15 +3,25 @@ export class Logger {
3
3
  constructor(verboseEnabled) {
4
4
  this.verboseEnabled = verboseEnabled;
5
5
  }
6
+ timestamp() {
7
+ const now = new Date();
8
+ const year = now.getFullYear();
9
+ const month = String(now.getMonth() + 1).padStart(2, "0");
10
+ const day = String(now.getDate()).padStart(2, "0");
11
+ const hours = String(now.getHours()).padStart(2, "0");
12
+ const minutes = String(now.getMinutes()).padStart(2, "0");
13
+ const seconds = String(now.getSeconds()).padStart(2, "0");
14
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
15
+ }
6
16
  info(message) {
7
- process.stdout.write(`[INFO] ${message}\n`);
17
+ process.stdout.write(`[${this.timestamp()}] [INFO] ${message}\n`);
8
18
  }
9
19
  warn(message) {
10
- process.stdout.write(`[WARN] ${message}\n`);
20
+ process.stdout.write(`[${this.timestamp()}] [WARN] ${message}\n`);
11
21
  }
12
22
  debug(message) {
13
23
  if (this.verboseEnabled) {
14
- process.stdout.write(`[DEBUG] ${message}\n`);
24
+ process.stdout.write(`[${this.timestamp()}] [DEBUG] ${message}\n`);
15
25
  }
16
26
  }
17
27
  }
@@ -1,13 +1,17 @@
1
1
  import type { AgentDef, ChannelDef, ConversationDef, GatewayMode, OpenClawSection } from "../types.js";
2
- import type { EnsureVersionResult, OpenClawProvider, ResolvedWorkspaceDef } from "./provider.js";
2
+ import type { ChannelAgentBinding, EnsureVersionResult, OpenClawProvider, ResolvedWorkspaceDef } from "./provider.js";
3
3
  export declare class CommandOpenClawProvider implements OpenClawProvider {
4
4
  private readonly stagedMessages;
5
+ private readonly enabledChannelPlugins;
6
+ constructor(verboseEnabled?: boolean);
5
7
  ensureVersion(config: OpenClawSection, dryRun: boolean, silent: boolean, preserveExistingState: boolean): Promise<EnsureVersionResult>;
6
8
  factoryReset(config: OpenClawSection, dryRun: boolean): Promise<void>;
7
9
  installPlugin(config: OpenClawSection, pluginSpec: string, dryRun: boolean): Promise<void>;
8
10
  startGateway(config: OpenClawSection, mode: GatewayMode, dryRun: boolean): Promise<void>;
9
11
  createWorkspace(config: OpenClawSection, workspace: ResolvedWorkspaceDef, dryRun: boolean): Promise<void>;
10
12
  configureChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
13
+ applyConfigPatch(config: OpenClawSection, patch: Record<string, unknown>, dryRun: boolean): Promise<void>;
14
+ bindChannelAgents(config: OpenClawSection, bindingsInput: ChannelAgentBinding[], dryRun: boolean): Promise<void>;
11
15
  bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void>;
12
16
  loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
13
17
  createAgent(config: OpenClawSection, agent: AgentDef, workspacePath: string, dryRun: boolean): Promise<void>;
@@ -1,7 +1,7 @@
1
1
  import { homedir, tmpdir } from "node:os";
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
4
+ import { mkdtemp, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
5
5
  import { spawn } from "node:child_process";
6
6
  import { createInterface } from "node:readline/promises";
7
7
  import { stdin as input, stdout as output } from "node:process";
@@ -23,6 +23,23 @@ const DEFAULT_COMMANDS = {
23
23
  run_agent: "${bin} agent --local --agent ${agent} --message ${prompt_q} --json",
24
24
  };
25
25
  const SECRET_FLAG_RE = /(--[A-Za-z0-9-]*(?:api-key|token|password|secret)[A-Za-z0-9-]*\s+)(?:'[^']*'|"[^"]*"|\S+)/g;
26
+ let TRACE_VERBOSE = false;
27
+ function timestamp() {
28
+ const now = new Date();
29
+ const year = now.getFullYear();
30
+ const month = String(now.getMonth() + 1).padStart(2, "0");
31
+ const day = String(now.getDate()).padStart(2, "0");
32
+ const hours = String(now.getHours()).padStart(2, "0");
33
+ const minutes = String(now.getMinutes()).padStart(2, "0");
34
+ const seconds = String(now.getSeconds()).padStart(2, "0");
35
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
36
+ }
37
+ function traceDebug(message) {
38
+ if (!TRACE_VERBOSE) {
39
+ return;
40
+ }
41
+ process.stdout.write(`[${timestamp()}] [DEBUG] ${message}\n`);
42
+ }
26
43
  const AUTH_CHOICE_TO_LLM_FLAG = {
27
44
  "openai-api-key": "--openai-api-key",
28
45
  "anthropic-api-key": "--anthropic-api-key",
@@ -70,9 +87,13 @@ async function commandExists(bin) {
70
87
  }
71
88
  }
72
89
  async function runShell(command, dryRun, extraEnv) {
90
+ const sanitized = sanitizeCommand(command);
73
91
  if (dryRun) {
92
+ traceDebug(`CMD DRY-RUN: ${sanitized}`);
74
93
  return "";
75
94
  }
95
+ const startedAt = Date.now();
96
+ traceDebug(`CMD START: ${sanitized}`);
76
97
  return new Promise((resolve, reject) => {
77
98
  const child = spawn(command, {
78
99
  shell: true,
@@ -92,17 +113,23 @@ async function runShell(command, dryRun, extraEnv) {
92
113
  });
93
114
  child.on("close", (code) => {
94
115
  if (code === 0) {
116
+ traceDebug(`CMD DONE (${Date.now() - startedAt}ms): ${sanitized}`);
95
117
  resolve(stdout.trim());
96
118
  return;
97
119
  }
120
+ traceDebug(`CMD FAIL (${Date.now() - startedAt}ms) code=${String(code)}: ${sanitized}`);
98
121
  reject(new ClawChefError(`Command failed (${code}): ${sanitizeCommand(command)}\n${stderr.trim()}`));
99
122
  });
100
123
  });
101
124
  }
102
125
  async function runShellInteractive(command, dryRun) {
126
+ const sanitized = sanitizeCommand(command);
103
127
  if (dryRun) {
128
+ traceDebug(`CMD DRY-RUN (interactive): ${sanitized}`);
104
129
  return;
105
130
  }
131
+ const startedAt = Date.now();
132
+ traceDebug(`CMD START (interactive): ${sanitized}`);
106
133
  return new Promise((resolve, reject) => {
107
134
  const child = spawn(command, {
108
135
  shell: true,
@@ -114,9 +141,11 @@ async function runShellInteractive(command, dryRun) {
114
141
  });
115
142
  child.on("close", (code) => {
116
143
  if (code === 0) {
144
+ traceDebug(`CMD DONE (interactive, ${Date.now() - startedAt}ms): ${sanitized}`);
117
145
  resolve();
118
146
  return;
119
147
  }
148
+ traceDebug(`CMD FAIL (interactive, ${Date.now() - startedAt}ms) code=${String(code)}: ${sanitized}`);
120
149
  reject(new ClawChefError(`Command failed (${code}): ${sanitizeCommand(command)}`));
121
150
  });
122
151
  });
@@ -151,6 +180,158 @@ function shouldAutoDisableTelegramChannel(channel) {
151
180
  const emptyBotToken = channel.bot_token !== undefined && channel.bot_token.trim().length === 0;
152
181
  return emptyToken || emptyBotToken;
153
182
  }
183
+ function isPlainObject(value) {
184
+ return typeof value === "object" && value !== null && !Array.isArray(value);
185
+ }
186
+ function splitConfigPath(pathExpression) {
187
+ const segments = [];
188
+ let token = "";
189
+ for (let i = 0; i < pathExpression.length; i += 1) {
190
+ const ch = pathExpression[i];
191
+ if (ch === ".") {
192
+ if (token) {
193
+ segments.push(token);
194
+ token = "";
195
+ }
196
+ continue;
197
+ }
198
+ if (ch === "[") {
199
+ if (token) {
200
+ segments.push(token);
201
+ token = "";
202
+ }
203
+ const end = pathExpression.indexOf("]", i + 1);
204
+ if (end < 0) {
205
+ throw new ClawChefError(`Invalid config path expression: ${pathExpression}`);
206
+ }
207
+ const bracketToken = pathExpression.slice(i + 1, end).trim();
208
+ if (!bracketToken) {
209
+ throw new ClawChefError(`Invalid empty bracket token in config path: ${pathExpression}`);
210
+ }
211
+ segments.push(bracketToken);
212
+ i = end;
213
+ continue;
214
+ }
215
+ token += ch;
216
+ }
217
+ if (token) {
218
+ segments.push(token);
219
+ }
220
+ if (segments.length === 0) {
221
+ throw new ClawChefError(`Invalid config path expression: ${pathExpression}`);
222
+ }
223
+ return segments;
224
+ }
225
+ function getConfigValue(root, pathExpression) {
226
+ const segments = splitConfigPath(pathExpression);
227
+ let cursor = root;
228
+ for (const segment of segments) {
229
+ if (!isPlainObject(cursor)) {
230
+ return undefined;
231
+ }
232
+ cursor = cursor[segment];
233
+ }
234
+ return cursor;
235
+ }
236
+ function setConfigValue(root, pathExpression, value) {
237
+ const segments = splitConfigPath(pathExpression);
238
+ let cursor = root;
239
+ for (let i = 0; i < segments.length - 1; i += 1) {
240
+ const segment = segments[i];
241
+ const existing = cursor[segment];
242
+ if (isPlainObject(existing)) {
243
+ cursor = existing;
244
+ continue;
245
+ }
246
+ const next = {};
247
+ cursor[segment] = next;
248
+ cursor = next;
249
+ }
250
+ cursor[segments[segments.length - 1]] = value;
251
+ }
252
+ function deepMergeConfig(base, patch) {
253
+ if (!isPlainObject(patch)) {
254
+ return patch;
255
+ }
256
+ const baseObject = isPlainObject(base) ? base : {};
257
+ const result = {};
258
+ for (const [k, v] of Object.entries(baseObject)) {
259
+ result[k] = toJsonPatchValue(v, `base.${k}`);
260
+ }
261
+ for (const [k, v] of Object.entries(patch)) {
262
+ const current = result[k];
263
+ if (isPlainObject(v) && isPlainObject(current)) {
264
+ result[k] = deepMergeConfig(current, v);
265
+ }
266
+ else {
267
+ result[k] = v;
268
+ }
269
+ }
270
+ return result;
271
+ }
272
+ function configPath() {
273
+ const fromEnv = process.env.OPENCLAW_CONFIG_PATH?.trim();
274
+ if (fromEnv) {
275
+ return fromEnv;
276
+ }
277
+ return path.join(homedir(), ".openclaw", "openclaw.json");
278
+ }
279
+ async function loadConfigJson(configFilePath) {
280
+ let raw;
281
+ try {
282
+ raw = await readFile(configFilePath, "utf8");
283
+ }
284
+ catch (err) {
285
+ const message = err instanceof Error ? err.message : String(err);
286
+ throw new ClawChefError(`Failed to read OpenClaw config at ${configFilePath}: ${message}`);
287
+ }
288
+ try {
289
+ const parsed = JSON.parse(raw);
290
+ if (!isPlainObject(parsed)) {
291
+ throw new ClawChefError(`OpenClaw config root must be an object: ${configFilePath}`);
292
+ }
293
+ return parsed;
294
+ }
295
+ catch (err) {
296
+ if (err instanceof ClawChefError) {
297
+ throw err;
298
+ }
299
+ const message = err instanceof Error ? err.message : String(err);
300
+ throw new ClawChefError(`Failed to parse OpenClaw config JSON at ${configFilePath}: ${message}`);
301
+ }
302
+ }
303
+ async function saveConfigJson(configFilePath, config, dryRun) {
304
+ if (dryRun) {
305
+ traceDebug(`CONFIG DRY-RUN write: ${configFilePath}`);
306
+ return;
307
+ }
308
+ const dir = path.dirname(configFilePath);
309
+ await mkdir(dir, { recursive: true });
310
+ const tempPath = `${configFilePath}.tmp-${process.pid}-${Date.now()}`;
311
+ const payload = `${JSON.stringify(config, null, 2)}\n`;
312
+ await writeFile(tempPath, payload, "utf8");
313
+ await rename(tempPath, configFilePath);
314
+ traceDebug(`CONFIG WRITE: ${configFilePath}`);
315
+ }
316
+ function toJsonPatchValue(value, pathLabel) {
317
+ if (value === null ||
318
+ typeof value === "string" ||
319
+ typeof value === "number" ||
320
+ typeof value === "boolean") {
321
+ return value;
322
+ }
323
+ if (Array.isArray(value)) {
324
+ return value.map((item, index) => toJsonPatchValue(item, `${pathLabel}[${index}]`));
325
+ }
326
+ if (isPlainObject(value)) {
327
+ const out = {};
328
+ for (const [k, v] of Object.entries(value)) {
329
+ out[k] = toJsonPatchValue(v, `${pathLabel}.${k}`);
330
+ }
331
+ return out;
332
+ }
333
+ throw new ClawChefError(`openclaw.config_patch contains unsupported value at ${pathLabel}`);
334
+ }
154
335
  async function chooseVersionMismatchAction(currentVersion, expectedVersion, silent) {
155
336
  if (silent) {
156
337
  return "force";
@@ -265,24 +446,21 @@ function isAccountLevelBinding(item, channel, account) {
265
446
  && match.teamId === undefined
266
447
  && match.roles === undefined);
267
448
  }
268
- function parseBindingsJson(raw) {
269
- if (!raw.trim()) {
449
+ function parseBindingsValue(value) {
450
+ if (value === undefined || value === null) {
270
451
  return [];
271
452
  }
272
- try {
273
- const parsed = JSON.parse(raw);
274
- if (!Array.isArray(parsed)) {
275
- throw new ClawChefError("openclaw config bindings is not an array");
276
- }
277
- return parsed;
278
- }
279
- catch (err) {
280
- const message = err instanceof Error ? err.message : String(err);
281
- throw new ClawChefError(`Failed to parse openclaw bindings JSON: ${message}`);
453
+ if (!Array.isArray(value)) {
454
+ throw new ClawChefError("openclaw config bindings is not an array");
282
455
  }
456
+ return value;
283
457
  }
284
458
  export class CommandOpenClawProvider {
285
459
  stagedMessages = new Map();
460
+ enabledChannelPlugins = new Set();
461
+ constructor(verboseEnabled = false) {
462
+ TRACE_VERBOSE = verboseEnabled;
463
+ }
286
464
  async ensureVersion(config, dryRun, silent, preserveExistingState) {
287
465
  const bin = config.bin ?? "openclaw";
288
466
  const installPolicy = config.install ?? "auto";
@@ -418,14 +596,15 @@ export class CommandOpenClawProvider {
418
596
  }
419
597
  async configureChannel(config, channel, dryRun) {
420
598
  const bin = config.bin ?? "openclaw";
599
+ const cfgPath = configPath();
421
600
  if (shouldAutoDisableTelegramChannel(channel)) {
422
- const enabledPath = telegramEnabledPath(channel.account);
423
- const disableCmd = `${bin} config set ${shellQuote(enabledPath)} false --strict-json`;
424
- await runShell(disableCmd, dryRun);
601
+ const openclawConfig = await loadConfigJson(cfgPath);
602
+ setConfigValue(openclawConfig, telegramEnabledPath(channel.account), false);
603
+ await saveConfigJson(cfgPath, openclawConfig, dryRun);
425
604
  return;
426
605
  }
427
606
  const enablePluginTemplate = config.commands?.enable_plugin;
428
- if (enablePluginTemplate?.trim()) {
607
+ if (enablePluginTemplate?.trim() && !this.enabledChannelPlugins.has(channel.channel)) {
429
608
  const enablePluginCmd = fillTemplate(enablePluginTemplate, {
430
609
  bin,
431
610
  version: config.version,
@@ -435,6 +614,10 @@ export class CommandOpenClawProvider {
435
614
  if (enablePluginCmd.trim()) {
436
615
  await runShell(enablePluginCmd, dryRun);
437
616
  }
617
+ this.enabledChannelPlugins.add(channel.channel);
618
+ }
619
+ else if (enablePluginTemplate?.trim()) {
620
+ traceDebug(`Skip plugin enable for channel=${channel.channel}; already enabled in this run`);
438
621
  }
439
622
  const flags = [
440
623
  "--channel",
@@ -475,11 +658,60 @@ export class CommandOpenClawProvider {
475
658
  const cmd = `${bin} channels add ${flags.join(" ")}`;
476
659
  await runShell(cmd, dryRun);
477
660
  if (channel.channel === "telegram" && channel.group_policy) {
478
- const configPath = telegramGroupPolicyPath(channel.account);
479
- const policyValue = JSON.stringify(channel.group_policy);
480
- const setPolicyCmd = `${bin} config set ${shellQuote(configPath)} ${shellQuote(policyValue)} --strict-json`;
481
- await runShell(setPolicyCmd, dryRun);
661
+ const openclawConfig = await loadConfigJson(cfgPath);
662
+ setConfigValue(openclawConfig, telegramGroupPolicyPath(channel.account), channel.group_policy);
663
+ await saveConfigJson(cfgPath, openclawConfig, dryRun);
664
+ }
665
+ }
666
+ async applyConfigPatch(config, patch, dryRun) {
667
+ const normalized = toJsonPatchValue(patch, "openclaw.config_patch");
668
+ if (!isPlainObject(normalized)) {
669
+ throw new ClawChefError("openclaw.config_patch must be an object");
670
+ }
671
+ const cfgPath = configPath();
672
+ const openclawConfig = await loadConfigJson(cfgPath);
673
+ const merged = deepMergeConfig(openclawConfig, normalized);
674
+ if (!isPlainObject(merged)) {
675
+ throw new ClawChefError("Merged OpenClaw config must be an object");
482
676
  }
677
+ await saveConfigJson(cfgPath, merged, dryRun);
678
+ }
679
+ async bindChannelAgents(config, bindingsInput, dryRun) {
680
+ if (bindingsInput.length === 0) {
681
+ return;
682
+ }
683
+ const customTemplate = config.commands?.bind_channel_agent;
684
+ if (customTemplate?.trim()) {
685
+ for (const binding of bindingsInput) {
686
+ await this.bindChannelAgent(config, binding.channel, binding.agent, dryRun);
687
+ }
688
+ return;
689
+ }
690
+ const cfgPath = configPath();
691
+ const openclawConfig = await loadConfigJson(cfgPath);
692
+ const bindings = parseBindingsValue(getConfigValue(openclawConfig, "bindings"));
693
+ for (const binding of bindingsInput) {
694
+ const account = binding.channel.account?.trim();
695
+ if (!account) {
696
+ throw new ClawChefError(`Channel ${binding.channel.channel} requires account for agent binding`);
697
+ }
698
+ const nextBinding = {
699
+ agentId: binding.agent,
700
+ match: {
701
+ channel: binding.channel.channel,
702
+ accountId: account,
703
+ },
704
+ };
705
+ const index = bindings.findIndex((item) => isAccountLevelBinding(item, binding.channel.channel, account));
706
+ if (index >= 0) {
707
+ bindings[index] = nextBinding;
708
+ }
709
+ else {
710
+ bindings.push(nextBinding);
711
+ }
712
+ }
713
+ setConfigValue(openclawConfig, "bindings", bindings);
714
+ await saveConfigJson(cfgPath, openclawConfig, dryRun);
483
715
  }
484
716
  async bindChannelAgent(config, channel, agent, dryRun) {
485
717
  const account = channel.account?.trim();
@@ -504,29 +736,7 @@ export class CommandOpenClawProvider {
504
736
  }
505
737
  return;
506
738
  }
507
- if (dryRun) {
508
- return;
509
- }
510
- const getCmd = `${bin} config get bindings --json 2>/dev/null || printf '[]'`;
511
- const rawBindings = await runShell(getCmd, false);
512
- const bindings = parseBindingsJson(rawBindings);
513
- const nextBinding = {
514
- agentId: agent,
515
- match: {
516
- channel: channel.channel,
517
- accountId: account,
518
- },
519
- };
520
- const index = bindings.findIndex((item) => isAccountLevelBinding(item, channel.channel, account));
521
- if (index >= 0) {
522
- bindings[index] = nextBinding;
523
- }
524
- else {
525
- bindings.push(nextBinding);
526
- }
527
- const json = JSON.stringify(bindings);
528
- const setCmd = `${bin} config set bindings ${shellQuote(json)} --json`;
529
- await runShell(setCmd, false);
739
+ await this.bindChannelAgents(config, [{ channel, agent }], dryRun);
530
740
  }
531
741
  async loginChannel(config, channel, dryRun) {
532
742
  if (!channel.login) {
@@ -7,7 +7,7 @@ export function createProvider(options) {
7
7
  return new MockOpenClawProvider();
8
8
  }
9
9
  if (provider === "remote") {
10
- return new RemoteOpenClawProvider(options.remote);
10
+ return new RemoteOpenClawProvider(options.remote, options.verbose);
11
11
  }
12
- return new CommandOpenClawProvider();
12
+ return new CommandOpenClawProvider(options.verbose);
13
13
  }
@@ -8,6 +8,7 @@ export declare class MockOpenClawProvider implements OpenClawProvider {
8
8
  startGateway(_config: OpenClawSection, _mode: GatewayMode, _dryRun: boolean): Promise<void>;
9
9
  createWorkspace(_config: OpenClawSection, workspace: ResolvedWorkspaceDef, _dryRun: boolean): Promise<void>;
10
10
  configureChannel(_config: OpenClawSection, channel: ChannelDef, _dryRun: boolean): Promise<void>;
11
+ applyConfigPatch(_config: OpenClawSection, _patch: Record<string, unknown>, _dryRun: boolean): Promise<void>;
11
12
  bindChannelAgent(_config: OpenClawSection, _channel: ChannelDef, _agent: string, _dryRun: boolean): Promise<void>;
12
13
  loginChannel(_config: OpenClawSection, _channel: ChannelDef, _dryRun: boolean): Promise<void>;
13
14
  createAgent(_config: OpenClawSection, agent: AgentDef, _workspacePath: string, _dryRun: boolean): Promise<void>;
@@ -44,6 +44,9 @@ export class MockOpenClawProvider {
44
44
  async configureChannel(_config, channel, _dryRun) {
45
45
  this.state.channels.add(`${channel.channel}::${channel.account ?? "default"}`);
46
46
  }
47
+ async applyConfigPatch(_config, _patch, _dryRun) {
48
+ return;
49
+ }
47
50
  async bindChannelAgent(_config, _channel, _agent, _dryRun) {
48
51
  return;
49
52
  }