@enactprotocol/cli 1.2.13 → 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.
Files changed (73) hide show
  1. package/README.md +88 -0
  2. package/package.json +34 -38
  3. package/src/commands/auth/index.ts +940 -0
  4. package/src/commands/cache/index.ts +361 -0
  5. package/src/commands/config/README.md +239 -0
  6. package/src/commands/config/index.ts +164 -0
  7. package/src/commands/env/README.md +197 -0
  8. package/src/commands/env/index.ts +392 -0
  9. package/src/commands/exec/README.md +110 -0
  10. package/src/commands/exec/index.ts +195 -0
  11. package/src/commands/get/index.ts +198 -0
  12. package/src/commands/index.ts +30 -0
  13. package/src/commands/inspect/index.ts +264 -0
  14. package/src/commands/install/README.md +146 -0
  15. package/src/commands/install/index.ts +682 -0
  16. package/src/commands/list/README.md +115 -0
  17. package/src/commands/list/index.ts +138 -0
  18. package/src/commands/publish/index.ts +350 -0
  19. package/src/commands/report/index.ts +366 -0
  20. package/src/commands/run/README.md +124 -0
  21. package/src/commands/run/index.ts +686 -0
  22. package/src/commands/search/index.ts +368 -0
  23. package/src/commands/setup/index.ts +274 -0
  24. package/src/commands/sign/index.ts +652 -0
  25. package/src/commands/trust/README.md +214 -0
  26. package/src/commands/trust/index.ts +453 -0
  27. package/src/commands/unyank/index.ts +107 -0
  28. package/src/commands/yank/index.ts +143 -0
  29. package/src/index.ts +96 -0
  30. package/src/types.ts +81 -0
  31. package/src/utils/errors.ts +409 -0
  32. package/src/utils/exit-codes.ts +159 -0
  33. package/src/utils/ignore.ts +147 -0
  34. package/src/utils/index.ts +107 -0
  35. package/src/utils/output.ts +242 -0
  36. package/src/utils/spinner.ts +214 -0
  37. package/tests/commands/auth.test.ts +217 -0
  38. package/tests/commands/cache.test.ts +286 -0
  39. package/tests/commands/config.test.ts +277 -0
  40. package/tests/commands/env.test.ts +293 -0
  41. package/tests/commands/exec.test.ts +112 -0
  42. package/tests/commands/get.test.ts +179 -0
  43. package/tests/commands/inspect.test.ts +201 -0
  44. package/tests/commands/install-integration.test.ts +343 -0
  45. package/tests/commands/install.test.ts +288 -0
  46. package/tests/commands/list.test.ts +160 -0
  47. package/tests/commands/publish.test.ts +186 -0
  48. package/tests/commands/report.test.ts +194 -0
  49. package/tests/commands/run.test.ts +231 -0
  50. package/tests/commands/search.test.ts +131 -0
  51. package/tests/commands/sign.test.ts +164 -0
  52. package/tests/commands/trust.test.ts +236 -0
  53. package/tests/commands/unyank.test.ts +114 -0
  54. package/tests/commands/yank.test.ts +154 -0
  55. package/tests/e2e.test.ts +554 -0
  56. package/tests/fixtures/calculator/enact.yaml +34 -0
  57. package/tests/fixtures/echo-tool/enact.md +31 -0
  58. package/tests/fixtures/env-tool/enact.yaml +19 -0
  59. package/tests/fixtures/greeter/enact.yaml +18 -0
  60. package/tests/fixtures/invalid-tool/enact.yaml +4 -0
  61. package/tests/index.test.ts +8 -0
  62. package/tests/types.test.ts +84 -0
  63. package/tests/utils/errors.test.ts +303 -0
  64. package/tests/utils/exit-codes.test.ts +189 -0
  65. package/tests/utils/ignore.test.ts +461 -0
  66. package/tests/utils/output.test.ts +126 -0
  67. package/tsconfig.json +17 -0
  68. package/tsconfig.tsbuildinfo +1 -0
  69. package/dist/index.js +0 -231612
  70. package/dist/index.js.bak +0 -231611
  71. package/dist/web/static/app.js +0 -663
  72. package/dist/web/static/index.html +0 -117
  73. 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
+ }