@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.
Files changed (195) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +228 -26
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/tool-dispatch.js +15 -0
  7. package/dist/approvals/canonical.js +91 -0
  8. package/dist/approvals/jwt.js +39 -2
  9. package/dist/approvals/op-labels.js +124 -0
  10. package/dist/approvals/render.js +42 -36
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +18 -3
  13. package/dist/cli.js +15 -0
  14. package/dist/commands/compact.js +28 -2
  15. package/dist/commands/config-set.js +284 -0
  16. package/dist/commands/config-show.js +20 -0
  17. package/dist/commands/export-audit.js +43 -0
  18. package/dist/commands/help.js +5 -0
  19. package/dist/commands/login.js +31 -14
  20. package/dist/commands/plan-audit-evidence.js +266 -0
  21. package/dist/commands/plan-audit.js +692 -0
  22. package/dist/commands/plan-chain.js +671 -0
  23. package/dist/commands/plan-continue.js +179 -0
  24. package/dist/commands/plan-gate.js +154 -0
  25. package/dist/commands/plan-model-tier.js +83 -0
  26. package/dist/commands/plan-resume.js +728 -46
  27. package/dist/config/loader.js +223 -5
  28. package/dist/config/model-defaults.js +14 -0
  29. package/dist/cost/pricing.js +27 -1
  30. package/dist/doctor/checks/_http-probe.js +1 -0
  31. package/dist/doctor/checks/cert.js +14 -3
  32. package/dist/doctor/checks/sap.js +30 -8
  33. package/dist/doctor/checks/system-roles.js +41 -0
  34. package/dist/doctor/checks/zcspeach.js +19 -4
  35. package/dist/doctor/run.js +2 -0
  36. package/dist/models/resolve.js +61 -0
  37. package/dist/models/server-config.js +155 -0
  38. package/dist/one-shot.js +76 -6
  39. package/dist/projects/answer-blockers.js +137 -0
  40. package/dist/projects/extract-cca.js +111 -17
  41. package/dist/projects/extract-modernize.js +4 -2
  42. package/dist/projects/extract-plan.js +184 -37
  43. package/dist/projects/extract-spec-gap.js +34 -7
  44. package/dist/projects/extract-test-coverage.js +4 -2
  45. package/dist/projects/extract-upgrade.js +116 -23
  46. package/dist/projects/handover-md.js +195 -0
  47. package/dist/projects/index.js +5 -2
  48. package/dist/projects/merge-cca.js +292 -0
  49. package/dist/projects/merge-upgrade.js +173 -0
  50. package/dist/projects/migration.js +103 -1
  51. package/dist/projects/output-paths.js +27 -0
  52. package/dist/projects/plan-run.js +285 -27
  53. package/dist/projects/plan-schema.js +136 -3
  54. package/dist/projects/promote-command.js +25 -2
  55. package/dist/projects/promote.js +128 -0
  56. package/dist/projects/run-lease.js +157 -0
  57. package/dist/projects/save-command.js +259 -21
  58. package/dist/projects/status.js +3 -1
  59. package/dist/projects/validate.js +1 -1
  60. package/dist/projects/workspace.js +164 -20
  61. package/dist/renderer/notices.js +64 -0
  62. package/dist/renderer/progress-chatter.js +8 -0
  63. package/dist/renderer/status-footer.js +22 -12
  64. package/dist/renderer/thinking-heartbeat.js +64 -8
  65. package/dist/renderer/todo-block.js +51 -0
  66. package/dist/renderer/tool-widget.js +55 -4
  67. package/dist/renderer/tty.js +43 -4
  68. package/dist/renderer/verify-chain.js +77 -0
  69. package/dist/repl/at-picker.js +60 -7
  70. package/dist/repl/bracketed-paste.js +28 -19
  71. package/dist/repl/builtin-commands.js +42 -0
  72. package/dist/repl/current-transport.js +10 -0
  73. package/dist/repl/early-line-buffer.js +68 -0
  74. package/dist/repl/history.js +86 -0
  75. package/dist/repl/ink-stdin-guard.js +64 -0
  76. package/dist/repl/inquirer-guard.js +70 -5
  77. package/dist/repl/mode-ceiling.js +16 -0
  78. package/dist/repl/mode-cycle.js +104 -0
  79. package/dist/repl/numbered-menu.js +131 -0
  80. package/dist/repl/post-turn-status.js +26 -6
  81. package/dist/repl/rule8-detector.js +17 -2
  82. package/dist/repl/safety-confirm.js +111 -2
  83. package/dist/repl/safety-mode-state.js +19 -3
  84. package/dist/repl/slash-completer.js +5 -0
  85. package/dist/repl/slash-picker.js +10 -15
  86. package/dist/repl.js +1232 -95
  87. package/dist/rewind/candidates.js +194 -0
  88. package/dist/rewind/cli.js +137 -0
  89. package/dist/rewind/format.js +27 -0
  90. package/dist/rewind/restore.js +245 -0
  91. package/dist/router/classifier.js +150 -6
  92. package/dist/sap/capability-matrix.js +20 -0
  93. package/dist/sap/capability-matrix.json +11236 -0
  94. package/dist/sap/capability.js +146 -0
  95. package/dist/sap/connection-manager.js +19 -1
  96. package/dist/sap/onboarding.js +42 -4
  97. package/dist/session/audit-export.js +459 -0
  98. package/dist/session/context-report.js +163 -0
  99. package/dist/session/pending.js +27 -0
  100. package/dist/session/recap.js +160 -0
  101. package/dist/skill-catalog.js +51 -40
  102. package/dist/skills/bundled-skills.js +272 -1
  103. package/dist/skills/promotion-dispatch.js +23 -0
  104. package/dist/tools/_command-shared.js +36 -12
  105. package/dist/tools/_filesystem-shared.js +139 -4
  106. package/dist/tools/_flag.js +25 -0
  107. package/dist/tools/approval.js +177 -26
  108. package/dist/tools/ask-question.js +400 -7
  109. package/dist/tools/capability/tool.js +74 -0
  110. package/dist/tools/dispatch-skill.js +22 -1
  111. package/dist/tools/extend-model/anchored-insert.js +1414 -0
  112. package/dist/tools/extend-model/tool.js +340 -0
  113. package/dist/tools/filesystem/extract-document.js +57 -0
  114. package/dist/tools/filesystem/file-edit.js +12 -2
  115. package/dist/tools/filesystem/file-read.js +2 -2
  116. package/dist/tools/filesystem/file-write.js +11 -2
  117. package/dist/tools/filesystem/glob.js +11 -0
  118. package/dist/tools/filesystem/grep.js +10 -0
  119. package/dist/tools/filesystem/read-document.js +107 -0
  120. package/dist/tools/fiori/apply.js +50 -0
  121. package/dist/tools/fiori/bin.js +3 -0
  122. package/dist/tools/fiori/catalog/index.js +27 -0
  123. package/dist/tools/fiori/catalog/value-help.js +230 -0
  124. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  125. package/dist/tools/fiori/cli.js +71 -0
  126. package/dist/tools/fiori/deploy-config.js +73 -0
  127. package/dist/tools/fiori/fe-extend.js +76 -0
  128. package/dist/tools/fiori/fe-scaffold.js +71 -0
  129. package/dist/tools/fiori/floorplan-map.js +19 -0
  130. package/dist/tools/fiori/i18n.js +39 -0
  131. package/dist/tools/fiori/manifest.js +70 -0
  132. package/dist/tools/fiori/render.js +77 -0
  133. package/dist/tools/fiori/samples/data/index.json +13602 -0
  134. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  135. package/dist/tools/fiori/samples/loader.js +248 -0
  136. package/dist/tools/fiori/samples/search.js +63 -0
  137. package/dist/tools/fiori/samples/types.js +2 -0
  138. package/dist/tools/fiori/scaffold.js +39 -0
  139. package/dist/tools/fiori/smoke/assertions.js +74 -0
  140. package/dist/tools/fiori/smoke/browser.js +52 -0
  141. package/dist/tools/fiori/smoke/driver.js +89 -0
  142. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  143. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  144. package/dist/tools/fiori/tools.js +681 -0
  145. package/dist/tools/fiori/types.js +1 -0
  146. package/dist/tools/local-build.js +86 -0
  147. package/dist/tools/local-files.js +31 -0
  148. package/dist/tools/project/_merge-shared.js +68 -0
  149. package/dist/tools/project/cca_merge.js +164 -0
  150. package/dist/tools/project/playbook_get.js +1 -1
  151. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  152. package/dist/tools/sap-read.js +132 -20
  153. package/dist/tools/sap-write.js +550 -21
  154. package/dist/tools/shell/shell_exec.js +41 -6
  155. package/dist/tools/snapshot.js +63 -14
  156. package/dist/tools/subagent/agent_run.js +27 -3
  157. package/dist/tools/subagent/background_run.js +17 -1
  158. package/dist/tools/todo.js +144 -0
  159. package/dist/tools/transport-resolution.js +86 -0
  160. package/dist/tools/transport.js +224 -5
  161. package/dist/tools/write-mode.js +4 -0
  162. package/dist/ui/app.js +378 -21
  163. package/dist/ui/approval-modal.js +49 -16
  164. package/dist/ui/ask-question-emitter.js +14 -0
  165. package/dist/ui/body.js +13 -0
  166. package/dist/ui/context-grid.js +108 -0
  167. package/dist/ui/footer.js +120 -27
  168. package/dist/ui/header.js +7 -0
  169. package/dist/ui/line-resolution.js +35 -8
  170. package/dist/ui/rewind-emitter.js +10 -0
  171. package/dist/ui/rewind-panel.js +81 -0
  172. package/dist/ui/sap-state-store.js +1 -0
  173. package/dist/ui/session-timeline.js +1 -0
  174. package/dist/ui/status-line.js +43 -0
  175. package/dist/ui/text-input.js +214 -0
  176. package/dist/ui/todo-emitter.js +25 -0
  177. package/dist/ui/todo-panel.js +64 -0
  178. package/dist/ui/turn-status-emitter.js +50 -4
  179. package/dist/ui/turn-status.js +18 -3
  180. package/dist/ui/widgets/ask-form.js +242 -0
  181. package/dist/ui/widgets/ask-question-modal.js +21 -8
  182. package/package.json +22 -3
  183. package/bench/README.md +0 -78
  184. package/bench/prompts/abap-document-cds.md +0 -44
  185. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  186. package/bench/prompts/abap-test-method.md +0 -42
  187. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  188. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  189. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  190. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  191. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  192. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  193. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  194. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  195. 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
