@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,340 @@
1
+ /**
2
+ * `extend_model_insert` — the deterministic anchored-insertion tool behind the
3
+ * `abap-extend-model` skill (B1). The skill reads the live source with
4
+ * `sap_get_source`, calls THIS tool to produce the modified source via anchored
5
+ * insertion (never blind LLM string-editing), shows the preview, then drives the
6
+ * safe write chain (snapshot → sap_set_source → sap_syntax_check → sap_activate
7
+ * → republish-if-needed → rollback) with the existing sap_* tools.
8
+ *
9
+ * This tool is a PURE transform: it does not touch the SAP system, so it is
10
+ * non-mutating. It is flag-gated with the other local-build tools (rides the
11
+ * `local_build` switch via LOCAL_BUILD_TOOLS in tools/local-build.ts).
12
+ *
13
+ * See docs/superpowers/specs/2026-06-24-revision-aware-cspeach-design.md §5.2.
14
+ */
15
+ import { registerTool } from '../index.js';
16
+ import { insertCdsField, insertDdlxLineItem, promoteCdsFieldToLineItem, addTableField, insertBdefField, buildClauseLine, buildHandlerMethod, insertBdefClause, insertHandlerMethod, createHandlerClassSkeleton, classBlockSpan, resolveBehaviorAlias, insertProjectionUse, insertMdeActionButton, insertDdlxHeaderInfo, insertDdlxSelectionField, insertDdlxIdentification, insertDdlxFacet, insertDdlxDataPoint, insertDdlxChart, insertCdsValueHelp, insertBdefSideEffects, } from './anchored-insert.js';
17
+ async function extendModelInsertHandler(args, _ctx) {
18
+ const { kind } = args;
19
+ // bdef-stub is branched BEFORE the source/field guard (which does not apply to this kind).
20
+ if (kind === 'bdef-stub') {
21
+ return handleBdefStub(args);
22
+ }
23
+ // projection-use and mde-action-button use `source` but NOT `field` — branch BEFORE the field guard.
24
+ if (kind === 'projection-use') {
25
+ if (typeof args.source !== 'string' || typeof args.useClause !== 'string' || args.useClause.trim() === '') {
26
+ return { content: 'error: projection-use requires source and a non-empty useClause (e.g. "action Approve")', is_error: true };
27
+ }
28
+ try {
29
+ const modified = insertProjectionUse(args.source, { alias: args.alias, useClause: args.useClause });
30
+ return { content: JSON.stringify({ modified, summary: `Exposed "use ${args.useClause}" in the projection BDEF${args.alias ? ` (alias ${args.alias})` : ''}.` }) };
31
+ }
32
+ catch (err) {
33
+ return { content: `error: projection-use failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
34
+ }
35
+ }
36
+ if (kind === 'mde-action-button') {
37
+ if (typeof args.source !== 'string' || typeof args.action !== 'string' || args.action.trim() === '' || typeof args.anchorField !== 'string' || args.anchorField.trim() === '') {
38
+ return { content: 'error: mde-action-button requires source and non-empty action and anchorField', is_error: true };
39
+ }
40
+ try {
41
+ const modified = insertMdeActionButton(args.source, {
42
+ action: args.action, label: args.label, position: args.position, anchorField: args.anchorField,
43
+ });
44
+ return { content: JSON.stringify({ modified, summary: `Added FE action button for '${args.action}' above ${args.anchorField} in the metadata extension.` }) };
45
+ }
46
+ catch (err) {
47
+ return { content: `error: mde-action-button failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
48
+ }
49
+ }
50
+ // ddlx-headerinfo uses `source` + typeName/typeNamePlural/titleField (NOT `field`) — branch BEFORE the field guard.
51
+ if (kind === 'ddlx-headerinfo') {
52
+ if (typeof args.source !== 'string'
53
+ || typeof args.typeName !== 'string' || args.typeName.trim() === ''
54
+ || typeof args.typeNamePlural !== 'string' || args.typeNamePlural.trim() === ''
55
+ || typeof args.titleField !== 'string' || args.titleField.trim() === '') {
56
+ return { content: 'error: ddlx-headerinfo requires source, typeName, typeNamePlural and titleField', is_error: true };
57
+ }
58
+ try {
59
+ const modified = insertDdlxHeaderInfo(args.source, {
60
+ typeName: args.typeName, typeNamePlural: args.typeNamePlural,
61
+ titleField: args.titleField, descriptionField: args.descriptionField,
62
+ });
63
+ return { content: JSON.stringify({ modified, summary: `Added a @UI.headerInfo block (typeName '${args.typeName}', title ${args.titleField}) above the annotate view.` }) };
64
+ }
65
+ catch (err) {
66
+ return { content: `error: ddlx-headerinfo failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
67
+ }
68
+ }
69
+ // ddlx-identification uses `source`; `field` is optional (FOR_ACTION button needs none) — branch BEFORE the field guard.
70
+ if (kind === 'ddlx-identification') {
71
+ if (typeof args.source !== 'string') {
72
+ return { content: 'error: ddlx-identification requires source', is_error: true };
73
+ }
74
+ const forAction = (typeof args.action === 'string' && args.action.trim() !== '')
75
+ ? { dataAction: args.action, label: args.label ?? args.action }
76
+ : undefined;
77
+ if (!forAction && (typeof args.field !== 'string' || args.field.trim() === '')) {
78
+ return { content: 'error: ddlx-identification requires either field (plain identification) or action (FOR_ACTION button)', is_error: true };
79
+ }
80
+ const position = typeof args.position === 'number' ? args.position : 10;
81
+ try {
82
+ const modified = insertDdlxIdentification(args.source, { field: args.field, position, forAction });
83
+ const summary = forAction
84
+ ? `Added a FOR_ACTION @UI.identification button for '${forAction.dataAction}' (Object Page).`
85
+ : `Added @UI.identification (position ${position}) on ${args.field}.`;
86
+ return { content: JSON.stringify({ modified, summary }) };
87
+ }
88
+ catch (err) {
89
+ return { content: `error: ddlx-identification failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
90
+ }
91
+ }
92
+ // ddlx-facet uses `source` + facetId/label/fieldGroupQualifier/fields (NOT a single `field`) — branch BEFORE the field guard.
93
+ if (kind === 'ddlx-facet') {
94
+ if (typeof args.source !== 'string'
95
+ || typeof args.facetId !== 'string' || args.facetId.trim() === ''
96
+ || typeof args.label !== 'string' || args.label.trim() === ''
97
+ || typeof args.fieldGroupQualifier !== 'string' || args.fieldGroupQualifier.trim() === ''
98
+ || !Array.isArray(args.fields)) {
99
+ return { content: 'error: ddlx-facet requires source, facetId, label, fieldGroupQualifier and a fields array', is_error: true };
100
+ }
101
+ const position = typeof args.position === 'number' ? args.position : 10;
102
+ try {
103
+ const modified = insertDdlxFacet(args.source, {
104
+ facetId: args.facetId, label: args.label, fieldGroupQualifier: args.fieldGroupQualifier,
105
+ position, fields: args.fields,
106
+ });
107
+ return { content: JSON.stringify({ modified, summary: `Added facet '${args.facetId}' (field group '${args.fieldGroupQualifier}') + @UI.fieldGroup on ${args.fields.length} field(s).` }) };
108
+ }
109
+ catch (err) {
110
+ return { content: `error: ddlx-facet failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
111
+ }
112
+ }
113
+ // ddlx-chart uses `source` + chartType/dimensions/measures (NOT a single `field`) — branch BEFORE the field guard.
114
+ if (kind === 'ddlx-chart') {
115
+ if (typeof args.source !== 'string'
116
+ || typeof args.chartType !== 'string'
117
+ || !Array.isArray(args.dimensions) || args.dimensions.length === 0
118
+ || !Array.isArray(args.measures) || args.measures.length === 0) {
119
+ return { content: 'error: ddlx-chart requires source, chartType and non-empty dimensions and measures arrays', is_error: true };
120
+ }
121
+ try {
122
+ const modified = insertDdlxChart(args.source, {
123
+ qualifier: args.qualifier, chartType: args.chartType,
124
+ dimensions: args.dimensions, measures: args.measures,
125
+ });
126
+ return { content: JSON.stringify({ modified, summary: `Added a #${args.chartType} @UI.chart + paired @UI.presentationVariant (#AS_CHART)${args.qualifier ? ` (qualifier ${args.qualifier})` : ''} above the annotate view.` }) };
127
+ }
128
+ catch (err) {
129
+ return { content: `error: ddlx-chart failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
130
+ }
131
+ }
132
+ // bdef-sideeffects uses `source` + sourceFields/targetFields (NOT `field`) — branch BEFORE the field guard.
133
+ if (kind === 'bdef-sideeffects') {
134
+ if (typeof args.source !== 'string'
135
+ || !Array.isArray(args.sourceFields) || args.sourceFields.length === 0
136
+ || !Array.isArray(args.targetFields) || args.targetFields.length === 0) {
137
+ return { content: 'error: bdef-sideeffects requires source and non-empty sourceFields and targetFields arrays', is_error: true };
138
+ }
139
+ try {
140
+ const modified = insertBdefSideEffects(args.source, {
141
+ entityAlias: args.entityAlias, sourceFields: args.sourceFields, targetFields: args.targetFields,
142
+ });
143
+ return { content: JSON.stringify({ modified, summary: `Added a side effects clause (field ${args.sourceFields.join(', ')} affects field ${args.targetFields.join(', ')}) to the managed BDEF${args.entityAlias ? ` (alias ${args.entityAlias})` : ''}.` }) };
144
+ }
145
+ catch (err) {
146
+ return { content: `error: bdef-sideeffects failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
147
+ }
148
+ }
149
+ const { source, field } = args;
150
+ if (typeof source !== 'string' || typeof field !== 'string' || field.trim() === '') {
151
+ return { content: 'error: source (string) and field (non-empty string) are required', is_error: true };
152
+ }
153
+ try {
154
+ let modified;
155
+ let summary;
156
+ if (kind === 'cds-field') {
157
+ modified = insertCdsField(source, field);
158
+ summary = `Inserted element "${field}" into the CDS view's element list (prior last element gained a trailing comma).`;
159
+ }
160
+ else if (kind === 'ddlx-lineitem') {
161
+ const position = typeof args.position === 'number' ? args.position : 10;
162
+ modified = insertDdlxLineItem(source, field, position);
163
+ summary = `Added "${field}" as an @UI.lineItem column at position ${position} in the DDLX metadata extension.`;
164
+ }
165
+ else if (kind === 'cds-lineitem') {
166
+ const position = typeof args.position === 'number' ? args.position : 10;
167
+ modified = promoteCdsFieldToLineItem(source, field, position);
168
+ summary = `Promoted field '${field}' to a list column (@UI.lineItem position ${position}) in the CDS view.`;
169
+ }
170
+ else if (kind === 'table-field') {
171
+ modified = addTableField(source, field);
172
+ summary = `Added field '${field}' to the table.`;
173
+ }
174
+ else if (kind === 'bdef-field') {
175
+ const target = args.target === 'mapping' ? 'mapping' : 'field';
176
+ modified = insertBdefField(source, { alias: args.alias, target, clause: field });
177
+ summary = `Added ${target} '${field}' to BDEF${args.alias ? ` (alias ${args.alias})` : ''}.`;
178
+ }
179
+ else if (kind === 'ddlx-selectionfield') {
180
+ const position = typeof args.position === 'number' ? args.position : 10;
181
+ modified = insertDdlxSelectionField(source, { field, position });
182
+ summary = `Added "${field}" as an @UI.selectionField filter at position ${position} in the DDLX metadata extension.`;
183
+ }
184
+ else if (kind === 'ddlx-datapoint') {
185
+ if (typeof args.title !== 'string' || args.title.trim() === '') {
186
+ return { content: 'error: ddlx-datapoint requires a non-empty title', is_error: true };
187
+ }
188
+ modified = insertDdlxDataPoint(source, { field, title: args.title, criticalityField: args.criticalityField });
189
+ summary = `Added a @UI.dataPoint (title '${args.title}'${args.criticalityField ? `, criticality ${args.criticalityField}` : ''}) on ${field}.`;
190
+ }
191
+ else if (kind === 'cds-valuehelp') {
192
+ if (typeof args.entity !== 'string' || args.entity.trim() === '' || typeof args.element !== 'string' || args.element.trim() === '') {
193
+ return { content: 'error: cds-valuehelp requires a non-empty entity and element', is_error: true };
194
+ }
195
+ modified = insertCdsValueHelp(source, {
196
+ field, entity: args.entity, element: args.element, additionalBinding: args.additionalBinding,
197
+ });
198
+ summary = `Added a @Consumption.valueHelpDefinition (entity '${args.entity}', element ${args.element}) above ${field} in the CDS view.`;
199
+ }
200
+ else {
201
+ return {
202
+ content: `error: unknown kind "${String(kind)}" — expected "cds-field", "ddlx-lineitem", "cds-lineitem", "table-field", "bdef-field", "bdef-stub", "projection-use", "mde-action-button", "ddlx-headerinfo", "ddlx-selectionfield", "ddlx-identification", "ddlx-facet", "ddlx-datapoint", "ddlx-chart", "cds-valuehelp" or "bdef-sideeffects"`,
203
+ is_error: true,
204
+ };
205
+ }
206
+ return { content: JSON.stringify({ modified, summary }) };
207
+ }
208
+ catch (err) {
209
+ return {
210
+ content: `error: anchored insertion failed — ${err instanceof Error ? err.message : String(err)}`,
211
+ is_error: true,
212
+ };
213
+ }
214
+ }
215
+ registerTool({
216
+ name: 'extend_model_insert',
217
+ description: 'Deterministically insert a field or @UI.lineItem column into existing CDS/DDLX source via '
218
+ + 'anchored insertion (no DSL parser, no blind string-editing). Returns the modified source for '
219
+ + 'the caller to snapshot, write (sap_set_source), syntax-check, and activate. kind="cds-field" '
220
+ + 'adds an element to a CDS view body; kind="ddlx-lineitem" adds an @UI.lineItem-annotated field '
221
+ + 'to a DDLX metadata extension (the Fiori Elements column-add); kind="cds-lineitem" promotes an '
222
+ + 'EXISTING exposed CDS-view field to a list column by inserting @UI.lineItem above its declaration '
223
+ + '(fail-safe: refuses multi-line/computed elements, idempotent); kind="table-field" appends a '
224
+ + 'NON-KEY field to a CDS `define table` body (refuses key fields — destructive); kind="bdef-field" '
225
+ + 'adds a `field ( … )` or mapping line to a MANAGED BDEF entity (target="field"|"mapping", optional '
226
+ + 'alias; refuses unmanaged); kind="bdef-stub" adds a validation/determination/action clause to a '
227
+ + 'MANAGED BDEF and the corresponding handler method skeleton to the CCIMP include; '
228
+ + 'kind="projection-use" exposes a `use action/function/association` line inside a PROJECTION BDEF '
229
+ + 'entity body (non-projection BDEFs refused); kind="mde-action-button" inserts a @UI.lineItem '
230
+ + 'FOR_ACTION button annotation above an anchor field in a metadata extension; '
231
+ + 'kind="ddlx-headerinfo" inserts a view-level @UI.headerInfo block above the `annotate view` line '
232
+ + '(Object Page header — idempotent, refuses a second block); kind="ddlx-selectionfield" adds an '
233
+ + '@UI.selectionField filter to a DDLX metadata extension (in place above an exposed field, or '
234
+ + 'appends the field + annotation when absent); kind="ddlx-identification" adds a @UI.identification '
235
+ + 'annotation on a field, or a #FOR_ACTION Object-Page action button (via `action`) — refuses a '
236
+ + 'duplicate dataAction; kind="ddlx-facet" adds a view-level @UI.facet (#FIELDGROUP_REFERENCE) above '
237
+ + 'the `annotate view` line (merged into an existing @UI.facet array, idempotent on facetId) plus a '
238
+ + 'per-field @UI.fieldGroup on each named field; kind="ddlx-datapoint" adds a @UI.dataPoint (title, '
239
+ + 'optional criticality field) above an existing field; kind="ddlx-chart" adds a view-level @UI.chart '
240
+ + 'and its paired @UI.presentationVariant (#AS_CHART) above the `annotate view` line, qualifier-'
241
+ + 'idempotent as a unit; kind="cds-valuehelp" inserts a @Consumption.valueHelpDefinition above an '
242
+ + 'EXISTING exposed CDS-view field (DDLS source; refuses multi-line/computed elements, idempotent); '
243
+ + 'kind="bdef-sideeffects" inserts a `side effects { field … affects field …; }` block into a MANAGED '
244
+ + 'BDEF entity body (refuses unmanaged). Pure transform — does not touch SAP.',
245
+ isMutating: false,
246
+ category: 'fiori',
247
+ flagGated: true,
248
+ input_schema: {
249
+ type: 'object',
250
+ properties: {
251
+ kind: {
252
+ type: 'string',
253
+ enum: ['cds-field', 'ddlx-lineitem', 'cds-lineitem', 'table-field', 'bdef-field', 'bdef-stub', 'projection-use', 'mde-action-button', 'ddlx-headerinfo', 'ddlx-selectionfield', 'ddlx-identification', 'ddlx-facet', 'ddlx-datapoint', 'ddlx-chart', 'cds-valuehelp', 'bdef-sideeffects'],
254
+ description: 'cds-field: add an element to a CDS view body. ddlx-lineitem: add an @UI.lineItem column to a DDLX metadata extension. cds-lineitem: promote an existing exposed CDS-view field to a list column (insert @UI.lineItem above its declaration). table-field: append a NON-KEY field to a CDS `define table` body (key fields refused). bdef-field: add a field/mapping line to a MANAGED BDEF entity (unmanaged refused). bdef-stub: add a validation/determination/action clause to a MANAGED BDEF and its handler skeleton to the CCIMP include. projection-use: expose a `use action/function/association` line inside a PROJECTION BDEF entity body (non-projection BDEFs refused). mde-action-button: insert a @UI.lineItem FOR_ACTION button annotation above an anchor field in a metadata extension. ddlx-headerinfo: insert a view-level @UI.headerInfo block above the `annotate view` line (Object Page header; idempotent). ddlx-selectionfield: add an @UI.selectionField filter to a DDLX metadata extension (in place above an exposed field, or append the field + annotation when absent). ddlx-identification: add an @UI.identification annotation on a field, or a #FOR_ACTION Object-Page action button (set `action`); refuses a duplicate dataAction. ddlx-facet: add a view-level @UI.facet (#FIELDGROUP_REFERENCE) above the `annotate view` line (merged into an existing @UI.facet array, idempotent on facetId) plus a per-field @UI.fieldGroup on each named field. ddlx-datapoint: add a @UI.dataPoint (title, optional criticality field) above an existing field. ddlx-chart: add a view-level @UI.chart and its paired @UI.presentationVariant (#AS_CHART) above the `annotate view` line (qualifier-idempotent as a unit). cds-valuehelp: insert a @Consumption.valueHelpDefinition above an existing exposed CDS-view field (DDLS source; refuses multi-line/computed elements, idempotent). bdef-sideeffects: insert a `side effects { field … affects field …; }` block into a MANAGED BDEF entity body (unmanaged refused).',
255
+ },
256
+ source: { type: 'string', description: 'Full current source of the CDS view, DDLX metadata extension, CDS table, or managed BDEF (from sap_get_source). Required for all kinds except bdef-stub.' },
257
+ field: {
258
+ type: 'string',
259
+ description: 'cds-field: the element to add, e.g. "Priority" or "priority as Priority". ddlx-lineitem / cds-lineitem: the field name to expose as a column. table-field: the field clause WITHOUT trailing ";", e.g. "reference : zde_reference". bdef-field: target="field" → a full field declaration "field ( readonly ) Reference"; target="mapping" → an assignment "Reference = reference" (no trailing ";"). ddlx-selectionfield: the field to expose as a filter. ddlx-identification: the field to annotate (required for a plain identification; optional anchor for a #FOR_ACTION button). Required for all kinds except bdef-stub, ddlx-headerinfo, and a FOR_ACTION ddlx-identification.',
260
+ },
261
+ position: { type: 'number', description: 'kind=ddlx-lineitem and kind=cds-lineitem: the @UI.lineItem column position (default 10). kind=ddlx-selectionfield: the @UI.selectionField filter order (default 10). kind=ddlx-identification: the @UI.identification position (default 10). kind=mde-action-button: optional column order for the inserted #FOR_ACTION entry. Ignored for other kinds.' },
262
+ alias: { type: 'string', description: 'kind=bdef-field: the behavior entity alias to extend (`alias <ALIAS>`). Optional only when the BDEF defines exactly one behavior; required when several exist. kind=bdef-stub: optional when the BDEF defines exactly one behavior; required when several exist (mirrors bdef-field behavior). kind=projection-use: the projection behavior entity alias; required when the projection BDEF defines more than one behavior.' },
263
+ target: { type: 'string', enum: ['field', 'mapping'], description: 'kind=bdef-field: "field" (default) adds a `field ( … )` line to the entity body; "mapping" adds an assignment line to the entity\'s mapping block.' },
264
+ bdefSource: { type: 'string', description: 'kind=bdef-stub: full managed BDEF source (from sap_get_source).' },
265
+ ccimpSource: { type: 'string', description: 'kind=bdef-stub: full CCIMP ("Local Types") include source of the behavior pool class (may be empty).' },
266
+ stubKind: { type: 'string', enum: ['validation', 'determination', 'action'], description: 'kind=bdef-stub: which clause to add.' },
267
+ name: { type: 'string', description: 'kind=bdef-stub: the validation/determination/action name (e.g. ValidateInstructorName).' },
268
+ trigger: { type: 'string', enum: ['modify', 'save'], description: 'kind=bdef-stub determination trigger point (default modify).' },
269
+ ops: { type: 'string', description: 'kind=bdef-stub validation/determination operations, e.g. "create; update;".' },
270
+ parameter: { type: 'string', description: 'kind=bdef-stub action input parameter type (CDS abstract entity).' },
271
+ result: { type: 'boolean', description: 'kind=bdef-stub action declares a result.' },
272
+ resultType: { type: 'string', description: 'kind=bdef-stub action result type (default $self; required-typed for static actions).' },
273
+ static: { type: 'boolean', description: 'kind=bdef-stub static (factory) action.' },
274
+ features: { type: 'boolean', description: 'kind=bdef-stub feature-controlled action (features : instance).' },
275
+ useClause: { type: 'string', description: 'kind=projection-use: the use clause to expose, e.g. "action Approve" or "association _Customer". Do NOT include a leading "use " or trailing ";". Required for projection-use.' },
276
+ action: { type: 'string', description: 'kind=mde-action-button: the action name to wire, e.g. "Approve" (required). kind=ddlx-identification: set this to emit a #FOR_ACTION Object-Page button for the named dataAction (omit for a plain field identification).' },
277
+ anchorField: { type: 'string', description: 'kind=mde-action-button: the field name above which to insert the action button annotation. Required for mde-action-button.' },
278
+ label: { type: 'string', description: 'kind=mde-action-button / kind=ddlx-identification (#FOR_ACTION): optional button label text. Defaults to the action name.' },
279
+ typeName: { type: 'string', description: 'kind=ddlx-headerinfo: singular Object-Page type name, e.g. "Maintenance Request". Required for ddlx-headerinfo.' },
280
+ typeNamePlural: { type: 'string', description: 'kind=ddlx-headerinfo: plural Object-Page type name, e.g. "Maintenance Requests". Required for ddlx-headerinfo.' },
281
+ titleField: { type: 'string', description: 'kind=ddlx-headerinfo: the field whose value renders as the header title. Required for ddlx-headerinfo.' },
282
+ descriptionField: { type: 'string', description: 'kind=ddlx-headerinfo: optional field for the header description line.' },
283
+ facetId: { type: 'string', description: 'kind=ddlx-facet: the facet id (idempotency key). Required for ddlx-facet.' },
284
+ fieldGroupQualifier: { type: 'string', description: 'kind=ddlx-facet: the field-group qualifier the facet targets and each per-field @UI.fieldGroup binds to. Required for ddlx-facet.' },
285
+ fields: { type: 'array', description: 'kind=ddlx-facet: the fields to place in the field group, each { field, position } — a @UI.fieldGroup annotation is added on each. Required for ddlx-facet.', items: { type: 'object', properties: { field: { type: 'string' }, position: { type: 'number' } }, required: ['field', 'position'] } },
286
+ title: { type: 'string', description: 'kind=ddlx-datapoint: the @UI.dataPoint title text. Required for ddlx-datapoint.' },
287
+ criticalityField: { type: 'string', description: 'kind=ddlx-datapoint: optional field name driving the data point criticality colour (the transform does NOT create this field).' },
288
+ entity: { type: 'string', description: 'kind=cds-valuehelp: the value-help provider CDS entity, e.g. "ZI_Customer". Required for cds-valuehelp.' },
289
+ element: { type: 'string', description: 'kind=cds-valuehelp: the provider element to bind to, e.g. "CustomerID". Required for cds-valuehelp.' },
290
+ additionalBinding: { type: 'object', description: 'kind=cds-valuehelp: optional extra value-help binding { localElement, element }.', properties: { localElement: { type: 'string' }, element: { type: 'string' } }, required: ['localElement', 'element'] },
291
+ entityAlias: { type: 'string', description: 'kind=bdef-sideeffects: the behavior entity alias to extend. Optional only when the BDEF defines exactly one behavior; required when several exist.' },
292
+ sourceFields: { type: 'array', description: 'kind=bdef-sideeffects: the trigger fields (`field <…>`), non-empty. Required for bdef-sideeffects.', items: { type: 'string' } },
293
+ targetFields: { type: 'array', description: 'kind=bdef-sideeffects: the affected fields (`affects field <…>`), non-empty. Required for bdef-sideeffects.', items: { type: 'string' } },
294
+ qualifier: { type: 'string', description: 'kind=ddlx-chart: optional qualifier binding the @UI.chart to its @UI.presentationVariant (idempotency key). Omit for an anonymous chart.' },
295
+ chartType: { type: 'string', enum: ['COLUMN', 'BAR', 'LINE', 'DONUT'], description: 'kind=ddlx-chart: the chart type, mapped to the CDS enum #COLUMN/#BAR/#LINE/#DONUT. Required for ddlx-chart.' },
296
+ dimensions: { type: 'array', description: 'kind=ddlx-chart: the chart dimension field names (non-empty). Required for ddlx-chart.', items: { type: 'string' } },
297
+ measures: { type: 'array', description: 'kind=ddlx-chart: the chart measure field names (non-empty). Required for ddlx-chart.', items: { type: 'string' } },
298
+ },
299
+ required: ['kind'],
300
+ },
301
+ handler: extendModelInsertHandler,
302
+ });
303
+ function handleBdefStub(args) {
304
+ const { bdefSource, ccimpSource, stubKind, name, alias } = args;
305
+ if (typeof bdefSource !== 'string' || typeof ccimpSource !== 'string') {
306
+ return { content: 'error: bdef-stub requires bdefSource and ccimpSource (strings)', is_error: true };
307
+ }
308
+ if (!stubKind || !['validation', 'determination', 'action'].includes(stubKind)) {
309
+ return { content: 'error: bdef-stub requires stubKind = validation|determination|action', is_error: true };
310
+ }
311
+ if (typeof name !== 'string' || name.trim() === '') {
312
+ return { content: 'error: bdef-stub requires a non-empty name', is_error: true };
313
+ }
314
+ try {
315
+ // Resolve alias: auto-picks the single behavior when no alias is given;
316
+ // throws (→ is_error) on ambiguity or missing behavior — same policy as bdef-field.
317
+ const entityAlias = alias ?? resolveBehaviorAlias(bdefSource);
318
+ const clauseLine = buildClauseLine(stubKind, {
319
+ name, ops: args.ops, trigger: args.trigger, parameter: args.parameter,
320
+ result: args.result, resultType: args.resultType, static: args.static, features: args.features,
321
+ });
322
+ const method = buildHandlerMethod(stubKind, {
323
+ name, entity: entityAlias, trigger: args.trigger, parameter: args.parameter,
324
+ result: args.result, features: args.features,
325
+ });
326
+ const modifiedBdef = insertBdefClause(bdefSource, { alias: entityAlias, clauseLine });
327
+ const className = `lhc_${entityAlias.toLowerCase()}`;
328
+ const exists = classBlockSpan(ccimpSource.split('\n'), className) !== null;
329
+ const modifiedCcimp = exists
330
+ ? insertHandlerMethod(ccimpSource, { entity: entityAlias, method })
331
+ : createHandlerClassSkeleton(ccimpSource, { entity: entityAlias, method });
332
+ const summary = `Added ${stubKind} '${name}' to BDEF (alias ${entityAlias}) and ${exists ? 'inserted the handler method into' : 'created'} ${className}.`;
333
+ return { content: JSON.stringify({ modifiedBdef, modifiedCcimp, createdHandlerClass: !exists, summary }) };
334
+ }
335
+ catch (err) {
336
+ return { content: `error: bdef-stub failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
337
+ }
338
+ }
339
+ // Exported for unit tests.
340
+ export const __extendModelInsertHandler = extendModelInsertHandler;
@@ -0,0 +1,57 @@
1
+ // Pure document text extraction. No filesystem, no network — buffer in, text
2
+ // out. Unit-tested directly; the read_document tool wraps it with containment.
3
+ import { extractText, getDocumentProxy } from 'unpdf';
4
+ import mammoth from 'mammoth';
5
+ import TurndownService from 'turndown';
6
+ /** Thrown when a PDF exceeds the caller's page ceiling — checked before full extraction. */
7
+ export class DocumentTooLargeError extends Error {
8
+ pages;
9
+ limit;
10
+ constructor(pages, limit) {
11
+ super(`document has ${pages} pages, exceeding the ${limit}-page limit`);
12
+ this.pages = pages;
13
+ this.limit = limit;
14
+ this.name = 'DocumentTooLargeError';
15
+ }
16
+ }
17
+ const NO_TEXT_WARNING = 'no extractable text — likely a scanned PDF; OCR is not supported';
18
+ export async function extractDocument(buffer, ext, opts) {
19
+ if (ext === '.pdf')
20
+ return extractPdf(buffer, opts?.maxPages);
21
+ if (ext === '.docx')
22
+ return extractDocx(buffer);
23
+ throw new Error(`extractDocument: unsupported extension "${ext}"`);
24
+ }
25
+ async function extractPdf(buffer, maxPages) {
26
+ // unpdf's bundled pdfjs rejects Buffer instances even though Buffer extends Uint8Array
27
+ // (it does an explicit constructor check). Wrapping is required.
28
+ const pdf = await getDocumentProxy(new Uint8Array(buffer));
29
+ // Check limit before full extraction to avoid unnecessary work
30
+ const total = pdf.numPages;
31
+ if (maxPages !== undefined && total > maxPages) {
32
+ throw new DocumentTooLargeError(total, maxPages);
33
+ }
34
+ // Fix 3: unpdf with mergePages:false always returns string[]; use it directly.
35
+ const { text } = await extractText(pdf, { mergePages: false });
36
+ // Fix 1: per-page emptiness detection — correctly handles mixed-content PDFs
37
+ // and avoids false-positives from literal "[page N]" text in the document.
38
+ let hasText = false;
39
+ const body = text.map((t, i) => {
40
+ const trimmed = (t ?? '').trim();
41
+ if (trimmed.length > 0)
42
+ hasText = true;
43
+ return `[page ${i + 1}]\n${trimmed}`;
44
+ }).join('\n\n');
45
+ return { text: body, pages: total, warnings: hasText ? [] : [NO_TEXT_WARNING] };
46
+ }
47
+ async function extractDocx(buffer) {
48
+ // Fix 2: esModuleInterop:true makes the default import correct — no interop guard needed.
49
+ const { value: html } = await mammoth.convertToHtml({ buffer });
50
+ const td = new TurndownService({ headingStyle: 'atx', bulletListMarker: '-' });
51
+ const raw = td.turndown(html).trim();
52
+ // Turndown emits "- text" (3 spaces) for bullet items; collapse to "- text".
53
+ // The regex anchors on a line-leading "-" so mid-line double spaces (e.g. "Col A Col B")
54
+ // and indented nested bullets (leading whitespace is captured in $1) are both preserved.
55
+ const md = raw.replace(/^([ \t]*)-[ \t]{2,}/gm, '$1- ');
56
+ return { text: md, warnings: md.length === 0 ? ['no extractable text in document'] : [] };
57
+ }
@@ -14,7 +14,7 @@
14
14
  import { promises as fs } from 'node:fs';
15
15
  import * as path from 'node:path';
16
16
  import { registerTool } from '../index.js';
17
- import { resolveSafePath, assertRealPathContained, PathOutsideRootError, BLOCKED_PREFIXES, isDenylistedPath, } from '../_filesystem-shared.js';
17
+ import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, blockedExecutableExtension, } from '../_filesystem-shared.js';
18
18
  export async function fileEditHandler(args, ctx) {
19
19
  // 1. Validate args before any IO
20
20
  if (args.old_string === args.new_string) {
@@ -52,7 +52,17 @@ export async function fileEditHandler(args, ctx) {
52
52
  const denylist = isDenylistedPath(realAbs, ctx.cwd);
53
53
  if (denylist.blocked) {
54
54
  return {
55
- content: `error: refusing to edit sensitive path "${denylist.relFromRoot}" (denylist: ${BLOCKED_PREFIXES.join(', ')})`,
55
+ content: `error: refusing to edit sensitive path "${denylist.relFromRoot}" (denylist: ${DENYLIST_DESCRIPTION})`,
56
+ is_error: true,
57
+ };
58
+ }
59
+ // 4b. Executable-extension denylist: refuse mutating an executable/script
60
+ // shim (same threat class as planting one — keeps file_edit from being
61
+ // an alternate path to a malicious .cmd/.exe/etc.).
62
+ const badExt = blockedExecutableExtension(realAbs);
63
+ if (badExt !== null) {
64
+ return {
65
+ content: `error: refusing to write executable file type ${badExt} — not permitted`,
56
66
  is_error: true,
57
67
  };
58
68
  }
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { promises as fs } from 'node:fs';
12
12
  import { registerTool } from '../index.js';
13
- import { resolveSafePath, assertRealPathContained, PathOutsideRootError, BLOCKED_PREFIXES, isDenylistedPath } from '../_filesystem-shared.js';
13
+ import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath } from '../_filesystem-shared.js';
14
14
  const MAX_LINES = 2000;
15
15
  export async function fileReadHandler(args, ctx) {
16
16
  // Validate offset and limit BEFORE clamping
@@ -47,7 +47,7 @@ export async function fileReadHandler(args, ctx) {
47
47
  const denylist = isDenylistedPath(realAbs, ctx.cwd);
48
48
  if (denylist.blocked) {
49
49
  return {
50
- content: `error: refusing to read sensitive path "${denylist.relFromRoot}" (denylist: ${BLOCKED_PREFIXES.join(', ')})`,
50
+ content: `error: refusing to read sensitive path "${denylist.relFromRoot}" (denylist: ${DENYLIST_DESCRIPTION})`,
51
51
  is_error: true,
52
52
  };
53
53
  }
@@ -20,7 +20,7 @@
20
20
  import { promises as fs } from 'node:fs';
21
21
  import * as path from 'node:path';
22
22
  import { registerTool } from '../index.js';
23
- import { resolveSafePath, assertRealPathContained, PathOutsideRootError, BLOCKED_PREFIXES, isDenylistedPath, } from '../_filesystem-shared.js';
23
+ import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, blockedExecutableExtension, } from '../_filesystem-shared.js';
24
24
  export async function fileWriteHandler(args, ctx) {
25
25
  // 1. Path containment — sync phase (pure string math, catches .. traversals)
26
26
  let abs;
@@ -77,7 +77,16 @@ export async function fileWriteHandler(args, ctx) {
77
77
  const denylist = isDenylistedPath(target, ctx.cwd);
78
78
  if (denylist.blocked) {
79
79
  return {
80
- content: `error: refusing to write sensitive path "${denylist.relFromRoot}" (denylist: ${BLOCKED_PREFIXES.join(', ')})`,
80
+ content: `error: refusing to write sensitive path "${denylist.relFromRoot}" (denylist: ${DENYLIST_DESCRIPTION})`,
81
+ is_error: true,
82
+ };
83
+ }
84
+ // 3b. Executable-extension denylist: refuse planting an executable/script
85
+ // shim (defense-in-depth against the cross-spawn cwd-first vector).
86
+ const badExt = blockedExecutableExtension(target);
87
+ if (badExt !== null) {
88
+ return {
89
+ content: `error: refusing to write executable file type ${badExt} — not permitted`,
81
90
  is_error: true,
82
91
  };
83
92
  }
@@ -113,6 +113,17 @@ export async function globHandler(args, ctx) {
113
113
  if (budget <= 0 || collected.length >= MAX_RESULTS * 4)
114
114
  return;
115
115
  const relPath = relPrefix ? `${relPrefix}/${ent.name}` : ent.name;
116
+ // Sandbox escape guard: never descend into a symlink. fs.readdir follows
117
+ // directory symlinks, so a pre-existing in-root symlink (e.g.
118
+ // `data -> /etc`) would otherwise leak filenames from OUTSIDE root.
119
+ // The Dirent reflects the link itself (lstat semantics): a symlink to a
120
+ // directory reports isSymbolicLink()=true and isDirectory()=false, so we
121
+ // must check the symlink case explicitly and skip it. A scaffolded UI5
122
+ // project has no legitimate reason to need glob to follow a symlink out
123
+ // of the sandbox root.
124
+ if (ent.isSymbolicLink()) {
125
+ continue;
126
+ }
116
127
  if (ent.isDirectory()) {
117
128
  if (ALWAYS_SKIPPED_DIRS.has(ent.name))
118
129
  continue;
@@ -78,6 +78,16 @@ export async function grepHandler(args, ctx) {
78
78
  if (budget <= 0 || matches.length >= MAX_MATCHES)
79
79
  return;
80
80
  const relPath = relPrefix ? `${relPrefix}/${ent.name}` : ent.name;
81
+ // Sandbox escape guard: never descend into a symlink. fs.readdir follows
82
+ // directory symlinks, so a pre-existing in-root symlink (e.g.
83
+ // `vendor -> ~/.ssh`) would otherwise let grep read + return content from
84
+ // OUTSIDE root. The Dirent reflects the link itself (lstat semantics): a
85
+ // symlink to a directory reports isSymbolicLink()=true and
86
+ // isDirectory()=false, so we must check the symlink case explicitly and
87
+ // skip it.
88
+ if (ent.isSymbolicLink()) {
89
+ continue;
90
+ }
81
91
  if (ent.isDirectory()) {
82
92
  if (ALWAYS_SKIPPED_DIRS.has(ent.name))
83
93
  continue;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * read_document — extract text from a .pdf or .docx and return it with line
3
+ * numbers, page/heading markers, and a one-line header. Reuses file_read's
4
+ * path containment + denylist + 2000-line windowing. Flag-gated:
5
+ * CSPEACH_TOOL_READ_DOCUMENT=on (enabled together with the local_build set).
6
+ */
7
+ import { promises as fs } from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import { registerTool } from '../index.js';
10
+ import { resolveSafePath, assertRealPathContained, PathOutsideRootError, DENYLIST_DESCRIPTION, isDenylistedPath, } from '../_filesystem-shared.js';
11
+ import { extractDocument, DocumentTooLargeError } from './extract-document.js';
12
+ const MAX_LINES = 2000;
13
+ const MAX_BYTES = 50 * 1024 * 1024; // 50 MB
14
+ const MAX_PAGES = 150;
15
+ const SUPPORTED = new Set(['.pdf', '.docx']);
16
+ export async function readDocumentHandler(args, ctx) {
17
+ if (args.offset !== undefined && args.offset < 0) {
18
+ return { content: `error: offset must be non-negative (got ${args.offset})`, is_error: true };
19
+ }
20
+ if (args.limit !== undefined && args.limit < 1) {
21
+ return { content: `error: limit must be a positive integer (got ${args.limit})`, is_error: true };
22
+ }
23
+ let abs;
24
+ try {
25
+ abs = resolveSafePath(ctx.cwd, args.path);
26
+ }
27
+ catch (err) {
28
+ if (err instanceof PathOutsideRootError)
29
+ return { content: `error: ${err.message}`, is_error: true };
30
+ return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
31
+ }
32
+ const ext = path.extname(abs).toLowerCase();
33
+ if (!SUPPORTED.has(ext)) {
34
+ return {
35
+ content: `error: read_document supports .pdf and .docx; convert "${args.path}" first (.doc/.pptx/scanned images are not supported)`,
36
+ is_error: true,
37
+ };
38
+ }
39
+ let realAbs;
40
+ try {
41
+ realAbs = await assertRealPathContained(abs, ctx.cwd);
42
+ }
43
+ catch (err) {
44
+ if (err instanceof PathOutsideRootError)
45
+ return { content: `error: ${err.message} (after symlink resolution)`, is_error: true };
46
+ if (err.code === 'ENOENT')
47
+ return { content: 'error: file not found', is_error: true };
48
+ return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
49
+ }
50
+ const denylist = isDenylistedPath(realAbs, ctx.cwd);
51
+ if (denylist.blocked) {
52
+ return { content: `error: refusing to read sensitive path "${denylist.relFromRoot}" (denylist: ${DENYLIST_DESCRIPTION})`, is_error: true };
53
+ }
54
+ let stat;
55
+ try {
56
+ stat = await fs.stat(realAbs);
57
+ }
58
+ catch (err) {
59
+ if (err?.code === 'ENOENT')
60
+ return { content: 'error: file not found', is_error: true };
61
+ return { content: `error: ${err instanceof Error ? err.message : String(err)}`, is_error: true };
62
+ }
63
+ if (stat.size > MAX_BYTES) {
64
+ return { content: `error: "${args.path}" is ${(stat.size / 1048576).toFixed(1)} MB, exceeding the ${MAX_BYTES / 1048576} MB limit`, is_error: true };
65
+ }
66
+ let result;
67
+ try {
68
+ const buffer = await fs.readFile(realAbs);
69
+ result = await extractDocument(buffer, ext, { maxPages: MAX_PAGES });
70
+ }
71
+ catch (err) {
72
+ if (err instanceof DocumentTooLargeError) {
73
+ return { content: `error: "${args.path}" has ${err.pages} pages, exceeding the ${err.limit}-page limit`, is_error: true };
74
+ }
75
+ return { content: `error: could not read "${args.path}": ${err instanceof Error ? err.message : String(err)}`, is_error: true };
76
+ }
77
+ // 'document' is the intentional label for .docx: result.pages is PDF-only and undefined for Word files.
78
+ const header = `${args.path} — ${result.pages !== undefined ? `${result.pages} pages` : 'document'}`;
79
+ const warnLines = result.warnings.map((w) => `[warning] ${w}`);
80
+ const allLines = result.text.replace(/\n$/, '').split('\n');
81
+ const offset = Math.max(0, args.offset ?? 0);
82
+ const limit = Math.min(args.limit ?? MAX_LINES, MAX_LINES);
83
+ const window = allLines.slice(offset, offset + limit);
84
+ const numbered = window.map((line, i) => `${offset + i + 1}\t${line}`).join('\n');
85
+ const truncated = allLines.length > offset + window.length;
86
+ const trailer = truncated
87
+ ? `\n... (truncated, ${allLines.length - (offset + window.length)} more lines — re-read with offset=${offset + window.length})`
88
+ : '';
89
+ return { content: [header, ...warnLines, numbered].join('\n') + trailer };
90
+ }
91
+ registerTool({
92
+ name: 'read_document',
93
+ description: 'Read a .pdf or .docx and return its extracted text with page/heading markers and line numbers. Optional offset + limit for windowed reads of large documents.',
94
+ isMutating: false,
95
+ category: 'filesystem',
96
+ flagGated: true,
97
+ input_schema: {
98
+ type: 'object',
99
+ properties: {
100
+ path: { type: 'string', description: 'Path to a .pdf or .docx, relative to the project root or absolute inside it.' },
101
+ offset: { type: 'number', description: 'Optional 0-based line offset into the extracted text.' },
102
+ limit: { type: 'number', description: 'Optional max lines to return (cap 2000).' },
103
+ },
104
+ required: ['path'],
105
+ },
106
+ handler: readDocumentHandler,
107
+ });