use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Stdio;
use sxmc::client::api;
use sxmc::error::{Result, SxmcError};
use sxmc::output;
use sxmc::projection::{apply_offset_limit, retain_object_fields};
pub struct ApiCommandOptions<'a> {
pub list: bool,
pub search: Option<&'a str>,
pub compact: bool,
pub names_only: bool,
pub required_only: bool,
pub counts_only: bool,
pub no_descriptions: bool,
pub offset: Option<usize>,
pub limit: Option<usize>,
pub fields: Option<&'a [String]>,
pub pretty: bool,
pub format: Option<output::StructuredOutputFormat>,
}
use sxmc::skills::{discovery, install, models::Skill, parser};
use tokio::process::Command;
pub struct SkillListOptions<'a> {
pub json_output: bool,
pub installed_only: bool,
pub names_only: bool,
pub counts_only: bool,
pub no_descriptions: bool,
pub fields: Option<&'a [String]>,
pub offset: Option<usize>,
pub limit: Option<usize>,
}
pub fn cmd_skills_list(paths: &[PathBuf], options: SkillListOptions<'_>) -> Result<()> {
let skill_dirs = discovery::discover_skills(paths)?;
let mut skills = Vec::new();
for dir in &skill_dirs {
let source = dir.parent().and_then(|p| p.to_str()).unwrap_or("unknown");
match parser::parse_skill(dir, source) {
Ok(skill) => {
let metadata = install::read_installed_skill_metadata(dir)?;
skills.push((skill, metadata));
}
Err(e) => eprintln!("Warning: {}: {}", dir.display(), e),
}
}
if options.installed_only {
skills.retain(|(_, metadata)| metadata.is_some());
}
skills.sort_by(|a, b| a.0.name.cmp(&b.0.name));
apply_offset_limit(&mut skills, options.offset, options.limit);
if options.counts_only {
let value = serde_json::json!({
"count": skills.len(),
"offset": options.offset,
"limit": options.limit,
"names_only": false,
"counts_only": true,
});
if options.json_output {
println!("{}", serde_json::to_string_pretty(&value)?);
} else {
println!("Skills: {}", skills.len());
}
} else if options.names_only {
if options.json_output {
let items: Vec<&str> = skills.iter().map(|(s, _)| s.name.as_str()).collect();
println!("{}", serde_json::to_string_pretty(&items)?);
} else if skills.is_empty() {
println!("No skills found.");
for p in paths {
println!(" {}", p.display());
}
} else {
for (skill, _) in &skills {
println!("{}", skill.name);
}
}
} else if options.json_output {
let items: Vec<serde_json::Value> = skills
.iter()
.map(|(s, metadata)| {
let mut value = serde_json::json!({
"name": s.name,
"description": s.frontmatter.description,
"scripts": s.scripts.iter().map(|sc| &sc.name).collect::<Vec<_>>(),
"references": s.references.iter().map(|r| &r.name).collect::<Vec<_>>(),
"source": s.source,
});
if let Some(metadata) = metadata {
if let Some(object) = value.as_object_mut() {
object.insert(
"managed".to_string(),
serde_json::Value::Bool(true),
);
object.insert(
"install_scope".to_string(),
serde_json::Value::String(metadata.install_scope.clone()),
);
object.insert(
"install_source".to_string(),
serde_json::Value::String(metadata.source.clone()),
);
object.insert(
"update_status".to_string(),
serde_json::Value::String("updatable".to_string()),
);
}
} else if options.installed_only {
if let Some(object) = value.as_object_mut() {
object.insert(
"managed".to_string(),
serde_json::Value::Bool(false),
);
object.insert(
"update_status".to_string(),
serde_json::Value::String("unmanaged".to_string()),
);
}
}
if options.no_descriptions {
if let Some(object) = value.as_object_mut() {
object.remove("description");
}
}
if let Some(fields) = options.fields {
value = retain_object_fields(value, fields);
}
value
})
.collect();
println!("{}", serde_json::to_string_pretty(&items)?);
} else if skills.is_empty() {
println!("No skills found.");
for p in paths {
println!(" {}", p.display());
}
} else {
for (skill, metadata) in &skills {
println!("{}", skill.name);
if !options.no_descriptions && !skill.frontmatter.description.is_empty() {
println!(" {}", skill.frontmatter.description);
}
if let Some(metadata) = metadata {
println!(
" Managed: {} ({})",
metadata.install_scope, metadata.source
);
}
if !skill.scripts.is_empty() {
let names: Vec<_> = skill.scripts.iter().map(|s| s.name.as_str()).collect();
println!(" Tools: {}", names.join(", "));
}
if !skill.references.is_empty() {
let names: Vec<_> = skill.references.iter().map(|r| r.name.as_str()).collect();
println!(" Resources: {}", names.join(", "));
}
println!();
}
}
Ok(())
}
pub fn cmd_skills_info(paths: &[PathBuf], name: &str, summary_only: bool) -> Result<()> {
let skill_dirs = discovery::discover_skills(paths)?;
for dir in &skill_dirs {
let source = dir.parent().and_then(|p| p.to_str()).unwrap_or("unknown");
if let Ok(skill) = parser::parse_skill(dir, source) {
if skill.name == name {
println!("Name: {}", skill.name);
println!("Description: {}", skill.frontmatter.description);
if let Some(ref hint) = skill.frontmatter.argument_hint {
println!("Arguments: {}", hint);
}
if summary_only {
if !skill.scripts.is_empty() {
let script_names = skill
.scripts
.iter()
.map(|script| script.name.as_str())
.collect::<Vec<_>>();
println!(
"Scripts: {} ({})",
skill.scripts.len(),
script_names.join(", ")
);
}
if !skill.references.is_empty() {
let reference_names = skill
.references
.iter()
.map(|reference| reference.name.as_str())
.collect::<Vec<_>>();
println!(
"References: {} ({})",
skill.references.len(),
reference_names.join(", ")
);
}
return Ok(());
}
println!("Source: {}", skill.source);
println!("Directory: {}", skill.base_dir.display());
if !skill.scripts.is_empty() {
println!("\nScripts:");
for s in &skill.scripts {
println!(" {} -> {}", s.name, s.path.display());
}
}
if !skill.references.is_empty() {
println!("\nReferences:");
for r in &skill.references {
println!(" {} ({})", r.name, r.uri);
}
}
println!("\n--- Body ---");
println!("{}", skill.body);
return Ok(());
}
}
}
Err(sxmc::error::SxmcError::SkillNotFound(name.to_string()))
}
pub async fn cmd_skills_run(
paths: &[PathBuf],
name: &str,
script: Option<&str>,
env_vars: &[String],
print_body: bool,
arguments: &[String],
) -> Result<()> {
let skill_dirs = discovery::discover_skills(paths)?;
for dir in &skill_dirs {
let source = dir.parent().and_then(|p| p.to_str()).unwrap_or("unknown");
if let Ok(skill) = parser::parse_skill(dir, source) {
if skill.name == name {
let body = interpolate_skill_body(&skill.body, arguments);
if print_body || skill.scripts.is_empty() {
println!("{}", body);
return Ok(());
}
let selected_script = select_skill_script(&skill, script)?;
let env_pairs = parse_skill_env_vars(env_vars)?;
execute_skill_script(&skill, selected_script.path.clone(), &env_pairs, arguments)
.await?;
return Ok(());
}
}
}
Err(sxmc::error::SxmcError::SkillNotFound(name.to_string()))
}
fn interpolate_skill_body(body: &str, arguments: &[String]) -> String {
let args_str = arguments.join(" ");
let mut rendered = body.to_string();
for (i, arg) in arguments.iter().enumerate().rev() {
rendered = rendered.replace(&format!("$ARGUMENTS[{}]", i), arg);
rendered = rendered.replace(&format!("${}", i), arg);
}
rendered.replace("$ARGUMENTS", &args_str)
}
fn select_skill_script<'a>(
skill: &'a Skill,
requested_script: Option<&str>,
) -> Result<&'a sxmc::skills::models::SkillScript> {
if let Some(requested_script) = requested_script {
let requested_lower = requested_script.to_ascii_lowercase();
return skill
.scripts
.iter()
.find(|candidate| {
candidate.name.eq_ignore_ascii_case(requested_script)
|| candidate
.path
.file_stem()
.and_then(|stem| stem.to_str())
.map(|stem| stem.eq_ignore_ascii_case(&requested_lower))
.unwrap_or(false)
})
.ok_or_else(|| {
let available = skill
.scripts
.iter()
.map(|item| item.name.as_str())
.collect::<Vec<_>>()
.join(", ");
SxmcError::ExecutionError(format!(
"Skill `{}` does not have a script named `{}`. Available scripts: {}",
skill.name, requested_script, available
))
});
}
match skill.scripts.as_slice() {
[only] => Ok(only),
[] => Err(SxmcError::ExecutionError(format!(
"Skill `{}` does not define any runnable scripts",
skill.name
))),
_ => {
let available = skill
.scripts
.iter()
.map(|item| item.name.as_str())
.collect::<Vec<_>>()
.join(", ");
Err(SxmcError::ExecutionError(format!(
"Skill `{}` has multiple scripts ({}). Re-run with --script <name> or --print-body.",
skill.name, available
)))
}
}
}
fn parse_skill_env_vars(env_vars: &[String]) -> Result<Vec<(String, String)>> {
env_vars
.iter()
.map(|entry| {
let Some((key, value)) = entry.split_once('=') else {
return Err(SxmcError::ExecutionError(format!(
"Invalid --env value `{}`. Expected KEY=VALUE.",
entry
)));
};
if key.trim().is_empty() {
return Err(SxmcError::ExecutionError(format!(
"Invalid --env value `{}`. Environment variable name cannot be empty.",
entry
)));
}
Ok((key.to_string(), value.to_string()))
})
.collect()
}
async fn execute_skill_script(
skill: &Skill,
script_path: PathBuf,
env_pairs: &[(String, String)],
arguments: &[String],
) -> Result<()> {
let (executable, mut launcher_args): (PathBuf, Vec<String>) =
sxmc::executor::script_command(&script_path);
launcher_args.extend(arguments.iter().cloned());
let mut command = Command::new(&executable);
command
.args(&launcher_args)
.current_dir(&skill.base_dir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env("SXMC_SKILL_NAME", &skill.name)
.env("SXMC_SKILL_DIR", &skill.base_dir)
.env("SXMC_SKILL_ARGUMENTS", arguments.join(" "));
for (key, value) in env_pairs {
command.env(key, value);
}
let output = command.output().await.map_err(|error| {
SxmcError::ExecutionError(format!(
"Failed to run {}: {}",
script_path.display(),
error
))
})?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !stdout.is_empty() {
print!("{}", stdout);
}
if !stderr.is_empty() {
eprint!("{}", stderr);
}
if output.status.success() {
Ok(())
} else {
Err(SxmcError::ExecutionError(format!(
"Skill script `{}` exited with status {}",
script_path.display(),
output
.status
.code()
.map(|code| code.to_string())
.unwrap_or_else(|| "terminated by signal".to_string())
)))
}
}
pub async fn cmd_api(
client: &api::ApiClient,
operation: Option<String>,
arguments: &HashMap<String, String>,
options: ApiCommandOptions<'_>,
) -> Result<()> {
if options.list || options.search.is_some() {
let selectors = api::ListSelectors {
compact: options.compact,
names_only: options.names_only,
required_only: options.required_only,
counts_only: options.counts_only,
no_descriptions: options.no_descriptions,
offset: options.offset,
limit: options.limit,
fields: options.fields,
};
if options.counts_only || options.fields.is_some() {
let format = output::resolve_structured_format(options.format, options.pretty);
println!(
"{}",
output::format_structured_value(
&client.list_value(options.search, &selectors),
format,
)
);
} else if let Some(format) =
output::prefer_structured_output(options.format, options.pretty)
{
println!(
"{}",
output::format_structured_value(
&client.list_value(options.search, &selectors),
format,
)
);
} else {
println!("{}", client.format_list(options.search, &selectors));
}
} else if let Some(op_name) = operation {
let result = client.execute(&op_name, arguments).await?;
let format = output::resolve_structured_format(options.format, options.pretty);
println!("{}", output::format_structured_value(&result, format));
} else {
eprintln!("Specify an operation name or use --list");
std::process::exit(1);
}
Ok(())
}