@frontera-sdk/cli 1.44.0 → 1.45.0

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 (58) hide show
  1. package/README.md +65 -1
  2. package/package.json +4 -3
  3. package/src/api/automation-api.ts +15 -0
  4. package/src/api/dataset-api.ts +99 -0
  5. package/src/api/governed-action-api.ts +80 -0
  6. package/src/api/platform-api.ts +293 -0
  7. package/src/auth-verify.ts +105 -0
  8. package/src/binding-registry.ts +87 -0
  9. package/src/commands/action/deploy.ts +1 -0
  10. package/src/commands/action/grant.ts +1 -0
  11. package/src/commands/action/index-commands.ts +8 -0
  12. package/src/commands/action/prepare.ts +1 -0
  13. package/src/commands/action/requests.ts +111 -0
  14. package/src/commands/action/review.ts +1 -0
  15. package/src/commands/agent/index-commands.ts +189 -7
  16. package/src/commands/app/init.ts +1 -1
  17. package/src/commands/app/pull.ts +1 -1
  18. package/src/commands/auth/add.ts +145 -0
  19. package/src/commands/auth/current.ts +82 -0
  20. package/src/commands/auth/index-commands.ts +16 -0
  21. package/src/commands/auth/list.ts +71 -0
  22. package/src/commands/auth/remove.ts +80 -0
  23. package/src/commands/auth/use.ts +84 -0
  24. package/src/commands/auth/verify.ts +93 -0
  25. package/src/commands/automation/run.ts +41 -2
  26. package/src/commands/blueprint/query.ts +294 -0
  27. package/src/commands/capability/index-commands.ts +334 -0
  28. package/src/commands/dataset/index-commands.ts +103 -14
  29. package/src/commands/kit/doctor.ts +101 -0
  30. package/src/commands/kit/index-commands.ts +7 -0
  31. package/src/commands/kit/shared.ts +52 -0
  32. package/src/commands/kit/status.ts +92 -0
  33. package/src/commands/kit/sync.ts +106 -0
  34. package/src/commands/kit/vendor.ts +120 -0
  35. package/src/commands/knowledge/index-commands.ts +165 -0
  36. package/src/commands/login.ts +64 -84
  37. package/src/commands/plugin/index-commands.ts +284 -21
  38. package/src/commands/registry.ts +104 -1
  39. package/src/commands/setup.ts +248 -0
  40. package/src/commands/source/index-commands.ts +446 -0
  41. package/src/commands/types.ts +14 -0
  42. package/src/config.ts +197 -100
  43. package/src/credential-store.ts +273 -0
  44. package/src/dev-env.ts +3 -3
  45. package/src/exit.ts +29 -2
  46. package/src/flag-help.ts +65 -3
  47. package/src/fs-atomic.ts +44 -0
  48. package/src/harness.ts +155 -4
  49. package/src/kit.ts +419 -0
  50. package/src/main.ts +13 -1
  51. package/src/paths.ts +43 -0
  52. package/src/profile-migration.ts +101 -0
  53. package/src/profiles.ts +240 -0
  54. package/src/project-context.ts +178 -0
  55. package/src/prompt.ts +23 -0
  56. package/src/templates/next-app-files.ts +4 -1
  57. package/src/vendor/kit-assets.json +31 -0
  58. package/src/vendor/sdk-sources.json +1 -1
@@ -1,6 +1,9 @@
1
+ import { readFileSync } from 'node:fs'
2
+
1
3
  import { PlatformApi } from '../../api/platform-api'
4
+ import { UsageError } from '../../errors'
2
5
  import { table } from '../../table'
3
- import type { Command } from '../types'
6
+ import { flagString, type Command } from '../types'
4
7
 
