@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,33 @@
1
+ import { SignJWT, jwtVerify } from 'jose';
2
+ import crypto from 'node:crypto';
3
+ // Session-scoped HMAC key — generated per CLI start, never persisted.
4
+ const sessionKey = crypto.randomBytes(32);
5
+ const usedNonces = new Set();
6
+ export async function mintApprovalId(payload) {
7
+ const nonce = crypto.randomBytes(16).toString('hex');
8
+ return new SignJWT({ ...payload, nonce })
9
+ .setProtectedHeader({ alg: 'HS256' })
10
+ .setIssuedAt()
11
+ .setExpirationTime('5m')
12
+ .sign(sessionKey);
13
+ }
14
+ export async function verifyAndSpendApprovalId(jwt, expectedObject, expectedOp) {
15
+ try {
16
+ const { payload } = await jwtVerify(jwt, sessionKey);
17
+ const p = payload;
18
+ if (usedNonces.has(p.nonce))
19
+ return { ok: false, reason: 'nonce_spent' };
20
+ if (p.object !== expectedObject)
21
+ return { ok: false, reason: 'object_mismatch' };
22
+ if (p.op !== expectedOp)
23
+ return { ok: false, reason: 'op_mismatch' };
24
+ usedNonces.add(p.nonce);
25
+ return { ok: true, payload: p };
26
+ }
27
+ catch (err) {
28
+ if (String(err?.code).includes('JWTExpired') || String(err?.message).toLowerCase().includes('expired')) {
29
+ return { ok: false, reason: 'expired' };
30
+ }
31
+ return { ok: false, reason: 'invalid_signature' };
32
+ }
33
+ }
@@ -0,0 +1,211 @@
1
+ import { select, input } from '@inquirer/prompts';
2
+ import chalk from 'chalk';
3
+ import { shouldUseInk } from '../renderer/tty.js';
4
+ import { approvalEmitter } from '../ui/approval-emitter.js';
5
+ import { withInquirer } from '../repl/inquirer-guard.js';
6
+ import { inquirerTheme } from '../repl/inquirer-theme.js';
7
+ import { PEACH } from '../renderer/banners.js';
8
+ const peach = (s) => chalk.hex(PEACH)(s);
9
+ /**
10
+ * Op-row glyph. 2026-05-01: dropped the per-op color spread (yellow M,
11
+ * green A, blue >, magenta ^). Using 5 different hues for routine plan
12
+ * rows trains the user to ignore color as semantic signal — by the time
13
+ * an actual delete shows up, red has already appeared on three other
14
+ * lines and lost its punch. Now: only `D` (delete) gets red, because
15
+ * delete is the only op that's destructive enough to deserve a hot
16
+ * accent. Everything else renders dim — the row layout already tells
17
+ * the user it's a change row.
18
+ */
19
+ function opIcon(op) {
20
+ switch (op) {
21
+ case 'create': return chalk.dim('A');
22
+ case 'modify': return chalk.dim('M');
23
+ case 'delete': return chalk.red('D');
24
+ case 'activate': return chalk.dim('>');
25
+ case 'release': return chalk.dim('^');
26
+ default: return chalk.dim('?');
27
+ }
28
+ }
29
+ /**
30
+ * Risk tag. 2026-05-01: yellow used to fire on `medium` too, which
31
+ * meant most plans had a yellow tag — defeating yellow's purpose as
32
+ * an "attention required" signal. Now: yellow ONLY on `high`,
33
+ * everything else dim. (Risk is also raised to red.bold for high so
34
+ * it pops without yellow doing extra work.)
35
+ */
36
+ function riskTag(r) {
37
+ if (r === 'high')
38
+ return chalk.red.bold('high risk');
39
+ return chalk.dim(r === 'medium' ? 'medium risk' : 'low risk');
40
+ }
41
+ /**
42
+ * Colorize a unified-diff string for terminal display:
43
+ * - `+ …` (additions) → green
44
+ * - `- …` (removals) → red
45
+ * - `@@ …` (hunk headers) → peach (brand)
46
+ * - `--- …` / `+++ …` headers → dim (informational, not part of the change)
47
+ * - context lines → dim
48
+ *
49
+ * Operates line-by-line so a long diff stays readable. The leading +/- char
50
+ * is part of the colored span, matching standard `git diff` rendering.
51
+ */
52
+ function colorizeDiff(diff) {
53
+ return diff
54
+ .split('\n')
55
+ .map((line) => {
56
+ if (line.startsWith('+++') || line.startsWith('---'))
57
+ return chalk.dim(line);
58
+ if (line.startsWith('@@'))
59
+ return peach(line);
60
+ if (line.startsWith('+'))
61
+ return chalk.green(line);
62
+ if (line.startsWith('-'))
63
+ return chalk.red(line);
64
+ return chalk.dim(line);
65
+ })
66
+ .join('\n');
67
+ }
68
+ /**
69
+ * Plan-level gate. Renders a compact summary of the change set and asks the
70
+ * user to apply-all, review-each, or cancel. Apply-all is the default —
71
+ * matches how users actually operate ("yes, do it") and skips the redundant
72
+ * per-change prompts that were the main "so much confusion" complaint.
73
+ *
74
+ * Color discipline:
75
+ * - Peach (PEACH) for the brand-bearing "Plan" header.
76
+ * - Default text for the summary, change list. No cyan, no bold-on-bold.
77
+ * - Yellow tag for medium risk, red for high. Low is dim — no shouting.
78
+ */
79
+ /**
80
+ * Collapse the changes list for *display purposes only* — `activate` is the
81
+ * implicit follow-on of a `modify`/`create` on the same object, so we hide
82
+ * the activate row when its sibling write is already in the list. The
83
+ * underlying `changes[]` (which drives approval-id minting and per-change
84
+ * approval prompts) is unchanged — only the user-facing plan summary is
85
+ * compressed. This is what fixes the "what 2 changes?" complaint when the
86
+ * model declares modify+activate of the same object.
87
+ */
88
+ function collapseImplicitActivates(changes) {
89
+ const writeKeys = new Set();
90
+ for (const c of changes) {
91
+ if (c.op === 'modify' || c.op === 'create') {
92
+ writeKeys.add(`${c.type}:${c.object}`);
93
+ }
94
+ }
95
+ return changes.filter((c) => {
96
+ if (c.op !== 'activate')
97
+ return true;
98
+ return !writeKeys.has(`${c.type}:${c.object}`);
99
+ });
100
+ }
101
+ export async function renderPlanGate(summary, changes, transport, effectiveRisk) {
102
+ const visibleChanges = collapseImplicitActivates(changes);
103
+ const visibleCount = visibleChanges.length;
104
+ const noun = visibleCount === 1 ? 'change' : 'changes';
105
+ console.log('');
106
+ console.log(`${peach('Plan')} ${summary}`);
107
+ const meta = [`${visibleCount} ${noun}`, riskTag(effectiveRisk.level)];
108
+ if (transport)
109
+ meta.push(`transport ${transport}`);
110
+ console.log(` ${chalk.dim(meta.join(' · '))}`);
111
+ if (effectiveRisk.raisedBy.length > 0) {
112
+ console.log(` ${chalk.dim(`(raised from low: ${effectiveRisk.raisedBy.join(', ')})`)}`);
113
+ }
114
+ for (const c of visibleChanges) {
115
+ console.log(` ${opIcon(c.op)} ${c.object.padEnd(30)} ${chalk.dim(`(${c.type})`)}`);
116
+ }
117
+ console.log('');
118
+ const choice = await withInquirer(() => select({
119
+ message: 'Apply?',
120
+ default: 'apply_all',
121
+ theme: inquirerTheme,
122
+ choices: [
123
+ { value: 'apply_all', name: `Apply all ${visibleCount} ${noun}` },
124
+ { value: 'review', name: 'Review each change individually' },
125
+ { value: 'cancel', name: 'Cancel' },
126
+ ],
127
+ }));
128
+ return choice;
129
+ }
130
+ export async function renderPerChangeApproval(change, effectiveRisk, transport) {
131
+ // No "Approval requested" preamble — the inquirer message below carries the
132
+ // change identifier. The risk + transport line was redundant with what
133
+ // renderPlanGate already showed, and was the main source of "why so much
134
+ // text for one prompt" feedback. Show it ONLY when this per-change call
135
+ // fires standalone (single-change request_approval) AND risk is non-low.
136
+ void effectiveRisk;
137
+ void transport;
138
+ const objLabel = `${opIcon(change.op)} ${change.object} (${change.type})`;
139
+ const choice = await withInquirer(() => select({
140
+ message: `${objLabel} — apply?`,
141
+ default: 'apply',
142
+ theme: inquirerTheme,
143
+ choices: [
144
+ { value: 'apply', name: 'Apply' },
145
+ { value: 'diff', name: 'Show diff' },
146
+ { value: 'skip', name: 'Skip (tell model why)' },
147
+ { value: 'cancel', name: 'Cancel turn' },
148
+ ],
149
+ }));
150
+ if (choice === 'apply')
151
+ return { approved: true };
152
+ if (choice === 'cancel')
153
+ return { approved: false, reason: '__cancel_turn__' };
154
+ if (choice === 'skip') {
155
+ const reason = await withInquirer(() => input({ message: 'Why skip?', theme: inquirerTheme }));
156
+ return { approved: false, reason };
157
+ }
158
+ // 'diff' — print colorized + lines (green) / - lines (red) / hunk headers
159
+ // (peach) so the user can scan the change at a glance instead of squinting
160
+ // at flat-gray text.
161
+ //
162
+ // 2026-05-01: detect "model sent a description, not a diff" — when the
163
+ // payload has no +/-/@ lines and no `--- /+++ ` headers, colorizeDiff
164
+ // would render every line in dim grey, making the summary look like a
165
+ // useless diff and forcing the user to hit Skip in frustration. Show an
166
+ // explicit warning instead so the user knows the model didn't supply
167
+ // proper diff content.
168
+ if (change.diff) {
169
+ if (looksLikeUnifiedDiff(change.diff)) {
170
+ console.log('\n' + colorizeDiff(change.diff) + '\n');
171
+ }
172
+ else {
173
+ console.log('\n' + chalk.yellow('⚠ Model did not provide a unified diff — only a description:'));
174
+ console.log(chalk.dim(change.diff) + '\n');
175
+ }
176
+ }
177
+ else {
178
+ console.log(chalk.dim('(No diff provided by the model)'));
179
+ }
180
+ return renderPerChangeApproval(change, effectiveRisk, transport);
181
+ }
182
+ /**
183
+ * Heuristic: a real unified diff has at least one line starting with `+`,
184
+ * `-`, or `@@`. A summary or change description won't. Used to gate
185
+ * colorizeDiff vs the "model didn't supply a diff" warning so the Show diff
186
+ * option doesn't return useless dim grey text when the model passed a
187
+ * one-paragraph description in the `diff` field.
188
+ */
189
+ function looksLikeUnifiedDiff(s) {
190
+ return s.split('\n').some((line) => {
191
+ if (line.startsWith('+++') || line.startsWith('---'))
192
+ return false;
193
+ return line.startsWith('+') || line.startsWith('-') || line.startsWith('@@');
194
+ });
195
+ }
196
+ /**
197
+ * v0.3 dispatcher: routes to Ink modal (via approvalEmitter) when running
198
+ * under Ink, else delegates to the existing inquirer-based flow.
199
+ */
200
+ export async function renderPerChangeApprovalV3(change, riskResult, transport) {
201
+ if (!shouldUseInk()) {
202
+ // Non-TTY: delegate to existing inquirer flow.
203
+ return renderPerChangeApproval(change, riskResult, transport);
204
+ }
205
+ return renderApprovalModalAndAwait(change, riskResult, transport);
206
+ }
207
+ async function renderApprovalModalAndAwait(change, riskResult, transport) {
208
+ return new Promise((resolve) => {
209
+ approvalEmitter.emit('request', { change, effectiveRisk: riskResult, transport, resolve });
210
+ });
211
+ }
@@ -0,0 +1,26 @@
1
+ export function effectiveRisk(declared, changes, ctx) {
2
+ let floor = declared;
3
+ const raisedBy = [];
4
+ const rank = (r) => ({ low: 0, medium: 1, high: 2 }[r]);
5
+ const raise = (r, label) => {
6
+ if (rank(r) > rank(floor)) {
7
+ floor = r;
8
+ raisedBy.push(label);
9
+ }
10
+ };
11
+ if (ctx.autoApprovePolicy === 'never')
12
+ raise('high', 'non_prod_override');
13
+ if (changes.some((c) => c.op === 'delete'))
14
+ raise('medium', 'delete_op');
15
+ if (changes.some((c) => c.op === 'release' || c.op === 'activate'))
16
+ raise('medium', 'shared_transport');
17
+ if (ctx.recentAtcFailure && changes.some((c) => ctx.recentAtcFailure.includes(c.object)))
18
+ raise('medium', 'atc_failed_24h');
19
+ if ((ctx.transportRecentDevelopers ?? 0) >= 3)
20
+ raise('medium', 'shared_transport');
21
+ if (changes.length > 5)
22
+ raise('medium', 'escalated_in_session');
23
+ if (ctx.prodNamespaces && changes.some((c) => ctx.prodNamespaces.some((p) => c.object.startsWith(p))))
24
+ raise('medium', 'prod_namespace');
25
+ return { level: floor, raisedBy };
26
+ }
@@ -0,0 +1,40 @@
1
+ import keytar from 'keytar';
2
+ const SERVICE = 'cspeach';
3
+ const ACCOUNT = 'api-key';
4
+ class KeytarStore {
5
+ async get() { return keytar.getPassword(SERVICE, ACCOUNT); }
6
+ async set(key) { await keytar.setPassword(SERVICE, ACCOUNT, key); }
7
+ async delete() { await keytar.deletePassword(SERVICE, ACCOUNT); }
8
+ async isKeychainAvailable() {
9
+ try {
10
+ const probe = `probe-${Date.now()}`;
11
+ await keytar.setPassword(SERVICE, 'probe', probe);
12
+ const ok = (await keytar.getPassword(SERVICE, 'probe')) === probe;
13
+ await keytar.deletePassword(SERVICE, 'probe');
14
+ return ok;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ }
21
+ class EphemeralStore {
22
+ cached = null;
23
+ async get() { return this.cached; }
24
+ async set(key) { this.cached = key; }
25
+ async delete() { this.cached = null; }
26
+ async isKeychainAvailable() { return false; }
27
+ }
28
+ let store;
29
+ export async function getStore() {
30
+ if (store)
31
+ return store;
32
+ const ktr = new KeytarStore();
33
+ if (await ktr.isKeychainAvailable()) {
34
+ store = ktr;
35
+ }
36
+ else {
37
+ store = new EphemeralStore();
38
+ }
39
+ return store;
40
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Read/write ~/.cspeach/auth.json — the customer's CSPeach API key
3
+ * + optional Anthropic key for BYOK trials.
4
+ *
5
+ * Permissions: 0600 on POSIX. Windows uses NTFS ACLs which Node's
6
+ * fs mode flag doesn't translate; we rely on the user-dir default
7
+ * permissions on Windows and recommend OS keychain (v2 roadmap)
8
+ * for that platform.
9
+ *
10
+ * Coexists with the existing keytar-based auth/api-key.ts. The
11
+ * device-code flow (cspeach login) writes here; older config-set
12
+ * paths still write to keytar. Task 9 wires the REPL to read here
13
+ * first, fall back to keytar.
14
+ */
15
+ import { promises as fs } from 'node:fs';
16
+ import * as path from 'node:path';
17
+ import { cspeachRoot } from '../config/paths.js';
18
+ let pathOverride = null;
19
+ /** Test-only override; pass null to reset. */
20
+ export function __setAuthFilePathForTests(p) {
21
+ pathOverride = p;
22
+ }
23
+ function authFilePath() {
24
+ return pathOverride ?? path.join(cspeachRoot(), 'auth.json');
25
+ }
26
+ export async function readAuthFile() {
27
+ try {
28
+ const raw = await fs.readFile(authFilePath(), 'utf-8');
29
+ const parsed = JSON.parse(raw);
30
+ if (typeof parsed.apiKey !== 'string' || typeof parsed.customerId !== 'string')
31
+ return null;
32
+ return {
33
+ apiKey: parsed.apiKey,
34
+ customerId: parsed.customerId,
35
+ byokAnthropicKey: typeof parsed.byokAnthropicKey === 'string' ? parsed.byokAnthropicKey : null,
36
+ };
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ export async function writeAuthFile(auth) {
43
+ const p = authFilePath();
44
+ await fs.mkdir(path.dirname(p), { recursive: true });
45
+ await fs.writeFile(p, JSON.stringify(auth, null, 2), { encoding: 'utf-8', mode: 0o600 });
46
+ // Defensive: re-chmod in case the create-time mode was ignored (e.g., umask 022).
47
+ if (process.platform !== 'win32') {
48
+ try {
49
+ await fs.chmod(p, 0o600);
50
+ }
51
+ catch { /* ignore */ }
52
+ }
53
+ }
54
+ export async function deleteAuthFile() {
55
+ try {
56
+ await fs.unlink(authFilePath());
57
+ }
58
+ catch { /* ignore */ }
59
+ }
@@ -0,0 +1,8 @@
1
+ // node-machine-id is CJS — import the default, then destructure.
2
+ import nodeMachineId from 'node-machine-id';
3
+ import os from 'node:os';
4
+ const { machineId } = nodeMachineId;
5
+ export async function getDeviceFingerprint() {
6
+ const mid = await machineId(true).catch(() => 'unknown');
7
+ return { machineId: mid, hostname: os.hostname() };
8
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Fetch /v1/me — customer profile + entitlements + usage.
3
+ * Result envelope so the REPL doesn't need try/catch.
4
+ */
5
+ export async function fetchMe(opts) {
6
+ const url = `${opts.proxyUrl.replace(/\/$/, '')}/v1/me`;
7
+ try {
8
+ const r = await fetch(url, { headers: { Authorization: `Bearer ${opts.apiKey}` } });
9
+ if (!r.ok) {
10
+ const body = await r.json().catch(() => ({}));
11
+ return { ok: false, status: r.status, error: body.error ?? 'unknown' };
12
+ }
13
+ const profile = await r.json();
14
+ return { ok: true, profile };
15
+ }
16
+ catch (err) {
17
+ return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
18
+ }
19
+ }
@@ -0,0 +1,58 @@
1
+ const RULES = [
2
+ { skill: 'abap-atc-fix', patterns: [/atc\s+(fix|auto\s+fix)|abap\s+test\s+cockpit.*fix/i] },
3
+ { skill: 'abap-cca', patterns: [/custom\s+code\s+analysis|\bcca\b|clean\s+core\s+(assessment|analysis)/i] },
4
+ { skill: 'abap-cloud-check', patterns: [/cloud\s+ready|clean\s+core(?!\s+(assessment|analysis))|s\/4hana\s+cloud\s+check/i] },
5
+ { skill: 'abap-data-model', patterns: [/data\s+model|ddic\s+(design|structure)|domain|data\s+element|create\s+(a\s+)?z[_\w]*\s+(table|structure)|new\s+(z[_\w]*\s+)?(table|structure|domain)/i] },
6
+ { skill: 'abap-design', patterns: [/solution\s+architecture|object\s+list|design\s+the\s+solution/i] },
7
+ { skill: 'abap-handover', patterns: [/technical\s+documentation|object\s+catalog|doc(ument)?\s+package|handover/i] },
8
+ { skill: 'abap-document', patterns: [/developer\s+documentation|document\s+this\s+(class|program|object)/i] },
9
+ // abap-incident MUST precede dump rules — pipeline framing beats keyword-only matches
10
+ { skill: 'abap-incident', patterns: [/incident|resolve.*dump.*to.*fix|pipeline.*(short\s+dump|st22|dump)|(dump|st22).*to.*transport|formal\s+(incident|dump)\s+report|end.to.end.*dump/i] },
11
+ { skill: 'abap-dump-triage', patterns: [/dump\s+triage|structured\s+investigation.*dump|dump\s+forensic/i] },
12
+ { skill: 'abap-dump-analyze', patterns: [/short\s+dump|runtime\s+error|\bst22\b|dump\s+analys/i] },
13
+ { skill: 'abap-eml', patterns: [/\beml\b|entity\s+manipulation|behavior\s+handler/i] },
14
+ { skill: 'abap-enhance', patterns: [/\bbadi\b|enhancement\s+spot|implicit\s+enhancement|customer\s+exit|user\s+exit|(hook\s+into|extend)\s+(sap|standard|me\d+|migo|miro|va\d+|vf\d+|vl\d+|fb\d+)/i] },
15
+ { skill: 'abap-estimate', patterns: [/effort\s+estimat|ticket\s+estimat|size\s+this\s+(work|task|ticket|story)|how\s+(many|long).*(hours|days).*(build|develop|implement)/i] },
16
+ { skill: 'abap-explain', patterns: [/\bexplain\b|what\s+does\s+this\s+(do|code)|walk.*through/i] },
17
+ // abap-test MUST precede abap-generate so "generate tests" routes to the test skill
18
+ // (test-gen merged into abap-test on 2026-05-02 — phrases preserved here).
19
+ { skill: 'abap-test', patterns: [/unit\s+test|abap\s+unit|test\s+(class|doubles)|generate\s+tests|test\s+generation|improve\s+test\s+coverage/i] },
20
+ // abap-modernize MUST precede abap-rap so "rebuild as RAP" / "convert to RAP" stays
21
+ // on modernize (rebuild intent) instead of falling to abap-rap's bare \brap\b match.
22
+ { skill: 'abap-modernize', patterns: [/modernise|modernize|classic.*to.*modern|convert.*to\s+(rap|fiori|odata)|rebuild.*as\s+(rap|fiori)/i] },
23
+ // abap-rap MUST precede abap-generate so RAP-stack prompts route to rap, not the
24
+ // generic generator (cluster I, 2026-05-03). Also captures "scaffold a CDS view +
25
+ // BDEF + service binding" type prompts which collide with abap-generate.
26
+ { skill: 'abap-rap', patterns: [/\brap\b|business\s+object(?!s?\s+for)|bdef|service\s+binding|srvb|scaffold.*(cds|odata\s+v4|bdef|behavior)|odata\s+v4\s+service/i] },
27
+ { skill: 'abap-generate', patterns: [/\bgenerate\b|create\s+(a\s+)?(new\s+)?(class|program|report|include)|build.*from\s+scratch/i] },
28
+ { skill: 'abap-impact', patterns: [/impact\s+analysis|where.*used|what.*affected\s+by/i] },
29
+ // abap-upgrade-* must precede abap-migrate
30
+ { skill: 'abap-upgrade-verify', patterns: [/upgrade\s+verify|remediation\s+report|verify\s+upgrade/i] },
31
+ { skill: 'abap-upgrade-fix', patterns: [/upgrade\s+fix|atc\s+remediation|fix\s+upgrade\s+finding/i] },
32
+ { skill: 'abap-upgrade-scan', patterns: [/upgrade\s+scan|scan.*s\/4|readiness\s+variant/i] },
33
+ { skill: 'abap-migrate', patterns: [/ecc.*migration|migrate.*s\/4|api\s+successor/i] },
34
+ { skill: 'abap-performance', patterns: [/performance|\bn\+1\b|select\s+inside\s+loop|slow\s+(query|select)/i] },
35
+ { skill: 'abap-preflight', patterns: [/preflight|pre.release|go\/no.go|release\s+readiness/i] },
36
+ { skill: 'abap-radar', patterns: [/radar|quick\s+look|overview\s+of\s+(this\s+)?(class|program|object)/i] },
37
+ { skill: 'abap-refactor', patterns: [/refactor|clean\s+up\s+(code|abap)|procedural.*to\s+oo/i] },
38
+ { skill: 'abap-review', patterns: [/^review|code\s+review|code\s+quality|clean\s+abap(?!\s+conformity)/i] },
39
+ { skill: 'abap-segw', patterns: [/segw|dpc_ext|mpc_ext|gateway\s+(project|service)|build.*odata.*(ecc|gateway)/i] },
40
+ { skill: 'abap-spec-gap', patterns: [/spec\s+gap|missing\s+(spec|requirements)|business\s+questions|clarif(y|ying)\s+(question|requirement)|gaps?\s+in\s+the\s+(spec|story|requirement)/i] },
41
+ // abap-transport-analysis MUST precede abap-transport
42
+ { skill: 'abap-transport-analysis', patterns: [/transport\s+analysis|pre.release\s+transport|transport\s+readiness/i] },
43
+ { skill: 'abap-transport', patterns: [/transport\s+(create|release|list)|\bcts\b|release.*request/i] },
44
+ ];
45
+ if (RULES.length !== 32) {
46
+ // Warn-not-throw — managed-mode users never exercise classify() (the proxy
47
+ // does server-side classification), so a module-load crash would break
48
+ // them for a local-only consistency check. Unit tests assert the exact
49
+ // count.
50
+ console.warn(`[classifier] RULES drift: expected 32, got ${RULES.length} — reconcile with .claude/skills/`);
51
+ }
52
+ export function classify(prompt) {
53
+ for (const rule of RULES) {
54
+ if (rule.patterns.some(p => p.test(prompt)))
55
+ return rule.skill;
56
+ }
57
+ return undefined;
58
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Pure argument-parsing helpers for the CSPeach CLI entry point.
3
+ * Extracted for unit-testability — no side effects, no I/O.
4
+ */
5
+ /**
6
+ * Scan `args` for `--sap-alias <name>`.
7
+ *
8
+ * - If the flag is absent: returns `{ rest: args }` (no-op).
9
+ * - If the flag is present and the alias is in `availableAliases`: strips the
10
+ * two tokens and returns `{ sapAlias, rest }`.
11
+ * - If the alias is NOT in `availableAliases`: returns `{ error, rest: args }`.
12
+ * - If `--sap-alias` is the last token (no name follows): returns `{ error, rest: args }`.
13
+ */
14
+ export function extractSapAliasFlag(args, availableAliases) {
15
+ const idx = args.indexOf('--sap-alias');
16
+ if (idx === -1) {
17
+ return { rest: args };
18
+ }
19
+ // Flag present but no value after it
20
+ if (idx === args.length - 1) {
21
+ return {
22
+ rest: args,
23
+ error: 'error: --sap-alias requires a name argument. Usage: cspeach --sap-alias <name>',
24
+ };
25
+ }
26
+ const alias = args[idx + 1];
27
+ // Unknown alias
28
+ if (availableAliases.length > 0 && !availableAliases.includes(alias)) {
29
+ const list = availableAliases.join(', ');
30
+ return {
31
+ rest: args,
32
+ error: `error: alias '${alias}' not found in config. Available: ${list}`,
33
+ };
34
+ }
35
+ // Strip the two tokens from args
36
+ const rest = [...args.slice(0, idx), ...args.slice(idx + 2)];
37
+ return { sapAlias: alias, rest };
38
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,148 @@
1
+ import chalk from 'chalk';
2
+ import { runAddSapWizard } from './sap/onboarding.js';
3
+ import { runGcOnStartup } from './session/gc.js';
4
+ import { checkForUpgrade } from './upgrade-check.js';
5
+ import { extractSapAliasFlag } from './cli-args.js';
6
+ export async function main(argv) {
7
+ runGcOnStartup().catch(() => { });
8
+ checkForUpgrade().catch(() => { });
9
+ let args = argv.slice(2);
10
+ if (args[0] === '--help' || args[0] === '-h') {
11
+ printHelp();
12
+ return 0;
13
+ }
14
+ if (args[0] === '--version') {
15
+ console.log(process.env.npm_package_version ?? '0.5.0');
16
+ return 0;
17
+ }
18
+ if (args[0] === '--upgrade') {
19
+ const { runUpgrade } = await import('./upgrade.js');
20
+ return runUpgrade();
21
+ }
22
+ if (args[0] === '--resume') {
23
+ const { runResume } = await import('./session/resume.js');
24
+ return runResume(args[1]);
25
+ }
26
+ // C.1 — extract --sap-alias <name> early so it is available for REPL and
27
+ // one-shot paths. Validation requires loading config to know available aliases.
28
+ let sapAlias;
29
+ if (args.includes('--sap-alias')) {
30
+ const { loadConfig } = await import('./config/loader.js');
31
+ const cfg = await loadConfig();
32
+ const availableAliases = Object.keys(cfg.sap);
33
+ const parsed = extractSapAliasFlag(args, availableAliases);
34
+ if (parsed.error) {
35
+ console.error(chalk.red(parsed.error));
36
+ return 1;
37
+ }
38
+ sapAlias = parsed.sapAlias;
39
+ args = parsed.rest;
40
+ }
41
+ if (args[0] === 'configure') {
42
+ console.log(chalk.dim('(alias for `cspeach config add-sap`)'));
43
+ await runAddSapWizard();
44
+ return 0;
45
+ }
46
+ if (args[0] === 'config' && args[1] === 'add-sap') {
47
+ await runAddSapWizard();
48
+ return 0;
49
+ }
50
+ if (args[0] === 'config' && args[1] === 'set') {
51
+ const { runConfigSet, ConfigSetError } = await import('./commands/config-set.js');
52
+ try {
53
+ await runConfigSet(args.slice(2));
54
+ }
55
+ catch (e) {
56
+ if (e instanceof ConfigSetError) {
57
+ console.error(chalk.red(`✗ ${e.message}`));
58
+ process.exit(e.exitCode);
59
+ }
60
+ throw e;
61
+ }
62
+ return 0;
63
+ }
64
+ if (args[0] === 'config' && args[1] === 'show') {
65
+ const { runConfigShow } = await import('./commands/config-show.js');
66
+ await runConfigShow(args.slice(2));
67
+ return 0;
68
+ }
69
+ if (args[0] === 'doctor') {
70
+ const { runDoctor } = await import('./doctor/run.js');
71
+ return runDoctor();
72
+ }
73
+ if (args[0] === 'login') {
74
+ const { runLoginCommand } = await import('./commands/login.js');
75
+ const { input } = await import('@inquirer/prompts');
76
+ const { loadConfig } = await import('./config/loader.js');
77
+ const cfg = await loadConfig();
78
+ const open = async (url) => {
79
+ const { spawn } = await import('node:child_process');
80
+ // On Windows, `start` is a cmd builtin and needs a shell; spawn the URL via cmd /c.
81
+ if (process.platform === 'win32') {
82
+ spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' }).unref();
83
+ }
84
+ else {
85
+ const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
86
+ spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
87
+ }
88
+ };
89
+ return runLoginCommand({
90
+ proxyUrl: cfg.proxy_url,
91
+ open,
92
+ print: (s) => console.log(s),
93
+ prompt: (q) => input({ message: q }),
94
+ });
95
+ }
96
+ if (args[0] === 'logout') {
97
+ const { runLogoutCommand } = await import('./commands/logout.js');
98
+ return runLogoutCommand({ print: (s) => console.log(s) });
99
+ }
100
+ if (args[0] === 'whoami') {
101
+ const { runWhoamiCommand } = await import('./commands/whoami.js');
102
+ const { loadConfig } = await import('./config/loader.js');
103
+ const cfg = await loadConfig();
104
+ return runWhoamiCommand({ proxyUrl: cfg.proxy_url, print: (s) => console.log(s) });
105
+ }
106
+ if (args[0] === 'analyze' && args[1] === 'project-context') {
107
+ const { runProjectContextImpactCommand } = await import('./commands/project-context-impact.js');
108
+ return runProjectContextImpactCommand(args.slice(2));
109
+ }
110
+ if (args[0] === '--one-shot') {
111
+ const { runOneShot } = await import('./one-shot.js');
112
+ return runOneShot(args.slice(1).join(' '), { sapAlias });
113
+ }
114
+ // Default: if first arg exists and is not a flag, treat as one-shot prompt.
115
+ if (args.length > 0 && !args[0].startsWith('-')) {
116
+ const { runOneShot } = await import('./one-shot.js');
117
+ return runOneShot(args.join(' '), { sapAlias });
118
+ }
119
+ // No args → REPL.
120
+ const { runRepl } = await import('./repl.js');
121
+ return runRepl({ sapAlias });
122
+ }
123
+ function printHelp() {
124
+ console.log(`
125
+ CSPeach — AI-assisted ABAP development CLI
126
+
127
+ Usage:
128
+ cspeach Start interactive REPL
129
+ cspeach "<prompt>" One-shot — runs one turn and exits
130
+ cspeach --one-shot "<prompt>" Same as above, explicit flag
131
+ cspeach --sap-alias <name> Use a specific SAP alias (overrides default)
132
+ cspeach --resume [id] Resume a previous session (list if no id)
133
+ cspeach config add-sap Add a SAP system interactively
134
+ cspeach configure Alias for \`cspeach config add-sap\`
135
+ cspeach config set <key> <value> Update a config key
136
+ cspeach config show [key] Show config (full or single key)
137
+ cspeach doctor Diagnose install + connectivity
138
+ cspeach login Sign in via device-code flow
139
+ cspeach logout Sign out (keep keytar key, if any)
140
+ cspeach whoami Print current account info
141
+ cspeach analyze project-context Compare flag-on vs flag-off session metrics
142
+ cspeach --upgrade Upgrade to latest via npm
143
+ cspeach --version Print version
144
+ cspeach --help Show this help
145
+
146
+ Docs: https://cspeach.dev
147
+ `);
148
+ }