@frontera-sdk/cli 1.52.44 → 1.52.46

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 (110) hide show
  1. package/package.json +4 -4
  2. package/src/api/apps-api.ts +3 -1
  3. package/src/api/automation-api.ts +32 -0
  4. package/src/api/credential-failure.ts +1 -1
  5. package/src/api/governed-action-api.ts +24 -0
  6. package/src/api/media-api.ts +24 -0
  7. package/src/api/platform-api.ts +13 -0
  8. package/src/binding-registry.ts +9 -2
  9. package/src/blueprint/compile.ts +3 -1
  10. package/src/blueprint/ontology-edit-plan.ts +71 -4
  11. package/src/blueprint/org-name-check.ts +64 -0
  12. package/src/blueprint/tree.ts +28 -0
  13. package/src/commands/action/deploy.ts +22 -5
  14. package/src/commands/action/index-commands.ts +2 -0
  15. package/src/commands/action/retire.ts +60 -0
  16. package/src/commands/agent/compose.ts +1 -0
  17. package/src/commands/agent/index-commands.ts +42 -0
  18. package/src/commands/app/deploy.ts +1 -1
  19. package/src/commands/app/dev.ts +2 -2
  20. package/src/commands/app/save.ts +1 -1
  21. package/src/commands/auth/add.ts +5 -1
  22. package/src/commands/auth/current.ts +6 -0
  23. package/src/commands/auth/index-commands.ts +3 -0
  24. package/src/commands/auth/login.ts +84 -0
  25. package/src/commands/auth/use.ts +21 -2
  26. package/src/commands/automation/bindings.ts +54 -10
  27. package/src/commands/automation/index-commands.ts +2 -3
  28. package/src/commands/blueprint/bind.ts +12 -1
  29. package/src/commands/blueprint/declarative.ts +6 -2
  30. package/src/commands/conversation/index-commands.ts +66 -0
  31. package/src/commands/conversation/local-runtime.ts +18 -0
  32. package/src/commands/kit/vendor.ts +62 -48
  33. package/src/commands/local/check.ts +57 -11
  34. package/src/commands/local/data.ts +75 -7
  35. package/src/commands/local/dev.ts +77 -9
  36. package/src/commands/local/embeddings.ts +74 -0
  37. package/src/commands/local/index-commands.ts +133 -11
  38. package/src/commands/local/init.ts +22 -2
  39. package/src/commands/local/new.ts +78 -0
  40. package/src/commands/local/publish.ts +34 -0
  41. package/src/commands/local/steps.ts +105 -0
  42. package/src/commands/local/sync-request.ts +69 -23
  43. package/src/commands/local/sync.ts +2 -0
  44. package/src/commands/media/index-commands.ts +58 -3
  45. package/src/commands/registry.ts +11 -3
  46. package/src/commands/trace/index-commands.ts +56 -0
  47. package/src/commands/workspace/index-commands.ts +14 -7
  48. package/src/config.ts +4 -1
  49. package/src/dev-broker.ts +15 -1
  50. package/src/device-login.ts +143 -0
  51. package/src/exit.ts +36 -8
  52. package/src/flag-help.ts +31 -0
  53. package/src/forge/claude-adapter.ts +17 -4
  54. package/src/forge/permission-rules.ts +158 -0
  55. package/src/forge/touches.ts +7 -6
  56. package/src/function-outside-imports.ts +13 -2
  57. package/src/function-source.ts +90 -0
  58. package/src/harness.ts +4 -4
  59. package/src/key-reach.ts +64 -0
  60. package/src/local/local-actions.ts +15 -3
  61. package/src/local/local-apply-error.ts +26 -0
  62. package/src/local/local-apps.ts +9 -3
  63. package/src/local/local-blueprint-schemas.ts +15 -1
  64. package/src/local/local-confidential.ts +81 -0
  65. package/src/local/local-data-load.ts +130 -0
  66. package/src/local/local-dev-references.ts +24 -4
  67. package/src/local/local-embedding-setup.ts +62 -0
  68. package/src/local/local-jobs.ts +108 -0
  69. package/src/local/local-keys.ts +5 -3
  70. package/src/local/local-new.ts +148 -0
  71. package/src/local/local-org-settings.ts +1 -1
  72. package/src/local/local-person-steps.ts +340 -0
  73. package/src/local/local-personas.ts +16 -5
  74. package/src/local/local-portability.ts +22 -1
  75. package/src/local/local-publish.ts +26 -0
  76. package/src/local/local-request-evidence.ts +35 -6
  77. package/src/local/local-runtime-history.ts +4 -3
  78. package/src/local/local-said-once.ts +51 -0
  79. package/src/local/local-scaffold.ts +40 -2
  80. package/src/local/local-schemas.ts +60 -11
  81. package/src/local/local-seed.ts +1 -1
  82. package/src/local/local-stand-ins.ts +44 -0
  83. package/src/local/local-studio-server.ts +13 -7
  84. package/src/local/local-supervisor.ts +22 -0
  85. package/src/local/local-sync-models.ts +5 -2
  86. package/src/local/local-sync-names.ts +3 -0
  87. package/src/local/local-sync-portable.ts +64 -15
  88. package/src/local/local-sync-precheck.ts +30 -1
  89. package/src/local/local-sync-push.ts +81 -8
  90. package/src/local/local-sync-remote.ts +10 -7
  91. package/src/local/local-test-models.ts +173 -0
  92. package/src/local/local-test-runner.ts +232 -25
  93. package/src/local/local-test-scope.ts +174 -0
  94. package/src/local/local-ungranted-reads.ts +55 -0
  95. package/src/local/local-writers.ts +171 -0
  96. package/src/main.ts +75 -17
  97. package/src/mcp/browser/ax-snapshot.ts +13 -1
  98. package/src/mcp/browser/page-scripts.ts +1 -0
  99. package/src/mcp/mcp-conversations.ts +171 -0
  100. package/src/mcp/mcp-server.ts +4 -1
  101. package/src/mcp/mcp-traces.ts +7 -2
  102. package/src/mcp/tools/agent-tools.ts +94 -2
  103. package/src/mcp/tools/project-tools.ts +149 -13
  104. package/src/mcp/tools/test-tools.ts +64 -13
  105. package/src/output.ts +22 -7
  106. package/src/pack.ts +16 -0
  107. package/src/project-context.ts +18 -5
  108. package/src/project.ts +4 -0
  109. package/src/remote-guard.ts +52 -0
  110. package/src/vendor/kit-assets.json +67 -6
