Skip to content

Commit bc9f548

Browse files
committed
feat: add token counter and pricing usage, added --version flag
1 parent 916713e commit bc9f548

File tree

9 files changed

+186
-56
lines changed

9 files changed

+186
-56
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@ekaone/json-cli",
3-
"version": "0.1.5",
3+
"version": "0.1.6",
44
"description": "AI-powered CLI task runner with JSON command plans",
55
"keywords": ["ai", "agent", "cli", "task-runner", "llm"],
66
"author": {

src/cli.ts

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import * as p from "@clack/prompts";
2+
import { createRequire } from "module";
23
import { resolveProvider, type ProviderName } from "./providers/index.js";
34
import { generatePlan } from "./planner.js";
45
import { runPlan } from "./runner.js";
6+
import { calculateCost, formatCost } from "./providers/pricing.js";
57
import type { Step } from "./catalog.js";
68

79
// ---------------------------------------------------------------------------
@@ -16,7 +18,8 @@ function showHelp(): void {
1618
--yes Skip confirmation prompt
1719
--dry-run Show plan without executing
1820
--debug Show system prompt and raw AI response
19-
--help Show this help message`,
21+
--help Show this help message
22+
--version, -v Show version`,
2023
);
2124
p.log.message(
2225
`Examples
@@ -45,6 +48,14 @@ function parseArgs(): {
4548
debug: boolean;
4649
} {
4750
const args = process.argv.slice(2);
51+
const require = createRequire(import.meta.url);
52+
const { version, name } = require("../package.json");
53+
54+
// show version
55+
if (args.includes("--version") || args.includes("-v")) {
56+
console.log(`${name}@${version}`);
57+
process.exit(0);
58+
}
4859

4960
// show help if no args or --help flag
5061
if (args.length === 0 || args.includes("--help")) {
@@ -101,28 +112,28 @@ async function main() {
101112
const spinner = p.spinner();
102113
spinner.start("Thinking...");
103114

104-
let plan;
115+
let planResult;
105116
try {
106117
const provider = resolveProvider(providerName);
107-
plan = await generatePlan(prompt, provider, debug);
108-
spinner.stop("Plan ready");
118+
planResult = await generatePlan(prompt, provider, debug);
119+
spinner.stop(debug ? "" : "Plan ready");
109120
} catch (err) {
110121
spinner.stop("Failed to generate plan");
111122
p.log.error(err instanceof Error ? err.message : String(err));
112123
process.exit(1);
113124
}
114125

115126
// Step 2: Show plan
116-
p.log.info(`Goal: ${plan.goal}\n`);
117-
plan.steps.forEach((step, i) => {
127+
p.log.info(`Goal: ${planResult.plan.goal}\n`);
128+
planResult.plan.steps.forEach((step, i) => {
118129
const isShell = step.type === "shell";
119130
p.log.message(
120131
` ${i + 1}. ${formatStep(step)}${isShell ? " ⚠ shell" : ""}`,
121132
);
122133
});
123134

124135
// Step 3: Extra warning if any shell steps
125-
const hasShell = plan.steps.some((s) => s.type === "shell");
136+
const hasShell = planResult.plan.steps.some((s) => s.type === "shell");
126137
if (hasShell) {
127138
p.log.warn(
128139
"Plan contains shell commands — review carefully before proceeding.",
@@ -131,38 +142,54 @@ async function main() {
131142

132143
// Step 4: Dry run — show plan and exit
133144
if (dryRun) {
134-
p.outro("Dry run complete — no commands were executed.");
135-
setTimeout(() => process.exit(0), 50);
136-
return;
145+
const { input, output } = planResult.usage;
146+
const cost = calculateCost(providerName, input, output);
147+
const costStr = formatCost(cost);
148+
const usageStr =
149+
input > 0 || output > 0
150+
? ` | tokens: ${input} in / ${output} out${costStr ? ` | ${costStr}` : ""}`
151+
: "";
152+
153+
p.outro(`Dry run complete — no commands were executed.${usageStr}`);
154+
await new Promise((r) => setTimeout(r, 100));
155+
process.exit(0);
137156
}
138157

139158
// Step 5: Confirm — skip if --yes
140159
if (!yes) {
141160
const confirmed = await p.confirm({ message: "Proceed?" });
142161
if (p.isCancel(confirmed) || !confirmed) {
143162
p.cancel("Aborted.");
144-
setTimeout(() => process.exit(0), 50);
145-
return;
163+
await new Promise((r) => setTimeout(r, 100));
164+
process.exit(0);
146165
}
147166
} else {
148167
p.log.info("Skipping confirmation (--yes)");
149168
}
150169

151170
// Step 6: Execute
152171
console.log("");
153-
const result = await runPlan(plan, (step, i, total) => {
172+
const result = await runPlan(planResult.plan, (step, i, total) => {
154173
p.log.step(`Step ${i + 1}/${total}: ${formatStep(step)}`);
155174
});
156175

157176
// Step 7: Result
158177
if (result.success) {
159-
p.outro("✅ All steps completed successfully.");
178+
const { input, output } = planResult.usage;
179+
const cost = calculateCost(providerName, input, output);
180+
const costStr = formatCost(cost);
181+
const usageStr =
182+
input > 0 || output > 0
183+
? ` | tokens: ${input} in / ${output} out${costStr ? ` | ${costStr}` : ""}`
184+
: "";
185+
186+
p.outro(`✅ All steps completed successfully.${usageStr}`);
160187
} else {
161188
p.log.error(
162189
`❌ Failed at step ${result.failedStep?.id}: ${result.failedStep?.description}\n${result.error ?? ""}`,
163190
);
164-
setTimeout(() => process.exit(1), 50);
165-
return;
191+
await new Promise((r) => setTimeout(r, 100));
192+
process.exit(1);
166193
}
167194
}
168195

src/planner.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { buildCatalogPrompt, PlanSchema, validateStep } from "./catalog.js";
2-
import type { AIProvider } from "./providers/types.js";
2+
import type { AIProvider, TokenUsage } from "./providers/types.js";
33
import type { Plan } from "./catalog.js";
44

55
// ---------------------------------------------------------------------------
@@ -51,7 +51,7 @@ export async function generatePlan(
5151
userPrompt: string,
5252
provider: AIProvider,
5353
debug: boolean = false,
54-
): Promise<Plan> {
54+
): Promise<{ plan: Plan; usage: TokenUsage }> {
5555
const systemPrompt = buildSystemPrompt();
5656

5757
if (debug) {
@@ -64,25 +64,25 @@ export async function generatePlan(
6464
console.log("│");
6565
}
6666

67-
const raw = await provider.generate(userPrompt, systemPrompt);
67+
const response = await provider.generate(userPrompt, systemPrompt);
6868

6969
if (debug) {
7070
console.log("● Raw AI response:");
7171
try {
72-
const parsed = JSON.parse(raw);
72+
const parsed = JSON.parse(response.content);
7373
console.log(
7474
"│ " + JSON.stringify(parsed, null, 2).split("\n").join("\n│ "),
7575
);
7676
} catch {
77-
console.log("│ " + raw);
77+
console.log("│ " + response.content);
7878
}
7979
console.log("│");
8080
console.log("◇ Plan ready");
8181
console.log("");
8282
}
8383

8484
// Strip markdown fences if any provider wraps output
85-
const cleaned = raw.replace(/```json|```/g, "").trim();
85+
const cleaned = response.content.replace(/```json|```/g, "").trim();
8686

8787
let parsed: unknown;
8888
try {
@@ -110,5 +110,8 @@ export async function generatePlan(
110110
}
111111
}
112112

113-
return result.data;
113+
return {
114+
plan: result.data,
115+
usage: response.usage,
116+
};
114117
}

src/providers/claude.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import Anthropic from "@anthropic-ai/sdk";
2-
import type { AIProvider } from "./types.js";
2+
import type { AIProvider, TokenUsage } from "./types.js";
33

44
export function createClaudeProvider(apiKey?: string): AIProvider {
55
const client = new Anthropic({
@@ -19,7 +19,16 @@ export function createClaudeProvider(apiKey?: string): AIProvider {
1919
const block = message.content[0];
2020
if (block.type !== "text")
2121
throw new Error("Unexpected response type from Claude");
22-
return block.text;
22+
23+
const usage: TokenUsage = {
24+
input: message.usage?.input_tokens ?? 0,
25+
output: message.usage?.output_tokens ?? 0,
26+
};
27+
28+
return {
29+
content: block.text,
30+
usage,
31+
};
2332
},
2433
};
2534
}

src/providers/ollama.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,42 @@
1-
import type { AIProvider } from "./types.js";
1+
import type { AIProvider, TokenUsage } from "./types.js";
22

3-
export function createOllamaProvider(model = "llama3.2", baseUrl = "http://localhost:11434"): AIProvider {
3+
export function createOllamaProvider(
4+
model = "llama3.2",
5+
baseUrl = "http://localhost:11434",
6+
): AIProvider {
47
return {
58
name: `ollama/${model}`,
69
async generate(userPrompt, systemPrompt) {
710
const response = await fetch(`${baseUrl}/api/chat`, {
8-
method: "POST",
11+
method: "POST",
912
headers: { "Content-Type": "application/json" },
1013
body: JSON.stringify({
1114
model,
1215
stream: false,
1316
messages: [
1417
{ role: "system", content: systemPrompt },
15-
{ role: "user", content: userPrompt },
18+
{ role: "user", content: userPrompt },
1619
],
1720
}),
1821
});
1922

2023
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
2124

22-
const data = await response.json() as { message?: { content?: string } };
25+
const data = (await response.json()) as {
26+
message?: { content?: string };
27+
};
2328
const content = data?.message?.content;
2429
if (!content) throw new Error("Empty response from Ollama");
25-
return content;
30+
31+
const usage: TokenUsage = {
32+
input: 0,
33+
output: 0,
34+
};
35+
36+
return {
37+
content,
38+
usage,
39+
};
2640
},
2741
};
2842
}

src/providers/openai.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import OpenAI from "openai";
2-
import type { AIProvider } from "./types.js";
2+
import type { AIProvider, TokenUsage } from "./types.js";
33

44
export function createOpenAIProvider(apiKey?: string): AIProvider {
55
const client = new OpenAI({ apiKey: apiKey ?? process.env.OPENAI_API_KEY });
@@ -8,18 +8,27 @@ export function createOpenAIProvider(apiKey?: string): AIProvider {
88
name: "openai",
99
async generate(userPrompt, systemPrompt) {
1010
const response = await client.chat.completions.create({
11-
model: "gpt-4o",
11+
model: "gpt-4o",
1212
messages: [
1313
{ role: "system", content: systemPrompt },
14-
{ role: "user", content: userPrompt },
14+
{ role: "user", content: userPrompt },
1515
],
16-
max_tokens: 1024,
16+
max_tokens: 1024,
1717
response_format: { type: "json_object" }, // enforces JSON output
1818
});
1919

2020
const content = response.choices[0]?.message.content;
2121
if (!content) throw new Error("Empty response from OpenAI");
22-
return content;
22+
23+
const usage: TokenUsage = {
24+
input: response.usage?.prompt_tokens ?? 0,
25+
output: response.usage?.completion_tokens ?? 0,
26+
};
27+
28+
return {
29+
content,
30+
usage,
31+
};
2332
},
2433
};
2534
}

src/providers/pricing.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { ProviderName } from "./index.js";
2+
3+
export const PRICING = {
4+
claude: { input: 3.0, output: 15.0 }, // per 1M tokens
5+
openai: { input: 2.5, output: 10.0 },
6+
ollama: { input: 0, output: 0 }, // local, free
7+
} as const;
8+
9+
export function calculateCost(
10+
provider: ProviderName,
11+
inputTokens: number,
12+
outputTokens: number,
13+
): number {
14+
const rates = PRICING[provider];
15+
const inputCost = (inputTokens / 1_000_000) * rates.input;
16+
const outputCost = (outputTokens / 1_000_000) * rates.output;
17+
return inputCost + outputCost;
18+
}
19+
20+
export function formatCost(cost: number): string {
21+
if (cost === 0) return "";
22+
return `~$${cost.toFixed(4)}`;
23+
}

src/providers/types.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1+
export interface TokenUsage {
2+
input: number;
3+
output: number;
4+
}
5+
16
export interface AIProvider {
27
name: string;
3-
generate(userPrompt: string, systemPrompt: string): Promise<string>;
8+
generate(
9+
userPrompt: string,
10+
systemPrompt: string,
11+
): Promise<{
12+
content: string;
13+
usage: TokenUsage;
14+
}>;
415
}

0 commit comments

Comments
 (0)