1use anyhow::Result;
2use colored::Colorize;
3use std::fs;
4use std::path::PathBuf;
5
6use crate::config::Config;
7use crate::storage::Storage;
8
9const EMBEDDED_SCUD_COMMANDS: &[(&str, &str)] = &[
13 ("stats", include_str!("../../assets/commands/scud/stats.md")),
14 ("next", include_str!("../../assets/commands/scud/next.md")),
15 ("show", include_str!("../../assets/commands/scud/show.md")),
16 ("list", include_str!("../../assets/commands/scud/list.md")),
17 ("waves", include_str!("../../assets/commands/scud/waves.md")),
18 (
19 "status",
20 include_str!("../../assets/commands/scud/status.md"),
21 ),
22];
23
24const EMBEDDED_SCUD_SKILLS: &[(&str, &str)] = &[
27 (
28 "scud-tasks",
29 include_str!("../../assets/skills/scud-tasks/SKILL.md"),
30 ),
31 ("scud", include_str!("../../assets/skills/scud/SKILL.md")),
32];
33
34const EMBEDDED_SPAWN_AGENTS: &[(&str, &str)] = &[
37 (
38 "builder",
39 include_str!("../assets/spawn-agents/builder.toml"),
40 ),
41 (
42 "reviewer",
43 include_str!("../assets/spawn-agents/reviewer.toml"),
44 ),
45 (
46 "planner",
47 include_str!("../assets/spawn-agents/planner.toml"),
48 ),
49 (
50 "researcher",
51 include_str!("../assets/spawn-agents/researcher.toml"),
52 ),
53 (
54 "analyzer",
55 include_str!("../assets/spawn-agents/analyzer.toml"),
56 ),
57 (
58 "fast-builder",
59 include_str!("../assets/spawn-agents/fast-builder.toml"),
60 ),
61 (
62 "outside-generalist",
63 include_str!("../assets/spawn-agents/outside-generalist.toml"),
64 ),
65 (
66 "repairer",
67 include_str!("../assets/spawn-agents/repairer.toml"),
68 ),
69];
70
71const SCUD_AGENTS: &[(&str, &[&str], &str)] = &[
75 (
76 "pm",
77 &["pm", "scud-pm"],
78 "Product Manager - PRD creation and requirements",
79 ),
80 (
81 "sm",
82 &["sm", "scud-sm"],
83 "Scrum Master - Task breakdown and planning",
84 ),
85 (
86 "architect",
87 &["architect", "scud-architect"],
88 "Architect - Technical design",
89 ),
90 (
91 "dev",
92 &["dev", "scud-dev"],
93 "Developer - Task implementation",
94 ),
95 (
96 "retrospective",
97 &["retrospective", "scud-retrospective"],
98 "Retrospective - Post-phase analysis",
99 ),
100 ("status", &["status"], "Status - Workflow status reporting"),
101];
102
103const SCUD_SKILLS: &[(&str, &[&str], &str)] = &[
107 (
108 "scud-tasks",
109 &["scud-tasks", "tasks"],
110 "Task management - view, update, claim, and track tasks",
111 ),
112 (
113 "scud",
114 &["scud", "guide"],
115 "SCUD CLI usage guide - list, waves, tags, next, log, etc",
116 ),
117];
118
119const OPENCODE_COMMANDS: &[&str] = &[
123 "task-list",
124 "task-next",
125 "task-show",
126 "task-status",
127 "task-claim",
128 "task-release",
129 "task-waves",
130 "task-stats",
131 "task-whois",
132 "task-tags",
133 "task-doctor",
134];
135
136const OPENCODE_HOOKS: &[&str] = &["session-start"];
138
139const OPENCODE_TOOLS: &[&str] = &["find_skills", "use_skill"];
141
142pub fn show(project_root: Option<PathBuf>) -> Result<()> {
143 let storage = Storage::new(project_root);
144
145 if !storage.is_initialized() {
146 println!("{}", "✗ SCUD is not initialized".red());
147 println!("Run: scud init");
148 return Ok(());
149 }
150
151 let config = storage.load_config()?;
152
153 println!("{}", "Current Configuration:".blue().bold());
154 println!();
155 println!(" {}: {}", "Provider".yellow(), config.llm.provider);
156 println!(" {}: {}", "Model".yellow(), config.llm.model);
157 println!(" {}: {}", "Max Tokens".yellow(), config.llm.max_tokens);
158 println!();
159 println!("{}", "Environment Variable:".blue().bold());
160 println!(" {}: {}", "Required".yellow(), config.api_key_env_var());
161
162 match std::env::var(config.api_key_env_var()) {
164 Ok(key) => {
165 let masked = format!(
166 "{}...{}",
167 &key[..10.min(key.len())],
168 &key[key.len().saturating_sub(4)..]
169 );
170 println!(
171 " {}: {} {}",
172 "Status".yellow(),
173 "Set".green(),
174 masked.dimmed()
175 );
176 }
177 Err(_) => {
178 println!(
179 " {}: {} (run: export {}=your-key)",
180 "Status".yellow(),
181 "Not Set".red(),
182 config.api_key_env_var()
183 );
184 }
185 }
186
187 println!();
188 println!("{}", "Config File:".blue().bold());
189 println!(" {}", storage.config_file().display().to_string().dimmed());
190
191 Ok(())
192}
193
194pub fn set_provider(
195 project_root: Option<PathBuf>,
196 provider: &str,
197 model: Option<String>,
198) -> Result<()> {
199 let storage = Storage::new(project_root);
200
201 if !storage.is_initialized() {
202 anyhow::bail!("SCUD is not initialized. Run: scud init");
203 }
204
205 let provider = provider.to_lowercase();
207 if !matches!(
208 provider.as_str(),
209 "xai" | "anthropic" | "openai" | "openrouter" | "claude-cli"
210 ) {
211 anyhow::bail!(
212 "Invalid provider: {}. Valid options: xai, anthropic, openai, openrouter, claude-cli",
213 provider
214 );
215 }
216
217 let mut config = storage.load_config()?;
218 config.llm.provider = provider.clone();
219
220 config.llm.model =
222 model.unwrap_or_else(|| Config::default_model_for_provider(&provider).to_string());
223
224 config.save(&storage.config_file())?;
226
227 println!("{}", "✅ Configuration updated!".green().bold());
228 println!();
229 println!(" {}: {}", "Provider".yellow(), config.llm.provider);
230 println!(" {}: {}", "Model".yellow(), config.llm.model);
231 println!();
232
233 if config.requires_api_key() {
234 println!("{}", "Remember to set your API key:".blue());
235 println!(
236 " export {}=your-api-key",
237 config.api_key_env_var().yellow()
238 );
239 } else {
240 println!("{}", "Using Claude CLI (no API key required)".green());
241 println!(
242 "{}",
243 "Make sure 'claude' command is available in your PATH".blue()
244 );
245 }
246
247 Ok(())
248}
249
250fn normalize_agent_name(name: &str) -> Option<&'static str> {
252 let name_lower = name.to_lowercase();
253 for (filename, aliases, _) in SCUD_AGENTS {
254 for alias in *aliases {
255 if name_lower == *alias {
256 return Some(filename);
257 }
258 }
259 }
260 None
261}
262
263fn normalize_skill_name(name: &str) -> Option<&'static str> {
265 let name_lower = name.to_lowercase();
266 for (dirname, aliases, _) in SCUD_SKILLS {
267 for alias in *aliases {
268 if name_lower == *alias {
269 return Some(dirname);
270 }
271 }
272 }
273 None
274}
275
276fn get_scud_commands_dir(project_root: Option<PathBuf>) -> PathBuf {
278 let base = project_root.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
279 base.join(".claude").join("commands").join("scud")
280}
281
282fn get_skills_dir(project_root: Option<PathBuf>) -> PathBuf {
284 let base = project_root.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
285 base.join(".claude").join("skills")
286}
287
288fn get_opencode_command_dir(project_root: Option<PathBuf>) -> PathBuf {
290 let base = project_root.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
291 base.join(".opencode").join("command")
292}
293
294fn get_opencode_hook_dir(project_root: Option<PathBuf>) -> PathBuf {
296 let base = project_root.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
297 base.join(".opencode").join("hook")
298}
299
300fn get_opencode_tool_dir(project_root: Option<PathBuf>) -> PathBuf {
302 let base = project_root.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
303 base.join(".opencode").join("tool")
304}
305
306fn get_opencode_skills_dir(project_root: Option<PathBuf>) -> PathBuf {
308 let base = project_root.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
309 base.join(".opencode").join("skills")
310}
311
312pub fn agents_list(project_root: Option<PathBuf>) -> Result<()> {
316 let scud_dir = get_scud_commands_dir(project_root.clone());
317 let skills_dir = get_skills_dir(project_root.clone());
318
319 println!("{}", "SCUD Workflow Agents".blue().bold());
321 println!("{}", "Location: .claude/commands/scud/".dimmed());
322 println!();
323
324 let mut agents_installed = 0;
325 let mut agents_not_installed = 0;
326
327 for (filename, aliases, description) in SCUD_AGENTS {
328 let agent_file = scud_dir.join(format!("{}.md", filename));
329 let installed = agent_file.exists();
330 let alias_str = aliases.join(", ");
331
332 if installed {
333 agents_installed += 1;
334 println!(
335 " {} {} ({}) - {}",
336 "✓".green(),
337 filename.green(),
338 alias_str.dimmed(),
339 description
340 );
341 } else {
342 agents_not_installed += 1;
343 println!(
344 " {} {} ({}) - {}",
345 "✗".red(),
346 filename.dimmed(),
347 alias_str.dimmed(),
348 description
349 );
350 }
351 }
352
353 println!();
354 println!(
355 "{} installed, {} not installed",
356 agents_installed.to_string().green(),
357 agents_not_installed.to_string().yellow()
358 );
359
360 println!();
362 println!("{}", "SCUD Skills".blue().bold());
363 println!("{}", "Location: .claude/skills/".dimmed());
364 println!();
365
366 let mut skills_installed = 0;
367 let mut skills_not_installed = 0;
368
369 for (dirname, aliases, description) in SCUD_SKILLS {
370 let skill_dir = skills_dir.join(dirname);
371 let skill_file = skill_dir.join("SKILL.md");
372 let installed = skill_file.exists();
373 let alias_str = aliases.join(", ");
374
375 if installed {
376 skills_installed += 1;
377 println!(
378 " {} {} ({}) - {}",
379 "✓".green(),
380 dirname.green(),
381 alias_str.dimmed(),
382 description
383 );
384 } else {
385 skills_not_installed += 1;
386 println!(
387 " {} {} ({}) - {}",
388 "✗".red(),
389 dirname.dimmed(),
390 alias_str.dimmed(),
391 description
392 );
393 }
394 }
395
396 println!();
397 println!(
398 "{} installed, {} not installed",
399 skills_installed.to_string().green(),
400 skills_not_installed.to_string().yellow()
401 );
402
403 println!();
405 println!("{}", "OpenCode Integration".blue().bold());
406 println!("{}", "Location: .opencode/".dimmed());
407 println!();
408
409 let opencode_cmd_dir = get_opencode_command_dir(project_root.clone());
410 let opencode_hook_dir = get_opencode_hook_dir(project_root.clone());
411 let opencode_tool_dir = get_opencode_tool_dir(project_root);
412
413 let mut opencode_installed = 0;
414
415 for cmd in OPENCODE_COMMANDS {
417 let cmd_file = opencode_cmd_dir.join(format!("{}.md", cmd));
418 if cmd_file.exists() {
419 opencode_installed += 1;
420 }
421 }
422
423 for hook in OPENCODE_HOOKS {
425 let hook_file = opencode_hook_dir.join(format!("{}.md", hook));
426 if hook_file.exists() {
427 opencode_installed += 1;
428 }
429 }
430
431 for tool in OPENCODE_TOOLS {
433 let tool_file = opencode_tool_dir.join(format!("{}.json", tool));
434 if tool_file.exists() {
435 opencode_installed += 1;
436 }
437 }
438
439 if opencode_installed > 0 {
440 println!(
441 " {} {} commands, {} hooks, {} tools installed",
442 "✓".green(),
443 OPENCODE_COMMANDS
444 .iter()
445 .filter(|c| opencode_cmd_dir.join(format!("{}.md", c)).exists())
446 .count(),
447 OPENCODE_HOOKS
448 .iter()
449 .filter(|h| opencode_hook_dir.join(format!("{}.md", h)).exists())
450 .count(),
451 OPENCODE_TOOLS
452 .iter()
453 .filter(|t| opencode_tool_dir.join(format!("{}.json", t)).exists())
454 .count(),
455 );
456 } else {
457 println!(" {} Not installed", "✗".red());
458 }
459
460 println!();
461 println!("{}", "Usage:".blue().bold());
462 println!(" scud config agents add <name> Add an agent or skill");
463 println!(" scud config agents add --all Add all agents, skills, and OpenCode support");
464 println!(" scud config agents remove <name> Remove an agent or skill");
465 println!(" scud config agents remove --all Remove all agents, skills, and OpenCode support");
466
467 Ok(())
468}
469
470pub fn agents_add(project_root: Option<PathBuf>, name: Option<String>, all: bool) -> Result<()> {
472 if !all && name.is_none() {
473 anyhow::bail!("Please specify an agent/skill name or use --all to add all");
474 }
475
476 let scud_dir = get_scud_commands_dir(project_root.clone());
478 let skills_dir = get_skills_dir(project_root.clone());
479 let opencode_cmd_dir = get_opencode_command_dir(project_root.clone());
480 let opencode_hook_dir = get_opencode_hook_dir(project_root.clone());
481 let opencode_tool_dir = get_opencode_tool_dir(project_root.clone());
482 let opencode_skills_dir = get_opencode_skills_dir(project_root);
483
484 fs::create_dir_all(&scud_dir)?;
486 fs::create_dir_all(&skills_dir)?;
487
488 let mut agents_added = 0;
489 let mut agents_already_exist = 0;
490 let mut skills_added = 0;
491 let mut skills_already_exist = 0;
492 let mut opencode_added = 0;
493 let mut opencode_already_exist = 0;
494
495 let (agents_to_add, skills_to_add): (Vec<&str>, Vec<&str>) = if all {
497 (
498 EMBEDDED_SCUD_COMMANDS
499 .iter()
500 .map(|(name, _)| *name)
501 .collect(),
502 EMBEDDED_SCUD_SKILLS.iter().map(|(name, _)| *name).collect(),
503 )
504 } else {
505 let name_ref = name.as_ref().unwrap();
506 if EMBEDDED_SCUD_COMMANDS.iter().any(|(n, _)| *n == name_ref) {
508 (vec![name_ref], vec![])
509 } else if EMBEDDED_SCUD_SKILLS.iter().any(|(n, _)| *n == name_ref) {
510 (vec![], vec![name_ref])
511 } else {
512 anyhow::bail!(
513 "Unknown agent/skill: '{}'. Valid agents: {}. Valid skills: {}",
514 name_ref,
515 EMBEDDED_SCUD_COMMANDS
516 .iter()
517 .map(|(n, _)| *n)
518 .collect::<Vec<_>>()
519 .join(", "),
520 EMBEDDED_SCUD_SKILLS
521 .iter()
522 .map(|(n, _)| *n)
523 .collect::<Vec<_>>()
524 .join(", ")
525 );
526 }
527 };
528
529 if !agents_to_add.is_empty() {
531 println!("{}", "Agents:".blue().bold());
532 for agent_name in &agents_to_add {
533 let dest = scud_dir.join(format!("{}.md", agent_name));
534
535 if dest.exists() {
536 agents_already_exist += 1;
537 println!(" {} {} (already installed)", "·".yellow(), agent_name);
538 continue;
539 }
540
541 if let Some((_, content)) = EMBEDDED_SCUD_COMMANDS
543 .iter()
544 .find(|(n, _)| *n == *agent_name)
545 {
546 fs::write(&dest, content)?;
547 agents_added += 1;
548 println!(" {} {}", "✓".green(), agent_name.green());
549 } else {
550 println!(
551 " {} {} (embedded content not found)",
552 "✗".red(),
553 agent_name
554 );
555 }
556 }
557 }
558
559 if !skills_to_add.is_empty() {
561 println!("{}", "Skills:".blue().bold());
562 for skill_name in &skills_to_add {
563 let dest = skills_dir.join(skill_name);
564 let skill_file = dest.join("SKILL.md");
565
566 if skill_file.exists() {
567 skills_already_exist += 1;
568 println!(" {} {} (already installed)", "·".yellow(), skill_name);
569 continue;
570 }
571
572 if let Some((_, content)) = EMBEDDED_SCUD_SKILLS.iter().find(|(n, _)| *n == *skill_name)
574 {
575 fs::create_dir_all(&dest)?;
576 fs::write(&skill_file, content)?;
577 skills_added += 1;
578 println!(" {} {}", "✓".green(), skill_name.green());
579
580 let opencode_dest = opencode_skills_dir.join(skill_name);
582 let opencode_skill_file = opencode_dest.join("SKILL.md");
583 if !opencode_skill_file.exists() {
584 fs::create_dir_all(&opencode_dest)?;
585 fs::write(&opencode_skill_file, content)?;
586 }
587 } else {
588 println!(
589 " {} {} (embedded content not found)",
590 "✗".red(),
591 skill_name
592 );
593 }
594 }
595 }
596
597 if all {
599 println!("{}", "OpenCode:".blue().bold());
600
601 fs::create_dir_all(&opencode_cmd_dir)?;
603 fs::create_dir_all(&opencode_hook_dir)?;
604 fs::create_dir_all(&opencode_tool_dir)?;
605
606 for cmd in OPENCODE_COMMANDS {
608 let dest = opencode_cmd_dir.join(format!("{}.md", cmd));
609
610 if dest.exists() {
611 opencode_already_exist += 1;
612 continue;
613 }
614
615 let embedded_name = match *cmd {
617 "task-list" => "list",
618 "task-next" => "next",
619 "task-show" => "show",
620 "task-status" => "status",
621 "task-claim" => "status", "task-release" => "status", "task-waves" => "waves",
624 "task-stats" => "stats",
625 "task-whois" => "status", "task-tags" => "status", "task-doctor" => "status", _ => continue,
629 };
630
631 if let Some((_, content)) = EMBEDDED_SCUD_COMMANDS
632 .iter()
633 .find(|(n, _)| *n == embedded_name)
634 {
635 fs::write(&dest, content)?;
636 opencode_added += 1;
637 }
638 }
639
640 for hook in OPENCODE_HOOKS {
642 let dest = opencode_hook_dir.join(format!("{}.md", hook));
643
644 if dest.exists() {
645 opencode_already_exist += 1;
646 continue;
647 }
648
649 let hook_content = "# Session Start Hook\n\nThis hook runs when an OpenCode session starts.\n\n```bash\nscud warmup\n```".to_string();
651 fs::write(&dest, hook_content)?;
652 opencode_added += 1;
653 }
654
655 for tool in OPENCODE_TOOLS {
657 let dest = opencode_tool_dir.join(format!("{}.json", tool));
658
659 if dest.exists() {
660 opencode_already_exist += 1;
661 continue;
662 }
663
664 let tool_content = match *tool {
666 "find_skills" => {
667 r#"{
668 "name": "find_skills",
669 "description": "Find available skills in the codebase",
670 "inputSchema": {
671 "type": "object",
672 "properties": {
673 "query": {
674 "type": "string",
675 "description": "Search query for skills"
676 }
677 }
678 }
679}"#
680 }
681 "use_skill" => {
682 r#"{
683 "name": "use_skill",
684 "description": "Use a specific skill",
685 "inputSchema": {
686 "type": "object",
687 "properties": {
688 "skill_name": {
689 "type": "string",
690 "description": "Name of the skill to use"
691 },
692 "parameters": {
693 "type": "object",
694 "description": "Parameters for the skill"
695 }
696 }
697 }
698}"#
699 }
700 _ => continue,
701 };
702
703 fs::write(&dest, tool_content)?;
704 opencode_added += 1;
705 }
706
707 if opencode_added > 0 {
708 println!(" {} {} files installed", "✓".green(), opencode_added);
709 }
710 if opencode_already_exist > 0 {
711 println!(
712 " {} {} files already installed",
713 "·".yellow(),
714 opencode_already_exist
715 );
716 }
717 }
718
719 println!();
720 let total_added = agents_added + skills_added + opencode_added;
721 let total_existing = agents_already_exist + skills_already_exist + opencode_already_exist;
722
723 if total_added > 0 {
724 println!(
725 "{}",
726 format!("✅ Added {} item(s)", total_added).green().bold()
727 );
728 }
729 if total_existing > 0 {
730 println!(
731 "{}",
732 format!("{} item(s) already installed", total_existing).yellow()
733 );
734 }
735
736 Ok(())
737}
738
739fn remove_dir_recursive(path: &PathBuf) -> Result<()> {
741 if path.exists() {
742 fs::remove_dir_all(path)?;
743 }
744 Ok(())
745}
746
747pub fn agents_remove(project_root: Option<PathBuf>, name: Option<String>, all: bool) -> Result<()> {
749 if !all && name.is_none() {
750 anyhow::bail!("Please specify an agent/skill name or use --all to remove all");
751 }
752
753 let scud_dir = get_scud_commands_dir(project_root.clone());
754 let skills_dir = get_skills_dir(project_root.clone());
755 let opencode_cmd_dir = get_opencode_command_dir(project_root.clone());
756 let opencode_hook_dir = get_opencode_hook_dir(project_root.clone());
757 let opencode_tool_dir = get_opencode_tool_dir(project_root.clone());
758 let opencode_skills_dir = get_opencode_skills_dir(project_root);
759
760 let mut agents_removed = 0;
761 let mut agents_not_found = 0;
762 let mut skills_removed = 0;
763 let mut skills_not_found = 0;
764 let mut opencode_removed = 0;
765
766 let (agents_to_remove, skills_to_remove): (Vec<&str>, Vec<&str>) = if all {
768 (
769 SCUD_AGENTS
770 .iter()
771 .map(|(filename, _, _)| *filename)
772 .collect(),
773 SCUD_SKILLS.iter().map(|(dirname, _, _)| *dirname).collect(),
774 )
775 } else {
776 let name_ref = name.as_ref().unwrap();
777 if let Some(agent) = normalize_agent_name(name_ref) {
779 (vec![agent], vec![])
780 } else if let Some(skill) = normalize_skill_name(name_ref) {
781 (vec![], vec![skill])
782 } else {
783 anyhow::bail!(
784 "Unknown agent/skill: '{}'. Valid agents: pm, sm, architect, dev, retrospective, status. Valid skills: scud-tasks",
785 name_ref
786 );
787 }
788 };
789
790 if !agents_to_remove.is_empty() {
792 println!("{}", "Agents:".blue().bold());
793 for agent_name in &agents_to_remove {
794 let agent_file = scud_dir.join(format!("{}.md", agent_name));
795
796 if !agent_file.exists() {
797 agents_not_found += 1;
798 println!(" {} {} (not installed)", "·".yellow(), agent_name);
799 continue;
800 }
801
802 fs::remove_file(&agent_file)?;
803 agents_removed += 1;
804 println!(" {} {}", "✓".green(), agent_name);
805 }
806 }
807
808 if !skills_to_remove.is_empty() {
810 println!("{}", "Skills:".blue().bold());
811 for skill_name in &skills_to_remove {
812 let skill_dir = skills_dir.join(skill_name);
813
814 if !skill_dir.exists() {
815 skills_not_found += 1;
816 println!(" {} {} (not installed)", "·".yellow(), skill_name);
817 continue;
818 }
819
820 remove_dir_recursive(&skill_dir)?;
821 skills_removed += 1;
822 println!(" {} {}", "✓".green(), skill_name);
823
824 let opencode_skill = opencode_skills_dir.join(skill_name);
826 if opencode_skill.exists() {
827 remove_dir_recursive(&opencode_skill)?;
828 }
829 }
830 }
831
832 if all {
834 println!("{}", "OpenCode:".blue().bold());
835
836 for cmd in OPENCODE_COMMANDS {
838 let cmd_file = opencode_cmd_dir.join(format!("{}.md", cmd));
839 if cmd_file.exists() {
840 fs::remove_file(&cmd_file)?;
841 opencode_removed += 1;
842 }
843 }
844
845 for hook in OPENCODE_HOOKS {
847 let hook_file = opencode_hook_dir.join(format!("{}.md", hook));
848 if hook_file.exists() {
849 fs::remove_file(&hook_file)?;
850 opencode_removed += 1;
851 }
852 }
853
854 for tool in OPENCODE_TOOLS {
856 let tool_file = opencode_tool_dir.join(format!("{}.json", tool));
857 if tool_file.exists() {
858 fs::remove_file(&tool_file)?;
859 opencode_removed += 1;
860 }
861 }
862
863 if opencode_removed > 0 {
864 println!(" {} {} files removed", "✓".green(), opencode_removed);
865 } else {
866 println!(" {} Not installed", "·".yellow());
867 }
868 }
869
870 println!();
871 let total_removed = agents_removed + skills_removed + opencode_removed;
872 let total_not_found = agents_not_found + skills_not_found;
873
874 if total_removed > 0 {
875 println!(
876 "{}",
877 format!("✅ Removed {} item(s)", total_removed)
878 .green()
879 .bold()
880 );
881 }
882 if total_not_found > 0 {
883 println!(
884 "{}",
885 format!("{} item(s) were not installed", total_not_found).yellow()
886 );
887 }
888
889 Ok(())
890}
891
892pub fn backpressure(
894 project_root: Option<PathBuf>,
895 commands: Vec<String>,
896 add: Option<String>,
897 remove: Option<String>,
898 list: bool,
899 clear: bool,
900) -> Result<()> {
901 let storage = Storage::new(project_root);
902
903 if !storage.is_initialized() {
904 anyhow::bail!("SCUD is not initialized. Run: scud init");
905 }
906
907 let config_path = storage.config_file();
908
909 let content = fs::read_to_string(&config_path).unwrap_or_default();
911 let mut config: toml::Value =
912 toml::from_str(&content).unwrap_or(toml::Value::Table(toml::map::Map::new()));
913
914 let bp_commands = get_backpressure_commands(&config);
916
917 if list {
918 println!("{}", "Backpressure Configuration".blue().bold());
920 println!();
921
922 if bp_commands.is_empty() {
923 println!(
924 " {} No commands configured (using auto-detect)",
925 "·".yellow()
926 );
927 println!();
928
929 let auto = crate::backpressure::BackpressureConfig::load(Some(
931 &storage.project_root().to_path_buf(),
932 ))?;
933 if !auto.commands.is_empty() {
934 println!("{}", "Auto-detected commands:".dimmed());
935 for cmd in &auto.commands {
936 println!(" {} {}", "·".dimmed(), cmd.dimmed());
937 }
938 }
939 } else {
940 println!("{}", "Commands (in order):".blue());
941 for (i, cmd) in bp_commands.iter().enumerate() {
942 println!(" {}. {}", i + 1, cmd.green());
943 }
944 }
945
946 println!();
947 println!("{}", "Usage:".blue().bold());
948 println!(" scud config backpressure \"cmd1\" \"cmd2\" Set commands");
949 println!(" scud config backpressure --add \"cmd\" Add a command");
950 println!(" scud config backpressure --remove \"cmd\" Remove a command");
951 println!(" scud config backpressure --clear Clear (use auto-detect)");
952
953 return Ok(());
954 }
955
956 if clear {
957 if let Some(swarm) = config.get_mut("swarm") {
959 if let Some(table) = swarm.as_table_mut() {
960 table.remove("backpressure");
961 }
962 }
963 save_config(&config_path, &config)?;
964 println!(
965 "{}",
966 "✓ Backpressure config cleared (will use auto-detect)".green()
967 );
968 return Ok(());
969 }
970
971 let mut new_commands = bp_commands.clone();
972
973 if let Some(cmd) = add {
974 if !new_commands.contains(&cmd) {
975 new_commands.push(cmd.clone());
976 println!("{}", format!("✓ Added: {}", cmd).green());
977 } else {
978 println!("{}", format!("· Already exists: {}", cmd).yellow());
979 }
980 } else if let Some(cmd) = remove {
981 if let Some(pos) = new_commands.iter().position(|c| c == &cmd) {
982 new_commands.remove(pos);
983 println!("{}", format!("✓ Removed: {}", cmd).green());
984 } else {
985 println!("{}", format!("· Not found: {}", cmd).yellow());
986 }
987 } else if !commands.is_empty() {
988 new_commands = commands;
990 println!("{}", "✓ Backpressure commands set:".green());
991 for cmd in &new_commands {
992 println!(" · {}", cmd);
993 }
994 } else {
995 return backpressure(
997 Some(storage.project_root().to_path_buf()),
998 vec![],
999 None,
1000 None,
1001 true,
1002 false,
1003 );
1004 }
1005
1006 set_backpressure_commands(&mut config, &new_commands);
1008 save_config(&config_path, &config)?;
1009
1010 Ok(())
1011}
1012
1013fn get_backpressure_commands(config: &toml::Value) -> Vec<String> {
1015 config
1016 .get("swarm")
1017 .and_then(|s| s.get("backpressure"))
1018 .and_then(|b| b.get("commands"))
1019 .and_then(|c| c.as_array())
1020 .map(|arr| {
1021 arr.iter()
1022 .filter_map(|v| v.as_str().map(String::from))
1023 .collect()
1024 })
1025 .unwrap_or_default()
1026}
1027
1028fn set_backpressure_commands(config: &mut toml::Value, commands: &[String]) {
1030 let table = config.as_table_mut().expect("Config must be a table");
1031
1032 if !table.contains_key("swarm") {
1034 table.insert(
1035 "swarm".to_string(),
1036 toml::Value::Table(toml::map::Map::new()),
1037 );
1038 }
1039
1040 let swarm = table.get_mut("swarm").unwrap().as_table_mut().unwrap();
1041
1042 if !swarm.contains_key("backpressure") {
1044 swarm.insert(
1045 "backpressure".to_string(),
1046 toml::Value::Table(toml::map::Map::new()),
1047 );
1048 }
1049
1050 let bp = swarm
1051 .get_mut("backpressure")
1052 .unwrap()
1053 .as_table_mut()
1054 .unwrap();
1055
1056 let cmd_array: Vec<toml::Value> = commands
1058 .iter()
1059 .map(|s| toml::Value::String(s.clone()))
1060 .collect();
1061 bp.insert("commands".to_string(), toml::Value::Array(cmd_array));
1062
1063 if !bp.contains_key("stop_on_failure") {
1065 bp.insert("stop_on_failure".to_string(), toml::Value::Boolean(true));
1066 }
1067 if !bp.contains_key("timeout_secs") {
1068 bp.insert("timeout_secs".to_string(), toml::Value::Integer(300));
1069 }
1070}
1071
1072fn save_config(path: &PathBuf, config: &toml::Value) -> Result<()> {
1074 let content = toml::to_string_pretty(config)?;
1075 fs::write(path, content)?;
1076 Ok(())
1077}
1078
1079fn get_spawn_agents_dir(project_root: Option<PathBuf>) -> PathBuf {
1081 let base = project_root.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
1082 base.join(".scud").join("agents")
1083}
1084
1085pub fn spawn_agents_add(
1088 project_root: Option<PathBuf>,
1089 name: Option<String>,
1090 all: bool,
1091 interactive: bool,
1092) -> Result<()> {
1093 let agents_dir = get_spawn_agents_dir(project_root);
1094 fs::create_dir_all(&agents_dir)?;
1095
1096 let agents_to_add: Vec<&str> = if all {
1097 EMBEDDED_SPAWN_AGENTS.iter().map(|(n, _)| *n).collect()
1098 } else if let Some(ref name) = name {
1099 if EMBEDDED_SPAWN_AGENTS
1100 .iter()
1101 .any(|(n, _)| *n == name.as_str())
1102 {
1103 vec![name.as_str()]
1104 } else {
1105 anyhow::bail!(
1106 "Unknown spawn agent: '{}'. Available: {}",
1107 name,
1108 EMBEDDED_SPAWN_AGENTS
1109 .iter()
1110 .map(|(n, _)| *n)
1111 .collect::<Vec<_>>()
1112 .join(", ")
1113 );
1114 }
1115 } else if interactive {
1116 use dialoguer::MultiSelect;
1118
1119 let items: Vec<String> = EMBEDDED_SPAWN_AGENTS
1120 .iter()
1121 .map(|(name, content)| {
1122 let desc = content
1124 .lines()
1125 .find(|l| l.starts_with("description"))
1126 .and_then(|l| l.split('=').nth(1))
1127 .map(|s| s.trim().trim_matches('"'))
1128 .unwrap_or("");
1129 format!("{} - {}", name, desc)
1130 })
1131 .collect();
1132
1133 let selections = MultiSelect::new()
1134 .with_prompt("Select spawn agents to install (space to toggle)")
1135 .items(&items)
1136 .defaults(&vec![true; items.len()])
1137 .interact()?;
1138
1139 selections
1140 .iter()
1141 .map(|&i| EMBEDDED_SPAWN_AGENTS[i].0)
1142 .collect()
1143 } else {
1144 anyhow::bail!("Please specify an agent name, use --all, or run interactively");
1145 };
1146
1147 if agents_to_add.is_empty() {
1148 println!("{}", "No agents selected.".yellow());
1149 return Ok(());
1150 }
1151
1152 println!("{}", "Spawn Agents:".blue().bold());
1153 let mut added = 0;
1154 let mut existing = 0;
1155
1156 for agent_name in agents_to_add {
1157 let dest = agents_dir.join(format!("{}.toml", agent_name));
1158
1159 if dest.exists() {
1160 existing += 1;
1161 println!(" {} {} (already installed)", "·".yellow(), agent_name);
1162 continue;
1163 }
1164
1165 if let Some((_, content)) = EMBEDDED_SPAWN_AGENTS.iter().find(|(n, _)| *n == agent_name) {
1166 fs::write(&dest, content)?;
1167 added += 1;
1168 println!(" {} {}", "✓".green(), agent_name.green());
1169 }
1170 }
1171
1172 println!();
1173 if added > 0 {
1174 println!(
1175 "{}",
1176 format!("✅ Installed {} spawn agent(s)", added)
1177 .green()
1178 .bold()
1179 );
1180 println!(
1181 "{}",
1182 "Agents are used via @agents section in .scg files".dimmed()
1183 );
1184 }
1185 if existing > 0 {
1186 println!(
1187 "{}",
1188 format!("{} agent(s) already installed", existing).yellow()
1189 );
1190 }
1191
1192 Ok(())
1193}
1194
1195pub fn spawn_agents_list(project_root: Option<PathBuf>) -> Result<()> {
1197 let agents_dir = get_spawn_agents_dir(project_root);
1198
1199 println!("{}", "Available Spawn Agents:".blue().bold());
1200 println!();
1201
1202 for (name, content) in EMBEDDED_SPAWN_AGENTS {
1203 let installed = agents_dir.join(format!("{}.toml", name)).exists();
1204 let status = if installed {
1205 "✓".green()
1206 } else {
1207 "·".dimmed()
1208 };
1209
1210 let desc = content
1212 .lines()
1213 .find(|l| l.starts_with("description"))
1214 .and_then(|l| l.split('=').nth(1))
1215 .map(|s| s.trim().trim_matches('"'))
1216 .unwrap_or("");
1217
1218 let harness = content
1219 .lines()
1220 .find(|l| l.starts_with("harness"))
1221 .and_then(|l| l.split('=').nth(1))
1222 .map(|s| s.trim().trim_matches('"'))
1223 .unwrap_or("?");
1224
1225 let model = content
1226 .lines()
1227 .find(|l| l.trim().starts_with("model") && !l.contains('['))
1228 .and_then(|l| l.split('=').nth(1))
1229 .map(|s| s.trim().trim_matches('"'))
1230 .unwrap_or("default");
1231
1232 println!(
1233 " {} {:<14} [{}:{}] {}",
1234 status,
1235 name.cyan(),
1236 harness,
1237 model,
1238 desc.dimmed()
1239 );
1240 }
1241
1242 println!();
1243 println!("Install: {}", "scud config spawn-agents add --all".cyan());
1244
1245 Ok(())
1246}
1247
1248pub fn spawn_agents_remove(
1250 project_root: Option<PathBuf>,
1251 name: Option<String>,
1252 all: bool,
1253) -> Result<()> {
1254 let agents_dir = get_spawn_agents_dir(project_root);
1255
1256 let agents_to_remove: Vec<&str> = if all {
1257 EMBEDDED_SPAWN_AGENTS.iter().map(|(n, _)| *n).collect()
1258 } else if let Some(ref name) = name {
1259 vec![name.as_str()]
1260 } else {
1261 anyhow::bail!("Please specify an agent name or use --all");
1262 };
1263
1264 println!("{}", "Removing Spawn Agents:".blue().bold());
1265 let mut removed = 0;
1266 let mut not_found = 0;
1267
1268 for agent_name in agents_to_remove {
1269 let path = agents_dir.join(format!("{}.toml", agent_name));
1270
1271 if !path.exists() {
1272 not_found += 1;
1273 println!(" {} {} (not installed)", "·".yellow(), agent_name);
1274 continue;
1275 }
1276
1277 fs::remove_file(&path)?;
1278 removed += 1;
1279 println!(" {} {}", "✓".green(), agent_name);
1280 }
1281
1282 println!();
1283 if removed > 0 {
1284 println!(
1285 "{}",
1286 format!("✅ Removed {} spawn agent(s)", removed)
1287 .green()
1288 .bold()
1289 );
1290 }
1291 if not_found > 0 {
1292 println!(
1293 "{}",
1294 format!("{} agent(s) were not installed", not_found).yellow()
1295 );
1296 }
1297
1298 Ok(())
1299}