@cspeach/cli 1.1.1 → 1.1.3

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 (54) hide show
  1. package/dist/agent/loop.js +33 -4
  2. package/dist/agent/session-context.js +24 -0
  3. package/dist/agent/tool-dispatch.js +17 -1
  4. package/dist/approvals/jwt.js +12 -1
  5. package/dist/cli-args.js +24 -0
  6. package/dist/cli.js +33 -4
  7. package/dist/commands/config-set.js +139 -4
  8. package/dist/config/loader.js +34 -0
  9. package/dist/cost/pricing.js +4 -0
  10. package/dist/cost/session-cost.js +28 -0
  11. package/dist/doctor/checks/_standalone-skip.js +37 -0
  12. package/dist/doctor/checks/cert.js +5 -0
  13. package/dist/doctor/checks/forge-rules.js +6 -0
  14. package/dist/doctor/checks/sap.js +5 -0
  15. package/dist/doctor/checks/standards.js +27 -0
  16. package/dist/doctor/checks/system-roles.js +5 -0
  17. package/dist/doctor/checks/zcspeach.js +5 -0
  18. package/dist/doctor/run.js +2 -0
  19. package/dist/one-shot.js +36 -11
  20. package/dist/renderer/abap-inline.js +2 -1
  21. package/dist/renderer/syntax.js +7 -1
  22. package/dist/repl/current-transport.js +23 -1
  23. package/dist/repl/post-turn-status.js +9 -6
  24. package/dist/repl.js +40 -7
  25. package/dist/sap/offline-adt-client.js +58 -0
  26. package/dist/sap/standalone-onboarding.js +118 -0
  27. package/dist/sap/standalone-profile.js +237 -0
  28. package/dist/skills/bundled-skills.js +41 -41
  29. package/dist/standards/standards-file.js +107 -0
  30. package/dist/standards/standards-init.js +194 -0
  31. package/dist/tools/capability/tool.js +2 -0
  32. package/dist/tools/fiori/fe-scaffold.js +25 -0
  33. package/dist/tools/fiori/smoke/assertions.js +8 -2
  34. package/dist/tools/fiori/smoke/driver.js +6 -0
  35. package/dist/tools/fiori/smoke/run-smoke.js +69 -2
  36. package/dist/tools/fiori/tools.js +4 -2
  37. package/dist/tools/index.js +25 -0
  38. package/dist/tools/sap-read.js +18 -0
  39. package/dist/tools/sap-write.js +32 -0
  40. package/dist/tools/snapshot.js +3 -0
  41. package/dist/tools/todo.js +11 -3
  42. package/dist/tools/transport.js +4 -0
  43. package/dist/ui/app.js +1 -1
  44. package/dist/ui/sap-state-store.js +1 -1
  45. package/dist/ui/status-line.js +2 -2
  46. package/dist/ui/todo-panel.js +27 -6
  47. package/fiori/zlas-inspection-lr/package.json +26 -0
  48. package/fiori/zlas-inspection-lr/ui5-local.yaml +45 -0
  49. package/fiori/zlas-inspection-lr/ui5.yaml +41 -0
  50. package/fiori/zlas-inspection-lr/webapp/Component.js +12 -0
  51. package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +9 -0
  52. package/fiori/zlas-inspection-lr/webapp/index.html +35 -0
  53. package/fiori/zlas-inspection-lr/webapp/manifest.json +145 -0
  54. 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';
@@ -19,6 +19,8 @@ import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
19
19
  // two-line dispatch renderer; re-add if a future in-place spinner returns)
20
20
  import { buildRetryCapPausePayload, buildSkippedSiblingResults } from './retry-cap.js';
21
21
  import { appendCostLine, buildEntry as buildCostEntry } from '../cost/cost-log.js';
22
+ import { computeSessionCost } from '../cost/session-cost.js';
23
+ import { globalStore } from '../ui/sap-state-store.js';
22
24
  import { lastAssistantIsAwaitingAnswer } from '../session/awaiting-answer.js';
23
25
  import { enrichUserMessage } from '../router/intent-extractor.js';
24
26
  import { resetRule8State, shouldGateRule8 } from '../repl/rule8-detector.js';
