@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
package/src/exit.ts CHANGED
@@ -43,10 +43,14 @@ const USAGE_CODES = new Set([
43
43
  'VALIDATION_ERROR',
44
44
  'BAD_REQUEST',
45
45
  'NOT_FOUND',
46
+ // A Blueprint file that doesn't parse, named with its line and column: fixed in the file.
47
+ 'BLUEPRINT_FILE_INVALID',
46
48
  // Profile selection. Every one of these is fixed by changing the command or
47
49
  // the directory binding — never by retrying, which is what separates them
48
50
  // from AUTH below: a wrong profile is a usage mistake, a revoked key is not.
49
51
  'PROFILE_NOT_FOUND',
52
+ // A change from a local project bound for another deployment, not said with --profile (X3).
53
+ 'REMOTE_FROM_LOCAL_PROJECT',
50
54
  'PROFILE_NOT_SELECTED',
51
55
  'PROFILE_ORIGIN_MISMATCH',
52
56
  'PROFILE_INVALID',
@@ -314,6 +318,28 @@ export function withRefusedNames(message: string, details: unknown): string {
314
318
  return names.length ? `${message}: ${names.join(', ')}` : message
315
319
  }
316
320
 
321
+ /**
322
+ * The blockers a refusal carries in its details (`RELEASE_BLOCKED`'s
323
+ * `blockers`: code, path, message), said under its message, at most five
324
+ * (Forge improvement plan N9). Before, `blueprint publish` and `local dev`
325
+ * printed "Blueprint validation is blocked." and nothing about why.
326
+ */
327
+ export function withBlockers(message: string, details: unknown): string {
328
+ if (typeof details !== 'object' || details === null) return message
329
+ const body = details as { blockers?: unknown; details?: { blockers?: unknown } }
330
+ const list = Array.isArray(body.blockers) ? body.blockers : Array.isArray(body.details?.blockers) ? body.details.blockers : []
331
+ const lines = list.flatMap((b): string[] => {
332
+ if (typeof b !== 'object' || b === null) return []
333
+ const { code, path, message: why } = b as { code?: unknown; path?: unknown; message?: unknown }
334
+ const text = typeof why === 'string' ? why : typeof code === 'string' ? code : ''
335
+ if (!text) return []
336
+ return [`${typeof code === 'string' ? `[${code}] ` : ''}${typeof path === 'string' && path ? `${path}: ` : ''}${text}`]
337
+ })
338
+ if (!lines.length) return message
339
+ const shown = lines.slice(0, 5)
340
+ return [`${message.replace(/[.:]\s*$/, '')}:`, ...shown.map((l) => ` ✕ ${l}`), ...(lines.length > shown.length ? [` … and ${lines.length - shown.length} more`] : [])].join('\n')
341
+ }
342
+
317
343
  function isFronteraError(
318
344
  err: unknown,
319
345
  ): err is { code: string; message: string; details?: unknown } {
@@ -346,7 +372,7 @@ export function toEnvelope(err: unknown): ErrorEnvelope {
346
372
  }
347
373
  const hint = hintFor(err.code, err.details)
348
374
  const reason = reasonOf(err.details)
349
- return { error: true, code: err.code, message: withRefusedNames(err.message, err.details), ...(hint ? { hint } : {}), ...(reason ? { details: { reason } } : {}) }
375
+ return { error: true, code: err.code, message: withBlockers(withRefusedNames(err.message, err.details), err.details), ...(hint ? { hint } : {}), ...(reason ? { details: { reason } } : {}) }
350
376
  }
351
377
 
352
378
  return {
@@ -359,15 +385,17 @@ export function toEnvelope(err: unknown): ErrorEnvelope {
359
385
  /**
360
386
  * The one recovery for a key sent to a command that acts as a person.
361
387
  *
362
- * `frontera login` and `auth add` store only `sk-ws-`/`sk-org-` keys, so the
363
- * session has to come from the environment. Worded once here because every
364
- * session-lane noun meets the same refusal — 401 on session-only routers, 403
365
- * on programmatic ones — and per-API copies of this sentence had started to
366
- * drift apart.
388
+ * `frontera auth add` stores only `sk-ws-`/`sk-org-` keys; a person's session
389
+ * comes from `frontera auth login --browser`, approved in the browser (Forge
390
+ * improvement plan N5). Before, this told people to set FRONTERA_TOKEN to a
391
+ * session token, which they could only copy out of the browser's developer
392
+ * tools. Worded once here because every session-lane noun meets the same
393
+ * refusal — 401 on session-only routers, 403 on programmatic ones — and
394
+ * per-API copies of this sentence had started to drift apart.
367
395
  */
368
396
  export const SESSION_LANE_HINT =
369
- 'this command acts as a person and refuses API keys — set FRONTERA_TOKEN to your session token '
370
- + 'and FRONTERA_API_URL, then re-run; `frontera login` stores keys only'
397
+ 'this command acts as a person and refuses API keys — sign in as yourself with '
398
+ + '`frontera auth login --browser` (approved in your browser, lasts 12 hours), then re-run'
371
399
 
372
400
  /** What a session-lane command needs to know about itself to word a refusal. */
373
401
  export interface SessionLaneMeta {
package/src/flag-help.ts CHANGED
@@ -15,7 +15,16 @@
15
15
  export const FLAG_HELP: Readonly<Record<string, string>> = {
16
16
  // Agent↔automation bindings. `agent` is deliberately singular: a binding
17
17
  // names one agent, and there is no wildcard form.
18
+ envelope: 'JSON as one document on stdout whatever happens (implies --json): { ok, data, warnings }, or { ok: false, error, warnings } on a failure (FRONTERA_JSON_ENVELOPE=1 sets it for every command)',
19
+ model: 'a catalog model to run on instead of the Agents’ own (their drafts are put back after)',
20
+ baseline: 'a kept test run to compare with: last, last-own, last-<model>, or a run id',
21
+ 'via-actions': 'submit each record as the governed Action its group names, as the local submitter persona',
22
+ 'no-snapshot': 'don’t save the records first (by default a load saves them, and a refused record puts them back)',
23
+ batch: 'how many records are submitted at once (1 to 16, default 4)',
18
24
  agent: 'agent slug this binding is for — one agent, no wildcard',
25
+ app: 'a Custom App by its slug: the App may start the Function as the person using it',
26
+ apps: 'list the Custom Apps that may start it, instead of the agents',
27
+ off: 'keep the binding, switched off',
19
28
  approval: '"required" (default) asks a human before each call; "auto" does not',
20
29
  wait: 'seconds the agent waits for the run before it is told the run is still going (5–300)',
21
30
  // Declarative Blueprint authoring — the file tree, and what applying it may do.
@@ -31,6 +40,12 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
31
40
  'dry-run': 'derive and print the write path without building anything',
32
41
  'plan-id': 'reuse this mutation plan id instead of minting one, so a retry names the same artifact',
33
42
  'binding-id': 'reuse this Binding id instead of minting one, so a retry names the same artifact',
43
+ attach: 'the media reference property an attachment Action fills with the file it is given',
44
+ like: 'start from another Agent’s files, by its slug',
45
+ customer: 'the Agent serves customers: write behavior settings (operating.json)',
46
+ 'attach-item-id': 'the text property that keeps the attached file\'s Media Item id (a link key)',
47
+ 'file-input': 'the Action input the file arrives in: a staged upload id (`frontera media stage`) or a run\'s file',
48
+ 'media-set': 'the Media Set the attached file joins; defaults to the media reference property\'s own set',
34
49
  reason: 'reason recorded with the deployment transition, request or approval decision; the reason of a rejection in an approval chain is shown to whoever raised the request',
35
50
  stage: 'approval-chain step being decided: required for a request approved in steps, and refused if another step is waiting by then',
36
51
  'no-activate': 'review the write path but leave it switched off',
@@ -103,6 +118,9 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
103
118
  query: 'a query string to send with the delivery, such as source=portal&urgent=1',
104
119
  'fail-fast': 'stop the check at the first step that fails, instead of running and reporting every step',
105
120
  strict: 'fail on inherited failures too: items and files unchanged since the pull, which otherwise are listed and do not fail the check',
121
+ 'org-profile': 'the organization profile organization-scoped commands use in this directory, beside a workspace profile on the same deployment',
122
+ repeat: 'run each Agent test case N times (1–10) and report its pass rate; a case that passes some runs and fails others is flaky',
123
+ 'pass-rate': 'with --repeat, the share of runs a flaky case must pass to pass (default 0.8, or 80%); below it, it fails',
106
124
  concurrency: 'how many Agent test cases run at once (1–16, default 2); cases that change records for real still run one at a time. Lower it when the model provider refuses for requests in flight',
107
125
 
108
126
  // Apps
@@ -263,6 +281,18 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
263
281
  picks: 'a side per conflict, numbered as `diff` shows them: 1=workspace,2=mine; =both keeps mine then the workspace’s where `diff` says both can be kept',
264
282
  edited: 'take the files as you edited them; the workspace’s copies beside them go',
265
283
  title: 'the change request’s title; a push that opens a new one needs it',
284
+ why: 'a person-step: why it is needed now, in a sentence',
285
+ changes: 'a person-step: what it changes, in the workspace’s words',
286
+ credential: 'a person-step: who or what it needs (workspace-key, organization-key, steward, admin, second-person, person)',
287
+ delivery: 'a person-step: the delivery it belongs to; steps with the same one show as one card',
288
+ all: 'list the settled ones too',
289
+ browser: 'sign in by approving a code in the browser, as yourself',
290
+ 'no-traces': 'leave out what each turn’s trace says',
291
+ tests: 'only what test runs started',
292
+ brief: 'the short form, without inputs and outputs',
293
+ conversation: 'only this conversation’s',
294
+ errors: 'only those with an error',
295
+ transcripts: 'include each case’s whole conversation, turn by turn',
266
296
  migration: 'for a Blueprint change that is not additive: what happens to existing records',
267
297
  documents: 'comma-separated knowledge bases whose documents a pull brings (a base pulled now brings its own anyway), or a push proposes (`*` for every one)',
268
298
  'no-documents': 'pull knowledge bases without their documents (by default a pulled base brings them)',
@@ -276,6 +306,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
276
306
  conflicts: 'the conflicts the picks were chosen against, as `diff --json` names them; refused if they moved',
277
307
  'from-workspace': 'start the project from a platform workspace instead of the examples',
278
308
  'no-sample-data': 'do not generate sample rows for the pulled Blueprint',
309
+ 'no-kit': 'leave out the Frontera skills (the kit) that local init vendors into the project; for a machine that uses the installed plugin instead',
279
310
  'keep-sample-data': 'leave the sample datasets as they are; by default they are made again (same seed, rows and day) after a pull that brings Blueprint items, or when an older generator made them',
280
311
  'remake-sample-data': 'make the sample datasets again with the newest sample rules (same seed, rows and day), even the ones a pull wrote, which local dev otherwise keeps as pulled',
281
312
  synthetic: 'generate sample rows for the Blueprint: values fit each property, links stay consistent',
@@ -6,6 +6,7 @@ import { ApprovalBroker, type ApprovalDecision, type Emit, type HostAdapter, typ
6
6
  import { ClaudeEventTranslator } from './claude-translator'
7
7
  import { forgeEnv, forgePath } from './env'
8
8
  import type { Logger } from './log'
9
+ import { fronteraPermissionRules, runsPersonStep } from './permission-rules'
9
10
 
10
11
  /**
11
12
  * Claude Code as a Forge host, driven through `@anthropic-ai/claude-agent-sdk`.
@@ -48,6 +49,15 @@ export function autoAllows(mode: string, toolName: string): boolean {
48
49
  return false
49
50
  }
50
51
 
52
+ /**
53
+ * A call the mode never answers on its own: a shell command that runs a
54
+ * person-step (`blueprint apply`, `app promote`, …). It always gets a card,
55
+ * even under Full access, where every other call is approved upstream.
56
+ */
57
+ export function asksThePerson(toolName: string, input: Record<string, unknown>): boolean {
58
+ return toolName === 'Bash' && typeof input.command === 'string' && runsPersonStep(input.command)
59
+ }
60
+
51
61
  /** The thread's effort, when the SDK would accept it. */
52
62
  export function claudeEffort(effort: string | null): Options['effort'] | undefined {
53
63
  if (!effort || !EFFORT_LEVELS.has(effort)) return undefined
@@ -95,7 +105,7 @@ export class ClaudeAdapter implements HostAdapter {
95
105
 
96
106
  const mode = ctx.thread.mode
97
107
  const canUseTool: CanUseTool = async (toolName, input, options) => {
98
- if (autoAllows(mode, toolName)) return { behavior: 'allow', updatedInput: input }
108
+ if (autoAllows(mode, toolName) && !asksThePerson(toolName, input)) return { behavior: 'allow', updatedInput: input }
99
109
  const toolCallId = options.toolUseID ?? crypto.randomUUID()
100
110
  flush(translator.ensureToolCall(toolCallId, toolName, input))
101
111
  const decision = await this.approvals.request(ctx, emit, toolCallId, toolName, input)
@@ -117,10 +127,13 @@ export class ClaudeAdapter implements HostAdapter {
117
127
  ...(ctx.thread.hostSessionId ? { resume: ctx.thread.hostSessionId } : {}),
118
128
  includePartialMessages: true,
119
129
  settingSources: ['user', 'project', 'local'],
130
+ // The same rules as a terminal session: read-only `frontera` commands
131
+ // run without a card, delivery commands always raise one.
132
+ settings: { permissions: fronteraPermissionRules() },
120
133
  additionalDirectories: [ctx.project.root],
121
- // Under bypassPermissions the SDK never consults the callback and warns
122
- // loudly that it was given one.
123
- ...(mode === 'bypassPermissions' ? {} : { canUseTool }),
134
+ // An ask rule reaches the callback in every mode, Full access included,
135
+ // so a delivery command is still a card there.
136
+ canUseTool,
124
137
  env: forgeEnv(ctx.project.profile ? { FRONTERA_PROFILE: ctx.project.profile } : {}),
125
138
  ...(await this.mcpServers(ctx)),
126
139
  }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The Claude Code permission rules Forge gives every Claude Code session it
3
+ * starts, in its terminal (`--settings`) and in its in-app chat (the Agent
4
+ * SDK's `settings`).
5
+ *
6
+ * Without rules, every `frontera` command went to Claude Code's auto-mode
7
+ * classifier: it denied `blueprint apply` and `app promote` outright, and a
8
+ * classifier outage ended the turn even on `--help`. With them:
9
+ *
10
+ * - commands that only read are allowed, so they never wait on a classifier
11
+ * or a prompt;
12
+ * - commands that make something live, or act with a person's authority,
13
+ * always ask. An ask rule prompts in every mode, Auto and Full access
14
+ * included, so no mode turns a delivery step into one the agent takes on
15
+ * its own.
16
+ *
17
+ * The lists name exact command paths, never a wildcard across arguments: an
18
+ * allow rule `frontera * list` would also match a write whose last argument
19
+ * is "list". Claude Code checks each part of a compound command (`&&`, `|`,
20
+ * `;`) on its own, so `frontera status && <anything>` is not allowed by the
21
+ * first part.
22
+ */
23
+
24
+ /** Commands that only read: help, status, lists, single items, diffs and previews. */
25
+ export const FRONTERA_READ_ONLY_COMMANDS: readonly string[] = [
26
+ '--help',
27
+ '--version',
28
+ 'help',
29
+ 'auth list',
30
+ 'auth current',
31
+ 'kit status',
32
+ 'kit doctor',
33
+ 'workspace list',
34
+ 'local status',
35
+ 'local logs',
36
+ 'local schema',
37
+ 'local providers',
38
+ 'local models-available',
39
+ 'local key-list',
40
+ 'local sync status',
41
+ 'local sync diff',
42
+ 'local sync push --preview',
43
+ 'local sync pull --preview',
44
+ 'local sync request show',
45
+ 'app list',
46
+ 'app versions',
47
+ 'app checks',
48
+ 'app deployments',
49
+ 'agent list',
50
+ 'agent get',
51
+ 'agent diff',
52
+ 'agent versions',
53
+ 'skill list',
54
+ 'skill get',
55
+ 'plugin list',
56
+ 'plugin get',
57
+ 'plugin catalog',
58
+ 'capability list',
59
+ 'capability available',
60
+ 'capability installs',
61
+ 'dataset list',
62
+ 'dataset get',
63
+ 'dataset sources',
64
+ 'source list',
65
+ 'source get',
66
+ 'knowledge list',
67
+ 'knowledge get',
68
+ 'knowledge sources',
69
+ 'knowledge agents',
70
+ 'media list',
71
+ 'media status',
72
+ 'pack list',
73
+ 'pack get',
74
+ 'secret list',
75
+ 'chat-app list',
76
+ 'chat-app get',
77
+ 'function list',
78
+ 'function versions',
79
+ 'function runs',
80
+ 'function bindings',
81
+ 'workflow list',
82
+ 'workflow get',
83
+ 'workflow validate',
84
+ 'workflow runs',
85
+ 'workflow lineage',
86
+ 'blueprint list',
87
+ 'blueprint get',
88
+ 'blueprint status',
89
+ 'blueprint plan',
90
+ 'blueprint object-sets',
91
+ 'marking list',
92
+ 'action list',
93
+ 'action requests',
94
+ 'action available',
95
+ 'conversation get',
96
+ 'conversation search',
97
+ 'trace get',
98
+ 'trace list',
99
+ 'local steps check',
100
+ ]
101
+
102
+ /**
103
+ * Commands that make something live, decide for the workspace or set a
104
+ * secret. They are the person's calls: the agent prepares them, the person
105
+ * says yes to each one.
106
+ */
107
+ export const FRONTERA_PERSON_COMMANDS: readonly string[] = [
108
+ 'blueprint apply',
109
+ 'blueprint publish',
110
+ 'blueprint rollback',
111
+ 'blueprint grant',
112
+ 'app promote',
113
+ 'app rollback',
114
+ 'agent publish',
115
+ 'chat-app publish',
116
+ 'function promote',
117
+ 'workflow promote',
118
+ 'local sync request apply',
119
+ 'action deploy',
120
+ 'action review',
121
+ 'action retire',
122
+ 'action grant',
123
+ 'action approve',
124
+ 'action reject',
125
+ 'capability grant',
126
+ 'media grant',
127
+ 'marking grant',
128
+ 'secret set',
129
+ // Stores a provider key from the shell (L9); not on the read-only list, whose rule would match it by prefix.
130
+ 'local embeddings use',
131
+ 'secret delete',
132
+ ]
133
+
134
+ const rule = (path: string) => `Bash(frontera ${path}:*)`
135
+
136
+ /** The `permissions` block for a Claude Code session Forge starts. */
137
+ export function fronteraPermissionRules(): { allow: string[]; ask: string[] } {
138
+ return { allow: FRONTERA_READ_ONLY_COMMANDS.map(rule), ask: FRONTERA_PERSON_COMMANDS.map(rule) }
139
+ }
140
+
141
+ /** `FOO=1 frontera …`: assignments before the command name it doesn't. */
142
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=\S*$/
143
+
144
+ /**
145
+ * Whether a shell command runs a person-step: any part of it, split the way
146
+ * Claude Code splits a compound command, that starts with `frontera` and one
147
+ * of the person-step paths. The in-app chat asks for these on a card in every
148
+ * mode, Full access included, as the ask rules do in the terminal.
149
+ */
150
+ export function runsPersonStep(command: string): boolean {
151
+ return command.split(/&&|\|\||[;|&\n]/).some((part) => {
152
+ const words = part.trim().split(/\s+/).filter(Boolean)
153
+ while (words.length && ASSIGNMENT.test(words[0]!)) words.shift()
154
+ if (words[0] !== 'frontera' && !/\/frontera$/.test(words[0] ?? '')) return false
155
+ const rest = words.slice(1).join(' ')
156
+ return FRONTERA_PERSON_COMMANDS.some((path) => rest === path || rest.startsWith(`${path} `))
157
+ })
158
+ }
@@ -44,11 +44,12 @@ export const FORGE_ARTIFACT_KINDS = [
44
44
  * `search`, `runs`, `bindings` — is a read and is deliberately absent.
45
45
  */
46
46
  export const TOUCHING_VERBS: Record<ForgeArtifactKind, readonly string[]> = {
47
- agent: ['apply', 'create', 'publish', 'revert', 'discard', 'skill-attach', 'skill-detach', 'archive', 'restore'],
47
+ // `behavior clear <agent>` stages the Agent's behavior settings away (X4); its Agent is the second positional.
48
+ agent: ['apply', 'create', 'publish', 'revert', 'discard', 'skill-attach', 'skill-detach', 'archive', 'restore', 'behavior'],
48
49
  automation: ['deploy', 'promote', 'enable', 'disable', 'init', 'bind', 'unbind', 'run'],
49
50
  app: ['deploy', 'promote', 'rollback', 'unpin', 'save', 'add'],
50
51
  blueprint: ['apply', 'publish', 'create', 'update', 'delete', 'rename', 'rollback', 'bind', 'grant', 'adopt', 'editable'],
51
- action: ['prepare', 'deploy', 'grant', 'review'],
52
+ action: ['prepare', 'deploy', 'grant', 'review', 'retire'],
52
53
  skill: ['push', 'delete'],
53
54
  source: ['create', 'update', 'revise', 'disable'],
54
55
  // `revise` cuts the dataset's next revision from its CSV.
@@ -119,12 +120,12 @@ const BLUEPRINT_KINDED_VERBS = new Set(['update', 'delete', 'rename'])
119
120
  */
120
121
  export const BOOLEAN_FLAGS = new Set([
121
122
  // Global.
122
- 'json', 'quiet', 'yes', 'help', 'force',
123
+ 'json', 'envelope', 'quiet', 'yes', 'help', 'force',
123
124
  // Every `'boolean'` FlagSpec a command declares (`grep "'boolean'" src/commands`).
124
125
  'accept-contract-change', 'accept-orphaned-knowledge', 'dry-run', 'end-user', 'no-activate',
125
126
  'no-components', 'no-git', 'no-input', 'no-install', 'no-open', 'no-promote', 'no-wait',
126
- 'remove-apps', 'token-stdin', 'prune', 'dev', 'revoke', 'draft', 'check',
127
- 'skip-build-check', 'prod', 'preview', 'undo', 'build',
127
+ 'customer', 'remove-apps', 'token-stdin', 'prune', 'dev', 'revoke', 'draft', 'check',
128
+ 'skip-build-check', 'prod', 'preview', 'undo', 'build', 'off',
128
129
  ])
129
130
 
130
131
  /**
@@ -398,7 +399,7 @@ export function parseFronteraCommands(line: string): ParsedFronteraCommand[] {
398
399
  if (!TOUCHING_VERBS[kind].includes(verb)) continue
399
400
  const ref = (FILE_POSITIONAL_VERBS[kind] ?? []).includes(verb)
400
401
  ? null
401
- : kind === 'blueprint' && BLUEPRINT_KINDED_VERBS.has(verb)
402
+ : (kind === 'blueprint' && BLUEPRINT_KINDED_VERBS.has(verb)) || (kind === 'agent' && verb === 'behavior')
402
403
  ? (rest[1] ?? null)
403
404
  : (rest[0] ?? null)
404
405
  const live =
@@ -53,10 +53,20 @@ const within = (root: string, path: string) => path === root || path.startsWith(
53
53
  * files it imports inside it too, as archive entries under `frontera-imports/`.
54
54
  */
55
55
  export function outsideImports(projectRoot: string, entry: string): SourceFile[] {
56
+ return importGraph(projectRoot, entry).outside
57
+ }
58
+
59
+ /**
60
+ * Every file `entry` reaches by relative import, followed file to file:
61
+ * `inside` the project folder (absolute paths, the entry included), and the
62
+ * ones `outside` it as archive entries under `frontera-imports/`.
63
+ */
64
+ export function importGraph(projectRoot: string, entry: string): { inside: string[]; outside: SourceFile[] } {
56
65
  const root = resolve(projectRoot)
57
66
  const bound = resolve(solutionRoot(root))
58
67
  const seen = new Set<string>()
59
68
  const out: SourceFile[] = []
69
+ const inside: string[] = []
60
70
  const read = (path: string): string | null => {
61
71
  try {
62
72
  return existsSync(path) && lstatSync(path).isFile() ? readFileSync(path, 'utf8') : null
@@ -69,7 +79,8 @@ export function outsideImports(projectRoot: string, entry: string): SourceFile[]
69
79
  const text = read(file)
70
80
  if (text === null) return
71
81
  seen.add(file)
72
- if (!within(root, file)) {
82
+ if (within(root, file)) inside.push(file)
83
+ else {
73
84
  const rel = relative(root, file).split(sep).join('/')
74
85
  if (CODE.test(file) && !isExcluded(rel.replace(/^(\.\.\/)+/, ''))) out.push({ rel: outsidePath(rel), bytes: new TextEncoder().encode(text) })
75
86
  }
@@ -85,5 +96,5 @@ export function outsideImports(projectRoot: string, entry: string): SourceFile[]
85
96
  }
86
97
  }
87
98
  visit(resolve(entry))
88
- return out
99
+ return { inside, outside: out }
89
100
  }
@@ -0,0 +1,90 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs'
2
+ import { join, relative, resolve, sep } from 'node:path'
3
+
4
+ import { importGraph } from './function-outside-imports'
5
+ import { collectSourceFiles, formatBytes, gitignoreMatcher, isExcluded, type SourceFile } from './packaging'
6
+
7
+ /**
8
+ * What a Function's source archive carries (Forge improvement plan L7; P4
9
+ * 2.9, P5 A14: a one-file Function in a project carried the whole project,
10
+ * 424 files and 8.3 MB, as its source).
11
+ *
12
+ * - A Function with a project of its own (`functions/<slug>/package.json`,
13
+ * or a project `frontera function init` made) keeps that project's files.
14
+ * - A Function in a solution project's own package (`functions/<slug>.ts`,
15
+ * whose nearest `package.json` is the project's, beside `frontera.yaml`)
16
+ * keeps only what it reaches: its entry, every file the entry imports by
17
+ * relative path, and the package files it builds with.
18
+ * - `.fronteraignore` at the packed folder's root, in `.gitignore` form,
19
+ * leaves more out. The hard exclusions (`.env`, `node_modules`, build
20
+ * output, `.frontera/`) always apply.
21
+ * - Past `SOURCE_WARN_BYTES` or `SOURCE_WARN_FILES`, the deploy warns and
22
+ * names the largest files.
23
+ */
24
+
25
+ export const SOURCE_WARN_BYTES = 2 * 1024 * 1024
26
+ export const SOURCE_WARN_FILES = 150
27
+ export const IGNORE_FILE = '.fronteraignore'
28
+
29
+ /** The files a one-file Function builds with, beside what it imports. */
30
+ const PACKAGE_FILES = ['package.json', 'tsconfig.json', 'bun.lock', 'bun.lockb', 'package-lock.json', 'bunfig.toml', IGNORE_FILE]
31
+
32
+ const posixRel = (root: string, path: string) => relative(root, path).split(sep).join('/')
33
+
34
+ /** `.fronteraignore`'s rules at `root`, in `.gitignore` form; nothing ignored without one. */
35
+ function fronteraIgnore(root: string): (rel: string) => boolean {
36
+ const path = join(root, IGNORE_FILE)
37
+ if (!existsSync(path)) return () => false
38
+ // The `.gitignore` matcher, read from the other file.
39
+ const rules = readFileSync(path, 'utf8')
40
+ .split('\n')
41
+ .map((l) => l.trim())
42
+ .filter((l) => l && !l.startsWith('#') && !l.startsWith('!'))
43
+ .map((l) => l.replace(/\/$/, ''))
44
+ return (rel) => {
45
+ const segments = rel.split('/')
46
+ return rules.some((rule) => (rule.startsWith('*.') ? rel.endsWith(rule.slice(1)) : segments.includes(rule) || rel === rule || (rule.includes('/') && (rel === rule || rel.startsWith(`${rule}/`)))))
47
+ }
48
+ }
49
+
50
+ export interface FunctionSource {
51
+ files: SourceFile[]
52
+ /** True when only the files the entry reaches were kept (a one-file Function in a solution project). */
53
+ reachedOnly: boolean
54
+ /** How many files `.fronteraignore` left out. */
55
+ ignored: number
56
+ }
57
+
58
+ /** The source of the Function at `entry`, packed from `packageRoot` (its nearest package.json's folder). */
59
+ export function functionSourceFiles(packageRoot: string, entry: string): FunctionSource {
60
+ const root = resolve(packageRoot)
61
+ const graph = importGraph(root, resolve(entry))
62
+ const reachedOnly = existsSync(join(root, 'frontera.yaml'))
63
+ let own: SourceFile[]
64
+ if (reachedOnly) {
65
+ const gitignored = gitignoreMatcher(root)
66
+ const paths = new Set([...graph.inside, ...PACKAGE_FILES.map((f) => join(root, f)).filter((p) => existsSync(p) && statSync(p).isFile())])
67
+ own = [...paths]
68
+ .map((p) => ({ path: p, rel: posixRel(root, p) }))
69
+ .filter(({ rel }) => !isExcluded(rel) && !gitignored(rel))
70
+ .sort((a, b) => a.rel.localeCompare(b.rel))
71
+ .map(({ path, rel }) => ({ rel, bytes: new Uint8Array(readFileSync(path)) }))
72
+ } else {
73
+ own = collectSourceFiles(root)
74
+ }
75
+ const ignore = fronteraIgnore(root)
76
+ const kept = own.filter((f) => !ignore(f.rel))
77
+ return { files: [...kept, ...graph.outside], reachedOnly, ignored: own.length - kept.length }
78
+ }
79
+
80
+ /** The warning for a source past the limits, naming its largest files; null under them. */
81
+ export function sourceSizeWarning(name: string, files: SourceFile[]): string | null {
82
+ const bytes = files.reduce((n, f) => n + f.bytes.byteLength, 0)
83
+ if (bytes <= SOURCE_WARN_BYTES && files.length <= SOURCE_WARN_FILES) return null
84
+ const largest = [...files]
85
+ .sort((a, b) => b.bytes.byteLength - a.bytes.byteLength)
86
+ .slice(0, 3)
87
+ .map((f) => `${f.rel} (${formatBytes(f.bytes.byteLength)})`)
88
+ .join(', ')
89
+ return `the source of ${name} is ${files.length} files, ${formatBytes(bytes)} (largest: ${largest}). Every version keeps it, and a pull brings it back: leave out what the Function doesn't need with ${IGNORE_FILE} (.gitignore form) beside its package.json`
90
+ }
package/src/harness.ts CHANGED
@@ -70,10 +70,10 @@ Two fields decide whether a call can work at all:
70
70
 
71
71
  - \`authLane\` — \`api-key\` reaches the command with \`sk-ws-\`/\`sk-org-\` or a
72
72
  session. \`session\` acts as a person and needs a person's session, not an API
73
- key: set \`FRONTERA_TOKEN\` to the session token and \`FRONTERA_API_URL\` (or
74
- pass \`--api-url\`). The CLI refuses an \`sk-ws-\`/\`sk-org-\` key on those
75
- before sending anything; \`frontera login\` stores keys only, so it cannot
76
- supply the session. On a project's local runtime, never fetch a person's
73
+ key: the person signs in with \`frontera auth login --browser\` (approved in
74
+ their browser, 12 hours), and those commands use it. The CLI refuses an
75
+ \`sk-ws-\`/\`sk-org-\` key on those before sending anything. Never ask the
76
+ person for a session token: prepare the step for them instead. On a project's local runtime, never fetch a person's
77
77
  session: \`--as <persona>\` runs \`action available\`, \`submit\`,
78
78
  \`approve\` and \`reject\` as a local persona (\`--as submitter\`,
79
79
  \`--as <approver>\`; \`frontera local status\` lists them).
@@ -0,0 +1,64 @@
1
+ import { BlueprintAuthoringApi } from './api/blueprint-authoring-api'
2
+ import { PlatformApi } from './api/platform-api'
3
+
4
+ /**
5
+ * What an organization key can reach (Forge improvement plan X3, T7; P2 F6,
6
+ * P5 A16): workspace-scoped commands run with the organization key were
7
+ * refused, and nothing said which workspaces the key may act in.
8
+ *
9
+ * An organization key carries a list of workspaces, and the service refuses
10
+ * an `x-workspace-id` that is not on it before anything else runs. So the
11
+ * key's own `whoami`, asked once per workspace, answers whether it may act
12
+ * there without the CLI reading the key's row or guessing.
13
+ */
14
+
15
+ export interface ReachedWorkspace {
16
+ id: string
17
+ name: string
18
+ slug: string
19
+ /** The key may act in it (`--workspace <id>`). */
20
+ actsIn: boolean
21
+ }
22
+
23
+ /** At most this many workspaces are asked about; the rest are listed without the mark. */
24
+ export const MAX_REACH_PROBES = 50
25
+
26
+ /** Whether the key may act in the workspace: its `whoami` there answers with it. */
27
+ export async function actsInWorkspace(apiUrl: string, token: string, workspaceId: string): Promise<boolean> {
28
+ try {
29
+ const me = await new PlatformApi(apiUrl, token, workspaceId).whoami()
30
+ return me.workspaceId === workspaceId
31
+ } catch {
32
+ return false
33
+ }
34
+ }
35
+
36
+ /**
37
+ * The organization's workspaces, each marked with whether this key may act
38
+ * in it. Null when the key can't list them (a workspace key, or one without
39
+ * `organization:update`).
40
+ */
41
+ export async function keyReach(
42
+ apiUrl: string,
43
+ token: string,
44
+ deps: { list?: () => Promise<Array<{ id?: string; name?: string; slug?: string }>>; acts?: (workspaceId: string) => Promise<boolean> } = {},
45
+ ): Promise<ReachedWorkspace[] | null> {
46
+ const list = deps.list ?? (() => new BlueprintAuthoringApi(apiUrl, token).orgWorkspaces())
47
+ const acts = deps.acts ?? ((id: string) => actsInWorkspace(apiUrl, token, id))
48
+ let rows: Array<{ id?: string; name?: string; slug?: string }>
49
+ try {
50
+ rows = await list()
51
+ } catch {
52
+ return null
53
+ }
54
+ const named = rows.filter((w): w is { id: string; name?: string; slug?: string } => typeof w.id === 'string' && w.id !== '')
55
+ return Promise.all(named.map(async (w, i) => ({ id: w.id, name: w.name ?? '', slug: w.slug ?? '', actsIn: i < MAX_REACH_PROBES ? await acts(w.id) : false })))
56
+ }
57
+
58
+ /** The reach in one line: `may act in 2 of 5 workspaces: Ops, Claims`. */
59
+ export function reachWords(reach: readonly ReachedWorkspace[]): string {
60
+ const acting = reach.filter((w) => w.actsIn)
61
+ if (!reach.length) return 'the organization has no workspaces it can list'
62
+ if (!acting.length) return `may act in none of the organization's ${reach.length} workspace${reach.length === 1 ? '' : 's'}: its workspace-scoped commands are refused`
63
+ return `may act in ${acting.length} of ${reach.length} workspace${reach.length === 1 ? '' : 's'}: ${acting.map((w) => w.name || w.slug || w.id).join(', ')} (\`--workspace <id>\`; \`frontera workspace list\` marks them)`
64
+ }
@@ -251,7 +251,7 @@ interface DeployFailure {
251
251
  }
252
252
 
253
253
  /** The write path for one Action that writes the Blueprint's records: built, validated, reviewed, activated. */
254
- async function deployOne(call: Call, published: Deployable): Promise<string | DeployFailure | null> {
254
+ async function deployOne(call: Call, published: Deployable, attachment?: LocalAction['attachment']): Promise<string | DeployFailure | null> {
255
255
  const { definition, contractDigest } = published
256
256
  const types = data<Array<{ id?: string; apiName?: string }>>(await call('org', 'GET', '/v1/blueprint/object-types')) ?? []
257
257
  const subject = (Array.isArray(types) ? types : []).find((t) => t.id === definition.subject.objectTypeId)?.apiName
@@ -260,9 +260,19 @@ async function deployOne(call: Call, published: Deployable): Promise<string | De
260
260
  await call('org', 'GET', `/v1/blueprint/object-types/${encodeURIComponent(subject)}`),
261
261
  )
262
262
  if (!shape?.objectType?.id) return `${subject} is not published here`
263
+ // A Media Set named in the file is found by its API name among this runtime's sets (the stand-ins included).
264
+ let mediaSetId: string | undefined
265
+ if (attachment?.mediaSet) {
266
+ const sets = data<Array<{ id?: string; apiName?: string }>>(await call('session', 'GET', '/v1/media/sets')) ?? []
267
+ mediaSetId = (Array.isArray(sets) ? sets : []).find((m) => m.apiName === attachment.mediaSet || m.id === attachment.mediaSet)?.id
268
+ if (!mediaSetId) return `its attachment names the Media Set ${attachment.mediaSet}, which this runtime has no set of`
269
+ }
270
+ const choice = attachment
271
+ ? { uploadInput: attachment.fileInput, ...(attachment.property ? { property: attachment.property } : {}), ...(attachment.itemIdProperty ? { itemIdProperty: attachment.itemIdProperty } : {}), ...(mediaSetId ? { mediaSetId } : {}) }
272
+ : undefined
263
273
  let plan
264
274
  try {
265
- plan = deriveOntologyEditPlan(definition, { id: shape.objectType.id, apiName: shape.objectType.apiName ?? subject, primaryKeyPropertyId: shape.objectType.primaryKeyPropertyId ?? null, properties: shape.properties ?? [] })
275
+ plan = deriveOntologyEditPlan(definition, { id: shape.objectType.id, apiName: shape.objectType.apiName ?? subject, primaryKeyPropertyId: shape.objectType.primaryKeyPropertyId ?? null, properties: shape.properties ?? [] }, choice)
266
276
  } catch (err) {
267
277
  if (err instanceof PlanDerivationError) return err.message
268
278
  throw err
@@ -309,6 +319,8 @@ export interface LocalAction {
309
319
  fit: ActionFit
310
320
  /** The capability that lets a role invoke it (`governance.invokeCapability`). */
311
321
  invokeCapability?: string
322
+ /** How its deployment attaches the file it takes: its file's `attachment` (L6). */
323
+ attachment?: { fileInput: string; property?: string; itemIdProperty?: string; mediaSet?: string }
312
324
  }
313
325
 
314
326
  /**
@@ -338,7 +350,7 @@ export async function deployActions(call: Call, actions: LocalAction[]): Promise
338
350
  // Not active, or active on a write path whose validation no longer holds: built again.
339
351
  if (published.deploymentStatus !== 'active' || published.reason === 'ACTION_BINDING_VALIDATION_STALE') {
340
352
  try {
341
- const why = await deployOne(call, published)
353
+ const why = await deployOne(call, published, action.attachment)
342
354
  failure = typeof why === 'string' ? { reason: why } : why
343
355
  } catch (err) {
344
356
  failure = { reason: (err as Error).message }