@frontera-sdk/cli 1.50.79 → 1.50.81

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 (37) hide show
  1. package/README.md +6 -0
  2. package/package.json +4 -4
  3. package/src/api/blueprint-authoring-api.ts +52 -0
  4. package/src/api/credential-failure.ts +34 -4
  5. package/src/api/dataset-api.ts +1 -1
  6. package/src/api/governed-action-api.ts +75 -7
  7. package/src/api/media-api.ts +181 -0
  8. package/src/api/platform-api.ts +59 -0
  9. package/src/api/workflow-api.ts +1 -1
  10. package/src/auth-verify.ts +1 -1
  11. package/src/commands/action/available.ts +59 -0
  12. package/src/commands/action/cancel.ts +34 -0
  13. package/src/commands/action/decide.ts +54 -0
  14. package/src/commands/action/deploy.ts +4 -1
  15. package/src/commands/action/index-commands.ts +16 -6
  16. package/src/commands/action/prepare.ts +3 -1
  17. package/src/commands/action/request-summary.ts +13 -0
  18. package/src/commands/action/requests.ts +6 -5
  19. package/src/commands/action/submit.ts +183 -0
  20. package/src/commands/blueprint/get.ts +139 -29
  21. package/src/commands/chat-app/index-commands.ts +475 -0
  22. package/src/commands/knowledge/upload-plan.ts +14 -8
  23. package/src/commands/marking/index-commands.ts +171 -0
  24. package/src/commands/media/index-commands.ts +592 -0
  25. package/src/commands/registry.ts +28 -6
  26. package/src/commands/types.ts +6 -0
  27. package/src/commands/workflow/list.ts +1 -1
  28. package/src/errors.ts +3 -2
  29. package/src/exit.ts +126 -1
  30. package/src/flag-help.ts +24 -2
  31. package/src/forge/touches.ts +2 -0
  32. package/src/harness.ts +5 -3
  33. package/src/main.ts +23 -38
  34. package/src/scopes.ts +43 -0
  35. package/src/template.ts +6 -4
  36. package/src/templates/next-app-files.ts +8 -4
  37. package/src/vendor/sdk-sources.json +16 -15
@@ -31,8 +31,11 @@ import { capabilityCommands } from './capability/index-commands'
31
31
  import { datasetCommands } from './dataset/index-commands'
32
32
  import { sourceCommands } from './source/index-commands'
33
33
  import { knowledgeCommands } from './knowledge/index-commands'
34
+ import { mediaCommands } from './media/index-commands'
34
35
  import { packCommands } from './pack/index-commands'
35
36
  import { secretCommands } from './secret/index-commands'
37
+ import { chatAppCommands } from './chat-app/index-commands'
38
+ import { markingCommands } from './marking/index-commands'
36
39
  import { automationCommands } from './automation/index-commands'
37
40
  import { workflowCommands } from './workflow/index-commands'
38
41
  import { completionCommand } from './completion'
