@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
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import { basename } from 'node:path';
3
3
  import { dispatchTool } from './tool-dispatch.js';
4
- import { listTools, getTool, toAnthropicTools } from '../tools/index.js';
4
+ import { listTools, getTool, toAnthropicTools, isStandalone, toolsForContext } from '../tools/index.js';
5
5
  import { saveSession } from '../session/store.js';
6
6
  import { recordCompletedToolCall } from '../session/pending.js';
7
7
  import { loadConfig } from '../config/loader.js';
@@ -20,6 +20,7 @@ import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
20
20
  import { buildRetryCapPausePayload, buildSkippedSiblingResults } from './retry-cap.js';
21
21
  import { appendCostLine, buildEntry as buildCostEntry } from '../cost/cost-log.js';
22
22
  import { computeSessionCost } from '../cost/session-cost.js';
23
+ import { refreshCreditsAfterTurn, creditsForCostLog } from '../cost/credits-wire.js';
23
24
  import { globalStore } from '../ui/sap-state-store.js';
24
25
  import { lastAssistantIsAwaitingAnswer } from '../session/awaiting-answer.js';
25
26
  import { enrichUserMessage } from '../router/intent-extractor.js';
@@ -30,6 +31,9 @@ import { input } from '@inquirer/prompts';
30
31
  import { withInquirer } from '../repl/inquirer-guard.js';
31
32
  import { expandTextFileAttachments } from '../projects/workspace.js';
32
33
  import { getSapSystemInfo, renderSapSystemBlock } from '../sap/system-info.js';
34
+ import { renderStandaloneContextBlock } from '../sap/standalone-profile.js';
35
+ import { renderStandardsBlock } from '../standards/standards-file.js';
36
+ import { composeSessionContextBlock } from './session-context.js';
33
37
  import { objectKeyFromInput } from './retry-key.js';
34
38
  import { repairPartialBlocks, healSessionMessagesInPlace, isPartialJsonApiError } from './repair-partial.js';
35
39
  import { drainSteering } from './steering-queue.js';
@@ -350,10 +354,23 @@ export async function runTurn(params) {
350
354
  // before — no behavioral regression.
351
355
  let sapSystemBlock = '';
352
356
  let sapSystemInfo = null;
353
- if (params.ctx.adt && params.ctx.sapAlias) {
357
+ const standaloneTurn = isStandalone(params.ctx);
358
+ if (standaloneTurn) {
359
+ // Standalone (2026-09-16): nothing to probe. Render the SAME
360
+ // `<sap_system …/>` element shape from the stored standalone profile —
361
+ // so skills read one element in both modes — plus the manual-
362
+ // implementation hand-off note.
363
+ sapSystemBlock = renderStandaloneContextBlock(cfg.standalone);
364
+ }
365
+ else if (params.ctx.adt && params.ctx.sapAlias) {
354
366
  sapSystemInfo = await getSapSystemInfo(params.ctx.adt, params.ctx.sapAlias);
355
367
  sapSystemBlock = renderSapSystemBlock(sapSystemInfo);
356
368
  }
369
+ // Customer development standards — opt-in, both modes. Renders '' when the
370
+ // user has no standards file, so the composed block (and therefore the whole
371
+ // prompt) stays byte-identical to pre-feature output for existing users.
372
+ const standardsBlock = await renderStandardsBlock(params.ctx.cwd);
373
+ const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, standardsBlock]);
357
374
  // Deterministically extract structured facts from the user's prompt
358
375
  // (package, transport, object names, environment hint, create intent)
359
376
  // and prepend them as an XML `<session_context>` block. The model reads
