Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bumpy-monkeys-happen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'sv': patch
---

feat(cli): refactor help to give better hints for humans & ai
5 changes: 5 additions & 0 deletions .changeset/proud-times-go.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/sv-utils': patch
---

feat: add color `hidden`
5 changes: 4 additions & 1 deletion packages/sv-utils/src/color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,8 @@ export const color = {
// Status colors
success: (str: string): string => styleText('green', str),
warning: (str: string): string => styleText('yellow', str),
error: (str: string): string => styleText('red', str)
error: (str: string): string => styleText('red', str),

// Visibility
hidden: (str: string): string => styleText('hidden', str)
};
9 changes: 9 additions & 0 deletions packages/sv/bin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env node

import { program } from 'commander';
import process from 'node:process';
import pkg from './package.json' with { type: 'json' };
import { add } from './src/cli/add.ts';
import { check } from './src/cli/check.ts';
Expand All @@ -13,4 +14,12 @@ console.log();

program.name(pkg.name).version(pkg.version, '-v, --version').configureHelp(helpConfig);
program.addCommand(create).addCommand(add).addCommand(migrate).addCommand(check);

// sv --help: show sv + create (which includes the addon reference)
// sv (bare): just the command list
const hasHelpFlag = process.argv.includes('--help') || process.argv.includes('-h');
if (hasHelpFlag) {
program.addHelpText('after', () => '\n' + create.helpInformation());
}

