@enactprotocol/cli 2.1.23 → 2.1.28

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 (47) hide show
  1. package/dist/commands/index.d.ts +1 -0
  2. package/dist/commands/index.d.ts.map +1 -1
  3. package/dist/commands/index.js +2 -0
  4. package/dist/commands/index.js.map +1 -1
  5. package/dist/commands/init/templates/agent-agents.d.ts +1 -1
  6. package/dist/commands/init/templates/agent-agents.d.ts.map +1 -1
  7. package/dist/commands/init/templates/agent-agents.js +25 -12
  8. package/dist/commands/init/templates/agent-agents.js.map +1 -1
  9. package/dist/commands/init/templates/claude.d.ts +1 -1
  10. package/dist/commands/init/templates/claude.d.ts.map +1 -1
  11. package/dist/commands/init/templates/claude.js +1 -1
  12. package/dist/commands/init/templates/tool-agents.d.ts +1 -1
  13. package/dist/commands/init/templates/tool-agents.d.ts.map +1 -1
  14. package/dist/commands/init/templates/tool-agents.js +8 -8
  15. package/dist/commands/install/index.d.ts.map +1 -1
  16. package/dist/commands/install/index.js +9 -1
  17. package/dist/commands/install/index.js.map +1 -1
  18. package/dist/commands/mcp/index.d.ts.map +1 -1
  19. package/dist/commands/mcp/index.js +204 -53
  20. package/dist/commands/mcp/index.js.map +1 -1
  21. package/dist/commands/run/index.d.ts.map +1 -1
  22. package/dist/commands/run/index.js +297 -38
  23. package/dist/commands/run/index.js.map +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +4 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/utils/errors.d.ts +8 -1
  29. package/dist/utils/errors.d.ts.map +1 -1
  30. package/dist/utils/errors.js +13 -2
  31. package/dist/utils/errors.js.map +1 -1
  32. package/package.json +5 -5
  33. package/src/commands/index.ts +2 -0
  34. package/src/commands/init/templates/agent-agents.ts +25 -12
  35. package/src/commands/init/templates/claude.ts +1 -1
  36. package/src/commands/init/templates/tool-agents.ts +8 -8
  37. package/src/commands/install/index.ts +11 -0
  38. package/src/commands/learn/index.ts +6 -11
  39. package/src/commands/mcp/index.ts +768 -0
  40. package/src/commands/run/README.md +51 -1
  41. package/src/commands/run/index.ts +374 -35
  42. package/src/index.ts +4 -1
  43. package/src/utils/errors.ts +26 -6
  44. package/tests/commands/init.test.ts +1 -1
  45. package/tests/commands/run.test.ts +260 -0
  46. package/tests/utils/errors.test.ts +36 -0
  47. package/tsconfig.tsbuildinfo +1 -1
