clawchef 0.1.7 → 0.1.8

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/AGENTS.md ADDED
@@ -0,0 +1,159 @@
1
+ # AGENTS Guide for `clawchef`
2
+
3
+ This file is for coding agents operating in this repository.
4
+ It documents the current build/test workflow and code conventions inferred from the codebase.
5
+
6
+ ## Scope and Source of Truth
7
+
8
+ - Primary language: TypeScript (ESM, NodeNext).
9
+ - Runtime target: Node.js `>=20`.
10
+ - Package manager: npm (`package-lock.json` is present).
11
+ - Compiler config: `tsconfig.json` with `strict: true` and `rootDir: src`, `outDir: dist`.
12
+ - Public entry points: CLI (`src/index.ts`) and Node API (`src/api.ts`).
13
+
14
+ ## Repository Rules Discovery
15
+
16
+ I checked for additional agent instruction files:
17
+
18
+ - `.cursor/rules/`: not present
19
+ - `.cursorrules`: not present
20
+ - `.github/copilot-instructions.md`: not present
21
+
22
+ So this `AGENTS.md` is the repo-local guidance file for agents.
23
+
24
+ ## Build, Lint, and Test Commands
25
+
26
+ ## Install
27
+
28
+ - `npm install`
29
+
30
+ ## Build
31
+
32
+ - `npm run build`
33
+ - Runs `tsc -p tsconfig.json`
34
+ - Emits JS + declarations into `dist/`
35
+
36
+ ## Type Check
37
+
38
+ - `npm run typecheck`
39
+ - Runs `tsc --noEmit -p tsconfig.json`
40
+ - Use this as the closest thing to linting gate today
41
+
42
+ ## Lint
43
+
44
+ - There is currently **no configured lint script** (`npm run lint` does not exist).
45
+ - Do not assume ESLint/Prettier are configured.
46
+ - Keep formatting and style aligned with existing files.
47
+
48
+ ## Tests
49
+
50
+ - There is currently **no test script** in this repository’s `package.json`.
51
+ - There is no committed `test/` directory in this repo at the moment.
52
+ - For now, validation is typically: build + typecheck + targeted manual CLI/API smoke checks.
53
+
54
+ ## Running a single test (when test files exist)
55
+
56
+ If you add Node test files (for example `*.test.mjs`), run one test file directly with:
57
+
58
+ - `node --test path/to/file.test.mjs`
59
+
60
+ Example:
61
+
62
+ - `node --test test/recipe-smoke.test.mjs`
63
+
64
+ Notes:
65
+
66
+ - Scaffolded recipe projects generated by `clawchef scaffold` include `test/recipe-smoke.test.mjs`.
67
+ - Those scaffolded projects also include `npm run test:recipe` and `npm test`, but this repo currently does not.
68
+
69
+ ## Coding Style Guidelines
70
+
71
+ These conventions are based on current source in `src/`.
72
+
73
+ ## Formatting and syntax
74
+
75
+ - Use 2-space indentation.
76
+ - Use semicolons.
77
+ - Use double quotes for strings.
78
+ - Prefer trailing commas in multiline literals/calls.
79
+ - Keep functions focused and relatively small unless complexity requires expansion.
80
+
81
+ ## Module system and imports
82
+
83
+ - Use ESM imports/exports only.
84
+ - For local imports, include `.js` extension in import specifiers (important for NodeNext output).
85
+ - Use `import type` for type-only imports.
86
+ - Prefer Node built-ins via `node:` prefix.
87
+ - Import ordering is not rigidly enforced; keep it consistent within each edited file.
88
+
89
+ ## Types and strictness
90
+
91
+ - Preserve strict TypeScript compatibility (`strict: true`).
92
+ - Avoid `any`; use `unknown` when needed and narrow safely.
93
+ - Prefer explicit interfaces/types for structured data (see `src/types.ts`).
94
+ - Prefer narrow unions for finite modes/options (`RunScope`, provider names, etc.).
95
+ - Keep runtime validation in sync with static types.
96
+ - If a recipe shape changes, update both `src/types.ts` and `src/schema.ts`.
97
+
98
+ ## Naming conventions
99
+
100
+ - `camelCase` for variables/functions.
101
+ - `PascalCase` for classes, interfaces, and type aliases intended as entities.
102
+ - `UPPER_SNAKE_CASE` for top-level constants with fixed meaning.
103
+ - Keep naming domain-oriented (`workspace`, `channel`, `agent`, `bootstrap`, `scope`).
104
+
105
+ ## Error handling conventions
106
+
107
+ - Throw `ClawChefError` for expected/user-facing failures.
108
+ - Include actionable, specific messages.
109
+ - For caught unknown errors, convert to readable message:
110
+ - `err instanceof Error ? err.message : String(err)`
111
+ - At CLI top-level, only `ClawChefError` should map to normal error output; unknown errors become fatal.
112
+
113
+ ## Logging and output
114
+
115
+ - Use `Logger` (`info`, `warn`, `debug`) for orchestration flow messages.
116
+ - `debug` logs should remain behind `--verbose`.
117
+ - Keep logs concise and operationally useful.
118
+ - Use `process.stdout.write` / `process.stderr.write` for CLI I/O (consistent with existing code).
119
+
120
+ ## Async and filesystem patterns
121
+
122
+ - Use async/await with `node:fs/promises` APIs.
123
+ - Prefer non-blocking I/O, except tiny startup reads where sync is already established (`readPackageVersion`).
124
+ - Wrap external command execution and network operations with clear errors.
125
+ - Preserve cleanup behavior for temp dirs/files via `try/finally`.
126
+
127
+ ## Recipe/schema evolution rules
128
+
129
+ - Schema is strict (`zod` `.strict()` used heavily); unknown keys are rejected.
130
+ - Enforce semantic constraints in `semanticValidate` (`src/recipe.ts`) after schema parse.
131
+ - Keep secret-handling guardrails intact:
132
+ - Inline secrets should be rejected where required.
133
+ - Template placeholders (`${var}`) are expected for secrets.
134
+
135
+ ## CLI/API change guidelines
136
+
137
+ - Keep CLI flags in `src/cli.ts` aligned with Node API options in `src/api.ts` when feature parity is intended.
138
+ - Validate mutually dependent options early (for example, scope + workspace pairing).
139
+ - Maintain provider parity (`command`, `remote`, `mock`) when adding operations.
140
+ - If provider interface changes, update all provider implementations and factory wiring.
141
+
142
+ ## Suggested pre-commit checklist for agents
143
+
144
+ - `npm run typecheck`
145
+ - `npm run build`
146
+ - If tests were added: run relevant Node tests, at least one targeted file
147
+ - Verify README snippets if CLI flags/behavior changed
148
+ - Confirm `src/types.ts` and `src/schema.ts` remain synchronized
149
+
150
+ ## Quick architecture map
151
+
152
+ - `src/cli.ts`: command parsing + CLI runtime wiring
153
+ - `src/api.ts`: Node API (`cook`, `validate`, `scaffold`)
154
+ - `src/recipe.ts`: recipe loading, template resolution, semantic validation
155
+ - `src/orchestrator.ts`: execution flow across workspaces/agents/channels/files
156
+ - `src/openclaw/*.ts`: provider abstraction and implementations
157
+ - `src/schema.ts` + `src/types.ts`: validation schema and TS domain model
158
+
159
+ When in doubt, follow existing patterns in adjacent files and keep changes minimal, explicit, and type-safe.
package/README.md CHANGED
@@ -304,7 +304,7 @@ Supported operation values sent by clawchef:
304
304
  - `ensure_version`, `factory_reset`, `start_gateway`
