Skip to content

Commit c5d50fb

Browse files
committed
feat(skills): add runtime policy, cli commands, and skills docs
1 parent bceeef3 commit c5d50fb

25 files changed

Lines changed: 2392 additions & 9 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ All notable user-visible changes are documented in this file.
77
### Added
88

99
- Versioning policy documented in `docs/versioning-and-release.md`.
10+
- Skills MVP baseline:
11+
- New `rexos-skills` crate (manifest/schema validation, loader precedence, dependency resolver)
12+
- Runtime skill policy + approval controls (`SessionSkillPolicy`)
13+
- Skill lifecycle ACP events and skill audit records (`rexos.audit.skill_runs`)
14+
- New CLI command group: `loopforge skills list|show|doctor|run`
15+
- New docs pages: skills reference and skills quickstart (EN + ZH)
1016

1117
## [0.1.0] - Planned
1218

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/loopforge-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ tokio.workspace = true
1919
uuid.workspace = true
2020
toml.workspace = true
2121
rexos = { path = "../rexos" }
22+
rexos-skills = { path = "../rexos-skills" }
2223

2324
[dev-dependencies]
2425
axum.workspace = true

crates/loopforge-cli/src/main.rs

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use rexos::{
1414
};
1515

1616
mod doctor;
17+
mod skills;
1718

1819
#[derive(Debug, Clone, serde::Serialize)]
1920
struct 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)]
150209
enum 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

Comments
 (0)