@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.
- package/README.md +1 -1
- package/dist/agent/intent-system-prompt.js +1 -1
- package/dist/agent/loop.js +209 -20
- 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/auth/org-anthropic-key.js +25 -0
- package/dist/classifier/client.js +18 -3
- package/dist/commands/config-set.js +95 -0
- package/dist/commands/login.js +31 -14
- package/dist/commands/plan-model-tier.js +83 -0
- package/dist/commands/plan-resume.js +148 -21
- package/dist/config/loader.js +95 -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/zcspeach.js +19 -4
- package/dist/one-shot.js +52 -4
- package/dist/projects/answer-blockers.js +137 -0
- package/dist/projects/extract-cca.js +108 -16
- package/dist/projects/extract-modernize.js +1 -1
- package/dist/projects/extract-plan.js +130 -37
- package/dist/projects/extract-spec-gap.js +34 -7
- package/dist/projects/extract-test-coverage.js +1 -1
- package/dist/projects/extract-upgrade.js +113 -22
- 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 +159 -25
- package/dist/projects/plan-schema.js +63 -3
- package/dist/projects/promote-command.js +25 -2
- package/dist/projects/promote.js +128 -0
- package/dist/projects/save-command.js +247 -20
- 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/tool-widget.js +18 -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/builtin-commands.js +37 -0
- package/dist/repl/early-line-buffer.js +68 -0
- package/dist/repl/inquirer-guard.js +70 -5
- package/dist/repl/numbered-menu.js +131 -0
- package/dist/repl/post-turn-status.js +2 -2
- 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-picker.js +10 -15
- package/dist/repl.js +301 -35
- 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/pending.js +27 -0
- package/dist/skill-catalog.js +48 -43
- package/dist/skills/bundled-skills.js +279 -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 +64 -21
- package/dist/tools/ask-question.js +96 -4
- package/dist/tools/capability/tool.js +74 -0
- package/dist/tools/dispatch-skill.js +22 -1
- package/dist/tools/extend-model/anchored-insert.js +810 -0
- package/dist/tools/extend-model/tool.js +188 -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-scaffold.js +45 -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/scaffold.js +39 -0
- package/dist/tools/fiori/tools.js +356 -0
- package/dist/tools/fiori/types.js +1 -0
- package/dist/tools/local-build.js +76 -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 +53 -9
- package/dist/tools/sap-write.js +530 -21
- package/dist/tools/shell/shell_exec.js +41 -6
- package/dist/tools/snapshot.js +37 -14
- package/dist/tools/subagent/background_run.js +17 -1
- 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 +6 -2
- package/dist/ui/body.js +13 -0
- package/dist/ui/footer.js +20 -6
- package/dist/ui/line-resolution.js +17 -6
- package/dist/ui/session-timeline.js +1 -0
- package/dist/ui/text-input.js +150 -0
- package/dist/ui/widgets/ask-question-modal.js +4 -1
- package/package.json +19 -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
package/dist/config/loader.js
CHANGED
|
@@ -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
|
-
|
|
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');
|
|
@@ -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
|
-
|
|
17
|
-
|
|
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 {
|
|
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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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:
|
|
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 {
|
|
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
|
-
//
|
|
33
|
-
//
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|