5
8
  /**
6
9
  * `/v1/apps` returns `{ install, catalog, … }` per row, not a flattened
@@ -78,35 +81,295 @@ const catalog: Command = {
78
81
  },
79
82
  }
80
83
 
84
+ const get: Command = {
85
+ meta: {
86
+ noun: 'plugin',
87
+ verb: 'get',
88
+ args: [{ name: 'plugin', required: true, description: 'install id, from `frontera plugin list`' }],
89
+ flags: {},
90
+ summary: 'Show one installed plugin — its configuration and connection state',
91
+ examples: ['frontera plugin get <installId>', 'frontera plugin get <installId> --json'],
92
+ },
93
+ async run(ctx) {
94
+ const installId = ctx.positional[0]
95
+ if (!installId) throw new UsageError('missing <plugin>', 'frontera plugin list')
96
+
97
+ const install = await new PlatformApi(ctx.apiUrl, ctx.token).plugin(installId)
98
+ return {
99
+ data: install,
100
+ text: table(
101
+ ['field', 'value'],
102
+ Object.entries(install).map(([k, v]) => [
103
+ k,
104
+ v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v),
105
+ ]),
106
+ [undefined, 70],
107
+ ),
108
+ }
109
+ },
110
+ }
111
+
112
+ const add: Command = {
113
+ meta: {
114
+ noun: 'plugin',
115
+ verb: 'add',
116
+ args: [{ name: 'kind', required: true, description: 'catalog kind, from `frontera plugin catalog`' }],
117
+ flags: { name: 'string', file: 'string' },
118
+ aliases: { f: 'file' },
119
+ summary: 'Install a plugin from the catalog',
120
+ examples: [
121
+ 'frontera plugin add slack --name "Support Slack"',
122
+ 'frontera plugin add postgres --file ./config.json',
123
+ ],
124
+ },
125
+ async run(ctx) {
126
+ const kind = ctx.positional[0]
127
+ if (!kind) throw new UsageError('missing <kind>', 'frontera plugin catalog — then pass a kind')
128
+
129
+ // Configuration by file, never by flag: a connector config carries
130
+ // credentials, and a flag lands in shell history and the process list.
131
+ // Same rule `secret set` states for itself.
132
+ const file = flagString(ctx, 'file')
133
+ let config: Record<string, unknown> | undefined
134
+ if (file) {
135
+ const raw = file === '-' ? readFileSync(0, 'utf8') : readFileSync(file, 'utf8')
136
+ try {
137
+ config = JSON.parse(raw) as Record<string, unknown>
138
+ } catch (e) {
139
+ throw new UsageError(
140
+ `${file === '-' ? 'stdin' : file} is not valid JSON: ${(e as Error).message}`,
141
+ 'the file holds the connector config object for this plugin kind',
142
+ )
143
+ }
144
+ }
145
+
146
+ const result = await new PlatformApi(ctx.apiUrl, ctx.token).createPluginInstall({
147
+ kind,
148
+ ...(flagString(ctx, 'name') ? { installName: flagString(ctx, 'name')! } : {}),
149
+ ...(config ? { config } : {}),
150
+ })
151
+
152
+ // An OAuth-backed install answers with a URL and is inert until a person
153
+ // opens it. Reporting "installed" without saying so would leave a caller
154
+ // wiring capabilities onto something that cannot connect.
155
+ const authUrl = (result as { authUrl?: string }).authUrl
156
+ return {
157
+ data: result,
158
+ text: authUrl
159
+ ? `Installed ${kind}, and it is NOT usable yet.\n`
160
+ + ` Open this to authorize it:\n ${authUrl}\n`
161
+ + ' Then `frontera plugin verify <installId>`.'
162
+ : `Installed ${kind}.\n`
163
+ + ' `frontera plugin verify <installId>` — confirm it can reach the far end.\n'
164
+ + ' `frontera capability available <installId>` — what it now offers.',
165
+ }
166
+ },
167
+ }
168
+
169
+ const configure: Command = {
170
+ meta: {
171
+ noun: 'plugin',
172
+ verb: 'configure',
173
+ args: [{ name: 'plugin', required: true, description: 'install id, from `frontera plugin list`' }],
174
+ flags: { name: 'string', file: 'string' },
175
+ aliases: { f: 'file' },
176
+ summary: 'Rename an install, or replace its stored configuration',
177
+ examples: [
178
+ 'frontera plugin configure <installId> --name "Support Slack"',
179
+ 'frontera plugin configure <installId> --file ./config.json',
180
+ ],
181
+ },
182
+ async run(ctx) {
183
+ const installId = ctx.positional[0]
184
+ if (!installId) throw new UsageError('missing <plugin>', 'frontera plugin list')
185
+
186
+ const name = flagString(ctx, 'name')
187
+ const file = flagString(ctx, 'file')
188
+ if (!name && !file) {
189
+ throw new UsageError(
190
+ 'nothing to change',
191
+ 'pass --name to rename, or --file to replace the configuration',
192
+ )
193
+ }
194
+
195
+ let config: Record<string, unknown> | undefined
196
+ if (file) {
197
+ const raw = file === '-' ? readFileSync(0, 'utf8') : readFileSync(file, 'utf8')
198
+ try {
199
+ config = JSON.parse(raw) as Record<string, unknown>
200
+ } catch (e) {
201
+ throw new UsageError(
202
+ `${file === '-' ? 'stdin' : file} is not valid JSON: ${(e as Error).message}`,
203
+ 'the file holds the connector config object — `frontera plugin get <installId>` shows the current one',
204
+ )
205
+ }
206
+ }
207
+
208
+ const result = await new PlatformApi(ctx.apiUrl, ctx.token).updatePluginInstall(installId, {
209
+ ...(name ? { installName: name } : {}),
210
+ ...(config ? { config } : {}),
211
+ })
212
+
213
+ return {
214
+ data: result,
215
+ text: `Updated ${installId}.`
216
+ + (config ? '\n Configuration replaced — `frontera plugin verify` to confirm it still connects.' : ''),
217
+ }
218
+ },
219
+ }
220
+
221
+ /**
222
+ * Policy is separate from configuration on purpose.
223
+ *
224
+ * `configure` changes how the install reaches the far end. This changes what
225
+ * the platform will let it do once it gets there — including `read_only`,
226
+ * which refuses every Action capability grant across every agent bound to it.
227
+ * Granting a capability against a read-only install fails with a conflict that
228
+ * no retry fixes, and this is the verb that fixes it.
229
+ */
230
+ const policy: Command = {
231
+ meta: {
232
+ noun: 'plugin',
233
+ verb: 'policy',
234
+ args: [{ name: 'plugin', required: true, description: 'install id, from `frontera plugin list`' }],
235
+ flags: { enable: 'boolean', disable: 'boolean', actions: 'string', 'new-capability': 'string' },
236
+ summary: 'Set an install’s policy — enabled, Action policy, new-capability default',
237
+ examples: [
238
+ 'frontera plugin policy <installId> --actions allow_all',
239
+ 'frontera plugin policy <installId> --disable',
240
+ ],
241
+ },
242
+ async run(ctx) {
243
+ const installId = ctx.positional[0]
244
+ if (!installId) throw new UsageError('missing <plugin>', 'frontera plugin list')
245
+
246
+ const enable = ctx.flags.enable === true
247
+ const disable = ctx.flags.disable === true
248
+ if (enable && disable) {
249
+ throw new UsageError('--enable and --disable contradict each other', 'pass one of them')
250
+ }
251
+
252
+ const actions = flagString(ctx, 'actions')
253
+ const ACTION_POLICIES = ['allow_all', 'read_only', 'custom'] as const
254
+ if (actions && !ACTION_POLICIES.includes(actions as never)) {
255
+ throw new UsageError(
256
+ `--actions must be one of ${ACTION_POLICIES.join(', ')}, not "${actions}"`,
257
+ 'read_only refuses every Action capability grant on this install',
258
+ )
259
+ }
260
+
261
+ const newCapability = flagString(ctx, 'new-capability')
262
+ if (newCapability && newCapability !== 'enabled' && newCapability !== 'disabled') {
263
+ throw new UsageError(
264
+ `--new-capability must be enabled or disabled, not "${newCapability}"`,
265
+ 'it decides what a capability discovered later defaults to; disabled is the safe choice',
266
+ )
267
+ }
268
+
269
+ if (!enable && !disable && !actions && !newCapability) {
270
+ throw new UsageError('nothing to change', 'pass --enable, --disable, --actions or --new-capability')
271
+ }
272
+
273
+ const result = await new PlatformApi(ctx.apiUrl, ctx.token).patchPluginInstall(installId, {
274
+ ...(enable ? { enabled: true } : {}),
275
+ ...(disable ? { enabled: false } : {}),
276
+ ...(actions ? { actionPolicy: actions as 'allow_all' | 'read_only' | 'custom' } : {}),
277
+ ...(newCapability ? { newCapabilityDefault: newCapability as 'enabled' | 'disabled' } : {}),
278
+ })
279
+
280
+ return { data: result, text: `Policy updated on ${installId}.` }
281
+ },
282
+ }
283
+
284
+ const verify: Command = {
285
+ meta: {
286
+ noun: 'plugin',
287
+ verb: 'verify',
288
+ args: [{ name: 'plugin', required: true, description: 'install id, from `frontera plugin list`' }],
289
+ flags: {},
290
+ summary: 'Check that an install can reach the far end',
291
+ examples: ['frontera plugin verify <installId>'],
292
+ },
293
+ async run(ctx) {
294
+ const installId = ctx.positional[0]
295
+ if (!installId) throw new UsageError('missing <plugin>', 'frontera plugin list')
296
+
297
+ // A failed check still answers 200 — the verdict is in the body, the same
298
+ // shape `dataset test-source` established. Exiting non-zero here would
299
+ // make an unreachable plugin indistinguishable from an unreachable API.
300
+ const result = await new PlatformApi(ctx.apiUrl, ctx.token).verifyPluginInstall(installId)
301
+
302
+ return {
303
+ data: result,
304
+ text: table(
305
+ ['field', 'value'],
306
+ Object.entries(result).map(([k, v]) => [
307
+ k,
308
+ v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v),
309
+ ]),
310
+ [undefined, 70],
311
+ ),
312
+ }
313
+ },
314
+ }
315
+
316
+ const remove: Command = {
317
+ meta: {
318
+ noun: 'plugin',
319
+ verb: 'remove',
320
+ args: [{ name: 'plugin', required: true, description: 'install id, from `frontera plugin list`' }],
321
+ flags: {},
322
+ summary: 'Uninstall a plugin from this workspace',
323
+ examples: ['frontera plugin remove <installId>'],
324
+ },
325
+ async run(ctx) {
326
+ const installId = ctx.positional[0]
327
+ if (!installId) throw new UsageError('missing <plugin>', 'frontera plugin list')
328
+
329
+ const result = await new PlatformApi(ctx.apiUrl, ctx.token).deletePluginInstall(installId)
330
+ return {
331
+ data: result,
332
+ text: `Uninstalled ${installId}.\n`
333
+ + ' Every agent bound to it loses the capabilities it carried.',
334
+ }
335
+ },
336
+ }
337
+
81
338
  /**
82
- * `connect` is reserved rather than half-built. It needs the headless consent
83
- * flow — print the authorization URL, poll, `--no-wait` to hand off — because
84
- * the Computer has no browser, and shipping it without that would produce a
85
- * command that hangs in exactly the environment it exists for.
339
+ * `connect` stays reserved, alone among the five.
340
+ *
341
+ * It needs the headless consent flow print the authorization URL, poll,
342
+ * `--no-wait` to hand off because the Computer has no browser, and shipping
343
+ * it without that produces a command that hangs in exactly the environment it
344
+ * exists for. `plugin add` prints the URL when an install needs one, which
345
+ * covers the start of the handshake; finishing it is what remains.
86
346
  */
