use luaskills::lua_skill::validate_luaskills_identifier;
use luaskills::runtime::render_host_visible_path;
use luaskills::{
LuaEngine, LuaEngineOptions, LuaInvocationContext, LuaRuntimeHostOptions,
LuaRuntimeManagedRuntimeConfig, LuaVmPoolConfig, RuntimeEntryDescriptor,
RuntimeInvocationResult, RuntimeRequestContext, RuntimeSkillRoot, SkillMeta,
};
use serde::Serialize;
use serde_json::{Value, json};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
const DEBUG_USAGE: &str = r#"luaskills-debug
Usage:
luaskills-debug sync --runtime-root <dir> --skill-path <dir> [managed runtime root options] [--output pretty|json]
luaskills-debug inspect --runtime-root <dir> --skill-path <dir> [managed runtime root options] [--output pretty|json]
luaskills-debug inspect --runtime-root <dir> --skill-id <id> [managed runtime root options] [--output pretty|json]
luaskills-debug list-tools --runtime-root <dir> --skill-path <dir> [managed runtime root options] [--output pretty|json|content]
luaskills-debug list-tools --runtime-root <dir> --skill-id <id> [managed runtime root options] [--output pretty|json|content]
luaskills-debug call --runtime-root <dir> --skill-path <dir> --tool <name> [managed runtime root options] [--args-json <json> | --args-file <path>] [--enable-host-result] [--output pretty|json|content]
luaskills-debug call --runtime-root <dir> --skill-id <id> --tool <name> [managed runtime root options] [--args-json <json> | --args-file <path>] [--enable-host-result] [--output pretty|json|content]
Managed runtime root options:
--managed-runtime-distribution-root <dir> Existing absolute Python/Node distribution root.
--managed-runtime-environment-root <dir> Absolute writable managed environment root.
Managed runtime resource options:
--managed-runtime-worker-pool-max-size <count> Maximum Workers per environment pool.
--managed-runtime-worker-idle-ttl-secs <seconds> Idle Worker retirement time.
--managed-runtime-session-limit <count> Persistent sessions per engine.
--managed-runtime-session-buffer-limit-bytes <bytes> Default retained bytes per output stream.
--managed-runtime-invoke-timeout-ms <milliseconds> Default invoke timeout; omitted is unlimited.
Examples:
luaskills-debug sync --runtime-root D:\runtime --skill-path D:\skills\vulcan-file
luaskills-debug inspect --runtime-root D:\runtime --skill-path D:\skills\vulcan-file
luaskills-debug inspect --runtime-root D:\runtime --skill-id vulcan-file
luaskills-debug list-tools --runtime-root D:\runtime --skill-path D:\skills\vulcan-file --output content
luaskills-debug call --runtime-root D:\runtime --skill-id vulcan-file --tool read --args-json "{\"path\":\"D:/demo.txt\"}"
"#;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DebugCommandKind {
Sync,
Inspect,
ListTools,
Call,
}
impl DebugCommandKind {
fn parse(value: &str) -> Result<Self, String> {
match value.trim() {
"sync" => Ok(Self::Sync),
"inspect" => Ok(Self::Inspect),
"list-tools" => Ok(Self::ListTools),
"call" => Ok(Self::Call),
other => Err(format!("Unknown command '{}'", other)),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DebugOutputMode {
Pretty,
Json,
Content,
}
impl DebugOutputMode {
fn parse(value: &str) -> Result<Self, String> {
match value.trim() {
"pretty" => Ok(Self::Pretty),
"json" => Ok(Self::Json),
"content" => Ok(Self::Content),
other => Err(format!("Unsupported output mode '{}'", other)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DebugCliCommand {
kind: DebugCommandKind,
runtime_root: PathBuf,
managed_runtime_distribution_root: Option<PathBuf>,
managed_runtime_environment_root: Option<PathBuf>,
managed_runtime_config: LuaRuntimeManagedRuntimeConfig,
skill_path: Option<PathBuf>,
skill_id: Option<String>,
tool_name: Option<String>,
args_json: Option<String>,
args_file: Option<PathBuf>,
enable_host_result: bool,
output_mode: DebugOutputMode,
}
struct PreparedDebugRuntime {
engine: LuaEngine,
manifest: SkillMeta,
skill_id: String,
runtime_root: PathBuf,
source_skill_path: Option<PathBuf>,
synced_skill_path: PathBuf,
entries: Vec<RuntimeEntryDescriptor>,
}
struct DebugSkillSyncResult {
skill_id: String,
runtime_root: PathBuf,
source_skill_path: PathBuf,
synced_skill_path: PathBuf,
}
#[derive(Debug, Serialize)]
struct DebugSyncOutput {
command: &'static str,
skill_id: String,
runtime_root: String,
source_skill_path: String,
synced_skill_path: String,
}
#[derive(Debug, Serialize)]
struct DebugInspectOutput {
command: &'static str,
skill_id: String,
manifest_name: String,
manifest_version: String,
debug: bool,
runtime_root: String,
source_skill_path: Option<String>,
synced_skill_path: String,
entries: Vec<RuntimeEntryDescriptor>,
}
#[derive(Debug, Serialize)]
struct DebugCallOutput {
command: &'static str,
skill_id: String,
requested_tool_name: String,
resolved_tool_name: String,
runtime_root: String,
synced_skill_path: String,
result: RuntimeInvocationResult,
}
fn main() {
match run_debug_binary() {
Ok(()) => {}
Err(error) => {
eprintln!("luaskills-debug: {}", error);
std::process::exit(1);
}
}
}
fn run_debug_binary() -> Result<(), String> {
let args: Vec<String> = env::args().skip(1).collect();
if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") {
print!("{}", DEBUG_USAGE);
return Ok(());
}
let command = parse_debug_cli(&args)?;
match command.kind {
DebugCommandKind::Sync => {
let sync_result = sync_debug_skill(&command)?;
let output = build_sync_output(&sync_result);
render_sync_output(command.output_mode, &output)
}
DebugCommandKind::Inspect => {
let prepared = prepare_debug_runtime(&command)?;
let output = build_inspect_output(&prepared);
render_inspect_output(command.output_mode, &output)
}
DebugCommandKind::ListTools => {
let prepared = prepare_debug_runtime(&command)?;
render_list_tools_output(command.output_mode, &prepared.entries)
}
DebugCommandKind::Call => {
let prepared = prepare_debug_runtime(&command)?;
let requested_tool_name = command
.tool_name
.clone()
.ok_or_else(|| "call requires --tool".to_string())?;
let resolved_tool_name =
resolve_debug_tool_name(&prepared.entries, &requested_tool_name)?;
let args_value = load_invocation_args(&command)?;
let invocation_context = build_debug_invocation_context(command.enable_host_result);
let result = prepared.engine.call_skill(
&resolved_tool_name,
&args_value,
Some(&invocation_context),
)?;
let output = DebugCallOutput {
command: "call",
skill_id: prepared.skill_id.clone(),
requested_tool_name,
resolved_tool_name,
runtime_root: render_host_visible_path(&prepared.runtime_root),
synced_skill_path: render_host_visible_path(&prepared.synced_skill_path),
result,
};
render_call_output(command.output_mode, &output)
}
}
}
fn parse_debug_cli(args: &[String]) -> Result<DebugCliCommand, String> {
let kind = DebugCommandKind::parse(
args.first()
.ok_or_else(|| "Missing debug subcommand".to_string())?,
)?;
let mut runtime_root: Option<PathBuf> = None;
let mut managed_runtime_distribution_root: Option<PathBuf> = None;
let mut managed_runtime_environment_root: Option<PathBuf> = None;
let mut managed_runtime_config = LuaRuntimeManagedRuntimeConfig::default();
let mut skill_path: Option<PathBuf> = None;
let mut skill_id: Option<String> = None;
let mut tool_name: Option<String> = None;
let mut args_json: Option<String> = None;
let mut args_file: Option<PathBuf> = None;
let mut enable_host_result = false;
let mut output_mode = DebugOutputMode::Pretty;
let mut index = 1usize;
while index < args.len() {
let flag = args[index].as_str();
match flag {
"--runtime-root" => {
runtime_root = Some(PathBuf::from(read_cli_value(args, &mut index, flag)?));
}
"--managed-runtime-distribution-root" => {
managed_runtime_distribution_root =
Some(PathBuf::from(read_cli_value(args, &mut index, flag)?));
}
"--managed-runtime-environment-root" => {
managed_runtime_environment_root =
Some(PathBuf::from(read_cli_value(args, &mut index, flag)?));
}
"--managed-runtime-worker-pool-max-size" => {
managed_runtime_config.worker_pool_max_size_per_environment =
read_positive_usize_cli_value(args, &mut index, flag)?;
}
"--managed-runtime-worker-idle-ttl-secs" => {
managed_runtime_config.worker_idle_ttl_secs =
read_positive_u64_cli_value(args, &mut index, flag)?;
}
"--managed-runtime-session-limit" => {
managed_runtime_config.persistent_session_limit_per_engine =
read_positive_usize_cli_value(args, &mut index, flag)?;
}
"--managed-runtime-session-buffer-limit-bytes" => {
managed_runtime_config.persistent_session_default_buffer_limit_bytes_per_stream =
read_positive_usize_cli_value(args, &mut index, flag)?;
}
"--managed-runtime-invoke-timeout-ms" => {
managed_runtime_config.invoke_default_timeout_ms =
Some(read_positive_u64_cli_value(args, &mut index, flag)?);
}
"--skill-path" => {
skill_path = Some(PathBuf::from(read_cli_value(args, &mut index, flag)?));
}
"--skill-id" => {
skill_id = Some(read_cli_value(args, &mut index, flag)?.to_string());
}
"--tool" => {
tool_name = Some(read_cli_value(args, &mut index, flag)?.to_string());
}
"--args-json" => {
args_json = Some(read_cli_value(args, &mut index, flag)?.to_string());
}
"--args-file" => {
args_file = Some(PathBuf::from(read_cli_value(args, &mut index, flag)?));
}
"--output" => {
output_mode = DebugOutputMode::parse(read_cli_value(args, &mut index, flag)?)?;
}
"--enable-host-result" => {
enable_host_result = true;
}
other => {
return Err(format!("Unknown option '{}'", other));
}
}
index += 1;
}
let runtime_root = runtime_root.ok_or_else(|| "--runtime-root is required".to_string())?;
if args_json.is_some() && args_file.is_some() {
return Err("--args-json and --args-file are mutually exclusive".to_string());
}
if skill_path.is_some() && skill_id.is_some() {
return Err("--skill-path and --skill-id are mutually exclusive".to_string());
}
if kind == DebugCommandKind::Sync && skill_path.is_none() {
return Err("sync requires --skill-path".to_string());
}
if kind != DebugCommandKind::Sync && skill_path.is_none() && skill_id.is_none() {
return Err(format!("{} requires --skill-id or --skill-path", args[0]));
}
if let Some(skill_id) = skill_id.as_deref() {
validate_luaskills_identifier(skill_id, "skill_id")?;
}
if kind == DebugCommandKind::Call && tool_name.is_none() {
return Err("call requires --tool".to_string());
}
managed_runtime_config.validate()?;
Ok(DebugCliCommand {
kind,
runtime_root,
managed_runtime_distribution_root,
managed_runtime_environment_root,
managed_runtime_config,
skill_path,
skill_id,
tool_name,
args_json,
args_file,
enable_host_result,
output_mode,
})
}
fn read_cli_value<'a>(
args: &'a [String],
index: &mut usize,
flag: &str,
) -> Result<&'a str, String> {
*index += 1;
args.get(*index)
.map(|value| value.as_str())
.ok_or_else(|| format!("{} requires a value", flag))
}
fn read_positive_usize_cli_value(
args: &[String],
index: &mut usize,
flag: &str,
) -> Result<usize, String> {
let raw_value = read_cli_value(args, index, flag)?;
let value = raw_value
.parse::<usize>()
.map_err(|error| format!("{flag} requires one positive integer: {error}"))?;
if value == 0 {
return Err(format!("{flag} must be greater than zero"));
}
Ok(value)
}
fn read_positive_u64_cli_value(
args: &[String],
index: &mut usize,
flag: &str,
) -> Result<u64, String> {
let raw_value = read_cli_value(args, index, flag)?;
let value = raw_value
.parse::<u64>()
.map_err(|error| format!("{flag} requires one positive integer: {error}"))?;
if value == 0 {
return Err(format!("{flag} must be greater than zero"));
}
Ok(value)
}
fn sync_debug_skill(command: &DebugCliCommand) -> Result<DebugSkillSyncResult, String> {
let runtime_root = absolutize_path(&command.runtime_root)?;
let source_skill_path = command
.skill_path
.as_ref()
.ok_or_else(|| "sync requires --skill-path".to_string())
.and_then(|path| absolutize_path(path))?;
let mut manifest = load_bound_skill_manifest(&source_skill_path)?;
let skill_id = manifest.effective_skill_id().to_string();
ensure_debug_runtime_layout(&runtime_root)?;
let synced_skill_path =
synchronize_skill_into_runtime_root(&runtime_root, &source_skill_path, &skill_id)?;
manifest.bind_directory_skill_id(skill_id.clone());
Ok(DebugSkillSyncResult {
skill_id,
runtime_root,
source_skill_path,
synced_skill_path,
})
}
fn build_sync_output(sync_result: &DebugSkillSyncResult) -> DebugSyncOutput {
DebugSyncOutput {
command: "sync",
skill_id: sync_result.skill_id.clone(),
runtime_root: render_host_visible_path(&sync_result.runtime_root),
source_skill_path: render_host_visible_path(&sync_result.source_skill_path),
synced_skill_path: render_host_visible_path(&sync_result.synced_skill_path),
}
}
fn resolve_debug_target(command: &DebugCliCommand) -> Result<(String, Option<PathBuf>), String> {
if command.skill_path.is_some() {
let sync_result = sync_debug_skill(command)?;
return Ok((sync_result.skill_id, Some(sync_result.source_skill_path)));
}
let skill_id = command
.skill_id
.clone()
.ok_or_else(|| "debug run requires --skill-id or --skill-path".to_string())?;
Ok((skill_id, None))
}
fn prepare_debug_runtime(command: &DebugCliCommand) -> Result<PreparedDebugRuntime, String> {
let runtime_root = absolutize_path(&command.runtime_root)?;
ensure_debug_runtime_layout(&runtime_root)?;
let (skill_id, source_skill_path) = resolve_debug_target(command)?;
let synced_skill_path = runtime_root.join("skills").join(&skill_id);
let synced_skill_manifest_path = synced_skill_path.join("skill.yaml");
if !debug_synced_skill_manifest_path_is_file(&synced_skill_manifest_path)? {
return Err(format!(
"Synchronized skill '{}' was not found under '{}'. Run 'luaskills-debug sync --runtime-root {} --skill-path <source-skill>' first.",
skill_id,
render_debug_path(&synced_skill_path),
render_debug_path(&runtime_root)
));
}
let manifest = load_bound_skill_manifest(&synced_skill_path)?;
let ignored_skill_ids = collect_ignored_skill_ids(&runtime_root.join("skills"), &skill_id)?;
let managed_runtime_distribution_root = command
.managed_runtime_distribution_root
.as_deref()
.map(absolutize_path)
.transpose()?;
let managed_runtime_environment_root = command
.managed_runtime_environment_root
.as_deref()
.map(absolutize_path)
.transpose()?;
let host_options = build_debug_host_options(
&runtime_root,
managed_runtime_distribution_root,
managed_runtime_environment_root,
command.managed_runtime_config,
ignored_skill_ids,
);
let pool_config = LuaVmPoolConfig {
min_size: 1,
max_size: 2,
idle_ttl_secs: 30,
};
let mut engine = LuaEngine::new(LuaEngineOptions::new(pool_config, host_options))
.map_err(|error| error.to_string())?;
let skill_roots = [RuntimeSkillRoot {
name: "ROOT".to_string(),
skills_dir: runtime_root.join("skills"),
}];
engine
.load_from_roots(&skill_roots)
.map_err(|error| error.to_string())?;
let runtime_entries = engine.list_entries().map_err(|error| error.to_string())?;
let entries = filter_skill_entries(&runtime_entries, &skill_id);
if entries.is_empty() {
return Err(format!(
"Skill '{}' loaded without any callable entries",
skill_id
));
}
Ok(PreparedDebugRuntime {
engine,
manifest,
skill_id,
runtime_root,
source_skill_path,
synced_skill_path,
entries,
})
}
fn debug_synced_skill_manifest_path_is_file(manifest_path: &Path) -> Result<bool, String> {
match fs::metadata(manifest_path) {
Ok(metadata) if metadata.is_file() => Ok(true),
Ok(_) => Err(format!(
"Synchronized skill manifest is not a file: '{}'",
render_debug_path(manifest_path)
)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(format!(
"Failed to inspect synchronized skill manifest '{}': {}",
render_debug_path(manifest_path),
error
)),
}
}
fn absolutize_path(path: &Path) -> Result<PathBuf, String> {
if path.is_absolute() {
Ok(path.to_path_buf())
} else {
Ok(env::current_dir()
.map_err(|error| format!("Failed to resolve current directory: {}", error))?
.join(path))
}
}
fn render_debug_path(path: &Path) -> String {
render_host_visible_path(path)
}
fn load_bound_skill_manifest(skill_path: &Path) -> Result<SkillMeta, String> {
if !debug_skill_source_path_is_directory(skill_path)? {
return Err(format!(
"Skill path '{}' is not a directory",
render_debug_path(skill_path)
));
}
let directory_name = skill_path
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| {
format!(
"Skill path '{}' must end with one UTF-8 directory name",
render_debug_path(skill_path)
)
})?;
validate_luaskills_identifier(directory_name, "skill directory name")?;
let manifest_path = skill_path.join("skill.yaml");
let manifest_text = fs::read_to_string(&manifest_path).map_err(|error| {
format!(
"Failed to read skill manifest '{}': {}",
render_debug_path(&manifest_path),
error
)
})?;
let mut manifest: SkillMeta = serde_yaml::from_str(&manifest_text).map_err(|error| {
format!(
"Failed to parse skill manifest '{}': {}",
render_debug_path(&manifest_path),
error
)
})?;
manifest.bind_directory_skill_id(directory_name.to_string());
manifest.resolve_entry_input_schemas(skill_path)?;
validate_luaskills_identifier(manifest.effective_skill_id(), "skill_id")?;
Ok(manifest)
}
fn debug_skill_source_path_is_directory(skill_path: &Path) -> Result<bool, String> {
match fs::metadata(skill_path) {
Ok(metadata) => Ok(metadata.is_dir()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(format!(
"Failed to inspect skill path '{}': {}",
render_debug_path(skill_path),
error
)),
}
}
fn ensure_debug_runtime_layout(runtime_root: &Path) -> Result<(), String> {
let required_directories = [
runtime_root.to_path_buf(),
runtime_root.join("skills"),
runtime_root.join("temp"),
runtime_root.join("temp").join("downloads"),
runtime_root.join("resources"),
runtime_root.join("lua_packages"),
runtime_root.join("libs"),
runtime_root.join("bin"),
runtime_root.join("dependencies"),
runtime_root.join("state"),
runtime_root.join("databases"),
runtime_root.join("config"),
runtime_root.join("system_lua_lib"),
];
for directory in required_directories {
fs::create_dir_all(&directory).map_err(|error| {
format!(
"Failed to create runtime directory '{}': {}",
render_debug_path(&directory),
error
)
})?;
}
Ok(())
}
fn synchronize_skill_into_runtime_root(
runtime_root: &Path,
source_skill_path: &Path,
skill_id: &str,
) -> Result<PathBuf, String> {
let target_skill_path = runtime_root.join("skills").join(skill_id);
if paths_refer_to_same_directory(source_skill_path, &target_skill_path)? {
return Ok(target_skill_path);
}
if debug_sync_target_path_is_directory(&target_skill_path)? {
fs::remove_dir_all(&target_skill_path).map_err(|error| {
format!(
"Failed to remove previous synchronized skill '{}': {}",
render_debug_path(&target_skill_path),
error
)
})?;
}
copy_directory_recursive(source_skill_path, &target_skill_path)?;
Ok(target_skill_path)
}
fn debug_sync_target_path_is_directory(target_skill_path: &Path) -> Result<bool, String> {
match fs::metadata(target_skill_path) {
Ok(metadata) if metadata.is_dir() => Ok(true),
Ok(_) => Err(format!(
"Previous synchronized skill path is not a directory: '{}'",
render_debug_path(target_skill_path)
)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(format!(
"Failed to inspect previous synchronized skill '{}': {}",
render_debug_path(target_skill_path),
error
)),
}
}
fn paths_refer_to_same_directory(left: &Path, right: &Path) -> Result<bool, String> {
if !same_directory_candidate_path_is_directory(left, "source")?
|| !same_directory_candidate_path_is_directory(right, "target")?
{
return Ok(false);
}
let left_canonical = fs::canonicalize(left).map_err(|error| {
format!(
"Failed to canonicalize source path '{}': {}",
render_debug_path(left),
error
)
})?;
let right_canonical = fs::canonicalize(right).map_err(|error| {
format!(
"Failed to canonicalize target path '{}': {}",
render_debug_path(right),
error
)
})?;
Ok(left_canonical == right_canonical)
}
fn same_directory_candidate_path_is_directory(
path: &Path,
path_label: &str,
) -> Result<bool, String> {
match fs::metadata(path) {
Ok(metadata) => Ok(metadata.is_dir()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(format!(
"Failed to inspect {} path '{}' before same-directory comparison: {}",
path_label,
render_debug_path(path),
error
)),
}
}
fn copy_directory_recursive(source: &Path, target: &Path) -> Result<(), String> {
fs::create_dir_all(target).map_err(|error| {
format!(
"Failed to create synchronized skill directory '{}': {}",
render_debug_path(target),
error
)
})?;
for entry in fs::read_dir(source).map_err(|error| {
format!(
"Failed to enumerate skill directory '{}': {}",
render_debug_path(source),
error
)
})? {
let entry = entry.map_err(|error| {
format!(
"Failed to read one directory entry under '{}': {}",
render_debug_path(source),
error
)
})?;
let file_type = entry.file_type().map_err(|error| {
format!(
"Failed to inspect entry '{}' type: {}",
render_debug_path(&entry.path()),
error
)
})?;
let destination = target.join(entry.file_name());
if file_type.is_symlink() {
return Err(format!(
"Symbolic-link entry '{}' is not supported by luaskills-debug",
render_debug_path(&entry.path())
));
}
if file_type.is_dir() {
copy_directory_recursive(&entry.path(), &destination)?;
} else if file_type.is_file() {
fs::copy(entry.path(), &destination).map_err(|error| {
format!(
"Failed to copy '{}' to '{}': {}",
render_debug_path(&entry.path()),
render_debug_path(&destination),
error
)
})?;
}
}
Ok(())
}
fn collect_ignored_skill_ids(
skills_dir: &Path,
target_skill_id: &str,
) -> Result<Vec<String>, String> {
if !debug_runtime_skills_path_is_directory(skills_dir)? {
return Ok(Vec::new());
}
let mut ignored = Vec::new();
for entry in fs::read_dir(skills_dir).map_err(|error| {
format!(
"Failed to enumerate runtime skills directory '{}': {}",
render_debug_path(skills_dir),
error
)
})? {
let entry = entry.map_err(|error| {
format!(
"Failed to read one runtime skill directory entry under '{}': {}",
render_debug_path(skills_dir),
error
)
})?;
if !entry
.file_type()
.map_err(|error| {
format!(
"Failed to inspect '{}': {}",
render_debug_path(&entry.path()),
error
)
})?
.is_dir()
{
continue;
}
let Some(candidate_id) = entry.file_name().to_str().map(|value| value.to_string()) else {
continue;
};
if candidate_id == target_skill_id {
continue;
}
if validate_luaskills_identifier(&candidate_id, "skill_id").is_ok() {
ignored.push(candidate_id);
}
}
ignored.sort();
Ok(ignored)
}
fn debug_runtime_skills_path_is_directory(skills_dir: &Path) -> Result<bool, String> {
match fs::metadata(skills_dir) {
Ok(metadata) if metadata.is_dir() => Ok(true),
Ok(_) => Err(format!(
"Runtime skills path is not a directory: '{}'",
render_debug_path(skills_dir)
)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(format!(
"Failed to inspect runtime skills directory '{}': {}",
render_debug_path(skills_dir),
error
)),
}
}
fn build_debug_host_options(
runtime_root: &Path,
managed_runtime_distribution_root: Option<PathBuf>,
managed_runtime_environment_root: Option<PathBuf>,
managed_runtime_config: LuaRuntimeManagedRuntimeConfig,
ignored_skill_ids: Vec<String>,
) -> LuaRuntimeHostOptions {
let mut host_options = LuaRuntimeHostOptions::with_runtime_root(runtime_root.to_path_buf());
host_options.managed_runtime_distribution_root = managed_runtime_distribution_root;
host_options.managed_runtime_environment_root = managed_runtime_environment_root;
host_options.managed_runtime_config = managed_runtime_config;
host_options.allow_network_download = true;
host_options.ignored_skill_ids = ignored_skill_ids;
host_options
}
fn filter_skill_entries(
entries: &[RuntimeEntryDescriptor],
skill_id: &str,
) -> Vec<RuntimeEntryDescriptor> {
let mut filtered = entries
.iter()
.filter(|entry| entry.skill_id == skill_id)
.cloned()
.collect::<Vec<_>>();
filtered.sort_by(|left, right| left.canonical_name.cmp(&right.canonical_name));
filtered
}
fn resolve_debug_tool_name(
entries: &[RuntimeEntryDescriptor],
requested_tool_name: &str,
) -> Result<String, String> {
let normalized = requested_tool_name.trim();
if normalized.is_empty() {
return Err("Tool name must not be empty".to_string());
}
if let Some(entry) = entries
.iter()
.find(|entry| entry.canonical_name == normalized)
{
return Ok(entry.canonical_name.clone());
}
let local_matches = entries
.iter()
.filter(|entry| entry.local_name == normalized)
.collect::<Vec<_>>();
match local_matches.as_slice() {
[entry] => Ok(entry.canonical_name.clone()),
[] => Err(format!(
"Tool '{}' not found. Available tools: {}",
normalized,
entries
.iter()
.map(|entry| entry.canonical_name.as_str())
.collect::<Vec<_>>()
.join(", ")
)),
_ => Err(format!(
"Tool '{}' is ambiguous within the loaded skill",
normalized
)),
}
}
fn load_invocation_args(command: &DebugCliCommand) -> Result<Value, String> {
match (&command.args_json, &command.args_file) {
(Some(args_json), None) => serde_json::from_str(args_json)
.map_err(|error| format!("Failed to parse --args-json: {}", error)),
(None, Some(args_file)) => {
let args_text = fs::read_to_string(args_file).map_err(|error| {
format!(
"Failed to read args file '{}': {}",
render_debug_path(args_file),
error
)
})?;
serde_json::from_str(&args_text).map_err(|error| {
format!(
"Failed to parse args file '{}': {}",
render_debug_path(args_file),
error
)
})
}
(None, None) => Ok(json!({})),
(Some(_), Some(_)) => Err("--args-json and --args-file are mutually exclusive".to_string()),
}
}
fn build_debug_invocation_context(enable_host_result: bool) -> LuaInvocationContext {
if !enable_host_result {
return LuaInvocationContext::empty();
}
let request_context = RuntimeRequestContext {
client_capabilities: json!({
"host_result": {
"enabled": true
}
}),
..RuntimeRequestContext::default()
};
LuaInvocationContext::new(Some(request_context), json!({}), json!({}))
}
fn build_inspect_output(prepared: &PreparedDebugRuntime) -> DebugInspectOutput {
DebugInspectOutput {
command: "inspect",
skill_id: prepared.skill_id.clone(),
manifest_name: prepared.manifest.name.clone(),
manifest_version: prepared.manifest.version().to_string(),
debug: prepared.manifest.debug,
runtime_root: render_host_visible_path(&prepared.runtime_root),
source_skill_path: prepared
.source_skill_path
.as_ref()
.map(|path| render_host_visible_path(path)),
synced_skill_path: render_host_visible_path(&prepared.synced_skill_path),
entries: prepared.entries.clone(),
}
}
fn render_sync_output(mode: DebugOutputMode, output: &DebugSyncOutput) -> Result<(), String> {
match mode {
DebugOutputMode::Pretty => {
println!("skill_id: {}", output.skill_id);
println!("runtime_root: {}", output.runtime_root);
println!("source_skill_path: {}", output.source_skill_path);
println!("synced_skill_path: {}", output.synced_skill_path);
Ok(())
}
DebugOutputMode::Json => {
println!(
"{}",
serde_json::to_string_pretty(output)
.map_err(|error| format!("Failed to serialize sync output: {}", error))?
);
Ok(())
}
DebugOutputMode::Content => Err("sync does not support --output content".to_string()),
}
}
fn render_inspect_output(mode: DebugOutputMode, output: &DebugInspectOutput) -> Result<(), String> {
match mode {
DebugOutputMode::Pretty => {
println!("skill_id: {}", output.skill_id);
println!("manifest_name: {}", output.manifest_name);
println!("manifest_version: {}", output.manifest_version);
println!("debug: {}", output.debug);
println!("runtime_root: {}", output.runtime_root);
if let Some(source_skill_path) = &output.source_skill_path {
println!("source_skill_path: {}", source_skill_path);
}
println!("synced_skill_path: {}", output.synced_skill_path);
println!("entries:");
for entry in &output.entries {
println!(" - {} ({})", entry.canonical_name, entry.local_name);
}
Ok(())
}
DebugOutputMode::Json => {
println!(
"{}",
serde_json::to_string_pretty(output)
.map_err(|error| format!("Failed to serialize inspect output: {}", error))?
);
Ok(())
}
DebugOutputMode::Content => Err("inspect does not support --output content".to_string()),
}
}
fn render_list_tools_output(
mode: DebugOutputMode,
entries: &[RuntimeEntryDescriptor],
) -> Result<(), String> {
match mode {
DebugOutputMode::Pretty => {
for entry in entries {
println!("{} -> {}", entry.local_name, entry.canonical_name);
}
Ok(())
}
DebugOutputMode::Json => {
println!(
"{}",
serde_json::to_string_pretty(entries)
.map_err(|error| format!("Failed to serialize tool list: {}", error))?
);
Ok(())
}
DebugOutputMode::Content => {
for entry in entries {
println!("{}", entry.canonical_name);
}
Ok(())
}
}
}
fn render_call_output(mode: DebugOutputMode, output: &DebugCallOutput) -> Result<(), String> {
match mode {
DebugOutputMode::Pretty => {
println!("skill_id: {}", output.skill_id);
println!("requested_tool_name: {}", output.requested_tool_name);
println!("resolved_tool_name: {}", output.resolved_tool_name);
println!("runtime_root: {}", output.runtime_root);
println!("synced_skill_path: {}", output.synced_skill_path);
println!("content:");
println!("{}", output.result.content);
println!("overflow_mode: {:?}", output.result.overflow_mode);
println!("template_hint: {:?}", output.result.template_hint);
println!("content_bytes: {}", output.result.content_bytes);
println!("content_lines: {}", output.result.content_lines);
if let Some(host_result) = &output.result.host_result {
println!("host_result.kind: {}", host_result.kind);
println!(
"host_result.payload:\n{}",
serde_json::to_string_pretty(&host_result.payload).map_err(|error| {
format!("Failed to serialize host_result payload: {}", error)
})?
);
}
Ok(())
}
DebugOutputMode::Json => {
println!(
"{}",
serde_json::to_string_pretty(output)
.map_err(|error| format!("Failed to serialize call output: {}", error))?
);
Ok(())
}
DebugOutputMode::Content => {
println!("{}", output.result.content);
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::{
DebugCliCommand, DebugCommandKind, DebugOutputMode, collect_ignored_skill_ids,
load_bound_skill_manifest, load_invocation_args, parse_debug_cli,
paths_refer_to_same_directory, prepare_debug_runtime, render_debug_path,
resolve_debug_tool_name, sync_debug_skill,
};
use luaskills::{LuaInvocationContext, LuaRuntimeManagedRuntimeConfig, RuntimeEntryDescriptor};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
fn make_entry(local_name: &str, canonical_name: &str) -> RuntimeEntryDescriptor {
RuntimeEntryDescriptor {
canonical_name: canonical_name.to_string(),
skill_id: "demo-skill".to_string(),
local_name: local_name.to_string(),
root_name: "ROOT".to_string(),
skill_dir: "D:/demo-skill".to_string(),
description: String::new(),
parameters: Vec::new(),
input_schema: serde_json::json!({
"type": "object",
"properties": {}
}),
}
}
#[test]
fn parse_debug_cli_accepts_call_json_args() {
let args = vec![
"call".to_string(),
"--runtime-root".to_string(),
"D:/runtime".to_string(),
"--managed-runtime-distribution-root".to_string(),
"D:/application/dependencies/runtimes".to_string(),
"--managed-runtime-environment-root".to_string(),
"D:/data/managed-runtime-envs".to_string(),
"--managed-runtime-worker-pool-max-size".to_string(),
"8".to_string(),
"--managed-runtime-worker-idle-ttl-secs".to_string(),
"90".to_string(),
"--managed-runtime-session-limit".to_string(),
"128".to_string(),
"--managed-runtime-session-buffer-limit-bytes".to_string(),
"2097152".to_string(),
"--managed-runtime-invoke-timeout-ms".to_string(),
"15000".to_string(),
"--skill-path".to_string(),
"D:/skills/demo-skill".to_string(),
"--tool".to_string(),
"ping".to_string(),
"--args-json".to_string(),
"{\"x\":1}".to_string(),
"--enable-host-result".to_string(),
"--output".to_string(),
"json".to_string(),
];
let command = parse_debug_cli(&args).expect("parse call command");
assert_eq!(command.kind, DebugCommandKind::Call);
assert_eq!(command.output_mode, DebugOutputMode::Json);
assert_eq!(command.tool_name.as_deref(), Some("ping"));
assert_eq!(
command.managed_runtime_distribution_root.as_deref(),
Some(Path::new("D:/application/dependencies/runtimes"))
);
assert_eq!(
command.managed_runtime_environment_root.as_deref(),
Some(Path::new("D:/data/managed-runtime-envs"))
);
assert_eq!(
command
.managed_runtime_config
.worker_pool_max_size_per_environment,
8
);
assert_eq!(command.managed_runtime_config.worker_idle_ttl_secs, 90);
assert_eq!(
command
.managed_runtime_config
.persistent_session_limit_per_engine,
128
);
assert_eq!(
command
.managed_runtime_config
.persistent_session_default_buffer_limit_bytes_per_stream,
2_097_152
);
assert_eq!(
command.managed_runtime_config.invoke_default_timeout_ms,
Some(15_000)
);
assert_eq!(
command.skill_path.as_deref(),
Some(Path::new("D:/skills/demo-skill"))
);
assert!(command.enable_host_result);
}
#[test]
fn parse_debug_cli_accepts_run_with_skill_id() {
let args = vec![
"call".to_string(),
"--runtime-root".to_string(),
"D:/runtime".to_string(),
"--skill-id".to_string(),
"demo-skill".to_string(),
"--tool".to_string(),
"ping".to_string(),
];
let command = parse_debug_cli(&args).expect("parse skill-id call command");
assert_eq!(command.kind, DebugCommandKind::Call);
assert_eq!(command.skill_id.as_deref(), Some("demo-skill"));
assert!(command.skill_path.is_none());
}
#[test]
fn parse_debug_cli_rejects_duplicate_arg_sources() {
let args = vec![
"call".to_string(),
"--runtime-root".to_string(),
"D:/runtime".to_string(),
"--skill-path".to_string(),
"D:/skills/demo-skill".to_string(),
"--tool".to_string(),
"ping".to_string(),
"--args-json".to_string(),
"{\"x\":1}".to_string(),
"--args-file".to_string(),
"args.json".to_string(),
];
let error = parse_debug_cli(&args).expect_err("duplicate arg sources should fail");
assert!(error.contains("mutually exclusive"));
}
#[test]
fn resolve_debug_tool_name_accepts_local_name() {
let entries = vec![
make_entry("ping", "demo-skill-ping"),
make_entry("read", "demo-skill-read"),
];
let resolved = resolve_debug_tool_name(&entries, "read").expect("resolve local name");
assert_eq!(resolved, "demo-skill-read");
}
#[test]
fn resolve_debug_tool_name_accepts_canonical_name() {
let entries = vec![make_entry("ping", "demo-skill-ping")];
let resolved =
resolve_debug_tool_name(&entries, "demo-skill-ping").expect("resolve canonical name");
assert_eq!(resolved, "demo-skill-ping");
}
fn make_temp_runtime_root() -> PathBuf {
let unique_suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_nanos();
env::temp_dir().join(format!("luaskills-debug-test-{}", unique_suffix))
}
fn remove_temp_directory(path: &Path) {
if path.exists() {
fs::remove_dir_all(path).expect("temporary directory should be removable");
}
}
#[test]
fn collect_ignored_skill_ids_accepts_missing_skills_dir() {
let runtime_root = make_temp_runtime_root();
let skills_dir = runtime_root.join("skills");
let ignored = collect_ignored_skill_ids(&skills_dir, "demo-skill")
.expect("missing skills directory should be accepted");
assert!(ignored.is_empty());
}
#[test]
fn collect_ignored_skill_ids_rejects_file_skills_dir() {
let runtime_root = make_temp_runtime_root();
fs::create_dir_all(&runtime_root).expect("runtime root should be created");
let skills_dir = runtime_root.join("skills");
fs::write(&skills_dir, b"not-a-directory").expect("skills fixture file should be written");
let error = collect_ignored_skill_ids(&skills_dir, "demo-skill")
.expect_err("file-backed skills path should fail");
let expected_error = format!(
"Runtime skills path is not a directory: '{}'",
render_debug_path(&skills_dir)
);
assert_eq!(error, expected_error);
remove_temp_directory(&runtime_root);
}
#[test]
fn collect_ignored_skill_ids_reports_skills_dir_probe_errors() {
let skills_dir = PathBuf::from("invalid\0skills");
let error = collect_ignored_skill_ids(&skills_dir, "demo-skill")
.expect_err("invalid skills path probe should fail");
assert!(
error.contains("Failed to inspect runtime skills directory"),
"unexpected error: {}",
error
);
assert!(error.contains("invalid"), "unexpected error: {}", error);
}
#[test]
fn paths_refer_to_same_directory_reports_source_probe_errors() {
let source_path = PathBuf::from("invalid\0source");
let runtime_root = make_temp_runtime_root();
fs::create_dir_all(&runtime_root).expect("target directory should be created");
let error = paths_refer_to_same_directory(&source_path, &runtime_root)
.expect_err("invalid source path probe should fail");
assert!(
error.contains("Failed to inspect source path"),
"unexpected error: {}",
error
);
assert!(error.contains("invalid"), "unexpected error: {}", error);
remove_temp_directory(&runtime_root);
}
#[test]
fn paths_refer_to_same_directory_reports_target_probe_errors() {
let runtime_root = make_temp_runtime_root();
let source_path = runtime_root.join("source");
fs::create_dir_all(&source_path).expect("source directory should be created");
let target_path = PathBuf::from("invalid\0target");
let error = paths_refer_to_same_directory(&source_path, &target_path)
.expect_err("invalid target path probe should fail");
assert!(
error.contains("Failed to inspect target path"),
"unexpected error: {}",
error
);
assert!(error.contains("invalid"), "unexpected error: {}", error);
remove_temp_directory(&runtime_root);
}
#[test]
fn load_bound_skill_manifest_parse_error_uses_host_visible_path() {
let runtime_root = make_temp_runtime_root();
let skill_dir = runtime_root.join("demo-skill");
fs::create_dir_all(&skill_dir).expect("skill directory should be created");
let manifest_path = skill_dir.join("skill.yaml");
fs::write(&manifest_path, "entries: [").expect("invalid manifest should be written");
let error =
load_bound_skill_manifest(&skill_dir).expect_err("invalid skill manifest should fail");
let expected_prefix = format!(
"Failed to parse skill manifest '{}':",
render_debug_path(&manifest_path)
);
assert!(
error.starts_with(&expected_prefix),
"unexpected error: {}",
error
);
remove_temp_directory(&runtime_root);
}
#[test]
fn load_bound_skill_manifest_reports_skill_path_probe_errors() {
let skill_path = PathBuf::from("invalid\0skill");
let error = match load_bound_skill_manifest(&skill_path) {
Ok(_) => panic!("invalid skill path probe should fail"),
Err(error) => error,
};
assert!(
error.starts_with("Failed to inspect skill path"),
"unexpected error: {}",
error
);
assert!(error.contains("invalid"), "unexpected error: {}", error);
}
#[test]
fn load_invocation_args_file_parse_error_uses_host_visible_path() {
let runtime_root = make_temp_runtime_root();
fs::create_dir_all(&runtime_root).expect("runtime root should be created");
let args_file = runtime_root.join("args.json");
fs::write(&args_file, "{not-json").expect("invalid args file should be written");
let command = DebugCliCommand {
kind: DebugCommandKind::Call,
runtime_root: runtime_root.clone(),
managed_runtime_distribution_root: None,
managed_runtime_environment_root: None,
managed_runtime_config: LuaRuntimeManagedRuntimeConfig::default(),
skill_path: None,
skill_id: Some("demo-skill".to_string()),
tool_name: Some("ping".to_string()),
args_json: None,
args_file: Some(args_file.clone()),
enable_host_result: false,
output_mode: DebugOutputMode::Pretty,
};
let error = load_invocation_args(&command).expect_err("invalid args JSON should fail");
let expected_prefix = format!(
"Failed to parse args file '{}':",
render_debug_path(&args_file)
);
assert!(
error.starts_with(&expected_prefix),
"unexpected error: {}",
error
);
remove_temp_directory(&runtime_root);
}
#[test]
fn sync_debug_skill_rejects_file_target_skill_path() {
let runtime_root = make_temp_runtime_root();
let skill_path = PathBuf::from(
"examples/ffi/standard_runtime/runtime_root/skills/demo-standard-ffi-skill",
);
let target_skill_path = runtime_root.join("skills").join("demo-standard-ffi-skill");
let target_parent = target_skill_path
.parent()
.expect("target skill path should have a parent");
fs::create_dir_all(target_parent).expect("target parent should be created");
fs::write(&target_skill_path, b"not a directory").expect("file target should be written");
let command = DebugCliCommand {
kind: DebugCommandKind::Sync,
runtime_root: runtime_root.clone(),
managed_runtime_distribution_root: None,
managed_runtime_environment_root: None,
managed_runtime_config: LuaRuntimeManagedRuntimeConfig::default(),
skill_path: Some(skill_path),
skill_id: None,
tool_name: None,
args_json: None,
args_file: None,
enable_host_result: false,
output_mode: DebugOutputMode::Pretty,
};
let error = match sync_debug_skill(&command) {
Ok(_) => panic!("file target skill path should fail"),
Err(error) => error,
};
assert!(
error.contains("Previous synchronized skill path is not a directory"),
"unexpected error: {}",
error
);
assert!(
error.contains(&render_debug_path(&target_skill_path)),
"unexpected error: {}",
error
);
remove_temp_directory(&runtime_root);
}
#[test]
fn prepare_debug_runtime_loads_and_calls_skill_from_runtime_root() {
let runtime_root = make_temp_runtime_root();
let distribution_root = runtime_root.with_extension("distributions");
let environment_root = runtime_root.with_extension("environments");
fs::create_dir_all(&distribution_root).expect("create explicit distribution root");
let skill_path = PathBuf::from(
"examples/ffi/standard_runtime/runtime_root/skills/demo-standard-ffi-skill",
);
let command = DebugCliCommand {
kind: DebugCommandKind::Call,
runtime_root: runtime_root.clone(),
managed_runtime_distribution_root: Some(distribution_root.clone()),
managed_runtime_environment_root: Some(environment_root.clone()),
managed_runtime_config: LuaRuntimeManagedRuntimeConfig::default(),
skill_path: Some(skill_path),
skill_id: None,
tool_name: Some("ping".to_string()),
args_json: Some(r#"{"note":"from-debug-bin"}"#.to_string()),
args_file: None,
enable_host_result: false,
output_mode: DebugOutputMode::Pretty,
};
let prepared = prepare_debug_runtime(&command).expect("runtime should prepare");
let resolved_name =
resolve_debug_tool_name(&prepared.entries, "ping").expect("tool should resolve");
let args = load_invocation_args(&command).expect("args should parse");
let result = prepared
.engine
.call_skill(&resolved_name, &args, Some(&LuaInvocationContext::empty()))
.expect("skill call should succeed");
assert_eq!(prepared.skill_id, "demo-standard-ffi-skill");
assert_eq!(resolved_name, "demo-standard-ffi-skill-ping");
assert_eq!(result.content, "standard-ffi-demo:from-debug-bin");
assert!(prepared.synced_skill_path.exists());
assert!(environment_root.is_dir());
remove_temp_directory(&runtime_root);
remove_temp_directory(&distribution_root);
remove_temp_directory(&environment_root);
}
#[test]
fn prepare_debug_runtime_rejects_directory_synced_skill_manifest() {
let runtime_root = make_temp_runtime_root();
let synced_skill_path = runtime_root.join("skills").join("demo-skill");
let manifest_path = synced_skill_path.join("skill.yaml");
fs::create_dir_all(&manifest_path).expect("directory manifest should be created");
let command = DebugCliCommand {
kind: DebugCommandKind::Call,
runtime_root: runtime_root.clone(),
managed_runtime_distribution_root: None,
managed_runtime_environment_root: None,
managed_runtime_config: LuaRuntimeManagedRuntimeConfig::default(),
skill_path: None,
skill_id: Some("demo-skill".to_string()),
tool_name: Some("ping".to_string()),
args_json: None,
args_file: None,
enable_host_result: false,
output_mode: DebugOutputMode::Pretty,
};
let error = match prepare_debug_runtime(&command) {
Ok(_) => panic!("directory synchronized manifest should fail"),
Err(error) => error,
};
assert!(
error.contains("Synchronized skill manifest is not a file"),
"unexpected error: {}",
error
);
assert!(
error.contains(&render_debug_path(&manifest_path)),
"unexpected error: {}",
error
);
remove_temp_directory(&runtime_root);
}
#[test]
fn prepare_debug_runtime_can_run_pre_synced_skill_by_id() {
let runtime_root = make_temp_runtime_root();
let skill_path = PathBuf::from(
"examples/ffi/standard_runtime/runtime_root/skills/demo-standard-ffi-skill",
);
let sync_command = DebugCliCommand {
kind: DebugCommandKind::Sync,
runtime_root: runtime_root.clone(),
managed_runtime_distribution_root: None,
managed_runtime_environment_root: None,
managed_runtime_config: LuaRuntimeManagedRuntimeConfig::default(),
skill_path: Some(skill_path),
skill_id: None,
tool_name: None,
args_json: None,
args_file: None,
enable_host_result: false,
output_mode: DebugOutputMode::Pretty,
};
let sync_output = sync_debug_skill(&sync_command).expect("sync should succeed");
let run_command = DebugCliCommand {
kind: DebugCommandKind::Call,
runtime_root: runtime_root.clone(),
managed_runtime_distribution_root: None,
managed_runtime_environment_root: None,
managed_runtime_config: LuaRuntimeManagedRuntimeConfig::default(),
skill_path: None,
skill_id: Some(sync_output.skill_id.clone()),
tool_name: Some("ping".to_string()),
args_json: Some(r#"{"note":"from-synced-runtime"}"#.to_string()),
args_file: None,
enable_host_result: false,
output_mode: DebugOutputMode::Pretty,
};
let prepared =
prepare_debug_runtime(&run_command).expect("pre-synced runtime should prepare");
let resolved_name =
resolve_debug_tool_name(&prepared.entries, "ping").expect("tool should resolve");
let args = load_invocation_args(&run_command).expect("args should parse");
let result = prepared
.engine
.call_skill(&resolved_name, &args, Some(&LuaInvocationContext::empty()))
.expect("skill call should succeed");
assert_eq!(prepared.skill_id, "demo-standard-ffi-skill");
assert!(prepared.source_skill_path.is_none());
assert_eq!(result.content, "standard-ffi-demo:from-synced-runtime");
remove_temp_directory(&runtime_root);
}
}