@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,941 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { basename } from 'node:path'
3
+
4
+ import { CliError, UsageError } from '../../errors'
5
+ import { canPrompt, promptSecret } from '../../prompt'
6
+ import { allTargets } from '../../local/local-dev-sync'
7
+ import { pullDocuments, type DocumentsPulled } from '../../local/local-knowledge-documents'
8
+ import { lessonsToPropose, pullLessons, type LessonsPulled } from '../../local/local-lessons'
9
+ import { analyticsNeeded, connectionsNeeded, documentationNeeded, standInPath, writePlatformOnly, writeStandIns } from '../../local/local-stand-ins'
10
+ import { localProject, type LocalProject } from '../../local/local-project'
11
+ import { normalizeOrigin, syncClient, type SyncClient } from '../../local/local-sync-client'
12
+ import { getSyncKey, projectFile, readLink, removeSyncKey, setSyncKey, writeLink, type SyncLink } from '../../local/local-sync-link'
13
+ import { describeRepair, repairBases, type BaseRepair } from '../../local/local-sync-base'
14
+ import { conflictsId, mergeItemFiles, type Choice, type Conflict, type Side } from '../../local/local-sync-merge'
15
+ import {
16
+ applyItems,
17
+ baseFiles,
18
+ computePlan,
19
+ forgetItems,
20
+ itemHash,
21
+ KIND_LABEL,
22
+ localFiles,
23
+ readLock,
24
+ refreshConflict,
25
+ resolveItem,
26
+ summarize,
27
+ SYNC_KINDS,
28
+ WORKSPACE_SUFFIX,
29
+ type ItemStatus,
30
+ type PlanItem,
31
+ type RemoteItem,
32
+ type Resolution,
33
+ type SyncKind,
34
+ } from '../../local/local-sync-plan'
35
+ import { readWorkspace, type SyncSource } from '../../local/local-sync-remote'
36
+ import { buildStatusText, projectBuildSummary } from '../../local/local-build-summary'
37
+ import { kindTitle } from '../../local/local-sync-names'
38
+ import { describeUnresolved } from '../../local/local-sync-refs'
39
+ import {
40
+ directoryOf,
41
+ dropBase,
42
+ fetchWorkspace,
43
+ knownGone,
44
+ markCleared,
45
+ readBase,
46
+ readCleared,
47
+ readSnapshot,
48
+ snapshotId,
49
+ snapshotItems,
50
+ usesGovernedActions,
51
+ writeBase,
52
+ type ClearedConflicts,
53
+ type FetchResult,
54
+ type RemoteSnapshot,
55
+ } from '../../local/local-sync-snapshot'
56
+ import { flagBool, flagString, type Command, type CommandContext } from '../types'
57
+ import { currentRequest, readRequests } from '../../local/local-sync-push'
58
+ import { syncPush, syncRequest } from './sync-request'
59
+
60
+ /**
61
+ * `frontera local sync`: a local project's link to one platform workspace.
62
+ *
63
+ * connect check a workspace key (and an organization key) and record the link
64
+ * fetch record what the workspace holds now (`.frontera/sync/`), reading only what changed
65
+ * status offline: the last pull against the files and the last fetch (`--fetch` fetches first)
66
+ * pull the plan (`--preview`), or bring the chosen items in: fast-forward, or a three-way merge
67
+ * diff one item's base, local and workspace versions, file by file, and how they merge
68
+ * resolve settle an item both sides changed: a side per conflict, or the files as edited
69
+ * push propose what changed here as a change request (`sync-request.ts`)
70
+ * request the current change request: show it, apply it, or close it
71
+ * disconnect forget the keys, and with `--forget-link` the link too
72
+ *
73
+ * Only fetch (and pull, when the snapshot is missing or old) calls the
74
+ * platform; status and the plan read the recorded snapshot. Only `push`
75
+ * and `request apply` write to the workspace, through a change request.
76
+ * Keys arrive on stdin or at a hidden prompt and go to the keychain; the
77
+ * link is in `frontera.yaml`.
78
+ */
79
+
80
+ /** A snapshot this recent is used as it is by pull; an older one is fetched again first. */
81
+ const FRESH_MS = 5 * 60 * 1000
82
+
83
+ const STATUS_WORD: Record<ItemStatus, string> = {
84
+ new: 'new',
85
+ // The words Forge's Pull dialog uses.
86
+ changed: 'incoming',
87
+ same: 'same',
88
+ edited: 'changed here',
89
+ conflict: 'both changed',
90
+ unresolved: 'conflicted',
91
+ 'local-only': 'only here',
92
+ 'deleted-remote': 'deleted in workspace',
93
+ 'deleted-local': 'deleted here',
94
+ }
95
+
96
+ function needLink(project: LocalProject): SyncLink {
97
+ const link = readLink(project.root)
98
+ if (!link) throw new CliError('this project is not linked to a workspace', { code: 'SYNC_NOT_LINKED', hint: 'run `frontera local sync connect --platform <url>` with a workspace key' })
99
+ return link
100
+ }
101
+
102
+ export function sourceFor(project: LocalProject, link: SyncLink): SyncSource {
103
+ const key = getSyncKey(project, 'workspace')
104
+ if (!key) throw new CliError('no workspace key for this project on this machine', { code: 'SYNC_NO_KEY', hint: 'run `frontera local sync connect` to store one (the link is in frontera.yaml; keys are per machine)' })
105
+ const org = getSyncKey(project, 'organization')
106
+ return {
107
+ workspace: syncClient(link.platform, key),
108
+ organization: org ? syncClient(link.platform, org) : undefined,
109
+ workspaceId: link.workspace,
110
+ }
111
+ }
112
+
113
+ /** Items the project holds that the workspace may not: for the plan's "only here". */
114
+ function localItems(project: LocalProject): Array<{ ref: string; kind: SyncKind; name: string; files: string[] }> {
115
+ return allTargets(project.root).flatMap((t): Array<{ ref: string; kind: SyncKind; name: string; files: string[] }> => {
116
+ if (t.kind === 'agent') return [{ ref: `agent/${t.slug}`, kind: 'agent', name: t.slug, files: [t.file] }]
117
+ if (t.kind === 'workflow') return [{ ref: `workflow/${t.slug}`, kind: 'workflow', name: t.slug, files: [t.file] }]
118
+ if (t.kind === 'function') {
119
+ const name = basename(t.file).replace(/\.ts$/, '')
120
+ return [{ ref: `function/${name}`, kind: 'function', name, files: [t.file] }]
121
+ }
122
+ return []
123
+ })
124
+ }
125
+
126
+ function kindsFlag(ctx: CommandContext): Set<SyncKind> | undefined {
127
+ const raw = flagString(ctx, 'kinds')
128
+ if (!raw) return undefined
129
+ const aliases: Record<string, SyncKind> = { agents: 'agent', workflows: 'workflow', functions: 'function', skills: 'skill', plugins: 'plugin', evaluations: 'evaluation', evals: 'evaluation', datasets: 'dataset', themes: 'theme', 'chat-apps': 'chat-app', chatapps: 'chat-app', apps: 'app' }
130
+ const out = new Set<SyncKind>()
131
+ for (const part of raw.split(',').map((s) => s.trim()).filter(Boolean)) {
132
+ const kind = (aliases[part] ?? part) as SyncKind
133
+ if (kind === 'lessons') throw new UsageError('lessons are brought with `--lessons`, not as a kind', 'pull with `frontera local sync pull --lessons`')
134
+ if (!(SYNC_KINDS as readonly string[]).includes(kind)) throw new UsageError(`unknown kind \`${part}\``, `use any of: ${SYNC_KINDS.filter((k) => k !== 'lessons').join(', ')}`)
135
+ out.add(kind)
136
+ }
137
+ return out
138
+ }
139
+
140
+ /** What a fetch did to items pulled with conflicts: redone against the newer version, or cleared by it. */
141
+ export interface ConflictsSettled {
142
+ /** Still conflicted: `.workspace` copies rewritten, conflicts renumbered. */
143
+ refreshed: Array<{ ref: string; conflicts: number }>
144
+ /** The newer version merged cleanly with the files here: the merge is written. */
145
+ cleared: string[]
146
+ bases: BaseRepair[]
147
+ }
148
+
149
+ /**
150
+ * Items pulled with conflicts whose workspace version moved since: merge
151
+ * them again with the version the snapshot holds now, so the `.workspace`
152
+ * copies, the numbering and the lock follow the workspace. A newer version
153
+ * that merges cleanly ends the conflict and finishes the pull it came from.
154
+ */
155
+ export async function settleConflicts(project: LocalProject, link: SyncLink, snapshot: RemoteSnapshot, source?: SyncSource): Promise<ConflictsSettled> {
156
+ const out: ConflictsSettled = { refreshed: [], cleared: [], bases: [] }
157
+ let lock = readLock(project.root)
158
+ if (!lock || lock.workspace !== link.workspace) return out
159
+ const items = new Map(snapshotItems(project.root, snapshot).map((i) => [i.ref, i]))
160
+ const moved = Object.entries(lock.items).filter(([ref, e]) => e.conflict && items.has(ref) && e.conflict.remote !== itemHash(items.get(ref)!.files))
161
+ if (!moved.length) return out
162
+ out.bases = await repairBases(project.root, moved.map(([ref, entry]) => ({ ref, entry, remote: items.get(ref)!.files })), { source, capabilities: snapshot.capabilities, directory: directoryOf(snapshot) })
163
+ const cleared: ClearedConflicts = {}
164
+ for (const [ref] of moved) {
165
+ const item = items.get(ref)!
166
+ const before = lock
167
+ const r = refreshConflict(project.root, link.workspace, item, lock, (p) => readBase(project.root, p))
168
+ lock = r.lock
169
+ if (r.outcome === 'renumbered') out.refreshed.push({ ref, conflicts: r.conflicts })
170
+ if (r.outcome === 'cleared') {
171
+ writeBase(project.root, [item], before)
172
+ cleared[ref] = { at: r.lock.items[ref]!.pulledAt, base: r.lock.items[ref]!.remote }
173
+ out.cleared.push(ref)
174
+ }
175
+ }
176
+ markCleared(project.root, cleared)
177
+ return out
178
+ }
179
+
180
+ /** Fetch into the snapshot with the project's keys, then bring conflicted items up to the newer version. */
181
+ export async function fetchFor(project: LocalProject, link: SyncLink, opts: { full?: boolean } = {}): Promise<FetchResult & { conflicts: ConflictsSettled }> {
182
+ const source = sourceFor(project, link)
183
+ const result = await fetchWorkspace(project.root, source, { blueprint: link.blueprint, full: opts.full })
184
+ await nameLink(project, link, source)
185
+ return { ...result, conflicts: await settleConflicts(project, link, result.snapshot, source) }
186
+ }
187
+
188
+ /**
189
+ * A link made while the platform could not say the workspace's name (or
190
+ * before it could) learns it on the next fetch, so the project shows the
191
+ * workspace by its name rather than its id. The link passed in is named too.
192
+ */
193
+ async function nameLink(project: LocalProject, link: SyncLink, source: SyncSource): Promise<void> {
194
+ if (link.workspaceName && link.organizationName) return
195
+ const identity = await source.workspace.find<{ workspace?: { name?: unknown }; organization?: { name?: unknown } }>('/v1/sync/identity').catch(() => null)
196
+ const name = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
197
+ const workspaceName = link.workspaceName ?? name(identity?.workspace?.name)
198
+ const organizationName = link.organizationName ?? name(identity?.organization?.name)
199
+ if (workspaceName === link.workspaceName && organizationName === link.organizationName) return
200
+ Object.assign(link, { workspaceName, organizationName })
201
+ writeLink(project.root, link)
202
+ }
203
+
204
+ /** The snapshot for this link, when one was fetched for it. */
205
+ function snapshotFor(project: LocalProject, link: SyncLink): RemoteSnapshot | null {
206
+ const snap = readSnapshot(project.root)
207
+ return snap && snap.workspace === link.workspace ? snap : null
208
+ }
209
+
210
+ /** The platform with the project's keys, or nothing when this machine has no workspace key. */
211
+ function sourceIfKeyed(project: LocalProject, link: SyncLink): SyncSource | undefined {
212
+ try {
213
+ return sourceFor(project, link)
214
+ } catch {
215
+ return undefined
216
+ }
217
+ }
218
+
219
+ /**
220
+ * The plan from the recorded snapshot. `fetch`: `auto` fetches first when
221
+ * there is no snapshot or it is older than a few minutes, `always` first,
222
+ * `never` not at all (no snapshot: no plan). Items asked for as drafts are
223
+ * read live, since the snapshot holds what is live.
224
+ *
225
+ * `repair`: before merges are counted, a both-changed item whose base is
226
+ * missing gets it back where it can (`local-sync-base.ts`): `offline` from
227
+ * unchanged copies only, `online` from the platform too, at the lock's
228
+ * revision. `bases` says what was found and what was not.
229
+ */
230
+ export async function planFor(project: LocalProject, link: SyncLink, opts: { kinds?: Set<SyncKind>; drafts?: Set<string>; fetch?: 'auto' | 'always' | 'never'; repair?: 'offline' | 'online' } = {}) {
231
+ const local = localItems(project).filter((l) => !opts.kinds || opts.kinds.has(l.kind))
232
+ const readFromBase = (path: string) => readBase(project.root, path)
233
+ const noBases: BaseRepair[] = []
234
+ if (opts.drafts?.size) {
235
+ const lock = readLock(project.root)
236
+ const base = lock?.workspace === link.workspace ? lock : null
237
+ const remote = await readWorkspace(sourceFor(project, link), { kinds: opts.kinds, drafts: opts.drafts, blueprint: link.blueprint })
238
+ return { remote, plan: computePlan(project.root, remote.items, base, local, { base: readFromBase, directory: remote.directory }), lock, snapshot: null, fetched: null, bases: noBases }
239
+ }
240
+ let snapshot = snapshotFor(project, link)
241
+ let fetched: Awaited<ReturnType<typeof fetchFor>> | null = null
242
+ const stale = !snapshot || Date.now() - Date.parse(snapshot.fetchedAt) > FRESH_MS
243
+ if (opts.fetch === 'always' || (opts.fetch !== 'never' && stale)) {
244
+ fetched = await fetchFor(project, link)
245
+ snapshot = fetched.snapshot
246
+ }
247
+ // Read after the fetch: settling conflicts may have moved it.
248
+ const lock = readLock(project.root)
249
+ const base = lock?.workspace === link.workspace ? lock : null
250
+ if (!snapshot) return { remote: { items: [], warnings: [], notes: [] }, plan: null, lock, snapshot: null, fetched, bases: noBases }
251
+ const items = snapshotItems(project.root, snapshot).filter((i) => !opts.kinds || opts.kinds.has(i.kind))
252
+ const planOpts = { remoteKnownGone: knownGone(snapshot), base: readFromBase, directory: directoryOf(snapshot) }
253
+ let plan = computePlan(project.root, items, base, local, planOpts)
254
+ let bases = noBases
255
+ if (opts.repair && base) {
256
+ const byRef = new Map(items.map((i) => [i.ref, i]))
257
+ const targets = plan.flatMap((p) => (p.status === 'conflict' || p.status === 'unresolved') && base.items[p.ref] ? [{ ref: p.ref, entry: base.items[p.ref]!, remote: byRef.get(p.ref)?.files }] : [])
258
+ if (targets.length) {
259
+ bases = await repairBases(project.root, targets, { source: opts.repair === 'online' ? sourceIfKeyed(project, link) : undefined, capabilities: snapshot.capabilities, directory: directoryOf(snapshot) })
260
+ if (bases.some((b) => b.restored.length)) plan = computePlan(project.root, items, base, local, planOpts)
261
+ }
262
+ }
263
+ // A conflict a later workspace change cleared says so while its base is the one that change left.
264
+ const cleared = readCleared(project.root)
265
+ for (const p of plan) if (cleared[p.ref] && base?.items[p.ref] && !base.items[p.ref]!.conflict && base.items[p.ref]!.remote === cleared[p.ref]!.base) p.cleared = true
266
+ return { remote: { items, warnings: snapshot.warnings, notes: snapshot.notes }, plan, lock, snapshot, fetched, bases }
267
+ }
268
+
269
+ /** The counts Forge's card and `status` show, by what they mean for a pull. */
270
+ export function statusCounts(plan: PlanItem[]) {
271
+ const counts = summarize(plan)
272
+ const nowNamed = plan.filter((p) => p.namesOnly).length
273
+ const clashing = plan.filter((p) => p.status === 'conflict' && (p.merge?.conflicts ?? 0) > 0).length
274
+ return {
275
+ counts,
276
+ // Only the workspace moved; an item that only gained names is counted apart.
277
+ incoming: counts.changed - nowNamed,
278
+ nowNamed,
279
+ newInWorkspace: counts.new,
280
+ changedHere: counts.edited,
281
+ bothChanged: counts.conflict,
282
+ /** Both changed and a merge would come out clean. */
283
+ mergeable: counts.conflict - clashing,
284
+ /** Items with conflicts: pulled and not resolved, or both changed in a way a merge cannot settle. */
285
+ conflicts: counts.unresolved + clashing,
286
+ unresolved: counts.unresolved,
287
+ /** Were conflicted until a later workspace change merged cleanly with them. */
288
+ clearedByWorkspace: plan.filter((p) => p.cleared).length,
289
+ deletedInWorkspace: counts['deleted-remote'],
290
+ deletedHere: counts['deleted-local'],
291
+ onlyHere: counts['local-only'],
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Each item that differs from the workspace, by reference, in the words of
297
+ * Forge's card: what a pull brings (`incoming`), what Propose changes sends
298
+ * (`outgoing`), a `conflict`, or `deleted-remote`. The same grouping as
299
+ * `statusCounts`, so a row's mark adds up with the card's counts.
300
+ */
301
+ export function itemStates(plan: PlanItem[]): Record<string, 'incoming' | 'outgoing' | 'conflict' | 'deleted-remote'> {
302
+ const out: Record<string, 'incoming' | 'outgoing' | 'conflict' | 'deleted-remote'> = {}
303
+ for (const p of plan) {
304
+ if (p.status === 'same' || p.namesOnly) continue
305
+ if (p.status === 'unresolved' || (p.status === 'conflict' && (p.merge?.conflicts ?? 0) > 0)) out[p.ref] = 'conflict'
306
+ else if (p.status === 'changed' || p.status === 'new' || p.status === 'conflict') out[p.ref] = 'incoming'
307
+ else if (p.status === 'edited' || p.status === 'local-only' || p.status === 'deleted-local') out[p.ref] = 'outgoing'
308
+ else if (p.status === 'deleted-remote') out[p.ref] = 'deleted-remote'
309
+ }
310
+ return out
311
+ }
312
+
313
+ function platformOf(snapshot: RemoteSnapshot | null) {
314
+ if (!snapshot) return null
315
+ return { version: snapshot.platformVersion, capabilities: snapshot.capabilities, older: snapshot.full }
316
+ }
317
+
318
+ function planText(link: SyncLink, plan: PlanItem[], warnings: string[], notes: string[]): string {
319
+ const visible = plan.filter((p) => p.status !== 'same')
320
+ const lines = [`${link.workspaceName ?? link.workspace} — ${visible.length ? `${visible.length} to look at` : 'the project matches the workspace'}`]
321
+ for (const kind of SYNC_KINDS) {
322
+ const rows = visible.filter((p) => p.kind === kind)
323
+ if (!rows.length) continue
324
+ lines.push('', KIND_LABEL[kind])
325
+ for (const p of rows) {
326
+ // How it runs locally, as Forge's pill says it: nothing for portable.
327
+ const runs = p.portability && p.portability.class !== 'portable' ? ` · ${p.portability.class.replace('-', ' ')}` : ''
328
+ lines.push(` ${p.selected ? '[x]' : '[ ]'} ${(statusWord(p) + runs).padEnd(28)} ${kindTitle(p.ref, p.title)} (${p.ref})`)
329
+ for (const u of describeUnresolved(p.unresolved)) lines.push(` ! ${u}`)
330
+ for (const n of p.notes) lines.push(` · ${n}`)
331
+ }
332
+ }
333
+ for (const n of notes) lines.push('', `Note: ${n}`)
334
+ for (const w of warnings) lines.push('', `Could not read ${w}`)
335
+ lines.push('', 'Pull with `frontera local sync pull <kind/name>…`, `--kinds <kinds>` or `--all-changed`.')
336
+ if (visible.some((p) => p.merge?.conflicts)) lines.push('See a conflict with `frontera local sync diff <kind/name>`; settle it with `resolve`.')
337
+ return lines.join('\n')
338
+ }
339
+
340
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
341
+
342
+ /** "2 min ago (2026-10-17 12:03)": how fresh first, as Forge says it, then the time. */
343
+ export function when(iso: string, now = Date.now()): string {
344
+ const t = Date.parse(iso)
345
+ if (!Number.isFinite(t)) return iso
346
+ const s = Math.max(0, Math.round((now - t) / 1000))
347
+ const m = Math.round(s / 60)
348
+ const h = Math.round(m / 60)
349
+ const d = Math.round(h / 24)
350
+ const rel = s < 45 ? 'just now' : m < 60 ? `${m} min ago` : h < 24 ? `${h} h ago` : d === 1 ? 'yesterday' : `${d} days ago`
351
+ const at = new Date(t)
352
+ const pad = (n: number) => String(n).padStart(2, '0')
353
+ return `${rel} (${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())})`
354
+ }
355
+
356
+ /** What an item's row says: its status, and for a merge how it comes out. */
357
+ function statusWord(p: PlanItem): string {
358
+ if (p.cleared) return `${STATUS_WORD[p.status]} (conflict cleared by a workspace change)`
359
+ if (p.namesOnly) return 'now named'
360
+ if (p.status === 'conflict') return p.merge?.conflicts ? plural(p.merge.conflicts, 'conflict') : 'merges cleanly'
361
+ if (p.status === 'unresolved') return `conflicted (${p.merge?.conflicts ?? 0})`
362
+ return STATUS_WORD[p.status]
363
+ }
364
+
365
+ /** Comma-separated refs from a flag. */
366
+ function refsFlag(ctx: CommandContext, name: string): string[] {
367
+ return (flagString(ctx, name) ?? '').split(',').map((s) => s.trim()).filter(Boolean)
368
+ }
369
+
370
+ async function readKeys(ctx: CommandContext, wantOrg: boolean): Promise<{ workspace: string; organization: string | null }> {
371
+ const fromStdin = flagString(ctx, 'workspace-key') === '-' || flagString(ctx, 'org-key') === '-'
372
+ if (fromStdin) {
373
+ const lines = readFileSync(0, 'utf8').split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
374
+ return { workspace: lines[0] ?? '', organization: wantOrg ? (lines[1] ?? null) : null }
375
+ }
376
+ if (!canPrompt()) throw new UsageError('no key given', 'pass `--workspace-key -` and the key on stdin')
377
+ const workspace = (await promptSecret('Workspace key (sk-ws-…): ')).trim()
378
+ const organization = wantOrg ? (await promptSecret('Organization key (sk-org-…): ')).trim() || null : null
379
+ return { workspace, organization }
380
+ }
381
+
382
+ interface WhoAmI { orgId: string | null; workspaceId: string | null; authKind: string }
383
+
384
+ async function checkKeys(platform: string, keys: { workspace: string; organization: string | null }) {
385
+ if (!keys.workspace.startsWith('sk-ws-')) throw new UsageError('that is not a workspace key', 'a workspace key starts with sk-ws- (Settings → API keys in the workspace)')
386
+ const ws = syncClient(platform, keys.workspace)
387
+ const me = await ws.get<WhoAmI>('/v1/whoami')
388
+ if (me.authKind !== 'workspace-key' || !me.workspaceId || !me.orgId) throw new CliError('the platform did not accept it as a workspace key', { code: 'UNAUTHORIZED', hint: 'create one in the workspace (Settings → API keys) and connect again' })
389
+ const identity = await ws.find<{ workspace: { name: string }; organization: { name: string } }>('/v1/sync/identity').catch(() => null)
390
+ let org: SyncClient | null = null
391
+ if (keys.organization) {
392
+ if (!keys.organization.startsWith('sk-org-')) throw new UsageError('that is not an organization key', 'an organization key starts with sk-org-')
393
+ org = syncClient(platform, keys.organization)
394
+ const orgMe = await org.find<WhoAmI>('/v1/whoami').catch(() => null)
395
+ if (orgMe?.orgId && orgMe.orgId !== me.orgId) throw new CliError('the organization key belongs to another organization than the workspace', { code: 'USAGE', hint: 'use a key from the organization that owns the workspace' })
396
+ // The lifecycle is what an organization key is for here; reading it proves the key.
397
+ await org.get('/v1/blueprint/lifecycle')
398
+ }
399
+ return { me, identity }
400
+ }
401
+
402
+ const connect = async (ctx: CommandContext) => {
403
+ const project = localProject(ctx.cwd)
404
+ const previous = readLink(project.root)
405
+ const platformRaw = flagString(ctx, 'platform') ?? previous?.platform
406
+ if (!platformRaw) throw new UsageError('give the platform address', 'frontera local sync connect --platform https://api.acme.frontera.example')
407
+ const platform = normalizeOrigin(platformRaw)
408
+ const wantOrg = flagString(ctx, 'org-key') !== undefined
409
+ const keys = await readKeys(ctx, wantOrg)
410
+ if (!keys.workspace) throw new UsageError('no workspace key given', 'pass `--workspace-key -` with the key on stdin, or run in a terminal to be prompted')
411
+ const { me, identity } = await checkKeys(platform, keys)
412
+ if (previous && previous.workspace !== me.workspaceId && !flagBool(ctx, 'relink')) {
413
+ throw new CliError(`this project is linked to another workspace (${previous.workspaceName ?? previous.workspace})`, { code: 'CONFLICT', hint: 'a project links to one workspace; pass --relink to point it at this one' })
414
+ }
415
+ const blueprintFlag = flagString(ctx, 'blueprint')
416
+ if (blueprintFlag && blueprintFlag !== 'workspace' && blueprintFlag !== 'organization') throw new UsageError('--blueprint is workspace or organization', '`--blueprint organization` reads the whole model and needs an organization key')
417
+ const link: SyncLink = {
418
+ platform,
419
+ workspace: me.workspaceId!,
420
+ workspaceName: identity?.workspace.name ?? previous?.workspaceName,
421
+ organization: keys.organization ? me.orgId! : previous?.organization,
422
+ organizationName: identity?.organization.name ?? previous?.organizationName,
423
+ blueprint: (blueprintFlag as SyncLink['blueprint'] | undefined) ?? (previous?.blueprint === 'organization' && keys.organization ? 'organization' : 'workspace'),
424
+ }
425
+ // A folder that is not a project yet gets the project file the link lives in.
426
+ if (!existsSync(projectFile(project.root))) writeFileSync(projectFile(project.root), `# A Frontera project. \`frontera local dev\` applies agents/, workflows/, functions/ and blueprint/.\nname: ${basename(project.root)}\n`)
427
+ setSyncKey(project, 'workspace', keys.workspace)
428
+ if (keys.organization) setSyncKey(project, 'organization', keys.organization)
429
+ writeLink(project.root, link)
430
+ return {
431
+ data: { link, keys: { workspace: true, organization: !!(keys.organization || getSyncKey(project, 'organization')) } },
432
+ text: [
433
+ `Linked to ${link.workspaceName ?? link.workspace}${link.organizationName ? ` (${link.organizationName})` : ''} on ${new URL(platform).host}.`,
434
+ 'Keys are in your keychain; the link is in frontera.yaml.',
435
+ 'See what you can pull: `frontera local sync pull --preview`.',
436
+ ].join('\n'),
437
+ }
438
+ }
439
+
440
+ const fetchCommand = async (ctx: CommandContext) => {
441
+ const project = localProject(ctx.cwd)
442
+ const link = needLink(project)
443
+ const result = await fetchFor(project, link, { full: flagBool(ctx, 'full') })
444
+ const snap = result.snapshot
445
+ const count = Object.keys(snap.items).length
446
+ const what = result.changed.length || result.removed.length
447
+ ? [result.changed.length ? `${result.changed.length} changed` : '', result.removed.length ? `${result.removed.length} removed` : ''].filter(Boolean).join(', ')
448
+ : 'nothing changed'
449
+ return {
450
+ data: { fetchedAt: snap.fetchedAt, changed: result.changed, removed: result.removed, fullIndex: result.fullIndex, items: count, platform: platformOf(snap), warnings: snap.warnings, refreshed: result.conflicts.refreshed, cleared: result.conflicts.cleared },
451
+ text: [
452
+ `Fetched ${link.workspaceName ?? link.workspace}: ${snap.full ? `read all ${count} items` : what}.`,
453
+ ...result.conflicts.refreshed.map((r) => `The workspace changed ${r.ref} again: its conflicts are redone against the newer version (${plural(r.conflicts, 'conflict')}); the .workspace copies are updated.`),
454
+ ...result.conflicts.cleared.map((r) => `Conflict cleared by a workspace change: ${r} now merges cleanly, and the merge is written.`),
455
+ ...result.conflicts.bases.filter((b) => b.missing.length).map((b) => describeRepair(b)),
456
+ ...(snap.full ? ['This platform is older: fast checks aren’t available, so every fetch reads the whole workspace.'] : []),
457
+ ...snap.warnings.map((w) => `Could not read ${w}`),
458
+ 'See where the project stands: `frontera local sync status`.',
459
+ ].join('\n'),
460
+ }
461
+ }
462
+
463
+ const status = async (ctx: CommandContext) => {
464
+ const project = localProject(ctx.cwd)
465
+ const link = readLink(project.root)
466
+ const keys = { workspace: !!getSyncKey(project, 'workspace'), organization: !!getSyncKey(project, 'organization') }
467
+ const lock = readLock(project.root)
468
+ const lastPull = lock && link && lock.workspace === link.workspace ? lock.pulledAt ?? null : null
469
+ const none = { fetchedAt: null, platform: null, incoming: 0, nowNamed: 0, newInWorkspace: 0, changedHere: 0, bothChanged: 0, mergeable: 0, conflicts: 0, unresolved: 0, clearedByWorkspace: 0, deletedInWorkspace: 0, deletedHere: 0, onlyHere: 0 }
470
+ if (!link) return { data: { link: null, keys, lastPull: null, counts: null, error: null, ...none }, text: 'Not linked to a workspace. Connect one with `frontera local sync connect --platform <url>`.' }
471
+ let error: string | null = null
472
+ let snapshot = snapshotFor(project, link)
473
+ // `--fetch` asks the platform first; `--offline` is the default now and kept for older callers.
474
+ if (flagBool(ctx, 'fetch') && !flagBool(ctx, 'offline')) {
475
+ if (!keys.workspace) error = 'No workspace key on this machine; connect again to store one.'
476
+ else {
477
+ try {
478
+ snapshot = (await fetchFor(project, link)).snapshot
479
+ } catch (err) {
480
+ error = (err as Error).message
481
+ }
482
+ }
483
+ }
484
+ const { plan } = snapshot ? await planFor(project, link, { fetch: 'never' }) : { plan: null }
485
+ // An Agent's lessons edited here are never in the plan; they are proposed all the same, so they count as changed here.
486
+ const lessons = plan ? lessonsToPropose(project.root, link.workspace) : []
487
+ const counted = plan ? statusCounts(plan) : null
488
+ const tally = counted ? { ...counted, changedHere: counted.changedHere + lessons.length } : null
489
+ const parts = tally
490
+ ? [
491
+ tally.incoming ? `${tally.incoming} incoming` : '',
492
+ tally.nowNamed ? `${tally.nowNamed} now named` : '',
493
+ tally.newInWorkspace ? `${tally.newInWorkspace} new` : '',
494
+ // Conflicts before what merges, as Forge's card says it.
495
+ tally.conflicts ? plural(tally.conflicts, 'conflict') : '',
496
+ tally.mergeable ? `${tally.mergeable} to merge` : '',
497
+ tally.clearedByWorkspace ? `${plural(tally.clearedByWorkspace, 'conflict')} cleared by a workspace change` : '',
498
+ tally.changedHere ? `${tally.changedHere} changed here` : '',
499
+ tally.deletedInWorkspace ? `${tally.deletedInWorkspace} deleted in the workspace` : '',
500
+ tally.deletedHere ? `${tally.deletedHere} deleted here` : '',
501
+ tally.onlyHere ? `${tally.onlyHere} only here` : '',
502
+ ].filter(Boolean)
503
+ : []
504
+ const fetchedAt = snapshot?.fetchedAt ?? null
505
+ // Governed Actions are read only with an organization key (§15.1): ask for one when the workspace uses them.
506
+ const organizationKeyFor = !keys.organization && usesGovernedActions(snapshot) ? 'actions' : null
507
+ // The project's change request, as Forge's card line says it.
508
+ const kept = currentRequest(readRequests(project.root, link.workspace))
509
+ // How the last `local dev` left the project, counted as Forge's home counts it.
510
+ const build = projectBuildSummary(project.root, project.id)
511
+ const request = kept ? `${kept.state === 'merged' ? 'Applied' : kept.applied && !kept.applied.ok && kept.state !== 'closed' ? 'Apply failed' : kept.state === 'closed' ? 'Closed' : 'Proposed'} · ${plural(kept.refs.length, 'item')} · ${kept.title}` : null
512
+ return {
513
+ data: { link, keys, lastPull, ...none, ...(tally ?? { counts: null }), ...(plan ? { items: { ...itemStates(plan), ...Object.fromEntries(lessons.map((slug) => [`lessons/${slug}`, 'outgoing' as const])) } } : {}), fetchedAt, platform: platformOf(snapshot), error, organizationKeyFor, build },
514
+ text: [
515
+ `Linked to ${link.workspaceName ?? link.workspace}${link.organizationName ? ` (${link.organizationName})` : ''} on ${new URL(link.platform).host}`,
516
+ ` Last pull ${lastPull ? when(lastPull) : 'never'}`,
517
+ ` Fetched ${fetchedAt ? when(fetchedAt) : 'never: run `frontera local sync fetch`'}`,
518
+ ` Keys workspace ${keys.workspace ? 'stored' : 'missing'}, organization ${keys.organization ? 'stored' : 'none'}`,
519
+ tally ? ` Workspace ${parts.length ? parts.join(', ') : 'matches the project'}` : ` Workspace ${error ?? 'not fetched yet'}`,
520
+ ...(tally && error ? [` Fetch ${error}`] : []),
521
+ ...(snapshot?.full ? [' Platform older: fast checks aren’t available, so each fetch reads the whole workspace'] : []),
522
+ ...(request ? [` Request ${request}`] : []),
523
+ ...(build ? [` Build ${buildStatusText(build)}`] : []),
524
+ ...(organizationKeyFor ? [' Actions this workspace’s workflows use governed Actions: run `frontera local sync connect` again with an organization key to bring them'] : []),
525
+ ].join('\n'),
526
+ }
527
+ }
528
+
529
+ const pull = async (ctx: CommandContext) => {
530
+ const project = localProject(ctx.cwd)
531
+ const link = needLink(project)
532
+ const kinds = kindsFlag(ctx)
533
+ const drafts = new Set((flagString(ctx, 'drafts') ?? '').split(',').map((s) => s.trim()).filter(Boolean))
534
+ const fetchMode = flagBool(ctx, 'offline') ? 'never' : flagBool(ctx, 'fetch') ? 'always' : 'auto'
535
+ const repair = fetchMode === 'never' ? 'offline' : 'online'
536
+ let { remote, plan, lock, snapshot, bases } = await planFor(project, link, { kinds, drafts, fetch: fetchMode, repair })
537
+ // A conflicted item whose workspace version moved by a fetch that did not settle it (an older CLI's):
538
+ // settle it before pulling, and plan again. A preview writes nothing, so it only plans.
539
+ if (snapshot && !flagBool(ctx, 'preview')) {
540
+ const settled = await settleConflicts(project, link, snapshot, repair === 'online' ? sourceIfKeyed(project, link) : undefined)
541
+ if (settled.refreshed.length || settled.cleared.length) ({ remote, plan, lock, snapshot, bases } = await planFor(project, link, { kinds, drafts, fetch: 'never', repair }))
542
+ }
543
+ if (!plan) throw new CliError('nothing fetched from the workspace yet', { code: 'SYNC_NOT_FETCHED', hint: 'run `frontera local sync fetch`, or pull without --offline' })
544
+ const planId = snapshot ? snapshotId(snapshot) : null
545
+ const preview = { link, items: plan, counts: summarize(plan), warnings: remote.warnings, notes: remote.notes, fetchedAt: snapshot?.fetchedAt ?? null, snapshot: planId, bases }
546
+ const baseLines = bases.length ? ['', 'Merge bases:', ...bases.map((b) => ` ${describeRepair(b)}`)] : []
547
+ if (flagBool(ctx, 'preview')) return { data: preview, text: [planText(link, plan, remote.warnings, remote.notes), ...baseLines].join('\n') }
548
+ // A plan made from an older snapshot is not applied: the workspace moved since it was shown (Terraform's stale plan).
549
+ const expected = flagString(ctx, 'snapshot')
550
+ if (expected && planId && expected !== planId) {
551
+ return {
552
+ data: { ...preview, stale: true, pulled: [], written: [], removed: [] },
553
+ text: ['The workspace changed since that preview; nothing was written. The plan now:', '', planText(link, plan, remote.warnings, remote.notes)].join('\n'),
554
+ }
555
+ }
556
+ const named = ctx.positional.slice(1)
557
+ const byRef = new Map(remote.items.map((i) => [i.ref, i]))
558
+ // Knowledge bases whose documents come too: opt-in, as documents can be large and sensitive.
559
+ const documents = refsFlag(ctx, 'documents').map((r) => r.replace(/^knowledge\//, ''))
560
+ const noBase = documents.filter((n) => !byRef.has(`knowledge/${n}`))
561
+ if (noBase.length) throw new UsageError(`no knowledge base ${noBase.join(', ')} in the workspace`, '--documents takes knowledge base names, as `--preview` lists them (knowledge/<name>)')
562
+ const byPlan = new Map(plan.map((p) => [p.ref, p]))
563
+ const status = new Map(plan.map((p) => [p.ref, p.status]))
564
+ const clean = (p: PlanItem) => p.status === 'changed' || (p.status === 'conflict' && p.merge?.conflicts === 0)
565
+ // What the workspace deleted: delete it here, or keep it as this project's own. Never by default.
566
+ const deleteHere = refsFlag(ctx, 'delete-here')
567
+ const keepLocal = refsFlag(ctx, 'keep-local')
568
+ const notDeleted = [...deleteHere, ...keepLocal].filter((r) => status.get(r) !== 'deleted-remote')
569
+ if (notDeleted.length) throw new UsageError(`not deleted in the workspace: ${notDeleted.join(', ')}`, '--delete-here and --keep-local take items the workspace deleted; `--preview` lists them')
570
+ let chosen: string[]
571
+ if (named.length) {
572
+ const unknown = named.filter((r) => !byRef.has(r))
573
+ const gone = unknown.filter((r) => status.get(r) === 'deleted-remote')
574
+ if (gone.length) throw new UsageError(`deleted in the workspace: ${gone.join(', ')}`, 'say what to do with it: `--delete-here <ref>` or `--keep-local <ref>`')
575
+ if (unknown.length) throw new UsageError(`not in the workspace: ${unknown.join(', ')}`, 'list what is there with `frontera local sync pull --preview`')
576
+ chosen = named
577
+ } else if (flagBool(ctx, 'all-changed')) {
578
+ chosen = plan.filter(clean).map((p) => p.ref)
579
+ } else if (kinds) {
580
+ // A kind pulls what is new, changed or merges cleanly; it never replaces local edits unasked.
581
+ chosen = plan.filter((p) => p.status === 'new' || clean(p)).map((p) => p.ref)
582
+ } else if (deleteHere.length || keepLocal.length || documents.length || flagBool(ctx, 'lessons')) {
583
+ chosen = []
584
+ } else {
585
+ throw new UsageError('say what to pull', 'name items (`agent/support-concierge`), kinds (`--kinds workflows`), or `--all-changed`; `--preview` lists them')
586
+ }
587
+ // A knowledge base not in the project yet comes with its documents.
588
+ for (const n of documents) if (status.get(`knowledge/${n}`) === 'new' && !chosen.includes(`knowledge/${n}`)) chosen.push(`knowledge/${n}`)
589
+ // An item pulled with conflicts waits for `resolve`: a pull writes nothing else for it.
590
+ const waiting = chosen.filter((r) => status.get(r) === 'unresolved')
591
+ chosen = chosen.filter((r) => status.get(r) !== 'unresolved')
592
+ // Bring what the chosen items name and the project does not have yet, so they work locally.
593
+ const queue = [...chosen]
594
+ const take = new Set(chosen)
595
+ const brought: string[] = []
596
+ while (queue.length) {
597
+ const ref = queue.shift()!
598
+ for (const dep of byRef.get(ref)?.requires ?? []) {
599
+ if (take.has(dep) || !byRef.has(dep) || status.get(dep) !== 'new') continue
600
+ take.add(dep)
601
+ brought.push(dep)
602
+ queue.push(dep)
603
+ }
604
+ }
605
+ // Items already matching the workspace are recorded too, so the next plan knows them.
606
+ const adopt = plan.filter((p) => p.status === 'same' && !lock?.items[p.ref]).map((p) => p.ref)
607
+ const items: RemoteItem[] = [...take, ...adopt].map((r) => byRef.get(r)!).filter(Boolean)
608
+ const replaced = [...take].filter((r) => status.get(r) === 'edited' || status.get(r) === 'deleted-local')
609
+ const merge = new Set([...take].filter((r) => status.get(r) === 'conflict'))
610
+ let base = lock?.workspace === link.workspace ? lock : null
611
+ const readFromBase = (path: string) => readBase(project.root, path)
612
+ const removedDeleted: string[] = []
613
+ for (const [refs, deleteFiles] of [[deleteHere, true], [keepLocal, false]] as const) {
614
+ if (!refs.length) continue
615
+ const gone = forgetItems(project.root, link.workspace, refs, base, deleteFiles)
616
+ dropBase(project.root, Object.values(gone.forgotten).flat())
617
+ removedDeleted.push(...gone.removed)
618
+ base = gone.lock
619
+ }
620
+ const result = applyItems(project.root, link.workspace, items, base, { merge, base: readFromBase })
621
+ // What was pulled is the base the next merge starts from; a conflicted item keeps its old base.
622
+ const conflictedRefs = new Set(result.conflicted.map((c) => c.ref))
623
+ writeBase(project.root, items.filter((i) => !conflictedRefs.has(i.ref)), base)
624
+ const pulled = [...take].filter((r) => !conflictedRefs.has(r))
625
+ // Documents asked for land beside each base's knowledge.json; `local dev` uploads them.
626
+ const docs: Record<string, DocumentsPulled> = {}
627
+ if (documents.length) {
628
+ const client = sourceFor(project, link).workspace
629
+ for (const n of documents) {
630
+ const id = byRef.get(`knowledge/${n}`)?.id
631
+ docs[n] = id ? await pullDocuments(project.root, client, { name: n, id }) : { written: [], present: [], skipped: [], failed: ['its id is not in the fetched snapshot: fetch again'] }
632
+ }
633
+ }
634
+ // Built-in connections the project's items use get a declared stand-in (`local-stand-ins.ts`).
635
+ const held = new Set(Object.keys(readLock(project.root)?.items ?? {}))
636
+ const heldItems = remote.items.filter((i) => held.has(i.ref))
637
+ const needs = connectionsNeeded(heldItems)
638
+ const documentation = documentationNeeded(heldItems)
639
+ const standIns = needs.length ? await writeStandIns(project.root, needs, fetchMode === 'never' ? undefined : sourceIfKeyed(project, link)?.workspace, documentation) : { written: [], replaced: [] }
640
+ // The workspace's analytics stays on the platform: declared, so the build says it on each Agent that uses it.
641
+ const platformOnly = writePlatformOnly(project.root, analyticsNeeded(heldItems))
642
+ const connectionStandIns = standIns.written.filter((n) => !documentation.has(n))
643
+ const docsStandIns = standIns.written.filter((n) => documentation.has(n))
644
+ const standInLines = [
645
+ ...(connectionStandIns.length ? ['', `Stand-ins for connections signed in on the platform (their tools answer “not connected” locally; never proposed back): ${connectionStandIns.map((n) => standInPath(n)).join(', ')}`] : []),
646
+ ...(docsStandIns.length ? ['', `Frontera’s documentation for Agents (served locally from the runtime’s documentation; never proposed back): ${docsStandIns.map((n) => standInPath(n)).join(', ')}`] : []),
647
+ ...(platformOnly.length ? ['', `Analytics tools stay on the platform (a local runtime has no analytics; never proposed back): ${platformOnly.join(', ')}`] : []),
648
+ ]
649
+ // Lessons asked for: each Agent's reviewed rules, for every Agent the project now holds that the workspace has.
650
+ let lessons: LessonsPulled | null = null
651
+ if (flagBool(ctx, 'lessons')) {
652
+ const agents = Object.keys(readLock(project.root)?.items ?? {}).flatMap((r) => (r.startsWith('agent/') ? [r.slice('agent/'.length)] : []))
653
+ lessons = await pullLessons(project.root, link.workspace, sourceFor(project, link).workspace, agents)
654
+ }
655
+ const lessonLines = !flagBool(ctx, 'lessons')
656
+ ? []
657
+ : !lessons
658
+ ? ['', 'Lessons: this platform doesn’t give them yet.']
659
+ : [
660
+ '',
661
+ `Lessons (reviewed rules from customer feedback; \`frontera local dev\` gives them to the local Agents): ${lessons.rules ? `${plural(lessons.rules, 'rule')} for ${plural(lessons.written.length + lessons.kept.length, 'Agent')}` : 'none'}`,
662
+ ...lessons.written.map((a) => ` ${a}: agents/${a}/lessons.yaml written`),
663
+ ...lessons.kept.map((a) => ` ${a}: agents/${a}/lessons.yaml changed here since the last pull, so kept`),
664
+ ...lessons.removed.map((a) => ` ${a}: no reviewed rules in the workspace any more; agents/${a}/lessons.yaml removed`),
665
+ ]
666
+ const docLines = Object.entries(docs).flatMap(([n, d]) => [
667
+ ` ${n}: ${plural(d.written.length, 'document')} written${d.present.length ? `, ${d.present.length} already here` : ''}${d.skipped.length ? `, ${d.skipped.length} not a kind local dev uploads (${d.skipped.join(', ')})` : ''}`,
668
+ ...d.failed.map((f) => ` could not read ${f}`),
669
+ ])
670
+ // The workspace's copies beside conflicted files are not project files; `local dev` ignores them.
671
+ const projectWritten = result.written.filter((p) => !p.endsWith(WORKSPACE_SUFFIX))
672
+ const conflictLines = result.conflicted.flatMap((c) => [
673
+ ` ${byPlan.get(c.ref) ? `${kindTitle(c.ref, byPlan.get(c.ref)!.title)} (${c.ref})` : c.ref} ${plural(c.conflicts, 'conflict')}`,
674
+ ...c.files.map((f) => ` ${f}${WORKSPACE_SUFFIX}`),
675
+ ])
676
+ return {
677
+ data: {
678
+ stale: false,
679
+ pulled,
680
+ brought,
681
+ replaced,
682
+ merged: result.merged,
683
+ conflicted: result.conflicted,
684
+ waiting,
685
+ deleted: deleteHere,
686
+ keptLocal: keepLocal,
687
+ written: result.written,
688
+ removed: [...removedDeleted, ...result.removed],
689
+ warnings: remote.warnings,
690
+ bases,
691
+ documents: docs,
692
+ ...(lessons ? { lessons } : {}),
693
+ ...(standIns.written.length ? { standIns: standIns.written } : {}),
694
+ },
695
+ text: [
696
+ pulled.length ? `Pulled ${plural(pulled.length, 'item')} from ${link.workspaceName ?? link.workspace}:` : result.conflicted.length || waiting.length || deleteHere.length || keepLocal.length || lessons ? '' : 'Nothing to pull.',
697
+ ...pulled.map((r) => ` ${r}${brought.includes(r) ? ' (needed by another item)' : ''}${replaced.includes(r) ? ' (replaced your local edits)' : ''}${result.merged.includes(r) ? ' (merged with your changes)' : ''}`),
698
+ ...(result.conflicted.length
699
+ ? [
700
+ '',
701
+ `Changed on both sides, with conflicts. The files keep your version; the workspace’s is beside each:`,
702
+ ...conflictLines,
703
+ 'See them with `frontera local sync diff <ref>`; settle with `resolve <ref> --keep workspace|mine`, or edit the file and `resolve <ref> --edited`.',
704
+ ]
705
+ : []),
706
+ ...(waiting.length ? ['', `Not pulled, conflicts to resolve first: ${waiting.join(', ')}`] : []),
707
+ ...(deleteHere.length ? ['', `Deleted here, as in the workspace: ${deleteHere.join(', ')}`] : []),
708
+ ...(keepLocal.length ? ['', `Kept as this project’s own: ${keepLocal.join(', ')}`] : []),
709
+ ...(projectWritten.length ? ['', `Wrote ${plural(projectWritten.length, 'file')}; \`frontera local dev\` applies them.`] : []),
710
+ ...(docLines.length ? ['', 'Documents (uploaded to the local knowledge base by `frontera local dev`):', ...docLines] : []),
711
+ ...lessonLines,
712
+ ...standInLines,
713
+ ...remote.warnings.map((w) => `Could not read ${w}`),
714
+ ...baseLines,
715
+ ]
716
+ .join('\n')
717
+ .replace(/^\n+/, ''),
718
+ }
719
+ }
720
+
721
+ /** Everything about one item for `diff` and `resolve`: the plan row, the workspace's version, and the three-way merge. */
722
+ async function itemState(project: LocalProject, link: SyncLink, ref: string) {
723
+ // Offline, as diff and resolve are: a lost base comes back only from an unchanged copy here (pull reads the platform).
724
+ const { remote, plan, lock, snapshot } = await planFor(project, link, { fetch: 'never', repair: 'offline' })
725
+ if (!plan || !snapshot) throw new CliError('nothing fetched from the workspace yet', { code: 'SYNC_NOT_FETCHED', hint: 'run `frontera local sync fetch`' })
726
+ const row = plan.find((p) => p.ref === ref)
727
+ const item = remote.items.find((i) => i.ref === ref) ?? null
728
+ const entry = lock?.workspace === link.workspace ? lock.items[ref] : undefined
729
+ if (!row && !item && !entry) throw new UsageError(`not in the workspace or the project: ${ref}`, 'list items with `frontera local sync pull --preview`')
730
+ const paths = [...new Set([...Object.keys(item?.files ?? {}), ...Object.keys(entry?.files ?? {}), ...(entry?.conflict?.files ?? [])])].sort()
731
+ const base = baseFiles(entry, (p) => readBase(project.root, p))
732
+ const local = localFiles(project.root, paths)
733
+ const theirs = item?.files ?? {}
734
+ return { row, item, lock, snapshot, paths, base, local, theirs, entry }
735
+ }
736
+
737
+ const SIDE_FLAG: Record<string, Side> = { workspace: 'theirs', mine: 'ours' }
738
+
739
+ function sideFor(value: string): Side {
740
+ const side = SIDE_FLAG[value]
741
+ if (!side) throw new UsageError(`keep \`${value}\`?`, 'keep `workspace` or `mine`')
742
+ return side
743
+ }
744
+
745
+ /** A pick: a side, or `both` (mine, then the workspace's) where the conflict offers it. */
746
+ function choiceFor(value: string): Choice {
747
+ if (value === 'both') return 'both'
748
+ const side = SIDE_FLAG[value]
749
+ if (!side) throw new UsageError(`pick \`${value}\`?`, 'pick `workspace`, `mine` or `both`')
750
+ return side
751
+ }
752
+
753
+ /** A conflict's version, as a line or a block. */
754
+ function showValue(v: unknown): string[] {
755
+ if (v === undefined) return ['(none)']
756
+ if (typeof v === 'string') return v === '' ? ['(nothing)'] : v.split('\n')
757
+ return JSON.stringify(v, null, 2).split('\n')
758
+ }
759
+
760
+ function conflictText(c: Conflict): string[] {
761
+ const where = c.path ? `${c.path}${c.line ? `, line ${c.line}` : ''}` : c.line ? `line ${c.line}` : 'the whole file'
762
+ const side = (label: string, v: unknown) => showValue(v).map((l, i) => ` ${i ? ''.padEnd(10) : label.padEnd(10)} ${l}`)
763
+ return [` ${c.index}. ${where}${c.both !== undefined ? ' (both can be kept)' : ''}`, ...side('base', c.base), ...side('mine', c.ours), ...side('workspace', c.theirs)]
764
+ }
765
+
766
+ const diff = async (ctx: CommandContext) => {
767
+ const project = localProject(ctx.cwd)
768
+ const link = needLink(project)
769
+ const ref = ctx.positional[1]
770
+ if (!ref) throw new UsageError('name the item', 'frontera local sync diff agent/support-concierge')
771
+ const s = await itemState(project, link, ref)
772
+ const result = mergeItemFiles(s.base, s.local, s.theirs)
773
+ const files = result.files.map((f) => {
774
+ const base = s.base[f.path] ?? null
775
+ const local = s.local[f.path] ?? null
776
+ const workspace = s.theirs[f.path] ?? null
777
+ const state = f.conflicts.length ? 'conflict' : local === workspace ? 'same' : base === local ? 'incoming' : base === workspace ? 'changed-here' : 'merges'
778
+ return { path: f.path, state, base, local, workspace, merged: f.merged, conflicts: f.conflicts }
779
+ })
780
+ const WORD: Record<string, string> = { same: 'the same', incoming: 'only the workspace changed it', 'changed-here': 'only changed here', merges: 'both changed it; merges cleanly' }
781
+ return {
782
+ data: { ref, title: s.row?.title ?? ref, status: s.row?.status ?? null, snapshot: snapshotId(s.snapshot), conflicts: result.conflicts, conflictsId: conflictsId(result.files), cleared: !!s.row?.cleared, baseMissing: Object.values(s.base).some((b) => b === null), files },
783
+ text: [
784
+ `${s.row ? `${kindTitle(ref, s.row.title)} (${ref})` : ref}: ${s.row ? statusWord(s.row) : 'not in the plan'}`,
785
+ ...files.flatMap((f) => [
786
+ '',
787
+ ` ${f.path}: ${f.conflicts.length ? plural(f.conflicts.length, 'conflict') : WORD[f.state]}`,
788
+ ...f.conflicts.flatMap(conflictText),
789
+ ]),
790
+ ...(result.conflicts
791
+ ? [
792
+ '',
793
+ `Settle with \`frontera local sync resolve ${ref} --keep workspace|mine\` (or \`--picks 1=workspace,2=mine\`), or edit and \`--edited\`.`,
794
+ ...(result.files.some((f) => f.conflicts.some((c) => c.both !== undefined)) ? ['A conflict that can keep both takes `=both`: mine, then the workspace’s.'] : []),
795
+ ]
796
+ : []),
797
+ ].join('\n'),
798
+ }
799
+ }
800
+
801
+ const resolve = async (ctx: CommandContext) => {
802
+ const project = localProject(ctx.cwd)
803
+ const link = needLink(project)
804
+ const ref = ctx.positional[1]
805
+ if (!ref) throw new UsageError('name the item', 'frontera local sync resolve agent/support-concierge --keep workspace')
806
+ const s = await itemState(project, link, ref)
807
+ // Picks are by number: they apply only to the conflicts they were chosen for (`conflictsId` from
808
+ // `diff`). Checked first, so a conflict a workspace change cleared reads as stale, not as an error.
809
+ const chosenFor = flagString(ctx, 'conflicts')
810
+ if (chosenFor && chosenFor !== conflictsId(mergeItemFiles(s.base, s.local, s.theirs).files)) {
811
+ throw new CliError('the conflicts changed since they were chosen: the workspace moved, and they are numbered again', { code: 'SYNC_CONFLICTS_STALE', hint: 'look again with `frontera local sync diff <ref>` and choose again' })
812
+ }
813
+ if (!s.row || (s.row.status !== 'conflict' && s.row.status !== 'unresolved')) {
814
+ throw new UsageError(`nothing to resolve in ${ref}: ${s.row ? statusWord(s.row) : 'it is not in the workspace'}`, 'resolve settles an item changed here and in the workspace')
815
+ }
816
+ if (!s.item) throw new UsageError(`the workspace no longer has ${ref}`, 'pull with `--delete-here` or `--keep-local` instead')
817
+ const expected = flagString(ctx, 'snapshot')
818
+ if (expected && expected !== snapshotId(s.snapshot)) throw new CliError('the workspace changed since you looked at this conflict', { code: 'SYNC_PLAN_STALE', hint: 'look again with `frontera local sync diff <ref>`' })
819
+ const keep = flagString(ctx, 'keep')
820
+ const picksRaw = flagString(ctx, 'picks')
821
+ const edited = flagBool(ctx, 'edited')
822
+ if (edited && (keep || picksRaw)) throw new UsageError('--edited takes the files as they are', 'use it alone, or choose sides with --keep / --picks')
823
+ if (!edited && !keep && !picksRaw) throw new UsageError('say how to resolve it', '`--keep workspace`, `--keep mine`, `--picks 1=workspace,2=mine,3=both`, or `--edited` after editing the file')
824
+ const picks = new Map<number, Choice>()
825
+ for (const part of (picksRaw ?? '').split(',').map((p) => p.trim()).filter(Boolean)) {
826
+ const [n, side] = part.split('=')
827
+ if (!n || !/^\d+$/.test(n) || !side) throw new UsageError(`\`${part}\` is not a pick`, 'a pick is <conflict number>=workspace|mine|both, as `diff` numbers them')
828
+ picks.set(Number(n), choiceFor(side))
829
+ }
830
+ const how: Resolution = edited ? { edited: true } : { keep: keep ? sideFor(keep) : undefined, picks }
831
+ const lock = s.lock?.workspace === link.workspace ? s.lock : null
832
+ let result
833
+ try {
834
+ result = resolveItem(project.root, link.workspace, s.item, lock, how, (p) => readBase(project.root, p))
835
+ } catch (err) {
836
+ throw new UsageError((err as Error).message, 'choose every conflict with --picks, or a side for the rest with --keep')
837
+ }
838
+ writeBase(project.root, [s.item], lock)
839
+ return {
840
+ data: { ref, resolved: true, written: result.written, removed: result.removed },
841
+ text: [`Resolved ${ref}: ${edited ? 'your edited files are kept' : 'merged with the sides you chose'}; the base is now the workspace’s.`, ...(result.written.length ? [`Wrote ${plural(result.written.length, 'file')}.`] : [])].join('\n'),
842
+ }
843
+ }
844
+
845
+ const disconnect = async (ctx: CommandContext) => {
846
+ const project = localProject(ctx.cwd)
847
+ const link = readLink(project.root)
848
+ removeSyncKey(project, 'workspace')
849
+ removeSyncKey(project, 'organization')
850
+ const forget = flagBool(ctx, 'forget-link')
851
+ if (forget && link) writeLink(project.root, null)
852
+ return {
853
+ data: { keysRemoved: true, linkRemoved: forget && !!link },
854
+ text: forget ? 'Removed the keys and the link. Pulled files stay in the project.' : 'Removed the keys from this machine. The link stays in frontera.yaml for your teammates; `--forget-link` removes it too.',
855
+ }
856
+ }
857
+
858
+ const SUBCOMMANDS: Record<string, (ctx: CommandContext) => Promise<{ data: unknown; text: string }>> = {
859
+ connect,
860
+ fetch: fetchCommand,
861
+ status,
862
+ pull,
863
+ diff,
864
+ resolve,
865
+ disconnect,
866
+ // Called through, not referenced: `sync-request.ts` imports this module back.
867
+ push: (ctx) => syncPush(ctx),
868
+ request: (ctx) => syncRequest(ctx),
869
+ }
870
+
871
+ export const localSync: Command = {
872
+ meta: {
873
+ noun: 'local',
874
+ verb: 'sync',
875
+ args: [
876
+ { name: 'action', required: true, description: 'connect | fetch | status | pull | diff | resolve | push | request | disconnect' },
877
+ { name: 'items', required: false, description: 'kind/name … to pull or push, the one to diff or resolve, or show | apply | close after request' },
878
+ ],
879
+ flags: {
880
+ platform: 'string',
881
+ 'workspace-key': 'string',
882
+ 'org-key': 'string',
883
+ blueprint: 'string',
884
+ relink: 'boolean',
885
+ preview: 'boolean',
886
+ kinds: 'string',
887
+ drafts: 'string',
888
+ 'all-changed': 'boolean',
889
+ offline: 'boolean',
890
+ fetch: 'boolean',
891
+ full: 'boolean',
892
+ snapshot: 'string',
893
+ conflicts: 'string',
894
+ 'delete-here': 'string',
895
+ 'keep-local': 'string',
896
+ keep: 'string',
897
+ picks: 'string',
898
+ edited: 'boolean',
899
+ 'forget-link': 'boolean',
900
+ title: 'string',
901
+ description: 'string',
902
+ migration: 'string',
903
+ documents: 'string',
904
+ lessons: 'boolean',
905
+ },
906
+ aliases: { t: 'title', d: 'description' },
907
+ summary: 'Link the project to a platform workspace, pull and merge what you choose, and propose changes back',
908
+ examples: [
909
+ 'frontera local sync connect --platform $URL --workspace-key -',
910
+ 'frontera local sync fetch',
911
+ 'frontera local sync status',
912
+ 'frontera local sync status --fetch',
913
+ 'frontera local sync pull --preview',
914
+ 'frontera local sync pull agent/support-concierge workflow/return-label',
915
+ 'frontera local sync pull --kinds blueprint,workflows',
916
+ 'frontera local sync pull knowledge/faq --documents faq',
917
+ 'frontera local sync pull agent/support-concierge --lessons',
918
+ 'frontera local sync diff agent/support-concierge',
919
+ 'frontera local sync resolve agent/support-concierge --keep workspace',
920
+ 'frontera local sync resolve agent/desk --picks 1=mine --conflicts <id>',
921
+ 'frontera local sync pull --delete-here workflow/old-label',
922
+ 'frontera local sync push --preview',
923
+ 'frontera local sync push -t "Tighten the refund procedure"',
924
+ 'frontera local sync push agent/support-concierge -t "New greeting"',
925
+ 'frontera local sync push --documents faq -t "Updated returns policy"',
926
+ 'frontera local sync request',
927
+ 'frontera local sync request apply',
928
+ 'frontera local sync request close',
929
+ 'frontera local sync disconnect --forget-link',
930
+ ],
931
+ offline: true,
932
+ internal: true,
933
+ },
934
+ async run(ctx) {
935
+ const action = ctx.positional[0]
936
+ const run = action ? SUBCOMMANDS[action] : undefined
937
+ const named = action ? `\`${action}\`` : '(none given)'
938
+ if (!run) throw new UsageError(`unknown sync action ${named}`, 'use connect, fetch, status, pull, diff, resolve, push, request or disconnect')
939
+ return run(ctx)
940
+ },
941
+ }