@enactprotocol/shared 2.1.22 → 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.
@@ -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
  }
@@ -431,6 +431,237 @@ describe("Command Interpolation", () => {
431
431
  });
432
432
  });
433
433
 
434
+ describe("knownParameters filtering", () => {
435
+ describe("parseCommand with knownParameters", () => {
436
+ test("only treats known parameters as parameters", () => {
437
+ const result = parseCommand("echo ${name} and ${unknown}", {
438
+ knownParameters: new Set(["name"]),
439
+ });
440
+
441
+ expect(result.parameters).toEqual(["name"]);
442
+ expect(result.tokens).toHaveLength(3);
443
+ expect(result.tokens[0]).toEqual({ type: "literal", value: "echo " });
444
+ expect(result.tokens[1]).toEqual({ type: "parameter", name: "name" });
445
+ expect(result.tokens[2]).toEqual({ type: "literal", value: " and ${unknown}" });
446
+ });
447
+
448
+ test("preserves bash array syntax ${#array[@]}", () => {
449
+ const result = parseCommand("echo ${#compliments[@]}", {
450
+ knownParameters: new Set(["name"]),
451
+ });
452
+
453
+ // The entire string should be a literal since #compliments[@] is not a known param
454
+ expect(result.parameters).toEqual([]);
455
+ expect(result.tokens).toHaveLength(1);
456
+ expect(result.tokens[0]).toEqual({
457
+ type: "literal",
458
+ value: "echo ${#compliments[@]}",
459
+ });
460
+ });
461
+
462
+ test("preserves bash array indexing ${array[$i]}", () => {
463
+ const result = parseCommand('echo "${compliments[$random_index]}"', {
464
+ knownParameters: new Set(["name"]),
465
+ });
466
+
467
+ expect(result.parameters).toEqual([]);
468
+ expect(result.tokens).toHaveLength(1);
469
+ expect(result.tokens[0]).toEqual({
470
+ type: "literal",
471
+ value: 'echo "${compliments[$random_index]}"',
472
+ });
473
+ });
474
+
475
+ test("handles mix of known params and bash syntax", () => {
476
+ const cmd = 'echo "${name}" and ${#arr[@]} and ${arr[$i]} and ${OTHER_VAR}';
477
+ const result = parseCommand(cmd, {
478
+ knownParameters: new Set(["name"]),
479
+ });
480
+
481
+ expect(result.parameters).toEqual(["name"]);
482
+ // Should have: literal, param, literal (containing all the bash stuff)
483
+ expect(result.tokens).toHaveLength(3);
484
+ expect(result.tokens[0]).toEqual({ type: "literal", value: "echo " });
485
+ expect(result.tokens[1]).toMatchObject({ type: "parameter", name: "name" });
486
+ expect(result.tokens[2]).toEqual({
487
+ type: "literal",
488
+ value: " and ${#arr[@]} and ${arr[$i]} and ${OTHER_VAR}",
489
+ });
490
+ });
491
+
492
+ test("legacy behavior when knownParameters not provided", () => {
493
+ const result = parseCommand("echo ${name} ${unknown}");
494
+
495
+ // Without knownParameters, all ${...} are treated as params
496
+ expect(result.parameters).toEqual(["name", "unknown"]);
497
+ });
498
+
499
+ test("empty knownParameters set treats nothing as parameter", () => {
500
+ const result = parseCommand("echo ${name} ${other}", {
501
+ knownParameters: new Set(),
502
+ });
503
+
504
+ expect(result.parameters).toEqual([]);
505
+ expect(result.tokens).toHaveLength(1);
506
+ expect(result.tokens[0]).toEqual({
507
+ type: "literal",
508
+ value: "echo ${name} ${other}",
509
+ });
510
+ });
511
+ });
512
+
513
+ describe("interpolateCommand with knownParameters", () => {
514
+ test("only substitutes known parameters", () => {
515
+ const result = interpolateCommand(
516
+ "echo ${name} and ${MY_VAR}",
517
+ { name: "Keith" },
518
+ { knownParameters: new Set(["name"]) }
519
+ );
520
+
521
+ expect(result).toBe("echo Keith and ${MY_VAR}");
522
+ });
523
+
524
+ test("preserves bash array operations", () => {
525
+ const cmd = 'arr=("a" "b"); echo ${#arr[@]} items: ${arr[0]}';
526
+ const result = interpolateCommand(
527
+ cmd,
528
+ {},
529
+ {
530
+ knownParameters: new Set(["name"]),
531
+ }
532
+ );
533
+
534
+ // Nothing should be substituted
535
+ expect(result).toBe(cmd);
536
+ });
537
+
538
+ test("substitutes only schema-defined params in complex command", () => {
539
+ const cmd = `
540
+ NAME="\${name}"
541
+ compliments=("Hello, $NAME!")
542
+ echo "\${compliments[0]}"
543
+ `;
544
+ const result = interpolateCommand(
545
+ cmd,
546
+ { name: "Alice" },
547
+ { knownParameters: new Set(["name"]) }
548
+ );
549
+
550
+ expect(result).toContain("Alice");
551
+ expect(result).toContain("${compliments[0]}");
552
+ });
553
+ });
554
+
555
+ describe("prepareCommand with knownParameters", () => {
556
+ test("passes through bash syntax while substituting known params", () => {
557
+ const result = prepareCommand(
558
+ "echo ${name} ${RANDOM}",
559
+ { name: "test" },
560
+ { knownParameters: new Set(["name"]) }
561
+ );
562
+
563
+ // Contains ${RANDOM} which has $, so needs shell wrap
564
+ expect(result).toEqual(["sh", "-c", "echo test ${RANDOM}"]);
565
+ });
566
+
567
+ test("handles full bash script with arrays", () => {
568
+ const cmd = `
569
+ arr=("\${name}" "b" "c")
570
+ echo \${#arr[@]}
571
+ echo \${arr[0]}
572
+ `;
573
+ const result = prepareCommand(
574
+ cmd,
575
+ { name: "Keith" },
576
+ {
577
+ knownParameters: new Set(["name"]),
578
+ }
579
+ );
580
+
581
+ // Should be shell wrapped due to special chars
582
+ expect(result[0]).toBe("sh");
583
+ expect(result[1]).toBe("-c");
584
+ // The name should be substituted but array syntax preserved
585
+ expect(result[2]).toContain("Keith");
586
+ expect(result[2]).toContain("${#arr[@]}");
587
+ expect(result[2]).toContain("${arr[0]}");
588
+ });
589
+ });
590
+
591
+ describe("getMissingParams with knownParameters", () => {
592
+ test("only checks known parameters", () => {
593
+ const result = getMissingParams(
594
+ "echo ${name} ${unknown}",
595
+ {},
596
+ { knownParameters: new Set(["name"]) }
597
+ );
598
+
599
+ // Only "name" is a known param, and it's missing
600
+ expect(result).toEqual(["name"]);
601
+ });
602
+
603
+ test("ignores unknown ${...} patterns", () => {
604
+ const result = getMissingParams(
605
+ "echo ${name} ${#arr[@]} ${arr[$i]}",
606
+ { name: "Keith" },
607
+ { knownParameters: new Set(["name"]) }
608
+ );
609
+
610
+ // name is provided, others are not params
611
+ expect(result).toEqual([]);
612
+ });
613
+ });
614
+
615
+ describe("real-world bash examples", () => {
616
+ test("compliment generator with bash arrays", () => {
617
+ const cmd = `
618
+ compliments=(
619
+ "You're great, \${name}!"
620
+ "Keep it up, \${name}!"
621
+ )
622
+ random_index=$((RANDOM % \${#compliments[@]}))
623
+ echo "\${compliments[$random_index]}"
624
+ `;
625
+ const result = interpolateCommand(
626
+ cmd,
627
+ { name: "Keith" },
628
+ { knownParameters: new Set(["name"]) }
629
+ );
630
+
631
+ // ${name} should be substituted
632
+ expect(result).toContain("You're great, Keith!");
633
+ expect(result).toContain("Keep it up, Keith!");
634
+ // Bash syntax should be preserved
635
+ expect(result).toContain("${#compliments[@]}");
636
+ expect(result).toContain("${compliments[$random_index]}");
637
+ });
638
+
639
+ test("script with environment variables", () => {
640
+ const cmd = 'echo "Hello ${name}, your home is ${HOME}"';
641
+ const result = interpolateCommand(
642
+ cmd,
643
+ { name: "Alice" },
644
+ { knownParameters: new Set(["name"]) }
645
+ );
646
+
647
+ expect(result).toBe('echo "Hello Alice, your home is ${HOME}"');
648
+ });
649
+
650
+ test("for loop with index variable", () => {
651
+ const cmd = 'for i in 1 2 3; do echo "${prefix}$i"; done';
652
+ const result = interpolateCommand(
653
+ cmd,
654
+ { prefix: "item-" },
655
+ { knownParameters: new Set(["prefix"]) }
656
+ );
657
+
658
+ // prefix substituted, $i preserved (though not in ${} form)
659
+ expect(result).toContain("item-");
660
+ expect(result).toContain("$i");
661
+ });
662
+ });
663
+ });
664
+
434
665
  describe("getMissingParams", () => {
435
666
  test("returns empty array when all params present", () => {
436
667
  const result = getMissingParams("echo ${a} ${b}", { a: "1", b: "2" });
@@ -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);