@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
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Mid-turn steering queue (2026-06-08).
3
+ *
4
+ * While a turn is in flight the user can type a correction and press Enter;
5
+ * the Footer enqueues it here instead of bouncing it. The agent loop drains
6
+ * this at each round boundary and injects the text as a user message before
7
+ * the next createStream, so the model reads the correction and adapts —
8
+ * CC-style "type any time". Module-level singleton, mirroring the
9
+ * pendingDispatch accessor pattern; there is exactly one interactive REPL
10
+ * turn loop at a time.
11
+ */
12
+ const queue = [];
13
+ /** Add a steer. Trims; ignores empty / whitespace-only input. */
14
+ export function enqueueSteering(text) {
15
+ const t = text.trim();
16
+ if (t.length === 0)
17
+ return;
18
+ queue.push(t);
19
+ }
20
+ /** Return all queued steers (FIFO) and clear the queue. */
21
+ export function drainSteering() {
22
+ return queue.splice(0, queue.length);
23
+ }
24
+ /** True when at least one steer is queued. */
25
+ export function hasSteering() {
26
+ return queue.length > 0;
27
+ }
@@ -23,6 +23,21 @@ export async function dispatchTool(name, args, ctx) {
23
23
  batch_count: getWriteOpsThisTurn().length + 1,
24
24
  });
