@frontera-sdk/cli 1.52.27 → 1.52.29

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 (59) hide show
  1. package/package.json +5 -4
  2. package/src/commands/local/data.ts +60 -0
  3. package/src/commands/local/dev.ts +361 -18
  4. package/src/commands/local/index-commands.ts +11 -3
  5. package/src/commands/local/init.ts +88 -7
  6. package/src/commands/local/sync-request.ts +750 -0
  7. package/src/commands/local/sync.ts +941 -0
  8. package/src/commands/skill/bundle-commands.ts +9 -6
  9. package/src/errors.ts +4 -1
  10. package/src/exit.ts +3 -0
  11. package/src/flag-help.ts +48 -0
  12. package/src/local/local-actions.ts +306 -0
  13. package/src/local/local-adapt.ts +216 -0
  14. package/src/local/local-agent-files.ts +9 -2
  15. package/src/local/local-agent-grants.ts +125 -0
  16. package/src/local/local-agent-name.ts +71 -0
  17. package/src/local/local-applied.ts +334 -3
  18. package/src/local/local-apps.ts +156 -0
  19. package/src/local/local-blueprint-outline.ts +465 -0
  20. package/src/local/local-brand.ts +123 -0
  21. package/src/local/local-build-summary.ts +106 -0
  22. package/src/local/local-chat-apps.ts +170 -0
  23. package/src/local/local-dev-references.ts +1132 -0
  24. package/src/local/local-dev-sync.ts +171 -7
  25. package/src/local/local-docs.ts +53 -5
  26. package/src/local/local-env.ts +2 -0
  27. package/src/local/local-function-draft.ts +33 -0
  28. package/src/local/local-function-shared.ts +65 -0
  29. package/src/local/local-function-state.ts +34 -0
  30. package/src/local/local-knowledge-documents.ts +302 -0
  31. package/src/local/local-lessons.ts +278 -0
  32. package/src/local/local-library.ts +124 -0
  33. package/src/local/local-media-sets.ts +31 -0
  34. package/src/local/local-object-sets.ts +181 -0
  35. package/src/local/local-objects.ts +159 -0
  36. package/src/local/local-operating.ts +9 -5
  37. package/src/local/local-org-settings.ts +118 -0
  38. package/src/local/local-portability.ts +346 -0
  39. package/src/local/local-project.ts +6 -2
  40. package/src/local/local-quality-checks.ts +171 -0
  41. package/src/local/local-scaffold.ts +14 -11
  42. package/src/local/local-schemas.ts +33 -2
  43. package/src/local/local-secrets.ts +306 -0
  44. package/src/local/local-stand-ins.ts +442 -0
  45. package/src/local/local-studio-server.ts +23 -2
  46. package/src/local/local-sync-base.ts +237 -0
  47. package/src/local/local-sync-client.ts +93 -0
  48. package/src/local/local-sync-link.ts +122 -0
  49. package/src/local/local-sync-merge.ts +395 -0
  50. package/src/local/local-sync-names.ts +169 -0
  51. package/src/local/local-sync-plan.ts +717 -0
  52. package/src/local/local-sync-push.ts +986 -0
  53. package/src/local/local-sync-refs.ts +221 -0
  54. package/src/local/local-sync-remote.ts +1482 -0
  55. package/src/local/local-sync-snapshot.ts +526 -0
  56. package/src/local/local-synthetic-data.ts +374 -0
  57. package/src/local/local-test-runner.ts +172 -14
  58. package/src/local/local-themes.ts +201 -0
  59. package/src/local/local-workflow-state.ts +131 -0
