Skip to content

Commit 9316909

Browse files
committed
feat: implement Fig spec generation for CLI commands and add tests
1 parent 978fb7b commit 9316909

File tree

3 files changed

+252
-0
lines changed

3 files changed

+252
-0
lines changed

src/citty.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
PositionalArgDef,
1111
SubCommandsDef,
1212
} from 'citty';
13+
import { generateFigSpec } from './fig';
1314

1415
function quoteIfNeeded(path: string) {
1516
return path.includes(' ') ? `'${path}'` : path;
@@ -139,6 +140,13 @@ export default async function tab<T extends ArgsDef = ArgsDef>(
139140
name: 'complete',
140141
description: 'Generate shell completion scripts',
141142
},
143+
args: {
144+
shell: {
145+
type: 'positional',
146+
description: 'Shell type (zsh, bash, fish, powershell, fig)',
147+
required: false,
148+
},
149+
},
142150
async run(ctx) {
143151
let shell: string | undefined = ctx.rawArgs[0];
144152
const extra = ctx.rawArgs.slice(ctx.rawArgs.indexOf('--') + 1);
@@ -168,6 +176,11 @@ export default async function tab<T extends ArgsDef = ArgsDef>(
168176
console.log(script);
169177
break;
170178
}
179+
case 'fig': {
180+
const spec = await generateFigSpec(instance);
181+
console.log(spec);
182+
break;
183+
}
171184
default: {
172185
// const args = (await resolve(instance.args))!;
173186
// const parsed = parseArgs(extra, args);

src/fig.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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+
}

tests/fig.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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+
});

0 commit comments

Comments
 (0)