@enactprotocol/shared 2.2.1 → 2.2.4

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/src/registry.ts CHANGED
@@ -19,6 +19,8 @@ import { getCacheDir, getEnactHome, getProjectEnactDir } from "./paths";
19
19
  export interface ToolsRegistry {
20
20
  /** Map of tool name to installed version */
21
21
  tools: Record<string, string>;
22
+ /** Map of alias to full tool name */
23
+ aliases?: Record<string, string>;
22
24
  }
23
25
 
24
26
  /**
@@ -56,7 +58,7 @@ export function loadToolsRegistry(scope: RegistryScope, startDir?: string): Tool
56
58
  const registryPath = getToolsJsonPath(scope, startDir);
57
59
 
58
60
  if (!registryPath || !existsSync(registryPath)) {
59
- return { tools: {} };
61
+ return { tools: {}, aliases: {} };
60
62
  }
61
63
 
62
64
  try {
@@ -64,10 +66,11 @@ export function loadToolsRegistry(scope: RegistryScope, startDir?: string): Tool
64
66
  const parsed = JSON.parse(content);
65
67
  return {
66
68
  tools: parsed.tools ?? {},
69
+ aliases: parsed.aliases ?? {},
67
70
  };
68
71
  } catch {
69
72
  // Return empty registry on parse error
70
- return { tools: {} };
73
+ return { tools: {}, aliases: {} };
71
74
  }
72
75
  }
73
76
 
@@ -217,3 +220,129 @@ export function getInstalledToolInfo(
217
220
  cachePath,
218
221
  };
219
222
  }
223
+
224
+ /**
225
+ * Add an alias for a tool
226
+ * @param alias - Short name for the tool (e.g., "firebase")
227
+ * @param toolName - Full tool name (e.g., "user/api/firebase")
228
+ * @param scope - Registry scope (global or project)
229
+ * @param startDir - Starting directory for project scope
230
+ * @throws Error if alias already exists for a different tool
231
+ */
232
+ export function addAlias(
233
+ alias: string,
234
+ toolName: string,
235
+ scope: RegistryScope,
236
+ startDir?: string
237
+ ): void {
238
+ const registry = loadToolsRegistry(scope, startDir);
239
+
240
+ // Initialize aliases if not present
241
+ if (!registry.aliases) {
242
+ registry.aliases = {};
243
+ }
244
+
245
+ // Check if alias already exists for a different tool
246
+ const existingTarget = registry.aliases[alias];
247
+ if (existingTarget && existingTarget !== toolName) {
248
+ throw new Error(
249
+ `Alias "${alias}" already exists for tool "${existingTarget}". Remove it first with 'enact alias --remove ${alias}'.`
250
+ );
251
+ }
252
+
253
+ registry.aliases[alias] = toolName;
254
+ saveToolsRegistry(registry, scope, startDir);
255
+ }
256
+
257
+ /**
258
+ * Remove an alias
259
+ * @param alias - Alias to remove
260
+ * @param scope - Registry scope
261
+ * @param startDir - Starting directory for project scope
262
+ * @returns true if alias was removed, false if it didn't exist
263
+ */
264
+ export function removeAlias(alias: string, scope: RegistryScope, startDir?: string): boolean {
265
+ const registry = loadToolsRegistry(scope, startDir);
266
+
267
+ if (!registry.aliases || !(alias in registry.aliases)) {
268
+ return false;
269
+ }
270
+
271
+ delete registry.aliases[alias];
272
+ saveToolsRegistry(registry, scope, startDir);
273
+ return true;
274
+ }
275
+
276
+ /**
277
+ * Resolve an alias to its full tool name
278
+ * @param alias - Alias to resolve
279
+ * @param scope - Registry scope
280
+ * @param startDir - Starting directory for project scope
281
+ * @returns Full tool name or null if alias doesn't exist
282
+ */
283
+ export function resolveAlias(
284
+ alias: string,
285
+ scope: RegistryScope,
286
+ startDir?: string
287
+ ): string | null {
288
+ const registry = loadToolsRegistry(scope, startDir);
289
+ return registry.aliases?.[alias] ?? null;
290
+ }
291
+
292
+ /**
293
+ * Get all aliases for a specific tool
294
+ * @param toolName - Full tool name
295
+ * @param scope - Registry scope
296
+ * @param startDir - Starting directory for project scope
297
+ * @returns Array of aliases for the tool
298
+ */
299
+ export function getAliasesForTool(
300
+ toolName: string,
301
+ scope: RegistryScope,
302
+ startDir?: string
303
+ ): string[] {
304
+ const registry = loadToolsRegistry(scope, startDir);
305
+ const aliases: string[] = [];
306
+
307
+ if (registry.aliases) {
308
+ for (const [alias, target] of Object.entries(registry.aliases)) {
309
+ if (target === toolName) {
310
+ aliases.push(alias);
311
+ }
312
+ }
313
+ }
314
+
315
+ return aliases;
316
+ }
317
+
318
+ /**
319
+ * Remove all aliases for a specific tool
320
+ * Useful when uninstalling a tool
321
+ * @param toolName - Full tool name
322
+ * @param scope - Registry scope
323
+ * @param startDir - Starting directory for project scope
324
+ * @returns Number of aliases removed
325
+ */
326
+ export function removeAliasesForTool(
327
+ toolName: string,
328
+ scope: RegistryScope,
329
+ startDir?: string
330
+ ): number {
331
+ const registry = loadToolsRegistry(scope, startDir);
332
+ let removed = 0;
333
+
334
+ if (registry.aliases) {
335
+ for (const [alias, target] of Object.entries(registry.aliases)) {
336
+ if (target === toolName) {
337
+ delete registry.aliases[alias];
338
+ removed++;
339
+ }
340
+ }
341
+
342
+ if (removed > 0) {
343
+ saveToolsRegistry(registry, scope, startDir);
344
+ }
345
+ }
346
+
347
+ return removed;
348
+ }
package/src/resolver.ts CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  loadManifest,
18
18
  } from "./manifest/loader";
