@enactprotocol/cli 2.1.24 → 2.1.29

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 (57) hide show
  1. package/dist/commands/index.d.ts +2 -0
  2. package/dist/commands/index.d.ts.map +1 -1
  3. package/dist/commands/index.js +4 -0
  4. package/dist/commands/index.js.map +1 -1
  5. package/dist/commands/init/templates/claude.d.ts +1 -1
  6. package/dist/commands/init/templates/claude.d.ts.map +1 -1
  7. package/dist/commands/init/templates/claude.js +268 -28
  8. package/dist/commands/init/templates/claude.js.map +1 -1
  9. package/dist/commands/init/templates/tool-agents.d.ts +1 -1
  10. package/dist/commands/init/templates/tool-agents.d.ts.map +1 -1
  11. package/dist/commands/init/templates/tool-agents.js +90 -15
  12. package/dist/commands/init/templates/tool-agents.js.map +1 -1
  13. package/dist/commands/install/index.d.ts.map +1 -1
  14. package/dist/commands/install/index.js +9 -1
  15. package/dist/commands/install/index.js.map +1 -1
  16. package/dist/commands/learn/index.d.ts.map +1 -1
  17. package/dist/commands/learn/index.js +4 -11
  18. package/dist/commands/learn/index.js.map +1 -1
  19. package/dist/commands/mcp/index.d.ts.map +1 -1
  20. package/dist/commands/mcp/index.js +204 -53
  21. package/dist/commands/mcp/index.js.map +1 -1
  22. package/dist/commands/run/index.d.ts.map +1 -1
  23. package/dist/commands/run/index.js +380 -39
  24. package/dist/commands/run/index.js.map +1 -1
  25. package/dist/commands/validate/index.d.ts +11 -0
  26. package/dist/commands/validate/index.d.ts.map +1 -0
  27. package/dist/commands/validate/index.js +299 -0
  28. package/dist/commands/validate/index.js.map +1 -0
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +6 -2
  32. package/dist/index.js.map +1 -1
  33. package/dist/types.d.ts +2 -0
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/types.js.map +1 -1
  36. package/dist/utils/errors.d.ts +8 -1
  37. package/dist/utils/errors.d.ts.map +1 -1
  38. package/dist/utils/errors.js +13 -2
  39. package/dist/utils/errors.js.map +1 -1
  40. package/package.json +5 -5
  41. package/src/commands/index.ts +5 -0
  42. package/src/commands/init/templates/claude.ts +268 -28
  43. package/src/commands/init/templates/tool-agents.ts +90 -15
  44. package/src/commands/install/index.ts +11 -0
  45. package/src/commands/learn/index.ts +6 -11
  46. package/src/commands/mcp/index.ts +768 -0
  47. package/src/commands/run/README.md +68 -1
  48. package/src/commands/run/index.ts +475 -35
  49. package/src/commands/validate/index.ts +344 -0
  50. package/src/index.ts +8 -1
  51. package/src/types.ts +2 -0
  52. package/src/utils/errors.ts +26 -6
  53. package/tests/commands/init.test.ts +2 -2
  54. package/tests/commands/run.test.ts +260 -0
  55. package/tests/commands/validate.test.ts +81 -0
  56. package/tests/utils/errors.test.ts +36 -0
  57. package/tsconfig.tsbuildinfo +1 -1
@@ -9,8 +9,20 @@
9
9
  * 3. Run from resolved location (never copies to installed tools)
10
10
  */
11
11
 
12
- import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
13
- import { dirname, join } from "node:path";
12
+ import {
13
+ cpSync,
14
+ existsSync,
15
+ mkdirSync,
16
+ mkdtempSync,
17
+ readdirSync,
18
+ renameSync,
19
+ rmSync,
20
+ statSync,
21
+ unlinkSync,
22
+ writeFileSync,
23
+ } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { basename, dirname, join, resolve } from "node:path";
14
26
  import * as clack from "@clack/prompts";
