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