+ }
@@ -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('');
@@ -13,7 +13,11 @@
13
13
  * Dependencies (open, print, prompt) are injected so the command is
14
14
  * unit-testable without stubbing platform globals.
15
15
  */
16
+ import keytar from 'keytar';
16
17
  import { writeAuthFile } from '../auth/auth-file.js';
18
+ import { fetchOrgAnthropicKey } from '../auth/org-anthropic-key.js';
19
+ const ANTHROPIC_KEYCHAIN_SERVICE = 'cspeach.anthropic';
20
+ const ANTHROPIC_KEYCHAIN_ACCOUNT = 'api-key';
17
21
  export async function runLoginCommand(deps) {
18
22
  const proxy = deps.proxyUrl.replace(/\/$/, '');
19
23
  const pollInterval = deps.pollIntervalMs ?? 5000;
@@ -92,22 +96,35 @@ export async function runLoginCommand(deps) {
92
96
  const me = await r.json();
93
97
  deps.print(`✓ Signed in as ${me.email} (${me.plan})`);
94
98
  if (me.trial_mode === 'byok' || me.plan === 'byo-key') {
95
- deps.print('');
96
- deps.print('This account is BYOK — you provide your own Anthropic API key.');
97
- deps.print('Get one at https://console.anthropic.com/settings/keys');
98
- const ak = (await deps.prompt('Paste your Anthropic API key (sk-ant-...): ')).trim();
99
- if (ak.startsWith('sk-ant-') && ak.length > 20) {
100
- // The non-BYOK save above just persisted these exact values,
101
- // so reading them back from disk would be redundant.
102
- await writeAuthFile({
103
- apiKey: polled.api_key,
104
- customerId: polled.customer_id,
105
- byokAnthropicKey: ak,
106
- });
107
- deps.print('✓ Anthropic key saved');
99
+ // Try to fetch the org's Anthropic key first — non-fatal if unavailable.
100
+ const orgKey = await fetchOrgAnthropicKey(proxy, polled.api_key, {
101
+ fetchImpl: deps.fetchImpl,
102
+ });
103
+ const setKey = deps.setKeychainKey ??
104
+ ((svc, acct, k) => keytar.setPassword(svc, acct, k));
105
+ if (orgKey !== null) {
106
+ await setKey(ANTHROPIC_KEYCHAIN_SERVICE, ANTHROPIC_KEYCHAIN_ACCOUNT, orgKey);
107
+ deps.print('Using your organization\'s Anthropic key.');
108
108
  }
109
109
  else {
110
- deps.print('error: Anthropic key looks malformed (must start with sk-ant-). Skipped — run `cspeach login` again later.');
110
+ // Fall back to the manual paste prompt — behaviour unchanged.
111
+ deps.print('');
112
+ deps.print('This account is BYOK — you provide your own Anthropic API key.');
113
+ deps.print('Get one at https://console.anthropic.com/settings/keys');
114
+ const ak = (await deps.prompt('Paste your Anthropic API key (sk-ant-...): ')).trim();
115
+ if (ak.startsWith('sk-ant-') && ak.length > 20) {
116
+ // The non-BYOK save above just persisted these exact values,
117
+ // so reading them back from disk would be redundant.
118
+ await writeAuthFile({
119
+ apiKey: polled.api_key,
120
+ customerId: polled.customer_id,
121
+ byokAnthropicKey: ak,
122
+ });
123
+ deps.print('✓ Anthropic key saved');
124
+ }
125
+ else {
126
+ deps.print('error: Anthropic key looks malformed (must start with sk-ant-). Skipped — run `cspeach login` again later.');
127
+ }
111
128
  }
112
129
  }
113
130
  }