@enactprotocol/cli 1.2.13 → 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 (73) hide show
  1. package/README.md +88 -0
  2. package/package.json +34 -38
  3. package/src/commands/auth/index.ts +940 -0
  4. package/src/commands/cache/index.ts +361 -0
  5. package/src/commands/config/README.md +239 -0
  6. package/src/commands/config/index.ts +164 -0
  7. package/src/commands/env/README.md +197 -0
  8. package/src/commands/env/index.ts +392 -0
  9. package/src/commands/exec/README.md +110 -0
  10. package/src/commands/exec/index.ts +195 -0
  11. package/src/commands/get/index.ts +198 -0
  12. package/src/commands/index.ts +30 -0
  13. package/src/commands/inspect/index.ts +264 -0
  14. package/src/commands/install/README.md +146 -0
  15. package/src/commands/install/index.ts +682 -0
  16. package/src/commands/list/README.md +115 -0
  17. package/src/commands/list/index.ts +138 -0
  18. package/src/commands/publish/index.ts +350 -0
  19. package/src/commands/report/index.ts +366 -0
  20. package/src/commands/run/README.md +124 -0
  21. package/src/commands/run/index.ts +686 -0
  22. package/src/commands/search/index.ts +368 -0
  23. package/src/commands/setup/index.ts +274 -0
  24. package/src/commands/sign/index.ts +652 -0
  25. package/src/commands/trust/README.md +214 -0
  26. package/src/commands/trust/index.ts +453 -0
  27. package/src/commands/unyank/index.ts +107 -0
  28. package/src/commands/yank/index.ts +143 -0
  29. package/src/index.ts +96 -0
  30. package/src/types.ts +81 -0
  31. package/src/utils/errors.ts +409 -0
  32. package/src/utils/exit-codes.ts +159 -0
  33. package/src/utils/ignore.ts +147 -0
  34. package/src/utils/index.ts +107 -0
  35. package/src/utils/output.ts +242 -0
  36. package/src/utils/spinner.ts +214 -0
  37. package/tests/commands/auth.test.ts +217 -0
  38. package/tests/commands/cache.test.ts +286 -0
  39. package/tests/commands/config.test.ts +277 -0
  40. package/tests/commands/env.test.ts +293 -0
  41. package/tests/commands/exec.test.ts +112 -0
  42. package/tests/commands/get.test.ts +179 -0
  43. package/tests/commands/inspect.test.ts +201 -0
  44. package/tests/commands/install-integration.test.ts +343 -0
  45. package/tests/commands/install.test.ts +288 -0
  46. package/tests/commands/list.test.ts +160 -0
  47. package/tests/commands/publish.test.ts +186 -0
  48. package/tests/commands/report.test.ts +194 -0
  49. package/tests/commands/run.test.ts +231 -0
  50. package/tests/commands/search.test.ts +131 -0
  51. package/tests/commands/sign.test.ts +164 -0
  52. package/tests/commands/trust.test.ts +236 -0
  53. package/tests/commands/unyank.test.ts +114 -0
  54. package/tests/commands/yank.test.ts +154 -0
  55. package/tests/e2e.test.ts +554 -0
  56. package/tests/fixtures/calculator/enact.yaml +34 -0
  57. package/tests/fixtures/echo-tool/enact.md +31 -0
  58. package/tests/fixtures/env-tool/enact.yaml +19 -0
  59. package/tests/fixtures/greeter/enact.yaml +18 -0
  60. package/tests/fixtures/invalid-tool/enact.yaml +4 -0
  61. package/tests/index.test.ts +8 -0
  62. package/tests/types.test.ts +84 -0
  63. package/tests/utils/errors.test.ts +303 -0
  64. package/tests/utils/exit-codes.test.ts +189 -0
  65. package/tests/utils/ignore.test.ts +461 -0
  66. package/tests/utils/output.test.ts +126 -0
  67. package/tsconfig.json +17 -0
  68. package/tsconfig.tsbuildinfo +1 -0
  69. package/dist/index.js +0 -231612
  70. package/dist/index.js.bak +0 -231611
  71. package/dist/web/static/app.js +0 -663
  72. package/dist/web/static/index.html +0 -117
  73. package/dist/web/static/style.css +0 -291
