@cspeach/cli 1.1.2 → 1.1.4

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 (45) hide show
  1. package/dist/agent/loop.js +31 -4
  2. package/dist/agent/session-context.js +24 -0
  3. package/dist/agent/tool-dispatch.js +17 -1
  4. package/dist/cli-args.js +24 -0
  5. package/dist/cli.js +33 -4
  6. package/dist/commands/config-set.js +155 -4
  7. package/dist/commands/cost.js +56 -4
  8. package/dist/commands/export-audit.js +4 -1
  9. package/dist/commands/plan-chain.js +8 -1
  10. package/dist/config/loader.js +44 -0
  11. package/dist/cost/cost-log.js +6 -1
  12. package/dist/cost/credits-wire.js +101 -0
  13. package/dist/cost/credits.js +199 -0
  14. package/dist/cost/display-mode.js +29 -0
  15. package/dist/cost/pricing.js +4 -0
  16. package/dist/doctor/checks/_standalone-skip.js +37 -0
  17. package/dist/doctor/checks/cert.js +5 -0
  18. package/dist/doctor/checks/credits.js +48 -0
  19. package/dist/doctor/checks/forge-rules.js +6 -0
  20. package/dist/doctor/checks/sap.js +5 -0
  21. package/dist/doctor/checks/standards.js +27 -0
  22. package/dist/doctor/checks/system-roles.js +5 -0
  23. package/dist/doctor/checks/zcspeach.js +5 -0
  24. package/dist/doctor/run.js +4 -0
  25. package/dist/one-shot.js +43 -11
  26. package/dist/renderer/status-footer.js +13 -3
  27. package/dist/repl/post-turn-status.js +5 -0
  28. package/dist/repl/session-spend-line.js +20 -0
  29. package/dist/repl.js +48 -9
  30. package/dist/sap/offline-adt-client.js +58 -0
  31. package/dist/sap/standalone-onboarding.js +118 -0
  32. package/dist/sap/standalone-profile.js +237 -0
  33. package/dist/session/audit-export.js +23 -4
  34. package/dist/standards/standards-file.js +107 -0
  35. package/dist/standards/standards-init.js +194 -0
  36. package/dist/tools/capability/tool.js +2 -0
  37. package/dist/tools/index.js +25 -0
  38. package/dist/tools/sap-read.js +18 -0
  39. package/dist/tools/sap-write.js +14 -0
  40. package/dist/tools/snapshot.js +3 -0
  41. package/dist/tools/transport.js +4 -0
  42. package/dist/ui/header.js +3 -1
  43. package/dist/ui/sap-state-store.js +4 -0
  44. package/dist/ui/status-line.js +20 -1
  45. package/package.json +109 -109
@@ -3,6 +3,7 @@ import { isDeepStrictEqual } from 'node:util';
3
3
  import toml from '@iarna/toml';
4
4
  import { configFile, cspeachRoot } from './paths.js';
5
5
  import { DEFAULT_SESSION_MODEL } from './model-defaults.js';
