@cspeach/cli 1.1.1 → 1.1.3
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/dist/agent/loop.js +33 -4
- package/dist/agent/session-context.js +24 -0
- package/dist/agent/tool-dispatch.js +17 -1
- package/dist/approvals/jwt.js +12 -1
- package/dist/cli-args.js +24 -0
- package/dist/cli.js +33 -4
- package/dist/commands/config-set.js +139 -4
- package/dist/config/loader.js +34 -0
- package/dist/cost/pricing.js +4 -0
- package/dist/cost/session-cost.js +28 -0
- package/dist/doctor/checks/_standalone-skip.js +37 -0
- package/dist/doctor/checks/cert.js +5 -0
- package/dist/doctor/checks/forge-rules.js +6 -0
- package/dist/doctor/checks/sap.js +5 -0
- package/dist/doctor/checks/standards.js +27 -0
- package/dist/doctor/checks/system-roles.js +5 -0
- package/dist/doctor/checks/zcspeach.js +5 -0
- package/dist/doctor/run.js +2 -0
- package/dist/one-shot.js +36 -11
- package/dist/renderer/abap-inline.js +2 -1
- package/dist/renderer/syntax.js +7 -1
- package/dist/repl/current-transport.js +23 -1
- package/dist/repl/post-turn-status.js +9 -6
- package/dist/repl.js +40 -7
- package/dist/sap/offline-adt-client.js +58 -0
- package/dist/sap/standalone-onboarding.js +118 -0
- package/dist/sap/standalone-profile.js +237 -0
- package/dist/skills/bundled-skills.js +41 -41
- package/dist/standards/standards-file.js +107 -0
- package/dist/standards/standards-init.js +194 -0
- package/dist/tools/capability/tool.js +2 -0
- package/dist/tools/fiori/fe-scaffold.js +25 -0
- package/dist/tools/fiori/smoke/assertions.js +8 -2
- package/dist/tools/fiori/smoke/driver.js +6 -0
- package/dist/tools/fiori/smoke/run-smoke.js +69 -2
- package/dist/tools/fiori/tools.js +4 -2
- package/dist/tools/index.js +25 -0
- package/dist/tools/sap-read.js +18 -0
- package/dist/tools/sap-write.js +32 -0
- package/dist/tools/snapshot.js +3 -0
- package/dist/tools/todo.js +11 -3
- package/dist/tools/transport.js +4 -0
- package/dist/ui/app.js +1 -1
- package/dist/ui/sap-state-store.js +1 -1
- package/dist/ui/status-line.js +2 -2
- package/dist/ui/todo-panel.js +27 -6
- package/fiori/zlas-inspection-lr/package.json +26 -0
- package/fiori/zlas-inspection-lr/ui5-local.yaml +45 -0
- package/fiori/zlas-inspection-lr/ui5.yaml +41 -0
- package/fiori/zlas-inspection-lr/webapp/Component.js +12 -0
- package/fiori/zlas-inspection-lr/webapp/i18n/i18n.properties +9 -0
- package/fiori/zlas-inspection-lr/webapp/index.html +35 -0
- package/fiori/zlas-inspection-lr/webapp/manifest.json +145 -0
- package/package.json +109 -109
package/dist/agent/loop.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { basename } from 'node:path';
|
|
3
3
|
import { dispatchTool } from './tool-dispatch.js';
|
|
4
|
-
import { listTools, getTool, toAnthropicTools } from '../tools/index.js';
|
|
4
|
+
import { listTools, getTool, toAnthropicTools, isStandalone, toolsForContext } from '../tools/index.js';
|
|
5
5
|
import { saveSession } from '../session/store.js';
|
|
6
6
|
import { recordCompletedToolCall } from '../session/pending.js';
|
|
7
7
|
import { loadConfig } from '../config/loader.js';
|
|
@@ -19,6 +19,8 @@ import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
|
|
|
19
19
|
// two-line dispatch renderer; re-add if a future in-place spinner returns)
|
|
20
20
|
import { buildRetryCapPausePayload, buildSkippedSiblingResults } from './retry-cap.js';
|
|
21
21
|
import { appendCostLine, buildEntry as buildCostEntry } from '../cost/cost-log.js';
|
|
22
|
+
import { computeSessionCost } from '../cost/session-cost.js';
|
|
23
|
+
import { globalStore } from '../ui/sap-state-store.js';
|
|
22
24
|
import { lastAssistantIsAwaitingAnswer } from '../session/awaiting-answer.js';
|
|
23
25
|
import { enrichUserMessage } from '../router/intent-extractor.js';
|
|
24
26
|
import { resetRule8State, shouldGateRule8 } from '../repl/rule8-detector.js';
|
|
@@ -28,6 +30,9 @@ import { input } from '@inquirer/prompts';
|
|
|
28
30
|
import { withInquirer } from '../repl/inquirer-guard.js';
|
|
29
31
|
import { expandTextFileAttachments } from '../projects/workspace.js';
|
|
30
32
|
import { getSapSystemInfo, renderSapSystemBlock } from '../sap/system-info.js';
|
|
33
|
+
import { renderStandaloneContextBlock } from '../sap/standalone-profile.js';
|
|
34
|
+
import { renderStandardsBlock } from '../standards/standards-file.js';
|
|
35
|
+
import { composeSessionContextBlock } from './session-context.js';
|
|
31
36
|
import { objectKeyFromInput } from './retry-key.js';
|
|
32
37
|
import { repairPartialBlocks, healSessionMessagesInPlace, isPartialJsonApiError } from './repair-partial.js';
|
|
33
38
|
import { drainSteering } from './steering-queue.js';
|
|
@@ -348,10 +353,23 @@ export async function runTurn(params) {
|
|
|
348
353
|
// before — no behavioral regression.
|
|
349
354
|
let sapSystemBlock = '';
|
|
350
355
|
let sapSystemInfo = null;
|
|
351
|
-
|
|
356
|
+
const standaloneTurn = isStandalone(params.ctx);
|
|
357
|
+
if (standaloneTurn) {
|
|
358
|
+
// Standalone (2026-09-16): nothing to probe. Render the SAME
|
|
359
|
+
// `<sap_system …/>` element shape from the stored standalone profile —
|
|
360
|
+
// so skills read one element in both modes — plus the manual-
|
|
361
|
+
// implementation hand-off note.
|
|
362
|
+
sapSystemBlock = renderStandaloneContextBlock(cfg.standalone);
|
|
363
|
+
}
|
|
364
|
+
else if (params.ctx.adt && params.ctx.sapAlias) {
|
|
352
365
|
sapSystemInfo = await getSapSystemInfo(params.ctx.adt, params.ctx.sapAlias);
|
|
353
366
|
sapSystemBlock = renderSapSystemBlock(sapSystemInfo);
|
|
354
367
|
}
|
|
368
|
+
// Customer development standards — opt-in, both modes. Renders '' when the
|
|
369
|
+
// user has no standards file, so the composed block (and therefore the whole
|
|
370
|
+
// prompt) stays byte-identical to pre-feature output for existing users.
|
|
371
|
+
const standardsBlock = await renderStandardsBlock(params.ctx.cwd);
|
|
372
|
+
const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, standardsBlock]);
|
|
355
373
|
// Deterministically extract structured facts from the user's prompt
|
|
356
374
|
// (package, transport, object names, environment hint, create intent)
|
|
357
375
|
// and prepend them as an XML `<session_context>` block. The model reads
|
|
@@ -366,7 +384,7 @@ export async function runTurn(params) {
|
|
|
366
384
|
// when extraction yields nothing (enrichUserMessage returns original).
|
|
367
385
|
const enriched = userMessageForLLM.startsWith('<session_context>')
|
|
368
386
|
? userMessageForLLM
|
|
369
|
-
: enrichUserMessage(userMessageForLLM,
|
|
387
|
+
: enrichUserMessage(userMessageForLLM, sessionContextBlock);
|
|
370
388
|
// Phase 2 of Track B — opt-in project-context enrichment, gated by
|
|
371
389
|
// CSPEACH_PROJECT_CONTEXT=on. Default OFF: production behavior is
|
|
372
390
|
// identical to today. When on, the rendered <project_context> block
|
|
@@ -503,7 +521,9 @@ export async function runTurn(params) {
|
|
|
503
521
|
// W2.5 — apply optional tool filter (used by agent_run with
|
|
504
522
|
// read_only: true to send a smaller, read-only tool list to
|
|
505
523
|
// the subagent). Mirrors CC's Tq2() pattern.
|
|
506
|
-
|
|
524
|
+
// Standalone (2026-09-16): hide every SAP-dependent tool from the
|
|
525
|
+
// model — calling one could only fail. Identity when connected.
|
|
526
|
+
const allTools = toolsForContext(listTools(), params.ctx);
|
|
507
527
|
const filteredTools = params.toolFilter ? allTools.filter(params.toolFilter) : allTools;
|
|
508
528
|
const tools = toAnthropicTools(filteredTools);
|
|
509
529
|
// Bug 11a (proactive heal, 2026-06-07) — strip any residual
|
|
@@ -698,6 +718,11 @@ export async function runTurn(params) {
|
|
|
698
718
|
session.usage.cache_creation_input_tokens =
|
|
699
719
|
(session.usage.cache_creation_input_tokens ?? 0)
|
|
700
720
|
+ (msgUsage.cache_creation_input_tokens ?? 0);
|
|
721
|
+
// F-L2-2 — live spend. The store's 200ms trailing rate limiter
|
|
722
|
+
// absorbs stream-event volume; no extra throttling here.
|
|
723
|
+
if (params.pushSpend) {
|
|
724
|
+
globalStore.update({ dollarSpendToday: computeSessionCost(session) });
|
|
725
|
+
}
|
|
701
726
|
}
|
|
702
727
|
// New message begins — reset the per-message cumulative-output scratch.
|
|
703
728
|
messageOutputTokensSoFar = 0;
|
|
@@ -720,6 +745,10 @@ export async function runTurn(params) {
|
|
|
720
745
|
if (inc > 0) {
|
|
721
746
|
session.usage.output_tokens += inc;
|
|
722
747
|
messageOutputTokensSoFar = deltaUsage.output_tokens;
|
|
748
|
+
// F-L2-2 — live spend (see message_start above).
|
|
749
|
+
if (params.pushSpend) {
|
|
750
|
+
globalStore.update({ dollarSpendToday: computeSessionCost(session) });
|
|
751
|
+
}
|
|
723
752
|
}
|
|
724
753
|
}
|
|
725
754
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for assembling the `<session_context>` preamble the agent loop
|
|
3
|
+
* prepends to each user message.
|
|
4
|
+
*
|
|
5
|
+
* `formatFactBlock` (router/intent-extractor.ts) takes ONE pre-rendered string
|
|
6
|
+
* and indents only its first line, so anything we add after the `<sap_system>`
|
|
7
|
+
* element has to carry its own two-space body indent. This helper is that
|
|
8
|
+
* single, tested place — the loop just hands it the parts it has.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Join the non-empty context parts into one block the fact-formatter can take.
|
|
12
|
+
*
|
|
13
|
+
* - No parts (or all empty) → '' — the caller's output is then byte-identical
|
|
14
|
+
* to the pre-feature behaviour.
|
|
15
|
+
* - One part → returned untouched.
|
|
16
|
+
* - Several → separated by a newline plus the two-space `<session_context>`
|
|
17
|
+
* body indent, so each part starts on its own indented line. Inner lines of a
|
|
18
|
+
* multi-line part (e.g. the standards document body) are left flush: they are
|
|
19
|
+
* element CONTENT, and indenting them would only pad the prompt.
|
|
20
|
+
*/
|
|
21
|
+
export function composeSessionContextBlock(parts) {
|
|
22
|
+
const nonEmpty = parts.filter((p) => typeof p === 'string' && p.length > 0);
|
|
23
|
+
return nonEmpty.join('\n ');
|
|
24
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { getTool } from '../tools/index.js';
|
|
1
|
+
import { getTool, isStandalone } from '../tools/index.js';
|
|
2
|
+
import { NOT_CONNECTED_MESSAGE } from '../sap/offline-adt-client.js';
|
|
2
3
|
import { presentSafetyConfirmation } from '../repl/safety-confirm.js';
|
|
3
4
|
import { shouldGateRule8, recordWriteOp, renderPlanSummary, getWriteOpsThisTurn, } from '../repl/rule8-detector.js';
|
|
4
5
|
export async function dispatchTool(name, args, ctx) {
|
|
@@ -6,6 +7,21 @@ export async function dispatchTool(name, args, ctx) {
|
|
|
6
7
|
if (!tool) {
|
|
7
8
|
return { content: JSON.stringify({ error: `unknown_tool: ${name}` }), is_error: true };
|
|
8
9
|
}
|
|
10
|
+
// Standalone safety net (2026-09-16). SAP-dependent tools are already
|
|
11
|
+
// filtered out of the list the model sees (agent/loop.ts), but a model can
|
|
12
|
+
// still call a tool it remembers from an earlier turn or from training. Fail
|
|
13
|
+
// as a normal tool ERROR RESULT — never a throw — so the model reads the
|
|
14
|
+
// message and recovers by asking the user to paste the source instead.
|
|
15
|
+
if (tool.requiresSap && isStandalone(ctx)) {
|
|
16
|
+
return {
|
|
17
|
+
content: JSON.stringify({
|
|
18
|
+
error: 'sap_not_connected',
|
|
19
|
+
standalone: true,
|
|
20
|
+
message: NOT_CONNECTED_MESSAGE,
|
|
21
|
+
}),
|
|
22
|
+
is_error: true,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
9
25
|
// Rule 8 — no batch without plan. 2nd+ mutating call this turn requires
|
|
10
26
|
// explicit confirmation. Fires regardless of /safety-mode toggle (batch
|
|
11
27
|
// detection is unconditional per Plan 3 §6.6).
|
package/dist/approvals/jwt.js
CHANGED
|
@@ -76,9 +76,20 @@ export async function verifyAndSpendApprovalId(jwt, expectedObject, expectedOp)
|
|
|
76
76
|
// signature-verification failure code. Mismatch reasons (nonce, object,
|
|
77
77
|
// op) fall out of the post-verify checks below and are returned
|
|
78
78
|
// verbatim — no catch involved.
|
|
79
|
+
//
|
|
80
|
+
// F-L2-13: normalize the token at the tool boundary. An approval_id that
|
|
81
|
+
// arrives wrapped in stray whitespace (a model-emitted argument with a
|
|
82
|
+
// trailing newline, a copy/paste with leading spaces) is a structurally
|
|
83
|
+
// valid JWT that jose rejects as malformed — the user then sees
|
|
84
|
+
// APPROVAL_INVALID / invalid_signature, which reads as "your token is
|
|
85
|
+
// forged" and offers no way forward. Trimming here covers all 10
|
|
86
|
+
// tool-boundary callers (sap-write.ts, transport.ts) at one site. Nonce
|
|
87
|
+
// bookkeeping keys on the decoded `payload.nonce`, never on the raw
|
|
88
|
+
// string, so trimming cannot desynchronize single-use accounting.
|
|
89
|
+
const token = String(jwt ?? '').trim();
|
|
79
90
|
let payload;
|
|
80
91
|
try {
|
|
81
|
-
const verified = await jwtVerify(
|
|
92
|
+
const verified = await jwtVerify(token, sessionKey);
|
|
82
93
|
payload = verified.payload;
|
|
83
94
|
}
|
|
84
95
|
catch (err) {
|
package/dist/cli-args.js
CHANGED
|
@@ -36,3 +36,27 @@ export function extractSapAliasFlag(args, availableAliases) {
|
|
|
36
36
|
const rest = [...args.slice(0, idx), ...args.slice(idx + 2)];
|
|
37
37
|
return { sapAlias: alias, rest };
|
|
38
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Scan `args` for `--no-sap` (standalone mode — run without a SAP system).
|
|
41
|
+
*
|
|
42
|
+
* - Absent: returns `{ noSap: false, rest: args }` — the SAME array reference,
|
|
43
|
+
* so every downstream path stays byte-identical to pre-feature behaviour.
|
|
44
|
+
* - Present: strips EVERY occurrence and returns `{ noSap: true, rest }`.
|
|
45
|
+
* - Present together with `--sap-alias`: returns `{ error, rest: args }`.
|
|
46
|
+
* Asking for a specific system AND for no system at all is a contradiction
|
|
47
|
+
* the CLI refuses rather than silently resolving one way.
|
|
48
|
+
*/
|
|
49
|
+
export function extractNoSapFlag(args) {
|
|
50
|
+
if (!args.includes('--no-sap')) {
|
|
51
|
+
return { noSap: false, rest: args };
|
|
52
|
+
}
|
|
53
|
+
if (args.includes('--sap-alias')) {
|
|
54
|
+
return {
|
|
55
|
+
noSap: true,
|
|
56
|
+
rest: args,
|
|
57
|
+
error: 'error: --no-sap and --sap-alias cannot be combined. ' +
|
|
58
|
+
'Use --no-sap to run without a SAP system, or --sap-alias <name> to pick one.',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return { noSap: true, rest: args.filter((a) => a !== '--no-sap') };
|
|
62
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -3,11 +3,22 @@ import { CLI_VERSION } from './lib/version.js';
|
|
|
3
3
|
import { runAddSapWizard } from './sap/onboarding.js';
|
|
4
4
|
import { runGcOnStartup } from './session/gc.js';
|
|
5
5
|
import { checkForUpgrade } from './upgrade-check.js';
|
|
6
|
-
import { extractSapAliasFlag } from './cli-args.js';
|
|
6
|
+
import { extractSapAliasFlag, extractNoSapFlag } from './cli-args.js';
|
|
7
7
|
export async function main(argv) {
|
|
8
8
|
runGcOnStartup().catch(() => { });
|
|
9
9
|
checkForUpgrade().catch(() => { });
|
|
10
10
|
let args = argv.slice(2);
|
|
11
|
+
// Standalone mode (2026-09-16) — `--no-sap` is accepted ANYWHERE in the args
|
|
12
|
+
// for both the REPL and one-shot, and stripped like `--sap-alias` below.
|
|
13
|
+
// Absent, `extractNoSapFlag` hands back the same array, so every existing
|
|
14
|
+
// path stays byte-identical.
|
|
15
|
+
const noSapParsed = extractNoSapFlag(args);
|
|
16
|
+
if (noSapParsed.error) {
|
|
17
|
+
console.error(chalk.red(noSapParsed.error));
|
|
18
|
+
return 1;
|
|
19
|
+
}
|
|
20
|
+
const noSap = noSapParsed.noSap;
|
|
21
|
+
args = noSapParsed.rest;
|
|
11
22
|
if (args[0] === '--help' || args[0] === '-h') {
|
|
12
23
|
printHelp();
|
|
13
24
|
return 0;
|
|
@@ -39,6 +50,15 @@ export async function main(argv) {
|
|
|
39
50
|
sapAlias = parsed.sapAlias;
|
|
40
51
|
args = parsed.rest;
|
|
41
52
|
}
|
|
53
|
+
// Standalone profile wizard — re-runnable any time. Checked BEFORE the plain
|
|
54
|
+
// `configure` branch so `configure --standalone` isn't swallowed by it.
|
|
55
|
+
// Asks what system the code will land on, then the standards question.
|
|
56
|
+
if ((args[0] === 'config' && args[1] === 'standalone')
|
|
57
|
+
|| (args[0] === 'configure' && args[1] === '--standalone')) {
|
|
58
|
+
const { runStandaloneProfileWizard } = await import('./sap/standalone-onboarding.js');
|
|
59
|
+
await runStandaloneProfileWizard({ cwd: process.cwd() });
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
42
62
|
if (args[0] === 'configure') {
|
|
43
63
|
console.log(chalk.dim('(alias for `cspeach config add-sap`)'));
|
|
44
64
|
await runAddSapWizard();
|
|
@@ -48,6 +68,12 @@ export async function main(argv) {
|
|
|
48
68
|
await runAddSapWizard();
|
|
49
69
|
return 0;
|
|
50
70
|
}
|
|
71
|
+
// Capture / import the team's development standards (works connected too).
|
|
72
|
+
if (args[0] === 'standards' && (args[1] === 'init' || args[1] === undefined)) {
|
|
73
|
+
const { runStandardsInit } = await import('./standards/standards-init.js');
|
|
74
|
+
await runStandardsInit(process.cwd());
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
51
77
|
if (args[0] === 'config' && args[1] === 'set') {
|
|
52
78
|
const { runConfigSet, ConfigSetError } = await import('./commands/config-set.js');
|
|
53
79
|
try {
|
|
@@ -124,16 +150,16 @@ export async function main(argv) {
|
|
|
124
150
|
}
|
|
125
151
|
if (args[0] === '--one-shot') {
|
|
126
152
|
const { runOneShot } = await import('./one-shot.js');
|
|
127
|
-
return runOneShot(args.slice(1).join(' '), { sapAlias });
|
|
153
|
+
return runOneShot(args.slice(1).join(' '), { sapAlias, noSap });
|
|
128
154
|
}
|
|
129
155
|
// Default: if first arg exists and is not a flag, treat as one-shot prompt.
|
|
130
156
|
if (args.length > 0 && !args[0].startsWith('-')) {
|
|
131
157
|
const { runOneShot } = await import('./one-shot.js');
|
|
132
|
-
return runOneShot(args.join(' '), { sapAlias });
|
|
158
|
+
return runOneShot(args.join(' '), { sapAlias, noSap });
|
|
133
159
|
}
|
|
134
160
|
// No args → REPL.
|
|
135
161
|
const { runRepl } = await import('./repl.js');
|
|
136
|
-
return runRepl({ sapAlias });
|
|
162
|
+
return runRepl({ sapAlias, noSap });
|
|
137
163
|
}
|
|
138
164
|
function printHelp() {
|
|
139
165
|
console.log(`
|
|
@@ -144,8 +170,11 @@ Usage:
|
|
|
144
170
|
cspeach "<prompt>" One-shot — runs one turn and exits
|
|
145
171
|
cspeach --one-shot "<prompt>" Same as above, explicit flag
|
|
146
172
|
cspeach --sap-alias <name> Use a specific SAP alias (overrides default)
|
|
173
|
+
cspeach --no-sap Start without a SAP system (paste-mode skills only)
|
|
147
174
|
cspeach --resume [id] Resume a previous session (list if no id)
|
|
148
175
|
cspeach config add-sap Add a SAP system interactively
|
|
176
|
+
cspeach config standalone Set the standalone SAP profile (no system)
|
|
177
|
+
cspeach standards init Capture your team's ABAP development standards
|
|
149
178
|
cspeach configure Alias for \`cspeach config add-sap\`
|
|
150
179
|
cspeach config set <key> <value> Update a config key
|
|
151
180
|
cspeach config unset <key> Remove a config key (revert to default)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { loadConfig, saveConfig, isValidWriteMode } from '../config/loader.js';
|
|
3
|
+
import { STANDALONE_PLATFORMS, STANDALONE_DATABASES, STANDALONE_DEPLOYMENTS, STANDALONE_IDES, isStandalonePlatform, deriveDatabase, deriveDeployment, defaultAbapCloudOnly, defaultRelease, } from '../sap/standalone-profile.js';
|
|
3
4
|
/**
|
|
4
5
|
* Mutable keys exposed via `cspeach config set`.
|
|
5
6
|
* Any key not in this set is rejected with an error listing valid keys.
|
|
@@ -17,6 +18,7 @@ const MUTABLE_KEYS = new Set([
|
|
|
17
18
|
'write_mode',
|
|
18
19
|
'plan_mode',
|
|
19
20
|
'plan_audit',
|
|
21
|
+
'no_sap',
|
|
20
22
|
'llm.mode',
|
|
21
23
|
'llm.ai_hub_base_url',
|
|
22
24
|
'llm.ai_hub_model_alias',
|
|
@@ -33,6 +35,17 @@ const SAP_ROLE_RE = /^sap\.([A-Z0-9_-]{2,32})\.role$/;
|
|
|
33
35
|
// SAP-scoped TLS keys: sap.<alias>.ca_cert_path | sap.<alias>.insecure_skip_tls_verify
|
|
34
36
|
const SAP_CA_CERT_PATH_RE = /^sap\.([A-Z0-9_-]{2,32})\.ca_cert_path$/;
|
|
35
37
|
const SAP_INSECURE_TLS_RE = /^sap\.([A-Z0-9_-]{2,32})\.insecure_skip_tls_verify$/;
|
|
38
|
+
// Standalone-profile keys: standalone.<field> (dotted, like the sap.* family).
|
|
39
|
+
const STANDALONE_KEY_RE = /^standalone\.([a-z_]+)$/;
|
|
40
|
+
const STANDALONE_FIELDS = [
|
|
41
|
+
'platform',
|
|
42
|
+
'release',
|
|
43
|
+
'database',
|
|
44
|
+
'deployment',
|
|
45
|
+
'ide',
|
|
46
|
+
'abap_cloud_only',
|
|
47
|
+
'notes',
|
|
48
|
+
];
|
|
36
49
|
/**
|
|
37
50
|
* Coerce a user-typed on/off-style string to a boolean. Accepts the common
|
|
38
51
|
* spellings a developer expects from a CLI toggle (`on`/`true`/`1` → true,
|
|
@@ -93,9 +106,10 @@ const VIRTUAL_KEYCHAIN_KEYS = new Set(['llm.byok_key', 'llm.ai_hub_token']);
|
|
|
93
106
|
* the deprecated `local_files` alias — the canonical name is `local_build`).
|
|
94
107
|
*/
|
|
95
108
|
function unsettableKeyList() {
|
|
96
|
-
return [
|
|
97
|
-
.filter((k) => !VIRTUAL_KEYCHAIN_KEYS.has(k) && k !== 'local_files')
|
|
98
|
-
|
|
109
|
+
return [
|
|
110
|
+
...[...MUTABLE_KEYS].filter((k) => !VIRTUAL_KEYCHAIN_KEYS.has(k) && k !== 'local_files'),
|
|
111
|
+
...UNSET_ONLY_KEYS,
|
|
112
|
+
].join(', ');
|
|
99
113
|
}
|
|
100
114
|
/**
|
|
101
115
|
* Mark a top-level config key as explicitly user-set so sparse serialisation
|
|
@@ -114,6 +128,76 @@ export function markExplicit(cfg, key) {
|
|
|
114
128
|
if (!cfg.file_keys.includes(top))
|
|
115
129
|
cfg.file_keys.push(top);
|
|
116
130
|
}
|
|
131
|
+
/** Human-readable "allowed values" error for a standalone.<field> set. */
|
|
132
|
+
function standaloneValueError(field, value) {
|
|
133
|
+
switch (field) {
|
|
134
|
+
case 'platform':
|
|
135
|
+
return `Invalid standalone.platform "${value}". Allowed: ${STANDALONE_PLATFORMS.join(', ')}`;
|
|
136
|
+
case 'database':
|
|
137
|
+
return `Invalid standalone.database "${value}". Allowed: ${STANDALONE_DATABASES.join(', ')}`;
|
|
138
|
+
case 'deployment':
|
|
139
|
+
return `Invalid standalone.deployment "${value}". Allowed: ${STANDALONE_DEPLOYMENTS.join(', ')}`;
|
|
140
|
+
case 'ide':
|
|
141
|
+
return `Invalid standalone.ide "${value}". Allowed: ${STANDALONE_IDES.join(', ')}`;
|
|
142
|
+
case 'abap_cloud_only':
|
|
143
|
+
return 'standalone.abap_cloud_only must be on/off (also accepts true/false, 1/0).';
|
|
144
|
+
default:
|
|
145
|
+
return `standalone.${field} cannot be empty.`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Apply one `standalone.<field>` value to the (possibly absent) profile.
|
|
150
|
+
*
|
|
151
|
+
* Setting `platform` on an absent profile seeds the whole table from that
|
|
152
|
+
* platform's derivations, so `config set standalone.platform S/4HANA` alone
|
|
153
|
+
* leaves a complete, consistent profile. Changing the platform later keeps the
|
|
154
|
+
* user's other answers — they said those on purpose.
|
|
155
|
+
*/
|
|
156
|
+
function applyStandaloneField(current, field, value) {
|
|
157
|
+
if (!current) {
|
|
158
|
+
// Guarded by the caller: only `platform` reaches here with no profile.
|
|
159
|
+
const platform = value;
|
|
160
|
+
return {
|
|
161
|
+
platform,
|
|
162
|
+
release: defaultRelease(platform),
|
|
163
|
+
database: deriveDatabase(platform) ?? 'HANA',
|
|
164
|
+
deployment: deriveDeployment(platform) ?? 'on-prem',
|
|
165
|
+
ide: 'ADT',
|
|
166
|
+
abap_cloud_only: defaultAbapCloudOnly(platform),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const next = { ...current };
|
|
170
|
+
switch (field) {
|
|
171
|
+
case 'platform':
|
|
172
|
+
next.platform = value;
|
|
173
|
+
break;
|
|
174
|
+
case 'release':
|
|
175
|
+
next.release = value.trim();
|
|
176
|
+
break;
|
|
177
|
+
case 'database':
|
|
178
|
+
next.database = value;
|
|
179
|
+
break;
|
|
180
|
+
case 'deployment':
|
|
181
|
+
next.deployment = value;
|
|
182
|
+
break;
|
|
183
|
+
case 'ide':
|
|
184
|
+
next.ide = value;
|
|
185
|
+
break;
|
|
186
|
+
case 'abap_cloud_only':
|
|
187
|
+
next.abap_cloud_only = coerceBool(value);
|
|
188
|
+
break;
|
|
189
|
+
case 'notes':
|
|
190
|
+
next.notes = value.trim();
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
return next;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Keys `config unset` accepts that are NOT flat `config set` targets.
|
|
197
|
+
* `standalone` is a table written field-by-field via `standalone.<field>`, but
|
|
198
|
+
* users still need one command to clear the whole profile.
|
|
199
|
+
*/
|
|
200
|
+
const UNSET_ONLY_KEYS = new Set(['standalone']);
|
|
117
201
|
/**
|
|
118
202
|
* Delete a (possibly dotted, max 2-level) key from a loaded config in place.
|
|
119
203
|
* Returns true if the key was present and removed, false if it was already
|
|
@@ -175,7 +259,7 @@ export async function runConfigUnset(args) {
|
|
|
175
259
|
if (key.startsWith('sap.')) {
|
|
176
260
|
errUnset(`"${key}" is a SAP-scoped key — those are managed per system via \`cspeach config set ${key} <value>\`, not config unset.`);
|
|
177
261
|
}
|
|
178
|
-
if (!MUTABLE_KEYS.has(key)) {
|
|
262
|
+
if (!MUTABLE_KEYS.has(key) && !UNSET_ONLY_KEYS.has(key)) {
|
|
179
263
|
errUnset(`Unknown key "${key}". Valid keys: ${unsettableKeyList()}`);
|
|
180
264
|
}
|
|
181
265
|
const cfg = await loadConfig();
|
|
@@ -272,6 +356,30 @@ export async function runConfigSet(args) {
|
|
|
272
356
|
console.log(chalk.green(`Set ${key} = ${b}`));
|
|
273
357
|
return;
|
|
274
358
|
}
|
|
359
|
+
// Standalone-profile key: standalone.<field>. Mirrors the sap.<alias>.<key>
|
|
360
|
+
// shape — a dotted key handled before the flat MUTABLE_KEYS switch.
|
|
361
|
+
const standaloneMatch = STANDALONE_KEY_RE.exec(key);
|
|
362
|
+
if (standaloneMatch) {
|
|
363
|
+
const field = standaloneMatch[1];
|
|
364
|
+
if (!STANDALONE_FIELDS.includes(field)) {
|
|
365
|
+
err(`Unknown standalone key "${key}". Valid: ${STANDALONE_FIELDS.map((f) => `standalone.${f}`).join(', ')}`);
|
|
366
|
+
}
|
|
367
|
+
if (!validateValue(key, value)) {
|
|
368
|
+
err(standaloneValueError(field, value));
|
|
369
|
+
}
|
|
370
|
+
const cfg = await loadConfig();
|
|
371
|
+
// The platform is load-bearing: every other field's default derives from
|
|
372
|
+
// it. Refuse to invent one so a lone `standalone.release` can never sit
|
|
373
|
+
// under a silently-guessed platform.
|
|
374
|
+
if (!cfg.standalone && field !== 'platform') {
|
|
375
|
+
err(`Set the platform first: cspeach config set standalone.platform <${STANDALONE_PLATFORMS.join(' | ')}>`);
|
|
376
|
+
}
|
|
377
|
+
cfg.standalone = applyStandaloneField(cfg.standalone, field, value);
|
|
378
|
+
markExplicit(cfg, key);
|
|
379
|
+
await saveConfig(cfg);
|
|
380
|
+
console.log(chalk.green(`Set ${key} = ${value}`));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
275
383
|
// Virtual keys that store secrets in the OS keychain instead of config.toml.
|
|
276
384
|
if (key === 'llm.byok_key' || key === 'llm.ai_hub_token') {
|
|
277
385
|
// Support stdin piping for secrets: `echo "key" | cspeach config set llm.byok_key -`
|
|
@@ -384,6 +492,16 @@ export async function runConfigSet(args) {
|
|
|
384
492
|
console.log(chalk.dim(' → takes effect on the next `cspeach` launch.'));
|
|
385
493
|
break;
|
|
386
494
|
}
|
|
495
|
+
case 'no_sap': {
|
|
496
|
+
// Standalone mode — start without a SAP system (paste-mode skills only).
|
|
497
|
+
const b = coerceBool(value);
|
|
498
|
+
if (b === null) {
|
|
499
|
+
err(`${key} must be on/off (also accepts true/false, 1/0).`);
|
|
500
|
+
}
|
|
501
|
+
cfg.no_sap = b;
|
|
502
|
+
console.log(chalk.dim(' → takes effect on the next `cspeach` launch.'));
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
387
505
|
case 'write_mode':
|
|
388
506
|
if (!isValidWriteMode(value)) {
|
|
389
507
|
err(`Invalid value for write_mode. Must be one of: auto, approval-gated, advisory-only`);
|
|
@@ -476,6 +594,21 @@ export function validateValue(key, value) {
|
|
|
476
594
|
if (SAP_INSECURE_TLS_RE.test(key)) {
|
|
477
595
|
return coerceBool(value) !== null;
|
|
478
596
|
}
|
|
597
|
+
// Standalone-profile keys: standalone.<field>
|
|
598
|
+
const standaloneMatch = STANDALONE_KEY_RE.exec(key);
|
|
599
|
+
if (standaloneMatch) {
|
|
600
|
+
const field = standaloneMatch[1];
|
|
601
|
+
switch (field) {
|
|
602
|
+
case 'platform': return isStandalonePlatform(value);
|
|
603
|
+
case 'database': return STANDALONE_DATABASES.includes(value);
|
|
604
|
+
case 'deployment': return STANDALONE_DEPLOYMENTS.includes(value);
|
|
605
|
+
case 'ide': return STANDALONE_IDES.includes(value);
|
|
606
|
+
case 'abap_cloud_only': return coerceBool(value) !== null;
|
|
607
|
+
case 'release':
|
|
608
|
+
case 'notes': return value.trim().length > 0;
|
|
609
|
+
default: return false;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
479
612
|
if (!MUTABLE_KEYS.has(key))
|
|
480
613
|
return false;
|
|
481
614
|
switch (key) {
|
|
@@ -506,6 +639,8 @@ export function validateValue(key, value) {
|
|
|
506
639
|
return PLAN_MODE_VALUES.includes(value);
|
|
507
640
|
case 'plan_audit':
|
|
508
641
|
return PLAN_AUDIT_VALUES.includes(value);
|
|
642
|
+
case 'no_sap':
|
|
643
|
+
return coerceBool(value) !== null;
|
|
509
644
|
case 'llm.mode':
|
|
510
645
|
return LLM_MODE_VALUES.includes(value);
|
|
511
646
|
case 'llm.ai_hub_base_url':
|
package/dist/config/loader.js
CHANGED
|
@@ -3,6 +3,7 @@ import { isDeepStrictEqual } from 'node:util';
|
|
|
3
3
|
import toml from '@iarna/toml';
|
|
4
4
|
import { configFile, cspeachRoot } from './paths.js';
|
|
5
5
|
import { DEFAULT_SESSION_MODEL } from './model-defaults.js';
|
|
6
|
+
import { sanitiseStandaloneProfile } from '../sap/standalone-profile.js';
|
|
6
7
|
/**
|
|
7
8
|
* Whitelist guard for WriteMode — rejects typos / stale values from disk so
|
|
8
9
|
* downstream code that trusts the WriteMode type doesn't see garbage.
|
|
@@ -141,6 +142,19 @@ export async function loadConfig() {
|
|
|
141
142
|
// resolveRenderSmoke reads as the default ON (fail-safe: a config typo can
|
|
142
143
|
// never silently disable the smoke).
|
|
143
144
|
render_smoke: typeof parsed.render_smoke === 'boolean' ? parsed.render_smoke : undefined,
|
|
145
|
+
// no_sap: same absent-preserving coercion as local_build (the key is
|
|
146
|
+
// dropped below when undefined, so it never serialises as
|
|
147
|
+
// `no_sap = undefined`). A present-but-invalid value collapses to
|
|
148
|
+
// `false` — fail-safe: a config typo can never silently disconnect a
|
|
149
|
+
// user from the SAP system they configured.
|
|
150
|
+
no_sap: parsed.no_sap === true
|
|
151
|
+
? true
|
|
152
|
+
: parsed.no_sap === undefined
|
|
153
|
+
? undefined
|
|
154
|
+
: false,
|
|
155
|
+
// standalone: whitelist-coerced profile table. An unrecognised platform
|
|
156
|
+
// drops the whole table (undefined → key deleted below).
|
|
157
|
+
standalone: sanitiseStandaloneProfile(parsed.standalone),
|
|
144
158
|
write_mode: isValidWriteMode(parsed.write_mode) ? parsed.write_mode : DEFAULT_CONFIG.write_mode,
|
|
145
159
|
// plan_mode: whitelist coercion — anything but the two valid literals
|
|
146
160
|
// (typos, wrong types) collapses to undefined, which callers treat as
|
|
@@ -210,6 +224,14 @@ export async function loadConfig() {
|
|
|
210
224
|
// callers see a genuine "absent" shape (resolveRenderSmoke treats it as ON).
|
|
211
225
|
if (merged.render_smoke === undefined)
|
|
212
226
|
delete merged.render_smoke;
|
|
227
|
+
// Same for `no_sap` and `standalone`: absent-or-invalid must not serialise
|
|
228
|
+
// as `undefined` — drop the keys so `config show` stays clean and callers
|
|
229
|
+
// see a genuine "absent" shape (resolveStandalone treats missing as off,
|
|
230
|
+
// and an absent profile triggers the standalone wizard).
|
|
231
|
+
if (merged.no_sap === undefined)
|
|
232
|
+
delete merged.no_sap;
|
|
233
|
+
if (merged.standalone === undefined)
|
|
234
|
+
delete merged.standalone;
|
|
213
235
|
// Same for `plan_mode`: absent-or-invalid must not serialise as
|
|
214
236
|
// `plan_mode = undefined` — drop the key so `config show` stays clean and
|
|
215
237
|
// callers see a genuine "absent" shape (treated as 'step').
|
|
@@ -305,6 +327,18 @@ export function resolveLocalBuild(cfg) {
|
|
|
305
327
|
export function resolveRenderSmoke(cfg) {
|
|
306
328
|
return cfg.render_smoke ?? true;
|
|
307
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* Resolve the effective standalone ("no SAP system") mode.
|
|
332
|
+
*
|
|
333
|
+
* effective = --no-sap flag OR config no_sap (default false)
|
|
334
|
+
*
|
|
335
|
+
* The flag can only turn standalone ON for a single run — there is no
|
|
336
|
+
* "--sap" counterpart — so the resolution is an OR, not a precedence chain.
|
|
337
|
+
* `no_sap` is already coerced to `true | false | undefined` by loadConfig.
|
|
338
|
+
*/
|
|
339
|
+
export function resolveStandalone(cfg, flagNoSap = false) {
|
|
340
|
+
return flagNoSap === true || cfg.no_sap === true;
|
|
341
|
+
}
|
|
308
342
|
export async function saveConfig(config) {
|
|
309
343
|
await fs.mkdir(cspeachRoot(), { recursive: true });
|
|
310
344
|
// The set of top-level keys the FILE carried (empty when it was absent).
|
package/dist/cost/pricing.js
CHANGED
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { getServerModelConfig } from '../models/server-config.js';
|
|
26
26
|
const PRICING = {
|
|
27
|
+
// Claude 5 family (2026-09-15) — Opus 5 keeps the Opus 4.5+ rate ($5/$25);
|
|
28
|
+
// Sonnet 5 is $2/$10. Cache read = 10% of input, cache write = 1.25x input.
|
|
29
|
+
'claude-opus-5': { inputPer1M: 5.00, outputPer1M: 25.00, cacheReadPer1M: 0.50, cacheCreatePer1M: 6.25 },
|
|
30
|
+
'claude-sonnet-5': { inputPer1M: 2.00, outputPer1M: 10.00, cacheReadPer1M: 0.20, cacheCreatePer1M: 2.50 },
|
|
27
31
|
// Claude Opus 4.5+ — input 5, output 25, cache_read 0.50, cache_write 6.25 (1.25x input).
|
|
28
32
|
// 2026-06-07 correction: 4.5/4.6/4.7 were carried at the LEGACY $15/$75; actual
|
|
29
33
|
// since Opus 4.5 is $5/$25. Only claude-opus-4 (4.0/4.1) genuinely was $15/$75.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped cost total — the single source of truth for the statusline
|
|
3
|
+
* `$` segment and the printed status footer's session line.
|
|
4
|
+
*
|
|
5
|
+
* F-L2-2 (2026-08-11): this computation was inlined in
|
|
6
|
+
* `repl/post-turn-status.ts`, the POST-turn chokepoint. That meant the
|
|
7
|
+
* statusline spend only ever moved between turns — during a long turn (the
|
|
8
|
+
* exact moment a user wants to watch the meter) it sat frozen. Extracted
|
|
9
|
+
* here verbatim so the agent loop can call it mid-stream (after each
|
|
10
|
+
* `message_start` / `message_delta` usage mutation) and push the running
|
|
11
|
+
* total to the store.
|
|
12
|
+
*
|
|
13
|
+
* Honest-semantics caveat, carried over unchanged from the original site:
|
|
14
|
+
* the total is priced at `session.model` over LIFETIME `session.usage`. On a
|
|
15
|
+
* session with model-tiered turns (A4 modelOverride) that is a mixed-model
|
|
16
|
+
* approximation — the per-turn cost JSONL records the model that actually
|
|
17
|
+
* ran. Same caveat as the footer's session line.
|
|
18
|
+
*/
|
|
19
|
+
import { computeCost } from './pricing.js';
|
|
20
|
+
/** USD cost of the session's lifetime usage, priced at `session.model`. */
|
|
21
|
+
export function computeSessionCost(session) {
|
|
22
|
+
return computeCost(session.model, {
|
|
23
|
+
input: session.usage.input_tokens,
|
|
24
|
+
output: session.usage.output_tokens,
|
|
25
|
+
cacheRead: session.usage.cache_read_input_tokens,
|
|
26
|
+
cacheCreate: session.usage.cache_creation_input_tokens ?? 0,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared "standalone mode is ON" result for the doctor's SAP checks.
|
|
3
|
+
*
|
|
4
|
+
* Standalone mode (2026-09-16) made "deliberately no SAP system" a SUPPORTED
|
|
5
|
+
* state, so with `no_sap = true` these checks report a neutral yellow `-` line
|
|
6
|
+
* that does not count toward the failed total.
|
|
7
|
+
*
|
|
8
|
+
* NOTHING else changes. With standalone OFF — including the plain "hasn't run
|
|
9
|
+
* `config add-sap` yet" case — every check keeps its original result, message
|
|
10
|
+
* and exit-code contribution, byte for byte.
|
|
11
|
+
*/
|
|
12
|
+
export const STANDALONE_SKIP_MESSAGE = 'standalone mode — SAP checks skipped';
|
|
13
|
+
/**
|
|
14
|
+
* True ONLY when the user has opted into standalone mode via `no_sap = true`.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately does NOT treat "no aliases configured" as standalone: for a
|
|
17
|
+
* user who simply hasn't added a system yet, a missing SAP connection is still
|
|
18
|
+
* a finding, and the pre-standalone output must be preserved exactly.
|
|
19
|
+
*
|
|
20
|
+
* Reads `cfg.no_sap` directly rather than calling `resolveStandalone` so this
|
|
21
|
+
* helper stays free of a runtime dependency on config/loader — several doctor
|
|
22
|
+
* tests replace that whole module with a mock. (`doctor` is its own process
|
|
23
|
+
* invocation, so the per-run `--no-sap` flag never reaches it anyway.)
|
|
24
|
+
*/
|
|
25
|
+
export function sapChecksSkipped(cfg) {
|
|
26
|
+
return cfg.no_sap === true;
|
|
27
|
+
}
|
|
28
|
+
/** The neutral skip result for a named check. */
|
|
29
|
+
export function standaloneSkip(name) {
|
|
30
|
+
return {
|
|
31
|
+
name,
|
|
32
|
+
ok: false,
|
|
33
|
+
skipped: true,
|
|
34
|
+
message: STANDALONE_SKIP_MESSAGE,
|
|
35
|
+
hint: 'Connect a system any time with: cspeach config add-sap',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import tls from 'node:tls';
|
|
2
2
|
import { loadConfig, resolveSapTls } from '../../config/loader.js';
|
|
3
|
+
import { sapChecksSkipped, standaloneSkip } from './_standalone-skip.js';
|
|
3
4
|
export async function checkCert() {
|
|
4
5
|
const cfg = await loadConfig();
|
|
6
|
+
// Standalone mode ONLY — an opted-in paste-mode user has nothing to probe.
|
|
7
|
+
// Everything below (including the "n/a" no-TLS line) is unchanged.
|
|
8
|
+
if (sapChecksSkipped(cfg))
|
|
9
|
+
return standaloneSkip('cert');
|
|
5
10
|
const systems = Object.entries(cfg.sap).filter(([_, s]) => s.useSsl);
|
|
6
11
|
if (systems.length === 0)
|
|
7
12
|
return { name: 'cert', ok: true, message: 'n/a (no TLS SAP systems configured)' };
|