@@ -0,0 +1,682 @@
1
+ /**
2
+ * enact install command
3
+ *
4
+ * Install a tool to the project or globally.
5
+ * All tools are extracted to ~/.enact/cache/{tool}/{version}/
6
+ * - Project install: Adds entry to .enact/tools.json
7
+ * - Global install: Adds entry to ~/.enact/tools.json
8
+ *
9
+ * Supports local paths and registry tools with verification.
10
+ */
11
+
12
+ import { cpSync, existsSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
13
+ import { dirname, join, resolve } from "node:path";
14
+ import {
15
+ type AttestationListResponse,
16
+ type ToolVersionInfo,
17
+ createApiClient,
18
+ downloadBundle,
19
+ getAttestationList,
20
+ getToolInfo,
21
+ getToolVersion,
22
+ verifyAllAttestations,
23
+ } from "@enactprotocol/api";
24
+ import {
25
+ addToolToRegistry,
26
+ getCacheDir,
27
+ getInstalledVersion,
28
+ getMinimumAttestations,
29
+ getProjectEnactDir,
30
+ getToolCachePath,
31
+ getTrustPolicy,
32
+ getTrustedAuditors,
33
+ loadConfig,
34
+ loadManifestFromDir,
35
+ pathExists,
36
+ } from "@enactprotocol/shared";
37
+ // Trust verification is done using @enactprotocol/api functions
38
+ import type { Command } from "commander";
39
+ import type { CommandContext, GlobalOptions } from "../../types";
40
+ import {
41
+ EXIT_FAILURE,
42
+ EXIT_TRUST_ERROR,
43
+ ManifestError,
44
+ RegistryError,
45
+ TrustError,
46
+ colors,
47
+ confirm,
48
+ dim,
49
+ error,
50
+ formatError,
51
+ handleError,
52
+ info,
53
+ json,
54
+ keyValue,
55
+ newline,
56
+ success,
57
+ suggest,
58
+ symbols,
59
+ withSpinner,
60
+ } from "../../utils";
61
+
62
+ interface InstallOptions extends GlobalOptions {
63
+ global?: boolean;
64
+ force?: boolean;
65
+ allowYanked?: boolean;
66
+ }
67
+
68
+ /**
69
+ * Parse tool@version syntax
70
+ */
71
+ function parseToolSpec(spec: string): { name: string; version: string | undefined } {
72
+ // Handle scoped packages like @scope/tool@version
73
+ const match = spec.match(/^(@[^@/]+\/[^@]+|[^@]+)(?:@(.+))?$/);
74
+ if (match?.[1]) {
75
+ return {
76
+ name: match[1],
77
+ version: match[2],
78
+ };
79
+ }
80
+ return { name: spec, version: undefined };
81
+ }
82
+
83
+ /**
84
+ * Copy a directory recursively
85
+ */
86
+ function copyDir(src: string, dest: string): void {
87
+ mkdirSync(dest, { recursive: true });
88
+ cpSync(src, dest, { recursive: true });
89
+ }
90
+
91
+ /**
92
+ * Check if a tool name looks like a local path
93
+ * Local paths start with ./, ../, or /
94
+ */
95
+ function isLocalPath(toolName: string): boolean {
96
+ return (
97
+ toolName === "." ||
98
+ toolName.startsWith("./") ||
99
+ toolName.startsWith("../") ||
100
+ toolName.startsWith("/")
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Extract a tar.gz bundle to a directory
106
+ * Uses tar command (available on all supported platforms)
107
+ */
108
+ async function extractBundle(bundleData: ArrayBuffer, destPath: string): Promise<void> {
109
+ // Create a temporary file for the bundle
110
+ const tempFile = join(getCacheDir(), `bundle-${Date.now()}.tar.gz`);
111
+ mkdirSync(dirname(tempFile), { recursive: true });
112
+ writeFileSync(tempFile, Buffer.from(bundleData));
113
+
114
+ // Create destination directory
115
+ mkdirSync(destPath, { recursive: true });
116
+
117
+ // Extract using tar command (available on all supported platforms)
118
+ const proc = Bun.spawn(["tar", "-xzf", tempFile, "-C", destPath], {
119
+ stdout: "pipe",
120
+ stderr: "pipe",
121
+ });
122
+
123
+ const exitCode = await proc.exited;
124
+
125
+ // Clean up temp file
126
+ try {
127
+ unlinkSync(tempFile);
128
+ } catch {
129
+ // Ignore cleanup errors
130
+ }
131
+
132
+ if (exitCode !== 0) {
133
+ const stderr = await new Response(proc.stderr).text();
134
+ throw new Error(`Failed to extract bundle: ${stderr}`);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Format bytes to human-readable string
140
+ */
141
+ function formatBytes(bytes: number): string {
142
+ if (bytes === 0) return "0 B";
143
+ const k = 1024;
144
+ const sizes = ["B", "KB", "MB", "GB"];
145
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
146
+ return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
147
+ }
148
+
149
+ /**
150
+ * Install from the registry
151
+ */
152
+ async function installFromRegistry(
153
+ toolName: string,
154
+ version: string | undefined,
155
+ options: InstallOptions,
156
+ ctx: CommandContext
157
+ ): Promise<void> {
158
+ const config = loadConfig();
159
+ const registryUrl =
160
+ process.env.ENACT_REGISTRY_URL ??
161
+ config.registry?.url ??
162
+ "https://siikwkfgsmouioodghho.supabase.co/functions/v1";
163
+ const authToken = config.registry?.authToken;
164
+ const client = createApiClient({
165
+ baseUrl: registryUrl,
166
+ authToken: authToken,
167
+ });
168
+
169
+ // Get tool info to find latest version if not specified
170
+ let targetVersion = version;
171
+ let toolInfo: ToolVersionInfo;
172
+
173
+ try {
174
+ if (!targetVersion) {
175
+ // Fetch tool metadata to get latest version
176
+ const metadata = await withSpinner(
177
+ `Fetching ${toolName} info...`,
178
+ async () => getToolInfo(client, toolName),
179
+ `${symbols.success} Found ${toolName}`
180
+ );
181
+ targetVersion = metadata.latestVersion;
182
+ }
183
+
184
+ // Get version-specific info
185
+ toolInfo = await withSpinner(
186
+ `Fetching ${toolName}@${targetVersion} details...`,
187
+ async () => getToolVersion(client, toolName, targetVersion!),
188
+ `${symbols.success} Got version details`
189
+ );
190
+ } catch (err) {
191
+ throw new RegistryError(`Failed to fetch tool info: ${formatError(err)}`);
192
+ }
193
+
194
+ // Check if version is yanked
195
+ if (toolInfo.yanked) {
196
+ const yankMessage = toolInfo.yankReason
197
+ ? `Version ${targetVersion} has been yanked: ${toolInfo.yankReason}`
198
+ : `Version ${targetVersion} has been yanked`;
199
+
200
+ if (options.allowYanked) {
201
+ info(`${symbols.warning} ${yankMessage}`);
202
+ if (toolInfo.yankReplacement) {
203
+ dim(` Recommended replacement: ${toolInfo.yankReplacement}`);
204
+ }
205
+ } else if (ctx.isInteractive) {
206
+ info(`${symbols.warning} ${yankMessage}`);
207
+ if (toolInfo.yankReplacement) {
208
+ info(`Recommended replacement: ${toolInfo.yankReplacement}`);
209
+ const useReplacement = await confirm(`Install ${toolInfo.yankReplacement} instead?`);
210
+ if (useReplacement) {
211
+ // Recursively install the replacement version
212
+ return installFromRegistry(toolName, toolInfo.yankReplacement, options, ctx);
213
+ }
214
+ }
215
+ const proceed = await confirm("Install yanked version anyway?");
216
+ if (!proceed) {
217
+ info("Installation cancelled.");
218
+ process.exit(0);
219
+ }
220
+ } else {
221
+ error(`${yankMessage}`);
222
+ if (toolInfo.yankReplacement) {
223
+ info(`Recommended replacement: ${toolInfo.yankReplacement}`);
224
+ suggest(`Run: enact install ${toolName}@${toolInfo.yankReplacement}`);
225
+ }
226
+ info("Use --allow-yanked to install yanked versions.");
227
+ process.exit(EXIT_FAILURE);
228
+ }
229
+ }
230
+
231
+ // Check trust policy - fetch and verify attestations
232
+ try {
233
+ const trustPolicy = getTrustPolicy();
234
+ const minimumAttestations = getMinimumAttestations();
235
+ const trustedAuditors = getTrustedAuditors();
236
+
237
+ // Fetch attestations from registry
238
+ const attestationsResponse: AttestationListResponse = await getAttestationList(
239
+ client,
240
+ toolName,
241
+ targetVersion!
242
+ );
243
+ const attestations = attestationsResponse.attestations;
244
+
245
+ if (attestations.length === 0) {
246
+ // No attestations found
247
+ info(`${symbols.warning} Tool ${toolName}@${targetVersion} has no attestations.`);
248
+
249
+ if (trustPolicy === "require_attestation") {
250
+ error("Trust policy requires attestations. Installation blocked.");
251
+ info("Configure trust policy in ~/.enact/config.yaml");
252
+ process.exit(EXIT_TRUST_ERROR);
253
+ } else if (ctx.isInteractive && trustPolicy === "prompt") {
254
+ const proceed = await confirm("Install unverified tool?");
255
+ if (!proceed) {
256
+ info("Installation cancelled.");
257
+ process.exit(0);
258
+ }
259
+ } else if (!ctx.isInteractive && trustPolicy === "prompt") {
260
+ error("Cannot install unverified tools in non-interactive mode.");
261
+ info("Run interactively to confirm installation.");
262
+ process.exit(EXIT_TRUST_ERROR);
263
+ }
264
+ // trustPolicy === "allow" - continue without prompting
265
+ } else {
266
+ // Verify attestations locally (never trust registry's verification status)
267
+ const verifiedAuditors = await verifyAllAttestations(
268
+ client,
269
+ toolName,
270
+ targetVersion!,
271
+ toolInfo.bundle?.hash ?? ""
272
+ );
273
+
274
+ // Check verified auditors against trust config using provider:identity format
275
+ const trustedVerifiedAuditors = verifiedAuditors
276
+ .filter((auditor) => trustedAuditors.includes(auditor.providerIdentity))
277
+ .map((auditor) => auditor.providerIdentity);
278
+
279
+ if (trustedVerifiedAuditors.length > 0) {
280
+ // Check if we meet minimum attestations threshold
281
+ if (trustedVerifiedAuditors.length < minimumAttestations) {
282
+ info(
283
+ `${symbols.warning} Tool ${toolName}@${targetVersion} has ${trustedVerifiedAuditors.length} trusted attestation(s), but ${minimumAttestations} required.`
284
+ );
285
+ dim(`Trusted attestations: ${trustedVerifiedAuditors.join(", ")}`);
286
+
287
+ if (trustPolicy === "require_attestation") {
288
+ error(
289
+ `Trust policy requires at least ${minimumAttestations} attestation(s) from trusted identities. Installation blocked.`
290
+ );
291
+ suggest("Run 'enact trust <identity>' to add more trusted identities");
292
+ process.exit(EXIT_TRUST_ERROR);
293
+ } else if (ctx.isInteractive && trustPolicy === "prompt") {
294
+ const proceed = await confirm("Install with fewer attestations than required?");
295
+ if (!proceed) {
296
+ info("Installation cancelled.");
297
+ process.exit(0);
298
+ }
299
+ } else if (!ctx.isInteractive && trustPolicy === "prompt") {
300
+ error(
301
+ "Cannot install tool without meeting minimum attestation requirement in non-interactive mode."
302
+ );
303
+ process.exit(EXIT_TRUST_ERROR);
304
+ }
305
+ // trustPolicy === "allow" - continue without prompting
306
+ } else {
307
+ // Tool meets or exceeds minimum attestations
308
+ if (options.verbose) {
309
+ success(
310
+ `Tool verified by ${trustedVerifiedAuditors.length} trusted identity(ies): ${trustedVerifiedAuditors.join(", ")}`
311
+ );
312
+ }
313
+ }
314
+ } else {
315
+ // Has attestations but none from trusted auditors
316
+ info(
317
+ `${symbols.warning} Tool ${toolName}@${targetVersion} has ${verifiedAuditors.length} attestation(s), but none from trusted auditors.`
318
+ );
319
+
320
+ if (trustPolicy === "require_attestation") {
321
+ error(
322
+ "Trust policy requires attestations from trusted identities. Installation blocked."
323
+ );
324
+ dim(`Your trusted auditors: ${trustedAuditors.join(", ")}`);
325
+ dim(`Tool attested by: ${verifiedAuditors.map((a) => a.providerIdentity).join(", ")}`);
326
+ suggest("Run 'enact trust <auditor>' to add a trusted auditor");
327
+ process.exit(EXIT_TRUST_ERROR);
328
+ } else if (ctx.isInteractive && trustPolicy === "prompt") {
329
+ dim(`Attested by: ${verifiedAuditors.map((a) => a.providerIdentity).join(", ")}`);
330
+ dim(`Your trusted auditors: ${trustedAuditors.join(", ")}`);
331
+ const proceed = await confirm("Install anyway?");
332
+ if (!proceed) {
333
+ info("Installation cancelled.");
334
+ process.exit(0);
335
+ }
336
+ } else if (!ctx.isInteractive && trustPolicy === "prompt") {
337
+ error("Cannot install tool without trusted attestations in non-interactive mode.");
338
+ process.exit(EXIT_TRUST_ERROR);
339
+ }
340
+ // trustPolicy === "allow" - continue without prompting
341
+ }
342
+ }
343
+ } catch (err) {
344
+ if (options.verbose) {
345
+ dim(`Trust check failed: ${formatError(err)}`);
346
+ }
347
+ // Continue with installation if trust check fails (with warning)
348
+ info(`${symbols.warning} Could not verify trust status. Proceeding with caution.`);
349
+ }
350
+
351
+ // Download bundle
352
+ let bundleResult: { data: ArrayBuffer; hash: string; size: number };
353
+ try {
354
+ bundleResult = await withSpinner(
355
+ `Downloading ${toolName}@${targetVersion}...`,
356
+ async () =>
357
+ downloadBundle(client, {
358
+ name: toolName,
359
+ version: targetVersion!,
360
+ verify: true,
361
+ acknowledgeYanked: toolInfo.yanked === true || options.allowYanked === true,
362
+ }),
363
+ `${symbols.success} Downloaded`
364
+ );
365
+ } catch (err) {
366
+ throw new RegistryError(`Failed to download bundle: ${formatError(err)}`);
367
+ }
368
+
369
+ // Verify hash if provided
370
+ if (toolInfo.bundle?.hash) {
371
+ const downloadedHash = bundleResult.hash.replace("sha256:", "");
372
+ const expectedHash = toolInfo.bundle.hash.replace("sha256:", "");
373
+
374
+ if (downloadedHash !== expectedHash) {
375
+ throw new TrustError(
376
+ `Bundle hash mismatch! The downloaded file may be corrupted or tampered with.${options.verbose ? `\nExpected: ${expectedHash}\nGot: ${downloadedHash}` : ""}`
377
+ );
378
+ }
379
+ }
380
+
381
+ // All installs: extract to cache and update tools.json
382
+ const isGlobal = options.global ?? false;
383
+ const scope = isGlobal ? "global" : "project";
384
+ const cachePath = getToolCachePath(toolName, targetVersion!);
385
+
386
+ // Check if already installed in the target scope
387
+ const existingVersion = getInstalledVersion(toolName, scope, isGlobal ? undefined : ctx.cwd);
388
+ if (existingVersion && !options.force) {
389
+ if (existingVersion === targetVersion) {
390
+ info(
391
+ `Tool ${toolName}@${targetVersion} is already installed ${isGlobal ? "globally" : "in this project"}.`
392
+ );
393
+ return;
394
+ }
395
+ if (ctx.isInteractive) {
396
+ const shouldOverwrite = await confirm(
397
+ `Tool ${toolName}@${existingVersion} is installed. Update to ${targetVersion}?`
398
+ );
399
+ if (!shouldOverwrite) {
400
+ info("Installation cancelled.");
401
+ return;
402
+ }
403
+ } else {
404
+ error(`Tool ${toolName}@${existingVersion} is already installed. Use --force to update.`);
405
+ process.exit(EXIT_FAILURE);
406
+ }
407
+ }
408
+
409
+ // Extract bundle to cache
410
+ try {
411
+ await withSpinner(
412
+ `Extracting ${toolName}...`,
413
+ async () => {
414
+ // Remove existing directory if force
415
+ if (pathExists(cachePath)) {
416
+ rmSync(cachePath, { recursive: true, force: true });
417
+ }
418
+ await extractBundle(bundleResult.data, cachePath);
419
+ },
420
+ `${symbols.success} Extracted to ${cachePath}`
421
+ );
422
+ } catch (err) {
423
+ throw new RegistryError(`Failed to extract bundle: ${formatError(err)}`);
424
+ }
425
+
426
+ // Update tools.json for the appropriate scope
427
+ addToolToRegistry(toolName, targetVersion!, scope, isGlobal ? undefined : ctx.cwd);
428
+
429
+ // Output result
430
+ if (options.json) {
431
+ json({
432
+ installed: true,
433
+ tool: toolName,
434
+ version: targetVersion,
435
+ location: cachePath,
436
+ scope,
437
+ hash: bundleResult.hash,
438
+ size: bundleResult.size,
439
+ verified: true,
440
+ });
441
+ return;
442
+ }
443
+
444
+ newline();
445
+ keyValue("Tool", toolName);
446
+ keyValue("Version", targetVersion ?? "unknown");
447
+ keyValue("Location", cachePath);
448
+ keyValue("Scope", isGlobal ? "global (~/.enact/tools.json)" : "project (.enact/tools.json)");
449
+ keyValue("Size", formatBytes(bundleResult.size));
450
+ keyValue("Hash", `${bundleResult.hash.substring(0, 20)}...`);
451
+ newline();
452
+ success(`Installed ${colors.bold(toolName)}@${targetVersion}`);
453
+ }
454
+
455
+ /**
456
+ * Install from a local path
457
+ *
458
+ * Both global and project installs:
459
+ * 1. Copy tool to cache (~/.enact/cache/{tool}/{version}/)
460
+ * 2. Update tools.json (global: ~/.enact/tools.json, project: .enact/tools.json)
461
+ */
462
+ async function installFromPath(
463
+ sourcePath: string,
464
+ options: InstallOptions,
465
+ ctx: CommandContext
466
+ ): Promise<void> {
467
+ const resolvedPath = resolve(ctx.cwd, sourcePath);
468
+
469
+ // Load manifest from source
470
+ const loaded = loadManifestFromDir(resolvedPath);
471
+ if (!loaded) {
472
+ throw new ManifestError(`No valid manifest found in: ${resolvedPath}`);
473
+ }
474
+
475
+ const manifest = loaded.manifest;
476
+ const isGlobal = options.global ?? false;
477
+ const version = manifest.version ?? "0.0.0";
478
+ const scope = isGlobal ? "global" : "project";
479
+
480
+ // All tools go to cache
481
+ const cachePath = getToolCachePath(manifest.name, version);
482
+
483
+ // Check if already installed in the target scope
484
+ const existingVersion = getInstalledVersion(manifest.name, scope, isGlobal ? undefined : ctx.cwd);
485
+ if (existingVersion && !options.force) {
486
+ if (existingVersion === version) {
487
+ info(
488
+ `Tool ${manifest.name}@${version} is already installed ${isGlobal ? "globally" : "in this project"}.`
489
+ );
490
+ return;
491
+ }
492
+ if (ctx.isInteractive) {
493
+ const shouldOverwrite = await confirm(
494
+ `Tool ${manifest.name}@${existingVersion} is installed. Update to ${version}?`
495
+ );
496
+ if (!shouldOverwrite) {
497
+ info("Installation cancelled.");
498
+ return;
499
+ }
500
+ } else {
501
+ error(
502
+ `Tool ${manifest.name}@${existingVersion} is already installed. Use --force to update.`
503
+ );
504
+ process.exit(EXIT_FAILURE);
505
+ }
506
+ }
507
+
508
+ // Copy tool to cache
509
+ await withSpinner(
510
+ `Installing ${manifest.name}...`,
511
+ async () => {
512
+ if (pathExists(cachePath)) {
513
+ rmSync(cachePath, { recursive: true, force: true });
514
+ }
515
+ mkdirSync(dirname(cachePath), { recursive: true });
516
+ copyDir(resolvedPath, cachePath);
517
+ },
518
+ `${symbols.success} Installed ${manifest.name}`
519
+ );
520
+
521
+ // Update tools.json for the appropriate scope
522
+ addToolToRegistry(manifest.name, version, scope, isGlobal ? undefined : ctx.cwd);
523
+
524
+ if (options.json) {
525
+ json({
526
+ installed: true,
527
+ tool: manifest.name,
528
+ version: manifest.version,
529
+ location: cachePath,
530
+ scope,
531
+ });
532
+ return;
533
+ }
534
+
535
+ newline();
536
+ keyValue("Tool", manifest.name);
537
+ keyValue("Version", manifest.version ?? "unversioned");
538
+ keyValue("Location", cachePath);
539
+ keyValue("Scope", isGlobal ? "global (~/.enact/tools.json)" : "project (.enact/tools.json)");
540
+ newline();
541
+ success(`Installed ${colors.bold(manifest.name)}`);
542
+ }
543
+
544
+ /**
545
+ * Install from a tool name (registry tool)
546
+ */
547
+ async function installFromName(
548
+ toolSpec: string,
549
+ options: InstallOptions,
550
+ ctx: CommandContext
551
+ ): Promise<void> {
552
+ const { name: toolName, version } = parseToolSpec(toolSpec);
553
+
554
+ // If it looks like a local path, use installFromPath
555
+ if (isLocalPath(toolName)) {
556
+ return installFromPath(toolName, options, ctx);
557
+ }
558
+
559
+ // Otherwise, it's a registry tool - fetch from registry
560
+ return installFromRegistry(toolName, version, options, ctx);
561
+ }
562
+
563
+ /**
564
+ * Install all tools from project .enact/tools.json
565
+ */
566
+ async function installProjectTools(options: InstallOptions, ctx: CommandContext): Promise<void> {
567
+ const projectDir = getProjectEnactDir(ctx.cwd);
568
+
569
+ if (!projectDir) {
570
+ info("No .enact/ directory found. Nothing to install.");
571
+ suggest("Run 'enact install <tool>' to install a specific tool.");
572
+ return;
573
+ }
574
+
575
+ const toolsJsonPath = join(projectDir, "tools.json");
576
+
577
+ if (!existsSync(toolsJsonPath)) {
578
+ info("No .enact/tools.json found. Nothing to install.");
579
+ suggest("Run 'enact install <tool>' to install a specific tool.");
580
+ return;
581
+ }
582
+
583
+ // Parse tools.json
584
+ let toolsConfig: { tools?: Record<string, string> };
585
+ try {
586
+ const content = await Bun.file(toolsJsonPath).text();
587
+ toolsConfig = JSON.parse(content);
588
+ } catch (err) {
589
+ throw new ManifestError(`Failed to parse .enact/tools.json: ${formatError(err)}`);
590
+ }
591
+
592
+ if (!toolsConfig.tools || Object.keys(toolsConfig.tools).length === 0) {
593
+ info("No tools specified in .enact/tools.json");
594
+ return;
595
+ }
596
+
597
+ // Install each tool
598
+ const tools = Object.entries(toolsConfig.tools);
599
+ info(`Installing ${tools.length} tool(s) from tools.json...`);
600
+ newline();
601
+
602
+ let installed = 0;
603
+ let failed = 0;
604
+
605
+ for (const [toolName, toolVersion] of tools) {
606
+ try {
607
+ const toolSpec = toolVersion ? `${toolName}@${toolVersion}` : toolName;
608
+ await installFromName(toolSpec, { ...options, json: false }, ctx);
609
+ installed++;
610
+ } catch (err) {
611
+ error(`Failed to install ${toolName}: ${formatError(err)}`);
612
+ failed++;
613
+ }
614
+ }
615
+
616
+ newline();
617
+ if (failed === 0) {
618
+ success(`Installed all ${installed} tool(s) successfully`);
619
+ } else {
620
+ info(`Installed ${installed} tool(s), ${failed} failed`);
621
+ }
622
+
623
+ if (options.json) {
624
+ json({
625
+ installed,
626
+ failed,
627
+ total: tools.length,
628
+ });
629
+ }
630
+ }
631
+
632
+ /**
633
+ * Install command handler
634
+ */
635
+ async function installHandler(
636
+ tool: string | undefined,
637
+ options: InstallOptions,
638
+ ctx: CommandContext
639
+ ): Promise<void> {
640
+ // If no tool specified, install from project
641
+ if (!tool) {
642
+ return installProjectTools(options, ctx);
643
+ }
644
+
645
+ // Check if it's a path
646
+ if (tool === "." || tool.startsWith("./") || tool.startsWith("/") || tool.startsWith("..")) {
647
+ return installFromPath(tool, options, ctx);
648
+ }
649
+
650
+ // Otherwise, try to resolve as tool name (local or registry)
651
+ return installFromName(tool, options, ctx);
652
+ }
653
+
654
+ /**
655
+ * Configure the install command
656
+ */
657
+ export function configureInstallCommand(program: Command): void {
658
+ program
659
+ .command("install")
660
+ .alias("i")
661
+ .description("Install a tool to the project or globally")
662
+ .argument("[tool]", "Tool to install (name[@version], path, or '.' for current directory)")
663
+ .option("-g, --global", "Install globally (adds to ~/.enact/tools.json)")
664
+ .option("-f, --force", "Overwrite existing installation")
665
+ .option("--allow-yanked", "Allow installing yanked versions")
666
+ .option("-v, --verbose", "Show detailed output")
667
+ .option("--json", "Output result as JSON")
668
+ .action(async (tool: string | undefined, options: InstallOptions) => {
669
+ const ctx: CommandContext = {
670
+ cwd: process.cwd(),
671
+ options,
672
+ isCI: Boolean(process.env.CI),
673
+ isInteractive: process.stdout.isTTY ?? false,
674
+ };
675
+
676
+ try {
677
+ await installHandler(tool, options, ctx);
678
+ } catch (err) {
679
+ handleError(err, options.verbose ? { verbose: true } : undefined);
680
+ }
681
+ });
682
+ }