@frontera-sdk/cli 1.52.28 → 1.52.29

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 (59) hide show
  1. package/package.json +5 -4
  2. package/src/commands/local/data.ts +60 -0
  3. package/src/commands/local/dev.ts +361 -18
  4. package/src/commands/local/index-commands.ts +11 -3
  5. package/src/commands/local/init.ts +88 -7
  6. package/src/commands/local/sync-request.ts +750 -0
  7. package/src/commands/local/sync.ts +941 -0
  8. package/src/commands/skill/bundle-commands.ts +9 -6
  9. package/src/errors.ts +4 -1
  10. package/src/exit.ts +3 -0
  11. package/src/flag-help.ts +48 -0
  12. package/src/local/local-actions.ts +306 -0
  13. package/src/local/local-adapt.ts +216 -0
  14. package/src/local/local-agent-files.ts +9 -2
  15. package/src/local/local-agent-grants.ts +125 -0
  16. package/src/local/local-agent-name.ts +71 -0
  17. package/src/local/local-applied.ts +334 -3
  18. package/src/local/local-apps.ts +156 -0
  19. package/src/local/local-blueprint-outline.ts +465 -0
  20. package/src/local/local-brand.ts +123 -0
  21. package/src/local/local-build-summary.ts +106 -0
  22. package/src/local/local-chat-apps.ts +170 -0
  23. package/src/local/local-dev-references.ts +1132 -0
  24. package/src/local/local-dev-sync.ts +171 -7
  25. package/src/local/local-docs.ts +53 -5
  26. package/src/local/local-env.ts +2 -0
  27. package/src/local/local-function-draft.ts +33 -0
  28. package/src/local/local-function-shared.ts +65 -0
  29. package/src/local/local-function-state.ts +34 -0
  30. package/src/local/local-knowledge-documents.ts +302 -0
  31. package/src/local/local-lessons.ts +278 -0
  32. package/src/local/local-library.ts +124 -0
  33. package/src/local/local-media-sets.ts +31 -0
  34. package/src/local/local-object-sets.ts +181 -0
  35. package/src/local/local-objects.ts +159 -0
  36. package/src/local/local-operating.ts +9 -5
  37. package/src/local/local-org-settings.ts +118 -0
  38. package/src/local/local-portability.ts +346 -0
  39. package/src/local/local-project.ts +6 -2
  40. package/src/local/local-quality-checks.ts +171 -0
  41. package/src/local/local-scaffold.ts +14 -11
  42. package/src/local/local-schemas.ts +33 -2
  43. package/src/local/local-secrets.ts +306 -0
  44. package/src/local/local-stand-ins.ts +442 -0
  45. package/src/local/local-studio-server.ts +23 -2
  46. package/src/local/local-sync-base.ts +237 -0
  47. package/src/local/local-sync-client.ts +93 -0
  48. package/src/local/local-sync-link.ts +122 -0
  49. package/src/local/local-sync-merge.ts +395 -0
  50. package/src/local/local-sync-names.ts +169 -0
  51. package/src/local/local-sync-plan.ts +717 -0
  52. package/src/local/local-sync-push.ts +986 -0
  53. package/src/local/local-sync-refs.ts +221 -0
  54. package/src/local/local-sync-remote.ts +1482 -0
  55. package/src/local/local-sync-snapshot.ts +526 -0
  56. package/src/local/local-synthetic-data.ts +374 -0
  57. package/src/local/local-test-runner.ts +172 -14
  58. package/src/local/local-themes.ts +201 -0
  59. package/src/local/local-workflow-state.ts +131 -0
