clawchef 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,12 +14,13 @@ Recipe-driven OpenClaw environment orchestrator.
14
14
  - Always runs factory reset first (with confirmation prompt unless `-s/--silent` is used).
15
15
  - If `openclaw` is missing, auto-installs the recipe version and skips factory reset.
16
16
  - Starts OpenClaw gateway service after each recipe execution.
17
- - Creates workspaces and agents (default workspace path: `~/.openclaw/workspaces/<workspace-name>`).
17
+ - Creates workspaces and agents (default workspace path: `~/.openclaw/workspace-<workspace-name>`).
18
+ - Supports workspace-level assets copy via `workspaces[].assets`.
18
19
  - Materializes files into target workspaces.
19
20
  - Installs skills.
21
+ - Supports plugin preinstall via `openclaw.plugins[]` and runtime `--plugin` flags.
20
22
  - Configures channels with `openclaw channels add`.
21
- - Enables channel plugins before channel configuration.
22
- - Supports interactive channel login at the end of execution (`channels[].login: true`).
23
+ - Supports interactive channel login at the end of execution (`channels[].login: true`) for channels that expose login.
23
24
  - Supports remote HTTP orchestration via runtime flags (`--provider remote`) when OpenClaw is reachable via API.
24
25
  - Writes preset conversation messages.
25
26
  - Runs agent and validates reply output.
@@ -27,29 +28,27 @@ Recipe-driven OpenClaw environment orchestrator.
27
28
  ## Install and run
28
29
 
29
30
  ```bash
30
- npm install
31
- npm run build
32
- npm i -g .
31
+ npm i -g clawchef
33
32
  clawchef cook recipes/sample.yaml
34
33
  ```
35
34
 
36
35
  Run recipe from URL:
37
36
 
38
37
  ```bash
39
- clawchef cook https://example.com/recipes/sample.yaml --provider remote -s
38
+ clawchef cook https://example.com/recipes/sample.yaml --provider remote
40
39
  ```
41
40
 
42
41
  Run recipe from archive (default `recipe.yaml`):
43
42
 
44
43
  ```bash
45
- clawchef cook ./bundle.tgz --provider mock -s
44
+ clawchef cook ./bundle.tgz --provider mock
46
45
  ```
47
46
 
48
47
  Run specific recipe in directory or archive:
49
48
 
50
49
  ```bash
51
- clawchef cook ./recipes-pack:team/recipe-prod.yaml --provider remote -s
52
- clawchef cook https://example.com/recipes-pack.zip:team/recipe-prod.yaml --provider remote -s
50
+ clawchef cook ./recipes-pack:team/recipe-prod.yaml --provider remote
51
+ clawchef cook https://example.com/recipes-pack.zip:team/recipe-prod.yaml --provider remote
53
52
  ```
54
53
 
55
54
  Dev mode:
@@ -61,13 +60,13 @@ clawchef cook recipes/sample.yaml --verbose
61
60
  Run sample with mock provider:
62
61
 
63
62
  ```bash
64
- clawchef cook recipes/sample.yaml --provider mock -s
63
+ clawchef cook recipes/sample.yaml --provider mock
65
64
  ```
66
65
 
67
66
  Run `content_from` sample:
68
67
 
69
68
  ```bash
70
- clawchef cook recipes/content-from-sample.yaml --provider mock -s
69
+ clawchef cook recipes/content-from-sample.yaml --provider mock
71
70
  ```
72
71
 
73
72
  Skip reset confirmation prompt:
@@ -76,6 +75,9 @@ Skip reset confirmation prompt:
76
75
  clawchef cook recipes/sample.yaml -s
77
76
  ```
78
77
 
78
+ Warning: `-s/--silent` suppresses the factory-reset confirmation and auto-chooses force reinstall on version mismatch.
79
+ Use it only in CI/non-interactive flows where destructive reset behavior is expected.
80
+
79
81
  From-zero OpenClaw bootstrap (recommended):
80
82
 
81
83
  ```bash
@@ -85,13 +87,19 @@ CLAWCHEF_VAR_OPENAI_API_KEY=sk-... clawchef cook recipes/openclaw-from-zero.yaml
85
87
  Telegram channel setup only:
86
88
 
87
89
  ```bash
88
- CLAWCHEF_VAR_TELEGRAM_BOT_TOKEN=123456:abc... clawchef cook recipes/openclaw-telegram.yaml -s
90
+ CLAWCHEF_VAR_TELEGRAM_BOT_TOKEN=123456:abc... clawchef cook recipes/openclaw-telegram.yaml
89
91
  ```