@@ -4,8 +4,7 @@ import { join, resolve } from 'node:path'
4
4
  import { CliError, UsageError } from '../../errors'
5
5
  import { documentPath, readFolders, readPulledDocuments, recordPulledDocuments, sha256Of, type PulledDocument } from '../../local/local-knowledge-documents'
6
6
  import { assetFilename } from '../skill/bundle-commands'
7
- import { outsideImports } from '../../function-outside-imports'
8
- import { packDirectory } from '../../pack'
7
+ import { packFunctionSource } from '../../pack'
9
8
  import { createTarGz } from '../../tar'
10
9
  import { functionEntry } from '../../local/local-dev-sync'
11
10
  import { adoptProposedLessons } from '../../local/local-lessons'
@@ -14,11 +13,12 @@ import { HISTORY_KINDS, projectAtRevision } from '../../local/local-sync-base'
14
13
  import { normalizeOrigin, unwrap } from '../../local/local-sync-client'
15
14
  import { getSyncKey, readLink, setModelMapping, type SyncLink } from '../../local/local-sync-link'
16
15
  import { itemTitle } from '../../local/local-sync-names'
17
- import { checkBody, markMissingModels, markRefused, type WorkspaceModel } from '../../local/local-sync-precheck'
16
+ import { checkBody, markMissingModels, markRefused, needsAPerson, planOf, type ApplyStep, type WorkspaceModel } from '../../local/local-sync-precheck'
17
+ import { afterApplyOf, recordAfterApply } from '../../local/local-person-steps'
18
18
  import { rows } from '../../local/local-sync-remote'
19
- import { evalsDetail, evalsLine, lastRender, sessionScreenshots } from '../../local/local-request-evidence'
19
+ import { evalsDetail, evalsLine, lastRender, sessionScreenshots, standInInstalls } from '../../local/local-request-evidence'
20
20
  import { readAppliedRun, readLastRun, recordApplied, recordRun } from '../../local/local-test-baseline'
21
- import { agentFilesProblem, runCases, selectCaseFiles } from '../../local/local-test-runner'
21
+ import { agentFilesProblem, caseRepeat, passRateThreshold, runCases, selectCaseFiles } from '../../local/local-test-runner'
22
22
  import { findAppProjects } from '../../mcp/mcp-apps'
23
23
  import { runtimeLink } from '../../mcp/mcp-runtime'
24
24
  import { readLock, type SyncKind } from '../../local/local-sync-plan'
@@ -250,6 +250,27 @@ export function releaseItem(slug: string, deployment: string): { ref: string; fi
250
250
  return { ref: `app/${slug}`, files: { [`apps/${slug}.release.json`]: `${JSON.stringify({ deployment }, null, 2)}\n` } }
251
251
  }
252
252
 
253
+ /**
254
+ * One item per Custom App (X6): `app/<slug>` holds its settings
255
+ * (`apps/<slug>.json`) and its release (`apps/<slug>.release.json`), so the
256
+ * settings pushed now and a release asked now or kept from the request go
257
+ * together; the first of a file wins.
258
+ */
259
+ export function mergeAppItems<T extends { ref: string; files: Record<string, string> }>(items: T[]): T[] {
260
+ const out: T[] = []
261
+ const at = new Map<string, number>()
262
+ for (const item of items) {
263
+ const i = at.get(item.ref)
264
+ if (!item.ref.startsWith('app/') || i === undefined) {
265
+ if (item.ref.startsWith('app/')) at.set(item.ref, out.length)
266
+ out.push(item)
267
+ continue
268
+ }
269
+ out[i] = { ...item, ...out[i]!, files: { ...item.files, ...out[i]!.files } }
270
+ }
271
+ return out
272
+ }
273
+
253
274
  /** The deployment a release item names. */
