Skip to main content

mcp_execution_cli/commands/
skill.rs

1//! Skill command implementation.
2//!
3//! Generates Claude Code instruction skill files (SKILL.md) from progressive loading
4//! TypeScript tools. This command:
5//! 1. Scans generated TypeScript files in `~/.claude/servers/{server}/`
6//! 2. Extracts tool metadata and categories
7//! 3. Generates structured context for skill creation
8//! 4. Returns a prompt for Claude to generate optimal SKILL.md content
9
10use 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
18/// Default base directory for generated servers.
19const DEFAULT_SERVERS_DIR: &str = ".claude/servers";
20
21/// Default base directory for skills.
22const DEFAULT_SKILLS_DIR: &str = ".claude/skills";
23
24/// Runs the skill command.
25///
26/// Scans generated progressive loading TypeScript files and prepares context
27/// for generating a Claude Code instruction skill (SKILL.md).
28///
29/// # Process
30///
31/// 1. Validates server ID format
32/// 2. Determines servers directory (default: ~/.claude/servers)
33/// 3. Validates path security (no symlink escape)
34/// 4. Scans TypeScript files in `{servers_dir}/{server}/`
35/// 5. Builds skill generation context
36/// 6. Returns structured output with generation prompt
37///
38/// # Arguments
39///
40/// * `server` - Server identifier (e.g., "github")
41/// * `servers_dir` - Base directory for generated servers (default: ~/.claude/servers)
42/// * `output_path` - Custom output path for SKILL.md (default: ~/.claude/skills/{server}/SKILL.md)
43/// * `skill_name` - Custom skill name (default: {server}-progressive)
44/// * `hints` - Use case hints for skill generation
45/// * `overwrite` - Whether to overwrite existing SKILL.md file
46/// * `output_format` - Output format (json, text, pretty)
47///
48/// # Errors
49///
50/// Returns an error if:
51/// - Server ID format is invalid
52/// - Servers directory does not exist
53/// - Server subdirectory does not exist
54/// - Path traversal detected
55/// - TypeScript files cannot be scanned
56///
57/// # Examples
58///
59/// ```no_run
60/// use mcp_execution_cli::commands::skill;
61/// use mcp_execution_core::cli::OutputFormat;
62///
63/// # async fn example() -> anyhow::Result<()> {
64/// // Generate skill for GitHub server
65/// let exit_code = skill::run(
66///     "github".to_string(),
67///     None,
68///     None,
69///     None,
70///     vec![],
71///     false,
72///     OutputFormat::Json
73/// ).await?;
74/// # Ok(())
75/// # }
76/// ```
77#[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    // Step 1: Validate server ID
96    validate_server_id(&server).map_err(|e| anyhow::anyhow!("Invalid server ID: {e}"))?;
97    info!("Server ID validated: {}", server);
98
99    // Step 2: Resolve servers directory
100    let servers_base = resolve_servers_dir(servers_dir.as_deref())?;
101    debug!("Servers base directory: {}", servers_base.display());
102
103    // Step 3: Build and validate server path
104    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    // Step 4: Check server directory exists
109    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    // Step 5: Scan TypeScript files
119    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    // Step 6: Build skill context
136    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    // Apply custom skill name if provided
141    if let Some(name) = skill_name {
142        context.skill_name = name;
143    }
144
145    // Apply custom output path if provided
146    if let Some(path) = output_path {
147        // Validate output path for path traversal
148        validate_output_path(&path)?;
149        context.output_path = path.display().to_string();
150    } else {
151        // Use default skills directory
152        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    // Check if output file exists and overwrite flag
158    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    // Step 7: Format and output
168    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
182/// Resolve servers directory from provided path or default.
183///
184/// # Arguments
185///
186/// * `servers_dir` - Optional custom servers directory
187///
188/// # Returns
189///
190/// Resolved path to servers directory.
191///
192/// # Errors
193///
194/// Returns error if home directory cannot be determined.
195fn resolve_servers_dir(servers_dir: Option<&Path>) -> Result<PathBuf> {
196    if let Some(dir) = servers_dir {
197        // Use provided path, expand ~ if needed
198        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        // Use default: ~/.claude/servers
206        let home = dirs::home_dir().context("Could not determine home directory")?;
207        Ok(home.join(DEFAULT_SERVERS_DIR))
208    }
209}
210
211/// Resolve skills directory (default: ~/.claude/skills).
212///
213/// # Returns
214///
215/// Resolved path to skills directory.
216///
217/// # Errors
218///
219/// Returns error if home directory cannot be determined.
220fn 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
225/// Validate path security to prevent path traversal attacks.
226///
227/// Ensures the resolved path is within the expected base directory.
228///
229/// # Arguments
230///
231/// * `path` - Path to validate
232/// * `base` - Expected base directory
233///
234/// # Returns
235///
236/// Canonicalized path if valid.
237///
238/// # Errors
239///
240/// Returns error if:
241/// - Path cannot be canonicalized
242/// - Path is outside the base directory (symlink escape)
243fn validate_path_security(path: &Path, base: &Path) -> Result<PathBuf> {
244    // Check for path traversal in components (more robust than string check)
245    if has_path_traversal(path) {
246        bail!("Path traversal detected: {}", path.display());
247    }
248
249    // If the path doesn't exist yet, validation passed
250    if !path.exists() {
251        return Ok(path.to_path_buf());
252    }
253
254    // Canonicalize to resolve symlinks
255    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        // Base doesn't exist, path components already validated
264        return Ok(path.to_path_buf());
265    };
266
267    // Verify path is within base directory
268    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
279/// Validate output path for path traversal attacks.
280///
281/// # Arguments
282///
283/// * `path` - Output path to validate
284///
285/// # Errors
286///
287/// Returns error if path contains traversal components (`..`).
288fn 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
298/// Check if path contains traversal components.
299///
300/// Uses path component analysis instead of string matching for robustness.
301fn 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        // Should expand ~ to home directory
334        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        // Non-existent paths without .. should be allowed
367        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        // Should detect traversal
382        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        // Should not flag valid paths
387        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        // Try to use path traversal in output path
428        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(), // uppercase not allowed
454            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        // Create a minimal TypeScript file with JSDoc (requires @tool and @server)
528        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        // Use custom output path to avoid conflicts with real files
577        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        // Use custom output path to avoid conflicts with real files
615        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        // Create existing output file
652        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, // no overwrite
662            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        // Create existing output file
687        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, // overwrite
697            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        // Server ID validation should reject path traversal attempts
750        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        // Should fail at server ID validation (contains invalid chars)
763        assert!(
764            result
765                .unwrap_err()
766                .to_string()
767                .contains("Invalid server ID")
768        );
769    }
770}