@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.
Files changed (210) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +108 -0
  3. package/dist/agent/anthropic-provider.js +59 -0
  4. package/dist/agent/llm-provider.js +1 -0
  5. package/dist/agent/loop.js +709 -0
  6. package/dist/agent/maybe-build-project-context.js +126 -0
  7. package/dist/agent/providers/ai-hub-provider.js +58 -0
  8. package/dist/agent/providers/byok-provider.js +53 -0
  9. package/dist/agent/providers/factory.js +13 -0
  10. package/dist/agent/providers/local-provider.js +125 -0
  11. package/dist/agent/repair-partial.js +31 -0
  12. package/dist/agent/retry-key.js +58 -0
  13. package/dist/agent/retry.js +17 -0
  14. package/dist/agent/sap-connection-adapter.js +82 -0
  15. package/dist/agent/skill-checkpoint.js +119 -0
  16. package/dist/agent/tool-dispatch.js +47 -0
  17. package/dist/agent/turn-assistant-text.js +49 -0
  18. package/dist/agent/turn-error-ux.js +126 -0
  19. package/dist/agent/turn-stream.js +79 -0
  20. package/dist/agent/turn-watchdog.js +71 -0
  21. package/dist/approvals/advisory-prompt.js +40 -0
  22. package/dist/approvals/advisory-render.js +38 -0
  23. package/dist/approvals/approval-prompt.js +100 -0
  24. package/dist/approvals/jwt.js +33 -0
  25. package/dist/approvals/render.js +211 -0
  26. package/dist/approvals/risk-floor.js +26 -0
  27. package/dist/auth/api-key.js +40 -0
  28. package/dist/auth/auth-file.js +59 -0
  29. package/dist/auth/device.js +8 -0
  30. package/dist/auth/me.js +19 -0
  31. package/dist/classifier/client.js +58 -0
  32. package/dist/cli-args.js +38 -0
  33. package/dist/cli.js +148 -0
  34. package/dist/commands/config-set.js +245 -0
  35. package/dist/commands/config-show.js +159 -0
  36. package/dist/commands/help.js +93 -0
  37. package/dist/commands/login.js +122 -0
  38. package/dist/commands/logout.js +17 -0
  39. package/dist/commands/project-context-impact.js +215 -0
  40. package/dist/commands/reroute.js +60 -0
  41. package/dist/commands/spec-gap-status.js +52 -0
  42. package/dist/commands/whoami.js +35 -0
  43. package/dist/config/loader.js +67 -0
  44. package/dist/config/paths.js +20 -0
  45. package/dist/doctor/checks/_http-probe.js +56 -0
  46. package/dist/doctor/checks/auth.js +15 -0
  47. package/dist/doctor/checks/cert.js +24 -0
  48. package/dist/doctor/checks/forge-rules.js +102 -0
  49. package/dist/doctor/checks/keychain-fallback.js +14 -0
  50. package/dist/doctor/checks/keychain.js +23 -0
  51. package/dist/doctor/checks/llm-mode.js +27 -0
  52. package/dist/doctor/checks/proxy.js +13 -0
  53. package/dist/doctor/checks/sap.js +33 -0
  54. package/dist/doctor/checks/skill.js +24 -0
  55. package/dist/doctor/checks/write-mode.js +34 -0
  56. package/dist/doctor/checks/zcspeach.js +76 -0
  57. package/dist/doctor/run.js +46 -0
  58. package/dist/errors/codes.js +12 -0
  59. package/dist/index.js +6 -0
  60. package/dist/lock-contention.js +22 -0
  61. package/dist/one-shot.js +104 -0
  62. package/dist/project-context/conventions.js +309 -0
  63. package/dist/project-context/detect.js +250 -0
  64. package/dist/project-context/domain/abap-cloud.js +26 -0
  65. package/dist/project-context/domain/abapgit.js +177 -0
  66. package/dist/project-context/domain/cap.js +164 -0
  67. package/dist/project-context/domain/fiori.js +326 -0
  68. package/dist/project-context/git.js +115 -0
  69. package/dist/project-context/index-files.js +235 -0
  70. package/dist/project-context/index.js +117 -0
  71. package/dist/project-context/render.js +308 -0
  72. package/dist/project-context/types.js +14 -0
  73. package/dist/projects/build.js +20 -0
  74. package/dist/projects/canonicalize.js +39 -0
  75. package/dist/projects/email-template.js +54 -0
  76. package/dist/projects/extract-cca.js +139 -0
  77. package/dist/projects/extract-design.js +107 -0
  78. package/dist/projects/extract-estimate.js +93 -0
  79. package/dist/projects/extract-modernize.js +130 -0
  80. package/dist/projects/extract-spec-gap.js +101 -0
  81. package/dist/projects/extract-test-coverage.js +137 -0
  82. package/dist/projects/extract-upgrade.js +230 -0
  83. package/dist/projects/filename.js +18 -0
  84. package/dist/projects/index.js +8 -0
  85. package/dist/projects/migration.js +111 -0
  86. package/dist/projects/promote-command.js +96 -0
  87. package/dist/projects/promote.js +107 -0
  88. package/dist/projects/save-command.js +124 -0
  89. package/dist/projects/save.js +21 -0
  90. package/dist/projects/status.js +170 -0
  91. package/dist/projects/types.js +1 -0
  92. package/dist/projects/validate.js +146 -0
  93. package/dist/projects/workspace.js +478 -0
  94. package/dist/renderer/abap-inline.js +121 -0
  95. package/dist/renderer/banners.js +39 -0
  96. package/dist/renderer/highlighters/abap.js +126 -0
  97. package/dist/renderer/highlighters/bdef.js +81 -0
  98. package/dist/renderer/highlighters/cds.js +91 -0
  99. package/dist/renderer/markdown.js +291 -0
  100. package/dist/renderer/pipeline.js +201 -0
  101. package/dist/renderer/progress-chatter.js +237 -0
  102. package/dist/renderer/question-normalizer.js +306 -0
  103. package/dist/renderer/severity.js +61 -0
  104. package/dist/renderer/status-footer.js +50 -0
  105. package/dist/renderer/syntax.js +58 -0
  106. package/dist/renderer/tables.js +55 -0
  107. package/dist/renderer/thinking-heartbeat.js +70 -0
  108. package/dist/renderer/tool-widget.js +199 -0
  109. package/dist/renderer/tty.js +66 -0
  110. package/dist/renderer/widget-extractor.js +87 -0
  111. package/dist/renderer/widget-fallback.js +78 -0
  112. package/dist/renderer/widget-schemas.js +43 -0
  113. package/dist/repl/at-completer.js +64 -0
  114. package/dist/repl/at-picker.js +122 -0
  115. package/dist/repl/bracketed-paste.js +284 -0
  116. package/dist/repl/current-transport.js +46 -0
  117. package/dist/repl/diff-display.js +41 -0
  118. package/dist/repl/file-picker.js +219 -0
  119. package/dist/repl/inquirer-guard.js +130 -0
  120. package/dist/repl/inquirer-theme.js +41 -0
  121. package/dist/repl/rule8-detector.js +99 -0
  122. package/dist/repl/safety-confirm.js +106 -0
  123. package/dist/repl/safety-mode-state.js +36 -0
  124. package/dist/repl/slash-completer.js +59 -0
  125. package/dist/repl/slash-picker.js +124 -0
  126. package/dist/repl/update-method-preview-hook.js +45 -0
  127. package/dist/repl.js +1383 -0
  128. package/dist/router/classifier.js +38 -0
  129. package/dist/router/intent-extractor.js +140 -0
  130. package/dist/router/routing-decision.js +19 -0
  131. package/dist/sap/connection-manager.js +52 -0
  132. package/dist/sap/onboarding.js +178 -0
  133. package/dist/sap/system-info.js +515 -0
  134. package/dist/session/awaiting-answer.js +73 -0
  135. package/dist/session/gc.js +28 -0
  136. package/dist/session/pending.js +37 -0
  137. package/dist/session/resume.js +77 -0
  138. package/dist/session/schema.js +20 -0
  139. package/dist/session/store.js +147 -0
  140. package/dist/session/time-ago.js +41 -0
  141. package/dist/skill-catalog.js +222 -0
  142. package/dist/skills/bundled-skills.js +1 -0
  143. package/dist/skills/canonical.js +12 -0
  144. package/dist/skills/manifest-client.js +93 -0
  145. package/dist/skills/promotion-dispatch.js +24 -0
  146. package/dist/skills/signing-public-key.js +4 -0
  147. package/dist/skills/source-bundled.js +20 -0
  148. package/dist/skills/source-managed.js +26 -0
  149. package/dist/skills/source-manifest.js +26 -0
  150. package/dist/tools/_command-shared.js +110 -0
  151. package/dist/tools/_filesystem-shared.js +81 -0
  152. package/dist/tools/_flag.js +39 -0
  153. package/dist/tools/approval.js +228 -0
  154. package/dist/tools/ask-question.js +205 -0
  155. package/dist/tools/dispatch-skill.js +81 -0
  156. package/dist/tools/filesystem/file-edit.js +140 -0
  157. package/dist/tools/filesystem/file-read.js +89 -0
  158. package/dist/tools/filesystem/file-write.js +128 -0
  159. package/dist/tools/filesystem/glob.js +177 -0
  160. package/dist/tools/filesystem/grep.js +163 -0
  161. package/dist/tools/index.js +32 -0
  162. package/dist/tools/project/convention_get.js +91 -0
  163. package/dist/tools/project/playbook_get.js +132 -0
  164. package/dist/tools/project/project_context_get.js +101 -0
  165. package/dist/tools/sap-read.js +454 -0
  166. package/dist/tools/sap-write.js +746 -0
  167. package/dist/tools/shell/shell_exec.js +209 -0
  168. package/dist/tools/snapshot.js +107 -0
  169. package/dist/tools/subagent/_background-shared.js +133 -0
  170. package/dist/tools/subagent/agent_run.js +186 -0
  171. package/dist/tools/subagent/background_run.js +143 -0
  172. package/dist/tools/subagent/monitor_emit.js +65 -0
  173. package/dist/tools/subagent/schedule_create.js +131 -0
  174. package/dist/tools/transport.js +233 -0
  175. package/dist/tools/update-method-intercept.js +119 -0
  176. package/dist/tools/verify.js +39 -0
  177. package/dist/tools/web/_web-shared.js +251 -0
  178. package/dist/tools/web/web_fetch.js +257 -0
  179. package/dist/tools/web/web_search.js +195 -0
  180. package/dist/tools/write-mode.js +22 -0
  181. package/dist/ui/app.js +95 -0
  182. package/dist/ui/approval-emitter.js +10 -0
  183. package/dist/ui/approval-modal.js +53 -0
  184. package/dist/ui/ascii-chars.js +6 -0
  185. package/dist/ui/body.js +102 -0
  186. package/dist/ui/coaching-picker-classic.js +36 -0
  187. package/dist/ui/coaching-picker-emitter.js +27 -0
  188. package/dist/ui/command-palette.js +34 -0
  189. package/dist/ui/error-emitter.js +21 -0
  190. package/dist/ui/footer.js +103 -0
  191. package/dist/ui/header.js +17 -0
  192. package/dist/ui/ink-classifier-route.js +19 -0
  193. package/dist/ui/login-banner.js +72 -0
  194. package/dist/ui/rich-error-box.js +9 -0
  195. package/dist/ui/sap-state-store.js +65 -0
  196. package/dist/ui/session-timeline.js +31 -0
  197. package/dist/ui/sidebar.js +10 -0
  198. package/dist/ui/skill-picker.js +50 -0
  199. package/dist/ui/status-row.js +12 -0
  200. package/dist/ui/widget-control.js +4 -0
  201. package/dist/ui/widgets/bar-chart.js +15 -0
  202. package/dist/ui/widgets/coaching-picker.js +41 -0
  203. package/dist/ui/widgets/component-registry.js +12 -0
  204. package/dist/ui/widgets/dep-graph.js +9 -0
  205. package/dist/ui/widgets/diff-viewer.js +11 -0
  206. package/dist/ui/widgets/question-card.js +11 -0
  207. package/dist/ui/widgets/stack-frames.js +5 -0
  208. package/dist/upgrade-check.js +28 -0
  209. package/dist/upgrade.js +13 -0
  210. 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
+ });