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