clawchef 0.1.1 → 0.1.3

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,10 +14,12 @@ 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.
20
21
  - Supports plugin preinstall via `openclaw.plugins[]` and runtime `--plugin` flags.
22
+ - Supports preserving existing OpenClaw state via `--keep-openclaw-state` (skip factory reset).
21
23
  - Configures channels with `openclaw channels add`.
22
24
  - Supports interactive channel login at the end of execution (`channels[].login: true`) for channels that expose login.
23
25
  - Supports remote HTTP orchestration via runtime flags (`--provider remote`) when OpenClaw is reachable via API.
@@ -34,20 +36,20 @@ clawchef cook recipes/sample.yaml
34
36
  Run recipe from URL:
35
37
 
36
38
  ```bash
37
- clawchef cook https://example.com/recipes/sample.yaml --provider remote -s
39
+ clawchef cook https://example.com/recipes/sample.yaml --provider remote
38
40
  ```
39
41
 
40
42
  Run recipe from archive (default `recipe.yaml`):
41
43
 
42
44
  ```bash
43
- clawchef cook ./bundle.tgz --provider mock -s
45
+ clawchef cook ./bundle.tgz --provider mock
44
46
  ```
45
47
 
46
48
  Run specific recipe in directory or archive:
47
49
 
48
50
  ```bash
49
- clawchef cook ./recipes-pack:team/recipe-prod.yaml --provider remote -s
50
- clawchef cook https://example.com/recipes-pack.zip:team/recipe-prod.yaml --provider remote -s
51
+ clawchef cook ./recipes-pack:team/recipe-prod.yaml --provider remote
52
+ clawchef cook https://example.com/recipes-pack.zip:team/recipe-prod.yaml --provider remote
51
53
  ```
52
54
 
53
55
  Dev mode:
@@ -59,13 +61,13 @@ clawchef cook recipes/sample.yaml --verbose
59
61
  Run sample with mock provider:
60
62
 
61
63
  ```bash
62
- clawchef cook recipes/sample.yaml --provider mock -s
64
+ clawchef cook recipes/sample.yaml --provider mock
63
65
  ```
64
66
 
65
67
  Run `content_from` sample:
66
68
 
67
69
  ```bash
68
- clawchef cook recipes/content-from-sample.yaml --provider mock -s
70
+ clawchef cook recipes/content-from-sample.yaml --provider mock
69
71
  ```
70
72
 
71
73
  Skip reset confirmation prompt:
@@ -74,6 +76,15 @@ Skip reset confirmation prompt:
74
76
  clawchef cook recipes/sample.yaml -s
75
77
  ```
76
78
 
79
+ Warning: `-s/--silent` suppresses the factory-reset confirmation and auto-chooses force reinstall on version mismatch.
80
+ Use it only in CI/non-interactive flows where destructive reset behavior is expected.
81
+
82
+ Keep existing OpenClaw state (skip reset and keep current version on mismatch):
83
+
84
+ ```bash
85
+ clawchef cook recipes/sample.yaml --keep-openclaw-state
86
+ ```
87
+
77
88
  From-zero OpenClaw bootstrap (recommended):
78
89
 
79
90
  ```bash
@@ -83,19 +94,19 @@ CLAWCHEF_VAR_OPENAI_API_KEY=sk-... clawchef cook recipes/openclaw-from-zero.yaml
83
94
  Telegram channel setup only:
84
95
 
85
96
  ```bash
86
- CLAWCHEF_VAR_TELEGRAM_BOT_TOKEN=123456:abc... clawchef cook recipes/openclaw-telegram.yaml -s
97
+ CLAWCHEF_VAR_TELEGRAM_BOT_TOKEN=123456:abc... clawchef cook recipes/openclaw-telegram.yaml
87
98
  ```
88
99
 
89
100
  Telegram mock channel setup (for tests):
90
101
 
91
102
  ```bash
92
- CLAWCHEF_VAR_TELEGRAM_MOCK_API_KEY=test-key clawchef cook recipes/openclaw-telegram-mock.yaml -s
103
+ CLAWCHEF_VAR_TELEGRAM_MOCK_API_KEY=test-key clawchef cook recipes/openclaw-telegram-mock.yaml
93
104
  ```
94
105
 
95
106
  Install plugin only for this run:
96
107
 
97
108
  ```bash
98
- clawchef cook recipes/openclaw-telegram-mock.yaml --plugin openclaw-telegram-mock-channel -s
109
+ clawchef cook recipes/openclaw-telegram-mock.yaml --plugin openclaw-telegram-mock-channel
99
110
  ```
100
111
 
101
112
  Remote HTTP orchestration:
@@ -103,7 +114,7 @@ Remote HTTP orchestration:
103
114
  ```bash
104
115
  CLAWCHEF_REMOTE_BASE_URL=https://remote-openclaw.example.com \
105
116
  CLAWCHEF_REMOTE_API_KEY=secret-token \
106
- clawchef cook recipes/openclaw-remote-http.yaml --provider remote -s --verbose
117
+ clawchef cook recipes/openclaw-remote-http.yaml --provider remote --verbose
107
118
  ```
108
119
 
109
120
  Validate recipe structure only:
@@ -138,9 +149,9 @@ clawchef scaffold ./my-recipe-project --name meetingbot
138
149
  Scaffold output:
139
150
 
140
151
  - `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`
