clawchef 0.1.0 → 0.1.1

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
@@ -17,9 +17,9 @@ Recipe-driven OpenClaw environment orchestrator.
17
17
  - Creates workspaces and agents (default workspace path: `~/.openclaw/workspaces/<workspace-name>`).
18
18
  - Materializes files into target workspaces.
19
19
  - Installs skills.
20
+ - Supports plugin preinstall via `openclaw.plugins[]` and runtime `--plugin` flags.
20
21
  - 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`).
22
+ - Supports interactive channel login at the end of execution (`channels[].login: true`) for channels that expose login.
23
23
  - Supports remote HTTP orchestration via runtime flags (`--provider remote`) when OpenClaw is reachable via API.
24
24
  - Writes preset conversation messages.
25
25
  - Runs agent and validates reply output.
@@ -27,9 +27,7 @@ Recipe-driven OpenClaw environment orchestrator.
27
27
  ## Install and run
28
28
 
29
29
  ```bash
30
- npm install
31
- npm run build
32
- npm i -g .
30
+ npm i -g clawchef
33
31
  clawchef cook recipes/sample.yaml
34
32
  ```
35
33
 
@@ -94,6 +92,12 @@ Telegram mock channel setup (for tests):
94
92
  CLAWCHEF_VAR_TELEGRAM_MOCK_API_KEY=test-key clawchef cook recipes/openclaw-telegram-mock.yaml -s
95
93
  ```
96
94
 
95
+ Install plugin only for this run:
96
+
97
+ ```bash
98
+ clawchef cook recipes/openclaw-telegram-mock.yaml --plugin openclaw-telegram-mock-channel -s
99
+ ```
100
+
97
101
  Remote HTTP orchestration:
98
102
 
99
103
  ```bash
@@ -121,12 +125,32 @@ clawchef validate ./bundle.zip
121
125
  clawchef validate ./bundle.zip:custom/recipe.yaml
122
126
  ```
123
127
 
128
+ Create a new recipe project scaffold:
129
+
130
+ ```bash
131
+ clawchef scaffold
132
+ clawchef scaffold ./my-recipe-project
133
+ clawchef scaffold ./my-recipe-project --name meetingbot
134
+ ```
135
+
136
+ `scaffold` prompts for project name (default: target directory name).
137
+
138
+ Scaffold output:
139
+
140
+ - `package.json` with `telegram-api-mock-server` in `devDependencies`
141
+ - `src/recipe.yaml` with `telegram-mock` channel and plugin preinstall
142
+ - `src/<project-name>-assets/{AGENTS.md,IDENTITY.md,SOUL.md,TOOLS.md}`
143
+ - `src/<project-name>-assets/scripts/scheduling.mjs`
144
+ - `test/recipe-smoke.test.mjs`
145
+
146
+ By default scaffold only writes files; it does not run `npm install`.
147
+
124
148
  ## Node.js API
125
149
 
126
150
  You can call clawchef directly from Node.js (without invoking CLI commands).
127
151
 
128
152
  ```ts
129
- import { cook, validate } from "clawchef";
153
+ import { cook, scaffold, validate } from "clawchef";
130
154
 
131
155
  await validate("recipes/sample.yaml");
132
156
 
@@ -137,11 +161,16 @@ await cook("recipes/sample.yaml", {
137
161
  openai_api_key: process.env.OPENAI_API_KEY ?? "",
138
162
  },
139
163
  });
164
+
165
+ await scaffold("./my-recipe-project", {
166
+ projectName: "my-recipe-project",
167
+ });
140
168
  ```
141
169
 
142
170
  `cook()` options:
143
171
 
144
172
  - `vars`: template variables (`Record<string, string>`)
173
+ - `plugins`: plugin npm specs to preinstall for this run (`string[]`)
145
174
  - `provider`: `command | remote | mock`
146
175
  - `remote`: remote provider config (same fields as CLI remote flags)
147
176
  - `dryRun`, `allowMissing`, `verbose`
@@ -152,6 +181,7 @@ Notes:
152
181
 
153
182
  - `validate()` throws on invalid recipe.
154
183
  - `cook()` throws on runtime/configuration errors.
184
+ - `scaffold()` creates `package.json`, `src/recipe.yaml`, `src/<project-name>-assets`, and `test/`.
155
185
 
156
186
  ## Variable precedence
157
187
 