90
92
 
91
93
  Telegram mock channel setup (for tests):
92
94
 
93
95
  ```bash
94
- CLAWCHEF_VAR_TELEGRAM_MOCK_API_KEY=test-key clawchef cook recipes/openclaw-telegram-mock.yaml -s
96
+ CLAWCHEF_VAR_TELEGRAM_MOCK_API_KEY=test-key clawchef cook recipes/openclaw-telegram-mock.yaml
97
+ ```
98
+
99
+ Install plugin only for this run:
100
+
101
+ ```bash
102
+ clawchef cook recipes/openclaw-telegram-mock.yaml --plugin openclaw-telegram-mock-channel
95
103
  ```
96
104
 
97
105
  Remote HTTP orchestration:
@@ -99,7 +107,7 @@ Remote HTTP orchestration:
99
107
  ```bash
100
108
  CLAWCHEF_REMOTE_BASE_URL=https://remote-openclaw.example.com \
101
109
  CLAWCHEF_REMOTE_API_KEY=secret-token \
102
- clawchef cook recipes/openclaw-remote-http.yaml --provider remote -s --verbose
110
+ clawchef cook recipes/openclaw-remote-http.yaml --provider remote --verbose
103
111
  ```
104
112
 
105
113
  Validate recipe structure only:
@@ -121,12 +129,32 @@ clawchef validate ./bundle.zip
121
129
  clawchef validate ./bundle.zip:custom/recipe.yaml
122
130
  ```
123
131
 
132
+ Create a new recipe project scaffold:
133
+
134
+ ```bash
135
+ clawchef scaffold
136
+ clawchef scaffold ./my-recipe-project
137
+ clawchef scaffold ./my-recipe-project --name meetingbot
138
+ ```
139
+
140
+ `scaffold` prompts for project name (default: target directory name).
141
+
142
+ Scaffold output:
143
+
144
+ - `package.json` with `telegram-api-mock-server` in `devDependencies`
145
+ - `src/recipe.yaml` with `telegram-mock` channel, plugin preinstall, and `workspaces[].assets`
146
+ - `src/<project-name>-assets/{AGENTS.md,IDENTITY.md,SOUL.md,TOOLS.md}`
147
+ - `src/<project-name>-assets/scripts/scheduling.mjs`
148
+ - `test/recipe-smoke.test.mjs`
149
+
150
+ By default scaffold only writes files; it does not run `npm install`.
151
+
124
152
  ## Node.js API
125
153
 
126
154
  You can call clawchef directly from Node.js (without invoking CLI commands).
127
155
 
128
156
  ```ts
129
- import { cook, validate } from "clawchef";
157
+ import { cook, scaffold, validate } from "clawchef";
130
158
 
131
159
  await validate("recipes/sample.yaml");
132
160
 
@@ -137,21 +165,30 @@ await cook("recipes/sample.yaml", {
137
165
  openai_api_key: process.env.OPENAI_API_KEY ?? "",
138
166
  },
139
167
  });
