@frontera-sdk/cli 1.52.45 → 1.52.47

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 (105) 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/local/check.ts +57 -11
  33. package/src/commands/local/data.ts +75 -7
  34. package/src/commands/local/dev.ts +77 -9
  35. package/src/commands/local/embeddings.ts +74 -0
  36. package/src/commands/local/index-commands.ts +133 -11
  37. package/src/commands/local/new.ts +78 -0
  38. package/src/commands/local/publish.ts +34 -0
  39. package/src/commands/local/steps.ts +105 -0
  40. package/src/commands/local/sync-request.ts +69 -23
  41. package/src/commands/local/sync.ts +2 -0
  42. package/src/commands/media/index-commands.ts +58 -3
  43. package/src/commands/registry.ts +11 -3
  44. package/src/commands/trace/index-commands.ts +56 -0
  45. package/src/commands/workspace/index-commands.ts +14 -7
  46. package/src/config.ts +4 -1
  47. package/src/dev-broker.ts +15 -1
  48. package/src/device-login.ts +143 -0
  49. package/src/exit.ts +36 -8
  50. package/src/flag-help.ts +30 -0
  51. package/src/forge/claude-adapter.ts +17 -4
  52. package/src/forge/permission-rules.ts +158 -0
  53. package/src/forge/touches.ts +7 -6
  54. package/src/function-outside-imports.ts +13 -2
  55. package/src/function-source.ts +90 -0
  56. package/src/harness.ts +4 -4
  57. package/src/key-reach.ts +64 -0
  58. package/src/local/local-actions.ts +15 -3
  59. package/src/local/local-apply-error.ts +26 -0
  60. package/src/local/local-apps.ts +9 -3
  61. package/src/local/local-blueprint-schemas.ts +15 -1
  62. package/src/local/local-confidential.ts +81 -0
  63. package/src/local/local-data-load.ts +130 -0
  64. package/src/local/local-dev-references.ts +24 -4
  65. package/src/local/local-embedding-setup.ts +62 -0
  66. package/src/local/local-jobs.ts +108 -0
  67. package/src/local/local-new.ts +148 -0
  68. package/src/local/local-org-settings.ts +1 -1
  69. package/src/local/local-person-steps.ts +340 -0
  70. package/src/local/local-personas.ts +16 -5
  71. package/src/local/local-portability.ts +22 -1
  72. package/src/local/local-publish.ts +26 -0
  73. package/src/local/local-request-evidence.ts +35 -6
  74. package/src/local/local-runtime-history.ts +4 -3
  75. package/src/local/local-said-once.ts +51 -0
  76. package/src/local/local-scaffold.ts +40 -2
  77. package/src/local/local-schemas.ts +60 -11
  78. package/src/local/local-stand-ins.ts +44 -0
  79. package/src/local/local-supervisor.ts +22 -0
  80. package/src/local/local-sync-models.ts +5 -2
  81. package/src/local/local-sync-names.ts +3 -0
  82. package/src/local/local-sync-portable.ts +64 -15
  83. package/src/local/local-sync-precheck.ts +30 -1
  84. package/src/local/local-sync-push.ts +81 -8
  85. package/src/local/local-sync-remote.ts +10 -7
  86. package/src/local/local-test-models.ts +173 -0
  87. package/src/local/local-test-runner.ts +232 -25
  88. package/src/local/local-test-scope.ts +174 -0
  89. package/src/local/local-ungranted-reads.ts +55 -0
  90. package/src/local/local-writers.ts +171 -0
  91. package/src/main.ts +75 -17
  92. package/src/mcp/browser/ax-snapshot.ts +13 -1
  93. package/src/mcp/browser/page-scripts.ts +1 -0
  94. package/src/mcp/mcp-conversations.ts +171 -0
  95. package/src/mcp/mcp-server.ts +4 -1
  96. package/src/mcp/mcp-traces.ts +7 -2
  97. package/src/mcp/tools/agent-tools.ts +94 -2
  98. package/src/mcp/tools/project-tools.ts +149 -13
  99. package/src/mcp/tools/test-tools.ts +64 -13
  100. package/src/output.ts +22 -7
  101. package/src/pack.ts +16 -0
  102. package/src/project-context.ts +18 -5
  103. package/src/project.ts +4 -0
  104. package/src/remote-guard.ts +52 -0
  105. package/src/vendor/kit-assets.json +11 -11
@@ -410,6 +410,47 @@ const discard: Command = {
410
410
  },
411
411
  }
412
412
 
