@agentguard-run/spend 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/LICENSE +1 -1
  3. package/README.es-419.md +37 -100
  4. package/README.md +58 -121
  5. package/README.pt-BR.md +37 -100
  6. package/dist/bindings/anthropic.d.ts +11 -0
  7. package/dist/bindings/anthropic.d.ts.map +1 -0
  8. package/dist/bindings/anthropic.js +116 -0
  9. package/dist/bindings/anthropic.js.map +1 -0
  10. package/dist/bindings/bedrock.d.ts +11 -0
  11. package/dist/bindings/bedrock.d.ts.map +1 -0
  12. package/dist/bindings/bedrock.js +177 -0
  13. package/dist/bindings/bedrock.js.map +1 -0
  14. package/dist/cli/auth.d.ts +7 -0
  15. package/dist/cli/auth.d.ts.map +1 -0
  16. package/dist/cli/auth.js +189 -0
  17. package/dist/cli/auth.js.map +1 -0
  18. package/dist/cli/coach.d.ts +5 -0
  19. package/dist/cli/coach.d.ts.map +1 -0
  20. package/dist/cli/coach.js +257 -0
  21. package/dist/cli/coach.js.map +1 -0
  22. package/dist/cli/colors.d.ts +8 -3
  23. package/dist/cli/colors.d.ts.map +1 -1
  24. package/dist/cli/colors.js +93 -4
  25. package/dist/cli/colors.js.map +1 -1
  26. package/dist/cli/demo.d.ts.map +1 -1
  27. package/dist/cli/demo.js +23 -2
  28. package/dist/cli/demo.js.map +1 -1
  29. package/dist/cli/main.d.ts +0 -6
  30. package/dist/cli/main.d.ts.map +1 -1
  31. package/dist/cli/main.js +42 -16
  32. package/dist/cli/main.js.map +1 -1
  33. package/dist/cli/models.d.ts +18 -0
  34. package/dist/cli/models.d.ts.map +1 -0
  35. package/dist/cli/models.js +277 -0
  36. package/dist/cli/models.js.map +1 -0
  37. package/dist/cli/tips.d.ts +21 -0
  38. package/dist/cli/tips.d.ts.map +1 -0
  39. package/dist/cli/tips.js +191 -0
  40. package/dist/cli/tips.js.map +1 -0
  41. package/dist/cli/wizard.d.ts +27 -0
  42. package/dist/cli/wizard.d.ts.map +1 -0
  43. package/dist/cli/wizard.js +182 -0
  44. package/dist/cli/wizard.js.map +1 -0
  45. package/dist/coach/anomaly.d.ts +26 -0
  46. package/dist/coach/anomaly.d.ts.map +1 -0
  47. package/dist/coach/anomaly.js +119 -0
  48. package/dist/coach/anomaly.js.map +1 -0
  49. package/dist/coach/conversation.d.ts +69 -0
  50. package/dist/coach/conversation.d.ts.map +1 -0
  51. package/dist/coach/conversation.js +228 -0
  52. package/dist/coach/conversation.js.map +1 -0
  53. package/dist/coach/forecast.d.ts +19 -0
  54. package/dist/coach/forecast.d.ts.map +1 -0
  55. package/dist/coach/forecast.js +57 -0
  56. package/dist/coach/forecast.js.map +1 -0
  57. package/dist/coach/llm-client.d.ts +41 -0
  58. package/dist/coach/llm-client.d.ts.map +1 -0
  59. package/dist/coach/llm-client.js +248 -0
  60. package/dist/coach/llm-client.js.map +1 -0
  61. package/dist/coach/output.d.ts +41 -0
  62. package/dist/coach/output.d.ts.map +1 -0
  63. package/dist/coach/output.js +173 -0
  64. package/dist/coach/output.js.map +1 -0
  65. package/dist/coach/system-prompt.d.ts +20 -0
  66. package/dist/coach/system-prompt.d.ts.map +1 -0
  67. package/dist/coach/system-prompt.js +177 -0
  68. package/dist/coach/system-prompt.js.map +1 -0
  69. package/dist/cost-table.d.ts +11 -36
  70. package/dist/cost-table.d.ts.map +1 -1
  71. package/dist/cost-table.js +114 -45
  72. package/dist/cost-table.js.map +1 -1
  73. package/dist/index.d.ts +9 -3
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +30 -2
  76. package/dist/index.js.map +1 -1
  77. package/dist/openrouter-catalog.d.ts +56 -0
  78. package/dist/openrouter-catalog.d.ts.map +1 -0
  79. package/dist/openrouter-catalog.js +183 -0
  80. package/dist/openrouter-catalog.js.map +1 -0
  81. package/dist/spend-guard.d.ts +38 -55
  82. package/dist/spend-guard.d.ts.map +1 -1
  83. package/dist/spend-guard.js +268 -83
  84. package/dist/spend-guard.js.map +1 -1
  85. package/dist/telemetry.d.ts.map +1 -1
  86. package/dist/telemetry.js +52 -21
  87. package/dist/telemetry.js.map +1 -1
  88. package/dist/templates/index.d.ts +17 -0
  89. package/dist/templates/index.d.ts.map +1 -0
  90. package/dist/templates/index.js +100 -0
  91. package/dist/templates/index.js.map +1 -0
  92. package/dist/types.d.ts +18 -3
  93. package/dist/types.d.ts.map +1 -1
  94. package/package.json +39 -4
  95. package/src/bindings/anthropic.ts +142 -0
  96. package/src/bindings/bedrock.ts +200 -0
  97. package/src/cli/auth.ts +145 -0
  98. package/src/cli/coach.ts +249 -0
  99. package/src/cli/models.ts +236 -0
  100. package/src/cli/tips.ts +161 -0
  101. package/src/cli/wizard.ts +160 -0
  102. package/src/coach/anomaly.ts +98 -0
  103. package/src/coach/conversation.ts +248 -0
  104. package/src/coach/forecast.ts +64 -0
  105. package/src/coach/llm-client.ts +247 -0
  106. package/src/coach/output.ts +172 -0
  107. package/src/coach/system-prompt.ts +181 -0
  108. package/src/openrouter-catalog.ts +180 -0
  109. package/src/templates/agent-support.yaml +30 -0
  110. package/src/templates/chargeback-evidence.yaml +30 -0
  111. package/src/templates/code-scan.yaml +30 -0
  112. package/src/templates/index.ts +109 -0
  113. package/src/templates/payment-approval.yaml +30 -0
  114. package/src/templates/risk-review.yaml +30 -0
  115. package/tests/fixtures/openrouter-catalog.json +1 -0
