@cspeach/cli 0.9.0 → 1.0.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 (131) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +209 -20
  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/auth/org-anthropic-key.js +25 -0
  10. package/dist/classifier/client.js +18 -3
  11. package/dist/commands/config-set.js +95 -0
  12. package/dist/commands/login.js +31 -14
  13. package/dist/commands/plan-model-tier.js +83 -0
  14. package/dist/commands/plan-resume.js +148 -21
  15. package/dist/config/loader.js +95 -1
  16. package/dist/doctor/checks/_http-probe.js +1 -0
  17. package/dist/doctor/checks/cert.js +14 -3
  18. package/dist/doctor/checks/sap.js +30 -8
  19. package/dist/doctor/checks/zcspeach.js +19 -4
  20. package/dist/one-shot.js +52 -4
  21. package/dist/projects/answer-blockers.js +137 -0
  22. package/dist/projects/extract-cca.js +108 -16
  23. package/dist/projects/extract-modernize.js +1 -1
  24. package/dist/projects/extract-plan.js +130 -37
  25. package/dist/projects/extract-spec-gap.js +34 -7
  26. package/dist/projects/extract-test-coverage.js +1 -1
  27. package/dist/projects/extract-upgrade.js +113 -22
  28. package/dist/projects/index.js +5 -2
  29. package/dist/projects/merge-cca.js +292 -0
  30. package/dist/projects/merge-upgrade.js +173 -0
  31. package/dist/projects/migration.js +103 -1
  32. package/dist/projects/output-paths.js +27 -0
  33. package/dist/projects/plan-run.js +159 -25
  34. package/dist/projects/plan-schema.js +63 -3
  35. package/dist/projects/promote-command.js +25 -2
  36. package/dist/projects/promote.js +128 -0
  37. package/dist/projects/save-command.js +247 -20
  38. package/dist/projects/status.js +3 -1
  39. package/dist/projects/validate.js +1 -1
  40. package/dist/projects/workspace.js +164 -20
  41. package/dist/renderer/notices.js +64 -0
  42. package/dist/renderer/progress-chatter.js +8 -0
  43. package/dist/renderer/tool-widget.js +18 -4
  44. package/dist/renderer/tty.js +43 -4
  45. package/dist/renderer/verify-chain.js +77 -0
  46. package/dist/repl/at-picker.js +60 -7
  47. package/dist/repl/builtin-commands.js +37 -0
  48. package/dist/repl/early-line-buffer.js +68 -0
  49. package/dist/repl/inquirer-guard.js +70 -5
  50. package/dist/repl/numbered-menu.js +131 -0
  51. package/dist/repl/post-turn-status.js +2 -2
  52. package/dist/repl/rule8-detector.js +17 -2
  53. package/dist/repl/safety-confirm.js +111 -2
  54. package/dist/repl/safety-mode-state.js +19 -3
  55. package/dist/repl/slash-picker.js +10 -15
  56. package/dist/repl.js +301 -35
  57. package/dist/router/classifier.js +150 -6
  58. package/dist/sap/capability-matrix.js +20 -0
  59. package/dist/sap/capability-matrix.json +11236 -0
  60. package/dist/sap/capability.js +146 -0
  61. package/dist/sap/connection-manager.js +19 -1
  62. package/dist/sap/onboarding.js +42 -4
  63. package/dist/session/pending.js +27 -0
  64. package/dist/skill-catalog.js +48 -43
  65. package/dist/skills/bundled-skills.js +279 -1
  66. package/dist/skills/promotion-dispatch.js +23 -0
  67. package/dist/tools/_command-shared.js +36 -12
  68. package/dist/tools/_filesystem-shared.js +139 -4
  69. package/dist/tools/_flag.js +25 -0
  70. package/dist/tools/approval.js +64 -21
  71. package/dist/tools/ask-question.js +96 -4
  72. package/dist/tools/capability/tool.js +74 -0
  73. package/dist/tools/dispatch-skill.js +22 -1
  74. package/dist/tools/extend-model/anchored-insert.js +810 -0
  75. package/dist/tools/extend-model/tool.js +188 -0
  76. package/dist/tools/filesystem/extract-document.js +57 -0
  77. package/dist/tools/filesystem/file-edit.js +12 -2
  78. package/dist/tools/filesystem/file-read.js +2 -2
  79. package/dist/tools/filesystem/file-write.js +11 -2
  80. package/dist/tools/filesystem/glob.js +11 -0
  81. package/dist/tools/filesystem/grep.js +10 -0
  82. package/dist/tools/filesystem/read-document.js +107 -0
  83. package/dist/tools/fiori/apply.js +50 -0
  84. package/dist/tools/fiori/bin.js +3 -0
  85. package/dist/tools/fiori/catalog/index.js +27 -0
  86. package/dist/tools/fiori/catalog/value-help.js +230 -0
  87. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  88. package/dist/tools/fiori/cli.js +71 -0
  89. package/dist/tools/fiori/deploy-config.js +73 -0
  90. package/dist/tools/fiori/fe-scaffold.js +45 -0
  91. package/dist/tools/fiori/i18n.js +39 -0
  92. package/dist/tools/fiori/manifest.js +70 -0
  93. package/dist/tools/fiori/render.js +77 -0
  94. package/dist/tools/fiori/scaffold.js +39 -0
  95. package/dist/tools/fiori/tools.js +356 -0
  96. package/dist/tools/fiori/types.js +1 -0
  97. package/dist/tools/local-build.js +76 -0
  98. package/dist/tools/local-files.js +31 -0
  99. package/dist/tools/project/_merge-shared.js +68 -0
  100. package/dist/tools/project/cca_merge.js +164 -0
  101. package/dist/tools/project/playbook_get.js +1 -1
  102. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  103. package/dist/tools/sap-read.js +53 -9
  104. package/dist/tools/sap-write.js +530 -21
  105. package/dist/tools/shell/shell_exec.js +41 -6
  106. package/dist/tools/snapshot.js +37 -14
  107. package/dist/tools/subagent/background_run.js +17 -1
  108. package/dist/tools/transport-resolution.js +86 -0
  109. package/dist/tools/transport.js +224 -5
  110. package/dist/tools/write-mode.js +4 -0
  111. package/dist/ui/app.js +6 -2
  112. package/dist/ui/body.js +13 -0
  113. package/dist/ui/footer.js +20 -6
  114. package/dist/ui/line-resolution.js +17 -6
  115. package/dist/ui/session-timeline.js +1 -0
  116. package/dist/ui/text-input.js +150 -0
  117. package/dist/ui/widgets/ask-question-modal.js +4 -1
  118. package/package.json +19 -3
  119. package/bench/README.md +0 -78
  120. package/bench/prompts/abap-document-cds.md +0 -44
  121. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  122. package/bench/prompts/abap-test-method.md +0 -42
  123. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  124. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  125. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  126. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  127. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  128. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  129. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  130. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  131. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -0,0 +1,188 @@
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, } 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
+ const { source, field } = args;
51
+ if (typeof source !== 'string' || typeof field !== 'string' || field.trim() === '') {
52
+ return { content: 'error: source (string) and field (non-empty string) are required', is_error: true };
53
+ }
54
+ try {
55
+ let modified;
56
+ let summary;
57
+ if (kind === 'cds-field') {
58
+ modified = insertCdsField(source, field);
59
+ summary = `Inserted element "${field}" into the CDS view's element list (prior last element gained a trailing comma).`;
60
+ }
61
+ else if (kind === 'ddlx-lineitem') {
62
+ const position = typeof args.position === 'number' ? args.position : 10;
63
+ modified = insertDdlxLineItem(source, field, position);
64
+ summary = `Added "${field}" as an @UI.lineItem column at position ${position} in the DDLX metadata extension.`;
65
+ }
66
+ else if (kind === 'cds-lineitem') {
67
+ const position = typeof args.position === 'number' ? args.position : 10;
68
+ modified = promoteCdsFieldToLineItem(source, field, position);
69
+ summary = `Promoted field '${field}' to a list column (@UI.lineItem position ${position}) in the CDS view.`;
70
+ }
71
+ else if (kind === 'table-field') {
72
+ modified = addTableField(source, field);
73
+ summary = `Added field '${field}' to the table.`;
74
+ }
75
+ else if (kind === 'bdef-field') {
76
+ const target = args.target === 'mapping' ? 'mapping' : 'field';
77
+ modified = insertBdefField(source, { alias: args.alias, target, clause: field });
78
+ summary = `Added ${target} '${field}' to BDEF${args.alias ? ` (alias ${args.alias})` : ''}.`;
79
+ }
80
+ else {
81
+ return {
82
+ content: `error: unknown kind "${String(kind)}" — expected "cds-field", "ddlx-lineitem", "cds-lineitem", "table-field", "bdef-field" or "bdef-stub"`,
83
+ is_error: true,
84
+ };
85
+ }
86
+ return { content: JSON.stringify({ modified, summary }) };
87
+ }
88
+ catch (err) {
89
+ return {
90
+ content: `error: anchored insertion failed — ${err instanceof Error ? err.message : String(err)}`,
91
+ is_error: true,
92
+ };
93
+ }
94
+ }
95
+ registerTool({
96
+ name: 'extend_model_insert',
97
+ description: 'Deterministically insert a field or @UI.lineItem column into existing CDS/DDLX source via '
98
+ + 'anchored insertion (no DSL parser, no blind string-editing). Returns the modified source for '
99
+ + 'the caller to snapshot, write (sap_set_source), syntax-check, and activate. kind="cds-field" '
100
+ + 'adds an element to a CDS view body; kind="ddlx-lineitem" adds an @UI.lineItem-annotated field '
101
+ + 'to a DDLX metadata extension (the Fiori Elements column-add); kind="cds-lineitem" promotes an '
102
+ + 'EXISTING exposed CDS-view field to a list column by inserting @UI.lineItem above its declaration '
103
+ + '(fail-safe: refuses multi-line/computed elements, idempotent); kind="table-field" appends a '
104
+ + 'NON-KEY field to a CDS `define table` body (refuses key fields — destructive); kind="bdef-field" '
105
+ + 'adds a `field ( … )` or mapping line to a MANAGED BDEF entity (target="field"|"mapping", optional '
106
+ + 'alias; refuses unmanaged); kind="bdef-stub" adds a validation/determination/action clause to a '
107
+ + 'MANAGED BDEF and the corresponding handler method skeleton to the CCIMP include; '
108
+ + 'kind="projection-use" exposes a `use action/function/association` line inside a PROJECTION BDEF '
109
+ + 'entity body (non-projection BDEFs refused); kind="mde-action-button" inserts a @UI.lineItem '
110
+ + 'FOR_ACTION button annotation above an anchor field in a metadata extension. Pure transform — '
111
+ + 'does not touch SAP.',
112
+ isMutating: false,
113
+ category: 'fiori',
114
+ flagGated: true,
115
+ input_schema: {
116
+ type: 'object',
117
+ properties: {
118
+ kind: {
119
+ type: 'string',
120
+ enum: ['cds-field', 'ddlx-lineitem', 'cds-lineitem', 'table-field', 'bdef-field', 'bdef-stub', 'projection-use', 'mde-action-button'],
121
+ 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.',
122
+ },
123
+ 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.' },
124
+ field: {
125
+ type: 'string',
126
+ 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 ";"). Required for all kinds except bdef-stub.',
127
+ },
128
+ position: { type: 'number', description: 'kind=ddlx-lineitem and kind=cds-lineitem: the @UI.lineItem column position (default 10). kind=mde-action-button: optional column order for the inserted #FOR_ACTION entry. Ignored for other kinds.' },
129
+ 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.' },
130
+ 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.' },
131
+ bdefSource: { type: 'string', description: 'kind=bdef-stub: full managed BDEF source (from sap_get_source).' },
132
+ ccimpSource: { type: 'string', description: 'kind=bdef-stub: full CCIMP ("Local Types") include source of the behavior pool class (may be empty).' },
133
+ stubKind: { type: 'string', enum: ['validation', 'determination', 'action'], description: 'kind=bdef-stub: which clause to add.' },
134
+ name: { type: 'string', description: 'kind=bdef-stub: the validation/determination/action name (e.g. ValidateInstructorName).' },
135
+ trigger: { type: 'string', enum: ['modify', 'save'], description: 'kind=bdef-stub determination trigger point (default modify).' },
136
+ ops: { type: 'string', description: 'kind=bdef-stub validation/determination operations, e.g. "create; update;".' },
137
+ parameter: { type: 'string', description: 'kind=bdef-stub action input parameter type (CDS abstract entity).' },
138
+ result: { type: 'boolean', description: 'kind=bdef-stub action declares a result.' },
139
+ resultType: { type: 'string', description: 'kind=bdef-stub action result type (default $self; required-typed for static actions).' },
140
+ static: { type: 'boolean', description: 'kind=bdef-stub static (factory) action.' },
141
+ features: { type: 'boolean', description: 'kind=bdef-stub feature-controlled action (features : instance).' },
142
+ 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.' },
143
+ action: { type: 'string', description: 'kind=mde-action-button: the action name to wire, e.g. "Approve". Required for mde-action-button.' },
144
+ anchorField: { type: 'string', description: 'kind=mde-action-button: the field name above which to insert the action button annotation. Required for mde-action-button.' },
145
+ label: { type: 'string', description: 'kind=mde-action-button: optional button label text. Defaults to the action name.' },
146
+ },
147
+ required: ['kind'],
148
+ },
149
+ handler: extendModelInsertHandler,
150
+ });
151
+ function handleBdefStub(args) {
152
+ const { bdefSource, ccimpSource, stubKind, name, alias } = args;
153
+ if (typeof bdefSource !== 'string' || typeof ccimpSource !== 'string') {
154
+ return { content: 'error: bdef-stub requires bdefSource and ccimpSource (strings)', is_error: true };
155
+ }
156
+ if (!stubKind || !['validation', 'determination', 'action'].includes(stubKind)) {
157
+ return { content: 'error: bdef-stub requires stubKind = validation|determination|action', is_error: true };
158
+ }
159
+ if (typeof name !== 'string' || name.trim() === '') {
160
+ return { content: 'error: bdef-stub requires a non-empty name', is_error: true };
161
+ }
162
+ try {
163
+ // Resolve alias: auto-picks the single behavior when no alias is given;
164
+ // throws (→ is_error) on ambiguity or missing behavior — same policy as bdef-field.
165
+ const entityAlias = alias ?? resolveBehaviorAlias(bdefSource);
166
+ const clauseLine = buildClauseLine(stubKind, {
167
+ name, ops: args.ops, trigger: args.trigger, parameter: args.parameter,
168
+ result: args.result, resultType: args.resultType, static: args.static, features: args.features,
169
+ });
170
+ const method = buildHandlerMethod(stubKind, {
171
+ name, entity: entityAlias, trigger: args.trigger, parameter: args.parameter,
172
+ result: args.result, features: args.features,
173
+ });
174
+ const modifiedBdef = insertBdefClause(bdefSource, { alias: entityAlias, clauseLine });
175
+ const className = `lhc_${entityAlias.toLowerCase()}`;
176
+ const exists = classBlockSpan(ccimpSource.split('\n'), className) !== null;
177
+ const modifiedCcimp = exists
178
+ ? insertHandlerMethod(ccimpSource, { entity: entityAlias, method })
179
+ : createHandlerClassSkeleton(ccimpSource, { entity: entityAlias, method });
180
+ const summary = `Added ${stubKind} '${name}' to BDEF (alias ${entityAlias}) and ${exists ? 'inserted the handler method into' : 'created'} ${className}.`;
181
+ return { content: JSON.stringify({ modifiedBdef, modifiedCcimp, createdHandlerClass: !exists, summary }) };
182
+ }
183
+ catch (err) {
184
+ return { content: `error: bdef-stub failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
185
+ }
186
+ }
187
+ // Exported for unit tests.
188
+ 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
+ });
@@ -0,0 +1,50 @@
1
+ // cspeach-cli/src/tools/fiori/apply.ts
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { getEntry } from './catalog/index.js';
5
+ import { renderEntry } from './render.js';
6
+ import { mergeManifest } from './manifest.js';
7
+ import { appendI18n } from './i18n.js';
8
+ import { resolveSafePath } from '../_filesystem-shared.js';
9
+ /**
10
+ * Render a catalog entry and apply it into an existing scaffolded app on disk.
11
+ *
12
+ * SECURITY — containment is enforced HERE, on every write.
13
+ * --------------------------------------------------------
14
+ * Each output path is derived from a catalog EJS template rendered with
15
+ * CUSTOMER params (e.g. `webapp/ext/fragment/<%- ns %>Chart.fragment.xml`), so a
16
+ * hostile param could otherwise carry `..` and escape `appDir`. Every path this
17
+ * function writes — rendered files, manifest.json, and the i18n file (whose
18
+ * relative location is itself read from on-disk manifest data) — is resolved
19
+ * through `resolveSafePath(opts.appDir, rel)`, which throws PathOutsideRootError
20
+ * if the resolved absolute path is not contained under appDir. This is the
21
+ * authoritative defense; the catalog-side `ns` pattern (render.ts) is the
22
+ * second, earlier layer.
23
+ */
24
+ export function applyEntry(opts) {
25
+ const rendered = renderEntry(getEntry(opts.entryName), opts.params);
26
+ // 1. write files — re-validate EVERY rendered (param-derived) path against appDir.
27
+ for (const f of rendered.files) {
28
+ const abs = resolveSafePath(opts.appDir, f.path);
29
+ mkdirSync(dirname(abs), { recursive: true });
30
+ writeFileSync(abs, f.content, 'utf8');
31
+ }
32
+ // 2. merge manifest — ALWAYS re-read from disk (I2). Fixed relative path, but
33
+ // routed through resolveSafePath for uniform containment.
34
+ const manifestPath = resolveSafePath(opts.appDir, 'webapp/manifest.json');
35
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
36
+ let merged = mergeManifest(manifest, rendered.manifestPatch);
37
+ for (const lib of rendered.libraries) {
38
+ merged = mergeManifest(merged, { 'sap.ui5': { dependencies: { libs: { [lib]: {} } } } });
39
+ }
40
+ writeFileSync(manifestPath, JSON.stringify(merged, null, 2), 'utf8');
41
+ // 3. append i18n — resolve path from sap.app.i18n (M1). i18nRel is on-disk
42
+ // data, not a render param, but it still drives a write target, so contain it.
43
+ const i18nRel = merged['sap.app']?.i18n ?? 'i18n/i18n.properties';
44
+ const i18nPath = resolveSafePath(opts.appDir, `webapp/${i18nRel}`);
45
+ const existing = existsSync(i18nPath) ? readFileSync(i18nPath, 'utf8') : '';
46
+ if (Object.keys(rendered.i18nKeys).length) {
47
+ mkdirSync(dirname(i18nPath), { recursive: true });
48
+ writeFileSync(i18nPath, appendI18n(existing, rendered.i18nKeys), 'utf8');
49
+ }
50
+ }
@@ -0,0 +1,3 @@
1
+ // cspeach-cli/src/tools/fiori/bin.ts
2
+ import { runFioriCli } from './cli.js';
3
+ runFioriCli(process.argv.slice(2)).then((code) => process.exit(code));
@@ -0,0 +1,27 @@
1
+ // cspeach-cli/src/tools/fiori/catalog/index.ts
2
+ //
3
+ // Catalog index for the Fiori engine.
4
+ // v1 scope: only the two "fiddly" SAPUI5 patterns — viz-chart and value-help.
5
+ // confirm-dialog (4.1), list-table-page (4.2), object-detail-page (4.3),
6
+ // message-toast (4.4) are SKILL.md recipes, not catalog entries.
7
+ import { vizChart } from './viz-chart.js';
8
+ import { valueHelp } from './value-help.js';
9
+ export const CATALOG = [vizChart, valueHelp];
10
+ export function getEntry(name) {
11
+ const e = CATALOG.find((x) => x.name === name);
12
+ if (!e)
13
+ throw new Error(`Unknown catalog entry "${name}". Available: ${CATALOG.map((c) => c.name).join(', ')}`);
14
+ return e;
15
+ }
16
+ export function listCatalog() {
17
+ // params included so the agent can construct a correct `apply --params` call
18
+ // straight from `fiori list` — no trial-and-error against required-param errors.
19
+ return CATALOG.map(({ name, title, description, categories, libraries, params }) => ({
20
+ name,
21
+ title,
22
+ description,
23
+ categories,
24
+ libraries,
25
+ params,
26
+ }));
27
+ }