clawchef 0.1.0

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.
Files changed (62) hide show
  1. package/README.md +405 -0
  2. package/dist/api.d.ts +14 -0
  3. package/dist/api.js +49 -0
  4. package/dist/assertions.d.ts +2 -0
  5. package/dist/assertions.js +32 -0
  6. package/dist/cli.d.ts +2 -0
  7. package/dist/cli.js +115 -0
  8. package/dist/env.d.ts +1 -0
  9. package/dist/env.js +14 -0
  10. package/dist/errors.d.ts +3 -0
  11. package/dist/errors.js +6 -0
  12. package/dist/index.d.ts +2 -0
  13. package/dist/index.js +18 -0
  14. package/dist/logger.d.ts +7 -0
  15. package/dist/logger.js +17 -0
  16. package/dist/openclaw/command-provider.d.ts +15 -0
  17. package/dist/openclaw/command-provider.js +489 -0
  18. package/dist/openclaw/factory.d.ts +3 -0
  19. package/dist/openclaw/factory.js +13 -0
  20. package/dist/openclaw/mock-provider.d.ts +15 -0
  21. package/dist/openclaw/mock-provider.js +65 -0
  22. package/dist/openclaw/provider.d.ts +20 -0
  23. package/dist/openclaw/provider.js +1 -0
  24. package/dist/openclaw/remote-provider.d.ts +19 -0
  25. package/dist/openclaw/remote-provider.js +158 -0
  26. package/dist/orchestrator.d.ts +4 -0
  27. package/dist/orchestrator.js +243 -0
  28. package/dist/recipe.d.ts +20 -0
  29. package/dist/recipe.js +522 -0
  30. package/dist/schema.d.ts +626 -0
  31. package/dist/schema.js +143 -0
  32. package/dist/template.d.ts +2 -0
  33. package/dist/template.js +30 -0
  34. package/dist/types.d.ts +136 -0
  35. package/dist/types.js +1 -0
  36. package/package.json +41 -0
  37. package/recipes/content-from-sample.yaml +20 -0
  38. package/recipes/openclaw-from-zero.yaml +45 -0
  39. package/recipes/openclaw-local.yaml +65 -0
  40. package/recipes/openclaw-remote-http.yaml +38 -0
  41. package/recipes/openclaw-telegram-mock.yaml +22 -0
  42. package/recipes/openclaw-telegram.yaml +19 -0
  43. package/recipes/sample.yaml +49 -0
  44. package/recipes/snippets/readme-template.md +3 -0
  45. package/src/api.ts +65 -0
  46. package/src/assertions.ts +37 -0
  47. package/src/cli.ts +123 -0
  48. package/src/env.ts +16 -0
  49. package/src/errors.ts +6 -0
  50. package/src/index.ts +20 -0
  51. package/src/logger.ts +17 -0
  52. package/src/openclaw/command-provider.ts +594 -0
  53. package/src/openclaw/factory.ts +16 -0
  54. package/src/openclaw/mock-provider.ts +104 -0
  55. package/src/openclaw/provider.ts +44 -0
  56. package/src/openclaw/remote-provider.ts +264 -0
  57. package/src/orchestrator.ts +271 -0
  58. package/src/recipe.ts +621 -0
  59. package/src/schema.ts +157 -0
  60. package/src/template.ts +41 -0
  61. package/src/types.ts +150 -0
  62. package/tsconfig.json +16 -0