305
305
  - `install_plugin`
306
306
  - `create_workspace`, `create_agent`, `materialize_file`, `install_skill`
307
- - `configure_channel`, `login_channel`
307
+ - `configure_channel`, `bind_channel_agent`, `login_channel`
308
308
  - `run_agent`
309
309
 
310
310
  For `run_agent`, clawchef expects `output` in response for assertions.
@@ -352,6 +352,7 @@ For `command` provider, default command templates are:
352
352
  - `install_plugin`: `${bin} plugins install ${plugin_spec_q}`
353
353
  - `factory_reset`: `${bin} reset --scope full --yes --non-interactive`
354
354
  - `start_gateway`: `${bin} gateway start`
355
+ - `bind_channel_agent`: built-in `openclaw config get/set bindings` upsert (override with `openclaw.commands.bind_channel_agent`)
355
356
  - `login_channel`: `${bin} channels login --channel ${channel_q}${account_arg}`
356
357
  - `create_workspace`: generated from `openclaw.bootstrap` (override with `openclaw.commands.create_workspace`)
357
358
  - `create_agent`: `${bin} agents add ${agent} --workspace ${workspace_path} --model ${model} --non-interactive --json`
@@ -377,6 +378,12 @@ channels:
377
378
  - channel: "telegram"
378
379
  token: "${telegram_bot_token}"
379
380
  account: "default"
381
+ agent: "main"
382
+
383
+ - channel: "telegram"
384
+ token: "${alerts_bot_token}"
385
+ account: "alerts"
386
+ agent: "alerts"
380
387
 
381
388
  - channel: "slack"
382
389
  bot_token: "${slack_bot_token}"
@@ -392,9 +399,12 @@ channels:
392
399
  Supported common fields:
393
400
 
394
401
  - required: `channel`
395
- - optional: `account`, `name`, `token`, `token_file`, `use_env`, `bot_token`, `access_token`, `app_token`, `webhook_url`, `webhook_path`, `signal_number`, `password`, `login`, `login_mode`, `login_account`
402
+ - optional: `account`, `agent`, `name`, `token`, `token_file`, `use_env`, `bot_token`, `access_token`, `app_token`, `webhook_url`, `webhook_path`, `signal_number`, `password`, `login`, `login_mode`, `login_account`
396
403
  - advanced passthrough: `extra_flags` (`snake_case` keys become `--kebab-case` CLI flags)
397
404
 
405
+ `channels[].agent` currently supports `channel: "telegram"` only.
406
+ If `agent` is set and `account` is omitted, clawchef defaults `account` to the same value as `agent`.
407
+
398
408
  ## Workspace path behavior
399
409
 
400
410
  - `workspaces[].path` is optional.
