@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
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';
@@ -17,6 +19,7 @@ import { createClassicOutputEmitter } from './renderer/notices.js';
17
19
  import { todoEmitter } from './ui/todo-emitter.js';
18
20
  import { applyLocalBuildConfig } from './tools/local-build.js';
19
21
  import { getEffectiveWriteMode } from './repl/mode-cycle.js';
22
+ import { initCredits } from './cost/credits-wire.js';
20
23
  // Side-effect imports for tool registration.
21
24
  import './tools/sap-read.js';
22
25
  import './tools/sap-write.js';
@@ -50,6 +53,9 @@ import './tools/subagent/schedule_draft_create.js';
50
53
  import './tools/subagent/agent_run.js';
51
54
  export async function runOneShot(prompt, opts) {
52
55
  const cfg = await loadConfig();
56
+ // Standalone (2026-09-16) — run with no SAP system at all: no connection, no
57
+ // AdtClient, SAP tools hidden. `--no-sap` for this run, or `no_sap = true`.
58
+ const standalone = resolveStandalone(cfg, opts?.noSap === true);
53
59
  setRenderingOverride(cfg.ui.rendering);
54
60
  // model-governance step 2d — fire-and-forget fetch of the served model-config
55
61
  // (managed mode only, never blocks). A turn that starts before it lands uses
@@ -70,7 +76,9 @@ export async function runOneShot(prompt, opts) {
70
76
  // says gated/auto — the banner must say so. No role ⇒ effective === config
71
77
  // (byte-identical). Alias absent (no SAP configured) ⇒ error out below,
72
78
  // same as before.
73
- const oneShotAlias = opts?.sapAlias ?? Object.keys(cfg.sap)[0];
79
+ // Standalone has no alias, so no role clamp applies — the configured
80
+ // write_mode stands on its own.
81
+ const oneShotAlias = standalone ? undefined : (opts?.sapAlias ?? Object.keys(cfg.sap)[0]);
74
82
  const oneShotRole = oneShotAlias !== undefined ? cfg.sap[oneShotAlias]?.role : undefined;
75
83
  if (getEffectiveWriteMode(cfg.write_mode, oneShotRole) === 'advisory-only') {
76
84
  console.log(chalk.yellow('⚠ Advisory mode: writes will be proposed, not executed.'));
@@ -90,18 +98,42 @@ export async function runOneShot(prompt, opts) {
90
98
  console.error('No API key configured. Run `cspeach` (interactive) first to set one.');
91
99
  return 1;
92
100
  }
101
+ // Credits (2026-09-16) — resolve the display mode before the turn runs, so
102
+ // the per-turn cost JSONL records the ledger charge and nothing downstream
103
+ // can print a dollar to a managed customer. One-shot prints no cost figure
104
+ // of its own today, but the agent loop's cost-log path is shared with the
105
+ // REPL and must not diverge. Capped + fail-safe; BYOK/local skip the call.
106
+ await initCredits(cfg, async () => (await store.get()) ?? '');
93
107
  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;
108
+ let alias;
109
+ let adt;
110
+ if (standalone) {
111
+ // No wizard, no picker, no getConnection. The offline stand-in rejects
112
+ // every call with an actionable message if anything slips past the tool
113
+ // filter + dispatch guard.
114
+ alias = STANDALONE_ALIAS;
115
+ adt = createOfflineAdtClient();
116
+ console.log(chalk.dim('Standalone mode: no SAP system connected — skills work from pasted code. ' +
117
+ 'Connect later with: cspeach config add-sap'));
118
+ // One-shot is headless by design (no prompts), so an unanswered standalone
119
+ // profile can't be filled in here — say how, and carry on with "unknown".
120
+ if (!cfg.standalone) {
121
+ console.log(chalk.dim('Standalone profile not set — run: cspeach config set standalone.platform ...'));
122
+ }
97
123
  }
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}`));
124
+ else {
125
+ if (aliases.length === 0) {
126
+ console.error('No SAP system configured. Run `cspeach config add-sap` first.');
127
+ return 1;
128
+ }
129
+ // C.1 — --sap-alias flag override
130
+ alias = opts?.sapAlias ?? aliases[0];
131
+ if (opts?.sapAlias) {
132
+ console.log(chalk.dim(`Using --sap-alias ${opts.sapAlias}`));
133
+ }
134
+ const conn = await getConnection(alias, cfg.sap[alias]);
135
+ adt = new AdtClient(conn);
102
136
  }
103
- const conn = await getConnection(alias, cfg.sap[alias]);
104
- const adt = new AdtClient(conn);
105
137
  const session = newSession(makeSessionId(), alias, null, resolveModelRole('session_default', cfg));
106
138
  await saveSession(session);
107
139
  const parsed = parseShortcut(prompt);
@@ -1,5 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import { formatCost } from '../cost/pricing.js';
3
+ import { creditsActive, formatCreditsLine, CREDITS_PENDING } from '../cost/credits.js';
3
4
  const RULE_WIDTH = 72;
4
5
  const RULE = chalk.dim('─'.repeat(RULE_WIDTH));
5
6
  /**
@@ -40,15 +41,24 @@ export const LARGE_SESSION_THRESHOLD = 100_000;
40
41
  * direct stdout (which would corrupt the live Ink frame).
41
42
  */
42
43
  export function formatStatusFooter(params) {
43
- const { sessionId, sapAlias, skill, turnCount, tokens, cachedTokens, elapsedMs, costThisTurn, costSession, sessionInputTokens, variant } = params;
44
+ const { sessionId, sapAlias, skill, turnCount, tokens, cachedTokens, elapsedMs, costThisTurn, costSession, sessionInputTokens, variant, credits } = params;
44
45
  const shortSession = sessionId.slice(0, 8);
45
46
  const skillDisplay = skill.startsWith('/') ? skill : `/${skill}`;
46
47
  const turns = `${turnCount} turn${turnCount === 1 ? '' : 's'}`;
47
48
  const tokStr = tokens.toLocaleString() + ' tok this turn';
48
49
  const cachedStr = cachedTokens > 0 ? `${cachedTokens.toLocaleString()} cached` : '';
49
50
  const elapsed = elapsedMs > 0 ? `${(elapsedMs / 1000).toFixed(1)}s` : '';
50
- const costTurnStr = costThisTurn != null ? formatCost(costThisTurn) : '';
51
- const costSessStr = costSession != null ? `session: ${formatCost(costSession)}` : '';
51
+ // Credits mode: the whole spend story collapses into ONE customer-facing
52
+ // segment (turn · session · balance) in place of the two dollar segments.
53
+ // Both variants read `costTurnStr`, so putting the combined line there
54
+ // covers the slim Ink receipt and the full classic receipt alike.
55
+ const inCredits = creditsActive();
56
+ const costTurnStr = inCredits
57
+ ? (credits ? formatCreditsLine(credits) : CREDITS_PENDING)
58
+ : (costThisTurn != null ? formatCost(costThisTurn) : '');
59
+ const costSessStr = inCredits
60
+ ? ''
61
+ : (costSession != null ? `session: ${formatCost(costSession)}` : '');
52
62
  // Ink turn-receipt (see the variant doc on StatusFooterParams):
53
63
  // turn-only facts — everything session-scoped lives in <StatusLine />.
54
64
  const parts = variant === 'turn'
@@ -20,6 +20,7 @@
20
20
  import { computeCost } from '../cost/pricing.js';
21
21
  import { computeSessionCost } from '../cost/session-cost.js';
22
22
  import { globalStore } from '../ui/sap-state-store.js';
23
+ import { globalCredits } from '../cost/credits.js';
23
24
  import { printStatusFooter, formatStatusFooter } from '../renderer/status-footer.js';
24
25
  import { shouldUseInk } from '../renderer/tty.js';
25
26
  export function snapshotTurnStart(session) {
@@ -71,6 +72,10 @@ export function pushPostTurnStatus(params) {
71
72
  costThisTurn: turnCost,
72
73
  costSession: sessionCost,
73
74
  sessionInputTokens: session.usage.input_tokens,
75
+ // Credits (2026-09-16): the agent loop already sampled the post-turn
76
+ // balance, so this snapshot is fresh. formatStatusFooter ignores it
77
+ // entirely in USD mode — the dollar receipt stays byte-identical.
78
+ credits: globalCredits.snapshot(),
74
79
  };
75
80
  if (shouldUseInk() && chunkEmitter) {
76
81
  // Push through chunkEmitter so the line lands in Body's <Static>
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The `spend:` row of the /exit session summary (2026-09-16).
3
+ *
4
+ * Extracted from repl.tsx's printGoodbye so the credits/USD switch has one
5
+ * testable home. USD output is byte-identical to the line printed before
6
+ * credits existed: ` spend: $1.37`.
7
+ */
8
+ import { formatCost } from '../cost/pricing.js';
9
+ import { creditsActive, formatCredits, CREDITS_PENDING } from '../cost/credits.js';
10
+ export function formatSessionSpendLine(sessionCostUsd, credits) {
11
+ if (!creditsActive())
12
+ return ` spend: ${formatCost(sessionCostUsd)}`;
13
+ if (!credits)
14
+ return ` spend: ${CREDITS_PENDING}`;
15
+ const value = formatCredits(credits.sessionTotal, credits.unitLabel);
16
+ const balance = credits.balance !== null
17
+ ? ` · balance ${Math.round(credits.balance).toLocaleString('en-US')}`
18
+ : '';
19
+ return ` spend: ${value}${balance}`;
20
+ }
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';
@@ -20,7 +22,10 @@ import { makeSessionId, saveSession } from './session/store.js';
20
22
  import { newSession } from './session/schema.js';
21
23
  import { resolveModelRole } from './models/resolve.js';
22
24
  import { loadServerModelConfig } from './models/server-config.js';
23
- import { computeCost, formatCost } from './cost/pricing.js';
25
+ import { computeCost } from './cost/pricing.js';
26
+ import { formatSessionSpendLine } from './repl/session-spend-line.js';
27
+ import { globalCredits } from './cost/credits.js';
28
+ import { initCredits } from './cost/credits-wire.js';
24
29
  import { createProviderForMode } from './agent/providers/factory.js';
25
30
  import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
26
31
  import { runTurn } from './agent/loop.js';
@@ -240,7 +245,7 @@ function printGoodbye(session) {
240
245
  console.log(chalk.dim(` turns: ${userTurns}`));
241
246
  console.log(chalk.dim(` last skill: ${session.skill ?? '(none)'}`));
242
247
  console.log(chalk.dim(` tokens: ${totalTokens.toLocaleString()} (input+output+cache)`));
243
- console.log(chalk.dim(` spend: ${formatCost(sessionCost)}`));
248
+ console.log(chalk.dim(formatSessionSpendLine(sessionCost, globalCredits.snapshot())));
244
249
  console.log('');
245
250
  console.log(chalk.cyan(`Resume with: cspeach --resume ${session.id}`));
246
251
  console.log(chalk.dim(`Full log: ~/.cspeach/streams/${session.id}.md`));
@@ -373,7 +378,26 @@ export async function runRepl(opts) {
373
378
  let cfg = await loadConfig();
374
379
  // Apply user-configured rendering override BEFORE any shouldUseInk() call.
375
380
  setRenderingOverride(cfg.ui.rendering);
376
- if (Object.keys(cfg.sap).length === 0) {
381
+ // Standalone (2026-09-16) — run with no SAP system at all. Decided BEFORE
382
+ // the onboarding wizard: someone who asked for --no-sap (or persisted
383
+ // no_sap) must never be pushed through the SAP setup they don't have.
384
+ // Having aliases configured does NOT override the request.
385
+ // A session that WAS standalone stays standalone on `--resume`: its
386
+ // sap_system is the reserved 'none' alias, which has no entry in cfg.sap.
387
+ const standalone = resolveStandalone(cfg, opts?.noSap === true) ||
388
+ opts?.resumedSession?.sap_system === STANDALONE_ALIAS;
389
+ if (standalone) {
390
+ console.log(chalk.dim('\nStandalone mode: no SAP system connected — skills work from pasted code. ' +
391
+ 'Connect later with: cspeach config add-sap'));
392
+ // Ask the profile questions ONCE — they become the <sap_system> element
393
+ // every skill reads, so without them a design would target "unknown".
394
+ if (!cfg.standalone) {
395
+ const { runStandaloneProfileWizard } = await import('./sap/standalone-onboarding.js');
396
+ await runStandaloneProfileWizard({ cwd: process.cwd() });
397
+ cfg = await loadConfig();
398
+ }
399
+ }
400
+ else if (Object.keys(cfg.sap).length === 0) {
377
401
  console.log(chalk.yellow('\nNo SAP systems configured. Starting onboarding wizard.'));
378
402
  await runAddSapWizard();
379
403
  cfg = await loadConfig();
@@ -383,6 +407,13 @@ export async function runRepl(opts) {
383
407
  // fetch lands simply uses the built-ins (fail-safe). Precedence when it does
384
408
  // land: env > local config > server > built-in.
385
409
  void loadServerModelConfig(cfg);
410
+ // Credits (2026-09-16) — resolve ONCE, before any cost figure can render.
411
+ // A managed customer is billed in credits at a multiplier we do not
412
+ // disclose, so the dollar figures (our Anthropic COGS) must never reach
413
+ // their screen. Awaited, but hard-capped at 2.5s inside fetchBalance and
414
+ // fail-safe: any failure leaves the process in USD mode, i.e. exactly the
415
+ // pre-credits behaviour. BYOK/ai-hub/local skip the round-trip entirely.
416
+ await initCredits(cfg, async () => apiKey ?? '');
386
417
  // Task C.10 — surface the current write_mode prominently. advisory-only gets
387
418
  // a bold yellow banner (no-writes is a surprising-enough state that the user
388
419
  // should not miss it). Other modes get a quiet one-liner so the state is
@@ -418,7 +449,12 @@ export async function runRepl(opts) {
418
449
  const aliases = Object.keys(cfg.sap);
419
450
  // C.1 — --sap-alias flag override skips the interactive picker
420
451
  let selectedAlias;
421
- if (opts?.sapAlias) {
452
+ if (standalone) {
453
+ // No picker: there is nothing to pick. The reserved 'none' alias is what
454
+ // isStandalone(ctx) reads downstream.
455
+ selectedAlias = STANDALONE_ALIAS;
456
+ }
457
+ else if (opts?.sapAlias) {
422
458
  console.log(chalk.dim(`Using --sap-alias ${opts.sapAlias}`));
423
459
  selectedAlias = opts.sapAlias;
424
460
  }
@@ -461,8 +497,11 @@ export async function runRepl(opts) {
461
497
  console.log(chalk.gray(`Write mode: ${startupWriteMode}${capped}\n`));
462
498
  }
463
499
  }
464
- const conn = await getConnection(selectedAlias, cfg.sap[selectedAlias]);
465
- const adt = new AdtClient(conn);
500
+ // Standalone: the offline stand-in rejects every call with an actionable
501
+ // message. This is the single construction site the AdtClient cast lives at.
502
+ const adt = standalone
503
+ ? createOfflineAdtClient()
504
+ : new AdtClient(await getConnection(selectedAlias, cfg.sap[selectedAlias]));
466
505
  // Phase A #3 (2026-05-16) — sidebar SAP-state wire, trigger 1 of 4.
467
506
  // Push system + user the moment we know the alias. Before this commit
468
507
  // the sidebar showed "no system / user: —" forever because the store
@@ -475,8 +514,8 @@ export async function runRepl(opts) {
475
514
  // superseded (unmounted) header.tsx — so nothing else loses the host;
476
515
  // the connection banner above already printed it once to scrollback.
477
516
  sapStateStore.update({
478
- system: selectedAlias,
479
- user: cfg.sap[selectedAlias].username,
517
+ system: standalone ? 'standalone' : selectedAlias,
518
+ user: standalone ? '' : cfg.sap[selectedAlias].username,
480
519
  writeMode: startupWriteMode,
481
520
  });
482
521
  // v0.6 — when resuming, adopt the on-disk SessionState verbatim. This
@@ -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
+ }