@cspeach/cli 0.9.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +209 -20
  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/auth/org-anthropic-key.js +25 -0
  10. package/dist/classifier/client.js +18 -3
  11. package/dist/commands/config-set.js +95 -0
  12. package/dist/commands/login.js +31 -14
  13. package/dist/commands/plan-model-tier.js +83 -0
  14. package/dist/commands/plan-resume.js +148 -21
  15. package/dist/config/loader.js +95 -1
  16. package/dist/doctor/checks/_http-probe.js +1 -0
  17. package/dist/doctor/checks/cert.js +14 -3
  18. package/dist/doctor/checks/sap.js +30 -8
  19. package/dist/doctor/checks/zcspeach.js +19 -4
  20. package/dist/one-shot.js +52 -4
  21. package/dist/projects/answer-blockers.js +137 -0
  22. package/dist/projects/extract-cca.js +108 -16
  23. package/dist/projects/extract-modernize.js +1 -1
  24. package/dist/projects/extract-plan.js +130 -37
  25. package/dist/projects/extract-spec-gap.js +34 -7
  26. package/dist/projects/extract-test-coverage.js +1 -1
  27. package/dist/projects/extract-upgrade.js +113 -22
  28. package/dist/projects/index.js +5 -2
  29. package/dist/projects/merge-cca.js +292 -0
  30. package/dist/projects/merge-upgrade.js +173 -0
  31. package/dist/projects/migration.js +103 -1
  32. package/dist/projects/output-paths.js +27 -0
  33. package/dist/projects/plan-run.js +159 -25
  34. package/dist/projects/plan-schema.js +63 -3
  35. package/dist/projects/promote-command.js +25 -2
  36. package/dist/projects/promote.js +128 -0
  37. package/dist/projects/save-command.js +247 -20
  38. package/dist/projects/status.js +3 -1
  39. package/dist/projects/validate.js +1 -1
  40. package/dist/projects/workspace.js +164 -20
  41. package/dist/renderer/notices.js +64 -0
  42. package/dist/renderer/progress-chatter.js +8 -0
  43. package/dist/renderer/tool-widget.js +18 -4
  44. package/dist/renderer/tty.js +43 -4
  45. package/dist/renderer/verify-chain.js +77 -0
  46. package/dist/repl/at-picker.js +60 -7
  47. package/dist/repl/builtin-commands.js +37 -0
  48. package/dist/repl/early-line-buffer.js +68 -0
  49. package/dist/repl/inquirer-guard.js +70 -5
  50. package/dist/repl/numbered-menu.js +131 -0
  51. package/dist/repl/post-turn-status.js +2 -2
  52. package/dist/repl/rule8-detector.js +17 -2
  53. package/dist/repl/safety-confirm.js +111 -2
  54. package/dist/repl/safety-mode-state.js +19 -3
  55. package/dist/repl/slash-picker.js +10 -15
  56. package/dist/repl.js +301 -35
  57. package/dist/router/classifier.js +150 -6
  58. package/dist/sap/capability-matrix.js +20 -0
  59. package/dist/sap/capability-matrix.json +11236 -0
  60. package/dist/sap/capability.js +146 -0
  61. package/dist/sap/connection-manager.js +19 -1
  62. package/dist/sap/onboarding.js +42 -4
  63. package/dist/session/pending.js +27 -0
  64. package/dist/skill-catalog.js +48 -43
  65. package/dist/skills/bundled-skills.js +279 -1
  66. package/dist/skills/promotion-dispatch.js +23 -0
  67. package/dist/tools/_command-shared.js +36 -12
  68. package/dist/tools/_filesystem-shared.js +139 -4
  69. package/dist/tools/_flag.js +25 -0
  70. package/dist/tools/approval.js +64 -21
  71. package/dist/tools/ask-question.js +96 -4
  72. package/dist/tools/capability/tool.js +74 -0
  73. package/dist/tools/dispatch-skill.js +22 -1
  74. package/dist/tools/extend-model/anchored-insert.js +810 -0
  75. package/dist/tools/extend-model/tool.js +188 -0
  76. package/dist/tools/filesystem/extract-document.js +57 -0
  77. package/dist/tools/filesystem/file-edit.js +12 -2
  78. package/dist/tools/filesystem/file-read.js +2 -2
  79. package/dist/tools/filesystem/file-write.js +11 -2
  80. package/dist/tools/filesystem/glob.js +11 -0
  81. package/dist/tools/filesystem/grep.js +10 -0
  82. package/dist/tools/filesystem/read-document.js +107 -0
  83. package/dist/tools/fiori/apply.js +50 -0
  84. package/dist/tools/fiori/bin.js +3 -0
  85. package/dist/tools/fiori/catalog/index.js +27 -0
  86. package/dist/tools/fiori/catalog/value-help.js +230 -0
  87. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  88. package/dist/tools/fiori/cli.js +71 -0
  89. package/dist/tools/fiori/deploy-config.js +73 -0
  90. package/dist/tools/fiori/fe-scaffold.js +45 -0
  91. package/dist/tools/fiori/i18n.js +39 -0
  92. package/dist/tools/fiori/manifest.js +70 -0
  93. package/dist/tools/fiori/render.js +77 -0
  94. package/dist/tools/fiori/scaffold.js +39 -0
  95. package/dist/tools/fiori/tools.js +356 -0
  96. package/dist/tools/fiori/types.js +1 -0
  97. package/dist/tools/local-build.js +76 -0
  98. package/dist/tools/local-files.js +31 -0
  99. package/dist/tools/project/_merge-shared.js +68 -0
  100. package/dist/tools/project/cca_merge.js +164 -0
  101. package/dist/tools/project/playbook_get.js +1 -1
  102. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  103. package/dist/tools/sap-read.js +53 -9
  104. package/dist/tools/sap-write.js +530 -21
  105. package/dist/tools/shell/shell_exec.js +41 -6
  106. package/dist/tools/snapshot.js +37 -14
  107. package/dist/tools/subagent/background_run.js +17 -1
  108. package/dist/tools/transport-resolution.js +86 -0
  109. package/dist/tools/transport.js +224 -5
  110. package/dist/tools/write-mode.js +4 -0
  111. package/dist/ui/app.js +6 -2
  112. package/dist/ui/body.js +13 -0
  113. package/dist/ui/footer.js +20 -6
  114. package/dist/ui/line-resolution.js +17 -6
  115. package/dist/ui/session-timeline.js +1 -0
  116. package/dist/ui/text-input.js +150 -0
  117. package/dist/ui/widgets/ask-question-modal.js +4 -1
  118. package/package.json +19 -3
  119. package/bench/README.md +0 -78
  120. package/bench/prompts/abap-document-cds.md +0 -44
  121. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  122. package/bench/prompts/abap-test-method.md +0 -42
  123. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  124. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  125. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  126. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  127. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  128. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  129. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  130. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  131. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -26,6 +26,13 @@ const DEFAULT_CONFIG = {
26
26
  write_mode: 'approval-gated',
27
27
  // Default `managed` — lowest-friction entry point; uses the cspeach.dev proxy.
28
28
  llm: { mode: 'managed' },
29
+ // local_build is intentionally NOT defaulted here. If it were set to `false`,
30
+ // the `...DEFAULT_CONFIG` spread would leave merged.local_build defined even
31
+ // when the file omits the key — which permanently defeats resolveLocalBuild's
32
+ // `local_build ?? local_files` fallback (the legacy `local_files = on` case
33
+ // could never fire). Absence must stay `undefined` so the fallback can read
34
+ // the legacy key. The effective default-off is enforced by resolveLocalBuild
35
+ // (returns false when both are undefined). See loadConfig coercion below.
29
36
  // Defaults tuned 2026-05-16 from session 0618a42d evidence — see CompactConfig doc.
30
37
  compact: {
31
38
  keep_recent_turns: 5,
@@ -62,6 +69,25 @@ function sanitiseCompact(raw) {
62
69
  min_turns_between_auto: intInRange(r.min_turns_between_auto, d.min_turns_between_auto, 1, 100),
63
70
  };
64
71
  }
72
+ /**
73
+ * Resolve the effective TLS options for a SAP system, applying the legacy
74
+ * `sslVerify: false` → insecure mapping. A configured `ca_cert_path` always
75
+ * wins (verification on). Pure — no I/O. Shared by the connection-manager and
76
+ * doctor checks so they agree on the posture.
77
+ */
78
+ export function resolveSapTls(sys) {
79
+ if (sys.ca_cert_path) {
80
+ return { mode: 'ca-trusted', caCertPath: sys.ca_cert_path, insecureSkipTlsVerify: false };
81
+ }
82
+ // Explicit insecure opt-in, OR the legacy sslVerify:false mapping.
83
+ const insecure = sys.insecure_skip_tls_verify === true || sys.sslVerify === false;
84
+ if (insecure) {
85
+ return { mode: 'insecure', insecureSkipTlsVerify: true };
86
+ }
87
+ return { mode: 'verify', insecureSkipTlsVerify: false };
88
+ }
89
+ /** Tracks whether the one-time legacy-sslVerify deprecation note has printed. */
90
+ let legacySslVerifyNoticeShown = false;
65
91
  export async function loadConfig() {
66
92
  try {
67
93
  const raw = await fs.readFile(configFile(), 'utf-8');
@@ -87,9 +113,27 @@ export async function loadConfig() {
87
113
  else {
88
114
  allow = [];
89
115
  }
90
- return {
116
+ const merged = {
91
117
  ...DEFAULT_CONFIG,
92
118
  ...parsed,
119
+ // Coerce defensively, but PRESERVE the absent→undefined distinction so the
120
+ // legacy fallback in resolveLocalBuild can fire:
121
+ // - explicit `true` → true
122
+ // - explicit `false`/off → false (defined; overrides legacy)
123
+ // - present-but-invalid (string) → false (defined; fails safe, can't
124
+ // grant access via a typo, but DOES override a stale legacy on)
125
+ // - absent / undefined → undefined (legacy fallback applies)
126
+ // A defined `false` (whether explicit or typo) must win the
127
+ // `local_build ?? local_files` resolution so an explicit off is honoured.
128
+ local_build: parsed.local_build === true
129
+ ? true
130
+ : parsed.local_build === undefined
131
+ ? undefined
132
+ : false,
133
+ // Preserve a legacy `local_files` value (read-only backward compat) ONLY
134
+ // when it's strictly true — a string/typo collapses to "absent" so it
135
+ // can't win the `local_build ?? local_files` fallback.
136
+ local_files: parsed.local_files === true ? true : undefined,
93
137
  write_mode: isValidWriteMode(parsed.write_mode) ? parsed.write_mode : DEFAULT_CONFIG.write_mode,
94
138
  llm: { ...DEFAULT_CONFIG.llm, ...(parsed.llm ?? {}) },
95
139
  classifier: { ...DEFAULT_CONFIG.classifier, ...(parsed.classifier ?? {}) },
@@ -100,6 +144,37 @@ export async function loadConfig() {
100
144
  compact: sanitiseCompact(parsed.compact),
101
145
  shell_exec: parsed.shell_exec === undefined ? undefined : { allow },
102
146
  };
147
+ // Drop the legacy `local_files` key entirely when it isn't a genuine on
148
+ // (the `...parsed` spread above can leave a `local_files = false` behind,
149
+ // and our explicit coercion sets it to `undefined`). Removing the key keeps
150
+ // `config show` and saveConfig output clean — no `local_files = undefined`.
151
+ if (merged.local_files === undefined)
152
+ delete merged.local_files;
153
+ // Same for `local_build`: when the key is absent from disk we coerce it to
154
+ // `undefined` (so resolveLocalBuild's legacy fallback can fire). Delete the
155
+ // key so it doesn't serialise as `local_build = undefined` and so callers
156
+ // see a clean "absent" shape. resolveLocalBuild treats missing === undefined.
157
+ if (merged.local_build === undefined)
158
+ delete merged.local_build;
159
+ // One-time deprecation note: a SAP system still relies on the legacy
160
+ // `sslVerify` toggle and has not adopted either new key. It KEEPS WORKING
161
+ // (resolveSapTls maps sslVerify:false → insecure), but we nudge the user
162
+ // once per process toward `ca_cert_path` (preferred) or the explicit
163
+ // insecure flag. Only fires when sslVerify is present AND no new key is set.
164
+ if (!legacySslVerifyNoticeShown) {
165
+ const legacyAlias = Object.entries(merged.sap ?? {}).find(([, s]) => typeof s?.sslVerify === 'boolean' &&
166
+ s.ca_cert_path === undefined &&
167
+ s.insecure_skip_tls_verify === undefined);
168
+ if (legacyAlias) {
169
+ legacySslVerifyNoticeShown = true;
170
+ const [alias, sys] = legacyAlias;
171
+ const suggestion = sys.sslVerify === false
172
+ ? `set 'ca_cert_path' to your corporate CA (keeps verification ON), or set 'insecure_skip_tls_verify = true' to keep verification off (dev only, logs a warning)`
173
+ : `the default is now verify-ON; you can drop 'sslVerify' or set 'ca_cert_path' for internal-CA systems`;
174
+ process.stderr.write(`[cspeach] note: [sap.${alias}] uses the deprecated 'sslVerify' key — ${suggestion}.\n`);
175
+ }
176
+ }
177
+ return merged;
103
178
  }
104
179
  catch (err) {
105
180
  if (err?.code === 'ENOENT')
@@ -107,6 +182,25 @@ export async function loadConfig() {
107
182
  throw err;
108
183
  }
109
184
  }
185
+ /**
186
+ * Resolve the effective "local build tools" toggle from a loaded config.
187
+ *
188
+ * Backward compat (2026-06-13 rename): the new key is `local_build`; the legacy
189
+ * key is `local_files`. A user who set `local_files = on` before the rename
190
+ * must keep working without re-running `config set`. The rule is:
191
+ *
192
+ * effective = local_build ?? local_files (default false)
193
+ *
194
+ * `local_build` always wins when present (so `config set local_build off`
195
+ * overrides a stale legacy `local_files = on`). Only when `local_build` is
196
+ * undefined does the legacy value apply. Both keys are already strictly coerced
197
+ * to `true | undefined` by loadConfig, so this is a pure read.
198
+ */
199
+ export function resolveLocalBuild(cfg) {
200
+ if (cfg.local_build !== undefined)
201
+ return cfg.local_build === true;
202
+ return cfg.local_files === true;
203
+ }
110
204
  export async function saveConfig(config) {
111
205
  await fs.mkdir(cspeachRoot(), { recursive: true });
112
206
  await fs.writeFile(configFile(), toml.stringify(config), 'utf-8');
@@ -35,6 +35,7 @@ export function probeGet(url, opts) {
35
35
  path: u.pathname + u.search,
36
36
  timeout: opts.timeoutMs ?? 8000,
37
37
  rejectUnauthorized: opts.rejectUnauthorized,
38
+ ...(opts.ca !== undefined ? { ca: opts.ca } : {}),
38
39
  headers,
39
40
  }, (res) => {
40
41
  const chunks = [];
@@ -1,5 +1,5 @@
1
1
  import tls from 'node:tls';
2
- import { loadConfig } from '../../config/loader.js';
2
+ import { loadConfig, resolveSapTls } from '../../config/loader.js';
3
3
  export async function checkCert() {
4
4
  const cfg = await loadConfig();
5
5
  const systems = Object.entries(cfg.sap).filter(([_, s]) => s.useSsl);
@@ -7,14 +7,25 @@ export async function checkCert() {
7
7
  return { name: 'cert', ok: true, message: 'n/a (no TLS SAP systems configured)' };
8
8
  const results = [];
9
9
  for (const [alias, sys] of systems) {
10
+ const tlsMode = resolveSapTls(sys);
10
11
  const info = await new Promise((resolve) => {
12
+ // Probe with rejectUnauthorized:false so we can always read the cert and
13
+ // report the OS-trust chain status, then describe the configured posture
14
+ // separately (verify / CA-trusted / INSECURE).
11
15
  const socket = tls.connect({ host: sys.host, port: sys.port, rejectUnauthorized: false, timeout: 5000 }, () => {
12
16
  const cert = socket.getPeerCertificate();
13
17
  const authorized = socket.authorized;
14
18
  socket.end();
15
19
  const chainValid = authorized ? 'yes' : 'no';
16
- const clientAccepted = !sys.sslVerify ? 'yes (trust-manager allows self-signed)' : (authorized ? 'yes' : 'no');
17
- resolve(`${alias}: chain valid: ${chainValid} / client accepted: ${clientAccepted} / subject: ${cert.subject?.CN ?? '?'}`);
20
+ // What the configured client mode means for acceptance:
21
+ // verify → accepts only if the OS/Node trust chain validates
22
+ // CA-trusted → accepts via the configured CA (verification ON)
23
+ // INSECURE → accepts anything (verification OFF — dev only)
24
+ const clientAccepted = tlsMode.mode === 'insecure' ? 'yes (INSECURE — verification disabled, dev only)'
25
+ : tlsMode.mode === 'ca-trusted' ? 'yes (CA-trusted — verification ON via configured CA)'
26
+ : (authorized ? 'yes' : 'no (verify ON — configure ca_cert_path to trust an internal CA)');
27
+ resolve(`${alias}: mode: ${tlsMode.mode} / chain valid: ${chainValid} / ` +
28
+ `client accepted: ${clientAccepted} / subject: ${cert.subject?.CN ?? '?'}`);
18
29
  });
19
30
  socket.on('error', (err) => resolve(`${alias}: ${err.message}`));
20
31
  });
@@ -1,4 +1,5 @@
1
- import { loadConfig } from '../../config/loader.js';
1
+ import { readFile } from 'node:fs/promises';
2
+ import { loadConfig, resolveSapTls } from '../../config/loader.js';
2
3
  import { probeGet } from './_http-probe.js';
3
4
  export async function checkSap() {
4
5
  const cfg = await loadConfig();
@@ -9,23 +10,44 @@ export async function checkSap() {
9
10
  let anyFailed = false;
10
11
  for (const [alias, sys] of systems) {
11
12
  const url = `${sys.useSsl ? 'https' : 'http'}://${sys.host}:${sys.port}/sap/bc/adt/discovery`;
12
- // Use node's https directly so we can honor sslVerify=false for
13
- // self-signed dev systems — Node's built-in fetch() doesn't expose a
14
- // per-request TLS-verify toggle. See checks/_http-probe.ts.
13
+ // TLS posture (2026-06-14): verification ON by default. resolveSapTls maps
14
+ // the per-alias config (ca_cert_path / insecure_skip_tls_verify, legacy
15
+ // sslVerify:false fallback). Node's built-in fetch() doesn't expose a
16
+ // per-request TLS-verify toggle, so we use node's https directly — see
17
+ // checks/_http-probe.ts.
18
+ const tls = resolveSapTls(sys);
19
+ // Tag the connectivity result with the cert mode so doctor surfaces it.
20
+ const modeTag = tls.mode === 'verify' ? 'verify'
21
+ : tls.mode === 'ca-trusted' ? 'CA-trusted'
22
+ : 'INSECURE';
23
+ let ca;
24
+ if (tls.mode === 'ca-trusted' && tls.caCertPath) {
25
+ try {
26
+ ca = await readFile(tls.caCertPath);
27
+ }
28
+ catch (e) {
29
+ // Fail closed, like the connection path: a configured-but-unreadable CA
30
+ // is an error, not a reason to drop verification.
31
+ results.push(`${alias}: CA unreadable at ${tls.caCertPath} (${e.message})`);
32
+ anyFailed = true;
33
+ continue;
34
+ }
35
+ }
15
36
  const r = await probeGet(url, {
16
- rejectUnauthorized: sys.sslVerify,
37
+ rejectUnauthorized: tls.mode !== 'insecure',
38
+ ca,
17
39
  timeoutMs: 8000,
18
40
  });
19
41
  if (r.error) {
20
- results.push(`${alias}: ${r.error}`);
42
+ results.push(`${alias}: ${r.error} [${modeTag}]`);
21
43
  anyFailed = true;
22
44
  }
23
45
  else if (r.status === 200 || r.status === 401) {
24
46
  // 200 = anonymous discovery allowed; 401 = reachable + auth-required (expected, fine)
25
- results.push(`${alias}: reachable`);
47
+ results.push(`${alias}: reachable [${modeTag}]`);
26
48
  }
27
49
  else {
28
- results.push(`${alias}: HTTP ${r.status}`);
50
+ results.push(`${alias}: HTTP ${r.status} [${modeTag}]`);
29
51
  anyFailed = true;
30
52
  }
31
53
  }
@@ -1,5 +1,6 @@
1
1
  import keytar from 'keytar';
2
- import { loadConfig } from '../../config/loader.js';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { loadConfig, resolveSapTls } from '../../config/loader.js';
3
4
  import { probeGet } from './_http-probe.js';
4
5
  const SAP_SERVICE = 'cspeach-sap';
5
6
  export async function checkZcspeach() {
@@ -29,10 +30,24 @@ export async function checkZcspeach() {
29
30
  results.push(`${alias}: skipped (no password in keychain)`);
30
31
  continue;
31
32
  }
32
- // Use node's https directly so we can honor sslVerify=false for
33
- // self-signed dev systems. See checks/_http-probe.ts.
33
+ // TLS posture (2026-06-14): verification ON by default; resolveSapTls maps
34
+ // the per-alias config (ca_cert_path / insecure_skip_tls_verify, legacy
35
+ // sslVerify:false fallback). See checks/_http-probe.ts.
36
+ const tls = resolveSapTls(sys);
37
+ let ca;
38
+ if (tls.mode === 'ca-trusted' && tls.caCertPath) {
39
+ try {
40
+ ca = await readFile(tls.caCertPath);
41
+ }
42
+ catch (e) {
43
+ results.push(`${alias}: CA unreadable at ${tls.caCertPath} (${e.message})`);
44
+ anyFailed = true;
45
+ continue;
46
+ }
47
+ }
34
48
  const r = await probeGet(url, {
35
- rejectUnauthorized: sys.sslVerify,
49
+ rejectUnauthorized: tls.mode !== 'insecure',
50
+ ca,
36
51
  auth: `${sys.username}:${pwd}`,
37
52
  accept: 'application/json',
38
53
  timeoutMs: 5000,
package/dist/one-shot.js CHANGED
@@ -1,15 +1,18 @@
1
1
  import chalk from 'chalk';
2
- import { loadConfig } from './config/loader.js';
2
+ import { loadConfig, resolveLocalBuild } from './config/loader.js';
3
3
  import { getStore } from './auth/api-key.js';
4
4
  import { getConnection } from './sap/connection-manager.js';
5
5
  import { AdtClient } from '@cspeach/sap-client';
6
6
  import { makeSessionId, saveSession } from './session/store.js';
7
7
  import { newSession } from './session/schema.js';
8
8
  import { createProviderForMode } from './agent/providers/factory.js';
9
+ import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
9
10
  import { runTurn } from './agent/loop.js';
10
- import { classifyPrompt, parseShortcut } from './router/classifier.js';
11
- import { setRenderingOverride } from './renderer/tty.js';
11
+ import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/classifier.js';
12
+ import { setRenderingOverride, setHeadlessOverride } from './renderer/tty.js';
12
13
  import { alwaysDeclinePreviewHook } from './repl/update-method-preview-hook.js';
14
+ import { createClassicOutputEmitter } from './renderer/notices.js';
15
+ import { applyLocalBuildConfig } from './tools/local-build.js';
13
16
  // Side-effect imports for tool registration.
14
17
  import './tools/sap-read.js';
15
18
  import './tools/sap-write.js';
@@ -19,16 +22,22 @@ import './tools/snapshot.js';
19
22
  import './tools/ask-question.js';
20
23
  import './tools/dispatch-skill.js';
21
24
  import './tools/filesystem/file-read.js';
25
+ import './tools/filesystem/read-document.js';
22
26
  import './tools/filesystem/file-edit.js';
23
27
  import './tools/filesystem/file-write.js';
24
28
  import './tools/filesystem/glob.js';
25
29
  import './tools/filesystem/grep.js';
26
30
  import './tools/shell/shell_exec.js';
31
+ import './tools/fiori/tools.js';
32
+ import './tools/extend-model/tool.js';
33
+ import './tools/capability/tool.js';
27
34
  import './tools/web/web_fetch.js';
28
35
  import './tools/web/web_search.js';
29
36
  import './tools/project/project_context_get.js';
30
37
  import './tools/project/playbook_get.js';
31
38
  import './tools/project/convention_get.js';
39
+ import './tools/project/cca_merge.js';
40
+ import './tools/project/upgrade_merge_progress.js';
32
41
  import './tools/subagent/_background-shared.js';
33
42
  import './tools/subagent/background_run.js';
34
43
  import './tools/subagent/monitor_emit.js';
@@ -37,12 +46,27 @@ import './tools/subagent/agent_run.js';
37
46
  export async function runOneShot(prompt, opts) {
38
47
  const cfg = await loadConfig();
39
48
  setRenderingOverride(cfg.ui.rendering);
49
+ // B5 (2026-06-11) — one-shot is headless by design: a single turn, no
50
+ // REPL, often piped (CI / battery). Any stdin prompt would hang forever
51
+ // (defect D1). The headless answer policy (ask_question auto-answers,
52
+ // save hook defaults YES, approvals fail fast) keys off this flag.
53
+ setHeadlessOverride(true);
40
54
  // Task C.10 — advisory-only is the only mode the one-shot announces, because
41
55
  // non-interactive output should stay minimal. The user needs to know that
42
56
  // writes will NOT be executed before a skill that normally writes runs.
43
57
  if (cfg.write_mode === 'advisory-only') {
44
58
  console.log(chalk.yellow('⚠ Advisory mode: writes will be proposed, not executed.'));
45
59
  }
60
+ // local_build toggle — force-enable the flag-gated filesystem tools AND
61
+ // shell_exec BEFORE the (single) turn when persisted-on. Resolve with backward
62
+ // compat (legacy `local_files = on` still applies). Announce so headless
63
+ // transcripts record that local file access + command execution were live
64
+ // (trust-visibility / auditability).
65
+ {
66
+ const announce = applyLocalBuildConfig(resolveLocalBuild(cfg), process.cwd());
67
+ if (announce)
68
+ console.log(chalk.dim(announce));
69
+ }
46
70
  const store = await getStore();
47
71
  if (!(await store.get())) {
48
72
  console.error('No API key configured. Run `cspeach` (interactive) first to set one.');
@@ -66,6 +90,14 @@ export async function runOneShot(prompt, opts) {
66
90
  const body = parsed.prompt;
67
91
  let skill;
68
92
  if (parsed.skill) {
93
+ // D35 (2026-06-11) — explicit slash naming a skill that isn't in the
94
+ // catalog. One-shot has no picker, so fail fast with the notice ("did
95
+ // you mean" / builtin-with-args) instead of dispatching to a
96
+ // nonexistent skill (and NEVER falling through to the classifier with
97
+ // the slash embedded). Shared gate: rejectUnknownSkill in classifier.ts.
98
+ if (rejectUnknownSkill(parsed, (m) => console.error(chalk.red(m)))) {
99
+ return 1;
100
+ }
69
101
  skill = parsed.skill;
70
102
  }
71
103
  else {
@@ -94,6 +126,19 @@ export async function runOneShot(prompt, opts) {
94
126
  }
95
127
  skill = c.skill;
96
128
  }
129
+ // License gate: non-managed modes (byok/local/ai-hub) bypass our proxy for
130
+ // inference, so without this anyone who never logged in could use the bundled
131
+ // skills untracked. Require a valid CSPeach login. (See license-gate.ts.)
132
+ try {
133
+ await assertModeLicensed(cfg.llm.mode, async () => (await store.get()) ?? '');
134
+ }
135
+ catch (e) {
136
+ if (e instanceof CspeachLicenseError) {
137
+ console.error(chalk.yellow(`\n${e.message}\n`));
138
+ process.exit(1);
139
+ }
140
+ throw e;
141
+ }
97
142
  const provider = createProviderForMode(cfg, async () => {
98
143
  const key = await store.get();
99
144
  if (!key)
@@ -104,7 +149,10 @@ export async function runOneShot(prompt, opts) {
104
149
  provider,
105
150
  userMessage: body,
106
151
  skill,
107
- ctx: { adt, sapAlias: alias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: alwaysDeclinePreviewHook },
152
+ // B2 fix (2026-06-12): one-shot runs render tool notices ('warn'/'info'
153
+ // — Rule 9 transport overrides, snapshot lines) to stdout instead of
154
+ // dropping them. Headless transcripts need these for auditability.
155
+ ctx: { adt, sapAlias: alias, session, cwd: process.cwd(), provider, skillSource: provider.skillSource, previewHook: alwaysDeclinePreviewHook, chunkEmitter: createClassicOutputEmitter() },
108
156
  });
109
157
  return 0;
110
158
  }
@@ -0,0 +1,137 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { readProjectFile } from './status.js';
3
+ import { validateEnvelope } from './validate.js';
4
+ /**
5
+ * The still-open BLOCKER-severity questions of a spec-gap envelope, in item
6
+ * order. Returns [] for any non-spec-gap artefact (only spec-gap has gaps with
7
+ * a severity + an `answered` status). Carries any candidate `options` so the
8
+ * answer UI can offer choices instead of a bare text box.
9
+ */
10
+ export function openBlockers(env) {
11
+ if (env.artefactType !== 'spec-gap')
12
+ return [];
13
+ const gapById = new Map();
14
+ for (const s of env.content.sections) {
15
+ for (const g of s.gaps)
16
+ gapById.set(g.id, g);
17
+ }
18
+ const out = [];
19
+ for (const it of env.interaction.items) {
20
+ if (it.status !== 'open')
21
+ continue;
22
+ const g = gapById.get(it.id);
23
+ if (g?.severity !== 'BLOCKER')
24
+ continue;
25
+ const ob = { id: it.id, text: g.text };
26
+ if (g.options && g.options.length > 0) {
27
+ ob.options = g.options;
28
+ if (g.optionsMultiSelect)
29
+ ob.multiSelect = true;
30
+ }
31
+ out.push(ob);
32
+ }
33
+ return out;
34
+ }
35
+ /**
36
+ * Return a copy of `env` with each item in `answers` marked answered. Keys with
37
+ * a blank value are ignored (treated as skipped). version + history are left
38
+ * untouched (invariant version === history.length holds); lastEditedAt/By move
39
+ * to the answerer to reflect who filled them.
40
+ */
41
+ export function applyBlockerAnswers(env, answers, answerer, nowIso) {
42
+ const items = env.interaction.items.map((it) => {
43
+ const a = answers.get(it.id);
44
+ if (a === undefined || a.trim().length === 0)
45
+ return it;
46
+ return {
47
+ ...it,
48
+ status: 'answered',
49
+ answer: a,
50
+ answeredBy: answerer,
51
+ answeredAt: nowIso,
52
+ };
53
+ });
54
+ return {
55
+ ...env,
56
+ interaction: { items },
57
+ lastEditedAt: nowIso,
58
+ lastEditedBy: answerer,
59
+ };
60
+ }
61
+ function affirmative(reply) {
62
+ const v = reply.trim().toLowerCase();
63
+ return v === 'y' || v === 'yes';
64
+ }
65
+ /**
66
+ * Offer to answer the open blockers of the just-saved spec-gap file inline.
67
+ * No-op (no prompt) when the file isn't a spec-gap or has no open blockers, so
68
+ * it is always safe to call after any save. In headless runs the prompt returns
69
+ * '' → declined → nothing happens.
70
+ *
71
+ * Returns the count recorded, or null when nothing was offered/written.
72
+ */
73
+ export async function offerAnswerBlockers(args) {
74
+ let env;
75
+ try {
76
+ env = readProjectFile(args.path);
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ const blockers = openBlockers(env);
82
+ if (blockers.length === 0)
83
+ return null;
84
+ const reply = await args.prompt(`Answer the ${blockers.length} unanswered blocker(s) now? [y/N]: `);
85
+ if (!affirmative(reply))
86
+ return null;
87
+ const answers = new Map();
88
+ for (let i = 0; i < blockers.length; i++) {
89
+ const b = blockers[i];
90
+ args.log('');
91
+ args.log(`Blocker ${i + 1}/${blockers.length}: ${b.text}`);
92
+ // Choices first, where the question has them and a picker is available.
93
+ // 'write' falls through to the text box; 'skip' leaves the blocker open.
94
+ let askText = true;
95
+ if (b.options && b.options.length > 0 && args.choose) {
96
+ const c = await args.choose({
97
+ question: b.text,
98
+ options: b.options,
99
+ multiSelect: !!b.multiSelect,
100
+ index: i,
101
+ total: blockers.length,
102
+ });
103
+ if (c.kind === 'picked') {
104
+ if (c.answer.trim().length > 0)
105
+ answers.set(b.id, c.answer.trim());
106
+ askText = false;
107
+ }
108
+ else if (c.kind === 'skip') {
109
+ askText = false;
110
+ }
111
+ // c.kind === 'write' → askText stays true
112
+ }
113
+ if (askText) {
114
+ const a = (await args.prompt(' Your answer (Enter to skip): ')).trim();
115
+ if (a.length > 0)
116
+ answers.set(b.id, a);
117
+ }
118
+ }
119
+ if (answers.size === 0) {
120
+ args.log('');
121
+ args.log('No answers entered — all blockers left open for the viewer.');
122
+ return null;
123
+ }
124
+ const nowIso = (args.now ?? (() => new Date().toISOString()))();
125
+ const revised = applyBlockerAnswers(env, answers, args.identity, nowIso);
126
+ const check = validateEnvelope(JSON.parse(JSON.stringify(revised)));
127
+ if (!check.ok) {
128
+ args.log(`[answer] revision invalid (${check.error.message}) — answers NOT saved.`);
129
+ return null;
130
+ }
131
+ writeFileSync(args.path, JSON.stringify(revised, null, 2), 'utf8');
132
+ const stillOpen = blockers.length - answers.size;
133
+ args.log('');
134
+ args.log(`Recorded ${answers.size} answer(s) in the file`
135
+ + (stillOpen > 0 ? ` — ${stillOpen} blocker(s) still open for the viewer.` : '.'));
136
+ return answers.size;
137
+ }