Skip to main content

zagens_runtime/cli/
setup.rs

1//! Setup/bootstrap template helpers (retained for tests after CLI sunset).
2
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, anyhow};
6
7use crate::mcp::{McpConfig, McpServerConfig};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub(crate) enum WriteStatus {
11    Created,
12    Overwritten,
13    SkippedExists,
14}
15
16pub(crate) fn ensure_parent_dir(path: &Path) -> Result<()> {
17    if let Some(parent) = path.parent()
18        && !parent.as_os_str().is_empty()
19    {
20        std::fs::create_dir_all(parent)
21            .with_context(|| format!("Failed to create directory for {}", parent.display()))?;
22    }
23    Ok(())
24}
25
26pub(crate) fn write_template_file(path: &Path, contents: &str, force: bool) -> Result<WriteStatus> {
27    ensure_parent_dir(path)?;
28
29    if path.exists() && !force {
30        return Ok(WriteStatus::SkippedExists);
31    }
32
33    let status = if path.exists() {
34        WriteStatus::Overwritten
35    } else {
36        WriteStatus::Created
37    };
38
39    std::fs::write(path, contents)
40        .with_context(|| format!("Failed to write template at {}", path.display()))?;
41
42    Ok(status)
43}
44
45pub(crate) fn mcp_template_json() -> Result<String> {
46    let mut cfg = McpConfig::default();
47    cfg.servers.insert(
48        "example".to_string(),
49        McpServerConfig {
50            command: Some("node".to_string()),
51            args: vec!["./path/to/your-mcp-server.js".to_string()],
52            env: std::collections::HashMap::new(),
53            url: None,
54            transport: None,
55            headers: std::collections::HashMap::new(),
56            auth: None,
57            connect_timeout: None,
58            execute_timeout: None,
59            read_timeout: None,
60            disabled: true,
61            enabled: true,
62            required: false,
63            enabled_tools: Vec::new(),
64            disabled_tools: Vec::new(),
65        },
66    );
67    serde_json::to_string_pretty(&cfg)
68        .map_err(|e| anyhow!("Failed to render MCP template JSON: {e}"))
69}
70
71pub(crate) fn init_mcp_config(path: &Path, force: bool) -> Result<WriteStatus> {
72    let template = mcp_template_json()?;
73    write_template_file(path, &template, force)
74}
75
76pub(crate) fn skills_template(name: &str) -> String {
77    format!(
78        "\
79---\n\
80name: {name}\n\
81description: Quick repo diagnostics and setup guidance\n\
82allowed-tools: diagnostics, list_dir, read_file, grep_files, git_status, git_diff\n\
83---\n\n\
84When this skill is active:\n\
851. Run the diagnostics tool to report workspace and sandbox status.\n\
862. Skim key project files (README.md, Cargo.toml, AGENTS.md) before editing.\n\
873. Prefer small, validated changes and summarize what you verified.\n\
88"
89    )
90}
91
92pub(crate) fn init_skills_dir(skills_dir: &Path, force: bool) -> Result<(PathBuf, WriteStatus)> {
93    std::fs::create_dir_all(skills_dir)
94        .with_context(|| format!("Failed to create skills dir {}", skills_dir.display()))?;
95
96    let skill_name = "getting-started";
97    let skill_path = skills_dir.join(skill_name).join("SKILL.md");
98    ensure_parent_dir(&skill_path)?;
99
100    let status = write_template_file(&skill_path, &skills_template(skill_name), force)?;
101    Ok((skill_path, status))
102}
103
104pub(crate) fn tools_readme_template() -> &'static str {
105    "# Local tools\n\n\
106     Drop self-describing scripts here so they can be discovered by setup/doctor.\n\n\
107     Each script should start with a frontmatter-style header:\n\n\
108     ```\n\
109     # name: my-tool\n\
110     # description: One-line summary\n\
111     # usage: my-tool [args...]\n\
112     ```\n"
113}
114
115pub(crate) fn tools_example_script() -> &'static str {
116    "#!/usr/bin/env sh\n\
117     # name: example\n\
118     # description: Print a confirmation that local tool discovery works\n\
119     # usage: example [name]\n\
120     printf 'deepseek-runtime local tool ok: %s\\n' \"${1:-world}\"\n"
121}
122
123pub(crate) fn init_tools_dir(
124    tools_dir: &Path,
125    force: bool,
126) -> Result<(PathBuf, WriteStatus, WriteStatus)> {
127    std::fs::create_dir_all(tools_dir)
128        .with_context(|| format!("Failed to create tools dir {}", tools_dir.display()))?;
129
130    let readme_path = tools_dir.join("README.md");
131    let readme_status = write_template_file(&readme_path, tools_readme_template(), force)?;
132
133    let example_path = tools_dir.join("example.sh");
134    let example_status = write_template_file(&example_path, tools_example_script(), force)?;
135
136    Ok((tools_dir.to_path_buf(), readme_status, example_status))
137}
138
139pub(crate) fn plugins_readme_template() -> &'static str {
140    "# Local plugins\n\n\
141     Plugins live in subdirectories with a `PLUGIN.md` describing usage.\n"
142}
143
144pub(crate) fn plugin_example_template() -> &'static str {
145    "---\n\
146     name: example\n\
147     description: Placeholder plugin\n\
148     status: example\n\
149     ---\n\n\
150     Starter plugin layout for local experiments.\n"
151}
152
153pub(crate) fn init_plugins_dir(
154    plugins_dir: &Path,
155    force: bool,
156) -> Result<(PathBuf, PathBuf, WriteStatus, WriteStatus)> {
157    std::fs::create_dir_all(plugins_dir)
158        .with_context(|| format!("Failed to create plugins dir {}", plugins_dir.display()))?;
159
160    let readme_path = plugins_dir.join("README.md");
161    let readme_status = write_template_file(&readme_path, plugins_readme_template(), force)?;
162
163    let example_path = plugins_dir.join("example").join("PLUGIN.md");
164    ensure_parent_dir(&example_path)?;
165    let example_status = write_template_file(&example_path, plugin_example_template(), force)?;
166
167    Ok((readme_path, example_path, readme_status, example_status))
168}
169
170pub(crate) fn deepseek_home_dir() -> PathBuf {
171    zagens_config::user_data_root()
172        .unwrap_or_else(|_| PathBuf::from(zagens_config::USER_DATA_DIR_NAME))
173}
174
175pub(crate) fn default_checkpoints_dir() -> PathBuf {
176    deepseek_home_dir().join("sessions").join("checkpoints")
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub(crate) struct CleanPlan {
181    pub(crate) targets: Vec<PathBuf>,
182}
183
184pub(crate) fn collect_clean_targets(checkpoints_dir: &Path) -> CleanPlan {
185    let candidates = ["latest.json", "offline_queue.json"];
186    let targets = candidates
187        .iter()
188        .map(|name| checkpoints_dir.join(name))
189        .filter(|p| p.exists())
190        .collect();
191    CleanPlan { targets }
192}
193
194pub(crate) fn execute_clean_plan(plan: &CleanPlan) -> Result<Vec<PathBuf>> {
195    let mut removed = Vec::with_capacity(plan.targets.len());
196    for path in &plan.targets {
197        std::fs::remove_file(path)
198            .with_context(|| format!("Failed to remove {}", path.display()))?;
199        removed.push(path.clone());
200    }
201    Ok(removed)
202}
203
204pub(crate) fn dotenv_status_line(workspace: &Path) -> String {
205    let dotenv = workspace.join(".env");
206    if dotenv.exists() {
207        return format!(".env present at {}", dotenv.display());
208    }
209
210    if workspace.join(".env.example").exists() {
211        return ".env not present in workspace (run `cp .env.example .env` and edit)".to_string();
212    }
213
214    ".env not present in workspace".to_string()
215}
216
217pub(crate) fn run_setup_clean(checkpoints_dir: &Path, force: bool) -> Result<()> {
218    use colored::Colorize;
219
220    if !checkpoints_dir.exists() {
221        println!(
222            "Nothing to clean — checkpoints dir does not exist: {}",
223            checkpoints_dir.display()
224        );
225        return Ok(());
226    }
227
228    let plan = collect_clean_targets(checkpoints_dir);
229    if plan.targets.is_empty() {
230        println!(
231            "Nothing to clean — no checkpoint files in {}",
232            checkpoints_dir.display()
233        );
234        return Ok(());
235    }
236
237    if !force {
238        println!(
239            "Would remove {} checkpoint file(s) (use --force to apply):",
240            plan.targets.len()
241        );
242        for path in &plan.targets {
243            println!("  · {}", path.display());
244        }
245        return Ok(());
246    }
247
248    let removed = execute_clean_plan(&plan)?;
249    println!("{}", "Cleaned checkpoints:".bold());
250    for path in &removed {
251        println!("  ✓ {}", path.display());
252    }
253    Ok(())
254}
255
256pub(crate) fn is_command_available(name: &str) -> bool {
257    let Some(path) = std::env::var_os("PATH") else {
258        return false;
259    };
260    for dir in std::env::split_paths(&path) {
261        let candidate = dir.join(name);
262        if candidate.is_file() {
263            return true;
264        }
265        #[cfg(windows)]
266        {
267            if candidate.extension().is_none() && candidate.with_extension("exe").is_file() {
268                return true;
269            }
270        }
271    }
272    false
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub(crate) enum ApiKeySource {
277    Env,
278    Config,
279    Keyring,
280    Missing,
281}
282
283pub(crate) fn resolve_api_key_source(config: &crate::config::Config) -> ApiKeySource {
284    if std::env::var("DEEPSEEK_API_KEY")
285        .ok()
286        .filter(|k| !k.trim().is_empty())
287        .is_some()
288    {
289        match std::env::var("DEEPSEEK_API_KEY_SOURCE").ok().as_deref() {
290            Some("config") => return ApiKeySource::Config,
291            Some("keyring") => return ApiKeySource::Keyring,
292            _ => {}
293        }
294    }
295
296    if config
297        .api_key
298        .as_ref()
299        .is_some_and(|k| !k.trim().is_empty())
300        || config
301            .provider_config()
302            .and_then(|entry| entry.api_key.as_ref())
303            .is_some_and(|k| !k.trim().is_empty())
304    {
305        ApiKeySource::Config
306    } else if std::env::var("DEEPSEEK_API_KEY")
307        .ok()
308        .filter(|k| !k.trim().is_empty())
309        .is_some()
310    {
311        ApiKeySource::Env
312    } else {
313        ApiKeySource::Missing
314    }
315}
316
317pub(crate) fn skills_count_for(dir: &Path) -> usize {
318    if !dir.exists() {
319        return 0;
320    }
321    crate::skills::SkillRegistry::discover(dir).len()
322}
323
324pub(crate) fn merge_project_config(config: &mut crate::config::Config, workspace: &Path) {
325    let path = zagens_config::workspace_meta_file_read(workspace, "config.toml");
326    let raw = match std::fs::read_to_string(&path) {
327        Ok(r) => r,
328        Err(_) => return,
329    };
330    let project: toml::Value = match toml::from_str(&raw) {
331        Ok(v) => v,
332        Err(_) => return,
333    };
334    let table = match project.as_table() {
335        Some(t) => t,
336        None => return,
337    };
338
339    const DENY_AT_PROJECT_SCOPE: &[&str] = &["api_key", "base_url", "provider", "mcp_config_path"];
340    for key in DENY_AT_PROJECT_SCOPE {
341        if table.contains_key(*key) {
342            eprintln!(
343                "warning: project-scope config key `{key}` is ignored — \
344                 set it in `~/.zagens/config.toml` instead. \
345                 (See #417 for the deny-list rationale.)"
346            );
347        }
348    }
349
350    for (key, field) in [
351        ("model", &mut config.default_text_model),
352        ("reasoning_effort", &mut config.reasoning_effort),
353        ("approval_policy", &mut config.approval_policy),
354        ("sandbox_mode", &mut config.sandbox_mode),
355        ("notes_path", &mut config.notes_path),
356    ] {
357        if let Some(v) = table.get(key).and_then(toml::Value::as_str)
358            && !v.is_empty()
359        {
360            let is_escalation = matches!(
361                (key, v),
362                ("approval_policy", "auto") | ("sandbox_mode", "danger-full-access")
363            );
364            if is_escalation {
365                eprintln!(
366                    "warning: project-scope `{key} = \"{v}\"` is ignored — \
367                     project config cannot escalate to the loosest value. \
368                     (See #417.)"
369                );
370                continue;
371            }
372            *field = Some(v.to_string());
373        }
374    }
375
376    if let Some(v) = table.get("max_subagents").and_then(toml::Value::as_integer)
377        && v > 0
378    {
379        config.max_subagents = Some((v as usize).clamp(1, crate::config::MAX_SUBAGENTS));
380    }
381    if let Some(v) = table.get("allow_shell").and_then(toml::Value::as_bool) {
382        config.allow_shell = Some(v);
383    }
384
385    if let Some(arr) = table.get("instructions").and_then(toml::Value::as_array) {
386        let entries: Vec<String> = arr
387            .iter()
388            .filter_map(|v| v.as_str().map(str::to_string))
389            .filter(|s| !s.trim().is_empty())
390            .collect();
391        config.instructions = Some(entries);
392    }
393}
394
395pub(crate) fn default_tools_dir() -> PathBuf {
396    deepseek_home_dir().join("tools")
397}
398
399pub(crate) fn default_plugins_dir() -> PathBuf {
400    deepseek_home_dir().join("plugins")
401}
402
403pub(crate) fn count_dir_entries(dir: &Path) -> usize {
404    std::fs::read_dir(dir)
405        .map(|rd| rd.flatten().count())
406        .unwrap_or(0)
407}
408
409pub(crate) fn run_setup(
410    config: &crate::config::Config,
411    workspace: &Path,
412    args: crate::cli::args::SetupArgs,
413) -> Result<()> {
414    use colored::Colorize;
415
416    if args.status {
417        return run_setup_status(config, workspace);
418    }
419    if args.clean {
420        return run_setup_clean(&default_checkpoints_dir(), args.force);
421    }
422
423    let any_explicit = args.mcp || args.skills || args.tools || args.plugins;
424    let run_mcp = args.mcp || args.all || !any_explicit;
425    let run_skills = args.skills || args.all || !any_explicit;
426    let run_tools = args.tools || args.all;
427    let run_plugins = args.plugins || args.all;
428
429    println!("{}", "Zagens Setup".bold());
430    println!("Workspace: {}", crate::utils::display_path(workspace));
431
432    if run_mcp {
433        let mcp_path = config.mcp_config_path();
434        let status = init_mcp_config(&mcp_path, args.force)?;
435        report_write_status("MCP config", &mcp_path, status);
436        println!("    Next: edit the file, then run `zagens mcp list` or `zagens mcp tools`.");
437    }
438
439    if run_skills {
440        let skills_dir = if args.local {
441            workspace.join("skills")
442        } else {
443            config.skills_dir()
444        };
445        let (skill_path, status) = init_skills_dir(&skills_dir, args.force)?;
446        report_write_status("Example skill", &skill_path, status);
447        println!(
448            "    Skills dir: {}",
449            crate::utils::display_path(&skills_dir)
450        );
451    }
452
453    if run_tools {
454        let tools_dir = default_tools_dir();
455        let (_, readme_status, example_status) = init_tools_dir(&tools_dir, args.force)?;
456        report_write_status("Tools README", &tools_dir.join("README.md"), readme_status);
457        report_write_status(
458            "Tools example",
459            &tools_dir.join("example.sh"),
460            example_status,
461        );
462    }
463
464    if run_plugins {
465        let plugins_dir = default_plugins_dir();
466        let (_, example_path, readme_status, example_status) =
467            init_plugins_dir(&plugins_dir, args.force)?;
468        report_write_status(
469            "Plugins README",
470            &plugins_dir.join("README.md"),
471            readme_status,
472        );
473        report_write_status("Plugin example", &example_path, example_status);
474    }
475
476    Ok(())
477}
478
479fn report_write_status(label: &str, path: &Path, status: WriteStatus) {
480    match status {
481        WriteStatus::Created => println!("  ✓ Created {label} at {}", path.display()),
482        WriteStatus::Overwritten => println!("  ✓ Overwrote {label} at {}", path.display()),
483        WriteStatus::SkippedExists => println!("  · {label} already exists at {}", path.display()),
484    }
485}
486
487pub(crate) fn run_setup_status(config: &crate::config::Config, workspace: &Path) -> Result<()> {
488    use colored::Colorize;
489
490    println!("{}", "Zagens Status".bold());
491    println!("workspace: {}", workspace.display());
492
493    match resolve_api_key_source(config) {
494        ApiKeySource::Env => println!("  ✓ api_key: set via DEEPSEEK_API_KEY"),
495        ApiKeySource::Keyring => println!("  ✓ api_key: set via OS keyring"),
496        ApiKeySource::Config => println!("  ✓ api_key: set via config"),
497        ApiKeySource::Missing => {
498            println!("  ✗ api_key: missing (run `zagens login` or set DEEPSEEK_API_KEY)");
499        }
500    }
501    println!("  · base_url: {}", config.deepseek_base_url());
502    println!(
503        "  · default_model: {}",
504        config
505            .default_text_model
506            .clone()
507            .unwrap_or_else(|| config.default_model())
508    );
509    println!("  · {}", dotenv_status_line(workspace));
510
511    let mcp_path = config.mcp_config_path();
512    println!(
513        "  · mcp_config: {} ({})",
514        mcp_path.display(),
515        if mcp_path.exists() {
516            "present"
517        } else {
518            "missing"
519        }
520    );
521
522    let skills_dir = config.skills_dir();
523    println!(
524        "  · skills: {} ({} discovered)",
525        skills_dir.display(),
526        skills_count_for(&skills_dir)
527    );
528
529    let tools_dir = default_tools_dir();
530    println!(
531        "  · tools: {} ({} entries)",
532        tools_dir.display(),
533        if tools_dir.exists() {
534            count_dir_entries(&tools_dir)
535        } else {
536            0
537        }
538    );
539
540    Ok(())
541}