@cspeach/cli 0.6.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/LICENSE +8 -0
- package/README.md +108 -0
- package/dist/agent/anthropic-provider.js +59 -0
- package/dist/agent/llm-provider.js +1 -0
- package/dist/agent/loop.js +709 -0
- package/dist/agent/maybe-build-project-context.js +126 -0
- package/dist/agent/providers/ai-hub-provider.js +58 -0
- package/dist/agent/providers/byok-provider.js +53 -0
- package/dist/agent/providers/factory.js +13 -0
- package/dist/agent/providers/local-provider.js +125 -0
- package/dist/agent/repair-partial.js +31 -0
- package/dist/agent/retry-key.js +58 -0
- package/dist/agent/retry.js +17 -0
- package/dist/agent/sap-connection-adapter.js +82 -0
- package/dist/agent/skill-checkpoint.js +119 -0
- package/dist/agent/tool-dispatch.js +47 -0
- package/dist/agent/turn-assistant-text.js +49 -0
- package/dist/agent/turn-error-ux.js +126 -0
- package/dist/agent/turn-stream.js +79 -0
- package/dist/agent/turn-watchdog.js +71 -0
- package/dist/approvals/advisory-prompt.js +40 -0
- package/dist/approvals/advisory-render.js +38 -0
- package/dist/approvals/approval-prompt.js +100 -0
- package/dist/approvals/jwt.js +33 -0
- package/dist/approvals/render.js +211 -0
- package/dist/approvals/risk-floor.js +26 -0
- package/dist/auth/api-key.js +40 -0
- package/dist/auth/auth-file.js +59 -0
- package/dist/auth/device.js +8 -0
- package/dist/auth/me.js +19 -0
- package/dist/classifier/client.js +58 -0
- package/dist/cli-args.js +38 -0
- package/dist/cli.js +148 -0
- package/dist/commands/config-set.js +245 -0
- package/dist/commands/config-show.js +159 -0
- package/dist/commands/help.js +93 -0
- package/dist/commands/login.js +122 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/project-context-impact.js +215 -0
- package/dist/commands/reroute.js +60 -0
- package/dist/commands/spec-gap-status.js +52 -0
- package/dist/commands/whoami.js +35 -0
- package/dist/config/loader.js +67 -0
- package/dist/config/paths.js +20 -0
- package/dist/doctor/checks/_http-probe.js +56 -0
- package/dist/doctor/checks/auth.js +15 -0
- package/dist/doctor/checks/cert.js +24 -0
- package/dist/doctor/checks/forge-rules.js +102 -0
- package/dist/doctor/checks/keychain-fallback.js +14 -0
- package/dist/doctor/checks/keychain.js +23 -0
- package/dist/doctor/checks/llm-mode.js +27 -0
- package/dist/doctor/checks/proxy.js +13 -0
- package/dist/doctor/checks/sap.js +33 -0
- package/dist/doctor/checks/skill.js +24 -0
- package/dist/doctor/checks/write-mode.js +34 -0
- package/dist/doctor/checks/zcspeach.js +76 -0
- package/dist/doctor/run.js +46 -0
- package/dist/errors/codes.js +12 -0
- package/dist/index.js +6 -0
- package/dist/lock-contention.js +22 -0
- package/dist/one-shot.js +104 -0
- package/dist/project-context/conventions.js +309 -0
- package/dist/project-context/detect.js +250 -0
- package/dist/project-context/domain/abap-cloud.js +26 -0
- package/dist/project-context/domain/abapgit.js +177 -0
- package/dist/project-context/domain/cap.js +164 -0
- package/dist/project-context/domain/fiori.js +326 -0
- package/dist/project-context/git.js +115 -0
- package/dist/project-context/index-files.js +235 -0
- package/dist/project-context/index.js +117 -0
- package/dist/project-context/render.js +308 -0
- package/dist/project-context/types.js +14 -0
- package/dist/projects/build.js +20 -0
- package/dist/projects/canonicalize.js +39 -0
- package/dist/projects/email-template.js +54 -0
- package/dist/projects/extract-cca.js +139 -0
- package/dist/projects/extract-design.js +107 -0
- package/dist/projects/extract-estimate.js +93 -0
- package/dist/projects/extract-modernize.js +130 -0
- package/dist/projects/extract-spec-gap.js +101 -0
- package/dist/projects/extract-test-coverage.js +137 -0
- package/dist/projects/extract-upgrade.js +230 -0
- package/dist/projects/filename.js +18 -0
- package/dist/projects/index.js +8 -0
- package/dist/projects/migration.js +111 -0
- package/dist/projects/promote-command.js +96 -0
- package/dist/projects/promote.js +107 -0
- package/dist/projects/save-command.js +124 -0
- package/dist/projects/save.js +21 -0
- package/dist/projects/status.js +170 -0
- package/dist/projects/types.js +1 -0
- package/dist/projects/validate.js +146 -0
- package/dist/projects/workspace.js +478 -0
- package/dist/renderer/abap-inline.js +121 -0
- package/dist/renderer/banners.js +39 -0
- package/dist/renderer/highlighters/abap.js +126 -0
- package/dist/renderer/highlighters/bdef.js +81 -0
- package/dist/renderer/highlighters/cds.js +91 -0
- package/dist/renderer/markdown.js +291 -0
- package/dist/renderer/pipeline.js +201 -0
- package/dist/renderer/progress-chatter.js +237 -0
- package/dist/renderer/question-normalizer.js +306 -0
- package/dist/renderer/severity.js +61 -0
- package/dist/renderer/status-footer.js +50 -0
- package/dist/renderer/syntax.js +58 -0
- package/dist/renderer/tables.js +55 -0
- package/dist/renderer/thinking-heartbeat.js +70 -0
- package/dist/renderer/tool-widget.js +199 -0
- package/dist/renderer/tty.js +66 -0
- package/dist/renderer/widget-extractor.js +87 -0
- package/dist/renderer/widget-fallback.js +78 -0
- package/dist/renderer/widget-schemas.js +43 -0
- package/dist/repl/at-completer.js +64 -0
- package/dist/repl/at-picker.js +122 -0
- package/dist/repl/bracketed-paste.js +284 -0
- package/dist/repl/current-transport.js +46 -0
- package/dist/repl/diff-display.js +41 -0
- package/dist/repl/file-picker.js +219 -0
- package/dist/repl/inquirer-guard.js +130 -0
- package/dist/repl/inquirer-theme.js +41 -0
- package/dist/repl/rule8-detector.js +99 -0
- package/dist/repl/safety-confirm.js +106 -0
- package/dist/repl/safety-mode-state.js +36 -0
- package/dist/repl/slash-completer.js +59 -0
- package/dist/repl/slash-picker.js +124 -0
- package/dist/repl/update-method-preview-hook.js +45 -0
- package/dist/repl.js +1383 -0
- package/dist/router/classifier.js +38 -0
- package/dist/router/intent-extractor.js +140 -0
- package/dist/router/routing-decision.js +19 -0
- package/dist/sap/connection-manager.js +52 -0
- package/dist/sap/onboarding.js +178 -0
- package/dist/sap/system-info.js +515 -0
- package/dist/session/awaiting-answer.js +73 -0
- package/dist/session/gc.js +28 -0
- package/dist/session/pending.js +37 -0
- package/dist/session/resume.js +77 -0
- package/dist/session/schema.js +20 -0
- package/dist/session/store.js +147 -0
- package/dist/session/time-ago.js +41 -0
- package/dist/skill-catalog.js +222 -0
- package/dist/skills/bundled-skills.js +1 -0
- package/dist/skills/canonical.js +12 -0
- package/dist/skills/manifest-client.js +93 -0
- package/dist/skills/promotion-dispatch.js +24 -0
- package/dist/skills/signing-public-key.js +4 -0
- package/dist/skills/source-bundled.js +20 -0
- package/dist/skills/source-managed.js +26 -0
- package/dist/skills/source-manifest.js +26 -0
- package/dist/tools/_command-shared.js +110 -0
- package/dist/tools/_filesystem-shared.js +81 -0
- package/dist/tools/_flag.js +39 -0
- package/dist/tools/approval.js +228 -0
- package/dist/tools/ask-question.js +205 -0
- package/dist/tools/dispatch-skill.js +81 -0
- package/dist/tools/filesystem/file-edit.js +140 -0
- package/dist/tools/filesystem/file-read.js +89 -0
- package/dist/tools/filesystem/file-write.js +128 -0
- package/dist/tools/filesystem/glob.js +177 -0
- package/dist/tools/filesystem/grep.js +163 -0
- package/dist/tools/index.js +32 -0
- package/dist/tools/project/convention_get.js +91 -0
- package/dist/tools/project/playbook_get.js +132 -0
- package/dist/tools/project/project_context_get.js +101 -0
- package/dist/tools/sap-read.js +454 -0
- package/dist/tools/sap-write.js +746 -0
- package/dist/tools/shell/shell_exec.js +209 -0
- package/dist/tools/snapshot.js +107 -0
- package/dist/tools/subagent/_background-shared.js +133 -0
- package/dist/tools/subagent/agent_run.js +186 -0
- package/dist/tools/subagent/background_run.js +143 -0
- package/dist/tools/subagent/monitor_emit.js +65 -0
- package/dist/tools/subagent/schedule_create.js +131 -0
- package/dist/tools/transport.js +233 -0
- package/dist/tools/update-method-intercept.js +119 -0
- package/dist/tools/verify.js +39 -0
- package/dist/tools/web/_web-shared.js +251 -0
- package/dist/tools/web/web_fetch.js +257 -0
- package/dist/tools/web/web_search.js +195 -0
- package/dist/tools/write-mode.js +22 -0
- package/dist/ui/app.js +95 -0
- package/dist/ui/approval-emitter.js +10 -0
- package/dist/ui/approval-modal.js +53 -0
- package/dist/ui/ascii-chars.js +6 -0
- package/dist/ui/body.js +102 -0
- package/dist/ui/coaching-picker-classic.js +36 -0
- package/dist/ui/coaching-picker-emitter.js +27 -0
- package/dist/ui/command-palette.js +34 -0
- package/dist/ui/error-emitter.js +21 -0
- package/dist/ui/footer.js +103 -0
- package/dist/ui/header.js +17 -0
- package/dist/ui/ink-classifier-route.js +19 -0
- package/dist/ui/login-banner.js +72 -0
- package/dist/ui/rich-error-box.js +9 -0
- package/dist/ui/sap-state-store.js +65 -0
- package/dist/ui/session-timeline.js +31 -0
- package/dist/ui/sidebar.js +10 -0
- package/dist/ui/skill-picker.js +50 -0
- package/dist/ui/status-row.js +12 -0
- package/dist/ui/widget-control.js +4 -0
- package/dist/ui/widgets/bar-chart.js +15 -0
- package/dist/ui/widgets/coaching-picker.js +41 -0
- package/dist/ui/widgets/component-registry.js +12 -0
- package/dist/ui/widgets/dep-graph.js +9 -0
- package/dist/ui/widgets/diff-viewer.js +11 -0
- package/dist/ui/widgets/question-card.js +11 -0
- package/dist/ui/widgets/stack-frames.js +5 -0
- package/dist/upgrade-check.js +28 -0
- package/dist/upgrade.js +13 -0
- package/package.json +83 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 2.1 of Track B — telemetry posthoc analyzer.
|
|
3
|
+
*
|
|
4
|
+
* Reads every persisted session JSON under ~/.cspeach/sessions/, groups
|
|
5
|
+
* sessions by whether the FIRST user message contains a
|
|
6
|
+
* `<project_context>` block (i.e. CSPEACH_PROJECT_CONTEXT was on for
|
|
7
|
+
* the session), and reports averaged usage + tool-call counts.
|
|
8
|
+
*
|
|
9
|
+
* This is the dogfooding-grade telemetry the technical plan calls for —
|
|
10
|
+
* built without new infrastructure. The numbers tell us whether the
|
|
11
|
+
* flag actually changes how the model behaves: does it ask fewer
|
|
12
|
+
* clarifying questions? does it call fewer tools because it already
|
|
13
|
+
* knows what files exist? does it use more tokens? Until we have
|
|
14
|
+
* dogfooding data, the case for flipping the default is unsupported.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
*
|
|
18
|
+
* cspeach analyze project-context
|
|
19
|
+
*
|
|
20
|
+
* Read-only. No mutation, no network. Sessions older than the cutoff
|
|
21
|
+
* (--since flag, default: all) are skipped.
|
|
22
|
+
*/
|
|
23
|
+
import fs from 'node:fs/promises';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import chalk from 'chalk';
|
|
26
|
+
import { sessionsDir } from '../config/paths.js';
|
|
27
|
+
/**
|
|
28
|
+
* Read all session JSONs in `dir` (defaults to sessionsDir()), classify
|
|
29
|
+
* each by whether the flag was on, and produce a comparison report.
|
|
30
|
+
*/
|
|
31
|
+
export async function analyzeProjectContextImpact(options = {}) {
|
|
32
|
+
const dir = options.dir ?? sessionsDir();
|
|
33
|
+
const sinceMs = options.since ? Date.parse(options.since) : null;
|
|
34
|
+
const sessions = await readAllSessions(dir);
|
|
35
|
+
let skipped = 0;
|
|
36
|
+
const metrics = [];
|
|
37
|
+
for (const s of sessions) {
|
|
38
|
+
if (s === null) {
|
|
39
|
+
skipped++;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (sinceMs !== null) {
|
|
43
|
+
const startedMs = Date.parse(s.started_at);
|
|
44
|
+
if (Number.isFinite(startedMs) && startedMs < sinceMs)
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
metrics.push(toMetrics(s));
|
|
48
|
+
}
|
|
49
|
+
const flagOn = metrics.filter((m) => m.flagOn);
|
|
50
|
+
const flagOff = metrics.filter((m) => !m.flagOn);
|
|
51
|
+
return {
|
|
52
|
+
totalSessions: metrics.length,
|
|
53
|
+
skippedSessions: skipped,
|
|
54
|
+
flagOn: aggregate(flagOn),
|
|
55
|
+
flagOff: aggregate(flagOff),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Render the report as a human-readable text block. Used by the CLI
|
|
60
|
+
* command. Returns a multi-line string ready to print.
|
|
61
|
+
*/
|
|
62
|
+
export function renderImpactReport(report) {
|
|
63
|
+
const lines = [];
|
|
64
|
+
lines.push(chalk.bold('Project-context impact analysis'));
|
|
65
|
+
lines.push('');
|
|
66
|
+
lines.push(`Sessions analyzed: ${report.totalSessions} (${report.skippedSessions} skipped)`);
|
|
67
|
+
lines.push(` flag ON : ${report.flagOn.count} sessions`);
|
|
68
|
+
lines.push(` flag OFF : ${report.flagOff.count} sessions`);
|
|
69
|
+
lines.push('');
|
|
70
|
+
if (report.flagOn.count === 0 || report.flagOff.count === 0) {
|
|
71
|
+
lines.push(chalk.dim('Need at least one session in each bucket to compare. Run with the flag on AND off to populate both.'));
|
|
72
|
+
return lines.join('\n');
|
|
73
|
+
}
|
|
74
|
+
lines.push(chalk.bold('Per-session averages'));
|
|
75
|
+
lines.push('');
|
|
76
|
+
lines.push(formatRow('metric', 'flag OFF', 'flag ON', 'delta'));
|
|
77
|
+
lines.push(formatRow('—'.repeat(20), '—'.repeat(12), '—'.repeat(12), '—'.repeat(12)));
|
|
78
|
+
lines.push(deltaRow('input tokens', report.flagOff.avgInputTokens, report.flagOn.avgInputTokens));
|
|
79
|
+
lines.push(deltaRow('output tokens', report.flagOff.avgOutputTokens, report.flagOn.avgOutputTokens));
|
|
80
|
+
lines.push(deltaRow('cache read tokens', report.flagOff.avgCacheReadTokens, report.flagOn.avgCacheReadTokens));
|
|
81
|
+
lines.push(deltaRow('tool calls', report.flagOff.avgToolCalls, report.flagOn.avgToolCalls));
|
|
82
|
+
lines.push(deltaRow('turn count', report.flagOff.avgTurns, report.flagOn.avgTurns));
|
|
83
|
+
lines.push(deltaRow('error tool rate', report.flagOff.errorToolRate, report.flagOn.errorToolRate, /* asPct */ true));
|
|
84
|
+
lines.push('');
|
|
85
|
+
lines.push(chalk.bold('Top skills (flag OFF)'));
|
|
86
|
+
for (const s of report.flagOff.topSkills)
|
|
87
|
+
lines.push(` ${s.n.toString().padStart(4)} ${s.skill}`);
|
|
88
|
+
lines.push('');
|
|
89
|
+
lines.push(chalk.bold('Top skills (flag ON)'));
|
|
90
|
+
for (const s of report.flagOn.topSkills)
|
|
91
|
+
lines.push(` ${s.n.toString().padStart(4)} ${s.skill}`);
|
|
92
|
+
lines.push('');
|
|
93
|
+
lines.push(chalk.dim('Note: averages are per-session, not per-turn. Sessions vary in length.'));
|
|
94
|
+
return lines.join('\n');
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* CLI entry point — reads optional --since and --dir flags from argv,
|
|
98
|
+
* invokes the analyzer, prints the rendered report.
|
|
99
|
+
*/
|
|
100
|
+
export async function runProjectContextImpactCommand(args) {
|
|
101
|
+
const since = pickFlag(args, '--since');
|
|
102
|
+
const dir = pickFlag(args, '--dir');
|
|
103
|
+
const report = await analyzeProjectContextImpact({
|
|
104
|
+
since: since ?? undefined,
|
|
105
|
+
dir: dir ?? undefined,
|
|
106
|
+
});
|
|
107
|
+
console.log(renderImpactReport(report));
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
/* ── Internals ─────────────────────────────────────────────────────── */
|
|
111
|
+
async function readAllSessions(dir) {
|
|
112
|
+
let files;
|
|
113
|
+
try {
|
|
114
|
+
files = await fs.readdir(dir);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const f of files) {
|
|
121
|
+
if (!f.endsWith('.json'))
|
|
122
|
+
continue;
|
|
123
|
+
try {
|
|
124
|
+
const raw = await fs.readFile(path.join(dir, f), 'utf-8');
|
|
125
|
+
const parsed = JSON.parse(raw);
|
|
126
|
+
out.push(parsed);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
out.push(null);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
function toMetrics(s) {
|
|
135
|
+
const firstUser = s.messages.find((m) => m.role === 'user');
|
|
136
|
+
const flagOn = firstUser !== undefined &&
|
|
137
|
+
typeof firstUser.content === 'string' &&
|
|
138
|
+
firstUser.content.includes('<project_context');
|
|
139
|
+
// Count "turns" as user messages — each user message kicks off a model turn.
|
|
140
|
+
const turnCount = s.messages.filter((m) => m.role === 'user').length;
|
|
141
|
+
const usage = s.usage ?? { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 };
|
|
142
|
+
const completedTools = s.toolCalls.filter((tc) => tc.completed === true);
|
|
143
|
+
return {
|
|
144
|
+
id: s.id,
|
|
145
|
+
startedAt: s.started_at,
|
|
146
|
+
flagOn,
|
|
147
|
+
skill: s.last_skill ?? s.skill ?? null,
|
|
148
|
+
inputTokens: usage.input_tokens ?? 0,
|
|
149
|
+
outputTokens: usage.output_tokens ?? 0,
|
|
150
|
+
cacheReadTokens: usage.cache_read_input_tokens ?? 0,
|
|
151
|
+
toolCallCount: completedTools.length,
|
|
152
|
+
hadErrorTool: completedTools.some((tc) => tc.completed === true && tc.is_error === true),
|
|
153
|
+
turnCount,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function aggregate(metrics) {
|
|
157
|
+
const n = metrics.length;
|
|
158
|
+
if (n === 0) {
|
|
159
|
+
return {
|
|
160
|
+
count: 0,
|
|
161
|
+
avgInputTokens: 0,
|
|
162
|
+
avgOutputTokens: 0,
|
|
163
|
+
avgCacheReadTokens: 0,
|
|
164
|
+
avgToolCalls: 0,
|
|
165
|
+
avgTurns: 0,
|
|
166
|
+
errorToolRate: 0,
|
|
167
|
+
topSkills: [],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
const sum = (pick) => metrics.reduce((s, m) => s + pick(m), 0);
|
|
171
|
+
const skillCounts = new Map();
|
|
172
|
+
for (const m of metrics) {
|
|
173
|
+
const k = m.skill ?? '(none)';
|
|
174
|
+
skillCounts.set(k, (skillCounts.get(k) ?? 0) + 1);
|
|
175
|
+
}
|
|
176
|
+
const topSkills = [...skillCounts.entries()]
|
|
177
|
+
.map(([skill, n]) => ({ skill, n }))
|
|
178
|
+
.sort((a, b) => b.n - a.n)
|
|
179
|
+
.slice(0, 5);
|
|
180
|
+
return {
|
|
181
|
+
count: n,
|
|
182
|
+
avgInputTokens: round(sum((m) => m.inputTokens) / n),
|
|
183
|
+
avgOutputTokens: round(sum((m) => m.outputTokens) / n),
|
|
184
|
+
avgCacheReadTokens: round(sum((m) => m.cacheReadTokens) / n),
|
|
185
|
+
avgToolCalls: round(sum((m) => m.toolCallCount) / n, 2),
|
|
186
|
+
avgTurns: round(sum((m) => m.turnCount) / n, 2),
|
|
187
|
+
errorToolRate: round(metrics.filter((m) => m.hadErrorTool).length / n, 3),
|
|
188
|
+
topSkills,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function round(n, decimals = 0) {
|
|
192
|
+
const f = Math.pow(10, decimals);
|
|
193
|
+
return Math.round(n * f) / f;
|
|
194
|
+
}
|
|
195
|
+
function formatRow(label, off, on, delta) {
|
|
196
|
+
return ` ${String(label).padEnd(20)} ${String(off).padStart(12)} ${String(on).padStart(12)} ${String(delta).padStart(12)}`;
|
|
197
|
+
}
|
|
198
|
+
function deltaRow(label, off, on, asPct = false) {
|
|
199
|
+
const offS = asPct ? formatPct(off) : off.toLocaleString();
|
|
200
|
+
const onS = asPct ? formatPct(on) : on.toLocaleString();
|
|
201
|
+
const diff = on - off;
|
|
202
|
+
const pctChange = off === 0 ? (on === 0 ? '0%' : '∞') : `${diff >= 0 ? '+' : ''}${round((diff / off) * 100, 1)}%`;
|
|
203
|
+
const diffStr = asPct ? `${diff >= 0 ? '+' : ''}${formatPct(diff)}` : `${diff >= 0 ? '+' : ''}${diff.toLocaleString()} (${pctChange})`;
|
|
204
|
+
return formatRow(label, offS, onS, diffStr);
|
|
205
|
+
}
|
|
206
|
+
function formatPct(n) {
|
|
207
|
+
return `${round(n * 100, 1)}%`;
|
|
208
|
+
}
|
|
209
|
+
function pickFlag(args, name) {
|
|
210
|
+
const idx = args.indexOf(name);
|
|
211
|
+
if (idx === -1)
|
|
212
|
+
return null;
|
|
213
|
+
const v = args[idx + 1];
|
|
214
|
+
return v && !v.startsWith('--') ? v : null;
|
|
215
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { parseShortcut } from '../router/classifier.js';
|
|
2
|
+
const USAGE = 'Usage: /reroute <skill>. Try /help to see skills.';
|
|
3
|
+
/**
|
|
4
|
+
* Parse "/reroute <skill> [ignored trailing text]" and validate against the
|
|
5
|
+
* catalog + stored last prompt.
|
|
6
|
+
*
|
|
7
|
+
* The normalisation (leading-slash handling, abap- auto-prepend) is done by
|
|
8
|
+
* running the skill token through the shipped parseShortcut() function —
|
|
9
|
+
* reused for consistency with the /skill-name shortcut path.
|
|
10
|
+
*
|
|
11
|
+
* Trailing extra words are tolerated: emit a warning in the dispatch result
|
|
12
|
+
* and proceed. More than one extra token is still dispatched — callers may
|
|
13
|
+
* want to print the warning prominently so the user notices.
|
|
14
|
+
*/
|
|
15
|
+
export function parseAndValidateReroute(rawInput, lastUserPrompt, catalog) {
|
|
16
|
+
const trimmed = rawInput.trim();
|
|
17
|
+
if (trimmed === '/reroute') {
|
|
18
|
+
return { kind: 'error', message: USAGE };
|
|
19
|
+
}
|
|
20
|
+
const rest = trimmed.slice('/reroute '.length).trim();
|
|
21
|
+
if (rest.length === 0) {
|
|
22
|
+
return { kind: 'error', message: USAGE };
|
|
23
|
+
}
|
|
24
|
+
const parts = rest.split(/\s+/);
|
|
25
|
+
const skillToken = parts[0];
|
|
26
|
+
const trailing = parts.slice(1).join(' ');
|
|
27
|
+
// Normalise via parseShortcut by constructing a synthetic "/<skill>" line.
|
|
28
|
+
const syntheticLine = skillToken.startsWith('/') ? skillToken : `/${skillToken}`;
|
|
29
|
+
const parsed = parseShortcut(syntheticLine);
|
|
30
|
+
const skill = parsed.skill;
|
|
31
|
+
if (!skill) {
|
|
32
|
+
// parseShortcut only returns `skill` when the input starts with /X —
|
|
33
|
+
// we always synthesise that, so this branch is defensive only.
|
|
34
|
+
return { kind: 'error', message: USAGE };
|
|
35
|
+
}
|
|
36
|
+
// Validate against catalog
|
|
37
|
+
const found = catalog.some((entry) => entry.name === skill);
|
|
38
|
+
if (!found) {
|
|
39
|
+
return {
|
|
40
|
+
kind: 'error',
|
|
41
|
+
message: `Unknown skill: /${skill}. Try /help to see skills.`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
// Need a prior prompt to reroute
|
|
45
|
+
if (!lastUserPrompt || lastUserPrompt.length === 0) {
|
|
46
|
+
return {
|
|
47
|
+
kind: 'error',
|
|
48
|
+
message: 'No previous prompt to reroute. Type your request first.',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (trailing.length > 0) {
|
|
52
|
+
return {
|
|
53
|
+
kind: 'dispatch',
|
|
54
|
+
skill,
|
|
55
|
+
prompt: lastUserPrompt,
|
|
56
|
+
warning: `ignoring trailing text: "${trailing}" — type a fresh prompt if you meant to change the prompt`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return { kind: 'dispatch', skill, prompt: lastUserPrompt };
|
|
60
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { readProjectFile, renderMultiFileStatus, renderStatus } from '../projects/status.js';
|
|
2
|
+
import { resolveAtTokenAsync, formatProjectFileList } from '../projects/workspace.js';
|
|
3
|
+
/**
|
|
4
|
+
* One-shot pass-through for `/abap-{spec-gap,design,estimate} --status
|
|
5
|
+
* @file1 [@file2 ...]`.
|
|
6
|
+
*
|
|
7
|
+
* Each token is resolved against the workspace folder (~/CSPeach by default),
|
|
8
|
+
* so bare names like `@customer-aging` work without absolute paths.
|
|
9
|
+
*
|
|
10
|
+
* Reads each `.cspeach.json` envelope from disk, validates it, and prints a
|
|
11
|
+
* status summary. Multiple files trigger the multi-file renderer which detects
|
|
12
|
+
* parallel-edit conflicts (same source + same version + diverging canonical
|
|
13
|
+
* SHAs).
|
|
14
|
+
*
|
|
15
|
+
* Intentionally does NOT consume model tokens — the REPL intercepts this
|
|
16
|
+
* command BEFORE dispatching to the agent loop. See `repl.tsx` near the
|
|
17
|
+
* `runTurn` call sites.
|
|
18
|
+
*/
|
|
19
|
+
export async function runSpecGapStatus(args) {
|
|
20
|
+
if (args.files.length === 0) {
|
|
21
|
+
args.log('Usage: /abap-{spec-gap,design,estimate} --status @file1.cspeach.json [@file2 ...]');
|
|
22
|
+
args.log('Bare names work too — e.g. @customer-aging — resolved from the workspace folder.');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
// Resolve each @<token> against the workspace folder. Bail on the first
|
|
26
|
+
// unresolved token rather than silently dropping it from the listing.
|
|
27
|
+
const resolvedPaths = [];
|
|
28
|
+
for (const token of args.files) {
|
|
29
|
+
const r = await resolveAtTokenAsync(token, process.cwd());
|
|
30
|
+
if (r.kind === 'path') {
|
|
31
|
+
resolvedPaths.push(r.path);
|
|
32
|
+
}
|
|
33
|
+
else if (r.kind === 'notFound') {
|
|
34
|
+
args.log(`--status: no file matching '${r.token}' in workspace ${r.workspace}`);
|
|
35
|
+
args.log(`Run /files to see available .cspeach.json files.`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
args.log(`--status: '${token}' is ambiguous — multiple matches:`);
|
|
40
|
+
args.log(formatProjectFileList(r.matches, ''));
|
|
41
|
+
args.log(`Use a more specific @<fragment> to disambiguate.`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const envs = resolvedPaths.map(readProjectFile);
|
|
46
|
+
if (envs.length === 1) {
|
|
47
|
+
args.log(renderStatus(envs[0]));
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
args.log(await renderMultiFileStatus(envs));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cspeach whoami` — print the current account without booting the REPL.
|
|
3
|
+
* Reads auth.json + calls /v1/me; falls back to a "not signed in via auth.json"
|
|
4
|
+
* note when the file is absent. Does NOT inspect keytar (would require Inquirer
|
|
5
|
+
* prompts to handle missing keychain on Windows; out of scope).
|
|
6
|
+
*/
|
|
7
|
+
import { readAuthFile } from '../auth/auth-file.js';
|
|
8
|
+
import { fetchMe } from '../auth/me.js';
|
|
9
|
+
export async function runWhoamiCommand(deps) {
|
|
10
|
+
const auth = await readAuthFile();
|
|
11
|
+
if (!auth) {
|
|
12
|
+
deps.print('Not signed in via cspeach login (no ~/.cspeach/auth.json).');
|
|
13
|
+
deps.print('Your REPL may still authenticate via the keytar-stored key — run `cspeach` to check.');
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
const me = await fetchMe({ proxyUrl: deps.proxyUrl, apiKey: auth.apiKey });
|
|
17
|
+
if (!me.ok) {
|
|
18
|
+
deps.print(`Auth file present but /v1/me returned ${me.status} (${me.error}).`);
|
|
19
|
+
deps.print('Run `cspeach login` to refresh.');
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
const p = me.profile;
|
|
23
|
+
deps.print(`Email: ${p.email}`);
|
|
24
|
+
deps.print(`Plan: ${p.plan}`);
|
|
25
|
+
if (p.trial_mode) {
|
|
26
|
+
deps.print(`Trial: ${p.trial_mode}, ends ${p.trial_ends_at}`);
|
|
27
|
+
if (p.trial_token_cap_input) {
|
|
28
|
+
deps.print(`Tokens: ${p.usage_input_tokens}/${p.trial_token_cap_input} in, ${p.usage_output_tokens}/${p.trial_token_cap_output ?? '∞'} out`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (p.entitlements.enabled_skills.length > 0) {
|
|
32
|
+
deps.print(`Skills: ${p.entitlements.enabled_skills.join(', ')}`);
|
|
33
|
+
}
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import toml from '@iarna/toml';
|
|
3
|
+
import { configFile, cspeachRoot } from './paths.js';
|
|
4
|
+
/**
|
|
5
|
+
* Whitelist guard for WriteMode — rejects typos / stale values from disk so
|
|
6
|
+
* downstream code that trusts the WriteMode type doesn't see garbage.
|
|
7
|
+
* Exported for reuse by `config-set` validation (Task C.6).
|
|
8
|
+
*/
|
|
9
|
+
export function isValidWriteMode(v) {
|
|
10
|
+
return v === 'auto' || v === 'approval-gated' || v === 'advisory-only';
|
|
11
|
+
}
|
|
12
|
+
const DEFAULT_CONFIG = {
|
|
13
|
+
proxy_url: 'https://api.cspeach.dev',
|
|
14
|
+
default_model: 'claude-opus-4-7',
|
|
15
|
+
effort: 'xhigh',
|
|
16
|
+
telemetry: 'minimal',
|
|
17
|
+
sap: {},
|
|
18
|
+
classifier: { safe_mode: false },
|
|
19
|
+
// Default to `classic` — the chalk REPL is the fully-tested v0.2 path.
|
|
20
|
+
// Ink mode (v0.3) is opt-in via `/ui ink` while v0.3.1 polish is pending
|
|
21
|
+
// (see docs/INK-MODE-BUGS.md). Flip this to 'auto' when Ink is production-ready.
|
|
22
|
+
ui: { rendering: 'classic' },
|
|
23
|
+
// Default `approval-gated` — preserves current UX (user approves each write).
|
|
24
|
+
write_mode: 'approval-gated',
|
|
25
|
+
// Default `managed` — lowest-friction entry point; uses the cspeach.dev proxy.
|
|
26
|
+
llm: { mode: 'managed' },
|
|
27
|
+
};
|
|
28
|
+
export async function loadConfig() {
|
|
29
|
+
try {
|
|
30
|
+
const raw = await fs.readFile(configFile(), 'utf-8');
|
|
31
|
+
const parsed = toml.parse(raw);
|
|
32
|
+
// Shallow merge top-level, but deep-merge `ui` and `classifier` so a user
|
|
33
|
+
// config that omits the new `ui.rendering` key still receives the default.
|
|
34
|
+
// Sanitize the optional shell_exec.allow array. The interface types it as
|
|
35
|
+
// string[] but TOML / hand-built input could be anything — for non-array we
|
|
36
|
+
// coerce to [] (defensive), for arrays we filter out non-string entries.
|
|
37
|
+
const allowRaw = parsed.shell_exec?.allow;
|
|
38
|
+
let allow;
|
|
39
|
+
if (Array.isArray(allowRaw)) {
|
|
40
|
+
allow = allowRaw.filter((x) => typeof x === 'string');
|
|
41
|
+
}
|
|
42
|
+
else if (allowRaw === undefined) {
|
|
43
|
+
allow = undefined;
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
allow = [];
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
...DEFAULT_CONFIG,
|
|
50
|
+
...parsed,
|
|
51
|
+
write_mode: isValidWriteMode(parsed.write_mode) ? parsed.write_mode : DEFAULT_CONFIG.write_mode,
|
|
52
|
+
llm: { ...DEFAULT_CONFIG.llm, ...(parsed.llm ?? {}) },
|
|
53
|
+
classifier: { ...DEFAULT_CONFIG.classifier, ...(parsed.classifier ?? {}) },
|
|
54
|
+
ui: { ...DEFAULT_CONFIG.ui, ...(parsed.ui ?? {}) },
|
|
55
|
+
shell_exec: parsed.shell_exec === undefined ? undefined : { allow },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (err?.code === 'ENOENT')
|
|
60
|
+
return DEFAULT_CONFIG;
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function saveConfig(config) {
|
|
65
|
+
await fs.mkdir(cspeachRoot(), { recursive: true });
|
|
66
|
+
await fs.writeFile(configFile(), toml.stringify(config), 'utf-8');
|
|
67
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
export function cspeachRoot() {
|
|
4
|
+
return path.join(os.homedir(), '.cspeach');
|
|
5
|
+
}
|
|
6
|
+
export function configFile() {
|
|
7
|
+
return path.join(cspeachRoot(), 'config.toml');
|
|
8
|
+
}
|
|
9
|
+
export function sessionsDir() {
|
|
10
|
+
return path.join(cspeachRoot(), 'sessions');
|
|
11
|
+
}
|
|
12
|
+
export function pinnedSessionsDir() {
|
|
13
|
+
return path.join(sessionsDir(), 'pinned');
|
|
14
|
+
}
|
|
15
|
+
export function snapshotsDir() {
|
|
16
|
+
return path.join(cspeachRoot(), 'snapshots');
|
|
17
|
+
}
|
|
18
|
+
export function logsDir() {
|
|
19
|
+
return path.join(cspeachRoot(), 'logs');
|
|
20
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP probe helper for doctor checks. Uses Node's `https`/`http` directly so
|
|
3
|
+
* we can honor `sslVerify: false` for self-signed dev systems — Node's
|
|
4
|
+
* built-in `fetch()` doesn't expose a per-request TLS-verify toggle, and
|
|
5
|
+
* setting NODE_TLS_REJECT_UNAUTHORIZED globally is a security footgun.
|
|
6
|
+
*
|
|
7
|
+
* The pattern mirrors `checks/cert.ts` which already uses `tls.connect({
|
|
8
|
+
* rejectUnauthorized: false })`.
|
|
9
|
+
*/
|
|
10
|
+
import https from 'node:https';
|
|
11
|
+
import http from 'node:http';
|
|
12
|
+
/**
|
|
13
|
+
* GET a URL, return status code + body. Never throws — always resolves with a
|
|
14
|
+
* ProbeResult. Network/TLS errors land in `error`; HTTP errors land in `status`.
|
|
15
|
+
*/
|
|
16
|
+
export function probeGet(url, opts) {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
let u;
|
|
19
|
+
try {
|
|
20
|
+
u = new URL(url);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
resolve({ status: 0, body: '', error: `invalid url: ${err.message}` });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const lib = u.protocol === 'https:' ? https : http;
|
|
27
|
+
const headers = {};
|
|
28
|
+
if (opts.auth)
|
|
29
|
+
headers['Authorization'] = `Basic ${Buffer.from(opts.auth).toString('base64')}`;
|
|
30
|
+
if (opts.accept)
|
|
31
|
+
headers['Accept'] = opts.accept;
|
|
32
|
+
const req = lib.get({
|
|
33
|
+
host: u.hostname,
|
|
34
|
+
port: u.port,
|
|
35
|
+
path: u.pathname + u.search,
|
|
36
|
+
timeout: opts.timeoutMs ?? 8000,
|
|
37
|
+
rejectUnauthorized: opts.rejectUnauthorized,
|
|
38
|
+
headers,
|
|
39
|
+
}, (res) => {
|
|
40
|
+
const chunks = [];
|
|
41
|
+
res.on('data', (c) => chunks.push(c));
|
|
42
|
+
res.on('end', () => {
|
|
43
|
+
resolve({
|
|
44
|
+
status: res.statusCode ?? 0,
|
|
45
|
+
body: Buffer.concat(chunks).toString('utf-8'),
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
res.on('error', (err) => resolve({ status: 0, body: '', error: err.message }));
|
|
49
|
+
});
|
|
50
|
+
req.on('error', (err) => resolve({ status: 0, body: '', error: err.message }));
|
|
51
|
+
req.on('timeout', () => {
|
|
52
|
+
req.destroy();
|
|
53
|
+
resolve({ status: 0, body: '', error: `timeout after ${opts.timeoutMs ?? 8000}ms` });
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { loadConfig } from '../../config/loader.js';
|
|
2
|
+
import { getStore } from '../../auth/api-key.js';
|
|
3
|
+
export async function checkAuth() {
|
|
4
|
+
const cfg = await loadConfig();
|
|
5
|
+
const store = await getStore();
|
|
6
|
+
const key = await store.get();
|
|
7
|
+
if (!key)
|
|
8
|
+
return { name: 'auth', ok: false, message: 'no API key configured', hint: 'Run: cspeach (and paste your invite key when prompted)' };
|
|
9
|
+
const r = await fetch(`${cfg.proxy_url}/v1/skills/abap-radar`, { headers: { Authorization: `Bearer ${key}` } });
|
|
10
|
+
if (r.status === 401)
|
|
11
|
+
return { name: 'auth', ok: false, message: 'API key rejected', hint: 'Request a new invite key' };
|
|
12
|
+
if (!r.ok)
|
|
13
|
+
return { name: 'auth', ok: false, message: `HTTP ${r.status}`, hint: 'Proxy error' };
|
|
14
|
+
return { name: 'auth', ok: true, message: 'API key valid' };
|
|
15
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import tls from 'node:tls';
|
|
2
|
+
import { loadConfig } from '../../config/loader.js';
|
|
3
|
+
export async function checkCert() {
|
|
4
|
+
const cfg = await loadConfig();
|
|
5
|
+
const systems = Object.entries(cfg.sap).filter(([_, s]) => s.useSsl);
|
|
6
|
+
if (systems.length === 0)
|
|
7
|
+
return { name: 'cert', ok: true, message: 'n/a (no TLS SAP systems configured)' };
|
|
8
|
+
const results = [];
|
|
9
|
+
for (const [alias, sys] of systems) {
|
|
10
|
+
const info = await new Promise((resolve) => {
|
|
11
|
+
const socket = tls.connect({ host: sys.host, port: sys.port, rejectUnauthorized: false, timeout: 5000 }, () => {
|
|
12
|
+
const cert = socket.getPeerCertificate();
|
|
13
|
+
const authorized = socket.authorized;
|
|
14
|
+
socket.end();
|
|
15
|
+
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 ?? '?'}`);
|
|
18
|
+
});
|
|
19
|
+
socket.on('error', (err) => resolve(`${alias}: ${err.message}`));
|
|
20
|
+
});
|
|
21
|
+
results.push(info);
|
|
22
|
+
}
|
|
23
|
+
return { name: 'cert', ok: true, message: results.join('; ') };
|
|
24
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor check: forge-rules (C.2)
|
|
3
|
+
*
|
|
4
|
+
* Verifies that Rule 7 (auto-snapshot before write) and Rule 10
|
|
5
|
+
* (auto-syntax-check after write) are enforced end-to-end against the
|
|
6
|
+
* customer's SAP system.
|
|
7
|
+
*
|
|
8
|
+
* DEVIATION FROM PLAN: calls AdtClient.setSource directly, NOT the CLI
|
|
9
|
+
* sap_set_source tool handler. Reasons:
|
|
10
|
+
* - The CLI handler requires an approval_id JWT (minted by request_approval).
|
|
11
|
+
* Doctor is non-interactive — minting an internal JWT adds complexity.
|
|
12
|
+
* - A known regression in the CLI handler (memory/project_phase3_lite_regression.md)
|
|
13
|
+
* causes double-dispatch that would always red this check.
|
|
14
|
+
* - Rule 7 (AUTO-SNAPSHOT at ~line 1994) and Rule 10 (AUTO-SYNTAX-CHECK at
|
|
15
|
+
* ~line 2114) live inside AdtClient.setSource itself. Calling AdtClient
|
|
16
|
+
* directly still exercises the full rule pipeline.
|
|
17
|
+
*/
|
|
18
|
+
import { loadConfig } from '../../config/loader.js';
|
|
19
|
+
import { getConnection } from '../../sap/connection-manager.js';
|
|
20
|
+
import { AdtClient, snapshots } from '@cspeach/sap-client';
|
|
21
|
+
const PROBE_NAME = 'Z_CSPEACH_DOCTOR_PROBE';
|
|
22
|
+
const PROBE_PACKAGE = '$TMP';
|
|
23
|
+
const PROBE_SOURCE = `REPORT ${PROBE_NAME}.\nWRITE / 'cspeach doctor probe — safe to delete'.`;
|
|
24
|
+
export async function checkForgeRules() {
|
|
25
|
+
const cfg = await loadConfig();
|
|
26
|
+
const aliases = Object.keys(cfg.sap);
|
|
27
|
+
if (aliases.length === 0) {
|
|
28
|
+
return {
|
|
29
|
+
name: 'forge-rules',
|
|
30
|
+
ok: false,
|
|
31
|
+
skipped: true,
|
|
32
|
+
message: 'no SAP system configured',
|
|
33
|
+
hint: 'Run: cspeach config add-sap',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const alias = aliases[0];
|
|
37
|
+
const conn = await getConnection(alias, cfg.sap[alias]);
|
|
38
|
+
const adt = new AdtClient(conn);
|
|
39
|
+
// Cleanup: delete a leftover probe from a prior failed run, best-effort.
|
|
40
|
+
try {
|
|
41
|
+
await adt.deleteObject('PROG', PROBE_NAME);
|
|
42
|
+
}
|
|
43
|
+
catch { /* best-effort */ }
|
|
44
|
+
try {
|
|
45
|
+
// Step 1: Create the probe shell (PROG in $TMP).
|
|
46
|
+
await adt.createObject('PROG', PROBE_NAME, PROBE_PACKAGE, 'cspeach doctor probe');
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
const msg = String(err);
|
|
50
|
+
if (msg.includes('401') || msg.includes('403')) {
|
|
51
|
+
return {
|
|
52
|
+
name: 'forge-rules',
|
|
53
|
+
ok: false,
|
|
54
|
+
skipped: true,
|
|
55
|
+
message: 'write probe not permitted',
|
|
56
|
+
hint: `User ${alias} can't write to ${PROBE_PACKAGE} — Rule 7/10 not verified. Configure a probe_package or grant write permissions.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
name: 'forge-rules',
|
|
61
|
+
ok: false,
|
|
62
|
+
message: `probe-create failed: ${msg.slice(0, 120)}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
// Capture snapshot count before write.
|
|
67
|
+
const snapsBefore = await snapshots.list('PROG', PROBE_NAME);
|
|
68
|
+
// Step 2: setSource — exercises Rule 7 (auto-snapshot) + Rule 10 (auto-syntax-check)
|
|
69
|
+
// inside AdtClient. Bypasses the CLI handler intentionally — see file header.
|
|
70
|
+
await adt.setSource('PROG', PROBE_NAME, PROBE_SOURCE);
|
|
71
|
+
// Step 3: Verify Rule 7 fired — snapshot count should have increased.
|
|
72
|
+
const snapsAfter = await snapshots.list('PROG', PROBE_NAME);
|
|
73
|
+
if (snapsAfter.length <= snapsBefore.length) {
|
|
74
|
+
return {
|
|
75
|
+
name: 'forge-rules',
|
|
76
|
+
ok: false,
|
|
77
|
+
message: `Rule 7 not enforced: snapshot count did not increase (was ${snapsBefore.length}, now ${snapsAfter.length})`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
// Step 4: Verify Rule 10 fired — syntax was checked. Re-run syntaxCheck and confirm clean.
|
|
81
|
+
const syntax = await adt.syntaxCheck('PROG', PROBE_NAME);
|
|
82
|
+
if (syntax.hasErrors) {
|
|
83
|
+
return {
|
|
84
|
+
name: 'forge-rules',
|
|
85
|
+
ok: false,
|
|
86
|
+
message: `Rule 10: syntax check returned errors on probe: ${syntax.messages[0]?.text ?? 'unknown'}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
name: 'forge-rules',
|
|
91
|
+
ok: true,
|
|
92
|
+
message: `Rule 7 (snapshot) + Rule 10 (syntax check) verified on ${PROBE_NAME}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
// Always cleanup, success or failure.
|
|
97
|
+
try {
|
|
98
|
+
await adt.deleteObject('PROG', PROBE_NAME);
|
|
99
|
+
}
|
|
100
|
+
catch { /* best-effort */ }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { getStore } from '../../auth/api-key.js';
|
|
2
|
+
export async function checkKeychainFallback() {
|
|
3
|
+
const store = await getStore();
|
|
4
|
+
const available = await store.isKeychainAvailable();
|
|
5
|
+
if (available) {
|
|
6
|
+
return { name: 'keychain-fallback', ok: true, message: 'not needed (keychain is available)' };
|
|
7
|
+
}
|
|
8
|
+
return {
|
|
9
|
+
name: 'keychain-fallback',
|
|
10
|
+
ok: true,
|
|
11
|
+
message: 'fallback mode active — per-session prompts for secrets',
|
|
12
|
+
hint: 'API key and SAP passwords will be prompted on each cspeach start',
|
|
13
|
+
};
|
|
14
|
+
}
|