@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.
Files changed (53) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +65 -0
  3. package/package.json +47 -0
  4. package/src/api/apps-api.ts +165 -0
  5. package/src/api/automation-api.ts +140 -0
  6. package/src/api/platform-api.ts +193 -0
  7. package/src/api/registry-api.ts +43 -0
  8. package/src/args.ts +108 -0
  9. package/src/commands/agent/compose.ts +155 -0
  10. package/src/commands/agent/index-commands.ts +348 -0
  11. package/src/commands/agent/resolve.ts +58 -0
  12. package/src/commands/app/add.ts +78 -0
  13. package/src/commands/app/deploy.ts +105 -0
  14. package/src/commands/app/init.ts +53 -0
  15. package/src/commands/app/list.ts +51 -0
  16. package/src/commands/app/promote.ts +31 -0
  17. package/src/commands/app/pull.ts +145 -0
  18. package/src/commands/app/save.ts +36 -0
  19. package/src/commands/app/shared.ts +25 -0
  20. package/src/commands/app/versions.ts +38 -0
  21. package/src/commands/automation/index-commands.ts +325 -0
  22. package/src/commands/blueprint/get.ts +160 -0
  23. package/src/commands/blueprint/list.ts +48 -0
  24. package/src/commands/blueprint/reserved.ts +40 -0
  25. package/src/commands/completion.ts +293 -0
  26. package/src/commands/init.ts +33 -0
  27. package/src/commands/knowledge/index-commands.ts +140 -0
  28. package/src/commands/login.ts +103 -0
  29. package/src/commands/plugin/index-commands.ts +112 -0
  30. package/src/commands/registry.ts +405 -0
  31. package/src/commands/skill/index-commands.ts +140 -0
  32. package/src/commands/types.ts +76 -0
  33. package/src/config.ts +142 -0
  34. package/src/context.ts +67 -0
  35. package/src/errors.ts +30 -0
  36. package/src/exit.ts +98 -0
  37. package/src/flag-help.ts +70 -0
  38. package/src/harness.ts +162 -0
  39. package/src/heal.ts +418 -0
  40. package/src/help.ts +128 -0
  41. package/src/main.ts +204 -0
  42. package/src/manifest.ts +80 -0
  43. package/src/output.ts +65 -0
  44. package/src/pack.ts +18 -0
  45. package/src/packaging.ts +116 -0
  46. package/src/project.ts +151 -0
  47. package/src/prompt.ts +48 -0
  48. package/src/registry.ts +62 -0
  49. package/src/secrets.ts +69 -0
  50. package/src/table.ts +47 -0
  51. package/src/tar.ts +73 -0
  52. package/src/template.ts +566 -0
  53. 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
+ }