15
27
  import {
16
28
  type AttestationListResponse,
@@ -35,12 +47,14 @@ import {
35
47
  prepareCommand,
36
48
  toolNameToPath,
37
49
  tryResolveTool,
50
+ tryResolveToolDetailed,
38
51
  validateInputs,
39
52
  } from "@enactprotocol/shared";
40
53
  import type { Command } from "commander";
41
54
  import type { CommandContext, GlobalOptions } from "../../types";
42
55
  import {
43
56
  EXIT_EXECUTION_ERROR,
57
+ ManifestError,
44
58
  ToolNotFoundError,
45
59
  TrustError,
46
60
  ValidationError,
@@ -66,7 +80,11 @@ interface RunOptions extends GlobalOptions {
66
80
  timeout?: string;
67
81
  noCache?: boolean;
68
82
  local?: boolean;
83
+ remote?: boolean;
69
84
  verbose?: boolean;
85
+ output?: string;
86
+ apply?: boolean;
87
+ debug?: boolean;
70
88
  }
71
89
 
72
90
  /**
@@ -144,6 +162,144 @@ function parseInputArgs(
144
162
  return inputs;
145
163
  }
146
164
 
165
+ /**
166
+ * Input path configuration (file or directory)
167
+ */
168
+ interface InputPathConfig {
169
+ /** Absolute path on host */
170
+ path: string;
171
+ /** Whether it's a file or directory */
172
+ type: "file" | "directory";
173
+ /** Named input (for multi-input support, e.g., "left" from --input left=./path) */
174
+ name?: string;
175
+ }
176
+
177
+ /**
178
+ * Parse --input flags to separate key=value parameters from directory/file paths
179
+ *
180
+ * The --input flag is overloaded to handle both:
181
+ * 1. Key=value parameters: --input name=Alice --input count=5
182
+ * 2. Directory/file paths: --input ./data --input left=./old
183
+ *
184
+ * Detection logic:
185
+ * - If value contains '=' and doesn't start with './' or '/' → key=value param
186
+ * - If value is a path (starts with ./, ../, /) → input path
187
+ * - If value exists as a path on disk → input path
188
+ * - Named input: name=./path where path exists → named input
189
+ */
190
+ function parseInputPaths(inputs: string[] | undefined): {
191
+ params: Record<string, unknown>;
192
+ inputPaths: InputPathConfig[];
193
+ } {
194
+ if (!inputs) return { params: {}, inputPaths: [] };
195
+
196
+ const params: Record<string, unknown> = {};
197
+ const inputPaths: InputPathConfig[] = [];
198
+
199
+ for (const input of inputs) {
200
+ const eqIndex = input.indexOf("=");
201
+
202
+ // Check if it's a path (no = or starts with path chars)
203
+ const looksLikePath =
204
+ input.startsWith("./") ||
205
+ input.startsWith("../") ||
206
+ input.startsWith("/") ||
207
+ (eqIndex === -1 && existsSync(input));
208
+
209
+ if (looksLikePath) {
210
+ // Simple path: --input ./data
211
+ const absolutePath = resolve(input);
212
+ if (!existsSync(absolutePath)) {
213
+ throw new Error(`Input path does not exist: ${input}`);
214
+ }
215
+ const stat = statSync(absolutePath);
216
+ inputPaths.push({
217
+ path: absolutePath,
218
+ type: stat.isDirectory() ? "directory" : "file",
219
+ });
220
+ } else if (eqIndex > 0) {
221
+ const key = input.slice(0, eqIndex);
222
+ const value = input.slice(eqIndex + 1);
223
+
224
+ // Check if value is a path (named input like left=./old)
225
+ const valueLooksLikePath =
226
+ value.startsWith("./") ||
227
+ value.startsWith("../") ||
228
+ value.startsWith("/") ||
229
+ existsSync(value);
230
+
231
+ if (valueLooksLikePath && existsSync(value)) {
232
+ // Named input path: --input left=./old
233
+ const absolutePath = resolve(value);
234
+ const stat = statSync(absolutePath);
235
+ inputPaths.push({
236
+ path: absolutePath,
237
+ type: stat.isDirectory() ? "directory" : "file",
238
+ name: key,
239
+ });
240
+ } else {
241
+ // Key=value parameter: --input name=Alice
242
+ try {
243
+ params[key] = JSON.parse(value);
244
+ } catch {
245
+ params[key] = value;
246
+ }
247
+ }
248
+ } else {
249
+ // No = sign and doesn't exist as path - treat as error
250
+ throw new Error(`Invalid input: "${input}". Expected key=value or a valid path.`);
251
+ }
252
+ }
253
+
254
+ return { params, inputPaths };
255
+ }
256
+
257
+ /**
258
+ * Atomically replace directory contents with new contents
259
+ *
260
+ * Process:
261
+ * 1. Create backup of original directory
262
+ * 2. Copy new contents to original location
263
+ * 3. Remove backup on success, or restore on failure
264
+ *
265
+ * @param targetDir - Directory to replace contents of
266
+ * @param sourceDir - Directory containing new contents
267
+ */
268
+ function atomicReplace(targetDir: string, sourceDir: string): void {
269
+ const backupDir = `${targetDir}.backup-${Date.now()}`;
270
+
271
+ try {
272
+ // Step 1: Backup original
273
+ if (existsSync(targetDir)) {
274
+ renameSync(targetDir, backupDir);
275
+ }
276
+
277
+ // Step 2: Move new contents to target
278
+ // We copy instead of rename because source might be on different filesystem (temp)
279
+ mkdirSync(targetDir, { recursive: true });
280
+ const entries = readdirSync(sourceDir, { withFileTypes: true });
281
+ for (const entry of entries) {
282
+ const srcPath = join(sourceDir, entry.name);
283
+ const destPath = join(targetDir, entry.name);
284
+ cpSync(srcPath, destPath, { recursive: true });
285
+ }
286
+
287
+ // Step 3: Remove backup on success
288
+ if (existsSync(backupDir)) {
289
+ rmSync(backupDir, { recursive: true, force: true });
290
+ }
291
+ } catch (err) {
292
+ // Restore backup on failure
293
+ if (existsSync(backupDir)) {
294
+ if (existsSync(targetDir)) {
295
+ rmSync(targetDir, { recursive: true, force: true });
296
+ }
297
+ renameSync(backupDir, targetDir);
298
+ }
299
+ throw err;
300
+ }
301
+ }
302
+
147
303
  /**
148
304
  * Extract a bundle to the cache directory
149
305
  */
@@ -418,7 +574,10 @@ function displayDryRun(
418
574
  manifest: ToolManifest,
419
575
  inputs: Record<string, unknown>,
420
576
  command: string[],
421
- env: Record<string, string>
577
+ env: Record<string, string>,
578
+ inputPaths: InputPathConfig[],
579
+ outputPath: string | undefined,
580
+ apply?: boolean
422
581
  ): void {
423
582
  newline();
424
583
  info(colors.bold("Dry Run Preview"));
@@ -430,7 +589,7 @@ function displayDryRun(
430
589
  newline();
431
590
 
432
591
  if (Object.keys(inputs).length > 0) {
433
- info("Inputs:");
592
+ info("Parameters:");
434
593
  for (const [key, value] of Object.entries(inputs)) {
435
594
  dim(` ${key}: ${JSON.stringify(value)}`);
436
595
  }
@@ -445,11 +604,111 @@ function displayDryRun(
445
604
  newline();
446
605
  }
447
606
 
607
+ if (inputPaths.length > 0) {
608
+ info("Input:");
609
+ for (const input of inputPaths) {
610
+ const target = input.name
611
+ ? `/inputs/${input.name}`
612
+ : input.type === "file"
613
+ ? `/input/${basename(input.path)}`
614
+ : "/input";
615
+ dim(` ${input.path} → ${target} (${input.type})`);
616
+ }
617
+ newline();
618
+ }
619
+
620
+ if (outputPath) {
621
+ info("Output:");
622
+ dim(` /output → ${outputPath}`);
623
+ if (apply) {
624
+ dim(` ${colors.warning("(--apply)")} Changes will be atomically applied to ${outputPath}`);
625
+ }
626
+ newline();
627
+ }
628
+
448
629
  info("Command:");
449
630
  dim(` ${command.join(" ")}`);
450
631
  newline();
451
632
  }
452
633
 
634
+ /**
635
+ * Display debug information about parameter resolution
636
+ */
637
+ function displayDebugInfo(
638
+ manifest: ToolManifest,
639
+ rawInputs: Record<string, unknown>,
640
+ inputsWithDefaults: Record<string, unknown>,
641
+ finalInputs: Record<string, unknown>,
642
+ env: Record<string, string>,
643
+ command: string[]
644
+ ): void {
645
+ newline();
646
+ info(colors.bold("Debug: Parameter Resolution"));
647
+ newline();
648
+
649
+ // Show schema information
650
+ if (manifest.inputSchema?.properties) {
651
+ info("Schema Properties:");
652
+ const required = new Set(manifest.inputSchema.required || []);
653
+ for (const [name, prop] of Object.entries(manifest.inputSchema.properties)) {
654
+ const propSchema = prop as { type?: string; default?: unknown; description?: string };
655
+ const isRequired = required.has(name);
656
+ const hasDefault = propSchema.default !== undefined;
657
+ const status = isRequired ? colors.error("required") : colors.dim("optional");
658
+ dim(
659
+ ` ${name}: ${propSchema.type || "any"} [${status}]${hasDefault ? ` (default: ${JSON.stringify(propSchema.default)})` : ""}`
660
+ );
661
+ }
662
+ newline();
663
+ }
664
+
665
+ // Show raw inputs (what was provided)
666
+ info("Raw Inputs (provided by user):");
667
+ if (Object.keys(rawInputs).length === 0) {
668
+ dim(" (none)");
669
+ } else {
670
+ for (const [key, value] of Object.entries(rawInputs)) {
671
+ dim(` ${key}: ${JSON.stringify(value)}`);
672
+ }
673
+ }
674
+ newline();
675
+
676
+ // Show inputs after defaults applied
677
+ info("After Defaults Applied:");
678
+ for (const [key, value] of Object.entries(inputsWithDefaults)) {
679
+ const wasDefault = rawInputs[key] === undefined;
680
+ dim(` ${key}: ${JSON.stringify(value)}${wasDefault ? colors.dim(" (default)") : ""}`);
681
+ }
682
+ newline();
683
+
684
+ // Show final inputs (after coercion)
685
+ info("Final Inputs (after validation/coercion):");
686
+ for (const [key, value] of Object.entries(finalInputs)) {
687
+ dim(` ${key}: ${JSON.stringify(value)}`);
688
+ }
689
+ newline();
690
+
691
+ // Show environment variables
692
+ if (Object.keys(env).length > 0) {
693
+ info("Environment Variables:");
694
+ for (const [key, value] of Object.entries(env)) {
695
+ // Mask potentially sensitive values
696
+ const isSensitive =
697
+ key.toLowerCase().includes("secret") ||
698
+ key.toLowerCase().includes("key") ||
699
+ key.toLowerCase().includes("token") ||
700
+ key.toLowerCase().includes("password");
701
+ dim(` ${key}=${isSensitive ? "***" : value}`);
702
+ }
703
+ newline();
704
+ }
705
+
706
+ // Show final command
707
+ info("Final Command:");
708
+ dim(` ${command.join(" ")}`);
709
+ newline();
710
+ }
711
+
453
712
  /**
454
713
  * Display execution result
455
714
  */
@@ -481,15 +740,30 @@ function displayResult(result: ExecutionResult, options: RunOptions): void {
481
740
  } else {
482
741
  error(`Execution failed: ${result.error?.message ?? "Unknown error"}`);
483
742
 
484
- if (result.error?.details) {
743
+ // Show stdout if present (useful for debugging - command may have printed before failing)
744
+ if (result.output?.stdout?.trim()) {
485
745
  newline();
486
- dim(JSON.stringify(result.error.details, null, 2));
746
+ info("stdout:");
747
+ console.log(result.output.stdout);
487
748
  }
488
749
 
489
- if (result.output?.stderr) {
750
+ // Show stderr (the actual error output)
751
+ if (result.output?.stderr?.trim()) {
490
752
  newline();
491
- dim("stderr:");
492
- dim(result.output.stderr);
753
+ error("stderr:");
754
+ console.log(result.output.stderr);
755
+ }
756
+
757
+ // Show additional error details if present (and different from stderr)
758
+ if (result.error?.details) {
759
+ const detailsStr = JSON.stringify(result.error.details, null, 2);
760
+ // Only show if it adds new information (not just duplicating stderr)
761
+ const stderrInDetails = result.error.details.stderr;
762
+ if (!stderrInDetails || stderrInDetails !== result.output?.stderr) {
763
+ newline();
764
+ dim("Additional details:");
765
+ dim(detailsStr);
766
+ }
493
767
  }
494
768
  }
495
769
  }
@@ -499,25 +773,48 @@ function displayResult(result: ExecutionResult, options: RunOptions): void {
499
773
  */
500
774
  async function runHandler(tool: string, options: RunOptions, ctx: CommandContext): Promise<void> {
501
775
  let resolution: ToolResolution | null = null;
776
+ let resolveResult: ReturnType<typeof tryResolveToolDetailed> | null = null;
502
777
 
503
- // First, try to resolve locally (project user → cache)
504
- if (!options.verbose) {
505
- resolution = tryResolveTool(tool, { startDir: ctx.cwd });
506
- } else {
507
- const spinner = clack.spinner();
508
- spinner.start(`Resolving tool: ${tool}`);
509
- resolution = tryResolveTool(tool, { startDir: ctx.cwd });
510
- if (resolution) {
511
- spinner.stop(`${symbols.success} Resolved: ${tool}`);
778
+ // Check if --remote flag is valid (requires namespace/name format)
779
+ const isRegistryFormat = tool.includes("/") && !tool.startsWith("/") && !tool.startsWith(".");
780
+ if (options.remote && !isRegistryFormat) {
781
+ throw new ValidationError(
782
+ `--remote requires a registry tool name (e.g., user/tool), got: ${tool}`
783
+ );
784
+ }
785
+
786
+ // Skip local resolution if --remote is set
787
+ if (!options.remote) {
788
+ // First, try to resolve locally (project → user → cache)
789
+ if (!options.verbose) {
790
+ resolveResult = tryResolveToolDetailed(tool, { startDir: ctx.cwd });
791
+ resolution = resolveResult.resolution;
512
792
  } else {
513
- spinner.stop(`${symbols.info} Checking registry...`);
793
+ const spinner = clack.spinner();
794
+ spinner.start(`Resolving tool: ${tool}`);
795
+ resolveResult = tryResolveToolDetailed(tool, { startDir: ctx.cwd });
796
+ resolution = resolveResult.resolution;
797
+ if (resolution) {
798
+ spinner.stop(`${symbols.success} Resolved: ${tool}`);
799
+ } else {
800
+ spinner.stop(`${symbols.info} Checking registry...`);
801
+ }
802
+ }
803
+
804
+ // If manifest was found but had errors, throw a descriptive error immediately
805
+ if (!resolution && resolveResult?.manifestFound && resolveResult?.error) {
806
+ const errorMessage = resolveResult.error.message;
807
+ const manifestPath = resolveResult.manifestPath;
808
+ throw new ManifestError(
809
+ `Invalid manifest${manifestPath ? ` at ${manifestPath}` : ""}: ${errorMessage}`
810
+ );
514
811
  }
515
812
  }
516
813
 
517
814
  // If not found locally and --local flag not set, try fetching from registry
518
815
  if (!resolution && !options.local) {
519
816
  // Check if this looks like a tool name (namespace/name format)
520
- if (tool.includes("/") && !tool.startsWith("/") && !tool.startsWith(".")) {
817
+ if (isRegistryFormat) {
521
818
  resolution = !options.verbose
522
819
  ? await fetchAndCacheTool(tool, options, ctx)
523
820
  : await withSpinner(
@@ -530,15 +827,30 @@ async function runHandler(tool: string, options: RunOptions, ctx: CommandContext
530
827
 
531
828
  if (!resolution) {
532
829
  if (options.local) {
533
- throw new ToolNotFoundError(`${tool} (--local flag set, skipped registry)`);
830
+ throw new ToolNotFoundError(tool, {
831
+ localOnly: true,
832
+ ...(resolveResult?.searchedLocations && {
833
+ searchedLocations: resolveResult.searchedLocations,
834
+ }),
835
+ });
534
836
  }
535
- throw new ToolNotFoundError(tool);
837
+ throw new ToolNotFoundError(tool, {
838
+ ...(resolveResult?.searchedLocations && {
839
+ searchedLocations: resolveResult.searchedLocations,
840
+ }),
841
+ });
536
842
  }
537
843
 
538
844
  const manifest = resolution.manifest;
539
845
 
540
- // Parse inputs
541
- const inputs = parseInputArgs(options.args, options.inputFile, options.input);
846
+ // Parse --input flags to separate key=value params from path inputs
847
+ const { params: pathParams, inputPaths } = parseInputPaths(options.input);
848
+
849
+ // Parse other input sources (--args, --input-file)
850
+ const otherInputs = parseInputArgs(options.args, options.inputFile, undefined);
851
+
852
+ // Merge inputs: path params override other inputs
853
+ const inputs = { ...otherInputs, ...pathParams };
542
854
 
543
855
  // Apply defaults from schema
544
856
  const inputsWithDefaults = manifest.inputSchema
@@ -555,6 +867,44 @@ async function runHandler(tool: string, options: RunOptions, ctx: CommandContext
555
867
  // Use coerced values from validation (or inputs with defaults)
556
868
  const finalInputs = validation.coercedValues ?? inputsWithDefaults;
557
869
 
870
+ // Validate output path if provided
871
+ if (options.output) {
872
+ const outputDir = dirname(resolve(options.output));
873
+ if (!existsSync(outputDir)) {
874
+ mkdirSync(outputDir, { recursive: true });
875
+ }
876
+ }
877
+
878
+ // Validate --apply flag requirements
879
+ // --apply requires exactly one input directory and output path
880
+ let applyInputPath: string | undefined;
881
+ if (options.apply) {
882
+ // Must have exactly one directory input
883
+ const dirInputs = inputPaths.filter((p) => p.type === "directory" && !p.name);
884
+ if (dirInputs.length !== 1) {
885
+ throw new ValidationError(
886
+ "--apply requires exactly one unnamed directory input (e.g., --input ./src)"
887
+ );
888
+ }
889
+ applyInputPath = dirInputs[0]?.path;
890
+
891
+ // Must have output path
892
+ if (!options.output) {
893
+ throw new ValidationError("--apply requires --output to be specified");
894
+ }
895
+
896
+ // Output should point to same location as input for in-place apply
897
+ const resolvedOutput = resolve(options.output);
898
+ if (applyInputPath && resolvedOutput !== applyInputPath) {
899
+ // Warn but allow - user might want to apply to a different location
900
+ if (options.verbose) {
901
+ dim(
902
+ `Note: --apply with different input/output paths will copy results to ${resolvedOutput}`
903
+ );
904
+ }
905
+ }
906
+ }
907
+
558
908
  // Check if this is an instruction-based tool (no command)
559
909
  if (!manifest.command) {
560
910
  // For instruction tools, just display the markdown body
@@ -630,9 +980,44 @@ async function runHandler(tool: string, options: RunOptions, ctx: CommandContext
630
980
  }
631
981
  }
632
982
 
983
+ // Build mount configuration
984
+ // Tool source directory is mounted to /workspace
985
+ const mountDirs: Record<string, string> = {
986
+ [resolution.sourceDir]: "/workspace",
987
+ };
988
+
989
+ // Add input paths to mount configuration
990
+ for (const input of inputPaths) {
991
+ if (input.name) {
992
+ // Named input: --input left=./old → /inputs/left
993
+ mountDirs[input.path] = `/inputs/${input.name}`;
994
+ } else if (input.type === "file") {
995
+ // Single file: mount parent dir and we'll use withFile in provider
996
+ // For now, mount as /input/<filename>
997
+ // Note: Dagger's withFile is better but requires provider changes
998
+ mountDirs[input.path] = `/input/${basename(input.path)}`;
999
+ } else {
1000
+ // Single directory: --input ./data → /input
1001
+ mountDirs[input.path] = "/input";
1002
+ }
1003
+ }
1004
+
1005
+ // Debug mode - show detailed parameter resolution info
1006
+ if (options.debug) {
1007
+ displayDebugInfo(manifest, inputs, inputsWithDefaults, finalInputs, envVars, command);
1008
+ }
1009
+
633
1010
  // Dry run mode
634
1011
  if (options.dryRun) {
635
- displayDryRun(manifest, finalInputs, command, envVars);
1012
+ displayDryRun(
1013
+ manifest,
1014
+ finalInputs,
1015
+ command,
1016
+ envVars,
1017
+ inputPaths,
1018
+ options.output,
1019
+ options.apply
1020
+ );
636
1021
  return;
637
1022
  }
638
1023
 
@@ -647,23 +1032,41 @@ async function runHandler(tool: string, options: RunOptions, ctx: CommandContext
647
1032
 
648
1033
  const provider = new DaggerExecutionProvider(providerConfig);
649
1034
 
1035
+ // For --apply, we export to a temp directory first, then atomically replace
1036
+ let tempOutputDir: string | undefined;
1037
+ if (options.apply && options.output) {
1038
+ tempOutputDir = mkdtempSync(join(tmpdir(), "enact-apply-"));
1039
+ }
1040
+
650
1041
  try {
651
1042
  await provider.initialize();
652
1043
 
653
- const executeTask = () =>
654
- provider.execute(
1044
+ const executeTask = () => {
1045
+ const execOptions: {
1046
+ mountDirs: Record<string, string>;
1047
+ inputPaths: typeof inputPaths;
1048
+ outputPath?: string;
1049
+ } = {
1050
+ mountDirs,
1051
+ inputPaths,
1052
+ };
1053
+
1054
+ // When using --apply, export to temp dir first
1055
+ if (tempOutputDir) {
1056
+ execOptions.outputPath = tempOutputDir;
1057
+ } else if (options.output) {
1058
+ execOptions.outputPath = resolve(options.output);
1059
+ }
1060
+
1061
+ return provider.execute(
655
1062
  manifest,
656
1063
  {
657
1064
  params: finalInputs,
658
1065
  envOverrides: envVars,
659
1066
  },
660
- {
661
- // Mount the tool directory to /work in the container
662
- mountDirs: {
663
- [resolution.sourceDir]: "/work",
664
- },
665
- }
1067
+ execOptions
666
1068
  );
1069
+ };
667
1070
 
668
1071
  // Build a descriptive message - container may need to be pulled
669
1072
  const containerImage = manifest.from ?? "node:18-alpine";
@@ -678,8 +1081,35 @@ async function runHandler(tool: string, options: RunOptions, ctx: CommandContext
678
1081
  if (!result.success) {
679
1082
  process.exit(EXIT_EXECUTION_ERROR);
680
1083
  }
1084
+
1085
+ // Apply atomically if --apply was used and execution succeeded
1086
+ if (options.apply && tempOutputDir && options.output) {
1087
+ const targetPath = resolve(options.output);
1088
+
1089
+ if (options.verbose) {
1090
+ info(`Applying changes atomically to ${targetPath}...`);
1091
+ }
1092
+
1093
+ try {
1094
+ atomicReplace(targetPath, tempOutputDir);
1095
+ if (options.verbose) {
1096
+ success(`Changes applied to ${targetPath}`);
1097
+ }
1098
+ } catch (applyErr) {
1099
+ error(`Failed to apply changes: ${formatError(applyErr)}`);
1100
+ dim("Original directory preserved. Changes available in temp directory.");
1101
+ throw applyErr;
1102
+ }
1103
+ }
681
1104
  } finally {
682
- // Provider doesn't have cleanup - Dagger handles this
1105
+ // Clean up temp directory if it exists
1106
+ if (tempOutputDir && existsSync(tempOutputDir)) {
1107
+ try {
1108
+ rmSync(tempOutputDir, { recursive: true, force: true });
1109
+ } catch {
1110
+ // Ignore cleanup errors
1111
+ }
1112
+ }
683
1113
  }
684
1114
  }
685
1115
 
@@ -715,11 +1145,21 @@ export function configureRunCommand(program: Command): void {
715
1145
  .argument("<tool>", "Tool to run (name, path, or '.' for current directory)")
716
1146
  .option("-a, --args <json>", "Input arguments as JSON string (recommended)")
717
1147
  .option("-f, --input-file <path>", "Load input arguments from JSON file")
718
- .option("-i, --input <key=value...>", "Input arguments as key=value pairs (simple values only)")
1148
+ .option(
1149
+ "-i, --input <value...>",
1150
+ "Input: key=value params, ./path for data, or name=./path for named inputs"
1151
+ )
1152
+ .option("-o, --output <path>", "Export /output directory to this path after execution")
1153
+ .option(
1154
+ "--apply",
1155
+ "Apply output back to input directory atomically (use with --input and --output pointing to same path)"
1156
+ )
719
1157
  .option("-t, --timeout <duration>", "Execution timeout (e.g., 30s, 5m)")
720
1158
  .option("--no-cache", "Disable container caching")
721
1159
  .option("--local", "Only resolve from local sources")
1160
+ .option("-r, --remote", "Skip local resolution and fetch from registry")
722
1161
  .option("--dry-run", "Show what would be executed without running")
1162
+ .option("--debug", "Show detailed parameter and environment variable resolution")
723
1163
  .option("-v, --verbose", "Show progress spinners and detailed output")
724
1164
  .option("--json", "Output result as JSON")
725
1165
  .action(async (tool: string, options: RunOptions) => {