@cspeach/cli 0.8.0 → 1.0.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 (141) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +330 -36
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/steering-queue.js +27 -0
  7. package/dist/agent/tool-dispatch.js +15 -0
  8. package/dist/approvals/canonical.js +91 -0
  9. package/dist/approvals/jwt.js +84 -7
  10. package/dist/approvals/render.js +38 -0
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +24 -5
  13. package/dist/commands/config-set.js +95 -0
  14. package/dist/commands/login.js +31 -14
  15. package/dist/commands/plan-model-tier.js +83 -0
  16. package/dist/commands/plan-resume.js +435 -0
  17. package/dist/config/loader.js +102 -3
  18. package/dist/cost/pricing.js +14 -5
  19. package/dist/doctor/checks/_http-probe.js +1 -0
  20. package/dist/doctor/checks/cert.js +14 -3
  21. package/dist/doctor/checks/sap.js +30 -8
  22. package/dist/doctor/checks/zcspeach.js +19 -4
  23. package/dist/one-shot.js +52 -4
  24. package/dist/projects/answer-blockers.js +137 -0
  25. package/dist/projects/email-template.js +2 -0
  26. package/dist/projects/extract-cca.js +108 -16
  27. package/dist/projects/extract-modernize.js +1 -1
  28. package/dist/projects/extract-plan.js +178 -0
  29. package/dist/projects/extract-spec-gap.js +34 -7
  30. package/dist/projects/extract-test-coverage.js +1 -1
  31. package/dist/projects/extract-upgrade.js +113 -22
  32. package/dist/projects/index.js +7 -1
  33. package/dist/projects/merge-cca.js +292 -0
  34. package/dist/projects/merge-upgrade.js +173 -0
  35. package/dist/projects/migration.js +103 -1
  36. package/dist/projects/output-paths.js +27 -0
  37. package/dist/projects/plan-run.js +254 -0
  38. package/dist/projects/plan-schema.js +210 -0
  39. package/dist/projects/promote-command.js +26 -2
  40. package/dist/projects/promote.js +128 -0
  41. package/dist/projects/save-command.js +263 -20
  42. package/dist/projects/status.js +22 -0
  43. package/dist/projects/validate.js +2 -0
  44. package/dist/projects/workspace.js +164 -20
  45. package/dist/renderer/notices.js +64 -0
  46. package/dist/renderer/progress-chatter.js +8 -0
  47. package/dist/renderer/syntax.js +16 -1
  48. package/dist/renderer/thinking-heartbeat.js +13 -1
  49. package/dist/renderer/tool-widget.js +18 -4
  50. package/dist/renderer/tty.js +43 -4
  51. package/dist/renderer/verify-chain.js +77 -0
  52. package/dist/repl/at-picker.js +93 -21
  53. package/dist/repl/builtin-commands.js +37 -0
  54. package/dist/repl/early-line-buffer.js +68 -0
  55. package/dist/repl/inquirer-guard.js +70 -5
  56. package/dist/repl/numbered-menu.js +131 -0
  57. package/dist/repl/post-turn-status.js +2 -2
  58. package/dist/repl/rule8-detector.js +17 -2
  59. package/dist/repl/safety-confirm.js +111 -2
  60. package/dist/repl/safety-mode-state.js +19 -3
  61. package/dist/repl/slash-picker.js +25 -19
  62. package/dist/repl.js +470 -22
  63. package/dist/router/classifier.js +150 -6
  64. package/dist/sap/capability-matrix.js +20 -0
  65. package/dist/sap/capability-matrix.json +11236 -0
  66. package/dist/sap/capability.js +146 -0
  67. package/dist/sap/connection-manager.js +19 -1
  68. package/dist/sap/onboarding.js +42 -4
  69. package/dist/session/pending.js +27 -0
  70. package/dist/skill-catalog.js +54 -43
  71. package/dist/skills/bundled-skills.js +279 -1
  72. package/dist/skills/promotion-dispatch.js +23 -0
  73. package/dist/tools/_command-shared.js +36 -12
  74. package/dist/tools/_filesystem-shared.js +139 -4
  75. package/dist/tools/_flag.js +25 -0
  76. package/dist/tools/approval.js +64 -21
  77. package/dist/tools/ask-question.js +96 -4
  78. package/dist/tools/capability/tool.js +74 -0
  79. package/dist/tools/dispatch-skill.js +22 -1
  80. package/dist/tools/extend-model/anchored-insert.js +810 -0
  81. package/dist/tools/extend-model/tool.js +188 -0
  82. package/dist/tools/filesystem/extract-document.js +57 -0
  83. package/dist/tools/filesystem/file-edit.js +12 -2
  84. package/dist/tools/filesystem/file-read.js +2 -2
  85. package/dist/tools/filesystem/file-write.js +11 -2
  86. package/dist/tools/filesystem/glob.js +11 -0
  87. package/dist/tools/filesystem/grep.js +10 -0
  88. package/dist/tools/filesystem/read-document.js +107 -0
  89. package/dist/tools/fiori/apply.js +50 -0
  90. package/dist/tools/fiori/bin.js +3 -0
  91. package/dist/tools/fiori/catalog/index.js +27 -0
  92. package/dist/tools/fiori/catalog/value-help.js +230 -0
  93. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  94. package/dist/tools/fiori/cli.js +71 -0
  95. package/dist/tools/fiori/deploy-config.js +73 -0
  96. package/dist/tools/fiori/fe-scaffold.js +45 -0
  97. package/dist/tools/fiori/i18n.js +39 -0
  98. package/dist/tools/fiori/manifest.js +70 -0
  99. package/dist/tools/fiori/render.js +77 -0
  100. package/dist/tools/fiori/scaffold.js +39 -0
  101. package/dist/tools/fiori/tools.js +356 -0
  102. package/dist/tools/fiori/types.js +1 -0
  103. package/dist/tools/local-build.js +76 -0
  104. package/dist/tools/local-files.js +31 -0
  105. package/dist/tools/project/_merge-shared.js +68 -0
  106. package/dist/tools/project/cca_merge.js +164 -0
  107. package/dist/tools/project/playbook_get.js +1 -1
  108. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  109. package/dist/tools/sap-read.js +53 -9
  110. package/dist/tools/sap-write.js +530 -21
  111. package/dist/tools/shell/shell_exec.js +41 -6
  112. package/dist/tools/snapshot.js +37 -14
  113. package/dist/tools/subagent/background_run.js +17 -1
  114. package/dist/tools/transport-resolution.js +86 -0
  115. package/dist/tools/transport.js +224 -5
  116. package/dist/tools/write-mode.js +4 -0
  117. package/dist/ui/app.js +84 -11
  118. package/dist/ui/body.js +13 -0
  119. package/dist/ui/command-palette.js +46 -10
  120. package/dist/ui/file-palette.js +44 -0
  121. package/dist/ui/footer.js +28 -11
  122. package/dist/ui/line-resolution.js +92 -0
  123. package/dist/ui/session-timeline.js +1 -0
  124. package/dist/ui/text-input.js +150 -0
  125. package/dist/ui/turn-status-emitter.js +52 -0
  126. package/dist/ui/turn-status.js +59 -0
  127. package/dist/ui/widgets/ask-question-modal.js +30 -2
  128. package/package.json +23 -4
  129. package/bench/README.md +0 -78
  130. package/bench/prompts/abap-document-cds.md +0 -44
  131. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  132. package/bench/prompts/abap-test-method.md +0 -42
  133. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  134. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  135. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  136. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  137. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  138. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  139. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  140. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  141. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -5,13 +5,21 @@