@@ -0,0 +1,200 @@
1
+ /** Native AWS Bedrock binding for AgentGuard Spend. */
2
+
3
+ import type { CapabilityTier, CallContext, Provider, SpendPolicy, SpendScope } from '../types';
4
+ import {
5
+ AgentGuardBlockedError,
6
+ SpendGuard,
7
+ type SpendGuardConfig,
8
+ extractText,
9
+ usageCountsFromObject,
10
+ wrapUsageStream,
11
+ type UsageCounts,
12
+ } from '../spend-guard';
13
+
14
+ export interface BedrockBindingOptions {
15
+ policy: SpendPolicy;
16
+ scope: SpendScope;
17
+ capabilityClaim?: CapabilityTier;
18
+ config?: Omit<SpendGuardConfig, 'policy'>;
19
+ }
20
+
21
+ type BedrockCommand = {
22
+ input?: Record<string, unknown>;
23
+ constructor?: { name?: string };
24
+ };
25
+
26
+ export function withSpendGuardBedrock(
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
+ client: any,
29
+ opts: BedrockBindingOptions,
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
+ ): any {
32
+ const guard = new SpendGuard({ policy: opts.policy, ...(opts.config ?? {}) });
33
+ const originalSend = client?.send?.bind(client);
34
+ if (!originalSend) throw new Error('withSpendGuardBedrock: expected client.send');
35
+
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ client.send = async (command: BedrockCommand, ...args: any[]) => {
38
+ if (!isInvokeModelCommand(command)) return originalSend(command, ...args);
39
+
40
+ const call = buildBedrockCall(command, guard, opts);
41
+ const { decision } = await guard.decide(call);
42
+ if (decision.action === 'block') throw new AgentGuardBlockedError(decision, opts.scope, opts.config?.locale);
43
+
44
+ if (decision.action === 'downgrade' && decision.modelResolved !== decision.modelRequested && command.input) {
45
+ command.input = { ...command.input, modelId: decision.modelResolved };
46
+ }
47
+
48
+ const response = await originalSend(command, ...args);
49
+ if (isStreamingCommand(command) && response?.body) {
50
+ return {
51
+ ...response,
52
+ body: wrapBedrockStream(response.body, guard, decision.decisionId, call),
53
+ };
54
+ }
55
+
56
+ const usage = extractBedrockResponseUsage(response, call.model);
57
+ if (usage) await guard.settleStreamUsage(decision.decisionId, usage.inputTokens, usage.outputTokens);
58
+ return response;
59
+ };
60
+
61
+ client.__agentguard = guard;
62
+ return client;
63
+ }
64
+
65
+ function isInvokeModelCommand(command: BedrockCommand): boolean {
66
+ const name = command?.constructor?.name;
67
+ return name === 'InvokeModelCommand' || name === 'InvokeModelWithResponseStreamCommand';
68
+ }
69
+
70
+ function isStreamingCommand(command: BedrockCommand): boolean {
71
+ return command?.constructor?.name === 'InvokeModelWithResponseStreamCommand';
72
+ }
73
+
74
+ function buildBedrockCall(command: BedrockCommand, guard: SpendGuard, opts: BedrockBindingOptions): CallContext {
75
+ const input = command.input ?? {};
76
+ const modelId = String(input.modelId ?? 'unknown');
77
+ const body = parseJsonBody(input.body);
78
+ const inputText = extractText(body);
79
+ return {
80
+ provider: 'bedrock' as Provider,
81
+ model: modelId,
82
+ inputTokens: guard.estimateTokens(inputText),
83
+ outputTokens: outputTokensFromBody(body),
84
+ scope: opts.scope,
85
+ capabilityClaim: opts.capabilityClaim,
86
+ };
87
+ }
88
+
89
+ function wrapBedrockStream(
90
+ stream: AsyncIterable<unknown>,
91
+ guard: SpendGuard,
92
+ decisionId: string,
93
+ call: CallContext,
94
+ ): AsyncIterable<unknown> {
95
+ const tracker = new BedrockStreamUsageTracker(guard, call);
96
+ return wrapUsageStream(stream, {
97
+ onChunk: (event) => tracker.observe(event),
98
+ settle: (partial, reason) => {
99
+ const usage = tracker.usage();
100
+ return guard.settleStreamUsage(decisionId, usage.inputTokens, usage.outputTokens, { partial, reason });
101
+ },
102
+ });
103
+ }
104
+
105
+ class BedrockStreamUsageTracker {
106
+ private input: number | null = null;
107
+ private output: number | null = null;
108
+ private content = '';
109
+
110
+ constructor(private guard: SpendGuard, private call: CallContext) {}
111
+
112
+ observe(event: unknown): void {
113
+ const payload = bedrockEventPayload(event);
114
+ if (!payload) return;
115
+ const usage = usageFromBedrockPayload(payload);
116
+ if (usage) {
117
+ this.input = usage.inputTokens;
118
+ this.output = usage.outputTokens;
119
+ }
120
+ this.content += textFromBedrockPayload(payload);
121
+ }
122
+
123
+ usage(): UsageCounts {
124
+ return {
125
+ inputTokens: this.input ?? this.call.inputTokens,
126
+ outputTokens: this.output ?? this.guard.estimateTokens(this.content),
127
+ };
128
+ }
129
+ }
130
+
131
+ function extractBedrockResponseUsage(response: unknown, modelId: string): UsageCounts | null {
132
+ const body = (response as { body?: unknown } | null)?.body;
133
+ const parsed = parseJsonBody(body);
134
+ const usage = usageFromBedrockPayload(parsed);
135
+ if (usage) return usage;
136
+ if (modelId.startsWith('amazon.nova.')) return usageCountsFromObject((parsed as Record<string, unknown> | null)?.usage);
137
+ return null;
138
+ }
139
+
140
+ function usageFromBedrockPayload(payload: unknown): UsageCounts | null {
141
+ if (!payload || typeof payload !== 'object') return null;
142
+ const obj = payload as Record<string, unknown>;
143
+ const direct = usageCountsFromObject(obj.usage);
144
+ if (direct) return direct;
145
+
146
+ const bedrockMetrics = obj['amazon-bedrock-invocationMetrics'];
147
+ if (bedrockMetrics && typeof bedrockMetrics === 'object') {
148
+ const metrics = bedrockMetrics as Record<string, unknown>;
149
+ const input = metrics.inputTokenCount;
150
+ const output = metrics.outputTokenCount;
151
+ if (Number.isSafeInteger(input) && Number.isSafeInteger(output)) {
152
+ return { inputTokens: input as number, outputTokens: output as number };
153
+ }
154
+ }
155
+
156
+ const nested = usageCountsFromObject((obj.message as Record<string, unknown> | undefined)?.usage);
157
+ if (nested) return nested;
158
+ return null;
159
+ }
160
+
161
+ function textFromBedrockPayload(payload: unknown): string {
162
+ if (!payload || typeof payload !== 'object') return '';
163
+ const obj = payload as Record<string, unknown>;
164
+ const delta = obj.delta as Record<string, unknown> | undefined;
165
+ if (typeof delta?.text === 'string') return delta.text;
166
+ const contentBlockDelta = obj.contentBlockDelta as Record<string, unknown> | undefined;
167
+ const nestedDelta = contentBlockDelta?.delta as Record<string, unknown> | undefined;
168
+ if (typeof nestedDelta?.text === 'string') return nestedDelta.text;
169
+ const output = obj.output as Record<string, unknown> | undefined;
170
+ if (output) return extractText(output);
171
+ return '';
172
+ }
173
+
174
+ function outputTokensFromBody(body: unknown): number {
175
+ if (!body || typeof body !== 'object') return 512;
176
+ const obj = body as Record<string, unknown>;
177
+ const value = obj.max_tokens ?? obj.maxTokens ?? obj.max_new_tokens ??
178
+ (obj.inferenceConfig as Record<string, unknown> | undefined)?.maxTokens;
179
+ return Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 512;
180
+ }
181
+
182
+ function parseJsonBody(body: unknown): unknown {
183
+ if (!body) return {};
184
+ try {
185
+ if (typeof body === 'string') return JSON.parse(body);
186
+ if (body instanceof Uint8Array) return JSON.parse(new TextDecoder().decode(body));
187
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(body)) return JSON.parse(body.toString('utf8'));
188
+ if (typeof body === 'object') return body;
189
+ } catch {
190
+ return {};
191
+ }
192
+ return {};
193
+ }
194
+
195
+ function bedrockEventPayload(event: unknown): unknown {
196
+ const obj = event as Record<string, unknown> | null;
197
+ const chunk = obj?.chunk as Record<string, unknown> | undefined;
198
+ const bytes = chunk?.bytes ?? chunk?.payload ?? obj?.payload;
199
+ return parseJsonBody(bytes);
200
+ }
@@ -0,0 +1,145 @@
1
+ import * as crypto from 'crypto';
2
+ import * as fs from 'fs';
3
+ import * as http from 'http';
4
+ import * as https from 'https';
5
+ import * as path from 'path';
6
+ import * as readline from 'readline';
7
+ import { AGENTGUARD_SPEND_VERSION } from '../index';
8
+ import { agentguardHome, banner, dim, green, statusBar, yellow } from './colors';
9
+
10
+ const OPENROUTER_AUTH_URL = 'https://openrouter.ai/api/v1/auth/key';
11
+
12
+ export function openRouterKeyPath(): string {
13
+ return path.join(agentguardHome(), 'openrouter-key');
14
+ }
15
+
16
+ export function keyFingerprint(key: string): string {
17
+ return crypto.createHash('sha256').update(key).digest('hex').slice(0, 16);
18
+ }
19
+
20
+ export async function validateOpenRouterKey(key: string, endpoint = process.env.AGENTGUARD_OPENROUTER_AUTH_URL || OPENROUTER_AUTH_URL): Promise<boolean> {
21
+ if (!key.trim()) return false;
22
+ if (endpoint.startsWith('mock://')) {
23
+ const expected = new URL(endpoint).searchParams.get('key');
24
+ return expected === key.trim();
25
+ }
26
+ try {
27
+ const status = await requestStatus(endpoint, key.trim());
28
+ return status >= 200 && status < 300;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ export function readOpenRouterKey(): string | null {
35
+ if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY;
36
+ try {
37
+ const value = fs.readFileSync(openRouterKeyPath(), 'utf8').trim();
38
+ return value.length > 0 ? value : null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ export function writeOpenRouterKey(key: string): void {
45
+ const filePath = openRouterKeyPath();
46
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
47
+ fs.writeFileSync(filePath, key.trim() + '\n', { mode: 0o600 });
48
+ try { fs.chmodSync(filePath, 0o600); } catch { return; }
49
+ }
50
+
51
+ export async function runAuth(argv: string[]): Promise<number> {
52
+ if (argv.includes('--help') || argv.includes('-h')) {
53
+ console.log('agentguard auth openrouter | status | clear openrouter');
54
+ return 0;
55
+ }
56
+ const sub = argv[0] ?? 'status';
57
+ if (sub === 'status') return authStatus();
58
+ if (sub === 'clear' && argv[1] === 'openrouter') return clearOpenRouter();
59
+ if (sub === 'openrouter') return configureOpenRouter(argv);
60
+ console.error(`agentguard auth: unknown command '${argv.join(' ')}'`);
61
+ return 2;
62
+ }
63
+
64
+ async function configureOpenRouter(argv: string[]): Promise<number> {
65
+ const flagKey = valueAfter(argv, '--key');
66
+ const key = flagKey ?? await promptForKey();
67
+ if (!key) {
68
+ console.error('No key provided.');
69
+ return 2;
70
+ }
71
+ const ok = await validateOpenRouterKey(key);
72
+ if (!ok) {
73
+ console.error('OpenRouter key validation failed.');
74
+ return 1;
75
+ }
76
+ writeOpenRouterKey(key);
77
+ console.log('');
78
+ console.log(' ' + banner(AGENTGUARD_SPEND_VERSION));
79
+ console.log('');
80
+ console.log(` ${green('configured')} openrouter fingerprint ${keyFingerprint(key)}`);
81
+ console.log(` ${dim('saved')} ${openRouterKeyPath()}`);
82
+ console.log('');
83
+ return 0;
84
+ }
85
+
86
+ function authStatus(): number {
87
+ const key = readOpenRouterKey();
88
+ console.log('');
89
+ console.log(' ' + banner(AGENTGUARD_SPEND_VERSION));
90
+ console.log('');
91
+ if (key) {
92
+ console.log(` ${green('openrouter')} configured fingerprint ${keyFingerprint(key)}`);
93
+ } else {
94
+ console.log(` ${yellow('openrouter')} not configured`);
95
+ }
96
+ console.log('');
97
+ console.log(' ' + statusBar());
98
+ console.log('');
99
+ return 0;
100
+ }
101
+
102
+ function clearOpenRouter(): number {
103
+ try { fs.unlinkSync(openRouterKeyPath()); } catch { return 0; }
104
+ console.log('OpenRouter key removed.');
105
+ return 0;
106
+ }
107
+
108
+ async function promptForKey(): Promise<string> {
109
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
110
+ return new Promise((resolve) => {
111
+ rl.question('OpenRouter API key: ', (answer) => {
112
+ rl.close();
113
+ resolve(answer.trim());
114
+ });
115
+ });
116
+ }
117
+
118
+ function requestStatus(endpoint: string, key: string): Promise<number> {
119
+ return new Promise((resolve, reject) => {
120
+ const url = new URL(endpoint);
121
+ const client = url.protocol === 'http:' ? http : https;
122
+ const req = client.request(
123
+ {
124
+ method: 'GET',
125
+ hostname: url.hostname,
126
+ port: url.port,
127
+ path: url.pathname + url.search,
128
+ headers: { authorization: `Bearer ${key}`, accept: 'application/json' },
129
+ timeout: 5000,
130
+ },
131
+ (res) => {
132
+ res.resume();
133
+ res.on('end', () => resolve(res.statusCode ?? 0));
134
+ },
135
+ );
136
+ req.on('error', reject);
137
+ req.on('timeout', () => req.destroy(new Error('OpenRouter auth validation timed out')));
138
+ req.end();
139
+ });
140
+ }
141
+
142
+ function valueAfter(argv: string[], flag: string): string | undefined {
143
+ const index = argv.indexOf(flag);
144
+ return index >= 0 ? argv[index + 1] : undefined;
145
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * `agentguard coach`: local LLM-driven policy setup.
3
+ */
4
+
5
+ import * as readline from 'readline';
6
+ import { AGENTGUARD_SPEND_VERSION } from '../index';
7
+ import { CoachConversation, formatCents } from '../coach/conversation';
8
+ import { forecastMonthEnd } from '../coach/forecast';
9
+ import { createCoachClient, resolveCoachApiKey, type CoachChatMessage, type CoachProvider } from '../coach/llm-client';
10
+ import { readDecisionSpend, reviewAnomalies } from '../coach/anomaly';
11
+ import { MASTER_SYSTEM_PROMPT } from '../coach/system-prompt';
12
+ import { createCoachSessionLogger, writeCoachOutputs } from '../coach/output';
13
+ import { banner, cyanBold, dim, green, yellow } from './colors';
14
+
15
+ interface CoachCliOptions {
16
+ provider: CoachProvider;
17
+ baseUrl?: string;
18
+ model?: string;
19
+ language?: 'ts' | 'py';
20
+ defaults: boolean;
21
+ yes: boolean;
22
+ }
23
+
24
+ const HELP = `agentguard coach: local LLM-driven policy setup
25
+
26
+ usage:
27
+ agentguard coach [--provider openrouter|openai|anthropic|compatible] [--base-url <url>] [--model <model>]
28
+ agentguard coach review [--scope <name>]
29
+ agentguard coach forecast [--scope <name>] [--cap-cents <cents>]
30
+
31
+ examples:
32
+ agentguard auth openrouter
33
+ agentguard coach
34
+ agentguard coach --base-url https://api.deepseek.com/v1 --model deepseek-chat
35
+ agentguard coach review
36
+ `;
37
+
38
+ export async function runCoach(argv: string[]): Promise<number> {
39
+ if (argv.includes('--help') || argv.includes('-h')) {
40
+ console.log(HELP);
41
+ return 0;
42
+ }
43
+ if (argv[0] === 'review') return runCoachReview(argv.slice(1));
44
+ if (argv[0] === 'forecast') return runCoachForecast(argv.slice(1));
45
+
46
+ const options = parseOptions(argv);
47
+ const apiKey = resolveCoachApiKey(options.provider);
48
+ if (options.provider !== 'mock' && !apiKey) {
49
+ console.log('');
50
+ console.log(' ' + banner(AGENTGUARD_SPEND_VERSION));
51
+ console.log('');
52
+ console.log(` ${yellow('agentguard coach needs a local provider key')}`);
53
+ console.log(' Run agentguard auth openrouter, set OPENROUTER_API_KEY, or pass --base-url with AGENTGUARD_COACH_API_KEY.');
54
+ console.log(' Prompts and policy details stay in your terminal and go only to your chosen provider.');
55
+ console.log('');
56
+ return 0;
57
+ }
58
+
59
+ let cancelled = false;
60
+ const logger = createCoachSessionLogger();
61
+ const onSigint = () => {
62
+ cancelled = true;
63
+ logger.append('cancelled', { reason: 'sigint' });
64
+ process.stdout.write('\ncoach cancelled. No policy files were written.\n');
65
+ };
66
+ process.once('SIGINT', onSigint);
67
+
68
+ try {
69
+ const conversation = new CoachConversation(options.language ? { language: options.language } : undefined);
70
+ const history: CoachChatMessage[] = [{ role: 'system', content: MASTER_SYSTEM_PROMPT }];
71
+ const client = createCoachClient({ provider: options.provider, baseUrl: options.baseUrl, model: options.model, apiKey: apiKey ?? undefined });
72
+
73
+ console.log('');
74
+ console.log(' ' + banner(AGENTGUARD_SPEND_VERSION));
75
+ console.log('');
76
+ console.log(cyanBold('AgentGuard Coach'));
77
+ console.log(dim('Local LLM setup. No AgentGuard service receives prompts, completions, keys, or policies.'));
78
+ console.log('');
79
+
80
+ if (options.defaults || !process.stdin.isTTY) {
81
+ fillDefaults(conversation);
82
+ logger.append('answers_defaulted', { answers: conversation.snapshot() });
83
+ } else {
84
+ await runInteractiveQuestions(conversation, logger, history, client, () => cancelled);
85
+ }
86
+
87
+ if (cancelled) return 130;
88
+ if (!conversation.isComplete()) {
89
+ console.log(yellow('coach did not collect enough answers to write policy files.'));
90
+ return 0;
91
+ }
92
+
93
+ const profile = conversation.profile(process.cwd());
94
+ if (options.language) profile.language = options.language;
95
+ logger.append('profile_built', { profile });
96
+
97
+ console.log('');
98
+ await streamCoachSummary(client, history, profileSummaryPrompt(profile), logger, () => cancelled);
99
+ if (cancelled) return 130;
100
+
101
+ const outputs = writeCoachOutputs(profile, { language: profile.language, overwrite: options.yes || options.defaults });
102
+ logger.append('files_written', { policyPath: outputs.policyPath, quickstartPath: outputs.quickstartPath, sessionLogPath: logger.path });
103
+
104
+ console.log('');
105
+ console.log(outputs.savingsTable);
106
+ console.log('');
107
+ console.log(`${green('created')} ${outputs.policyPath}`);
108
+ console.log(`${green('created')} ${outputs.quickstartPath}`);
109
+ console.log(`${green('session')} ${logger.path}`);
110
+ console.log('');
111
+ console.log('Next: agentguard demo --policy ~/.agentguard/policy.yaml');
112
+ console.log('Verify receipts: https://agentguard.run/verify');
113
+ console.log('');
114
+ return 0;
115
+ } finally {
116
+ process.removeListener('SIGINT', onSigint);
117
+ }
118
+ }
119
+
120
+ async function runCoachReview(argv: string[]): Promise<number> {
121
+ const scope = valueAfter(argv, '--scope') ?? 'default';
122
+ const anomalies = reviewAnomalies(readDecisionSpend(scope));
123
+ console.log('');
124
+ console.log(' ' + banner(AGENTGUARD_SPEND_VERSION));
125
+ console.log('');
126
+ if (anomalies.length === 0) {
127
+ console.log('No local spend anomalies found for the last 24 hours.');
128
+ return 0;
129
+ }
130
+ for (const item of anomalies) {
131
+ console.log(`${item.scope}/${item.agentId}: ${formatCents(item.last24hCents)} in last 24h. Baseline ${formatCents(item.baselineCents)}, sigma ${formatCents(item.sigmaCents)}.`);
132
+ console.log(`Suggestion: ${item.suggestion}`);
133
+ }
134
+ return 0;
135
+ }
136
+
137
+ async function runCoachForecast(argv: string[]): Promise<number> {
138
+ const scope = valueAfter(argv, '--scope') ?? 'default';
139
+ const cap = valueAfter(argv, '--cap-cents');
140
+ const forecast = forecastMonthEnd(readDecisionSpend(scope), cap ? Number(cap) : null);
141
+ console.log('');
142
+ console.log(' ' + banner(AGENTGUARD_SPEND_VERSION));
143
+ console.log('');
144
+ console.log(`Days observed: ${forecast.daysObserved}`);
145
+ console.log(`Projected month-end: ${formatCents(forecast.monthEndCents)}`);
146
+ if (forecast.capCents !== null) console.log(`Cap: ${formatCents(forecast.capCents)}`);
147
+ console.log(forecast.message);
148
+ return 0;
149
+ }
150
+
151
+ async function runInteractiveQuestions(
152
+ conversation: CoachConversation,
153
+ logger: ReturnType<typeof createCoachSessionLogger>,
154
+ history: CoachChatMessage[],
155
+ client: ReturnType<typeof createCoachClient>,
156
+ isCancelled: () => boolean,
157
+ ): Promise<void> {
158
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
159
+ const ask = (prompt: string) => new Promise<string>((resolve) => rl.question(`${prompt}\n> `, resolve));
160
+ try {
161
+ while (!conversation.isComplete() && !isCancelled()) {
162
+ const question = conversation.currentQuestion();
163
+ if (!question) break;
164
+ const answer = (await ask(question.prompt)).trim();
165
+ if (answer.toLowerCase() === 'back') {
166
+ conversation.back();
167
+ logger.append('back', { to: conversation.currentQuestion()?.id });
168
+ continue;
169
+ }
170
+ if (!answer) continue;
171
+ conversation.answer(answer);
172
+ logger.append('answer', { question: question.id, answer });
173
+ history.push({ role: 'user', content: `${question.prompt}\n${answer}` });
174
+ await streamCoachSummary(client, history, coachQuestionPrompt(question.id), logger, isCancelled);
175
+ conversation.next();
176
+ }
177
+ } finally {
178
+ rl.close();
179
+ }
180
+ }
181
+
182
+ async function streamCoachSummary(
183
+ client: ReturnType<typeof createCoachClient>,
184
+ history: CoachChatMessage[],
185
+ prompt: string,
186
+ logger: ReturnType<typeof createCoachSessionLogger>,
187
+ isCancelled: () => boolean,
188
+ ): Promise<void> {
189
+ history.push({ role: 'user', content: prompt });
190
+ let content = '';
191
+ process.stdout.write('\n');
192
+ try {
193
+ for await (const token of client.streamChat(history)) {
194
+ if (isCancelled()) break;
195
+ content += token;
196
+ process.stdout.write(token);
197
+ }
198
+ process.stdout.write('\n');
199
+ } catch (err) {
200
+ const message = err instanceof Error ? err.message : String(err);
201
+ content = `Coach provider unavailable: ${message}`;
202
+ console.log(content);
203
+ }
204
+ history.push({ role: 'assistant', content });
205
+ logger.append('assistant', { content });
206
+ }
207
+
208
+ function fillDefaults(conversation: CoachConversation): void {
209
+ const answers = [
210
+ 'A customer support agent for an ecommerce business',
211
+ 'Team of 5, around 2000 support tickets and orders per month',
212
+ 'triage refunds, draft support replies, assemble chargeback evidence',
213
+ '$199 monthly budget with low per-call costs',
214
+ 'Confirmed',
215
+ ];
216
+ for (const answer of answers) {
217
+ if (!conversation.currentQuestion()) break;
218
+ conversation.answer(answer);
219
+ conversation.next();
220
+ }
221
+ }
222
+
223
+ function coachQuestionPrompt(id: string): string {
224
+ return `Acknowledge the ${id} answer in 2 short sentences. Ask only the next unanswered setup question. Do not write files yet.`;
225
+ }
226
+
227
+ function profileSummaryPrompt(profile: ReturnType<CoachConversation['profile']>): string {
228
+ return `Prepare a concise final setup summary for this profile before files are written: ${JSON.stringify(profile)}. Include projected savings math in words. Do not invent model names or pricing.`;
229
+ }
230
+
231
+ function parseOptions(argv: string[]): CoachCliOptions {
232
+ const baseUrl = valueAfter(argv, '--base-url');
233
+ const providerFlag = valueAfter(argv, '--provider') as CoachProvider | undefined;
234
+ const provider = providerFlag ?? (baseUrl ? 'compatible' : 'openrouter');
235
+ const language = valueAfter(argv, '--language') as 'ts' | 'py' | undefined;
236
+ return {
237
+ provider,
238
+ baseUrl,
239
+ model: valueAfter(argv, '--model'),
240
+ language: language === 'py' ? 'py' : language === 'ts' ? 'ts' : undefined,
241
+ defaults: argv.includes('--defaults'),
242
+ yes: argv.includes('--yes') || argv.includes('-y'),
243
+ };
244
+ }
245
+
246
+ function valueAfter(argv: string[], flag: string): string | undefined {
247
+ const index = argv.indexOf(flag);
248
+ return index >= 0 ? argv[index + 1] : undefined;
249
+ }