@enactprotocol/shared 2.1.23 → 2.1.24

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.
@@ -9,7 +9,7 @@ import { basename, join } from "node:path";
9
9
  import type { ParsedManifest, ToolManifest, ValidationResult } from "../types/manifest";
10
10
  import { MANIFEST_FILES } from "../types/manifest";
11
11
  import { ManifestParseError, parseManifestAuto } from "./parser";
12
- import { validateManifest } from "./validator";
12
+ import { type ValidateManifestOptions, validateManifest } from "./validator";
13
13
 
14
14
  /**
15
15
  * Error thrown when loading a manifest fails
@@ -41,14 +41,22 @@ export interface LoadedManifest {
41
41
  warnings?: ValidationResult["warnings"];
42
42
  }
43
43
 
44
+ /**
45
+ * Options for loading a manifest
46
+ */
47
+ export interface LoadManifestOptions extends ValidateManifestOptions {
48
+ // Inherits allowSimpleNames from ValidateManifestOptions
49
+ }
50
+
44
51
  /**
45
52
  * Load a manifest from a file path
46
53
  *
47
54
  * @param filePath - Path to the manifest file (SKILL.md, enact.md, enact.yaml, or enact.yml)
55
+ * @param options - Options for loading and validation
48
56
  * @returns LoadedManifest with validated manifest and metadata
49
57
  * @throws ManifestLoadError if file doesn't exist, parse fails, or validation fails
50
58
  */
