@seamapi/cli 0.11.0 → 0.12.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 (39) hide show
  1. package/README.md +60 -0
  2. package/bin/cli.js +38 -98
  3. package/bin/cli.js.map +1 -1
  4. package/completions/seam.bash +11 -0
  5. package/completions/seam.fish +11 -0
  6. package/completions/seam.zsh +13 -0
  7. package/lib/command-spec.d.ts +53 -0
  8. package/lib/command-spec.js +290 -0
  9. package/lib/command-spec.js.map +1 -0
  10. package/lib/completion/describe.d.ts +10 -0
  11. package/lib/completion/describe.js +16 -0
  12. package/lib/completion/describe.js.map +1 -0
  13. package/lib/completion/index.d.ts +20 -0
  14. package/lib/completion/index.js +65 -0
  15. package/lib/completion/index.js.map +1 -0
  16. package/lib/completion/render-bash.d.ts +2 -0
  17. package/lib/completion/render-bash.js +98 -0
  18. package/lib/completion/render-bash.js.map +1 -0
  19. package/lib/completion/render-fish.d.ts +2 -0
  20. package/lib/completion/render-fish.js +58 -0
  21. package/lib/completion/render-fish.js.map +1 -0
  22. package/lib/completion/render-zsh.d.ts +2 -0
  23. package/lib/completion/render-zsh.js +121 -0
  24. package/lib/completion/render-zsh.js.map +1 -0
  25. package/lib/render-help.d.ts +8 -0
  26. package/lib/render-help.js +150 -0
  27. package/lib/render-help.js.map +1 -0
  28. package/lib/version.d.ts +1 -1
  29. package/lib/version.js +1 -1
  30. package/package.json +2 -1
  31. package/src/bin/cli.ts +54 -104
  32. package/src/lib/command-spec.ts +400 -0
  33. package/src/lib/completion/describe.ts +21 -0
  34. package/src/lib/completion/index.ts +82 -0
  35. package/src/lib/completion/render-bash.ts +125 -0
  36. package/src/lib/completion/render-fish.ts +80 -0
  37. package/src/lib/completion/render-zsh.ts +157 -0
  38. package/src/lib/render-help.ts +197 -0
  39. package/src/lib/version.ts +1 -1