152
+ - `recipe.yaml` with `telegram-mock` channel, plugin preinstall, and `workspaces[].assets`
153
+ - `assets/{AGENTS.md,IDENTITY.md,SOUL.md,TOOLS.md}`
154
+ - `assets/scripts/scheduling.mjs`
144
155
  - `test/recipe-smoke.test.mjs`
145
156
 
146
157
  By default scaffold only writes files; it does not run `npm install`.
@@ -177,11 +188,14 @@ await scaffold("./my-recipe-project", {
177
188
  - `silent` (default: `true` in Node API)
178
189
  - `loadDotEnvFromCwd` (default: `true`)
179
190
 
191
+ Node API `silent: true` has the same risk as CLI `-s`: no reset confirmation and force reinstall on version mismatch.
192
+ Set `silent: false` when you want an interactive safety prompt.
193
+
180
194
  Notes:
181
195
 
182
196
  - `validate()` throws on invalid recipe.
183
197
  - `cook()` throws on runtime/configuration errors.
184
- - `scaffold()` creates `package.json`, `src/recipe.yaml`, `src/<project-name>-assets`, and `test/`.
198
+ - `scaffold()` creates `package.json`, `recipe.yaml`, `assets/`, and `test/`.
185
199
 
186
200
  ## Variable precedence
187
201
 
@@ -243,7 +257,7 @@ Request payload format (POST):
243
257
  "payload": {
244
258
  "workspace": {
245
259
  "name": "demo",
246
- "path": "/home/runner/.openclaw/workspaces/demo"
260
+ "path": "/home/runner/.openclaw/workspace-demo"
247
261
  }
248
262
  }
249
263
  }
@@ -356,54 +370,27 @@ Supported common fields:
356
370
  - 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`
357
371
  - advanced passthrough: `extra_flags` (`snake_case` keys become `--kebab-case` CLI flags)
358
372
 
359
- ### Telegram mock channel (for recipe tests)
360
-
361
- Use `channel: "telegram-mock"` when you need to test Telegram-related recipe flows without connecting to the real Telegram network.
362
-
363
- `clawchef` treats `telegram-mock` like any other channel and passes mock-specific flags through `extra_flags`.
364
-
365
- Example:
366
-
367
- ```yaml
368
- params:
369
- telegram_mock_api_key:
370
- required: true
371
-
372
- channels:
373
- - channel: "telegram-mock"
374
- account: "testbot"
375
- token: "${telegram_mock_api_key}"
376
- extra_flags:
377
- mock_bind: "127.0.0.1:18790"
378
- mock_api_key: "${telegram_mock_api_key}"
379
- mode: "webhook"
380
- ```
381
-
382
- Typical test setup:
383
-
384
- - Start OpenClaw with the telegram-mock plugin enabled.
385
- - Run `clawchef cook ...` to configure workspaces/agents/channels.
386
- - Use your external test program (HTTP API or Node.js SDK) to inject inbound mock messages and assert outbound events.
387
-
388
- Login fields:
389
-
390
- - `login: true` enables channel login step
391
- - `login_mode`: currently supports `interactive`
392
- - `login_account`: override account used for login (defaults to `account`)
393
-
394
- Security rules:
395
-
396
- - Do not inline secret values in `channels[]`.
397
- - Use `${var}` placeholders and inject values via `--var` / `CLAWCHEF_VAR_*`.
398
-
399
373
  ## Workspace path behavior
400
374
 
401
375
  - `workspaces[].path` is optional.
402
- - If omitted, clawchef uses `~/.openclaw/workspaces/<workspace-name>`.
376
+ - If omitted, clawchef uses `~/.openclaw/workspace-<workspace-name>`.
377
+ - `workspaces[].assets` is optional.
378
+ - If `assets` is set, clawchef recursively copies files from that directory into the workspace root.
379
+ - `assets` is resolved relative to the recipe file path (unless absolute path is given).
380
+ - `files[]` runs after assets copy, so `files[]` can override copied asset files.
381
+ - Direct URL recipes do not support `workspaces[].assets` (assets must resolve to a local directory).
403
382
  - If provided, relative paths are resolved from the recipe file directory.
404
383
  - For direct URL recipe files, relative workspace paths are resolved from the current working directory.
405
384
  - For directory/archive recipe references, relative workspace paths are resolved from the selected recipe file directory.
406
385
 
386
+ Example:
387
+
388
+ ```yaml
389
+ workspaces:
390
+ - name: "workspace-meeting"
391
+ assets: "./meetingbot-assets"
392
+ ```
393
+
407
394
  ## File content references
408
395
 
409
396
  In `files[]`, set exactly one of:
package/dist/api.js CHANGED
@@ -15,6 +15,7 @@ function normalizeCookOptions(options) {
15
15
  allowMissing: Boolean(options.allowMissing),
16
16
  verbose: Boolean(options.verbose),
17
17
  silent: options.silent ?? true,
18
+ keepOpenClawState: false,
18
19
  provider: options.provider ?? "command",
19
20
  remote: options.remote ?? {},
20
21
  };
package/dist/cli.js CHANGED
@@ -77,6 +77,7 @@ export function buildCli() {
77
77
  .option("--allow-missing", "Allow unresolved template variables", false)
78
78
  .option("--verbose", "Verbose logging", false)
79
79
  .option("-s, --silent", "Skip reset confirmation prompt", false)
80
+ .option("--keep-openclaw-state", "Preserve existing OpenClaw state (skip factory reset)", false)
80
81
  .option("--provider <provider>", "Execution provider: command | remote | mock")
81
82
  .option("--plugin <npm-spec>", "Preinstall plugin package (repeatable)", (v, p) => p.concat([v]), [])
82
83
  .option("--remote-base-url <url>", "Remote OpenClaw API base URL")
@@ -94,6 +95,7 @@ export function buildCli() {
94
95
  allowMissing: Boolean(opts.allowMissing),
95
96
  verbose: Boolean(opts.verbose),
96
97
  silent: Boolean(opts.silent),
98
+ keepOpenClawState: Boolean(opts.keepOpenclawState),
97
99
  provider,
98
100
  remote: {
99
101
  base_url: opts.remoteBaseUrl ?? readEnv("CLAWCHEF_REMOTE_BASE_URL"),
@@ -2,7 +2,7 @@ import type { AgentDef, ChannelDef, ConversationDef, OpenClawSection } from "../
2
2
  import type { EnsureVersionResult, OpenClawProvider, ResolvedWorkspaceDef } from "./provider.js";
3
3
  export declare class CommandOpenClawProvider implements OpenClawProvider {
4
4
  private readonly stagedMessages;
5
- ensureVersion(config: OpenClawSection, dryRun: boolean, silent: boolean): Promise<EnsureVersionResult>;
5
+ ensureVersion(config: OpenClawSection, dryRun: boolean, silent: boolean, keepOpenClawState: boolean): Promise<EnsureVersionResult>;
6
6
  factoryReset(config: OpenClawSection, dryRun: boolean): Promise<void>;
7
7
  installPlugin(config: OpenClawSection, pluginSpec: string, dryRun: boolean): Promise<void>;
8
8
  startGateway(config: OpenClawSection, dryRun: boolean): Promise<void>;
@@ -221,7 +221,7 @@ function bootstrapRuntimeEnv(bootstrap) {
221
221
  }
222
222
  export class CommandOpenClawProvider {
223
223
  stagedMessages = new Map();
224
- async ensureVersion(config, dryRun, silent) {
224
+ async ensureVersion(config, dryRun, silent, keepOpenClawState) {
225
225
  const bin = config.bin ?? "openclaw";
226
226
  const installPolicy = config.install ?? "auto";
227
227
  const useCmd = commandFor(config, "use_version", { bin, version: config.version });
@@ -272,6 +272,9 @@ export class CommandOpenClawProvider {
272
272
  if (installedThisRun) {
273
273
  throw new ClawChefError(`OpenClaw version mismatch after install: current ${currentVersion}, expected ${config.version}`);
274
274
  }
275
+ if (keepOpenClawState) {
276
+ return { installedThisRun: false };
277
+ }
275
278
  const choice = await chooseVersionMismatchAction(currentVersion, config.version, silent);
276
279
  if (choice === "ignore") {
277
280
  return { installedThisRun: false };
@@ -2,7 +2,7 @@ import type { AgentDef, ChannelDef, ConversationDef, OpenClawSection } from "../
2
2
  import type { EnsureVersionResult, OpenClawProvider, ResolvedWorkspaceDef } from "./provider.js";
3
3
  export declare class MockOpenClawProvider implements OpenClawProvider {
4
4
  private state;
5
- ensureVersion(config: OpenClawSection, _dryRun: boolean, _silent: boolean): Promise<EnsureVersionResult>;
5
+ ensureVersion(config: OpenClawSection, _dryRun: boolean, _silent: boolean, _keepOpenClawState: boolean): Promise<EnsureVersionResult>;
6
6
  installPlugin(_config: OpenClawSection, _pluginSpec: string, _dryRun: boolean): Promise<void>;
7
7
  factoryReset(_config: OpenClawSection, _dryRun: boolean): Promise<void>;
8
8
  startGateway(_config: OpenClawSection, _dryRun: boolean): Promise<void>;
@@ -7,7 +7,7 @@ export class MockOpenClawProvider {
7
7
  skills: new Set(),
8
8
  messages: new Map(),
9
9
  };
10
- async ensureVersion(config, _dryRun, _silent) {
10
+ async ensureVersion(config, _dryRun, _silent, _keepOpenClawState) {
11
11
  const policy = config.install ?? "auto";
12
12
  const installed = this.state.installedVersions.has(config.version);
13
13
  let installedThisRun = false;
@@ -6,7 +6,7 @@ export interface EnsureVersionResult {
6
6
  installedThisRun: boolean;
7
7
  }
8
8
  export interface OpenClawProvider {
9
- ensureVersion(config: OpenClawSection, dryRun: boolean, silent: boolean): Promise<EnsureVersionResult>;
9
+ ensureVersion(config: OpenClawSection, dryRun: boolean, silent: boolean, keepOpenClawState: boolean): Promise<EnsureVersionResult>;
10
10
  installPlugin(config: OpenClawSection, pluginSpec: string, dryRun: boolean): Promise<void>;
11
11
  factoryReset(config: OpenClawSection, dryRun: boolean): Promise<void>;
12
12
  startGateway(config: OpenClawSection, dryRun: boolean): Promise<void>;
@@ -5,7 +5,7 @@ export declare class RemoteOpenClawProvider implements OpenClawProvider {
5
5
  private readonly remoteConfig;
6
6
  constructor(remoteConfig: Partial<OpenClawRemoteConfig>);
7
7
  private perform;
8
- ensureVersion(config: OpenClawSection, dryRun: boolean, _silent: boolean): Promise<EnsureVersionResult>;
8
+ ensureVersion(config: OpenClawSection, dryRun: boolean, _silent: boolean, _keepOpenClawState: boolean): Promise<EnsureVersionResult>;
9
9
  installPlugin(config: OpenClawSection, pluginSpec: string, dryRun: boolean): Promise<void>;
10
10
  factoryReset(config: OpenClawSection, dryRun: boolean): Promise<void>;
11
11
  startGateway(config: OpenClawSection, dryRun: boolean): Promise<void>;
@@ -94,7 +94,7 @@ export class RemoteOpenClawProvider {
94
94
  clearTimeout(timeout);
95
95
  }
96
96
  }
97
- async ensureVersion(config, dryRun, _silent) {
97
+ async ensureVersion(config, dryRun, _silent, _keepOpenClawState) {
98
98
  const result = await this.perform(config, "ensure_version", {
99
99
  install: config.install,
100
100
  }, dryRun);
@@ -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;
@@ -99,11 +122,14 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
99
122
  const remoteMode = options.provider === "remote";
100
123
  const workspacePaths = new Map();
101
124
  logger.info(`Running recipe: ${recipe.name}`);
102
- const versionResult = await provider.ensureVersion(recipe.openclaw, options.dryRun, options.silent);
125
+ const versionResult = await provider.ensureVersion(recipe.openclaw, options.dryRun, options.silent, options.keepOpenClawState);
103
126
  logger.info(`OpenClaw version ready: ${recipe.openclaw.version}`);
104
127
  if (versionResult.installedThisRun) {
105
128
  logger.info("OpenClaw was installed in this run; skipping factory reset");
106
129
  }
130
+ else if (options.keepOpenClawState) {
131
+ logger.info("Keeping existing OpenClaw state; skipping factory reset");
132
+ }
107
133
  else {
108
134
  const confirmed = await confirmFactoryReset(options);
109
135
  if (!confirmed) {
@@ -126,6 +152,39 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
126
152
  }
127
153
  await provider.createWorkspace(recipe.openclaw, { ...ws, path: absPath }, options.dryRun);
128
154
  logger.info(`Workspace created: ${ws.name}`);
155
+ if (!ws.assets?.trim()) {
156
+ continue;
157
+ }
158
+ const resolvedAssets = resolveFileRef(recipeOrigin, ws.assets);
159
+ if (resolvedAssets.kind !== "local") {
160
+ throw new ClawChefError(`Workspace assets must resolve to a local directory: ${ws.assets}. Direct URL recipes cannot use workspaces[].assets.`);
161
+ }
162
+ let assetDirStat;
163
+ try {
164
+ assetDirStat = await stat(resolvedAssets.value);
165
+ }
166
+ catch (err) {
167
+ const message = err instanceof Error ? err.message : String(err);
168
+ throw new ClawChefError(`Workspace assets path is not accessible: ${resolvedAssets.value} (${message})`);
169
+ }
170
+ if (!assetDirStat.isDirectory()) {
171
+ throw new ClawChefError(`Workspace assets must be a directory: ${resolvedAssets.value}`);
172
+ }
173
+ const assetFiles = await collectLocalAssetFiles(resolvedAssets.value);
174
+ for (const assetFile of assetFiles) {
175
+ if (provider.materializeFile) {
176
+ const content = await readFile(assetFile.absolutePath, "utf8");
177
+ await provider.materializeFile(recipe.openclaw, ws.name, assetFile.relativePath, content, true, options.dryRun);
178
+ }
179
+ else {
180
+ const target = path.resolve(absPath, assetFile.relativePath);
181
+ if (!options.dryRun) {
182
+ await mkdir(path.dirname(target), { recursive: true });
183
+ await copyFile(assetFile.absolutePath, target);
184
+ }
185
+ }
186
+ logger.info(`Workspace asset copied: ${ws.name}/${assetFile.relativePath}`);
187
+ }
129
188
  }
130
189
  for (const agent of recipe.agents ?? []) {
131
190
  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}`);