@@ -233,6 +263,7 @@ Expected response format:
233
263
  Supported operation values sent by clawchef:
234
264
 
235
265
  - `ensure_version`, `factory_reset`, `start_gateway`
266
+ - `install_plugin`
236
267
  - `create_workspace`, `create_agent`, `materialize_file`, `install_skill`
237
268
  - `configure_channel`, `login_channel`
238
269
  - `run_agent`
@@ -258,6 +289,20 @@ Supported fields include:
258
289
  - setup toggles: `skip_channels`, `skip_skills`, `skip_health`, `skip_ui`, `skip_daemon`, `install_daemon`
259
290
  - 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
291
 
292
+ ### Plugin preinstall
293
+
294
+ Use `openclaw.plugins` to preinstall plugin packages before workspace/channel setup.
295
+
296
+ Example:
297
+
298
+ ```yaml
299
+ openclaw:
300
+ version: "2026.2.9"
301
+ plugins:
302
+ - "openclaw-telegram-mock-channel"
303
+ - "@scope/custom-channel-plugin@1.2.3"
304
+ ```
305
+
261
306
  When `openclaw.bootstrap` contains provider keys, `clawchef` also injects them into runtime env for `openclaw agent --local`.
262
307
 
263
308
  For `command` provider, default command templates are:
@@ -265,9 +310,9 @@ For `command` provider, default command templates are:
265
310
  - `use_version`: `${bin} --version`
266
311
  - `install_version`: `npm install -g openclaw@${version}`
267
312
  - `uninstall_version`: `npm uninstall -g openclaw`
313
+ - `install_plugin`: `${bin} plugins install ${plugin_spec_q}`
268
314
  - `factory_reset`: `${bin} reset --scope full --yes --non-interactive`
269
315
  - `start_gateway`: `${bin} gateway start`
270
- - `enable_plugin`: `${bin} plugins enable ${channel_q}`
271
316
  - `login_channel`: `${bin} channels login --channel ${channel_q}${account_arg}`
272
317
  - `create_workspace`: generated from `openclaw.bootstrap` (override with `openclaw.commands.create_workspace`)
273
318
  - `create_agent`: `${bin} agents add ${agent} --workspace ${workspace_path} --model ${model} --non-interactive --json`
@@ -277,10 +322,14 @@ For `command` provider, default command templates are:
277
322
 
278
323
  You can override any command under `openclaw.commands` in recipe.
279
324
 
325
+ By default, clawchef does not auto-run `openclaw plugins enable <channel>` during channel configuration.
326
+ If you need custom enable behavior, set `openclaw.commands.enable_plugin` explicitly.
327
+
280
328
  ## Channels
281
329
 
282
330
  Use `channels[]` to configure accounts via `openclaw channels add`.
283
331
  If `login: true` is set, clawchef runs channel login at the end of `cook` (after gateway start).
332
+ Telegram does not support `openclaw channels login`; do not set `login` for `channel: "telegram"`.
284
333
 
285
334
  Example:
286
335
 
@@ -289,7 +338,6 @@ channels:
289
338
  - channel: "telegram"
290
339
  token: "${telegram_bot_token}"
291
340
  account: "default"
292
- login: true
293
341
 
294
342
  - channel: "slack"
295
343
  bot_token: "${slack_bot_token}"
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
  }
@@ -112,6 +112,12 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
112
112
  await provider.factoryReset(recipe.openclaw, options.dryRun);
113
113
  logger.info("Factory reset completed");
114
114
  }
