@frontera-sdk/cli 1.52.38 → 1.52.40

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 (225) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +48 -4
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/index-commands.ts +69 -7
  33. package/src/commands/agent/resolve.ts +2 -0
  34. package/src/commands/app/checks.ts +44 -0
  35. package/src/commands/app/deploy.ts +243 -60
  36. package/src/commands/app/deployments.ts +97 -0
  37. package/src/commands/app/dev.ts +103 -11
  38. package/src/commands/app/init.ts +36 -3
  39. package/src/commands/app/promote.ts +45 -19
  40. package/src/commands/app/pull.ts +3 -1
  41. package/src/commands/app/rollback.ts +46 -0
  42. package/src/commands/app/sdk.ts +54 -8
  43. package/src/commands/app/unpin.ts +27 -0
  44. package/src/commands/app/versions.ts +1 -1
  45. package/src/commands/automation/bindings.ts +4 -2
  46. package/src/commands/automation/build-entry.ts +4 -1
  47. package/src/commands/automation/index-commands.ts +3 -1
  48. package/src/commands/automation/pull.ts +6 -1
  49. package/src/commands/automation/run.ts +34 -9
  50. package/src/commands/blueprint/bind.ts +2 -1
  51. package/src/commands/blueprint/declarative.ts +331 -31
  52. package/src/commands/blueprint/get.ts +6 -2
  53. package/src/commands/blueprint/grants.ts +64 -10
  54. package/src/commands/blueprint/metric.ts +108 -0
  55. package/src/commands/blueprint/object-sets.ts +71 -0
  56. package/src/commands/blueprint/query.ts +155 -31
  57. package/src/commands/dataset/index-commands.ts +156 -7
  58. package/src/commands/knowledge/index-commands.ts +122 -20
  59. package/src/commands/local/check.ts +657 -44
  60. package/src/commands/local/data.ts +33 -12
  61. package/src/commands/local/dev.ts +875 -88
  62. package/src/commands/local/docs-mcp.ts +10 -18
  63. package/src/commands/local/index-commands.ts +300 -63
  64. package/src/commands/local/init.ts +18 -5
  65. package/src/commands/local/key.ts +0 -3
  66. package/src/commands/local/providers.ts +0 -7
  67. package/src/commands/local/remove.ts +116 -0
  68. package/src/commands/local/schema.ts +3 -4
  69. package/src/commands/local/sdk-refresh.ts +59 -0
  70. package/src/commands/local/snapshot.ts +100 -0
  71. package/src/commands/local/sync-request.ts +320 -13
  72. package/src/commands/local/sync.ts +102 -5
  73. package/src/commands/mcp/serve.ts +34 -0
  74. package/src/commands/registry.ts +52 -8
  75. package/src/commands/types.ts +29 -0
  76. package/src/commands/workflow/archive.ts +46 -0
  77. package/src/commands/workflow/get.ts +2 -1
  78. package/src/commands/workflow/index-commands.ts +5 -0
  79. package/src/commands/workflow/push.ts +49 -2
  80. package/src/commands/workflow/run.ts +65 -7
  81. package/src/commands/workflow/runs.ts +9 -6
  82. package/src/commands/workflow/webhook-test.ts +166 -0
  83. package/src/config.ts +43 -6
  84. package/src/dev-broker.ts +52 -0
  85. package/src/exit.ts +103 -4
  86. package/src/flag-help.ts +77 -17
  87. package/src/forge/touches.ts +15 -12
  88. package/src/function-outside-imports.ts +89 -0
  89. package/src/harness.ts +42 -24
  90. package/src/heal.ts +112 -1
  91. package/src/local/fix-words.ts +12 -0
  92. package/src/local/function-bundle-recovery.ts +775 -0
  93. package/src/local/local-actions.ts +103 -10
  94. package/src/local/local-agent-action-grants.ts +210 -0
  95. package/src/local/local-agent-checks.ts +232 -0
  96. package/src/local/local-agent-grants.ts +29 -7
  97. package/src/local/local-agent-lean-plugins.ts +68 -0
  98. package/src/local/local-agent-missing-tools.ts +68 -0
  99. package/src/local/local-agent-name.ts +27 -0
  100. package/src/local/local-app-code.ts +191 -0
  101. package/src/local/local-app-dev-files.ts +186 -0
  102. package/src/local/local-app-dev-session.ts +88 -0
  103. package/src/local/local-app-lock-install.ts +114 -0
  104. package/src/local/local-app-pulled.ts +167 -0
  105. package/src/local/local-app-restore.ts +111 -0
  106. package/src/local/local-app-runtime.ts +68 -0
  107. package/src/local/local-app-sdk-paths.ts +149 -0
  108. package/src/local/local-app-sdk.ts +509 -0
  109. package/src/local/local-applied.ts +211 -21
  110. package/src/local/local-apply-history.ts +84 -0
  111. package/src/local/local-apply-summary.ts +86 -0
  112. package/src/local/local-apply-watchdog.ts +95 -0
  113. package/src/local/local-apps.ts +180 -7
  114. package/src/local/local-blueprint-errors.ts +198 -0
  115. package/src/local/local-blueprint-outline.ts +31 -12
  116. package/src/local/local-blueprint-schemas.ts +582 -0
  117. package/src/local/local-build-inputs.ts +48 -0
  118. package/src/local/local-build-summary.ts +495 -28
  119. package/src/local/local-case-evidence.ts +120 -0
  120. package/src/local/local-case-not-run.ts +153 -0
  121. package/src/local/local-chat-apps.ts +93 -16
  122. package/src/local/local-check-inherited.ts +83 -0
  123. package/src/local/local-cities.ts +89 -0
  124. package/src/local/local-component-health.ts +119 -0
  125. package/src/local/local-control.ts +34 -7
  126. package/src/local/local-db-snapshot.ts +348 -0
  127. package/src/local/local-dev-references.ts +346 -35
  128. package/src/local/local-dev-sync.ts +136 -7
  129. package/src/local/local-docker.ts +35 -1
  130. package/src/local/local-docs-search.ts +1 -1
  131. package/src/local/local-docs.ts +281 -2
  132. package/src/local/local-empty-workflows.ts +92 -0
  133. package/src/local/local-env.ts +16 -1
  134. package/src/local/local-file-checks.ts +104 -0
  135. package/src/local/local-function-ids.ts +141 -0
  136. package/src/local/local-function-recovery.ts +141 -0
  137. package/src/local/local-function-state.ts +87 -0
  138. package/src/local/local-keys.ts +7 -5
  139. package/src/local/local-knowledge-ingestion.ts +225 -0
  140. package/src/local/local-knowledge-uploads.ts +217 -0
  141. package/src/local/local-media-placeholders.ts +182 -0
  142. package/src/local/local-media-sets.ts +90 -10
  143. package/src/local/local-model-parameters.ts +89 -0
  144. package/src/local/local-object-sets.ts +18 -6
  145. package/src/local/local-operating.ts +35 -1
  146. package/src/local/local-org-settings.ts +205 -7
  147. package/src/local/local-orphans.ts +321 -0
  148. package/src/local/local-personas.ts +296 -0
  149. package/src/local/local-portability.ts +141 -16
  150. package/src/local/local-ports.ts +99 -7
  151. package/src/local/local-project-notices.ts +234 -0
  152. package/src/local/local-project-secrets.ts +133 -0
  153. package/src/local/local-project.ts +12 -0
  154. package/src/local/local-provider-refusal.ts +66 -0
  155. package/src/local/local-request-evidence.ts +112 -0
  156. package/src/local/local-runtime-health.ts +123 -0
  157. package/src/local/local-runtime-history.ts +50 -0
  158. package/src/local/local-runtime-target.ts +42 -0
  159. package/src/local/local-sample-data.ts +141 -0
  160. package/src/local/local-sample-remake.ts +138 -0
  161. package/src/local/local-scaffold.ts +336 -53
  162. package/src/local/local-schema-check.ts +148 -0
  163. package/src/local/local-schemas.ts +742 -25
  164. package/src/local/local-secret-carry.ts +107 -0
  165. package/src/local/local-secrets.ts +38 -8
  166. package/src/local/local-seed.ts +102 -26
  167. package/src/local/local-stand-ins.ts +1 -1
  168. package/src/local/local-state.ts +43 -2
  169. package/src/local/local-studio-server.ts +170 -7
  170. package/src/local/local-supervisor.ts +44 -6
  171. package/src/local/local-sync-names.ts +2 -0
  172. package/src/local/local-sync-plan.ts +11 -0
  173. package/src/local/local-sync-push.ts +10 -7
  174. package/src/local/local-sync-refs.ts +49 -3
  175. package/src/local/local-sync-remote.ts +343 -32
  176. package/src/local/local-sync-snapshot.ts +15 -10
  177. package/src/local/local-synthetic-data.ts +2116 -99
  178. package/src/local/local-test-baseline.ts +113 -0
  179. package/src/local/local-test-cases.ts +76 -1
  180. package/src/local/local-test-runner.ts +582 -91
  181. package/src/local/local-test-runs.ts +13 -5
  182. package/src/local/local-vendored-sdk.ts +183 -0
  183. package/src/local/local-workflow-state.ts +16 -3
  184. package/src/local/local-workflow-tests.ts +482 -0
  185. package/src/local/local-workspace-grants.ts +81 -0
  186. package/src/main.ts +50 -9
  187. package/src/mcp/a11y-scan.ts +55 -0
  188. package/src/mcp/browser/ax-snapshot.ts +181 -0
  189. package/src/mcp/browser/credentials.ts +78 -0
  190. package/src/mcp/browser/frames.ts +336 -0
  191. package/src/mcp/browser/headless-session.ts +413 -0
  192. package/src/mcp/browser/headless-tab.ts +575 -0
  193. package/src/mcp/browser/keys.ts +70 -0
  194. package/src/mcp/browser/page-scripts.ts +129 -0
  195. package/src/mcp/browser/wait.ts +73 -0
  196. package/src/mcp/forge-link.ts +126 -0
  197. package/src/mcp/headless.ts +199 -0
  198. package/src/mcp/mcp-apps.ts +435 -0
  199. package/src/mcp/mcp-chat.ts +226 -0
  200. package/src/mcp/mcp-cli.ts +53 -0
  201. package/src/mcp/mcp-protocol.ts +122 -0
  202. package/src/mcp/mcp-runtime.ts +113 -0
  203. package/src/mcp/mcp-server.ts +108 -0
  204. package/src/mcp/mcp-traces.ts +409 -0
  205. package/src/mcp/page-observation.ts +193 -0
  206. package/src/mcp/tools/agent-tools.ts +700 -0
  207. package/src/mcp/tools/app-tools.ts +241 -0
  208. package/src/mcp/tools/browser-tools.ts +310 -0
  209. package/src/mcp/tools/docs-tools.ts +32 -0
  210. package/src/mcp/tools/project-tools.ts +205 -0
  211. package/src/mcp/tools/test-tools.ts +339 -0
  212. package/src/mcp/tools/workflow-tools.ts +611 -0
  213. package/src/output.ts +1 -1
  214. package/src/pack.ts +8 -3
  215. package/src/packaging.ts +1 -1
  216. package/src/project-bootstrap.ts +107 -4
  217. package/src/project-context.ts +18 -1
  218. package/src/project.ts +27 -2
  219. package/src/provenance.ts +25 -0
  220. package/src/render-evidence.ts +65 -7
  221. package/src/template.ts +14 -8
  222. package/src/templates/next-app-files.ts +35 -6
  223. package/src/templates/next-skills.ts +61 -27
  224. package/src/vendor/kit-assets.json +6 -6
  225. package/src/vendor/sdk-sources.json +24 -21
