@frontera-sdk/cli 1.52.22 → 1.52.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,301 @@
1
+ import { existsSync, readFileSync, rmSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { writeFileAtomic } from '../fs-atomic'
5
+ import type { LocalProject } from './local-project'
6
+ import { isAlive, readState } from './local-state'
7
+
8
+ /**
9
+ * The formats of a project's files, as JSON Schema, for the coding agents and
10
+ * editors working in it (`frontera local schema <kind>`).
11
+ *
12
+ * The service checks three of them, so their schemas come from the service
13
+ * the runtime runs (`GET /v1/config/file-schemas/<kind>`), made from the same
14
+ * zod schemas that check the files. The other two only this CLI reads, so it
15
+ * describes them itself, beside the code that reads them. `local start` writes
16
+ * all five into `.frontera/local/schemas/` once the service is up, so they can
17
+ * be read while the runtime is stopped and always describe the release it ran.
18
+ */
19
+
20
+ export const SCHEMA_KINDS = ['operating', 'agent', 'workflow', 'evals', 'dataset'] as const
21
+ export type SchemaKind = (typeof SCHEMA_KINDS)[number]
22
+
23
+ /** Where each kind of file lives in a project. */
24
+ export const SCHEMA_FILES: Record<SchemaKind, string> = {
25
+ operating: 'agents/<slug>/operating.json',
26
+ agent: 'agents/<slug>/agent.json',
27
+ workflow: 'workflows/<slug>.yaml',
28
+ evals: 'agents/<slug>/evals/*.yaml',
29
+ dataset: 'datasets/<apiName>.json',
30
+ }
31
+
32
+ export function isSchemaKind(value: string): value is SchemaKind {
33
+ return (SCHEMA_KINDS as readonly string[]).includes(value)
34
+ }
35
+
36
+ const DRAFT = 'https://json-schema.org/draft/2020-12/schema'
37
+
38
+ type JsonSchema = Record<string, unknown>
39
+
40
+ /** `agents/<slug>/evals/*.yaml`, as `local test` and Studio read it (`local-test-runner.ts`). */
41
+ const EVALS_SCHEMA: JsonSchema = {
42
+ $schema: DRAFT,
43
+ title: 'Agent test cases (agents/<slug>/evals/*.yaml)',
44
+ description:
45
+ "`frontera local test` and Studio's Evaluations run each case on the Agent's draft (`--live`: its live version): a new conversation, the user turns sent in order, then the last reply checked.",
46
+ type: 'object',
47
+ properties: {
48
+ cases: {
49
+ type: 'array',
50
+ items: {
51
+ type: 'object',
52
+ properties: {
53
+ name: { type: 'string', minLength: 1, description: 'Names the case in results.' },
54
+ conversation: {
55
+ type: 'array',
56
+ minItems: 1,
57
+ description: 'One turn per item. Only the user turns are sent; an assistant turn is kept for whoever reads the case.',
58
+ items: {
59
+ type: 'object',
60
+ properties: { user: { type: 'string' }, assistant: { type: 'string' } },
61
+ minProperties: 1,
62
+ maxProperties: 1,
63
+ additionalProperties: false,
64
+ },
65
+ },
66
+ expected: { type: 'string', description: 'The last reply, word for word; differences in whitespace are ignored.' },
67
+ expectedContains: {
68
+ type: 'array',
69
+ minItems: 1,
70
+ items: { type: 'string', minLength: 1 },
71
+ description: 'Phrases the last reply must contain, ignoring case. When present, expected is not checked.',
72
+ },
73
+ savedFrom: { type: 'string', description: 'Where the case was saved from; Studio writes it.' },
74
+ },
75
+ required: ['name', 'conversation'],
76
+ anyOf: [{ required: ['expected'] }, { required: ['expectedContains'] }],
77
+ additionalProperties: false,
78
+ },
79
+ },
80
+ },
81
+ required: ['cases'],
82
+ additionalProperties: false,
83
+ }
84
+
85
+ /** The column types `dataset create` accepts (`commands/dataset/index-commands.ts`). */
86
+ const COLUMN_TYPES = ['text', 'numeric', 'boolean', 'date', 'timestamptz']
87
+
88
+ const COLUMN_NAMES = {
89
+ type: 'array',
90
+ minItems: 1,
91
+ maxItems: 256,
92
+ items: { type: 'string', minLength: 1 },
93
+ description: 'The columns to load, by name.',
94
+ }
95
+
96
+ /** Every kind of dataset definition reads these. */
97
+ const DATASET_COMMON = {
98
+ apiName: {
99
+ type: 'string',
100
+ minLength: 1,
101
+ maxLength: 100,
102
+ description: "How an object type's backing.dataset and `frontera blueprint bind` name the dataset.",
103
+ },
104
+ displayName: { type: 'string', minLength: 1, maxLength: 200, description: 'Defaults to apiName.' },
105
+ description: { type: 'string', maxLength: 4000 },
106
+ keyColumns: {
107
+ anyOf: [{ type: 'array', minItems: 1, maxItems: 16, items: { type: 'string', minLength: 1 } }, { type: 'null' }],
108
+ description: 'The columns that identify a row, or null when none does.',
109
+ },
110
+ deterministicKeyConfirmed: {
111
+ type: 'boolean',
112
+ description: 'Confirms that keyColumns identify every row uniquely. Ignored without keyColumns.',
113
+ },
114
+ }
115
+
116
+ /** `datasets/<apiName>.json`, as `dataset create` reads it (`commands/dataset/index-commands.ts`). */
117
+ const DATASET_SCHEMA: JsonSchema = {
118
+ $schema: DRAFT,
119
+ title: 'Dataset definition (datasets/<apiName>.json)',
120
+ description:
121
+ 'Data the Blueprint reads. kind says where the columns come from: blank declares them, file reads a CSV, source pulls a relation from a connected Source. A key another kind reads is refused.',
122
+ type: 'object',
123
+ oneOf: [
124
+ {
125
+ title: 'blank',
126
+ type: 'object',
127
+ properties: {
128
+ ...DATASET_COMMON,
129
+ kind: { const: 'blank', description: 'The default: columns only, no rows.' },
130
+ columns: {
131
+ type: 'array',
132
+ minItems: 1,
133
+ items: {
134
+ type: 'object',
135
+ properties: {
136
+ name: { type: 'string', pattern: '^[a-z_][a-z0-9_]*$' },
137
+ databaseType: { type: 'string', enum: COLUMN_TYPES },
138
+ nullable: {
139
+ type: 'boolean',
140
+ description: 'Whether the column may hold nulls. A required Blueprint property may not read a nullable column that holds them.',
141
+ },
142
+ },
143
+ required: ['name', 'databaseType', 'nullable'],
144
+ additionalProperties: false,
145
+ },
146
+ },
147
+ },
148
+ required: ['apiName', 'columns'],
149
+ additionalProperties: false,
150
+ },
151
+ {
152
+ title: 'file',
153
+ type: 'object',
154
+ properties: {
155
+ ...DATASET_COMMON,
156
+ kind: { const: 'file' },
157
+ path: { type: 'string', minLength: 1, description: 'The CSV, relative to this file.' },
158
+ includedColumns: COLUMN_NAMES,
159
+ },
160
+ required: ['apiName', 'kind', 'path', 'includedColumns'],
161
+ additionalProperties: false,
162
+ },
163
+ {
164
+ title: 'source',
165
+ type: 'object',
166
+ properties: {
167
+ ...DATASET_COMMON,
168
+ kind: { const: 'source' },
169
+ source: { type: 'string', minLength: 1, description: 'The connected Source, by its display name.' },
170
+ schema: { type: 'string', minLength: 1 },
171
+ relation: { type: 'string', minLength: 1 },
172
+ includedColumns: COLUMN_NAMES,
173
+ },
174
+ required: ['apiName', 'kind', 'source', 'schema', 'relation', 'includedColumns'],
175
+ additionalProperties: false,
176
+ },
177
+ ],
178
+ }
179
+
180
+ /** The schema this CLI owns for a kind, or null when the service owns it. */
181
+ export function cliSchema(kind: SchemaKind): JsonSchema | null {
182
+ if (kind === 'evals') return EVALS_SCHEMA
183
+ if (kind === 'dataset') return DATASET_SCHEMA
184
+ return null
185
+ }
186
+
187
+ export function schemasDir(project: LocalProject): string {
188
+ return join(project.stateDir, 'schemas')
189
+ }
190
+
191
+ export function schemaPath(project: LocalProject, kind: SchemaKind): string {
192
+ return join(schemasDir(project), `${kind}.schema.json`)
193
+ }
194
+
195
+ /** How to reach the running runtime's service: its URL and a credential it accepts. */
196
+ export interface SchemaSource {
197
+ serviceUrl: string
198
+ token: string
199
+ }
200
+
201
+ /** The running runtime, or null when it is stopped, still starting, or not seeded. */
202
+ export function runtimeSource(project: LocalProject): SchemaSource | null {
203
+ const state = readState(project)
204
+ if (!state || state.phase !== 'ready' || !state.supervisorPid || !isAlive(state.supervisorPid)) return null
205
+ const token = state.credentials?.workspaceKey ?? state.credentials?.session
206
+ return token ? { serviceUrl: `http://127.0.0.1:${state.ports.service}`, token } : null
207
+ }
208
+
209
+ type Fetched = { ok: true; schema: JsonSchema } | { ok: false; status: number; reason: string }
210
+
211
+ /** One kind's schema from the runtime's service. */
212
+ export async function fetchServiceSchema(source: SchemaSource, kind: SchemaKind): Promise<Fetched> {
213
+ try {
214
+ const res = await fetch(new URL(`/v1/config/file-schemas/${kind}`, source.serviceUrl), {
215
+ headers: { authorization: `Bearer ${source.token}` },
216
+ signal: AbortSignal.timeout(10_000),
217
+ })
218
+ const text = await res.text()
219
+ let body: { data?: { schema?: unknown }; message?: string } | null = null
220
+ try {
221
+ body = text ? JSON.parse(text) : null
222
+ } catch {
223
+ // Not JSON: reported by status below.
224
+ }
225
+ const schema = body?.data?.schema
226
+ if (res.ok && schema && typeof schema === 'object') return { ok: true, schema: schema as JsonSchema }
227
+ if (res.status === 404) return { ok: false, status: 404, reason: "the runtime's service does not describe its files (it predates file formats)" }
228
+ return { ok: false, status: res.status, reason: `the service answered ${res.status}${body?.message ? `: ${body.message}` : ''}` }
229
+ } catch (err) {
230
+ return { ok: false, status: 0, reason: `the service did not answer: ${(err as Error).message}` }
231
+ }
232
+ }
233
+
234
+ /** A schema `local start` kept, or null. */
235
+ export function readKeptSchema(project: LocalProject, kind: SchemaKind): JsonSchema | null {
236
+ const path = schemaPath(project, kind)
237
+ if (!existsSync(path)) return null
238
+ try {
239
+ const value = JSON.parse(readFileSync(path, 'utf8')) as unknown
240
+ return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonSchema) : null
241
+ } catch {
242
+ return null
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Write every kind's schema into the project's state, for the release the
248
+ * runtime runs. Returns what could not be written; never throws, so it cannot
249
+ * fail a start. A kind the service did not answer for is removed rather than
250
+ * kept, so a file never describes a release other than the one that ran.
251
+ */
252
+ export async function keepSchemas(project: LocalProject, source: SchemaSource): Promise<string[]> {
253
+ const problems: string[] = []
254
+ for (const kind of SCHEMA_KINDS) {
255
+ try {
256
+ let schema = cliSchema(kind)
257
+ if (!schema) {
258
+ const fetched = await fetchServiceSchema(source, kind)
259
+ if (fetched.ok) schema = fetched.schema
260
+ else problems.push(`${kind}: ${fetched.reason}`)
261
+ }
262
+ if (schema) writeFileAtomic(schemaPath(project, kind), `${JSON.stringify(schema, null, 2)}\n`, 0o644)
263
+ else rmSync(schemaPath(project, kind), { force: true })
264
+ } catch (err) {
265
+ problems.push(`${kind}: ${(err as Error).message}`)
266
+ }
267
+ }
268
+ return problems
269
+ }
270
+
271
+ export type SchemaAnswer =
272
+ | { ok: true; schema: JsonSchema; from: 'cli' | 'runtime' | 'kept'; note?: string }
273
+ | { ok: false; reason: string; hint: string }
274
+
275
+ /**
276
+ * The schema for a kind: the CLI's own, or the running runtime's, or, with the
277
+ * runtime stopped or not answering, the one its last start kept.
278
+ */
279
+ export async function resolveSchema(project: LocalProject, kind: SchemaKind, source: SchemaSource | null): Promise<SchemaAnswer> {
280
+ const own = cliSchema(kind)
281
+ if (own) return { ok: true, schema: own, from: 'cli' }
282
+ const kept = readKeptSchema(project, kind)
283
+ if (!source) {
284
+ if (kept) return { ok: true, schema: kept, from: 'kept', note: 'The local runtime is not running: this is the format of the release it ran last.' }
285
+ return {
286
+ ok: false,
287
+ reason: `no ${kind} format here yet: the local runtime is not running, and no start has kept one`,
288
+ hint: 'run `frontera local start` once; it keeps the formats of the release it runs in .frontera/local/schemas/',
289
+ }
290
+ }
291
+ const fetched = await fetchServiceSchema(source, kind)
292
+ if (fetched.ok) return { ok: true, schema: fetched.schema, from: 'runtime' }
293
+ if (kept) return { ok: true, schema: kept, from: 'kept', note: `The local runtime did not answer (${fetched.reason}): this is the format its last start kept.` }
294
+ return {
295
+ ok: false,
296
+ reason: `the ${kind} format is not available: ${fetched.reason}`,
297
+ hint: fetched.status === 404
298
+ ? 'restart the runtime so it runs a service that does: `frontera local stop`, then `frontera local start`'
299
+ : 'see `frontera local logs service`, or `frontera local status`',
300
+ }
301
+ }
@@ -0,0 +1,332 @@
1
+ import { appendFileSync } from 'node:fs'
2
+
3
+ import { findProvider, getKey, keylessEmbeddings, LOCAL_PROVIDERS } from './local-keys'
4
+ import type { LocalCredentials } from './local-state'
5
+
6
+ /**
7
+ * Seed a fresh local runtime over its own HTTP API.
8
+ *
9
+ * The same sequence a person goes through, so the organization gets its roles,
10
+ * its `General` workspace and its Blueprint Steward from the service's own
11
+ * bootstrap instead of rows written behind its back (which is why the
12
+ * database-level seed scripts are not used here):
13
+ *
14
+ * sign up → sign in → create organization (bootstraps the workspace)
15
+ * → workspace key (`sk-ws-`, `mcp` scope) → organization key (`sk-org-`)
16
+ *
17
+ * Model keys are applied on every start, not only at seed time, because an
18
+ * organization's model calls read only the organization's own stored
19
+ * credential — never the process environment — and an FDE may have added a
20
+ * key since the last start.
21
+ */
22
+
23
+ export const OWNER_EMAIL = 'owner@frontera.local'
24
+
25
+ interface SeedArgs {
26
+ serviceUrl: string
27
+ password: string
28
+ databaseUrl: string
29
+ logFile: string
30
+ }
31
+
32
+ class SeedError extends Error {}
33
+
34
+ function log(file: string, line: string) {
35
+ try {
36
+ appendFileSync(file, `[local-seed] ${line}\n`)
37
+ } catch {
38
+ // Logging must never fail the seed.
39
+ }
40
+ }
41
+
42
+ export async function call<T>(serviceUrl: string, path: string, init: { method?: string; body?: unknown; token?: string; orgId?: string; workspaceId?: string } = {}): Promise<{ status: number; body: T; headers: Headers }> {
43
+ const headers: Record<string, string> = { 'content-type': 'application/json' }
44
+ if (init.token) headers.authorization = `Bearer ${init.token}`
45
+ if (init.orgId) headers['x-org-id'] = init.orgId
46
+ if (init.workspaceId) headers['x-workspace-id'] = init.workspaceId
47
+ const res = await fetch(new URL(path, serviceUrl), {
48
+ method: init.method ?? (init.body === undefined ? 'GET' : 'POST'),
49
+ headers,
50
+ body: init.body === undefined ? undefined : JSON.stringify(init.body),
51
+ signal: AbortSignal.timeout(60_000),
52
+ })
53
+ const text = await res.text()
54
+ let body: unknown = text
55
+ try {
56
+ body = text ? JSON.parse(text) : null
57
+ } catch {
58
+ // Not JSON; keep the text for the error message.
59
+ }
60
+ return { status: res.status, body: body as T, headers: res.headers }
61
+ }
62
+
63
+ function expect(ok: boolean, what: string, status: number, body: unknown): void {
64
+ if (!ok) throw new SeedError(`${what} failed (${status}): ${JSON.stringify(body).slice(0, 300)}`)
65
+ }
66
+
67
+ export async function signIn(serviceUrl: string, password: string): Promise<string> {
68
+ const res = await call<{ token?: string }>(serviceUrl, '/api/auth/sign-in/email', { body: { email: OWNER_EMAIL, password } })
69
+ const token = res.body?.token ?? res.headers.get('set-auth-token') ?? undefined
70
+ expect(res.status === 200 && !!token, 'sign-in', res.status, res.body)
71
+ return token!
72
+ }
73
+
74
+ /**
75
+ * The organization key for the local profile: every capability the owner may
76
+ * delegate, in the seeded workspace too. The runtime is one person's, on their
77
+ * machine, so the key is as capable as they are; the service still refuses
78
+ * what no key may hold.
79
+ */
80
+ async function mintOrgKey(serviceUrl: string, token: string, organizationId: string, workspaceId: string): Promise<string> {
81
+ const caps = await call<{ data?: Array<{ resource: string; actions: Array<{ action: string; held: boolean }> }> }>(
82
+ serviceUrl,
83
+ '/v1/org/api-keys/capabilities',
84
+ { token, orgId: organizationId },
85
+ )
86
+ expect(Array.isArray(caps.body?.data), 'list the delegable capabilities', caps.status, caps.body)
87
+ const permissions: Record<string, string[]> = {}
88
+ for (const group of caps.body.data!) {
89
+ const held = group.actions.filter((a) => a.held).map((a) => a.action)
90
+ if (held.length) permissions[group.resource] = held
91
+ }
92
+ const expiresAt = new Date(Date.now() + 360 * 24 * 3600 * 1000).toISOString()
93
+ const mint = () =>
94
+ call<{ data?: { key?: string }; message?: string }>(serviceUrl, '/v1/org/api-keys', {
95
+ token,
96
+ orgId: organizationId,
97
+ body: { name: 'Local runtime', permissions, workspaceIds: [workspaceId], expiresAt },
98
+ })
99
+ let res = await mint()
100
+ // `held` is the owner's organization ceiling; inside the workspace the
101
+ // owner's workspace role is the ceiling, and the refusal names what it lacks.
102
+ const missing = res.status === 403 ? /Missing: (.+)$/.exec(res.body?.message ?? '')?.[1] : undefined
103
+ if (missing) {
104
+ for (const pair of missing.split(',').map((p) => p.trim())) {
105
+ const [resource, action] = pair.split(':')
106
+ if (!resource || !action || !permissions[resource]) continue
107
+ permissions[resource] = permissions[resource]!.filter((a) => a !== action)
108
+ if (!permissions[resource]!.length) delete permissions[resource]
109
+ }
110
+ res = await mint()
111
+ }
112
+ expect(!!res.body?.data?.key, 'create the organization key', res.status, res.body)
113
+ return res.body.data!.key!
114
+ }
115
+
116
+ /** A runtime seeded with the earlier, narrower organization key gets a full one. */
117
+ export async function ensureOrgKey(serviceUrl: string, session: string, credentials: LocalCredentials, logFile: string): Promise<LocalCredentials> {
118
+ if (credentials.orgKeyGrant === 'owner') return credentials
119
+ const orgKey = await mintOrgKey(serviceUrl, session, credentials.organizationId, credentials.workspaceId)
120
+ log(logFile, 'replaced the organization key with one carrying the owner\'s delegable capabilities')
121
+ return { ...credentials, orgKey, orgKeyGrant: 'owner' }
122
+ }
123
+
124
+ export async function seedLocalRuntime(args: SeedArgs): Promise<LocalCredentials> {
125
+ const { serviceUrl, password } = args
126
+
127
+ const signUp = await call(serviceUrl, '/api/auth/sign-up/email', { body: { email: OWNER_EMAIL, password, name: 'Local owner' } })
128
+ // 422/400 "user already exists" after a partial earlier seed is fine: sign in next.
129
+ if (signUp.status !== 200) log(args.logFile, `sign-up answered ${signUp.status}; signing in`)
130
+ const token = await signIn(serviceUrl, password)
131
+
132
+ const org = await call<{ id?: string }>(serviceUrl, '/api/auth/organization/create', {
133
+ token,
134
+ body: { name: 'Local', slug: `local-${Date.now().toString(36)}` },
135
+ })
136
+ expect(org.status === 200 && !!org.body?.id, 'create organization', org.status, org.body)
137
+ const organizationId = org.body.id!
138
+
139
+ const workspaces = await call<{ data?: Array<{ id: string; orgId: string; slug?: string; name?: string }> }>(serviceUrl, '/v1/workspaces', {
140
+ token,
141
+ orgId: organizationId,
142
+ })
143
+ const workspace = workspaces.body?.data?.find((w) => w.orgId === organizationId)
144
+ expect(!!workspace, 'find the bootstrapped workspace', workspaces.status, workspaces.body)
145
+
146
+ const wsKey = await call<{ data?: { rawKey?: string } }>(serviceUrl, `/v1/workspaces/${workspace!.id}/api-keys`, {
147
+ token,
148
+ orgId: organizationId,
149
+ workspaceId: workspace!.id,
150
+ body: { name: 'Local runtime', scope: 'mcp' },
151
+ })
152
+ expect(!!wsKey.body?.data?.rawKey, 'create the workspace key', wsKey.status, wsKey.body)
153
+
154
+ const orgKey = await mintOrgKey(serviceUrl, token, organizationId, workspace!.id)
155
+
156
+ log(args.logFile, `seeded ${OWNER_EMAIL}, organization ${organizationId}, workspace ${workspace!.id}`)
157
+ return {
158
+ email: OWNER_EMAIL,
159
+ organizationId,
160
+ workspaceId: workspace!.id,
161
+ workspaceSlug: workspace!.slug,
162
+ orgKey,
163
+ orgKeyGrant: 'owner',
164
+ workspaceKey: wsKey.body.data!.rawKey!,
165
+ }
166
+ }
167
+
168
+ /** Provider ids the service stores as organization credentials, each with an `apiKey` field. */
169
+ const CREDENTIAL_PROVIDERS = new Set(['anthropic', 'openai', 'openrouter', 'google'])
170
+
171
+ /** Embedding defaults by provider, in preference order; the service requires 1536-wide vectors. */
172
+ const EMBEDDING: Array<{ provider: string; model: string }> = [
173
+ { provider: 'openai', model: 'text-embedding-3-small' },
174
+ { provider: 'openrouter', model: 'openai/text-embedding-3-small' },
175
+ ]
176
+
177
+ /**
178
+ * Copy the FDE's stored keys into the local organization's credentials, and
179
+ * pick an embedding model when one is possible. Returns what was applied, for
180
+ * the status line; failures are logged, never fatal — a runtime without a model
181
+ * key still starts, and says so.
182
+ */
183
+ export async function applyModelKeys(args: { serviceUrl: string; password: string; organizationId: string; logFile: string }): Promise<string[]> {
184
+ const applied: string[] = []
185
+ let token: string
186
+ try {
187
+ token = await signIn(args.serviceUrl, args.password)
188
+ } catch (err) {
189
+ log(args.logFile, `could not sign in to apply model keys: ${(err as Error).message}`)
190
+ return applied
191
+ }
192
+ for (const p of LOCAL_PROVIDERS) {
193
+ if (!CREDENTIAL_PROVIDERS.has(p.id)) continue
194
+ const key = getKey(p.id)
195
+ if (!key) continue
196
+ const res = await call(args.serviceUrl, `/v1/admin/provider-credentials/${p.id}`, {
197
+ method: 'PUT',
198
+ token,
199
+ orgId: args.organizationId,
200
+ body: { fields: { apiKey: key } },
201
+ })
202
+ if (res.status >= 200 && res.status < 300) applied.push(p.id)
203
+ else log(args.logFile, `storing the ${findProvider(p.id)?.label} key answered ${res.status}: ${JSON.stringify(res.body).slice(0, 200)}`)
204
+ }
205
+ let embedding = EMBEDDING.find((e) => applied.includes(e.provider))
206
+ if (!embedding && keylessEmbeddings()) {
207
+ // A placeholder OpenAI credential: the service's OpenAI client is pointed
208
+ // at the local embeddings (see serviceEnv), which embed without a key.
209
+ const res = await call(args.serviceUrl, '/v1/admin/provider-credentials/openai', {
210
+ method: 'PUT',
211
+ token,
212
+ orgId: args.organizationId,
213
+ body: { fields: { apiKey: 'local-embeddings' } },
214
+ })
215
+ if (res.status >= 200 && res.status < 300) {
216
+ embedding = EMBEDDING[0]
217
+ applied.push('local-embeddings')
218
+ } else log(args.logFile, `storing the local embeddings credential answered ${res.status}: ${JSON.stringify(res.body).slice(0, 200)}`)
219
+ }
220
+ if (embedding) {
221
+ const res = await call(args.serviceUrl, '/v1/config/embedding', {
222
+ method: 'PUT',
223
+ token,
224
+ orgId: args.organizationId,
225
+ body: embedding,
226
+ })
227
+ if (res.status >= 300) log(args.logFile, `setting the embedding model answered ${res.status}: ${JSON.stringify(res.body).slice(0, 200)}`)
228
+ }
229
+ return applied
230
+ }
231
+
232
+ /**
233
+ * Runtimes started before the echo model was removed still list it: the
234
+ * `local` custom provider, its `local-model` catalog entry, and that model as
235
+ * the workspace's judge. Take all three away, so no Agent or test can pick a
236
+ * model that says nothing about behaviour. Idempotent and never fatal.
237
+ */
238
+ export async function retireLocalModel(args: { serviceUrl: string; password: string; organizationId: string; workspaceId: string; logFile: string }): Promise<void> {
239
+ let token: string
240
+ try {
241
+ token = await signIn(args.serviceUrl, args.password)
242
+ } catch {
243
+ return
244
+ }
245
+ const scope = { token, orgId: args.organizationId, workspaceId: args.workspaceId }
246
+ // An empty PATCH answers with the current judge settings and changes nothing.
247
+ const judge = await call<{ data?: { judgeModelSlug?: string | null } }>(args.serviceUrl, '/v1/eval/settings', { ...scope, method: 'PATCH', body: {} })
248
+ if (judge.body?.data?.judgeModelSlug === 'local-model') {
249
+ const cleared = await call(args.serviceUrl, '/v1/eval/settings', { ...scope, method: 'PATCH', body: { judgeModelSlug: null } })
250
+ if (cleared.status >= 300) log(args.logFile, `clearing the echo model as judge answered ${cleared.status}`)
251
+ }
252
+ const catalog = await call<{ data?: Array<{ slug: string }> }>(args.serviceUrl, '/v1/config/models/catalog', { token, orgId: args.organizationId })
253
+ if (catalog.body?.data?.some((m) => m.slug === 'local-model')) {
254
+ const removed = await call(args.serviceUrl, '/v1/config/models/catalog?slug=local-model', { method: 'DELETE', token, orgId: args.organizationId })
255
+ if (removed.status >= 300) log(args.logFile, `removing the echo model from the catalog answered ${removed.status}`)
256
+ }
257
+ const providers = await call<{ data?: Array<{ slug: string }> }>(args.serviceUrl, '/v1/admin/custom-providers', { token, orgId: args.organizationId })
258
+ if (providers.body?.data?.some((p) => p.slug === 'local')) {
259
+ const removed = await call(args.serviceUrl, '/v1/admin/custom-providers/local', { method: 'DELETE', token, orgId: args.organizationId })
260
+ if (removed.status >= 300) log(args.logFile, `removing the echo model's provider answered ${removed.status}`)
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Chat models a stored provider key unlocks in the local catalog, so an Agent
266
+ * can name a real model in its `agent.json`. Kept short and current: the
267
+ * catalog slug is what `modelSlug` takes, the model id is the provider's own.
268
+ */
269
+ export const PROVIDER_MODELS: Record<string, Array<{ slug: string; modelId: string; name: string }>> = {
270
+ openrouter: [
271
+ { slug: 'claude-sonnet-5-5', modelId: 'anthropic/claude-sonnet-5.5', name: 'Claude Sonnet 5.5' },
272
+ { slug: 'claude-haiku-5-5', modelId: 'anthropic/claude-haiku-5.5', name: 'Claude Haiku 5.5' },
273
+ { slug: 'gemini-3-8-flash', modelId: 'google/gemini-3.8-flash', name: 'Gemini 3.8 Flash' },
274
+ { slug: 'gpt-5-6-terra', modelId: 'openai/gpt-5.6-terra', name: 'GPT-5.6 Terra' },
275
+ ],
276
+ }
277
+
278
+ /**
279
+ * Add the chat models each applied provider unlocks to the local catalog.
280
+ * Idempotent and never fatal: a model already there is left alone, and a
281
+ * refusal is logged.
282
+ */
283
+ export async function ensureProviderModels(args: { serviceUrl: string; password: string; organizationId: string; providers: string[]; logFile: string }): Promise<string[]> {
284
+ const wanted = args.providers.flatMap((provider) => (PROVIDER_MODELS[provider] ?? []).map((m) => ({ ...m, provider })))
285
+ if (!wanted.length) return []
286
+ let token: string
287
+ try {
288
+ token = await signIn(args.serviceUrl, args.password)
289
+ } catch {
290
+ return []
291
+ }
292
+ const catalog = await call<{ data?: Array<{ provider: string; modelId: string }> }>(args.serviceUrl, '/v1/config/models/catalog', { token, orgId: args.organizationId })
293
+ const added: string[] = []
294
+ for (const model of wanted) {
295
+ if (catalog.body?.data?.some((m) => m.provider === model.provider && m.modelId === model.modelId)) continue
296
+ const res = await call(args.serviceUrl, '/v1/config/models/catalog', {
297
+ token,
298
+ orgId: args.organizationId,
299
+ body: { slug: model.slug, provider: model.provider, modelId: model.modelId, name: model.name, kind: 'text' },
300
+ })
301
+ if (res.status < 300) added.push(model.slug)
302
+ else log(args.logFile, `adding ${model.name} to the catalog answered ${res.status}: ${JSON.stringify(res.body).slice(0, 200)}`)
303
+ }
304
+ return added
305
+ }
306
+
307
+ /**
308
+ * Make Blueprint usable in a fresh local organization: authoring on (it is
309
+ * off by default for an organization) and the lifecycle adopted, so the
310
+ * Blueprint CLI loop (`pull`, `plan`, `apply`) and Studio's Blueprint section
311
+ * work from the first start. The seeded owner is already the Steward.
312
+ */
313
+ export async function ensureBlueprint(args: { serviceUrl: string; password: string; organizationId: string; logFile: string }): Promise<void> {
314
+ let token: string
315
+ try {
316
+ token = await signIn(args.serviceUrl, args.password)
317
+ } catch {
318
+ return
319
+ }
320
+ const flag = await call(args.serviceUrl, '/v1/config/feature-flags', {
321
+ method: 'PUT',
322
+ token,
323
+ orgId: args.organizationId,
324
+ body: { key: 'blueprint.authoring', value: true },
325
+ })
326
+ if (flag.status >= 300) log(args.logFile, `turning on Blueprint authoring answered ${flag.status}`)
327
+ const lifecycle = await call(args.serviceUrl, '/v1/blueprint/lifecycle', { token, orgId: args.organizationId })
328
+ if (lifecycle.status === 404) {
329
+ const adopted = await call(args.serviceUrl, '/v1/blueprint/lifecycle/adopt', { token, orgId: args.organizationId, body: {} })
330
+ if (adopted.status >= 300) log(args.logFile, `adopting the Blueprint lifecycle answered ${adopted.status}: ${JSON.stringify(adopted.body).slice(0, 200)}`)
331
+ }
332
+ }