@robota-sdk/agent-command 3.0.0-beta.67 → 3.0.0-beta.68

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.
@@ -1,5 +1,7 @@
1
1
  import {
2
2
  buildProviderSetupPatch,
3
+ formatOrgPolicyViolationMessage,
4
+ isApiKeyPlaintext,
3
5
  setCurrentProvider,
4
6
  upsertProviderProfile,
5
7
  } from '@robota-sdk/agent-framework';
@@ -19,12 +21,6 @@ import type {
19
21
  IProviderSetupInput,
20
22
  } from '@robota-sdk/agent-framework';
21
23
 
22
- const YES = 'yes';
23
- const PROVIDER_RESTART_EFFECT = {
24
- type: 'session-restart-requested',
25
- reason: 'other',
26
- } as const;
27
-
28
24
  export function formatProviderChoiceLabel(
29
25
  name: string,
30
26
  profile: IProviderProfileSettings,
@@ -40,53 +36,37 @@ export function buildProviderSwitch(
40
36
  options: IProviderCommandModuleOptions,
41
37
  ): ICommandResult {
42
38
  if (!profileName) {
43
- return { message: 'Usage: provider use <profile>', success: false };
39
+ return { message: 'Usage: /provider switch <profile>', success: false };
44
40
  }
45
41
  if (!providers?.[profileName]) {
46
42
  return { message: `Provider profile "${profileName}" was not found.`, success: false };
47
43
  }
44
+ const { orgPolicy } = options;
45
+ if (orgPolicy?.allowedProviders && !orgPolicy.allowedProviders.includes(profileName)) {
46
+ return {
47
+ message: formatOrgPolicyViolationMessage(
48
+ `Provider "${profileName}" is not allowed by your organization policy. Allowed: ${orgPolicy.allowedProviders.join(', ')}.`,
49
+ orgPolicy.adminContact,
50
+ ),
51
+ success: false,
52
+ };
53
+ }
48
54
  if (options.settings.readMergedSettings().currentProvider === profileName) {
49
- return { message: `Provider profile "${profileName}" is already current.`, success: true };
55
+ return { message: `Already using provider "${profileName}".`, success: true };
50
56
  }
57
+ const profile = providers[profileName];
58
+ const target = options.settings.readTargetSettings();
59
+ const merged = options.settings.readMergedSettings();
60
+ const next =
61
+ target.providers?.[profileName] !== undefined || merged.providers?.[profileName] !== undefined
62
+ ? { ...target, currentProvider: profileName }
63
+ : setCurrentProvider(target, profileName);
64
+ options.settings.writeTargetSettings(next);
65
+ const modelLabel = profile.model ?? 'unknown model';
51
66
  return {
52
- message: `Provider change requested: ${profileName}`,
67
+ message: `Switched to ${profileName} (${modelLabel}). History preserved.`,
53
68
  success: true,
54
- interaction: createProviderSwitchInteraction(profileName, options),
55
- };
56
- }
57
-
58
- function createProviderSwitchInteraction(
59
- profileName: string,
60
- options: IProviderCommandModuleOptions,
61
- ): ICommandInteraction {
62
- return {
63
- prompt: {
64
- kind: 'choice',
65
- title: `Change provider to ${profileName}? This will restart the session.`,
66
- options: [
67
- { value: YES, label: 'Yes' },
68
- { value: 'no', label: 'No' },
69
- ],
70
- },
71
- submit: (value) => {
72
- if (value !== YES) {
73
- return { message: 'Provider change cancelled.', success: true };
74
- }
75
- const merged = options.settings.readMergedSettings();
76
- const target = options.settings.readTargetSettings();
77
- const next =
78
- target.providers?.[profileName] !== undefined ||
79
- merged.providers?.[profileName] !== undefined
80
- ? { ...target, currentProvider: profileName }
81
- : setCurrentProvider(target, profileName);
82
- options.settings.writeTargetSettings(next);
83
- return {
84
- message: `Provider changed to ${profileName}. Restarting...`,
85
- success: true,
86
- effects: [{ ...PROVIDER_RESTART_EFFECT, message: 'Provider change restart' }],
87
- };
88
- },
89
- cancel: () => ({ message: 'Provider change cancelled.', success: true }),
69
+ effects: [{ type: 'provider-hot-swap-requested', profileName }],
90
70
  };
91
71
  }
92
72
 
@@ -176,6 +156,16 @@ function completeProviderEdit(
176
156
  if (!currentProfile) {
177
157
  return { message: `Provider profile "${profileName}" was not found.`, success: false };
178
158
  }
159
+ const { orgPolicy } = options;
160
+ if (orgPolicy?.requireApiKeyFromEnv && isApiKeyPlaintext(input.apiKey)) {
161
+ return {
162
+ message: formatOrgPolicyViolationMessage(
163
+ 'Your organization policy requires API keys to be stored as environment variable references ($ENV:VAR_NAME), not as plaintext.',
164
+ orgPolicy.adminContact,
165
+ ),
166
+ success: false,
167
+ };
168
+ }
179
169
  const target = options.settings.readTargetSettings();
180
170
  const patch = buildProviderSetupPatch(input, {
181
171
  providerDefinitions: options.providerDefinitions,
@@ -190,11 +180,9 @@ function completeProviderEdit(
190
180
  const isCurrent = merged.currentProvider === profileName;
191
181
  return {
192
182
  message: isCurrent
193
- ? `Provider ${profileName} updated. Restarting...`
183
+ ? `Provider ${profileName} updated. Switching...`
194
184
  : `Provider ${profileName} updated.`,
195
185
  success: true,
196
- ...(isCurrent
197
- ? { effects: [{ ...PROVIDER_RESTART_EFFECT, message: 'Provider edit restart' }] }
198
- : {}),
186
+ ...(isCurrent ? { effects: [{ type: 'provider-hot-swap-requested', profileName }] } : {}),
199
187
  };
200
188
  }
@@ -0,0 +1,64 @@
1
+ import type { TPromptInput } from './provider-setup-flow.js';
2
+ import type { ITerminalOutput } from '@robota-sdk/agent-core';
3
+
4
+ export type TOnboardingPath = 'has-key' | 'free-key' | 'local';
5
+
6
+ export interface IOnboardingResult {
7
+ path: TOnboardingPath;
8
+ preselectedType?: string;
9
+ }
10
+
11
+ const ONBOARDING_PROMPT = `
12
+ Do you have an API key for an AI provider?
13
+
14
+ 1. Yes, I have an API key
15
+ 2. No — get a free key (Google Gemini, takes ~2 min)
16
+ 3. No — use a local model (LM Studio, no API key needed)
17
+
18
+ Choose [1-3] (default: 1): `;
19
+
20
+ const GEMINI_GUIDE = `
21
+ ── Get a free Gemini API key ────────────────────────────────────────────────
22
+
23
+ 1. Open https://aistudio.google.com/apikey in your browser
24
+ 2. Sign in with your Google account
25
+ 3. Click "Create API key" (takes ~30 seconds)
26
+ 4. Copy the key starting with "AIza..."
27
+
28
+ ─────────────────────────────────────────────────────────────────────────────
29
+ `;
30
+
31
+ const LOCAL_MODEL_GUIDE = `
32
+ ── Set up a local model with LM Studio ──────────────────────────────────────
33
+
34
+ 1. Download LM Studio from https://lmstudio.ai
35
+ 2. Open LM Studio → search for a model (e.g. "llama" or "phi") → Download
36
+ 3. Go to Developer tab → click Start Server
37
+ (default address: http://localhost:1234)
38
+
39
+ When the server is running, come back here and press Enter to continue.
40
+
41
+ ─────────────────────────────────────────────────────────────────────────────
42
+ `;
43
+
44
+ export async function runOnboardingBranch(
45
+ promptInput: TPromptInput,
46
+ terminal: ITerminalOutput,
47
+ ): Promise<IOnboardingResult> {
48
+ const raw = await promptInput(ONBOARDING_PROMPT);
49
+ const choice = raw.trim() || '1';
50
+
51
+ if (choice === '2') {
52
+ terminal.writeLine(GEMINI_GUIDE);
53
+ await promptInput(' Press Enter when you have your API key: ');
54
+ return { path: 'free-key', preselectedType: 'gemini' };
55
+ }
56
+
57
+ if (choice === '3') {
58
+ terminal.writeLine(LOCAL_MODEL_GUIDE);
59
+ await promptInput(' Press Enter when LM Studio server is running: ');
60
+ return { path: 'local', preselectedType: 'gemma' };
61
+ }
62
+
63
+ return { path: 'has-key' };
64
+ }
@@ -177,12 +177,21 @@ export function formatProviderSetupPromptLabel(
177
177
  return `${prefix} ${step.title}${suffix}: `;
178
178
  }
179
179
 
180
+ const CATEGORY_BADGES: Record<string, string> = {
181
+ 'cloud-paid': '[Cloud/Paid]',
182
+ 'cloud-free': '[Cloud/Free]',
183
+ 'local-free': '[Local/Free]',
184
+ };
185
+
180
186
  export function formatProviderSetupChoiceLabel(definition: IProviderDefinition): string {
181
- const label =
187
+ const badge =
188
+ definition.category !== undefined ? `${CATEGORY_BADGES[definition.category] ?? ''} ` : '';
189
+ const name =
182
190
  definition.displayName !== undefined
183
191
  ? `${definition.displayName} (${definition.type})`
184
192
  : definition.type;
185
- return definition.description !== undefined ? `${label} - ${definition.description}` : label;
193
+ const label = `${badge}${name}`;
194
+ return definition.description !== undefined ? `${label} — ${definition.description}` : label;
186
195
  }
187
196
 
188
197
  export function formatProviderSetupHelpLinks(
@@ -10,6 +10,7 @@ import {
10
10
  applyProviderConfiguration,
11
11
  } from '@robota-sdk/agent-framework';
12
12
 
13
+ import { runOnboardingBranch } from './provider-onboarding.js';
13
14
  import {
14
15
  formatProviderSetupSelectionPrompt,
15
16
  resolveProviderSetupSelection,
@@ -38,11 +39,22 @@ export async function runProviderStartupSetup(
38
39
  terminal: ITerminalOutput,
39
40
  providerDefinitions: readonly IProviderDefinition[],
40
41
  ): Promise<void> {
41
- const providerChoice = await promptInput(formatProviderSetupSelectionPrompt(providerDefinitions));
42
- const type = resolveProviderSetupSelection(providerChoice, providerDefinitions);
42
+ const onboarding = await runOnboardingBranch(promptInput, terminal);
43
+ const existingProfileNames = Object.keys(readMergedProviderSettings(cwd).providers ?? {});
43
44
  const settingsPath = resolveSettingsPathForScope(cwd, ctx.settingsScope);
45
+
46
+ let type: string;
47
+ if (onboarding.preselectedType !== undefined) {
48
+ type = onboarding.preselectedType;
49
+ } else {
50
+ const providerChoice = await promptInput(
51
+ formatProviderSetupSelectionPrompt(providerDefinitions),
52
+ );
53
+ type = resolveProviderSetupSelection(providerChoice, providerDefinitions);
54
+ }
55
+
44
56
  const input = await runProviderSetupPromptFlow(type, promptInput, providerDefinitions, {
45
- existingProfileNames: Object.keys(readMergedProviderSettings(cwd).providers ?? {}),
57
+ existingProfileNames,
46
58
  });
47
59
  applyProviderConfiguration(settingsPath, input, { providerDefinitions });
48
60
  const language = await promptInput(' Response language (ko/en/ja/zh, default: en): ');
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { calculateCost, formatTokens, formatUsd } from '../model-pricing.js';
3
+
4
+ describe('calculateCost', () => {
5
+ it('returns exact cost for known Anthropic Sonnet model', () => {
6
+ const cost = calculateCost('claude-sonnet-4-5', 1_000_000, 1_000_000);
7
+ expect(cost).toBeCloseTo(3 + 15, 5);
8
+ });
9
+
10
+ it('returns exact cost for known Anthropic Opus model', () => {
11
+ const cost = calculateCost('claude-opus-4-7', 1_000_000, 1_000_000);
12
+ expect(cost).toBeCloseTo(15 + 75, 5);
13
+ });
14
+
15
+ it('calculates fractional token costs correctly', () => {
16
+ const cost = calculateCost('claude-sonnet-4-5', 45_000, 12_000);
17
+ expect(cost).toBeDefined();
18
+ expect(cost!).toBeCloseTo((45_000 / 1_000_000) * 3 + (12_000 / 1_000_000) * 15, 8);
19
+ });
20
+
21
+ it('falls back to pattern matching for claude-sonnet variant', () => {
22
+ const cost = calculateCost('claude-sonnet-99-99', 1_000_000, 0);
23
+ expect(cost).toBeCloseTo(3, 5);
24
+ });
25
+
26
+ it('falls back to pattern matching for deepseek variant', () => {
27
+ const cost = calculateCost('deepseek-v3-turbo', 1_000_000, 0);
28
+ expect(cost).toBeDefined();
29
+ });
30
+
31
+ it('returns undefined for completely unknown model', () => {
32
+ const cost = calculateCost('unknown-model-xyz-123', 1_000, 1_000);
33
+ expect(cost).toBeUndefined();
34
+ });
35
+
36
+ it('returns zero cost when both token counts are zero', () => {
37
+ const cost = calculateCost('claude-sonnet-4-5', 0, 0);
38
+ expect(cost).toBe(0);
39
+ });
40
+ });
41
+
42
+ describe('formatUsd', () => {
43
+ it('formats small amounts with 4 decimal places', () => {
44
+ expect(formatUsd(0.0043)).toBe('$0.0043');
45
+ });
46
+
47
+ it('formats sub-dollar amounts with 3 decimal places', () => {
48
+ expect(formatUsd(0.187)).toBe('$0.187');
49
+ });
50
+
51
+ it('formats dollar+ amounts with 2 decimal places', () => {
52
+ expect(formatUsd(1.24)).toBe('$1.24');
53
+ });
54
+ });
55
+
56
+ describe('formatTokens', () => {
57
+ it('formats token counts with comma separators', () => {
58
+ expect(formatTokens(45000)).toBe('45,000');
59
+ });
60
+
61
+ it('formats small counts without separator', () => {
62
+ expect(formatTokens(999)).toBe('999');
63
+ });
64
+ });
@@ -139,7 +139,7 @@ describe('createSessionCommandModule', () => {
139
139
  expect(entry).toEqual(
140
140
  expect.objectContaining({
141
141
  name: 'cost',
142
- description: 'Show session info',
142
+ description: expect.stringContaining('token usage'),
143
143
  source: 'session',
144
144
  modelInvocable: false,
145
145
  }),
@@ -288,18 +288,62 @@ describe('createSessionCommandModule', () => {
288
288
  });
289
289
  });
290
290
 
291
- it('shows session info through the session command API', async () => {
291
+ it('shows session info through the session command API (no token data yet)', async () => {
292
292
  const executor = new SystemCommandExecutor([
293
293
  ...(createSessionCommandModule().systemCommands ?? []),
294
294
  ]);
295
295
 
296
296
  const result = await executor.execute('cost', createCommandContext(), '');
297
297
 
298
- expect(result).toEqual({
299
- success: true,
300
- message: 'Session: session_1\nMessages: 5',
301
- data: { sessionId: 'session_1', messageCount: 5 },
302
- });
298
+ expect(result?.success).toBe(true);
299
+ expect(result?.message).toContain('session_1');
300
+ expect(result?.message).toContain('Messages: 5');
301
+ expect(result?.message).toContain('not yet available');
302
+ });
303
+
304
+ it('shows token counts and estimated cost when session has usage data', async () => {
305
+ const runtime = {
306
+ ...createRuntime(),
307
+ getSessionTokenUsage: () => ({ inputTokens: 45_000, outputTokens: 12_000 }),
308
+ getModelId: () => 'claude-sonnet-4-5',
309
+ };
310
+ const context = { ...createCommandContext(), getSession: () => runtime };
311
+ const executor = new SystemCommandExecutor([
312
+ ...(createSessionCommandModule().systemCommands ?? []),
313
+ ]);
314
+
315
+ const result = await executor.execute('cost', context, '');
316
+
317
+ expect(result?.success).toBe(true);
318
+ expect(result?.message).toContain('45,000');
319
+ expect(result?.message).toContain('12,000');
320
+ expect(result?.message).toContain('$');
321
+ expect((result?.data as Record<string, unknown>)?.inputTokens).toBe(45_000);
322
+ expect((result?.data as Record<string, unknown>)?.outputTokens).toBe(12_000);
323
+ expect((result?.data as Record<string, unknown>)?.estimatedCostUsd).toBeDefined();
324
+ });
325
+
326
+ it('sets a monthly budget via /cost budget subcommand', async () => {
327
+ const executor = new SystemCommandExecutor([
328
+ ...(createSessionCommandModule().systemCommands ?? []),
329
+ ]);
330
+ const context = { ...createCommandContext(), getCwd: () => '/tmp/robota-test-budget' };
331
+
332
+ const result = await executor.execute('cost', context, 'budget 5.00');
333
+
334
+ expect(result?.success).toBe(true);
335
+ expect(result?.message).toContain('$5.00');
336
+ });
337
+
338
+ it('rejects invalid budget amount', async () => {
339
+ const executor = new SystemCommandExecutor([
340
+ ...(createSessionCommandModule().systemCommands ?? []),
341
+ ]);
342
+
343
+ const result = await executor.execute('cost', createCommandContext(), 'budget notanumber');
344
+
345
+ expect(result?.success).toBe(false);
346
+ expect(result?.message).toContain('Usage:');
303
347
  });
304
348
 
305
349
  it('validates the current session replay log through the SDK common API', async () => {
@@ -0,0 +1,79 @@
1
+ /** USD prices per 1,000,000 tokens (as of May 2026 — update when providers change rates). */
2
+ interface IModelPrice {
3
+ inputPerMillion: number;
4
+ outputPerMillion: number;
5
+ }
6
+
7
+ const MODEL_PRICES: Record<string, IModelPrice> = {
8
+ // Anthropic Claude 4
9
+ 'claude-opus-4-7': { inputPerMillion: 15, outputPerMillion: 75 },
10
+ 'claude-opus-4-5': { inputPerMillion: 15, outputPerMillion: 75 },
11
+ 'claude-sonnet-4-6': { inputPerMillion: 3, outputPerMillion: 15 },
12
+ 'claude-sonnet-4-5': { inputPerMillion: 3, outputPerMillion: 15 },
13
+ 'claude-haiku-4-5': { inputPerMillion: 0.8, outputPerMillion: 4 },
14
+ // Anthropic Claude 3
15
+ 'claude-3-5-sonnet-20241022': { inputPerMillion: 3, outputPerMillion: 15 },
16
+ 'claude-3-5-haiku-20241022': { inputPerMillion: 0.8, outputPerMillion: 4 },
17
+ 'claude-3-opus-20240229': { inputPerMillion: 15, outputPerMillion: 75 },
18
+ // OpenAI
19
+ 'gpt-4o': { inputPerMillion: 2.5, outputPerMillion: 10 },
20
+ 'gpt-4o-mini': { inputPerMillion: 0.15, outputPerMillion: 0.6 },
21
+ o1: { inputPerMillion: 15, outputPerMillion: 60 },
22
+ 'o1-mini': { inputPerMillion: 3, outputPerMillion: 12 },
23
+ o3: { inputPerMillion: 10, outputPerMillion: 40 },
24
+ 'o3-mini': { inputPerMillion: 1.1, outputPerMillion: 4.4 },
25
+ // DeepSeek
26
+ 'deepseek-chat': { inputPerMillion: 0.14, outputPerMillion: 0.28 },
27
+ 'deepseek-reasoner': { inputPerMillion: 0.55, outputPerMillion: 2.19 },
28
+ // Google Gemini
29
+ 'gemini-2.0-flash': { inputPerMillion: 0.1, outputPerMillion: 0.4 },
30
+ 'gemini-2.0-flash-thinking': { inputPerMillion: 0.35, outputPerMillion: 3.5 },
31
+ 'gemini-1.5-pro': { inputPerMillion: 1.25, outputPerMillion: 5 },
32
+ 'gemini-1.5-flash': { inputPerMillion: 0.075, outputPerMillion: 0.3 },
33
+ };
34
+
35
+ const PATTERN_PRICES: Array<{ pattern: RegExp; price: IModelPrice }> = [
36
+ { pattern: /claude-opus/i, price: { inputPerMillion: 15, outputPerMillion: 75 } },
37
+ { pattern: /claude-sonnet/i, price: { inputPerMillion: 3, outputPerMillion: 15 } },
38
+ { pattern: /claude-haiku/i, price: { inputPerMillion: 0.8, outputPerMillion: 4 } },
39
+ { pattern: /gpt-4o-mini/i, price: { inputPerMillion: 0.15, outputPerMillion: 0.6 } },
40
+ { pattern: /gpt-4/i, price: { inputPerMillion: 2.5, outputPerMillion: 10 } },
41
+ { pattern: /deepseek/i, price: { inputPerMillion: 0.14, outputPerMillion: 0.28 } },
42
+ { pattern: /gemini-2/i, price: { inputPerMillion: 0.1, outputPerMillion: 0.4 } },
43
+ { pattern: /gemini-1/i, price: { inputPerMillion: 1.25, outputPerMillion: 5 } },
44
+ ];
45
+
46
+ function lookupPrice(modelId: string): IModelPrice | undefined {
47
+ const exact = MODEL_PRICES[modelId];
48
+ if (exact) return exact;
49
+ for (const { pattern, price } of PATTERN_PRICES) {
50
+ if (pattern.test(modelId)) return price;
51
+ }
52
+ return undefined;
53
+ }
54
+
55
+ /** Returns USD cost, or undefined if the model is not in the pricing table. */
56
+ export function calculateCost(
57
+ modelId: string,
58
+ inputTokens: number,
59
+ outputTokens: number,
60
+ ): number | undefined {
61
+ const price = lookupPrice(modelId);
62
+ if (!price) return undefined;
63
+ return (
64
+ (inputTokens / 1_000_000) * price.inputPerMillion +
65
+ (outputTokens / 1_000_000) * price.outputPerMillion
66
+ );
67
+ }
68
+
69
+ /** Format a USD amount for display (e.g. "$0.0043"). */
70
+ export function formatUsd(amount: number): string {
71
+ if (amount < 0.01) return `$${amount.toFixed(4)}`;
72
+ if (amount < 1) return `$${amount.toFixed(3)}`;
73
+ return `$${amount.toFixed(2)}`;
74
+ }
75
+
76
+ /** Format a token count with comma separators (e.g. "45,000"). */
77
+ export function formatTokens(count: number): string {
78
+ return count.toLocaleString('en-US');
79
+ }
@@ -1,3 +1,6 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
1
4
  import {
2
5
  RENAME_COMMAND_USAGE,
3
6
  clearConversationHistory,
@@ -9,6 +12,8 @@ import {
9
12
  validateCommandSessionReplayLog,
10
13
  } from '@robota-sdk/agent-framework';
11
14
 
15
+ import { calculateCost, formatTokens, formatUsd } from './model-pricing.js';
16
+
12
17
  import type { ICommandHostContext, ICommandResult } from '@robota-sdk/agent-framework';
13
18
 
14
19
  export const CLEAR_COMMAND_MESSAGE = 'Conversation cleared.';
@@ -45,16 +50,127 @@ export function executeResumeCommand(_context: ICommandHostContext, _args: strin
45
50
  };
46
51
  }
47
52
 
48
- export function executeCostCommand(context: ICommandHostContext, _args: string): ICommandResult {
53
+ const BUDGET_FILE = '.robota/budget.json';
54
+
55
+ interface IBudgetConfig {
56
+ monthly: number;
57
+ }
58
+
59
+ function readBudget(cwd: string): IBudgetConfig | undefined {
60
+ const file = join(cwd, BUDGET_FILE);
61
+ if (!existsSync(file)) return undefined;
62
+ let raw: string;
63
+ try {
64
+ raw = readFileSync(file, 'utf-8');
65
+ } catch {
66
+ // allow-fallback: budget file read failure disables feature gracefully
67
+ return undefined;
68
+ }
69
+ try {
70
+ return JSON.parse(raw) as IBudgetConfig;
71
+ } catch {
72
+ // allow-fallback: malformed budget JSON treated as no budget set
73
+ return undefined;
74
+ }
75
+ }
76
+
77
+ function writeBudget(cwd: string, config: IBudgetConfig): void {
78
+ const dir = join(cwd, '.robota');
79
+ mkdirSync(dir, { recursive: true });
80
+ writeFileSync(join(cwd, BUDGET_FILE), JSON.stringify(config, null, 2));
81
+ }
82
+
83
+ function clearBudget(cwd: string): void {
84
+ const file = join(cwd, BUDGET_FILE);
85
+ if (existsSync(file)) {
86
+ writeFileSync(file, '{}');
87
+ }
88
+ }
89
+
90
+ function buildCostOutput(context: ICommandHostContext): {
91
+ lines: string[];
92
+ data: Record<string, unknown>;
93
+ } {
94
+ const session = context.getSession();
49
95
  const sessionInfo = readCommandSessionInfo(context);
50
- return {
51
- success: true,
52
- message: `Session: ${sessionInfo.sessionId}\nMessages: ${sessionInfo.messageCount}`,
53
- data: {
54
- sessionId: sessionInfo.sessionId,
55
- messageCount: sessionInfo.messageCount,
56
- },
96
+ const tokenUsage = session.getSessionTokenUsage?.();
97
+ const modelId = session.getModelId?.();
98
+ const lines: string[] = [
99
+ `Session: ${sessionInfo.sessionId}`,
100
+ `Messages: ${sessionInfo.messageCount}`,
101
+ ];
102
+ const data: Record<string, unknown> = {
103
+ sessionId: sessionInfo.sessionId,
104
+ messageCount: sessionInfo.messageCount,
57
105
  };
106
+
107
+ if (tokenUsage) {
108
+ lines.push(
109
+ `Tokens: ${formatTokens(tokenUsage.inputTokens)} input / ${formatTokens(tokenUsage.outputTokens)} output`,
110
+ );
111
+ data.inputTokens = tokenUsage.inputTokens;
112
+ data.outputTokens = tokenUsage.outputTokens;
113
+
114
+ if (modelId) {
115
+ const cost = calculateCost(modelId, tokenUsage.inputTokens, tokenUsage.outputTokens);
116
+ if (cost !== undefined) {
117
+ lines.push(`Cost: ${formatUsd(cost)} (${modelId})`);
118
+ data.estimatedCostUsd = cost;
119
+
120
+ const budget = readBudget(context.getCwd());
121
+ if (budget?.monthly) {
122
+ const remaining = budget.monthly - cost;
123
+ const pct = Math.min(100, Math.round((cost / budget.monthly) * 100));
124
+ lines.push(
125
+ `Budget: ${formatUsd(remaining)} remaining of ${formatUsd(budget.monthly)}/mo (${pct}% used)`,
126
+ );
127
+ data.budgetMonthly = budget.monthly;
128
+ data.budgetRemainingUsd = remaining;
129
+ }
130
+ }
131
+ }
132
+ } else {
133
+ lines.push('Tokens: not yet available (no turns completed)');
134
+ }
135
+
136
+ return { lines, data };
137
+ }
138
+
139
+ export function executeCostCommand(context: ICommandHostContext, args: string): ICommandResult {
140
+ const trimmed = args.trim();
141
+
142
+ if (trimmed.startsWith('budget')) {
143
+ const budgetArg = trimmed.slice('budget'.length).trim();
144
+
145
+ if (budgetArg === 'clear') {
146
+ clearBudget(context.getCwd());
147
+ return { success: true, message: 'Monthly budget cleared.' };
148
+ }
149
+
150
+ if (budgetArg === '') {
151
+ const current = readBudget(context.getCwd());
152
+ if (!current?.monthly) {
153
+ return {
154
+ success: true,
155
+ message: 'No budget set. Use: /cost budget <amount>',
156
+ };
157
+ }
158
+ return { success: true, message: `Monthly budget: ${formatUsd(current.monthly)}` };
159
+ }
160
+
161
+ const amount = parseFloat(budgetArg);
162
+ if (!Number.isFinite(amount) || amount <= 0) {
163
+ return {
164
+ success: false,
165
+ message: 'Usage: /cost budget <amount> (e.g. /cost budget 5.00)',
166
+ };
167
+ }
168
+ writeBudget(context.getCwd(), { monthly: amount });
169
+ return { success: true, message: `Monthly budget set to ${formatUsd(amount)}.` };
170
+ }
171
+
172
+ const { lines, data } = buildCostOutput(context);
173
+ return { success: true, message: lines.join('\n'), data };
58
174
  }
59
175
 
60
176
  export function executeValidateSessionCommand(