package/src/recipe.ts ADDED
@@ -0,0 +1,621 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { tmpdir } from "node:os";
4
+ import { spawn } from "node:child_process";
5
+ import { readFile, mkdtemp, stat, writeFile, rm, mkdir } from "node:fs/promises";
6
+ import YAML from "js-yaml";
7
+ import { recipeSchema } from "./schema.js";
8
+ import { ClawChefError } from "./errors.js";
9
+ import { deepResolveTemplates } from "./template.js";
10
+ import type { Recipe, RunOptions } from "./types.js";
11
+
12
+ export type RecipeOrigin =
13
+ | {
14
+ kind: "local";
15
+ recipePath: string;
16
+ recipeDir: string;
17
+ }
18
+ | {
19
+ kind: "url";
20
+ recipeUrl: string;
21
+ };
22
+
23
+ export interface LoadedRecipe {
24
+ recipe: Recipe;
25
+ origin: RecipeOrigin;
26
+ cleanup?: () => Promise<void>;
27
+ }
28
+
29
+ export interface LoadedRecipeText {
30
+ source: string;
31
+ cleanup?: () => Promise<void>;
32
+ }
33
+
34
+ const AUTH_CHOICE_TO_FIELD: Record<string, string> = {
35
+ "openai-api-key": "openai_api_key",
36
+ "anthropic-api-key": "anthropic_api_key",
37
+ "openrouter-api-key": "openrouter_api_key",
38
+ "xai-api-key": "xai_api_key",
39
+ "gemini-api-key": "gemini_api_key",
40
+ "ai-gateway-api-key": "ai_gateway_api_key",
41
+ "cloudflare-ai-gateway-api-key": "cloudflare_ai_gateway_api_key",
42
+ token: "token",
43
+ };
44
+
45
+ const SECRET_BOOTSTRAP_FIELDS = [
46
+ "openai_api_key",
47
+ "anthropic_api_key",
48
+ "openrouter_api_key",
49
+ "xai_api_key",
50
+ "gemini_api_key",
51
+ "ai_gateway_api_key",
52
+ "cloudflare_ai_gateway_api_key",
53
+ "token",
54
+ ] as const;
55
+
56
+ const ALLOWED_CHANNELS = new Set([
57
+ "telegram",
58
+ "telegram-mock",
59
+ "whatsapp",
60
+ "discord",
61
+ "googlechat",
62
+ "slack",
63
+ "signal",
64
+ "imessage",
65
+ "feishu",
66
+ "nostr",
67
+ "msteams",
68
+ "mattermost",
69
+ "nextcloud-talk",
70
+ "matrix",
71
+ "bluebubbles",
72
+ "line",
73
+ "zalo",
74
+ "zalouser",
75
+ "tlon",
76
+ ]);
77
+
78
+ const CHANNEL_SECRET_FIELDS = ["token", "bot_token", "access_token", "app_token", "password"] as const;
79
+
80
+ const TEMPLATE_TOKEN_RE = /\$\{[A-Za-z_][A-Za-z0-9_]*\}/;
81
+
82
+ function assertNoInlineSecrets(recipe: Recipe): void {
83
+ const bootstrap = recipe.openclaw.bootstrap;
84
+ if (bootstrap) {
85
+ for (const field of SECRET_BOOTSTRAP_FIELDS) {
86
+ const value = bootstrap[field];
87
+ if (!value) {
88
+ continue;
89
+ }
90
+ if (!TEMPLATE_TOKEN_RE.test(value)) {
91
+ throw new ClawChefError(
92
+ `Inline secret in openclaw.bootstrap.${field} is not allowed. Use \${var} and pass it via --var or CLAWCHEF_VAR_*`,
93
+ );
94
+ }
95
+ }
96
+ }
97
+
98
+ for (const channel of recipe.channels ?? []) {
99
+ for (const field of CHANNEL_SECRET_FIELDS) {
100
+ const value = channel[field];
101
+ if (!value) {
102
+ continue;
103
+ }
104
+ if (!TEMPLATE_TOKEN_RE.test(value)) {
105
+ throw new ClawChefError(
106
+ `Inline secret in channels[].${field} is not allowed. Use \${var} and pass it via --var or CLAWCHEF_VAR_*`,
107
+ );
108
+ }
109
+ }
110
+
111
+ for (const [key, value] of Object.entries(channel.extra_flags ?? {})) {
112
+ if (typeof value !== "string") {
113
+ continue;
114
+ }
115
+ if (!/(token|password|secret|api[_-]?key)/i.test(key)) {
116
+ continue;
117
+ }
118
+ if (!TEMPLATE_TOKEN_RE.test(value)) {
119
+ throw new ClawChefError(
120
+ `Inline secret in channels[].extra_flags.${key} is not allowed. Use \${var} and pass it via --var or CLAWCHEF_VAR_*`,
121
+ );
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ function collectVars(recipe: Recipe, cliVars: Record<string, string>): Record<string, string> {
128
+ const vars: Record<string, string> = {};
129
+ const params = recipe.params ?? {};
130
+
131
+ for (const [envKey, envValue] of Object.entries(process.env)) {
132
+ if (!envKey.startsWith("CLAWCHEF_VAR_") || envValue === undefined) {
133
+ continue;
134
+ }
135
+ const suffix = envKey.slice("CLAWCHEF_VAR_".length).trim();
136
+ if (!suffix) {
137
+ continue;
138
+ }
139
+ vars[suffix.toLowerCase()] = envValue;
140
+ }
141
+
142
+ for (const [key, def] of Object.entries(params)) {
143
+ const envKey = `CLAWCHEF_VAR_${key.toUpperCase()}`;
144
+ const envValue = process.env[envKey];
145
+
146
+ if (Object.prototype.hasOwnProperty.call(cliVars, key)) {
147
+ vars[key] = cliVars[key];
148
+ continue;
149
+ }
150
+ if (envValue !== undefined) {
151
+ vars[key] = envValue;
152
+ continue;
153
+ }
154
+ if (def.default !== undefined) {
155
+ vars[key] = def.default;
156
+ continue;
157
+ }
158
+ if (def.required) {
159
+ throw new ClawChefError(`Parameter ${key} is required but was not provided via --var or environment`);
160
+ }
161
+ }
162
+
163
+ for (const [k, v] of Object.entries(cliVars)) {
164
+ vars[k] = v;
165
+ }
166
+
167
+ return vars;
168
+ }
169
+
170
+ function semanticValidate(recipe: Recipe): void {
171
+ const ws = new Set((recipe.workspaces ?? []).map((w) => w.name));
172
+ for (const agent of recipe.agents ?? []) {
173
+ if (!ws.has(agent.workspace)) {
174
+ throw new ClawChefError(`Agent ${agent.name} references missing workspace: ${agent.workspace}`);
175
+ }
176
+ }
177
+ for (const file of recipe.files ?? []) {
178
+ if (!ws.has(file.workspace)) {
179
+ throw new ClawChefError(`File ${file.path} references missing workspace: ${file.workspace}`);
180
+ }
181
+ }
182
+ const agents = new Set((recipe.agents ?? []).map((a) => `${a.workspace}::${a.name}`));
183
+ for (const conv of recipe.conversations ?? []) {
184
+ if (!ws.has(conv.workspace)) {
185
+ throw new ClawChefError(`Conversation references missing workspace: ${conv.workspace}`);
186
+ }
187
+ if (!agents.has(`${conv.workspace}::${conv.agent}`)) {
188
+ throw new ClawChefError(
189
+ `Conversation references missing agent: ${conv.agent} (workspace: ${conv.workspace})`,
190
+ );
191
+ }
192
+ }
193
+
194
+ for (const channel of recipe.channels ?? []) {
195
+ if (!ALLOWED_CHANNELS.has(channel.channel)) {
196
+ throw new ClawChefError(
197
+ `Unsupported channel: ${channel.channel}. Allowed: ${Array.from(ALLOWED_CHANNELS).join(", ")}`,
198
+ );
199
+ }
200
+
201
+ const hasAuth =
202
+ Boolean(channel.use_env) ||
203
+ Boolean(channel.token?.trim()) ||
204
+ Boolean(channel.token_file?.trim()) ||
205
+ Boolean(channel.bot_token?.trim()) ||
206
+ Boolean(channel.access_token?.trim()) ||
207
+ Boolean(channel.app_token?.trim()) ||
208
+ Boolean(channel.password?.trim()) ||
209
+ Boolean(channel.webhook_url?.trim()) ||
210
+ Object.entries(channel.extra_flags ?? {}).some(([key, value]) => {
211
+ if (typeof value === "boolean") {
212
+ return false;
213
+ }
214
+ return /(token|password|secret|api[_-]?key|webhook)/i.test(key) && String(value).trim().length > 0;
215
+ });
216
+
217
+ if (!hasAuth) {
218
+ throw new ClawChefError(
219
+ `channels[] entry for ${channel.channel} requires at least one auth input (for example token/bot_token/access_token/token_file/use_env)`,
220
+ );
221
+ }
222
+ }
223
+
224
+ const bootstrap = recipe.openclaw.bootstrap;
225
+ const authChoice = bootstrap?.auth_choice;
226
+ if (authChoice) {
227
+ const requiredField = AUTH_CHOICE_TO_FIELD[authChoice];
228
+ if (requiredField) {
229
+ const value = (bootstrap as Record<string, string | undefined>)[requiredField];
230
+ if (!value || !value.trim()) {
231
+ throw new ClawChefError(
232
+ `openclaw.bootstrap.auth_choice=${authChoice} requires openclaw.bootstrap.${requiredField}`,
233
+ );
234
+ }
235
+ }
236
+ if (authChoice === "token") {
237
+ if (!bootstrap?.token_provider || !bootstrap.token_profile_id) {
238
+ throw new ClawChefError(
239
+ "openclaw.bootstrap.auth_choice=token requires token_provider and token_profile_id",
240
+ );
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ const DEFAULT_RECIPE_FILE = "recipe.yaml";
247
+ const ARCHIVE_EXTENSIONS = [".tar.gz", ".tgz", ".zip", ".tar"] as const;
248
+
249
+ interface ResolvedRecipeReference {
250
+ recipePath: string;
251
+ origin: RecipeOrigin;
252
+ cleanupPaths: string[];
253
+ }
254
+
255
+ function isHttpUrl(value: string): boolean {
256
+ try {
257
+ const url = new URL(value);
258
+ return url.protocol === "http:" || url.protocol === "https:";
259
+ } catch {
260
+ return false;
261
+ }
262
+ }
263
+
264
+ function archiveExtensionFromPath(filePath: string): string | undefined {
265
+ const lower = filePath.toLowerCase();
266
+ return ARCHIVE_EXTENSIONS.find((ext) => lower.endsWith(ext));
267
+ }
268
+
269
+ function splitLocalReference(input: string): { base: string; inner: string } | undefined {
270
+ const idx = input.lastIndexOf(":");
271
+ if (idx <= 0 || idx >= input.length - 1) {
272
+ return undefined;
273
+ }
274
+ return {
275
+ base: input.slice(0, idx),
276
+ inner: input.slice(idx + 1),
277
+ };
278
+ }
279
+
280
+ function parseUrlReference(input: string): { archiveUrl: string; inner?: string; directUrl?: string } {
281
+ const parsed = new URL(input);
282
+ const lowerPath = parsed.pathname.toLowerCase();
283
+
284
+ for (const ext of ARCHIVE_EXTENSIONS) {
285
+ const marker = `${ext}:`;
286
+ const idx = lowerPath.lastIndexOf(marker);
287
+ if (idx >= 0) {
288
+ const archivePath = parsed.pathname.slice(0, idx + ext.length);
289
+ const inner = parsed.pathname.slice(idx + ext.length + 1);
290
+ const archiveUrl = new URL(input);
291
+ archiveUrl.pathname = archivePath;
292
+ return {
293
+ archiveUrl: archiveUrl.toString(),
294
+ inner,
295
+ };
296
+ }
297
+ }
298
+
299
+ const archiveExt = archiveExtensionFromPath(parsed.pathname);
300
+ if (archiveExt) {
301
+ return {
302
+ archiveUrl: parsed.toString(),
303
+ };
304
+ }
305
+
306
+ return {
307
+ archiveUrl: "",
308
+ directUrl: parsed.toString(),
309
+ };
310
+ }
311
+
312
+ async function runCommand(command: string, args: string[]): Promise<void> {
313
+ await new Promise<void>((resolve, reject) => {
314
+ const child = spawn(command, args, {
315
+ stdio: ["ignore", "pipe", "pipe"],
316
+ });
317
+
318
+ const stdoutChunks: string[] = [];
319
+ const stderrChunks: string[] = [];
320
+ child.stdout.on("data", (buf) => {
321
+ stdoutChunks.push(String(buf));
322
+ });
323
+ child.stderr.on("data", (buf) => {
324
+ stderrChunks.push(String(buf));
325
+ });
326
+
327
+ child.on("error", (err) => {
328
+ reject(new ClawChefError(`Failed to run ${command}: ${err.message}`));
329
+ });
330
+
331
+ child.on("close", (code) => {
332
+ if (code === 0) {
333
+ resolve();
334
+ return;
335
+ }
336
+ const stderr = stderrChunks.join("").trim();
337
+ const stdout = stdoutChunks.join("").trim();
338
+ const details = stderr || stdout || "unknown error";
339
+ reject(new ClawChefError(`Failed to run ${command}: ${details}`));
340
+ });
341
+ });
342
+ }
343
+
344
+ async function extractArchive(archivePath: string, extractDir: string): Promise<void> {
345
+ const lower = archivePath.toLowerCase();
346
+ if (lower.endsWith(".zip")) {
347
+ await runCommand("unzip", ["-oq", archivePath, "-d", extractDir]);
348
+ return;
349
+ }
350
+ if (lower.endsWith(".tar") || lower.endsWith(".tar.gz") || lower.endsWith(".tgz")) {
351
+ await runCommand("tar", ["-xf", archivePath, "-C", extractDir]);
352
+ return;
353
+ }
354
+ throw new ClawChefError(`Unsupported archive format: ${archivePath}`);
355
+ }
356
+
357
+ async function resolveRecipeReference(recipeInput: string): Promise<ResolvedRecipeReference> {
358
+ if (isHttpUrl(recipeInput)) {
359
+ const parsed = parseUrlReference(recipeInput);
360
+ if (parsed.directUrl) {
361
+ let response: Response;
362
+ try {
363
+ response = await fetch(parsed.directUrl);
364
+ } catch (err) {
365
+ const message = err instanceof Error ? err.message : String(err);
366
+ throw new ClawChefError(`Failed to fetch recipe URL ${parsed.directUrl}: ${message}`);
367
+ }
368
+ if (!response.ok) {
369
+ throw new ClawChefError(`Failed to fetch recipe URL ${parsed.directUrl}: HTTP ${response.status}`);
370
+ }
371
+
372
+ const tempDir = await mkdtemp(path.join(tmpdir(), "clawchef-recipe-"));
373
+ const recipePath = path.join(tempDir, DEFAULT_RECIPE_FILE);
374
+ await writeFile(recipePath, await response.text(), "utf8");
375
+
376
+ return {
377
+ recipePath,
378
+ origin: {
379
+ kind: "url",
380
+ recipeUrl: parsed.directUrl,
381
+ },
382
+ cleanupPaths: [tempDir],
383
+ };
384
+ }
385
+
386
+ const archiveUrl = parsed.archiveUrl;
387
+ const recipeInArchive = parsed.inner?.trim() || DEFAULT_RECIPE_FILE;
388
+ const tempDir = await mkdtemp(path.join(tmpdir(), "clawchef-recipe-"));
389
+ const archiveExt = archiveExtensionFromPath(new URL(archiveUrl).pathname) ?? ".archive";
390
+ const downloadedArchivePath = path.join(tempDir, `archive${archiveExt}`);
391
+
392
+ let response: Response;
393
+ try {
394
+ response = await fetch(archiveUrl);
395
+ } catch (err) {
396
+ const message = err instanceof Error ? err.message : String(err);
397
+ throw new ClawChefError(`Failed to fetch recipe archive URL ${archiveUrl}: ${message}`);
398
+ }
399
+ if (!response.ok) {
400
+ throw new ClawChefError(`Failed to fetch recipe archive URL ${archiveUrl}: HTTP ${response.status}`);
401
+ }
402
+ const bytes = Buffer.from(await response.arrayBuffer());
403
+ await writeFile(downloadedArchivePath, bytes);
404
+
405
+ const extractDir = path.join(tempDir, "extracted");
406
+ await rm(extractDir, { recursive: true, force: true });
407
+ await mkdir(extractDir, { recursive: true });
408
+ await extractArchive(downloadedArchivePath, extractDir);
409
+
410
+ const recipePath = path.join(extractDir, recipeInArchive);
411
+ try {
412
+ const s = await stat(recipePath);
413
+ if (!s.isFile()) {
414
+ throw new ClawChefError(`Recipe in archive is not a file: ${recipeInArchive}`);
415
+ }
416
+ } catch {
417
+ throw new ClawChefError(`Recipe file not found in archive: ${recipeInArchive}`);
418
+ }
419
+
420
+ return {
421
+ recipePath,
422
+ origin: {
423
+ kind: "local",
424
+ recipePath,
425
+ recipeDir: path.dirname(recipePath),
426
+ },
427
+ cleanupPaths: [tempDir],
428
+ };
429
+ }
430
+
431
+ const localPath = path.resolve(recipeInput);
432
+ let localEntry: Awaited<ReturnType<typeof stat>> | undefined;
433
+ try {
434
+ localEntry = await stat(localPath);
435
+ } catch {
436
+ localEntry = undefined;
437
+ }
438
+
439
+ if (localEntry?.isDirectory()) {
440
+ const recipePath = path.join(localPath, DEFAULT_RECIPE_FILE);
441
+ return {
442
+ recipePath,
443
+ origin: {
444
+ kind: "local",
445
+ recipePath,
446
+ recipeDir: path.dirname(recipePath),
447
+ },
448
+ cleanupPaths: [],
449
+ };
450
+ }
451
+
452
+ if (localEntry?.isFile()) {
453
+ const archiveExt = archiveExtensionFromPath(localPath);
454
+ if (archiveExt) {
455
+ const tempDir = await mkdtemp(path.join(tmpdir(), "clawchef-recipe-"));
456
+ const extractDir = path.join(tempDir, "extracted");
457
+ await mkdir(extractDir, { recursive: true });
458
+ await extractArchive(localPath, extractDir);
459
+ const recipePath = path.join(extractDir, DEFAULT_RECIPE_FILE);
460
+ try {
461
+ const s = await stat(recipePath);
462
+ if (!s.isFile()) {
463
+ throw new ClawChefError(`Recipe in archive is not a file: ${DEFAULT_RECIPE_FILE}`);
464
+ }
465
+ } catch {
466
+ throw new ClawChefError(`Recipe file not found in archive: ${DEFAULT_RECIPE_FILE}`);
467
+ }
468
+ return {
469
+ recipePath,
470
+ origin: {
471
+ kind: "local",
472
+ recipePath,
473
+ recipeDir: path.dirname(recipePath),
474
+ },
475
+ cleanupPaths: [tempDir],
476
+ };
477
+ }
478
+
479
+ return {
480
+ recipePath: localPath,
481
+ origin: {
482
+ kind: "local",
483
+ recipePath: localPath,
484
+ recipeDir: path.dirname(localPath),
485
+ },
486
+ cleanupPaths: [],
487
+ };
488
+ }
489
+
490
+ const split = splitLocalReference(recipeInput);
491
+ if (!split) {
492
+ throw new ClawChefError(`Recipe not found: ${recipeInput}`);
493
+ }
494
+
495
+ const basePath = path.resolve(split.base);
496
+ const selector = split.inner;
497
+ let entry;
498
+ try {
499
+ entry = await stat(basePath);
500
+ } catch {
501
+ throw new ClawChefError(`Recipe base not found: ${split.base}`);
502
+ }
503
+
504
+ if (entry.isDirectory()) {
505
+ const recipePath = path.join(basePath, selector);
506
+ return {
507
+ recipePath,
508
+ origin: {
509
+ kind: "local",
510
+ recipePath,
511
+ recipeDir: path.dirname(recipePath),
512
+ },
513
+ cleanupPaths: [],
514
+ };
515
+ }
516
+
517
+ if (entry.isFile()) {
518
+ const archiveExt = archiveExtensionFromPath(basePath);
519
+ if (!archiveExt) {
520
+ throw new ClawChefError(`Recipe selector with ':' is only supported for directories or archives: ${recipeInput}`);
521
+ }
522
+
523
+ const tempDir = await mkdtemp(path.join(tmpdir(), "clawchef-recipe-"));
524
+ const extractDir = path.join(tempDir, "extracted");
525
+ await mkdir(extractDir, { recursive: true });
526
+ await extractArchive(basePath, extractDir);
527
+ const recipePath = path.join(extractDir, selector);
528
+ try {
529
+ const s = await stat(recipePath);
530
+ if (!s.isFile()) {
531
+ throw new ClawChefError(`Recipe in archive is not a file: ${selector}`);
532
+ }
533
+ } catch {
534
+ throw new ClawChefError(`Recipe file not found in archive: ${selector}`);
535
+ }
536
+ return {
537
+ recipePath,
538
+ origin: {
539
+ kind: "local",
540
+ recipePath,
541
+ recipeDir: path.dirname(recipePath),
542
+ },
543
+ cleanupPaths: [tempDir],
544
+ };
545
+ }
546
+
547
+ throw new ClawChefError(`Unsupported recipe reference: ${recipeInput}`);
548
+ }
549
+
550
+ async function withResolvedRecipe<T>(
551
+ recipeInput: string,
552
+ fn: (resolved: ResolvedRecipeReference) => Promise<T>,
553
+ ): Promise<{ result: T; cleanup?: () => Promise<void> }> {
554
+ const resolved = await resolveRecipeReference(recipeInput);
555
+ let shouldCleanup = true;
556
+ const cleanup = async (): Promise<void> => {
557
+ if (!shouldCleanup) {
558
+ return;
559
+ }
560
+ shouldCleanup = false;
561
+ for (const cleanupPath of resolved.cleanupPaths) {
562
+ await rm(cleanupPath, { recursive: true, force: true });
563
+ }
564
+ };
565
+
566
+ try {
567
+ const result = await fn(resolved);
568
+ if (resolved.cleanupPaths.length === 0) {
569
+ shouldCleanup = false;
570
+ return { result };
571
+ }
572
+ return { result, cleanup };
573
+ } catch (err) {
574
+ await cleanup();
575
+ throw err;
576
+ }
577
+ }
578
+
579
+ export async function loadRecipeText(recipeRef: string): Promise<LoadedRecipeText> {
580
+ const { result, cleanup } = await withResolvedRecipe(recipeRef, async (resolved) => {
581
+ const source = await readFile(resolved.recipePath, "utf8");
582
+ return { source };
583
+ });
584
+
585
+ return {
586
+ source: result.source,
587
+ cleanup,
588
+ };
589
+ }
590
+
591
+ export async function loadRecipe(recipePath: string, options: RunOptions): Promise<LoadedRecipe> {
592
+ const { result, cleanup } = await withResolvedRecipe(recipePath, async (recipeRef) => {
593
+ const source = await readFile(recipeRef.recipePath, "utf8");
594
+ const raw = YAML.load(source);
595
+ const firstParse = recipeSchema.safeParse(raw);
596
+ if (!firstParse.success) {
597
+ throw new ClawChefError(`Recipe format is invalid: ${firstParse.error.message}`);
598
+ }
599
+
600
+ assertNoInlineSecrets(firstParse.data);
601
+
602
+ const vars = collectVars(firstParse.data, options.vars);
603
+ const rendered = deepResolveTemplates(firstParse.data, vars, options.allowMissing);
604
+ const secondParse = recipeSchema.safeParse(rendered);
605
+ if (!secondParse.success) {
606
+ throw new ClawChefError(`Recipe is invalid after parameter resolution: ${secondParse.error.message}`);
607
+ }
608
+
609
+ semanticValidate(secondParse.data);
610
+ return {
611
+ recipe: secondParse.data,
612
+ origin: recipeRef.origin,
613
+ };
614
+ });
615
+
616
+ return {
617
+ recipe: result.recipe,
618
+ origin: result.origin,
619
+ cleanup,
620
+ };
621
+ }