@@ -28,7 +28,9 @@ The `run` command executes a tool using the command defined in its manifest (`en
28
28
  | Option | Description |
29
29
  |--------|-------------|
30
30
  | `-a, --args <json>` | Input arguments as a JSON object |
31
- | `-i, --input <key=value>` | Input arguments as key=value pairs (can be repeated) |
31
+ | `-i, --input <value>` | Input: `key=value` for params, `./path` for files/directories, `name=./path` for named inputs |
32
+ | `-o, --output <path>` | Export `/output` directory to this path after execution |
33
+ | `--apply` | Apply output back to input directory atomically (for in-place transformations) |
32
34
  | `-t, --timeout <duration>` | Execution timeout (e.g., `30s`, `5m`, `1h`) |
33
35
  | `--no-cache` | Disable container caching |
34
36
  | `--local` | Only resolve from local sources |
@@ -77,6 +79,54 @@ enact run my-tool --args '{"config":{"debug":true}}' --input file=input.txt
77
79
  enact run my-tool --input name=test --input count=5 --input enabled=true
78
80
  ```
79
81
 
82
+ ### Input Files and Directories
83
+
84
+ Mount files or directories into the container for file-based tools:
85
+
86
+ ```bash
87
+ # Single file input (mounted to /input/<filename>)
88
+ enact run my-tool --input ./document.pdf
89
+
90
+ # Single directory input (mounted to /input)
91
+ enact run my-tool --input ./data
92
+
93
+ # Named inputs (mounted to /inputs/<name>)
94
+ enact run my-tool --input left=./old --input right=./new
95
+ ```
96
+
97
+ ### Output Export
98
+
99
+ Export the container's `/output` directory to the host:
100
+
101
+ ```bash
102
+ # Export output to a local directory
103
+ enact run my-tool --input ./src --output ./dist
104
+
105
+ # The tool writes to /output inside the container
106
+ # After execution, /output is copied to ./dist
107
+ ```
108
+
109
+ ### In-Place Transformations with --apply
110
+
111
+ For tools that transform data in-place (formatters, linters with --fix, etc.):
112
+
113
+ ```bash
114
+ # Apply changes atomically back to the input directory
115
+ enact run formatter --input ./src --output ./src --apply
116
+
117
+ # What happens:
118
+ # 1. ./src is mounted read-only to /input
119
+ # 2. Tool processes files and writes to /output
120
+ # 3. On success, ./src is atomically replaced with /output contents
121
+ # 4. On failure, ./src remains unchanged
122
+ ```
123
+
124
+ **Notes:**
125
+ - `--apply` requires exactly one unnamed directory input
126
+ - `--apply` requires `--output` to be specified
127
+ - Changes are atomic: either all succeed or original is preserved
128
+ - Tool source files are mounted to `/workspace` by default
129
+
80
130
  ## Input Resolution
81
131
 
82
132
  Inputs are resolved in the following priority order:
@@ -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,10 @@ interface RunOptions extends GlobalOptions {
66
80
  timeout?: string;
67
81
  noCache?: boolean;
68
82
  local?: boolean;
69
- quiet?: boolean;
83
+ remote?: boolean;
84
+ verbose?: boolean;
85
+ output?: string;
86
+ apply?: boolean;
70
87
  }
71
88
 
72
89
  /**
@@ -144,6 +161,144 @@ function parseInputArgs(
144
161
  return inputs;
145
162
  }
146
163
 
164
+ /**
165
+ * Input path configuration (file or directory)
166
+ */
167
+ interface InputPathConfig {
168
+ /** Absolute path on host */
169
+ path: string;
170
+ /** Whether it's a file or directory */
171
+ type: "file" | "directory";
172
+ /** Named input (for multi-input support, e.g., "left" from --input left=./path) */
173
+ name?: string;
174
+ }
175
+
176
+ /**
177
+ * Parse --input flags to separate key=value parameters from directory/file paths
178
+ *
179
+ * The --input flag is overloaded to handle both:
180
+ * 1. Key=value parameters: --input name=Alice --input count=5
181
+ * 2. Directory/file paths: --input ./data --input left=./old
182
+ *
183
+ * Detection logic:
184
+ * - If value contains '=' and doesn't start with './' or '/' → key=value param
185
+ * - If value is a path (starts with ./, ../, /) → input path
186
+ * - If value exists as a path on disk → input path
187
+ * - Named input: name=./path where path exists → named input
188
+ */
189
+ function parseInputPaths(inputs: string[] | undefined): {
190
+ params: Record<string, unknown>;
191
+ inputPaths: InputPathConfig[];
192
+ } {
193
+ if (!inputs) return { params: {}, inputPaths: [] };
194
+
195
+ const params: Record<string, unknown> = {};
196
+ const inputPaths: InputPathConfig[] = [];
197
+
198
+ for (const input of inputs) {
199
+ const eqIndex = input.indexOf("=");
200
+
201
+ // Check if it's a path (no = or starts with path chars)
202
+ const looksLikePath =
203
+ input.startsWith("./") ||
204
+ input.startsWith("../") ||
205
+ input.startsWith("/") ||
206
+ (eqIndex === -1 && existsSync(input));
207
+
208
+ if (looksLikePath) {
209
+ // Simple path: --input ./data
210
+ const absolutePath = resolve(input);
211
+ if (!existsSync(absolutePath)) {
212
+ throw new Error(`Input path does not exist: ${input}`);
213
+ }
214
+ const stat = statSync(absolutePath);
215
+ inputPaths.push({
216
+ path: absolutePath,
217
+ type: stat.isDirectory() ? "directory" : "file",
218
+ });
219
+ } else if (eqIndex > 0) {
220
+ const key = input.slice(0, eqIndex);
221
+ const value = input.slice(eqIndex + 1);
222
+
223
+ // Check if value is a path (named input like left=./old)
224
+ const valueLooksLikePath =
225
+ value.startsWith("./") ||
226
+ value.startsWith("../") ||
227
+ value.startsWith("/") ||
228
+ existsSync(value);
229
+
230
+ if (valueLooksLikePath && existsSync(value)) {
231
+ // Named input path: --input left=./old
232
+ const absolutePath = resolve(value);
233
+ const stat = statSync(absolutePath);
234
+ inputPaths.push({
235
+ path: absolutePath,
236
+ type: stat.isDirectory() ? "directory" : "file",
237
+ name: key,
238
+ });
239
+ } else {
240
+ // Key=value parameter: --input name=Alice
241
+ try {
242
+ params[key] = JSON.parse(value);
243
+ } catch {
244
+ params[key] = value;
245
+ }
246
+ }
247
+ } else {
248
+ // No = sign and doesn't exist as path - treat as error
249
+ throw new Error(`Invalid input: "${input}". Expected key=value or a valid path.`);
250
+ }
251
+ }
252
+
253
+ return { params, inputPaths };
254
+ }
255
+
256
+ /**
257
+ * Atomically replace directory contents with new contents
258
+ *
259
+ * Process:
260
+ * 1. Create backup of original directory
261
+ * 2. Copy new contents to original location
262
+ * 3. Remove backup on success, or restore on failure
263
+ *
264
+ * @param targetDir - Directory to replace contents of
265
+ * @param sourceDir - Directory containing new contents
266
+ */
267
+ function atomicReplace(targetDir: string, sourceDir: string): void {
268
+ const backupDir = `${targetDir}.backup-${Date.now()}`;
269
+
270
+ try {
271
+ // Step 1: Backup original
272
+ if (existsSync(targetDir)) {
273
+ renameSync(targetDir, backupDir);
274
+ }
275
+
276
+ // Step 2: Move new contents to target
277
+ // We copy instead of rename because source might be on different filesystem (temp)
278
+ mkdirSync(targetDir, { recursive: true });
279
+ const entries = readdirSync(sourceDir, { withFileTypes: true });
280
+ for (const entry of entries) {
281
+ const srcPath = join(sourceDir, entry.name);
282
+ const destPath = join(targetDir, entry.name);
283
+ cpSync(srcPath, destPath, { recursive: true });
284
+ }
285
+
286
+ // Step 3: Remove backup on success
287
+ if (existsSync(backupDir)) {
288
+ rmSync(backupDir, { recursive: true, force: true });
289
+ }
290
+ } catch (err) {
291
+ // Restore backup on failure
292
+ if (existsSync(backupDir)) {
293
+ if (existsSync(targetDir)) {
294
+ rmSync(targetDir, { recursive: true, force: true });
295
+ }
296
+ renameSync(backupDir, targetDir);
297
+ }
298
+ throw err;
299
+ }
300
+ }
301
+
147
302
  /**
148
303
  * Extract a bundle to the cache directory
149
304
  */
@@ -418,7 +573,10 @@ function displayDryRun(
418
573
  manifest: ToolManifest,
419
574
  inputs: Record<string, unknown>,
420
575
  command: string[],
421
- env: Record<string, string>
576
+ env: Record<string, string>,
577
+ inputPaths: InputPathConfig[],
578
+ outputPath: string | undefined,
579
+ apply?: boolean
422
580
  ): void {
423
581
  newline();
424
582
  info(colors.bold("Dry Run Preview"));
@@ -430,7 +588,7 @@ function displayDryRun(
430
588
  newline();
431
589
 
432
590
  if (Object.keys(inputs).length > 0) {
433
- info("Inputs:");
591
+ info("Parameters:");
434
592
  for (const [key, value] of Object.entries(inputs)) {
435
593
  dim(` ${key}: ${JSON.stringify(value)}`);
436
594
  }
@@ -445,6 +603,28 @@ function displayDryRun(
445
603
  newline();
446
604
  }
447
605
 
606
+ if (inputPaths.length > 0) {
607
+ info("Input:");
608
+ for (const input of inputPaths) {
609
+ const target = input.name
610
+ ? `/inputs/${input.name}`
611
+ : input.type === "file"
612
+ ? `/input/${basename(input.path)}`
613
+ : "/input";
614
+ dim(` ${input.path} → ${target} (${input.type})`);
615
+ }
616
+ newline();
617
+ }
618
+
619
+ if (outputPath) {
620
+ info("Output:");
621
+ dim(` /output → ${outputPath}`);
622
+ if (apply) {
623
+ dim(` ${colors.warning("(--apply)")} Changes will be atomically applied to ${outputPath}`);
624
+ }
625
+ newline();
626
+ }
627
+
448
628
  info("Command:");
449
629
  dim(` ${command.join(" ")}`);
450
630
  newline();
@@ -499,26 +679,49 @@ function displayResult(result: ExecutionResult, options: RunOptions): void {
499
679
  */
500
680
  async function runHandler(tool: string, options: RunOptions, ctx: CommandContext): Promise<void> {
501
681
  let resolution: ToolResolution | null = null;
682
+ let resolveResult: ReturnType<typeof tryResolveToolDetailed> | null = null;
502
683
 
503
- // First, try to resolve locally (project user → cache)
504
- if (options.quiet) {
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}`);
684
+ // Check if --remote flag is valid (requires namespace/name format)
685
+ const isRegistryFormat = tool.includes("/") && !tool.startsWith("/") && !tool.startsWith(".");
686
+ if (options.remote && !isRegistryFormat) {
687
+ throw new ValidationError(
688
+ `--remote requires a registry tool name (e.g., user/tool), got: ${tool}`
689
+ );
690
+ }
691
+
692
+ // Skip local resolution if --remote is set
693
+ if (!options.remote) {
694
+ // First, try to resolve locally (project → user → cache)
695
+ if (!options.verbose) {
696
+ resolveResult = tryResolveToolDetailed(tool, { startDir: ctx.cwd });
697
+ resolution = resolveResult.resolution;
512
698
  } else {
513
- spinner.stop(`${symbols.info} Checking registry...`);
699
+ const spinner = clack.spinner();
700
+ spinner.start(`Resolving tool: ${tool}`);
701
+ resolveResult = tryResolveToolDetailed(tool, { startDir: ctx.cwd });
702
+ resolution = resolveResult.resolution;
703
+ if (resolution) {
704
+ spinner.stop(`${symbols.success} Resolved: ${tool}`);
705
+ } else {
706
+ spinner.stop(`${symbols.info} Checking registry...`);
707
+ }
708
+ }
709
+
710
+ // If manifest was found but had errors, throw a descriptive error immediately
711
+ if (!resolution && resolveResult?.manifestFound && resolveResult?.error) {
712
+ const errorMessage = resolveResult.error.message;
713
+ const manifestPath = resolveResult.manifestPath;
714
+ throw new ManifestError(
715
+ `Invalid manifest${manifestPath ? ` at ${manifestPath}` : ""}: ${errorMessage}`
716
+ );
514
717
  }
515
718
  }
516
719
 
517
720
  // If not found locally and --local flag not set, try fetching from registry
518
721
  if (!resolution && !options.local) {
519
722
  // Check if this looks like a tool name (namespace/name format)
520
- if (tool.includes("/") && !tool.startsWith("/") && !tool.startsWith(".")) {
521
- resolution = options.quiet
723
+ if (isRegistryFormat) {
724
+ resolution = !options.verbose
522
725
  ? await fetchAndCacheTool(tool, options, ctx)
523
726
  : await withSpinner(
524
727
  `Fetching ${tool} from registry...`,
@@ -530,15 +733,30 @@ async function runHandler(tool: string, options: RunOptions, ctx: CommandContext
530
733
 
531
734
  if (!resolution) {
532
735
  if (options.local) {
533
- throw new ToolNotFoundError(`${tool} (--local flag set, skipped registry)`);
736
+ throw new ToolNotFoundError(tool, {
737
+ localOnly: true,
738
+ ...(resolveResult?.searchedLocations && {
739
+ searchedLocations: resolveResult.searchedLocations,
740
+ }),
741
+ });
534
742
  }
535
- throw new ToolNotFoundError(tool);
743
+ throw new ToolNotFoundError(tool, {
744
+ ...(resolveResult?.searchedLocations && {
745
+ searchedLocations: resolveResult.searchedLocations,
746
+ }),
747
+ });
536
748
  }
537
749
 
538
750
  const manifest = resolution.manifest;
539
751
 
540
- // Parse inputs
541
- const inputs = parseInputArgs(options.args, options.inputFile, options.input);
752
+ // Parse --input flags to separate key=value params from path inputs
753
+ const { params: pathParams, inputPaths } = parseInputPaths(options.input);
754
+
755
+ // Parse other input sources (--args, --input-file)
756
+ const otherInputs = parseInputArgs(options.args, options.inputFile, undefined);
757
+
758
+ // Merge inputs: path params override other inputs
759
+ const inputs = { ...otherInputs, ...pathParams };
542
760
 
543
761
  // Apply defaults from schema
544
762
  const inputsWithDefaults = manifest.inputSchema
@@ -555,6 +773,44 @@ async function runHandler(tool: string, options: RunOptions, ctx: CommandContext
555
773
  // Use coerced values from validation (or inputs with defaults)
556
774
  const finalInputs = validation.coercedValues ?? inputsWithDefaults;
557
775
 
776
+ // Validate output path if provided
777
+ if (options.output) {
778
+ const outputDir = dirname(resolve(options.output));
779
+ if (!existsSync(outputDir)) {
780
+ mkdirSync(outputDir, { recursive: true });
781
+ }
782
+ }
783
+
784
+ // Validate --apply flag requirements
785
+ // --apply requires exactly one input directory and output path
786
+ let applyInputPath: string | undefined;
787
+ if (options.apply) {
788
+ // Must have exactly one directory input
789
+ const dirInputs = inputPaths.filter((p) => p.type === "directory" && !p.name);
790
+ if (dirInputs.length !== 1) {
791
+ throw new ValidationError(
792
+ "--apply requires exactly one unnamed directory input (e.g., --input ./src)"
793
+ );
794
+ }
795
+ applyInputPath = dirInputs[0]?.path;
796
+
797
+ // Must have output path
798
+ if (!options.output) {
799
+ throw new ValidationError("--apply requires --output to be specified");
800
+ }
801
+
802
+ // Output should point to same location as input for in-place apply
803
+ const resolvedOutput = resolve(options.output);
804
+ if (applyInputPath && resolvedOutput !== applyInputPath) {
805
+ // Warn but allow - user might want to apply to a different location
806
+ if (options.verbose) {
807
+ dim(
808
+ `Note: --apply with different input/output paths will copy results to ${resolvedOutput}`
809
+ );
810
+ }
811
+ }
812
+ }
813
+
558
814
  // Check if this is an instruction-based tool (no command)
559
815
  if (!manifest.command) {
560
816
  // For instruction tools, just display the markdown body
@@ -630,9 +886,39 @@ async function runHandler(tool: string, options: RunOptions, ctx: CommandContext
630
886
  }
631
887
  }
632
888
 
889
+ // Build mount configuration
890
+ // Tool source directory is mounted to /workspace
891
+ const mountDirs: Record<string, string> = {
892
+ [resolution.sourceDir]: "/workspace",
893
+ };
894
+
895
+ // Add input paths to mount configuration
896
+ for (const input of inputPaths) {
897
+ if (input.name) {
898
+ // Named input: --input left=./old → /inputs/left
899
+ mountDirs[input.path] = `/inputs/${input.name}`;
900
+ } else if (input.type === "file") {
901
+ // Single file: mount parent dir and we'll use withFile in provider
902
+ // For now, mount as /input/<filename>
903
+ // Note: Dagger's withFile is better but requires provider changes
904
+ mountDirs[input.path] = `/input/${basename(input.path)}`;
905
+ } else {
906
+ // Single directory: --input ./data → /input
907
+ mountDirs[input.path] = "/input";
908
+ }
909
+ }
910
+
633
911
  // Dry run mode
634
912
  if (options.dryRun) {
635
- displayDryRun(manifest, finalInputs, command, envVars);
913
+ displayDryRun(
914
+ manifest,
915
+ finalInputs,
916
+ command,
917
+ envVars,
918
+ inputPaths,
919
+ options.output,
920
+ options.apply
921
+ );
636
922
  return;
637
923
  }
638
924
 
@@ -647,29 +933,47 @@ async function runHandler(tool: string, options: RunOptions, ctx: CommandContext
647
933
 
648
934
  const provider = new DaggerExecutionProvider(providerConfig);
649
935
 
936
+ // For --apply, we export to a temp directory first, then atomically replace
937
+ let tempOutputDir: string | undefined;
938
+ if (options.apply && options.output) {
939
+ tempOutputDir = mkdtempSync(join(tmpdir(), "enact-apply-"));
940
+ }
941
+
650
942
  try {
651
943
  await provider.initialize();
652
944
 
653
- const executeTask = () =>
654
- provider.execute(
945
+ const executeTask = () => {
946
+ const execOptions: {
947
+ mountDirs: Record<string, string>;
948
+ inputPaths: typeof inputPaths;
949
+ outputPath?: string;
950
+ } = {
951
+ mountDirs,
952
+ inputPaths,
953
+ };
954
+
955
+ // When using --apply, export to temp dir first
956
+ if (tempOutputDir) {
957
+ execOptions.outputPath = tempOutputDir;
958
+ } else if (options.output) {
959
+ execOptions.outputPath = resolve(options.output);
960
+ }
961
+
962
+ return provider.execute(
655
963
  manifest,
656
964
  {
657
965
  params: finalInputs,
658
966
  envOverrides: envVars,
659
967
  },
660
- {
661
- // Mount the tool directory to /work in the container
662
- mountDirs: {
663
- [resolution.sourceDir]: "/work",
664
- },
665
- }
968
+ execOptions
666
969
  );
970
+ };
667
971
 
668
972
  // Build a descriptive message - container may need to be pulled
669
973
  const containerImage = manifest.from ?? "node:18-alpine";
670
974
  const spinnerMessage = `Running ${manifest.name} (${containerImage})...`;
671
975
 
672
- const result = options.quiet
976
+ const result = !options.verbose
673
977
  ? await executeTask()
674
978
  : await withSpinner(spinnerMessage, executeTask, `${symbols.success} Execution complete`);
675
979
 
@@ -678,8 +982,35 @@ async function runHandler(tool: string, options: RunOptions, ctx: CommandContext
678
982
  if (!result.success) {
679
983
  process.exit(EXIT_EXECUTION_ERROR);
680
984
  }
985
+
986
+ // Apply atomically if --apply was used and execution succeeded
987
+ if (options.apply && tempOutputDir && options.output) {
988
+ const targetPath = resolve(options.output);
989
+
990
+ if (options.verbose) {
991
+ info(`Applying changes atomically to ${targetPath}...`);
992
+ }
993
+
994
+ try {
995
+ atomicReplace(targetPath, tempOutputDir);
996
+ if (options.verbose) {
997
+ success(`Changes applied to ${targetPath}`);
998
+ }
999
+ } catch (applyErr) {
1000
+ error(`Failed to apply changes: ${formatError(applyErr)}`);
1001
+ dim("Original directory preserved. Changes available in temp directory.");
1002
+ throw applyErr;
1003
+ }
1004
+ }
681
1005
  } finally {
682
- // Provider doesn't have cleanup - Dagger handles this
1006
+ // Clean up temp directory if it exists
1007
+ if (tempOutputDir && existsSync(tempOutputDir)) {
1008
+ try {
1009
+ rmSync(tempOutputDir, { recursive: true, force: true });
1010
+ } catch {
1011
+ // Ignore cleanup errors
1012
+ }
1013
+ }
683
1014
  }
684
1015
  }
685
1016
 
@@ -715,13 +1046,21 @@ export function configureRunCommand(program: Command): void {
715
1046
  .argument("<tool>", "Tool to run (name, path, or '.' for current directory)")
716
1047
  .option("-a, --args <json>", "Input arguments as JSON string (recommended)")
717
1048
  .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)")
1049
+ .option(
1050
+ "-i, --input <value...>",
1051
+ "Input: key=value params, ./path for data, or name=./path for named inputs"
1052
+ )
1053
+ .option("-o, --output <path>", "Export /output directory to this path after execution")
1054
+ .option(
1055
+ "--apply",
1056
+ "Apply output back to input directory atomically (use with --input and --output pointing to same path)"
1057
+ )
719
1058
  .option("-t, --timeout <duration>", "Execution timeout (e.g., 30s, 5m)")
720
1059
  .option("--no-cache", "Disable container caching")
721
1060
  .option("--local", "Only resolve from local sources")
1061
+ .option("-r, --remote", "Skip local resolution and fetch from registry")
722
1062
  .option("--dry-run", "Show what would be executed without running")
723
- .option("-q, --quiet", "Suppress spinner output, show only tool output")
724
- .option("-v, --verbose", "Show detailed output")
1063
+ .option("-v, --verbose", "Show progress spinners and detailed output")
725
1064
  .option("--json", "Output result as JSON")
726
1065
  .action(async (tool: string, options: RunOptions) => {
727
1066
  const ctx: CommandContext = {
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  configureInstallCommand,
22
22
  configureLearnCommand,
23
23
  configureListCommand,
24
+ configureMcpCommand,
24
25
  configurePublishCommand,
25
26
  configureReportCommand,
26
27
  configureRunCommand,
@@ -34,7 +35,7 @@ import {
34
35
  } from "./commands";
35
36
  import { error, formatError } from "./utils";
36
37
 
37
- export const version = "2.1.23";
38
+ export const version = "2.1.28";
38
39
 
39
40
  // Export types for external use
40
41
  export type { GlobalOptions, CommandContext } from "./types";
@@ -81,6 +82,8 @@ async function main() {
81
82
 
82
83
  // Private tools - visibility management
83
84
  configureVisibilityCommand(program);
85
+ // MCP integration commands
86
+ configureMcpCommand(program);
84
87
 
85
88
  // Global error handler - handle Commander's help/version exits gracefully
86
89
  program.exitOverride((err) => {
@@ -45,12 +45,32 @@ export class CliError extends Error {
45
45
  * Tool not found error
46
46
  */
47
47
  export class ToolNotFoundError extends CliError {
48
- constructor(toolName: string) {
49
- super(
50
- `Tool not found: ${toolName}`,
51
- EXIT_TOOL_NOT_FOUND,
52
- "Check the tool name or provide a path to a local tool.\nFor registry tools, use the format: owner/namespace/tool[@version]"
53
- );
48
+ constructor(
49
+ toolName: string,
50
+ options?: {
51
+ /** Additional context about why the tool wasn't found */
52
+ reason?: string;
53
+ /** Locations that were searched */
54
+ searchedLocations?: string[];
55
+ /** Whether --local flag was set */
56
+ localOnly?: boolean;
57
+ }
58
+ ) {
59
+ let message = `Tool not found: ${toolName}`;
60
+ if (options?.reason) {
61
+ message += `\n${options.reason}`;
62
+ }
63
+ if (options?.searchedLocations && options.searchedLocations.length > 0) {
64
+ message += `\nSearched locations:\n${options.searchedLocations.map((l) => ` - ${l}`).join("\n")}`;
65
+ }
66
+
67
+ let suggestion =
68
+ "Check the tool name or provide a path to a local tool.\nFor registry tools, use the format: owner/namespace/tool[@version]";
69
+ if (options?.localOnly) {
70
+ suggestion = "Remove --local flag to search the registry, or check the tool path.";
71
+ }
72
+
73
+ super(message, EXIT_TOOL_NOT_FOUND, suggestion);
54
74
  this.name = "ToolNotFoundError";
55
75
  }
56
76
  }
@@ -456,7 +456,7 @@ describe("init command", () => {
456
456
  expect(content).toContain("enact publish");
457
457
  expect(content).toContain("${param}");
458
458
  expect(content).toContain("build:");
459
- expect(content).toContain("/work");
459
+ expect(content).toContain("/workspace");
460
460
  });
461
461
 
462
462
  test("AGENTS.md for agent projects contains usage instructions", async () => {