@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,103 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useState, useEffect, useCallback } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import TextInput from 'ink-text-input';
5
+ import Fuse from 'fuse.js';
6
+ import * as fs from 'node:fs';
7
+ import * as os from 'node:os';
8
+ import * as path from 'node:path';
9
+ const HISTORY_FILE = path.join(os.homedir(), '.cspeach', 'history');
10
+ function loadHistory() {
11
+ try {
12
+ return fs.readFileSync(HISTORY_FILE, 'utf8').split('\n').filter(Boolean);
13
+ }
14
+ catch {
15
+ return [];
16
+ }
17
+ }
18
+ function appendHistory(line, history) {
19
+ // Skip consecutive duplicates (matches bash HISTCONTROL=ignoredups).
20
+ if (history[history.length - 1] === line)
21
+ return;
22
+ fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
23
+ fs.appendFileSync(HISTORY_FILE, line + '\n');
24
+ history.push(line);
25
+ }
26
+ const PEACH = '#FFCBA4';
27
+ export function Footer({ onSubmit, onSlashTrigger, onHistoryTrigger, onValueChange, paletteResult }) {
28
+ const [value, setValue] = useState('');
29
+ const [history] = useState(loadHistory);
30
+ const [histIdx, setHistIdx] = useState(-1);
31
+ const [fuzzyMode, setFuzzyMode] = useState(false);
32
+ const [fuzzyQuery, setFuzzyQuery] = useState('');
33
+ const [fuzzyResults, setFuzzyResults] = useState([]);
34
+ const fuse = React.useMemo(() => new Fuse(history, { threshold: 0.4 }), [history]);
35
+ // Mirror state into parent when it cares (e.g. App's ? key-binding gate).
36
+ useEffect(() => { onValueChange?.(value); }, [value, onValueChange]);
37
+ // If the parent pushes a palette result, pre-fill our input.
38
+ useEffect(() => {
39
+ if (paletteResult && paletteResult.length > 0)
40
+ setValue(paletteResult);
41
+ }, [paletteResult]);
42
+ useEffect(() => {
43
+ if (fuzzyMode) {
44
+ setFuzzyResults(fuse.search(fuzzyQuery).slice(0, 8).map((r) => r.item));
45
+ }
46
+ }, [fuzzyQuery, fuzzyMode, fuse]);
47
+ useInput((input, key) => {
48
+ if (key.ctrl && input === 'r') {
49
+ onHistoryTrigger();
50
+ setFuzzyMode(true);
51
+ setFuzzyQuery('');
52
+ return;
53
+ }
54
+ if (fuzzyMode) {
55
+ if (key.escape) {
56
+ setFuzzyMode(false);
57
+ return;
58
+ }
59
+ if (key.return && fuzzyResults[0]) {
60
+ // TODO (v0.4): cursor-aware selection for Ctrl+R palette.
61
+ // Current implementation always selects the top result, matching
62
+ // bash/zsh default reverse-i-search behaviour — users who want a
63
+ // different result re-type to refine the query.
64
+ setValue(fuzzyResults[0]);
65
+ setFuzzyMode(false);
66
+ return;
67
+ }
68
+ return;
69
+ }
70
+ if (key.upArrow) {
71
+ const next = Math.min(histIdx + 1, history.length - 1);
72
+ setHistIdx(next);
73
+ setValue(history[history.length - 1 - next] ?? '');
74
+ return;
75
+ }
76
+ if (key.downArrow) {
77
+ const next = Math.max(histIdx - 1, -1);
78
+ setHistIdx(next);
79
+ setValue(next === -1 ? '' : history[history.length - 1 - next] ?? '');
80
+ return;
81
+ }
82
+ });
83
+ const handleSubmit = useCallback((line) => {
84
+ if (!line.trim())
85
+ return;
86
+ appendHistory(line.trim(), history);
87
+ setHistIdx(-1);
88
+ setValue('');
89
+ if (line.trim() === '/') {
90
+ onSlashTrigger();
91
+ return;
92
+ }
93
+ if (line.trim() === '/history') {
94
+ onHistoryTrigger();
95
+ return;
96
+ }
97
+ onSubmit(line.trim());
98
+ }, [onSubmit, onSlashTrigger, onHistoryTrigger, history]);
99
+ if (fuzzyMode) {
100
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "History search (Ctrl+R)" }), _jsx(TextInput, { value: fuzzyQuery, onChange: setFuzzyQuery, placeholder: "type to search..." }), fuzzyResults.map((r, i) => _jsx(Text, { dimColor: i > 0, children: r }, i)), _jsx(Text, { dimColor: true, children: "Esc: cancel Enter: select" })] }));
101
+ }
102
+ return (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, color: PEACH, children: "CSPeach \u203A " }), _jsx(TextInput, { value: value, onChange: setValue, onSubmit: handleSubmit })] }));
103
+ }
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useSapState } from './sap-state-store.js';
4
+ const PEACH = '#FFCBA4';
5
+ export function Header({ writeMode } = {}) {
6
+ const { system } = useSapState();
7
+ const isAdvisory = writeMode === 'advisory-only';
8
+ const modeLabel = isAdvisory
9
+ ? 'ADVISORY (writes proposed, never executed)'
10
+ : writeMode === 'approval-gated'
11
+ ? 'approval-gated'
12
+ : writeMode === 'auto'
13
+ ? 'auto'
14
+ : undefined;
15
+ const modeColor = isAdvisory ? 'yellow' : 'green';
16
+ return (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, color: PEACH, children: "CSPeach v0.3" }), _jsx(Text, { children: " " }), modeLabel && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " write: " }), _jsx(Text, { color: modeColor, bold: isAdvisory, children: modeLabel })] })), _jsx(Box, { flexGrow: 1 }), _jsx(Text, { color: "cyan", children: system })] }));
17
+ }
@@ -0,0 +1,19 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ /**
5
+ * Medium-confidence banner rendered inside the Ink frame. Auto-settles to
6
+ * the classifier's top pick after 800ms, unless the user presses Tab — in
7
+ * which case we open the CommandPalette with alternates.
8
+ */
9
+ export function InkClassifierRoute({ skill, confidence, onSettle, onOpenPalette }) {
10
+ useEffect(() => {
11
+ const t = setTimeout(onSettle, 800);
12
+ return () => clearTimeout(t);
13
+ }, [onSettle]);
14
+ useInput((_input, key) => {
15
+ if (key.tab)
16
+ onOpenPalette();
17
+ });
18
+ return (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: ["\\u2504 Suggest /", skill, " (confidence ", Math.round(confidence * 100), "%) \\u2014 Tab to pick"] }) }));
19
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Format the login banner shown at REPL startup. Decision 8: verbose
3
+ * with budget + warning. Yellow warning fires when:
4
+ * - token usage ≥ 80% of input OR output cap (managed-capped trials only)
5
+ * - trial expiry < 24 hours away
6
+ * - trial already expired
7
+ */
8
+ const MS_PER_HOUR = 3600 * 1000;
9
+ const MS_PER_DAY = 24 * MS_PER_HOUR;
10
+ export function formatLoginBanner(profile) {
11
+ // Non-trial path.
12
+ if (profile.trial_mode === null) {
13
+ return {
14
+ line: `Signed in as ${profile.email} (${profile.plan})`,
15
+ warningLine: null,
16
+ };
17
+ }
18
+ // Trial — figure out time left.
19
+ const now = Date.now();
20
+ const ends = profile.trial_ends_at ? new Date(profile.trial_ends_at).getTime() : now;
21
+ const msLeft = ends - now;
22
+ let timeFragment;
23
+ let isExpired = false;
24
+ if (msLeft <= 0) {
25
+ timeFragment = 'EXPIRED';
26
+ isExpired = true;
27
+ }
28
+ else if (msLeft < MS_PER_DAY) {
29
+ const hours = Math.max(1, Math.floor(msLeft / MS_PER_HOUR));
30
+ timeFragment = `${hours} hour${hours === 1 ? '' : 's'} left`;
31
+ }
32
+ else {
33
+ const days = Math.floor(msLeft / MS_PER_DAY);
34
+ timeFragment = `${days} day${days === 1 ? '' : 's'} left`;
35
+ }
36
+ if (profile.trial_mode === 'byok') {
37
+ return {
38
+ line: `Signed in as ${profile.email} — BYOK trial — ${timeFragment}`,
39
+ warningLine: isExpired
40
+ ? `⚠ Your trial has expired. Visit cspeach.dev/upgrade.`
41
+ : (msLeft < MS_PER_DAY ? `⚠ Trial expires soon — ${timeFragment}.` : null),
42
+ };
43
+ }
44
+ // managed-capped
45
+ const usedInput = profile.usage_input_tokens;
46
+ const capInput = profile.trial_token_cap_input ?? 0;
47
+ const inputPct = capInput > 0 ? Math.round((usedInput / capInput) * 100) : 0;
48
+ const tokenFragment = capInput > 0
49
+ ? ` — ${formatTokens(usedInput)}/${formatTokens(capInput)} tokens (${inputPct}%)`
50
+ : '';
51
+ const line = `Signed in as ${profile.email} — trial (managed) — ${timeFragment}${tokenFragment}`;
52
+ let warning = null;
53
+ if (isExpired) {
54
+ warning = `⚠ Your trial has expired. Visit cspeach.dev/upgrade.`;
55
+ }
56
+ else if (inputPct >= 80) {
57
+ warning = `⚠ Trial token budget at ${inputPct}% — upgrade or switch to BYOK before hitting the cap.`;
58
+ }
59
+ else if (msLeft < MS_PER_DAY) {
60
+ warning = `⚠ Trial expires in ${timeFragment} — upgrade at cspeach.dev/upgrade.`;
61
+ }
62
+ return { line, warningLine: warning };
63
+ }
64
+ // Always render with a `k` suffix so the banner column width stays stable.
65
+ // Sub-1k values that aren't zero render as `<1k` rather than the raw number.
66
+ function formatTokens(n) {
67
+ if (n === 0)
68
+ return '0k';
69
+ if (n < 1000)
70
+ return `<1k`;
71
+ return `${Math.round(n / 1000)}k`;
72
+ }
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ function severityColor(s) {
4
+ return s === 'error' ? 'red' : s === 'warning' ? 'yellow' : 'blue';
5
+ }
6
+ export function RichErrorBox({ code, message, hint, severity = 'error' }) {
7
+ const color = severityColor(severity);
8
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: color, paddingX: 2, paddingY: 1, children: [_jsx(Text, { bold: true, color: color, children: code }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: message }) }), hint && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: hint }) }))] }));
9
+ }
@@ -0,0 +1,65 @@
1
+ import React, { createContext, useContext } from 'react';
2
+ const DEFAULT_STATE = {
3
+ system: '', user: '', transport: null, locksHeld: [],
4
+ tokenUsageToday: 0, dollarSpendToday: 0, dollarCap: 10, recentTurns: [],
5
+ };
6
+ /**
7
+ * Rate limiter: calls `fn` at most once every `minIntervalMs`. Trailing-only:
8
+ * the first call in a quiet window fires immediately, subsequent calls within
9
+ * the window coalesce into a single trailing call at the window boundary.
10
+ *
11
+ * NOTE: A naive setTimeout(fn, 200) with leading + trailing yields ~10Hz for
12
+ * back-to-back updates (leading fire at t=0, trailing at t=200, leading again
13
+ * at t=400...). This implementation guarantees at most one call per window →
14
+ * true 5Hz max.
15
+ */
16
+ function createRateLimiter(minIntervalMs) {
17
+ let lastFire = 0;
18
+ let pending = null;
19
+ let timer = null;
20
+ return (fn) => {
21
+ pending = fn;
22
+ const now = Date.now();
23
+ const elapsed = now - lastFire;
24
+ if (elapsed >= minIntervalMs) {
25
+ lastFire = now;
26
+ pending();
27
+ pending = null;
28
+ }
29
+ else if (!timer) {
30
+ timer = setTimeout(() => {
31
+ timer = null;
32
+ if (pending) {
33
+ lastFire = Date.now();
34
+ pending();
35
+ pending = null;
36
+ }
37
+ }, minIntervalMs - elapsed);
38
+ }
39
+ };
40
+ }
41
+ const sidebarUpdate = createRateLimiter(200); // 5Hz max
42
+ export class SapStateStore {
43
+ state = { ...DEFAULT_STATE };
44
+ listeners = new Set();
45
+ subscribe(fn) {
46
+ this.listeners.add(fn);
47
+ return () => { this.listeners.delete(fn); };
48
+ }
49
+ update(partial) {
50
+ this.state = { ...this.state, ...partial };
51
+ sidebarUpdate(() => {
52
+ const snap = this.state;
53
+ this.listeners.forEach((fn) => fn(snap));
54
+ });
55
+ }
56
+ get() { return this.state; }
57
+ }
58
+ export const globalStore = new SapStateStore();
59
+ export const SapStateContext = createContext(globalStore);
60
+ export function useSapState() {
61
+ const store = useContext(SapStateContext);
62
+ const [state, setState] = React.useState(store.get());
63
+ React.useEffect(() => store.subscribe(setState), [store]);
64
+ return state;
65
+ }
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from 'ink';
3
+ // v1.1: add 'skipped' (approval rejection logs) and 'rolled_back' (snapshot restore).
4
+ // Both need session-schema bump (schema_version 2) — deferred.
5
+ const MUTATING_TOOLS = new Set([
6
+ 'sap_set_source', 'sap_update_method', 'sap_delete_object', 'sap_activate',
7
+ 'sap_create_object', 'sap_transport_release', 'sap_message_maintain',
8
+ 'sap_number_range_intervals', 'sap_service_binding_publish',
9
+ ]);
10
+ export function toTimelineEntry(call) {
11
+ // args shape depends on the tool; extract defensively.
12
+ const args = (call.args ?? {});
13
+ const objectName = typeof args['name'] === 'string' ? args['name'] : '-';
14
+ const objectType = typeof args['type'] === 'string' ? args['type'] : '-';
15
+ return {
16
+ timestamp: call.completed_at,
17
+ toolName: call.tool,
18
+ objectName,
19
+ objectType,
20
+ durationMs: call.duration_ms,
21
+ isError: call.is_error,
22
+ };
23
+ }
24
+ export function SessionTimeline({ entries, onClose }) {
25
+ const writes = entries.filter((e) => MUTATING_TOOLS.has(e.toolName));
26
+ useInput((input, key) => {
27
+ if (key.escape || input === 'q')
28
+ onClose();
29
+ });
30
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 2, paddingY: 1, width: 72, children: [_jsx(Text, { bold: true, color: "cyan", children: "Write history \\u2014 Esc to close" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [writes.length === 0 && _jsx(Text, { dimColor: true, children: "(no writes this session)" }), writes.map((e, i) => (_jsxs(Box, { children: [_jsxs(Text, { dimColor: true, children: [e.timestamp, " "] }), _jsx(Text, { color: e.isError ? 'red' : 'green', children: e.isError ? '\u25CF failed ' : '\u25CF applied' }), _jsxs(Text, { children: [" ", e.toolName.padEnd(20)] }), _jsxs(Text, { color: "yellow", children: [" ", e.objectName] }), _jsxs(Text, { dimColor: true, children: [" (", e.objectType, ") ", e.durationMs, "ms"] })] }, i)))] })] }));
31
+ }
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { useSapState } from './sap-state-store.js';
4
+ export function Sidebar() {
5
+ const s = useSapState();
6
+ const spendColor = s.dollarSpendToday > s.dollarCap * 0.9 ? 'red'
7
+ : s.dollarSpendToday > s.dollarCap * 0.7 ? 'yellow' : 'green';
8
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, width: 30, children: [_jsx(Text, { bold: true, color: "cyan", children: s.system || 'no system' }), _jsxs(Text, { color: "gray", children: ["user: ", _jsx(Text, { color: "white", children: s.user || '—' })] }), _jsxs(Text, { color: "gray", children: ["transport: ", _jsx(Text, { color: "yellow", children: s.transport ?? 'none' })] }), _jsxs(Text, { color: "gray", children: ["locks: ", s.locksHeld.length === 0 ? _jsx(Text, { dimColor: true, children: "(none)" })
9
+ : s.locksHeld.map((l, i) => _jsxs(Text, { color: "red", children: [l, " "] }, i))] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: spendColor, children: [s.tokenUsageToday.toLocaleString(), " tok / $", s.dollarSpendToday.toFixed(2), " of $", s.dollarCap] }) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "\u2500 recent turns \u2500" }), s.recentTurns.slice(-5).reverse().map((t, i) => (_jsxs(Text, { dimColor: true, children: [t.time, " ", t.command.slice(0, 20)] }, i))), s.recentTurns.length === 0 && _jsx(Text, { dimColor: true, children: "(no turns yet)" })] })] }));
10
+ }
@@ -0,0 +1,50 @@
1
+ import { select } from '@inquirer/prompts';
2
+ /**
3
+ * Sentinel value returned when the user cancels the picker (Esc / Ctrl+C or
4
+ * selects the "↩ cancel" entry). Callers re-prompt rather than dispatching.
5
+ */
6
+ export const PICK_CANCELLED = Symbol('PICK_CANCELLED');
7
+ /**
8
+ * Arrow-key picker for the top-3 classifier candidates. Returns the chosen
9
+ * skill name. Each choice shows the skill, a trimmed reasoning line, and
10
+ * the confidence percentage.
11
+ *
12
+ * Cancel routes:
13
+ * - Selecting the "↩ cancel" item returns PICK_CANCELLED
14
+ * - Pressing Ctrl+C inquirer raises ExitPromptError → we catch and treat as cancel
15
+ * (without Ctrl+C'ing the whole process)
16
+ */
17
+ export async function pickSkill(result) {
18
+ const candidates = [result.skill, ...result.alternates].slice(0, 3);
19
+ const reasoning = (result.reasoning ?? '').slice(0, 60);
20
+ const confidencePct = Math.round(result.confidence * 100);
21
+ const choices = candidates.map((skill, idx) => {
22
+ // Top-1 carries the reasoning + confidence; alternates are simple names.
23
+ if (idx === 0) {
24
+ const suffix = reasoning
25
+ ? ` — ${reasoning} (${confidencePct}%)`
26
+ : ` (${confidencePct}%)`;
27
+ return { name: `/${skill}${suffix}`, value: skill };
28
+ }
29
+ return { name: `/${skill}`, value: skill };
30
+ });
31
+ // Cancel entry — user can arrow-down past the candidates and pick this to
32
+ // go back to the REPL prompt and retype the request.
33
+ choices.push({ name: '↩ cancel (go back to prompt)', value: '__cancel__' });
34
+ try {
35
+ const chosen = await select({
36
+ message: 'Pick a skill — or ↩ to cancel:',
37
+ choices,
38
+ });
39
+ if (chosen === '__cancel__')
40
+ return PICK_CANCELLED;
41
+ return chosen;
42
+ }
43
+ catch (err) {
44
+ // inquirer throws ExitPromptError on Ctrl+C. Treat as cancel, not a crash.
45
+ const name = err?.name;
46
+ if (name === 'ExitPromptError')
47
+ return PICK_CANCELLED;
48
+ throw err;
49
+ }
50
+ }
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ /**
4
+ * Single-line status row shown inside the Ink frame (above Footer).
5
+ * Mirrors the non-Ink chalk-based `printStatusFooter` in renderer/status-footer.ts
6
+ * so users see the same fields in both modes.
7
+ */
8
+ export function StatusRow({ data }) {
9
+ if (!data)
10
+ return _jsx(Box, {});
11
+ return (_jsx(Box, { children: _jsxs(Text, { dimColor: true, children: [data.sessionId, " SAP: ", data.sapAlias, " skill: ", data.skill, " tokens: ", data.tokens, " elapsed: ", data.elapsedMs, "ms"] }) }));
12
+ }
@@ -0,0 +1,4 @@
1
+ // A longer sentinel than `\x00WIDGET:` to reduce risk of collision with
2
+ // LLM-emitted payloads. A single `\x00` is too short — malicious or accidental
3
+ // `\x00` bytes in model output would render arbitrary widgets.
4
+ export const WIDGET_CONTROL = '\x00CSPEACH-WIDGET-V3\x00';
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ const VALID_COLORS = ['red', 'yellow', 'cyan', 'green', 'blue', 'magenta', 'white', 'gray'];
4
+ function toInkColor(c) {
5
+ return VALID_COLORS.includes(c) ? c : 'white';
6
+ }
7
+ export function BarChart({ data }) {
8
+ const max = Math.max(...data.map((d) => d.value), 1);
9
+ const barWidth = 30;
10
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Distribution" }), data.map((item) => {
11
+ const filled = Math.round((item.value / max) * barWidth);
12
+ const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
13
+ return (_jsxs(Box, { children: [_jsxs(Text, { children: [item.label.padEnd(28), " "] }), _jsx(Text, { color: toInkColor(item.color), children: bar }), _jsxs(Text, { children: [" ", item.value] })] }, item.label));
14
+ })] }));
15
+ }
@@ -0,0 +1,41 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { Box, Text, useInput } from 'ink';
4
+ import { PICK_CANCELLED } from '../skill-picker.js';
5
+ const PEACH = '#FFCBA4';
6
+ /**
7
+ * Ink-mode coaching picker rendered as an overlay inside <App>. The parent
8
+ * (App) mounts this component only when coachingPending !== null, so the
9
+ * component's useInput being always-active is safe — it can only fire
10
+ * while the overlay is visible.
11
+ *
12
+ * Cursor starts at 0 when params.preSelect (coach mode — Enter accepts top-1)
13
+ * and at -1 when false (picker mode — user must arrow-key to their choice).
14
+ */
15
+ export function CoachingPicker({ params, onResolve }) {
16
+ const [cursor, setCursor] = useState(params.preSelect ? 0 : -1);
17
+ useInput((_input, key) => {
18
+ if (key.escape) {
19
+ onResolve(PICK_CANCELLED);
20
+ return;
21
+ }
22
+ if (key.upArrow) {
23
+ setCursor((c) => Math.max(-1, c - 1));
24
+ return;
25
+ }
26
+ if (key.downArrow) {
27
+ setCursor((c) => Math.min(params.options.length - 1, c + 1));
28
+ return;
29
+ }
30
+ if (key.return) {
31
+ if (cursor < 0) {
32
+ onResolve(PICK_CANCELLED);
33
+ return;
34
+ }
35
+ const chosen = params.options[cursor];
36
+ if (chosen)
37
+ onResolve(chosen.skill);
38
+ }
39
+ }, { isActive: true });
40
+ return (_jsxs(Box, { borderStyle: "round", borderColor: PEACH, flexDirection: "column", padding: 1, children: [_jsxs(Text, { bold: true, children: ["I'm not sure which skill fits for: \"", params.prompt.slice(0, 50), params.prompt.length > 50 ? '...' : '', "\"."] }), _jsx(Text, { bold: true, children: "Pick one:" }), params.options.map((opt, i) => (_jsxs(Box, { borderStyle: i === cursor ? 'round' : 'single', borderColor: i === cursor ? PEACH : 'gray', flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { bold: true, children: ["/", opt.skill, " \u00B7 ", Math.round(opt.confidence * 100), "%"] }), _jsxs(Text, { dimColor: true, children: ["Does: ", opt.whatItDoes] }), _jsxs(Text, { dimColor: true, children: ["Pick if: ", opt.whenToPick] })] }, opt.skill))), _jsx(Text, { dimColor: true, children: "\u2191/\u2193 navigate \u00B7 Enter select \u00B7 Esc cancel" })] }));
41
+ }
@@ -0,0 +1,12 @@
1
+ import { DepGraph } from './dep-graph.js';
2
+ import { BarChart } from './bar-chart.js';
3
+ import { StackFrames } from './stack-frames.js';
4
+ import { DiffViewer } from './diff-viewer.js';
5
+ import { QuestionCard } from './question-card.js';
6
+ export const WIDGET_COMPONENTS = {
7
+ 'dep-graph': DepGraph,
8
+ 'bar-chart': BarChart,
9
+ 'stack-frames': StackFrames,
10
+ 'diff-viewer': DiffViewer,
11
+ question: QuestionCard,
12
+ };
@@ -0,0 +1,9 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export function DepGraph({ data }) {
4
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Dependencies \u2014 ", data.root] }), data.children.map((c, i) => {
5
+ const isLast = i === data.children.length - 1;
6
+ const prefix = isLast ? '└─' : '├─';
7
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [prefix, " "] }), _jsx(Text, { color: "yellow", children: c.name }), _jsxs(Text, { color: "gray", children: [" (", c.type, ") "] }), _jsx(Text, { dimColor: true, children: c.relation })] }, c.name));
8
+ })] }));
9
+ }
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export function DiffViewer({ data }) {
4
+ const lines = data.unified.split('\n');
5
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, color: "gray", children: "Diff" }), lines.map((line, idx) => {
6
+ const color = line.startsWith('+') ? 'green' :
7
+ line.startsWith('-') ? 'red' :
8
+ line.startsWith('@@') ? 'cyan' : 'gray';
9
+ return (_jsx(Text, { color: color, bold: line.startsWith('@@'), children: line }, idx));
10
+ })] }));
11
+ }
@@ -0,0 +1,11 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ /**
4
+ * Ink-mode rendering of cspeach-widget:question. Uses a double-border box
5
+ * so it's visually distinct from the single-border tool-widget. No
6
+ * interactive answer collection at this scope — user reads the question
7
+ * and types their answer as the next REPL prompt.
8
+ */
9
+ export function QuestionCard({ data }) {
10
+ return (_jsxs(Box, { borderStyle: "double", borderColor: "cyan", flexDirection: "column", padding: 1, marginTop: 1, children: [_jsxs(Text, { bold: true, children: ["Question: ", data.id] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: data.question }) }), data.context ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Context: ", data.context] }) })) : null, (data.kind === 'choice' || data.kind === 'multi') && data.choices ? (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: data.kind === 'choice' ? 'Pick one:' : 'Pick any (comma-separate):' }), data.choices.map((c, i) => (_jsxs(Text, { children: ["[", i + 1, "] ", c.label] }, c.value)))] })) : null] }));
11
+ }
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export function StackFrames({ data }) {
4
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 1, children: [_jsx(Text, { bold: true, color: "red", children: "Stack Frames" }), data.map((f) => (_jsxs(Box, { flexDirection: "column", marginTop: f.frame === 0 ? 0 : 1, children: [_jsx(Box, { children: _jsxs(Text, { color: f.type === 'error' ? 'red' : 'gray', children: ['#' + String(f.frame).padStart(2, '0'), " ", f.program, ":", f.line] }) }), _jsx(Box, { marginLeft: 5, children: _jsx(Text, { color: f.type === 'error' ? 'white' : 'gray', dimColor: f.type === 'context', children: f.code }) })] }, f.frame)))] }));
5
+ }
@@ -0,0 +1,28 @@
1
+ import chalk from 'chalk';
2
+ import { loadConfig } from './config/loader.js';
3
+ function compareVersions(a, b) {
4
+ const pa = a.split('.').map(Number);
5
+ const pb = b.split('.').map(Number);
6
+ for (let i = 0; i < 3; i++) {
7
+ const na = pa[i] ?? 0;
8
+ const nb = pb[i] ?? 0;
9
+ if (na !== nb)
10
+ return na - nb;
11
+ }
12
+ return 0;
13
+ }
14
+ export async function checkForUpgrade() {
15
+ const cfg = await loadConfig();
16
+ try {
17
+ const r = await fetch(`${cfg.proxy_url}/v1/skills/abap-radar`, { method: 'HEAD', signal: AbortSignal.timeout(3000) });
18
+ const minCli = r.headers.get('x-skill-min-cli-version');
19
+ const current = process.env.npm_package_version ?? '0.5.0';
20
+ if (minCli && compareVersions(current, minCli) < 0) {
21
+ console.log(chalk.yellow.bold(`\n⚠ Your cspeach (${current}) is older than the minimum required (${minCli}).`));
22
+ console.log(chalk.yellow(` Run: cspeach --upgrade\n`));
23
+ }
24
+ }
25
+ catch {
26
+ // Offline or proxy down — don't block startup.
27
+ }
28
+ }
@@ -0,0 +1,13 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { confirm } from '@inquirer/prompts';
3
+ import chalk from 'chalk';
4
+ export async function runUpgrade() {
5
+ console.log(chalk.cyan('Upgrading cspeach to latest via npm…'));
6
+ const yes = await confirm({ message: 'Run `npm i -g cspeach@latest`?', default: true });
7
+ if (!yes)
8
+ return 0;
9
+ return new Promise((resolve) => {
10
+ const proc = spawn('npm', ['i', '-g', 'cspeach@latest'], { stdio: 'inherit', shell: true });
11
+ proc.on('exit', (code) => resolve(code ?? 1));
12
+ });
13
+ }
package/package.json ADDED
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "@cspeach/cli",
3
+ "version": "0.6.0",
4
+ "description": "CSPeach — AI-assisted ABAP development CLI. Direct SAP access, 33 skills, safety gates.",
5
+ "keywords": [
6
+ "abap",
7
+ "sap",
8
+ "ai",
9
+ "cli",
10
+ "ai-assisted-development",
11
+ "s4hana",
12
+ "abap-development",
13
+ "claude",
14
+ "anthropic"
15
+ ],
16
+ "homepage": "https://cspeach.dev",
17
+ "bugs": {
18
+ "email": "laeeq@cremencing.com"
19
+ },
20
+ "author": "Laeeq Siddique (Cremencing Solutions)",
21
+ "license": "SEE LICENSE IN LICENSE",
22
+ "private": false,
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "bin": {
28
+ "cspeach": "dist/index.js"
29
+ },
30
+ "scripts": {
31
+ "dev": "tsx src/index.ts",
32
+ "prebuild": "tsx scripts/prebuild.ts",
33
+ "build": "tsc && node scripts/make-bin-executable.js",
34
+ "install:global": "tsx scripts/install-global.ts",
35
+ "pretest": "tsx scripts/sync-signing-key.ts || node -e \"console.warn('WARN: sync-signing-key skipped — using stub')\"",
36
+ "start": "node dist/index.js",
37
+ "typecheck": "tsc --noEmit",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest",
40
+ "adt:smoke": "tsx scripts/adt-smoke.ts",
41
+ "smoke:all": "pnpm zcspeach:smoke && pnpm adt:smoke",
42
+ "zcspeach:smoke": "tsx scripts/zcspeach-smoke.ts",
43
+ "prepare": "husky"
44
+ },
45
+ "engines": {
46
+ "node": ">=20.0.0"
47
+ },
48
+ "dependencies": {
49
+ "@anthropic-ai/sdk": "^0.90.0",
50
+ "@cspeach/sap-client": "^0.1.0",
51
+ "@iarna/toml": "^2.2.5",
52
+ "@inkjs/ui": "^2.0.0",
53
+ "@inquirer/prompts": "^7.0.0",
54
+ "@noble/ed25519": "2.1.0",
55
+ "@noble/hashes": "1.4.0",
56
+ "chalk": "^5.3.0",
57
+ "cli-highlight": "^2.1.11",
58
+ "cli-table3": "^0.6.5",
59
+ "clipboardy": "^5.3.1",
60
+ "diff": "^9.0.0",
61
+ "fuse.js": "^7.0.0",
62
+ "ink": "^5.0.0",
63
+ "ink-text-input": "^6.0.0",
64
+ "jose": "^5.9.0",
65
+ "keytar": "^7.9.0",
66
+ "marked": "^11.2.0",
67
+ "marked-terminal": "^6.2.0",
68
+ "node-machine-id": "^1.1.12",
69
+ "openai": "4.67.3",
70
+ "react": "^18.3.0",
71
+ "zod": "^3.23.0"
72
+ },
73
+ "devDependencies": {
74
+ "@types/diff": "^7.0.2",
75
+ "@types/node": "^20.17.0",
76
+ "@types/react": "^18.3.0",
77
+ "husky": "^9.1.7",
78
+ "ink-testing-library": "^4.0.0",
79
+ "tsx": "^4.19.0",
80
+ "typescript": "^5.7.0",
81
+ "vitest": "^2.1.0"
82
+ }
83
+ }