@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,395 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
4
+
5
+ /**
6
+ * Three-way merge for a pull (§7 of the sync v2 design): the base (what the
7
+ * last pull wrote), ours (the project's files) and theirs (the workspace, as
8
+ * fetched). Pure: no files, no network; the caller reads and writes.
9
+ *
10
+ * Rules, the same in every kind:
11
+ *
12
+ * - **JSON and YAML** merge field by field. Object keys merge recursively; a
13
+ * field one side changed takes that side; a field both sides changed the
14
+ * same way takes it once; a field both changed differently is a conflict.
15
+ * A field added on one side is kept; deleted on one side and untouched on
16
+ * the other is deleted; deleted on one side and changed on the other is a
17
+ * conflict.
18
+ * - **Arrays** merge as one value, except a list of objects that each carry a
19
+ * stable key (`apiName`, `id`, `name`, `slug`, `filename`, `modeKey`,
20
+ * `function`, `workflow`, `key`: the first one present as a unique string in
21
+ * every element of all three versions). Such a list merges element by
22
+ * element, matched by that key, with the object rules above. Its order is
23
+ * ours, as the folder has it, unless only the workspace reordered the
24
+ * elements all three share; what the other side added comes after.
25
+ * - **Multi-line strings** inside JSON or YAML (an Agent's prompts, a
26
+ * procedure's instructions) and **every other file** (Markdown, code) merge
27
+ * line by line, diff3-style: edits to different lines merge; the same lines
28
+ * changed differently on both sides conflict, and so do edits to adjacent
29
+ * lines with no unchanged line between them (as in git).
30
+ *
31
+ * A conflict never decides on its own: unless `choose` picks a side, the
32
+ * result keeps ours, and the conflict is reported with all three versions.
33
+ * Where both sides can stand together, as in a merge editor's Accept both,
34
+ * the conflict also says what taking both gives (`both`): for lines, ours
35
+ * then theirs; for a list, ours then what theirs adds, each element once. A
36
+ * single value (a name, a number, a whole file one side deleted) has no
37
+ * both.
38
+ */
39
+
40
+ export type Side = 'ours' | 'theirs'
41
+
42
+ /** What settles one conflict: a side, or both where the conflict offers it. */
43
+ export type Choice = Side | 'both'
44
+
45
+ export interface Conflict {
46
+ /** 1-based, in the order the merge meets conflicts: across an item's files when merged with `mergeItemFiles`. */
47
+ index: number
48
+ /** The field (`config.name`, `procedures[id=refund].instructions`); '' for a whole text file. */
49
+ path: string
50
+ /** The three versions of the field or the lines; `undefined` where a version does not have it. */
51
+ base: unknown
52
+ ours: unknown
53
+ theirs: unknown
54
+ /** For a line hunk: the 1-based line in our version where it starts. */
55
+ line?: number
56
+ /** What choosing both gives, where both can stand together: ours then theirs (lines), or the two lists joined without repeats. Absent for a single value. */
57
+ both?: unknown
58
+ }
59
+
60
+ export interface MergeOptions {
61
+ /** Pick a side for a conflict by its index, or both where it has `both`; `undefined` leaves it unresolved (ours is kept). */
62
+ choose?: (index: number) => Choice | undefined
63
+ /** The index the first conflict gets, minus one: so several files number on from each other. */
64
+ offset?: number
65
+ }
66
+
67
+ /** A key that is not there, as distinct from one holding `undefined` or `null`. */
68
+ const ABSENT: unique symbol = Symbol('absent')
69
+ type Value = unknown
70
+
71
+ const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
72
+
73
+ export function deepEqual(a: unknown, b: unknown): boolean {
74
+ if (a === b) return true
75
+ if (Array.isArray(a)) return Array.isArray(b) && a.length === b.length && a.every((x, i) => deepEqual(x, b[i]))
76
+ if (isObject(a) && isObject(b)) {
77
+ const ka = Object.keys(a)
78
+ const kb = Object.keys(b)
79
+ return ka.length === kb.length && ka.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]))
80
+ }
81
+ return false
82
+ }
83
+
84
+ class Merger {
85
+ conflicts: Conflict[] = []
86
+ private n: number
87
+
88
+ constructor(private opts: MergeOptions) {
89
+ this.n = opts.offset ?? 0
90
+ }
91
+
92
+ /** Record a conflict and return what to take: the chosen side (or both, where it has both), or ours. */
93
+ conflict(c: Omit<Conflict, 'index'>): Choice {
94
+ const index = ++this.n
95
+ const { both, ...rest } = c
96
+ this.conflicts.push({ index, ...rest, base: shown(c.base), ours: shown(c.ours), theirs: shown(c.theirs), ...(both !== undefined ? { both } : {}) })
97
+ const choice = this.opts.choose?.(index) ?? 'ours'
98
+ if (choice === 'both' && both === undefined) throw new Error(`conflict ${index} is one value: keep mine or the workspace's`)
99
+ return choice
100
+ }
101
+
102
+ value(path: string, b: Value, o: Value, t: Value): Value {
103
+ if (deepEqual(o, t)) return o
104
+ if (deepEqual(b, o)) return t
105
+ if (deepEqual(b, t)) return o
106
+ if (isObject(o) && isObject(t)) return this.object(path, isObject(b) ? b : null, o, t)
107
+ if (Array.isArray(o) && Array.isArray(t)) return this.array(path, Array.isArray(b) ? b : null, o, t)
108
+ if (typeof o === 'string' && typeof t === 'string' && [b, o, t].some((s) => typeof s === 'string' && s.includes('\n'))) {
109
+ return this.lines(path, typeof b === 'string' ? b : '', o, t)
110
+ }
111
+ return this.conflict({ path, base: b, ours: o, theirs: t }) === 'ours' ? o : t
112
+ }
113
+
114
+ object(path: string, b: Record<string, unknown> | null, o: Record<string, unknown>, t: Record<string, unknown>): Record<string, unknown> {
115
+ const out: Record<string, unknown> = {}
116
+ // Our order first, so the merged file reads like the one in the folder; then what only the workspace has.
117
+ const keys = [...Object.keys(o), ...Object.keys(t).filter((k) => !(k in o)), ...Object.keys(b ?? {}).filter((k) => !(k in o) && !(k in t))]
118
+ for (const k of keys) {
119
+ const at = (x: Record<string, unknown> | null) => (x && Object.prototype.hasOwnProperty.call(x, k) ? x[k] : ABSENT)
120
+ const v = this.value(path ? `${path}.${k}` : k, at(b), at(o), at(t))
121
+ if (v !== ABSENT) out[k] = v
122
+ }
123
+ return out
124
+ }
125
+
126
+ array(path: string, b: unknown[] | null, o: unknown[], t: unknown[]): unknown {
127
+ const key = stableKey([...(b ? [b] : []), o, t])
128
+ if (!key) {
129
+ const both = union(o, t)
130
+ const choice = this.conflict({ path, base: b ?? ABSENT, ours: o, theirs: t, both })
131
+ return choice === 'both' ? both : choice === 'ours' ? o : t
132
+ }
133
+ const index = (list: unknown[] | null) => new Map((list ?? []).map((x) => [(x as Record<string, string>)[key]!, x]))
134
+ const bm = index(b)
135
+ const om = index(o)
136
+ const tm = index(t)
137
+ const order = (m: Map<string, unknown>, among: Map<string, unknown>[]) => [...m.keys()].filter((k) => among.every((x) => x.has(k)))
138
+ const shared = (m: Map<string, unknown>) => order(m, [bm, om, tm]).join('\0')
139
+ // Our order, as the folder has it, unless only the workspace reordered what all three share.
140
+ const theirsFirst = shared(tm) !== shared(bm) && shared(om) === shared(bm)
141
+ const [first, second] = theirsFirst ? [tm, om] : [om, tm]
142
+ const keys = [...first.keys(), ...[...second.keys()].filter((k) => !first.has(k)), ...[...bm.keys()].filter((k) => !om.has(k) && !tm.has(k))]
143
+ const out: unknown[] = []
144
+ for (const k of keys) {
145
+ const at = (m: Map<string, unknown>) => (m.has(k) ? m.get(k) : ABSENT)
146
+ const v = this.value(`${path}[${key}=${k}]`, at(bm), at(om), at(tm))
147
+ if (v !== ABSENT) out.push(v)
148
+ }
149
+ return out
150
+ }
151
+
152
+ lines(path: string, base: string, ours: string, theirs: string): string {
153
+ const result = diff3(base.split('\n'), ours.split('\n'), theirs.split('\n'))
154
+ const out: string[] = []
155
+ for (const chunk of result) {
156
+ if (chunk.kind === 'ok') {
157
+ out.push(...chunk.lines)
158
+ continue
159
+ }
160
+ const both = [...chunk.ours, ...chunk.theirs]
161
+ const choice = this.conflict({ path, base: chunk.base.join('\n'), ours: chunk.ours.join('\n'), theirs: chunk.theirs.join('\n'), line: chunk.oursLine, both: both.join('\n') })
162
+ out.push(...(choice === 'both' ? both : choice === 'ours' ? chunk.ours : chunk.theirs))
163
+ }
164
+ return out.join('\n')
165
+ }
166
+ }
167
+
168
+ /** Two lists as one: ours in its order, then what only theirs has, each element once. */
169
+ function union(ours: unknown[], theirs: unknown[]): unknown[] {
170
+ const out: unknown[] = []
171
+ for (const x of [...ours, ...theirs]) if (!out.some((y) => deepEqual(x, y))) out.push(x)
172
+ return out
173
+ }
174
+
175
+ /** A conflict's version as a reader sees it: `undefined` where it is absent. */
176
+ function shown(v: unknown): unknown {
177
+ return v === ABSENT ? undefined : v
178
+ }
179
+
180
+ const KEY_FIELDS = ['apiName', 'id', 'name', 'slug', 'filename', 'modeKey', 'function', 'workflow', 'objectType', 'key']
181
+
182
+ /** The field every element of every list carries as a unique string, if there is one. */
183
+ export function stableKey(lists: unknown[][]): string | null {
184
+ if (!lists.some((l) => l.length)) return null
185
+ for (const key of KEY_FIELDS) {
186
+ const ok = lists.every((list) => {
187
+ const seen = new Set<string>()
188
+ for (const x of list) {
189
+ if (!isObject(x) || typeof x[key] !== 'string' || seen.has(x[key] as string)) return false
190
+ seen.add(x[key] as string)
191
+ }
192
+ return true
193
+ })
194
+ if (ok) return key
195
+ }
196
+ return null
197
+ }
198
+
199
+ /**
200
+ * Field-by-field merge of two documents from their base. `base` is
201
+ * `undefined` when there is none (an item pulled before base content was
202
+ * kept): then only what both sides agree on, or only one side has, merges.
203
+ */
204
+ export function mergeJson(base: unknown, ours: unknown, theirs: unknown, opts: MergeOptions = {}): { merged: unknown; conflicts: Conflict[] } {
205
+ const m = new Merger(opts)
206
+ const merged = m.value('', base === undefined ? ABSENT : base, ours, theirs)
207
+ return { merged: merged === ABSENT ? undefined : merged, conflicts: m.conflicts }
208
+ }
209
+
210
+ /** Line-by-line merge of three texts, diff3-style. */
211
+ export function mergeLines(base: string, ours: string, theirs: string, opts: MergeOptions = {}): { merged: string; conflicts: Conflict[] } {
212
+ const m = new Merger(opts)
213
+ const merged = ours === theirs ? ours : base === ours ? theirs : base === theirs ? ours : m.lines('', base, ours, theirs)
214
+ return { merged, conflicts: m.conflicts }
215
+ }
216
+
217
+ // ── diff3 ────────────────────────────────────────────────────────────────────
218
+
219
+ type Chunk = { kind: 'ok'; lines: string[] } | { kind: 'conflict'; base: string[]; ours: string[]; theirs: string[]; oursLine: number }
220
+
221
+ /** Past this many table cells, the middle of two texts is compared as one block instead of line by line. */
222
+ const LCS_LIMIT = 4_000_000
223
+
224
+ /** For each line of `a`, the line of `b` a longest common subsequence pairs it with, or -1. */
225
+ function matchLines(a: string[], b: string[]): Int32Array {
226
+ const out = new Int32Array(a.length).fill(-1)
227
+ let start = 0
228
+ while (start < a.length && start < b.length && a[start] === b[start]) {
229
+ out[start] = start
230
+ start++
231
+ }
232
+ let endA = a.length
233
+ let endB = b.length
234
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
235
+ endA--
236
+ endB--
237
+ out[endA] = endB
238
+ }
239
+ const n = endA - start
240
+ const m = endB - start
241
+ if (!n || !m || n * m > LCS_LIMIT) return out
242
+ const table: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1))
243
+ for (let i = n - 1; i >= 0; i--) {
244
+ for (let j = m - 1; j >= 0; j--) table[i]![j] = a[start + i] === b[start + j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!)
245
+ }
246
+ let i = 0
247
+ let j = 0
248
+ while (i < n && j < m) {
249
+ if (a[start + i] === b[start + j]) {
250
+ out[start + i] = start + j
251
+ i++
252
+ j++
253
+ } else if (table[i + 1]![j]! >= table[i]![j + 1]!) i++
254
+ else j++
255
+ }
256
+ return out
257
+ }
258
+
259
+ const same = (x: string[], y: string[]) => x.length === y.length && x.every((l, i) => l === y[i])
260
+
261
+ /**
262
+ * Walk the base with both sides: a base line both sides kept is stable; the
263
+ * lines between two stable points changed on one side (take it), on both the
264
+ * same way (take it once), or on both differently (a conflict).
265
+ */
266
+ export function diff3(base: string[], ours: string[], theirs: string[]): Chunk[] {
267
+ const mo = matchLines(base, ours)
268
+ const mt = matchLines(base, theirs)
269
+ const out: Chunk[] = []
270
+ const ok = (lines: string[]) => {
271
+ if (!lines.length) return
272
+ const last = out[out.length - 1]
273
+ if (last?.kind === 'ok') last.lines.push(...lines)
274
+ else out.push({ kind: 'ok', lines: [...lines] })
275
+ }
276
+ let i = 0
277
+ let a = 0
278
+ let b = 0
279
+ while (i < base.length || a < ours.length || b < theirs.length) {
280
+ if (i < base.length && mo[i] === a && mt[i] === b) {
281
+ ok([base[i]!])
282
+ i++
283
+ a++
284
+ b++
285
+ continue
286
+ }
287
+ // The next base line both sides kept, at or after this one.
288
+ let j = i
289
+ while (j < base.length && !(mo[j]! >= a && mt[j]! >= b)) j++
290
+ const oa = j < base.length ? mo[j]! : ours.length
291
+ const ob = j < base.length ? mt[j]! : theirs.length
292
+ const bs = base.slice(i, j)
293
+ const os = ours.slice(a, oa)
294
+ const ts = theirs.slice(b, ob)
295
+ if (same(os, bs)) ok(ts)
296
+ else if (same(ts, bs) || same(os, ts)) ok(os)
297
+ else out.push({ kind: 'conflict', base: bs, ours: os, theirs: ts, oursLine: a + 1 })
298
+ i = j
299
+ a = oa
300
+ b = ob
301
+ }
302
+ return out
303
+ }
304
+
305
+ // ── Files ────────────────────────────────────────────────────────────────────
306
+
307
+ export interface FileMerge {
308
+ path: string
309
+ /** The merged content to write, or null for no file. Where a conflict is left unresolved, ours. */
310
+ merged: string | null
311
+ conflicts: Conflict[]
312
+ }
313
+
314
+ type Format = 'json' | 'yaml' | 'text'
315
+
316
+ function formatOf(path: string): Format {
317
+ if (/\.json$/i.test(path)) return 'json'
318
+ if (/\.ya?ml$/i.test(path)) return 'yaml'
319
+ return 'text'
320
+ }
321
+
322
+ function parse(format: Format, text: string): { ok: true; value: unknown } | { ok: false } {
323
+ try {
324
+ return { ok: true, value: format === 'json' ? JSON.parse(text) : parseYaml(text) }
325
+ } catch {
326
+ return { ok: false }
327
+ }
328
+ }
329
+
330
+ /** A document as a pull writes it: two-space JSON with a final newline, YAML without wrapping. */
331
+ function render(format: Format, value: unknown): string {
332
+ return format === 'json' ? `${JSON.stringify(value, null, 2)}\n` : stringifyYaml(value, { lineWidth: 0 })
333
+ }
334
+
335
+ /**
336
+ * One file's merge, by its kind: JSON and YAML field by field (falling back
337
+ * to lines when a version does not parse), anything else by line. A file one
338
+ * side deleted and the other changed is a conflict over the whole file.
339
+ */
340
+ export function mergeFile(path: string, base: string | null, ours: string | null, theirs: string | null, opts: MergeOptions = {}): FileMerge {
341
+ if (ours === theirs) return { path, merged: ours, conflicts: [] }
342
+ if (base === ours) return { path, merged: theirs, conflicts: [] }
343
+ if (base === theirs) return { path, merged: ours, conflicts: [] }
344
+ if (ours === null || theirs === null) {
345
+ const m = new Merger(opts)
346
+ const side = m.conflict({ path: '', base: base ?? undefined, ours: ours ?? undefined, theirs: theirs ?? undefined })
347
+ return { path, merged: side === 'ours' ? ours : theirs, conflicts: m.conflicts }
348
+ }
349
+ const format = formatOf(path)
350
+ if (format !== 'text') {
351
+ const o = parse(format, ours)
352
+ const t = parse(format, theirs)
353
+ const b = base === null ? ({ ok: true, value: undefined } as const) : parse(format, base)
354
+ if (o.ok && t.ok && b.ok) {
355
+ const { merged, conflicts } = mergeJson(b.value, o.value, t.value, opts)
356
+ // Keep a side's text as it is when the merge is that side: formatting and comments survive.
357
+ const text = deepEqual(merged, o.value) ? ours : deepEqual(merged, t.value) ? theirs : render(format, merged)
358
+ return { path, merged: text, conflicts }
359
+ }
360
+ }
361
+ const { merged, conflicts } = mergeLines(base ?? '', ours, theirs, opts)
362
+ return { path, merged, conflicts }
363
+ }
364
+
365
+ /**
366
+ * Every file of one item: the paths any of the three versions has, in order,
367
+ * conflicts numbered across them so a choice by index names one conflict.
368
+ */
369
+ export function mergeItemFiles(
370
+ base: Record<string, string | null>,
371
+ ours: Record<string, string | null>,
372
+ theirs: Record<string, string | null>,
373
+ opts: Omit<MergeOptions, 'offset'> = {},
374
+ ): { files: FileMerge[]; conflicts: number } {
375
+ const paths = [...new Set([...Object.keys(base), ...Object.keys(ours), ...Object.keys(theirs)])].sort()
376
+ const files: FileMerge[] = []
377
+ let offset = 0
378
+ for (const path of paths) {
379
+ const f = mergeFile(path, base[path] ?? null, ours[path] ?? null, theirs[path] ?? null, { ...opts, offset })
380
+ offset += f.conflicts.length
381
+ files.push(f)
382
+ }
383
+ return { files, conflicts: offset }
384
+ }
385
+
386
+ /**
387
+ * An item's numbered conflicts as one value: where each is and what each
388
+ * side holds. A choice made by number (`--picks 1=workspace`) applies only
389
+ * while it is the same; a workspace change that moves or renumbers the
390
+ * conflicts changes it, and stale picks are refused.
391
+ */
392
+ export function conflictsId(files: FileMerge[]): string {
393
+ const list = files.flatMap((f) => f.conflicts.map((c) => [f.path, c.index, c.path, c.line ?? null, c.base ?? null, c.ours ?? null, c.theirs ?? null]))
394
+ return createHash('sha256').update(JSON.stringify(list)).digest('hex').slice(0, 16)
395
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * One name for each synced item everywhere it is said: the CLI's status,
3
+ * preview and pull lines, Forge's Pull and Propose dialogs, the change
4
+ * request's rows and steps. An item is called by its display name (the
5
+ * Agent's own name, the Blueprint file's display name, a skill's display
6
+ * name), else its slug made readable, and a sentence puts its kind first:
7
+ * "Action Notify the customer", "Skill Brand".
8
+ */
9
+
10
+ const KIND_WORD: Record<string, string> = {
11
+ agent: 'Agent',
12
+ workflow: 'Workflow',
13
+ function: 'Function',
14
+ skill: 'Skill',
15
+ knowledge: 'Knowledge base',
16
+ plugin: 'Plugin',
17
+ evaluation: 'Evaluation dataset',
18
+ dataset: 'Dataset',
19
+ theme: 'Slide theme',
20
+ 'chat-app': 'Chat App',
21
+ app: 'App',
22
+ brand: 'Brand',
23
+ library: 'Library installation',
24
+ lessons: 'Lessons for Agent',
25
+ }
26
+
27
+ const BLUEPRINT_WORD: Record<string, string> = {
28
+ 'object-types': 'Object type',
29
+ 'link-types': 'Link type',
30
+ 'shared-fields': 'Shared field',
31
+ 'semantic-types': 'Semantic type',
32
+ metrics: 'Metric',
33
+ actions: 'Action',
34
+ 'object-sets': 'Saved object set',
35
+ }
36
+
37
+ /** `word-count` → "Word count". */
38
+ export function readableName(slug: string): string {
39
+ const words = slug.replace(/[-_]+/g, ' ').trim()
40
+ return words ? words[0]!.toUpperCase() + words.slice(1) : slug
41
+ }
42
+
43
+ /** The kind of the item a reference names, in a word: `blueprint/actions/x` is an Action. */
44
+ export function kindWord(ref: string): string {
45
+ const [kind, sub] = ref.split('/')
46
+ if (kind === 'blueprint') return BLUEPRINT_WORD[sub ?? ''] ?? 'Blueprint'
47
+ return KIND_WORD[kind ?? ''] ?? readableName(kind ?? 'item')
48
+ }
49
+
50
+ /** An item as a sentence names it: its kind, then its name. A name that already says its kind ("Organization brand") stands alone. */
51
+ export function kindTitle(ref: string, title: string): string {
52
+ const word = kindWord(ref)
53
+ return title.toLowerCase().includes(word.toLowerCase()) ? title : `${word} ${title}`
54
+ }
55
+
56
+ /** A display name from a document's `displayName` or `name`, when it has one. */
57
+ function declared(doc: unknown, ...keys: string[]): string | null {
58
+ if (!doc || typeof doc !== 'object') return null
59
+ for (const k of keys) {
60
+ const v = (doc as Record<string, unknown>)[k]
61
+ if (typeof v === 'string' && v.trim()) return v.trim()
62
+ }
63
+ return null
64
+ }
65
+
66
+ function parseJson(text: string | undefined): unknown {
67
+ if (!text) return null
68
+ try {
69
+ return JSON.parse(text)
70
+ } catch {
71
+ return null
72
+ }
73
+ }
74
+
75
+ /** `displayName:` or `name:` at the top level of YAML (a Blueprint file, a workflow) or in a Markdown file's front matter. */
76
+ function yamlField(text: string | undefined, key: string): string | null {
77
+ if (!text) return null
78
+ const m = text.match(new RegExp(`^${key}:\\s*(.+?)\\s*$`, 'm'))
79
+ if (!m) return null
80
+ const raw = m[1]!.replace(/^(['"])(.*)\1$/, '$2').trim()
81
+ return raw && !raw.startsWith('{') && !raw.startsWith('[') ? raw : null
82
+ }
83
+
84
+ /**
85
+ * Whether a title someone kept is only a slug form, not a name: empty, the
86
+ * item's slug or reference, a Blueprint file's apiName, or the "apiName
87
+ * (kind)" a snapshot gave Blueprint files before they were named by display
88
+ * name. A change request proposed before items were named this way keeps
89
+ * such titles; whoever shows it names the item afresh instead.
90
+ */
91
+ export function isSlugTitle(ref: string, title: string | null | undefined): boolean {
92
+ const t = title?.trim()
93
+ if (!t) return true
94
+ const [kind, ...rest] = ref.split('/')
95
+ const name = rest.join('/')
96
+ if (t === ref || t === name) return true
97
+ if (kind !== 'blueprint') return false
98
+ return t === rest[1] || / \((semantic type|shared field|object type|link type|metric|action|saved object set|blueprint)\)$/i.test(t)
99
+ }
100
+
101
+ /**
102
+ * The files `itemTitle` reads a name from, by project path: a caller that
103
+ * holds only the reference (a kept change request) reads these from the
104
+ * project. A kind named from its slug alone has none.
105
+ */
106
+ export function namingPaths(kind: string, name: string): string[] {
107
+ if (kind === 'agent') return [`agents/${name}/agent.json`]
108
+ if (kind === 'blueprint') {
109
+ const [dir, apiName] = name.split('/')
110
+ return ['yaml', 'yml', 'json'].map((ext) => `blueprint/${dir}/${apiName}.${ext}`)
111
+ }
112
+ if (kind === 'skill') return [`skills/${name}/SKILL.md`]
113
+ if (kind === 'workflow') return [`workflows/${name}.yaml`, `workflows/${name}.yml`]
114
+ if (kind === 'chat-app') return [`chat-apps/${name}.json`]
115
+ if (kind === 'app') return [`apps/${name}.json`]
116
+ if (kind === 'knowledge') return [`knowledge/${name}/knowledge.json`]
117
+ return []
118
+ }
119
+
120
+ /**
121
+ * An item's name as people know it: the name the workspace gave it
122
+ * (`known`), else what its files declare, else its slug made readable.
123
+ * `files` are the item's files by project path, as the project or the
124
+ * workspace holds them. A `known` that is only a slug form (`isSlugTitle`)
125
+ * gives way to the files.
126
+ */
127
+ export function itemTitle(kind: string, name: string, files: Record<string, string>, known?: string | null): string {
128
+ if (known && !isSlugTitle(`${kind}/${name}`, known)) return known.trim()
129
+ if (kind === 'agent') {
130
+ const doc = parseJson(files[`agents/${name}/agent.json`]) as { config?: unknown; name?: unknown } | null
131
+ const own = declared(doc?.config, 'name') ?? declared(doc, 'name')
132
+ if (own) return own
133
+ }
134
+ if (kind === 'blueprint') {
135
+ const [dir, apiName] = name.split('/')
136
+ const file = Object.entries(files).find(([p]) => p.startsWith(`blueprint/${dir}/${apiName}.`))?.[1]
137
+ const own = yamlField(file, 'displayName') ?? declared(parseJson(file), 'displayName')
138
+ return own ?? (dir === 'object-sets' ? readableName(apiName ?? name) : (apiName ?? name))
139
+ }
140
+ if (kind === 'skill') {
141
+ const own = yamlField(files[`skills/${name}/SKILL.md`], 'displayName')
142
+ if (own) return own
143
+ }
144
+ if (kind === 'workflow') {
145
+ const file = files[`workflows/${name}.yaml`] ?? files[`workflows/${name}.yml`]
146
+ const own = yamlField(file, 'displayName')
147
+ if (own) return own
148
+ }
149
+ if (kind === 'chat-app' || kind === 'app') {
150
+ const file = Object.entries(files).find(([p]) => p.endsWith('.json'))?.[1]
151
+ const own = declared(parseJson(file), 'displayName', 'name')
152
+ if (own) return own
153
+ }
154
+ if (kind === 'knowledge') {
155
+ const own = declared(parseJson(files[`knowledge/${name}/knowledge.json`]), 'displayName', 'name')
156
+ if (own) return own
157
+ }
158
+ // An Agent's lessons are named by their Agent: "Lessons for Agent Claims desk".
159
+ if (kind === 'lessons') {
160
+ const own = declared((parseJson(files[`agents/${name}/agent.json`]) as { config?: unknown } | null)?.config, 'name')
161
+ return own ?? readableName(name)
162
+ }
163
+ if (kind === 'evaluation') {
164
+ // As a pull names it: the dataset, then the Agent its cases test.
165
+ const [agent, dataset] = name.split('.')
166
+ return `${readableName(dataset ?? name)} → ${agent}`
167
+ }
168
+ return readableName(name)
169
+ }