@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
package/README.md CHANGED
@@ -97,8 +97,14 @@ project) and `frontera app init` (scaffold a new app) are different commands.
97
97
  | `frontera app save` | package the working tree to storage |
98
98
  | `frontera app deploy` | build output → an immutable version |
99
99
  | `frontera app list` / `versions` | what exists, what is live |
100
+ | `frontera chat-app list` / `get` | Chat Apps, and one's live version, roster, audience and endpoints |
101
+ | `frontera chat-app draft` / `publish` | stage a configuration file, then publish it live |
102
+ | `frontera chat-app access` / `endpoint` | who may open it, and where it is reachable |
100
103
  | `frontera blueprint list` / `get` | what data an app can read |
101
104
  | `frontera blueprint generate-types` | generate committed App-local types from that data contract |
105
+ | `frontera marking list` / `grants` / `create` / `grant` / `revoke` | security markings — needs a person's session, never an API key; or the platform, in Blueprint → Access → Markings |
106
+ | `frontera media create` / `grant` / `upload` | fill a Media Set from local files (session only) |
107
+ | `frontera media process` / `status --watch` / `search` | switch processing on, wait for it, spot-check the passages |
102
108
 
103
109
  `frontera help --json` returns the whole table as data.
104
110
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.50.79",
3
+ "version": "1.50.81",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -39,14 +39,14 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@anthropic-ai/claude-agent-sdk": "^0.3.251",
42
- "@frontera-sdk/functions": "1.50.79",
43
- "@frontera-sdk/core": "1.50.79",
42
+ "@frontera-sdk/functions": "1.50.81",
43
+ "@frontera-sdk/core": "1.50.81",
44
44
  "ai": "^6.0.116",
45
45
  "gray-matter": "^4.0.3",
46
46
  "yaml": "^2.9.0"
47
47
  },
48
48
  "devDependencies": {
49
- "@frontera-sdk/forge-contracts": "1.50.79",
49
+ "@frontera-sdk/forge-contracts": "1.50.81",
50
50
  "@types/bun": "^1.3.14",
51
51
  "typescript": "^5.9.3"
52
52
  }
@@ -4,6 +4,19 @@ import { credentialFailure } from './credential-failure'
4
4
  import { formatValidationDetails } from './validation-detail'
5
5
  import type { DefinitionBundle } from '../blueprint/model'
6
6
 
