@frontera-sdk/cli 1.52.23 → 1.52.25

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 (45) hide show
  1. package/package.json +4 -4
  2. package/src/api/dataset-api.ts +8 -4
  3. package/src/automation-template.ts +3 -0
  4. package/src/blueprint/tree.ts +27 -0
  5. package/src/commands/local/check.ts +64 -0
  6. package/src/commands/local/dev.ts +301 -0
  7. package/src/commands/local/docs-mcp.ts +36 -0
  8. package/src/commands/local/index-commands.ts +477 -0
  9. package/src/commands/local/init.ts +78 -0
  10. package/src/commands/local/key.ts +108 -0
  11. package/src/commands/local/providers.ts +245 -0
  12. package/src/commands/local/schema.ts +46 -0
  13. package/src/commands/registry.ts +6 -4
  14. package/src/commands/types.ts +7 -0
  15. package/src/flag-help.ts +18 -0
  16. package/src/harness.ts +1 -1
  17. package/src/local/local-agent-files.ts +122 -0
  18. package/src/local/local-applied.ts +135 -0
  19. package/src/local/local-apply-error.ts +35 -0
  20. package/src/local/local-control.ts +143 -0
  21. package/src/local/local-dev-sync.ts +256 -0
  22. package/src/local/local-docker.ts +146 -0
  23. package/src/local/local-docs-search.ts +126 -0
  24. package/src/local/local-docs.ts +241 -0
  25. package/src/local/local-embeddings.ts +79 -0
  26. package/src/local/local-env.ts +225 -0
  27. package/src/local/local-keys.ts +115 -0
  28. package/src/local/local-operating.ts +82 -0
  29. package/src/local/local-ports.ts +111 -0
  30. package/src/local/local-process.ts +106 -0
  31. package/src/local/local-project.ts +54 -0
  32. package/src/local/local-providers.ts +268 -0
  33. package/src/local/local-scaffold.ts +404 -0
  34. package/src/local/local-schemas.ts +301 -0
  35. package/src/local/local-seed.ts +332 -0
  36. package/src/local/local-state.ts +165 -0
  37. package/src/local/local-studio-assets.ts +68 -0
  38. package/src/local/local-studio-server.ts +304 -0
  39. package/src/local/local-supervisor.ts +561 -0
  40. package/src/local/local-test-cases.ts +58 -0
  41. package/src/local/local-test-runner.ts +191 -0
  42. package/src/local/local-test-runs.ts +75 -0
  43. package/src/local/local-traces.ts +322 -0
  44. package/src/main.ts +1 -1
  45. package/src/packaging.ts +5 -0