168
+
169
+ await scaffold("./my-recipe-project", {
170
+ projectName: "my-recipe-project",
171
+ });
140
172
  ```
141
173
 
142
174
  `cook()` options:
143
175
 
144
176
  - `vars`: template variables (`Record<string, string>`)
177
+ - `plugins`: plugin npm specs to preinstall for this run (`string[]`)
145
178
  - `provider`: `command | remote | mock`
146
179
  - `remote`: remote provider config (same fields as CLI remote flags)
147
180
  - `dryRun`, `allowMissing`, `verbose`
148
181
  - `silent` (default: `true` in Node API)
149
182
  - `loadDotEnvFromCwd` (default: `true`)
150
183
 
184
+ Node API `silent: true` has the same risk as CLI `-s`: no reset confirmation and force reinstall on version mismatch.
185
+ Set `silent: false` when you want an interactive safety prompt.
186
+
151
187
  Notes:
152
188
 
153
189
  - `validate()` throws on invalid recipe.
154
190
  - `cook()` throws on runtime/configuration errors.
191
+ - `scaffold()` creates `package.json`, `src/recipe.yaml`, `src/<project-name>-assets`, and `test/`.
155
192
 
156
193
  ## Variable precedence
157
194
 
@@ -213,7 +250,7 @@ Request payload format (POST):
213
250
  "payload": {
214
251
  "workspace": {
215
252
  "name": "demo",
216
- "path": "/home/runner/.openclaw/workspaces/demo"
253
+ "path": "/home/runner/.openclaw/workspace-demo"
217
254
  }
218
255
  }
219
256
  }
@@ -233,6 +270,7 @@ Expected response format:
233
270
  Supported operation values sent by clawchef:
234
271
 
235
272
  - `ensure_version`, `factory_reset`, `start_gateway`
273
+ - `install_plugin`
236
274
  - `create_workspace`, `create_agent`, `materialize_file`, `install_skill`
237
275
  - `configure_channel`, `login_channel`
238
276
  - `run_agent`
@@ -258,6 +296,20 @@ Supported fields include:
258
296
  - setup toggles: `skip_channels`, `skip_skills`, `skip_health`, `skip_ui`, `skip_daemon`, `install_daemon`
259
297
  - auth/provider: `auth_choice`, `openai_api_key`, `anthropic_api_key`, `openrouter_api_key`, `xai_api_key`, `gemini_api_key`, `ai_gateway_api_key`, `cloudflare_ai_gateway_api_key`, `token`, `token_provider`, `token_profile_id`
260
298
 
299
+ ### Plugin preinstall
300
+
301
+ Use `openclaw.plugins` to preinstall plugin packages before workspace/channel setup.
302
+
303
+ Example:
304
+
305
+ ```yaml
306
+ openclaw:
307
+ version: "2026.2.9"
308
+ plugins:
309
+ - "openclaw-telegram-mock-channel"
310
+ - "@scope/custom-channel-plugin@1.2.3"
311
+ ```
312
+
261
313
  When `openclaw.bootstrap` contains provider keys, `clawchef` also injects them into runtime env for `openclaw agent --local`.
262
314
 
263
315
  For `command` provider, default command templates are:
@@ -265,9 +317,9 @@ For `command` provider, default command templates are:
265
317
  - `use_version`: `${bin} --version`
266
318
  - `install_version`: `npm install -g openclaw@${version}`
267
319
  - `uninstall_version`: `npm uninstall -g openclaw`
320
+ - `install_plugin`: `${bin} plugins install ${plugin_spec_q}`
268
321
  - `factory_reset`: `${bin} reset --scope full --yes --non-interactive`
269
322
  - `start_gateway`: `${bin} gateway start`
270
- - `enable_plugin`: `${bin} plugins enable ${channel_q}`
271
323
  - `login_channel`: `${bin} channels login --channel ${channel_q}${account_arg}`
272
324
  - `create_workspace`: generated from `openclaw.bootstrap` (override with `openclaw.commands.create_workspace`)
273
325
  - `create_agent`: `${bin} agents add ${agent} --workspace ${workspace_path} --model ${model} --non-interactive --json`
@@ -277,10 +329,14 @@ For `command` provider, default command templates are:
277
329
 
278
330
  You can override any command under `openclaw.commands` in recipe.
279
331
 
332
+ By default, clawchef does not auto-run `openclaw plugins enable <channel>` during channel configuration.
333
+ If you need custom enable behavior, set `openclaw.commands.enable_plugin` explicitly.
334
+
280
335
  ## Channels
281
336
 
282
337
  Use `channels[]` to configure accounts via `openclaw channels add`.
283
338
  If `login: true` is set, clawchef runs channel login at the end of `cook` (after gateway start).
339
+ Telegram does not support `openclaw channels login`; do not set `login` for `channel: "telegram"`.
284
340
 
285
341
  Example:
286
342
 
@@ -289,7 +345,6 @@ channels:
289
345
  - channel: "telegram"
290
346
  token: "${telegram_bot_token}"
291
347
  account: "default"
292
- login: true
293
348
 
294
349
  - channel: "slack"
295
350
  bot_token: "${slack_bot_token}"
@@ -308,54 +363,27 @@ Supported common fields:
308
363
  - 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`
309
364
  - advanced passthrough: `extra_flags` (`snake_case` keys become `--kebab-case` CLI flags)
310
365
 
