@cspeach/cli 0.8.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 (141) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/intent-system-prompt.js +1 -1
  3. package/dist/agent/loop.js +330 -36
  4. package/dist/agent/providers/license-gate.js +44 -0
  5. package/dist/agent/skill-checkpoint.js +1 -1
  6. package/dist/agent/steering-queue.js +27 -0
  7. package/dist/agent/tool-dispatch.js +15 -0
  8. package/dist/approvals/canonical.js +91 -0
  9. package/dist/approvals/jwt.js +84 -7
  10. package/dist/approvals/render.js +38 -0
  11. package/dist/auth/org-anthropic-key.js +25 -0
  12. package/dist/classifier/client.js +24 -5
  13. package/dist/commands/config-set.js +95 -0
  14. package/dist/commands/login.js +31 -14
  15. package/dist/commands/plan-model-tier.js +83 -0
  16. package/dist/commands/plan-resume.js +435 -0
  17. package/dist/config/loader.js +102 -3
  18. package/dist/cost/pricing.js +14 -5
  19. package/dist/doctor/checks/_http-probe.js +1 -0
  20. package/dist/doctor/checks/cert.js +14 -3
  21. package/dist/doctor/checks/sap.js +30 -8
  22. package/dist/doctor/checks/zcspeach.js +19 -4
  23. package/dist/one-shot.js +52 -4
  24. package/dist/projects/answer-blockers.js +137 -0
  25. package/dist/projects/email-template.js +2 -0
  26. package/dist/projects/extract-cca.js +108 -16
  27. package/dist/projects/extract-modernize.js +1 -1
  28. package/dist/projects/extract-plan.js +178 -0
  29. package/dist/projects/extract-spec-gap.js +34 -7
  30. package/dist/projects/extract-test-coverage.js +1 -1
  31. package/dist/projects/extract-upgrade.js +113 -22
  32. package/dist/projects/index.js +7 -1
  33. package/dist/projects/merge-cca.js +292 -0
  34. package/dist/projects/merge-upgrade.js +173 -0
  35. package/dist/projects/migration.js +103 -1
  36. package/dist/projects/output-paths.js +27 -0
  37. package/dist/projects/plan-run.js +254 -0
  38. package/dist/projects/plan-schema.js +210 -0
  39. package/dist/projects/promote-command.js +26 -2
  40. package/dist/projects/promote.js +128 -0
  41. package/dist/projects/save-command.js +263 -20
  42. package/dist/projects/status.js +22 -0
  43. package/dist/projects/validate.js +2 -0
  44. package/dist/projects/workspace.js +164 -20
  45. package/dist/renderer/notices.js +64 -0
  46. package/dist/renderer/progress-chatter.js +8 -0
  47. package/dist/renderer/syntax.js +16 -1
  48. package/dist/renderer/thinking-heartbeat.js +13 -1
  49. package/dist/renderer/tool-widget.js +18 -4
  50. package/dist/renderer/tty.js +43 -4
  51. package/dist/renderer/verify-chain.js +77 -0
  52. package/dist/repl/at-picker.js +93 -21
  53. package/dist/repl/builtin-commands.js +37 -0
  54. package/dist/repl/early-line-buffer.js +68 -0
  55. package/dist/repl/inquirer-guard.js +70 -5
  56. package/dist/repl/numbered-menu.js +131 -0
  57. package/dist/repl/post-turn-status.js +2 -2
  58. package/dist/repl/rule8-detector.js +17 -2
  59. package/dist/repl/safety-confirm.js +111 -2
  60. package/dist/repl/safety-mode-state.js +19 -3
  61. package/dist/repl/slash-picker.js +25 -19
  62. package/dist/repl.js +470 -22
  63. package/dist/router/classifier.js +150 -6
  64. package/dist/sap/capability-matrix.js +20 -0
  65. package/dist/sap/capability-matrix.json +11236 -0
  66. package/dist/sap/capability.js +146 -0
  67. package/dist/sap/connection-manager.js +19 -1
  68. package/dist/sap/onboarding.js +42 -4
  69. package/dist/session/pending.js +27 -0
  70. package/dist/skill-catalog.js +54 -43
  71. package/dist/skills/bundled-skills.js +279 -1
  72. package/dist/skills/promotion-dispatch.js +23 -0
  73. package/dist/tools/_command-shared.js +36 -12
  74. package/dist/tools/_filesystem-shared.js +139 -4
  75. package/dist/tools/_flag.js +25 -0
  76. package/dist/tools/approval.js +64 -21
  77. package/dist/tools/ask-question.js +96 -4
  78. package/dist/tools/capability/tool.js +74 -0
  79. package/dist/tools/dispatch-skill.js +22 -1
  80. package/dist/tools/extend-model/anchored-insert.js +810 -0
  81. package/dist/tools/extend-model/tool.js +188 -0
  82. package/dist/tools/filesystem/extract-document.js +57 -0
  83. package/dist/tools/filesystem/file-edit.js +12 -2
  84. package/dist/tools/filesystem/file-read.js +2 -2
  85. package/dist/tools/filesystem/file-write.js +11 -2
  86. package/dist/tools/filesystem/glob.js +11 -0
  87. package/dist/tools/filesystem/grep.js +10 -0
  88. package/dist/tools/filesystem/read-document.js +107 -0
  89. package/dist/tools/fiori/apply.js +50 -0
  90. package/dist/tools/fiori/bin.js +3 -0
  91. package/dist/tools/fiori/catalog/index.js +27 -0
  92. package/dist/tools/fiori/catalog/value-help.js +230 -0
  93. package/dist/tools/fiori/catalog/viz-chart.js +177 -0
  94. package/dist/tools/fiori/cli.js +71 -0
  95. package/dist/tools/fiori/deploy-config.js +73 -0
  96. package/dist/tools/fiori/fe-scaffold.js +45 -0
  97. package/dist/tools/fiori/i18n.js +39 -0
  98. package/dist/tools/fiori/manifest.js +70 -0
  99. package/dist/tools/fiori/render.js +77 -0
  100. package/dist/tools/fiori/scaffold.js +39 -0
  101. package/dist/tools/fiori/tools.js +356 -0
  102. package/dist/tools/fiori/types.js +1 -0
  103. package/dist/tools/local-build.js +76 -0
  104. package/dist/tools/local-files.js +31 -0
  105. package/dist/tools/project/_merge-shared.js +68 -0
  106. package/dist/tools/project/cca_merge.js +164 -0
  107. package/dist/tools/project/playbook_get.js +1 -1
  108. package/dist/tools/project/upgrade_merge_progress.js +206 -0
  109. package/dist/tools/sap-read.js +53 -9
  110. package/dist/tools/sap-write.js +530 -21
  111. package/dist/tools/shell/shell_exec.js +41 -6
  112. package/dist/tools/snapshot.js +37 -14
  113. package/dist/tools/subagent/background_run.js +17 -1
  114. package/dist/tools/transport-resolution.js +86 -0
  115. package/dist/tools/transport.js +224 -5
  116. package/dist/tools/write-mode.js +4 -0
  117. package/dist/ui/app.js +84 -11
  118. package/dist/ui/body.js +13 -0
  119. package/dist/ui/command-palette.js +46 -10
  120. package/dist/ui/file-palette.js +44 -0
  121. package/dist/ui/footer.js +28 -11
  122. package/dist/ui/line-resolution.js +92 -0
  123. package/dist/ui/session-timeline.js +1 -0
  124. package/dist/ui/text-input.js +150 -0
  125. package/dist/ui/turn-status-emitter.js +52 -0
  126. package/dist/ui/turn-status.js +59 -0
  127. package/dist/ui/widgets/ask-question-modal.js +30 -2
  128. package/package.json +23 -4
  129. package/bench/README.md +0 -78
  130. package/bench/prompts/abap-document-cds.md +0 -44
  131. package/bench/prompts/abap-explain-bdef-handler.md +0 -57
  132. package/bench/prompts/abap-test-method.md +0 -42
  133. package/bench/results/abap-document-cds/claude-haiku-4-5.md +0 -189
  134. package/bench/results/abap-document-cds/claude-opus-4-7.md +0 -120
  135. package/bench/results/abap-document-cds/claude-sonnet-4-6.md +0 -151
  136. package/bench/results/abap-explain-bdef-handler/claude-haiku-4-5.md +0 -112
  137. package/bench/results/abap-explain-bdef-handler/claude-opus-4-7.md +0 -101
  138. package/bench/results/abap-explain-bdef-handler/claude-sonnet-4-6.md +0 -101
  139. package/bench/results/abap-test-method/claude-haiku-4-5.md +0 -186
  140. package/bench/results/abap-test-method/claude-opus-4-7.md +0 -193
  141. package/bench/results/abap-test-method/claude-sonnet-4-6.md +0 -234