7
+ /** A row of `GET /v1/blueprint/markings`. */
8
+ export interface MarkingRow {
9
+ marking: string
10
+ displayName: string | null
11
+ description: string | null
12
+ }
13
+
14
+ /** Who a marking is granted to. A role is named; the other kinds are ids. */
15
+ export interface MarkingPrincipal {
16
+ kind: 'workspace' | 'role' | 'user' | 'agent'
17
+ id: string
18
+ }
19
+
7
20
  /**
8
21
  * Blueprint AUTHORING over HTTP.
9
22
  *
@@ -45,8 +58,18 @@ export class BlueprintAuthoringApi {
45
58
  // The credential failures worth telling apart, because each has a different
46
59
  // fix and a generic "request failed" sends the reader to the wrong one.
47
60
  // Shared with the other clients so the three of them cannot drift again.
61
+ // A lane refusal, worded before the generic credential one gets it — same
62
+ // reason as `workflow-api.ts`: "check the key's capability" cannot work on
63
+ // a route closed to organization keys, since no grant reopens it.
64
+ if (payload?.detail === 'org-key-route-closed') {
65
+ throw new CliError(`${path.split('?')[0]} is closed to organization keys by design`, {
66
+ code: (payload?.code as string) ?? 'FORBIDDEN',
67
+ hint: 'no grant on the key opens it — this needs a person’s session, or the platform',
68
+ })
69
+ }
48
70
  const credential = credentialFailure(res.status, payload?.message, {
49
71
  token: this.token,
72
+ code: payload?.code,
50
73
  whenSilent: "The key is not permitted to author this organization's Blueprint.",
51
74
  })
52
75
  if (credential) throw credential
@@ -514,6 +537,35 @@ export class BlueprintAuthoringApi {
514
537
  )
515
538
  }
516
539
 
540
+ // ── security markings: who may see which marked rows ─────────────────────
541
+
542
+ markings(): Promise<{ markings: MarkingRow[] }> {
543
+ return this.call('/v1/blueprint/markings')
544
+ }
545
+
546
+ createMarking(body: { marking: string; displayName?: string; description?: string }) {
547
+ return this.call<{ marking: string }>('/v1/blueprint/markings', { method: 'POST', body })
548
+ }
549
+
550
+ principalMarkings(principal: MarkingPrincipal): Promise<{ markings: string[] }> {
551
+ const query = new URLSearchParams({ principalKind: principal.kind, principalId: principal.id })
552
+ return this.call(`/v1/blueprint/markings/grants?${query}`)
553
+ }
554
+
555
+ grantMarking(marking: string, principal: MarkingPrincipal) {
556
+ return this.call('/v1/blueprint/markings/grants', {
557
+ method: 'POST',
558
+ body: { marking, principalKind: principal.kind, principalId: principal.id },
559
+ })
560
+ }
561
+
562
+ revokeMarking(marking: string, principal: MarkingPrincipal) {
563
+ return this.call('/v1/blueprint/markings/grants', {
564
+ method: 'DELETE',
565
+ body: { marking, principalKind: principal.kind, principalId: principal.id },
566
+ })
567
+ }
568
+
517
569
  // ── datasets: read-only, and reachable because the key holds `dataset:read` ─
518
570
 
519
571
  /**
@@ -24,6 +24,10 @@ import { orgScopedServiceNouns } from '../scopes'
24
24
  * request leaves.
25
25
  */
26
26
 
27
+ /** A person's session refused for permission: no key would help, a role would. */
28
+ export const PERSON_FORBIDDEN_HINT =
29
+ 'the signed-in person lacks the permission this needs — ask an administrator for a role that grants it'
30
+
27
31
  /** The lane split, which is the first thing to check and the easiest to miss. */