115
+ const pluginSpecs = Array.from(new Set([...(recipe.openclaw.plugins ?? []), ...options.plugins].map((v) => v.trim())))
116
+ .filter((v) => v.length > 0);
117
+ for (const pluginSpec of pluginSpecs) {
118
+ await provider.installPlugin(recipe.openclaw, pluginSpec, options.dryRun);
119
+ logger.info(`Plugin preinstalled: ${pluginSpec}`);
120
+ }
115
121
  for (const ws of recipe.workspaces ?? []) {
116
122
  const absPath = resolveWorkspacePath(recipeOrigin, ws.name, ws.path);
117
123
  workspacePaths.set(ws.name, absPath);
package/dist/recipe.js CHANGED
@@ -148,6 +148,10 @@ function semanticValidate(recipe) {
148
148
  if (!ALLOWED_CHANNELS.has(channel.channel)) {
149
149
  throw new ClawChefError(`Unsupported channel: ${channel.channel}. Allowed: ${Array.from(ALLOWED_CHANNELS).join(", ")}`);
150
150
  }
151
+ if (channel.channel === "telegram" &&
152
+ (channel.login || channel.login_mode !== undefined || channel.login_account !== undefined)) {
153
+ 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.");
154
+ }
151
155
  const hasAuth = Boolean(channel.use_env) ||
152
156
  Boolean(channel.token?.trim()) ||
153
157
  Boolean(channel.token_file?.trim()) ||
@@ -0,0 +1,8 @@
1
+ export interface ScaffoldOptions {
2
+ projectName?: string;
3
+ }
4
+ export interface ScaffoldResult {
5
+ targetDir: string;
6
+ projectName: string;
7
+ }
8
+ export declare function scaffoldProject(targetDirArg?: string, options?: ScaffoldOptions): Promise<ScaffoldResult>;
@@ -0,0 +1,197 @@
1
+ import path from "node:path";
2
+ import { access, mkdir, readdir, writeFile } from "node:fs/promises";
3
+ import { constants } from "node:fs";
4
+ import { ClawChefError } from "./errors.js";
5
+ function normalizeProjectName(value) {
6
+ const normalized = value
7
+ .trim()
8
+ .toLowerCase()
9
+ .replace(/[\s_]+/g, "-")
10
+ .replace(/[^a-z0-9-]/g, "-")
11
+ .replace(/-+/g, "-")
12
+ .replace(/^-|-$/g, "");
13
+ if (!normalized) {
14
+ throw new ClawChefError("Project name is empty after normalization. Use letters, numbers, spaces, underscore, or dash.");
15
+ }
16
+ return normalized;
17
+ }
18
+ async function pathExists(filePath) {
19
+ try {
20
+ await access(filePath, constants.F_OK);
21
+ return true;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ async function ensureDirectoryEmpty(targetDir) {
28
+ const exists = await pathExists(targetDir);
29
+ if (!exists) {
30
+ await mkdir(targetDir, { recursive: true });
31
+ return;
32
+ }
33
+ const entries = await readdir(targetDir);
34
+ if (entries.length > 0) {
35
+ throw new ClawChefError(`Target directory is not empty: ${targetDir}`);
36
+ }
37
+ }
38
+ function makePackageJson(projectName) {
39
+ const content = {
40
+ name: `${projectName}-recipe`,
41
+ version: "0.1.0",
42
+ private: true,
43
+ type: "module",
44
+ scripts: {
45
+ "test:recipe": "node --test test/recipe-smoke.test.mjs",
46
+ test: "node --test \"test/**/*.test.mjs\"",
47
+ },
48
+ devDependencies: {
49
+ "telegram-api-mock-server": "^0.1.5",
50
+ },
51
+ };
52
+ return `${JSON.stringify(content, null, 2)}\n`;
53
+ }
54
+ function makeRecipeYaml(projectName) {
55
+ return `version: "1"
56
+ name: "${projectName}"
57
+
58
+ params:
59
+ openclaw_version:
60
+ default: "2026.2.9"
61
+ workspace_name:
62
+ default: "${projectName}"
63
+ agent_name:
64
+ default: "${projectName}"
65
+ agent_model:
66
+ default: "openai/gpt-4.1"
67
+ telegram_mock_api_key:
68
+ required: true
69
+
70
+ openclaw:
71
+ bin: "openclaw"
72
+ version: "\${openclaw_version}"
73
+ install: "never"
74
+ plugins:
75
+ - "openclaw-telegram-mock-channel"
76
+
77
+ workspaces:
78
+ - name: "\${workspace_name}"
79
+
80
+ agents:
81
+ - workspace: "\${workspace_name}"
82
+ name: "\${agent_name}"
83
+ model: "\${agent_model}"
84
+
85
+ files:
86
+ - workspace: "\${workspace_name}"
87
+ path: "AGENTS.md"
88
+ overwrite: true
89
+ content_from: "./${projectName}-assets/AGENTS.md"
90
+
91
+ - workspace: "\${workspace_name}"
92
+ path: "IDENTITY.md"
93
+ overwrite: true
94
+ content_from: "./${projectName}-assets/IDENTITY.md"
95
+
96
+ - workspace: "\${workspace_name}"
97
+ path: "SOUL.md"
98
+ overwrite: true
99
+ content_from: "./${projectName}-assets/SOUL.md"
100
+
101
+ - workspace: "\${workspace_name}"
102
+ path: "TOOLS.md"
103
+ overwrite: true
104
+ content_from: "./${projectName}-assets/TOOLS.md"
105
+
106
+ - workspace: "\${workspace_name}"
107
+ path: "scripts/scheduling.mjs"
108
+ overwrite: true
109
+ content_from: "./${projectName}-assets/scripts/scheduling.mjs"
110
+
111
+ channels:
112
+ - channel: "telegram-mock"
113
+ account: "default"
114
+ token: "\${telegram_mock_api_key}"
115
+ extra_flags:
116
+ mock_bind: "127.0.0.1:18790"
117
+ mock_api_key: "\${telegram_mock_api_key}"
118
+ mode: "webhook"
119
+ `;
120
+ }
121
+ function makeAgentsDoc(projectName) {
122
+ return `# ${projectName}
123
+
124
+ Project scaffold generated by clawchef.
125
+ `;
126
+ }
127
+ function makeIdentityDoc(projectName) {
128
+ return `# Identity
129
+
130
+ You are the ${projectName} assistant.
131
+ `;
132
+ }
133
+ function makeSoulDoc() {
134
+ return `# Soul
135
+
136
+ Keep responses practical, concise, and action-oriented.
137
+ `;
138
+ }
139
+ function makeToolsDoc() {
140
+ return `# Tools
141
+
142
+ - Use available workspace files first.
143
+ - Ask for missing secrets explicitly.
144
+ `;
145
+ }
146
+ function makeSchedulingScript() {
147
+ return `#!/usr/bin/env node
148
+ import process from "node:process";
149
+
150
+ const message = process.argv.slice(2).join(" ").trim();
151
+ if (!message) {
152
+ console.error("Usage: node scripts/scheduling.mjs <message>");
153
+ process.exit(1);
154
+ }
155
+
156
+ console.log("[scheduling] " + message);
157
+ `;
158
+ }
159
+ function makeRecipeSmokeTest() {
160
+ return `import test from "node:test";
161
+ import assert from "node:assert/strict";
162
+ import { access } from "node:fs/promises";
163
+
164
+ test("recipe scaffold files exist", async () => {
165
+ await access("src/recipe.yaml");
166
+ await access("package.json");
167
+ assert.ok(true);
168
+ });
169
+ `;
170
+ }
171
+ export async function scaffoldProject(targetDirArg, options = {}) {
172
+ const targetDir = path.resolve(targetDirArg?.trim() ? targetDirArg : process.cwd());
173
+ await ensureDirectoryEmpty(targetDir);
174
+ const defaultName = path.basename(targetDir);
175
+ const rawProjectName = options.projectName?.trim() || defaultName;
176
+ const projectName = normalizeProjectName(rawProjectName);
177
+ const srcDir = path.join(targetDir, "src");
178
+ const assetsDir = path.join(srcDir, `${projectName}-assets`);
179
+ const assetsScriptsDir = path.join(assetsDir, "scripts");
180
+ const testDir = path.join(targetDir, "test");
181
+ await mkdir(srcDir, { recursive: true });
182
+ await mkdir(assetsDir, { recursive: true });
183
+ await mkdir(assetsScriptsDir, { recursive: true });
184
+ await mkdir(testDir, { recursive: true });
185
+ await writeFile(path.join(targetDir, "package.json"), makePackageJson(projectName), "utf8");
186
+ await writeFile(path.join(srcDir, "recipe.yaml"), makeRecipeYaml(projectName), "utf8");
187
+ await writeFile(path.join(assetsDir, "AGENTS.md"), makeAgentsDoc(projectName), "utf8");
188
+ await writeFile(path.join(assetsDir, "IDENTITY.md"), makeIdentityDoc(projectName), "utf8");
189
+ await writeFile(path.join(assetsDir, "SOUL.md"), makeSoulDoc(), "utf8");
190
+ await writeFile(path.join(assetsDir, "TOOLS.md"), makeToolsDoc(), "utf8");
191
+ await writeFile(path.join(assetsScriptsDir, "scheduling.mjs"), makeSchedulingScript(), "utf8");
192
+ await writeFile(path.join(testDir, "recipe-smoke.test.mjs"), makeRecipeSmokeTest(), "utf8");
193
+ return {
194
+ targetDir,
195
+ projectName,
196
+ };
197
+ }