@@ -368,7 +385,7 @@ export async function runTurn(params) {
368
385
  // when extraction yields nothing (enrichUserMessage returns original).
369
386
  const enriched = userMessageForLLM.startsWith('<session_context>')
370
387
  ? userMessageForLLM
371
- : enrichUserMessage(userMessageForLLM, sapSystemBlock);
388
+ : enrichUserMessage(userMessageForLLM, sessionContextBlock);
372
389
  // Phase 2 of Track B — opt-in project-context enrichment, gated by
373
390
  // CSPEACH_PROJECT_CONTEXT=on. Default OFF: production behavior is
374
391
  // identical to today. When on, the rendered <project_context> block
@@ -505,7 +522,9 @@ export async function runTurn(params) {
505
522
  // W2.5 — apply optional tool filter (used by agent_run with
506
523
  // read_only: true to send a smaller, read-only tool list to
507
524
  // the subagent). Mirrors CC's Tq2() pattern.
508
- const allTools = listTools();
525
+ // Standalone (2026-09-16): hide every SAP-dependent tool from the
526
+ // model — calling one could only fail. Identity when connected.
527
+ const allTools = toolsForContext(listTools(), params.ctx);
509
528
  const filteredTools = params.toolFilter ? allTools.filter(params.toolFilter) : allTools;
510
529
  const tools = toAnthropicTools(filteredTools);
511
530
  // Bug 11a (proactive heal, 2026-06-07) — strip any residual
@@ -805,6 +824,11 @@ export async function runTurn(params) {
805
824
  // never crash a turn. Runs for both happy and interrupted paths so
806
825
  // the user's spend is recorded even when a turn dies mid-stream.
807
826
  {
827
+ // Credits (2026-09-16): sample the customer's balance BEFORE building
828
+ // the cost line so the JSONL records what the LEDGER charged alongside
829
+ // our COGS, and so the status footer the REPL prints next reads a fresh
830
+ // figure. No-op + no request outside credits mode; capped at 2.5 s.
831
+ await refreshCreditsAfterTurn();
808
832
  const turnTokens = {
809
833
  input: (session.usage?.input_tokens ?? 0) - turnStartTokens.input,
810
834
  output: (session.usage?.output_tokens ?? 0) - turnStartTokens.output,
@@ -820,6 +844,9 @@ export async function runTurn(params) {
820
844
  model: params.modelOverride ?? session.model,
821
845
  tokens: turnTokens,
822
846
  duration_ms: turnDurationMs,
847
+ // Additive: present only in credits mode (undefined in USD mode, so
848
+ // the emitted line is byte-identical to a pre-credits one).
849
+ credits: creditsForCostLog(),
823
850
  });
824
851
  void appendCostLine(session.id, entry);
825
852
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Helpers for assembling the `<session_context>` preamble the agent loop
3
+ * prepends to each user message.
4
+ *
5
+ * `formatFactBlock` (router/intent-extractor.ts) takes ONE pre-rendered string
6
+ * and indents only its first line, so anything we add after the `<sap_system>`
7
+ * element has to carry its own two-space body indent. This helper is that
8
+ * single, tested place — the loop just hands it the parts it has.
9
+ */
10
+ /**
11
+ * Join the non-empty context parts into one block the fact-formatter can take.
12
+ *
13
+ * - No parts (or all empty) → '' — the caller's output is then byte-identical
14
+ * to the pre-feature behaviour.
15
+ * - One part → returned untouched.
16
+ * - Several → separated by a newline plus the two-space `<session_context>`
17
+ * body indent, so each part starts on its own indented line. Inner lines of a
18
+ * multi-line part (e.g. the standards document body) are left flush: they are
19
+ * element CONTENT, and indenting them would only pad the prompt.
20
+ */
21
+ export function composeSessionContextBlock(parts) {
22
+ const nonEmpty = parts.filter((p) => typeof p === 'string' && p.length > 0);
23
+ return nonEmpty.join('\n ');
24
+ }
@@ -1,4 +1,5 @@
1
- import { getTool } from '../tools/index.js';
1
+ import { getTool, isStandalone } from '../tools/index.js';
2
+ import { NOT_CONNECTED_MESSAGE } from '../sap/offline-adt-client.js';
2
3
  import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
3
4
  import { shouldGateRule8, recordWriteOp, renderPlanSummary, getWriteOpsThisTurn, } from '../repl/rule8-detector.js';
4
5
  export async function dispatchTool(name, args, ctx) {
@@ -6,6 +7,21 @@ export async function dispatchTool(name, args, ctx) {
6
7
  if (!tool) {
7
8
  return { content: JSON.stringify({ error: `unknown_tool: ${name}` }), is_error: true };
8
9
  }
10
+ // Standalone safety net (2026-09-16). SAP-dependent tools are already
11
+ // filtered out of the list the model sees (agent/loop.ts), but a model can
12
+ // still call a tool it remembers from an earlier turn or from training. Fail
13
+ // as a normal tool ERROR RESULT — never a throw — so the model reads the
14
+ // message and recovers by asking the user to paste the source instead.
15
+ if (tool.requiresSap && isStandalone(ctx)) {
16
+ return {
17
+ content: JSON.stringify({
18
+ error: 'sap_not_connected',
19
+ standalone: true,
20
+ message: NOT_CONNECTED_MESSAGE,
21
+ }),
22
+ is_error: true,
23
+ };
24
+ }
9
25
  // Rule 8 — no batch without plan. 2nd+ mutating call this turn requires
10
26
  // explicit confirmation. Fires regardless of /safety-mode toggle (batch
11
27
  // detection is unconditional per Plan 3 §6.6).
package/dist/cli-args.js CHANGED
@@ -36,3 +36,27 @@ export function extractSapAliasFlag(args, availableAliases) {
36
36
  const rest = [...args.slice(0, idx), ...args.slice(idx + 2)];
37
37
  return { sapAlias: alias, rest };
38
38
  }
39
+ /**
40
+ * Scan `args` for `--no-sap` (standalone mode — run without a SAP system).
41
+ *
42
+ * - Absent: returns `{ noSap: false, rest: args }` — the SAME array reference,
43
+ * so every downstream path stays byte-identical to pre-feature behaviour.
44
+ * - Present: strips EVERY occurrence and returns `{ noSap: true, rest }`.
45
+ * - Present together with `--sap-alias`: returns `{ error, rest: args }`.
46
+ * Asking for a specific system AND for no system at all is a contradiction
47
+ * the CLI refuses rather than silently resolving one way.
48
+ */
49
+ export function extractNoSapFlag(args) {
50
+ if (!args.includes('--no-sap')) {
51
+ return { noSap: false, rest: args };
52
+ }
53
+ if (args.includes('--sap-alias')) {
54
+ return {
55
+ noSap: true,
56
+ rest: args,
57
+ error: 'error: --no-sap and --sap-alias cannot be combined. ' +
58
+ 'Use --no-sap to run without a SAP system, or --sap-alias <name> to pick one.',
59
+ };
60
+ }
61
+ return { noSap: true, rest: args.filter((a) => a !== '--no-sap') };
62
+ }
package/dist/cli.js CHANGED
@@ -3,11 +3,22 @@ import { CLI_VERSION } from './lib/version.js';
3
3
  import { runAddSapWizard } from './sap/onboarding.js';
4
4
  import { runGcOnStartup } from './session/gc.js';
5
5
  import { checkForUpgrade } from './upgrade-check.js';
6
- import { extractSapAliasFlag } from './cli-args.js';
6
+ import { extractSapAliasFlag, extractNoSapFlag } from './cli-args.js';
7
7
  export async function main(argv) {
8
8
  runGcOnStartup().catch(() => { });
9
9
  checkForUpgrade().catch(() => { });
10
10
  let args = argv.slice(2);
11
+ // Standalone mode (2026-09-16) — `--no-sap` is accepted ANYWHERE in the args
12
+ // for both the REPL and one-shot, and stripped like `--sap-alias` below.
13
+ // Absent, `extractNoSapFlag` hands back the same array, so every existing
14
+ // path stays byte-identical.
15
+ const noSapParsed = extractNoSapFlag(args);
16
+ if (noSapParsed.error) {
17
+ console.error(chalk.red(noSapParsed.error));
18
+ return 1;
19
+ }
20
+ const noSap = noSapParsed.noSap;
21
+ args = noSapParsed.rest;
11
22
  if (args[0] === '--help' || args[0] === '-h') {
12
23
  printHelp();
13
24
  return 0;
@@ -39,6 +50,15 @@ export async function main(argv) {
39
50
  sapAlias = parsed.sapAlias;
40
51
  args = parsed.rest;
41
52
  }
53
+ // Standalone profile wizard — re-runnable any time. Checked BEFORE the plain
54
+ // `configure` branch so `configure --standalone` isn't swallowed by it.
55
+ // Asks what system the code will land on, then the standards question.
56
+ if ((args[0] === 'config' && args[1] === 'standalone')
57
+ || (args[0] === 'configure' && args[1] === '--standalone')) {
58
+ const { runStandaloneProfileWizard } = await import('./sap/standalone-onboarding.js');
59
+ await runStandaloneProfileWizard({ cwd: process.cwd() });
60
+ return 0;
61
+ }
42
62
  if (args[0] === 'configure') {
43
63
  console.log(chalk.dim('(alias for `cspeach config add-sap`)'));
44
64
  await runAddSapWizard();
@@ -48,6 +68,12 @@ export async function main(argv) {
48
68
  await runAddSapWizard();
49
69
  return 0;
50
70
  }
71
+ // Capture / import the team's development standards (works connected too).
72
+ if (args[0] === 'standards' && (args[1] === 'init' || args[1] === undefined)) {
73
+ const { runStandardsInit } = await import('./standards/standards-init.js');
74
+ await runStandardsInit(process.cwd());
75
+ return 0;
76
+ }
51
77
  if (args[0] === 'config' && args[1] === 'set') {
52
78
  const { runConfigSet, ConfigSetError } = await import('./commands/config-set.js');
53
79
  try {
@@ -124,16 +150,16 @@ export async function main(argv) {
124
150
  }
125
151
  if (args[0] === '--one-shot') {
126
152
  const { runOneShot } = await import('./one-shot.js');
127
- return runOneShot(args.slice(1).join(' '), { sapAlias });
153
+ return runOneShot(args.slice(1).join(' '), { sapAlias, noSap });
128
154
  }
129
155
  // Default: if first arg exists and is not a flag, treat as one-shot prompt.
130
156
  if (args.length > 0 && !args[0].startsWith('-')) {
131
157
  const { runOneShot } = await import('./one-shot.js');
132
- return runOneShot(args.join(' '), { sapAlias });
158
+ return runOneShot(args.join(' '), { sapAlias, noSap });
133
159
  }
134
160
  // No args → REPL.
135
161
  const { runRepl } = await import('./repl.js');
136
- return runRepl({ sapAlias });
162
+ return runRepl({ sapAlias, noSap });
137
163
  }
138
164
  function printHelp() {
139
165
  console.log(`
@@ -144,8 +170,11 @@ Usage:
144
170
  cspeach "<prompt>" One-shot — runs one turn and exits
145
171
  cspeach --one-shot "<prompt>" Same as above, explicit flag
146
172
  cspeach --sap-alias <name> Use a specific SAP alias (overrides default)
173
+ cspeach --no-sap Start without a SAP system (paste-mode skills only)
147
174
  cspeach --resume [id] Resume a previous session (list if no id)
148
175
  cspeach config add-sap Add a SAP system interactively
176
+ cspeach config standalone Set the standalone SAP profile (no system)
177
+ cspeach standards init Capture your team's ABAP development standards
149
178
  cspeach configure Alias for \`cspeach config add-sap\`
150
179
  cspeach config set <key> <value> Update a config key
151
180
  cspeach config unset <key> Remove a config key (revert to default)
@@ -1,5 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import { loadConfig, saveConfig, isValidWriteMode } from '../config/loader.js';
3
+ import { STANDALONE_PLATFORMS, STANDALONE_DATABASES, STANDALONE_DEPLOYMENTS, STANDALONE_IDES, isStandalonePlatform, deriveDatabase, deriveDeployment, defaultAbapCloudOnly, defaultRelease, } from '../sap/standalone-profile.js';
3
4
  /**
4
5
  * Mutable keys exposed via `cspeach config set`.
5
6
  * Any key not in this set is rejected with an error listing valid keys.
@@ -14,9 +15,11 @@ const MUTABLE_KEYS = new Set([
14
15
  'classifier.safe_mode',
15
16
  'local_build',
16
17
  'local_files', // deprecated alias for local_build (2026-06-13) — still accepted
18
+ 'show_cogs',
17
19
  'write_mode',
18
20
  'plan_mode',
19
21
  'plan_audit',
22
+ 'no_sap',
20
23
  'llm.mode',
21
24
  'llm.ai_hub_base_url',
22
25
  'llm.ai_hub_model_alias',
@@ -33,6 +36,17 @@ const SAP_ROLE_RE = /^sap\.([A-Z0-9_-]{2,32})\.role$/;
33
36
  // SAP-scoped TLS keys: sap.<alias>.ca_cert_path | sap.<alias>.insecure_skip_tls_verify
34
37
  const SAP_CA_CERT_PATH_RE = /^sap\.([A-Z0-9_-]{2,32})\.ca_cert_path$/;
35
38
  const SAP_INSECURE_TLS_RE = /^sap\.([A-Z0-9_-]{2,32})\.insecure_skip_tls_verify$/;
39
+ // Standalone-profile keys: standalone.<field> (dotted, like the sap.* family).
40
+ const STANDALONE_KEY_RE = /^standalone\.([a-z_]+)$/;
41
+ const STANDALONE_FIELDS = [
42
+ 'platform',
43
+ 'release',
44
+ 'database',
45
+ 'deployment',
46
+ 'ide',
47
+ 'abap_cloud_only',
48
+ 'notes',
49
+ ];
36
50
  /**
37
51
  * Coerce a user-typed on/off-style string to a boolean. Accepts the common
38
52
  * spellings a developer expects from a CLI toggle (`on`/`true`/`1` → true,
@@ -93,9 +107,10 @@ const VIRTUAL_KEYCHAIN_KEYS = new Set(['llm.byok_key', 'llm.ai_hub_token']);
93
107
  * the deprecated `local_files` alias — the canonical name is `local_build`).
94
108
  */
95
109
  function unsettableKeyList() {
96
- return [...MUTABLE_KEYS]
97
- .filter((k) => !VIRTUAL_KEYCHAIN_KEYS.has(k) && k !== 'local_files')
98
- .join(', ');
110
+ return [
111
+ ...[...MUTABLE_KEYS].filter((k) => !VIRTUAL_KEYCHAIN_KEYS.has(k) && k !== 'local_files'),
112
+ ...UNSET_ONLY_KEYS,
113
+ ].join(', ');
99
114
  }
100
115
  /**
101
116
  * Mark a top-level config key as explicitly user-set so sparse serialisation
@@ -114,6 +129,76 @@ export function markExplicit(cfg, key) {
114
129
  if (!cfg.file_keys.includes(top))
115
130
  cfg.file_keys.push(top);
116
131
  }
132
+ /** Human-readable "allowed values" error for a standalone.<field> set. */
133
+ function standaloneValueError(field, value) {
134
+ switch (field) {
135
+ case 'platform':
136
+ return `Invalid standalone.platform "${value}". Allowed: ${STANDALONE_PLATFORMS.join(', ')}`;
137
+ case 'database':
138
+ return `Invalid standalone.database "${value}". Allowed: ${STANDALONE_DATABASES.join(', ')}`;
139
+ case 'deployment':
140
+ return `Invalid standalone.deployment "${value}". Allowed: ${STANDALONE_DEPLOYMENTS.join(', ')}`;
141
+ case 'ide':
142
+ return `Invalid standalone.ide "${value}". Allowed: ${STANDALONE_IDES.join(', ')}`;
143
+ case 'abap_cloud_only':
144
+ return 'standalone.abap_cloud_only must be on/off (also accepts true/false, 1/0).';
145
+ default:
146
+ return `standalone.${field} cannot be empty.`;
147
+ }
148
+ }
149
+ /**
150
+ * Apply one `standalone.<field>` value to the (possibly absent) profile.
151
+ *
152
+ * Setting `platform` on an absent profile seeds the whole table from that
153
+ * platform's derivations, so `config set standalone.platform S/4HANA` alone
154
+ * leaves a complete, consistent profile. Changing the platform later keeps the
155
+ * user's other answers — they said those on purpose.
156
+ */
157
+ function applyStandaloneField(current, field, value) {
158
+ if (!current) {
159
+ // Guarded by the caller: only `platform` reaches here with no profile.
160
+ const platform = value;
161
+ return {
162
+ platform,
163
+ release: defaultRelease(platform),
164
+ database: deriveDatabase(platform) ?? 'HANA',
165
+ deployment: deriveDeployment(platform) ?? 'on-prem',
166
+ ide: 'ADT',
167
+ abap_cloud_only: defaultAbapCloudOnly(platform),
168
+ };
169
+ }
170
+ const next = { ...current };
171
+ switch (field) {
172
+ case 'platform':
173
+ next.platform = value;
174
+ break;
175
+ case 'release':
176
+ next.release = value.trim();
177
+ break;
178
+ case 'database':
179
+ next.database = value;
180
+ break;
181
+ case 'deployment':
182
+ next.deployment = value;
183
+ break;
184
+ case 'ide':
185
+ next.ide = value;
186
+ break;
187
+ case 'abap_cloud_only':
188
+ next.abap_cloud_only = coerceBool(value);
189
+ break;
190
+ case 'notes':
191
+ next.notes = value.trim();
192
+ break;
193
+ }
194
+ return next;
195
+ }
196
+ /**
197
+ * Keys `config unset` accepts that are NOT flat `config set` targets.
198
+ * `standalone` is a table written field-by-field via `standalone.<field>`, but
199
+ * users still need one command to clear the whole profile.
200
+ */
201
+ const UNSET_ONLY_KEYS = new Set(['standalone']);
117
202
  /**
118
203
  * Delete a (possibly dotted, max 2-level) key from a loaded config in place.
119
204
  * Returns true if the key was present and removed, false if it was already
@@ -175,7 +260,7 @@ export async function runConfigUnset(args) {
175
260
  if (key.startsWith('sap.')) {
176
261
  errUnset(`"${key}" is a SAP-scoped key — those are managed per system via \`cspeach config set ${key} <value>\`, not config unset.`);
177
262
  }
178
- if (!MUTABLE_KEYS.has(key)) {
263
+ if (!MUTABLE_KEYS.has(key) && !UNSET_ONLY_KEYS.has(key)) {
179
264
  errUnset(`Unknown key "${key}". Valid keys: ${unsettableKeyList()}`);
180
265
  }
181
266
  const cfg = await loadConfig();
@@ -272,6 +357,30 @@ export async function runConfigSet(args) {
272
357
  console.log(chalk.green(`Set ${key} = ${b}`));
273
358
  return;
274
359
  }
360
+ // Standalone-profile key: standalone.<field>. Mirrors the sap.<alias>.<key>
361
+ // shape — a dotted key handled before the flat MUTABLE_KEYS switch.
362
+ const standaloneMatch = STANDALONE_KEY_RE.exec(key);
363
+ if (standaloneMatch) {
364
+ const field = standaloneMatch[1];
365
+ if (!STANDALONE_FIELDS.includes(field)) {
366
+ err(`Unknown standalone key "${key}". Valid: ${STANDALONE_FIELDS.map((f) => `standalone.${f}`).join(', ')}`);
367
+ }
368
+ if (!validateValue(key, value)) {
369
+ err(standaloneValueError(field, value));
370
+ }
371
+ const cfg = await loadConfig();
372
+ // The platform is load-bearing: every other field's default derives from
373
+ // it. Refuse to invent one so a lone `standalone.release` can never sit
374
+ // under a silently-guessed platform.
375
+ if (!cfg.standalone && field !== 'platform') {
376
+ err(`Set the platform first: cspeach config set standalone.platform <${STANDALONE_PLATFORMS.join(' | ')}>`);
377
+ }
378
+ cfg.standalone = applyStandaloneField(cfg.standalone, field, value);
379
+ markExplicit(cfg, key);
380
+ await saveConfig(cfg);
381
+ console.log(chalk.green(`Set ${key} = ${value}`));
382
+ return;
383
+ }
275
384
  // Virtual keys that store secrets in the OS keychain instead of config.toml.
276
385
  if (key === 'llm.byok_key' || key === 'llm.ai_hub_token') {
277
386
  // Support stdin piping for secrets: `echo "key" | cspeach config set llm.byok_key -`
@@ -384,6 +493,30 @@ export async function runConfigSet(args) {
384
493
  console.log(chalk.dim(' → takes effect on the next `cspeach` launch.'));
385
494
  break;
386
495
  }
496
+ case 'no_sap': {
497
+ // Standalone mode — start without a SAP system (paste-mode skills only).
498
+ const b = coerceBool(value);
499
+ if (b === null) {
500
+ err(`${key} must be on/off (also accepts true/false, 1/0).`);
501
+ }
502
+ cfg.no_sap = b;
503
+ console.log(chalk.dim(' → takes effect on the next `cspeach` launch.'));
504
+ break;
505
+ }
506
+ case 'show_cogs': {
507
+ // Operator escape hatch — render OUR Anthropic COGS in USD even on a
508
+ // managed, metered account (where the customer-facing unit is credits).
509
+ // Off/absent is the product default; see cost/display-mode.ts.
510
+ const b = coerceBool(value);
511
+ if (b === null) {
512
+ err(`${key} must be on/off (also accepts true/false, 1/0).`);
513
+ }
514
+ cfg.show_cogs = b;
515
+ console.log(chalk.dim(b
516
+ ? ' → cost figures will show USD (our Anthropic cost), not credits.'
517
+ : ' → cost figures will show credits on a managed, metered account.'));
518
+ break;
519
+ }
387
520
  case 'write_mode':
388
521
  if (!isValidWriteMode(value)) {
389
522
  err(`Invalid value for write_mode. Must be one of: auto, approval-gated, advisory-only`);
@@ -476,6 +609,21 @@ export function validateValue(key, value) {
476
609
  if (SAP_INSECURE_TLS_RE.test(key)) {
477
610
  return coerceBool(value) !== null;
478
611
  }
612
+ // Standalone-profile keys: standalone.<field>
613
+ const standaloneMatch = STANDALONE_KEY_RE.exec(key);
614
+ if (standaloneMatch) {
615
+ const field = standaloneMatch[1];
616
+ switch (field) {
617
+ case 'platform': return isStandalonePlatform(value);
618
+ case 'database': return STANDALONE_DATABASES.includes(value);
619
+ case 'deployment': return STANDALONE_DEPLOYMENTS.includes(value);
620
+ case 'ide': return STANDALONE_IDES.includes(value);
621
+ case 'abap_cloud_only': return coerceBool(value) !== null;
622
+ case 'release':
623
+ case 'notes': return value.trim().length > 0;
624
+ default: return false;
625
+ }
626
+ }
479
627
  if (!MUTABLE_KEYS.has(key))
480
628
  return false;
481
629
  switch (key) {
@@ -499,6 +647,7 @@ export function validateValue(key, value) {
499
647
  return value === 'true' || value === 'false';
500
648
  case 'local_build':
501
649
  case 'local_files': // deprecated alias — same on/off coercion
650
+ case 'show_cogs':
502
651
  return coerceBool(value) !== null;
503
652
  case 'write_mode':
504
653
  return isValidWriteMode(value);
@@ -506,6 +655,8 @@ export function validateValue(key, value) {
506
655
  return PLAN_MODE_VALUES.includes(value);
507
656
  case 'plan_audit':
508
657
  return PLAN_AUDIT_VALUES.includes(value);
658
+ case 'no_sap':
659
+ return coerceBool(value) !== null;
509
660
  case 'llm.mode':
510
661
  return LLM_MODE_VALUES.includes(value);
511
662
  case 'llm.ai_hub_base_url':
@@ -11,7 +11,24 @@
11
11
  import chalk from 'chalk';
12
12
  import { readCostLog, summarise } from '../cost/cost-log.js';
13
13
  import { formatCost } from '../cost/pricing.js';
14
- export async function runCostCommand(sessionId) {
14
+ import { creditsActive, formatCredits, globalCredits, CREDITS_PENDING, } from '../cost/credits.js';
15
+ /**
16
+ * Sum the credits the proxy charged across the logged turns. Entries written
17
+ * before credits existed (or on turns whose balance fetch failed) carry no
18
+ * number — they are skipped, not counted as zero, and the caller says so.
19
+ */
20
+ function summariseCredits(entries) {
21
+ let total = 0;
22
+ let unknown = 0;
23
+ for (const e of entries) {
24
+ if (typeof e.credits_charged === 'number')
25
+ total += e.credits_charged;
26
+ else
27
+ unknown++;
28
+ }
29
+ return { total, unknown };
30
+ }
31
+ export async function runCostCommand(sessionId, credits = globalCredits.snapshot()) {
15
32
  const entries = await readCostLog(sessionId);
16
33
  if (entries.length === 0) {
17
34
  console.log('');
@@ -20,9 +37,27 @@ export async function runCostCommand(sessionId) {
20
37
  return;
21
38
  }
22
39
  const s = summarise(entries);
40
+ // Credits mode: the customer is billed in credits at a multiplier we do not
41
+ // disclose, so NOTHING derived from cost/pricing.ts may be printed here —
42
+ // not the total, not the per-model split, not the per-turn figures. Tokens
43
+ // stay: they are a usage fact, not a COGS leak.
44
+ const inCredits = creditsActive();
45
+ const perTurn = summariseCredits(entries);
23
46
  // Header
24
47
  console.log('');
25
- console.log(chalk.bold(`Session cost so far: ${formatCost(s.totalCost)}`) + chalk.dim(` (${s.turns} turn${s.turns === 1 ? '' : 's'})`));
48
+ if (inCredits) {
49
+ const unit = credits?.unitLabel ?? 'credits';
50
+ // Prefer the live session tracker (balance deltas observed this run);
51
+ // fall back to the logged per-turn charges when it has nothing yet.
52
+ const total = credits && credits.sessionTotal > 0 ? credits.sessionTotal : perTurn.total;
53
+ console.log(chalk.bold(`Session usage so far: ${formatCredits(total, unit)}`) + chalk.dim(` (${s.turns} turn${s.turns === 1 ? '' : 's'})`));
54
+ if (credits?.balance != null) {
55
+ console.log(chalk.dim(`Remaining balance: ${formatCredits(credits.balance, unit)}`));
56
+ }
57
+ }
58
+ else {
59
+ console.log(chalk.bold(`Session cost so far: ${formatCost(s.totalCost)}`) + chalk.dim(` (${s.turns} turn${s.turns === 1 ? '' : 's'})`));
60
+ }
26
61
  // Token breakdown
27
62
  console.log('');
28
63
  console.log(chalk.dim('Tokens:'));
@@ -35,7 +70,10 @@ export async function runCostCommand(sessionId) {
35
70
  console.log('');
36
71
  console.log(chalk.dim('By model:'));
37
72
  for (const m of s.byModel) {
38
- console.log(` ${m.model.padEnd(24)} ${m.turns} turn${m.turns === 1 ? '' : 's'} ${formatCost(m.cost)}`);
73
+ // Credits mode: turn counts only. A per-model dollar split is a direct
74
+ // read on our per-model COGS.
75
+ const tail = inCredits ? '' : ` ${formatCost(m.cost)}`;
76
+ console.log(` ${m.model.padEnd(24)} ${m.turns} turn${m.turns === 1 ? '' : 's'}${tail}`);
39
77
  }
40
78
  }
41
79
  else if (s.byModel[0]) {
@@ -48,7 +86,21 @@ export async function runCostCommand(sessionId) {
48
86
  console.log(chalk.dim(`Recent turns (last ${recent.length}):`));
49
87
  for (const e of recent) {
50
88
  const tok = (e.tokens.input + e.tokens.output).toLocaleString();
51
- console.log(` turn ${String(e.turn).padStart(3)} ${tok.padStart(10)} tok ${formatCost(e.cost)}`);
89
+ // Credits mode: the per-turn figure is what the LEDGER charged (a balance
90
+ // delta recorded at turn end), never our computed USD. A turn whose
91
+ // balance sample failed shows the pending marker rather than a number we
92
+ // would have to invent.
93
+ const tail = inCredits
94
+ ? (typeof e.credits_charged === 'number'
95
+ ? formatCredits(e.credits_charged, credits?.unitLabel ?? 'credits')
96
+ : CREDITS_PENDING)
97
+ : formatCost(e.cost);
98
+ console.log(` turn ${String(e.turn).padStart(3)} ${tok.padStart(10)} tok ${tail}`);
99
+ }
100
+ if (inCredits && perTurn.unknown > 0) {
101
+ console.log('');
102
+ console.log(chalk.dim(`${perTurn.unknown} turn${perTurn.unknown === 1 ? '' : 's'} had no balance reading — `
103
+ + `their usage is included in a later turn's figure.`));
52
104
  }
53
105
  console.log('');
54
106
  console.log(chalk.dim(`Detailed JSONL: ~/.cspeach/sessions/${sessionId}-cost.jsonl`));
@@ -15,6 +15,7 @@
15
15
  import fs from 'node:fs/promises';
16
16
  import path from 'node:path';
17
17
  import { buildAuditReport } from '../session/audit-export.js';
18
+ import { globalCredits } from '../cost/credits.js';
18
19
  import { readCostLog } from '../cost/cost-log.js';
19
20
  /** Parse the text after `/export` into a subcommand + optional filename. */
20
21
  export function parseExportArgs(rest) {
@@ -30,7 +31,9 @@ export async function runExportAuditCommand(opts) {
30
31
  const { session, transport, emit } = opts;
31
32
  const cwd = opts.cwd ?? process.cwd();
32
33
  const costEntries = opts.costEntries ?? (await readCostLog(session.id));
33
- const report = buildAuditReport({ session, costEntries, transport });
34
+ // The exported .md is a file the customer opens and forwards, so it carries
35
+ // their credits (not our COGS) whenever credits mode is active.
36
+ const report = buildAuditReport({ session, costEntries, transport, credits: globalCredits.snapshot() });
34
37
  const name = opts.filename && opts.filename.trim().length > 0
35
38
  ? opts.filename.trim()
36
39
  : defaultAuditFilename(session);
@@ -30,6 +30,7 @@ import { applyAuditResult, applyPhaseResolution, buildResumeCommand, offerNextPh
30
30
  import { appendCostLine, buildEntry } from '../cost/cost-log.js';
31
31
  import { startThinkingHeartbeat, AUDIT_HEARTBEAT_THRESHOLDS } from '../renderer/thinking-heartbeat.js';
32
32
  import { computeCost, formatCost } from '../cost/pricing.js';
33
+ import { creditsActive } from '../cost/credits.js';
33
34
  /** Two consecutive failed verdicts on the same phase force status 'blocked'. */
34
35
  export const MAX_CONSECUTIVE_AUDIT_FAILURES = 2;
35
36
  /** Infra error strings are model/HTTP noise — clamp when rendering (Task 5 review). */
@@ -659,7 +660,13 @@ function renderAuditCost(args, cost) {
659
660
  try {
660
661
  const tok = cost.tokens.input + cost.tokens.output;
661
662
  const usd = computeCost(cost.model, cost.tokens);
662
- args.log(chalk.dim(`[audit] ${tok.toLocaleString()} tok · ${formatCost(usd)} (${cost.model})`));
663
+ // Credits mode: the audit turn's USD is our COGS. There is no per-audit
664
+ // credit figure to substitute (the ledger charges the turn, not this
665
+ // sub-step), so the dim line drops the cost fragment and keeps tokens —
666
+ // never a dollar on a managed customer's screen.
667
+ args.log(chalk.dim(creditsActive()
668
+ ? `[audit] ${tok.toLocaleString()} tok (${cost.model})`
669
+ : `[audit] ${tok.toLocaleString()} tok · ${formatCost(usd)} (${cost.model})`));
663
670
  void appendCostLine(args.sessionId, {
664
671
  ...buildEntry({ turn: 0, model: cost.model, tokens: cost.tokens, duration_ms: cost.durationMs }),
665
672
  label: 'audit',