clawchef 0.1.2 → 0.1.4

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
@@ -19,6 +19,7 @@ Recipe-driven OpenClaw environment orchestrator.
19
19
  - Materializes files into target workspaces.
20
20
  - Installs skills.
21
21
  - Supports plugin preinstall via `openclaw.plugins[]` and runtime `--plugin` flags.
22
+ - Supports preserving existing OpenClaw state via `--keep-openclaw-state` (skip factory reset).
22
23
  - Configures channels with `openclaw channels add`.
23
24
  - Supports interactive channel login at the end of execution (`channels[].login: true`) for channels that expose login.
24
25
  - Supports remote HTTP orchestration via runtime flags (`--provider remote`) when OpenClaw is reachable via API.
@@ -38,6 +39,12 @@ Run recipe from URL:
38
39
  clawchef cook https://example.com/recipes/sample.yaml --provider remote
39
40
  ```
40
41
 
42
+ Run recipe from GitHub repository root (`recipe.yaml` at repo root):
43
+
44
+ ```bash
45
+ clawchef cook https://github.com/renorzr/loom-plus-recipe
46
+ ```
47
+
41
48
  Run recipe from archive (default `recipe.yaml`):
42
49
 
43
50
  ```bash
@@ -78,6 +85,12 @@ clawchef cook recipes/sample.yaml -s
78
85
  Warning: `-s/--silent` suppresses the factory-reset confirmation and auto-chooses force reinstall on version mismatch.
79
86
  Use it only in CI/non-interactive flows where destructive reset behavior is expected.
80
87
 
88
+ Keep existing OpenClaw state (skip reset and keep current version on mismatch):
89
+
90
+ ```bash
91
+ clawchef cook recipes/sample.yaml --keep-openclaw-state
92
+ ```
93
+
81
94
  From-zero OpenClaw bootstrap (recommended):
82
95
 
83
96
  ```bash
@@ -142,9 +155,9 @@ clawchef scaffold ./my-recipe-project --name meetingbot
142
155
  Scaffold output:
143
156
 
144
157
  - `package.json` with `telegram-api-mock-server` in `devDependencies`
145
- - `src/recipe.yaml` with `telegram-mock` channel, plugin preinstall, and `workspaces[].assets`
146
- - `src/<project-name>-assets/{AGENTS.md,IDENTITY.md,SOUL.md,TOOLS.md}`
147
- - `src/<project-name>-assets/scripts/scheduling.mjs`
158
+ - `recipe.yaml` with `telegram-mock` channel, plugin preinstall, and `workspaces[].assets`
159
+ - `assets/{AGENTS.md,IDENTITY.md,SOUL.md,TOOLS.md}`
160
+ - `assets/scripts/scheduling.mjs`
148
161
  - `test/recipe-smoke.test.mjs`
149
162
 
150
163
  By default scaffold only writes files; it does not run `npm install`.
@@ -188,7 +201,7 @@ Notes:
188
201
 
189
202
  - `validate()` throws on invalid recipe.
190
203
  - `cook()` throws on runtime/configuration errors.
191
- - `scaffold()` creates `package.json`, `src/recipe.yaml`, `src/<project-name>-assets`, and `test/`.
204
+ - `scaffold()` creates `package.json`, `recipe.yaml`, `assets/`, and `test/`.
192
205
 
193
206
  ## Variable precedence
194
207
 
@@ -212,6 +225,7 @@ If `.env` exists in the directory where `clawchef` is executed, it is loaded bef
212
225
  - `https://host/recipe.yaml`
213
226
  - `https://host/archive.zip` (loads `recipe.yaml` from archive)
214
227
  - `https://host/archive.zip:custom/recipe.yaml`
228
+ - `https://github.com/<owner>/<repo>` (loads `recipe.yaml` from repo root)
215
229
 
216
230
  Supported archives: `.zip`, `.tar`, `.tar.gz`, `.tgz`.
