@frontera-sdk/cli 1.44.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +65 -1
  2. package/package.json +4 -3
  3. package/src/api/automation-api.ts +15 -0
  4. package/src/api/dataset-api.ts +99 -0
  5. package/src/api/governed-action-api.ts +80 -0
  6. package/src/api/platform-api.ts +293 -0
  7. package/src/auth-verify.ts +105 -0
  8. package/src/binding-registry.ts +87 -0
  9. package/src/commands/action/deploy.ts +1 -0
  10. package/src/commands/action/grant.ts +1 -0
  11. package/src/commands/action/index-commands.ts +8 -0
  12. package/src/commands/action/prepare.ts +1 -0
  13. package/src/commands/action/requests.ts +111 -0
  14. package/src/commands/action/review.ts +1 -0
  15. package/src/commands/agent/index-commands.ts +189 -7
  16. package/src/commands/app/init.ts +1 -1
  17. package/src/commands/app/pull.ts +1 -1
  18. package/src/commands/auth/add.ts +145 -0
  19. package/src/commands/auth/current.ts +82 -0
  20. package/src/commands/auth/index-commands.ts +16 -0
  21. package/src/commands/auth/list.ts +71 -0
  22. package/src/commands/auth/remove.ts +80 -0
  23. package/src/commands/auth/use.ts +84 -0
  24. package/src/commands/auth/verify.ts +93 -0
  25. package/src/commands/automation/run.ts +41 -2
  26. package/src/commands/blueprint/query.ts +294 -0
  27. package/src/commands/capability/index-commands.ts +334 -0
  28. package/src/commands/dataset/index-commands.ts +103 -14
  29. package/src/commands/kit/doctor.ts +101 -0
  30. package/src/commands/kit/index-commands.ts +7 -0
  31. package/src/commands/kit/shared.ts +52 -0
  32. package/src/commands/kit/status.ts +92 -0
  33. package/src/commands/kit/sync.ts +106 -0
  34. package/src/commands/kit/vendor.ts +120 -0
  35. package/src/commands/knowledge/index-commands.ts +165 -0
  36. package/src/commands/login.ts +64 -84
  37. package/src/commands/plugin/index-commands.ts +284 -21
  38. package/src/commands/registry.ts +104 -1
  39. package/src/commands/setup.ts +248 -0
  40. package/src/commands/source/index-commands.ts +446 -0
  41. package/src/commands/types.ts +14 -0
  42. package/src/config.ts +197 -100
  43. package/src/credential-store.ts +273 -0
  44. package/src/dev-env.ts +3 -3
  45. package/src/exit.ts +29 -2
  46. package/src/flag-help.ts +65 -3
  47. package/src/fs-atomic.ts +44 -0
  48. package/src/harness.ts +155 -4
  49. package/src/kit.ts +419 -0
  50. package/src/main.ts +13 -1
  51. package/src/paths.ts +43 -0
  52. package/src/profile-migration.ts +101 -0
  53. package/src/profiles.ts +240 -0
  54. package/src/project-context.ts +178 -0
  55. package/src/prompt.ts +23 -0
  56. package/src/templates/next-app-files.ts +4 -1
  57. package/src/vendor/kit-assets.json +31 -0
  58. package/src/vendor/sdk-sources.json +1 -1
package/README.md CHANGED
@@ -7,7 +7,8 @@ bunx @frontera-sdk/cli --help
7
7
  ```
8
8
 
9
9
  ```bash
10
- frontera login
10
+ frontera auth add acme-prod --api-url https://api.frontera.example --from -
11
+ frontera auth use acme-prod
11
12
  frontera app init shipments-console
12
13
  cd shipments-console && bun install
13
14
  frontera app dev
@@ -19,6 +20,69 @@ key never enters browser JavaScript or `.env.local`; the browser receives only
19
20
  a short-lived, App-scoped token. Existing Vite Apps remain supported with
20
21
  `--framework react` and their legacy `dev-host.html` flow.
21
22
 
