@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
@@ -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 {
@@ -12,6 +12,7 @@ import { checkWriteMode } from './checks/write-mode.js';
12
12
  import { checkLlmMode } from './checks/llm-mode.js';
13
13
  import { checkForgeRules } from './checks/forge-rules.js';
14
14
  import { checkSystemRoles } from './checks/system-roles.js';
15
+ import { checkStandards } from './checks/standards.js';
15
16
  export async function runDoctor() {
16
17
  console.log(chalk.cyan.bold('\nCSPeach — doctor\n'));
17
18
  const cfg = await loadConfig();
@@ -28,6 +29,7 @@ export async function runDoctor() {
28
29
  checks.push(await checkSkill());
29
30
  checks.push(await checkKeychainFallback());
30
31
  checks.push(await checkForgeRules());
32
+ checks.push(await checkStandards());
31
33
  let failed = 0;
32
34
  for (const c of checks) {
33
35
  const icon = c.skipped
package/dist/one-shot.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import chalk from 'chalk';
2
- import { loadConfig, resolveLocalBuild } from './config/loader.js';
2
+ import { loadConfig, resolveLocalBuild, resolveStandalone } from './config/loader.js';
3
+ import { createOfflineAdtClient } from './sap/offline-adt-client.js';
4
+ import { STANDALONE_ALIAS } from './tools/index.js';
3
5
  import { getStore } from './auth/api-key.js';
4
6
  import { getConnection } from './sap/connection-manager.js';
5
7
  import { AdtClient } from '@cspeach/sap-client';
@@ -50,6 +52,9 @@ import './tools/subagent/schedule_draft_create.js';
50
52
  import './tools/subagent/agent_run.js';
51
53
  export async function runOneShot(prompt, opts) {
52
54
  const cfg = await loadConfig();
55
+ // Standalone (2026-09-16) — run with no SAP system at all: no connection, no
56
+ // AdtClient, SAP tools hidden. `--no-sap` for this run, or `no_sap = true`.
57
+ const standalone = resolveStandalone(cfg, opts?.noSap === true);
53
58
  setRenderingOverride(cfg.ui.rendering);
54
59
  // model-governance step 2d — fire-and-forget fetch of the served model-config
55
60
  // (managed mode only, never blocks). A turn that starts before it lands uses
@@ -70,7 +75,9 @@ export async function runOneShot(prompt, opts) {
70
75
  // says gated/auto — the banner must say so. No role ⇒ effective === config
71
76
  // (byte-identical). Alias absent (no SAP configured) ⇒ error out below,
72
77
  // same as before.
73
- const oneShotAlias = opts?.sapAlias ?? Object.keys(cfg.sap)[0];
78
+ // Standalone has no alias, so no role clamp applies — the configured
79
+ // write_mode stands on its own.
80
+ const oneShotAlias = standalone ? undefined : (opts?.sapAlias ?? Object.keys(cfg.sap)[0]);
74
81
  const oneShotRole = oneShotAlias !== undefined ? cfg.sap[oneShotAlias]?.role : undefined;
75
82
  if (getEffectiveWriteMode(cfg.write_mode, oneShotRole) === 'advisory-only') {
76
83
  console.log(chalk.yellow('⚠ Advisory mode: writes will be proposed, not executed.'));
@@ -91,17 +98,35 @@ export async function runOneShot(prompt, opts) {
91
98
  return 1;
92
99
  }
93
100
  const aliases = Object.keys(cfg.sap);
94
- if (aliases.length === 0) {
95
- console.error('No SAP system configured. Run `cspeach config add-sap` first.');
96
- return 1;
101
+ let alias;
102
+ let adt;
103
+ if (standalone) {
104
+ // No wizard, no picker, no getConnection. The offline stand-in rejects
105
+ // every call with an actionable message if anything slips past the tool
106
+ // filter + dispatch guard.
107
+ alias = STANDALONE_ALIAS;
108
+ adt = createOfflineAdtClient();
109
+ console.log(chalk.dim('Standalone mode: no SAP system connected — skills work from pasted code. ' +
110
+ 'Connect later with: cspeach config add-sap'));
111
+ // One-shot is headless by design (no prompts), so an unanswered standalone
112
+ // profile can't be filled in here — say how, and carry on with "unknown".
113
+ if (!cfg.standalone) {
114
+ console.log(chalk.dim('Standalone profile not set — run: cspeach config set standalone.platform ...'));
115
+ }
97
116
  }
98
- // C.1 — --sap-alias flag override
99
- const alias = opts?.sapAlias ?? aliases[0];
100
- if (opts?.sapAlias) {
101
- console.log(chalk.dim(`Using --sap-alias ${opts.sapAlias}`));
117
+ else {
118
+ if (aliases.length === 0) {
119
+ console.error('No SAP system configured. Run `cspeach config add-sap` first.');
120
+ return 1;
121
+ }
122
+ // C.1 — --sap-alias flag override
123
+ alias = opts?.sapAlias ?? aliases[0];
124
+ if (opts?.sapAlias) {
125
+ console.log(chalk.dim(`Using --sap-alias ${opts.sapAlias}`));
126
+ }
127
+ const conn = await getConnection(alias, cfg.sap[alias]);
128
+ adt = new AdtClient(conn);
102
129
  }
103
- const conn = await getConnection(alias, cfg.sap[alias]);
104
- const adt = new AdtClient(conn);
105
130
  const session = newSession(makeSessionId(), alias, null, resolveModelRole('session_default', cfg));
106
131
  await saveSession(session);
107
132
  const parsed = parseShortcut(prompt);
@@ -7,6 +7,7 @@
7
7
  * ```abap → existing ABAP regex logic (unchanged)
8
8
  * ```cds → cli-highlight CDS grammar (via syntax.ts)
9
9
  * ```bdef → cli-highlight BDEF grammar (via syntax.ts)
10
+ * ```ddl → cli-highlight CDS grammar (ddl is a CDS alias; via syntax.ts)
10
11
  * ```<any other or empty> → pass through (no highlight)
11
12
  *
12
13
  * The function is renamed `applyCodeHighlight`. A deprecation re-export
@@ -64,7 +65,7 @@ export function applyCodeHighlight(text) {
64
65
  // dependency for the hottest fenced-block type.
65
66
  out.push(highlightAbapBlock(body));
66
67
  }
67
- else if (lang === 'cds' || lang === 'bdef') {
68
+ else if (lang === 'cds' || lang === 'bdef' || lang === 'ddl') {
68
69
  // cli-highlight grammars via the Task 5 dispatcher.
69
70
  out.push(highlightCodeBlock(body, lang));
70
71
  }
@@ -47,7 +47,13 @@ const hljs = createRequire(require.resolve('cli-highlight'))('highlight.js');
47
47
  hljs.registerLanguage('abap', () => abapGrammar);
48
48
  hljs.registerLanguage('cds', () => cdsGrammar);
49
49
  hljs.registerLanguage('bdef', () => bdefGrammar);
50
- const SUPPORTED = new Set(['abap', 'cds', 'bdef']);
50
+ // `ddl` is an alias of the CDS grammar: ```ddl fences carry CDS DDL source
51
+ // (the abap-rap templates emit them). Registering it on the same hljs
52
+ // instance also silences marked-terminal's "Could not find the language
53
+ // 'ddl'" warning, since marked-terminal calls cli-highlight directly with
54
+ // the raw fence lang and never goes through highlightCodeBlock().
55
+ hljs.registerLanguage('ddl', () => cdsGrammar);
56
+ const SUPPORTED = new Set(['abap', 'cds', 'bdef', 'ddl']);
51
57
  /**
52
58
  * Highlight a code block body (without fence markers).
53
59
  *
@@ -15,12 +15,23 @@
15
15
  * Module-scope singleton per CLI process. Tests must call clearCurrentTransport()
16
16
  * in beforeEach to avoid leakage across Vitest test files.
17
17
  *
18
- * Task 9 (ux-wave1) — this setter is ALSO the single statusline wire for the
18
+ * Task 9 (ux-wave1) — this setter is ALSO a statusline wire for the
19
19
  * `tr <TRKORR>` segment: every path that changes the session transport
20
20
  * (sap_transport create success, owning-transport adoption in the create
21
21
  * guard, /transport slash command) funnels through set/clear here, so the
22
22
  * globalStore push lives HERE rather than scattered across tool handlers.
23
23
  * Store writes are harmless in classic mode (nothing subscribes there).
24
+ *
25
+ * F-L2-1 (2026-08-11) — that wire alone is not enough, so this module now
26
+ * carries a SECOND, write-side wire: noteWriteTransport(). The session flow
27
+ * above is the only writer of `store.transport`, but a per-write `corrNr`
28
+ * coming from a prompt or a plan never travels through it — live evidence: a
29
+ * whole session's writes stamped S4HK903635 while the statusline read
30
+ * `tr none`. noteWriteTransport records what a write ACTUALLY stamped into a
31
+ * SEPARATE store field (`lastWriteTransport`) that the statusline falls back
32
+ * to. It deliberately does NOT touch the session pointer below: that pointer
33
+ * feeds the 3-tier write-transport fallback, and moving it would change write
34
+ * behaviour, not just the display.
24
35
  */
25
36
  import { globalStore } from '../ui/sap-state-store.js';
26
37
  let currentTransport = null;
@@ -35,6 +46,17 @@ export function clearCurrentTransport() {
35
46
  currentTransport = null;
36
47
  globalStore.update({ transport: null });
37
48
  }
49
+ /**
50
+ * Statusline-only wire (F-L2-1): record the transport actually stamped on the
51
+ * last successful write. Never touches the session transport pointer — see the
52
+ * module doc. No-op on a falsy trkorr (local / $TMP objects write without one,
53
+ * and blanking the display there would lose the last real datapoint).
54
+ */
55
+ export function noteWriteTransport(trkorr) {
56
+ if (!trkorr)
57
+ return;
58
+ globalStore.update({ lastWriteTransport: trkorr });
59
+ }
38
60
  /**
39
61
  * Handles the `/transport [<trkorr>|clear]` slash command. Returns user-facing
40
62
  * status text. Mirrors the shape of handleSafetyModeCommand in safety-mode-state.ts.
@@ -18,6 +18,7 @@
18
18
  * primary per-turn status surface.
19
19
  */
20
20
  import { computeCost } from '../cost/pricing.js';
21
+ import { computeSessionCost } from '../cost/session-cost.js';
21
22
  import { globalStore } from '../ui/sap-state-store.js';
22
23
  import { printStatusFooter, formatStatusFooter } from '../renderer/status-footer.js';
23
24
  import { shouldUseInk } from '../renderer/tty.js';
@@ -43,12 +44,9 @@ export function pushPostTurnStatus(params) {
43
44
  cacheCreate: (session.usage.cache_creation_input_tokens ?? 0) - snapshot.cacheCreate,
44
45
  };
45
46
  const turnCost = computeCost(turnModel ?? session.model, turnCostTokens);
46
- const sessionCost = computeCost(session.model, {
47
- input: session.usage.input_tokens,
48
- output: session.usage.output_tokens,
49
- cacheRead: session.usage.cache_read_input_tokens,
50
- cacheCreate: session.usage.cache_creation_input_tokens ?? 0,
51
- });
47
+ // F-L2-2: extracted to cost/session-cost.ts so the agent loop can push the
48
+ // SAME total mid-stream. This call site is unchanged in behaviour.
49
+ const sessionCost = computeSessionCost(session);
52
50
  // Task 9 (ux-wave1) — statusline spend wire. Honest semantics note: the
53
51
  // store field is named `dollarSpendToday` (legacy sidebar naming, kept to
54
52
  // avoid rename churn) but the value written is SESSION-scoped — the same
@@ -56,6 +54,11 @@ export function pushPostTurnStatus(params) {
56
54
  // lifetime session.usage at session.model rates (mixed-model turns are an
57
55
  // approximation, same caveat as the footer's session line). There is no
58
56
  // day-spanning ledger; the statusline shows session spend.
57
+ //
58
+ // F-L2-2 (2026-08-11): the agent loop now pushes the same value mid-stream
59
+ // so the segment ticks DURING a turn, not only between turns. This push
60
+ // stays as the authoritative end-of-turn settle (the loop's pushes are
61
+ // gated to REPL turns; this one runs on every path that reaches here).
59
62
  globalStore.update({ dollarSpendToday: sessionCost });
60
63
  const statusPayload = {
61
64
  sessionId: session.id,
package/dist/repl.js CHANGED
@@ -7,7 +7,9 @@ import chalk from 'chalk';
7
7
  // 'node:readline' directly for that specific operation.
8
8
  import * as readline from 'node:readline/promises';
9
9
  import { select, password as promptPassword } from '@inquirer/prompts';
10
- import { loadConfig, resolveLocalBuild } from './config/loader.js';
10
+ import { loadConfig, resolveLocalBuild, resolveStandalone } from './config/loader.js';
11
+ import { createOfflineAdtClient } from './sap/offline-adt-client.js';
12
+ import { STANDALONE_ALIAS } from './tools/index.js';
11
13
  import { getStore } from './auth/api-key.js';
12
14
  import { readAuthFile } from './auth/auth-file.js';
13
15
  import { fetchMe } from './auth/me.js';
@@ -373,7 +375,26 @@ export async function runRepl(opts) {
373
375
  let cfg = await loadConfig();
374
376
  // Apply user-configured rendering override BEFORE any shouldUseInk() call.
375
377
  setRenderingOverride(cfg.ui.rendering);
376
- if (Object.keys(cfg.sap).length === 0) {
378
+ // Standalone (2026-09-16) — run with no SAP system at all. Decided BEFORE
379
+ // the onboarding wizard: someone who asked for --no-sap (or persisted
380
+ // no_sap) must never be pushed through the SAP setup they don't have.
381
+ // Having aliases configured does NOT override the request.
382
+ // A session that WAS standalone stays standalone on `--resume`: its
383
+ // sap_system is the reserved 'none' alias, which has no entry in cfg.sap.
384
+ const standalone = resolveStandalone(cfg, opts?.noSap === true) ||
385
+ opts?.resumedSession?.sap_system === STANDALONE_ALIAS;
386
+ if (standalone) {
387
+ console.log(chalk.dim('\nStandalone mode: no SAP system connected — skills work from pasted code. ' +
388
+ 'Connect later with: cspeach config add-sap'));
389
+ // Ask the profile questions ONCE — they become the <sap_system> element
390
+ // every skill reads, so without them a design would target "unknown".
391
+ if (!cfg.standalone) {
392
+ const { runStandaloneProfileWizard } = await import('./sap/standalone-onboarding.js');
393
+ await runStandaloneProfileWizard({ cwd: process.cwd() });
394
+ cfg = await loadConfig();
395
+ }
396
+ }
397
+ else if (Object.keys(cfg.sap).length === 0) {
377
398
  console.log(chalk.yellow('\nNo SAP systems configured. Starting onboarding wizard.'));
378
399
  await runAddSapWizard();
379
400
  cfg = await loadConfig();
@@ -418,7 +439,12 @@ export async function runRepl(opts) {
418
439
  const aliases = Object.keys(cfg.sap);
419
440
  // C.1 — --sap-alias flag override skips the interactive picker
420
441
  let selectedAlias;
421
- if (opts?.sapAlias) {
442
+ if (standalone) {
443
+ // No picker: there is nothing to pick. The reserved 'none' alias is what
444
+ // isStandalone(ctx) reads downstream.
445
+ selectedAlias = STANDALONE_ALIAS;
446
+ }
447
+ else if (opts?.sapAlias) {
422
448
  console.log(chalk.dim(`Using --sap-alias ${opts.sapAlias}`));
423
449
  selectedAlias = opts.sapAlias;
424
450
  }
@@ -461,8 +487,11 @@ export async function runRepl(opts) {
461
487
  console.log(chalk.gray(`Write mode: ${startupWriteMode}${capped}\n`));
462
488
  }
463
489
  }
464
- const conn = await getConnection(selectedAlias, cfg.sap[selectedAlias]);
465
- const adt = new AdtClient(conn);
490
+ // Standalone: the offline stand-in rejects every call with an actionable
491
+ // message. This is the single construction site the AdtClient cast lives at.
492
+ const adt = standalone
493
+ ? createOfflineAdtClient()
494
+ : new AdtClient(await getConnection(selectedAlias, cfg.sap[selectedAlias]));
466
495
  // Phase A #3 (2026-05-16) — sidebar SAP-state wire, trigger 1 of 4.
467
496
  // Push system + user the moment we know the alias. Before this commit
468
497
  // the sidebar showed "no system / user: —" forever because the store
@@ -475,8 +504,8 @@ export async function runRepl(opts) {
475
504
  // superseded (unmounted) header.tsx — so nothing else loses the host;
476
505
  // the connection banner above already printed it once to scrollback.
477
506
  sapStateStore.update({
478
- system: selectedAlias,
479
- user: cfg.sap[selectedAlias].username,
507
+ system: standalone ? 'standalone' : selectedAlias,
508
+ user: standalone ? '' : cfg.sap[selectedAlias].username,
480
509
  writeMode: startupWriteMode,
481
510
  });
482
511
  // v0.6 — when resuming, adopt the on-disk SessionState verbatim. This
@@ -822,6 +851,7 @@ export async function runRepl(opts) {
822
851
  skill: rr.skill,
823
852
  ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
824
853
  chunkEmitter,
854
+ pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
825
855
  });
826
856
  }
827
857
  finally {
@@ -1254,6 +1284,7 @@ export async function runRepl(opts) {
1254
1284
  skill: skill,
1255
1285
  ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
1256
1286
  chunkEmitter,
1287
+ pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
1257
1288
  signal: currentTurnAbort.signal,
1258
1289
  suppressSaveHook: planResume !== null || planContinue !== null,
1259
1290
  // A4 — undefined on every non-plan turn (and when tiering is off).
@@ -2270,6 +2301,7 @@ export async function runRepl(opts) {
2270
2301
  userMessage: rr.prompt,
2271
2302
  skill: rr.skill,
2272
2303
  ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
2304
+ pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
2273
2305
  });
2274
2306
  continue;
2275
2307
  }
@@ -2797,6 +2829,7 @@ export async function runRepl(opts) {
2797
2829
  userMessage: prompt,
2798
2830
  skill: skill,
2799
2831
  ctx: { adt, sapAlias: selectedAlias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: replPreviewHook, chunkEmitter: classicOutputEmitter, todoEmitter, currentTransport: currentTransportAccessor, pendingDispatch: pendingDispatchAccessor },
2832
+ pushSpend: true, // F-L2-2 — top-level REPL turn: live statusline spend
2800
2833
  signal: turnAbort.signal,
2801
2834
  suppressSaveHook: planResume !== null || planContinue !== null,
2802
2835
  // A4 — undefined on every non-plan turn (and when tiering is off).
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Offline AdtClient stand-in for standalone mode (`--no-sap` / `no_sap = true`).
3
+ *
4
+ * `ToolContext.adt` is typed as a live `AdtClient` and read unconditionally at
5
+ * many call sites. Rather than widen that type across the codebase (and force
6
+ * every handler to grow a null-check), standalone injects a Proxy whose every
7
+ * method call returns a rejected Promise carrying `CspeachNotConnectedError`.
8
+ *
9
+ * The model should never get here — SAP-dependent tools are filtered out of the
10
+ * tool list (`requiresSap`, tools/index.ts) and blocked again at dispatch
11
+ * (agent/tool-dispatch.ts). This is the third, last-resort layer: if anything
12
+ * slips through it fails with an actionable message instead of a TypeError.
13
+ */
14
+ export const NOT_CONNECTED_MESSAGE = 'No SAP system connected (standalone mode). Paste the ABAP source or describe the object, or run: cspeach config add-sap';
15
+ export class CspeachNotConnectedError extends Error {
16
+ constructor(message = NOT_CONNECTED_MESSAGE) {
17
+ super(message);
18
+ this.name = 'CspeachNotConnectedError';
19
+ }
20
+ }
21
+ /**
22
+ * Property names that must resolve to `undefined` rather than to a callable.
23
+ *
24
+ * `then` is the important one: returning a function for it would make the proxy
25
+ * a thenable, so `await client` (or any `Promise.resolve(client)`) would invoke
26
+ * it as a resolver and never settle. The rest are inspection / serialisation
27
+ * hooks that Node, vitest and JSON.stringify probe on arbitrary objects.
28
+ */
29
+ const NON_CALLABLE = new Set([
30
+ 'then',
31
+ 'catch',
32
+ 'finally',
33
+ 'toJSON',
34
+ 'constructor',
35
+ 'inspect',
36
+ 'nodeType',
37
+ '$$typeof',
38
+ ]);
39
+ /**
40
+ * Build the standalone stand-in. Cast to `AdtClient` at the single construction
41
+ * site in the caller — the shape is intentionally structural, not a subclass,
42
+ * so a new sap-client method needs no change here.
43
+ */
44
+ export function createOfflineAdtClient() {
45
+ const target = {};
46
+ return new Proxy(target, {
47
+ get(_t, prop) {
48
+ if (typeof prop === 'symbol')
49
+ return undefined;
50
+ if (NON_CALLABLE.has(prop))
51
+ return undefined;
52
+ return (..._args) => Promise.reject(new CspeachNotConnectedError());
53
+ },
54
+ has() {
55
+ return true;
56
+ },
57
+ });
58
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Standalone profile wizard — asked ONCE the first time CSPeach starts without
3
+ * a SAP system (`--no-sap` / `no_sap = true`), and re-runnable via
4
+ * `cspeach config standalone`.
5
+ *
6
+ * With nothing to probe, these answers ARE the system context: they become the
7
+ * `<sap_system …/>` element every skill reads (see standalone-profile.ts), so a
8
+ * generated design targets the customer's real release instead of guessing.
9
+ *
10
+ * Prompts are injected so the flow is unit-testable without a TTY; the default
11
+ * implementation uses the same `@inquirer/prompts` primitives as the SAP
12
+ * onboarding wizard.
13
+ */
14
+ import chalk from 'chalk';
15
+ import { loadConfig, saveConfig } from '../config/loader.js';
16
+ import { runStandardsInit } from '../standards/standards-init.js';
17
+ import { STANDALONE_PLATFORMS, STANDALONE_DATABASES, STANDALONE_DEPLOYMENTS, STANDALONE_IDES, deriveDatabase, deriveDeployment, defaultAbapCloudOnly, defaultRelease, releaseExamples, } from './standalone-profile.js';
18
+ async function defaultPrompts() {
19
+ const { input, select, confirm } = await import('@inquirer/prompts');
20
+ return {
21
+ select: (o) => select({ message: o.message, choices: o.choices, default: o.default }),
22
+ input: (o) => input({ message: o.message, default: o.default }),
23
+ confirm: (o) => confirm({ message: o.message, default: o.default }),
24
+ };
25
+ }
26
+ /**
27
+ * Ask the standalone profile questions, persist `[standalone]`, and return the
28
+ * profile. Questions the platform already answers (database on any S/4HANA or
29
+ * BTP system, deployment on the cloud editions) are derived, not asked.
30
+ */
31
+ export async function runStandaloneProfileWizard(opts = {}) {
32
+ const cwd = opts.cwd ?? process.cwd();
33
+ const p = opts.prompts ?? (await defaultPrompts());
34
+ console.log(chalk.cyan.bold('\nCSPeach — standalone setup\n'));
35
+ console.log(chalk.dim('No SAP system is connected, so tell CSPeach what system your code will land on.\n'));
36
+ // 1 — platform
37
+ const platform = (await p.select({
38
+ key: 'platform',
39
+ message: 'Which SAP platform do you develop for?',
40
+ choices: STANDALONE_PLATFORMS.map((v) => ({ value: v, name: v })),
41
+ default: 'S/4HANA',
42
+ }));
43
+ // 2 — release (free text, platform-appropriate examples + default)
44
+ const release = (await p.input({
45
+ key: 'release',
46
+ message: `Release (e.g. ${releaseExamples(platform)}):`,
47
+ default: defaultRelease(platform),
48
+ })).trim() || defaultRelease(platform);
49
+ // 3 — database (only ECC can be something other than HANA)
50
+ const derivedDb = deriveDatabase(platform);
51
+ const database = (derivedDb ??
52
+ (await p.select({
53
+ key: 'database',
54
+ message: 'Database:',
55
+ choices: STANDALONE_DATABASES.map((v) => ({ value: v, name: v })),
56
+ default: 'HANA',
57
+ })));
58
+ // 4 — deployment (derived for the cloud editions)
59
+ const derivedDeployment = deriveDeployment(platform);
60
+ const deployment = (derivedDeployment ??
61
+ (await p.select({
62
+ key: 'deployment',
63
+ message: 'Where does it run?',
64
+ choices: [
65
+ { value: 'on-prem', name: 'on-prem' },
66
+ { value: 'private-cloud', name: 'private-cloud (hosted / RISE private edition)' },
67
+ ].filter((c) => STANDALONE_DEPLOYMENTS.includes(c.value)),
68
+ default: 'on-prem',
69
+ })));
70
+ // 5 — IDE the team uses (drives where a Manual Implementation Guide points)
71
+ const ide = (await p.select({
72
+ key: 'ide',
73
+ message: 'Which IDE does the team use?',
74
+ choices: STANDALONE_IDES.map((v) => ({
75
+ value: v,
76
+ name: v === 'both' ? 'both ADT and SE80' : v,
77
+ })),
78
+ default: 'ADT',
79
+ }));
80
+ // 6 — clean-core requirement
81
+ const abapCloudOnly = await p.confirm({
82
+ key: 'abap_cloud_only',
83
+ message: 'Must new code be ABAP Cloud / clean-core compliant?',
84
+ default: defaultAbapCloudOnly(platform),
85
+ });
86
+ // 7 — optional one-liner
87
+ const notes = (await p.input({
88
+ key: 'notes',
89
+ message: 'Anything else in one line (e.g. "no BTP", "transports via ChaRM")? Enter to skip:',
90
+ default: '',
91
+ })).trim();
92
+ const profile = {
93
+ platform,
94
+ release,
95
+ database,
96
+ deployment,
97
+ ide,
98
+ abap_cloud_only: abapCloudOnly,
99
+ };
100
+ if (notes.length > 0)
101
+ profile.notes = notes;
102
+ const cfg = await loadConfig();
103
+ cfg.standalone = profile;
104
+ // Mark the table explicit so sparse serialisation keeps it.
105
+ if (!Array.isArray(cfg.file_keys))
106
+ cfg.file_keys = [];
107
+ if (!cfg.file_keys.includes('standalone'))
108
+ cfg.file_keys.push('standalone');
109
+ await saveConfig(cfg);
110
+ console.log(chalk.green(`\n✓ Standalone profile saved: ${platform} ${release} (${deployment}, ${ide}` +
111
+ `${abapCloudOnly ? ', ABAP Cloud only' : ''})`));
112
+ console.log(chalk.dim(' Change it any time: cspeach config set standalone.<key> <value>\n'));
113
+ // 8 — development standards (the last question; also `cspeach standards init`)
114
+ if (!opts.skipStandards) {
115
+ await runStandardsInit(cwd, p);
116
+ }
117
+ return profile;
118
+ }