diff --git a/src/citty.ts b/src/citty.ts index 4ff6325..d10c6b3 100644 --- a/src/citty.ts +++ b/src/citty.ts @@ -10,6 +10,7 @@ import type { PositionalArgDef, SubCommandsDef, } from 'citty'; +import { generateFigSpec } from './fig'; function quoteIfNeeded(path: string) { return path.includes(' ') ? `'${path}'` : path; @@ -139,6 +140,13 @@ export default async function tab( name: 'complete', description: 'Generate shell completion scripts', }, + args: { + shell: { + type: 'positional', + description: 'Shell type (zsh, bash, fish, powershell, fig)', + required: false, + }, + }, async run(ctx) { let shell: string | undefined = ctx.rawArgs[0]; const extra = ctx.rawArgs.slice(ctx.rawArgs.indexOf('--') + 1); @@ -168,6 +176,11 @@ export default async function tab( console.log(script); break; } + case 'fig': { + const spec = await generateFigSpec(instance); + console.log(spec); + break; + } default: { // const args = (await resolve(instance.args))!; // const parsed = parseArgs(extra, args); diff --git a/src/fig.ts b/src/fig.ts new file mode 100644 index 0000000..8a5f71e --- /dev/null +++ b/src/fig.ts @@ -0,0 +1,122 @@ +import type { CommandDef, ArgsDef, PositionalArgDef, CommandMeta } from 'citty'; + +type FigSpec = { + name: string; + description: string; + options?: FigOption[]; + subcommands?: FigSubcommand[]; + args?: FigArg[]; +}; + +type FigOption = { + name: string; + description: string; + args?: FigArg[]; + isRequired?: boolean; +}; + +type FigSubcommand = { + name: string; + description: string; + options?: FigOption[]; + subcommands?: FigSubcommand[]; + args?: FigArg[]; +}; + +type FigArg = { + name: string; + description?: string; + isOptional?: boolean; + isVariadic?: boolean; + suggestions?: FigSuggestion[]; +}; + +type FigSuggestion = { + name: string; + description?: string; +}; + +async function processArgs( + args: T +): Promise<{ options: FigOption[]; args: FigArg[] }> { + const options: FigOption[] = []; + const positionalArgs: FigArg[] = []; + + for (const [name, arg] of Object.entries(args)) { + if (arg.type === 'positional') { + const positionalArg = arg as PositionalArgDef; + positionalArgs.push({ + name, + description: positionalArg.description, + isOptional: !positionalArg.required, + // Assume variadic if the name suggests it (e.g. [...files]) + isVariadic: name.startsWith('[...') || name.startsWith('<...'), + }); + } else { + const option: FigOption = { + name: `--${name}`, + description: arg.description || '', + isRequired: arg.required, + }; + + if ('alias' in arg && arg.alias) { + // Handle both string and array aliases + const aliases = Array.isArray(arg.alias) ? arg.alias : [arg.alias]; + aliases.forEach((alias) => { + options.push({ + ...option, + name: `-${alias}`, + }); + }); + } + + options.push(option); + } + } + + return { options, args: positionalArgs }; +} + +async function processCommand( + command: CommandDef, + parentName = '' +): Promise { + const resolvedMeta = await Promise.resolve(command.meta); + const meta = resolvedMeta as CommandMeta; + const subCommands = await Promise.resolve(command.subCommands); + + if (!meta || !meta.name) { + throw new Error('Command meta or name is missing'); + } + + const spec: FigSpec = { + name: parentName ? `${parentName} ${meta.name}` : meta.name, + description: meta.description || '', + }; + + if (command.args) { + const resolvedArgs = await Promise.resolve(command.args); + // Cast to ArgsDef since we know the resolved value will be compatible + const { options, args } = await processArgs(resolvedArgs as ArgsDef); + if (options.length > 0) spec.options = options; + if (args.length > 0) spec.args = args; + } + + if (subCommands) { + spec.subcommands = await Promise.all( + Object.entries(subCommands).map(async ([_, subCmd]) => { + const resolved = await Promise.resolve(subCmd); + return processCommand(resolved, spec.name); + }) + ); + } + + return spec; +} + +export async function generateFigSpec( + command: CommandDef +): Promise { + const spec = await processCommand(command); + return JSON.stringify(spec, null, 2); +} diff --git a/tests/fig.test.ts b/tests/fig.test.ts new file mode 100644 index 0000000..d957e6e --- /dev/null +++ b/tests/fig.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import { defineCommand } from 'citty'; +import { generateFigSpec } from '../src/fig'; + +describe('Fig Spec Generator', () => { + it('should generate a valid Fig spec for a simple command', async () => { + const command = defineCommand({ + meta: { + name: 'test-cli', + description: 'A test CLI', + }, + args: { + port: { + type: 'string', + description: 'Port number', + required: true, + }, + host: { + type: 'string', + description: 'Host name', + alias: 'H', + }, + }, + }); + + const spec = await generateFigSpec(command); + const parsed = JSON.parse(spec); + + expect(parsed).toMatchObject({ + name: 'test-cli', + description: 'A test CLI', + options: expect.arrayContaining([ + { + name: '--port', + description: 'Port number', + isRequired: true, + }, + { + name: '--host', + description: 'Host name', + }, + { + name: '-H', + description: 'Host name', + }, + ]), + }); + }); + + it('should generate a valid Fig spec for a command with subcommands', async () => { + const command = defineCommand({ + meta: { + name: 'test-cli', + description: 'A test CLI', + }, + subCommands: { + dev: defineCommand({ + meta: { + name: 'dev', + description: 'Development mode', + }, + args: { + port: { + type: 'string', + description: 'Port number', + }, + }, + }), + build: defineCommand({ + meta: { + name: 'build', + description: 'Build mode', + }, + args: { + output: { + type: 'positional', + description: 'Output directory', + required: true, + }, + }, + }), + }, + }); + + const spec = await generateFigSpec(command); + const parsed = JSON.parse(spec); + + expect(parsed).toMatchObject({ + name: 'test-cli', + description: 'A test CLI', + subcommands: expect.arrayContaining([ + { + name: 'test-cli dev', + description: 'Development mode', + options: [ + { + name: '--port', + description: 'Port number', + }, + ], + }, + { + name: 'test-cli build', + description: 'Build mode', + args: [ + { + name: 'output', + description: 'Output directory', + isOptional: false, + isVariadic: false, + }, + ], + }, + ]), + }); + }); +});