25
25
  if (!r.confirmed) {
26
+ // B5 — discriminate WHO declined. A headless fail-fast (nobody ever
27
+ // saw the card) must not masquerade as a user decision: the model
28
+ // (and any transcript reader) reacts differently to "the user said
29
+ // no" vs "no user was available to say yes". Uniform `headless: true`
30
+ // marker matches the other headless result payloads.
31
+ if (r.headless) {
32
+ return {
33
+ content: JSON.stringify({
34
+ error: 'headless_safety_decline',
35
+ headless: true,
36
+ reason: r.reason,
37
+ }),
38
+ is_error: true,
39
+ };
40
+ }
26
41
  return {
27
42
  content: JSON.stringify({ error: 'cancelled_by_user', reason: r.reason }),
28
43
  is_error: true,
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Canonical approval-object strings (Task A3 — defects D17/D21/D28).
3
+ *
4
+ * The battery exposed an APPROVAL_INVALID:object_mismatch epidemic (~8 wasted
5
+ * approval round-trips): request_approval minted whatever free-text object
6
+ * string the model wrote in the change row, while every write tool validated
7
+ * against a structured argument (args.name / args.className / args.description
8
+ * / objects[0].name). Any decoration the model added at mint time — and models
9
+ * reliably add decoration — broke the byte-equality check in
10
+ * verifyAndSpendApprovalId.
11
+ *
12
+ * Observed mismatch classes (exact strings from the battery sessions):
13
+ * D17 "ZBP_I_DOWNTIMELOG (CCIMP / lhc_DowntimeLog.validateEndAfterStart)"
14
+ * vs "ZBP_I_DOWNTIMELOG" → trailing parenthetical
15
+ * D21 "Transport: Magic fix" vs "Magic fix" → leading transport label
16
+ * D28 "ZR_PM_MAINTREQ,ZBP_R_PM_MAINTREQ,…" vs "ZR_PM_MAINTREQ"
17
+ * → one batch activate approval, per-object activate calls
18
+ *
19
+ * The fix is ONE code path for both sides:
20
+ * - mint side (jwt.ts#mintChangeApproval, used by request_approval) stores
21
+ * canonicalApprovalObject(change.object) in the JWT;
22
+ * - spend side (jwt.ts#verifyAndSpendApprovalId) compares via
23
+ * approvalObjectMatches, which canonicalizes BOTH the JWT payload object
24
+ * and the tool's expected object before comparing.
25
+ * The spend side canonicalizes too because (a) the expected side is always the
26
+ * raw structured tool argument, never pre-canonicalized, and (b) it is
27
+ * defense-in-depth against any future mint site that bypasses
28
+ * mintChangeApproval and stores a raw string.
29
+ */
30
+ /** Leading transport-request labels the model prepends to CTS descriptions. */
31
+ const LEADING_LABEL = /^(?:transport(?:\s+request)?|tr|cts|request)\s*:\s*/i;
32
+ /** A trailing parenthetical decoration, e.g. "(testclasses include)". */
33
+ const TRAILING_PAREN = /\s*\([^()]*\)\s*$/;
34
+ /** What an ABAP repository object name looks like once canonicalized. */
35
+ const OBJECT_NAME = /^[A-Z0-9_/]+$/;
36
+ /**
37
+ * Normalize an approval object string so mint and spend sides can never
38
+ * disagree on decoration, case, or whitespace:
39
+ *
40
+ * 1. trim
41
+ * 2. strip a leading "Transport:" / "TR:" / "Request:" label (D21)
42
+ * 3. strip trailing parenthetical decorations, stacked or single (D17)
43
+ * 4. collapse internal whitespace
44
+ * 5. uppercase
45
+ *
46
+ * All steps are applied symmetrically to both sides. Note that step 3 strips
47
+ * trailing parentheticals from free text too: transport descriptions
48
+ * "Fix dump (urgent)" and "Fix dump (rollback)" both canonicalize to
49
+ * "FIX DUMP" and therefore cross-match. This collision class is accepted
50
+ * because descriptions are display labels, not object identities, and every
51
+ * approval is session-scoped, TTL-bounded, and user-confirmed — the user saw
52
+ * the specific change row that minted the JWT.
53
+ */
54
+ export function canonicalApprovalObject(raw) {
55
+ let s = String(raw ?? '').trim();
56
+ s = s.replace(LEADING_LABEL, '');
57
+ for (let prev = ''; prev !== s;) {
58
+ prev = s;
59
+ s = s.replace(TRAILING_PAREN, '');
60
+ }
61
+ return s.replace(/\s+/g, ' ').trim().toUpperCase();
62
+ }
63
+ /**
64
+ * Spend-side comparison: does the object string stored in the approval JWT
65
+ * authorize an operation on `expected` (the structured argument the write
66
+ * tool validates against)? Returns the match kind, or `false` for no match.
67
+ *
68
+ * - byte equality ('exact'), OR
69
+ * - canonical equality ('canonical'), OR
70
+ * - the minted string is a comma-joined list of ABAP object names and
71
+ * `expected` is one of them ('list_member', D28 — batch activate approval
72
+ * spent by per-object sap_activate calls).
73
+ *
74
+ * The list rule only applies when EVERY part looks like an object name
75
+ * (no spaces / free text), so a transport description containing commas can
76
+ * never partially match.
77
+ */
78
+ export function approvalObjectMatches(minted, expected) {
79
+ if (minted === expected)
80
+ return 'exact';
81
+ const m = canonicalApprovalObject(minted);
82
+ const e = canonicalApprovalObject(expected);
83
+ if (m === e)
84
+ return 'canonical';
85
+ const parts = m.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
86
+ if (parts.length < 2)
87
+ return false;
88
+ if (!parts.every((p) => OBJECT_NAME.test(p)))
89
+ return false;
90
+ return parts.includes(e) ? 'list_member' : false;
91
+ }
@@ -1,16 +1,68 @@
1
1
  import { SignJWT, jwtVerify } from 'jose';
2
2
  import crypto from 'node:crypto';
3
+ import { auditLog } from '@cspeach/sap-client';
4
+ import { canonicalApprovalObject, approvalObjectMatches } from './canonical.js';
3
5
  // Session-scoped HMAC key — generated per CLI start, never persisted.
4
6
  const sessionKey = crypto.randomBytes(32);
5
7
  const usedNonces = new Set();
8
+ /**
9
+ * Op-group coverage. An approval the user granted for one op also authorizes
10
+ * the natural follow-on ops that *complete* it, on the SAME object:
11
+ *
12
+ * - approving `create` covers create → modify (source write) → activate,
13
+ * because creating a DDIC/repo object via ADT is always shell → source →
14
+ * activate. One user "yes" to "create ZPM_DT_REASON", not three.
15
+ * - approving `modify` covers modify → activate (write then activate).
16
+ * - `delete` / `release` stay single-op (and single-use) — one-shot ops the
17
+ * user must approve explicitly each time.
18
+ *
19
+ * This removes the op_mismatch APPROVAL_INVALID storm (create approval used,
20
+ * then set_source rejected because it's a different op) without weakening the
21
+ * invariant that an approval only ever authorizes ITS object, within TTL.
22
+ */
23
+ const OP_COVERAGE = {
24
+ create: new Set(['create', 'modify', 'activate']),
25
+ modify: new Set(['modify', 'activate']),
26
+ delete: new Set(['delete']),
27
+ activate: new Set(['activate']),
28
+ release: new Set(['release']),
29
+ };
30
+ /** create/modify approvals are reusable across their object's lifecycle ops
31
+ * until TTL; delete/release are genuinely one-shot. */
32
+ function isOneShotOp(op) {
33
+ return op === 'delete' || op === 'release';
34
+ }
6
35
  export async function mintApprovalId(payload) {
7
36
  const nonce = crypto.randomBytes(16).toString('hex');
8
37
  return new SignJWT({ ...payload, nonce })
9
38
  .setProtectedHeader({ alg: 'HS256' })
10
39
  .setIssuedAt()
11
- .setExpirationTime('5m')
40
+ // 30m, was 5m. A single build phase (DDIC batch + RAP stack) runs many
41
+ // rounds over a slow VPN; the one-write-per-round guard stretches it well
42
+ // past 5m, which expired approvals mid-phase and forced re-prompts. The
43
+ // key is session-scoped (regenerated per CLI start) and every approval is
44
+ // object+op-group bounded, so a longer in-turn window is safe.
45
+ .setExpirationTime('30m')
12
46
  .sign(sessionKey);
13
47
  }
48
+ /**
49
+ * THE minting path for request_approval changes (Task A3 — D17/D21/D28).
50
+ *
51
+ * The object string stored in the JWT is canonicalized by the same helper the
52
+ * spend-side comparison in verifyAndSpendApprovalId uses, so mint and spend
53
+ * can never disagree on decoration ("Transport: …", "(testclasses include)"),
54
+ * case, or whitespace. request_approval and any future minting site MUST go
55
+ * through this function instead of calling mintApprovalId with a free-text
56
+ * object string.
57
+ */
58
+ export async function mintChangeApproval(change, sessionId) {
59
+ return mintApprovalId({
60
+ object: canonicalApprovalObject(change.object),
61
+ type: change.type,
62
+ op: change.op,
63
+ session_id: sessionId,
64
+ });
65
+ }
14
66
  export async function verifyAndSpendApprovalId(jwt, expectedObject, expectedOp) {
15
67
  // 2026-05-15 (bug 7): the previous catch lumped EVERY thrown failure
16
68
  // into `'invalid_signature'`, including expirations (which used to be
@@ -43,12 +95,37 @@ export async function verifyAndSpendApprovalId(jwt, expectedObject, expectedOp)
43
95
  // taxonomy short. Mismatch / spent-nonce paths NEVER reach this catch.
44
96
  return { ok: false, reason: 'invalid_signature' };
45
97
  }
46
- if (usedNonces.has(payload.nonce))
47
- return { ok: false, reason: 'nonce_spent' };
48
- if (payload.object !== expectedObject)
98
+ // Canonical comparison (Task A3 — D17/D21/D28): both sides are normalized
99
+ // by the same helper that mintChangeApproval used at mint time, and a
100
+ // comma-joined batch approval matches each of its member objects. The spend
101
+ // side canonicalizes as well because (a) `expectedObject` is always the raw
102
+ // structured tool argument, never pre-canonicalized, and (b) it is
103
+ // defense-in-depth against any future mint site that bypasses
104
+ // mintChangeApproval and stores a raw string. (It is NOT back-compat for
105
+ // old JWTs: the HMAC key is per-process and never persisted, so no
106
+ // pre-canonicalization token can ever reach this verifier.)
107
+ const matchKind = approvalObjectMatches(payload.object, expectedObject);
108
+ if (!matchKind) {
49
109
  return { ok: false, reason: 'object_mismatch' };
50
- if (payload.op !== expectedOp)
110
+ }
111
+ // Op-group coverage: a create approval also authorizes the modify+activate
112
+ // that complete the create; a modify approval authorizes its activate.
113
+ const covered = OP_COVERAGE[payload.op];
114
+ if (!covered || !covered.has(expectedOp))
51
115
  return { ok: false, reason: 'op_mismatch' };
52
- usedNonces.add(payload.nonce);
53
- return { ok: true, payload };
116
+ // Single-use only for one-shot destructive ops. create/modify approvals are
117
+ // reusable for their object's lifecycle ops (create→modify→activate) within
118
+ // TTL — that IS the change the user approved, so re-spending is not replay.
119
+ if (isOneShotOp(payload.op)) {
120
+ if (usedNonces.has(payload.nonce))
121
+ return { ok: false, reason: 'nonce_spent' };
122
+ usedNonces.add(payload.nonce);
123
+ }
124
+ // Auditability: a lenient (non-byte-equal) match is an authorization
125
+ // decision worth a trace — record WHICH fold let the spend through, in the
126
+ // same audit log the write tools use.
127
+ if (matchKind !== 'exact') {
128
+ await auditLog('approvalSpend', payload.type, expectedObject, 'success', 0, `matchKind=${matchKind} minted="${payload.object}"`);
129
+ }
130
+ return { ok: true, payload, matchKind };
54
131
  }
@@ -2,6 +2,7 @@ import { select, input } from '@inquirer/prompts';
2
2
  import chalk from 'chalk';
3
3
  import { shouldUseInk } from '../renderer/tty.js';
4
4
  import { approvalEmitter } from '../ui/approval-emitter.js';
5
+ import { askQuestionEmitter } from '../ui/ask-question-emitter.js';
5
6
  import { withInquirer } from '../repl/inquirer-guard.js';
6
7
  import { inquirerTheme } from '../repl/inquirer-theme.js';
7
8
  import { PEACH } from '../renderer/banners.js';
@@ -102,6 +103,43 @@ export async function renderPlanGate(summary, changes, transport, effectiveRisk)
102
103
  const visibleChanges = collapseImplicitActivates(changes);
103
104
  const visibleCount = visibleChanges.length;
104
105
  const noun = visibleCount === 1 ? 'change' : 'changes';
106
+ // Ink path (default): route the gate through the ask_question choice modal so
107
+ // it renders INSIDE the React frame. The prior inquirer select drew raw to
108
+ // stdout while Ink owned the terminal — it toggled raw mode out from under
109
+ // Ink and competed with the Footer for keystrokes (the "turn in progress —
110
+ // input kept in box" confusion, and dead arrow keys on the modal that
111
+ // followed). One input system = no handoff, no competition. Mirrors
112
+ // renderPerChangeApprovalV3, which already went Ink-native via approvalEmitter.
113
+ if (shouldUseInk()) {
114
+ const ctx = [
115
+ summary,
116
+ [`${visibleCount} ${noun}`, riskTag(effectiveRisk.level), transport ? `transport ${transport}` : '']
117
+ .filter(Boolean).join(' · '),
118
+ ];
119
+ if (effectiveRisk.raisedBy.length > 0) {
120
+ ctx.push(`(raised from low: ${effectiveRisk.raisedBy.join(', ')})`);
121
+ }
122
+ for (const c of visibleChanges)
123
+ ctx.push(` ${opIcon(c.op)} ${c.object} (${c.type})`);
124
+ const result = await askQuestionEmitter.request({
125
+ id: 'plan-gate',
126
+ question: 'Apply this plan?',
127
+ // Strip chalk ANSI from opIcon/riskTag — the Ink modal renders `context`
128
+ // itself (dimColor), so embedded escape codes would show as raw text.
129
+ context: ctx.join('\n').replace(/\x1b\[[0-9;]*m/g, ''),
130
+ kind: 'choice',
131
+ choices: [
132
+ { value: 'apply_all', label: `Apply all ${visibleCount} ${noun}` },
133
+ { value: 'review', label: 'Review each change individually' },
134
+ { value: 'cancel', label: 'Cancel' },
135
+ ],
136
+ });
137
+ if (result.answer === 'apply_all' || result.answer === 'review' || result.answer === 'cancel') {
138
+ return result.answer;
139
+ }
140
+ return 'cancel'; // Esc / no answer → cancel the plan (safe default)
141
+ }
142
+ // Non-Ink (one-shot / non-TTY / classic): the original inquirer flow.
105
143
  console.log('');
106
144
  console.log(`${peach('Plan')} ${summary}`);
107
145
  const meta = [`${visibleCount} ${noun}`, riskTag(effectiveRisk.level)];
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Fetch the organisation's Anthropic API key from the CSPeach proxy.
3
+ *
4
+ * Returns the key string on success, or null on any failure.
5
+ * Non-fatal by design — callers must fall back to the manual prompt when null
6
+ * is returned. The key is NEVER logged.
7
+ */
8
+ export async function fetchOrgAnthropicKey(proxyUrl, bearer, deps) {
9
+ const fetchFn = deps?.fetchImpl ?? fetch;
10
+ const url = `${proxyUrl.replace(/\/$/, '')}/v1/me/anthropic-key`;
11
+ try {
12
+ const r = await fetchFn(url, {
13
+ headers: { Authorization: `Bearer ${bearer}` },
14
+ });
15
+ if (!r.ok)
16
+ return null;
17
+ const body = await r.json();
18
+ if (typeof body.key === 'string' && body.key.length > 0)
19
+ return body.key;
20
+ return null;
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
@@ -8,8 +8,10 @@ const RULES = [
8
8
  { skill: 'abap-document', patterns: [/developer\s+documentation|document\s+this\s+(class|program|object)/i] },
9
9
  // abap-incident MUST precede dump rules — pipeline framing beats keyword-only matches
10
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] },
11
+ // abap-dump (2026-06-12) — merger of abap-dump-analyze + abap-dump-triage.
12
+ // Triage-vs-fix is an intent decision INSIDE the skill now, so both
13
+ // pattern sets route here.
14
+ { skill: 'abap-dump', patterns: [/short\s+dump|runtime\s+error|\bst22\b|dump\s+(analys|triage|forensic)|structured\s+investigation.*dump/i] },
13
15
  { skill: 'abap-eml', patterns: [/\beml\b|entity\s+manipulation|behavior\s+handler/i] },
14
16
  { 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
17
  { 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] },
@@ -20,11 +22,24 @@ const RULES = [
20
22
  // abap-modernize MUST precede abap-rap so "rebuild as RAP" / "convert to RAP" stays
21
23
  // on modernize (rebuild intent) instead of falling to abap-rap's bare \brap\b match.
22
24
  { skill: 'abap-modernize', patterns: [/modernise|modernize|classic.*to.*modern|convert.*to\s+(rap|fiori|odata)|rebuild.*as\s+(rap|fiori)/i] },
25
+ // abap-fiori-build (D12, 2026-06-12) MUST follow abap-modernize ("convert
26
+ // this WRITE report to Fiori" = rebuild intent) and precede abap-rap so
27
+ // "fiori app for my RAP/OData service" lands on the UI skill, not the
28
+ // backend scaffolder.
29
+ { skill: 'abap-fiori-build', patterns: [/\bfiori\b|\b(sap)?ui5\b|freestyle\s+app|\bui\s+app\b|front.?end\s+(for|on)\s/i] },
23
30
  // abap-rap MUST precede abap-generate so RAP-stack prompts route to rap, not the
24
31
  // generic generator (cluster I, 2026-05-03). Also captures "scaffold a CDS view +
25
32
  // BDEF + service binding" type prompts which collide with abap-generate.
26
33
  { 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] },
34
+ // Daily-edit phrasings (D12, 2026-06-12): "add a validation to ZCL_X",
35
+ // "change/fix ZFOO" are abap-generate work — the old rules only matched
36
+ // explicit "generate"/"create" verbs, so daily edits fell through and the
37
+ // coaching picker offered enhance/refactor/test instead.
38
+ { skill: 'abap-generate', patterns: [
39
+ /\bgenerate\b|create\s+(a\s+)?(new\s+)?(class|program|report|include)|build.*from\s+scratch/i,
40
+ /\badd\s+(a\s+|an\s+)?[\w\s-]{1,40}\bto\s+z[_a-z0-9]+/i,
41
+ /\b(change|fix|update|modify|adjust)\s+(the\s+)?z[_a-z0-9]+\b/i,
42
+ ] },
28
43
  { skill: 'abap-impact', patterns: [/impact\s+analysis|where.*used|what.*affected\s+by/i] },
29
44
  // abap-upgrade-* must precede abap-migrate
30
45
  { skill: 'abap-upgrade-verify', patterns: [/upgrade\s+verify|remediation\s+report|verify\s+upgrade/i] },
@@ -32,6 +47,10 @@ const RULES = [
32
47
  { skill: 'abap-upgrade-scan', patterns: [/upgrade\s+scan|scan.*s\/4|readiness\s+variant/i] },
33
48
  { skill: 'abap-migrate', patterns: [/ecc.*migration|migrate.*s\/4|api\s+successor/i] },
34
49
  { skill: 'abap-performance', patterns: [/performance|\bn\+1\b|select\s+inside\s+loop|slow\s+(query|select)/i] },
50
+ // abap-plan (B4, 2026-06-06): deliberately NO bare \bplan\b — "I plan to
51
+ // refactor X" must keep falling through to the refactor/explain rules.
52
+ // Multi-session / project-plan framing only; refine after real prompts.
53
+ { skill: 'abap-plan', patterns: [/project\s+plan|implementation\s+plan|plan\s+(this|the|my)\s+(project|build|development)|multi.session\s+(project|plan|build)|resume\s+(the\s+|my\s+)?plan|step.by.step\s+plan/i] },
35
54
  { skill: 'abap-preflight', patterns: [/preflight|pre.release|go\/no.go|release\s+readiness/i] },
36
55
  { skill: 'abap-radar', patterns: [/radar|quick\s+look|overview\s+of\s+(this\s+)?(class|program|object)/i] },
37
56
  { skill: 'abap-refactor', patterns: [/refactor|clean\s+up\s+(code|abap)|procedural.*to\s+oo/i] },
@@ -42,12 +61,12 @@ const RULES = [
42
61
  { skill: 'abap-transport-analysis', patterns: [/transport\s+analysis|pre.release\s+transport|transport\s+readiness/i] },
43
62
  { skill: 'abap-transport', patterns: [/transport\s+(create|release|list)|\bcts\b|release.*request/i] },
44
63
  ];
45
- if (RULES.length !== 32) {
64
+ if (RULES.length !== 33) {
46
65
  // Warn-not-throw — managed-mode users never exercise classify() (the proxy
47
66
  // does server-side classification), so a module-load crash would break
48
67
  // them for a local-only consistency check. Unit tests assert the exact
49
68
  // count.
50
- console.warn(`[classifier] RULES drift: expected 32, got ${RULES.length} — reconcile with .claude/skills/`);
69
+ console.warn(`[classifier] RULES drift: expected 33, got ${RULES.length} — reconcile with .claude/skills/`);
51
70
  }
52
71
  export function classify(prompt) {
53
72
  for (const rule of RULES) {
@@ -10,6 +10,8 @@ const MUTABLE_KEYS = new Set([
10
10
  'effort',
11
11
  'telemetry',
12
12
  'classifier.safe_mode',
13
+ 'local_build',
14
+ 'local_files', // deprecated alias for local_build (2026-06-13) — still accepted
13
15
  'write_mode',
14
16
  'llm.mode',
15
17
  'llm.ai_hub_base_url',
@@ -22,6 +24,25 @@ const MUTABLE_KEYS = new Set([
22
24
  ]);
23
25
  // SAP-scoped key pattern: sap.<alias>.auto_approve
24
26
  const SAP_AUTO_APPROVE_RE = /^sap\.([A-Z0-9_-]{2,32})\.auto_approve$/;
27
+ // SAP-scoped TLS keys: sap.<alias>.ca_cert_path | sap.<alias>.insecure_skip_tls_verify
28
+ const SAP_CA_CERT_PATH_RE = /^sap\.([A-Z0-9_-]{2,32})\.ca_cert_path$/;
29
+ const SAP_INSECURE_TLS_RE = /^sap\.([A-Z0-9_-]{2,32})\.insecure_skip_tls_verify$/;
30
+ /**
31
+ * Coerce a user-typed on/off-style string to a boolean. Accepts the common
32
+ * spellings a developer expects from a CLI toggle (`on`/`true`/`1` → true,
33
+ * `off`/`false`/`0` → false). Returns null for anything else so the caller
34
+ * can reject with a clear error. Case-insensitive.
35
+ */
36
+ const TRUE_TOKENS = new Set(['on', 'true', '1']);
37
+ const FALSE_TOKENS = new Set(['off', 'false', '0']);
38
+ function coerceBool(value) {
39
+ const v = value.trim().toLowerCase();
40
+ if (TRUE_TOKENS.has(v))
41
+ return true;
42
+ if (FALSE_TOKENS.has(v))
43
+ return false;
44
+ return null;
45
+ }
25
46
  const EFFORT_VALUES = ['low', 'medium', 'high', 'xhigh', 'max'];
26
47
  const TELEMETRY_VALUES = ['minimal', 'full'];
27
48
  const AUTO_APPROVE_VALUES = ['never', 'low', 'medium'];
@@ -73,6 +94,45 @@ export async function runConfigSet(args) {
73
94
  console.log(chalk.green(`Set ${key} = ${value}`));
74
95
  return;
75
96
  }
97
+ // SAP-scoped TLS: sap.<alias>.ca_cert_path — trust a CA, verification STAYS ON.
98
+ const caMatch = SAP_CA_CERT_PATH_RE.exec(key);
99
+ if (caMatch) {
100
+ const alias = caMatch[1];
101
+ const cfg = await loadConfig();
102
+ if (!cfg.sap[alias]) {
103
+ err(`SAP system "${alias}" not found in config. Add it first with: cspeach config add-sap`);
104
+ }
105
+ // Path is stored as-is (no existence check here — the connection fails loudly
106
+ // and fail-closed if the CA can't be read at connect time). Setting a CA also
107
+ // clears any insecure flag so the two never conflict.
108
+ cfg.sap[alias].ca_cert_path = value;
109
+ delete cfg.sap[alias].insecure_skip_tls_verify;
110
+ await saveConfig(cfg);
111
+ console.log(chalk.green(`Set ${key} = ${value}`));
112
+ console.log(chalk.dim(' → TLS verification stays ON; this CA is added to the trust store.'));
113
+ return;
114
+ }
115
+ // SAP-scoped TLS: sap.<alias>.insecure_skip_tls_verify — dev-only opt-out.
116
+ const insecMatch = SAP_INSECURE_TLS_RE.exec(key);
117
+ if (insecMatch) {
118
+ const alias = insecMatch[1];
119
+ const b = coerceBool(value);
120
+ if (b === null) {
121
+ err(`${key} must be on/off (also accepts true/false, 1/0).`);
122
+ }
123
+ const cfg = await loadConfig();
124
+ if (!cfg.sap[alias]) {
125
+ err(`SAP system "${alias}" not found in config. Add it first with: cspeach config add-sap`);
126
+ }
127
+ cfg.sap[alias].insecure_skip_tls_verify = b;
128
+ if (b) {
129
+ console.log(chalk.yellow(` ⚠ TLS verification will be DISABLED for ${alias} — dev only, NEVER use in production. ` +
130
+ `Prefer: cspeach config set sap.${alias}.ca_cert_path <path-to-corporate-CA>`));
131
+ }
132
+ await saveConfig(cfg);
133
+ console.log(chalk.green(`Set ${key} = ${b}`));
134
+ return;
135
+ }
76
136
  // Virtual keys that store secrets in the OS keychain instead of config.toml.
77
137
  if (key === 'llm.byok_key' || key === 'llm.ai_hub_token') {
78
138
  // Support stdin piping for secrets: `echo "key" | cspeach config set llm.byok_key -`
@@ -142,6 +202,31 @@ export async function runConfigSet(args) {
142
202
  }
143
203
  cfg.classifier.safe_mode = value === 'true';
144
204
  break;
205
+ case 'local_files':
206
+ // Deprecated alias (2026-06-13). Still applied — but it WRITES to the new
207
+ // `local_build` key so the user's config converges on the new name. The
208
+ // shared `local_build` case below also DELETES any stale legacy
209
+ // `local_files` key so it doesn't linger after migration. One-line
210
+ // deprecation note, then fall through.
211
+ console.log(chalk.yellow(' → "local_files" is deprecated; use "local_build". Applying to local_build.'));
212
+ // fallthrough
213
+ case 'local_build': {
214
+ const b = coerceBool(value);
215
+ if (b === null) {
216
+ err(`${key} must be on/off (also accepts true/false, 1/0).`);
217
+ }
218
+ cfg.local_build = b;
219
+ // Migration hygiene (2026-06-13): whether the user typed `local_build` or
220
+ // the deprecated `local_files` alias, we write the new key AND drop any
221
+ // stale legacy `local_files` so it doesn't linger in `config show` after
222
+ // migration. resolveLocalBuild prefers local_build anyway, but a leftover
223
+ // legacy key is confusing and could surprise a future reader.
224
+ delete cfg.local_files;
225
+ // Config is read at startup; a running session isn't retroactively
226
+ // changed (the tool override is applied once, before the first turn).
227
+ console.log(chalk.dim(' → takes effect on the next `cspeach` launch.'));
228
+ break;
229
+ }
145
230
  case 'write_mode':
146
231
  if (!isValidWriteMode(value)) {
147
232
  err(`Invalid value for write_mode. Must be one of: auto, approval-gated, advisory-only`);
@@ -201,6 +286,13 @@ export function validateValue(key, value) {
201
286
  if (SAP_AUTO_APPROVE_RE.test(key)) {
202
287
  return AUTO_APPROVE_VALUES.includes(value);
203
288
  }
289
+ // SAP-scoped TLS keys.
290
+ if (SAP_CA_CERT_PATH_RE.test(key)) {
291
+ return value.length > 0; // any non-empty path; existence checked at connect
292
+ }
293
+ if (SAP_INSECURE_TLS_RE.test(key)) {
294
+ return coerceBool(value) !== null;
295
+ }
204
296
  if (!MUTABLE_KEYS.has(key))
205
297
  return false;
206
298
  switch (key) {
@@ -220,6 +312,9 @@ export function validateValue(key, value) {
220
312
  return TELEMETRY_VALUES.includes(value);
221
313
  case 'classifier.safe_mode':
222
314
  return value === 'true' || value === 'false';
315
+ case 'local_build':
316
+ case 'local_files': // deprecated alias — same on/off coercion
317
+ return coerceBool(value) !== null;
223
318
  case 'write_mode':
224
319
  return isValidWriteMode(value);
225
320
  case 'llm.mode':
@@ -13,7 +13,11 @@
13
13
  * Dependencies (open, print, prompt) are injected so the command is
14
14
  * unit-testable without stubbing platform globals.
15
15
  */
16
+ import keytar from 'keytar';
16
17
  import { writeAuthFile } from '../auth/auth-file.js';
18
+ import { fetchOrgAnthropicKey } from '../auth/org-anthropic-key.js';
19
+ const ANTHROPIC_KEYCHAIN_SERVICE = 'cspeach.anthropic';
20
+ const ANTHROPIC_KEYCHAIN_ACCOUNT = 'api-key';
17
21
  export async function runLoginCommand(deps) {
18
22
  const proxy = deps.proxyUrl.replace(/\/$/, '');
19
23
  const pollInterval = deps.pollIntervalMs ?? 5000;
@@ -92,22 +96,35 @@ export async function runLoginCommand(deps) {
92
96
  const me = await r.json();
93
97
  deps.print(`✓ Signed in as ${me.email} (${me.plan})`);
94
98
  if (me.trial_mode === 'byok' || me.plan === 'byo-key') {
95
- deps.print('');
96
- deps.print('This account is BYOK — you provide your own Anthropic API key.');
97
- deps.print('Get one at https://console.anthropic.com/settings/keys');
98
- const ak = (await deps.prompt('Paste your Anthropic API key (sk-ant-...): ')).trim();
99
- if (ak.startsWith('sk-ant-') && ak.length > 20) {
100
- // The non-BYOK save above just persisted these exact values,
101
- // so reading them back from disk would be redundant.
102
- await writeAuthFile({
103
- apiKey: polled.api_key,
104
- customerId: polled.customer_id,
105
- byokAnthropicKey: ak,
106
- });
107
- deps.print('✓ Anthropic key saved');
99
+ // Try to fetch the org's Anthropic key first — non-fatal if unavailable.
100
+ const orgKey = await fetchOrgAnthropicKey(proxy, polled.api_key, {
101
+ fetchImpl: deps.fetchImpl,
102
+ });
103
+ const setKey = deps.setKeychainKey ??
104
+ ((svc, acct, k) => keytar.setPassword(svc, acct, k));
105
+ if (orgKey !== null) {
106
+ await setKey(ANTHROPIC_KEYCHAIN_SERVICE, ANTHROPIC_KEYCHAIN_ACCOUNT, orgKey);
107
+ deps.print('Using your organization\'s Anthropic key.');
108
108
  }
109
109
  else {
110
- deps.print('error: Anthropic key looks malformed (must start with sk-ant-). Skipped — run `cspeach login` again later.');
110
+ // Fall back to the manual paste prompt — behaviour unchanged.
111
+ deps.print('');
112
+ deps.print('This account is BYOK — you provide your own Anthropic API key.');
113
+ deps.print('Get one at https://console.anthropic.com/settings/keys');
114
+ const ak = (await deps.prompt('Paste your Anthropic API key (sk-ant-...): ')).trim();
115
+ if (ak.startsWith('sk-ant-') && ak.length > 20) {
116
+ // The non-BYOK save above just persisted these exact values,
117
+ // so reading them back from disk would be redundant.
118
+ await writeAuthFile({
119
+ apiKey: polled.api_key,
120
+ customerId: polled.customer_id,
121
+ byokAnthropicKey: ak,
122
+ });
123
+ deps.print('✓ Anthropic key saved');
124
+ }
125
+ else {
126
+ deps.print('error: Anthropic key looks malformed (must start with sk-ant-). Skipped — run `cspeach login` again later.');
127
+ }
111
128
  }
112
129
  }
113
130
  }