@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,160 @@
1
+ import { PlatformApi } from '../../api/platform-api'
2
+ import { CliError, UsageError } from '../../errors'
3
+ import type { Command } from '../types'
4
+
5
+ interface Property {
6
+ apiName?: string
7
+ displayName?: string
8
+ propertyType?: string
9
+ dataType?: string
10
+ }
11
+
12
+ interface Link {
13
+ apiName?: string
14
+ fromObjectTypeId?: string
15
+ toObjectTypeId?: string
16
+ cardinality?: string
17
+ }
18
+
19
+ interface Metric {
20
+ apiName?: string
21
+ displayName?: string
22
+ objectTypeId?: string
23
+ }
24
+
25
+ interface ObjectTypeRow {
26
+ apiName?: string
27
+ displayName?: string
28
+ description?: string | null
29
+ }
30
+
31
+ /**
32
+ * Resolve what the caller typed to a real api name.
33
+ *
34
+ * Api names are capitalised (`City`, `SupportTicket`) and the service matches
35
+ * them exactly, so `city` returned "object type with ID city not found" — an
36
+ * error that names a different concept than the argument does, and does not
37
+ * say the one thing that would fix it.
38
+ *
39
+ * The exact name is tried first, so the common path stays one request. Only a
40
+ * miss pays for the list, and then it either resolves the case or names the
41
+ * closest thing it found.
42
+ */
43
+ async function resolveApiName(api: PlatformApi, typed: string): Promise<string> {
44
+ const types = (await api.blueprintObjectTypes()) as ObjectTypeRow[]
45
+ const names = types.map((t) => t.apiName).filter((n): n is string => Boolean(n))
46
+
47
+ const insensitive = names.find((n) => n.toLowerCase() === typed.toLowerCase())
48
+ if (insensitive) return insensitive
49
+
50
+ const partial = names.filter((n) => n.toLowerCase().includes(typed.toLowerCase()))
51
+ const suggestion =
52
+ partial.length > 0
53
+ ? `did you mean ${partial.slice(0, 3).join(', ')}?`
54
+ : `known object types: ${names.join(', ') || '(none readable with this credential)'}`
55
+
56
+ throw new CliError(`no object type named "${typed}"`, { code: 'NOT_FOUND', hint: suggestion })
57
+ }
58
+
59
+ export const blueprintGet: Command = {
60
+ meta: {
61
+ noun: 'blueprint',
62
+ verb: 'get',
63
+ args: [
64
+ {
65
+ name: 'apiName',
66
+ required: true,
67
+ description: 'object type api name from `frontera blueprint list` (e.g. Shipment); case is corrected for you',
68
+ },
69
+ ],
70
+ flags: {},
71
+ summary: 'Show one object type: its properties, relations and metrics',
72
+ examples: ['frontera blueprint get Shipment', 'frontera blueprint get shipment --json'],
73
+ },
74
+
75
+ async run(ctx) {
76
+ const typed = ctx.positional[0]
77
+ if (!typed) {
78
+ throw new UsageError(
79
+ 'missing <apiName>',
80
+ 'run `frontera blueprint list` and pass a name from the API NAME column',
81
+ )
82
+ }
83
+
84
+ const api = new PlatformApi(ctx.apiUrl, ctx.token)
85
+
86
+ // The response is `{ objectType, properties }`, NOT a flat object type.
87
+ // Reading apiName off the top level silently yielded undefined and fell
88
+ // back to whatever the caller typed — so the header echoed the input and
89
+ // looked correct only when the case already matched.
90
+ let payload: { objectType?: ObjectTypeRow & { description?: string | null }; properties?: Property[] }
91
+ try {
92
+ payload = (await api.blueprintObjectType(typed)) as typeof payload
93
+ } catch {
94
+ // Miss: resolve the case, or fail with the names that do exist.
95
+ payload = (await api.blueprintObjectType(await resolveApiName(api, typed))) as typeof payload
96
+ }
97
+
98
+ const type = payload.objectType ?? {}
99
+ const apiName = type.apiName ?? typed
100
+
101
+ // Relations and metrics, not just columns. A property list alone says what
102
+ // a table holds and nothing about how it joins or what is measured on it —
103
+ // which is most of what someone designing against the model needs.
104
+ //
105
+ // Both key on object-type UUIDs, not api names, so the catalog is fetched
106
+ // to resolve them. Matching on names silently produced "Relations (0)" for
107
+ // a type with eight of them.
108
+ const [links, metrics, types] = await Promise.all([
109
+ api.blueprintLinkTypes().catch(() => [] as unknown[]),
110
+ api.blueprintMetrics().catch(() => [] as unknown[]),
111
+ api.blueprintObjectTypes().catch(() => [] as unknown[]),
112
+ ])
113
+
114
+ const nameById = new Map<string, string>()
115
+ for (const t of types as Array<{ id?: string; apiName?: string }>) {
116
+ if (t.id && t.apiName) nameById.set(t.id, t.apiName)
117
+ }
118
+ const selfId = (type as { id?: string }).id ?? ''
119
+
120
+ const related = (links as Link[]).filter(
121
+ (l) => l.fromObjectTypeId === selfId || l.toObjectTypeId === selfId,
122
+ )
123
+ const own = (metrics as Metric[]).filter((m) => m.objectTypeId === selfId)
124
+
125
+ const props = payload.properties ?? []
126
+ const heading = [apiName, type.displayName].filter(Boolean)
127
+ const lines = [
128
+ heading[1] && heading[1] !== heading[0] ? `${heading[0]} — ${heading[1]}` : String(heading[0]),
129
+ ...(type.description ? ['', type.description] : []),
130
+ '',
131
+ `Properties (${props.length})`,
132
+ ...(props.length === 0
133
+ ? [' (none)']
134
+ : props.map(
135
+ (p) =>
136
+ ` ${(p.apiName ?? '?').padEnd(26)}${(p.dataType ?? '').padEnd(12)}${p.propertyType ?? ''}`,
137
+ )),
138
+ '',
139
+ `Relations (${related.length})`,
140
+ ...(related.length === 0
141
+ ? [' (none)']
142
+ : related.map((l) => {
143
+ const outbound = l.fromObjectTypeId === selfId
144
+ const otherId = outbound ? l.toObjectTypeId : l.fromObjectTypeId
145
+ const other = nameById.get(otherId ?? '') ?? otherId ?? ''
146
+ return ` ${(l.apiName ?? '?').padEnd(26)}${outbound ? '→' : '←'} ${other.padEnd(18)}${l.cardinality ?? ''}`
147
+ })),
148
+ '',
149
+ `Metrics (${own.length})`,
150
+ ...(own.length === 0
151
+ ? [' (none)']
152
+ : own.map((m) => ` ${(m.apiName ?? '?').padEnd(26)}${m.displayName ?? ''}`)),
153
+ ]
154
+
155
+ return {
156
+ data: { ...payload, relations: related, metrics: own },
157
+ text: lines.join('\n'),
158
+ }
159
+ },
160
+ }
@@ -0,0 +1,48 @@
1
+ import { PlatformApi } from '../../api/platform-api'
2
+ import { table } from '../../table'
3
+ import type { Command } from '../types'
4
+
5
+ interface ObjectTypeRow {
6
+ apiName?: string
7
+ displayName?: string
8
+ description?: string | null
9
+ }
10
+
11
+ /**
12
+ * What data does this workspace actually have?
13
+ *
14
+ * An app is built on the Blueprint SDK, so an agent authoring one has to know
15
+ * the object types before it can design against them. Reads resolve through
16
+ * the workspace's granted slice, so what appears here is exactly what the app
17
+ * will be able to query at runtime — anything absent is absent for the app too.
18
+ */
19
+ export const blueprintList: Command = {
20
+ meta: {
21
+ noun: 'blueprint',
22
+ verb: 'list',
23
+ args: [],
24
+ flags: {},
25
+ summary: 'List the object types this workspace can read',
26
+ examples: ['frontera blueprint list', 'frontera blueprint list --json'],
27
+ },
28
+
29
+ async run(ctx) {
30
+ const api = new PlatformApi(ctx.apiUrl, ctx.token)
31
+ const types = (await api.blueprintObjectTypes()) as ObjectTypeRow[]
32
+
33
+ return {
34
+ data: types,
35
+ text:
36
+ types.length === 0
37
+ ? 'No object types are readable with this credential.'
38
+ : // API NAME first and labelled: it is the value `blueprint get`
39
+ // takes, and unlabelled columns left a reader guessing which of
40
+ // the two names to pass.
41
+ table(
42
+ ['api name', 'display name', 'description'],
43
+ types.map((t) => [t.apiName ?? '?', t.displayName ?? '', t.description ?? '']),
44
+ [undefined, undefined, 70],
45
+ ),
46
+ }
47
+ },
48
+ }
@@ -0,0 +1,40 @@
1
+ import type { Command } from '../types'
2
+
3
+ /**
4
+ * Blueprint AUTHORING is not built yet.
5
+ *
6
+ * Reserved in the command table rather than left out, and this is the whole
7
+ * point of the distinction: a caller that reads "unknown command" concludes it
8
+ * mistyped and retries, while one that reads the real reason picks another
9
+ * route. An absent command and a deliberately withheld one are different
10
+ * facts.
11
+ *
12
+ * What is not ready is the organization-level shared draft — its revision
13
+ * lifecycle, adoption and grant model — not the noun. Reading ships today.
14
+ */
15
+ const MESSAGE =
16
+ 'blueprint authoring is not available in this release; ' +
17
+ '`frontera blueprint list` and `frontera blueprint get` are read-only'
18
+
19
+ const RESERVED_VERBS = [
20
+ { verb: 'create', summary: 'Create an object type on the shared draft' },
21
+ { verb: 'apply', summary: 'Apply a Blueprint document to the shared draft' },
22
+ { verb: 'grant', summary: 'Grant Blueprint access to a workspace or agent' },
23
+ { verb: 'publish', summary: 'Publish the shared draft as a release' },
24
+ ] as const
25
+
26
+ export const blueprintReserved: Command[] = RESERVED_VERBS.map(({ verb, summary }) => ({
27
+ meta: {
28
+ noun: 'blueprint',
29
+ verb,
30
+ args: [],
31
+ flags: {},
32
+ summary,
33
+ examples: [`frontera blueprint ${verb}`],
34
+ reserved: MESSAGE,
35
+ },
36
+ async run() {
37
+ // Unreachable: the shell refuses a reserved command before dispatch.
38
+ throw new Error(MESSAGE)
39
+ },
40
+ }))
@@ -0,0 +1,293 @@
1
+ import { UsageError } from '../errors'
2
+ import type { CommandDescriptor } from './registry'
3
+ import type { Command } from './types'
4
+
5
+ /**
6
+ * Shell completion, generated from the command table.
7
+ *
8
+ * Bun, Codex and Claude Code all ship one; hand-written completion is also how
9
+ * every one of them has at some point advertised a flag that no longer exists.
10
+ * Generating it from the same index `--help` renders means it cannot describe a
11
+ * command that is not there.
12
+ *
13
+ * Reserved verbs are excluded: completing to something that always fails is
14
+ * worse than not completing at all.
15
+ *
16
+ * The index is PASSED IN rather than imported. This module is listed in the
17
+ * command table, so importing the table statically closed a cycle — and a
18
+ * cycle only bites when module evaluation happens to reach one side first,
19
+ * which made it pass locally on one Bun version and fail on CI's. The type
20
+ * import above is erased at runtime, so it cannot reintroduce it.
21
+ */
22
+ const SHELLS = ['bash', 'zsh', 'fish'] as const
23
+ type Shell = (typeof SHELLS)[number]
24
+
25
+ type Index = readonly CommandDescriptor[]
26
+
27
+ function available(index: Index): CommandDescriptor[] {
28
+ return index.filter((c) => c.available)
29
+ }
30
+
31
+ function nounsIn(index: Index): string[] {
32
+ return [...new Set(available(index).map((c) => c.noun))]
33
+ }
34
+
35
+ function verbsFor(index: Index, noun: string): string[] {
36
+ return available(index)
37
+ .filter((c) => c.noun === noun && c.verb !== '')
38
+ .map((c) => c.verb)
39
+ }
40
+
41
+ function flagsForNounVerb(index: Index, noun: string, verb: string): string[] {
42
+ const command = available(index).find((c) => c.noun === noun && c.verb === verb)
43
+ return command ? command.flags.map((f) => `--${f.name}`) : []
44
+ }
45
+
46
+ /**
47
+ * The flags every command shares — the fallback when nothing more specific
48
+ * matches. Intersection rather than "whatever some command happens to carry":
49
+ * picking a sample command advertised its own flags everywhere the moment that
50
+ * command grew one.
51
+ */
52
+ function globalFlags(index: Index): string[] {
53
+ const commands = available(index)
54
+ const first = commands[0]
55
+ if (!first) return []
56
+ return first.flags
57
+ .map((f) => `--${f.name}`)
58
+ .filter((flag) => commands.every((c) => c.flags.some((f) => `--${f.name}` === flag)))
59
+ }
60
+
61
+ /**
62
+ * What a NOUN means, as opposed to what its first verb does.
63
+ *
64
+ * Without this, `app` was described as "Scaffold a new Frontera app project" —
65
+ * the summary of whichever command happened to sort first. A menu that
66
+ * describes the group by one of its members is worse than terse.
67
+ */
68
+ export type NounSummaries = Readonly<Record<string, string>>
69
+
70
+ function summaryForNoun(index: Index, summaries: NounSummaries, noun: string): string {
71
+ return summaries[noun] || available(index).find((c) => c.noun === noun)!.summary
72
+ }
73
+
74
+ /** Nouns handled by main.ts rather than the table, so they are not in it. */
75
+ const BUILTINS: ReadonlyArray<{ noun: string; summary: string }> = [
76
+ { noun: 'help', summary: 'Show every command' },
77
+ { noun: 'version', summary: 'Print the CLI version' },
78
+ ]
79
+
80
+ function bash(index: Index): string {
81
+ const withVerbs = nounsIn(index).filter((n) => verbsFor(index, n).length > 0)
82
+ const cases = withVerbs
83
+ .map((noun) => ` ${noun}) verbs="${verbsFor(index, noun).join(' ')}" ;;`)
84
+ .join('\n')
85
+
86
+ const flagCases = withVerbs
87
+ .flatMap((noun) =>
88
+ verbsFor(index, noun).map(
89
+ (verb) =>
90
+ ` "${noun} ${verb}") flags="${flagsForNounVerb(index, noun, verb).join(' ')}" ;;`,
91
+ ),
92
+ )
93
+ .join('\n')
94
+
95
+ const globals = globalFlags(index)
96
+
97
+ return `# frontera completion for bash — generated, do not edit
98
+ _frontera() {
99
+ local cur prev nouns verbs flags
100
+ cur="\${COMP_WORDS[COMP_CWORD]}"
101
+
102
+ if [ "\$COMP_CWORD" -eq 1 ]; then
103
+ nouns="${[...nounsIn(index), ...BUILTINS.map((b) => b.noun)].join(' ')}"
104
+ COMPREPLY=( \$(compgen -W "\$nouns" -- "\$cur") )
105
+ return
106
+ fi
107
+
108
+ if [ "\$COMP_CWORD" -eq 2 ]; then
109
+ case "\${COMP_WORDS[1]}" in
110
+ ${cases}
111
+ *) verbs="" ;;
112
+ esac
113
+ COMPREPLY=( \$(compgen -W "\$verbs" -- "\$cur") )
114
+ return
115
+ fi
116
+
117
+ case "\${COMP_WORDS[1]} \${COMP_WORDS[2]}" in
118
+ ${flagCases}
119
+ *) flags="${globals.join(' ')}" ;;
120
+ esac
121
+ COMPREPLY=( \$(compgen -W "\$flags" -- "\$cur") )
122
+ }
123
+ complete -F _frontera frontera
124
+ `
125
+ }
126
+
127
+ /**
128
+ * A description inside a zsh `name:description` spec.
129
+ *
130
+ * The colon is the separator, so an unescaped one in the text silently turns
131
+ * the rest of the summary into a completion ACTION — `get:Show one object
132
+ * type: its properties` ran `its properties` as a completion function. Single
133
+ * quotes are dropped rather than escaped: the spec is single-quoted, and a
134
+ * summary is prose, not a value anyone parses back out.
135
+ */
136
+ function zshDescription(text: string): string {
137
+ return text.replace(/'/g, '').replace(/:/g, '\\:')
138
+ }
139
+
140
+ function zsh(index: Index, nounSummaries: NounSummaries): string {
141
+ const nounLines = [
142
+ ...nounsIn(index).map(
143
+ (noun) => ` '${noun}:${zshDescription(summaryForNoun(index, nounSummaries, noun))}'`,
144
+ ),
145
+ ...BUILTINS.map((b) => ` '${b.noun}:${zshDescription(b.summary)}'`),
146
+ ].join('\n')
147
+
148
+ const verbBlocks = nounsIn(index)
149
+ .filter((n) => verbsFor(index, n).length > 0)
150
+ .map((noun) => {
151
+ const lines = available(index)
152
+ .filter((c) => c.noun === noun && c.verb !== '')
153
+ .map((c) => ` '${c.verb}:${zshDescription(c.summary)}'`)
154
+ .join('\n')
155
+ return ` ${noun})\n verbs=(\n${lines}\n )\n ;;`
156
+ })
157
+ .join('\n')
158
+
159
+ const flagBlocks = nounsIn(index)
160
+ .filter((n) => verbsFor(index, n).length > 0)
161
+ .flatMap((noun) =>
162
+ verbsFor(index, noun).map(
163
+ (verb) =>
164
+ ` "${noun} ${verb}") flags=(${flagsForNounVerb(index, noun, verb).join(' ')}) ;;`,
165
+ ),
166
+ )
167
+ .join('\n')
168
+
169
+ /**
170
+ * Arrays plus `_describe`, not a line-continued `_values`.
171
+ *
172
+ * The continuation is what broke: only the first spec line carried a `\`, so
173
+ * zsh ran every line after it as a command and the caller got a screen of
174
+ * `command not found: login:Verify a workspace key` on every TAB. An array
175
+ * literal needs no continuation at all, which is the point — the generator
176
+ * cannot emit a half-continued one.
177
+ */
178
+ return `#compdef frontera
179
+ # frontera completion for zsh — generated, do not edit
180
+ _frontera() {
181
+ local -a nouns verbs flags
182
+
183
+ if (( CURRENT == 2 )); then
184
+ nouns=(
185
+ ${nounLines}
186
+ )
187
+ _describe -t commands 'frontera command' nouns
188
+ return
189
+ fi
190
+
191
+ if (( CURRENT == 3 )); then
192
+ case "\${words[2]}" in
193
+ ${verbBlocks}
194
+ esac
195
+ if (( \${#verbs} )); then
196
+ _describe -t verbs "frontera \${words[2]}" verbs
197
+ else
198
+ _files
199
+ fi
200
+ return
201
+ fi
202
+
203
+ case "\${words[2]} \${words[3]}" in
204
+ ${flagBlocks}
205
+ *) flags=(${globalFlags(index).join(' ')}) ;;
206
+ esac
207
+
208
+ # Only when a flag is what is being typed: everything else in this position
209
+ # is an id or a path, and offering \`--json\` for an agent id helps no one.
210
+ if [[ "\${words[CURRENT]}" == -* ]]; then
211
+ compadd -- \${flags}
212
+ else
213
+ _files
214
+ fi
215
+ }
216
+ _frontera "\$@"
217
+ `
218
+ }
219
+
220
+ function fish(index: Index, nounSummaries: NounSummaries): string {
221
+ const lines: string[] = ['# frontera completion for fish — generated, do not edit']
222
+ lines.push('complete -c frontera -f')
223
+
224
+ for (const noun of nounsIn(index)) {
225
+ const summary = summaryForNoun(index, nounSummaries, noun)
226
+ lines.push(
227
+ `complete -c frontera -n "__fish_use_subcommand" -a "${noun}" -d "${summary.replace(/"/g, "'")}"`,
228
+ )
229
+ }
230
+
231
+ for (const builtin of BUILTINS) {
232
+ lines.push(
233
+ `complete -c frontera -n "__fish_use_subcommand" -a "${builtin.noun}" -d "${builtin.summary}"`,
234
+ )
235
+ }
236
+
237
+ for (const c of available(index)) {
238
+ if (!c.verb) continue
239
+ lines.push(
240
+ `complete -c frontera -n "__fish_seen_subcommand_from ${c.noun}" -a "${c.verb}" -d "${c.summary.replace(/"/g, "'")}"`,
241
+ )
242
+ }
243
+ return `${lines.join('\n')}\n`
244
+ }
245
+
246
+ const GENERATORS: Record<Shell, (index: Index, nounSummaries: NounSummaries) => string> = {
247
+ bash,
248
+ zsh,
249
+ fish,
250
+ }
251
+
252
+ export function completionScript(
253
+ shell: Shell,
254
+ index: Index,
255
+ nounSummaries: NounSummaries = {},
256
+ ): string {
257
+ return GENERATORS[shell](index, nounSummaries)
258
+ }
259
+
260
+ export const completionCommand: Command = {
261
+ meta: {
262
+ noun: 'completion',
263
+ verb: '',
264
+ args: [{ name: 'shell', required: true, description: `one of: ${SHELLS.join(', ')}` }],
265
+ flags: {},
266
+ summary: 'Print a shell completion script',
267
+ examples: [
268
+ 'frontera completion zsh > ~/.zfunc/_frontera',
269
+ 'frontera completion bash >> ~/.bashrc',
270
+ ],
271
+ // Nothing here talks to the API, and someone setting up their shell has
272
+ // usually not logged in yet.
273
+ offline: true,
274
+ },
275
+
276
+ async run(ctx) {
277
+ const shell = ctx.positional[0]
278
+ if (!shell || !SHELLS.includes(shell as Shell)) {
279
+ throw new UsageError(
280
+ shell ? `unsupported shell: ${shell}` : 'missing <shell>',
281
+ `frontera completion <${SHELLS.join('|')}>`,
282
+ )
283
+ }
284
+
285
+ // Imported HERE, not at module scope: this command is in the table it
286
+ // reads, and a static import would close the cycle again. By call time
287
+ // both modules are fully initialised.
288
+ const { describeCommands, nouns, nounSummary } = await import('./registry')
289
+ const summaries = Object.fromEntries(nouns().map((n) => [n, nounSummary(n)]))
290
+ const script = completionScript(shell as Shell, describeCommands(), summaries)
291
+ return { data: { shell, script }, text: script }
292
+ },
293
+ }
@@ -0,0 +1,33 @@
1
+ import { writeHarnessFiles } from '../harness'
2
+ import { flagBool, type Command } from './types'
3
+
4
+ /**
5
+ * Prepare a directory for agent-driven Frontera work.
6
+ *
7
+ * Deliberately top-level and offline: it teaches the CLI, so it has to be
8
+ * reachable from any directory a harness lands in, including one that is not
9
+ * an app project and before any credential exists.
10
+ */
11
+ export const initCommand: Command = {
12
+ meta: {
13
+ noun: 'init',
14
+ verb: '',
15
+ args: [],
16
+ flags: { force: 'boolean' },
17
+ summary: 'Write AGENTS.md and the CLI skill into this directory',
18
+ examples: ['frontera init', 'frontera init --force'],
19
+ offline: true,
20
+ },
21
+
22
+ async run(ctx) {
23
+ const result = writeHarnessFiles(ctx.cwd, { force: flagBool(ctx, 'force') })
24
+
25
+ const lines = [
26
+ ...result.written.map((f) => ` + ${f}`),
27
+ ...result.skipped.map((f) => ` · ${f} (exists — use --force to replace)`),
28
+ ]
29
+ if (lines.length === 0) lines.push(' Already prepared — nothing to write.')
30
+
31
+ return { data: result, text: lines.join('\n') }
32
+ },
33
+ }