agentplane 0.1.4 → 0.1.6
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/dist/agents/agents-template.d.ts.map +1 -0
- package/dist/{agents-template.js → agents/agents-template.js} +2 -2
- package/dist/{task-backend.d.ts → backends/task-backend.d.ts} +5 -3
- package/dist/backends/task-backend.d.ts.map +1 -0
- package/dist/{task-backend.js → backends/task-backend.js} +102 -268
- package/dist/backends/task-index.d.ts +16 -0
- package/dist/backends/task-index.d.ts.map +1 -0
- package/dist/backends/task-index.js +84 -0
- package/dist/cli/archive.d.ts +16 -0
- package/dist/cli/archive.d.ts.map +1 -0
- package/dist/cli/archive.js +149 -0
- package/dist/cli/checksum.d.ts +3 -0
- package/dist/cli/checksum.d.ts.map +1 -0
- package/dist/cli/checksum.js +12 -0
- package/dist/cli/command-guide.d.ts.map +1 -0
- package/dist/cli/error-map.d.ts +4 -0
- package/dist/cli/error-map.d.ts.map +1 -0
- package/dist/cli/error-map.js +42 -0
- package/dist/cli/exit-codes.d.ts +3 -0
- package/dist/cli/exit-codes.d.ts.map +1 -0
- package/dist/cli/exit-codes.js +12 -0
- package/dist/cli/help.d.ts.map +1 -0
- package/dist/{help.js → cli/help.js} +1 -1
- package/dist/cli/http.d.ts +4 -0
- package/dist/cli/http.d.ts.map +1 -0
- package/dist/cli/http.js +95 -0
- package/dist/cli/output.d.ts +16 -0
- package/dist/cli/output.d.ts.map +1 -0
- package/dist/cli/output.js +47 -0
- package/dist/cli/recipes-bundled.js +2 -2
- package/dist/cli/run-cli.d.ts.map +1 -0
- package/dist/cli/run-cli.js +2681 -0
- package/dist/{run-cli.test-helpers.d.ts → cli/run-cli.test-helpers.d.ts} +6 -0
- package/dist/cli/run-cli.test-helpers.d.ts.map +1 -0
- package/dist/{run-cli.test-helpers.js → cli/run-cli.test-helpers.js} +67 -0
- package/dist/cli/update-check.d.ts +31 -0
- package/dist/cli/update-check.d.ts.map +1 -0
- package/dist/cli/update-check.js +86 -0
- package/dist/cli.js +1 -1
- package/dist/commands/backend.d.ts +15 -0
- package/dist/commands/backend.d.ts.map +1 -0
- package/dist/commands/backend.js +211 -0
- package/dist/commands/recipes.d.ts +13 -0
- package/dist/commands/recipes.d.ts.map +1 -0
- package/dist/commands/recipes.js +1919 -0
- package/dist/commands/upgrade.d.ts +6 -0
- package/dist/commands/upgrade.d.ts.map +1 -0
- package/dist/commands/upgrade.js +291 -0
- package/dist/commands/workflow.d.ts +367 -0
- package/dist/commands/workflow.d.ts.map +1 -0
- package/dist/commands/workflow.js +4619 -0
- package/dist/meta/version.d.ts.map +1 -0
- package/dist/meta/version.js +20 -0
- package/dist/recipes/bundled-recipes.d.ts.map +1 -0
- package/dist/shared/comment-format.d.ts.map +1 -0
- package/dist/shared/env.d.ts.map +1 -0
- package/dist/{errors.d.ts → shared/errors.d.ts} +1 -3
- package/dist/shared/errors.d.ts.map +1 -0
- package/dist/{errors.js → shared/errors.js} +1 -3
- package/package.json +6 -2
- package/dist/agents-template.d.ts.map +0 -1
- package/dist/bundled-recipes.d.ts.map +0 -1
- package/dist/command-guide.d.ts.map +0 -1
- package/dist/comment-format.d.ts.map +0 -1
- package/dist/env.d.ts.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/help.d.ts.map +0 -1
- package/dist/run-cli.d.ts.map +0 -1
- package/dist/run-cli.js +0 -9454
- package/dist/run-cli.test-helpers.d.ts.map +0 -1
- package/dist/task-backend.d.ts.map +0 -1
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js +0 -3
- /package/dist/{agents-template.d.ts → agents/agents-template.d.ts} +0 -0
- /package/dist/{command-guide.d.ts → cli/command-guide.d.ts} +0 -0
- /package/dist/{command-guide.js → cli/command-guide.js} +0 -0
- /package/dist/{help.d.ts → cli/help.d.ts} +0 -0
- /package/dist/{run-cli.d.ts → cli/run-cli.d.ts} +0 -0
- /package/dist/{version.d.ts → meta/version.d.ts} +0 -0
- /package/dist/{bundled-recipes.d.ts → recipes/bundled-recipes.d.ts} +0 -0
- /package/dist/{bundled-recipes.js → recipes/bundled-recipes.js} +0 -0
- /package/dist/{comment-format.d.ts → shared/comment-format.d.ts} +0 -0
- /package/dist/{comment-format.js → shared/comment-format.js} +0 -0
- /package/dist/{env.d.ts → shared/env.d.ts} +0 -0
- /package/dist/{env.js → shared/env.js} +0 -0
|
@@ -0,0 +1,1919 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createPublicKey, verify } from "node:crypto";
|
|
3
|
+
import { cp, mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, writeFile, } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { resolveProject } from "@agentplaneorg/core";
|
|
8
|
+
import { extractArchive } from "../cli/archive.js";
|
|
9
|
+
import { sha256File } from "../cli/checksum.js";
|
|
10
|
+
import { mapCoreError } from "../cli/error-map.js";
|
|
11
|
+
import { fileExists, getPathKind } from "../cli/fs-utils.js";
|
|
12
|
+
import { downloadToFile, fetchJson, fetchText } from "../cli/http.js";
|
|
13
|
+
import { emptyStateMessage, infoMessage, invalidFieldMessage, invalidPathMessage, missingValueMessage, missingFileMessage, requiredFieldMessage, successMessage, usageMessage, } from "../cli/output.js";
|
|
14
|
+
import { CliError } from "../shared/errors.js";
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
const INSTALLED_RECIPES_NAME = "recipes.json";
|
|
17
|
+
const RECIPES_DIR_NAME = "recipes";
|
|
18
|
+
const RECIPES_SCENARIOS_DIR_NAME = "scenarios";
|
|
19
|
+
const RECIPES_SCENARIOS_INDEX_NAME = "scenarios.json";
|
|
20
|
+
const RECIPES_REMOTE_INDEX_NAME = "recipes-index.json";
|
|
21
|
+
const RECIPES_REMOTE_INDEX_SIG_NAME = "recipes-index.json.sig";
|
|
22
|
+
const RECIPE_USAGE = "Usage: agentplane recipes <list|info|explain|install|remove|list-remote|cache> [args]";
|
|
23
|
+
const RECIPE_USAGE_EXAMPLE = "agentplane recipes list";
|
|
24
|
+
const RECIPE_INFO_USAGE = "Usage: agentplane recipes info <id>";
|
|
25
|
+
const RECIPE_INFO_USAGE_EXAMPLE = "agentplane recipes info viewer";
|
|
26
|
+
const RECIPE_EXPLAIN_USAGE = "Usage: agentplane recipes explain <id>";
|
|
27
|
+
const RECIPE_EXPLAIN_USAGE_EXAMPLE = "agentplane recipes explain viewer";
|
|
28
|
+
const RECIPE_INSTALL_USAGE = "Usage: agentplane recipes install --name <id> [--index <path|url>] [--refresh] | --path <path> | --url <url>";
|
|
29
|
+
const RECIPE_INSTALL_USAGE_EXAMPLE = "agentplane recipes install --name viewer";
|
|
30
|
+
const RECIPE_REMOVE_USAGE = "Usage: agentplane recipes remove <id>";
|
|
31
|
+
const RECIPE_REMOVE_USAGE_EXAMPLE = "agentplane recipes remove viewer";
|
|
32
|
+
const RECIPE_CACHE_USAGE = "Usage: agentplane recipes cache <prune> [args]";
|
|
33
|
+
const RECIPE_CACHE_USAGE_EXAMPLE = "agentplane recipes cache prune --dry-run";
|
|
34
|
+
const RECIPE_CACHE_PRUNE_USAGE = "Usage: agentplane recipes cache prune [--dry-run] [--all]";
|
|
35
|
+
const RECIPE_CACHE_PRUNE_USAGE_EXAMPLE = "agentplane recipes cache prune --dry-run";
|
|
36
|
+
const RECIPE_LIST_REMOTE_USAGE = "Usage: agentplane recipes list-remote [--refresh] [--index <path|url>]";
|
|
37
|
+
const RECIPE_LIST_REMOTE_USAGE_EXAMPLE = "agentplane recipes list-remote --refresh";
|
|
38
|
+
const DEFAULT_RECIPES_INDEX_URL = "https://raw.githubusercontent.com/basilisk-labs/agentplane-recipes/main/index.json";
|
|
39
|
+
const RECIPES_INDEX_PUBLIC_KEYS_ENV = "AGENTPLANE_RECIPES_INDEX_PUBLIC_KEYS";
|
|
40
|
+
const RECIPES_INDEX_PUBLIC_KEYS = {
|
|
41
|
+
"2026-02": `-----BEGIN PUBLIC KEY-----
|
|
42
|
+
MCowBQYDK2VwAyEAeRWdXKVZtz0v+bnQS3zb24jMfa0gflsRUHQkeJkji6E=
|
|
43
|
+
-----END PUBLIC KEY-----`,
|
|
44
|
+
};
|
|
45
|
+
const RECIPE_CONFLICT_MODES = ["fail", "rename", "overwrite"];
|
|
46
|
+
const AGENTPLANE_HOME_ENV = "AGENTPLANE_HOME";
|
|
47
|
+
const GLOBAL_RECIPES_DIR_NAME = "recipes";
|
|
48
|
+
const PROJECT_RECIPES_CACHE_DIR_NAME = "recipes-cache";
|
|
49
|
+
const SCENARIO_USAGE = "Usage: agentplane scenario <list|info|run> [args]";
|
|
50
|
+
const SCENARIO_USAGE_EXAMPLE = "agentplane scenario list";
|
|
51
|
+
const SCENARIO_INFO_USAGE = "Usage: agentplane scenario info <recipe:scenario>";
|
|
52
|
+
const SCENARIO_INFO_USAGE_EXAMPLE = "agentplane scenario info viewer:demo";
|
|
53
|
+
const SCENARIO_RUN_USAGE = "Usage: agentplane scenario run <recipe:scenario>";
|
|
54
|
+
const SCENARIO_RUN_USAGE_EXAMPLE = "agentplane scenario run viewer:demo";
|
|
55
|
+
const SCENARIO_REPORT_NAME = "report.json";
|
|
56
|
+
const SENSITIVE_ARG_FLAGS = new Set([
|
|
57
|
+
"--token",
|
|
58
|
+
"--secret",
|
|
59
|
+
"--password",
|
|
60
|
+
"--api-key",
|
|
61
|
+
"--apikey",
|
|
62
|
+
"--access-key",
|
|
63
|
+
"--client-secret",
|
|
64
|
+
"--auth",
|
|
65
|
+
"--authorization",
|
|
66
|
+
"--bearer",
|
|
67
|
+
]);
|
|
68
|
+
function isRecord(value) {
|
|
69
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
70
|
+
}
|
|
71
|
+
function redactArgs(args) {
|
|
72
|
+
const out = [...args];
|
|
73
|
+
for (let i = 0; i < out.length; i++) {
|
|
74
|
+
const arg = out[i];
|
|
75
|
+
if (!arg)
|
|
76
|
+
continue;
|
|
77
|
+
const eqIndex = arg.indexOf("=");
|
|
78
|
+
const flag = eqIndex === -1 ? arg : arg.slice(0, eqIndex);
|
|
79
|
+
if (!SENSITIVE_ARG_FLAGS.has(flag))
|
|
80
|
+
continue;
|
|
81
|
+
if (eqIndex !== -1) {
|
|
82
|
+
out[i] = `${flag}=<redacted>`;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
out[i] = flag;
|
|
86
|
+
if (i + 1 < out.length && !out[i + 1]?.startsWith("-")) {
|
|
87
|
+
out[i + 1] = "<redacted>";
|
|
88
|
+
i += 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
function dedupeStrings(items) {
|
|
94
|
+
const seen = new Set();
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const item of items) {
|
|
97
|
+
const trimmed = item.trim();
|
|
98
|
+
if (!trimmed)
|
|
99
|
+
continue;
|
|
100
|
+
if (seen.has(trimmed))
|
|
101
|
+
continue;
|
|
102
|
+
seen.add(trimmed);
|
|
103
|
+
out.push(trimmed);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
async function resolvePathFallback(filePath) {
|
|
108
|
+
try {
|
|
109
|
+
return await realpath(filePath);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return path.resolve(filePath);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function isNotGitRepoError(err) {
|
|
116
|
+
if (err instanceof Error) {
|
|
117
|
+
return err.message.startsWith("Not a git repository");
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
async function getGitDiffSummary(cwd) {
|
|
122
|
+
try {
|
|
123
|
+
const [diff, staged, status] = await Promise.all([
|
|
124
|
+
execFileAsync("git", ["diff", "--stat"], { cwd }),
|
|
125
|
+
execFileAsync("git", ["diff", "--stat", "--staged"], { cwd }),
|
|
126
|
+
execFileAsync("git", ["status", "--porcelain"], { cwd }),
|
|
127
|
+
]);
|
|
128
|
+
const diffStat = diff.stdout.trim();
|
|
129
|
+
const stagedStat = staged.stdout.trim();
|
|
130
|
+
const statusLines = status.stdout.trim();
|
|
131
|
+
return {
|
|
132
|
+
diff_stat: diffStat || undefined,
|
|
133
|
+
staged_stat: stagedStat || undefined,
|
|
134
|
+
status: statusLines
|
|
135
|
+
? statusLines
|
|
136
|
+
.split("\n")
|
|
137
|
+
.map((line) => line.trim())
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
: [],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function collectScenarioEnvKeys(stepEnv) {
|
|
147
|
+
return dedupeStrings([
|
|
148
|
+
...Object.keys(stepEnv ?? {}),
|
|
149
|
+
"AGENTPLANE_RUN_DIR",
|
|
150
|
+
"AGENTPLANE_STEP_DIR",
|
|
151
|
+
"AGENTPLANE_RECIPES_CACHE_DIR",
|
|
152
|
+
"AGENTPLANE_RECIPE_ID",
|
|
153
|
+
"AGENTPLANE_SCENARIO_ID",
|
|
154
|
+
"AGENTPLANE_TOOL_ID",
|
|
155
|
+
]);
|
|
156
|
+
}
|
|
157
|
+
async function writeScenarioReport(opts) {
|
|
158
|
+
const report = {
|
|
159
|
+
schema_version: 1,
|
|
160
|
+
recipe: opts.recipeId,
|
|
161
|
+
scenario: opts.scenarioId,
|
|
162
|
+
run_id: opts.runId,
|
|
163
|
+
started_at: opts.startedAt,
|
|
164
|
+
ended_at: new Date().toISOString(),
|
|
165
|
+
status: opts.status,
|
|
166
|
+
steps: opts.steps,
|
|
167
|
+
git: opts.gitSummary,
|
|
168
|
+
};
|
|
169
|
+
await writeFile(path.join(opts.runDir, SCENARIO_REPORT_NAME), `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
170
|
+
}
|
|
171
|
+
async function maybeResolveProject(opts) {
|
|
172
|
+
try {
|
|
173
|
+
return await resolveProject({
|
|
174
|
+
cwd: opts.cwd,
|
|
175
|
+
rootOverride: opts.rootOverride ?? null,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
if (isNotGitRepoError(err)) {
|
|
180
|
+
if (opts.rootOverride)
|
|
181
|
+
throw err;
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function parseRecipeListRemoteFlags(args) {
|
|
188
|
+
const out = { refresh: false };
|
|
189
|
+
for (let i = 0; i < args.length; i++) {
|
|
190
|
+
const arg = args[i];
|
|
191
|
+
if (!arg)
|
|
192
|
+
continue;
|
|
193
|
+
if (!arg.startsWith("--")) {
|
|
194
|
+
throw new CliError({
|
|
195
|
+
exitCode: 2,
|
|
196
|
+
code: "E_USAGE",
|
|
197
|
+
message: usageMessage(RECIPE_LIST_REMOTE_USAGE, RECIPE_LIST_REMOTE_USAGE_EXAMPLE),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (arg === "--refresh") {
|
|
201
|
+
out.refresh = true;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const next = args[i + 1];
|
|
205
|
+
if (!next) {
|
|
206
|
+
throw new CliError({ exitCode: 2, code: "E_USAGE", message: missingValueMessage(arg) });
|
|
207
|
+
}
|
|
208
|
+
switch (arg) {
|
|
209
|
+
case "--index": {
|
|
210
|
+
out.index = next;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
default: {
|
|
214
|
+
throw new CliError({
|
|
215
|
+
exitCode: 2,
|
|
216
|
+
code: "E_USAGE",
|
|
217
|
+
message: usageMessage(RECIPE_LIST_REMOTE_USAGE, RECIPE_LIST_REMOTE_USAGE_EXAMPLE),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
i++;
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
function normalizeRecipeId(value) {
|
|
226
|
+
const trimmed = value.trim();
|
|
227
|
+
if (!trimmed)
|
|
228
|
+
throw new Error(requiredFieldMessage("manifest.id"));
|
|
229
|
+
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
|
230
|
+
throw new Error(invalidPathMessage("manifest.id", "must not contain path separators"));
|
|
231
|
+
}
|
|
232
|
+
if (trimmed === "." || trimmed === "..") {
|
|
233
|
+
throw new Error(invalidPathMessage("manifest.id", "must not be '.' or '..'"));
|
|
234
|
+
}
|
|
235
|
+
return trimmed;
|
|
236
|
+
}
|
|
237
|
+
function normalizeAgentId(value) {
|
|
238
|
+
const trimmed = value.trim();
|
|
239
|
+
if (!trimmed)
|
|
240
|
+
throw new Error(requiredFieldMessage("agent.id"));
|
|
241
|
+
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
|
242
|
+
throw new Error(invalidPathMessage("agent.id", "must not contain path separators"));
|
|
243
|
+
}
|
|
244
|
+
if (trimmed === "." || trimmed === "..") {
|
|
245
|
+
throw new Error(invalidPathMessage("agent.id", "must not be '.' or '..'"));
|
|
246
|
+
}
|
|
247
|
+
return trimmed;
|
|
248
|
+
}
|
|
249
|
+
function normalizeScenarioId(value) {
|
|
250
|
+
const trimmed = value.trim();
|
|
251
|
+
if (!trimmed)
|
|
252
|
+
throw new Error(requiredFieldMessage("scenario.id"));
|
|
253
|
+
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
|
254
|
+
throw new Error(invalidPathMessage("scenario.id", "must not contain path separators"));
|
|
255
|
+
}
|
|
256
|
+
if (trimmed === "." || trimmed === "..") {
|
|
257
|
+
throw new Error(invalidPathMessage("scenario.id", "must not be '.' or '..'"));
|
|
258
|
+
}
|
|
259
|
+
return trimmed;
|
|
260
|
+
}
|
|
261
|
+
function normalizeRecipeTags(value) {
|
|
262
|
+
if (value === undefined)
|
|
263
|
+
return [];
|
|
264
|
+
if (!Array.isArray(value))
|
|
265
|
+
throw new Error(invalidFieldMessage("manifest.tags", "string[]"));
|
|
266
|
+
const tags = value.map((tag) => {
|
|
267
|
+
if (typeof tag !== "string")
|
|
268
|
+
throw new Error(invalidFieldMessage("manifest.tags", "string[]"));
|
|
269
|
+
return tag.trim();
|
|
270
|
+
});
|
|
271
|
+
return dedupeStrings(tags);
|
|
272
|
+
}
|
|
273
|
+
function validateRecipeManifest(raw) {
|
|
274
|
+
if (!isRecord(raw))
|
|
275
|
+
throw new Error(invalidFieldMessage("manifest", "object"));
|
|
276
|
+
if (raw.schema_version !== "1")
|
|
277
|
+
throw new Error(invalidFieldMessage("manifest.schema_version", '"1"'));
|
|
278
|
+
if (typeof raw.id !== "string")
|
|
279
|
+
throw new Error(invalidFieldMessage("manifest.id", "string"));
|
|
280
|
+
if (typeof raw.version !== "string")
|
|
281
|
+
throw new Error(invalidFieldMessage("manifest.version", "string"));
|
|
282
|
+
if (typeof raw.name !== "string")
|
|
283
|
+
throw new Error(invalidFieldMessage("manifest.name", "string"));
|
|
284
|
+
if (typeof raw.summary !== "string")
|
|
285
|
+
throw new Error(invalidFieldMessage("manifest.summary", "string"));
|
|
286
|
+
if (typeof raw.description !== "string")
|
|
287
|
+
throw new Error(invalidFieldMessage("manifest.description", "string"));
|
|
288
|
+
const id = normalizeRecipeId(raw.id);
|
|
289
|
+
const version = raw.version.trim();
|
|
290
|
+
if (!version)
|
|
291
|
+
throw new Error(requiredFieldMessage("manifest.version"));
|
|
292
|
+
const tags = normalizeRecipeTags(raw.tags);
|
|
293
|
+
return {
|
|
294
|
+
schema_version: "1",
|
|
295
|
+
id,
|
|
296
|
+
version,
|
|
297
|
+
name: raw.name.trim(),
|
|
298
|
+
summary: raw.summary.trim(),
|
|
299
|
+
description: raw.description.trim(),
|
|
300
|
+
tags: tags.length > 0 ? tags : undefined,
|
|
301
|
+
agents: Array.isArray(raw.agents) ? raw.agents : undefined,
|
|
302
|
+
tools: Array.isArray(raw.tools) ? raw.tools : undefined,
|
|
303
|
+
scenarios: Array.isArray(raw.scenarios)
|
|
304
|
+
? raw.scenarios
|
|
305
|
+
: undefined,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function validateInstalledRecipesFile(raw) {
|
|
309
|
+
if (!isRecord(raw))
|
|
310
|
+
throw new Error(invalidFieldMessage("recipes.json", "object"));
|
|
311
|
+
if (raw.schema_version !== 1)
|
|
312
|
+
throw new Error(invalidFieldMessage("recipes.json.schema_version", "1"));
|
|
313
|
+
if (!Array.isArray(raw.recipes))
|
|
314
|
+
throw new Error(invalidFieldMessage("recipes.json.recipes", "array"));
|
|
315
|
+
const updatedAt = typeof raw.updated_at === "string" ? raw.updated_at : "";
|
|
316
|
+
const recipes = raw.recipes
|
|
317
|
+
.filter((entry) => isRecord(entry))
|
|
318
|
+
.map((entry) => {
|
|
319
|
+
const manifest = validateRecipeManifest(entry.manifest);
|
|
320
|
+
const id = typeof entry.id === "string" ? entry.id.trim() : manifest.id;
|
|
321
|
+
const version = typeof entry.version === "string" ? entry.version.trim() : manifest.version;
|
|
322
|
+
const source = typeof entry.source === "string" ? entry.source.trim() : "";
|
|
323
|
+
const installedAt = typeof entry.installed_at === "string" ? entry.installed_at.trim() : "";
|
|
324
|
+
if (!id || !version || !source || !installedAt) {
|
|
325
|
+
throw new Error(invalidFieldMessage("recipes.json.recipes[]", "id, version, source, installed_at"));
|
|
326
|
+
}
|
|
327
|
+
if (id !== manifest.id || version !== manifest.version) {
|
|
328
|
+
throw new Error(invalidFieldMessage("recipes.json.recipes[]", "id/version match manifest"));
|
|
329
|
+
}
|
|
330
|
+
const tags = normalizeRecipeTags(entry.tags ?? manifest.tags ?? []);
|
|
331
|
+
return { id, version, source, installed_at: installedAt, tags, manifest };
|
|
332
|
+
});
|
|
333
|
+
return { schema_version: 1, updated_at: updatedAt, recipes };
|
|
334
|
+
}
|
|
335
|
+
function sortInstalledRecipes(file) {
|
|
336
|
+
const recipes = [...file.recipes].toSorted((a, b) => a.id.localeCompare(b.id));
|
|
337
|
+
return { schema_version: 1, updated_at: file.updated_at, recipes };
|
|
338
|
+
}
|
|
339
|
+
function loadRecipesIndexPublicKeys() {
|
|
340
|
+
const raw = process.env[RECIPES_INDEX_PUBLIC_KEYS_ENV];
|
|
341
|
+
if (!raw)
|
|
342
|
+
return { ...RECIPES_INDEX_PUBLIC_KEYS };
|
|
343
|
+
try {
|
|
344
|
+
const parsed = JSON.parse(raw);
|
|
345
|
+
const merged = { ...RECIPES_INDEX_PUBLIC_KEYS };
|
|
346
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
347
|
+
if (typeof value === "string" && value.trim())
|
|
348
|
+
merged[key] = value.trim();
|
|
349
|
+
}
|
|
350
|
+
return merged;
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return { ...RECIPES_INDEX_PUBLIC_KEYS };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function validateRecipesIndexSignature(raw) {
|
|
357
|
+
if (!isRecord(raw))
|
|
358
|
+
throw new Error(invalidFieldMessage("recipes index signature", "object"));
|
|
359
|
+
if (raw.schema_version !== 1) {
|
|
360
|
+
throw new Error(invalidFieldMessage("recipes index signature.schema_version", "1"));
|
|
361
|
+
}
|
|
362
|
+
const keyId = typeof raw.key_id === "string" ? raw.key_id.trim() : "";
|
|
363
|
+
const signature = typeof raw.signature === "string" ? raw.signature.trim() : "";
|
|
364
|
+
if (!keyId || !signature) {
|
|
365
|
+
throw new Error(invalidFieldMessage("recipes index signature", "key_id, signature"));
|
|
366
|
+
}
|
|
367
|
+
const algorithm = typeof raw.algorithm === "string" ? raw.algorithm.trim() : undefined;
|
|
368
|
+
return { schema_version: 1, key_id: keyId, signature, algorithm };
|
|
369
|
+
}
|
|
370
|
+
function verifyRecipesIndexSignature(indexText, signature) {
|
|
371
|
+
if (signature.algorithm && signature.algorithm !== "ed25519") {
|
|
372
|
+
throw new Error(invalidFieldMessage("recipes index signature.algorithm", "ed25519"));
|
|
373
|
+
}
|
|
374
|
+
const keys = loadRecipesIndexPublicKeys();
|
|
375
|
+
const publicKey = keys[signature.key_id];
|
|
376
|
+
if (!publicKey) {
|
|
377
|
+
throw new Error(invalidFieldMessage("recipes index signature.key_id", "known key id"));
|
|
378
|
+
}
|
|
379
|
+
const key = createPublicKey(publicKey);
|
|
380
|
+
const ok = verify(null, Buffer.from(indexText), key, Buffer.from(signature.signature, "base64"));
|
|
381
|
+
if (!ok) {
|
|
382
|
+
throw new Error("Invalid signature for recipes index");
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function signatureSourceForIndex(source) {
|
|
386
|
+
return `${source}.sig`;
|
|
387
|
+
}
|
|
388
|
+
function validateRecipesIndex(raw) {
|
|
389
|
+
if (!isRecord(raw))
|
|
390
|
+
throw new Error(invalidFieldMessage("recipes index", "object"));
|
|
391
|
+
if (raw.schema_version !== 1)
|
|
392
|
+
throw new Error(invalidFieldMessage("recipes index.schema_version", "1"));
|
|
393
|
+
if (!Array.isArray(raw.recipes))
|
|
394
|
+
throw new Error(invalidFieldMessage("recipes index.recipes", "array"));
|
|
395
|
+
const recipes = raw.recipes
|
|
396
|
+
.filter((entry) => isRecord(entry))
|
|
397
|
+
.map((entry) => {
|
|
398
|
+
const id = typeof entry.id === "string" ? entry.id : "";
|
|
399
|
+
const summary = typeof entry.summary === "string" ? entry.summary : "";
|
|
400
|
+
const description = typeof entry.description === "string" ? entry.description : undefined;
|
|
401
|
+
const versionsRaw = Array.isArray(entry.versions) ? entry.versions : [];
|
|
402
|
+
if (!id || !summary || versionsRaw.length === 0) {
|
|
403
|
+
throw new Error(invalidFieldMessage("recipes index.recipes[]", "id, summary, versions"));
|
|
404
|
+
}
|
|
405
|
+
const versions = versionsRaw
|
|
406
|
+
.filter((version) => isRecord(version))
|
|
407
|
+
.map((version) => {
|
|
408
|
+
const versionId = typeof version.version === "string" ? version.version : "";
|
|
409
|
+
const url = typeof version.url === "string" ? version.url : "";
|
|
410
|
+
const sha256 = typeof version.sha256 === "string" ? version.sha256 : "";
|
|
411
|
+
if (!versionId || !url || !sha256) {
|
|
412
|
+
throw new Error(invalidFieldMessage("recipes index.recipes[].versions[]", "version, url, sha256"));
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
version: versionId,
|
|
416
|
+
url,
|
|
417
|
+
sha256,
|
|
418
|
+
min_agentplane_version: typeof version.min_agentplane_version === "string"
|
|
419
|
+
? version.min_agentplane_version
|
|
420
|
+
: undefined,
|
|
421
|
+
tags: Array.isArray(version.tags)
|
|
422
|
+
? version.tags.filter((tag) => typeof tag === "string")
|
|
423
|
+
: undefined,
|
|
424
|
+
};
|
|
425
|
+
});
|
|
426
|
+
return { id, summary, description, versions };
|
|
427
|
+
});
|
|
428
|
+
return { schema_version: 1, recipes };
|
|
429
|
+
}
|
|
430
|
+
function validateScenarioDefinition(raw, sourcePath) {
|
|
431
|
+
if (!isRecord(raw))
|
|
432
|
+
throw new Error(invalidFieldMessage("scenario", "object", sourcePath));
|
|
433
|
+
if (raw.schema_version !== undefined && raw.schema_version !== "1") {
|
|
434
|
+
throw new Error(invalidFieldMessage("scenario.schema_version", '"1"', sourcePath));
|
|
435
|
+
}
|
|
436
|
+
const rawId = typeof raw.id === "string" ? raw.id : "";
|
|
437
|
+
const id = normalizeScenarioId(rawId);
|
|
438
|
+
const goal = typeof raw.goal === "string" ? raw.goal.trim() : "";
|
|
439
|
+
if (!goal)
|
|
440
|
+
throw new Error(requiredFieldMessage("scenario.goal", sourcePath));
|
|
441
|
+
if (!("inputs" in raw))
|
|
442
|
+
throw new Error(requiredFieldMessage("scenario.inputs", sourcePath));
|
|
443
|
+
if (!("outputs" in raw))
|
|
444
|
+
throw new Error(requiredFieldMessage("scenario.outputs", sourcePath));
|
|
445
|
+
if (!Array.isArray(raw.steps)) {
|
|
446
|
+
throw new Error(invalidFieldMessage("scenario.steps", "array", sourcePath));
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
schema_version: "1",
|
|
450
|
+
id,
|
|
451
|
+
summary: typeof raw.summary === "string" ? raw.summary.trim() : undefined,
|
|
452
|
+
description: typeof raw.description === "string" ? raw.description.trim() : undefined,
|
|
453
|
+
goal,
|
|
454
|
+
inputs: raw.inputs,
|
|
455
|
+
outputs: raw.outputs,
|
|
456
|
+
steps: raw.steps,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
async function readScenarioDefinition(filePath) {
|
|
460
|
+
const raw = JSON.parse(await readFile(filePath, "utf8"));
|
|
461
|
+
return validateScenarioDefinition(raw, filePath);
|
|
462
|
+
}
|
|
463
|
+
async function readScenarioIndex(filePath) {
|
|
464
|
+
const raw = JSON.parse(await readFile(filePath, "utf8"));
|
|
465
|
+
if (!isRecord(raw))
|
|
466
|
+
throw new Error(invalidFieldMessage("scenarios index", "object"));
|
|
467
|
+
if (raw.schema_version !== 1)
|
|
468
|
+
throw new Error(invalidFieldMessage("scenarios index.schema_version", "1"));
|
|
469
|
+
if (!Array.isArray(raw.scenarios))
|
|
470
|
+
throw new Error(invalidFieldMessage("scenarios index.scenarios", "array"));
|
|
471
|
+
const scenarios = raw.scenarios
|
|
472
|
+
.filter((entry) => isRecord(entry))
|
|
473
|
+
.map((entry) => ({
|
|
474
|
+
id: typeof entry.id === "string" ? entry.id : "",
|
|
475
|
+
summary: typeof entry.summary === "string" ? entry.summary : undefined,
|
|
476
|
+
}))
|
|
477
|
+
.filter((entry) => entry.id);
|
|
478
|
+
return { schema_version: 1, scenarios };
|
|
479
|
+
}
|
|
480
|
+
function formatJsonBlock(value, indent) {
|
|
481
|
+
const payload = JSON.stringify(value, null, 2);
|
|
482
|
+
if (!payload)
|
|
483
|
+
return "";
|
|
484
|
+
return payload
|
|
485
|
+
.split("\n")
|
|
486
|
+
.map((line) => `${indent}${line}`)
|
|
487
|
+
.join("\n");
|
|
488
|
+
}
|
|
489
|
+
async function collectRecipeScenarioDetails(recipeDir, manifest) {
|
|
490
|
+
const scenariosDir = path.join(recipeDir, RECIPES_SCENARIOS_DIR_NAME);
|
|
491
|
+
if ((await getPathKind(scenariosDir)) === "dir") {
|
|
492
|
+
const files = await readdir(scenariosDir);
|
|
493
|
+
const jsonFiles = files.filter((file) => file.toLowerCase().endsWith(".json")).toSorted();
|
|
494
|
+
const details = [];
|
|
495
|
+
for (const file of jsonFiles) {
|
|
496
|
+
const scenario = await readScenarioDefinition(path.join(scenariosDir, file));
|
|
497
|
+
details.push({
|
|
498
|
+
id: scenario.id,
|
|
499
|
+
summary: scenario.summary,
|
|
500
|
+
description: scenario.description,
|
|
501
|
+
goal: scenario.goal,
|
|
502
|
+
inputs: scenario.inputs,
|
|
503
|
+
outputs: scenario.outputs,
|
|
504
|
+
steps: scenario.steps,
|
|
505
|
+
source: "definition",
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
return details.toSorted((a, b) => a.id.localeCompare(b.id));
|
|
509
|
+
}
|
|
510
|
+
const scenariosIndexPath = path.join(recipeDir, RECIPES_SCENARIOS_INDEX_NAME);
|
|
511
|
+
if (await fileExists(scenariosIndexPath)) {
|
|
512
|
+
const index = await readScenarioIndex(scenariosIndexPath);
|
|
513
|
+
return index.scenarios
|
|
514
|
+
.map((scenario) => ({
|
|
515
|
+
id: scenario.id,
|
|
516
|
+
summary: scenario.summary,
|
|
517
|
+
source: "index",
|
|
518
|
+
}))
|
|
519
|
+
.toSorted((a, b) => a.id.localeCompare(b.id));
|
|
520
|
+
}
|
|
521
|
+
const manifestScenarios = manifest.scenarios ?? [];
|
|
522
|
+
if (manifestScenarios.length > 0) {
|
|
523
|
+
return manifestScenarios
|
|
524
|
+
.map((scenario) => ({
|
|
525
|
+
id: scenario?.id ?? "",
|
|
526
|
+
summary: scenario?.summary,
|
|
527
|
+
source: "manifest",
|
|
528
|
+
}))
|
|
529
|
+
.filter((scenario) => scenario.id)
|
|
530
|
+
.toSorted((a, b) => a.id.localeCompare(b.id));
|
|
531
|
+
}
|
|
532
|
+
return [];
|
|
533
|
+
}
|
|
534
|
+
function normalizeScenarioToolStep(raw, sourcePath) {
|
|
535
|
+
if (!isRecord(raw)) {
|
|
536
|
+
throw new Error(invalidFieldMessage("scenario step", "object", sourcePath));
|
|
537
|
+
}
|
|
538
|
+
const tool = typeof raw.tool === "string" ? raw.tool.trim() : "";
|
|
539
|
+
if (!tool) {
|
|
540
|
+
throw new Error(requiredFieldMessage("scenario step.tool", sourcePath));
|
|
541
|
+
}
|
|
542
|
+
const args = Array.isArray(raw.args) ? raw.args.filter((arg) => typeof arg === "string") : [];
|
|
543
|
+
if (Array.isArray(raw.args) && args.length !== raw.args.length) {
|
|
544
|
+
throw new Error(invalidFieldMessage("scenario step.args", "string[]", sourcePath));
|
|
545
|
+
}
|
|
546
|
+
const env = {};
|
|
547
|
+
if (raw.env !== undefined) {
|
|
548
|
+
if (!isRecord(raw.env)) {
|
|
549
|
+
throw new Error(invalidFieldMessage("scenario step.env", "object", sourcePath));
|
|
550
|
+
}
|
|
551
|
+
for (const [key, value] of Object.entries(raw.env)) {
|
|
552
|
+
if (typeof value !== "string") {
|
|
553
|
+
throw new Error(invalidFieldMessage("scenario step.env", "string map", sourcePath));
|
|
554
|
+
}
|
|
555
|
+
env[key] = value;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return { tool, args, env };
|
|
559
|
+
}
|
|
560
|
+
async function readInstalledRecipesFile(filePath) {
|
|
561
|
+
try {
|
|
562
|
+
const raw = JSON.parse(await readFile(filePath, "utf8"));
|
|
563
|
+
return sortInstalledRecipes(validateInstalledRecipesFile(raw));
|
|
564
|
+
}
|
|
565
|
+
catch (err) {
|
|
566
|
+
const code = err?.code;
|
|
567
|
+
if (code === "ENOENT")
|
|
568
|
+
return { schema_version: 1, updated_at: "", recipes: [] };
|
|
569
|
+
throw err;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
async function writeInstalledRecipesFile(filePath, file) {
|
|
573
|
+
const sorted = sortInstalledRecipes({
|
|
574
|
+
...file,
|
|
575
|
+
updated_at: new Date().toISOString(),
|
|
576
|
+
});
|
|
577
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
578
|
+
await writeFile(filePath, `${JSON.stringify(sorted, null, 2)}\n`, "utf8");
|
|
579
|
+
}
|
|
580
|
+
async function readRecipeManifest(manifestPath) {
|
|
581
|
+
const raw = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
582
|
+
return validateRecipeManifest(raw);
|
|
583
|
+
}
|
|
584
|
+
async function resolveRecipeRoot(extractedDir) {
|
|
585
|
+
const rootManifest = path.join(extractedDir, "manifest.json");
|
|
586
|
+
if (await fileExists(rootManifest))
|
|
587
|
+
return extractedDir;
|
|
588
|
+
const entries = await readdir(extractedDir, { withFileTypes: true });
|
|
589
|
+
const dirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
590
|
+
if (dirs.length !== 1) {
|
|
591
|
+
throw new Error(missingFileMessage("manifest.json", "archive root"));
|
|
592
|
+
}
|
|
593
|
+
const candidate = path.join(extractedDir, dirs[0]);
|
|
594
|
+
if (!(await fileExists(path.join(candidate, "manifest.json")))) {
|
|
595
|
+
throw new Error(missingFileMessage("manifest.json", "archive root"));
|
|
596
|
+
}
|
|
597
|
+
return candidate;
|
|
598
|
+
}
|
|
599
|
+
function resolveAgentplaneHome() {
|
|
600
|
+
const overridden = process.env[AGENTPLANE_HOME_ENV]?.trim();
|
|
601
|
+
if (overridden)
|
|
602
|
+
return overridden;
|
|
603
|
+
return path.join(os.homedir(), ".agentplane");
|
|
604
|
+
}
|
|
605
|
+
function resolveGlobalRecipesDir() {
|
|
606
|
+
return path.join(resolveAgentplaneHome(), GLOBAL_RECIPES_DIR_NAME);
|
|
607
|
+
}
|
|
608
|
+
function resolveInstalledRecipesPath() {
|
|
609
|
+
return path.join(resolveAgentplaneHome(), INSTALLED_RECIPES_NAME);
|
|
610
|
+
}
|
|
611
|
+
function resolveRecipesIndexCachePath() {
|
|
612
|
+
return path.join(resolveAgentplaneHome(), RECIPES_REMOTE_INDEX_NAME);
|
|
613
|
+
}
|
|
614
|
+
function resolveInstalledRecipeDir(entry) {
|
|
615
|
+
return path.join(resolveGlobalRecipesDir(), entry.id, entry.version);
|
|
616
|
+
}
|
|
617
|
+
function resolveProjectRecipesCacheDir(resolved) {
|
|
618
|
+
return path.join(resolved.agentplaneDir, PROJECT_RECIPES_CACHE_DIR_NAME);
|
|
619
|
+
}
|
|
620
|
+
function parseRecipeInstallArgs(args) {
|
|
621
|
+
let onConflict = "fail";
|
|
622
|
+
let name = null;
|
|
623
|
+
let localPath = null;
|
|
624
|
+
let url = null;
|
|
625
|
+
let index;
|
|
626
|
+
let refresh = false;
|
|
627
|
+
const positional = [];
|
|
628
|
+
for (let i = 0; i < args.length; i++) {
|
|
629
|
+
const arg = args[i];
|
|
630
|
+
if (!arg)
|
|
631
|
+
continue;
|
|
632
|
+
if (arg === "--name") {
|
|
633
|
+
const next = args[i + 1];
|
|
634
|
+
if (!next)
|
|
635
|
+
throw new CliError({
|
|
636
|
+
exitCode: 2,
|
|
637
|
+
code: "E_USAGE",
|
|
638
|
+
message: usageMessage(RECIPE_INSTALL_USAGE, RECIPE_INSTALL_USAGE_EXAMPLE),
|
|
639
|
+
});
|
|
640
|
+
name = next;
|
|
641
|
+
i++;
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
if (arg === "--path") {
|
|
645
|
+
const next = args[i + 1];
|
|
646
|
+
if (!next)
|
|
647
|
+
throw new CliError({
|
|
648
|
+
exitCode: 2,
|
|
649
|
+
code: "E_USAGE",
|
|
650
|
+
message: usageMessage(RECIPE_INSTALL_USAGE, RECIPE_INSTALL_USAGE_EXAMPLE),
|
|
651
|
+
});
|
|
652
|
+
localPath = next;
|
|
653
|
+
i++;
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (arg === "--url") {
|
|
657
|
+
const next = args[i + 1];
|
|
658
|
+
if (!next)
|
|
659
|
+
throw new CliError({
|
|
660
|
+
exitCode: 2,
|
|
661
|
+
code: "E_USAGE",
|
|
662
|
+
message: usageMessage(RECIPE_INSTALL_USAGE, RECIPE_INSTALL_USAGE_EXAMPLE),
|
|
663
|
+
});
|
|
664
|
+
url = next;
|
|
665
|
+
i++;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (arg === "--index") {
|
|
669
|
+
const next = args[i + 1];
|
|
670
|
+
if (!next)
|
|
671
|
+
throw new CliError({
|
|
672
|
+
exitCode: 2,
|
|
673
|
+
code: "E_USAGE",
|
|
674
|
+
message: usageMessage(RECIPE_INSTALL_USAGE, RECIPE_INSTALL_USAGE_EXAMPLE),
|
|
675
|
+
});
|
|
676
|
+
index = next;
|
|
677
|
+
i++;
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
if (arg === "--refresh") {
|
|
681
|
+
refresh = true;
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
if (arg === "--on-conflict") {
|
|
685
|
+
const next = args[i + 1];
|
|
686
|
+
if (!next)
|
|
687
|
+
throw new CliError({
|
|
688
|
+
exitCode: 2,
|
|
689
|
+
code: "E_USAGE",
|
|
690
|
+
message: usageMessage(RECIPE_INSTALL_USAGE, RECIPE_INSTALL_USAGE_EXAMPLE),
|
|
691
|
+
});
|
|
692
|
+
if (!RECIPE_CONFLICT_MODES.includes(next)) {
|
|
693
|
+
throw new CliError({
|
|
694
|
+
exitCode: 2,
|
|
695
|
+
code: "E_USAGE",
|
|
696
|
+
message: usageMessage(RECIPE_INSTALL_USAGE, RECIPE_INSTALL_USAGE_EXAMPLE),
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
onConflict = next;
|
|
700
|
+
i++;
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
if (arg.startsWith("--")) {
|
|
704
|
+
throw new CliError({
|
|
705
|
+
exitCode: 2,
|
|
706
|
+
code: "E_USAGE",
|
|
707
|
+
message: usageMessage(RECIPE_INSTALL_USAGE, RECIPE_INSTALL_USAGE_EXAMPLE),
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
positional.push(arg);
|
|
711
|
+
}
|
|
712
|
+
const explicitFlags = [name, localPath, url].filter(Boolean).length;
|
|
713
|
+
if (explicitFlags > 1) {
|
|
714
|
+
throw new CliError({
|
|
715
|
+
exitCode: 2,
|
|
716
|
+
code: "E_USAGE",
|
|
717
|
+
message: usageMessage(RECIPE_INSTALL_USAGE, RECIPE_INSTALL_USAGE_EXAMPLE),
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
if (positional.length > 1) {
|
|
721
|
+
throw new CliError({
|
|
722
|
+
exitCode: 2,
|
|
723
|
+
code: "E_USAGE",
|
|
724
|
+
message: usageMessage(RECIPE_INSTALL_USAGE, RECIPE_INSTALL_USAGE_EXAMPLE),
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
if (positional.length > 0 && explicitFlags > 0) {
|
|
728
|
+
throw new CliError({
|
|
729
|
+
exitCode: 2,
|
|
730
|
+
code: "E_USAGE",
|
|
731
|
+
message: usageMessage(RECIPE_INSTALL_USAGE, RECIPE_INSTALL_USAGE_EXAMPLE),
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
if (name)
|
|
735
|
+
return { source: { type: "name", value: name }, index, refresh, onConflict };
|
|
736
|
+
if (localPath)
|
|
737
|
+
return { source: { type: "path", value: localPath }, index, refresh, onConflict };
|
|
738
|
+
if (url)
|
|
739
|
+
return { source: { type: "url", value: url }, index, refresh, onConflict };
|
|
740
|
+
if (positional.length === 1) {
|
|
741
|
+
return { source: { type: "auto", value: positional[0] }, index, refresh, onConflict };
|
|
742
|
+
}
|
|
743
|
+
throw new CliError({
|
|
744
|
+
exitCode: 2,
|
|
745
|
+
code: "E_USAGE",
|
|
746
|
+
message: usageMessage(RECIPE_INSTALL_USAGE, RECIPE_INSTALL_USAGE_EXAMPLE),
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
function parseRecipeListArgs(args) {
|
|
750
|
+
const flags = { full: false };
|
|
751
|
+
for (let i = 0; i < args.length; i++) {
|
|
752
|
+
const arg = args[i];
|
|
753
|
+
if (!arg)
|
|
754
|
+
continue;
|
|
755
|
+
if (arg === "--full") {
|
|
756
|
+
flags.full = true;
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
if (arg === "--tag") {
|
|
760
|
+
const next = args[i + 1];
|
|
761
|
+
if (!next)
|
|
762
|
+
throw new CliError({
|
|
763
|
+
exitCode: 2,
|
|
764
|
+
code: "E_USAGE",
|
|
765
|
+
message: usageMessage(RECIPE_USAGE, RECIPE_USAGE_EXAMPLE),
|
|
766
|
+
});
|
|
767
|
+
flags.tag = next.trim();
|
|
768
|
+
i++;
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
if (arg.startsWith("--")) {
|
|
772
|
+
throw new CliError({
|
|
773
|
+
exitCode: 2,
|
|
774
|
+
code: "E_USAGE",
|
|
775
|
+
message: usageMessage(RECIPE_USAGE, RECIPE_USAGE_EXAMPLE),
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
throw new CliError({
|
|
779
|
+
exitCode: 2,
|
|
780
|
+
code: "E_USAGE",
|
|
781
|
+
message: usageMessage(RECIPE_USAGE, RECIPE_USAGE_EXAMPLE),
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
if (flags.tag !== undefined && !flags.tag) {
|
|
785
|
+
throw new CliError({
|
|
786
|
+
exitCode: 2,
|
|
787
|
+
code: "E_USAGE",
|
|
788
|
+
message: usageMessage(RECIPE_USAGE, RECIPE_USAGE_EXAMPLE),
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
return flags;
|
|
792
|
+
}
|
|
793
|
+
function parseRecipeCachePruneArgs(args) {
|
|
794
|
+
const flags = { dryRun: false, all: false };
|
|
795
|
+
for (const arg of args) {
|
|
796
|
+
if (!arg)
|
|
797
|
+
continue;
|
|
798
|
+
if (arg === "--dry-run") {
|
|
799
|
+
flags.dryRun = true;
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
if (arg === "--all") {
|
|
803
|
+
flags.all = true;
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
throw new CliError({
|
|
807
|
+
exitCode: 2,
|
|
808
|
+
code: "E_USAGE",
|
|
809
|
+
message: usageMessage(RECIPE_CACHE_PRUNE_USAGE, RECIPE_CACHE_PRUNE_USAGE_EXAMPLE),
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
return flags;
|
|
813
|
+
}
|
|
814
|
+
async function applyRecipeAgents(opts) {
|
|
815
|
+
const agents = opts.manifest.agents ?? [];
|
|
816
|
+
if (agents.length === 0)
|
|
817
|
+
return;
|
|
818
|
+
const agentsDir = path.join(opts.agentplaneDir, "agents");
|
|
819
|
+
await mkdir(agentsDir, { recursive: true });
|
|
820
|
+
for (const agent of agents) {
|
|
821
|
+
const rawId = typeof agent?.id === "string" ? agent.id : "";
|
|
822
|
+
const rawFile = typeof agent?.file === "string" ? agent.file : "";
|
|
823
|
+
if (!rawId.trim() || !rawFile.trim()) {
|
|
824
|
+
throw new Error("manifest.agents entries must include id and file");
|
|
825
|
+
}
|
|
826
|
+
const agentId = normalizeAgentId(rawId);
|
|
827
|
+
const sourcePath = path.join(opts.recipeDir, rawFile);
|
|
828
|
+
if (!(await fileExists(sourcePath))) {
|
|
829
|
+
throw new Error(missingFileMessage("recipe agent file", rawFile));
|
|
830
|
+
}
|
|
831
|
+
const rawAgent = JSON.parse(await readFile(sourcePath, "utf8"));
|
|
832
|
+
if (!isRecord(rawAgent)) {
|
|
833
|
+
throw new Error(invalidFieldMessage("recipe agent file", "JSON object", rawFile));
|
|
834
|
+
}
|
|
835
|
+
const baseId = `${opts.manifest.id}__${agentId}`;
|
|
836
|
+
let targetId = baseId;
|
|
837
|
+
let targetPath = path.join(agentsDir, `${targetId}.json`);
|
|
838
|
+
if (await getPathKind(targetPath)) {
|
|
839
|
+
if (opts.onConflict === "fail") {
|
|
840
|
+
throw new CliError({
|
|
841
|
+
exitCode: 5,
|
|
842
|
+
code: "E_IO",
|
|
843
|
+
message: `Agent already exists: ${targetId}`,
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
if (opts.onConflict === "rename") {
|
|
847
|
+
let counter = 1;
|
|
848
|
+
while (await getPathKind(targetPath)) {
|
|
849
|
+
targetId = `${baseId}__${counter}`;
|
|
850
|
+
targetPath = path.join(agentsDir, `${targetId}.json`);
|
|
851
|
+
counter += 1;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
rawAgent.id = targetId;
|
|
856
|
+
await writeFile(targetPath, `${JSON.stringify(rawAgent, null, 2)}\n`, "utf8");
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
async function applyRecipeScenarios(opts) {
|
|
860
|
+
const scenariosDir = path.join(opts.recipeDir, RECIPES_SCENARIOS_DIR_NAME);
|
|
861
|
+
const scenariosIndexPath = path.join(opts.recipeDir, RECIPES_SCENARIOS_INDEX_NAME);
|
|
862
|
+
const payload = { schema_version: 1, scenarios: [] };
|
|
863
|
+
if ((await getPathKind(scenariosDir)) === "dir") {
|
|
864
|
+
const entries = await readdir(scenariosDir);
|
|
865
|
+
const jsonEntries = entries.filter((entry) => entry.toLowerCase().endsWith(".json")).toSorted();
|
|
866
|
+
for (const entry of jsonEntries) {
|
|
867
|
+
const scenarioPath = path.join(scenariosDir, entry);
|
|
868
|
+
const scenario = await readScenarioDefinition(scenarioPath);
|
|
869
|
+
payload.scenarios.push({ id: scenario.id, summary: scenario.summary });
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
else {
|
|
873
|
+
const scenarios = opts.manifest.scenarios ?? [];
|
|
874
|
+
payload.scenarios = scenarios
|
|
875
|
+
.filter((scenario) => isRecord(scenario))
|
|
876
|
+
.map((scenario) => ({
|
|
877
|
+
id: typeof scenario.id === "string" ? scenario.id : "",
|
|
878
|
+
summary: typeof scenario.summary === "string" ? scenario.summary : "",
|
|
879
|
+
}))
|
|
880
|
+
.filter((scenario) => scenario.id);
|
|
881
|
+
}
|
|
882
|
+
if (payload.scenarios.length === 0)
|
|
883
|
+
return;
|
|
884
|
+
await writeFile(scenariosIndexPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
885
|
+
}
|
|
886
|
+
function isHttpUrl(value) {
|
|
887
|
+
return value.startsWith("http://") || value.startsWith("https://");
|
|
888
|
+
}
|
|
889
|
+
async function readRecipesIndexText(source, cwd) {
|
|
890
|
+
if (isHttpUrl(source)) {
|
|
891
|
+
return await fetchText(source);
|
|
892
|
+
}
|
|
893
|
+
return await readFile(path.resolve(cwd, source), "utf8");
|
|
894
|
+
}
|
|
895
|
+
async function readRecipesIndexSignature(source, cwd) {
|
|
896
|
+
const sigSource = signatureSourceForIndex(source);
|
|
897
|
+
if (isHttpUrl(sigSource)) {
|
|
898
|
+
const raw = await fetchJson(sigSource);
|
|
899
|
+
return validateRecipesIndexSignature(raw);
|
|
900
|
+
}
|
|
901
|
+
const rawText = await readFile(path.resolve(cwd, sigSource), "utf8");
|
|
902
|
+
return validateRecipesIndexSignature(JSON.parse(rawText));
|
|
903
|
+
}
|
|
904
|
+
async function loadRecipesRemoteIndex(opts) {
|
|
905
|
+
const cachePath = resolveRecipesIndexCachePath();
|
|
906
|
+
const cacheSigPath = path.join(path.dirname(cachePath), RECIPES_REMOTE_INDEX_SIG_NAME);
|
|
907
|
+
const cacheDir = path.dirname(cachePath);
|
|
908
|
+
let rawIndex;
|
|
909
|
+
if (opts.refresh || !(await fileExists(cachePath))) {
|
|
910
|
+
const source = opts.source ?? DEFAULT_RECIPES_INDEX_URL;
|
|
911
|
+
const indexText = await readRecipesIndexText(source, opts.cwd);
|
|
912
|
+
const signature = await readRecipesIndexSignature(source, opts.cwd);
|
|
913
|
+
verifyRecipesIndexSignature(indexText, signature);
|
|
914
|
+
rawIndex = JSON.parse(indexText);
|
|
915
|
+
await mkdir(cacheDir, { recursive: true });
|
|
916
|
+
await writeFile(cachePath, indexText, "utf8");
|
|
917
|
+
await writeFile(cacheSigPath, `${JSON.stringify(signature, null, 2)}\n`, "utf8");
|
|
918
|
+
}
|
|
919
|
+
else {
|
|
920
|
+
const [indexText, sigText] = await Promise.all([
|
|
921
|
+
readFile(cachePath, "utf8"),
|
|
922
|
+
readFile(cacheSigPath, "utf8"),
|
|
923
|
+
]);
|
|
924
|
+
const signature = validateRecipesIndexSignature(JSON.parse(sigText));
|
|
925
|
+
verifyRecipesIndexSignature(indexText, signature);
|
|
926
|
+
rawIndex = JSON.parse(indexText);
|
|
927
|
+
}
|
|
928
|
+
return validateRecipesIndex(rawIndex);
|
|
929
|
+
}
|
|
930
|
+
async function cmdRecipeList(opts) {
|
|
931
|
+
try {
|
|
932
|
+
const flags = parseRecipeListArgs(opts.args);
|
|
933
|
+
const filePath = resolveInstalledRecipesPath();
|
|
934
|
+
const installed = await readInstalledRecipesFile(filePath);
|
|
935
|
+
let recipes = installed.recipes;
|
|
936
|
+
if (flags.tag) {
|
|
937
|
+
const needle = flags.tag.toLowerCase();
|
|
938
|
+
recipes = recipes.filter((entry) => entry.tags.some((tag) => tag.toLowerCase() === needle));
|
|
939
|
+
}
|
|
940
|
+
if (recipes.length === 0) {
|
|
941
|
+
if (flags.tag) {
|
|
942
|
+
process.stdout.write(`${emptyStateMessage(`installed recipes for tag ${flags.tag}`)}\n`);
|
|
943
|
+
return 0;
|
|
944
|
+
}
|
|
945
|
+
process.stdout.write(`${emptyStateMessage("installed recipes", "Use `agentplane recipes list-remote` or `agentplane recipes install <id>`.")}\n`);
|
|
946
|
+
return 0;
|
|
947
|
+
}
|
|
948
|
+
if (flags.full) {
|
|
949
|
+
const payload = {
|
|
950
|
+
schema_version: 1,
|
|
951
|
+
updated_at: installed.updated_at,
|
|
952
|
+
recipes,
|
|
953
|
+
};
|
|
954
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
955
|
+
return 0;
|
|
956
|
+
}
|
|
957
|
+
for (const entry of recipes) {
|
|
958
|
+
process.stdout.write(`${entry.id}@${entry.version} - ${entry.manifest.summary || "No summary"}\n`);
|
|
959
|
+
}
|
|
960
|
+
return 0;
|
|
961
|
+
}
|
|
962
|
+
catch (err) {
|
|
963
|
+
if (err instanceof CliError)
|
|
964
|
+
throw err;
|
|
965
|
+
throw mapCoreError(err, { command: "recipes list", root: opts.rootOverride ?? null });
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
async function cmdRecipeListRemote(opts) {
|
|
969
|
+
const flags = parseRecipeListRemoteFlags(opts.args);
|
|
970
|
+
try {
|
|
971
|
+
const index = await loadRecipesRemoteIndex({
|
|
972
|
+
cwd: opts.cwd,
|
|
973
|
+
source: flags.index,
|
|
974
|
+
refresh: flags.refresh,
|
|
975
|
+
});
|
|
976
|
+
for (const recipe of index.recipes) {
|
|
977
|
+
const latest = [...recipe.versions]
|
|
978
|
+
.toSorted((a, b) => a.version.localeCompare(b.version))
|
|
979
|
+
.at(-1);
|
|
980
|
+
if (!latest)
|
|
981
|
+
continue;
|
|
982
|
+
process.stdout.write(`${recipe.id}@${latest.version} - ${recipe.summary}\n`);
|
|
983
|
+
}
|
|
984
|
+
return 0;
|
|
985
|
+
}
|
|
986
|
+
catch (err) {
|
|
987
|
+
if (err instanceof CliError)
|
|
988
|
+
throw err;
|
|
989
|
+
throw mapCoreError(err, { command: "recipes list-remote", root: opts.rootOverride ?? null });
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
async function cmdScenarioList(opts) {
|
|
993
|
+
try {
|
|
994
|
+
const installed = await readInstalledRecipesFile(resolveInstalledRecipesPath());
|
|
995
|
+
const entries = [];
|
|
996
|
+
for (const recipe of installed.recipes) {
|
|
997
|
+
const recipeDir = resolveInstalledRecipeDir(recipe);
|
|
998
|
+
const scenariosDir = path.join(recipeDir, RECIPES_SCENARIOS_DIR_NAME);
|
|
999
|
+
if ((await getPathKind(scenariosDir)) === "dir") {
|
|
1000
|
+
const files = await readdir(scenariosDir);
|
|
1001
|
+
const jsonFiles = files.filter((entry) => entry.toLowerCase().endsWith(".json")).toSorted();
|
|
1002
|
+
for (const file of jsonFiles) {
|
|
1003
|
+
const scenario = await readScenarioDefinition(path.join(scenariosDir, file));
|
|
1004
|
+
entries.push({ recipeId: recipe.id, scenarioId: scenario.id, summary: scenario.summary });
|
|
1005
|
+
}
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
const scenariosIndexPath = path.join(recipeDir, RECIPES_SCENARIOS_INDEX_NAME);
|
|
1009
|
+
if (await fileExists(scenariosIndexPath)) {
|
|
1010
|
+
const index = await readScenarioIndex(scenariosIndexPath);
|
|
1011
|
+
for (const scenario of index.scenarios) {
|
|
1012
|
+
entries.push({
|
|
1013
|
+
recipeId: recipe.id,
|
|
1014
|
+
scenarioId: scenario.id,
|
|
1015
|
+
summary: scenario.summary,
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
if (entries.length === 0) {
|
|
1021
|
+
process.stdout.write(`${emptyStateMessage("scenarios", "Install a recipe to add scenarios.")}\n`);
|
|
1022
|
+
return 0;
|
|
1023
|
+
}
|
|
1024
|
+
const sorted = entries.toSorted((a, b) => {
|
|
1025
|
+
const byRecipe = a.recipeId.localeCompare(b.recipeId);
|
|
1026
|
+
if (byRecipe !== 0)
|
|
1027
|
+
return byRecipe;
|
|
1028
|
+
return a.scenarioId.localeCompare(b.scenarioId);
|
|
1029
|
+
});
|
|
1030
|
+
for (const entry of sorted) {
|
|
1031
|
+
process.stdout.write(`${entry.recipeId}:${entry.scenarioId} - ${entry.summary ?? "No summary"}\n`);
|
|
1032
|
+
}
|
|
1033
|
+
return 0;
|
|
1034
|
+
}
|
|
1035
|
+
catch (err) {
|
|
1036
|
+
if (err instanceof CliError)
|
|
1037
|
+
throw err;
|
|
1038
|
+
throw mapCoreError(err, { command: "scenario list", root: opts.rootOverride ?? null });
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
async function cmdScenarioInfo(opts) {
|
|
1042
|
+
try {
|
|
1043
|
+
const [recipeId, scenarioId] = opts.id.split(":");
|
|
1044
|
+
if (!recipeId || !scenarioId) {
|
|
1045
|
+
throw new CliError({
|
|
1046
|
+
exitCode: 2,
|
|
1047
|
+
code: "E_USAGE",
|
|
1048
|
+
message: usageMessage(SCENARIO_INFO_USAGE, SCENARIO_INFO_USAGE_EXAMPLE),
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
const installed = await readInstalledRecipesFile(resolveInstalledRecipesPath());
|
|
1052
|
+
const entry = installed.recipes.find((recipe) => recipe.id === recipeId);
|
|
1053
|
+
if (!entry) {
|
|
1054
|
+
throw new CliError({
|
|
1055
|
+
exitCode: 5,
|
|
1056
|
+
code: "E_IO",
|
|
1057
|
+
message: `Recipe not installed: ${recipeId}`,
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
const recipeDir = resolveInstalledRecipeDir(entry);
|
|
1061
|
+
const scenariosDir = path.join(recipeDir, RECIPES_SCENARIOS_DIR_NAME);
|
|
1062
|
+
let scenario = null;
|
|
1063
|
+
if ((await getPathKind(scenariosDir)) === "dir") {
|
|
1064
|
+
const files = await readdir(scenariosDir);
|
|
1065
|
+
const jsonFiles = files.filter((file) => file.toLowerCase().endsWith(".json")).toSorted();
|
|
1066
|
+
for (const file of jsonFiles) {
|
|
1067
|
+
const candidate = await readScenarioDefinition(path.join(scenariosDir, file));
|
|
1068
|
+
if (candidate.id === scenarioId) {
|
|
1069
|
+
scenario = candidate;
|
|
1070
|
+
break;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
let summary;
|
|
1075
|
+
if (!scenario) {
|
|
1076
|
+
const scenariosIndexPath = path.join(recipeDir, RECIPES_SCENARIOS_INDEX_NAME);
|
|
1077
|
+
if (await fileExists(scenariosIndexPath)) {
|
|
1078
|
+
const index = await readScenarioIndex(scenariosIndexPath);
|
|
1079
|
+
const entrySummary = index.scenarios.find((item) => item.id === scenarioId);
|
|
1080
|
+
summary = entrySummary?.summary;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
if (!scenario && !summary) {
|
|
1084
|
+
throw new CliError({
|
|
1085
|
+
exitCode: 5,
|
|
1086
|
+
code: "E_IO",
|
|
1087
|
+
message: `Scenario not found: ${recipeId}:${scenarioId}`,
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
process.stdout.write(`Scenario: ${recipeId}:${scenarioId}\n`);
|
|
1091
|
+
if (summary)
|
|
1092
|
+
process.stdout.write(`Summary: ${summary}\n`);
|
|
1093
|
+
if (!scenario) {
|
|
1094
|
+
process.stdout.write("Details: Scenario definition not found in recipe.\n");
|
|
1095
|
+
return 0;
|
|
1096
|
+
}
|
|
1097
|
+
if (scenario.summary)
|
|
1098
|
+
process.stdout.write(`Summary: ${scenario.summary}\n`);
|
|
1099
|
+
if (scenario.description)
|
|
1100
|
+
process.stdout.write(`Description: ${scenario.description}\n`);
|
|
1101
|
+
process.stdout.write(`Goal: ${scenario.goal}\n`);
|
|
1102
|
+
process.stdout.write(`Inputs: ${JSON.stringify(scenario.inputs, null, 2)}\n`);
|
|
1103
|
+
process.stdout.write(`Outputs: ${JSON.stringify(scenario.outputs, null, 2)}\n`);
|
|
1104
|
+
process.stdout.write("Steps:\n");
|
|
1105
|
+
let stepIndex = 1;
|
|
1106
|
+
for (const step of scenario.steps) {
|
|
1107
|
+
process.stdout.write(` ${stepIndex}. ${JSON.stringify(step)}\n`);
|
|
1108
|
+
stepIndex += 1;
|
|
1109
|
+
}
|
|
1110
|
+
return 0;
|
|
1111
|
+
}
|
|
1112
|
+
catch (err) {
|
|
1113
|
+
if (err instanceof CliError)
|
|
1114
|
+
throw err;
|
|
1115
|
+
throw mapCoreError(err, { command: "scenario info", root: opts.rootOverride ?? null });
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
async function executeRecipeTool(opts) {
|
|
1119
|
+
try {
|
|
1120
|
+
const command = opts.runtime === "node" ? "node" : "bash";
|
|
1121
|
+
const { stdout, stderr } = await execFileAsync(command, [opts.entrypoint, ...opts.args], {
|
|
1122
|
+
cwd: opts.cwd,
|
|
1123
|
+
env: opts.env,
|
|
1124
|
+
});
|
|
1125
|
+
return { exitCode: 0, stdout: String(stdout), stderr: String(stderr) };
|
|
1126
|
+
}
|
|
1127
|
+
catch (err) {
|
|
1128
|
+
let execErr = null;
|
|
1129
|
+
if (err && typeof err === "object") {
|
|
1130
|
+
execErr = err;
|
|
1131
|
+
}
|
|
1132
|
+
const exitCode = typeof execErr?.code === "number" ? execErr.code : 1;
|
|
1133
|
+
return {
|
|
1134
|
+
exitCode,
|
|
1135
|
+
stdout: String(execErr?.stdout ?? ""),
|
|
1136
|
+
stderr: String(execErr?.stderr ?? ""),
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
function sanitizeRunId(value) {
|
|
1141
|
+
return value.replaceAll(/[^a-zA-Z0-9._-]/g, "_");
|
|
1142
|
+
}
|
|
1143
|
+
async function cmdScenarioRun(opts) {
|
|
1144
|
+
try {
|
|
1145
|
+
const resolved = await resolveProject({
|
|
1146
|
+
cwd: opts.cwd,
|
|
1147
|
+
rootOverride: opts.rootOverride ?? null,
|
|
1148
|
+
});
|
|
1149
|
+
const [recipeId, scenarioId] = opts.id.split(":");
|
|
1150
|
+
if (!recipeId || !scenarioId) {
|
|
1151
|
+
throw new CliError({
|
|
1152
|
+
exitCode: 2,
|
|
1153
|
+
code: "E_USAGE",
|
|
1154
|
+
message: usageMessage(SCENARIO_RUN_USAGE, SCENARIO_RUN_USAGE_EXAMPLE),
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
const installed = await readInstalledRecipesFile(resolveInstalledRecipesPath());
|
|
1158
|
+
const entry = installed.recipes.find((recipe) => recipe.id === recipeId);
|
|
1159
|
+
if (!entry) {
|
|
1160
|
+
throw new CliError({
|
|
1161
|
+
exitCode: 5,
|
|
1162
|
+
code: "E_IO",
|
|
1163
|
+
message: `Recipe not installed: ${recipeId}`,
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
const recipeDir = resolveInstalledRecipeDir(entry);
|
|
1167
|
+
const manifestPath = path.join(recipeDir, "manifest.json");
|
|
1168
|
+
const manifest = await readRecipeManifest(manifestPath);
|
|
1169
|
+
const scenariosDir = path.join(recipeDir, RECIPES_SCENARIOS_DIR_NAME);
|
|
1170
|
+
if ((await getPathKind(scenariosDir)) !== "dir") {
|
|
1171
|
+
throw new CliError({
|
|
1172
|
+
exitCode: 5,
|
|
1173
|
+
code: "E_IO",
|
|
1174
|
+
message: `Scenario definitions not found for recipe: ${recipeId}`,
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
let scenario = null;
|
|
1178
|
+
const files = await readdir(scenariosDir);
|
|
1179
|
+
const jsonFiles = files.filter((file) => file.toLowerCase().endsWith(".json")).toSorted();
|
|
1180
|
+
for (const file of jsonFiles) {
|
|
1181
|
+
const candidate = await readScenarioDefinition(path.join(scenariosDir, file));
|
|
1182
|
+
if (candidate.id === scenarioId) {
|
|
1183
|
+
scenario = candidate;
|
|
1184
|
+
break;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
if (!scenario) {
|
|
1188
|
+
throw new CliError({
|
|
1189
|
+
exitCode: 5,
|
|
1190
|
+
code: "E_IO",
|
|
1191
|
+
message: `Scenario not found: ${recipeId}:${scenarioId}`,
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
const runsRoot = path.join(resolved.agentplaneDir, RECIPES_DIR_NAME, recipeId, "runs");
|
|
1195
|
+
await mkdir(runsRoot, { recursive: true });
|
|
1196
|
+
const recipesCacheDir = resolveProjectRecipesCacheDir(resolved);
|
|
1197
|
+
await mkdir(recipesCacheDir, { recursive: true });
|
|
1198
|
+
const runStartedAt = new Date().toISOString();
|
|
1199
|
+
const runId = `${new Date()
|
|
1200
|
+
.toISOString()
|
|
1201
|
+
.replaceAll(":", "-")
|
|
1202
|
+
.replaceAll(".", "-")}-${sanitizeRunId(scenarioId)}`;
|
|
1203
|
+
const runDir = path.join(runsRoot, runId);
|
|
1204
|
+
await mkdir(runDir, { recursive: true });
|
|
1205
|
+
const stepsMeta = [];
|
|
1206
|
+
const stepsReport = [];
|
|
1207
|
+
for (let index = 0; index < scenario.steps.length; index++) {
|
|
1208
|
+
const step = normalizeScenarioToolStep(scenario.steps[index], `${recipeId}:${scenarioId}`);
|
|
1209
|
+
const toolEntry = manifest.tools?.find((tool) => tool?.id === step.tool);
|
|
1210
|
+
if (!toolEntry) {
|
|
1211
|
+
throw new CliError({
|
|
1212
|
+
exitCode: 5,
|
|
1213
|
+
code: "E_IO",
|
|
1214
|
+
message: `Tool not found in recipe manifest: ${step.tool}`,
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
const runtime = toolEntry.runtime === "node" || toolEntry.runtime === "bash" ? toolEntry.runtime : "";
|
|
1218
|
+
const entrypoint = typeof toolEntry.entrypoint === "string" ? toolEntry.entrypoint : "";
|
|
1219
|
+
if (!runtime || !entrypoint) {
|
|
1220
|
+
throw new CliError({
|
|
1221
|
+
exitCode: 3,
|
|
1222
|
+
code: "E_VALIDATION",
|
|
1223
|
+
message: `Tool entry is missing runtime/entrypoint: ${step.tool}`,
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
if (Array.isArray(toolEntry.permissions) && toolEntry.permissions.length > 0) {
|
|
1227
|
+
process.stdout.write(`Warning: tool ${toolEntry.id} declares permissions: ${toolEntry.permissions.join(", ")}\n`);
|
|
1228
|
+
}
|
|
1229
|
+
const entrypointPath = path.join(recipeDir, entrypoint);
|
|
1230
|
+
if (!(await fileExists(entrypointPath))) {
|
|
1231
|
+
throw new CliError({
|
|
1232
|
+
exitCode: 5,
|
|
1233
|
+
code: "E_IO",
|
|
1234
|
+
message: `Tool entrypoint not found: ${entrypoint}`,
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
const stepDir = path.join(runDir, `step-${index + 1}-${sanitizeRunId(step.tool)}`);
|
|
1238
|
+
await mkdir(stepDir, { recursive: true });
|
|
1239
|
+
const stepEnvKeys = collectScenarioEnvKeys(step.env);
|
|
1240
|
+
const env = {
|
|
1241
|
+
...process.env,
|
|
1242
|
+
...step.env,
|
|
1243
|
+
AGENTPLANE_RUN_DIR: runDir,
|
|
1244
|
+
AGENTPLANE_STEP_DIR: stepDir,
|
|
1245
|
+
AGENTPLANE_RECIPES_CACHE_DIR: recipesCacheDir,
|
|
1246
|
+
AGENTPLANE_RECIPE_ID: recipeId,
|
|
1247
|
+
AGENTPLANE_SCENARIO_ID: scenarioId,
|
|
1248
|
+
AGENTPLANE_TOOL_ID: step.tool,
|
|
1249
|
+
};
|
|
1250
|
+
const startedAt = Date.now();
|
|
1251
|
+
const result = await executeRecipeTool({
|
|
1252
|
+
runtime,
|
|
1253
|
+
entrypoint: entrypointPath,
|
|
1254
|
+
args: step.args,
|
|
1255
|
+
cwd: recipeDir,
|
|
1256
|
+
env,
|
|
1257
|
+
});
|
|
1258
|
+
const durationMs = Date.now() - startedAt;
|
|
1259
|
+
await writeFile(path.join(stepDir, "stdout.log"), result.stdout, "utf8");
|
|
1260
|
+
await writeFile(path.join(stepDir, "stderr.log"), result.stderr, "utf8");
|
|
1261
|
+
stepsMeta.push({
|
|
1262
|
+
tool: step.tool,
|
|
1263
|
+
runtime,
|
|
1264
|
+
entrypoint,
|
|
1265
|
+
exitCode: result.exitCode,
|
|
1266
|
+
duration_ms: durationMs,
|
|
1267
|
+
});
|
|
1268
|
+
stepsReport.push({
|
|
1269
|
+
step: index + 1,
|
|
1270
|
+
tool: step.tool,
|
|
1271
|
+
runtime,
|
|
1272
|
+
entrypoint,
|
|
1273
|
+
args: redactArgs(step.args),
|
|
1274
|
+
env_keys: stepEnvKeys,
|
|
1275
|
+
exit_code: result.exitCode,
|
|
1276
|
+
duration_ms: durationMs,
|
|
1277
|
+
});
|
|
1278
|
+
if (result.exitCode !== 0) {
|
|
1279
|
+
const gitSummary = await getGitDiffSummary(resolved.gitRoot);
|
|
1280
|
+
await writeScenarioReport({
|
|
1281
|
+
runDir,
|
|
1282
|
+
recipeId,
|
|
1283
|
+
scenarioId,
|
|
1284
|
+
runId,
|
|
1285
|
+
startedAt: runStartedAt,
|
|
1286
|
+
status: "failed",
|
|
1287
|
+
steps: stepsReport,
|
|
1288
|
+
gitSummary,
|
|
1289
|
+
});
|
|
1290
|
+
await writeFile(path.join(runDir, "meta.json"), `${JSON.stringify({
|
|
1291
|
+
recipe: recipeId,
|
|
1292
|
+
scenario: scenarioId,
|
|
1293
|
+
run_id: runId,
|
|
1294
|
+
steps: stepsMeta,
|
|
1295
|
+
}, null, 2)}\n`, "utf8");
|
|
1296
|
+
throw new CliError({
|
|
1297
|
+
exitCode: result.exitCode,
|
|
1298
|
+
code: "E_INTERNAL",
|
|
1299
|
+
message: `Scenario step failed: ${step.tool}`,
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
const gitSummary = await getGitDiffSummary(resolved.gitRoot);
|
|
1304
|
+
await writeScenarioReport({
|
|
1305
|
+
runDir,
|
|
1306
|
+
recipeId,
|
|
1307
|
+
scenarioId,
|
|
1308
|
+
runId,
|
|
1309
|
+
startedAt: runStartedAt,
|
|
1310
|
+
status: "success",
|
|
1311
|
+
steps: stepsReport,
|
|
1312
|
+
gitSummary,
|
|
1313
|
+
});
|
|
1314
|
+
await writeFile(path.join(runDir, "meta.json"), `${JSON.stringify({
|
|
1315
|
+
recipe: recipeId,
|
|
1316
|
+
scenario: scenarioId,
|
|
1317
|
+
run_id: runId,
|
|
1318
|
+
steps: stepsMeta,
|
|
1319
|
+
}, null, 2)}\n`, "utf8");
|
|
1320
|
+
process.stdout.write(`Run artifacts: ${path.relative(resolved.gitRoot, runDir)}\n`);
|
|
1321
|
+
return 0;
|
|
1322
|
+
}
|
|
1323
|
+
catch (err) {
|
|
1324
|
+
if (err instanceof CliError)
|
|
1325
|
+
throw err;
|
|
1326
|
+
throw mapCoreError(err, { command: "scenario run", root: opts.rootOverride ?? null });
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
async function cmdRecipeInfo(opts) {
|
|
1330
|
+
try {
|
|
1331
|
+
const installed = await readInstalledRecipesFile(resolveInstalledRecipesPath());
|
|
1332
|
+
const entry = installed.recipes.find((recipe) => recipe.id === opts.id);
|
|
1333
|
+
if (!entry) {
|
|
1334
|
+
throw new CliError({
|
|
1335
|
+
exitCode: 5,
|
|
1336
|
+
code: "E_IO",
|
|
1337
|
+
message: `Recipe not installed: ${opts.id}`,
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
const manifest = entry.manifest;
|
|
1341
|
+
process.stdout.write(`Recipe: ${manifest.id}@${manifest.version}\n`);
|
|
1342
|
+
process.stdout.write(`Name: ${manifest.name}\n`);
|
|
1343
|
+
process.stdout.write(`Summary: ${manifest.summary}\n`);
|
|
1344
|
+
process.stdout.write(`Description: ${manifest.description}\n`);
|
|
1345
|
+
if (manifest.tags && manifest.tags.length > 0) {
|
|
1346
|
+
process.stdout.write(`Tags: ${manifest.tags.join(", ")}\n`);
|
|
1347
|
+
}
|
|
1348
|
+
const agents = manifest.agents ?? [];
|
|
1349
|
+
const tools = manifest.tools ?? [];
|
|
1350
|
+
const scenarios = manifest.scenarios ?? [];
|
|
1351
|
+
if (agents.length > 0) {
|
|
1352
|
+
process.stdout.write("Agents:\n");
|
|
1353
|
+
for (const agent of agents) {
|
|
1354
|
+
const label = agent?.id ?? "unknown";
|
|
1355
|
+
const summary = agent?.summary ? ` - ${agent.summary}` : "";
|
|
1356
|
+
process.stdout.write(` - ${label}${summary}\n`);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
if (tools.length > 0) {
|
|
1360
|
+
process.stdout.write("Tools:\n");
|
|
1361
|
+
for (const tool of tools) {
|
|
1362
|
+
const label = tool?.id ?? "unknown";
|
|
1363
|
+
const summary = tool?.summary ? ` - ${tool.summary}` : "";
|
|
1364
|
+
process.stdout.write(` - ${label}${summary}\n`);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
if (scenarios.length > 0) {
|
|
1368
|
+
process.stdout.write("Scenarios:\n");
|
|
1369
|
+
for (const scenario of scenarios) {
|
|
1370
|
+
const label = scenario?.id ?? "unknown";
|
|
1371
|
+
const summary = scenario?.summary ? ` - ${scenario.summary}` : "";
|
|
1372
|
+
process.stdout.write(` - ${label}${summary}\n`);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
return 0;
|
|
1376
|
+
}
|
|
1377
|
+
catch (err) {
|
|
1378
|
+
if (err instanceof CliError)
|
|
1379
|
+
throw err;
|
|
1380
|
+
throw mapCoreError(err, { command: "recipes info", root: opts.rootOverride ?? null });
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
async function cmdRecipeExplain(opts) {
|
|
1384
|
+
try {
|
|
1385
|
+
const installed = await readInstalledRecipesFile(resolveInstalledRecipesPath());
|
|
1386
|
+
const entry = installed.recipes.find((recipe) => recipe.id === opts.id);
|
|
1387
|
+
if (!entry) {
|
|
1388
|
+
throw new CliError({
|
|
1389
|
+
exitCode: 5,
|
|
1390
|
+
code: "E_IO",
|
|
1391
|
+
message: `Recipe not installed: ${opts.id}`,
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
const manifest = entry.manifest;
|
|
1395
|
+
const recipeDir = resolveInstalledRecipeDir(entry);
|
|
1396
|
+
const scenarioDetails = await collectRecipeScenarioDetails(recipeDir, manifest);
|
|
1397
|
+
process.stdout.write(`Recipe: ${manifest.id}@${manifest.version}\n`);
|
|
1398
|
+
process.stdout.write(`Name: ${manifest.name}\n`);
|
|
1399
|
+
process.stdout.write(`Summary: ${manifest.summary}\n`);
|
|
1400
|
+
process.stdout.write(`Description: ${manifest.description}\n`);
|
|
1401
|
+
if (manifest.tags && manifest.tags.length > 0) {
|
|
1402
|
+
process.stdout.write(`Tags: ${manifest.tags.join(", ")}\n`);
|
|
1403
|
+
}
|
|
1404
|
+
const agents = manifest.agents ?? [];
|
|
1405
|
+
const tools = manifest.tools ?? [];
|
|
1406
|
+
if (agents.length > 0) {
|
|
1407
|
+
process.stdout.write("Agents:\n");
|
|
1408
|
+
for (const agent of agents) {
|
|
1409
|
+
const label = agent?.id ?? "unknown";
|
|
1410
|
+
const summary = agent?.summary ? ` - ${agent.summary}` : "";
|
|
1411
|
+
process.stdout.write(` - ${label}${summary}\n`);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
if (tools.length > 0) {
|
|
1415
|
+
process.stdout.write("Tools:\n");
|
|
1416
|
+
for (const tool of tools) {
|
|
1417
|
+
const label = tool?.id ?? "unknown";
|
|
1418
|
+
const summary = tool?.summary ? ` - ${tool.summary}` : "";
|
|
1419
|
+
process.stdout.write(` - ${label}${summary}\n`);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
if (scenarioDetails.length > 0) {
|
|
1423
|
+
process.stdout.write("Scenarios:\n");
|
|
1424
|
+
for (const scenario of scenarioDetails) {
|
|
1425
|
+
const summary = scenario.summary ? ` - ${scenario.summary}` : "";
|
|
1426
|
+
process.stdout.write(` - ${scenario.id}${summary}\n`);
|
|
1427
|
+
if (scenario.description) {
|
|
1428
|
+
process.stdout.write(` Description: ${scenario.description}\n`);
|
|
1429
|
+
}
|
|
1430
|
+
if (scenario.goal) {
|
|
1431
|
+
process.stdout.write(` Goal: ${scenario.goal}\n`);
|
|
1432
|
+
}
|
|
1433
|
+
if (scenario.inputs !== undefined) {
|
|
1434
|
+
const payload = formatJsonBlock(scenario.inputs, " ");
|
|
1435
|
+
if (payload)
|
|
1436
|
+
process.stdout.write(` Inputs:\n${payload}\n`);
|
|
1437
|
+
}
|
|
1438
|
+
if (scenario.outputs !== undefined) {
|
|
1439
|
+
const payload = formatJsonBlock(scenario.outputs, " ");
|
|
1440
|
+
if (payload)
|
|
1441
|
+
process.stdout.write(` Outputs:\n${payload}\n`);
|
|
1442
|
+
}
|
|
1443
|
+
if (scenario.steps && scenario.steps.length > 0) {
|
|
1444
|
+
process.stdout.write(" Steps:\n");
|
|
1445
|
+
let stepIndex = 1;
|
|
1446
|
+
for (const step of scenario.steps) {
|
|
1447
|
+
process.stdout.write(` ${stepIndex}. ${JSON.stringify(step)}\n`);
|
|
1448
|
+
stepIndex += 1;
|
|
1449
|
+
}
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
if (scenario.source !== "definition") {
|
|
1453
|
+
process.stdout.write(" Details: Scenario definition not found in recipe.\n");
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
return 0;
|
|
1458
|
+
}
|
|
1459
|
+
catch (err) {
|
|
1460
|
+
if (err instanceof CliError)
|
|
1461
|
+
throw err;
|
|
1462
|
+
throw mapCoreError(err, { command: "recipes explain", root: opts.rootOverride ?? null });
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
async function cmdRecipeInstall(opts) {
|
|
1466
|
+
try {
|
|
1467
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "agentplane-recipe-"));
|
|
1468
|
+
try {
|
|
1469
|
+
let sourcePath = "";
|
|
1470
|
+
let sourceLabel = "";
|
|
1471
|
+
let expectedSha = "";
|
|
1472
|
+
let indexTags = [];
|
|
1473
|
+
const resolveFromIndex = async (recipeId) => {
|
|
1474
|
+
const index = await loadRecipesRemoteIndex({
|
|
1475
|
+
cwd: opts.cwd,
|
|
1476
|
+
source: opts.index,
|
|
1477
|
+
refresh: opts.refresh,
|
|
1478
|
+
});
|
|
1479
|
+
const entry = index.recipes.find((recipe) => recipe.id === recipeId);
|
|
1480
|
+
if (!entry) {
|
|
1481
|
+
throw new CliError({
|
|
1482
|
+
exitCode: 5,
|
|
1483
|
+
code: "E_IO",
|
|
1484
|
+
message: `Recipe not found in remote index: ${recipeId}`,
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
const latest = [...entry.versions]
|
|
1488
|
+
.toSorted((a, b) => a.version.localeCompare(b.version))
|
|
1489
|
+
.at(-1);
|
|
1490
|
+
if (!latest) {
|
|
1491
|
+
throw new CliError({
|
|
1492
|
+
exitCode: 3,
|
|
1493
|
+
code: "E_VALIDATION",
|
|
1494
|
+
message: `Recipe ${entry.id} has no versions in the remote index`,
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
expectedSha = latest.sha256;
|
|
1498
|
+
sourceLabel = `${entry.id}@${latest.version}`;
|
|
1499
|
+
indexTags = normalizeRecipeTags(latest.tags ?? []);
|
|
1500
|
+
if (isHttpUrl(latest.url)) {
|
|
1501
|
+
const url = new URL(latest.url);
|
|
1502
|
+
const filename = path.basename(url.pathname) || "recipe.tar.gz";
|
|
1503
|
+
const target = path.join(tempRoot, filename);
|
|
1504
|
+
await downloadToFile(latest.url, target);
|
|
1505
|
+
return target;
|
|
1506
|
+
}
|
|
1507
|
+
const resolved = path.resolve(opts.cwd, latest.url);
|
|
1508
|
+
if (!(await fileExists(resolved))) {
|
|
1509
|
+
throw new CliError({
|
|
1510
|
+
exitCode: 5,
|
|
1511
|
+
code: "E_IO",
|
|
1512
|
+
message: `Recipe archive not found: ${latest.url}`,
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
return resolved;
|
|
1516
|
+
};
|
|
1517
|
+
const resolveSourcePath = async (source) => {
|
|
1518
|
+
if (source.type === "name")
|
|
1519
|
+
return await resolveFromIndex(source.value);
|
|
1520
|
+
if (source.type === "url") {
|
|
1521
|
+
const url = new URL(source.value);
|
|
1522
|
+
const filename = path.basename(url.pathname) || "recipe.tar.gz";
|
|
1523
|
+
const target = path.join(tempRoot, filename);
|
|
1524
|
+
sourceLabel = source.value;
|
|
1525
|
+
await downloadToFile(source.value, target);
|
|
1526
|
+
return target;
|
|
1527
|
+
}
|
|
1528
|
+
if (source.type === "path") {
|
|
1529
|
+
const candidate = await resolvePathFallback(source.value);
|
|
1530
|
+
if (!(await fileExists(candidate))) {
|
|
1531
|
+
throw new CliError({
|
|
1532
|
+
exitCode: 5,
|
|
1533
|
+
code: "E_IO",
|
|
1534
|
+
message: `Recipe archive not found: ${source.value}`,
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1537
|
+
sourceLabel = candidate;
|
|
1538
|
+
return candidate;
|
|
1539
|
+
}
|
|
1540
|
+
if (isHttpUrl(source.value)) {
|
|
1541
|
+
return await resolveSourcePath({ type: "url", value: source.value });
|
|
1542
|
+
}
|
|
1543
|
+
const candidate = await resolvePathFallback(source.value);
|
|
1544
|
+
if (await fileExists(candidate)) {
|
|
1545
|
+
return await resolveSourcePath({ type: "path", value: source.value });
|
|
1546
|
+
}
|
|
1547
|
+
return await resolveSourcePath({ type: "name", value: source.value });
|
|
1548
|
+
};
|
|
1549
|
+
sourcePath = await resolveSourcePath(opts.source);
|
|
1550
|
+
if (!sourceLabel)
|
|
1551
|
+
sourceLabel = opts.source.value;
|
|
1552
|
+
const actualSha = expectedSha ? await sha256File(sourcePath) : "";
|
|
1553
|
+
if (expectedSha && actualSha !== expectedSha) {
|
|
1554
|
+
throw new CliError({
|
|
1555
|
+
exitCode: 3,
|
|
1556
|
+
code: "E_VALIDATION",
|
|
1557
|
+
message: `Recipe checksum mismatch for ${sourceLabel}`,
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
await extractArchive({
|
|
1561
|
+
archivePath: sourcePath,
|
|
1562
|
+
destDir: tempRoot,
|
|
1563
|
+
usage: RECIPE_INSTALL_USAGE,
|
|
1564
|
+
example: RECIPE_INSTALL_USAGE_EXAMPLE,
|
|
1565
|
+
});
|
|
1566
|
+
const recipeRoot = await resolveRecipeRoot(tempRoot);
|
|
1567
|
+
const manifest = await readRecipeManifest(path.join(recipeRoot, "manifest.json"));
|
|
1568
|
+
const resolvedTags = manifest.tags && manifest.tags.length > 0 ? manifest.tags : normalizeRecipeTags(indexTags);
|
|
1569
|
+
const manifestWithTags = resolvedTags.length > 0 ? { ...manifest, tags: resolvedTags } : manifest;
|
|
1570
|
+
const installDir = resolveInstalledRecipeDir(manifestWithTags);
|
|
1571
|
+
const installKind = await getPathKind(installDir);
|
|
1572
|
+
if (installKind && installKind !== "dir") {
|
|
1573
|
+
throw new CliError({
|
|
1574
|
+
exitCode: 5,
|
|
1575
|
+
code: "E_IO",
|
|
1576
|
+
message: `Recipe install path is not a directory: ${installDir}`,
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
const hadExisting = Boolean(installKind);
|
|
1580
|
+
if (installKind) {
|
|
1581
|
+
await rm(installDir, { recursive: true, force: true });
|
|
1582
|
+
}
|
|
1583
|
+
await mkdir(path.dirname(installDir), { recursive: true });
|
|
1584
|
+
await mkdir(resolveGlobalRecipesDir(), { recursive: true });
|
|
1585
|
+
try {
|
|
1586
|
+
await rename(recipeRoot, installDir);
|
|
1587
|
+
}
|
|
1588
|
+
catch (err) {
|
|
1589
|
+
const code = err?.code;
|
|
1590
|
+
if (code === "EXDEV") {
|
|
1591
|
+
await cp(recipeRoot, installDir, { recursive: true });
|
|
1592
|
+
}
|
|
1593
|
+
else {
|
|
1594
|
+
throw err;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
try {
|
|
1598
|
+
const project = await maybeResolveProject({
|
|
1599
|
+
cwd: opts.cwd,
|
|
1600
|
+
rootOverride: opts.rootOverride,
|
|
1601
|
+
});
|
|
1602
|
+
if (project) {
|
|
1603
|
+
await applyRecipeAgents({
|
|
1604
|
+
manifest: manifestWithTags,
|
|
1605
|
+
recipeDir: installDir,
|
|
1606
|
+
agentplaneDir: project.agentplaneDir,
|
|
1607
|
+
onConflict: opts.onConflict,
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
await applyRecipeScenarios({ manifest: manifestWithTags, recipeDir: installDir });
|
|
1611
|
+
}
|
|
1612
|
+
catch (err) {
|
|
1613
|
+
if (!hadExisting) {
|
|
1614
|
+
await rm(installDir, { recursive: true, force: true });
|
|
1615
|
+
}
|
|
1616
|
+
throw err;
|
|
1617
|
+
}
|
|
1618
|
+
const recipesPath = resolveInstalledRecipesPath();
|
|
1619
|
+
const installed = await readInstalledRecipesFile(recipesPath);
|
|
1620
|
+
const updated = installed.recipes.filter((entry) => entry.id !== manifestWithTags.id);
|
|
1621
|
+
updated.push({
|
|
1622
|
+
id: manifestWithTags.id,
|
|
1623
|
+
version: manifestWithTags.version,
|
|
1624
|
+
source: sourceLabel,
|
|
1625
|
+
installed_at: new Date().toISOString(),
|
|
1626
|
+
tags: resolvedTags,
|
|
1627
|
+
manifest: manifestWithTags,
|
|
1628
|
+
});
|
|
1629
|
+
await writeInstalledRecipesFile(recipesPath, {
|
|
1630
|
+
schema_version: 1,
|
|
1631
|
+
updated_at: installed.updated_at,
|
|
1632
|
+
recipes: updated,
|
|
1633
|
+
});
|
|
1634
|
+
process.stdout.write(`Installed recipe ${manifestWithTags.id}@${manifestWithTags.version}\n`);
|
|
1635
|
+
return 0;
|
|
1636
|
+
}
|
|
1637
|
+
finally {
|
|
1638
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
catch (err) {
|
|
1642
|
+
if (err instanceof CliError)
|
|
1643
|
+
throw err;
|
|
1644
|
+
throw mapCoreError(err, { command: "recipes install", root: opts.rootOverride ?? null });
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
async function cmdRecipeRemove(opts) {
|
|
1648
|
+
try {
|
|
1649
|
+
const recipesPath = resolveInstalledRecipesPath();
|
|
1650
|
+
const installed = await readInstalledRecipesFile(recipesPath);
|
|
1651
|
+
const entry = installed.recipes.find((recipe) => recipe.id === opts.id);
|
|
1652
|
+
if (!entry) {
|
|
1653
|
+
throw new CliError({
|
|
1654
|
+
exitCode: 5,
|
|
1655
|
+
code: "E_IO",
|
|
1656
|
+
message: `Recipe not installed: ${opts.id}`,
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
const recipeDir = resolveInstalledRecipeDir(entry);
|
|
1660
|
+
await rm(recipeDir, { recursive: true, force: true });
|
|
1661
|
+
const updated = installed.recipes.filter((recipe) => recipe.id !== opts.id);
|
|
1662
|
+
await writeInstalledRecipesFile(recipesPath, {
|
|
1663
|
+
schema_version: 1,
|
|
1664
|
+
updated_at: installed.updated_at,
|
|
1665
|
+
recipes: updated,
|
|
1666
|
+
});
|
|
1667
|
+
process.stdout.write(`${successMessage("removed recipe", `${entry.id}@${entry.version}`)}\n`);
|
|
1668
|
+
return 0;
|
|
1669
|
+
}
|
|
1670
|
+
catch (err) {
|
|
1671
|
+
if (err instanceof CliError)
|
|
1672
|
+
throw err;
|
|
1673
|
+
throw mapCoreError(err, { command: "recipes remove", root: opts.rootOverride ?? null });
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
async function listRecipeCacheEntries(cacheDir) {
|
|
1677
|
+
const entries = [];
|
|
1678
|
+
const recipeDirs = await readdir(cacheDir, { withFileTypes: true });
|
|
1679
|
+
for (const recipeDir of recipeDirs) {
|
|
1680
|
+
if (!recipeDir.isDirectory())
|
|
1681
|
+
continue;
|
|
1682
|
+
const recipeId = recipeDir.name;
|
|
1683
|
+
const versionRoot = path.join(cacheDir, recipeId);
|
|
1684
|
+
const versions = await readdir(versionRoot, { withFileTypes: true });
|
|
1685
|
+
for (const versionDir of versions) {
|
|
1686
|
+
if (!versionDir.isDirectory())
|
|
1687
|
+
continue;
|
|
1688
|
+
const version = versionDir.name;
|
|
1689
|
+
entries.push({
|
|
1690
|
+
id: recipeId,
|
|
1691
|
+
version,
|
|
1692
|
+
path: path.join(versionRoot, version),
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
return entries;
|
|
1697
|
+
}
|
|
1698
|
+
async function cmdRecipeCachePrune(opts) {
|
|
1699
|
+
const flags = parseRecipeCachePruneArgs(opts.args);
|
|
1700
|
+
try {
|
|
1701
|
+
const cacheDir = resolveGlobalRecipesDir();
|
|
1702
|
+
if (!(await fileExists(cacheDir))) {
|
|
1703
|
+
process.stdout.write(`${infoMessage(`recipe cache directory not found: ${cacheDir}`)}\n`);
|
|
1704
|
+
return 0;
|
|
1705
|
+
}
|
|
1706
|
+
const cacheEntries = await listRecipeCacheEntries(cacheDir);
|
|
1707
|
+
if (cacheEntries.length === 0) {
|
|
1708
|
+
process.stdout.write(`${infoMessage("recipe cache is empty")}\n`);
|
|
1709
|
+
return 0;
|
|
1710
|
+
}
|
|
1711
|
+
if (flags.all) {
|
|
1712
|
+
if (flags.dryRun) {
|
|
1713
|
+
for (const entry of cacheEntries) {
|
|
1714
|
+
process.stdout.write(`${infoMessage(`dry-run: would remove ${entry.id}@${entry.version}`)}\n`);
|
|
1715
|
+
}
|
|
1716
|
+
process.stdout.write(`${infoMessage(`dry-run: would remove ${cacheEntries.length} cached recipes`)}\n`);
|
|
1717
|
+
return 0;
|
|
1718
|
+
}
|
|
1719
|
+
await rm(cacheDir, { recursive: true, force: true });
|
|
1720
|
+
await writeInstalledRecipesFile(resolveInstalledRecipesPath(), {
|
|
1721
|
+
schema_version: 1,
|
|
1722
|
+
updated_at: "",
|
|
1723
|
+
recipes: [],
|
|
1724
|
+
});
|
|
1725
|
+
process.stdout.write(`${successMessage("removed cached recipes", undefined, `count=${cacheEntries.length}`)}\n`);
|
|
1726
|
+
return 0;
|
|
1727
|
+
}
|
|
1728
|
+
const installed = await readInstalledRecipesFile(resolveInstalledRecipesPath());
|
|
1729
|
+
const keep = new Set(installed.recipes.map((entry) => `${entry.id}@${entry.version}`));
|
|
1730
|
+
const prune = cacheEntries.filter((entry) => !keep.has(`${entry.id}@${entry.version}`));
|
|
1731
|
+
if (prune.length === 0) {
|
|
1732
|
+
process.stdout.write(`${infoMessage("recipe cache already clean (no uninstalled entries)")}\n`);
|
|
1733
|
+
return 0;
|
|
1734
|
+
}
|
|
1735
|
+
if (flags.dryRun) {
|
|
1736
|
+
for (const entry of prune) {
|
|
1737
|
+
process.stdout.write(`${infoMessage(`dry-run: would remove ${entry.id}@${entry.version}`)}\n`);
|
|
1738
|
+
}
|
|
1739
|
+
process.stdout.write(`${infoMessage(`dry-run: would remove ${prune.length} cached recipes`)}\n`);
|
|
1740
|
+
return 0;
|
|
1741
|
+
}
|
|
1742
|
+
const recipeDirs = new Set();
|
|
1743
|
+
for (const entry of prune) {
|
|
1744
|
+
recipeDirs.add(path.dirname(entry.path));
|
|
1745
|
+
await rm(entry.path, { recursive: true, force: true });
|
|
1746
|
+
}
|
|
1747
|
+
for (const recipeDir of recipeDirs) {
|
|
1748
|
+
const remaining = await readdir(recipeDir);
|
|
1749
|
+
if (remaining.length === 0) {
|
|
1750
|
+
await rm(recipeDir, { recursive: true, force: true });
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
process.stdout.write(`${successMessage("removed cached recipes", undefined, `count=${prune.length}`)}\n`);
|
|
1754
|
+
return 0;
|
|
1755
|
+
}
|
|
1756
|
+
catch (err) {
|
|
1757
|
+
if (err instanceof CliError)
|
|
1758
|
+
throw err;
|
|
1759
|
+
throw mapCoreError(err, { command: "recipes cache prune", root: opts.rootOverride ?? null });
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
export async function cmdRecipes(opts) {
|
|
1763
|
+
const subcommand = opts.command;
|
|
1764
|
+
if (!subcommand) {
|
|
1765
|
+
throw new CliError({
|
|
1766
|
+
exitCode: 2,
|
|
1767
|
+
code: "E_USAGE",
|
|
1768
|
+
message: usageMessage(RECIPE_USAGE, RECIPE_USAGE_EXAMPLE),
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
if (subcommand === "list") {
|
|
1772
|
+
return await cmdRecipeList({
|
|
1773
|
+
cwd: opts.cwd,
|
|
1774
|
+
rootOverride: opts.rootOverride,
|
|
1775
|
+
args: opts.args,
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
if (subcommand === "list-remote") {
|
|
1779
|
+
return await cmdRecipeListRemote({
|
|
1780
|
+
cwd: opts.cwd,
|
|
1781
|
+
rootOverride: opts.rootOverride,
|
|
1782
|
+
args: opts.args,
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
if (subcommand === "info") {
|
|
1786
|
+
if (opts.args.length !== 1) {
|
|
1787
|
+
throw new CliError({
|
|
1788
|
+
exitCode: 2,
|
|
1789
|
+
code: "E_USAGE",
|
|
1790
|
+
message: usageMessage(RECIPE_INFO_USAGE, RECIPE_INFO_USAGE_EXAMPLE),
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
return await cmdRecipeInfo({
|
|
1794
|
+
cwd: opts.cwd,
|
|
1795
|
+
rootOverride: opts.rootOverride,
|
|
1796
|
+
id: opts.args[0],
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1799
|
+
if (subcommand === "explain") {
|
|
1800
|
+
if (opts.args.length !== 1) {
|
|
1801
|
+
throw new CliError({
|
|
1802
|
+
exitCode: 2,
|
|
1803
|
+
code: "E_USAGE",
|
|
1804
|
+
message: usageMessage(RECIPE_EXPLAIN_USAGE, RECIPE_EXPLAIN_USAGE_EXAMPLE),
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
return await cmdRecipeExplain({
|
|
1808
|
+
cwd: opts.cwd,
|
|
1809
|
+
rootOverride: opts.rootOverride,
|
|
1810
|
+
id: opts.args[0],
|
|
1811
|
+
});
|
|
1812
|
+
}
|
|
1813
|
+
if (subcommand === "install") {
|
|
1814
|
+
const parsed = parseRecipeInstallArgs(opts.args);
|
|
1815
|
+
return await cmdRecipeInstall({
|
|
1816
|
+
cwd: opts.cwd,
|
|
1817
|
+
rootOverride: opts.rootOverride,
|
|
1818
|
+
source: parsed.source,
|
|
1819
|
+
index: parsed.index,
|
|
1820
|
+
refresh: parsed.refresh,
|
|
1821
|
+
onConflict: parsed.onConflict,
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
if (subcommand === "remove") {
|
|
1825
|
+
if (opts.args.length !== 1) {
|
|
1826
|
+
throw new CliError({
|
|
1827
|
+
exitCode: 2,
|
|
1828
|
+
code: "E_USAGE",
|
|
1829
|
+
message: usageMessage(RECIPE_REMOVE_USAGE, RECIPE_REMOVE_USAGE_EXAMPLE),
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
return await cmdRecipeRemove({
|
|
1833
|
+
cwd: opts.cwd,
|
|
1834
|
+
rootOverride: opts.rootOverride,
|
|
1835
|
+
id: opts.args[0],
|
|
1836
|
+
});
|
|
1837
|
+
}
|
|
1838
|
+
if (subcommand === "cache") {
|
|
1839
|
+
const cacheSub = opts.args[0];
|
|
1840
|
+
const cacheArgs = opts.args.slice(1);
|
|
1841
|
+
if (!cacheSub) {
|
|
1842
|
+
throw new CliError({
|
|
1843
|
+
exitCode: 2,
|
|
1844
|
+
code: "E_USAGE",
|
|
1845
|
+
message: usageMessage(RECIPE_CACHE_USAGE, RECIPE_CACHE_USAGE_EXAMPLE),
|
|
1846
|
+
});
|
|
1847
|
+
}
|
|
1848
|
+
if (cacheSub === "prune") {
|
|
1849
|
+
return await cmdRecipeCachePrune({
|
|
1850
|
+
cwd: opts.cwd,
|
|
1851
|
+
rootOverride: opts.rootOverride,
|
|
1852
|
+
args: cacheArgs,
|
|
1853
|
+
});
|
|
1854
|
+
}
|
|
1855
|
+
throw new CliError({
|
|
1856
|
+
exitCode: 2,
|
|
1857
|
+
code: "E_USAGE",
|
|
1858
|
+
message: usageMessage(RECIPE_CACHE_USAGE, RECIPE_CACHE_USAGE_EXAMPLE),
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
throw new CliError({
|
|
1862
|
+
exitCode: 2,
|
|
1863
|
+
code: "E_USAGE",
|
|
1864
|
+
message: usageMessage(RECIPE_USAGE, RECIPE_USAGE_EXAMPLE),
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
export async function cmdScenario(opts) {
|
|
1868
|
+
const subcommand = opts.command;
|
|
1869
|
+
if (!subcommand) {
|
|
1870
|
+
throw new CliError({
|
|
1871
|
+
exitCode: 2,
|
|
1872
|
+
code: "E_USAGE",
|
|
1873
|
+
message: usageMessage(SCENARIO_USAGE, SCENARIO_USAGE_EXAMPLE),
|
|
1874
|
+
});
|
|
1875
|
+
}
|
|
1876
|
+
if (subcommand === "list") {
|
|
1877
|
+
if (opts.args.length > 0) {
|
|
1878
|
+
throw new CliError({
|
|
1879
|
+
exitCode: 2,
|
|
1880
|
+
code: "E_USAGE",
|
|
1881
|
+
message: usageMessage(SCENARIO_USAGE, SCENARIO_USAGE_EXAMPLE),
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
return await cmdScenarioList({ cwd: opts.cwd, rootOverride: opts.rootOverride });
|
|
1885
|
+
}
|
|
1886
|
+
if (subcommand === "info") {
|
|
1887
|
+
if (opts.args.length !== 1) {
|
|
1888
|
+
throw new CliError({
|
|
1889
|
+
exitCode: 2,
|
|
1890
|
+
code: "E_USAGE",
|
|
1891
|
+
message: usageMessage(SCENARIO_INFO_USAGE, SCENARIO_INFO_USAGE_EXAMPLE),
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
return await cmdScenarioInfo({
|
|
1895
|
+
cwd: opts.cwd,
|
|
1896
|
+
rootOverride: opts.rootOverride,
|
|
1897
|
+
id: opts.args[0],
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
if (subcommand === "run") {
|
|
1901
|
+
if (opts.args.length !== 1) {
|
|
1902
|
+
throw new CliError({
|
|
1903
|
+
exitCode: 2,
|
|
1904
|
+
code: "E_USAGE",
|
|
1905
|
+
message: usageMessage(SCENARIO_RUN_USAGE, SCENARIO_RUN_USAGE_EXAMPLE),
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
return await cmdScenarioRun({
|
|
1909
|
+
cwd: opts.cwd,
|
|
1910
|
+
rootOverride: opts.rootOverride,
|
|
1911
|
+
id: opts.args[0],
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
throw new CliError({
|
|
1915
|
+
exitCode: 2,
|
|
1916
|
+
code: "E_USAGE",
|
|
1917
|
+
message: usageMessage(SCENARIO_USAGE, SCENARIO_USAGE_EXAMPLE),
|
|
1918
|
+
});
|
|
1919
|
+
}
|