@enactprotocol/cli 1.2.8 → 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 -231410
  70. package/dist/index.js.bak +0 -231409
  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,368 @@
1
+ /**
2
+ * enact search command
3
+ *
4
+ * Search the Enact registry for tools.
5
+ * With --local or -g, search installed tools instead.
6
+ */
7
+
8
+ import { existsSync, readdirSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { type SearchResult, createApiClient, searchTools } from "@enactprotocol/api";
11
+ import {
12
+ getProjectEnactDir,
13
+ listInstalledTools,
14
+ loadConfig,
15
+ tryLoadManifestFromDir,
16
+ } from "@enactprotocol/shared";
17
+ import type { Command } from "commander";
18
+ import type { CommandContext, GlobalOptions } from "../../types";
19
+ import {
20
+ type TableColumn,
21
+ dim,
22
+ error,
23
+ formatError,
24
+ header,
25
+ info,
26
+ json,
27
+ newline,
28
+ table,
29
+ } from "../../utils";
30
+
31
+ interface SearchOptions extends GlobalOptions {
32
+ tags?: string;
33
+ limit?: string;
34
+ offset?: string;
35
+ local?: boolean;
36
+ global?: boolean;
37
+ }
38
+
39
+ interface SearchResultRow {
40
+ name: string;
41
+ version: string;
42
+ description: string;
43
+ rating: string;
44
+ downloads: string;
45
+ [key: string]: string;
46
+ }
47
+
48
+ /**
49
+ * Format download count for display
50
+ */
51
+ function formatDownloads(count: number): string {
52
+ if (count >= 1000000) {
53
+ return `${(count / 1000000).toFixed(1)}M`;
54
+ }
55
+ if (count >= 1000) {
56
+ return `${(count / 1000).toFixed(1)}K`;
57
+ }
58
+ return String(count);
59
+ }
60
+
61
+ /**
62
+ * Truncate description to fit table
63
+ */
64
+ function truncateDescription(desc: string, maxLen: number): string {
65
+ if (desc.length <= maxLen) return desc;
66
+ return `${desc.substring(0, maxLen - 3)}...`;
67
+ }
68
+
69
+ /**
70
+ * Tool info from local search
71
+ */
72
+ interface LocalToolInfo {
73
+ name: string;
74
+ version: string;
75
+ description: string;
76
+ location: string;
77
+ scope: "project" | "global";
78
+ }
79
+
80
+ /**
81
+ * Search installed tools locally
82
+ */
83
+ function searchLocalTools(
84
+ query: string,
85
+ scope: "project" | "global",
86
+ cwd: string
87
+ ): LocalToolInfo[] {
88
+ const tools: LocalToolInfo[] = [];
89
+ const queryLower = query.toLowerCase();
90
+
91
+ if (scope === "global") {
92
+ // Search global tools via tools.json
93
+ const installedTools = listInstalledTools("global");
94
+
95
+ for (const tool of installedTools) {
96
+ // Load manifest from cache to get description
97
+ const loaded = tryLoadManifestFromDir(tool.cachePath);
98
+ const name = tool.name.toLowerCase();
99
+ const desc = (loaded?.manifest.description ?? "").toLowerCase();
100
+
101
+ // Simple fuzzy matching: check if query terms appear in name or description
102
+ const queryTerms = queryLower.split(/\s+/);
103
+ const matches = queryTerms.every((term) => name.includes(term) || desc.includes(term));
104
+
105
+ if (matches) {
106
+ tools.push({
107
+ name: tool.name,
108
+ version: tool.version,
109
+ description: loaded?.manifest.description ?? "-",
110
+ location: tool.cachePath,
111
+ scope: "global",
112
+ });
113
+ }
114
+ }
115
+ return tools;
116
+ }
117
+
118
+ // Search project tools by walking directory
119
+ const projectDir = getProjectEnactDir(cwd);
120
+ const baseDir = projectDir ? join(projectDir, "tools") : null;
121
+
122
+ if (!baseDir || !existsSync(baseDir)) {
123
+ return tools;
124
+ }
125
+
126
+ // Walk the tools directory structure
127
+ function walkDir(dir: string): void {
128
+ try {
129
+ const entries = readdirSync(dir, { withFileTypes: true });
130
+
131
+ for (const entry of entries) {
132
+ if (!entry.isDirectory()) continue;
133
+
134
+ const entryPath = join(dir, entry.name);
135
+
136
+ // Try to load manifest from this directory
137
+ const loaded = tryLoadManifestFromDir(entryPath);
138
+ if (loaded) {
139
+ const manifest = loaded.manifest;
140
+ const name = manifest.name.toLowerCase();
141
+ const desc = (manifest.description ?? "").toLowerCase();
142
+
143
+ // Simple fuzzy matching: check if query terms appear in name or description
144
+ const queryTerms = queryLower.split(/\s+/);
145
+ const matches = queryTerms.every((term) => name.includes(term) || desc.includes(term));
146
+
147
+ if (matches) {
148
+ tools.push({
149
+ name: manifest.name,
150
+ version: manifest.version ?? "-",
151
+ description: manifest.description ?? "-",
152
+ location: entryPath,
153
+ scope: "project",
154
+ });
155
+ }
156
+ } else {
157
+ // Recurse into subdirectories (for nested namespaces)
158
+ walkDir(entryPath);
159
+ }
160
+ }
161
+ } catch {
162
+ // Ignore errors reading directories
163
+ }
164
+ }
165
+
166
+ walkDir(baseDir);
167
+ return tools;
168
+ }
169
+
170
+ /**
171
+ * Search command handler
172
+ */
173
+ async function searchHandler(
174
+ query: string,
175
+ options: SearchOptions,
176
+ ctx: CommandContext
177
+ ): Promise<void> {
178
+ // Handle local search (--local or -g)
179
+ if (options.local || options.global) {
180
+ const scope = options.global ? "global" : "project";
181
+ const results = searchLocalTools(query, scope, ctx.cwd);
182
+
183
+ // JSON output
184
+ if (options.json) {
185
+ json({ query, scope, results, total: results.length });
186
+ return;
187
+ }
188
+
189
+ // No results
190
+ if (results.length === 0) {
191
+ info(`No ${scope} tools found matching "${query}"`);
192
+ dim(
193
+ scope === "project"
194
+ ? "Try 'enact search -g' to search global tools, or search the registry without flags"
195
+ : "Try searching the registry without the -g flag"
196
+ );
197
+ return;
198
+ }
199
+
200
+ header(`${scope === "global" ? "Global" : "Project"} Tools matching "${query}"`);
201
+ newline();
202
+
203
+ const columns: TableColumn[] = [
204
+ { key: "name", header: "Name", width: 28 },
205
+ { key: "description", header: "Description", width: 50 },
206
+ ];
207
+
208
+ if (ctx.options.verbose) {
209
+ columns.push({ key: "version", header: "Version", width: 10 });
210
+ columns.push({ key: "location", header: "Location", width: 40 });
211
+ }
212
+
213
+ const rows = results.map((r) => ({
214
+ name: r.name,
215
+ description: truncateDescription(r.description, 48),
216
+ version: r.version,
217
+ location: r.location,
218
+ }));
219
+
220
+ table(rows, columns);
221
+ newline();
222
+ dim(`Found ${results.length} matching tool(s)`);
223
+ return;
224
+ }
225
+
226
+ // Default: Registry search
227
+ const config = loadConfig();
228
+ const registryUrl =
229
+ process.env.ENACT_REGISTRY_URL ??
230
+ config.registry?.url ??
231
+ "https://siikwkfgsmouioodghho.supabase.co/functions/v1";
232
+ const authToken = config.registry?.authToken;
233
+ const client = createApiClient({
234
+ baseUrl: registryUrl,
235
+ authToken: authToken,
236
+ });
237
+
238
+ const limit = options.limit ? Number.parseInt(options.limit, 10) : 20;
239
+ const offset = options.offset ? Number.parseInt(options.offset, 10) : 0;
240
+
241
+ if (ctx.options.verbose) {
242
+ info(`Searching for: "${query}"`);
243
+ if (options.tags) {
244
+ info(`Tags: ${options.tags}`);
245
+ }
246
+ }
247
+
248
+ try {
249
+ const response = await searchTools(client, {
250
+ query,
251
+ tags: options.tags,
252
+ limit,
253
+ offset,
254
+ });
255
+
256
+ // JSON output
257
+ if (options.json) {
258
+ json({
259
+ query,
260
+ results: response.results,
261
+ total: response.total,
262
+ limit: response.limit,
263
+ offset: response.offset,
264
+ hasMore: response.hasMore,
265
+ });
266
+ return;
267
+ }
268
+
269
+ // No results
270
+ if (response.results.length === 0) {
271
+ info(`No tools found matching "${query}"`);
272
+ dim("Try a different search term or remove tag filters");
273
+ return;
274
+ }
275
+
276
+ // Format results for table
277
+ const rows: SearchResultRow[] = response.results.map((result: SearchResult) => ({
278
+ name: result.name,
279
+ version: result.version,
280
+ description: truncateDescription(result.description, 40),
281
+ rating: result.trustStatus ? `${result.trustStatus.auditorCount} ✓` : "-",
282
+ downloads: formatDownloads(result.downloads),
283
+ }));
284
+
285
+ header(`Search Results for "${query}"`);
286
+ newline();
287
+
288
+ const columns: TableColumn[] = [
289
+ { key: "name", header: "Name", width: 30 },
290
+ { key: "version", header: "Version", width: 10 },
291
+ { key: "description", header: "Description", width: 42 },
292
+ { key: "rating", header: "Rating", width: 8 },
293
+ { key: "downloads", header: "↓", width: 8 },
294
+ ];
295
+
296
+ table(rows, columns);
297
+ newline();
298
+
299
+ // Pagination info
300
+ const showing = offset + response.results.length;
301
+ dim(`Showing ${offset + 1}-${showing} of ${response.total} results`);
302
+
303
+ if (response.hasMore) {
304
+ dim(`Use --offset ${showing} to see more results`);
305
+ }
306
+ } catch (err) {
307
+ // Handle specific error types
308
+ if (err instanceof Error) {
309
+ const message = err.message.toLowerCase();
310
+
311
+ // Connection errors
312
+ if (
313
+ message.includes("fetch") ||
314
+ message.includes("econnrefused") ||
315
+ message.includes("network") ||
316
+ message.includes("timeout") ||
317
+ err.name === "AbortError"
318
+ ) {
319
+ error("Unable to connect to registry");
320
+ dim("Check your internet connection or try again later");
321
+ dim(`Registry URL: ${registryUrl}`);
322
+ process.exit(1);
323
+ }
324
+
325
+ // JSON parsing errors (server returned non-JSON)
326
+ if (message.includes("json") || message.includes("unexpected token")) {
327
+ error("Registry returned an invalid response");
328
+ dim("The server may be down or experiencing issues");
329
+ dim(`Registry URL: ${registryUrl}`);
330
+ process.exit(1);
331
+ }
332
+ }
333
+
334
+ // Re-throw other errors
335
+ throw err;
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Configure the search command
341
+ */
342
+ export function configureSearchCommand(program: Command): void {
343
+ program
344
+ .command("search <query>")
345
+ .description("Search the Enact registry for tools")
346
+ .option("--local", "Search project tools (.enact/tools/) instead of registry")
347
+ .option("-g, --global", "Search global tools (~/.enact/tools/) instead of registry")
348
+ .option("-t, --tags <tags>", "Filter by tags (comma-separated, registry only)")
349
+ .option("-l, --limit <number>", "Maximum results to return (default: 20, registry only)")
350
+ .option("-o, --offset <number>", "Pagination offset (default: 0, registry only)")
351
+ .option("-v, --verbose", "Show detailed output")
352
+ .option("--json", "Output as JSON")
353
+ .action(async (query: string, options: SearchOptions) => {
354
+ const ctx: CommandContext = {
355
+ cwd: process.cwd(),
356
+ options,
357
+ isCI: Boolean(process.env.CI),
358
+ isInteractive: process.stdout.isTTY ?? false,
359
+ };
360
+
361
+ try {
362
+ await searchHandler(query, options, ctx);
363
+ } catch (err) {
364
+ error(formatError(err));
365
+ process.exit(1);
366
+ }
367
+ });
368
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * enact setup command
3
+ *
4
+ * Set up Enact configuration interactively
5
+ */
6
+
7
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
8
+ import { dirname } from "node:path";
9
+ import * as clack from "@clack/prompts";
10
+ import { type EnactConfig, getConfigPath, loadConfig } from "@enactprotocol/shared";
11
+ import type { Command } from "commander";
12
+ import type { CommandContext, GlobalOptions } from "../../types";
13
+ import { dim, error, formatError, info } from "../../utils";
14
+
15
+ interface SetupOptions extends GlobalOptions {
16
+ force?: boolean;
17
+ global?: boolean;
18
+ }
19
+
20
+ /**
21
+ * Default configuration values
22
+ */
23
+ const DEFAULT_CONFIG: Partial<EnactConfig> = {
24
+ version: "1.0.0",
25
+ trust: {
26
+ minimum_attestations: 1,
27
+ },
28
+ cache: {
29
+ maxSizeMb: 1024,
30
+ ttlSeconds: 604800, // 7 days
31
+ },
32
+ execution: {
33
+ defaultTimeout: "30s",
34
+ verbose: false,
35
+ },
36
+ registry: {
37
+ url: "https://siikwkfgsmouioodghho.supabase.co/functions/v1",
38
+ },
39
+ };
40
+
41
+ /**
42
+ * Setup command handler
43
+ */
44
+ async function setupHandler(options: SetupOptions, _ctx: CommandContext): Promise<void> {
45
+ const actualScope = options.global ? "global" : "project";
46
+ const configPath = getConfigPath();
47
+
48
+ // Check if config already exists
49
+ if (existsSync(configPath) && !options.force) {
50
+ clack.log.warn(`Configuration already exists at: ${configPath}`);
51
+ const overwrite = await clack.confirm({
52
+ message: "Overwrite existing configuration?",
53
+ });
54
+
55
+ if (clack.isCancel(overwrite) || !overwrite) {
56
+ clack.cancel("Setup cancelled");
57
+ process.exit(0);
58
+ }
59
+ }
60
+
61
+ clack.intro(`Setting up Enact ${actualScope} configuration`);
62
+
63
+ // Load existing config if available
64
+ let existingConfig: EnactConfig = {};
65
+ try {
66
+ if (existsSync(configPath)) {
67
+ existingConfig = loadConfig();
68
+ }
69
+ } catch {
70
+ // Ignore errors loading existing config
71
+ }
72
+
73
+ // Prompt for configuration
74
+ const registryUrl = await clack.text({
75
+ message: "Registry URL (recommended: https://siikwkfgsmouioodghho.supabase.co/functions/v1)",
76
+ placeholder: "https://siikwkfgsmouioodghho.supabase.co/functions/v1",
77
+ defaultValue: "https://siikwkfgsmouioodghho.supabase.co/functions/v1",
78
+ validate: (value) => {
79
+ // Allow empty to use default
80
+ if (!value || value.trim() === "") return undefined;
81
+ try {
82
+ new URL(value);
83
+ return undefined;
84
+ } catch {
85
+ return "Invalid URL format";
86
+ }
87
+ },
88
+ });
89
+
90
+ if (clack.isCancel(registryUrl)) {
91
+ clack.cancel("Setup cancelled");
92
+ process.exit(0);
93
+ }
94
+
95
+ // Use default if empty
96
+ const finalRegistryUrl =
97
+ (registryUrl as string).trim() || "https://siikwkfgsmouioodghho.supabase.co/functions/v1";
98
+
99
+ const isLocalDev =
100
+ finalRegistryUrl.includes("localhost") || finalRegistryUrl.includes("127.0.0.1");
101
+ const isOfficialRegistry = finalRegistryUrl.includes("siikwkfgsmouioodghho.supabase.co");
102
+
103
+ let authToken: string | undefined;
104
+ if (isLocalDev) {
105
+ const useAnonKey = await clack.confirm({
106
+ message: "Use local development anon key?",
107
+ initialValue: true,
108
+ });
109
+
110
+ if (clack.isCancel(useAnonKey)) {
111
+ clack.cancel("Setup cancelled");
112
+ process.exit(0);
113
+ }
114
+
115
+ if (useAnonKey) {
116
+ authToken =
117
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0";
118
+ }
119
+ } else if (isOfficialRegistry) {
120
+ // Use official registry anon key
121
+ authToken =
122
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNpaWt3a2Znc21vdWlvb2RnaGhvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjQ2MTkzMzksImV4cCI6MjA4MDE5NTMzOX0.kxnx6-IPFhmGx6rzNx36vbyhFMFZKP_jFqaDbKnJ_E0";
123
+ }
124
+
125
+ const minimumAttestations = await clack.text({
126
+ message: "Minimum attestations required for trust",
127
+ placeholder: "1",
128
+ defaultValue: String(
129
+ existingConfig.trust?.minimum_attestations || DEFAULT_CONFIG.trust?.minimum_attestations
130
+ ),
131
+ validate: (value) => {
132
+ if (!value || value.trim() === "") return undefined; // Allow empty for default
133
+ const num = Number(value);
134
+ if (Number.isNaN(num) || num < 0) return "Must be a positive number";
135
+ return undefined;
136
+ },
137
+ });
138
+
139
+ if (clack.isCancel(minimumAttestations)) {
140
+ clack.cancel("Setup cancelled");
141
+ process.exit(0);
142
+ }
143
+
144
+ const finalMinAttestations = (minimumAttestations as string).trim()
145
+ ? Number(minimumAttestations)
146
+ : existingConfig.trust?.minimum_attestations || DEFAULT_CONFIG.trust?.minimum_attestations || 1;
147
+
148
+ const cacheMaxSize = await clack.text({
149
+ message: "Maximum cache size (MB)",
150
+ placeholder: "1024",
151
+ defaultValue: String(existingConfig.cache?.maxSizeMb || DEFAULT_CONFIG.cache?.maxSizeMb),
152
+ validate: (value) => {
153
+ if (!value || value.trim() === "") return undefined; // Allow empty for default
154
+ const num = Number(value);
155
+ if (Number.isNaN(num) || num <= 0) return "Must be a positive number";
156
+ return undefined;
157
+ },
158
+ });
159
+
160
+ if (clack.isCancel(cacheMaxSize)) {
161
+ clack.cancel("Setup cancelled");
162
+ process.exit(0);
163
+ }
164
+
165
+ const finalCacheMaxSize = (cacheMaxSize as string).trim()
166
+ ? Number(cacheMaxSize)
167
+ : existingConfig.cache?.maxSizeMb || DEFAULT_CONFIG.cache?.maxSizeMb || 1024;
168
+
169
+ const defaultTimeout = await clack.text({
170
+ message: "Default execution timeout",
171
+ placeholder: "30s",
172
+ defaultValue:
173
+ existingConfig.execution?.defaultTimeout || DEFAULT_CONFIG.execution?.defaultTimeout || "30s",
174
+ validate: (value) => {
175
+ if (!value || value.trim() === "") return undefined; // Allow empty for default
176
+ if (!/^\d+[smh]$/.test(value)) {
177
+ return "Must be in format: 30s, 5m, or 1h";
178
+ }
179
+ return undefined;
180
+ },
181
+ });
182
+
183
+ if (clack.isCancel(defaultTimeout)) {
184
+ clack.cancel("Setup cancelled");
185
+ process.exit(0);
186
+ }
187
+
188
+ const finalDefaultTimeout =
189
+ (defaultTimeout as string).trim() ||
190
+ existingConfig.execution?.defaultTimeout ||
191
+ DEFAULT_CONFIG.execution?.defaultTimeout ||
192
+ "30s";
193
+
194
+ // Build configuration
195
+ const config: EnactConfig = {
196
+ version: "1.0.0",
197
+ trust: {
198
+ minimum_attestations: finalMinAttestations,
199
+ },
200
+ cache: {
201
+ maxSizeMb: finalCacheMaxSize,
202
+ ttlSeconds: existingConfig.cache?.ttlSeconds || DEFAULT_CONFIG.cache?.ttlSeconds || 604800,
203
+ },
204
+ execution: {
205
+ defaultTimeout: finalDefaultTimeout,
206
+ verbose: existingConfig.execution?.verbose || DEFAULT_CONFIG.execution?.verbose || false,
207
+ },
208
+ registry: {
209
+ url: finalRegistryUrl,
210
+ ...(authToken && { authToken }),
211
+ },
212
+ };
213
+
214
+ // Save configuration
215
+ const configDir = dirname(configPath);
216
+ if (!existsSync(configDir)) {
217
+ mkdirSync(configDir, { recursive: true });
218
+ }
219
+
220
+ // Convert to YAML format
221
+ const yaml = [
222
+ `version: ${config.version}`,
223
+ "trust:",
224
+ ` minimum_attestations: ${config.trust?.minimum_attestations}`,
225
+ "cache:",
226
+ ` maxSizeMb: ${config.cache?.maxSizeMb}`,
227
+ ` ttlSeconds: ${config.cache?.ttlSeconds}`,
228
+ "execution:",
229
+ ` defaultTimeout: ${config.execution?.defaultTimeout}`,
230
+ ` verbose: ${config.execution?.verbose}`,
231
+ "registry:",
232
+ ` url: ${config.registry?.url}`,
233
+ ...(config.registry?.authToken ? [` authToken: ${config.registry.authToken}`] : []),
234
+ ].join("\n");
235
+
236
+ writeFileSync(configPath, `${yaml}\n`, "utf-8");
237
+
238
+ clack.outro(`Configuration saved to ${configPath}`);
239
+
240
+ if (options.verbose) {
241
+ info("\nConfiguration:");
242
+ dim(yaml);
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Configure the setup command
248
+ */
249
+ export function configureSetupCommand(program: Command): void {
250
+ program
251
+ .command("setup")
252
+ .description("Set up Enact configuration")
253
+ .option("-g, --global", "Initialize global configuration (~/.enact/config.yaml)")
254
+ .option("-f, --force", "Overwrite existing configuration without prompting")
255
+ .option("-v, --verbose", "Show detailed output")
256
+ .action(async (options: SetupOptions) => {
257
+ const ctx: CommandContext = {
258
+ cwd: process.cwd(),
259
+ options,
260
+ isCI: Boolean(process.env.CI),
261
+ isInteractive: process.stdout.isTTY ?? false,
262
+ };
263
+
264
+ try {
265
+ await setupHandler(options, ctx);
266
+ } catch (err) {
267
+ error(formatError(err));
268
+ if (options.verbose && err instanceof Error && err.stack) {
269
+ dim(err.stack);
270
+ }
271
+ process.exit(1);
272
+ }
273
+ });
274
+ }