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
256 changes: 204 additions & 52 deletions src/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -861,10 +861,6 @@ async function handleWellKnownSkills(
}
process.exit(1);
}

p.log.info(
`Selected ${selectedSkills.length} skill${selectedSkills.length !== 1 ? 's' : ''}: ${selectedSkills.map((s) => pc.cyan(s.installName)).join(', ')}`
);
} else if (skills.length === 1) {
selectedSkills = skills;
const firstSkill = skills[0]!;
Expand Down Expand Up @@ -1666,10 +1662,47 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise<
if (options.list) {
console.log();
p.log.step(pc.bold('Available Skills'));

// Group available skills by plugin for list output
const groupedSkills: Record<string, Skill[]> = {};
const ungroupedSkills: Skill[] = [];

for (const skill of skills) {
p.log.message(` ${pc.cyan(getSkillDisplayName(skill))}`);
p.log.message(` ${pc.dim(skill.description)}`);
if (skill.pluginName) {
const group = skill.pluginName;
if (!groupedSkills[group]) groupedSkills[group] = [];
groupedSkills[group].push(skill);
} else {
ungroupedSkills.push(skill);
}
}

// Print groups
const sortedGroups = Object.keys(groupedSkills).sort();
for (const group of sortedGroups) {
// Convert kebab-case to Title Case for display header
const title = group
.split('-')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');

console.log(pc.bold(title));
for (const skill of groupedSkills[group]!) {
p.log.message(` ${pc.cyan(getSkillDisplayName(skill))}`);
p.log.message(` ${pc.dim(skill.description)}`);
}
console.log();
}

// Print ungrouped
if (ungroupedSkills.length > 0) {
if (sortedGroups.length > 0) console.log(pc.bold('General'));
for (const skill of ungroupedSkills) {
p.log.message(` ${pc.cyan(getSkillDisplayName(skill))}`);
p.log.message(` ${pc.dim(skill.description)}`);
}
}

console.log();
p.outro('Use --skill <name> to install specific skills');
await cleanup(tempDir);
Expand Down Expand Up @@ -1707,18 +1740,59 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise<
selectedSkills = skills;
p.log.info(`Installing all ${skills.length} skills`);
} else {
const skillChoices = skills.map((s) => ({
value: s,
label: getSkillDisplayName(s),
hint: s.description.length > 60 ? s.description.slice(0, 57) + '...' : s.description,
}));

const selected = await multiselect({
message: 'Select skills to install',
options: skillChoices,
required: true,
// Sort skills by plugin name first, then by skill name
const sortedSkills = [...skills].sort((a, b) => {
if (a.pluginName && !b.pluginName) return -1;
if (!a.pluginName && b.pluginName) return 1;
if (a.pluginName && b.pluginName && a.pluginName !== b.pluginName) {
return a.pluginName.localeCompare(b.pluginName);
}
return getSkillDisplayName(a).localeCompare(getSkillDisplayName(b));
});

// Check if any skills have plugin grouping
const hasGroups = sortedSkills.some((s) => s.pluginName);

let selected: Skill[] | symbol;

if (hasGroups) {
// Build grouped options for groupMultiselect
const kebabToTitle = (s: string) =>
s
.split('-')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');

const grouped: Record<string, p.Option<Skill>[]> = {};
for (const s of sortedSkills) {
const groupName = s.pluginName ? kebabToTitle(s.pluginName) : 'Other';
if (!grouped[groupName]) grouped[groupName] = [];
grouped[groupName]!.push({
value: s,
label: getSkillDisplayName(s),
hint: s.description.length > 60 ? s.description.slice(0, 57) + '...' : s.description,
});
}

selected = await p.groupMultiselect({
message: `Select skills to install ${pc.dim('(space to toggle)')}`,
options: grouped,
required: true,
});
} else {
const skillChoices = sortedSkills.map((s) => ({
value: s,
label: getSkillDisplayName(s),
hint: s.description.length > 60 ? s.description.slice(0, 57) + '...' : s.description,
}));

selected = await multiselect({
message: 'Select skills to install',
options: skillChoices,
required: true,
});
}

if (p.isCancel(selected)) {
p.cancel('Installation cancelled');
await cleanup(tempDir);
Expand Down Expand Up @@ -1892,22 +1966,61 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise<
overwriteStatus.get(skillName)!.set(agent, installed);
}

// Group selected skills for summary
const groupedSummary: Record<string, Skill[]> = {};
const ungroupedSummary: Skill[] = [];

for (const skill of selectedSkills) {
if (summaryLines.length > 0) summaryLines.push('');
if (skill.pluginName) {
const group = skill.pluginName;
if (!groupedSummary[group]) groupedSummary[group] = [];
groupedSummary[group].push(skill);
} else {
ungroupedSummary.push(skill);
}
}

// Helper to print summary lines for a list of skills
const printSkillSummary = (skills: Skill[]) => {
for (const skill of skills) {
if (summaryLines.length > 0) summaryLines.push('');

const canonicalPath = getCanonicalPath(skill.name, { global: installGlobally });
const shortCanonical = shortenPath(canonicalPath, cwd);
summaryLines.push(`${pc.cyan(shortCanonical)}`);
summaryLines.push(...buildAgentSummaryLines(targetAgents, installMode));

const skillOverwrites = overwriteStatus.get(skill.name);
const overwriteAgents = targetAgents
.filter((a) => skillOverwrites?.get(a))
.map((a) => agents[a].displayName);

if (overwriteAgents.length > 0) {
summaryLines.push(` ${pc.yellow('overwrites:')} ${formatList(overwriteAgents)}`);
}
}
};

// Build grouped summary
const sortedGroups = Object.keys(groupedSummary).sort();

const canonicalPath = getCanonicalPath(skill.name, { global: installGlobally });
const shortCanonical = shortenPath(canonicalPath, cwd);
summaryLines.push(`${pc.cyan(shortCanonical)}`);
summaryLines.push(...buildAgentSummaryLines(targetAgents, installMode));
for (const group of sortedGroups) {
const title = group
.split('-')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');

const skillOverwrites = overwriteStatus.get(skill.name);
const overwriteAgents = targetAgents
.filter((a) => skillOverwrites?.get(a))
.map((a) => agents[a].displayName);
summaryLines.push('');
summaryLines.push(pc.bold(title));
printSkillSummary(groupedSummary[group]!);
}

if (overwriteAgents.length > 0) {
summaryLines.push(` ${pc.yellow('overwrites:')} ${formatList(overwriteAgents)}`);
if (ungroupedSummary.length > 0) {
if (sortedGroups.length > 0) {
summaryLines.push('');
summaryLines.push(pc.bold('General'));
}
printSkillSummary(ungroupedSummary);
}

console.log();
Expand Down Expand Up @@ -1955,6 +2068,7 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise<
mode: InstallMode;
symlinkFailed?: boolean;
error?: string;
pluginName?: string;
}[] = [];

for (const skill of selectedSkills) {
Expand All @@ -1966,6 +2080,7 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise<
results.push({
skill: getSkillDisplayName(skill),
agent: agents[agent].displayName,
pluginName: skill.pluginName,
...result,
});
}
Expand Down Expand Up @@ -2056,6 +2171,7 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise<
sourceUrl: parsed.url,
skillPath: skillPathValue,
skillFolderHash,
pluginName: skill.pluginName,
});
} catch {
// Don't fail installation if lock file update fails
Expand Down Expand Up @@ -2090,37 +2206,79 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise<

if (successful.length > 0) {
const bySkill = new Map<string, typeof results>();

// Group results by plugin name
const groupedResults: Record<string, typeof results> = {};
const ungroupedResults: typeof results = [];

for (const r of successful) {
const skillResults = bySkill.get(r.skill) || [];
skillResults.push(r);
bySkill.set(r.skill, skillResults);

// We only need to group once per skill (take the first result for that skill)
if (skillResults.length === 1) {
if (r.pluginName) {
const group = r.pluginName;
if (!groupedResults[group]) groupedResults[group] = [];
// We'll store just one entry per skill here to drive the loop
groupedResults[group].push(r);
} else {
ungroupedResults.push(r);
}
}
}

const skillCount = bySkill.size;
const symlinkFailures = successful.filter((r) => r.mode === 'symlink' && r.symlinkFailed);
const copiedAgents = symlinkFailures.map((r) => r.agent);
const resultLines: string[] = [];

for (const [skillName, skillResults] of bySkill) {
const firstResult = skillResults[0]!;

if (firstResult.mode === 'copy') {
// Copy mode: show skill name and list all agent paths
resultLines.push(`${pc.green('✓')} ${skillName} ${pc.dim('(copied)')}`);
for (const r of skillResults) {
const shortPath = shortenPath(r.path, cwd);
resultLines.push(` ${pc.dim('→')} ${shortPath}`);
}
} else {
// Symlink mode: show canonical path and universal/symlinked agents
if (firstResult.canonicalPath) {
const shortPath = shortenPath(firstResult.canonicalPath, cwd);
resultLines.push(`${pc.green('✓')} ${shortPath}`);
const printSkillResults = (entries: typeof results) => {
for (const entry of entries) {
const skillResults = bySkill.get(entry.skill) || [];
const firstResult = skillResults[0]!;

if (firstResult.mode === 'copy') {
// Copy mode: show skill name and list all agent paths
resultLines.push(`${pc.green('✓')} ${entry.skill} ${pc.dim('(copied)')}`);
for (const r of skillResults) {
const shortPath = shortenPath(r.path, cwd);
resultLines.push(` ${pc.dim('→')} ${shortPath}`);
}
} else {
resultLines.push(`${pc.green('✓')} ${skillName}`);
// Symlink mode: show canonical path and universal/symlinked agents
if (firstResult.canonicalPath) {
const shortPath = shortenPath(firstResult.canonicalPath, cwd);
resultLines.push(`${pc.green('✓')} ${shortPath}`);
} else {
resultLines.push(`${pc.green('✓')} ${entry.skill}`);
}
resultLines.push(...buildResultLines(skillResults, targetAgents));
}
resultLines.push(...buildResultLines(skillResults, targetAgents));
}
};

// Print grouped results
const sortedResultGroups = Object.keys(groupedResults).sort();

for (const group of sortedResultGroups) {
const title = group
.split('-')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');

resultLines.push('');
resultLines.push(pc.bold(title));
printSkillResults(groupedResults[group]!);
}

if (ungroupedResults.length > 0) {
if (sortedResultGroups.length > 0) {
resultLines.push('');
resultLines.push(pc.bold('General'));
}
printSkillResults(ungroupedResults);
}

const title = pc.green(`Installed ${skillCount} skill${skillCount !== 1 ? 's' : ''}`);
Expand Down Expand Up @@ -2171,6 +2329,7 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise<
}
}

// Cleanup helper
async function cleanup(tempDir: string | null) {
if (tempDir) {
try {
Expand All @@ -2183,10 +2342,6 @@ async function cleanup(tempDir: string | null) {

/**
* Prompt user to install the find-skills skill after their first installation.
* This helps users discover skills via their coding agent.
* The prompt is only shown once - if dismissed, it's stored in the lock file.
*
* @param options - Installation options, used to check for -y/--yes flag
*/
async function promptForFindSkills(
options?: AddOptions,
Expand Down Expand Up @@ -2223,8 +2378,6 @@ async function promptForFindSkills(

if (install) {
// Install find-skills to the same agents the user selected, excluding replit
// (replit doesn't support global skill installation)
// Mark as dismissed first to prevent recursive prompts
await dismissPrompt('findSkillsPrompt');

// Filter out replit from target agents
Expand All @@ -2239,8 +2392,7 @@ async function promptForFindSkills(
p.log.step('Installing find-skills skill...');

try {
// Call runAdd directly instead of spawning subprocess
// Use the same agents that were selected for the original install (minus replit)
// Call runAdd directly
await runAdd(['vercel-labs/skills'], {
skill: ['find-skills'],
global: true,
Expand Down
Loading