217
231
 
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);
@@ -122,11 +122,14 @@ export async function runRecipe(recipe, recipeOrigin, options, logger) {
122
122
  const remoteMode = options.provider === "remote";
123
123
  const workspacePaths = new Map();
124
124
  logger.info(`Running recipe: ${recipe.name}`);
125
- 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);
126
126
  logger.info(`OpenClaw version ready: ${recipe.openclaw.version}`);
127
127
  if (versionResult.installedThisRun) {
128
128
  logger.info("OpenClaw was installed in this run; skipping factory reset");
129
129
  }
130
+ else if (options.keepOpenClawState) {
131
+ logger.info("Keeping existing OpenClaw state; skipping factory reset");
132
+ }
130
133
  else {
131
134
  const confirmed = await confirmFactoryReset(options);
132
135
  if (!confirmed) {
package/dist/recipe.js CHANGED
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import process from "node:process";
3
3
  import { tmpdir } from "node:os";
4
4
  import { spawn } from "node:child_process";
5
- import { readFile, mkdtemp, stat, writeFile, rm, mkdir } from "node:fs/promises";
5
+ import { readFile, mkdtemp, stat, writeFile, rm, mkdir, readdir } from "node:fs/promises";
6
6
  import YAML from "js-yaml";
7
7
  import { recipeSchema } from "./schema.js";
8
8
  import { ClawChefError } from "./errors.js";
@@ -245,6 +245,128 @@ function parseUrlReference(input) {
245
245
  directUrl: parsed.toString(),
246
246
  };
247
247
  }
248
+ function parseGitHubRepoReference(input) {
249
+ let parsed;
250
+ try {
251
+ parsed = new URL(input);
252
+ }
253
+ catch {
254
+ return undefined;
255
+ }
256
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
257
+ return undefined;
258
+ }
259
+ if (parsed.hostname.toLowerCase() !== "github.com") {
260
+ return undefined;
261
+ }
262
+ if (parsed.search || parsed.hash) {
263
+ return undefined;
264
+ }
265
+ const parts = parsed.pathname.split("/").filter((part) => part.length > 0);
266
+ if (parts.length !== 2) {
267
+ return undefined;
268
+ }
269
+ const owner = parts[0];
270
+ const repo = parts[1].endsWith(".git") ? parts[1].slice(0, -4) : parts[1];
271
+ if (!owner || !repo) {
272
+ return undefined;
273
+ }
274
+ return { owner, repo };
275
+ }
276
+ async function resolveRecipePathFromExtracted(extractDir, recipeInArchive, missingMessage) {
277
+ const directPath = path.join(extractDir, recipeInArchive);
278
+ try {
279
+ const directStat = await stat(directPath);
280
+ if (directStat.isFile()) {
281
+ return directPath;
282
+ }
283
+ throw new ClawChefError(`Recipe in archive is not a file: ${recipeInArchive}`);
284
+ }
285
+ catch (err) {
286
+ if (err instanceof ClawChefError) {
287
+ throw err;
288
+ }
289
+ }
290
+ const entries = await readdir(extractDir, { withFileTypes: true });
291
+ const matched = [];
292
+ for (const entry of entries) {
293
+ if (!entry.isDirectory()) {
294
+ continue;
295
+ }
296
+ const candidate = path.join(extractDir, entry.name, recipeInArchive);
297
+ try {
298
+ const s = await stat(candidate);
299
+ if (s.isFile()) {
300
+ matched.push(candidate);
301
+ }
302
+ }
303
+ catch {
304
+ continue;
305
+ }
306
+ }
307
+ if (matched.length === 1) {
308
+ return matched[0];
309
+ }
310
+ if (matched.length > 1) {
311
+ throw new ClawChefError(`Multiple recipe files found in extracted archive for ${recipeInArchive}; provide explicit selector`);
312
+ }
313
+ throw new ClawChefError(missingMessage);
314
+ }
315
+ async function resolveGitHubRepoReference(recipeInput) {
316
+ const repoRef = parseGitHubRepoReference(recipeInput);
317
+ if (!repoRef) {
318
+ return undefined;
319
+ }
320
+ const repoApiUrl = `https://api.github.com/repos/${repoRef.owner}/${repoRef.repo}`;
321
+ const repoHeaders = {
322
+ Accept: "application/vnd.github+json",
323
+ "User-Agent": "clawchef",
324
+ };
325
+ let repoResponse;
326
+ try {
327
+ repoResponse = await fetch(repoApiUrl, { headers: repoHeaders });
328
+ }
329
+ catch (err) {
330
+ const message = err instanceof Error ? err.message : String(err);
331
+ throw new ClawChefError(`Failed to fetch GitHub repository metadata ${repoApiUrl}: ${message}`);
332
+ }
333
+ if (!repoResponse.ok) {
334
+ throw new ClawChefError(`Failed to fetch GitHub repository metadata ${repoApiUrl}: HTTP ${repoResponse.status}`);
335
+ }
336
+ const repoJson = (await repoResponse.json());
337
+ const defaultBranch = repoJson.default_branch?.trim();
338
+ if (!defaultBranch) {
339
+ throw new ClawChefError(`GitHub repository ${repoRef.owner}/${repoRef.repo} has no default branch`);
340
+ }
341
+ const tarballUrl = `https://api.github.com/repos/${repoRef.owner}/${repoRef.repo}/tarball/${encodeURIComponent(defaultBranch)}`;
342
+ let tarballResponse;
343
+ try {
344
+ tarballResponse = await fetch(tarballUrl, { headers: repoHeaders });
345
+ }
346
+ catch (err) {
347
+ const message = err instanceof Error ? err.message : String(err);
348
+ throw new ClawChefError(`Failed to download GitHub repository tarball ${tarballUrl}: ${message}`);
349
+ }
350
+ if (!tarballResponse.ok) {
351
+ throw new ClawChefError(`Failed to download GitHub repository tarball ${tarballUrl}: HTTP ${tarballResponse.status}`);
352
+ }
353
+ const tempDir = await mkdtemp(path.join(tmpdir(), "clawchef-recipe-"));
354
+ const archivePath = path.join(tempDir, "repo.tar.gz");
355
+ await writeFile(archivePath, Buffer.from(await tarballResponse.arrayBuffer()));
356
+ const extractDir = path.join(tempDir, "extracted");
357
+ await mkdir(extractDir, { recursive: true });
358
+ await extractArchive(archivePath, extractDir);
359
+ const recipePath = await resolveRecipePathFromExtracted(extractDir, DEFAULT_RECIPE_FILE, `Recipe file not found in GitHub repository root: ${DEFAULT_RECIPE_FILE}`);
360
+ return {
361
+ recipePath,
362
+ origin: {
363
+ kind: "local",
364
+ recipePath,
365
+ recipeDir: path.dirname(recipePath),
366
+ },
367
+ cleanupPaths: [tempDir],
368
+ };
369
+ }
248
370
  async function runCommand(command, args) {
249
371
  await new Promise((resolve, reject) => {
250
372
  const child = spawn(command, args, {
@@ -287,6 +409,10 @@ async function extractArchive(archivePath, extractDir) {
287
409
  }
288
410
  async function resolveRecipeReference(recipeInput) {
289
411
  if (isHttpUrl(recipeInput)) {
412
+ const githubResolved = await resolveGitHubRepoReference(recipeInput);
413
+ if (githubResolved) {
414
+ return githubResolved;
415
+ }
290
416
  const parsed = parseUrlReference(recipeInput);
291
417
  if (parsed.directUrl) {
292
418
  let response;
@@ -334,16 +460,7 @@ async function resolveRecipeReference(recipeInput) {
334
460
  await rm(extractDir, { recursive: true, force: true });
335
461
  await mkdir(extractDir, { recursive: true });
336
462
  await extractArchive(downloadedArchivePath, extractDir);
337
- const recipePath = path.join(extractDir, recipeInArchive);
338
- try {
339
- const s = await stat(recipePath);
340
- if (!s.isFile()) {
341
- throw new ClawChefError(`Recipe in archive is not a file: ${recipeInArchive}`);
342
- }
343
- }
344
- catch {
345
- throw new ClawChefError(`Recipe file not found in archive: ${recipeInArchive}`);
346
- }
463
+ const recipePath = await resolveRecipePathFromExtracted(extractDir, recipeInArchive, `Recipe file not found in archive: ${recipeInArchive}`);
347
464
  return {
348
465
  recipePath,
349
466
  origin: {
@@ -381,16 +498,7 @@ async function resolveRecipeReference(recipeInput) {
381
498
  const extractDir = path.join(tempDir, "extracted");
382
499
  await mkdir(extractDir, { recursive: true });
383
500
  await extractArchive(localPath, extractDir);
384
- const recipePath = path.join(extractDir, DEFAULT_RECIPE_FILE);
385
- try {
386
- const s = await stat(recipePath);
387
- if (!s.isFile()) {
388
- throw new ClawChefError(`Recipe in archive is not a file: ${DEFAULT_RECIPE_FILE}`);
389
- }
390
- }
391
- catch {
392
- throw new ClawChefError(`Recipe file not found in archive: ${DEFAULT_RECIPE_FILE}`);
393
- }
501
+ const recipePath = await resolveRecipePathFromExtracted(extractDir, DEFAULT_RECIPE_FILE, `Recipe file not found in archive: ${DEFAULT_RECIPE_FILE}`);
394
502
  return {
395
503
  recipePath,
396
504
  origin: {
@@ -445,16 +553,7 @@ async function resolveRecipeReference(recipeInput) {
445
553
  const extractDir = path.join(tempDir, "extracted");
446
554
  await mkdir(extractDir, { recursive: true });
447
555
  await extractArchive(basePath, extractDir);
448
- const recipePath = path.join(extractDir, selector);
449
- try {
450
- const s = await stat(recipePath);
451
- if (!s.isFile()) {
452
- throw new ClawChefError(`Recipe in archive is not a file: ${selector}`);
453
- }
454
- }
455
- catch {
456
- throw new ClawChefError(`Recipe file not found in archive: ${selector}`);
457
- }
556
+ const recipePath = await resolveRecipePathFromExtracted(extractDir, selector, `Recipe file not found in archive: ${selector}`);
458
557
  return {
459
558
  recipePath,
460
559
  origin: {
package/dist/scaffold.js CHANGED
@@ -76,7 +76,7 @@ openclaw:
76
76
 
77
77
  workspaces:
78
78
  - name: "\${workspace_name}"
79
- assets: "./${projectName}-assets"
79
+ assets: "./assets"
80
80
 
81
81
  agents:
82
82
  - workspace: "\${workspace_name}"
@@ -134,11 +134,17 @@ console.log("[scheduling] " + message);
134
134
  function makeRecipeSmokeTest() {
135
135
  return `import test from "node:test";
136
136
  import assert from "node:assert/strict";
137
+ import path from "node:path";
138
+ import { fileURLToPath } from "node:url";
137
139
  import { access } from "node:fs/promises";
138
140
 
141
+ const testDir = path.dirname(fileURLToPath(import.meta.url));
142
+ const projectRoot = path.resolve(testDir, "..");
143
+
139
144
  test("recipe scaffold files exist", async () => {
140
- await access("src/recipe.yaml");
141
- 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"));
142
148
  assert.ok(true);
143
149
  });
144
150
  `;
@@ -149,16 +155,14 @@ export async function scaffoldProject(targetDirArg, options = {}) {
149
155
  const defaultName = path.basename(targetDir);
150
156
  const rawProjectName = options.projectName?.trim() || defaultName;
151
157
  const projectName = normalizeProjectName(rawProjectName);
152
- const srcDir = path.join(targetDir, "src");
153
- const assetsDir = path.join(srcDir, `${projectName}-assets`);
158
+ const assetsDir = path.join(targetDir, "assets");
154
159
  const assetsScriptsDir = path.join(assetsDir, "scripts");
155
160
  const testDir = path.join(targetDir, "test");
156
- await mkdir(srcDir, { recursive: true });
157
161
  await mkdir(assetsDir, { recursive: true });
158
162
  await mkdir(assetsScriptsDir, { recursive: true });
159
163
  await mkdir(testDir, { recursive: true });
160
164
  await writeFile(path.join(targetDir, "package.json"), makePackageJson(projectName), "utf8");
161
- await writeFile(path.join(srcDir, "recipe.yaml"), makeRecipeYaml(projectName), "utf8");
165
+ await writeFile(path.join(targetDir, "recipe.yaml"), makeRecipeYaml(projectName), "utf8");
162
166
  await writeFile(path.join(assetsDir, "AGENTS.md"), makeAgentsDoc(projectName), "utf8");
163
167
  await writeFile(path.join(assetsDir, "IDENTITY.md"), makeIdentityDoc(projectName), "utf8");
164
168
  await writeFile(path.join(assetsDir, "SOUL.md"), makeSoulDoc(), "utf8");
package/dist/types.d.ts CHANGED
@@ -135,6 +135,7 @@ export interface RunOptions {
135
135
  allowMissing: boolean;
136
136
  verbose: boolean;
137
137
  silent: boolean;
138
+ keepOpenClawState: boolean;
138
139
  provider: OpenClawProvider;
139
140
  remote: Partial<OpenClawRemoteConfig>;
140
141
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawchef",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Recipe-driven OpenClaw environment orchestrator",
5
5
  "homepage": "https://renorzr.github.io/clawchef",
6
6
  "repository": {
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",
@@ -148,11 +148,18 @@ export async function runRecipe(
148
148
  const workspacePaths = new Map<string, string>();
149
149
 
150
150
  logger.info(`Running recipe: ${recipe.name}`);
151
- 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
+ );
152
157
  logger.info(`OpenClaw version ready: ${recipe.openclaw.version}`);
153
158
 
154
159
  if (versionResult.installedThisRun) {
155
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");
156
163
  } else {
157
164
  const confirmed = await confirmFactoryReset(options);
158
165
  if (!confirmed) {
package/src/recipe.ts CHANGED
@@ -2,7 +2,7 @@ import path from "node:path";
2
2
  import process from "node:process";
3
3
  import { tmpdir } from "node:os";
4
4
  import { spawn } from "node:child_process";
5
- import { readFile, mkdtemp, stat, writeFile, rm, mkdir } from "node:fs/promises";
5
+ import { readFile, mkdtemp, stat, writeFile, rm, mkdir, readdir } from "node:fs/promises";
6
6
  import YAML from "js-yaml";
7
7
  import { recipeSchema } from "./schema.js";
8
8
  import { ClawChefError } from "./errors.js";
@@ -266,6 +266,11 @@ interface ResolvedRecipeReference {
266
266
  cleanupPaths: string[];
267
267
  }
268
268
 
269
+ interface GitHubRepoRef {
270
+ owner: string;
271
+ repo: string;
272
+ }
273
+
269
274
  function isHttpUrl(value: string): boolean {
270
275
  try {
271
276
  const url = new URL(value);
@@ -323,6 +328,149 @@ function parseUrlReference(input: string): { archiveUrl: string; inner?: string;
323
328
  };
324
329
  }
325
330
 
331
+ function parseGitHubRepoReference(input: string): GitHubRepoRef | undefined {
332
+ let parsed: URL;
333
+ try {
334
+ parsed = new URL(input);
335
+ } catch {
336
+ return undefined;
337
+ }
338
+
339
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
340
+ return undefined;
341
+ }
342
+ if (parsed.hostname.toLowerCase() !== "github.com") {
343
+ return undefined;
344
+ }
345
+ if (parsed.search || parsed.hash) {
346
+ return undefined;
347
+ }
348
+
349
+ const parts = parsed.pathname.split("/").filter((part) => part.length > 0);
350
+ if (parts.length !== 2) {
351
+ return undefined;
352
+ }
353
+
354
+ const owner = parts[0];
355
+ const repo = parts[1].endsWith(".git") ? parts[1].slice(0, -4) : parts[1];
356
+ if (!owner || !repo) {
357
+ return undefined;
358
+ }
359
+
360
+ return { owner, repo };
361
+ }
362
+
363
+ async function resolveRecipePathFromExtracted(
364
+ extractDir: string,
365
+ recipeInArchive: string,
366
+ missingMessage: string,
367
+ ): Promise<string> {
368
+ const directPath = path.join(extractDir, recipeInArchive);
369
+ try {
370
+ const directStat = await stat(directPath);
371
+ if (directStat.isFile()) {
372
+ return directPath;
373
+ }
374
+ throw new ClawChefError(`Recipe in archive is not a file: ${recipeInArchive}`);
375
+ } catch (err) {
376
+ if (err instanceof ClawChefError) {
377
+ throw err;
378
+ }
379
+ }
380
+
381
+ const entries = await readdir(extractDir, { withFileTypes: true });
382
+ const matched: string[] = [];
383
+ for (const entry of entries) {
384
+ if (!entry.isDirectory()) {
385
+ continue;
386
+ }
387
+ const candidate = path.join(extractDir, entry.name, recipeInArchive);
388
+ try {
389
+ const s = await stat(candidate);
390
+ if (s.isFile()) {
391
+ matched.push(candidate);
392
+ }
393
+ } catch {
394
+ continue;
395
+ }
396
+ }
397
+
398
+ if (matched.length === 1) {
399
+ return matched[0];
400
+ }
401
+ if (matched.length > 1) {
402
+ throw new ClawChefError(`Multiple recipe files found in extracted archive for ${recipeInArchive}; provide explicit selector`);
403
+ }
404
+
405
+ throw new ClawChefError(missingMessage);
406
+ }
407
+
408
+ async function resolveGitHubRepoReference(recipeInput: string): Promise<ResolvedRecipeReference | undefined> {
409
+ const repoRef = parseGitHubRepoReference(recipeInput);
410
+ if (!repoRef) {
411
+ return undefined;
412
+ }
413
+
414
+ const repoApiUrl = `https://api.github.com/repos/${repoRef.owner}/${repoRef.repo}`;
415
+ const repoHeaders = {
416
+ Accept: "application/vnd.github+json",
417
+ "User-Agent": "clawchef",
418
+ };
419
+
420
+ let repoResponse: Response;
421
+ try {
422
+ repoResponse = await fetch(repoApiUrl, { headers: repoHeaders });
423
+ } catch (err) {
424
+ const message = err instanceof Error ? err.message : String(err);
425
+ throw new ClawChefError(`Failed to fetch GitHub repository metadata ${repoApiUrl}: ${message}`);
426
+ }
427
+ if (!repoResponse.ok) {
428
+ throw new ClawChefError(`Failed to fetch GitHub repository metadata ${repoApiUrl}: HTTP ${repoResponse.status}`);
429
+ }
430
+
431
+ const repoJson = (await repoResponse.json()) as { default_branch?: string };
432
+ const defaultBranch = repoJson.default_branch?.trim();
433
+ if (!defaultBranch) {
434
+ throw new ClawChefError(`GitHub repository ${repoRef.owner}/${repoRef.repo} has no default branch`);
435
+ }
436
+
437
+ const tarballUrl = `https://api.github.com/repos/${repoRef.owner}/${repoRef.repo}/tarball/${encodeURIComponent(defaultBranch)}`;
438
+ let tarballResponse: Response;
439
+ try {
440
+ tarballResponse = await fetch(tarballUrl, { headers: repoHeaders });
441
+ } catch (err) {
442
+ const message = err instanceof Error ? err.message : String(err);
443
+ throw new ClawChefError(`Failed to download GitHub repository tarball ${tarballUrl}: ${message}`);
444
+ }
445
+ if (!tarballResponse.ok) {
446
+ throw new ClawChefError(`Failed to download GitHub repository tarball ${tarballUrl}: HTTP ${tarballResponse.status}`);
447
+ }
448
+
449
+ const tempDir = await mkdtemp(path.join(tmpdir(), "clawchef-recipe-"));
450
+ const archivePath = path.join(tempDir, "repo.tar.gz");
451
+ await writeFile(archivePath, Buffer.from(await tarballResponse.arrayBuffer()));
452
+
453
+ const extractDir = path.join(tempDir, "extracted");
454
+ await mkdir(extractDir, { recursive: true });
455
+ await extractArchive(archivePath, extractDir);
456
+
457
+ const recipePath = await resolveRecipePathFromExtracted(
458
+ extractDir,
459
+ DEFAULT_RECIPE_FILE,
460
+ `Recipe file not found in GitHub repository root: ${DEFAULT_RECIPE_FILE}`,
461
+ );
462
+
463
+ return {
464
+ recipePath,
465
+ origin: {
466
+ kind: "local",
467
+ recipePath,
468
+ recipeDir: path.dirname(recipePath),
469
+ },
470
+ cleanupPaths: [tempDir],
471
+ };
472
+ }
473
+
326
474
  async function runCommand(command: string, args: string[]): Promise<void> {
327
475
  await new Promise<void>((resolve, reject) => {
328
476
  const child = spawn(command, args, {
@@ -370,6 +518,11 @@ async function extractArchive(archivePath: string, extractDir: string): Promise<
370
518
 
371
519
  async function resolveRecipeReference(recipeInput: string): Promise<ResolvedRecipeReference> {
372
520
  if (isHttpUrl(recipeInput)) {
521
+ const githubResolved = await resolveGitHubRepoReference(recipeInput);
522
+ if (githubResolved) {
523
+ return githubResolved;
524
+ }
525
+
373
526
  const parsed = parseUrlReference(recipeInput);
374
527
  if (parsed.directUrl) {
375
528
  let response: Response;
@@ -421,15 +574,11 @@ async function resolveRecipeReference(recipeInput: string): Promise<ResolvedReci
421
574
  await mkdir(extractDir, { recursive: true });
422
575
  await extractArchive(downloadedArchivePath, extractDir);
423
576
 
424
- const recipePath = path.join(extractDir, recipeInArchive);
425
- try {
426
- const s = await stat(recipePath);
427
- if (!s.isFile()) {
428
- throw new ClawChefError(`Recipe in archive is not a file: ${recipeInArchive}`);
429
- }
430
- } catch {
431
- throw new ClawChefError(`Recipe file not found in archive: ${recipeInArchive}`);
432
- }
577
+ const recipePath = await resolveRecipePathFromExtracted(
578
+ extractDir,
579
+ recipeInArchive,
580
+ `Recipe file not found in archive: ${recipeInArchive}`,
581
+ );
433
582
 
434
583
  return {
435
584
  recipePath,
@@ -470,15 +619,11 @@ async function resolveRecipeReference(recipeInput: string): Promise<ResolvedReci
470
619
  const extractDir = path.join(tempDir, "extracted");
471
620
  await mkdir(extractDir, { recursive: true });
472
621
  await extractArchive(localPath, extractDir);
473
- const recipePath = path.join(extractDir, DEFAULT_RECIPE_FILE);
474
- try {
475
- const s = await stat(recipePath);
476
- if (!s.isFile()) {
477
- throw new ClawChefError(`Recipe in archive is not a file: ${DEFAULT_RECIPE_FILE}`);
478
- }
479
- } catch {
480
- throw new ClawChefError(`Recipe file not found in archive: ${DEFAULT_RECIPE_FILE}`);
481
- }
622
+ const recipePath = await resolveRecipePathFromExtracted(
623
+ extractDir,
624
+ DEFAULT_RECIPE_FILE,
625
+ `Recipe file not found in archive: ${DEFAULT_RECIPE_FILE}`,
626
+ );
482
627
  return {
483
628
  recipePath,
484
629
  origin: {
@@ -538,15 +683,11 @@ async function resolveRecipeReference(recipeInput: string): Promise<ResolvedReci
538
683
  const extractDir = path.join(tempDir, "extracted");
539
684
  await mkdir(extractDir, { recursive: true });
540
685
  await extractArchive(basePath, extractDir);
541
- const recipePath = path.join(extractDir, selector);
542
- try {
543
- const s = await stat(recipePath);
544
- if (!s.isFile()) {
545
- throw new ClawChefError(`Recipe in archive is not a file: ${selector}`);
546
- }
547
- } catch {
548
- throw new ClawChefError(`Recipe file not found in archive: ${selector}`);
549
- }
686
+ const recipePath = await resolveRecipePathFromExtracted(
687
+ extractDir,
688
+ selector,
689
+ `Recipe file not found in archive: ${selector}`,
690
+ );
550
691
  return {
551
692
  recipePath,
552
693
  origin: {
package/src/scaffold.ts CHANGED
@@ -89,7 +89,7 @@ openclaw:
89
89
 
90
90
  workspaces:
91
91
  - name: "\${workspace_name}"
92
- assets: "./${projectName}-assets"
92
+ assets: "./assets"
93
93
 
94
94
  agents:
95
95
  - workspace: "\${workspace_name}"
@@ -153,11 +153,17 @@ console.log("[scheduling] " + message);
153
153
  function makeRecipeSmokeTest(): string {
154
154
  return `import test from "node:test";
155
155
  import assert from "node:assert/strict";
156
+ import path from "node:path";
157
+ import { fileURLToPath } from "node:url";
156
158
  import { access } from "node:fs/promises";
157
159
 
160
+ const testDir = path.dirname(fileURLToPath(import.meta.url));
161
+ const projectRoot = path.resolve(testDir, "..");
162
+
158
163
  test("recipe scaffold files exist", async () => {
159
- await access("src/recipe.yaml");
160
- 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"));
161
167
  assert.ok(true);
162
168
  });
163
169
  `;
@@ -171,18 +177,16 @@ export async function scaffoldProject(targetDirArg?: string, options: ScaffoldOp
171
177
  const rawProjectName = options.projectName?.trim() || defaultName;
172
178
  const projectName = normalizeProjectName(rawProjectName);
173
179
 
174
- const srcDir = path.join(targetDir, "src");
175
- const assetsDir = path.join(srcDir, `${projectName}-assets`);
180
+ const assetsDir = path.join(targetDir, "assets");
176
181
  const assetsScriptsDir = path.join(assetsDir, "scripts");
177
182
  const testDir = path.join(targetDir, "test");
178
183
 
179
- await mkdir(srcDir, { recursive: true });
180
184
  await mkdir(assetsDir, { recursive: true });
181
185
  await mkdir(assetsScriptsDir, { recursive: true });
182
186
  await mkdir(testDir, { recursive: true });
183
187
 
184
188
  await writeFile(path.join(targetDir, "package.json"), makePackageJson(projectName), "utf8");
185
- await writeFile(path.join(srcDir, "recipe.yaml"), makeRecipeYaml(projectName), "utf8");
189
+ await writeFile(path.join(targetDir, "recipe.yaml"), makeRecipeYaml(projectName), "utf8");
186
190
  await writeFile(path.join(assetsDir, "AGENTS.md"), makeAgentsDoc(projectName), "utf8");
187
191
  await writeFile(path.join(assetsDir, "IDENTITY.md"), makeIdentityDoc(projectName), "utf8");
188
192
  await writeFile(path.join(assetsDir, "SOUL.md"), makeSoulDoc(), "utf8");
package/src/types.ts CHANGED
@@ -149,6 +149,7 @@ export interface RunOptions {
149
149
  allowMissing: boolean;
150
150
  verbose: boolean;
151
151
  silent: boolean;
152
+ keepOpenClawState: boolean;
152
153
  provider: OpenClawProvider;
153
154
  remote: Partial<OpenClawRemoteConfig>;
154
155
  }