1
+//! Agent Skills support for siGit Code.
2
+//!
3
+//! Implements the open [Agent Skills](https://agentskills.io) format: a skill is
4
+//! a directory containing a `SKILL.md` file with YAML frontmatter (`name` +
5
+//! `description`, plus optional fields) followed by Markdown instructions. Skills
6
+//! may bundle `scripts/`, `references/`, and `assets/` the agent loads on demand.
7
+//!
8
+//! Loading follows the spec's *progressive disclosure*:
9
+//!
10
+//! 1. **Discovery** — at turn-build time we scan the skill roots and load only
11
+//! each skill's `name` and `description` into the `skill` tool's description,
12
+//! so the model knows what's available for a small context cost.
13
+//! 2. **Activation** — when a task matches, the model calls the `skill` tool with
14
+//! a name; [`activate_skill`] reads the full `SKILL.md` body into context.
15
+//! 3. **Execution** — the model follows the instructions, reading bundled files
16
+//! (under the reported skill directory) with the normal file/command tools.
17
+//!
18
+//! Skills are discovered from, in priority order (earlier wins on name clashes):
19
+//!
20
+//! - `<cwd>/.sigit/skills/` and `<cwd>/.claude/skills/` (project-local)
21
+//! - `$SIGIT_CONFIG_DIR/skills/` (default `~/.config/sigit/skills/`)
22
+//! - `~/.claude/skills/` (shared with the broader ecosystem)
23
+
24
+use std::path::{Path, PathBuf};
25
+
26
+use serde_json::{Value, json};
27
+
28
+/// The agent-facing tool name used to activate a skill.
29
+pub const SKILL_TOOL_NAME: &str = "skill";
30
+
31
+/// Hard cap on how many skills we advertise, to bound the tool description size.
32
+const MAX_ADVERTISED_SKILLS: usize = 100;
33
+
34
+/// A discovered skill: its identifying metadata plus where it lives on disk.
35
+#[derive(Debug, Clone, PartialEq, Eq)]
36
+pub struct Skill {
37
+ /// The `name` from frontmatter. Lowercase alphanumeric + single hyphens.
38
+ pub name: String,
39
+ /// The `description` from frontmatter: what the skill does and when to use it.
40
+ pub description: String,
41
+ /// Optional `license` field.
42
+ pub license: Option<String>,
43
+ /// Optional `compatibility` field (environment requirements).
44
+ pub compatibility: Option<String>,
45
+ /// The skill's root directory (the one holding `SKILL.md`).
46
+ pub dir: PathBuf,
47
+}
48
+
49
+impl Skill {
50
+ /// Absolute path to this skill's `SKILL.md`.
51
+ fn skill_md(&self) -> PathBuf {
52
+ self.dir.join("SKILL.md")
53
+ }
54
+}
55
+
56
+/// JSON Schema for the `skill` tool's arguments.
57
+pub fn skill_tool_schema() -> Value {
58
+ json!({
59
+ "type": "object",
60
+ "properties": {
61
+ "name": {
62
+ "type": "string",
63
+ "description": "The name of the skill to activate, exactly as listed in this tool's description."
64
+ }
65
+ },
66
+ "required": ["name"],
67
+ "additionalProperties": false
68
+ })
69
+}
70
+
71
+/// Build the `skill` tool description, embedding the discovery list (each skill's
72
+/// `name` and `description`) so the model can decide when to activate one.
73
+pub fn skill_tool_description(skills: &[Skill]) -> String {
74
+ let mut out = String::from(
75
+ "Activate an Agent Skill to load its full instructions into context. \
76
+ Skills are reusable, on-demand capabilities — specialized knowledge and \
77
+ step-by-step workflows packaged as a folder. Only the name and description \
78
+ of each skill are loaded up front; calling this tool with a skill's `name` \
79
+ reads its full instructions (and tells you the skill's directory, so you \
80
+ can read any bundled scripts, references, or assets with the file and \
81
+ command tools). Activate a skill as soon as the user's task matches one of \
82
+ the descriptions below; follow its instructions over your defaults.\n\n\
83
+ Available skills:\n",
84
+ );
85
+ for skill in skills.iter().take(MAX_ADVERTISED_SKILLS) {
86
+ out.push_str("- ");
87
+ out.push_str(&skill.name);
88
+ out.push_str(": ");
89
+ out.push_str(&skill.description);
90
+ out.push('\n');
91
+ }
92
+ out
93
+}
94
+
95
+/// Human-readable list of discovered skills, for the `/skills` slash command.
96
+pub fn format_skills_list() -> String {
97
+ let skills = discover_skills();
98
+ if skills.is_empty() {
99
+ return "No skills found. Add a skill folder (with a SKILL.md) under \
100
+ .sigit/skills/ or .claude/skills/ in your project, or under \
101
+ ~/.config/sigit/skills/. See https://agentskills.io."
102
+ .to_string();
103
+ }
104
+
105
+ let mut lines = vec![format!("{} skill(s) available:", skills.len())];
106
+ for skill in &skills {
107
+ lines.push(format!("- {}: {}", skill.name, skill.description));
108
+ }
109
+ lines.push(String::new());
110
+ lines.push(
111
+ "I activate a skill automatically when your task matches its description.".to_string(),
112
+ );
113
+ lines.join("\n")
114
+}
115
+
116
+/// Execute the `skill` tool: parse the requested name and return the full
117
+/// `SKILL.md` body, prefixed with the skill's directory so relative references
118
+/// (e.g. `scripts/foo.py`, `references/REFERENCE.md`) can be resolved.
119
+pub fn activate_skill(arguments: &str) -> String {
120
+ let args: Value = match serde_json::from_str(arguments) {
121
+ Ok(v) => v,
122
+ Err(err) => return format!("Error: failed to parse arguments: {err}"),
123
+ };
124
+
125
+ let name = match args.get("name").and_then(Value::as_str) {
126
+ Some(n) => n.trim(),
127
+ None => return "Error: missing required parameter \"name\"".to_string(),
128
+ };
129
+
130
+ // Re-discover so activation always reflects the skills on disk right now.
131
+ let skills = discover_skills();
132
+ let Some(skill) = skills.iter().find(|s| s.name == name) else {
133
+ if skills.is_empty() {
134
+ return format!("Error: no skill named \"{name}\" is available (no skills found).");
135
+ }
136
+ let available = skills
137
+ .iter()
138
+ .map(|s| s.name.as_str())
139
+ .collect::<Vec<_>>()
140
+ .join(", ");
141
+ return format!("Error: no skill named \"{name}\". Available skills: {available}.");
142
+ };
143
+
144
+ let body = match read_skill_body(&skill.skill_md()) {
145
+ Ok(body) => body,
146
+ Err(err) => {
147
+ return format!(
148
+ "Error: could not read SKILL.md for \"{name}\" at {}: {err}",
149
+ skill.skill_md().display()
150
+ );
151
+ }
152
+ };
153
+
154
+ // Surface the optional metadata so the agent (and user) can sanity-check
155
+ // environment requirements before following the instructions.
156
+ let mut notes = String::new();
157
+ if let Some(compatibility) = &skill.compatibility {
158
+ notes.push_str(&format!("Compatibility: {compatibility}\n"));
159
+ }
160
+ if let Some(license) = &skill.license {
161
+ notes.push_str(&format!("License: {license}\n"));
162
+ }
163
+ if !notes.is_empty() {
164
+ notes.push('\n');
165
+ }
166
+
167
+ let dir = skill.dir.display();
168
+ format!(
169
+ "Skill \"{name}\" activated. Its directory is {dir} — resolve any relative \
170
+ file references (scripts/, references/, assets/) against that path. Follow \
171
+ these instructions:\n\n{notes}{body}"
172
+ )
173
+}
174
+
175
+/// Discover all valid skills across the known roots. Earlier roots win when two
176
+/// skills share a `name`. The result is sorted by name for stable output.
177
+pub fn discover_skills() -> Vec<Skill> {
178
+ let mut skills: Vec<Skill> = Vec::new();
179
+ let mut seen_names: Vec<String> = Vec::new();
180
+
181
+ for root in skill_roots() {
182
+ collect_skills_from_root(&root, &mut skills, &mut seen_names);
183
+ }
184
+
185
+ skills.sort_by(|a, b| a.name.cmp(&b.name));
186
+ skills
187
+}
188
+
189
+/// The skill directories to scan, in priority order.
190
+fn skill_roots() -> Vec<PathBuf> {
191
+ let mut roots = Vec::new();
192
+
193
+ // Project-local skills win over user-global ones.
194
+ if let Ok(cwd) = std::env::current_dir() {
195
+ roots.push(cwd.join(".sigit").join("skills"));
196
+ roots.push(cwd.join(".claude").join("skills"));
197
+ }
198
+
199
+ // User-global siGit config dir (honours SIGIT_CONFIG_DIR).
200
+ if let Some(config_dir) = sigit_config_dir() {
201
+ roots.push(config_dir.join("skills"));
202
+ }
203
+
204
+ // Shared with the broader Agent Skills ecosystem.
205
+ if let Some(home) = home_dir() {
206
+ roots.push(home.join(".claude").join("skills"));
207
+ }
208
+
209
+ roots
210
+}
211
+
212
+fn sigit_config_dir() -> Option<PathBuf> {
213
+ if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
214
+ && !dir.is_empty()
215
+ {
216
+ return Some(PathBuf::from(dir));
217
+ }
218
+ home_dir().map(|home| home.join(".config").join("sigit"))
219
+}
220
+
221
+fn home_dir() -> Option<PathBuf> {
222
+ std::env::var("HOME").ok().map(PathBuf::from)
223
+}
224
+
225
+/// Scan a single root for skill subdirectories, appending newly-seen skills.
226
+fn collect_skills_from_root(root: &Path, skills: &mut Vec<Skill>, seen_names: &mut Vec<String>) {
227
+ let entries = match std::fs::read_dir(root) {
228
+ Ok(entries) => entries,
229
+ // Most roots won't exist; that's expected, not an error.
230
+ Err(_) => return,
231
+ };
232
+
233
+ for entry in entries.flatten() {
234
+ let dir = entry.path();
235
+ if !dir.is_dir() {
236
+ continue;
237
+ }
238
+
239
+ let skill_md = dir.join("SKILL.md");
240
+ if !skill_md.is_file() {
241
+ continue;
242
+ }
243
+
244
+ let contents = match std::fs::read_to_string(&skill_md) {
245
+ Ok(contents) => contents,
246
+ Err(error) => {
247
+ log::warn!("skipping skill at {}: {error}", skill_md.display());
248
+ continue;
249
+ }
250
+ };
251
+
252
+ let skill = match parse_skill(&contents, &dir) {
253
+ Ok(skill) => skill,
254
+ Err(error) => {
255
+ log::warn!("skipping invalid skill at {}: {error}", skill_md.display());
256
+ continue;
257
+ }
258
+ };
259
+
260
+ // First root to define a name wins; later duplicates are ignored.
261
+ if seen_names.iter().any(|name| name == &skill.name) {
262
+ log::debug!(
263
+ "skill \"{}\" at {} shadowed by an earlier definition",
264
+ skill.name,
265
+ dir.display()
266
+ );
267
+ continue;
268
+ }
269
+
270
+ seen_names.push(skill.name.clone());
271
+ skills.push(skill);
272
+ }
273
+}
274
+
275
+/// Parse a `SKILL.md` into a [`Skill`], validating the required fields against
276
+/// the Agent Skills spec. `dir` is the skill's root directory.
277
+fn parse_skill(contents: &str, dir: &Path) -> Result<Skill, String> {
278
+ let frontmatter = extract_frontmatter(contents)
279
+ .ok_or_else(|| "missing YAML frontmatter (expected leading `---` block)".to_string())?;
280
+
281
+ let fields = parse_frontmatter_fields(frontmatter);
282
+
283
+ let name = fields
284
+ .iter()
285
+ .find(|(k, _)| k == "name")
286
+ .map(|(_, v)| v.clone())
287
+ .ok_or_else(|| "frontmatter is missing required field `name`".to_string())?;
288
+ validate_name(&name)?;
289
+
290
+ let description = fields
291
+ .iter()
292
+ .find(|(k, _)| k == "description")
293
+ .map(|(_, v)| v.clone())
294
+ .ok_or_else(|| "frontmatter is missing required field `description`".to_string())?;
295
+ if description.is_empty() {
296
+ return Err("`description` must not be empty".to_string());
297
+ }
298
+ if description.chars().count() > 1024 {
299
+ return Err("`description` exceeds the 1024-character limit".to_string());
300
+ }
301
+
302
+ // The spec requires `name` to match the parent directory name. Warn but stay
303
+ // lenient — the frontmatter name is the identity used for activation.
304
+ if let Some(dir_name) = dir.file_name().and_then(|n| n.to_str())
305
+ && dir_name != name
306
+ {
307
+ log::warn!(
308
+ "skill name \"{name}\" does not match its directory \"{dir_name}\" at {}",
309
+ dir.display()
310
+ );
311
+ }
312
+
313
+ let license = fields
314
+ .iter()
315
+ .find(|(k, _)| k == "license")
316
+ .map(|(_, v)| v.clone())
317
+ .filter(|v| !v.is_empty());
318
+ let compatibility = fields
319
+ .iter()
320
+ .find(|(k, _)| k == "compatibility")
321
+ .map(|(_, v)| v.clone())
322
+ .filter(|v| !v.is_empty());
323
+
324
+ Ok(Skill {
325
+ name,
326
+ description,
327
+ license,
328
+ compatibility,
329
+ dir: dir.to_path_buf(),
330
+ })
331
+}
332
+
333
+/// Validate the `name` field per the Agent Skills spec: 1-64 chars, lowercase
334
+/// alphanumeric and hyphens only, no leading/trailing or consecutive hyphens.
335
+fn validate_name(name: &str) -> Result<(), String> {
336
+ let len = name.chars().count();
337
+ if len == 0 {
338
+ return Err("`name` must not be empty".to_string());
339
+ }
340
+ if len > 64 {
341
+ return Err("`name` exceeds the 64-character limit".to_string());
342
+ }
343
+ if !name
344
+ .chars()
345
+ .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
346
+ {
347
+ return Err("`name` may only contain lowercase letters, digits, and hyphens".to_string());
348
+ }
349
+ if name.starts_with('-') || name.ends_with('-') {
350
+ return Err("`name` must not start or end with a hyphen".to_string());
351
+ }
352
+ if name.contains("--") {
353
+ return Err("`name` must not contain consecutive hyphens".to_string());
354
+ }
355
+ Ok(())
356
+}
357
+
358
+/// Extract the YAML frontmatter block from a `SKILL.md`: the text between a
359
+/// leading `---` line and the next `---` line. Returns `None` if absent.
360
+fn extract_frontmatter(contents: &str) -> Option<&str> {
361
+ // Strip an optional UTF-8 BOM and leading blank lines before the opener.
362
+ let trimmed = contents.trim_start_matches('\u{feff}');
363
+ let mut rest = trimmed;
364
+ loop {
365
+ let line_end = rest.find('\n').map(|i| i + 1).unwrap_or(rest.len());
366
+ let (line, after) = rest.split_at(line_end);
367
+ if line.trim().is_empty() {
368
+ rest = after;
369
+ continue;
370
+ }
371
+ if line.trim() != "---" {
372
+ return None;
373
+ }
374
+ // `after` now begins just past the opening `---` line.
375
+ let body = after;
376
+ let mut search = body;
377
+ let mut offset = 0;
378
+ loop {
379
+ let end = search.find('\n').map(|i| i + 1).unwrap_or(search.len());
380
+ let (l, a) = search.split_at(end);
381
+ if l.trim() == "---" {
382
+ return Some(&body[..offset]);
383
+ }
384
+ if a.is_empty() {
385
+ return None;
386
+ }
387
+ offset += end;
388
+ search = a;
389
+ }
390
+ }
391
+}
392
+
393
+/// Parse top-level `key: value` scalar pairs from frontmatter, skipping nested
394
+/// mappings (indented lines) and comments. Quoted values are unquoted. We only
395
+/// need scalar metadata (`name`, `description`, `license`, `compatibility`).
396
+fn parse_frontmatter_fields(frontmatter: &str) -> Vec<(String, String)> {
397
+ let mut fields = Vec::new();
398
+ for line in frontmatter.lines() {
399
+ // Indented lines belong to a nested mapping/sequence — skip them.
400
+ if line.starts_with(char::is_whitespace) {
401
+ continue;
402
+ }
403
+ let line = line.trim_end();
404
+ if line.is_empty() || line.starts_with('#') {
405
+ continue;
406
+ }
407
+ let Some((key, value)) = line.split_once(':') else {
408
+ continue;
409
+ };
410
+ let key = key.trim();
411
+ if key.is_empty() {
412
+ continue;
413
+ }
414
+ let value = unquote(value.trim());
415
+ fields.push((key.to_string(), value));
416
+ }
417
+ fields
418
+}
419
+
420
+/// Strip a single layer of matching single or double quotes; otherwise return
421
+/// the value unchanged. Also drops a trailing `# comment` on unquoted scalars.
422
+fn unquote(value: &str) -> String {
423
+ if value.len() >= 2 {
424
+ let bytes = value.as_bytes();
425
+ let first = bytes[0];
426
+ let last = bytes[value.len() - 1];
427
+ if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
428
+ return value[1..value.len() - 1].to_string();
429
+ }
430
+ }
431
+ value.to_string()
432
+}
433
+
434
+/// Read the Markdown body of a `SKILL.md` (everything after the frontmatter),
435
+/// falling back to the whole file if no frontmatter delimiter is found.
436
+fn read_skill_body(skill_md: &Path) -> Result<String, String> {
437
+ let contents = std::fs::read_to_string(skill_md).map_err(|e| e.to_string())?;
438
+ Ok(strip_frontmatter(&contents).trim().to_string())
439
+}
440
+
441
+/// Return the content after the frontmatter block, or the whole input if there
442
+/// is no frontmatter.
443
+fn strip_frontmatter(contents: &str) -> &str {
444
+ let trimmed = contents.trim_start_matches('\u{feff}');
445
+ let after_opener = match trimmed.strip_prefix("---") {
446
+ Some(rest) => match rest.strip_prefix('\n') {
447
+ Some(rest) => rest,
448
+ None => return contents,
449
+ },
450
+ None => return contents,
451
+ };
452
+ // Find the closing `---` line.
453
+ let mut search = after_opener;
454
+ let mut offset = 0;
455
+ loop {
456
+ let end = search.find('\n').map(|i| i + 1).unwrap_or(search.len());
457
+ let (line, after) = search.split_at(end);
458
+ if line.trim() == "---" {
459
+ return &after_opener[offset + end..];
460
+ }
461
+ if after.is_empty() {
462
+ return contents;
463
+ }
464
+ offset += end;
465
+ search = after;
466
+ }
467
+}
468
+
469
+#[cfg(test)]
470
+mod tests {
471
+ use super::*;
472
+ use std::fs;
473
+
474
+ fn unique_dir(name: &str) -> PathBuf {
475
+ let nanos = std::time::SystemTime::now()
476
+ .duration_since(std::time::UNIX_EPOCH)
477
+ .unwrap()
478
+ .as_nanos();
479
+ std::env::temp_dir().join(format!("sigit-skills-test-{name}-{nanos}"))
480
+ }
481
+
482
+ #[test]
483
+ fn validate_name_accepts_valid_names() {
484
+ assert!(validate_name("pdf-processing").is_ok());
485
+ assert!(validate_name("data-analysis").is_ok());
486
+ assert!(validate_name("code-review").is_ok());
487
+ assert!(validate_name("a").is_ok());
488
+ assert!(validate_name("skill1").is_ok());
489
+ }
490
+
491
+ #[test]
492
+ fn validate_name_rejects_invalid_names() {
493
+ assert!(validate_name("").is_err());
494
+ assert!(validate_name("PDF-Processing").is_err());
495
+ assert!(validate_name("-pdf").is_err());
496
+ assert!(validate_name("pdf-").is_err());
497
+ assert!(validate_name("pdf--processing").is_err());
498
+ assert!(validate_name("pdf_processing").is_err());
499
+ assert!(validate_name(&"a".repeat(65)).is_err());
500
+ }
501
+
502
+ #[test]
503
+ fn extract_frontmatter_reads_block() {
504
+ let md = "---\nname: foo\ndescription: bar\n---\n\nBody here.\n";
505
+ let fm = extract_frontmatter(md).expect("frontmatter");
506
+ assert!(fm.contains("name: foo"));
507
+ assert!(fm.contains("description: bar"));
508
+ assert!(!fm.contains("Body here"));
509
+ }
510
+
511
+ #[test]
512
+ fn extract_frontmatter_none_without_delimiter() {
513
+ assert!(extract_frontmatter("no frontmatter here").is_none());
514
+ assert!(extract_frontmatter("---\nname: foo\n").is_none());
515
+ }
516
+
517
+ #[test]
518
+ fn parse_fields_handles_quotes_and_nesting() {
519
+ let fm = "name: pdf-processing\ndescription: \"Extract PDF text\"\nmetadata:\n author: me\n version: \"1.0\"\nlicense: Apache-2.0\n";
520
+ let fields = parse_frontmatter_fields(fm);
521
+ let get = |k: &str| {
522
+ fields
523
+ .iter()
524
+ .find(|(key, _)| key == k)
525
+ .map(|(_, v)| v.clone())
526
+ };
527
+ assert_eq!(get("name").as_deref(), Some("pdf-processing"));
528
+ assert_eq!(get("description").as_deref(), Some("Extract PDF text"));
529
+ assert_eq!(get("license").as_deref(), Some("Apache-2.0"));
530
+ // Nested keys under `metadata:` are skipped.
531
+ assert!(get("author").is_none());
532
+ assert!(get("version").is_none());
533
+ }
534
+
535
+ #[test]
536
+ fn parse_skill_requires_name_and_description() {
537
+ let dir = Path::new("/tmp/example-skill");
538
+ assert!(parse_skill("---\ndescription: x\n---\n", dir).is_err());
539
+ assert!(parse_skill("---\nname: x\n---\n", dir).is_err());
540
+ let ok = parse_skill(
541
+ "---\nname: example-skill\ndescription: does things\n---\nbody",
542
+ dir,
543
+ );
544
+ assert!(ok.is_ok());
545
+ let skill = ok.unwrap();
546
+ assert_eq!(skill.name, "example-skill");
547
+ assert_eq!(skill.description, "does things");
548
+ }
549
+
550
+ #[test]
551
+ fn strip_frontmatter_returns_body() {
552
+ let md = "---\nname: foo\ndescription: bar\n---\n\n# Heading\n\nText.\n";
553
+ assert_eq!(strip_frontmatter(md).trim(), "# Heading\n\nText.");
554
+ }
555
+
556
+ #[test]
557
+ fn strip_frontmatter_passes_through_without_block() {
558
+ assert_eq!(strip_frontmatter("just body"), "just body");
559
+ }
560
+
561
+ #[test]
562
+ fn discover_and_activate_roundtrip() {
563
+ let root = unique_dir("roundtrip");
564
+ let skills_root = root.join(".sigit").join("skills");
565
+ let skill_dir = skills_root.join("hello-world");
566
+ fs::create_dir_all(&skill_dir).unwrap();
567
+ fs::write(
568
+ skill_dir.join("SKILL.md"),
569
+ "---\nname: hello-world\ndescription: Say hello. Use when greeting.\n---\n\nGreet the user warmly.\n",
570
+ )
571
+ .unwrap();
572
+
573
+ // discover_skills() reads the current directory, so run from `root`.
574
+ let prev = std::env::current_dir().unwrap();
575
+ std::env::set_current_dir(&root).unwrap();
576
+
577
+ let skills = discover_skills();
578
+ let found = skills.iter().find(|s| s.name == "hello-world");
579
+ assert!(found.is_some(), "expected to discover hello-world");
580
+ assert_eq!(found.unwrap().description, "Say hello. Use when greeting.");
581
+
582
+ let activated = activate_skill(r#"{"name": "hello-world"}"#);
583
+ assert!(activated.contains("Greet the user warmly."));
584
+ assert!(activated.contains("activated"));
585
+
586
+ let missing = activate_skill(r#"{"name": "nope"}"#);
587
+ assert!(missing.contains("no skill named"));
588
+
589
+ std::env::set_current_dir(prev).unwrap();
590
+ let _ = fs::remove_dir_all(&root);
591
+ }
592
+
593
+ #[test]
594
+ fn skill_tool_description_lists_skills() {
595
+ let skills = vec![Skill {
596
+ name: "pdf-processing".to_string(),
597
+ description: "Extract PDF text".to_string(),
598
+ license: None,
599
+ compatibility: None,
600
+ dir: PathBuf::from("/x/pdf-processing"),
601
+ }];
602
+ let desc = skill_tool_description(&skills);
603
+ assert!(desc.contains("Available skills:"));
604
+ assert!(desc.contains("- pdf-processing: Extract PDF text"));
605
+ }
606
+
607
+ #[test]
608
+ fn skill_tool_schema_requires_name() {
609
+ let schema = skill_tool_schema();
610
+ assert_eq!(schema["type"], "object");
611
+ assert_eq!(schema["required"][0], "name");
612
+ }
613
+}