@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,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
+ }
@@ -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) => !noun || c.meta.noun === noun).map((c) => describeCommand(c.meta))
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. */
@@ -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
+ }