@@ -102,8 +105,10 @@ export const COMMANDS: readonly Command[] = [
102
105
  ...datasetCommands,
103
106
  ...sourceCommands,
104
107
  ...knowledgeCommands,
108
+ ...mediaCommands,
105
109
  ...packCommands,
106
110
  ...secretCommands,
111
+ ...chatAppCommands,
107
112
  ...automationCommands,
108
113
  ...workflowCommands,
109
114
 
@@ -121,6 +126,7 @@ export const COMMANDS: readonly Command[] = [
121
126
  blueprintBind,
122
127
  blueprintEditable,
123
128
  blueprintGrant,
129
+ ...markingCommands,
124
130
 
125
131
  ...actionCommands,
126
132
  ]
@@ -164,9 +170,11 @@ const NOUN_SUMMARY: Readonly<Record<string, string>> = {
164
170
  knowledge: 'Knowledge bases and the sources inside them',
165
171
  pack: 'Reusable skill bundles — author once, install per workspace',
166
172
  secret: 'Workspace secrets — named here, never printed back',
173
+ 'chat-app': 'Chat Apps — stage, publish and open an Agent roster',
167
174
  function: 'Functions — TypeScript deployed here, run on a schedule',
168
175
  workflow: 'Workflows — declared steps, versioned, promoted, then run',
169
176
  blueprint: 'The shared model of the organization — what an app can read',
177
+ marking: 'Security markings — who may see which marked Blueprint rows',
170
178
  action: 'Governed Actions — arm the write path a published Action runs',
171
179
  capability: 'What an agent may DO — plugin operations granted to it',
172
180
  auth: 'Credential profiles — one per API key, selected by directory',
@@ -179,6 +187,11 @@ export function nounSummary(noun: string): string {
179
187
  return NOUN_SUMMARY[noun] ?? ''
180
188
  }
181
189
 
190
+ const KEYS_ARE_SETTINGS_ONLY =
191
+ 'organization keys are managed in Organization settings → API Keys, workspace keys in '
192
+ + 'Workspace settings → API Keys — by a signed-in administrator, never by the CLI: a '
193
+ + 'credential that mints credentials is the escalation that separation exists to prevent'
194
+
182
195
  /**
183
196
  * Where the CLI ends, and what to do instead.
184
197
  *
@@ -202,8 +215,12 @@ export const UNSUPPORTED: Readonly<Record<string, string>> = {
202
215
  channel:
203
216
  'channels have no CLI surface — create one and bind it to an agent in the Console, '
204
217
  + 'under the agent’s Channels tab',
205
- key: 'API keys are minted in the Console, never by the CLI — a credential that mints credentials '
206
- + 'is the escalation that separation exists to prevent',
218
+ key: KEYS_ARE_SETTINGS_ONLY,
219
+ // The names a caller reaches for when the goal is provisioning or rotating an
220
+ // organization key. The service routes are session-only by construction, so
221
+ // there is nothing for a key-holding CLI to call.
222
+ 'org-key': KEYS_ARE_SETTINGS_ONLY,
223
+ 'api-key': KEYS_ARE_SETTINGS_ONLY,
207
224
  sheet: 'sheets have no CLI surface and none is planned',
208
225
  task: 'task and run history is Console-only; `frontera function runs` covers function runs',
209
226
  eval: 'the evaluation suite (datasets, scorers, judges, queues) is Console-only',
@@ -262,7 +279,7 @@ const GLOBAL_SECTION = [
262
279
  ['FRONTERA_API_URL', 'Frontera API origin'],
263
280
  // Upstream's key-kind detail, kept — profiles change where a key is stored,
264
281
  // not which kinds are valid.
265
- ['FRONTERA_TOKEN', 'sk-ws-… or sk-org-… — requires FRONTERA_API_URL or --api-url'],
282
+ ['FRONTERA_TOKEN', 'sk-ws-… or sk-org-… key, or a session token for session-only commands — requires FRONTERA_API_URL or --api-url'],
266
283
  ['FRONTERA_PROFILE', 'profile to use, overriding the directory selection'],
267
284
  ['FRONTERA_SECRET_STORE', 'where keys are stored: keychain (default on macOS) or file'],
268
285
  ['NO_COLOR', 'set to disable colour'],
@@ -370,6 +387,12 @@ export function renderNounHelp(noun: string): string {
370
387
  return lines.join('\n')
371
388
  }
372
389
 
390
+ /** The help line every session-lane command prints — see `SESSION_LANE_HINT`. */
391
+ export const SESSION_LANE_HELP =
392
+ 'Needs a person’s session, not an API key. Set FRONTERA_TOKEN to your session token '
393
+ + 'and FRONTERA_API_URL (or pass --api-url). sk-ws- and sk-org- keys are refused with '
394
+ + '401 or 403; `frontera login` stores keys only.'
395
+
373
396
  export function renderCommandHelp(meta: CommandMeta): string {
374
397
  const width = terminalWidth()
375
398
  const lines = [accent(usage(meta)), '', ...wrap(meta.summary, width)]
@@ -385,8 +408,7 @@ export function renderCommandHelp(meta: CommandMeta): string {
385
408
  lines.push(
386
409
  '',
387
410
  ...wrap(
388
- 'Needs a session credential: run `frontera login`. This command’s endpoints '
389
- + 'refuse sk-ws- and sk-org- keys, and answer 401 rather than a permission error.',
411
+ SESSION_LANE_HELP,
390
412
  width,
391
413
  ).map(dim),
392
414
  )
@@ -456,7 +478,7 @@ export interface CommandDescriptor {
456
478
  requiresCredential: boolean
457
479
  /**
458
480
  * `api-key` — reachable with `sk-ws-`/`sk-org-` or a session.
459
- * `session` — reachable ONLY with a session token from `frontera login`.
481
+ * `session` — reachable ONLY with a person's session token in `FRONTERA_TOKEN`.
460
482
  */
461
483
  authLane: 'api-key' | 'session'
462
484
  }
@@ -68,6 +68,12 @@ export interface CommandMeta {
68
68
  * that forgets to declare this is far more likely to be on it.
69
69
  */
70
70
  authLane?: 'api-key' | 'session'
71
+ /**
72
+ * Session-lane only: where a person does this in the product, e.g.
73
+ * `Blueprint → Access → Markings`. Appended to `SESSION_LANE_HINT` when a key
74
+ * is refused, so a noun names its way out without rewording the refusal.
75
+ */
76
+ sessionAlternative?: string
71
77
  /**
72
78
  * Present → the command is planned but unbuilt, and fails with this message.
73
79
  * Reserved in the table rather than omitted: a caller reading "unknown
@@ -16,7 +16,7 @@ export const workflowList: Command = {
16
16
  verb: 'list',
17
17
  args: [],
18
18
  flags: {},
19
- summary: 'List workflows, their live version and what can start them',
19
+ summary: 'List workflows in service, their live version and what can start them. Archived workflows are left out',
20
20
  examples: ['frontera workflow list'],
21
21
  },
22
22
 
package/src/errors.ts CHANGED
@@ -13,8 +13,9 @@ export class CliError extends Error {
13
13
  readonly code: string
14
14
  readonly hint?: string
15
15
 
16
- constructor(message: string, opts: { code: string; hint?: string }) {
17
- super(message)
16
+ /** `cause` keeps the original error — and its `details` — when one is reworded. */
17
+ constructor(message: string, opts: { code: string; hint?: string; cause?: unknown }) {
18
+ super(message, opts.cause === undefined ? undefined : { cause: opts.cause })
18
19
  this.name = 'CliError'
19
20
  this.code = opts.code
20
21
  if (opts.hint) this.hint = opts.hint
package/src/exit.ts CHANGED
@@ -1,4 +1,6 @@
1
- import { CliError } from './errors'
1
+ import { PERSON_FORBIDDEN_HINT } from './api/credential-failure'
2
+ import { CliError, UsageError } from './errors'
3
+ import { commandLabel, commandScope } from './scopes'
2
4
 
3
5
  /**
4
6
  * Exit codes, one per action the caller can take.
@@ -61,6 +63,11 @@ const AUTH_CODES = new Set([
61
63
  'FORBIDDEN',
62
64
  // The key is gone, not merely unselected — same recovery as a revoked one.
63
65
  'PROFILE_SECRET_MISSING',
66
+ // Policy refusals the service keeps its own code for: separation of duties,
67
+ // and rows behind a marking the caller is not cleared for. Neither ever
68
+ // succeeds on retry, which is what exit 1 would tell a script to do.
69
+ 'ACTION_FORBIDDEN',
70
+ 'DATASET_ROWS_COMPARTMENTED',
64
71
  ])
65
72
 
66
73
  /**
@@ -212,3 +219,121 @@ export function toEnvelope(err: unknown): ErrorEnvelope {
212
219
  message: err instanceof Error ? err.message : String(err),
213
220
  }
214
221
  }
222
+
223
+ /**
224
+ * The one recovery for a key sent to a command that acts as a person.
225
+ *
226
+ * `frontera login` and `auth add` store only `sk-ws-`/`sk-org-` keys, so the
227
+ * session has to come from the environment. Worded once here because every
228
+ * session-lane noun meets the same refusal — 401 on session-only routers, 403
229
+ * on programmatic ones — and per-API copies of this sentence had started to
230
+ * drift apart.
231
+ */
232
+ export const SESSION_LANE_HINT =
233
+ 'this command acts as a person and refuses API keys — set FRONTERA_TOKEN to your session token '
234
+ + 'and FRONTERA_API_URL, then re-run; `frontera login` stores keys only'
235
+
236
+ /** What a session-lane command needs to know about itself to word a refusal. */
237
+ export interface SessionLaneMeta {
238
+ noun: string
239
+ verb: string
240
+ scope?: 'workspace' | 'organization'
241
+ authLane?: 'api-key' | 'session'
242
+ sessionAlternative?: string
243
+ }
244
+
245
+ /** The first line a key reads on a session-lane command. */
246
+ export function sessionLaneKeyMessage(meta: { noun: string; verb: string }): string {
247
+ return `\`${commandLabel(meta)}\` acts as a person; API keys are refused`
248
+ }
249
+
250
+ /**
251
+ * The hint that replaces a failure's own on a session-lane command, or
252
+ * `undefined` to keep it.
253
+ *
254
+ * A key (`sk-…`) refused with 401/403 gets `SESSION_LANE_HINT`, plus where a
255
+ * person does the same thing in the product when the command names one. A
256
+ * person's session refused with 403 is told the person lacks the permission —
257
+ * the service's generic FORBIDDEN hint says "use a different key", which is
258
+ * the wrong fix for someone who holds no key. A session refused with 401 keeps
259
+ * its own hint: it has expired, and re-signing in is the fix. No token at all
260
+ * means the failure came before a credential resolved, so it is about neither.
261
+ */
262
+ export function sessionLaneHint(
263
+ meta: Pick<SessionLaneMeta, 'authLane' | 'sessionAlternative'>,
264
+ token: string,
265
+ code: string,
266
+ ): string | undefined {
267
+ if (meta.authLane !== 'session' || !token) return undefined
268
+ if (!token.startsWith('sk-')) return code === 'FORBIDDEN' ? PERSON_FORBIDDEN_HINT : undefined
269
+ if (code !== 'UNAUTHORIZED' && code !== 'FORBIDDEN') return undefined
270
+ return meta.sessionAlternative
271
+ ? `${SESSION_LANE_HINT}; or do it in ${meta.sessionAlternative}`
272
+ : SESSION_LANE_HINT
273
+ }
274
+
275
+ /**
276
+ * Refused before the request, or `null` to let it go.
277
+ *
278
+ * Any key on a session-lane command, whatever its scope: every such route
279
+ * refuses every key, so nothing is lost by not asking. It also covers a route
280
+ * that answers a key with an empty 200 instead of a refusal, which would read
281
+ * as "you can do nothing here" and exit 0. This trusts `authLane`, so a
282
+ * command is tagged `session` only when EVERY route it calls refuses every
283
+ * key — by mounting session-only auth, or by an authority check no key
284
+ * principal can pass (the governed Action invoke routes join organization
285
+ * and workspace membership). `action deploy` and `action prepare` reach
286
+ * routes a key legitimately uses, and are `api-key`. Then a person's session on a
287
+ * workspace-scoped command that named no workspace, which the service answers
288
+ * with a bare 400. A missing argument outranks the workspace, as it does for
289
+ * the organization-key refusal.
290
+ */
291
+ export function sessionLaneRefusal(opts: {
292
+ meta: SessionLaneMeta
293
+ token: string
294
+ workspace: string | undefined
295
+ missingArg: boolean
296
+ }): CliError | null {
297
+ const { meta, token, workspace, missingArg } = opts
298
+ if (meta.authLane !== 'session') return null
299
+ if (token.startsWith('sk-')) {
300
+ return new CliError(sessionLaneKeyMessage(meta), {
301
+ code: 'FORBIDDEN',
302
+ hint: sessionLaneHint(meta, token, 'FORBIDDEN')!,
303
+ })
304
+ }
305
+ if (missingArg || workspace || commandScope(meta) !== 'workspace') return null
306
+ return new UsageError(
307
+ `\`${commandLabel(meta)}\` acts inside a workspace, and this command names none`,
308
+ 'session commands name their workspace — pass --workspace <id>',
309
+ )
310
+ }
311
+
312
+ /**
313
+ * The failure a session-lane command reports, rewritten, or `null` to report
314
+ * it as it is. The original error rides along as `cause`, with its details.
315
+ *
316
+ * A key gets the key message too, not only the hint: an API client's own
317
+ * message names a revoked key or a missing capability, which contradicts the
318
+ * hint on the line below it. `sessionLaneRefusal` refuses keys before any
319
+ * request, so this branch is a guard for a failure raised on some other path.
320
+ *
321
+ * A person refused with the service's generic FORBIDDEN keeps the service
322
+ * message and gets the person hint, because the generic hint says "use a
323
+ * different key". A `CliError` is left alone: the CLI worded it for this
324
+ * command already — `credentialFailure` names the capability when it knows it.
325
+ */
326
+ export function sessionLaneFailure(
327
+ meta: SessionLaneMeta,
328
+ token: string,
329
+ err: unknown,
330
+ ): CliError | null {
331
+ const failure = toEnvelope(err)
332
+ const hint = sessionLaneHint(meta, token, failure.code)
333
+ if (!hint) return null
334
+ if (token.startsWith('sk-')) {
335
+ return new CliError(sessionLaneKeyMessage(meta), { code: failure.code, hint, cause: err })
336
+ }
337
+ if (err instanceof CliError) return null
338
+ return new CliError(failure.message, { code: failure.code, hint, cause: err })
339
+ }
package/src/flag-help.ts CHANGED
@@ -31,7 +31,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
31
31
  'dry-run': 'derive and print the write path without building anything',
32
32
  'plan-id': 'reuse this mutation plan id instead of minting one, so a retry names the same artifact',
33
33
  'binding-id': 'reuse this Binding id instead of minting one, so a retry names the same artifact',
34
- reason: 'reason recorded on the deployment transition',
34
+ reason: 'reason recorded with the deployment transition, request or approval decision',
35
35
  'no-activate': 'review the write path but leave it switched off',
36
36
  role: 'organization role the capability is held by',
37
37
  add: 'comma-separated property API names to add to the current set',
@@ -87,7 +87,10 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
87
87
  input: "JSON object of the declared inputs — inline ('{\"key\": 1}') or @file.json",
88
88
  // The primary key alone. A subject-bound workflow takes exactly one, so the
89
89
  // flag spends no characters on the `{"pk": …}` the route models it as.
90
- subject: 'primary key of the object a subject-bound workflow runs for',
90
+ subject: 'primary key of the object a subject-bound workflow or Action acts on',
91
+ // Action requests.
92
+ 'subject-version': 'record version of --subject the request expects, so a stale read is refused',
93
+ 'idempotency-key': 'reuse a key to retry a submit and get the same request back; generated if omitted',
91
94
  'no-promote': 'publish the version without moving the live pointer',
92
95
  'skip-build-check': 'deploy even when the build output is older than the source',
93
96
  'no-wait': 'return as soon as the run is queued instead of waiting for it to finish',
@@ -95,6 +98,9 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
95
98
  // between their working copy and what is deployed.
96
99
  dev: 'run the file served by an `automation dev` session instead of a deployed version',
97
100
  draft: 'act on your saved draft rather than a published version',
101
+ // Chat Apps
102
+ accent: 'presentation accent: blue, violet, green, amber, rose, teal or slate',
103
+ validate: 'report what blocks publishing, without publishing',
98
104
 
99
105
  // Agents
100
106
  name: 'display name; defaults to the slug',
@@ -150,6 +156,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
150
156
 
151
157
  // Knowledge
152
158
  description: 'one-line description stored on the resource',
159
+ 'display-name': 'human-readable name shown beside the identifier',
153
160
  'top-k': 'how many chunks to return (default 10)',
154
161
  // A DISTANCE cutoff, not the similarity the output column shows — the query
155
162
  // keeps chunks whose cosine distance is below it, so a HIGHER value is more
@@ -161,6 +168,17 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
161
168
  strategy:
162
169
  'text extraction to use per file: auto (default), text, or ocr; ocr needs an OCR engine on the base',
163
170
 
171
+ // Media Sets
172
+ markings: 'comma-separated security markings added to each uploaded item, on top of the set default',
173
+ ocr: 'extract text with OCR instead of reading the text layer — for scans and images',
174
+ 'skip-embed': 'cut passages but do not embed them; search needs embeddings',
175
+ watch: 'poll until nothing is pending (up to 10 minutes), then print the final state',
176
+ 'skip-existing':
177
+ 'upload only paths the set does not already hold, so a re-run after a partial failure adds no versions. '
178
+ + 'A path held by an item above your clearance is not seen, and is uploaded again',
179
+ 'max-distance': 'maximum cosine distance a passage may have; higher is more permissive',
180
+ permissions: 'comma-separated: read, write, manage, read_sensitive (default read; read is always included)',
181
+
164
182
  // Auth and setup
165
183
  // "key", not "workspace key": `login` verifies an organization key too, and
166
184
  // naming only one kind here was the last place the CLI still implied that a
@@ -196,6 +214,7 @@ const PLACEHOLDER: Readonly<Record<string, string>> = {
196
214
  'expect-revision': 'hash',
197
215
  notes: 'text',
198
216
  description: 'text',
217
+ 'display-name': 'text',
199
218
  strategy: 'auto|text|ocr',
200
219
  from: '-|path',
201
220
  'password-from': '-|path',
@@ -212,6 +231,9 @@ const PLACEHOLDER: Readonly<Record<string, string>> = {
212
231
  'new-capability': 'enabled|disabled',
213
232
  'top-k': 'n',
214
233
  threshold: '0-2',
234
+ markings: 'a,b',
235
+ 'max-distance': '0-2',
236
+ permissions: 'read,write',
215
237
  template: 'non-negative|percentage|status-enum|code-length',
216
238
  }
217
239
 
@@ -167,6 +167,8 @@ export const MCP_READ_TOOLS = new Set([
167
167
  // what the copilot's tool search ranks on, and changing it to suit this
168
168
  // regex would cost reach on the surface people actually use.
169
169
  'workflow_list', 'workflow_inspect', 'workflow_resources', 'workflow_runs',
170
+ // Shows the steps about to be built as placeholders; writes nothing.
171
+ 'workflow_plan',
170
172
  // Runs one step of a draft and changes no part of the definition. Same
171
173
  // call as `test_agent` two lines up, and for the same reason: an authoring
172
174
  // loop tests a step repeatedly, and each one would jerk the pane.
package/src/harness.ts CHANGED
@@ -59,9 +59,11 @@ and it cannot drift, because both are projected from the same table.
59
59
  Two fields decide whether a call can work at all:
60
60
 
61
61
  - \`authLane\` — \`api-key\` reaches the command with \`sk-ws-\`/\`sk-org-\` or a
62
- session. \`session\` reaches it with a session token ONLY: a valid key answers
63
- 401 there, which looks exactly like an expired credential. Run
64
- \`frontera login\` before those.
62
+ session. \`session\` acts as a person and needs a person's session, not an API
63
+ key: set \`FRONTERA_TOKEN\` to the session token and \`FRONTERA_API_URL\` (or
64
+ pass \`--api-url\`). The CLI refuses an \`sk-ws-\`/\`sk-org-\` key on those
65
+ before sending anything; \`frontera login\` stores keys only, so it cannot
66
+ supply the session.
65
67
  - \`available\` — \`false\` means the verb is named but unbuilt, and
66
68
  \`unavailableReason\` says why. Pick another route rather than retrying.
67
69
 
package/src/main.ts CHANGED
@@ -3,11 +3,11 @@ import { parseArgs } from './args'
3
3
  import { resolveCredential } from './config'
4
4
  import { findProjectRoot } from './context'
5
5
  import { CliError, UsageError } from './errors'
6
- import { EXIT } from './exit'
6
+ import { EXIT, sessionLaneFailure, sessionLaneRefusal } from './exit'
7
7
  import { resolveOrganization, setSelectedOrganization } from './organization'
8
8
  import { createOutput, type OutputMode } from './output'
9
9
  import { readProject } from './project'
10
- import { commandScope } from './scopes'
10
+ import { commandScope, unaddressedWorkspaceRefusal } from './scopes'
11
11
  import {
12
12
  aliasesFor,
13
13
  describeCommand,
@@ -142,6 +142,8 @@ async function main(): Promise<number> {
142
142
 
143
143
  const mode: OutputMode = flags.json === true ? 'json' : 'human'
144
144
  const output = createOutput(mode, { quiet: flags.quiet === true })
145
+ // Read by the failure path: which credential was refused decides the hint.
146
+ let token = ''
145
147
 
146
148
  try {
147
149
  // Reserved commands fail BEFORE anything else — no credential is
@@ -208,6 +210,7 @@ async function main(): Promise<number> {
208
210
  },
209
211
  { cwd },
210
212
  )
213
+ token = credential.token
211
214
 
212
215
  const workspaceFlag = typeof flags.workspace === 'string' ? flags.workspace.trim() : undefined
213
216
  const orgFlag = typeof flags.org === 'string' ? flags.org.trim() : undefined
@@ -241,41 +244,21 @@ async function main(): Promise<number> {
241
244
  )
242
245
  }
243
246
 
244
- /**
245
- * An organization key that named no workspace cannot run a
246
- * workspace-scoped command, so it is refused rather than warned.
247
- *
248
- * This was a note, and a note was not enough. The request still went out,
249
- * ran at organization scope, matched nothing, and `agent list` printed
250
- * "No agents in this workspace." and exited 0 — for a request that had
251
- * never looked at a workspace. Every sibling already refused (`app` and
252
- * `pack` with 400, `skill` with 403, `knowledge` and `secret` with advice);
253
- * `agent` was the one that answered a plausible lie with a success code.
254
- *
255
- * The wording is `knowledge`'s, because both routes out are real: an
256
- * organization key names a workspace per invocation, a workspace key
257
- * carries one permanently.
258
- */
259
- if (
260
- !command.meta.offline
261
- && !workspaceFlag
262
- // A missing argument outranks a missing workspace, for the reason the
263
- // project gate above gives: `skill pull` with no skill named has a
264
- // better error waiting inside the command, and it names the command
265
- // that produces the value. Let it be raised.
266
- && !missingArg
267
- && commandScope(command.meta) === 'workspace'
268
- && credential.token.startsWith('sk-org-')
269
- ) {
270
- throw new CliError(
271
- `\`${label}\` acts inside a workspace, `
272
- + 'and this organization key names none',
273
- {
274
- code: 'FORBIDDEN',
275
- hint: 'pass --workspace <id> (see `frontera workspace list`), '
276
- + 'or use a workspace key (sk-ws-…) created for the workspace you mean',
277
- },
278
- )
247
+ // A missing argument outranks a missing workspace, for the reason the
248
+ // project gate above gives: `skill pull` with no skill named has a better
249
+ // error waiting inside the command, and it names the command that
250
+ // produces the value. Let it be raised.
251
+ if (!command.meta.offline) {
252
+ const refusal = sessionLaneRefusal({
253
+ meta: command.meta,
254
+ token: credential.token,
255
+ workspace: workspaceFlag,
256
+ missingArg: missingArg !== undefined,
257
+ })
258
+ ?? (missingArg
259
+ ? null
260
+ : unaddressedWorkspaceRefusal({ meta: command.meta, token: credential.token, workspace: workspaceFlag }))
261
+ if (refusal) throw refusal
279
262
  }
280
263
 
281
264
  /**
@@ -319,7 +302,9 @@ async function main(): Promise<number> {
319
302
  output.render(result.data, () => result.text)
320
303
  return EXIT.OK
321
304
  } catch (err) {
322
- return output.fail(err)
305
+ // A key on a session-lane command: one recovery for every such noun,
306
+ // rather than each API client rewording its own 401/403.
307
+ return output.fail(sessionLaneFailure(command.meta, token, err) ?? err)
323
308
  }
324
309
  }
325
310
 
package/src/scopes.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { CliError } from './errors'
2
+
1
3
  /**
2
4
  * Which grain each noun acts at — stated once.
3
5
  *
@@ -54,3 +56,44 @@ export function commandScope(meta: {
54
56
  export function orgScopedServiceNouns(): string[] {
55
57
  return [...ORG_SCOPED_NOUNS].filter((n) => n !== 'auth' && n !== 'kit').sort()
56
58
  }
59
+
60
+ /**
61
+ * The refusal for an organization key that named no workspace on a
62
+ * workspace-scoped command, or `null` to let the request go.
63
+ *
64
+ * An organization key that named no workspace cannot run a
65
+ * workspace-scoped command, so it is refused rather than warned.
66
+ *
67
+ * This was a note, and a note was not enough. The request still went out,
68
+ * ran at organization scope, matched nothing, and `agent list` printed
69
+ * "No agents in this workspace." and exited 0 — for a request that had
70
+ * never looked at a workspace. Every sibling already refused (`app` and
71
+ * `pack` with 400, `skill` with 403, `knowledge` and `secret` with advice);
72
+ * `agent` was the one that answered a plausible lie with a success code.
73
+ *
74
+ * The wording is `knowledge`'s, because both routes out are real: an
75
+ * organization key names a workspace per invocation, a workspace key
76
+ * carries one permanently.
77
+ */
78
+ export function unaddressedWorkspaceRefusal(opts: {
79
+ meta: { noun: string; verb: string; scope?: 'workspace' | 'organization' }
80
+ token: string
81
+ workspace: string | undefined
82
+ }): CliError | null {
83
+ const { meta, token, workspace } = opts
84
+ if (workspace || commandScope(meta) !== 'workspace' || !token.startsWith('sk-org-')) return null
85
+ return new CliError(
86
+ `\`${commandLabel(meta)}\` acts inside a workspace, `
87
+ + 'and this organization key names none',
88
+ {
89
+ code: 'FORBIDDEN',
90
+ hint: 'pass --workspace <id> (see `frontera workspace list`), '
91
+ + 'or use a workspace key (sk-ws-…) created for the workspace you mean',
92
+ },
93
+ )
94
+ }
95
+
96
+ /** `noun verb`, as a reader typed it. */
97
+ export function commandLabel(meta: { noun: string; verb: string }): string {
98
+ return `${meta.noun}${meta.verb ? ` ${meta.verb}` : ''}`
99
+ }
package/src/template.ts CHANGED
@@ -247,9 +247,10 @@ createFronteraApp(<App />, { providers: [blueprintProvider] })
247
247
  import { useFronteraApp } from '@frontera-sdk/core/create-frontera-app'
248
248
 
249
249
  /**
250
- * The object type this page reads.
250
+ * The object type this page reads. Optional.
251
251
  *
252
- * \`frontera blueprint list\` shows what this workspace exposes; put an API name
252
+ * Left empty, the App builds, deploys and runs without Blueprint data and sends
253
+ * no queries. To read records, \`frontera blueprint list\` shows what this workspace exposes; put an API name
253
254
  * here — \`Shipment\`, \`LoanApplication\`, whatever the deployment models — and
254
255
  * \`frontera blueprint get <apiName>\` lists its properties.
255
256
  *
@@ -275,8 +276,9 @@ export default function App() {
275
276
 
276
277
  {OBJECT_TYPE === '' ? (
277
278
  <div className="rounded-lg border border-border bg-card p-6 text-sm text-muted-foreground">
278
- Set <code className="font-mono text-foreground">OBJECT_TYPE</code> in{' '}
279
- <code className="font-mono text-foreground">src/App.tsx</code> to read live data. Run{' '}
279
+ Your App is running. Blueprint data is optional: to read records, set{' '}
280
+ <code className="font-mono text-foreground">OBJECT_TYPE</code> in{' '}
281
+ <code className="font-mono text-foreground">src/App.tsx</code>. Run{' '}
280
282
  <code className="font-mono text-foreground">frontera blueprint list</code> to see what
281
283
  this workspace exposes.
282
284
  </div>
@@ -349,9 +349,9 @@ export function cn(...inputs: ClassValue[]) {
349
349
  emptyHint: 'Clear the search, or widen the filter.',
350
350
  errorTitle: 'Could not load records',
351
351
  retry: 'Try again',
352
- unconfiguredTitle: 'Point this App at an object type',
352
+ unconfiguredTitle: 'Your App is running',
353
353
  unconfiguredBody:
354
- 'Run \`frontera blueprint list\` to see what this workspace exposes, then set STARTER_OBJECT_TYPE in src/lib/blueprint/starter.ts.',
354
+ 'Blueprint data is optional. To list records here, run \`frontera blueprint list\` and set STARTER_OBJECT_TYPE in src/lib/blueprint/starter.ts — or replace this page with your own.',
355
355
  previous: 'Previous',
356
356
  next: 'Next',
357
357
  range: (from: number, to: number, total: number | null) =>
@@ -361,8 +361,10 @@ export function cn(...inputs: ClassValue[]) {
361
361
  `,
362
362
 
363
363
  'src/lib/blueprint/starter.ts': `/**
364
- * The object type the starter page reads.
364
+ * The object type the starter page reads. Optional.
365
365
  *
366
+ * Left empty, the App builds, deploys and runs without Blueprint data; the
367
+ * starter page shows a welcome panel and sends no queries. To list records,
366
368
  * \`frontera blueprint list\` shows what this workspace exposes; put an API name
367
369
  * here — \`Shipment\`, \`LoanApplication\`, whatever the deployment models — and
368
370
  * \`frontera blueprint get <apiName>\` lists its properties.
@@ -1004,7 +1006,9 @@ frontera app dev
1004
1006
  with real, short-lived Blueprint access. \`bun run dev\` alone starts Next
1005
1007
  without a session — useful for pure layout work, not for data.
1006
1008
 
1007
- ## Point it at your data
1009
+ ## Point it at your data (optional)
1010
+
1011
+ The App runs without Blueprint data. To have the starter page list records:
1008
1012
 
1009
1013
  \`\`\`bash
1010
1014
  frontera blueprint list