@@ -0,0 +1,241 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { RELEASE_LABEL, suggestedLabel } from '../../commands/app/deploy'
5
+ import { appSdkDrift, sdkDriftLines } from '../../local/local-app-sdk'
6
+ import type { LocalProject } from '../../local/local-project'
7
+ import { forgeSession, observeInForge, readForgeLink } from '../forge-link'
8
+ import { AXE_RUN, axeSource, summarizeA11y } from '../a11y-scan'
9
+ import { observeHeadless } from '../headless'
10
+ import { ensureDevServer, findAppProjects, listDevServers, localAppEnv, resolveAppProject, stopDevServerAndSay } from '../mcp-apps'
11
+ import { runSelf } from '../mcp-cli'
12
+ import { errorResult, intArg, stringArg, type McpTool, type ToolResult } from '../mcp-protocol'
13
+ import { redactSecret, requireRuntime, runtimeLink, signedStudioUrl } from '../mcp-runtime'
14
+ import { platformCalls, renderTargetOf, writeRenderEvidence, type PageObservation, type RenderTarget } from '../page-observation'
15
+
16
+ /**
17
+ * Custom Apps: run one against the local runtime, look at it the way a
18
+ * person would, and ship a preview. `app_render` is the local twin of the
19
+ * cloud copilot's `render_app`: it writes the render evidence a deploy
20
+ * carries, so a version deployed from here no longer says nobody looked
21
+ * at it. There is no tool to promote: that stays with a person.
22
+ */
23
+
24
+ const appProperty = { type: 'string', description: 'The App\'s slug. Optional when the project has one App.' }
25
+
26
+ function joinPath(base: string, path: string): string {
27
+ if (!path || path === '/') return base
28
+ const url = new URL(base)
29
+ // The Vite host page carries the App's path in its hash.
30
+ if (url.pathname.endsWith('/dev-host.html')) {
31
+ url.hash = path.startsWith('/') ? path : `/${path}`
32
+ return url.toString()
33
+ }
34
+ return new URL(path.startsWith('/') ? path : `/${path}`, url.origin).toString()
35
+ }
36
+
37
+ /** The App's `package.json` version, for a label suggestion. */
38
+ function appPackageVersion(root: string): string | null {
39
+ try {
40
+ const v = (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version?: unknown }).version
41
+ return typeof v === 'string' ? v : null
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ /** The text and picture of an observation, for the agent. */
48
+ export function observationResult(summary: Record<string, unknown>, observation: PageObservation): ToolResult {
49
+ return {
50
+ content: [
51
+ { type: 'text', text: JSON.stringify(summary, null, 2) },
52
+ { type: 'image', data: observation.screenshot, mimeType: 'image/png' },
53
+ ],
54
+ }
55
+ }
56
+
57
+ export function appTools(project: LocalProject): McpTool[] {
58
+ return [
59
+ {
60
+ name: 'app_dev',
61
+ description:
62
+ "Start the App's dev server against the local runtime (its records are the project's datasets, never the workspace's; a governed Action the App submits changes them when it runs here, and one that would reach another system is switched off), or reuse the one already running, and return its URL. A server this session starts stops when the session ends; `frontera local stop` also stops it. Pass stop to stop it now. The App's files git tracks (or the pull recorded) that the dev server rewrites, such as next-env.d.ts, are put back once it is ready and again when it stops; trackedFiles says which, and an edit of yours is never put back. With no App in the call and several in the project, lists them.",
63
+ inputSchema: {
64
+ type: 'object',
65
+ properties: { app: appProperty, stop: { type: 'boolean', description: 'Stop the App\'s dev server instead.' } },
66
+ additionalProperties: false,
67
+ },
68
+ annotations: { title: 'Run an App locally', readOnlyHint: false, openWorldHint: false },
69
+ async run(input) {
70
+ const slug = stringArg(input, 'app')
71
+ const apps = findAppProjects(project.root)
72
+ if (!slug && apps.length > 1) {
73
+ const running = listDevServers(project)
74
+ return {
75
+ content: [{ type: 'text', text: JSON.stringify({ apps: apps.map((a) => ({ app: a.slug, path: a.path, framework: a.framework, running: running.find((s) => s.slug === a.slug)?.url ?? null })), next: 'Call app_dev with app set to one of these.' }, null, 2) }],
76
+ }
77
+ }
78
+ const app = resolveAppProject(project.root, slug)
79
+ if (input.stop === true) {
80
+ // Stopped, then what it put back as it stopped: the tracked files the dev server rewrote.
81
+ const { stopped, trackedFiles } = await stopDevServerAndSay(project, app.slug)
82
+ return { content: [{ type: 'text', text: stopped ? [`Stopped ${app.slug}'s dev server.`, ...trackedFiles.map((l) => `${app.path}: ${l}.`)].join('\n') : `${app.slug} had no dev server running.` }] }
83
+ }
84
+ const link = requireRuntime(project)
85
+ const { server, reused, installed, trackedFiles } = await ensureDevServer(project, app, link)
86
+ // An installed SDK older than (or without what is in) the one the skills describe: said with the upgrade.
87
+ const sdkLines = sdkDriftLines(app.path, appSdkDrift(app.root))
88
+ const sdk = sdkLines.filter((l) => !/^note:/.test(l))
89
+ const sdkNotes = sdkLines.filter((l) => /^note:/.test(l))
90
+ return {
91
+ content: [
92
+ {
93
+ type: 'text',
94
+ text: JSON.stringify({ app: app.slug, url: server.url, reused, ...(installed ? { installed } : {}), ...(trackedFiles?.length ? { trackedFiles: trackedFiles.map((l) => `${app.path}: ${l}`) } : {}), runtime: link.serviceUrl, log: server.log, ...(sdk.length ? { warnings: sdk } : {}), ...(sdkNotes.length ? { notes: sdkNotes } : {}), next: 'app_render shows the page with its console; browser_open opens it in Forge\'s Browser to click through.' }, null, 2),
95
+ },
96
+ ],
97
+ }
98
+ },
99
+ },
100
+ {
101
+ name: 'app_render',
102
+ description:
103
+ "Open the App (or one of its pages), wait for it to settle, and return a screenshot, the console's errors and warnings, failed requests, and the Blueprint reads and governed Action requests it made. Writes the render evidence a deploy carries. Starts the dev server when none runs. In Forge the page opens in the person's Browser.",
104
+ inputSchema: {
105
+ type: 'object',
106
+ properties: {
107
+ app: appProperty,
108
+ path: { type: 'string', description: 'The App\'s page to open, e.g. /tickets (default /).' },
109
+ url: { type: 'string', description: 'Render this address instead of the dev server, e.g. a preview deployment\'s link.' },
110
+ width: { type: 'integer', minimum: 320, maximum: 2560, description: 'Viewport width (default 1440; 768 tablet, 375 phone).' },
111
+ waitForText: { type: 'string', description: 'Wait until this text shows before the screenshot.' },
112
+ fullPage: { type: 'boolean', description: 'Capture the whole page, not only the viewport.' },
113
+ },
114
+ additionalProperties: false,
115
+ },
116
+ annotations: { title: 'Render an App', readOnlyHint: false, openWorldHint: false },
117
+ async run(input) {
118
+ const app = resolveAppProject(project.root, stringArg(input, 'app'))
119
+ const path = stringArg(input, 'path') ?? '/'
120
+ const explicit = stringArg(input, 'url')
121
+ const link = runtimeLink(project)
122
+ let url: string
123
+ let installed: string | undefined
124
+ // What the dev server rewrote among the App's tracked files, put back as it started (next-env.d.ts).
125
+ let trackedFiles: string[] | undefined
126
+ let target: RenderTarget
127
+ if (explicit) {
128
+ url = explicit
129
+ // A deploy attaches this render only when it is of this App: its dev server or one of its previews.
130
+ target = renderTargetOf(explicit, app.slug, listDevServers(project).find((s) => s.slug === app.slug)?.url ?? null)
131
+ } else {
132
+ if (typeof link === 'string') return errorResult(link)
133
+ const dev = await ensureDevServer(project, app, link)
134
+ installed = dev.installed
135
+ trackedFiles = dev.trackedFiles
136
+ url = joinPath(dev.server.url, path)
137
+ target = { of: 'dev-server', url, app: app.slug }
138
+ }
139
+ const width = intArg(input, 'width', 1440, 320, 2560)
140
+ // A preview on this project's Studio opens signed in, as `frontera local ui`'s link does; the token never reaches an answer.
141
+ const signed = signedStudioUrl(project, link, url)
142
+ const req = { url: signed.url, width, height: width < 800 ? 812 : 900, waitForText: stringArg(input, 'waitForText'), fullPage: input.fullPage === true, a11yScript: axeSource(), a11yRun: AXE_RUN }
143
+ const session = forgeSession()
144
+ const forge = readForgeLink(project.stateDir)
145
+ const raw = (forge ? await observeInForge(forge, req, { session, label: app.name }) : null) ?? (await observeHeadless(req))
146
+ // Forge answers with the scan's raw findings; they are kept in one shape.
147
+ const { screenshot: picture, ...seen } = { ...raw, a11y: raw.a11y && !Array.isArray(raw.a11y) ? raw.a11y : summarizeA11y(raw.a11y) }
148
+ const observation = { ...redactSecret(seen, signed.secret), screenshot: picture }
149
+ const { record, screenshot, evidence } = writeRenderEvidence({
150
+ appRoot: app.root,
151
+ solutionRoot: project.root,
152
+ session,
153
+ slug: app.slug,
154
+ path,
155
+ observation,
156
+ runtime: explicit || typeof link === 'string' ? { kind: 'url', url } : { kind: 'local', url: link.serviceUrl, version: link.state.platformVersion },
157
+ target,
158
+ })
159
+ const calls = platformCalls(observation.requests)
160
+ const problems = record.consoleErrors + record.failedRequests + (observation.navigationError ? 1 : 0)
161
+ return observationResult(
162
+ {
163
+ app: app.slug,
164
+ url: observation.url,
165
+ title: observation.title,
166
+ verdict: problems ? `${problems} problem${problems === 1 ? '' : 's'} to look at` : 'Rendered with no console errors or failed requests',
167
+ browser: observation.where === 'forge' ? 'Forge Browser (the person can see this tab)' : 'headless',
168
+ ...(installed ? { installed } : {}),
169
+ ...(trackedFiles?.length ? { trackedFiles: trackedFiles.map((l) => `${app.path}: ${l}`) } : {}),
170
+ ...(observation.navigationError ? { navigationError: observation.navigationError } : {}),
171
+ console: observation.console.slice(0, 25),
172
+ failedRequests: observation.failedRequests.slice(0, 15),
173
+ blueprintCalls: calls.blueprint.slice(0, 20),
174
+ actionRequests: calls.actions,
175
+ functionCalls: calls.functions.slice(0, 10),
176
+ ...(observation.a11y ? { accessibility: { serious: observation.a11y.violations, findings: observation.a11y.findings.slice(0, 10) } } : {}),
177
+ text: observation.text.slice(0, 1_500),
178
+ evidence: {
179
+ record: `${app.path === '.' ? '' : `${app.path}/`}.frontera/last-render.json`,
180
+ screenshot,
181
+ details: evidence,
182
+ // Said here, not only at deploy: a render of another address is not this App's evidence.
183
+ ...(target.of === 'elsewhere'
184
+ ? { attachesToDeploy: false, note: `${url} is not ${app.slug}'s dev server or one of its previews, so app_deploy will not attach this render. Render the App (no url), or its preview link.` }
185
+ : { attachesToDeploy: true, of: target.of }),
186
+ },
187
+ },
188
+ observation,
189
+ )
190
+ },
191
+ },
192
+ {
193
+ name: 'app_deploy',
194
+ description:
195
+ "Build the App (bun run build), check it (bun run check, and that the build is newer than the source) and upload it as a preview deployment: it gets its own link and moves this branch's and this session's preview links, and never changes what users see. Deploys to the local runtime unless target is workspace (the linked workspace, with the person's own sign-in). Render first: the deployment carries the render evidence, and evidence.ok says whether that render was clean. Promotion stays with a person.",
196
+ inputSchema: {
197
+ type: 'object',
198
+ properties: {
199
+ app: appProperty,
200
+ target: { type: 'string', enum: ['local', 'workspace'], description: 'Where the preview goes (default local).' },
201
+ label: { type: 'string', description: 'An optional release label: a version such as 1.4.0, or 1.4.0-e2e.1 with a suffix (not "e2e-1"); unique per App. Every deployment has its own id without one.' },
202
+ build: { type: 'boolean', description: 'Build before deploying (default true). false deploys the build output already on disk.' },
203
+ },
204
+ additionalProperties: false,
205
+ },
206
+ annotations: { title: 'Deploy an App preview', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
207
+ async run(input) {
208
+ const app = resolveAppProject(project.root, stringArg(input, 'app'))
209
+ const target = stringArg(input, 'target') === 'workspace' ? 'workspace' : 'local'
210
+ const env = target === 'local' ? localAppEnv(requireRuntime(project)) : {}
211
+ const label = stringArg(input, 'label')
212
+ if (label && !RELEASE_LABEL.test(label)) {
213
+ return errorResult(`label "${label}" is not a version: labels look like 1.4.0, or 1.4.0-e2e.1 with a suffix. Try "${suggestedLabel(label, appPackageVersion(app.root))}", or leave label out (every deployment has its own id).`)
214
+ }
215
+ const result = await runSelf(['app', 'deploy', '--preview', ...(input.build === false ? [] : ['--build']), ...(label ? ['--label', label] : [])], { cwd: app.root, env })
216
+ const deployment = (result.data && typeof result.data === 'object' ? result.data : null) as { evidence?: { ok: boolean; problems: string[] } } | null
217
+ const evidence = deployment?.evidence
218
+ return {
219
+ ...(result.ok ? {} : { isError: true as const }),
220
+ content: [
221
+ {
222
+ type: 'text',
223
+ text: JSON.stringify(
224
+ {
225
+ ok: result.ok,
226
+ target,
227
+ // First, because it is what the agent must carry into its report.
228
+ ...(evidence ? { evidence: evidence.ok ? evidence : { ...evidence, next: 'Say so when you report this preview: app_render the pages again after fixing what it found, then deploy again.' } } : {}),
229
+ deployment: result.data,
230
+ log: result.log,
231
+ },
232
+ null,
233
+ 2,
234
+ ),
235
+ },
236
+ ],
237
+ }
238
+ },
239
+ },
240
+ ]
241
+ }
@@ -0,0 +1,310 @@
1
+ import { existsSync, statSync } from 'node:fs'
2
+ import { isAbsolute } from 'node:path'
3
+
4
+ import { localProject, type LocalProject } from '../../local/local-project'
5
+ import { headlessSession, type HeadlessBrowserSession } from '../browser/headless-session'
6
+ import { callForgeBrowser, forgeSession, readForgeLink } from '../forge-link'
7
+ import { observeHeadless } from '../headless'
8
+ import { findAppProjects, listDevServers } from '../mcp-apps'
9
+ import { stringArg, type McpTool, type ToolResult } from '../mcp-protocol'
10
+ import { redactSecret, runtimeLink, signedStudioUrl, type RuntimeLink } from '../mcp-runtime'
11
+ import { saveScreenshot } from '../page-observation'
12
+
13
+ /**
14
+ * A browser for the coding agent. With the project open in Forge, each tool
15
+ * runs in a tab of Forge's Browser, labelled with the session that drives
16
+ * it, where the person can take over or stop it. Without Forge (a plain
17
+ * Claude Code or Codex session, an IDE, CI) the same tools run in a headless
18
+ * Chromium this server owns, with the same contracts and answers. Every
19
+ * answer ends saying which: `browser: forge` or `browser: headless`. The
20
+ * shapes follow Playwright MCP's, so agents need no new habits: snapshot for
21
+ * the page's accessibility tree with element refs, then click and type by ref.
22
+ *
23
+ * Reading and navigating are free on any site. Acting outside the project
24
+ * (submitting a form on another website, or on the linked workspace, or
25
+ * running script there) asks the person first, in Forge; headless, nobody
26
+ * can be asked, so it is refused. An agent acts only in the tabs it opened;
27
+ * in Forge the person's tabs and other sessions' it may only read
28
+ * (snapshot, screenshot, console, network).
29
+ */
30
+
31
+ const tabId = { type: 'string', description: 'The tab, from browser_open or browser_tabs; your session\'s current tab when left out. You act only in tabs you opened; the person\'s and other sessions\' tabs you may only read (browser_open the same address to work on it).' }
32
+ const ref = { type: 'string', description: 'The element\'s ref from the latest browser_snapshot, e.g. e12.' }
33
+ const element = { type: 'string', description: 'What the element is, in words ("Save button"), shown to the person while you act.' }
34
+ const waitFields = {
35
+ text: { type: 'string', description: 'Wait until this text shows on the page.' },
36
+ textGone: { type: 'string', description: 'Wait until this text is gone.' },
37
+ selector: { type: 'string', description: 'Wait until an element matching this CSS selector shows (or, with state hidden, is gone).' },
38
+ state: { type: 'string', enum: ['visible', 'hidden'], description: 'For selector or ref: visible (default) or hidden.' },
39
+ networkIdle: { type: 'boolean', description: 'Wait until no request has run for half a second.' },
40
+ timeout: { type: 'number', description: 'Seconds to wait at most (default 10, at most 60).' },
41
+ }
42
+ const waitFor = {
43
+ type: 'object',
44
+ description: 'Before reading the page, wait (bounded) for content that appears a moment after it loads.',
45
+ properties: waitFields,
46
+ additionalProperties: false,
47
+ }
48
+
49
+ interface BrowserToolSpec {
50
+ name: string
51
+ title: string
52
+ description: string
53
+ properties: Record<string, unknown>
54
+ required?: string[]
55
+ readOnly: boolean
56
+ }
57
+
58
+ const SPECS: BrowserToolSpec[] = [
59
+ {
60
+ name: 'browser_open',
61
+ title: 'Open a page',
62
+ description: "Open an address in a new tab and return its accessibility snapshot: a dev server, Studio (opened signed in), the linked workspace or any website. In the Forge Browser when the project is open in Forge (the person watches), else in a headless browser; the answer ends with which.",
63
+ properties: { url: { type: 'string', description: 'The address, e.g. http://127.0.0.1:3100/tickets.' }, waitFor },
64
+ required: ['url'],
65
+ readOnly: true,
66
+ },
67
+ {
68
+ name: 'browser_navigate',
69
+ title: 'Navigate',
70
+ description: 'Go to an address in the tab, or back, forward or reload. Waits for the page to load and settle, then returns the new snapshot.',
71
+ properties: { url: { type: 'string', description: 'Where to go.' }, action: { type: 'string', enum: ['back', 'forward', 'reload'], description: 'Instead of an address.' }, waitFor, tabId },
72
+ readOnly: true,
73
+ },
74
+ {
75
+ name: 'browser_snapshot',
76
+ title: 'Read the page',
77
+ description: "The page's accessibility tree with a ref for each element you can act on. Embedded frames (an App previewed in Studio) are read in under their Iframe line, marked [frame=address]; their refs act inside the frame like any other. A ref keeps naming the same element until the page navigates to another document; take a new snapshot after that, and after the person has used the tab.",
78
+ properties: { waitFor: { ...waitFor, properties: { ...waitFields, ref: { type: 'string', description: 'For a ref from an earlier snapshot: wait until it shows (or, with state hidden, is gone).' } } }, tabId },
79
+ readOnly: true,
80
+ },
81
+ {
82
+ name: 'browser_click',
83
+ title: 'Click',
84
+ description: 'Click an element by its ref. The person sees the element outlined and the pointer move. Submitting a form outside the project asks the person first.',
85
+ properties: { ref, element, doubleClick: { type: 'boolean' }, button: { type: 'string', enum: ['left', 'right', 'middle'] }, tabId },
86
+ required: ['ref', 'element'],
87
+ readOnly: false,
88
+ },
89
+ {
90
+ name: 'browser_type',
91
+ title: 'Type',
92
+ description: 'Type text into an element by its ref, optionally pressing Enter after. Password fields are refused: ask the person to sign in.',
93
+ properties: { ref, element, text: { type: 'string' }, submit: { type: 'boolean', description: 'Press Enter after typing.' }, clear: { type: 'boolean', description: 'Clear the field first (default true).' }, tabId },
94
+ required: ['ref', 'element', 'text'],
95
+ readOnly: false,
96
+ },
97
+ {
98
+ name: 'browser_press_key',
99
+ title: 'Press a key',
100
+ description: 'Press a key or a combination (Enter, Escape, Tab, ArrowDown, a, Shift+Tab, Meta+a), in the element by ref (focused first) or wherever the page has focus. Enter or Space outside the project asks the person first.',
101
+ properties: { key: { type: 'string' }, ref: { type: 'string', description: 'Optional: the element to press it in.' }, element, tabId },
102
+ required: ['key'],
103
+ readOnly: false,
104
+ },
105
+ {
106
+ name: 'browser_select_option',
107
+ title: 'Choose an option',
108
+ description: 'Choose one or more options of a <select> by value or label. For a custom list box, click it open and click the option.',
109
+ properties: { ref, element, values: { type: 'array', items: { type: 'string' }, description: 'The options, by value or label.' }, tabId },
110
+ required: ['ref', 'element', 'values'],
111
+ readOnly: false,
112
+ },
113
+ {
114
+ name: 'browser_hover',
115
+ title: 'Hover',
116
+ description: 'Move the pointer over an element by ref, so what shows on hover (a menu, a tooltip) appears in the snapshot.',
117
+ properties: { ref, element, tabId },
118
+ required: ['ref', 'element'],
119
+ readOnly: false,
120
+ },
121
+ {
122
+ name: 'browser_wait_for',
123
+ title: 'Wait for the page',
124
+ description: 'Wait, bounded, until text shows or is gone, an element (selector or ref) shows or is gone, the network goes quiet, or for a number of seconds. Returns the snapshot; an error when it did not happen in time.',
125
+ properties: { ...waitFields, ref: { type: 'string', description: 'An element\'s ref: wait until it shows (or, with state hidden, is gone).' }, time: { type: 'number', description: 'Instead: just wait this many seconds (at most 60).' }, tabId },
126
+ readOnly: true,
127
+ },
128
+ {
129
+ name: 'browser_evaluate',
130
+ title: 'Run script in the page',
131
+ description: "Run a JavaScript expression in the page (its own world, where the App's state lives) and return the result as JSON, at most 20,000 characters. A function, such as () => document.title, is called; a promise is awaited (10 s at most). With a ref, it runs in the frame that element is in (an App embedded in Studio) and a function is called with the element. Outside the project it asks the person first. Prefer snapshot, click and type to check what a person sees.",
132
+ properties: {
133
+ expression: { type: 'string', description: 'An expression or a function, e.g. () => window.location.pathname, or (element) => element.value with a ref.' },
134
+ ref: { type: 'string', description: 'Optional: an element from the snapshot. The script runs in its frame and a function gets the element.' },
135
+ tabId,
136
+ },
137
+ required: ['expression'],
138
+ readOnly: false,
139
+ },
140
+ {
141
+ name: 'browser_set_viewport',
142
+ title: 'Set the page width',
143
+ description: "Lay the tab's page out at a device width (desktop, tablet 768, phone 375) or at a width (and height) in pixels, for responsive checks. desktop goes back to the window's own size.",
144
+ properties: { device: { type: 'string', enum: ['desktop', 'tablet', 'phone'] }, width: { type: 'integer', minimum: 240, maximum: 3840 }, height: { type: 'integer', minimum: 240, maximum: 4320 }, tabId },
145
+ readOnly: false,
146
+ },
147
+ {
148
+ name: 'browser_handle_dialog',
149
+ title: 'Answer a dialog',
150
+ description: "Answer the dialog the page opened (alert, confirm, prompt, or leaving the page): accept (OK) or not (Cancel), with text for a prompt. Actions say when one opened, and what it says; the page waits until it is answered. OK outside the project asks the person first.",
151
+ properties: { accept: { type: 'boolean', description: 'OK (default) or Cancel.' }, promptText: { type: 'string', description: 'For a prompt: the text to answer with.' }, tabId },
152
+ readOnly: false,
153
+ },
154
+ {
155
+ name: 'browser_screenshot',
156
+ title: 'Screenshot',
157
+ description: 'A screenshot of the tab once its page settled (a tab behind the person\'s is captured without bringing it to the front), saved in the evidence folder of the project the tab belongs to, named after the App when it shows one. Without Forge, url renders an address headless without opening a tab.',
158
+ properties: { tabId, fullPage: { type: 'boolean' }, url: { type: 'string', description: 'Without Forge only: an address to render once, instead of a tab.' } },
159
+ readOnly: true,
160
+ },
161
+ {
162
+ name: 'browser_console',
163
+ title: 'Console',
164
+ description: "The tab's console messages since it opened, newest last.",
165
+ properties: { tabId, onlyErrors: { type: 'boolean', description: 'Only errors and warnings.' } },
166
+ readOnly: true,
167
+ },
168
+ {
169
+ name: 'browser_network',
170
+ title: 'Network',
171
+ description: "The tab's requests since it opened: method, address, status and time, failed ones first. With onlyFailed, only the failed ones, then how many others were captured; with none failed it says how many were captured, so an empty answer never hides a capture.",
172
+ properties: { tabId, onlyFailed: { type: 'boolean', description: 'Only failed requests (refused, aborted, or 400 and above).' } },
173
+ readOnly: true,
174
+ },
175
+ {
176
+ name: 'browser_tabs',
177
+ title: 'Tabs',
178
+ description: 'List the Browser\'s tabs (owner says whose: you, the person, another session), make one your current tab, or close one of yours. You may read any of them; you act only in yours.',
179
+ properties: { action: { type: 'string', enum: ['list', 'select', 'close'], description: 'Default list.' }, tabId },
180
+ readOnly: false,
181
+ },
182
+ ]
183
+
184
+ /** Where a screenshot's evidence goes: the folder of the project the tab belongs to (as Forge says), else this server's. */
185
+ export function evidenceProject(project: LocalProject, tabRoot: unknown): LocalProject {
186
+ if (typeof tabRoot !== 'string' || !isAbsolute(tabRoot) || tabRoot === project.root) return project
187
+ try {
188
+ return existsSync(tabRoot) && statSync(tabRoot).isDirectory() ? localProject(tabRoot) : project
189
+ } catch {
190
+ return project
191
+ }
192
+ }
193
+
194
+ /** The project's App an address shows: its dev server's origin, or a platform or Studio page under `/apps/<slug>`. */
195
+ export function appForUrl(project: LocalProject, url: string): string | null {
196
+ let parsed: URL
197
+ try {
198
+ parsed = new URL(url)
199
+ } catch {
200
+ return null
201
+ }
202
+ const dev = listDevServers(project).find((s) => {
203
+ try {
204
+ return new URL(s.url).origin === parsed.origin
205
+ } catch {
206
+ return false
207
+ }
208
+ })
209
+ if (dev) return dev.slug
210
+ const slug = /^\/apps\/([^/?#]+)/.exec(parsed.pathname)?.[1]
211
+ if (!slug) return null
212
+ const name = decodeURIComponent(slug)
213
+ return findAppProjects(project.root).some((a) => a.slug === name) ? name : null
214
+ }
215
+
216
+ /** Keep a screenshot the Forge Browser returned in the evidence folder of the tab's project, named after its App, and say where. */
217
+ function keepScreenshots(result: ToolResult & { meta?: { url?: string; root?: string } }, project: LocalProject, session: string | null): ToolResult {
218
+ const { meta, ...answer } = result
219
+ const owner = evidenceProject(project, meta?.root)
220
+ const url = typeof meta?.url === 'string' ? meta.url : ''
221
+ const app = url ? appForUrl(owner, url) : null
222
+ let host = ''
223
+ try {
224
+ host = url ? new URL(url).hostname : ''
225
+ } catch {
226
+ // Not an address.
227
+ }
228
+ const label = app ? `${app}-screenshot` : host ? `${host}-screenshot` : 'screenshot'
229
+ const saved: string[] = []
230
+ for (const c of answer.content) if (c.type === 'image') saved.push(saveScreenshot(owner.root, session, c.data, label))
231
+ if (!saved.length) return answer
232
+ const where = owner.root === project.root ? saved.join(', ') : saved.map((p) => `${p} in ${owner.root}`).join(', ')
233
+ return { ...answer, content: [...answer.content, { type: 'text', text: `Saved ${where}${app ? ` (App ${app})` : ''}` }] }
234
+ }
235
+
236
+ /**
237
+ * The agent's address, signed in when it is this project's Studio (an App
238
+ * preview at `/apps/<slug>?version=…` sits behind Studio's sign-in): Studio
239
+ * swaps the `?token=` for its cookie and redirects to the same address
240
+ * without it. Forge is told the token too, so the tab blanks it out of its
241
+ * requests, console and address; the answer is redacted here as well.
242
+ */
243
+ export function signBrowserInput(project: LocalProject, link: RuntimeLink | string, tool: string, input: Record<string, unknown>): { input: Record<string, unknown>; secret: string | null } {
244
+ if (tool !== 'browser_open' && tool !== 'browser_navigate') return { input, secret: null }
245
+ const url = stringArg(input, 'url')
246
+ if (!url) return { input, secret: null }
247
+ const signed = signedStudioUrl(project, link, url)
248
+ return signed.secret ? { input: { ...input, url: signed.url, redact: signed.secret }, secret: signed.secret } : { input, secret: null }
249
+ }
250
+
251
+ /** The answer with the token blanked out of its words and the screenshot's address; pictures untouched. */
252
+ export function redactAnswer<T extends ToolResult & { meta?: { url?: string } }>(answer: T, secret: string | null): T {
253
+ if (!secret) return answer
254
+ return {
255
+ ...answer,
256
+ content: answer.content.map((c) => (c.type === 'text' ? redactSecret(c, secret) : c)),
257
+ ...(answer.meta ? { meta: redactSecret(answer.meta, secret) } : {}),
258
+ }
259
+ }
260
+
261
+ /** Say which browser answered, last, so the agent knows whether the person saw it. */
262
+ function tagged(answer: ToolResult, browser: 'forge' | 'headless'): ToolResult {
263
+ return { ...answer, content: [...answer.content, { type: 'text', text: `browser: ${browser}` }] }
264
+ }
265
+
266
+ export function browserTools(project: LocalProject, deps: { runtime?: () => RuntimeLink | string; headless?: () => HeadlessBrowserSession } = {}): McpTool[] {
267
+ const runtime = deps.runtime ?? (() => runtimeLink(project))
268
+ const headless = deps.headless ?? headlessSession
269
+ return SPECS.map((spec) => ({
270
+ name: spec.name,
271
+ description: spec.description,
272
+ inputSchema: { type: 'object', properties: spec.properties, ...(spec.required ? { required: spec.required } : {}), additionalProperties: false },
273
+ annotations: { title: spec.title, readOnlyHint: spec.readOnly, openWorldHint: true },
274
+ async run(input): Promise<ToolResult> {
275
+ const session = forgeSession()
276
+ const signed = signBrowserInput(project, runtime(), spec.name, input)
277
+ // Forge first: the person watches there. Not reachable, or no window to show a page in: headless.
278
+ const link = readForgeLink(project.stateDir)
279
+ const raw = link ? await callForgeBrowser(link, spec.name, signed.input, { session }) : null
280
+ if (raw) {
281
+ const answer = redactAnswer(raw, signed.secret)
282
+ return tagged(spec.name === 'browser_screenshot' ? keepScreenshots(answer, project, session) : answer, 'forge')
283
+ }
284
+ const url = stringArg(input, 'url')
285
+ if (spec.name === 'browser_screenshot' && url) {
286
+ const once = signedStudioUrl(project, runtime(), url)
287
+ const observation = redactSecret(await observeHeadless({ url: once.url, fullPage: input.fullPage === true }), once.secret)
288
+ const app = appForUrl(project, url)
289
+ let label = 'page'
290
+ try {
291
+ label = app ? `${app}-screenshot` : new URL(url).hostname
292
+ } catch {
293
+ // Not an address; the page's own name stands.
294
+ }
295
+ const path = saveScreenshot(project.root, session, observation.screenshot, label)
296
+ return tagged(
297
+ {
298
+ content: [
299
+ { type: 'text', text: JSON.stringify({ url: observation.url, title: observation.title, browser: 'headless', saved: path, ...(app ? { app } : {}), consoleErrors: observation.console.filter((c) => c.level === 'error').length }, null, 2) },
300
+ { type: 'image', data: observation.screenshot, mimeType: 'image/png' },
301
+ ],
302
+ },
303
+ 'headless',
304
+ )
305
+ }
306
+ const answer = redactAnswer(await headless().run(spec.name, signed.input, project.root), signed.secret)
307
+ return tagged(spec.name === 'browser_screenshot' ? keepScreenshots(answer, project, session) : answer, 'headless')
308
+ },
309
+ }))
310
+ }
@@ -0,0 +1,32 @@
1
+ import { answerDocsMessage, docsCopyPath, type SchemaLookup } from '../../local/local-docs'
2
+ import type { LocalProject } from '../../local/local-project'
3
+ import type { McpTool, ToolResult } from '../mcp-protocol'
4
+
5
+ /**
6
+ * The documentation tools, unchanged from the `frontera-docs` server they
7
+ * absorb: same names and inputs, answered from the copy `local start` keeps,
8
+ * so they work with the runtime stopped.
9
+ */
10
+
11
+ const DOCS_TOOLS = [
12
+ { name: 'search_documentation', description: "Search Frontera's documentation for the release the local runtime runs. Returns guides with the best matching heading and a snippet; read one with read_documentation." },
13
+ { name: 'read_documentation', description: 'Read a guide by its slug from a search result, whole or one heading of it.' },
14
+ { name: 'list_documentation', description: 'List every guide: slug, title and description. Use it to browse an area, e.g. everything under "workflows/".' },
15
+ { name: 'read_file_schema', description: 'The exact format of a kind of project file (agent.json, operating.json, a workflow, the Blueprint), as JSON Schema, for the runtime\'s release. Read it before writing such a file.' },
16
+ ] as const
17
+
18
+ export function docsTools(project: LocalProject, schemaFor: SchemaLookup): McpTool[] {
19
+ const docsPath = docsCopyPath(project)
20
+ // The schemas come from the docs server itself, so the two never drift.
21
+ const listed = answerDocsMessage({ jsonrpc: '2.0', id: 0, method: 'tools/list' }, docsPath, schemaFor) as { result: { tools: Array<{ name: string; inputSchema: Record<string, unknown> }> } }
22
+ return DOCS_TOOLS.map((tool) => ({
23
+ name: tool.name,
24
+ description: tool.description,
25
+ inputSchema: listed.result.tools.find((t) => t.name === tool.name)?.inputSchema ?? { type: 'object' },
26
+ annotations: { readOnlyHint: true, openWorldHint: false },
27
+ async run(input) {
28
+ const answer = answerDocsMessage({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: tool.name, arguments: input } }, docsPath, schemaFor) as { result: ToolResult }
29
+ return answer.result
30
+ },
31
+ }))
32
+ }