19
19
  import { getCacheDir, getProjectEnactDir } from "./paths";
20
- import { getInstalledVersion, getToolCachePath } from "./registry";
20
+ import { getInstalledVersion, getToolCachePath, resolveAlias } from "./registry";
21
21
  import type { ToolLocation, ToolResolution } from "./types/manifest";
22
22
 
23
23
  /**
@@ -174,15 +174,26 @@ export function resolveToolFromPath(filePath: string): ToolResolution {
174
174
  /**
175
175
  * Resolve a tool by name, searching through standard locations
176
176
  *
177
- * @param toolName - Tool name (e.g., "acme/utils/greeter")
177
+ * @param toolName - Tool name (e.g., "acme/utils/greeter") or alias (e.g., "firebase")
178
178
  * @param options - Resolution options
179
179
  * @returns ToolResolution
180
180
  * @throws ToolResolveError if not found
181
181
  */
182
182
  export function resolveTool(toolName: string, options: ResolveOptions = {}): ToolResolution {
183
- const normalizedName = normalizeToolName(toolName);
183
+ let normalizedName = normalizeToolName(toolName);
184
184
  const searchedLocations: string[] = [];
185
185
 
186
+ // Check if this might be an alias (no slashes = not a full tool name)
187
+ if (!normalizedName.includes("/")) {
188
+ // Try project-level alias first, then global
189
+ const aliasedName =
190
+ resolveAlias(normalizedName, "project", options.startDir) ??
191
+ resolveAlias(normalizedName, "global");
192
+ if (aliasedName) {
193
+ normalizedName = normalizeToolName(aliasedName);
194
+ }
195
+ }
196
+
186
197
  // 1. Try project tools (.enact/tools/{name}/)
187
198
  if (!options.skipProject) {
188
199
  const projectDir = getProjectEnactDir(options.startDir);
@@ -134,6 +134,17 @@ export interface ToolManifest {
134
134
  /** Resource limits and requirements */
135
135
  resources?: ResourceRequirements;
136
136
 
137
+ // ==================== Agent Skills Spec Fields ====================
138
+
139
+ /** Environment requirements (intended product, system packages, network access, etc.) */
140
+ compatibility?: string;
141
+
142
+ /** Arbitrary key-value metadata for additional properties */
143
+ metadata?: Record<string, string>;
144
+
145
+ /** Space-delimited list of pre-approved tools the skill may use (experimental) */
146
+ allowedTools?: string;
147
+
137
148
  // ==================== Documentation ====================
138
149
 
139
150
  /** Extended documentation (Markdown) */
@@ -182,10 +182,10 @@ describe("manifest validator", () => {
182
182
  }
183
183
  });
184
184
 
185
- test("fails for description over 500 characters", () => {
185
+ test("fails for description over 1024 characters", () => {
186
186
  const manifest = {
187
187
  name: "org/tool",
188
- description: "a".repeat(501),
188
+ description: "a".repeat(1025),
189
189
  };
190
190
 
191
191
  const result = validateManifest(manifest);
@@ -6,14 +6,19 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
6
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import {
9
+ addAlias,
9
10
  addToolToRegistry,
11
+ getAliasesForTool,
10
12
  getInstalledVersion,
11
13
  getToolCachePath,
12
14
  getToolsJsonPath,
13
15
  isToolInstalled,
14
16
  listInstalledTools,
15
17
  loadToolsRegistry,
18
+ removeAlias,
19
+ removeAliasesForTool,
16
20
  removeToolFromRegistry,
21
+ resolveAlias,
17
22
  saveToolsRegistry,
18
23
  } from "../src/registry";
19
24
 
@@ -228,4 +233,194 @@ describe("registry", () => {
228
233
  expect(tools.length).toBe(0);
229
234
  });
230
235
  });
236
+
237
+ describe("addAlias", () => {
238
+ test("adds alias to registry", () => {
239
+ addToolToRegistry("test/aliased-tool", "1.0.0", "project", PROJECT_DIR);
240
+ addAlias("mytool", "test/aliased-tool", "project", PROJECT_DIR);
241
+
242
+ const registry = loadToolsRegistry("project", PROJECT_DIR);
243
+ expect(registry.aliases?.mytool).toBe("test/aliased-tool");
244
+
245
+ // Clean up
246
+ rmSync(join(PROJECT_ENACT_DIR, "tools.json"));
247
+ });
248
+
249
+ test("throws error when alias already exists for different tool", () => {
250
+ addToolToRegistry("test/tool1", "1.0.0", "project", PROJECT_DIR);
251
+ addToolToRegistry("test/tool2", "1.0.0", "project", PROJECT_DIR);
252
+ addAlias("shared", "test/tool1", "project", PROJECT_DIR);
253
+
254
+ expect(() => {
255
+ addAlias("shared", "test/tool2", "project", PROJECT_DIR);
256
+ }).toThrow('Alias "shared" already exists for tool "test/tool1"');
257
+
258
+ // Clean up
259
+ rmSync(join(PROJECT_ENACT_DIR, "tools.json"));
260
+ });
261
+
262
+ test("allows adding same alias for same tool (idempotent)", () => {
263
+ addToolToRegistry("test/same-tool", "1.0.0", "project", PROJECT_DIR);
264
+ addAlias("same", "test/same-tool", "project", PROJECT_DIR);
265
+
266
+ // Should not throw
267
+ expect(() => {
268
+ addAlias("same", "test/same-tool", "project", PROJECT_DIR);
269
+ }).not.toThrow();
270
+
271
+ // Clean up
272
+ rmSync(join(PROJECT_ENACT_DIR, "tools.json"));
273
+ });
274
+ });
275
+
276
+ describe("removeAlias", () => {
277
+ test("removes existing alias", () => {
278
+ addToolToRegistry("test/removable", "1.0.0", "project", PROJECT_DIR);
279
+ addAlias("removeme", "test/removable", "project", PROJECT_DIR);
280
+
281
+ const removed = removeAlias("removeme", "project", PROJECT_DIR);
282
+ expect(removed).toBe(true);
283
+
284
+ const registry = loadToolsRegistry("project", PROJECT_DIR);
285
+ expect(registry.aliases?.removeme).toBeUndefined();
286
+
287
+ // Clean up
288
+ rmSync(join(PROJECT_ENACT_DIR, "tools.json"));
289
+ });
290
+
291
+ test("returns false for non-existent alias", () => {
292
+ const removed = removeAlias("nonexistent", "project", PROJECT_DIR);
293
+ expect(removed).toBe(false);
294
+ });
295
+ });
296
+
297
+ describe("resolveAlias", () => {
298
+ test("resolves existing alias to tool name", () => {
299
+ addToolToRegistry("org/category/full-name", "1.0.0", "project", PROJECT_DIR);
300
+ addAlias("short", "org/category/full-name", "project", PROJECT_DIR);
301
+
302
+ const resolved = resolveAlias("short", "project", PROJECT_DIR);
303
+ expect(resolved).toBe("org/category/full-name");
304
+
305
+ // Clean up
306
+ rmSync(join(PROJECT_ENACT_DIR, "tools.json"));
307
+ });
308
+
309
+ test("returns null for non-existent alias", () => {
310
+ const resolved = resolveAlias("unknown", "project", PROJECT_DIR);
311
+ expect(resolved).toBeNull();
312
+ });
313
+ });
314
+
315
+ describe("getAliasesForTool", () => {
316
+ test("returns all aliases for a tool", () => {
317
+ addToolToRegistry("test/multi-alias", "1.0.0", "project", PROJECT_DIR);
318
+ addAlias("alias1", "test/multi-alias", "project", PROJECT_DIR);
319
+ addAlias("alias2", "test/multi-alias", "project", PROJECT_DIR);
320
+
321
+ const aliases = getAliasesForTool("test/multi-alias", "project", PROJECT_DIR);
322
+ expect(aliases).toContain("alias1");
323
+ expect(aliases).toContain("alias2");
324
+ expect(aliases.length).toBe(2);
325
+
326
+ // Clean up
327
+ rmSync(join(PROJECT_ENACT_DIR, "tools.json"));
328
+ });
329
+
330
+ test("returns empty array for tool without aliases", () => {
331
+ addToolToRegistry("test/no-alias", "1.0.0", "project", PROJECT_DIR);
332
+
333
+ const aliases = getAliasesForTool("test/no-alias", "project", PROJECT_DIR);
334
+ expect(aliases).toEqual([]);
335
+
336
+ // Clean up
337
+ rmSync(join(PROJECT_ENACT_DIR, "tools.json"));
338
+ });
339
+ });
340
+
341
+ describe("removeAliasesForTool", () => {
342
+ test("removes all aliases for a tool", () => {
343
+ addToolToRegistry("test/cleanup", "1.0.0", "project", PROJECT_DIR);
344
+ addAlias("cleanup1", "test/cleanup", "project", PROJECT_DIR);
345
+ addAlias("cleanup2", "test/cleanup", "project", PROJECT_DIR);
346
+
347
+ const removed = removeAliasesForTool("test/cleanup", "project", PROJECT_DIR);
348
+ expect(removed).toBe(2);
349
+
350
+ const registry = loadToolsRegistry("project", PROJECT_DIR);
351
+ expect(registry.aliases?.cleanup1).toBeUndefined();
352
+ expect(registry.aliases?.cleanup2).toBeUndefined();
353
+
354
+ // Clean up
355
+ rmSync(join(PROJECT_ENACT_DIR, "tools.json"));
356
+ });
357
+
358
+ test("returns 0 for tool without aliases", () => {
359
+ addToolToRegistry("test/no-aliases-to-remove", "1.0.0", "project", PROJECT_DIR);
360
+
361
+ const removed = removeAliasesForTool("test/no-aliases-to-remove", "project", PROJECT_DIR);
362
+ expect(removed).toBe(0);
363
+
364
+ // Clean up
365
+ rmSync(join(PROJECT_ENACT_DIR, "tools.json"));
366
+ });
367
+
368
+ test("does not remove aliases for other tools", () => {
369
+ addToolToRegistry("test/keep", "1.0.0", "project", PROJECT_DIR);
370
+ addToolToRegistry("test/remove", "1.0.0", "project", PROJECT_DIR);
371
+ addAlias("keepme", "test/keep", "project", PROJECT_DIR);
372
+ addAlias("removeme", "test/remove", "project", PROJECT_DIR);
373
+
374
+ removeAliasesForTool("test/remove", "project", PROJECT_DIR);
375
+
376
+ const registry = loadToolsRegistry("project", PROJECT_DIR);
377
+ expect(registry.aliases?.keepme).toBe("test/keep");
378
+ expect(registry.aliases?.removeme).toBeUndefined();
379
+
380
+ // Clean up
381
+ rmSync(join(PROJECT_ENACT_DIR, "tools.json"));
382
+ });
383
+ });
384
+
385
+ describe("loadToolsRegistry with aliases", () => {
386
+ test("loads existing registry with aliases", () => {
387
+ const registryPath = join(PROJECT_ENACT_DIR, "tools.json");
388
+ writeFileSync(
389
+ registryPath,
390
+ JSON.stringify({
391
+ tools: {
392
+ "test/tool": "1.0.0",
393
+ },
394
+ aliases: {
395
+ t: "test/tool",
396
+ },
397
+ })
398
+ );
399
+
400
+ const registry = loadToolsRegistry("project", PROJECT_DIR);
401
+ expect(registry.tools["test/tool"]).toBe("1.0.0");
402
+ expect(registry.aliases?.t).toBe("test/tool");
403
+
404
+ // Clean up
405
+ rmSync(registryPath);
406
+ });
407
+
408
+ test("returns empty aliases when not present in file", () => {
409
+ const registryPath = join(PROJECT_ENACT_DIR, "tools.json");
410
+ writeFileSync(
411
+ registryPath,
412
+ JSON.stringify({
413
+ tools: {
414
+ "test/tool": "1.0.0",
415
+ },
416
+ })
417
+ );
418
+
419
+ const registry = loadToolsRegistry("project", PROJECT_DIR);
420
+ expect(registry.aliases).toEqual({});
421
+
422
+ // Clean up
423
+ rmSync(registryPath);
424
+ });
425
+ });
231
426
  });
@@ -1,6 +1,7 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { addAlias, addToolToRegistry, removeAlias } from "../src/registry";
4
5
  import {
5
6
  ToolResolveError,
6
7
  getToolPath,
@@ -269,4 +270,83 @@ Documentation here.
269
270
  expect(error.searchedLocations).toEqual(["/path/1", "/path/2"]);
270
271
  });
271
272
  });
