cloud-cost-cli 0.2.0 ā 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.
- package/dist/bin/cloud-cost-cli.js +23 -1
- package/dist/src/commands/ask.d.ts +11 -0
- package/dist/src/commands/ask.js +164 -0
- package/dist/src/commands/config.d.ts +1 -0
- package/dist/src/commands/config.js +120 -0
- package/dist/src/commands/costs.d.ts +6 -0
- package/dist/src/commands/costs.js +54 -0
- package/dist/src/commands/scan.d.ts +3 -0
- package/dist/src/commands/scan.js +85 -2
- package/dist/src/commands/script.d.ts +8 -0
- package/dist/src/commands/script.js +27 -0
- package/dist/src/reporters/table.d.ts +2 -1
- package/dist/src/reporters/table.js +66 -2
- package/dist/src/services/ai.d.ts +44 -0
- package/dist/src/services/ai.js +345 -0
- package/dist/src/services/script-generator.d.ts +21 -0
- package/dist/src/services/script-generator.js +245 -0
- package/dist/src/utils/cache.d.ts +25 -0
- package/dist/src/utils/cache.js +197 -0
- package/dist/src/utils/config.d.ts +37 -0
- package/dist/src/utils/config.js +175 -0
- package/dist/src/utils/cost-tracker.d.ts +33 -0
- package/dist/src/utils/cost-tracker.js +135 -0
- package/package.json +4 -2
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
4
|
const commander_1 = require("commander");
|
|
5
5
|
const scan_js_1 = require("../src/commands/scan.js");
|
|
6
|
+
const ask_js_1 = require("../src/commands/ask.js");
|
|
7
|
+
const config_js_1 = require("../src/commands/config.js");
|
|
8
|
+
const costs_js_1 = require("../src/commands/costs.js");
|
|
6
9
|
const program = new commander_1.Command();
|
|
7
10
|
program
|
|
8
11
|
.name('cloud-cost-cli')
|
|
9
12
|
.description('Optimize your cloud spend in seconds')
|
|
10
|
-
.version('0.
|
|
13
|
+
.version('0.3.0-beta.1');
|
|
11
14
|
program
|
|
12
15
|
.command('scan')
|
|
13
16
|
.description('Scan cloud account for cost savings')
|
|
@@ -21,6 +24,25 @@ program
|
|
|
21
24
|
.option('--days <N>', 'Analysis period in days', '30')
|
|
22
25
|
.option('--min-savings <amount>', 'Filter by minimum savings ($/month)')
|
|
23
26
|
.option('--accurate', 'Use real-time pricing from AWS (slower but more accurate)')
|
|
27
|
+
.option('--explain', 'AI-powered explanations for top opportunities')
|
|
28
|
+
.option('--ai-provider <openai|ollama>', 'AI provider (reads from config if not specified)')
|
|
29
|
+
.option('--ai-model <model>', 'AI model (gpt-4o-mini for OpenAI, llama3.2:3b for Ollama)')
|
|
24
30
|
.option('--verbose', 'Verbose logging')
|
|
25
31
|
.action(scan_js_1.scanCommand);
|
|
32
|
+
program
|
|
33
|
+
.command('ask <query>')
|
|
34
|
+
.description('Ask natural language questions about your cloud costs')
|
|
35
|
+
.option('--ai-provider <openai|ollama>', 'AI provider (reads from config if not specified)')
|
|
36
|
+
.option('--ai-model <model>', 'AI model to use')
|
|
37
|
+
.action(ask_js_1.askCommand);
|
|
38
|
+
program
|
|
39
|
+
.command('config <action> [key] [value]')
|
|
40
|
+
.description('Manage configuration (actions: init, show, get, set, path)')
|
|
41
|
+
.action(config_js_1.configCommand);
|
|
42
|
+
program
|
|
43
|
+
.command('costs')
|
|
44
|
+
.description('Show AI usage costs')
|
|
45
|
+
.option('--days <N>', 'Number of days to include', '30')
|
|
46
|
+
.option('--clear', 'Clear cost tracking data')
|
|
47
|
+
.action(costs_js_1.costsCommand);
|
|
26
48
|
program.parse();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
interface AskCommandOptions {
|
|
2
|
+
provider?: string;
|
|
3
|
+
aiProvider?: string;
|
|
4
|
+
aiModel?: string;
|
|
5
|
+
region?: string;
|
|
6
|
+
location?: string;
|
|
7
|
+
subscriptionId?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function askCommand(query: string, options: AskCommandOptions): Promise<void>;
|
|
10
|
+
export declare function saveScanCache(provider: string, region: string | undefined, report: any): void;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
exports.askCommand = askCommand;
|
|
40
|
+
exports.saveScanCache = saveScanCache;
|
|
41
|
+
const ai_1 = require("../services/ai");
|
|
42
|
+
const logger_1 = require("../utils/logger");
|
|
43
|
+
const config_1 = require("../utils/config");
|
|
44
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
45
|
+
const fs = __importStar(require("fs"));
|
|
46
|
+
const path = __importStar(require("path"));
|
|
47
|
+
async function askCommand(query, options) {
|
|
48
|
+
(0, logger_1.info)(`Analyzing: "${query}"`);
|
|
49
|
+
// Load most recent scan results
|
|
50
|
+
const scanData = loadRecentScan();
|
|
51
|
+
if (!scanData) {
|
|
52
|
+
(0, logger_1.error)('No recent scan found. Please run a scan first:');
|
|
53
|
+
(0, logger_1.info)(' cloud-cost-cli scan --provider aws --region us-east-1');
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
const ageMinutes = Math.floor((Date.now() - scanData.timestamp) / 60000);
|
|
57
|
+
(0, logger_1.info)(`Using scan from ${ageMinutes} minutes ago (${scanData.provider}${scanData.region ? ', ' + scanData.region : ''})`);
|
|
58
|
+
// Initialize AI service
|
|
59
|
+
// Load config file to get defaults
|
|
60
|
+
const fileConfig = config_1.ConfigLoader.load();
|
|
61
|
+
// CLI flags override config file
|
|
62
|
+
const provider = options.aiProvider || fileConfig.ai?.provider || 'openai';
|
|
63
|
+
const model = options.aiModel || fileConfig.ai?.model;
|
|
64
|
+
if (provider === 'openai' && !process.env.OPENAI_API_KEY && !fileConfig.ai?.apiKey) {
|
|
65
|
+
(0, logger_1.error)('Natural language queries require OPENAI_API_KEY or --ai-provider ollama');
|
|
66
|
+
(0, logger_1.info)('Set it with: export OPENAI_API_KEY="sk-..."');
|
|
67
|
+
(0, logger_1.info)('Or run: cloud-cost-cli config set ai.provider ollama');
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const aiService = new ai_1.AIService({
|
|
72
|
+
provider,
|
|
73
|
+
apiKey: provider === 'openai' ? (process.env.OPENAI_API_KEY || fileConfig.ai?.apiKey) : undefined,
|
|
74
|
+
model,
|
|
75
|
+
});
|
|
76
|
+
console.log(chalk_1.default.cyan('\nš¤ Thinking...\n'));
|
|
77
|
+
const answer = await aiService.answerQuery(query, scanData.report);
|
|
78
|
+
console.log(chalk_1.default.bold('š” Answer:\n'));
|
|
79
|
+
console.log(chalk_1.default.white(answer.response));
|
|
80
|
+
if (answer.suggestions && answer.suggestions.length > 0) {
|
|
81
|
+
console.log(chalk_1.default.bold('\nš Suggestions:\n'));
|
|
82
|
+
answer.suggestions.forEach((suggestion, i) => {
|
|
83
|
+
console.log(chalk_1.default.green(` ${i + 1}. ${suggestion}`));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (answer.relatedOpportunities && answer.relatedOpportunities.length > 0) {
|
|
87
|
+
console.log(chalk_1.default.bold('\nšÆ Related Opportunities:\n'));
|
|
88
|
+
answer.relatedOpportunities.forEach((opp, i) => {
|
|
89
|
+
console.log(chalk_1.default.yellow(` ${i + 1}. ${opp.resourceId}: ${opp.recommendation} (Save $${opp.estimatedSavings.toFixed(2)}/mo)`));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
console.log();
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
(0, logger_1.error)(`Query failed: ${err.message}`);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function loadRecentScan() {
|
|
100
|
+
const cacheDir = path.join(process.env.HOME || '/tmp', '.cloud-cost-cli', 'scans');
|
|
101
|
+
if (!fs.existsSync(cacheDir)) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const files = fs.readdirSync(cacheDir)
|
|
106
|
+
.filter(f => f.endsWith('.json'))
|
|
107
|
+
.map(f => ({
|
|
108
|
+
path: path.join(cacheDir, f),
|
|
109
|
+
stat: fs.statSync(path.join(cacheDir, f)),
|
|
110
|
+
}))
|
|
111
|
+
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
|
112
|
+
if (files.length === 0) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
// Get most recent scan (max 24 hours old)
|
|
116
|
+
const mostRecent = files[0];
|
|
117
|
+
const ageMs = Date.now() - mostRecent.stat.mtimeMs;
|
|
118
|
+
if (ageMs > 24 * 60 * 60 * 1000) {
|
|
119
|
+
return null; // Too old
|
|
120
|
+
}
|
|
121
|
+
const data = fs.readFileSync(mostRecent.path, 'utf-8');
|
|
122
|
+
return JSON.parse(data);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function saveScanCache(provider, region, report) {
|
|
129
|
+
const cacheDir = path.join(process.env.HOME || '/tmp', '.cloud-cost-cli', 'scans');
|
|
130
|
+
if (!fs.existsSync(cacheDir)) {
|
|
131
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
132
|
+
}
|
|
133
|
+
const cache = {
|
|
134
|
+
timestamp: Date.now(),
|
|
135
|
+
provider,
|
|
136
|
+
region,
|
|
137
|
+
report,
|
|
138
|
+
};
|
|
139
|
+
const filename = `scan-${provider}-${Date.now()}.json`;
|
|
140
|
+
const filepath = path.join(cacheDir, filename);
|
|
141
|
+
try {
|
|
142
|
+
fs.writeFileSync(filepath, JSON.stringify(cache, null, 2), 'utf-8');
|
|
143
|
+
// Clean up old scans (keep only last 10)
|
|
144
|
+
const files = fs.readdirSync(cacheDir)
|
|
145
|
+
.filter(f => f.endsWith('.json'))
|
|
146
|
+
.map(f => ({
|
|
147
|
+
path: path.join(cacheDir, f),
|
|
148
|
+
stat: fs.statSync(path.join(cacheDir, f)),
|
|
149
|
+
}))
|
|
150
|
+
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
|
151
|
+
// Delete all but the 10 most recent
|
|
152
|
+
files.slice(10).forEach(f => {
|
|
153
|
+
try {
|
|
154
|
+
fs.unlinkSync(f.path);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
// Ignore deletion errors
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
// Failed to save cache, not critical
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function configCommand(action: string, key?: string, value?: string): Promise<void>;
|
|
@@ -0,0 +1,120 @@
|
|
|
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.configCommand = configCommand;
|
|
7
|
+
const config_1 = require("../utils/config");
|
|
8
|
+
const logger_1 = require("../utils/logger");
|
|
9
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
10
|
+
async function configCommand(action, key, value) {
|
|
11
|
+
switch (action) {
|
|
12
|
+
case 'init':
|
|
13
|
+
initConfig();
|
|
14
|
+
break;
|
|
15
|
+
case 'show':
|
|
16
|
+
showConfig();
|
|
17
|
+
break;
|
|
18
|
+
case 'get':
|
|
19
|
+
if (!key) {
|
|
20
|
+
(0, logger_1.error)('Key required for get action');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
getConfigValue(key);
|
|
24
|
+
break;
|
|
25
|
+
case 'set':
|
|
26
|
+
if (!key || !value) {
|
|
27
|
+
(0, logger_1.error)('Key and value required for set action');
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
setConfigValue(key, value);
|
|
31
|
+
break;
|
|
32
|
+
case 'path':
|
|
33
|
+
showConfigPath();
|
|
34
|
+
break;
|
|
35
|
+
default:
|
|
36
|
+
(0, logger_1.error)(`Unknown config action: ${action}`);
|
|
37
|
+
(0, logger_1.info)('Available actions: init, show, get, set, path');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function initConfig() {
|
|
42
|
+
const configPath = config_1.ConfigLoader.getConfigPath();
|
|
43
|
+
const example = config_1.ConfigLoader.generateExample();
|
|
44
|
+
(0, logger_1.info)('Creating example configuration...');
|
|
45
|
+
console.log(chalk_1.default.dim('ā'.repeat(80)));
|
|
46
|
+
console.log(example);
|
|
47
|
+
console.log(chalk_1.default.dim('ā'.repeat(80)));
|
|
48
|
+
config_1.ConfigLoader.save(JSON.parse(example));
|
|
49
|
+
(0, logger_1.success)(`Configuration saved to: ${configPath}`);
|
|
50
|
+
(0, logger_1.info)('Edit this file to customize your settings');
|
|
51
|
+
}
|
|
52
|
+
function showConfig() {
|
|
53
|
+
const config = config_1.ConfigLoader.load();
|
|
54
|
+
const configPath = config_1.ConfigLoader.getConfigPath();
|
|
55
|
+
console.log(chalk_1.default.bold(`\nConfiguration (from ${configPath}):\n`));
|
|
56
|
+
console.log(JSON.stringify(config, null, 2));
|
|
57
|
+
console.log();
|
|
58
|
+
const errors = config_1.ConfigLoader.validate(config);
|
|
59
|
+
if (errors.length > 0) {
|
|
60
|
+
console.log(chalk_1.default.yellow('ā ļø Validation warnings:'));
|
|
61
|
+
errors.forEach(err => console.log(chalk_1.default.yellow(` - ${err}`)));
|
|
62
|
+
console.log();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function getConfigValue(key) {
|
|
66
|
+
const config = config_1.ConfigLoader.load();
|
|
67
|
+
const value = getNestedValue(config, key);
|
|
68
|
+
if (value === undefined) {
|
|
69
|
+
(0, logger_1.error)(`Key not found: ${key}`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
console.log(JSON.stringify(value, null, 2));
|
|
73
|
+
}
|
|
74
|
+
function setConfigValue(key, value) {
|
|
75
|
+
const config = config_1.ConfigLoader.load();
|
|
76
|
+
// Parse value (try JSON first, fall back to string)
|
|
77
|
+
let parsedValue = value;
|
|
78
|
+
try {
|
|
79
|
+
parsedValue = JSON.parse(value);
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
// Not JSON, use as string
|
|
83
|
+
}
|
|
84
|
+
setNestedValue(config, key, parsedValue);
|
|
85
|
+
const errors = config_1.ConfigLoader.validate(config);
|
|
86
|
+
if (errors.length > 0) {
|
|
87
|
+
(0, logger_1.error)('Configuration validation failed:');
|
|
88
|
+
errors.forEach(err => console.log(chalk_1.default.red(` - ${err}`)));
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
config_1.ConfigLoader.save(config);
|
|
92
|
+
(0, logger_1.success)(`Set ${key} = ${JSON.stringify(parsedValue)}`);
|
|
93
|
+
}
|
|
94
|
+
function showConfigPath() {
|
|
95
|
+
const configPath = config_1.ConfigLoader.getConfigPath();
|
|
96
|
+
console.log(configPath);
|
|
97
|
+
}
|
|
98
|
+
function getNestedValue(obj, path) {
|
|
99
|
+
const keys = path.split('.');
|
|
100
|
+
let current = obj;
|
|
101
|
+
for (const key of keys) {
|
|
102
|
+
if (current === undefined || current === null) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
current = current[key];
|
|
106
|
+
}
|
|
107
|
+
return current;
|
|
108
|
+
}
|
|
109
|
+
function setNestedValue(obj, path, value) {
|
|
110
|
+
const keys = path.split('.');
|
|
111
|
+
let current = obj;
|
|
112
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
113
|
+
const key = keys[i];
|
|
114
|
+
if (current[key] === undefined || typeof current[key] !== 'object') {
|
|
115
|
+
current[key] = {};
|
|
116
|
+
}
|
|
117
|
+
current = current[key];
|
|
118
|
+
}
|
|
119
|
+
current[keys[keys.length - 1]] = value;
|
|
120
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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.costsCommand = costsCommand;
|
|
7
|
+
const cost_tracker_1 = require("../utils/cost-tracker");
|
|
8
|
+
const logger_1 = require("../utils/logger");
|
|
9
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
10
|
+
async function costsCommand(options) {
|
|
11
|
+
const tracker = new cost_tracker_1.CostTracker();
|
|
12
|
+
if (options.clear) {
|
|
13
|
+
tracker.clear();
|
|
14
|
+
(0, logger_1.info)('Cost tracking data cleared');
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const days = parseInt(options.days || '30');
|
|
18
|
+
const summary = tracker.getSummary(days);
|
|
19
|
+
console.log(chalk_1.default.bold(`\nš° AI Cost Summary (last ${days} days):\n`));
|
|
20
|
+
if (summary.totalOperations === 0) {
|
|
21
|
+
console.log(chalk_1.default.dim('No AI operations tracked yet.'));
|
|
22
|
+
console.log(chalk_1.default.dim('Use --explain or ask commands to generate AI insights.\n'));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
console.log(chalk_1.default.bold('Total:'));
|
|
26
|
+
console.log(` Operations: ${summary.totalOperations}`);
|
|
27
|
+
console.log(` Cost: $${summary.totalCost.toFixed(4)}`);
|
|
28
|
+
console.log();
|
|
29
|
+
console.log(chalk_1.default.bold('By Provider:'));
|
|
30
|
+
Object.entries(summary.byProvider).forEach(([provider, stats]) => {
|
|
31
|
+
const icon = provider === 'openai' ? 'āļø ' : 'š ';
|
|
32
|
+
console.log(` ${icon} ${provider}:`);
|
|
33
|
+
console.log(` Operations: ${stats.operations}`);
|
|
34
|
+
console.log(` Cost: $${stats.cost.toFixed(4)}`);
|
|
35
|
+
});
|
|
36
|
+
console.log();
|
|
37
|
+
console.log(chalk_1.default.bold('By Model:'));
|
|
38
|
+
Object.entries(summary.byModel).forEach(([model, stats]) => {
|
|
39
|
+
console.log(` ${model}:`);
|
|
40
|
+
console.log(` Operations: ${stats.operations}`);
|
|
41
|
+
console.log(` Cost: $${stats.cost.toFixed(4)}`);
|
|
42
|
+
});
|
|
43
|
+
console.log();
|
|
44
|
+
// Show average cost per operation
|
|
45
|
+
const avgCost = summary.totalCost / summary.totalOperations;
|
|
46
|
+
console.log(chalk_1.default.dim(`Average cost per operation: $${avgCost.toFixed(4)}`));
|
|
47
|
+
// Estimate monthly cost at current rate
|
|
48
|
+
const daysElapsed = days;
|
|
49
|
+
const monthlyEstimate = (summary.totalCost / daysElapsed) * 30;
|
|
50
|
+
if (daysElapsed >= 7) {
|
|
51
|
+
console.log(chalk_1.default.dim(`Estimated monthly cost: $${monthlyEstimate.toFixed(2)}`));
|
|
52
|
+
}
|
|
53
|
+
console.log();
|
|
54
|
+
}
|
|
@@ -10,6 +10,9 @@ interface ScanCommandOptions {
|
|
|
10
10
|
minSavings?: string;
|
|
11
11
|
verbose?: boolean;
|
|
12
12
|
accurate?: boolean;
|
|
13
|
+
explain?: boolean;
|
|
14
|
+
aiProvider?: string;
|
|
15
|
+
aiModel?: string;
|
|
13
16
|
}
|
|
14
17
|
export declare function scanCommand(options: ScanCommandOptions): Promise<void>;
|
|
15
18
|
export {};
|
|
@@ -17,6 +17,9 @@ const public_ips_1 = require("../providers/azure/public-ips");
|
|
|
17
17
|
const table_1 = require("../reporters/table");
|
|
18
18
|
const json_1 = require("../reporters/json");
|
|
19
19
|
const logger_1 = require("../utils/logger");
|
|
20
|
+
const ai_1 = require("../services/ai");
|
|
21
|
+
const ask_1 = require("./ask");
|
|
22
|
+
const config_1 = require("../utils/config");
|
|
20
23
|
async function scanCommand(options) {
|
|
21
24
|
try {
|
|
22
25
|
if (options.provider === 'aws') {
|
|
@@ -110,11 +113,51 @@ async function scanAWS(options) {
|
|
|
110
113
|
};
|
|
111
114
|
// Render output
|
|
112
115
|
const topN = parseInt(options.top || '5');
|
|
116
|
+
let aiService;
|
|
117
|
+
if (options.explain) {
|
|
118
|
+
// Load config file to get defaults
|
|
119
|
+
const fileConfig = config_1.ConfigLoader.load();
|
|
120
|
+
// CLI flags override config file
|
|
121
|
+
const provider = options.aiProvider || fileConfig.ai?.provider || 'openai';
|
|
122
|
+
const model = options.aiModel || fileConfig.ai?.model;
|
|
123
|
+
const maxExplanations = fileConfig.ai?.maxExplanations;
|
|
124
|
+
// Debug logging
|
|
125
|
+
if (process.env.DEBUG) {
|
|
126
|
+
console.error('options.aiProvider:', options.aiProvider, '(type:', typeof options.aiProvider, ')');
|
|
127
|
+
console.error('fileConfig.ai?.provider:', fileConfig.ai?.provider);
|
|
128
|
+
console.error('Provider detected:', provider);
|
|
129
|
+
console.error('Has API key in config:', !!fileConfig.ai?.apiKey);
|
|
130
|
+
console.error('Has env API key:', !!process.env.OPENAI_API_KEY);
|
|
131
|
+
}
|
|
132
|
+
if (provider === 'openai' && !process.env.OPENAI_API_KEY && !fileConfig.ai?.apiKey) {
|
|
133
|
+
(0, logger_1.error)('--explain with OpenAI requires OPENAI_API_KEY environment variable or config file');
|
|
134
|
+
(0, logger_1.info)('Set it with: export OPENAI_API_KEY="sk-..."');
|
|
135
|
+
(0, logger_1.info)('Or use --ai-provider ollama for local AI (requires Ollama installed)');
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
aiService = new ai_1.AIService({
|
|
140
|
+
provider,
|
|
141
|
+
apiKey: provider === 'openai' ? (process.env.OPENAI_API_KEY || fileConfig.ai?.apiKey) : undefined,
|
|
142
|
+
model,
|
|
143
|
+
maxExplanations,
|
|
144
|
+
});
|
|
145
|
+
if (provider === 'ollama') {
|
|
146
|
+
(0, logger_1.info)('Using local Ollama for AI explanations (privacy-first, no API costs)');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
error(`Failed to initialize AI service: ${error.message}`);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Save scan cache for natural language queries
|
|
155
|
+
(0, ask_1.saveScanCache)(options.provider, options.region, report);
|
|
113
156
|
if (options.output === 'json') {
|
|
114
157
|
(0, json_1.renderJSON)(report);
|
|
115
158
|
}
|
|
116
159
|
else {
|
|
117
|
-
(0, table_1.renderTable)(report, topN);
|
|
160
|
+
await (0, table_1.renderTable)(report, topN, aiService);
|
|
118
161
|
}
|
|
119
162
|
}
|
|
120
163
|
async function scanAzure(options) {
|
|
@@ -189,10 +232,50 @@ async function scanAzure(options) {
|
|
|
189
232
|
};
|
|
190
233
|
// Render output
|
|
191
234
|
const topN = parseInt(options.top || '5');
|
|
235
|
+
let aiService;
|
|
236
|
+
if (options.explain) {
|
|
237
|
+
// Load config file to get defaults
|
|
238
|
+
const fileConfig = config_1.ConfigLoader.load();
|
|
239
|
+
// CLI flags override config file
|
|
240
|
+
const provider = options.aiProvider || fileConfig.ai?.provider || 'openai';
|
|
241
|
+
const model = options.aiModel || fileConfig.ai?.model;
|
|
242
|
+
const maxExplanations = fileConfig.ai?.maxExplanations;
|
|
243
|
+
// Debug logging
|
|
244
|
+
if (process.env.DEBUG) {
|
|
245
|
+
console.error('options.aiProvider:', options.aiProvider, '(type:', typeof options.aiProvider, ')');
|
|
246
|
+
console.error('fileConfig.ai?.provider:', fileConfig.ai?.provider);
|
|
247
|
+
console.error('Provider detected:', provider);
|
|
248
|
+
console.error('Has API key in config:', !!fileConfig.ai?.apiKey);
|
|
249
|
+
console.error('Has env API key:', !!process.env.OPENAI_API_KEY);
|
|
250
|
+
}
|
|
251
|
+
if (provider === 'openai' && !process.env.OPENAI_API_KEY && !fileConfig.ai?.apiKey) {
|
|
252
|
+
(0, logger_1.error)('--explain with OpenAI requires OPENAI_API_KEY environment variable or config file');
|
|
253
|
+
(0, logger_1.info)('Set it with: export OPENAI_API_KEY="sk-..."');
|
|
254
|
+
(0, logger_1.info)('Or use --ai-provider ollama for local AI (requires Ollama installed)');
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
aiService = new ai_1.AIService({
|
|
259
|
+
provider,
|
|
260
|
+
apiKey: provider === 'openai' ? (process.env.OPENAI_API_KEY || fileConfig.ai?.apiKey) : undefined,
|
|
261
|
+
model,
|
|
262
|
+
maxExplanations,
|
|
263
|
+
});
|
|
264
|
+
if (provider === 'ollama') {
|
|
265
|
+
(0, logger_1.info)('Using local Ollama for AI explanations (privacy-first, no API costs)');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
error(`Failed to initialize AI service: ${error.message}`);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// Save scan cache for natural language queries
|
|
274
|
+
(0, ask_1.saveScanCache)('azure', client.location, report);
|
|
192
275
|
if (options.output === 'json') {
|
|
193
276
|
(0, json_1.renderJSON)(report);
|
|
194
277
|
}
|
|
195
278
|
else {
|
|
196
|
-
(0, table_1.renderTable)(report, topN);
|
|
279
|
+
await (0, table_1.renderTable)(report, topN, aiService);
|
|
197
280
|
}
|
|
198
281
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { SavingsOpportunity } from '../types';
|
|
2
|
+
interface ScriptCommandOptions {
|
|
3
|
+
opportunity: string;
|
|
4
|
+
output?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function scriptCommand(options: ScriptCommandOptions): Promise<void>;
|
|
7
|
+
export declare function generateScriptForOpportunity(opportunity: SavingsOpportunity): string | null;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.scriptCommand = scriptCommand;
|
|
4
|
+
exports.generateScriptForOpportunity = generateScriptForOpportunity;
|
|
5
|
+
const script_generator_1 = require("../services/script-generator");
|
|
6
|
+
const logger_1 = require("../utils/logger");
|
|
7
|
+
async function scriptCommand(options) {
|
|
8
|
+
(0, logger_1.info)('Script generation is currently only available after running a scan.');
|
|
9
|
+
(0, logger_1.info)('Usage: cloud-cost-cli scan --provider aws --region us-east-1');
|
|
10
|
+
(0, logger_1.info)('Then use the displayed resource IDs to generate scripts.');
|
|
11
|
+
// This is a placeholder - in a real implementation, we'd:
|
|
12
|
+
// 1. Load scan results from a cache/temp file
|
|
13
|
+
// 2. Find the opportunity by index or ID
|
|
14
|
+
// 3. Generate the script
|
|
15
|
+
// 4. Output to file or stdout
|
|
16
|
+
(0, logger_1.error)('Script generation requires a recent scan. Run "scan" first.');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
// Helper function to generate script for a single opportunity
|
|
20
|
+
function generateScriptForOpportunity(opportunity) {
|
|
21
|
+
const generator = new script_generator_1.ScriptGenerator();
|
|
22
|
+
const script = generator.generateRemediation(opportunity);
|
|
23
|
+
if (!script) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return generator.renderScript(script);
|
|
27
|
+
}
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import { ScanReport } from '../types';
|
|
2
|
-
|
|
2
|
+
import { AIService } from '../services/ai';
|
|
3
|
+
export declare function renderTable(report: ScanReport, topN?: number, aiService?: AIService): Promise<void>;
|
|
@@ -7,7 +7,7 @@ exports.renderTable = renderTable;
|
|
|
7
7
|
const cli_table3_1 = __importDefault(require("cli-table3"));
|
|
8
8
|
const chalk_1 = __importDefault(require("chalk"));
|
|
9
9
|
const utils_1 = require("../utils");
|
|
10
|
-
function renderTable(report, topN = 5) {
|
|
10
|
+
async function renderTable(report, topN = 5, aiService) {
|
|
11
11
|
console.log(chalk_1.default.bold('\nCloud Cost Optimization Report'));
|
|
12
12
|
console.log(`Provider: ${report.provider} | Region: ${report.region} | Account: ${report.accountId}`);
|
|
13
13
|
console.log(`Analyzed: ${report.scanPeriod.start.toISOString().split('T')[0]} to ${report.scanPeriod.end.toISOString().split('T')[0]}\n`);
|
|
@@ -21,7 +21,8 @@ function renderTable(report, topN = 5) {
|
|
|
21
21
|
console.log(chalk_1.default.bold(`Top ${opportunities.length} Savings Opportunities (est. ${(0, utils_1.formatCurrency)(report.totalPotentialSavings)}/month):\n`));
|
|
22
22
|
const table = new cli_table3_1.default({
|
|
23
23
|
head: ['#', 'Type', 'Resource ID', 'Recommendation', 'Savings/mo'],
|
|
24
|
-
colWidths: [5,
|
|
24
|
+
colWidths: [5, 12, 40, 60, 15],
|
|
25
|
+
wordWrap: true,
|
|
25
26
|
style: {
|
|
26
27
|
head: ['cyan'],
|
|
27
28
|
},
|
|
@@ -36,8 +37,71 @@ function renderTable(report, topN = 5) {
|
|
|
36
37
|
]);
|
|
37
38
|
});
|
|
38
39
|
console.log(table.toString());
|
|
40
|
+
// Show AI explanations if enabled
|
|
41
|
+
if (aiService && aiService.isEnabled()) {
|
|
42
|
+
console.log(chalk_1.default.bold('\nš¤ AI-Powered Insights:\n'));
|
|
43
|
+
const maxExplanations = aiService.getMaxExplanations();
|
|
44
|
+
const opportunitiesToExplain = opportunities.slice(0, Math.min(maxExplanations, opportunities.length));
|
|
45
|
+
for (let i = 0; i < opportunitiesToExplain.length; i++) {
|
|
46
|
+
const opp = opportunitiesToExplain[i];
|
|
47
|
+
try {
|
|
48
|
+
console.log(chalk_1.default.cyan(`Analyzing opportunity #${i + 1}...`));
|
|
49
|
+
const explanation = await aiService.explainOpportunity(opp);
|
|
50
|
+
const cacheIndicator = explanation.cached ? chalk_1.default.dim(' (cached)') : '';
|
|
51
|
+
console.log(chalk_1.default.bold(`\nš” Opportunity #${i + 1}: ${opp.resourceId}${cacheIndicator}`));
|
|
52
|
+
console.log(chalk_1.default.dim('ā'.repeat(80)));
|
|
53
|
+
console.log(chalk_1.default.white(explanation.summary));
|
|
54
|
+
console.log();
|
|
55
|
+
console.log(chalk_1.default.bold('Why this is wasteful:'));
|
|
56
|
+
console.log(explanation.whyWasteful);
|
|
57
|
+
if (explanation.actionPlan.length > 0) {
|
|
58
|
+
console.log();
|
|
59
|
+
console.log(chalk_1.default.bold('Action plan:'));
|
|
60
|
+
explanation.actionPlan.forEach((step) => {
|
|
61
|
+
console.log(chalk_1.default.green(` ${step}`));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
console.log();
|
|
65
|
+
console.log(`Risk: ${getRiskEmoji(explanation.riskLevel)} ${explanation.riskLevel.toUpperCase()}`);
|
|
66
|
+
console.log(`Time: ā±ļø ${explanation.estimatedTime}`);
|
|
67
|
+
// Try to generate remediation script
|
|
68
|
+
try {
|
|
69
|
+
const script = await aiService.generateRemediationScript(opp);
|
|
70
|
+
if (script) {
|
|
71
|
+
console.log();
|
|
72
|
+
console.log(chalk_1.default.bold('š§ Remediation Script:'));
|
|
73
|
+
console.log(chalk_1.default.dim('ā'.repeat(80)));
|
|
74
|
+
console.log(chalk_1.default.gray(script));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
// Script generation failed, skip silently
|
|
79
|
+
}
|
|
80
|
+
console.log();
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
console.log(chalk_1.default.yellow(`ā ļø AI explanation failed: ${error.message}`));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Show total count if there are more opportunities
|
|
88
|
+
if (report.opportunities.length > topN) {
|
|
89
|
+
console.log(chalk_1.default.dim(`\n... and ${report.opportunities.length - topN} more opportunities (use --top ${report.opportunities.length} to see all)`));
|
|
90
|
+
}
|
|
39
91
|
console.log(chalk_1.default.bold(`\nTotal potential savings: ${chalk_1.default.green((0, utils_1.formatCurrency)(report.totalPotentialSavings))}/month (${chalk_1.default.green((0, utils_1.formatCurrency)(report.totalPotentialSavings * 12))}/year)`));
|
|
40
92
|
console.log(`\nSummary: ${report.summary.totalResources} resources analyzed | ${report.summary.idleResources} idle | ${report.summary.oversizedResources} oversized | ${report.summary.unusedResources} unused`);
|
|
41
93
|
console.log(chalk_1.default.dim(`\nš” Note: Cost estimates based on us-east-1 pricing and may vary by region.`));
|
|
42
94
|
console.log(chalk_1.default.dim(` For more accurate estimates, actual costs depend on your usage and region.\n`));
|
|
43
95
|
}
|
|
96
|
+
function getRiskEmoji(risk) {
|
|
97
|
+
switch (risk) {
|
|
98
|
+
case 'low':
|
|
99
|
+
return 'ā
';
|
|
100
|
+
case 'medium':
|
|
101
|
+
return 'ā ļø';
|
|
102
|
+
case 'high':
|
|
103
|
+
return 'šØ';
|
|
104
|
+
default:
|
|
105
|
+
return 'ā';
|
|
106
|
+
}
|
|
107
|
+
}
|