@enactprotocol/shared 1.2.11 → 2.0.0

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.
Files changed (134) hide show
  1. package/README.md +44 -0
  2. package/package.json +16 -58
  3. package/src/config.ts +476 -0
  4. package/src/constants.ts +36 -0
  5. package/src/execution/command.ts +314 -0
  6. package/src/execution/index.ts +73 -0
  7. package/src/execution/runtime.ts +308 -0
  8. package/src/execution/types.ts +379 -0
  9. package/src/execution/validation.ts +508 -0
  10. package/src/index.ts +237 -30
  11. package/src/manifest/index.ts +36 -0
  12. package/src/manifest/loader.ts +187 -0
  13. package/src/manifest/parser.ts +173 -0
  14. package/src/manifest/validator.ts +309 -0
  15. package/src/paths.ts +108 -0
  16. package/src/registry.ts +219 -0
  17. package/src/resolver.ts +345 -0
  18. package/src/types/index.ts +30 -0
  19. package/src/types/manifest.ts +255 -0
  20. package/src/types.ts +5 -188
  21. package/src/utils/fs.ts +281 -0
  22. package/src/utils/logger.ts +270 -59
  23. package/src/utils/version.ts +304 -36
  24. package/tests/config.test.ts +515 -0
  25. package/tests/execution/command.test.ts +317 -0
  26. package/tests/execution/validation.test.ts +384 -0
  27. package/tests/fixtures/invalid-tool.yaml +4 -0
  28. package/tests/fixtures/valid-tool.md +62 -0
  29. package/tests/fixtures/valid-tool.yaml +40 -0
  30. package/tests/index.test.ts +8 -0
  31. package/tests/manifest/loader.test.ts +291 -0
  32. package/tests/manifest/parser.test.ts +345 -0
  33. package/tests/manifest/validator.test.ts +394 -0
  34. package/tests/manifest-types.test.ts +358 -0
  35. package/tests/paths.test.ts +153 -0
  36. package/tests/registry.test.ts +231 -0
  37. package/tests/resolver.test.ts +272 -0
  38. package/tests/utils/fs.test.ts +388 -0
  39. package/tests/utils/logger.test.ts +480 -0
  40. package/tests/utils/version.test.ts +390 -0
  41. package/tsconfig.json +12 -0
  42. package/tsconfig.tsbuildinfo +1 -0
  43. package/dist/LocalToolResolver.d.ts +0 -84
  44. package/dist/LocalToolResolver.js +0 -353
  45. package/dist/api/enact-api.d.ts +0 -130
  46. package/dist/api/enact-api.js +0 -428
  47. package/dist/api/index.d.ts +0 -2
  48. package/dist/api/index.js +0 -2
  49. package/dist/api/types.d.ts +0 -103
  50. package/dist/api/types.js +0 -1
  51. package/dist/constants.d.ts +0 -7
  52. package/dist/constants.js +0 -10
  53. package/dist/core/DaggerExecutionProvider.d.ts +0 -169
  54. package/dist/core/DaggerExecutionProvider.js +0 -1029
  55. package/dist/core/DirectExecutionProvider.d.ts +0 -23
  56. package/dist/core/DirectExecutionProvider.js +0 -406
  57. package/dist/core/EnactCore.d.ts +0 -162
  58. package/dist/core/EnactCore.js +0 -597
  59. package/dist/core/NativeExecutionProvider.d.ts +0 -9
  60. package/dist/core/NativeExecutionProvider.js +0 -16
  61. package/dist/core/index.d.ts +0 -3
  62. package/dist/core/index.js +0 -3
  63. package/dist/exec/index.d.ts +0 -3
  64. package/dist/exec/index.js +0 -3
  65. package/dist/exec/logger.d.ts +0 -11
  66. package/dist/exec/logger.js +0 -57
  67. package/dist/exec/validate.d.ts +0 -5
  68. package/dist/exec/validate.js +0 -167
  69. package/dist/index.d.ts +0 -21
  70. package/dist/index.js +0 -25
  71. package/dist/lib/enact-direct.d.ts +0 -150
  72. package/dist/lib/enact-direct.js +0 -159
  73. package/dist/lib/index.d.ts +0 -1
  74. package/dist/lib/index.js +0 -1
  75. package/dist/security/index.d.ts +0 -3
  76. package/dist/security/index.js +0 -3
  77. package/dist/security/security.d.ts +0 -23
  78. package/dist/security/security.js +0 -137
  79. package/dist/security/sign.d.ts +0 -103
  80. package/dist/security/sign.js +0 -666
  81. package/dist/security/verification-enforcer.d.ts +0 -53
  82. package/dist/security/verification-enforcer.js +0 -204
  83. package/dist/services/McpCoreService.d.ts +0 -98
  84. package/dist/services/McpCoreService.js +0 -124
  85. package/dist/services/index.d.ts +0 -1
  86. package/dist/services/index.js +0 -1
  87. package/dist/types.d.ts +0 -132
  88. package/dist/types.js +0 -3
  89. package/dist/utils/config.d.ts +0 -111
  90. package/dist/utils/config.js +0 -342
  91. package/dist/utils/env-loader.d.ts +0 -54
  92. package/dist/utils/env-loader.js +0 -270
  93. package/dist/utils/help.d.ts +0 -36
  94. package/dist/utils/help.js +0 -248
  95. package/dist/utils/index.d.ts +0 -7
  96. package/dist/utils/index.js +0 -7
  97. package/dist/utils/logger.d.ts +0 -35
  98. package/dist/utils/logger.js +0 -75
  99. package/dist/utils/silent-monitor.d.ts +0 -67
  100. package/dist/utils/silent-monitor.js +0 -242
  101. package/dist/utils/timeout.d.ts +0 -5
  102. package/dist/utils/timeout.js +0 -23
  103. package/dist/utils/version.d.ts +0 -4
  104. package/dist/utils/version.js +0 -35
  105. package/dist/web/env-manager-server.d.ts +0 -29
  106. package/dist/web/env-manager-server.js +0 -367
  107. package/dist/web/index.d.ts +0 -1
  108. package/dist/web/index.js +0 -1
  109. package/src/LocalToolResolver.ts +0 -424
  110. package/src/api/enact-api.ts +0 -604
  111. package/src/api/index.ts +0 -2
  112. package/src/api/types.ts +0 -114
  113. package/src/core/DaggerExecutionProvider.ts +0 -1357
  114. package/src/core/DirectExecutionProvider.ts +0 -484
  115. package/src/core/EnactCore.ts +0 -847
  116. package/src/core/index.ts +0 -3
  117. package/src/exec/index.ts +0 -3
  118. package/src/exec/logger.ts +0 -63
  119. package/src/exec/validate.ts +0 -238
  120. package/src/lib/enact-direct.ts +0 -254
  121. package/src/lib/index.ts +0 -1
  122. package/src/services/McpCoreService.ts +0 -201
  123. package/src/services/index.ts +0 -1
  124. package/src/utils/config.ts +0 -438
  125. package/src/utils/env-loader.ts +0 -370
  126. package/src/utils/help.ts +0 -257
  127. package/src/utils/index.ts +0 -7
  128. package/src/utils/silent-monitor.ts +0 -328
  129. package/src/utils/timeout.ts +0 -26
  130. package/src/web/env-manager-server.ts +0 -465
  131. package/src/web/index.ts +0 -1
  132. package/src/web/static/app.js +0 -663
  133. package/src/web/static/index.html +0 -117
  134. package/src/web/static/style.css +0 -291
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Manifest validator using Zod
3
+ *
4
+ * Validates that parsed manifests conform to the Enact specification
5
+ */
6
+
7
+ import { z } from "zod/v4";
8
+ import type {
9
+ ToolManifest,
10
+ ValidationError,
11
+ ValidationResult,
12
+ ValidationWarning,
13
+ } from "../types/manifest";
14
+
15
+ // ==================== Zod Schemas ====================
16
+
17
+ /**
18
+ * Environment variable schema
19
+ */
20
+ const EnvVariableSchema = z.object({
21
+ description: z.string().min(1, "Description is required"),
22
+ secret: z.boolean().optional(),
23
+ default: z.string().optional(),
24
+ });
25
+
26
+ /**
27
+ * Author schema
28
+ */
29
+ const AuthorSchema = z.object({
30
+ name: z.string().min(1, "Author name is required"),
31
+ email: z.string().email().optional(),
32
+ url: z.string().url().optional(),
33
+ });
34
+
35
+ /**
36
+ * Tool annotations schema
37
+ */
38
+ const ToolAnnotationsSchema = z.object({
39
+ title: z.string().optional(),
40
+ readOnlyHint: z.boolean().optional(),
41
+ destructiveHint: z.boolean().optional(),
42
+ idempotentHint: z.boolean().optional(),
43
+ openWorldHint: z.boolean().optional(),
44
+ });
45
+
46
+ /**
47
+ * Resource requirements schema
48
+ */
49
+ const ResourceRequirementsSchema = z.object({
50
+ memory: z.string().optional(),
51
+ gpu: z.string().optional(),
52
+ disk: z.string().optional(),
53
+ });
54
+
55
+ /**
56
+ * Tool example schema
57
+ */
58
+ const ToolExampleSchema = z.object({
59
+ input: z.record(z.string(), z.unknown()).optional(),
60
+ output: z.unknown().optional(),
61
+ description: z.string().optional(),
62
+ });
63
+
64
+ /**
65
+ * JSON Schema validation (basic structure check)
66
+ * We don't fully validate JSON Schema here, just ensure it's an object
67
+ */
68
+ const JsonSchemaSchema = z
69
+ .object({
70
+ type: z.string().optional(),
71
+ properties: z.record(z.string(), z.unknown()).optional(),
72
+ required: z.array(z.string()).optional(),
73
+ items: z.unknown().optional(),
74
+ enum: z.array(z.unknown()).optional(),
75
+ description: z.string().optional(),
76
+ })
77
+ .passthrough(); // Allow additional JSON Schema fields
78
+
79
+ /**
80
+ * Semantic version regex
81
+ */
82
+ const SEMVER_REGEX =
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
+
85
+ /**
86
+ * Tool name regex - hierarchical path format
87
+ */
88
+ const TOOL_NAME_REGEX = /^[a-z0-9_-]+(?:\/[a-z0-9_-]+)+$/;
89
+
90
+ /**
91
+ * Go duration regex (used for timeout)
92
+ */
93
+ const GO_DURATION_REGEX = /^(\d+)(ns|us|µs|ms|s|m|h)$/;
94
+
95
+ /**
96
+ * Complete tool manifest schema
97
+ */
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
148
+
149
+ // ==================== Validation Functions ====================
150
+
151
+ /**
152
+ * Convert Zod error to our ValidationError format
153
+ */
154
+ function zodErrorToValidationErrors(zodError: z.ZodError): ValidationError[] {
155
+ return zodError.issues.map((issue) => ({
156
+ path: issue.path.join("."),
157
+ message: issue.message,
158
+ code: issue.code,
159
+ }));
160
+ }
161
+
162
+ /**
163
+ * Generate warnings for recommended but missing fields
164
+ */
165
+ function generateWarnings(manifest: ToolManifest): ValidationWarning[] {
166
+ const warnings: ValidationWarning[] = [];
167
+
168
+ // Check for recommended fields
169
+ if (!manifest.enact) {
170
+ warnings.push({
171
+ path: "enact",
172
+ message: "Protocol version (enact) is recommended for compatibility",
173
+ code: "MISSING_RECOMMENDED",
174
+ });
175
+ }
176
+
177
+ if (!manifest.version) {
178
+ warnings.push({
179
+ path: "version",
180
+ message: "Tool version is recommended for versioning and updates",
181
+ code: "MISSING_RECOMMENDED",
182
+ });
183
+ }
184
+
185
+ if (!manifest.from && manifest.command) {
186
+ warnings.push({
187
+ path: "from",
188
+ message: "Container image (from) is recommended for reproducibility",
189
+ code: "MISSING_RECOMMENDED",
190
+ });
191
+ }
192
+
193
+ if (!manifest.license) {
194
+ warnings.push({
195
+ path: "license",
196
+ message: "License is recommended for published tools",
197
+ code: "MISSING_RECOMMENDED",
198
+ });
199
+ }
200
+
201
+ if (!manifest.inputSchema && manifest.command) {
202
+ warnings.push({
203
+ path: "inputSchema",
204
+ message: "Input schema is recommended for tools with parameters",
205
+ code: "MISSING_RECOMMENDED",
206
+ });
207
+ }
208
+
209
+ if (!manifest.outputSchema) {
210
+ warnings.push({
211
+ path: "outputSchema",
212
+ message: "Output schema is recommended for structured output validation",
213
+ code: "MISSING_RECOMMENDED",
214
+ });
215
+ }
216
+
217
+ // Check for potential issues
218
+ if (manifest.env) {
219
+ for (const [key, value] of Object.entries(manifest.env)) {
220
+ if (value.secret && value.default) {
221
+ warnings.push({
222
+ path: `env.${key}`,
223
+ message: "Secret variables should not have default values",
224
+ code: "SECRET_WITH_DEFAULT",
225
+ });
226
+ }
227
+ }
228
+ }
229
+
230
+ // Check for timeout on command tools
231
+ if (manifest.command && !manifest.timeout) {
232
+ warnings.push({
233
+ path: "timeout",
234
+ message: "Timeout is recommended for command-based tools",
235
+ code: "MISSING_RECOMMENDED",
236
+ });
237
+ }
238
+
239
+ return warnings;
240
+ }
241
+
242
+ /**
243
+ * Validate a tool manifest
244
+ *
245
+ * @param manifest - The manifest to validate (parsed but unvalidated)
246
+ * @returns ValidationResult with valid flag, errors, and warnings
247
+ */
248
+ export function validateManifest(manifest: unknown): ValidationResult {
249
+ const result = ToolManifestSchema.safeParse(manifest);
250
+
251
+ if (!result.success) {
252
+ return {
253
+ valid: false,
254
+ errors: zodErrorToValidationErrors(result.error),
255
+ warnings: [],
256
+ };
257
+ }
258
+
259
+ // Generate warnings for missing recommended fields
260
+ const warnings = generateWarnings(result.data as ToolManifest);
261
+
262
+ return {
263
+ valid: true,
264
+ warnings: warnings.length > 0 ? warnings : undefined,
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Validate and return the typed manifest
270
+ * Throws if validation fails
271
+ *
272
+ * @param manifest - The manifest to validate
273
+ * @returns The validated ToolManifest
274
+ * @throws Error if validation fails
275
+ */
276
+ export function validateManifestStrict(manifest: unknown): ToolManifest {
277
+ const result = validateManifest(manifest);
278
+
279
+ if (!result.valid) {
280
+ const errorMessages = result.errors?.map((e) => `${e.path}: ${e.message}`).join(", ");
281
+ throw new Error(`Manifest validation failed: ${errorMessages}`);
282
+ }
283
+
284
+ return manifest as ToolManifest;
285
+ }
286
+
287
+ /**
288
+ * Check if a string is a valid tool name
289
+ */
290
+ export function isValidToolName(name: string): boolean {
291
+ return TOOL_NAME_REGEX.test(name);
292
+ }
293
+
294
+ /**
295
+ * Check if a string is a valid semver version
296
+ */
297
+ export function isValidVersion(version: string): boolean {
298
+ return SEMVER_REGEX.test(version);
299
+ }
300
+
301
+ /**
302
+ * Check if a string is a valid Go duration
303
+ */
304
+ export function isValidTimeout(timeout: string): boolean {
305
+ return GO_DURATION_REGEX.test(timeout);
306
+ }
307
+
308
+ // Export the schema for external use
309
+ export { ToolManifestSchema };
package/src/paths.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Path resolution utilities for Enact directories and tool locations
3
+ */
4
+
5
+ import { existsSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join, resolve } from "node:path";
8
+
9
+ /**
10
+ * Scope for tool directories
11
+ */
12
+ export type ToolScope = "user" | "project";
13
+
14
+ /**
15
+ * Get the Enact home directory (~/.enact/)
16
+ * @returns Absolute path to ~/.enact/
17
+ */
18
+ export function getEnactHome(): string {
19
+ return join(homedir(), ".enact");
20
+ }
21
+
22
+ /**
23
+ * Get the project-level .enact directory
24
+ * Searches up from current working directory to find .enact/
25
+ * NOTE: Does NOT return ~/.enact/ - that's the global home, not a project dir
26
+ * @param startDir - Directory to start searching from (defaults to cwd)
27
+ * @returns Absolute path to .enact/ or null if not found
28
+ */
29
+ export function getProjectEnactDir(startDir?: string): string | null {
30
+ let currentDir = resolve(startDir ?? process.cwd());
31
+ const root = resolve("/");
32
+ const enactHome = getEnactHome();
33
+
34
+ // Walk up directory tree looking for .enact/
35
+ while (currentDir !== root) {
36
+ const enactDir = join(currentDir, ".enact");
37
+ // Skip ~/.enact/ - that's the global home, not a project directory
38
+ if (existsSync(enactDir) && enactDir !== enactHome) {
39
+ return enactDir;
40
+ }
41
+ const parentDir = resolve(currentDir, "..");
42
+ if (parentDir === currentDir) {
43
+ break; // Reached root
44
+ }
45
+ currentDir = parentDir;
46
+ }
47
+
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * Get the tools directory for specified scope
53
+ *
54
+ * NOTE: For global scope ("user"), this is DEPRECATED.
55
+ * Global tools are now tracked in ~/.enact/tools.json and stored in cache.
56
+ * Use getToolsJsonPath("global") and getToolCachePath() from ./registry instead.
57
+ *
58
+ * For project scope, this returns .enact/tools/ where project tools are copied.
59
+ *
60
+ * @param scope - 'user' for ~/.enact/tools/ (deprecated) or 'project' for .enact/tools/
61
+ * @param startDir - For project scope, directory to start searching from
62
+ * @returns Absolute path to tools directory or null if project scope and not found
63
+ * @deprecated Use registry.ts functions for global tools
64
+ */
65
+ export function getToolsDir(scope: ToolScope, startDir?: string): string | null {
66
+ if (scope === "user") {
67
+ // DEPRECATED: Global tools now use tools.json + cache
68
+ // This path is kept for backward compatibility during migration
69
+ return join(getEnactHome(), "tools");
70
+ }
71
+
72
+ const projectDir = getProjectEnactDir(startDir);
73
+ return projectDir ? join(projectDir, "tools") : null;
74
+ }
75
+
76
+ /**
77
+ * Get the cache directory (~/.enact/cache/)
78
+ * @returns Absolute path to ~/.enact/cache/
79
+ */
80
+ export function getCacheDir(): string {
81
+ return join(getEnactHome(), "cache");
82
+ }
83
+
84
+ /**
85
+ * Get the configuration file path (~/.enact/config.yaml)
86
+ * @returns Absolute path to ~/.enact/config.yaml
87
+ */
88
+ export function getConfigPath(): string {
89
+ return join(getEnactHome(), "config.yaml");
90
+ }
91
+
92
+ /**
93
+ * Get the global .env file path (~/.enact/.env)
94
+ * @returns Absolute path to ~/.enact/.env
95
+ */
96
+ export function getGlobalEnvPath(): string {
97
+ return join(getEnactHome(), ".env");
98
+ }
99
+
100
+ /**
101
+ * Get the project .env file path (.enact/.env)
102
+ * @param startDir - Directory to start searching from
103
+ * @returns Absolute path to .enact/.env or null if not found
104
+ */
105
+ export function getProjectEnvPath(startDir?: string): string | null {
106
+ const projectDir = getProjectEnactDir(startDir);
107
+ return projectDir ? join(projectDir, ".env") : null;
108
+ }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Local tool registry management
3
+ *
4
+ * Manages tools.json files for tracking installed tools:
5
+ * - Global: ~/.enact/tools.json (installed with -g)
6
+ * - Project: .enact/tools.json (project dependencies)
7
+ *
8
+ * Tools are stored in cache and referenced by version in tools.json.
9
+ * This eliminates the need for a separate ~/.enact/tools/ directory.
10
+ */
11
+
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { getCacheDir, getEnactHome, getProjectEnactDir } from "./paths";
15
+
16
+ /**
17
+ * Structure of tools.json file
18
+ */
19
+ export interface ToolsRegistry {
20
+ /** Map of tool name to installed version */
21
+ tools: Record<string, string>;
22
+ }
23
+
24
+ /**
25
+ * Scope for tool registry
26
+ */
27
+ export type RegistryScope = "global" | "project";
28
+
29
+ /**
30
+ * Information about an installed tool
31
+ */
32
+ export interface InstalledToolInfo {
33
+ name: string;
34
+ version: string;
35
+ scope: RegistryScope;
36
+ cachePath: string;
37
+ }
38
+
39
+ /**
40
+ * Get the path to tools.json for the specified scope
41
+ */
42
+ export function getToolsJsonPath(scope: RegistryScope, startDir?: string): string | null {
43
+ if (scope === "global") {
44
+ return join(getEnactHome(), "tools.json");
45
+ }
46
+
47
+ const projectDir = getProjectEnactDir(startDir);
48
+ return projectDir ? join(projectDir, "tools.json") : null;
49
+ }
50
+
51
+ /**
52
+ * Load tools.json from the specified scope
53
+ * Returns empty registry if file doesn't exist
54
+ */
55
+ export function loadToolsRegistry(scope: RegistryScope, startDir?: string): ToolsRegistry {
56
+ const registryPath = getToolsJsonPath(scope, startDir);
57
+
58
+ if (!registryPath || !existsSync(registryPath)) {
59
+ return { tools: {} };
60
+ }
61
+
62
+ try {
63
+ const content = readFileSync(registryPath, "utf-8");
64
+ const parsed = JSON.parse(content);
65
+ return {
66
+ tools: parsed.tools ?? {},
67
+ };
68
+ } catch {
69
+ // Return empty registry on parse error
70
+ return { tools: {} };
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Save tools.json to the specified scope
76
+ */
77
+ export function saveToolsRegistry(
78
+ registry: ToolsRegistry,
79
+ scope: RegistryScope,
80
+ startDir?: string
81
+ ): void {
82
+ let registryPath = getToolsJsonPath(scope, startDir);
83
+
84
+ // For project scope, create .enact/ directory if it doesn't exist
85
+ if (!registryPath && scope === "project") {
86
+ const projectRoot = startDir ?? process.cwd();
87
+ const enactDir = join(projectRoot, ".enact");
88
+ mkdirSync(enactDir, { recursive: true });
89
+ registryPath = join(enactDir, "tools.json");
90
+ }
91
+
92
+ if (!registryPath) {
93
+ throw new Error("Cannot save project registry: unable to determine registry path");
94
+ }
95
+
96
+ // Ensure directory exists
97
+ const dir = dirname(registryPath);
98
+ if (!existsSync(dir)) {
99
+ mkdirSync(dir, { recursive: true });
100
+ }
101
+
102
+ const content = JSON.stringify(registry, null, 2);
103
+ writeFileSync(registryPath, content, "utf-8");
104
+ }
105
+
106
+ /**
107
+ * Add a tool to the registry
108
+ */
109
+ export function addToolToRegistry(
110
+ toolName: string,
111
+ version: string,
112
+ scope: RegistryScope,
113
+ startDir?: string
114
+ ): void {
115
+ const registry = loadToolsRegistry(scope, startDir);
116
+ registry.tools[toolName] = version;
117
+ saveToolsRegistry(registry, scope, startDir);
118
+ }
119
+
120
+ /**
121
+ * Remove a tool from the registry
122
+ */
123
+ export function removeToolFromRegistry(
124
+ toolName: string,
125
+ scope: RegistryScope,
126
+ startDir?: string
127
+ ): boolean {
128
+ const registry = loadToolsRegistry(scope, startDir);
129
+
130
+ if (!(toolName in registry.tools)) {
131
+ return false;
132
+ }
133
+
134
+ delete registry.tools[toolName];
135
+ saveToolsRegistry(registry, scope, startDir);
136
+ return true;
137
+ }
138
+
139
+ /**
140
+ * Check if a tool is installed in the registry
141
+ */
142
+ export function isToolInstalled(
143
+ toolName: string,
144
+ scope: RegistryScope,
145
+ startDir?: string
146
+ ): boolean {
147
+ const registry = loadToolsRegistry(scope, startDir);
148
+ return toolName in registry.tools;
149
+ }
150
+
151
+ /**
152
+ * Get the installed version of a tool
153
+ * Returns null if not installed
154
+ */
155
+ export function getInstalledVersion(
156
+ toolName: string,
157
+ scope: RegistryScope,
158
+ startDir?: string
159
+ ): string | null {
160
+ const registry = loadToolsRegistry(scope, startDir);
161
+ return registry.tools[toolName] ?? null;
162
+ }
163
+
164
+ /**
165
+ * Get the cache path for an installed tool
166
+ */
167
+ export function getToolCachePath(toolName: string, version: string): string {
168
+ const cacheDir = getCacheDir();
169
+ const normalizedVersion = version.startsWith("v") ? version.slice(1) : version;
170
+ return join(cacheDir, toolName, `v${normalizedVersion}`);
171
+ }
172
+
173
+ /**
174
+ * List all installed tools in a registry
175
+ */
176
+ export function listInstalledTools(scope: RegistryScope, startDir?: string): InstalledToolInfo[] {
177
+ const registry = loadToolsRegistry(scope, startDir);
178
+ const tools: InstalledToolInfo[] = [];
179
+
180
+ for (const [name, version] of Object.entries(registry.tools)) {
181
+ tools.push({
182
+ name,
183
+ version,
184
+ scope,
185
+ cachePath: getToolCachePath(name, version),
186
+ });
187
+ }
188
+
189
+ return tools;
190
+ }
191
+
192
+ /**
193
+ * Get tool info if installed (checks cache path exists)
194
+ */
195
+ export function getInstalledToolInfo(
196
+ toolName: string,
197
+ scope: RegistryScope,
198
+ startDir?: string
199
+ ): InstalledToolInfo | null {
200
+ const version = getInstalledVersion(toolName, scope, startDir);
201
+
202
+ if (!version) {
203
+ return null;
204
+ }
205
+
206
+ const cachePath = getToolCachePath(toolName, version);
207
+
208
+ // Verify cache exists
209
+ if (!existsSync(cachePath)) {
210
+ return null;
211
+ }
212
+
213
+ return {
214
+ name: toolName,
215
+ version,
216
+ scope,
217
+ cachePath,
218
+ };
219
+ }