claude-autopm 1.31.0 → 2.1.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.
@@ -0,0 +1,531 @@
1
+ /**
2
+ * @fileoverview Configuration manager for standalone AutoPM
3
+ * Handles hierarchical configuration, provider management, and encrypted API keys
4
+ *
5
+ * Based on Context7 documentation patterns:
6
+ * - node-config: Hierarchical configuration (default → environment → runtime)
7
+ * - Node.js crypto: Secure encryption for API keys
8
+ *
9
+ * @example
10
+ * const manager = new ConfigManager('/path/to/config.json');
11
+ * manager.setMasterPassword('password');
12
+ * manager.setApiKey('claude', 'sk-...');
13
+ * manager.save();
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const os = require('os');
19
+ const Encryption = require('../utils/Encryption');
20
+
21
+ /**
22
+ * Configuration manager for AutoPM
23
+ */
24
+ class ConfigManager {
25
+ /**
26
+ * Create a ConfigManager instance
27
+ *
28
+ * @param {string} [configPath] - Path to config file (defaults to ~/.autopm/config.json)
29
+ */
30
+ constructor(configPath) {
31
+ this.configPath = configPath || path.join(os.homedir(), '.autopm', 'config.json');
32
+ this.encryption = new Encryption();
33
+ this.masterPassword = null;
34
+ this.modified = false;
35
+
36
+ // Default configuration
37
+ this.config = this.getDefaultConfig();
38
+
39
+ // Load existing config if available
40
+ this.load();
41
+ }
42
+
43
+ /**
44
+ * Get default configuration structure
45
+ *
46
+ * @private
47
+ * @returns {Object} Default configuration
48
+ */
49
+ getDefaultConfig() {
50
+ return {
51
+ version: '1.0.0',
52
+ defaultProvider: 'claude',
53
+ environment: 'development',
54
+ providers: {
55
+ claude: {
56
+ model: 'claude-sonnet-4-20250514',
57
+ temperature: 0.7,
58
+ maxTokens: 4096,
59
+ rateLimit: {
60
+ tokensPerInterval: 60,
61
+ interval: 'minute'
62
+ },
63
+ circuitBreaker: {
64
+ failureThreshold: 5,
65
+ successThreshold: 2,
66
+ timeout: 60000
67
+ }
68
+ }
69
+ },
70
+ apiKeys: {}
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Get configuration value by key (supports dot notation)
76
+ *
77
+ * @param {string} [key] - Configuration key (e.g., 'providers.claude.model')
78
+ * @param {*} [defaultValue] - Default value if key doesn't exist
79
+ * @returns {*} Configuration value
80
+ *
81
+ * @example
82
+ * manager.getConfig('version'); // '1.0.0'
83
+ * manager.getConfig('providers.claude.model'); // 'claude-sonnet-4-20250514'
84
+ * manager.getConfig('nonexistent', 'default'); // 'default'
85
+ */
86
+ getConfig(key, defaultValue) {
87
+ if (!key) {
88
+ return this.config;
89
+ }
90
+
91
+ const keys = key.split('.');
92
+ let value = this.config;
93
+
94
+ for (const k of keys) {
95
+ if (value && typeof value === 'object' && k in value) {
96
+ value = value[k];
97
+ } else {
98
+ return defaultValue;
99
+ }
100
+ }
101
+
102
+ return value;
103
+ }
104
+
105
+ /**
106
+ * Set configuration value by key (supports dot notation)
107
+ *
108
+ * @param {string} key - Configuration key
109
+ * @param {*} value - Configuration value
110
+ * @throws {Error} If key is invalid
111
+ *
112
+ * @example
113
+ * manager.setConfig('version', '2.0.0');
114
+ * manager.setConfig('providers.claude.temperature', 0.9);
115
+ */
116
+ setConfig(key, value) {
117
+ if (!key || typeof key !== 'string') {
118
+ throw new Error('Key is required');
119
+ }
120
+
121
+ // Prevent circular references
122
+ if (typeof value === 'object' && value !== null) {
123
+ try {
124
+ JSON.stringify(value);
125
+ } catch (error) {
126
+ throw new Error('Cannot set value with circular references');
127
+ }
128
+ }
129
+
130
+ const keys = key.split('.');
131
+ let current = this.config;
132
+
133
+ // Navigate to parent object
134
+ for (let i = 0; i < keys.length - 1; i++) {
135
+ const k = keys[i];
136
+ if (!(k in current) || typeof current[k] !== 'object') {
137
+ current[k] = {};
138
+ }
139
+ current = current[k];
140
+ }
141
+
142
+ // Set value
143
+ current[keys[keys.length - 1]] = value;
144
+ this.modified = true;
145
+ }
146
+
147
+ /**
148
+ * Remove configuration value by key (supports dot notation)
149
+ *
150
+ * @param {string} key - Configuration key
151
+ *
152
+ * @example
153
+ * manager.removeConfig('custom.setting');
154
+ */
155
+ removeConfig(key) {
156
+ if (!key) {
157
+ return;
158
+ }
159
+
160
+ const keys = key.split('.');
161
+ let current = this.config;
162
+
163
+ // Navigate to parent object
164
+ for (let i = 0; i < keys.length - 1; i++) {
165
+ const k = keys[i];
166
+ if (!(k in current) || typeof current[k] !== 'object') {
167
+ return; // Key doesn't exist
168
+ }
169
+ current = current[k];
170
+ }
171
+
172
+ // Delete key
173
+ delete current[keys[keys.length - 1]];
174
+ this.modified = true;
175
+ }
176
+
177
+ /**
178
+ * Check if configuration key exists
179
+ *
180
+ * @param {string} key - Configuration key
181
+ * @returns {boolean} True if key exists
182
+ *
183
+ * @example
184
+ * manager.hasConfig('version'); // true
185
+ * manager.hasConfig('nonexistent'); // false
186
+ */
187
+ hasConfig(key) {
188
+ return this.getConfig(key) !== undefined;
189
+ }
190
+
191
+ /**
192
+ * Get default provider name
193
+ *
194
+ * @returns {string} Default provider name
195
+ */
196
+ getDefaultProvider() {
197
+ return this.getConfig('defaultProvider');
198
+ }
199
+
200
+ /**
201
+ * Set default provider
202
+ *
203
+ * @param {string} name - Provider name
204
+ */
205
+ setDefaultProvider(name) {
206
+ this.setConfig('defaultProvider', name);
207
+ }
208
+
209
+ /**
210
+ * Get provider configuration
211
+ *
212
+ * @param {string} name - Provider name
213
+ * @returns {Object|undefined} Provider configuration
214
+ *
215
+ * @example
216
+ * const claude = manager.getProvider('claude');
217
+ * // { model: '...', temperature: 0.7, maxTokens: 4096 }
218
+ */
219
+ getProvider(name) {
220
+ return this.getConfig(`providers.${name}`);
221
+ }
222
+
223
+ /**
224
+ * Set provider configuration (merges with existing)
225
+ *
226
+ * @param {string} name - Provider name
227
+ * @param {Object} config - Provider configuration
228
+ * @throws {Error} If configuration is invalid
229
+ *
230
+ * @example
231
+ * manager.setProvider('openai', {
232
+ * model: 'gpt-4',
233
+ * temperature: 0.8,
234
+ * maxTokens: 2000
235
+ * });
236
+ */
237
+ setProvider(name, config) {
238
+ // Merge with existing configuration
239
+ const existing = this.getProvider(name) || {};
240
+ const merged = { ...existing, ...config };
241
+
242
+ // Validate merged configuration
243
+ this.validateProvider(merged);
244
+
245
+ this.setConfig(`providers.${name}`, merged);
246
+ }
247
+
248
+ /**
249
+ * List all provider names
250
+ *
251
+ * @returns {string[]} Provider names
252
+ */
253
+ listProviders() {
254
+ const providers = this.getConfig('providers') || {};
255
+ return Object.keys(providers);
256
+ }
257
+
258
+ /**
259
+ * Remove provider configuration
260
+ *
261
+ * @param {string} name - Provider name
262
+ * @throws {Error} If trying to remove default provider
263
+ */
264
+ removeProvider(name) {
265
+ if (name === this.getDefaultProvider()) {
266
+ throw new Error('Cannot remove default provider without setting new default first');
267
+ }
268
+ this.removeConfig(`providers.${name}`);
269
+ }
270
+
271
+ /**
272
+ * Validate provider configuration
273
+ *
274
+ * @param {Object} config - Provider configuration
275
+ * @throws {Error} If configuration is invalid
276
+ */
277
+ validateProvider(config) {
278
+ if (!config.model) {
279
+ throw new Error('Provider configuration must have model');
280
+ }
281
+
282
+ if (config.temperature !== undefined) {
283
+ if (typeof config.temperature !== 'number' || config.temperature < 0 || config.temperature > 1) {
284
+ throw new Error('Temperature must be between 0 and 1');
285
+ }
286
+ }
287
+
288
+ if (config.maxTokens !== undefined) {
289
+ if (typeof config.maxTokens !== 'number' || config.maxTokens <= 0) {
290
+ throw new Error('maxTokens must be positive');
291
+ }
292
+ }
293
+
294
+ if (config.rateLimit) {
295
+ const validIntervals = ['second', 'minute', 'hour'];
296
+ if (!validIntervals.includes(config.rateLimit.interval)) {
297
+ throw new Error('Rate limit interval must be one of: second, minute, hour');
298
+ }
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Validate entire configuration
304
+ *
305
+ * @throws {Error} If configuration is invalid
306
+ */
307
+ validateConfig() {
308
+ // Validate each provider
309
+ const providers = this.getConfig('providers') || {};
310
+ for (const [name, config] of Object.entries(providers)) {
311
+ try {
312
+ this.validateProvider(config);
313
+ } catch (error) {
314
+ throw new Error(`Invalid configuration for provider '${name}': ${error.message}`);
315
+ }
316
+ }
317
+
318
+ // Validate default provider exists
319
+ const defaultProvider = this.getDefaultProvider();
320
+ if (defaultProvider && !this.getProvider(defaultProvider)) {
321
+ throw new Error(`Default provider '${defaultProvider}' is not configured`);
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Set master password for API key encryption
327
+ *
328
+ * @param {string} password - Master password
329
+ */
330
+ setMasterPassword(password) {
331
+ this.masterPassword = password;
332
+ }
333
+
334
+ /**
335
+ * Check if master password is set
336
+ *
337
+ * @returns {boolean} True if master password is set
338
+ */
339
+ hasMasterPassword() {
340
+ return this.masterPassword !== null;
341
+ }
342
+
343
+ /**
344
+ * Set encrypted API key for provider
345
+ *
346
+ * @param {string} provider - Provider name
347
+ * @param {string} apiKey - API key to encrypt
348
+ * @throws {Error} If master password is not set
349
+ *
350
+ * @example
351
+ * manager.setMasterPassword('password');
352
+ * manager.setApiKey('claude', 'sk-...');
353
+ */
354
+ setApiKey(provider, apiKey) {
355
+ if (!this.hasMasterPassword()) {
356
+ throw new Error('Master password not set. Call setMasterPassword() first.');
357
+ }
358
+
359
+ // Encrypt API key
360
+ const encrypted = this.encryption.encrypt(apiKey, this.masterPassword);
361
+
362
+ // Store encrypted data
363
+ if (!this.config.apiKeys) {
364
+ this.config.apiKeys = {};
365
+ }
366
+ this.config.apiKeys[provider] = encrypted;
367
+ this.modified = true;
368
+ }
369
+
370
+ /**
371
+ * Get decrypted API key for provider
372
+ *
373
+ * @param {string} provider - Provider name
374
+ * @returns {string|null} Decrypted API key or null if not found
375
+ * @throws {Error} If master password is not set or decryption fails
376
+ *
377
+ * @example
378
+ * const apiKey = manager.getApiKey('claude');
379
+ */
380
+ getApiKey(provider) {
381
+ if (!this.hasMasterPassword()) {
382
+ throw new Error('Master password not set. Call setMasterPassword() first.');
383
+ }
384
+
385
+ const encrypted = this.config.apiKeys?.[provider];
386
+ if (!encrypted) {
387
+ return null;
388
+ }
389
+
390
+ // Decrypt API key
391
+ return this.encryption.decrypt(encrypted, this.masterPassword);
392
+ }
393
+
394
+ /**
395
+ * Remove API key for provider
396
+ *
397
+ * @param {string} provider - Provider name
398
+ */
399
+ removeApiKey(provider) {
400
+ if (this.config.apiKeys && this.config.apiKeys[provider]) {
401
+ delete this.config.apiKeys[provider];
402
+ this.modified = true;
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Check if API key exists for provider
408
+ *
409
+ * @param {string} provider - Provider name
410
+ * @returns {boolean} True if API key exists
411
+ */
412
+ hasApiKey(provider) {
413
+ return !!(this.config.apiKeys && this.config.apiKeys[provider]);
414
+ }
415
+
416
+ /**
417
+ * Change master password and re-encrypt all API keys
418
+ *
419
+ * @param {string} oldPassword - Current password
420
+ * @param {string} newPassword - New password
421
+ * @throws {Error} If old password is incorrect
422
+ */
423
+ changeMasterPassword(oldPassword, newPassword) {
424
+ // Verify old password by trying to decrypt all keys
425
+ const apiKeys = {};
426
+ const providers = Object.keys(this.config.apiKeys || {});
427
+
428
+ for (const provider of providers) {
429
+ const encrypted = this.config.apiKeys[provider];
430
+ try {
431
+ apiKeys[provider] = this.encryption.decrypt(encrypted, oldPassword);
432
+ } catch (error) {
433
+ throw new Error('Old password is incorrect');
434
+ }
435
+ }
436
+
437
+ // Re-encrypt all keys with new password
438
+ this.masterPassword = newPassword;
439
+ for (const [provider, apiKey] of Object.entries(apiKeys)) {
440
+ this.setApiKey(provider, apiKey);
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Check if configuration has been modified
446
+ *
447
+ * @returns {boolean} True if modified
448
+ */
449
+ isModified() {
450
+ return this.modified;
451
+ }
452
+
453
+ /**
454
+ * Save configuration to file (atomic write)
455
+ *
456
+ * @throws {Error} If file write fails
457
+ */
458
+ save() {
459
+ // Ensure directory exists
460
+ const dir = path.dirname(this.configPath);
461
+ if (!fs.existsSync(dir)) {
462
+ fs.mkdirSync(dir, { recursive: true });
463
+ }
464
+
465
+ // Atomic write: write to temp file, then rename
466
+ const tempPath = `${this.configPath}.tmp`;
467
+
468
+ try {
469
+ fs.writeFileSync(tempPath, JSON.stringify(this.config, null, 2), 'utf8');
470
+ fs.renameSync(tempPath, this.configPath);
471
+ this.modified = false;
472
+ } catch (error) {
473
+ // Clean up temp file on error
474
+ if (fs.existsSync(tempPath)) {
475
+ fs.unlinkSync(tempPath);
476
+ }
477
+ throw error;
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Load configuration from file
483
+ *
484
+ * Merges loaded config with defaults (defaults have lower priority)
485
+ */
486
+ load() {
487
+ if (!fs.existsSync(this.configPath)) {
488
+ return;
489
+ }
490
+
491
+ try {
492
+ const content = fs.readFileSync(this.configPath, 'utf8');
493
+ const loaded = JSON.parse(content);
494
+
495
+ // Merge with defaults (loaded config takes priority)
496
+ this.config = this.mergeConfig(this.getDefaultConfig(), loaded);
497
+ this.modified = false;
498
+ } catch (error) {
499
+ // If config is corrupted, keep defaults
500
+ console.error(`Error loading config: ${error.message}`);
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Merge configuration objects (deep merge)
506
+ *
507
+ * @private
508
+ * @param {Object} defaults - Default configuration
509
+ * @param {Object} overrides - Override configuration
510
+ * @returns {Object} Merged configuration
511
+ */
512
+ mergeConfig(defaults, overrides) {
513
+ const result = { ...defaults };
514
+
515
+ for (const [key, value] of Object.entries(overrides)) {
516
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
517
+ if (result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])) {
518
+ result[key] = this.mergeConfig(result[key], value);
519
+ } else {
520
+ result[key] = value;
521
+ }
522
+ } else {
523
+ result[key] = value;
524
+ }
525
+ }
526
+
527
+ return result;
528
+ }
529
+ }
530
+
531
+ module.exports = ConfigManager;
@@ -0,0 +1,164 @@
1
+ /**
2
+ * AIProviderError - Custom Error for AI Provider Operations
3
+ *
4
+ * Following Node.js best practices for custom error classes:
5
+ * - Extends native Error class
6
+ * - Restores prototype chain with Object.setPrototypeOf
7
+ * - Captures stack trace with Error.captureStackTrace
8
+ * - Provides operational/programming error distinction
9
+ *
10
+ * @see https://github.com/goldbergyoni/nodebestpractices#-21-use-async-await-or-promises-for-async-error-handling
11
+ */
12
+
13
+ /**
14
+ * Custom error class for AI provider operations
15
+ *
16
+ * @class AIProviderError
17
+ * @extends {Error}
18
+ *
19
+ * @example
20
+ * // Creating an error
21
+ * throw new AIProviderError('INVALID_API_KEY', 'API key is missing or invalid');
22
+ *
23
+ * @example
24
+ * // Creating a programming error (not operational)
25
+ * throw new AIProviderError('UNKNOWN_ERROR', 'Unexpected system error', false);
26
+ *
27
+ * @example
28
+ * // Creating an error with HTTP status
29
+ * throw new AIProviderError('RATE_LIMIT', 'Too many requests', true, 429);
30
+ *
31
+ * @example
32
+ * // Catching and checking error type
33
+ * try {
34
+ * await provider.complete(prompt);
35
+ * } catch (error) {
36
+ * if (error instanceof AIProviderError && error.isOperational) {
37
+ * // Handle gracefully
38
+ * } else {
39
+ * // Log and crash
40
+ * }
41
+ * }
42
+ */
43
+ class AIProviderError extends Error {
44
+ /**
45
+ * Creates an instance of AIProviderError
46
+ *
47
+ * @param {string} code - Error code (e.g., 'INVALID_API_KEY', 'RATE_LIMIT')
48
+ * @param {string} message - Human-readable error message
49
+ * @param {boolean} [isOperational=true] - Whether error is operational (expected) or programming error
50
+ * @param {number} [httpStatus] - Optional HTTP status code
51
+ *
52
+ * @throws {AIProviderError}
53
+ */
54
+ constructor(code, message, isOperational = true, httpStatus = undefined) {
55
+ // Call parent constructor
56
+ super(message);
57
+
58
+ // Restore prototype chain
59
+ // Required for proper instanceof checks in ES6+ with transpilation
60
+ Object.setPrototypeOf(this, new.target.prototype);
61
+
62
+ // Set error name
63
+ this.name = 'AIProviderError';
64
+
65
+ // Set custom properties
66
+ this.code = code;
67
+ this.isOperational = isOperational;
68
+
69
+ // Optional HTTP status
70
+ if (httpStatus !== undefined) {
71
+ this.httpStatus = httpStatus;
72
+ }
73
+
74
+ // Capture stack trace, excluding constructor call from stack
75
+ Error.captureStackTrace(this, this.constructor);
76
+ }
77
+
78
+ /**
79
+ * Returns string representation of error
80
+ *
81
+ * @returns {string} Formatted error string
82
+ */
83
+ toString() {
84
+ return `${this.name} [${this.code}]: ${this.message}`;
85
+ }
86
+
87
+ /**
88
+ * Custom JSON serialization
89
+ * Ensures all custom properties are included when JSON.stringify is called
90
+ *
91
+ * @returns {Object} JSON-serializable object
92
+ */
93
+ toJSON() {
94
+ return {
95
+ name: this.name,
96
+ code: this.code,
97
+ message: this.message,
98
+ isOperational: this.isOperational,
99
+ httpStatus: this.httpStatus,
100
+ stack: this.stack
101
+ };
102
+ }
103
+ }
104
+
105
+ // Static error code constants
106
+ // These provide type-safe error code references throughout the codebase
107
+
108
+ /**
109
+ * Invalid or missing API key
110
+ * @type {string}
111
+ * @static
112
+ */
113
+ AIProviderError.INVALID_API_KEY = 'INVALID_API_KEY';
114
+
115
+ /**
116
+ * Rate limit exceeded
117
+ * @type {string}
118
+ * @static
119
+ */
120
+ AIProviderError.RATE_LIMIT = 'RATE_LIMIT';
121
+
122
+ /**
123
+ * Service temporarily unavailable
124
+ * @type {string}
125
+ * @static
126
+ */
127
+ AIProviderError.SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE';
128
+
129
+ /**
130
+ * Network connection error
131
+ * @type {string}
132
+ * @static
133
+ */
134
+ AIProviderError.NETWORK_ERROR = 'NETWORK_ERROR';
135
+
136
+ /**
137
+ * Invalid request parameters
138
+ * @type {string}
139
+ * @static
140
+ */
141
+ AIProviderError.INVALID_REQUEST = 'INVALID_REQUEST';
142
+
143
+ /**
144
+ * Context length exceeded (prompt too long)
145
+ * @type {string}
146
+ * @static
147
+ */
148
+ AIProviderError.CONTEXT_LENGTH_EXCEEDED = 'CONTEXT_LENGTH_EXCEEDED';
149
+
150
+ /**
151
+ * Content policy violation
152
+ * @type {string}
153
+ * @static
154
+ */
155
+ AIProviderError.CONTENT_POLICY_VIOLATION = 'CONTENT_POLICY_VIOLATION';
156
+
157
+ /**
158
+ * Unknown or unexpected error
159
+ * @type {string}
160
+ * @static
161
+ */
162
+ AIProviderError.UNKNOWN_ERROR = 'UNKNOWN_ERROR';
163
+
164
+ module.exports = AIProviderError;