clawchef 0.1.12 → 0.1.14

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
@@ -420,7 +442,37 @@ Supported common fields:
420
442
  `channels[].agent` currently supports `channel: "telegram"` only.
421
443
  If `agent` is set and `account` is omitted, clawchef defaults `account` to the same value as `agent`.
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
- If `channel: "telegram"` has `token: ""` or `bot_token: ""`, clawchef auto-disables that telegram account (`enabled=false`) and skips channel add/bind.
445
+ If `channel: "telegram"` has `token: ""` or `bot_token: ""`, clawchef skips that telegram account and does not run channel add/bind.
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
+ ```
424
476
 
425
477
  ## Workspace path behavior
426
478
 
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
  });
@@ -136,13 +165,6 @@ function telegramGroupPolicyPath(account) {
136
165
  }
137
166
  return `channels.telegram.accounts[${trimmed}].groupPolicy`;
138
167
  }
139
- function telegramEnabledPath(account) {
140
- const trimmed = account?.trim();
141
- if (!trimmed) {
142
- return "channels.telegram.enabled";
143
- }
144
- return `channels.telegram.accounts[${trimmed}].enabled`;
145
- }
146
168
  function shouldAutoDisableTelegramChannel(channel) {
147
169
  if (channel.channel !== "telegram") {
148
170
  return false;
@@ -151,6 +173,158 @@ function shouldAutoDisableTelegramChannel(channel) {
151
173
  const emptyBotToken = channel.bot_token !== undefined && channel.bot_token.trim().length === 0;
152
174
  return emptyToken || emptyBotToken;
153
175
  }
176
+ function isPlainObject(value) {
177
+ return typeof value === "object" && value !== null && !Array.isArray(value);
178
+ }
179
+ function splitConfigPath(pathExpression) {
180
+ const segments = [];
181
+ let token = "";
182
+ for (let i = 0; i < pathExpression.length; i += 1) {
183
+ const ch = pathExpression[i];
184
+ if (ch === ".") {
185
+ if (token) {
186
+ segments.push(token);
187
+ token = "";
188
+ }
189
+ continue;
190
+ }
191
+ if (ch === "[") {
192
+ if (token) {
193
+ segments.push(token);
194
+ token = "";
195
+ }
196
+ const end = pathExpression.indexOf("]", i + 1);
197
+ if (end < 0) {
198
+ throw new ClawChefError(`Invalid config path expression: ${pathExpression}`);
199
+ }
200
+ const bracketToken = pathExpression.slice(i + 1, end).trim();
201
+ if (!bracketToken) {
202
+ throw new ClawChefError(`Invalid empty bracket token in config path: ${pathExpression}`);
203
+ }
204
+ segments.push(bracketToken);
205
+ i = end;
206
+ continue;
207
+ }
208
+ token += ch;
209
+ }
210
+ if (token) {
211
+ segments.push(token);
212
+ }
213
+ if (segments.length === 0) {
214
+ throw new ClawChefError(`Invalid config path expression: ${pathExpression}`);
215
+ }
216
+ return segments;
217
+ }
218
+ function getConfigValue(root, pathExpression) {
219
+ const segments = splitConfigPath(pathExpression);
220
+ let cursor = root;
221
+ for (const segment of segments) {
222
+ if (!isPlainObject(cursor)) {
223
+ return undefined;
224
+ }
225
+ cursor = cursor[segment];
226
+ }
227
+ return cursor;
228
+ }
229
+ function setConfigValue(root, pathExpression, value) {
230
+ const segments = splitConfigPath(pathExpression);
231
+ let cursor = root;
232
+ for (let i = 0; i < segments.length - 1; i += 1) {
233
+ const segment = segments[i];
234
+ const existing = cursor[segment];
235
+ if (isPlainObject(existing)) {
236
+ cursor = existing;
237
+ continue;
238
+ }
239
+ const next = {};
240
+ cursor[segment] = next;
241
+ cursor = next;
242
+ }
243
+ cursor[segments[segments.length - 1]] = value;
244
+ }
245
+ function deepMergeConfig(base, patch) {
246
+ if (!isPlainObject(patch)) {
247
+ return patch;
248
+ }
249
+ const baseObject = isPlainObject(base) ? base : {};
250
+ const result = {};
251
+ for (const [k, v] of Object.entries(baseObject)) {
252
+ result[k] = toJsonPatchValue(v, `base.${k}`);
253
+ }
254
+ for (const [k, v] of Object.entries(patch)) {
255
+ const current = result[k];
256
+ if (isPlainObject(v) && isPlainObject(current)) {
257
+ result[k] = deepMergeConfig(current, v);
258
+ }
259
+ else {
260
+ result[k] = v;
261
+ }
262
+ }
263
+ return result;
264
+ }
265
+ function configPath() {
266
+ const fromEnv = process.env.OPENCLAW_CONFIG_PATH?.trim();
267
+ if (fromEnv) {
268
+ return fromEnv;
269
+ }
270
+ return path.join(homedir(), ".openclaw", "openclaw.json");
271
+ }
272
+ async function loadConfigJson(configFilePath) {
273
+ let raw;
274
+ try {
275
+ raw = await readFile(configFilePath, "utf8");
276
+ }
277
+ catch (err) {
278
+ const message = err instanceof Error ? err.message : String(err);
279
+ throw new ClawChefError(`Failed to read OpenClaw config at ${configFilePath}: ${message}`);
280
+ }
281
+ try {
282
+ const parsed = JSON.parse(raw);
283
+ if (!isPlainObject(parsed)) {
284
+ throw new ClawChefError(`OpenClaw config root must be an object: ${configFilePath}`);
285
+ }
286
+ return parsed;
287
+ }
288
+ catch (err) {
289
+ if (err instanceof ClawChefError) {
290
+ throw err;
291
+ }
292
+ const message = err instanceof Error ? err.message : String(err);
293
+ throw new ClawChefError(`Failed to parse OpenClaw config JSON at ${configFilePath}: ${message}`);
294
+ }
295
+ }
296
+ async function saveConfigJson(configFilePath, config, dryRun) {
297
+ if (dryRun) {
298
+ traceDebug(`CONFIG DRY-RUN write: ${configFilePath}`);
299
+ return;
300
+ }
301
+ const dir = path.dirname(configFilePath);
302
+ await mkdir(dir, { recursive: true });
303
+ const tempPath = `${configFilePath}.tmp-${process.pid}-${Date.now()}`;
304
+ const payload = `${JSON.stringify(config, null, 2)}\n`;
305
+ await writeFile(tempPath, payload, "utf8");
306
+ await rename(tempPath, configFilePath);
307
+ traceDebug(`CONFIG WRITE: ${configFilePath}`);
308
+ }
309
+ function toJsonPatchValue(value, pathLabel) {
310
+ if (value === null ||
311
+ typeof value === "string" ||
312
+ typeof value === "number" ||
313
+ typeof value === "boolean") {
314
+ return value;
315
+ }
316
+ if (Array.isArray(value)) {
317
+ return value.map((item, index) => toJsonPatchValue(item, `${pathLabel}[${index}]`));
318
+ }
319
+ if (isPlainObject(value)) {
320
+ const out = {};
321
+ for (const [k, v] of Object.entries(value)) {
322
+ out[k] = toJsonPatchValue(v, `${pathLabel}.${k}`);
323
+ }
324
+ return out;
325
+ }
326
+ throw new ClawChefError(`openclaw.config_patch contains unsupported value at ${pathLabel}`);
327
+ }
154
328
  async function chooseVersionMismatchAction(currentVersion, expectedVersion, silent) {
155
329
  if (silent) {
156
330
  return "force";
@@ -265,24 +439,21 @@ function isAccountLevelBinding(item, channel, account) {
265
439
  && match.teamId === undefined
266
440
  && match.roles === undefined);
267
441
  }
268
- function parseBindingsJson(raw) {
269
- if (!raw.trim()) {
442
+ function parseBindingsValue(value) {
443
+ if (value === undefined || value === null) {
270
444
  return [];
271
445
  }
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}`);
446
+ if (!Array.isArray(value)) {
447
+ throw new ClawChefError("openclaw config bindings is not an array");
282
448
  }