87
- const RESERVED = 'plugin writes are not available in this release; list and catalog are read-only'
88
-
89
- const reserved: Command[] = (
90
- [
91
- ['add', 'Install a plugin from the catalog'],
92
- ['configure', 'Set a plugin’s configuration or credentials'],
93
- ['connect', 'Complete an OAuth connection'],
94
- ['verify', 'Check a plugin’s connection'],
95
- ['remove', 'Uninstall a plugin'],
96
- ] as const
97
- ).map(([verb, summary]) => ({
347
+ const RESERVED = 'completing an OAuth handshake needs a browser the CLI does not have; '
348
+ + '`frontera plugin add` prints the authorization URL to open'
349
+
350
+ const connect: Command = {
98
351
  meta: {
99
352
  noun: 'plugin',
100
- verb,
353
+ verb: 'connect',
101
354
  args: [],
102
355
  flags: {},
103
- summary,
104
- examples: [`frontera plugin ${verb} <id>`],
356
+ summary: 'Complete an OAuth connection',
357
+ examples: ['frontera plugin connect <installId>'],
105
358
  reserved: RESERVED,
106
359
  },
107
360
  async run(): Promise<never> {
108
361
  throw new Error(RESERVED)
109
362
  },
110
- }))
363
+ }
111
364
 