6
+ import { sanitiseStandaloneProfile } from '../sap/standalone-profile.js';
6
7
  /**
7
8
  * Whitelist guard for WriteMode — rejects typos / stale values from disk so
8
9
  * downstream code that trusts the WriteMode type doesn't see garbage.
@@ -141,6 +142,24 @@ export async function loadConfig() {
141
142
  // resolveRenderSmoke reads as the default ON (fail-safe: a config typo can
142
143
  // never silently disable the smoke).
143
144
  render_smoke: typeof parsed.render_smoke === 'boolean' ? parsed.render_smoke : undefined,
145
+ // no_sap: same absent-preserving coercion as local_build (the key is
146
+ // dropped below when undefined, so it never serialises as
147
+ // `no_sap = undefined`). A present-but-invalid value collapses to
148
+ // `false` — fail-safe: a config typo can never silently disconnect a
149
+ // user from the SAP system they configured.
150
+ no_sap: parsed.no_sap === true
151
+ ? true
152
+ : parsed.no_sap === undefined
153
+ ? undefined
154
+ : false,
155
+ // standalone: whitelist-coerced profile table. An unrecognised platform
156
+ // drops the whole table (undefined → key deleted below).
157
+ standalone: sanitiseStandaloneProfile(parsed.standalone),
158
+ // show_cogs: plain boolean, DEFAULT off (absent). Preserve only a genuine
159
+ // boolean; a typo/wrong-type collapses to undefined, which reads as OFF —
160
+ // fail-safe in the customer's favour: a config typo can never leak our
161
+ // USD COGS onto a managed customer's screen.
162
+ show_cogs: typeof parsed.show_cogs === 'boolean' ? parsed.show_cogs : undefined,
144
163
  write_mode: isValidWriteMode(parsed.write_mode) ? parsed.write_mode : DEFAULT_CONFIG.write_mode,
145
164
  // plan_mode: whitelist coercion — anything but the two valid literals
146
165
  // (typos, wrong types) collapses to undefined, which callers treat as
@@ -210,6 +229,19 @@ export async function loadConfig() {
210
229
  // callers see a genuine "absent" shape (resolveRenderSmoke treats it as ON).
211
230
  if (merged.render_smoke === undefined)
212
231
  delete merged.render_smoke;
232
+ // Same for `no_sap` and `standalone`: absent-or-invalid must not serialise
233
+ // as `undefined` — drop the keys so `config show` stays clean and callers
234
+ // see a genuine "absent" shape (resolveStandalone treats missing as off,
235
+ // and an absent profile triggers the standalone wizard).
236
+ if (merged.no_sap === undefined)
237
+ delete merged.no_sap;
238
+ if (merged.standalone === undefined)
239
+ delete merged.standalone;
240
+ // Same for `show_cogs`: absent-or-invalid must not serialise as
241
+ // `show_cogs = undefined` — drop the key so `config show` stays clean and
242
+ // callers see a genuine "absent" shape (treated as OFF → credits).
243
+ if (merged.show_cogs === undefined)
244
+ delete merged.show_cogs;
213
245
  // Same for `plan_mode`: absent-or-invalid must not serialise as
214
246
  // `plan_mode = undefined` — drop the key so `config show` stays clean and
215
247
  // callers see a genuine "absent" shape (treated as 'step').
@@ -305,6 +337,18 @@ export function resolveLocalBuild(cfg) {
305
337
  export function resolveRenderSmoke(cfg) {
306
338
  return cfg.render_smoke ?? true;
307
339
  }
340
+ /**
341
+ * Resolve the effective standalone ("no SAP system") mode.
342
+ *
343
+ * effective = --no-sap flag OR config no_sap (default false)
344
+ *
345
+ * The flag can only turn standalone ON for a single run — there is no
346
+ * "--sap" counterpart — so the resolution is an OR, not a precedence chain.
347
+ * `no_sap` is already coerced to `true | false | undefined` by loadConfig.
348
+ */
349
+ export function resolveStandalone(cfg, flagNoSap = false) {
350
+ return flagNoSap === true || cfg.no_sap === true;
351
+ }
308
352
  export async function saveConfig(config) {
309
353
  await fs.mkdir(cspeachRoot(), { recursive: true });
310
354
  // The set of top-level keys the FILE carried (empty when it was absent).
@@ -27,7 +27,7 @@ export function costLogPath(sessionId) {
27
27
  * `appendCostLine` or accumulate in memory for the `/cost` command.
28
28
  */
29
29
  export function buildEntry(opts) {
30
- return {
30
+ const entry = {
31
31
  ts: new Date().toISOString(),
32
32
  turn: opts.turn,
33
33
  model: opts.model,
@@ -35,6 +35,11 @@ export function buildEntry(opts) {
35
35
  cost: computeCost(opts.model, opts.tokens),
36
36
  duration_ms: opts.duration_ms,
37
37
  };
38
+ if (opts.credits) {
39
+ entry.credits_charged = opts.credits.charged;
40
+ entry.balance_credits = opts.credits.balance;
41
+ }
42
+ return entry;
38
43
  }
39
44
  /**
40
45
  * Append a single line to the session's cost log. Best-effort:
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The one place the credits meter is driven (2026-09-16).
3
+ *
4
+ * Two touch points, both best-effort and both no-ops outside credits mode:
5
+ *
6
+ * initCredits() — REPL / one-shot startup. ONE balance call,
7
+ * capped at BALANCE_TIMEOUT_MS. Resolves the
8
+ * display mode and seeds the tracker + store.
9
+ * refreshCreditsAfterTurn() — post-turn. One more capped call; the proxy
10
+ * commits a turn's charge BEFORE the SSE stream
11
+ * closes, so this sample already includes the
12
+ * turn that just ended.
13
+ *
14
+ * The post-turn refresh is awaited inside the agent loop's post-turn block
15
+ * rather than fired and forgotten, because two consumers need the fresh
16
+ * number synchronously: the JSONL cost line written moments later, and the
17
+ * post-turn status footer the REPL prints once runTurn returns. Awaiting a
18
+ * 2.5 s-capped call at the end of a multi-second turn is cheap; showing the
19
+ * previous turn's figure would be wrong.
20
+ *
21
+ * The cfg + bearer getter are captured at init so every later call site stays
22
+ * a one-liner and no module has to re-derive the display mode for itself.
23
+ */
24
+ import { fetchBalance, globalCredits, setCostDisplayMode, creditsActive, } from './credits.js';
25
+ import { costDisplayMode } from './display-mode.js';
26
+ import { globalStore } from '../ui/sap-state-store.js';
27
+ let wiredCfg = null;
28
+ let wiredBearer = null;
29
+ /** Mirror the tracker's state onto the Ink store so the chrome re-renders. */
30
+ function publish() {
31
+ const s = globalCredits.snapshot();
32
+ globalStore.update({
33
+ creditsMode: creditsActive(),
34
+ creditsSessionTotal: s.sessionTotal,
35
+ creditsBalance: s.balance,
36
+ creditsUnitLabel: s.unitLabel,
37
+ creditsPending: s.pending,
38
+ });
39
+ }
40
+ /**
41
+ * Startup: resolve the display mode and seed the opening balance.
42
+ * Never throws; on any failure the process stays in USD mode, which is the
43
+ * pre-credits behaviour.
44
+ */
45
+ export async function initCredits(cfg, getBearer) {
46
+ try {
47
+ // Non-managed modes can't be metered by us — skip the round-trip entirely.
48
+ if (cfg.llm?.mode !== 'managed') {
49
+ setCostDisplayMode('usd');
50
+ return;
51
+ }
52
+ const info = await fetchBalance(cfg, getBearer);
53
+ const mode = costDisplayMode(cfg, info);
54
+ setCostDisplayMode(mode);
55
+ if (mode !== 'credits')
56
+ return;
57
+ wiredCfg = cfg;
58
+ wiredBearer = getBearer;
59
+ globalCredits.start(info);
60
+ publish();
61
+ }
62
+ catch {
63
+ // Startup must never be blocked by the meter.
64
+ setCostDisplayMode('usd');
65
+ }
66
+ }
67
+ /**
68
+ * Post-turn: sample the balance again and fold the delta into the session
69
+ * total. Returns null (and makes no request) outside credits mode.
70
+ */
71
+ export async function refreshCreditsAfterTurn() {
72
+ if (!creditsActive() || !wiredCfg || !wiredBearer)
73
+ return null;
74
+ try {
75
+ const info = await fetchBalance(wiredCfg, wiredBearer);
76
+ const r = globalCredits.afterTurn(info);
77
+ publish();
78
+ return r;
79
+ }
80
+ catch {
81
+ // Treat an unexpected throw exactly like a failed fetch: unknown, not zero.
82
+ const r = globalCredits.afterTurn(null);
83
+ publish();
84
+ return r;
85
+ }
86
+ }
87
+ /**
88
+ * The credits payload for a cost-log line, or undefined in USD mode so the
89
+ * emitted JSONL keeps its pre-credits shape byte for byte.
90
+ */
91
+ export function creditsForCostLog() {
92
+ if (!creditsActive())
93
+ return undefined;
94
+ const s = globalCredits.snapshot();
95
+ return { charged: s.chargedThisTurn, balance: s.balance };
96
+ }
97
+ /** Test seam — drop the captured cfg/bearer between cases. */
98
+ export function resetCreditsWire() {
99
+ wiredCfg = null;
100
+ wiredBearer = null;
101
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Customer-facing spend — credits, not dollars (2026-09-16).
3
+ *
4
+ * Every figure in `cost/pricing.ts` is OUR Anthropic COGS. A managed
5
+ * customer is billed in CREDITS at a multiplier we do not disclose, so
6
+ * showing them a `$` number is both wrong (it is not what they pay) and a
7
+ * business leak (it reveals our margin). This module is the customer-facing
8
+ * half of the split:
9
+ *
10
+ * cost/pricing.ts → USD, our cost, telemetry + BYOK/local/ai-hub display
11
+ * cost/credits.ts → credits, what the customer is actually charged
12
+ *
13
+ * The proxy is the only source of truth for credits. It commits a turn's
14
+ * charge BEFORE the SSE stream closes, so a balance fetched after the turn
15
+ * ends already reflects that turn — which is why the per-turn charge here is
16
+ * a BALANCE DELTA, not a locally computed number. We never price a turn in
17
+ * credits ourselves; we only observe what the ledger did.
18
+ *
19
+ * Everything in here is best-effort. A balance fetch that fails, times out,
20
+ * or returns garbage must never crash or block a turn — it degrades to
21
+ * "unknown", which the UI renders as `credits: updating…` (never a dollar
22
+ * fallback; see display-mode.ts).
23
+ */
24
+ /** Hard cap on the balance round-trip — never block a turn on it. */
25
+ export const BALANCE_TIMEOUT_MS = 2_500;
26
+ const DEFAULT_UNIT_LABEL = 'credits';
27
+ function num(v) {
28
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
29
+ }
30
+ /**
31
+ * Fetch the customer's credit balance from the proxy.
32
+ *
33
+ * Uses the same bearer key the CLI already sends on every other proxy call.
34
+ * NEVER throws and NEVER hangs past BALANCE_TIMEOUT_MS — returns null on any
35
+ * failure (transport error, non-2xx, timeout, unparseable body, no key).
36
+ */
37
+ export async function fetchBalance(cfg, getBearer) {
38
+ const controller = new AbortController();
39
+ const timer = setTimeout(() => controller.abort(), BALANCE_TIMEOUT_MS);
40
+ try {
41
+ const key = await getBearer();
42
+ if (!key)
43
+ return null;
44
+ const url = `${cfg.proxy_url.replace(/\/$/, '')}/v1/me/balance`;
45
+ const r = await fetch(url, {
46
+ headers: { Authorization: `Bearer ${key}` },
47
+ signal: controller.signal,
48
+ });
49
+ if (!r.ok)
50
+ return null;
51
+ const body = await r.json();
52
+ if (typeof body !== 'object' || body === null)
53
+ return null;
54
+ const b = body;
55
+ const info = {
56
+ metered: b.metered === true,
57
+ unit_label: typeof b.unit_label === 'string' && b.unit_label ? b.unit_label : DEFAULT_UNIT_LABEL,
58
+ balance_credits: num(b.balance_credits),
59
+ subscription_credits: num(b.subscription_credits),
60
+ purchased_credits: num(b.purchased_credits),
61
+ };
62
+ if (b.unprovisioned === true)
63
+ info.unprovisioned = true;
64
+ return info;
65
+ }
66
+ catch {
67
+ // Timeout, offline, DNS, malformed JSON — all the same to the caller.
68
+ return null;
69
+ }
70
+ finally {
71
+ clearTimeout(timer);
72
+ }
73
+ }
74
+ /**
75
+ * In-memory, session-scoped credit meter.
76
+ *
77
+ * Honest-arithmetic contract:
78
+ * - A turn's charge is `previousBalance − currentBalance`, clamped at ≥ 0
79
+ * (a mid-session top-up must never subtract from the session total).
80
+ * - When a fetch fails we do NOT invent a charge and we do NOT move the
81
+ * baseline. The turn is reported as `null` (unknown) and the spend it
82
+ * incurred is folded into the next SUCCESSFUL fetch's delta — so the
83
+ * session total stays correct even across missed samples.
84
+ * - With no baseline at all (session opened offline) the first successful
85
+ * fetch only establishes the baseline; it cannot be a delta.
86
+ */
87
+ export class CreditsTracker {
88
+ baseline = null;
89
+ state = {
90
+ chargedThisTurn: null,
91
+ sessionTotal: 0,
92
+ balance: null,
93
+ unitLabel: DEFAULT_UNIT_LABEL,
94
+ pending: false,
95
+ };
96
+ /** Seed the session's opening balance. */
97
+ start(balance) {
98
+ if (balance)
99
+ this.state.unitLabel = balance.unit_label;
100
+ const opening = balance ? balance.balance_credits : null;
101
+ this.baseline = opening;
102
+ this.state = {
103
+ ...this.state,
104
+ chargedThisTurn: null,
105
+ sessionTotal: 0,
106
+ balance: opening,
107
+ pending: false,
108
+ };
109
+ }
110
+ /** Record a post-turn balance sample and return this turn's charge. */
111
+ afterTurn(balance) {
112
+ if (balance)
113
+ this.state.unitLabel = balance.unit_label;
114
+ const current = balance ? balance.balance_credits : null;
115
+ if (current === null) {
116
+ // Unknown — keep the baseline so the next good sample covers both turns.
117
+ this.state = { ...this.state, chargedThisTurn: null, pending: true };
118
+ return { chargedThisTurn: null, sessionTotal: this.state.sessionTotal, balance: this.state.balance };
119
+ }
120
+ if (this.baseline === null) {
121
+ // First balance we have ever seen — establish the baseline only.
122
+ this.baseline = current;
123
+ this.state = { ...this.state, chargedThisTurn: null, balance: current, pending: false };
124
+ return { chargedThisTurn: null, sessionTotal: this.state.sessionTotal, balance: current };
125
+ }
126
+ const charged = Math.max(0, this.baseline - current);
127
+ this.baseline = current;
128
+ const sessionTotal = this.state.sessionTotal + charged;
129
+ this.state = { ...this.state, chargedThisTurn: charged, sessionTotal, balance: current, pending: false };
130
+ return { chargedThisTurn: charged, sessionTotal, balance: current };
131
+ }
132
+ snapshot() {
133
+ return { ...this.state };
134
+ }
135
+ }
136
+ /**
137
+ * Format a credit count for display: thousands-separated, unit-labelled.
138
+ * Credits are whole units to the customer, so fractions round.
139
+ */
140
+ export function formatCredits(n, unitLabel = DEFAULT_UNIT_LABEL) {
141
+ return `${Math.round(n).toLocaleString('en-US')} ${unitLabel}`;
142
+ }
143
+ /**
144
+ * Shown instead of a number when the post-turn balance fetch failed.
145
+ * Deliberately NOT a dollar fallback: a managed customer must never see our
146
+ * COGS, and "updating" is the truth — the charge is committed server-side and
147
+ * will surface on the next successful sample.
148
+ */
149
+ export const CREDITS_PENDING = 'credits: updating…';
150
+ /**
151
+ * The customer-facing spend line, e.g.
152
+ * `▲ 52 credits this turn · 519 this session · balance 9,481`
153
+ * Degrades gracefully: an unknown turn charge renders CREDITS_PENDING, and a
154
+ * missing balance simply drops that segment.
155
+ */
156
+ export function formatCreditsLine(s) {
157
+ if (s.chargedThisTurn === null)
158
+ return CREDITS_PENDING;
159
+ const parts = [
160
+ `▲ ${formatCredits(s.chargedThisTurn, s.unitLabel)} this turn`,
161
+ `${Math.round(s.sessionTotal).toLocaleString('en-US')} this session`,
162
+ ];
163
+ if (s.balance !== null) {
164
+ parts.push(`balance ${Math.round(s.balance).toLocaleString('en-US')}`);
165
+ }
166
+ return parts.join(' · ');
167
+ }
168
+ /**
169
+ * Session-scoped singletons.
170
+ *
171
+ * The cost-render sites are scattered across chalk printers, Ink components,
172
+ * slash commands and the markdown audit exporter — threading config + balance
173
+ * through every one of them would be a far larger diff than the behaviour
174
+ * warrants, and would leave each site free to re-derive the mode differently
175
+ * (exactly how a leak happens). Instead the mode is decided ONCE at startup
176
+ * (see costDisplayMode) and published here; every render site asks.
177
+ *
178
+ * Tests inject explicitly via each site's optional parameter, so the singleton
179
+ * is a default, never a hard dependency.
180
+ */
181
+ export const globalCredits = new CreditsTracker();
182
+ let activeMode = 'usd';
183
+ /** Publish the resolved display mode for the whole process. */
184
+ export function setCostDisplayMode(mode) {
185
+ activeMode = mode;
186
+ }
187
+ /** The resolved display mode. Defaults to 'usd' until startup resolves it —
188
+ * fail-safe for BYOK/local/one-shot paths that never call the setter. */
189
+ export function activeCostDisplayMode() {
190
+ return activeMode;
191
+ }
192
+ /** Convenience predicate for render sites. */
193
+ export function creditsActive() {
194
+ return activeMode === 'credits';
195
+ }
196
+ /** Test seam — reset the process-wide mode between cases. */
197
+ export function resetCostDisplayMode() {
198
+ activeMode = 'usd';
199
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The one rule that decides whether a cost figure renders as credits or as
3
+ * dollars (2026-09-16).
4
+ *
5
+ * `'credits'` — a MANAGED customer on a METERED account. They pay us in
6
+ * credits; the USD number we compute locally is our Anthropic COGS and
7
+ * must never reach their screen.
8
+ *
9
+ * `'usd'` — everything else:
10
+ * - byok / ai-hub / local: the customer holds the provider relationship,
11
+ * so the dollar figure IS their bill. Showing it is the whole point.
12
+ * - managed but unmetered (or the balance fetch failed): we have no
13
+ * credit truth to show. Fall back to the existing dollar rendering so
14
+ * the meter is never blank.
15
+ * - `show_cogs = on`: our own operator escape hatch for reading real COGS
16
+ * on a managed account (margin analysis, pricing work). Never a default.
17
+ *
18
+ * Callers must treat this as the single source of truth — no site may
19
+ * re-derive the mode from `cfg.llm.mode` alone.
20
+ */
21
+ export function costDisplayMode(cfg, balanceInfo) {
22
+ if (cfg.llm?.mode !== 'managed')
23
+ return 'usd';
24
+ if (balanceInfo?.metered !== true)
25
+ return 'usd';
26
+ if (cfg.show_cogs === true)
27
+ return 'usd';
28
+ return 'credits';
29
+ }
@@ -24,6 +24,10 @@
24
24
  */
25
25
  import { getServerModelConfig } from '../models/server-config.js';
26
26
  const PRICING = {
27
+ // Claude 5 family (2026-09-15) — Opus 5 keeps the Opus 4.5+ rate ($5/$25);
28
+ // Sonnet 5 is $2/$10. Cache read = 10% of input, cache write = 1.25x input.
29
+ 'claude-opus-5': { inputPer1M: 5.00, outputPer1M: 25.00, cacheReadPer1M: 0.50, cacheCreatePer1M: 6.25 },
30
+ 'claude-sonnet-5': { inputPer1M: 2.00, outputPer1M: 10.00, cacheReadPer1M: 0.20, cacheCreatePer1M: 2.50 },
27
31
  // Claude Opus 4.5+ — input 5, output 25, cache_read 0.50, cache_write 6.25 (1.25x input).
28
32
  // 2026-06-07 correction: 4.5/4.6/4.7 were carried at the LEGACY $15/$75; actual
29
33
  // since Opus 4.5 is $5/$25. Only claude-opus-4 (4.0/4.1) genuinely was $15/$75.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Shared "standalone mode is ON" result for the doctor's SAP checks.
3
+ *
4
+ * Standalone mode (2026-09-16) made "deliberately no SAP system" a SUPPORTED
5
+ * state, so with `no_sap = true` these checks report a neutral yellow `-` line
6
+ * that does not count toward the failed total.
7
+ *
8
+ * NOTHING else changes. With standalone OFF — including the plain "hasn't run
9
+ * `config add-sap` yet" case — every check keeps its original result, message
10
+ * and exit-code contribution, byte for byte.
11
+ */
12
+ export const STANDALONE_SKIP_MESSAGE = 'standalone mode — SAP checks skipped';
13
+ /**
14
+ * True ONLY when the user has opted into standalone mode via `no_sap = true`.
15
+ *
16
+ * Deliberately does NOT treat "no aliases configured" as standalone: for a
17
+ * user who simply hasn't added a system yet, a missing SAP connection is still
18
+ * a finding, and the pre-standalone output must be preserved exactly.
19
+ *
20
+ * Reads `cfg.no_sap` directly rather than calling `resolveStandalone` so this
21
+ * helper stays free of a runtime dependency on config/loader — several doctor
22
+ * tests replace that whole module with a mock. (`doctor` is its own process
23
+ * invocation, so the per-run `--no-sap` flag never reaches it anyway.)
24
+ */
25
+ export function sapChecksSkipped(cfg) {
26
+ return cfg.no_sap === true;
27
+ }
28
+ /** The neutral skip result for a named check. */
29
+ export function standaloneSkip(name) {
30
+ return {
31
+ name,
32
+ ok: false,
33
+ skipped: true,
34
+ message: STANDALONE_SKIP_MESSAGE,
35
+ hint: 'Connect a system any time with: cspeach config add-sap',
36
+ };
37
+ }
@@ -1,7 +1,12 @@
1
1
  import tls from 'node:tls';
2
2
  import { loadConfig, resolveSapTls } from '../../config/loader.js';
3
+ import { sapChecksSkipped, standaloneSkip } from './_standalone-skip.js';
3
4
  export async function checkCert() {
4
5
  const cfg = await loadConfig();
6
+ // Standalone mode ONLY — an opted-in paste-mode user has nothing to probe.
7
+ // Everything below (including the "n/a" no-TLS line) is unchanged.
8
+ if (sapChecksSkipped(cfg))
9
+ return standaloneSkip('cert');
5
10
  const systems = Object.entries(cfg.sap).filter(([_, s]) => s.useSsl);
6
11
  if (systems.length === 0)
7
12
  return { name: 'cert', ok: true, message: 'n/a (no TLS SAP systems configured)' };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `cspeach doctor` — credits line (2026-09-16).
3
+ *
4
+ * Purely informational: whether this account is billed in credits and, if so,
5
+ * what is left. It NEVER fails the run — a customer with no balance reading
6
+ * still has a perfectly working CLI, and an unreachable balance endpoint is a
7
+ * proxy problem the proxy check already reports.
8
+ *
9
+ * Deliberately dollar-free: doctor output is the first thing a customer pastes
10
+ * into a support thread.
11
+ */
12
+ import { fetchBalance, formatCredits } from '../../cost/credits.js';
13
+ import { getStore } from '../../auth/api-key.js';
14
+ function defaultProbe(cfg) {
15
+ return () => fetchBalance(cfg, async () => {
16
+ const store = await getStore();
17
+ return (await store.get()) ?? '';
18
+ });
19
+ }
20
+ export async function checkCredits(cfg, probe = defaultProbe(cfg)) {
21
+ // BYOK / ai-hub / local never touch our ledger — no reason to call the proxy.
22
+ if (cfg.llm?.mode !== 'managed') {
23
+ return { name: 'credits', ok: true, message: 'not metered (BYOK)' };
24
+ }
25
+ const info = await probe().catch(() => null);
26
+ if (!info) {
27
+ return {
28
+ name: 'credits', ok: true, skipped: true, message: 'unavailable',
29
+ hint: 'Could not read your balance — check the proxy check above, then retry.',
30
+ };
31
+ }
32
+ if (!info.metered) {
33
+ return { name: 'credits', ok: true, message: 'not metered (BYOK)' };
34
+ }
35
+ if (info.balance_credits === null) {
36
+ return {
37
+ name: 'credits', ok: true, skipped: true, message: 'unavailable',
38
+ hint: info.unprovisioned
39
+ ? 'Your account is metered but has no credit ledger yet — visit cspeach.dev/portal.'
40
+ : 'The server reported no balance figure.',
41
+ };
42
+ }
43
+ return {
44
+ name: 'credits',
45
+ ok: true,
46
+ message: `balance ${formatCredits(info.balance_credits, info.unit_label)}`,
47
+ };
48
+ }
@@ -18,11 +18,17 @@
18
18
  import { loadConfig } from '../../config/loader.js';
19
19
  import { getConnection } from '../../sap/connection-manager.js';
20
20
  import { AdtClient, snapshots } from '@cspeach/sap-client';
21
+ import { sapChecksSkipped, standaloneSkip } from './_standalone-skip.js';
21
22
  const PROBE_NAME = 'Z_CSPEACH_DOCTOR_PROBE';
22
23
  const PROBE_PACKAGE = '$TMP';
23
24
  const PROBE_SOURCE = `REPORT ${PROBE_NAME}.\nWRITE / 'cspeach doctor probe — safe to delete'.`;
24
25
  export async function checkForgeRules() {
25
26
  const cfg = await loadConfig();
27
+ // Standalone mode ONLY — the Rule 7/10 probe needs a live system, and an
28
+ // opted-in paste-mode user has none. Everything below (including the
29
+ // no-alias skip) is unchanged.
30
+ if (sapChecksSkipped(cfg))
31
+ return standaloneSkip('forge-rules');
26
32
  const aliases = Object.keys(cfg.sap);
27
33
  if (aliases.length === 0) {
28
34
  return {
@@ -1,8 +1,13 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { loadConfig, resolveSapTls } from '../../config/loader.js';
3
3
  import { probeGet } from './_http-probe.js';
4
+ import { sapChecksSkipped, standaloneSkip } from './_standalone-skip.js';
4
5
  export async function checkSap() {
5
6
  const cfg = await loadConfig();
7
+ // Standalone mode ONLY — an opted-in paste-mode user has nothing to probe.
8
+ // Everything below (including the no-alias failure) is unchanged.
9
+ if (sapChecksSkipped(cfg))
10
+ return standaloneSkip('sap');
6
11
  const systems = Object.entries(cfg.sap);
7
12
  if (systems.length === 0)
8
13
  return { name: 'sap', ok: false, message: 'no SAP systems configured', hint: 'Run: cspeach config add-sap' };
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Doctor check: standards (2026-09-16).
3
+ *
4
+ * Purely informational — surfaces WHICH development-standards document the
5
+ * session context is carrying, so a team that thinks their rules are being
6
+ * followed can confirm it at a glance. Having none is a normal state (CSPeach's
7
+ * built-in conventions apply), so it renders as a neutral `-` line, never a
8
+ * failure.
9
+ */
10
+ import { resolveStandardsFile, displayStandardsPath } from '../../standards/standards-file.js';
11
+ export async function checkStandards() {
12
+ const cwd = process.cwd();
13
+ const file = await resolveStandardsFile(cwd);
14
+ if (!file) {
15
+ return {
16
+ name: 'standards',
17
+ ok: false,
18
+ skipped: true,
19
+ message: 'none (run: cspeach standards init)',
20
+ };
21
+ }
22
+ return {
23
+ name: 'standards',
24
+ ok: true,
25
+ message: displayStandardsPath(file.path, cwd),
26
+ };
27
+ }
@@ -1,3 +1,4 @@
1
+ import { sapChecksSkipped, standaloneSkip } from './_standalone-skip.js';
1
2
  /**
2
3
  * Doctor check: system-roles (UX Wave 1, Task 1).
3
4
  *
@@ -11,6 +12,10 @@
11
12
  * never counts toward the doctor's failed total. Pure — no I/O beyond cfg.
12
13
  */
13
14
  export async function checkSystemRoles(cfg) {
15
+ // Standalone mode ONLY — nothing to put a write-mode ceiling on. Everything
16
+ // below (including the no-alias skip) is unchanged.
17
+ if (sapChecksSkipped(cfg))
18
+ return standaloneSkip('system-roles');
14
19
  const entries = Object.entries(cfg.sap ?? {});
15
20
  if (entries.length === 0) {
16
21
  return {
@@ -2,9 +2,14 @@ import keytar from 'keytar';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { loadConfig, resolveSapTls } from '../../config/loader.js';
4
4
  import { probeGet } from './_http-probe.js';
5
+ import { sapChecksSkipped, standaloneSkip } from './_standalone-skip.js';
5
6
  const SAP_SERVICE = 'cspeach-sap';
6
7
  export async function checkZcspeach() {
7
8
  const cfg = await loadConfig();
9
+ // Standalone mode ONLY — an opted-in paste-mode user has nothing to probe.
10
+ // Everything below (including the no-alias failure) is unchanged.
11
+ if (sapChecksSkipped(cfg))
12
+ return standaloneSkip('zcspeach');
8
13
  const systems = Object.entries(cfg.sap);
9
14
  if (systems.length === 0) {
10
15
  return {
@@ -10,8 +10,10 @@ import { checkSkill } from './checks/skill.js';
10
10
  import { checkKeychainFallback } from './checks/keychain-fallback.js';
11
11
  import { checkWriteMode } from './checks/write-mode.js';
12
12
  import { checkLlmMode } from './checks/llm-mode.js';
13
+ import { checkCredits } from './checks/credits.js';
13
14
  import { checkForgeRules } from './checks/forge-rules.js';
14
15
  import { checkSystemRoles } from './checks/system-roles.js';
16
+ import { checkStandards } from './checks/standards.js';
15
17
  export async function runDoctor() {
16
18
  console.log(chalk.cyan.bold('\nCSPeach — doctor\n'));
17
19
  const cfg = await loadConfig();
@@ -19,6 +21,7 @@ export async function runDoctor() {
19
21
  checks.push(await checkProxy());
20
22
  checks.push(await checkAuth());
21
23
  checks.push(await checkLlmMode(cfg));
24
+ checks.push(await checkCredits(cfg));
22
25
  checks.push(await checkWriteMode(cfg));
23
26
  checks.push(await checkSystemRoles(cfg));
24
27
  checks.push(await checkKeychain());
@@ -28,6 +31,7 @@ export async function runDoctor() {
28
31
  checks.push(await checkSkill());
29
32
  checks.push(await checkKeychainFallback());
30
33
  checks.push(await checkForgeRules());
34
+ checks.push(await checkStandards());
31
35
  let failed = 0;
32
36
  for (const c of checks) {
33
37
  const icon = c.skipped