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, validate_skill_name,
16};
17use serde::Serialize;
18use std::path::{Path, PathBuf};
19use tracing::{debug, info};
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/// # Side Effects
73///
74/// When no custom `--output` is given, resolving the confined default path creates and
75/// confines the `{server}/` segment directory under the skills directory *before* the
76/// `--overwrite` check and before rendering - unavoidably, since that resolution is what the
77/// `--overwrite` check itself runs against. A refused (existing file, no `--overwrite`) or
78/// otherwise failed run can therefore leave an empty `{server}/` directory behind - the same
79/// side effect `save_skill`'s own default-path resolution has. A custom `--output` path has no
80/// such side effect: its parent directory is created only after the `--overwrite` gate and
81/// rendering succeed, matching this command's behavior before issue #501.
82///
83/// # Examples
84///
85/// ```no_run
86/// use mcp_execution_cli::commands::skill;
87/// use mcp_execution_core::cli::OutputFormat;
88///
89/// # async fn example() -> anyhow::Result<()> {
90/// // Generate skill for GitHub server
91/// let exit_code = skill::run(
92///     "github".to_string(),
93///     None,
94///     None,
95///     None,
96///     vec![],
97///     false,
98///     OutputFormat::Json
99/// ).await?;
100/// # Ok(())
101/// # }
102/// ```
103// One argument per CLI flag; clap already destructures flags for us, and grouping them into a
104// struct would only benefit this function, not caller ergonomics.
105pub async fn run(
106    server: String,
107    servers_dir: Option<PathBuf>,
108    output_path: Option<PathBuf>,
109    skill_name: Option<String>,
110    hints: Vec<String>,
111    overwrite: bool,
112    output_format: OutputFormat,
113) -> Result<ExitCode> {
114    debug!("Generating skill for server: {}", server);
115    debug!("Servers directory: {:?}", servers_dir);
116    debug!("Output path: {:?}", output_path);
117    debug!("Skill name: {:?}", skill_name);
118    debug!("Hints: {:?}", hints);
119    debug!("Overwrite: {}", overwrite);
120    debug!("Output format: {}", output_format);
121
122    // Step 1: Validate server ID
123    // A malformed `server` id is a CLI-argument mistake, so this is wrapped
124    // as `CoreError::InvalidArgument` (rather than a bare anyhow string) to
125    // classify as `ExitCode::INVALID_INPUT` in `runner::classify_exit_code`.
126    validate_server_id(&server)
127        .map_err(|e| CoreError::InvalidArgument(format!("Invalid server ID: {e}")))?;
128    info!("Server ID validated: {}", server);
129
130    let tool_dir = resolve_tool_dir(&server, servers_dir.as_deref())?;
131
132    let scan_result = scan_server_tools(&tool_dir, &server).await?;
133
134    let (context, custom_output_path) = prepare_skill_context(
135        &server,
136        &scan_result.tools,
137        hints,
138        skill_name.as_deref(),
139        output_path,
140    )?;
141
142    // See this function's own doc comment (`# Side Effects`) for why the default branch below
143    // creates `{server}/` this early, and why the custom branch's own directory creation is
144    // deferred past the `--overwrite` gate instead of happening here.
145    let had_custom_output_path = custom_output_path.is_some();
146    let output_path = if let Some(path) = custom_output_path {
147        // A custom `--output` path is a CLI operator's own flag, evaluated with the operator's
148        // own filesystem permissions - confining it would not defend against anything a symlink
149        // swapped in by a *racing local process* could not already do to any path this process
150        // touches, so it keeps its existing narrower, traversal-only validation
151        // (`validate_output_path`) instead of the confined walk below.
152        path
153    } else {
154        // No `--output` override: confine and create the default `{server}` segment directory
155        // the same way `save_skill`'s own default path is confined, rejecting it outright if it
156        // already exists as a symlink - `write_skill_md`'s O_NOFOLLOW guard on its temp file
157        // only protects that file's own terminal component, not an ancestor directory planted
158        // as a symlink ahead of time (issue #501). See `resolve_default_output_path`'s own doc
159        // comment for why this leaves the terminal `SKILL.md` component's pre-existing-symlink
160        // case untouched.
161        let skills_dir = resolve_skills_dir()?;
162        resolve_default_output_path(&skills_dir, &server).await?
163    };
164
165    // Check if output file exists and overwrite flag
166    if output_path.exists() && !overwrite {
167        bail!(
168            "Output file already exists: {}\n\
169             Use --overwrite to replace existing file.",
170            output_path.display()
171        );
172    }
173
174    // Step 7: Render SKILL.md and write atomically.
175    let rendered = render_skill_md(&context).context("failed to render SKILL.md template")?;
176
177    // A custom `--output` path's parent directory is created only now, after the `--overwrite`
178    // gate and rendering above - matching this branch's pre-#501 behavior, where directory
179    // creation lived inside `write_skill_md` and so ran after both. Unlike the default branch,
180    // nothing here needs to run earlier: `validate_output_path` already ran in
181    // `prepare_skill_context`, and `output_path.exists()` above returns `false` regardless of
182    // whether the parent exists yet, so deferring this creates no gate-ordering hazard.
183    if had_custom_output_path && let Some(parent) = output_path.parent() {
184        tokio::fs::create_dir_all(parent)
185            .await
186            .with_context(|| format!("failed to create directory: {}", parent.display()))?;
187    }
188
189    write_skill_md(&rendered, &output_path).await?;
190
191    let bytes_written = rendered.len();
192    info!(
193        "SKILL.md written to {} ({} bytes, {} tools)",
194        output_path.display(),
195        bytes_written,
196        context.tool_count,
197    );
198
199    // Two independent, additive warning sources (issue #473): scan-time drift
200    // (`scan_result.warnings`) and `use_case_hints` sanitization warnings already carried on
201    // `context.warnings` (populated by `build_skill_context`, see its doc comment). Neither
202    // overwrites the other.
203    let mut warnings = scan_result.warnings;
204    warnings.extend(context.warnings.iter().cloned());
205
206    let result = SkillWriteResult {
207        success: true,
208        output_path: output_path.display().to_string(),
209        bytes_written,
210        tool_count: context.tool_count,
211        warnings,
212    };
213
214    crate::formatters::emit(&result, output_format, ExitCode::SUCCESS)
215}
216
217/// Resolves and validates the server's tool directory under `servers_dir` (or its default).
218///
219/// # Errors
220///
221/// Returns an error if the home directory cannot be determined, the resolved path escapes
222/// its base via a symlink, or the server directory does not exist.
223fn resolve_tool_dir(server: &str, servers_dir: Option<&Path>) -> Result<PathBuf> {
224    // Step 2: Resolve servers directory
225    let servers_base = resolve_servers_dir(servers_dir)?;
226    debug!("Servers base directory: {}", servers_base.display());
227
228    // Step 3: Build and validate server path
229    let tool_dir = servers_base.join(server);
230    let tool_dir = validate_path_security(&tool_dir, &servers_base)?;
231    debug!("Server directory: {}", tool_dir.display());
232
233    // Step 4: Check server directory exists
234    if !tool_dir.exists() {
235        bail!(
236            "Server directory not found: {}\n\
237             Run 'mcp-execution-cli generate --from-config {}' first to generate TypeScript files.",
238            tool_dir.display(),
239            server
240        );
241    }
242
243    Ok(tool_dir)
244}
245
246/// Scans `tool_dir` for generated TypeScript tool files.
247///
248/// # Errors
249///
250/// Returns an error if the directory cannot be scanned or no tool files are found.
251async fn scan_server_tools(tool_dir: &Path, server: &str) -> Result<ScanResult> {
252    // Step 5: Scan TypeScript files
253    info!("Scanning TypeScript files in {}", tool_dir.display());
254    let scan_result = scan_tools_directory(tool_dir)
255        .await
256        .context("Failed to scan tools directory")?;
257
258    if scan_result.tools.is_empty() {
259        bail!(
260            "No TypeScript tool files found in {}\n\
261             Run 'mcp-execution-cli generate --from-config {}' first.",
262            tool_dir.display(),
263            server
264        );
265    }
266
267    // `tools.len()` reflects sidecar entries that were cross-checked against an
268    // actual `.ts` file on disk by `scan_tools_directory` (issue #154) — not a
269    // raw sidecar entry count.
270    info!(
271        "Verified {} tool files against sidecar",
272        scan_result.tools.len()
273    );
274
275    Ok(scan_result)
276}
277
278/// Builds the skill generation context and validates a custom `--output` path, if the caller
279/// supplied one.
280///
281/// Returns `Some(path)` only when `output_path` was supplied - traversal-validated, but
282/// otherwise returned unchanged. `None` means the caller wants the default path; resolving that
283/// confined default requires an async filesystem walk (`resolve_default_output_path`) this
284/// function cannot perform itself, since it stays synchronous for its own unit tests, so `run`
285/// resolves it separately instead of this function pre-computing a plain, unconfined join.
286///
287/// The returned path is kept separate from `GenerateSkillResult` rather than written back into
288/// its `default_output_path_hint` field: that field is a non-authoritative display hint
289/// `build_skill_context` computes (see its doc comment), not a slot for this command's actual
290/// write target — overwriting it here would resurrect the same field-reuse-across-semantics
291/// pattern issue #436 eliminated from the MCP tool pair.
292///
293/// # Errors
294///
295/// Returns an error if a custom `output_path` fails traversal validation.
296fn prepare_skill_context(
297    server: &str,
298    tools: &[ParsedToolFile],
299    hints: Vec<String>,
300    skill_name: Option<&str>,
301    output_path: Option<PathBuf>,
302) -> Result<(GenerateSkillResult, Option<PathBuf>)> {
303    // Step 6: Build skill context. Custom skill name is validated up front (same pattern as
304    // `validate_server_id` above) and passed into `build_skill_context` itself — not applied as
305    // a post-hoc override — so an oversized name fails fast here instead of being rendered and
306    // written to disk only for `extract_skill_metadata` to reject it later (issue #413), and so
307    // the name is consistently reflected in `generation_prompt` as well as `skill_name` (issue
308    // #435).
309    let hints_ref: Option<Vec<String>> = if hints.is_empty() { None } else { Some(hints) };
310
311    if let Some(name) = skill_name {
312        validate_skill_name(name)
313            .map_err(|e| CoreError::InvalidArgument(format!("Invalid skill name: {e}")))?;
314    }
315
316    let context = build_skill_context(server, tools, hints_ref.as_deref(), skill_name);
317
318    if let Some(path) = &output_path {
319        validate_output_path(path)?;
320    }
321
322    Ok((context, output_path))
323}
324
325/// Writes `rendered` SKILL.md content to `output_path` atomically (write-temp then rename).
326///
327/// The caller is responsible for `output_path`'s parent directory already existing: the two call
328/// sites create it themselves, with different guarantees (`resolve_default_output_path`'s
329/// confined walk for the default path, a plain `create_dir_all` for a custom `--output` path) -
330/// duplicating that step here would either weaken the default path's confinement or run it
331/// twice.
332///
333/// # Errors
334///
335/// Returns an error if the temp file cannot be written or the rename fails.
336async fn write_skill_md(rendered: &str, output_path: &Path) -> Result<()> {
337    // Atomic write: the temp file itself is written through `write_confined_file`, which opens
338    // it with `O_NOFOLLOW` on Unix, so a symlink pre-planted at the predictable `.tmp` path is
339    // rejected instead of followed (issue #501) - the same primitive `save_skill` uses for the
340    // equivalent race on its own write step (issue #496). The final `std::fs::rename` needs no
341    // equivalent guard: it replaces whatever directory entry is at `output_path` rather than
342    // following it, so a pre-existing symlink at `output_path` itself (e.g. a dotfiles setup
343    // symlinking `SKILL.md` into a repo) is safely replaced rather than followed or rejected -
344    // matching this function's pre-#501 behavior for the final path, on both the default and a
345    // custom `--output` path.
346    let tmp_path = output_path.with_added_extension("tmp");
347    mcp_execution_core::write_confined_file(&tmp_path, rendered.as_bytes())
348        .await
349        .with_context(|| format!("failed to write temp file: {}", tmp_path.display()))?;
350    std::fs::rename(&tmp_path, output_path)
351        .with_context(|| format!("failed to rename to: {}", output_path.display()))?;
352
353    Ok(())
354}
355
356/// Resolves and confines the default `SKILL.md` output path under `skills_dir`
357/// (`skills_dir/{server}/SKILL.md`), creating and confining the `{server}` segment directory the
358/// same way `save_skill`'s own default path is confined via `resolve_skill_output_path` -
359/// rejecting it outright if it already exists as a symlink, regardless of where it points (issue
360/// #217/#501). Deliberately does *not* use `resolve_skill_output_path` itself: that helper also
361/// confinement-checks the terminal `SKILL.md` component and rejects it outright if it is already
362/// a symlink, which would break the existing `skill --overwrite` dotfiles pattern (symlinking
363/// `SKILL.md` into a repo) that `write_skill_md`'s `rename` already replaces safely. Calling
364/// `resolve_confined_path` directly with `target: None` confines and creates only the segment
365/// directory, leaving the terminal component's pre-existing-symlink case exactly as it was
366/// before this function existed.
367///
368/// Only used when the caller did not supply a custom `--output` path; a custom path keeps its
369/// existing narrower, traversal-only validation (`validate_output_path`) instead - see `run`'s
370/// call site for why confining it would not add anything.
371///
372/// # Errors
373///
374/// Returns an error if the resolved path escapes `skills_dir` - including via a pre-existing
375/// symlink at the server's own segment directory.
376async fn resolve_default_output_path(skills_dir: &Path, server: &str) -> Result<PathBuf> {
377    // `server` is already validated by `validate_server_id` (a `validate_server_id_slug`
378    // passthrough) at `run`'s entry, before this is ever reached, so `resolve_confined_path`'s
379    // own structural `validate_path_segment` check below is the only validation this call gets -
380    // intentionally: re-running `validate_server_id_slug` here would just re-check what already
381    // passed.
382    let segment_dir =
383        mcp_execution_core::resolve_confined_path(skills_dir, server, Path::new(""), None)
384            .await
385            .with_context(|| {
386                format!("failed to resolve default skills directory for server: {server}")
387            })?;
388
389    Ok(segment_dir.join("SKILL.md"))
390}
391
392/// Resolve servers directory from provided path or default.
393///
394/// # Arguments
395///
396/// * `servers_dir` - Optional custom servers directory
397///
398/// # Returns
399///
400/// Resolved path to servers directory.
401///
402/// # Errors
403///
404/// Returns error if home directory cannot be determined.
405fn resolve_servers_dir(servers_dir: Option<&Path>) -> Result<PathBuf> {
406    if let Some(dir) = servers_dir {
407        // Use provided path, expand ~ if needed
408        if let Some(stripped) = dir.to_str().and_then(|s| s.strip_prefix("~/")) {
409            let home = dirs::home_dir().context("Could not determine home directory")?;
410            Ok(home.join(stripped))
411        } else {
412            Ok(dir.to_path_buf())
413        }
414    } else {
415        // Use default: ~/.claude/servers
416        let home = dirs::home_dir().context("Could not determine home directory")?;
417        Ok(home.join(DEFAULT_SERVERS_DIR))
418    }
419}
420
421/// Resolve skills directory (default: ~/.claude/skills).
422///
423/// # Returns
424///
425/// Resolved path to skills directory.
426///
427/// # Errors
428///
429/// Returns error if home directory cannot be determined.
430fn resolve_skills_dir() -> Result<PathBuf> {
431    let home = dirs::home_dir().context("Could not determine home directory")?;
432    Ok(home.join(DEFAULT_SKILLS_DIR))
433}
434
435/// Validate path security to prevent path traversal attacks.
436///
437/// Ensures the resolved path is within the expected base directory.
438///
439/// # Arguments
440///
441/// * `path` - Path to validate
442/// * `base` - Expected base directory
443///
444/// # Returns
445///
446/// Canonicalized path if valid.
447///
448/// # Errors
449///
450/// Returns error if:
451/// - Path cannot be canonicalized
452/// - Path is outside the base directory (symlink escape)
453fn validate_path_security(path: &Path, base: &Path) -> Result<PathBuf> {
454    // Check for path traversal in components (more robust than string check).
455    // A traversal attempt is a malicious/invalid argument, so it is wrapped
456    // as `CoreError::SecurityViolation` to classify as
457    // `ExitCode::INVALID_INPUT` in `runner::classify_exit_code`.
458    if has_path_traversal(path) {
459        return Err(CoreError::SecurityViolation {
460            reason: format!("path traversal detected: {}", path.display()),
461        }
462        .into());
463    }
464
465    // If the path doesn't exist yet, validation passed
466    if !path.exists() {
467        return Ok(path.to_path_buf());
468    }
469
470    // Canonicalize to resolve symlinks
471    let canonical_path = path
472        .canonicalize()
473        .with_context(|| format!("Failed to canonicalize path: {}", path.display()))?;
474
475    let canonical_base = if base.exists() {
476        base.canonicalize()
477            .with_context(|| format!("Failed to canonicalize base: {}", base.display()))?
478    } else {
479        // Base doesn't exist, path components already validated
480        return Ok(path.to_path_buf());
481    };
482
483    // Verify path is within base directory
484    if !canonical_path.starts_with(&canonical_base) {
485        return Err(CoreError::SecurityViolation {
486            reason: format!(
487                "path {} is outside base directory {}",
488                canonical_path.display(),
489                canonical_base.display()
490            ),
491        }
492        .into());
493    }
494
495    Ok(canonical_path)
496}
497
498/// Validate output path for path traversal attacks.
499///
500/// This only rejects traversal escapes (`..`), not absolute paths — callers must ensure `path`
501/// originates from a trusted source (e.g. an interactive CLI operator's own flag), not
502/// agent/LLM-supplied input. This is a narrower contract than
503/// `mcp_execution_skill::output_path::relative_target`, which additionally rejects absolute
504/// paths because it confines the MCP-server-exposed `save_skill` tool, reachable from
505/// agent/LLM-supplied arguments.
506///
507/// # Arguments
508///
509/// * `path` - Output path to validate
510///
511/// # Errors
512///
513/// Returns error if path contains traversal components (`..`).
514fn validate_output_path(path: &Path) -> Result<()> {
515    if has_path_traversal(path) {
516        return Err(CoreError::SecurityViolation {
517            reason: format!(
518                "invalid output path (path traversal detected): {}",
519                path.display()
520            ),
521        }
522        .into());
523    }
524    Ok(())
525}
526
527/// Check if path contains traversal components.
528///
529/// Uses path component analysis instead of string matching for robustness.
530fn has_path_traversal(path: &Path) -> bool {
531    mcp_execution_core::contains_parent_dir(path)
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use crate::formatters::format_output;
538    use mcp_execution_core::metadata::{
539        METADATA_FILE_NAME, METADATA_SCHEMA_VERSION, ParameterMetadata, ServerMetadata,
540        ToolMetadata,
541    };
542    use mcp_execution_core::provenance::GenerationProvenance;
543    use mcp_execution_core::{ServerConfig, ServerId, ToolName};
544    use tempfile::TempDir;
545
546    fn test_provenance() -> GenerationProvenance {
547        let config = ServerConfig::builder()
548            .command("test-command".to_string())
549            .build()
550            .unwrap();
551        GenerationProvenance::capture(&config, &[])
552    }
553
554    /// Writes a minimal `_meta.json` sidecar with a single tool into `server_dir`,
555    /// matching what `mcp-execution-codegen` would emit for a generated server.
556    ///
557    /// Also writes a matching stub `{typescript_name}.ts` file, since
558    /// `scan_tools_directory` cross-checks the sidecar against files on disk.
559    fn write_meta_sidecar(server_dir: &Path, server_id: &str, tool_name: &str) {
560        let meta = ServerMetadata {
561            schema_version: METADATA_SCHEMA_VERSION,
562            server_id: ServerId::new(server_id).unwrap(),
563            server_name: server_id.to_string(),
564            server_version: "1.0.0".to_string(),
565            tools: vec![ToolMetadata {
566                name: ToolName::new(tool_name).unwrap(),
567                typescript_name: tool_name.to_string(),
568                category: Some("testing".to_string()),
569                keywords: vec!["test".to_string()],
570                description: Some(format!("Test tool: {tool_name}")),
571                parameters: vec![ParameterMetadata {
572                    name: "input".to_string(),
573                    typescript_type: "string".to_string(),
574                    required: true,
575                    description: Some("Test input".to_string()),
576                }],
577            }],
578            provenance: test_provenance(),
579        };
580
581        let content = serde_json::to_string_pretty(&meta).unwrap();
582        std::fs::write(server_dir.join(METADATA_FILE_NAME), content).unwrap();
583        std::fs::write(server_dir.join(format!("{tool_name}.ts")), "export {}").unwrap();
584    }
585
586    #[test]
587    fn test_resolve_servers_dir_default() {
588        let result = resolve_servers_dir(None);
589        assert!(result.is_ok());
590        let path = result.unwrap();
591        assert!(path.to_string_lossy().contains(".claude/servers"));
592    }
593
594    #[test]
595    fn test_resolve_servers_dir_custom() {
596        let custom = PathBuf::from("/custom/servers");
597        let result = resolve_servers_dir(Some(&custom));
598        assert!(result.is_ok());
599        assert_eq!(result.unwrap(), custom);
600    }
601
602    #[test]
603    fn test_resolve_servers_dir_tilde() {
604        let custom = PathBuf::from("~/custom/servers");
605        let result = resolve_servers_dir(Some(&custom));
606        assert!(result.is_ok());
607        let path = result.unwrap();
608        // Should expand ~ to home directory
609        assert!(!path.to_string_lossy().starts_with('~'));
610        assert!(path.to_string_lossy().contains("custom/servers"));
611    }
612
613    #[test]
614    fn test_validate_path_security_valid() {
615        let temp = TempDir::new().unwrap();
616        let base = temp.path();
617        let subdir = base.join("server");
618        std::fs::create_dir(&subdir).unwrap();
619
620        let result = validate_path_security(&subdir, base);
621        assert!(result.is_ok());
622    }
623
624    #[test]
625    fn test_validate_path_security_traversal() {
626        let temp = TempDir::new().unwrap();
627        let base = temp.path();
628        let evil_path = base.join("..").join("etc").join("passwd");
629
630        let result = validate_path_security(&evil_path, base);
631        assert!(result.is_err());
632        let err = result.unwrap_err();
633        assert!(err.to_string().contains("traversal"));
634        // Regression test for #195/S3: a path traversal attempt is a
635        // malicious/invalid argument, so it must carry a `CoreError` that
636        // `runner::classify_exit_code` maps to `ExitCode::INVALID_INPUT`.
637        assert!(matches!(
638            err.downcast_ref::<CoreError>(),
639            Some(CoreError::SecurityViolation { .. })
640        ));
641    }
642
643    #[test]
644    fn test_validate_path_security_nonexistent() {
645        let temp = TempDir::new().unwrap();
646        let base = temp.path();
647        let new_path = base.join("new-server");
648
649        // Non-existent paths without .. should be allowed
650        let result = validate_path_security(&new_path, base);
651        assert!(result.is_ok());
652    }
653
654    #[test]
655    fn test_resolve_skills_dir() {
656        let result = resolve_skills_dir();
657        assert!(result.is_ok());
658        let path = result.unwrap();
659        assert!(path.to_string_lossy().contains(".claude/skills"));
660    }
661
662    #[test]
663    fn test_has_path_traversal() {
664        // Should detect traversal
665        assert!(has_path_traversal(Path::new("../etc/passwd")));
666        assert!(has_path_traversal(Path::new("/tmp/../etc/passwd")));
667        assert!(has_path_traversal(Path::new("foo/../../bar")));
668
669        // Should not flag valid paths
670        assert!(!has_path_traversal(Path::new("/etc/passwd")));
671        assert!(!has_path_traversal(Path::new("foo/bar/baz")));
672        assert!(!has_path_traversal(Path::new("./foo/bar")));
673        assert!(!has_path_traversal(Path::new("...")));
674        assert!(!has_path_traversal(Path::new("..foo")));
675    }
676
677    #[test]
678    fn test_validate_output_path_valid() {
679        assert!(validate_output_path(Path::new("/tmp/skill.md")).is_ok());
680        assert!(validate_output_path(Path::new("~/.claude/skills/github/SKILL.md")).is_ok());
681        assert!(validate_output_path(Path::new("./output.md")).is_ok());
682    }
683
684    #[test]
685    fn test_validate_output_path_traversal() {
686        let result = validate_output_path(Path::new("../../../etc/passwd"));
687        assert!(result.is_err());
688        assert!(result.unwrap_err().to_string().contains("path traversal"));
689
690        let result = validate_output_path(Path::new("/tmp/../etc/passwd"));
691        assert!(result.is_err());
692    }
693
694    #[tokio::test]
695    async fn test_run_output_path_traversal() {
696        let temp = TempDir::new().unwrap();
697        let server_dir = temp.path().join("github");
698        std::fs::create_dir(&server_dir).unwrap();
699        write_meta_sidecar(&server_dir, "github", "test");
700
701        // Try to use path traversal in output path
702        let evil_output = temp
703            .path()
704            .join("..")
705            .join("..")
706            .join("etc")
707            .join("evil.md");
708
709        let result = run(
710            "github".to_string(),
711            Some(temp.path().to_path_buf()),
712            Some(evil_output),
713            None,
714            vec![],
715            false,
716            OutputFormat::Json,
717        )
718        .await;
719
720        assert!(result.is_err());
721        assert!(result.unwrap_err().to_string().contains("path traversal"));
722    }
723
724    #[tokio::test]
725    async fn test_run_invalid_server_id() {
726        let result = run(
727            "INVALID_ID".to_string(), // uppercase not allowed
728            None,
729            None,
730            None,
731            vec![],
732            false,
733            OutputFormat::Json,
734        )
735        .await;
736
737        assert!(result.is_err());
738        let err = result.unwrap_err();
739        assert!(err.to_string().contains("Invalid server ID"));
740        // Regression test for #195/S3: an invalid `server` id is a
741        // CLI-argument mistake, so it must carry a `CoreError` that
742        // `runner::classify_exit_code` maps to `ExitCode::INVALID_INPUT`.
743        assert!(matches!(
744            err.downcast_ref::<CoreError>(),
745            Some(CoreError::InvalidArgument(_))
746        ));
747    }
748
749    #[tokio::test]
750    async fn test_run_server_not_found() {
751        let temp = TempDir::new().unwrap();
752        let result = run(
753            "nonexistent-server".to_string(),
754            Some(temp.path().to_path_buf()),
755            None,
756            None,
757            vec![],
758            false,
759            OutputFormat::Json,
760        )
761        .await;
762
763        assert!(result.is_err());
764        assert!(
765            result
766                .unwrap_err()
767                .to_string()
768                .contains("Server directory not found")
769        );
770    }
771
772    #[tokio::test]
773    async fn test_run_no_typescript_files() {
774        let temp = TempDir::new().unwrap();
775        let server_dir = temp.path().join("empty-server");
776        std::fs::create_dir(&server_dir).unwrap();
777
778        // No `_meta.json` sidecar: the directory exists but was never generated
779        // (or predates the sidecar), so scanning must hard-error.
780        let result = run(
781            "empty-server".to_string(),
782            Some(temp.path().to_path_buf()),
783            None,
784            None,
785            vec![],
786            false,
787            OutputFormat::Json,
788        )
789        .await;
790
791        assert!(result.is_err());
792        assert!(
793            result
794                .unwrap_err()
795                .to_string()
796                .contains("Failed to scan tools directory")
797        );
798    }
799
800    #[tokio::test]
801    async fn test_run_with_valid_typescript_files() {
802        let temp = TempDir::new().unwrap();
803        let server_dir = temp.path().join("test-server");
804        std::fs::create_dir(&server_dir).unwrap();
805        write_meta_sidecar(&server_dir, "test-server", "test_tool");
806
807        let output_path = temp.path().join("SKILL.md");
808
809        let result = run(
810            "test-server".to_string(),
811            Some(temp.path().to_path_buf()),
812            Some(output_path.clone()),
813            None,
814            vec![],
815            false,
816            OutputFormat::Json,
817        )
818        .await;
819
820        assert!(
821            result.is_ok(),
822            "Expected success but got: {:?}",
823            result.err()
824        );
825        assert!(output_path.exists(), "SKILL.md must be written to disk");
826        let content = std::fs::read_to_string(&output_path).unwrap();
827        assert!(
828            content.starts_with("---\n"),
829            "SKILL.md must start with YAML frontmatter"
830        );
831    }
832
833    /// A custom `--output` path whose parent directory does not exist yet must still have it
834    /// created - `run`'s custom-path branch owns this now that `write_skill_md` no longer calls
835    /// `create_dir_all` itself.
836    #[tokio::test]
837    async fn test_run_creates_nested_parent_directory_for_custom_output_path() {
838        let temp = TempDir::new().unwrap();
839        let server_dir = temp.path().join("test-server");
840        std::fs::create_dir(&server_dir).unwrap();
841        write_meta_sidecar(&server_dir, "test-server", "test_tool");
842
843        let output_path = temp.path().join("nested").join("dir").join("SKILL.md");
844        assert!(!output_path.parent().unwrap().exists());
845
846        let result = run(
847            "test-server".to_string(),
848            Some(temp.path().to_path_buf()),
849            Some(output_path.clone()),
850            None,
851            vec![],
852            false,
853            OutputFormat::Json,
854        )
855        .await;
856
857        assert!(
858            result.is_ok(),
859            "Expected success but got: {:?}",
860            result.err()
861        );
862        assert!(output_path.exists(), "SKILL.md must be written to disk");
863    }
864
865    #[tokio::test]
866    async fn test_run_with_orphan_ts_file_succeeds() {
867        // Issue #161: a `.ts` file not referenced by `_meta.json` remains
868        // non-fatal (unlike a missing file, which is `ScanError::StaleMetadata`)
869        // — `run` must still succeed and write SKILL.md.
870        let temp = TempDir::new().unwrap();
871        let server_dir = temp.path().join("test-server");
872        std::fs::create_dir(&server_dir).unwrap();
873        write_meta_sidecar(&server_dir, "test-server", "test_tool");
874        std::fs::write(server_dir.join("orphanTool.ts"), "export {}").unwrap();
875
876        let output_path = temp.path().join("SKILL.md");
877
878        let result = run(
879            "test-server".to_string(),
880            Some(temp.path().to_path_buf()),
881            Some(output_path.clone()),
882            None,
883            vec![],
884            false,
885            OutputFormat::Json,
886        )
887        .await;
888
889        assert!(
890            result.is_ok(),
891            "an orphaned .ts file must not fail the run: {:?}",
892            result.err()
893        );
894        assert!(output_path.exists(), "SKILL.md must still be written");
895    }
896
897    #[test]
898    fn test_skill_write_result_json_includes_warnings() {
899        // Issue #161: the JSON output must name any excluded `.ts` file so a
900        // caller relying only on `--format json` can detect the drift, since
901        // it is no longer visible only via `tracing::warn!`.
902        let result = SkillWriteResult {
903            success: true,
904            output_path: "/tmp/SKILL.md".to_string(),
905            bytes_written: 42,
906            tool_count: 1,
907            warnings: vec![
908                "'orphanTool.ts' is not referenced by _meta.json and was excluded from SKILL.md \
909                 (re-run 'generate' to refresh the sidecar)"
910                    .to_string(),
911            ],
912        };
913
914        let output = format_output(&result, OutputFormat::Json).unwrap();
915
916        assert!(
917            output.contains("\"warnings\""),
918            "JSON output must contain a warnings field: {output}"
919        );
920        assert!(
921            output.contains("orphanTool.ts"),
922            "warnings must name the excluded file: {output}"
923        );
924    }
925
926    #[tokio::test]
927    async fn test_run_with_custom_skill_name() {
928        let temp = TempDir::new().unwrap();
929        let server_dir = temp.path().join("github");
930        std::fs::create_dir(&server_dir).unwrap();
931        write_meta_sidecar(&server_dir, "github", "create_issue");
932
933        // Use custom output path to avoid conflicts with real files
934        let output_path = temp.path().join("SKILL.md");
935
936        let result = run(
937            "github".to_string(),
938            Some(temp.path().to_path_buf()),
939            Some(output_path.clone()),
940            Some("github-advanced".to_string()),
941            vec![],
942            false,
943            OutputFormat::Json,
944        )
945        .await;
946
947        assert!(
948            result.is_ok(),
949            "Expected success but got: {:?}",
950            result.err()
951        );
952
953        // Issue #435: confirm the custom name actually landed in the written SKILL.md, not just
954        // that the call reported success.
955        let written = std::fs::read_to_string(&output_path).unwrap();
956        assert!(
957            written.contains("name: github-advanced"),
958            "written SKILL.md must use the custom skill name: {written}"
959        );
960    }
961
962    /// Issue #436: a custom `--skill-name` must be threaded into `build_skill_context` as a
963    /// constructor input, not patched onto the result afterward — so `generation_prompt`
964    /// reflects the requested name instead of the stale `{server}-progressive` default.
965    /// `test_run_with_custom_skill_name` above only inspects the rendered `SKILL.md` file (which
966    /// goes through `render_skill_md`, not `generation_prompt`); this test inspects
967    /// `prepare_skill_context`'s returned `generation_prompt` directly.
968    #[test]
969    fn test_prepare_skill_context_with_custom_skill_name_reflects_it_in_generation_prompt() {
970        let tools = vec![];
971
972        let (context, _output_path) =
973            prepare_skill_context("github", &tools, vec![], Some("github-advanced"), None).unwrap();
974
975        assert_eq!(context.skill_name, "github-advanced");
976        assert!(
977            context.generation_prompt.contains("github-advanced"),
978            "generation_prompt must reflect the custom skill_name, not the default: {}",
979            context.generation_prompt
980        );
981        assert!(!context.generation_prompt.contains("github-progressive"));
982    }
983
984    /// Issue #436 (S1 follow-up): `prepare_skill_context`'s resolved output path must never be
985    /// written back into `default_output_path_hint` — that field is a non-authoritative display
986    /// hint, not this command's actual write target. Confirms the hint keeps its
987    /// `build_skill_context`-computed default shape even when a custom `output_path` is
988    /// supplied, and that the actual resolved path is returned separately.
989    #[test]
990    fn test_prepare_skill_context_does_not_overwrite_default_output_path_hint() {
991        let tools = vec![];
992        let custom_output = PathBuf::from("/tmp/custom/SKILL.md");
993
994        let (context, resolved_output_path) =
995            prepare_skill_context("github", &tools, vec![], None, Some(custom_output.clone()))
996                .unwrap();
997
998        assert_eq!(resolved_output_path, Some(custom_output));
999        assert_eq!(
1000            context.default_output_path_hint, "~/.claude/skills/github/SKILL.md",
1001            "default_output_path_hint must stay build_skill_context's own default, not be \
1002             overwritten with the resolved write path"
1003        );
1004    }
1005
1006    /// Critic finding S1 (issue #473 follow-up): hints dropped past `mcp_execution_skill::
1007    /// MAX_USE_CASE_HINTS` must not be silent. `build_skill_context` (called from
1008    /// `prepare_skill_context`) seeds `GenerateSkillResult::warnings` with a drop warning; `run`
1009    /// then merges it onto `SkillWriteResult.warnings` alongside `scan_result.warnings` (see
1010    /// `run`'s warning-merge comment) — the same channel `ScanResult::warnings` drift already
1011    /// surfaces on, so a caller inspecting `--format json` output sees both kinds of non-fatal
1012    /// data loss the same way.
1013    #[test]
1014    fn test_prepare_skill_context_surfaces_use_case_hint_cap_warning() {
1015        let tools = vec![];
1016        let hints: Vec<String> = (0..(mcp_execution_skill::types::MAX_USE_CASE_HINTS + 2))
1017            .map(|i| format!("hint-{i}"))
1018            .collect();
1019
1020        let (context, _output_path) =
1021            prepare_skill_context("github", &tools, hints, None, None).unwrap();
1022
1023        assert_eq!(context.warnings.len(), 1, "{:?}", context.warnings);
1024        assert!(
1025            context.warnings[0].contains("dropped"),
1026            "{:?}",
1027            context.warnings
1028        );
1029    }
1030
1031    /// Issue #413: an oversized `--skill-name` must be rejected up front, before anything is
1032    /// rendered or written to disk — not left to fail later at `extract_skill_metadata`'s
1033    /// `MAX_FRONTMATTER_SIZE` check on a file that's already been written.
1034    #[tokio::test]
1035    async fn test_run_rejects_oversized_skill_name() {
1036        let temp = TempDir::new().unwrap();
1037        let server_dir = temp.path().join("github");
1038        std::fs::create_dir(&server_dir).unwrap();
1039        write_meta_sidecar(&server_dir, "github", "create_issue");
1040
1041        let output_path = temp.path().join("SKILL.md");
1042        let oversized_name = "a".repeat(mcp_execution_skill::MAX_SKILL_NAME_LENGTH + 1);
1043
1044        let result = run(
1045            "github".to_string(),
1046            Some(temp.path().to_path_buf()),
1047            Some(output_path.clone()),
1048            Some(oversized_name),
1049            vec![],
1050            false,
1051            OutputFormat::Json,
1052        )
1053        .await;
1054
1055        assert!(result.is_err(), "oversized skill_name must be rejected");
1056        assert!(
1057            !output_path.exists(),
1058            "no SKILL.md should be written when skill_name validation fails"
1059        );
1060    }
1061
1062    /// Issue #473: `--hint` must have a real, observable effect on the written SKILL.md, not
1063    /// just report success — before the fix, hints only reached the LLM-facing
1064    /// `generation_prompt`, which the CLI never uses (it renders `render_skill_md` directly),
1065    /// so a hint-bearing run and a hint-less run produced byte-identical output.
1066    #[tokio::test]
1067    async fn test_run_with_hints() {
1068        let temp = TempDir::new().unwrap();
1069        let server_dir = temp.path().join("github");
1070        std::fs::create_dir(&server_dir).unwrap();
1071        write_meta_sidecar(&server_dir, "github", "list_prs");
1072
1073        // Use custom output path to avoid conflicts with real files
1074        let output_path = temp.path().join("SKILL.md");
1075
1076        let result = run(
1077            "github".to_string(),
1078            Some(temp.path().to_path_buf()),
1079            Some(output_path.clone()),
1080            None,
1081            vec!["code review".to_string(), "CI/CD".to_string()],
1082            false,
1083            OutputFormat::Json,
1084        )
1085        .await;
1086
1087        assert!(
1088            result.is_ok(),
1089            "Expected success but got: {:?}",
1090            result.err()
1091        );
1092
1093        let written = std::fs::read_to_string(&output_path).unwrap();
1094        assert!(
1095            written.contains("## Use Cases"),
1096            "written SKILL.md must include a Use Cases section: {written}"
1097        );
1098        assert!(written.contains("code review"), "{written}");
1099        assert!(written.contains("CI/CD"), "{written}");
1100    }
1101
1102    /// Sibling of `test_run_with_hints`: no `--hint` supplied must produce a written SKILL.md
1103    /// with no "Use Cases" section at all, confirming the fix does not force the section to
1104    /// always render.
1105    #[tokio::test]
1106    async fn test_run_without_hints_omits_use_cases_section() {
1107        let temp = TempDir::new().unwrap();
1108        let server_dir = temp.path().join("github");
1109        std::fs::create_dir(&server_dir).unwrap();
1110        write_meta_sidecar(&server_dir, "github", "list_prs");
1111
1112        let output_path = temp.path().join("SKILL.md");
1113
1114        let result = run(
1115            "github".to_string(),
1116            Some(temp.path().to_path_buf()),
1117            Some(output_path.clone()),
1118            None,
1119            vec![],
1120            false,
1121            OutputFormat::Json,
1122        )
1123        .await;
1124
1125        assert!(
1126            result.is_ok(),
1127            "Expected success but got: {:?}",
1128            result.err()
1129        );
1130
1131        let written = std::fs::read_to_string(&output_path).unwrap();
1132        assert!(
1133            !written.contains("## Use Cases"),
1134            "written SKILL.md must not have a Use Cases section without --hint: {written}"
1135        );
1136    }
1137
1138    #[tokio::test]
1139    async fn test_run_output_exists_no_overwrite() {
1140        let temp = TempDir::new().unwrap();
1141        let server_dir = temp.path().join("github");
1142        std::fs::create_dir(&server_dir).unwrap();
1143        write_meta_sidecar(&server_dir, "github", "test");
1144
1145        // Create existing output file
1146        let output_path = temp.path().join("SKILL.md");
1147        std::fs::write(&output_path, "existing content").unwrap();
1148
1149        let result = run(
1150            "github".to_string(),
1151            Some(temp.path().to_path_buf()),
1152            Some(output_path),
1153            None,
1154            vec![],
1155            false, // no overwrite
1156            OutputFormat::Json,
1157        )
1158        .await;
1159
1160        assert!(result.is_err());
1161        assert!(result.unwrap_err().to_string().contains("already exists"));
1162    }
1163
1164    #[tokio::test]
1165    async fn test_run_output_exists_with_overwrite() {
1166        let temp = TempDir::new().unwrap();
1167        let server_dir = temp.path().join("github");
1168        std::fs::create_dir(&server_dir).unwrap();
1169        write_meta_sidecar(&server_dir, "github", "test");
1170
1171        // Create existing output file
1172        let output_path = temp.path().join("SKILL.md");
1173        std::fs::write(&output_path, "existing content").unwrap();
1174
1175        let result = run(
1176            "github".to_string(),
1177            Some(temp.path().to_path_buf()),
1178            Some(output_path),
1179            None,
1180            vec![],
1181            true, // overwrite
1182            OutputFormat::Json,
1183        )
1184        .await;
1185
1186        assert!(
1187            result.is_ok(),
1188            "Expected success but got: {:?}",
1189            result.err()
1190        );
1191    }
1192
1193    #[tokio::test]
1194    async fn test_run_all_output_formats() {
1195        let temp = TempDir::new().unwrap();
1196        let server_dir = temp.path().join("test");
1197        std::fs::create_dir(&server_dir).unwrap();
1198        write_meta_sidecar(&server_dir, "test", "test");
1199
1200        for format in [OutputFormat::Json, OutputFormat::Text, OutputFormat::Pretty] {
1201            let output_path = temp.path().join(format!("SKILL-{format}.md"));
1202            let result = run(
1203                "test".to_string(),
1204                Some(temp.path().to_path_buf()),
1205                Some(output_path),
1206                None,
1207                vec![],
1208                false,
1209                format,
1210            )
1211            .await;
1212
1213            assert!(
1214                result.is_ok(),
1215                "Format {:?} should succeed: {:?}",
1216                format,
1217                result.err()
1218            );
1219        }
1220    }
1221
1222    #[tokio::test]
1223    async fn test_run_stale_metadata_fails_instead_of_silently_succeeding() {
1224        // Issue #154 repro: a `_meta.json` sidecar that has drifted from the
1225        // `.ts` files on disk (one entry's file was deleted, an unrelated file
1226        // was added) must now make `skill` fail loudly instead of silently
1227        // generating a SKILL.md with stale/missing tool references.
1228        let temp = TempDir::new().unwrap();
1229        let server_dir = temp.path().join("github");
1230        std::fs::create_dir(&server_dir).unwrap();
1231
1232        let meta = ServerMetadata {
1233            schema_version: METADATA_SCHEMA_VERSION,
1234            server_id: ServerId::new("github").unwrap(),
1235            server_name: "GitHub".to_string(),
1236            server_version: "1.0.0".to_string(),
1237            tools: vec![
1238                ToolMetadata {
1239                    name: ToolName::new("create_issue").unwrap(),
1240                    typescript_name: "createIssue".to_string(),
1241                    category: Some("issues".to_string()),
1242                    keywords: vec!["create".to_string()],
1243                    description: Some("Create an issue".to_string()),
1244                    parameters: vec![ParameterMetadata {
1245                        name: "title".to_string(),
1246                        typescript_type: "string".to_string(),
1247                        required: true,
1248                        description: Some("Issue title".to_string()),
1249                    }],
1250                },
1251                ToolMetadata {
1252                    name: ToolName::new("list_repos").unwrap(),
1253                    typescript_name: "listRepos".to_string(),
1254                    category: Some("repos".to_string()),
1255                    keywords: vec!["list".to_string()],
1256                    description: Some("List repos".to_string()),
1257                    parameters: vec![],
1258                },
1259            ],
1260            provenance: test_provenance(),
1261        };
1262        let content = serde_json::to_string_pretty(&meta).unwrap();
1263        std::fs::write(server_dir.join(METADATA_FILE_NAME), content).unwrap();
1264
1265        // Generate as normal for `list_repos`, but simulate a `.ts` file that
1266        // was deleted (or never written, e.g. an interrupted `generate`) for
1267        // `create_issue` — this is the drift the sidecar must now catch.
1268        std::fs::write(server_dir.join("listRepos.ts"), "export {}").unwrap();
1269        // An unrelated `.ts` file left over on disk, not referenced by the
1270        // sidecar at all — must not mask the missing-file error above.
1271        std::fs::write(server_dir.join("orphanTool.ts"), "export {}").unwrap();
1272
1273        let output_path = temp.path().join("SKILL.md");
1274
1275        let result = run(
1276            "github".to_string(),
1277            Some(temp.path().to_path_buf()),
1278            Some(output_path.clone()),
1279            None,
1280            vec![],
1281            false,
1282            OutputFormat::Json,
1283        )
1284        .await;
1285
1286        assert!(
1287            result.is_err(),
1288            "drifted sidecar must fail instead of silently succeeding"
1289        );
1290        let err = result.unwrap_err();
1291        // `anyhow::Error`'s `Display` only shows the outer context; the full
1292        // chain (including the `ScanError::StaleMetadata` source) is in `{err:?}`.
1293        let message = format!("{err:?}");
1294        assert!(
1295            message.contains("create_issue") || message.contains("createIssue.ts"),
1296            "error must identify the tool/file with the missing .ts: {message}"
1297        );
1298        assert!(
1299            !output_path.exists(),
1300            "SKILL.md must not be written when the sidecar is stale"
1301        );
1302    }
1303
1304    #[tokio::test]
1305    async fn test_run_path_traversal_server_id() {
1306        let temp = TempDir::new().unwrap();
1307
1308        // Server ID validation should reject path traversal attempts
1309        let result = run(
1310            "../etc".to_string(),
1311            Some(temp.path().to_path_buf()),
1312            None,
1313            None,
1314            vec![],
1315            false,
1316            OutputFormat::Json,
1317        )
1318        .await;
1319
1320        assert!(result.is_err());
1321        // Should fail at server ID validation (contains invalid chars)
1322        assert!(
1323            result
1324                .unwrap_err()
1325                .to_string()
1326                .contains("Invalid server ID")
1327        );
1328    }
1329
1330    #[tokio::test]
1331    async fn test_write_skill_md_writes_content() {
1332        let base = TempDir::new().unwrap();
1333        let output_path = base.path().join("SKILL.md");
1334
1335        write_skill_md("rendered content", &output_path)
1336            .await
1337            .unwrap();
1338
1339        assert_eq!(
1340            std::fs::read_to_string(&output_path).unwrap(),
1341            "rendered content"
1342        );
1343        // The temp file must not survive a successful write.
1344        assert!(!output_path.with_added_extension("tmp").exists());
1345    }
1346
1347    /// `write_skill_md` must preserve the overwrite semantics `run`'s own `--overwrite` gate
1348    /// relies on: a pre-existing regular `SKILL.md` is replaced with the new content, not
1349    /// rejected or merged with the old.
1350    #[tokio::test]
1351    async fn test_write_skill_md_overwrites_existing_regular_file() {
1352        let base = TempDir::new().unwrap();
1353        let output_path = base.path().join("SKILL.md");
1354        std::fs::write(&output_path, "old content").unwrap();
1355
1356        write_skill_md("new content", &output_path).await.unwrap();
1357
1358        assert_eq!(
1359            std::fs::read_to_string(&output_path).unwrap(),
1360            "new content"
1361        );
1362    }
1363
1364    /// Issue #501: `write_skill_md`'s actual vulnerability was a symlink planted at its
1365    /// predictable temp path (`SKILL.md.tmp`), not at the final `SKILL.md` path - `rename`
1366    /// already replaces whatever entry sits at the final path rather than following it, so a
1367    /// symlink planted there was never the bug. Plants the symlink at the `.tmp` path instead and
1368    /// asserts the write is rejected without ever touching the symlink's target, and that no
1369    /// half-written `SKILL.md` is left behind at the final path.
1370    #[tokio::test]
1371    #[cfg(unix)]
1372    async fn test_write_skill_md_rejects_symlink_planted_at_tmp_path() {
1373        let base = TempDir::new().unwrap();
1374        let outside = TempDir::new().unwrap();
1375        let outside_file = outside.path().join("real.md");
1376
1377        let output_path = base.path().join("SKILL.md");
1378        let tmp_path = output_path.with_added_extension("tmp");
1379        std::os::unix::fs::symlink(&outside_file, &tmp_path).unwrap();
1380
1381        let result = write_skill_md("attacker-controlled", &output_path).await;
1382
1383        assert!(result.is_err());
1384        assert!(!outside_file.exists());
1385        assert!(!output_path.exists());
1386    }
1387
1388    #[tokio::test]
1389    async fn test_resolve_default_output_path_creates_and_confines_segment_directory() {
1390        let skills_dir = TempDir::new().unwrap();
1391
1392        let resolved = resolve_default_output_path(skills_dir.path(), "my-server")
1393            .await
1394            .unwrap();
1395
1396        let canonical_base = skills_dir.path().canonicalize().unwrap();
1397        assert_eq!(resolved, canonical_base.join("my-server").join("SKILL.md"));
1398        assert!(canonical_base.join("my-server").is_dir());
1399    }
1400
1401    /// Issue #501 (S3): the default output path must be confined the same way `save_skill`'s
1402    /// own default path is - a symlink already planted at the `{server}` segment directory (e.g.
1403    /// by an earlier process with write access to `~/.claude/skills`) must be rejected outright,
1404    /// not followed by the parent-directory creation a plain `create_dir_all` would have done.
1405    #[tokio::test]
1406    #[cfg(unix)]
1407    async fn test_resolve_default_output_path_rejects_symlinked_segment_directory() {
1408        let skills_dir = TempDir::new().unwrap();
1409        let outside = TempDir::new().unwrap();
1410        std::os::unix::fs::symlink(outside.path(), skills_dir.path().join("evil-server")).unwrap();
1411
1412        let err = resolve_default_output_path(skills_dir.path(), "evil-server")
1413            .await
1414            .unwrap_err();
1415
1416        // `{:#}` walks the full anyhow chain: `to_string()`/`{}` would only print the outer
1417        // `with_context` message, not the underlying `ConfinementError::SegmentIsSymlink` cause.
1418        assert!(format!("{err:#}").contains("symlink"), "{err:?}");
1419        assert!(!outside.path().join("SKILL.md").exists());
1420    }
1421
1422    /// Preserves pre-#501 behavior by user decision: `resolve_default_output_path` confines only
1423    /// the `{server}` segment directory, not the terminal `SKILL.md` component, so a symlink
1424    /// already at `SKILL.md` itself (e.g. a dotfiles setup symlinking it into a repo) is left
1425    /// alone by resolution and then safely *replaced* by `write_skill_md`'s `rename` - not
1426    /// rejected the way a symlinked segment directory is.
1427    #[tokio::test]
1428    #[cfg(unix)]
1429    async fn test_default_path_symlinked_skill_md_is_replaced_not_rejected() {
1430        let skills_dir = TempDir::new().unwrap();
1431        let outside = TempDir::new().unwrap();
1432        let outside_file = outside.path().join("real.md");
1433        std::fs::write(&outside_file, "linked content").unwrap();
1434
1435        let server_dir = skills_dir.path().join("my-server");
1436        std::fs::create_dir_all(&server_dir).unwrap();
1437        std::os::unix::fs::symlink(&outside_file, server_dir.join("SKILL.md")).unwrap();
1438
1439        let output_path = resolve_default_output_path(skills_dir.path(), "my-server")
1440            .await
1441            .unwrap();
1442        write_skill_md("new content", &output_path).await.unwrap();
1443
1444        assert!(!output_path.is_symlink());
1445        assert_eq!(
1446            std::fs::read_to_string(&output_path).unwrap(),
1447            "new content"
1448        );
1449        // The symlink's old target must be untouched - `rename` swaps the directory entry, it
1450        // never writes through the link.
1451        assert_eq!(
1452            std::fs::read_to_string(&outside_file).unwrap(),
1453            "linked content"
1454        );
1455    }
1456}