cli4ai 1.2.0 → 1.2.1

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 (113) hide show
  1. package/README.md +39 -0
  2. package/dist/bin.d.ts +6 -0
  3. package/dist/bin.js +105 -0
  4. package/dist/cli.d.ts +5 -0
  5. package/dist/cli.js +335 -0
  6. package/dist/commands/add.d.ts +11 -0
  7. package/dist/commands/add.js +459 -0
  8. package/dist/commands/browse.d.ts +4 -0
  9. package/dist/commands/browse.js +379 -0
  10. package/dist/commands/config.d.ts +10 -0
  11. package/dist/commands/config.js +121 -0
  12. package/dist/commands/info.d.ts +9 -0
  13. package/dist/commands/info.js +122 -0
  14. package/dist/commands/init.d.ts +10 -0
  15. package/dist/commands/init.js +458 -0
  16. package/dist/commands/list.d.ts +10 -0
  17. package/dist/commands/list.js +76 -0
  18. package/dist/commands/mcp-config.d.ts +10 -0
  19. package/dist/commands/mcp-config.js +49 -0
  20. package/dist/commands/remotes.d.ts +22 -0
  21. package/dist/commands/remotes.js +196 -0
  22. package/dist/commands/remove.d.ts +8 -0
  23. package/dist/commands/remove.js +61 -0
  24. package/dist/commands/routines.d.ts +29 -0
  25. package/dist/commands/routines.js +363 -0
  26. package/dist/commands/run.d.ts +12 -0
  27. package/dist/commands/run.js +104 -0
  28. package/dist/commands/scheduler.d.ts +27 -0
  29. package/dist/commands/scheduler.js +350 -0
  30. package/dist/commands/search.d.ts +9 -0
  31. package/dist/commands/search.js +159 -0
  32. package/dist/commands/secrets.d.ts +28 -0
  33. package/dist/commands/secrets.js +236 -0
  34. package/dist/commands/serve.d.ts +13 -0
  35. package/dist/commands/serve.js +49 -0
  36. package/dist/commands/start.d.ts +8 -0
  37. package/dist/commands/start.js +27 -0
  38. package/dist/commands/update.d.ts +17 -0
  39. package/dist/commands/update.js +210 -0
  40. package/dist/core/config.d.ts +91 -0
  41. package/dist/core/config.js +738 -0
  42. package/dist/core/execute.d.ts +51 -0
  43. package/dist/core/execute.js +475 -0
  44. package/dist/core/link.d.ts +39 -0
  45. package/dist/core/link.js +214 -0
  46. package/dist/core/lockfile.d.ts +63 -0
  47. package/dist/core/lockfile.js +140 -0
  48. package/dist/core/manifest.d.ts +96 -0
  49. package/dist/core/manifest.js +224 -0
  50. package/dist/core/registry.d.ts +74 -0
  51. package/dist/core/registry.js +116 -0
  52. package/dist/core/remote-client.d.ts +98 -0
  53. package/dist/core/remote-client.js +252 -0
  54. package/dist/core/remotes.d.ts +88 -0
  55. package/dist/core/remotes.js +206 -0
  56. package/dist/core/routine-engine.d.ts +124 -0
  57. package/dist/core/routine-engine.js +699 -0
  58. package/dist/core/routines.d.ts +36 -0
  59. package/dist/core/routines.js +132 -0
  60. package/dist/core/scheduler-daemon.d.ts +10 -0
  61. package/dist/core/scheduler-daemon.js +77 -0
  62. package/dist/core/scheduler.d.ts +131 -0
  63. package/dist/core/scheduler.js +492 -0
  64. package/dist/core/secrets.d.ts +48 -0
  65. package/dist/core/secrets.js +384 -0
  66. package/dist/lib/cli.d.ts +84 -0
  67. package/dist/lib/cli.js +216 -0
  68. package/dist/mcp/adapter.d.ts +35 -0
  69. package/dist/mcp/adapter.js +94 -0
  70. package/dist/mcp/config-gen.d.ts +31 -0
  71. package/dist/mcp/config-gen.js +75 -0
  72. package/dist/mcp/server.d.ts +41 -0
  73. package/dist/mcp/server.js +296 -0
  74. package/dist/server/service.d.ts +85 -0
  75. package/dist/server/service.js +304 -0
  76. package/package.json +6 -3
  77. package/src/bin.ts +0 -118
  78. package/src/cli.ts +0 -412
  79. package/src/commands/add.ts +0 -562
  80. package/src/commands/browse.ts +0 -449
  81. package/src/commands/config.ts +0 -154
  82. package/src/commands/info.ts +0 -133
  83. package/src/commands/init.ts +0 -514
  84. package/src/commands/list.ts +0 -95
  85. package/src/commands/mcp-config.ts +0 -69
  86. package/src/commands/remotes.ts +0 -253
  87. package/src/commands/remove.ts +0 -78
  88. package/src/commands/routines.ts +0 -427
  89. package/src/commands/run.ts +0 -127
  90. package/src/commands/scheduler.ts +0 -438
  91. package/src/commands/search.ts +0 -185
  92. package/src/commands/secrets.ts +0 -292
  93. package/src/commands/serve.ts +0 -66
  94. package/src/commands/start.ts +0 -40
  95. package/src/commands/update.ts +0 -252
  96. package/src/core/config.ts +0 -845
  97. package/src/core/execute.ts +0 -569
  98. package/src/core/link.ts +0 -246
  99. package/src/core/lockfile.ts +0 -187
  100. package/src/core/manifest.ts +0 -327
  101. package/src/core/registry.ts +0 -165
  102. package/src/core/remote-client.ts +0 -419
  103. package/src/core/remotes.ts +0 -268
  104. package/src/core/routine-engine.ts +0 -895
  105. package/src/core/routines.ts +0 -171
  106. package/src/core/scheduler-daemon.ts +0 -94
  107. package/src/core/scheduler.ts +0 -606
  108. package/src/core/secrets.ts +0 -430
  109. package/src/lib/cli.ts +0 -261
  110. package/src/mcp/adapter.ts +0 -131
  111. package/src/mcp/config-gen.ts +0 -106
  112. package/src/mcp/server.ts +0 -365
  113. package/src/server/service.ts +0 -434