311
- ### Telegram mock channel (for recipe tests)
312
-
313
- Use `channel: "telegram-mock"` when you need to test Telegram-related recipe flows without connecting to the real Telegram network.
314
-
315
- `clawchef` treats `telegram-mock` like any other channel and passes mock-specific flags through `extra_flags`.
316
-
317
- Example:
318
-
319
- ```yaml
320
- params:
321
- telegram_mock_api_key:
322
- required: true
323
-
324
- channels:
325
- - channel: "telegram-mock"
326
- account: "testbot"
327
- token: "${telegram_mock_api_key}"
328
- extra_flags:
329
- mock_bind: "127.0.0.1:18790"
330
- mock_api_key: "${telegram_mock_api_key}"
331
- mode: "webhook"
332
- ```
333
-
334
- Typical test setup:
335
-
336
- - Start OpenClaw with the telegram-mock plugin enabled.
337
- - Run `clawchef cook ...` to configure workspaces/agents/channels.
338
- - Use your external test program (HTTP API or Node.js SDK) to inject inbound mock messages and assert outbound events.
339
-
340
- Login fields:
341
-
342
- - `login: true` enables channel login step
343
- - `login_mode`: currently supports `interactive`
344
- - `login_account`: override account used for login (defaults to `account`)
345
-
346
- Security rules:
347
-
348
- - Do not inline secret values in `channels[]`.
349
- - Use `${var}` placeholders and inject values via `--var` / `CLAWCHEF_VAR_*`.
350
-
351
366
  ## Workspace path behavior
352
367
 
353
368
  - `workspaces[].path` is optional.
354
- - If omitted, clawchef uses `~/.openclaw/workspaces/<workspace-name>`.
369
+ - If omitted, clawchef uses `~/.openclaw/workspace-<workspace-name>`.
370
+ - `workspaces[].assets` is optional.
371
+ - If `assets` is set, clawchef recursively copies files from that directory into the workspace root.
372
+ - `assets` is resolved relative to the recipe file path (unless absolute path is given).
373
+ - `files[]` runs after assets copy, so `files[]` can override copied asset files.
374
+ - Direct URL recipes do not support `workspaces[].assets` (assets must resolve to a local directory).
355
375
  - If provided, relative paths are resolved from the recipe file directory.
356
376
  - For direct URL recipe files, relative workspace paths are resolved from the current working directory.
357
377
  - For directory/archive recipe references, relative workspace paths are resolved from the selected recipe file directory.
358
378
 
379
+ Example:
380
+
381
+ ```yaml
382
+ workspaces:
383
+ - name: "workspace-meeting"
384
+ assets: "./meetingbot-assets"
385
+ ```
386
+
359
387
  ## File content references
360
388
 
361
389
  In `files[]`, set exactly one of:
