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::{
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/// Output of a successful `skill` command invocation.
22#[derive(Debug, Serialize)]
23struct SkillWriteResult {
24    success: bool,
25    output_path: String,
26    bytes_written: usize,
27    tool_count: usize,
28}
29
30/// Default base directory for generated servers.
31const DEFAULT_SERVERS_DIR: &str = ".claude/servers";
32
33/// Default base directory for skills.
34const DEFAULT_SKILLS_DIR: &str = ".claude/skills";
35
36/// Runs the skill command.
37///
38/// Scans generated progressive loading TypeScript files and prepares context
39/// for generating a Claude Code instruction skill (SKILL.md).
40///
41/// # Process
42///
43/// 1. Validates server ID format
44/// 2. Determines servers directory (default: ~/.claude/servers)
45/// 3. Validates path security (no symlink escape)
46/// 4. Scans TypeScript files in `{servers_dir}/{server}/`
47/// 5. Builds skill generation context
48/// 6. Returns structured output with generation prompt
49///
50/// # Arguments
51///
52/// * `server` - Server identifier (e.g., "github")
53/// * `servers_dir` - Base directory for generated servers (default: ~/.claude/servers)
54/// * `output_path` - Custom output path for SKILL.md (default: ~/.claude/skills/{server}/SKILL.md)
55/// * `skill_name` - Custom skill name (default: {server}-progressive)
56/// * `hints` - Use case hints for skill generation
57/// * `overwrite` - Whether to overwrite existing SKILL.md file
58/// * `output_format` - Output format (json, text, pretty)
59///
60/// # Errors
61///
62/// Returns an error if:
63/// - Server ID format is invalid
64/// - Servers directory does not exist
65/// - Server subdirectory does not exist
66/// - Path traversal detected
67/// - TypeScript files cannot be scanned
68///
69/// # Examples
70///
71/// ```no_run
72/// use mcp_execution_cli::commands::skill;
73/// use mcp_execution_core::cli::OutputFormat;
74///
75/// # async fn example() -> anyhow::Result<()> {
76/// // Generate skill for GitHub server
77/// let exit_code = skill::run(
78///     "github".to_string(),
79///     None,
80///     None,
81///     None,
82///     vec![],
83///     false,
84///     OutputFormat::Json
85/// ).await?;
86/// # Ok(())
87/// # }
88/// ```
89#[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    // Step 1: Validate server ID
108    validate_server_id(&server).map_err(|e| anyhow::anyhow!("Invalid server ID: {e}"))?;
109    info!("Server ID validated: {}", server);
110
111    // Step 2: Resolve servers directory
112    let servers_base = resolve_servers_dir(servers_dir.as_deref())?;
113    debug!("Servers base directory: {}", servers_base.display());
114
115    // Step 3: Build and validate server path
116    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    // Step 4: Check server directory exists
121    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    // Step 5: Scan TypeScript files
131    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    // Step 6: Build skill context
148    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    // Apply custom skill name if provided
153    if let Some(name) = skill_name {
154        context.skill_name = name;
155    }
156
157    // Apply custom output path if provided
158    if let Some(path) = output_path {
159        // Validate output path for path traversal
160        validate_output_path(&path)?;
161        context.output_path = path.display().to_string();
162    } else {
163        // Use default skills directory
164        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    // Check if output file exists and overwrite flag
170    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    // Step 7: Render SKILL.md and write atomically.
180    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    // Atomic write: write to a temp file in the same directory, then rename.
188    // `with_added_extension` appends `.tmp` without removing `.md` (N1).
189    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
216/// Resolve servers directory from provided path or default.
217///
218/// # Arguments
219///
220/// * `servers_dir` - Optional custom servers directory
221///
222/// # Returns
223///
224/// Resolved path to servers directory.
225///
226/// # Errors
227///
228/// Returns error if home directory cannot be determined.
229fn resolve_servers_dir(servers_dir: Option<&Path>) -> Result<PathBuf> {
230    if let Some(dir) = servers_dir {
231        // Use provided path, expand ~ if needed
232        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        // Use default: ~/.claude/servers
240        let home = dirs::home_dir().context("Could not determine home directory")?;
241        Ok(home.join(DEFAULT_SERVERS_DIR))
242    }
243}
244
245/// Resolve skills directory (default: ~/.claude/skills).
246///
247/// # Returns
248///
249/// Resolved path to skills directory.
250///
251/// # Errors
252///
253/// Returns error if home directory cannot be determined.
254fn 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
259/// Validate path security to prevent path traversal attacks.
260///
261/// Ensures the resolved path is within the expected base directory.
262///
263/// # Arguments
264///
265/// * `path` - Path to validate
266/// * `base` - Expected base directory
267///
268/// # Returns
269///
270/// Canonicalized path if valid.
271///
272/// # Errors
273///
274/// Returns error if:
275/// - Path cannot be canonicalized
276/// - Path is outside the base directory (symlink escape)
277fn validate_path_security(path: &Path, base: &Path) -> Result<PathBuf> {
278    // Check for path traversal in components (more robust than string check)
279    if has_path_traversal(path) {
280        bail!("Path traversal detected: {}", path.display());
281    }
282
283    // If the path doesn't exist yet, validation passed
284    if !path.exists() {
285        return Ok(path.to_path_buf());
286    }
287
288    // Canonicalize to resolve symlinks
289    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        // Base doesn't exist, path components already validated
298        return Ok(path.to_path_buf());
299    };
300
301    // Verify path is within base directory
302    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
313/// Validate output path for path traversal attacks.
314///
315/// # Arguments
316///
317/// * `path` - Output path to validate
318///
319/// # Errors
320///
321/// Returns error if path contains traversal components (`..`).
322fn 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
332/// Check if path contains traversal components.
333///
334/// Uses path component analysis instead of string matching for robustness.
335fn 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        // Should expand ~ to home directory
368        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        // Non-existent paths without .. should be allowed
401        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        // Should detect traversal
416        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        // Should not flag valid paths
421        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        // Try to use path traversal in output path
462        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(), // uppercase not allowed
488            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        // Create a minimal TypeScript file with JSDoc (requires @tool and @server)
562        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        // Use custom output path to avoid conflicts with real files
619        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        // Use custom output path to avoid conflicts with real files
657        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        // Create existing output file
694        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, // no overwrite
704            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        // Create existing output file
729        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, // overwrite
739            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        // Server ID validation should reject path traversal attempts
793        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        // Should fail at server ID validation (contains invalid chars)
806        assert!(
807            result
808                .unwrap_err()
809                .to_string()
810                .contains("Invalid server ID")
811        );
812    }
813}