112
- export const pluginCommands: Command[] = [list, catalog, ...reserved]
365
+ export const pluginCommands: Command[] = [
366
+ list,
367
+ get,
368
+ catalog,
369
+ add,
370
+ configure,
371
+ policy,
372
+ verify,
373
+ remove,
374
+ connect,
375
+ ]
@@ -16,6 +16,7 @@ import { appSdk } from './app/sdk'
16
16
  import { appDev } from './app/dev'
17
17
  import { blueprintList } from './blueprint/list'
18
18
  import { blueprintGet } from './blueprint/get'
19
+ import { blueprintInstance, blueprintQuery } from './blueprint/query'
19
20
  import { blueprintGenerateTypes } from './blueprint/generate-types'
20
21
  import { blueprintAuthoringCommands } from './blueprint/authoring'
21
22
  import { blueprintDeclarativeCommands } from './blueprint/declarative'
@@ -26,7 +27,9 @@ import { actionCommands } from './action/index-commands'
26
27
  import { agentCommands } from './agent/index-commands'
27
28
  import { skillCommands } from './skill/index-commands'
28
29
  import { pluginCommands } from './plugin/index-commands'
30
+ import { capabilityCommands } from './capability/index-commands'
29
31
  import { datasetCommands } from './dataset/index-commands'