package/dist/api.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { OpenClawProvider, OpenClawRemoteConfig } from "./types.js";
2
+ import type { ScaffoldOptions, ScaffoldResult } from "./scaffold.js";
2
3
  export interface CookOptions {
3
4
  vars?: Record<string, string>;
5
+ plugins?: string[];
4
6
  dryRun?: boolean;
5
7
  allowMissing?: boolean;
6
8
  verbose?: boolean;
@@ -11,4 +13,6 @@ export interface CookOptions {
11
13
  }
12
14
  export declare function cook(recipeRef: string, options?: CookOptions): Promise<void>;
13
15
  export declare function validate(recipeRef: string): Promise<void>;
16
+ export declare function scaffold(targetDir?: string, options?: ScaffoldOptions): Promise<ScaffoldResult>;
14
17
  export type { OpenClawProvider, OpenClawRemoteConfig };
18
+ export type { ScaffoldOptions, ScaffoldResult };
package/dist/api.js CHANGED
@@ -4,10 +4,13 @@ import { importDotEnvFromCwd } from "./env.js";
4
4
  import { Logger } from "./logger.js";
5
5
  import { runRecipe } from "./orchestrator.js";
6
6
  import { loadRecipe, loadRecipeText } from "./recipe.js";
7
+ import { scaffoldProject } from "./scaffold.js";
7
8
  import { recipeSchema } from "./schema.js";
8
9
  function normalizeCookOptions(options) {
10
+ const plugins = Array.from(new Set((options.plugins ?? []).map((value) => value.trim()).filter((value) => value.length > 0)));
9
11
  return {
10
12
  vars: options.vars ?? {},
13
+ plugins,
11
14
  dryRun: Boolean(options.dryRun),
12
15
  allowMissing: Boolean(options.allowMissing),
13
16
  verbose: Boolean(options.verbose),
@@ -47,3 +50,6 @@ export async function validate(recipeRef) {
47
50
  }
48
51
  }
49
52
  }
53
+ export async function scaffold(targetDir, options = {}) {
54
+ return scaffoldProject(targetDir, options);
55
+ }
package/dist/cli.js CHANGED
@@ -4,7 +4,11 @@ import { Logger } from "./logger.js";
4
4
  import { runRecipe } from "./orchestrator.js";
5
5
  import { loadRecipe, loadRecipeText } from "./recipe.js";
6
6
  import { recipeSchema } from "./schema.js";
7
+ import { scaffoldProject } from "./scaffold.js";
7
8
  import YAML from "js-yaml";
9
+ import path from "node:path";
10
+ import { createInterface } from "node:readline/promises";
11
+ import { stdin as input, stdout as output } from "node:process";
8
12
  function parseVarFlags(values) {
9
13
  const out = {};
10
14
  for (const item of values) {
@@ -18,6 +22,9 @@ function parseVarFlags(values) {
18
22
  }
19
23
  return out;
20
24
  }
25
+ function parsePluginFlags(values) {
26
+ return Array.from(new Set(values.map((value) => value.trim()).filter((value) => value.length > 0)));
27
+ }
21
28
  function readEnv(name) {
22
29
  const value = process.env[name];
23
30
  if (value === undefined) {
@@ -42,6 +49,20 @@ function parseOptionalInt(value, fieldName) {
42
49
  }
43
50
  return parsed;
44
51
  }
52
+ async function promptProjectName(defaultValue) {
53
+ if (!input.isTTY) {
54
+ return defaultValue;
55
+ }
56
+ const rl = createInterface({ input, output });
57
+ try {
58
+ const answer = await rl.question(`Project name [${defaultValue}]: `);
59
+ const value = answer.trim();
60
+ return value || defaultValue;
61
+ }
62
+ finally {
63
+ rl.close();
64
+ }
65
+ }
45
66
  export function buildCli() {
46
67
  const program = new Command();
47
68
  program
@@ -57,6 +78,7 @@ export function buildCli() {
57
78
  .option("--verbose", "Verbose logging", false)
58
79
  .option("-s, --silent", "Skip reset confirmation prompt", false)
59
80
  .option("--provider <provider>", "Execution provider: command | remote | mock")
81
+ .option("--plugin <npm-spec>", "Preinstall plugin package (repeatable)", (v, p) => p.concat([v]), [])
60
82
  .option("--remote-base-url <url>", "Remote OpenClaw API base URL")
61
83
  .option("--remote-api-key <key>", "Remote OpenClaw API key")
62
84
  .option("--remote-api-header <header>", "Remote auth header name")
@@ -67,6 +89,7 @@ export function buildCli() {
67
89
  const provider = parseProvider(opts.provider ?? readEnv("CLAWCHEF_PROVIDER") ?? "command");
68
90
  const options = {
69
91
  vars: parseVarFlags(opts.var),
92
+ plugins: parsePluginFlags(opts.plugin),
70
93
  dryRun: Boolean(opts.dryRun),
71
94
  allowMissing: Boolean(opts.allowMissing),
72
95
  verbose: Boolean(opts.verbose),
@@ -92,6 +115,19 @@ export function buildCli() {
92
115
  }
93
116
  }
94
117
  });
118
+ program
119
+ .command("scaffold")
120
+ .argument("[dir]", "Target directory (default: current directory)")
121
+ .option("--name <project-name>", "Project name (default: directory name)")
122
+ .action(async (dir, opts) => {
123
+ const resolvedDir = path.resolve(dir?.trim() ? dir : process.cwd());
124
+ const defaultName = path.basename(resolvedDir);
125
+ const projectName = opts.name?.trim() ? opts.name.trim() : await promptProjectName(defaultName);
126
+ const result = await scaffoldProject(resolvedDir, { projectName });
127
+ process.stdout.write(`Scaffold created at ${result.targetDir}\n`);
128
+ process.stdout.write(`Project name: ${result.projectName}\n`);
129
+ process.stdout.write("Next: run npm install\n");
130
+ });
95
131
  program
96
132
  .command("validate")
97
133
  .argument("<recipe>", "Recipe path/URL/dir/archive[:file]")
@@ -4,6 +4,7 @@ export declare class CommandOpenClawProvider implements OpenClawProvider {
4
4
  private readonly stagedMessages;
5
5
  ensureVersion(config: OpenClawSection, dryRun: boolean, silent: boolean): Promise<EnsureVersionResult>;
6
6
  factoryReset(config: OpenClawSection, dryRun: boolean): Promise<void>;
7
+ installPlugin(config: OpenClawSection, pluginSpec: string, dryRun: boolean): Promise<void>;
7
8
  startGateway(config: OpenClawSection, dryRun: boolean): Promise<void>;
8
9
  createWorkspace(config: OpenClawSection, workspace: ResolvedWorkspaceDef, dryRun: boolean): Promise<void>;
9
10
  configureChannel(config: OpenClawSection, channel: ChannelDef, dryRun: boolean): Promise<void>;
@@ -10,9 +10,10 @@ const DEFAULT_COMMANDS = {
10
10
  use_version: "${bin} --version",
11
11
  install_version: "npm install -g openclaw@${version}",
12
12
  uninstall_version: "npm uninstall -g openclaw",
13
+ install_plugin: "${bin} plugins install ${plugin_spec_q}",
13
14
  factory_reset: "${bin} reset --scope full --yes --non-interactive",
14
15
  start_gateway: "${bin} gateway start",
15
- enable_plugin: "${bin} plugins enable ${channel_q}",
16
+ enable_plugin: "",
16
17
  login_channel: "${bin} channels login --channel ${channel_q}${account_arg}",
17
18
  create_agent: "${bin} agents add ${agent} --workspace ${workspace_path} --model ${model} --non-interactive --json",
18
19
  install_skill: "${bin} skills check",
@@ -302,6 +303,23 @@ export class CommandOpenClawProvider {
302
303
  }
303
304
  await runShell(resetCmd, dryRun);
304
305
  }
306
+ async installPlugin(config, pluginSpec, dryRun) {
307
+ const trimmed = pluginSpec.trim();
308
+ if (!trimmed) {
309
+ return;
310
+ }
311
+ const bin = config.bin ?? "openclaw";
312
+ const cmd = commandFor(config, "install_plugin", {
313
+ bin,
314
+ version: config.version,
315
+ plugin_spec: trimmed,
316
+ plugin_spec_q: shellQuote(trimmed),
317
+ });
318
+ if (!cmd.trim()) {
319
+ return;
320
+ }
321
+ await runShell(cmd, dryRun);
322
+ }
305
323
  async startGateway(config, dryRun) {
306
324
  const bin = config.bin ?? "openclaw";
307
325
  const startCmd = commandFor(config, "start_gateway", { bin, version: config.version });
@@ -328,14 +346,17 @@ export class CommandOpenClawProvider {
328
346
  }
329
347
  async configureChannel(config, channel, dryRun) {
330
348
  const bin = config.bin ?? "openclaw";
331
- const enablePluginCmd = commandFor(config, "enable_plugin", {
332
- bin,
333
- version: config.version,
334
- channel: channel.channel,
335
- channel_q: shellQuote(channel.channel),
336
- });
337
- if (enablePluginCmd.trim()) {
338
- await runShell(enablePluginCmd, dryRun);
349
+ const enablePluginTemplate = config.commands?.enable_plugin;
350
+ if (enablePluginTemplate?.trim()) {
351
+ const enablePluginCmd = fillTemplate(enablePluginTemplate, {
352
+ bin,
353
+ version: config.version,
354
+ channel: channel.channel,
355
+ channel_q: shellQuote(channel.channel),
356
+ });
357
+ if (enablePluginCmd.trim()) {
358
+ await runShell(enablePluginCmd, dryRun);
359
+ }
339
360
  }
340
361
  const flags = [
341
362
  "--channel",
@@ -3,6 +3,7 @@ import type { EnsureVersionResult, OpenClawProvider, ResolvedWorkspaceDef } from
3
3
  export declare class MockOpenClawProvider implements OpenClawProvider {
4
4
  private state;
5
5
  ensureVersion(config: OpenClawSection, _dryRun: boolean, _silent: boolean): Promise<EnsureVersionResult>;
6
+ installPlugin(_config: OpenClawSection, _pluginSpec: string, _dryRun: boolean): Promise<void>;
6
7
  factoryReset(_config: OpenClawSection, _dryRun: boolean): Promise<void>;
7
8
  startGateway(_config: OpenClawSection, _dryRun: boolean): Promise<void>;
8
9
  createWorkspace(_config: OpenClawSection, workspace: ResolvedWorkspaceDef, _dryRun: boolean): Promise<void>;
@@ -25,6 +25,9 @@ export class MockOpenClawProvider {
25
25
  this.state.currentVersion = config.version;
26
26
  return { installedThisRun };
27
27
  }
28
+ async installPlugin(_config, _pluginSpec, _dryRun) {
29
+ return;
30
+ }
28
31
  async factoryReset(_config, _dryRun) {
29
32
  this.state.workspaces.clear();
30
33
  this.state.channels.clear();
@@ -7,6 +7,7 @@ export interface EnsureVersionResult {
7
7
  }
8
8
  export interface OpenClawProvider {
9
9
  ensureVersion(config: OpenClawSection, dryRun: boolean, silent: boolean): Promise<EnsureVersionResult>;
10
+ installPlugin(config: OpenClawSection, pluginSpec: string, dryRun: boolean): Promise<void>;
10
11
  factoryReset(config: OpenClawSection, dryRun: boolean): Promise<void>;
11
12
  startGateway(config: OpenClawSection, dryRun: boolean): Promise<void>;
12
13
  createWorkspace(config: OpenClawSection, workspace: ResolvedWorkspaceDef, dryRun: boolean): Promise<void>;
@@ -6,6 +6,7 @@ export declare class RemoteOpenClawProvider implements OpenClawProvider {
6
6
  constructor(remoteConfig: Partial<OpenClawRemoteConfig>);
7
7
  private perform;
8
8
  ensureVersion(config: OpenClawSection, dryRun: boolean, _silent: boolean): Promise<EnsureVersionResult>;
9
+ installPlugin(config: OpenClawSection, pluginSpec: string, dryRun: boolean): Promise<void>;
9
10
  factoryReset(config: OpenClawSection, dryRun: boolean): Promise<void>;
10
11
  startGateway(config: OpenClawSection, dryRun: boolean): Promise<void>;
11
12
  createWorkspace(config: OpenClawSection, workspace: ResolvedWorkspaceDef, dryRun: boolean): Promise<void>;
@@ -102,6 +102,11 @@ export class RemoteOpenClawProvider {
102
102
  installedThisRun: Boolean(result.installed_this_run),
103
103
  };
104
104
  }
105
+ async installPlugin(config, pluginSpec, dryRun) {
106
+ await this.perform(config, "install_plugin", {
107
+ plugin_spec: pluginSpec,
108
+ }, dryRun);
109
+ }
105
110
  async factoryReset(config, dryRun) {
106
111
  await this.perform(config, "factory_reset", undefined, dryRun);
107
112
  }
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { homedir } from "node:os";
3
- import { mkdir, access, copyFile, writeFile, readFile } from "node:fs/promises";
3
+ import { mkdir, access, copyFile, writeFile, readFile, readdir, stat } from "node:fs/promises";
4
4
  import { constants } from "node:fs";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { stdin as input, stdout as output } from "node:process";
@@ -32,7 +32,9 @@ function resolveWorkspacePath(recipeOrigin, name, configuredPath) {
32
32
  }
33
33
  return path.resolve(configuredPath);
34
34
  }
35
- return path.join(homedir(), ".openclaw", "workspaces", name);
35
+ const trimmedName = name.trim() || name;
36
+ const workspaceName = trimmedName.startsWith("workspace-") ? trimmedName : `workspace-${trimmedName}`;
37
+ return path.join(homedir(), ".openclaw", workspaceName);
36
38
  }
37
39
  function isHttpUrl(value) {
38
40
  try {
@@ -78,6 +80,27 @@ async function readBinaryFromRef(recipeOrigin, reference) {
78
80
  const bytes = await response.arrayBuffer();
79
81
  return Buffer.from(bytes);
80
82
  }
83
+ async function collectLocalAssetFiles(rootDir, relDir = "") {
84
+ const currentDir = relDir ? path.join(rootDir, relDir) : rootDir;
85
+ const entries = await readdir(currentDir, { withFileTypes: true });
86
+ const out = [];
87
+ for (const entry of entries) {
88
+ const nextRel = relDir ? path.join(relDir, entry.name) : entry.name;
89
+ if (entry.isDirectory()) {
90
+ out.push(...await collectLocalAssetFiles(rootDir, nextRel));
91
+ continue;
92
+ }
93
+ if (entry.isFile()) {
94
+ out.push({
95
+ absolutePath: path.join(rootDir, nextRel),
96
+ relativePath: nextRel,
97
+ });
98
+ continue;
99
+ }
100
+ throw new ClawChefError(`Unsupported entry in assets directory: ${path.join(rootDir, nextRel)}`);
101
+ }
102
+ return out;
103
+ }
81
104
  async function confirmFactoryReset(options) {
82
105
  if (options.silent || options.dryRun) {
83
106
  return true;
@@ -112,6 +135,12 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
112
135
  await provider.factoryReset(recipe.openclaw, options.dryRun);
113
136
  logger.info("Factory reset completed");
114
137
  }
138
+ const pluginSpecs = Array.from(new Set([...(recipe.openclaw.plugins ?? []), ...options.plugins].map((v) => v.trim())))
139
+ .filter((v) => v.length > 0);
140
+ for (const pluginSpec of pluginSpecs) {
141
+ await provider.installPlugin(recipe.openclaw, pluginSpec, options.dryRun);
142
+ logger.info(`Plugin preinstalled: ${pluginSpec}`);
143
+ }
115
144
  for (const ws of recipe.workspaces ?? []) {
116
145
  const absPath = resolveWorkspacePath(recipeOrigin, ws.name, ws.path);
117
146
  workspacePaths.set(ws.name, absPath);
@@ -120,6 +149,39 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
120
149
  }
121
150
  await provider.createWorkspace(recipe.openclaw, { ...ws, path: absPath }, options.dryRun);
122
151
  logger.info(`Workspace created: ${ws.name}`);
152
+ if (!ws.assets?.trim()) {
153
+ continue;
154
+ }
155
+ const resolvedAssets = resolveFileRef(recipeOrigin, ws.assets);
156
+ if (resolvedAssets.kind !== "local") {
157
+ throw new ClawChefError(`Workspace assets must resolve to a local directory: ${ws.assets}. Direct URL recipes cannot use workspaces[].assets.`);
158
+ }
159
+ let assetDirStat;
160
+ try {
161
+ assetDirStat = await stat(resolvedAssets.value);
162
+ }
163
+ catch (err) {
164
+ const message = err instanceof Error ? err.message : String(err);
165
+ throw new ClawChefError(`Workspace assets path is not accessible: ${resolvedAssets.value} (${message})`);
166
+ }
167
+ if (!assetDirStat.isDirectory()) {
168
+ throw new ClawChefError(`Workspace assets must be a directory: ${resolvedAssets.value}`);
169
+ }
170
+ const assetFiles = await collectLocalAssetFiles(resolvedAssets.value);
171
+ for (const assetFile of assetFiles) {
172
+ if (provider.materializeFile) {
173
+ const content = await readFile(assetFile.absolutePath, "utf8");
174
+ await provider.materializeFile(recipe.openclaw, ws.name, assetFile.relativePath, content, true, options.dryRun);
175
+ }
176
+ else {
177
+ const target = path.resolve(absPath, assetFile.relativePath);
178
+ if (!options.dryRun) {
179
+ await mkdir(path.dirname(target), { recursive: true });
180
+ await copyFile(assetFile.absolutePath, target);
181
+ }
182
+ }
183
+ logger.info(`Workspace asset copied: ${ws.name}/${assetFile.relativePath}`);
184
+ }
123
185
  }
124
186
  for (const agent of recipe.agents ?? []) {
125
187
  const workspacePath = workspacePaths.get(agent.workspace);
package/dist/recipe.js CHANGED
@@ -125,6 +125,11 @@ function collectVars(recipe, cliVars) {
125
125
  }
126
126
  function semanticValidate(recipe) {
127
127
  const ws = new Set((recipe.workspaces ?? []).map((w) => w.name));
128
+ for (const workspace of recipe.workspaces ?? []) {
129
+ if (workspace.assets !== undefined && !workspace.assets.trim()) {
130
+ throw new ClawChefError(`Workspace ${workspace.name} has empty assets path`);
131
+ }
132
+ }
128
133
  for (const agent of recipe.agents ?? []) {
129
134
  if (!ws.has(agent.workspace)) {
130
135
  throw new ClawChefError(`Agent ${agent.name} references missing workspace: ${agent.workspace}`);
@@ -148,6 +153,10 @@ function semanticValidate(recipe) {
148
153
  if (!ALLOWED_CHANNELS.has(channel.channel)) {
149
154
  throw new ClawChefError(`Unsupported channel: ${channel.channel}. Allowed: ${Array.from(ALLOWED_CHANNELS).join(", ")}`);
150
155
  }
156
+ if (channel.channel === "telegram" &&
157
+ (channel.login || channel.login_mode !== undefined || channel.login_account !== undefined)) {
158
+ 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.");
159
+ }
151
160
  const hasAuth = Boolean(channel.use_env) ||
152
161
  Boolean(channel.token?.trim()) ||
153
162
  Boolean(channel.token_file?.trim()) ||