clawchef 0.1.3 → 0.1.5

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
@@ -8,6 +8,7 @@ Recipe-driven OpenClaw environment orchestrator.
8
8
  - Accepts recipe input from local file/dir/archive and HTTP URL/archive.
9
9
  - Resolves `${var}` parameters from `--var`, environment, and defaults.
10
10
  - Auto-loads environment variables from `.env` in the current working directory.
11
+ - Supports loading env vars from a custom `.env` path/URL via `--env-file`.
11
12
  - Requires secrets to be injected via `--var` / `CLAWCHEF_VAR_*` (no inline secrets in recipe).
12
13
  - Prepares OpenClaw version (install or reuse).
13
14
  - When installed OpenClaw version mismatches recipe version, prompts: ignore / abort / force reinstall (silent mode auto-picks force reinstall).
@@ -39,6 +40,19 @@ Run recipe from URL:
39
40
  clawchef cook https://example.com/recipes/sample.yaml --provider remote
40
41
  ```
41
42
 
43
+ Run with custom env file (local path or HTTP URL):
44
+
45
+ ```bash
46
+ clawchef cook recipes/sample.yaml --env-file ./.env.staging
47
+ clawchef cook recipes/sample.yaml --env-file https://example.com/envs/staging.env
48
+ ```
49
+
50
+ Run recipe from GitHub repository root (`recipe.yaml` at repo root):
51
+
52
+ ```bash
53
+ clawchef cook https://github.com/renorzr/loom-plus-recipe
54
+ ```
55
+
42
56
  Run recipe from archive (default `recipe.yaml`):
43
57
 
44
58
  ```bash
