@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.
- package/dist/agent/loop.js +22 -4
- package/dist/agent/session-context.js +24 -0
- package/dist/agent/tool-dispatch.js +17 -1
- package/dist/cli-args.js +24 -0
- package/dist/cli.js +33 -4
- package/dist/commands/config-set.js +139 -4
- package/dist/config/loader.js +34 -0
- package/dist/cost/pricing.js +4 -0
- package/dist/doctor/checks/_standalone-skip.js +37 -0
- package/dist/doctor/checks/cert.js +5 -0
- package/dist/doctor/checks/forge-rules.js +6 -0
- package/dist/doctor/checks/sap.js +5 -0
- package/dist/doctor/checks/standards.js +27 -0
- package/dist/doctor/checks/system-roles.js +5 -0
- package/dist/doctor/checks/zcspeach.js +5 -0
- package/dist/doctor/run.js +2 -0
- package/dist/one-shot.js +36 -11
- package/dist/repl.js +36 -7
- package/dist/sap/offline-adt-client.js +58 -0
- package/dist/sap/standalone-onboarding.js +118 -0
- package/dist/sap/standalone-profile.js +237 -0
- package/dist/standards/standards-file.js +107 -0
- package/dist/standards/standards-init.js +194 -0
- package/dist/tools/capability/tool.js +2 -0
- package/dist/tools/index.js +25 -0
- package/dist/tools/sap-read.js +18 -0
- package/dist/tools/sap-write.js +14 -0
- package/dist/tools/snapshot.js +3 -0
- package/dist/tools/transport.js +4 -0
- package/package.json +109 -109
package/dist/doctor/run.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
465
|
-
|
|
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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
+
}
|