@@ -0,0 +1,216 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { parse as parseYaml } from 'yaml'
5
+
6
+ import { loadBuild, refOfTarget, refWords } from './local-applied'
7
+ import { allTargets } from './local-dev-sync'
8
+ import type { LocalProject } from './local-project'
9
+ import { objectTypeFit } from './local-portability'
10
+ import { parseRef, type Unresolved } from './local-sync-refs'
11
+
12
+ /**
13
+ * What a file names that the local runtime will never have, applied around
14
+ * instead of refusing the whole file (design §8, "adapted").
15
+ *
16
+ * Two kinds of missing are told apart:
17
+ * - **Coming**: the project holds it (a Function file, a workflow, an object
18
+ * type the runtime builds) and it is not applied yet. The file waits for it
19
+ * (`blocked`), and the retry in `local dev` applies it once it is there.
20
+ * - **Absent**: nothing in the project provides it: a built-in connection
21
+ * signed in on the platform, a knowledge base not pulled, an object type or
22
+ * governed Action the runtime leaves off. Waiting would wait forever, so the
23
+ * file is applied without the part that names it, and each omission is an
24
+ * adaptation in the build, in words.
25
+ *
26
+ * What is left out is the smallest part that still makes sense: a link in
27
+ * guidance text stays as plain words, a procedure's tool or knowledge scope
28
+ * loses that one entry, a step is left out, an action is left out with the
29
+ * steps that run it. A procedure whose completion or verification needs the
30
+ * missing action is switched off rather than run half-built.
31
+ */
32
+
33
+ /** Whether the project provides a reference the runtime does not have yet, so a file naming it should wait. */
34
+ export function projectProvides(project: LocalProject, organizationId?: string | null): (ref: string) => boolean {
35
+ const refs = new Set(allTargets(project.root).map(refOfTarget))
36
+ const blueprint = join(project.root, 'blueprint')
37
+ const docsIn = (dir: string): Array<Record<string, unknown>> => {
38
+ const at = join(blueprint, dir)
39
+ if (!existsSync(at)) return []
40
+ return readdirSync(at).flatMap((name) => {
41
+ if (!/\.(ya?ml|json)$/.test(name) || name.startsWith('.')) return []
42
+ try {
43
+ const doc = parseYaml(readFileSync(join(at, name), 'utf8')) as Record<string, unknown> | null
44
+ return doc && typeof doc.apiName === 'string' ? [doc] : []
45
+ } catch {
46
+ return []
47
+ }
48
+ })
49
+ }
50
+ const objectTypes = new Set(docsIn('object-types').filter((d) => !objectTypeFit(d, () => true).leftOff).map((d) => String(d.apiName)))
51
+ const leftOff = organizationId ? loadBuild(project, organizationId).blueprint?.actions ?? {} : {}
52
+ const actions = new Set(docsIn('actions').map((d) => String(d.apiName)).filter((name) => leftOff[name]?.status !== 'left-off'))
53
+ // A saved object set is named by its file (`blueprint/object-sets/open-cases.yaml`).
54
+ const setsDir = join(blueprint, 'object-sets')
55
+ const objectSets = new Set(existsSync(setsDir) ? readdirSync(setsDir).flatMap((n) => (/\.(ya?ml|json)$/.test(n) && !n.startsWith('.') ? [n.replace(/\.(ya?ml|json)$/, '')] : [])) : [])
56
+ return (ref: string) => {
57
+ const parsed = parseRef(ref)
58
+ if (!parsed) return false
59
+ if (parsed.kind === 'objectType') return objectTypes.has(parsed.name)
60
+ if (parsed.kind === 'action') return actions.has(parsed.name)
61
+ if (parsed.kind === 'objectSet') return objectSets.has(parsed.name)
62
+ return refs.has(ref)
63
+ }
64
+ }
65
+
66
+ /** Why an absent reference is absent, in words: "isn’t connected here". */
67
+ export function absentWhy(ref: string): string {
68
+ const kind = parseRef(ref)?.kind
69
+ if (kind === 'plugin') return 'isn’t connected here'
70
+ if (kind === 'objectType' || kind === 'action') return 'the local runtime leaves it off'
71
+ return 'isn’t on this runtime'
72
+ }
73
+
74
+ const LINK = /\[\[([A-Za-z]+):([^\]\s]+)\]\]/g
75
+
76
+ type Json = Record<string, unknown>
77
+
78
+ function at(doc: unknown, path: string[]): unknown {
79
+ let v = doc
80
+ for (const p of path) v = v && typeof v === 'object' ? (v as Json)[p] : undefined
81
+ return v
82
+ }
83
+
84
+ function setAt(doc: unknown, path: string[], value: unknown): void {
85
+ const parent = at(doc, path.slice(0, -1))
86
+ if (parent && typeof parent === 'object') (parent as Json)[path[path.length - 1]!] = value
87
+ }
88
+
89
+ /** A label for where a link sits: a procedure by name, an Agent's instructions, or the field. */
90
+ function placeOf(doc: unknown, path: string[]): string {
91
+ if (path[0] === 'procedures' && /^\d+$/.test(path[1] ?? '')) return `procedure ‘${procedureName(at(doc, path.slice(0, 2)))}’`
92
+ if (path[0] === 'prompts') return 'its instructions'
93
+ return path.filter((p) => !/^\d+$/.test(p)).join('.') || 'the file'
94
+ }
95
+
96
+ function procedureName(proc: unknown): string {
97
+ const p = (proc ?? {}) as { name?: unknown; id?: unknown }
98
+ return String(p.name || p.id || 'unnamed')
99
+ }
100
+
101
+ /**
102
+ * Each link to an absent reference becomes its name as plain words
103
+ * (`[[plugin:google-sheets]]` → `google-sheets`), so the guidance still reads
104
+ * and no link points nowhere. Returns the warnings, one per link.
105
+ */
106
+ export function plainLinks(doc: unknown, links: Unresolved[]): string[] {
107
+ const warnings: string[] = []
108
+ for (const u of links) {
109
+ const path = u.path.split('.')
110
+ const text = at(doc, path)
111
+ const ref = parseRef(u.value)
112
+ if (typeof text !== 'string' || !ref) continue
113
+ setAt(doc, path, text.replace(LINK, (whole, kind: string, name: string) => (kind === ref.kind && name === ref.name ? name : whole)))
114
+ warnings.push(` warning: The link to ${refWords(u.value)} in ${placeOf(doc, path)} is plain text here: it ${absentWhy(u.value)}`)
115
+ }
116
+ return [...new Set(warnings)]
117
+ }
118
+
119
+ const REMOVE = Symbol('remove')
120
+
121
+ /** Arrays whose elements are procedure steps. */
122
+ const STEP_LISTS = new Set(['steps', 'onFailure', 'otherwise'])
123
+
124
+ /**
125
+ * An Agent's behavior settings without what names an absent reference
126
+ * (`procedures.2.actions.0.functionId` = `function/x`). `doc` is changed in
127
+ * place. Returns one warning per omission, in words the build shows:
128
+ * "Procedure ‘Book a visit’ step 3 left out: uses Plugin google-sheets, which
129
+ * isn’t connected here".
130
+ */
131
+ export function adaptOperating(doc: Json, absent: Unresolved[]): string[] {
132
+ const warnings: string[] = []
133
+ const removedActions = new Map<Json, Map<string, string>>()
134
+ const uses = (u: Unresolved) => `uses ${refWords(u.value)}, which ${absentWhy(u.value)}`
135
+ const remove = (path: string[]) => setAt(doc, path, REMOVE)
136
+ for (const u of absent) {
137
+ const path = u.path.split('.')
138
+ if (path[0] === 'procedures' && /^\d+$/.test(path[1] ?? '')) {
139
+ const proc = at(doc, path.slice(0, 2)) as Json | undefined
140
+ if (!proc || typeof proc !== 'object') continue
141
+ const name = procedureName(proc)
142
+ const rel = path.slice(2)
143
+ if ((rel[0] === 'tools' || rel[0] === 'knowledge') && rel[1] === 'ids' && /^\d+$/.test(rel[2] ?? '')) {
144
+ remove(path.slice(0, 5))
145
+ warnings.push(` warning: Procedure ‘${name}’ goes without ${refWords(u.value)}: it ${absentWhy(u.value)}`)
146
+ continue
147
+ }
148
+ if (rel[0] === 'actions' && /^\d+$/.test(rel[1] ?? '')) {
149
+ const action = at(proc, rel.slice(0, 2)) as Json | undefined
150
+ if (action && typeof action.id === 'string') {
151
+ const byProc = removedActions.get(proc) ?? new Map<string, string>()
152
+ byProc.set(action.id, `Procedure ‘${name}’ action ‘${String(action.label || action.id)}’ ${uses(u)}`)
153
+ removedActions.set(proc, byProc)
154
+ }
155
+ remove(path.slice(0, 4))
156
+ continue
157
+ }
158
+ // The innermost step that holds it.
159
+ let step = -1
160
+ for (let i = rel.length - 2; i >= 0; i--) {
161
+ if (STEP_LISTS.has(rel[i]!) && /^\d+$/.test(rel[i + 1] ?? '')) {
162
+ step = i
163
+ break
164
+ }
165
+ }
166
+ if (step >= 0) {
167
+ remove([...path.slice(0, 2), ...rel.slice(0, step + 2)])
168
+ warnings.push(` warning: Procedure ‘${name}’ step ${Number(rel[step + 1]) + 1} left out: ${uses(u)}`)
169
+ continue
170
+ }
171
+ proc.enabled = false
172
+ warnings.push(` warning: Procedure ‘${name}’ is switched off: it ${uses(u)}`)
173
+ continue
174
+ }
175
+ // Outside the procedures: the innermost list entry that holds it, else the field.
176
+ const last = path.findLastIndex((p) => /^\d+$/.test(p))
177
+ const held = last >= 0 ? path.slice(0, last + 1) : path
178
+ remove(held)
179
+ const field = held.filter((p) => !/^\d+$/.test(p)).join('.') || 'a setting'
180
+ warnings.push(` warning: ${last >= 0 ? `An entry of ${field}` : `The setting ${field}`} left out: ${uses(u)}`)
181
+ }
182
+ // An action left out takes the steps that run it, and the strict block before it, along.
183
+ for (const [proc, actions] of removedActions) {
184
+ const ids = new Set(actions.keys())
185
+ const needed = [(proc.verification as Json | undefined)?.actionId, (proc.completion as Json | undefined)?.actionId].some((id) => typeof id === 'string' && ids.has(id))
186
+ if (needed) {
187
+ proc.enabled = false
188
+ for (const why of actions.values()) warnings.push(` warning: ${why}; the procedure is switched off, as it can’t finish without it`)
189
+ continue
190
+ }
191
+ const strip = (v: unknown): unknown => {
192
+ if (Array.isArray(v)) return v.filter((s) => !(s && typeof s === 'object' && (((s as Json).type === 'run' && ids.has(String((s as Json).actionId))) || ((s as Json).anchor === 'before-action' && ids.has(String((s as Json).actionId)))))).map(strip)
193
+ if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v as Json).map(([k, x]) => [k, strip(x)]))
194
+ return v
195
+ }
196
+ for (const key of ['blocks', 'steps']) if (proc[key] !== undefined) proc[key] = strip(proc[key])
197
+ for (const why of actions.values()) warnings.push(` warning: ${why}; it is left out, with the steps that run it`)
198
+ }
199
+ sweep(doc)
200
+ return [...new Set(warnings)]
201
+ }
202
+
203
+ /** Drop what was marked for removal, at any depth. */
204
+ function sweep(v: unknown): void {
205
+ if (Array.isArray(v)) {
206
+ for (let i = v.length - 1; i >= 0; i--) if (v[i] === REMOVE) v.splice(i, 1)
207
+ for (const x of v) sweep(x)
208
+ return
209
+ }
210
+ if (v && typeof v === 'object') {
211
+ for (const [k, x] of Object.entries(v as Json)) {
212
+ if (x === REMOVE) delete (v as Json)[k]
213
+ else sweep(x)
214
+ }
215
+ }
216
+ }
@@ -3,9 +3,11 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
 
