@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
@@ -44,55 +44,8 @@ export const kitVendor: Command = {
44
44
  requireCompatible()
45
45
 
46
46
  const root = kitRoot(ctx)
47
- const rendered = renderKit()
48
- const lock = readLock(root)
49
47
  const force = flagBool(ctx, 'force')
50
-
51
- const report = writeGenerated(root, rendered.generated, lock, { force })
52
-
53
- const edited: string[] = []
54
- const repaired: string[] = []
55
-
56
- if (rendered.agentsMd !== null) {
57
- const path = join(root, 'AGENTS.md')
58
- const existing = existsSync(path) ? readFileSync(path, 'utf8') : null
59
- const next = applyManagedBlock(existing, rendered.agentsMd)
60
- if (next !== existing) {
61
- writeFileSync(path, next)
62
- edited.push('AGENTS.md')
63
- }
64
- }
65
-
66
- if (rendered.claudeMd !== null) {
67
- const path = join(root, 'CLAUDE.md')
68
- const existing = existsSync(path) ? readFileSync(path, 'utf8') : null
69
- const result = applyClaudeImport(existing, rendered.claudeMd)
70
- if (result.content !== existing) {
71
- writeFileSync(path, result.content)
72
- edited.push('CLAUDE.md')
73
- }
74
- if (result.repaired) repaired.push('CLAUDE.md')
75
- }
76
-
77
- // The lock records what WAS written. A conflicted file keeps whatever digest
78
- // it had, so the next run still reports it rather than adopting the local
79
- // edit as ours.
80
- const locked = Object.fromEntries(
81
- Object.entries(rendered.generated).filter(([rel]) => !report.conflicts.includes(rel)),
82
- )
83
- writeLock(root, locked, cliVersion())
84
-
85
- const data = {
86
- root,
87
- kitVersion: KIT.kitVersion,
88
- hosts: KIT.hosts,
89
- written: report.written,
90
- unchanged: report.unchanged,
91
- conflicts: report.conflicts,
92
- edited,
93
- repaired,
94
- lock: LOCK_FILE,
95
- }
48
+ const { data, report, edited, repaired } = vendorKit(root, { force })
96
49
 
97
50
  const lines = [
98
51
  ...report.written.map((f) => ` + ${f}`),
@@ -117,4 +70,65 @@ export const kitVendor: Command = {
117
70
  },
118
71
  }
119
72
 
73
+ /**
74
+ * Write the kit into `root`: the skills in both host directories, the managed
75
+ * AGENTS.md block, the CLAUDE.md import and the lock.
76
+ *
77
+ * Shared with `frontera local init`, so a project folder Forge opens carries the
78
+ * whole kit, the solution skills included, rather than only the CLI skill.
79
+ */
80
+ export function vendorKit(root: string, opts: { force?: boolean } = {}) {
81
+ const rendered = renderKit()
82
+ const lock = readLock(root)
83
+ const force = opts.force === true
84
+
85
+ const report = writeGenerated(root, rendered.generated, lock, { force })
86
+
87
+ const edited: string[] = []
88
+ const repaired: string[] = []
89
+
90
+ if (rendered.agentsMd !== null) {
91
+ const path = join(root, 'AGENTS.md')
92
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : null
93
+ const next = applyManagedBlock(existing, rendered.agentsMd)
94
+ if (next !== existing) {
95
+ writeFileSync(path, next)
96
+ edited.push('AGENTS.md')
97
+ }
98
+ }
99
+
100
+ if (rendered.claudeMd !== null) {
101
+ const path = join(root, 'CLAUDE.md')
102
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : null
103
+ const result = applyClaudeImport(existing, rendered.claudeMd)
104
+ if (result.content !== existing) {
105
+ writeFileSync(path, result.content)
106
+ edited.push('CLAUDE.md')
107
+ }
108
+ if (result.repaired) repaired.push('CLAUDE.md')
109
+ }
110
+
111
+ // The lock records what WAS written. A conflicted file keeps whatever digest
112
+ // it had, so the next run still reports it rather than adopting the local
113
+ // edit as ours.
114
+ const locked = Object.fromEntries(
115
+ Object.entries(rendered.generated).filter(([rel]) => !report.conflicts.includes(rel)),
116
+ )
117
+ writeLock(root, locked, cliVersion())
118
+
119
+ const data = {
120
+ root,
121
+ kitVersion: KIT.kitVersion,
122
+ hosts: KIT.hosts,
123
+ written: report.written,
124
+ unchanged: report.unchanged,
125
+ conflicts: report.conflicts,
126
+ edited,
127
+ repaired,
128
+ lock: LOCK_FILE,
129
+ }
130
+
131
+ return { data, report, edited, repaired }
132
+ }
133
+
120
134
  export { CliError }
@@ -22,7 +22,8 @@ import { isAlive, readState } from '../../local/local-state'
22
22
  import { recordRun } from '../../local/local-test-baseline'
23
23
  import { notRunGroups, notRunWords } from '../../local/local-case-not-run'
24
24
  import { evidenceLine, failingCaseEvidence, type CaseEvidence } from '../../local/local-case-evidence'
25
- import { agentFilesProblem, caseConcurrency, findCaseFiles, runCases, selectCaseFiles, waitedWords } from '../../local/local-test-runner'
25
+ import { agentFilesProblem, caseConcurrency, caseRepeat, findCaseFiles, judgedWords, passRateThreshold, repeatWords, runCases, selectCaseFiles, waitedWords } from '../../local/local-test-runner'
26
+ import { agentScope, customerFacingAgents, customerFacingWords, modelSubstitutions, skippedCases, skippedWords, type AgentScope, type SkippedCases } from '../../local/local-test-scope'
26
27
  import { findWorkflowTestFiles, recordWorkflowTestRun, runWorkflowTests, workflowFileState } from '../../local/local-workflow-tests'
27
28
  import { vendoredSdkLine, vendoredSdkState } from '../../local/local-vendored-sdk'
28
29
  import { withFix } from '../../local/fix-words'
@@ -145,6 +146,14 @@ export interface CheckStep {
145
146
  * step does not pass, but these are not failures of the Agent.
146
147
  */
147
148
  couldNotRun?: Array<{ what: string; fix: string; cases: string[]; notSent?: number }>
149
+ /** agents: each Agent with cases, and why it is in the check (X1). */
150
+ agents?: AgentScope[]
151
+ /** agents: the cases the check left out, by reason. */
152
+ skipped?: SkippedCases[]
153
+ /** agents: Agents that run on another model here than their files name. */
154
+ modelSubstitutions?: string[]
155
+ /** agents: with --repeat, the cases that passed some runs and failed others. */
156
+ flaky?: string[]
148
157
  }
149
158
 
150
159
  /** Run a command in the project, its output on stderr so stdout stays the check's own result. */
@@ -319,12 +328,23 @@ async function check(ctx: CommandContext) {
319
328
  const strict = flagBool(ctx, 'strict')
320
329
  // How many Agent cases run at once (`--concurrency`), checked before anything starts.
321
330
  let concurrency: number | undefined
331
+ let repeat: number | undefined
332
+ let passRate: number | undefined
322
333
  try {
323
334
  concurrency = caseConcurrency(flagString(ctx, 'concurrency'))
335
+ repeat = caseRepeat(flagString(ctx, 'repeat'))
336
+ passRate = passRateThreshold(flagString(ctx, 'pass-rate'))
324
337
  } catch (err) {
325
- throw new UsageError((err as Error).message, 'frontera local check --concurrency 1')
338
+ throw new UsageError((err as Error).message, 'frontera local check --repeat 3')
339
+ }
340
+ if (passRate !== undefined && !repeat) throw new UsageError('--pass-rate is the share of a repeated case’s runs that must pass: give --repeat too', 'frontera local check --repeat 5 --pass-rate 0.8')
341
+ // `--agent a,b`: only those Agents' cases (X1); every Agent's otherwise.
342
+ const namedAgents = (flagString(ctx, 'agent') ?? '').split(',').map((a) => a.trim()).filter(Boolean)
343
+ for (const a of namedAgents) {
344
+ if (!findCaseFiles(root, a).length) throw new UsageError(`no test cases for Agent ${a} (agents/${a}/evals/*.yaml)`, 'name an Agent that has cases, e.g. `frontera local check --agent <slug>`')
326
345
  }
327
346
  const unchanged = unchangedSincePull(root)
347
+ const customerFacing = customerFacingAgents(root)
328
348
  // The tests' writes: a baseline after the apply, restored after the tests unless `--keep-state`.
329
349
  const keepState = flagBool(ctx, 'keep-state')
330
350
  let testData: CheckTestData | null = null
@@ -337,7 +357,7 @@ async function check(ctx: CommandContext) {
337
357
  }
338
358
  /** Whether the Agent or workflow tests will run anything, and so may write. */
339
359
  const testsWillRun = () =>
340
- (!skip.has('agents') && !blockedBy('agents', failedSteps, failFast) && findCaseFiles(root).some((f) => f.cases.length > 0))
360
+ (!skip.has('agents') && !blockedBy('agents', failedSteps, failFast) && findCaseFiles(root).some((f) => f.cases.length > 0 && (!namedAgents.length || namedAgents.includes(f.agent))))
341
361
  || (!skip.has('workflows') && !blockedBy('workflows', failedSteps, failFast) && findWorkflowTestFiles(root).some((f) => f.cases.length > 0))
342
362
 
343
363
  const say = (step: CheckStepName, line: string) => ctx.output.note(`── ${step}: ${line}`)
@@ -387,6 +407,8 @@ async function check(ctx: CommandContext) {
387
407
  }
388
408
  if (step === 'apply') {
389
409
  say(step, 'frontera local dev --once')
410
+ // Which Agents serve customers: an operating.json leaves an Agent out of the built-in Chat (X4).
411
+ if (customerFacing.length) ctx.output.note(` note: ${customerFacingWords(customerFacing)}`)
390
412
  const started = Date.now()
391
413
  const code = await runIn(root, self(['local', 'dev', '--once']))
392
414
  // Which items did not apply, and whether each is still as the pull wrote it. `local dev --once` makes the
@@ -545,12 +567,20 @@ async function check(ctx: CommandContext) {
545
567
  }
546
568
  const serviceUrl = `http://127.0.0.1:${state.ports.service}`
547
569
  if (step === 'agents') {
548
- const files = selectCaseFiles(root)
570
+ const files = selectCaseFiles(root).filter((f) => !namedAgents.length || namedAgents.includes(f.agent))
549
571
  if (!files.some((f) => f.cases.length || f.problem)) {
550
572
  record({ step, status: 'skipped', note: 'no Agent test cases (agents/<slug>/evals/*.yaml)', ms: 0 })
551
573
  continue
552
574
  }
553
- say(step, 'frontera local test (Agents)')
575
+ say(step, `frontera local test (${namedAgents.length ? `Agent${namedAgents.length === 1 ? '' : 's'} ${namedAgents.join(', ')}` : 'Agents'})${repeat && repeat > 1 ? `, each case ${repeat} times` : ''}`)
576
+ // Why each Agent is in the check; one unchanged since the pull, or only renamed, has its failures inherited (X1).
577
+ const scope = agentScope(root, files, unchanged, namedAgents)
578
+ for (const a of scope) ctx.output.note(` ${a.agent} (${a.cases} case${a.cases === 1 ? '' : 's'}): ${a.why}`)
579
+ const inheritedAgents = new Set(scope.filter((a) => a.inherited).map((a) => a.agent))
580
+ const skippedHere = skippedCases(root, namedAgents.length ? { agents: namedAgents } : {})
581
+ if (skippedHere.length) ctx.output.note(` ${skippedWords(skippedHere)}`)
582
+ const models = modelSubstitutions(project, state.credentials.organizationId, files.map((f) => f.agent))
583
+ for (const line of models) ctx.output.note(` note: ${line}`)
554
584
  const started = Date.now()
555
585
  const orgId = state.credentials.organizationId
556
586
  const results = await runCases({
@@ -559,15 +589,21 @@ async function check(ctx: CommandContext) {
559
589
  files,
560
590
  live: false,
561
591
  concurrency,
592
+ ...(repeat ? { repeat } : {}),
593
+ ...(passRate ? { passRate } : {}),
562
594
  staleDraft: (slug) => agentFilesProblem(project, orgId, slug),
563
595
  onResult: (r) => {
564
596
  // The cases not sent after the provider refused every one are said once, below.
565
597
  if (r.notRun?.skipped) return
566
- ctx.output.note(` ${r.passed ? '✓' : r.notRun ? '–' : '✕'} ${r.agent} · ${r.name}${r.passed ? '' : r.notRun ? ` — couldn’t run: ${r.notRun.what}` : ` — ${r.reason}`}${r.passed && r.retried ? ' (run twice: the provider refused the first try for its limits)' : ''}${r.waited?.length ? ` (${waitedWords(r.waited)})` : ''}`)
598
+ ctx.output.note(` ${r.passed ? (r.repeat?.flaky ? '~' : '✓') : r.notRun ? '–' : '✕'} ${r.agent} · ${r.name}${r.passed ? '' : r.notRun ? ` — couldn’t run: ${r.notRun.what}` : ` — ${r.reason}`}${repeatWords(r)}${judgedWords(r)}${r.passed && r.retried ? ' (run twice: the provider refused the first try for its limits)' : ''}${r.waited?.length ? ` (${waitedWords(r.waited)})` : ''}`)
567
599
  },
568
600
  })
569
601
  recordRun(project, results)
570
- const bad = results.filter((r) => !r.passed && !r.notRun)
602
+ const failing = results.filter((r) => !r.passed && !r.notRun)
603
+ // An inherited Agent's failures are listed and pass unless --strict, as every other step's are.
604
+ const inheritedFails = strict ? [] : failing.filter((r) => inheritedAgents.has(r.agent))
605
+ const bad = failing.filter((r) => !inheritedFails.includes(r))
606
+ const agentData = { agents: scope, ...(skippedHere.length ? { skipped: skippedHere } : {}), ...(models.length ? { modelSubstitutions: models } : {}), ...(results.some((r) => r.repeat?.flaky) ? { flaky: results.filter((r) => r.repeat?.flaky).map((r) => `${r.agent} · ${r.name}`) } : {}) }
571
607
  // Each failing case's conversation, last turn's trace and reply, read before the rollback (which keeps the conversation).
572
608
  const evidence = await failingCaseEvidence(project, results)
573
609
  for (const [i, e] of evidence) ctx.output.note(` ${results[i]!.agent} · ${results[i]!.name}: ${evidenceLine(e)}`)
@@ -581,8 +617,10 @@ async function check(ctx: CommandContext) {
581
617
  record({
582
618
  step,
583
619
  status: bad.length || notRun.length ? 'failed' : 'passed',
584
- command: 'frontera local test',
620
+ command: `frontera local test${namedAgents.length === 1 ? ` --agent ${namedAgents[0]}` : ''}${repeat && repeat > 1 ? ` --repeat ${repeat}` : ''}`,
585
621
  ms: Date.now() - started,
622
+ ...agentData,
623
+ ...(inheritedFails.length ? { inherited: inheritedFails.map((r) => `${r.agent} · ${r.name}: ${r.reason ?? 'failed'}`) } : {}),
586
624
  ...(note ? { note } : {}),
587
625
  ...(bad.length ? { failures: bad.map((r) => ({ name: `${r.agent} · ${r.name}`, reason: r.reason, ...(evidence.get(results.indexOf(r)) ?? {}) })) } : {}),
588
626
  ...(notRun.length ? { couldNotRun: notRun.map((g) => ({ what: `couldn’t run: ${g.what}`, fix: g.fix, cases: g.cases, ...(g.skipped ? { notSent: g.skipped } : {}) })) } : {}),
@@ -629,7 +667,7 @@ async function check(ctx: CommandContext) {
629
667
  }
630
668
  }
631
669
  } else if (testData?.kept && testsRan) ctx.output.note('── test data: the tests’ writes were kept (--keep-state); the next `frontera local check` rolls them back')
632
- const testDataField = testData && testsRan ? { testData } : {}
670
+ const testDataField = { ...(testData && testsRan ? { testData } : {}), ...(customerFacing.length ? { customerFacing } : {}) }
633
671
 
634
672
  if (flagBool(ctx, 'stop')) {
635
673
  ctx.output.note('── frontera local stop')
@@ -676,9 +714,17 @@ export const localCheck: Command = {
676
714
  noun: 'local',
677
715
  verb: 'check',
678
716
  args: [],
679
- flags: { stop: 'boolean', skip: 'string', 'fail-fast': 'boolean', strict: 'boolean', 'keep-state': 'boolean', concurrency: 'string' },
717
+ flags: { stop: 'boolean', skip: 'string', 'fail-fast': 'boolean', strict: 'boolean', 'keep-state': 'boolean', concurrency: 'string', agent: 'string', repeat: 'string', 'pass-rate': 'string' },
718
+ flagHelp: {
719
+ agent: 'check only these Agents’ test cases (a slug, or slugs separated by commas); the other steps run as always',
720
+ repeat: 'run each Agent case N times (1 to 10); a case that passes some runs is flaky, and passes at --pass-rate',
721
+ 'pass-rate': 'with --repeat, the share of runs a flaky case must pass (default 0.8, or 80%)',
722
+ },
723
+ details: [
724
+ 'The agents step says why each Agent is in it: named with `--agent`, every Agent with cases, or, in a pulled project, whether it changed since the pull. An Agent unchanged since the pull, or changed only in its name, has its failures listed as inherited: they pass unless `--strict`, as every other step\'s inherited failures do.',
725
+ ],
680
726
  summary: 'The whole gate with one exit code (start, apply, typecheck, unit tests, App checks, Agent and workflow tests): anything changed or added fails it, and failures only in items unchanged since the pull are listed as inherited and pass unless --strict',
681
- examples: ['frontera local check', 'frontera local check --json', 'frontera local check --strict', 'frontera local check --skip typecheck,apps', 'frontera local check --fail-fast', 'frontera local check --keep-state', 'frontera local check --concurrency 1', 'frontera local check --stop'],
727
+ examples: ['frontera local check', 'frontera local check --json', 'frontera local check --agent claims-triage --repeat 3', 'frontera local check --strict', 'frontera local check --skip typecheck,apps', 'frontera local check --fail-fast', 'frontera local check --keep-state', 'frontera local check --concurrency 1', 'frontera local check --stop'],
682
728
  offline: true,
683
729
  },
684
730
  run: check,
@@ -1,8 +1,14 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
2
- import { join } from 'node:path'
2
+ import { join, resolve } from 'node:path'
3
3
 
4
- import { UsageError } from '../../errors'
4
+ import { GovernedActionApi } from '../../api/governed-action-api'
5
+ import { CliError, UsageError } from '../../errors'
6
+ import { loadViaActions, loadWords, readLoadFile } from '../../local/local-data-load'
7
+ import { restoreBaseline, saveBaseline } from '../../local/local-db-snapshot'
8
+ import { actAsLocalPersona, redact } from '../../local/local-personas'
5
9
  import { localProject } from '../../local/local-project'
10
+ import { waitForDatabaseReady } from '../../local/local-runtime-health'
11
+ import { readState } from '../../local/local-state'
6
12
  import { isoDay, SAMPLE_OPTIONS_FILE, writeSampleOptions } from '../../local/local-sample-remake'
7
13
  import { addMissingBackings, generateSyntheticData, loadScenario, parseRegion, projectRegion, REGION_CODES, SCENARIO_CONTRACT, SCENARIO_TEMPLATE } from '../../local/local-synthetic-data'
8
14
  import { flagBool, flagString, type Command } from '../types'
@@ -17,14 +23,27 @@ export const localData: Command = {
17
23
  meta: {
18
24
  noun: 'local',
19
25
  verb: 'data',
20
- args: [],
21
- flags: { synthetic: 'boolean', rows: 'string', seed: 'string', region: 'string', today: 'string' },
22
- summary: "Generate sample data for the project's Blueprint (datasets/*.csv; media-backed types get none), with consistent links",
23
- details: SCENARIO_CONTRACT,
24
- examples: ['frontera local data --synthetic', 'frontera local data --synthetic --rows 500 --seed 42', 'frontera local data --synthetic --region ID', 'frontera local data --synthetic --region 1.29,103.85,20', 'frontera local data --synthetic --today 2026-10-18'],
26
+ args: [
27
+ { name: 'action', required: false, description: 'load, to load records through governed Actions' },
28
+ { name: 'file', required: false, description: 'with load: the JSON or YAML file of { action, records } groups' },
29
+ ],
30
+ flags: { synthetic: 'boolean', rows: 'string', seed: 'string', region: 'string', today: 'string', 'via-actions': 'boolean', 'no-snapshot': 'boolean', batch: 'string' },
31
+ flagHelp: {
32
+ 'via-actions': 'with load: submit each record as the governed Action its group names, as the local submitter persona',
33
+ 'no-snapshot': 'with load: don’t save the records first (by default a load saves them, and a failed load puts them back)',
34
+ batch: 'with load: how many records are submitted at once (1 to 16, default 4)',
35
+ },
36
+ summary: "Generate sample data for the project's Blueprint (datasets/*.csv; media-backed types get none), with consistent links, or load records through governed Actions",
37
+ details: [
38
+ ...SCENARIO_CONTRACT,
39
+ '`load <file> --via-actions` loads records through the project\'s governed Actions on the local runtime: the file is a list of groups, each `{ action: <apiName>, records: [...] }`, run in its order (put a group whose records name another\'s after it). A record is the Action\'s input, or `{ input, subject }` for an Action on an existing record. Each is submitted as the local `submitter` persona, in batches, with a key from the file and the record, so loading the same file again submits nothing twice. The records are saved first; when a record is refused, the load stops after its group and puts them back (`--no-snapshot` skips both). A record that waits on an approval step is said.',
40
+ ],
41
+ examples: ['frontera local data load data/customers.yaml --via-actions', 'frontera local data --synthetic', 'frontera local data --synthetic --rows 500 --seed 42', 'frontera local data --synthetic --region ID', 'frontera local data --synthetic --region 1.29,103.85,20', 'frontera local data --synthetic --today 2026-10-18'],
25
42
  offline: true,
26
43
  },
27
44
  async run(ctx) {
45
+ if (ctx.positional[0] === 'load') return loadCommand(ctx)
46
+ if (ctx.positional.length) throw new UsageError(`unknown data action \`${ctx.positional[0]}\``, '`frontera local data --synthetic`, or `frontera local data load <file> --via-actions`')
28
47
  if (!flagBool(ctx, 'synthetic')) throw new UsageError('say what to make', 'run `frontera local data --synthetic`')
29
48
  const rows = Number(flagString(ctx, 'rows') ?? 200)
30
49
  const seed = Number(flagString(ctx, 'seed') ?? 7)
@@ -79,3 +98,52 @@ export const localData: Command = {
79
98
  }
80
99
  },
81
100
  }
101
+
102
+ /** `local data load <file> --via-actions` (Forge improvement plan L2). */
103
+ async function loadCommand(ctx: Parameters<Command['run']>[0]) {
104
+ const file = ctx.positional[1]
105
+ if (!file) throw new UsageError('name the file to load', 'frontera local data load data/customers.yaml --via-actions')
106
+ if (!flagBool(ctx, 'via-actions')) throw new UsageError('a load goes through governed Actions: say so with --via-actions', '`frontera local data load <file> --via-actions`; sample rows for datasets come from `frontera local data --synthetic`')
107
+ const batch = flagString(ctx, 'batch') === undefined ? 4 : Number(flagString(ctx, 'batch'))
108
+ if (!Number.isInteger(batch) || batch < 1 || batch > 16) throw new UsageError('--batch takes a whole number from 1 to 16', 'e.g. `--batch 4`')
109
+ const project = localProject(ctx.cwd)
110
+ const state = readState(project)
111
+ if (!state || state.phase !== 'ready' || !state.credentials) throw new CliError('the local runtime is not running', { code: 'LOCAL_NOT_RUNNING', hint: 'run `frontera local start` first' })
112
+ const serviceUrl = `http://127.0.0.1:${state.ports.service}`
113
+ const groups = readLoadFile(resolve(ctx.cwd, file))
114
+ const organizationId = state.credentials.organizationId
115
+ const snapshot = !flagBool(ctx, 'no-snapshot')
116
+ if (snapshot) {
117
+ const saved = await ctx.output.task('Saving the records first', () => saveBaseline(project, organizationId, { serviceAnswers: () => waitForDatabaseReady(serviceUrl) }, true))
118
+ if (!saved.ok) throw new CliError(`the records could not be saved first, so nothing was loaded: ${saved.note}`, { code: 'LOCAL_SNAPSHOT_FAILED', hint: '`--no-snapshot` loads without saving them' })
119
+ }
120
+ const persona = await actAsLocalPersona({ meta: { noun: 'action', verb: 'submit' }, cwd: ctx.cwd, apiUrl: serviceUrl, wanted: 'submitter' })
121
+ const client = new GovernedActionApi(serviceUrl, persona.token, persona.workspaceId)
122
+ const outcomes = []
123
+ try {
124
+ for (const group of groups) {
125
+ const [o] = await loadViaActions(file, [group], (action, invocation, key) => client.submit(action, invocation, key) as never, {
126
+ batch,
127
+ subjectType: async (action) => ((await client.publishedAction(action).catch(() => null))?.definition as { subject?: { objectTypeId?: string } } | undefined)?.subject?.objectTypeId ?? null,
128
+ })
129
+ outcomes.push(o!)
130
+ ctx.output.note(` ${o!.refused.length ? '✕' : '✓'} ${loadWords(o!)}`)
131
+ for (const r of o!.refused.slice(0, 3)) ctx.output.note(` record ${r.index + 1}: ${redact(r.reason, persona.token)}`)
132
+ if (o!.refused.length) break
133
+ }
134
+ } catch (err) {
135
+ throw redact(err, persona.token)
136
+ }
137
+ const stopped = outcomes.some((o) => o.refused.length)
138
+ let putBack: string | null = null
139
+ if (stopped && snapshot) {
140
+ const restored = await ctx.output.task('Putting the records back', () => restoreBaseline(project, organizationId, { serviceAnswers: () => waitForDatabaseReady(serviceUrl) }, { secrets: 'keep', history: 'keep' }))
141
+ putBack = restored.ok ? 'the records are as they were before the load' : `the records could not be put back: ${restored.note}`
142
+ }
143
+ const data = { file, groups: outcomes, ...(stopped ? { stopped: true, putBack } : {}) }
144
+ if (stopped) {
145
+ const at = outcomes[outcomes.length - 1]!
146
+ throw new CliError(`${at.action}: ${at.refused.length} record${at.refused.length === 1 ? '' : 's'} refused, so the load stopped${putBack ? `; ${putBack}` : ''}`, { code: 'LOCAL_LOAD_REFUSED', hint: 'fix the records (or the Action) and load the file again: what was applied is not submitted twice', details: data })
147
+ }
148
+ return { data, text: `Loaded ${file} through governed Actions as the local submitter:\n${outcomes.map((o) => ` ${loadWords(o)}`).join('\n')}` }
149
+ }
@@ -36,7 +36,7 @@ import { ensureMediaSets, mediaUpload } from '../../local/local-media-sets'
36
36
  import { fillPlaceholderMedia } from '../../local/local-media-placeholders'
37
37
  import { applyChatApp, keyCalls } from '../../local/local-chat-apps'
38
38
  import { applyLessons } from '../../local/local-lessons'
39
- import { applyStandIn, platformOnlyNames, readStandIn, standInNames } from '../../local/local-stand-ins'
39
+ import { applyStandIn, missingGrantInstalls, platformOnlyNames, readStandIn, standInNames, standInPath, writeGrantStandIn } from '../../local/local-stand-ins'
40
40
  import { projectProvides } from '../../local/local-adapt'
41
41
  import { applyOperatingConfig } from '../../local/local-operating'
42
42
  import { localEmbeddingsReachable } from '../../local/local-keys'
@@ -51,15 +51,18 @@ import { decideStepNotes, keepLiveCurrent, mirrorWorkflowState } from '../../loc
51
51
  import type { Directory } from '../../local/local-sync-refs'
52
52
  import { localProject, type LocalProject } from '../../local/local-project'
53
53
  import { isAlive, readState, recordApplied, recordApplying, type LocalCredentials } from '../../local/local-state'
54
- import { applyErrorText } from '../../local/local-apply-error'
54
+ import { applyErrorText, applyFailureReason } from '../../local/local-apply-error'
55
+ import { sayInFull } from '../../local/local-said-once'
55
56
  import { applyFailures, inheritedApplyLine, splitApplyFailures } from '../../local/local-apply-summary'
56
57
  import { unchangedSincePull } from '../../local/local-check-inherited'
58
+ import { unreadConfidential, unreadConfidentialLines } from '../../local/local-confidential'
59
+ import { ungrantedReadLines, ungrantedReads } from '../../local/local-ungranted-reads'
57
60
  import { readWorkspaceGrants } from '../../local/local-workspace-grants'
58
61
  import { ApplyWatchdog, stoppedReapplyingLine } from '../../local/local-apply-watchdog'
59
62
  import { hasGeneratedSampleData, pulledSampleData, pulledSampleDataNote, remakeLines, remakeReason, remakeSampleData } from '../../local/local-sample-remake'
60
63
  import { appsAwayByRestore } from '../../local/local-app-restore'
61
64
  import { BASELINE_RECORD, readBaseline } from '../../local/local-db-snapshot'
62
- import { canChangeRuntime, filesChangedTrigger, recordApply } from '../../local/local-apply-history'
65
+ import { canChangeRuntime, filesChangedTrigger, readApplies, recordApply } from '../../local/local-apply-history'
63
66
  import { failedForRuntime, RUNTIME_DOWN_ERROR, RUNTIME_STOPPED_LINE, runtimeAnswering } from '../../local/local-runtime-health'
64
67
  import { noticeLines, projectNotices } from '../../local/local-project-notices'
65
68
  import { refreshVendoredSdk, vendoredSdkState } from '../../local/local-vendored-sdk'
@@ -342,6 +345,27 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
342
345
  result = withNotes(await runCli(project, commandFor(target, profiles), opts.auth))
343
346
  }
344
347
  }
348
+ // A Function that grants itself a workspace Plugin this runtime has no install of: a stand-in of that
349
+ // name is written and installed, and the Function deployed again; its calls say "not connected" here (X2).
350
+ for (let n = 0; !result.ok && target.kind === 'function' && n < 5; n++) {
351
+ const missing = missingGrantInstalls(result.output)
352
+ if (!missing.length) break
353
+ const installs = [...new Set(missing.map((g) => g.install))]
354
+ const made: string[] = []
355
+ for (const install of installs) {
356
+ const wrote = writeGrantStandIn(project.root, install, missing.filter((g) => g.install === install).map((g) => g.capability))
357
+ if (!wrote && !readStandIn(project.root, install)) break
358
+ const added = await applyStandIn(project, install, profiles.workspace, (args) => runCli(project, args, opts.auth))
359
+ if (!added.ok) {
360
+ result = { ...result, output: `${result.output.trim()}\n the stand-in ${standInPath(install)} could not be installed: ${oneLine(added.output)}` }
361
+ break
362
+ }
363
+ made.push(install)
364
+ }
365
+ if (made.length !== installs.length) break
366
+ notes = [notes, ...made.map((i) => ` warning: ${original.file} grants itself the Plugin ${i}, which this runtime doesn’t have: the stand-in ${standInPath(i)} stands in for it, and its calls answer “not connected” here (the workspace’s own install is used there)`)].filter(Boolean).join('\n')
367
+ result = withNotes(await runCli(project, command, opts.auth, bundleEnv))
368
+ }
345
369
  // A Function whose build cannot find a file it imports from beside its folder: said as that, first.
346
370
  if (!result.ok && target.kind === 'function') result = { ...result, output: missingImportsOutput(project, original.file, result.output) }
347
371
  if (!result.ok) return result
@@ -448,6 +472,14 @@ async function apply(project: LocalProject, target: SyncTarget, opts: ApplyOptio
448
472
  if (workspaceGrants?.problems.length) return { ok: false, output: workspaceGrants.problems.map((p) => `error [WORKSPACE_GRANTS]: ${p}`).join('\n') }
449
473
  const unknownTypes = (workspaceGrants?.grants ?? []).filter((g) => !objectTypes.includes(g.objectType)).map((g) => g.objectType)
450
474
  if (unknownTypes.length) result = { ...result, output: `${result.output}\n warning: blueprint/workspace-grants.yaml names ${unknownTypes.join(', ')}, which the project's Blueprint doesn't hold here` }
475
+ // Confidential fields nothing here may read (X7): each reads as hidden, which looks like a mistake once an Agent says so.
476
+ if (target.kind === 'blueprint') {
477
+ const unread = unreadConfidentialLines(unreadConfidential(project.root))
478
+ if (unread.length) result = { ...result, output: [result.output, ...unread].join('\n') }
479
+ // What the project's workflows and Apps read that the linked workspace isn't granted (X2).
480
+ const ungranted = ungrantedReadLines(ungrantedReads(project.root))
481
+ if (ungranted.length) result = { ...result, output: [result.output, ...ungranted].join('\n') }
482
+ }
451
483
  for (const step of followUpsFor(target, profiles, { publish: opts.publish, workspaceId: opts.workspaceId, objectTypes, grants: workspaceGrants?.grants ?? [] })) {
452
484
  const done = await runCli(project, step.args, opts.auth)
453
485
  if (!done.ok && !step.alreadyDone?.test(done.output)) return done
@@ -807,7 +839,9 @@ async function dev(ctx: CommandContext) {
807
839
  const mirrored = await mirrorOrgSettings(synced, settingsCalls(serviceUrl, creds), { localEmbeddings: localEmbeddingsReachable() }).catch(() => null)
808
840
  if (mirrored) {
809
841
  for (const line of mirrored.lines) ctx.output.note(line)
810
- for (const d of mirrored.differences) ctx.output.note(` differs from the workspace: ${d}`)
842
+ // In full once per runtime start, then one line while the list is the same (N9).
843
+ if (sayInFull(project, 'settings-differ', mirrored.differences, state.startedAt)) for (const d of mirrored.differences) ctx.output.note(` differs from the workspace: ${d}`)
844
+ else if (mirrored.differences.length) ctx.output.note(` ${mirrored.differences.length} setting${mirrored.differences.length === 1 ? '' : 's'} differ from the workspace, as before (\`frontera local status\` lists them)`)
811
845
  recordSettingsDifferences(project, organizationId, mirrored.differences)
812
846
  }
813
847
  }
@@ -870,7 +904,7 @@ async function dev(ctx: CommandContext) {
870
904
  const missing = result.missing?.length ? result.missing : target.kind === 'bindings' ? missingFromBindings(result.output) : []
871
905
  if (missing.length || result.unnamedActions) return { ...base, status: 'blocked', needs: missing, ...(result.unnamedActions ? { unnamedActions: result.unnamedActions } : {}) }
872
906
  // A Blueprint published with an Action that didn't deploy keeps how each type and Action came out.
873
- return { ...base, status: 'failed', error: oneLine(applyErrorText(result.output)), ...failureCodeAndHint(result.output), ...(result.blueprint ? blueprintRecord(result.blueprint) : {}) }
907
+ return { ...base, status: 'failed', error: applyFailureReason(result.output), ...failureCodeAndHint(result.output), ...(result.blueprint ? blueprintRecord(result.blueprint) : {}) }
874
908
  }
875
909
 
876
910
  const runOne = async (target: SyncTarget) => {
@@ -1296,6 +1330,30 @@ async function dev(ctx: CommandContext) {
1296
1330
  }
1297
1331
  })())
1298
1332
 
1333
+ /**
1334
+ * An Agent whose operating.json was deleted while its agent.json stays: its behavior settings are
1335
+ * taken away on the runtime too, as a change request takes them away in the workspace (X4). Before,
1336
+ * the runtime kept them, and the Agent stayed out of the built-in Chat here.
1337
+ */
1338
+ const clearDeletedBehavior = async (): Promise<void> => {
1339
+ if (!creds?.session) return
1340
+ const known = loadHashes(project, organizationId)
1341
+ const present = new Set(allTargets(project.root).map((t) => t.key))
1342
+ for (const key of [...known.keys()].filter((k) => k.startsWith('operating:') && !present.has(k))) {
1343
+ const slug = key.slice('operating:'.length)
1344
+ if (!present.has(`agent:${slug}`)) continue
1345
+ const cleared = await applyOperatingConfig({ serviceUrl, creds, slug, file: join(project.root, 'agents', slug, 'operating.json'), document: null })
1346
+ if (!cleared.ok) {
1347
+ ctx.output.note(` warning: agents/${slug}/operating.json was deleted, and its behavior settings couldn’t be taken away here: ${oneLine(cleared.output)}`)
1348
+ continue
1349
+ }
1350
+ ctx.output.note(` ✓ Agent ${slug}: behavior settings taken away (agents/${slug}/operating.json was deleted); publish it and the built-in Chat offers it again`)
1351
+ applied.delete(key)
1352
+ known.delete(key)
1353
+ saveHashes(project, organizationId, known)
1354
+ }
1355
+ }
1356
+
1299
1357
  // Watch from the start, so a save during the first sync is not missed.
1300
1358
  const once = flagBool(ctx, 'once')
1301
1359
  // One watcher per project (Forge keeps one running; a second would apply every change twice). `--once` is always allowed.
@@ -1322,6 +1380,7 @@ async function dev(ctx: CommandContext) {
1322
1380
  if (filename.toString() === BASELINE_RECORD) afterRestore()
1323
1381
  // An Agent's folder, a workflow's or a Function's file deleted: what the runtime holds follows.
1324
1382
  if (/^(agents|workflows|functions)([\\/]|$)/.test(filename.toString()) && !existsSync(join(project.root, filename.toString()))) void sweepOrphans()
1383
+ if (/^agents[\\/][^\\/]+[\\/]operating\.json$/.test(filename.toString()) && !existsSync(join(project.root, filename.toString()))) void clearDeletedBehavior()
1325
1384
  // Only a change that queued an item starts a round: the watcher's own writes under `.frontera/`,
1326
1385
  // a screenshot, a coding agent's settings or a note in docs/ never do.
1327
1386
  if (queuePath(filename.toString()) && !busy) schedule()
@@ -1332,6 +1391,7 @@ async function dev(ctx: CommandContext) {
1332
1391
  pruneBuild(project, organizationId, new Set(targets.map((t) => t.key)))
1333
1392
  // An Agent, workflow or Function whose file was deleted is taken out of service here, as the files now say.
1334
1393
  await sweepOrphans()
1394
+ await clearDeletedBehavior()
1335
1395
  ctx.output.note(targets.length ? `Applying ${targets.length} item${targets.length === 1 ? '' : 's'} to the local runtime` : 'Nothing to apply yet')
1336
1396
  // Files in the applied folders that match no kind: said, not skipped in silence.
1337
1397
  for (const line of unappliedFiles(project.root)) {
@@ -1377,13 +1437,21 @@ async function dev(ctx: CommandContext) {
1377
1437
  throw new CliError(message, { code: 'LOCAL_APPLY_FAILED', hint: 'fix the files above and run again; `frontera local status` lists each item that didn’t apply', details: { failed: made, ...inheritedField } })
1378
1438
  }
1379
1439
  const done = targets.length - unchanged
1440
+ // Nothing new here, but the watcher (or another `local dev`) applied these files already: said when, and why (N9).
1441
+ const already = done === 0 ? readApplies(project.stateDir).filter((r) => r.applied > 0).at(-1) : undefined
1442
+ const alreadyField = already ? { alreadyApplied: { at: already.finished, trigger: already.trigger } } : {}
1443
+ const alreadyText = already ? `; already applied by \`frontera local dev\` at ${new Date(already.finished).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} (${already.trigger})` : ''
1380
1444
  if (inherited.length) {
1381
1445
  const summary = inheritedApplyLine(inherited.length)
1382
- ctx.output.note(` ${summary}:`)
1383
- for (const f of inherited) ctx.output.note(` ${f.ref ?? f.item}: ${oneLine(f.error)}`)
1384
- return { data: { applied: done, unchanged, failed: 0, ...inheritedField, summary, ...(warned.size ? { warnings: [...warned.values()] } : {}) }, text: `Applied ${done}${unchanged ? `, ${unchanged} unchanged` : ''}; ${summary}` }
1446
+ // Each inherited failure in full once per runtime start, then the one line while they stay the same.
1447
+ const lines = inherited.map((f) => `${f.ref ?? f.item}: ${oneLine(f.error)}`)
1448
+ if (sayInFull(project, 'inherited-failures', lines, state.startedAt)) {
1449
+ ctx.output.note(` ${summary}:`)
1450
+ for (const l of lines) ctx.output.note(` ${l}`)
1451
+ } else ctx.output.note(` ${summary}, as before (\`frontera local status\` lists them)`)
1452
+ return { data: { applied: done, unchanged, failed: 0, ...inheritedField, summary, ...alreadyField, ...(warned.size ? { warnings: [...warned.values()] } : {}) }, text: `Applied ${done}${unchanged ? `, ${unchanged} unchanged` : ''}${alreadyText}; ${summary}` }
1385
1453
  }
1386
- return { data: { applied: done, unchanged, ...(warned.size ? { warnings: [...warned.values()] } : {}) }, text: `Applied ${done}${unchanged ? `, ${unchanged} unchanged` : ''}` }
1454
+ return { data: { applied: done, unchanged, ...alreadyField, ...(warned.size ? { warnings: [...warned.values()] } : {}) }, text: `Applied ${done}${unchanged ? `, ${unchanged} unchanged` : ''}${alreadyText}` }
1387
1455
  }
1388
1456
 
1389
1457
  ctx.output.note('Watching datasets/, blueprint/, knowledge/, skills/, mcp/, themes/, functions/, workflows/, agents/, chat-apps/, apps/, brand/ and library.json (Ctrl+C to stop)')
@@ -0,0 +1,74 @@
1
+ import { CliError, UsageError } from '../../errors'
2
+ import { embeddingKeyOptions, embeddingNextStep, EMBEDDING_KEY_SOURCES, recommendedEmbeddingKey } from '../../local/local-embedding-setup'
3
+ import { configuredProviders, setKey } from '../../local/local-keys'
4
+ import { PLATFORM_DEFAULT_EMBEDDING } from '../../local/local-org-settings'
5
+ import { localProject } from '../../local/local-project'
6
+ import { readSnapshot } from '../../local/local-sync-snapshot'
7
+ import { readState } from '../../local/local-state'
8
+ import { type Command } from '../types'
9
+
10
+ /**
11
+ * `frontera local embeddings [use <provider>]` (Forge improvement plan L9):
12
+ * what the local runtime embeds knowledge with, and real embeddings from a
13
+ * key the person already has. A key is read from the environment and stored
14
+ * as `local key-set` stores one; it is never printed.
15
+ */
16
+ export const localEmbeddings: Command = {
17
+ meta: {
18
+ noun: 'local',
19
+ verb: 'embeddings',
20
+ args: [
21
+ { name: 'use', required: false, description: '`use` to store a key from this shell for the runtime’s embeddings' },
22
+ { name: 'provider', required: false, description: EMBEDDING_KEY_SOURCES.map((s) => s.provider).join(' | ') },
23
+ ],
24
+ flags: {},
25
+ summary: 'Say what the local runtime embeds knowledge with, and use a key you already have for real embeddings',
26
+ details: [
27
+ 'Without a key the runtime embeds with approximate local vectors (passages found by shared words). An OpenAI, OpenRouter or Gemini key gives a provider’s embedding model; the one that embeds as the workspace does is recommended.',
28
+ '`use <provider>` reads its key from this shell’s environment (OPENAI_API_KEY, OPENROUTER_API_KEY, or GOOGLE_GENERATIVE_AI_API_KEY, GEMINI_API_KEY, GOOGLE_API_KEY), stores it in the keychain and never prints it. The runtime takes it at its next start.',
29
+ ],
30
+ examples: ['frontera local embeddings', 'frontera local embeddings use openai'],
31
+ offline: true,
32
+ },
33
+ async run(ctx) {
34
+ const [sub, provider] = ctx.positional
35
+ const project = localProject(ctx.cwd)
36
+ const state = readState(project)
37
+ const keyless = !!state?.credentials?.modelProviders?.includes('local-embeddings')
38
+ const workspace = readSnapshot(project.root)?.settings?.embedding ?? PLATFORM_DEFAULT_EMBEDDING
39
+ const options = embeddingKeyOptions(process.env, configuredProviders(), workspace)
40
+
41
+ if (sub === undefined) {
42
+ const recommended = recommendedEmbeddingKey(options)
43
+ const next = embeddingNextStep(options, keyless)
44
+ const row = (o: (typeof options)[number]) => ` ${o.label.padEnd(14)} ${o.stored ? 'key stored' : o.envVar ? `key in ${o.envVar}` : 'no key'}${o.matchesWorkspace ? ', embeds as the workspace does' : ''}`
45
+ return {
46
+ data: { running: state?.phase === 'ready', keyless, workspaceEmbedding: workspace, options, recommended: recommended?.provider ?? null, next },
47
+ text: [
48
+ state?.phase === 'ready' ? (keyless ? 'The runtime embeds with the approximate local vectors (no key).' : 'The runtime embeds with a provider’s model.') : 'The local runtime is not running.',
49
+ 'Providers that can embed here:',
50
+ ...options.map(row),
51
+ `Next: ${next}`,
52
+ ].join('\n'),
53
+ }
54
+ }
55
+ if (sub !== 'use') throw new UsageError(`\`local embeddings\` takes \`use <provider>\`, not "${sub}"`, 'frontera local embeddings use openai')
56
+ const option = options.find((o) => o.provider === provider)
57
+ if (!option) throw new UsageError(provider ? `${provider} gives no embeddings here` : 'name the provider', `one of: ${EMBEDDING_KEY_SOURCES.map((s) => s.provider).join(', ')}`)
58
+ if (!option.envVar) {
59
+ throw new CliError(`no ${option.label} key is in this shell’s environment`, {
60
+ code: 'USAGE',
61
+ hint: `set ${EMBEDDING_KEY_SOURCES.find((s) => s.provider === option.provider)!.vars.join(' or ')} and run it again, or store one at a prompt: \`frontera local key-set ${option.provider}\``,
62
+ })
63
+ }
64
+ setKey(option.provider, process.env[option.envVar]!.trim())
65
+ return {
66
+ data: { provider: option.provider, stored: true, from: option.envVar, matchesWorkspace: option.matchesWorkspace, restart: true },
67
+ text: [
68
+ `Stored the ${option.label} key from ${option.envVar} for the local runtime (its value is not shown).`,
69
+ `The runtime embeds with it from its next start: \`frontera local stop\`, then \`frontera local start\`; the next \`frontera local dev\` sets each knowledge base’s threshold for that model.`,
70
+ ...(option.matchesWorkspace ? [] : ['The workspace embeds with another model, so retrieval here ranks a little differently from the workspace’s.']),
71
+ ].join('\n'),
72
+ }
73
+ },
74
+ }