@@ -0,0 +1,442 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ import { parse as parseYaml } from 'yaml'
5
+
6
+ import { answerDocsMessage } from './local-docs'
7
+ import { HOST_FROM_CONTAINER } from './local-env'
8
+ import type { LocalProject } from './local-project'
9
+ import { readState } from './local-state'
10
+ import type { SyncClient } from './local-sync-client'
11
+ import type { RemoteItem } from './local-sync-plan'
12
+ import { rows } from './local-sync-remote'
13
+
14
+ /**
15
+ * Stand-ins for built-in connections (design §8, class Stubbed).
16
+ *
17
+ * A built-in connection (Google Sheets, BigQuery, GitHub…) is signed in on
18
+ * the platform with OAuth or a service account, so the local runtime cannot
19
+ * reach it. An Agent, a procedure, a workflow step or trigger that names one
20
+ * would otherwise be refused locally, or applied without it. Instead the
21
+ * pull declares a stand-in, `mcp/<name>.stand-in.json` (the connection's name
22
+ * and tools, as the platform lists them), and `local dev` installs it as a
23
+ * Plugin of that name, served by the runtime's own Studio server: it lists
24
+ * the connection's tools, and each answers "not connected" in words. So the
25
+ * Agent, the procedure and the workflow apply as written, and what they do
26
+ * with the connection fails visibly instead of silently.
27
+ *
28
+ * A stand-in is the project's declaration, never a Plugin of the workspace:
29
+ * a change request never proposes it (it is not a `.plugin.json`), and a
30
+ * real `mcp/<name>.plugin.json` takes its place.
31
+ *
32
+ * Two of the workspace's built-in Plugins are not connections to someone
33
+ * else's system, and are declared apart:
34
+ * - **Documentation** (Frontera's documentation for Agents): its stand-in
35
+ * `serves: "documentation"`, answering the plugin's tools from the
36
+ * documentation `local start` copied for the runtime's release (the same
37
+ * copy `frontera local docs-mcp` serves the project's coding agents). The
38
+ * Agent's documentation tools work here.
39
+ * - **Analytics**: the workspace's analytics tools read its analytics
40
+ * project, which a local runtime does not have (design §8). The pull
41
+ * declares it platform-only, `mcp/<name>.platform.json`. An Agent that
42
+ * uses it runs here without those tools, said in the build as "Analytics
43
+ * tools stay on the platform". So that a workflow step on it still
44
+ * applies, `local dev` installs a Plugin of its name served by the Studio
45
+ * server, as for a connection's stand-in, whose every tool answers in
46
+ * words that the analytics tools stay on the platform.
47
+ */
48
+
49
+ export const standInPath = (name: string) => `mcp/${name}.stand-in.json`
50
+
51
+ export interface StandInFile {
52
+ standIn: true
53
+ /** The connection as people know it: "Google Sheets". */
54
+ connection: string
55
+ /** `documentation`: it serves the runtime's documentation instead of answering "not connected". */
56
+ serves?: 'documentation'
57
+ note: string
58
+ tools: Array<{ name: string; description?: string }>
59
+ }
60
+
61
+ const NOTE = 'A stand-in for a connection signed in on the platform. On the local runtime its tools answer “not connected”. Frontera writes this file from the workspace; a change request never proposes it.'
62
+ const DOCS_NOTE = 'Frontera’s documentation for Agents. On the local runtime it serves the documentation of the runtime’s release. Frontera writes this file from the workspace; a change request never proposes it.'
63
+
64
+ /** The Documentation plugin's tools, as the platform's documentation server lists them. */
65
+ const DOCS_TOOLS: StandInFile['tools'] = [
66
+ { name: 'get_documentation_context', description: 'The documentation release this runtime serves' },
67
+ { name: 'read_documentation', description: 'Read a guide' },
68
+ { name: 'search_documentation', description: 'Search the documentation' },
69
+ ]
70
+
71
+ /** The built-in Plugins these items need that get a stand-in (connections, the Documentation plugin), by install name. */
72
+ export function connectionsNeeded(items: Array<Pick<RemoteItem, 'portability'>>): string[] {
73
+ return [...new Set(items.flatMap((i) => (i.portability?.needs ?? []).flatMap((n) => (n.kind === 'connection' || n.kind === 'documentation' ? [n.name] : []))))].sort()
74
+ }
75
+
76
+ /** Which of those are the Documentation plugin. */
77
+ export function documentationNeeded(items: Array<Pick<RemoteItem, 'portability'>>): Set<string> {
78
+ return new Set(items.flatMap((i) => (i.portability?.needs ?? []).flatMap((n) => (n.kind === 'documentation' ? [n.name] : []))))
79
+ }
80
+
81
+ /** The workspace's analytics Plugins these items use: platform-only. */
82
+ export function analyticsNeeded(items: Array<Pick<RemoteItem, 'portability'>>): string[] {
83
+ return [...new Set(items.flatMap((i) => (i.portability?.needs ?? []).flatMap((n) => (n.kind === 'analytics' ? [n.name] : []))))].sort()
84
+ }
85
+
86
+ // ── Platform-only Plugins ────────────────────────────────────────────────────
87
+
88
+ export const platformOnlyPath = (name: string) => `mcp/${name}.platform.json`
89
+
90
+ /** What an Agent that uses the workspace's analytics is told, in the build and the Pull dialog. */
91
+ export const ANALYTICS_STAY = 'Analytics tools stay on the platform: a local runtime has no analytics'
92
+
93
+ /** What each of the analytics Plugin's tools answers on the local runtime. */
94
+ export function analyticsStay(tool: string): string {
95
+ return `Analytics tools stay on the platform: a local runtime has no analytics, so ${tool} didn’t run. Run this in the workspace.`
96
+ }
97
+
98
+ /** Whether the project declares this Plugin platform-only (`mcp/<name>.platform.json`). */
99
+ export function isPlatformOnly(root: string, name: string): boolean {
100
+ return existsSync(join(root, platformOnlyPath(name)))
101
+ }
102
+
103
+ export interface PlatformOnlyFile {
104
+ platformOnly: 'analytics'
105
+ note: string
106
+ }
107
+
108
+ const ANALYTICS_NOTE = 'The workspace’s analytics tools. They stay on the platform: they read the workspace’s analytics, which a local runtime does not have. Frontera writes this file from the workspace; nothing is installed locally, and a change request never proposes it.'
109
+
110
+ /** The Plugins the project declares platform-only, by install name. */
111
+ export function platformOnlyNames(root: string): Set<string> {
112
+ try {
113
+ return new Set(readdirSync(join(root, 'mcp')).flatMap((f) => (f.endsWith('.platform.json') && f !== '.platform.json' ? [f.slice(0, -'.platform.json'.length)] : [])))
114
+ } catch {
115
+ return new Set()
116
+ }
117
+ }
118
+
119
+ /** Declare each analytics Plugin the project's items use as platform-only. Returns the paths written. */
120
+ export function writePlatformOnly(root: string, names: string[]): string[] {
121
+ const out: string[] = []
122
+ const doc: PlatformOnlyFile = { platformOnly: 'analytics', note: ANALYTICS_NOTE }
123
+ const text = `${JSON.stringify(doc, null, 2)}\n`
124
+ for (const name of names) {
125
+ const path = join(root, platformOnlyPath(name))
126
+ if (existsSync(path) && readFileSync(path, 'utf8') === text) continue
127
+ mkdirSync(dirname(path), { recursive: true })
128
+ writeFileSync(path, text)
129
+ out.push(platformOnlyPath(name))
130
+ }
131
+ return out
132
+ }
133
+
134
+ /**
135
+ * An Agent's rows that name a platform-only Plugin (its install, its MCP
136
+ * server, its tool settings) are left off its local copy, said once.
137
+ * Changes `doc`; returns the warning.
138
+ */
139
+ export function dropPlatformOnly(doc: Record<string, unknown>, names: ReadonlySet<string>): string[] {
140
+ if (!names.size) return []
141
+ const named = (v: unknown) => typeof v === 'string' && v.startsWith('plugin/') && names.has(v.slice('plugin/'.length))
142
+ let dropped = false
143
+ const keep = (key: string, test: (row: Record<string, unknown>) => boolean) => {
144
+ if (!Array.isArray(doc[key])) return
145
+ const rows = doc[key] as Array<Record<string, unknown>>
146
+ const kept = rows.filter((r) => !test(r ?? {}))
147
+ if (kept.length !== rows.length) dropped = true
148
+ doc[key] = kept
149
+ }
150
+ keep('appInstalls', (r) => named(r.installId))
151
+ keep('mcpServers', (r) => typeof r.name === 'string' && names.has(r.name))
152
+ keep('pluginTools', (r) => named(r.plugin))
153
+ return dropped ? [` warning: ${ANALYTICS_STAY}`] : []
154
+ }
155
+
156
+ export function readStandIn(root: string, name: string): StandInFile | null {
157
+ try {
158
+ const doc = JSON.parse(readFileSync(join(root, standInPath(name)), 'utf8')) as StandInFile
159
+ return doc && doc.standIn === true ? doc : null
160
+ } catch {
161
+ return null
162
+ }
163
+ }
164
+
165
+ /** The stand-ins the project declares, by connection name. */
166
+ export function standInNames(root: string): Set<string> {
167
+ try {
168
+ return new Set(readdirSync(join(root, 'mcp')).flatMap((f) => (f.endsWith('.stand-in.json') && f !== '.stand-in.json' ? [f.slice(0, -'.stand-in.json'.length)] : [])))
169
+ } catch {
170
+ return new Set()
171
+ }
172
+ }
173
+
174
+ interface AppRow {
175
+ install?: { id: string; installName: string }
176
+ catalog?: { kind?: string; name?: string; displayName?: string }
177
+ }
178
+
179
+ export interface StandInsWritten {
180
+ /** Declared now, or brought up to date with the workspace's tools. */
181
+ written: string[]
182
+ /** A real Plugin file of that name is in the project: it is used instead. */
183
+ replaced: string[]
184
+ }
185
+
186
+ /**
187
+ * Declare a stand-in for each connection the project's items need: its name
188
+ * and tools from the workspace, when the platform can be read; otherwise its
189
+ * name alone (the tools the project's files name still answer). An existing
190
+ * stand-in is rewritten only to follow the workspace.
191
+ */
192
+ export async function writeStandIns(root: string, names: string[], ws?: SyncClient, documentation: ReadonlySet<string> = new Set()): Promise<StandInsWritten> {
193
+ const out: StandInsWritten = { written: [], replaced: [] }
194
+ if (!names.length) return out
195
+ const apps = ws ? await ws.get<unknown>('/v1/apps').then((b) => rows<AppRow>(b)).catch(() => [] as AppRow[]) : []
196
+ for (const name of names) {
197
+ if (existsSync(join(root, 'mcp', `${name}.plugin.json`))) {
198
+ out.replaced.push(name)
199
+ continue
200
+ }
201
+ const row = apps.find((a) => a.install?.installName === name)
202
+ let tools: StandInFile['tools'] | null = null
203
+ if (ws && row?.install) {
204
+ tools = await ws
205
+ .get<unknown>(`/v1/apps/${encodeURIComponent(row.install.id)}/capabilities`)
206
+ .then((b) => rows<{ name?: string; displayName?: string; description?: string | null }>(b).flatMap((t) => (t.name ? [{ name: t.name, ...(t.description || t.displayName ? { description: String(t.description || t.displayName) } : {}) }] : [])))
207
+ .catch(() => null)
208
+ }
209
+ const before = readStandIn(root, name)
210
+ const docs = documentation.has(name) || before?.serves === 'documentation'
211
+ const doc: StandInFile = {
212
+ standIn: true,
213
+ connection: row?.catalog?.displayName ?? before?.connection ?? name,
214
+ ...(docs ? { serves: 'documentation' as const } : {}),
215
+ note: docs ? DOCS_NOTE : NOTE,
216
+ tools: (tools?.length ? tools : before?.tools?.length ? before.tools : docs ? DOCS_TOOLS : []).sort((a, b) => a.name.localeCompare(b.name)),
217
+ }
218
+ const text = `${JSON.stringify(doc, null, 2)}\n`
219
+ const path = join(root, standInPath(name))
220
+ if (existsSync(path) && readFileSync(path, 'utf8') === text) continue
221
+ mkdirSync(dirname(path), { recursive: true })
222
+ writeFileSync(path, text)
223
+ out.written.push(name)
224
+ }
225
+ return out
226
+ }
227
+
228
+ /**
229
+ * The tools a stand-in lists: the connection's, as the workspace listed
230
+ * them, and every tool the project's Agents and workflows name on it (a
231
+ * workflow step's `capability`, an Agent's tool setting), so each resolves.
232
+ */
233
+ export function standInTools(root: string, name: string): StandInFile['tools'] {
234
+ const declared = readStandIn(root, name)?.tools ?? []
235
+ const tools = new Map(declared.map((t) => [t.name, t]))
236
+ const ref = `plugin/${name}`
237
+ const visit = (v: unknown) => {
238
+ if (Array.isArray(v)) return v.forEach(visit)
239
+ if (!v || typeof v !== 'object') return
240
+ const o = v as Record<string, unknown>
241
+ const names = o.resourceId === ref || o.plugin === ref || o.installId === ref
242
+ if (names) {
243
+ const params = (o.params ?? {}) as Record<string, unknown>
244
+ for (const tool of [o.capability, o.tool, params.capability]) if (typeof tool === 'string' && tool && !tools.has(tool)) tools.set(tool, { name: tool })
245
+ }
246
+ for (const x of Object.values(o)) visit(x)
247
+ }
248
+ for (const top of ['agents', 'workflows']) {
249
+ const walk = (dir: string, depth: number) => {
250
+ if (depth > 3 || !existsSync(dir)) return
251
+ for (const f of readdirSync(dir, { withFileTypes: true })) {
252
+ if (f.name.startsWith('.')) continue
253
+ const path = join(dir, f.name)
254
+ if (f.isDirectory()) walk(path, depth + 1)
255
+ else if (/\.(json|ya?ml)$/.test(f.name)) {
256
+ try {
257
+ const text = readFileSync(path, 'utf8')
258
+ if (text.includes(ref)) visit(f.name.endsWith('.json') ? JSON.parse(text) : parseYaml(text))
259
+ } catch {
260
+ // A file that does not parse names nothing here.
261
+ }
262
+ }
263
+ }
264
+ }
265
+ walk(join(root, top), 0)
266
+ }
267
+ return [...tools.values()].sort((a, b) => a.name.localeCompare(b.name))
268
+ }
269
+
270
+ /** Where the runtime reaches a stand-in: the Studio server, from wherever the service runs. */
271
+ export function standInUrl(project: LocalProject, name: string): string | null {
272
+ const state = readState(project)
273
+ if (!state?.ports?.studio) return null
274
+ const host = state.mode === 'image' ? HOST_FROM_CONTAINER : '127.0.0.1'
275
+ return `http://${host}:${state.ports.studio}/__stand-in/${encodeURIComponent(name)}/mcp`
276
+ }
277
+
278
+ /** What every one of a stand-in's tools answers. */
279
+ export function notConnected(connection: string, tool: string): string {
280
+ return `${connection} isn’t connected on this local runtime, so ${tool} didn’t run. ${connection} is signed in on the platform only: try this in the workspace, or use sample data here.`
281
+ }
282
+
283
+ /**
284
+ * A stand-in's MCP endpoint (streamable HTTP, JSON answers): it lists the
285
+ * connection's tools and answers every call with a "not connected" error. It holds
286
+ * nothing and reaches nothing, so it needs no session.
287
+ */
288
+ export async function standInMcp(root: string, name: string, req: Request, docsPath?: string): Promise<Response> {
289
+ if (req.method !== 'POST') return new Response(null, { status: 405, headers: { allow: 'POST' } })
290
+ // The analytics Plugin, platform-only, is served the same way: each of its tools says it stays on the platform.
291
+ const platformOnly = isPlatformOnly(root, name) && !readStandIn(root, name)
292
+ const doc: StandInFile | null = platformOnly ? { standIn: true, connection: 'Analytics', note: '', tools: [] } : readStandIn(root, name)
293
+ if (!doc) return new Response('No such stand-in', { status: 404 })
294
+ let body: unknown
295
+ try {
296
+ body = await req.json()
297
+ } catch {
298
+ return Response.json({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }, { status: 400 })
299
+ }
300
+ const answer = (msg: { id?: unknown; method?: string; params?: Record<string, unknown> }) => {
301
+ if (msg.id === undefined || msg.id === null) return null
302
+ // The Documentation plugin: the runtime's documentation, with the tools the platform's has.
303
+ if (doc.serves === 'documentation') return answerDocsMessage(msg as Parameters<typeof answerDocsMessage>[0], docsPath ?? '', undefined, 'agent')
304
+ switch (msg.method) {
305
+ case 'initialize':
306
+ return { jsonrpc: '2.0', id: msg.id, result: { protocolVersion: typeof msg.params?.protocolVersion === 'string' ? msg.params.protocolVersion : '2025-03-26', capabilities: { tools: {} }, serverInfo: { name: `${name} (stand-in)`, version: '1' }, instructions: platformOnly ? ANALYTICS_STAY : notConnected(doc.connection, 'its tools') } }
307
+ case 'ping':
308
+ return { jsonrpc: '2.0', id: msg.id, result: {} }
309
+ case 'tools/list':
310
+ return {
311
+ jsonrpc: '2.0',
312
+ id: msg.id,
313
+ result: { tools: standInTools(root, name).map((t) => ({ name: t.name, description: `${t.description ? `${t.description} ` : ''}(${platformOnly ? 'Analytics tools stay on the platform: a local runtime has no analytics.' : `Not connected on the local runtime: ${doc.connection} runs on the platform only.`})`, inputSchema: { type: 'object', additionalProperties: true } })) },
314
+ }
315
+ case 'tools/call': {
316
+ // An error, not a result marked isError: a workflow step takes a result as its output and
317
+ // goes on; an error stops the step with the words, and an Agent's tool call reports them.
318
+ const tool = typeof msg.params?.name === 'string' ? msg.params.name : 'this tool'
319
+ return { jsonrpc: '2.0', id: msg.id, error: { code: -32000, message: platformOnly ? analyticsStay(tool) : notConnected(doc.connection, tool) } }
320
+ }
321
+ default:
322
+ return { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `Method not found: ${msg.method}` } }
323
+ }
324
+ }
325
+ const batch = Array.isArray(body)
326
+ const replies = (batch ? (body as unknown[]) : [body]).map((m) => answer((m ?? {}) as Parameters<typeof answer>[0])).filter(Boolean)
327
+ if (!replies.length) return new Response(null, { status: 202 })
328
+ return Response.json(batch ? replies : replies[0])
329
+ }
330
+
331
+ function parseJson<T>(output: string): T | null {
332
+ const start = output.search(/[[{]/)
333
+ if (start < 0) return null
334
+ try {
335
+ return JSON.parse(output.slice(start)) as T
336
+ } catch {
337
+ return null
338
+ }
339
+ }
340
+
341
+ /** Why an item using a stand-in runs as it does, as its build says it. */
342
+ export function standInAdaptation(connection: string): string {
343
+ return `${connection} isn’t connected here: a stand-in answers its tools with “not connected”`
344
+ }
345
+
346
+ /**
347
+ * Install a stand-in on the runtime as a Plugin of the connection's name,
348
+ * pointed at its endpoint, every tool on: an Agent, procedure or workflow
349
+ * that names the connection then resolves, and each call says it is not
350
+ * connected.
351
+ */
352
+ export async function applyStandIn(
353
+ project: LocalProject,
354
+ name: string,
355
+ workspaceProfile: string,
356
+ run: (args: string[]) => Promise<{ ok: boolean; output: string }>,
357
+ ): Promise<{ ok: boolean; output: string }> {
358
+ const platformOnly = isPlatformOnly(project.root, name) && !readStandIn(project.root, name)
359
+ const doc: StandInFile | null = platformOnly ? { standIn: true, connection: 'Analytics', note: '', tools: [] } : readStandIn(project.root, name)
360
+ if (!doc) return { ok: false, output: `error [USAGE]: ${standInPath(name)} is not a stand-in` }
361
+ const made = standInPluginFile(project, name)
362
+ if (!made) return { ok: false, output: 'error [LOCAL_NOT_RUNNING]: the runtime has no Studio port for the stand-in' }
363
+ const p = ['--profile', workspaceProfile]
364
+ const find = async () => {
365
+ const listed = await run(['plugin', 'list', '--json', ...p])
366
+ return (parseJson<Array<{ install?: { id: string; installName: string } }>>(listed.output) ?? []).find((r) => r.install?.installName === name)?.install
367
+ }
368
+ let id = (await find())?.id
369
+ const step = id ? await run(['plugin', 'configure', id, '--file', made.file, ...p]) : await run(['plugin', 'add', 'mcp', '--name', name, '--file', made.file, ...p])
370
+ if (!step.ok) return step
371
+ id ??= (await find())?.id
372
+ if (!id) return { ok: false, output: `error [NOT_FOUND]: the stand-in ${name} was added but is not listed` }
373
+ const open = await run(['plugin', 'policy', id, '--enable', '--actions', 'allow_all', '--new-capability', 'enabled', ...p])
374
+ const lines = platformOnly
375
+ ? [` analytics Plugin ${name}: ${standInTools(project.root, name).length} tools, each answering that analytics tools stay on the platform`, ` warning: ${ANALYTICS_STAY}`]
376
+ : doc.serves === 'documentation'
377
+ ? [` ${doc.connection}: ${standInTools(project.root, name).length} tools, serving the documentation of this runtime’s release`]
378
+ : [` stand-in for ${doc.connection}: ${standInTools(project.root, name).length} tools, each answering “not connected”`, ` warning: ${standInAdaptation(doc.connection)}`]
379
+ if (!open.ok) lines.push(` warning: The stand-in ${name}’s tools could not be switched on: ${open.output.trim().split('\n')[0]}`)
380
+ return { ok: true, output: lines.join('\n') }
381
+ }
382
+
383
+ /**
384
+ * A workflow's Plugin triggers on a connection the project has a stand-in
385
+ * for: a stand-in answers tools, not events, so the trigger is left out (the
386
+ * workflow still runs by hand, from an Agent, or from its other triggers).
387
+ * Changes `doc`; returns each as a warning.
388
+ */
389
+ export function dropStandInTriggers(root: string, doc: unknown, names: ReadonlySet<string>): string[] {
390
+ const d = (doc ?? {}) as Record<string, unknown>
391
+ if (!names.size || !Array.isArray(d.trigger)) return []
392
+ const out: string[] = []
393
+ d.trigger = (d.trigger as Array<Record<string, unknown>>).filter((t) => {
394
+ const name = t?.kind === 'plugin' && typeof t.resourceId === 'string' && t.resourceId.startsWith('plugin/') ? t.resourceId.slice('plugin/'.length) : null
395
+ if (!name || !names.has(name)) return true
396
+ const event = typeof t.event === 'string' ? t.event : ''
397
+ if (isPlatformOnly(root, name) && !readStandIn(root, name)) {
398
+ out.push(` warning: Its Plugin trigger ${event} is left out here: ${ANALYTICS_STAY}`.replace('trigger is', 'trigger is'))
399
+ return false
400
+ }
401
+ const connection = readStandIn(root, name)?.connection ?? name
402
+ out.push(` warning: Its Plugin trigger ${event} is left out here: ${connection} isn’t connected here, and its stand-in answers tools, not events`.replace('trigger is', 'trigger is'))
403
+ return false
404
+ })
405
+ return out
406
+ }
407
+
408
+ /**
409
+ * A local Agent is a draft, and a draft runs the MCP servers its own list
410
+ * names (`mcpServers`), not its Plugin installs: each connection it uses
411
+ * that has a stand-in gets the stand-in's server in that list. Changes `doc`.
412
+ */
413
+ export function addStandInServers(project: LocalProject, doc: Record<string, unknown>, names: ReadonlySet<string>): void {
414
+ if (!names.size || !Array.isArray(doc.appInstalls)) return
415
+ const servers = Array.isArray(doc.mcpServers) ? (doc.mcpServers as Array<Record<string, unknown>>) : []
416
+ for (const row of doc.appInstalls as Array<Record<string, unknown>>) {
417
+ const name = typeof row?.installId === 'string' && row.installId.startsWith('plugin/') ? row.installId.slice('plugin/'.length) : null
418
+ if (!name || !names.has(name)) continue
419
+ const url = standInUrl(project, name)
420
+ if (!url) continue
421
+ // The workspace's own server for it (the Documentation plugin's, at the platform's address) points here instead.
422
+ const listed = servers.find((s) => s?.name === name)
423
+ if (listed) {
424
+ listed.transportType = 'http'
425
+ listed.url = url
426
+ delete listed.headers
427
+ continue
428
+ }
429
+ servers.push({ name, transportType: 'http', url, enabled: row.enabled !== false, autoStage: false, toonCompression: false })
430
+ }
431
+ if (servers.length) doc.mcpServers = servers
432
+ }
433
+
434
+ /** The Plugin file a stand-in is installed from: the runtime's own address for it, nothing secret. */
435
+ export function standInPluginFile(project: LocalProject, name: string): { file: string; url: string } | null {
436
+ const url = standInUrl(project, name)
437
+ if (!url) return null
438
+ const file = join('.frontera', 'local', 'resolved', 'mcp', `${name}.plugin.json`)
439
+ mkdirSync(join(project.root, dirname(file)), { recursive: true })
440
+ writeFileSync(join(project.root, file), `${JSON.stringify({ transport: 'http', url }, null, 2)}\n`)
441
+ return { file, url }
442
+ }
@@ -5,10 +5,14 @@ import { extname, join, normalize } from 'node:path'
5
5
  import type { LocalProject } from './local-project'
6
6
  import { AgentFileError, saveAgentEdit, type AgentFileEdit } from './local-agent-files'
7
7
  import { solutionStatus } from './local-applied'
8
+ import { projectBlueprintOutline } from './local-blueprint-outline'
9
+ import { itemBuild } from './local-build-summary'
8
10
  import { logPath, readApplied, readState, type ComponentName } from './local-state'
9
11
  import { appendTestCase, renderTestCase } from './local-test-cases'
10
12
  import { findCaseFiles } from './local-test-runner'
11
13
  import { createTestRuns } from './local-test-runs'
14
+ import { docsCopyPath } from './local-docs'
15
+ import { standInMcp } from './local-stand-ins'
12
16
 
13
17
  /**
14
18
  * Frontera Studio's server: the static app, the API, and the runtime's own
@@ -18,7 +22,9 @@ import { createTestRuns } from './local-test-runs'
18
22
  * Three rules shape it:
19
23
  * - It binds to 127.0.0.1 only, and every request needs the session cookie
20
24
  * that the printed `?token=` link sets. "No auth configured means public" is
21
- * the pattern we are deliberately not copying.
25
+ * the pattern we are deliberately not copying. The one exception is a
26
+ * built-in connection's stand-in (`/__stand-in/<name>/mcp`), which the
27
+ * service calls: it lists tool names and answers "not connected", nothing else.
22
28
  * - The browser never sees a credential. `/api/*` is proxied to the local
23
29
  * service with the seeded owner's session added here, so the API explorer
24
30
  * and every Studio view call the real API exactly as the platform does.
@@ -113,6 +119,16 @@ export function startStudioServer(opts: StudioServerOptions) {
113
119
  if (url.pathname === '/__local/solution') {
114
120
  return json({ items: solutionStatus(project, readState(project)?.credentials?.organizationId ?? null) })
115
121
  }
122
+ // How the last `local dev` built one item (`?ref=agent/desk`), with each adaptation in words: the Agent page says it.
123
+ if (url.pathname === '/__local/build') {
124
+ const ref = url.searchParams.get('ref') ?? ''
125
+ if (!/^[a-z-]+\/[A-Za-z0-9][\w./-]{0,200}$/.test(ref)) return json({ error: 'name an item, as agent/<slug>' }, 400)
126
+ return json({ build: itemBuild(project.root, ref, project.id) })
127
+ }
128
+ // The Blueprint's definitions as the files state them, so they show before anything is applied.
129
+ if (url.pathname === '/__local/blueprint') {
130
+ return json({ objectTypes: projectBlueprintOutline(project.root) })
131
+ }
116
132
  // The solution's saved cases (`agents/<slug>/evals/*.yaml`), read fresh each time.
117
133
  if (url.pathname === '/__local/test-cases') {
118
134
  try {
@@ -265,6 +281,11 @@ export function startStudioServer(opts: StudioServerOptions) {
265
281
  }
266
282
 
267
283
  if (url.pathname === '/__local/health') return json({ ok: true })
284
+ // A built-in connection's stand-in (`local-stand-ins.ts`), which the runtime's service calls as an MCP
285
+ // server: it holds and reaches nothing, and answers every tool with "not connected", so it takes no session.
286
+ // The Documentation plugin's serves the release's customer documentation, which is public to every reader.
287
+ const standIn = /^\/__stand-in\/([A-Za-z0-9._-]+)\/mcp$/.exec(url.pathname)
288
+ if (standIn) return standInMcp(project.root, standIn[1]!, req, docsCopyPath(project))
268
289
 
269
290
  const cookie = cookieToken(req)
270
291
  if (!cookie || !sameToken(cookie, opts.sessionToken)) {
@@ -284,7 +305,7 @@ export function startStudioServer(opts: StudioServerOptions) {
284
305
  const edit = (await req.json().catch(() => null)) as AgentFileEdit | null
285
306
  if (!edit || typeof edit !== 'object') return json({ error: 'the edit must be a JSON object' }, 400)
286
307
  try {
287
- return json(await saveAgentEdit(project, organizationId, decodeURIComponent(editMatch[1]!), edit))
308
+ return json(await saveAgentEdit(project, organizationId, decodeURIComponent(editMatch[1]!), edit, opts.serviceUrl))
288
309
  } catch (err) {
289
310
  if (err instanceof AgentFileError) return json({ error: err.message }, err.status)
290
311
  return json({ error: (err as Error).message }, 500)