77import chalk from 'chalk' ;
88import { Command , Option } from 'clipanion' ;
99import { existsSync , mkdirSync , writeFileSync } from 'node:fs' ;
10- import { join } from 'node:path' ;
10+ import { join , basename } from 'node:path' ;
1111import { homedir } from 'node:os' ;
1212import {
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' ;
2329import {
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+
834989function 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 ( / \. m d $ / i, '' ) ;
999+
1000+ if ( ! stem || stem . startsWith ( '.' ) || stem . startsWith ( '-' ) ) {
1001+ return null ;
1002+ }
1003+
1004+ if ( ! / ^ [ a - z A - Z 0 - 9 ] [ a - z A - Z 0 - 9 _ - ] * $ / . test ( stem ) ) {
1005+ return null ;
1006+ }
1007+
1008+ if ( stem . length > 64 ) {
1009+ return null ;
1010+ }
1011+
1012+ return stem ;
1013+ }
0 commit comments