@enactprotocol/shared 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.
- package/dist/execution/index.d.ts +1 -1
- package/dist/execution/index.d.ts.map +1 -1
- package/dist/execution/index.js.map +1 -1
- package/dist/execution/types.d.ts +16 -1
- package/dist/execution/types.d.ts.map +1 -1
- package/dist/execution/types.js.map +1 -1
- package/dist/execution/validation.d.ts.map +1 -1
- package/dist/execution/validation.js +3 -1
- package/dist/execution/validation.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/resolver.d.ts +26 -0
- package/dist/resolver.d.ts.map +1 -1
- package/dist/resolver.js +103 -12
- package/dist/resolver.js.map +1 -1
- package/package.json +2 -2
- package/src/execution/index.ts +1 -0
- package/src/execution/types.ts +17 -0
- package/src/execution/validation.ts +3 -1
- package/src/index.ts +27 -0
- package/src/mcp-registry.ts +337 -0
- package/src/resolver.ts +136 -13
- package/tests/execution/command.test.ts +62 -0
- package/tests/execution/validation.test.ts +16 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tools registry management
|
|
3
|
+
*
|
|
4
|
+
* Manages mcp.json for tracking tools exposed to MCP clients:
|
|
5
|
+
* - Global only: ~/.enact/mcp.json
|
|
6
|
+
*
|
|
7
|
+
* Similar to tools.json but adds toolset management for organizing
|
|
8
|
+
* which tools are exposed to MCP clients.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { getCacheDir, getEnactHome } from "./paths";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Structure of mcp.json file
|
|
17
|
+
*/
|
|
18
|
+
export interface McpRegistry {
|
|
19
|
+
/** Map of tool name to installed version */
|
|
20
|
+
tools: Record<string, string>;
|
|
21
|
+
/** Named collections of tools */
|
|
22
|
+
toolsets: Record<string, string[]>;
|
|
23
|
+
/** Currently active toolset (null = expose all tools) */
|
|
24
|
+
activeToolset: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Information about an MCP-exposed tool
|
|
29
|
+
*/
|
|
30
|
+
export interface McpToolInfo {
|
|
31
|
+
name: string;
|
|
32
|
+
version: string;
|
|
33
|
+
cachePath: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get the path to mcp.json
|
|
38
|
+
*/
|
|
39
|
+
export function getMcpJsonPath(): string {
|
|
40
|
+
return join(getEnactHome(), "mcp.json");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Load mcp.json
|
|
45
|
+
* Returns empty registry if file doesn't exist
|
|
46
|
+
*/
|
|
47
|
+
export function loadMcpRegistry(): McpRegistry {
|
|
48
|
+
const registryPath = getMcpJsonPath();
|
|
49
|
+
|
|
50
|
+
if (!existsSync(registryPath)) {
|
|
51
|
+
return { tools: {}, toolsets: {}, activeToolset: null };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const content = readFileSync(registryPath, "utf-8");
|
|
56
|
+
const parsed = JSON.parse(content);
|
|
57
|
+
return {
|
|
58
|
+
tools: parsed.tools ?? {},
|
|
59
|
+
toolsets: parsed.toolsets ?? {},
|
|
60
|
+
activeToolset: parsed.activeToolset ?? null,
|
|
61
|
+
};
|
|
62
|
+
} catch {
|
|
63
|
+
// Return empty registry on parse error
|
|
64
|
+
return { tools: {}, toolsets: {}, activeToolset: null };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Save mcp.json
|
|
70
|
+
*/
|
|
71
|
+
export function saveMcpRegistry(registry: McpRegistry): void {
|
|
72
|
+
const registryPath = getMcpJsonPath();
|
|
73
|
+
|
|
74
|
+
// Ensure directory exists
|
|
75
|
+
const dir = dirname(registryPath);
|
|
76
|
+
if (!existsSync(dir)) {
|
|
77
|
+
mkdirSync(dir, { recursive: true });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const content = JSON.stringify(registry, null, 2);
|
|
81
|
+
writeFileSync(registryPath, content, "utf-8");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Add a tool to the MCP registry
|
|
86
|
+
*/
|
|
87
|
+
export function addMcpTool(toolName: string, version: string): void {
|
|
88
|
+
const registry = loadMcpRegistry();
|
|
89
|
+
registry.tools[toolName] = version;
|
|
90
|
+
saveMcpRegistry(registry);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Remove a tool from the MCP registry
|
|
95
|
+
*/
|
|
96
|
+
export function removeMcpTool(toolName: string): boolean {
|
|
97
|
+
const registry = loadMcpRegistry();
|
|
98
|
+
|
|
99
|
+
if (!(toolName in registry.tools)) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
delete registry.tools[toolName];
|
|
104
|
+
|
|
105
|
+
// Also remove from all toolsets
|
|
106
|
+
for (const toolsetName of Object.keys(registry.toolsets)) {
|
|
107
|
+
const toolset = registry.toolsets[toolsetName];
|
|
108
|
+
if (toolset) {
|
|
109
|
+
registry.toolsets[toolsetName] = toolset.filter((t) => t !== toolName);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
saveMcpRegistry(registry);
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check if a tool is in the MCP registry
|
|
119
|
+
*/
|
|
120
|
+
export function isMcpToolInstalled(toolName: string): boolean {
|
|
121
|
+
const registry = loadMcpRegistry();
|
|
122
|
+
return toolName in registry.tools;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get the installed version of an MCP tool
|
|
127
|
+
* Returns null if not installed
|
|
128
|
+
*/
|
|
129
|
+
export function getMcpToolVersion(toolName: string): string | null {
|
|
130
|
+
const registry = loadMcpRegistry();
|
|
131
|
+
return registry.tools[toolName] ?? null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get the cache path for an MCP tool
|
|
136
|
+
*/
|
|
137
|
+
function getMcpToolCachePath(toolName: string, version: string): string {
|
|
138
|
+
const cacheDir = getCacheDir();
|
|
139
|
+
const normalizedVersion = version.startsWith("v") ? version.slice(1) : version;
|
|
140
|
+
return join(cacheDir, toolName, `v${normalizedVersion}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* List all tools that should be exposed to MCP clients
|
|
145
|
+
* If activeToolset is set, only returns tools in that toolset
|
|
146
|
+
* Otherwise returns all tools
|
|
147
|
+
*/
|
|
148
|
+
export function listMcpTools(): McpToolInfo[] {
|
|
149
|
+
const registry = loadMcpRegistry();
|
|
150
|
+
const tools: McpToolInfo[] = [];
|
|
151
|
+
|
|
152
|
+
// Determine which tools to expose
|
|
153
|
+
let toolsToExpose: string[];
|
|
154
|
+
|
|
155
|
+
const activeToolsetTools = registry.activeToolset
|
|
156
|
+
? registry.toolsets[registry.activeToolset]
|
|
157
|
+
: undefined;
|
|
158
|
+
|
|
159
|
+
if (activeToolsetTools) {
|
|
160
|
+
// Filter to only tools in the active toolset that are also installed
|
|
161
|
+
toolsToExpose = activeToolsetTools.filter((name) => name in registry.tools);
|
|
162
|
+
} else {
|
|
163
|
+
// Expose all installed tools
|
|
164
|
+
toolsToExpose = Object.keys(registry.tools);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const name of toolsToExpose) {
|
|
168
|
+
const version = registry.tools[name];
|
|
169
|
+
if (version) {
|
|
170
|
+
tools.push({
|
|
171
|
+
name,
|
|
172
|
+
version,
|
|
173
|
+
cachePath: getMcpToolCachePath(name, version),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return tools;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get info for a specific MCP tool if it's exposed
|
|
183
|
+
*/
|
|
184
|
+
export function getMcpToolInfo(toolName: string): McpToolInfo | null {
|
|
185
|
+
const registry = loadMcpRegistry();
|
|
186
|
+
const version = registry.tools[toolName];
|
|
187
|
+
|
|
188
|
+
if (!version) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Check if tool is in active toolset (if one is set)
|
|
193
|
+
if (registry.activeToolset) {
|
|
194
|
+
const activeToolsetTools = registry.toolsets[registry.activeToolset];
|
|
195
|
+
if (activeToolsetTools && !activeToolsetTools.includes(toolName)) {
|
|
196
|
+
return null; // Tool is installed but not in active toolset
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const cachePath = getMcpToolCachePath(toolName, version);
|
|
201
|
+
|
|
202
|
+
// Verify cache exists
|
|
203
|
+
if (!existsSync(cachePath)) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { name: toolName, version, cachePath };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Toolset management
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Create a new toolset
|
|
214
|
+
*/
|
|
215
|
+
export function createToolset(name: string, tools: string[] = []): void {
|
|
216
|
+
const registry = loadMcpRegistry();
|
|
217
|
+
registry.toolsets[name] = tools;
|
|
218
|
+
saveMcpRegistry(registry);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Delete a toolset
|
|
223
|
+
*/
|
|
224
|
+
export function deleteToolset(name: string): boolean {
|
|
225
|
+
const registry = loadMcpRegistry();
|
|
226
|
+
|
|
227
|
+
if (!(name in registry.toolsets)) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
delete registry.toolsets[name];
|
|
232
|
+
|
|
233
|
+
// Clear active toolset if it was the deleted one
|
|
234
|
+
if (registry.activeToolset === name) {
|
|
235
|
+
registry.activeToolset = null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
saveMcpRegistry(registry);
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Add a tool to a toolset
|
|
244
|
+
*/
|
|
245
|
+
export function addToolToToolset(toolsetName: string, toolName: string): boolean {
|
|
246
|
+
const registry = loadMcpRegistry();
|
|
247
|
+
|
|
248
|
+
const toolset = registry.toolsets[toolsetName];
|
|
249
|
+
if (!toolset) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!toolset.includes(toolName)) {
|
|
254
|
+
toolset.push(toolName);
|
|
255
|
+
saveMcpRegistry(registry);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Remove a tool from a toolset
|
|
263
|
+
*/
|
|
264
|
+
export function removeToolFromToolset(toolsetName: string, toolName: string): boolean {
|
|
265
|
+
const registry = loadMcpRegistry();
|
|
266
|
+
|
|
267
|
+
const toolset = registry.toolsets[toolsetName];
|
|
268
|
+
if (!toolset) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const index = toolset.indexOf(toolName);
|
|
273
|
+
if (index === -1) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
toolset.splice(index, 1);
|
|
278
|
+
saveMcpRegistry(registry);
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Set the active toolset
|
|
284
|
+
*/
|
|
285
|
+
export function setActiveToolset(name: string | null): boolean {
|
|
286
|
+
const registry = loadMcpRegistry();
|
|
287
|
+
|
|
288
|
+
if (name !== null && !(name in registry.toolsets)) {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
registry.activeToolset = name;
|
|
293
|
+
saveMcpRegistry(registry);
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Get the active toolset name
|
|
299
|
+
*/
|
|
300
|
+
export function getActiveToolset(): string | null {
|
|
301
|
+
const registry = loadMcpRegistry();
|
|
302
|
+
return registry.activeToolset;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* List all toolsets
|
|
307
|
+
*/
|
|
308
|
+
export function listToolsets(): Array<{ name: string; tools: string[]; isActive: boolean }> {
|
|
309
|
+
const registry = loadMcpRegistry();
|
|
310
|
+
|
|
311
|
+
return Object.entries(registry.toolsets).map(([name, tools]) => ({
|
|
312
|
+
name,
|
|
313
|
+
tools,
|
|
314
|
+
isActive: registry.activeToolset === name,
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Sync MCP registry with global tools.json
|
|
320
|
+
* Adds any tools from tools.json that aren't in mcp.json
|
|
321
|
+
* Does NOT remove tools (allows mcp.json to have subset of tools.json)
|
|
322
|
+
*/
|
|
323
|
+
export function syncMcpWithGlobalTools(globalTools: Record<string, string>): void {
|
|
324
|
+
const registry = loadMcpRegistry();
|
|
325
|
+
let changed = false;
|
|
326
|
+
|
|
327
|
+
for (const [name, version] of Object.entries(globalTools)) {
|
|
328
|
+
if (!(name in registry.tools)) {
|
|
329
|
+
registry.tools[name] = version;
|
|
330
|
+
changed = true;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (changed) {
|
|
335
|
+
saveMcpRegistry(registry);
|
|
336
|
+
}
|
|
337
|
+
}
|
package/src/resolver.ts
CHANGED
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync, readdirSync } from "node:fs";
|
|
12
12
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
type LoadManifestOptions,
|
|
15
|
+
ManifestLoadError,
|
|
16
|
+
findManifestFile,
|
|
17
|
+
loadManifest,
|
|
18
|
+
} from "./manifest/loader";
|
|
14
19
|
import { getCacheDir, getProjectEnactDir } from "./paths";
|
|
15
20
|
import { getInstalledVersion, getToolCachePath } from "./registry";
|
|
16
21
|
import type { ToolLocation, ToolResolution } from "./types/manifest";
|
|
@@ -29,6 +34,22 @@ export class ToolResolveError extends Error {
|
|
|
29
34
|
}
|
|
30
35
|
}
|
|
31
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Result of trying to resolve a tool with error details
|
|
39
|
+
*/
|
|
40
|
+
export interface TryResolveResult {
|
|
41
|
+
/** The resolved tool, or null if not found/invalid */
|
|
42
|
+
resolution: ToolResolution | null;
|
|
43
|
+
/** Error that occurred during resolution, if any */
|
|
44
|
+
error?: Error;
|
|
45
|
+
/** Locations that were searched */
|
|
46
|
+
searchedLocations: string[];
|
|
47
|
+
/** Whether a manifest was found but had errors */
|
|
48
|
+
manifestFound: boolean;
|
|
49
|
+
/** Path where manifest was found (if any) */
|
|
50
|
+
manifestPath?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
32
53
|
/**
|
|
33
54
|
* Options for tool resolution
|
|
34
55
|
*/
|
|
@@ -267,21 +288,123 @@ export function tryResolveTool(
|
|
|
267
288
|
toolNameOrPath: string,
|
|
268
289
|
options: ResolveOptions = {}
|
|
269
290
|
): ToolResolution | null {
|
|
291
|
+
const result = tryResolveToolDetailed(toolNameOrPath, options);
|
|
292
|
+
return result.resolution;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Try to resolve a tool with detailed error information
|
|
297
|
+
*
|
|
298
|
+
* Unlike tryResolveTool, this function returns information about why
|
|
299
|
+
* resolution failed, allowing callers to provide better error messages.
|
|
300
|
+
*
|
|
301
|
+
* @param toolNameOrPath - Tool name or path
|
|
302
|
+
* @param options - Resolution options
|
|
303
|
+
* @returns TryResolveResult with resolution or error details
|
|
304
|
+
*/
|
|
305
|
+
export function tryResolveToolDetailed(
|
|
306
|
+
toolNameOrPath: string,
|
|
307
|
+
options: ResolveOptions = {}
|
|
308
|
+
): TryResolveResult {
|
|
309
|
+
const searchedLocations: string[] = [];
|
|
310
|
+
|
|
311
|
+
// Check if it looks like a path
|
|
312
|
+
const isPath =
|
|
313
|
+
toolNameOrPath.startsWith("/") ||
|
|
314
|
+
toolNameOrPath.startsWith("./") ||
|
|
315
|
+
toolNameOrPath.startsWith("../") ||
|
|
316
|
+
toolNameOrPath.includes("\\") ||
|
|
317
|
+
existsSync(toolNameOrPath);
|
|
318
|
+
|
|
319
|
+
if (isPath) {
|
|
320
|
+
// Resolve from path
|
|
321
|
+
const absolutePath = isAbsolute(toolNameOrPath) ? toolNameOrPath : resolve(toolNameOrPath);
|
|
322
|
+
searchedLocations.push(absolutePath);
|
|
323
|
+
|
|
324
|
+
// Check if path exists
|
|
325
|
+
if (!existsSync(absolutePath)) {
|
|
326
|
+
return {
|
|
327
|
+
resolution: null,
|
|
328
|
+
searchedLocations,
|
|
329
|
+
manifestFound: false,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Find manifest file
|
|
334
|
+
const manifestPath =
|
|
335
|
+
absolutePath.endsWith(".yaml") ||
|
|
336
|
+
absolutePath.endsWith(".yml") ||
|
|
337
|
+
absolutePath.endsWith(".md")
|
|
338
|
+
? absolutePath
|
|
339
|
+
: findManifestFile(absolutePath);
|
|
340
|
+
|
|
341
|
+
if (!manifestPath) {
|
|
342
|
+
return {
|
|
343
|
+
resolution: null,
|
|
344
|
+
searchedLocations,
|
|
345
|
+
manifestFound: false,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Try to load the manifest
|
|
350
|
+
try {
|
|
351
|
+
const resolution = resolveToolFromPath(toolNameOrPath);
|
|
352
|
+
return {
|
|
353
|
+
resolution,
|
|
354
|
+
searchedLocations,
|
|
355
|
+
manifestFound: true,
|
|
356
|
+
manifestPath,
|
|
357
|
+
};
|
|
358
|
+
} catch (error) {
|
|
359
|
+
// Manifest found but invalid
|
|
360
|
+
return {
|
|
361
|
+
resolution: null,
|
|
362
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
363
|
+
searchedLocations,
|
|
364
|
+
manifestFound: true,
|
|
365
|
+
manifestPath,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Resolve by name
|
|
270
371
|
try {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
toolNameOrPath
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
372
|
+
const resolution = resolveTool(toolNameOrPath, options);
|
|
373
|
+
return {
|
|
374
|
+
resolution,
|
|
375
|
+
searchedLocations: getToolSearchPaths(toolNameOrPath, options),
|
|
376
|
+
manifestFound: true,
|
|
377
|
+
manifestPath: resolution.manifestPath,
|
|
378
|
+
};
|
|
379
|
+
} catch (error) {
|
|
380
|
+
// Check if error is due to manifest validation vs not found
|
|
381
|
+
if (error instanceof ToolResolveError) {
|
|
382
|
+
return {
|
|
383
|
+
resolution: null,
|
|
384
|
+
error,
|
|
385
|
+
searchedLocations: error.searchedLocations ?? [],
|
|
386
|
+
manifestFound: false,
|
|
387
|
+
};
|
|
280
388
|
}
|
|
281
389
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
390
|
+
// ManifestLoadError means manifest was found but invalid
|
|
391
|
+
if (error instanceof ManifestLoadError) {
|
|
392
|
+
return {
|
|
393
|
+
resolution: null,
|
|
394
|
+
error,
|
|
395
|
+
searchedLocations: getToolSearchPaths(toolNameOrPath, options),
|
|
396
|
+
manifestFound: true,
|
|
397
|
+
manifestPath: error.filePath,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Other error
|
|
402
|
+
return {
|
|
403
|
+
resolution: null,
|
|
404
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
405
|
+
searchedLocations: getToolSearchPaths(toolNameOrPath, options),
|
|
406
|
+
manifestFound: false,
|
|
407
|
+
};
|
|
285
408
|
}
|
|
286
409
|
}
|
|
287
410
|
|
|
@@ -662,6 +662,68 @@ echo "\${compliments[$random_index]}"
|
|
|
662
662
|
});
|
|
663
663
|
});
|
|
664
664
|
|
|
665
|
+
describe("onMissing: empty option", () => {
|
|
666
|
+
test("replaces missing params with empty string when onMissing is empty", () => {
|
|
667
|
+
const result = interpolateCommand(
|
|
668
|
+
"echo ${required} ${optional}",
|
|
669
|
+
{ required: "hello" },
|
|
670
|
+
{ onMissing: "empty" }
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
// Empty string is inserted (not quoted) - the shell will treat it as nothing
|
|
674
|
+
expect(result).toBe("echo hello ");
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("handles multiple missing optional params", () => {
|
|
678
|
+
const result = interpolateCommand(
|
|
679
|
+
"cmd ${a} ${b} ${c}",
|
|
680
|
+
{ a: "value" },
|
|
681
|
+
{ onMissing: "empty" }
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
// Multiple missing params become empty strings
|
|
685
|
+
expect(result).toBe("cmd value ");
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("prepareCommand uses empty for missing params by default", () => {
|
|
689
|
+
// prepareCommand sets onMissing: "empty" by default
|
|
690
|
+
const result = prepareCommand("echo ${name} ${optional}", { name: "test" });
|
|
691
|
+
|
|
692
|
+
// When parsed, the empty string is just omitted from the args array
|
|
693
|
+
expect(result).toEqual(["echo", "test"]);
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test("handles optional params in complex commands", () => {
|
|
697
|
+
const result = interpolateCommand(
|
|
698
|
+
"curl ${url} -H '${header}' -d '${data}'",
|
|
699
|
+
{ url: "https://example.com" },
|
|
700
|
+
{ onMissing: "empty" }
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
// Empty strings are inserted, quotes around params are stripped
|
|
704
|
+
expect(result).toBe("curl 'https://example.com' -H -d ");
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("preserves provided values while emptying missing ones", () => {
|
|
708
|
+
const result = interpolateCommand(
|
|
709
|
+
"tool ${required} ${optional1} ${optional2}",
|
|
710
|
+
{ required: "value", optional2: "present" },
|
|
711
|
+
{ onMissing: "empty" }
|
|
712
|
+
);
|
|
713
|
+
|
|
714
|
+
// optional1 becomes empty, optional2 keeps its value
|
|
715
|
+
expect(result).toBe("tool value present");
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
test("does not throw for missing params when onMissing is empty", () => {
|
|
719
|
+
// This is the key behavior - validation catches truly missing required params,
|
|
720
|
+
// but interpolation should not throw for optional params
|
|
721
|
+
expect(() => {
|
|
722
|
+
interpolateCommand("echo ${missing}", {}, { onMissing: "empty" });
|
|
723
|
+
}).not.toThrow();
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
|
|
665
727
|
describe("getMissingParams", () => {
|
|
666
728
|
test("returns empty array when all params present", () => {
|
|
667
729
|
const result = getMissingParams("echo ${a} ${b}", { a: "1", b: "2" });
|
|
@@ -37,6 +37,22 @@ describe("Input Validation", () => {
|
|
|
37
37
|
expect(result.errors[0]?.path).toContain("name");
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
test("includes type hint in error message for missing required params", () => {
|
|
41
|
+
const result = validateInputs({ count: 5 }, simpleSchema);
|
|
42
|
+
|
|
43
|
+
expect(result.valid).toBe(false);
|
|
44
|
+
expect(result.errors[0]?.message).toContain("name");
|
|
45
|
+
expect(result.errors[0]?.message).toContain("string");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("allows optional params to be omitted", () => {
|
|
49
|
+
// Only "name" is required, "count" is optional
|
|
50
|
+
const result = validateInputs({ name: "test" }, simpleSchema);
|
|
51
|
+
|
|
52
|
+
expect(result.valid).toBe(true);
|
|
53
|
+
expect(result.errors).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
40
56
|
test("reports type errors", () => {
|
|
41
57
|
const result = validateInputs({ name: "test", count: "not a number" }, simpleSchema);
|
|
42
58
|
|