use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use crate::types::{self, FunctionDef, ToolDefinition};
use super::normalize_path;
pub(super) fn tool_definitions() -> Vec<ToolDefinition> {
vec![
ToolDefinition {
tool_type: "function".to_string(),
function: FunctionDef {
name: "write_output".to_string(),
description: "Write final output to the output directory. Use for deliverable files (HTML, reports, etc.). Path is relative to the output directory. Use append: true to append to existing file. For content >~6k chars, split into multiple calls: first call overwrites, subsequent calls use append: true.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "File path relative to the output directory"
},
"content": {
"type": "string",
"description": "Content to write"
},
"append": {
"type": "boolean",
"description": "If true, append content to end of file. Default: false (overwrite)."
}
},
"required": ["file_path", "content"]
}),
},
},
ToolDefinition {
tool_type: "function".to_string(),
function: FunctionDef {
name: "list_output".to_string(),
description: "List files in the output directory (where write_output saves files). Use when the user asks what files were generated, or to find output files by name. No path needed.".to_string(),
parameters: json!({
"type": "object",
"properties": {
"recursive": {
"type": "boolean",
"description": "If true, list recursively. Default: false."
}
},
"required": []
}),
},
},
]
}
pub(super) fn execute_write_output(args: &Value, workspace: &Path) -> Result<String> {
let file_path = args
.get("file_path")
.and_then(|v| v.as_str())
.context("'file_path' is required")?;
let content = args
.get("content")
.and_then(|v| v.as_str())
.context("'content' is required")?;
let append = args
.get("append")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let output_root = match types::get_output_dir() {
Some(dir) => PathBuf::from(dir),
None => workspace.join("output"),
};
let input = Path::new(file_path);
let resolved = if input.is_absolute() {
input.to_path_buf()
} else {
output_root.join(input)
};
let normalized = normalize_path(&resolved);
if !normalized.starts_with(&output_root) {
anyhow::bail!(
"Path escapes output directory: {} (output_root: {})",
file_path,
output_root.display()
);
}
if append {
skilllite_fs::append_file(&normalized, content).with_context(|| {
format!("Failed to append to output file: {}", normalized.display())
})?;
} else {
skilllite_fs::write_file(&normalized, content)
.with_context(|| format!("Failed to write output file: {}", normalized.display()))?;
}
Ok(format!(
"Successfully {} {} bytes to {}",
if append { "appended" } else { "wrote" },
content.len(),
normalized.display()
))
}
pub(super) fn execute_list_output(args: &Value) -> Result<String> {
let recursive = args
.get("recursive")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let output_root = types::get_output_dir()
.map(PathBuf::from)
.ok_or_else(|| anyhow::anyhow!("Output directory not configured (SKILLLITE_OUTPUT_DIR)"))?;
let entries = match skilllite_fs::list_directory(&output_root, recursive) {
Ok(e) => e,
Err(_) => return Ok("Output directory does not exist or is empty.".to_string()),
};
if entries.is_empty() {
return Ok("Output directory is empty.".to_string());
}
Ok(entries.join("\n"))
}