@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
@@ -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
+ }
@@ -31,6 +31,7 @@
31
31
  // never an invented approval.
32
32
  import { summarise } from '../cost/cost-log.js';
33
33
  import { CLI_VERSION } from '../lib/version.js';
34
+ import { creditsActive, formatCredits } from '../cost/credits.js';
34
35
  /**
35
36
  * Object-mutating tools — one row per completed call in the Writes table.
36
37
  * Kept as a literal name set (not the tool registry) so this pure builder
@@ -319,7 +320,7 @@ function transportsSeen(toolCalls, sessionTransport) {
319
320
  }
320
321
  // ── Main builder ──────────────────────────────────────────────────────────
321
322
  export function buildAuditReport(input) {
322
- const { session, costEntries, transport } = input;
323
+ const { session, costEntries, transport, credits } = input;
323
324
  const calls = session.toolCalls;
324
325
  const completed = calls.filter((c) => c.completed === true);
325
326
  const approvals = parseApprovals(calls);
@@ -437,15 +438,33 @@ export function buildAuditReport(input) {
437
438
  const cost = summarise(costEntries);
438
439
  const auditEntries = costEntries.filter((e) => e.label);
439
440
  const auditCost = Math.round(auditEntries.reduce((s, e) => s + e.cost, 0) * 10000) / 10000;
440
- L(`- **Total:** $${cost.totalCost.toFixed(4)} over ${cost.turns} turn${cost.turns === 1 ? '' : 's'}`);
441
+ const inCredits = creditsActive();
442
+ if (inCredits) {
443
+ const unit = credits?.unitLabel ?? 'credits';
444
+ const logged = costEntries.reduce((s, e) => s + (typeof e.credits_charged === 'number' ? e.credits_charged : 0), 0);
445
+ const total = credits && credits.sessionTotal > 0 ? credits.sessionTotal : logged;
446
+ L(`- **Total:** ${formatCredits(total, unit)} over ${cost.turns} turn${cost.turns === 1 ? '' : 's'}`);
447
+ }
448
+ else {
449
+ L(`- **Total:** $${cost.totalCost.toFixed(4)} over ${cost.turns} turn${cost.turns === 1 ? '' : 's'}`);
450
+ }
441
451
  L(`- **Tokens:** ${cost.totalTokens.input.toLocaleString('en-US')} in · ${cost.totalTokens.output.toLocaleString('en-US')} out · ${cost.totalTokens.cacheRead.toLocaleString('en-US')} cache-read · ${cost.totalTokens.cacheCreate.toLocaleString('en-US')} cache-write`);
452
+ if (inCredits && credits?.balance != null) {
453
+ L(`- **Remaining:** balance ${Math.round(credits.balance).toLocaleString('en-US')}`);
454
+ }
442
455
  if (auditEntries.length > 0) {
443
- L(`- **Includes** ${auditEntries.length} labelled audit turn${auditEntries.length === 1 ? '' : 's'}: $${auditCost.toFixed(4)}`);
456
+ // Credits mode: the labelled-audit turn COUNT is still useful provenance;
457
+ // its USD cost is not ours to publish to a customer.
458
+ L(inCredits
459
+ ? `- **Includes** ${auditEntries.length} labelled audit turn${auditEntries.length === 1 ? '' : 's'}`
460
+ : `- **Includes** ${auditEntries.length} labelled audit turn${auditEntries.length === 1 ? '' : 's'}: $${auditCost.toFixed(4)}`);
444
461
  }
445
462
  if (cost.byModel.length > 1) {
446
463
  L('- **By model:**');
447
464
  for (const m of cost.byModel) {
448
- L(` - ${m.model}: ${m.turns} turn${m.turns === 1 ? '' : 's'} · $${m.cost.toFixed(4)}`);
465
+ L(inCredits
466
+ ? ` - ${m.model}: ${m.turns} turn${m.turns === 1 ? '' : 's'}`
467
+ : ` - ${m.model}: ${m.turns} turn${m.turns === 1 ? '' : 's'} · $${m.cost.toFixed(4)}`);
449
468
  }
450
469
  }
451
470
  else if (cost.byModel[0]) {
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Customer development standards — the team's own ABAP conventions, injected
3
+ * into every turn's `<session_context>` so skills follow the customer's rules
4
+ * instead of CSPeach's defaults.
5
+ *
6
+ * Entirely opt-in: when no standards file exists this module renders NOTHING,
7
+ * so an existing user's prompts are byte-identical to before the feature.
8
+ *
9
+ * Lookup order (first hit wins):
10
+ * 1. `<cwd>/CSPEACH-STANDARDS.md` — checked in next to the code
11
+ * 2. `<cwd>/.cspeach/standards.md` — project-local, gitignorable
12
+ * 3. `~/.cspeach/standards.md` — per-developer default
13
+ *
14
+ * Applies in BOTH standalone and connected mode. The system prompt is assembled
15
+ * server-side by the proxy, so the override rule travels inside the element
16
+ * rather than in a CLI-side preamble constant.
17
+ */
18
+ import { promises as fs } from 'node:fs';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import { cspeachRoot } from '../config/paths.js';
22
+ /** Hard cap on how much of the document travels in each turn's context. */
23
+ export const STANDARDS_MAX_CHARS = 8000;
24
+ /** Standard file name a team checks in beside their code. */
25
+ export const STANDARDS_REPO_FILENAME = 'CSPEACH-STANDARDS.md';
26
+ /**
27
+ * First line inside `<development_standards>`. CSPeach's own conventions ship
28
+ * in the server-side system prompt; this tells the model which wins.
29
+ */
30
+ export const STANDARDS_OVERRIDE_RULE = "Rule: these customer standards override CSPeach's default conventions. If a default conflicts, follow the customer standard and say so once.";
31
+ /** Per-process cache, keyed by cwd. Mirrors the project-context cache. */
32
+ const cache = new Map();
33
+ /** Test hook — drop the per-process cache. */
34
+ export function resetStandardsCache() {
35
+ cache.clear();
36
+ }
37
+ /** The candidate paths, in precedence order, for a given cwd. */
38
+ export function standardsCandidates(cwd) {
39
+ return [
40
+ path.join(cwd, STANDARDS_REPO_FILENAME),
41
+ path.join(cwd, '.cspeach', 'standards.md'),
42
+ path.join(cspeachRoot(), 'standards.md'),
43
+ ];
44
+ }
45
+ /**
46
+ * Find the active standards file for `cwd`, or null when the user has none.
47
+ * Best-effort: an unreadable candidate is skipped, never thrown.
48
+ */
49
+ export async function resolveStandardsFile(cwd) {
50
+ if (cache.has(cwd))
51
+ return cache.get(cwd);
52
+ let found = null;
53
+ for (const candidate of standardsCandidates(cwd)) {
54
+ try {
55
+ const raw = await fs.readFile(candidate, 'utf8');
56
+ const content = raw.trim();
57
+ // A present-but-empty file means "nothing to say" — keep looking, and if
58
+ // nothing else turns up render no element at all.
59
+ if (content.length === 0)
60
+ continue;
61
+ found = { path: candidate, content };
62
+ break;
63
+ }
64
+ catch {
65
+ // ENOENT / EACCES / EISDIR — try the next candidate.
66
+ }
67
+ }
68
+ cache.set(cwd, found);
69
+ return found;
70
+ }
71
+ /**
72
+ * Display form of a standards path: relative when it sits under cwd, `~`-
73
+ * prefixed when it sits under the home directory, absolute otherwise.
74
+ */
75
+ export function displayStandardsPath(filePath, cwd) {
76
+ const rel = path.relative(cwd, filePath);
77
+ if (rel && !rel.startsWith('..') && !path.isAbsolute(rel))
78
+ return rel;
79
+ const home = os.homedir();
80
+ const relHome = path.relative(home, filePath);
81
+ if (relHome && !relHome.startsWith('..') && !path.isAbsolute(relHome)) {
82
+ return '~' + path.sep + relHome;
83
+ }
84
+ return filePath;
85
+ }
86
+ /**
87
+ * Render the `<development_standards>` element for `<session_context>`, or ''
88
+ * when the user has no standards file (the no-regression path).
89
+ */
90
+ export async function renderStandardsBlock(cwd) {
91
+ const file = await resolveStandardsFile(cwd);
92
+ if (!file)
93
+ return '';
94
+ let body = file.content;
95
+ if (body.length > STANDARDS_MAX_CHARS) {
96
+ body = body.slice(0, STANDARDS_MAX_CHARS) + `\n[truncated — full document at ${file.path}]`;
97
+ }
98
+ return [
99
+ `<development_standards source="${escapeAttr(displayStandardsPath(file.path, cwd))}">`,
100
+ STANDARDS_OVERRIDE_RULE,
101
+ body,
102
+ '</development_standards>',
103
+ ].join('\n');
104
+ }
105
+ function escapeAttr(s) {
106
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
107
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * `cspeach standards init` — capture the team's ABAP development standards
3
+ * once, as a Markdown file the session context then carries into every turn
4
+ * (see standards-file.ts).
5
+ *
6
+ * Three routes:
7
+ * a) import — point at an existing .md/.txt and copy it in with a header
8
+ * b) answer — a short interview, written as one `## ` section per answer
9
+ * c) defaults— write nothing; CSPeach's built-in conventions apply
10
+ *
11
+ * Also runs as the LAST question of the standalone profile wizard.
12
+ *
13
+ * Prompts are injected so the flow is unit-testable without a TTY; the default
14
+ * implementation uses the same `@inquirer/prompts` primitives as the SAP wizard.
15
+ */
16
+ import chalk from 'chalk';
17
+ import { promises as fs } from 'node:fs';
18
+ import path from 'node:path';
19
+ import { cspeachRoot } from '../config/paths.js';
20
+ import { STANDARDS_REPO_FILENAME, displayStandardsPath, resetStandardsCache, } from './standards-file.js';
21
+ /**
22
+ * The interview. Every question is OPTIONAL — an empty answer means the team
23
+ * has no rule there, and no section is written for it.
24
+ */
25
+ export const STANDARDS_INTERVIEW_QUESTIONS = [
26
+ {
27
+ key: 'namespace',
28
+ heading: 'Namespace / prefix',
29
+ message: 'Namespace or prefix for custom objects (Enter to skip):',
30
+ default: 'Z',
31
+ },
32
+ {
33
+ key: 'packages',
34
+ heading: 'Packages for new objects',
35
+ message: 'Package(s) new objects belong in, e.g. ZFI_CORE (Enter to skip):',
36
+ },
37
+ {
38
+ key: 'naming',
39
+ heading: 'Naming style',
40
+ message: 'Naming style for classes / methods / variables — e.g. "zcl_<area>_<noun>", Hungarian lv_/ls_/lt_ (Enter to skip):',
41
+ },
42
+ {
43
+ key: 'forbidden',
44
+ heading: 'Forbidden or discouraged statements',
45
+ message: 'Forbidden or discouraged statements, e.g. "no SELECT *", "no CALL TRANSACTION" (Enter to skip):',
46
+ },
47
+ {
48
+ key: 'atc_variant',
49
+ heading: 'ATC check variant',
50
+ message: 'ATC check variant name (Enter to skip):',
51
+ },
52
+ {
53
+ key: 'clean_core',
54
+ heading: 'Clean core',
55
+ message: 'Clean-core requirement:',
56
+ choices: ['ABAP Cloud only', 'released APIs preferred', 'classic allowed', '(skip)'],
57
+ },
58
+ {
59
+ key: 'transport_process',
60
+ heading: 'Transport / change process',
61
+ message: 'Transport / change process, e.g. "ChaRM, one TR per ticket" (Enter to skip):',
62
+ },
63
+ {
64
+ key: 'header_template',
65
+ heading: 'Code header template',
66
+ message: 'Required code header / comment template (Enter to skip):',
67
+ },
68
+ {
69
+ key: 'unit_tests',
70
+ heading: 'Unit tests',
71
+ message: 'Unit-test expectation:',
72
+ choices: ['required for all classes', 'business logic only', 'none', '(skip)'],
73
+ },
74
+ {
75
+ key: 'other',
76
+ heading: 'Anything else',
77
+ message: 'Anything else CSPeach should follow (Enter to skip):',
78
+ },
79
+ ];
80
+ /** Header written at the top of an IMPORTED standards document. */
81
+ export function importedStandardsHeader(sourcePath, when = new Date()) {
82
+ const date = when.toISOString().slice(0, 10);
83
+ return `# Development standards (imported from ${sourcePath} on ${date})`;
84
+ }
85
+ /**
86
+ * Render the interview answers as Markdown — one `## ` section per ANSWERED
87
+ * item, in question order. Returns '' when nothing was answered, so the caller
88
+ * can skip writing a file that would say nothing.
89
+ */
90
+ export function renderStandardsMarkdown(answers) {
91
+ const sections = [];
92
+ for (const q of STANDARDS_INTERVIEW_QUESTIONS) {
93
+ const raw = answers[q.key];
94
+ const value = typeof raw === 'string' ? raw.trim() : '';
95
+ if (value.length === 0 || value === '(skip)')
96
+ continue;
97
+ sections.push(`## ${q.heading}\n\n${value}`);
98
+ }
99
+ if (sections.length === 0)
100
+ return '';
101
+ return `# Development standards\n\n${sections.join('\n\n')}\n`;
102
+ }
103
+ /**
104
+ * Write the standards document. Prefers `<cwd>/CSPEACH-STANDARDS.md` so the
105
+ * team can check it in; falls back to `~/.cspeach/standards.md` when cwd is not
106
+ * writable (read-only checkout, a directory the user doesn't own).
107
+ * Returns the path actually written.
108
+ */
109
+ export async function writeStandardsFile(cwd, content) {
110
+ const repoPath = path.join(cwd, STANDARDS_REPO_FILENAME);
111
+ try {
112
+ await fs.writeFile(repoPath, content, 'utf8');
113
+ resetStandardsCache();
114
+ return repoPath;
115
+ }
116
+ catch {
117
+ const homePath = path.join(cspeachRoot(), 'standards.md');
118
+ await fs.mkdir(path.dirname(homePath), { recursive: true });
119
+ await fs.writeFile(homePath, content, 'utf8');
120
+ resetStandardsCache();
121
+ return homePath;
122
+ }
123
+ }
124
+ async function defaultPrompts() {
125
+ const { input, select } = await import('@inquirer/prompts');
126
+ return {
127
+ select: (opts) => select({ message: opts.message, choices: opts.choices, default: opts.default }),
128
+ input: (opts) => input({ message: opts.message, default: opts.default }),
129
+ };
130
+ }
131
+ /**
132
+ * Ask whether the team has development standards, and capture them.
133
+ * Never throws on a bad answer — a missing import path degrades to 'defaults'
134
+ * with a printed hint, because this runs inside startup wizards.
135
+ */
136
+ export async function runStandardsInit(cwd, prompts) {
137
+ const p = prompts ?? (await defaultPrompts());
138
+ const route = await p.select({
139
+ key: 'route',
140
+ message: 'Does your team have ABAP development standards?',
141
+ choices: [
142
+ { value: 'import', name: 'Point me to the document' },
143
+ { value: 'interview', name: 'Answer a few questions' },
144
+ { value: 'defaults', name: 'Use CSPeach defaults for now' },
145
+ ],
146
+ default: 'defaults',
147
+ });
148
+ if (route === 'import') {
149
+ const src = (await p.input({
150
+ key: 'source',
151
+ message: 'Path to the standards document (.md or .txt):',
152
+ })).trim();
153
+ try {
154
+ const body = await fs.readFile(src, 'utf8');
155
+ const content = `${importedStandardsHeader(src)}\n\n${body.trim()}\n`;
156
+ const written = await writeStandardsFile(cwd, content);
157
+ console.log(chalk.green(`✓ Standards imported to ${displayStandardsPath(written, cwd)}`));
158
+ return { action: 'import', path: written };
159
+ }
160
+ catch (e) {
161
+ console.log(chalk.yellow(` Could not read "${src}" (${e.message}). Keeping CSPeach defaults.`));
162
+ printHowToAddLater(cwd);
163
+ return { action: 'defaults' };
164
+ }
165
+ }
166
+ if (route === 'interview') {
167
+ const answers = {};
168
+ for (const q of STANDARDS_INTERVIEW_QUESTIONS) {
169
+ const answer = q.choices
170
+ ? await p.select({
171
+ key: q.key,
172
+ message: q.message,
173
+ choices: q.choices.map((c) => ({ value: c, name: c })),
174
+ default: '(skip)',
175
+ })
176
+ : await p.input({ key: q.key, message: q.message, default: q.default });
177
+ answers[q.key] = answer;
178
+ }
179
+ const md = renderStandardsMarkdown(answers);
180
+ if (md.length === 0) {
181
+ console.log(chalk.dim(' Nothing answered — keeping CSPeach defaults.'));
182
+ printHowToAddLater(cwd);
183
+ return { action: 'defaults' };
184
+ }
185
+ const written = await writeStandardsFile(cwd, md);
186
+ console.log(chalk.green(`✓ Standards written to ${displayStandardsPath(written, cwd)}`));
187
+ return { action: 'interview', path: written };
188
+ }
189
+ printHowToAddLater(cwd);
190
+ return { action: 'defaults' };
191
+ }
192
+ function printHowToAddLater(cwd) {
193
+ console.log(chalk.dim(` Add standards later: drop a ${STANDARDS_REPO_FILENAME} in ${cwd}, or run: cspeach standards init`));
194
+ }
@@ -57,6 +57,8 @@ registerTool({
57
57
  + 'the feature consulted, the matrix column used, the reason, and (only for with-fallback) a concrete '
58
58
  + 'fallback instruction. Read-only — never writes to SAP.',
59
59
  isMutating: false,
60
+ // Probes the CONNECTED release via getSapSystemInfo(ctx.adt, …).
61
+ requiresSap: true,
60
62
  category: 'sap',
61
63
  flagGated: true,
62
64
  input_schema: {
@@ -20,6 +20,31 @@ export function listTools() {
20
20
  // always returned, preserving today's listTools() semantics exactly.
21
21
  return Array.from(registry.values()).filter((t) => !t.flagGated || isToolFlagOn(t.name));
22
22
  }
23
+ /**
24
+ * Reserved alias for standalone mode — CSPeach running with no SAP system.
25
+ * Set at the single ctx-construction site in repl.tsx / one-shot.ts alongside
26
+ * the offline AdtClient stand-in (sap/offline-adt-client.ts).
27
+ */
28
+ export const STANDALONE_ALIAS = 'none';
29
+ /** True when this context has no SAP system behind it. */
30
+ export function isStandalone(ctx) {
31
+ return ctx.sapAlias === STANDALONE_ALIAS;
32
+ }
33
+ /**
34
+ * Narrow a tool list to what the given context can actually run: in standalone
35
+ * mode every `requiresSap` tool is hidden from the LLM, because calling one
36
+ * could only ever fail.
37
+ *
38
+ * Deliberately NOT folded into `listTools()` — other callers (the local_build
39
+ * startup hook, tool-count diagnostics, the doctor) must keep seeing the whole
40
+ * registry. When not standalone this returns the SAME array reference it was
41
+ * given, so the connected path does no work and stays byte-identical.
42
+ */
43
+ export function toolsForContext(tools, ctx) {
44
+ if (!isStandalone(ctx))
45
+ return tools;
46
+ return tools.filter((t) => !t.requiresSap);
47
+ }
23
48
  export function getToolsByCategory(category) {
24
49
  return listTools().filter((t) => t.category === category);
25
50
  }