1use anyhow::{Context, Result, bail};
11use mcp_execution_core::cli::{ExitCode, OutputFormat};
12use mcp_execution_skill::{
13 build_skill_context, render_skill_md, scan_tools_directory, validate_server_id,
14};
15use serde::Serialize;
16use std::path::{Path, PathBuf};
17use tracing::{debug, info};
18
19use crate::formatters::format_output;
20
21#[derive(Debug, Serialize)]
23struct SkillWriteResult {
24 success: bool,
25 output_path: String,
26 bytes_written: usize,
27 tool_count: usize,
28}
29
30const DEFAULT_SERVERS_DIR: &str = ".claude/servers";
32
33const DEFAULT_SKILLS_DIR: &str = ".claude/skills";
35
36#[allow(clippy::too_many_arguments)]
90pub async fn run(
91 server: String,
92 servers_dir: Option<PathBuf>,
93 output_path: Option<PathBuf>,
94 skill_name: Option<String>,
95 hints: Vec<String>,
96 overwrite: bool,
97 output_format: OutputFormat,
98) -> Result<ExitCode> {
99 debug!("Generating skill for server: {}", server);
100 debug!("Servers directory: {:?}", servers_dir);
101 debug!("Output path: {:?}", output_path);
102 debug!("Skill name: {:?}", skill_name);
103 debug!("Hints: {:?}", hints);
104 debug!("Overwrite: {}", overwrite);
105 debug!("Output format: {}", output_format);
106
107 validate_server_id(&server).map_err(|e| anyhow::anyhow!("Invalid server ID: {e}"))?;
109 info!("Server ID validated: {}", server);
110
111 let servers_base = resolve_servers_dir(servers_dir.as_deref())?;
113 debug!("Servers base directory: {}", servers_base.display());
114
115 let tool_dir = servers_base.join(&server);
117 let tool_dir = validate_path_security(&tool_dir, &servers_base)?;
118 debug!("Server directory: {}", tool_dir.display());
119
120 if !tool_dir.exists() {
122 bail!(
123 "Server directory not found: {}\n\
124 Run 'mcp-execution-cli generate --from-config {}' first to generate TypeScript files.",
125 tool_dir.display(),
126 server
127 );
128 }
129
130 info!("Scanning TypeScript files in {}", tool_dir.display());
132 let tools = scan_tools_directory(&tool_dir)
133 .await
134 .context("Failed to scan tools directory")?;
135
136 if tools.is_empty() {
137 bail!(
138 "No TypeScript tool files found in {}\n\
139 Run 'mcp-execution-cli generate --from-config {}' first.",
140 tool_dir.display(),
141 server
142 );
143 }
144
145 info!("Found {} tool files", tools.len());
146
147 let hints_ref: Option<Vec<String>> = if hints.is_empty() { None } else { Some(hints) };
149
150 let mut context = build_skill_context(&server, &tools, hints_ref.as_deref());
151
152 if let Some(name) = skill_name {
154 context.skill_name = name;
155 }
156
157 if let Some(path) = output_path {
159 validate_output_path(&path)?;
161 context.output_path = path.display().to_string();
162 } else {
163 let skills_dir = resolve_skills_dir()?;
165 let default_output = skills_dir.join(&server).join("SKILL.md");
166 context.output_path = default_output.display().to_string();
167 }
168
169 let output_path = PathBuf::from(&context.output_path);
171 if output_path.exists() && !overwrite {
172 bail!(
173 "Output file already exists: {}\n\
174 Use --overwrite to replace existing file.",
175 output_path.display()
176 );
177 }
178
179 let rendered = render_skill_md(&context).context("failed to render SKILL.md template")?;
181
182 if let Some(parent) = output_path.parent() {
183 std::fs::create_dir_all(parent)
184 .with_context(|| format!("failed to create directory: {}", parent.display()))?;
185 }
186
187 let tmp_path = output_path.with_added_extension("tmp");
190 std::fs::write(&tmp_path, &rendered)
191 .with_context(|| format!("failed to write temp file: {}", tmp_path.display()))?;
192 std::fs::rename(&tmp_path, &output_path)
193 .with_context(|| format!("failed to rename to: {}", output_path.display()))?;
194
195 let bytes_written = rendered.len();
196 info!(
197 "SKILL.md written to {} ({} bytes, {} tools)",
198 output_path.display(),
199 bytes_written,
200 context.tool_count,
201 );
202
203 let result = SkillWriteResult {
204 success: true,
205 output_path: output_path.display().to_string(),
206 bytes_written,
207 tool_count: context.tool_count,
208 };
209
210 let output = format_output(&result, output_format)?;
211 println!("{output}");
212
213 Ok(ExitCode::SUCCESS)
214}
215
216fn resolve_servers_dir(servers_dir: Option<&Path>) -> Result<PathBuf> {
230 if let Some(dir) = servers_dir {
231 if let Some(stripped) = dir.to_str().and_then(|s| s.strip_prefix("~/")) {
233 let home = dirs::home_dir().context("Could not determine home directory")?;
234 Ok(home.join(stripped))
235 } else {
236 Ok(dir.to_path_buf())
237 }
238 } else {
239 let home = dirs::home_dir().context("Could not determine home directory")?;
241 Ok(home.join(DEFAULT_SERVERS_DIR))
242 }
243}
244
245fn resolve_skills_dir() -> Result<PathBuf> {
255 let home = dirs::home_dir().context("Could not determine home directory")?;
256 Ok(home.join(DEFAULT_SKILLS_DIR))
257}
258
259fn validate_path_security(path: &Path, base: &Path) -> Result<PathBuf> {
278 if has_path_traversal(path) {
280 bail!("Path traversal detected: {}", path.display());
281 }
282
283 if !path.exists() {
285 return Ok(path.to_path_buf());
286 }
287
288 let canonical_path = path
290 .canonicalize()
291 .with_context(|| format!("Failed to canonicalize path: {}", path.display()))?;
292
293 let canonical_base = if base.exists() {
294 base.canonicalize()
295 .with_context(|| format!("Failed to canonicalize base: {}", base.display()))?
296 } else {
297 return Ok(path.to_path_buf());
299 };
300
301 if !canonical_path.starts_with(&canonical_base) {
303 bail!(
304 "Security error: path {} is outside base directory {}",
305 canonical_path.display(),
306 canonical_base.display()
307 );
308 }
309
310 Ok(canonical_path)
311}
312
313fn validate_output_path(path: &Path) -> Result<()> {
323 if has_path_traversal(path) {
324 bail!(
325 "Invalid output path (path traversal detected): {}",
326 path.display()
327 );
328 }
329 Ok(())
330}
331
332fn has_path_traversal(path: &Path) -> bool {
336 use std::path::Component;
337 path.components().any(|c| matches!(c, Component::ParentDir))
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343 use tempfile::TempDir;
344
345 #[test]
346 fn test_resolve_servers_dir_default() {
347 let result = resolve_servers_dir(None);
348 assert!(result.is_ok());
349 let path = result.unwrap();
350 assert!(path.to_string_lossy().contains(".claude/servers"));
351 }
352
353 #[test]
354 fn test_resolve_servers_dir_custom() {
355 let custom = PathBuf::from("/custom/servers");
356 let result = resolve_servers_dir(Some(&custom));
357 assert!(result.is_ok());
358 assert_eq!(result.unwrap(), custom);
359 }
360
361 #[test]
362 fn test_resolve_servers_dir_tilde() {
363 let custom = PathBuf::from("~/custom/servers");
364 let result = resolve_servers_dir(Some(&custom));
365 assert!(result.is_ok());
366 let path = result.unwrap();
367 assert!(!path.to_string_lossy().starts_with('~'));
369 assert!(path.to_string_lossy().contains("custom/servers"));
370 }
371
372 #[test]
373 fn test_validate_path_security_valid() {
374 let temp = TempDir::new().unwrap();
375 let base = temp.path();
376 let subdir = base.join("server");
377 std::fs::create_dir(&subdir).unwrap();
378
379 let result = validate_path_security(&subdir, base);
380 assert!(result.is_ok());
381 }
382
383 #[test]
384 fn test_validate_path_security_traversal() {
385 let temp = TempDir::new().unwrap();
386 let base = temp.path();
387 let evil_path = base.join("..").join("etc").join("passwd");
388
389 let result = validate_path_security(&evil_path, base);
390 assert!(result.is_err());
391 assert!(result.unwrap_err().to_string().contains("traversal"));
392 }
393
394 #[test]
395 fn test_validate_path_security_nonexistent() {
396 let temp = TempDir::new().unwrap();
397 let base = temp.path();
398 let new_path = base.join("new-server");
399
400 let result = validate_path_security(&new_path, base);
402 assert!(result.is_ok());
403 }
404
405 #[test]
406 fn test_resolve_skills_dir() {
407 let result = resolve_skills_dir();
408 assert!(result.is_ok());
409 let path = result.unwrap();
410 assert!(path.to_string_lossy().contains(".claude/skills"));
411 }
412
413 #[test]
414 fn test_has_path_traversal() {
415 assert!(has_path_traversal(Path::new("../etc/passwd")));
417 assert!(has_path_traversal(Path::new("/tmp/../etc/passwd")));
418 assert!(has_path_traversal(Path::new("foo/../../bar")));
419
420 assert!(!has_path_traversal(Path::new("/etc/passwd")));
422 assert!(!has_path_traversal(Path::new("foo/bar/baz")));
423 assert!(!has_path_traversal(Path::new("./foo/bar")));
424 assert!(!has_path_traversal(Path::new("...")));
425 assert!(!has_path_traversal(Path::new("..foo")));
426 }
427
428 #[test]
429 fn test_validate_output_path_valid() {
430 assert!(validate_output_path(Path::new("/tmp/skill.md")).is_ok());
431 assert!(validate_output_path(Path::new("~/.claude/skills/github/SKILL.md")).is_ok());
432 assert!(validate_output_path(Path::new("./output.md")).is_ok());
433 }
434
435 #[test]
436 fn test_validate_output_path_traversal() {
437 let result = validate_output_path(Path::new("../../../etc/passwd"));
438 assert!(result.is_err());
439 assert!(result.unwrap_err().to_string().contains("path traversal"));
440
441 let result = validate_output_path(Path::new("/tmp/../etc/passwd"));
442 assert!(result.is_err());
443 }
444
445 #[tokio::test]
446 async fn test_run_output_path_traversal() {
447 let temp = TempDir::new().unwrap();
448 let server_dir = temp.path().join("github");
449 std::fs::create_dir(&server_dir).unwrap();
450
451 let ts_content = r"/**
452 * @tool test
453 * @server github
454 * @description Test
455 * @keywords test
456 */
457async function test(x: string): Promise<void> {}
458";
459 std::fs::write(server_dir.join("test.ts"), ts_content).unwrap();
460
461 let evil_output = temp
463 .path()
464 .join("..")
465 .join("..")
466 .join("etc")
467 .join("evil.md");
468
469 let result = run(
470 "github".to_string(),
471 Some(temp.path().to_path_buf()),
472 Some(evil_output),
473 None,
474 vec![],
475 false,
476 OutputFormat::Json,
477 )
478 .await;
479
480 assert!(result.is_err());
481 assert!(result.unwrap_err().to_string().contains("path traversal"));
482 }
483
484 #[tokio::test]
485 async fn test_run_invalid_server_id() {
486 let result = run(
487 "INVALID_ID".to_string(), None,
489 None,
490 None,
491 vec![],
492 false,
493 OutputFormat::Json,
494 )
495 .await;
496
497 assert!(result.is_err());
498 assert!(
499 result
500 .unwrap_err()
501 .to_string()
502 .contains("Invalid server ID")
503 );
504 }
505
506 #[tokio::test]
507 async fn test_run_server_not_found() {
508 let temp = TempDir::new().unwrap();
509 let result = run(
510 "nonexistent-server".to_string(),
511 Some(temp.path().to_path_buf()),
512 None,
513 None,
514 vec![],
515 false,
516 OutputFormat::Json,
517 )
518 .await;
519
520 assert!(result.is_err());
521 assert!(
522 result
523 .unwrap_err()
524 .to_string()
525 .contains("Server directory not found")
526 );
527 }
528
529 #[tokio::test]
530 async fn test_run_no_typescript_files() {
531 let temp = TempDir::new().unwrap();
532 let server_dir = temp.path().join("empty-server");
533 std::fs::create_dir(&server_dir).unwrap();
534
535 let result = run(
536 "empty-server".to_string(),
537 Some(temp.path().to_path_buf()),
538 None,
539 None,
540 vec![],
541 false,
542 OutputFormat::Json,
543 )
544 .await;
545
546 assert!(result.is_err());
547 assert!(
548 result
549 .unwrap_err()
550 .to_string()
551 .contains("No TypeScript tool files found")
552 );
553 }
554
555 #[tokio::test]
556 async fn test_run_with_valid_typescript_files() {
557 let temp = TempDir::new().unwrap();
558 let server_dir = temp.path().join("test-server");
559 std::fs::create_dir(&server_dir).unwrap();
560
561 let ts_content = r"/**
563 * @tool test_tool
564 * @server test-server
565 * @description Test tool description
566 * @category testing
567 * @keywords test,example
568 */
569async function testTool(input: string): Promise<void> {
570 console.log(input);
571}
572";
573 std::fs::write(server_dir.join("test_tool.ts"), ts_content).unwrap();
574
575 let output_path = temp.path().join("SKILL.md");
576
577 let result = run(
578 "test-server".to_string(),
579 Some(temp.path().to_path_buf()),
580 Some(output_path.clone()),
581 None,
582 vec![],
583 false,
584 OutputFormat::Json,
585 )
586 .await;
587
588 assert!(
589 result.is_ok(),
590 "Expected success but got: {:?}",
591 result.err()
592 );
593 assert!(output_path.exists(), "SKILL.md must be written to disk");
594 let content = std::fs::read_to_string(&output_path).unwrap();
595 assert!(
596 content.starts_with("---\n"),
597 "SKILL.md must start with YAML frontmatter"
598 );
599 }
600
601 #[tokio::test]
602 async fn test_run_with_custom_skill_name() {
603 let temp = TempDir::new().unwrap();
604 let server_dir = temp.path().join("github");
605 std::fs::create_dir(&server_dir).unwrap();
606
607 let ts_content = r"/**
608 * @tool create_issue
609 * @server github
610 * @description Create a GitHub issue
611 * @category issues
612 * @keywords create,issue
613 */
614async function createIssue(title: string): Promise<void> {}
615";
616 std::fs::write(server_dir.join("create_issue.ts"), ts_content).unwrap();
617
618 let output_path = temp.path().join("SKILL.md");
620
621 let result = run(
622 "github".to_string(),
623 Some(temp.path().to_path_buf()),
624 Some(output_path),
625 Some("github-advanced".to_string()),
626 vec![],
627 false,
628 OutputFormat::Json,
629 )
630 .await;
631
632 assert!(
633 result.is_ok(),
634 "Expected success but got: {:?}",
635 result.err()
636 );
637 }
638
639 #[tokio::test]
640 async fn test_run_with_hints() {
641 let temp = TempDir::new().unwrap();
642 let server_dir = temp.path().join("github");
643 std::fs::create_dir(&server_dir).unwrap();
644
645 let ts_content = r"/**
646 * @tool list_prs
647 * @server github
648 * @description List pull requests
649 * @category pull-requests
650 * @keywords list,prs
651 */
652async function listPrs(repo: string): Promise<void> {}
653";
654 std::fs::write(server_dir.join("list_prs.ts"), ts_content).unwrap();
655
656 let output_path = temp.path().join("SKILL.md");
658
659 let result = run(
660 "github".to_string(),
661 Some(temp.path().to_path_buf()),
662 Some(output_path),
663 None,
664 vec!["code review".to_string(), "CI/CD".to_string()],
665 false,
666 OutputFormat::Json,
667 )
668 .await;
669
670 assert!(
671 result.is_ok(),
672 "Expected success but got: {:?}",
673 result.err()
674 );
675 }
676
677 #[tokio::test]
678 async fn test_run_output_exists_no_overwrite() {
679 let temp = TempDir::new().unwrap();
680 let server_dir = temp.path().join("github");
681 std::fs::create_dir(&server_dir).unwrap();
682
683 let ts_content = r"/**
684 * @tool test
685 * @server github
686 * @description Test
687 * @keywords test
688 */
689async function test(x: string): Promise<void> {}
690";
691 std::fs::write(server_dir.join("test.ts"), ts_content).unwrap();
692
693 let output_path = temp.path().join("SKILL.md");
695 std::fs::write(&output_path, "existing content").unwrap();
696
697 let result = run(
698 "github".to_string(),
699 Some(temp.path().to_path_buf()),
700 Some(output_path),
701 None,
702 vec![],
703 false, OutputFormat::Json,
705 )
706 .await;
707
708 assert!(result.is_err());
709 assert!(result.unwrap_err().to_string().contains("already exists"));
710 }
711
712 #[tokio::test]
713 async fn test_run_output_exists_with_overwrite() {
714 let temp = TempDir::new().unwrap();
715 let server_dir = temp.path().join("github");
716 std::fs::create_dir(&server_dir).unwrap();
717
718 let ts_content = r"/**
719 * @tool test
720 * @server github
721 * @description Test
722 * @keywords test
723 */
724async function test(x: string): Promise<void> {}
725";
726 std::fs::write(server_dir.join("test.ts"), ts_content).unwrap();
727
728 let output_path = temp.path().join("SKILL.md");
730 std::fs::write(&output_path, "existing content").unwrap();
731
732 let result = run(
733 "github".to_string(),
734 Some(temp.path().to_path_buf()),
735 Some(output_path),
736 None,
737 vec![],
738 true, OutputFormat::Json,
740 )
741 .await;
742
743 assert!(
744 result.is_ok(),
745 "Expected success but got: {:?}",
746 result.err()
747 );
748 }
749
750 #[tokio::test]
751 async fn test_run_all_output_formats() {
752 let temp = TempDir::new().unwrap();
753 let server_dir = temp.path().join("test");
754 std::fs::create_dir(&server_dir).unwrap();
755
756 let ts_content = r"/**
757 * @tool test
758 * @server test
759 * @description Test
760 * @keywords test
761 */
762async function test(x: string): Promise<void> {}
763";
764 std::fs::write(server_dir.join("test.ts"), ts_content).unwrap();
765
766 for format in [OutputFormat::Json, OutputFormat::Text, OutputFormat::Pretty] {
767 let output_path = temp.path().join(format!("SKILL-{format}.md"));
768 let result = run(
769 "test".to_string(),
770 Some(temp.path().to_path_buf()),
771 Some(output_path),
772 None,
773 vec![],
774 false,
775 format,
776 )
777 .await;
778
779 assert!(
780 result.is_ok(),
781 "Format {:?} should succeed: {:?}",
782 format,
783 result.err()
784 );
785 }
786 }
787
788 #[tokio::test]
789 async fn test_run_path_traversal_server_id() {
790 let temp = TempDir::new().unwrap();
791
792 let result = run(
794 "../etc".to_string(),
795 Some(temp.path().to_path_buf()),
796 None,
797 None,
798 vec![],
799 false,
800 OutputFormat::Json,
801 )
802 .await;
803
804 assert!(result.is_err());
805 assert!(
807 result
808 .unwrap_err()
809 .to_string()
810 .contains("Invalid server ID")
811 );
812 }
813}