32
+ import { sourceCommands } from './source/index-commands'
30
33
  import { knowledgeCommands } from './knowledge/index-commands'
31
34
  import { packCommands } from './pack/index-commands'
32
35
  import { secretCommands } from './secret/index-commands'
@@ -34,6 +37,9 @@ import { automationCommands } from './automation/index-commands'
34
37
  import { completionCommand } from './completion'
35
38
  import { initCommand } from './init'
36
39
  import { loginCommand } from './login'
40
+ import { setupCommand } from './setup'
41
+ import { authCommands } from './auth/index-commands'
42
+ import { kitCommands } from './kit/index-commands'
37
43
 
38
44
  /**
39
45
  * Flags every command accepts. Declared once so a caller never has to wonder
@@ -45,6 +51,10 @@ export const GLOBAL_FLAGS: FlagSpec = {
45
51
  yes: 'boolean',
46
52
  help: 'boolean',
47
53
  'api-url': 'string',
54
+ // Global because the override has to work on EVERY command, not only the
55
+ // auth ones — a one-command escape hatch is useless if it exists on six
56
+ // commands and the seventh is the one being diagnosed.
57
+ profile: 'string',
48
58
  }
49
59
 
50
60
  /** Additionally accepted by any command that resolves a project. */
@@ -52,9 +62,13 @@ export const PROJECT_FLAGS: FlagSpec = { dir: 'string' }
52
62
 
