cloud-cost-cli 0.1.1 → 0.3.0-beta.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 (46) hide show
  1. package/README.md +116 -122
  2. package/dist/bin/cloud-cost-cli.js +28 -3
  3. package/dist/src/analyzers/cost-estimator.d.ts +14 -0
  4. package/dist/src/analyzers/cost-estimator.js +74 -16
  5. package/dist/src/analyzers/pricing-service.d.ts +38 -0
  6. package/dist/src/analyzers/pricing-service.js +263 -0
  7. package/dist/src/commands/ask.d.ts +11 -0
  8. package/dist/src/commands/ask.js +164 -0
  9. package/dist/src/commands/config.d.ts +1 -0
  10. package/dist/src/commands/config.js +120 -0
  11. package/dist/src/commands/costs.d.ts +6 -0
  12. package/dist/src/commands/costs.js +54 -0
  13. package/dist/src/commands/scan.d.ts +6 -0
  14. package/dist/src/commands/scan.js +255 -85
  15. package/dist/src/commands/script.d.ts +8 -0
  16. package/dist/src/commands/script.js +27 -0
  17. package/dist/src/providers/azure/client.d.ts +20 -0
  18. package/dist/src/providers/azure/client.js +41 -0
  19. package/dist/src/providers/azure/disks.d.ts +4 -0
  20. package/dist/src/providers/azure/disks.js +87 -0
  21. package/dist/src/providers/azure/index.d.ts +6 -0
  22. package/dist/src/providers/azure/index.js +15 -0
  23. package/dist/src/providers/azure/public-ips.d.ts +3 -0
  24. package/dist/src/providers/azure/public-ips.js +47 -0
  25. package/dist/src/providers/azure/sql.d.ts +4 -0
  26. package/dist/src/providers/azure/sql.js +134 -0
  27. package/dist/src/providers/azure/storage.d.ts +8 -0
  28. package/dist/src/providers/azure/storage.js +100 -0
  29. package/dist/src/providers/azure/vms.d.ts +4 -0
  30. package/dist/src/providers/azure/vms.js +164 -0
  31. package/dist/src/reporters/table.d.ts +2 -1
  32. package/dist/src/reporters/table.js +69 -3
  33. package/dist/src/services/ai.d.ts +44 -0
  34. package/dist/src/services/ai.js +345 -0
  35. package/dist/src/services/script-generator.d.ts +21 -0
  36. package/dist/src/services/script-generator.js +245 -0
  37. package/dist/src/utils/cache.d.ts +25 -0
  38. package/dist/src/utils/cache.js +197 -0
  39. package/dist/src/utils/config.d.ts +37 -0
  40. package/dist/src/utils/config.js +175 -0
  41. package/dist/src/utils/cost-tracker.d.ts +33 -0
  42. package/dist/src/utils/cost-tracker.js +135 -0
  43. package/dist/src/utils/formatter.d.ts +2 -0
  44. package/dist/src/utils/formatter.js +29 -1
  45. package/docs/RELEASE.md +14 -25
  46. package/package.json +15 -3
