@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.
- 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,245 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import { UsageError } from '../../errors'
|
|
4
|
+
import { promptSecret } from '../../prompt'
|
|
5
|
+
import { configuredProviders, getKey, LOCAL_PROVIDERS, removeKey, setKey } from '../../local/local-keys'
|
|
6
|
+
import {
|
|
7
|
+
applyProviderSetup,
|
|
8
|
+
availableModels,
|
|
9
|
+
CUSTOM_SLUG,
|
|
10
|
+
customKeyId,
|
|
11
|
+
modelSlug,
|
|
12
|
+
readProviderSetup,
|
|
13
|
+
rememberSlugs,
|
|
14
|
+
removeRuntimeModel,
|
|
15
|
+
removeRuntimeProvider,
|
|
16
|
+
runningTarget,
|
|
17
|
+
runtimeCatalog,
|
|
18
|
+
runtimeJudge,
|
|
19
|
+
writeProviderSetup,
|
|
20
|
+
type ProviderSetup,
|
|
21
|
+
type RuntimeTarget,
|
|
22
|
+
} from '../../local/local-providers'
|
|
23
|
+
import { flagBool, flagString, type Command, type CommandContext } from '../types'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `frontera local providers` and its verbs: the model providers and models an
|
|
27
|
+
* FDE's local runtimes use, beyond the built-in providers' keys (`key-set`).
|
|
28
|
+
* Each change is kept for every project's runtime and, when this project's
|
|
29
|
+
* runtime is up, applied to it at once. Keys arrive on stdin or at a hidden
|
|
30
|
+
* prompt, never as a flag.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Apply the setup to the running runtime, if any, and keep the slugs it gave back. */
|
|
34
|
+
async function applyNow(ctx: CommandContext, setup: ProviderSetup): Promise<{ applied: boolean; errors: string[] }> {
|
|
35
|
+
const target = runningTarget(ctx.cwd)
|
|
36
|
+
if (!target) {
|
|
37
|
+
writeProviderSetup(setup)
|
|
38
|
+
return { applied: false, errors: [] }
|
|
39
|
+
}
|
|
40
|
+
const result = await applyProviderSetup(target, setup)
|
|
41
|
+
writeProviderSetup(rememberSlugs(setup, result))
|
|
42
|
+
return { applied: true, errors: result.errors }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function needRuntime(ctx: CommandContext): RuntimeTarget {
|
|
46
|
+
const target = runningTarget(ctx.cwd)
|
|
47
|
+
if (!target) throw new UsageError('the local runtime is not running', 'frontera local start')
|
|
48
|
+
return target
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function readSecret(ctx: CommandContext, label: string): Promise<string> {
|
|
52
|
+
return flagBool(ctx, 'stdin') ? readFileSync(0, 'utf8').trim() : (await promptSecret(`${label} API key (Enter for none): `)).trim()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const afterward = (r: { applied: boolean; errors: string[] }) =>
|
|
56
|
+
r.errors.length ? `\nThe runtime refused part of it:\n${r.errors.map((e) => ` ${e}`).join('\n')}` : r.applied ? '' : '\nIt applies when the runtime starts.'
|
|
57
|
+
|
|
58
|
+
export const localProviders: Command = {
|
|
59
|
+
meta: {
|
|
60
|
+
noun: 'local',
|
|
61
|
+
verb: 'providers',
|
|
62
|
+
args: [],
|
|
63
|
+
flags: {},
|
|
64
|
+
summary: 'List the model providers, models and default models of your local runtimes',
|
|
65
|
+
examples: ['frontera local providers', 'frontera local providers --json'],
|
|
66
|
+
offline: true,
|
|
67
|
+
internal: true,
|
|
68
|
+
},
|
|
69
|
+
async run(ctx) {
|
|
70
|
+
const setup = readProviderSetup()
|
|
71
|
+
const keyed = new Set(configuredProviders())
|
|
72
|
+
const target = runningTarget(ctx.cwd)
|
|
73
|
+
const [catalog, judge] = target ? await Promise.all([runtimeCatalog(target).catch(() => null), runtimeJudge(target).catch(() => null)]) : [null, null]
|
|
74
|
+
const data = {
|
|
75
|
+
builtIn: LOCAL_PROVIDERS.map((p) => ({ id: p.id, label: p.label, key: keyed.has(p.id) })),
|
|
76
|
+
custom: setup.custom.map((c) => ({ ...c, provider: `custom:${c.slug}`, key: getKey(customKeyId(c.slug)) !== null })),
|
|
77
|
+
models: setup.models,
|
|
78
|
+
defaults: setup.defaults,
|
|
79
|
+
runtime: target ? { running: true, catalog: catalog ?? [], judgeModel: judge } : { running: false, catalog: [], judgeModel: null },
|
|
80
|
+
}
|
|
81
|
+
const lines = [
|
|
82
|
+
'Providers',
|
|
83
|
+
...data.builtIn.map((p) => ` ${p.key ? '●' : '○'} ${p.label.padEnd(22)} ${p.key ? 'key stored' : '—'}`),
|
|
84
|
+
...data.custom.map((c) => ` ● ${c.label.padEnd(22)} ${c.baseUrl}${c.key ? '' : ' (no key)'}`),
|
|
85
|
+
'',
|
|
86
|
+
'Models Agents can use',
|
|
87
|
+
...(target ? (catalog ?? []).map((m) => ` ${m.slug.padEnd(28)} ${m.name}`) : setup.models.map((m) => ` ${(m.slug ?? '').padEnd(28)} ${m.name}`)),
|
|
88
|
+
'',
|
|
89
|
+
`New Agents use ${setup.defaults.agentModel ?? '(not chosen)'}`,
|
|
90
|
+
`Tests graded by ${judge ?? setup.defaults.judgeModel ?? '(not chosen)'}`,
|
|
91
|
+
]
|
|
92
|
+
return { data, text: lines.join('\n') }
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const localProviderAdd: Command = {
|
|
97
|
+
meta: {
|
|
98
|
+
noun: 'local',
|
|
99
|
+
verb: 'provider-add',
|
|
100
|
+
args: [{ name: 'slug', required: true, description: 'lowercase id, e.g. acme-gateway (the provider becomes custom:<slug>)' }],
|
|
101
|
+
flags: { 'base-url': 'string', label: 'string', stdin: 'boolean' },
|
|
102
|
+
summary: 'Add an OpenAI-compatible model provider (a gateway, Azure, a model served on this machine)',
|
|
103
|
+
examples: ['frontera local provider-add ollama --base-url http://127.0.0.1:11434/v1', 'frontera local provider-add acme --base-url https://acme.example/v1 --stdin'],
|
|
104
|
+
offline: true,
|
|
105
|
+
internal: true,
|
|
106
|
+
},
|
|
107
|
+
async run(ctx) {
|
|
108
|
+
const slug = ctx.positional[0] ?? ''
|
|
109
|
+
if (!CUSTOM_SLUG.test(slug)) throw new UsageError(`\`${slug}\` is not a provider id`, 'use 2–40 lowercase letters, digits and hyphens, e.g. acme-gateway')
|
|
110
|
+
const baseUrl = flagString(ctx, 'base-url') ?? ''
|
|
111
|
+
if (!/^https?:\/\/\S+$/.test(baseUrl)) throw new UsageError('the provider needs its base URL', '--base-url https://host/v1 (the OpenAI-compatible root, ending before /chat/completions)')
|
|
112
|
+
const label = flagString(ctx, 'label') ?? slug
|
|
113
|
+
const key = await readSecret(ctx, label)
|
|
114
|
+
if (key) setKey(customKeyId(slug), key)
|
|
115
|
+
const setup = readProviderSetup()
|
|
116
|
+
const spec = { slug, label, baseUrl: baseUrl.replace(/\/+$/, '') }
|
|
117
|
+
setup.custom = [...setup.custom.filter((c) => c.slug !== slug), spec]
|
|
118
|
+
const result = await applyNow(ctx, setup)
|
|
119
|
+
return { data: { provider: `custom:${slug}`, ...spec, key: !!key, applied: result.applied, errors: result.errors }, text: `Added ${label} (custom:${slug}).${afterward(result)}` }
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const localProviderRemove: Command = {
|
|
124
|
+
meta: {
|
|
125
|
+
noun: 'local',
|
|
126
|
+
verb: 'provider-remove',
|
|
127
|
+
args: [{ name: 'slug', required: true, description: 'the custom provider id' }],
|
|
128
|
+
flags: {},
|
|
129
|
+
summary: 'Remove an OpenAI-compatible provider, its key and its models',
|
|
130
|
+
examples: ['frontera local provider-remove acme-gateway'],
|
|
131
|
+
offline: true,
|
|
132
|
+
internal: true,
|
|
133
|
+
},
|
|
134
|
+
async run(ctx) {
|
|
135
|
+
const slug = ctx.positional[0] ?? ''
|
|
136
|
+
const setup = readProviderSetup()
|
|
137
|
+
const spec = setup.custom.find((c) => c.slug === slug)
|
|
138
|
+
if (!spec) throw new UsageError(`no provider \`${slug}\``, 'frontera local providers')
|
|
139
|
+
const gone = new Set(setup.models.filter((m) => m.provider === `custom:${slug}`).map((m) => m.slug))
|
|
140
|
+
setup.custom = setup.custom.filter((c) => c.slug !== slug)
|
|
141
|
+
setup.models = setup.models.filter((m) => m.provider !== `custom:${slug}`)
|
|
142
|
+
if (setup.defaults.agentModel && gone.has(setup.defaults.agentModel)) delete setup.defaults.agentModel
|
|
143
|
+
if (setup.defaults.judgeModel && gone.has(setup.defaults.judgeModel)) delete setup.defaults.judgeModel
|
|
144
|
+
removeKey(customKeyId(slug))
|
|
145
|
+
writeProviderSetup(setup)
|
|
146
|
+
const target = runningTarget(ctx.cwd)
|
|
147
|
+
if (target) await removeRuntimeProvider(target, slug)
|
|
148
|
+
return { data: { provider: `custom:${slug}`, removed: true }, text: `Removed ${spec.label}.` }
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const localModelsAvailable: Command = {
|
|
153
|
+
meta: {
|
|
154
|
+
noun: 'local',
|
|
155
|
+
verb: 'models-available',
|
|
156
|
+
args: [{ name: 'provider', required: true, description: 'anthropic | openai | openrouter | google | custom:<slug>' }],
|
|
157
|
+
flags: {},
|
|
158
|
+
summary: "List the models a provider offers (needs the runtime running and the provider's key)",
|
|
159
|
+
examples: ['frontera local models-available openrouter', 'frontera local models-available custom:acme-gateway --json'],
|
|
160
|
+
offline: true,
|
|
161
|
+
internal: true,
|
|
162
|
+
},
|
|
163
|
+
async run(ctx) {
|
|
164
|
+
const provider = ctx.positional[0] ?? ''
|
|
165
|
+
const models = await availableModels(needRuntime(ctx), provider)
|
|
166
|
+
return { data: models, text: models.length ? models.map((m) => ` ${m.id.padEnd(44)} ${m.name}`).join('\n') : `${provider} offered no models: check its key or base URL.` }
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const localModelAdd: Command = {
|
|
171
|
+
meta: {
|
|
172
|
+
noun: 'local',
|
|
173
|
+
verb: 'model-add',
|
|
174
|
+
args: [
|
|
175
|
+
{ name: 'provider', required: true, description: 'anthropic | openai | openrouter | google | custom:<slug>' },
|
|
176
|
+
{ name: 'model', required: true, description: "the provider's model id, e.g. anthropic/claude-sonnet-5.5" },
|
|
177
|
+
],
|
|
178
|
+
flags: { name: 'string', slug: 'string' },
|
|
179
|
+
summary: 'Make a model available to Agents in your local runtimes',
|
|
180
|
+
examples: ['frontera local model-add openrouter openai/gpt-6-luna --name "GPT-6 Luna"', 'frontera local model-add custom:acme-gateway llama-4-70b'],
|
|
181
|
+
offline: true,
|
|
182
|
+
internal: true,
|
|
183
|
+
},
|
|
184
|
+
async run(ctx) {
|
|
185
|
+
const [provider = '', modelId = ''] = ctx.positional
|
|
186
|
+
if (!provider || !modelId) throw new UsageError('name the provider and its model id', 'frontera local model-add openrouter openai/gpt-6-luna')
|
|
187
|
+
const name = flagString(ctx, 'name') ?? modelId.split('/').pop() ?? modelId
|
|
188
|
+
const setup = readProviderSetup()
|
|
189
|
+
setup.models = [...setup.models.filter((m) => !(m.provider === provider && m.modelId === modelId)), { provider, modelId, name, slug: flagString(ctx, 'slug') ?? modelSlug(name) }]
|
|
190
|
+
const result = await applyNow(ctx, setup)
|
|
191
|
+
const slug = readProviderSetup().models.find((m) => m.provider === provider && m.modelId === modelId)?.slug
|
|
192
|
+
return { data: { provider, modelId, name, slug, applied: result.applied, errors: result.errors }, text: `Added ${name}: Agents name it \`${slug}\` in agent.json.${afterward(result)}` }
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export const localModelRemove: Command = {
|
|
197
|
+
meta: {
|
|
198
|
+
noun: 'local',
|
|
199
|
+
verb: 'model-remove',
|
|
200
|
+
args: [{ name: 'slug', required: true, description: 'the catalog slug' }],
|
|
201
|
+
flags: {},
|
|
202
|
+
summary: 'Stop offering a model to Agents in your local runtimes',
|
|
203
|
+
examples: ['frontera local model-remove gpt-6-luna'],
|
|
204
|
+
offline: true,
|
|
205
|
+
internal: true,
|
|
206
|
+
},
|
|
207
|
+
async run(ctx) {
|
|
208
|
+
const slug = ctx.positional[0] ?? ''
|
|
209
|
+
const setup = readProviderSetup()
|
|
210
|
+
setup.models = setup.models.filter((m) => m.slug !== slug)
|
|
211
|
+
if (setup.defaults.agentModel === slug) delete setup.defaults.agentModel
|
|
212
|
+
if (setup.defaults.judgeModel === slug) delete setup.defaults.judgeModel
|
|
213
|
+
writeProviderSetup(setup)
|
|
214
|
+
const target = runningTarget(ctx.cwd)
|
|
215
|
+
if (target) await removeRuntimeModel(target, slug)
|
|
216
|
+
return { data: { slug, removed: true }, text: `Removed ${slug}.` }
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export const localModelDefault: Command = {
|
|
221
|
+
meta: {
|
|
222
|
+
noun: 'local',
|
|
223
|
+
verb: 'model-default',
|
|
224
|
+
args: [{ name: 'slug', required: true, description: 'the catalog slug' }],
|
|
225
|
+
flags: { for: 'string' },
|
|
226
|
+
summary: 'Choose the model new Agents start with and the one that grades tests',
|
|
227
|
+
examples: ['frontera local model-default claude-sonnet-5-5', 'frontera local model-default gpt-6-luna --for tests'],
|
|
228
|
+
offline: true,
|
|
229
|
+
internal: true,
|
|
230
|
+
},
|
|
231
|
+
async run(ctx) {
|
|
232
|
+
const slug = ctx.positional[0] ?? ''
|
|
233
|
+
if (!slug) throw new UsageError('name the model', 'frontera local model-default <slug>')
|
|
234
|
+
const which = flagString(ctx, 'for') ?? 'both'
|
|
235
|
+
if (!['agents', 'tests', 'both'].includes(which)) throw new UsageError(`--for ${which}?`, 'use agents, tests or both')
|
|
236
|
+
const setup = readProviderSetup()
|
|
237
|
+
if (which !== 'tests') setup.defaults.agentModel = slug
|
|
238
|
+
if (which !== 'agents') setup.defaults.judgeModel = slug
|
|
239
|
+
const result = await applyNow(ctx, setup)
|
|
240
|
+
return {
|
|
241
|
+
data: { ...setup.defaults, applied: result.applied, errors: result.errors },
|
|
242
|
+
text: `${which === 'tests' ? 'Tests are' : which === 'agents' ? 'New Agents are' : 'New Agents and tests are'} now on ${slug}.${afterward(result)}`,
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { CliError, UsageError } from '../../errors'
|
|
2
|
+
import { localProject } from '../../local/local-project'
|
|
3
|
+
import { cliSchema, isSchemaKind, resolveSchema, runtimeSource, SCHEMA_FILES, SCHEMA_KINDS } from '../../local/local-schemas'
|
|
4
|
+
import type { Command } from '../types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `frontera local schema <kind>`: the exact format of one kind of project file,
|
|
8
|
+
* as JSON Schema, for the coding agents and editors working in the project.
|
|
9
|
+
* The guides describe the product; this is the contract a file is checked
|
|
10
|
+
* against, for the release the local runtime runs. Without a kind it lists
|
|
11
|
+
* the kinds and the files each describes.
|
|
12
|
+
*/
|
|
13
|
+
export const localSchema: Command = {
|
|
14
|
+
meta: {
|
|
15
|
+
noun: 'local',
|
|
16
|
+
verb: 'schema',
|
|
17
|
+
args: [{ name: 'kind', required: false, description: SCHEMA_KINDS.join(' | ') }],
|
|
18
|
+
flags: {},
|
|
19
|
+
summary: 'Print the JSON Schema of a project file (operating, agent, workflow, evals, dataset) for the runtime\'s release',
|
|
20
|
+
examples: ['frontera local schema', 'frontera local schema operating', "frontera local schema workflow | jq '.\"$defs\" | keys'"],
|
|
21
|
+
offline: true,
|
|
22
|
+
internal: true,
|
|
23
|
+
},
|
|
24
|
+
async run(ctx) {
|
|
25
|
+
const kind = ctx.positional[0]
|
|
26
|
+
if (!kind) {
|
|
27
|
+
const kinds = SCHEMA_KINDS.map((k) => ({ kind: k, file: SCHEMA_FILES[k], from: cliSchema(k) ? 'cli' : 'service' }))
|
|
28
|
+
return {
|
|
29
|
+
data: kinds,
|
|
30
|
+
text: [
|
|
31
|
+
'Project file formats, as JSON Schema: `frontera local schema <kind>`.',
|
|
32
|
+
...kinds.map((k) => ` ${k.kind.padEnd(10)} ${k.file}`),
|
|
33
|
+
'`frontera local start` keeps them in .frontera/local/schemas/<kind>.schema.json.',
|
|
34
|
+
].join('\n'),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (!isSchemaKind(kind)) {
|
|
38
|
+
throw new UsageError(`unknown file kind \`${kind}\``, `use one of: ${SCHEMA_KINDS.join(', ')}`)
|
|
39
|
+
}
|
|
40
|
+
const project = localProject(ctx.cwd)
|
|
41
|
+
const answer = await resolveSchema(project, kind, runtimeSource(project))
|
|
42
|
+
if (!answer.ok) throw new CliError(answer.reason, { code: 'LOCAL_SCHEMA_UNAVAILABLE', hint: answer.hint })
|
|
43
|
+
if (answer.note) ctx.output.note(answer.note)
|
|
44
|
+
return { data: answer.schema, text: JSON.stringify(answer.schema, null, 2) }
|
|
45
|
+
},
|
|
46
|
+
}
|
package/src/commands/registry.ts
CHANGED
|
@@ -46,6 +46,7 @@ import { authCommands } from './auth/index-commands'
|
|
|
46
46
|
import { kitCommands } from './kit/index-commands'
|
|
47
47
|
import { workspaceCommands } from './workspace/index-commands'
|
|
48
48
|
import { forgeCommands } from './forge/index-commands'
|
|
49
|
+
import { localCommands } from './local/index-commands'
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
52
|
* Flags every command accepts. Declared once so a caller never has to wonder
|
|
@@ -86,6 +87,7 @@ export const COMMANDS: readonly Command[] = [
|
|
|
86
87
|
...kitCommands,
|
|
87
88
|
...workspaceCommands,
|
|
88
89
|
...forgeCommands,
|
|
90
|
+
...localCommands,
|
|
89
91
|
|
|
90
92
|
appInit,
|
|
91
93
|
appList,
|
|
@@ -149,8 +151,8 @@ export function findCommand(noun: string, verb: string | undefined): Command | n
|
|
|
149
151
|
)
|
|
150
152
|
}
|
|
151
153
|
|
|
152
|
-
export function nouns(): string[] {
|
|
153
|
-
return [...new Set(COMMANDS.map((c) => c.meta.noun))]
|
|
154
|
+
export function nouns(includeInternal = false): string[] {
|
|
155
|
+
return [...new Set(COMMANDS.filter((c) => includeInternal || !c.meta.internal).map((c) => c.meta.noun))]
|
|
154
156
|
}
|
|
155
157
|
|
|
156
158
|
/**
|
|
@@ -307,7 +309,7 @@ export function renderHelp(): string {
|
|
|
307
309
|
|
|
308
310
|
// Setup first and ungrouped: these are the two commands someone runs before
|
|
309
311
|
// any noun makes sense.
|
|
310
|
-
const setup = COMMANDS.filter((c) => c.meta.verb === '')
|
|
312
|
+
const setup = COMMANDS.filter((c) => c.meta.verb === '' && !c.meta.internal)
|
|
311
313
|
if (setup.length > 0) {
|
|
312
314
|
lines.push('', heading('Getting started'))
|
|
313
315
|
lines.push(
|
|
@@ -514,7 +516,7 @@ export function describeCommand(meta: CommandMeta): CommandDescriptor {
|
|
|
514
516
|
}
|
|
515
517
|
|
|
516
518
|
export function describeCommands(noun?: string): CommandDescriptor[] {
|
|
517
|
-
return COMMANDS.filter((c) =>
|
|
519
|
+
return COMMANDS.filter((c) => (noun ? c.meta.noun === noun : !c.meta.internal)).map((c) => describeCommand(c.meta))
|
|
518
520
|
}
|
|
519
521
|
|
|
520
522
|
/** Levenshtein distance, iterative with one row — enough for a typo check. */
|
package/src/commands/types.ts
CHANGED
|
@@ -81,6 +81,13 @@ export interface CommandMeta {
|
|
|
81
81
|
* reason picks another route.
|
|
82
82
|
*/
|
|
83
83
|
reserved?: string
|
|
84
|
+
/**
|
|
85
|
+
* Internal FDE tooling. It needs Frontera's private platform source or
|
|
86
|
+
* images, so it is left out of the top-level help, `help --json` and the
|
|
87
|
+
* CLI reference generated from it (which customers read). `frontera <noun>
|
|
88
|
+
* --help` and the commands themselves still work for anyone who knows them.
|
|
89
|
+
*/
|
|
90
|
+
internal?: boolean
|
|
84
91
|
}
|
|
85
92
|
|
|
86
93
|
export interface CommandContext {
|
package/src/flag-help.ts
CHANGED
|
@@ -58,6 +58,9 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
|
58
58
|
+ 'and naming none is refused rather than guessed',
|
|
59
59
|
output: 'project-relative .ts path for generated output',
|
|
60
60
|
check: 'verify generated output is current without writing it',
|
|
61
|
+
'base-url': "OpenAI-compatible endpoint the provider's models are reached at, e.g. https://llm.acme.example/v1",
|
|
62
|
+
slug: 'id the runtime gives the model, used to choose it as a default; derived from its name when left out',
|
|
63
|
+
for: '"agents" (new Agents), "tests" (grading tests) or "both" (default)',
|
|
61
64
|
|
|
62
65
|
// Forge daemon
|
|
63
66
|
origin: 'browser origin allowed by CORS — the platform web origin; also FRONTERA_FORGE_ORIGIN',
|
|
@@ -73,6 +76,21 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
|
73
76
|
'refuse the write unless the resource is still at this revision; exit 3 if it moved',
|
|
74
77
|
notes: 'note recorded against the published version',
|
|
75
78
|
|
|
79
|
+
// Local runtime (internal FDE tooling)
|
|
80
|
+
source: 'platform checkout to run the local runtime from (default: the one this CLI runs from)',
|
|
81
|
+
image: 'run the local runtime from release images instead of a platform checkout',
|
|
82
|
+
exclude: 'optional components to leave out of the local runtime: jobs, runner, storage',
|
|
83
|
+
'no-backup': 'also delete the local database and secrets',
|
|
84
|
+
follow: 'keep printing new log lines as they are written',
|
|
85
|
+
lines: 'how many of the latest log lines to print (default 100)',
|
|
86
|
+
stdin: 'read the value from standard input instead of a hidden prompt',
|
|
87
|
+
live: 'test the published Agent instead of its draft',
|
|
88
|
+
once: 'apply the solution once and exit instead of watching for changes',
|
|
89
|
+
'no-publish': 'keep Blueprint changes as a draft instead of publishing them to the local workspace',
|
|
90
|
+
registry: 'the registry host and namespace image mode pulls the platform images from, such as registry.example.com/frontera',
|
|
91
|
+
reapply: 'apply every file again, including those unchanged since they were last applied',
|
|
92
|
+
stop: 'stop the local runtime when the check ends',
|
|
93
|
+
|
|
76
94
|
// Apps
|
|
77
95
|
framework: 'App scaffold to create: next (default) or react (legacy Vite)',
|
|
78
96
|
port: 'loopback port to listen on — the App development server, or the Forge daemon (0: any free port)',
|
package/src/harness.ts
CHANGED
|
@@ -269,7 +269,7 @@ or click through when verifying your work — a screenshot of \`/\` is a screens
|
|
|
269
269
|
of the refusal panel.
|
|
270
270
|
`
|
|
271
271
|
|
|
272
|
-
const AGENTS_MD = `# Frontera
|
|
272
|
+
export const AGENTS_MD = `# Frontera
|
|
273
273
|
|
|
274
274
|
This directory works with the Frontera platform through the \`frontera\` CLI.
|
|
275
275
|
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { contentHash, loadHashes, saveHashes } from './local-applied'
|
|
6
|
+
import { localProfileName } from './local-control'
|
|
7
|
+
import { targetFor } from './local-dev-sync'
|
|
8
|
+
import type { LocalProject } from './local-project'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Studio's editor, the counterpart of Mastra Studio's: a change made in the
|
|
12
|
+
* browser is written into the project's `agents/<slug>/agent.json` and then
|
|
13
|
+
* applied, so the files stay the source and a commit carries the change.
|
|
14
|
+
* Only the fields Studio offers are touched; the rest of the file is kept.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface AgentFileEdit {
|
|
18
|
+
description?: string
|
|
19
|
+
/** Instruction prompts by filename. */
|
|
20
|
+
prompts?: Array<{ filename: string; content: string }>
|
|
21
|
+
/** Model per mode, and its reasoning effort (null: the model's default). */
|
|
22
|
+
models?: Array<{ modeKey: string; modelSlug: string; reasoningEffort?: string | null }>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The efforts the chat runtime accepts (the service's reasoningEffortEnum). */
|
|
26
|
+
export const REASONING_EFFORTS = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const
|
|
27
|
+
|
|
28
|
+
export class AgentFileError extends Error {
|
|
29
|
+
constructor(
|
|
30
|
+
message: string,
|
|
31
|
+
readonly status: number,
|
|
32
|
+
) {
|
|
33
|
+
super(message)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SLUG = /^[a-z0-9][a-z0-9-]*$/
|
|
38
|
+
|
|
39
|
+
export function agentFilePath(root: string, slug: string): string {
|
|
40
|
+
if (!SLUG.test(slug)) throw new AgentFileError(`"${slug}" is not an Agent slug`, 400)
|
|
41
|
+
return join(root, 'agents', slug, 'agent.json')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Apply an edit to the file's document; returns the new text, or null when nothing changed. */
|
|
45
|
+
export function editAgentDocument(text: string, edit: AgentFileEdit): string | null {
|
|
46
|
+
const doc = JSON.parse(text) as {
|
|
47
|
+
config?: Record<string, unknown>
|
|
48
|
+
prompts?: Array<{ filename?: string; content?: string }>
|
|
49
|
+
modelConfigs?: Array<{ modeKey?: string; modelSlug?: string; reasoningEffort?: string | null }>
|
|
50
|
+
}
|
|
51
|
+
if (edit.description !== undefined) {
|
|
52
|
+
doc.config = { ...(doc.config ?? {}), description: edit.description }
|
|
53
|
+
}
|
|
54
|
+
for (const p of edit.prompts ?? []) {
|
|
55
|
+
const prompt = doc.prompts?.find((x) => x.filename === p.filename)
|
|
56
|
+
if (!prompt) throw new AgentFileError(`agent.json has no prompt named ${p.filename}`, 400)
|
|
57
|
+
prompt.content = p.content
|
|
58
|
+
}
|
|
59
|
+
for (const m of edit.models ?? []) {
|
|
60
|
+
const mode = doc.modelConfigs?.find((x) => x.modeKey === m.modeKey)
|
|
61
|
+
if (!mode) throw new AgentFileError(`agent.json has no model mode ${m.modeKey}`, 400)
|
|
62
|
+
if (!m.modelSlug.trim()) throw new AgentFileError(`mode ${m.modeKey} needs a model`, 400)
|
|
63
|
+
mode.modelSlug = m.modelSlug.trim()
|
|
64
|
+
if (m.reasoningEffort !== undefined) {
|
|
65
|
+
if (m.reasoningEffort === null) delete mode.reasoningEffort
|
|
66
|
+
else if ((REASONING_EFFORTS as readonly string[]).includes(m.reasoningEffort)) mode.reasoningEffort = m.reasoningEffort
|
|
67
|
+
else throw new AgentFileError(`"${m.reasoningEffort}" is not a reasoning effort`, 400)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const next = `${JSON.stringify(doc, null, 2)}\n`
|
|
71
|
+
return next === text ? null : next
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function applyWithCli(project: LocalProject, slug: string, file: string): Promise<{ ok: boolean; output: string }> {
|
|
75
|
+
const compiled = Bun.main.startsWith('/$bunfs') || Bun.main.startsWith('B:\\~BUN')
|
|
76
|
+
const self = compiled ? [process.execPath] : [process.execPath, Bun.main]
|
|
77
|
+
const args = ['agent', 'apply', slug, '-f', file, '--profile', localProfileName(project)]
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
const child = spawn('nice', ['-n', '10', ...self, ...args], { cwd: project.root, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, NO_COLOR: '1' } })
|
|
80
|
+
let output = ''
|
|
81
|
+
child.stdout.on('data', (c) => (output += c))
|
|
82
|
+
child.stderr.on('data', (c) => (output += c))
|
|
83
|
+
child.on('error', (err) => resolve({ ok: false, output: err.message }))
|
|
84
|
+
child.on('close', (code) => resolve({ ok: code === 0, output }))
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Write the edit into the file, then apply it as `local dev` would, and record
|
|
90
|
+
* it as applied so a running `local dev` does not apply it a second time.
|
|
91
|
+
*/
|
|
92
|
+
export async function saveAgentEdit(
|
|
93
|
+
project: LocalProject,
|
|
94
|
+
organizationId: string,
|
|
95
|
+
slug: string,
|
|
96
|
+
edit: AgentFileEdit,
|
|
97
|
+
): Promise<{ path: string; changed: boolean; applied: boolean; output: string }> {
|
|
98
|
+
const path = agentFilePath(project.root, slug)
|
|
99
|
+
const rel = join('agents', slug, 'agent.json')
|
|
100
|
+
if (!existsSync(path)) {
|
|
101
|
+
throw new AgentFileError(`${rel} does not exist. Studio edits the project's files; this Agent has none.`, 404)
|
|
102
|
+
}
|
|
103
|
+
let next: string | null
|
|
104
|
+
try {
|
|
105
|
+
next = editAgentDocument(readFileSync(path, 'utf8'), edit)
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (err instanceof AgentFileError) throw err
|
|
108
|
+
throw new AgentFileError(`${rel} could not be read: ${(err as Error).message}`, 422)
|
|
109
|
+
}
|
|
110
|
+
if (next === null) return { path: rel, changed: false, applied: true, output: '' }
|
|
111
|
+
writeFileSync(path, next)
|
|
112
|
+
const result = await applyWithCli(project, slug, rel)
|
|
113
|
+
if (result.ok) {
|
|
114
|
+
const target = targetFor(project.root, path)
|
|
115
|
+
if (target) {
|
|
116
|
+
const applied = loadHashes(project, organizationId)
|
|
117
|
+
applied.set(target.key, contentHash(project, target))
|
|
118
|
+
saveHashes(project, organizationId, applied)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { path: rel, changed: true, applied: result.ok, output: result.output.trim().split('\n').slice(0, 6).join('\n') }
|
|
122
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { lstatSync, readdirSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { join, relative } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { writeFileAtomic } from '../fs-atomic'
|
|
6
|
+
import { allTargets, describeTarget, datasetDocument, isIgnoredName, type SyncTarget } from './local-dev-sync'
|
|
7
|
+
import type { LocalProject } from './local-project'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What `frontera local dev` has applied to this runtime, by content hash. It
|
|
11
|
+
* lets a new `local dev` skip files that have not changed (instead of piling up
|
|
12
|
+
* workflow and Function versions), and lets Studio show which files the
|
|
13
|
+
* runtime is behind on. Kept per local organization: after a reset the runtime
|
|
14
|
+
* is empty, and every file counts as not applied.
|
|
15
|
+
*
|
|
16
|
+
* Hashing never throws: editors leave lock files and dangling symlinks, files
|
|
17
|
+
* vanish between a listing and a read, and none of that may stop `local dev`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** A file's digest, cached by size and modification time so Studio's polling stays cheap. */
|
|
21
|
+
const digests = new Map<string, { size: number; mtimeMs: number; digest: string }>()
|
|
22
|
+
|
|
23
|
+
function fileDigest(path: string): string | null {
|
|
24
|
+
try {
|
|
25
|
+
const stat = lstatSync(path)
|
|
26
|
+
if (!stat.isFile()) return null
|
|
27
|
+
const cached = digests.get(path)
|
|
28
|
+
if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.digest
|
|
29
|
+
const digest = createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
30
|
+
digests.set(path, { size: stat.size, mtimeMs: stat.mtimeMs, digest })
|
|
31
|
+
return digest
|
|
32
|
+
} catch {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Every regular file under a directory, by path and digest, in a stable order. Symlinks and dotfiles are skipped. */
|
|
38
|
+
function treeHash(dir: string): string | null {
|
|
39
|
+
const hash = createHash('sha256')
|
|
40
|
+
let any = false
|
|
41
|
+
const walk = (at: string, depth: number) => {
|
|
42
|
+
if (depth > 8) return
|
|
43
|
+
let names: string[]
|
|
44
|
+
try {
|
|
45
|
+
names = readdirSync(at).sort()
|
|
46
|
+
} catch {
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
for (const name of names) {
|
|
50
|
+
if (isIgnoredName(name) || name === 'node_modules') continue
|
|
51
|
+
const path = join(at, name)
|
|
52
|
+
let stat
|
|
53
|
+
try {
|
|
54
|
+
stat = lstatSync(path)
|
|
55
|
+
} catch {
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
if (stat.isDirectory()) walk(path, depth + 1)
|
|
59
|
+
else if (stat.isFile()) {
|
|
60
|
+
const digest = fileDigest(path)
|
|
61
|
+
if (!digest) continue
|
|
62
|
+
any = true
|
|
63
|
+
hash.update(relative(dir, path)).update('\0').update(digest).update('\0')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
walk(dir, 0)
|
|
68
|
+
return any ? hash.digest('hex') : null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function contentHash(project: LocalProject, target: SyncTarget): string | null {
|
|
72
|
+
// The Blueprint is applied as one tree, so it changes when any file in it does.
|
|
73
|
+
if (target.kind === 'blueprint') return treeHash(join(project.root, 'blueprint'))
|
|
74
|
+
const own = fileDigest(join(project.root, target.file))
|
|
75
|
+
if (!own) return null
|
|
76
|
+
const hash = createHash('sha256').update(own)
|
|
77
|
+
// A dataset is its definition and its rows.
|
|
78
|
+
if (target.kind === 'dataset') {
|
|
79
|
+
const csv = datasetDocument(project.root, target)?.csv
|
|
80
|
+
const rows = csv ? fileDigest(join(project.root, csv)) : null
|
|
81
|
+
if (rows) hash.update(rows)
|
|
82
|
+
}
|
|
83
|
+
// A Function is bundled with what it imports: the other files in functions/ and the vendored SDK.
|
|
84
|
+
if (target.kind === 'function') {
|
|
85
|
+
hash.update(treeHash(join(project.root, 'functions')) ?? '').update(treeHash(join(project.root, 'vendor')) ?? '')
|
|
86
|
+
}
|
|
87
|
+
return hash.digest('hex')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function hashesPath(project: LocalProject): string {
|
|
91
|
+
return join(project.stateDir, 'applied-hashes.json')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function loadHashes(project: LocalProject, organizationId: string): Map<string, string | null> {
|
|
95
|
+
try {
|
|
96
|
+
const saved = JSON.parse(readFileSync(hashesPath(project), 'utf8')) as { organizationId?: string; hashes?: Record<string, string> }
|
|
97
|
+
if (saved.organizationId === organizationId && saved.hashes) return new Map(Object.entries(saved.hashes))
|
|
98
|
+
} catch {
|
|
99
|
+
// Missing or unreadable: nothing counts as applied.
|
|
100
|
+
}
|
|
101
|
+
return new Map()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function saveHashes(project: LocalProject, organizationId: string, applied: Map<string, string | null>): void {
|
|
105
|
+
const hashes = Object.fromEntries([...applied].filter((e): e is [string, string] => typeof e[1] === 'string'))
|
|
106
|
+
try {
|
|
107
|
+
writeFileAtomic(hashesPath(project), `${JSON.stringify({ organizationId, hashes })}\n`, 0o600)
|
|
108
|
+
} catch {
|
|
109
|
+
// Losing this only means the next start applies everything again.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface SolutionItem {
|
|
114
|
+
kind: SyncTarget['kind']
|
|
115
|
+
name: string
|
|
116
|
+
/** Path in the solution (the Blueprint is its folder). */
|
|
117
|
+
path: string
|
|
118
|
+
/** `applied`: the runtime has this content; `changed`: edited since; `new`: never applied here. */
|
|
119
|
+
state: 'applied' | 'changed' | 'new'
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Each file `local dev` applies, and whether the runtime has its current content. */
|
|
123
|
+
export function solutionStatus(project: LocalProject, organizationId: string | null): SolutionItem[] {
|
|
124
|
+
const applied = organizationId ? loadHashes(project, organizationId) : new Map<string, string | null>()
|
|
125
|
+
return allTargets(project.root).map((target) => {
|
|
126
|
+
const was = applied.get(target.key)
|
|
127
|
+
const now = contentHash(project, target)
|
|
128
|
+
return {
|
|
129
|
+
kind: target.kind,
|
|
130
|
+
name: describeTarget(target),
|
|
131
|
+
path: target.kind === 'blueprint' ? 'blueprint/' : target.file,
|
|
132
|
+
state: was === undefined ? 'new' : was === now ? 'applied' : 'changed',
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
}
|