- 1
//! Skills: markdown packages with frontmatter, discovered from - 2
//! `.vak/skills/<name>/SKILL.md` (project) and - 3
//! `<home>/skills/<name>/SKILL.md` (user). Discovery metadata enters the - 4
//! capability packet; the brokered `skill` loader returns full content. - 5
- 6
use async_trait::async_trait; - 7
use serde_json::{Value, json}; - 8
use sha2::{Digest, Sha256}; - 9
use std::collections::BTreeMap; - 10
use std::path::{Path, PathBuf}; - 11
- 12
#[derive(Debug, Clone, PartialEq)] - 13
pub struct Skill { - 14
pub name: String, - 15
pub description: String, - 16
pub path: PathBuf, - 17
pub provenance: Option<String>, - 18
pub shadowed: bool, - 19
/// Optional `serves:` frontmatter — what this skill is for, in its own - 20
/// words (`serves: documents, live-data`). `None` means undeclared, - 21
/// which is never narrowed away by the per-turn capability slice. See - 22
/// `crate::capability::domain`. - 23
pub serves: Option<Vec<String>>, - 24
} - 25
- 26
/// A skill candidate that was found on disk but failed to parse. - 27
/// Returned by [`discover_with_diagnostics`] so inspection surfaces - 28
/// can explain *why* a skill is missing rather than leaving the - 29
/// operator to guess. - 30
#[derive(Debug, Clone, PartialEq)] - 31
pub struct SkillDiagnostic { - 32
/// Absolute path to the SKILL.md that failed. - 33
pub path: PathBuf, - 34
/// Human-readable reason the parse failed. - 35
pub reason: String, - 36
/// Provenance label if the root was a plugin. - 37
pub provenance: Option<String>, - 38
} - 39
- 40
impl Skill { - 41
pub fn digest(&self) -> Result<String, std::io::Error> { - 42
let bytes = std::fs::read(&self.path)?; - 43
Ok(format!("{:x}", Sha256::digest(bytes))) - 44
} - 45
} - 46
- 47
#[derive(Debug, Clone, PartialEq, Eq)] - 48
pub struct FrozenSkill { - 49
pub name: String, - 50
pub description: String, - 51
pub path: PathBuf, - 52
pub digest: String, - 53
pub provenance: Option<String>, - 54
} - 55
- 56
impl FrozenSkill { - 57
pub fn load(&self) -> Result<String, String> { - 58
let bytes = std::fs::read(&self.path).map_err(|error| { - 59
format!( - 60
r#"{{"type":"capability_unavailable","kind":"skill","name":{},"message":{}}}"#, - 61
json_string(&self.name), - 62
json_string(&error.to_string()) - 63
) - 64
})?; - 65
let actual = format!("{:x}", Sha256::digest(&bytes)); - 66
if actual != self.digest { - 67
return Err(format!( - 68
r#"{{"type":"capability_stale","kind":"skill","name":{},"message":"skill changed after session admission; start a new session"}}"#, - 69
json_string(&self.name) - 70
)); - 71
} - 72
let content = String::from_utf8(bytes).map_err(|error| { - 73
format!( - 74
r#"{{"type":"capability_invalid","kind":"skill","name":{},"message":{}}}"#, - 75
json_string(&self.name), - 76
json_string(&error.to_string()) - 77
) - 78
})?; - 79
let body = strip_frontmatter(&content).trim(); - 80
let base = self.path.parent().unwrap_or(Path::new(".")); - 81
let provenance = self.provenance.as_deref().unwrap_or("workspace-or-user"); - 82
Ok(format!( - 83
"<skill name=\"{}\" location=\"{}\" provenance=\"{}\">\nReferences are relative to {}.\n\n{}\n</skill>", - 84
self.name, - 85
self.path.display(), - 86
provenance, - 87
base.display(), - 88
body - 89
)) - 90
} - 91
} - 92
- 93
pub fn frozen_from_capabilities( - 94
capabilities: &[vak_session::types::CapabilityDescriptor], - 95
) -> Vec<FrozenSkill> { - 96
capabilities - 97
.iter() - 98
.filter(|capability| capability.kind == vak_session::types::CapabilityKind::Skill) - 99
.filter_map(|capability| { - 100
Some(FrozenSkill { - 101
name: capability.name.clone(), - 102
description: capability.description.clone(), - 103
path: capability.source.clone()?, - 104
digest: capability.digest.clone()?, - 105
provenance: capability.provenance.clone(), - 106
}) - 107
}) - 108
.collect() - 109
} - 110
- 111
pub fn expand_invocation(input: &str, skills: &[FrozenSkill]) -> Result<Option<String>, String> { - 112
let trimmed = input.trim_start(); - 113
let Some(rest) = trimmed.strip_prefix("/skill:") else { - 114
return Ok(None); - 115
}; - 116
let mut parts = rest.splitn(2, char::is_whitespace); - 117
let name = parts.next().unwrap_or_default(); - 118
let Some(skill) = skills.iter().find(|skill| skill.name == name) else { - 119
return Err(format!( - 120
r#"{{"type":"capability_not_admitted","kind":"skill","name":{}}}"#, - 121
json_string(name) - 122
)); - 123
}; - 124
let block = skill.load()?; - 125
let args = parts.next().unwrap_or_default().trim(); - 126
Ok(Some(if args.is_empty() { - 127
block - 128
} else { - 129
format!("{block}\n\n{args}") - 130
})) - 131
} - 132
- 133
#[derive(Debug, Clone)] - 134
pub struct SkillTool { - 135
skills: BTreeMap<String, FrozenSkill>, - 136
description: String, - 137
} - 138
- 139
impl SkillTool { - 140
/// Declared as a constant so capability declarations can read it - 141
/// without the turn's admitted skill set. - 142
pub const SERVES: &'static [&'static str] = &["documents", "orchestration"]; - 143
- 144
/// The skill catalogue itself is in the prompt (`prompt_section_from_capabilities`), - 145
/// once; the tool carries only the admitted names, as its schema enum. - 146
pub fn new(skills: impl IntoIterator<Item = FrozenSkill>) -> Self { - 147
let skills = skills - 148
.into_iter() - 149
.map(|skill| (skill.name.clone(), skill)) - 150
.collect::<BTreeMap<_, _>>(); - 151
Self { - 152
skills, - 153
description: "Load one skill document by its exact name from the skills listed \ - 154
in your instructions. Skills are instructions, not executable functions." - 155
.into(), - 156
} - 157
} - 158
} - 159
- 160
#[async_trait] - 161
impl vak_tools::Tool for SkillTool { - 162
fn name(&self) -> &str { - 163
"skill" - 164
} - 165
- 166
fn serves(&self) -> &'static [&'static str] { - 167
Self::SERVES - 168
} - 169
- 170
fn always_loaded(&self) -> bool { - 171
true - 172
} - 173
- 174
fn description(&self) -> &str { - 175
&self.description - 176
} - 177
- 178
fn schema(&self) -> Value { - 179
json!({ - 180
"type": "object", - 181
"properties": { - 182
"name": { - 183
"type": "string", - 184
"enum": self.skills.keys().collect::<Vec<_>>(), - 185
"description": "Exact admitted skill name" - 186
} - 187
}, - 188
"required": ["name"], - 189
"additionalProperties": false - 190
}) - 191
} - 192
- 193
async fn execute(&self, args: &Value, _ctx: &vak_tools::ToolContext) -> vak_tools::ToolOutput { - 194
let Some(name) = args.get("name").and_then(Value::as_str) else { - 195
return vak_tools::ToolOutput::error( - 196
r#"{"type":"invalid_arguments","capability":"skill","message":"missing required string 'name'"}"#, - 197
); - 198
}; - 199
let skill = self - 200
.skills - 201
.get(name) - 202
.or_else(|| self.skills.get(&name.replace('_', "-"))) - 203
.or_else(|| self.skills.get(&name.replace('-', "_"))); - 204
let Some(skill) = skill else { - 205
return vak_tools::ToolOutput::error(format!( - 206
r#"{{"type":"capability_not_admitted","kind":"skill","name":{}}}"#, - 207
json_string(name) - 208
)); - 209
}; - 210
match skill.load() { - 211
Ok(content) => vak_tools::ToolOutput::ok(content), - 212
Err(error) => vak_tools::ToolOutput::error(error), - 213
} - 214
} - 215
- 216
fn claims(&self, _args: &Value) -> vak_tools::ResourceClaims { - 217
vak_tools::ResourceClaims { - 218
read_only: true, - 219
..Default::default() - 220
} - 221
} - 222
} - 223
- 224
fn json_string(value: &str) -> String { - 225
serde_json::to_string(value).unwrap_or_else(|_| "\"invalid\"".into()) - 226
} - 227
- 228
fn strip_frontmatter(content: &str) -> &str { - 229
let Some(rest) = content.strip_prefix("---") else { - 230
return content; - 231
}; - 232
rest.split_once("\n---") - 233
.map_or(content, |(_, body)| body.trim_start_matches(['\r', '\n'])) - 234
} - 235
- 236
pub fn discover(cwd: &Path, home: &Path) -> Vec<Skill> { - 237
discover_with_plugins(cwd, home, &[]) - 238
} - 239
- 240
pub fn discover_with_plugins(cwd: &Path, home: &Path, plugins: &[(PathBuf, String)]) -> Vec<Skill> { - 241
discover_all_with_plugins(cwd, home, plugins) - 242
.into_iter() - 243
.filter(|skill| !skill.shadowed) - 244
.collect() - 245
} - 246
- 247
/// Like [`discover_with_plugins`] but also returns diagnostics for every - 248
/// skill candidate that was found on disk but failed validation. - 249
pub fn discover_with_diagnostics( - 250
cwd: &Path, - 251
home: &Path, - 252
plugins: &[(PathBuf, String)], - 253
) -> (Vec<Skill>, Vec<SkillDiagnostic>) { - 254
let mut roots = vec![(cwd.join(".vak/skills"), None), (home.join("skills"), None)]; - 255
let agents_dir = home.join("agents"); - 256
if let Ok(entries) = std::fs::read_dir(&agents_dir) { - 257
for entry in entries.flatten() { - 258
let p = entry.path(); - 259
if p.is_dir() { - 260
roots.push((p.join("skills"), None)); - 261
} - 262
} - 263
} - 264
roots.extend( - 265
plugins - 266
.iter() - 267
.map(|(root, provenance)| (root.join("skills"), Some(provenance.clone()))), - 268
); - 269
roots.dedup_by(|a, b| a.0 == b.0); - 270
let mut skills = Vec::new(); - 271
let mut diagnostics = Vec::new(); - 272
for (root, provenance) in roots { - 273
let Ok(entries) = std::fs::read_dir(&root) else { - 274
continue; - 275
}; - 276
for entry in entries.flatten() { - 277
let skill_path = entry.path().join("SKILL.md"); - 278
if !skill_path.is_file() { - 279
continue; - 280
} - 281
match validate(&skill_path) { - 282
Ok((mut skill, _warnings)) => { - 283
skill.provenance = provenance.clone(); - 284
skills.push(skill); - 285
} - 286
Err(reason) => { - 287
diagnostics.push(SkillDiagnostic { - 288
path: skill_path, - 289
reason, - 290
provenance: provenance.clone(), - 291
}); - 292
} - 293
} - 294
} - 295
} - 296
skills.sort_by(|a, b| a.name.cmp(&b.name)); - 297
let mut seen = std::collections::HashSet::new(); - 298
for skill in &mut skills { - 299
skill.shadowed = !seen.insert(skill.name.clone()); - 300
} - 301
skills.retain(|skill| !skill.shadowed); - 302
(skills, diagnostics) - 303
} - 304
- 305
/// Discovers every valid skill, retaining lower-precedence entries so - 306
/// inspection surfaces can explain why a skill is not active. - 307
pub fn discover_all_with_plugins( - 308
cwd: &Path, - 309
home: &Path, - 310
plugins: &[(PathBuf, String)], - 311
) -> Vec<Skill> { - 312
let mut roots = vec![(cwd.join(".vak/skills"), None), (home.join("skills"), None)]; - 313
let agents_dir = home.join("agents"); - 314
if let Ok(entries) = std::fs::read_dir(&agents_dir) { - 315
for entry in entries.flatten() { - 316
let p = entry.path(); - 317
if p.is_dir() { - 318
roots.push((p.join("skills"), None)); - 319
} - 320
} - 321
} - 322
roots.extend( - 323
plugins - 324
.iter() - 325
.map(|(root, provenance)| (root.join("skills"), Some(provenance.clone()))), - 326
); - 327
roots.dedup_by(|a, b| a.0 == b.0); - 328
let mut out = Vec::new(); - 329
for (root, provenance) in roots { - 330
let Ok(entries) = std::fs::read_dir(&root) else { - 331
continue; - 332
}; - 333
for entry in entries.flatten() { - 334
let skill_path = entry.path().join("SKILL.md"); - 335
if !skill_path.is_file() { - 336
continue; - 337
} - 338
if let Some(mut skill) = parse(&skill_path) { - 339
skill.provenance = provenance.clone(); - 340
out.push(skill); - 341
} - 342
} - 343
} - 344
out.sort_by(|a, b| a.name.cmp(&b.name)); - 345
let mut seen = std::collections::HashSet::new(); - 346
for skill in &mut out { - 347
skill.shadowed = !seen.insert(skill.name.clone()); - 348
} - 349
out - 350
} - 351
- 352
pub fn parse(path: &Path) -> Option<Skill> { - 353
validate(path).ok().map(|(skill, _)| skill) - 354
} - 355
- 356
pub fn validate(path: &Path) -> Result<(Skill, Vec<String>), String> { - 357
let text = std::fs::read_to_string(path).map_err(|error| error.to_string())?; - 358
let rest = text - 359
.strip_prefix("---") - 360
.ok_or_else(|| "file must begin with YAML frontmatter delimiter ---".to_string())?; - 361
let (frontmatter, _) = rest - 362
.split_once("\n---") - 363
.ok_or_else(|| "frontmatter is missing its closing --- delimiter".to_string())?; - 364
let mut name = None; - 365
let mut description = None; - 366
let mut compatibility = None; - 367
let mut serves = None; - 368
let mut warnings = Vec::new(); - 369
for line in frontmatter.lines() { - 370
let line = line.trim(); - 371
if let Some(v) = line.strip_prefix("name:") { - 372
name = Some(v.trim().trim_matches('"').to_string()); - 373
} else if let Some(v) = line.strip_prefix("description:") { - 374
description = Some(v.trim().trim_matches('"').to_string()); - 375
} else if let Some(v) = line.strip_prefix("compatibility:") { - 376
compatibility = Some(v.trim().trim_matches('"').to_string()); - 377
} else if let Some(v) = line.strip_prefix("serves:") { - 378
// Accept `a, b` and `[a, b]`; an empty value is "declared - 379
// nothing", which is different from not declaring at all only - 380
// in that it is a mistake worth not silently honouring — so an - 381
// empty list stays `None` (undeclared) rather than becoming a - 382
// slice that matches nothing. - 383
let raw = v.trim().trim_matches(['[', ']'].as_slice()); - 384
let parsed: Vec<String> = raw - 385
.split(',') - 386
.map(|part| part.trim().trim_matches('"').to_string()) - 387
.filter(|part| !part.is_empty()) - 388
.collect(); - 389
if !parsed.is_empty() { - 390
serves = Some(parsed); - 391
} - 392
} else if line.starts_with("allowed-tools:") { - 393
warnings.push("allowed-tools is advisory and never grants authorization".into()); - 394
} - 395
} - 396
let name = name.ok_or_else(|| "frontmatter requires name".to_string())?; - 397
if !valid_name(&name) { - 398
return Err(format!( - 399
"name '{name}' is not valid lowercase kebab-case (1-64 chars)" - 400
)); - 401
} - 402
let description = description.ok_or_else(|| "frontmatter requires description".to_string())?; - 403
if description.trim().is_empty() { - 404
return Err("description must not be empty".into()); - 405
} - 406
if description.chars().count() > 1024 { - 407
return Err("description must be at most 1024 characters".into()); - 408
} - 409
if compatibility.as_deref().is_some_and(str::is_empty) { - 410
return Err("compatibility must not be empty when provided".into()); - 411
} - 412
// Reject skills whose descriptions reference retired tool names - 413
// (e.g. `python_eval`, `react_preview`). A skill that instructs the - 414
// model to call a tool that no longer exists produces - 415
// `unknown_capability` errors and model hallucinations of tool output. - 416
// This is a hard rejection — the skill must not enter the capability - 417
// contract sent to the model (AGNS invariant 9: model catalogues are - 418
// discovered, never hardcoded). - 419
let lower = text.to_lowercase(); - 420
for tool in vak_tools::retired::RETIRED_TOOLS { - 421
let pattern = format!("`{}`", tool.name.to_lowercase()); - 422
if lower.contains(&pattern) { - 423
return Err(format!( - 424
"skill '{}' references retired tool '{}' — run `vak setup seed` \ - 425
to remove the containing plugin, or edit this SKILL.md to use \ - 426
the replacement: {}", - 427
name, tool.name, tool.replacement - 428
)); - 429
} - 430
} - 431
Ok(( - 432
Skill { - 433
name, - 434
description, - 435
path: path.to_path_buf(), - 436
provenance: None, - 437
shadowed: false, - 438
serves, - 439
}, - 440
warnings, - 441
)) - 442
} - 443
- 444
fn valid_name(name: &str) -> bool { - 445
!name.is_empty() - 446
&& name.len() <= 64 - 447
&& !name.starts_with('-') - 448
&& !name.ends_with('-') - 449
&& !name.contains("--") - 450
&& name - 451
.chars() - 452
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') - 453
} - 454
- 455
/// The one place skills are listed to the model: name and description, - 456
/// one line each. Bodies are loaded on demand through the `skill` tool, - 457
/// which checks the admitted digest. - 458
pub fn prompt_section_from_capabilities( - 459
capabilities: &[vak_session::types::CapabilityDescriptor], - 460
) -> String { - 461
let skills = capabilities - 462
.iter() - 463
.filter(|capability| capability.kind == vak_session::types::CapabilityKind::Skill) - 464
.collect::<Vec<_>>(); - 465
if skills.is_empty() { - 466
return String::new(); - 467
} - 468
let mut out = String::from( - 469
"\nSkills (load one with `skill({\"name\": \"...\"})` before relying on it; a skill name is never a tool name):\n", - 470
); - 471
for skill in skills { - 472
let description = skill - 473
.description - 474
.split_whitespace() - 475
.collect::<Vec<_>>() - 476
.join(" "); - 477
out.push_str(&format!("- `{}`: {}\n", skill.name, description)); - 478
} - 479
out - 480
} - 481
- 482
#[cfg(test)] - 483
#[allow(clippy::unwrap_used)] - 484
mod tests { - 485
use super::*; - 486
- 487
#[test] - 488
fn parser_requires_standard_name_and_description() -> Result<(), Box<dyn std::error::Error>> { - 489
let dir = tempfile::tempdir()?; - 490
let path = dir.path().join("SKILL.md"); - 491
std::fs::write(&path, "---\nname: Good_Name\ndescription: bad\n---\nbody")?; - 492
assert!(parse(&path).is_none()); - 493
std::fs::write( - 494
&path, - 495
"---\nname: good-name\ndescription: useful\n---\nbody", - 496
)?; - 497
let skill = - 498
parse(&path).ok_or_else(|| std::io::Error::other("valid skill should parse"))?; - 499
assert_eq!(skill.name, "good-name"); - 500
Ok(()) - 501
} - 502
- 503
#[test] - 504
fn discovery_marks_lower_precedence_duplicates_shadowed() - 505
-> Result<(), Box<dyn std::error::Error>> { - 506
let dir = tempfile::tempdir()?; - 507
let project = dir.path().join(".vak/skills/demo"); - 508
let home = dir.path().join("home/skills/demo"); - 509
std::fs::create_dir_all(&project)?; - 510
std::fs::create_dir_all(&home)?; - 511
let body = "---\nname: demo\ndescription: demo skill\n---\nbody"; - 512
std::fs::write(project.join("SKILL.md"), body)?; - 513
std::fs::write(home.join("SKILL.md"), body)?; - 514
let all = discover_all_with_plugins(dir.path(), &dir.path().join("home"), &[]); - 515
assert_eq!(all.len(), 2); - 516
assert!(!all[0].shadowed); - 517
assert!(all[1].shadowed); - 518
assert_eq!(discover(dir.path(), &dir.path().join("home")).len(), 1); - 519
Ok(()) - 520
} - 521
- 522
#[test] - 523
fn validation_reports_advisory_fields_and_rejects_long_descriptions() - 524
-> Result<(), Box<dyn std::error::Error>> { - 525
let dir = tempfile::tempdir()?; - 526
let path = dir.path().join("SKILL.md"); - 527
std::fs::write( - 528
&path, - 529
"---\nname: safe-skill\ndescription: useful\nallowed-tools: Bash\n---\nbody", - 530
)?; - 531
let (_, warnings) = validate(&path).map_err(std::io::Error::other)?; - 532
assert_eq!( - 533
warnings, - 534
vec!["allowed-tools is advisory and never grants authorization"] - 535
); - 536
std::fs::write( - 537
&path, - 538
format!( - 539
"---\nname: safe-skill\ndescription: {}\n---\nbody", - 540
"x".repeat(1025) - 541
), - 542
)?; - 543
assert!(validate(&path).is_err()); - 544
Ok(()) - 545
} - 546
- 547
#[test] - 548
fn prompt_lists_each_skill_once_without_leaking_paths_or_bodies() { - 549
let skill = vak_session::types::CapabilityDescriptor { - 550
name: "code-task".into(), - 551
kind: vak_session::types::CapabilityKind::Skill, - 552
invocation: vak_session::types::CapabilityInvocation::ModelTool, - 553
description: "focused\n implementation".into(), - 554
source: Some("/workspace/.vak/skills/code-task/SKILL.md".into()), - 555
digest: Some("sha".into()), - 556
provenance: None, - 557
configuration: serde_json::Value::Null, - 558
}; - 559
let prompt = prompt_section_from_capabilities(&[skill]); - 560
assert!(prompt.contains("`skill({\"name\": \"...\"})`")); - 561
assert!(prompt.contains("- `code-task`: focused implementation\n")); - 562
assert!(!prompt.contains("/workspace/.vak/skills")); - 563
let tool = SkillTool::new([FrozenSkill { - 564
name: "code-task".into(), - 565
description: "focused implementation".into(), - 566
path: "/workspace/.vak/skills/code-task/SKILL.md".into(), - 567
digest: "sha".into(), - 568
provenance: None, - 569
}]); - 570
use vak_tools::Tool; - 571
assert!( - 572
!tool.description().contains("focused implementation"), - 573
"the catalogue is listed once, in the prompt" - 574
); - 575
assert_eq!(tool.schema()["properties"]["name"]["enum"][0], "code-task"); - 576
} - 577
- 578
#[tokio::test] - 579
async fn skill_tool_loads_only_the_frozen_digest() -> Result<(), Box<dyn std::error::Error>> { - 580
use vak_tools::Tool; - 581
- 582
let dir = tempfile::tempdir()?; - 583
let path = dir.path().join("SKILL.md"); - 584
std::fs::write( - 585
&path, - 586
"---\nname: code-task\ndescription: focused implementation\n---\nUse table-driven tests.", - 587
)?; - 588
let skill = parse(&path).ok_or("skill should parse")?; - 589
let digest = skill.digest()?; - 590
let tool = SkillTool::new([FrozenSkill { - 591
name: skill.name, - 592
description: skill.description, - 593
path: path.clone(), - 594
digest, - 595
provenance: None, - 596
}]); - 597
let ctx = vak_tools::ToolContext { - 598
cwd: dir.path().to_path_buf(), - 599
cancel: tokio_util::sync::CancellationToken::new(), - 600
sandbox: None, - 601
sandbox_sink: None, - 602
agent_id: None, - 603
new_documents: Vec::new(), - 604
}; - 605
let loaded = tool - 606
.execute(&serde_json::json!({"name": "code-task"}), &ctx) - 607
.await; - 608
assert!(!loaded.is_error); - 609
assert!(loaded.content.contains("Use table-driven tests.")); - 610
assert!(loaded.content.contains("References are relative to")); - 611
- 612
std::fs::write(&path, "changed after admission")?; - 613
let stale = tool - 614
.execute(&serde_json::json!({"name": "code-task"}), &ctx) - 615
.await; - 616
assert!(stale.is_error); - 617
assert!(stale.content.contains("capability_stale")); - 618
Ok(()) - 619
} - 620
- 621
#[test] - 622
fn discover_with_diagnostics_reports_parse_failures() -> Result<(), Box<dyn std::error::Error>> - 623
{ - 624
let dir = tempfile::tempdir()?; - 625
let good = dir.path().join(".vak/skills/good-skill"); - 626
let bad = dir.path().join(".vak/skills/Bad_Skill"); - 627
std::fs::create_dir_all(&good)?; - 628
std::fs::create_dir_all(&bad)?; - 629
std::fs::write( - 630
good.join("SKILL.md"), - 631
"---\nname: good-skill\ndescription: useful\n---\nbody", - 632
)?; - 633
std::fs::write( - 634
bad.join("SKILL.md"), - 635
"---\nname: Bad_Skill\ndescription: bad\n---\nbody", - 636
)?; - 637
let home = dir.path().join("home"); - 638
std::fs::create_dir_all(&home)?; - 639
let (skills, diagnostics) = discover_with_diagnostics(dir.path(), &home, &[]); - 640
assert_eq!(skills.len(), 1); - 641
assert_eq!(skills[0].name, "good-skill"); - 642
assert_eq!(diagnostics.len(), 1); - 643
assert!( - 644
diagnostics[0] - 645
.reason - 646
.contains("not valid lowercase kebab-case") - 647
); - 648
Ok(()) - 649
} - 650
- 651
#[test] - 652
fn explicit_skill_command_expands_before_model_dispatch() - 653
-> Result<(), Box<dyn std::error::Error>> { - 654
let dir = tempfile::tempdir()?; - 655
let path = dir.path().join("SKILL.md"); - 656
std::fs::write( - 657
&path, - 658
"---\nname: code-task\ndescription: focused implementation\n---\nFollow the workflow.", - 659
)?; - 660
let skill = parse(&path).ok_or("skill should parse")?; - 661
let digest = skill.digest()?; - 662
let frozen = FrozenSkill { - 663
name: skill.name, - 664
description: skill.description, - 665
path, - 666
digest, - 667
provenance: None, - 668
}; - 669
let expanded = expand_invocation("/skill:code-task fix parser", &[frozen])? - 670
.ok_or("command should expand")?; - 671
assert!(expanded.contains("<skill name=\"code-task\"")); - 672
assert!(expanded.contains("Follow the workflow.")); - 673
assert!(expanded.ends_with("fix parser")); - 674
Ok(()) - 675
} - 676
- 677
#[test] - 678
fn validate_rejects_skills_referencing_retired_tools() { - 679
let dir = tempfile::tempdir().unwrap(); - 680
let path = dir.path().join("SKILL.md"); - 681
// A skill description that mentions `python_eval` — the exact - 682
// pattern from the stale python-sandbox plugin. - 683
std::fs::write( - 684
&path, - 685
"---\nname: bad-skill\ndescription: Uses `python_eval` for code.\n---\nCall `python_eval`.\n", - 686
) - 687
.unwrap(); - 688
let result = validate(&path); - 689
assert!( - 690
result.is_err(), - 691
"skill referencing retired tool must be rejected" - 692
); - 693
let err = result.unwrap_err(); - 694
assert!( - 695
err.contains("python_eval"), - 696
"error must name the retired tool: {err}" - 697
); - 698
assert!( - 699
err.contains("bash"), - 700
"error must mention the replacement: {err}" - 701
); - 702
} - 703
- 704
#[test] - 705
fn validate_accepts_skills_without_retired_tool_refs() { - 706
let dir = tempfile::tempdir().unwrap(); - 707
let path = dir.path().join("SKILL.md"); - 708
std::fs::write( - 709
&path, - 710
"---\nname: good-skill\ndescription: Use bash for code execution.\n---\nUse `bash` to run commands.\n", - 711
) - 712
.unwrap(); - 713
assert!(validate(&path).is_ok()); - 714
} - 715
} - 716
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.