@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,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
|
+
}
|