@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,115 @@
1
+ /**
2
+ * Git state extractor.
3
+ *
4
+ * Surfaces just enough VCS context for the agent to know "what branch
5
+ * are we on, is the working copy dirty, what just shipped." Shells out
6
+ * to the git CLI rather than pulling in a Git library — git is already
7
+ * available wherever a developer runs CSPeach.
8
+ *
9
+ * Discipline:
10
+ *
11
+ * - Best-effort. If git isn't installed, the directory isn't a repo,
12
+ * or any subprocess errors out, we return `isRepo: false` and the
13
+ * rest of the fields zero/null. We never throw.
14
+ *
15
+ * - Each subprocess has a short timeout (3s). Hung git processes (e.g.
16
+ * waiting on a stuck lock) cannot block project-context computation.
17
+ *
18
+ * - We do not run any commands that mutate state. Only `git rev-parse`,
19
+ * `git status --porcelain`, `git log`. Read-only by construction.
20
+ */
21
+ import { spawn } from 'node:child_process';
22
+ const GIT_TIMEOUT_MS = 3000;
23
+ const EMPTY_STATE = {
24
+ isRepo: false,
25
+ branch: null,
26
+ headSha: null,
27
+ dirtyFileCount: 0,
28
+ recentCommits: [],
29
+ };
30
+ /**
31
+ * Inspect the git state at `cwd`. Returns sane defaults on every error
32
+ * (no git, no repo, subprocess timeout, etc.) — never throws.
33
+ */
34
+ export async function getGitState(cwd) {
35
+ // Check repo membership first; bails early on non-repos so we don't
36
+ // shell out four times unnecessarily.
37
+ const insideTree = await runGit(cwd, ['rev-parse', '--is-inside-work-tree']);
38
+ if (!insideTree.ok || insideTree.stdout.trim() !== 'true') {
39
+ return EMPTY_STATE;
40
+ }
41
+ const [branchResult, shaResult, statusResult, logResult] = await Promise.all([
42
+ runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']),
43
+ runGit(cwd, ['rev-parse', 'HEAD']),
44
+ runGit(cwd, ['status', '--porcelain']),
45
+ runGit(cwd, ['log', '-5', '--pretty=%h %s']),
46
+ ]);
47
+ const branch = branchResult.ok ? branchResult.stdout.trim() || null : null;
48
+ const headSha = shaResult.ok ? shaResult.stdout.trim() || null : null;
49
+ const dirtyFileCount = statusResult.ok
50
+ ? statusResult.stdout
51
+ .split('\n')
52
+ .filter((line) => line.trim().length > 0).length
53
+ : 0;
54
+ const recentCommits = logResult.ok
55
+ ? logResult.stdout
56
+ .split('\n')
57
+ .map((line) => line.trim())
58
+ .filter((line) => line.length > 0)
59
+ : [];
60
+ return {
61
+ isRepo: true,
62
+ branch,
63
+ headSha,
64
+ dirtyFileCount,
65
+ recentCommits,
66
+ };
67
+ }
68
+ /**
69
+ * Run a single git subprocess with a hard timeout. Resolves with
70
+ * `{ ok: false, stdout: '' }` on any failure (non-zero exit, ENOENT,
71
+ * timeout). Never rejects.
72
+ */
73
+ function runGit(cwd, args) {
74
+ return new Promise((resolve) => {
75
+ let resolved = false;
76
+ let stdout = '';
77
+ let child;
78
+ const finish = (ok) => {
79
+ if (resolved)
80
+ return;
81
+ resolved = true;
82
+ try {
83
+ child?.kill();
84
+ }
85
+ catch {
86
+ // ignore
87
+ }
88
+ resolve({ ok, stdout });
89
+ };
90
+ const timer = setTimeout(() => finish(false), GIT_TIMEOUT_MS);
91
+ try {
92
+ child = spawn('git', args, {
93
+ cwd,
94
+ windowsHide: true,
95
+ stdio: ['ignore', 'pipe', 'ignore'],
96
+ });
97
+ }
98
+ catch {
99
+ clearTimeout(timer);
100
+ finish(false);
101
+ return;
102
+ }
103
+ child.stdout?.on('data', (chunk) => {
104
+ stdout += chunk.toString('utf-8');
105
+ });
106
+ child.on('error', () => {
107
+ clearTimeout(timer);
108
+ finish(false);
109
+ });
110
+ child.on('close', (code) => {
111
+ clearTimeout(timer);
112
+ finish(code === 0);
113
+ });
114
+ });
115
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Project file index walker.
3
+ *
4
+ * Walks the project tree and produces a FileIndex — counts + paths +
5
+ * top-level dirs — without ever reading file contents. The model gets
6
+ * a structural view of what exists; if it needs content, it uses
7
+ * `file_read` (Phase 3 tool) on demand.
8
+ *
9
+ * Discipline:
10
+ *
11
+ * - Hard cap at 10,000 files traversed. Beyond that we mark the
12
+ * project as `truncated` and surface that to the model. Larger
13
+ * projects rely on on-demand glob/grep search rather than the
14
+ * static index.
15
+ *
16
+ * - At most 500 paths shipped in `FileIndex.paths`. Selected by
17
+ * relevance — source files first, sorted by modification time
18
+ * (most recent first) so the model sees what the user has been
19
+ * working on.
20
+ *
21
+ * - Honors .gitignore + .cspeachignore via a minimal in-tree parser.
22
+ * No external dependencies. The parser handles the common cases —
23
+ * name patterns, *.ext patterns, directory patterns. Edge-case
24
+ * .gitignore wildcards (e.g. ! negation, glob stars) are not fully
25
+ * supported; users with exotic ignore rules can lean on
26
+ * .cspeachignore for project-context-specific exclusions.
27
+ *
28
+ * - Always-skipped directories: node_modules, .git, dist, build,
29
+ * .cspeach, .abapforge, .next, .turbo, target. The first wave of
30
+ * walks before any .gitignore parse stage uses this safety net.
31
+ */
32
+ import { promises as fs } from 'node:fs';
33
+ import * as path from 'node:path';
34
+ /** Hard cap on files traversed. Beyond this the walk stops + truncated=true. */
35
+ const MAX_FILES_TRAVERSED = 10_000;
36
+ /** Cap on paths shipped in FileIndex. Selected by relevance. */
37
+ const MAX_PATHS_IN_INDEX = 500;
38
+ const ALWAYS_SKIPPED_DIRS = new Set([
39
+ 'node_modules',
40
+ '.git',
41
+ 'dist',
42
+ 'build',
43
+ '.cspeach',
44
+ '.abapforge',
45
+ '.next',
46
+ '.turbo',
47
+ 'target',
48
+ '.vscode',
49
+ '.idea',
50
+ ]);
51
+ /**
52
+ * Source-shaped extensions, prioritized when selecting the top 500
53
+ * paths shipped in the index. The model cares about these more than
54
+ * generated assets / lock files.
55
+ */
56
+ const SOURCE_EXTENSIONS = new Set([
57
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
58
+ '.abap',
59
+ '.cds', '.ddls', '.ddlx', '.dcls', '.bdef', '.srvd', '.srvb',
60
+ '.xml',
61
+ '.yaml', '.yml',
62
+ '.json',
63
+ '.md',
64
+ '.properties',
65
+ '.py', '.go', '.rs', '.java',
66
+ '.css', '.scss',
67
+ '.html',
68
+ ]);
69
+ /**
70
+ * Walk the project rooted at `cwd` and produce a FileIndex. Best-effort:
71
+ * IO errors during the walk are silently skipped (the offending dir/file
72
+ * just doesn't contribute to the count). Never throws.
73
+ */
74
+ export async function indexFiles(cwd) {
75
+ const ignoreMatchers = await loadIgnoreMatchers(cwd);
76
+ /** Files collected with their stat info — used to rank for the path list. */
77
+ const collected = [];
78
+ const byExtension = {};
79
+ let totalFiles = 0;
80
+ let truncated = false;
81
+ async function walk(dir, relPrefix) {
82
+ if (truncated)
83
+ return;
84
+ let entries;
85
+ try {
86
+ entries = await fs.readdir(dir, { withFileTypes: true });
87
+ }
88
+ catch {
89
+ return;
90
+ }
91
+ for (const ent of entries) {
92
+ if (truncated)
93
+ return;
94
+ const relPath = relPrefix ? `${relPrefix}/${ent.name}` : ent.name;
95
+ if (ent.isDirectory()) {
96
+ if (ALWAYS_SKIPPED_DIRS.has(ent.name))
97
+ continue;
98
+ if (matchesIgnore(ignoreMatchers, relPath, /* isDirectory */ true))
99
+ continue;
100
+ await walk(path.join(dir, ent.name), relPath);
101
+ }
102
+ else if (ent.isFile()) {
103
+ if (matchesIgnore(ignoreMatchers, relPath, /* isDirectory */ false))
104
+ continue;
105
+ if (totalFiles >= MAX_FILES_TRAVERSED) {
106
+ truncated = true;
107
+ return;
108
+ }
109
+ totalFiles++;
110
+ const ext = extOf(ent.name);
111
+ byExtension[ext] = (byExtension[ext] ?? 0) + 1;
112
+ // Read mtime cheaply; on error use 0 so the file sorts late.
113
+ let mtime = 0;
114
+ try {
115
+ const s = await fs.stat(path.join(dir, ent.name));
116
+ mtime = s.mtimeMs;
117
+ }
118
+ catch {
119
+ // ignore
120
+ }
121
+ collected.push({ path: relPath, ext, mtime });
122
+ }
123
+ }
124
+ }
125
+ await walk(cwd, '');
126
+ // Top-level dirs that survived the walk (i.e. weren't skipped).
127
+ let topLevelDirs = [];
128
+ try {
129
+ const topLevel = await fs.readdir(cwd, { withFileTypes: true });
130
+ topLevelDirs = topLevel
131
+ .filter((e) => e.isDirectory() && !ALWAYS_SKIPPED_DIRS.has(e.name) && !matchesIgnore(ignoreMatchers, e.name, true))
132
+ .map((e) => e.name)
133
+ .sort();
134
+ }
135
+ catch {
136
+ // leave empty
137
+ }
138
+ // Rank collected files: source files first, then by mtime descending.
139
+ collected.sort((a, b) => {
140
+ const aSource = SOURCE_EXTENSIONS.has(a.ext) ? 1 : 0;
141
+ const bSource = SOURCE_EXTENSIONS.has(b.ext) ? 1 : 0;
142
+ if (aSource !== bSource)
143
+ return bSource - aSource;
144
+ return b.mtime - a.mtime;
145
+ });
146
+ const paths = collected.slice(0, MAX_PATHS_IN_INDEX).map((f) => f.path);
147
+ return {
148
+ totalFiles,
149
+ byExtension,
150
+ topLevelDirs,
151
+ paths,
152
+ truncated,
153
+ };
154
+ }
155
+ async function loadIgnoreMatchers(cwd) {
156
+ const patterns = [];
157
+ for (const filename of ['.gitignore', '.cspeachignore']) {
158
+ const raw = await readFileSafe(path.join(cwd, filename));
159
+ if (raw === null)
160
+ continue;
161
+ for (const rawLine of raw.split('\n')) {
162
+ const pat = parseIgnoreLine(rawLine);
163
+ if (pat)
164
+ patterns.push(pat);
165
+ }
166
+ }
167
+ return { patterns };
168
+ }
169
+ function parseIgnoreLine(rawLine) {
170
+ const line = rawLine.replace(/^\s+|\s+$/g, '');
171
+ if (line.length === 0)
172
+ return null;
173
+ if (line.startsWith('#'))
174
+ return null;
175
+ // We don't support negation (!), root-anchored paths (/), or **.
176
+ // Anything more exotic than basename-or-glob is silently ignored —
177
+ // users who need that can rely on the ALWAYS_SKIPPED_DIRS hardcode
178
+ // or contribute back if needed.
179
+ if (line.startsWith('!'))
180
+ return null;
181
+ if (line.includes('/')) {
182
+ // Path-rooted patterns are partially handled — we treat "build/" or
183
+ // "logs" as a basename match. "a/b/c" is too specific for our v1.
184
+ if (!line.endsWith('/'))
185
+ return null;
186
+ }
187
+ const dirOnly = line.endsWith('/');
188
+ const pattern = dirOnly ? line.slice(0, -1) : line;
189
+ if (pattern.length === 0)
190
+ return null;
191
+ if (pattern.includes('*')) {
192
+ // Convert simple "*.ext" or "foo*" pattern to a regex.
193
+ // We only handle * (no ? no [..]).
194
+ const re = new RegExp('^' + pattern.split('*').map(escapeRegex).join('.*') + '$');
195
+ return {
196
+ source: rawLine,
197
+ dirOnly,
198
+ matches: (basename) => re.test(basename),
199
+ };
200
+ }
201
+ // Literal basename match.
202
+ return {
203
+ source: rawLine,
204
+ dirOnly,
205
+ matches: (basename) => basename === pattern,
206
+ };
207
+ }
208
+ function matchesIgnore(m, relPath, isDirectory) {
209
+ const basename = relPath.split('/').pop() ?? relPath;
210
+ for (const p of m.patterns) {
211
+ if (p.dirOnly && !isDirectory)
212
+ continue;
213
+ if (p.matches(basename))
214
+ return true;
215
+ }
216
+ return false;
217
+ }
218
+ function escapeRegex(s) {
219
+ return s.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
220
+ }
221
+ /* ── Small helpers ─────────────────────────────────────────────────── */
222
+ function extOf(filename) {
223
+ const idx = filename.lastIndexOf('.');
224
+ if (idx <= 0)
225
+ return '';
226
+ return filename.slice(idx).toLowerCase();
227
+ }
228
+ async function readFileSafe(p) {
229
+ try {
230
+ return await fs.readFile(p, 'utf-8');
231
+ }
232
+ catch {
233
+ return null;
234
+ }
235
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Project context module — public API.
3
+ *
4
+ * Phase 1 of Track B. As of Phase 1F this module is fully implemented;
5
+ * NOTHING IN THE EXISTING CLI IMPORTS IT YET. Phase 2 wires it into
6
+ * the agent's system prompt behind the CSPEACH_PROJECT_CONTEXT feature
7
+ * flag. Until then, this module is fully isolated — risk to existing
8
+ * CSPeach is zero by construction.
9
+ *
10
+ * Usage:
11
+ *
12
+ * import { getProjectContext, renderProjectContext } from './project-context/index.js';
13
+ *
14
+ * const ctx = await getProjectContext(process.cwd());
15
+ * if (ctx) {
16
+ * const block = renderProjectContext(ctx);
17
+ * // splice `block` into the system prompt
18
+ * }
19
+ *
20
+ * Returns null on any error or when nothing project-shaped is detected.
21
+ * Never throws — error handling is internal. See ./detect.ts for the
22
+ * detection priority order and ./types.ts for the structured shape.
23
+ */
24
+ import { readConventions } from './conventions.js';
25
+ import { detectProjectType } from './detect.js';
26
+ import { extractAbapCloud } from './domain/abap-cloud.js';
27
+ import { extractAbapGit } from './domain/abapgit.js';
28
+ import { extractCap } from './domain/cap.js';
29
+ import { extractFiori } from './domain/fiori.js';
30
+ import { getGitState } from './git.js';
31
+ import { indexFiles } from './index-files.js';
32
+ export { renderProjectContext } from './render.js';
33
+ /**
34
+ * Compute the full project context for a directory. Best-effort: returns
35
+ * null on hard failure (e.g. cwd doesn't exist), otherwise an envelope
36
+ * with whatever was detectable. Even projects of type 'unknown' return
37
+ * a non-null ProjectContext so callers can surface the SAP-only case
38
+ * (live ABAP work with no on-disk project).
39
+ *
40
+ * Phase 1F implementation. Phase 2 will wrap this in a feature-flag
41
+ * check at the agent loop's prompt assembly site.
42
+ */
43
+ export async function getProjectContext(cwd, options = {}) {
44
+ // Detection runs first — if cwd is bogus we bail completely.
45
+ const detected = await detectProjectType(cwd);
46
+ if (detected === null) {
47
+ // Even with no on-disk project, a SAP connection alone is enough
48
+ // to return SOMETHING. Otherwise null.
49
+ if (options.sapConnection) {
50
+ return assembleSapOnlyContext(cwd, options.sapConnection);
51
+ }
52
+ return null;
53
+ }
54
+ // Run independent extractors in parallel — they share no state.
55
+ const [conventions, index, git, domainObjects] = await Promise.all([
56
+ readConventions(cwd),
57
+ indexFiles(cwd),
58
+ getGitState(cwd),
59
+ extractDomain(cwd, detected),
60
+ ]);
61
+ return {
62
+ type: detected,
63
+ root: cwd,
64
+ conventions,
65
+ index,
66
+ entryPoints: [], // Phase 2/3 will populate per-type entry-point lists
67
+ domainObjects,
68
+ git,
69
+ sapConnection: options.sapConnection ?? null,
70
+ detectedAt: new Date().toISOString(),
71
+ };
72
+ }
73
+ /* ── Internal helpers ──────────────────────────────────────────────── */
74
+ /**
75
+ * Run the right domain extractor for the detected type. `mixed` is
76
+ * type-stubbed for now — recursive per-module assembly is a follow-up
77
+ * (the technical plan flags it as Phase 1F's "if you encounter it,
78
+ * type-stub it; assembly handles the recursion later" note).
79
+ */
80
+ async function extractDomain(cwd, type) {
81
+ switch (type) {
82
+ case 'fiori-ui5':
83
+ return extractFiori(cwd);
84
+ case 'cap-node':
85
+ return extractCap(cwd);
86
+ case 'abapgit':
87
+ return extractAbapGit(cwd);
88
+ case 'abap-cloud':
89
+ return extractAbapCloud(cwd);
90
+ case 'mixed':
91
+ // Stub. Assembly of per-module sub-contexts is Phase 1F+.
92
+ return { type: 'mixed', modules: [] };
93
+ case 'unknown':
94
+ default:
95
+ return { type: 'unknown' };
96
+ }
97
+ }
98
+ /**
99
+ * Build a minimal ProjectContext for the SAP-only case (caller passed
100
+ * a sapConnection but cwd doesn't resolve to any project shape). This
101
+ * is the most common CSPeach use case today: a consultant connected
102
+ * to live SAP via ADT with no on-disk project at all.
103
+ */
104
+ async function assembleSapOnlyContext(cwd, sapConnection) {
105
+ const conventions = await readConventions(cwd);
106
+ return {
107
+ type: 'unknown',
108
+ root: cwd,
109
+ conventions,
110
+ index: { totalFiles: 0, byExtension: {}, topLevelDirs: [], paths: [], truncated: false },
111
+ entryPoints: [],
112
+ domainObjects: { type: 'unknown' },
113
+ git: { isRepo: false, branch: null, headSha: null, dirtyFileCount: 0, recentCommits: [] },
114
+ sapConnection,
115
+ detectedAt: new Date().toISOString(),
116
+ };
117
+ }