@enactprotocol/cli 2.1.24 → 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.
- package/dist/commands/index.d.ts +1 -0
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +2 -0
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/init/templates/claude.d.ts +1 -1
- package/dist/commands/init/templates/claude.d.ts.map +1 -1
- package/dist/commands/init/templates/claude.js +1 -1
- package/dist/commands/init/templates/tool-agents.d.ts +1 -1
- package/dist/commands/init/templates/tool-agents.d.ts.map +1 -1
- package/dist/commands/init/templates/tool-agents.js +8 -8
- package/dist/commands/install/index.d.ts.map +1 -1
- package/dist/commands/install/index.js +9 -1
- package/dist/commands/install/index.js.map +1 -1
- package/dist/commands/mcp/index.d.ts.map +1 -1
- package/dist/commands/mcp/index.js +204 -53
- package/dist/commands/mcp/index.js.map +1 -1
- package/dist/commands/run/index.d.ts.map +1 -1
- package/dist/commands/run/index.js +294 -34
- package/dist/commands/run/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/utils/errors.d.ts +8 -1
- package/dist/utils/errors.d.ts.map +1 -1
- package/dist/utils/errors.js +13 -2
- package/dist/utils/errors.js.map +1 -1
- package/package.json +5 -5
- package/src/commands/index.ts +2 -0
- package/src/commands/init/templates/claude.ts +1 -1
- package/src/commands/init/templates/tool-agents.ts +8 -8
- package/src/commands/install/index.ts +11 -0
- package/src/commands/learn/index.ts +6 -11
- package/src/commands/mcp/index.ts +768 -0
- package/src/commands/run/README.md +51 -1
- package/src/commands/run/index.ts +370 -30
- package/src/index.ts +4 -1
- package/src/utils/errors.ts +26 -6
- package/tests/commands/init.test.ts +1 -1
- package/tests/commands/run.test.ts +260 -0
- package/tests/utils/errors.test.ts +36 -0
- 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 <
|
|
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 {
|
|
13
|
-
|
|
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;
|
|
83
|
+
remote?: boolean;
|
|
69
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("
|
|
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,25 +679,48 @@ 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
|
-
//
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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.
|
|
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 (
|
|
723
|
+
if (isRegistryFormat) {
|
|
521
724
|
resolution = !options.verbose
|
|
522
725
|
? await fetchAndCacheTool(tool, options, ctx)
|
|
523
726
|
: await withSpinner(
|
|
@@ -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(
|
|
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
|
|
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(
|
|
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,23 +933,41 @@ 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
|
-
|
|
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";
|
|
@@ -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
|
-
//
|
|
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,10 +1046,19 @@ 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(
|
|
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
1063
|
.option("-v, --verbose", "Show progress spinners and detailed output")
|
|
724
1064
|
.option("--json", "Output result as JSON")
|
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.
|
|
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) => {
|
package/src/utils/errors.ts
CHANGED
|
@@ -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(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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("/
|
|
459
|
+
expect(content).toContain("/workspace");
|
|
460
460
|
});
|
|
461
461
|
|
|
462
462
|
test("AGENTS.md for agent projects contains usage instructions", async () => {
|