28
32
  function laneNote(token: string): string {
29
33
  // A workspace key is refused organization scope BY DESIGN — it is not a
@@ -48,13 +52,39 @@ function laneNote(token: string): string {
48
52
  export function credentialFailure(
49
53
  status: number,
50
54
  message: string | undefined,
51
- opts: { token: string; capability?: string; whenSilent?: string },
55
+ opts: { token: string; code?: string; capability?: string; whenSilent?: string },
52
56
  ): CliError | null {
57
+ // A person's session, not a key: "the key may not carry the capability"
58
+ // sends its holder hunting for a credential they were never meant to use.
59
+ const person = !opts.token.startsWith('sk-')
60
+
53
61
  if (status === 401) {
62
+ return person
63
+ ? new CliError('The session token was refused — it has expired or been signed out.', {
64
+ code: 'UNAUTHORIZED',
65
+ hint: 'sign in again, then set FRONTERA_TOKEN to the new session token',
66
+ })
67
+ : new CliError(
68
+ 'The credential was refused. An organization key that has been revoked or has '
69
+ + 'expired reads exactly like this — mint or rotate one in Settings → API keys.',
70
+ { code: 'UNAUTHORIZED', hint: 'frontera auth verify — then `frontera login --api-url <url>`' },
71
+ )
72
+ }
73
+
74
+ if (status === 403 && person) {
75
+ // Only the generic RBAC refusal is a missing role. A 403 with its own code
76
+ // — `ACTION_FORBIDDEN` for separation of duties, say — is a rule no role
77
+ // lifts, so its code and message go back to the caller untouched.
78
+ if (opts.code !== undefined && opts.code !== 'FORBIDDEN') return null
54
79
  return new CliError(
55
- 'The credential was refused. An organization key that has been revoked or has '
56
- + 'expired reads exactly like this — mint or rotate one in Settings → API keys.',
57
- { code: 'UNAUTHORIZED', hint: 'frontera auth verify — then `frontera login --api-url <url>`' },
80
+ // The service's own words; the hint below says what they mean.
81
+ message ?? 'The signed-in person is not permitted to reach that resource.',
82
+ {
83
+ code: 'FORBIDDEN',
84
+ hint: opts.capability
85
+ ? `the signed-in person needs ${opts.capability} — ask an administrator for a role that grants it`
86
+ : PERSON_FORBIDDEN_HINT,
87
+ },
58
88
  )
59
89
  }
60
90
 
@@ -106,7 +106,7 @@ export class DatasetApi {
106
106
  // Datasets and sources are organization-scoped, so a workspace key is
107
107
  // refused here by design — and this client used to answer that with a
108
108
  // bare "Insufficient permissions" and no route out.
109
- const credential = credentialFailure(response.status, payload?.message, { token: this.token })
109
+ const credential = credentialFailure(response.status, payload?.message, { token: this.token, code: payload?.code })
110
110
  if (credential) throw credential
111
111
  // `details` is TypeBox's own error array. Stringified it was unreadable,
112
112
  // and this is the last gate before a Source exists.
@@ -38,6 +38,22 @@ export interface ActionRequestSummary {
38
38
  deadlineAt?: string | null
39
39
  }
40
40
 
41
+ /** A row of `GET /v1/blueprint/governed-actions/discovery`. */
42
+ export interface ActionDiscovery {
43
+ actionDefinitionId: string
44
+ apiName: string
45
+ displayName?: string
46
+ description?: string
47
+ /** `existing` Actions act on one object, named by `subjectRef`. */
48
+ subject?: { mode?: string; objectTypeId?: string }
49
+ approval?: { mode?: string; threshold?: number; separationOfDuties?: boolean }
50
+ /** JSON Schema of the invocation envelope: `input`, `subjectRef`, `reason`… */
51
+ inputSchema?: {
52
+ required?: string[]
53
+ properties?: { input?: { required?: string[]; properties?: Record<string, unknown> } }
54
+ }
55
+ }
56
+
41
57
  export class GovernedActionApi {
42
58
  /**
43
59
  * `workspaceId` for the one verb on this plane that needs it: the deployment
@@ -60,10 +76,16 @@ export class GovernedActionApi {
60
76
  }
61
77
  }
62
78
 
63
- private async call<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
79
+ private async call<T>(
80
+ path: string,
81
+ init: { method?: string; body?: unknown; headers?: Record<string, string> } = {},
82
+ ): Promise<T> {
64
83
  const response = await fetch(`${this.apiUrl}${path}`, {
65
84
  method: init.method ?? 'GET',
66
- headers: this.headers(init.body === undefined ? {} : { 'content-type': 'application/json' }),
85
+ headers: this.headers({
86
+ ...(init.body === undefined ? {} : { 'content-type': 'application/json' }),
87
+ ...init.headers,
88
+ }),
67
89
  ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
68
90
  })
69
91
  const text = await response.text()
@@ -72,11 +94,14 @@ export class GovernedActionApi {
72
94
 
73
95
  if (!response.ok) {
74
96
  const body = payload as { message?: string; code?: string; details?: unknown } | null
75
- const credential = credentialFailure(response.status, body?.message, { token: this.token })
97
+ const credential = credentialFailure(response.status, body?.message, { token: this.token, code: body?.code })
76
98
  if (credential) throw credential
77
99
  throw new CliError(body?.message ?? `${response.status} from ${path}`, {
78
100
  code: body?.code ?? 'FAILURE',
79
101
  ...(body?.details ? { hint: JSON.stringify(body.details) } : {}),
102
+ // The status is what separates "refused" from "unknown": `action
103
+ // submit` offers a retry only for the second.
104
+ cause: { status: response.status, details: body?.details },
80
105
  })
81
106
  }
82
107
  const envelope = payload as Envelope<T>
@@ -93,10 +118,9 @@ export class GovernedActionApi {
93
118
  * neither says whether anything has ever run through it, or what happened
94
119
  * when it did.
95
120
  *
96
- * Reads only. Invoking stays absent for the reason this module's header
97
- * gives — both CLI credential kinds present a non-member principal, and the
98
- * invoke check joins organization membership. The LIST gate is different:
99
- * `actionRequest: ['read']`, which a key can hold.
121
+ * The LIST gate is `actionRequest: ['read']`, which a workspace key can hold
122
+ * — unlike invoking, whose check joins organization membership and so needs
123
+ * a session (see `submit` below).
100
124
  *
101
125
  * `nextCursor` rides on the envelope beside `data`, so this cannot go
102
126
  * through `call` — that unwraps to `data` and would drop the cursor,
@@ -126,6 +150,7 @@ export class GovernedActionApi {
126
150
  // needs; the shared mapper adds the lane note the bare hint was missing.
127
151
  const credential = credentialFailure(response.status, body?.message, {
128
152
  token: this.token,
153
+ code: body?.code,
129
154
  capability: 'actionRequest:read',
130
155
  })
131
156
  if (credential) throw credential
@@ -147,6 +172,49 @@ export class GovernedActionApi {
147
172
  return this.call(`/v1/blueprint/governed-actions/requests/${encodeURIComponent(requestId)}`)
148
173
  }
149
174
 
175
+ /**
176
+ * The Actions THIS caller may invoke, with the input schema each one takes.
177
+ *
178
+ * Filtered by the same membership check submit runs, per Action, and a
179
+ * refused Action is skipped rather than reported: the service answers a
180
+ * workspace key with an empty list, not a refusal. The verbs on this half of
181
+ * the plane are session-lane, so the dispatcher refuses a key before it asks.
182
+ */
183
+ discover(): Promise<ActionDiscovery[]> {
184
+ return this.call('/v1/blueprint/governed-actions/discovery')
185
+ }
186
+
187
+ /**
188
+ * Raise a request against a deployed Action.
189
+ *
190
+ * The key is the caller's, not ours: the service answers a repeated key with
191
+ * the request it already made, which is what makes a retry after a timeout
192
+ * safe — and only if the retry sends the same key.
193
+ */
194
+ submit(apiName: string, invocation: Record<string, unknown>, idempotencyKey: string): Promise<ActionRequestSummary> {
195
+ return this.call(`/v1/blueprint/governed-actions/actions/${encodeURIComponent(apiName)}/requests`, {
196
+ method: 'POST',
197
+ body: { invocation },
198
+ headers: { 'idempotency-key': idempotencyKey },
199
+ })
200
+ }
201
+
202
+ decideApproval(requestId: string, decision: 'approve' | 'reject', reason: string): Promise<{
203
+ approval: Record<string, unknown>
204
+ request: ActionRequestSummary
205
+ }> {
206
+ return this.call(`/v1/blueprint/governed-actions/requests/${encodeURIComponent(requestId)}/approvals`, {
207
+ method: 'POST',
208
+ body: { decision, reason },
209
+ })
210
+ }
211
+
212
+ cancelRequest(requestId: string): Promise<ActionRequestSummary> {
213
+ return this.call(`/v1/blueprint/governed-actions/requests/${encodeURIComponent(requestId)}/cancel`, {
214
+ method: 'POST',
215
+ })
216
+ }
217
+
150
218
  /** The published Action: its definition and the digest a Binding pins. */
151
219
  publishedAction(apiName: string): Promise<{
152
220
  definition: PublishedActionDefinition
@@ -0,0 +1,181 @@
1
+ import { CliError } from '../errors'
2
+ import { organizationHeaders } from '../organization'
3
+ import { credentialFailure } from './credential-failure'
4
+ import { formatValidationDetails } from './validation-detail'
5
+
6
+ /**
7
+ * Media Sets, from the CLI: create a set, fill it from local files, switch its
8
+ * processing on, watch it finish, and grant it to a workspace.
9
+ *
10
+ * SESSION ONLY (`authLane: 'session'` on every verb). The router accepts
11
+ * programmatic auth, but its direct plane asks ORGANIZATION-scoped permissions
12
+ * a workspace key cannot satisfy, and it is closed to organization keys. The
13
+ * dispatcher words that refusal once for every session-lane noun.
14
+ */
15
+
16
+ interface Options {
17
+ method?: string
18
+ body?: unknown
19
+ /** Multipart. Never set alongside `body` — the two choose different content types. */
20
+ form?: FormData
21
+ }
22
+
23
+ export interface MediaSet {
24
+ id: string
25
+ apiName: string
26
+ displayName: string
27
+ description?: string | null
28
+ createdAt?: string
29
+ itemCount?: number
30
+ }
31
+
32
+ export interface MediaItem {
33
+ mediaItemId: string
34
+ path: string
35
+ mimeType: string
36
+ sizeBytes: number
37
+ version: number
38
+ isLatest: boolean
39
+ supersedesMediaItemId?: string | null
40
+ createdAt?: string
41
+ }
42
+
43
+ export interface ExtractionStatus {
44
+ extraction: { extractEnabled: boolean; extractProfile: 'text' | 'ocr'; ocrModel?: string }
45
+ coverage: { ready: number; pending: number; failed: number; skipped: number; items: number }
46
+ }
47
+
48
+ export interface ChunkingStatus {
49
+ chunking: { chunkEnabled: boolean; embedEnabled: boolean; embedModel: string | null }
50
+ coverage: {
51
+ ready: number
52
+ pending: number
53
+ failed: number
54
+ skipped: number
55
+ extracts: number
56
+ chunks: number
57
+ embedded: number
58
+ truncated: number
59
+ embedFailed: number
60
+ embedPending: number
61
+ }
62
+ }
63
+
64
+ export interface ChunkMatch {
65
+ chunkId: string
66
+ mediaItemId: string
67
+ path: string
68
+ ordinal: number
69
+ text: string
70
+ distance: number
71
+ }
72
+
73
+ export class MediaApi {
74
+ constructor(
75
+ private readonly apiUrl: string,
76
+ private readonly token: string,
77
+ /** Sent as `x-workspace-id`: a non-administrator sees only the sets granted to it. */
78
+ private readonly workspaceId?: string,
79
+ ) {}
80
+
81
+ private async call<T>(path: string, options: Options = {}): Promise<T> {
82
+ const response = await fetch(`${this.apiUrl}/v1/media${path}`, {
83
+ method: options.form ? 'POST' : (options.method ?? 'GET'),
84
+ headers: {
85
+ Authorization: `Bearer ${this.token}`,
86
+ ...organizationHeaders(),
87
+ ...(this.workspaceId ? { 'x-workspace-id': this.workspaceId } : {}),
88
+ // Never set for multipart: `fetch` writes the boundary itself.
89
+ ...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
90
+ },
91
+ ...(options.form ? { body: options.form } : {}),
92
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
93
+ })
94
+ const text = await response.text()
95
+ let payload: { data?: unknown; message?: string; code?: string; details?: unknown } | null
96
+ try { payload = text ? JSON.parse(text) : null } catch { payload = null }
97
+
98
+ if (!response.ok) {
99
+ const credential = credentialFailure(response.status, payload?.message, { token: this.token, code: payload?.code })
100
+ if (credential) throw credential
101
+ const detail = formatValidationDetails(payload?.details)
102
+ throw new CliError(payload?.message ?? `Request failed (${response.status}).`, {
103
+ code: payload?.code ?? 'FAILURE',
104
+ ...(detail ? { hint: detail } : {}),
105
+ })
106
+ }
107
+ return (payload?.data ?? payload) as T
108
+ }
109
+
110
+ private set(mediaSetId: string): string {
111
+ return `/sets/${encodeURIComponent(mediaSetId)}`
112
+ }
113
+
114
+ async list(): Promise<MediaSet[]> {
115
+ return (await this.call<{ mediaSets: MediaSet[] }>('/sets')).mediaSets
116
+ }
117
+
118
+ /** Ready items, newest first, as the calling workspace may see them. */
119
+ async items(mediaSetId: string): Promise<MediaItem[]> {
120
+ return (await this.call<{ items: MediaItem[] }>(`${this.set(mediaSetId)}/items`)).items
121
+ }
122
+
123
+ async create(body: { apiName: string; displayName: string; description?: string }): Promise<MediaSet> {
124
+ return (await this.call<{ mediaSet: MediaSet }>('/sets', { method: 'POST', body })).mediaSet
125
+ }
126
+
127
+ /**
128
+ * One file into the set. `logicalPath` is the item's identity inside the
129
+ * set: uploading to a path that already holds a file makes a new VERSION of
130
+ * it, not a second item.
131
+ */
132
+ async upload(
133
+ mediaSetId: string,
134
+ file: { bytes: Uint8Array; filename: string; mime: string; logicalPath: string; markings?: string },
135
+ ): Promise<MediaItem> {
136
+ const form = new FormData()
137
+ form.set('file', new File([new Blob([file.bytes.buffer as ArrayBuffer])], file.filename, { type: file.mime }))
138
+ form.set('logicalPath', file.logicalPath)
139
+ if (file.markings) form.set('markings', file.markings)
140
+ return (await this.call<{ item: MediaItem }>(`${this.set(mediaSetId)}/items`, { form })).item
141
+ }
142
+
143
+ extraction(mediaSetId: string): Promise<ExtractionStatus> {
144
+ return this.call(`${this.set(mediaSetId)}/extraction`)
145
+ }
146
+
147
+ chunking(mediaSetId: string): Promise<ChunkingStatus> {
148
+ return this.call(`${this.set(mediaSetId)}/chunking`)
149
+ }
150
+
151
+ setExtraction(mediaSetId: string, body: { enabled: boolean; profile?: 'text' | 'ocr' }): Promise<unknown> {
152
+ return this.call(`${this.set(mediaSetId)}/extraction`, { method: 'PUT', body })
153
+ }
154
+
155
+ setChunking(mediaSetId: string, body: { enabled: boolean; embed?: { enabled: boolean } }): Promise<unknown> {
156
+ return this.call(`${this.set(mediaSetId)}/chunking`, { method: 'PUT', body })
157
+ }
158
+
159
+ retryExtraction(mediaSetId: string): Promise<{ retried: number }> {
160
+ return this.call(`${this.set(mediaSetId)}/extraction/retry`, { method: 'POST' })
161
+ }
162
+
163
+ retryChunking(mediaSetId: string): Promise<{ retried: { chunking: number; embedding: number } }> {
164
+ return this.call(`${this.set(mediaSetId)}/chunking/retry`, { method: 'POST' })
165
+ }
166
+
167
+ search(
168
+ mediaSetId: string,
169
+ body: { query: string; limit?: number; maxDistance?: number },
170
+ ): Promise<{ model: string; matches: ChunkMatch[] }> {
171
+ return this.call(`${this.set(mediaSetId)}/chunks/search`, { method: 'POST', body })
172
+ }
173
+
174
+ grant(mediaSetId: string, body: { workspaceId: string; permissions?: string[] }): Promise<{ permissions: string[] }> {
175
+ return this.call(`${this.set(mediaSetId)}/grants`, { method: 'POST', body })
176
+ }
177
+
178
+ revoke(mediaSetId: string, workspaceId: string): Promise<{ revoked: boolean }> {
179
+ return this.call(`${this.set(mediaSetId)}/grants/${encodeURIComponent(workspaceId)}`, { method: 'DELETE' })
180
+ }
181
+ }
@@ -196,6 +196,65 @@ export class PlatformApi {
196
196
  return this.getList<unknown>('/v1/platform-apps/')
197
197
  }
198
198
 
199
+ // ── Chat Apps ─────────────────────────────────────────────────────────────
200
+
201
+ /**
202
+ * Create a Chat App, or return the one already holding this slug.
203
+ *
204
+ * The same upsert `app deploy` uses, with `kind: 'chat'`. The service refuses
205
+ * a slug held by a Custom App rather than converting it.
206
+ */
207
+ ensureChatApp(body: { slug: string; displayName: string; description?: string; accent?: string }) {
208
+ return this.client.request<{ id: string; slug: string; created: boolean }>('/v1/platform-apps/ensure', {
209
+ method: 'POST',
210
+ body: { ...body, kind: 'chat' },
211
+ })
212
+ }
213
+
214
+ /** Draft, live version, audience, endpoints and validation findings. */
215
+ chatAppEditor(appId: string) {
216
+ return this.get<unknown>(`/v1/chat-apps/${encodeURIComponent(appId)}/editor`)
217
+ }
218
+
219
+ /** Drafts are per principal: a key's draft is its own, not a person's. */
220
+ saveChatAppDraft(appId: string, configuration: unknown) {
221
+ return this.client.request<unknown>(`/v1/chat-apps/${encodeURIComponent(appId)}/draft`, {
222
+ method: 'PUT',
223
+ body: { configuration },
224
+ })
225
+ }
226
+
227
+ /** Without `configuration`, publishes the caller's saved draft. */
228
+ publishChatApp(appId: string, body: { configuration?: unknown; dryRun?: boolean }) {
229
+ return this.client.request<unknown>(`/v1/chat-apps/${encodeURIComponent(appId)}/publish`, {
230
+ method: 'POST',
231
+ body,
232
+ })
233
+ }
234
+
235
+ /** Replaces the whole audience. No grants closes the App to everyone. */
236
+ setChatAppAccess(
237
+ appId: string,
238
+ grants: Array<{ principalKind: 'workspace' | 'user' | 'group'; principalId?: string }>,
239
+ ) {
240
+ return this.client.request<unknown>(`/v1/chat-apps/${encodeURIComponent(appId)}/access`, {
241
+ method: 'PUT',
242
+ body: { grants },
243
+ })
244
+ }
245
+
246
+ setChatAppEndpoint(appId: string, kind: 'platform' | 'standalone' | 'widget', enabled: boolean) {
247
+ return this.client.request<unknown>(
248
+ `/v1/chat-apps/${encodeURIComponent(appId)}/endpoints/${kind}`,
249
+ { method: 'PUT', body: { enabled } },
250
+ )
251
+ }
252
+
253
+ /** Workspace members — the user ids an access grant names. */
254
+ chatAppMembers(appId: string) {
255
+ return this.getList<unknown>(`/v1/chat-apps/${encodeURIComponent(appId)}/members`)
256
+ }
257
+
199
258
  /** `{ agentId, revision, snapshot }`, or null when nothing is staged. */
200
259
  async agentDraft(id: string): Promise<AgentDraft | null> {
201
260
  // Wrapped twice: `ok({ data: … })` around the SDK's own envelope.
@@ -178,7 +178,7 @@ export class WorkflowApi {
178
178
  + 'credential runs as is still open, so no capability on an organization key opens this.',
179
179
  })
180
180
  }
181
- const credential = credentialFailure(response.status, body?.message, { token: this.token })
181
+ const credential = credentialFailure(response.status, body?.message, { token: this.token, code: body?.code })
182
182
  if (credential) throw credential
183
183
  // A rejected definition carries its reasons in `details.errors`, and they
184
184
  // are the whole answer — a caller told only "Invalid workflow definition"
@@ -25,7 +25,7 @@ export function classifyKey(token: string): CredentialKind {
25
25
  throw new UsageError(
26
26
  'that does not look like a Frontera key',
27
27
  'workspace keys start with sk-ws- (Workspace settings → API Keys); '
28
- + 'organization keys start with sk-org- (Settings → API keys)',
28
+ + 'organization keys start with sk-org- (Organization settings → API Keys)',
29
29
  )
30
30
  }
31
31
 
@@ -0,0 +1,59 @@
1
+ import { GovernedActionApi } from '../../api/governed-action-api'
2
+ import { table } from '../../table'
3
+ import type { Command } from '../types'
4
+
5
+ /**
6
+ * The Actions this caller may submit, and what each one takes.
7
+ *
8
+ * Not `action list`: that is the organization's catalog and its deployment
9
+ * state, whoever is asking. This is one person's view in one workspace —
10
+ * every Action whose capability, subject grant and deployment all line up for
11
+ * them. An Action in `list` and missing here is the gap to chase.
12
+ */
13
+ export const actionAvailable: Command = {
14
+ meta: {
15
+ noun: 'action',
16
+ verb: 'available',
17
+ // Invoking needs a person: the check joins organization and workspace
18
+ // membership. The service would answer a key with an empty list, so the
19
+ // dispatcher refuses a key before the request.
20
+ authLane: 'session',
21
+ scope: 'workspace' as const,
22
+ args: [],
23
+ flags: {},
24
+ summary: 'List the Actions you can submit in this workspace, and their inputs',
25
+ examples: ['frontera action available', 'frontera action available --json'],
26
+ },
27
+
28
+ async run(ctx) {
29
+ const actions = await new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId).discover()
30
+
31
+ if (actions.length === 0) {
32
+ return {
33
+ data: { actions: [] },
34
+ text: 'No Actions you can submit here.\n'
35
+ + ' `frontera action list` shows what is published and deployed; an Action there\n'
36
+ + ' and not here is missing a capability grant or a subject grant for you.',
37
+ }
38
+ }
39
+
40
+ return {
41
+ data: { actions },
42
+ text: table(
43
+ ['ACTION', 'SUBJECT', 'APPROVAL', 'INPUTS'],
44
+ actions.map((a) => [
45
+ a.apiName,
46
+ a.subject?.mode ?? '',
47
+ a.approval?.mode === 'none' || !a.approval?.mode
48
+ ? 'none'
49
+ : `${a.approval.mode} ×${a.approval.threshold ?? 1}`,
50
+ // Required ones marked: the envelope refuses a missing one without
51
+ // naming it, so this is the only place the name is visible.
52
+ Object.keys(a.inputSchema?.properties?.input?.properties ?? {})
53
+ .map((name) => (a.inputSchema?.properties?.input?.required ?? []).includes(name) ? `${name}*` : name)
54
+ .join(' '),
55
+ ]),
56
+ ),
57
+ }
58
+ },
59
+ }
@@ -0,0 +1,34 @@
1
+ import { GovernedActionApi } from '../../api/governed-action-api'
2
+ import { UsageError } from '../../errors'
3
+ import type { Command } from '../types'
4
+ import { requestSummary } from './request-summary'
5
+
6
+ /**
7
+ * Withdraw a request before it runs.
8
+ *
9
+ * Only while nothing has been attempted: `awaiting_approval` or `ready`, never
10
+ * dispatched. Anything later answers 409 "can no longer be cancelled", because
11
+ * an effect may already be on the far side. Cancelling twice is a no-op.
12
+ * Session-lane: the cancel gate locks the caller's workspace membership.
13
+ */
14
+ export const actionCancel: Command = {
15
+ meta: {
16
+ noun: 'action',
17
+ verb: 'cancel',
18
+ authLane: 'session',
19
+ scope: 'workspace' as const,
20
+ args: [{ name: 'requestId', required: true, description: 'The request to cancel' }],
21
+ flags: {},
22
+ summary: 'Cancel an Action request that has not run yet',
23
+ examples: ['frontera action cancel <requestId>'],
24
+ },
25
+
26
+ async run(ctx) {
27
+ const requestId = ctx.positional[0]
28
+ if (!requestId) {
29
+ throw new UsageError('missing <requestId>', 'frontera action requests — to find it')
30
+ }
31
+ const request = await new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId).cancelRequest(requestId)
32
+ return { data: request, text: requestSummary(request) }
33
+ },
34
+ }