@cspeach/cli 0.9.0 → 1.1.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 +228 -26
- package/dist/agent/providers/license-gate.js +44 -0
- package/dist/agent/skill-checkpoint.js +1 -1
- package/dist/agent/tool-dispatch.js +15 -0
- package/dist/approvals/canonical.js +91 -0
- package/dist/approvals/jwt.js +39 -2
- package/dist/approvals/op-labels.js +124 -0
- package/dist/approvals/render.js +42 -36
- package/dist/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +18 -3
- package/dist/cli.js +15 -0
- package/dist/commands/compact.js +28 -2
- package/dist/commands/config-set.js +284 -0
- package/dist/commands/config-show.js +20 -0
- package/dist/commands/export-audit.js +43 -0
- package/dist/commands/help.js +5 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-audit-evidence.js +266 -0
- package/dist/commands/plan-audit.js +692 -0
- package/dist/commands/plan-chain.js +671 -0
- package/dist/commands/plan-continue.js +179 -0
- package/dist/commands/plan-gate.js +154 -0
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +728 -46
- package/dist/config/loader.js +223 -5
- package/dist/config/model-defaults.js +14 -0
- package/dist/cost/pricing.js +27 -1
- 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/system-roles.js +41 -0
- package/dist/doctor/checks/zcspeach.js +19 -4
- package/dist/doctor/run.js +2 -0
- package/dist/models/resolve.js +61 -0
- package/dist/models/server-config.js +155 -0
- package/dist/one-shot.js +76 -6
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/extract-cca.js +111 -17
- package/dist/projects/extract-modernize.js +4 -2
- package/dist/projects/extract-plan.js +184 -37
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +4 -2
- package/dist/projects/extract-upgrade.js +116 -23
- package/dist/projects/handover-md.js +195 -0
- package/dist/projects/index.js +5 -2
- 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 +285 -27
- package/dist/projects/plan-schema.js +136 -3
- package/dist/projects/promote-command.js +25 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/run-lease.js +157 -0
- package/dist/projects/save-command.js +259 -21
- package/dist/projects/status.js +3 -1
- package/dist/projects/validate.js +1 -1
- 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/status-footer.js +22 -12
- package/dist/renderer/thinking-heartbeat.js +64 -8
- package/dist/renderer/todo-block.js +51 -0
- package/dist/renderer/tool-widget.js +55 -4
- package/dist/renderer/tty.js +43 -4
- package/dist/renderer/verify-chain.js +77 -0
- package/dist/repl/at-picker.js +60 -7
- package/dist/repl/bracketed-paste.js +28 -19
- package/dist/repl/builtin-commands.js +42 -0
- package/dist/repl/current-transport.js +10 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/history.js +86 -0
- package/dist/repl/ink-stdin-guard.js +64 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/mode-ceiling.js +16 -0
- package/dist/repl/mode-cycle.js +104 -0
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +26 -6
- 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-completer.js +5 -0
- package/dist/repl/slash-picker.js +10 -15
- package/dist/repl.js +1232 -95
- package/dist/rewind/candidates.js +194 -0
- package/dist/rewind/cli.js +137 -0
- package/dist/rewind/format.js +27 -0
- package/dist/rewind/restore.js +245 -0
- 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/audit-export.js +459 -0
- package/dist/session/context-report.js +163 -0
- package/dist/session/pending.js +27 -0
- package/dist/session/recap.js +160 -0
- package/dist/skill-catalog.js +51 -40
- package/dist/skills/bundled-skills.js +272 -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 +177 -26
- package/dist/tools/ask-question.js +400 -7
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +1414 -0
- package/dist/tools/extend-model/tool.js +340 -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-extend.js +76 -0
- package/dist/tools/fiori/fe-scaffold.js +71 -0
- package/dist/tools/fiori/floorplan-map.js +19 -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/samples/data/index.json +13602 -0
- package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
- package/dist/tools/fiori/samples/loader.js +248 -0
- package/dist/tools/fiori/samples/search.js +63 -0
- package/dist/tools/fiori/samples/types.js +2 -0
- package/dist/tools/fiori/scaffold.js +39 -0
- package/dist/tools/fiori/smoke/assertions.js +74 -0
- package/dist/tools/fiori/smoke/browser.js +52 -0
- package/dist/tools/fiori/smoke/driver.js +89 -0
- package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
- package/dist/tools/fiori/smoke/run-smoke.js +149 -0
- package/dist/tools/fiori/tools.js +681 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +86 -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 +132 -20
- package/dist/tools/sap-write.js +550 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +63 -14
- package/dist/tools/subagent/agent_run.js +27 -3
- package/dist/tools/subagent/background_run.js +17 -1
- package/dist/tools/todo.js +144 -0
- 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 +378 -21
- package/dist/ui/approval-modal.js +49 -16
- package/dist/ui/ask-question-emitter.js +14 -0
- package/dist/ui/body.js +13 -0
- package/dist/ui/context-grid.js +108 -0
- package/dist/ui/footer.js +120 -27
- package/dist/ui/header.js +7 -0
- package/dist/ui/line-resolution.js +35 -8
- package/dist/ui/rewind-emitter.js +10 -0
- package/dist/ui/rewind-panel.js +81 -0
- package/dist/ui/sap-state-store.js +1 -0
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/status-line.js +43 -0
- package/dist/ui/text-input.js +214 -0
- package/dist/ui/todo-emitter.js +25 -0
- package/dist/ui/todo-panel.js +64 -0
- package/dist/ui/turn-status-emitter.js +50 -4
- package/dist/ui/turn-status.js +18 -3
- package/dist/ui/widgets/ask-form.js +242 -0
- package/dist/ui/widgets/ask-question-modal.js +21 -8
- package/package.json +22 -3
- 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
|
@@ -7,10 +7,16 @@ import { loadConfig, saveConfig, isValidWriteMode } from '../config/loader.js';
|
|
|
7
7
|
const MUTABLE_KEYS = new Set([
|
|
8
8
|
'proxy_url',
|
|
9
9
|
'default_model',
|
|
10
|
+
'audit_model',
|
|
11
|
+
'compact_model',
|
|
10
12
|
'effort',
|
|
11
13
|
'telemetry',
|
|
12
14
|
'classifier.safe_mode',
|
|
15
|
+
'local_build',
|
|
16
|
+
'local_files', // deprecated alias for local_build (2026-06-13) — still accepted
|
|
13
17
|
'write_mode',
|
|
18
|
+
'plan_mode',
|
|
19
|
+
'plan_audit',
|
|
14
20
|
'llm.mode',
|
|
15
21
|
'llm.ai_hub_base_url',
|
|
16
22
|
'llm.ai_hub_model_alias',
|
|
@@ -22,10 +28,34 @@ const MUTABLE_KEYS = new Set([
|
|
|
22
28
|
]);
|
|
23
29
|
// SAP-scoped key pattern: sap.<alias>.auto_approve
|
|
24
30
|
const SAP_AUTO_APPROVE_RE = /^sap\.([A-Z0-9_-]{2,32})\.auto_approve$/;
|
|
31
|
+
// SAP-scoped key pattern: sap.<alias>.role (system role → write-mode ceiling)
|
|
32
|
+
const SAP_ROLE_RE = /^sap\.([A-Z0-9_-]{2,32})\.role$/;
|
|
33
|
+
// SAP-scoped TLS keys: sap.<alias>.ca_cert_path | sap.<alias>.insecure_skip_tls_verify
|
|
34
|
+
const SAP_CA_CERT_PATH_RE = /^sap\.([A-Z0-9_-]{2,32})\.ca_cert_path$/;
|
|
35
|
+
const SAP_INSECURE_TLS_RE = /^sap\.([A-Z0-9_-]{2,32})\.insecure_skip_tls_verify$/;
|
|
36
|
+
/**
|
|
37
|
+
* Coerce a user-typed on/off-style string to a boolean. Accepts the common
|
|
38
|
+
* spellings a developer expects from a CLI toggle (`on`/`true`/`1` → true,
|
|
39
|
+
* `off`/`false`/`0` → false). Returns null for anything else so the caller
|
|
40
|
+
* can reject with a clear error. Case-insensitive.
|
|
41
|
+
*/
|
|
42
|
+
const TRUE_TOKENS = new Set(['on', 'true', '1']);
|
|
43
|
+
const FALSE_TOKENS = new Set(['off', 'false', '0']);
|
|
44
|
+
function coerceBool(value) {
|
|
45
|
+
const v = value.trim().toLowerCase();
|
|
46
|
+
if (TRUE_TOKENS.has(v))
|
|
47
|
+
return true;
|
|
48
|
+
if (FALSE_TOKENS.has(v))
|
|
49
|
+
return false;
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
25
52
|
const EFFORT_VALUES = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
26
53
|
const TELEMETRY_VALUES = ['minimal', 'full'];
|
|
27
54
|
const AUTO_APPROVE_VALUES = ['never', 'low', 'medium'];
|
|
55
|
+
const SYSTEM_ROLE_VALUES = ['dev', 'qas', 'prd'];
|
|
28
56
|
const LLM_MODE_VALUES = ['managed', 'byok', 'ai-hub', 'local'];
|
|
57
|
+
const PLAN_MODE_VALUES = ['step', 'guarded'];
|
|
58
|
+
const PLAN_AUDIT_VALUES = ['on', 'off'];
|
|
29
59
|
/**
|
|
30
60
|
* Thrown on validation failure. The outer CLI dispatcher converts to
|
|
31
61
|
* `console.error` + `process.exit(exitCode)`. Throwing instead of calling
|
|
@@ -44,6 +74,119 @@ export class ConfigSetError extends Error {
|
|
|
44
74
|
function err(msg) {
|
|
45
75
|
throw new ConfigSetError(`[config set] ${msg}`);
|
|
46
76
|
}
|
|
77
|
+
function errUnset(msg) {
|
|
78
|
+
throw new ConfigSetError(`[config unset] ${msg}`);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Internal, loader-derived flags that are never serialised (stripped by
|
|
82
|
+
* saveConfig). They must never be targets of `config set`/`config unset`.
|
|
83
|
+
*/
|
|
84
|
+
const INTERNAL_KEYS = new Set(['default_model_set', 'file_keys']);
|
|
85
|
+
/**
|
|
86
|
+
* Virtual keys whose values live in the OS keychain, not config.toml. `config
|
|
87
|
+
* unset` cannot remove them — that is a keychain operation — so they are
|
|
88
|
+
* rejected with a pointer rather than silently no-op'd.
|
|
89
|
+
*/
|
|
90
|
+
const VIRTUAL_KEYCHAIN_KEYS = new Set(['llm.byok_key', 'llm.ai_hub_token']);
|
|
91
|
+
/**
|
|
92
|
+
* MUTABLE_KEYS that `config unset` can act on (excludes keychain virtuals and
|
|
93
|
+
* the deprecated `local_files` alias — the canonical name is `local_build`).
|
|
94
|
+
*/
|
|
95
|
+
function unsettableKeyList() {
|
|
96
|
+
return [...MUTABLE_KEYS]
|
|
97
|
+
.filter((k) => !VIRTUAL_KEYCHAIN_KEYS.has(k) && k !== 'local_files')
|
|
98
|
+
.join(', ');
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Mark a top-level config key as explicitly user-set so sparse serialisation
|
|
102
|
+
* (saveConfig) preserves it even when its value equals the built-in default.
|
|
103
|
+
* `file_keys` tracks TOP-LEVEL keys, so we push the first dotted segment
|
|
104
|
+
* (`default_model` → `default_model`, `classifier.safe_mode` → `classifier`).
|
|
105
|
+
* Dedup-safe. Closes the one edge the value-diff can't see: `config set
|
|
106
|
+
* default_model claude-opus-4-8` (equal to the built-in default) on a file that
|
|
107
|
+
* lacked the key would otherwise be dropped on save, permanently opting the
|
|
108
|
+
* user out of the model-governance server steering (default_model_set semantics).
|
|
109
|
+
*/
|
|
110
|
+
export function markExplicit(cfg, key) {
|
|
111
|
+
const top = key.split('.')[0];
|
|
112
|
+
if (!Array.isArray(cfg.file_keys))
|
|
113
|
+
cfg.file_keys = [];
|
|
114
|
+
if (!cfg.file_keys.includes(top))
|
|
115
|
+
cfg.file_keys.push(top);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Delete a (possibly dotted, max 2-level) key from a loaded config in place.
|
|
119
|
+
* Returns true if the key was present and removed, false if it was already
|
|
120
|
+
* absent (the caller renders a friendly no-op). Also drops the top-level key
|
|
121
|
+
* from `file_keys` — but ONLY when the whole top-level key was removed. If a
|
|
122
|
+
* nested sibling survives (e.g. unsetting `llm.local_model` while `llm.mode`
|
|
123
|
+
* remains), keeping the top-level entry in `file_keys` is what stops sparse
|
|
124
|
+
* save from dropping the now-default-equal parent table.
|
|
125
|
+
*/
|
|
126
|
+
function unsetConfigKey(cfg, key) {
|
|
127
|
+
const parts = key.split('.');
|
|
128
|
+
const top = parts[0];
|
|
129
|
+
const obj = cfg;
|
|
130
|
+
let removedTop = false;
|
|
131
|
+
if (parts.length === 1) {
|
|
132
|
+
if (!Object.prototype.hasOwnProperty.call(obj, top))
|
|
133
|
+
return false;
|
|
134
|
+
delete obj[top];
|
|
135
|
+
removedTop = true;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const parent = obj[top];
|
|
139
|
+
if (!parent || typeof parent !== 'object')
|
|
140
|
+
return false;
|
|
141
|
+
const p = parent;
|
|
142
|
+
const leaf = parts.slice(1).join('.');
|
|
143
|
+
if (!Object.prototype.hasOwnProperty.call(p, leaf))
|
|
144
|
+
return false;
|
|
145
|
+
delete p[leaf];
|
|
146
|
+
if (Object.keys(p).length === 0) {
|
|
147
|
+
delete obj[top];
|
|
148
|
+
removedTop = true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (removedTop && Array.isArray(cfg.file_keys)) {
|
|
152
|
+
cfg.file_keys = cfg.file_keys.filter((k) => k !== top);
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Executes `cspeach config unset <key>`.
|
|
158
|
+
* Removes a MUTABLE top-level key so it reverts to the built-in default (and,
|
|
159
|
+
* for the model keys, re-enables server model-steering). Rejects internal keys,
|
|
160
|
+
* keychain virtuals, and non-MUTABLE keys. Unsetting an absent key is a friendly
|
|
161
|
+
* no-op. Throws ConfigSetError on validation failure (the CLI dispatcher in
|
|
162
|
+
* cli.ts converts it to console.error + exit).
|
|
163
|
+
*/
|
|
164
|
+
export async function runConfigUnset(args) {
|
|
165
|
+
const key = args[0];
|
|
166
|
+
if (!key) {
|
|
167
|
+
errUnset('Usage: cspeach config unset <key>\n Valid keys: ' + unsettableKeyList());
|
|
168
|
+
}
|
|
169
|
+
if (INTERNAL_KEYS.has(key)) {
|
|
170
|
+
errUnset(`"${key}" is an internal key (derived on load, never stored) and cannot be unset.`);
|
|
171
|
+
}
|
|
172
|
+
if (VIRTUAL_KEYCHAIN_KEYS.has(key)) {
|
|
173
|
+
errUnset(`"${key}" is stored in the OS keychain, not config.toml — it cannot be removed with config unset.`);
|
|
174
|
+
}
|
|
175
|
+
if (key.startsWith('sap.')) {
|
|
176
|
+
errUnset(`"${key}" is a SAP-scoped key — those are managed per system via \`cspeach config set ${key} <value>\`, not config unset.`);
|
|
177
|
+
}
|
|
178
|
+
if (!MUTABLE_KEYS.has(key)) {
|
|
179
|
+
errUnset(`Unknown key "${key}". Valid keys: ${unsettableKeyList()}`);
|
|
180
|
+
}
|
|
181
|
+
const cfg = await loadConfig();
|
|
182
|
+
const removed = unsetConfigKey(cfg, key);
|
|
183
|
+
if (!removed) {
|
|
184
|
+
console.log(chalk.dim(`${key} was not set — nothing to unset.`));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
await saveConfig(cfg);
|
|
188
|
+
console.log(chalk.green(`Unset ${key} — it now falls back to the built-in default (and any server steering).`));
|
|
189
|
+
}
|
|
47
190
|
/**
|
|
48
191
|
* Executes `cspeach config set <key> <value>`.
|
|
49
192
|
* Exits process with code 1 on validation error.
|
|
@@ -73,6 +216,62 @@ export async function runConfigSet(args) {
|
|
|
73
216
|
console.log(chalk.green(`Set ${key} = ${value}`));
|
|
74
217
|
return;
|
|
75
218
|
}
|
|
219
|
+
// SAP-scoped key: sap.<alias>.role — system role, feeds the write-mode ceiling
|
|
220
|
+
// (prd → advisory-only, qas → approval-gated, dev → no ceiling).
|
|
221
|
+
const roleMatch = SAP_ROLE_RE.exec(key);
|
|
222
|
+
if (roleMatch) {
|
|
223
|
+
const alias = roleMatch[1];
|
|
224
|
+
if (!SYSTEM_ROLE_VALUES.includes(value)) {
|
|
225
|
+
err(`Invalid value for ${key}. Allowed: ${SYSTEM_ROLE_VALUES.join(', ')}`);
|
|
226
|
+
}
|
|
227
|
+
const cfg = await loadConfig();
|
|
228
|
+
if (!cfg.sap[alias]) {
|
|
229
|
+
err(`SAP system "${alias}" not found in config. Add it first with: cspeach config add-sap`);
|
|
230
|
+
}
|
|
231
|
+
cfg.sap[alias].role = value;
|
|
232
|
+
await saveConfig(cfg);
|
|
233
|
+
console.log(chalk.green(`Set ${key} = ${value}`));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
// SAP-scoped TLS: sap.<alias>.ca_cert_path — trust a CA, verification STAYS ON.
|
|
237
|
+
const caMatch = SAP_CA_CERT_PATH_RE.exec(key);
|
|
238
|
+
if (caMatch) {
|
|
239
|
+
const alias = caMatch[1];
|
|
240
|
+
const cfg = await loadConfig();
|
|
241
|
+
if (!cfg.sap[alias]) {
|
|
242
|
+
err(`SAP system "${alias}" not found in config. Add it first with: cspeach config add-sap`);
|
|
243
|
+
}
|
|
244
|
+
// Path is stored as-is (no existence check here — the connection fails loudly
|
|
245
|
+
// and fail-closed if the CA can't be read at connect time). Setting a CA also
|
|
246
|
+
// clears any insecure flag so the two never conflict.
|
|
247
|
+
cfg.sap[alias].ca_cert_path = value;
|
|
248
|
+
delete cfg.sap[alias].insecure_skip_tls_verify;
|
|
249
|
+
await saveConfig(cfg);
|
|
250
|
+
console.log(chalk.green(`Set ${key} = ${value}`));
|
|
251
|
+
console.log(chalk.dim(' → TLS verification stays ON; this CA is added to the trust store.'));
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
// SAP-scoped TLS: sap.<alias>.insecure_skip_tls_verify — dev-only opt-out.
|
|
255
|
+
const insecMatch = SAP_INSECURE_TLS_RE.exec(key);
|
|
256
|
+
if (insecMatch) {
|
|
257
|
+
const alias = insecMatch[1];
|
|
258
|
+
const b = coerceBool(value);
|
|
259
|
+
if (b === null) {
|
|
260
|
+
err(`${key} must be on/off (also accepts true/false, 1/0).`);
|
|
261
|
+
}
|
|
262
|
+
const cfg = await loadConfig();
|
|
263
|
+
if (!cfg.sap[alias]) {
|
|
264
|
+
err(`SAP system "${alias}" not found in config. Add it first with: cspeach config add-sap`);
|
|
265
|
+
}
|
|
266
|
+
cfg.sap[alias].insecure_skip_tls_verify = b;
|
|
267
|
+
if (b) {
|
|
268
|
+
console.log(chalk.yellow(` ⚠ TLS verification will be DISABLED for ${alias} — dev only, NEVER use in production. ` +
|
|
269
|
+
`Prefer: cspeach config set sap.${alias}.ca_cert_path <path-to-corporate-CA>`));
|
|
270
|
+
}
|
|
271
|
+
await saveConfig(cfg);
|
|
272
|
+
console.log(chalk.green(`Set ${key} = ${b}`));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
76
275
|
// Virtual keys that store secrets in the OS keychain instead of config.toml.
|
|
77
276
|
if (key === 'llm.byok_key' || key === 'llm.ai_hub_token') {
|
|
78
277
|
// Support stdin piping for secrets: `echo "key" | cspeach config set llm.byok_key -`
|
|
@@ -124,6 +323,24 @@ export async function runConfigSet(args) {
|
|
|
124
323
|
}
|
|
125
324
|
cfg.default_model = value;
|
|
126
325
|
break;
|
|
326
|
+
case 'audit_model':
|
|
327
|
+
// model-governance Step 1 — the per-phase auditor's model. Same claude-
|
|
328
|
+
// prefix guard as default_model; env CSPEACH_AUDIT_MODEL still overrides
|
|
329
|
+
// this at runtime (see resolveAuditModel).
|
|
330
|
+
if (!value.startsWith('claude-')) {
|
|
331
|
+
err(`model must start with "claude-", got "${value}".`);
|
|
332
|
+
}
|
|
333
|
+
cfg.audit_model = value;
|
|
334
|
+
break;
|
|
335
|
+
case 'compact_model':
|
|
336
|
+
// model-governance step 2d — the model `/compact` summarisation runs on.
|
|
337
|
+
// Same claude- prefix guard as default_model/audit_model; env
|
|
338
|
+
// CSPEACH_COMPACT_MODEL still overrides this at runtime (resolveModelRole).
|
|
339
|
+
if (!value.startsWith('claude-')) {
|
|
340
|
+
err(`model must start with "claude-", got "${value}".`);
|
|
341
|
+
}
|
|
342
|
+
cfg.compact_model = value;
|
|
343
|
+
break;
|
|
127
344
|
case 'effort':
|
|
128
345
|
if (!EFFORT_VALUES.includes(value)) {
|
|
129
346
|
err(`Invalid effort value "${value}". Allowed: ${EFFORT_VALUES.join(', ')}`);
|
|
@@ -142,12 +359,55 @@ export async function runConfigSet(args) {
|
|
|
142
359
|
}
|
|
143
360
|
cfg.classifier.safe_mode = value === 'true';
|
|
144
361
|
break;
|
|
362
|
+
case 'local_files':
|
|
363
|
+
// Deprecated alias (2026-06-13). Still applied — but it WRITES to the new
|
|
364
|
+
// `local_build` key so the user's config converges on the new name. The
|
|
365
|
+
// shared `local_build` case below also DELETES any stale legacy
|
|
366
|
+
// `local_files` key so it doesn't linger after migration. One-line
|
|
367
|
+
// deprecation note, then fall through.
|
|
368
|
+
console.log(chalk.yellow(' → "local_files" is deprecated; use "local_build". Applying to local_build.'));
|
|
369
|
+
// fallthrough
|
|
370
|
+
case 'local_build': {
|
|
371
|
+
const b = coerceBool(value);
|
|
372
|
+
if (b === null) {
|
|
373
|
+
err(`${key} must be on/off (also accepts true/false, 1/0).`);
|
|
374
|
+
}
|
|
375
|
+
cfg.local_build = b;
|
|
376
|
+
// Migration hygiene (2026-06-13): whether the user typed `local_build` or
|
|
377
|
+
// the deprecated `local_files` alias, we write the new key AND drop any
|
|
378
|
+
// stale legacy `local_files` so it doesn't linger in `config show` after
|
|
379
|
+
// migration. resolveLocalBuild prefers local_build anyway, but a leftover
|
|
380
|
+
// legacy key is confusing and could surprise a future reader.
|
|
381
|
+
delete cfg.local_files;
|
|
382
|
+
// Config is read at startup; a running session isn't retroactively
|
|
383
|
+
// changed (the tool override is applied once, before the first turn).
|
|
384
|
+
console.log(chalk.dim(' → takes effect on the next `cspeach` launch.'));
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
145
387
|
case 'write_mode':
|
|
146
388
|
if (!isValidWriteMode(value)) {
|
|
147
389
|
err(`Invalid value for write_mode. Must be one of: auto, approval-gated, advisory-only`);
|
|
148
390
|
}
|
|
149
391
|
cfg.write_mode = value;
|
|
150
392
|
break;
|
|
393
|
+
case 'plan_mode':
|
|
394
|
+
// 'step' = prompt before every phase (default when unset);
|
|
395
|
+
// 'guarded' = auto-chain non-write phases, stop on write phases /
|
|
396
|
+
// unresolved audits. Per-run --guarded/--step flags override this.
|
|
397
|
+
if (!PLAN_MODE_VALUES.includes(value)) {
|
|
398
|
+
err(`Invalid plan_mode "${value}". Allowed: ${PLAN_MODE_VALUES.join(', ')}`);
|
|
399
|
+
}
|
|
400
|
+
cfg.plan_mode = value;
|
|
401
|
+
break;
|
|
402
|
+
case 'plan_audit':
|
|
403
|
+
// 'on' = audit every phase (default when unset); 'off' = skip the
|
|
404
|
+
// independent auditor entirely (owner off-switch, 2026-07-06). Per-run
|
|
405
|
+
// --audit/--no-audit flags override this default.
|
|
406
|
+
if (!PLAN_AUDIT_VALUES.includes(value)) {
|
|
407
|
+
err(`Invalid plan_audit "${value}". Allowed: ${PLAN_AUDIT_VALUES.join(', ')}`);
|
|
408
|
+
}
|
|
409
|
+
cfg.plan_audit = value;
|
|
410
|
+
break;
|
|
151
411
|
case 'llm.mode':
|
|
152
412
|
if (!LLM_MODE_VALUES.includes(value)) {
|
|
153
413
|
err(`Invalid llm.mode "${value}". Allowed: ${LLM_MODE_VALUES.join(', ')}`);
|
|
@@ -185,6 +445,10 @@ export async function runConfigSet(args) {
|
|
|
185
445
|
default:
|
|
186
446
|
err(`Unhandled key "${key}".`);
|
|
187
447
|
}
|
|
448
|
+
// Mark this top-level key as explicitly user-set so sparse serialisation
|
|
449
|
+
// keeps it even when the value equals the built-in default (the edge the
|
|
450
|
+
// value-diff can't see — e.g. `config set default_model claude-opus-4-8`).
|
|
451
|
+
markExplicit(cfg, key);
|
|
188
452
|
await saveConfig(cfg);
|
|
189
453
|
console.log(chalk.green(`Set ${key} = ${value}`));
|
|
190
454
|
}
|
|
@@ -201,6 +465,17 @@ export function validateValue(key, value) {
|
|
|
201
465
|
if (SAP_AUTO_APPROVE_RE.test(key)) {
|
|
202
466
|
return AUTO_APPROVE_VALUES.includes(value);
|
|
203
467
|
}
|
|
468
|
+
// SAP-scoped key: sap.<alias>.role
|
|
469
|
+
if (SAP_ROLE_RE.test(key)) {
|
|
470
|
+
return SYSTEM_ROLE_VALUES.includes(value);
|
|
471
|
+
}
|
|
472
|
+
// SAP-scoped TLS keys.
|
|
473
|
+
if (SAP_CA_CERT_PATH_RE.test(key)) {
|
|
474
|
+
return value.length > 0; // any non-empty path; existence checked at connect
|
|
475
|
+
}
|
|
476
|
+
if (SAP_INSECURE_TLS_RE.test(key)) {
|
|
477
|
+
return coerceBool(value) !== null;
|
|
478
|
+
}
|
|
204
479
|
if (!MUTABLE_KEYS.has(key))
|
|
205
480
|
return false;
|
|
206
481
|
switch (key) {
|
|
@@ -213,6 +488,8 @@ export function validateValue(key, value) {
|
|
|
213
488
|
return false;
|
|
214
489
|
}
|
|
215
490
|
case 'default_model':
|
|
491
|
+
case 'audit_model':
|
|
492
|
+
case 'compact_model':
|
|
216
493
|
return value.startsWith('claude-');
|
|
217
494
|
case 'effort':
|
|
218
495
|
return EFFORT_VALUES.includes(value);
|
|
@@ -220,8 +497,15 @@ export function validateValue(key, value) {
|
|
|
220
497
|
return TELEMETRY_VALUES.includes(value);
|
|
221
498
|
case 'classifier.safe_mode':
|
|
222
499
|
return value === 'true' || value === 'false';
|
|
500
|
+
case 'local_build':
|
|
501
|
+
case 'local_files': // deprecated alias — same on/off coercion
|
|
502
|
+
return coerceBool(value) !== null;
|
|
223
503
|
case 'write_mode':
|
|
224
504
|
return isValidWriteMode(value);
|
|
505
|
+
case 'plan_mode':
|
|
506
|
+
return PLAN_MODE_VALUES.includes(value);
|
|
507
|
+
case 'plan_audit':
|
|
508
|
+
return PLAN_AUDIT_VALUES.includes(value);
|
|
225
509
|
case 'llm.mode':
|
|
226
510
|
return LLM_MODE_VALUES.includes(value);
|
|
227
511
|
case 'llm.ai_hub_base_url':
|
|
@@ -14,6 +14,16 @@ const REDACTED_KEYS = new Set([
|
|
|
14
14
|
'llm.byok_key', // Anthropic API key — keychain "cspeach.anthropic"
|
|
15
15
|
'llm.ai_hub_token', // SAP AI Hub bearer — keychain "cspeach.ai-hub"
|
|
16
16
|
]);
|
|
17
|
+
/**
|
|
18
|
+
* INTERNAL, loader-derived flags that are attached to the config object at load
|
|
19
|
+
* time but never serialised (stripped by saveConfig). They must not render in
|
|
20
|
+
* `config show` — `default_model_set` was a pre-existing leak, `file_keys` was
|
|
21
|
+
* added by sparse-config task 1. Both are top-level only.
|
|
22
|
+
*/
|
|
23
|
+
const INTERNAL_KEYS = new Set([
|
|
24
|
+
'default_model_set',
|
|
25
|
+
'file_keys',
|
|
26
|
+
]);
|
|
17
27
|
function formatTomlValue(value) {
|
|
18
28
|
if (typeof value === 'string')
|
|
19
29
|
return `"${value}"`;
|
|
@@ -61,6 +71,9 @@ function printConfigAsToml(cfg, prefix = '') {
|
|
|
61
71
|
const tables = entries.filter(([, v]) => typeof v === 'object' && v !== null && !Array.isArray(v));
|
|
62
72
|
for (const [k, v] of scalars) {
|
|
63
73
|
const fullKey = prefix ? `${prefix}.${k}` : k;
|
|
74
|
+
// Skip internal, never-serialised keys so they never leak into output.
|
|
75
|
+
if (INTERNAL_KEYS.has(fullKey))
|
|
76
|
+
continue;
|
|
64
77
|
if (REDACTED_KEYS.has(fullKey)) {
|
|
65
78
|
console.log(`${k} = [REDACTED]`);
|
|
66
79
|
}
|
|
@@ -126,6 +139,13 @@ export async function runConfigShow(args) {
|
|
|
126
139
|
return;
|
|
127
140
|
}
|
|
128
141
|
const keyPath = args[0];
|
|
142
|
+
// Reject internal, loader-derived keys in the single-key path too — the full
|
|
143
|
+
// dump already filters INTERNAL_KEYS, but resolveDottedKey would happily
|
|
144
|
+
// print e.g. the raw `file_keys` array. Mirror `config unset`'s wording.
|
|
145
|
+
if (INTERNAL_KEYS.has(keyPath)) {
|
|
146
|
+
console.error(chalk.red(`[config show] "${keyPath}" is an internal key (derived on load, never stored) and cannot be shown.`));
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
129
149
|
const resolved = resolveDottedKey(cfg, keyPath);
|
|
130
150
|
if (resolved === undefined) {
|
|
131
151
|
console.error(chalk.red(`[config show] Key "${keyPath}" not found.`));
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// cspeach-cli/src/commands/export-audit.ts
|
|
2
|
+
//
|
|
3
|
+
// `/export audit [filename]` — write the signable session audit record to a
|
|
4
|
+
// markdown file in the cwd and print the path.
|
|
5
|
+
//
|
|
6
|
+
// Thin I/O wrapper over the pure buildAuditReport builder (session/audit-export
|
|
7
|
+
// .ts): reads the session's cost JSONL, resolves the target filename (explicit
|
|
8
|
+
// arg or a deterministic default), writes the file, and emits a confirmation
|
|
9
|
+
// line through the caller-supplied `emit` (chunkEmitter in Ink mode, console
|
|
10
|
+
// .log in classic mode — same seam as printHelp / runCostCommand).
|
|
11
|
+
//
|
|
12
|
+
// The default filename's date comes from SESSION DATA (last_turn_at), never
|
|
13
|
+
// now() — a re-export of an old session names the file for when the work
|
|
14
|
+
// happened, matching the deterministic report body.
|
|
15
|
+
import fs from 'node:fs/promises';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { buildAuditReport } from '../session/audit-export.js';
|
|
18
|
+
import { readCostLog } from '../cost/cost-log.js';
|
|
19
|
+
/** Parse the text after `/export` into a subcommand + optional filename. */
|
|
20
|
+
export function parseExportArgs(rest) {
|
|
21
|
+
const parts = rest.trim().split(/\s+/).filter(Boolean);
|
|
22
|
+
return { sub: (parts[0] ?? '').toLowerCase(), filename: parts[1] };
|
|
23
|
+
}
|
|
24
|
+
/** Deterministic default filename: date from session data, not now(). */
|
|
25
|
+
export function defaultAuditFilename(session) {
|
|
26
|
+
const date = (session.last_turn_at || session.started_at || '').slice(0, 10) || 'undated';
|
|
27
|
+
return `cspeach-audit-${session.id}-${date}.md`;
|
|
28
|
+
}
|
|
29
|
+
export async function runExportAuditCommand(opts) {
|
|
30
|
+
const { session, transport, emit } = opts;
|
|
31
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
32
|
+
const costEntries = opts.costEntries ?? (await readCostLog(session.id));
|
|
33
|
+
const report = buildAuditReport({ session, costEntries, transport });
|
|
34
|
+
const name = opts.filename && opts.filename.trim().length > 0
|
|
35
|
+
? opts.filename.trim()
|
|
36
|
+
: defaultAuditFilename(session);
|
|
37
|
+
const outPath = path.resolve(cwd, name);
|
|
38
|
+
await fs.writeFile(outPath, report, 'utf-8');
|
|
39
|
+
emit('');
|
|
40
|
+
emit(`Audit written to ${outPath}`);
|
|
41
|
+
emit('');
|
|
42
|
+
return { path: outPath };
|
|
43
|
+
}
|
package/dist/commands/help.js
CHANGED
|
@@ -93,7 +93,12 @@ export function printHelp(emit = (s) => console.log(s)) {
|
|
|
93
93
|
emit(` ${chalk.bold('/ui [auto|ink|classic]'.padEnd(NAME_COL_PAD))} View or change UI rendering mode`);
|
|
94
94
|
emit(` ${chalk.bold('/reroute <skill>'.padEnd(NAME_COL_PAD))} Re-run your last prompt with a different skill`);
|
|
95
95
|
emit(` ${chalk.bold('/new'.padEnd(NAME_COL_PAD))} End the current Q&A chain — next prompt is classified fresh`);
|
|
96
|
+
emit(` ${chalk.bold('/mode [advisory|gated|auto]'.padEnd(NAME_COL_PAD))} Cycle or set the session write mode (Shift+Tab / Alt+M also cycle)`);
|
|
97
|
+
emit(` ${chalk.bold('/recap'.padEnd(NAME_COL_PAD))} Show the session recap (last session · open tasks · plan progress)`);
|
|
98
|
+
emit(` ${chalk.bold('/rewind'.padEnd(NAME_COL_PAD))} Undo a write from this session — restore an object to a pre-write snapshot (Esc Esc)`);
|
|
96
99
|
emit(` ${chalk.bold('/cost'.padEnd(NAME_COL_PAD))} Show this session's API spend so far ($ + token breakdown)`);
|
|
100
|
+
emit(` ${chalk.bold('/context'.padEnd(NAME_COL_PAD))} Show what fills the model context window — a composition grid of bars`);
|
|
101
|
+
emit(` ${chalk.bold('/export audit [file]'.padEnd(NAME_COL_PAD))} Write the signable session audit record to a markdown file`);
|
|
97
102
|
emit(` ${chalk.bold('/compact'.padEnd(NAME_COL_PAD))} Summarise older turns into a compact context block — cuts subsequent turn cost by 80-90%`);
|
|
98
103
|
emit(` ${chalk.bold('/exit'.padEnd(NAME_COL_PAD))} Quit CSPeach`);
|
|
99
104
|
emit('');
|
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
|
}
|