23
+ ## One machine, many customers
24
+
25
+ A credential is a **profile**, and the working directory selects which profile
26
+ is in play. Two keys for the same deployment coexist under different names, and
27
+ a command run in a customer's directory uses that customer's key with no flag.
28
+
29
+ ```bash
30
+ frontera auth add acme-prod --api-url https://api.frontera.example --from -
31
+ frontera auth add globex-dev --api-url https://api.frontera.example --from -
32
+
33
+ cd ~/Customers/acme && frontera auth use acme-prod
34
+ cd ~/Customers/globex && frontera auth use globex-dev
35
+
36
+ frontera auth current --json # which profile, which origin, which scope
37
+ ```
38
+
39
+ `auth use` writes a gitignored `.frontera/context.json` holding a profile
40
+ **name** — never a token and never an origin. Resolution walks up from the
41
+ working directory and the nearest binding wins, so a nested customer project
42
+ safely overrides its parent.
43
+
44
+ Precedence is explicit over inferred: `--profile` beats `FRONTERA_PROFILE`,
45
+ which beats the directory. `--api-url` must agree with the selected profile's
46
+ origin — a profile's key is never sent to another deployment. `FRONTERA_TOKEN`
47
+ still works for CI and the Computer, and requires an explicit origin alongside
48
+ it for the same reason.
49
+
50
+ Keys live in the OS credential store addressed by profile; `config.json` holds
51
+ metadata and a short fingerprint, never key bytes. A version-1 configuration
52
+ migrates on first use, verifying every copied secret before the old plaintext
53
+ is dropped. `frontera login` remains as a deprecated alias that manages the
54
+ `default` profile.
55
+
56
+ | | |
57
+ |---|---|
58
+ | `frontera auth add <profile>` | verify a key and store it (stdin or no-echo prompt only) |
59
+ | `frontera auth use <profile>` | bind this directory tree — no initialization needed |
60
+ | `frontera auth current` | the resolved profile, origin, scope and provenance |
61
+ | `frontera auth list` / `verify` / `remove` | what exists, is it still valid, delete it |
62
+
63
+ ## Agent harness
64
+
65
+ ```bash
66
+ frontera setup # every coding host installed here
67
+ frontera setup codex
68
+ frontera setup claude
69
+ ```
70
+
71
+ Codex and Claude Code both install plugins from a marketplace, and both accept a
72
+ local directory as one. The CLI carries the Frontera skills pinned at build
73
+ time, so `setup` materializes a marketplace under
74
+ `~/.local/share/frontera/harness` and points the host at it — nothing published,
75
+ nothing hosted, no network. Upgrading the CLI upgrades the plugin.
76
+
77
+ Nothing needs to be written into a repository. For teams that want the
78
+ instructions versioned with the code — or for Windows checkouts, archives and
79
+ cloud sandboxes — `frontera kit vendor` writes both host projections
80
+ (`.agents/skills` and `.claude/skills`), a bounded managed block in `AGENTS.md`,
81
+ a `CLAUDE.md` importing `@AGENTS.md`, and a committable
82
+ `frontera.kit.lock.json`. `kit status`, `kit sync` and `kit doctor` report
83
+ drift, update generated files without discarding local edits, and diagnose a
84
+ skill reachable from two sources at once.
85
+
22
86
  ## The commands
23
87
 
24
88
  `frontera <noun> <verb>`. Note that `frontera init` (prepare an existing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontera-sdk/cli",
3
- "version": "1.44.0",
3
+ "version": "1.45.0",
4
4
  "description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
5
5
  "keywords": [
6
6
  "frontera",
@@ -34,11 +34,12 @@
34
34
  "test": "bun test",
35
35
  "typecheck": "bunx tsc --noEmit",
36
36
  "sync:sdk": "bun run scripts/sync-sdk.ts",
37
+ "sync:kit": "bun run scripts/sync-kit.ts",
37
38
  "build:release": "bun run scripts/build-release.ts"
38
39
  },
39
40
  "dependencies": {
40
- "@frontera-sdk/automation": "1.43.10",
41
- "@frontera-sdk/core": "1.43.10",
41
+ "@frontera-sdk/automation": "1.45.0",
42
+ "@frontera-sdk/core": "1.45.0",
42
43
  "gray-matter": "^4.0.3",
43
44
  "yaml": "^2.9.0"
44
45
  },
@@ -232,6 +232,21 @@ export class AutomationApi {
232
232
  )
233
233
  }