@@ -8,6 +8,7 @@ export declare class CommandOpenClawProvider implements OpenClawProvider {
8
8
  startGateway(config: OpenClawSection, 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
+ bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void>;
11
12
  loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
12
13
  createAgent(config: OpenClawSection, agent: AgentDef, workspacePath: string, dryRun: boolean): Promise<void>;
13
14
  installSkill(config: OpenClawSection, workspace: string, agent: string, skill: string, dryRun: boolean): Promise<void>;
@@ -14,6 +14,7 @@ const DEFAULT_COMMANDS = {
14
14
  factory_reset: "${bin} reset --scope full --yes --non-interactive",
15
15
  start_gateway: "${bin} gateway start",
16
16
  enable_plugin: "",
17
+ bind_channel_agent: "",
17
18
  login_channel: "${bin} channels login --channel ${channel_q}${account_arg}",
18
19
  create_agent: "${bin} agents add ${agent} --workspace ${workspace_path} --model ${model} --non-interactive --json",
19
20
  install_skill: "${bin} skills check",
@@ -227,6 +228,36 @@ function bootstrapRuntimeEnv(bootstrap) {
227
228
  }
228
229
  return env;
229
230
  }
231
+ function isAccountLevelBinding(item, channel, account) {
232
+ const match = item.match;
233
+ if (!match || typeof match !== "object") {
234
+ return false;
235
+ }
236
+ if (match.channel !== channel || match.accountId !== account) {
237
+ return false;
238
+ }
239
+ return (match.peer === undefined
240
+ && match.parentPeer === undefined
241
+ && match.guildId === undefined
242
+ && match.teamId === undefined
243
+ && match.roles === undefined);
244
+ }
245
+ function parseBindingsJson(raw) {
246
+ if (!raw.trim()) {
247
+ return [];
248
+ }
249
+ try {
250
+ const parsed = JSON.parse(raw);
251
+ if (!Array.isArray(parsed)) {
252
+ throw new ClawChefError("openclaw config bindings is not an array");
253
+ }
254
+ return parsed;
255
+ }
256
+ catch (err) {
257
+ const message = err instanceof Error ? err.message : String(err);
258
+ throw new ClawChefError(`Failed to parse openclaw bindings JSON: ${message}`);
259
+ }
260
+ }
230
261
  export class CommandOpenClawProvider {
231
262
  stagedMessages = new Map();
232
263
  async ensureVersion(config, dryRun, silent, preserveExistingState) {
@@ -408,6 +439,53 @@ export class CommandOpenClawProvider {
408
439
  const cmd = `${bin} channels add ${flags.join(" ")}`;
409
440
  await runShell(cmd, dryRun);
410
441
  }
442
+ async bindChannelAgent(config, channel, agent, dryRun) {
443
+ const account = channel.account?.trim();
444
+ if (!account) {
445
+ throw new ClawChefError(`Channel ${channel.channel} requires account for agent binding`);
446
+ }
447
+ const bin = config.bin ?? "openclaw";
448
+ const customTemplate = config.commands?.bind_channel_agent;
449
+ if (customTemplate?.trim()) {
450
+ const customCmd = fillTemplate(customTemplate, {
451
+ bin,
452
+ version: config.version,
453
+ channel: channel.channel,
454
+ channel_q: shellQuote(channel.channel),
455
+ account,
456
+ account_q: shellQuote(account),
457
+ agent,
458
+ agent_q: shellQuote(agent),
459
+ });
460
+ if (customCmd.trim()) {
461
+ await runShell(customCmd, dryRun);
462
+ }
463
+ return;
464
+ }
465
+ if (dryRun) {
466
+ return;
467
+ }
468
+ const getCmd = `${bin} config get bindings --json 2>/dev/null || printf '[]'`;
469
+ const rawBindings = await runShell(getCmd, false);
470
+ const bindings = parseBindingsJson(rawBindings);
471
+ const nextBinding = {
472
+ agentId: agent,
473
+ match: {
474
+ channel: channel.channel,
475
+ accountId: account,
476
+ },
477
+ };
478
+ const index = bindings.findIndex((item) => isAccountLevelBinding(item, channel.channel, account));
479
+ if (index >= 0) {
480
+ bindings[index] = nextBinding;
481
+ }
482
+ else {
483
+ bindings.push(nextBinding);
484
+ }
485
+ const json = JSON.stringify(bindings);
486
+ const setCmd = `${bin} config set bindings ${shellQuote(json)} --json`;
487
+ await runShell(setCmd, false);
488
+ }
411
489
  async loginChannel(config, channel, dryRun) {
412
490
  if (!channel.login) {
413
491
  return;
@@ -8,6 +8,7 @@ export declare class MockOpenClawProvider implements OpenClawProvider {
8
8
  startGateway(_config: OpenClawSection, _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
+ bindChannelAgent(_config: OpenClawSection, _channel: ChannelDef, _agent: string, _dryRun: boolean): Promise<void>;
11
12
  loginChannel(_config: OpenClawSection, _channel: ChannelDef, _dryRun: boolean): Promise<void>;
12
13
  createAgent(_config: OpenClawSection, agent: AgentDef, _workspacePath: string, _dryRun: boolean): Promise<void>;
13
14
  installSkill(_config: OpenClawSection, workspace: string, agent: string, skill: 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 bindChannelAgent(_config, _channel, _agent, _dryRun) {
48
+ return;
49
+ }
47
50
  async loginChannel(_config, _channel, _dryRun) {
48
51
  return;
49
52
  }
@@ -12,6 +12,7 @@ export interface OpenClawProvider {
12
12
  startGateway(config: OpenClawSection, dryRun: boolean): Promise<void>;
13
13
  createWorkspace(config: OpenClawSection, workspace: ResolvedWorkspaceDef, dryRun: boolean): Promise<void>;
14
14
  configureChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
15
+ bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void>;
15
16
  loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
16
17
  materializeFile?(config: OpenClawSection, workspace: string, filePath: string, content: string, overwrite: boolean | undefined, dryRun: boolean): Promise<void>;
17
18
  createAgent(config: OpenClawSection, agent: AgentDef, workspacePath: string, dryRun: boolean): Promise<void>;
@@ -11,6 +11,7 @@ export declare class RemoteOpenClawProvider implements OpenClawProvider {
11
11
  startGateway(config: OpenClawSection, dryRun: boolean): Promise<void>;
12
12
  createWorkspace(config: OpenClawSection, workspace: ResolvedWorkspaceDef, dryRun: boolean): Promise<void>;
13
13
  configureChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
14
+ bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void>;
14
15
  loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
15
16
  materializeFile(config: OpenClawSection, workspace: string, filePath: string, content: string, overwrite: boolean | undefined, dryRun: boolean): Promise<void>;
16
17
  createAgent(config: OpenClawSection, agent: AgentDef, workspacePath: string, dryRun: boolean): Promise<void>;
@@ -119,6 +119,13 @@ export class RemoteOpenClawProvider {
119
119
  async configureChannel(config, channel, dryRun) {
120
120
  await this.perform(config, "configure_channel", { channel }, dryRun);
121
121
  }
122
+ async bindChannelAgent(config, channel, agent, dryRun) {
123
+ await this.perform(config, "bind_channel_agent", {
124
+ channel: channel.channel,
125
+ account: channel.account,
126
+ agent,
127
+ }, dryRun);
128
+ }
122
129
  async loginChannel(config, channel, dryRun) {
123
130
  await this.perform(config, "login_channel", { channel }, dryRun);
124
131
  }
@@ -215,8 +215,15 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
215
215
  logger.info(`Agent created: ${agent.workspace}/${agent.name}`);
216
216
  }
217
217
  for (const channel of recipe.channels ?? []) {
218
- await provider.configureChannel(recipe.openclaw, channel, options.dryRun);
219
- logger.info(`Channel configured: ${channel.channel}${channel.account ? `/${channel.account}` : ""}`);
218
+ const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
219
+ ? { ...channel, account: channel.agent.trim() }
220
+ : channel;
221
+ await provider.configureChannel(recipe.openclaw, effectiveChannel, options.dryRun);
222
+ logger.info(`Channel configured: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
223
+ if (effectiveChannel.agent?.trim()) {
224
+ await provider.bindChannelAgent(recipe.openclaw, effectiveChannel, effectiveChannel.agent, options.dryRun);
225
+ logger.info(`Channel bound to agent: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""} -> ${effectiveChannel.agent}`);
226
+ }
220
227
  }
221
228
  for (const workspace of recipe.workspaces ?? []) {
222
229
  const wsPath = workspacePaths.get(workspace.name);
@@ -319,14 +326,17 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
319
326
  await provider.startGateway(recipe.openclaw, options.dryRun);
320
327
  logger.info("Gateway started");
321
328
  for (const channel of recipe.channels ?? []) {
322
- if (!channel.login) {
329
+ const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
330
+ ? { ...channel, account: channel.agent.trim() }
331
+ : channel;
332
+ if (!effectiveChannel.login) {
323
333
  continue;
324
334
  }
325
335
  if (!options.dryRun && !input.isTTY) {
326
- throw new ClawChefError(`Channel login for ${channel.channel} requires an interactive terminal session`);
336
+ throw new ClawChefError(`Channel login for ${effectiveChannel.channel} requires an interactive terminal session`);
327
337
  }
328
- await provider.loginChannel(recipe.openclaw, channel, options.dryRun);
329
- logger.info(`Channel login completed: ${channel.channel}${channel.account ? `/${channel.account}` : ""}`);
338
+ await provider.loginChannel(recipe.openclaw, effectiveChannel, options.dryRun);
339
+ logger.info(`Channel login completed: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
330
340
  }
331
341
  logger.info("Recipe execution completed");
332
342
  }
package/dist/recipe.js CHANGED
@@ -145,12 +145,14 @@ function filterRecipeByWorkspaceName(recipe, workspaceName) {
145
145
  }
146
146
  function semanticValidate(recipe) {
147
147
  const ws = new Set((recipe.workspaces ?? []).map((w) => w.name));
148
+ const agentNameCounts = new Map();
148
149
  for (const workspace of recipe.workspaces ?? []) {
149
150
  if (workspace.assets !== undefined && !workspace.assets.trim()) {
150
151
  throw new ClawChefError(`Workspace ${workspace.name} has empty assets path`);
151
152
  }
152
153
  }
153
154
  for (const agent of recipe.agents ?? []) {
155
+ agentNameCounts.set(agent.name, (agentNameCounts.get(agent.name) ?? 0) + 1);
154
156
  if (!ws.has(agent.workspace)) {
155
157
  throw new ClawChefError(`Agent ${agent.name} references missing workspace: ${agent.workspace}`);
156
158
  }
@@ -179,6 +181,18 @@ function semanticValidate(recipe) {
179
181
  (channel.login || channel.login_mode !== undefined || channel.login_account !== undefined)) {
180
182
  throw new ClawChefError("channels[] entry for telegram does not support login/login_mode/login_account. Configure token (or use_env/token_file), then start gateway.");
181
183
  }
184
+ if (channel.agent?.trim()) {
185
+ if (channel.channel !== "telegram") {
186
+ throw new ClawChefError(`channels[] entry for ${channel.channel} does not support agent binding. Use channel: telegram with agent.`);
187
+ }
188
+ const matched = agentNameCounts.get(channel.agent) ?? 0;
189
+ if (matched === 0) {
190
+ throw new ClawChefError(`channels[] entry references missing agent by name: ${channel.agent}`);
191
+ }
192
+ if (matched > 1) {
193
+ throw new ClawChefError(`channels[] entry references duplicate agent name: ${channel.agent}. Agent names must be unique for channel binding.`);
194
+ }
195
+ }
182
196
  const hasAuth = Boolean(channel.use_env) ||
183
197
  Boolean(channel.token?.trim()) ||
184
198
  Boolean(channel.token_file?.trim()) ||
package/dist/schema.d.ts CHANGED
@@ -89,6 +89,7 @@ export declare const recipeSchema: z.ZodObject<{
89
89
  factory_reset: z.ZodOptional<z.ZodString>;
90
90
  start_gateway: z.ZodOptional<z.ZodString>;
91
91
  enable_plugin: z.ZodOptional<z.ZodString>;
92
+ bind_channel_agent: z.ZodOptional<z.ZodString>;
92
93
  login_channel: z.ZodOptional<z.ZodString>;
93
94
  create_workspace: z.ZodOptional<z.ZodString>;
94
95
  create_agent: z.ZodOptional<z.ZodString>;
@@ -103,6 +104,7 @@ export declare const recipeSchema: z.ZodObject<{
103
104
  factory_reset?: string | undefined;
104
105
  start_gateway?: string | undefined;
105
106
  enable_plugin?: string | undefined;
107
+ bind_channel_agent?: string | undefined;
106
108
  login_channel?: string | undefined;
107
109
  create_agent?: string | undefined;
108
110
  install_skill?: string | undefined;
@@ -117,6 +119,7 @@ export declare const recipeSchema: z.ZodObject<{
117
119
  factory_reset?: string | undefined;
118
120
  start_gateway?: string | undefined;
119
121
  enable_plugin?: string | undefined;
122
+ bind_channel_agent?: string | undefined;
120
123
  login_channel?: string | undefined;
121
124
  create_agent?: string | undefined;
122
125
  install_skill?: string | undefined;
@@ -158,6 +161,7 @@ export declare const recipeSchema: z.ZodObject<{
158
161
  factory_reset?: string | undefined;
159
162
  start_gateway?: string | undefined;
160
163
  enable_plugin?: string | undefined;
164
+ bind_channel_agent?: string | undefined;
161
165
  login_channel?: string | undefined;
162
166
  create_agent?: string | undefined;
163
167
  install_skill?: string | undefined;
@@ -199,6 +203,7 @@ export declare const recipeSchema: z.ZodObject<{
199
203
  factory_reset?: string | undefined;
200
204
  start_gateway?: string | undefined;
201
205
  enable_plugin?: string | undefined;
206
+ bind_channel_agent?: string | undefined;
202
207
  login_channel?: string | undefined;
203
208
  create_agent?: string | undefined;
204
209
  install_skill?: string | undefined;
@@ -268,6 +273,7 @@ export declare const recipeSchema: z.ZodObject<{
268
273
  channels: z.ZodOptional<z.ZodArray<z.ZodObject<{
269
274
  channel: z.ZodString;
270
275
  account: z.ZodOptional<z.ZodString>;
276
+ agent: z.ZodOptional<z.ZodString>;
271
277
  login: z.ZodOptional<z.ZodBoolean>;
272
278
  login_mode: z.ZodOptional<z.ZodEnum<["interactive"]>>;
273
279
  login_account: z.ZodOptional<z.ZodString>;
@@ -287,6 +293,7 @@ export declare const recipeSchema: z.ZodObject<{
287
293
  channel: string;
288
294
  token?: string | undefined;
289
295
  account?: string | undefined;
296
+ agent?: string | undefined;
290
297
  login?: boolean | undefined;
291
298
  login_mode?: "interactive" | undefined;
292
299
  login_account?: string | undefined;
@@ -305,6 +312,7 @@ export declare const recipeSchema: z.ZodObject<{
305
312
  channel: string;
306
313
  token?: string | undefined;
307
314
  account?: string | undefined;
315
+ agent?: string | undefined;
308
316
  login?: boolean | undefined;
309
317
  login_mode?: "interactive" | undefined;
310
318
  login_account?: string | undefined;
@@ -437,6 +445,7 @@ export declare const recipeSchema: z.ZodObject<{
437
445
  factory_reset?: string | undefined;
438
446
  start_gateway?: string | undefined;
439
447
  enable_plugin?: string | undefined;
448
+ bind_channel_agent?: string | undefined;
440
449
  login_channel?: string | undefined;
441
450
  create_agent?: string | undefined;
442
451
  install_skill?: string | undefined;
@@ -468,6 +477,7 @@ export declare const recipeSchema: z.ZodObject<{
468
477
  channel: string;
469
478
  token?: string | undefined;
470
479
  account?: string | undefined;
480
+ agent?: string | undefined;
471
481
  login?: boolean | undefined;
472
482
  login_mode?: "interactive" | undefined;
473
483
  login_account?: string | undefined;
@@ -538,6 +548,7 @@ export declare const recipeSchema: z.ZodObject<{
538
548
  factory_reset?: string | undefined;
539
549
  start_gateway?: string | undefined;
540
550
  enable_plugin?: string | undefined;
551
+ bind_channel_agent?: string | undefined;
541
552
  login_channel?: string | undefined;
542
553
  create_agent?: string | undefined;
543
554
  install_skill?: string | undefined;
@@ -569,6 +580,7 @@ export declare const recipeSchema: z.ZodObject<{
569
580
  channel: string;
570
581
  token?: string | undefined;
571
582
  account?: string | undefined;
583
+ agent?: string | undefined;
572
584
  login?: boolean | undefined;
573
585
  login_mode?: "interactive" | undefined;
574
586
  login_account?: string | undefined;
package/dist/schema.js CHANGED
@@ -13,6 +13,7 @@ const openClawCommandsSchema = z
13
13
  factory_reset: z.string().optional(),
14
14
  start_gateway: z.string().optional(),
15
15
  enable_plugin: z.string().optional(),
16
+ bind_channel_agent: z.string().optional(),
16
17
  login_channel: z.string().optional(),
17
18
  create_workspace: z.string().optional(),
18
19
  create_agent: z.string().optional(),
@@ -79,6 +80,7 @@ const channelSchema = z
79
80
  .object({
80
81
  channel: z.string().min(1),
81
82
  account: z.string().min(1).optional(),
83
+ agent: z.string().min(1).optional(),
82
84
  login: z.boolean().optional(),
83
85
  login_mode: z.enum(["interactive"]).optional(),
84
86
  login_account: z.string().min(1).optional(),
package/dist/types.d.ts CHANGED
@@ -22,6 +22,7 @@ export interface OpenClawCommandOverrides {
22
22
  factory_reset?: string;
23
23
  start_gateway?: string;
24
24
  enable_plugin?: string;
25
+ bind_channel_agent?: string;
25
26
  login_channel?: string;
26
27
  create_workspace?: string;
27
28
  create_agent?: string;
@@ -67,6 +68,7 @@ export interface WorkspaceDef {
67
68
  export interface ChannelDef {
68
69
  channel: string;
69
70
  account?: string;
71
+ agent?: string;
70
72
  login?: boolean;
71
73
  login_mode?: "interactive";
72
74
  login_account?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawchef",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Recipe-driven OpenClaw environment orchestrator",
5
5
  "homepage": "https://renorzr.github.io/clawchef",
6
6
  "repository": {
@@ -17,6 +17,7 @@ const DEFAULT_COMMANDS = {
17
17
  factory_reset: "${bin} reset --scope full --yes --non-interactive",
18
18
  start_gateway: "${bin} gateway start",
19
19
  enable_plugin: "",
20
+ bind_channel_agent: "",
20
21
  login_channel: "${bin} channels login --channel ${channel_q}${account_arg}",
21
22
  create_agent:
22
23
  "${bin} agents add ${agent} --workspace ${workspace_path} --model ${model} --non-interactive --json",
@@ -31,6 +32,20 @@ interface StagedMessage {
31
32
  content: string;
32
33
  }
33
34
 
35
+ interface BindingItem {
36
+ agentId?: unknown;
37
+ match?: {
38
+ channel?: unknown;
39
+ accountId?: unknown;
40
+ peer?: unknown;
41
+ parentPeer?: unknown;
42
+ guildId?: unknown;
43
+ teamId?: unknown;
44
+ roles?: unknown;
45
+ };
46
+ [key: string]: unknown;
47
+ }
48
+
34
49
  const SECRET_FLAG_RE =
35
50
  /(--[A-Za-z0-9-]*(?:api-key|token|password|secret)[A-Za-z0-9-]*\s+)(?:'[^']*'|"[^"]*"|\S+)/g;
36
51
 
@@ -280,6 +295,39 @@ function bootstrapRuntimeEnv(bootstrap: OpenClawBootstrap | undefined): Record<s
280
295
  return env;
281
296
  }
282
297
 
298
+ function isAccountLevelBinding(item: BindingItem, channel: string, account: string): boolean {
299
+ const match = item.match;
300
+ if (!match || typeof match !== "object") {
301
+ return false;
302
+ }
303
+ if (match.channel !== channel || match.accountId !== account) {
304
+ return false;
305
+ }
306
+ return (
307
+ match.peer === undefined
308
+ && match.parentPeer === undefined
309
+ && match.guildId === undefined
310
+ && match.teamId === undefined
311
+ && match.roles === undefined
312
+ );
313
+ }
314
+
315
+ function parseBindingsJson(raw: string): BindingItem[] {
316
+ if (!raw.trim()) {
317
+ return [];
318
+ }
319
+ try {
320
+ const parsed = JSON.parse(raw) as unknown;
321
+ if (!Array.isArray(parsed)) {
322
+ throw new ClawChefError("openclaw config bindings is not an array");
323
+ }
324
+ return parsed as BindingItem[];
325
+ } catch (err) {
326
+ const message = err instanceof Error ? err.message : String(err);
327
+ throw new ClawChefError(`Failed to parse openclaw bindings JSON: ${message}`);
328
+ }
329
+ }
330
+
283
331
  export class CommandOpenClawProvider implements OpenClawProvider {
284
332
  private readonly stagedMessages = new Map<string, StagedMessage[]>();
285
333
 
@@ -502,6 +550,58 @@ export class CommandOpenClawProvider implements OpenClawProvider {
502
550
  await runShell(cmd, dryRun);
503
551
  }
504
552
 
553
+ async bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void> {
554
+ const account = channel.account?.trim();
555
+ if (!account) {
556
+ throw new ClawChefError(`Channel ${channel.channel} requires account for agent binding`);
557
+ }
558
+
559
+ const bin = config.bin ?? "openclaw";
560
+ const customTemplate = config.commands?.bind_channel_agent;
561
+ if (customTemplate?.trim()) {
562
+ const customCmd = fillTemplate(customTemplate, {
563
+ bin,
564
+ version: config.version,
565
+ channel: channel.channel,
566
+ channel_q: shellQuote(channel.channel),
567
+ account,
568
+ account_q: shellQuote(account),
569
+ agent,
570
+ agent_q: shellQuote(agent),
571
+ });
572
+ if (customCmd.trim()) {
573
+ await runShell(customCmd, dryRun);
574
+ }
575
+ return;
576
+ }
577
+
578
+ if (dryRun) {
579
+ return;
580
+ }
581
+
582
+ const getCmd = `${bin} config get bindings --json 2>/dev/null || printf '[]'`;
583
+ const rawBindings = await runShell(getCmd, false);
584
+ const bindings = parseBindingsJson(rawBindings);
585
+ const nextBinding: BindingItem = {
586
+ agentId: agent,
587
+ match: {
588
+ channel: channel.channel,
589
+ accountId: account,
590
+ },
591
+ };
592
+
593
+ const index = bindings.findIndex((item) => isAccountLevelBinding(item, channel.channel, account));
594
+ if (index >= 0) {
595
+ bindings[index] = nextBinding;
596
+ } else {
597
+ bindings.push(nextBinding);
598
+ }
599
+
600
+ const json = JSON.stringify(bindings);
601
+ const setCmd = `${bin} config set bindings ${shellQuote(json)} --json`;
602
+ await runShell(setCmd, false);
603
+ }
604
+
505
605
  async loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void> {
506
606
  if (!channel.login) {
507
607
  return;
@@ -69,6 +69,15 @@ export class MockOpenClawProvider implements OpenClawProvider {
69
69
  this.state.channels.add(`${channel.channel}::${channel.account ?? "default"}`);
70
70
  }
71
71
 
72
+ async bindChannelAgent(
73
+ _config: OpenClawSection,
74
+ _channel: ChannelDef,
75
+ _agent: string,
76
+ _dryRun: boolean,
77
+ ): Promise<void> {
78
+ return;
79
+ }
80
+
72
81
  async loginChannel(_config: OpenClawSection, _channel: ChannelDef, _dryRun: boolean): Promise<void> {
73
82
  return;
74
83
  }
@@ -18,6 +18,7 @@ export interface OpenClawProvider {
18
18
  startGateway(config: OpenClawSection, dryRun: boolean): Promise<void>;
19
19
  createWorkspace(config: OpenClawSection, workspace: ResolvedWorkspaceDef, dryRun: boolean): Promise<void>;
20
20
  configureChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
21
+ bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void>;
21
22
  loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
22
23
  materializeFile?(
23
24
  config: OpenClawSection,
@@ -188,6 +188,19 @@ export class RemoteOpenClawProvider implements OpenClawProvider {
188
188
  await this.perform(config, "configure_channel", { channel }, dryRun);
189
189
  }
190
190
 
191
+ async bindChannelAgent(config: OpenClawSection, channel: ChannelDef, agent: string, dryRun: boolean): Promise<void> {
192
+ await this.perform(
193
+ config,
194
+ "bind_channel_agent",
195
+ {
196
+ channel: channel.channel,
197
+ account: channel.account,
198
+ agent,
199
+ },
200
+ dryRun,
201
+ );
202
+ }
203
+
191
204
  async loginChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void> {
192
205
  await this.perform(config, "login_channel", { channel }, dryRun);
193
206
  }
@@ -261,8 +261,18 @@ export async function runRecipe(
261
261
  }
262
262
 
263
263
  for (const channel of recipe.channels ?? []) {
264
- await provider.configureChannel(recipe.openclaw, channel, options.dryRun);
265
- logger.info(`Channel configured: ${channel.channel}${channel.account ? `/${channel.account}` : ""}`);
264
+ const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
265
+ ? { ...channel, account: channel.agent.trim() }
266
+ : channel;
267
+
268
+ await provider.configureChannel(recipe.openclaw, effectiveChannel, options.dryRun);
269
+ logger.info(`Channel configured: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
270
+ if (effectiveChannel.agent?.trim()) {
271
+ await provider.bindChannelAgent(recipe.openclaw, effectiveChannel, effectiveChannel.agent, options.dryRun);
272
+ logger.info(
273
+ `Channel bound to agent: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""} -> ${effectiveChannel.agent}`,
274
+ );
275
+ }
266
276
  }
267
277
 
268
278
  for (const workspace of recipe.workspaces ?? []) {
@@ -369,16 +379,19 @@ export async function runRecipe(
369
379
  logger.info("Gateway started");
370
380
 
371
381
  for (const channel of recipe.channels ?? []) {
372
- if (!channel.login) {
382
+ const effectiveChannel = channel.agent?.trim() && !channel.account?.trim()
383
+ ? { ...channel, account: channel.agent.trim() }
384
+ : channel;
385
+ if (!effectiveChannel.login) {
373
386
  continue;
374
387
  }
375
388
  if (!options.dryRun && !input.isTTY) {
376
389
  throw new ClawChefError(
377
- `Channel login for ${channel.channel} requires an interactive terminal session`,
390
+ `Channel login for ${effectiveChannel.channel} requires an interactive terminal session`,
378
391
  );
379
392
  }
380
- await provider.loginChannel(recipe.openclaw, channel, options.dryRun);
381
- logger.info(`Channel login completed: ${channel.channel}${channel.account ? `/${channel.account}` : ""}`);
393
+ await provider.loginChannel(recipe.openclaw, effectiveChannel, options.dryRun);
394
+ logger.info(`Channel login completed: ${effectiveChannel.channel}${effectiveChannel.account ? `/${effectiveChannel.account}` : ""}`);
382
395
  }
383
396
 
384
397
  logger.info("Recipe execution completed");
package/src/recipe.ts CHANGED
@@ -193,12 +193,14 @@ function filterRecipeByWorkspaceName(recipe: Recipe, workspaceName: string): Rec
193
193
 
194
194
  function semanticValidate(recipe: Recipe): void {
195
195
  const ws = new Set((recipe.workspaces ?? []).map((w) => w.name));
196
+ const agentNameCounts = new Map<string, number>();
196
197
  for (const workspace of recipe.workspaces ?? []) {
197
198
  if (workspace.assets !== undefined && !workspace.assets.trim()) {
198
199
  throw new ClawChefError(`Workspace ${workspace.name} has empty assets path`);
199
200
  }
200
201
  }
201
202
  for (const agent of recipe.agents ?? []) {
203
+ agentNameCounts.set(agent.name, (agentNameCounts.get(agent.name) ?? 0) + 1);
202
204
  if (!ws.has(agent.workspace)) {
203
205
  throw new ClawChefError(`Agent ${agent.name} references missing workspace: ${agent.workspace}`);
204
206
  }
@@ -238,6 +240,23 @@ function semanticValidate(recipe: Recipe): void {
238
240
  );
239
241
  }
240
242
 
243
+ if (channel.agent?.trim()) {
244
+ if (channel.channel !== "telegram") {
245
+ throw new ClawChefError(
246
+ `channels[] entry for ${channel.channel} does not support agent binding. Use channel: telegram with agent.`,
247
+ );
248
+ }
249
+ const matched = agentNameCounts.get(channel.agent) ?? 0;
250
+ if (matched === 0) {
251
+ throw new ClawChefError(`channels[] entry references missing agent by name: ${channel.agent}`);
252
+ }
253
+ if (matched > 1) {
254
+ throw new ClawChefError(
255
+ `channels[] entry references duplicate agent name: ${channel.agent}. Agent names must be unique for channel binding.`,
256
+ );
257
+ }
258
+ }
259
+
241
260
  const hasAuth =
242
261
  Boolean(channel.use_env) ||
243
262
  Boolean(channel.token?.trim()) ||
package/src/schema.ts CHANGED
@@ -15,6 +15,7 @@ const openClawCommandsSchema = z
15
15
  factory_reset: z.string().optional(),
16
16
  start_gateway: z.string().optional(),
17
17
  enable_plugin: z.string().optional(),
18
+ bind_channel_agent: z.string().optional(),
18
19
  login_channel: z.string().optional(),
19
20
  create_workspace: z.string().optional(),
20
21
  create_agent: z.string().optional(),
@@ -87,6 +88,7 @@ const channelSchema = z
87
88
  .object({
88
89
  channel: z.string().min(1),
89
90
  account: z.string().min(1).optional(),
91
+ agent: z.string().min(1).optional(),
90
92
  login: z.boolean().optional(),
91
93
  login_mode: z.enum(["interactive"]).optional(),
92
94
  login_account: z.string().min(1).optional(),
package/src/types.ts CHANGED
@@ -25,6 +25,7 @@ export interface OpenClawCommandOverrides {
25
25
  factory_reset?: string;
26
26
  start_gateway?: string;
27
27
  enable_plugin?: string;
28
+ bind_channel_agent?: string;
28
29
  login_channel?: string;
29
30
  create_workspace?: string;
30
31
  create_agent?: string;
@@ -74,6 +75,7 @@ export interface WorkspaceDef {
74
75
  export interface ChannelDef {
75
76
  channel: string;
76
77
  account?: string;
78
+ agent?: string;
77
79
  login?: boolean;
78
80
  login_mode?: "interactive";
79
81
  login_account?: string;