@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.
- package/README.md +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +330 -36
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/steering-queue.js +27 -0
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +84 -7
- package/dist/approvals/render.js +38 -0
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +24 -5
- package/dist/commands/config-set.js +95 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +435 -0
- package/dist/config/loader.js +102 -3
- package/dist/cost/pricing.js +14 -5
- package/dist/doctor/checks/_http-probe.js +1 -0
- package/dist/doctor/checks/cert.js +14 -3
- package/dist/doctor/checks/sap.js +30 -8
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/one-shot.js +52 -4
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/email-template.js +2 -0
- package/dist/projects/extract-cca.js +108 -16
- package/dist/projects/extract-modernize.js +1 -1
- package/dist/projects/extract-plan.js +178 -0
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +1 -1
- package/dist/projects/extract-upgrade.js +113 -22
- package/dist/projects/index.js +7 -1
- package/dist/projects/merge-cca.js +292 -0
- package/dist/projects/merge-upgrade.js +173 -0
- package/dist/projects/migration.js +103 -1
- package/dist/projects/output-paths.js +27 -0
- package/dist/projects/plan-run.js +254 -0
- package/dist/projects/plan-schema.js +210 -0
- package/dist/projects/promote-command.js +26 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/save-command.js +263 -20
- package/dist/projects/status.js +22 -0
- package/dist/projects/validate.js +2 -0
- package/dist/projects/workspace.js +164 -20
- package/dist/renderer/notices.js +64 -0
- package/dist/renderer/progress-chatter.js +8 -0
- package/dist/renderer/syntax.js +16 -1
- package/dist/renderer/thinking-heartbeat.js +13 -1
- package/dist/renderer/tool-widget.js +18 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +93 -21
- package/dist/repl/builtin-commands.js +37 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +2 -2
- package/dist/repl/rule8-detector.js +17 -2
- package/dist/repl/safety-confirm.js +111 -2
- package/dist/repl/safety-mode-state.js +19 -3
- package/dist/repl/slash-picker.js +25 -19
- package/dist/repl.js +470 -22
- package/dist/router/classifier.js +150 -6
- package/dist/sap/capability-matrix.js +20 -0
- package/dist/sap/capability-matrix.json +11236 -0
- package/dist/sap/capability.js +146 -0
- package/dist/sap/connection-manager.js +19 -1
- package/dist/sap/onboarding.js +42 -4
- package/dist/session/pending.js +27 -0
- package/dist/skill-catalog.js +54 -43
- package/dist/skills/bundled-skills.js +279 -1
- package/dist/skills/promotion-dispatch.js +23 -0
- package/dist/tools/_command-shared.js +36 -12
- package/dist/tools/_filesystem-shared.js +139 -4
- package/dist/tools/_flag.js +25 -0
- package/dist/tools/approval.js +64 -21
- package/dist/tools/ask-question.js +96 -4
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +810 -0
- package/dist/tools/extend-model/tool.js +188 -0
- package/dist/tools/filesystem/extract-document.js +57 -0
- package/dist/tools/filesystem/file-edit.js +12 -2
- package/dist/tools/filesystem/file-read.js +2 -2
- package/dist/tools/filesystem/file-write.js +11 -2
- package/dist/tools/filesystem/glob.js +11 -0
- package/dist/tools/filesystem/grep.js +10 -0
- package/dist/tools/filesystem/read-document.js +107 -0
- package/dist/tools/fiori/apply.js +50 -0
- package/dist/tools/fiori/bin.js +3 -0
- package/dist/tools/fiori/catalog/index.js +27 -0
- package/dist/tools/fiori/catalog/value-help.js +230 -0
- package/dist/tools/fiori/catalog/viz-chart.js +177 -0
- package/dist/tools/fiori/cli.js +71 -0
- package/dist/tools/fiori/deploy-config.js +73 -0
- package/dist/tools/fiori/fe-scaffold.js +45 -0
- package/dist/tools/fiori/i18n.js +39 -0
- package/dist/tools/fiori/manifest.js +70 -0
- package/dist/tools/fiori/render.js +77 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/tools.js +356 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +76 -0
- package/dist/tools/local-files.js +31 -0
- package/dist/tools/project/_merge-shared.js +68 -0
- package/dist/tools/project/cca_merge.js +164 -0
- package/dist/tools/project/playbook_get.js +1 -1
- package/dist/tools/project/upgrade_merge_progress.js +206 -0
- package/dist/tools/sap-read.js +53 -9
- package/dist/tools/sap-write.js +530 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +37 -14
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/transport-resolution.js +86 -0
- package/dist/tools/transport.js +224 -5
- package/dist/tools/write-mode.js +4 -0
- package/dist/ui/app.js +84 -11
- package/dist/ui/body.js +13 -0
- package/dist/ui/command-palette.js +46 -10
- package/dist/ui/file-palette.js +44 -0
- package/dist/ui/footer.js +28 -11
- package/dist/ui/line-resolution.js +92 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/text-input.js +150 -0
- package/dist/ui/turn-status-emitter.js +52 -0
- package/dist/ui/turn-status.js +59 -0
- package/dist/ui/widgets/ask-question-modal.js +30 -2
- package/package.json +23 -4
- package/bench/README.md +0 -78
- package/bench/prompts/abap-document-cds.md +0 -44
- package/bench/prompts/abap-explain-bdef-handler.md +0 -57
- package/bench/prompts/abap-test-method.md +0 -42
- package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
- package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
- package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
- package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
- package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
- package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
- package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
- package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
- 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
|
+
}
|
package/dist/approvals/jwt.js
CHANGED
|
@@ -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
|
-
.
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
}
|
package/dist/approvals/render.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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 !==
|
|
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
|
|
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':
|
package/dist/commands/login.js
CHANGED
|
@@ -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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
await
|
|
103
|
-
|
|
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
|
-
|
|
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
|
}
|