51
- export function loadManifest(filePath: string): LoadedManifest {
59
+ export function loadManifest(filePath: string, options: LoadManifestOptions = {}): LoadedManifest {
52
60
  // Check file exists
53
61
  if (!existsSync(filePath)) {
54
62
  throw new ManifestLoadError(`Manifest file not found: ${filePath}`, filePath);
@@ -82,7 +90,7 @@ export function loadManifest(filePath: string): LoadedManifest {
82
90
  }
83
91
 
84
92
  // Validate the manifest
85
- const validation = validateManifest(parsed.manifest);
93
+ const validation = validateManifest(parsed.manifest, options);
86
94
 
87
95
  if (!validation.valid) {
88
96
  const errorMessages =
@@ -114,15 +122,19 @@ export function loadManifest(filePath: string): LoadedManifest {
114
122
  * Searches for SKILL.md, enact.md, enact.yaml, or enact.yml in the given directory
115
123
  *
116
124
  * @param dir - Directory to search for manifest
125
+ * @param options - Options for loading and validation
117
126
  * @returns LoadedManifest if found
118
127
  * @throws ManifestLoadError if no manifest found or loading fails
119
128
  */
120
- export function loadManifestFromDir(dir: string): LoadedManifest {
129
+ export function loadManifestFromDir(
130
+ dir: string,
131
+ options: LoadManifestOptions = {}
132
+ ): LoadedManifest {
121
133
  // Try each manifest filename in order of preference
122
134
  for (const filename of MANIFEST_FILES) {
123
135
  const filePath = join(dir, filename);
124
136
  if (existsSync(filePath)) {
125
- return loadManifest(filePath);
137
+ return loadManifest(filePath, options);
126
138
  }
127
139
  }
128
140
 
@@ -162,11 +174,15 @@ export function hasManifest(dir: string): boolean {
162
174
  * Try to load a manifest, returning null instead of throwing
163
175
  *
164
176
  * @param filePath - Path to the manifest file
177
+ * @param options - Options for loading and validation
165
178
  * @returns LoadedManifest or null if loading fails
166
179
  */
167
- export function tryLoadManifest(filePath: string): LoadedManifest | null {
180
+ export function tryLoadManifest(
181
+ filePath: string,
182
+ options: LoadManifestOptions = {}
183
+ ): LoadedManifest | null {
168
184
  try {
169
- return loadManifest(filePath);
185
+ return loadManifest(filePath, options);
170
186
  } catch {
171
187
  return null;
172
188
  }
@@ -176,11 +192,15 @@ export function tryLoadManifest(filePath: string): LoadedManifest | null {
176
192
  * Try to load a manifest from a directory, returning null instead of throwing
177
193
  *
178
194
  * @param dir - Directory to search
195
+ * @param options - Options for loading and validation
179
196
  * @returns LoadedManifest or null if no manifest found or loading fails
180
197
  */
181
- export function tryLoadManifestFromDir(dir: string): LoadedManifest | null {
198
+ export function tryLoadManifestFromDir(
199
+ dir: string,
200
+ options: LoadManifestOptions = {}
201
+ ): LoadedManifest | null {
182
202
  try {
183
- return loadManifestFromDir(dir);
203
+ return loadManifestFromDir(dir, options);
184
204
  } catch {
185
205
  return null;
186
206
  }
@@ -83,68 +83,85 @@ const SEMVER_REGEX =
83
83
  /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
84
84
 
85
85
  /**
86
- * Tool name regex - hierarchical path format
86
+ * Tool name regex - hierarchical path format (required for publishing)
87
87
  */
88
88
  const TOOL_NAME_REGEX = /^[a-z0-9_-]+(?:\/[a-z0-9_-]+)+$/;
89
89
 
90
+ /**
91
+ * Tool name regex - simple format (allowed for local tools)
92
+ * Allows both hierarchical (org/tool) and simple (my-tool) names
93
+ */
94
+ const TOOL_NAME_REGEX_LOCAL = /^[a-z0-9_-]+(?:\/[a-z0-9_-]+)*$/;
95
+
90
96
  /**
91
97
  * Go duration regex (used for timeout)
92
98
  */
93
99
  const GO_DURATION_REGEX = /^(\d+)(ns|us|µs|ms|s|m|h)$/;
94
100
 
95
101
  /**
96
- * Complete tool manifest schema
102
+ * Create a tool manifest schema with configurable name validation
97
103
  */
98
- const ToolManifestSchema = z
99
- .object({
100
- // Required fields
101
- name: z
102
- .string()
103
- .min(1, "Tool name is required")
104
- .regex(
105
- TOOL_NAME_REGEX,
106
- "Tool name must be hierarchical path format (e.g., 'org/tool' or 'org/category/tool')"
107
- ),
108
-
109
- description: z
110
- .string()
111
- .min(1, "Description is required")
112
- .max(500, "Description should be 500 characters or less"),
113
-
114
- // Recommended fields
115
- enact: z.string().optional(),
116
- version: z
117
- .string()
118
- .regex(SEMVER_REGEX, "Version must be valid semver (e.g., '1.0.0')")
119
- .optional(),
120
- from: z.string().optional(),
121
- command: z.string().optional(),
122
- timeout: z
123
- .string()
124
- .regex(GO_DURATION_REGEX, "Timeout must be Go duration format (e.g., '30s', '5m', '1h')")
125
- .optional(),
126
- license: z.string().optional(),
127
- tags: z.array(z.string()).optional(),
128
-
129
- // Schema fields
130
- inputSchema: JsonSchemaSchema.optional(),
131
- outputSchema: JsonSchemaSchema.optional(),
132
-
133
- // Environment variables
134
- env: z.record(z.string(), EnvVariableSchema).optional(),
135
-
136
- // Behavior & Resources
137
- annotations: ToolAnnotationsSchema.optional(),
138
- resources: ResourceRequirementsSchema.optional(),
139
-
140
- // Documentation
141
- doc: z.string().optional(),
142
- authors: z.array(AuthorSchema).optional(),
143
-
144
- // Testing
145
- examples: z.array(ToolExampleSchema).optional(),
146
- })
147
- .passthrough(); // Allow x-* custom fields
104
+ function createToolManifestSchema(allowSimpleNames: boolean) {
105
+ const nameRegex = allowSimpleNames ? TOOL_NAME_REGEX_LOCAL : TOOL_NAME_REGEX;
106
+ const nameMessage = allowSimpleNames
107
+ ? "Tool name must contain only lowercase letters, numbers, hyphens, and underscores"
108
+ : "Tool name must be hierarchical path format (e.g., 'org/tool' or 'org/category/tool')";
109
+
110
+ return z
111
+ .object({
112
+ // Required fields
113
+ name: z.string().min(1, "Tool name is required").regex(nameRegex, nameMessage),
114
+
115
+ description: z
116
+ .string()
117
+ .min(1, "Description is required")
118
+ .max(500, "Description should be 500 characters or less"),
119
+
120
+ // Recommended fields
121
+ enact: z.string().optional(),
122
+ version: z
123
+ .string()
124
+ .regex(SEMVER_REGEX, "Version must be valid semver (e.g., '1.0.0')")
125
+ .optional(),
126
+ from: z.string().optional(),
127
+ command: z.string().optional(),
128
+ timeout: z
129
+ .string()
130
+ .regex(GO_DURATION_REGEX, "Timeout must be Go duration format (e.g., '30s', '5m', '1h')")
131
+ .optional(),
132
+ license: z.string().optional(),
133
+ tags: z.array(z.string()).optional(),
134
+
135
+ // Schema fields
136
+ inputSchema: JsonSchemaSchema.optional(),
137
+ outputSchema: JsonSchemaSchema.optional(),
138
+
139
+ // Environment variables
140
+ env: z.record(z.string(), EnvVariableSchema).optional(),
141
+
142
+ // Behavior & Resources
143
+ annotations: ToolAnnotationsSchema.optional(),
144
+ resources: ResourceRequirementsSchema.optional(),
145
+
146
+ // Documentation
147
+ doc: z.string().optional(),
148
+ authors: z.array(AuthorSchema).optional(),
149
+
150
+ // Testing
151
+ examples: z.array(ToolExampleSchema).optional(),
152
+ })
153
+ .passthrough(); // Allow x-* custom fields
154
+ }
155
+
156
+ /**
157
+ * Complete tool manifest schema (strict - requires hierarchical names)
158
+ */
159
+ const ToolManifestSchema = createToolManifestSchema(false);
160
+
161
+ /**
162
+ * Local tool manifest schema (relaxed - allows simple names)
163
+ */
164
+ const ToolManifestSchemaLocal = createToolManifestSchema(true);
148
165
 
149
166
  // ==================== Validation Functions ====================
150
167
 
@@ -239,14 +256,31 @@ function generateWarnings(manifest: ToolManifest): ValidationWarning[] {
239
256
  return warnings;
240
257
  }
241
258
 
259
+ /**
260
+ * Options for manifest validation
261
+ */
262
+ export interface ValidateManifestOptions {
263
+ /**
264
+ * Allow simple tool names without hierarchy (e.g., "my-tool" instead of "org/my-tool").
265
+ * Use this for local tools that won't be published.
266
+ * @default false
267
+ */
268
+ allowSimpleNames?: boolean;
269
+ }
270
+
242
271
  /**
243
272
  * Validate a tool manifest
244
273
  *
245
274
  * @param manifest - The manifest to validate (parsed but unvalidated)
275
+ * @param options - Validation options
246
276
  * @returns ValidationResult with valid flag, errors, and warnings
247
277
  */
248
- export function validateManifest(manifest: unknown): ValidationResult {
249
- const result = ToolManifestSchema.safeParse(manifest);
278
+ export function validateManifest(
279
+ manifest: unknown,
280
+ options: ValidateManifestOptions = {}
281
+ ): ValidationResult {
282
+ const schema = options.allowSimpleNames ? ToolManifestSchemaLocal : ToolManifestSchema;
283
+ const result = schema.safeParse(manifest);
250
284
 
251
285
  if (!result.success) {
252
286
  return {
@@ -270,11 +304,15 @@ export function validateManifest(manifest: unknown): ValidationResult {
270
304
  * Throws if validation fails
271
305
  *
272
306
  * @param manifest - The manifest to validate
307
+ * @param options - Validation options
273
308
  * @returns The validated ToolManifest
274
309
  * @throws Error if validation fails
275
310
  */
276
- export function validateManifestStrict(manifest: unknown): ToolManifest {
277
- const result = validateManifest(manifest);
311
+ export function validateManifestStrict(
312
+ manifest: unknown,
313
+ options: ValidateManifestOptions = {}
314
+ ): ToolManifest {
315
+ const result = validateManifest(manifest, options);
278
316
 
279
317
  if (!result.valid) {
280
318
  const errorMessages = result.errors?.map((e) => `${e.path}: ${e.message}`).join(", ");
@@ -285,12 +323,19 @@ export function validateManifestStrict(manifest: unknown): ToolManifest {
285
323
  }
286
324
 
287
325
  /**
288
- * Check if a string is a valid tool name
326
+ * Check if a string is a valid tool name (hierarchical format for publishing)
289
327
  */
290
328
  export function isValidToolName(name: string): boolean {
291
329
  return TOOL_NAME_REGEX.test(name);
292
330
  }
293
331
 
332
+ /**
333
+ * Check if a string is a valid local tool name (allows simple names)
334
+ */
335
+ export function isValidLocalToolName(name: string): boolean {
336
+ return TOOL_NAME_REGEX_LOCAL.test(name);
337
+ }
338
+
294
339
  /**
295
340
  * Check if a string is a valid semver version
296
341
  */
package/src/resolver.ts CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { existsSync, readdirSync } from "node:fs";
12
12
  import { dirname, isAbsolute, join, resolve } from "node:path";
13
- import { findManifestFile, loadManifest } from "./manifest/loader";
13
+ import { type LoadManifestOptions, findManifestFile, loadManifest } from "./manifest/loader";
14
14
  import { getCacheDir, getProjectEnactDir } from "./paths";
15
15
  import { getInstalledVersion, getToolCachePath } from "./registry";
16
16
  import type { ToolLocation, ToolResolution } from "./types/manifest";
@@ -73,9 +73,14 @@ export function getToolPath(toolsDir: string, toolName: string): string {
73
73
  *
74
74
  * @param dir - Directory to check
75
75
  * @param location - The location type for metadata
76
+ * @param options - Options for loading the manifest
76
77
  * @returns ToolResolution or null if not found/invalid
77
78
  */
78
- function tryLoadFromDir(dir: string, location: ToolLocation): ToolResolution | null {
79
+ function tryLoadFromDir(
80
+ dir: string,
81
+ location: ToolLocation,
82
+ options: LoadManifestOptions = {}
83
+ ): ToolResolution | null {
79
84
  if (!existsSync(dir)) {
80
85
  return null;
81
86
  }
@@ -86,7 +91,7 @@ function tryLoadFromDir(dir: string, location: ToolLocation): ToolResolution | n
86
91
  }
87
92
 
88
93
  try {
89
- const loaded = loadManifest(manifestPath);
94
+ const loaded = loadManifest(manifestPath, options);
90
95
  return {
91
96
  manifest: loaded.manifest,
92
97
  sourceDir: dir,
@@ -103,6 +108,9 @@ function tryLoadFromDir(dir: string, location: ToolLocation): ToolResolution | n
103
108
  /**
104
109
  * Resolve a tool from a file path
105
110
  *
111
+ * Local/file tools are allowed to have simple names (without hierarchy)
112
+ * since they don't need to be published.
113
+ *
106
114
  * @param filePath - Path to manifest file or directory containing manifest
107
115
  * @returns ToolResolution
108
116
  * @throws ToolResolveError if not found
@@ -110,6 +118,9 @@ function tryLoadFromDir(dir: string, location: ToolLocation): ToolResolution | n
110
118
  export function resolveToolFromPath(filePath: string): ToolResolution {
111
119
  const absolutePath = isAbsolute(filePath) ? filePath : resolve(filePath);
112
120
 
121
+ // Local tools can have simple names (no hierarchy required)
122
+ const localOptions: LoadManifestOptions = { allowSimpleNames: true };
123
+
113
124
  // Check if it's a manifest file directly
114
125
  if (
115
126
  absolutePath.endsWith(".yaml") ||
@@ -120,7 +131,7 @@ export function resolveToolFromPath(filePath: string): ToolResolution {
120
131
  throw new ToolResolveError(`Manifest file not found: ${absolutePath}`, filePath);
121
132
  }
122
133
 
123
- const loaded = loadManifest(absolutePath);
134
+ const loaded = loadManifest(absolutePath, localOptions);
124
135
  return {
125
136
  manifest: loaded.manifest,
126
137
  sourceDir: dirname(absolutePath),
@@ -131,7 +142,7 @@ export function resolveToolFromPath(filePath: string): ToolResolution {
131
142
  }
132
143
 
133
144
  // Treat as directory
134
- const result = tryLoadFromDir(absolutePath, "file");
145
+ const result = tryLoadFromDir(absolutePath, "file", localOptions);
135
146
  if (result) {
136
147
  return result;
137
148
  }
@@ -298,7 +309,8 @@ export function resolveToolAuto(
298
309
 
299
310
  // Check if the path exists as-is (could be a relative directory without ./)
300
311
  if (existsSync(toolNameOrPath)) {
301
- const result = tryLoadFromDir(resolve(toolNameOrPath), "file");
312
+ // Local tools can have simple names (no hierarchy required)
313
+ const result = tryLoadFromDir(resolve(toolNameOrPath), "file", { allowSimpleNames: true });
302
314
  if (result) {
303
315
  return result;
304
316
  }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
+ isValidLocalToolName,
3
4
  isValidTimeout,
4
5
  isValidToolName,
5
6
  isValidVersion,
@@ -237,6 +238,63 @@ describe("manifest validator", () => {
237
238
  });
238
239
  });
239
240
 
241
+ describe("allowSimpleNames option", () => {
242
+ test("rejects simple names by default", () => {
243
+ const manifest = {
244
+ name: "my-tool", // No slash
245
+ description: "A local tool",
246
+ };
247
+
248
+ const result = validateManifest(manifest);
249
+ expect(result.valid).toBe(false);
250
+ expect(result.errors?.some((e) => e.path === "name")).toBe(true);
251
+ });
252
+
253
+ test("accepts simple names with allowSimpleNames option", () => {
254
+ const manifest = {
255
+ name: "my-tool",
256
+ description: "A local tool",
257
+ };
258
+
259
+ const result = validateManifest(manifest, { allowSimpleNames: true });
260
+ expect(result.valid).toBe(true);
261
+ });
262
+
263
+ test("accepts hierarchical names with allowSimpleNames option", () => {
264
+ const manifest = {
265
+ name: "org/tool",
266
+ description: "A published tool",
267
+ };
268
+
269
+ const result = validateManifest(manifest, { allowSimpleNames: true });
270
+ expect(result.valid).toBe(true);
271
+ });
272
+
273
+ test("still rejects invalid characters with allowSimpleNames", () => {
274
+ const manifest = {
275
+ name: "My-Tool", // Uppercase not allowed
276
+ description: "Invalid tool name",
277
+ };
278
+
279
+ const result = validateManifest(manifest, { allowSimpleNames: true });
280
+ expect(result.valid).toBe(false);
281
+ });
282
+
283
+ test("validateManifestStrict respects allowSimpleNames option", () => {
284
+ const manifest = {
285
+ name: "local-tool",
286
+ description: "A local tool",
287
+ };
288
+
289
+ // Should throw without option
290
+ expect(() => validateManifestStrict(manifest)).toThrow();
291
+
292
+ // Should succeed with option
293
+ const result = validateManifestStrict(manifest, { allowSimpleNames: true });
294
+ expect(result.name).toBe("local-tool");
295
+ });
296
+ });
297
+
240
298
  describe("warnings", () => {
241
299
  test("warns about missing recommended fields", () => {
242
300
  const manifest = {
@@ -360,6 +418,25 @@ describe("manifest validator", () => {
360
418
  });
361
419
  });
362
420
 
421
+ describe("isValidLocalToolName", () => {
422
+ test("returns true for simple names (no hierarchy)", () => {
423
+ expect(isValidLocalToolName("my-tool")).toBe(true);
424
+ expect(isValidLocalToolName("tool_name")).toBe(true);
425
+ expect(isValidLocalToolName("simple")).toBe(true);
426
+ });
427
+
428
+ test("returns true for hierarchical names", () => {
429
+ expect(isValidLocalToolName("org/tool")).toBe(true);
430
+ expect(isValidLocalToolName("acme/utils/greeter")).toBe(true);
431
+ });
432
+
433
+ test("returns false for invalid names", () => {
434
+ expect(isValidLocalToolName("My-Tool")).toBe(false); // Uppercase
435
+ expect(isValidLocalToolName("tool name")).toBe(false); // Space
436
+ expect(isValidLocalToolName("")).toBe(false);
437
+ });
438
+ });
439
+
363
440
  describe("isValidVersion", () => {
364
441
  test("returns true for valid semver", () => {
365
442
  expect(isValidVersion("1.0.0")).toBe(true);