mcp_execution_cli/commands/
skill.rs1use anyhow::{Context, Result, bail};
11use mcp_execution_core::cli::{ExitCode, OutputFormat};
12use mcp_execution_skill::{build_skill_context, scan_tools_directory, validate_server_id};
13use std::path::{Path, PathBuf};
14use tracing::{debug, info};
15
16use crate::formatters::format_output;
17
18const DEFAULT_SERVERS_DIR: &str = ".claude/servers";
20
21const DEFAULT_SKILLS_DIR: &str = ".claude/skills";
23
24#[allow(clippy::too_many_arguments)]
78pub async fn run(
79 server: String,
80 servers_dir: Option<PathBuf>,
81 output_path: Option<PathBuf>,
82 skill_name: Option<String>,
83 hints: Vec<String>,
84 overwrite: bool,
85 output_format: OutputFormat,
86) -> Result<ExitCode> {
87 debug!("Generating skill for server: {}", server);
88 debug!("Servers directory: {:?}", servers_dir);
89 debug!("Output path: {:?}", output_path);
90 debug!("Skill name: {:?}", skill_name);
91 debug!("Hints: {:?}", hints);
92 debug!("Overwrite: {}", overwrite);
93 debug!("Output format: {}", output_format);
94
95 validate_server_id(&server).map_err(|e| anyhow::anyhow!("Invalid server ID: {e}"))?;
97 info!("Server ID validated: {}", server);
98
99 let servers_base = resolve_servers_dir(servers_dir.as_deref())?;
101 debug!("Servers base directory: {}", servers_base.display());
102
103 let tool_dir = servers_base.join(&server);
105 let tool_dir = validate_path_security(&tool_dir, &servers_base)?;
106 debug!("Server directory: {}", tool_dir.display());
107
108 if !tool_dir.exists() {
110 bail!(
111 "Server directory not found: {}\n\
112 Run 'mcp-execution-cli generate --from-config {}' first to generate TypeScript files.",
113 tool_dir.display(),
114 server
115 );
116 }
117
118 info!("Scanning TypeScript files in {}", tool_dir.display());
120 let tools = scan_tools_directory(&tool_dir)
121 .await
122 .context("Failed to scan tools directory")?;
123
124 if tools.is_empty() {
125 bail!(
126 "No TypeScript tool files found in {}\n\
127 Run 'mcp-execution-cli generate --from-config {}' first.",
128 tool_dir.display(),
129 server
130 );
131 }
132
133 info!("Found {} tool files", tools.len());
134
135 let hints_ref: Option<Vec<String>> = if hints.is_empty() { None } else { Some(hints) };
137
138 let mut context = build_skill_context(&server, &tools, hints_ref.as_deref());
139
140 if let Some(name) = skill_name {
142 context.skill_name = name;
143 }
144
145 if let Some(path) = output_path {
147 validate_output_path(&path)?;
149 context.output_path = path.display().to_string();
150 } else {
151 let skills_dir = resolve_skills_dir()?;
153 let default_output = skills_dir.join(&server).join("SKILL.md");
154 context.output_path = default_output.display().to_string();
155 }
156
157 let output_path = PathBuf::from(&context.output_path);
159 if output_path.exists() && !overwrite {
160 bail!(
161 "Output file already exists: {}\n\
162 Use --overwrite to replace existing file.",
163 output_path.display()
164 );
165 }
166
167 let output = format_output(&context, output_format)?;
169 println!("{output}");
170
171 info!(
172 "Skill context generated successfully for server '{}'",
173 server
174 );
175 info!("Output path: {}", context.output_path);
176 info!("Tool count: {}", context.tool_count);
177 info!("Categories: {}", context.categories.len());
178
179 Ok(ExitCode::SUCCESS)
180}
181
182fn resolve_servers_dir(servers_dir: Option<&Path>) -> Result<PathBuf> {
196 if let Some(dir) = servers_dir {
197 if let Some(stripped) = dir.to_str().and_then(|s| s.strip_prefix("~/")) {
199 let home = dirs::home_dir().context("Could not determine home directory")?;
200 Ok(home.join(stripped))
201 } else {
202 Ok(dir.to_path_buf())
203 }
204 } else {
205 let home = dirs::home_dir().context("Could not determine home directory")?;
207 Ok(home.join(DEFAULT_SERVERS_DIR))
208 }
209}
210
211fn resolve_skills_dir() -> Result<PathBuf> {
221 let home = dirs::home_dir().context("Could not determine home directory")?;
222 Ok(home.join(DEFAULT_SKILLS_DIR))
223}
224
225fn validate_path_security(path: &Path, base: &Path) -> Result<PathBuf> {
244 if has_path_traversal(path) {
246 bail!("Path traversal detected: {}", path.display());
247 }
248
249 if !path.exists() {
251 return Ok(path.to_path_buf());
252 }
253
254 let canonical_path = path
256 .canonicalize()
257 .with_context(|| format!("Failed to canonicalize path: {}", path.display()))?;
258
259 let canonical_base = if base.exists() {
260 base.canonicalize()
261 .with_context(|| format!("Failed to canonicalize base: {}", base.display()))?
262 } else {
263 return Ok(path.to_path_buf());
265 };
266
267 if !canonical_path.starts_with(&canonical_base) {
269 bail!(
270 "Security error: path {} is outside base directory {}",
271 canonical_path.display(),
272 canonical_base.display()
273 );
274 }
275
276 Ok(canonical_path)
277}
278
279fn validate_output_path(path: &Path) -> Result<()> {
289 if has_path_traversal(path) {
290 bail!(
291 "Invalid output path (path traversal detected): {}",
292 path.display()
293 );
294 }
295 Ok(())
296}
297
298fn has_path_traversal(path: &Path) -> bool {
302 use std::path::Component;
303 path.components().any(|c| matches!(c, Component::ParentDir))
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use tempfile::TempDir;
310
311 #[test]
312 fn test_resolve_servers_dir_default() {
313 let result = resolve_servers_dir(None);
314 assert!(result.is_ok());
315 let path = result.unwrap();
316 assert!(path.to_string_lossy().contains(".claude/servers"));
317 }
318
319 #[test]
320 fn test_resolve_servers_dir_custom() {
321 let custom = PathBuf::from("/custom/servers");
322 let result = resolve_servers_dir(Some(&custom));
323 assert!(result.is_ok());
324 assert_eq!(result.unwrap(), custom);
325 }
326
327 #[test]
328 fn test_resolve_servers_dir_tilde() {
329 let custom = PathBuf::from("~/custom/servers");
330 let result = resolve_servers_dir(Some(&custom));
331 assert!(result.is_ok());
332 let path = result.unwrap();
333 assert!(!path.to_string_lossy().starts_with('~'));
335 assert!(path.to_string_lossy().contains("custom/servers"));
336 }
337
338 #[test]
339 fn test_validate_path_security_valid() {
340 let temp = TempDir::new().unwrap();
341 let base = temp.path();
342 let subdir = base.join("server");
343 std::fs::create_dir(&subdir).unwrap();
344
345 let result = validate_path_security(&subdir, base);
346 assert!(result.is_ok());
347 }
348
349 #[test]
350 fn test_validate_path_security_traversal() {
351 let temp = TempDir::new().unwrap();
352 let base = temp.path();
353 let evil_path = base.join("..").join("etc").join("passwd");
354
355 let result = validate_path_security(&evil_path, base);
356 assert!(result.is_err());
357 assert!(result.unwrap_err().to_string().contains("traversal"));
358 }
359
360 #[test]
361 fn test_validate_path_security_nonexistent() {
362 let temp = TempDir::new().unwrap();
363 let base = temp.path();
364 let new_path = base.join("new-server");
365
366 let result = validate_path_security(&new_path, base);
368 assert!(result.is_ok());
369 }
370
371 #[test]
372 fn test_resolve_skills_dir() {
373 let result = resolve_skills_dir();
374 assert!(result.is_ok());
375 let path = result.unwrap();
376 assert!(path.to_string_lossy().contains(".claude/skills"));
377 }
378
379 #[test]
380 fn test_has_path_traversal() {
381 assert!(has_path_traversal(Path::new("../etc/passwd")));
383 assert!(has_path_traversal(Path::new("/tmp/../etc/passwd")));
384 assert!(has_path_traversal(Path::new("foo/../../bar")));
385
386 assert!(!has_path_traversal(Path::new("/etc/passwd")));
388 assert!(!has_path_traversal(Path::new("foo/bar/baz")));
389 assert!(!has_path_traversal(Path::new("./foo/bar")));
390 assert!(!has_path_traversal(Path::new("...")));
391 assert!(!has_path_traversal(Path::new("..foo")));
392 }
393
394 #[test]
395 fn test_validate_output_path_valid() {
396 assert!(validate_output_path(Path::new("/tmp/skill.md")).is_ok());
397 assert!(validate_output_path(Path::new("~/.claude/skills/github/SKILL.md")).is_ok());
398 assert!(validate_output_path(Path::new("./output.md")).is_ok());
399 }
400
401 #[test]
402 fn test_validate_output_path_traversal() {
403 let result = validate_output_path(Path::new("../../../etc/passwd"));
404 assert!(result.is_err());
405 assert!(result.unwrap_err().to_string().contains("path traversal"));
406
407 let result = validate_output_path(Path::new("/tmp/../etc/passwd"));
408 assert!(result.is_err());
409 }
410
411 #[tokio::test]
412 async fn test_run_output_path_traversal() {
413 let temp = TempDir::new().unwrap();
414 let server_dir = temp.path().join("github");
415 std::fs::create_dir(&server_dir).unwrap();
416
417 let ts_content = r"/**
418 * @tool test
419 * @server github
420 * @description Test
421 * @keywords test
422 */
423async function test(x: string): Promise<void> {}
424";
425 std::fs::write(server_dir.join("test.ts"), ts_content).unwrap();
426
427 let evil_output = temp
429 .path()
430 .join("..")
431 .join("..")
432 .join("etc")
433 .join("evil.md");
434
435 let result = run(
436 "github".to_string(),
437 Some(temp.path().to_path_buf()),
438 Some(evil_output),
439 None,
440 vec![],
441 false,
442 OutputFormat::Json,
443 )
444 .await;
445
446 assert!(result.is_err());
447 assert!(result.unwrap_err().to_string().contains("path traversal"));
448 }
449
450 #[tokio::test]
451 async fn test_run_invalid_server_id() {
452 let result = run(
453 "INVALID_ID".to_string(), None,
455 None,
456 None,
457 vec![],
458 false,
459 OutputFormat::Json,
460 )
461 .await;
462
463 assert!(result.is_err());
464 assert!(
465 result
466 .unwrap_err()
467 .to_string()
468 .contains("Invalid server ID")
469 );
470 }
471
472 #[tokio::test]
473 async fn test_run_server_not_found() {
474 let temp = TempDir::new().unwrap();
475 let result = run(
476 "nonexistent-server".to_string(),
477 Some(temp.path().to_path_buf()),
478 None,
479 None,
480 vec![],
481 false,
482 OutputFormat::Json,
483 )
484 .await;
485
486 assert!(result.is_err());
487 assert!(
488 result
489 .unwrap_err()
490 .to_string()
491 .contains("Server directory not found")
492 );
493 }
494
495 #[tokio::test]
496 async fn test_run_no_typescript_files() {
497 let temp = TempDir::new().unwrap();
498 let server_dir = temp.path().join("empty-server");
499 std::fs::create_dir(&server_dir).unwrap();
500
501 let result = run(
502 "empty-server".to_string(),
503 Some(temp.path().to_path_buf()),
504 None,
505 None,
506 vec![],
507 false,
508 OutputFormat::Json,
509 )
510 .await;
511
512 assert!(result.is_err());
513 assert!(
514 result
515 .unwrap_err()
516 .to_string()
517 .contains("No TypeScript tool files found")
518 );
519 }
520
521 #[tokio::test]
522 async fn test_run_with_valid_typescript_files() {
523 let temp = TempDir::new().unwrap();
524 let server_dir = temp.path().join("test-server");
525 std::fs::create_dir(&server_dir).unwrap();
526
527 let ts_content = r"/**
529 * @tool test_tool
530 * @server test-server
531 * @description Test tool description
532 * @category testing
533 * @keywords test,example
534 */
535async function testTool(input: string): Promise<void> {
536 console.log(input);
537}
538";
539 std::fs::write(server_dir.join("test_tool.ts"), ts_content).unwrap();
540
541 let result = run(
542 "test-server".to_string(),
543 Some(temp.path().to_path_buf()),
544 None,
545 None,
546 vec![],
547 false,
548 OutputFormat::Json,
549 )
550 .await;
551
552 assert!(
553 result.is_ok(),
554 "Expected success but got: {:?}",
555 result.err()
556 );
557 }
558
559 #[tokio::test]
560 async fn test_run_with_custom_skill_name() {
561 let temp = TempDir::new().unwrap();
562 let server_dir = temp.path().join("github");
563 std::fs::create_dir(&server_dir).unwrap();
564
565 let ts_content = r"/**
566 * @tool create_issue
567 * @server github
568 * @description Create a GitHub issue
569 * @category issues
570 * @keywords create,issue
571 */
572async function createIssue(title: string): Promise<void> {}
573";
574 std::fs::write(server_dir.join("create_issue.ts"), ts_content).unwrap();
575
576 let output_path = temp.path().join("SKILL.md");
578
579 let result = run(
580 "github".to_string(),
581 Some(temp.path().to_path_buf()),
582 Some(output_path),
583 Some("github-advanced".to_string()),
584 vec![],
585 false,
586 OutputFormat::Json,
587 )
588 .await;
589
590 assert!(
591 result.is_ok(),
592 "Expected success but got: {:?}",
593 result.err()
594 );
595 }
596
597 #[tokio::test]
598 async fn test_run_with_hints() {
599 let temp = TempDir::new().unwrap();
600 let server_dir = temp.path().join("github");
601 std::fs::create_dir(&server_dir).unwrap();
602
603 let ts_content = r"/**
604 * @tool list_prs
605 * @server github
606 * @description List pull requests
607 * @category pull-requests
608 * @keywords list,prs
609 */
610async function listPrs(repo: string): Promise<void> {}
611";
612 std::fs::write(server_dir.join("list_prs.ts"), ts_content).unwrap();
613
614 let output_path = temp.path().join("SKILL.md");
616
617 let result = run(
618 "github".to_string(),
619 Some(temp.path().to_path_buf()),
620 Some(output_path),
621 None,
622 vec!["code review".to_string(), "CI/CD".to_string()],
623 false,
624 OutputFormat::Json,
625 )
626 .await;
627
628 assert!(
629 result.is_ok(),
630 "Expected success but got: {:?}",
631 result.err()
632 );
633 }
634
635 #[tokio::test]
636 async fn test_run_output_exists_no_overwrite() {
637 let temp = TempDir::new().unwrap();
638 let server_dir = temp.path().join("github");
639 std::fs::create_dir(&server_dir).unwrap();
640
641 let ts_content = r"/**
642 * @tool test
643 * @server github
644 * @description Test
645 * @keywords test
646 */
647async function test(x: string): Promise<void> {}
648";
649 std::fs::write(server_dir.join("test.ts"), ts_content).unwrap();
650
651 let output_path = temp.path().join("SKILL.md");
653 std::fs::write(&output_path, "existing content").unwrap();
654
655 let result = run(
656 "github".to_string(),
657 Some(temp.path().to_path_buf()),
658 Some(output_path),
659 None,
660 vec![],
661 false, OutputFormat::Json,
663 )
664 .await;
665
666 assert!(result.is_err());
667 assert!(result.unwrap_err().to_string().contains("already exists"));
668 }
669
670 #[tokio::test]
671 async fn test_run_output_exists_with_overwrite() {
672 let temp = TempDir::new().unwrap();
673 let server_dir = temp.path().join("github");
674 std::fs::create_dir(&server_dir).unwrap();
675
676 let ts_content = r"/**
677 * @tool test
678 * @server github
679 * @description Test
680 * @keywords test
681 */
682async function test(x: string): Promise<void> {}
683";
684 std::fs::write(server_dir.join("test.ts"), ts_content).unwrap();
685
686 let output_path = temp.path().join("SKILL.md");
688 std::fs::write(&output_path, "existing content").unwrap();
689
690 let result = run(
691 "github".to_string(),
692 Some(temp.path().to_path_buf()),
693 Some(output_path),
694 None,
695 vec![],
696 true, OutputFormat::Json,
698 )
699 .await;
700
701 assert!(
702 result.is_ok(),
703 "Expected success but got: {:?}",
704 result.err()
705 );
706 }
707
708 #[tokio::test]
709 async fn test_run_all_output_formats() {
710 let temp = TempDir::new().unwrap();
711 let server_dir = temp.path().join("test");
712 std::fs::create_dir(&server_dir).unwrap();
713
714 let ts_content = r"/**
715 * @tool test
716 * @server test
717 * @description Test
718 * @keywords test
719 */
720async function test(x: string): Promise<void> {}
721";
722 std::fs::write(server_dir.join("test.ts"), ts_content).unwrap();
723
724 for format in [OutputFormat::Json, OutputFormat::Text, OutputFormat::Pretty] {
725 let result = run(
726 "test".to_string(),
727 Some(temp.path().to_path_buf()),
728 None,
729 None,
730 vec![],
731 false,
732 format,
733 )
734 .await;
735
736 assert!(
737 result.is_ok(),
738 "Format {:?} should succeed: {:?}",
739 format,
740 result.err()
741 );
742 }
743 }
744
745 #[tokio::test]
746 async fn test_run_path_traversal_server_id() {
747 let temp = TempDir::new().unwrap();
748
749 let result = run(
751 "../etc".to_string(),
752 Some(temp.path().to_path_buf()),
753 None,
754 None,
755 vec![],
756 false,
757 OutputFormat::Json,
758 )
759 .await;
760
761 assert!(result.is_err());
762 assert!(
764 result
765 .unwrap_err()
766 .to_string()
767 .contains("Invalid server ID")
768 );
769 }
770}