@cspeach/cli 0.6.0

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 (210) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +108 -0
  3. package/dist/agent/anthropic-provider.js +59 -0
  4. package/dist/agent/llm-provider.js +1 -0
  5. package/dist/agent/loop.js +709 -0
  6. package/dist/agent/maybe-build-project-context.js +126 -0
  7. package/dist/agent/providers/ai-hub-provider.js +58 -0
  8. package/dist/agent/providers/byok-provider.js +53 -0
  9. package/dist/agent/providers/factory.js +13 -0
  10. package/dist/agent/providers/local-provider.js +125 -0
  11. package/dist/agent/repair-partial.js +31 -0
  12. package/dist/agent/retry-key.js +58 -0
  13. package/dist/agent/retry.js +17 -0
  14. package/dist/agent/sap-connection-adapter.js +82 -0
  15. package/dist/agent/skill-checkpoint.js +119 -0
  16. package/dist/agent/tool-dispatch.js +47 -0
  17. package/dist/agent/turn-assistant-text.js +49 -0
  18. package/dist/agent/turn-error-ux.js +126 -0
  19. package/dist/agent/turn-stream.js +79 -0
  20. package/dist/agent/turn-watchdog.js +71 -0
  21. package/dist/approvals/advisory-prompt.js +40 -0
  22. package/dist/approvals/advisory-render.js +38 -0
  23. package/dist/approvals/approval-prompt.js +100 -0
  24. package/dist/approvals/jwt.js +33 -0
  25. package/dist/approvals/render.js +211 -0
  26. package/dist/approvals/risk-floor.js +26 -0
  27. package/dist/auth/api-key.js +40 -0
  28. package/dist/auth/auth-file.js +59 -0
  29. package/dist/auth/device.js +8 -0
  30. package/dist/auth/me.js +19 -0
  31. package/dist/classifier/client.js +58 -0
  32. package/dist/cli-args.js +38 -0
  33. package/dist/cli.js +148 -0
  34. package/dist/commands/config-set.js +245 -0
  35. package/dist/commands/config-show.js +159 -0
  36. package/dist/commands/help.js +93 -0
  37. package/dist/commands/login.js +122 -0
  38. package/dist/commands/logout.js +17 -0
  39. package/dist/commands/project-context-impact.js +215 -0
  40. package/dist/commands/reroute.js +60 -0
  41. package/dist/commands/spec-gap-status.js +52 -0
  42. package/dist/commands/whoami.js +35 -0
  43. package/dist/config/loader.js +67 -0
  44. package/dist/config/paths.js +20 -0
  45. package/dist/doctor/checks/_http-probe.js +56 -0
  46. package/dist/doctor/checks/auth.js +15 -0
  47. package/dist/doctor/checks/cert.js +24 -0
  48. package/dist/doctor/checks/forge-rules.js +102 -0
  49. package/dist/doctor/checks/keychain-fallback.js +14 -0
  50. package/dist/doctor/checks/keychain.js +23 -0
  51. package/dist/doctor/checks/llm-mode.js +27 -0
  52. package/dist/doctor/checks/proxy.js +13 -0
  53. package/dist/doctor/checks/sap.js +33 -0
  54. package/dist/doctor/checks/skill.js +24 -0
  55. package/dist/doctor/checks/write-mode.js +34 -0
  56. package/dist/doctor/checks/zcspeach.js +76 -0
  57. package/dist/doctor/run.js +46 -0
  58. package/dist/errors/codes.js +12 -0
  59. package/dist/index.js +6 -0
  60. package/dist/lock-contention.js +22 -0
  61. package/dist/one-shot.js +104 -0
  62. package/dist/project-context/conventions.js +309 -0
  63. package/dist/project-context/detect.js +250 -0
  64. package/dist/project-context/domain/abap-cloud.js +26 -0
  65. package/dist/project-context/domain/abapgit.js +177 -0
  66. package/dist/project-context/domain/cap.js +164 -0
  67. package/dist/project-context/domain/fiori.js +326 -0
  68. package/dist/project-context/git.js +115 -0
  69. package/dist/project-context/index-files.js +235 -0
  70. package/dist/project-context/index.js +117 -0
  71. package/dist/project-context/render.js +308 -0
  72. package/dist/project-context/types.js +14 -0
  73. package/dist/projects/build.js +20 -0
  74. package/dist/projects/canonicalize.js +39 -0
  75. package/dist/projects/email-template.js +54 -0
  76. package/dist/projects/extract-cca.js +139 -0
  77. package/dist/projects/extract-design.js +107 -0
  78. package/dist/projects/extract-estimate.js +93 -0
  79. package/dist/projects/extract-modernize.js +130 -0
  80. package/dist/projects/extract-spec-gap.js +101 -0
  81. package/dist/projects/extract-test-coverage.js +137 -0
  82. package/dist/projects/extract-upgrade.js +230 -0
  83. package/dist/projects/filename.js +18 -0
  84. package/dist/projects/index.js +8 -0
  85. package/dist/projects/migration.js +111 -0
  86. package/dist/projects/promote-command.js +96 -0
  87. package/dist/projects/promote.js +107 -0
  88. package/dist/projects/save-command.js +124 -0
  89. package/dist/projects/save.js +21 -0
  90. package/dist/projects/status.js +170 -0
  91. package/dist/projects/types.js +1 -0
  92. package/dist/projects/validate.js +146 -0
  93. package/dist/projects/workspace.js +478 -0
  94. package/dist/renderer/abap-inline.js +121 -0
  95. package/dist/renderer/banners.js +39 -0
  96. package/dist/renderer/highlighters/abap.js +126 -0
  97. package/dist/renderer/highlighters/bdef.js +81 -0
  98. package/dist/renderer/highlighters/cds.js +91 -0
  99. package/dist/renderer/markdown.js +291 -0
  100. package/dist/renderer/pipeline.js +201 -0
  101. package/dist/renderer/progress-chatter.js +237 -0
  102. package/dist/renderer/question-normalizer.js +306 -0
  103. package/dist/renderer/severity.js +61 -0
  104. package/dist/renderer/status-footer.js +50 -0
  105. package/dist/renderer/syntax.js +58 -0
  106. package/dist/renderer/tables.js +55 -0
  107. package/dist/renderer/thinking-heartbeat.js +70 -0
  108. package/dist/renderer/tool-widget.js +199 -0
  109. package/dist/renderer/tty.js +66 -0
  110. package/dist/renderer/widget-extractor.js +87 -0
  111. package/dist/renderer/widget-fallback.js +78 -0
  112. package/dist/renderer/widget-schemas.js +43 -0
  113. package/dist/repl/at-completer.js +64 -0
  114. package/dist/repl/at-picker.js +122 -0
  115. package/dist/repl/bracketed-paste.js +284 -0
  116. package/dist/repl/current-transport.js +46 -0
  117. package/dist/repl/diff-display.js +41 -0
  118. package/dist/repl/file-picker.js +219 -0
  119. package/dist/repl/inquirer-guard.js +130 -0
  120. package/dist/repl/inquirer-theme.js +41 -0
  121. package/dist/repl/rule8-detector.js +99 -0
  122. package/dist/repl/safety-confirm.js +106 -0
  123. package/dist/repl/safety-mode-state.js +36 -0
  124. package/dist/repl/slash-completer.js +59 -0
  125. package/dist/repl/slash-picker.js +124 -0
  126. package/dist/repl/update-method-preview-hook.js +45 -0
  127. package/dist/repl.js +1383 -0
  128. package/dist/router/classifier.js +38 -0
  129. package/dist/router/intent-extractor.js +140 -0
  130. package/dist/router/routing-decision.js +19 -0
  131. package/dist/sap/connection-manager.js +52 -0
  132. package/dist/sap/onboarding.js +178 -0
  133. package/dist/sap/system-info.js +515 -0
  134. package/dist/session/awaiting-answer.js +73 -0
  135. package/dist/session/gc.js +28 -0
  136. package/dist/session/pending.js +37 -0
  137. package/dist/session/resume.js +77 -0
  138. package/dist/session/schema.js +20 -0
  139. package/dist/session/store.js +147 -0
  140. package/dist/session/time-ago.js +41 -0
  141. package/dist/skill-catalog.js +222 -0
  142. package/dist/skills/bundled-skills.js +1 -0
  143. package/dist/skills/canonical.js +12 -0
  144. package/dist/skills/manifest-client.js +93 -0
  145. package/dist/skills/promotion-dispatch.js +24 -0
  146. package/dist/skills/signing-public-key.js +4 -0
  147. package/dist/skills/source-bundled.js +20 -0
  148. package/dist/skills/source-managed.js +26 -0
  149. package/dist/skills/source-manifest.js +26 -0
  150. package/dist/tools/_command-shared.js +110 -0
  151. package/dist/tools/_filesystem-shared.js +81 -0
  152. package/dist/tools/_flag.js +39 -0
  153. package/dist/tools/approval.js +228 -0
  154. package/dist/tools/ask-question.js +205 -0
  155. package/dist/tools/dispatch-skill.js +81 -0
  156. package/dist/tools/filesystem/file-edit.js +140 -0
  157. package/dist/tools/filesystem/file-read.js +89 -0
  158. package/dist/tools/filesystem/file-write.js +128 -0
  159. package/dist/tools/filesystem/glob.js +177 -0
  160. package/dist/tools/filesystem/grep.js +163 -0
  161. package/dist/tools/index.js +32 -0
  162. package/dist/tools/project/convention_get.js +91 -0
  163. package/dist/tools/project/playbook_get.js +132 -0
  164. package/dist/tools/project/project_context_get.js +101 -0
  165. package/dist/tools/sap-read.js +454 -0
  166. package/dist/tools/sap-write.js +746 -0
  167. package/dist/tools/shell/shell_exec.js +209 -0
  168. package/dist/tools/snapshot.js +107 -0
  169. package/dist/tools/subagent/_background-shared.js +133 -0
  170. package/dist/tools/subagent/agent_run.js +186 -0
  171. package/dist/tools/subagent/background_run.js +143 -0
  172. package/dist/tools/subagent/monitor_emit.js +65 -0
  173. package/dist/tools/subagent/schedule_create.js +131 -0
  174. package/dist/tools/transport.js +233 -0
  175. package/dist/tools/update-method-intercept.js +119 -0
  176. package/dist/tools/verify.js +39 -0
  177. package/dist/tools/web/_web-shared.js +251 -0
  178. package/dist/tools/web/web_fetch.js +257 -0
  179. package/dist/tools/web/web_search.js +195 -0
  180. package/dist/tools/write-mode.js +22 -0
  181. package/dist/ui/app.js +95 -0
  182. package/dist/ui/approval-emitter.js +10 -0
  183. package/dist/ui/approval-modal.js +53 -0
  184. package/dist/ui/ascii-chars.js +6 -0
  185. package/dist/ui/body.js +102 -0
  186. package/dist/ui/coaching-picker-classic.js +36 -0
  187. package/dist/ui/coaching-picker-emitter.js +27 -0
  188. package/dist/ui/command-palette.js +34 -0
  189. package/dist/ui/error-emitter.js +21 -0
  190. package/dist/ui/footer.js +103 -0
  191. package/dist/ui/header.js +17 -0
  192. package/dist/ui/ink-classifier-route.js +19 -0
  193. package/dist/ui/login-banner.js +72 -0
  194. package/dist/ui/rich-error-box.js +9 -0
  195. package/dist/ui/sap-state-store.js +65 -0
  196. package/dist/ui/session-timeline.js +31 -0
  197. package/dist/ui/sidebar.js +10 -0
  198. package/dist/ui/skill-picker.js +50 -0
  199. package/dist/ui/status-row.js +12 -0
  200. package/dist/ui/widget-control.js +4 -0
  201. package/dist/ui/widgets/bar-chart.js +15 -0
  202. package/dist/ui/widgets/coaching-picker.js +41 -0
  203. package/dist/ui/widgets/component-registry.js +12 -0
  204. package/dist/ui/widgets/dep-graph.js +9 -0
  205. package/dist/ui/widgets/diff-viewer.js +11 -0
  206. package/dist/ui/widgets/question-card.js +11 -0
  207. package/dist/ui/widgets/stack-frames.js +5 -0
  208. package/dist/upgrade-check.js +28 -0
  209. package/dist/upgrade.js +13 -0
  210. package/package.json +83 -0
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Project conventions reader.
3
+ *
4
+ * Source priority (highest first):
5
+ *
6
+ * 1. CLAUDE.md — preferred. Matches Claude Code's convention so
7
+ * developers who use both tools don't maintain two files.
8
+ * Read as free-text, included verbatim in `freeText`.
9
+ *
10
+ * 2. CSPEACH.md — optional CSPeach-specific override. Appended to
11
+ * CLAUDE.md content when both exist. Use when CSPeach should
12
+ * behave differently from Claude Code in the same repo (rare).
13
+ *
14
+ * 3. .cspeach/conventions.toml — structured overrides for naming
15
+ * patterns + style. Sits alongside the optional .cspeach/ workspace
16
+ * directory that already exists for project-rooted CSPeach files.
17
+ *
18
+ * 4. Inferred from existing files — when none of the above exist, we
19
+ * sample one or two files to detect indent + EOL. Naming patterns
20
+ * stay empty (best-effort inference is a follow-up; manual
21
+ * override via .cspeach/conventions.toml is the recommended path
22
+ * for projects that need it).
23
+ *
24
+ * Returns sensible defaults on every error. Never throws.
25
+ */
26
+ import { promises as fs } from 'node:fs';
27
+ import * as path from 'node:path';
28
+ /**
29
+ * Default conventions used when no source has anything to say. Picked
30
+ * to match the most common conventions in modern SAP / web projects:
31
+ * 2-space indent, LF line endings, no inferred naming patterns.
32
+ */
33
+ const DEFAULT_CONVENTIONS = {
34
+ freeText: null,
35
+ namingPatterns: {
36
+ classes: [],
37
+ interfaces: [],
38
+ cdsViews: [],
39
+ },
40
+ style: {
41
+ indentSize: 2,
42
+ indentChar: 'space',
43
+ eol: 'lf',
44
+ },
45
+ };
46
+ /**
47
+ * Read project conventions. Best-effort: returns DEFAULT_CONVENTIONS on
48
+ * any IO failure. Combines the four sources per priority order; later
49
+ * sources override earlier ones for structured fields, and append to
50
+ * `freeText` rather than replace it.
51
+ */
52
+ export async function readConventions(cwd) {
53
+ const claudeBody = await readFileSafe(path.join(cwd, 'CLAUDE.md'));
54
+ const cspeachBody = await readFileSafe(path.join(cwd, 'CSPEACH.md'));
55
+ const freeText = combineFreeText(claudeBody, cspeachBody);
56
+ const tomlPath = path.join(cwd, '.cspeach', 'conventions.toml');
57
+ const tomlOverrides = await readConventionsToml(tomlPath);
58
+ const inferred = await inferStyleFromExistingFiles(cwd);
59
+ return {
60
+ freeText,
61
+ namingPatterns: {
62
+ classes: tomlOverrides?.namingPatterns?.classes ?? DEFAULT_CONVENTIONS.namingPatterns.classes,
63
+ interfaces: tomlOverrides?.namingPatterns?.interfaces ?? DEFAULT_CONVENTIONS.namingPatterns.interfaces,
64
+ cdsViews: tomlOverrides?.namingPatterns?.cdsViews ?? DEFAULT_CONVENTIONS.namingPatterns.cdsViews,
65
+ },
66
+ style: {
67
+ indentSize: tomlOverrides?.style?.indentSize ?? inferred?.indentSize ?? DEFAULT_CONVENTIONS.style.indentSize,
68
+ indentChar: tomlOverrides?.style?.indentChar ?? inferred?.indentChar ?? DEFAULT_CONVENTIONS.style.indentChar,
69
+ eol: tomlOverrides?.style?.eol ?? inferred?.eol ?? DEFAULT_CONVENTIONS.style.eol,
70
+ },
71
+ };
72
+ }
73
+ /* ── Free-text combination ─────────────────────────────────────────── */
74
+ function combineFreeText(claude, cspeach) {
75
+ if (claude === null && cspeach === null)
76
+ return null;
77
+ const parts = [];
78
+ if (claude !== null) {
79
+ parts.push('## CLAUDE.md\n\n' + claude.trim());
80
+ }
81
+ if (cspeach !== null) {
82
+ parts.push('## CSPEACH.md\n\n' + cspeach.trim());
83
+ }
84
+ return parts.join('\n\n');
85
+ }
86
+ /**
87
+ * Parse `.cspeach/conventions.toml` if it exists. Returns null when the
88
+ * file is missing or malformed. We use a minimal TOML parser (just
89
+ * enough for the subset we accept) to avoid adding a dependency.
90
+ */
91
+ async function readConventionsToml(p) {
92
+ const raw = await readFileSafe(p);
93
+ if (raw === null)
94
+ return null;
95
+ try {
96
+ return parseMinimalToml(raw);
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
102
+ /**
103
+ * Tiny TOML parser supporting only the subset we need:
104
+ * - Section headers: [namingPatterns], [style]
105
+ * - Key = "string", Key = 123, Key = ["a", "b"]
106
+ * - # comments to end of line
107
+ *
108
+ * We avoid pulling in a TOML dependency (e.g. `smol-toml`) because the
109
+ * surface we parse is tiny and a typo-tolerant minimal parser keeps the
110
+ * convention file from failing the whole context read.
111
+ */
112
+ function parseMinimalToml(raw) {
113
+ const out = {};
114
+ let section = null;
115
+ for (const rawLine of raw.split('\n')) {
116
+ const line = rawLine.replace(/#.*$/, '').trim();
117
+ if (line.length === 0)
118
+ continue;
119
+ // Section header
120
+ const sectionMatch = /^\[([a-zA-Z_]+)\]$/.exec(line);
121
+ if (sectionMatch) {
122
+ const name = sectionMatch[1];
123
+ if (name === 'namingPatterns' || name === 'style') {
124
+ section = name;
125
+ }
126
+ else {
127
+ section = null;
128
+ }
129
+ continue;
130
+ }
131
+ // Key = value
132
+ const kvMatch = /^([a-zA-Z_]+)\s*=\s*(.+)$/.exec(line);
133
+ if (!kvMatch)
134
+ continue;
135
+ const key = kvMatch[1];
136
+ const valueRaw = kvMatch[2].trim();
137
+ if (section === 'namingPatterns') {
138
+ const arr = parseStringArray(valueRaw);
139
+ if (arr !== null) {
140
+ if (!out.namingPatterns)
141
+ out.namingPatterns = {};
142
+ if (key === 'classes')
143
+ out.namingPatterns.classes = arr;
144
+ else if (key === 'interfaces')
145
+ out.namingPatterns.interfaces = arr;
146
+ else if (key === 'cdsViews')
147
+ out.namingPatterns.cdsViews = arr;
148
+ }
149
+ }
150
+ else if (section === 'style') {
151
+ if (!out.style)
152
+ out.style = {};
153
+ if (key === 'indentSize') {
154
+ const n = Number.parseInt(valueRaw, 10);
155
+ if (Number.isFinite(n) && n > 0)
156
+ out.style.indentSize = n;
157
+ }
158
+ else if (key === 'indentChar') {
159
+ const s = parseString(valueRaw);
160
+ if (s === 'tab' || s === 'space')
161
+ out.style.indentChar = s;
162
+ }
163
+ else if (key === 'eol') {
164
+ const s = parseString(valueRaw);
165
+ if (s === 'lf' || s === 'crlf')
166
+ out.style.eol = s;
167
+ }
168
+ }
169
+ }
170
+ return out;
171
+ }
172
+ function parseString(v) {
173
+ const m = /^"([^"]*)"$/.exec(v) ?? /^'([^']*)'$/.exec(v);
174
+ return m ? m[1] : null;
175
+ }
176
+ function parseStringArray(v) {
177
+ if (!v.startsWith('[') || !v.endsWith(']'))
178
+ return null;
179
+ const inner = v.slice(1, -1).trim();
180
+ if (inner.length === 0)
181
+ return [];
182
+ const items = [];
183
+ // Simple split — doesn't handle escaped commas, which is fine for our subset.
184
+ for (const part of inner.split(',')) {
185
+ const s = parseString(part.trim());
186
+ if (s === null)
187
+ return null;
188
+ items.push(s);
189
+ }
190
+ return items;
191
+ }
192
+ /* ── Style inference ───────────────────────────────────────────────── */
193
+ /**
194
+ * Sample one or two text files in the project tree and infer indent +
195
+ * EOL. Used only when conventions.toml doesn't override. Cheap heuristic
196
+ * — peek at the first few hundred bytes of the first 2 files we find.
197
+ *
198
+ * Returns null on any failure; caller falls back to DEFAULT_CONVENTIONS.style.
199
+ */
200
+ async function inferStyleFromExistingFiles(cwd) {
201
+ try {
202
+ const samplePaths = await findSampleFiles(cwd, 2);
203
+ if (samplePaths.length === 0)
204
+ return null;
205
+ let indentChar;
206
+ let indentSize;
207
+ let eol;
208
+ for (const p of samplePaths) {
209
+ try {
210
+ const raw = await fs.readFile(p, 'utf-8');
211
+ const head = raw.slice(0, 4096);
212
+ // EOL: presence of \r\n means CRLF.
213
+ if (eol === undefined) {
214
+ eol = head.includes('\r\n') ? 'crlf' : 'lf';
215
+ }
216
+ // Indent: scan for the first CODE-shaped indented line. Tab vs
217
+ // space is determined by what character appears first.
218
+ //
219
+ // Skip JSDoc / block-comment continuation lines (they start with
220
+ // ` *` — one space + asterisk — which would otherwise be misread
221
+ // as "1-space indent." This was a real dogfooding finding: most
222
+ // CSPeach .ts files start with a `/** ... */` docblock; the very
223
+ // first indented line was the ` * comment` continuation, and the
224
+ // detector reported indentSize=1.
225
+ if (indentChar === undefined) {
226
+ for (const line of head.split('\n')) {
227
+ if (line.length === 0)
228
+ continue;
229
+ // Skip block-comment continuation: `\s*\*\s` pattern (any
230
+ // amount of leading whitespace, then `*`, then a space).
231
+ if (/^\s*\*(\s|$|\/)/.test(line))
232
+ continue;
233
+ // Also skip pure-whitespace lines (some editors leave
234
+ // trailing whitespace inside docblocks).
235
+ if (/^\s*$/.test(line))
236
+ continue;
237
+ const ch = line[0];
238
+ if (ch === '\t') {
239
+ indentChar = 'tab';
240
+ indentSize = 1;
241
+ break;
242
+ }
243
+ if (ch === ' ') {
244
+ indentChar = 'space';
245
+ let count = 0;
246
+ while (count < line.length && line[count] === ' ')
247
+ count++;
248
+ indentSize = count > 0 ? count : 2;
249
+ break;
250
+ }
251
+ }
252
+ }
253
+ }
254
+ catch {
255
+ // Skip unreadable file
256
+ }
257
+ }
258
+ if (indentChar === undefined && eol === undefined)
259
+ return null;
260
+ return { indentChar, indentSize, eol };
261
+ }
262
+ catch {
263
+ return null;
264
+ }
265
+ }
266
+ /**
267
+ * Find up to `limit` text-ish source files for sampling. Skips
268
+ * node_modules, .git, dist, build, .cspeach output dirs. Prefers files
269
+ * likely to reflect human-authored style (ts, js, abap, cds, xml).
270
+ */
271
+ async function findSampleFiles(cwd, limit) {
272
+ const out = [];
273
+ const TEXT_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.abap', '.cds', '.xml', '.yaml', '.yml'];
274
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.cspeach', '.abapforge']);
275
+ async function walk(d) {
276
+ if (out.length >= limit)
277
+ return;
278
+ let entries;
279
+ try {
280
+ entries = await fs.readdir(d, { withFileTypes: true });
281
+ }
282
+ catch {
283
+ return;
284
+ }
285
+ for (const ent of entries) {
286
+ if (out.length >= limit)
287
+ return;
288
+ if (ent.isFile()) {
289
+ if (TEXT_EXTENSIONS.some((ext) => ent.name.endsWith(ext))) {
290
+ out.push(path.join(d, ent.name));
291
+ }
292
+ }
293
+ else if (ent.isDirectory() && !SKIP_DIRS.has(ent.name)) {
294
+ await walk(path.join(d, ent.name));
295
+ }
296
+ }
297
+ }
298
+ await walk(cwd);
299
+ return out;
300
+ }
301
+ /* ── Small filesystem helper ───────────────────────────────────────── */
302
+ async function readFileSafe(p) {
303
+ try {
304
+ return await fs.readFile(p, 'utf-8');
305
+ }
306
+ catch {
307
+ return null;
308
+ }
309
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Project-type detection — identifies what kind of SAP/development
3
+ * project lives at a given directory.
4
+ *
5
+ * Detection is purely structural — it looks at files + directories,
6
+ * not contents (except parsing one package.json for the Fiori/CAP
7
+ * discrimination). The function never runs ABAP, never connects to
8
+ * SAP, never modifies anything. Pure read-only filesystem inspection.
9
+ *
10
+ * Priority order matters: a CAP project that happens to contain a
11
+ * `webapp/` directory should still classify as CAP, not Fiori. The
12
+ * priority below is calibrated to that case.
13
+ */
14
+ import { promises as fs } from 'node:fs';
15
+ import * as path from 'node:path';
16
+ /**
17
+ * Detect the project type at the given working directory.
18
+ *
19
+ * Priority (first match wins):
20
+ * 1. `mta.yaml` at root → mixed (multi-target application)
21
+ * 2. CAP Node: `package.json` with `@sap/cds` + `srv/` + `db/` dirs
22
+ * 3. Fiori UI5: `webapp/manifest.json` + (`ui5.yaml` OR
23
+ * `package.json` with `@sap/ux-ui5-tooling`)
24
+ * 4. abapGit: `.abapgit.xml` at root
25
+ * OR `src/` containing `*.prog.abap` / `*.clas.abap` / `*.intf.abap`
26
+ * 5. ABAP Cloud: same disk shape as abapGit, but with `.bdef` /
27
+ * `.behaviordefinition` / `.dcl` / `.ddls` files indicating cloud
28
+ * ABAP patterns
29
+ * 6. Anything else → `unknown`
30
+ *
31
+ * Returns null on IO errors (missing dir, permission denied, etc.) so
32
+ * the caller can decide whether to treat null as "no project" or "error
33
+ * reading project." Today's only consumer in Phase 1A treats both as
34
+ * "no project context" — that semantic is intentional.
35
+ */
36
+ export async function detectProjectType(cwd) {
37
+ try {
38
+ const stat = await fs.stat(cwd);
39
+ if (!stat.isDirectory())
40
+ return null;
41
+ }
42
+ catch {
43
+ // cwd doesn't exist or isn't accessible → not a project.
44
+ return null;
45
+ }
46
+ // 1. Mixed (mta.yaml at root)
47
+ if (await fileExists(path.join(cwd, 'mta.yaml'))) {
48
+ return 'mixed';
49
+ }
50
+ // 2. CAP Node (checked BEFORE Fiori — a CAP project may have a webapp/
51
+ // subdirectory under its `app/` folder, which would otherwise match
52
+ // Fiori detection).
53
+ if (await isCapNode(cwd)) {
54
+ return 'cap-node';
55
+ }
56
+ // 3. Fiori UI5 (webapp/manifest.json + ui5.yaml or package.json with
57
+ // @sap/ux-ui5-tooling)
58
+ if (await isFioriUi5(cwd)) {
59
+ return 'fiori-ui5';
60
+ }
61
+ // 4 & 5. abapGit and ABAP Cloud share the same on-disk shape
62
+ // (src/*.<type>.abap). We discriminate by the presence of
63
+ // cloud-specific extensions.
64
+ if (await isAbapDiskProject(cwd)) {
65
+ if (await hasAbapCloudMarkers(cwd)) {
66
+ return 'abap-cloud';
67
+ }
68
+ return 'abapgit';
69
+ }
70
+ // 6. Nothing matched.
71
+ return 'unknown';
72
+ }
73
+ /* ── Per-type detection helpers ─────────────────────────────────────── */
74
+ async function isCapNode(cwd) {
75
+ const pkgPath = path.join(cwd, 'package.json');
76
+ const pkg = await readJsonSafe(pkgPath);
77
+ if (!pkg)
78
+ return false;
79
+ const deps = collectDeps(pkg);
80
+ if (!('@sap/cds' in deps))
81
+ return false;
82
+ // CAP convention: srv/ for service definitions, db/ for entity models
83
+ return ((await directoryExists(path.join(cwd, 'srv'))) &&
84
+ (await directoryExists(path.join(cwd, 'db'))));
85
+ }
86
+ async function isFioriUi5(cwd) {
87
+ // Must have webapp/manifest.json
88
+ if (!(await fileExists(path.join(cwd, 'webapp', 'manifest.json')))) {
89
+ return false;
90
+ }
91
+ // PLUS either ui5.yaml OR package.json with @sap/ux-ui5-tooling
92
+ if (await fileExists(path.join(cwd, 'ui5.yaml'))) {
93
+ return true;
94
+ }
95
+ const pkg = await readJsonSafe(path.join(cwd, 'package.json'));
96
+ if (pkg) {
97
+ const deps = collectDeps(pkg);
98
+ if ('@sap/ux-ui5-tooling' in deps)
99
+ return true;
100
+ }
101
+ return false;
102
+ }
103
+ /**
104
+ * True if a `src/` directory exists and contains at least one ABAP
105
+ * source file. Accepts BOTH classic abapGit naming (.prog.abap,
106
+ * .clas.abap, etc.) AND ABAP-Cloud-specific naming (.bdef.asbdef,
107
+ * .srvd.srvd, .dcls.asdcls, etc.) — a cloud-only project still
108
+ * qualifies as a disk-project; discrimination of cloud-vs-classic
109
+ * happens in the calling code via `hasAbapCloudMarkers`.
110
+ */
111
+ async function isAbapDiskProject(cwd) {
112
+ // .abapgit.xml at root is a strong signal regardless of src/ contents.
113
+ if (await fileExists(path.join(cwd, '.abapgit.xml'))) {
114
+ return true;
115
+ }
116
+ const srcDir = path.join(cwd, 'src');
117
+ if (!(await directoryExists(srcDir)))
118
+ return false;
119
+ // Check both extension catalogs — a project with ONLY cloud-specific
120
+ // markers (.bdef, .srvd, .dcls, etc.) should still qualify as a
121
+ // disk-project, otherwise hasAbapCloudMarkers never gets to run.
122
+ const allAbapExtensions = [
123
+ ...ABAP_DISK_EXTENSIONS,
124
+ ...ABAP_CLOUD_EXTENSIONS,
125
+ ];
126
+ return await directoryContainsExtension(srcDir, allAbapExtensions, /* recursive */ true);
127
+ }
128
+ /**
129
+ * Cloud-specific markers that distinguish ABAP Cloud / RAP-on-cloud
130
+ * (on-disk) from a generic abapGit working copy. Behavior definitions
131
+ * (`.bdef.abap` / `.behaviordefinition.bdef`) and ABAP Cloud package
132
+ * markers are the canonical signal.
133
+ */
134
+ async function hasAbapCloudMarkers(cwd) {
135
+ const srcDir = path.join(cwd, 'src');
136
+ return await directoryContainsExtension(srcDir, ABAP_CLOUD_EXTENSIONS, /* recursive */ true);
137
+ }
138
+ /* ── File-system primitives ─────────────────────────────────────────── */
139
+ async function fileExists(p) {
140
+ try {
141
+ const s = await fs.stat(p);
142
+ return s.isFile();
143
+ }
144
+ catch {
145
+ return false;
146
+ }
147
+ }
148
+ async function directoryExists(p) {
149
+ try {
150
+ const s = await fs.stat(p);
151
+ return s.isDirectory();
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ }
157
+ async function readJsonSafe(p) {
158
+ try {
159
+ const raw = await fs.readFile(p, 'utf-8');
160
+ return JSON.parse(raw);
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ }
166
+ /**
167
+ * Merge dependencies + devDependencies + peerDependencies into one
168
+ * lookup. Returns empty record if pkg is malformed.
169
+ */
170
+ function collectDeps(pkg) {
171
+ const out = {};
172
+ for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) {
173
+ const block = pkg[field];
174
+ if (block && typeof block === 'object') {
175
+ for (const [k, v] of Object.entries(block)) {
176
+ if (typeof v === 'string')
177
+ out[k] = v;
178
+ }
179
+ }
180
+ }
181
+ return out;
182
+ }
183
+ /**
184
+ * True if any file under `dir` ends with one of the given extensions.
185
+ * Recursive walk; bails after first hit. Cap at 5000 dir entries
186
+ * checked total to prevent runaway walks on very large trees.
187
+ */
188
+ async function directoryContainsExtension(dir, extensions, recursive) {
189
+ let budget = 5000;
190
+ async function walk(d) {
191
+ if (budget <= 0)
192
+ return false;
193
+ let entries;
194
+ try {
195
+ entries = await fs.readdir(d, { withFileTypes: true });
196
+ }
197
+ catch {
198
+ return false;
199
+ }
200
+ for (const ent of entries) {
201
+ if (budget <= 0)
202
+ return false;
203
+ budget--;
204
+ if (ent.isFile()) {
205
+ for (const ext of extensions) {
206
+ if (ent.name.endsWith(ext))
207
+ return true;
208
+ }
209
+ }
210
+ else if (recursive && ent.isDirectory()) {
211
+ // Skip noisy dirs that shouldn't be walked.
212
+ if (ent.name === 'node_modules' || ent.name === '.git')
213
+ continue;
214
+ if (await walk(path.join(d, ent.name)))
215
+ return true;
216
+ }
217
+ }
218
+ return false;
219
+ }
220
+ return walk(dir);
221
+ }
222
+ /* ── Extension catalogs ─────────────────────────────────────────────── */
223
+ /**
224
+ * abapGit naming convention: <object_name>.<type>.abap and friends.
225
+ * Object types we accept as evidence of an ABAP working copy.
226
+ */
227
+ const ABAP_DISK_EXTENSIONS = [
228
+ '.prog.abap', // programs (executable + module-pool)
229
+ '.clas.abap', // classes
230
+ '.intf.abap', // interfaces
231
+ '.fugr.abap', // function groups
232
+ '.tabl.xml', // tables (DDIC)
233
+ '.dtel.xml', // data elements
234
+ '.doma.xml', // domains
235
+ '.ddls.asddls', // CDS views (interface DDL source)
236
+ '.ddls.asccds', // CDS views (consumption DDL source, abapGit naming)
237
+ ];
238
+ /**
239
+ * ABAP Cloud / RAP-on-disk specific markers. Presence of any of these
240
+ * tells us we're looking at ABAP Cloud rather than classic abapGit
241
+ * (even though the two share the disk layout under src/).
242
+ */
243
+ const ABAP_CLOUD_EXTENSIONS = [
244
+ '.bdef.asbdef', // RAP behavior definition (cloud)
245
+ '.behaviordefinition', // alternate naming some projects use
246
+ '.dcls.asdcls', // CDS access control / DCL
247
+ '.ddlx.asddlx', // metadata extension
248
+ '.srvb.srvb', // service binding
249
+ '.srvd.srvd', // service definition
250
+ ];
@@ -0,0 +1,26 @@
1
+ /**
2
+ * ABAP Cloud / RAP-on-disk domain extractor.
3
+ *
4
+ * Builds on the abapGit walker — same on-disk shape, same filename
5
+ * convention, same package inference. The only delta is that ABAP Cloud
6
+ * projects emit RAP-specific objects (behavior definitions, service
7
+ * definitions, service bindings, DCL access controls, metadata
8
+ * extensions) that we surface as a separate concept.
9
+ *
10
+ * For Phase 1E we promote behavior definitions specifically — they're
11
+ * the entry point a RAP-aware skill needs to know about most often.
12
+ * Other RAP objects (SRVD, SRVB, DCLS, DDLX) are still surfaced through
13
+ * the generic objects[] list, just not called out separately.
14
+ */
15
+ import { collectAbapObjects } from './abapgit.js';
16
+ /**
17
+ * Walk `src/` under cwd and return the abap-cloud DomainObjectIndex
18
+ * variant: full object list plus the names of all behavior definitions.
19
+ */
20
+ export async function extractAbapCloud(cwd) {
21
+ const objects = await collectAbapObjects(cwd);
22
+ const behaviorDefinitions = objects
23
+ .filter((o) => o.type === 'BDEF')
24
+ .map((o) => o.name);
25
+ return { type: 'abap-cloud', objects, behaviorDefinitions };
26
+ }