@cspeach/cli 1.1.2 → 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.
@@ -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);
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
@@ -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
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Standalone-mode SAP profile — the "what system would this land on?" facts a
3
+ * prospect gives us ONCE when they run CSPeach without a SAP connection
4
+ * (`--no-sap` / `no_sap = true`).
5
+ *
6
+ * This module is PURE: types, whitelists, derivations and rendering only. The
7
+ * interactive wizard lives in `standalone-onboarding.ts` so tests never pull in
8
+ * `@inquirer/prompts`, and `config/loader.ts` can import the shape + sanitiser
9
+ * without a prompt dependency.
10
+ *
11
+ * The rendered element deliberately reuses the CONNECTED `<sap_system …/>`
12
+ * shape (see sap/system-info.ts `renderSapSystemBlock`) so skills read one
13
+ * element in both modes and need no change.
14
+ */
15
+ export const STANDALONE_PLATFORMS = [
16
+ 'ECC',
17
+ 'S/4HANA',
18
+ 'S/4HANA Cloud Private',
19
+ 'S/4HANA Cloud Public',
20
+ 'BTP ABAP Environment',
21
+ ];
22
+ export const STANDALONE_DATABASES = ['HANA', 'other'];
23
+ export const STANDALONE_DEPLOYMENTS = ['on-prem', 'private-cloud', 'public-cloud'];
24
+ export const STANDALONE_IDES = ['ADT', 'SE80', 'both'];
25
+ /** The three platforms that are "cloud" for defaulting purposes. */
26
+ const CLOUD_PLATFORMS = new Set([
27
+ 'S/4HANA Cloud Private',
28
+ 'S/4HANA Cloud Public',
29
+ 'BTP ABAP Environment',
30
+ ]);
31
+ export function isStandalonePlatform(v) {
32
+ return typeof v === 'string' && STANDALONE_PLATFORMS.includes(v);
33
+ }
34
+ /**
35
+ * Deployment implied by the platform. `null` means "the platform doesn't decide
36
+ * it — ask the user" (ECC and on-prem S/4HANA can both be on-prem or hosted in
37
+ * a private cloud).
38
+ */
39
+ export function deriveDeployment(platform) {
40
+ switch (platform) {
41
+ case 'S/4HANA Cloud Public':
42
+ case 'BTP ABAP Environment':
43
+ return 'public-cloud';
44
+ case 'S/4HANA Cloud Private':
45
+ return 'private-cloud';
46
+ default:
47
+ return null;
48
+ }
49
+ }
50
+ /**
51
+ * Database implied by the platform. S/4HANA (any edition) and BTP ABAP are
52
+ * HANA by definition; only ECC can sit on something else. `null` = ask.
53
+ */
54
+ export function deriveDatabase(platform) {
55
+ return platform === 'ECC' ? null : 'HANA';
56
+ }
57
+ /** Default answer to "must new code be ABAP Cloud / clean-core compliant?". */
58
+ export function defaultAbapCloudOnly(platform) {
59
+ return CLOUD_PLATFORMS.has(platform);
60
+ }
61
+ /** Release examples shown in the wizard prompt, per platform. */
62
+ export function releaseExamples(platform) {
63
+ switch (platform) {
64
+ case 'ECC':
65
+ return '6.0 EhP8 / NW 7.50';
66
+ case 'S/4HANA':
67
+ return '2023 FPS01 / 2022 / 2021 / 1909';
68
+ case 'S/4HANA Cloud Private':
69
+ return '2023 FPS01 / 2022 / current';
70
+ default:
71
+ return 'current';
72
+ }
73
+ }
74
+ /** Pre-filled answer for the release prompt, per platform. */
75
+ export function defaultRelease(platform) {
76
+ switch (platform) {
77
+ case 'ECC':
78
+ return '6.0 EhP8';
79
+ case 'S/4HANA':
80
+ case 'S/4HANA Cloud Private':
81
+ return '2023';
82
+ default:
83
+ return 'current';
84
+ }
85
+ }
86
+ /** S/4HANA release year → SAP_BASIS / ABAP language version. */
87
+ const S4_YEAR_TO_ABAP = {
88
+ '1909': '7.54',
89
+ '2020': '7.55',
90
+ '2021': '7.56',
91
+ '2022': '7.57',
92
+ '2023': '7.58',
93
+ '2025': '7.59',
94
+ };
95
+ function releaseYear(release) {
96
+ for (const year of Object.keys(S4_YEAR_TO_ABAP)) {
97
+ // Word-boundary match so "2023 FPS01" and "S/4HANA 2022 FPS02" both land.
98
+ if (new RegExp(`(^|\\D)${year}(\\D|$)`).test(release))
99
+ return year;
100
+ }
101
+ return undefined;
102
+ }
103
+ /**
104
+ * Map platform + release to the `abap_version` attribute skills already read
105
+ * from the connected `<sap_system>` element.
106
+ *
107
+ * Note on S/4HANA Cloud Private: it runs the SAME on-prem code line, so when
108
+ * the user states a release year we report the real ABAP version (2023 → 7.58)
109
+ * instead of the generic "ABAP Cloud" marker. Only a year-less answer
110
+ * ("current") falls back to "ABAP Cloud".
111
+ */
112
+ export function mapAbapVersion(platform, release) {
113
+ switch (platform) {
114
+ case 'ECC':
115
+ return '7.50 (classic ABAP, no ABAP Cloud)';
116
+ case 'S/4HANA': {
117
+ const y = releaseYear(release);
118
+ return y ? S4_YEAR_TO_ABAP[y] : 'unknown';
119
+ }
120
+ case 'S/4HANA Cloud Private': {
121
+ const y = releaseYear(release);
122
+ return y ? S4_YEAR_TO_ABAP[y] : 'ABAP Cloud';
123
+ }
124
+ default:
125
+ return 'ABAP Cloud';
126
+ }
127
+ }
128
+ /** Map the profile's deployment onto the existing `SapDeployment` values. */
129
+ export function mapSapDeployment(deployment) {
130
+ return deployment === 'public-cloud' ? 'public-cloud' : 'on-prem-or-private-cloud';
131
+ }
132
+ /**
133
+ * Coerce a possibly-hand-edited `[standalone]` TOML table into a valid profile.
134
+ *
135
+ * The platform is load-bearing (every other default derives from it), so an
136
+ * unrecognised platform drops the WHOLE table — better a clean "profile not
137
+ * set" than a confident wrong release rendered into every prompt. Individual
138
+ * bad sub-fields are repaired from the platform defaults.
139
+ */
140
+ export function sanitiseStandaloneProfile(raw) {
141
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
142
+ return undefined;
143
+ const r = raw;
144
+ if (!isStandalonePlatform(r.platform))
145
+ return undefined;
146
+ const platform = r.platform;
147
+ const release = typeof r.release === 'string' && r.release.trim().length > 0
148
+ ? r.release.trim()
149
+ : defaultRelease(platform);
150
+ const database = typeof r.database === 'string' && STANDALONE_DATABASES.includes(r.database)
151
+ ? r.database
152
+ : (deriveDatabase(platform) ?? 'HANA');
153
+ const deployment = typeof r.deployment === 'string' && STANDALONE_DEPLOYMENTS.includes(r.deployment)
154
+ ? r.deployment
155
+ : (deriveDeployment(platform) ?? 'on-prem');
156
+ const ide = typeof r.ide === 'string' && STANDALONE_IDES.includes(r.ide)
157
+ ? r.ide
158
+ : 'ADT';
159
+ const profile = {
160
+ platform,
161
+ release,
162
+ database,
163
+ deployment,
164
+ ide,
165
+ abap_cloud_only: typeof r.abap_cloud_only === 'boolean' ? r.abap_cloud_only : defaultAbapCloudOnly(platform),
166
+ };
167
+ if (typeof r.notes === 'string' && r.notes.trim().length > 0)
168
+ profile.notes = r.notes.trim();
169
+ return profile;
170
+ }
171
+ function escapeAttr(s) {
172
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
173
+ }
174
+ /** Hard cap on the free-text `notes` attribute, so one long line can't bloat every turn. */
175
+ export const STANDALONE_NOTES_MAX_CHARS = 200;
176
+ /**
177
+ * Render the standalone `<sap_system …/>` element — the SAME element name the
178
+ * connected path emits, with `connected="false" mode="standalone"` plus the
179
+ * three extra facts the profile knows (database / ide / abap_cloud_only).
180
+ *
181
+ * With no profile every fact renders as "unknown" rather than being omitted:
182
+ * a uniform attribute set is easier for a skill to read than a shape that
183
+ * changes depending on what the user answered.
184
+ */
185
+ export function renderStandaloneSapSystemBlock(profile) {
186
+ const platform = profile ? profile.platform : 'unknown';
187
+ const release = profile ? profile.release : 'unknown';
188
+ const abapVersion = profile ? mapAbapVersion(profile.platform, profile.release) : 'unknown';
189
+ const deployment = profile ? mapSapDeployment(profile.deployment) : 'unknown';
190
+ const database = profile ? profile.database : 'unknown';
191
+ const ide = profile ? profile.ide : 'unknown';
192
+ const cloudOnly = profile ? String(profile.abap_cloud_only ?? defaultAbapCloudOnly(profile.platform)) : 'unknown';
193
+ const attrs = [
194
+ `platform="${escapeAttr(platform)}"`,
195
+ `release="${escapeAttr(release)}"`,
196
+ `abap_version="${escapeAttr(abapVersion)}"`,
197
+ `deployment="${escapeAttr(deployment)}"`,
198
+ `connected="false"`,
199
+ `mode="standalone"`,
200
+ `database="${escapeAttr(database)}"`,
201
+ `ide="${escapeAttr(ide)}"`,
202
+ `abap_cloud_only="${escapeAttr(cloudOnly)}"`,
203
+ ];
204
+ // `notes` is the user's own free-text constraint ("no BTP", "transports via
205
+ // ChaRM") — worth giving the model, but only when they actually answered:
206
+ // the attribute is OMITTED entirely rather than rendered empty/"unknown".
207
+ const notes = profile?.notes?.trim();
208
+ if (notes) {
209
+ attrs.push(`notes="${escapeAttr(notes.slice(0, STANDALONE_NOTES_MAX_CHARS))}"`);
210
+ }
211
+ return `<sap_system ${attrs.join(' ')}/>`;
212
+ }
213
+ /**
214
+ * The manual-implementation hand-off rule. Standalone cannot create or activate
215
+ * anything, so every would-be write has to end as instructions the developer
216
+ * can follow by hand — on disk when the file tools are available, otherwise in
217
+ * the reply — plus the rule for repairing a single failed step.
218
+ */
219
+ export const STANDALONE_NOTE = '<standalone_note>No SAP system is connected (standalone mode). Do not call SAP tools. ' +
220
+ 'Work from code the user pastes or describes; when a task needs system data (source, table definitions, ATC output, dumps), ask the user to paste it. ' +
221
+ 'Target the platform and release in sap_system exactly — use only syntax and APIs available on that release. ' +
222
+ 'Because nothing can be created or activated here, every task that would normally write to SAP must instead end with a "Manual Implementation Guide": ' +
223
+ 'numbered steps in dependency order, one object per step, each stating the object type and name, where to create it (ADT wizard path, or SE80/SE11/SE24/SE38 transaction for SE80 teams), ' +
224
+ 'the exact source to paste, the activation step, and any prerequisite (package, transport, number range, message class). ' +
225
+ 'Finish with a short verification checklist (syntax check, activation, ATC, unit test) the user can run themselves.\n' +
226
+ 'File outputs: when file tools are available (local build is on), also write every produced object to the working folder as ./cspeach-out/<yyyy-mm-dd>-<short-task-name>/NN-<OBJECT_NAME>.<abap|ddls|bdef|srvd|srvb|ddlx|dcls|txt> in dependency order, and write the Manual Implementation Guide to GUIDE.md in the same folder, with each step pointing at its file. Tell the user the folder path once. If file tools are not available, say so once and keep everything in the reply.\n' +
227
+ 'Step failures: when the user reports a failure at a step ("step 3 failed" plus an error message or screenshot), ask only for what is missing to diagnose it, fix only the affected object, rewrite its file, re-issue that step and any step that depends on it, and state which steps are unchanged. Never restart the whole guide.</standalone_note>';
228
+ /**
229
+ * The full standalone contribution to `<session_context>`: the sap_system
230
+ * element followed by the hand-off note.
231
+ *
232
+ * `formatFactBlock` (router/intent-extractor.ts) indents only the FIRST line of
233
+ * the block it is handed, so every following line carries its own two spaces.
234
+ */
235
+ export function renderStandaloneContextBlock(profile) {
236
+ return `${renderStandaloneSapSystemBlock(profile)}\n ${STANDALONE_NOTE}`;
237
+ }