@@ -0,0 +1,345 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.AIService = void 0;
7
+ const openai_1 = __importDefault(require("openai"));
8
+ const ollama_1 = require("ollama");
9
+ const script_generator_1 = require("./script-generator");
10
+ const cache_1 = require("../utils/cache");
11
+ const config_1 = require("../utils/config");
12
+ const cost_tracker_1 = require("../utils/cost-tracker");
13
+ class AIService {
14
+ openaiClient = null;
15
+ ollamaClient = null;
16
+ provider;
17
+ model = 'gpt-4o-mini';
18
+ enabled = false;
19
+ maxExplanations = 3;
20
+ cache;
21
+ useCache = true;
22
+ costTracker;
23
+ constructor(config) {
24
+ this.cache = new cache_1.ExplanationCache();
25
+ this.costTracker = new cost_tracker_1.CostTracker();
26
+ // Load from config file if no explicit config provided
27
+ if (!config) {
28
+ const fileConfig = config_1.ConfigLoader.load();
29
+ // Determine provider (precedence: explicit config > env detection)
30
+ let provider = 'openai';
31
+ if (fileConfig.ai?.provider) {
32
+ // User explicitly set provider in config - ALWAYS respect it
33
+ provider = fileConfig.ai.provider;
34
+ }
35
+ else if (fileConfig.ai?.apiKey || process.env.OPENAI_API_KEY) {
36
+ provider = 'openai';
37
+ }
38
+ else {
39
+ provider = 'ollama';
40
+ }
41
+ config = {
42
+ provider,
43
+ apiKey: fileConfig.ai?.apiKey || process.env.OPENAI_API_KEY,
44
+ model: fileConfig.ai?.model,
45
+ maxExplanations: fileConfig.ai?.maxExplanations,
46
+ };
47
+ }
48
+ this.provider = config.provider;
49
+ this.maxExplanations = config.maxExplanations || 3;
50
+ // Debug logging (remove later)
51
+ if (process.env.DEBUG) {
52
+ console.error('AIService config:', JSON.stringify({
53
+ provider: config.provider,
54
+ hasApiKey: !!config.apiKey,
55
+ model: config.model,
56
+ }));
57
+ }
58
+ if (config.provider === 'openai') {
59
+ if (!config.apiKey) {
60
+ throw new Error('OpenAI API key required');
61
+ }
62
+ this.openaiClient = new openai_1.default({ apiKey: config.apiKey });
63
+ this.model = config.model || 'gpt-4o-mini';
64
+ this.enabled = true;
65
+ }
66
+ else if (config.provider === 'ollama') {
67
+ this.ollamaClient = new ollama_1.Ollama({ host: 'http://localhost:11434' });
68
+ this.model = config.model || 'llama3.2:3b';
69
+ this.enabled = true;
70
+ }
71
+ }
72
+ isEnabled() {
73
+ return this.enabled;
74
+ }
75
+ getMaxExplanations() {
76
+ return this.maxExplanations;
77
+ }
78
+ async explainOpportunity(opportunity) {
79
+ // Check cache first
80
+ if (this.useCache) {
81
+ const cached = this.cache.get(opportunity, this.provider, this.model);
82
+ if (cached) {
83
+ return { ...cached, cached: true };
84
+ }
85
+ }
86
+ const prompt = this.buildPrompt(opportunity);
87
+ try {
88
+ let content;
89
+ if (this.provider === 'openai' && this.openaiClient) {
90
+ const response = await this.openaiClient.chat.completions.create({
91
+ model: this.model,
92
+ messages: [
93
+ {
94
+ role: 'system',
95
+ content: 'You are a cloud cost optimization expert. Provide clear, actionable advice for reducing cloud costs. Be concise, practical, and encouraging. Focus on real-world steps users can take immediately. Always respond with valid JSON.',
96
+ },
97
+ {
98
+ role: 'user',
99
+ content: prompt,
100
+ },
101
+ ],
102
+ temperature: 0.7,
103
+ max_tokens: 500,
104
+ });
105
+ content = response.choices[0]?.message?.content || '';
106
+ // Track cost
107
+ if (response.usage) {
108
+ this.costTracker.track({
109
+ provider: 'openai',
110
+ model: this.model,
111
+ operation: 'explanation',
112
+ inputTokens: response.usage.prompt_tokens,
113
+ outputTokens: response.usage.completion_tokens,
114
+ });
115
+ }
116
+ }
117
+ else if (this.provider === 'ollama' && this.ollamaClient) {
118
+ const response = await this.ollamaClient.chat({
119
+ model: this.model,
120
+ messages: [
121
+ {
122
+ role: 'system',
123
+ content: 'You are a cloud cost optimization expert. Provide clear, actionable advice for reducing cloud costs. Be concise, practical, and encouraging. Focus on real-world steps users can take immediately. Always respond with valid JSON.',
124
+ },
125
+ {
126
+ role: 'user',
127
+ content: prompt,
128
+ },
129
+ ],
130
+ options: {
131
+ temperature: 0.7,
132
+ num_predict: 500,
133
+ },
134
+ });
135
+ content = response.message.content;
136
+ }
137
+ else {
138
+ throw new Error('No AI provider configured');
139
+ }
140
+ const explanation = this.parseExplanation(content, opportunity);
141
+ // Cache the result
142
+ if (this.useCache) {
143
+ this.cache.set(opportunity, this.provider, this.model, explanation);
144
+ }
145
+ return { ...explanation, cached: false };
146
+ }
147
+ catch (error) {
148
+ throw new Error(`AI explanation failed: ${error.message}`);
149
+ }
150
+ }
151
+ async generateRemediationScript(opportunity) {
152
+ const generator = new script_generator_1.ScriptGenerator();
153
+ const script = generator.generateRemediation(opportunity);
154
+ if (!script) {
155
+ return null;
156
+ }
157
+ return generator.renderScript(script);
158
+ }
159
+ buildPrompt(opportunity) {
160
+ return `
161
+ Analyze this cloud cost optimization opportunity and provide actionable guidance:
162
+
163
+ **Resource Details:**
164
+ - Type: ${opportunity.resourceType}
165
+ - ID: ${opportunity.resourceId}
166
+ - Name: ${opportunity.resourceName || 'N/A'}
167
+ - Category: ${opportunity.category}
168
+ - Current Cost: $${opportunity.currentCost}/month
169
+ - Potential Savings: $${opportunity.estimatedSavings}/month
170
+ - Recommendation: ${opportunity.recommendation}
171
+ - Metadata: ${JSON.stringify(opportunity.metadata, null, 2)}
172
+
173
+ **Please provide:**
174
+
175
+ 1. **Summary** (1 sentence): Quick explanation of what's wasteful
176
+ 2. **Why Wasteful** (2-3 sentences): Explain the problem in simple terms
177
+ 3. **Action Plan** (numbered steps): Specific actions to take, prioritized
178
+ 4. **Risk Level** (low/medium/high): How risky is this change?
179
+ 5. **Estimated Time**: How long will this take to implement?
180
+
181
+ Format your response as JSON:
182
+ {
183
+ "summary": "...",
184
+ "whyWasteful": "...",
185
+ "actionPlan": ["1. ...", "2. ...", "3. ..."],
186
+ "riskLevel": "low|medium|high",
187
+ "estimatedTime": "X minutes/hours"
188
+ }
189
+ `;
190
+ }
191
+ parseExplanation(content, opportunity) {
192
+ try {
193
+ // Try to extract JSON from the response
194
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
195
+ if (jsonMatch) {
196
+ const parsed = JSON.parse(jsonMatch[0]);
197
+ return {
198
+ summary: parsed.summary || 'AI explanation unavailable',
199
+ whyWasteful: parsed.whyWasteful || '',
200
+ actionPlan: Array.isArray(parsed.actionPlan) ? parsed.actionPlan : [],
201
+ riskLevel: parsed.riskLevel || 'medium',
202
+ estimatedTime: parsed.estimatedTime || 'Unknown',
203
+ };
204
+ }
205
+ // Fallback: return the raw content as summary
206
+ return {
207
+ summary: content.substring(0, 200),
208
+ whyWasteful: content,
209
+ actionPlan: [],
210
+ riskLevel: 'medium',
211
+ estimatedTime: 'Unknown',
212
+ };
213
+ }
214
+ catch (error) {
215
+ // Parsing failed, return raw content
216
+ return {
217
+ summary: 'AI explanation available in raw format',
218
+ whyWasteful: content,
219
+ actionPlan: [],
220
+ riskLevel: 'medium',
221
+ estimatedTime: 'Unknown',
222
+ };
223
+ }
224
+ }
225
+ async answerQuery(query, scanReport) {
226
+ const context = this.buildQueryContext(scanReport);
227
+ const prompt = this.buildQueryPrompt(query, context);
228
+ try {
229
+ let content;
230
+ if (this.provider === 'openai' && this.openaiClient) {
231
+ const response = await this.openaiClient.chat.completions.create({
232
+ model: this.model,
233
+ messages: [
234
+ {
235
+ role: 'system',
236
+ content: 'You are a cloud cost optimization expert. Answer user questions about their cloud costs clearly and concisely. Provide actionable insights and suggest specific cost-saving opportunities when relevant. Always respond with valid JSON.',
237
+ },
238
+ {
239
+ role: 'user',
240
+ content: prompt,
241
+ },
242
+ ],
243
+ temperature: 0.7,
244
+ max_tokens: 800,
245
+ });
246
+ content = response.choices[0]?.message?.content || '';
247
+ // Track cost
248
+ if (response.usage) {
249
+ this.costTracker.track({
250
+ provider: 'openai',
251
+ model: this.model,
252
+ operation: 'query',
253
+ inputTokens: response.usage.prompt_tokens,
254
+ outputTokens: response.usage.completion_tokens,
255
+ });
256
+ }
257
+ }
258
+ else if (this.provider === 'ollama' && this.ollamaClient) {
259
+ const response = await this.ollamaClient.chat({
260
+ model: this.model,
261
+ messages: [
262
+ {
263
+ role: 'system',
264
+ content: 'You are a cloud cost optimization expert. Answer user questions about their cloud costs clearly and concisely. Provide actionable insights and suggest specific cost-saving opportunities when relevant. Always respond with valid JSON.',
265
+ },
266
+ {
267
+ role: 'user',
268
+ content: prompt,
269
+ },
270
+ ],
271
+ options: {
272
+ temperature: 0.7,
273
+ num_predict: 800,
274
+ },
275
+ });
276
+ content = response.message.content;
277
+ }
278
+ else {
279
+ throw new Error('No AI provider configured');
280
+ }
281
+ return this.parseQueryAnswer(content, scanReport);
282
+ }
283
+ catch (error) {
284
+ throw new Error(`Query failed: ${error.message}`);
285
+ }
286
+ }
287
+ buildQueryContext(scanReport) {
288
+ const { summary, opportunities, totalPotentialSavings } = scanReport;
289
+ let context = `Cost Optimization Report:\n`;
290
+ context += `Total potential savings: $${totalPotentialSavings?.toFixed(2) || '0.00'}/month\n`;
291
+ context += `Total opportunities: ${opportunities?.length || 0}\n`;
292
+ context += `Idle resources: ${summary?.idleResources || 0}\n`;
293
+ context += `Unused resources: ${summary?.unusedResources || 0}\n`;
294
+ context += `Oversized resources: ${summary?.oversizedResources || 0}\n\n`;
295
+ if (opportunities && opportunities.length > 0) {
296
+ context += `Top opportunities:\n`;
297
+ opportunities.slice(0, 10).forEach((opp, i) => {
298
+ context += `${i + 1}. ${opp.resourceType} (${opp.resourceId}): ${opp.recommendation} - Save $${opp.estimatedSavings?.toFixed(2) || '0.00'}/mo\n`;
299
+ });
300
+ }
301
+ else {
302
+ context += `No opportunities found.\n`;
303
+ }
304
+ return context;
305
+ }
306
+ buildQueryPrompt(query, context) {
307
+ return `User question: "${query}"
308
+
309
+ Context from recent cost scan:
310
+ ${context}
311
+
312
+ Please answer the user's question based on this cost data. Respond in JSON format:
313
+ {
314
+ "response": "Your clear, helpful answer here",
315
+ "suggestions": ["Optional suggestion 1", "Optional suggestion 2"],
316
+ "relatedOpportunityIndexes": [0, 1, 2] // Indexes from the context (0-based)
317
+ }`;
318
+ }
319
+ parseQueryAnswer(content, scanReport) {
320
+ try {
321
+ // Try to extract JSON from markdown code blocks
322
+ const jsonMatch = content.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
323
+ const jsonStr = jsonMatch ? jsonMatch[1] : content;
324
+ const parsed = JSON.parse(jsonStr.trim());
325
+ const answer = {
326
+ response: parsed.response || content,
327
+ suggestions: Array.isArray(parsed.suggestions) ? parsed.suggestions : undefined,
328
+ };
329
+ // Map opportunity indexes to actual opportunities
330
+ if (Array.isArray(parsed.relatedOpportunityIndexes)) {
331
+ answer.relatedOpportunities = parsed.relatedOpportunityIndexes
332
+ .map((idx) => scanReport.opportunities[idx])
333
+ .filter((opp) => opp !== undefined);
334
+ }
335
+ return answer;
336
+ }
337
+ catch (error) {
338
+ // Fallback: return raw content
339
+ return {
340
+ response: content,
341
+ };
342
+ }
343
+ }
344
+ }
345
+ exports.AIService = AIService;
@@ -0,0 +1,21 @@
1
+ import { SavingsOpportunity } from '../types';
2
+ export interface RemediationScript {
3
+ description: string;
4
+ steps: ScriptStep[];
5
+ estimatedTime: string;
6
+ riskLevel: 'low' | 'medium' | 'high';
7
+ reversible: boolean;
8
+ }
9
+ export interface ScriptStep {
10
+ description: string;
11
+ command: string;
12
+ optional: boolean;
13
+ confirmationRequired: boolean;
14
+ }
15
+ export declare class ScriptGenerator {
16
+ generateRemediation(opportunity: SavingsOpportunity): RemediationScript | null;
17
+ private generateAWSScript;
18
+ private generateAzureScript;
19
+ private extractResourceGroup;
20
+ renderScript(script: RemediationScript): string;
21
+ }
@@ -0,0 +1,245 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ScriptGenerator = void 0;
4
+ class ScriptGenerator {
5
+ generateRemediation(opportunity) {
6
+ const { provider, resourceType, category } = opportunity;
7
+ if (provider === 'aws') {
8
+ return this.generateAWSScript(opportunity);
9
+ }
10
+ else if (provider === 'azure') {
11
+ return this.generateAzureScript(opportunity);
12
+ }
13
+ return null;
14
+ }
15
+ generateAWSScript(opportunity) {
16
+ const { resourceType, resourceId, category, metadata } = opportunity;
17
+ switch (resourceType) {
18
+ case 'ec2':
19
+ if (category === 'idle') {
20
+ return {
21
+ description: 'Stop idle EC2 instance',
22
+ steps: [
23
+ {
24
+ description: 'Create AMI backup (recommended)',
25
+ command: `aws ec2 create-image --instance-id ${resourceId} --name "backup-${resourceId}-$(date +%Y%m%d)" --description "Pre-stop backup"`,
26
+ optional: true,
27
+ confirmationRequired: false,
28
+ },
29
+ {
30
+ description: 'Stop the instance',
31
+ command: `aws ec2 stop-instances --instance-ids ${resourceId}`,
32
+ optional: false,
33
+ confirmationRequired: true,
34
+ },
35
+ {
36
+ description: 'Wait for instance to stop',
37
+ command: `aws ec2 wait instance-stopped --instance-ids ${resourceId}`,
38
+ optional: false,
39
+ confirmationRequired: false,
40
+ },
41
+ {
42
+ description: 'Verify instance state',
43
+ command: `aws ec2 describe-instances --instance-ids ${resourceId} --query 'Reservations[0].Instances[0].State.Name'`,
44
+ optional: false,
45
+ confirmationRequired: false,
46
+ },
47
+ ],
48
+ estimatedTime: '5-10 minutes',
49
+ riskLevel: 'low',
50
+ reversible: true,
51
+ };
52
+ }
53
+ break;
54
+ case 'ebs':
55
+ if (category === 'unused') {
56
+ return {
57
+ description: 'Delete unattached EBS volume',
58
+ steps: [
59
+ {
60
+ description: 'Create snapshot backup',
61
+ command: `aws ec2 create-snapshot --volume-id ${resourceId} --description "Pre-delete backup of ${resourceId}"`,
62
+ optional: true,
63
+ confirmationRequired: false,
64
+ },
65
+ {
66
+ description: 'Wait for snapshot to complete',
67
+ command: `aws ec2 wait snapshot-completed --snapshot-ids <snapshot-id-from-previous-step>`,
68
+ optional: true,
69
+ confirmationRequired: false,
70
+ },
71
+ {
72
+ description: 'Delete the volume',
73
+ command: `aws ec2 delete-volume --volume-id ${resourceId}`,
74
+ optional: false,
75
+ confirmationRequired: true,
76
+ },
77
+ ],
78
+ estimatedTime: '10-15 minutes',
79
+ riskLevel: 'low',
80
+ reversible: true,
81
+ };
82
+ }
83
+ break;
84
+ case 'eip':
85
+ if (category === 'unused') {
86
+ return {
87
+ description: 'Release unassociated Elastic IP',
88
+ steps: [
89
+ {
90
+ description: 'Verify IP is not associated',
91
+ command: `aws ec2 describe-addresses --allocation-ids ${resourceId} --query 'Addresses[0].AssociationId'`,
92
+ optional: false,
93
+ confirmationRequired: false,
94
+ },
95
+ {
96
+ description: 'Release the Elastic IP',
97
+ command: `aws ec2 release-address --allocation-id ${resourceId}`,
98
+ optional: false,
99
+ confirmationRequired: true,
100
+ },
101
+ ],
102
+ estimatedTime: '1 minute',
103
+ riskLevel: 'low',
104
+ reversible: false,
105
+ };
106
+ }
107
+ break;
108
+ case 'elb':
109
+ if (category === 'unused') {
110
+ const elbName = metadata.loadBalancerName || resourceId;
111
+ return {
112
+ description: 'Delete unused load balancer',
113
+ steps: [
114
+ {
115
+ description: 'Check for active targets',
116
+ command: `aws elbv2 describe-target-health --target-group-arn <target-group-arn>`,
117
+ optional: true,
118
+ confirmationRequired: false,
119
+ },
120
+ {
121
+ description: 'Delete the load balancer',
122
+ command: `aws elbv2 delete-load-balancer --load-balancer-arn ${resourceId}`,
123
+ optional: false,
124
+ confirmationRequired: true,
125
+ },
126
+ ],
127
+ estimatedTime: '2-3 minutes',
128
+ riskLevel: 'medium',
129
+ reversible: false,
130
+ };
131
+ }
132
+ break;
133
+ }
134
+ return null;
135
+ }
136
+ generateAzureScript(opportunity) {
137
+ const { resourceType, resourceId, category, metadata } = opportunity;
138
+ const resourceGroup = this.extractResourceGroup(resourceId);
139
+ if (!resourceGroup) {
140
+ return null;
141
+ }
142
+ switch (resourceType) {
143
+ case 'vm':
144
+ if (category === 'idle') {
145
+ const vmName = metadata.vmName || resourceId;
146
+ return {
147
+ description: 'Stop idle Azure VM',
148
+ steps: [
149
+ {
150
+ description: 'Deallocate (stop) the VM',
151
+ command: `az vm deallocate --resource-group ${resourceGroup} --name ${vmName}`,
152
+ optional: false,
153
+ confirmationRequired: true,
154
+ },
155
+ {
156
+ description: 'Verify VM state',
157
+ command: `az vm show --resource-group ${resourceGroup} --name ${vmName} --query 'powerState'`,
158
+ optional: false,
159
+ confirmationRequired: false,
160
+ },
161
+ ],
162
+ estimatedTime: '3-5 minutes',
163
+ riskLevel: 'low',
164
+ reversible: true,
165
+ };
166
+ }
167
+ break;
168
+ case 'disk':
169
+ if (category === 'unused') {
170
+ const diskName = metadata.diskName || 'unknown';
171
+ return {
172
+ description: 'Delete unattached managed disk',
173
+ steps: [
174
+ {
175
+ description: 'Create snapshot backup',
176
+ command: `az snapshot create --resource-group ${resourceGroup} --name ${diskName}-backup-$(date +%Y%m%d) --source ${resourceId}`,
177
+ optional: true,
178
+ confirmationRequired: false,
179
+ },
180
+ {
181
+ description: 'Delete the disk',
182
+ command: `az disk delete --resource-group ${resourceGroup} --name ${diskName} --yes`,
183
+ optional: false,
184
+ confirmationRequired: true,
185
+ },
186
+ ],
187
+ estimatedTime: '5 minutes',
188
+ riskLevel: 'low',
189
+ reversible: true,
190
+ };
191
+ }
192
+ break;
193
+ case 'ip':
194
+ if (category === 'unused') {
195
+ const ipName = metadata.publicIpName || 'unknown';
196
+ return {
197
+ description: 'Delete unassociated public IP',
198
+ steps: [
199
+ {
200
+ description: 'Verify IP is not associated',
201
+ command: `az network public-ip show --resource-group ${resourceGroup} --name ${ipName} --query 'ipConfiguration'`,
202
+ optional: false,
203
+ confirmationRequired: false,
204
+ },
205
+ {
206
+ description: 'Delete the public IP',
207
+ command: `az network public-ip delete --resource-group ${resourceGroup} --name ${ipName}`,
208
+ optional: false,
209
+ confirmationRequired: true,
210
+ },
211
+ ],
212
+ estimatedTime: '1 minute',
213
+ riskLevel: 'low',
214
+ reversible: false,
215
+ };
216
+ }
217
+ break;
218
+ }
219
+ return null;
220
+ }
221
+ extractResourceGroup(resourceId) {
222
+ const match = resourceId.match(/resourceGroups\/([^\/]+)/i);
223
+ return match ? match[1] : null;
224
+ }
225
+ renderScript(script) {
226
+ let output = '';
227
+ output += `# ${script.description}\n`;
228
+ output += `# Estimated time: ${script.estimatedTime}\n`;
229
+ output += `# Risk level: ${script.riskLevel.toUpperCase()}\n`;
230
+ output += `# Reversible: ${script.reversible ? 'Yes' : 'No'}\n\n`;
231
+ script.steps.forEach((step, index) => {
232
+ output += `# Step ${index + 1}: ${step.description}\n`;
233
+ if (step.optional) {
234
+ output += `# (Optional)\n`;
235
+ }
236
+ if (step.confirmationRequired) {
237
+ output += `# ⚠️ CONFIRMATION REQUIRED - Review before running\n`;
238
+ }
239
+ output += `${step.command}\n\n`;
240
+ });
241
+ output += `# Done! Verify the changes took effect.\n`;
242
+ return output;
243
+ }
244
+ }
245
+ exports.ScriptGenerator = ScriptGenerator;
@@ -0,0 +1,25 @@
1
+ import { AIExplanation } from '../services/ai';
2
+ export interface CachedExplanation {
3
+ opportunityHash: string;
4
+ explanation: AIExplanation;
5
+ timestamp: number;
6
+ provider: string;
7
+ model: string;
8
+ }
9
+ export declare class ExplanationCache {
10
+ private cacheDir;
11
+ private cacheDuration;
12
+ constructor(cacheDuration?: number);
13
+ private ensureCacheDir;
14
+ private hashOpportunity;
15
+ private getCachePath;
16
+ get(opportunityData: any, provider: string, model: string): AIExplanation | null;
17
+ set(opportunityData: any, provider: string, model: string, explanation: AIExplanation): void;
18
+ clear(): number;
19
+ clearExpired(): number;
20
+ getStats(): {
21
+ total: number;
22
+ size: number;
23
+ oldest: number | null;
24
+ };
25
+ }