449
+ return value;
283
450
  }
284
451
  export class CommandOpenClawProvider {
285
452
  stagedMessages = new Map();
453
+ enabledChannelPlugins = new Set();
454
+ constructor(verboseEnabled = false) {
455
+ TRACE_VERBOSE = verboseEnabled;
456
+ }
286
457
  async ensureVersion(config, dryRun, silent, preserveExistingState) {
287
458
  const bin = config.bin ?? "openclaw";
288
459
  const installPolicy = config.install ?? "auto";
@@ -418,14 +589,13 @@ export class CommandOpenClawProvider {
418
589
  }
419
590
  async configureChannel(config, channel, dryRun) {
420
591
  const bin = config.bin ?? "openclaw";
592
+ const cfgPath = configPath();
421
593
  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);
594
+ traceDebug(`Skip telegram channel with empty token: ${channel.channel}${channel.account ? `/${channel.account}` : ""}`);
425
595
  return;
426
596
  }
427
597
  const enablePluginTemplate = config.commands?.enable_plugin;
428
- if (enablePluginTemplate?.trim()) {
598
+ if (enablePluginTemplate?.trim() && !this.enabledChannelPlugins.has(channel.channel)) {
429
599
  const enablePluginCmd = fillTemplate(enablePluginTemplate, {
430
600
  bin,
431
601
  version: config.version,
@@ -435,6 +605,10 @@ export class CommandOpenClawProvider {
435
605
  if (enablePluginCmd.trim()) {
436
606
  await runShell(enablePluginCmd, dryRun);
437
607
  }
608
+ this.enabledChannelPlugins.add(channel.channel);
609
+ }
610
+ else if (enablePluginTemplate?.trim()) {
611
+ traceDebug(`Skip plugin enable for channel=${channel.channel}; already enabled in this run`);
438
612
  }
439
613
  const flags = [
440
614
  "--channel",
@@ -475,12 +649,61 @@ export class CommandOpenClawProvider {
475
649
  const cmd = `${bin} channels add ${flags.join(" ")}`;
476
650
  await runShell(cmd, dryRun);
477
651
  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);
652
+ const openclawConfig = await loadConfigJson(cfgPath);
653
+ setConfigValue(openclawConfig, telegramGroupPolicyPath(channel.account), channel.group_policy);
654
+ await saveConfigJson(cfgPath, openclawConfig, dryRun);
482
655
  }
483
656
  }
657
+ async applyConfigPatch(config, patch, dryRun) {
658
+ const normalized = toJsonPatchValue(patch, "openclaw.config_patch");
659
+ if (!isPlainObject(normalized)) {
660
+ throw new ClawChefError("openclaw.config_patch must be an object");
661
+ }
662
+ const cfgPath = configPath();
663
+ const openclawConfig = await loadConfigJson(cfgPath);
664
+ const merged = deepMergeConfig(openclawConfig, normalized);
665
+ if (!isPlainObject(merged)) {
666
+ throw new ClawChefError("Merged OpenClaw config must be an object");
667
+ }
668
+ await saveConfigJson(cfgPath, merged, dryRun);
669
+ }
670
+ async bindChannelAgents(config, bindingsInput, dryRun) {
671
+ if (bindingsInput.length === 0) {
672
+ return;
673
+ }
674
+ const customTemplate = config.commands?.bind_channel_agent;
675
+ if (customTemplate?.trim()) {
676
+ for (const binding of bindingsInput) {
677
+ await this.bindChannelAgent(config, binding.channel, binding.agent, dryRun);
678
+ }
679
+ return;
680
+ }
681
+ const cfgPath = configPath();
682
+ const openclawConfig = await loadConfigJson(cfgPath);
683
+ const bindings = parseBindingsValue(getConfigValue(openclawConfig, "bindings"));
684
+ for (const binding of bindingsInput) {
685
+ const account = binding.channel.account?.trim();
686
+ if (!account) {
687
+ throw new ClawChefError(`Channel ${binding.channel.channel} requires account for agent binding`);
688
+ }
689
+ const nextBinding = {
690
+ agentId: binding.agent,
691
+ match: {
692
+ channel: binding.channel.channel,
693
+ accountId: account,
694
+ },
695
+ };
696
+ const index = bindings.findIndex((item) => isAccountLevelBinding(item, binding.channel.channel, account));
697
+ if (index >= 0) {
698
+ bindings[index] = nextBinding;
699
+ }
700
+ else {
701
+ bindings.push(nextBinding);
702
+ }
703
+ }
704
+ setConfigValue(openclawConfig, "bindings", bindings);
705
+ await saveConfigJson(cfgPath, openclawConfig, dryRun);
706
+ }
484
707
  async bindChannelAgent(config, channel, agent, dryRun) {
485
708
  const account = channel.account?.trim();
486
709
  if (!account) {
@@ -504,29 +727,7 @@ export class CommandOpenClawProvider {
504
727
  }
505
728
  return;
506
729
  }
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);
730
+ await this.bindChannelAgents(config, [{ channel, agent }], dryRun);
530
731
  }
531
732
  async loginChannel(config, channel, dryRun) {
532
733
  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
  }