@frontera-sdk/cli 1.45.2 → 1.45.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.45.2",
3
+ "version": "1.45.4",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -38,8 +38,8 @@
38
38
  "build:release": "bun run scripts/build-release.ts"
39
39
  },
40
40
  "dependencies": {
41
- "@frontera-sdk/automation": "1.45.0",
42
- "@frontera-sdk/core": "1.45.0",
41
+ "@frontera-sdk/automation": "1.45.2",
42
+ "@frontera-sdk/core": "1.45.2",
43
43
  "gray-matter": "^4.0.3",
44
44
  "yaml": "^2.9.0"
45
45
  },
@@ -38,10 +38,22 @@ export interface AgentDraft {
38
38
  export class PlatformApi {
39
39
  private readonly client: FronteraClient
40
40
 
41
- constructor(apiBaseUrl: string, token: string) {
41
+ /**
42
+ * `workspaceId` is for an ORGANIZATION key, which spans a list of workspaces
43
+ * and is refused a workspace-scoped route until one is named — the service
44
+ * will not widen an unaddressed request to org scope. The SDK turns it into
45
+ * `x-workspace-id`.
46
+ *
47
+ * Not taken from the profile's `workspaceId`: that field is descriptive by
48
+ * design (`profiles.ts`), recorded at verify time and never re-derived, so
49
+ * treating it as authority would send a stale id as fact. This comes from
50
+ * `--workspace`, which the caller states now.
51
+ */
52
+ constructor(apiBaseUrl: string, token: string, workspaceId?: string) {
42
53
  this.client = new FronteraClient({
43
54
  apiBaseUrl,
44
55
  credential: { kind: 'apiKey', key: token },
56
+ ...(workspaceId ? { workspaceId } : {}),
45
57
  })
46
58
  }
47
59
 
@@ -278,7 +290,9 @@ export class PlatformApi {
278
290
  {
279
291
  code: body?.code ?? statusCode(res.status),
280
292
  ...(res.status === 401
281
- ? { hint: 'set FRONTERA_TOKEN to a valid sk-ws- workspace key, or run `frontera login`' }
293
+ ? { hint: 'run `frontera auth current` to see which credential this resolves to a '
294
+ + 'workspace key (sk-ws-…) serves agents, skills, plugins and knowledge; an '
295
+ + 'organization key (sk-org-…) serves Blueprint authoring, datasets and Sources' }
282
296
  : {}),
283
297
  },
284
298
  )
@@ -8,7 +8,7 @@ import { renderComposition, type Lookups } from './compose'
8
8
  import { resolveAgentRef, type AgentRow } from './resolve'
9
9
 
10
10
  function api(ctx: CommandContext): PlatformApi {
11
- return new PlatformApi(ctx.apiUrl, ctx.token)
11
+ return new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
12
12
  }
13
13
 
14
14
  async function target(ctx: CommandContext, verb: string): Promise<{ api: PlatformApi; id: string; ref: string }> {
@@ -510,7 +510,7 @@ const skillAttach: Command = {
510
510
  if (!ref) throw new UsageError('missing <agent>', 'frontera agent list')
511
511
  if (!skillId) throw new UsageError('missing <skill>', 'frontera skill list — then pass an id')
512
512
 
513
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
513
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
514
514
  const result = await api.attachAgentSkill(await resolveAgentRef(api, ref), skillId)
515
515
 
516
516
  return {
@@ -538,7 +538,7 @@ const skillDetach: Command = {
538
538
  if (!ref) throw new UsageError('missing <agent>', 'frontera agent list')
539
539
  if (!skillId) throw new UsageError('missing <skill>', `frontera agent get ${ref} — lists what it loads`)
540
540
 
541
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
541
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
542
542
  const result = await api.detachAgentSkill(await resolveAgentRef(api, ref), skillId)
543
543
 
544
544
  return { data: result, text: `Detached ${skillId} from ${ref}. The skill itself is untouched.` }
@@ -581,7 +581,7 @@ const revert: Command = {
581
581
  throw new UsageError('missing <version>', `frontera agent versions ${ref} — then pass a version id`)
582
582
  }
583
583
 
584
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
584
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
585
585
  const result = await api.revertAgent(await resolveAgentRef(api, ref), versionId, {
586
586
  // The old snapshot can name a knowledge base that has since been
587
587
  // deleted. Refusing by default is right — silently reverting to an agent
@@ -77,7 +77,7 @@ export const appDev: Command = {
77
77
 
78
78
  let broker: ReturnType<typeof createDevSessionBroker> | null = null
79
79
  if (framework === 'next') {
80
- const identity = await new PlatformApi(ctx.apiUrl, ctx.token).whoami()
80
+ const identity = await new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId).whoami()
81
81
  if (!identity.workspaceId || !identity.orgId) {
82
82
  throw new UsageError(
83
83
  'App development requires a workspace-scoped credential',
@@ -29,7 +29,7 @@ export const appList: Command = {
29
29
  },
30
30
 
31
31
  async run(ctx) {
32
- const rows = (await new PlatformApi(ctx.apiUrl, ctx.token).platformApps()) as AppRow[]
32
+ const rows = (await new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId).platformApps()) as AppRow[]
33
33
 
34
34
  return {
35
35
  data: rows,
@@ -52,8 +52,20 @@ export const authCurrent: Command = {
52
52
  profile: selection.profile,
53
53
  profileSource: selection.source,
54
54
  ...(selection.path ? { profileSourcePath: selection.path } : {}),
55
- apiUrl: metadata.apiUrl,
55
+ // The EFFECTIVE origin, not the profile's.
56
+ //
57
+ // Reporting `metadata.apiUrl` while `apiUrlSource` said "environment" was
58
+ // a contradiction in one object: the field named where the value came
59
+ // from and then showed a different value. This command exists to answer
60
+ // "which deployment am I about to write to", so it is the one place that
61
+ // must not be optimistic — an agent reading `acme` and writing to
62
+ // `globex` is the failure the whole profile model is built to prevent.
63
+ apiUrl: suppliedOrigin ?? metadata.apiUrl,
56
64
  apiUrlSource: suppliedOrigin ? (flagString(ctx, 'api-url') ? 'flag' : 'environment') : 'profile',
65
+ // Kept alongside so a mismatch is visible rather than merely absent.
66
+ ...(suppliedOrigin && suppliedOrigin !== metadata.apiUrl
67
+ ? { profileApiUrl: metadata.apiUrl }
68
+ : {}),
57
69
  credentialSource: envToken ? 'environment' : (located?.source ?? null),
58
70
  credentialKind: metadata.credentialKind,
59
71
  workspaceId: metadata.workspaceId,
@@ -76,6 +88,11 @@ export const authCurrent: Command = {
76
88
  if (envToken) {
77
89
  lines.push(' FRONTERA_TOKEN is set and takes precedence over this profile’s stored key')
78
90
  }
91
+ if (suppliedOrigin && suppliedOrigin !== metadata.apiUrl) {
92
+ lines.push(
93
+ ` the origin above is overridden — this profile's own origin is ${metadata.apiUrl}`,
94
+ )
95
+ }
79
96
 
80
97
  return { data, text: lines.join('\n') }
81
98
  },
@@ -26,7 +26,7 @@ export const blueprintGenerateTypes: Command = {
26
26
 
27
27
  async run(ctx) {
28
28
  const project = ctx.project!
29
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
29
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
30
30
  const schema = parseBlueprintSchema(await api.blueprintSchema())
31
31
  const contents = generateBlueprintTypes(schema)
32
32
  const written = writeBlueprintTypesFile(
@@ -83,7 +83,7 @@ export const blueprintGet: Command = {
83
83
  )
84
84
  }
85
85
 
86
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
86
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
87
87
  const schema = parseBlueprintSchema(await api.blueprintSchema())
88
88
  const apiName = resolveApiName(schema.objectTypes, typed)
89
89
  const type = schema.objectTypes.find((objectType) => objectType.apiName === apiName) as BlueprintSchemaObject
@@ -28,7 +28,7 @@ export const blueprintList: Command = {
28
28
  },
29
29
 
30
30
  async run(ctx) {
31
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
31
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
32
32
  const types = parseBlueprintSchema(await api.blueprintSchema()).objectTypes
33
33
 
34
34
  return {
@@ -19,8 +19,8 @@ import { flagString, type Command } from '../types'
19
19
  */
20
20
 
21
21
  /** Constructed per call: the credential is per-invocation, not per-process. */
22
- function api(ctx: { apiUrl: string; token: string }): PlatformApi {
23
- return new PlatformApi(ctx.apiUrl, ctx.token)
22
+ function api(ctx: { apiUrl: string; token: string; workspaceId?: string }): PlatformApi {
23
+ return new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
24
24
  }
25
25
 
26
26
  /** `status:open` and `amount:>:1000` — the two forms worth typing by hand. */
@@ -74,7 +74,7 @@ const list: Command = {
74
74
  const ref = ctx.positional[0]
75
75
  if (!ref) throw new UsageError('missing <agent>', 'frontera agent list — then pass an id or slug')
76
76
 
77
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
77
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
78
78
  const agentId = await resolveAgentRef(api, ref)
79
79
  const rows = (await api.agentCapabilities(agentId)) as CapabilityRow[]
80
80
 
@@ -119,7 +119,7 @@ const available: Command = {
119
119
  const installId = ctx.positional[0]
120
120
  if (!installId) throw new UsageError('missing <plugin>', 'frontera plugin list — then pass an install id')
121
121
 
122
- const rows = (await new PlatformApi(ctx.apiUrl, ctx.token)
122
+ const rows = (await new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
123
123
  .pluginCapabilities(installId)) as CapabilityRow[]
124
124
 
125
125
  return {
@@ -158,7 +158,7 @@ const grant: Command = {
158
158
  )
159
159
  }
160
160
 
161
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
161
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
162
162
  const agentId = await resolveAgentRef(api, ref)
163
163
  const result = await api.grantAgentCapability(agentId, capabilityId)
164
164
 
@@ -196,7 +196,7 @@ const revoke: Command = {
196
196
  throw new UsageError('missing <capability>', `frontera capability list ${ref}`)
197
197
  }
198
198
 
199
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
199
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
200
200
  const agentId = await resolveAgentRef(api, ref)
201
201
  const result = await api.revokeAgentCapability(agentId, capabilityId)
202
202
 
@@ -223,7 +223,7 @@ const installs: Command = {
223
223
  const ref = ctx.positional[0]
224
224
  if (!ref) throw new UsageError('missing <agent>', 'frontera agent list — then pass an id or slug')
225
225
 
226
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
226
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
227
227
  const rows = (await api.agentInstalls(await resolveAgentRef(api, ref))) as Array<{
228
228
  install?: { installName?: string }
229
229
  catalog?: { name?: string; kind?: string }
@@ -281,7 +281,7 @@ const bind: Command = {
281
281
  // switching later does not migrate anything already connected.
282
282
  const accountMode = ctx.flags['end-user'] === true ? 'end_user' : 'agent_owned'
283
283
 
284
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
284
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
285
285
  const agentId = await resolveAgentRef(api, ref)
286
286
  const result = await api.bindAgentInstall(agentId, installId, accountMode)
287
287
 
@@ -312,7 +312,7 @@ const unbind: Command = {
312
312
  if (!ref) throw new UsageError('missing <agent>', 'frontera agent list — then pass an id or slug')
313
313
  if (!installId) throw new UsageError('missing <plugin>', `frontera capability installs ${ref}`)
314
314
 
315
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
315
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
316
316
  const agentId = await resolveAgentRef(api, ref)
317
317
  const result = await api.unbindAgentInstall(agentId, installId).catch((e: unknown) => {
318
318
  throw new CliError((e as Error).message, {
@@ -51,7 +51,7 @@ const list: Command = {
51
51
  examples: ['frontera knowledge list', 'frontera knowledge list --json'],
52
52
  },
53
53
  async run(ctx) {
54
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
54
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
55
55
  // The route takes the workspace explicitly, and a key holder cannot see
56
56
  // its own workspace id from anywhere else — hence /v1/whoami.
57
57
  const workspaceId = await requireWorkspaceId(api)
@@ -86,7 +86,7 @@ const sources: Command = {
86
86
  const ref = ctx.positional[0]
87
87
  if (!ref) throw new UsageError('missing <base>', 'frontera knowledge list — then pass a name or id')
88
88
 
89
- const client = new PlatformApi(ctx.apiUrl, ctx.token)
89
+ const client = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
90
90
  const id = await resolveKnowledgeRef(client, ref)
91
91
  const rows = (await client.knowledgeSources(id)) as Array<Record<string, unknown>>
92
92
 
@@ -124,7 +124,7 @@ const get: Command = {
124
124
  const ref = ctx.positional[0]
125
125
  if (!ref) throw new UsageError('missing <base>', 'frontera knowledge list — then pass a name or id')
126
126
 
127
- const client = new PlatformApi(ctx.apiUrl, ctx.token)
127
+ const client = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
128
128
  const base = await client.knowledgeBase(await resolveKnowledgeRef(client, ref))
129
129
 
130
130
  return {
@@ -188,7 +188,7 @@ const search: Command = {
188
188
  throw new UsageError(`--threshold must be a number, not "${thresholdRaw}"`, 'try --threshold 0.7')
189
189
  }
190
190
 
191
- const client = new PlatformApi(ctx.apiUrl, ctx.token)
191
+ const client = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
192
192
  const baseId = await resolveKnowledgeRef(client, ref)
193
193
  const result = await client.knowledgeSearch(baseId, {
194
194
  query,
@@ -258,7 +258,7 @@ const retry: Command = {
258
258
  throw new UsageError('missing <source>', `frontera knowledge sources ${ref} — the id column`)
259
259
  }
260
260
 
261
- const client = new PlatformApi(ctx.apiUrl, ctx.token)
261
+ const client = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
262
262
  const baseId = await resolveKnowledgeRef(client, ref)
263
263
  const result = await client.retryKnowledgeSource(baseId, sourceId)
264
264
 
@@ -291,7 +291,7 @@ const create: Command = {
291
291
  throw new UsageError('missing <name>', 'frontera knowledge create <name> [--description <text>]')
292
292
  }
293
293
 
294
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
294
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
295
295
  const workspaceId = await requireWorkspaceId(api)
296
296
 
297
297
  // Checked here rather than left to the unique constraint: the service
@@ -379,7 +379,7 @@ const upload: Command = {
379
379
  })
380
380
  }
381
381
 
382
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
382
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
383
383
  const baseId = await resolveKnowledgeRef(api, ref)
384
384
  const outcomes = await uploadAll(api, baseId, plan.files, strategy)
385
385
  const summary = summarizeUploads(ref, baseId, outcomes, plan)
@@ -415,7 +415,7 @@ const attach: Command = {
415
415
  if (!baseRef) throw new UsageError('missing <base>', 'frontera knowledge list — then pass a name or id')
416
416
  if (!agentRef) throw new UsageError('missing <agent>', 'frontera agent list — then pass a slug or id')
417
417
 
418
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
418
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
419
419
  const baseId = await resolveKnowledgeRef(api, baseRef)
420
420
  const agentId = await resolveAgentRef(api, agentRef)
421
421
  await api.attachKnowledgeAgent(baseId, agentId)
@@ -450,7 +450,7 @@ const detach: Command = {
450
450
  if (!baseRef) throw new UsageError('missing <base>', 'frontera knowledge list — then pass a name or id')
451
451
  if (!agentRef) throw new UsageError('missing <agent>', 'frontera agent list — then pass a slug or id')
452
452
 
453
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
453
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
454
454
  const baseId = await resolveKnowledgeRef(api, baseRef)
455
455
  const agentId = await resolveAgentRef(api, agentRef)
456
456
  await api.detachKnowledgeAgent(baseId, agentId)
@@ -477,7 +477,7 @@ const agents: Command = {
477
477
  const ref = ctx.positional[0]
478
478
  if (!ref) throw new UsageError('missing <base>', 'frontera knowledge list — then pass a name or id')
479
479
 
480
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
480
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
481
481
  const baseId = await resolveKnowledgeRef(api, ref)
482
482
  const rows = (await api.knowledgeAttachments(baseId)) as Array<Record<string, unknown>>
483
483
 
@@ -65,7 +65,7 @@ const list: Command = {
65
65
  examples: ['frontera pack list', 'frontera pack list --json'],
66
66
  },
67
67
  async run(ctx) {
68
- const rows = (await new PlatformApi(ctx.apiUrl, ctx.token).packs()) as PackListEntry[]
68
+ const rows = (await new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId).packs()) as PackListEntry[]
69
69
 
70
70
  return {
71
71
  data: rows,
@@ -101,7 +101,7 @@ const get: Command = {
101
101
  const ref = ctx.positional[0]
102
102
  if (!ref) throw new UsageError('missing <pack>', 'frontera pack list — then pass a name or id')
103
103
 
104
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
104
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
105
105
  const detail = (await api.pack(await resolvePackRef(api, ref))) as {
106
106
  pack?: PackRow
107
107
  install?: unknown
@@ -177,7 +177,7 @@ const push: Command = {
177
177
  throw new UsageError('the manifest has no `name`', 'a pack is addressed by name everywhere else')
178
178
  }
179
179
 
180
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
180
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
181
181
  const rows = (await api.packs()) as PackListEntry[]
182
182
  const existing = rows.find(
183
183
  (r) => (r.pack?.name ?? '').toLowerCase() === manifest.name!.toLowerCase(),
@@ -222,7 +222,7 @@ const install: Command = {
222
222
  const ref = ctx.positional[0]
223
223
  if (!ref) throw new UsageError('missing <pack>', 'frontera pack list — then pass a name or id')
224
224
 
225
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
225
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
226
226
  const id = await resolvePackRef(api, ref)
227
227
  const result = (await api.installPack(id)) as { skills?: unknown[] }
228
228
 
@@ -252,7 +252,7 @@ const uninstall: Command = {
252
252
  const ref = ctx.positional[0]
253
253
  if (!ref) throw new UsageError('missing <pack>', 'frontera pack list — then pass a name or id')
254
254
 
255
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
255
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
256
256
  const id = await resolvePackRef(api, ref)
257
257
  const removeApps = flagBool(ctx, 'remove-apps')
258
258
  const result = await api.uninstallPack(id, removeApps)
@@ -285,7 +285,7 @@ const apply: Command = {
285
285
  if (!packRef) throw new UsageError('missing <pack>', 'frontera pack list — then pass a name or id')
286
286
  if (!agentRef) throw new UsageError('missing <agent>', 'frontera agent list — then pass a slug or id')
287
287
 
288
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
288
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
289
289
  const packId = await resolvePackRef(api, packRef)
290
290
  const agentId = await resolveAgentRef(api, agentRef)
291
291
  const result = await api.applyPackToAgent(packId, agentId)
@@ -323,7 +323,7 @@ const detach: Command = {
323
323
  if (!packRef) throw new UsageError('missing <pack>', 'frontera pack list — then pass a name or id')
324
324
  if (!agentRef) throw new UsageError('missing <agent>', 'frontera agent list — then pass a slug or id')
325
325
 
326
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
326
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
327
327
  const packId = await resolvePackRef(api, packRef)
328
328
  const agentId = await resolveAgentRef(api, agentRef)
329
329
  await api.removePackFromAgent(packId, agentId)
@@ -348,7 +348,7 @@ const remove: Command = {
348
348
  const ref = ctx.positional[0]
349
349
  if (!ref) throw new UsageError('missing <pack>', 'frontera pack list — then pass a name or id')
350
350
 
351
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
351
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
352
352
  const id = await resolvePackRef(api, ref)
353
353
  await api.deletePack(id)
354
354
 
@@ -34,7 +34,7 @@ const list: Command = {
34
34
  examples: ['frontera plugin list', 'frontera plugin list --json'],
35
35
  },
36
36
  async run(ctx) {
37
- const rows = (await new PlatformApi(ctx.apiUrl, ctx.token).pluginInstalls()) as InstallRow[]
37
+ const rows = (await new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId).pluginInstalls()) as InstallRow[]
38
38
  return {
39
39
  data: rows,
40
40
  text:
@@ -63,7 +63,7 @@ const catalog: Command = {
63
63
  examples: ['frontera plugin catalog'],
64
64
  },
65
65
  async run(ctx) {
66
- const rows = (await new PlatformApi(ctx.apiUrl, ctx.token).pluginCatalog()) as Array<{
66
+ const rows = (await new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId).pluginCatalog()) as Array<{
67
67
  kind?: string
68
68
  displayName?: string
69
69
  authType?: string
@@ -94,7 +94,7 @@ const get: Command = {
94
94
  const installId = ctx.positional[0]
95
95
  if (!installId) throw new UsageError('missing <plugin>', 'frontera plugin list')
96
96
 
97
- const install = await new PlatformApi(ctx.apiUrl, ctx.token).plugin(installId)
97
+ const install = await new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId).plugin(installId)
98
98
  return {
99
99
  data: install,
100
100
  text: table(
@@ -143,7 +143,7 @@ const add: Command = {
143
143
  }
144
144
  }
145
145
 
146
- const result = await new PlatformApi(ctx.apiUrl, ctx.token).createPluginInstall({
146
+ const result = await new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId).createPluginInstall({
147
147
  kind,
148
148
  ...(flagString(ctx, 'name') ? { installName: flagString(ctx, 'name')! } : {}),
149
149
  ...(config ? { config } : {}),
@@ -205,7 +205,7 @@ const configure: Command = {
205
205
  }
206
206
  }
207
207
 
208
- const result = await new PlatformApi(ctx.apiUrl, ctx.token).updatePluginInstall(installId, {
208
+ const result = await new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId).updatePluginInstall(installId, {
209
209
  ...(name ? { installName: name } : {}),
210
210
  ...(config ? { config } : {}),
211
211
  })
@@ -270,7 +270,7 @@ const policy: Command = {
270
270
  throw new UsageError('nothing to change', 'pass --enable, --disable, --actions or --new-capability')
271
271
  }
272
272
 
273
- const result = await new PlatformApi(ctx.apiUrl, ctx.token).patchPluginInstall(installId, {
273
+ const result = await new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId).patchPluginInstall(installId, {
274
274
  ...(enable ? { enabled: true } : {}),
275
275
  ...(disable ? { enabled: false } : {}),
276
276
  ...(actions ? { actionPolicy: actions as 'allow_all' | 'read_only' | 'custom' } : {}),
@@ -297,7 +297,7 @@ const verify: Command = {
297
297
  // A failed check still answers 200 — the verdict is in the body, the same
298
298
  // shape `dataset test-source` established. Exiting non-zero here would
299
299
  // make an unreachable plugin indistinguishable from an unreachable API.
300
- const result = await new PlatformApi(ctx.apiUrl, ctx.token).verifyPluginInstall(installId)
300
+ const result = await new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId).verifyPluginInstall(installId)
301
301
 
302
302
  return {
303
303
  data: result,
@@ -326,7 +326,7 @@ const remove: Command = {
326
326
  const installId = ctx.positional[0]
327
327
  if (!installId) throw new UsageError('missing <plugin>', 'frontera plugin list')
328
328
 
329
- const result = await new PlatformApi(ctx.apiUrl, ctx.token).deletePluginInstall(installId)
329
+ const result = await new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId).deletePluginInstall(installId)
330
330
  return {
331
331
  data: result,
332
332
  text: `Uninstalled ${installId}.\n`
@@ -40,6 +40,7 @@ import { loginCommand } from './login'
40
40
  import { setupCommand } from './setup'
41
41
  import { authCommands } from './auth/index-commands'
42
42
  import { kitCommands } from './kit/index-commands'
43
+ import { workspaceCommands } from './workspace/index-commands'
43
44
 
44
45
  /**
45
46
  * Flags every command accepts. Declared once so a caller never has to wonder
@@ -55,6 +56,10 @@ export const GLOBAL_FLAGS: FlagSpec = {
55
56
  // auth ones — a one-command escape hatch is useless if it exists on six
56
57
  // commands and the seventh is the one being diagnosed.
57
58
  profile: 'string',
59
+ // Organization keys only. A workspace key already names its workspace; an
60
+ // organization key spans a list, and the service refuses to guess — leaving
61
+ // an unaddressed request at org scope is WIDER than what was asked for.
62
+ workspace: 'string',
58
63
  }
59
64
 
60
65
  /** Additionally accepted by any command that resolves a project. */
@@ -68,6 +73,7 @@ export const COMMANDS: readonly Command[] = [
68
73
 
69
74
  ...authCommands,
70
75
  ...kitCommands,
76
+ ...workspaceCommands,
71
77
 
72
78
  appInit,
73
79
  appList,
@@ -142,6 +148,7 @@ const NOUN_SUMMARY: Readonly<Record<string, string>> = {
142
148
  capability: 'What an agent may DO — plugin operations granted to it',
143
149
  auth: 'Credential profiles — one per API key, selected by directory',
144
150
  kit: 'Optional project-local Frontera skills for Codex and Claude Code',
151
+ workspace: 'Workspaces here — the ids --workspace takes',
145
152
  }
146
153
 
147
154
  export function nounSummary(noun: string): string {
@@ -171,9 +178,6 @@ export const UNSUPPORTED: Readonly<Record<string, string>> = {
171
178
  channel:
172
179
  'channels have no CLI surface — create one and bind it to an agent in the Console, '
173
180
  + '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
181
  key: 'API keys are minted in the Console, never by the CLI — a credential that mints credentials '
178
182
  + 'is the escalation that separation exists to prevent',
179
183
  sheet: 'sheets have no CLI surface and none is planned',
@@ -51,7 +51,7 @@ const list: Command = {
51
51
  examples: ['frontera secret list', 'frontera secret list --json'],
52
52
  },
53
53
  async run(ctx) {
54
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
54
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
55
55
  const rows = (await api.workspaceSecrets(await requireWorkspaceId(api))) as SecretRow[]
56
56
 
57
57
  return {
@@ -129,7 +129,7 @@ const set: Command = {
129
129
  })
130
130
  }
131
131
 
132
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
132
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
133
133
  const workspaceId = await requireWorkspaceId(api)
134
134
  const description = flagString(ctx, 'description')
135
135
 
@@ -179,7 +179,7 @@ const remove: Command = {
179
179
  const name = ctx.positional[0]
180
180
  if (!name) throw new UsageError('missing <name>', 'frontera secret list — then pass a name')
181
181
 
182
- const api = new PlatformApi(ctx.apiUrl, ctx.token)
182
+ const api = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
183
183
  await api.deleteWorkspaceSecret(await requireWorkspaceId(api), name)
184
184
 
185
185
  // No `--force`. The service refuses while anything still resolves the
@@ -125,7 +125,7 @@ const pull: Command = {
125
125
  const ref = ctx.positional[0]
126
126
  if (!ref) throw new UsageError('missing <skill>', 'frontera skill pull <skill> [--dir <path>]')
127
127
 
128
- const client = new PlatformApi(ctx.apiUrl, ctx.token)
128
+ const client = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
129
129
  const id = await resolveSkillRef(client, ref)
130
130
  const doc = (await client.workspaceSkill(id)) as SkillDoc
131
131
  if (!doc.name) throw new CliError('skill has no name', { code: 'INTERNAL_ERROR', hint: 'run `frontera skill list` and pull by id' })
@@ -320,8 +320,8 @@ const push: Command = {
320
320
  }
321
321
 
322
322
  // One PlatformApi per run is enough; a tiny helper keeps push readable.
323
- function client(ctx: { apiUrl: string; token: string }): PlatformApi {
324
- return new PlatformApi(ctx.apiUrl, ctx.token)
323
+ function client(ctx: { apiUrl: string; token: string; workspaceId?: string }): PlatformApi {
324
+ return new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
325
325
  }
326
326
 
327
327
  export const skillBundleCommands: Command[] = [pull, push]
@@ -29,7 +29,7 @@ const list: Command = {
29
29
  examples: ['frontera skill list', 'frontera skill list --json'],
30
30
  },
31
31
  async run(ctx) {
32
- const rows = (await new PlatformApi(ctx.apiUrl, ctx.token).workspaceSkills()) as SkillRow[]
32
+ const rows = (await new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId).workspaceSkills()) as SkillRow[]
33
33
  return {
34
34
  data: rows,
35
35
  text:
@@ -59,7 +59,7 @@ const get: Command = {
59
59
  const ref = ctx.positional[0]
60
60
  if (!ref) throw new UsageError('missing <skill>', 'frontera skill list — then pass a name or id')
61
61
 
62
- const client = new PlatformApi(ctx.apiUrl, ctx.token)
62
+ const client = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
63
63
  // Name or id, like every other noun. `skill list` leads with the id but a
64
64
  // reader reaches for the name, and an id-only argument made this the one
65
65
  // command that refused the obvious input.
@@ -118,7 +118,7 @@ const remove: Command = {
118
118
  const ref = ctx.positional[0]
119
119
  if (!ref) throw new UsageError('missing <skill>', 'frontera skill list — then pass a name or id')
120
120
 
121
- const client = new PlatformApi(ctx.apiUrl, ctx.token)
121
+ const client = new PlatformApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
122
122
  const id = await resolveSkillRef(client, ref)
123
123
  await client.deleteWorkspaceSkill(id)
124
124
 
@@ -63,6 +63,11 @@ export interface CommandContext {
63
63
  output: Output
64
64
  /** Present when `meta.needsProject`. */
65
65
  project: AppProject | null
66
+ /**
67
+ * `--workspace`, for an organization key that must name which workspace it is
68
+ * acting in. Undefined for a workspace key, which carries its own.
69
+ */
70
+ workspaceId?: string
66
71
  }
67
72
 
68
73
  /**
@@ -0,0 +1,76 @@
1
+ import { BlueprintAuthoringApi } from '../../api/blueprint-authoring-api'
2
+ import { table } from '../../table'
3
+ import type { Command } from '../types'
4
+
5
+ /**
6
+ * Workspaces, read-only.
7
+ *
8
+ * This noun exists for one reason: `--workspace <id>` is how an organization
9
+ * key names where it is acting, and nothing in the CLI could tell you what to
10
+ * put there. The id had to come from the Console or a database, which made a
11
+ * flag that exists to unblock organization keys unusable without leaving the
12
+ * terminal.
13
+ *
14
+ * The read is organization-scoped (`organization: ['update']`), which is what
15
+ * an organization key carries — so the credential that needs the answer is the
16
+ * one that can ask. A workspace key already knows its own workspace and does
17
+ * not need this.
18
+ *
19
+ * Managing workspaces stays out. Creating, deleting and membership are
20
+ * Console-only, and the verbs below are declared rather than omitted so that
21
+ * reaching for one says why instead of "unknown verb".
22
+ */
23
+
24
+ const list: Command = {
25
+ meta: {
26
+ noun: 'workspace',
27
+ verb: 'list',
28
+ args: [],
29
+ flags: {},
30
+ summary: 'List workspaces in this organization, with the ids --workspace takes',
31
+ examples: ['frontera workspace list', 'frontera workspace list --json'],
32
+ },
33
+ async run(ctx) {
34
+ const rows = await new BlueprintAuthoringApi(ctx.apiUrl, ctx.token).orgWorkspaces()
35
+
36
+ return {
37
+ data: rows,
38
+ text: rows.length === 0
39
+ ? 'No workspaces visible to this credential.\n'
40
+ + ' This read needs an organization key (sk-org-…) carrying organization:update.'
41
+ : // ID first: it is the value `--workspace` takes, and the reason to
42
+ // run this at all. Name and slug are for recognising which is which.
43
+ table(
44
+ ['id', 'name', 'slug'],
45
+ rows.map((w) => [w.id ?? '?', w.name ?? '', w.slug ?? '']),
46
+ [undefined, 34, undefined],
47
+ ),
48
+ }
49
+ },
50
+ }
51
+
52
+ const RESERVED = 'the CLI reads workspaces, it does not manage them — create, delete and '
53
+ + 'membership are Console-only'
54
+
55
+ const reserved: Command[] = (
56
+ [
57
+ ['create', 'Create a workspace'],
58
+ ['delete', 'Delete a workspace'],
59
+ ['members', 'List or change who belongs to a workspace'],
60
+ ] as const
61
+ ).map(([verb, summary]) => ({
62
+ meta: {
63
+ noun: 'workspace',
64
+ verb,
65
+ args: [],
66
+ flags: {},
67
+ summary,
68
+ examples: [`frontera workspace ${verb}`],
69
+ reserved: RESERVED,
70
+ },
71
+ async run(): Promise<never> {
72
+ throw new Error(RESERVED)
73
+ },
74
+ }))
75
+
76
+ export const workspaceCommands: Command[] = [list, ...reserved]
package/src/exit.ts CHANGED
@@ -44,6 +44,10 @@ const USAGE_CODES = new Set([
44
44
  'PROJECT_CONTEXT_NOT_FOUND',
45
45
  'PROJECT_CONTEXT_CORRUPT',
46
46
  'PROJECT_CONTEXT_UNTRUSTED',
47
+ // The directory cannot be written to. Fixed by binding somewhere else or by
48
+ // not binding at all — never by retrying, which is why it sits with USAGE
49
+ // rather than FAILURE.
50
+ 'PROJECT_CONTEXT_UNWRITABLE',
47
51
  // Authoring kit. Both are resolved by running a named command, not by retry.
48
52
  'KIT_VERSION_MISMATCH',
49
53
  'GENERATED_FILE_CONFLICT',
@@ -82,7 +86,12 @@ const SERVICE_HINTS: Record<string, string> = {
82
86
  // which case they are in throw a CliError with a specific hint instead.
83
87
  CONFLICT: 'the message above names what conflicted — resolve that, then retry',
84
88
  VALIDATION_STALE: 're-fetch the resource and re-run validation against the current revision',
85
- UNAUTHORIZED: 'set FRONTERA_TOKEN to a valid sk-ws- workspace key',
89
+ // Names neither kind. Both are valid here — a workspace key for agents,
90
+ // skills, plugins and knowledge; an organization key for Blueprint authoring,
91
+ // datasets and Sources — and naming only one sent holders of the other
92
+ // looking for a credential they already had.
93
+ UNAUTHORIZED: 'run `frontera auth current` to see which profile this resolves to, '
94
+ + 'then `frontera auth verify` — or set FRONTERA_TOKEN',
86
95
  FORBIDDEN: 'this credential lacks permission for that resource — ask an admin, or use a different key',
87
96
  NOT_FOUND: 'list the resource first to confirm the id',
88
97
  RATE_LIMITED: 'wait and retry',
package/src/flag-help.ts CHANGED
@@ -43,6 +43,9 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
43
43
  help: 'show this help',
44
44
  'api-url': "API origin to use — must match the selected profile's origin",
45
45
  profile: 'credential profile to use for this command, overriding the directory selection',
46
+ workspace:
47
+ "workspace to act in, for an organization key (sk-org-…) — it must be on that key's own "
48
+ + 'list. A workspace key already names one and ignores this',
46
49
  output: 'project-relative .ts path for generated output',
47
50
  check: 'verify generated output is current without writing it',
48
51
 
@@ -152,6 +155,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
152
155
  */
153
156
  const PLACEHOLDER: Readonly<Record<string, string>> = {
154
157
  'api-url': 'origin',
158
+ workspace: 'id',
155
159
  profile: 'name',
156
160
  output: 'path',
157
161
  dataset: 'name',
package/src/main.ts CHANGED
@@ -184,6 +184,61 @@ async function main(): Promise<number> {
184
184
  { cwd },
185
185
  )
186
186
 
187
+ const workspaceFlag = typeof flags.workspace === 'string' ? flags.workspace.trim() : undefined
188
+
189
+ /**
190
+ * Nouns whose commands build a `PlatformApi`, which is the only client that
191
+ * carries `x-workspace-id`.
192
+ *
193
+ * `--workspace` is global — same reasoning as `--profile`: an escape hatch
194
+ * that exists on six commands and not the seventh is useless when the
195
+ * seventh is the one being diagnosed. But global must not mean "silently
196
+ * ignored where it cannot work": passing it to `dataset rows` sent nothing
197
+ * and reported nothing, which is the plausible-wrong-answer shape this CLI
198
+ * refuses everywhere else.
199
+ *
200
+ * Listed rather than derived because the mapping is a fact about which
201
+ * client a command constructs, and a wrong entry should be a failing test
202
+ * rather than a silent no-op.
203
+ */
204
+ const WORKSPACE_ADDRESSABLE = new Set([
205
+ 'agent', 'app', 'blueprint', 'capability', 'knowledge',
206
+ 'pack', 'plugin', 'secret', 'skill',
207
+ ])
208
+
209
+ if (workspaceFlag && !WORKSPACE_ADDRESSABLE.has(command.meta.noun)) {
210
+ throw new UsageError(
211
+ `--workspace does nothing on \`${command.meta.noun}\``,
212
+ 'these commands are organization-scoped, or need no credential at all. '
213
+ + '--workspace applies to: ' + [...WORKSPACE_ADDRESSABLE].sort().join(', '),
214
+ )
215
+ }
216
+
217
+ // An organization key with no workspace named runs at ORG scope, and a
218
+ // workspace-scoped read then answers with an empty list rather than a
219
+ // refusal — `agent list` said "No agents in this workspace" for a workspace
220
+ // holding thirty. A plausible wrong answer is worse than an error, so say
221
+ // so before the command runs.
222
+ //
223
+ // Exempt where org scope is the point: those nouns are correct as-is, and
224
+ // `workspace list` is the FIRST thing such a caller runs to find an id.
225
+ // Listed rather than inferred — there is no scope field on a command, and
226
+ // adding one to silence a note would be the wrong trade.
227
+ const ORG_SCOPED_NOUNS = new Set(['blueprint', 'dataset', 'source', 'workspace', 'auth', 'kit'])
228
+ if (
229
+ !command.meta.offline
230
+ && !workspaceFlag
231
+ && !ORG_SCOPED_NOUNS.has(command.meta.noun)
232
+ && credential.token.startsWith('sk-org-')
233
+ ) {
234
+ output.note(
235
+ 'note: organization key with no --workspace. Organization-scoped commands '
236
+ + '(blueprint authoring, dataset, source) work as-is; workspace-scoped ones '
237
+ + '(agent, skill, plugin, capability, knowledge, pack, secret) will read nothing. '
238
+ + '`frontera workspace list` shows the ids.',
239
+ )
240
+ }
241
+
187
242
  const result = await command.run({
188
243
  cwd,
189
244
  apiUrl: credential.apiUrl,
@@ -192,6 +247,7 @@ async function main(): Promise<number> {
192
247
  flags,
193
248
  output,
194
249
  project,
250
+ ...(workspaceFlag ? { workspaceId: workspaceFlag } : {}),
195
251
  })
196
252
 
197
253
  output.render(result.data, () => result.text)
@@ -139,7 +139,28 @@ export function resolveBindingRoot(
139
139
  export function writeContext(root: string, profile: string, env: Env = process.env): string {
140
140
  const path = contextPath(root)
141
141
  const body: ProjectContext = { schemaVersion: 1, profile }
142
- writeFileAtomic(path, `${JSON.stringify(body, null, 2)}\n`, 0o600)
142
+ try {
143
+ writeFileAtomic(path, `${JSON.stringify(body, null, 2)}\n`, 0o600)
144
+ } catch (err) {
145
+ // A directory nobody owns is an ordinary place to be standing — `/Users` on
146
+ // macOS, `/` in a container, a read-only mount in CI. The raw ENOENT/EACCES
147
+ // escaped as `error [EACCES]: EACCES: permission denied, mkdir …`: a code
148
+ // outside the CLI's own set, no hint, and mapped to exit 1, which tells a
149
+ // caller to retry something no retry can fix.
150
+ //
151
+ // Named alternatives rather than just the refusal, because binding is not
152
+ // the only way to select a profile — and in a directory like this it is the
153
+ // wrong one anyway.
154
+ const code = (err as NodeJS.ErrnoException).code
155
+ if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS' || code === 'ENOSPC') {
156
+ throw new CliError(`cannot write a profile binding into ${root}`, {
157
+ code: 'PROJECT_CONTEXT_UNWRITABLE',
158
+ hint: 'cd to a directory you own and run it there, or bind a different one with '
159
+ + '`--dir <path>` — or skip binding entirely with `--profile <name>` or FRONTERA_PROFILE',
160
+ })
161
+ }
162
+ throw err
163
+ }
143
164
  recordBinding(root, profile, env)
144
165
  return path
145
166
  }