Skip to content

Commit 847af0f

Browse files
authored
feat(agents): add skill-to-subagent converter command (#34)
* feat(agents): add skill-to-subagent converter command Add `skillkit agent from-skill` command to convert SkillKit skills into Claude Code native subagent format (.md files in .claude/agents/). Features: - Reference mode (default): generates subagent with `skills: [skill-name]` - Inline mode (--inline): embeds full skill content in system prompt - Options: --model, --permission, --global, --output, --dry-run New files: - packages/core/src/agents/skill-converter.ts - packages/core/src/agents/__tests__/skill-converter.test.ts (23 tests) Closes #22 * fix(agents): address code review feedback - Update CLI option descriptions to list all valid values (model: inherit, permission: bypassPermissions) - Add filename sanitization to prevent path traversal attacks via --output - Fix parseAllowedTools to handle both string and YAML array formats - Support space-separated tool lists in allowed-tools - Add tests for array and space-separated formats
1 parent 34e8cd0 commit 847af0f

File tree

7 files changed

+780
-8
lines changed

7 files changed

+780
-8
lines changed

apps/skillkit/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
AgentListCommand,
5353
AgentShowCommand,
5454
AgentCreateCommand,
55+
AgentFromSkillCommand,
5556
AgentTranslateCommand,
5657
AgentSyncCommand,
5758
AgentValidateCommand,
@@ -153,6 +154,7 @@ cli.register(AgentCommand);
153154
cli.register(AgentListCommand);
154155
cli.register(AgentShowCommand);
155156
cli.register(AgentCreateCommand);
157+
cli.register(AgentFromSkillCommand);
156158
cli.register(AgentTranslateCommand);
157159
cli.register(AgentSyncCommand);
158160
cli.register(AgentValidateCommand);

packages/cli/src/commands/agent.ts

Lines changed: 181 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import chalk from 'chalk';
88
import { Command, Option } from 'clipanion';
99
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
10-
import { join } from 'node:path';
10+
import { join, basename } from 'node:path';
1111
import { homedir } from 'node:os';
1212
import {
1313
findAllAgents,
@@ -17,8 +17,14 @@ import {
1717
validateAgent,
1818
translateAgent,
1919
getAgentTargetDirectory,
20+
discoverSkills,
21+
readSkillContent,
22+
generateSubagentFromSkill,
2023
type CustomAgent,
2124
type AgentType,
25+
type Skill,
26+
type AgentPermissionMode,
27+
type SkillToSubagentOptions,
2228
} from '@skillkit/core';
2329
import {
2430
getBundledAgents,
@@ -43,17 +49,19 @@ export class AgentCommand extends Command {
4349
that can be invoked with @mentions or the --agent flag.
4450
4551
Sub-commands:
46-
agent list - List all installed agents
47-
agent show - Show agent details
48-
agent create - Create a new agent
49-
agent translate - Translate agents between formats
50-
agent sync - Sync agents to target AI agent
51-
agent validate - Validate agent definitions
52+
agent list - List all installed agents
53+
agent show - Show agent details
54+
agent create - Create a new agent
55+
agent from-skill - Convert a skill to a subagent
56+
agent translate - Translate agents between formats
57+
agent sync - Sync agents to target AI agent
58+
agent validate - Validate agent definitions
5259
`,
5360
examples: [
5461
['List all agents', '$0 agent list'],
5562
['Show agent details', '$0 agent show architect'],
5663
['Create new agent', '$0 agent create security-reviewer'],
64+
['Convert skill to subagent', '$0 agent from-skill code-simplifier'],
5765
['Translate to Cursor format', '$0 agent translate --to cursor'],
5866
['Sync agents', '$0 agent sync --agent claude-code'],
5967
],
@@ -64,6 +72,7 @@ export class AgentCommand extends Command {
6472
console.log(' agent list List all installed agents');
6573
console.log(' agent show <name> Show agent details');
6674
console.log(' agent create <name> Create a new agent');
75+
console.log(' agent from-skill <name> Convert a skill to a subagent');
6776
console.log(' agent translate Translate agents between formats');
6877
console.log(' agent sync Sync agents to target AI agent');
6978
console.log(' agent validate [path] Validate agent definitions');
@@ -831,9 +840,174 @@ export class AgentAvailableCommand extends Command {
831840
}
832841
}
833842

843+
export class AgentFromSkillCommand extends Command {
844+
static override paths = [['agent', 'from-skill']];
845+
846+
static override usage = Command.Usage({
847+
description: 'Convert a skill into a Claude Code subagent',
848+
details: `
849+
Converts a SkillKit skill into a Claude Code native subagent format.
850+
The generated .md file can be used with @mentions in Claude Code.
851+
852+
By default, the subagent references the skill (skills: [skill-name]).
853+
Use --inline to embed the full skill content in the system prompt.
854+
`,
855+
examples: [
856+
['Convert skill to subagent', '$0 agent from-skill code-simplifier'],
857+
['Create global subagent', '$0 agent from-skill code-simplifier --global'],
858+
['Embed skill content inline', '$0 agent from-skill code-simplifier --inline'],
859+
['Set model for subagent', '$0 agent from-skill code-simplifier --model opus'],
860+
['Preview without writing', '$0 agent from-skill code-simplifier --dry-run'],
861+
],
862+
});
863+
864+
skillName = Option.String({ required: true });
865+
866+
inline = Option.Boolean('--inline,-i', false, {
867+
description: 'Embed full skill content in system prompt',
868+
});
869+
870+
model = Option.String('--model,-m', {
871+
description: 'Model to use (sonnet, opus, haiku, inherit)',
872+
});
873+
874+
permission = Option.String('--permission,-p', {
875+
description: 'Permission mode (default, plan, auto-edit, full-auto, bypassPermissions)',
876+
});
877+
878+
global = Option.Boolean('--global,-g', false, {
879+
description: 'Create in ~/.claude/agents/ instead of .claude/agents/',
880+
});
881+
882+
output = Option.String('--output,-o', {
883+
description: 'Custom output filename (without .md)',
884+
});
885+
886+
dryRun = Option.Boolean('--dry-run,-n', false, {
887+
description: 'Preview without writing files',
888+
});
889+
890+
async execute(): Promise<number> {
891+
const skills = discoverSkills(process.cwd());
892+
const skill = skills.find((s: Skill) => s.name === this.skillName);
893+
894+
if (!skill) {
895+
console.log(chalk.red(`Skill not found: ${this.skillName}`));
896+
console.log(chalk.dim('Available skills:'));
897+
for (const s of skills.slice(0, 10)) {
898+
console.log(chalk.dim(` - ${s.name}`));
899+
}
900+
if (skills.length > 10) {
901+
console.log(chalk.dim(` ... and ${skills.length - 10} more`));
902+
}
903+
return 1;
904+
}
905+
906+
const skillContent = readSkillContent(skill.path);
907+
if (!skillContent) {
908+
console.log(chalk.red(`Could not read skill content: ${skill.path}`));
909+
return 1;
910+
}
911+
912+
const options: SkillToSubagentOptions = {
913+
inline: this.inline,
914+
};
915+
916+
if (this.model) {
917+
const validModels = ['sonnet', 'opus', 'haiku', 'inherit'];
918+
if (!validModels.includes(this.model)) {
919+
console.log(chalk.red(`Invalid model: ${this.model}`));
920+
console.log(chalk.dim(`Valid options: ${validModels.join(', ')}`));
921+
return 1;
922+
}
923+
options.model = this.model as 'sonnet' | 'opus' | 'haiku' | 'inherit';
924+
}
925+
926+
if (this.permission) {
927+
const validModes = ['default', 'plan', 'auto-edit', 'full-auto', 'bypassPermissions'];
928+
if (!validModes.includes(this.permission)) {
929+
console.log(chalk.red(`Invalid permission mode: ${this.permission}`));
930+
console.log(chalk.dim(`Valid options: ${validModes.join(', ')}`));
931+
return 1;
932+
}
933+
options.permissionMode = this.permission as AgentPermissionMode;
934+
}
935+
936+
const content = generateSubagentFromSkill(skill, skillContent, options);
937+
938+
const targetDir = this.global
939+
? join(homedir(), '.claude', 'agents')
940+
: join(process.cwd(), '.claude', 'agents');
941+
942+
let filename: string;
943+
if (this.output) {
944+
const sanitized = sanitizeFilename(this.output);
945+
if (!sanitized) {
946+
console.log(chalk.red(`Invalid output filename: ${this.output}`));
947+
console.log(chalk.dim('Filename must contain only alphanumeric characters, hyphens, and underscores'));
948+
return 1;
949+
}
950+
filename = `${sanitized}.md`;
951+
} else {
952+
filename = `${skill.name}.md`;
953+
}
954+
955+
const outputPath = join(targetDir, filename);
956+
957+
if (this.dryRun) {
958+
console.log(chalk.cyan('Preview (dry run):\n'));
959+
console.log(chalk.dim(`Would write to: ${outputPath}`));
960+
console.log(chalk.dim('─'.repeat(50)));
961+
console.log(content);
962+
console.log(chalk.dim('─'.repeat(50)));
963+
return 0;
964+
}
965+
966+
if (!existsSync(targetDir)) {
967+
mkdirSync(targetDir, { recursive: true });
968+
}
969+
970+
if (existsSync(outputPath)) {
971+
console.log(chalk.yellow(`Overwriting existing file: ${outputPath}`));
972+
}
973+
974+
writeFileSync(outputPath, content);
975+
976+
console.log(chalk.green(`Created subagent: ${outputPath}`));
977+
console.log();
978+
console.log(chalk.dim(`Invoke with: @${skill.name}`));
979+
if (!this.inline) {
980+
console.log(chalk.dim(`Skills referenced: ${skill.name}`));
981+
} else {
982+
console.log(chalk.dim('Skill content embedded inline'));
983+
}
984+
985+
return 0;
986+
}
987+
}
988+
834989
function formatCategoryName(category: string): string {
835990
return category
836991
.split('-')
837992
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
838993
.join(' ');
839994
}
995+
996+
function sanitizeFilename(input: string): string | null {
997+
const base = basename(input);
998+
const stem = base.replace(/\.md$/i, '');
999+
1000+
if (!stem || stem.startsWith('.') || stem.startsWith('-')) {
1001+
return null;
1002+
}
1003+
1004+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(stem)) {
1005+
return null;
1006+
}
1007+
1008+
if (stem.length > 64) {
1009+
return null;
1010+
}
1011+
1012+
return stem;
1013+
}

packages/cli/src/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export {
4949
AgentListCommand,
5050
AgentShowCommand,
5151
AgentCreateCommand,
52+
AgentFromSkillCommand,
5253
AgentTranslateCommand,
5354
AgentSyncCommand,
5455
AgentValidateCommand,

0 commit comments

Comments
 (0)