@enactprotocol/cli 1.2.8 → 2.0.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.
- package/README.md +88 -0
- package/package.json +34 -38
- package/src/commands/auth/index.ts +940 -0
- package/src/commands/cache/index.ts +361 -0
- package/src/commands/config/README.md +239 -0
- package/src/commands/config/index.ts +164 -0
- package/src/commands/env/README.md +197 -0
- package/src/commands/env/index.ts +392 -0
- package/src/commands/exec/README.md +110 -0
- package/src/commands/exec/index.ts +195 -0
- package/src/commands/get/index.ts +198 -0
- package/src/commands/index.ts +30 -0
- package/src/commands/inspect/index.ts +264 -0
- package/src/commands/install/README.md +146 -0
- package/src/commands/install/index.ts +682 -0
- package/src/commands/list/README.md +115 -0
- package/src/commands/list/index.ts +138 -0
- package/src/commands/publish/index.ts +350 -0
- package/src/commands/report/index.ts +366 -0
- package/src/commands/run/README.md +124 -0
- package/src/commands/run/index.ts +686 -0
- package/src/commands/search/index.ts +368 -0
- package/src/commands/setup/index.ts +274 -0
- package/src/commands/sign/index.ts +652 -0
- package/src/commands/trust/README.md +214 -0
- package/src/commands/trust/index.ts +453 -0
- package/src/commands/unyank/index.ts +107 -0
- package/src/commands/yank/index.ts +143 -0
- package/src/index.ts +96 -0
- package/src/types.ts +81 -0
- package/src/utils/errors.ts +409 -0
- package/src/utils/exit-codes.ts +159 -0
- package/src/utils/ignore.ts +147 -0
- package/src/utils/index.ts +107 -0
- package/src/utils/output.ts +242 -0
- package/src/utils/spinner.ts +214 -0
- package/tests/commands/auth.test.ts +217 -0
- package/tests/commands/cache.test.ts +286 -0
- package/tests/commands/config.test.ts +277 -0
- package/tests/commands/env.test.ts +293 -0
- package/tests/commands/exec.test.ts +112 -0
- package/tests/commands/get.test.ts +179 -0
- package/tests/commands/inspect.test.ts +201 -0
- package/tests/commands/install-integration.test.ts +343 -0
- package/tests/commands/install.test.ts +288 -0
- package/tests/commands/list.test.ts +160 -0
- package/tests/commands/publish.test.ts +186 -0
- package/tests/commands/report.test.ts +194 -0
- package/tests/commands/run.test.ts +231 -0
- package/tests/commands/search.test.ts +131 -0
- package/tests/commands/sign.test.ts +164 -0
- package/tests/commands/trust.test.ts +236 -0
- package/tests/commands/unyank.test.ts +114 -0
- package/tests/commands/yank.test.ts +154 -0
- package/tests/e2e.test.ts +554 -0
- package/tests/fixtures/calculator/enact.yaml +34 -0
- package/tests/fixtures/echo-tool/enact.md +31 -0
- package/tests/fixtures/env-tool/enact.yaml +19 -0
- package/tests/fixtures/greeter/enact.yaml +18 -0
- package/tests/fixtures/invalid-tool/enact.yaml +4 -0
- package/tests/index.test.ts +8 -0
- package/tests/types.test.ts +84 -0
- package/tests/utils/errors.test.ts +303 -0
- package/tests/utils/exit-codes.test.ts +189 -0
- package/tests/utils/ignore.test.ts +461 -0
- package/tests/utils/output.test.ts +126 -0
- package/tsconfig.json +17 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/dist/index.js +0 -231410
- package/dist/index.js.bak +0 -231409
- package/dist/web/static/app.js +0 -663
- package/dist/web/static/index.html +0 -117
- package/dist/web/static/style.css +0 -291
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* enact run command
|
|
3
|
+
*
|
|
4
|
+
* Execute a tool in its container environment with the manifest-defined command.
|
|
5
|
+
*
|
|
6
|
+
* Resolution order:
|
|
7
|
+
* 1. Check local sources (project → user → cache)
|
|
8
|
+
* 2. If not found and --local not set, fetch from registry to cache
|
|
9
|
+
* 3. Run from resolved location (never copies to installed tools)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import * as clack from "@clack/prompts";
|
|
15
|
+
import {
|
|
16
|
+
type AttestationListResponse,
|
|
17
|
+
createApiClient,
|
|
18
|
+
downloadBundle,
|
|
19
|
+
getAttestationList,
|
|
20
|
+
getToolInfo,
|
|
21
|
+
getToolVersion,
|
|
22
|
+
verifyAllAttestations,
|
|
23
|
+
} from "@enactprotocol/api";
|
|
24
|
+
import { DaggerExecutionProvider, type ExecutionResult } from "@enactprotocol/execution";
|
|
25
|
+
import { resolveSecrets, resolveToolEnv } from "@enactprotocol/secrets";
|
|
26
|
+
import {
|
|
27
|
+
type ToolManifest,
|
|
28
|
+
type ToolResolution,
|
|
29
|
+
applyDefaults,
|
|
30
|
+
getCacheDir,
|
|
31
|
+
getMinimumAttestations,
|
|
32
|
+
getTrustPolicy,
|
|
33
|
+
getTrustedAuditors,
|
|
34
|
+
loadConfig,
|
|
35
|
+
prepareCommand,
|
|
36
|
+
toolNameToPath,
|
|
37
|
+
tryResolveTool,
|
|
38
|
+
validateInputs,
|
|
39
|
+
} from "@enactprotocol/shared";
|
|
40
|
+
import type { Command } from "commander";
|
|
41
|
+
import type { CommandContext, GlobalOptions } from "../../types";
|
|
42
|
+
import {
|
|
43
|
+
EXIT_EXECUTION_ERROR,
|
|
44
|
+
ToolNotFoundError,
|
|
45
|
+
TrustError,
|
|
46
|
+
ValidationError,
|
|
47
|
+
colors,
|
|
48
|
+
confirm,
|
|
49
|
+
dim,
|
|
50
|
+
error,
|
|
51
|
+
formatError,
|
|
52
|
+
handleError,
|
|
53
|
+
info,
|
|
54
|
+
json,
|
|
55
|
+
keyValue,
|
|
56
|
+
newline,
|
|
57
|
+
success,
|
|
58
|
+
symbols,
|
|
59
|
+
withSpinner,
|
|
60
|
+
} from "../../utils";
|
|
61
|
+
|
|
62
|
+
interface RunOptions extends GlobalOptions {
|
|
63
|
+
args?: string;
|
|
64
|
+
input?: string[];
|
|
65
|
+
timeout?: string;
|
|
66
|
+
noCache?: boolean;
|
|
67
|
+
local?: boolean;
|
|
68
|
+
quiet?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Parse input arguments from various formats
|
|
73
|
+
*/
|
|
74
|
+
function parseInputArgs(
|
|
75
|
+
argsJson: string | undefined,
|
|
76
|
+
inputFlags: string[] | undefined
|
|
77
|
+
): Record<string, unknown> {
|
|
78
|
+
const inputs: Record<string, unknown> = {};
|
|
79
|
+
|
|
80
|
+
// Parse --args JSON
|
|
81
|
+
if (argsJson) {
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(argsJson);
|
|
84
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
85
|
+
Object.assign(inputs, parsed);
|
|
86
|
+
}
|
|
87
|
+
} catch (err) {
|
|
88
|
+
throw new Error(`Invalid JSON in --args: ${formatError(err)}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Parse --input key=value pairs
|
|
93
|
+
if (inputFlags) {
|
|
94
|
+
for (const input of inputFlags) {
|
|
95
|
+
const eqIndex = input.indexOf("=");
|
|
96
|
+
if (eqIndex === -1) {
|
|
97
|
+
throw new Error(`Invalid input format: "${input}". Expected key=value`);
|
|
98
|
+
}
|
|
99
|
+
const key = input.slice(0, eqIndex);
|
|
100
|
+
const value = input.slice(eqIndex + 1);
|
|
101
|
+
|
|
102
|
+
// Try to parse as JSON for complex values
|
|
103
|
+
try {
|
|
104
|
+
inputs[key] = JSON.parse(value);
|
|
105
|
+
} catch {
|
|
106
|
+
inputs[key] = value;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return inputs;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Extract a bundle to the cache directory
|
|
116
|
+
*/
|
|
117
|
+
async function extractToCache(
|
|
118
|
+
bundleData: ArrayBuffer,
|
|
119
|
+
toolName: string,
|
|
120
|
+
version: string
|
|
121
|
+
): Promise<string> {
|
|
122
|
+
const cacheDir = getCacheDir();
|
|
123
|
+
const toolPath = toolNameToPath(toolName);
|
|
124
|
+
const versionDir = join(cacheDir, toolPath, `v${version.replace(/^v/, "")}`);
|
|
125
|
+
|
|
126
|
+
// Create a temporary file for the bundle
|
|
127
|
+
const tempFile = join(cacheDir, `bundle-${Date.now()}.tar.gz`);
|
|
128
|
+
mkdirSync(dirname(tempFile), { recursive: true });
|
|
129
|
+
writeFileSync(tempFile, Buffer.from(bundleData));
|
|
130
|
+
|
|
131
|
+
// Create destination directory
|
|
132
|
+
mkdirSync(versionDir, { recursive: true });
|
|
133
|
+
|
|
134
|
+
// Extract using tar command
|
|
135
|
+
const proc = Bun.spawn(["tar", "-xzf", tempFile, "-C", versionDir], {
|
|
136
|
+
stdout: "pipe",
|
|
137
|
+
stderr: "pipe",
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const exitCode = await proc.exited;
|
|
141
|
+
|
|
142
|
+
// Clean up temp file
|
|
143
|
+
try {
|
|
144
|
+
unlinkSync(tempFile);
|
|
145
|
+
} catch {
|
|
146
|
+
// Ignore cleanup errors
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (exitCode !== 0) {
|
|
150
|
+
const stderr = await new Response(proc.stderr).text();
|
|
151
|
+
throw new Error(`Failed to extract bundle: ${stderr}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return versionDir;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Parse tool@version syntax
|
|
159
|
+
*/
|
|
160
|
+
function parseToolSpec(spec: string): { name: string; version: string | undefined } {
|
|
161
|
+
// Handle namespace/tool@version format
|
|
162
|
+
const match = spec.match(/^([^@]+)(?:@(.+))?$/);
|
|
163
|
+
if (match?.[1]) {
|
|
164
|
+
return {
|
|
165
|
+
name: match[1],
|
|
166
|
+
version: match[2]?.replace(/^v/, ""), // Remove leading 'v' if present
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return { name: spec, version: undefined };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Fetch a tool from the registry and cache it
|
|
174
|
+
* Verifies attestations according to trust policy before caching
|
|
175
|
+
* Returns ToolResolution if successful
|
|
176
|
+
*/
|
|
177
|
+
async function fetchAndCacheTool(
|
|
178
|
+
toolSpec: string,
|
|
179
|
+
options: RunOptions,
|
|
180
|
+
ctx: CommandContext
|
|
181
|
+
): Promise<ToolResolution> {
|
|
182
|
+
const { name: toolName, version: requestedVersion } = parseToolSpec(toolSpec);
|
|
183
|
+
|
|
184
|
+
const config = loadConfig();
|
|
185
|
+
const registryUrl =
|
|
186
|
+
process.env.ENACT_REGISTRY_URL ??
|
|
187
|
+
config.registry?.url ??
|
|
188
|
+
"https://siikwkfgsmouioodghho.supabase.co/functions/v1";
|
|
189
|
+
const client = createApiClient({ baseUrl: registryUrl });
|
|
190
|
+
|
|
191
|
+
// Get tool info to find latest version or use requested version
|
|
192
|
+
const toolInfo = await getToolInfo(client, toolName);
|
|
193
|
+
const targetVersion = requestedVersion ?? toolInfo.latestVersion;
|
|
194
|
+
|
|
195
|
+
if (!targetVersion) {
|
|
196
|
+
throw new Error(`No published versions for ${toolName}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Try loading from cache first
|
|
200
|
+
const cached = tryResolveTool(toolName, {
|
|
201
|
+
skipProject: true,
|
|
202
|
+
skipUser: true,
|
|
203
|
+
version: targetVersion,
|
|
204
|
+
});
|
|
205
|
+
if (cached) {
|
|
206
|
+
return cached;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Get version details
|
|
210
|
+
const versionInfo = await getToolVersion(client, toolName, targetVersion);
|
|
211
|
+
|
|
212
|
+
// Check if version is yanked
|
|
213
|
+
if (versionInfo.yanked && !options.verbose) {
|
|
214
|
+
const yankMessage = versionInfo.yankReason
|
|
215
|
+
? `Version ${targetVersion} has been yanked: ${versionInfo.yankReason}`
|
|
216
|
+
: `Version ${targetVersion} has been yanked`;
|
|
217
|
+
info(`${symbols.warning} ${yankMessage}`);
|
|
218
|
+
if (versionInfo.yankReplacement) {
|
|
219
|
+
dim(` Recommended: ${versionInfo.yankReplacement}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ========================================
|
|
224
|
+
// TRUST VERIFICATION - same as install
|
|
225
|
+
// ========================================
|
|
226
|
+
const trustPolicy = getTrustPolicy();
|
|
227
|
+
const minimumAttestations = getMinimumAttestations();
|
|
228
|
+
const trustedAuditors = getTrustedAuditors();
|
|
229
|
+
|
|
230
|
+
// Fetch attestations from registry
|
|
231
|
+
const attestationsResponse: AttestationListResponse = await getAttestationList(
|
|
232
|
+
client,
|
|
233
|
+
toolName,
|
|
234
|
+
targetVersion
|
|
235
|
+
);
|
|
236
|
+
const attestations = attestationsResponse.attestations;
|
|
237
|
+
|
|
238
|
+
if (attestations.length === 0) {
|
|
239
|
+
// No attestations found
|
|
240
|
+
info(`${symbols.warning} Tool ${toolName}@${targetVersion} has no attestations.`);
|
|
241
|
+
|
|
242
|
+
if (trustPolicy === "require_attestation") {
|
|
243
|
+
throw new TrustError("Trust policy requires attestations. Execution blocked.");
|
|
244
|
+
}
|
|
245
|
+
if (ctx.isInteractive && trustPolicy === "prompt") {
|
|
246
|
+
const proceed = await confirm("Run unverified tool?");
|
|
247
|
+
if (!proceed) {
|
|
248
|
+
info("Execution cancelled.");
|
|
249
|
+
process.exit(0);
|
|
250
|
+
}
|
|
251
|
+
} else if (!ctx.isInteractive && trustPolicy === "prompt") {
|
|
252
|
+
throw new TrustError("Cannot run unverified tools in non-interactive mode.");
|
|
253
|
+
}
|
|
254
|
+
// trustPolicy === "allow" - continue without prompting
|
|
255
|
+
} else {
|
|
256
|
+
// Verify attestations locally (never trust registry's verification status)
|
|
257
|
+
const verifiedAuditors = await verifyAllAttestations(
|
|
258
|
+
client,
|
|
259
|
+
toolName,
|
|
260
|
+
targetVersion,
|
|
261
|
+
versionInfo.bundle.hash ?? ""
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// Check verified auditors against trust config using provider:identity format
|
|
265
|
+
const trustedVerifiedAuditors = verifiedAuditors
|
|
266
|
+
.filter((auditor) => trustedAuditors.includes(auditor.providerIdentity))
|
|
267
|
+
.map((auditor) => auditor.providerIdentity);
|
|
268
|
+
|
|
269
|
+
if (trustedVerifiedAuditors.length > 0) {
|
|
270
|
+
// Check if we meet minimum attestations threshold
|
|
271
|
+
if (trustedVerifiedAuditors.length < minimumAttestations) {
|
|
272
|
+
info(
|
|
273
|
+
`${symbols.warning} Tool ${toolName}@${targetVersion} has ${trustedVerifiedAuditors.length} trusted attestation(s), but ${minimumAttestations} required.`
|
|
274
|
+
);
|
|
275
|
+
dim(`Trusted attestations: ${trustedVerifiedAuditors.join(", ")}`);
|
|
276
|
+
|
|
277
|
+
if (trustPolicy === "require_attestation") {
|
|
278
|
+
throw new TrustError(
|
|
279
|
+
`Trust policy requires at least ${minimumAttestations} attestation(s) from trusted identities.`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
if (ctx.isInteractive && trustPolicy === "prompt") {
|
|
283
|
+
const proceed = await confirm("Run with fewer attestations than required?");
|
|
284
|
+
if (!proceed) {
|
|
285
|
+
info("Execution cancelled.");
|
|
286
|
+
process.exit(0);
|
|
287
|
+
}
|
|
288
|
+
} else if (!ctx.isInteractive && trustPolicy === "prompt") {
|
|
289
|
+
throw new TrustError(
|
|
290
|
+
"Cannot run tool without meeting minimum attestation requirement in non-interactive mode."
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
// trustPolicy === "allow" - continue without prompting
|
|
294
|
+
} else {
|
|
295
|
+
// Tool meets or exceeds minimum attestations
|
|
296
|
+
if (options.verbose) {
|
|
297
|
+
success(
|
|
298
|
+
`Tool verified by ${trustedVerifiedAuditors.length} trusted identity(ies): ${trustedVerifiedAuditors.join(", ")}`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
// Has attestations but none from trusted auditors
|
|
304
|
+
info(
|
|
305
|
+
`${symbols.warning} Tool ${toolName}@${targetVersion} has ${verifiedAuditors.length} attestation(s), but none from trusted auditors.`
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
if (trustPolicy === "require_attestation") {
|
|
309
|
+
dim(`Your trusted auditors: ${trustedAuditors.join(", ")}`);
|
|
310
|
+
dim(`Tool attested by: ${verifiedAuditors.map((a) => a.providerIdentity).join(", ")}`);
|
|
311
|
+
throw new TrustError(
|
|
312
|
+
"Trust policy requires attestations from trusted identities. Execution blocked."
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
if (ctx.isInteractive && trustPolicy === "prompt") {
|
|
316
|
+
dim(`Attested by: ${verifiedAuditors.map((a) => a.providerIdentity).join(", ")}`);
|
|
317
|
+
dim(`Your trusted auditors: ${trustedAuditors.join(", ")}`);
|
|
318
|
+
const proceed = await confirm("Run anyway?");
|
|
319
|
+
if (!proceed) {
|
|
320
|
+
info("Execution cancelled.");
|
|
321
|
+
process.exit(0);
|
|
322
|
+
}
|
|
323
|
+
} else if (!ctx.isInteractive && trustPolicy === "prompt") {
|
|
324
|
+
throw new TrustError(
|
|
325
|
+
"Cannot run tool without trusted attestations in non-interactive mode."
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
// trustPolicy === "allow" - continue without prompting
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ========================================
|
|
333
|
+
// Download and cache the bundle
|
|
334
|
+
// ========================================
|
|
335
|
+
const bundleResult = await downloadBundle(client, {
|
|
336
|
+
name: toolName,
|
|
337
|
+
version: targetVersion,
|
|
338
|
+
verify: true,
|
|
339
|
+
acknowledgeYanked: versionInfo.yanked,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Verify hash
|
|
343
|
+
if (versionInfo.bundle.hash) {
|
|
344
|
+
const downloadedHash = bundleResult.hash.replace("sha256:", "");
|
|
345
|
+
const expectedHash = versionInfo.bundle.hash.replace("sha256:", "");
|
|
346
|
+
if (downloadedHash !== expectedHash) {
|
|
347
|
+
throw new TrustError("Bundle hash mismatch - download may be corrupted or tampered with");
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Extract to cache
|
|
352
|
+
const extractedDir = await extractToCache(bundleResult.data, toolName, targetVersion);
|
|
353
|
+
|
|
354
|
+
// Resolve the cached tool
|
|
355
|
+
const resolution = tryResolveTool(toolName, {
|
|
356
|
+
skipProject: true,
|
|
357
|
+
skipUser: true,
|
|
358
|
+
version: targetVersion,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
if (!resolution) {
|
|
362
|
+
throw new Error(`Failed to resolve cached tool at ${extractedDir}`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return resolution;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Display dry run information
|
|
370
|
+
*/
|
|
371
|
+
function displayDryRun(
|
|
372
|
+
manifest: ToolManifest,
|
|
373
|
+
inputs: Record<string, unknown>,
|
|
374
|
+
command: string[],
|
|
375
|
+
env: Record<string, string>
|
|
376
|
+
): void {
|
|
377
|
+
newline();
|
|
378
|
+
info(colors.bold("Dry Run Preview"));
|
|
379
|
+
newline();
|
|
380
|
+
|
|
381
|
+
keyValue("Tool", manifest.name);
|
|
382
|
+
keyValue("Version", manifest.version ?? "unversioned");
|
|
383
|
+
keyValue("Container", manifest.from ?? "alpine:latest");
|
|
384
|
+
newline();
|
|
385
|
+
|
|
386
|
+
if (Object.keys(inputs).length > 0) {
|
|
387
|
+
info("Inputs:");
|
|
388
|
+
for (const [key, value] of Object.entries(inputs)) {
|
|
389
|
+
dim(` ${key}: ${JSON.stringify(value)}`);
|
|
390
|
+
}
|
|
391
|
+
newline();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (Object.keys(env).length > 0) {
|
|
395
|
+
info("Environment:");
|
|
396
|
+
for (const [key] of Object.entries(env)) {
|
|
397
|
+
dim(` ${key}: ***`);
|
|
398
|
+
}
|
|
399
|
+
newline();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
info("Command:");
|
|
403
|
+
dim(` ${command.join(" ")}`);
|
|
404
|
+
newline();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Display execution result
|
|
409
|
+
*/
|
|
410
|
+
function displayResult(result: ExecutionResult, options: RunOptions): void {
|
|
411
|
+
if (options.json) {
|
|
412
|
+
json(result);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (result.success) {
|
|
417
|
+
if (result.output?.stdout) {
|
|
418
|
+
// Print stdout directly (most common use case)
|
|
419
|
+
process.stdout.write(result.output.stdout);
|
|
420
|
+
// Ensure newline at end
|
|
421
|
+
if (!result.output.stdout.endsWith("\n")) {
|
|
422
|
+
newline();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (options.verbose && result.output?.stderr) {
|
|
427
|
+
dim(`stderr: ${result.output.stderr}`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (options.verbose && result.metadata) {
|
|
431
|
+
newline();
|
|
432
|
+
dim(`Duration: ${result.metadata.durationMs}ms`);
|
|
433
|
+
dim(`Exit code: ${result.output?.exitCode ?? 0}`);
|
|
434
|
+
}
|
|
435
|
+
} else {
|
|
436
|
+
error(`Execution failed: ${result.error?.message ?? "Unknown error"}`);
|
|
437
|
+
|
|
438
|
+
if (result.error?.details) {
|
|
439
|
+
newline();
|
|
440
|
+
dim(JSON.stringify(result.error.details, null, 2));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (result.output?.stderr) {
|
|
444
|
+
newline();
|
|
445
|
+
dim("stderr:");
|
|
446
|
+
dim(result.output.stderr);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Run command handler
|
|
453
|
+
*/
|
|
454
|
+
async function runHandler(tool: string, options: RunOptions, ctx: CommandContext): Promise<void> {
|
|
455
|
+
let resolution: ToolResolution | null = null;
|
|
456
|
+
|
|
457
|
+
// First, try to resolve locally (project → user → cache)
|
|
458
|
+
if (options.quiet) {
|
|
459
|
+
resolution = tryResolveTool(tool, { startDir: ctx.cwd });
|
|
460
|
+
} else {
|
|
461
|
+
const spinner = clack.spinner();
|
|
462
|
+
spinner.start(`Resolving tool: ${tool}`);
|
|
463
|
+
resolution = tryResolveTool(tool, { startDir: ctx.cwd });
|
|
464
|
+
if (resolution) {
|
|
465
|
+
spinner.stop(`${symbols.success} Resolved: ${tool}`);
|
|
466
|
+
} else {
|
|
467
|
+
spinner.stop(`${symbols.info} Checking registry...`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// If not found locally and --local flag not set, try fetching from registry
|
|
472
|
+
if (!resolution && !options.local) {
|
|
473
|
+
// Check if this looks like a tool name (namespace/name format)
|
|
474
|
+
if (tool.includes("/") && !tool.startsWith("/") && !tool.startsWith(".")) {
|
|
475
|
+
resolution = options.quiet
|
|
476
|
+
? await fetchAndCacheTool(tool, options, ctx)
|
|
477
|
+
: await withSpinner(
|
|
478
|
+
`Fetching ${tool} from registry...`,
|
|
479
|
+
async () => fetchAndCacheTool(tool, options, ctx),
|
|
480
|
+
`${symbols.success} Cached: ${tool}`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (!resolution) {
|
|
486
|
+
if (options.local) {
|
|
487
|
+
throw new ToolNotFoundError(`${tool} (--local flag set, skipped registry)`);
|
|
488
|
+
}
|
|
489
|
+
throw new ToolNotFoundError(tool);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const manifest = resolution.manifest;
|
|
493
|
+
|
|
494
|
+
// Parse inputs
|
|
495
|
+
const inputs = parseInputArgs(options.args, options.input);
|
|
496
|
+
|
|
497
|
+
// Apply defaults from schema
|
|
498
|
+
const inputsWithDefaults = manifest.inputSchema
|
|
499
|
+
? applyDefaults(inputs, manifest.inputSchema)
|
|
500
|
+
: inputs;
|
|
501
|
+
|
|
502
|
+
// Validate inputs against schema
|
|
503
|
+
const validation = validateInputs(inputsWithDefaults, manifest.inputSchema);
|
|
504
|
+
if (!validation.valid) {
|
|
505
|
+
const errors = validation.errors.map((err) => `${err.path}: ${err.message}`).join(", ");
|
|
506
|
+
throw new ValidationError(`Input validation failed: ${errors}`);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Use coerced values from validation (or inputs with defaults)
|
|
510
|
+
const finalInputs = validation.coercedValues ?? inputsWithDefaults;
|
|
511
|
+
|
|
512
|
+
// Check if this is an instruction-based tool (no command)
|
|
513
|
+
if (!manifest.command) {
|
|
514
|
+
// For instruction tools, just display the markdown body
|
|
515
|
+
let instructions: string | undefined;
|
|
516
|
+
|
|
517
|
+
// Try to get body from markdown file
|
|
518
|
+
if (resolution.manifestPath.endsWith(".md")) {
|
|
519
|
+
const { readFileSync } = await import("node:fs");
|
|
520
|
+
const content = readFileSync(resolution.manifestPath, "utf-8");
|
|
521
|
+
// Extract body after frontmatter
|
|
522
|
+
const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?([\s\S]*)$/);
|
|
523
|
+
if (match?.[1]) {
|
|
524
|
+
instructions = match[1].trim();
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Fall back to doc field or description
|
|
529
|
+
instructions = instructions || manifest.doc || manifest.description;
|
|
530
|
+
|
|
531
|
+
if (options.json) {
|
|
532
|
+
json({
|
|
533
|
+
type: "instruction-tool",
|
|
534
|
+
name: manifest.name,
|
|
535
|
+
version: manifest.version,
|
|
536
|
+
description: manifest.description,
|
|
537
|
+
inputs: finalInputs,
|
|
538
|
+
instructions,
|
|
539
|
+
});
|
|
540
|
+
} else {
|
|
541
|
+
// Display the markdown instructions
|
|
542
|
+
if (instructions) {
|
|
543
|
+
process.stdout.write(instructions);
|
|
544
|
+
if (!instructions.endsWith("\n")) {
|
|
545
|
+
newline();
|
|
546
|
+
}
|
|
547
|
+
} else {
|
|
548
|
+
info(`Tool "${manifest.name}" has no instructions defined.`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Prepare command
|
|
555
|
+
const command = prepareCommand(manifest.command, finalInputs);
|
|
556
|
+
|
|
557
|
+
// Resolve environment variables (non-secrets)
|
|
558
|
+
const { resolved: envResolved } = resolveToolEnv(manifest.env ?? {}, ctx.cwd);
|
|
559
|
+
const envVars: Record<string, string> = {};
|
|
560
|
+
for (const [key, resolution] of envResolved) {
|
|
561
|
+
envVars[key] = resolution.value;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Resolve secrets
|
|
565
|
+
const secretDeclarations = Object.entries(manifest.env ?? {})
|
|
566
|
+
.filter(([_, v]) => v.secret)
|
|
567
|
+
.map(([k]) => k);
|
|
568
|
+
|
|
569
|
+
if (secretDeclarations.length > 0) {
|
|
570
|
+
const namespace = manifest.name.split("/").slice(0, -1).join("/") || manifest.name;
|
|
571
|
+
const secretResults = await resolveSecrets(namespace, secretDeclarations);
|
|
572
|
+
|
|
573
|
+
for (const [key, result] of secretResults) {
|
|
574
|
+
if (result.found && result.value) {
|
|
575
|
+
envVars[key] = result.value;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Dry run mode
|
|
581
|
+
if (options.dryRun) {
|
|
582
|
+
displayDryRun(manifest, finalInputs, command, envVars);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Execute the tool
|
|
587
|
+
const providerConfig: { defaultTimeout?: number; verbose?: boolean } = {};
|
|
588
|
+
if (options.timeout) {
|
|
589
|
+
providerConfig.defaultTimeout = parseTimeout(options.timeout);
|
|
590
|
+
}
|
|
591
|
+
if (options.verbose) {
|
|
592
|
+
providerConfig.verbose = true;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const provider = new DaggerExecutionProvider(providerConfig);
|
|
596
|
+
|
|
597
|
+
try {
|
|
598
|
+
await provider.initialize();
|
|
599
|
+
|
|
600
|
+
const executeTask = () =>
|
|
601
|
+
provider.execute(
|
|
602
|
+
manifest,
|
|
603
|
+
{
|
|
604
|
+
params: finalInputs,
|
|
605
|
+
envOverrides: envVars,
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
// Mount the tool directory to /work in the container
|
|
609
|
+
mountDirs: {
|
|
610
|
+
[resolution.sourceDir]: "/work",
|
|
611
|
+
},
|
|
612
|
+
}
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
// Build a descriptive message - container may need to be pulled
|
|
616
|
+
const containerImage = manifest.from ?? "node:18-alpine";
|
|
617
|
+
const spinnerMessage = `Running ${manifest.name} (${containerImage})...`;
|
|
618
|
+
|
|
619
|
+
const result = options.quiet
|
|
620
|
+
? await executeTask()
|
|
621
|
+
: await withSpinner(spinnerMessage, executeTask, `${symbols.success} Execution complete`);
|
|
622
|
+
|
|
623
|
+
displayResult(result, options);
|
|
624
|
+
|
|
625
|
+
if (!result.success) {
|
|
626
|
+
process.exit(EXIT_EXECUTION_ERROR);
|
|
627
|
+
}
|
|
628
|
+
} finally {
|
|
629
|
+
// Provider doesn't have cleanup - Dagger handles this
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Parse timeout string (e.g., "30s", "5m", "1h")
|
|
635
|
+
*/
|
|
636
|
+
function parseTimeout(timeout: string): number {
|
|
637
|
+
const match = timeout.match(/^(\d+)(s|m|h)?$/);
|
|
638
|
+
if (!match) {
|
|
639
|
+
throw new Error(`Invalid timeout format: ${timeout}. Use format like "30s", "5m", or "1h".`);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const value = Number.parseInt(match[1] ?? "0", 10);
|
|
643
|
+
const unit = match[2] || "s";
|
|
644
|
+
|
|
645
|
+
switch (unit) {
|
|
646
|
+
case "h":
|
|
647
|
+
return value * 60 * 60 * 1000;
|
|
648
|
+
case "m":
|
|
649
|
+
return value * 60 * 1000;
|
|
650
|
+
default:
|
|
651
|
+
return value * 1000;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Configure the run command
|
|
657
|
+
*/
|
|
658
|
+
export function configureRunCommand(program: Command): void {
|
|
659
|
+
program
|
|
660
|
+
.command("run")
|
|
661
|
+
.description("Execute a tool with its manifest-defined command")
|
|
662
|
+
.argument("<tool>", "Tool to run (name, path, or '.' for current directory)")
|
|
663
|
+
.option("-a, --args <json>", "Input arguments as JSON")
|
|
664
|
+
.option("-i, --input <key=value...>", "Input arguments as key=value pairs")
|
|
665
|
+
.option("-t, --timeout <duration>", "Execution timeout (e.g., 30s, 5m)")
|
|
666
|
+
.option("--no-cache", "Disable container caching")
|
|
667
|
+
.option("--local", "Only resolve from local sources")
|
|
668
|
+
.option("--dry-run", "Show what would be executed without running")
|
|
669
|
+
.option("-q, --quiet", "Suppress spinner output, show only tool output")
|
|
670
|
+
.option("-v, --verbose", "Show detailed output")
|
|
671
|
+
.option("--json", "Output result as JSON")
|
|
672
|
+
.action(async (tool: string, options: RunOptions) => {
|
|
673
|
+
const ctx: CommandContext = {
|
|
674
|
+
cwd: process.cwd(),
|
|
675
|
+
options,
|
|
676
|
+
isCI: Boolean(process.env.CI),
|
|
677
|
+
isInteractive: process.stdout.isTTY ?? false,
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
try {
|
|
681
|
+
await runHandler(tool, options, ctx);
|
|
682
|
+
} catch (err) {
|
|
683
|
+
handleError(err, options.verbose ? { verbose: true } : undefined);
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
}
|