1use 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 `~/.deepseek/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}