@@ -28,6 +30,9 @@ import { input } from '@inquirer/prompts';
28
30
  import { withInquirer } from '../repl/inquirer-guard.js';
29
31
  import { expandTextFileAttachments } from '../projects/workspace.js';
30
32
  import { getSapSystemInfo, renderSapSystemBlock } from '../sap/system-info.js';
33
+ import { renderStandaloneContextBlock } from '../sap/standalone-profile.js';
34
+ import { renderStandardsBlock } from '../standards/standards-file.js';
35
+ import { composeSessionContextBlock } from './session-context.js';
31
36
  import { objectKeyFromInput } from './retry-key.js';
32
37
  import { repairPartialBlocks, healSessionMessagesInPlace, isPartialJsonApiError } from './repair-partial.js';
33
38
  import { drainSteering } from './steering-queue.js';
@@ -348,10 +353,23 @@ export async function runTurn(params) {
348
353
  // before — no behavioral regression.
349
354
  let sapSystemBlock = '';
350
355
  let sapSystemInfo = null;
351
- if (params.ctx.adt && params.ctx.sapAlias) {
356
+ const standaloneTurn = isStandalone(params.ctx);
357
+ if (standaloneTurn) {
358
+ // Standalone (2026-09-16): nothing to probe. Render the SAME
359
+ // `<sap_system …/>` element shape from the stored standalone profile —
360
+ // so skills read one element in both modes — plus the manual-
361
+ // implementation hand-off note.
362
+ sapSystemBlock = renderStandaloneContextBlock(cfg.standalone);
363
+ }
364
+ else if (params.ctx.adt && params.ctx.sapAlias) {
352
365
  sapSystemInfo = await getSapSystemInfo(params.ctx.adt, params.ctx.sapAlias);
353
366
  sapSystemBlock = renderSapSystemBlock(sapSystemInfo);
354
367
  }
368
+ // Customer development standards — opt-in, both modes. Renders '' when the
369
+ // user has no standards file, so the composed block (and therefore the whole
370
+ // prompt) stays byte-identical to pre-feature output for existing users.
371
+ const standardsBlock = await renderStandardsBlock(params.ctx.cwd);
372
+ const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, standardsBlock]);
355
373
  // Deterministically extract structured facts from the user's prompt
356
374
  // (package, transport, object names, environment hint, create intent)
357
375
  // and prepend them as an XML `<session_context>` block. The model reads
@@ -366,7 +384,7 @@ export async function runTurn(params) {
366
384
  // when extraction yields nothing (enrichUserMessage returns original).
367
385
  const enriched = userMessageForLLM.startsWith('<session_context>')
368
386
  ? userMessageForLLM
369
- : enrichUserMessage(userMessageForLLM, sapSystemBlock);
387
+ : enrichUserMessage(userMessageForLLM, sessionContextBlock);
370
388
  // Phase 2 of Track B — opt-in project-context enrichment, gated by
371
389
  // CSPEACH_PROJECT_CONTEXT=on. Default OFF: production behavior is
372
390
  // identical to today. When on, the rendered <project_context> block
@@ -503,7 +521,9 @@ export async function runTurn(params) {
503
521
  // W2.5 — apply optional tool filter (used by agent_run with
504
522
  // read_only: true to send a smaller, read-only tool list to
505
523
  // the subagent). Mirrors CC's Tq2() pattern.
506
- const allTools = listTools();
524
+ // Standalone (2026-09-16): hide every SAP-dependent tool from the
525
+ // model — calling one could only fail. Identity when connected.
526
+ const allTools = toolsForContext(listTools(), params.ctx);
507
527
  const filteredTools = params.toolFilter ? allTools.filter(params.toolFilter) : allTools;
508
528
  const tools = toAnthropicTools(filteredTools);
509
529
  // Bug 11a (proactive heal, 2026-06-07) — strip any residual
@@ -698,6 +718,11 @@ export async function runTurn(params) {
698
718
  session.usage.cache_creation_input_tokens =
699
719
  (session.usage.cache_creation_input_tokens ?? 0)
700
720
  + (msgUsage.cache_creation_input_tokens ?? 0);
721
+ // F-L2-2 — live spend. The store's 200ms trailing rate limiter
722
+ // absorbs stream-event volume; no extra throttling here.
723
+ if (params.pushSpend) {
724
+ globalStore.update({ dollarSpendToday: computeSessionCost(session) });
725
+ }
701
726
  }
702
727
  // New message begins — reset the per-message cumulative-output scratch.
703
728
  messageOutputTokensSoFar = 0;
@@ -720,6 +745,10 @@ export async function runTurn(params) {
720
745
  if (inc > 0) {
721
746
  session.usage.output_tokens += inc;
722
747
  messageOutputTokensSoFar = deltaUsage.output_tokens;
748
+ // F-L2-2 — live spend (see message_start above).
749
+ if (params.pushSpend) {
750
+ globalStore.update({ dollarSpendToday: computeSessionCost(session) });
751
+ }
723
752
  }
724
753
  }
725
754
  }
@@ -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).
@@ -76,9 +76,20 @@ export async function verifyAndSpendApprovalId(jwt, expectedObject, expectedOp)
76
76
  // signature-verification failure code. Mismatch reasons (nonce, object,