@@ -184,6 +198,7 @@ await scaffold("./my-recipe-project", {
184
198
  - `plugins`: plugin npm specs to preinstall for this run (`string[]`)
185
199
  - `provider`: `command | remote | mock`
186
200
  - `remote`: remote provider config (same fields as CLI remote flags)
201
+ - `envFile`: custom env file path/URL; when set, default cwd `.env` loading is skipped
187
202
  - `dryRun`, `allowMissing`, `verbose`
188
203
  - `silent` (default: `true` in Node API)
189
204
  - `loadDotEnvFromCwd` (default: `true`)
@@ -206,6 +221,7 @@ Notes:
206
221
  If `params.<key>.required: true` and no value is found, run fails.
207
222
 
208
223
  If `.env` exists in the directory where `clawchef` is executed, it is loaded before recipe parsing.
224
+ If `--env-file` (or Node API `envFile`) is provided, only that env source is loaded.
209
225
 
210
226
  ## Recipe reference formats
211
227
 
@@ -219,6 +235,7 @@ If `.env` exists in the directory where `clawchef` is executed, it is loaded bef
219
235
  - `https://host/recipe.yaml`
220
236
  - `https://host/archive.zip` (loads `recipe.yaml` from archive)
221
237
  - `https://host/archive.zip:custom/recipe.yaml`
238
+ - `https://github.com/<owner>/<repo>` (loads `recipe.yaml` from repo root)
222
239
 
223
240
  Supported archives: `.zip`, `.tar`, `.tar.gz`, `.tgz`.
224
241
 
package/dist/api.d.ts CHANGED
@@ -9,6 +9,7 @@ export interface CookOptions {
9
9
  silent?: boolean;
10
10
  provider?: OpenClawProvider;
11
11
  remote?: Partial<OpenClawRemoteConfig>;
12
+ envFile?: string;
12
13
  loadDotEnvFromCwd?: boolean;
13
14
  }
14
15
  export declare function cook(recipeRef: string, options?: CookOptions): Promise<void>;
package/dist/api.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import YAML from "js-yaml";
2
2
  import { ClawChefError } from "./errors.js";
3
- import { importDotEnvFromCwd } from "./env.js";
3
+ import { importDotEnvFromCwd, importDotEnvFromRef } from "./env.js";
4
4
  import { Logger } from "./logger.js";
5
5
  import { runRecipe } from "./orchestrator.js";
6
6
  import { loadRecipe, loadRecipeText } from "./recipe.js";
@@ -21,7 +21,10 @@ function normalizeCookOptions(options) {
21
21
  };
22
22
  }
23
23
  export async function cook(recipeRef, options = {}) {
24
- if (options.loadDotEnvFromCwd ?? true) {
24
+ if (options.envFile) {
25
+ await importDotEnvFromRef(options.envFile);
26
+ }
27
+ else if (options.loadDotEnvFromCwd ?? true) {
25
28
  importDotEnvFromCwd();
26
29
  }
27
30
  const runOptions = normalizeCookOptions(options);
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Command } from "commander";
2
2
  import { ClawChefError } from "./errors.js";
3
+ import { importDotEnvFromCwd, importDotEnvFromRef } from "./env.js";
3
4
  import { Logger } from "./logger.js";
4
5
  import { runRecipe } from "./orchestrator.js";
5
6
  import { loadRecipe, loadRecipeText } from "./recipe.js";
@@ -78,6 +79,7 @@ export function buildCli() {
78
79
  .option("--verbose", "Verbose logging", false)
79
80
  .option("-s, --silent", "Skip reset confirmation prompt", false)
80
81
  .option("--keep-openclaw-state", "Preserve existing OpenClaw state (skip factory reset)", false)
82
+ .option("--env-file <path-or-url>", "Load env vars from local file or HTTP URL")
81
83
  .option("--provider <provider>", "Execution provider: command | remote | mock")
82
84
  .option("--plugin <npm-spec>", "Preinstall plugin package (repeatable)", (v, p) => p.concat([v]), [])
83
85
  .option("--remote-base-url <url>", "Remote OpenClaw API base URL")
@@ -87,6 +89,12 @@ export function buildCli() {
87
89
  .option("--remote-timeout-ms <ms>", "Remote operation timeout in milliseconds")
88
90
  .option("--remote-operation-path <path>", "Remote operation endpoint path")
89
91
  .action(async (recipeRef, opts) => {
92
+ if (opts.envFile) {
93
+ await importDotEnvFromRef(String(opts.envFile));
94
+ }
95
+ else {
96
+ importDotEnvFromCwd();
97
+ }
90
98
  const provider = parseProvider(opts.provider ?? readEnv("CLAWCHEF_PROVIDER") ?? "command");
91
99
  const options = {
92
100
  vars: parseVarFlags(opts.var),
package/dist/env.d.ts CHANGED
@@ -1 +1,2 @@
1
1
  export declare function importDotEnvFromCwd(): void;
2
+ export declare function importDotEnvFromRef(ref: string): Promise<void>;
package/dist/env.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
- import { config as loadDotenv } from "dotenv";
4
+ import { config as loadDotenv, parse as parseDotenv } from "dotenv";
4
5
  import { ClawChefError } from "./errors.js";
5
6
  export function importDotEnvFromCwd() {
6
7
  const envPath = path.resolve(process.cwd(), ".env");
@@ -12,3 +13,51 @@ export function importDotEnvFromCwd() {
12
13
  throw new ClawChefError(`Failed to load .env from current directory: ${result.error.message}`);
13
14
  }
14
15
  }
16
+ function isHttpUrl(value) {
17
+ try {
18
+ const url = new URL(value);
19
+ return url.protocol === "http:" || url.protocol === "https:";
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ function applyEnv(entries) {
26
+ for (const [key, value] of Object.entries(entries)) {
27
+ if (process.env[key] === undefined) {
28
+ process.env[key] = value;
29
+ }
30
+ }
31
+ }
32
+ export async function importDotEnvFromRef(ref) {
33
+ const trimmed = ref.trim();
34
+ if (!trimmed) {
35
+ throw new ClawChefError("--env-file cannot be empty");
36
+ }
37
+ if (isHttpUrl(trimmed)) {
38
+ let response;
39
+ try {
40
+ response = await fetch(trimmed);
41
+ }
42
+ catch (err) {
43
+ const message = err instanceof Error ? err.message : String(err);
44
+ throw new ClawChefError(`Failed to fetch env file URL ${trimmed}: ${message}`);
45
+ }
46
+ if (!response.ok) {
47
+ throw new ClawChefError(`Failed to fetch env file URL ${trimmed}: HTTP ${response.status}`);
48
+ }
49
+ const content = await response.text();
50
+ applyEnv(parseDotenv(content));
51
+ return;
52
+ }
53
+ const envPath = path.resolve(trimmed);
54
+ let content;
55
+ try {
56
+ content = await readFile(envPath, "utf8");
57
+ }
58
+ catch (err) {
59
+ const message = err instanceof Error ? err.message : String(err);
60
+ throw new ClawChefError(`Failed to load env file ${envPath}: ${message}`);
61
+ }
62
+ applyEnv(parseDotenv(content));
63
+ }
package/dist/index.js CHANGED
@@ -1,9 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { buildCli } from "./cli.js";
3
- import { importDotEnvFromCwd } from "./env.js";
4
3
  import { ClawChefError } from "./errors.js";
5
4
  async function main() {
6
- importDotEnvFromCwd();
7
5
  const program = buildCli();
8
6
  await program.parseAsync(process.argv);
9
7
  }
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawchef",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Recipe-driven OpenClaw environment orchestrator",
5
5
  "homepage": "https://renorzr.github.io/clawchef",
6
6
  "repository": {
package/src/api.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import YAML from "js-yaml";
2
2
  import { ClawChefError } from "./errors.js";
3
- import { importDotEnvFromCwd } from "./env.js";
3
+ import { importDotEnvFromCwd, importDotEnvFromRef } from "./env.js";
4
4
  import { Logger } from "./logger.js";
5
5
  import { runRecipe } from "./orchestrator.js";
6
6
  import { loadRecipe, loadRecipeText } from "./recipe.js";
@@ -18,6 +18,7 @@ export interface CookOptions {
18
18
  silent?: boolean;
19
19
  provider?: OpenClawProvider;
20
20
  remote?: Partial<OpenClawRemoteConfig>;
21
+ envFile?: string;
21
22
  loadDotEnvFromCwd?: boolean;
22
23
  }
23
24
 
@@ -37,7 +38,9 @@ function normalizeCookOptions(options: CookOptions): RunOptions {
37
38
  }
38
39
 
39
40
  export async function cook(recipeRef: string, options: CookOptions = {}): Promise<void> {
40
- if (options.loadDotEnvFromCwd ?? true) {
41
+ if (options.envFile) {
42
+ await importDotEnvFromRef(options.envFile);
43
+ } else if (options.loadDotEnvFromCwd ?? true) {
41
44
  importDotEnvFromCwd();
42
45
  }
43
46
 
package/src/cli.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Command } from "commander";
2
2
  import { ClawChefError } from "./errors.js";
3
+ import { importDotEnvFromCwd, importDotEnvFromRef } from "./env.js";
3
4
  import { Logger } from "./logger.js";
4
5
  import { runRecipe } from "./orchestrator.js";
5
6
  import { loadRecipe, loadRecipeText } from "./recipe.js";
@@ -88,6 +89,7 @@ export function buildCli(): Command {
88
89
  .option("--verbose", "Verbose logging", false)
89
90
  .option("-s, --silent", "Skip reset confirmation prompt", false)
90
91
  .option("--keep-openclaw-state", "Preserve existing OpenClaw state (skip factory reset)", false)
92
+ .option("--env-file <path-or-url>", "Load env vars from local file or HTTP URL")
91
93
  .option("--provider <provider>", "Execution provider: command | remote | mock")
92
94
  .option("--plugin <npm-spec>", "Preinstall plugin package (repeatable)", (v, p: string[]) => p.concat([v]), [])
93
95
  .option("--remote-base-url <url>", "Remote OpenClaw API base URL")
@@ -97,6 +99,12 @@ export function buildCli(): Command {
97
99
  .option("--remote-timeout-ms <ms>", "Remote operation timeout in milliseconds")
98
100
  .option("--remote-operation-path <path>", "Remote operation endpoint path")
99
101
  .action(async (recipeRef: string, opts) => {
102
+ if (opts.envFile) {
103
+ await importDotEnvFromRef(String(opts.envFile));
104
+ } else {
105
+ importDotEnvFromCwd();
106
+ }
107
+
100
108
  const provider = parseProvider(opts.provider ?? readEnv("CLAWCHEF_PROVIDER") ?? "command");
101
109
  const options: RunOptions = {
102
110
  vars: parseVarFlags(opts.var),
package/src/env.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
- import { config as loadDotenv } from "dotenv";
4
+ import { config as loadDotenv, parse as parseDotenv } from "dotenv";
4
5
  import { ClawChefError } from "./errors.js";
5
6
 
6
7
  export function importDotEnvFromCwd(): void {
@@ -14,3 +15,53 @@ export function importDotEnvFromCwd(): void {
14
15
  throw new ClawChefError(`Failed to load .env from current directory: ${result.error.message}`);
15
16
  }
16
17
  }
18
+
19
+ function isHttpUrl(value: string): boolean {
20
+ try {
21
+ const url = new URL(value);
22
+ return url.protocol === "http:" || url.protocol === "https:";
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ function applyEnv(entries: Record<string, string>): void {
29
+ for (const [key, value] of Object.entries(entries)) {
30
+ if (process.env[key] === undefined) {
31
+ process.env[key] = value;
32
+ }
33
+ }
34
+ }
35
+
36
+ export async function importDotEnvFromRef(ref: string): Promise<void> {
37
+ const trimmed = ref.trim();
38
+ if (!trimmed) {
39
+ throw new ClawChefError("--env-file cannot be empty");
40
+ }
41
+
42
+ if (isHttpUrl(trimmed)) {
43
+ let response: Response;
44
+ try {
45
+ response = await fetch(trimmed);
46
+ } catch (err) {
47
+ const message = err instanceof Error ? err.message : String(err);
48
+ throw new ClawChefError(`Failed to fetch env file URL ${trimmed}: ${message}`);
49
+ }
50
+ if (!response.ok) {
51
+ throw new ClawChefError(`Failed to fetch env file URL ${trimmed}: HTTP ${response.status}`);
52
+ }
53
+ const content = await response.text();
54
+ applyEnv(parseDotenv(content));
55
+ return;
56
+ }
57
+
58
+ const envPath = path.resolve(trimmed);
59
+ let content: string;
60
+ try {
61
+ content = await readFile(envPath, "utf8");
62
+ } catch (err) {
63
+ const message = err instanceof Error ? err.message : String(err);
64
+ throw new ClawChefError(`Failed to load env file ${envPath}: ${message}`);
65
+ }
66
+ applyEnv(parseDotenv(content));
67
+ }
package/src/index.ts CHANGED
@@ -1,10 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { buildCli } from "./cli.js";
3
- import { importDotEnvFromCwd } from "./env.js";
4
3
  import { ClawChefError } from "./errors.js";
5
4
 
6
5
  async function main(): Promise<void> {
7
- importDotEnvFromCwd();
8
6
  const program = buildCli();
9
7
  await program.parseAsync(process.argv);
10
8
  }
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: {