@cspeach/cli 0.9.0 → 1.1.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 (195) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +228 -26
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/tool-dispatch.js +15 -0
  7. package/dist/approvals/canonical.js +91 -0
  8. package/dist/approvals/jwt.js +39 -2
  9. package/dist/approvals/op-labels.js +124 -0
  10. package/dist/approvals/render.js +42 -36
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +18 -3
  13. package/dist/cli.js +15 -0
  14. package/dist/commands/compact.js +28 -2
  15. package/dist/commands/config-set.js +284 -0
  16. package/dist/commands/config-show.js +20 -0
  17. package/dist/commands/export-audit.js +43 -0
  18. package/dist/commands/help.js +5 -0
  19. package/dist/commands/login.js +31 -14
  20. package/dist/commands/plan-audit-evidence.js +266 -0
  21. package/dist/commands/plan-audit.js +692 -0
  22. package/dist/commands/plan-chain.js +671 -0
  23. package/dist/commands/plan-continue.js +179 -0
  24. package/dist/commands/plan-gate.js +154 -0
  25. package/dist/commands/plan-model-tier.js +83 -0
  26. package/dist/commands/plan-resume.js +728 -46
  27. package/dist/config/loader.js +223 -5
  28. package/dist/config/model-defaults.js +14 -0
  29. package/dist/cost/pricing.js +27 -1
  30. package/dist/doctor/checks/_http-probe.js +1 -0
  31. package/dist/doctor/checks/cert.js +14 -3
  32. package/dist/doctor/checks/sap.js +30 -8
  33. package/dist/doctor/checks/system-roles.js +41 -0
  34. package/dist/doctor/checks/zcspeach.js +19 -4
  35. package/dist/doctor/run.js +2 -0
  36. package/dist/models/resolve.js +61 -0
  37. package/dist/models/server-config.js +155 -0
  38. package/dist/one-shot.js +76 -6
  39. package/dist/projects/answer-blockers.js +137 -0
  40. package/dist/projects/extract-cca.js +111 -17
  41. package/dist/projects/extract-modernize.js +4 -2
  42. package/dist/projects/extract-plan.js +184 -37
  43. package/dist/projects/extract-spec-gap.js +34 -7
  44. package/dist/projects/extract-test-coverage.js +4 -2
  45. package/dist/projects/extract-upgrade.js +116 -23
  46. package/dist/projects/handover-md.js +195 -0
  47. package/dist/projects/index.js +5 -2
  48. package/dist/projects/merge-cca.js +292 -0
  49. package/dist/projects/merge-upgrade.js +173 -0
  50. package/dist/projects/migration.js +103 -1
  51. package/dist/projects/output-paths.js +27 -0
  52. package/dist/projects/plan-run.js +285 -27
  53. package/dist/projects/plan-schema.js +136 -3
  54. package/dist/projects/promote-command.js +25 -2
  55. package/dist/projects/promote.js +128 -0
  56. package/dist/projects/run-lease.js +157 -0
  57. package/dist/projects/save-command.js +259 -21
  58. package/dist/projects/status.js +3 -1
  59. package/dist/projects/validate.js +1 -1
  60. package/dist/projects/workspace.js +164 -20
  61. package/dist/renderer/notices.js +64 -0
  62. package/dist/renderer/progress-chatter.js +8 -0
  63. package/dist/renderer/status-footer.js +22 -12
  64. package/dist/renderer/thinking-heartbeat.js +64 -8
  65. package/dist/renderer/todo-block.js +51 -0
  66. package/dist/renderer/tool-widget.js +55 -4
  67. package/dist/renderer/tty.js +43 -4
  68. package/dist/renderer/verify-chain.js +77 -0
  69. package/dist/repl/at-picker.js +60 -7
  70. package/dist/repl/bracketed-paste.js +28 -19
  71. package/dist/repl/builtin-commands.js +42 -0
  72. package/dist/repl/current-transport.js +10 -0
  73. package/dist/repl/early-line-buffer.js +68 -0
  74. package/dist/repl/history.js +86 -0
  75. package/dist/repl/ink-stdin-guard.js +64 -0
  76. package/dist/repl/inquirer-guard.js +70 -5
  77. package/dist/repl/mode-ceiling.js +16 -0
  78. package/dist/repl/mode-cycle.js +104 -0
  79. package/dist/repl/numbered-menu.js +131 -0
  80. package/dist/repl/post-turn-status.js +26 -6
  81. package/dist/repl/rule8-detector.js +17 -2
  82. package/dist/repl/safety-confirm.js +111 -2
  83. package/dist/repl/safety-mode-state.js +19 -3
  84. package/dist/repl/slash-completer.js +5 -0
  85. package/dist/repl/slash-picker.js +10 -15
  86. package/dist/repl.js +1232 -95
  87. package/dist/rewind/candidates.js +194 -0
  88. package/dist/rewind/cli.js +137 -0
  89. package/dist/rewind/format.js +27 -0
  90. package/dist/rewind/restore.js +245 -0
  91. package/dist/router/classifier.js +150 -6
  92. package/dist/sap/capability-matrix.js +20 -0
  93. package/dist/sap/capability-matrix.json +11236 -0
  94. package/dist/sap/capability.js +146 -0
  95. package/dist/sap/connection-manager.js +19 -1
  96. package/dist/sap/onboarding.js +42 -4
  97. package/dist/session/audit-export.js +459 -0
  98. package/dist/session/context-report.js +163 -0
  99. package/dist/session/pending.js +27 -0
  100. package/dist/session/recap.js +160 -0
  101. package/dist/skill-catalog.js +51 -40
  102. package/dist/skills/bundled-skills.js +272 -1
  103. package/dist/skills/promotion-dispatch.js +23 -0
  104. package/dist/tools/_command-shared.js +36 -12
  105. package/dist/tools/_filesystem-shared.js +139 -4
  106. package/dist/tools/_flag.js +25 -0
  107. package/dist/tools/approval.js +177 -26
  108. package/dist/tools/ask-question.js +400 -7
  109. package/dist/tools/capability/tool.js +74 -0
  110. package/dist/tools/dispatch-skill.js +22 -1
  111. package/dist/tools/extend-model/anchored-insert.js +1414 -0
  112. package/dist/tools/extend-model/tool.js +340 -0
  113. package/dist/tools/filesystem/extract-document.js +57 -0
  114. package/dist/tools/filesystem/file-edit.js +12 -2
  115. package/dist/tools/filesystem/file-read.js +2 -2
  116. package/dist/tools/filesystem/file-write.js +11 -2
  117. package/dist/tools/filesystem/glob.js +11 -0
  118. package/dist/tools/filesystem/grep.js +10 -0
  119. package/dist/tools/filesystem/read-document.js +107 -0
  120. package/dist/tools/fiori/apply.js +50 -0
  121. package/dist/tools/fiori/bin.js +3 -0
  122. package/dist/tools/fiori/catalog/index.js +27 -0
  123. package/dist/tools/fiori/catalog/value-help.js +230 -0
  124. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  125. package/dist/tools/fiori/cli.js +71 -0
  126. package/dist/tools/fiori/deploy-config.js +73 -0
  127. package/dist/tools/fiori/fe-extend.js +76 -0
  128. package/dist/tools/fiori/fe-scaffold.js +71 -0
  129. package/dist/tools/fiori/floorplan-map.js +19 -0
  130. package/dist/tools/fiori/i18n.js +39 -0
  131. package/dist/tools/fiori/manifest.js +70 -0
  132. package/dist/tools/fiori/render.js +77 -0
  133. package/dist/tools/fiori/samples/data/index.json +13602 -0
  134. package/dist/tools/fiori/samples/data/sources.generated.js +808 -0
  135. package/dist/tools/fiori/samples/loader.js +248 -0
  136. package/dist/tools/fiori/samples/search.js +63 -0
  137. package/dist/tools/fiori/samples/types.js +2 -0
  138. package/dist/tools/fiori/scaffold.js +39 -0
  139. package/dist/tools/fiori/smoke/assertions.js +74 -0
  140. package/dist/tools/fiori/smoke/browser.js +52 -0
  141. package/dist/tools/fiori/smoke/driver.js +89 -0
  142. package/dist/tools/fiori/smoke/freestyle-spec.js +317 -0
  143. package/dist/tools/fiori/smoke/run-smoke.js +149 -0
  144. package/dist/tools/fiori/tools.js +681 -0
  145. package/dist/tools/fiori/types.js +1 -0
  146. package/dist/tools/local-build.js +86 -0
  147. package/dist/tools/local-files.js +31 -0
  148. package/dist/tools/project/_merge-shared.js +68 -0
  149. package/dist/tools/project/cca_merge.js +164 -0
  150. package/dist/tools/project/playbook_get.js +1 -1
  151. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  152. package/dist/tools/sap-read.js +132 -20
  153. package/dist/tools/sap-write.js +550 -21
  154. package/dist/tools/shell/shell_exec.js +41 -6
  155. package/dist/tools/snapshot.js +63 -14
  156. package/dist/tools/subagent/agent_run.js +27 -3
  157. package/dist/tools/subagent/background_run.js +17 -1
  158. package/dist/tools/todo.js +144 -0
  159. package/dist/tools/transport-resolution.js +86 -0
  160. package/dist/tools/transport.js +224 -5
  161. package/dist/tools/write-mode.js +4 -0
  162. package/dist/ui/app.js +378 -21
  163. package/dist/ui/approval-modal.js +49 -16
  164. package/dist/ui/ask-question-emitter.js +14 -0
  165. package/dist/ui/body.js +13 -0
  166. package/dist/ui/context-grid.js +108 -0
  167. package/dist/ui/footer.js +120 -27
  168. package/dist/ui/header.js +7 -0
  169. package/dist/ui/line-resolution.js +35 -8
  170. package/dist/ui/rewind-emitter.js +10 -0
  171. package/dist/ui/rewind-panel.js +81 -0
  172. package/dist/ui/sap-state-store.js +1 -0
  173. package/dist/ui/session-timeline.js +1 -0
  174. package/dist/ui/status-line.js +43 -0
  175. package/dist/ui/text-input.js +214 -0
  176. package/dist/ui/todo-emitter.js +25 -0
  177. package/dist/ui/todo-panel.js +64 -0
  178. package/dist/ui/turn-status-emitter.js +50 -4
  179. package/dist/ui/turn-status.js +18 -3
  180. package/dist/ui/widgets/ask-form.js +242 -0
  181. package/dist/ui/widgets/ask-question-modal.js +21 -8
  182. package/package.json +22 -3
  183. package/bench/README.md +0 -78
  184. package/bench/prompts/abap-document-cds.md +0 -44
  185. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  186. package/bench/prompts/abap-test-method.md +0 -42
  187. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  188. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  189. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  190. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  191. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  192. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  193. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  194. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  195. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -0,0 +1,681 @@
