@@ -14,6 +14,7 @@ use rexos::{
1414} ;
1515
1616mod doctor;
17+ mod skills;
1718
1819#[ derive( Debug , Clone , serde:: Serialize ) ]
1920struct ConfigValidationReport {
@@ -119,6 +120,11 @@ enum Command {
119120 #[ command( subcommand) ]
120121 command : ConfigCommand ,
121122 } ,
123+ /// Skills discovery, doctor and execution helpers
124+ Skills {
125+ #[ command( subcommand) ]
126+ command : SkillsCommand ,
127+ } ,
122128 /// Long-running harness helpers (initializer + sessions)
123129 Harness {
124130 #[ command( subcommand) ]
@@ -146,6 +152,59 @@ enum ConfigCommand {
146152 } ,
147153}
148154
155+ #[ derive( Debug , clap:: Subcommand ) ]
156+ enum SkillsCommand {
157+ /// List discovered skills (workspace + ~/.codex/skills)
158+ List {
159+ /// Workspace root directory
160+ #[ arg( long, default_value = "." ) ]
161+ workspace : PathBuf ,
162+ /// Print JSON output (machine-readable)
163+ #[ arg( long) ]
164+ json : bool ,
165+ } ,
166+ /// Show one skill's resolved metadata
167+ Show {
168+ /// Skill name
169+ name : String ,
170+ /// Workspace root directory
171+ #[ arg( long, default_value = "." ) ]
172+ workspace : PathBuf ,
173+ /// Print JSON output (machine-readable)
174+ #[ arg( long) ]
175+ json : bool ,
176+ } ,
177+ /// Diagnose skill manifest and entry issues
178+ Doctor {
179+ /// Workspace root directory
180+ #[ arg( long, default_value = "." ) ]
181+ workspace : PathBuf ,
182+ /// Print JSON output (machine-readable)
183+ #[ arg( long) ]
184+ json : bool ,
185+ /// Exit non-zero on warnings too
186+ #[ arg( long) ]
187+ strict : bool ,
188+ } ,
189+ /// Execute one skill with real runtime tools and model routing
190+ Run {
191+ /// Skill name
192+ name : String ,
193+ /// Workspace root directory
194+ #[ arg( long, default_value = "." ) ]
195+ workspace : PathBuf ,
196+ /// Input payload passed to the skill
197+ #[ arg( long) ]
198+ input : String ,
199+ /// Optional session id (generated per-workspace if omitted)
200+ #[ arg( long) ]
201+ session : Option < String > ,
202+ /// Task kind for model routing
203+ #[ arg( long, value_enum, default_value_t = AgentKind :: Coding ) ]
204+ kind : AgentKind ,
205+ } ,
206+ }
207+
149208#[ derive( Debug , clap:: Subcommand ) ]
150209enum HarnessCommand {
151210 /// Initialize a workspace directory for long-running agent sessions
@@ -620,6 +679,190 @@ async fn main() -> anyhow::Result<()> {
620679 }
621680 }
622681 } ,
682+ Command :: Skills { command } => match command {
683+ SkillsCommand :: List { workspace, json } => {
684+ let list = skills:: list_skills ( & workspace) ?;
685+ if json {
686+ println ! ( "{}" , serde_json:: to_string_pretty( & list) ?) ;
687+ } else if list. is_empty ( ) {
688+ println ! ( "no skills discovered" ) ;
689+ } else {
690+ for item in list {
691+ println ! (
692+ "{} v{} source={} entry={}" ,
693+ item. name, item. version, item. source, item. entry_path
694+ ) ;
695+ }
696+ }
697+ }
698+ SkillsCommand :: Show {
699+ name,
700+ workspace,
701+ json,
702+ } => {
703+ let skill = skills:: find_skill ( & workspace, & name) ?;
704+ let item = serde_json:: json!( {
705+ "name" : skill. name,
706+ "version" : skill. manifest. version. to_string( ) ,
707+ "source" : skills:: source_name( skill. source) ,
708+ "root_dir" : skill. root_dir,
709+ "manifest_path" : skill. manifest_path,
710+ "entry" : skill. manifest. entry,
711+ "permissions" : skill. manifest. permissions,
712+ "dependencies" : skill
713+ . manifest
714+ . dependencies
715+ . iter( )
716+ . map( |d| serde_json:: json!( {
717+ "name" : d. name,
718+ "version_req" : d. version_req. to_string( ) ,
719+ } ) )
720+ . collect:: <Vec <_>>( ) ,
721+ } ) ;
722+ if json {
723+ println ! ( "{}" , serde_json:: to_string_pretty( & item) ?) ;
724+ } else {
725+ println ! ( "{}" , serde_json:: to_string_pretty( & item) ?) ;
726+ }
727+ }
728+ SkillsCommand :: Doctor {
729+ workspace,
730+ json,
731+ strict,
732+ } => {
733+ let report = skills:: doctor ( & workspace) ?;
734+ if json {
735+ println ! ( "{}" , serde_json:: to_string_pretty( & report) ?) ;
736+ } else {
737+ println ! ( "discovered_skills: {}" , report. discovered_count) ;
738+ if report. issues . is_empty ( ) {
739+ println ! ( "doctor: ok" ) ;
740+ } else {
741+ for issue in & report. issues {
742+ let level = match issue. level {
743+ skills:: SkillsDoctorLevel :: Warn => "warn" ,
744+ skills:: SkillsDoctorLevel :: Error => "error" ,
745+ } ;
746+ if let Some ( path) = & issue. path {
747+ println ! ( "[{level}] {}: {} ({path})" , issue. id, issue. message) ;
748+ } else {
749+ println ! ( "[{level}] {}: {}" , issue. id, issue. message) ;
750+ }
751+ }
752+ }
753+ }
754+
755+ let has_error = report
756+ . issues
757+ . iter ( )
758+ . any ( |i| matches ! ( i. level, skills:: SkillsDoctorLevel :: Error ) ) ;
759+ let has_warn = report
760+ . issues
761+ . iter ( )
762+ . any ( |i| matches ! ( i. level, skills:: SkillsDoctorLevel :: Warn ) ) ;
763+ if has_error || ( strict && has_warn) {
764+ std:: process:: exit ( 1 ) ;
765+ }
766+ }
767+ SkillsCommand :: Run {
768+ name,
769+ workspace,
770+ input,
771+ session,
772+ kind,
773+ } => {
774+ let paths = RexosPaths :: discover ( ) ?;
775+ paths. ensure_dirs ( ) ?;
776+ RexosConfig :: ensure_default ( & paths) ?;
777+ let cfg = RexosConfig :: load ( & paths) ?;
778+ let skills_cfg = RexosConfig :: load_skills_config ( & paths) . unwrap_or_default ( ) ;
779+
780+ std:: fs:: create_dir_all ( & workspace)
781+ . with_context ( || format ! ( "create workspace: {}" , workspace. display( ) ) ) ?;
782+
783+ let skill = skills:: find_skill ( & workspace, & name) ?;
784+ let skill_entry = skills:: read_skill_entry ( & skill) ?;
785+
786+ let memory = MemoryStore :: open_or_create ( & paths) ?;
787+ let llms = rexos:: llm:: registry:: LlmRegistry :: from_config ( & cfg) ?;
788+ let router = rexos:: router:: ModelRouter :: new ( cfg. router ) ;
789+ let agent = rexos:: agent:: AgentRuntime :: new ( memory, llms, router) ;
790+
791+ let session_id = match session {
792+ Some ( id) => id,
793+ None => rexos:: harness:: resolve_session_id ( & workspace) ?,
794+ } ;
795+ let experimental_mode = skills_cfg. experimental ;
796+ agent. set_session_skill_policy (
797+ & session_id,
798+ rexos:: agent:: SessionSkillPolicy {
799+ allowlist : skills_cfg. allowlist ,
800+ require_approval : skills_cfg. require_approval ,
801+ auto_approve_readonly : skills_cfg. auto_approve_readonly ,
802+ } ,
803+ ) ?;
804+ if experimental_mode {
805+ eprintln ! ( "skills: experimental mode is enabled in config" ) ;
806+ }
807+
808+ agent. record_skill_discovered (
809+ & session_id,
810+ & skill. name ,
811+ skills:: source_name ( skill. source ) ,
812+ & skill. manifest . version . to_string ( ) ,
813+ ) ?;
814+ agent. authorize_skill ( & session_id, & skill. name , & skill. manifest . permissions ) ?;
815+
816+ let allowed_tools = skills:: permission_tools ( & skill. manifest . permissions ) ;
817+ if !allowed_tools. is_empty ( ) {
818+ agent. set_session_allowed_tools ( & session_id, allowed_tools) ?;
819+ }
820+
821+ let system = format ! (
822+ "You are executing skill `{}` version {}.\\ n\
823+ Follow the skill instructions exactly.\\ n\
824+ If tool permissions are restricted, do not call tools outside the granted scope.\\ n\\ n\
825+ --- SKILL INSTRUCTIONS START ---\\ n{}\\ n--- SKILL INSTRUCTIONS END ---",
826+ skill. name, skill. manifest. version, skill_entry
827+ ) ;
828+
829+ let out = match agent
830+ . run_session (
831+ workspace,
832+ & session_id,
833+ Some ( & system) ,
834+ & input,
835+ kind. into ( ) ,
836+ )
837+ . await
838+ {
839+ Ok ( out) => {
840+ agent. record_skill_execution (
841+ & session_id,
842+ & skill. name ,
843+ & skill. manifest . permissions ,
844+ true ,
845+ None ,
846+ ) ?;
847+ out
848+ }
849+ Err ( e) => {
850+ let err_text = e. to_string ( ) ;
851+ let _ = agent. record_skill_execution (
852+ & session_id,
853+ & skill. name ,
854+ & skill. manifest . permissions ,
855+ false ,
856+ Some ( & err_text) ,
857+ ) ;
858+ return Err ( e) ;
859+ }
860+ } ;
861+
862+ println ! ( "{out}" ) ;
863+ eprintln ! ( "[loopforge] session_id={session_id}" ) ;
864+ }
865+ } ,
623866 Command :: Harness { command } => match command {
624867 HarnessCommand :: Init {
625868 dir,
@@ -1259,6 +1502,33 @@ mod tests {
12591502 ) ;
12601503 }
12611504
1505+ #[ test]
1506+ fn cli_parses_skills_list_subcommand ( ) {
1507+ let parsed = Cli :: try_parse_from ( [ "loopforge" , "skills" , "list" , "--workspace" , "." ] ) ;
1508+ assert ! (
1509+ parsed. is_ok( ) ,
1510+ "expected `loopforge skills list` to parse, got: {parsed:?}"
1511+ ) ;
1512+ }
1513+
1514+ #[ test]
1515+ fn cli_parses_skills_run_subcommand ( ) {
1516+ let parsed = Cli :: try_parse_from ( [
1517+ "loopforge" ,
1518+ "skills" ,
1519+ "run" ,
1520+ "hello-skill" ,
1521+ "--workspace" ,
1522+ "." ,
1523+ "--input" ,
1524+ "do x" ,
1525+ ] ) ;
1526+ assert ! (
1527+ parsed. is_ok( ) ,
1528+ "expected `loopforge skills run` to parse, got: {parsed:?}"
1529+ ) ;
1530+ }
1531+
12621532 #[ test]
12631533 fn cli_parses_onboard_subcommand ( ) {
12641534 let parsed =
0 commit comments