@cspeach/cli 1.1.2 → 1.1.4
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 +31 -4
- package/dist/agent/session-context.js +24 -0
- package/dist/agent/tool-dispatch.js +17 -1
- package/dist/cli-args.js +24 -0
- package/dist/cli.js +33 -4
- package/dist/commands/config-set.js +155 -4
- package/dist/commands/cost.js +56 -4
- package/dist/commands/export-audit.js +4 -1
- package/dist/commands/plan-chain.js +8 -1
- package/dist/config/loader.js +44 -0
- package/dist/cost/cost-log.js +6 -1
- package/dist/cost/credits-wire.js +101 -0
- package/dist/cost/credits.js +199 -0
- package/dist/cost/display-mode.js +29 -0
- package/dist/cost/pricing.js +4 -0
- package/dist/doctor/checks/_standalone-skip.js +37 -0
- package/dist/doctor/checks/cert.js +5 -0
- package/dist/doctor/checks/credits.js +48 -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 +4 -0
- package/dist/one-shot.js +43 -11
- package/dist/renderer/status-footer.js +13 -3
- package/dist/repl/post-turn-status.js +5 -0
- package/dist/repl/session-spend-line.js +20 -0
- package/dist/repl.js +48 -9
- 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/session/audit-export.js +23 -4
- 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/index.js +25 -0
- package/dist/tools/sap-read.js +18 -0
- package/dist/tools/sap-write.js +14 -0
- package/dist/tools/snapshot.js +3 -0
- package/dist/tools/transport.js +4 -0
- package/dist/ui/header.js +3 -1
- package/dist/ui/sap-state-store.js +4 -0
- package/dist/ui/status-line.js +20 -1
- 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';
|
|
@@ -20,6 +20,7 @@ import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
|
|
|
20
20
|
import { buildRetryCapPausePayload, buildSkippedSiblingResults } from './retry-cap.js';
|
|
21
21
|
import { appendCostLine, buildEntry as buildCostEntry } from '../cost/cost-log.js';
|
|
22
22
|
import { computeSessionCost } from '../cost/session-cost.js';
|
|
23
|
+
import { refreshCreditsAfterTurn, creditsForCostLog } from '../cost/credits-wire.js';
|
|
23
24
|
import { globalStore } from '../ui/sap-state-store.js';
|
|
24
25
|
import { lastAssistantIsAwaitingAnswer } from '../session/awaiting-answer.js';
|
|
25
26
|
import { enrichUserMessage } from '../router/intent-extractor.js';
|
|
@@ -30,6 +31,9 @@ import { input } from '@inquirer/prompts';
|
|
|
30
31
|
import { withInquirer } from '../repl/inquirer-guard.js';
|
|
31
32
|
import { expandTextFileAttachments } from '../projects/workspace.js';
|
|
32
33
|
import { getSapSystemInfo, renderSapSystemBlock } from '../sap/system-info.js';
|
|
34
|
+
import { renderStandaloneContextBlock } from '../sap/standalone-profile.js';
|
|
35
|
+
import { renderStandardsBlock } from '../standards/standards-file.js';
|
|
36
|
+
import { composeSessionContextBlock } from './session-context.js';
|
|
33
37
|
import { objectKeyFromInput } from './retry-key.js';
|
|
34
38
|
import { repairPartialBlocks, healSessionMessagesInPlace, isPartialJsonApiError } from './repair-partial.js';
|
|
35
39
|
import { drainSteering } from './steering-queue.js';
|
|
@@ -350,10 +354,23 @@ export async function runTurn(params) {
|
|
|
350
354
|
// before — no behavioral regression.
|
|
351
355
|
let sapSystemBlock = '';
|
|
352
356
|
let sapSystemInfo = null;
|
|
353
|
-
|
|
357
|
+
const standaloneTurn = isStandalone(params.ctx);
|
|
358
|
+
if (standaloneTurn) {
|
|
359
|
+
// Standalone (2026-09-16): nothing to probe. Render the SAME
|
|
360
|
+
// `<sap_system …/>` element shape from the stored standalone profile —
|
|
361
|
+
// so skills read one element in both modes — plus the manual-
|
|
362
|
+
// implementation hand-off note.
|
|
363
|
+
sapSystemBlock = renderStandaloneContextBlock(cfg.standalone);
|
|
364
|
+
}
|
|
365
|
+
else if (params.ctx.adt && params.ctx.sapAlias) {
|
|
354
366
|
sapSystemInfo = await getSapSystemInfo(params.ctx.adt, params.ctx.sapAlias);
|
|
355
367
|
sapSystemBlock = renderSapSystemBlock(sapSystemInfo);
|
|
356
368
|
}
|
|
369
|
+
// Customer development standards — opt-in, both modes. Renders '' when the
|
|
370
|
+
// user has no standards file, so the composed block (and therefore the whole
|
|
371
|
+
// prompt) stays byte-identical to pre-feature output for existing users.
|
|
372
|
+
const standardsBlock = await renderStandardsBlock(params.ctx.cwd);
|
|
373
|
+
const sessionContextBlock = composeSessionContextBlock([sapSystemBlock, standardsBlock]);
|
|
357
374
|
// Deterministically extract structured facts from the user's prompt
|
|
358
375
|
// (package, transport, object names, environment hint, create intent)
|
|
359
376
|
// and prepend them as an XML `<session_context>` block. The model reads
|
|
@@ -368,7 +385,7 @@ export async function runTurn(params) {
|
|
|
368
385
|
// when extraction yields nothing (enrichUserMessage returns original).
|
|
369
386
|
const enriched = userMessageForLLM.startsWith('<session_context>')
|
|
370
387
|
? userMessageForLLM
|
|
371
|
-
: enrichUserMessage(userMessageForLLM,
|
|
388
|
+
: enrichUserMessage(userMessageForLLM, sessionContextBlock);
|
|
372
389
|
// Phase 2 of Track B — opt-in project-context enrichment, gated by
|
|
373
390
|
// CSPEACH_PROJECT_CONTEXT=on. Default OFF: production behavior is
|
|
374
391
|
// identical to today. When on, the rendered <project_context> block
|
|
@@ -505,7 +522,9 @@ export async function runTurn(params) {
|
|
|
505
522
|
// W2.5 — apply optional tool filter (used by agent_run with
|
|
506
523
|
// read_only: true to send a smaller, read-only tool list to
|
|
507
524
|
// the subagent). Mirrors CC's Tq2() pattern.
|
|
508
|
-
|
|
525
|
+
// Standalone (2026-09-16): hide every SAP-dependent tool from the
|
|
526
|
+
// model — calling one could only fail. Identity when connected.
|
|
527
|
+
const allTools = toolsForContext(listTools(), params.ctx);
|
|
509
528
|
const filteredTools = params.toolFilter ? allTools.filter(params.toolFilter) : allTools;
|
|
510
529
|
const tools = toAnthropicTools(filteredTools);
|
|
511
530
|
// Bug 11a (proactive heal, 2026-06-07) — strip any residual
|
|
@@ -805,6 +824,11 @@ export async function runTurn(params) {
|
|
|
805
824
|
// never crash a turn. Runs for both happy and interrupted paths so
|
|
806
825
|
// the user's spend is recorded even when a turn dies mid-stream.
|
|
807
826
|
{
|
|
827
|
+
// Credits (2026-09-16): sample the customer's balance BEFORE building
|
|
828
|
+
// the cost line so the JSONL records what the LEDGER charged alongside
|
|
829
|
+
// our COGS, and so the status footer the REPL prints next reads a fresh
|
|
830
|
+
// figure. No-op + no request outside credits mode; capped at 2.5 s.
|
|
831
|
+
await refreshCreditsAfterTurn();
|
|
808
832
|
const turnTokens = {
|
|
809
833
|
input: (session.usage?.input_tokens ?? 0) - turnStartTokens.input,
|
|
810
834
|
output: (session.usage?.output_tokens ?? 0) - turnStartTokens.output,
|
|
@@ -820,6 +844,9 @@ export async function runTurn(params) {
|
|
|
820
844
|
model: params.modelOverride ?? session.model,
|
|
821
845
|
tokens: turnTokens,
|
|
822
846
|
duration_ms: turnDurationMs,
|
|
847
|
+
// Additive: present only in credits mode (undefined in USD mode, so
|
|
848
|
+
// the emitted line is byte-identical to a pre-credits one).
|
|
849
|
+
credits: creditsForCostLog(),
|
|
823
850
|
});
|
|
824
851
|
void appendCostLine(session.id, entry);
|
|
825
852
|
}
|
|
@@ -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/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.
|
|
@@ -14,9 +15,11 @@ const MUTABLE_KEYS = new Set([
|
|
|
14
15
|
'classifier.safe_mode',
|
|
15
16
|
'local_build',
|
|
16
17
|
'local_files', // deprecated alias for local_build (2026-06-13) — still accepted
|
|
18
|
+
'show_cogs',
|
|
17
19
|
'write_mode',
|
|
18
20
|
'plan_mode',
|
|
19
21
|
'plan_audit',
|
|
22
|
+
'no_sap',
|
|
20
23
|
'llm.mode',
|
|
21
24
|
'llm.ai_hub_base_url',
|
|
22
25
|
'llm.ai_hub_model_alias',
|
|
@@ -33,6 +36,17 @@ const SAP_ROLE_RE = /^sap\.([A-Z0-9_-]{2,32})\.role$/;
|
|
|
33
36
|
// SAP-scoped TLS keys: sap.<alias>.ca_cert_path | sap.<alias>.insecure_skip_tls_verify
|
|
34
37
|
const SAP_CA_CERT_PATH_RE = /^sap\.([A-Z0-9_-]{2,32})\.ca_cert_path$/;
|
|
35
38
|
const SAP_INSECURE_TLS_RE = /^sap\.([A-Z0-9_-]{2,32})\.insecure_skip_tls_verify$/;
|
|
39
|
+
// Standalone-profile keys: standalone.<field> (dotted, like the sap.* family).
|
|
40
|
+
const STANDALONE_KEY_RE = /^standalone\.([a-z_]+)$/;
|
|
41
|
+
const STANDALONE_FIELDS = [
|
|
42
|
+
'platform',
|
|
43
|
+
'release',
|
|
44
|
+
'database',
|
|
45
|
+
'deployment',
|
|
46
|
+
'ide',
|
|
47
|
+
'abap_cloud_only',
|
|
48
|
+
'notes',
|
|
49
|
+
];
|
|
36
50
|
/**
|
|
37
51
|
* Coerce a user-typed on/off-style string to a boolean. Accepts the common
|
|
38
52
|
* spellings a developer expects from a CLI toggle (`on`/`true`/`1` → true,
|
|
@@ -93,9 +107,10 @@ const VIRTUAL_KEYCHAIN_KEYS = new Set(['llm.byok_key', 'llm.ai_hub_token']);
|
|
|
93
107
|
* the deprecated `local_files` alias — the canonical name is `local_build`).
|
|
94
108
|
*/
|
|
95
109
|
function unsettableKeyList() {
|
|
96
|
-
return [
|
|
97
|
-
.filter((k) => !VIRTUAL_KEYCHAIN_KEYS.has(k) && k !== 'local_files')
|
|
98
|
-
|
|
110
|
+
return [
|
|
111
|
+
...[...MUTABLE_KEYS].filter((k) => !VIRTUAL_KEYCHAIN_KEYS.has(k) && k !== 'local_files'),
|
|
112
|
+
...UNSET_ONLY_KEYS,
|
|
113
|
+
].join(', ');
|
|
99
114
|
}
|
|
100
115
|
/**
|
|
101
116
|
* Mark a top-level config key as explicitly user-set so sparse serialisation
|
|
@@ -114,6 +129,76 @@ export function markExplicit(cfg, key) {
|
|
|
114
129
|
if (!cfg.file_keys.includes(top))
|
|
115
130
|
cfg.file_keys.push(top);
|
|
116
131
|
}
|
|
132
|
+
/** Human-readable "allowed values" error for a standalone.<field> set. */
|
|
133
|
+
function standaloneValueError(field, value) {
|
|
134
|
+
switch (field) {
|
|
135
|
+
case 'platform':
|
|
136
|
+
return `Invalid standalone.platform "${value}". Allowed: ${STANDALONE_PLATFORMS.join(', ')}`;
|
|
137
|
+
case 'database':
|
|
138
|
+
return `Invalid standalone.database "${value}". Allowed: ${STANDALONE_DATABASES.join(', ')}`;
|
|
139
|
+
case 'deployment':
|
|
140
|
+
return `Invalid standalone.deployment "${value}". Allowed: ${STANDALONE_DEPLOYMENTS.join(', ')}`;
|
|
141
|
+
case 'ide':
|
|
142
|
+
return `Invalid standalone.ide "${value}". Allowed: ${STANDALONE_IDES.join(', ')}`;
|
|
143
|
+
case 'abap_cloud_only':
|
|
144
|
+
return 'standalone.abap_cloud_only must be on/off (also accepts true/false, 1/0).';
|
|
145
|
+
default:
|
|
146
|
+
return `standalone.${field} cannot be empty.`;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Apply one `standalone.<field>` value to the (possibly absent) profile.
|
|
151
|
+
*
|
|
152
|
+
* Setting `platform` on an absent profile seeds the whole table from that
|
|
153
|
+
* platform's derivations, so `config set standalone.platform S/4HANA` alone
|
|
154
|
+
* leaves a complete, consistent profile. Changing the platform later keeps the
|
|
155
|
+
* user's other answers — they said those on purpose.
|
|
156
|
+
*/
|
|
157
|
+
function applyStandaloneField(current, field, value) {
|
|
158
|
+
if (!current) {
|
|
159
|
+
// Guarded by the caller: only `platform` reaches here with no profile.
|
|
160
|
+
const platform = value;
|
|
161
|
+
return {
|
|
162
|
+
platform,
|
|
163
|
+
release: defaultRelease(platform),
|
|
164
|
+
database: deriveDatabase(platform) ?? 'HANA',
|
|
165
|
+
deployment: deriveDeployment(platform) ?? 'on-prem',
|
|
166
|
+
ide: 'ADT',
|
|
167
|
+
abap_cloud_only: defaultAbapCloudOnly(platform),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
const next = { ...current };
|
|
171
|
+
switch (field) {
|
|
172
|
+
case 'platform':
|
|
173
|
+
next.platform = value;
|
|
174
|
+
break;
|
|
175
|
+
case 'release':
|
|
176
|
+
next.release = value.trim();
|
|
177
|
+
break;
|
|
178
|
+
case 'database':
|
|
179
|
+
next.database = value;
|
|
180
|
+
break;
|
|
181
|
+
case 'deployment':
|
|
182
|
+
next.deployment = value;
|
|
183
|
+
break;
|
|
184
|
+
case 'ide':
|
|
185
|
+
next.ide = value;
|
|
186
|
+
break;
|
|
187
|
+
case 'abap_cloud_only':
|
|
188
|
+
next.abap_cloud_only = coerceBool(value);
|
|
189
|
+
break;
|
|
190
|
+
case 'notes':
|
|
191
|
+
next.notes = value.trim();
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
return next;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Keys `config unset` accepts that are NOT flat `config set` targets.
|
|
198
|
+
* `standalone` is a table written field-by-field via `standalone.<field>`, but
|
|
199
|
+
* users still need one command to clear the whole profile.
|
|
200
|
+
*/
|
|
201
|
+
const UNSET_ONLY_KEYS = new Set(['standalone']);
|
|
117
202
|
/**
|
|
118
203
|
* Delete a (possibly dotted, max 2-level) key from a loaded config in place.
|
|
119
204
|
* Returns true if the key was present and removed, false if it was already
|
|
@@ -175,7 +260,7 @@ export async function runConfigUnset(args) {
|
|
|
175
260
|
if (key.startsWith('sap.')) {
|
|
176
261
|
errUnset(`"${key}" is a SAP-scoped key — those are managed per system via \`cspeach config set ${key} <value>\`, not config unset.`);
|
|
177
262
|
}
|
|
178
|
-
if (!MUTABLE_KEYS.has(key)) {
|
|
263
|
+
if (!MUTABLE_KEYS.has(key) && !UNSET_ONLY_KEYS.has(key)) {
|
|
179
264
|
errUnset(`Unknown key "${key}". Valid keys: ${unsettableKeyList()}`);
|
|
180
265
|
}
|
|
181
266
|
const cfg = await loadConfig();
|
|
@@ -272,6 +357,30 @@ export async function runConfigSet(args) {
|
|
|
272
357
|
console.log(chalk.green(`Set ${key} = ${b}`));
|
|
273
358
|
return;
|
|
274
359
|
}
|
|
360
|
+
// Standalone-profile key: standalone.<field>. Mirrors the sap.<alias>.<key>
|
|
361
|
+
// shape — a dotted key handled before the flat MUTABLE_KEYS switch.
|
|
362
|
+
const standaloneMatch = STANDALONE_KEY_RE.exec(key);
|
|
363
|
+
if (standaloneMatch) {
|
|
364
|
+
const field = standaloneMatch[1];
|
|
365
|
+
if (!STANDALONE_FIELDS.includes(field)) {
|
|
366
|
+
err(`Unknown standalone key "${key}". Valid: ${STANDALONE_FIELDS.map((f) => `standalone.${f}`).join(', ')}`);
|
|
367
|
+
}
|
|
368
|
+
if (!validateValue(key, value)) {
|
|
369
|
+
err(standaloneValueError(field, value));
|
|
370
|
+
}
|
|
371
|
+
const cfg = await loadConfig();
|
|
372
|
+
// The platform is load-bearing: every other field's default derives from
|
|
373
|
+
// it. Refuse to invent one so a lone `standalone.release` can never sit
|
|
374
|
+
// under a silently-guessed platform.
|
|
375
|
+
if (!cfg.standalone && field !== 'platform') {
|
|
376
|
+
err(`Set the platform first: cspeach config set standalone.platform <${STANDALONE_PLATFORMS.join(' | ')}>`);
|
|
377
|
+
}
|
|
378
|
+
cfg.standalone = applyStandaloneField(cfg.standalone, field, value);
|
|
379
|
+
markExplicit(cfg, key);
|
|
380
|
+
await saveConfig(cfg);
|
|
381
|
+
console.log(chalk.green(`Set ${key} = ${value}`));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
275
384
|
// Virtual keys that store secrets in the OS keychain instead of config.toml.
|
|
276
385
|
if (key === 'llm.byok_key' || key === 'llm.ai_hub_token') {
|
|
277
386
|
// Support stdin piping for secrets: `echo "key" | cspeach config set llm.byok_key -`
|
|
@@ -384,6 +493,30 @@ export async function runConfigSet(args) {
|
|
|
384
493
|
console.log(chalk.dim(' → takes effect on the next `cspeach` launch.'));
|
|
385
494
|
break;
|
|
386
495
|
}
|
|
496
|
+
case 'no_sap': {
|
|
497
|
+
// Standalone mode — start without a SAP system (paste-mode skills only).
|
|
498
|
+
const b = coerceBool(value);
|
|
499
|
+
if (b === null) {
|
|
500
|
+
err(`${key} must be on/off (also accepts true/false, 1/0).`);
|
|
501
|
+
}
|
|
502
|
+
cfg.no_sap = b;
|
|
503
|
+
console.log(chalk.dim(' → takes effect on the next `cspeach` launch.'));
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
case 'show_cogs': {
|
|
507
|
+
// Operator escape hatch — render OUR Anthropic COGS in USD even on a
|
|
508
|
+
// managed, metered account (where the customer-facing unit is credits).
|
|
509
|
+
// Off/absent is the product default; see cost/display-mode.ts.
|
|
510
|
+
const b = coerceBool(value);
|
|
511
|
+
if (b === null) {
|
|
512
|
+
err(`${key} must be on/off (also accepts true/false, 1/0).`);
|
|
513
|
+
}
|
|
514
|
+
cfg.show_cogs = b;
|
|
515
|
+
console.log(chalk.dim(b
|
|
516
|
+
? ' → cost figures will show USD (our Anthropic cost), not credits.'
|
|
517
|
+
: ' → cost figures will show credits on a managed, metered account.'));
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
387
520
|
case 'write_mode':
|
|
388
521
|
if (!isValidWriteMode(value)) {
|
|
389
522
|
err(`Invalid value for write_mode. Must be one of: auto, approval-gated, advisory-only`);
|
|
@@ -476,6 +609,21 @@ export function validateValue(key, value) {
|
|
|
476
609
|
if (SAP_INSECURE_TLS_RE.test(key)) {
|
|
477
610
|
return coerceBool(value) !== null;
|
|
478
611
|
}
|
|
612
|
+
// Standalone-profile keys: standalone.<field>
|
|
613
|
+
const standaloneMatch = STANDALONE_KEY_RE.exec(key);
|
|
614
|
+
if (standaloneMatch) {
|
|
615
|
+
const field = standaloneMatch[1];
|
|
616
|
+
switch (field) {
|
|
617
|
+
case 'platform': return isStandalonePlatform(value);
|
|
618
|
+
case 'database': return STANDALONE_DATABASES.includes(value);
|
|
619
|
+
case 'deployment': return STANDALONE_DEPLOYMENTS.includes(value);
|
|
620
|
+
case 'ide': return STANDALONE_IDES.includes(value);
|
|
621
|
+
case 'abap_cloud_only': return coerceBool(value) !== null;
|
|
622
|
+
case 'release':
|
|
623
|
+
case 'notes': return value.trim().length > 0;
|
|
624
|
+
default: return false;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
479
627
|
if (!MUTABLE_KEYS.has(key))
|
|
480
628
|
return false;
|
|
481
629
|
switch (key) {
|
|
@@ -499,6 +647,7 @@ export function validateValue(key, value) {
|
|
|
499
647
|
return value === 'true' || value === 'false';
|
|
500
648
|
case 'local_build':
|
|
501
649
|
case 'local_files': // deprecated alias — same on/off coercion
|
|
650
|
+
case 'show_cogs':
|
|
502
651
|
return coerceBool(value) !== null;
|
|
503
652
|
case 'write_mode':
|
|
504
653
|
return isValidWriteMode(value);
|
|
@@ -506,6 +655,8 @@ export function validateValue(key, value) {
|
|
|
506
655
|
return PLAN_MODE_VALUES.includes(value);
|
|
507
656
|
case 'plan_audit':
|
|
508
657
|
return PLAN_AUDIT_VALUES.includes(value);
|
|
658
|
+
case 'no_sap':
|
|
659
|
+
return coerceBool(value) !== null;
|
|
509
660
|
case 'llm.mode':
|
|
510
661
|
return LLM_MODE_VALUES.includes(value);
|
|
511
662
|
case 'llm.ai_hub_base_url':
|
package/dist/commands/cost.js
CHANGED
|
@@ -11,7 +11,24 @@
|
|
|
11
11
|
import chalk from 'chalk';
|
|
12
12
|
import { readCostLog, summarise } from '../cost/cost-log.js';
|
|
13
13
|
import { formatCost } from '../cost/pricing.js';
|
|
14
|
-
|
|
14
|
+
import { creditsActive, formatCredits, globalCredits, CREDITS_PENDING, } from '../cost/credits.js';
|
|
15
|
+
/**
|
|
16
|
+
* Sum the credits the proxy charged across the logged turns. Entries written
|
|
17
|
+
* before credits existed (or on turns whose balance fetch failed) carry no
|
|
18
|
+
* number — they are skipped, not counted as zero, and the caller says so.
|
|
19
|
+
*/
|
|
20
|
+
function summariseCredits(entries) {
|
|
21
|
+
let total = 0;
|
|
22
|
+
let unknown = 0;
|
|
23
|
+
for (const e of entries) {
|
|
24
|
+
if (typeof e.credits_charged === 'number')
|
|
25
|
+
total += e.credits_charged;
|
|
26
|
+
else
|
|
27
|
+
unknown++;
|
|
28
|
+
}
|
|
29
|
+
return { total, unknown };
|
|
30
|
+
}
|
|
31
|
+
export async function runCostCommand(sessionId, credits = globalCredits.snapshot()) {
|
|
15
32
|
const entries = await readCostLog(sessionId);
|
|
16
33
|
if (entries.length === 0) {
|
|
17
34
|
console.log('');
|
|
@@ -20,9 +37,27 @@ export async function runCostCommand(sessionId) {
|
|
|
20
37
|
return;
|
|
21
38
|
}
|
|
22
39
|
const s = summarise(entries);
|
|
40
|
+
// Credits mode: the customer is billed in credits at a multiplier we do not
|
|
41
|
+
// disclose, so NOTHING derived from cost/pricing.ts may be printed here —
|
|
42
|
+
// not the total, not the per-model split, not the per-turn figures. Tokens
|
|
43
|
+
// stay: they are a usage fact, not a COGS leak.
|
|
44
|
+
const inCredits = creditsActive();
|
|
45
|
+
const perTurn = summariseCredits(entries);
|
|
23
46
|
// Header
|
|
24
47
|
console.log('');
|
|
25
|
-
|
|
48
|
+
if (inCredits) {
|
|
49
|
+
const unit = credits?.unitLabel ?? 'credits';
|
|
50
|
+
// Prefer the live session tracker (balance deltas observed this run);
|
|
51
|
+
// fall back to the logged per-turn charges when it has nothing yet.
|
|
52
|
+
const total = credits && credits.sessionTotal > 0 ? credits.sessionTotal : perTurn.total;
|
|
53
|
+
console.log(chalk.bold(`Session usage so far: ${formatCredits(total, unit)}`) + chalk.dim(` (${s.turns} turn${s.turns === 1 ? '' : 's'})`));
|
|
54
|
+
if (credits?.balance != null) {
|
|
55
|
+
console.log(chalk.dim(`Remaining balance: ${formatCredits(credits.balance, unit)}`));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
console.log(chalk.bold(`Session cost so far: ${formatCost(s.totalCost)}`) + chalk.dim(` (${s.turns} turn${s.turns === 1 ? '' : 's'})`));
|
|
60
|
+
}
|
|
26
61
|
// Token breakdown
|
|
27
62
|
console.log('');
|
|
28
63
|
console.log(chalk.dim('Tokens:'));
|
|
@@ -35,7 +70,10 @@ export async function runCostCommand(sessionId) {
|
|
|
35
70
|
console.log('');
|
|
36
71
|
console.log(chalk.dim('By model:'));
|
|
37
72
|
for (const m of s.byModel) {
|
|
38
|
-
|
|
73
|
+
// Credits mode: turn counts only. A per-model dollar split is a direct
|
|
74
|
+
// read on our per-model COGS.
|
|
75
|
+
const tail = inCredits ? '' : ` ${formatCost(m.cost)}`;
|
|
76
|
+
console.log(` ${m.model.padEnd(24)} ${m.turns} turn${m.turns === 1 ? '' : 's'}${tail}`);
|
|
39
77
|
}
|
|
40
78
|
}
|
|
41
79
|
else if (s.byModel[0]) {
|
|
@@ -48,7 +86,21 @@ export async function runCostCommand(sessionId) {
|
|
|
48
86
|
console.log(chalk.dim(`Recent turns (last ${recent.length}):`));
|
|
49
87
|
for (const e of recent) {
|
|
50
88
|
const tok = (e.tokens.input + e.tokens.output).toLocaleString();
|
|
51
|
-
|
|
89
|
+
// Credits mode: the per-turn figure is what the LEDGER charged (a balance
|
|
90
|
+
// delta recorded at turn end), never our computed USD. A turn whose
|
|
91
|
+
// balance sample failed shows the pending marker rather than a number we
|
|
92
|
+
// would have to invent.
|
|
93
|
+
const tail = inCredits
|
|
94
|
+
? (typeof e.credits_charged === 'number'
|
|
95
|
+
? formatCredits(e.credits_charged, credits?.unitLabel ?? 'credits')
|
|
96
|
+
: CREDITS_PENDING)
|
|
97
|
+
: formatCost(e.cost);
|
|
98
|
+
console.log(` turn ${String(e.turn).padStart(3)} ${tok.padStart(10)} tok ${tail}`);
|
|
99
|
+
}
|
|
100
|
+
if (inCredits && perTurn.unknown > 0) {
|
|
101
|
+
console.log('');
|
|
102
|
+
console.log(chalk.dim(`${perTurn.unknown} turn${perTurn.unknown === 1 ? '' : 's'} had no balance reading — `
|
|
103
|
+
+ `their usage is included in a later turn's figure.`));
|
|
52
104
|
}
|
|
53
105
|
console.log('');
|
|
54
106
|
console.log(chalk.dim(`Detailed JSONL: ~/.cspeach/sessions/${sessionId}-cost.jsonl`));
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import fs from 'node:fs/promises';
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { buildAuditReport } from '../session/audit-export.js';
|
|
18
|
+
import { globalCredits } from '../cost/credits.js';
|
|
18
19
|
import { readCostLog } from '../cost/cost-log.js';
|
|
19
20
|
/** Parse the text after `/export` into a subcommand + optional filename. */
|
|
20
21
|
export function parseExportArgs(rest) {
|
|
@@ -30,7 +31,9 @@ export async function runExportAuditCommand(opts) {
|
|
|
30
31
|
const { session, transport, emit } = opts;
|
|
31
32
|
const cwd = opts.cwd ?? process.cwd();
|
|
32
33
|
const costEntries = opts.costEntries ?? (await readCostLog(session.id));
|
|
33
|
-
|
|
34
|
+
// The exported .md is a file the customer opens and forwards, so it carries
|
|
35
|
+
// their credits (not our COGS) whenever credits mode is active.
|
|
36
|
+
const report = buildAuditReport({ session, costEntries, transport, credits: globalCredits.snapshot() });
|
|
34
37
|
const name = opts.filename && opts.filename.trim().length > 0
|
|
35
38
|
? opts.filename.trim()
|
|
36
39
|
: defaultAuditFilename(session);
|
|
@@ -30,6 +30,7 @@ import { applyAuditResult, applyPhaseResolution, buildResumeCommand, offerNextPh
|
|
|
30
30
|
import { appendCostLine, buildEntry } from '../cost/cost-log.js';
|
|
31
31
|
import { startThinkingHeartbeat, AUDIT_HEARTBEAT_THRESHOLDS } from '../renderer/thinking-heartbeat.js';
|
|
32
32
|
import { computeCost, formatCost } from '../cost/pricing.js';
|
|
33
|
+
import { creditsActive } from '../cost/credits.js';
|
|
33
34
|
/** Two consecutive failed verdicts on the same phase force status 'blocked'. */
|
|
34
35
|
export const MAX_CONSECUTIVE_AUDIT_FAILURES = 2;
|
|
35
36
|
/** Infra error strings are model/HTTP noise — clamp when rendering (Task 5 review). */
|
|
@@ -659,7 +660,13 @@ function renderAuditCost(args, cost) {
|
|
|
659
660
|
try {
|
|
660
661
|
const tok = cost.tokens.input + cost.tokens.output;
|
|
661
662
|
const usd = computeCost(cost.model, cost.tokens);
|
|
662
|
-
|
|
663
|
+
// Credits mode: the audit turn's USD is our COGS. There is no per-audit
|
|
664
|
+
// credit figure to substitute (the ledger charges the turn, not this
|
|
665
|
+
// sub-step), so the dim line drops the cost fragment and keeps tokens —
|
|
666
|
+
// never a dollar on a managed customer's screen.
|
|
667
|
+
args.log(chalk.dim(creditsActive()
|
|
668
|
+
? `[audit] ${tok.toLocaleString()} tok (${cost.model})`
|
|
669
|
+
: `[audit] ${tok.toLocaleString()} tok · ${formatCost(usd)} (${cost.model})`));
|
|
663
670
|
void appendCostLine(args.sessionId, {
|
|
664
671
|
...buildEntry({ turn: 0, model: cost.model, tokens: cost.tokens, duration_ms: cost.durationMs }),
|
|
665
672
|
label: 'audit',
|