53
63
  export const COMMANDS: readonly Command[] = [
54
64
  initCommand,
65
+ setupCommand,
55
66
  loginCommand,
56
67
  completionCommand,
57
68
 
69
+ ...authCommands,
70
+ ...kitCommands,
71
+
58
72
  appInit,
59
73
  appList,
60
74
  appAdd,
@@ -69,7 +83,9 @@ export const COMMANDS: readonly Command[] = [
69
83
  ...agentCommands,
70
84
  ...skillCommands,
71
85
  ...pluginCommands,
86
+ ...capabilityCommands,
72
87
  ...datasetCommands,
88
+ ...sourceCommands,
73
89
  ...knowledgeCommands,
74
90
  ...packCommands,
75
91
  ...secretCommands,
@@ -77,6 +93,10 @@ export const COMMANDS: readonly Command[] = [
77
93
 
78
94
  blueprintList,
79
95
  blueprintGet,
96
+ // Reads, next to the other two reads: the authoring verbs below all report
97
+ // success without proving anything came back, and these are what prove it.
98
+ blueprintQuery,
99
+ blueprintInstance,
80
100
  blueprintGenerateTypes,
81
101
  // Authoring is no longer reserved: the organization API key (`sk-org-`) reaches the
82
102
  // organization-level shared draft, which is what these verbs were waiting on.
@@ -112,18 +132,68 @@ const NOUN_SUMMARY: Readonly<Record<string, string>> = {
112
132
  skill: 'Workspace skills an agent loads at runtime',
113
133
  plugin: 'Integrations and MCP servers connected to this workspace',
114
134
  dataset: 'Source datasets a Blueprint object type can bind to',
135
+ source: 'Connected Sources — where a dataset’s rows come from',
115
136
  knowledge: 'Knowledge bases and the sources inside them',
116
137
  pack: 'Reusable skill bundles — author once, install per workspace',
117
138
  secret: 'Workspace secrets — named here, never printed back',
118
139
  automation: 'Automations — TypeScript deployed here, run on a schedule',
119
140
  blueprint: 'The shared model of the organization — what an app can read',
120
141
  action: 'Governed Actions — arm the write path a published Action runs',
142
+ capability: 'What an agent may DO — plugin operations granted to it',
143
+ auth: 'Credential profiles — one per API key, selected by directory',
144
+ kit: 'Optional project-local Frontera skills for Codex and Claude Code',
121
145
  }
122
146
 
123
147
  export function nounSummary(noun: string): string {
124
148
  return NOUN_SUMMARY[noun] ?? ''
125
149
  }
126
150
 
151
+ /**
152
+ * Where the CLI ends, and what to do instead.
153
+ *
154
+ * A `reserved` verb tells a caller that a command is planned but unbuilt.
155
+ * Nothing tells them about the surface that has no verb AT ALL — and that is
156
+ * most of the platform: channels, workspaces, sheets and the evaluation suite
157
+ * are all API surfaces the CLI deliberately does not carry. A caller asked to
158
+ * bind a channel reads "unknown command: channel", concludes it mistyped, and
159
+ * either guesses a synonym or greps the binary. Both waste a turn and neither
160
+ * ends at the answer.
161
+ *
162
+ * Keyed by the noun a caller would REACH FOR, not by the one that exists —
163
+ * these entries exist to be hit by a wrong guess. Every value names where the
164
+ * capability actually lives, because "not supported" without a destination is
165
+ * the same dead end in a politer voice.
166
+ *
167
+ * A test asserts no key here collides with a real noun: the day one of these
168
+ * ships, its entry has to go, or the CLI would disown a command it has.
169
+ */
170
+ export const UNSUPPORTED: Readonly<Record<string, string>> = {
171
+ channel:
172
+ 'channels have no CLI surface — create one and bind it to an agent in the Console, '
173
+ + 'under the agent’s Channels tab',
174
+ workspace:
175
+ 'workspace and member management is Console-only. `frontera login` selects which workspace '
176
+ + 'a key acts on; it cannot create one',
177
+ key: 'API keys are minted in the Console, never by the CLI — a credential that mints credentials '
178
+ + 'is the escalation that separation exists to prevent',
179
+ sheet: 'sheets have no CLI surface and none is planned',
180
+ task: 'task and run history is Console-only; `frontera automation runs` covers automation runs',
181
+ eval: 'the evaluation suite (datasets, scorers, judges, queues) is Console-only',
182
+ usage: 'usage, cost and audit reporting is Console-only',
183
+ site: 'published sites are managed in the Console; `frontera app` covers Frontera Apps',
184
+ object:
185
+ 'the legacy object surface is superseded by Blueprint — use `frontera blueprint` instead',
186
+ artifact:
187
+ 'artifacts belong to the chat product — download or export one from the Console, '
188
+ + 'from the session that produced it',
189
+ user: 'users, roles and provider credentials are Console-only',
190
+ }
191
+
192
+ /** The message for a noun the CLI deliberately does not carry, if it is one. */
193
+ export function unsupportedNoun(noun: string): string | null {
194
+ return UNSUPPORTED[noun] ?? null
195
+ }
196
+
127
197
  /** Short forms every command understands, plus the command's own. */
128
198
  export function aliasesFor(meta: CommandMeta): Readonly<Record<string, string>> {
129
199
  return { h: 'help', ...(meta.aliases ?? {}) }
@@ -162,7 +232,11 @@ const GLOBAL_SECTION = [
162
232
  heading('Environment'),
163
233
  ...definitions([
164
234
  ['FRONTERA_API_URL', 'Frontera API origin'],
165
- ['FRONTERA_TOKEN', 'workspace API key (sk-ws-…)'],
235
+ // Upstream's key-kind detail, kept profiles change where a key is stored,
236
+ // not which kinds are valid.
237
+ ['FRONTERA_TOKEN', 'sk-ws-… or sk-org-… — requires FRONTERA_API_URL or --api-url'],
238
+ ['FRONTERA_PROFILE', 'profile to use, overriding the directory selection'],
239
+ ['FRONTERA_SECRET_STORE', 'where keys are stored: keychain (default on macOS) or file'],
166
240
  ['NO_COLOR', 'set to disable colour'],
167
241
  ]),
168
242
  ]
@@ -276,6 +350,20 @@ export function renderCommandHelp(meta: CommandMeta): string {
276
350
  lines.push('', ...wrap(`Not available yet: ${meta.reserved}`, width).map(dim))
277
351
  }
278
352
 
353
+ // Stated only for the exception. Printing "works with an API key" on the
354
+ // thirty commands where that is true trains a reader to skip the line, and
355
+ // the one command where it is false is the one that has to be read.
356
+ if (meta.authLane === 'session') {
357
+ lines.push(
358
+ '',
359
+ ...wrap(
360
+ 'Needs a session credential: run `frontera login`. This command’s endpoints '
361
+ + 'refuse sk-ws- and sk-org- keys, and answer 401 rather than a permission error.',
362
+ width,
363
+ ).map(dim),
364
+ )
365
+ }
366
+
279
367
  if (meta.args.length > 0) {
280
368
  lines.push('', heading('Arguments'))
281
369
  lines.push(
@@ -338,6 +426,11 @@ export interface CommandDescriptor {
338
426
  examples: string[]
339
427
  needsProject: boolean
340
428
  requiresCredential: boolean
429
+ /**
430
+ * `api-key` — reachable with `sk-ws-`/`sk-org-` or a session.
431
+ * `session` — reachable ONLY with a session token from `frontera login`.
432
+ */
433
+ authLane: 'api-key' | 'session'
341
434
  }
342
435
 
343
436
  export function describeCommand(meta: CommandMeta): CommandDescriptor {
@@ -366,6 +459,7 @@ export function describeCommand(meta: CommandMeta): CommandDescriptor {
366
459
  examples: [...meta.examples],
367
460
  needsProject: Boolean(meta.needsProject),
368
461
  requiresCredential: !meta.offline,
462
+ authLane: meta.authLane ?? 'api-key',
369
463
  }
370
464
  }
371
465
 
@@ -414,6 +508,15 @@ function nearest(typed: string, candidates: string[]): string | null {
414
508
  export function unknownCommand(noun: string, verb: string | undefined): UsageError {
415
509
  const known = nouns()
416
510
  if (!known.includes(noun)) {
511
+ // A deliberate boundary answers before the typo check does. `frontera
512
+ // channel bind` is not a misspelling of anything, and offering the nearest
513
+ // real noun would send a caller to an unrelated command with a plausible
514
+ // name rather than to the Console, where the capability is.
515
+ const boundary = unsupportedNoun(noun)
516
+ if (boundary) {
517
+ return new UsageError(`\`${noun}\` is not a Frontera CLI command`, boundary)
518
+ }
519
+
417
520
  const guess = nearest(noun, known)
418
521
  return new UsageError(
419
522
  `unknown command: ${noun}`,