234
234
 
235
+ /**
236
+ * One run, by id.
237
+ *
238
+ * `runs` lists, and a listing is where a failure is NOTICED — the message it
239
+ * carries is what the handler threw, and everything about how the run got
240
+ * there is in the steps. Fetching the run itself is what makes those two
241
+ * readable together after the fact, rather than only in the tail of a list
242
+ * that has since scrolled.
243
+ */
244
+ runById(slug: string, runId: string): Promise<AutomationRunSummary> {
245
+ return this.client.request<AutomationRunSummary>(
246
+ `/v1/automations/${encodeURIComponent(slug)}/runs/${encodeURIComponent(runId)}`,
247
+ )
248
+ }
249
+
235
250
  /** One run's trail. Workspace-scoped server-side, so a guessed id reads
236
251
  * nothing. */
237
252
  runSteps(runId: string): Promise<AutomationRunStep[]> {
@@ -127,10 +127,109 @@ export class DatasetApi {
127
127
  )
128
128
  }
129
129
 
130
+ /**
131
+ * Rows of a published revision — the read half of every create form above.
132
+ *
133
+ * `dataset get` shows the column CONTRACT, which is what a Blueprint binding
134
+ * pins, and says nothing about whether a single row is behind it. A `source`
135
+ * dataset whose relation went empty and a `blank` one that never carried
136
+ * rows present identically there, and both bind without complaint, so the
137
+ * contract alone cannot answer "is there data".
138
+ *
139
+ * Capped at 100 rows a page by the service, cursor-paged. A revision is
140
+ * immutable, so a cursor stays valid.
141
+ */
142
+ revisionRows(
143
+ revisionId: string,
144
+ body: { limit?: number; cursor?: string } = {},
145
+ ): Promise<{
146
+ columns?: Array<{ name?: string; databaseType?: string }>
147
+ rows?: unknown[][]
148
+ nextCursor?: string | null
149
+ readAt?: string
150
+ consistency?: string
151
+ }> {
152
+ return this.call(
153
+ `/v1/data-integration/dataset-revisions/${encodeURIComponent(revisionId)}/rows`,
154
+ { method: 'POST', body },
155
+ )
156
+ }
157
+
130
158
  listSources(): Promise<SourceSummary[]> {
131
159
  return this.page<SourceSummary>('/v1/data-integration/sources')
132
160
  }
133
161
 
162
+ getSource(sourceId: string): Promise<SourceSummary & Record<string, unknown>> {
163
+ return this.call(`/v1/data-integration/sources/${encodeURIComponent(sourceId)}`)
164
+ }
165
+
166
+ /**
167
+ * Provision a Source, and the Datasets it carries, in one call.
168
+ *
169
+ * The step that broke every scripted org stand-up: `dataset create --from-source`
170
+ * NAMED a Source, and nothing could make one. Postgres only today — the
171
+ * service takes `connectorType: 'postgres'` as a literal, so a second
172
+ * connector is a service change, not a CLI flag.
173
+ *
174
+ * `config.password` never comes from the definition file. The command reads
175
+ * it from stdin and merges it here, for the reason `secret set` states: a
176
+ * credential in a committed file is a credential in the repository.
177
+ */
178
+ provisionSource(body: {
179
+ displayName: string
180
+ description?: string | null
181
+ connectorType: 'postgres'
182
+ config: Record<string, unknown>
183
+ datasets: unknown[]
184
+ }): Promise<Record<string, unknown>> {
185
+ return this.call('/v1/data-integration/sources/provision', { method: 'POST', body })
186
+ }
187
+
188
+ /**
189
+ * Replace a Source's connection and add Datasets to it.
190
+ *
191
+ * Both concurrency tokens are required by the service and neither is
192
+ * optional here: `expectedUpdatedAt` and `expectedRevision` come from
193
+ * `source get`, and a stale pair is refused rather than merged. Datasets are
194
+ * ADDITIONS — omitting the field leaves every existing one in place, and
195
+ * there is no removal channel on this route.
196
+ */
197
+ updateSource(
198
+ sourceId: string,
199
+ body: {
200
+ expectedUpdatedAt: string
201
+ expectedRevision: number
202
+ displayName: string
203
+ description?: string | null
204
+ config: Record<string, unknown>
205
+ datasets?: unknown[]
206
+ },
207
+ ): Promise<Record<string, unknown>> {
208
+ return this.call(`/v1/data-integration/sources/${encodeURIComponent(sourceId)}`, {
209
+ method: 'PATCH',
210
+ body,
211
+ })
212
+ }
213
+
214
+ /** A new connection revision, leaving the Datasets alone. */
215
+ reviseSource(
216
+ sourceId: string,
217
+ body: { expectedRevision: number; config: Record<string, unknown> },
218
+ ): Promise<Record<string, unknown>> {
219
+ return this.call(`/v1/data-integration/sources/${encodeURIComponent(sourceId)}/revisions`, {
220
+ method: 'POST',
221
+ body,
222
+ })
223
+ }
224
+
225
+ /** Stops reads without deleting. Datasets bound to it stop refreshing. */
226
+ disableSource(sourceId: string, body: { expectedUpdatedAt: string }): Promise<unknown> {
227
+ return this.call(`/v1/data-integration/sources/${encodeURIComponent(sourceId)}/disable`, {
228
+ method: 'POST',
229
+ body,
230
+ })
231
+ }
232
+
134
233
  /** Columns and a sample, read from the CSV before anything is created. */
