1use anyhow::{Context, Result, bail};
11use mcp_execution_core::Error as CoreError;
12use mcp_execution_core::cli::{ExitCode, OutputFormat};
13use mcp_execution_skill::{
14 GenerateSkillResult, ParsedToolFile, ScanResult, build_skill_context, render_skill_md,
15 scan_tools_directory, validate_server_id, validate_skill_name,
16};
17use serde::Serialize;
18use std::path::{Path, PathBuf};
19use tracing::{debug, info};
20
21#[derive(Debug, Serialize)]
23struct SkillWriteResult {
24 success: bool,
25 output_path: String,
26 bytes_written: usize,
27 tool_count: usize,
28 warnings: Vec<String>,
31}
32
33const DEFAULT_SERVERS_DIR: &str = ".claude/servers";
35
36const DEFAULT_SKILLS_DIR: &str = ".claude/skills";
38
39pub async fn run(
106 server: String,
107 servers_dir: Option<PathBuf>,
108 output_path: Option<PathBuf>,
109 skill_name: Option<String>,
110 hints: Vec<String>,
111 overwrite: bool,
112 output_format: OutputFormat,
113) -> Result<ExitCode> {
114 debug!("Generating skill for server: {}", server);
115 debug!("Servers directory: {:?}", servers_dir);
116 debug!("Output path: {:?}", output_path);
117 debug!("Skill name: {:?}", skill_name);
118 debug!("Hints: {:?}", hints);
119 debug!("Overwrite: {}", overwrite);
120 debug!("Output format: {}", output_format);
121
122 validate_server_id(&server)
127 .map_err(|e| CoreError::InvalidArgument(format!("Invalid server ID: {e}")))?;
128 info!("Server ID validated: {}", server);
129
130 let tool_dir = resolve_tool_dir(&server, servers_dir.as_deref())?;
131
132 let scan_result = scan_server_tools(&tool_dir, &server).await?;
133
134 let (context, custom_output_path) = prepare_skill_context(
135 &server,
136 &scan_result.tools,
137 hints,
138 skill_name.as_deref(),
139 output_path,
140 )?;
141
142 let had_custom_output_path = custom_output_path.is_some();
146 let output_path = if let Some(path) = custom_output_path {
147 path
153 } else {
154 let skills_dir = resolve_skills_dir()?;
162 resolve_default_output_path(&skills_dir, &server).await?
163 };
164
165 if output_path.exists() && !overwrite {
167 bail!(
168 "Output file already exists: {}\n\
169 Use --overwrite to replace existing file.",
170 output_path.display()
171 );
172 }
173
174 let rendered = render_skill_md(&context).context("failed to render SKILL.md template")?;
176
177 if had_custom_output_path && let Some(parent) = output_path.parent() {
184 tokio::fs::create_dir_all(parent)
185 .await
186 .with_context(|| format!("failed to create directory: {}", parent.display()))?;
187 }
188
189 write_skill_md(&rendered, &output_path).await?;
190
191 let bytes_written = rendered.len();
192 info!(
193 "SKILL.md written to {} ({} bytes, {} tools)",
194 output_path.display(),
195 bytes_written,
196 context.tool_count,
197 );
198
199 let mut warnings = scan_result.warnings;
204 warnings.extend(context.warnings.iter().cloned());
205
206 let result = SkillWriteResult {
207 success: true,
208 output_path: output_path.display().to_string(),
209 bytes_written,
210 tool_count: context.tool_count,
211 warnings,
212 };
213
214 crate::formatters::emit(&result, output_format, ExitCode::SUCCESS)
215}
216
217fn resolve_tool_dir(server: &str, servers_dir: Option<&Path>) -> Result<PathBuf> {
224 let servers_base = resolve_servers_dir(servers_dir)?;
226 debug!("Servers base directory: {}", servers_base.display());
227
228 let tool_dir = servers_base.join(server);
230 let tool_dir = validate_path_security(&tool_dir, &servers_base)?;
231 debug!("Server directory: {}", tool_dir.display());
232
233 if !tool_dir.exists() {
235 bail!(
236 "Server directory not found: {}\n\
237 Run 'mcp-execution-cli generate --from-config {}' first to generate TypeScript files.",
238 tool_dir.display(),
239 server
240 );
241 }
242
243 Ok(tool_dir)
244}
245
246async fn scan_server_tools(tool_dir: &Path, server: &str) -> Result<ScanResult> {
252 info!("Scanning TypeScript files in {}", tool_dir.display());
254 let scan_result = scan_tools_directory(tool_dir)
255 .await
256 .context("Failed to scan tools directory")?;
257
258 if scan_result.tools.is_empty() {
259 bail!(
260 "No TypeScript tool files found in {}\n\
261 Run 'mcp-execution-cli generate --from-config {}' first.",
262 tool_dir.display(),
263 server
264 );
265 }
266
267 info!(
271 "Verified {} tool files against sidecar",
272 scan_result.tools.len()
273 );
274
275 Ok(scan_result)
276}
277
278fn prepare_skill_context(
297 server: &str,
298 tools: &[ParsedToolFile],
299 hints: Vec<String>,
300 skill_name: Option<&str>,
301 output_path: Option<PathBuf>,
302) -> Result<(GenerateSkillResult, Option<PathBuf>)> {
303 let hints_ref: Option<Vec<String>> = if hints.is_empty() { None } else { Some(hints) };
310
311 if let Some(name) = skill_name {
312 validate_skill_name(name)
313 .map_err(|e| CoreError::InvalidArgument(format!("Invalid skill name: {e}")))?;
314 }
315
316 let context = build_skill_context(server, tools, hints_ref.as_deref(), skill_name);
317
318 if let Some(path) = &output_path {
319 validate_output_path(path)?;
320 }
321
322 Ok((context, output_path))
323}
324
325async fn write_skill_md(rendered: &str, output_path: &Path) -> Result<()> {
337 let tmp_path = output_path.with_added_extension("tmp");
347 mcp_execution_core::write_confined_file(&tmp_path, rendered.as_bytes())
348 .await
349 .with_context(|| format!("failed to write temp file: {}", tmp_path.display()))?;
350 std::fs::rename(&tmp_path, output_path)
351 .with_context(|| format!("failed to rename to: {}", output_path.display()))?;
352
353 Ok(())
354}
355
356async fn resolve_default_output_path(skills_dir: &Path, server: &str) -> Result<PathBuf> {
377 let segment_dir =
383 mcp_execution_core::resolve_confined_path(skills_dir, server, Path::new(""), None)
384 .await
385 .with_context(|| {
386 format!("failed to resolve default skills directory for server: {server}")
387 })?;
388
389 Ok(segment_dir.join("SKILL.md"))
390}
391
392fn resolve_servers_dir(servers_dir: Option<&Path>) -> Result<PathBuf> {
406 if let Some(dir) = servers_dir {
407 if let Some(stripped) = dir.to_str().and_then(|s| s.strip_prefix("~/")) {
409 let home = dirs::home_dir().context("Could not determine home directory")?;
410 Ok(home.join(stripped))
411 } else {
412 Ok(dir.to_path_buf())
413 }
414 } else {
415 let home = dirs::home_dir().context("Could not determine home directory")?;
417 Ok(home.join(DEFAULT_SERVERS_DIR))
418 }
419}
420
421fn resolve_skills_dir() -> Result<PathBuf> {
431 let home = dirs::home_dir().context("Could not determine home directory")?;
432 Ok(home.join(DEFAULT_SKILLS_DIR))
433}
434
435fn validate_path_security(path: &Path, base: &Path) -> Result<PathBuf> {
454 if has_path_traversal(path) {
459 return Err(CoreError::SecurityViolation {
460 reason: format!("path traversal detected: {}", path.display()),
461 }
462 .into());
463 }
464
465 if !path.exists() {
467 return Ok(path.to_path_buf());
468 }
469
470 let canonical_path = path
472 .canonicalize()
473 .with_context(|| format!("Failed to canonicalize path: {}", path.display()))?;
474
475 let canonical_base = if base.exists() {
476 base.canonicalize()
477 .with_context(|| format!("Failed to canonicalize base: {}", base.display()))?
478 } else {
479 return Ok(path.to_path_buf());
481 };
482
483 if !canonical_path.starts_with(&canonical_base) {
485 return Err(CoreError::SecurityViolation {
486 reason: format!(
487 "path {} is outside base directory {}",
488 canonical_path.display(),
489 canonical_base.display()
490 ),
491 }
492 .into());
493 }
494
495 Ok(canonical_path)
496}
497
498fn validate_output_path(path: &Path) -> Result<()> {
515 if has_path_traversal(path) {
516 return Err(CoreError::SecurityViolation {
517 reason: format!(
518 "invalid output path (path traversal detected): {}",
519 path.display()
520 ),
521 }
522 .into());
523 }
524 Ok(())
525}
526
527fn has_path_traversal(path: &Path) -> bool {
531 mcp_execution_core::contains_parent_dir(path)
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537 use crate::formatters::format_output;
538 use mcp_execution_core::metadata::{
539 METADATA_FILE_NAME, METADATA_SCHEMA_VERSION, ParameterMetadata, ServerMetadata,
540 ToolMetadata,
541 };
542 use mcp_execution_core::provenance::GenerationProvenance;
543 use mcp_execution_core::{ServerConfig, ServerId, ToolName};
544 use tempfile::TempDir;
545
546 fn test_provenance() -> GenerationProvenance {
547 let config = ServerConfig::builder()
548 .command("test-command".to_string())
549 .build()
550 .unwrap();
551 GenerationProvenance::capture(&config, &[])
552 }
553
554 fn write_meta_sidecar(server_dir: &Path, server_id: &str, tool_name: &str) {
560 let meta = ServerMetadata {
561 schema_version: METADATA_SCHEMA_VERSION,
562 server_id: ServerId::new(server_id).unwrap(),
563 server_name: server_id.to_string(),
564 server_version: "1.0.0".to_string(),
565 tools: vec![ToolMetadata {
566 name: ToolName::new(tool_name).unwrap(),
567 typescript_name: tool_name.to_string(),
568 category: Some("testing".to_string()),
569 keywords: vec!["test".to_string()],
570 description: Some(format!("Test tool: {tool_name}")),
571 parameters: vec![ParameterMetadata {
572 name: "input".to_string(),
573 typescript_type: "string".to_string(),
574 required: true,
575 description: Some("Test input".to_string()),
576 }],
577 }],
578 provenance: test_provenance(),
579 };
580
581 let content = serde_json::to_string_pretty(&meta).unwrap();
582 std::fs::write(server_dir.join(METADATA_FILE_NAME), content).unwrap();
583 std::fs::write(server_dir.join(format!("{tool_name}.ts")), "export {}").unwrap();
584 }
585
586 #[test]
587 fn test_resolve_servers_dir_default() {
588 let result = resolve_servers_dir(None);
589 assert!(result.is_ok());
590 let path = result.unwrap();
591 assert!(path.to_string_lossy().contains(".claude/servers"));
592 }
593
594 #[test]
595 fn test_resolve_servers_dir_custom() {
596 let custom = PathBuf::from("/custom/servers");
597 let result = resolve_servers_dir(Some(&custom));
598 assert!(result.is_ok());
599 assert_eq!(result.unwrap(), custom);
600 }
601
602 #[test]
603 fn test_resolve_servers_dir_tilde() {
604 let custom = PathBuf::from("~/custom/servers");
605 let result = resolve_servers_dir(Some(&custom));
606 assert!(result.is_ok());
607 let path = result.unwrap();
608 assert!(!path.to_string_lossy().starts_with('~'));
610 assert!(path.to_string_lossy().contains("custom/servers"));
611 }
612
613 #[test]
614 fn test_validate_path_security_valid() {
615 let temp = TempDir::new().unwrap();
616 let base = temp.path();
617 let subdir = base.join("server");
618 std::fs::create_dir(&subdir).unwrap();
619
620 let result = validate_path_security(&subdir, base);
621 assert!(result.is_ok());
622 }
623
624 #[test]
625 fn test_validate_path_security_traversal() {
626 let temp = TempDir::new().unwrap();
627 let base = temp.path();
628 let evil_path = base.join("..").join("etc").join("passwd");
629
630 let result = validate_path_security(&evil_path, base);
631 assert!(result.is_err());
632 let err = result.unwrap_err();
633 assert!(err.to_string().contains("traversal"));
634 assert!(matches!(
638 err.downcast_ref::<CoreError>(),
639 Some(CoreError::SecurityViolation { .. })
640 ));
641 }
642
643 #[test]
644 fn test_validate_path_security_nonexistent() {
645 let temp = TempDir::new().unwrap();
646 let base = temp.path();
647 let new_path = base.join("new-server");
648
649 let result = validate_path_security(&new_path, base);
651 assert!(result.is_ok());
652 }
653
654 #[test]
655 fn test_resolve_skills_dir() {
656 let result = resolve_skills_dir();
657 assert!(result.is_ok());
658 let path = result.unwrap();
659 assert!(path.to_string_lossy().contains(".claude/skills"));
660 }
661
662 #[test]
663 fn test_has_path_traversal() {
664 assert!(has_path_traversal(Path::new("../etc/passwd")));
666 assert!(has_path_traversal(Path::new("/tmp/../etc/passwd")));
667 assert!(has_path_traversal(Path::new("foo/../../bar")));
668
669 assert!(!has_path_traversal(Path::new("/etc/passwd")));
671 assert!(!has_path_traversal(Path::new("foo/bar/baz")));
672 assert!(!has_path_traversal(Path::new("./foo/bar")));
673 assert!(!has_path_traversal(Path::new("...")));
674 assert!(!has_path_traversal(Path::new("..foo")));
675 }
676
677 #[test]
678 fn test_validate_output_path_valid() {
679 assert!(validate_output_path(Path::new("/tmp/skill.md")).is_ok());
680 assert!(validate_output_path(Path::new("~/.claude/skills/github/SKILL.md")).is_ok());
681 assert!(validate_output_path(Path::new("./output.md")).is_ok());
682 }
683
684 #[test]
685 fn test_validate_output_path_traversal() {
686 let result = validate_output_path(Path::new("../../../etc/passwd"));
687 assert!(result.is_err());
688 assert!(result.unwrap_err().to_string().contains("path traversal"));
689
690 let result = validate_output_path(Path::new("/tmp/../etc/passwd"));
691 assert!(result.is_err());
692 }
693
694 #[tokio::test]
695 async fn test_run_output_path_traversal() {
696 let temp = TempDir::new().unwrap();
697 let server_dir = temp.path().join("github");
698 std::fs::create_dir(&server_dir).unwrap();
699 write_meta_sidecar(&server_dir, "github", "test");
700
701 let evil_output = temp
703 .path()
704 .join("..")
705 .join("..")
706 .join("etc")
707 .join("evil.md");
708
709 let result = run(
710 "github".to_string(),
711 Some(temp.path().to_path_buf()),
712 Some(evil_output),
713 None,
714 vec![],
715 false,
716 OutputFormat::Json,
717 )
718 .await;
719
720 assert!(result.is_err());
721 assert!(result.unwrap_err().to_string().contains("path traversal"));
722 }
723
724 #[tokio::test]
725 async fn test_run_invalid_server_id() {
726 let result = run(
727 "INVALID_ID".to_string(), None,
729 None,
730 None,
731 vec![],
732 false,
733 OutputFormat::Json,
734 )
735 .await;
736
737 assert!(result.is_err());
738 let err = result.unwrap_err();
739 assert!(err.to_string().contains("Invalid server ID"));
740 assert!(matches!(
744 err.downcast_ref::<CoreError>(),
745 Some(CoreError::InvalidArgument(_))
746 ));
747 }
748
749 #[tokio::test]
750 async fn test_run_server_not_found() {
751 let temp = TempDir::new().unwrap();
752 let result = run(
753 "nonexistent-server".to_string(),
754 Some(temp.path().to_path_buf()),
755 None,
756 None,
757 vec![],
758 false,
759 OutputFormat::Json,
760 )
761 .await;
762
763 assert!(result.is_err());
764 assert!(
765 result
766 .unwrap_err()
767 .to_string()
768 .contains("Server directory not found")
769 );
770 }
771
772 #[tokio::test]
773 async fn test_run_no_typescript_files() {
774 let temp = TempDir::new().unwrap();
775 let server_dir = temp.path().join("empty-server");
776 std::fs::create_dir(&server_dir).unwrap();
777
778 let result = run(
781 "empty-server".to_string(),
782 Some(temp.path().to_path_buf()),
783 None,
784 None,
785 vec![],
786 false,
787 OutputFormat::Json,
788 )
789 .await;
790
791 assert!(result.is_err());
792 assert!(
793 result
794 .unwrap_err()
795 .to_string()
796 .contains("Failed to scan tools directory")
797 );
798 }
799
800 #[tokio::test]
801 async fn test_run_with_valid_typescript_files() {
802 let temp = TempDir::new().unwrap();
803 let server_dir = temp.path().join("test-server");
804 std::fs::create_dir(&server_dir).unwrap();
805 write_meta_sidecar(&server_dir, "test-server", "test_tool");
806
807 let output_path = temp.path().join("SKILL.md");
808
809 let result = run(
810 "test-server".to_string(),
811 Some(temp.path().to_path_buf()),
812 Some(output_path.clone()),
813 None,
814 vec![],
815 false,
816 OutputFormat::Json,
817 )
818 .await;
819
820 assert!(
821 result.is_ok(),
822 "Expected success but got: {:?}",
823 result.err()
824 );
825 assert!(output_path.exists(), "SKILL.md must be written to disk");
826 let content = std::fs::read_to_string(&output_path).unwrap();
827 assert!(
828 content.starts_with("---\n"),
829 "SKILL.md must start with YAML frontmatter"
830 );
831 }
832
833 #[tokio::test]
837 async fn test_run_creates_nested_parent_directory_for_custom_output_path() {
838 let temp = TempDir::new().unwrap();
839 let server_dir = temp.path().join("test-server");
840 std::fs::create_dir(&server_dir).unwrap();
841 write_meta_sidecar(&server_dir, "test-server", "test_tool");
842
843 let output_path = temp.path().join("nested").join("dir").join("SKILL.md");
844 assert!(!output_path.parent().unwrap().exists());
845
846 let result = run(
847 "test-server".to_string(),
848 Some(temp.path().to_path_buf()),
849 Some(output_path.clone()),
850 None,
851 vec![],
852 false,
853 OutputFormat::Json,
854 )
855 .await;
856
857 assert!(
858 result.is_ok(),
859 "Expected success but got: {:?}",
860 result.err()
861 );
862 assert!(output_path.exists(), "SKILL.md must be written to disk");
863 }
864
865 #[tokio::test]
866 async fn test_run_with_orphan_ts_file_succeeds() {
867 let temp = TempDir::new().unwrap();
871 let server_dir = temp.path().join("test-server");
872 std::fs::create_dir(&server_dir).unwrap();
873 write_meta_sidecar(&server_dir, "test-server", "test_tool");
874 std::fs::write(server_dir.join("orphanTool.ts"), "export {}").unwrap();
875
876 let output_path = temp.path().join("SKILL.md");
877
878 let result = run(
879 "test-server".to_string(),
880 Some(temp.path().to_path_buf()),
881 Some(output_path.clone()),
882 None,
883 vec![],
884 false,
885 OutputFormat::Json,
886 )
887 .await;
888
889 assert!(
890 result.is_ok(),
891 "an orphaned .ts file must not fail the run: {:?}",
892 result.err()
893 );
894 assert!(output_path.exists(), "SKILL.md must still be written");
895 }
896
897 #[test]
898 fn test_skill_write_result_json_includes_warnings() {
899 let result = SkillWriteResult {
903 success: true,
904 output_path: "/tmp/SKILL.md".to_string(),
905 bytes_written: 42,
906 tool_count: 1,
907 warnings: vec![
908 "'orphanTool.ts' is not referenced by _meta.json and was excluded from SKILL.md \
909 (re-run 'generate' to refresh the sidecar)"
910 .to_string(),
911 ],
912 };
913
914 let output = format_output(&result, OutputFormat::Json).unwrap();
915
916 assert!(
917 output.contains("\"warnings\""),
918 "JSON output must contain a warnings field: {output}"
919 );
920 assert!(
921 output.contains("orphanTool.ts"),
922 "warnings must name the excluded file: {output}"
923 );
924 }
925
926 #[tokio::test]
927 async fn test_run_with_custom_skill_name() {
928 let temp = TempDir::new().unwrap();
929 let server_dir = temp.path().join("github");
930 std::fs::create_dir(&server_dir).unwrap();
931 write_meta_sidecar(&server_dir, "github", "create_issue");
932
933 let output_path = temp.path().join("SKILL.md");
935
936 let result = run(
937 "github".to_string(),
938 Some(temp.path().to_path_buf()),
939 Some(output_path.clone()),
940 Some("github-advanced".to_string()),
941 vec![],
942 false,
943 OutputFormat::Json,
944 )
945 .await;
946
947 assert!(
948 result.is_ok(),
949 "Expected success but got: {:?}",
950 result.err()
951 );
952
953 let written = std::fs::read_to_string(&output_path).unwrap();
956 assert!(
957 written.contains("name: github-advanced"),
958 "written SKILL.md must use the custom skill name: {written}"
959 );
960 }
961
962 #[test]
969 fn test_prepare_skill_context_with_custom_skill_name_reflects_it_in_generation_prompt() {
970 let tools = vec![];
971
972 let (context, _output_path) =
973 prepare_skill_context("github", &tools, vec![], Some("github-advanced"), None).unwrap();
974
975 assert_eq!(context.skill_name, "github-advanced");
976 assert!(
977 context.generation_prompt.contains("github-advanced"),
978 "generation_prompt must reflect the custom skill_name, not the default: {}",
979 context.generation_prompt
980 );
981 assert!(!context.generation_prompt.contains("github-progressive"));
982 }
983
984 #[test]
990 fn test_prepare_skill_context_does_not_overwrite_default_output_path_hint() {
991 let tools = vec![];
992 let custom_output = PathBuf::from("/tmp/custom/SKILL.md");
993
994 let (context, resolved_output_path) =
995 prepare_skill_context("github", &tools, vec![], None, Some(custom_output.clone()))
996 .unwrap();
997
998 assert_eq!(resolved_output_path, Some(custom_output));
999 assert_eq!(
1000 context.default_output_path_hint, "~/.claude/skills/github/SKILL.md",
1001 "default_output_path_hint must stay build_skill_context's own default, not be \
1002 overwritten with the resolved write path"
1003 );
1004 }
1005
1006 #[test]
1014 fn test_prepare_skill_context_surfaces_use_case_hint_cap_warning() {
1015 let tools = vec![];
1016 let hints: Vec<String> = (0..(mcp_execution_skill::types::MAX_USE_CASE_HINTS + 2))
1017 .map(|i| format!("hint-{i}"))
1018 .collect();
1019
1020 let (context, _output_path) =
1021 prepare_skill_context("github", &tools, hints, None, None).unwrap();
1022
1023 assert_eq!(context.warnings.len(), 1, "{:?}", context.warnings);
1024 assert!(
1025 context.warnings[0].contains("dropped"),
1026 "{:?}",
1027 context.warnings
1028 );
1029 }
1030
1031 #[tokio::test]
1035 async fn test_run_rejects_oversized_skill_name() {
1036 let temp = TempDir::new().unwrap();
1037 let server_dir = temp.path().join("github");
1038 std::fs::create_dir(&server_dir).unwrap();
1039 write_meta_sidecar(&server_dir, "github", "create_issue");
1040
1041 let output_path = temp.path().join("SKILL.md");
1042 let oversized_name = "a".repeat(mcp_execution_skill::MAX_SKILL_NAME_LENGTH + 1);
1043
1044 let result = run(
1045 "github".to_string(),
1046 Some(temp.path().to_path_buf()),
1047 Some(output_path.clone()),
1048 Some(oversized_name),
1049 vec![],
1050 false,
1051 OutputFormat::Json,
1052 )
1053 .await;
1054
1055 assert!(result.is_err(), "oversized skill_name must be rejected");
1056 assert!(
1057 !output_path.exists(),
1058 "no SKILL.md should be written when skill_name validation fails"
1059 );
1060 }
1061
1062 #[tokio::test]
1067 async fn test_run_with_hints() {
1068 let temp = TempDir::new().unwrap();
1069 let server_dir = temp.path().join("github");
1070 std::fs::create_dir(&server_dir).unwrap();
1071 write_meta_sidecar(&server_dir, "github", "list_prs");
1072
1073 let output_path = temp.path().join("SKILL.md");
1075
1076 let result = run(
1077 "github".to_string(),
1078 Some(temp.path().to_path_buf()),
1079 Some(output_path.clone()),
1080 None,
1081 vec!["code review".to_string(), "CI/CD".to_string()],
1082 false,
1083 OutputFormat::Json,
1084 )
1085 .await;
1086
1087 assert!(
1088 result.is_ok(),
1089 "Expected success but got: {:?}",
1090 result.err()
1091 );
1092
1093 let written = std::fs::read_to_string(&output_path).unwrap();
1094 assert!(
1095 written.contains("## Use Cases"),
1096 "written SKILL.md must include a Use Cases section: {written}"
1097 );
1098 assert!(written.contains("code review"), "{written}");
1099 assert!(written.contains("CI/CD"), "{written}");
1100 }
1101
1102 #[tokio::test]
1106 async fn test_run_without_hints_omits_use_cases_section() {
1107 let temp = TempDir::new().unwrap();
1108 let server_dir = temp.path().join("github");
1109 std::fs::create_dir(&server_dir).unwrap();
1110 write_meta_sidecar(&server_dir, "github", "list_prs");
1111
1112 let output_path = temp.path().join("SKILL.md");
1113
1114 let result = run(
1115 "github".to_string(),
1116 Some(temp.path().to_path_buf()),
1117 Some(output_path.clone()),
1118 None,
1119 vec![],
1120 false,
1121 OutputFormat::Json,
1122 )
1123 .await;
1124
1125 assert!(
1126 result.is_ok(),
1127 "Expected success but got: {:?}",
1128 result.err()
1129 );
1130
1131 let written = std::fs::read_to_string(&output_path).unwrap();
1132 assert!(
1133 !written.contains("## Use Cases"),
1134 "written SKILL.md must not have a Use Cases section without --hint: {written}"
1135 );
1136 }
1137
1138 #[tokio::test]
1139 async fn test_run_output_exists_no_overwrite() {
1140 let temp = TempDir::new().unwrap();
1141 let server_dir = temp.path().join("github");
1142 std::fs::create_dir(&server_dir).unwrap();
1143 write_meta_sidecar(&server_dir, "github", "test");
1144
1145 let output_path = temp.path().join("SKILL.md");
1147 std::fs::write(&output_path, "existing content").unwrap();
1148
1149 let result = run(
1150 "github".to_string(),
1151 Some(temp.path().to_path_buf()),
1152 Some(output_path),
1153 None,
1154 vec![],
1155 false, OutputFormat::Json,
1157 )
1158 .await;
1159
1160 assert!(result.is_err());
1161 assert!(result.unwrap_err().to_string().contains("already exists"));
1162 }
1163
1164 #[tokio::test]
1165 async fn test_run_output_exists_with_overwrite() {
1166 let temp = TempDir::new().unwrap();
1167 let server_dir = temp.path().join("github");
1168 std::fs::create_dir(&server_dir).unwrap();
1169 write_meta_sidecar(&server_dir, "github", "test");
1170
1171 let output_path = temp.path().join("SKILL.md");
1173 std::fs::write(&output_path, "existing content").unwrap();
1174
1175 let result = run(
1176 "github".to_string(),
1177 Some(temp.path().to_path_buf()),
1178 Some(output_path),
1179 None,
1180 vec![],
1181 true, OutputFormat::Json,
1183 )
1184 .await;
1185
1186 assert!(
1187 result.is_ok(),
1188 "Expected success but got: {:?}",
1189 result.err()
1190 );
1191 }
1192
1193 #[tokio::test]
1194 async fn test_run_all_output_formats() {
1195 let temp = TempDir::new().unwrap();
1196 let server_dir = temp.path().join("test");
1197 std::fs::create_dir(&server_dir).unwrap();
1198 write_meta_sidecar(&server_dir, "test", "test");
1199
1200 for format in [OutputFormat::Json, OutputFormat::Text, OutputFormat::Pretty] {
1201 let output_path = temp.path().join(format!("SKILL-{format}.md"));
1202 let result = run(
1203 "test".to_string(),
1204 Some(temp.path().to_path_buf()),
1205 Some(output_path),
1206 None,
1207 vec![],
1208 false,
1209 format,
1210 )
1211 .await;
1212
1213 assert!(
1214 result.is_ok(),
1215 "Format {:?} should succeed: {:?}",
1216 format,
1217 result.err()
1218 );
1219 }
1220 }
1221
1222 #[tokio::test]
1223 async fn test_run_stale_metadata_fails_instead_of_silently_succeeding() {
1224 let temp = TempDir::new().unwrap();
1229 let server_dir = temp.path().join("github");
1230 std::fs::create_dir(&server_dir).unwrap();
1231
1232 let meta = ServerMetadata {
1233 schema_version: METADATA_SCHEMA_VERSION,
1234 server_id: ServerId::new("github").unwrap(),
1235 server_name: "GitHub".to_string(),
1236 server_version: "1.0.0".to_string(),
1237 tools: vec![
1238 ToolMetadata {
1239 name: ToolName::new("create_issue").unwrap(),
1240 typescript_name: "createIssue".to_string(),
1241 category: Some("issues".to_string()),
1242 keywords: vec!["create".to_string()],
1243 description: Some("Create an issue".to_string()),
1244 parameters: vec![ParameterMetadata {
1245 name: "title".to_string(),
1246 typescript_type: "string".to_string(),
1247 required: true,
1248 description: Some("Issue title".to_string()),
1249 }],
1250 },
1251 ToolMetadata {
1252 name: ToolName::new("list_repos").unwrap(),
1253 typescript_name: "listRepos".to_string(),
1254 category: Some("repos".to_string()),
1255 keywords: vec!["list".to_string()],
1256 description: Some("List repos".to_string()),
1257 parameters: vec![],
1258 },
1259 ],
1260 provenance: test_provenance(),
1261 };
1262 let content = serde_json::to_string_pretty(&meta).unwrap();
1263 std::fs::write(server_dir.join(METADATA_FILE_NAME), content).unwrap();
1264
1265 std::fs::write(server_dir.join("listRepos.ts"), "export {}").unwrap();
1269 std::fs::write(server_dir.join("orphanTool.ts"), "export {}").unwrap();
1272
1273 let output_path = temp.path().join("SKILL.md");
1274
1275 let result = run(
1276 "github".to_string(),
1277 Some(temp.path().to_path_buf()),
1278 Some(output_path.clone()),
1279 None,
1280 vec![],
1281 false,
1282 OutputFormat::Json,
1283 )
1284 .await;
1285
1286 assert!(
1287 result.is_err(),
1288 "drifted sidecar must fail instead of silently succeeding"
1289 );
1290 let err = result.unwrap_err();
1291 let message = format!("{err:?}");
1294 assert!(
1295 message.contains("create_issue") || message.contains("createIssue.ts"),
1296 "error must identify the tool/file with the missing .ts: {message}"
1297 );
1298 assert!(
1299 !output_path.exists(),
1300 "SKILL.md must not be written when the sidecar is stale"
1301 );
1302 }
1303
1304 #[tokio::test]
1305 async fn test_run_path_traversal_server_id() {
1306 let temp = TempDir::new().unwrap();
1307
1308 let result = run(
1310 "../etc".to_string(),
1311 Some(temp.path().to_path_buf()),
1312 None,
1313 None,
1314 vec![],
1315 false,
1316 OutputFormat::Json,
1317 )
1318 .await;
1319
1320 assert!(result.is_err());
1321 assert!(
1323 result
1324 .unwrap_err()
1325 .to_string()
1326 .contains("Invalid server ID")
1327 );
1328 }
1329
1330 #[tokio::test]
1331 async fn test_write_skill_md_writes_content() {
1332 let base = TempDir::new().unwrap();
1333 let output_path = base.path().join("SKILL.md");
1334
1335 write_skill_md("rendered content", &output_path)
1336 .await
1337 .unwrap();
1338
1339 assert_eq!(
1340 std::fs::read_to_string(&output_path).unwrap(),
1341 "rendered content"
1342 );
1343 assert!(!output_path.with_added_extension("tmp").exists());
1345 }
1346
1347 #[tokio::test]
1351 async fn test_write_skill_md_overwrites_existing_regular_file() {
1352 let base = TempDir::new().unwrap();
1353 let output_path = base.path().join("SKILL.md");
1354 std::fs::write(&output_path, "old content").unwrap();
1355
1356 write_skill_md("new content", &output_path).await.unwrap();
1357
1358 assert_eq!(
1359 std::fs::read_to_string(&output_path).unwrap(),
1360 "new content"
1361 );
1362 }
1363
1364 #[tokio::test]
1371 #[cfg(unix)]
1372 async fn test_write_skill_md_rejects_symlink_planted_at_tmp_path() {
1373 let base = TempDir::new().unwrap();
1374 let outside = TempDir::new().unwrap();
1375 let outside_file = outside.path().join("real.md");
1376
1377 let output_path = base.path().join("SKILL.md");
1378 let tmp_path = output_path.with_added_extension("tmp");
1379 std::os::unix::fs::symlink(&outside_file, &tmp_path).unwrap();
1380
1381 let result = write_skill_md("attacker-controlled", &output_path).await;
1382
1383 assert!(result.is_err());
1384 assert!(!outside_file.exists());
1385 assert!(!output_path.exists());
1386 }
1387
1388 #[tokio::test]
1389 async fn test_resolve_default_output_path_creates_and_confines_segment_directory() {
1390 let skills_dir = TempDir::new().unwrap();
1391
1392 let resolved = resolve_default_output_path(skills_dir.path(), "my-server")
1393 .await
1394 .unwrap();
1395
1396 let canonical_base = skills_dir.path().canonicalize().unwrap();
1397 assert_eq!(resolved, canonical_base.join("my-server").join("SKILL.md"));
1398 assert!(canonical_base.join("my-server").is_dir());
1399 }
1400
1401 #[tokio::test]
1406 #[cfg(unix)]
1407 async fn test_resolve_default_output_path_rejects_symlinked_segment_directory() {
1408 let skills_dir = TempDir::new().unwrap();
1409 let outside = TempDir::new().unwrap();
1410 std::os::unix::fs::symlink(outside.path(), skills_dir.path().join("evil-server")).unwrap();
1411
1412 let err = resolve_default_output_path(skills_dir.path(), "evil-server")
1413 .await
1414 .unwrap_err();
1415
1416 assert!(format!("{err:#}").contains("symlink"), "{err:?}");
1419 assert!(!outside.path().join("SKILL.md").exists());
1420 }
1421
1422 #[tokio::test]
1428 #[cfg(unix)]
1429 async fn test_default_path_symlinked_skill_md_is_replaced_not_rejected() {
1430 let skills_dir = TempDir::new().unwrap();
1431 let outside = TempDir::new().unwrap();
1432 let outside_file = outside.path().join("real.md");
1433 std::fs::write(&outside_file, "linked content").unwrap();
1434
1435 let server_dir = skills_dir.path().join("my-server");
1436 std::fs::create_dir_all(&server_dir).unwrap();
1437 std::os::unix::fs::symlink(&outside_file, server_dir.join("SKILL.md")).unwrap();
1438
1439 let output_path = resolve_default_output_path(skills_dir.path(), "my-server")
1440 .await
1441 .unwrap();
1442 write_skill_md("new content", &output_path).await.unwrap();
1443
1444 assert!(!output_path.is_symlink());
1445 assert_eq!(
1446 std::fs::read_to_string(&output_path).unwrap(),
1447 "new content"
1448 );
1449 assert_eq!(
1452 std::fs::read_to_string(&outside_file).unwrap(),
1453 "linked content"
1454 );
1455 }
1456}