5
5
  * surface, every rule-triggering operation gets a card showing which rule
6
6
  * applies, what's about to happen, and what rollback is available.
7
7
  *
8
- * Pure orchestration over `@inquirer/prompts.confirm` — no I/O of its own
9
- * beyond the prompt invocation.
8
+ * Three presentation surfaces, decided per call (headless first):
9
+ * - headless → fail-fast decline, no prompt (B5)
10
+ * - Ink → askQuestionEmitter choice modal (UX-5 review I1) —
11
+ * inquirer must NEVER run under Ink: it fights the
12
+ * React frame for stdin and paints over it
13
+ * - classic → inquirer select via withInquirer (unchanged)
10
14
  */
11
15
  import chalk from 'chalk';
12
16
  import { select } from '@inquirer/prompts';
13
17
  import { withInquirer } from './inquirer-guard.js';
14
18
  import { inquirerTheme } from './inquirer-theme.js';
19
+ import { isHeadless, shouldUseInk } from '../renderer/tty.js';
20
+ import { askQuestionEmitter } from '../ui/ask-question-emitter.js';
21
+ import { clearActiveSpinner } from '../renderer/tool-widget.js';
22
+ import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
15
23
  const RULE_LABEL = {
16
24
  RULE_7_SNAPSHOT_BEFORE_WRITE: 'RULE 7 — snapshot before write',
17
25
  RULE_7A_USE_UPDATE_METHOD: 'RULE 7a — use sap_update_method (not sap_set_source)',
@@ -21,7 +29,100 @@ const RULE_LABEL = {
21
29
  RULE_10_VERIFY_AFTER_WRITE: 'RULE 10 — verify after write',
22
30
  };
23
31
  export async function presentSafetyConfirmation(action) {
32
+ // B5 (2026-06-11) — headless fail-fast. This card is a stdin modal (the
33
+ // Rule 8 batch gate reaches it on the 2nd+ mutating call per turn even in
34
+ // one-shot, e.g. two local file writes); a headless run would hang on it
35
+ // forever. Headless runs NEVER auto-confirm safety gates — decline with a
36
+ // clear reason so the model can wrap up gracefully.
37
+ if (isHeadless()) {
38
+ console.error(chalk.red(`headless: ${RULE_LABEL[action.rule]} requires interactive confirmation — ` +
39
+ 'declining (headless runs never auto-confirm safety gates; run interactively).'));
40
+ return {
41
+ confirmed: false,
42
+ reason: 'headless run cannot confirm safety gates — run interactively',
43
+ headless: true,
44
+ };
45
+ }
24
46
  const card = renderCard(action);
47
+ // UX-5 review I1 (2026-06-12) — Ink-native branch. /safety-mode on made
48
+ // this card reachable under Ink, where the raw inquirer select() below
49
+ // toggled raw mode out from under the React frame and painted over it.
50
+ // Mirror of renderPlanGate (approvals/render.ts): route through the
51
+ // askQuestionEmitter choice modal — one input system, no stdin handoff.
52
+ // The modal renders `context` itself in dimColor, so strip the chalk ANSI
53
+ // codes (they'd show as raw escape text). Same choice semantics as the
54
+ // classic path below: Apply/Cancel, plus the Rule 9 irreversibility ack.
55
+ // Esc / no listener resolve as cancelled → decline (safe default).
56
+ if (shouldUseInk()) {
57
+ // CRITICAL race fix (2026-06-13) — this gate fires from INSIDE
58
+ // dispatchTool, on the 2nd+ mutating op of a turn. For local-FS ops
59
+ // (file_write / shell_exec, the fiori-build path) the agent loop
60
+ // classifies the tool as NON-interactive: it starts the in-place tool
61
+ // spinner, a per-second labelled heartbeat, AND keeps the turn-status
62
+ // row ticking — none of which it pauses (only ask_question /
63
+ // request_approval get paused at the loop level). So when this modal
64
+ // opens, three render sources are still repainting the Ink frame
65
+ // underneath it. That produced the observed live bug: the RULE 8 card
66
+ // rendered DOUBLED, Enter was not consumed by the modal, the keystroke
67
+ // LEAKED to the outer prompt (history-replay of the slash command), and
68
+ // the turn wedged for ~10 minutes while the tool spinner kept ticking.
69
+ //
70
+ // The gate must claim exclusive input focus itself (it can't rely on the
71
+ // loop, which doesn't know a non-interactive tool is about to block on
72
+ // the user). Mirror what the loop does for interactive tools:
73
+ // - clearActiveSpinner(): stop the live in-place tool spinner so only
74
+ // ONE surface (this modal) renders.
75
+ // - turnStatusEmitter.pause(): freeze the ticking status row so it
76
+ // stops repainting the dynamic frame and overdrawing the modal.
77
+ // resume() in the finally restores normal rendering the moment the gate
78
+ // resolves. (The per-second heartbeat is loop-local and is stopped on
79
+ // the loop side by ordering the gate ahead of heartbeat start — see
80
+ // tool-dispatch.ts / loop.ts.)
81
+ clearActiveSpinner();
82
+ const statusWasActive = turnStatusEmitter.snapshot().active;
83
+ if (statusWasActive)
84
+ turnStatusEmitter.pause();
85
+ try {
86
+ const first = await askQuestionEmitter.request({
87
+ id: 'safety-confirm',
88
+ question: `⚠ ${RULE_LABEL[action.rule]}`,
89
+ context: stripAnsi(card).trim(),
90
+ kind: 'choice',
91
+ choices: [
92
+ { value: 'apply', label: 'Apply' },
93
+ { value: 'cancel', label: 'Cancel' },
94
+ ],
95
+ });
96
+ if (first.cancelled || first.answer !== 'apply') {
97
+ return { confirmed: false, reason: 'user declined first prompt' };
98
+ }
99
+ // RULE 9 transport-release is irreversible — require a second explicit ack.
100
+ if (action.rule === 'RULE_9_TRANSPORT_RELEASE') {
101
+ const second = await askQuestionEmitter.request({
102
+ id: 'safety-confirm-irreversible',
103
+ question: `IRREVERSIBLE — releasing ${action.transport} cannot be undone from this side. ` +
104
+ `Are you absolutely sure?`,
105
+ kind: 'choice',
106
+ // Cancel first → the modal cursor starts there (mirrors the classic
107
+ // path's `default: 'cancel'`).
108
+ choices: [
109
+ { value: 'cancel', label: 'Cancel' },
110
+ { value: 'release', label: 'Release transport' },
111
+ ],
112
+ });
113
+ if (second.cancelled || second.answer !== 'release') {
114
+ return { confirmed: false, reason: 'user declined irreversibility ack' };
115
+ }
116
+ }
117
+ return { confirmed: true };
118
+ }
119
+ finally {
120
+ // Restore normal rendering. resume() is a no-op if the turn already
121
+ // ended (e.g. the user Ctrl+C'd mid-modal) or was never paused.
122
+ if (statusWasActive)
123
+ turnStatusEmitter.resume();
124
+ }
125
+ }
25
126
  // 2026-05-01: switched from confirm() to select() to fix the "press Enter
26
127
  // first, then 'y'" race observed in dispatched-tool rule-8 prompts. confirm
27
128
  // has a brief window after rendering its multi-line message before raw
@@ -60,6 +161,14 @@ export async function presentSafetyConfirmation(action) {
60
161
  }
61
162
  return { confirmed: true };
62
163
  }
164
+ /**
165
+ * Strip chalk ANSI escape codes. The Ink ask-question modal renders the
166
+ * card via its own <Text dimColor> — embedded escapes would display as
167
+ * literal `[33m` noise there. (Same approach as renderPlanGate.)
168
+ */
169
+ function stripAnsi(s) {
170
+ return s.replace(/\x1b\[[0-9;]*m/g, '');
171
+ }
63
172
  /**
64
173
  * Minimal indented summary block — replaces the chunky ASCII-art box.
65
174
  * Renders as:
@@ -1,7 +1,9 @@
1
1
  /**
2
- * Module-scope state for the safety-confirm-as-mode toggle. The REPL turns
3
- * this on via `--safety-confirm` flag at start, or `/safety-mode on|off` at
4
- * runtime. Tool dispatchers that hit a Forge Rule (snapshot, write, batch,
2
+ * Module-scope state for the safety-confirm-as-mode toggle. Off by default;
3
+ * the user turns it on with the `/safety-mode on` slash command at runtime
4
+ * (M3, 2026-06-12: there is no startup flag — an earlier version of this
5
+ * header claimed a `--safety-confirm` flag that was never implemented).
6
+ * Tool dispatchers that hit a Forge Rule (snapshot, write, batch,
5
7
  * transport release, activation) read isSafetyConfirmEnabled() and choose
6
8
  * presentSafetyConfirmation() vs the legacy ad-hoc inquirer prompt.
7
9
  *
@@ -34,3 +36,17 @@ export function handleSafetyModeCommand(args) {
34
36
  }
35
37
  return `Safety-confirm mode is currently ${enabled ? 'ON' : 'OFF'}. Use \`/safety-mode on\` or \`/safety-mode off\`.`;
36
38
  }
39
+ /**
40
+ * REPL slash-dispatch entry. Returns the status string when `trimmed` is a
41
+ * `/safety-mode` command (bare or with args), or null when the line is not
42
+ * a /safety-mode command at all. Both REPL paths (Ink handleSubmit and the
43
+ * classic readline loop) route through this single matcher so the command
44
+ * behaves identically everywhere — mirrors how /transport dispatches via
45
+ * handleTransportCommand.
46
+ */
47
+ export function matchSafetyModeCommand(trimmed) {
48
+ if (trimmed !== '/safety-mode' && !trimmed.startsWith('/safety-mode '))
49
+ return null;
50
+ const args = trimmed === '/safety-mode' ? '' : trimmed.slice('/safety-mode '.length);
51
+ return handleSafetyModeCommand(args);
52
+ }
@@ -21,21 +21,10 @@
21
21
  import { search } from '@inquirer/prompts';
22
22
  import { withInquirer } from './inquirer-guard.js';
23
23
  import { inquirerTheme } from './inquirer-theme.js';
24
- import { SKILL_CATALOG } from '../skill-catalog.js';
25
- const BUILTIN_ENTRIES = [
26
- { name: '/cancel', description: 'Cancel the current pre-filled command, return to a clean prompt' },
27
- { name: '/cost', description: 'Show this session\'s API spend so far ($ + token breakdown)' },
28
- { name: '/compact', description: 'Summarise older turns into a compact context block — cuts subsequent turn cost by 80-90%' },
29
- { name: '/exit', description: 'Exit CSPeach' },
30
- { name: '/quit', description: 'Exit CSPeach (alias)' },
31
- { name: '/help', description: 'Show all skills and commands' },
32
- { name: '/skills', description: 'Show all skills (alias)' },
33
- { name: '/new', description: 'Reset routing — next prompt is classified afresh' },
34
- { name: '/reset', description: 'Reset routing (alias)' },
35
- { name: '/ui', description: 'View or change rendering mode (auto / ink / classic)' },
36
- { name: '/reroute', description: 'Re-dispatch the previous prompt to a different skill' },
37
- { name: '/transport', description: 'View or set the active transport for this session' },
38
- ];
24
+ import { SKILL_CATALOG, SKILL_ALIASES } from '../skill-catalog.js';
25
+ // 2026-06-11 — builtin list extracted to builtin-commands.ts (pure data)
26
+ // so the router layer can check builtin heads without importing inquirer.
27
+ import { BUILTIN_ENTRIES } from './builtin-commands.js';
39
28
  function buildCatalogue() {
40
29
  const skillEntries = SKILL_CATALOG.map((s) => ({
41
30
  name: `/${s.name}`,
@@ -44,6 +33,20 @@ function buildCatalogue() {
44
33
  return [...BUILTIN_ENTRIES, ...skillEntries].sort((a, b) => a.name.localeCompare(b.name));
45
34
  }
46
35
  const ALL_ENTRIES = buildCatalogue();
36
+ /**
37
+ * 2026-06-06 (Ink picker parity): the full command catalogue (builtins +
38
+ * skills) and the substring pre-filter, exported so the Ink CommandPalette
39
+ * shares the exact same data + seeding semantics as the classic inquirer
40
+ * picker instead of maintaining a drifting copy.
41
+ */
42
+ export const ALL_SLASH_ENTRIES = ALL_ENTRIES;
43
+ /** Substring filter over the catalogue; empty prefix returns everything. */
44
+ export function filterSlashEntries(prefix) {
45
+ const lower = prefix.trim().toLowerCase();
46
+ return lower.length === 0
47
+ ? [...ALL_ENTRIES]
48
+ : ALL_ENTRIES.filter((e) => e.name.toLowerCase().includes(lower));
49
+ }
47
50
  /**
48
51
  * Open the slash picker. Returns the picked command WITHOUT a leading
49
52
  * slash (caller is expected to construct the full prompt) or null if
@@ -58,10 +61,7 @@ export async function openSlashPicker(prefix) {
58
61
  // contains the typed prefix as a substring — substring rather than
59
62
  // startsWith because users often remember a fragment in the middle of
60
63
  // a name (e.g. 'fix' should bring up /abap-atc-fix and /abap-upgrade-fix).
61
- const lowerPrefix = prefix.trim().toLowerCase();
62
- const seeded = lowerPrefix.length === 0
63
- ? ALL_ENTRIES
64
- : ALL_ENTRIES.filter((e) => e.name.toLowerCase().includes(lowerPrefix));
64
+ const seeded = filterSlashEntries(prefix);
65
65
  // If the prefix narrows down to exactly one command, just return it
66
66
  // without opening a picker — saves the user a keystroke.
67
67
  if (seeded.length === 1) {
@@ -122,5 +122,11 @@ export function isExactSlashMatch(trimmed) {
122
122
  if (!trimmed.startsWith('/'))
123
123
  return false;
124
124
  const head = trimmed.split(/\s+/, 1)[0].toLowerCase();
125
+ // Retired-but-redirected names (SKILL_ALIASES) count as exact so the
126
+ // line passes through to parseShortcut, which resolves the redirect and
127
+ // emits the "renamed" notice — instead of dumping the user into the
128
+ // picker. Aliases are deliberately NOT in the picker/completer lists.
129
+ if (head.startsWith('/') && head.slice(1) in SKILL_ALIASES)
130
+ return true;
125
131
  return ALL_ENTRIES.some((e) => e.name === head);
126
132
  }