@frontera-sdk/cli 1.52.23 → 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.
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,477 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { existsSync, readFileSync, rmSync } from 'node:fs'
3
+ import { join, resolve } from 'node:path'
4
+
5
+ import { CliError, UsageError } from '../../errors'
6
+ import { writeFileAtomic } from '../../fs-atomic'
7
+ import { solutionStatus } from '../../local/local-applied'
8
+ import { dockerAvailable } from '../../local/local-docker'
9
+ import { readConfig, writeConfig, type LocalConfig } from '../../local/local-env'
10
+ import { configuredProviders } from '../../local/local-keys'
11
+ import { claimPorts, isPortFree, portHolder, releasePorts } from '../../local/local-ports'
12
+ import { localProject, type LocalProject } from '../../local/local-project'
13
+ import {
14
+ destroyRuntimeData,
15
+ detectPlatformSource,
16
+ isPlatformSource,
17
+ localProfileName,
18
+ registerLocalProfile,
19
+ spawnSupervisor,
20
+ stopRuntime,
21
+ waitForPhase,
22
+ } from '../../local/local-control'
23
+ import { clearState, ensureStateDir, isAlive, logPath, readState, type ComponentName, type LocalState } from '../../local/local-state'
24
+ import { newSessionToken } from '../../local/local-studio-server'
25
+ import { DEFAULT_REGISTRY, runSupervisor } from '../../local/local-supervisor'
26
+ import { findCaseFiles, runCases } from '../../local/local-test-runner'
27
+ import { flagBool, flagString, type Command, type CommandContext } from '../types'
28
+ import { localCheck } from './check'
29
+ import { localDev } from './dev'
30
+ import { localDocsMcp } from './docs-mcp'
31
+ import { localInit } from './init'
32
+ import { localKeyList, localKeyRemove, localKeySet } from './key'
33
+ import { localModelAdd, localModelDefault, localModelRemove, localModelsAvailable, localProviderAdd, localProviderRemove, localProviders } from './providers'
34
+ import { localSchema } from './schema'
35
+
36
+ /**
37
+ * `frontera local …`: Frontera's runtimes on this machine, for internal FDEs.
38
+ *
39
+ * Modelled on `supabase start`: one command brings up only the runtimes a
40
+ * solution needs (no product UI, no kernel), seeds an owner, organization,
41
+ * workspace and keys, and prints where everything is. `frontera local ui` opens
42
+ * Frontera Studio on top of it. Nothing here talks to a Frontera deployment.
43
+ */
44
+
45
+ const COMPONENTS: ComponentName[] = ['postgres', 'storage', 'service', 'jobs', 'runner', 'studio']
46
+ const OPTIONAL: ComponentName[] = ['jobs', 'runner', 'storage']
47
+
48
+ const sessionFile = (project: LocalProject) => join(project.stateDir, 'studio-session')
49
+
50
+ function readSession(project: LocalProject): string | null {
51
+ const path = sessionFile(project)
52
+ return existsSync(path) ? readFileSync(path, 'utf8').trim() : null
53
+ }
54
+
55
+ function cliVersion(): string {
56
+ try {
57
+ const pkg = JSON.parse(readFileSync(resolve(import.meta.dir, '..', '..', '..', 'package.json'), 'utf8')) as { version: string }
58
+ return pkg.version
59
+ } catch {
60
+ return 'latest'
61
+ }
62
+ }
63
+
64
+ function studioUrl(state: LocalState, project: LocalProject): string {
65
+ const token = readSession(project)
66
+ return `http://127.0.0.1:${state.ports.studio}/${token ? `?token=${token}` : ''}`
67
+ }
68
+
69
+ const DOT: Record<string, string> = { running: '●', starting: '◐', off: '○', stopped: '○', failed: '✕' }
70
+
71
+ function renderStatus(state: LocalState, project: LocalProject, profile?: { profile: string; bound: boolean }): string {
72
+ const lines: string[] = []
73
+ const phase = state.supervisorPid && !isAlive(state.supervisorPid) && state.phase !== 'stopped' ? 'stopped (supervisor exited)' : state.phase
74
+ lines.push(`Frontera local runtime · ${project.root.split('/').pop()} · ${phase}`)
75
+ if (state.error) lines.push(` ${state.error}`)
76
+ lines.push('')
77
+ if (state.phase === 'ready') {
78
+ lines.push(` Studio ${studioUrl(state, project)}`)
79
+ lines.push(` API http://127.0.0.1:${state.ports.service}`)
80
+ lines.push(` API docs http://127.0.0.1:${state.ports.service}/docs`)
81
+ if (state.components.find((c) => c.name === 'jobs')?.status === 'running') {
82
+ lines.push(` Job engine http://127.0.0.1:${state.ports.jobs}`)
83
+ }
84
+ lines.push(` Postgres postgresql://frontera:•••@127.0.0.1:${state.ports.postgres}/frontera`)
85
+ lines.push('')
86
+ }
87
+ for (const c of state.components) {
88
+ const where = c.kind === 'container' ? 'container' : c.kind === 'process' ? 'process' : 'supervisor'
89
+ lines.push(` ${DOT[c.status] ?? '·'} ${c.name.padEnd(9)} ${c.status.padEnd(9)} ${c.status === 'off' ? (c.detail ?? '') : where}${c.status === 'failed' && c.detail ? ` — ${c.detail}` : ''}`)
90
+ }
91
+ if (state.credentials) {
92
+ lines.push('')
93
+ lines.push(` Signed in as ${state.credentials.email}`)
94
+ const name = profile?.profile ?? localProfileName(project)
95
+ lines.push(
96
+ profile?.bound === false
97
+ ? ` CLI profile ${name} — this directory is bound to another profile; pass \`--profile ${name}\``
98
+ : ` CLI profile ${name} (this directory uses it)`,
99
+ )
100
+ lines.push(` Blueprint pass \`--profile ${name}-org\` (Blueprint commands need an organization key)`)
101
+ const files = solutionStatus(project, state.credentials.organizationId)
102
+ if (files.length) {
103
+ const behind = files.filter((f) => f.state !== 'applied')
104
+ lines.push(
105
+ behind.length
106
+ ? ` Project ${behind.length} of ${files.length} files not on the runtime (${behind.map((f) => f.name).join(', ')}) — run \`frontera local dev\``
107
+ : ` Project all ${files.length} files applied`,
108
+ )
109
+ }
110
+ }
111
+ const providers = configuredProviders()
112
+ lines.push(` Model keys ${providers.length ? providers.join(', ') : 'none — run `frontera local key-set <provider>`'}`)
113
+ if (state.credentials?.modelProviders?.includes('local-embeddings')) {
114
+ lines.push(' Embeddings local: words hashed into vectors, no key (an OpenAI or OpenRouter key replaces it)')
115
+ }
116
+ return lines.join('\n')
117
+ }
118
+
119
+ /** A registry host and namespace, such as `registry.example.com/frontera`; undefined keeps the default. */
120
+ export function normalizeRegistry(raw: string | undefined): string | undefined {
121
+ const value = raw?.trim().replace(/^[a-z]+:\/\//i, '').replace(/\/+$/, '')
122
+ if (!value) return undefined
123
+ if (!/^[a-z0-9.-]+(:\d+)?(\/[a-z0-9._-]+)*$/i.test(value)) {
124
+ throw new UsageError(`\`${raw}\` is not a registry address`, 'pass a registry host and namespace, such as `registry.example.com/frontera` or `localhost:5000/frontera`')
125
+ }
126
+ return value
127
+ }
128
+
129
+ function parseList(raw: string | undefined): ComponentName[] {
130
+ if (!raw) return []
131
+ const names = raw.split(',').map((s) => s.trim()).filter(Boolean) as ComponentName[]
132
+ for (const n of names) {
133
+ if (!OPTIONAL.includes(n)) {
134
+ throw new UsageError(`\`${n}\` cannot be excluded`, `only ${OPTIONAL.join(', ')} are optional; postgres, service and studio always run`)
135
+ }
136
+ }
137
+ return names
138
+ }
139
+
140
+ async function start(ctx: CommandContext): Promise<{ data: unknown; text: string }> {
141
+ const project = localProject(ctx.cwd)
142
+ const existing = readState(project)
143
+ if (existing?.supervisorPid && isAlive(existing.supervisorPid) && existing.phase !== 'stopped') {
144
+ const profile = existing.credentials ? await registerLocalProfile(project, existing) : undefined
145
+ return { data: existing, text: `Already running.\n\n${renderStatus(existing, project, profile)}` }
146
+ }
147
+
148
+ const docker = dockerAvailable()
149
+ if (!docker.ok) {
150
+ throw new CliError(`no container runtime answered: ${docker.reason}`, {
151
+ code: 'LOCAL_DOCKER',
152
+ hint: 'start Docker Desktop or OrbStack (`open -a OrbStack`), then run `frontera local start` again',
153
+ })
154
+ }
155
+
156
+ const sourceFlag = flagString(ctx, 'source')
157
+ const useImage = flagBool(ctx, 'image')
158
+ const source = sourceFlag ? resolve(ctx.cwd, sourceFlag) : useImage ? null : (process.env.FRONTERA_PLATFORM_SOURCE ?? detectPlatformSource())
159
+ if (sourceFlag && !isPlatformSource(source!)) {
160
+ throw new UsageError(`${source} is not a Frontera platform checkout`, 'point --source at the repository root that holds packages/service')
161
+ }
162
+ const exclude = parseList(flagString(ctx, 'exclude'))
163
+ const previous = readConfig(project)
164
+ const config: LocalConfig = {
165
+ mode: source ? 'source' : 'image',
166
+ platformSource: source ?? undefined,
167
+ platformVersion: flagString(ctx, 'version') ?? previous?.platformVersion ?? cliVersion(),
168
+ registry: normalizeRegistry(flagString(ctx, 'registry') ?? process.env.FRONTERA_LOCAL_REGISTRY ?? previous?.registry),
169
+ exclude,
170
+ }
171
+ ensureStateDir(project)
172
+ writeConfig(project, config)
173
+
174
+ const ports = claimPorts(project.id, project.root)
175
+ for (const name of [...COMPONENTS, 'cache', 'embeddings', 'traces'] as const) {
176
+ if (exclude.includes(name as ComponentName)) continue
177
+ if (!(await isPortFree(ports[name]))) {
178
+ const holder = portHolder(ports[name])
179
+ throw new CliError(`port ${ports[name]} (${name}) is already in use${holder ? ` by ${holder}` : ''}`, {
180
+ code: 'LOCAL_PORT',
181
+ hint: holder?.includes('docker') || holder?.includes('OrbStack') ? 'a container from an earlier run may still hold it; run `frontera local stop`' : 'stop that process, then start again',
182
+ })
183
+ }
184
+ }
185
+
186
+ const session = newSessionToken()
187
+ writeFileAtomic(sessionFile(project), `${session}\n`, 0o600)
188
+ const supervisorPid = spawnSupervisor(project, session)
189
+
190
+ const seen = new Set<string>()
191
+ const state = await ctx.output.task(
192
+ config.mode === 'source' ? 'Starting the local runtime from this platform checkout' : `Starting the local runtime (release ${config.platformVersion} from ${config.registry ?? DEFAULT_REGISTRY})`,
193
+ () =>
194
+ waitForPhase(project, supervisorPid, (s) => {
195
+ for (const c of s.components) {
196
+ const key = `${c.name}:${c.status}`
197
+ if (c.status === 'running' && !seen.has(key)) {
198
+ seen.add(key)
199
+ ctx.output.note(` ● ${c.name} running`)
200
+ }
201
+ }
202
+ if (s.phase === 'seeding' && !seen.has('seed')) {
203
+ seen.add('seed')
204
+ ctx.output.note(' Seeding an owner, organization, workspace and keys')
205
+ }
206
+ }),
207
+ )
208
+ if (state.phase !== 'ready') {
209
+ throw new CliError(`the local runtime did not start: ${state.error ?? state.phase}`, {
210
+ code: 'LOCAL_START',
211
+ hint: 'see `frontera local logs <component>`; fix the cause and run `frontera local start` again',
212
+ })
213
+ }
214
+ const profile = await registerLocalProfile(project, state)
215
+ return { data: { ...state, credentials: state.credentials ? { ...state.credentials, orgKey: undefined, workspaceKey: undefined } : undefined, profile }, text: renderStatus(state, project, profile) }
216
+ }
217
+
218
+ const startMeta = {
219
+ args: [],
220
+ flags: { source: 'string', image: 'boolean', version: 'string', registry: 'string', exclude: 'string' },
221
+ summary: 'Start Frontera\'s runtimes on this machine and seed an owner, workspace and keys',
222
+ examples: ['frontera local start', 'frontera local start --exclude runner', 'frontera local start --image --version 1.52.19', 'frontera local start --image --registry registry.example.com/frontera'],
223
+ offline: true,
224
+ internal: true,
225
+ } as const
226
+
227
+ export const localStart: Command = { meta: { noun: 'local', verb: 'start', ...startMeta }, run: start }
228
+
229
+ async function stop(ctx: CommandContext) {
230
+ const project = localProject(ctx.cwd)
231
+ const state = readState(project)
232
+ if (!state) return { data: { stopped: false }, text: 'No local runtime here.' }
233
+ await ctx.output.task('Stopping the local runtime', () => stopRuntime(project))
234
+ if (flagBool(ctx, 'no-backup')) {
235
+ destroyRuntimeData(project)
236
+ clearState(project)
237
+ rmSync(join(project.stateDir, 'secrets.json'), { force: true })
238
+ releasePorts(project.id)
239
+ return { data: { stopped: true, dataRemoved: true }, text: 'Stopped, and removed the database and local secrets.' }
240
+ }
241
+ return { data: { stopped: true }, text: 'Stopped. Data is kept; `frontera local start` brings it back.' }
242
+ }
243
+
244
+ export const localStop: Command = {
245
+ meta: {
246
+ noun: 'local',
247
+ verb: 'stop',
248
+ args: [],
249
+ flags: { 'no-backup': 'boolean' },
250
+ summary: 'Stop the local runtime (keeps its data unless --no-backup)',
251
+ examples: ['frontera local stop', 'frontera local stop --no-backup'],
252
+ offline: true,
253
+ internal: true,
254
+ },
255
+ run: stop,
256
+ }
257
+
258
+ async function status(ctx: CommandContext) {
259
+ const project = localProject(ctx.cwd)
260
+ const state = readState(project)
261
+ if (!state) {
262
+ return { data: null, text: 'No local runtime here. Run `frontera local start`.' }
263
+ }
264
+ if (flagString(ctx, 'output') === 'env') {
265
+ const lines = [
266
+ `FRONTERA_API_URL=http://127.0.0.1:${state.ports.service}`,
267
+ `FRONTERA_STUDIO_URL=http://127.0.0.1:${state.ports.studio}`,
268
+ `FRONTERA_LOCAL_PROFILE=${localProfileName(project)}`,
269
+ ...(state.credentials
270
+ ? [
271
+ `FRONTERA_LOCAL_KEY=${state.credentials.orgKey}`,
272
+ ...(state.credentials.workspaceKey ? [`FRONTERA_WORKSPACE_KEY=${state.credentials.workspaceKey}`] : []),
273
+ `FRONTERA_ORG_ID=${state.credentials.organizationId}`,
274
+ `FRONTERA_WORKSPACE_ID=${state.credentials.workspaceId}`,
275
+ ]
276
+ : []),
277
+ ]
278
+ return { data: Object.fromEntries(lines.map((l) => l.split(/=(.*)/s).slice(0, 2))), text: lines.join('\n') }
279
+ }
280
+ const { credentials, ...rest } = state
281
+ return {
282
+ data: { ...rest, credentials: credentials ? { email: credentials.email, organizationId: credentials.organizationId, workspaceId: credentials.workspaceId } : null },
283
+ text: renderStatus(state, project),
284
+ }
285
+ }
286
+
287
+ export const localStatus: Command = {
288
+ meta: {
289
+ noun: 'local',
290
+ verb: 'status',
291
+ args: [],
292
+ flags: { output: 'string' },
293
+ aliases: { o: 'output' },
294
+ summary: 'Show the local runtime: components, URLs and credentials (-o env for scripts)',
295
+ examples: ['frontera local status', 'eval "$(frontera local status -o env)"'],
296
+ offline: true,
297
+ internal: true,
298
+ },
299
+ run: status,
300
+ }
301
+
302
+ async function logs(ctx: CommandContext) {
303
+ const project = localProject(ctx.cwd)
304
+ const component = (ctx.positional[0] ?? 'service') as ComponentName
305
+ if (!COMPONENTS.includes(component)) {
306
+ throw new UsageError(`unknown component \`${component}\``, `use one of: ${COMPONENTS.join(', ')}`)
307
+ }
308
+ const file = logPath(project, component)
309
+ if (!existsSync(file)) return { data: { lines: [] }, text: `No ${component} log yet.` }
310
+ if (flagBool(ctx, 'follow')) {
311
+ await new Promise<void>((r) => {
312
+ const child = spawn('tail', ['-n', '100', '-f', file], { stdio: 'inherit' })
313
+ child.on('exit', () => r())
314
+ })
315
+ return { data: null, text: '' }
316
+ }
317
+ const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean).slice(-Number(flagString(ctx, 'lines') ?? 100))
318
+ return { data: { component, lines }, text: lines.join('\n') }
319
+ }
320
+
321
+ export const localLogs: Command = {
322
+ meta: {
323
+ noun: 'local',
324
+ verb: 'logs',
325
+ args: [{ name: 'component', required: false, description: COMPONENTS.join(' | ') }],
326
+ flags: { follow: 'boolean', lines: 'string' },
327
+ aliases: { f: 'follow', n: 'lines' },
328
+ summary: 'Print a component\'s log (service by default)',
329
+ examples: ['frontera local logs', 'frontera local logs jobs -f'],
330
+ offline: true,
331
+ internal: true,
332
+ },
333
+ run: logs,
334
+ }
335
+
336
+ async function reset(ctx: CommandContext) {
337
+ const project = localProject(ctx.cwd)
338
+ await ctx.output.task('Stopping the local runtime', () => stopRuntime(project))
339
+ destroyRuntimeData(project)
340
+ clearState(project)
341
+ rmSync(join(project.stateDir, 'secrets.json'), { force: true })
342
+ // Traces of the wiped runtime would point at conversations that are gone.
343
+ rmSync(join(project.stateDir, 'traces.jsonl'), { force: true })
344
+ const result = await start(ctx)
345
+ return { data: result.data, text: `Reset to a clean runtime.\n\n${result.text}` }
346
+ }
347
+
348
+ export const localReset: Command = {
349
+ meta: {
350
+ noun: 'local',
351
+ verb: 'reset',
352
+ ...startMeta,
353
+ summary: 'Wipe the local database and secrets, then start a clean, freshly seeded runtime',
354
+ examples: ['frontera local reset'],
355
+ },
356
+ run: reset,
357
+ }
358
+
359
+ async function ui(ctx: CommandContext) {
360
+ const project = localProject(ctx.cwd)
361
+ const state = readState(project)
362
+ if (!state || state.phase !== 'ready' || !state.supervisorPid || !isAlive(state.supervisorPid)) {
363
+ throw new CliError('the local runtime is not running', { code: 'LOCAL_NOT_RUNNING', hint: 'run `frontera local start` first' })
364
+ }
365
+ const url = studioUrl(state, project)
366
+ if (!flagBool(ctx, 'no-open') && process.platform === 'darwin') spawn('open', [url], { stdio: 'ignore', detached: true }).unref()
367
+ else if (!flagBool(ctx, 'no-open') && process.platform === 'linux') spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref()
368
+ return { data: { url }, text: `Frontera Studio: ${url}` }
369
+ }
370
+
371
+ export const localUi: Command = {
372
+ meta: {
373
+ noun: 'local',
374
+ verb: 'ui',
375
+ args: [],
376
+ flags: { 'no-open': 'boolean' },
377
+ summary: 'Open Frontera Studio for the running local runtime',
378
+ examples: ['frontera local ui', 'frontera local ui --no-open'],
379
+ offline: true,
380
+ internal: true,
381
+ },
382
+ run: ui,
383
+ }
384
+
385
+ async function test(ctx: CommandContext) {
386
+ const project = localProject(ctx.cwd)
387
+ const state = readState(project)
388
+ if (!state || state.phase !== 'ready' || !state.credentials?.session) {
389
+ throw new CliError('the local runtime is not running', { code: 'LOCAL_NOT_RUNNING', hint: 'run `frontera local start` first' })
390
+ }
391
+ const files = findCaseFiles(project.root, flagString(ctx, 'agent'))
392
+ const total = files.reduce((n, f) => n + f.cases.length, 0)
393
+ if (total === 0) {
394
+ return {
395
+ data: { passed: 0, failed: 0, results: [] },
396
+ text: 'No test cases. Save one from Studio (Agents → Save as test case) or add agents/<slug>/evals/*.yaml.',
397
+ }
398
+ }
399
+ const results = await runCases({
400
+ serviceUrl: `http://127.0.0.1:${state.ports.service}`,
401
+ creds: state.credentials,
402
+ files,
403
+ live: flagBool(ctx, 'live'),
404
+ onResult: (r) => ctx.output.note(` ${r.passed ? '✓' : '✕'} ${r.agent} · ${r.name}${r.passed ? '' : ` — ${r.reason}`} (${r.ms} ms)`),
405
+ })
406
+ const failed = results.filter((r) => !r.passed)
407
+ const summary = `${results.length - failed.length} passed, ${failed.length} failed`
408
+ if (failed.length) {
409
+ throw new CliError(`${summary}: ${failed.map((f) => `${f.agent} · ${f.name}`).join('; ')}`, {
410
+ code: 'LOCAL_TESTS_FAILED',
411
+ hint: 'open the Agent in Studio (`frontera local ui`) to see the conversation, then fix the Agent or the case',
412
+ })
413
+ }
414
+ return { data: { passed: results.length, failed: 0, results }, text: summary }
415
+ }
416
+
417
+ export const localTest: Command = {
418
+ meta: {
419
+ noun: 'local',
420
+ verb: 'test',
421
+ args: [],
422
+ flags: { agent: 'string', live: 'boolean' },
423
+ summary: 'Run the project\'s saved Agent test cases against the local runtime',
424
+ examples: ['frontera local test', 'frontera local test --agent claims-triage', 'frontera local test --live'],
425
+ offline: true,
426
+ internal: true,
427
+ },
428
+ run: test,
429
+ }
430
+
431
+ /** The supervisor itself. Spawned detached by `start`; not for direct use. */
432
+ export const localDaemon: Command = {
433
+ meta: {
434
+ noun: 'local',
435
+ verb: 'daemon',
436
+ args: [],
437
+ flags: {},
438
+ summary: 'Run the local runtime supervisor (started by `frontera local start`)',
439
+ examples: ['frontera local start'],
440
+ offline: true,
441
+ internal: true,
442
+ },
443
+ async run(ctx) {
444
+ const project = localProject(ctx.cwd)
445
+ const ports = claimPorts(project.id, project.root)
446
+ await runSupervisor(project, ports, process.env.FRONTERA_LOCAL_SESSION ?? readSession(project) ?? newSessionToken())
447
+ // Keep the supervisor alive: the Studio server and child watchers run on the event loop.
448
+ await new Promise(() => {})
449
+ return { data: null, text: '' }
450
+ },
451
+ }
452
+
453
+ export const localCommands: readonly Command[] = [
454
+ localInit,
455
+ localStart,
456
+ localStop,
457
+ localStatus,
458
+ localLogs,
459
+ localReset,
460
+ localUi,
461
+ localDev,
462
+ localTest,
463
+ localCheck,
464
+ localKeySet,
465
+ localKeyList,
466
+ localKeyRemove,
467
+ localProviders,
468
+ localProviderAdd,
469
+ localProviderRemove,
470
+ localModelsAvailable,
471
+ localModelAdd,
472
+ localModelRemove,
473
+ localModelDefault,
474
+ localDocsMcp,
475
+ localSchema,
476
+ localDaemon,
477
+ ]
@@ -0,0 +1,78 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { basename, resolve } from 'node:path'
3
+
4
+ import { UsageError } from '../../errors'
5
+ import { writeHarnessFiles } from '../../harness'
6
+ import { slugify } from '../../local/local-project'
7
+ import { readProviderSetup } from '../../local/local-providers'
8
+ import { writeSolutionScaffold } from '../../local/local-scaffold'
9
+ import { flagBool, type Command } from '../types'
10
+
11
+ /**
12
+ * `frontera local init [name]`: a new project in the layout `local dev`
13
+ * applies, like `supabase init`. Into `<name>/`, or this folder when no name
14
+ * is given; files already there are kept unless `--force`.
15
+ */
16
+ export const localInit: Command = {
17
+ meta: {
18
+ noun: 'local',
19
+ verb: 'init',
20
+ args: [{ name: 'name', required: false, description: 'folder to create the project in (default: this folder)' }],
21
+ flags: { force: 'boolean', 'no-install': 'boolean' },
22
+ summary: 'Create a project: an Agent with a test case, a workflow, a Function and a Blueprint object type with its data',
23
+ examples: ['frontera local init claims-intake', 'frontera local init', 'frontera local init acme-support --no-install'],
24
+ offline: true,
25
+ internal: true,
26
+ },
27
+
28
+ async run(ctx) {
29
+ const given = ctx.positional[0]
30
+ const target = given ? resolve(ctx.cwd, given) : ctx.cwd
31
+ const name = slugify(basename(target))
32
+ if (!name) throw new UsageError('the project needs a name with letters or digits', 'frontera local init <name>')
33
+
34
+ const force = flagBool(ctx, 'force')
35
+ // The CLI skill and CLAUDE.md, so a coding agent here knows the CLI. First,
36
+ // so the scaffold's AGENTS.md (the generic one plus this project's loop)
37
+ // replaces the generic one rather than the other way round.
38
+ const harness = writeHarnessFiles(target, { force })
39
+ // The starter Agent takes the model the FDE chose for new Agents (`frontera local model-default`).
40
+ const scaffold = writeSolutionScaffold(target, name, force, { agentModel: readProviderSetup().defaults.agentModel })
41
+ const listed = (files: string[]) => files.filter((f) => !f.startsWith('vendor/'))
42
+ const written = [...new Set(listed([...scaffold.written, ...harness.written]))]
43
+ const skipped = listed([...scaffold.skipped, ...harness.skipped]).filter((f) => !written.includes(f))
44
+
45
+ let installed: boolean | null = null
46
+ if (!flagBool(ctx, 'no-install') && scaffold.written.includes('package.json')) {
47
+ ctx.output.note('Installing the Function dependencies (bun install)')
48
+ // Kept off stdout, which carries the command's own (possibly JSON) output.
49
+ const res = spawnSync('nice', ['-n', '10', 'bun', 'install', '--silent'], { cwd: target, stdio: ['ignore', 'ignore', 'pipe'], encoding: 'utf8' })
50
+ installed = res.status === 0
51
+ if (!installed) {
52
+ const why = (res.stderr || res.error?.message || '').trim().split('\n')[0]
53
+ ctx.output.note(` bun install failed${why ? `: ${why}` : ''}; run it in the folder before deploying Functions`)
54
+ }
55
+ }
56
+
57
+ const cd = given ? [` cd ${given}`] : []
58
+ const sdkWritten = scaffold.written.some((f) => f.startsWith('vendor/'))
59
+ const heading = written.length
60
+ ? `Created the project ${name}`
61
+ : `${name} already has every file${sdkWritten ? '; the vendored Functions SDK was completed' : ''}`
62
+ return {
63
+ data: { name, path: target, written, skipped, installed },
64
+ text: [
65
+ heading,
66
+ ...written.map((f) => ` + ${f}`),
67
+ ...(written.length && sdkWritten ? [' + vendor/frontera/ (the Functions SDK)'] : []),
68
+ ...skipped.map((f) => ` · ${f} (exists; --force replaces it)`),
69
+ '',
70
+ 'Next:',
71
+ ...cd,
72
+ ' frontera local start',
73
+ ' frontera local dev',
74
+ ' frontera local ui',
75
+ ].join('\n'),
76
+ }
77
+ },
78
+ }
@@ -0,0 +1,108 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ import { UsageError } from '../../errors'
4
+ import { promptSecret } from '../../prompt'
5
+ import { configuredProviders, findProvider, LOCAL_PROVIDERS, removeKey, setKey } from '../../local/local-keys'
6
+ import { localProject } from '../../local/local-project'
7
+ import { removeRuntimeKey, runningTarget } from '../../local/local-providers'
8
+ import { applyModelKeys, ensureProviderModels } from '../../local/local-seed'
9
+ import { logPath } from '../../local/local-state'
10
+ import { flagBool, type Command } from '../types'
11
+
12
+ /**
13
+ * `frontera local key …`: each FDE's own provider keys for the local runtime.
14
+ *
15
+ * The key arrives on stdin or at a hidden prompt, never as a flag: a flag lands
16
+ * in shell history and in the process list for anyone running `ps`. A running
17
+ * runtime takes a new or removed key at once; Cohere, read only from the
18
+ * service's environment, waits for the next start.
19
+ */
20
+
21
+ function providerArg(raw: string | undefined) {
22
+ const provider = raw ? findProvider(raw) : undefined
23
+ if (!provider) {
24
+ throw new UsageError(
25
+ `unknown provider ${raw ? `\`${raw}\`` : '(none given)'}`,
26
+ `use one of: ${LOCAL_PROVIDERS.map((p) => p.id).join(', ')}`,
27
+ )
28
+ }
29
+ return provider
30
+ }
31
+
32
+ export const localKeySet: Command = {
33
+ meta: {
34
+ noun: 'local',
35
+ verb: 'key-set',
36
+ args: [{ name: 'provider', required: true, description: LOCAL_PROVIDERS.map((p) => p.id).join(' | ') }],
37
+ flags: { stdin: 'boolean' },
38
+ summary: 'Store your model-provider key for the local runtime (OS keychain)',
39
+ examples: ['frontera local key-set anthropic', 'printf %s "$KEY" | frontera local key-set openrouter --stdin'],
40
+ offline: true,
41
+ internal: true,
42
+ },
43
+ async run(ctx) {
44
+ const provider = providerArg(ctx.positional[0])
45
+ const key = flagBool(ctx, 'stdin')
46
+ ? readFileSync(0, 'utf8').trim()
47
+ : (await promptSecret(`${provider.label} API key: `)).trim()
48
+ if (!key) throw new UsageError('no key given', 'pipe it with --stdin, or run in a terminal to be prompted')
49
+ setKey(provider.id, key)
50
+ const target = runningTarget(ctx.cwd)
51
+ let live = false
52
+ if (target && provider.id !== 'cohere') {
53
+ const logFile = logPath(localProject(ctx.cwd), 'service')
54
+ const applied = await applyModelKeys({ serviceUrl: target.serviceUrl, password: target.password, organizationId: target.organizationId, logFile })
55
+ await ensureProviderModels({ serviceUrl: target.serviceUrl, password: target.password, organizationId: target.organizationId, providers: applied, logFile })
56
+ live = applied.includes(provider.id)
57
+ }
58
+ return {
59
+ data: { provider: provider.id, stored: true, live },
60
+ text: live
61
+ ? `Stored your ${provider.label} key; the runtime uses it now.`
62
+ : target
63
+ ? `Stored your ${provider.label} key. Restart the runtime to use it: \`frontera local stop && frontera local start\`.`
64
+ : `Stored your ${provider.label} key. The runtime uses it from its next start.`,
65
+ }
66
+ },
67
+ }
68
+
69
+ export const localKeyList: Command = {
70
+ meta: {
71
+ noun: 'local',
72
+ verb: 'key-list',
73
+ args: [],
74
+ flags: {},
75
+ summary: 'List which providers have a key for the local runtime',
76
+ examples: ['frontera local key-list'],
77
+ offline: true,
78
+ internal: true,
79
+ },
80
+ async run() {
81
+ const configured = new Set(configuredProviders())
82
+ const rows = LOCAL_PROVIDERS.map((p) => ({ provider: p.id, label: p.label, configured: configured.has(p.id) }))
83
+ return {
84
+ data: rows,
85
+ text: rows.map((r) => ` ${r.configured ? '●' : '○'} ${r.label.padEnd(16)} ${r.configured ? 'key stored' : '—'}`).join('\n'),
86
+ }
87
+ },
88
+ }
89
+
90
+ export const localKeyRemove: Command = {
91
+ meta: {
92
+ noun: 'local',
93
+ verb: 'key-remove',
94
+ args: [{ name: 'provider', required: true, description: 'provider id' }],
95
+ flags: {},
96
+ summary: 'Remove a stored model-provider key',
97
+ examples: ['frontera local key-remove openai'],
98
+ offline: true,
99
+ internal: true,
100
+ },
101
+ async run(ctx) {
102
+ const provider = providerArg(ctx.positional[0])
103
+ const removed = removeKey(provider.id)
104
+ const target = runningTarget(ctx.cwd)
105
+ if (target && removed) await removeRuntimeKey(target, provider.id)
106
+ return { data: { provider: provider.id, removed }, text: removed ? `Removed your ${provider.label} key.` : `No ${provider.label} key was stored.` }
107
+ },
108
+ }