@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,478 @@
1
+ /**
2
+ * CSPeach workspace folder — the user's "primary folder" for .cspeach.json
3
+ * artefacts.
4
+ *
5
+ * Why this exists: before v0.5.x, CLI saves landed in process.cwd() and
6
+ * @<file> tokens needed full absolute paths. Both pushed real friction onto
7
+ * the user — the path-copy circus. The workspace folder is one configurable
8
+ * directory that the CLI saves into and resolves bare @<name> tokens against.
9
+ *
10
+ * Default resolution (2026-05-08, "project-rooted workspace"):
11
+ * 1. `cfg.workspace_folder` in `~/.cspeach/config.toml` — explicit override,
12
+ * highest priority.
13
+ * 2. `<git-root>/.cspeach/` — when the cwd is inside a git repository.
14
+ * Each customer / project gets its own workspace; multi-project
15
+ * consultants don't have artefacts piling up in one folder.
16
+ * 3. `<cwd>/.cspeach/` — fallback when cwd isn't inside a git repo.
17
+ *
18
+ * Earlier (pre-2026-05-08) default was `~/CSPeach/`. A first-run migration
19
+ * prompt (see src/projects/migration.ts) offers to move existing artefacts
20
+ * into the new project-rooted workspace, or pin workspace_folder back to
21
+ * `~/CSPeach/` for users who prefer the old behaviour.
22
+ *
23
+ * The folder is created on first use — no `cspeach init` ceremony.
24
+ */
25
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
26
+ import { dirname, join, isAbsolute } from 'node:path';
27
+ import os from 'node:os';
28
+ import toml from '@iarna/toml';
29
+ import { loadConfig } from '../config/loader.js';
30
+ /**
31
+ * Walk up the directory tree from `startDir` looking for a `.git` entry
32
+ * (file or directory — submodules use a file). Returns the directory
33
+ * containing `.git`, or null when the search hits the filesystem root
34
+ * without finding one.
35
+ *
36
+ * Bounded by parent === dir, which terminates at "/" on POSIX and at
37
+ * each drive root on Windows.
38
+ */
39
+ export function findProjectRoot(startDir) {
40
+ let dir = startDir;
41
+ for (let i = 0; i < 200; i++) {
42
+ if (existsSync(join(dir, '.git')))
43
+ return dir;
44
+ const parent = dirname(dir);
45
+ if (parent === dir)
46
+ return null;
47
+ dir = parent;
48
+ }
49
+ return null;
50
+ }
51
+ /**
52
+ * Resolve the project-rooted default workspace path for a given cwd.
53
+ * Pure: does NOT touch the filesystem (other than the .git existence
54
+ * check via findProjectRoot). Caller decides whether to mkdir.
55
+ */
56
+ export function defaultWorkspaceFor(cwd) {
57
+ const root = findProjectRoot(cwd);
58
+ return join(root ?? cwd, '.cspeach');
59
+ }
60
+ /**
61
+ * If the workspace lives directly under a project root that has a
62
+ * .gitignore, append a `.cspeach/` line so per-developer state doesn't
63
+ * get committed by accident. Idempotent — skips if .cspeach/ is already
64
+ * listed under any of the common patterns. No-op if .gitignore is
65
+ * missing (we don't create one — that's the user's call).
66
+ */
67
+ function maybeAddToGitignore(workspaceDir) {
68
+ const projectRoot = dirname(workspaceDir);
69
+ const gitignorePath = join(projectRoot, '.gitignore');
70
+ if (!existsSync(gitignorePath))
71
+ return;
72
+ let content;
73
+ try {
74
+ content = readFileSync(gitignorePath, 'utf-8');
75
+ }
76
+ catch {
77
+ return;
78
+ }
79
+ const linesNorm = content.split('\n').map((l) => l.trim());
80
+ const already = linesNorm.some((l) => l === '.cspeach/' || l === '.cspeach' || l === '/.cspeach/' || l === '/.cspeach');
81
+ if (already)
82
+ return;
83
+ const sep = content.length === 0 || content.endsWith('\n') ? '' : '\n';
84
+ const block = `${sep}\n# CSPeach project workspace — per-developer state, not for commit.\n.cspeach/\n`;
85
+ try {
86
+ writeFileSync(gitignorePath, content + block, 'utf-8');
87
+ }
88
+ catch {
89
+ // Read-only filesystem / permission denied — silently skip; the workspace
90
+ // still works, the user just has to ignore it manually.
91
+ }
92
+ }
93
+ /**
94
+ * Resolve the workspace folder path, creating it if missing.
95
+ * Returns the absolute path. Idempotent.
96
+ */
97
+ export async function ensureWorkspace() {
98
+ const cfg = await loadConfig();
99
+ const configured = cfg.workspace_folder?.trim();
100
+ const target = configured && configured.length > 0
101
+ ? expandHome(configured)
102
+ : defaultWorkspaceFor(process.cwd());
103
+ const justCreated = !existsSync(target);
104
+ if (justCreated) {
105
+ mkdirSync(target, { recursive: true });
106
+ // Only add to .gitignore for default project-rooted workspaces.
107
+ // Explicit overrides (e.g. ~/CSPeach/, /shared/team/cspeach/) are
108
+ // outside any project tree by design — adding to a parent .gitignore
109
+ // would be overreach.
110
+ if (!configured)
111
+ maybeAddToGitignore(target);
112
+ }
113
+ return target;
114
+ }
115
+ /**
116
+ * Synchronous variant for callers that cannot await — most importantly
117
+ * Node's readline `completer` interface, which is sync. Reads the TOML
118
+ * config directly via fs.readFileSync. Falls back to project-rooted
119
+ * default on any error (missing config, parse failure, etc.) — the
120
+ * completer should never throw.
121
+ *
122
+ * Earlier revision used `require('@iarna/toml')` here, which threw in
123
+ * ESM context (cspeach-cli has `"type": "module"`) and silently broke
124
+ * Tab completion. Top-level import is correct for ESM modules.
125
+ */
126
+ export function ensureWorkspaceSync() {
127
+ let target = defaultWorkspaceFor(process.cwd());
128
+ try {
129
+ const cfgPath = join(os.homedir(), '.cspeach', 'config.toml');
130
+ if (existsSync(cfgPath)) {
131
+ const raw = readFileSync(cfgPath, 'utf-8');
132
+ const parsed = toml.parse(raw);
133
+ const configured = parsed.workspace_folder?.trim();
134
+ if (configured && configured.length > 0) {
135
+ target = expandHome(configured);
136
+ }
137
+ }
138
+ }
139
+ catch {
140
+ // Fall through to default — completer must never throw on a borked config.
141
+ }
142
+ if (!existsSync(target)) {
143
+ mkdirSync(target, { recursive: true });
144
+ }
145
+ return target;
146
+ }
147
+ /**
148
+ * Synchronous list of supported files in `workspace` — both
149
+ * `.cspeach.json` envelopes and `.txt` / `.md` text inputs. Mirrors
150
+ * listProjectFiles but skips the metadata parse (filename + kind +
151
+ * mtime only) since the readline completer only needs filenames.
152
+ * Lighter and faster for the per-keystroke hot path. Errors swallowed
153
+ * — see ensureWorkspaceSync.
154
+ */
155
+ export function listProjectFilesSync(workspace) {
156
+ try {
157
+ const entries = readdirSync(workspace);
158
+ const out = [];
159
+ for (const name of entries) {
160
+ const isEnvelope = name.endsWith('.cspeach.json');
161
+ const isText = isTextFilename(name);
162
+ if (!isEnvelope && !isText)
163
+ continue;
164
+ const full = join(workspace, name);
165
+ const stat = statSync(full);
166
+ if (!stat.isFile())
167
+ continue;
168
+ out.push({
169
+ filename: name,
170
+ kind: isEnvelope ? 'envelope' : 'text',
171
+ mtimeMs: stat.mtimeMs,
172
+ });
173
+ }
174
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
175
+ return out;
176
+ }
177
+ catch {
178
+ return [];
179
+ }
180
+ }
181
+ /**
182
+ * Expand a leading `~/` to the user's home directory. Other paths are
183
+ * returned unchanged.
184
+ */
185
+ function expandHome(p) {
186
+ if (p.startsWith('~/') || p === '~') {
187
+ return join(os.homedir(), p.slice(p === '~' ? 1 : 2));
188
+ }
189
+ return p;
190
+ }
191
+ /** File suffixes recognised as text-input candidates for skills. */
192
+ const TEXT_SUFFIXES = ['.txt', '.md'];
193
+ function isTextFilename(name) {
194
+ const lower = name.toLowerCase();
195
+ return TEXT_SUFFIXES.some((s) => lower.endsWith(s));
196
+ }
197
+ /**
198
+ * List supported workspace files (.cspeach.json envelopes AND .txt/.md
199
+ * text inputs), parsed for a tiny metadata summary. Sorted by mtime
200
+ * descending (most-recent first).
201
+ *
202
+ * Errors on individual envelope files (corrupt JSON, missing fields)
203
+ * yield an `unknown` summary rather than throwing — callers see the
204
+ * file name with no metadata so the user can investigate.
205
+ */
206
+ export async function listProjectFiles() {
207
+ const ws = await ensureWorkspace();
208
+ const entries = readdirSync(ws);
209
+ const out = [];
210
+ for (const name of entries) {
211
+ const isEnvelope = name.endsWith('.cspeach.json');
212
+ const isText = isTextFilename(name);
213
+ if (!isEnvelope && !isText)
214
+ continue;
215
+ const path = join(ws, name);
216
+ const stat = statSync(path);
217
+ if (!stat.isFile())
218
+ continue;
219
+ if (isEnvelope) {
220
+ let artefactType = 'unknown';
221
+ let version = null;
222
+ let title = null;
223
+ try {
224
+ const env = JSON.parse(readFileSync(path, 'utf8'));
225
+ if (env.artefactType)
226
+ artefactType = env.artefactType;
227
+ if (typeof env.version === 'number')
228
+ version = env.version;
229
+ if (typeof env.title === 'string')
230
+ title = env.title;
231
+ }
232
+ catch {
233
+ // leave defaults; the caller still sees the file
234
+ }
235
+ out.push({
236
+ path,
237
+ filename: name,
238
+ kind: 'envelope',
239
+ artefactType,
240
+ version,
241
+ title,
242
+ sizeBytes: stat.size,
243
+ mtimeMs: stat.mtimeMs,
244
+ });
245
+ }
246
+ else {
247
+ // text — no envelope metadata, just the file presence
248
+ out.push({
249
+ path,
250
+ filename: name,
251
+ kind: 'text',
252
+ artefactType: 'unknown',
253
+ version: null,
254
+ title: null,
255
+ sizeBytes: stat.size,
256
+ mtimeMs: stat.mtimeMs,
257
+ });
258
+ }
259
+ }
260
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
261
+ return out;
262
+ }
263
+ /**
264
+ * Inspect a `design` envelope and decide whether the design describes a
265
+ * RAP stack (Behavior Definition / Behavior Pool / Service Binding) or a
266
+ * classic build (report, class, function module). Used by the /files
267
+ * action menu and the /abap-design chain prompt to recommend the right
268
+ * BUILD skill — /abap-rap for RAP-shaped designs, /abap-generate for
269
+ * classic.
270
+ *
271
+ * Detection is conservative: a single RAP marker in any object's `type`
272
+ * field flags the whole design. The match is case-insensitive substring
273
+ * against the labels /abap-design produces (e.g. "BDEF", "Behavior
274
+ * Definition", "Service Binding"). Returns 'unknown' on any read/parse
275
+ * error so callers fall back to a neutral default ordering.
276
+ */
277
+ export function detectDesignShape(envelopePath) {
278
+ let objects;
279
+ try {
280
+ const env = JSON.parse(readFileSync(envelopePath, 'utf8'));
281
+ objects = env.content?.objects;
282
+ }
283
+ catch {
284
+ return 'unknown';
285
+ }
286
+ if (!Array.isArray(objects) || objects.length === 0)
287
+ return 'unknown';
288
+ const RAP_MARKERS = [
289
+ 'behavior definition', 'bdef',
290
+ 'behavior implementation', 'behavior pool',
291
+ 'service binding', 'srvb',
292
+ 'service definition', 'srvd',
293
+ ];
294
+ const hasRap = objects.some((o) => {
295
+ const type = String(o?.type ?? '').toLowerCase();
296
+ return RAP_MARKERS.some((m) => type.includes(m));
297
+ });
298
+ return hasRap ? 'rap' : 'classic';
299
+ }
300
+ /**
301
+ * Resolve a CLI `@<token>` argument:
302
+ * - absolute path → returned verbatim (no workspace lookup)
303
+ * - relative path (starts with ./, ../, or contains a path separator)
304
+ * → resolved against `cwd`
305
+ * - bare name → searched in the workspace folder. Substring match
306
+ * on filename. Single match wins; multiple → ambiguous;
307
+ * none → notFound.
308
+ *
309
+ * Substring rather than prefix so the user can type any unique fragment of
310
+ * a long auto-generated filename (e.g. `@b9b0` finds
311
+ * `sales-order-status-dashboard-spec-gap-b9b0-v2.cspeach.json`).
312
+ *
313
+ * Pure function except for the workspace-listing read; all other I/O lives
314
+ * in the caller. Easy to unit-test by injecting a fake `files` list.
315
+ */
316
+ export function resolveAtToken(args) {
317
+ const { token, cwd, workspace, files } = args;
318
+ if (isAbsolute(token)) {
319
+ return { kind: 'path', path: token };
320
+ }
321
+ if (token.startsWith('./') || token.startsWith('../') || token.startsWith('.\\') || token.startsWith('..\\')) {
322
+ return { kind: 'path', path: join(cwd, token) };
323
+ }
324
+ if (token.includes('/') || token.includes('\\')) {
325
+ // Other relative paths (no leading ./) — resolve against cwd, like a shell.
326
+ return { kind: 'path', path: join(cwd, token) };
327
+ }
328
+ // Bare name — workspace lookup.
329
+ const lower = token.toLowerCase();
330
+ const matches = files.filter((f) => f.filename.toLowerCase().includes(lower));
331
+ if (matches.length === 1) {
332
+ return { kind: 'path', path: matches[0].path };
333
+ }
334
+ if (matches.length === 0) {
335
+ return { kind: 'notFound', token, workspace };
336
+ }
337
+ return { kind: 'ambiguous', matches };
338
+ }
339
+ /**
340
+ * High-level resolver — combines listProjectFiles + resolveAtToken into a
341
+ * single async call. Callers in promote-command, status, etc. use this.
342
+ */
343
+ export async function resolveAtTokenAsync(token, cwd) {
344
+ const workspace = await ensureWorkspace();
345
+ const files = await listProjectFiles();
346
+ return resolveAtToken({ token, cwd, workspace, files });
347
+ }
348
+ /**
349
+ * Format `listProjectFiles` output as a human-readable block for the
350
+ * `/files` REPL command and the ambiguous-match error path.
351
+ *
352
+ * Columns: filename, artefactType, version, last-modified relative time.
353
+ * No headers — keeps the output dense and copy-pasteable.
354
+ */
355
+ export function formatProjectFileList(files, workspace) {
356
+ if (files.length === 0) {
357
+ return `Workspace: ${workspace}\n (empty — drop .cspeach.json envelopes or .txt/.md spec docs here)`;
358
+ }
359
+ const now = Date.now();
360
+ const lines = [`Workspace: ${workspace}`, ''];
361
+ for (const f of files) {
362
+ const ageStr = relativeTime(now - f.mtimeMs);
363
+ const meta = f.kind === 'envelope'
364
+ ? `${f.artefactType.padEnd(9)} v${f.version ?? '?'}`
365
+ : `text ${formatBytes(f.sizeBytes).padStart(7)}`;
366
+ lines.push(` ${f.filename} — ${meta} ${ageStr}`);
367
+ }
368
+ lines.push('');
369
+ lines.push(`${files.length} file(s). Use @<partial-name> to load — e.g. @${shortHint(files[0].filename)}`);
370
+ return lines.join('\n');
371
+ }
372
+ function formatBytes(n) {
373
+ if (n < 1024)
374
+ return `${n} B`;
375
+ if (n < 1024 * 1024)
376
+ return `${Math.round(n / 1024)} KB`;
377
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
378
+ }
379
+ function relativeTime(deltaMs) {
380
+ const m = Math.round(deltaMs / 60_000);
381
+ if (m < 1)
382
+ return 'just now';
383
+ if (m < 60)
384
+ return `${m} minute${m === 1 ? '' : 's'} ago`;
385
+ const h = Math.round(m / 60);
386
+ if (h < 24)
387
+ return `${h} hour${h === 1 ? '' : 's'} ago`;
388
+ const d = Math.round(h / 24);
389
+ return `${d} day${d === 1 ? '' : 's'} ago`;
390
+ }
391
+ function shortHint(filename) {
392
+ // First word of the filename (everything before the first hyphen) is
393
+ // usually a strong unique fragment.
394
+ const m = /^([^-]+)/.exec(filename);
395
+ return m ? m[1] : filename.slice(0, 8);
396
+ }
397
+ /**
398
+ * Cap on text-file content the model receives. Long text files would
399
+ * blow the model's context budget and bury the user's actual prompt.
400
+ * 64 KB is generous for typical requirement docs (a 20-page Word
401
+ * conversion clocks in around 30 KB), and chops cleanly on a UTF-8
402
+ * char boundary because we slice the decoded string, not the bytes.
403
+ */
404
+ const MAX_TEXT_FILE_CHARS = 64 * 1024;
405
+ /**
406
+ * Scan `userMessage` for `@<token>` references that resolve to .txt /
407
+ * .md files in the workspace, read their content, and substitute the
408
+ * token with an `<attached file="...">` block containing the file
409
+ * body. Returns the expanded message.
410
+ *
411
+ * Tokens that resolve to .cspeach.json envelopes are LEFT ALONE —
412
+ * those are handled by the --from / --status code paths and substitute
413
+ * here would double-handle them. Tokens that don't resolve to any
414
+ * file in the workspace are also left alone (treated as literal text)
415
+ * with a warning emitted via the optional `log` callback.
416
+ *
417
+ * Used by runTurn before the user message hits the LLM, so any text
418
+ * file the user references gets ingested as input context.
419
+ */
420
+ export async function expandTextFileAttachments(userMessage, cwd, log) {
421
+ // Match @<word> tokens. Same regex as parseFromFlag's anchor but
422
+ // we keep it global to find every occurrence.
423
+ const tokenRe = /@(?:"([^"]+)"|(\S+))/g;
424
+ const matches = [];
425
+ let m;
426
+ while ((m = tokenRe.exec(userMessage)) !== null) {
427
+ const token = m[1] ?? m[2] ?? '';
428
+ if (token.length === 0)
429
+ continue;
430
+ matches.push({ match: m[0], token, index: m.index });
431
+ }
432
+ if (matches.length === 0)
433
+ return userMessage;
434
+ // Resolve each token; only act on those pointing to .txt / .md files
435
+ // that exist on disk. Build a list of substitutions to apply.
436
+ const substitutions = [];
437
+ for (const { match, token, index } of matches) {
438
+ const resolved = await resolveAtTokenAsync(token, cwd);
439
+ if (resolved.kind !== 'path')
440
+ continue;
441
+ const path = resolved.path;
442
+ if (!isTextFilename(path))
443
+ continue;
444
+ // Only ingest if the file exists. Missing files are just left as
445
+ // literal @<token> in the message (the LLM gets to see them too).
446
+ if (!existsSync(path)) {
447
+ if (log)
448
+ log(`(attached @${token} not found in workspace — left as literal)`);
449
+ continue;
450
+ }
451
+ let content;
452
+ try {
453
+ content = readFileSync(path, 'utf8');
454
+ }
455
+ catch (err) {
456
+ if (log)
457
+ log(`(could not read @${token}: ${err.message})`);
458
+ continue;
459
+ }
460
+ if (content.length > MAX_TEXT_FILE_CHARS) {
461
+ content = content.slice(0, MAX_TEXT_FILE_CHARS) + '\n... [truncated, file exceeds 64 KB]';
462
+ }
463
+ const filename = path.split(/[\\\/]/).pop() ?? token;
464
+ const replacement = `<attached file="${filename}">\n${content}\n</attached>`;
465
+ substitutions.push({ index, matchLen: match.length, replacement });
466
+ if (log)
467
+ log(`(attached ${filename} — ${formatBytes(Buffer.byteLength(content, 'utf8'))})`);
468
+ }
469
+ if (substitutions.length === 0)
470
+ return userMessage;
471
+ // Apply substitutions back-to-front so earlier indices stay valid.
472
+ substitutions.sort((a, b) => b.index - a.index);
473
+ let out = userMessage;
474
+ for (const s of substitutions) {
475
+ out = out.slice(0, s.index) + s.replacement + out.slice(s.index + s.matchLen);
476
+ }
477
+ return out;
478
+ }
@@ -0,0 +1,121 @@
1
+ // C:\Users\Admin\ClaudeProjects\ABAPForge\cspeach-cli\src\renderer\abap-inline.ts
2
+ /**
3
+ * Whole-text syntax-highlight pass for fenced code blocks.
4
+ *
5
+ * v0.1.1 shipped this file as a regex-based ABAP-only highlighter
6
+ * (function `applyAbapHighlight`). v0.2 extends it to dispatch by language:
7
+ * ```abap → existing ABAP regex logic (unchanged)
8
+ * ```cds → cli-highlight CDS grammar (via syntax.ts)
9
+ * ```bdef → cli-highlight BDEF grammar (via syntax.ts)
10
+ * ```<any other or empty> → pass through (no highlight)
11
+ *
12
+ * The function is renamed `applyCodeHighlight`. A deprecation re-export
13
+ * `applyAbapHighlight` is kept because `pipeline.ts` currently imports that
14
+ * name; removing the re-export would require an edit to pipeline.ts, which
15
+ * is out of scope for this task.
16
+ */
17
+ import chalk from 'chalk';
18
+ import { highlightCodeBlock } from './syntax.js';
19
+ const ABAP_KEYWORDS = new Set([
20
+ 'DATA', 'TYPES', 'CONSTANTS', 'CLASS', 'INTERFACE', 'METHOD', 'ENDMETHOD',
21
+ 'ENDCLASS', 'ENDINTERFACE', 'IF', 'ELSE', 'ELSEIF', 'ENDIF', 'LOOP',
22
+ 'ENDLOOP', 'DO', 'ENDDO', 'WHILE', 'ENDWHILE', 'TRY', 'CATCH', 'CLEANUP',
23
+ 'ENDTRY', 'RAISE', 'SELECT', 'FROM', 'WHERE', 'INTO', 'TABLE', 'FOR',
24
+ 'ALL', 'ENTRIES', 'IN', 'CALL', 'FUNCTION', 'METHOD', 'PERFORMING',
25
+ 'PERFORM', 'WRITE', 'MOVE', 'APPEND', 'READ', 'DELETE', 'INSERT',
26
+ 'MODIFY', 'ASSIGN', 'UNASSIGN', 'CHECK', 'RETURN', 'EXIT', 'CONTINUE',
27
+ 'CASE', 'WHEN', 'ENDCASE', 'NEW', 'CREATE', 'OBJECT', 'FIELD-SYMBOLS',
28
+ 'TYPE', 'REF', 'TO', 'IMPORTING', 'EXPORTING', 'CHANGING', 'RETURNING',
29
+ 'EXCEPTIONS', 'PUBLIC', 'PROTECTED', 'PRIVATE', 'SECTION', 'DEFINITION',
30
+ 'IMPLEMENTATION', 'INHERITING', 'INTERFACES', 'ALIASES', 'ABSTRACT',
31
+ 'FINAL', 'STATIC', 'REDEFINITION', 'AND', 'OR', 'NOT', 'IS', 'INITIAL',
32
+ 'BOUND', 'INSTANCE', 'LOCAL', 'FRIENDS', 'DEFERRED',
33
+ ]);
34
+ // Capture the language tag on the opening fence (was: /^```(abap)?(\s*)$/i).
35
+ // Now accepts any alphanumeric/_/- lang slug and makes it available as m[1].
36
+ const OPEN_FENCE_RE = /^```([a-z0-9_-]*)\s*$/i;
37
+ const CLOSE_FENCE_RE = /^```\s*$/;
38
+ /** Whole-text pass. Splits by fenced blocks; routes each block by lang. */
39
+ export function applyCodeHighlight(text) {
40
+ const lines = text.split('\n');
41
+ const out = [];
42
+ let inFence = false;
43
+ let lang = '';
44
+ let blockLines = [];
45
+ for (const line of lines) {
46
+ if (!inFence) {
47
+ const m = OPEN_FENCE_RE.exec(line);
48
+ if (m) {
49
+ inFence = true;
50
+ lang = (m[1] ?? '').toLowerCase();
51
+ blockLines = [];
52
+ out.push(line);
53
+ continue;
54
+ }
55
+ out.push(line);
56
+ continue;
57
+ }
58
+ // inFence === true
59
+ if (CLOSE_FENCE_RE.test(line)) {
60
+ const body = blockLines.join('\n');
61
+ if (lang === 'abap') {
62
+ // Preserve v0.1.1 regex-based highlighter for ABAP — it is stable,
63
+ // has been tested in production, and avoids a second runtime
64
+ // dependency for the hottest fenced-block type.
65
+ out.push(highlightAbapBlock(body));
66
+ }
67
+ else if (lang === 'cds' || lang === 'bdef') {
68
+ // cli-highlight grammars via the Task 5 dispatcher.
69
+ out.push(highlightCodeBlock(body, lang));
70
+ }
71
+ else {
72
+ // Unknown / empty lang → pass through unchanged.
73
+ out.push(body);
74
+ }
75
+ out.push(line);
76
+ inFence = false;
77
+ lang = '';
78
+ blockLines = [];
79
+ continue;
80
+ }
81
+ blockLines.push(line);
82
+ }
83
+ // Unterminated fence at EOF — emit what we buffered without highlighting.
84
+ if (inFence && blockLines.length > 0) {
85
+ out.push(blockLines.join('\n'));
86
+ }
87
+ return out.join('\n');
88
+ }
89
+ /**
90
+ * Deprecated name kept for pipeline.ts compatibility. New call sites should
91
+ * import `applyCodeHighlight` directly.
92
+ */
93
+ export { applyCodeHighlight as applyAbapHighlight };
94
+ function highlightAbapBlock(block) {
95
+ return block.split('\n').map(highlightLine).join('\n');
96
+ }
97
+ function highlightLine(line) {
98
+ // Comment: full line starting with * or " (ABAP inline comment)
99
+ const trimmed = line.trimStart();
100
+ if (trimmed.startsWith('*') || trimmed.startsWith('"')) {
101
+ return chalk.gray(line);
102
+ }
103
+ // Tokenise: strings first (to avoid coloring contents), then the rest.
104
+ // Z/Y prefix heuristic: matches any word starting with Z or Y — case-insensitive
105
+ // on the prefix letter because modern ABAP (Eclipse ADT / Clean ABAP) is typically
106
+ // lowercase (`zcl_my_class`) while legacy tooling uses UPPERCASE (`ZCL_MY_CLASS`).
107
+ // The `\w+` tail accepts either case. May false-positive on English words like
108
+ // "Yes", "Zebra" if they appear as bare identifiers — acceptable trade-off for
109
+ // zero-config highlighting.
110
+ return line.replace(/('(?:[^']|'')*')|(\b[ZYzy]\w+)|(\b(?:0x[\da-fA-F]+|\d+(?:\.\d+)?)\b)|(\b[A-Z_][A-Z0-9_-]*\b)/g, (match, str, zy, num, word) => {
111
+ if (str !== undefined)
112
+ return chalk.green(str);
113
+ if (zy !== undefined)
114
+ return chalk.yellow(match);
115
+ if (num !== undefined)
116
+ return chalk.magenta(match);
117
+ if (word !== undefined && ABAP_KEYWORDS.has(word.toUpperCase()))
118
+ return chalk.cyan(match);
119
+ return match;
120
+ });
121
+ }
@@ -0,0 +1,39 @@
1
+ import chalk from 'chalk';
2
+ /** CSPeach brand colour — used for the brand-name text in the startup line,
3
+ * the tool-dispatch bullet (⏺), and the result continuation glyph (⎿).
4
+ * Single source of truth so a future rebrand is one find-replace.
5
+ * The startup banner uses the 🍑 emoji directly (no tint) for identity. */
6
+ export const PEACH = '#FFCBA4';
7
+ /**
8
+ * Minimal startup line — terse, single bullet, three lines max. Modeled on
9
+ * Claude Code's `✻ Welcome to Claude Code v1.0.x` pattern but with the
10
+ * CSPeach brand glyph (Georgia peach emoji) replacing the generic star.
11
+ * No box, no fanfare — get out of the way.
12
+ *
13
+ * Renders:
14
+ * 🍑 CSPeach v0.5.0
15
+ * ABAP + AI + safety gates · by Cremencing Solutions
16
+ *
17
+ * Note on indent — the 🍑 emoji renders as 2 cells in modern terminals
18
+ * (Windows Terminal, iTerm2, Git Bash). Line 2 uses 3 leading spaces so
19
+ * the tagline column-aligns with the brand-name letters above. The emoji
20
+ * is intentionally NOT chalk-colored — terminals render emoji with their
21
+ * native color glyph and applying a tint usually corrupts that.
22
+ *
23
+ * The fallback literal is the published version — bumped here whenever
24
+ * the CLI is released. npm_package_version is set when the binary runs
25
+ * via `npm exec` / `pnpm`, but unset for direct `cspeach` shim launches,
26
+ * which is why the literal exists at all.
27
+ */
28
+ export function printStartupBanner() {
29
+ const version = process.env.npm_package_version ?? '0.5.0';
30
+ console.log('');
31
+ console.log(`🍑 ${chalk.hex(PEACH).bold('CSPeach')} ${chalk.white.bold('v' + version)}`);
32
+ console.log(` ${chalk.gray('ABAP + AI + safety gates')} ${chalk.dim('·')} ${chalk.gray('by Cremencing Solutions')}`);
33
+ console.log('');
34
+ }
35
+ export function printKeytarFallbackBanner() {
36
+ console.log(chalk.yellow('⚠ OS keychain unavailable on this system.'));
37
+ console.log(chalk.yellow(' API key + SAP passwords will be prompted per session.'));
38
+ console.log(chalk.yellow(' Run `cspeach doctor` for remediation hints.\n'));
39
+ }