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    /// Non-fatal drift warnings, e.g. `.ts` files excluded from generation
29    /// because `_meta.json` has no matching entry for them (issue #161).
30    warnings: Vec<String>,
31}
32
33/// Default base directory for generated servers.
34const DEFAULT_SERVERS_DIR: &str = ".claude/servers";
35
36/// Default base directory for skills.
37const DEFAULT_SKILLS_DIR: &str = ".claude/skills";
38
39/// Runs the skill command.
40///
41/// Scans generated progressive loading TypeScript files and prepares context
42/// for generating a Claude Code instruction skill (SKILL.md).
43///
44/// # Process
45///
46/// 1. Validates server ID format
47/// 2. Determines servers directory (default: ~/.claude/servers)
48/// 3. Validates path security (no symlink escape)
49/// 4. Scans TypeScript files in `{servers_dir}/{server}/`
50/// 5. Builds skill generation context
51/// 6. Returns structured output with generation prompt
52///
53/// # Arguments
54///
55/// * `server` - Server identifier (e.g., "github")
56/// * `servers_dir` - Base directory for generated servers (default: ~/.claude/servers)
57/// * `output_path` - Custom output path for SKILL.md (default: ~/.claude/skills/{server}/SKILL.md)
58/// * `skill_name` - Custom skill name (default: {server}-progressive)
59/// * `hints` - Use case hints for skill generation
60/// * `overwrite` - Whether to overwrite existing SKILL.md file
61/// * `output_format` - Output format (json, text, pretty)
62///
63/// # Errors
64///
65/// Returns an error if:
66/// - Server ID format is invalid
67/// - Servers directory does not exist
68/// - Server subdirectory does not exist
69/// - Path traversal detected
70/// - TypeScript files cannot be scanned
71///
72/// # Examples
73///
74/// ```no_run
75/// use mcp_execution_cli::commands::skill;
76/// use mcp_execution_core::cli::OutputFormat;
77///
78/// # async fn example() -> anyhow::Result<()> {
79/// // Generate skill for GitHub server
80/// let exit_code = skill::run(
81///     "github".to_string(),
82///     None,
83///     None,
84///     None,
85///     vec![],
86///     false,
87///     OutputFormat::Json
88/// ).await?;
89/// # Ok(())
90/// # }
91/// ```
92// One argument per CLI flag; clap already destructures flags for us, and
93// grouping them into a struct would only benefit this function, not caller ergonomics.
94#[allow(clippy::too_many_arguments)]
95pub async fn run(
96    server: String,
97    servers_dir: Option<PathBuf>,
98    output_path: Option<PathBuf>,
99    skill_name: Option<String>,
100    hints: Vec<String>,
101    overwrite: bool,
102    output_format: OutputFormat,
103) -> Result<ExitCode> {
104    debug!("Generating skill for server: {}", server);
105    debug!("Servers directory: {:?}", servers_dir);
106    debug!("Output path: {:?}", output_path);
107    debug!("Skill name: {:?}", skill_name);
108    debug!("Hints: {:?}", hints);
109    debug!("Overwrite: {}", overwrite);
110    debug!("Output format: {}", output_format);
111
112    // Step 1: Validate server ID
113    validate_server_id(&server).map_err(|e| anyhow::anyhow!("Invalid server ID: {e}"))?;
114    info!("Server ID validated: {}", server);
115
116    // Step 2: Resolve servers directory
117    let servers_base = resolve_servers_dir(servers_dir.as_deref())?;
118    debug!("Servers base directory: {}", servers_base.display());
119
120    // Step 3: Build and validate server path
121    let tool_dir = servers_base.join(&server);
122    let tool_dir = validate_path_security(&tool_dir, &servers_base)?;
123    debug!("Server directory: {}", tool_dir.display());
124
125    // Step 4: Check server directory exists
126    if !tool_dir.exists() {
127        bail!(
128            "Server directory not found: {}\n\
129             Run 'mcp-execution-cli generate --from-config {}' first to generate TypeScript files.",
130            tool_dir.display(),
131            server
132        );
133    }
134
135    // Step 5: Scan TypeScript files
136    info!("Scanning TypeScript files in {}", tool_dir.display());
137    let scan_result = scan_tools_directory(&tool_dir)
138        .await
139        .context("Failed to scan tools directory")?;
140
141    if scan_result.tools.is_empty() {
142        bail!(
143            "No TypeScript tool files found in {}\n\
144             Run 'mcp-execution-cli generate --from-config {}' first.",
145            tool_dir.display(),
146            server
147        );
148    }
149
150    // `tools.len()` reflects sidecar entries that were cross-checked against an
151    // actual `.ts` file on disk by `scan_tools_directory` (issue #154) — not a
152    // raw sidecar entry count.
153    info!(
154        "Verified {} tool files against sidecar",
155        scan_result.tools.len()
156    );
157
158    // Step 6: Build skill context
159    let hints_ref: Option<Vec<String>> = if hints.is_empty() { None } else { Some(hints) };
160
161    let mut context = build_skill_context(&server, &scan_result.tools, hints_ref.as_deref());
162
163    // Apply custom skill name if provided
164    if let Some(name) = skill_name {
165        context.skill_name = name;
166    }
167
168    // Apply custom output path if provided
169    if let Some(path) = output_path {
170        // Validate output path for path traversal
171        validate_output_path(&path)?;
172        context.output_path = path.display().to_string();
173    } else {
174        // Use default skills directory
175        let skills_dir = resolve_skills_dir()?;
176        let default_output = skills_dir.join(&server).join("SKILL.md");
177        context.output_path = default_output.display().to_string();
178    }
179
180    // Check if output file exists and overwrite flag
181    let output_path = PathBuf::from(&context.output_path);
182    if output_path.exists() && !overwrite {
183        bail!(
184            "Output file already exists: {}\n\
185             Use --overwrite to replace existing file.",
186            output_path.display()
187        );
188    }
189
190    // Step 7: Render SKILL.md and write atomically.
191    let rendered = render_skill_md(&context).context("failed to render SKILL.md template")?;
192
193    if let Some(parent) = output_path.parent() {
194        std::fs::create_dir_all(parent)
195            .with_context(|| format!("failed to create directory: {}", parent.display()))?;
196    }
197
198    // Atomic write: write to a temp file in the same directory, then rename.
199    // `with_added_extension` appends `.tmp` without removing `.md` (N1).
200    let tmp_path = output_path.with_added_extension("tmp");
201    std::fs::write(&tmp_path, &rendered)
202        .with_context(|| format!("failed to write temp file: {}", tmp_path.display()))?;
203    std::fs::rename(&tmp_path, &output_path)
204        .with_context(|| format!("failed to rename to: {}", output_path.display()))?;
205
206    let bytes_written = rendered.len();
207    info!(
208        "SKILL.md written to {} ({} bytes, {} tools)",
209        output_path.display(),
210        bytes_written,
211        context.tool_count,
212    );
213
214    let result = SkillWriteResult {
215        success: true,
216        output_path: output_path.display().to_string(),
217        bytes_written,
218        tool_count: context.tool_count,
219        warnings: scan_result.warnings,
220    };
221
222    let output = format_output(&result, output_format)?;
223    println!("{output}");
224
225    Ok(ExitCode::SUCCESS)
226}
227
228/// Resolve servers directory from provided path or default.
229///
230/// # Arguments
231///
232/// * `servers_dir` - Optional custom servers directory
233///
234/// # Returns
235///
236/// Resolved path to servers directory.
237///
238/// # Errors
239///
240/// Returns error if home directory cannot be determined.
241fn resolve_servers_dir(servers_dir: Option<&Path>) -> Result<PathBuf> {
242    if let Some(dir) = servers_dir {
243        // Use provided path, expand ~ if needed
244        if let Some(stripped) = dir.to_str().and_then(|s| s.strip_prefix("~/")) {
245            let home = dirs::home_dir().context("Could not determine home directory")?;
246            Ok(home.join(stripped))
247        } else {
248            Ok(dir.to_path_buf())
249        }
250    } else {
251        // Use default: ~/.claude/servers
252        let home = dirs::home_dir().context("Could not determine home directory")?;
253        Ok(home.join(DEFAULT_SERVERS_DIR))
254    }
255}
256
257/// Resolve skills directory (default: ~/.claude/skills).
258///
259/// # Returns
260///
261/// Resolved path to skills directory.
262///
263/// # Errors
264///
265/// Returns error if home directory cannot be determined.
266fn resolve_skills_dir() -> Result<PathBuf> {
267    let home = dirs::home_dir().context("Could not determine home directory")?;
268    Ok(home.join(DEFAULT_SKILLS_DIR))
269}
270
271/// Validate path security to prevent path traversal attacks.
272///
273/// Ensures the resolved path is within the expected base directory.
274///
275/// # Arguments
276///
277/// * `path` - Path to validate
278/// * `base` - Expected base directory
279///
280/// # Returns
281///
282/// Canonicalized path if valid.
283///
284/// # Errors
285///
286/// Returns error if:
287/// - Path cannot be canonicalized
288/// - Path is outside the base directory (symlink escape)
289fn validate_path_security(path: &Path, base: &Path) -> Result<PathBuf> {
290    // Check for path traversal in components (more robust than string check)
291    if has_path_traversal(path) {
292        bail!("Path traversal detected: {}", path.display());
293    }
294
295    // If the path doesn't exist yet, validation passed
296    if !path.exists() {
297        return Ok(path.to_path_buf());
298    }
299
300    // Canonicalize to resolve symlinks
301    let canonical_path = path
302        .canonicalize()
303        .with_context(|| format!("Failed to canonicalize path: {}", path.display()))?;
304
305    let canonical_base = if base.exists() {
306        base.canonicalize()
307            .with_context(|| format!("Failed to canonicalize base: {}", base.display()))?
308    } else {
309        // Base doesn't exist, path components already validated
310        return Ok(path.to_path_buf());
311    };
312
313    // Verify path is within base directory
314    if !canonical_path.starts_with(&canonical_base) {
315        bail!(
316            "Security error: path {} is outside base directory {}",
317            canonical_path.display(),
318            canonical_base.display()
319        );
320    }
321
322    Ok(canonical_path)
323}
324
325/// Validate output path for path traversal attacks.
326///
327/// # Arguments
328///
329/// * `path` - Output path to validate
330///
331/// # Errors
332///
333/// Returns error if path contains traversal components (`..`).
334fn validate_output_path(path: &Path) -> Result<()> {
335    if has_path_traversal(path) {
336        bail!(
337            "Invalid output path (path traversal detected): {}",
338            path.display()
339        );
340    }
341    Ok(())
342}
343
344/// Check if path contains traversal components.
345///
346/// Uses path component analysis instead of string matching for robustness.
347fn has_path_traversal(path: &Path) -> bool {
348    use std::path::Component;
349    path.components().any(|c| matches!(c, Component::ParentDir))
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use mcp_execution_core::metadata::{
356        METADATA_FILE_NAME, METADATA_SCHEMA_VERSION, ParameterMetadata, ServerMetadata,
357        ToolMetadata,
358    };
359    use tempfile::TempDir;
360
361    /// Writes a minimal `_meta.json` sidecar with a single tool into `server_dir`,
362    /// matching what `mcp-execution-codegen` would emit for a generated server.
363    ///
364    /// Also writes a matching stub `{typescript_name}.ts` file, since
365    /// `scan_tools_directory` cross-checks the sidecar against files on disk.
366    fn write_meta_sidecar(server_dir: &Path, server_id: &str, tool_name: &str) {
367        let meta = ServerMetadata {
368            schema_version: METADATA_SCHEMA_VERSION,
369            server_id: server_id.to_string(),
370            server_name: server_id.to_string(),
371            server_version: "1.0.0".to_string(),
372            tools: vec![ToolMetadata {
373                name: tool_name.to_string(),
374                typescript_name: tool_name.to_string(),
375                category: Some("testing".to_string()),
376                keywords: vec!["test".to_string()],
377                description: Some(format!("Test tool: {tool_name}")),
378                parameters: vec![ParameterMetadata {
379                    name: "input".to_string(),
380                    typescript_type: "string".to_string(),
381                    required: true,
382                    description: Some("Test input".to_string()),
383                }],
384            }],
385        };
386
387        let content = serde_json::to_string_pretty(&meta).unwrap();
388        std::fs::write(server_dir.join(METADATA_FILE_NAME), content).unwrap();
389        std::fs::write(server_dir.join(format!("{tool_name}.ts")), "export {}").unwrap();
390    }
391
392    #[test]
393    fn test_resolve_servers_dir_default() {
394        let result = resolve_servers_dir(None);
395        assert!(result.is_ok());
396        let path = result.unwrap();
397        assert!(path.to_string_lossy().contains(".claude/servers"));
398    }
399
400    #[test]
401    fn test_resolve_servers_dir_custom() {
402        let custom = PathBuf::from("/custom/servers");
403        let result = resolve_servers_dir(Some(&custom));
404        assert!(result.is_ok());
405        assert_eq!(result.unwrap(), custom);
406    }
407
408    #[test]
409    fn test_resolve_servers_dir_tilde() {
410        let custom = PathBuf::from("~/custom/servers");
411        let result = resolve_servers_dir(Some(&custom));
412        assert!(result.is_ok());
413        let path = result.unwrap();
414        // Should expand ~ to home directory
415        assert!(!path.to_string_lossy().starts_with('~'));
416        assert!(path.to_string_lossy().contains("custom/servers"));
417    }
418
419    #[test]
420    fn test_validate_path_security_valid() {
421        let temp = TempDir::new().unwrap();
422        let base = temp.path();
423        let subdir = base.join("server");
424        std::fs::create_dir(&subdir).unwrap();
425
426        let result = validate_path_security(&subdir, base);
427        assert!(result.is_ok());
428    }
429
430    #[test]
431    fn test_validate_path_security_traversal() {
432        let temp = TempDir::new().unwrap();
433        let base = temp.path();
434        let evil_path = base.join("..").join("etc").join("passwd");
435
436        let result = validate_path_security(&evil_path, base);
437        assert!(result.is_err());
438        assert!(result.unwrap_err().to_string().contains("traversal"));
439    }
440
441    #[test]
442    fn test_validate_path_security_nonexistent() {
443        let temp = TempDir::new().unwrap();
444        let base = temp.path();
445        let new_path = base.join("new-server");
446
447        // Non-existent paths without .. should be allowed
448        let result = validate_path_security(&new_path, base);
449        assert!(result.is_ok());
450    }
451
452    #[test]
453    fn test_resolve_skills_dir() {
454        let result = resolve_skills_dir();
455        assert!(result.is_ok());
456        let path = result.unwrap();
457        assert!(path.to_string_lossy().contains(".claude/skills"));
458    }
459
460    #[test]
461    fn test_has_path_traversal() {
462        // Should detect traversal
463        assert!(has_path_traversal(Path::new("../etc/passwd")));
464        assert!(has_path_traversal(Path::new("/tmp/../etc/passwd")));
465        assert!(has_path_traversal(Path::new("foo/../../bar")));
466
467        // Should not flag valid paths
468        assert!(!has_path_traversal(Path::new("/etc/passwd")));
469        assert!(!has_path_traversal(Path::new("foo/bar/baz")));
470        assert!(!has_path_traversal(Path::new("./foo/bar")));
471        assert!(!has_path_traversal(Path::new("...")));
472        assert!(!has_path_traversal(Path::new("..foo")));
473    }
474
475    #[test]
476    fn test_validate_output_path_valid() {
477        assert!(validate_output_path(Path::new("/tmp/skill.md")).is_ok());
478        assert!(validate_output_path(Path::new("~/.claude/skills/github/SKILL.md")).is_ok());
479        assert!(validate_output_path(Path::new("./output.md")).is_ok());
480    }
481
482    #[test]
483    fn test_validate_output_path_traversal() {
484        let result = validate_output_path(Path::new("../../../etc/passwd"));
485        assert!(result.is_err());
486        assert!(result.unwrap_err().to_string().contains("path traversal"));
487
488        let result = validate_output_path(Path::new("/tmp/../etc/passwd"));
489        assert!(result.is_err());
490    }
491
492    #[tokio::test]
493    async fn test_run_output_path_traversal() {
494        let temp = TempDir::new().unwrap();
495        let server_dir = temp.path().join("github");
496        std::fs::create_dir(&server_dir).unwrap();
497        write_meta_sidecar(&server_dir, "github", "test");
498
499        // Try to use path traversal in output path
500        let evil_output = temp
501            .path()
502            .join("..")
503            .join("..")
504            .join("etc")
505            .join("evil.md");
506
507        let result = run(
508            "github".to_string(),
509            Some(temp.path().to_path_buf()),
510            Some(evil_output),
511            None,
512            vec![],
513            false,
514            OutputFormat::Json,
515        )
516        .await;
517
518        assert!(result.is_err());
519        assert!(result.unwrap_err().to_string().contains("path traversal"));
520    }
521
522    #[tokio::test]
523    async fn test_run_invalid_server_id() {
524        let result = run(
525            "INVALID_ID".to_string(), // uppercase not allowed
526            None,
527            None,
528            None,
529            vec![],
530            false,
531            OutputFormat::Json,
532        )
533        .await;
534
535        assert!(result.is_err());
536        assert!(
537            result
538                .unwrap_err()
539                .to_string()
540                .contains("Invalid server ID")
541        );
542    }
543
544    #[tokio::test]
545    async fn test_run_server_not_found() {
546        let temp = TempDir::new().unwrap();
547        let result = run(
548            "nonexistent-server".to_string(),
549            Some(temp.path().to_path_buf()),
550            None,
551            None,
552            vec![],
553            false,
554            OutputFormat::Json,
555        )
556        .await;
557
558        assert!(result.is_err());
559        assert!(
560            result
561                .unwrap_err()
562                .to_string()
563                .contains("Server directory not found")
564        );
565    }
566
567    #[tokio::test]
568    async fn test_run_no_typescript_files() {
569        let temp = TempDir::new().unwrap();
570        let server_dir = temp.path().join("empty-server");
571        std::fs::create_dir(&server_dir).unwrap();
572
573        // No `_meta.json` sidecar: the directory exists but was never generated
574        // (or predates the sidecar), so scanning must hard-error.
575        let result = run(
576            "empty-server".to_string(),
577            Some(temp.path().to_path_buf()),
578            None,
579            None,
580            vec![],
581            false,
582            OutputFormat::Json,
583        )
584        .await;
585
586        assert!(result.is_err());
587        assert!(
588            result
589                .unwrap_err()
590                .to_string()
591                .contains("Failed to scan tools directory")
592        );
593    }
594
595    #[tokio::test]
596    async fn test_run_with_valid_typescript_files() {
597        let temp = TempDir::new().unwrap();
598        let server_dir = temp.path().join("test-server");
599        std::fs::create_dir(&server_dir).unwrap();
600        write_meta_sidecar(&server_dir, "test-server", "test_tool");
601
602        let output_path = temp.path().join("SKILL.md");
603
604        let result = run(
605            "test-server".to_string(),
606            Some(temp.path().to_path_buf()),
607            Some(output_path.clone()),
608            None,
609            vec![],
610            false,
611            OutputFormat::Json,
612        )
613        .await;
614
615        assert!(
616            result.is_ok(),
617            "Expected success but got: {:?}",
618            result.err()
619        );
620        assert!(output_path.exists(), "SKILL.md must be written to disk");
621        let content = std::fs::read_to_string(&output_path).unwrap();
622        assert!(
623            content.starts_with("---\n"),
624            "SKILL.md must start with YAML frontmatter"
625        );
626    }
627
628    #[tokio::test]
629    async fn test_run_with_orphan_ts_file_succeeds() {
630        // Issue #161: a `.ts` file not referenced by `_meta.json` remains
631        // non-fatal (unlike a missing file, which is `ScanError::StaleMetadata`)
632        // — `run` must still succeed and write SKILL.md.
633        let temp = TempDir::new().unwrap();
634        let server_dir = temp.path().join("test-server");
635        std::fs::create_dir(&server_dir).unwrap();
636        write_meta_sidecar(&server_dir, "test-server", "test_tool");
637        std::fs::write(server_dir.join("orphanTool.ts"), "export {}").unwrap();
638
639        let output_path = temp.path().join("SKILL.md");
640
641        let result = run(
642            "test-server".to_string(),
643            Some(temp.path().to_path_buf()),
644            Some(output_path.clone()),
645            None,
646            vec![],
647            false,
648            OutputFormat::Json,
649        )
650        .await;
651
652        assert!(
653            result.is_ok(),
654            "an orphaned .ts file must not fail the run: {:?}",
655            result.err()
656        );
657        assert!(output_path.exists(), "SKILL.md must still be written");
658    }
659
660    #[test]
661    fn test_skill_write_result_json_includes_warnings() {
662        // Issue #161: the JSON output must name any excluded `.ts` file so a
663        // caller relying only on `--format json` can detect the drift, since
664        // it is no longer visible only via `tracing::warn!`.
665        let result = SkillWriteResult {
666            success: true,
667            output_path: "/tmp/SKILL.md".to_string(),
668            bytes_written: 42,
669            tool_count: 1,
670            warnings: vec![
671                "'orphanTool.ts' is not referenced by _meta.json and was excluded from SKILL.md \
672                 (re-run 'generate' to refresh the sidecar)"
673                    .to_string(),
674            ],
675        };
676
677        let output = format_output(&result, OutputFormat::Json).unwrap();
678
679        assert!(
680            output.contains("\"warnings\""),
681            "JSON output must contain a warnings field: {output}"
682        );
683        assert!(
684            output.contains("orphanTool.ts"),
685            "warnings must name the excluded file: {output}"
686        );
687    }
688
689    #[tokio::test]
690    async fn test_run_with_custom_skill_name() {
691        let temp = TempDir::new().unwrap();
692        let server_dir = temp.path().join("github");
693        std::fs::create_dir(&server_dir).unwrap();
694        write_meta_sidecar(&server_dir, "github", "create_issue");
695
696        // Use custom output path to avoid conflicts with real files
697        let output_path = temp.path().join("SKILL.md");
698
699        let result = run(
700            "github".to_string(),
701            Some(temp.path().to_path_buf()),
702            Some(output_path),
703            Some("github-advanced".to_string()),
704            vec![],
705            false,
706            OutputFormat::Json,
707        )
708        .await;
709
710        assert!(
711            result.is_ok(),
712            "Expected success but got: {:?}",
713            result.err()
714        );
715    }
716
717    #[tokio::test]
718    async fn test_run_with_hints() {
719        let temp = TempDir::new().unwrap();
720        let server_dir = temp.path().join("github");
721        std::fs::create_dir(&server_dir).unwrap();
722        write_meta_sidecar(&server_dir, "github", "list_prs");
723
724        // Use custom output path to avoid conflicts with real files
725        let output_path = temp.path().join("SKILL.md");
726
727        let result = run(
728            "github".to_string(),
729            Some(temp.path().to_path_buf()),
730            Some(output_path),
731            None,
732            vec!["code review".to_string(), "CI/CD".to_string()],
733            false,
734            OutputFormat::Json,
735        )
736        .await;
737
738        assert!(
739            result.is_ok(),
740            "Expected success but got: {:?}",
741            result.err()
742        );
743    }
744
745    #[tokio::test]
746    async fn test_run_output_exists_no_overwrite() {
747        let temp = TempDir::new().unwrap();
748        let server_dir = temp.path().join("github");
749        std::fs::create_dir(&server_dir).unwrap();
750        write_meta_sidecar(&server_dir, "github", "test");
751
752        // Create existing output file
753        let output_path = temp.path().join("SKILL.md");
754        std::fs::write(&output_path, "existing content").unwrap();
755
756        let result = run(
757            "github".to_string(),
758            Some(temp.path().to_path_buf()),
759            Some(output_path),
760            None,
761            vec![],
762            false, // no overwrite
763            OutputFormat::Json,
764        )
765        .await;
766
767        assert!(result.is_err());
768        assert!(result.unwrap_err().to_string().contains("already exists"));
769    }
770
771    #[tokio::test]
772    async fn test_run_output_exists_with_overwrite() {
773        let temp = TempDir::new().unwrap();
774        let server_dir = temp.path().join("github");
775        std::fs::create_dir(&server_dir).unwrap();
776        write_meta_sidecar(&server_dir, "github", "test");
777
778        // Create existing output file
779        let output_path = temp.path().join("SKILL.md");
780        std::fs::write(&output_path, "existing content").unwrap();
781
782        let result = run(
783            "github".to_string(),
784            Some(temp.path().to_path_buf()),
785            Some(output_path),
786            None,
787            vec![],
788            true, // overwrite
789            OutputFormat::Json,
790        )
791        .await;
792
793        assert!(
794            result.is_ok(),
795            "Expected success but got: {:?}",
796            result.err()
797        );
798    }
799
800    #[tokio::test]
801    async fn test_run_all_output_formats() {
802        let temp = TempDir::new().unwrap();
803        let server_dir = temp.path().join("test");
804        std::fs::create_dir(&server_dir).unwrap();
805        write_meta_sidecar(&server_dir, "test", "test");
806
807        for format in [OutputFormat::Json, OutputFormat::Text, OutputFormat::Pretty] {
808            let output_path = temp.path().join(format!("SKILL-{format}.md"));
809            let result = run(
810                "test".to_string(),
811                Some(temp.path().to_path_buf()),
812                Some(output_path),
813                None,
814                vec![],
815                false,
816                format,
817            )
818            .await;
819
820            assert!(
821                result.is_ok(),
822                "Format {:?} should succeed: {:?}",
823                format,
824                result.err()
825            );
826        }
827    }
828
829    #[tokio::test]
830    async fn test_run_stale_metadata_fails_instead_of_silently_succeeding() {
831        // Issue #154 repro: a `_meta.json` sidecar that has drifted from the
832        // `.ts` files on disk (one entry's file was deleted, an unrelated file
833        // was added) must now make `skill` fail loudly instead of silently
834        // generating a SKILL.md with stale/missing tool references.
835        let temp = TempDir::new().unwrap();
836        let server_dir = temp.path().join("github");
837        std::fs::create_dir(&server_dir).unwrap();
838
839        let meta = ServerMetadata {
840            schema_version: METADATA_SCHEMA_VERSION,
841            server_id: "github".to_string(),
842            server_name: "GitHub".to_string(),
843            server_version: "1.0.0".to_string(),
844            tools: vec![
845                ToolMetadata {
846                    name: "create_issue".to_string(),
847                    typescript_name: "createIssue".to_string(),
848                    category: Some("issues".to_string()),
849                    keywords: vec!["create".to_string()],
850                    description: Some("Create an issue".to_string()),
851                    parameters: vec![ParameterMetadata {
852                        name: "title".to_string(),
853                        typescript_type: "string".to_string(),
854                        required: true,
855                        description: Some("Issue title".to_string()),
856                    }],
857                },
858                ToolMetadata {
859                    name: "list_repos".to_string(),
860                    typescript_name: "listRepos".to_string(),
861                    category: Some("repos".to_string()),
862                    keywords: vec!["list".to_string()],
863                    description: Some("List repos".to_string()),
864                    parameters: vec![],
865                },
866            ],
867        };
868        let content = serde_json::to_string_pretty(&meta).unwrap();
869        std::fs::write(server_dir.join(METADATA_FILE_NAME), content).unwrap();
870
871        // Generate as normal for `list_repos`, but simulate a `.ts` file that
872        // was deleted (or never written, e.g. an interrupted `generate`) for
873        // `create_issue` — this is the drift the sidecar must now catch.
874        std::fs::write(server_dir.join("listRepos.ts"), "export {}").unwrap();
875        // An unrelated `.ts` file left over on disk, not referenced by the
876        // sidecar at all — must not mask the missing-file error above.
877        std::fs::write(server_dir.join("orphanTool.ts"), "export {}").unwrap();
878
879        let output_path = temp.path().join("SKILL.md");
880
881        let result = run(
882            "github".to_string(),
883            Some(temp.path().to_path_buf()),
884            Some(output_path.clone()),
885            None,
886            vec![],
887            false,
888            OutputFormat::Json,
889        )
890        .await;
891
892        assert!(
893            result.is_err(),
894            "drifted sidecar must fail instead of silently succeeding"
895        );
896        let err = result.unwrap_err();
897        // `anyhow::Error`'s `Display` only shows the outer context; the full
898        // chain (including the `ScanError::StaleMetadata` source) is in `{err:?}`.
899        let message = format!("{err:?}");
900        assert!(
901            message.contains("create_issue") || message.contains("createIssue.ts"),
902            "error must identify the tool/file with the missing .ts: {message}"
903        );
904        assert!(
905            !output_path.exists(),
906            "SKILL.md must not be written when the sidecar is stale"
907        );
908    }
909
910    #[tokio::test]
911    async fn test_run_path_traversal_server_id() {
912        let temp = TempDir::new().unwrap();
913
914        // Server ID validation should reject path traversal attempts
915        let result = run(
916            "../etc".to_string(),
917            Some(temp.path().to_path_buf()),
918            None,
919            None,
920            vec![],
921            false,
922            OutputFormat::Json,
923        )
924        .await;
925
926        assert!(result.is_err());
927        // Should fail at server ID validation (contains invalid chars)
928        assert!(
929            result
930                .unwrap_err()
931                .to_string()
932                .contains("Invalid server ID")
933        );
934    }
935}