@frontera-sdk/cli 1.52.22 → 1.52.24
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.
- package/package.json +4 -4
- package/src/api/dataset-api.ts +8 -4
- package/src/automation-template.ts +3 -0
- package/src/blueprint/tree.ts +27 -0
- package/src/commands/local/check.ts +64 -0
- package/src/commands/local/dev.ts +301 -0
- package/src/commands/local/docs-mcp.ts +36 -0
- package/src/commands/local/index-commands.ts +477 -0
- package/src/commands/local/init.ts +78 -0
- package/src/commands/local/key.ts +108 -0
- package/src/commands/local/providers.ts +245 -0
- package/src/commands/local/schema.ts +46 -0
- package/src/commands/registry.ts +6 -4
- package/src/commands/types.ts +7 -0
- package/src/flag-help.ts +18 -0
- package/src/harness.ts +1 -1
- package/src/local/local-agent-files.ts +122 -0
- package/src/local/local-applied.ts +135 -0
- package/src/local/local-apply-error.ts +35 -0
- package/src/local/local-control.ts +143 -0
- package/src/local/local-dev-sync.ts +256 -0
- package/src/local/local-docker.ts +146 -0
- package/src/local/local-docs-search.ts +126 -0
- package/src/local/local-docs.ts +241 -0
- package/src/local/local-embeddings.ts +79 -0
- package/src/local/local-env.ts +225 -0
- package/src/local/local-keys.ts +115 -0
- package/src/local/local-operating.ts +82 -0
- package/src/local/local-ports.ts +111 -0
- package/src/local/local-process.ts +106 -0
- package/src/local/local-project.ts +54 -0
- package/src/local/local-providers.ts +268 -0
- package/src/local/local-scaffold.ts +404 -0
- package/src/local/local-schemas.ts +301 -0
- package/src/local/local-seed.ts +332 -0
- package/src/local/local-state.ts +165 -0
- package/src/local/local-studio-assets.ts +68 -0
- package/src/local/local-studio-server.ts +304 -0
- package/src/local/local-supervisor.ts +561 -0
- package/src/local/local-test-cases.ts +58 -0
- package/src/local/local-test-runner.ts +191 -0
- package/src/local/local-test-runs.ts +75 -0
- package/src/local/local-traces.ts +322 -0
- package/src/main.ts +1 -1
- 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
|
+
}
|