Skip to content

Commit 5543b87

Browse files
committed
fix: recognize Claude plugin skill ownership
1 parent 29cad6c commit 5543b87

3 files changed

Lines changed: 136 additions & 3 deletions

File tree

crates/agnix-core/src/rules/per_client_skill.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,35 @@ pub(crate) fn detect_client(path: &Path) -> SkillClient {
119119
SkillClient::Unknown
120120
}
121121

122+
/// Return whether `path` is a skill owned by a Claude Code plugin.
123+
///
124+
/// Claude plugins may expose either a root `SKILL.md` or skills below the
125+
/// plugin's `skills/` directory. Require the documented sibling manifest so a
126+
/// generic `skills/foo/SKILL.md` tree is not misclassified as Claude-owned.
127+
fn is_claude_plugin_skill(path: &Path, config: &LintConfig) -> bool {
128+
let Some(parent) = path.parent() else {
129+
return false;
130+
};
131+
132+
let has_manifest = |root: &Path| {
133+
config
134+
.fs()
135+
.is_file(&root.join(".claude-plugin").join("plugin.json"))
136+
};
137+
138+
if path.file_name().and_then(|name| name.to_str()) == Some("SKILL.md") && has_manifest(parent) {
139+
return true;
140+
}
141+
142+
for ancestor in parent.ancestors() {
143+
if ancestor.file_name().and_then(|name| name.to_str()) == Some("skills") {
144+
return ancestor.parent().is_some_and(has_manifest);
145+
}
146+
}
147+
148+
false
149+
}
150+
122151
/// Map a `tools = [...]` entry (or `--tools` value) to a [`SkillClient`].
123152
/// Matching is case-insensitive to tolerate configs that use different casing.
124153
fn skill_client_from_tool_str(tool: &str) -> Option<SkillClient> {
@@ -151,6 +180,9 @@ pub(crate) fn resolve_skill_client(path: &Path, config: &LintConfig) -> SkillCli
151180
if by_path != SkillClient::Unknown {
152181
return by_path;
153182
}
183+
if is_claude_plugin_skill(path, config) {
184+
return SkillClient::ClaudeCode;
185+
}
154186

155187
let mut from_tools = config
156188
.tools()
@@ -437,6 +469,7 @@ mod tests {
437469
use super::*;
438470
use crate::config::LintConfig;
439471
use crate::rules::Validator;
472+
use std::fs;
440473

441474
fn make_skill(frontmatter: &str, body: &str) -> String {
442475
format!("---\n{}\n---\n{}", frontmatter, body)
@@ -552,6 +585,39 @@ mod tests {
552585
);
553586
}
554587

588+
#[test]
589+
fn test_resolve_skill_client_claude_plugin_layouts() {
590+
let temp = tempfile::tempdir().unwrap();
591+
let plugin_root = temp.path().join("my-plugin");
592+
let manifest = plugin_root.join(".claude-plugin").join("plugin.json");
593+
fs::create_dir_all(manifest.parent().unwrap()).unwrap();
594+
fs::write(&manifest, "{}").unwrap();
595+
596+
assert_eq!(
597+
resolve_skill_client(
598+
&plugin_root.join("skills/review/SKILL.md"),
599+
&LintConfig::default()
600+
),
601+
SkillClient::ClaudeCode
602+
);
603+
assert_eq!(
604+
resolve_skill_client(&plugin_root.join("SKILL.md"), &LintConfig::default()),
605+
SkillClient::ClaudeCode
606+
);
607+
}
608+
609+
#[test]
610+
fn test_resolve_skill_client_generic_skills_without_plugin_manifest() {
611+
let temp = tempfile::tempdir().unwrap();
612+
assert_eq!(
613+
resolve_skill_client(
614+
&temp.path().join("skills/review/SKILL.md"),
615+
&LintConfig::default()
616+
),
617+
SkillClient::Unknown
618+
);
619+
}
620+
555621
// ===== Validation tests =====
556622

557623
#[test]

crates/agnix-core/src/rules/skill/mod.rs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1930,6 +1930,26 @@ fn is_valid_skill_tool_name(tool: &str) -> bool {
19301930
is_valid_mcp_tool_format(tool, KNOWN_TOOLS)
19311931
}
19321932

1933+
/// Derive Claude Code's command name when frontmatter omits `name`.
1934+
///
1935+
/// Directory-based skills use the directory name, including plugin-root
1936+
/// `SKILL.md` files. Legacy command files use their Markdown file stem.
1937+
fn derived_claude_skill_name(path: &Path) -> String {
1938+
if path.file_name().and_then(|name| name.to_str()) == Some("SKILL.md") {
1939+
return path
1940+
.parent()
1941+
.and_then(Path::file_name)
1942+
.and_then(|name| name.to_str())
1943+
.unwrap_or_default()
1944+
.to_string();
1945+
}
1946+
1947+
path.file_stem()
1948+
.and_then(|name| name.to_str())
1949+
.unwrap_or_default()
1950+
.to_string()
1951+
}
1952+
19331953
impl Validator for SkillValidator {
19341954
fn metadata(&self) -> ValidatorMetadata {
19351955
ValidatorMetadata {
@@ -2016,9 +2036,10 @@ impl Validator for SkillValidator {
20162036
name: frontmatter
20172037
.name
20182038
.as_deref()
2019-
.unwrap_or_default()
2020-
.trim()
2021-
.to_string(),
2039+
.map(str::trim)
2040+
.filter(|name| !name.is_empty())
2041+
.map(str::to_string)
2042+
.unwrap_or_else(|| derived_claude_skill_name(path)),
20222043
description: frontmatter
20232044
.description
20242045
.as_deref()

crates/agnix-core/src/rules/skill/tests.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,30 @@ Use the directory name as the command and this paragraph as the description."#;
9898
);
9999
}
100100

101+
#[test]
102+
fn test_claude_plugin_skill_allows_missing_name_and_description() {
103+
let temp = tempfile::tempdir().unwrap();
104+
let plugin_root = temp.path().join("my-plugin");
105+
let manifest = plugin_root.join(".claude-plugin").join("plugin.json");
106+
let skill_path = plugin_root.join("skills/review/SKILL.md");
107+
fs::create_dir_all(manifest.parent().unwrap()).unwrap();
108+
fs::create_dir_all(skill_path.parent().unwrap()).unwrap();
109+
fs::write(&manifest, "{}").unwrap();
110+
111+
let diagnostics = SkillValidator.validate(
112+
&skill_path,
113+
"---\n---\nReview the current changes.",
114+
&LintConfig::default(),
115+
);
116+
117+
assert!(
118+
diagnostics
119+
.iter()
120+
.all(|d| d.rule != "AS-002" && d.rule != "AS-003"),
121+
"manifest-owned plugin skills use Claude's optional-field contract: {diagnostics:?}"
122+
);
123+
}
124+
101125
#[test]
102126
fn test_as_004_invalid_name_format() {
103127
let content = r#"---
@@ -508,6 +532,28 @@ Body"#;
508532
);
509533
}
510534

535+
#[test]
536+
fn test_cc_sk_006_uses_directory_name_when_frontmatter_omits_name() {
537+
let content = r#"---
538+
description: Deploys to production
539+
---
540+
Body"#;
541+
542+
let diagnostics = SkillValidator.validate(
543+
Path::new(".claude/skills/deploy-prod/SKILL.md"),
544+
content,
545+
&LintConfig::default(),
546+
);
547+
548+
let hits: Vec<_> = diagnostics
549+
.iter()
550+
.filter(|d| d.rule == "CC-SK-006")
551+
.collect();
552+
assert_eq!(hits.len(), 1);
553+
assert!(hits[0].message.contains("deploy-prod"));
554+
assert!(!diagnostics.iter().any(|d| d.rule == "AS-002"));
555+
}
556+
511557
#[test]
512558
fn test_cc_sk_006_dangerous_name_with_safety() {
513559
let content = r#"---

0 commit comments

Comments
 (0)