use std::env;
use std::fs;
use std::path::{Path, PathBuf};
const GENERATED_HEADER: &str = "# Generated by `draxl mcp setup --client codex`.\n";
const APPROVED_READ_TOOLS: &[&str] = &[
"draxl_inspect_file",
"draxl_get_node",
"draxl_check_conflicts",
];
const CODEX_DEVELOPER_INSTRUCTIONS: &str = r#"This workspace is configured for Draxl MCP editing.
Read files and inspect code normally.
Use the `draxl_*` MCP tools for edits to `.rs.dx` files.
Do not modify `.rs.dx` files directly.
When a Draxl tool fails, report the failure instead of falling back to direct file edits for that file.
Start by calling `draxl_inspect_file` on the target `.rs.dx` file.
Pass `expected_fingerprint` on mutating tool calls when it is available."#;
pub(crate) fn run_setup<I>(args: I) -> Result<(), String>
where
I: IntoIterator<Item = String>,
{
let options = SetupOptions::parse(args)?;
let root = canonicalize_existing_dir(
options.root.unwrap_or(
env::current_dir()
.map_err(|err| format!("failed to determine current directory: {err}"))?,
),
)?;
let draxl_exe = env::current_exe()
.map_err(|err| format!("failed to determine draxl executable path: {err}"))?
.canonicalize()
.map_err(|err| format!("failed to canonicalize draxl executable path: {err}"))?;
let config = build_codex_config(&draxl_exe, &root);
if options.print {
print!("{config}");
return Ok(());
}
let codex_dir = root.join(".codex");
let config_path = codex_dir.join("config.toml");
if config_path.exists() {
let existing = fs::read_to_string(&config_path)
.map_err(|err| format!("failed to read {}: {err}", config_path.display()))?;
if !options.force && !is_managed_config(&existing) {
return Err(format!(
"refusing to overwrite unmanaged Codex config at {}. Re-run with `--force` or use `--print`.",
config_path.display()
));
}
}
fs::create_dir_all(&codex_dir)
.map_err(|err| format!("failed to create {}: {err}", codex_dir.display()))?;
fs::write(&config_path, &config)
.map_err(|err| format!("failed to write {}: {err}", config_path.display()))?;
println!("wrote {}", config_path.display());
if let Some(updated_exclude) = ensure_git_exclude(&root)? {
println!("updated {}", updated_exclude.display());
}
Ok(())
}
pub(crate) fn setup_usage() -> &'static str {
"usage:
draxl mcp setup --client codex [--root <workspace>] [--print] [--force]"
}
#[derive(Debug, Default)]
struct SetupOptions {
client: Option<String>,
root: Option<PathBuf>,
print: bool,
force: bool,
}
impl SetupOptions {
fn parse<I>(args: I) -> Result<Self, String>
where
I: IntoIterator<Item = String>,
{
let mut options = Self::default();
let mut args = args.into_iter();
while let Some(arg) = args.next() {
match arg.as_str() {
"--client" => {
let Some(client) = args.next() else {
return Err(format!("missing value for `--client`\n\n{}", setup_usage()));
};
options.client = Some(client);
}
"--root" => {
let Some(root) = args.next() else {
return Err(format!("missing value for `--root`\n\n{}", setup_usage()));
};
options.root = Some(PathBuf::from(root));
}
"--print" => options.print = true,
"--force" => options.force = true,
_ => return Err(format!("unknown argument `{arg}`\n\n{}", setup_usage())),
}
}
match options.client.as_deref() {
Some("codex") => Ok(options),
Some(other) => Err(format!(
"unsupported client `{other}`; expected `codex`\n\n{}",
setup_usage()
)),
None => Err(format!("missing `--client codex`\n\n{}", setup_usage())),
}
}
}
fn build_codex_config(draxl_exe: &Path, root: &Path) -> String {
let root_text = root.display().to_string();
let draxl_exe_text = draxl_exe.display().to_string();
let args = ["mcp", "serve", "--root", root_text.as_str()]
.into_iter()
.map(toml_string)
.collect::<Vec<_>>()
.join(", ");
let tool_sections = APPROVED_READ_TOOLS
.iter()
.map(|tool| format!("[mcp_servers.draxl.tools.{tool}]\napproval_mode = \"approve\"\n"))
.collect::<Vec<_>>()
.join("\n");
format!(
r#"{GENERATED_HEADER}sandbox_mode = "read-only"
approval_policy = "on-request"
allow_login_shell = false
developer_instructions = """
{CODEX_DEVELOPER_INSTRUCTIONS}
"""
[mcp_servers.draxl]
command = {draxl_exe}
args = [{args}]
cwd = {root}
{tool_sections}"#,
draxl_exe = toml_string(&draxl_exe_text),
root = toml_string(&root_text),
)
}
fn is_managed_config(source: &str) -> bool {
source.starts_with(GENERATED_HEADER)
}
fn canonicalize_existing_dir(path: PathBuf) -> Result<PathBuf, String> {
let canonical = path
.canonicalize()
.map_err(|err| format!("failed to canonicalize {}: {err}", path.display()))?;
if !canonical.is_dir() {
return Err(format!(
"workspace root must be a directory: {}",
canonical.display()
));
}
Ok(canonical)
}
fn toml_string(value: &str) -> String {
serde_json::to_string(value).expect("json string escaping should succeed")
}
fn ensure_git_exclude(root: &Path) -> Result<Option<PathBuf>, String> {
let Some((repo_root, git_dir)) = find_git_repo(root)? else {
return Ok(None);
};
let exclude_path = git_dir.join("info").join("exclude");
let entry = exclude_entry(&repo_root, root)?;
let existing = fs::read_to_string(&exclude_path).unwrap_or_default();
if existing.lines().any(|line| line.trim() == entry) {
return Ok(None);
}
if let Some(parent) = exclude_path.parent() {
fs::create_dir_all(parent)
.map_err(|err| format!("failed to create {}: {err}", parent.display()))?;
}
let mut updated = existing;
if !updated.is_empty() && !updated.ends_with('\n') {
updated.push('\n');
}
updated.push_str(&entry);
updated.push('\n');
fs::write(&exclude_path, updated)
.map_err(|err| format!("failed to write {}: {err}", exclude_path.display()))?;
Ok(Some(exclude_path))
}
fn exclude_entry(repo_root: &Path, root: &Path) -> Result<String, String> {
let relative = root
.strip_prefix(repo_root)
.map_err(|err| format!("failed to relativize {}: {err}", root.display()))?;
let entry = if relative.as_os_str().is_empty() {
".codex".to_owned()
} else {
relative.join(".codex").to_string_lossy().into_owned()
};
Ok(format!("{}/", entry.replace('\\', "/")))
}
fn find_git_repo(root: &Path) -> Result<Option<(PathBuf, PathBuf)>, String> {
for candidate in root.ancestors() {
let dot_git = candidate.join(".git");
if dot_git.is_dir() {
return Ok(Some((candidate.to_path_buf(), dot_git)));
}
if dot_git.is_file() {
let git_dir = resolve_git_dir(candidate, &dot_git)?;
return Ok(Some((candidate.to_path_buf(), git_dir)));
}
}
Ok(None)
}
fn resolve_git_dir(repo_root: &Path, dot_git_file: &Path) -> Result<PathBuf, String> {
let source = fs::read_to_string(dot_git_file)
.map_err(|err| format!("failed to read {}: {err}", dot_git_file.display()))?;
let Some(path_text) = source.trim().strip_prefix("gitdir: ") else {
return Err(format!(
"unsupported gitdir file format in {}",
dot_git_file.display()
));
};
let git_dir = Path::new(path_text);
let resolved = if git_dir.is_absolute() {
git_dir.to_path_buf()
} else {
repo_root.join(git_dir)
};
Ok(resolved)
}