@@ -0,0 +1,268 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { writeFileAtomic } from '../fs-atomic'
5
+ import { configDir } from '../paths'
6
+ import { HOST_FROM_CONTAINER, loadOrCreateSecrets } from './local-env'
7
+ import { getKey } from './local-keys'
8
+ import { localProject } from './local-project'
9
+ import { call, signIn } from './local-seed'
10
+ import { isAlive, readState } from './local-state'
11
+
12
+ /**
13
+ * The model setup an FDE makes for their local runtimes, beyond the built-in
14
+ * providers' keys: OpenAI-compatible providers of their own (a gateway, Azure,
15
+ * a model served on this machine), the models Agents can pick, and which
16
+ * model new Agents and test grading use.
17
+ *
18
+ * It belongs to the person, not the project: it lives in the CLI's config
19
+ * directory, next to the keys, and every project's runtime receives it at
20
+ * start. Keys never go in this file; a custom provider's key sits in the
21
+ * keychain under `custom:<slug>`, like a built-in provider's.
22
+ */
23
+
24
+ export interface CustomProviderSpec {
25
+ /** The provider's runtime id is `custom:<slug>`. */
26
+ slug: string
27
+ label: string
28
+ baseUrl: string
29
+ }
30
+
31
+ export interface ChosenModel {
32
+ /** `anthropic`, `openrouter`, `custom:<slug>`… */
33
+ provider: string
34
+ /** The provider's own id for the model. */
35
+ modelId: string
36
+ name: string
37
+ /** The catalog slug `agent.json` names, as the runtime gave it back. */
38
+ slug?: string
39
+ }
40
+
41
+ export interface ProviderSetup {
42
+ version: 1
43
+ custom: CustomProviderSpec[]
44
+ models: ChosenModel[]
45
+ defaults: {
46
+ /** The model a new Agent starts with (`frontera local init`, and what AGENTS.md tells coding agents). */
47
+ agentModel?: string
48
+ /** The model that grades test cases and evaluations. */
49
+ judgeModel?: string
50
+ }
51
+ }
52
+
53
+ const EMPTY: ProviderSetup = { version: 1, custom: [], models: [], defaults: {} }
54
+
55
+ /** A custom provider's slug: lowercase letters, digits and hyphens, as the service requires. */
56
+ export const CUSTOM_SLUG = /^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/
57
+
58
+ export const customKeyId = (slug: string) => `custom:${slug}`
59
+
60
+ function setupPath(): string {
61
+ return process.env.FRONTERA_LOCAL_PROVIDERS_FILE ?? join(configDir(), 'local-providers.json')
62
+ }
63
+
64
+ export function readProviderSetup(): ProviderSetup {
65
+ const path = setupPath()
66
+ if (!existsSync(path)) return structuredClone(EMPTY)
67
+ try {
68
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<ProviderSetup>
69
+ return { version: 1, custom: parsed.custom ?? [], models: parsed.models ?? [], defaults: parsed.defaults ?? {} }
70
+ } catch {
71
+ return structuredClone(EMPTY)
72
+ }
73
+ }
74
+
75
+ export function writeProviderSetup(setup: ProviderSetup): void {
76
+ writeFileAtomic(setupPath(), `${JSON.stringify(setup, null, 2)}\n`, 0o600)
77
+ }
78
+
79
+ /** A catalog slug from a model's name: `GPT-6 Luna` → `gpt-6-luna`. */
80
+ export function modelSlug(name: string): string {
81
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'model'
82
+ }
83
+
84
+ /**
85
+ * A base URL as the runtime's service must dial it: a service in a container
86
+ * reaches this machine through the host's name, not 127.0.0.1.
87
+ */
88
+ export function serviceReachable(baseUrl: string, containerHost?: string): string {
89
+ if (!containerHost) return baseUrl
90
+ try {
91
+ const url = new URL(baseUrl)
92
+ if (url.hostname === '127.0.0.1' || url.hostname === 'localhost') url.hostname = containerHost
93
+ return url.toString().replace(/\/$/, '')
94
+ } catch {
95
+ return baseUrl
96
+ }
97
+ }
98
+
99
+ export interface RuntimeTarget {
100
+ serviceUrl: string
101
+ password: string
102
+ organizationId: string
103
+ workspaceId: string
104
+ /** Set when the service runs in a container (release images). */
105
+ containerHost?: string
106
+ }
107
+
108
+ /** The runtime of the project around `cwd`, when it is up; changes then apply at once instead of at the next start. */
109
+ export function runningTarget(cwd: string): RuntimeTarget | null {
110
+ const project = localProject(cwd)
111
+ const state = readState(project)
112
+ if (!state || state.phase !== 'ready' || !state.supervisorPid || !isAlive(state.supervisorPid) || !state.credentials) return null
113
+ return {
114
+ serviceUrl: `http://127.0.0.1:${state.ports.service}`,
115
+ password: loadOrCreateSecrets(project).ownerPassword,
116
+ organizationId: state.credentials.organizationId,
117
+ workspaceId: state.credentials.workspaceId,
118
+ containerHost: state.mode === 'image' ? HOST_FROM_CONTAINER : undefined,
119
+ }
120
+ }
121
+
122
+ export interface ApplyResult {
123
+ custom: string[]
124
+ /** `provider/modelId` → the catalog slug the runtime uses. */
125
+ models: Record<string, string>
126
+ errors: string[]
127
+ }
128
+
129
+ type Catalog = { data?: Array<{ slug: string; provider: string; modelId: string }> }
130
+
131
+ /**
132
+ * Bring a runtime in line with the setup: register each custom provider (with
133
+ * its key from the keychain), add each chosen model to the catalog, and make
134
+ * the chosen grading model the judge. Idempotent; it only adds and updates,
135
+ * and a refusal is reported, not thrown.
136
+ */
137
+ export async function applyProviderSetup(target: RuntimeTarget, setup = readProviderSetup()): Promise<ApplyResult> {
138
+ const result: ApplyResult = { custom: [], models: {}, errors: [] }
139
+ let token: string
140
+ try {
141
+ token = await signIn(target.serviceUrl, target.password)
142
+ } catch (err) {
143
+ result.errors.push(`could not sign in to the runtime: ${(err as Error).message}`)
144
+ return result
145
+ }
146
+ const scope = { token, orgId: target.organizationId }
147
+
148
+ if (setup.custom.length) {
149
+ const list = await call<{ data?: Array<{ slug: string }> }>(target.serviceUrl, '/v1/admin/custom-providers', scope)
150
+ const existing = new Set((list.body?.data ?? []).map((p) => p.slug))
151
+ for (const spec of setup.custom) {
152
+ const key = getKey(customKeyId(spec.slug))
153
+ const baseUrl = serviceReachable(spec.baseUrl, target.containerHost)
154
+ const res = existing.has(spec.slug)
155
+ ? await call(target.serviceUrl, `/v1/admin/custom-providers/${spec.slug}`, { ...scope, method: 'PUT', body: { label: spec.label, baseUrl, ...(key ? { apiKey: key } : {}) } })
156
+ : // The service needs a key; an endpoint that takes none gets a placeholder.
157
+ await call(target.serviceUrl, '/v1/admin/custom-providers', { ...scope, body: { slug: spec.slug, label: spec.label, baseUrl, apiKey: key ?? 'none' } })
158
+ if (res.status < 300) result.custom.push(spec.slug)
159
+ else result.errors.push(`${spec.label}: the runtime answered ${res.status} ${JSON.stringify(res.body).slice(0, 200)}`)
160
+ }
161
+ }
162
+
163
+ if (setup.models.length) {
164
+ const catalog = await call<Catalog>(target.serviceUrl, '/v1/config/models/catalog', scope)
165
+ for (const model of setup.models) {
166
+ const key = `${model.provider}/${model.modelId}`
167
+ const found = catalog.body?.data?.find((m) => m.provider === model.provider && m.modelId === model.modelId)
168
+ if (found) {
169
+ result.models[key] = found.slug
170
+ continue
171
+ }
172
+ const res = await call<{ data?: { slug?: string } }>(target.serviceUrl, '/v1/config/models/catalog', {
173
+ ...scope,
174
+ body: { slug: model.slug ?? modelSlug(model.name), provider: model.provider, modelId: model.modelId, name: model.name, kind: 'text' },
175
+ })
176
+ // The runtime may answer with another slug (the pair existed, or the slug was taken).
177
+ if (res.status < 300 && res.body?.data?.slug) result.models[key] = res.body.data.slug
178
+ else result.errors.push(`${model.name}: the runtime answered ${res.status} ${JSON.stringify(res.body).slice(0, 200)}`)
179
+ }
180
+ }
181
+
182
+ // Models added before the service described reasoning (or by hand) get the levels their
183
+ // provider publishes, so an Agent's effort reaches the model. Only rows with none; a
184
+ // release without the route answers 404, which is fine.
185
+ const filled = await call(target.serviceUrl, '/v1/config/models/catalog/reasoning/fill', { ...scope, method: 'POST', body: {} })
186
+ if (filled.status >= 300 && filled.status !== 404) result.errors.push(`describing the models' reasoning: the runtime answered ${filled.status}`)
187
+
188
+ if (setup.defaults.judgeModel) {
189
+ const res = await call(target.serviceUrl, '/v1/eval/settings', {
190
+ ...scope,
191
+ workspaceId: target.workspaceId,
192
+ method: 'PATCH',
193
+ body: { judgeModelSlug: setup.defaults.judgeModel },
194
+ })
195
+ if (res.status >= 300) result.errors.push(`the grading model: the runtime answered ${res.status} ${JSON.stringify(res.body).slice(0, 200)}`)
196
+ }
197
+ return result
198
+ }
199
+
200
+ /** Record the slugs a runtime gave back, so `agent.json` and the defaults use what the runtime knows. */
201
+ export function rememberSlugs(setup: ProviderSetup, applied: ApplyResult): ProviderSetup {
202
+ return {
203
+ ...setup,
204
+ models: setup.models.map((m) => ({ ...m, slug: applied.models[`${m.provider}/${m.modelId}`] ?? m.slug })),
205
+ }
206
+ }
207
+
208
+ async function session(target: RuntimeTarget) {
209
+ return { token: await signIn(target.serviceUrl, target.password), orgId: target.organizationId }
210
+ }
211
+
212
+ export interface CatalogModel {
213
+ slug: string
214
+ name: string
215
+ provider: string
216
+ modelId: string
217
+ kind?: string
218
+ }
219
+
220
+ /** The chat models Agents in the runtime can pick, by catalog slug. */
221
+ export async function runtimeCatalog(target: RuntimeTarget): Promise<CatalogModel[]> {
222
+ const res = await call<{ data?: CatalogModel[] }>(target.serviceUrl, '/v1/config/models/catalog', await session(target))
223
+ return (res.body?.data ?? []).filter((m) => (m.kind ?? 'text') === 'text')
224
+ }
225
+
226
+ /** The workspace's grading model, read without changing it (an empty update answers with the settings). */
227
+ export async function runtimeJudge(target: RuntimeTarget): Promise<string | null> {
228
+ const res = await call<{ data?: { judgeModelSlug?: string | null } }>(target.serviceUrl, '/v1/eval/settings', {
229
+ ...(await session(target)),
230
+ workspaceId: target.workspaceId,
231
+ method: 'PATCH',
232
+ body: {},
233
+ })
234
+ return res.body?.data?.judgeModelSlug ?? null
235
+ }
236
+
237
+ export interface AvailableModel {
238
+ id: string
239
+ name: string
240
+ reasoning?: boolean
241
+ contextLength?: number | null
242
+ }
243
+
244
+ /** What a provider offers to add, as the runtime lists it (OpenRouter and custom endpoints are asked live). */
245
+ export async function availableModels(target: RuntimeTarget, provider: string): Promise<AvailableModel[]> {
246
+ const res = await call<{ data?: AvailableModel[] }>(target.serviceUrl, `/v1/config/providers/${encodeURIComponent(provider)}/models`, await session(target))
247
+ if (res.status >= 300) throw new Error(`the runtime answered ${res.status} ${JSON.stringify(res.body).slice(0, 200)}`)
248
+ return (res.body?.data ?? []).map((m) => ({ id: m.id, name: m.name, reasoning: m.reasoning, contextLength: m.contextLength }))
249
+ }
250
+
251
+ /** Take a model out of the runtime's catalog. */
252
+ export async function removeRuntimeModel(target: RuntimeTarget, slug: string): Promise<void> {
253
+ const res = await call(target.serviceUrl, `/v1/config/models/catalog?slug=${encodeURIComponent(slug)}`, { ...(await session(target)), method: 'DELETE' })
254
+ if (res.status >= 300 && res.status !== 404) throw new Error(`the runtime answered ${res.status} ${JSON.stringify(res.body).slice(0, 200)}`)
255
+ }
256
+
257
+ /** Take a custom provider out of the runtime, with its models first (the runtime refuses it while models use it). */
258
+ export async function removeRuntimeProvider(target: RuntimeTarget, slug: string): Promise<void> {
259
+ for (const model of (await runtimeCatalog(target)).filter((m) => m.provider === `custom:${slug}`)) await removeRuntimeModel(target, model.slug)
260
+ const res = await call(target.serviceUrl, `/v1/admin/custom-providers/${slug}`, { ...(await session(target)), method: 'DELETE' })
261
+ if (res.status >= 300 && res.status !== 404) throw new Error(`the runtime answered ${res.status} ${JSON.stringify(res.body).slice(0, 200)}`)
262
+ }
263
+
264
+ /** Forget a built-in provider's key in the runtime. */
265
+ export async function removeRuntimeKey(target: RuntimeTarget, provider: string): Promise<void> {
266
+ const res = await call(target.serviceUrl, `/v1/admin/provider-credentials/${provider}`, { ...(await session(target)), method: 'DELETE' })
267
+ if (res.status >= 300 && res.status !== 404) throw new Error(`the runtime answered ${res.status} ${JSON.stringify(res.body).slice(0, 200)}`)
268
+ }
@@ -0,0 +1,404 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ import { AUTOMATION_SDK_FILES } from '../automation-template'
5
+ import { AGENTS_MD } from '../harness'
6
+ import vendored from '../vendor/sdk-sources.json'
7
+
8
+ /**
9
+ * A new project in the layout `frontera local dev` applies: one Agent with a
10
+ * test case, one workflow, one Function, and a Blueprint object type with the
11
+ * dataset it reads. Each is small enough to read in a minute and works
12
+ * against the keyless local model as written.
13
+ *
14
+ * The Functions SDK is vendored into `vendor/frontera/` and resolved by the
15
+ * `paths` in `tsconfig.json`, the way `frontera function init` does it: the
16
+ * SDK packages are not published, and `function deploy` builds with Bun, which
17
+ * honours those paths.
18
+ */
19
+
20
+ /** The starter Agent, on the model the FDE chose for new Agents; with none chosen yet it has no model until they add one. */
21
+ const AGENT = (name: string, modelSlug?: string) =>
22
+ JSON.stringify(
23
+ {
24
+ config: { name: 'Assistant', description: `Answers questions for ${name}`, defaultMode: modelSlug ? 'default' : null },
25
+ modelConfigs: modelSlug ? [{ modeKey: 'default', modelSlug, displayName: 'Default', sortOrder: 0 }] : [],
26
+ prompts: [
27
+ {
28
+ filename: 'instructions.md',
29
+ kind: 'instructions',
30
+ content: 'You help people with their questions.\n\nAnswer in two or three sentences. When you do not know, say so.',
31
+ enabled: true,
32
+ sortOrder: 0,
33
+ },
34
+ ],
35
+ },
36
+ null,
37
+ 2,
38
+ ) + '\n'
39
+
40
+ /** Behavior settings: open-ended, with one customer variable the test setup can fill in. */
41
+ const OPERATING = JSON.stringify(
42
+ {
43
+ version: 1,
44
+ execution: { harness: 'open-ended' },
45
+ variables: [
46
+ {
47
+ key: 'customer_name',
48
+ label: 'Customer name',
49
+ description: 'How to address the person writing in',
50
+ scope: 'end-customer',
51
+ type: 'text',
52
+ testValue: 'Sam',
53
+ },
54
+ ],
55
+ },
56
+ null,
57
+ 2,
58
+ ) + '\n'
59
+
60
+ const EVALS = `# Test cases for the assistant. Each case: a conversation, then what a good reply does.
61
+ # \`frontera local test\` (or Evaluations in Studio) runs them against the draft.
62
+ #
63
+ # This one passes with the keyless local model, which repeats what it was told.
64
+ # Once the Agent uses a real model, write expectations about the answer instead.
65
+ cases:
66
+ - name: replies-to-a-greeting
67
+ conversation:
68
+ - user: Hello from a new project
69
+ expectedContains: [Hello from a new project]
70
+ `
71
+
72
+ const WORKFLOW = `apiVersion: workflow/v1
73
+ name: hello
74
+ description: Greets someone, to show a run end to end
75
+ input:
76
+ name: { type: string, required: true, description: Who to greet }
77
+ trigger:
78
+ - kind: manual
79
+ nodes:
80
+ - id: greet
81
+ kind: transform
82
+ params:
83
+ value: { type: expr, expr: '"Hello, " & $input.name' }
84
+ `
85
+
86
+ const FUNCTION = `import { automation } from '@frontera-sdk/functions'
87
+
88
+ export default automation(
89
+ {
90
+ name: 'word-count',
91
+ description: 'Counts the words in a piece of text.',
92
+ trigger: { manual: true, agent: true },
93
+ inputs: {
94
+ text: { type: 'string', required: true, description: 'The text to count' },
95
+ },
96
+ },
97
+ async (ctx) => {
98
+ const { text } = ctx.input as { text: string }
99
+ const words = text.trim().split(/\\s+/).filter(Boolean).length
100
+ await ctx.log('counted', { words })
101
+ return { words }
102
+ },
103
+ )
104
+ `
105
+
106
+ const FUNCTION_TEST = `import { expect, test } from 'bun:test'
107
+
108
+ import { createTestContext } from '@frontera-sdk/functions'
109
+
110
+ import wordCount from './word-count'
111
+
112
+ // A Function is a handler, so it is tested without a runtime: \`bun test\`.
113
+ // The context refuses what the platform refuses (a grant the manifest does not
114
+ // declare, a step name used twice), so a passing test means something.
115
+ test('counts the words', async () => {
116
+ const { ctx, logs } = createTestContext({ input: { text: 'four words right here' } })
117
+ expect(await wordCount.handler(ctx)).toEqual({ words: 4 })
118
+ expect(logs.map((l) => l.message)).toEqual(['counted'])
119
+ })
120
+ `
121
+
122
+ const README = (name: string) => `# ${name}
123
+
124
+ A Frontera project. Every file here is applied to a local runtime by
125
+ \`frontera local dev\` and tried in Frontera Studio.
126
+
127
+ | Path | What it is |
128
+ |---|---|
129
+ | \`agents/<slug>/agent.json\` | An Agent's draft: model, instructions, tools |
130
+ | \`agents/<slug>/operating.json\` | Its behavior: procedures, variables, focus, reply checks |
131
+ | \`agents/<slug>/evals/*.yaml\` | Its test cases |
132
+ | \`workflows/<slug>.yaml\` | A workflow |
133
+ | \`functions/<name>.ts\` | A Function |
134
+ | \`datasets/<apiName>.json\` + \`.csv\` | Sample data the Blueprint reads |
135
+ | \`blueprint/\` | The Blueprint: object types, links, metrics, actions |
136
+ | \`fixtures/\` | Sample data for tests |
137
+
138
+ ## Develop
139
+
140
+ \`\`\`bash
141
+ frontera local start # the runtimes, on this machine
142
+ frontera local dev # apply each file as you save it
143
+ frontera local ui # open Studio
144
+ frontera local test # run the Agents' test cases
145
+ bun test # run the Functions' unit tests, no runtime needed
146
+ frontera local check # start, apply and test in one command, before pushing
147
+ \`\`\`
148
+
149
+ An Agent's model is the catalog slug in \`modelSlug\` in its \`agent.json\`.
150
+ \`frontera local providers\` lists the providers and models your runtimes
151
+ have and the model new Agents start with. Add a provider's key with
152
+ \`frontera local key-set <provider>\`, a provider of your own (a gateway,
153
+ Azure, a model on this machine) with \`frontera local provider-add\`, and a
154
+ model with \`frontera local model-add\`. An Agent needs a model with a key
155
+ before it can be tried.
156
+
157
+ ## Add Blueprint objects
158
+
159
+ An object type reads a dataset. Add \`datasets/<apiName>.json\` with its CSV
160
+ beside it (see \`datasets/tickets.json\`), scaffold the object type, and point
161
+ its \`backing\` at the dataset:
162
+
163
+ \`\`\`bash
164
+ frontera blueprint new object-type <Name>
165
+ \`\`\`
166
+
167
+ \`frontera local dev\` creates the dataset, loads a changed CSV as a new
168
+ snapshot, and publishes the Blueprint to the local workspace.
169
+ `
170
+
171
+ const DATASET = JSON.stringify(
172
+ {
173
+ apiName: 'tickets',
174
+ displayName: 'Tickets',
175
+ kind: 'file',
176
+ path: './tickets.csv',
177
+ includedColumns: ['id', 'subject', 'status'],
178
+ keyColumns: ['id'],
179
+ deterministicKeyConfirmed: true,
180
+ },
181
+ null,
182
+ 2,
183
+ ) + '\n'
184
+
185
+ const DATASET_ROWS = `id,subject,status
186
+ T-1,Cannot sign in,open
187
+ T-2,Invoice total is wrong,closed
188
+ T-3,Refund not received,open
189
+ `
190
+
191
+ const OBJECT_TYPE = `apiName: Ticket
192
+ displayName: Ticket
193
+ pluralDisplayName: Tickets
194
+ primaryKey: id
195
+ title: subject
196
+ governance:
197
+ ownership:
198
+ kind: team
199
+ ownerId: blueprint-administration
200
+ sensitivity:
201
+ classification: internal
202
+ categories: []
203
+ properties:
204
+ - apiName: id
205
+ displayName: ID
206
+ valueType: string
207
+ required: true
208
+ propertyType: attribute
209
+ - apiName: subject
210
+ displayName: Subject
211
+ valueType: string
212
+ required: false
213
+ propertyType: attribute
214
+ - apiName: status
215
+ displayName: Status
216
+ valueType: string
217
+ required: false
218
+ propertyType: attribute
219
+ validations: []
220
+ backing:
221
+ dataset: tickets
222
+ mapping:
223
+ id: id
224
+ subject: subject
225
+ status: status
226
+ `
227
+
228
+ /**
229
+ * The CLI's AGENTS.md, plus what a coding agent needs for this project. The
230
+ * \`local\` commands are internal and absent from \`frontera help\`, so an
231
+ * agent following the generic text alone would never find the loop.
232
+ */
233
+ const SOLUTION_AGENTS_MD = `${AGENTS_MD}
234
+ ## This project
235
+
236
+ The files here are the source of truth, developed against a Frontera runtime
237
+ on this machine (\`frontera local\`). Nothing in this loop talks to a
238
+ Frontera deployment.
239
+
240
+ | Path | What it is |
241
+ |---|---|
242
+ | \`agents/<slug>/agent.json\` | An Agent's draft: model, instructions, tools |
243
+ | \`agents/<slug>/operating.json\` | Its behavior: procedures, variables, focus, reply checks |
244
+ | \`agents/<slug>/evals/*.yaml\` | Its test cases: a conversation and what a good reply does |
245
+ | \`workflows/<slug>.yaml\` | A workflow |
246
+ | \`functions/<name>.ts\` | A Function (the SDK is vendored in \`vendor/\`) |
247
+ | \`datasets/<apiName>.json\` + \`.csv\` | Sample data the Blueprint reads |
248
+ | \`blueprint/\` | The Blueprint: object types, links, metrics, actions |
249
+
250
+ The loop:
251
+
252
+ 1. \`frontera local start\` brings the runtime up (once; needs Docker).
253
+ 2. Edit files. Then run \`frontera local dev --once\`: it applies what
254
+ changed and prints each refused file's error and validation messages.
255
+ Fix those before going on. (A person may keep \`frontera local dev\`
256
+ running instead; it applies on save.)
257
+ 3. \`frontera local test\` runs the Agents' test cases and exits non-zero
258
+ when one fails. \`bun test\` runs the Functions' unit tests offline.
259
+ 4. Before handing work back, run \`frontera local check\`: it starts the
260
+ runtime if needed, applies everything and runs the test cases, and exits
261
+ non-zero at the first failure.
262
+ 5. \`frontera local status -o env\` prints the API URL and keys for scripts.
263
+
264
+ \`frontera local <verb> --help\` documents each command; they are not
265
+ listed by \`frontera help\`.
266
+
267
+ ## Documentation
268
+
269
+ Frontera's documentation for the platform this project runs on is an MCP
270
+ server here: \`frontera-docs\` in \`.mcp.json\` (Claude Code) and
271
+ \`.cursor/mcp.json\` (Cursor); for Codex, add the command
272
+ \`frontera local docs-mcp\` as an MCP server. Its tools search and read the
273
+ same pages as the documentation in the platform, for the release the local
274
+ runtime runs. Before writing or changing an \`agent.json\`, an
275
+ \`operating.json\` (procedures, variables), a workflow, a Function or the
276
+ Blueprint, search the documentation for it and follow what it says; do not
277
+ guess a field or a step kind. It reads the documentation that
278
+ \`frontera local start\` keeps for the runtime's release, so start the runtime
279
+ once; after that it needs no key and works while the runtime is stopped.
280
+
281
+ The guides describe the product and its concepts, mostly through its
282
+ screens. The exact format of each file is a JSON Schema for the release the
283
+ runtime runs: \`frontera local schema <kind>\` prints it, for \`operating\`,
284
+ \`agent\`, \`workflow\`, \`evals\` and \`dataset\` (\`frontera local schema\`
285
+ lists which file each one is), \`frontera local start\` keeps them in
286
+ \`.frontera/local/schemas/\`, and the documentation server's
287
+ \`read_file_schema\` tool returns them too. Read the schema before you write
288
+ a file, rather than guessing a field or reading Frontera's source, and do
289
+ not add a \`$schema\` key to a file: most files refuse a key they do not
290
+ know. A schema checks a file's shape; what the file refers to (the action a
291
+ step runs, the Function an action names) is checked when it is applied, and
292
+ \`frontera local dev --once\` prints each refused file's validation messages.
293
+
294
+ ## Models
295
+
296
+ An Agent's model is the catalog slug in \`modelSlug\` (\`agent.json\`).
297
+ \`frontera local providers --json\` lists the models this machine's runtimes
298
+ offer and the one new Agents start with; use those slugs. Keys, providers and the models offered are
299
+ the person's to set (\`frontera local key-set\`, \`provider-add\`,
300
+ \`model-add\`, or Forge's Models page): ask them rather than doing it, and
301
+ never handle a key yourself.
302
+
303
+ Change the files, not the runtime: \`local dev\` keeps the two in step, and
304
+ a change made only on the runtime is lost on the next reset. A changed CSV
305
+ must keep its dataset's columns.
306
+ `
307
+
308
+ /**
309
+ * Frontera's documentation for the coding agents working in the project:
310
+ * a stdio MCP server the harness launches. It reads the copy `local start`
311
+ * keeps, so there is no key and nothing secret in this file.
312
+ */
313
+ const DOCS_MCP = `${JSON.stringify({ mcpServers: { 'frontera-docs': { command: 'frontera', args: ['local', 'docs-mcp'] } } }, null, 2)}\n`
314
+
315
+ const GITIGNORE = `node_modules/
316
+ .env
317
+ `
318
+
319
+ export function solutionScaffoldFiles(name: string, opts: { agentModel?: string } = {}): Record<string, string> {
320
+ const sdk = vendored.files as Record<string, string>
321
+ const out: Record<string, string> = {}
322
+ for (const rel of AUTOMATION_SDK_FILES) {
323
+ const content = sdk[rel]
324
+ if (content === undefined) throw new Error(`vendored SDK is missing ${rel} — run \`bun run sync:sdk\` in packages/frontera-cli`)
325
+ out[`vendor/${rel}`] = content
326
+ }
327
+
328
+ out['frontera.yaml'] = `# A Frontera project. \`frontera local dev\` applies agents/, workflows/, functions/ and blueprint/.\nname: ${name}\n`
329
+ out['README.md'] = README(name)
330
+ out['AGENTS.md'] = SOLUTION_AGENTS_MD
331
+ out['.gitignore'] = GITIGNORE
332
+ out['.mcp.json'] = DOCS_MCP
333
+ out['.cursor/mcp.json'] = DOCS_MCP
334
+ out['agents/assistant/agent.json'] = AGENT(name, opts.agentModel)
335
+ out['agents/assistant/operating.json'] = OPERATING
336
+ out['agents/assistant/evals/regression.yaml'] = EVALS
337
+ out['workflows/hello.yaml'] = WORKFLOW
338
+ out['functions/word-count.ts'] = FUNCTION
339
+ out['functions/word-count.test.ts'] = FUNCTION_TEST
340
+ out['datasets/tickets.json'] = DATASET
341
+ out['datasets/tickets.csv'] = DATASET_ROWS
342
+ out['blueprint/object-types/Ticket.yaml'] = OBJECT_TYPE
343
+ out['package.json'] =
344
+ JSON.stringify(
345
+ {
346
+ name,
347
+ private: true,
348
+ type: 'module',
349
+ scripts: { typecheck: 'tsc --noEmit', test: 'bun test' },
350
+ // The vendored manifest validates cron expressions with it.
351
+ dependencies: { 'cron-parser': '^5.0.6' },
352
+ devDependencies: { typescript: '^5.6.0', '@types/bun': 'latest' },
353
+ },
354
+ null,
355
+ 2,
356
+ ) + '\n'
357
+ out['tsconfig.json'] =
358
+ JSON.stringify(
359
+ {
360
+ compilerOptions: {
361
+ target: 'ES2022',
362
+ module: 'ESNext',
363
+ moduleResolution: 'bundler',
364
+ strict: true,
365
+ noEmit: true,
366
+ skipLibCheck: true,
367
+ types: ['bun'],
368
+ baseUrl: '.',
369
+ paths: {
370
+ '@frontera-sdk/functions': ['./vendor/frontera/automation/index.ts'],
371
+ '@frontera-sdk/functions/*': ['./vendor/frontera/automation/*'],
372
+ '@frontera-sdk/blueprint/types': ['./vendor/frontera/blueprint/types.ts'],
373
+ '@frontera-sdk/blueprint/*': ['./vendor/frontera/blueprint/*'],
374
+ },
375
+ },
376
+ include: ['functions', 'vendor'],
377
+ },
378
+ null,
379
+ 2,
380
+ ) + '\n'
381
+ return out
382
+ }
383
+
384
+ /**
385
+ * Write the files that are not there yet; with `force`, replace those that
386
+ * are. An AGENTS.md that is still the CLI's generic one is replaced anyway:
387
+ * nobody has changed it, and the project's version contains it.
388
+ */
389
+ export function writeSolutionScaffold(target: string, name: string, force: boolean, opts: { agentModel?: string } = {}): { written: string[]; skipped: string[] } {
390
+ const written: string[] = []
391
+ const skipped: string[] = []
392
+ for (const [rel, content] of Object.entries(solutionScaffoldFiles(name, opts))) {
393
+ const full = join(target, rel)
394
+ const generic = rel === 'AGENTS.md' && existsSync(full) && readFileSync(full, 'utf8') === AGENTS_MD
395
+ if (existsSync(full) && !force && !generic) {
396
+ skipped.push(rel)
397
+ continue
398
+ }
399
+ mkdirSync(dirname(full), { recursive: true })
400
+ writeFileSync(full, content)
401
+ written.push(rel)
402
+ }
403
+ return { written, skipped }
404
+ }