@@ -0,0 +1,70 @@
1
+ // cspeach-cli/src/tools/fiori/manifest.ts
2
+ /** Deep-merges manifest.json fragments. Unions object maps by key and arrays
3
+ * of named objects (routes) by `name`; identical value = no-op, differing
4
+ * value for the same key = hard error. Never silent last-wins. */
5
+ export class ManifestMergeError extends Error {
6
+ path;
7
+ a;
8
+ b;
9
+ constructor(path, a, b) {
10
+ super(`Manifest merge conflict at "${path}": ${JSON.stringify(a)} vs ${JSON.stringify(b)}`);
11
+ this.path = path;
12
+ this.a = a;
13
+ this.b = b;
14
+ this.name = 'ManifestMergeError';
15
+ }
16
+ }
17
+ const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
18
+ /** Arrays whose elements are merged by a key field instead of concatenated. */
19
+ const ARRAY_KEY_BY = { routes: 'name' };
20
+ function mergeNode(a, b, path) {
21
+ if (b === undefined)
22
+ return a;
23
+ if (a === undefined)
24
+ return b;
25
+ if (isObj(a) && isObj(b)) {
26
+ const out = { ...a };
27
+ for (const k of Object.keys(b))
28
+ out[k] = mergeNode(a[k], b[k], `${path}.${k}`);
29
+ return out;
30
+ }
31
+ if (Array.isArray(a) && Array.isArray(b)) {
32
+ const keyField = ARRAY_KEY_BY[path.split('.').pop() ?? ''];
33
+ if (keyField) {
34
+ const out = [...a];
35
+ for (const item of b) {
36
+ const idx = out.findIndex((x) => x?.[keyField] === item?.[keyField]);
37
+ if (idx === -1)
38
+ out.push(item);
39
+ else if (JSON.stringify(out[idx]) !== JSON.stringify(item))
40
+ throw new ManifestMergeError(`${path}[${keyField}=${item[keyField]}]`, out[idx], item);
41
+ }
42
+ return out;
43
+ }
44
+ return [...a, ...b];
45
+ }
46
+ if (JSON.stringify(a) === JSON.stringify(b))
47
+ return a;
48
+ throw new ManifestMergeError(path, a, b);
49
+ }
50
+ export function mergeManifest(base, patch) {
51
+ return mergeNode(base ?? {}, patch ?? {}, 'manifest');
52
+ }
53
+ export function addDataSource(m, name, uri, type = 'OData', odataVersion) {
54
+ const ds = { uri, type };
55
+ if (odataVersion)
56
+ ds.settings = { odataVersion };
57
+ return mergeManifest(m, { 'sap.app': { dataSources: { [name]: ds } } });
58
+ }
59
+ export function addModel(m, name, def) {
60
+ return mergeManifest(m, { 'sap.ui5': { models: { [name]: def } } });
61
+ }
62
+ export function addTarget(m, name, viewName, viewLevel = 1) {
63
+ return mergeManifest(m, { 'sap.ui5': { routing: { targets: { [name]: { viewName, viewLevel, viewType: 'XML' } } } } });
64
+ }
65
+ export function addRoute(m, name, pattern, target) {
66
+ return mergeManifest(m, { 'sap.ui5': { routing: { routes: [{ name, pattern, target: [target] }] } } });
67
+ }
68
+ export function addLib(m, lib) {
69
+ return mergeManifest(m, { 'sap.ui5': { dependencies: { libs: { [lib]: {} } } } });
70
+ }
@@ -0,0 +1,77 @@
1
+ // cspeach-cli/src/tools/fiori/render.ts
2
+ import { render as ejsRender } from 'ejs';
3
+ export class MissingParamError extends Error {
4
+ param;
5
+ constructor(param, entry) {
6
+ super(`Catalog entry "${entry}" requires param "${param}"`);
7
+ this.param = param;
8
+ this.name = 'MissingParamError';
9
+ }
10
+ }
11
+ /**
12
+ * Raised when a param value fails its declared `pattern`. This is a SECURITY
13
+ * boundary for any param that reaches a file path (e.g. `ns`): a value like
14
+ * `../../../x` must fail here, loudly, before render — never silently produce a
15
+ * traversal path. Keep the message human-actionable.
16
+ */
17
+ export class InvalidParamError extends Error {
18
+ param;
19
+ constructor(param, entry, value, hint) {
20
+ super(`Catalog entry "${entry}" param "${param}" is invalid: ${hint} ` +
21
+ `(got ${JSON.stringify(String(value))}).`);
22
+ this.param = param;
23
+ this.name = 'InvalidParamError';
24
+ }
25
+ }
26
+ const ejs = (tpl, data) => ejsRender(tpl, data, { rmWhitespace: false });
27
+ /**
28
+ * Render manifestPatch with JSON-safe per-placeholder substitution.
29
+ *
30
+ * Works by serialising the patch to JSON and replacing each `<%- name %>`
31
+ * placeholder with a JSON-escaped string value (the surrounding quotes from
32
+ * JSON.stringify are stripped because the placeholder already sits inside a
33
+ * JSON string literal). Only simple `<%- name %>` (raw output) substitutions
34
+ * are supported; any remaining `<%` tag after substitution triggers a clear
35
+ * error so callers are not silently given a partially-rendered patch.
36
+ */
37
+ function renderManifestPatch(patch, params, entryName) {
38
+ const json = JSON.stringify(patch).replace(/<%-\s*(\w+)\s*%>/g, (_m, name) => {
39
+ const val = params[name];
40
+ // JSON.stringify yields a quoted, fully-escaped string literal; strip the
41
+ // surrounding quotes because the placeholder already sits inside a JSON string.
42
+ return JSON.stringify(val === undefined ? '' : String(val)).slice(1, -1);
43
+ });
44
+ if (json.includes('<%')) {
45
+ throw new Error(`Catalog entry "${entryName}" manifestPatch uses an unsupported EJS expression; ` +
46
+ `only simple "<%- name %>" substitutions are allowed in manifestPatch.`);
47
+ }
48
+ return JSON.parse(json);
49
+ }
50
+ /** Render a catalog entry's EJS files/i18n/manifest with resolved params. */
51
+ export function renderEntry(entry, params) {
52
+ for (const [name, def] of Object.entries(entry.params)) {
53
+ const value = params[name];
54
+ if (def.required && (value === undefined || value === null))
55
+ throw new MissingParamError(name, entry.name);
56
+ // Enforce declared pattern on any provided value. This is the early,
57
+ // catalog-driven half of the defense-in-depth: a path-bearing param (ns)
58
+ // declares a strict identifier pattern so a `../`-traversal is rejected
59
+ // here, before it can ever reach `join(appDir, renderedPath)`.
60
+ if (def.pattern !== undefined && value !== undefined && value !== null) {
61
+ const re = new RegExp(`^(?:${def.pattern})$`);
62
+ if (typeof value !== 'string' || !re.test(value)) {
63
+ throw new InvalidParamError(name, entry.name, value, `must be a simple identifier matching /${def.pattern}/ ` +
64
+ `(no dots, slashes, or path separators)`);
65
+ }
66
+ }
67
+ }
68
+ const files = entry.files.map((f) => ({
69
+ path: ejs(f.path, params),
70
+ content: ejs(f.content, params),
71
+ }));
72
+ const i18nKeys = {};
73
+ for (const [k, v] of Object.entries(entry.i18nKeys))
74
+ i18nKeys[ejs(k, params)] = ejs(v, params);
75
+ const manifestPatch = renderManifestPatch(entry.manifestPatch, params, entry.name);
76
+ return { files, manifestPatch, i18nKeys, libraries: entry.libraries };
77
+ }
@@ -0,0 +1,39 @@
1
+ // cspeach-cli/src/tools/fiori/scaffold.ts
2
+ import { generate, TemplateType } from '@sap-ux/fiori-freestyle-writer';
3
+ import { create } from 'mem-fs';
4
+ import { create as createEditor } from 'mem-fs-editor';
5
+ // Without an explicit ui5 config the writer falls back to minUI5Version 1.60,
6
+ // which fiori-tools-proxy resolves to the 1.71 LTS — whose bootstrap does NOT
7
+ // understand the hyphenated `data-sap-ui-on-init` attribute the writer itself
8
+ // emits in index.html. Result: silent blank page (ComponentSupport never runs).
9
+ // 1.120 is the S/4HANA 2023-era LTS and parses the modern attribute.
10
+ const DEFAULT_UI5_VERSION = '1.120.0';
11
+ const TEMPLATE = {
12
+ basic: TemplateType.Basic, worklist: TemplateType.Worklist, listdetail: TemplateType.ListDetail,
13
+ };
14
+ /** Scaffold a freestyle UI5 app and FULLY flush it to disk (awaits commit). */
15
+ export async function scaffoldFreestyle(opts) {
16
+ const fs = createEditor(create());
17
+ // NOTE (Phase-0 spike finding): the real freestyle-writer@3 API REQUIRES
18
+ // `app.projectType` and a `package` object — without them ui5-application-writer
19
+ // throws `Cannot read properties of undefined (reading 'version')`.
20
+ // 'EDMXBackend' = standalone (non-CAP) freestyle app.
21
+ const ui5Version = opts.ui5Version ?? DEFAULT_UI5_VERSION;
22
+ const data = {
23
+ app: { id: opts.appId, title: opts.appTitle, projectType: 'EDMXBackend' },
24
+ package: { name: opts.appId },
25
+ template: { type: TEMPLATE[opts.template], settings: opts.template === 'basic' ? { viewName: 'Main' } : {} },
26
+ appOptions: { typescript: opts.typescript ?? false },
27
+ ui5: { minUI5Version: ui5Version, version: ui5Version },
28
+ };
29
+ if (opts.service) {
30
+ // The writer's OdataVersion enum is '2' | '4' — NOT '2.0'/'4.0'. Passing
31
+ // '4.0' matches neither, and the writer silently skips the version-specific
32
+ // manifest wiring (settings.odataVersion, V4 model defaults), leaving the
33
+ // default model to instantiate as a V2 ODataModel against a V4 service.
34
+ const writerVersion = opts.service.version === '4.0' ? '4' : '2';
35
+ data.service = { url: opts.service.url, path: opts.service.path, version: writerVersion };
36
+ }
37
+ await generate(opts.basePath, data, fs);
38
+ await new Promise((res, rej) => fs.commit((err) => (err ? rej(err) : res())));
39
+ }
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Fiori engine tools — built-in, flag-gated wrappers around the existing
3
+ * CSPeach Fiori engine (scaffold.ts, apply.ts, catalog/, deploy-config.ts).
4
+ *
5
+ * WHY THESE EXIST
6
+ * ---------------
7
+ * The engine has always been OUR code (cspeach-cli/src/tools/fiori/), but it
8
+ * was only reachable through `pnpm fiori <cmd>` (bin.ts → cli.ts), a pnpm
9
+ * script that only works INSIDE the repo. Customers on the shipped npm
10
+ * `@cspeach/cli` binary could not run it, so the /abap-fiori-build skill had to
11
+ * improvise file writes by hand — non-deterministic output per customer.
12
+ *
13
+ * These tools register the same engine functions as first-class tools so every
14
+ * customer gets the SAME deterministic scaffold/apply/deploy-config behavior,
15
+ * no pnpm and no repo required. They DO NOT reimplement the engine — each
16
+ * handler calls the existing scaffoldFreestyle()/applyEntry()/listCatalog()/
17
+ * writeDeployConfig() exports.
18
+ *
19
+ * SANDBOX (defense in depth)
20
+ * --------------------------
21
+ * These tools write file trees (basePath / appDir). Sandboxing is enforced at
22
+ * TWO layers, because the appDir boundary alone is not sufficient:
23
+ *
24
+ * 1. Tool boundary: the customer-supplied basePath/appDir is run through
25
+ * resolveSafePath(ctx.cwd, …) so the app ROOT cannot escape the project.
26
+ *
27
+ * 2. Engine boundary (the real fix): applyEntry() re-validates EVERY file it
28
+ * writes against appDir via resolveSafePath(appDir, renderedPath). This is
29
+ * required because catalog templates render OUTPUT PATHS from customer
30
+ * params (e.g. `webapp/ext/fragment/<%- ns %>Chart.fragment.xml`) — a
31
+ * hostile `ns` could otherwise carry `..` and write CONTENT outside the
32
+ * app root even though appDir itself was contained. A matching catalog-side
33
+ * `ns` pattern (render.ts) rejects such values even earlier.
34
+ *
35
+ * So: a `..`-traversal cannot escape via the appDir arg NOR via a param that
36
+ * lands in a rendered path.
37
+ *
38
+ * FLAG / LOCAL_BUILD
39
+ * ------------------
40
+ * All four are flagGated:true (category 'fiori') and listed in
41
+ * LOCAL_BUILD_TOOLS, so `cspeach config set local_build on` enables them
42
+ * alongside file_write/shell_exec — they are part of "build apps locally".
43
+ *
44
+ * `pnpm fiori` (bin.ts/cli.ts) is UNTOUCHED and still works for repo dev; these
45
+ * tools are an additional surface, not a replacement.
46
+ */
47
+ import { promises as fs } from 'node:fs';
48
+ import * as path from 'node:path';
49
+ import { registerTool } from '../index.js';
50
+ import { resolveSafePath, PathOutsideRootError } from '../_filesystem-shared.js';
51
+ import { scaffoldFreestyle } from './scaffold.js';
52
+ import { applyEntry } from './apply.js';
53
+ import { listCatalog } from './catalog/index.js';
54
+ import { writeDeployConfig } from './deploy-config.js';
55
+ import { scaffoldFioriElements } from './fe-scaffold.js';
56
+ /** Resolve a user-supplied dir inside the project root, mapping the escape error. */
57
+ function safeDir(ctx, userPath) {
58
+ try {
59
+ return { abs: resolveSafePath(ctx.cwd, userPath) };
60
+ }
61
+ catch (err) {
62
+ if (err instanceof PathOutsideRootError)
63
+ return { error: err.message };
64
+ return { error: err instanceof Error ? err.message : String(err) };
65
+ }
66
+ }
67
+ /** Relative-to-cwd, forward-slash path for stable, readable result strings. */
68
+ function rel(ctx, abs) {
69
+ return path.relative(path.resolve(ctx.cwd), abs).replace(/\\/g, '/') || '.';
70
+ }
71
+ /** Recursively list files under `dir` as cwd-relative paths (sorted). */
72
+ async function listFilesUnder(ctx, dir) {
73
+ const out = [];
74
+ async function walk(d) {
75
+ let entries;
76
+ try {
77
+ entries = await fs.readdir(d, { withFileTypes: true });
78
+ }
79
+ catch {
80
+ return;
81
+ }
82
+ for (const e of entries) {
83
+ const full = path.join(d, e.name);
84
+ if (e.isDirectory())
85
+ await walk(full);
86
+ else
87
+ out.push(rel(ctx, full));
88
+ }
89
+ }
90
+ await walk(dir);
91
+ return out.sort();
92
+ }
93
+ // ───────────────────────────── fiori_scaffold ─────────────────────────────
94
+ export async function fioriScaffoldHandler(args, ctx) {
95
+ if (!args.basePath)
96
+ return { content: 'error: basePath is required', is_error: true };
97
+ if (!args.appId)
98
+ return { content: 'error: appId is required', is_error: true };
99
+ const resolved = safeDir(ctx, args.basePath);
100
+ if ('error' in resolved)
101
+ return { content: `error: ${resolved.error}`, is_error: true };
102
+ try {
103
+ await scaffoldFreestyle({
104
+ basePath: resolved.abs,
105
+ appId: args.appId,
106
+ appTitle: args.appTitle ?? args.appId,
107
+ template: args.template ?? 'basic',
108
+ service: args.serviceUrl
109
+ ? { url: args.serviceUrl, path: args.servicePath ?? '', version: args.serviceVersion ?? '4.0' }
110
+ : undefined,
111
+ typescript: args.typescript ?? false,
112
+ ui5Version: args.ui5Version,
113
+ });
114
+ }
115
+ catch (err) {
116
+ return { content: `error: scaffold failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
117
+ }
118
+ const files = await listFilesUnder(ctx, resolved.abs);
119
+ const appDir = rel(ctx, resolved.abs);
120
+ return {
121
+ content: `Scaffolded freestyle app "${args.appId}" into ${appDir}/ (${files.length} files).\n` +
122
+ `App dir: ${appDir}\n` +
123
+ `Files:\n${files.map((f) => ` ${f}`).join('\n')}`,
124
+ };
125
+ }
126
+ // ─────────────────────────────── fiori_apply ──────────────────────────────
127
+ export async function fioriApplyHandler(args, ctx) {
128
+ if (!args.appDir)
129
+ return { content: 'error: appDir is required', is_error: true };
130
+ if (!args.entry)
131
+ return { content: 'error: entry is required', is_error: true };
132
+ const resolved = safeDir(ctx, args.appDir);
133
+ if ('error' in resolved)
134
+ return { content: `error: ${resolved.error}`, is_error: true };
135
+ // Params may arrive as a parsed object (preferred) or a JSON string (mirrors
136
+ // the `--params '<json>'` CLI flag). Tolerate both.
137
+ let params;
138
+ if (typeof args.params === 'string') {
139
+ try {
140
+ params = args.params.trim() ? JSON.parse(args.params) : {};
141
+ }
142
+ catch (err) {
143
+ return { content: `error: params is not valid JSON — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
144
+ }
145
+ }
146
+ else {
147
+ params = args.params ?? {};
148
+ }
149
+ try {
150
+ // applyEntry preserves the engine's loud-on-conflict manifest merge — a
151
+ // conflicting manifest patch throws here and surfaces as is_error, never a
152
+ // silent last-wins overwrite.
153
+ applyEntry({ appDir: resolved.abs, entryName: args.entry, params });
154
+ }
155
+ catch (err) {
156
+ return { content: `error: apply failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
157
+ }
158
+ return {
159
+ content: `Applied catalog entry "${args.entry}" into ${rel(ctx, resolved.abs)}.\n` +
160
+ `Entry files written, manifest.json deep-merged (loud on conflict), i18n keys appended.`,
161
+ };
162
+ }
163
+ // ─────────────────────────────── fiori_list ───────────────────────────────
164
+ export async function fioriListHandler(_args, _ctx) {
165
+ // listCatalog() returns each entry's param specs so the model can build a
166
+ // correct fiori_apply params object in one shot (no required-param iteration).
167
+ const entries = listCatalog();
168
+ return { content: JSON.stringify(entries, null, 2) };
169
+ }
170
+ // ──────────────────────────── fiori_deploy_config ─────────────────────────
171
+ export async function fioriDeployConfigHandler(args, ctx) {
172
+ if (!args.appDir)
173
+ return { content: 'error: appDir is required', is_error: true };
174
+ if (!args.url)
175
+ return { content: 'error: url is required', is_error: true };
176
+ if (!args.client)
177
+ return { content: 'error: client is required', is_error: true };
178
+ if (!args.name)
179
+ return { content: 'error: name (BSP app name) is required', is_error: true };
180
+ if (!args.package)
181
+ return { content: 'error: package is required', is_error: true };
182
+ const resolved = safeDir(ctx, args.appDir);
183
+ if ('error' in resolved)
184
+ return { content: `error: ${resolved.error}`, is_error: true };
185
+ try {
186
+ writeDeployConfig(resolved.abs, {
187
+ url: args.url,
188
+ client: args.client,
189
+ appName: args.name,
190
+ description: args.description,
191
+ package: args.package,
192
+ transport: args.transport ?? '',
193
+ ignoreCertErrors: args.ignoreCertErrors === true,
194
+ });
195
+ }
196
+ catch (err) {
197
+ // writeDeployConfig validates app-name length/case, package namespace, and
198
+ // transport format — those validation errors surface here as is_error.
199
+ return { content: `error: deploy-config failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
200
+ }
201
+ const appDir = rel(ctx, resolved.abs);
202
+ return {
203
+ content: `Deploy config written: ${appDir}/ui5-deploy.yaml (BSP app ${args.name} → package ${args.package}` +
204
+ `${args.transport ? `, transport ${args.transport}` : ''}).\n` +
205
+ `package.json gained "deploy" and "deploy-test" scripts. Run "npm run deploy-test" to validate without deploying.`,
206
+ };
207
+ }
208
+ // ──────────────────────────── fiori_scaffold_fe ───────────────────────────
209
+ export async function fioriScaffoldFeHandler(args, ctx) {
210
+ if (!args.basePath)
211
+ return { content: 'error: basePath is required', is_error: true };
212
+ if (!args.appId)
213
+ return { content: 'error: appId is required', is_error: true };
214
+ if (!args.mainEntity)
215
+ return { content: 'error: mainEntity is required', is_error: true };
216
+ // metadata is OPTIONAL (the FE writer does not need it — spike §4); no $metadata fetch.
217
+ const resolved = safeDir(ctx, args.basePath);
218
+ if ('error' in resolved)
219
+ return { content: `error: ${resolved.error}`, is_error: true };
220
+ try {
221
+ await scaffoldFioriElements({
222
+ basePath: resolved.abs,
223
+ appId: args.appId,
224
+ appTitle: args.appTitle ?? args.appId,
225
+ template: args.template ?? 'lrop',
226
+ service: { url: args.serviceUrl, path: args.servicePath, version: args.serviceVersion ?? '4.0', metadata: args.metadata, client: args.client },
227
+ mainEntity: args.mainEntity,
228
+ ui5Version: args.ui5Version,
229
+ });
230
+ }
231
+ catch (err) {
232
+ return { content: `error: FE scaffold failed — ${err instanceof Error ? err.message : String(err)}`, is_error: true };
233
+ }
234
+ const files = await listFilesUnder(ctx, resolved.abs);
235
+ const appDir = rel(ctx, resolved.abs);
236
+ return {
237
+ content: `Scaffolded Fiori Elements ${args.template ?? 'lrop'} app "${args.appId}" into ${appDir}/ (${files.length} files).\n` +
238
+ `Driven by the service's backend @UI annotations (no local annotation.xml). App dir: ${appDir}\n` +
239
+ `Files:\n${files.map((f) => ` ${f}`).join('\n')}`,
240
+ };
241
+ }
242
+ // ─────────────────────────────── registration ─────────────────────────────
243
+ registerTool({
244
+ name: 'fiori_scaffold',
245
+ description: 'Scaffold a complete freestyle SAPUI5 app (webapp/ tree: manifest.json, index.html, ' +
246
+ 'Component.js, a Main view+controller, i18n, ui5.yaml, package.json) into a project ' +
247
+ 'directory. Built-in CSPeach Fiori engine — no pnpm or repo needed. Optionally wires a ' +
248
+ 'primary OData V2/V4 service. basePath is sandboxed to the project root.',
249
+ isMutating: true,
250
+ category: 'fiori',
251
+ flagGated: true,
252
+ input_schema: {
253
+ type: 'object',
254
+ properties: {
255
+ basePath: { type: 'string', description: 'Directory to scaffold into (relative to project root, created if missing).' },
256
+ appId: { type: 'string', description: 'App namespace in reverse-domain form, e.g. "zso.orderlist".' },
257
+ appTitle: { type: 'string', description: 'Human-readable app title. Defaults to appId.' },
258
+ template: { type: 'string', enum: ['basic', 'worklist', 'listdetail'], description: 'Base template. Default "basic".' },
259
+ serviceUrl: { type: 'string', description: 'Backend host for the primary OData service, e.g. "https://host:44300".' },
260
+ servicePath: { type: 'string', description: 'Relative OData service path, e.g. "/sap/opu/odata4/zso/srv/".' },
261
+ serviceVersion: { type: 'string', enum: ['2.0', '4.0'], description: 'OData version of the primary service. Default "4.0".' },
262
+ typescript: { type: 'boolean', description: 'Generate a TypeScript app. Default false.' },
263
+ ui5Version: { type: 'string', description: 'UI5 version for manifest minUI5Version + preview. Default 1.120.0. Do not go below 1.84.' },
264
+ },
265
+ required: ['basePath', 'appId'],
266
+ },
267
+ handler: fioriScaffoldHandler,
268
+ });
269
+ registerTool({
270
+ name: 'fiori_apply',
271
+ description: 'Apply a catalog pattern (viz-chart or value-help) into an already-scaffolded app. ' +
272
+ 'Renders the entry, writes its files, deep-merges manifest.json (errors loudly on ' +
273
+ 'conflict — never silent last-wins), and appends i18n keys. Call fiori_list first to ' +
274
+ 'read each entry\'s param specs. appDir is sandboxed to the project root.',
275
+ isMutating: true,
276
+ category: 'fiori',
277
+ flagGated: true,
278
+ input_schema: {
279
+ type: 'object',
280
+ properties: {
281
+ appDir: { type: 'string', description: 'Root directory of the already-scaffolded app (relative to project root).' },
282
+ entry: { type: 'string', description: 'Catalog entry name. Use fiori_list to see available entries (e.g. "viz-chart", "value-help").' },
283
+ params: {
284
+ type: 'object',
285
+ description: 'Entry parameters as an object. The required params per entry are returned by fiori_list.',
286
+ },
287
+ },
288
+ required: ['appDir', 'entry'],
289
+ },
290
+ handler: fioriApplyHandler,
291
+ });
292
+ registerTool({
293
+ name: 'fiori_list',
294
+ description: 'List the Fiori engine catalog entries (the patterns that need a frozen template: ' +
295
+ 'viz-chart, value-help) with each entry\'s title, description, libraries, and full ' +
296
+ 'param spec. Read this before fiori_apply to build a correct params object in one shot.',
297
+ isMutating: false,
298
+ category: 'fiori',
299
+ flagGated: true,
300
+ input_schema: { type: 'object', properties: {} },
301
+ handler: fioriListHandler,
302
+ });
303
+ registerTool({
304
+ name: 'fiori_deploy_config',
305
+ description: 'Write the ABAP-repository (BSP) deploy configuration for a scaffolded app: ui5-deploy.yaml ' +
306
+ 'plus "deploy" / "deploy-test" package.json scripts. Validates BSP app name (≤15 chars, ' +
307
+ 'uppercase), package namespace, and transport format. Credentials are NEVER written — ' +
308
+ 'fiori deploy takes env-var NAMES at deploy time. appDir is sandboxed to the project root.',
309
+ isMutating: true,
310
+ category: 'fiori',
311
+ flagGated: true,
312
+ input_schema: {
313
+ type: 'object',
314
+ properties: {
315
+ appDir: { type: 'string', description: 'Root directory of the scaffolded app (relative to project root).' },
316
+ url: { type: 'string', description: 'ABAP system base URL, e.g. "https://host:44300".' },
317
+ client: { type: 'string', description: 'SAP client, e.g. "100".' },
318
+ name: { type: 'string', description: 'BSP application name — max 15 chars, UPPERCASE, Z/Y prefix.' },
319
+ package: { type: 'string', description: 'ABAP package: Z-/Y-prefixed, $TMP, or a registered /namespace/pkg.' },
320
+ transport: { type: 'string', description: 'Workbench transport (e.g. "S4HK903359"). Required unless package is $TMP.' },
321
+ description: { type: 'string', description: 'BSP application description.' },
322
+ ignoreCertErrors: { type: 'boolean', description: 'Accept self-signed certificates (dev systems only). Default false.' },
323
+ },
324
+ required: ['appDir', 'url', 'client', 'name', 'package'],
325
+ },
326
+ handler: fioriDeployConfigHandler,
327
+ });
328
+ registerTool({
329
+ name: 'fiori_scaffold_fe',
330
+ description: 'Scaffold a trivial Fiori Elements app shell (List Report Object Page, Worklist, or Overview Page) ' +
331
+ 'against an ALREADY-PUBLISHED OData service. Driven entirely by the backend @UI annotations on the ' +
332
+ 'CDS projection/DDLX — authors NO local annotation.xml (not an FE generator). metadata is OPTIONAL ' +
333
+ '(the running app reads it from the live service); pass it only if you already have it. basePath ' +
334
+ 'sandboxed to the project root.',
335
+ isMutating: true,
336
+ category: 'fiori',
337
+ flagGated: true,
338
+ input_schema: {
339
+ type: 'object',
340
+ properties: {
341
+ basePath: { type: 'string', description: 'Directory to scaffold into (relative to project root).' },
342
+ appId: { type: 'string', description: 'App namespace, e.g. "z.tcrs.courses".' },
343
+ appTitle: { type: 'string', description: 'Human-readable title. Defaults to appId.' },
344
+ template: { type: 'string', enum: ['lrop', 'worklist', 'ovp'], description: 'FE template. Default "lrop". All three ship.' },
345
+ serviceUrl: { type: 'string', description: 'Backend host, e.g. "https://host:44300".' },
346
+ servicePath: { type: 'string', description: 'OData service path, e.g. "/sap/opu/odata4/sap/zc_x/srvd/sap/zc_x/0001/".' },
347
+ serviceVersion: { type: 'string', enum: ['2.0', '4.0'], description: 'OData version. Default "4.0".' },
348
+ metadata: { type: 'string', description: 'OPTIONAL service $metadata (EDMX XML). The writer does not need it; pass only if you already have it.' },
349
+ client: { type: 'string', description: 'OPTIONAL SAP client (e.g. "100"), written to the manifest for the proxy layer.' },
350
+ mainEntity: { type: 'string', description: 'The entity set to bind, e.g. "Course".' },
351
+ ui5Version: { type: 'string', description: 'UI5 version. Default 1.120.0.' },
352
+ },
353
+ required: ['basePath', 'appId', 'serviceUrl', 'servicePath', 'mainEntity'],
354
+ },
355
+ handler: fioriScaffoldFeHandler,
356
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,76 @@
1
+ // cspeach-cli/src/tools/local-build.ts
2
+ /**
3
+ * Startup wiring for the persisted `local_build` config toggle (2026-06-13,
4
+ * renamed + broadened from `local_files`).
5
+ *
6
+ * When the effective toggle is on (see config/loader.ts resolveLocalBuild —
7
+ * `local_build ?? local_files` for backward compat), the otherwise flag-gated
8
+ * LOCAL BUILD tools are force-enabled via the in-memory override in `_flag.ts`,
9
+ * BEFORE the first listTools()/turn. The enable set is now:
10
+ *
11
+ * filesystem: file_read, file_write, file_edit, glob, grep
12
+ * shell: shell_exec
13
+ * fiori: fiori_scaffold, fiori_apply, fiori_list, fiori_deploy_config
14
+ * extend: extend_model_insert (anchored CDS/DDLX edit — abap-extend-model)
15
+ *
16
+ * This is what makes /abap-fiori-build runnable end to end — it writes local
17
+ * app files (FS tools), runs safelisted build CLIs (shell), AND scaffolds /
18
+ * composes the app via the built-in Fiori engine (fiori_* tools). The fiori_*
19
+ * tools replace the old repo-only `pnpm fiori scaffold` path so shipped-binary
20
+ * customers get the same deterministic engine output without pnpm or the repo.
21
+ *
22
+ * The toggle is PERSISTED (config.toml), not re-consented each session, so
23
+ * `applyLocalBuildConfig` returns an announce line the entry point prints
24
+ * whenever the tools are live — the user must always see that local file access
25
+ * AND command execution are on this session (trust-visibility requirement).
26
+ *
27
+ * The blast radius is honestly named: the announce line surfaces the cwd (where
28
+ * read/write/edit operate) AND the shell safelist (which commands shell_exec may
29
+ * run). The FS tools' own cwd-sandbox (writes confined to the project root;
30
+ * .cspeach/.env/.git denylisted) and shell_exec's argv-only safelist enforcement
31
+ * still apply regardless of this toggle — enabling shell_exec only makes it
32
+ * visible, it does NOT make it unrestricted.
33
+ */
34
+ import { enableToolFlags } from './_flag.js';
35
+ // Source-of-truth for the shell safelist names surfaced in the announce line.
36
+ // Imported (not hand-copied) so the announce can never drift from the actual
37
+ // commands shell_exec permits. If shell_exec's DEFAULT_SAFELIST changes, this
38
+ // announce string follows automatically.
39
+ import { DEFAULT_SAFELIST } from './_command-shared.js';
40
+ /**
41
+ * The exact tool NAMEs the flag uses (file_read → CSPEACH_TOOL_FILE_READ,
42
+ * shell_exec → CSPEACH_TOOL_SHELL_EXEC). `shell_exec` matches the tool's
43
+ * registered name in tools/shell/shell_exec.ts, so isToolFlagOn('shell_exec')
44
+ * checks the same override key enableToolFlags(['shell_exec']) sets.
45
+ */
46
+ export const LOCAL_BUILD_TOOLS = [
47
+ 'file_read',
48
+ 'read_document',
49
+ 'file_write',
50
+ 'file_edit',
51
+ 'glob',
52
+ 'grep',
53
+ 'shell_exec',
54
+ 'fiori_scaffold',
55
+ 'fiori_apply',
56
+ 'fiori_list',
57
+ 'fiori_deploy_config',
58
+ 'fiori_scaffold_fe',
59
+ 'extend_model_insert',
60
+ 'system_capability',
61
+ ];
62
+ /**
63
+ * Apply the persisted `local_build` toggle. When on, force-enables the FS +
64
+ * shell build tools and returns a one-line announce string (undefined when off,
65
+ * so the caller prints nothing). `cwd` is surfaced so the user sees exactly
66
+ * which directory tree read/write/edit operate under; the shell safelist is
67
+ * surfaced so the user sees which commands shell_exec may run.
68
+ */
69
+ export function applyLocalBuildConfig(localBuild, cwd) {
70
+ if (localBuild !== true)
71
+ return undefined;
72
+ enableToolFlags([...LOCAL_BUILD_TOOLS]);
73
+ const safelist = [...DEFAULT_SAFELIST].join(', ');
74
+ return (`local build tools enabled — file read/write/edit under ${cwd} ` +
75
+ `+ safelisted commands (${safelist}) — cspeach config set local_build off to disable`);
76
+ }
@@ -0,0 +1,31 @@
1
+ // cspeach-cli/src/tools/local-files.ts
2
+ /**
3
+ * Startup wiring for the persisted `local_files` config toggle.
4
+ *
5
+ * When `cfg.local_files === true`, the otherwise flag-gated filesystem tools
6
+ * (file_read/file_write/file_edit/glob/grep) are force-enabled via the
7
+ * in-memory override in `_flag.ts`, BEFORE the first listTools()/turn. This is
8
+ * what makes /abap-fiori-build runnable — it writes local app files.
9
+ *
10
+ * The toggle is PERSISTED (config.toml), not re-consented each session, so
11
+ * `applyLocalFilesConfig` returns an announce line the entry point prints
12
+ * whenever the tools are live — the user must always see that local file
13
+ * access is on this session (trust-visibility requirement). The FS tools'
14
+ * own cwd-sandbox (writes confined to the project root; .cspeach/.env/.git
15
+ * denylisted) still applies regardless of this toggle.
16
+ */
17
+ import { enableToolFlags } from './_flag.js';
18
+ /** The exact tool NAMEs the flag uses (file_read → CSPEACH_TOOL_FILE_READ). */
19
+ export const LOCAL_FILE_TOOLS = ['file_read', 'file_write', 'file_edit', 'glob', 'grep'];
20
+ /**
21
+ * Apply the persisted `local_files` toggle. When on, force-enables the FS
22
+ * tools and returns a one-line announce string (undefined when off, so the
23
+ * caller prints nothing). `cwd` is surfaced so the user sees exactly which
24
+ * directory tree read/write/edit operate under.
25
+ */
26
+ export function applyLocalFilesConfig(localFiles, cwd) {
27
+ if (localFiles !== true)
28
+ return undefined;
29
+ enableToolFlags([...LOCAL_FILE_TOOLS]);
30
+ return `local file tools enabled (read/write/edit under ${cwd}) — cspeach config set local_files off to disable`;
31
+ }