@@ -1,430 +0,0 @@
1
- /**
2
- * Secrets management for cli4ai
3
- *
4
- * Priority order:
5
- * 1. Scoped environment variables (C4AI_<PKG>__<KEY>)
6
- * 2. Environment variables (for CI/CD)
7
- * 3. Scoped local secrets vault (<pkg>:<key>)
8
- * 4. Global local secrets vault (~/.cli4ai/secrets.json)
9
- *
10
- * Secrets are stored encrypted using a machine-specific key with random entropy.
11
- *
12
- * SECURITY: The encryption key is derived from:
13
- * - Machine hostname
14
- * - Username
15
- * - A random 32-byte salt stored in ~/.cli4ai/secrets.salt
16
- *
17
- * This ensures that even if an attacker knows the hostname and username,
18
- * they cannot reconstruct the key without access to the salt file.
19
- */
20
-
21
- import { readFileSync, writeFileSync, existsSync, chmodSync, statSync, mkdirSync } from 'fs';
22
- import { createCipheriv, createDecipheriv, randomBytes, createHash, pbkdf2Sync } from 'crypto';
23
- import { hostname, userInfo, platform } from 'os';
24
- import { dirname, resolve } from 'path';
25
- import { CLI4AI_HOME, ensureCli4aiHome } from './config.js';
26
-
27
- const SECRETS_FILE = resolve(CLI4AI_HOME, 'secrets.json');
28
- const SALT_FILE = resolve(CLI4AI_HOME, 'secrets.salt');
29
- const ALGORITHM = 'aes-256-gcm';
30
- const PBKDF2_ITERATIONS = 100000; // Strong iteration count for key derivation
31
-
32
- const SECRETS_FILE_OVERRIDE_ENV = 'C4AI_SECRETS_FILE';
33
- const SALT_FILE_OVERRIDE_ENV = 'C4AI_SECRETS_SALT_FILE';
34
-
35
- export type SecretSource = 'env_scoped' | 'env' | 'vault_scoped' | 'vault' | 'missing';
36
-
37
- function getSecretsFilePath(): string {
38
- const override = process.env[SECRETS_FILE_OVERRIDE_ENV];
39
- return override ? resolve(override) : SECRETS_FILE;
40
- }
41
-
42
- function getSaltFilePath(): string {
43
- const override = process.env[SALT_FILE_OVERRIDE_ENV];
44
- return override ? resolve(override) : SALT_FILE;
45
- }
46
-
47
- function ensureSecretsDir(filePath: string): void {
48
- const dir = dirname(filePath);
49
- if (!existsSync(dir)) {
50
- mkdirSync(dir, { recursive: true });
51
- }
52
- }
53
-
54
- function normalizeEnvVarSegment(value: string): string {
55
- return value
56
- .trim()
57
- .toUpperCase()
58
- .replace(/[^A-Z0-9]+/g, '_')
59
- .replace(/^_+|_+$/g, '');
60
- }
61
-
62
- function getScopedEnvVarName(packageName: string, key: string): string {
63
- return `C4AI_${normalizeEnvVarSegment(packageName)}__${normalizeEnvVarSegment(key)}`;
64
- }
65
-
66
- function makeScopedVaultKey(packageName: string, key: string): string {
67
- return `${packageName}:${key}`;
68
- }
69
-
70
- function getLegacyMachineKey(): Buffer {
71
- const machineId = `${hostname()}-${userInfo().username}-cli4ai-secrets`;
72
- return createHash('sha256').update(machineId).digest();
73
- }
74
-
75
- /**
76
- * Get or create the random salt for key derivation.
77
- * The salt is stored in a separate file with restricted permissions.
78
- */
79
- function getSaltIfExists(): Buffer | null {
80
- const saltFilePath = getSaltFilePath();
81
-
82
- if (existsSync(saltFilePath)) {
83
- // SECURITY: Verify file permissions on Unix-like systems
84
- if (platform() !== 'win32') {
85
- try {
86
- const stats = statSync(saltFilePath);
87
- const mode = stats.mode & 0o777;
88
- if (mode !== 0o600) {
89
- console.error(`Warning: Salt file has insecure permissions (${mode.toString(8)}). Should be 600.`);
90
- // Attempt to fix permissions
91
- chmodSync(saltFilePath, 0o600);
92
- }
93
- } catch {
94
- // Ignore permission check errors
95
- }
96
- }
97
-
98
- try {
99
- const salt = readFileSync(saltFilePath);
100
- if (salt.length === 32) {
101
- return salt;
102
- }
103
- // Invalid salt file, regenerate
104
- console.error('Warning: Invalid salt file, regenerating...');
105
- } catch {
106
- // Failed to read, regenerate
107
- }
108
- }
109
-
110
- return null;
111
- }
112
-
113
- function getOrCreateSalt(): Buffer {
114
- const saltFilePath = getSaltFilePath();
115
- ensureSecretsDir(saltFilePath);
116
-
117
- const salt = getSaltIfExists();
118
- if (salt) return salt;
119
-
120
- // Generate new random salt
121
- const newSalt = randomBytes(32);
122
- writeFileSync(saltFilePath, newSalt, { mode: 0o600 });
123
- return newSalt;
124
- }
125
-
126
- /**
127
- * Try to get additional machine-specific entropy.
128
- * This is best-effort and won't fail if sources are unavailable.
129
- */
130
- function getMachineEntropy(): string {
131
- const parts: string[] = [
132
- hostname(),
133
- userInfo().username,
134
- ];
135
-
136
- // Try to get machine-id on Linux
137
- if (platform() === 'linux') {
138
- try {
139
- const machineId = readFileSync('/etc/machine-id', 'utf-8').trim();
140
- parts.push(machineId);
141
- } catch {
142
- // Not available
143
- }
144
- }
145
-
146
- // Try to get hardware UUID on macOS
147
- if (platform() === 'darwin') {
148
- try {
149
- // This is a backup - the salt file is the primary entropy source
150
- const { execSync } = require('child_process');
151
- const uuid = execSync('ioreg -rd1 -c IOPlatformExpertDevice | grep IOPlatformUUID', {
152
- encoding: 'utf-8',
153
- timeout: 1000,
154
- stdio: ['pipe', 'pipe', 'pipe']
155
- });
156
- const match = uuid.match(/"([^"]+)"$/);
157
- if (match) {
158
- parts.push(match[1]);
159
- }
160
- } catch {
161
- // Not available
162
- }
163
- }
164
-
165
- return parts.join('-');
166
- }
167
-
168
- /**
169
- * Generate a machine-specific encryption key with strong entropy.
170
- *
171
- * Uses PBKDF2 with:
172
- * - Machine-specific data (hostname, username, hardware IDs when available)
173
- * - A random 32-byte salt stored on disk
174
- * - 100,000 iterations for key stretching
175
- */
176
- function getMachineKey(options: { createSalt: boolean }): Buffer {
177
- const salt = options.createSalt ? getOrCreateSalt() : getSaltIfExists();
178
- if (!salt) {
179
- throw new Error('Secrets salt file is missing');
180
- }
181
- const machineData = getMachineEntropy();
182
-
183
- // Use PBKDF2 for proper key derivation with the random salt
184
- return pbkdf2Sync(machineData, salt, PBKDF2_ITERATIONS, 32, 'sha512');
185
- }
186
-
187
- /**
188
- * Encrypt a string value
189
- */
190
- function encrypt(text: string): string {
191
- const key = getMachineKey({ createSalt: true });
192
- const iv = randomBytes(16);
193
- const cipher = createCipheriv(ALGORITHM, key, iv);
194
-
195
- let encrypted = cipher.update(text, 'utf8', 'hex');
196
- encrypted += cipher.final('hex');
197
-
198
- const authTag = cipher.getAuthTag();
199
-
200
- return JSON.stringify({
201
- iv: iv.toString('hex'),
202
- encrypted,
203
- authTag: authTag.toString('hex')
204
- });
205
- }
206
-
207
- function decryptWithKey(data: string, key: Buffer): string {
208
- const { iv, encrypted, authTag } = JSON.parse(data);
209
-
210
- const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'hex'));
211
- decipher.setAuthTag(Buffer.from(authTag, 'hex'));
212
-
213
- let decrypted = decipher.update(encrypted, 'hex', 'utf8');
214
- decrypted += decipher.final('utf8');
215
-
216
- return decrypted;
217
- }
218
-
219
- /**
220
- * Decrypt a string value
221
- */
222
- function decrypt(data: string): string {
223
- const key = getMachineKey({ createSalt: false });
224
- return decryptWithKey(data, key);
225
- }
226
-
227
- function decryptLegacy(data: string): string {
228
- const key = getLegacyMachineKey();
229
- return decryptWithKey(data, key);
230
- }
231
-
232
- /**
233
- * Check and warn about insecure file permissions
234
- */
235
- function checkFilePermissions(filePath: string, fileName: string): void {
236
- if (platform() === 'win32') return;
237
-
238
- try {
239
- const stats = statSync(filePath);
240
- const mode = stats.mode & 0o777;
241
- // Warn if file is readable by group or others
242
- if (mode & 0o077) {
243
- console.error(`Warning: ${fileName} has insecure permissions (${mode.toString(8)}). Should be 600.`);
244
- // Attempt to fix permissions
245
- try {
246
- chmodSync(filePath, 0o600);
247
- console.error(` Fixed permissions to 600.`);
248
- } catch {
249
- console.error(` Could not fix permissions. Please run: chmod 600 ${filePath}`);
250
- }
251
- }
252
- } catch {
253
- // Ignore errors
254
- }
255
- }
256
-
257
- /**
258
- * Load all secrets from vault
259
- */
260
- function loadSecrets(): Record<string, string> {
261
- const secretsFilePath = getSecretsFilePath();
262
- ensureSecretsDir(secretsFilePath);
263
-
264
- if (!existsSync(secretsFilePath)) {
265
- return {};
266
- }
267
-
268
- // SECURITY: Check file permissions
269
- checkFilePermissions(secretsFilePath, 'secrets.json');
270
-
271
- try {
272
- const content = readFileSync(secretsFilePath, 'utf-8');
273
- const encrypted = JSON.parse(content) as Record<string, unknown>;
274
- const secrets: Record<string, string> = {};
275
- const migrated: Record<string, string> = {};
276
-
277
- for (const [key, value] of Object.entries(encrypted)) {
278
- if (typeof value !== 'string') continue;
279
- try {
280
- secrets[key] = decrypt(value);
281
- } catch {
282
- // Back-compat: attempt legacy decrypt (pre-salt secrets).
283
- try {
284
- const legacy = decryptLegacy(value);
285
- secrets[key] = legacy;
286
- migrated[key] = legacy;
287
- } catch {
288
- // Skip corrupted/unreadable entries
289
- }
290
- }
291
- }
292
-
293
- // If we successfully decrypted legacy secrets, rewrite those entries using the current scheme.
294
- if (Object.keys(migrated).length > 0) {
295
- try {
296
- const updated: Record<string, unknown> = { ...encrypted };
297
- for (const [k, v] of Object.entries(migrated)) {
298
- updated[k] = encrypt(v);
299
- }
300
- writeFileSync(secretsFilePath, JSON.stringify(updated, null, 2), { mode: 0o600 });
301
- } catch {
302
- // Best-effort migration only. If we can't write (e.g. restricted FS), still return decrypted secrets.
303
- }
304
- }
305
-
306
- return secrets;
307
- } catch {
308
- return {};
309
- }
310
- }
311
-
312
- /**
313
- * Save all secrets to vault
314
- */
315
- function saveSecrets(secrets: Record<string, string>): void {
316
- const secretsFilePath = getSecretsFilePath();
317
- ensureSecretsDir(secretsFilePath);
318
-
319
- const encrypted: Record<string, string> = {};
320
-
321
- for (const [key, value] of Object.entries(secrets)) {
322
- encrypted[key] = encrypt(value);
323
- }
324
-
325
- writeFileSync(secretsFilePath, JSON.stringify(encrypted, null, 2), { mode: 0o600 });
326
- }
327
-
328
- /**
329
- * Get a secret value (env var takes precedence)
330
- */
331
- export function getSecret(key: string, packageName?: string): string | undefined {
332
- // Environment variables take precedence (for CI/CD)
333
- if (packageName) {
334
- const scopedEnvKey = getScopedEnvVarName(packageName, key);
335
- if (process.env[scopedEnvKey]) {
336
- return process.env[scopedEnvKey];
337
- }
338
- }
339
-
340
- if (process.env[key]) {
341
- return process.env[key];
342
- }
343
-
344
- // Check vault
345
- const secrets = loadSecrets();
346
- if (packageName) {
347
- const scopedVaultKey = makeScopedVaultKey(packageName, key);
348
- if (secrets[scopedVaultKey]) {
349
- return secrets[scopedVaultKey];
350
- }
351
- }
352
-
353
- return secrets[key];
354
- }
355
-
356
- /**
357
- * Set a secret in the vault
358
- */
359
- export function setSecret(key: string, value: string, packageName?: string): void {
360
- const secrets = loadSecrets();
361
- const vaultKey = packageName ? makeScopedVaultKey(packageName, key) : key;
362
- secrets[vaultKey] = value;
363
- saveSecrets(secrets);
364
- }
365
-
366
- /**
367
- * Delete a secret from the vault
368
- */
369
- export function deleteSecret(key: string, packageName?: string): boolean {
370
- const secrets = loadSecrets();
371
- const vaultKey = packageName ? makeScopedVaultKey(packageName, key) : key;
372
- if (vaultKey in secrets) {
373
- delete secrets[vaultKey];
374
- saveSecrets(secrets);
375
- return true;
376
- }
377
- return false;
378
- }
379
-
380
- /**
381
- * List all secret keys (not values)
382
- */
383
- export function listSecretKeys(): string[] {
384
- const secrets = loadSecrets();
385
- return Object.keys(secrets);
386
- }
387
-
388
- /**
389
- * Check if a secret exists (in env or vault)
390
- */
391
- export function hasSecret(key: string, packageName?: string): boolean {
392
- return getSecretSource(key, packageName) !== 'missing';
393
- }
394
-
395
- /**
396
- * Get secret source (for display)
397
- */
398
- export function getSecretSource(key: string, packageName?: string): SecretSource {
399
- if (packageName) {
400
- const scopedEnvKey = getScopedEnvVarName(packageName, key);
401
- if (process.env[scopedEnvKey]) return 'env_scoped';
402
- }
403
- if (process.env[key]) return 'env';
404
-
405
- const secrets = loadSecrets();
406
-
407
- if (packageName) {
408
- const scopedVaultKey = makeScopedVaultKey(packageName, key);
409
- if (secrets[scopedVaultKey]) return 'vault_scoped';
410
- }
411
- if (secrets[key]) return 'vault';
412
-
413
- return 'missing';
414
- }
415
-
416
- /**
417
- * Export secrets as environment variables (for subprocess)
418
- */
419
- export function getSecretsAsEnv(keys: string[], packageName?: string): Record<string, string> {
420
- const env: Record<string, string> = {};
421
-
422
- for (const key of keys) {
423
- const value = getSecret(key, packageName);
424
- if (value) {
425
- env[key] = value;
426
- }
427
- }
428
-
429
- return env;
430
- }
package/src/lib/cli.ts DELETED
@@ -1,261 +0,0 @@
1
- /**
2
- * cli4ai - cli4ai.com
3
- * Standardized CLI framework for AI agent tools
4
- */
5
-
6
- import { Command } from 'commander';
7
- import { readFileSync } from 'fs';
8
- import { dirname, join } from 'path';
9
- import { fileURLToPath } from 'url';
10
-
11
- const __dirname = dirname(fileURLToPath(import.meta.url));
12
- const pkgPath = join(__dirname, '..', '..', 'package.json');
13
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
14
-
15
- export const BRAND = 'cli4ai - cli4ai.com';
16
- export const VERSION = pkg.version;
17
-
18
- // ═══════════════════════════════════════════════════════════════════════════
19
- // TYPES
20
- // ═══════════════════════════════════════════════════════════════════════════
21
-
22
- export interface CLIError {
23
- error: string;
24
- message: string;
25
- [key: string]: unknown;
26
- }
27
-
28
- export type CommandFn = (...args: unknown[]) => Promise<void> | void;
29
-
30
- // ═══════════════════════════════════════════════════════════════════════════
31
- // ERROR CODES
32
- // ═══════════════════════════════════════════════════════════════════════════
33
-
34
- export const ErrorCodes = {
35
- ENV_MISSING: 'ENV_MISSING',
36
- INVALID_INPUT: 'INVALID_INPUT',
37
- NOT_FOUND: 'NOT_FOUND',
38
- AUTH_FAILED: 'AUTH_FAILED',
39
- API_ERROR: 'API_ERROR',
40
- NETWORK_ERROR: 'NETWORK_ERROR',
41
- RATE_LIMITED: 'RATE_LIMITED',
42
- TIMEOUT: 'TIMEOUT',
43
- PARSE_ERROR: 'PARSE_ERROR',
44
- MANIFEST_ERROR: 'MANIFEST_ERROR',
45
- INSTALL_ERROR: 'INSTALL_ERROR',
46
- NPM_ERROR: 'NPM_ERROR',
47
- } as const;
48
-
49
- export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes];
50
-
51
- const EXIT_CODES: Record<string, number> = {
52
- [ErrorCodes.NOT_FOUND]: 2,
53
- [ErrorCodes.INVALID_INPUT]: 3,
54
- [ErrorCodes.ENV_MISSING]: 4,
55
- [ErrorCodes.MANIFEST_ERROR]: 4,
56
- [ErrorCodes.INSTALL_ERROR]: 4,
57
- [ErrorCodes.AUTH_FAILED]: 6,
58
- [ErrorCodes.NETWORK_ERROR]: 7,
59
- [ErrorCodes.RATE_LIMITED]: 8,
60
- [ErrorCodes.TIMEOUT]: 9,
61
- [ErrorCodes.PARSE_ERROR]: 10,
62
- [ErrorCodes.NPM_ERROR]: 11,
63
- };
64
-
65
- function getExitCode(code: string): number {
66
- return EXIT_CODES[code] ?? 1;
67
- }
68
-
69
- // ═══════════════════════════════════════════════════════════════════════════
70
- // ENV VALIDATION
71
- // ═══════════════════════════════════════════════════════════════════════════
72
-
73
- // SECURITY NOTE: We intentionally do NOT auto-load .env files from the filesystem.
74
- // This prevents supply chain attacks where malicious .env files in parent directories
75
- // could inject credentials or override security settings.
76
- //
77
- // Use `cli4ai secrets set <key>` for secure credential storage, or set environment
78
- // variables explicitly in your shell/CI environment.
79
-
80
- /**
81
- * Require environment variables to be set. Exits with parseable error if missing.
82
- *
83
- * NOTE: Does not auto-load .env files. Use `cli4ai secrets` for secure credential storage.
84
- */
85
- export function requireEnv(...variables: string[]): void {
86
- const missing = variables.filter(v => !process.env[v]);
87
- if (missing.length > 0) {
88
- outputError('ENV_MISSING', `Missing required environment variables: ${missing.join(', ')}`, {
89
- variables: missing,
90
- hint: 'Use "cli4ai secrets set <key>" to store securely, or set in your shell environment'
91
- });
92
- }
93
- }
94
-
95
- /**
96
- * Get env var or exit with error
97
- *
98
- * NOTE: Does not auto-load .env files. Use `cli4ai secrets` for secure credential storage.
99
- */
100
- export function env(name: string): string {
101
- const value = process.env[name];
102
- if (!value) {
103
- outputError('ENV_MISSING', `Missing required environment variable: ${name}`, {
104
- variables: [name],
105
- hint: 'Use "cli4ai secrets set ' + name + '" to store securely, or set in your shell environment'
106
- });
107
- }
108
- return value;
109
- }
110
-
111
- /**
112
- * Get env var or return default
113
- */
114
- export function envOr(name: string, defaultValue: string): string {
115
- return process.env[name] || defaultValue;
116
- }
117
-
118
- // ═══════════════════════════════════════════════════════════════════════════
119
- // OUTPUT
120
- // ═══════════════════════════════════════════════════════════════════════════
121
-
122
- /**
123
- * Output JSON data to stdout
124
- */
125
- export function output(data: unknown): void {
126
- console.log(JSON.stringify(data, null, 2));
127
- }
128
-
129
- /**
130
- * Output error and exit. Format is parseable JSON.
131
- */
132
- export function outputError(
133
- code: string,
134
- message: string,
135
- details?: Record<string, unknown>,
136
- exitCodeOverride?: number
137
- ): never {
138
- console.error(JSON.stringify({
139
- error: code,
140
- message,
141
- ...details
142
- }));
143
- process.exit(exitCodeOverride ?? getExitCode(code));
144
- }
145
-
146
- /**
147
- * Log to stderr (for progress messages)
148
- */
149
- export function log(message: string): void {
150
- console.error(message);
151
- }
152
-
153
- // ═══════════════════════════════════════════════════════════════════════════
154
- // CLI CREATION
155
- // ═══════════════════════════════════════════════════════════════════════════
156
-
157
- /**
158
- * Create a branded CLI program
159
- */
160
- export function cli(name: string, version: string, description: string): Command {
161
- const program = new Command()
162
- .name(name)
163
- .version(version, '-v, --version', 'Show version')
164
- .description(description)
165
- .addHelpText('beforeAll', `\n${BRAND}\n`)
166
- .configureOutput({
167
- writeErr: (str) => {
168
- // Don't double-output errors
169
- if (!str.includes('"error"')) {
170
- process.stderr.write(str);
171
- }
172
- }
173
- })
174
- .exitOverride((err) => {
175
- if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
176
- process.exit(0);
177
- }
178
- if (err.code === 'commander.missingArgument') {
179
- outputError('INVALID_INPUT', err.message, { code: err.code });
180
- }
181
- if (err.code === 'commander.unknownCommand') {
182
- outputError('INVALID_INPUT', err.message, { code: err.code });
183
- }
184
- if (err.code !== 'commander.help') {
185
- process.exit(1);
186
- }
187
- });
188
-
189
- program.addHelpCommand('help [command]', 'Show help for command');
190
-
191
- return program;
192
- }
193
-
194
- // ═══════════════════════════════════════════════════════════════════════════
195
- // ERROR HANDLING
196
- // ═══════════════════════════════════════════════════════════════════════════
197
-
198
- /**
199
- * Wrap an async action with error handling
200
- */
201
- export function withErrorHandling<T extends unknown[]>(
202
- fn: (...args: T) => Promise<void>
203
- ): (...args: T) => Promise<void> {
204
- return async (...args: T) => {
205
- try {
206
- await fn(...args);
207
- } catch (err) {
208
- const message = err instanceof Error ? err.message : String(err);
209
- outputError('API_ERROR', message);
210
- }
211
- };
212
- }
213
-
214
- // ═══════════════════════════════════════════════════════════════════════════
215
- // UTILITY HELPERS
216
- // ═══════════════════════════════════════════════════════════════════════════
217
-
218
- /**
219
- * Parse a string as JSON, or return error
220
- */
221
- export function parseJson<T>(str: string, context?: string): T {
222
- try {
223
- return JSON.parse(str);
224
- } catch {
225
- outputError('PARSE_ERROR', `Invalid JSON${context ? ` in ${context}` : ''}`, {
226
- input: str.slice(0, 100)
227
- });
228
- }
229
- }
230
-
231
- /**
232
- * Sleep for ms milliseconds
233
- */
234
- export const sleep = (ms: number): Promise<void> =>
235
- new Promise(r => setTimeout(r, ms));
236
-
237
- /**
238
- * Format bytes to human readable
239
- */
240
- export function formatBytes(bytes: number): string {
241
- if (bytes < 1024) return `${bytes} B`;
242
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
243
- if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
244
- return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
245
- }
246
-
247
- /**
248
- * Format date to ISO string (date only)
249
- */
250
- export function formatDate(date: Date | number | string): string {
251
- const d = new Date(date);
252
- return d.toISOString().slice(0, 10);
253
- }
254
-
255
- /**
256
- * Format date to ISO string (datetime)
257
- */
258
- export function formatDateTime(date: Date | number | string): string {
259
- const d = new Date(date);
260
- return d.toISOString().slice(0, 19).replace('T', ' ');
261
- }