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/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
  });
@@ -205,6 +239,177 @@ function shouldAutoDisableTelegramChannel(channel: ChannelDef): boolean {
205
239
 
206
240
  type VersionMismatchChoice = "ignore" | "abort" | "force";
207
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
+
208
413
  async function chooseVersionMismatchAction(
209
414
  currentVersion: string,
210
415
  expectedVersion: string,
@@ -338,24 +543,23 @@ function isAccountLevelBinding(item: BindingItem, channel: string, account: stri
338
543
  );
339
544
  }
340
545
 
341
- function parseBindingsJson(raw: string): BindingItem[] {
342
- if (!raw.trim()) {
546
+ function parseBindingsValue(value: unknown): BindingItem[] {
547
+ if (value === undefined || value === null) {
343
548
  return [];
344
549
  }
345
- try {
346
- const parsed = JSON.parse(raw) as unknown;
347
- if (!Array.isArray(parsed)) {
348
- throw new ClawChefError("openclaw config bindings is not an array");
349
- }
350
- return parsed as BindingItem[];
351
- } catch (err) {
352
- const message = err instanceof Error ? err.message : String(err);
353
- 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");
354
552
  }
553
+ return value as BindingItem[];
355
554
  }
356
555
 
357
556
  export class CommandOpenClawProvider implements OpenClawProvider {
358
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
+ }
359
563
 
360
564
  async ensureVersion(
361
565
  config: OpenClawSection,
@@ -527,16 +731,17 @@ export class CommandOpenClawProvider implements OpenClawProvider {
527
731
 
528
732
  async configureChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void> {
529
733
  const bin = config.bin ?? "openclaw";
734
+ const cfgPath = configPath();
530
735
 
531
736
  if (shouldAutoDisableTelegramChannel(channel)) {
532
- const enabledPath = telegramEnabledPath(channel.account);
533
- const disableCmd = `${bin} config set ${shellQuote(enabledPath)} false --strict-json`;
534
- await runShell(disableCmd, dryRun);
737
+ const openclawConfig = await loadConfigJson(cfgPath);
738
+ setConfigValue(openclawConfig, telegramEnabledPath(channel.account), false);
739
+ await saveConfigJson(cfgPath, openclawConfig, dryRun);
535
740
  return;
536
741
  }
537
742
 
538
743
  const enablePluginTemplate = config.commands?.enable_plugin;
539
- if (enablePluginTemplate?.trim()) {
744
+ if (enablePluginTemplate?.trim() && !this.enabledChannelPlugins.has(channel.channel)) {
540
745
  const enablePluginCmd = fillTemplate(enablePluginTemplate, {
541
746
  bin,
542
747
  version: config.version,
@@ -546,6 +751,9 @@ export class CommandOpenClawProvider implements OpenClawProvider {
546
751
  if (enablePluginCmd.trim()) {
547
752
  await runShell(enablePluginCmd, dryRun);
548
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`);
549
757
  }
550
758
 
551
759
  const flags: string[] = [
@@ -593,13 +801,70 @@ export class CommandOpenClawProvider implements OpenClawProvider {
593
801
  await runShell(cmd, dryRun);
594
802
 
595
803
  if (channel.channel === "telegram" && channel.group_policy) {
596
- const configPath = telegramGroupPolicyPath(channel.account);
597
- const policyValue = JSON.stringify(channel.group_policy);
598
- const setPolicyCmd = `${bin} config set ${shellQuote(configPath)} ${shellQuote(policyValue)} --strict-json`;
599
- 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);
600
807
  }
601
808
  }
602
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
+
603
868
  async bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void> {
604
869
  const account = channel.account?.trim();
605
870
  if (!account) {
@@ -625,31 +890,7 @@ export class CommandOpenClawProvider implements OpenClawProvider {
625
890
  return;
626
891
  }
627
892
 
628
- if (dryRun) {
629
- return;
630
- }
631
-
632
- const getCmd = `${bin} config get bindings --json 2>/dev/null || printf '[]'`;
633
- const rawBindings = await runShell(getCmd, false);
634
- const bindings = parseBindingsJson(rawBindings);
635
- const nextBinding: BindingItem = {
636
- agentId: agent,
637
- match: {
638
- channel: channel.channel,
639
- accountId: account,
640
- },
641
- };
642
-
643
- const index = bindings.findIndex((item) => isAccountLevelBinding(item, channel.channel, account));
644
- if (index >= 0) {
645
- bindings[index] = nextBinding;
646
- } else {
647
- bindings.push(nextBinding);
648
- }
649
-
650
- const json = JSON.stringify(bindings);
651
- const setCmd = `${bin} config set bindings ${shellQuote(json)} --json`;
652
- await runShell(setCmd, false);
893
+ await this.bindChannelAgents(config, [{ channel, agent }], dryRun);
653
894
  }
654
895
 
655
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?(
@@ -29,6 +29,17 @@ interface RemoteOperationResponse {
29
29
  const DEFAULT_TIMEOUT_MS = 60_000;
30
30
  const DEFAULT_OPERATION_PATH = "/v1/clawchef/operation";
31
31
 
32
+ function timestamp(): string {
33
+ const now = new Date();
34
+ const year = now.getFullYear();
35
+ const month = String(now.getMonth() + 1).padStart(2, "0");
36
+ const day = String(now.getDate()).padStart(2, "0");
37
+ const hours = String(now.getHours()).padStart(2, "0");
38
+ const minutes = String(now.getMinutes()).padStart(2, "0");
39
+ const seconds = String(now.getSeconds()).padStart(2, "0");
40
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
41
+ }
42
+
32
43
  function parseResponseBody(raw: string): RemoteOperationResponse {
33
44
  if (!raw.trim()) {
34
45
  return {};
@@ -84,9 +95,18 @@ function assertRemoteConfig(remote: Partial<OpenClawRemoteConfig>): OpenClawRemo
84
95
  export class RemoteOpenClawProvider implements OpenClawProvider {
85
96
  private readonly stagedMessages = new Map<string, StagedMessage[]>();
86
97
  private readonly remoteConfig: Partial<OpenClawRemoteConfig>;
98
+ private readonly verboseEnabled: boolean;
87
99
 
88
- constructor(remoteConfig: Partial<OpenClawRemoteConfig>) {
100
+ constructor(remoteConfig: Partial<OpenClawRemoteConfig>, verboseEnabled = false) {
89
101
  this.remoteConfig = remoteConfig;
102
+ this.verboseEnabled = verboseEnabled;
103
+ }
104
+
105
+ private debug(message: string): void {
106
+ if (!this.verboseEnabled) {
107
+ return;
108
+ }
109
+ process.stdout.write(`[${timestamp()}] [DEBUG] ${message}\n`);
90
110
  }
91
111
 
92
112
  private async perform(
@@ -96,6 +116,7 @@ export class RemoteOpenClawProvider implements OpenClawProvider {
96
116
  dryRun: boolean,
97
117
  ): Promise<RemoteOperationResponse> {
98
118
  if (dryRun) {
119
+ this.debug(`REMOTE DRY-RUN op=${operation}`);
99
120
  return { ok: true };
100
121
  }
101
122
 
@@ -109,6 +130,8 @@ export class RemoteOpenClawProvider implements OpenClawProvider {
109
130
  recipe_version: config.version,
110
131
  payload,
111
132
  };
133
+ const startedAt = Date.now();
134
+ this.debug(`REMOTE START op=${operation} url=${operationUrl(remote)}`);
112
135
 
113
136
  try {
114
137
  const response = await fetch(operationUrl(remote), {
@@ -130,8 +153,11 @@ export class RemoteOpenClawProvider implements OpenClawProvider {
130
153
  throw new ClawChefError(`Remote operation failed for ${operation}: ${parsed.message ?? "unknown error"}`);
131
154
  }
132
155
 
156
+ this.debug(`REMOTE DONE op=${operation} status=${response.status} (${Date.now() - startedAt}ms)`);
157
+
133
158
  return parsed;
134
159
  } catch (err) {
160
+ this.debug(`REMOTE FAIL op=${operation} (${Date.now() - startedAt}ms)`);
135
161
  if (err instanceof ClawChefError) {
136
162
  throw err;
137
163
  }
@@ -192,6 +218,10 @@ export class RemoteOpenClawProvider implements OpenClawProvider {
192
218
  await this.perform(config, "configure_channel", { channel }, dryRun);
193
219
  }
194
220
 
221
+ async applyConfigPatch(config: OpenClawSection, patch: Record<string, unknown>, dryRun: boolean): Promise<void> {
222
+ await this.perform(config, "apply_config_patch", { patch }, dryRun);
223
+ }
224
+
195
225
  async bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void> {
196
226
  await this.perform(
197
227
  config,