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.
package/src/api.ts CHANGED
@@ -12,6 +12,7 @@ import type { ScaffoldOptions, ScaffoldResult } from "./scaffold.js";
12
12
  export interface CookOptions {
13
13
  vars?: Record<string, string>;
14
14
  plugins?: string[];
15
+ filePatterns?: string[];
15
16
  dryRun?: boolean;
16
17
  allowMissing?: boolean;
17
18
  verbose?: boolean;
@@ -27,6 +28,9 @@ export interface CookOptions {
27
28
 
28
29
  function normalizeCookOptions(options: CookOptions): RunOptions {
29
30
  const plugins = Array.from(new Set((options.plugins ?? []).map((value) => value.trim()).filter((value) => value.length > 0)));
31
+ const filePatterns = Array.from(
32
+ new Set((options.filePatterns ?? []).map((value) => value.trim()).filter((value) => value.length > 0)),
33
+ );
30
34
  const scope = options.scope ?? "full";
31
35
  const workspaceName = options.workspaceName?.trim() || undefined;
32
36
  if (scope === "workspace" && !workspaceName) {
@@ -35,9 +39,13 @@ function normalizeCookOptions(options: CookOptions): RunOptions {
35
39
  if (scope !== "workspace" && workspaceName) {
36
40
  throw new ClawChefError("workspaceName is only allowed when scope=workspace");
37
41
  }
42
+ if (scope !== "files" && filePatterns.length > 0) {
43
+ throw new ClawChefError("filePatterns is only allowed when scope=files");
44
+ }
38
45
  return {
39
46
  vars: options.vars ?? {},
40
47
  plugins,
48
+ filePatterns,
41
49
  scope,
42
50
  workspaceName,
43
51
  gatewayMode: options.gatewayMode ?? "service",
package/src/cli.ts CHANGED
@@ -45,6 +45,10 @@ function parsePluginFlags(values: string[]): string[] {
45
45
  return Array.from(new Set(values.map((value) => value.trim()).filter((value) => value.length > 0)));
46
46
  }
47
47
 
48
+ function parseFileFlags(values: string[]): string[] {
49
+ return Array.from(new Set(values.map((value) => value.trim()).filter((value) => value.length > 0)));
50
+ }
51
+
48
52
  function readEnv(name: string): string | undefined {
49
53
  const value = process.env[name];
50
54
  if (value === undefined) {
@@ -62,10 +66,10 @@ function parseProvider(value: string): "command" | "mock" | "remote" {
62
66
  }
63
67
 
64
68
  function parseScope(value: string): RunScope {
65
- if (value === "full" || value === "files" || value === "workspace") {
69
+ if (value === "full" || value === "stateful" || value === "files" || value === "workspace") {
66
70
  return value;
67
71
  }
68
- throw new ClawChefError(`Invalid --scope value: ${value}. Expected full, files, or workspace`);
72
+ throw new ClawChefError(`Invalid --scope value: ${value}. Expected full, stateful, files, or workspace`);
69
73
  }
70
74
 
71
75
  function parseGatewayMode(value: string): GatewayMode {
@@ -117,7 +121,8 @@ export function buildCli(): Command {
117
121
  .option("--allow-missing", "Allow unresolved template variables", false)
118
122
  .option("--verbose", "Verbose logging", false)
119
123
  .option("-s, --silent", "Skip reset confirmation prompt", false)
120
- .option("--scope <scope>", "Run scope: full | files | workspace", "full")
124
+ .option("--scope <scope>", "Run scope: full | stateful | files | workspace", "full")
125
+ .option("--file <pattern>", "File pattern filter (only with --scope files, repeatable)", (v, p: string[]) => p.concat([v]), [])
121
126
  .option("--workspace <name>", "Workspace name (required when --scope workspace)")
122
127
  .option("--gateway-mode <mode>", "Gateway mode: service | run | none", "service")
123
128
  .option("--dotenv-ref <path-or-url>", "Load env vars from local file or HTTP URL")
@@ -139,6 +144,7 @@ export function buildCli(): Command {
139
144
  const provider = parseProvider(opts.provider ?? readEnv("CLAWCHEF_PROVIDER") ?? "command");
140
145
  const scope = parseScope(String(opts.scope ?? "full"));
141
146
  const gatewayMode = parseGatewayMode(String(opts.gatewayMode ?? "service"));
147
+ const filePatterns = parseFileFlags(opts.file);
142
148
  const workspaceName = opts.workspace?.trim() ? String(opts.workspace).trim() : undefined;
143
149
  if (scope === "workspace" && !workspaceName) {
144
150
  throw new ClawChefError("--scope workspace requires --workspace <name>");
@@ -146,9 +152,13 @@ export function buildCli(): Command {
146
152
  if (scope !== "workspace" && workspaceName) {
147
153
  throw new ClawChefError("--workspace is only allowed when --scope workspace");
148
154
  }
155
+ if (scope !== "files" && filePatterns.length > 0) {
156
+ throw new ClawChefError("--file is only allowed when --scope files");
157
+ }
149
158
  const options: RunOptions = {
150
159
  vars: parseVarFlags(opts.var),
151
160
  plugins: parsePluginFlags(opts.plugin),
161
+ filePatterns,
152
162
  scope,
153
163
  workspaceName,
154
164
  gatewayMode,
package/src/logger.ts CHANGED
@@ -1,17 +1,28 @@
1
1
  export class Logger {
2
2
  constructor(private readonly verboseEnabled: boolean) {}
3
3
 
4
+ private timestamp(): string {
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
+ }
14
+
4
15
  info(message: string): void {
5
- process.stdout.write(`[INFO] ${message}\n`);
16
+ process.stdout.write(`[${this.timestamp()}] [INFO] ${message}\n`);
6
17
  }
7
18
 
8
19
  warn(message: string): void {
9
- process.stdout.write(`[WARN] ${message}\n`);
20
+ process.stdout.write(`[${this.timestamp()}] [WARN] ${message}\n`);
10
21
  }
11
22
 
12
23
  debug(message: string): void {
13
24
  if (this.verboseEnabled) {
14
- process.stdout.write(`[DEBUG] ${message}\n`);
25
+ process.stdout.write(`[${this.timestamp()}] [DEBUG] ${message}\n`);
15
26
  }
16
27
  }
17
28
  }
@@ -1,13 +1,13 @@
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";
8
8
  import { ClawChefError } from "../errors.js";
9
9
  import type { AgentDef, ChannelDef, ConversationDef, GatewayMode, OpenClawBootstrap, OpenClawSection } from "../types.js";
10
- import type { EnsureVersionResult, OpenClawProvider, ResolvedWorkspaceDef } from "./provider.js";
10
+ import type { ChannelAgentBinding, EnsureVersionResult, OpenClawProvider, ResolvedWorkspaceDef } from "./provider.js";
11
11
 
12
12
  const DEFAULT_COMMANDS = {
13
13
  use_version: "${bin} --version",
@@ -50,6 +50,26 @@ interface BindingItem {
50
50
  const SECRET_FLAG_RE =
51
51
  /(--[A-Za-z0-9-]*(?:api-key|token|password|secret)[A-Za-z0-9-]*\s+)(?:'[^']*'|"[^"]*"|\S+)/g;
52
52
 
53
+ let TRACE_VERBOSE = false;
54
+
55
+ function timestamp(): string {
56
+ const now = new Date();
57
+ const year = now.getFullYear();
58
+ const month = String(now.getMonth() + 1).padStart(2, "0");
59
+ const day = String(now.getDate()).padStart(2, "0");
60
+ const hours = String(now.getHours()).padStart(2, "0");
61
+ const minutes = String(now.getMinutes()).padStart(2, "0");
62
+ const seconds = String(now.getSeconds()).padStart(2, "0");
63
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
64
+ }
65
+
66
+ function traceDebug(message: string): void {
67
+ if (!TRACE_VERBOSE) {
68
+ return;
69
+ }
70
+ process.stdout.write(`[${timestamp()}] [DEBUG] ${message}\n`);
71
+ }
72
+
53
73
  type BootstrapStringField =
54
74
  | "cloudflare_ai_gateway_account_id"
55
75
  | "cloudflare_ai_gateway_gateway_id"
@@ -111,10 +131,15 @@ async function commandExists(bin: string): Promise<boolean> {
111
131
  }
112
132
 
113
133
  async function runShell(command: string, dryRun: boolean, extraEnv?: Record<string, string>): Promise<string> {
134
+ const sanitized = sanitizeCommand(command);
114
135
  if (dryRun) {
136
+ traceDebug(`CMD DRY-RUN: ${sanitized}`);
115
137
  return "";
116
138
  }
117
139
 
140
+ const startedAt = Date.now();
141
+ traceDebug(`CMD START: ${sanitized}`);
142
+
118
143
  return new Promise<string>((resolve, reject) => {
119
144
  const child = spawn(command, {
120
145
  shell: true,
@@ -135,19 +160,26 @@ async function runShell(command: string, dryRun: boolean, extraEnv?: Record<stri
135
160
  });
136
161
  child.on("close", (code) => {
137
162
  if (code === 0) {
163
+ traceDebug(`CMD DONE (${Date.now() - startedAt}ms): ${sanitized}`);
138
164
  resolve(stdout.trim());
139
165
  return;
140
166
  }
167
+ traceDebug(`CMD FAIL (${Date.now() - startedAt}ms) code=${String(code)}: ${sanitized}`);
141
168
  reject(new ClawChefError(`Command failed (${code}): ${sanitizeCommand(command)}\n${stderr.trim()}`));
142
169
  });
143
170
  });
144
171
  }
145
172
 
146
173
  async function runShellInteractive(command: string, dryRun: boolean): Promise<void> {
174
+ const sanitized = sanitizeCommand(command);
147
175
  if (dryRun) {
176
+ traceDebug(`CMD DRY-RUN (interactive): ${sanitized}`);
148
177
  return;
149
178
  }
150
179
 
180
+ const startedAt = Date.now();
181
+ traceDebug(`CMD START (interactive): ${sanitized}`);
182
+
151
183
  return new Promise<void>((resolve, reject) => {
152
184
  const child = spawn(command, {
153
185
  shell: true,
@@ -160,9 +192,11 @@ async function runShellInteractive(command: string, dryRun: boolean): Promise<vo
160
192
  });
161
193
  child.on("close", (code) => {
162
194
  if (code === 0) {
195
+ traceDebug(`CMD DONE (interactive, ${Date.now() - startedAt}ms): ${sanitized}`);
163
196
  resolve();
164
197
  return;
165
198
  }
199
+ traceDebug(`CMD FAIL (interactive, ${Date.now() - startedAt}ms) code=${String(code)}: ${sanitized}`);
166
200
  reject(new ClawChefError(`Command failed (${code}): ${sanitizeCommand(command)}`));
167
201
  });
168
202
  });
@@ -186,8 +220,196 @@ function telegramGroupPolicyPath(account: string | undefined): string {
186
220
  return `channels.telegram.accounts[${trimmed}].groupPolicy`;
187
221
  }
188
222
 
223
+ function telegramEnabledPath(account: string | undefined): string {
224
+ const trimmed = account?.trim();
225
+ if (!trimmed) {
226
+ return "channels.telegram.enabled";
227
+ }
228
+ return `channels.telegram.accounts[${trimmed}].enabled`;
229
+ }
230
+
231
+ function shouldAutoDisableTelegramChannel(channel: ChannelDef): boolean {
232
+ if (channel.channel !== "telegram") {
233
+ return false;
234
+ }
235
+ const emptyToken = channel.token !== undefined && channel.token.trim().length === 0;
236
+ const emptyBotToken = channel.bot_token !== undefined && channel.bot_token.trim().length === 0;
237
+ return emptyToken || emptyBotToken;
238
+ }
239
+
189
240
  type VersionMismatchChoice = "ignore" | "abort" | "force";
190
241
 
242
+ type JsonPatchValue = null | boolean | number | string | JsonPatchValue[] | { [key: string]: JsonPatchValue };
243
+ type ConfigJson = { [key: string]: unknown };
244
+
245
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
246
+ return typeof value === "object" && value !== null && !Array.isArray(value);
247
+ }
248
+
249
+ function splitConfigPath(pathExpression: string): string[] {
250
+ const segments: string[] = [];
251
+ let token = "";
252
+ for (let i = 0; i < pathExpression.length; i += 1) {
253
+ const ch = pathExpression[i];
254
+ if (ch === ".") {
255
+ if (token) {
256
+ segments.push(token);
257
+ token = "";
258
+ }
259
+ continue;
260
+ }
261
+ if (ch === "[") {
262
+ if (token) {
263
+ segments.push(token);
264
+ token = "";
265
+ }
266
+ const end = pathExpression.indexOf("]", i + 1);
267
+ if (end < 0) {
268
+ throw new ClawChefError(`Invalid config path expression: ${pathExpression}`);
269
+ }
270
+ const bracketToken = pathExpression.slice(i + 1, end).trim();
271
+ if (!bracketToken) {
272
+ throw new ClawChefError(`Invalid empty bracket token in config path: ${pathExpression}`);
273
+ }
274
+ segments.push(bracketToken);
275
+ i = end;
276
+ continue;
277
+ }
278
+ token += ch;
279
+ }
280
+ if (token) {
281
+ segments.push(token);
282
+ }
283
+ if (segments.length === 0) {
284
+ throw new ClawChefError(`Invalid config path expression: ${pathExpression}`);
285
+ }
286
+ return segments;
287
+ }
288
+
289
+ function getConfigValue(root: unknown, pathExpression: string): unknown {
290
+ const segments = splitConfigPath(pathExpression);
291
+ let cursor: unknown = root;
292
+ for (const segment of segments) {
293
+ if (!isPlainObject(cursor)) {
294
+ return undefined;
295
+ }
296
+ cursor = (cursor as Record<string, unknown>)[segment];
297
+ }
298
+ return cursor;
299
+ }
300
+
301
+ function setConfigValue(root: ConfigJson, pathExpression: string, value: unknown): void {
302
+ const segments = splitConfigPath(pathExpression);
303
+ let cursor: Record<string, unknown> = root;
304
+
305
+ for (let i = 0; i < segments.length - 1; i += 1) {
306
+ const segment = segments[i];
307
+ const existing = cursor[segment];
308
+ if (isPlainObject(existing)) {
309
+ cursor = existing;
310
+ continue;
311
+ }
312
+ const next: Record<string, unknown> = {};
313
+ cursor[segment] = next;
314
+ cursor = next;
315
+ }
316
+
317
+ cursor[segments[segments.length - 1]] = value;
318
+ }
319
+
320
+ function deepMergeConfig(base: unknown, patch: JsonPatchValue): JsonPatchValue {
321
+ if (!isPlainObject(patch)) {
322
+ return patch;
323
+ }
324
+
325
+ const baseObject = isPlainObject(base) ? base : {};
326
+ const result: Record<string, JsonPatchValue> = {};
327
+
328
+ for (const [k, v] of Object.entries(baseObject)) {
329
+ result[k] = toJsonPatchValue(v, `base.${k}`);
330
+ }
331
+
332
+ for (const [k, v] of Object.entries(patch)) {
333
+ const current = result[k];
334
+ if (isPlainObject(v) && isPlainObject(current)) {
335
+ result[k] = deepMergeConfig(current, v);
336
+ } else {
337
+ result[k] = v;
338
+ }
339
+ }
340
+
341
+ return result;
342
+ }
343
+
344
+ function configPath(): string {
345
+ const fromEnv = process.env.OPENCLAW_CONFIG_PATH?.trim();
346
+ if (fromEnv) {
347
+ return fromEnv;
348
+ }
349
+ return path.join(homedir(), ".openclaw", "openclaw.json");
350
+ }
351
+
352
+ async function loadConfigJson(configFilePath: string): Promise<ConfigJson> {
353
+ let raw: string;
354
+ try {
355
+ raw = await readFile(configFilePath, "utf8");
356
+ } catch (err) {
357
+ const message = err instanceof Error ? err.message : String(err);
358
+ throw new ClawChefError(`Failed to read OpenClaw config at ${configFilePath}: ${message}`);
359
+ }
360
+
361
+ try {
362
+ const parsed = JSON.parse(raw) as unknown;
363
+ if (!isPlainObject(parsed)) {
364
+ throw new ClawChefError(`OpenClaw config root must be an object: ${configFilePath}`);
365
+ }
366
+ return parsed;
367
+ } catch (err) {
368
+ if (err instanceof ClawChefError) {
369
+ throw err;
370
+ }
371
+ const message = err instanceof Error ? err.message : String(err);
372
+ throw new ClawChefError(`Failed to parse OpenClaw config JSON at ${configFilePath}: ${message}`);
373
+ }
374
+ }
375
+
376
+ async function saveConfigJson(configFilePath: string, config: ConfigJson, dryRun: boolean): Promise<void> {
377
+ if (dryRun) {
378
+ traceDebug(`CONFIG DRY-RUN write: ${configFilePath}`);
379
+ return;
380
+ }
381
+
382
+ const dir = path.dirname(configFilePath);
383
+ await mkdir(dir, { recursive: true });
384
+ const tempPath = `${configFilePath}.tmp-${process.pid}-${Date.now()}`;
385
+ const payload = `${JSON.stringify(config, null, 2)}\n`;
386
+ await writeFile(tempPath, payload, "utf8");
387
+ await rename(tempPath, configFilePath);
388
+ traceDebug(`CONFIG WRITE: ${configFilePath}`);
389
+ }
390
+
391
+ function toJsonPatchValue(value: unknown, pathLabel: string): JsonPatchValue {
392
+ if (
393
+ value === null ||
394
+ typeof value === "string" ||
395
+ typeof value === "number" ||
396
+ typeof value === "boolean"
397
+ ) {
398
+ return value;
399
+ }
400
+ if (Array.isArray(value)) {
401
+ return value.map((item, index) => toJsonPatchValue(item, `${pathLabel}[${index}]`));
402
+ }
403
+ if (isPlainObject(value)) {
404
+ const out: { [key: string]: JsonPatchValue } = {};
405
+ for (const [k, v] of Object.entries(value)) {
406
+ out[k] = toJsonPatchValue(v, `${pathLabel}.${k}`);
407
+ }
408
+ return out;
409
+ }
410
+ throw new ClawChefError(`openclaw.config_patch contains unsupported value at ${pathLabel}`);
411
+ }
412
+
191
413
  async function chooseVersionMismatchAction(
192
414
  currentVersion: string,
193
415
  expectedVersion: string,
@@ -321,24 +543,23 @@ function isAccountLevelBinding(item: BindingItem, channel: string, account: stri
321
543
  );
322
544
  }
323
545
 
324
- function parseBindingsJson(raw: string): BindingItem[] {
325
- if (!raw.trim()) {
546
+ function parseBindingsValue(value: unknown): BindingItem[] {
547
+ if (value === undefined || value === null) {
326
548
  return [];
327
549
  }
328
- try {
329
- const parsed = JSON.parse(raw) as unknown;
330
- if (!Array.isArray(parsed)) {
331
- throw new ClawChefError("openclaw config bindings is not an array");
332
- }
333
- return parsed as BindingItem[];
334
- } catch (err) {
335
- const message = err instanceof Error ? err.message : String(err);
336
- throw new ClawChefError(`Failed to parse openclaw bindings JSON: ${message}`);
550
+ if (!Array.isArray(value)) {
551
+ throw new ClawChefError("openclaw config bindings is not an array");
337
552
  }
553
+ return value as BindingItem[];
338
554
  }
339
555
 
340
556
  export class CommandOpenClawProvider implements OpenClawProvider {
341
557
  private readonly stagedMessages = new Map<string, StagedMessage[]>();
558
+ private readonly enabledChannelPlugins = new Set<string>();
559
+
560
+ constructor(verboseEnabled = false) {
561
+ TRACE_VERBOSE = verboseEnabled;
562
+ }
342
563
 
343
564
  async ensureVersion(
344
565
  config: OpenClawSection,
@@ -510,8 +731,17 @@ export class CommandOpenClawProvider implements OpenClawProvider {
510
731
 
511
732
  async configureChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void> {
512
733
  const bin = config.bin ?? "openclaw";
734
+ const cfgPath = configPath();
735
+
736
+ if (shouldAutoDisableTelegramChannel(channel)) {
737
+ const openclawConfig = await loadConfigJson(cfgPath);
738
+ setConfigValue(openclawConfig, telegramEnabledPath(channel.account), false);
739
+ await saveConfigJson(cfgPath, openclawConfig, dryRun);
740
+ return;
741
+ }
742
+
513
743
  const enablePluginTemplate = config.commands?.enable_plugin;
514
- if (enablePluginTemplate?.trim()) {
744
+ if (enablePluginTemplate?.trim() && !this.enabledChannelPlugins.has(channel.channel)) {
515
745
  const enablePluginCmd = fillTemplate(enablePluginTemplate, {
516
746
  bin,
517
747
  version: config.version,
@@ -521,6 +751,9 @@ export class CommandOpenClawProvider implements OpenClawProvider {
521
751
  if (enablePluginCmd.trim()) {
522
752
  await runShell(enablePluginCmd, dryRun);
523
753
  }
754
+ this.enabledChannelPlugins.add(channel.channel);
755
+ } else if (enablePluginTemplate?.trim()) {
756
+ traceDebug(`Skip plugin enable for channel=${channel.channel}; already enabled in this run`);
524
757
  }
525
758
 
526
759
  const flags: string[] = [
@@ -568,13 +801,70 @@ export class CommandOpenClawProvider implements OpenClawProvider {
568
801
  await runShell(cmd, dryRun);
569
802
 
570
803
  if (channel.channel === "telegram" && channel.group_policy) {
571
- const configPath = telegramGroupPolicyPath(channel.account);
572
- const policyValue = JSON.stringify(channel.group_policy);
573
- const setPolicyCmd = `${bin} config set ${shellQuote(configPath)} ${shellQuote(policyValue)} --strict-json`;
574
- await runShell(setPolicyCmd, dryRun);
804
+ const openclawConfig = await loadConfigJson(cfgPath);
805
+ setConfigValue(openclawConfig, telegramGroupPolicyPath(channel.account), channel.group_policy);
806
+ await saveConfigJson(cfgPath, openclawConfig, dryRun);
575
807
  }
576
808
  }
577
809
 
810
+ async applyConfigPatch(config: OpenClawSection, patch: Record<string, unknown>, dryRun: boolean): Promise<void> {
811
+ const normalized = toJsonPatchValue(patch, "openclaw.config_patch");
812
+ if (!isPlainObject(normalized)) {
813
+ throw new ClawChefError("openclaw.config_patch must be an object");
814
+ }
815
+
816
+ const cfgPath = configPath();
817
+ const openclawConfig = await loadConfigJson(cfgPath);
818
+ const merged = deepMergeConfig(openclawConfig, normalized);
819
+ if (!isPlainObject(merged)) {
820
+ throw new ClawChefError("Merged OpenClaw config must be an object");
821
+ }
822
+ await saveConfigJson(cfgPath, merged, dryRun);
823
+ }
824
+
825
+ async bindChannelAgents(config: OpenClawSection, bindingsInput: ChannelAgentBinding[], dryRun: boolean): Promise<void> {
826
+ if (bindingsInput.length === 0) {
827
+ return;
828
+ }
829
+
830
+ const customTemplate = config.commands?.bind_channel_agent;
831
+ if (customTemplate?.trim()) {
832
+ for (const binding of bindingsInput) {
833
+ await this.bindChannelAgent(config, binding.channel, binding.agent, dryRun);
834
+ }
835
+ return;
836
+ }
837
+
838
+ const cfgPath = configPath();
839
+ const openclawConfig = await loadConfigJson(cfgPath);
840
+ const bindings = parseBindingsValue(getConfigValue(openclawConfig, "bindings"));
841
+
842
+ for (const binding of bindingsInput) {
843
+ const account = binding.channel.account?.trim();
844
+ if (!account) {
845
+ throw new ClawChefError(`Channel ${binding.channel.channel} requires account for agent binding`);
846
+ }
847
+
848
+ const nextBinding: BindingItem = {
849
+ agentId: binding.agent,
850
+ match: {
851
+ channel: binding.channel.channel,
852
+ accountId: account,
853
+ },
854
+ };
855
+
856
+ const index = bindings.findIndex((item: BindingItem) => isAccountLevelBinding(item, binding.channel.channel, account));
857
+ if (index >= 0) {
858
+ bindings[index] = nextBinding;
859
+ } else {
860
+ bindings.push(nextBinding);
861
+ }
862
+ }
863
+
864
+ setConfigValue(openclawConfig, "bindings", bindings);
865
+ await saveConfigJson(cfgPath, openclawConfig, dryRun);
866
+ }
867
+
578
868
  async bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void> {
579
869
  const account = channel.account?.trim();
580
870
  if (!account) {
@@ -600,31 +890,7 @@ export class CommandOpenClawProvider implements OpenClawProvider {
600
890
  return;
601
891
  }
602
892
 
603
- if (dryRun) {
604
- return;
605
- }
606
-
607
- const getCmd = `${bin} config get bindings --json 2>/dev/null || printf '[]'`;
608
- const rawBindings = await runShell(getCmd, false);
609
- const bindings = parseBindingsJson(rawBindings);
610
- const nextBinding: BindingItem = {
611
- agentId: agent,
612
- match: {
613
- channel: channel.channel,
614
- accountId: account,
615
- },
616
- };
617
-
618
- const index = bindings.findIndex((item) => isAccountLevelBinding(item, channel.channel, account));
619
- if (index >= 0) {
620
- bindings[index] = nextBinding;
621
- } else {
622
- bindings.push(nextBinding);
623
- }
624
-
625
- const json = JSON.stringify(bindings);
626
- const setCmd = `${bin} config set bindings ${shellQuote(json)} --json`;
627
- await runShell(setCmd, false);
893
+ await this.bindChannelAgents(config, [{ channel, agent }], dryRun);
628
894
  }
629
895
 
630
896
  async loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void> {
@@ -10,7 +10,7 @@ export function createProvider(options: RunOptions): OpenClawProvider {
10
10
  return new MockOpenClawProvider();
11
11
  }
12
12
  if (provider === "remote") {
13
- return new RemoteOpenClawProvider(options.remote);
13
+ return new RemoteOpenClawProvider(options.remote, options.verbose);
14
14
  }
15
- return new CommandOpenClawProvider();
15
+ return new CommandOpenClawProvider(options.verbose);
16
16
  }
@@ -69,6 +69,10 @@ export class MockOpenClawProvider implements OpenClawProvider {
69
69
  this.state.channels.add(`${channel.channel}::${channel.account ?? "default"}`);
70
70
  }
71
71
 
72
+ async applyConfigPatch(_config: OpenClawSection, _patch: Record<string, unknown>, _dryRun: boolean): Promise<void> {
73
+ return;
74
+ }
75
+
72
76
  async bindChannelAgent(
73
77
  _config: OpenClawSection,
74
78
  _channel: ChannelDef,
@@ -6,6 +6,11 @@ export interface EnsureVersionResult {
6
6
  installedThisRun: boolean;
7
7
  }
8
8
 
9
+ export interface ChannelAgentBinding {
10
+ channel: ChannelDef;
11
+ agent: string;
12
+ }
13
+
9
14
  export interface OpenClawProvider {
10
15
  ensureVersion(
11
16
  config: OpenClawSection,
@@ -18,6 +23,8 @@ export interface OpenClawProvider {
18
23
  startGateway(config: OpenClawSection, mode: GatewayMode, dryRun: boolean): Promise<void>;
19
24
  createWorkspace(config: OpenClawSection, workspace: ResolvedWorkspaceDef, dryRun: boolean): Promise<void>;
20
25
  configureChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
26
+ applyConfigPatch(config: OpenClawSection, patch: Record<string, unknown>, dryRun: boolean): Promise<void>;
27
+ bindChannelAgents?(config: OpenClawSection, bindings: ChannelAgentBinding[], dryRun: boolean): Promise<void>;
21
28
  bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void>;
22
29
  loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
23
30
  materializeFile?(