@cspeach/cli 1.1.33 → 1.1.35

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 (119) hide show
  1. package/dist/agent/anthropic-provider.js +2 -0
  2. package/dist/agent/azure-profile.js +7 -0
  3. package/dist/agent/connection-profile.js +49 -0
  4. package/dist/agent/loop.js +182 -39
  5. package/dist/agent/provider-shape.js +36 -0
  6. package/dist/agent/providers/ai-hub-provider.js +6 -5
  7. package/dist/agent/providers/azure-provider.js +125 -0
  8. package/dist/agent/providers/byok-provider.js +8 -7
  9. package/dist/agent/providers/factory.js +8 -3
  10. package/dist/agent/providers/local-provider.js +18 -22
  11. package/dist/agent/risk-gate.js +202 -0
  12. package/dist/agent/summarise-via-provider.js +55 -8
  13. package/dist/agent/tool-dispatch.js +19 -0
  14. package/dist/agent/turn-error-ux.js +22 -0
  15. package/dist/auth/api-key.js +10 -5
  16. package/dist/auth/auth-file.js +35 -6
  17. package/dist/auth/azure/azure-key.js +21 -0
  18. package/dist/auth/azure/azure-log.js +45 -0
  19. package/dist/auth/azure/cache-store.js +171 -0
  20. package/dist/auth/azure/clear.js +28 -0
  21. package/dist/auth/azure/loopback.js +192 -0
  22. package/dist/auth/azure/msal-client.js +41 -0
  23. package/dist/auth/azure/sign-in.js +219 -0
  24. package/dist/auth/azure/startup-sign-in.js +64 -0
  25. package/dist/auth/azure/token-provider.js +116 -0
  26. package/dist/auth/key-resolver.js +258 -0
  27. package/dist/auth/me.js +11 -5
  28. package/dist/cli.js +21 -2
  29. package/dist/commands/compact.js +12 -1
  30. package/dist/commands/config-set.js +74 -3
  31. package/dist/commands/config-show.js +25 -0
  32. package/dist/commands/cost.js +19 -6
  33. package/dist/commands/help.js +2 -2
  34. package/dist/commands/login-azure.js +33 -0
  35. package/dist/commands/login.js +56 -8
  36. package/dist/commands/logout.js +34 -10
  37. package/dist/commands/plan-boundary.js +4 -4
  38. package/dist/commands/plan-model-tier.js +8 -3
  39. package/dist/commands/usage.js +6 -0
  40. package/dist/commands/whoami.js +113 -9
  41. package/dist/config/loader.js +5 -0
  42. package/dist/connection/azure-auth.js +135 -0
  43. package/dist/connection/azure-errors.js +103 -0
  44. package/dist/connection/azure-facts.js +21 -0
  45. package/dist/connection/azure-gate.js +40 -0
  46. package/dist/connection/azure-last-call.js +5 -0
  47. package/dist/connection/azure-turn-error.js +25 -0
  48. package/dist/connection/cache.js +70 -0
  49. package/dist/connection/fetch.js +33 -0
  50. package/dist/connection/guard.js +94 -0
  51. package/dist/connection/limit-gate.js +26 -0
  52. package/dist/connection/local-usage.js +71 -0
  53. package/dist/connection/login-step.js +62 -0
  54. package/dist/connection/startup.js +67 -0
  55. package/dist/connection/state.js +68 -0
  56. package/dist/connection/types.js +1 -0
  57. package/dist/cost/cost-log.js +3 -1
  58. package/dist/cost/credits-wire.js +1 -1
  59. package/dist/cost/credits.js +4 -0
  60. package/dist/cost/display-mode.js +7 -0
  61. package/dist/cost/router-spend.js +3 -0
  62. package/dist/cost/session-cost.js +4 -0
  63. package/dist/doctor/checks/azure.js +100 -0
  64. package/dist/doctor/checks/credits.js +4 -3
  65. package/dist/doctor/checks/usage-reporting.js +22 -0
  66. package/dist/doctor/run.js +7 -0
  67. package/dist/lib/graceful-exit.js +17 -0
  68. package/dist/licence/licence-check.js +205 -0
  69. package/dist/licence/paths.js +6 -0
  70. package/dist/licence/turn-gate.js +66 -0
  71. package/dist/models/model-identity.js +65 -0
  72. package/dist/models/resolve.js +8 -0
  73. package/dist/models/server-config.js +3 -2
  74. package/dist/one-shot.js +86 -2
  75. package/dist/projects/answer-blockers.js +25 -12
  76. package/dist/renderer/change-card.js +28 -4
  77. package/dist/renderer/footer-line.js +7 -1
  78. package/dist/renderer/notices.js +27 -13
  79. package/dist/renderer/progress-chatter.js +3 -1
  80. package/dist/renderer/status-footer.js +13 -6
  81. package/dist/renderer/tool-labels.js +8 -2
  82. package/dist/renderer/verify-chain.js +41 -18
  83. package/dist/renderer/wrap-rows.js +87 -0
  84. package/dist/repl/ask-or-no.js +18 -0
  85. package/dist/repl/builtin-commands.js +10 -2
  86. package/dist/repl/post-turn-status.js +3 -2
  87. package/dist/repl/safety-confirm.js +47 -2
  88. package/dist/repl/session-spend-line.js +4 -1
  89. package/dist/repl/startup-account.js +51 -0
  90. package/dist/repl.js +217 -48
  91. package/dist/router/byok-classifier.js +41 -14
  92. package/dist/router/classifier.js +5 -16
  93. package/dist/router/router-target.js +41 -0
  94. package/dist/sap/connection-manager.js +15 -0
  95. package/dist/sap/onboarding.js +70 -64
  96. package/dist/sap/session-end.js +66 -0
  97. package/dist/session/audit-export.js +11 -4
  98. package/dist/skills/manifest-client.js +37 -4
  99. package/dist/skills/skills-cache.js +115 -0
  100. package/dist/skills/source-authed.js +276 -0
  101. package/dist/tools/ask-question.js +1 -1
  102. package/dist/tools/filesystem/glob.js +12 -3
  103. package/dist/tools/filesystem/grep.js +14 -3
  104. package/dist/tools/sap-read.js +22 -22
  105. package/dist/tools/sap-write.js +249 -18
  106. package/dist/tools/snapshot.js +26 -0
  107. package/dist/tools/tool-loading.js +9 -10
  108. package/dist/ui/app.js +19 -9
  109. package/dist/ui/approval-modal.js +37 -5
  110. package/dist/ui/live-area.js +14 -1
  111. package/dist/ui/widgets/ask-card.js +183 -107
  112. package/dist/ui/widgets/coaching-picker.js +22 -1
  113. package/dist/ui/widgets/confirm-card.js +102 -76
  114. package/dist/usage/usage-queue.js +323 -0
  115. package/dist/usage/usage-reporter.js +270 -0
  116. package/package.json +4 -3
  117. package/dist/skills/bundled-skills.js +0 -286
  118. package/dist/skills/source-bundled.js +0 -20
  119. package/dist/skills/source-manifest.js +0 -36
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Security batch item 3 — usage counts from byok / ai-hub / local model
3
+ * calls (managed calls are counted by the proxy). Counts only: seat (the
4
+ * bearer), skill, model, four token counters, turn time, CLI version, session
5
+ * id, request id. Never prompts, responses, source, object names, SAP system
6
+ * ids or file paths. Queued, sent in background batches; never blocks a turn.
7
+ */
8
+ import { randomUUID } from 'node:crypto';
9
+ import { loadConfig } from '../config/loader.js';
10
+ import { CLI_VERSION } from '../lib/version.js';
11
+ import { enqueueUsage, leaseBatch, ackBatch, deleteUsageQueue, keyPrefixFor } from './usage-queue.js';
12
+ import { countedTokens, addLocalUsage } from '../connection/local-usage.js';
13
+ /**
14
+ * The dated default (spec item 3, "Rollout"): with usage_reporting unset,
15
+ * reporting is OFF before this instant and ON from it — the 2 weeks' notice.
16
+ * Task 13 sets it to the publish day + 14 days.
17
+ */
18
+ export const USAGE_REPORTING_DEFAULT_ON_FROM = '2026-10-26T00:00:00.000Z';
19
+ export const BATCH_SIZE = 100;
20
+ const FLUSH_DELAY_MS = 1500;
21
+ const SEND_TIMEOUT_MS = 5000;
22
+ const MAX_ROUNDS = 10;
23
+ /** Statuses that mean "this batch will never be accepted": drop it, never retry (F3). */
24
+ const DROP_STATUSES = new Set([400, 413, 422]);
25
+ const REQUEST_ID_RE = /^[A-Za-z0-9_-]{8,128}$/;
26
+ /** The proxy's per-field limits (cspeach-proxy/src/routes/usage.ts): a label it would reject is made safe here, never dropped. */
27
+ const MAX_TOKENS = 50_000_000;
28
+ const VERSION_RE = /^(\d{1,4})\.(\d{1,5})\.(\d{1,6})(?:[-+].*)?$/;
29
+ function label(v, bad, max, fallback) {
30
+ const s = (typeof v === 'string' ? v : '').replace(bad, '-').slice(0, max);
31
+ return s.length > 0 ? s : fallback;
32
+ }
33
+ const count = (v) => typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.min(Math.floor(v), MAX_TOKENS) : 0;
34
+ /** F6 — x.y.z the proxy accepts: prerelease / build metadata stripped; anything else '0.0.0'. */
35
+ export function normaliseCliVersion(v) {
36
+ const m = VERSION_RE.exec(String(v ?? '').trim());
37
+ return m ? `${m[1]}.${m[2]}.${m[3]}` : '0.0.0';
38
+ }
39
+ /**
40
+ * F7 — a local model may be configured as a file path (a gguf on disk). Only
41
+ * its file name, without the extension, is reported; a plain model name
42
+ * (`llama3.1:70b`) has no path and is left as it is.
43
+ */
44
+ function localModelName(m) {
45
+ if (!/[\\/]/.test(m) && !/^[A-Za-z]:/.test(m))
46
+ return m;
47
+ const base = m.split(/[\\/]/).pop() ?? '';
48
+ // Only real model-file extensions (final review, Task 9 I2): a version dot
49
+ // in a name such as qwen2.5 is part of the name.
50
+ return base.replace(/^[A-Za-z]:/, '').replace(/\.(?:gguf|bin|safetensors|pt|pth|onnx)$/i, '');
51
+ }
52
+ export function usageReportingState(cfg, now = new Date(), onFrom = USAGE_REPORTING_DEFAULT_ON_FROM, companyRequired = false) {
53
+ // Azure tenant mode, Task 8b (plan D11): the company's settings say usage
54
+ // counts are required — on, whatever the dated default or a stored "off".
55
+ if (companyRequired)
56
+ return { on: true, why: 'company-required' };
57
+ if (cfg.usage_reporting === 'on')
58
+ return { on: true, why: 'set-on' };
59
+ if (cfg.usage_reporting === 'off')
60
+ return { on: false, why: 'set-off' };
61
+ return now.getTime() >= Date.parse(onFrom) ? { on: true, why: 'default-on' } : { on: false, why: 'default-off-until', onFrom };
62
+ }
63
+ /**
64
+ * The one record shape POST /v1/usage takes — built field by field, so
65
+ * nothing else a caller passes can ride along. Null for managed calls (the
66
+ * proxy counts those) and for a call that never started. A started call
67
+ * with zero tokens (a local server that reports no usage) is still a turn
68
+ * and is recorded with zeros (F2).
69
+ */
70
+ export function usageRecordFor(c) {
71
+ if (c.mode !== 'byok' && c.mode !== 'ai-hub' && c.mode !== 'local' && c.mode !== 'azure')
72
+ return null;
73
+ if (c.started === false)
74
+ return null;
75
+ const model = c.mode === 'local' && typeof c.model === 'string' ? localModelName(c.model) : c.model;
76
+ return {
77
+ request_id: typeof c.requestId === 'string' && REQUEST_ID_RE.test(c.requestId) ? c.requestId : randomUUID(),
78
+ mode: c.mode,
79
+ skill: label(c.skill, /[^A-Za-z0-9_-]/g, 64, 'none'),
80
+ model: label(model, /[^A-Za-z0-9._:/@-]/g, 100, 'unknown'),
81
+ input_tokens: count(c.input),
82
+ output_tokens: count(c.output),
83
+ cache_read_tokens: count(c.cacheRead),
84
+ cache_write_tokens: count(c.cacheWrite),
85
+ cli_version: normaliseCliVersion(CLI_VERSION),
86
+ session_id: label(c.sessionId, /[^A-Za-z0-9_-]/g, 64, 'none'),
87
+ at: (c.at ?? new Date()).toISOString(),
88
+ };
89
+ }
90
+ /**
91
+ * Azure tenant mode, Task 8b — why the company keeps usage counts on for this
92
+ * seat, or null when the developer's switch applies:
93
+ * - 'required': the company's settings require usage counts;
94
+ * - 'limit': this seat has a monthly limit (fix round 1, ruling 1), which
95
+ * needs the counts even when the company made them optional.
96
+ * Only azure mode reads it; every other mode answers null with no I/O.
97
+ * Settings the company removed no longer keep anything on.
98
+ */
99
+ export async function companyUsageRule(cfg) {
100
+ if (cfg.llm?.mode !== 'azure')
101
+ return null;
102
+ const { readConnectionCache } = await import('../connection/cache.js');
103
+ const c = await readConnectionCache().catch(() => null);
104
+ if (!c || c.removed)
105
+ return null;
106
+ if (c.connection.usage_counts === 'required')
107
+ return 'required';
108
+ return c.connection.limit ? 'limit' : null;
109
+ }
110
+ /** True when the company keeps usage counts on for this seat (required, or a monthly limit). */
111
+ export async function companyRequiresUsageCounts(cfg) {
112
+ return (await companyUsageRule(cfg)) !== null;
113
+ }
114
+ /** Only azure asks the company; every other mode keeps today's switch exactly. */
115
+ async function requiredFor(cfg, deps) {
116
+ if (cfg.llm?.mode !== 'azure')
117
+ return false;
118
+ return (deps.companyRequired ?? companyRequiresUsageCounts)(cfg).catch(() => false);
119
+ }
120
+ async function defaultGetKey() {
121
+ const { getStore } = await import('../auth/api-key.js');
122
+ return (await getStore()).get();
123
+ }
124
+ let flushTimer = null;
125
+ function scheduleDefault(run) {
126
+ if (flushTimer)
127
+ return;
128
+ flushTimer = setTimeout(() => { flushTimer = null; run(); }, FLUSH_DELAY_MS);
129
+ flushTimer.unref?.();
130
+ }
131
+ /** After each non-managed model call. Never throws; never awaits the network. */
132
+ export async function reportCallUsage(c, deps = {}) {
133
+ try {
134
+ const rec = usageRecordFor(c);
135
+ if (!rec)
136
+ return;
137
+ // Task 8b — the monthly limit counts every azure call on this laptop,
138
+ // whether or not the counts are reported (honest-developer control).
139
+ if (rec.mode === 'azure')
140
+ await (deps.addLocal ?? ((n) => addLocalUsage(n)))(countedTokens(rec)).catch(() => { });
141
+ const cfg = await (deps.loadConfig ?? loadConfig)();
142
+ if (!usageReportingState(cfg, (deps.now ?? (() => new Date()))(), undefined, await requiredFor(cfg, deps)).on) {
143
+ // Off: no queue file is written, and an existing one is deleted.
144
+ await deleteUsageQueue();
145
+ return;
146
+ }
147
+ // F10 — a record belongs to the seat that made the call; not signed in → no record.
148
+ const key = await (deps.getKey ?? defaultGetKey)();
149
+ if (!key)
150
+ return;
151
+ if (!(await enqueueUsage(rec, keyPrefixFor(key))))
152
+ return;
153
+ (deps.scheduleFlush ?? scheduleDefault)(() => {
154
+ try {
155
+ void flushUsageQueue(cfg.proxy_url, deps).catch(() => { });
156
+ }
157
+ catch { /* best effort */ }
158
+ });
159
+ }
160
+ catch { /* usage counts are best effort */ }
161
+ }
162
+ /**
163
+ * Send this seat's queued records in batches of at most 100 to the CSPeach
164
+ * proxy, with the seat's key. Re-checks first (F10): a managed seat, a seat
165
+ * with reporting off, or no key sends nothing. Each batch is leased (F1): it
166
+ * stays on disk until the proxy answered, then exactly those request ids are
167
+ * removed. 2xx → removed; 400 / 413 / 422 → removed (never accepted, F3);
168
+ * anything else or no answer → kept for later. Never logs the key.
169
+ */
170
+ export async function flushUsageQueue(proxyUrl, deps = {}) {
171
+ const none = { sent: 0, kept: 0 };
172
+ let cfg;
173
+ try {
174
+ cfg = await (deps.loadConfig ?? loadConfig)();
175
+ }
176
+ catch {
177
+ return none;
178
+ }
179
+ if ((cfg.llm?.mode ?? 'managed') === 'managed')
180
+ return none;
181
+ if (!usageReportingState(cfg, (deps.now ?? (() => new Date()))(), undefined, await requiredFor(cfg, deps)).on)
182
+ return none;
183
+ let key;
184
+ try {
185
+ key = await (deps.getKey ?? defaultGetKey)();
186
+ }
187
+ catch {
188
+ key = null;
189
+ }
190
+ if (!key)
191
+ return none;
192
+ const seat = keyPrefixFor(key);
193
+ const ack = deps.ack ?? ackBatch;
194
+ let sent = 0;
195
+ for (let round = 0; round < MAX_ROUNDS; round++) {
196
+ const batch = await leaseBatch(BATCH_SIZE, seat);
197
+ if (batch.length === 0)
198
+ break;
199
+ const ids = batch.map((r) => r.request_id);
200
+ let r;
201
+ try {
202
+ r = await (deps.fetchImpl ?? fetch)(`${proxyUrl.replace(/\/$/, '')}/v1/usage`, {
203
+ method: 'POST',
204
+ headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json', 'User-Agent': `cspeach/${CLI_VERSION}` },
205
+ body: JSON.stringify({ records: batch }),
206
+ signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
207
+ });
208
+ }
209
+ catch {
210
+ return { sent, kept: batch.length }; // offline: the lease simply lapses; nothing was removed
211
+ }
212
+ // F8 — always read the body (frees the socket); the rejected count is a
213
+ // debug line only, and never carries the key.
214
+ const text = await r.text().catch(() => '');
215
+ if (r.ok) {
216
+ sent += batch.length;
217
+ if (process.env.CSPEACH_DEBUG === '1') {
218
+ let rejected = 0;
219
+ try {
220
+ rejected = Number(JSON.parse(text).rejected) || 0;
221
+ }
222
+ catch { /* not JSON */ }
223
+ if (rejected > 0)
224
+ process.stderr.write(`[usage] the proxy rejected ${rejected} usage record(s)\n`);
225
+ }
226
+ // A failed removal keeps the batch for a resend (the proxy dedupes);
227
+ // stop here rather than lease the same batch again this flush.
228
+ if (!(await ack(ids)))
229
+ return { sent, kept: 0 };
230
+ continue;
231
+ }
232
+ if (DROP_STATUSES.has(r.status)) {
233
+ if (!(await ack(ids)))
234
+ return { sent, kept: 0 };
235
+ continue;
236
+ }
237
+ return { sent, kept: batch.length }; // 401 / 403 / 404 / 429 / 5xx: keep for later
238
+ }
239
+ return { sent, kept: 0 };
240
+ }
241
+ /**
242
+ * Controller ruling (Task 9 fix round 1) — at REPL / one-shot start, a
243
+ * non-managed seat with reporting on sends what is waiting. Fire-and-forget:
244
+ * returns at once (true when a flush was started), never throws, adds no
245
+ * start-up latency. Managed: returns false with no I/O at all.
246
+ */
247
+ export function startUsageFlushAtStartup(cfg, deps = {}) {
248
+ try {
249
+ if ((cfg.llm?.mode ?? 'managed') === 'managed')
250
+ return false;
251
+ const flush = deps.flush ?? ((url) => flushUsageQueue(url));
252
+ if (cfg.llm?.mode === 'azure') {
253
+ // Task 8b — whether the company requires counts is read from disk, so
254
+ // the decision is async: fire-and-forget like the flush itself.
255
+ void (async () => {
256
+ if (!usageReportingState(cfg, (deps.now ?? (() => new Date()))(), undefined, await requiredFor(cfg, deps)).on)
257
+ return;
258
+ await flush(cfg.proxy_url);
259
+ })().catch(() => { });
260
+ return true;
261
+ }
262
+ if (!usageReportingState(cfg, (deps.now ?? (() => new Date()))()).on)
263
+ return false;
264
+ void Promise.resolve(flush(cfg.proxy_url)).catch(() => { });
265
+ return true;
266
+ }
267
+ catch {
268
+ return false;
269
+ }
270
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cspeach/cli",
3
- "version": "1.1.33",
4
- "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 34 skills, safety gates.",
3
+ "version": "1.1.35",
4
+ "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, signed skills, safety gates.",
5
5
  "keywords": [
6
6
  "abap",
7
7
  "sap",
@@ -52,8 +52,9 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@anthropic-ai/sdk": "^0.90.0",
55
+ "@azure/msal-node": "6.0.1",
55
56
  "@cspeach/register-core": "0.4.0",
56
- "@cspeach/sap-client": "0.1.18",
57
+ "@cspeach/sap-client": "0.1.20",
57
58
  "@iarna/toml": "^2.2.5",
58
59
  "@inkjs/ui": "^2.0.0",
59
60
  "@inquirer/prompts": "^7.0.0",