5
5
  import { contentHash, loadHashes, saveHashes } from './local-applied'
6
+ import { localDirectory, resolveTarget } from './local-dev-references'
6
7
  import { localProfileName } from './local-control'
7
8
  import { targetFor } from './local-dev-sync'
8
9
  import type { LocalProject } from './local-project'
10
+ import { readState } from './local-state'
9
11
 
10
12
  /**
11
13
  * Studio's editor, the counterpart of Mastra Studio's: a change made in the
@@ -94,6 +96,7 @@ export async function saveAgentEdit(
94
96
  organizationId: string,
95
97
  slug: string,
96
98
  edit: AgentFileEdit,
99
+ serviceUrl?: string,
97
100
  ): Promise<{ path: string; changed: boolean; applied: boolean; output: string }> {
98
101
  const path = agentFilePath(project.root, slug)
99
102
  const rel = join('agents', slug, 'agent.json')
@@ -109,9 +112,13 @@ export async function saveAgentEdit(
109
112
  }
110
113
  if (next === null) return { path: rel, changed: false, applied: true, output: '' }
111
114
  writeFileSync(path, next)
112
- const result = await applyWithCli(project, slug, rel)
115
+ // Names in the file (`skill/acme-reply-style`) are applied as this runtime's ids, as `local dev` does.
116
+ const target = targetFor(project.root, path)
117
+ const creds = readState(project)?.credentials
118
+ const resolved = target && serviceUrl && creds ? await resolveTarget(project, target, () => localDirectory(serviceUrl, creds), creds.workspaceId) : { ok: true, output: '' }
119
+ if (!resolved.ok) return { path: rel, changed: true, applied: false, output: resolved.output.split('\n').slice(0, 6).join('\n') }
120
+ const result = await applyWithCli(project, slug, 'file' in resolved && resolved.file ? resolved.file : rel)
113
121
  if (result.ok) {
114
- const target = targetFor(project.root, path)
115
122
  if (target) {
116
123
  const applied = loadHashes(project, organizationId)
117
124
  applied.set(target.key, contentHash(project, target))
@@ -0,0 +1,125 @@
1
+ import { parseRef } from './local-sync-refs'
2
+ import type { LocalCredentials } from './local-state'
3
+
4
+ /**
5
+ * An Agent's Context and data on the local runtime: the object types it may
6
+ * read through its Blueprint tools, from the `blueprint` list in its
7
+ * `agents/<slug>/bindings.json` (`{ "objectType": "objectType/Incident",
8
+ * "permissions": ["read"] }`). On the platform these are the Agent's
9
+ * Blueprint grants; without them it has no Blueprint tools at all.
10
+ *
11
+ * Granting is a Steward's call on the organization's Blueprint, so it is made
12
+ * with the runtime's organization key, after the Blueprint is applied and
13
+ * published (bindings apply after it). The runtime ends up with the file's
14
+ * grants: missing ones are granted, changed permissions replaced, and grants
15
+ * the file does not list revoked. An object type the runtime leaves off
16
+ * (media files) is skipped with a warning; one it does not hold yet waits
17
+ * for the Blueprint to build, so the Agent's tools read as blocked on it.
18
+ */
19
+
20
+ export interface AgentGrant {
21
+ objectType: string
22
+ permissions?: string[]
23
+ }
24
+
25
+ export interface GrantOutcome {
26
+ /** One line each, ` objectType/X: why`, as the other bindings report a refusal. */
27
+ failures: string[]
28
+ /** ` warning: …` lines (adaptations) and plain lines for the log. */
29
+ notes: string[]
30
+ }
31
+
32
+ /** The build's word on object types it left off, by API name, with why. */
33
+ export type LeftOff = (apiName: string) => string | null
34
+
35
+ export async function applyAgentGrants(input: {
36
+ slug: string
37
+ grants: AgentGrant[]
38
+ serviceUrl: string
39
+ creds: LocalCredentials
40
+ /** Object types the last Blueprint build left off (`build.json`). */
41
+ leftOff: LeftOff
42
+ /** Whether the project's Blueprint has a file for this object type. */
43
+ inProject: (apiName: string) => boolean
44
+ fetch?: typeof fetch
45
+ }): Promise<GrantOutcome> {
46
+ const { slug, creds } = input
47
+ const doFetch = input.fetch ?? fetch
48
+ const failures: string[] = []
49
+ const notes: string[] = []
50
+ const call = async (key: string | undefined, path: string, method = 'GET', body?: unknown): Promise<{ ok: boolean; status: number; data: unknown; text: string }> => {
51
+ const res = await doFetch(new URL(path, input.serviceUrl), {
52
+ method,
53
+ headers: { authorization: `Bearer ${key ?? ''}`, 'content-type': 'application/json' },
54
+ body: body === undefined ? undefined : JSON.stringify(body),
55
+ })
56
+ const text = await res.text()
57
+ let data: unknown = null
58
+ try {
59
+ data = JSON.parse(text)
60
+ } catch {
61
+ // Not JSON: the text is the message.
62
+ }
63
+ return { ok: res.ok, status: res.status, data, text }
64
+ }
65
+ const list = <T>(body: unknown): T[] => {
66
+ let v = body
67
+ for (let i = 0; i < 3 && v && !Array.isArray(v) && typeof v === 'object' && 'data' in (v as object); i++) v = (v as { data: unknown }).data
68
+ return Array.isArray(v) ? (v as T[]) : []
69
+ }
70
+ const brief = (r: { status: number; text: string }) => `${r.status} ${r.text.slice(0, 200)}`
71
+
72
+ // Wanted: by API name, the file's permissions (read when it names none).
73
+ const wanted = new Map<string, string[]>()
74
+ for (const g of input.grants) {
75
+ const ref = typeof g?.objectType === 'string' ? parseRef(g.objectType) : null
76
+ if (!ref || ref.kind !== 'objectType') {
77
+ failures.push(` ${String(g?.objectType)}: not an object type reference (objectType/<apiName>)`)
78
+ continue
79
+ }
80
+ wanted.set(ref.name, [...(Array.isArray(g.permissions) && g.permissions.length ? g.permissions : ['read'])].sort())
81
+ }
82
+
83
+ // The workspace's own list, which holds Agents never published too; the organization's
84
+ // Blueprint list (`org-agents`) leaves those out, and a pulled draft is one of them.
85
+ const agents = await call(creds.workspaceKey, '/v1/sync/agents')
86
+ if (!agents.ok) return { failures: [...failures, ` agent/${slug}: the runtime’s Agents could not be read: ${brief(agents)}`], notes }
87
+ const agent = list<{ id: string; agentId?: string }>(agents.data).find((a) => a.agentId === slug)
88
+ if (!agent) return { failures: [...failures, ` agent/${slug}: not found on the runtime`], notes }
89
+
90
+ const held = await call(creds.workspaceKey, '/v1/blueprint/object-types')
91
+ const onRuntime = new Set(held.ok ? list<{ apiName?: string }>(held.data).flatMap((t) => (t.apiName ? [t.apiName] : [])) : [])
92
+ const current = await call(creds.orgKey, `/v1/blueprint/grants/agent/${encodeURIComponent(agent.id)}`)
93
+ if (!current.ok) return { failures: [...failures, ` agent/${slug}: its Blueprint access could not be read: ${brief(current)}`], notes }
94
+ const has = new Map(list<{ objectTypeApiName: string; permissions?: string[] }>(current.data).map((g) => [g.objectTypeApiName, [...(g.permissions ?? ['read'])].sort()]))
95
+
96
+ let granted = 0
97
+ for (const [apiName, permissions] of wanted) {
98
+ const why = input.leftOff(apiName)
99
+ if (why) {
100
+ notes.push(` warning: Context and data: ${apiName} is left off here (${why}), so ${slug} can’t read it locally`)
101
+ continue
102
+ }
103
+ if (!onRuntime.has(apiName)) {
104
+ // The Blueprint does not hold it (yet): blocked until it builds, or named by nothing in the project.
105
+ failures.push(` objectType/${apiName}: ${input.inProject(apiName) ? 'not on the runtime yet: the Blueprint isn’t built' : 'not found in the project’s Blueprint'}`)
106
+ continue
107
+ }
108
+ if (has.get(apiName)?.join(',') !== permissions.join(',')) {
109
+ const res = await call(creds.orgKey, '/v1/blueprint/grants/agent', 'POST', { agentId: agent.id, objectTypeApiName: apiName, permissions })
110
+ if (!res.ok) {
111
+ failures.push(` objectType/${apiName}: ${brief(res)}`)
112
+ continue
113
+ }
114
+ }
115
+ granted += 1
116
+ }
117
+ // The file is what the Agent may read: a grant it does not list is withdrawn.
118
+ for (const apiName of has.keys()) {
119
+ if (wanted.has(apiName)) continue
120
+ const res = await call(creds.orgKey, `/v1/blueprint/grants/agent/${encodeURIComponent(agent.id)}/${encodeURIComponent(apiName)}`, 'DELETE')
121
+ if (!res.ok) failures.push(` objectType/${apiName}: could not be revoked: ${brief(res)}`)
122
+ }
123
+ if (granted) notes.push(` Context and data: ${granted} object type${granted === 1 ? '' : 's'}`)
124
+ return { failures, notes }
125
+ }
@@ -0,0 +1,71 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import type { LocalCredentials } from './local-state'
5
+
6
+ /**
7
+ * An Agent's name and description on the local runtime, as its file has them.
8
+ *
9
+ * The Agent's row carries the name the platform lists it by (Studio's lists,
10
+ * breadcrumbs and pickers). On the platform, publishing moves the draft's
11
+ * name onto the row; `local dev` keeps Agents as drafts (Studio runs the
12
+ * draft), so the row kept whatever it was created with: the slug, since
13
+ * `agent create` was called without one. A pulled "Telco Operations" read
14
+ * `telco-operations` everywhere.
15
+ *
16
+ * So `local dev` creates an Agent with its name, and after each apply of
17
+ * `agents/<slug>/agent.json` sets the row's name and description when they
18
+ * differ from the file's. Only the row changes: the draft already holds them.
19
+ */
20
+
21
+ export interface AgentIdentity {
22
+ name?: string
23
+ description?: string
24
+ }
25
+
26
+ /** `config.name` and `config.description` from an Agent's file; empty when it has none or does not parse. */
27
+ export function agentIdentity(root: string, file: string): AgentIdentity {
28
+ try {
29
+ const doc = JSON.parse(readFileSync(join(root, file), 'utf8')) as { config?: { name?: unknown; description?: unknown }; snapshot?: { config?: { name?: unknown; description?: unknown } } }
30
+ const config = doc.snapshot?.config ?? doc.config ?? {}
31
+ const text = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
32
+ return { ...(text(config.name) ? { name: text(config.name) } : {}), ...(text(config.description) ? { description: text(config.description) } : {}) }
33
+ } catch {
34
+ return {}
35
+ }
36
+ }
37
+
38
+ /** The flags `agent create` takes for them. */
39
+ export function createFlags(identity: AgentIdentity): string[] {
40
+ return [...(identity.name ? ['--name', identity.name] : []), ...(identity.description ? ['--description', identity.description] : [])]
41
+ }
42
+
43
+ /**
44
+ * Set the row's name and description to the file's where they differ.
45
+ * Returns a line for the log (a `warning:` one when it could not), or null
46
+ * when nothing changed. Never throws:
47
+ * a name that could not be set is a warning, not a failed apply.
48
+ */
49
+ export async function syncAgentName(input: { slug: string; identity: AgentIdentity; serviceUrl: string; creds: LocalCredentials; fetch?: typeof fetch }): Promise<string | null> {
50
+ const { identity, creds } = input
51
+ if (!identity.name && !identity.description) return null
52
+ const doFetch = input.fetch ?? fetch
53
+ const headers = { authorization: `Bearer ${creds.workspaceKey ?? ''}`, 'content-type': 'application/json' }
54
+ try {
55
+ const listed = await doFetch(new URL('/v1/sync/agents', input.serviceUrl), { headers })
56
+ if (!listed.ok) return ` warning: ${input.slug}'s name could not be checked (${listed.status})`
57
+ const body = (await listed.json()) as { data?: Array<{ id: string; agentId: string; name?: string | null; description?: string | null }> }
58
+ const row = body.data?.find((a) => a.agentId === input.slug)
59
+ if (!row) return null
60
+ const patch: Record<string, string> = {}
61
+ if (identity.name && row.name !== identity.name) patch.name = identity.name
62
+ if (identity.description && (row.description ?? '') !== identity.description) patch.description = identity.description
63
+ if (!Object.keys(patch).length) return null
64
+ // `direct`: the row itself, as a publish would set it; the draft already has these.
65
+ const res = await doFetch(new URL(`/v1/config/agents/${encodeURIComponent(row.id)}?direct=1`, input.serviceUrl), { method: 'PATCH', headers, body: JSON.stringify(patch) })
66
+ if (!res.ok) return ` warning: ${input.slug}'s name could not be set (${res.status})`
67
+ return patch.name ? ` named ${input.slug} “${patch.name}”` : ` updated ${input.slug}’s description`
68
+ } catch (err) {
69
+ return ` warning: ${input.slug}'s name could not be set: ${(err as Error).message}`
70
+ }
71
+ }