413
+ /**
414
+ * `frontera agent behavior clear <agent>`: take an Agent's behavior settings
415
+ * away on its draft (Forge improvement plan X4; P5 A15). An Agent whose live
416
+ * version has behavior settings serves customers, and the built-in Chat
417
+ * neither offers nor runs it; before, putting one back in Chat took a raw API
418
+ * call with a session token. Publishing the draft makes it take effect, which
419
+ * stays the person's step.
420
+ */
421
+ const behavior: Command = {
422
+ meta: {
423
+ noun: 'agent',
424
+ verb: 'behavior',
425
+ args: [
426
+ { name: 'action', required: true, description: 'clear: take every behavior setting away on the draft' },
427
+ { name: 'agent', required: true, description: 'agent id or slug' },
428
+ ],
429
+ flags: {},
430
+ summary: 'Take an Agent\'s behavior settings away on its draft, so once published it serves the built-in Chat again',
431
+ details: [
432
+ 'An Agent whose live version has behavior settings (procedures, guardrails, variables, session defaults or Analysis) serves customers: the built-in Chat neither offers nor runs it. `clear` stages none on the draft; `frontera agent publish <agent>` makes it take effect. In a project, deleting `agents/<slug>/operating.json` and proposing the Agent does the same.',
433
+ ],
434
+ examples: ['frontera agent behavior clear support-desk', 'frontera agent publish support-desk'],
435
+ },
436
+ async run(ctx) {
437
+ const action = ctx.positional[0]
438
+ if (action !== 'clear') throw new UsageError(action ? `\`agent behavior ${action}\` is not a command` : 'say what to do: clear', 'frontera agent behavior clear <agent>')
439
+ const ref = ctx.positional[1]
440
+ if (!ref) throw new UsageError('missing <agent>', 'frontera agent behavior clear <id-or-slug> — see `frontera agent list`')
441
+ const client = api(ctx)
442
+ const id = await resolveAgentRef(client, ref)
443
+ const view = await client.agentOperatingConfig(id).catch(() => null)
444
+ const had = view ? (view.draft ?? view.live) != null : null
445
+ if (had === false) return { data: { agent: ref, cleared: false, reason: 'no behavior settings' }, text: `${ref} has no behavior settings: it already serves the built-in Chat once published.` }
446
+ await client.stageAgentOperatingConfig(id, null)
447
+ return {
448
+ data: { agent: ref, cleared: true, staged: true },
449
+ text: [`Cleared ${ref}'s behavior settings on its draft.`, ` Publish it to take effect: \`frontera agent publish ${ref}\`. Once published, the built-in Chat offers it again.`].join('\n'),
450
+ }
451
+ },
452
+ }
453
+
413
454
  const archive: Command = {
414
455
  meta: {
415
456
  noun: 'agent',
@@ -615,6 +656,7 @@ export const agentCommands: Command[] = [
615
656
  publish,
616
657
  diff,
617
658
  discard,
659
+ behavior,
618
660
  archive,
619
661
  restore,
620
662
  versions,
@@ -284,7 +284,7 @@ export const appDeploy: Command = {
284
284
  }
285
285
 
286
286
  const client = new AppsApi(ctx.apiUrl, ctx.token)
287
- const app = await client.ensureApp(project.slug, project.displayName, project.appId)
287
+ const app = await client.ensureApp(project.slug, project.displayName, project.appId, { icon: project.icon })
288
288
 
289
289
  ctx.output.note(
290
290
  ` bundle ${distFiles.length} files, ${formatBytes(distFiles.reduce((n, f) => n + f.bytes.byteLength, 0))}`,
@@ -109,7 +109,7 @@ export const appDev: Command = {
109
109
  }
110
110
  if (installed) ctx.output.note(`[dev] ${installed.summary}`)
111
111
  const apps = new AppsApi(ctx.apiUrl, ctx.token)
112
- const app = await apps.ensureApp(project.slug, project.displayName, project.appId)
112
+ const app = await apps.ensureApp(project.slug, project.displayName, project.appId, { icon: project.icon })
113
113
  writeAppId(project.root, app.id)
114
114
  // The solution this App is in, if any: where status reads how this server's sessions fare.
115
115
  const solution = enclosingSolution(project.root)
@@ -132,7 +132,7 @@ export const appDev: Command = {
132
132
  appId,
133
133
  mint: (id) => apps.appToken(id),
134
134
  // Gone from the runtime (`local snapshot restore`): registered again by its slug, as `app dev` started.
135
- reRegister: async () => (await apps.ensureApp(project.slug, project.displayName, null)).id,
135
+ reRegister: async () => (await apps.ensureApp(project.slug, project.displayName, null, { icon: project.icon })).id,
136
136
  onReRegistered: (id) => {
137
137
  if (id === appId) return
138
138
  ctx.output.note(`[dev] the runtime no longer had ${app.slug} (a restore of its database?): registered it again as ${id}; \`frontera app deploy\` puts its code back`)
@@ -21,7 +21,7 @@ export const appSave: Command = {
21
21
 
22
22
  // save and deploy are the only verbs that may bring an app into
23
23
  // existence; every read verb resolves instead.
24
- const app = await client.ensureApp(project.slug, project.displayName, project.appId)
24
+ const app = await client.ensureApp(project.slug, project.displayName, project.appId, { icon: project.icon })
25
25
  const { tgz } = packDirectory(project.root, ctx.output, 'packaged')
26
26
  const res = await client.saveDraft(app.id, tgz, project.parentVersion)
27
27
 
@@ -2,6 +2,7 @@ import { verifyKey } from '../../auth-verify'
2
2
  import { ensureMigrated } from '../../config'
3
3
  import { credentialStore } from '../../credential-store'
4
4
  import { CliError, UsageError } from '../../errors'
5
+ import { keyReach, reachWords } from '../../key-reach'
5
6
  import { canPrompt, promptConfirm, promptSecret } from '../../prompt'
6
7
  import {
7
8
  assertApiUrl,
@@ -129,8 +130,11 @@ export const authAdd: Command = {
129
130
  replaced: Boolean(existing),
130
131
  }
131
132
 
133
+ // What the key can reach: an organization key acts only in the workspaces on its list (X3).
134
+ const reach = verified.kind === 'organization' ? await keyReach(apiUrl, token) : null
135
+ if (reach) Object.assign(data, { workspaces: reach })
132
136
  const scope = verified.kind === 'organization'
133
- ? "it authors this organization's Blueprint"
137
+ ? `it authors this organization's Blueprint${reach ? `, and ${reachWords(reach)}` : ''}`
134
138
  : `workspace ${verified.workspaceId}`
135
139
 
136
140
  return {
@@ -2,6 +2,7 @@ import { credentialStore } from '../../credential-store'
2
2
  import { describeSelection } from '../../config'
3
3
  import { CliError } from '../../errors'
4
4
  import { listProfiles } from '../../profiles'
5
+ import { findContext } from '../../project-context'
5
6
  import { flagString, type Command } from '../types'
6
7
 
7
8
  /**
@@ -80,11 +81,16 @@ export const authCurrent: Command = {
80
81
  ? "organization Blueprint author"
81
82
  : `workspace ${metadata.workspaceId ?? 'unknown'}`
82
83
 
84
+ // The binding's organization profile, for organization-scoped commands here (X3).
85
+ const found = selection.source === 'directory' ? findContext(ctx.cwd) : null
86
+ const org = found?.organizationProfile ?? null
87
+ if (org) Object.assign(data, { organizationProfile: org })
83
88
  const lines = [
84
89
  `${selection.profile}${where}`,
85
90
  ` ${metadata.apiUrl} — ${scope}`,
86
91
  ` key ${data.hasSecret ? metadata.fingerprint : 'MISSING — run `frontera auth add ' + selection.profile + '`'}`,
87
92
  ]
93
+ if (org) lines.push(` This project uses ${found!.profile} for workspace commands and ${org} for organization commands${found!.organizationTrusted ? '' : ' (not bound on this machine: run `frontera auth use` here again)'}`)
88
94
  if (envToken) {
89
95
  lines.push(' FRONTERA_TOKEN is set and takes precedence over this profile’s stored key')
90
96
  }
@@ -1,6 +1,7 @@
1
1
  import { authAdd } from './add'
2
2
  import { authCurrent } from './current'
3
3
  import { authList } from './list'
4
+ import { authLogin, authLogout } from './login'
4
5
  import { authRemove } from './remove'
5
6
  import { authUse } from './use'
6
7
  import { authVerify } from './verify'
@@ -13,4 +14,6 @@ export const authCommands: readonly Command[] = [
13
14
  authUse,
14
15
  authVerify,
15
16
  authRemove,
17
+ authLogin,
18
+ authLogout,
16
19
  ]
@@ -0,0 +1,84 @@
1
+ import { describeSelection } from '../../config'
2
+ import { credentialStore } from '../../credential-store'
3
+ import { awaitDeviceSession, forgetDeviceSession, readDeviceSession, requestDeviceCode, saveDeviceSession, sessionEmail } from '../../device-login'
4
+ import { CliError, UsageError } from '../../errors'
5
+ import { flagBool, flagString, type Command, type CommandContext } from '../types'
6
+
7
+ /**
8
+ * `frontera auth login --browser` and `frontera auth logout` (Forge
9
+ * improvement plan N5): a person's session for the commands that act as a
10
+ * person, approved in the browser. The profile's key stays what every other
11
+ * command uses; the session is kept beside it, used only by those commands,
12
+ * and lasts 12 hours. No token is ever printed, pasted or copied out of a
13
+ * browser.
14
+ */
15
+
16
+ function openBrowser(url: string): void {
17
+ const command = process.platform === 'darwin' ? ['open', url] : process.platform === 'win32' ? ['cmd', '/c', 'start', '', url] : ['xdg-open', url]
18
+ try {
19
+ Bun.spawn(command, { stdout: 'ignore', stderr: 'ignore' }).unref()
20
+ } catch {
21
+ // Opening is convenience only; the address is always printed.
22
+ }
23
+ }
24
+
25
+ async function selected(ctx: CommandContext) {
26
+ const { selection, metadata } = await describeSelection({ profile: flagString(ctx, 'profile') }, { cwd: ctx.cwd })
27
+ if (!selection.profile || !metadata) {
28
+ throw new CliError('no Frontera profile is selected for this directory', { code: 'PROFILE_NOT_SELECTED', hint: 'pick one with `frontera auth use <profile>`, or name it with --profile' })
29
+ }
30
+ return { profile: selection.profile, apiUrl: metadata.apiUrl }
31
+ }
32
+
33
+ export const authLogin: Command = {
34
+ meta: {
35
+ noun: 'auth',
36
+ verb: 'login',
37
+ args: [],
38
+ flags: { browser: 'boolean', 'no-open': 'boolean' },
39
+ flagHelp: { 'no-open': 'print the approval address without opening a browser' },
40
+ summary: 'Sign in as yourself in the browser, for the commands that act as a person',
41
+ details: [
42
+ 'Prints a code and opens the platform’s approval page; approve the code there while signed in. The CLI then keeps a session for the selected profile, beside its key, and uses it only for commands that act as a person (they refuse API keys). The session lasts 12 hours; `frontera auth logout` ends it sooner.',
43
+ 'Approve only a code you started yourself, in your own terminal, just now.',
44
+ ],
45
+ examples: ['frontera auth login --browser', 'frontera auth login --browser --profile acme --no-open'],
46
+ offline: true,
47
+ },
48
+ async run(ctx) {
49
+ if (!flagBool(ctx, 'browser')) throw new UsageError('say how to sign in', 'frontera auth login --browser')
50
+ const { profile, apiUrl } = await selected(ctx)
51
+ const store = credentialStore()
52
+ const existing = await readDeviceSession(store, profile, apiUrl)
53
+ if (existing) {
54
+ return { data: { profile, signedIn: true, email: existing.email ?? null, expiresAt: existing.expiresAt, reused: true }, text: `Already signed in for ${profile}${existing.email ? ` as ${existing.email}` : ''}, until ${existing.expiresAt}. \`frontera auth logout\` ends it.` }
55
+ }
56
+ const code = await requestDeviceCode(apiUrl)
57
+ ctx.output.note(`To sign in, open ${code.verificationUri} and enter the code ${code.userCode}`)
58
+ if (!flagBool(ctx, 'no-open')) openBrowser(code.verificationUriComplete)
59
+ const session = await ctx.output.task('Waiting for you to approve it in the browser', () => awaitDeviceSession(apiUrl, code))
60
+ const email = await sessionEmail(apiUrl, session.token)
61
+ await saveDeviceSession(store, profile, { ...session, ...(email ? { email } : {}) })
62
+ return {
63
+ data: { profile, signedIn: true, email, expiresAt: session.expiresAt, reused: false },
64
+ text: `Signed in for ${profile}${email ? ` as ${email}` : ''} until ${session.expiresAt}. Commands that act as a person use this session; every other command keeps using the profile's key.`,
65
+ }
66
+ },
67
+ }
68
+
69
+ export const authLogout: Command = {
70
+ meta: {
71
+ noun: 'auth',
72
+ verb: 'logout',
73
+ args: [],
74
+ flags: {},
75
+ summary: 'End the browser sign-in for the selected profile; its key stays',
76
+ examples: ['frontera auth logout', 'frontera auth logout --profile acme'],
77
+ offline: true,
78
+ },
79
+ async run(ctx) {
80
+ const { profile } = await selected(ctx)
81
+ const forgotten = await forgetDeviceSession(credentialStore(), profile)
82
+ return { data: { profile, signedOut: forgotten }, text: forgotten ? `Signed out of ${profile}'s browser session; its key stays.` : `No browser session for ${profile}.` }
83
+ },
84
+ }
@@ -25,10 +25,15 @@ export const authUse: Command = {
25
25
  noun: 'auth',
26
26
  verb: 'use',
27
27
  args: [{ name: 'profile', required: true, description: 'profile to select for this directory tree' }],
28
- flags: { dir: 'string' },
28
+ flags: { dir: 'string', 'org-profile': 'string' },
29
+ flagHelp: { 'org-profile': 'the organization profile organization-scoped commands use here (Blueprint authoring, governed Actions), beside a workspace profile on the same origin' },
29
30
  summary: 'Select a profile for this directory and everything under it',
31
+ details: [
32
+ '`--org-profile <name>` names the organization profile for organization-scoped commands beside a workspace profile: in this directory, workspace commands use the workspace key and organization commands the organization key, with no `--profile`. It is named, never guessed from the origin: two customers can share one host.',
33
+ ],
30
34
  examples: [
31
35
  'frontera auth use acme-prod',
36
+ 'frontera auth use acme-prod --org-profile acme-org',
32
37
  'frontera auth use globex-dev --dir ~/Customers/globex',
33
38
  ],
34
39
  offline: true,
@@ -53,8 +58,20 @@ export const authUse: Command = {
53
58
  })
54
59
  }
55
60
 
61
+ // An organization profile for organization-scoped commands here (X3): an organization key on the same origin.
62
+ const orgProfile = flagString(ctx, 'org-profile')
63
+ let orgMetadata: ReturnType<typeof requireProfile> | null = null
64
+ if (orgProfile) {
65
+ assertProfileName(orgProfile)
66
+ orgMetadata = requireProfile(orgProfile)
67
+ if (metadata.credentialKind !== 'workspace') throw new UsageError(`${name} is an organization profile; --org-profile goes beside a workspace profile`, `frontera auth use <workspace profile> --org-profile ${orgProfile}`)
68
+ if (orgMetadata.credentialKind !== 'organization') throw new UsageError(`${orgProfile} holds a workspace key, not an organization key`, 'add the organization key as its own profile: `frontera auth add <name> --api-url <origin>` with an sk-org- key')
69
+ if (orgMetadata.apiUrl !== metadata.apiUrl) throw new UsageError(`${orgProfile} is for ${orgMetadata.apiUrl}, and ${name} for ${metadata.apiUrl}`, 'both profiles must reach the same deployment')
70
+ if ((await store.get(orgProfile)) === null) throw new CliError(`profile "${orgProfile}" has no stored key`, { code: 'PROFILE_SECRET_MISSING', hint: `re-add it with \`frontera auth add ${orgProfile} --api-url ${orgMetadata.apiUrl}\`` })
71
+ }
72
+
56
73
  const { root, source } = resolveBindingRoot(ctx.cwd, flagString(ctx, 'dir'))
57
- const path = writeContext(root, name)
74
+ const path = writeContext(root, name, process.env, orgProfile)
58
75
  const gitignore = ensureGitignored(root)
59
76
 
60
77
  const scope = metadata.credentialKind === 'organization'
@@ -73,10 +90,12 @@ export const authUse: Command = {
73
90
  workspaceId: metadata.workspaceId,
74
91
  orgId: metadata.orgId,
75
92
  fingerprint: metadata.fingerprint,
93
+ ...(orgProfile ? { organizationProfile: orgProfile } : {}),
76
94
  },
77
95
  text: [
78
96
  `Selected ${name} for ${root}`,
79
97
  ` ${metadata.apiUrl} — ${scope}`,
98
+ ...(orgProfile ? [` This project uses ${name} for workspace commands and ${orgProfile} for organization commands`] : []),
80
99
  ` wrote ${relative(root, path) || path}${gitignore === 'present' ? '' : `, ${gitignore} .gitignore entry`}`,
81
100
  ].join('\n'),
82
101
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * `frontera function bind|unbind|bindings` — the operator's half of making an
3
- * automation callable from a conversation.
3
+ * automation callable from a conversation, or startable from a Custom App
4
+ * (`--app`, X6).
4
5
  *
5
6
  * The author's half is in the manifest (`trigger: { agent: true }`, plus a
6
7
  * description of the automation and of every input) and is checked at deploy.
@@ -31,22 +32,37 @@ function requireAgent(ctx: CommandContext): string {
31
32
  const agent = flagString(ctx, 'agent')
32
33
  if (!agent) {
33
34
  throw new UsageError(
34
- 'missing --agent <agent-slug>',
35
- 'a binding names exactly one agent — there is no "all agents" form on purpose',
35
+ 'missing --agent <agent-slug> or --app <app-slug>',
36
+ 'a binding names exactly one agent or one Custom App — there is no "all" form on purpose',
36
37
  )
37
38
  }
38
39
  return agent
39
40
  }
40
41
 
42
+ /**
43
+ * `--app <slug>`: the App lane (X6). A Custom App's code holds no secrets, so
44
+ * it starts a Function bound to it, as the person using it. One of `--agent`
45
+ * and `--app`, never both: they are different permissions.
46
+ */
47
+ function appOf(ctx: CommandContext): string | null {
48
+ const app = flagString(ctx, 'app')
49
+ if (app === undefined) return null
50
+ if (flagString(ctx, 'agent') !== undefined) throw new UsageError('pass --agent or --app, not both', 'an Agent binding and an App binding are separate permissions: make each with its own command')
51
+ if (!/^[a-z0-9-]+$/.test(app)) throw new UsageError(`--app takes a Custom App's slug, got "${app}"`, '`frontera app list` shows each App’s slug')
52
+ return app
53
+ }
54
+
41
55
  const bind: Command = {
42
56
  meta: {
43
57
  noun: 'function',
44
58
  verb: 'bind',
45
59
  args: [{ name: 'slug', required: true, description: 'automation slug' }],
46
- flags: { agent: 'string', approval: 'string', wait: 'string' },
47
- summary: 'Let one agent run this automation from a conversation',
60
+ flags: { agent: 'string', app: 'string', approval: 'string', wait: 'string', off: 'boolean' },
61
+ flagHelp: { app: 'a Custom App by its slug: the App may start the Function as the person using it', off: 'with --app: keep the binding, switched off' },
62
+ summary: 'Let one agent run this automation from a conversation, or one Custom App start it',
48
63
  examples: [
49
64
  'frontera function bind reconcile-invoices --agent finance-analyst',
65
+ 'frontera function bind start-return --app returns-desk',
50
66
  'frontera function bind reconcile-invoices --agent analyst --approval auto',
51
67
  'frontera function bind reconcile-invoices --agent analyst --wait 120',
52
68
  ],
@@ -54,6 +70,15 @@ const bind: Command = {
54
70
 
55
71
  async run(ctx) {
56
72
  const slug = requireSlug(ctx)
73
+ const app = appOf(ctx)
74
+ if (app) {
75
+ if (flagString(ctx, 'approval') !== undefined || flagString(ctx, 'wait') !== undefined) throw new UsageError('--approval and --wait are an Agent binding’s', 'an App starts a Function when a person clicks, so there is no approval card or wait')
76
+ const result = await new AutomationApi(ctx.apiUrl, ctx.token, ctx.workspaceId).bindApp(slug, app, { enabled: ctx.flags.off !== true })
77
+ return {
78
+ data: result,
79
+ text: `${result.appDisplayName} (${result.appSlug}) may start ${slug}${result.enabled ? '' : ' — switched off'}.${result.inertReason === 'no_live_version' ? ` ${slug} has no live version yet, so the binding starts nothing until it is promoted.` : ''}\nIn a project, apps/${app}.json lists it after a pull; a change request carries that file.`,
80
+ }
81
+ }
57
82
  const agent = requireAgent(ctx)
58
83
 
59
84
  // `required` is the default at every layer, and it is spelled out here
@@ -96,13 +121,19 @@ const unbind: Command = {
96
121
  noun: 'function',
97
122
  verb: 'unbind',
98
123
  args: [{ name: 'slug', required: true, description: 'automation slug' }],
99
- flags: { agent: 'string' },
100
- summary: 'Stop one agent being able to run this automation',
101
- examples: ['frontera function unbind reconcile-invoices --agent finance-analyst'],
124
+ flags: { agent: 'string', app: 'string' },
125
+ flagHelp: { app: 'a Custom App by its slug: it may no longer start the Function' },
126
+ summary: 'Stop one agent being able to run this automation, or one Custom App starting it',
127
+ examples: ['frontera function unbind reconcile-invoices --agent finance-analyst', 'frontera function unbind start-return --app returns-desk'],
102
128
  },
103
129
 
104
130
  async run(ctx) {
105
131
  const slug = requireSlug(ctx)
132
+ const app = appOf(ctx)
133
+ if (app) {
134
+ await new AutomationApi(ctx.apiUrl, ctx.token, ctx.workspaceId).unbindApp(slug, app)
135
+ return { data: { unbound: true }, text: `${app} can no longer start ${slug}.` }
136
+ }
106
137
  const agent = requireAgent(ctx)
107
138
  await new AutomationApi(ctx.apiUrl, ctx.token, ctx.workspaceId).unbind(slug, agent)
108
139
  return { data: { unbound: true }, text: `${agent} can no longer run ${slug}.` }
@@ -126,10 +157,12 @@ const bindings: Command = {
126
157
  description: 'automation slug — omit it and pass --agent to ask from the agent side',
127
158
  },
128
159
  ],
129
- flags: { agent: 'string' },
130
- summary: 'List the agents that may run an automation, or what one agent may run',
160
+ flags: { agent: 'string', apps: 'boolean' },
161
+ flagHelp: { apps: 'the Custom Apps that may start it, instead of the agents' },
162
+ summary: 'List the agents (or Custom Apps) that may run an automation, or what one agent may run',
131
163
  examples: [
132
164
  'frontera function bindings reconcile-invoices',
165
+ 'frontera function bindings start-return --apps',
133
166
  'frontera function bindings --agent finance-analyst',
134
167
  ],
135
168
  },
@@ -139,6 +172,17 @@ const bindings: Command = {
139
172
  const agent = flagString(ctx, 'agent')
140
173
  const slug = ctx.positional[0]
141
174
 
175
+ if (ctx.flags.apps === true) {
176
+ if (!slug) throw new UsageError('--apps lists one Function’s Apps: pass its slug', 'frontera function bindings <slug> --apps')
177
+ const rows = await client.appBindings(slug)
178
+ return {
179
+ data: rows,
180
+ text: rows.length === 0
181
+ ? `No Custom App can start ${slug}.`
182
+ : table(['app', 'name', 'enabled', 'starts it now', 'bound by'], rows.map((b) => [b.appSlug, b.appDisplayName, String(b.enabled), b.projects === false ? `no (${b.inertReason === 'no_live_version' ? 'nothing live' : 'switched off'})` : 'yes', b.createdBy])),
183
+ }
184
+ }
185
+
142
186
  if (!slug && !agent) {
143
187
  throw new UsageError(
144
188
  'pass an automation slug, or --agent <agent-slug>',
@@ -6,8 +6,7 @@ import { validateManifest } from '@frontera-sdk/functions'
6
6
 
7
7
  import { AutomationApi } from '../../api/automation-api'
8
8
  import { UsageError } from '../../errors'
9
- import { outsideImports } from '../../function-outside-imports'
10
- import { packDirectory } from '../../pack'
9
+ import { packFunctionSource } from '../../pack'
11
10
  import { automationInit } from './init'
12
11
  import { automationPull } from './pull'
13
12
  import { automationRun, automationRuns } from './run'
@@ -120,7 +119,7 @@ const deploy: Command = {
120
119
  let source: Uint8Array | undefined
121
120
  if (projectRoot) {
122
121
  // With the files it imports from outside its folder, so a pull brings a Function that builds.
123
- source = packDirectory(projectRoot, ctx.output, 'source', outsideImports(projectRoot, resolve(file))).tgz
122
+ source = packFunctionSource(projectRoot, resolve(file), ctx.output, 'source').tgz
124
123
  } else {
125
124
  // Degrade honestly rather than refuse. Authoring inside this monorepo is
126
125
  // still supported and simply cannot retain source — there is no
@@ -61,6 +61,17 @@ async function datasetRevision(client: BlueprintAuthoringApi, name: string) {
61
61
  return current
62
62
  }
63
63
 
64
+ /**
65
+ * The object type file's `backing` once bound: the dataset and the mapping,
66
+ * merged into what the file says, never in place of it, so `editable` and the
67
+ * rest stay (Forge improvement plan X11; a rebind lost the editable fields,
68
+ * P5 A4).
69
+ */
70
+ export function boundBacking(before: unknown, dataset: string, mapping: Record<string, unknown>): Record<string, unknown> {
71
+ const kept = before && typeof before === 'object' && !Array.isArray(before) ? (before as Record<string, unknown>) : {}
72
+ return { ...kept, dataset, mapping }
73
+ }
74
+
64
75
  export const blueprintBind: Command = {
65
76
  meta: {
66
77
  noun: 'blueprint',
@@ -213,7 +224,7 @@ export const blueprintBind: Command = {
213
224
  }
214
225
  writeTree(treeRoot, [{
215
226
  ...file,
216
- document: { ...file.document, backing: { dataset: datasetName, mapping: written } },
227
+ document: { ...file.document, backing: boundBacking(file.document.backing, datasetName, written) },
217
228
  }])
218
229
  treeUpdated = true
219
230
  }
@@ -24,6 +24,7 @@ import {
24
24
  import { metricRuleProblems } from '../../blueprint/metric-rules'
25
25
  import { actionRuleProblems, type ActionRuleProblems, type InheritedConflict } from '../../blueprint/action-rules'
26
26
  import { pickCurrentRevision } from '../../blueprint/dataset-revision'
27
+ import { nameCheckWords, orgNameCheck } from '../../blueprint/org-name-check'
27
28
  import {
28
29
  assertPrunesLeaveNoDanglingReference,
29
30
  diff,
@@ -1693,9 +1694,12 @@ export const blueprintNew: Command = {
1693
1694
  document: scaffold(kind, apiName, options),
1694
1695
  }
1695
1696
  writeTree(directory, [file])
1697
+ // In a project linked to a workspace: is the name already the organization's, outside the slice (X11)?
1698
+ const check = await orgNameCheck(ctx.cwd, kind, apiName)
1699
+ const warning = nameCheckWords(kind, apiName, check)
1696
1700
  return {
1697
- data: { path: relative },
1698
- text: `Wrote ${relative}. Edit it, then \`frontera blueprint plan\`.`,
1701
+ data: { path: relative, organizationName: check },
1702
+ text: [`Wrote ${relative}. Edit it, then \`frontera blueprint plan\`.`, ...(warning ? [warning] : [])].join('\n'),
1699
1703
  }
1700
1704
  },
1701
1705
  }
@@ -0,0 +1,66 @@
1
+ import { UsageError } from '../../errors'
2
+ import { conversationTranscript, searchConversations, type TranscriptTurn } from '../../mcp/mcp-conversations'
3
+ import { flagBool, flagString, type Command } from '../types'
4
+ import { projectRuntime } from './local-runtime'
5
+
6
+ /**
7
+ * `frontera conversation get|search`: the project's local runtime's
8
+ * conversations, read whole (Forge improvement plan N7), the same reads as
9
+ * the MCP tools `conversation_get` and `conversation_search`.
10
+ */
11
+
12
+ function turnLines(turns: TranscriptTurn[]): string[] {
13
+ return turns.flatMap((t) => [
14
+ `Turn ${t.turn}${t.procedure?.name ? ` · procedure ${t.procedure.name}` : ''}`,
15
+ ...(t.user ? [` Person: ${t.user}`] : []),
16
+ ...t.toolCalls.map((c) => ` Tool: ${c.name}${c.ok ? '' : c.waiting ? ' (waiting on a person)' : ' (no result)'}`),
17
+ ` Agent: ${t.reply || '(no reply)'}`,
18
+ ...(t.replyChecks?.length ? [` Reply check changed this reply (${t.replyChecks.length})`] : []),
19
+ '',
20
+ ])
21
+ }
22
+
23
+ export const conversationGet: Command = {
24
+ meta: {
25
+ noun: 'conversation',
26
+ verb: 'get',
27
+ args: [{ name: 'conversation', required: true, description: 'The conversation id (a test case’s, or one `conversation search` found)' }],
28
+ flags: { 'no-traces': 'boolean' },
29
+ flagHelp: { 'no-traces': 'leave out what each turn’s trace says (its procedure and the reply check)' },
30
+ summary: 'Read one conversation on the project’s local runtime: every turn, tool call, procedure and reply check',
31
+ examples: ['frontera conversation get 6f1c…', 'frontera conversation get 6f1c… --json'],
32
+ offline: true,
33
+ },
34
+ async run(ctx) {
35
+ const id = ctx.positional[0]
36
+ if (!id) throw new UsageError('missing <conversation>', '`frontera conversation search` lists them')
37
+ const link = projectRuntime(ctx.cwd)
38
+ const transcript = await conversationTranscript(link, id, { traces: !flagBool(ctx, 'no-traces') })
39
+ if (!transcript) throw new UsageError(`no conversation ${id} on the local runtime`, '`frontera conversation search` lists them')
40
+ return { data: transcript, text: [`Conversation ${id}: ${transcript.turns.length} turn${transcript.turns.length === 1 ? '' : 's'}`, '', ...turnLines(transcript.turns)].join('\n').trimEnd() }
41
+ },
42
+ }
43
+
44
+ export const conversationSearch: Command = {
45
+ meta: {
46
+ noun: 'conversation',
47
+ verb: 'search',
48
+ args: [{ name: 'query', required: false, description: 'Words the conversation contains' }],
49
+ flags: { agent: 'string', tests: 'boolean', limit: 'string' },
50
+ flagHelp: { agent: 'only this Agent’s conversations (its slug)', tests: 'only conversations test runs started', limit: 'at most this many (default 20)' },
51
+ summary: 'Find conversations on the project’s local runtime, newest first',
52
+ examples: ['frontera conversation search "refund"', 'frontera conversation search --agent sdr --tests'],
53
+ offline: true,
54
+ },
55
+ async run(ctx) {
56
+ const link = projectRuntime(ctx.cwd)
57
+ const limit = Number(flagString(ctx, 'limit') ?? 20)
58
+ const hits = await searchConversations(link, { query: ctx.positional[0], agent: flagString(ctx, 'agent'), tests: flagBool(ctx, 'tests'), limit: Number.isFinite(limit) ? limit : 20 })
59
+ return {
60
+ data: { conversations: hits },
61
+ text: hits.length ? hits.map((h) => `${h.conversationId} ${h.updatedAt ?? ''} ${h.agent ?? ''} ${h.title ?? ''}`.trimEnd()).join('\n') : 'No conversations match.',
62
+ }
63
+ },
64
+ }
65
+
66
+ export const conversationCommands: readonly Command[] = [conversationGet, conversationSearch]
@@ -0,0 +1,18 @@
1
+ import { CliError } from '../../errors'
2
+ import { localProject } from '../../local/local-project'
3
+ import { runtimeLink, type RuntimeLink } from '../../mcp/mcp-runtime'
4
+
5
+ /**
6
+ * The local runtime `frontera conversation` and `frontera trace` read (Forge
7
+ * improvement plan N7): the one of the project this folder is in. Reading a
8
+ * workspace's conversations, which are customer data, from a coding agent is
9
+ * an open question the plan leaves to its owner, so these commands never
10
+ * reach a workspace.
11
+ */
12
+ export function projectRuntime(cwd: string): RuntimeLink {
13
+ const link = runtimeLink(localProject(cwd))
14
+ if (typeof link === 'string') {
15
+ throw new CliError(link, { code: 'LOCAL_NOT_RUNNING', hint: 'run it in the project folder with its local runtime started (`frontera local start`); a workspace’s conversations are read in the product, not from here' })
16
+ }
17
+ return link
18
+ }