@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,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* glob — Phase 3 filesystem tool.
|
|
3
|
+
*
|
|
4
|
+
* Finds files in the project matching a pattern. Returns paths sorted by
|
|
5
|
+
* modification time descending (most recent first). Cap at 500 results;
|
|
6
|
+
* honors .gitignore and the shared sensitive-path denylist.
|
|
7
|
+
*
|
|
8
|
+
* Pattern support (deliberately minimal — Node fs + a hand-rolled regex,
|
|
9
|
+
* no external glob dependency):
|
|
10
|
+
* * — any chars within a single path segment
|
|
11
|
+
* ** — any number of path segments (including zero)
|
|
12
|
+
* ? — single char within a single path segment
|
|
13
|
+
*
|
|
14
|
+
* Other glob features (character classes, brace expansion, !-negation) are
|
|
15
|
+
* not supported. The model can compose multiple glob calls if it needs them.
|
|
16
|
+
*
|
|
17
|
+
* Flag-gated: invisible to listTools() unless CSPEACH_TOOL_GLOB=on.
|
|
18
|
+
*/
|
|
19
|
+
import { promises as fs } from 'node:fs';
|
|
20
|
+
import * as path from 'node:path';
|
|
21
|
+
import { registerTool } from '../index.js';
|
|
22
|
+
import { resolveSafePath, PathOutsideRootError, isDenylistedPath } from '../_filesystem-shared.js';
|
|
23
|
+
const MAX_RESULTS = 500;
|
|
24
|
+
const MAX_FILES_TRAVERSED = 10_000;
|
|
25
|
+
const ALWAYS_SKIPPED_DIRS = new Set([
|
|
26
|
+
'node_modules', '.git', 'dist', 'build', '.cspeach', '.cspeach-design', '.abapforge',
|
|
27
|
+
'.next', '.turbo', 'target', '.vscode', '.idea',
|
|
28
|
+
]);
|
|
29
|
+
/**
|
|
30
|
+
* Convert a glob pattern to a regex.
|
|
31
|
+
*
|
|
32
|
+
* * → [^/]*
|
|
33
|
+
* ? → [^/]
|
|
34
|
+
* ** → .*
|
|
35
|
+
* any other char → escaped literal
|
|
36
|
+
*
|
|
37
|
+
* Anchors the regex with ^ and $.
|
|
38
|
+
*/
|
|
39
|
+
export function globToRegex(pattern) {
|
|
40
|
+
let out = '^';
|
|
41
|
+
let i = 0;
|
|
42
|
+
while (i < pattern.length) {
|
|
43
|
+
const ch = pattern[i];
|
|
44
|
+
if (ch === '*') {
|
|
45
|
+
if (pattern[i + 1] === '*') {
|
|
46
|
+
out += '.*';
|
|
47
|
+
i += 2;
|
|
48
|
+
// Eat an optional `/` after `**`
|
|
49
|
+
if (pattern[i] === '/')
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
out += '[^/]*';
|
|
54
|
+
i++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
else if (ch === '?') {
|
|
58
|
+
out += '[^/]';
|
|
59
|
+
i++;
|
|
60
|
+
}
|
|
61
|
+
else if (/[.+^${}()|[\]\\]/.test(ch)) {
|
|
62
|
+
out += '\\' + ch;
|
|
63
|
+
i++;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
out += ch;
|
|
67
|
+
i++;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
out += '$';
|
|
71
|
+
return new RegExp(out);
|
|
72
|
+
}
|
|
73
|
+
export async function globHandler(args, ctx) {
|
|
74
|
+
// Validate args
|
|
75
|
+
if (!args.pattern || typeof args.pattern !== 'string') {
|
|
76
|
+
return { content: 'error: pattern is required (non-empty string)', is_error: true };
|
|
77
|
+
}
|
|
78
|
+
// Resolve search root — defaults to ctx.cwd
|
|
79
|
+
let searchRoot;
|
|
80
|
+
try {
|
|
81
|
+
searchRoot = args.cwd ? resolveSafePath(ctx.cwd, args.cwd) : path.resolve(ctx.cwd);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (err instanceof PathOutsideRootError) {
|
|
85
|
+
return { content: `error: ${err.message}`, is_error: true };
|
|
86
|
+
}
|
|
87
|
+
return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
|
|
88
|
+
}
|
|
89
|
+
// Verify the search root exists
|
|
90
|
+
try {
|
|
91
|
+
const stat = await fs.stat(searchRoot);
|
|
92
|
+
if (!stat.isDirectory()) {
|
|
93
|
+
return { content: `error: search root "${args.cwd ?? '.'}" is not a directory`, is_error: true };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return { content: `error: search root "${args.cwd ?? '.'}" does not exist`, is_error: true };
|
|
98
|
+
}
|
|
99
|
+
const regex = globToRegex(args.pattern);
|
|
100
|
+
const collected = [];
|
|
101
|
+
let budget = MAX_FILES_TRAVERSED;
|
|
102
|
+
async function walk(dir, relPrefix) {
|
|
103
|
+
if (budget <= 0 || collected.length >= MAX_RESULTS * 4)
|
|
104
|
+
return;
|
|
105
|
+
let entries;
|
|
106
|
+
try {
|
|
107
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
for (const ent of entries) {
|
|
113
|
+
if (budget <= 0 || collected.length >= MAX_RESULTS * 4)
|
|
114
|
+
return;
|
|
115
|
+
const relPath = relPrefix ? `${relPrefix}/${ent.name}` : ent.name;
|
|
116
|
+
if (ent.isDirectory()) {
|
|
117
|
+
if (ALWAYS_SKIPPED_DIRS.has(ent.name))
|
|
118
|
+
continue;
|
|
119
|
+
await walk(path.join(dir, ent.name), relPath);
|
|
120
|
+
}
|
|
121
|
+
else if (ent.isFile()) {
|
|
122
|
+
budget--;
|
|
123
|
+
if (!regex.test(relPath))
|
|
124
|
+
continue;
|
|
125
|
+
// Denylist check on the matched path
|
|
126
|
+
const abs = path.join(dir, ent.name);
|
|
127
|
+
const denylist = isDenylistedPath(abs, ctx.cwd);
|
|
128
|
+
if (denylist.blocked)
|
|
129
|
+
continue;
|
|
130
|
+
let mtime = 0;
|
|
131
|
+
try {
|
|
132
|
+
const s = await fs.stat(abs);
|
|
133
|
+
mtime = s.mtimeMs;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// skip stat failure — file still included with mtime 0
|
|
137
|
+
}
|
|
138
|
+
collected.push({ relPath, mtime });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
await walk(searchRoot, '');
|
|
143
|
+
// Sort by mtime descending, cap at MAX_RESULTS
|
|
144
|
+
collected.sort((a, b) => b.mtime - a.mtime);
|
|
145
|
+
const sample = collected.slice(0, MAX_RESULTS).map((f) => f.relPath);
|
|
146
|
+
if (sample.length === 0) {
|
|
147
|
+
return { content: `no files match "${args.pattern}"` };
|
|
148
|
+
}
|
|
149
|
+
const truncated = collected.length > MAX_RESULTS;
|
|
150
|
+
const trailer = truncated
|
|
151
|
+
? `\n... (${collected.length - MAX_RESULTS} more matches — narrow the pattern)`
|
|
152
|
+
: '';
|
|
153
|
+
return { content: sample.join('\n') + trailer };
|
|
154
|
+
}
|
|
155
|
+
registerTool({
|
|
156
|
+
name: 'glob',
|
|
157
|
+
description: 'Find files in the project matching a pattern. Supports *, **, ?. Returns up to 500 paths ' +
|
|
158
|
+
'sorted by modification time (most recent first).',
|
|
159
|
+
isMutating: false,
|
|
160
|
+
category: 'filesystem',
|
|
161
|
+
flagGated: true,
|
|
162
|
+
input_schema: {
|
|
163
|
+
type: 'object',
|
|
164
|
+
properties: {
|
|
165
|
+
pattern: {
|
|
166
|
+
type: 'string',
|
|
167
|
+
description: 'Glob pattern, e.g. "src/**/*.ts" or "*.json".',
|
|
168
|
+
},
|
|
169
|
+
cwd: {
|
|
170
|
+
type: 'string',
|
|
171
|
+
description: 'Optional subdirectory to search relative to the project root. Defaults to project root.',
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
required: ['pattern'],
|
|
175
|
+
},
|
|
176
|
+
handler: globHandler,
|
|
177
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* grep — Phase 3 filesystem tool.
|
|
3
|
+
*
|
|
4
|
+
* Searches file content in the project for a regex pattern. Returns
|
|
5
|
+
* matching lines up to a hard cap of 250 matches. Reuses glob.ts's
|
|
6
|
+
* globToRegex for the optional `glob` arg to scope which files get
|
|
7
|
+
* scanned. Three output modes:
|
|
8
|
+
*
|
|
9
|
+
* "content" — default. Lines like `path/foo.ts:42:matched line`
|
|
10
|
+
* "files_with_matches" — just the filenames that had at least one hit
|
|
11
|
+
* "count" — count of matches per file
|
|
12
|
+
*
|
|
13
|
+
* Pure Node fs + RegExp — no ripgrep dependency. Acceptable for the
|
|
14
|
+
* CSPeach project sizes the file walker caps at (10K files). Skips
|
|
15
|
+
* files larger than 2 MB (likely binary). Honors the shared sensitive-
|
|
16
|
+
* path denylist + ALWAYS_SKIPPED_DIRS.
|
|
17
|
+
*
|
|
18
|
+
* Flag-gated: invisible to listTools() unless CSPEACH_TOOL_GREP=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, PathOutsideRootError, isDenylistedPath } from '../_filesystem-shared.js';
|
|
24
|
+
import { globToRegex } from './glob.js';
|
|
25
|
+
const MAX_MATCHES = 250;
|
|
26
|
+
const MAX_FILES_TRAVERSED = 10_000;
|
|
27
|
+
const MAX_FILE_BYTES = 2_000_000;
|
|
28
|
+
const ALWAYS_SKIPPED_DIRS = new Set([
|
|
29
|
+
'node_modules', '.git', 'dist', 'build', '.cspeach', '.cspeach-design', '.abapforge',
|
|
30
|
+
'.next', '.turbo', 'target', '.vscode', '.idea',
|
|
31
|
+
]);
|
|
32
|
+
export async function grepHandler(args, ctx) {
|
|
33
|
+
if (!args.pattern || typeof args.pattern !== 'string') {
|
|
34
|
+
return { content: 'error: pattern is required (non-empty string)', is_error: true };
|
|
35
|
+
}
|
|
36
|
+
let searchRegex;
|
|
37
|
+
try {
|
|
38
|
+
searchRegex = new RegExp(args.pattern, args.case_insensitive ? 'i' : '');
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
return { content: `error: invalid regex — ${err.message}`, is_error: true };
|
|
42
|
+
}
|
|
43
|
+
let searchRoot;
|
|
44
|
+
try {
|
|
45
|
+
searchRoot = args.path ? resolveSafePath(ctx.cwd, args.path) : path.resolve(ctx.cwd);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (err instanceof PathOutsideRootError) {
|
|
49
|
+
return { content: `error: ${err.message}`, is_error: true };
|
|
50
|
+
}
|
|
51
|
+
return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const stat = await fs.stat(searchRoot);
|
|
55
|
+
if (!stat.isDirectory()) {
|
|
56
|
+
return { content: `error: search root "${args.path ?? '.'}" is not a directory`, is_error: true };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return { content: `error: search root "${args.path ?? '.'}" does not exist`, is_error: true };
|
|
61
|
+
}
|
|
62
|
+
const fileFilter = args.glob ? globToRegex(args.glob) : null;
|
|
63
|
+
const outputMode = args.output_mode ?? 'content';
|
|
64
|
+
const matches = [];
|
|
65
|
+
const fileMatchCounts = new Map();
|
|
66
|
+
let budget = MAX_FILES_TRAVERSED;
|
|
67
|
+
async function walk(dir, relPrefix) {
|
|
68
|
+
if (budget <= 0 || matches.length >= MAX_MATCHES)
|
|
69
|
+
return;
|
|
70
|
+
let entries;
|
|
71
|
+
try {
|
|
72
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (const ent of entries) {
|
|
78
|
+
if (budget <= 0 || matches.length >= MAX_MATCHES)
|
|
79
|
+
return;
|
|
80
|
+
const relPath = relPrefix ? `${relPrefix}/${ent.name}` : ent.name;
|
|
81
|
+
if (ent.isDirectory()) {
|
|
82
|
+
if (ALWAYS_SKIPPED_DIRS.has(ent.name))
|
|
83
|
+
continue;
|
|
84
|
+
await walk(path.join(dir, ent.name), relPath);
|
|
85
|
+
}
|
|
86
|
+
else if (ent.isFile()) {
|
|
87
|
+
budget--;
|
|
88
|
+
if (fileFilter && !fileFilter.test(relPath))
|
|
89
|
+
continue;
|
|
90
|
+
const abs = path.join(dir, ent.name);
|
|
91
|
+
const denylist = isDenylistedPath(abs, ctx.cwd);
|
|
92
|
+
if (denylist.blocked)
|
|
93
|
+
continue;
|
|
94
|
+
let stat;
|
|
95
|
+
try {
|
|
96
|
+
stat = await fs.stat(abs);
|
|
97
|
+
if (stat.size > MAX_FILE_BYTES)
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
let raw;
|
|
104
|
+
try {
|
|
105
|
+
raw = await fs.readFile(abs, 'utf-8');
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const lines = raw.split('\n');
|
|
111
|
+
for (let i = 0; i < lines.length; i++) {
|
|
112
|
+
if (matches.length >= MAX_MATCHES)
|
|
113
|
+
break;
|
|
114
|
+
if (searchRegex.test(lines[i])) {
|
|
115
|
+
matches.push({ file: relPath, line: i + 1, text: lines[i] });
|
|
116
|
+
fileMatchCounts.set(relPath, (fileMatchCounts.get(relPath) ?? 0) + 1);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
await walk(searchRoot, '');
|
|
123
|
+
if (matches.length === 0) {
|
|
124
|
+
return { content: `no matches for /${args.pattern}/${args.case_insensitive ? 'i' : ''}` };
|
|
125
|
+
}
|
|
126
|
+
const truncated = matches.length >= MAX_MATCHES;
|
|
127
|
+
const trailer = truncated
|
|
128
|
+
? `\n... (cap of ${MAX_MATCHES} matches reached — narrow the pattern or use a glob filter)`
|
|
129
|
+
: '';
|
|
130
|
+
if (outputMode === 'files_with_matches') {
|
|
131
|
+
const files = [...new Set(matches.map((m) => m.file))];
|
|
132
|
+
return { content: files.join('\n') + trailer };
|
|
133
|
+
}
|
|
134
|
+
if (outputMode === 'count') {
|
|
135
|
+
const entries = [...fileMatchCounts.entries()].sort((a, b) => b[1] - a[1]);
|
|
136
|
+
return { content: entries.map(([file, count]) => `${count}\t${file}`).join('\n') + trailer };
|
|
137
|
+
}
|
|
138
|
+
return { content: matches.map((m) => `${m.file}:${m.line}:${m.text}`).join('\n') + trailer };
|
|
139
|
+
}
|
|
140
|
+
registerTool({
|
|
141
|
+
name: 'grep',
|
|
142
|
+
description: 'Search file content in the project for a regex pattern. Returns matching lines (path + line number) up to 250 matches. ' +
|
|
143
|
+
'Optional glob filter and three output modes (content, files_with_matches, count).',
|
|
144
|
+
isMutating: false,
|
|
145
|
+
category: 'filesystem',
|
|
146
|
+
flagGated: true,
|
|
147
|
+
input_schema: {
|
|
148
|
+
type: 'object',
|
|
149
|
+
properties: {
|
|
150
|
+
pattern: { type: 'string', description: 'Regex pattern (ECMAScript flavor).' },
|
|
151
|
+
path: { type: 'string', description: 'Optional subdirectory to search relative to project root.' },
|
|
152
|
+
glob: { type: 'string', description: 'Optional glob filter on files to scan (e.g. "**/*.ts").' },
|
|
153
|
+
output_mode: {
|
|
154
|
+
type: 'string',
|
|
155
|
+
enum: ['content', 'files_with_matches', 'count'],
|
|
156
|
+
description: 'Output shape. Default: content.',
|
|
157
|
+
},
|
|
158
|
+
case_insensitive: { type: 'boolean', description: 'When true, match case-insensitively.' },
|
|
159
|
+
},
|
|
160
|
+
required: ['pattern'],
|
|
161
|
+
},
|
|
162
|
+
handler: grepHandler,
|
|
163
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool registry — central store for all CSPeach CLI tools.
|
|
3
|
+
*
|
|
4
|
+
* ToolContext carries the live AdtClient plus session/runtime state.
|
|
5
|
+
*/
|
|
6
|
+
import { isToolFlagOn } from './_flag.js';
|
|
7
|
+
const registry = new Map();
|
|
8
|
+
export function registerTool(tool) {
|
|
9
|
+
// Default category to 'sap' for back-compat with the existing 30+ tools
|
|
10
|
+
// that register without setting it. Spread to avoid mutating the
|
|
11
|
+
// caller's object — getTool() then always returns a normalised value.
|
|
12
|
+
registry.set(tool.name, { ...tool, category: tool.category ?? 'sap' });
|
|
13
|
+
}
|
|
14
|
+
export function getTool(name) {
|
|
15
|
+
return registry.get(name);
|
|
16
|
+
}
|
|
17
|
+
export function listTools() {
|
|
18
|
+
// Phase 3 visibility gate: flag-gated tools are filtered out unless
|
|
19
|
+
// their env flag is on. Non-flag-gated tools (the existing 30+) are
|
|
20
|
+
// always returned, preserving today's listTools() semantics exactly.
|
|
21
|
+
return Array.from(registry.values()).filter((t) => !t.flagGated || isToolFlagOn(t.name));
|
|
22
|
+
}
|
|
23
|
+
export function getToolsByCategory(category) {
|
|
24
|
+
return listTools().filter((t) => t.category === category);
|
|
25
|
+
}
|
|
26
|
+
export function toAnthropicTools(tools) {
|
|
27
|
+
return tools.map((t) => ({
|
|
28
|
+
name: t.name,
|
|
29
|
+
description: t.description,
|
|
30
|
+
input_schema: t.input_schema,
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* convention_get — Phase 3 / Chunk 3D.
|
|
3
|
+
*
|
|
4
|
+
* Thin slice over getProjectContext().conventions. Returns the full
|
|
5
|
+
* conventions object as JSON when field='all' (default), or a single
|
|
6
|
+
* field's value as a primitive/JSON when one of the named fields is
|
|
7
|
+
* requested.
|
|
8
|
+
*
|
|
9
|
+
* Reuses the project-context module's existing per-process cache —
|
|
10
|
+
* conventions are read by readConventions() inside getProjectContext()
|
|
11
|
+
* and re-rendering is cheap once the underlying cache is warm.
|
|
12
|
+
*
|
|
13
|
+
* Flag-gated: invisible to listTools() unless
|
|
14
|
+
* CSPEACH_TOOL_CONVENTION_GET=on. isMutating: false — reads only.
|
|
15
|
+
*/
|
|
16
|
+
import { registerTool } from '../index.js';
|
|
17
|
+
import { getProjectContext } from '../../project-context/index.js';
|
|
18
|
+
const VALID_FIELDS = new Set([
|
|
19
|
+
'all', 'freeText', 'namingPatterns', 'indentSize', 'indentChar', 'eol',
|
|
20
|
+
]);
|
|
21
|
+
const FALLBACK_CONVENTIONS = {
|
|
22
|
+
freeText: null,
|
|
23
|
+
namingPatterns: { classes: [], interfaces: [], cdsViews: [] },
|
|
24
|
+
style: { indentSize: 2, indentChar: 'space', eol: 'lf' },
|
|
25
|
+
};
|
|
26
|
+
export async function conventionGetHandler(args, ctx) {
|
|
27
|
+
const field = (args.field ?? 'all');
|
|
28
|
+
if (!VALID_FIELDS.has(field)) {
|
|
29
|
+
return {
|
|
30
|
+
content: `error: unknown field '${field}'. Valid fields: ${Array.from(VALID_FIELDS).join(', ')}.`,
|
|
31
|
+
is_error: true,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
let conventions;
|
|
35
|
+
try {
|
|
36
|
+
const pc = await getProjectContext(ctx.cwd);
|
|
37
|
+
conventions = pc?.conventions ?? FALLBACK_CONVENTIONS;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// getProjectContext is documented as never-throws (see
|
|
41
|
+
// project-context/index.ts header). Reaching this catch means a
|
|
42
|
+
// contract violation upstream. Falling back to defaults keeps the
|
|
43
|
+
// tool responsive — surfacing the error to the model isn't useful
|
|
44
|
+
// because the model can't fix a CSPeach internal regression.
|
|
45
|
+
conventions = FALLBACK_CONVENTIONS;
|
|
46
|
+
}
|
|
47
|
+
if (field === 'all') {
|
|
48
|
+
return { content: JSON.stringify(conventions, null, 2) };
|
|
49
|
+
}
|
|
50
|
+
if (field === 'freeText') {
|
|
51
|
+
return { content: conventions.freeText ?? '' };
|
|
52
|
+
}
|
|
53
|
+
if (field === 'namingPatterns') {
|
|
54
|
+
return { content: JSON.stringify(conventions.namingPatterns, null, 2) };
|
|
55
|
+
}
|
|
56
|
+
if (field === 'indentSize') {
|
|
57
|
+
return { content: String(conventions.style.indentSize) };
|
|
58
|
+
}
|
|
59
|
+
if (field === 'indentChar') {
|
|
60
|
+
return { content: conventions.style.indentChar };
|
|
61
|
+
}
|
|
62
|
+
if (field === 'eol') {
|
|
63
|
+
return { content: conventions.style.eol };
|
|
64
|
+
}
|
|
65
|
+
// Compile-time exhaustiveness guard: any future ConventionField value
|
|
66
|
+
// added without a matching `if` branch above narrows `field` to a
|
|
67
|
+
// non-never type here and breaks the build. The runtime return below
|
|
68
|
+
// is unreachable.
|
|
69
|
+
const _exhaustive = field;
|
|
70
|
+
return { content: `error: unhandled field ${String(_exhaustive)}`, is_error: true };
|
|
71
|
+
}
|
|
72
|
+
registerTool({
|
|
73
|
+
name: 'convention_get',
|
|
74
|
+
description: "Read project conventions (indent size, EOL, naming patterns, free-text). " +
|
|
75
|
+
"Pass `field` to retrieve a single value; default 'all' returns the full structured object as JSON. " +
|
|
76
|
+
"Valid fields: 'all' | 'freeText' | 'namingPatterns' | 'indentSize' | 'indentChar' | 'eol'.",
|
|
77
|
+
isMutating: false,
|
|
78
|
+
category: 'project',
|
|
79
|
+
flagGated: true,
|
|
80
|
+
input_schema: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: {
|
|
83
|
+
field: {
|
|
84
|
+
type: 'string',
|
|
85
|
+
enum: ['all', 'freeText', 'namingPatterns', 'indentSize', 'indentChar', 'eol'],
|
|
86
|
+
description: "Which field to return. Defaults to 'all'.",
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
handler: (args, ctx) => conventionGetHandler(args, ctx),
|
|
91
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* playbook_get — Phase 3 / Chunk 3D.
|
|
3
|
+
*
|
|
4
|
+
* Fetches a SKILL.md body via ctx.skillSource (the managed / manifest /
|
|
5
|
+
* bundled SkillSource abstraction the agent loop already uses). Lets the
|
|
6
|
+
* agent inspect another skill's playbook for orchestration patterns
|
|
7
|
+
* (e.g. /abap-incident calling /abap-dump-analyze inline) without
|
|
8
|
+
* triggering a full skill dispatch.
|
|
9
|
+
*
|
|
10
|
+
* Caching: in-process Map-based LRU. Keyed by validated skill name.
|
|
11
|
+
* 5-minute TTL (skills don't change mid-session). Hard cap 50 entries.
|
|
12
|
+
*
|
|
13
|
+
* Error envelope discipline: SkillSource throws on 404 / network /
|
|
14
|
+
* signature-mismatch; we catch and convert to a structured error
|
|
15
|
+
* envelope. API key values from CSPEACH_API_KEY / ANTHROPIC_API_KEY are
|
|
16
|
+
* scrubbed at the envelope edge as defense-in-depth.
|
|
17
|
+
*
|
|
18
|
+
* Flag-gated: invisible to listTools() unless
|
|
19
|
+
* CSPEACH_TOOL_PLAYBOOK_GET=on. isMutating: false — reads only.
|
|
20
|
+
*/
|
|
21
|
+
import { registerTool } from '../index.js';
|
|
22
|
+
const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9:_-]{0,63}$/;
|
|
23
|
+
const DEFAULT_TTL_MS = 300_000;
|
|
24
|
+
const MAX_CACHE_ENTRIES = 50;
|
|
25
|
+
const cache = new Map();
|
|
26
|
+
let cacheTtlMs = DEFAULT_TTL_MS;
|
|
27
|
+
/** Test-only: clear the cache between cases. */
|
|
28
|
+
export function __resetPlaybookCacheForTests() {
|
|
29
|
+
cache.clear();
|
|
30
|
+
}
|
|
31
|
+
/** Test-only: shorten the TTL so the "TTL expired" test can be deterministic. */
|
|
32
|
+
export function __setPlaybookCacheTtlForTests(ms) {
|
|
33
|
+
cacheTtlMs = ms;
|
|
34
|
+
}
|
|
35
|
+
function lookupCache(name) {
|
|
36
|
+
const entry = cache.get(name);
|
|
37
|
+
if (!entry)
|
|
38
|
+
return null;
|
|
39
|
+
if (entry.expiresAt < Date.now()) {
|
|
40
|
+
cache.delete(name);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
// Move to end (LRU touch).
|
|
44
|
+
cache.delete(name);
|
|
45
|
+
cache.set(name, entry);
|
|
46
|
+
return entry.body;
|
|
47
|
+
}
|
|
48
|
+
function storeCache(name, body) {
|
|
49
|
+
if (cache.size >= MAX_CACHE_ENTRIES) {
|
|
50
|
+
const firstKey = cache.keys().next().value;
|
|
51
|
+
if (firstKey !== undefined)
|
|
52
|
+
cache.delete(firstKey);
|
|
53
|
+
}
|
|
54
|
+
cache.set(name, { body, expiresAt: Date.now() + cacheTtlMs });
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Defense-in-depth: scrub any known-secret env values from `message`
|
|
58
|
+
* before it ships in an error envelope. Plain string ops — no regex
|
|
59
|
+
* metachar risk on the key itself.
|
|
60
|
+
*/
|
|
61
|
+
function scrubSecrets(message) {
|
|
62
|
+
const candidates = [
|
|
63
|
+
process.env.ANTHROPIC_API_KEY,
|
|
64
|
+
process.env.CSPEACH_API_KEY,
|
|
65
|
+
process.env.CSPEACH_BRAVE_SEARCH_KEY,
|
|
66
|
+
].filter((v) => typeof v === 'string' && v.length > 8);
|
|
67
|
+
let out = message;
|
|
68
|
+
for (const key of candidates) {
|
|
69
|
+
if (out.includes(key)) {
|
|
70
|
+
out = out.split(key).join('***');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
export async function playbookGetHandler(args, ctx) {
|
|
76
|
+
if (!args.name || typeof args.name !== 'string' || args.name.trim().length === 0) {
|
|
77
|
+
return { content: 'error: skill name is required (non-empty string).', is_error: true };
|
|
78
|
+
}
|
|
79
|
+
const name = args.name.trim();
|
|
80
|
+
if (!NAME_PATTERN.test(name)) {
|
|
81
|
+
return {
|
|
82
|
+
content: `error: invalid skill name '${name}'. Names must match ${NAME_PATTERN.source}.`,
|
|
83
|
+
is_error: true,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (!ctx.skillSource) {
|
|
87
|
+
return {
|
|
88
|
+
content: 'error: playbook_get requires ctx.skillSource — not wired into this ToolContext. ' +
|
|
89
|
+
'This is a CSPeach internal wiring issue; the REPL and one-shot entry points provide one automatically.',
|
|
90
|
+
is_error: true,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const cached = lookupCache(name);
|
|
94
|
+
if (cached !== null) {
|
|
95
|
+
return { content: cached };
|
|
96
|
+
}
|
|
97
|
+
let body;
|
|
98
|
+
try {
|
|
99
|
+
const skill = await ctx.skillSource.getSkillBody(name);
|
|
100
|
+
body = skill.body;
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
104
|
+
// Scrub the full composed envelope, not just `raw`. Defense in depth
|
|
105
|
+
// against the theoretical case where a crafted env-var value could
|
|
106
|
+
// slip through NAME_PATTERN (alphanumerics + `:_-`, ≤64 chars) and
|
|
107
|
+
// appear in the interpolated `name` segment.
|
|
108
|
+
return {
|
|
109
|
+
content: scrubSecrets(`error: failed to fetch playbook '${name}': ${raw}`),
|
|
110
|
+
is_error: true,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
storeCache(name, body);
|
|
114
|
+
return { content: body };
|
|
115
|
+
}
|
|
116
|
+
registerTool({
|
|
117
|
+
name: 'playbook_get',
|
|
118
|
+
description: "Fetch a SKILL.md body by skill name (e.g. 'abap-review', 'abap-rap'). " +
|
|
119
|
+
"Useful when one skill needs to inspect another's playbook pattern. " +
|
|
120
|
+
"Returns the full SKILL.md body verbatim. Cached in-process for 5 minutes.",
|
|
121
|
+
isMutating: false,
|
|
122
|
+
category: 'project',
|
|
123
|
+
flagGated: true,
|
|
124
|
+
input_schema: {
|
|
125
|
+
type: 'object',
|
|
126
|
+
properties: {
|
|
127
|
+
name: { type: 'string', description: 'Skill name without leading slash, e.g. abap-review.' },
|
|
128
|
+
},
|
|
129
|
+
required: ['name'],
|
|
130
|
+
},
|
|
131
|
+
handler: (args, ctx) => playbookGetHandler(args, ctx),
|
|
132
|
+
});
|