1
+ /**
2
+ * Fiori engine tools — built-in, flag-gated wrappers around the existing
3
+ * CSPeach Fiori engine (scaffold.ts, apply.ts, catalog/, deploy-config.ts).
4
+ *
5
+ * WHY THESE EXIST
6
+ * ---------------
7
+ * The engine has always been OUR code (cspeach-cli/src/tools/fiori/), but it
8
+ * was only reachable through `pnpm fiori <cmd>` (bin.ts → cli.ts), a pnpm
9
+ * script that only works INSIDE the repo. Customers on the shipped npm
10
+ * `@cspeach/cli` binary could not run it, so the /abap-fiori-build skill had to
11
+ * improvise file writes by hand — non-deterministic output per customer.
12
+ *
13
+ * These tools register the same engine functions as first-class tools so every
14
+ * customer gets the SAME deterministic scaffold/apply/deploy-config behavior,
15
+ * no pnpm and no repo required. They DO NOT reimplement the engine — each
16
+ * handler calls the existing scaffoldFreestyle()/applyEntry()/listCatalog()/
17
+ * writeDeployConfig() exports.
18
+ *
19
+ * SANDBOX (defense in depth)
20
+ * --------------------------
21
+ * These tools write file trees (basePath / appDir). Sandboxing is enforced at
22
+ * TWO layers, because the appDir boundary alone is not sufficient:
23
+ *
24
+ * 1. Tool boundary: the customer-supplied basePath/appDir is run through
25
+ * resolveSafePath(ctx.cwd, …) so the app ROOT cannot escape the project.
26
+ *
27
+ * 2. Engine boundary (the real fix): applyEntry() re-validates EVERY file it
28
+ * writes against appDir via resolveSafePath(appDir, renderedPath). This is
29
+ * required because catalog templates render OUTPUT PATHS from customer
30
+ * params (e.g. `webapp/ext/fragment/<%- ns %>Chart.fragment.xml`) — a
31
+ * hostile `ns` could otherwise carry `..` and write CONTENT outside the
32
+ * app root even though appDir itself was contained. A matching catalog-side
33
+ * `ns` pattern (render.ts) rejects such values even earlier.
34
+ *
35
+ * So: a `..`-traversal cannot escape via the appDir arg NOR via a param that
36
+ * lands in a rendered path.
37
+ *
38
+ * FLAG / LOCAL_BUILD
39
+ * ------------------
40
+ * Every fiori_* tool here is flagGated:true (category 'fiori') and listed in
41
+ * LOCAL_BUILD_TOOLS, so `cspeach config set local_build on` enables them
42
+ * alongside file_write/shell_exec — they are part of "build apps locally".
43
+ *
44
+ * `pnpm fiori` (bin.ts/cli.ts) is UNTOUCHED and still works for repo dev; these
45
+ * tools are an additional surface, not a replacement.
46
+ */
47
+ import { promises as fs } from 'node:fs';
48
+ import * as path from 'node:path';
49
+ import { registerTool } from '../index.js';
50
+ import { resolveSafePath, PathOutsideRootError } from '../_filesystem-shared.js';
51
+ import { scaffoldFreestyle } from './scaffold.js';
52
+ import { applyEntry } from './apply.js';
53
+ import { listCatalog } from './catalog/index.js';
54
+ import { writeDeployConfig } from './deploy-config.js';
55
+ import { scaffoldFioriElements } from './fe-scaffold.js';
56
+ import { feExtend, FE_EXTEND_OPS, NotV4FeAppError } from './fe-extend.js';
57
+ import { runSmoke } from './smoke/run-smoke.js';
58
+ import { deriveFreestyleSmokeSpec, FreestyleSmokeDerivationError } from './smoke/freestyle-spec.js';
59
+ import { loadConfig, resolveRenderSmoke } from '../../config/loader.js';
60
+ import { searchSamples } from './samples/search.js';
61
+ import { loadSampleSource, SampleFetchError, UnsafeSampleNameError } from './samples/loader.js';
62
+ // The grounding corpus ships as an imported JSON module + the bundled
63
+ // SAMPLE_SOURCES map (read inside loadSampleSource). NEVER readFileSync the
64
+ // corpus — the import is the only shipping-safe read (rule A-C1).
65
+ import sampleIndexJson from './samples/data/index.json' with { type: 'json' };
66
+ const SAMPLE_INDEX = sampleIndexJson;
67
+ // Short provenance line, always attached to a fiori_sample_get payload so the
68
+ // model can cite the source (licensing constraint A-M1). Derived from the
69
+ // corpus NOTICE: OpenUI5 sources, adapted, under Apache-2.0.
70
+ const SAMPLE_ATTRIBUTION = 'Adapted from OpenUI5 (github.com/SAP/openui5) sample sources, licensed under Apache-2.0.';
71
+ /** Resolve a user-supplied dir inside the project root, mapping the escape error. */
72
+ function safeDir(ctx, userPath) {
73
+ try {
74
+ return { abs: resolveSafePath(ctx.cwd, userPath) };
75
+ }
76
+ catch (err) {
77
+ if (err instanceof PathOutsideRootError)
78
+ return { error: err.message };
79
+ return { error: err instanceof Error ? err.message : String(err) };
80
+ }
81
+ }
82
+ /** Relative-to-cwd, forward-slash path for stable, readable result strings. */
83
+ function rel(ctx, abs) {
84
+ return path.relative(path.resolve(ctx.cwd), abs).replace(/\\/g, '/') || '.';
85
+ }
86
+ /** Recursively list files under `dir` as cwd-relative paths (sorted). */
87
+ async function listFilesUnder(ctx, dir) {
88
+ const out = [];
89
+ async function walk(d) {
90
+ let entries;
91
+ try {
92
+ entries = await fs.readdir(d, { withFileTypes: true });
93
+ }
94
+ catch {
95
+ return;
96
+ }
97
+ for (const e of entries) {
98
+ const full = path.join(d, e.name);
99
+ if (e.isDirectory())
100
+ await walk(full);
101
+ else
102
+ out.push(rel(ctx, full));
103
+ }
104
+ }
105
+ await walk(dir);
106
+ return out.sort();
107
+ }
108
+ // ───────────────────────────── fiori_scaffold ─────────────────────────────
109
+ export async function fioriScaffoldHandler(args, ctx) {
110
+ if (!args.basePath)
111
+ return { content: 'error: basePath is required', is_error: true };
112
+ if (!args.appId)
113
+ return { content: 'error: appId is required', is_error: true };
114
+ const resolved = safeDir(ctx, args.basePath);
115
+ if ('error' in resolved)
116
+ return { content: `error: ${resolved.error}`, is_error: true };
117
+ try {
118
+ await scaffoldFreestyle({
119
+ basePath: resolved.abs,
120
+ appId: args.appId,
121
+ appTitle: args.appTitle ?? args.appId,
122
+ template: args.template ?? 'basic',
123
+ service: args.serviceUrl
124
+ ? { url: args.serviceUrl, path: args.servicePath ?? '', version: args.serviceVersion ?? '4.0' }
125
+ : undefined,
126
+ typescript: args.typescript ?? false,
127
+ ui5Version: args.ui5Version,
128
+ });
129
+ }
130
+ catch (err) {
131
+ return { content: `error: scaffold failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
132
+ }
133
+ const files = await listFilesUnder(ctx, resolved.abs);
134
+ const appDir = rel(ctx, resolved.abs);
135
+ return {
136
+ content: `Scaffolded freestyle app "${args.appId}" into ${appDir}/ (${files.length} files).\n` +
137
+ `App dir: ${appDir}\n` +
138
+ `Files:\n${files.map((f) => ` ${f}`).join('\n')}`,
139
+ };
140
+ }
141
+ // ─────────────────────────────── fiori_apply ──────────────────────────────
142
+ export async function fioriApplyHandler(args, ctx) {
143
+ if (!args.appDir)
144
+ return { content: 'error: appDir is required', is_error: true };
145
+ if (!args.entry)
146
+ return { content: 'error: entry is required', is_error: true };
147
+ const resolved = safeDir(ctx, args.appDir);
148
+ if ('error' in resolved)
149
+ return { content: `error: ${resolved.error}`, is_error: true };
150
+ // Params may arrive as a parsed object (preferred) or a JSON string (mirrors
151
+ // the `--params '<json>'` CLI flag). Tolerate both.
152
+ let params;
153
+ if (typeof args.params === 'string') {
154
+ try {
155
+ params = args.params.trim() ? JSON.parse(args.params) : {};
156
+ }
157
+ catch (err) {
158
+ return { content: `error: params is not valid JSON — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
159
+ }
160
+ }
161
+ else {
162
+ params = args.params ?? {};
163
+ }
164
+ try {
165
+ // applyEntry preserves the engine's loud-on-conflict manifest merge — a
166
+ // conflicting manifest patch throws here and surfaces as is_error, never a
167
+ // silent last-wins overwrite.
168
+ applyEntry({ appDir: resolved.abs, entryName: args.entry, params });
169
+ }
170
+ catch (err) {
171
+ return { content: `error: apply failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
172
+ }
173
+ return {
174
+ content: `Applied catalog entry "${args.entry}" into ${rel(ctx, resolved.abs)}.\n` +
175
+ `Entry files written, manifest.json deep-merged (loud on conflict), i18n keys appended.`,
176
+ };
177
+ }
178
+ // ─────────────────────────────── fiori_list ───────────────────────────────
179
+ export async function fioriListHandler(_args, _ctx) {
180
+ // listCatalog() returns each entry's param specs so the model can build a
181
+ // correct fiori_apply params object in one shot (no required-param iteration).
182
+ const entries = listCatalog();
183
+ return { content: JSON.stringify(entries, null, 2) };
184
+ }
185
+ // ──────────────────────────── fiori_deploy_config ─────────────────────────
186
+ export async function fioriDeployConfigHandler(args, ctx) {
187
+ if (!args.appDir)
188
+ return { content: 'error: appDir is required', is_error: true };
189
+ if (!args.url)
190
+ return { content: 'error: url is required', is_error: true };
191
+ if (!args.client)
192
+ return { content: 'error: client is required', is_error: true };
193
+ if (!args.name)
194
+ return { content: 'error: name (BSP app name) is required', is_error: true };
195
+ if (!args.package)
196
+ return { content: 'error: package is required', is_error: true };
197
+ const resolved = safeDir(ctx, args.appDir);
198
+ if ('error' in resolved)
199
+ return { content: `error: ${resolved.error}`, is_error: true };
200
+ try {
201
+ writeDeployConfig(resolved.abs, {
202
+ url: args.url,
203
+ client: args.client,
204
+ appName: args.name,
205
+ description: args.description,
206
+ package: args.package,
207
+ transport: args.transport ?? '',
208
+ ignoreCertErrors: args.ignoreCertErrors === true,
209
+ });
210
+ }
211
+ catch (err) {
212
+ // writeDeployConfig validates app-name length/case, package namespace, and
213
+ // transport format — those validation errors surface here as is_error.
214
+ return { content: `error: deploy-config failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
215
+ }
216
+ const appDir = rel(ctx, resolved.abs);
217
+ return {
218
+ content: `Deploy config written: ${appDir}/ui5-deploy.yaml (BSP app ${args.name} → package ${args.package}` +
219
+ `${args.transport ? `, transport ${args.transport}` : ''}).\n` +
220
+ `package.json gained "deploy" and "deploy-test" scripts. Run "npm run deploy-test" to validate without deploying.`,
221
+ };
222
+ }
223
+ // ──────────────────────────── fiori_scaffold_fe ───────────────────────────
224
+ export async function fioriScaffoldFeHandler(args, ctx) {
225
+ if (!args.basePath)
226
+ return { content: 'error: basePath is required', is_error: true };
227
+ if (!args.appId)
228
+ return { content: 'error: appId is required', is_error: true };
229
+ if (!args.mainEntity)
230
+ return { content: 'error: mainEntity is required', is_error: true };
231
+ if (args.localAnnotations && (!args.localAnnotations.technicalName || !args.localAnnotations.xml)) {
232
+ return { content: 'error: localAnnotations requires both technicalName and xml', is_error: true };
233
+ }
234
+ // metadata is OPTIONAL (the FE writer does not need it — spike §4); no $metadata fetch.
235
+ const resolved = safeDir(ctx, args.basePath);
236
+ if ('error' in resolved)
237
+ return { content: `error: ${resolved.error}`, is_error: true };
238
+ try {
239
+ await scaffoldFioriElements({
240
+ basePath: resolved.abs,
241
+ appId: args.appId,
242
+ appTitle: args.appTitle ?? args.appId,
243
+ template: args.template ?? 'lrop',
244
+ service: { url: args.serviceUrl, path: args.servicePath, version: args.serviceVersion ?? '4.0', metadata: args.metadata, client: args.client },
245
+ mainEntity: args.mainEntity,
246
+ ui5Version: args.ui5Version,
247
+ localAnnotations: args.localAnnotations,
248
+ });
249
+ }
250
+ catch (err) {
251
+ return { content: `error: FE scaffold failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
252
+ }
253
+ const files = await listFilesUnder(ctx, resolved.abs);
254
+ const appDir = rel(ctx, resolved.abs);
255
+ return {
256
+ content: `Scaffolded Fiori Elements ${args.template ?? 'lrop'} app "${args.appId}" into ${appDir}/ (${files.length} files).\n` +
257
+ `Driven by the service's backend @UI annotations (no local annotation.xml). App dir: ${appDir}\n` +
258
+ `Files:\n${files.map((f) => ` ${f}`).join('\n')}`,
259
+ };
260
+ }
261
+ // ───────────────────────────── fiori_fe_extend ────────────────────────────
262
+ export async function feExtendHandler(args, ctx) {
263
+ if (!args.basePath)
264
+ return { content: 'error: basePath is required', is_error: true };
265
+ if (!args.op)
266
+ return { content: 'error: op is required', is_error: true };
267
+ if (!FE_EXTEND_OPS.includes(args.op)) {
268
+ return { content: `error: unknown op "${args.op}" — expected one of ${FE_EXTEND_OPS.join(', ')}`, is_error: true };
269
+ }
270
+ const resolved = safeDir(ctx, args.basePath);
271
+ if ('error' in resolved)
272
+ return { content: `error: ${resolved.error}`, is_error: true };
273
+ const before = new Set(await listFilesUnder(ctx, resolved.abs));
274
+ try {
275
+ await feExtend({ basePath: resolved.abs, op: args.op, params: args.params ?? {} });
276
+ }
277
+ catch (err) {
278
+ // V4-only scope limit: fe-fpm-writer refuses non-V4-FE apps (no
279
+ // sap.fe.templates) — surfaced as a clean typed line, not the writer's
280
+ // internal wording.
281
+ if (err instanceof NotV4FeAppError)
282
+ return { content: `error: ${err.message}`, is_error: true };
283
+ return { content: `error: fe extend failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
284
+ }
285
+ // Report only the newly-written files (the generator also touches manifest.json).
286
+ const after = await listFilesUnder(ctx, resolved.abs);
287
+ const created = after.filter((f) => !before.has(f));
288
+ const appDir = rel(ctx, resolved.abs);
289
+ return {
290
+ content: `Added FE extension "${args.op}" to ${appDir}/ (manifest.json updated).\n` +
291
+ (created.length
292
+ ? `New files:\n${created.map((f) => ` ${f}`).join('\n')}`
293
+ : `No new files (manifest-only change).`),
294
+ };
295
+ }
296
+ /**
297
+ * FREESTYLE MODE (appDir given): derive a SmokeSpec from the app's own webapp/
298
+ * tree via deriveFreestyleSmokeSpec, then let explicit spec args override the
299
+ * derived fields FIELD-BY-FIELD (explicit wins). `appUrl` always comes from the
300
+ * arg — derivation produces no URL. Returns the merged spec + derivation warnings,
301
+ * or a clean `error` string (bad appDir, or a typed derivation failure — named).
302
+ *
303
+ * Kept as a pure, exported helper so the derive+merge contract is unit-testable
304
+ * without launching a browser; the handler only orchestrates.
305
+ */
306
+ export function resolveFreestyleSmokeSpec(args, ctx) {
307
+ const resolved = safeDir(ctx, args.appDir);
308
+ if ('error' in resolved)
309
+ return { error: resolved.error };
310
+ let derived;
311
+ try {
312
+ derived = deriveFreestyleSmokeSpec(resolved.abs);
313
+ }
314
+ catch (err) {
315
+ // Name the typed derivation failure (no stack trace) so the model sees WHY.
316
+ if (err instanceof FreestyleSmokeDerivationError) {
317
+ return { error: `${err.name}: ${err.message}` };
318
+ }
319
+ return { error: err instanceof Error ? err.message : String(err) };
320
+ }
321
+ const { warnings } = derived;
322
+ // Field-by-field merge (explicit wins). `??` keeps an explicitly-provided empty
323
+ // array/false as an override, and omits fields that are neither explicit nor derived.
324
+ const controlKind = args.controlKind ?? derived.controlKind;
325
+ const expectedColumns = args.expectedColumns ?? derived.expectedColumns;
326
+ const exercises = args.exercises ?? derived.exercises;
327
+ const spec = {
328
+ appUrl: args.appUrl, // always the arg — derivation carries no real preview URL
329
+ ...(controlKind !== undefined ? { controlKind } : {}),
330
+ ...(expectedColumns !== undefined ? { expectedColumns } : {}),
331
+ ...(args.allowEmptyRows !== undefined ? { allowEmptyRows: args.allowEmptyRows } : {}),
332
+ ...(exercises !== undefined ? { exercises } : {}),
333
+ };
334
+ return { spec, warnings };
335
+ }
336
+ export async function fioriRenderSmokeHandler(args, ctx,
337
+ // Test seam: injected runSmoke deps (browser/openApp/renderSmokeEnabled). In
338
+ // production this is undefined and the real config-driven deps are used.
339
+ deps) {
340
+ if (!args.appUrl) {
341
+ return {
342
+ content: 'error: appUrl is required — the LOCAL AUTHENTICATED PREVIEW URL (the `npm run start` / ' +
343
+ '`fiori run` URL, e.g. http://localhost:8080/index.html), NOT the deployed BSP URL.',
344
+ is_error: true,
345
+ };
346
+ }
347
+ // FE mode (no appDir): byte-identical to Track 2 — `args` is the spec, no warnings.
348
+ // Freestyle mode (appDir): derive + merge, and surface the derivation warnings.
349
+ let spec = args;
350
+ let warnings;
351
+ if (args.appDir) {
352
+ const derived = resolveFreestyleSmokeSpec(args, ctx);
353
+ if ('error' in derived) {
354
+ return {
355
+ content: `error: could not derive a freestyle smoke spec from appDir "${args.appDir}" — ${derived.error}`,
356
+ is_error: true,
357
+ };
358
+ }
359
+ spec = derived.spec;
360
+ warnings = derived.warnings;
361
+ }
362
+ // The render_smoke config toggle (plain default true) gates whether a browser
363
+ // is actually launched; when off, runSmoke returns a skipped manual result.
364
+ const cfg = await loadConfig();
365
+ const runDeps = { renderSmokeEnabled: resolveRenderSmoke(cfg), ...deps };
366
+ const result = await runSmoke(spec, runDeps);
367
+ // A skip is NOT a tool error — it's a valid `verification:'manual'` outcome the
368
+ // caller must see and act on (bring the preview up, etc.). Return the full
369
+ // SmokeResult as JSON either way. In freestyle mode attach the derivation
370
+ // warnings (always present — even []) so the model sees skipped presses/routes.
371
+ const payload = warnings !== undefined ? { ...result, warnings } : result;
372
+ return { content: JSON.stringify(payload, null, 2) };
373
+ }
374
+ // ─────────────────────────────── fiori_sample_search ──────────────────────
375
+ export async function fioriSampleSearchHandler(args, _ctx) {
376
+ if (!args.text || !args.text.trim()) {
377
+ return { content: 'error: text is required — free-text keywords to match against the sample corpus', is_error: true };
378
+ }
379
+ // searchSamples is pure (Task 2): score the imported index, keep only real
380
+ // matches, ordered by descending score. Shape each hit as a compact,
381
+ // CatalogIndexItem-style summary + its score for the model to pick from.
382
+ // Clamp at the tool boundary: a model-supplied 0, negative, or fractional max
383
+ // would otherwise reach searchSamples' slice as-is (0 → no hits at all, a
384
+ // negative → silently drops from the tail). Undefined still means "default".
385
+ const max = args.max === undefined ? undefined : Math.max(1, Math.floor(args.max) || 1);
386
+ const hits = searchSamples(SAMPLE_INDEX, { text: args.text, control: args.control, max });
387
+ const summaries = hits.map(({ entry, score }) => ({
388
+ name: entry.name,
389
+ control: entry.control,
390
+ library: entry.library,
391
+ description: entry.description,
392
+ keywords: entry.keywords,
393
+ vendored: entry.sourceRef.vendored,
394
+ score,
395
+ }));
396
+ return { content: JSON.stringify(summaries, null, 2) };
397
+ }
398
+ // ─────────────────────────────── fiori_sample_get ─────────────────────────
399
+ export async function fioriSampleGetHandler(args, _ctx) {
400
+ if (!args.name || !args.name.trim()) {
401
+ return { content: 'error: name is required — the sample name from fiori_sample_search (e.g. "sap.m/Wizard")', is_error: true };
402
+ }
403
+ const entry = SAMPLE_INDEX.find((e) => e.name === args.name);
404
+ if (!entry) {
405
+ return {
406
+ content: `error: sample "${args.name}" is not found in the sample index — call fiori_sample_search first to get a valid name.`,
407
+ is_error: true,
408
+ };
409
+ }
410
+ // loadSampleSource (Task 3): vendored → bundled SAMPLE_SOURCES; un-vendored →
411
+ // lazy fetch + cache. Its typed errors carry actionable, model-facing messages
412
+ // (fall back to recipes + sap-docs) — surface them verbatim, don't mask them.
413
+ try {
414
+ const loaded = await loadSampleSource(entry, {});
415
+ return {
416
+ content: JSON.stringify({ name: entry.name, files: loaded.files, license: 'Apache-2.0', attribution: SAMPLE_ATTRIBUTION }, null, 2),
417
+ };
418
+ }
419
+ catch (err) {
420
+ if (err instanceof SampleFetchError || err instanceof UnsafeSampleNameError) {
421
+ return { content: `error: ${err.message}`, is_error: true };
422
+ }
423
+ return { content: `error: could not load sample "${args.name}" — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
424
+ }
425
+ }
426
+ // ─────────────────────────────── registration ─────────────────────────────
427
+ registerTool({
428
+ name: 'fiori_scaffold',
429
+ description: 'Scaffold a complete freestyle SAPUI5 app (webapp/ tree: manifest.json, index.html, ' +
430
+ 'Component.js, a Main view+controller, i18n, ui5.yaml, package.json) into a project ' +
431
+ 'directory. Built-in CSPeach Fiori engine — no pnpm or repo needed. Optionally wires a ' +
432
+ 'primary OData V2/V4 service. basePath is sandboxed to the project root.',
433
+ isMutating: true,
434
+ category: 'fiori',
435
+ flagGated: true,
436
+ input_schema: {
437
+ type: 'object',
438
+ properties: {
439
+ basePath: { type: 'string', description: 'Directory to scaffold into (relative to project root, created if missing).' },
440
+ appId: { type: 'string', description: 'App namespace in reverse-domain form, e.g. "zso.orderlist".' },
441
+ appTitle: { type: 'string', description: 'Human-readable app title. Defaults to appId.' },
442
+ template: { type: 'string', enum: ['basic', 'worklist', 'listdetail'], description: 'Base template. Default "basic".' },
443
+ serviceUrl: { type: 'string', description: 'Backend host for the primary OData service, e.g. "https://host:44300".' },
444
+ servicePath: { type: 'string', description: 'Relative OData service path, e.g. "/sap/opu/odata4/zso/srv/".' },
445
+ serviceVersion: { type: 'string', enum: ['2.0', '4.0'], description: 'OData version of the primary service. Default "4.0".' },
446
+ typescript: { type: 'boolean', description: 'Generate a TypeScript app. Default false.' },
447
+ ui5Version: { type: 'string', description: 'UI5 version for manifest minUI5Version + preview. Default 1.120.0. Do not go below 1.84.' },
448
+ },
449
+ required: ['basePath', 'appId'],
450
+ },
451
+ handler: fioriScaffoldHandler,
452
+ });
453
+ registerTool({
454
+ name: 'fiori_apply',
455
+ description: 'Apply a catalog pattern (viz-chart or value-help) into an already-scaffolded app. ' +
456
+ 'Renders the entry, writes its files, deep-merges manifest.json (errors loudly on ' +
457
+ 'conflict — never silent last-wins), and appends i18n keys. Call fiori_list first to ' +
458
+ 'read each entry\'s param specs. appDir is sandboxed to the project root.',
459
+ isMutating: true,
460
+ category: 'fiori',
461
+ flagGated: true,
462
+ input_schema: {
463
+ type: 'object',
464
+ properties: {
465
+ appDir: { type: 'string', description: 'Root directory of the already-scaffolded app (relative to project root).' },
466
+ entry: { type: 'string', description: 'Catalog entry name. Use fiori_list to see available entries (e.g. "viz-chart", "value-help").' },
467
+ params: {
468
+ type: 'object',
469
+ description: 'Entry parameters as an object. The required params per entry are returned by fiori_list.',
470
+ },
471
+ },
472
+ required: ['appDir', 'entry'],
473
+ },
474
+ handler: fioriApplyHandler,
475
+ });
476
+ registerTool({
477
+ name: 'fiori_list',
478
+ description: 'List the Fiori engine catalog entries (the patterns that need a frozen template: ' +
479
+ 'viz-chart, value-help) with each entry\'s title, description, libraries, and full ' +
480
+ 'param spec. Read this before fiori_apply to build a correct params object in one shot.',
481
+ isMutating: false,
482
+ category: 'fiori',
483
+ flagGated: true,
484
+ input_schema: { type: 'object', properties: {} },
485
+ handler: fioriListHandler,
486
+ });
487
+ registerTool({
488
+ name: 'fiori_deploy_config',
489
+ description: 'Write the ABAP-repository (BSP) deploy configuration for a scaffolded app: ui5-deploy.yaml ' +
490
+ 'plus "deploy" / "deploy-test" package.json scripts. Validates BSP app name (≤15 chars, ' +
491
+ 'uppercase), package namespace, and transport format. Credentials are NEVER written — ' +
492
+ 'fiori deploy takes env-var NAMES at deploy time. appDir is sandboxed to the project root.',
493
+ isMutating: true,
494
+ category: 'fiori',
495
+ flagGated: true,
496
+ input_schema: {
497
+ type: 'object',
498
+ properties: {
499
+ appDir: { type: 'string', description: 'Root directory of the scaffolded app (relative to project root).' },
500
+ url: { type: 'string', description: 'ABAP system base URL, e.g. "https://host:44300".' },
501
+ client: { type: 'string', description: 'SAP client, e.g. "100".' },
502
+ name: { type: 'string', description: 'BSP application name — max 15 chars, UPPERCASE, Z/Y prefix.' },
503
+ package: { type: 'string', description: 'ABAP package: Z-/Y-prefixed, $TMP, or a registered /namespace/pkg.' },
504
+ transport: { type: 'string', description: 'Workbench transport (e.g. "S4HK903359"). Required unless package is $TMP.' },
505
+ description: { type: 'string', description: 'BSP application description.' },
506
+ ignoreCertErrors: { type: 'boolean', description: 'Accept self-signed certificates (dev systems only). Default false.' },
507
+ },
508
+ required: ['appDir', 'url', 'client', 'name', 'package'],
509
+ },
510
+ handler: fioriDeployConfigHandler,
511
+ });
512
+ registerTool({
513
+ name: 'fiori_scaffold_fe',
514
+ description: 'Scaffold a trivial Fiori Elements app shell (List Report Object Page, Worklist, Overview Page, or Analytical List Page) ' +
515
+ 'against an ALREADY-PUBLISHED OData service. Driven entirely by the backend @UI annotations on the ' +
516
+ 'CDS projection/DDLX — authors NO local annotation.xml (not an FE generator). metadata is OPTIONAL ' +
517
+ '(the running app reads it from the live service); pass it only if you already have it. basePath ' +
518
+ 'sandboxed to the project root.',
519
+ isMutating: true,
520
+ category: 'fiori',
521
+ flagGated: true,
522
+ input_schema: {
523
+ type: 'object',
524
+ properties: {
525
+ basePath: { type: 'string', description: 'Directory to scaffold into (relative to project root).' },
526
+ appId: { type: 'string', description: 'App namespace, e.g. "z.tcrs.courses".' },
527
+ appTitle: { type: 'string', description: 'Human-readable title. Defaults to appId.' },
528
+ template: { type: 'string', enum: ['lrop', 'worklist', 'ovp', 'alp'], description: 'FE template: lrop (List Report), worklist, ovp (Overview Page), or alp (Analytical List Page). Default "lrop". All four ship.' },
529
+ serviceUrl: { type: 'string', description: 'Backend host, e.g. "https://host:44300".' },
530
+ servicePath: { type: 'string', description: 'OData service path, e.g. "/sap/opu/odata4/sap/zc_x/srvd/sap/zc_x/0001/".' },
531
+ serviceVersion: { type: 'string', enum: ['2.0', '4.0'], description: 'OData version. Default "4.0".' },
532
+ metadata: { type: 'string', description: 'OPTIONAL service $metadata (EDMX XML). The writer does not need it; pass only if you already have it.' },
533
+ client: { type: 'string', description: 'OPTIONAL SAP client (e.g. "100"), written to the manifest for the proxy layer.' },
534
+ mainEntity: { type: 'string', description: 'The entity set to bind, e.g. "Course".' },
535
+ ui5Version: { type: 'string', description: 'UI5 version. Default 1.120.0.' },
536
+ localAnnotations: {
537
+ type: 'object',
538
+ description: 'OPTIONAL local annotations.xml for a FOREIGN OData service you do NOT own (cannot add backend @UI). ' +
539
+ 'Wires the given EDMX as a LOCAL ODataAnnotation dataSource (annotations/<technicalName>.xml) that ' +
540
+ 'resolves at runtime — no catalog fetch. Omit for services whose backend already carries @UI annotations.',
541
+ properties: {
542
+ technicalName: { type: 'string', description: 'Local annotation name; also the dataSource key and file name (annotations/<technicalName>.xml).' },
543
+ xml: { type: 'string', description: 'The annotations EDMX (UI.LineItem/HeaderInfo/SelectionFields/Facets in EDMX form).' },
544
+ },
545
+ required: ['technicalName', 'xml'],
546
+ },
547
+ },
548
+ required: ['basePath', 'appId', 'serviceUrl', 'servicePath', 'mainEntity'],
549
+ },
550
+ handler: fioriScaffoldFeHandler,
551
+ });
552
+ registerTool({
553
+ name: 'fiori_fe_extend',
554
+ description: 'Add a Fiori Elements extension point to an EXISTING V4 FE app on disk via @sap-ux/fe-fpm-writer: ' +
555
+ 'a custom-column (table column + fragment), custom-action (toolbar/table action), custom-section ' +
556
+ '(object-page section + fragment), or controller-extension (a .controller.js/.ts + manifest wiring). ' +
557
+ 'V4 Fiori Elements ONLY (LROP/Worklist/ALP/FEOP) — OVP and all OData V2 apps are refused with a typed ' +
558
+ 'error. params map 1:1 to fe-fpm-writer\'s CustomTableColumn / CustomAction / CustomSection / ' +
559
+ 'ControllerExtension config and are passed verbatim. basePath is sandboxed to the project root.',
560
+ isMutating: true,
561
+ category: 'fiori',
562
+ flagGated: true,
563
+ input_schema: {
564
+ type: 'object',
565
+ properties: {
566
+ basePath: { type: 'string', description: 'App root of the existing V4 FE app (the folder containing webapp/manifest.json), relative to the project root.' },
567
+ op: {
568
+ type: 'string',
569
+ enum: [...FE_EXTEND_OPS],
570
+ description: 'Extension point to add: custom-column, custom-action, custom-section, or controller-extension.',
571
+ },
572
+ params: {
573
+ type: 'object',
574
+ description: 'Extension config, passed VERBATIM to the matching fe-fpm-writer generator. Required fields per op: ' +
575
+ 'custom-column → { name, target (routing target, e.g. "<Entity>List"), targetEntity, position { placement: "After"|"Before"|"End", anchor? }, header }; ' +
576
+ 'custom-action → { name, target { page (routing target), control ("@com.sap.vocabularies.UI.v1.LineItem" for a table action) }, settings { text } }; ' +
577
+ 'custom-section → { name, target (object-page routing target, e.g. "<Entity>ObjectPage"), title }; ' +
578
+ 'controller-extension → { name, extension ("ListReport"|"ObjectPage" or a page-target object) }. ' +
579
+ 'eventHandler is optional on column/action/section.',
580
+ },
581
+ },
582
+ required: ['basePath', 'op', 'params'],
583
+ },
584
+ handler: feExtendHandler,
585
+ });
586
+ registerTool({
587
+ name: 'fiori_render_smoke',
588
+ description: 'Content-asserting render smoke (Track 2 D-4): launch a headless Edge/Chrome against the LOCAL ' +
589
+ 'AUTHENTICATED PREVIEW (the `npm run start` URL — NEVER the deployed BSP URL) and assert the app ' +
590
+ 'actually rendered — booted with no console errors, no failed OData ($metadata/$batch/entity) calls, ' +
591
+ 'expected columns present, and (for a list) rows returned. TWO MODES: FIORI-ELEMENTS mode — pass the ' +
592
+ 'spec fields (expectedColumns/controlKind/…) yourself. FREESTYLE mode — pass `appDir` (the scaffolded ' +
593
+ 'app root) plus `appUrl` and the spec is DERIVED from the app\'s own webapp/ views + i18n + manifest ' +
594
+ '(controlKind, expected columns, press/route exercises); any spec field you ALSO pass overrides the ' +
595
+ 'derived one. Derivation notes surface as a `warnings` array in the result. SKIPPABLE BUT NEVER ' +
596
+ 'SILENTLY GREEN: when the render_smoke config is off, no browser is found, the preview is unreachable, ' +
597
+ 'or the page is a SAP logon form, it returns verification:"manual" + passed:false with a "skipped" ' +
598
+ 'check explaining why — it can never report a passing smoke without a real render. Flag-gated (enabled ' +
599
+ 'with local_build), read-only.',
600
+ isMutating: false,
601
+ category: 'fiori',
602
+ flagGated: true,
603
+ input_schema: {
604
+ type: 'object',
605
+ properties: {
606
+ appUrl: {
607
+ type: 'string',
608
+ description: 'The LOCAL AUTHENTICATED PREVIEW URL (the `npm run start` / `fiori run` URL that proxies to the ' +
609
+ 'real backend with your auth), e.g. "http://localhost:8080/index.html". NOT the deployed BSP URL.',
610
+ },
611
+ appDir: {
612
+ type: 'string',
613
+ description: 'FREESTYLE mode only: the scaffolded app root (relative to the project root, sandboxed). When ' +
614
+ 'given, the SmokeSpec is DERIVED from that app\'s webapp/ views + i18n + manifest, and any spec ' +
615
+ 'field you also pass overrides the derived one. Omit for Fiori Elements mode (pass the spec fields directly).',
616
+ },
617
+ expectedColumns: {
618
+ type: 'array',
619
+ items: { type: 'string' },
620
+ description: 'Column headers that MUST be present (checked case-insensitively). Omit for non-list controls.',
621
+ },
622
+ controlKind: {
623
+ type: 'string',
624
+ enum: ['table', 'cards', 'chart', 'form', 'custom'],
625
+ description: 'The app\'s primary control. Only "table" gets a row-count assertion.',
626
+ },
627
+ allowEmptyRows: {
628
+ type: 'boolean',
629
+ description: 'When true, a table that renders 0 rows still passes (empty list acknowledged). Default false.',
630
+ },
631
+ exercises: {
632
+ type: 'array',
633
+ description: 'Post-boot interactions to exercise (press/route). Declared for Track 3; unused in Track 2.',
634
+ items: { type: 'object' },
635
+ },
636
+ },
637
+ required: ['appUrl'],
638
+ },
639
+ handler: fioriRenderSmokeHandler,
640
+ });
641
+ registerTool({
642
+ name: 'fiori_sample_search',
643
+ description: 'Search the vendored UI5 Demo Kit sample corpus (grounding for the freestyle composer): free-text ' +
644
+ 'keywords + an optional exact control name return the best-matching samples as scored summaries ' +
645
+ '(name, control, library, description, keywords, vendored flag, score), highest score first. Use this ' +
646
+ 'to FIND a real UI5 sample to ground a control against, then fiori_sample_get to read its source. ' +
647
+ 'Read-only; flag-gated (enabled with local_build).',
648
+ isMutating: false,
649
+ category: 'fiori',
650
+ flagGated: true,
651
+ input_schema: {
652
+ type: 'object',
653
+ properties: {
654
+ text: { type: 'string', description: 'Free-text keywords, e.g. "wizard step" or "value help dialog".' },
655
+ control: { type: 'string', description: 'OPTIONAL exact UI5 control name (e.g. "Wizard") — an exact match dominates the ranking.' },
656
+ max: { type: 'number', description: 'Max results to return. Default 5.' },
657
+ },
658
+ required: ['text'],
659
+ },
660
+ handler: fioriSampleSearchHandler,
661
+ });
662
+ registerTool({
663
+ name: 'fiori_sample_get',
664
+ description: 'Fetch one UI5 sample\'s source files by name (the grounding payload the composer reads). Returns ' +
665
+ '{ files: [{ path, content }], license: "Apache-2.0", attribution }. Vendored samples are served from ' +
666
+ 'the bundled corpus; un-vendored ones are lazily fetched and cached. The Apache-2.0 attribution line ' +
667
+ 'is ALWAYS included. Call fiori_sample_search first to get a valid name. An unknown name, or a sample ' +
668
+ 'that cannot be retrieved, returns a clean error steering you to the recipes catalog + sap-docs. ' +
669
+ 'Read-only; flag-gated (enabled with local_build).',
670
+ isMutating: false,
671
+ category: 'fiori',
672
+ flagGated: true,
673
+ input_schema: {
674
+ type: 'object',
675
+ properties: {
676
+ name: { type: 'string', description: 'The sample name from fiori_sample_search, e.g. "sap.m/Wizard".' },
677
+ },
678
+ required: ['name'],
679
+ },
680
+ handler: fioriSampleGetHandler,
681
+ });