@frontera-sdk/cli 1.44.1 → 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.
- package/README.md +65 -1
- package/package.json +4 -3
- package/src/api/automation-api.ts +15 -0
- package/src/api/dataset-api.ts +99 -0
- package/src/api/governed-action-api.ts +80 -0
- package/src/api/platform-api.ts +293 -0
- package/src/auth-verify.ts +105 -0
- package/src/binding-registry.ts +87 -0
- package/src/commands/action/deploy.ts +1 -0
- package/src/commands/action/grant.ts +1 -0
- package/src/commands/action/index-commands.ts +8 -0
- package/src/commands/action/prepare.ts +1 -0
- package/src/commands/action/requests.ts +111 -0
- package/src/commands/action/review.ts +1 -0
- package/src/commands/agent/index-commands.ts +189 -7
- package/src/commands/app/init.ts +1 -1
- package/src/commands/app/pull.ts +1 -1
- package/src/commands/auth/add.ts +145 -0
- package/src/commands/auth/current.ts +82 -0
- package/src/commands/auth/index-commands.ts +16 -0
- package/src/commands/auth/list.ts +71 -0
- package/src/commands/auth/remove.ts +80 -0
- package/src/commands/auth/use.ts +84 -0
- package/src/commands/auth/verify.ts +93 -0
- package/src/commands/automation/run.ts +41 -2
- package/src/commands/blueprint/query.ts +294 -0
- package/src/commands/capability/index-commands.ts +334 -0
- package/src/commands/dataset/index-commands.ts +103 -14
- package/src/commands/kit/doctor.ts +101 -0
- package/src/commands/kit/index-commands.ts +7 -0
- package/src/commands/kit/shared.ts +52 -0
- package/src/commands/kit/status.ts +92 -0
- package/src/commands/kit/sync.ts +106 -0
- package/src/commands/kit/vendor.ts +120 -0
- package/src/commands/knowledge/index-commands.ts +165 -0
- package/src/commands/login.ts +64 -84
- package/src/commands/plugin/index-commands.ts +284 -21
- package/src/commands/registry.ts +104 -1
- package/src/commands/setup.ts +248 -0
- package/src/commands/source/index-commands.ts +446 -0
- package/src/commands/types.ts +14 -0
- package/src/config.ts +197 -100
- package/src/credential-store.ts +273 -0
- package/src/dev-env.ts +3 -3
- package/src/exit.ts +29 -2
- package/src/flag-help.ts +65 -3
- package/src/fs-atomic.ts +44 -0
- package/src/harness.ts +155 -4
- package/src/kit.ts +419 -0
- package/src/main.ts +13 -1
- package/src/paths.ts +43 -0
- package/src/profile-migration.ts +101 -0
- package/src/profiles.ts +240 -0
- package/src/project-context.ts +178 -0
- package/src/prompt.ts +23 -0
- package/src/templates/next-app-files.ts +4 -1
- package/src/vendor/kit-assets.json +31 -0
- 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
|