@frontera-sdk/cli 1.44.1 → 1.45.1

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 +431 -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 +60 -0
  58. package/src/vendor/sdk-sources.json +1 -1
@@ -0,0 +1,294 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ import { PlatformApi } from '../../api/platform-api'
4
+ import { CliError, UsageError } from '../../errors'
5
+ import { table } from '../../table'
6
+ import { flagString, type Command } from '../types'
7
+
8
+ /**
9
+ * Read rows through the ontology.
10
+ *
11
+ * The CLI could author an object type, bind it to a dataset revision and
12
+ * publish a release without ever seeing a row, so a binding that resolved to
13
+ * nothing was indistinguishable from one that worked — the authoring verbs all
14
+ * reported success either way. This is the verb that settles it.
15
+ *
16
+ * Reads resolve through the same granted slice as `blueprint list`, so what
17
+ * comes back here is exactly what an app or an agent will see at runtime.
18
+ * Nothing this returns is a preview of a wider set.
19
+ */
20
+
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)
24
+ }
25
+
26
+ /** `status:open` and `amount:>:1000` — the two forms worth typing by hand. */
27
+ export function parseWhere(raw: string): unknown {
28
+ const trimmed = raw.trim()
29
+ // A JSON object is passed through untouched: the shorthand covers one
30
+ // condition, and anything with an `and`/`or` in it needs the real grammar
31
+ // rather than a punctuation language invented here to avoid it.
32
+ if (trimmed.startsWith('{')) {
33
+ try {
34
+ return JSON.parse(trimmed)
35
+ } catch (e) {
36
+ throw new UsageError(
37
+ `--where is not valid JSON: ${(e as Error).message}`,
38
+ 'pass a where-node like \'{"property":"status","op":"eq","value":"open"}\', '
39
+ + 'or the shorthand <property>:<value>',
40
+ )
41
+ }
42
+ }
43
+
44
+ const parts = trimmed.split(':')
45
+ if (parts.length === 2) {
46
+ return { property: parts[0], op: 'eq', value: parts[1] }
47
+ }
48
+ if (parts.length === 3) {
49
+ return { property: parts[0], op: parts[1], value: parts[2] }
50
+ }
51
+ throw new UsageError(
52
+ `cannot read --where "${raw}"`,
53
+ 'use <property>:<value>, <property>:<op>:<value>, or a JSON where-node',
54
+ )
55
+ }
56
+
57
+ export function parseOrder(raw: string): Array<{ property: string; dir: 'asc' | 'desc' }> {
58
+ return raw.split(',').map((clause) => {
59
+ const [property, dir = 'asc'] = clause.trim().split(':')
60
+ if (!property) throw new UsageError(`cannot read --order "${raw}"`, 'use <property>:asc or <property>:desc')
61
+ if (dir !== 'asc' && dir !== 'desc') {
62
+ throw new UsageError(`--order direction must be asc or desc, not "${dir}"`, `try ${property}:desc`)
63
+ }
64
+ return { property, dir }
65
+ })
66
+ }
67
+
68
+ /**
69
+ * Give the shorthand's value the type the property actually has.
70
+ *
71
+ * A flag is always a string, and the query compiler is strictly typed:
72
+ * `--where salesIdr:gt:9000000` reached it as `"9000000"` and came back
73
+ * "expected a finite number", so the shorthand could not filter a numeric
74
+ * column at all.
75
+ *
76
+ * Driven by the declared `dataType`, never by how the value LOOKS. This
77
+ * ontology has `cityCode` — a string whose every value parses as a number
78
+ * (`52.01`) — so guessing from the literal would have silently broken equality
79
+ * on it while appearing to fix the numeric case.
80
+ *
81
+ * An unknown property is left as written: the service names it better than a
82
+ * guess here would, and a property that is absent is the caller's real error.
83
+ */
84
+ export function coerceWhereValue(value: unknown, dataType: string | undefined): unknown {
85
+ if (typeof value !== 'string') return value
86
+ if (dataType === 'number' || dataType === 'integer') {
87
+ const n = Number(value)
88
+ return Number.isFinite(n) ? n : value
89
+ }
90
+ if (dataType === 'boolean') {
91
+ if (value === 'true') return true
92
+ if (value === 'false') return false
93
+ }
94
+ return value
95
+ }
96
+
97
+ /** `{ property, op, value }` from the shorthand, retyped against the ontology. */
98
+ export function typeWhereNode(node: unknown, types: Map<string, string>): unknown {
99
+ const condition = node as { property?: string; value?: unknown }
100
+ if (!condition || typeof condition !== 'object' || typeof condition.property !== 'string') return node
101
+ if (!('value' in condition)) return node
102
+ return { ...condition, value: coerceWhereValue(condition.value, types.get(condition.property)) }
103
+ }
104
+
105
+ export function parseLimit(raw: string): number {
106
+ const n = Number(raw)
107
+ // NaN would reach the service as `pageSize: null` and be ignored, returning
108
+ // the default page and reporting nothing wrong — so it is refused here.
109
+ if (!Number.isInteger(n) || n < 1) {
110
+ throw new UsageError(`--limit must be a positive whole number, not "${raw}"`, 'try --limit 20')
111
+ }
112
+ return n
113
+ }
114
+
115
+ /** A cell a terminal can show: objects and arrays collapse rather than sprawl. */
116
+ function cell(value: unknown): string {
117
+ if (value === null || value === undefined) return ''
118
+ if (typeof value === 'object') return JSON.stringify(value)
119
+ return String(value)
120
+ }
121
+
122
+ export const blueprintQuery: Command = {
123
+ meta: {
124
+ noun: 'blueprint',
125
+ verb: 'query',
126
+ args: [
127
+ {
128
+ name: 'apiName',
129
+ required: false,
130
+ description: 'object type to read, from `frontera blueprint list`; omit when using --file',
131
+ },
132
+ ],
133
+ flags: {
134
+ select: 'string',
135
+ where: 'string',
136
+ order: 'string',
137
+ limit: 'string',
138
+ 'page-token': 'string',
139
+ file: 'string',
140
+ },
141
+ aliases: { f: 'file' },
142
+ summary: 'Read rows of an object type through the ontology',
143
+ examples: [
144
+ 'frontera blueprint query Invoice --limit 5',
145
+ 'frontera blueprint query Invoice --where status:open --select id,amount',
146
+ 'frontera blueprint query Invoice --order amount:desc --limit 20',
147
+ 'frontera blueprint query --file ./query.json --json',
148
+ ],
149
+ },
150
+
151
+ async run(ctx) {
152
+ const apiName = ctx.positional[0]
153
+ const file = flagString(ctx, 'file')
154
+
155
+ if (!apiName && !file) {
156
+ throw new UsageError(
157
+ 'missing <apiName>',
158
+ 'frontera blueprint list — then `frontera blueprint query <apiName>`',
159
+ )
160
+ }
161
+
162
+ // `--file` carries a whole QueryRequest, which is the only way to express
163
+ // a union, a searchAround or a nested boolean filter. The flags build the
164
+ // common case; they do not try to become the grammar.
165
+ let body: Parameters<PlatformApi['blueprintQuery']>[0]
166
+ if (file) {
167
+ const raw = file === '-' ? readFileSync(0, 'utf8') : readFileSync(file, 'utf8')
168
+ let parsed: unknown
169
+ try {
170
+ parsed = JSON.parse(raw)
171
+ } catch (e) {
172
+ throw new UsageError(
173
+ `${file === '-' ? 'stdin' : file} is not valid JSON: ${(e as Error).message}`,
174
+ 'the file holds a QueryRequest: { "objectSet": { "type": "base", "objectType": "…" } }',
175
+ )
176
+ }
177
+ const doc = parsed as Record<string, unknown>
178
+ if (!doc.objectSet) {
179
+ throw new UsageError(
180
+ 'the query document has no `objectSet`',
181
+ 'the minimum is { "objectSet": { "type": "base", "objectType": "Invoice" } }',
182
+ )
183
+ }
184
+ body = doc as typeof body
185
+ } else {
186
+ const where = flagString(ctx, 'where')
187
+ let node = where ? parseWhere(where) : null
188
+
189
+ // One extra read, and only when the shorthand is used: the flag carries
190
+ // a string and the compiler is typed, so the property's declared type is
191
+ // what makes `--where salesIdr:gt:9000000` a number rather than a 400.
192
+ // A JSON where-node is already typed by whoever wrote it, and is left
193
+ // exactly as given.
194
+ if (node && where && !where.trim().startsWith('{')) {
195
+ const shape = (await api(ctx).blueprintObjectType(apiName!)) as {
196
+ properties?: Array<{ apiName?: string; dataType?: string }>
197
+ }
198
+ const types = new Map<string, string>()
199
+ for (const p of shape?.properties ?? []) {
200
+ if (p.apiName && p.dataType) types.set(p.apiName, p.dataType)
201
+ }
202
+ node = typeWhereNode(node, types)
203
+ }
204
+
205
+ body = {
206
+ objectSet: node
207
+ ? { type: 'filter', objectSet: { type: 'base', objectType: apiName }, where: node }
208
+ : { type: 'base', objectType: apiName },
209
+ }
210
+ }
211
+
212
+ const select = flagString(ctx, 'select')
213
+ if (select) body.select = select.split(',').map((s) => s.trim()).filter(Boolean)
214
+ const order = flagString(ctx, 'order')
215
+ if (order) body.orderBy = parseOrder(order)
216
+ const limit = flagString(ctx, 'limit')
217
+ if (limit) body.pageSize = parseLimit(limit)
218
+ const pageToken = flagString(ctx, 'page-token')
219
+ if (pageToken) body.pageToken = pageToken
220
+
221
+ const result = await api(ctx).blueprintQuery(body)
222
+ const rows = result.rows ?? []
223
+
224
+ if (rows.length === 0) {
225
+ return {
226
+ data: result,
227
+ // An empty result after a successful bind is the confusing case, so it
228
+ // names the two things that produce one rather than printing "0 rows".
229
+ text:
230
+ 'No rows.\n'
231
+ + ' The object type may have no dataset binding yet, or the filter matched nothing.\n'
232
+ + ` Check the binding with \`frontera blueprint get ${apiName ?? '<apiName>'}\`.`,
233
+ }
234
+ }
235
+
236
+ // Columns come from the first row: `select` fixes them when it is given,
237
+ // and without it the service decides, so echoing the row's own keys is the
238
+ // only ordering that matches what came back.
239
+ const columns = Object.keys(rows[0]!)
240
+ const more = result.nextPageToken
241
+ ? `\n\nMore rows. Continue with --page-token ${result.nextPageToken}`
242
+ : ''
243
+
244
+ return {
245
+ data: result,
246
+ text:
247
+ table(columns, rows.map((r) => columns.map((c) => cell(r[c]))))
248
+ + `\n\n${rows.length} row${rows.length === 1 ? '' : 's'}.${more}`,
249
+ }
250
+ },
251
+ }
252
+
253
+ export const blueprintInstance: Command = {
254
+ meta: {
255
+ noun: 'blueprint',
256
+ verb: 'instance',
257
+ args: [
258
+ { name: 'apiName', required: true, description: 'object type, from `frontera blueprint list`' },
259
+ { name: 'pk', required: true, description: 'primary key value of the record to read' },
260
+ ],
261
+ flags: {},
262
+ summary: 'Read one object by primary key',
263
+ examples: ['frontera blueprint instance Invoice INV-1042', 'frontera blueprint instance Invoice INV-1042 --json'],
264
+ },
265
+
266
+ async run(ctx) {
267
+ const [apiName, pk] = ctx.positional
268
+ if (!apiName) throw new UsageError('missing <apiName>', 'frontera blueprint list — then pass an api name')
269
+ if (!pk) {
270
+ throw new UsageError(
271
+ 'missing <pk>',
272
+ `frontera blueprint query ${apiName} --limit 5 — to see primary keys`,
273
+ )
274
+ }
275
+
276
+ const instance = await api(ctx).blueprintInstance(apiName, pk)
277
+ if (!instance) {
278
+ throw new CliError(`no ${apiName} with primary key "${pk}"`, {
279
+ code: 'NOT_FOUND',
280
+ hint: `frontera blueprint query ${apiName} --limit 5 — to see which keys exist`,
281
+ })
282
+ }
283
+
284
+ const fields = instance as Record<string, unknown>
285
+ return {
286
+ data: instance,
287
+ text: table(
288
+ ['property', 'value'],
289
+ Object.entries(fields).map(([k, v]) => [k, cell(v)]),
290
+ [undefined, 80],
291
+ ),
292
+ }
293
+ },
294
+ }
@@ -0,0 +1,334 @@
1
+ import { PlatformApi } from '../../api/platform-api'
2
+ import { CliError, UsageError } from '../../errors'
3
+ import { table } from '../../table'
4
+ import { resolveAgentRef } from '../agent/resolve'
5
+ import { type Command } from '../types'
6
+
7
+ /**
8
+ * What an agent may actually do.
9
+ *
10
+ * `agent apply` writes prompts, models, knowledge and skills, and stops here.
11
+ * The capability graph — which plugin operations an agent is allowed to call —
12
+ * was Console-only, so "configure an agent from a repo" was never true: the
13
+ * half that decides what the agent can DO could not be scripted at all.
14
+ *
15
+ * Its own noun rather than `agent capability …` because the registry is
16
+ * noun-and-verb, and because a capability is addressed by its own id: it
17
+ * belongs to an INSTALL and is granted TO an agent, so neither noun owns it.
18
+ *
19
+ * The one thing to know before using any of this: a grant STAGES onto the
20
+ * agent draft. Nothing reaches the running agent until `frontera agent
21
+ * publish`, which is the same rule `agent apply` follows and the reason both
22
+ * are safe to run against a live agent.
23
+ */
24
+
25
+ /**
26
+ * Two endpoints, two shapes for the same thing.
27
+ *
28
+ * `/apps/:installId/capabilities` answers a catalog row — `displayName`,
29
+ * `name`, `kind`. `/apps/agents/:id/capabilities` answers a BINDING joined to
30
+ * that row and prefixes every borrowed field: `capabilityDisplayName`,
31
+ * `capabilityKind`. Reading only the short names printed a uuid where the name
32
+ * belongs and left `kind` blank, which is how this was found — by running it.
33
+ */
34
+ interface CapabilityRow {
35
+ id?: string
36
+ capabilityId?: string
37
+ name?: string
38
+ displayName?: string
39
+ kind?: string
40
+ capabilityName?: string
41
+ capabilityDisplayName?: string
42
+ capabilityKind?: string
43
+ installId?: string
44
+ installName?: string
45
+ catalogKind?: string
46
+ accountMode?: string
47
+ requiresApproval?: boolean
48
+ requiresApprovalOverride?: boolean | null
49
+ }
50
+
51
+ function rowId(row: CapabilityRow): string {
52
+ return row.capabilityId ?? row.id ?? ''
53
+ }
54
+
55
+ function rowLabel(row: CapabilityRow): string {
56
+ return row.capabilityDisplayName ?? row.displayName
57
+ ?? row.capabilityName ?? row.name ?? rowId(row)
58
+ }
59
+
60
+ function rowKind(row: CapabilityRow): string {
61
+ return row.capabilityKind ?? row.kind ?? ''
62
+ }
63
+
64
+ const list: Command = {
65
+ meta: {
66
+ noun: 'capability',
67
+ verb: 'list',
68
+ args: [{ name: 'agent', required: true, description: 'agent id or slug' }],
69
+ flags: {},
70
+ summary: 'List the capabilities one agent holds',
71
+ examples: ['frontera capability list support-bot', 'frontera capability list support-bot --json'],
72
+ },
73
+ async run(ctx) {
74
+ const ref = ctx.positional[0]
75
+ if (!ref) throw new UsageError('missing <agent>', 'frontera agent list — then pass an id or slug')
76
+
77
+ const api = new PlatformApi(ctx.apiUrl, ctx.token)
78
+ const agentId = await resolveAgentRef(api, ref)
79
+ const rows = (await api.agentCapabilities(agentId)) as CapabilityRow[]
80
+
81
+ if (rows.length === 0) {
82
+ return {
83
+ data: rows,
84
+ // Not an error state, and the most common one: an agent with no
85
+ // capabilities can still answer questions, it just cannot act.
86
+ text:
87
+ `${ref} holds no capabilities — it can read and answer, but cannot call a plugin.\n`
88
+ + ' `frontera capability available <plugin>` lists what an install offers.',
89
+ }
90
+ }
91
+
92
+ return {
93
+ data: rows,
94
+ text: table(
95
+ ['capability', 'kind', 'install', 'mode', 'id'],
96
+ rows.map((r) => [
97
+ rowLabel(r),
98
+ rowKind(r),
99
+ r.installName ?? r.catalogKind ?? r.installId ?? '',
100
+ r.accountMode ?? '',
101
+ rowId(r),
102
+ ]),
103
+ [34, 10, 24, 12, undefined],
104
+ ),
105
+ }
106
+ },
107
+ }
108
+
109
+ const available: Command = {
110
+ meta: {
111
+ noun: 'capability',
112
+ verb: 'available',
113
+ args: [{ name: 'plugin', required: true, description: 'plugin install id, from `frontera plugin list`' }],
114
+ flags: {},
115
+ summary: 'List the capabilities one installed plugin offers',
116
+ examples: ['frontera capability available <installId>'],
117
+ },
118
+ async run(ctx) {
119
+ const installId = ctx.positional[0]
120
+ if (!installId) throw new UsageError('missing <plugin>', 'frontera plugin list — then pass an install id')
121
+
122
+ const rows = (await new PlatformApi(ctx.apiUrl, ctx.token)
123
+ .pluginCapabilities(installId)) as CapabilityRow[]
124
+
125
+ return {
126
+ data: rows,
127
+ text: rows.length === 0
128
+ ? 'This install exposes no capabilities.\n'
129
+ + ' `frontera plugin verify <installId>` — an unconnected install discovers none.'
130
+ : table(
131
+ ['capability', 'kind', 'id'],
132
+ rows.map((r) => [rowLabel(r), rowKind(r), rowId(r)]),
133
+ [40, 12, undefined],
134
+ ),
135
+ }
136
+ },
137
+ }
138
+
139
+ const grant: Command = {
140
+ meta: {
141
+ noun: 'capability',
142
+ verb: 'grant',
143
+ args: [
144
+ { name: 'agent', required: true, description: 'agent id or slug' },
145
+ { name: 'capability', required: true, description: 'capability id, from `frontera capability available`' },
146
+ ],
147
+ flags: {},
148
+ summary: 'Give an agent one capability — staged onto its draft',
149
+ examples: ['frontera capability grant support-bot <capabilityId>'],
150
+ },
151
+ async run(ctx) {
152
+ const [ref, capabilityId] = ctx.positional
153
+ if (!ref) throw new UsageError('missing <agent>', 'frontera agent list — then pass an id or slug')
154
+ if (!capabilityId) {
155
+ throw new UsageError(
156
+ 'missing <capability>',
157
+ 'frontera capability available <installId> — to see capability ids',
158
+ )
159
+ }
160
+
161
+ const api = new PlatformApi(ctx.apiUrl, ctx.token)
162
+ const agentId = await resolveAgentRef(api, ref)
163
+ const result = await api.grantAgentCapability(agentId, capabilityId)
164
+
165
+ return {
166
+ data: result,
167
+ // Both facts stated every time. The staging is why nothing appears to
168
+ // happen to the live agent, and the auto-bind is why an install the
169
+ // caller never mentioned shows up in `agent diff`.
170
+ text:
171
+ `Granted to the ${ref} draft.\n`
172
+ + ' The parent install was bound too, if it was not already.\n\n'
173
+ + 'Next:\n'
174
+ + ` frontera agent diff ${ref}\n`
175
+ + ` frontera agent publish ${ref} # nothing reaches the running agent until this`,
176
+ }
177
+ },
178
+ }
179
+
180
+ const revoke: Command = {
181
+ meta: {
182
+ noun: 'capability',
183
+ verb: 'revoke',
184
+ args: [
185
+ { name: 'agent', required: true, description: 'agent id or slug' },
186
+ { name: 'capability', required: true, description: 'capability id, from `frontera capability list`' },
187
+ ],
188
+ flags: {},
189
+ summary: 'Take one capability away from an agent — staged onto its draft',
190
+ examples: ['frontera capability revoke support-bot <capabilityId>'],
191
+ },
192
+ async run(ctx) {
193
+ const [ref, capabilityId] = ctx.positional
194
+ if (!ref) throw new UsageError('missing <agent>', 'frontera agent list — then pass an id or slug')
195
+ if (!capabilityId) {
196
+ throw new UsageError('missing <capability>', `frontera capability list ${ref}`)
197
+ }
198
+
199
+ const api = new PlatformApi(ctx.apiUrl, ctx.token)
200
+ const agentId = await resolveAgentRef(api, ref)
201
+ const result = await api.revokeAgentCapability(agentId, capabilityId)
202
+
203
+ return {
204
+ data: result,
205
+ text:
206
+ `Revoked on the ${ref} draft.\n`
207
+ + ' The install binding is left in place — revoking one capability is not uninstalling.\n\n'
208
+ + `Publish it with \`frontera agent publish ${ref}\`.`,
209
+ }
210
+ },
211
+ }
212
+
213
+ const installs: Command = {
214
+ meta: {
215
+ noun: 'capability',
216
+ verb: 'installs',
217
+ args: [{ name: 'agent', required: true, description: 'agent id or slug' }],
218
+ flags: {},
219
+ summary: 'List the plugin installs an agent is bound to',
220
+ examples: ['frontera capability installs support-bot'],
221
+ },
222
+ async run(ctx) {
223
+ const ref = ctx.positional[0]
224
+ if (!ref) throw new UsageError('missing <agent>', 'frontera agent list — then pass an id or slug')
225
+
226
+ const api = new PlatformApi(ctx.apiUrl, ctx.token)
227
+ const rows = (await api.agentInstalls(await resolveAgentRef(api, ref))) as Array<{
228
+ install?: { installName?: string }
229
+ catalog?: { name?: string; kind?: string }
230
+ accountMode?: string
231
+ enabled?: boolean
232
+ agentOwnedConnected?: boolean
233
+ installId?: string
234
+ }>
235
+
236
+ return {
237
+ data: rows,
238
+ text: rows.length === 0
239
+ ? `${ref} is bound to no plugin installs.`
240
+ : table(
241
+ // `install` and `catalog` are NESTED here, unlike every other row
242
+ // this noun renders — reading the name off the top level printed
243
+ // "?" for every install.
244
+ ['install', 'mode', 'connected', 'id'],
245
+ rows.map((r) => [
246
+ String(r.install?.installName ?? r.catalog?.name ?? r.catalog?.kind ?? '?'),
247
+ String(r.accountMode ?? ''),
248
+ // `enabled` is the binding switch; whether the shared credential
249
+ // actually resolved is the thing that decides if a call works.
250
+ String(r.enabled === false ? 'disabled' : (r.agentOwnedConnected ?? '')),
251
+ String(r.installId ?? ''),
252
+ ]),
253
+ [30, 14, 11, undefined],
254
+ ),
255
+ }
256
+ },
257
+ }
258
+
259
+ const bind: Command = {
260
+ meta: {
261
+ noun: 'capability',
262
+ verb: 'bind',
263
+ args: [
264
+ { name: 'agent', required: true, description: 'agent id or slug' },
265
+ { name: 'plugin', required: true, description: 'plugin install id, from `frontera plugin list`' },
266
+ ],
267
+ flags: { 'end-user': 'boolean' },
268
+ summary: 'Bind a whole plugin install to an agent, without granting capabilities',
269
+ examples: [
270
+ 'frontera capability bind support-bot <installId>',
271
+ 'frontera capability bind support-bot <installId> --end-user',
272
+ ],
273
+ },
274
+ async run(ctx) {
275
+ const [ref, installId] = ctx.positional
276
+ if (!ref) throw new UsageError('missing <agent>', 'frontera agent list — then pass an id or slug')
277
+ if (!installId) throw new UsageError('missing <plugin>', 'frontera plugin list')
278
+
279
+ // Not a preference. `agent_owned` reaches data through one credential the
280
+ // workspace holds; `end_user` makes every caller connect their own, and
281
+ // switching later does not migrate anything already connected.
282
+ const accountMode = ctx.flags['end-user'] === true ? 'end_user' : 'agent_owned'
283
+
284
+ const api = new PlatformApi(ctx.apiUrl, ctx.token)
285
+ const agentId = await resolveAgentRef(api, ref)
286
+ const result = await api.bindAgentInstall(agentId, installId, accountMode)
287
+
288
+ return {
289
+ data: result,
290
+ text:
291
+ `Bound ${installId} to ${ref} as ${accountMode}.\n`
292
+ + ' Binding grants nothing on its own — `frontera capability grant` is what lets the\n'
293
+ + ' agent call an operation.',
294
+ }
295
+ },
296
+ }
297
+
298
+ const unbind: Command = {
299
+ meta: {
300
+ noun: 'capability',
301
+ verb: 'unbind',
302
+ args: [
303
+ { name: 'agent', required: true, description: 'agent id or slug' },
304
+ { name: 'plugin', required: true, description: 'plugin install id' },
305
+ ],
306
+ flags: {},
307
+ summary: 'Unbind a plugin install from an agent, with the capabilities it carried',
308
+ examples: ['frontera capability unbind support-bot <installId>'],
309
+ },
310
+ async run(ctx) {
311
+ const [ref, installId] = ctx.positional
312
+ if (!ref) throw new UsageError('missing <agent>', 'frontera agent list — then pass an id or slug')
313
+ if (!installId) throw new UsageError('missing <plugin>', `frontera capability installs ${ref}`)
314
+
315
+ const api = new PlatformApi(ctx.apiUrl, ctx.token)
316
+ const agentId = await resolveAgentRef(api, ref)
317
+ const result = await api.unbindAgentInstall(agentId, installId).catch((e: unknown) => {
318
+ throw new CliError((e as Error).message, {
319
+ code: 'FAILURE',
320
+ hint: `frontera capability installs ${ref} — to see what is bound`,
321
+ })
322
+ })
323
+
324
+ return {
325
+ data: result,
326
+ text: `Unbound ${installId} from ${ref}. Its capabilities went with it.`,
327
+ }
328
+ },
329
+ }
330
+
331
+ export const capabilityCommands: Command[] = [list, available, installs, grant, revoke, bind, unbind]
332
+
333
+ /** Exported for the shape test — the two projections must both resolve. */
334
+ export const rowLabelForTest = rowLabel