254
275
  export function releasedDeployment(item: Pick<ViewItem, 'name' | 'files'>): string | null {
255
276
  try {
@@ -314,7 +335,7 @@ export function functionBuilder(project: Pick<LocalProject, 'root'>, ctx: Pick<C
314
335
  const at = findAutomationProjectRoot(resolve(root, entry))
315
336
  const inside = at !== null && (at === root || at.startsWith(`${root}/`))
316
337
  const tgz = inside
317
- ? packDirectory(at!, ctx.output, `source of ${item.name}`, outsideImports(at!, resolve(root, entry))).tgz
338
+ ? packFunctionSource(at!, resolve(root, entry), ctx.output, `source of ${item.name}`).tgz
318
339
  : createTarGz(Object.entries(item.files).map(([rel, text]) => ({ rel, bytes: new TextEncoder().encode(text) })))
319
340
  const source = Buffer.from(tgz).toString('base64')
320
341
  return { manifest, bundle: Buffer.from(code, 'utf8').toString('base64'), source }
@@ -415,19 +436,20 @@ async function deletionUsers(project: LocalProject, link: SyncLink, items: PushI
415
436
  * with its reason and fix. A platform without the check leaves them to
416
437
  * Propose: `checked` says which.
417
438
  */
418
- async function precheck(api: Platform, items: PushItem[]): Promise<{ checked: boolean; refused: string[]; problem?: string }> {
439
+ async function precheck(api: Platform, items: PushItem[], organizationKey: string | null): Promise<{ checked: boolean; refused: string[]; plan: ApplyStep[]; problem?: string }> {
419
440
  const body = checkBody(items)
420
- if (!body.length) return { checked: true, refused: [] }
441
+ if (!body.length) return { checked: true, refused: [], plan: [] }
421
442
  try {
422
- const answer = await api.call<{ checks?: unknown[] }>('POST', '/v1/change-requests/check', { items: body }, { timeoutMs: 120_000 })
423
- return { checked: true, refused: markRefused(items, checksOf(answer)) }
443
+ // The organization key stored for the project, so the plan says whether it can make a Steward's steps.
444
+ const answer = await api.call<{ checks?: unknown[]; plan?: unknown }>('POST', '/v1/change-requests/check', { items: body, ...(organizationKey ? { organizationKey } : {}) }, { organizationKey, timeoutMs: 120_000 })
445
+ return { checked: true, refused: markRefused(items, checksOf(answer)), plan: planOf(answer) }
424
446
  } catch (err) {
425
447
  if (err instanceof CliError && (err.code === 'NOT_FOUND' || err.code === 'SYNC_CHECKS')) {
426
448
  // An older platform has no check; a refusal of the check itself names the items as the push would.
427
- if (err.code === 'SYNC_CHECKS') return { checked: true, refused: markRefused(items, checksOf(err.details)) }
428
- return { checked: false, refused: [] }
449
+ if (err.code === 'SYNC_CHECKS') return { checked: true, refused: markRefused(items, checksOf(err.details)), plan: [] }
450
+ return { checked: false, refused: [], plan: [] }
429
451
  }
430
- return { checked: false, refused: [], problem: (err as Error).message.split('\n')[0] }
452
+ return { checked: false, refused: [], plan: [], problem: (err as Error).message.split('\n')[0] }
431
453
  }
432
454
  }
433
455
 
@@ -441,7 +463,7 @@ async function workspaceModels(api: Platform): Promise<WorkspaceModel[]> {
441
463
  }
442
464
  }
443
465
 
444
- function previewText(link: SyncLink, items: PushItem[], kept: { title: string } | null, orgNote: string | null, available: boolean, usedBy: Map<string, string[]> = new Map()): string {
466
+ function previewText(link: SyncLink, items: PushItem[], kept: { title: string } | null, orgNote: string | null, available: boolean, usedBy: Map<string, string[]> = new Map(), person: ReturnType<typeof needsAPerson> = []): string {
445
467
  const ok = items.filter((i) => i.proposable && !i.sample)
446
468
  const samples = items.filter((i) => i.proposable && i.sample)
447
469
  const not = items.filter((i) => !i.proposable)
@@ -453,6 +475,10 @@ function previewText(link: SyncLink, items: PushItem[], kept: { title: string }
453
475
  ]
454
476
  if (samples.length) lines.push('', 'Samples from setting up the project, left out unless named:', ...samples.map((i) => ` [ ] ${KIND_WORD[i.kind]} ${i.title}`))
455
477
  if (not.length) lines.push('', 'Not proposed:', ...not.flatMap((i) => [` [ ] ${KIND_WORD[i.kind]} ${i.title}: ${i.reason}`, ...(i.fix ? [` Fix: ${i.fix}`] : [])]))
478
+ const before = person.filter((p) => !p.after)
479
+ const after = person.filter((p) => p.after)
480
+ if (before.length) lines.push('', 'Needs a person before Apply:', ...before.flatMap((p) => [` - ${p.step}: ${p.message}`, ` Fix: ${p.fix}`]))
481
+ if (after.length) lines.push('', 'Left for a person after Apply:', ...after.flatMap((p) => [` - ${p.step}: ${p.message}`, ` Then: ${p.fix}`]))
456
482
  if (orgNote) lines.push('', orgNote)
457
483
  if (!available) lines.push('', 'This platform does not take change requests yet.')
458
484
  if (ok.length && available) lines.push('', 'Propose with `frontera local sync push -t "<title>"`, or name items to propose only those.')
@@ -530,12 +556,13 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
530
556
  const documents = documentNames.length
531
557
  ? { names: new Set(documentNames), pulled: readPulledDocuments(project.root, link.workspace).bases, platform: await platformDocuments(platformFor(project, link), documentNames, remote.items) }
532
558
  : undefined
533
- const all = pushCandidates({ root: project.root, plan, lock, snapshot, remote: remote.items, ...(documents ? { documents } : {}) })
559
+ const all = pushCandidates({ root: project.root, plan, lock, snapshot, remote: remote.items, named, ...(documents ? { documents } : {}) })
534
560
  // Before anything is sent: models only this computer has, then the workspace's own checks.
535
561
  const known = snapshot.names?.model ? new Set(Object.keys(snapshot.names.model)) : null
536
562
  const missing = known ? markMissingModels(all, known) : new Map<string, string[]>()
537
563
  const api = platformFor(project, link)
538
- const checked = available ? await precheck(api, all) : { checked: false, refused: [] as string[] }
564
+ const checked = available ? await precheck(api, all, getSyncKey(project, 'organization')) : { checked: false, refused: [] as string[], plan: [] as ApplyStep[] }
565
+ const person = needsAPerson(checked.plan)
539
566
  const kept = currentRequest(readRequests(project.root, link.workspace))
540
567
  const updating = kept && isOpen(kept.state) ? kept : null
541
568
  const orgStored = !!getSyncKey(project, 'organization')
@@ -570,9 +597,12 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
570
597
  mapped: link.models ?? {},
571
598
  },
572
599
  organizationKey: { needed: needsOrganizationKey(chosen), stored: orgStored },
600
+ // Apply's steps in order, and the ones the project's credentials can't take (N3).
601
+ applyPlan: checked.plan,
602
+ needsPerson: person,
573
603
  fetchedAt: snapshot.fetchedAt,
574
604
  },
575
- text: previewText(link, chosen, updating, orgNote, available, usedBy),
605
+ text: previewText(link, chosen, updating, orgNote, available, usedBy, person),
576
606
  }
577
607
  }
578
608
 
@@ -605,8 +635,9 @@ export async function syncPush(ctx: CommandContext): Promise<{ data: unknown; te
605
635
 
606
636
  // Custom App releases: each names a deployment that already exists. A later push keeps the releases the request has.
607
637
  const releases = await releaseItems(api, releaseAsks)
608
- const keptReleases = target ? (await api.call<View>('GET', `/v1/change-requests/${target.id}`)).items.filter((i) => i.kind === 'app' && !releases.some((r) => r.ref === i.ref)).map((i) => ({ ref: i.ref, files: i.files })) : []
609
- const items = [...(await pushBody(going, functionBuilder(project, ctx), assetUploader(project, api))), ...releases.map(({ ref, files }) => ({ ref, files })), ...keptReleases]
638
+ const keptReleases = target ? (await api.call<View>('GET', `/v1/change-requests/${target.id}`)).items.filter((i) => i.kind === 'app' && !releases.some((r) => r.ref === i.ref) && releasedDeployment(i)).map((i) => releaseItem(i.name, releasedDeployment(i)!)) : []
639
+ // An App's settings pushed now and its release go as one item (X6).
640
+ const items = mergeAppItems([...(await pushBody(going, functionBuilder(project, ctx), assetUploader(project, api))), ...releases.map(({ ref, files }) => ({ ref, files })), ...keptReleases])
610
641
  let view: View
611
642
  try {
612
643
  view = target
@@ -887,7 +918,8 @@ async function apply(ctx: CommandContext) {
887
918
  if (view.request.state === 'closed') throw new CliError(`“${view.request.title}” is closed`, { code: 'CONFLICT', hint: 'propose the changes again with `frontera local sync push -t "<title>"`' })
888
919
  const allTitles = Object.fromEntries(view.items.map((i) => [i.ref, titleOf(i, titles)]))
889
920
  const blueprint = view.items.some((i) => i.kind === 'blueprint')
890
- const organizationKey = blueprint ? getSyncKey(project, 'organization') : null
921
+ // Sent whenever one is stored: an Agent's Blueprint grants are a Steward's too, in a request without Blueprint files (P5 A5).
922
+ const organizationKey = getSyncKey(project, 'organization')
891
923
  if (blueprint && !organizationKey) {
892
924
  throw new CliError('this change request changes the Blueprint, and applying it needs the organization key; none is stored for this project', { code: 'SYNC_NO_ORG_KEY', hint: 'connect again with `frontera local sync connect --workspace-key - --org-key -` (both keys on stdin), then apply' })
893
925
  }
@@ -947,13 +979,18 @@ async function apply(ctx: CommandContext) {
947
979
  await recordAppliedDocuments(project, link, answer).catch(() => null)
948
980
  // The Agents applied: their cases' last local runs become the baseline the next request's evals compare with.
949
981
  recordApplied(project, answer.items.filter((i) => i.kind === 'agent' && !i.deleted).map((i) => i.name))
982
+ // What the request left for a person: kept as the delivery's person-steps, which Forge shows as Pending in workspace (N6).
983
+ const pending = recordAfterApply(project.root, answer.request.title, afterApplyOf(answer.outcome))
950
984
  return {
951
- data: { applied: true, outcome, request: { id: answer.request.id, title: answer.request.title, state: answer.request.state }, fetched },
985
+ data: { applied: true, outcome, request: { id: answer.request.id, title: answer.request.title, state: answer.request.state }, fetched, pendingInWorkspace: pending },
952
986
  text: [
953
987
  `Applied “${answer.request.title}” to ${link.workspaceName ?? link.workspace}.`,
954
988
  ...describeOutcome(outcome, allTitles),
955
989
  '',
956
990
  fetched.error ? `Could not fetch afterwards: ${fetched.error}. Run \`frontera local sync fetch\`.` : `Fetched: ${plural(fetched.adopted.length, 'item')} now match${fetched.adopted.length === 1 ? 'es' : ''} the workspace.`,
991
+ ...(pending.length
992
+ ? ['', `Pending in workspace (${pending.length}), for a person:`, ...pending.map((p) => ` - ${p.changes}${p.command ? `: \`${p.command}\`` : ''}${p.check ? ' (checks itself)' : ''}`), 'Forge lists them on the project’s home; `frontera local steps` here.']
993
+ : []),
957
994
  ].join('\n'),
958
995
  }
959
996
  }
@@ -1027,6 +1064,15 @@ async function evidence(ctx: CommandContext) {
1027
1064
  const attached: Array<{ kind: string; ref: string | null; title: string }> = []
1028
1065
  const skipped: string[] = []
1029
1066
 
1067
+ // `--repeat N`: each gated case runs N times, and a flaky one passes at `--pass-rate` (X1).
1068
+ let repeat: number | undefined
1069
+ let passRate: number | undefined
1070
+ try {
1071
+ repeat = caseRepeat(flagString(ctx, 'repeat'))
1072
+ passRate = passRateThreshold(flagString(ctx, 'pass-rate'))
1073
+ } catch (err) {
1074
+ throw new UsageError((err as Error).message, 'frontera local sync request evidence --repeat 3')
1075
+ }
1030
1076
  if (want.evals) {
1031
1077
  const agents = view.items.filter((i) => i.kind === 'agent' && !i.deleted)
1032
1078
  if (agents.length) {
@@ -1038,10 +1084,10 @@ async function evidence(ctx: CommandContext) {
1038
1084
  // Chosen as `local test` and MCP `test_run { gate: true }` choose them, and run on what the files say:
1039
1085
  // a draft that isn't fails its cases with what to run, as there.
1040
1086
  const files = selectCaseFiles(project.root, { agent: item.name, gate: true })
1041
- const results = files.length ? await runCases({ serviceUrl: runtime.serviceUrl, creds: runtime.creds, files, live: false, staleDraft: (slug) => agentFilesProblem(project, runtime.creds.organizationId, slug), onResult: () => {} }) : []
1087
+ const results = files.length ? await runCases({ serviceUrl: runtime.serviceUrl, creds: runtime.creds, files, live: false, ...(repeat ? { repeat } : {}), ...(passRate ? { passRate } : {}), staleDraft: (slug) => agentFilesProblem(project, runtime.creds.organizationId, slug), onResult: () => {} }) : []
1042
1088
  // Compared with the run when the Agent was last applied from here; never applied from here, with the last run.
1043
1089
  const baseline = Object.keys(applied).some((k) => k.startsWith(`${item.name}\u0000`)) ? applied : last
1044
- const detail = evalsDetail({ files, results, baseline, runtime: `local ${runtime.state.version ?? ''}`.trim() })
1090
+ const detail = evalsDetail({ files, results, baseline, runtime: `local ${runtime.state.version ?? ''}`.trim(), standIns: standInInstalls(project.root) })
1045
1091
  if (results.length) recordRun(project, results)
1046
1092
  // Cases that couldn't run (the provider refused them) prove nothing either way: attached, they would read as none newly failing.
1047
1093
  if (detail.couldNotRun.length) {
@@ -1009,6 +1009,8 @@ export const localSync: Command = {
1009
1009
  force: 'boolean',
1010
1010
  reason: 'string',
1011
1011
  evals: 'boolean',
1012
+ repeat: 'string',
1013
+ 'pass-rate': 'string',
1012
1014
  renders: 'boolean',
1013
1015
  screenshots: 'string',
1014
1016
  trace: 'string',
@@ -91,7 +91,8 @@ const list: Command = {
91
91
  verb: 'list',
92
92
  authLane: 'session',
93
93
  args: [],
94
- flags: {},
94
+ // A local persona (L6): on the project's local runtime only.
95
+ flags: { as: 'string' },
95
96
  summary: 'List the Media Sets visible to you in a workspace',
96
97
  sessionAlternative: 'Blueprint → Media Sets',
97
98
  examples: ['frontera media list --workspace <workspaceId>', 'frontera media list --workspace <workspaceId> --json'],
@@ -187,7 +188,7 @@ const upload: Command = {
187
188
  { name: 'set', required: true, description: 'Media Set API name or id, from `frontera media list`' },
188
189
  { name: 'path...', required: true, description: 'files or directories; directories are walked, unsupported types skipped' },
189
190
  ],
190
- flags: { markings: 'string', 'skip-existing': 'boolean' },
191
+ flags: { markings: 'string', 'skip-existing': 'boolean', as: 'string' },
191
192
  summary: 'Upload local files into a Media Set; the same path again adds a new version',
192
193
  sessionAlternative: 'Blueprint → Media Sets → Files',
193
194
  examples: [
@@ -316,6 +317,60 @@ const upload: Command = {
316
317
  },
317
318
  }
318
319
 
320
+ /**
321
+ * A file staged for an Action to attach (L6): the Media Set's upload lane,
322
+ * which keeps the file out of the set until an Action request that carries
323
+ * the returned id applies. Pass the id as the Action's upload input.
324
+ */
325
+ const stage: Command = {
326
+ meta: {
327
+ noun: 'media',
328
+ verb: 'stage',
329
+ authLane: 'session',
330
+ args: [
331
+ { name: 'set', required: true, description: 'the Media Set the Action attaches into: API name or id' },
332
+ { name: 'file', required: true, description: 'the file to stage' },
333
+ ],
334
+ flags: { markings: 'string', as: 'string' },
335
+ summary: 'Stage a file for an Action to attach; prints the id to pass as its upload input',
336
+ sessionAlternative: 'the Action\'s form, which stages the file it is given',
337
+ examples: [
338
+ 'frontera media stage evidence photo.jpg --workspace <workspaceId>',
339
+ 'frontera media stage evidence photo.jpg --as submitter',
340
+ ],
341
+ },
342
+ async run(ctx) {
343
+ const [ref, path] = ctx.positional
344
+ if (!ref || !path) throw new UsageError('missing <set> or <file>', 'frontera media stage <set> <file>')
345
+ const mime = MEDIA_MIME_BY_EXTENSION[extensionOf(path)]
346
+ if (!mime) throw new UsageError(`cannot stage ${path}: not a type a Media Set takes`, `the types: ${Object.keys(MEDIA_MIME_BY_EXTENSION).join(', ')}`)
347
+ let size: number
348
+ try {
349
+ size = statSync(path).size
350
+ } catch {
351
+ throw new UsageError(`no file at ${path}`, 'pass the path of one file')
352
+ }
353
+ if (size > MEDIA_MAX_BYTES) throw new CliError(`${path} is larger than the 50 MB Media Set limit`, { code: 'BAD_REQUEST' })
354
+ const client = api(ctx)
355
+ const setId = await resolveSet(client, ref)
356
+ const markings = flagString(ctx, 'markings')
357
+ const staged = await client.stage(setId, {
358
+ bytes: new Uint8Array(readFileSync(path)),
359
+ filename: basename(path),
360
+ mime,
361
+ logicalPath: basename(path),
362
+ ...(markings ? { markings } : {}),
363
+ })
364
+ return {
365
+ data: { set: ref, setId, upload: staged },
366
+ text: [
367
+ `staged ${basename(path)} for an Action: ${staged.mediaItemId}`,
368
+ ' pass it as the Action\'s upload input (`frontera action submit <apiName> --input \'{…, "<upload input>": "' + staged.mediaItemId + '"}\'`); it joins the set only when that request applies',
369
+ ].join('\n'),
370
+ }
371
+ },
372
+ }
373
+
319
374
  const processCommand: Command = {
320
375
  meta: {
321
376
  noun: 'media',
@@ -589,4 +644,4 @@ const revoke: Command = {
589
644
  },
590
645
  }
591
646
 
592
- export const mediaCommands: Command[] = [list, create, upload, processCommand, status, retry, search, grant, revoke]
647
+ export const mediaCommands: Command[] = [list, create, upload, stage, processCommand, status, retry, search, grant, revoke]
@@ -50,10 +50,13 @@ import { loginCommand } from './login'
50
50
  import { setupCommand } from './setup'
51
51
  import { authCommands } from './auth/index-commands'
52
52
  import { kitCommands } from './kit/index-commands'
53
+ import { conversationCommands } from './conversation/index-commands'
54
+ import { traceCommands } from './trace/index-commands'
53
55
  import { workspaceCommands } from './workspace/index-commands'
54
56
  import { forgeCommands } from './forge/index-commands'
55
57
  import { localCommands } from './local/index-commands'
56
58
  import { localSnapshot } from './local/snapshot'
59
+ import { localSteps } from './local/steps'
57
60
  import { mcpServe } from './mcp/serve'
58
61
 
59
62
  /**
@@ -62,6 +65,8 @@ import { mcpServe } from './mcp/serve'
62
65
  */
63
66
  export const GLOBAL_FLAGS: FlagSpec = {
64
67
  json: 'boolean',
68
+ // `{ ok, data, warnings }` on stdout, failures included (X10); implies --json.
69
+ envelope: 'boolean',
65
70
  quiet: 'boolean',
66
71
  yes: 'boolean',
67
72
  help: 'boolean',
@@ -93,10 +98,13 @@ export const COMMANDS: readonly Command[] = [
93
98
 
94
99
  ...authCommands,
95
100
  ...kitCommands,
101
+ ...conversationCommands,
102
+ ...traceCommands,
96
103
  ...workspaceCommands,
97
104
  ...forgeCommands,
98
105
  ...localCommands,
99
106
  localSnapshot,
107
+ localSteps,
100
108
  mcpServe,
101
109
 
102
110
  appInit,
@@ -431,9 +439,9 @@ export function renderNounHelp(noun: string): string {
431
439
 
432
440
  /** The help line every session-lane command prints — see `SESSION_LANE_HINT`. */
433
441
  export const SESSION_LANE_HELP =
434
- 'Needs a person’s session, not an API key. Set FRONTERA_TOKEN to your session token '
435
- + 'and FRONTERA_API_URL (or pass --api-url). sk-ws- and sk-org- keys are refused with '
436
- + '401 or 403; `frontera login` stores keys only.'
442
+ 'Needs a person’s session, not an API key: sign in as yourself with '
443
+ + '`frontera auth login --browser` (approved in your browser, lasts 12 hours). '
444
+ + 'sk-ws- and sk-org- keys are refused with 401 or 403.'
437
445
 
438
446
  export function renderCommandHelp(meta: CommandMeta): string {
439
447
  const width = terminalWidth()
@@ -0,0 +1,56 @@
1
+ import { UsageError } from '../../errors'
2
+ import { getTrace, listTraces, readTrace } from '../../mcp/mcp-traces'
3
+ import { projectRuntime } from '../conversation/local-runtime'
4
+ import { flagBool, flagString, type Command } from '../types'
5
+
6
+ /**
7
+ * `frontera trace get|list`: the project's local runtime's traces (Forge
8
+ * improvement plan N7), the reads the MCP tools `trace_get` and `trace_list`
9
+ * make, for a person or an agent working in the shell.
10
+ */
11
+
12
+ export const traceGet: Command = {
13
+ meta: {
14
+ noun: 'trace',
15
+ verb: 'get',
16
+ args: [{ name: 'trace', required: true, description: 'The trace id (from a test run, `trace list` or `conversation get`)' }],
17
+ flags: { brief: 'boolean' },
18
+ flagHelp: { brief: 'the span tree without each span’s input and output' },
19
+ summary: 'Read one trace on the project’s local runtime: spans, tool calls, tokens, the reply check',
20
+ examples: ['frontera trace get 0c5e… --json'],
21
+ offline: true,
22
+ },
23
+ async run(ctx) {
24
+ const id = ctx.positional[0]
25
+ if (!id) throw new UsageError('missing <trace>', '`frontera trace list` lists the recent ones')
26
+ const link = projectRuntime(ctx.cwd)
27
+ const trace = await getTrace(link, id)
28
+ if (!trace) throw new UsageError(`no trace ${id} on the local runtime`, '`frontera trace list` lists the recent ones')
29
+ const read = readTrace(link, trace, { detail: !flagBool(ctx, 'brief') })
30
+ return { data: read, text: JSON.stringify(read, null, 2) }
31
+ },
32
+ }
33
+
34
+ export const traceList: Command = {
35
+ meta: {
36
+ noun: 'trace',
37
+ verb: 'list',
38
+ args: [],
39
+ flags: { conversation: 'string', name: 'string', errors: 'boolean', limit: 'string' },
40
+ flagHelp: { conversation: 'only this conversation’s turns', name: 'only traces whose name contains this, e.g. an Agent’s slug', errors: 'only traces with an error', limit: 'at most this many (default 10)' },
41
+ summary: 'Recent traces on the project’s local runtime, newest first',
42
+ examples: ['frontera trace list', 'frontera trace list --conversation 6f1c… --json'],
43
+ offline: true,
44
+ },
45
+ async run(ctx) {
46
+ const link = projectRuntime(ctx.cwd)
47
+ const limit = Math.min(Math.max(Number(flagString(ctx, 'limit') ?? 10) || 10, 1), 50)
48
+ const traces = await listTraces(link, { limit, conversationId: flagString(ctx, 'conversation'), name: flagString(ctx, 'name'), errorsOnly: flagBool(ctx, 'errors') })
49
+ return {
50
+ data: { traces },
51
+ text: traces.length ? traces.map((t) => `${t.id} ${t.at} ${t.name}${t.level !== 'DEFAULT' ? ` ${t.level}` : ''}`).join('\n') : 'No traces.',
52
+ }
53
+ },
54
+ }
55
+
56
+ export const traceCommands: readonly Command[] = [traceGet, traceList]
@@ -1,4 +1,5 @@
1
1
  import { BlueprintAuthoringApi } from '../../api/blueprint-authoring-api'
2
+ import { keyReach, reachWords } from '../../key-reach'
2
3
  import { table } from '../../table'
3
4
  import type { Command } from '../types'
4
5
 
@@ -27,24 +28,30 @@ const list: Command = {
27
28
  verb: 'list',
28
29
  args: [],
29
30
  flags: {},
30
- summary: 'List workspaces in this organization, with the ids --workspace takes',
31
+ summary: 'List workspaces in this organization, with the ids --workspace takes and, for an organization key, which it may act in',
31
32
  examples: ['frontera workspace list', 'frontera workspace list --json'],
32
33
  },
33
34
  async run(ctx) {
34
35
  const rows = await new BlueprintAuthoringApi(ctx.apiUrl, ctx.token).orgWorkspaces()
36
+ // An organization key acts only in the workspaces on its list: each row says whether it may (X3).
37
+ const reach = ctx.token.startsWith('sk-org-') && rows.length ? await keyReach(ctx.apiUrl, ctx.token, { list: async () => rows }) : null
38
+ const actsIn = new Map((reach ?? []).map((w) => [w.id, w.actsIn]))
35
39
 
36
40
  return {
37
- data: rows,
41
+ data: reach ? rows.map((w) => ({ ...w, actsIn: actsIn.get(w.id ?? '') ?? false })) : rows,
38
42
  text: rows.length === 0
39
43
  ? 'No workspaces visible to this credential.\n'
40
44
  + ' This read needs an organization key (sk-org-…) carrying organization:update.'
41
45
  : // ID first: it is the value `--workspace` takes, and the reason to
42
46
  // run this at all. Name and slug are for recognising which is which.
43
- table(
44
- ['id', 'name', 'slug'],
45
- rows.map((w) => [w.id ?? '?', w.name ?? '', w.slug ?? '']),
46
- [undefined, 34, undefined],
47
- ),
47
+ [
48
+ table(
49
+ reach ? ['id', 'name', 'slug', 'this key'] : ['id', 'name', 'slug'],
50
+ rows.map((w) => [w.id ?? '?', w.name ?? '', w.slug ?? '', ...(reach ? [actsIn.get(w.id ?? '') ? 'acts here' : '—'] : [])]),
51
+ [undefined, 34, undefined, undefined],
52
+ ),
53
+ ...(reach ? ['', `This key ${reachWords(reach)}.`] : []),
54
+ ].join('\n'),
48
55
  }
49
56
  },
50
57
  }
package/src/config.ts CHANGED
@@ -140,7 +140,10 @@ export function resolveProfileName(
140
140
  },
141
141
  )
142
142
  }
143
- const companion = opts.organization ? localOrganizationCompanion(found.profile, env) : null
143
+ // An organization command takes the organization profile the binding names (X3), or a local
144
+ // runtime's own organization profile beside its workspace profile.
145
+ const named = opts.organization && found.organizationProfile && found.organizationTrusted && getProfile(found.organizationProfile, env)?.credentialKind === 'organization' ? found.organizationProfile : null
146
+ const companion = named ?? (opts.organization ? localOrganizationCompanion(found.profile, env) : null)
144
147
  if (companion) return { profile: companion, source: 'directory', path: found.path }
145
148
  return { profile: found.profile, source: 'directory', path: found.path }
146
149
  }
package/src/dev-broker.ts CHANGED
@@ -79,12 +79,26 @@ function freeOn(port: number, host: string): Promise<boolean> {
79
79
  const server = createServer()
80
80
  server.once('error', (err: NodeJS.ErrnoException) => {
81
81
  // No IPv6 on this machine: nothing can hold the port there either.
82
- resolve(err.code === 'EADDRNOTAVAIL' || err.code === 'EAFNOSUPPORT')
82
+ if (err.code === 'EADDRNOTAVAIL' || err.code === 'EAFNOSUPPORT') return resolve(true)
83
+ // Bun says only "Failed to listen at ::1", with no code: free when no IPv6 port can be bound at all.
84
+ if (!err.code && host.includes(':')) return void ipv6Available().then((has) => resolve(!has))
85
+ resolve(false)
83
86
  })
84
87
  server.listen({ port, host, exclusive: true, ...(host === '::' ? { ipv6Only: true } : {}) }, () => server.close(() => resolve(true)))
85
88
  })
86
89
  }
87
90
 
91
+ let ipv6: Promise<boolean> | null = null
92
+ /** Whether this machine can listen on IPv6 at all: a port the system picks on ::1. */
93
+ function ipv6Available(): Promise<boolean> {
94
+ ipv6 ??= new Promise((resolve) => {
95
+ const server = createServer()
96
+ server.once('error', () => resolve(false))
97
+ server.listen({ port: 0, host: '::1' }, () => server.close(() => resolve(true)))
98
+ })
99
+ return ipv6
100
+ }
101
+
88
102
  /**
89
103
  * Whether nothing listens on `port` on either loopback, IPv4 or IPv6, nor on
90
104
  * a wildcard. Probing 127.0.0.1 alone let a dev server start beside another
@@ -0,0 +1,143 @@
1
+ import type { CredentialStore } from './credential-store'
2
+ import { CliError } from './errors'
3
+
4
+ /**
5
+ * A person's session for the CLI, approved in the browser (Forge improvement
6
+ * plan N5, RFC 8628 device authorization).
7
+ *
8
+ * Some commands act as a person (publishing a Blueprint, reviewing an
9
+ * Action, granting) and refuse every API key. Before, the only way to run
10
+ * them from a terminal was a session token copied out of the browser's
11
+ * developer tools. Now `frontera auth login --browser` asks the platform for
12
+ * a code, the person approves it on `/auth/device` while signed in, and the
13
+ * CLI keeps the session it receives in the keychain, beside the profile's
14
+ * key, for those commands only. The platform makes it short-lived (12 hours)
15
+ * and `frontera auth logout` revokes it.
16
+ */
17
+
18
+ /** The client id the platform accepts device codes for. */
19
+ export const DEVICE_CLIENT_ID = 'frontera-cli'
20
+
21
+ const GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
22
+
23
+ export interface DeviceSession {
24
+ token: string
25
+ /** ISO time the platform said it stops working. */
26
+ expiresAt: string
27
+ apiUrl: string
28
+ email?: string
29
+ }
30
+
31
+ export interface DeviceCode {
32
+ deviceCode: string
33
+ userCode: string
34
+ verificationUri: string
35
+ verificationUriComplete: string
36
+ intervalMs: number
37
+ expiresAt: number
38
+ }
39
+
40
+ /** The store slot a profile's session lives in, beside its key. */
41
+ export function sessionSlot(profile: string): string {
42
+ return `${profile}#session`
43
+ }
44
+
45
+ type Fetch = (url: string, init: RequestInit) => Promise<Response>
46
+
47
+ const authUrl = (apiUrl: string, path: string) => `${apiUrl.replace(/\/+$/, '')}/api/auth${path}`
48
+
49
+ async function post(fetchFn: Fetch, url: string, body: unknown, bearer?: string): Promise<{ status: number; data: Record<string, unknown> }> {
50
+ const res = await fetchFn(url, { method: 'POST', headers: { 'content-type': 'application/json', ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) }, body: JSON.stringify(body) })
51
+ const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
52
+ return { status: res.status, data }
53
+ }
54
+
55
+ /** Ask the platform for a code. A platform without device authorization answers 404. */
56
+ export async function requestDeviceCode(apiUrl: string, fetchFn: Fetch = fetch, now = Date.now()): Promise<DeviceCode> {
57
+ const { status, data } = await post(fetchFn, authUrl(apiUrl, '/device/code'), { client_id: DEVICE_CLIENT_ID })
58
+ if (status === 404) throw new CliError('this platform does not offer browser sign-in for the CLI yet', { code: 'DEVICE_LOGIN_UNAVAILABLE', hint: 'ask your administrator to update the platform; until then, do this step in the product' })
59
+ if (status >= 400 || typeof data.device_code !== 'string' || typeof data.user_code !== 'string') {
60
+ throw new CliError(`the platform refused a sign-in code: ${String(data.error_description ?? data.error ?? status)}`, { code: 'DEVICE_LOGIN_REFUSED', hint: 'check the profile points at your deployment (`frontera auth current`), then try again' })
61
+ }
62
+ const uri = String(data.verification_uri ?? '/auth/device')
63
+ const absolute = /^https?:\/\//.test(uri) ? uri : new URL(uri, apiUrl).toString()
64
+ return {
65
+ deviceCode: data.device_code,
66
+ userCode: data.user_code,
67
+ verificationUri: absolute,
68
+ verificationUriComplete: typeof data.verification_uri_complete === 'string' && /^https?:\/\//.test(data.verification_uri_complete) ? data.verification_uri_complete : `${absolute}?user_code=${encodeURIComponent(data.user_code)}`,
69
+ intervalMs: Math.max(1, Number(data.interval) || 5) * 1000,
70
+ expiresAt: now + (Number(data.expires_in) || 600) * 1000,
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Poll until the person approves, denies or the code expires. The platform
76
+ * answers `authorization_pending` until then and `slow_down` when polled too
77
+ * often, which adds five seconds as the RFC says.
78
+ */
79
+ export async function awaitDeviceSession(
80
+ apiUrl: string,
81
+ code: DeviceCode,
82
+ deps: { fetch?: Fetch; sleep?: (ms: number) => Promise<void>; now?: () => number } = {},
83
+ ): Promise<DeviceSession> {
84
+ const fetchFn = deps.fetch ?? fetch
85
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)))
86
+ const now = deps.now ?? Date.now
87
+ let interval = code.intervalMs
88
+ while (now() < code.expiresAt) {
89
+ await sleep(interval)
90
+ const { data } = await post(fetchFn, authUrl(apiUrl, '/device/token'), { grant_type: GRANT, device_code: code.deviceCode, client_id: DEVICE_CLIENT_ID })
91
+ if (typeof data.access_token === 'string') {
92
+ return { token: data.access_token, expiresAt: new Date(now() + (Number(data.expires_in) || 0) * 1000).toISOString(), apiUrl }
93
+ }
94
+ if (data.error === 'authorization_pending') continue
95
+ if (data.error === 'slow_down') {
96
+ interval += 5000
97
+ continue
98
+ }
99
+ if (data.error === 'access_denied') throw new CliError('the sign-in was denied in the browser', { code: 'DEVICE_LOGIN_DENIED', hint: 'run `frontera auth login --browser` again to start over' })
100
+ if (data.error === 'expired_token') break
101
+ throw new CliError(`the sign-in failed: ${String(data.error_description ?? data.error ?? 'no answer')}`, { code: 'DEVICE_LOGIN_FAILED', hint: 'run `frontera auth login --browser` again' })
102
+ }
103
+ throw new CliError('the sign-in code expired before it was approved', { code: 'DEVICE_LOGIN_EXPIRED', hint: 'run `frontera auth login --browser` again' })
104
+ }
105
+
106
+ /** Who the session is, for the line the CLI prints; null when the platform doesn't say. */
107
+ export async function sessionEmail(apiUrl: string, token: string, fetchFn: Fetch = fetch): Promise<string | null> {
108
+ const res = await fetchFn(authUrl(apiUrl, '/get-session'), { method: 'GET', headers: { authorization: `Bearer ${token}` } }).catch(() => null)
109
+ const data = res ? ((await res.json().catch(() => null)) as { user?: { email?: string } } | null) : null
110
+ return data?.user?.email ?? null
111
+ }
112
+
113
+ export async function saveDeviceSession(store: CredentialStore, profile: string, session: DeviceSession): Promise<void> {
114
+ await store.set(sessionSlot(profile), JSON.stringify(session))
115
+ }
116
+
117
+ /** The profile's session when it is for this origin and still good for a minute; null otherwise. */
118
+ export async function readDeviceSession(store: CredentialStore, profile: string, apiUrl: string, now = Date.now()): Promise<DeviceSession | null> {
119
+ const raw = await store.get(sessionSlot(profile)).catch(() => null)
120
+ if (!raw) return null
121
+ try {
122
+ const session = JSON.parse(raw) as DeviceSession
123
+ if (typeof session.token !== 'string' || session.apiUrl.replace(/\/+$/, '') !== apiUrl.replace(/\/+$/, '')) return null
124
+ if (Date.parse(session.expiresAt) - now < 60_000) return null
125
+ return session
126
+ } catch {
127
+ return null
128
+ }
129
+ }
130
+
131
+ /** Revoke the session on the platform (best effort) and forget it here. */
132
+ export async function forgetDeviceSession(store: CredentialStore, profile: string, fetchFn: Fetch = fetch): Promise<boolean> {
133
+ const raw = await store.get(sessionSlot(profile)).catch(() => null)
134
+ if (!raw) return false
135
+ try {
136
+ const session = JSON.parse(raw) as DeviceSession
137
+ await post(fetchFn, authUrl(session.apiUrl, '/revoke-session'), { token: session.token }, session.token).catch(() => null)
138
+ } catch {
139
+ // Not a session this CLI wrote; it is forgotten all the same.
140
+ }
141
+ await store.delete(sessionSlot(profile))
142
+ return true
143
+ }