@frontera-sdk/cli 0.1.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/LICENSE +202 -0
- package/README.md +65 -0
- package/package.json +47 -0
- package/src/api/apps-api.ts +165 -0
- package/src/api/automation-api.ts +140 -0
- package/src/api/platform-api.ts +193 -0
- package/src/api/registry-api.ts +43 -0
- package/src/args.ts +108 -0
- package/src/commands/agent/compose.ts +155 -0
- package/src/commands/agent/index-commands.ts +348 -0
- package/src/commands/agent/resolve.ts +58 -0
- package/src/commands/app/add.ts +78 -0
- package/src/commands/app/deploy.ts +105 -0
- package/src/commands/app/init.ts +53 -0
- package/src/commands/app/list.ts +51 -0
- package/src/commands/app/promote.ts +31 -0
- package/src/commands/app/pull.ts +145 -0
- package/src/commands/app/save.ts +36 -0
- package/src/commands/app/shared.ts +25 -0
- package/src/commands/app/versions.ts +38 -0
- package/src/commands/automation/index-commands.ts +325 -0
- package/src/commands/blueprint/get.ts +160 -0
- package/src/commands/blueprint/list.ts +48 -0
- package/src/commands/blueprint/reserved.ts +40 -0
- package/src/commands/completion.ts +293 -0
- package/src/commands/init.ts +33 -0
- package/src/commands/knowledge/index-commands.ts +140 -0
- package/src/commands/login.ts +103 -0
- package/src/commands/plugin/index-commands.ts +112 -0
- package/src/commands/registry.ts +405 -0
- package/src/commands/skill/index-commands.ts +140 -0
- package/src/commands/types.ts +76 -0
- package/src/config.ts +142 -0
- package/src/context.ts +67 -0
- package/src/errors.ts +30 -0
- package/src/exit.ts +98 -0
- package/src/flag-help.ts +70 -0
- package/src/harness.ts +162 -0
- package/src/heal.ts +418 -0
- package/src/help.ts +128 -0
- package/src/main.ts +204 -0
- package/src/manifest.ts +80 -0
- package/src/output.ts +65 -0
- package/src/pack.ts +18 -0
- package/src/packaging.ts +116 -0
- package/src/project.ts +151 -0
- package/src/prompt.ts +48 -0
- package/src/registry.ts +62 -0
- package/src/secrets.ts +69 -0
- package/src/table.ts +47 -0
- package/src/tar.ts +73 -0
- package/src/template.ts +566 -0
- package/src/vendor/sdk-sources.json +25 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import type { FlagSpec } from '../args'
|
|
2
|
+
import { UsageError } from '../errors'
|
|
3
|
+
import { describeFlag, flagUsage } from '../flag-help'
|
|
4
|
+
import { accent, definitions, dim, heading, terminalWidth, wrap } from '../help'
|
|
5
|
+
import type { Command, CommandMeta } from './types'
|
|
6
|
+
|
|
7
|
+
import { appInit } from './app/init'
|
|
8
|
+
import { appList } from './app/list'
|
|
9
|
+
import { appAdd } from './app/add'
|
|
10
|
+
import { appPull } from './app/pull'
|
|
11
|
+
import { appSave } from './app/save'
|
|
12
|
+
import { appDeploy } from './app/deploy'
|
|
13
|
+
import { appPromote } from './app/promote'
|
|
14
|
+
import { appVersions } from './app/versions'
|
|
15
|
+
import { blueprintList } from './blueprint/list'
|
|
16
|
+
import { blueprintGet } from './blueprint/get'
|
|
17
|
+
import { blueprintReserved } from './blueprint/reserved'
|
|
18
|
+
import { agentCommands } from './agent/index-commands'
|
|
19
|
+
import { skillCommands } from './skill/index-commands'
|
|
20
|
+
import { pluginCommands } from './plugin/index-commands'
|
|
21
|
+
import { knowledgeCommands } from './knowledge/index-commands'
|
|
22
|
+
import { automationCommands } from './automation/index-commands'
|
|
23
|
+
import { completionCommand } from './completion'
|
|
24
|
+
import { initCommand } from './init'
|
|
25
|
+
import { loginCommand } from './login'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Flags every command accepts. Declared once so a caller never has to wonder
|
|
29
|
+
* whether `--json` works here — it works everywhere.
|
|
30
|
+
*/
|
|
31
|
+
export const GLOBAL_FLAGS: FlagSpec = {
|
|
32
|
+
json: 'boolean',
|
|
33
|
+
quiet: 'boolean',
|
|
34
|
+
yes: 'boolean',
|
|
35
|
+
help: 'boolean',
|
|
36
|
+
'api-url': 'string',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Additionally accepted by any command that resolves a project. */
|
|
40
|
+
export const PROJECT_FLAGS: FlagSpec = { dir: 'string' }
|
|
41
|
+
|
|
42
|
+
export const COMMANDS: readonly Command[] = [
|
|
43
|
+
initCommand,
|
|
44
|
+
loginCommand,
|
|
45
|
+
completionCommand,
|
|
46
|
+
|
|
47
|
+
appInit,
|
|
48
|
+
appList,
|
|
49
|
+
appAdd,
|
|
50
|
+
appPull,
|
|
51
|
+
appSave,
|
|
52
|
+
appDeploy,
|
|
53
|
+
appPromote,
|
|
54
|
+
appVersions,
|
|
55
|
+
|
|
56
|
+
...agentCommands,
|
|
57
|
+
...skillCommands,
|
|
58
|
+
...pluginCommands,
|
|
59
|
+
...knowledgeCommands,
|
|
60
|
+
...automationCommands,
|
|
61
|
+
|
|
62
|
+
blueprintList,
|
|
63
|
+
blueprintGet,
|
|
64
|
+
...blueprintReserved,
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
export function findCommand(noun: string, verb: string | undefined): Command | null {
|
|
68
|
+
return (
|
|
69
|
+
COMMANDS.find((c) => c.meta.noun === noun && c.meta.verb === (verb ?? '')) ?? null
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function nouns(): string[] {
|
|
74
|
+
return [...new Set(COMMANDS.map((c) => c.meta.noun))]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* What each noun is for.
|
|
79
|
+
*
|
|
80
|
+
* Top-level help lists nouns before verbs, so the noun line has to carry its
|
|
81
|
+
* own meaning — otherwise the first thing a reader sees is `app`, `agent`,
|
|
82
|
+
* `plugin` with no way to tell which one holds what they came for.
|
|
83
|
+
*/
|
|
84
|
+
const NOUN_SUMMARY: Readonly<Record<string, string>> = {
|
|
85
|
+
app: 'Frontera Apps — scaffold, deploy and version a React app',
|
|
86
|
+
agent: 'Agents — read a configuration, stage a change, publish it',
|
|
87
|
+
skill: 'Workspace skills an agent loads at runtime',
|
|
88
|
+
plugin: 'Integrations and MCP servers connected to this workspace',
|
|
89
|
+
knowledge: 'Knowledge bases and the sources inside them',
|
|
90
|
+
automation: 'Automations — TypeScript deployed here, run on a schedule',
|
|
91
|
+
blueprint: 'The shared model of the organization — what an app can read',
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function nounSummary(noun: string): string {
|
|
95
|
+
return NOUN_SUMMARY[noun] ?? ''
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Short forms every command understands, plus the command's own. */
|
|
99
|
+
export function aliasesFor(meta: CommandMeta): Readonly<Record<string, string>> {
|
|
100
|
+
return { h: 'help', ...(meta.aliases ?? {}) }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function flagsFor(meta: CommandMeta): FlagSpec {
|
|
104
|
+
return {
|
|
105
|
+
...GLOBAL_FLAGS,
|
|
106
|
+
...(meta.needsProject || meta.optionalProject ? PROJECT_FLAGS : {}),
|
|
107
|
+
...meta.flags,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function usage(meta: CommandMeta): string {
|
|
112
|
+
const parts = ['frontera', meta.noun, meta.verb].filter(Boolean)
|
|
113
|
+
for (const a of meta.args) parts.push(a.required ? `<${a.name}>` : `[${a.name}]`)
|
|
114
|
+
return parts.join(' ')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The verb and its arguments, without the `frontera <noun>` prefix. */
|
|
118
|
+
function verbUsage(meta: CommandMeta): string {
|
|
119
|
+
const parts = [meta.verb].filter(Boolean)
|
|
120
|
+
for (const a of meta.args) parts.push(a.required ? `<${a.name}>` : `[${a.name}]`)
|
|
121
|
+
return parts.join(' ')
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const GLOBAL_SECTION = [
|
|
125
|
+
'',
|
|
126
|
+
heading('Global flags'),
|
|
127
|
+
...definitions(
|
|
128
|
+
Object.entries(GLOBAL_FLAGS).map(
|
|
129
|
+
([name, type]) => [flagUsage(name, type), describeFlag(name)] as const,
|
|
130
|
+
),
|
|
131
|
+
),
|
|
132
|
+
'',
|
|
133
|
+
heading('Environment'),
|
|
134
|
+
...definitions([
|
|
135
|
+
['FRONTERA_API_URL', 'Frontera API origin'],
|
|
136
|
+
['FRONTERA_TOKEN', 'workspace API key (sk-ws-…)'],
|
|
137
|
+
['NO_COLOR', 'set to disable colour'],
|
|
138
|
+
]),
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Help is GENERATED from the table, never written by hand.
|
|
143
|
+
*
|
|
144
|
+
* Hand-maintained help drifts, and drifted help is a wrong instruction handed
|
|
145
|
+
* straight to a model — which will act on it rather than notice. Generating it
|
|
146
|
+
* also means a command cannot ship undocumented: the summary and example are
|
|
147
|
+
* required fields.
|
|
148
|
+
*/
|
|
149
|
+
export function renderHelp(): string {
|
|
150
|
+
const lines: string[] = [
|
|
151
|
+
`${accent('frontera')} — author and deploy on the Frontera platform`,
|
|
152
|
+
'',
|
|
153
|
+
heading('Usage'),
|
|
154
|
+
...definitions([
|
|
155
|
+
['frontera <noun> <verb> [args] [flags]', ''],
|
|
156
|
+
['frontera --version', ''],
|
|
157
|
+
]),
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
// Setup first and ungrouped: these are the two commands someone runs before
|
|
161
|
+
// any noun makes sense.
|
|
162
|
+
const setup = COMMANDS.filter((c) => c.meta.verb === '')
|
|
163
|
+
if (setup.length > 0) {
|
|
164
|
+
lines.push('', heading('Getting started'))
|
|
165
|
+
lines.push(
|
|
166
|
+
...definitions(setup.map((c) => [accent(usage(c.meta)), c.meta.summary] as const)),
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const noun of nouns()) {
|
|
171
|
+
const group = COMMANDS.filter((c) => c.meta.noun === noun && c.meta.verb !== '')
|
|
172
|
+
if (group.length === 0) continue
|
|
173
|
+
|
|
174
|
+
lines.push('', `${heading(accent(`frontera ${noun}`))} ${dim(nounSummary(noun))}`.trimEnd())
|
|
175
|
+
lines.push(
|
|
176
|
+
...definitions(
|
|
177
|
+
group
|
|
178
|
+
.filter((c) => !c.meta.reserved)
|
|
179
|
+
.map((c) => [verbUsage(c.meta), c.meta.summary] as const),
|
|
180
|
+
{ indent: 4 },
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
// Reserved verbs are named but not itemised. Naming them stops a caller
|
|
185
|
+
// concluding they mistyped; itemising all fifteen of them at the top level
|
|
186
|
+
// buried the twenty commands that work.
|
|
187
|
+
const reserved = group.filter((c) => c.meta.reserved).map((c) => c.meta.verb)
|
|
188
|
+
if (reserved.length > 0) {
|
|
189
|
+
lines.push(` ${dim(`${reserved.join(', ')} — not available yet`)}`)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
lines.push(
|
|
194
|
+
...GLOBAL_SECTION,
|
|
195
|
+
'',
|
|
196
|
+
heading('Learn more'),
|
|
197
|
+
...definitions([
|
|
198
|
+
['frontera <noun> --help', 'every verb for one noun, including the reserved ones'],
|
|
199
|
+
['frontera <noun> <verb> --help', "one command's arguments and flags"],
|
|
200
|
+
['frontera help --json', 'the whole command table as data'],
|
|
201
|
+
]),
|
|
202
|
+
)
|
|
203
|
+
return lines.join('\n')
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Help for a whole noun — `frontera agent --help`, or a bare `frontera agent`.
|
|
208
|
+
*
|
|
209
|
+
* This was missing, and the CLI's own error hints pointed at it: a reserved
|
|
210
|
+
* verb tells the caller to "run `frontera <noun> --help`", which answered
|
|
211
|
+
* "unknown verb for agent: --help". A hint that leads nowhere is worse than
|
|
212
|
+
* no hint, because it costs a turn before the caller stops trusting them.
|
|
213
|
+
*/
|
|
214
|
+
export function renderNounHelp(noun: string): string {
|
|
215
|
+
const group = COMMANDS.filter((c) => c.meta.noun === noun)
|
|
216
|
+
const summary = nounSummary(noun)
|
|
217
|
+
const lines = [`${accent(`frontera ${noun}`)}${summary ? ` — ${summary}` : ''}`, '', heading('Commands')]
|
|
218
|
+
|
|
219
|
+
lines.push(
|
|
220
|
+
...definitions(
|
|
221
|
+
group.map(
|
|
222
|
+
(c) =>
|
|
223
|
+
[
|
|
224
|
+
c.meta.reserved ? dim(verbUsage(c.meta)) : verbUsage(c.meta),
|
|
225
|
+
c.meta.reserved ? `${c.meta.summary} (not available yet)` : c.meta.summary,
|
|
226
|
+
] as const,
|
|
227
|
+
),
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
lines.push(
|
|
232
|
+
'',
|
|
233
|
+
heading('Learn more'),
|
|
234
|
+
...definitions([
|
|
235
|
+
[`frontera ${noun} <verb> --help`, "one command's arguments and flags"],
|
|
236
|
+
[`frontera ${noun} --help --json`, 'these commands as data'],
|
|
237
|
+
]),
|
|
238
|
+
)
|
|
239
|
+
return lines.join('\n')
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function renderCommandHelp(meta: CommandMeta): string {
|
|
243
|
+
const width = terminalWidth()
|
|
244
|
+
const lines = [accent(usage(meta)), '', ...wrap(meta.summary, width)]
|
|
245
|
+
|
|
246
|
+
if (meta.reserved) {
|
|
247
|
+
lines.push('', ...wrap(`Not available yet: ${meta.reserved}`, width).map(dim))
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (meta.args.length > 0) {
|
|
251
|
+
lines.push('', heading('Arguments'))
|
|
252
|
+
lines.push(
|
|
253
|
+
...definitions(
|
|
254
|
+
meta.args.map(
|
|
255
|
+
(a) =>
|
|
256
|
+
[
|
|
257
|
+
a.required ? `<${a.name}>` : `[${a.name}]`,
|
|
258
|
+
a.required ? a.description : `${a.description} (optional)`,
|
|
259
|
+
] as const,
|
|
260
|
+
),
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const spec = flagsFor(meta)
|
|
266
|
+
// Command flags first, then the ones every command shares — what is specific
|
|
267
|
+
// to this command is what the reader came for.
|
|
268
|
+
const shared = new Set(Object.keys(GLOBAL_FLAGS))
|
|
269
|
+
const own = Object.keys(spec).filter((f) => !shared.has(f)).sort()
|
|
270
|
+
const short = Object.entries(aliasesFor(meta)).reduce<Record<string, string>>((acc, [s, long]) => {
|
|
271
|
+
acc[long] = s
|
|
272
|
+
return acc
|
|
273
|
+
}, {})
|
|
274
|
+
|
|
275
|
+
if (own.length > 0) {
|
|
276
|
+
lines.push('', heading('Flags'))
|
|
277
|
+
lines.push(
|
|
278
|
+
...definitions(
|
|
279
|
+
own.map((f) => [flagUsage(f, spec[f]!, short[f]), describeFlag(f)] as const),
|
|
280
|
+
),
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
lines.push('', heading('Examples'))
|
|
285
|
+
lines.push(...meta.examples.map((e) => ` ${e}`))
|
|
286
|
+
|
|
287
|
+
lines.push(...GLOBAL_SECTION)
|
|
288
|
+
return lines.join('\n')
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The command table as data.
|
|
293
|
+
*
|
|
294
|
+
* `--help` is the discovery surface, and an agent discovering it had to parse
|
|
295
|
+
* columns of prose to learn what exists. This is the same information without
|
|
296
|
+
* the layout: one call to `frontera help --json` returns every command, its
|
|
297
|
+
* arguments, its flags and whether it is available, so a harness can plan
|
|
298
|
+
* against the real surface instead of guessing at it.
|
|
299
|
+
*/
|
|
300
|
+
export interface CommandDescriptor {
|
|
301
|
+
noun: string
|
|
302
|
+
verb: string
|
|
303
|
+
usage: string
|
|
304
|
+
summary: string
|
|
305
|
+
available: boolean
|
|
306
|
+
unavailableReason?: string
|
|
307
|
+
args: Array<{ name: string; required: boolean; description: string }>
|
|
308
|
+
flags: Array<{ name: string; type: 'string' | 'boolean'; short?: string; description: string }>
|
|
309
|
+
examples: string[]
|
|
310
|
+
needsProject: boolean
|
|
311
|
+
requiresCredential: boolean
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function describeCommand(meta: CommandMeta): CommandDescriptor {
|
|
315
|
+
const spec = flagsFor(meta)
|
|
316
|
+
const short = Object.entries(aliasesFor(meta)).reduce<Record<string, string>>((acc, [s, long]) => {
|
|
317
|
+
acc[long] = s
|
|
318
|
+
return acc
|
|
319
|
+
}, {})
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
noun: meta.noun,
|
|
323
|
+
verb: meta.verb,
|
|
324
|
+
usage: usage(meta),
|
|
325
|
+
summary: meta.summary,
|
|
326
|
+
available: !meta.reserved,
|
|
327
|
+
...(meta.reserved ? { unavailableReason: meta.reserved } : {}),
|
|
328
|
+
args: meta.args.map((a) => ({ ...a })),
|
|
329
|
+
flags: Object.keys(spec)
|
|
330
|
+
.sort()
|
|
331
|
+
.map((name) => ({
|
|
332
|
+
name,
|
|
333
|
+
type: spec[name]!,
|
|
334
|
+
...(short[name] ? { short: short[name]! } : {}),
|
|
335
|
+
description: describeFlag(name),
|
|
336
|
+
})),
|
|
337
|
+
examples: [...meta.examples],
|
|
338
|
+
needsProject: Boolean(meta.needsProject),
|
|
339
|
+
requiresCredential: !meta.offline,
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function describeCommands(noun?: string): CommandDescriptor[] {
|
|
344
|
+
return COMMANDS.filter((c) => !noun || c.meta.noun === noun).map((c) => describeCommand(c.meta))
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Levenshtein distance, iterative with one row — enough for a typo check. */
|
|
348
|
+
function editDistance(a: string, b: string): number {
|
|
349
|
+
let previous = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
350
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
351
|
+
const current = [i]
|
|
352
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
353
|
+
current[j] = Math.min(
|
|
354
|
+
previous[j]! + 1,
|
|
355
|
+
current[j - 1]! + 1,
|
|
356
|
+
previous[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
previous = current
|
|
360
|
+
}
|
|
361
|
+
return previous[b.length]!
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The closest real name to what was typed, if anything is close enough.
|
|
366
|
+
*
|
|
367
|
+
* Listing every valid name is correct but slow to act on — `frontera app
|
|
368
|
+
* delpoy` should not require reading eight alternatives to find the one that
|
|
369
|
+
* differs by a swap. Threshold scales with length so short names do not match
|
|
370
|
+
* each other by accident: at distance 2, `get` and `set` would be neighbours.
|
|
371
|
+
*/
|
|
372
|
+
function nearest(typed: string, candidates: string[]): string | null {
|
|
373
|
+
const limit = typed.length <= 4 ? 1 : 2
|
|
374
|
+
let best: { name: string; distance: number } | null = null
|
|
375
|
+
|
|
376
|
+
for (const candidate of candidates) {
|
|
377
|
+
const distance = editDistance(typed.toLowerCase(), candidate.toLowerCase())
|
|
378
|
+
if (distance <= limit && (!best || distance < best.distance)) {
|
|
379
|
+
best = { name: candidate, distance }
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return best?.name ?? null
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function unknownCommand(noun: string, verb: string | undefined): UsageError {
|
|
386
|
+
const known = nouns()
|
|
387
|
+
if (!known.includes(noun)) {
|
|
388
|
+
const guess = nearest(noun, known)
|
|
389
|
+
return new UsageError(
|
|
390
|
+
`unknown command: ${noun}`,
|
|
391
|
+
guess
|
|
392
|
+
? `did you mean \`frontera ${guess}\`? — run \`frontera help\` for all of them`
|
|
393
|
+
: `run \`frontera help\` — available: ${known.join(', ')}`,
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const verbs = COMMANDS.filter((c) => c.meta.noun === noun).map((c) => c.meta.verb)
|
|
398
|
+
const guess = verb ? nearest(verb, verbs) : null
|
|
399
|
+
return new UsageError(
|
|
400
|
+
`unknown verb for ${noun}: ${verb ?? '(none)'}`,
|
|
401
|
+
guess
|
|
402
|
+
? `did you mean \`frontera ${noun} ${guess}\`? — \`frontera ${noun} --help\` lists them all`
|
|
403
|
+
: `run \`frontera ${noun} --help\` — ${noun} accepts: ${verbs.join(', ')}`,
|
|
404
|
+
)
|
|
405
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { PlatformApi } from '../../api/platform-api'
|
|
2
|
+
import { CliError, UsageError } from '../../errors'
|
|
3
|
+
import { table } from '../../table'
|
|
4
|
+
import type { Command } from '../types'
|
|
5
|
+
|
|
6
|
+
interface SkillRow {
|
|
7
|
+
id?: string
|
|
8
|
+
name?: string
|
|
9
|
+
displayName?: string
|
|
10
|
+
description?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `skill` is the PLATFORM resource — a workspace skill an agent binds and
|
|
15
|
+
* loads at runtime. It is not `.agents/skills/`, which teaches a coding
|
|
16
|
+
* harness and is written by `frontera init`, and it is not `frontera app add`,
|
|
17
|
+
* which copies registry source into a project. One word, one meaning.
|
|
18
|
+
*/
|
|
19
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
20
|
+
|
|
21
|
+
async function resolveSkillRef(client: PlatformApi, ref: string): Promise<string> {
|
|
22
|
+
if (UUID.test(ref)) return ref
|
|
23
|
+
|
|
24
|
+
const rows = (await client.workspaceSkills()) as SkillRow[]
|
|
25
|
+
const match =
|
|
26
|
+
rows.find((s) => (s.name ?? '').toLowerCase() === ref.toLowerCase()) ??
|
|
27
|
+
rows.find((s) => (s.displayName ?? '').toLowerCase() === ref.toLowerCase())
|
|
28
|
+
if (match?.id) return match.id
|
|
29
|
+
|
|
30
|
+
const near = rows
|
|
31
|
+
.filter((s) => `${s.name ?? ''} ${s.displayName ?? ''}`.toLowerCase().includes(ref.toLowerCase()))
|
|
32
|
+
.map((s) => s.name)
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
|
|
35
|
+
throw new CliError(`no skill named "${ref}"`, {
|
|
36
|
+
code: 'NOT_FOUND',
|
|
37
|
+
hint:
|
|
38
|
+
near.length > 0
|
|
39
|
+
? `did you mean ${near.slice(0, 3).join(', ')}?`
|
|
40
|
+
: 'run `frontera skill list` to see names and ids',
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const list: Command = {
|
|
45
|
+
meta: {
|
|
46
|
+
noun: 'skill',
|
|
47
|
+
verb: 'list',
|
|
48
|
+
args: [],
|
|
49
|
+
flags: {},
|
|
50
|
+
summary: 'List workspace skills',
|
|
51
|
+
examples: ['frontera skill list', 'frontera skill list --json'],
|
|
52
|
+
},
|
|
53
|
+
async run(ctx) {
|
|
54
|
+
const rows = (await new PlatformApi(ctx.apiUrl, ctx.token).workspaceSkills()) as SkillRow[]
|
|
55
|
+
return {
|
|
56
|
+
data: rows,
|
|
57
|
+
text:
|
|
58
|
+
rows.length === 0
|
|
59
|
+
? 'No workspace skills defined.'
|
|
60
|
+
: table(
|
|
61
|
+
['id', 'name', 'description'],
|
|
62
|
+
rows.map((s) => [s.id ?? '?', s.name ?? '', s.description ?? '']),
|
|
63
|
+
// Skill descriptions are trigger prose and run to hundreds of
|
|
64
|
+
// characters; uncapped they wrapped every row. --json has them whole.
|
|
65
|
+
[undefined, undefined, 70],
|
|
66
|
+
),
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const get: Command = {
|
|
72
|
+
meta: {
|
|
73
|
+
noun: 'skill',
|
|
74
|
+
verb: 'get',
|
|
75
|
+
args: [{ name: 'skill', required: true, description: 'skill name or id, from `frontera skill list`' }],
|
|
76
|
+
flags: {},
|
|
77
|
+
summary: 'Write one workspace skill as a document to stdout',
|
|
78
|
+
examples: ['frontera skill get kpi-performance-analysis', 'frontera skill get 8f1c… --json'],
|
|
79
|
+
},
|
|
80
|
+
async run(ctx) {
|
|
81
|
+
const ref = ctx.positional[0]
|
|
82
|
+
if (!ref) throw new UsageError('missing <skill>', 'frontera skill list — then pass a name or id')
|
|
83
|
+
|
|
84
|
+
const client = new PlatformApi(ctx.apiUrl, ctx.token)
|
|
85
|
+
// Name or id, like every other noun. `skill list` leads with the id but a
|
|
86
|
+
// reader reaches for the name, and an id-only argument made this the one
|
|
87
|
+
// command that refused the obvious input.
|
|
88
|
+
const id = await resolveSkillRef(client, ref)
|
|
89
|
+
const doc = (await client.workspaceSkill(id)) as SkillRow & {
|
|
90
|
+
body?: string
|
|
91
|
+
trigger?: string
|
|
92
|
+
keywords?: string[] | null
|
|
93
|
+
references?: Array<{ filename?: string }> | null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The body is markdown a person reads, and JSON-encoding it turns every
|
|
97
|
+
// newline into `\n` — technically the same content, unreadable in a
|
|
98
|
+
// terminal. Metadata first, then the body verbatim.
|
|
99
|
+
const meta = [
|
|
100
|
+
['name', doc.name],
|
|
101
|
+
['display name', doc.displayName],
|
|
102
|
+
['trigger', doc.trigger],
|
|
103
|
+
['keywords', doc.keywords?.join(', ')],
|
|
104
|
+
['references', doc.references?.map((r) => r.filename).filter(Boolean).join(', ')],
|
|
105
|
+
].filter(([, v]) => v) as Array<[string, string]>
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
data: doc,
|
|
109
|
+
text: [
|
|
110
|
+
...meta.map(([k, v]) => `${k.padEnd(14)}${v}`),
|
|
111
|
+
...(doc.description ? ['', doc.description] : []),
|
|
112
|
+
...(doc.body ? ['', '─'.repeat(60), '', doc.body.trimEnd()] : ['', '(no body)']),
|
|
113
|
+
].join('\n'),
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const RESERVED = 'skill writes are not available in this release; list and get are read-only'
|
|
119
|
+
|
|
120
|
+
const reserved: Command[] = (
|
|
121
|
+
[
|
|
122
|
+
['apply', 'Create or update a workspace skill from a document'],
|
|
123
|
+
['delete', 'Delete a workspace skill'],
|
|
124
|
+
] as const
|
|
125
|
+
).map(([verb, summary]) => ({
|
|
126
|
+
meta: {
|
|
127
|
+
noun: 'skill',
|
|
128
|
+
verb,
|
|
129
|
+
args: [],
|
|
130
|
+
flags: {},
|
|
131
|
+
summary,
|
|
132
|
+
examples: [`frontera skill ${verb} <id>`],
|
|
133
|
+
reserved: RESERVED,
|
|
134
|
+
},
|
|
135
|
+
async run(): Promise<never> {
|
|
136
|
+
throw new Error(RESERVED)
|
|
137
|
+
},
|
|
138
|
+
}))
|
|
139
|
+
|
|
140
|
+
export const skillCommands: Command[] = [list, get, ...reserved]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { FlagSpec } from '../args'
|
|
2
|
+
import type { AppProject } from '../context'
|
|
3
|
+
import type { Output } from '../output'
|
|
4
|
+
|
|
5
|
+
export interface ArgSpec {
|
|
6
|
+
name: string
|
|
7
|
+
required: boolean
|
|
8
|
+
description: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CommandMeta {
|
|
12
|
+
noun: string
|
|
13
|
+
verb: string
|
|
14
|
+
args: readonly ArgSpec[]
|
|
15
|
+
flags: FlagSpec
|
|
16
|
+
/** Short forms, e.g. `{ f: 'file' }` for `-f`. */
|
|
17
|
+
aliases?: Readonly<Record<string, string>>
|
|
18
|
+
summary: string
|
|
19
|
+
/** At least one, copy-pasteable. Agents read --help before documentation. */
|
|
20
|
+
examples: readonly string[]
|
|
21
|
+
/** Resolve an app project before running, and fail if there is none. */
|
|
22
|
+
needsProject?: boolean
|
|
23
|
+
/**
|
|
24
|
+
* Resolve a project when one is present, but run without it.
|
|
25
|
+
*
|
|
26
|
+
* For commands that touch the filesystem without requiring an existing
|
|
27
|
+
* project: `pull` hydrates a directory that is usually empty, and `add`
|
|
28
|
+
* lists the registry for someone deciding what to scaffold. Both accept
|
|
29
|
+
* `--dir` for the same reason.
|
|
30
|
+
*/
|
|
31
|
+
optionalProject?: boolean
|
|
32
|
+
/** No credential needed — scaffolding runs before a key exists. */
|
|
33
|
+
offline?: boolean
|
|
34
|
+
/**
|
|
35
|
+
* Present → the command is planned but unbuilt, and fails with this message.
|
|
36
|
+
* Reserved in the table rather than omitted: a caller reading "unknown
|
|
37
|
+
* command" concludes it mistyped and retries, while one reading the real
|
|
38
|
+
* reason picks another route.
|
|
39
|
+
*/
|
|
40
|
+
reserved?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CommandContext {
|
|
44
|
+
cwd: string
|
|
45
|
+
apiUrl: string
|
|
46
|
+
token: string
|
|
47
|
+
positional: string[]
|
|
48
|
+
flags: Record<string, string | boolean>
|
|
49
|
+
output: Output
|
|
50
|
+
/** Present when `meta.needsProject`. */
|
|
51
|
+
project: AppProject | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A command returns data and the text a person should see. It never writes to
|
|
56
|
+
* a stream and never sets an exit code — `output` decides which of the two to
|
|
57
|
+
* emit and where. That split is what makes `--json` uniform by construction.
|
|
58
|
+
*/
|
|
59
|
+
export interface CommandResult {
|
|
60
|
+
data: unknown
|
|
61
|
+
text: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface Command {
|
|
65
|
+
meta: CommandMeta
|
|
66
|
+
run(ctx: CommandContext): Promise<CommandResult>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function flagString(ctx: CommandContext, name: string): string | undefined {
|
|
70
|
+
const v = ctx.flags[name]
|
|
71
|
+
return typeof v === 'string' ? v : undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function flagBool(ctx: CommandContext, name: string): boolean {
|
|
75
|
+
return ctx.flags[name] === true
|
|
76
|
+
}
|