program.parse();
174 changes: 83 additions & 91 deletions packages/sv/src/cli/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,86 +152,28 @@ export const add = new Command('add')
.configureHelp({
...common.helpConfig,
formatHelp(cmd, helper) {
const termWidth = helper.padWidth(cmd, helper);
const helpWidth = helper.helpWidth ?? 80; // in case prepareContext() was not called
const s = common.getHelpSections(cmd, helper);

function callFormatItem(term: string, description: string) {
return helper.formatItem(term, termWidth, description, helper);
}

// Usage
let output = [
`${helper.styleTitle('Usage:')} ${helper.styleUsage(helper.commandUsage(cmd))}`,
''
];

// Description
const commandDescription = helper.commandDescription(cmd);
if (commandDescription.length > 0) {
output = output.concat([
helper.boxWrap(helper.styleCommandDescription(commandDescription), helpWidth),
''
]);
}

// Arguments
const argumentList = helper.visibleArguments(cmd).map((argument) => {
return callFormatItem(
helper.styleArgumentTerm(helper.argumentTerm(argument)),
helper.styleArgumentDescription(helper.argumentDescription(argument))
);
});
if (argumentList.length > 0) {
output = output.concat([helper.styleTitle('Arguments:'), ...argumentList, '']);
}

// Addon Options
const addonList = addonOptions.map((option) => {
const description = option.choices;
return callFormatItem(
helper.styleArgumentTerm(option.id),
helper.styleArgumentDescription(description)
);
});
if (addonList.length > 0) {
output = output.concat([helper.styleTitle('Add-On Options:'), ...addonList, '']);
}

// Options
const optionList = helper.visibleOptions(cmd).map((option) => {
return callFormatItem(
helper.styleOptionTerm(helper.optionTerm(option)),
helper.styleOptionDescription(helper.optionDescription(option))
);
});
if (optionList.length > 0) {
output = output.concat([helper.styleTitle('Options:'), ...optionList, '']);
}

if (helper.showGlobalOptions) {
const globalOptionList = helper.visibleGlobalOptions(cmd).map((option) => {
return callFormatItem(
helper.styleOptionTerm(helper.optionTerm(option)),
helper.styleOptionDescription(helper.optionDescription(option))
);
});
if (globalOptionList.length > 0) {
output = output.concat([helper.styleTitle('Global Options:'), ...globalOptionList, '']);
}
}

// Commands
const commandList = helper.visibleCommands(cmd).map((cmd) => {
return callFormatItem(
helper.styleSubcommandTerm(helper.subcommandTerm(cmd)),
helper.styleSubcommandDescription(helper.subcommandDescription(cmd))
);
const addonSection = formatAddonHelpSection({
styleTitle: s.styleTitle,
formatItem: (term, desc) =>
s.formatItem(helper.styleArgumentTerm(term), helper.styleArgumentDescription(desc))
});
if (commandList.length > 0) {
output = output.concat([helper.styleTitle('Commands:'), ...commandList, '']);
}

return output.join('\n');
return [
...s.usage,
...s.description,
...s.arguments,
...addonSection,
...s.options,
...s.globalOptions,
...s.commands,
s.styleTitle('Examples:'),
' sv add prettier eslint',
' sv add vitest="usages:unit" tailwindcss="plugins:none"',
' sv add drizzle="database:postgresql+client:postgres.js+docker:yes"',
''
].join('\n');
}
})
.action(async (addonInputs: AddonInput[], opts) => {
Expand Down Expand Up @@ -879,67 +821,117 @@ export function addonArgsHandler(acc: AddonInput[], current: string): AddonInput
return acc;
}

function getAddonOptionFlags() {
const options: Array<{ id: string; choices: string; preset: string }> = [];
export function getOfficialAddonIds(): string[] {
return officialAddons.map((a) => a.id);
}

export function getAddonOptionFlags() {
const options: Array<{ id: string; choices: string }> = [];
for (const addon of officialAddons) {
const id = addon.id;
const details = getAddonDetails(id);
if (Object.values(details.options).length === 0) continue;

const { defaults, groups } = getOptionChoices(details);
const { groups, groupDefaults } = getOptionChoices(details);
const choices = Object.entries(groups)
.map(([group, choices]) => `${color.optional(`${group}:`)} ${color.dim(choices.join(', '))}`)
.map(([group, choices]) => {
const defaults = groupDefaults[group];
const defaultStr =
defaults === undefined
? ''
: defaults.length > 0
? ` (default: ${defaults.join(', ')})`
: ' (default: none)';
return `${color.optional(`${group}:`)} ${color.dim(choices.join(', '))}${defaultStr}`;
})
.join('\n');
const preset = defaults.join(', ') || 'none';
options.push({ id, choices, preset });
options.push({ id, choices });
}
return options;
}

/**
* Shared addon help section used by `add --help`, `create --help`, and `sv --help`.
* Returns formatted lines showing all addons, their options, and syntax examples.
*/
export function formatAddonHelpSection(opts: {
styleTitle: (s: string) => string;
formatItem: (term: string, desc: string) => string;
}): string[] {
const { styleTitle, formatItem } = opts;
const output: string[] = [];

// All add-ons: those with options show their choices and defaults
const allIds = getOfficialAddonIds();
const withOptionsMap = new Map(addonOptions.map((o) => [o.id, o]));
const addonList = allIds.map((id) => {
const option = withOptionsMap.get(id);
if (!option) return formatItem(id, '(no options)');
return formatItem(id, option.choices);
});
if (addonList.length > 0) {
output.push(styleTitle('Add-Ons:'), ...addonList, '');
}

// Syntax
output.push(
styleTitle('Add-On Syntax:'),
' <addon> add with defaults (may still prompt)',
' <addon>=<opt>:<val> set a single option',
' <addon>=<opt1>:<val1>+<opt2>:<val2> set multiple options',
' <addon>=<opt>:none explicitly set no value (for multiselect)',
' To skip prompts, explicitly set ALL options (use defaults shown above).',
''
);

return output;
}

function getOptionChoices(details: AddonDefinition) {
const choices: string[] = [];
const defaults: string[] = [];
const groups: Record<string, string[]> = {};
const groupDefaults: Record<string, string[]> = {};
const options: OptionValues<any> = {};
for (const [id, question] of Object.entries(details.options)) {
let values: string[] = [];
const applyDefault = question.condition?.(options) !== false;
const groupId = question.group ?? id;
groupDefaults[groupId] ??= [];

if (question.type === 'boolean') {
values = ['yes', `no`];
if (applyDefault) {
options[id] = question.default;
defaults.push((question.default ? values[0] : values[1])!);
groupDefaults[groupId].push((question.default ? values[0] : values[1])!);
}
}
if (question.type === 'select') {
values = question.options.map((o) => o.value);
if (applyDefault) {
options[id] = question.default;
defaults.push(question.default);
groupDefaults[groupId].push(question.default);
}
}
if (question.type === 'multiselect') {
values = question.options.map((o) => o.value);
if (applyDefault) {
options[id] = question.default;
defaults.push(...question.default);
groupDefaults[groupId].push(...question.default);
}
}
if (question.type === 'string' || question.type === 'number') {
values = ['<user-input>'];
if (applyDefault && question.default !== undefined) {
options[id] = question.default;
defaults.push(question.default.toString());
groupDefaults[groupId].push(question.default.toString());
}
}

choices.push(...values);
// we'll fallback to the question's id
const groupId = question.group ?? id;
groups[groupId] ??= [];
groups[groupId].push(...values);
}
return { choices, defaults, groups };
return { choices, groups, groupDefaults };
}

export async function resolveNonOfficialAddons(
Expand Down
38 changes: 35 additions & 3 deletions packages/sv/src/cli/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { dist } from '../create/utils.ts';
import {
addonArgsHandler,
classifyAddons,
formatAddonHelpSection,
promptAddonQuestions,
resolveAddons,
runAddonsApply,
Expand All @@ -52,7 +53,10 @@ const templateOption = new Option('--template <type>', 'template to scaffold').c
templateChoices
);
const noAddonsOption = new Option('--no-add-ons', 'do not prompt to add add-ons').conflicts('add');
const addOption = new Option('--add <addon...>', 'add-on to include').default([]);
const addOption = new Option(
'--add <addon...>',
'add-ons to include (see Add-Ons section below)'
).default([]);
export const noDownloadCheckOption = new Option(
'--no-download-check',
'skip all download confirmation prompts'
Expand All @@ -77,7 +81,7 @@ type Options = v.InferOutput<typeof OptionsSchema>;
type ProjectPath = v.InferOutput<typeof ProjectPathSchema>;

export const create = new Command('create')
.description('scaffolds a new SvelteKit project')
.description('Scaffold a new project (--add to include add-ons)')
.argument('[path]', 'where the project will be created')
.addOption(templateOption)
.addOption(langOption)
Expand All @@ -89,7 +93,35 @@ export const create = new Command('create')
.option('--no-dir-check', 'even if the folder is not empty, no prompt will be shown')
.addOption(noDownloadCheckOption)
.addOption(installOption)
.configureHelp(common.helpConfig)
.configureHelp({
...common.helpConfig,
formatHelp(cmd, helper) {
const s = common.getHelpSections(cmd, helper);

const addonSection = formatAddonHelpSection({
styleTitle: s.styleTitle,
formatItem: (term, desc) =>
s.formatItem(helper.styleArgumentTerm(term), helper.styleArgumentDescription(desc))
});

return [
...s.usage,
...s.description,
...s.arguments,
...s.options,
...addonSection,
s.styleTitle('Non-interactive usage:'),
' Provide --template, --types, --add, and --install (or --no-install) to skip prompts entirely.',
' Note: --add and --no-add-ons cannot be used together.',
'',
s.styleTitle('Examples:'),
' sv create my-app --template minimal --types ts --add prettier eslint --install pnpm',
' sv create my-app --template minimal --types ts --add prettier vitest="usages:unit" tailwindcss="plugins:none" --install pnpm',
' sv create my-app --template minimal --types ts --add drizzle="database:postgresql+client:postgres.js" --no-install',
''
].join('\n');
}
})
.action((projectPath, opts) => {
const cwd = v.parse(ProjectPathSchema, projectPath);
const options = v.parse(OptionsSchema, opts);
Expand Down
Loading
Loading