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 +44 -57
- package/dist/api.js +1 -0
- package/dist/cli.js +2 -0
- package/dist/openclaw/command-provider.d.ts +1 -1
- package/dist/openclaw/command-provider.js +4 -1
- package/dist/openclaw/mock-provider.d.ts +1 -1
- package/dist/openclaw/mock-provider.js +1 -1
- package/dist/openclaw/provider.d.ts +1 -1
- package/dist/openclaw/remote-provider.d.ts +1 -1
- package/dist/openclaw/remote-provider.js +1 -1
- package/dist/orchestrator.js +62 -3
- package/dist/recipe.js +5 -0
- package/dist/scaffold.js +11 -32
- package/dist/schema.d.ts +5 -0
- package/dist/schema.js +1 -0
- package/dist/types.d.ts +2 -0
- package/package.json +9 -1
- package/src/api.ts +1 -0
- package/src/cli.ts +2 -0
- package/src/openclaw/command-provider.ts +10 -1
- package/src/openclaw/mock-provider.ts +6 -1
- package/src/openclaw/provider.ts +6 -1
- package/src/openclaw/remote-provider.ts +6 -1
- package/src/orchestrator.ts +85 -3
- package/src/recipe.ts +5 -0
- package/src/scaffold.ts +11 -32
- package/src/schema.ts +1 -0
- package/src/types.ts +2 -0
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/
|
|
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
|
|
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
|
|
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
|
|
50
|
-
clawchef cook https://example.com/recipes-pack.zip:team/recipe-prod.yaml --provider remote
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
- `
|
|
142
|
-
- `
|
|
143
|
-
- `
|
|
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`, `
|
|
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/
|
|
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/
|
|
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);
|
package/dist/orchestrator.js
CHANGED
|
@@ -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
|
-
|
|
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("
|
|
166
|
-
await access("
|
|
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
|
|
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(
|
|
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
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.
|
|
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(
|
|
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(
|
|
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;
|
package/src/openclaw/provider.ts
CHANGED
|
@@ -7,7 +7,12 @@ export interface EnsureVersionResult {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export interface OpenClawProvider {
|
|
10
|
-
ensureVersion(
|
|
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(
|
|
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",
|
package/src/orchestrator.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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("
|
|
185
|
-
await access("
|
|
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
|
|
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(
|
|
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
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
|
}
|