package/dist/scaffold.js CHANGED
@@ -76,38 +76,13 @@ openclaw:
76
76
 
77
77
  workspaces:
78
78
  - name: "\${workspace_name}"
79
+ assets: "./assets"
79
80
 
80
81
  agents:
81
82
  - workspace: "\${workspace_name}"
82
83
  name: "\${agent_name}"
83
84
  model: "\${agent_model}"
84
85
 
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
86
  channels:
112
87
  - channel: "telegram-mock"
113
88
  account: "default"
@@ -159,11 +134,17 @@ console.log("[scheduling] " + message);
159
134
  function makeRecipeSmokeTest() {
160
135
  return `import test from "node:test";
161
136
  import assert from "node:assert/strict";
137
+ import path from "node:path";
138
+ import { fileURLToPath } from "node:url";
162
139
  import { access } from "node:fs/promises";
163
140
 
141
+ const testDir = path.dirname(fileURLToPath(import.meta.url));
142
+ const projectRoot = path.resolve(testDir, "..");
143
+
164
144
  test("recipe scaffold files exist", async () => {
165
- await access("src/recipe.yaml");
166
- await access("package.json");
145
+ await access(path.join(projectRoot, "recipe.yaml"));
146
+ await access(path.join(projectRoot, "assets", "AGENTS.md"));
147
+ await access(path.join(projectRoot, "package.json"));
167
148
  assert.ok(true);
168
149
  });
169
150
  `;
@@ -174,16 +155,14 @@ export async function scaffoldProject(targetDirArg, options = {}) {
174
155
  const defaultName = path.basename(targetDir);
175
156
  const rawProjectName = options.projectName?.trim() || defaultName;
176
157
  const projectName = normalizeProjectName(rawProjectName);
177
- const srcDir = path.join(targetDir, "src");
178
- const assetsDir = path.join(srcDir, `${projectName}-assets`);
158
+ const assetsDir = path.join(targetDir, "assets");
179
159
  const assetsScriptsDir = path.join(assetsDir, "scripts");
180
160
  const testDir = path.join(targetDir, "test");
181
- await mkdir(srcDir, { recursive: true });
182
161
  await mkdir(assetsDir, { recursive: true });
183
162
  await mkdir(assetsScriptsDir, { recursive: true });
184
163
  await mkdir(testDir, { recursive: true });
185
164
  await writeFile(path.join(targetDir, "package.json"), makePackageJson(projectName), "utf8");
186
- await writeFile(path.join(srcDir, "recipe.yaml"), makeRecipeYaml(projectName), "utf8");
165
+ await writeFile(path.join(targetDir, "recipe.yaml"), makeRecipeYaml(projectName), "utf8");
187
166
  await writeFile(path.join(assetsDir, "AGENTS.md"), makeAgentsDoc(projectName), "utf8");
188
167
  await writeFile(path.join(assetsDir, "IDENTITY.md"), makeIdentityDoc(projectName), "utf8");
189
168
  await writeFile(path.join(assetsDir, "SOUL.md"), makeSoulDoc(), "utf8");
package/dist/schema.d.ts CHANGED
@@ -240,12 +240,15 @@ export declare const recipeSchema: z.ZodObject<{
240
240
  workspaces: z.ZodOptional<z.ZodArray<z.ZodObject<{
241
241
  name: z.ZodString;
242
242
  path: z.ZodOptional<z.ZodString>;
243
+ assets: z.ZodOptional<z.ZodString>;
243
244
  }, "strict", z.ZodTypeAny, {
244
245
  name: string;
245
246
  path?: string | undefined;
247
+ assets?: string | undefined;
246
248
  }, {
247
249
  name: string;
248
250
  path?: string | undefined;
251
+ assets?: string | undefined;
249
252
  }>, "many">>;
250
253
  channels: z.ZodOptional<z.ZodArray<z.ZodObject<{
251
254
  channel: z.ZodString;
@@ -479,6 +482,7 @@ export declare const recipeSchema: z.ZodObject<{
479
482
  workspaces?: {
480
483
  name: string;
481
484
  path?: string | undefined;
485
+ assets?: string | undefined;
482
486
  }[] | undefined;
483
487
  channels?: {
484
488
  channel: string;
@@ -586,6 +590,7 @@ export declare const recipeSchema: z.ZodObject<{
586
590
  workspaces?: {
587
591
  name: string;
588
592
  path?: string | undefined;
593
+ assets?: string | undefined;
589
594
  }[] | undefined;
590
595
  channels?: {
591
596
  channel: string;
package/dist/schema.js CHANGED
@@ -64,6 +64,7 @@ const workspaceSchema = z
64
64
  .object({
65
65
  name: z.string().min(1),
66
66
  path: z.string().min(1).optional(),
67
+ assets: z.string().min(1).optional(),
67
68
  })
68
69
  .strict();
69
70
  const channelSchema = z
package/dist/types.d.ts CHANGED
@@ -66,6 +66,7 @@ export interface OpenClawSection {
66
66
  export interface WorkspaceDef {
67
67
  name: string;
68
68
  path?: string;
69
+ assets?: string;
69
70
  }
70
71
  export interface ChannelDef {
71
72
  channel: string;
@@ -134,6 +135,7 @@ export interface RunOptions {
134
135
  allowMissing: boolean;
135
136
  verbose: boolean;
136
137
  silent: boolean;
138
+ keepOpenClawState: boolean;
137
139
  provider: OpenClawProvider;
138
140
  remote: Partial<OpenClawRemoteConfig>;
139
141
  }
package/package.json CHANGED
@@ -1,7 +1,15 @@
1
1
  {
2
2
  "name": "clawchef",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Recipe-driven OpenClaw environment orchestrator",
5
+ "homepage": "https://renorzr.github.io/clawchef",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/renorzr/clawchef.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/renorzr/clawchef/issues"
12
+ },
5
13
  "type": "module",
6
14
  "main": "dist/api.js",
7
15
  "types": "dist/api.d.ts",
package/src/api.ts CHANGED
@@ -30,6 +30,7 @@ function normalizeCookOptions(options: CookOptions): RunOptions {
30
30
  allowMissing: Boolean(options.allowMissing),
31
31
  verbose: Boolean(options.verbose),
32
32
  silent: options.silent ?? true,
33
+ keepOpenClawState: false,
33
34
  provider: options.provider ?? "command",
34
35
  remote: options.remote ?? {},
35
36
  };
package/src/cli.ts CHANGED
@@ -87,6 +87,7 @@ export function buildCli(): Command {
87
87
  .option("--allow-missing", "Allow unresolved template variables", false)
88
88
  .option("--verbose", "Verbose logging", false)
89
89
  .option("-s, --silent", "Skip reset confirmation prompt", false)
90
+ .option("--keep-openclaw-state", "Preserve existing OpenClaw state (skip factory reset)", false)
90
91
  .option("--provider <provider>", "Execution provider: command | remote | mock")
91
92
  .option("--plugin <npm-spec>", "Preinstall plugin package (repeatable)", (v, p: string[]) => p.concat([v]), [])
92
93
  .option("--remote-base-url <url>", "Remote OpenClaw API base URL")
@@ -104,6 +105,7 @@ export function buildCli(): Command {
104
105
  allowMissing: Boolean(opts.allowMissing),
105
106
  verbose: Boolean(opts.verbose),
106
107
  silent: Boolean(opts.silent),
108
+ keepOpenClawState: Boolean(opts.keepOpenclawState),
107
109
  provider,
108
110
  remote: {
109
111
  base_url: opts.remoteBaseUrl ?? readEnv("CLAWCHEF_REMOTE_BASE_URL"),
@@ -273,7 +273,12 @@ function bootstrapRuntimeEnv(bootstrap: OpenClawBootstrap | undefined): Record<s
273
273
  export class CommandOpenClawProvider implements OpenClawProvider {
274
274
  private readonly stagedMessages = new Map<string, StagedMessage[]>();
275
275
 
276
- async ensureVersion(config: OpenClawSection, dryRun: boolean, silent: boolean): Promise<EnsureVersionResult> {
276
+ async ensureVersion(
277
+ config: OpenClawSection,
278
+ dryRun: boolean,
279
+ silent: boolean,
280
+ keepOpenClawState: boolean,
281
+ ): Promise<EnsureVersionResult> {
277
282
  const bin = config.bin ?? "openclaw";
278
283
  const installPolicy = config.install ?? "auto";
279
284
  const useCmd = commandFor(config, "use_version", { bin, version: config.version });
@@ -338,6 +343,10 @@ export class CommandOpenClawProvider implements OpenClawProvider {
338
343
  );
339
344
  }
340
345
 
346
+ if (keepOpenClawState) {
347
+ return { installedThisRun: false };
348
+ }
349
+
341
350
  const choice = await chooseVersionMismatchAction(currentVersion, config.version, silent);
342
351
 
343
352
  if (choice === "ignore") {
@@ -21,7 +21,12 @@ export class MockOpenClawProvider implements OpenClawProvider {
21
21
  messages: new Map(),
22
22
  };
23
23
 
24
- async ensureVersion(config: OpenClawSection, _dryRun: boolean, _silent: boolean): Promise<EnsureVersionResult> {
24
+ async ensureVersion(
25
+ config: OpenClawSection,
26
+ _dryRun: boolean,
27
+ _silent: boolean,
28
+ _keepOpenClawState: boolean,
29
+ ): Promise<EnsureVersionResult> {
25
30
  const policy = config.install ?? "auto";
26
31
  const installed = this.state.installedVersions.has(config.version);
27
32
  let installedThisRun = false;
@@ -7,7 +7,12 @@ export interface EnsureVersionResult {
7
7
  }
8
8
 
9
9
  export interface OpenClawProvider {
10
- ensureVersion(config: OpenClawSection, dryRun: boolean, silent: boolean): Promise<EnsureVersionResult>;
10
+ ensureVersion(
11
+ config: OpenClawSection,
12
+ dryRun: boolean,
13
+ silent: boolean,
14
+ keepOpenClawState: boolean,
15
+ ): Promise<EnsureVersionResult>;
11
16
  installPlugin(config: OpenClawSection, pluginSpec: string, dryRun: boolean): Promise<void>;
12
17
  factoryReset(config: OpenClawSection, dryRun: boolean): Promise<void>;
13
18
  startGateway(config: OpenClawSection, dryRun: boolean): Promise<void>;
@@ -141,7 +141,12 @@ export class RemoteOpenClawProvider implements OpenClawProvider {
141
141
  }
142
142
  }
143
143
 
144
- async ensureVersion(config: OpenClawSection, dryRun: boolean, _silent: boolean): Promise<EnsureVersionResult> {
144
+ async ensureVersion(
145
+ config: OpenClawSection,
146
+ dryRun: boolean,
147
+ _silent: boolean,
148
+ _keepOpenClawState: boolean,
149
+ ): Promise<EnsureVersionResult> {
145
150
  const result = await this.perform(
146
151
  config,
147
152
  "ensure_version",
@@ -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";
@@ -37,7 +37,9 @@ function resolveWorkspacePath(recipeOrigin: RecipeOrigin, name: string, configur
37
37
  }
38
38
  return path.resolve(configuredPath);
39
39
  }
40
- return path.join(homedir(), ".openclaw", "workspaces", name);
40
+ const trimmedName = name.trim() || name;
41
+ const workspaceName = trimmedName.startsWith("workspace-") ? trimmedName : `workspace-${trimmedName}`;
42
+ return path.join(homedir(), ".openclaw", workspaceName);
41
43
  }
42
44
 
43
45
  function isHttpUrl(value: string): boolean {
@@ -87,6 +89,35 @@ async function readBinaryFromRef(recipeOrigin: RecipeOrigin, reference: string):
87
89
  return Buffer.from(bytes);
88
90
  }
89
91
 
92
+ interface LocalAssetFile {
93
+ absolutePath: string;
94
+ relativePath: string;
95
+ }
96
+
97
+ async function collectLocalAssetFiles(rootDir: string, relDir = ""): Promise<LocalAssetFile[]> {
98
+ const currentDir = relDir ? path.join(rootDir, relDir) : rootDir;
99
+ const entries = await readdir(currentDir, { withFileTypes: true });
100
+ const out: LocalAssetFile[] = [];
101
+
102
+ for (const entry of entries) {
103
+ const nextRel = relDir ? path.join(relDir, entry.name) : entry.name;
104
+ if (entry.isDirectory()) {
105
+ out.push(...await collectLocalAssetFiles(rootDir, nextRel));
106
+ continue;
107
+ }
108
+ if (entry.isFile()) {
109
+ out.push({
110
+ absolutePath: path.join(rootDir, nextRel),
111
+ relativePath: nextRel,
112
+ });
113
+ continue;
114
+ }
115
+ throw new ClawChefError(`Unsupported entry in assets directory: ${path.join(rootDir, nextRel)}`);
116
+ }
117
+
118
+ return out;
119
+ }
120
+
90
121
  async function confirmFactoryReset(options: RunOptions): Promise<boolean> {
91
122
  if (options.silent || options.dryRun) {
92
123
  return true;
@@ -117,11 +148,18 @@ export async function runRecipe(
117
148
  const workspacePaths = new Map<string, string>();
118
149
 
119
150
  logger.info(`Running recipe: ${recipe.name}`);
120
- const versionResult = await provider.ensureVersion(recipe.openclaw, options.dryRun, options.silent);
151
+ const versionResult = await provider.ensureVersion(
152
+ recipe.openclaw,
153
+ options.dryRun,
154
+ options.silent,
155
+ options.keepOpenClawState,
156
+ );
121
157
  logger.info(`OpenClaw version ready: ${recipe.openclaw.version}`);
122
158
 
123
159
  if (versionResult.installedThisRun) {
124
160
  logger.info("OpenClaw was installed in this run; skipping factory reset");
161
+ } else if (options.keepOpenClawState) {
162
+ logger.info("Keeping existing OpenClaw state; skipping factory reset");
125
163
  } else {
126
164
  const confirmed = await confirmFactoryReset(options);
127
165
  if (!confirmed) {
@@ -146,6 +184,50 @@ export async function runRecipe(
146
184
  }
147
185
  await provider.createWorkspace(recipe.openclaw, { ...ws, path: absPath }, options.dryRun);
148
186
  logger.info(`Workspace created: ${ws.name}`);
187
+
188
+ if (!ws.assets?.trim()) {
189
+ continue;
190
+ }
191
+
192
+ const resolvedAssets = resolveFileRef(recipeOrigin, ws.assets);
193
+ if (resolvedAssets.kind !== "local") {
194
+ throw new ClawChefError(
195
+ `Workspace assets must resolve to a local directory: ${ws.assets}. Direct URL recipes cannot use workspaces[].assets.`,
196
+ );
197
+ }
198
+
199
+ let assetDirStat;
200
+ try {
201
+ assetDirStat = await stat(resolvedAssets.value);
202
+ } catch (err) {
203
+ const message = err instanceof Error ? err.message : String(err);
204
+ throw new ClawChefError(`Workspace assets path is not accessible: ${resolvedAssets.value} (${message})`);
205
+ }
206
+ if (!assetDirStat.isDirectory()) {
207
+ throw new ClawChefError(`Workspace assets must be a directory: ${resolvedAssets.value}`);
208
+ }
209
+
210
+ const assetFiles = await collectLocalAssetFiles(resolvedAssets.value);
211
+ for (const assetFile of assetFiles) {
212
+ if (provider.materializeFile) {
213
+ const content = await readFile(assetFile.absolutePath, "utf8");
214
+ await provider.materializeFile(
215
+ recipe.openclaw,
216
+ ws.name,
217
+ assetFile.relativePath,
218
+ content,
219
+ true,
220
+ options.dryRun,
221
+ );
222
+ } else {
223
+ const target = path.resolve(absPath, assetFile.relativePath);
224
+ if (!options.dryRun) {
225
+ await mkdir(path.dirname(target), { recursive: true });
226
+ await copyFile(assetFile.absolutePath, target);
227
+ }
228
+ }
229
+ logger.info(`Workspace asset copied: ${ws.name}/${assetFile.relativePath}`);
230
+ }
149
231
  }
150
232
 
151
233
  for (const agent of recipe.agents ?? []) {
package/src/recipe.ts CHANGED
@@ -169,6 +169,11 @@ function collectVars(recipe: Recipe, cliVars: Record<string, string>): Record<st
169
169
 
170
170
  function semanticValidate(recipe: Recipe): void {
171
171
  const ws = new Set((recipe.workspaces ?? []).map((w) => w.name));
172
+ for (const workspace of recipe.workspaces ?? []) {
173
+ if (workspace.assets !== undefined && !workspace.assets.trim()) {
174
+ throw new ClawChefError(`Workspace ${workspace.name} has empty assets path`);
175
+ }
176
+ }
172
177
  for (const agent of recipe.agents ?? []) {
173
178
  if (!ws.has(agent.workspace)) {
174
179
  throw new ClawChefError(`Agent ${agent.name} references missing workspace: ${agent.workspace}`);
package/src/scaffold.ts CHANGED
@@ -89,38 +89,13 @@ openclaw:
89
89
 
90
90
  workspaces:
91
91
  - name: "\${workspace_name}"
92
+ assets: "./assets"
92
93
 
93
94
  agents:
94
95
  - workspace: "\${workspace_name}"
95
96
  name: "\${agent_name}"
96
97
  model: "\${agent_model}"
97
98
 
98
- files:
99
- - workspace: "\${workspace_name}"
100
- path: "AGENTS.md"
101
- overwrite: true
102
- content_from: "./${projectName}-assets/AGENTS.md"
103
-
104
- - workspace: "\${workspace_name}"
105
- path: "IDENTITY.md"
106
- overwrite: true
107
- content_from: "./${projectName}-assets/IDENTITY.md"
108
-
109
- - workspace: "\${workspace_name}"
110
- path: "SOUL.md"
111
- overwrite: true
112
- content_from: "./${projectName}-assets/SOUL.md"
113
-
114
- - workspace: "\${workspace_name}"
115
- path: "TOOLS.md"
116
- overwrite: true
117
- content_from: "./${projectName}-assets/TOOLS.md"
118
-
119
- - workspace: "\${workspace_name}"
120
- path: "scripts/scheduling.mjs"
121
- overwrite: true
122
- content_from: "./${projectName}-assets/scripts/scheduling.mjs"
123
-
124
99
  channels:
125
100
  - channel: "telegram-mock"
126
101
  account: "default"
@@ -178,11 +153,17 @@ console.log("[scheduling] " + message);
178
153
  function makeRecipeSmokeTest(): string {
179
154
  return `import test from "node:test";
180
155
  import assert from "node:assert/strict";
156
+ import path from "node:path";
157
+ import { fileURLToPath } from "node:url";
181
158
  import { access } from "node:fs/promises";
182
159
 
160
+ const testDir = path.dirname(fileURLToPath(import.meta.url));
161
+ const projectRoot = path.resolve(testDir, "..");
162
+
183
163
  test("recipe scaffold files exist", async () => {
184
- await access("src/recipe.yaml");
185
- await access("package.json");
164
+ await access(path.join(projectRoot, "recipe.yaml"));
165
+ await access(path.join(projectRoot, "assets", "AGENTS.md"));
166
+ await access(path.join(projectRoot, "package.json"));
186
167
  assert.ok(true);
187
168
  });
188
169
  `;
@@ -196,18 +177,16 @@ export async function scaffoldProject(targetDirArg?: string, options: ScaffoldOp
196
177
  const rawProjectName = options.projectName?.trim() || defaultName;
197
178
  const projectName = normalizeProjectName(rawProjectName);
198
179
 
199
- const srcDir = path.join(targetDir, "src");
200
- const assetsDir = path.join(srcDir, `${projectName}-assets`);
180
+ const assetsDir = path.join(targetDir, "assets");
201
181
  const assetsScriptsDir = path.join(assetsDir, "scripts");
202
182
  const testDir = path.join(targetDir, "test");
203
183
 
204
- await mkdir(srcDir, { recursive: true });
205
184
  await mkdir(assetsDir, { recursive: true });
206
185
  await mkdir(assetsScriptsDir, { recursive: true });
207
186
  await mkdir(testDir, { recursive: true });
208
187
 
209
188
  await writeFile(path.join(targetDir, "package.json"), makePackageJson(projectName), "utf8");
210
- await writeFile(path.join(srcDir, "recipe.yaml"), makeRecipeYaml(projectName), "utf8");
189
+ await writeFile(path.join(targetDir, "recipe.yaml"), makeRecipeYaml(projectName), "utf8");
211
190
  await writeFile(path.join(assetsDir, "AGENTS.md"), makeAgentsDoc(projectName), "utf8");
212
191
  await writeFile(path.join(assetsDir, "IDENTITY.md"), makeIdentityDoc(projectName), "utf8");
213
192
  await writeFile(path.join(assetsDir, "SOUL.md"), makeSoulDoc(), "utf8");
package/src/schema.ts CHANGED
@@ -69,6 +69,7 @@ const workspaceSchema = z
69
69
  .object({
70
70
  name: z.string().min(1),
71
71
  path: z.string().min(1).optional(),
72
+ assets: z.string().min(1).optional(),
72
73
  })
73
74
  .strict();
74
75
 
package/src/types.ts CHANGED
@@ -72,6 +72,7 @@ export interface OpenClawSection {
72
72
  export interface WorkspaceDef {
73
73
  name: string;
74
74
  path?: string;
75
+ assets?: string;
75
76
  }
76
77
 
77
78
  export interface ChannelDef {
@@ -148,6 +149,7 @@ export interface RunOptions {
148
149
  allowMissing: boolean;
149
150
  verbose: boolean;
150
151
  silent: boolean;
152
+ keepOpenClawState: boolean;
151
153
  provider: OpenClawProvider;
152
154
  remote: Partial<OpenClawRemoteConfig>;
153
155
  }