273
+
274
+ describe("alias resolution", () => {
275
+ test("resolves tool via alias", () => {
276
+ // Set up an alias for the project tool
277
+ addToolToRegistry("test/project-tool", "1.0.0", "project", PROJECT_DIR);
278
+ addAlias("pt", "test/project-tool", "project", PROJECT_DIR);
279
+
280
+ try {
281
+ // Resolve using the alias (no slashes = potential alias)
282
+ const result = resolveTool("pt", { startDir: PROJECT_DIR });
283
+ expect(result.manifest.name).toBe("test/project-tool");
284
+ expect(result.location).toBe("project");
285
+ } finally {
286
+ // Clean up
287
+ removeAlias("pt", "project", PROJECT_DIR);
288
+ rmSync(join(PROJECT_ENACT_DIR, "tools.json"), { force: true });
289
+ }
290
+ });
291
+
292
+ test("alias resolution is case-insensitive (normalized to lowercase)", () => {
293
+ addToolToRegistry("test/project-tool", "1.0.0", "project", PROJECT_DIR);
294
+ addAlias("mytool", "test/project-tool", "project", PROJECT_DIR);
295
+
296
+ try {
297
+ // Lowercase alias should work
298
+ const result = resolveTool("mytool", { startDir: PROJECT_DIR });
299
+ expect(result.manifest.name).toBe("test/project-tool");
300
+
301
+ // Uppercase alias should also work (normalized to lowercase)
302
+ const upperResult = resolveTool("MYTOOL", { startDir: PROJECT_DIR });
303
+ expect(upperResult.manifest.name).toBe("test/project-tool");
304
+
305
+ // Mixed case should also work
306
+ const mixedResult = resolveTool("MyTool", { startDir: PROJECT_DIR });
307
+ expect(mixedResult.manifest.name).toBe("test/project-tool");
308
+ } finally {
309
+ removeAlias("mytool", "project", PROJECT_DIR);
310
+ rmSync(join(PROJECT_ENACT_DIR, "tools.json"), { force: true });
311
+ }
312
+ });
313
+
314
+ test("full tool names bypass alias resolution", () => {
315
+ addToolToRegistry("test/project-tool", "1.0.0", "project", PROJECT_DIR);
316
+ // Create an alias that would conflict if checked
317
+ addAlias("test/project-tool", "some/other/tool", "project", PROJECT_DIR);
318
+
319
+ try {
320
+ // Full name with slashes should resolve directly, not via alias
321
+ const result = resolveTool("test/project-tool", { startDir: PROJECT_DIR });
322
+ expect(result.manifest.name).toBe("test/project-tool");
323
+ } finally {
324
+ removeAlias("test/project-tool", "project", PROJECT_DIR);
325
+ rmSync(join(PROJECT_ENACT_DIR, "tools.json"), { force: true });
326
+ }
327
+ });
328
+
329
+ test("tryResolveTool works with aliases", () => {
330
+ addToolToRegistry("test/project-tool", "1.0.0", "project", PROJECT_DIR);
331
+ addAlias("try-alias", "test/project-tool", "project", PROJECT_DIR);
332
+
333
+ try {
334
+ const result = tryResolveTool("try-alias", { startDir: PROJECT_DIR });
335
+ expect(result).not.toBeNull();
336
+ expect(result?.manifest.name).toBe("test/project-tool");
337
+ } finally {
338
+ removeAlias("try-alias", "project", PROJECT_DIR);
339
+ rmSync(join(PROJECT_ENACT_DIR, "tools.json"), { force: true });
340
+ }
341
+ });
342
+
343
+ test("non-existent alias returns null from tryResolveTool", () => {
344
+ const result = tryResolveTool("nonexistent-alias", {
345
+ startDir: PROJECT_DIR,
346
+ skipUser: true,
347
+ skipCache: true,
348
+ });
349
+ expect(result).toBeNull();
350
+ });
351
+ });
272
352
  });