use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Command {
pub name: String,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub subcommands: Option<Vec<Command>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<Vec<Argument>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Argument {
pub name: String,
pub description: String,
#[serde(rename = "type")]
pub arg_type: String,
#[serde(default)]
pub required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub choices: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntrospectionOutput {
#[serde(rename = "schemaVersion")]
pub schema_version: String,
#[serde(rename = "type")]
pub output_type: String,
pub ok: bool,
#[serde(flatten)]
pub data: serde_json::Value,
}
impl IntrospectionOutput {
pub fn new(output_type: &str, data: serde_json::Value) -> Self {
Self {
schema_version: "1.0".to_string(),
output_type: output_type.to_string(),
ok: true,
data,
}
}
}
pub fn get_commands() -> Vec<Command> {
vec![
Command {
name: "install-skills".to_string(),
description: "Install embedded skill(s) bundled in the binary".to_string(),
subcommands: None,
arguments: Some(vec![
Argument {
name: "agent".to_string(),
description: "Target agent name(s) for agent-specific installation (can be comma-separated or specified multiple times, e.g., --agent claude,opencode or --agent claude --agent opencode)".to_string(),
arg_type: "string".to_string(),
required: false,
choices: None,
},
Argument {
name: "skill".to_string(),
description: "Specific skill name to install (if source contains multiple)"
.to_string(),
arg_type: "string".to_string(),
required: false,
choices: None,
},
Argument {
name: "global".to_string(),
description: "Install globally (default: project-local)".to_string(),
arg_type: "boolean".to_string(),
required: false,
choices: None,
},
Argument {
name: "yes".to_string(),
description: "Skip confirmation prompts".to_string(),
arg_type: "boolean".to_string(),
required: false,
choices: None,
},
Argument {
name: "non-interactive".to_string(),
description: "Run in non-interactive mode".to_string(),
arg_type: "boolean".to_string(),
required: false,
choices: None,
},
Argument {
name: "json".to_string(),
description: "Output result as JSON".to_string(),
arg_type: "boolean".to_string(),
required: false,
choices: None,
},
]),
},
Command {
name: "commands".to_string(),
description: "List all available commands".to_string(),
subcommands: None,
arguments: Some(vec![Argument {
name: "output".to_string(),
description: "Output format (json)".to_string(),
arg_type: "string".to_string(),
required: false,
choices: Some(vec!["json".to_string()]),
}]),
},
Command {
name: "schema".to_string(),
description: "Get JSON schema for a command".to_string(),
subcommands: None,
arguments: Some(vec![
Argument {
name: "command".to_string(),
description: "Command name to get schema for".to_string(),
arg_type: "string".to_string(),
required: true,
choices: None,
},
Argument {
name: "output".to_string(),
description: "Output format (json-schema)".to_string(),
arg_type: "string".to_string(),
required: false,
choices: Some(vec!["json-schema".to_string()]),
},
]),
},
]
}
pub fn output_commands_json() -> Result<String> {
let commands = get_commands();
let output = IntrospectionOutput::new("commands.list", json!({ "commands": commands }));
serde_json::to_string_pretty(&output).context("Failed to serialize commands")
}
pub fn get_command_schema(command_name: &str) -> Result<String> {
let commands = get_commands();
let command = commands
.iter()
.find(|c| c.name == command_name)
.with_context(|| format!("Command not found: {}", command_name))?;
let mut properties = serde_json::Map::new();
let mut required = Vec::new();
if let Some(args) = &command.arguments {
for arg in args {
let mut prop = serde_json::Map::new();
prop.insert("type".to_string(), json!(arg.arg_type));
prop.insert("description".to_string(), json!(arg.description));
if let Some(choices) = &arg.choices {
prop.insert("enum".to_string(), json!(choices));
}
properties.insert(arg.name.clone(), json!(prop));
if arg.required {
required.push(arg.name.clone());
}
}
}
let schema = json!({
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": false
});
let output = IntrospectionOutput::new("schema", json!({ "schema": schema }));
serde_json::to_string_pretty(&output).context("Failed to serialize schema")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_commands() {
let commands = get_commands();
assert!(!commands.is_empty());
let install_cmd = commands.iter().find(|c| c.name == "install-skills");
assert!(install_cmd.is_some());
}
#[test]
fn test_install_skill_command_has_no_source_argument() {
let commands = get_commands();
let install_cmd = commands
.iter()
.find(|c| c.name == "install-skills")
.unwrap();
let source_arg = install_cmd
.arguments
.as_ref()
.unwrap()
.iter()
.find(|a| a.name == "source");
assert!(source_arg.is_none());
}
#[test]
fn test_output_commands_json() {
let json = output_commands_json().unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["schemaVersion"], "1.0");
assert_eq!(parsed["type"], "commands.list");
assert_eq!(parsed["ok"], true);
assert!(parsed["commands"].is_array());
}
#[test]
fn test_get_command_schema() {
let schema = get_command_schema("install-skills").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&schema).unwrap();
assert_eq!(parsed["schemaVersion"], "1.0");
assert_eq!(parsed["type"], "schema");
assert_eq!(parsed["ok"], true);
assert!(parsed["schema"]["properties"].is_object());
assert!(parsed["schema"]["properties"]["source"].is_null());
}
#[test]
fn test_schema_includes_yes_and_non_interactive() {
let schema = get_command_schema("install-skills").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&schema).unwrap();
assert!(parsed["schema"]["properties"]["yes"].is_object());
assert!(parsed["schema"]["properties"]["non-interactive"].is_object());
}
#[test]
fn test_schema_includes_agent_skill_global() {
let schema = get_command_schema("install-skills").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&schema).unwrap();
assert!(parsed["schema"]["properties"]["agent"].is_object());
assert!(parsed["schema"]["properties"]["skill"].is_object());
assert!(parsed["schema"]["properties"]["global"].is_object());
assert_eq!(
parsed["schema"]["properties"]["agent"]["description"],
"Target agent name(s) for agent-specific installation (can be comma-separated or specified multiple times, e.g., --agent claude,opencode or --agent claude --agent opencode)"
);
assert_eq!(
parsed["schema"]["properties"]["skill"]["description"],
"Specific skill name to install (if source contains multiple)"
);
assert_eq!(
parsed["schema"]["properties"]["global"]["description"],
"Install globally (default: project-local)"
);
}
#[test]
fn test_get_command_schema_not_found() {
let result = get_command_schema("nonexistent");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Command not found"));
}
}