[READ-ONLY] Mirror of https://github.com/bombshell-dev/tab. shell autocompletions for javascript CLIs
4

Configure Feed

Select the types of activity you want to include in your feed.

feat: implement Fig spec generation for CLI commands and add tests (#10)

authored by

Paul Valladares and committed by
GitHub
(Feb 25, 2025, 9:10 PM +0330) ebb2dcac 62a5a3bc

+252
+13
src/citty.ts
··· 10 10 PositionalArgDef, 11 11 SubCommandsDef, 12 12 } from 'citty'; 13 + import { generateFigSpec } from './fig'; 13 14 14 15 function quoteIfNeeded(path: string) { 15 16 return path.includes(' ') ? `'${path}'` : path; ··· 139 140 name: 'complete', 140 141 description: 'Generate shell completion scripts', 141 142 }, 143 + args: { 144 + shell: { 145 + type: 'positional', 146 + description: 'Shell type (zsh, bash, fish, powershell, fig)', 147 + required: false, 148 + }, 149 + }, 142 150 async run(ctx) { 143 151 let shell: string | undefined = ctx.rawArgs[0]; 144 152 const extra = ctx.rawArgs.slice(ctx.rawArgs.indexOf('--') + 1); ··· 166 174 case 'powershell': { 167 175 const script = powershell.generate(name, x); 168 176 console.log(script); 177 + break; 178 + } 179 + case 'fig': { 180 + const spec = await generateFigSpec(instance); 181 + console.log(spec); 169 182 break; 170 183 } 171 184 default: {
+122
src/fig.ts
··· 1 + import type { CommandDef, ArgsDef, PositionalArgDef, CommandMeta } from 'citty'; 2 + 3 + type FigSpec = { 4 + name: string; 5 + description: string; 6 + options?: FigOption[]; 7 + subcommands?: FigSubcommand[]; 8 + args?: FigArg[]; 9 + }; 10 + 11 + type FigOption = { 12 + name: string; 13 + description: string; 14 + args?: FigArg[]; 15 + isRequired?: boolean; 16 + }; 17 + 18 + type FigSubcommand = { 19 + name: string; 20 + description: string; 21 + options?: FigOption[]; 22 + subcommands?: FigSubcommand[]; 23 + args?: FigArg[]; 24 + }; 25 + 26 + type FigArg = { 27 + name: string; 28 + description?: string; 29 + isOptional?: boolean; 30 + isVariadic?: boolean; 31 + suggestions?: FigSuggestion[]; 32 + }; 33 + 34 + type FigSuggestion = { 35 + name: string; 36 + description?: string; 37 + }; 38 + 39 + async function processArgs<T extends ArgsDef>( 40 + args: T 41 + ): Promise<{ options: FigOption[]; args: FigArg[] }> { 42 + const options: FigOption[] = []; 43 + const positionalArgs: FigArg[] = []; 44 + 45 + for (const [name, arg] of Object.entries(args)) { 46 + if (arg.type === 'positional') { 47 + const positionalArg = arg as PositionalArgDef; 48 + positionalArgs.push({ 49 + name, 50 + description: positionalArg.description, 51 + isOptional: !positionalArg.required, 52 + // Assume variadic if the name suggests it (e.g. [...files]) 53 + isVariadic: name.startsWith('[...') || name.startsWith('<...'), 54 + }); 55 + } else { 56 + const option: FigOption = { 57 + name: `--${name}`, 58 + description: arg.description || '', 59 + isRequired: arg.required, 60 + }; 61 + 62 + if ('alias' in arg && arg.alias) { 63 + // Handle both string and array aliases 64 + const aliases = Array.isArray(arg.alias) ? arg.alias : [arg.alias]; 65 + aliases.forEach((alias) => { 66 + options.push({ 67 + ...option, 68 + name: `-${alias}`, 69 + }); 70 + }); 71 + } 72 + 73 + options.push(option); 74 + } 75 + } 76 + 77 + return { options, args: positionalArgs }; 78 + } 79 + 80 + async function processCommand<T extends ArgsDef>( 81 + command: CommandDef<T>, 82 + parentName = '' 83 + ): Promise<FigSpec> { 84 + const resolvedMeta = await Promise.resolve(command.meta); 85 + const meta = resolvedMeta as CommandMeta; 86 + const subCommands = await Promise.resolve(command.subCommands); 87 + 88 + if (!meta || !meta.name) { 89 + throw new Error('Command meta or name is missing'); 90 + } 91 + 92 + const spec: FigSpec = { 93 + name: parentName ? `${parentName} ${meta.name}` : meta.name, 94 + description: meta.description || '', 95 + }; 96 + 97 + if (command.args) { 98 + const resolvedArgs = await Promise.resolve(command.args); 99 + // Cast to ArgsDef since we know the resolved value will be compatible 100 + const { options, args } = await processArgs(resolvedArgs as ArgsDef); 101 + if (options.length > 0) spec.options = options; 102 + if (args.length > 0) spec.args = args; 103 + } 104 + 105 + if (subCommands) { 106 + spec.subcommands = await Promise.all( 107 + Object.entries(subCommands).map(async ([_, subCmd]) => { 108 + const resolved = await Promise.resolve(subCmd); 109 + return processCommand(resolved, spec.name); 110 + }) 111 + ); 112 + } 113 + 114 + return spec; 115 + } 116 + 117 + export async function generateFigSpec<T extends ArgsDef>( 118 + command: CommandDef<T> 119 + ): Promise<string> { 120 + const spec = await processCommand(command); 121 + return JSON.stringify(spec, null, 2); 122 + }
+117
tests/fig.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { defineCommand } from 'citty'; 3 + import { generateFigSpec } from '../src/fig'; 4 + 5 + describe('Fig Spec Generator', () => { 6 + it('should generate a valid Fig spec for a simple command', async () => { 7 + const command = defineCommand({ 8 + meta: { 9 + name: 'test-cli', 10 + description: 'A test CLI', 11 + }, 12 + args: { 13 + port: { 14 + type: 'string', 15 + description: 'Port number', 16 + required: true, 17 + }, 18 + host: { 19 + type: 'string', 20 + description: 'Host name', 21 + alias: 'H', 22 + }, 23 + }, 24 + }); 25 + 26 + const spec = await generateFigSpec(command); 27 + const parsed = JSON.parse(spec); 28 + 29 + expect(parsed).toMatchObject({ 30 + name: 'test-cli', 31 + description: 'A test CLI', 32 + options: expect.arrayContaining([ 33 + { 34 + name: '--port', 35 + description: 'Port number', 36 + isRequired: true, 37 + }, 38 + { 39 + name: '--host', 40 + description: 'Host name', 41 + }, 42 + { 43 + name: '-H', 44 + description: 'Host name', 45 + }, 46 + ]), 47 + }); 48 + }); 49 + 50 + it('should generate a valid Fig spec for a command with subcommands', async () => { 51 + const command = defineCommand({ 52 + meta: { 53 + name: 'test-cli', 54 + description: 'A test CLI', 55 + }, 56 + subCommands: { 57 + dev: defineCommand({ 58 + meta: { 59 + name: 'dev', 60 + description: 'Development mode', 61 + }, 62 + args: { 63 + port: { 64 + type: 'string', 65 + description: 'Port number', 66 + }, 67 + }, 68 + }), 69 + build: defineCommand({ 70 + meta: { 71 + name: 'build', 72 + description: 'Build mode', 73 + }, 74 + args: { 75 + output: { 76 + type: 'positional', 77 + description: 'Output directory', 78 + required: true, 79 + }, 80 + }, 81 + }), 82 + }, 83 + }); 84 + 85 + const spec = await generateFigSpec(command); 86 + const parsed = JSON.parse(spec); 87 + 88 + expect(parsed).toMatchObject({ 89 + name: 'test-cli', 90 + description: 'A test CLI', 91 + subcommands: expect.arrayContaining([ 92 + { 93 + name: 'test-cli dev', 94 + description: 'Development mode', 95 + options: [ 96 + { 97 + name: '--port', 98 + description: 'Port number', 99 + }, 100 + ], 101 + }, 102 + { 103 + name: 'test-cli build', 104 + description: 'Build mode', 105 + args: [ 106 + { 107 + name: 'output', 108 + description: 'Output directory', 109 + isOptional: false, 110 + isVariadic: false, 111 + }, 112 + ], 113 + }, 114 + ]), 115 + }); 116 + }); 117 + });