@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,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ask_question` tool — the reliable replacement for text-fence widget
|
|
3
|
+
* emission. Skills call this tool whenever they need a clarifying answer
|
|
4
|
+
* from the user. The CLI pauses the agentic loop, renders the question,
|
|
5
|
+
* collects the user's input via inquirer, and returns the answer as a
|
|
6
|
+
* tool_result for the LLM's next turn.
|
|
7
|
+
*
|
|
8
|
+
* Why this exists: v0.3.1 first tried having skills emit a
|
|
9
|
+
* `cspeach-widget:question` fenced block in their text output. LLMs
|
|
10
|
+
* couldn't emit the exact fence syntax reliably; streaming chunk
|
|
11
|
+
* boundaries split the JSON unpredictably; the CLI normaliser that tried
|
|
12
|
+
* to patch all the edge cases kept introducing new ones. Tool calls are
|
|
13
|
+
* structurally reliable: the API validates the input schema, no parsing
|
|
14
|
+
* needed, no streaming ambiguity.
|
|
15
|
+
*
|
|
16
|
+
* Renders:
|
|
17
|
+
* - kind=text: prompt for free-form answer
|
|
18
|
+
* - kind=choice: arrow-key pick-one via inquirer select
|
|
19
|
+
* - kind=multi: space-to-toggle via inquirer checkbox
|
|
20
|
+
*
|
|
21
|
+
* Returns JSON `{ answer, id, kind, cancelled? }` so the LLM knows what
|
|
22
|
+
* it just got back and can cross-reference the question id.
|
|
23
|
+
*/
|
|
24
|
+
import chalk from 'chalk';
|
|
25
|
+
import { registerTool } from './index.js';
|
|
26
|
+
import { input, select, checkbox } from '@inquirer/prompts';
|
|
27
|
+
import { withInquirer } from '../repl/inquirer-guard.js';
|
|
28
|
+
registerTool({
|
|
29
|
+
name: 'ask_question',
|
|
30
|
+
description: "Ask the user a clarifying question and get their answer. Use this for ANY user-decision point in a skill conversation — object names, design choices, approval checkpoints, etc. The CLI renders a formatted prompt, waits for the user's answer, and returns it as the tool result. Prefer this over emitting a text question — it is the only reliable mechanism for mid-turn user interaction.",
|
|
31
|
+
isMutating: false,
|
|
32
|
+
input_schema: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
id: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
description: 'Short stable identifier for this question (e.g. "env", "names", "output-style"). Used in the tool result so you can cross-reference the answer to the question you asked.',
|
|
38
|
+
},
|
|
39
|
+
question: {
|
|
40
|
+
type: 'string',
|
|
41
|
+
description: 'The question text the user will see. Keep concise — max ~200 chars.',
|
|
42
|
+
},
|
|
43
|
+
context: {
|
|
44
|
+
type: 'string',
|
|
45
|
+
description: "Optional one-line explanation of why this matters. Shown in dim text below the question.",
|
|
46
|
+
},
|
|
47
|
+
kind: {
|
|
48
|
+
type: 'string',
|
|
49
|
+
enum: ['text', 'choice', 'multi'],
|
|
50
|
+
description: '"text" = free-form answer; "choice" = pick exactly one; "multi" = pick several.',
|
|
51
|
+
},
|
|
52
|
+
choices: {
|
|
53
|
+
type: 'array',
|
|
54
|
+
description: 'Required when kind is "choice" or "multi". Omit for "text".',
|
|
55
|
+
items: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
value: { type: 'string', description: 'Machine value returned if the user picks this.' },
|
|
59
|
+
label: { type: 'string', description: 'Human-readable label shown in the picker.' },
|
|
60
|
+
},
|
|
61
|
+
required: ['value', 'label'],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
required: ['id', 'question', 'kind'],
|
|
66
|
+
},
|
|
67
|
+
handler: async (args, _ctx) => {
|
|
68
|
+
const id = String(args.id);
|
|
69
|
+
const question = String(args.question);
|
|
70
|
+
const context = args.context ? String(args.context) : undefined;
|
|
71
|
+
const kind = args.kind;
|
|
72
|
+
const choices = Array.isArray(args.choices)
|
|
73
|
+
? args.choices
|
|
74
|
+
: [];
|
|
75
|
+
// 2026-05-01 redesign: when the LLM provides choices, use a real
|
|
76
|
+
// arrow-key picker (select / checkbox) instead of free-text input.
|
|
77
|
+
// The previous "always free-text, smart-resolve digits" pattern produced
|
|
78
|
+
// user mistakes (e.g. typing "2222" trying for "2") because the prompt
|
|
79
|
+
// didn't communicate that just pressing Enter on a highlighted choice
|
|
80
|
+
// works. select() is unambiguous: ↑↓ navigates, Enter picks. We still
|
|
81
|
+
// expose a "Type a custom answer" option for the LLM-allows-free-text
|
|
82
|
+
// case so flexibility isn't lost.
|
|
83
|
+
console.log('');
|
|
84
|
+
console.log(chalk.bold(question));
|
|
85
|
+
if (context)
|
|
86
|
+
console.log(chalk.dim(context));
|
|
87
|
+
console.log('');
|
|
88
|
+
let answer;
|
|
89
|
+
try {
|
|
90
|
+
if (kind === 'choice' && choices.length > 0) {
|
|
91
|
+
// Arrow-key pick-one. Always include a "type a custom answer" escape
|
|
92
|
+
// so the LLM's free-form-allowed contract still holds.
|
|
93
|
+
const CUSTOM = '__cspeach_custom__';
|
|
94
|
+
const picked = await withInquirer(() => select({
|
|
95
|
+
message: 'Pick an answer:',
|
|
96
|
+
choices: [
|
|
97
|
+
...choices.map((c) => ({ value: c.value, name: c.label })),
|
|
98
|
+
{ value: CUSTOM, name: chalk.dim('(type a custom answer)') },
|
|
99
|
+
],
|
|
100
|
+
}));
|
|
101
|
+
if (picked === CUSTOM) {
|
|
102
|
+
const raw = await withInquirer(() => input({ message: 'Your answer:' }));
|
|
103
|
+
answer = raw.trim();
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
answer = picked;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else if (kind === 'multi' && choices.length > 0) {
|
|
110
|
+
// Space-to-toggle multi-pick. checkbox returns string[] of values.
|
|
111
|
+
const picked = await withInquirer(() => checkbox({
|
|
112
|
+
message: 'Pick one or more (space to toggle, enter to confirm):',
|
|
113
|
+
choices: choices.map((c) => ({ value: c.value, name: c.label })),
|
|
114
|
+
}));
|
|
115
|
+
answer = picked.join(',');
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
// Free-text. kind === 'text' or no choices supplied.
|
|
119
|
+
const raw = await withInquirer(() => input({ message: 'Your answer:' }));
|
|
120
|
+
answer = raw.trim();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
const name = err?.name;
|
|
125
|
+
if (name === 'ExitPromptError') {
|
|
126
|
+
return {
|
|
127
|
+
content: JSON.stringify({ id, kind, answer: null, cancelled: true }),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
if (answer.length === 0) {
|
|
133
|
+
return {
|
|
134
|
+
content: JSON.stringify({ id, kind, answer: null, cancelled: true }),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
console.log(chalk.dim(` → ${answer}`));
|
|
138
|
+
return {
|
|
139
|
+
content: JSON.stringify({ id, kind, answer }),
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
/**
|
|
144
|
+
* Resolve a single typed answer to either a choice value or raw text.
|
|
145
|
+
* Matches (in order):
|
|
146
|
+
* 1. A 1-based index into choices (e.g. "2" → choices[1].value)
|
|
147
|
+
* 2. An exact (case-insensitive) match against choice values or labels
|
|
148
|
+
* 3. Otherwise → the raw input as-is (free text)
|
|
149
|
+
*/
|
|
150
|
+
function resolveSingleAnswer(raw, choices) {
|
|
151
|
+
const asInt = Number.parseInt(raw, 10);
|
|
152
|
+
if (Number.isFinite(asInt) && asInt >= 1 && asInt <= choices.length && String(asInt) === raw) {
|
|
153
|
+
return choices[asInt - 1].value;
|
|
154
|
+
}
|
|
155
|
+
const lower = raw.toLowerCase();
|
|
156
|
+
for (const c of choices) {
|
|
157
|
+
if (c.value.toLowerCase() === lower || c.label.toLowerCase() === lower)
|
|
158
|
+
return c.value;
|
|
159
|
+
}
|
|
160
|
+
return raw;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Resolve a multi-answer input. Accepts:
|
|
164
|
+
* - "1,3,5" or "1 3 5" — comma/space-separated indices → comma-joined values
|
|
165
|
+
* - "vkorg,erdat" — value/label list → comma-joined values
|
|
166
|
+
* - "all" — all choice values
|
|
167
|
+
* - "none" — empty string
|
|
168
|
+
* - anything else → free text as-is
|
|
169
|
+
*/
|
|
170
|
+
function resolveMultiAnswer(raw, choices) {
|
|
171
|
+
const lower = raw.trim().toLowerCase();
|
|
172
|
+
if (lower === 'all')
|
|
173
|
+
return choices.map((c) => c.value).join(',');
|
|
174
|
+
if (lower === 'none' || lower === '')
|
|
175
|
+
return '';
|
|
176
|
+
// Try comma/space split
|
|
177
|
+
const parts = raw.split(/[,\s]+/).map((p) => p.trim()).filter(Boolean);
|
|
178
|
+
if (parts.length === 0)
|
|
179
|
+
return raw;
|
|
180
|
+
const resolved = [];
|
|
181
|
+
let allMatched = true;
|
|
182
|
+
for (const part of parts) {
|
|
183
|
+
const asInt = Number.parseInt(part, 10);
|
|
184
|
+
if (Number.isFinite(asInt) &&
|
|
185
|
+
asInt >= 1 &&
|
|
186
|
+
asInt <= choices.length &&
|
|
187
|
+
String(asInt) === part) {
|
|
188
|
+
resolved.push(choices[asInt - 1].value);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const partLower = part.toLowerCase();
|
|
192
|
+
const hit = choices.find((c) => c.value.toLowerCase() === partLower || c.label.toLowerCase() === partLower);
|
|
193
|
+
if (hit) {
|
|
194
|
+
resolved.push(hit.value);
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
allMatched = false;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (allMatched && resolved.length > 0)
|
|
202
|
+
return resolved.join(',');
|
|
203
|
+
// Fell back — return raw text
|
|
204
|
+
return raw;
|
|
205
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatch_skill — auto-route the next user submission to a slash command
|
|
3
|
+
* the user already explicitly approved via an inquirer picker.
|
|
4
|
+
*
|
|
5
|
+
* UX problem this fixes: when /abap-generate detects a RAP-shaped design
|
|
6
|
+
* and asks "Switch to /abap-rap?" via ask_question, the user picks
|
|
7
|
+
* "Switch" — and was then told "Type `/abap-rap --from @<file>` at the
|
|
8
|
+
* next prompt". Re-typing what they already approved is busywork. The
|
|
9
|
+
* picker IS the consent.
|
|
10
|
+
*
|
|
11
|
+
* Contract:
|
|
12
|
+
* - The skill calls dispatch_skill ONLY after the user has explicitly
|
|
13
|
+
* consented via an inquirer prompt (ask_question, picker, etc.).
|
|
14
|
+
* Never auto-dispatch from prose / TL;DR alone — that's still the
|
|
15
|
+
* user's keypress to make.
|
|
16
|
+
* - The command argument MUST start with a known slash route. Free
|
|
17
|
+
* text without a leading `/` is rejected.
|
|
18
|
+
* - Set the slot via ctx.pendingDispatch.set(command). repl.tsx reads
|
|
19
|
+
* it after runTurn and routes as the next submission.
|
|
20
|
+
* - Skill ends the turn naturally after this tool call. Do not also
|
|
21
|
+
* emit a "Type X at the next prompt" hint — the dispatch supersedes it.
|
|
22
|
+
*
|
|
23
|
+
* The harness clears pendingDispatch at the start of every consume cycle,
|
|
24
|
+
* so a stale dispatch from a prior turn never re-fires.
|
|
25
|
+
*/
|
|
26
|
+
import { registerTool } from './index.js';
|
|
27
|
+
registerTool({
|
|
28
|
+
name: 'dispatch_skill',
|
|
29
|
+
description: 'Queue the next user submission to a slash command the user already explicitly approved via an inquirer picker. Use this in place of telling the user "Type X at the next prompt" — re-typing what they already picked is friction. Only call this AFTER an explicit user consent (ask_question pick, picker confirmation, etc.); never from prose or a one-line suggestion. The command must start with a known slash route (e.g. "/abap-rap --from @<file>"). After this tool returns, end the turn — do not also print a continuation hint.',
|
|
30
|
+
isMutating: false,
|
|
31
|
+
input_schema: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
command: {
|
|
35
|
+
type: 'string',
|
|
36
|
+
description: 'Full slash command including arguments. Must start with `/`. Example: `/abap-rap --from @order-approval-system-design-2924-v1.cspeach.json`.',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
required: ['command'],
|
|
40
|
+
},
|
|
41
|
+
handler: async (args, ctx) => {
|
|
42
|
+
const command = typeof args.command === 'string' ? args.command.trim() : '';
|
|
43
|
+
if (command.length === 0) {
|
|
44
|
+
return {
|
|
45
|
+
is_error: true,
|
|
46
|
+
content: JSON.stringify({
|
|
47
|
+
error: 'DISPATCH_INVALID',
|
|
48
|
+
reason: 'command argument is required and must be a non-empty string',
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (!command.startsWith('/')) {
|
|
53
|
+
return {
|
|
54
|
+
is_error: true,
|
|
55
|
+
content: JSON.stringify({
|
|
56
|
+
error: 'DISPATCH_INVALID',
|
|
57
|
+
reason: `command must start with "/" — got "${command.slice(0, 32)}…"`,
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (!ctx.pendingDispatch) {
|
|
62
|
+
// Non-REPL path (one-shot, tests). Tell the model the dispatch
|
|
63
|
+
// can't take effect here so it falls back to a continuation hint.
|
|
64
|
+
return {
|
|
65
|
+
is_error: true,
|
|
66
|
+
content: JSON.stringify({
|
|
67
|
+
error: 'DISPATCH_UNSUPPORTED',
|
|
68
|
+
reason: 'pendingDispatch not registered in this context (one-shot or test mode); use a continuation hint instead',
|
|
69
|
+
}),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
ctx.pendingDispatch.set(command);
|
|
73
|
+
return {
|
|
74
|
+
content: JSON.stringify({
|
|
75
|
+
ok: true,
|
|
76
|
+
queued: command,
|
|
77
|
+
note: 'The next user submission will run this command automatically. End the turn — do not print a "Type X" hint.',
|
|
78
|
+
}),
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* file_edit — Phase 3 filesystem tool.
|
|
3
|
+
*
|
|
4
|
+
* Exact-match string replacement in a project file. Errors if old_string
|
|
5
|
+
* is not found, or if it appears multiple times and replace_all is false.
|
|
6
|
+
* First mutating Phase 3 tool (isMutating: true).
|
|
7
|
+
*
|
|
8
|
+
* Write is atomic: content goes to <path>.tmp then fs.rename swaps it in.
|
|
9
|
+
* Line endings are preserved — Node reads and writes UTF-8 with no
|
|
10
|
+
* normalization, so whatever endings the file had on disk are kept.
|
|
11
|
+
*
|
|
12
|
+
* Flag-gated: invisible to listTools() unless CSPEACH_TOOL_FILE_EDIT=on.
|
|
13
|
+
*/
|
|
14
|
+
import { promises as fs } from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import { registerTool } from '../index.js';
|
|
17
|
+
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, BLOCKED_PREFIXES, isDenylistedPath, } from '../_filesystem-shared.js';
|
|
18
|
+
export async function fileEditHandler(args, ctx) {
|
|
19
|
+
// 1. Validate args before any IO
|
|
20
|
+
if (args.old_string === args.new_string) {
|
|
21
|
+
return {
|
|
22
|
+
content: 'error: old_string and new_string are identical — no-op edit refused',
|
|
23
|
+
is_error: true,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
// 2. Path containment — sync phase (pure string math)
|
|
27
|
+
let abs;
|
|
28
|
+
try {
|
|
29
|
+
abs = resolveSafePath(ctx.cwd, args.path);
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
if (err instanceof PathOutsideRootError) {
|
|
33
|
+
return { content: `error: ${err.message}`, is_error: true };
|
|
34
|
+
}
|
|
35
|
+
return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
|
|
36
|
+
}
|
|
37
|
+
// 3. Path containment — async phase (follows symlinks)
|
|
38
|
+
let realAbs;
|
|
39
|
+
try {
|
|
40
|
+
realAbs = await assertRealPathContained(abs, ctx.cwd);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
if (err instanceof PathOutsideRootError) {
|
|
44
|
+
return { content: `error: ${err.message} (after symlink resolution)`, is_error: true };
|
|
45
|
+
}
|
|
46
|
+
if (err.code === 'ENOENT') {
|
|
47
|
+
return { content: 'error: file not found', is_error: true };
|
|
48
|
+
}
|
|
49
|
+
return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
|
|
50
|
+
}
|
|
51
|
+
// 4. Denylist check: refuse sensitive in-root paths
|
|
52
|
+
const denylist = isDenylistedPath(realAbs, ctx.cwd);
|
|
53
|
+
if (denylist.blocked) {
|
|
54
|
+
return {
|
|
55
|
+
content: `error: refusing to edit sensitive path "${denylist.relFromRoot}" (denylist: ${BLOCKED_PREFIXES.join(', ')})`,
|
|
56
|
+
is_error: true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// 5. Read the file
|
|
60
|
+
let content;
|
|
61
|
+
try {
|
|
62
|
+
content = await fs.readFile(realAbs, 'utf-8');
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
return { content: `error: ${err.code === 'ENOENT' ? 'file not found' : err.message}`, is_error: true };
|
|
66
|
+
}
|
|
67
|
+
// 6. Count occurrences of old_string
|
|
68
|
+
let count = 0;
|
|
69
|
+
let searchFrom = 0;
|
|
70
|
+
while (true) {
|
|
71
|
+
const idx = content.indexOf(args.old_string, searchFrom);
|
|
72
|
+
if (idx === -1)
|
|
73
|
+
break;
|
|
74
|
+
count++;
|
|
75
|
+
searchFrom = idx + args.old_string.length;
|
|
76
|
+
}
|
|
77
|
+
if (count === 0) {
|
|
78
|
+
return {
|
|
79
|
+
content: `error: old_string not found in "${args.path}"`,
|
|
80
|
+
is_error: true,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (count > 1 && !args.replace_all) {
|
|
84
|
+
return {
|
|
85
|
+
content: `error: old_string matches ${count} times in "${args.path}"; ` +
|
|
86
|
+
`pass replace_all: true to replace all or make old_string more specific`,
|
|
87
|
+
is_error: true,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// 7. Perform replacement
|
|
91
|
+
const newContent = args.replace_all
|
|
92
|
+
? content.replaceAll(args.old_string, args.new_string)
|
|
93
|
+
: content.replace(args.old_string, args.new_string);
|
|
94
|
+
const replacementCount = args.replace_all ? count : 1;
|
|
95
|
+
// 8. Atomic write: tmp file then rename
|
|
96
|
+
const tmpPath = realAbs + '.tmp';
|
|
97
|
+
try {
|
|
98
|
+
await fs.writeFile(tmpPath, newContent, 'utf-8');
|
|
99
|
+
await fs.rename(tmpPath, realAbs);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
// Best-effort cleanup of tmp file on failure
|
|
103
|
+
await fs.unlink(tmpPath).catch(() => undefined);
|
|
104
|
+
return { content: `error: write failed — ${err.message}`, is_error: true };
|
|
105
|
+
}
|
|
106
|
+
const noun = replacementCount === 1 ? 'replacement' : 'replacements';
|
|
107
|
+
const relPath = path.relative(path.resolve(ctx.cwd), realAbs).replace(/\\/g, '/');
|
|
108
|
+
return { content: `edited ${relPath} (${replacementCount} ${noun})` };
|
|
109
|
+
}
|
|
110
|
+
registerTool({
|
|
111
|
+
name: 'file_edit',
|
|
112
|
+
description: 'Exact-match string replacement in a project file. ' +
|
|
113
|
+
'Errors if old_string is not found, or if it matches multiple times without replace_all: true.',
|
|
114
|
+
isMutating: true,
|
|
115
|
+
category: 'filesystem',
|
|
116
|
+
flagGated: true,
|
|
117
|
+
input_schema: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: {
|
|
120
|
+
path: {
|
|
121
|
+
type: 'string',
|
|
122
|
+
description: 'Path relative to the project root, or an absolute path inside the project.',
|
|
123
|
+
},
|
|
124
|
+
old_string: {
|
|
125
|
+
type: 'string',
|
|
126
|
+
description: 'The exact string to find and replace.',
|
|
127
|
+
},
|
|
128
|
+
new_string: {
|
|
129
|
+
type: 'string',
|
|
130
|
+
description: 'The replacement string.',
|
|
131
|
+
},
|
|
132
|
+
replace_all: {
|
|
133
|
+
type: 'boolean',
|
|
134
|
+
description: 'When true, replace every occurrence. Default false (errors if more than one match).',
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
required: ['path', 'old_string', 'new_string'],
|
|
138
|
+
},
|
|
139
|
+
handler: fileEditHandler,
|
|
140
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* file_read — Phase 3 filesystem tool.
|
|
3
|
+
*
|
|
4
|
+
* Reads a file from the project and returns content with cat -n style
|
|
5
|
+
* line numbers (matching the existing CSPeach Read tool convention).
|
|
6
|
+
* Hard cap at 2000 lines per call; offset + limit support windowed
|
|
7
|
+
* reads of large files. Path containment via resolveSafePath.
|
|
8
|
+
*
|
|
9
|
+
* Flag-gated: invisible to listTools() unless CSPEACH_TOOL_FILE_READ=on.
|
|
10
|
+
*/
|
|
11
|
+
import { promises as fs } from 'node:fs';
|
|
12
|
+
import { registerTool } from '../index.js';
|
|
13
|
+
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, BLOCKED_PREFIXES, isDenylistedPath } from '../_filesystem-shared.js';
|
|
14
|
+
const MAX_LINES = 2000;
|
|
15
|
+
export async function fileReadHandler(args, ctx) {
|
|
16
|
+
// Validate offset and limit BEFORE clamping
|
|
17
|
+
if (args.offset !== undefined && args.offset < 0) {
|
|
18
|
+
return { content: `error: offset must be non-negative (got ${args.offset})`, is_error: true };
|
|
19
|
+
}
|
|
20
|
+
if (args.limit !== undefined && args.limit < 1) {
|
|
21
|
+
return { content: `error: limit must be a positive integer (got ${args.limit})`, is_error: true };
|
|
22
|
+
}
|
|
23
|
+
let abs;
|
|
24
|
+
try {
|
|
25
|
+
abs = resolveSafePath(ctx.cwd, args.path);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
if (err instanceof PathOutsideRootError) {
|
|
29
|
+
return { content: `error: ${err.message}`, is_error: true };
|
|
30
|
+
}
|
|
31
|
+
return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
|
|
32
|
+
}
|
|
33
|
+
let realAbs;
|
|
34
|
+
try {
|
|
35
|
+
realAbs = await assertRealPathContained(abs, ctx.cwd);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
if (err instanceof PathOutsideRootError) {
|
|
39
|
+
return { content: `error: ${err.message} (after symlink resolution)`, is_error: true };
|
|
40
|
+
}
|
|
41
|
+
if (err.code === 'ENOENT') {
|
|
42
|
+
return { content: 'error: file not found', is_error: true };
|
|
43
|
+
}
|
|
44
|
+
return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
|
|
45
|
+
}
|
|
46
|
+
// Denylist check: refuse sensitive in-root paths
|
|
47
|
+
const denylist = isDenylistedPath(realAbs, ctx.cwd);
|
|
48
|
+
if (denylist.blocked) {
|
|
49
|
+
return {
|
|
50
|
+
content: `error: refusing to read sensitive path "${denylist.relFromRoot}" (denylist: ${BLOCKED_PREFIXES.join(', ')})`,
|
|
51
|
+
is_error: true,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = await fs.readFile(realAbs, 'utf-8');
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
return { content: `error: ${err.code === 'ENOENT' ? 'file not found' : err.message}`, is_error: true };
|
|
60
|
+
}
|
|
61
|
+
// Strip trailing newline before split to avoid phantom blank last line
|
|
62
|
+
const allLines = raw.replace(/\n$/, '').split('\n');
|
|
63
|
+
const offset = Math.max(0, args.offset ?? 0);
|
|
64
|
+
const limit = Math.min(args.limit ?? MAX_LINES, MAX_LINES);
|
|
65
|
+
const window = allLines.slice(offset, offset + limit);
|
|
66
|
+
const numbered = window.map((line, i) => `${offset + i + 1}\t${line}`).join('\n');
|
|
67
|
+
const truncated = allLines.length > offset + window.length;
|
|
68
|
+
const trailer = truncated
|
|
69
|
+
? `\n... (truncated, ${allLines.length - (offset + window.length)} more lines — re-read with offset=${offset + window.length})`
|
|
70
|
+
: '';
|
|
71
|
+
return { content: numbered + trailer };
|
|
72
|
+
}
|
|
73
|
+
registerTool({
|
|
74
|
+
name: 'file_read',
|
|
75
|
+
description: 'Read a file from the project. Returns content with line numbers. Optional offset + limit for windowed reads of large files.',
|
|
76
|
+
isMutating: false,
|
|
77
|
+
category: 'filesystem',
|
|
78
|
+
flagGated: true,
|
|
79
|
+
input_schema: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: {
|
|
82
|
+
path: { type: 'string', description: 'Path relative to the project root, or an absolute path inside the project.' },
|
|
83
|
+
offset: { type: 'number', description: 'Optional 0-based line offset.' },
|
|
84
|
+
limit: { type: 'number', description: 'Optional max lines to return (cap 2000).' },
|
|
85
|
+
},
|
|
86
|
+
required: ['path'],
|
|
87
|
+
},
|
|
88
|
+
handler: fileReadHandler,
|
|
89
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* file_write — Phase 3 filesystem tool.
|
|
3
|
+
*
|
|
4
|
+
* Creates a new file or overwrites an existing one. Unlike file_edit which
|
|
5
|
+
* requires the file to exist, file_write is the canonical "put this content
|
|
6
|
+
* at this path" primitive. Parent directories are auto-created so the model
|
|
7
|
+
* can write to fresh subdirectories without a separate mkdir step.
|
|
8
|
+
*
|
|
9
|
+
* Write is atomic: content goes to <path>.tmp then fs.rename swaps it in so
|
|
10
|
+
* a crash mid-write never leaves a partial file at the target path.
|
|
11
|
+
*
|
|
12
|
+
* Path containment via resolveSafePath + assertRealPathContained. For
|
|
13
|
+
* non-existent target files (the common create case), assertRealPathContained
|
|
14
|
+
* would throw ENOENT because fs.realpath requires the path to exist. In that
|
|
15
|
+
* case we fall back to verifying the parent directory is contained instead,
|
|
16
|
+
* using the original abs path (from resolveSafePath) as the write target.
|
|
17
|
+
*
|
|
18
|
+
* Flag-gated: invisible to listTools() unless CSPEACH_TOOL_FILE_WRITE=on.
|
|
19
|
+
*/
|
|
20
|
+
import { promises as fs } from 'node:fs';
|
|
21
|
+
import * as path from 'node:path';
|
|
22
|
+
import { registerTool } from '../index.js';
|
|
23
|
+
import { resolveSafePath, assertRealPathContained, PathOutsideRootError, BLOCKED_PREFIXES, isDenylistedPath, } from '../_filesystem-shared.js';
|
|
24
|
+
export async function fileWriteHandler(args, ctx) {
|
|
25
|
+
// 1. Path containment — sync phase (pure string math, catches .. traversals)
|
|
26
|
+
let abs;
|
|
27
|
+
try {
|
|
28
|
+
abs = resolveSafePath(ctx.cwd, args.path);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
if (err instanceof PathOutsideRootError) {
|
|
32
|
+
return { content: `error: ${err.message}`, is_error: true };
|
|
33
|
+
}
|
|
34
|
+
return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
|
|
35
|
+
}
|
|
36
|
+
// 2. Path containment — async phase (follows symlinks to catch symlink escapes).
|
|
37
|
+
// For non-existent files (the common create case), fs.realpath throws ENOENT.
|
|
38
|
+
// In that case, verify the parent directory is contained instead.
|
|
39
|
+
let target;
|
|
40
|
+
try {
|
|
41
|
+
target = await assertRealPathContained(abs, ctx.cwd);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
if (err.code === 'ENOENT') {
|
|
45
|
+
// Target file doesn't exist yet (create case) — verify parent is contained.
|
|
46
|
+
const parent = path.dirname(abs);
|
|
47
|
+
try {
|
|
48
|
+
await assertRealPathContained(parent, ctx.cwd);
|
|
49
|
+
}
|
|
50
|
+
catch (parentErr) {
|
|
51
|
+
if (parentErr.code === 'ENOENT') {
|
|
52
|
+
// Parent doesn't exist either (will be mkdir -p'd) — verify via string math.
|
|
53
|
+
const relFromRoot = path.relative(path.resolve(ctx.cwd), parent);
|
|
54
|
+
if (relFromRoot.startsWith('..') || path.isAbsolute(relFromRoot)) {
|
|
55
|
+
return { content: 'error: parent directory escapes project root', is_error: true };
|
|
56
|
+
}
|
|
57
|
+
// Parent is fine — use the original abs as write target.
|
|
58
|
+
}
|
|
59
|
+
else if (parentErr instanceof PathOutsideRootError) {
|
|
60
|
+
return { content: `error: ${parentErr.message}`, is_error: true };
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
return { content: `error: ${parentErr.message}`, is_error: true };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Use the original abs path (resolveSafePath result) as write target.
|
|
67
|
+
target = abs;
|
|
68
|
+
}
|
|
69
|
+
else if (err instanceof PathOutsideRootError) {
|
|
70
|
+
return { content: `error: ${err.message} (after symlink resolution)`, is_error: true };
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// 3. Denylist check: refuse sensitive in-root paths
|
|
77
|
+
const denylist = isDenylistedPath(target, ctx.cwd);
|
|
78
|
+
if (denylist.blocked) {
|
|
79
|
+
return {
|
|
80
|
+
content: `error: refusing to write sensitive path "${denylist.relFromRoot}" (denylist: ${BLOCKED_PREFIXES.join(', ')})`,
|
|
81
|
+
is_error: true,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
// 4. Ensure parent directory exists (no-op if it already does)
|
|
85
|
+
try {
|
|
86
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
return { content: `error: could not create parent directory — ${err.message}`, is_error: true };
|
|
90
|
+
}
|
|
91
|
+
// 5. Atomic write: tmp file then rename
|
|
92
|
+
const tmpPath = target + '.tmp';
|
|
93
|
+
try {
|
|
94
|
+
await fs.writeFile(tmpPath, args.content, 'utf-8');
|
|
95
|
+
await fs.rename(tmpPath, target);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
// Best-effort cleanup of tmp file on failure
|
|
99
|
+
await fs.unlink(tmpPath).catch(() => undefined);
|
|
100
|
+
return { content: `error: write failed — ${err.message}`, is_error: true };
|
|
101
|
+
}
|
|
102
|
+
const relPath = path.relative(path.resolve(ctx.cwd), target).replace(/\\/g, '/');
|
|
103
|
+
const bytes = Buffer.byteLength(args.content, 'utf-8');
|
|
104
|
+
return { content: `wrote ${relPath} (${bytes} bytes)` };
|
|
105
|
+
}
|
|
106
|
+
registerTool({
|
|
107
|
+
name: 'file_write',
|
|
108
|
+
description: 'Create a new file or overwrite an existing one with the given content. ' +
|
|
109
|
+
'Parent directories are created automatically. Uses an atomic write (tmp + rename).',
|
|
110
|
+
isMutating: true,
|
|
111
|
+
category: 'filesystem',
|
|
112
|
+
flagGated: true,
|
|
113
|
+
input_schema: {
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: {
|
|
116
|
+
path: {
|
|
117
|
+
type: 'string',
|
|
118
|
+
description: 'Path relative to the project root, or an absolute path inside the project.',
|
|
119
|
+
},
|
|
120
|
+
content: {
|
|
121
|
+
type: 'string',
|
|
122
|
+
description: 'The full content to write to the file.',
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
required: ['path', 'content'],
|
|
126
|
+
},
|
|
127
|
+
handler: fileWriteHandler,
|
|
128
|
+
});
|