@@ -0,0 +1,400 @@
1
+ import type { Blueprint } from '@seamapi/blueprint'
2
+
3
+ type Endpoint = Blueprint['routes'][number]['endpoints'][number]
4
+ type Parameter = Endpoint['request']['parameters'][number]
5
+
6
+ export interface CommandFlag {
7
+ /** Long form without the leading `--`, or `null` for short-only flags. */
8
+ long: string | null
9
+ /** Short form without the leading `-`, or `null` when there is none. */
10
+ short: string | null
11
+ description: string
12
+ /** Known values for the flag, used to complete and document its argument. */
13
+ values: string[]
14
+ /** Whether the flag is followed by a value. */
15
+ takesValue: boolean
16
+ isRequired: boolean
17
+ }
18
+
19
+ /**
20
+ * Whether a command is part of the CLI itself or calls a Seam API endpoint.
21
+ */
22
+ export type CommandKind = 'cli' | 'api'
23
+
24
+ export interface CommandDefinition {
25
+ path: string[]
26
+ kind: CommandKind
27
+ /** One line naming what the command does. */
28
+ title: string
29
+ /** Longer prose about the command, empty when there is none to add. */
30
+ description: string
31
+ flags: CommandFlag[]
32
+ }
33
+
34
+ export interface Subcommand {
35
+ name: string
36
+ /** 'api' when the name holds any command that calls the Seam API. */
37
+ kind: CommandKind
38
+ description: string
39
+ }
40
+
41
+ export interface CommandGroup {
42
+ /** Command path completed by this group, empty for `seam` itself. */
43
+ path: string[]
44
+ subcommands: Subcommand[]
45
+ }
46
+
47
+ export interface CommandSpec {
48
+ /** Every invocable command, sorted by command path. */
49
+ commands: CommandDefinition[]
50
+ /** Every incomplete command path, sorted by command path. */
51
+ groups: CommandGroup[]
52
+ /** Flags accepted regardless of the command. */
53
+ globalFlags: CommandFlag[]
54
+ }
55
+
56
+ export const globalFlags: CommandFlag[] = [
57
+ {
58
+ long: 'help',
59
+ short: 'h',
60
+ description: 'Display this help guide.',
61
+ values: [],
62
+ takesValue: false,
63
+ isRequired: false,
64
+ },
65
+ {
66
+ long: 'interactive',
67
+ short: 'i',
68
+ description:
69
+ 'Always prompt to review and edit properties, prefilled with the given arguments.',
70
+ values: [],
71
+ takesValue: false,
72
+ isRequired: false,
73
+ },
74
+ {
75
+ long: 'json',
76
+ short: null,
77
+ description:
78
+ 'Write the response to stdout as JSON. Enabled automatically when stdout is not a terminal, disable with --no-json.',
79
+ values: [],
80
+ takesValue: false,
81
+ isRequired: false,
82
+ },
83
+ {
84
+ long: 'non-interactive',
85
+ short: 'y',
86
+ description:
87
+ 'Never prompt: exit with an error if the command or any required property is missing.',
88
+ values: [],
89
+ takesValue: false,
90
+ isRequired: false,
91
+ },
92
+ {
93
+ long: 'remote-api-defs',
94
+ short: null,
95
+ description: 'Use the API definitions served by the Seam API.',
96
+ values: [],
97
+ takesValue: false,
98
+ isRequired: false,
99
+ },
100
+ {
101
+ long: 'update',
102
+ short: null,
103
+ description: 'Force an update of the cached Seam API definitions.',
104
+ values: [],
105
+ takesValue: false,
106
+ isRequired: false,
107
+ },
108
+ {
109
+ long: 'version',
110
+ short: null,
111
+ description: 'Print the CLI version.',
112
+ values: [],
113
+ takesValue: false,
114
+ isRequired: false,
115
+ },
116
+ ]
117
+
118
+ export const flagTokens = (flag: CommandFlag): string[] => {
119
+ const tokens = []
120
+ if (flag.long != null) tokens.push(`--${flag.long}`)
121
+ if (flag.short != null) tokens.push(`-${flag.short}`)
122
+ return tokens
123
+ }
124
+
125
+ export const getCommandSpec = (blueprint: Blueprint): CommandSpec => {
126
+ const commands = sortByPath(
127
+ dedupeByPath([
128
+ ...blueprint.routes
129
+ .flatMap((route) => route.endpoints)
130
+ .map(toCommandDefinition)
131
+ // Command and flag names end up unquoted or single-quoted in shell
132
+ // scripts, so drop any the definitions should never contain rather
133
+ // than emit something a shell could read as syntax.
134
+ .filter((command) => command.path.every(isSafeToken)),
135
+ ...localCommands,
136
+ ]),
137
+ )
138
+
139
+ return { commands, groups: toCommandGroups(commands), globalFlags }
140
+ }
141
+
142
+ export const findCommand = (
143
+ spec: CommandSpec,
144
+ path: string[],
145
+ ): CommandDefinition | undefined =>
146
+ spec.commands.find((command) => isSamePath(command.path, path))
147
+
148
+ export const findGroup = (
149
+ spec: CommandSpec,
150
+ path: string[],
151
+ ): CommandGroup | undefined =>
152
+ spec.groups.find((group) => isSamePath(group.path, path))
153
+
154
+ const isSamePath = (a: string[], b: string[]): boolean =>
155
+ a.length === b.length && a.every((word, index) => word === b[index])
156
+
157
+ const stringFlag = (long: string, description: string): CommandFlag => ({
158
+ long,
159
+ short: null,
160
+ description,
161
+ values: [],
162
+ takesValue: true,
163
+ isRequired: false,
164
+ })
165
+
166
+ /**
167
+ * Commands handled by the CLI itself, which have no endpoint in the blueprint.
168
+ *
169
+ * Keep in sync with the command handling in `src/bin/cli.ts` and the extra
170
+ * commands offered by `interactForCommandSelection`.
171
+ */
172
+ const localCommands: CommandDefinition[] = [
173
+ {
174
+ path: ['completion', 'bash'],
175
+ kind: 'cli',
176
+ title: 'Print the bash completion script.',
177
+ description: '',
178
+ flags: [],
179
+ },
180
+ {
181
+ path: ['completion', 'fish'],
182
+ kind: 'cli',
183
+ title: 'Print the fish completion script.',
184
+ description: '',
185
+ flags: [],
186
+ },
187
+ {
188
+ path: ['completion', 'zsh'],
189
+ kind: 'cli',
190
+ title: 'Print the zsh completion script.',
191
+ description: '',
192
+ flags: [],
193
+ },
194
+ {
195
+ path: ['config', 'reveal-location'],
196
+ kind: 'cli',
197
+ title: 'Print the path to the CLI configuration file.',
198
+ description: '',
199
+ flags: [],
200
+ },
201
+ {
202
+ path: ['config', 'use-remote-api-defs'],
203
+ kind: 'cli',
204
+ title: 'Choose whether to use the API definitions served by Seam.',
205
+ description: '',
206
+ flags: [],
207
+ },
208
+ {
209
+ path: ['health', 'get-health'],
210
+ kind: 'api',
211
+ title: 'Report the health of the Seam API.',
212
+ description: '',
213
+ flags: [],
214
+ },
215
+ {
216
+ path: ['login'],
217
+ kind: 'cli',
218
+ title: 'Log in to Seam.',
219
+ description:
220
+ 'Prompts for a personal access token unless one is passed with --token.',
221
+ flags: [
222
+ stringFlag('server', 'Seam API server to log in to.'),
223
+ stringFlag('token', 'Personal access token to log in with.'),
224
+ stringFlag('workspace-id', 'Workspace to select after logging in.'),
225
+ ],
226
+ },
227
+ {
228
+ path: ['logout'],
229
+ kind: 'cli',
230
+ title: 'Log out of Seam.',
231
+ description: '',
232
+ flags: [],
233
+ },
234
+ {
235
+ path: ['select', 'server'],
236
+ kind: 'cli',
237
+ title: 'Select the Seam API server.',
238
+ description: '',
239
+ flags: [stringFlag('server', 'Seam API server to select.')],
240
+ },
241
+ {
242
+ path: ['select', 'workspace'],
243
+ kind: 'cli',
244
+ title: 'Select the current workspace.',
245
+ description: '',
246
+ flags: [],
247
+ },
248
+ {
249
+ path: ['wizard'],
250
+ kind: 'cli',
251
+ title: 'Set up Seam in the current project.',
252
+ description:
253
+ 'Takes a project from zero to a working Seam integration. Run seam wizard --help for its own options.',
254
+ flags: [],
255
+ },
256
+ ]
257
+
258
+ const toCommandDefinition = (endpoint: Endpoint): CommandDefinition => {
259
+ const description = toPlainText(endpoint.description)
260
+
261
+ return {
262
+ path: toCommandPath(endpoint.path),
263
+ kind: 'api',
264
+ title:
265
+ endpoint.title === ''
266
+ ? firstSentence(description)
267
+ : toPlainText(endpoint.title),
268
+ description,
269
+ flags: [...endpoint.request.parameters]
270
+ .map(toCommandFlag)
271
+ .filter((flag) => flag.long == null || isSafeToken(flag.long))
272
+ .sort((a, b) => compare(a.long ?? a.short, b.long ?? b.short)),
273
+ }
274
+ }
275
+
276
+ const toCommandFlag = (parameter: Parameter): CommandFlag => ({
277
+ long: toFlagName(parameter.name),
278
+ short: null,
279
+ description: toPlainText(parameter.description),
280
+ values: toFlagValues(parameter),
281
+ takesValue: true,
282
+ isRequired: parameter.isRequired,
283
+ })
284
+
285
+ const toFlagValues = (parameter: Parameter): string[] => {
286
+ if (parameter.format === 'enum') {
287
+ return parameter.values.map(({ name }) => name).filter(isSafeToken)
288
+ }
289
+
290
+ if (parameter.format === 'list' && parameter.itemFormat === 'enum') {
291
+ return parameter.itemEnumValues.map(({ name }) => name).filter(isSafeToken)
292
+ }
293
+
294
+ // Nothing marks parameters as boolean-only flags, so minimist reads the next
295
+ // argument as the value.
296
+ if (parameter.format === 'boolean') return ['true', 'false']
297
+
298
+ return []
299
+ }
300
+
301
+ /**
302
+ * Whether a word is safe to write into a shell script. Command, flag, and
303
+ * enum names come from the API definitions and are embedded unquoted or
304
+ * single-quoted in completion scripts, so never emit one that a shell could
305
+ * read as syntax.
306
+ */
307
+ const isSafeToken = (token: string): boolean => /^[\w.:@/+-]+$/.test(token)
308
+
309
+ interface GroupEntry {
310
+ isCommand: boolean
311
+ kind: CommandKind
312
+ description: string
313
+ }
314
+
315
+ const toCommandGroups = (commands: CommandDefinition[]): CommandGroup[] => {
316
+ const groups = new Map<string, Map<string, GroupEntry>>()
317
+
318
+ for (const command of commands) {
319
+ for (const [depth, name] of command.path.entries()) {
320
+ const key = command.path.slice(0, depth).join(' ')
321
+
322
+ const entries = groups.get(key) ?? new Map<string, GroupEntry>()
323
+ groups.set(key, entries)
324
+
325
+ // An entry is an API command if any command it holds calls the API.
326
+ const kind =
327
+ command.kind === 'api' ? 'api' : (entries.get(name)?.kind ?? 'cli')
328
+
329
+ // A command and a group may share a name, e.g., a hypothetical
330
+ // `seam devices` alongside `seam devices list`. Prefer the command
331
+ // title, since it describes what running the name does.
332
+ if (depth === command.path.length - 1) {
333
+ entries.set(name, { isCommand: true, kind, description: command.title })
334
+ continue
335
+ }
336
+
337
+ const entry = entries.get(name)
338
+ entries.set(name, {
339
+ isCommand: entry?.isCommand ?? false,
340
+ kind,
341
+ description: entry?.description ?? '',
342
+ })
343
+ }
344
+ }
345
+
346
+ // Groups have no description of their own in the API definitions, so name
347
+ // the commands they hold instead. Leave the list whole: help wraps it, and
348
+ // completion shortens it to fit a menu column.
349
+ const summarizeGroup = (key: string): string =>
350
+ [...(groups.get(key)?.keys() ?? [])].join(', ')
351
+
352
+ return [...groups]
353
+ .map(([key, entries]) => ({
354
+ path: key === '' ? [] : key.split(' '),
355
+ subcommands: [...entries]
356
+ .map(([name, entry]) => ({
357
+ name,
358
+ kind: entry.kind,
359
+ description: entry.isCommand
360
+ ? entry.description
361
+ : summarizeGroup(key === '' ? name : `${key} ${name}`),
362
+ }))
363
+ .sort((a, b) => compare(a.name, b.name)),
364
+ }))
365
+ .sort((a, b) => compare(a.path.join(' '), b.path.join(' ')))
366
+ }
367
+
368
+ const dedupeByPath = (commands: CommandDefinition[]): CommandDefinition[] => {
369
+ const byPath = new Map<string, CommandDefinition>()
370
+ for (const command of commands) {
371
+ const key = command.path.join(' ')
372
+ if (byPath.has(key)) continue
373
+ byPath.set(key, command)
374
+ }
375
+ return [...byPath.values()]
376
+ }
377
+
378
+ const sortByPath = (commands: CommandDefinition[]): CommandDefinition[] =>
379
+ [...commands].sort((a, b) => compare(a.path.join(' '), b.path.join(' ')))
380
+
381
+ const compare = (a: string | null, b: string | null): number =>
382
+ (a ?? '') < (b ?? '') ? -1 : (a ?? '') > (b ?? '') ? 1 : 0
383
+
384
+ const toCommandPath = (path: string): string[] =>
385
+ path.replace(/^\//, '').split('/').map(toFlagName)
386
+
387
+ const toFlagName = (name: string): string => name.replace(/_/g, '-')
388
+
389
+ /** Reduce documentation markdown to a single line of prose. */
390
+ export const toPlainText = (markdown: string): string =>
391
+ markdown
392
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
393
+ .replace(/[`*]/g, '')
394
+ .replace(/\s+/g, ' ')
395
+ .trim()
396
+
397
+ export const firstSentence = (text: string): string => {
398
+ const [sentence] = text.split(/(?<=\.)\s/)
399
+ return sentence ?? text
400
+ }
@@ -0,0 +1,21 @@
1
+ import { firstSentence } from '../command-spec.js'
2
+ import { ellipsis } from '../util/ellipsis.js'
3
+
4
+ const maxDescriptionLength = 72
5
+
6
+ /**
7
+ * Reduce a description to one short line that is safe to embed in a
8
+ * single-quoted shell string.
9
+ *
10
+ * Completion menus give a description a single narrow column, and the shells
11
+ * offer no way to escape a quote inside the generated scripts, so drop any
12
+ * character that would end the string early. A colon goes too, since zsh reads
13
+ * it as the separator in a `_describe` entry.
14
+ */
15
+ export const describeForShell = (description: string): string =>
16
+ ellipsis(
17
+ firstSentence(description)
18
+ .replace(/['"`$\\:]/g, '')
19
+ .trim(),
20
+ maxDescriptionLength,
21
+ )
@@ -0,0 +1,82 @@
1
+ import type { Blueprint } from '@seamapi/blueprint'
2
+
3
+ import { type CommandSpec, getCommandSpec } from '../command-spec.js'
4
+ import { renderBashCompletion } from './render-bash.js'
5
+ import { renderFishCompletion } from './render-fish.js'
6
+ import { renderZshCompletion } from './render-zsh.js'
7
+
8
+ export const completionShells = ['bash', 'fish', 'zsh'] as const
9
+
10
+ export type CompletionShell = (typeof completionShells)[number]
11
+
12
+ export const isCompletionShell = (shell: unknown): shell is CompletionShell =>
13
+ completionShells.includes(shell as CompletionShell)
14
+
15
+ /** File name to install the completion script for each shell as. */
16
+ export const completionFileNames: Record<CompletionShell, string> = {
17
+ bash: 'seam.bash',
18
+ fish: 'seam.fish',
19
+ zsh: 'seam.zsh',
20
+ }
21
+
22
+ const renderers: Record<CompletionShell, (spec: CommandSpec) => string> = {
23
+ bash: renderBashCompletion,
24
+ fish: renderFishCompletion,
25
+ zsh: renderZshCompletion,
26
+ }
27
+
28
+ export const renderCompletion = (
29
+ shell: CompletionShell,
30
+ blueprint: Blueprint,
31
+ ): string => renderers[shell](getCommandSpec(blueprint))
32
+
33
+ /**
34
+ * Render the completion loader installed by system packages.
35
+ *
36
+ * The loader runs 'seam completion' the first time the shell completes a seam
37
+ * command, so installed completions always match the CLI's current Seam API
38
+ * definitions instead of the definitions packaged at release time. Each shell
39
+ * loads its completion file on demand, so the CLI runs once per shell session
40
+ * at first completion, never at shell startup.
41
+ *
42
+ * The loader degrades to no completions when the seam command is missing or
43
+ * cannot produce a script, e.g., offline before the definitions are cached.
44
+ */
45
+ export const renderCompletionStub = (shell: CompletionShell): string =>
46
+ stubs[shell]
47
+
48
+ const stubHeader = (shell: CompletionShell): string =>
49
+ `# ${shell} completion loader for the seam command.
50
+ #
51
+ # Generated by @seamapi/cli. Loads completions from the CLI on first use, so
52
+ # they always match the CLI's current Seam API definitions. Requires the seam
53
+ # command on PATH. Print the underlying script with 'seam completion ${shell}'.`
54
+
55
+ const stubs: Record<CompletionShell, string> = {
56
+ bash: `${stubHeader('bash')}
57
+ #
58
+ # Install to /usr/share/bash-completion/completions/seam
59
+
60
+ if command -v seam > /dev/null 2>&1; then
61
+ eval "$(seam completion bash 2> /dev/null)"
62
+ fi
63
+ `,
64
+ fish: `${stubHeader('fish')}
65
+ #
66
+ # Install to /usr/share/fish/vendor_completions.d/seam.fish
67
+
68
+ if command --query seam
69
+ seam completion fish 2> /dev/null | source
70
+ end
71
+ `,
72
+ zsh: `#compdef seam
73
+ ${stubHeader('zsh')}
74
+ #
75
+ # Install to a directory in fpath as _seam
76
+
77
+ # The generated script ends by dispatching on funcstack, so evaluating it
78
+ # while this autoloaded _seam runs both redefines _seam and completes the
79
+ # in-flight request.
80
+ eval "$(seam completion zsh 2> /dev/null)"
81
+ `,
82
+ }
@@ -0,0 +1,125 @@
1
+ import {
2
+ type CommandFlag,
3
+ type CommandSpec,
4
+ flagTokens,
5
+ } from '../command-spec.js'
6
+
7
+ export const renderBashCompletion = (spec: CommandSpec): string => {
8
+ const globalTokens = spec.globalFlags.flatMap(flagTokens).sort()
9
+ const valuelessTokens = spec.globalFlags
10
+ .filter(({ takesValue }) => !takesValue)
11
+ .flatMap(flagTokens)
12
+ .sort()
13
+
14
+ return `${[
15
+ header,
16
+ `_seam_global_flags='${globalTokens.join(' ')}'`,
17
+ `_seam_valueless_flags=' ${valuelessTokens.join(' ')} '`,
18
+ renderCase('_seam_subcommands', subcommandBranches(spec)),
19
+ renderCase('_seam_flags', flagBranches(spec)),
20
+ renderCase('_seam_flag_values', flagValueBranches(spec)),
21
+ completionFunction,
22
+ 'complete -F _seam_completion seam',
23
+ ].join('\n\n')}\n`
24
+ }
25
+
26
+ const header = `# bash completion for the seam command.
27
+ #
28
+ # Generated by @seamapi/cli from the Seam API definitions.
29
+ # Do not edit: regenerate with 'seam completion bash'.
30
+ #
31
+ # Load it for the current shell with
32
+ #
33
+ # source <(seam completion bash)
34
+ #
35
+ # or install it for every shell with
36
+ #
37
+ # seam completion bash > /usr/share/bash-completion/completions/seam`
38
+
39
+ const completionFunction = `_seam_completion() {
40
+ local current previous word command subcommands
41
+ local -i index
42
+
43
+ current="\${COMP_WORDS[COMP_CWORD]}"
44
+ previous=''
45
+ if (( COMP_CWORD > 0 )); then
46
+ previous="\${COMP_WORDS[COMP_CWORD - 1]}"
47
+ fi
48
+
49
+ # --flag=value splits into three words under the default word breaks.
50
+ if [[ "$previous" == '=' ]] && (( COMP_CWORD > 1 )); then
51
+ previous="\${COMP_WORDS[COMP_CWORD - 2]}"
52
+ fi
53
+
54
+ # The command path is the run of words before the first flag.
55
+ command=''
56
+ for (( index = 1; index < COMP_CWORD; index++ )); do
57
+ word="\${COMP_WORDS[index]}"
58
+ if [[ "$word" == -* ]]; then
59
+ break
60
+ fi
61
+ command="\${command:+$command }$word"
62
+ done
63
+
64
+ # Completing the value of a flag that takes one.
65
+ if [[ "$previous" == -* && "$_seam_valueless_flags" != *" $previous "* ]]; then
66
+ COMPREPLY=( $(compgen -W "$(_seam_flag_values "$command $previous")" -- "$current") )
67
+ return 0
68
+ fi
69
+
70
+ if [[ "$current" == -* ]]; then
71
+ COMPREPLY=( $(compgen -W "$(_seam_flags "$command") $_seam_global_flags" -- "$current") )
72
+ return 0
73
+ fi
74
+
75
+ subcommands="$(_seam_subcommands "$command")"
76
+ if [[ -z "$subcommands" ]]; then
77
+ COMPREPLY=( $(compgen -W "$(_seam_flags "$command") $_seam_global_flags" -- "$current") )
78
+ return 0
79
+ fi
80
+
81
+ COMPREPLY=( $(compgen -W "$subcommands" -- "$current") )
82
+ return 0
83
+ }`
84
+
85
+ interface Branch {
86
+ pattern: string
87
+ words: string[]
88
+ }
89
+
90
+ const subcommandBranches = (spec: CommandSpec): Branch[] =>
91
+ spec.groups.map((group) => ({
92
+ pattern: group.path.join(' '),
93
+ words: group.subcommands.map(({ name }) => name),
94
+ }))
95
+
96
+ const flagBranches = (spec: CommandSpec): Branch[] =>
97
+ spec.commands
98
+ .filter(({ flags }) => flags.length > 0)
99
+ .map((command) => ({
100
+ pattern: command.path.join(' '),
101
+ words: command.flags.flatMap(flagTokens),
102
+ }))
103
+
104
+ const flagValueBranches = (spec: CommandSpec): Branch[] =>
105
+ spec.commands.flatMap((command) =>
106
+ command.flags.filter(hasValues).flatMap((flag) =>
107
+ flagTokens(flag).map((token) => ({
108
+ pattern: `${command.path.join(' ')} ${token}`,
109
+ words: flag.values,
110
+ })),
111
+ ),
112
+ )
113
+
114
+ const hasValues = (flag: CommandFlag): boolean => flag.values.length > 0
115
+
116
+ const renderCase = (name: string, branches: Branch[]): string =>
117
+ [
118
+ `${name}() {`,
119
+ ` case "$1" in`,
120
+ ...branches.map(
121
+ ({ pattern, words }) => ` '${pattern}') echo '${words.join(' ')}' ;;`,
122
+ ),
123
+ ` esac`,
124
+ `}`,
125
+ ].join('\n')
@@ -0,0 +1,80 @@
1
+ import type { CommandFlag, CommandSpec } from '../command-spec.js'
2
+ import { describeForShell } from './describe.js'
3
+
4
+ export const renderFishCompletion = (spec: CommandSpec): string =>
5
+ `${[
6
+ header,
7
+ helpers,
8
+ ['complete -c seam -f', ...subcommandCompletions(spec)].join('\n'),
9
+ flagCompletions(spec).join('\n'),
10
+ globalFlagCompletions(spec).join('\n'),
11
+ ].join('\n\n')}\n`
12
+
13
+ const header = `# fish completion for the seam command.
14
+ #
15
+ # Generated by @seamapi/cli from the Seam API definitions.
16
+ # Do not edit: regenerate with 'seam completion fish'.
17
+ #
18
+ # Install it with
19
+ #
20
+ # seam completion fish > ~/.config/fish/completions/seam.fish`
21
+
22
+ const helpers = `function __seam_command --description 'Print the seam command path on the command line'
23
+ set -l tokens (commandline -opc)
24
+ set -l command
25
+ if test (count $tokens) -gt 1
26
+ # The command path is the run of words before the first flag.
27
+ for token in $tokens[2..-1]
28
+ if string match -q -- '-*' $token
29
+ break
30
+ end
31
+ set -a command $token
32
+ end
33
+ end
34
+ string join ' ' -- $command
35
+ end
36
+
37
+ function __seam_using --description 'Test whether the command line names the given seam command'
38
+ set -l command (__seam_command)
39
+ test "$command" = "$argv[1]"
40
+ end`
41
+
42
+ const subcommandCompletions = (spec: CommandSpec): string[] =>
43
+ spec.groups.flatMap((group) =>
44
+ group.subcommands.map(({ name, description }) =>
45
+ complete([
46
+ `-n '__seam_using "${group.path.join(' ')}"'`,
47
+ `-a '${name}'`,
48
+ describe(description),
49
+ ]),
50
+ ),
51
+ )
52
+
53
+ const flagCompletions = (spec: CommandSpec): string[] =>
54
+ spec.commands.flatMap((command) =>
55
+ command.flags.map((flag) =>
56
+ complete([
57
+ `-n '__seam_using "${command.path.join(' ')}"'`,
58
+ ...flagOptions(flag),
59
+ ]),
60
+ ),
61
+ )
62
+
63
+ const globalFlagCompletions = (spec: CommandSpec): string[] =>
64
+ spec.globalFlags.map((flag) => complete(flagOptions(flag)))
65
+
66
+ const flagOptions = (flag: CommandFlag): string[] => [
67
+ ...(flag.short == null ? [] : [`-s ${flag.short}`]),
68
+ ...(flag.long == null ? [] : [`-l ${flag.long}`]),
69
+ ...(flag.takesValue ? ['-r'] : []),
70
+ ...(flag.values.length === 0 ? [] : [`-a '${flag.values.join(' ')}'`]),
71
+ describe(flag.description),
72
+ ]
73
+
74
+ const describe = (description: string): string => {
75
+ const summary = describeForShell(description)
76
+ return summary === '' ? '' : `-d '${summary}'`
77
+ }
78
+
79
+ const complete = (options: string[]): string =>
80
+ ['complete -c seam', ...options.filter((option) => option !== '')].join(' ')