77
77
  // op) fall out of the post-verify checks below and are returned
78
78
  // verbatim — no catch involved.
79
+ //
80
+ // F-L2-13: normalize the token at the tool boundary. An approval_id that
81
+ // arrives wrapped in stray whitespace (a model-emitted argument with a
82
+ // trailing newline, a copy/paste with leading spaces) is a structurally
83
+ // valid JWT that jose rejects as malformed — the user then sees
84
+ // APPROVAL_INVALID / invalid_signature, which reads as "your token is
85
+ // forged" and offers no way forward. Trimming here covers all 10
86
+ // tool-boundary callers (sap-write.ts, transport.ts) at one site. Nonce
87
+ // bookkeeping keys on the decoded `payload.nonce`, never on the raw
88
+ // string, so trimming cannot desynchronize single-use accounting.
89
+ const token = String(jwt ?? '').trim();
79
90
  let payload;
80
91
  try {
81
- const verified = await jwtVerify(jwt, sessionKey);
92
+ const verified = await jwtVerify(token, sessionKey);
82
93
  payload = verified.payload;
83
94
  }
84
95
  catch (err) {
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.
@@ -17,6 +18,7 @@ const MUTABLE_KEYS = new Set([
17
18
  'write_mode',
18
19
  'plan_mode',
19
20
  'plan_audit',
21
+ 'no_sap',
20
22
  'llm.mode',
21
23
  'llm.ai_hub_base_url',
22
24
  'llm.ai_hub_model_alias',
@@ -33,6 +35,17 @@ const SAP_ROLE_RE = /^sap\.([A-Z0-9_-]{2,32})\.role$/;
33
35
  // SAP-scoped TLS keys: sap.<alias>.ca_cert_path | sap.<alias>.insecure_skip_tls_verify
34
36
  const SAP_CA_CERT_PATH_RE = /^sap\.([A-Z0-9_-]{2,32})\.ca_cert_path$/;
35
37
  const SAP_INSECURE_TLS_RE = /^sap\.([A-Z0-9_-]{2,32})\.insecure_skip_tls_verify$/;
38
+ // Standalone-profile keys: standalone.<field> (dotted, like the sap.* family).
39
+ const STANDALONE_KEY_RE = /^standalone\.([a-z_]+)$/;
40
+ const STANDALONE_FIELDS = [
41
+ 'platform',
42
+ 'release',
43
+ 'database',
44
+ 'deployment',
45
+ 'ide',
46
+ 'abap_cloud_only',
47
+ 'notes',
48
+ ];
36
49
  /**
37
50
  * Coerce a user-typed on/off-style string to a boolean. Accepts the common
38
51
  * spellings a developer expects from a CLI toggle (`on`/`true`/`1` → true,
@@ -93,9 +106,10 @@ const VIRTUAL_KEYCHAIN_KEYS = new Set(['llm.byok_key', 'llm.ai_hub_token']);
93
106
  * the deprecated `local_files` alias — the canonical name is `local_build`).
94
107
  */
95
108
  function unsettableKeyList() {
96
- return [...MUTABLE_KEYS]
97
- .filter((k) => !VIRTUAL_KEYCHAIN_KEYS.has(k) && k !== 'local_files')
98
- .join(', ');
109
+ return [
110
+ ...[...MUTABLE_KEYS].filter((k) => !VIRTUAL_KEYCHAIN_KEYS.has(k) && k !== 'local_files'),
111
+ ...UNSET_ONLY_KEYS,
112
+ ].join(', ');
99
113
  }
100
114
  /**
101
115
  * Mark a top-level config key as explicitly user-set so sparse serialisation
@@ -114,6 +128,76 @@ export function markExplicit(cfg, key) {
114
128
  if (!cfg.file_keys.includes(top))
115
129
  cfg.file_keys.push(top);
116
130
  }
131
+ /** Human-readable "allowed values" error for a standalone.<field> set. */
132
+ function standaloneValueError(field, value) {
133
+ switch (field) {
134
+ case 'platform':
135
+ return `Invalid standalone.platform "${value}". Allowed: ${STANDALONE_PLATFORMS.join(', ')}`;
136
+ case 'database':
137
+ return `Invalid standalone.database "${value}". Allowed: ${STANDALONE_DATABASES.join(', ')}`;
138
+ case 'deployment':
139
+ return `Invalid standalone.deployment "${value}". Allowed: ${STANDALONE_DEPLOYMENTS.join(', ')}`;
140
+ case 'ide':
141
+ return `Invalid standalone.ide "${value}". Allowed: ${STANDALONE_IDES.join(', ')}`;
142
+ case 'abap_cloud_only':
143
+ return 'standalone.abap_cloud_only must be on/off (also accepts true/false, 1/0).';
144
+ default:
145
+ return `standalone.${field} cannot be empty.`;
146
+ }
147
+ }
148
+ /**
149
+ * Apply one `standalone.<field>` value to the (possibly absent) profile.
150
+ *
151
+ * Setting `platform` on an absent profile seeds the whole table from that
152
+ * platform's derivations, so `config set standalone.platform S/4HANA` alone
153
+ * leaves a complete, consistent profile. Changing the platform later keeps the
154
+ * user's other answers — they said those on purpose.
155
+ */
156
+ function applyStandaloneField(current, field, value) {
157
+ if (!current) {
158
+ // Guarded by the caller: only `platform` reaches here with no profile.
159
+ const platform = value;
160
+ return {
161
+ platform,
162
+ release: defaultRelease(platform),
163
+ database: deriveDatabase(platform) ?? 'HANA',
164
+ deployment: deriveDeployment(platform) ?? 'on-prem',
165
+ ide: 'ADT',
166
+ abap_cloud_only: defaultAbapCloudOnly(platform),
167
+ };
168
+ }
169
+ const next = { ...current };
170
+ switch (field) {
171
+ case 'platform':
172
+ next.platform = value;
173
+ break;
174
+ case 'release':
175
+ next.release = value.trim();
176
+ break;
177
+ case 'database':
178
+ next.database = value;
179
+ break;
180
+ case 'deployment':
181
+ next.deployment = value;
182
+ break;
183
+ case 'ide':
184
+ next.ide = value;
185
+ break;
186
+ case 'abap_cloud_only':
187
+ next.abap_cloud_only = coerceBool(value);
188
+ break;
189
+ case 'notes':
190
+ next.notes = value.trim();
191
+ break;
192
+ }
193
+ return next;
194
+ }
195
+ /**
196
+ * Keys `config unset` accepts that are NOT flat `config set` targets.
197
+ * `standalone` is a table written field-by-field via `standalone.<field>`, but
198
+ * users still need one command to clear the whole profile.
199
+ */
200
+ const UNSET_ONLY_KEYS = new Set(['standalone']);
117
201
  /**
118
202
  * Delete a (possibly dotted, max 2-level) key from a loaded config in place.
119
203
  * Returns true if the key was present and removed, false if it was already
@@ -175,7 +259,7 @@ export async function runConfigUnset(args) {
175
259
  if (key.startsWith('sap.')) {
176
260
  errUnset(`"${key}" is a SAP-scoped key — those are managed per system via \`cspeach config set ${key} <value>\`, not config unset.`);
177
261
  }
178
- if (!MUTABLE_KEYS.has(key)) {
262
+ if (!MUTABLE_KEYS.has(key) && !UNSET_ONLY_KEYS.has(key)) {
179
263
  errUnset(`Unknown key "${key}". Valid keys: ${unsettableKeyList()}`);
180
264
  }
181
265
  const cfg = await loadConfig();
@@ -272,6 +356,30 @@ export async function runConfigSet(args) {
272
356
  console.log(chalk.green(`Set ${key} = ${b}`));
273
357
  return;
274
358
  }
359
+ // Standalone-profile key: standalone.<field>. Mirrors the sap.<alias>.<key>
360
+ // shape — a dotted key handled before the flat MUTABLE_KEYS switch.
361
+ const standaloneMatch = STANDALONE_KEY_RE.exec(key);
362
+ if (standaloneMatch) {
363
+ const field = standaloneMatch[1];
364
+ if (!STANDALONE_FIELDS.includes(field)) {
365
+ err(`Unknown standalone key "${key}". Valid: ${STANDALONE_FIELDS.map((f) => `standalone.${f}`).join(', ')}`);
366
+ }
367
+ if (!validateValue(key, value)) {
368
+ err(standaloneValueError(field, value));
369
+ }
370
+ const cfg = await loadConfig();
371
+ // The platform is load-bearing: every other field's default derives from
372
+ // it. Refuse to invent one so a lone `standalone.release` can never sit
373
+ // under a silently-guessed platform.
374
+ if (!cfg.standalone && field !== 'platform') {
375
+ err(`Set the platform first: cspeach config set standalone.platform <${STANDALONE_PLATFORMS.join(' | ')}>`);
376
+ }
377
+ cfg.standalone = applyStandaloneField(cfg.standalone, field, value);
378
+ markExplicit(cfg, key);
379
+ await saveConfig(cfg);
380
+ console.log(chalk.green(`Set ${key} = ${value}`));
381
+ return;
382
+ }
275
383
  // Virtual keys that store secrets in the OS keychain instead of config.toml.
276
384
  if (key === 'llm.byok_key' || key === 'llm.ai_hub_token') {
277
385
  // Support stdin piping for secrets: `echo "key" | cspeach config set llm.byok_key -`
@@ -384,6 +492,16 @@ export async function runConfigSet(args) {
384
492
  console.log(chalk.dim(' → takes effect on the next `cspeach` launch.'));
385
493
  break;
386
494
  }
495
+ case 'no_sap': {
496
+ // Standalone mode — start without a SAP system (paste-mode skills only).
497
+ const b = coerceBool(value);
498
+ if (b === null) {
499
+ err(`${key} must be on/off (also accepts true/false, 1/0).`);
500
+ }
501
+ cfg.no_sap = b;
502
+ console.log(chalk.dim(' → takes effect on the next `cspeach` launch.'));
503
+ break;
504
+ }
387
505
  case 'write_mode':
388
506
  if (!isValidWriteMode(value)) {
389
507
  err(`Invalid value for write_mode. Must be one of: auto, approval-gated, advisory-only`);
@@ -476,6 +594,21 @@ export function validateValue(key, value) {
476
594
  if (SAP_INSECURE_TLS_RE.test(key)) {
477
595
  return coerceBool(value) !== null;
478
596
  }
597
+ // Standalone-profile keys: standalone.<field>
598
+ const standaloneMatch = STANDALONE_KEY_RE.exec(key);
599
+ if (standaloneMatch) {
600
+ const field = standaloneMatch[1];
601
+ switch (field) {
602
+ case 'platform': return isStandalonePlatform(value);
603
+ case 'database': return STANDALONE_DATABASES.includes(value);
604
+ case 'deployment': return STANDALONE_DEPLOYMENTS.includes(value);
605
+ case 'ide': return STANDALONE_IDES.includes(value);
606
+ case 'abap_cloud_only': return coerceBool(value) !== null;
607
+ case 'release':
608
+ case 'notes': return value.trim().length > 0;
609
+ default: return false;
610
+ }
611
+ }
479
612
  if (!MUTABLE_KEYS.has(key))
480
613
  return false;
481
614
  switch (key) {
@@ -506,6 +639,8 @@ export function validateValue(key, value) {
506
639
  return PLAN_MODE_VALUES.includes(value);
507
640
  case 'plan_audit':
508
641
  return PLAN_AUDIT_VALUES.includes(value);
642
+ case 'no_sap':
643
+ return coerceBool(value) !== null;
509
644
  case 'llm.mode':
510
645
  return LLM_MODE_VALUES.includes(value);
511
646
  case 'llm.ai_hub_base_url':
@@ -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,19 @@ 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),
144
158
  write_mode: isValidWriteMode(parsed.write_mode) ? parsed.write_mode : DEFAULT_CONFIG.write_mode,
145
159
  // plan_mode: whitelist coercion — anything but the two valid literals
146
160
  // (typos, wrong types) collapses to undefined, which callers treat as
@@ -210,6 +224,14 @@ export async function loadConfig() {
210
224
  // callers see a genuine "absent" shape (resolveRenderSmoke treats it as ON).
211
225
  if (merged.render_smoke === undefined)
212
226
  delete merged.render_smoke;
227
+ // Same for `no_sap` and `standalone`: absent-or-invalid must not serialise
228
+ // as `undefined` — drop the keys so `config show` stays clean and callers
229
+ // see a genuine "absent" shape (resolveStandalone treats missing as off,
230
+ // and an absent profile triggers the standalone wizard).
231
+ if (merged.no_sap === undefined)
232
+ delete merged.no_sap;
233
+ if (merged.standalone === undefined)
234
+ delete merged.standalone;
213
235
  // Same for `plan_mode`: absent-or-invalid must not serialise as
214
236
  // `plan_mode = undefined` — drop the key so `config show` stays clean and
215
237
  // callers see a genuine "absent" shape (treated as 'step').
@@ -305,6 +327,18 @@ export function resolveLocalBuild(cfg) {
305
327
  export function resolveRenderSmoke(cfg) {
306
328
  return cfg.render_smoke ?? true;
307
329
  }
330
+ /**
331
+ * Resolve the effective standalone ("no SAP system") mode.
332
+ *
333
+ * effective = --no-sap flag OR config no_sap (default false)
334
+ *
335
+ * The flag can only turn standalone ON for a single run — there is no
336
+ * "--sap" counterpart — so the resolution is an OR, not a precedence chain.
337
+ * `no_sap` is already coerced to `true | false | undefined` by loadConfig.
338
+ */
339
+ export function resolveStandalone(cfg, flagNoSap = false) {
340
+ return flagNoSap === true || cfg.no_sap === true;
341
+ }
308
342
  export async function saveConfig(config) {
309
343
  await fs.mkdir(cspeachRoot(), { recursive: true });
310
344
  // The set of top-level keys the FILE carried (empty when it was absent).
@@ -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,28 @@
1
+ /**
2
+ * Session-scoped cost total — the single source of truth for the statusline
3
+ * `$` segment and the printed status footer's session line.
4
+ *
5
+ * F-L2-2 (2026-08-11): this computation was inlined in
6
+ * `repl/post-turn-status.ts`, the POST-turn chokepoint. That meant the
7
+ * statusline spend only ever moved between turns — during a long turn (the
8
+ * exact moment a user wants to watch the meter) it sat frozen. Extracted
9
+ * here verbatim so the agent loop can call it mid-stream (after each
10
+ * `message_start` / `message_delta` usage mutation) and push the running
11
+ * total to the store.
12
+ *
13
+ * Honest-semantics caveat, carried over unchanged from the original site:
14
+ * the total is priced at `session.model` over LIFETIME `session.usage`. On a
15
+ * session with model-tiered turns (A4 modelOverride) that is a mixed-model
16
+ * approximation — the per-turn cost JSONL records the model that actually
17
+ * ran. Same caveat as the footer's session line.
18
+ */
19
+ import { computeCost } from './pricing.js';
20
+ /** USD cost of the session's lifetime usage, priced at `session.model`. */
21
+ export function computeSessionCost(session) {
22
+ return computeCost(session.model, {
23
+ input: session.usage.input_tokens,
24
+ output: session.usage.output_tokens,
25
+ cacheRead: session.usage.cache_read_input_tokens,
26
+ cacheCreate: session.usage.cache_creation_input_tokens ?? 0,
27
+ });
28
+ }
@@ -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)' };