clawchef 0.1.3 → 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
@@ -39,6 +39,12 @@ Run recipe from URL:
39
39
  clawchef cook https://example.com/recipes/sample.yaml --provider remote
40
40
  ```
41
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
+
42
48
  Run recipe from archive (default `recipe.yaml`):
43
49
 
44
50
  ```bash
@@ -219,6 +225,7 @@ If `.env` exists in the directory where `clawchef` is executed, it is loaded bef
219
225
  - `https://host/recipe.yaml`
220
226
  - `https://host/archive.zip` (loads `recipe.yaml` from archive)
221
227
  - `https://host/archive.zip:custom/recipe.yaml`
228
+ - `https://github.com/<owner>/<repo>` (loads `recipe.yaml` from repo root)
222
229
 
223
230
  Supported archives: `.zip`, `.tar`, `.tar.gz`, `.tgz`.
224
231
 
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.4",
4
4
  "description": "Recipe-driven OpenClaw environment orchestrator",
5
5
  "homepage": "https://renorzr.github.io/clawchef",
6
6
  "repository": {
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: {