135
234
  async uploadPreview(path: string): Promise<UploadPreview> {
136
235
  const form = new FormData()
@@ -13,6 +13,29 @@ import type { ObjectTypeShape, PublishedActionDefinition } from '../blueprint/on
13
13
 
14
14
  interface Envelope<T> { error?: boolean; data?: T }
15
15
 
16
+ /**
17
+ * A row of `GET /v1/blueprint/governed-actions/requests`.
18
+ *
19
+ * Transcribed from a real response, not guessed. The list projection carries
20
+ * no action apiName and no failure text — an earlier version of this interface
21
+ * declared both and rendered two permanently empty columns. `effectCertainty`
22
+ * is the field that actually matters on a failure: it says whether the effect
23
+ * reached the far side, which is the difference between "retry" and "do not".
24
+ */
25
+ export interface ActionRequestSummary {
26
+ id?: string
27
+ lifecycle?: string
28
+ /** The definition's uuid. The list does not resolve it to an apiName. */
29
+ actionDefinitionId?: string
30
+ actionContractDigest?: string
31
+ effectCertainty?: string
32
+ hasEverDispatched?: boolean
33
+ subjectRef?: unknown
34
+ createdAt?: string
35
+ updatedAt?: string
36
+ deadlineAt?: string | null
37
+ }
38
+
16
39
  export class GovernedActionApi {
17
40
  constructor(private readonly apiUrl: string, private readonly token: string) {}
18
41
 
@@ -42,6 +65,63 @@ export class GovernedActionApi {
42
65
  : payload) as T
43
66
  }
44
67
 
68
+ /**
69
+ * Requests raised against the Actions in this organization.
70
+ *
71
+ * The read half of a plane the CLI could only arm. `action list` says an
72
+ * Action is deployed and `action review` says the write path is active;
73
+ * neither says whether anything has ever run through it, or what happened
74
+ * when it did.
75
+ *
76
+ * Reads only. Invoking stays absent for the reason this module's header
77
+ * gives — both CLI credential kinds present a non-member principal, and the
78
+ * invoke check joins organization membership. The LIST gate is different:
79
+ * `actionRequest: ['read']`, which a key can hold.
80
+ *
81
+ * `nextCursor` rides on the envelope beside `data`, so this cannot go
82
+ * through `call` — that unwraps to `data` and would drop the cursor,
83
+ * silently capping every caller at one page.
84
+ */
85
+ async listRequests(query: { lifecycle?: string; limit?: number; cursor?: string } = {}): Promise<{
86
+ requests: ActionRequestSummary[]
87
+ nextCursor?: string
88
+ }> {
89
+ const params = new URLSearchParams()
90
+ if (query.lifecycle) params.set('lifecycle', query.lifecycle)
91
+ if (query.limit !== undefined) params.set('limit', String(query.limit))
92
+ if (query.cursor) params.set('cursor', query.cursor)
93
+ const suffix = params.size > 0 ? `?${params}` : ''
94
+
95
+ const response = await fetch(
96
+ `${this.apiUrl}/v1/blueprint/governed-actions/requests${suffix}`,
97
+ { headers: { authorization: `Bearer ${this.token}` } },
98
+ )
99
+ const text = await response.text()
100
+ let payload: unknown
101
+ try { payload = text ? JSON.parse(text) : null } catch { payload = null }
102
+
103
+ if (!response.ok) {
104
+ const body = payload as { message?: string; code?: string } | null
105
+ throw new CliError(body?.message ?? `${response.status} from /governed-actions/requests`, {
106
+ code: body?.code ?? 'FAILURE',
107
+ ...(response.status === 403
108
+ ? { hint: 'the credential needs actionRequest:read — mint a key that carries it' }
109
+ : {}),
110
+ })
111
+ }
112
+
113
+ const envelope = payload as { data?: ActionRequestSummary[]; nextCursor?: string } | null
114
+ return {
115
+ requests: envelope?.data ?? [],
116
+ ...(envelope?.nextCursor ? { nextCursor: envelope.nextCursor } : {}),
117
+ }
118
+ }
119
+
120
+ /** One request, with everything the surface records about how it resolved. */
121
+ getRequest(requestId: string): Promise<Record<string, unknown>> {
122
+ return this.call(`/v1/blueprint/governed-actions/requests/${encodeURIComponent(requestId)}`)
123
+ }
124
+
45
125
  /** The published Action: its definition and the digest a Binding pins. */
46
126
  publishedAction(apiName: string): Promise<{
47
127
  definition: PublishedActionDefinition
@@ -97,6 +97,47 @@ export class PlatformApi {
97
97
  return this.getList<unknown>('/v1/blueprint/link-types')
98
98
  }
99
99
 
100
+ /**
101
+ * Run an object-set query and return rows.
102
+ *
103
+ * The counterpart to nineteen authoring verbs that had none: a caller could
104
+ * create an object type, bind it to a dataset and publish a release without
105
+ * ever seeing a row come back, so a binding that resolved to nothing looked
106
+ * exactly like one that worked.
107
+ *
108
+ * `objectSet` is the expression tree, not a table name — `{ type: 'base',
109
+ * objectType }` is the whole of the simple case and the command builds it.
110
+ * Paging is cursor-based: hand `nextPageToken` back as `pageToken`. There is
111
+ * no offset form; the service refuses `page` rather than silently serving
112
+ * page 1 forever.
113
+ */
114
+ blueprintQuery(body: {
115
+ objectSet: unknown
116
+ select?: string[]
117
+ orderBy?: Array<{ property: string; dir: 'asc' | 'desc' }>
118
+ pageSize?: number
119
+ pageToken?: string
120
+ }) {
121
+ return this.client.request<{
122
+ rows?: Array<Record<string, unknown>>
123
+ nextPageToken?: string | null
124
+ }>('/v1/blueprint/query', { method: 'POST', body })
125
+ }
126
+
127
+ /**
128
+ * One object by primary key.
129
+ *
130
+ * Separate from `blueprintQuery` because it is the check a caller actually
131
+ * runs after a bind — "is this specific record reachable" — and expressing
132
+ * it as a filtered query means constructing a where-node to ask a question
133
+ * with a one-line answer.
134
+ */
135
+ blueprintInstance(apiName: string, pk: string) {
136
+ return this.get<unknown>(
137
+ `/v1/blueprint/object-types/${encodeURIComponent(apiName)}/instances/${encodeURIComponent(pk)}`,
138
+ )
139
+ }
140
+
100
141
  // ── Agents ────────────────────────────────────────────────────────────────
101
142
 
102
143
  agents() {
@@ -267,6 +308,217 @@ export class PlatformApi {
267
308
  return this.getList<unknown>('/v1/apps/catalog')
268
309
  }
269
310
 
311
+ /**
312
+ * Strip the SECOND envelope.
313
+ *
314
+ * The apps router answers `ok({ data: … })`, so after the SDK unwraps the
315
+ * outer `{ data, message, error }` a second `{ data }` is left. Reading a
316
+ * field off that layer silently yields undefined — which is how `plugin add`
317
+ * printed the success text for an OAuth install whose `authUrl` it had, and
318
+ * `plugin verify` rendered a one-row table containing a JSON blob.
319
+ */
320
+ private static inner<T>(body: unknown): T {
321
+ const wrapper = body as { data?: T } | null
322
+ return (wrapper && typeof wrapper === 'object' && 'data' in wrapper
323
+ ? (wrapper.data as T)
324
+ : (body as T))
325
+ }
326
+
327
+ async plugin(installId: string): Promise<Record<string, unknown>> {
328
+ const body = await this.get<unknown>(`/v1/apps/${encodeURIComponent(installId)}`)
329
+ return PlatformApi.inner<Record<string, unknown>>(body) ?? {}
330
+ }
331
+
332
+ /**
333
+ * Install a plugin.
334
+ *
335
+ * `kind` is the catalog entry, not a display name — `frontera plugin catalog`
336
+ * is where it comes from. An OAuth-backed install answers with an `authUrl`
337
+ * and lands unusable until a person opens it, which is why `plugin connect`
338
+ * stays reserved: the CLI can start that handshake and cannot finish it.
339
+ */
340
+ async createPluginInstall(body: {
341
+ kind: string
342
+ installName?: string
343
+ description?: string | null
344
+ config?: Record<string, unknown>
345
+ }): Promise<{ install?: Record<string, unknown>; authUrl?: string } & Record<string, unknown>> {
346
+ // `config` is REQUIRED by the route, not optional. Omitting it when the
347
+ // caller passed no --file answered a bare "Validation failed", so a plugin
348
+ // whose kind needs no configuration — every OAuth one, since the consent
349
+ // flow supplies it — could not be installed from the CLI at all.
350
+ const raw = await this.client.request<unknown>('/v1/apps/', {
351
+ method: 'POST',
352
+ body: { config: {}, ...body },
353
+ })
354
+ // Two shapes behind one envelope: `created({ data: { install, authUrl } })`
355
+ // when consent is pending, `created({ data: install })` when it is not. The
356
+ // caller reads `authUrl` to decide what to print, so both must land on the
357
+ // same level.
358
+ return PlatformApi.inner(raw) ?? {}
359
+ }
360
+
361
+ /** Rename, or replace stored configuration. */
362
+ updatePluginInstall(installId: string, body: { installName?: string; config?: Record<string, unknown> }) {
363
+ return this.client.request<unknown>(`/v1/apps/${encodeURIComponent(installId)}`, {
364
+ method: 'PUT',
365
+ body,
366
+ })
367
+ }
368
+
369
+ /**
370
+ * Policy, not configuration: whether the install is on, whether its Actions
371
+ * may run at all, and what a newly discovered capability defaults to.
372
+ */
373
+ patchPluginInstall(
374
+ installId: string,
375
+ body: {
376
+ enabled?: boolean
377
+ actionPolicy?: 'allow_all' | 'read_only' | 'custom'
378
+ newCapabilityDefault?: 'enabled' | 'disabled'
379
+ },
380
+ ) {
381
+ return this.client.request<unknown>(`/v1/apps/${encodeURIComponent(installId)}`, {
382
+ method: 'PATCH',
383
+ body,
384
+ })
385
+ }
386
+
387
+ deletePluginInstall(installId: string) {
388
+ return this.client.request<unknown>(`/v1/apps/${encodeURIComponent(installId)}`, {
389
+ method: 'DELETE',
390
+ })
391
+ }
392
+
393
+ /**
394
+ * Reachability, credentials and scopes.
395
+ *
396
+ * A failed check still answers 200 — the handler returns `ok(...)`
397
+ * unconditionally and the verdict is in the body, the same shape
398
+ * `dataset test-source` established.
399
+ */
400
+ async verifyPluginInstall(installId: string): Promise<Record<string, unknown>> {
401
+ const raw = await this.client.request<unknown>(
402
+ `/v1/apps/${encodeURIComponent(installId)}/verify`,
403
+ { method: 'POST' },
404
+ )
405
+ return PlatformApi.inner<Record<string, unknown>>(raw) ?? {}
406
+ }
407
+
408
+ /** Everything one install exposes, whether or not an agent holds it. */
409
+ pluginCapabilities(installId: string) {
410
+ return this.getList<unknown>(`/v1/apps/${encodeURIComponent(installId)}/capabilities`)
411
+ }
412
+
413
+ // ── Agent capabilities ────────────────────────────────────────────────────
414
+
415
+ /**
416
+ * What this agent may actually do.
417
+ *
418
+ * Reads the DRAFT when one exists and the live snapshot otherwise, which is
419
+ * the same rule the grant below writes under — so a list taken straight after
420
+ * a grant shows it, and `agent diff` is where the staged-vs-live difference
421
+ * is visible.
422
+ */
423
+ agentCapabilities(agentId: string) {
424
+ return this.getList<unknown>(`/v1/apps/agents/${encodeURIComponent(agentId)}/capabilities`)
425
+ }
426
+
427
+ /**
428
+ * Grant one capability to an agent.
429
+ *
430
+ * Two things this does that its name does not say. It STAGES onto the agent
431
+ * draft rather than writing live — nothing changes for the running agent
432
+ * until `frontera agent publish`. And it auto-binds the parent install if the
433
+ * agent did not already hold it, so a grant is never half-applied.
434
+ *
435
+ * Refused with a conflict when the capability is an Action and the install
436
+ * is set to `read_only`; that is a policy on the install, fixed with
437
+ * `frontera plugin policy`, not by retrying.
438
+ */
439
+ grantAgentCapability(agentId: string, capabilityId: string) {
440
+ return this.client.request<unknown>(
441
+ `/v1/apps/agents/${encodeURIComponent(agentId)}/capabilities/${encodeURIComponent(capabilityId)}`,
442
+ { method: 'POST' },
443
+ )
444
+ }
445
+
446
+ revokeAgentCapability(agentId: string, capabilityId: string) {
447
+ return this.client.request<unknown>(
448
+ `/v1/apps/agents/${encodeURIComponent(agentId)}/capabilities/${encodeURIComponent(capabilityId)}`,
449
+ { method: 'DELETE' },
450
+ )
451
+ }
452
+
453
+ /** Which installs the agent is bound to, with the account mode each uses. */
454
+ agentInstalls(agentId: string) {
455
+ return this.getList<unknown>(`/v1/apps/agents/${encodeURIComponent(agentId)}/installs`)
456
+ }
457
+
458
+ /**
459
+ * `agent_owned` uses one credential the workspace holds; `end_user` makes
460
+ * every caller connect their own. The choice is not reversible by retrying
461
+ * with the other value — it decides whose data the agent reaches.
462
+ */
463
+ bindAgentInstall(agentId: string, installId: string, accountMode: 'agent_owned' | 'end_user') {
464
+ return this.client.request<unknown>(
465
+ `/v1/apps/agents/${encodeURIComponent(agentId)}/installs/${encodeURIComponent(installId)}`,
466
+ { method: 'POST', body: { accountMode } },
467
+ )
468
+ }
469
+
470
+ unbindAgentInstall(agentId: string, installId: string) {
471
+ return this.client.request<unknown>(
472
+ `/v1/apps/agents/${encodeURIComponent(agentId)}/installs/${encodeURIComponent(installId)}`,
473
+ { method: 'DELETE' },
474
+ )
475
+ }
476
+
477
+ // ── Agent skills and versions ─────────────────────────────────────────────
478
+
479
+ /**
480
+ * Bind a workspace skill to an agent.
481
+ *
482
+ * The step that had no verb: `skill push` uploads a skill and nothing loaded
483
+ * it, so the only route from a bundle folder to a running agent went through
484
+ * `pack apply` or the Console.
485
+ */
486
+ attachAgentSkill(agentId: string, skillId: string) {
487
+ return this.client.request<unknown>(
488
+ `/v1/config/agents/${encodeURIComponent(agentId)}/workspace-skills/${encodeURIComponent(skillId)}`,
489
+ { method: 'POST' },
490
+ )
491
+ }
492
+
493
+ detachAgentSkill(agentId: string, skillId: string) {
494
+ return this.client.request<unknown>(
495
+ `/v1/config/agents/${encodeURIComponent(agentId)}/workspace-skills/${encodeURIComponent(skillId)}`,
496
+ { method: 'DELETE' },
497
+ )
498
+ }
499
+
500
+ /**
501
+ * Publish an earlier version's snapshot as a new one.
502
+ *
503
+ * Forward-only: it does not rewind history, it appends. `agent versions`
504
+ * listed what could be gone back to and nothing could go there, so the only
505
+ * recovery from a bad publish was to reconstruct the document by hand.
506
+ */
507
+ revertAgent(agentId: string, versionId: string, body: { acceptOrphanedKnowledge?: boolean } = {}) {
508
+ return this.client.request<unknown>(
509
+ `/v1/config/agents/${encodeURIComponent(agentId)}/versions/${encodeURIComponent(versionId)}/revert`,
510
+ { method: 'POST', body },
511
+ )
512
+ }
513
+
514
+ /** Re-run ingestion for a source that failed. Additive and safe to repeat. */
515
+ retryKnowledgeSource(baseId: string, sourceId: string) {
516
+ return this.client.request<unknown>(
517
+ `/v1/workspace-knowledge/${encodeURIComponent(baseId)}/sources/${encodeURIComponent(sourceId)}/retry`,
518
+ { method: 'POST', body: {} },
519
+ )
520
+ }
521
+
270
522
  // ── Knowledge ─────────────────────────────────────────────────────────────
271
523
 
272
524
  knowledgeBases(workspaceId: string) {
@@ -284,6 +536,47 @@ export class PlatformApi {
284
536
  return this.getList<unknown>(`/v1/workspace-knowledge/${encodeURIComponent(id)}/sources`)
285
537
  }
286
538
 
539
+ /**
540
+ * One knowledge base by id.
541
+ *
542
+ * `knowledge list` existed and `knowledge get` did not, so the only way to
543
+ * read back what `knowledge create` had just made was to list every base in
544
+ * the workspace and filter client-side.
545
+ */
546
+ async knowledgeBase(id: string): Promise<Record<string, unknown>> {
547
+ const body = await this.get<{ data?: Record<string, unknown> } | Record<string, unknown>>(
548
+ `/v1/workspace-knowledge/${encodeURIComponent(id)}`,
549
+ )
550
+ return ((body as { data?: Record<string, unknown> })?.data ?? body ?? {}) as Record<string, unknown>
551
+ }
552
+
553
+ /**
554
+ * Retrieval against a base — the read half of `knowledge upload`.
555
+ *
556
+ * Ingestion is asynchronous and a partial batch still exits 0, so "the
557
+ * upload succeeded" and "the corpus is searchable" are different claims and
558
+ * only this one can settle the second. Empty results minutes after an upload
559
+ * usually mean sources are still `processing`, not that retrieval is broken;
560
+ * the command says so rather than printing nothing.
561
+ */
562
+ async knowledgeSearch(
563
+ baseId: string,
564
+ body: { query: string; topK?: number; threshold?: number },
565
+ ): Promise<{ results: Array<Record<string, unknown>> }> {
566
+ const res = await this.client.request<unknown>(
567
+ `/v1/workspace-knowledge/${encodeURIComponent(baseId)}/search`,
568
+ { method: 'POST', body },
569
+ )
570
+ const payload = PlatformApi.inner<Record<string, unknown>>(res) ?? {}
571
+ // The route answers `{ results }` — named for the retrieval orchestrator it
572
+ // wraps, not for the chunk table underneath. An earlier version read
573
+ // `chunks` and therefore reported "no chunks matched" for every successful
574
+ // search; it went unnoticed because the first base tried was empty, which
575
+ // produces the identical output.
576
+ const rows = (payload.results ?? payload.chunks) as Array<Record<string, unknown>> | undefined
577
+ return { results: Array.isArray(rows) ? rows : [] }
578
+ }
579
+
287
580
  /**
288
581
  * Create a workspace knowledge base.
289
582
  *