skiller 0.3.0

Declarative project and global skill management over the Vercel Skills CLI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{Context, Result, bail};

use crate::model::{
    CatalogMetadata, CatalogRegistration, GlobalConfig, valid_name, validate_alias, validate_schema,
};
use crate::paths::{
    cache_root, global_config_path, read_json, read_json_or_default, safe_remove_owned_dir,
    sanitize_child_output, write_global_config,
};

#[derive(Debug, Clone)]
pub struct CatalogIndex {
    pub alias: String,
    pub source: String,
    pub root: PathBuf,
    pub metadata: CatalogMetadata,
    pub skills: BTreeMap<String, CatalogSkill>,
}

#[derive(Debug, Clone)]
pub struct CatalogSkill {
    pub name: String,
    pub description: String,
    pub scope: Option<String>,
    pub installed_name: String,
    pub global: bool,
    pub requires: Vec<String>,
}

pub fn load_global_config() -> Result<GlobalConfig> {
    let config: GlobalConfig = read_json_or_default(&global_config_path()?)?;
    validate_schema(config.version, "global config")?;
    for alias in config.catalogs.keys() {
        validate_alias(alias)?;
    }
    Ok(config)
}

pub fn add_catalog(alias: &str, source: &str) -> Result<()> {
    validate_alias(alias)?;
    if source.trim().is_empty() {
        bail!("catalog source cannot be empty");
    }
    let mut config = load_global_config()?;
    if config.catalogs.contains_key(alias) {
        bail!("catalog alias already exists: {alias}");
    }
    let registration = CatalogRegistration {
        source: source.to_owned(),
    };
    let index = sync_catalog(alias, &registration)?;
    config.catalogs.insert(alias.to_owned(), registration);
    write_global_config(&config)?;
    println!(
        "added catalog {alias}: {} skill{} from {source}",
        index.skills.len(),
        if index.skills.len() == 1 { "" } else { "s" }
    );
    Ok(())
}

pub fn sync_registered_catalogs(config: &GlobalConfig) -> Result<BTreeMap<String, CatalogIndex>> {
    config
        .catalogs
        .iter()
        .map(|(alias, registration)| {
            sync_catalog(alias, registration).map(|catalog| (alias.clone(), catalog))
        })
        .collect()
}

pub fn sync_catalog(alias: &str, registration: &CatalogRegistration) -> Result<CatalogIndex> {
    validate_alias(alias)?;
    let source_path = PathBuf::from(&registration.source);
    let root = if source_path.exists() {
        source_path
            .canonicalize()
            .with_context(|| format!("resolving catalog source {}", registration.source))?
    } else {
        clone_catalog(alias, &registration.source)?
    };
    scan_catalog(alias, &registration.source, &root)
}

fn clone_catalog(alias: &str, source: &str) -> Result<PathBuf> {
    let catalogs_root = cache_root()?.join("catalogs");
    crate::paths::ensure_real_dir(&catalogs_root)?;
    let destination = catalogs_root.join(alias);
    safe_remove_owned_dir(&destination, &catalogs_root)?;

    let candidates = clone_candidates(source);
    let mut failures = Vec::new();
    for candidate in candidates {
        let output = Command::new("git")
            .args(["clone", "--depth", "1", &candidate])
            .arg(&destination)
            .output()
            .with_context(|| format!("starting git clone for {source}"))?;
        if output.status.success() {
            return destination
                .canonicalize()
                .with_context(|| format!("resolving cloned catalog {alias}"));
        }
        failures.push(sanitize_child_output(&output.stderr));
        safe_remove_owned_dir(&destination, &catalogs_root)?;
    }
    bail!(
        "failed to clone catalog {source}: {}",
        failures.join(" | ").trim()
    )
}

fn clone_candidates(source: &str) -> Vec<String> {
    let slash_count = source.bytes().filter(|byte| *byte == b'/').count();
    if slash_count == 1 && !source.contains(':') && !source.starts_with('.') {
        vec![
            format!("https://github.com/{source}.git"),
            format!("git@github.com:{source}.git"),
        ]
    } else {
        vec![source.to_owned()]
    }
}

fn scan_catalog(alias: &str, source: &str, root: &Path) -> Result<CatalogIndex> {
    let metadata_path = root.join("skiller.json");
    let metadata: CatalogMetadata = if metadata_path.exists() {
        let value: CatalogMetadata = read_json(&metadata_path)?;
        validate_schema(value.version, "catalog metadata")?;
        value
    } else {
        CatalogMetadata::default()
    };
    for scope in metadata.scopes.keys() {
        if !valid_name(scope) {
            bail!("invalid scope name in {}: {scope}", metadata_path.display());
        }
    }

    let skills_root = root.join("skills");
    let entries = std::fs::read_dir(&skills_root).with_context(|| {
        format!(
            "catalog has no readable skills directory: {}",
            skills_root.display()
        )
    })?;
    let mut skills = BTreeMap::new();
    for entry in entries {
        let entry = entry?;
        let file_type = entry.file_type()?;
        if !file_type.is_dir() || file_type.is_symlink() {
            continue;
        }
        let directory_name = entry.file_name().to_string_lossy().into_owned();
        let skill_path = entry.path().join("SKILL.md");
        if !skill_path.is_file() {
            continue;
        }
        let raw = std::fs::read_to_string(&skill_path)
            .with_context(|| format!("reading {}", skill_path.display()))?;
        let frontmatter = frontmatter(&raw, &skill_path)?;
        let name = scalar(frontmatter, "name").unwrap_or_else(|| directory_name.clone());
        if name != directory_name || !valid_name(&name) {
            bail!(
                "Agent Skills requires a valid name matching its folder: {}",
                skill_path.display()
            );
        }
        let description =
            scalar(frontmatter, "description").unwrap_or_else(|| "No description".to_owned());
        let skill_metadata = metadata.skills.get(&name).cloned().unwrap_or_default();
        let scope = skill_metadata.scope;
        if let Some(scope) = &scope
            && !metadata.scopes.contains_key(scope)
        {
            bail!("skill {name} references unknown scope {scope}");
        }
        let requires = nested_scalar(frontmatter, "skiller.requires")
            .map(|value| {
                value
                    .split(',')
                    .map(str::trim)
                    .filter(|dependency| !dependency.is_empty())
                    .map(str::to_owned)
                    .collect()
            })
            .unwrap_or_default();
        let installed_name = installed_name(&name, scope.as_deref())?;
        skills.insert(
            name.clone(),
            CatalogSkill {
                name,
                description,
                scope,
                installed_name,
                global: skill_metadata.global,
                requires,
            },
        );
    }
    if skills.is_empty() {
        bail!(
            "catalog contains no flat skills under {}",
            skills_root.display()
        );
    }
    for name in metadata.skills.keys() {
        if !skills.contains_key(name) {
            bail!("catalog metadata references missing skill: {name}");
        }
    }
    validate_dependencies(&skills)?;
    Ok(CatalogIndex {
        alias: alias.to_owned(),
        source: source.to_owned(),
        root: root.to_owned(),
        metadata,
        skills,
    })
}

pub fn installed_name(name: &str, scope: Option<&str>) -> Result<String> {
    // ^ Agent Skills names and matching folder names: https://agentskills.io/specification
    let value = match scope {
        Some(scope) => format!("{name}-{scope}"),
        None => name.to_owned(),
    };
    if !valid_name(&value) {
        bail!("postfixed skill name is not Agent Skills compatible: {value}");
    }
    Ok(value)
}

fn frontmatter<'a>(raw: &'a str, path: &Path) -> Result<&'a str> {
    let mut lines = raw.split_inclusive('\n');
    let first = lines
        .next()
        .unwrap_or_default()
        .trim_end_matches(['\r', '\n']);
    if first != "---" {
        bail!("SKILL.md is missing YAML frontmatter: {}", path.display());
    }
    let start = first.len() + raw[first.len()..].find('\n').map_or(0, |_| 1);
    let rest = &raw[start..];
    let mut offset = 0;
    for line in rest.split_inclusive('\n') {
        if line.trim_end_matches(['\r', '\n']) == "---" {
            return Ok(&rest[..offset]);
        }
        offset += line.len();
    }
    bail!("SKILL.md frontmatter is not closed: {}", path.display())
}

fn scalar(frontmatter: &str, key: &str) -> Option<String> {
    let lines: Vec<_> = frontmatter.lines().collect();
    lines.iter().enumerate().find_map(|(index, line)| {
        let (candidate, value) = line.split_once(':')?;
        if candidate.trim() != key || candidate.starts_with(char::is_whitespace) {
            return None;
        }
        scalar_value(&lines, index, value)
    })
}

fn nested_scalar(frontmatter: &str, key: &str) -> Option<String> {
    let lines: Vec<_> = frontmatter.lines().collect();
    lines.iter().enumerate().find_map(|(index, line)| {
        let (candidate, value) = line.split_once(':')?;
        (candidate.trim() == key)
            .then(|| scalar_value(&lines, index, value))
            .flatten()
    })
}

fn scalar_value(lines: &[&str], index: usize, value: &str) -> Option<String> {
    let value = value.trim();
    if value.starts_with('>') || value.starts_with('|') {
        let folded = lines[index + 1..]
            .iter()
            .take_while(|line| line.is_empty() || line.starts_with(char::is_whitespace))
            .map(|line| line.trim())
            .filter(|line| !line.is_empty())
            .collect::<Vec<_>>()
            .join(" ");
        return (!folded.is_empty()).then_some(folded);
    }
    if value.is_empty() {
        return None;
    }
    Some(value.trim_matches(['\'', '"']).to_owned())
}

fn validate_dependencies(skills: &BTreeMap<String, CatalogSkill>) -> Result<()> {
    for skill in skills.values() {
        for dependency in &skill.requires {
            if !skills.contains_key(dependency) {
                bail!("skill {} has invalid dependency {dependency}", skill.name);
            }
        }
    }
    fn visit(
        name: &str,
        skills: &BTreeMap<String, CatalogSkill>,
        visiting: &mut Vec<String>,
        visited: &mut std::collections::BTreeSet<String>,
    ) -> Result<()> {
        if let Some(index) = visiting.iter().position(|candidate| candidate == name) {
            let mut cycle = visiting[index..].to_vec();
            cycle.push(name.to_owned());
            bail!("catalog dependency cycle: {}", cycle.join(" -> "));
        }
        if visited.contains(name) {
            return Ok(());
        }
        visiting.push(name.to_owned());
        for dependency in &skills[name].requires {
            visit(dependency, skills, visiting, visited)?;
        }
        visiting.pop();
        visited.insert(name.to_owned());
        Ok(())
    }
    let mut visited = std::collections::BTreeSet::new();
    for name in skills.keys() {
        visit(name, skills, &mut Vec::new(), &mut visited)?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn scope_is_postfixed_with_portable_separator() {
        assert_eq!(
            installed_name("develop", Some("engineering")).unwrap(),
            "develop-engineering"
        );
        assert!(installed_name(&"a".repeat(60), Some("scope")).is_err());
    }

    #[test]
    fn github_shorthand_has_authenticated_fallback() {
        assert_eq!(
            clone_candidates("vlwkaos/skills"),
            vec![
                "https://github.com/vlwkaos/skills.git",
                "git@github.com:vlwkaos/skills.git"
            ]
        );
    }

    #[test]
    fn nested_skiller_dependency_string_is_parsed() {
        let frontmatter = "name: develop\nmetadata:\n  skiller.requires: \"recall,simplify\"\n";
        assert_eq!(
            nested_scalar(frontmatter, "skiller.requires").as_deref(),
            Some("recall,simplify")
        );
    }

    #[test]
    fn folded_description_is_joined_for_noninteractive_output() {
        let frontmatter = "name: recall\ndescription: >-\n  Load project context before planning.\n  Skip literal lookups.\nmetadata:\n  skiller.requires: dream\n";
        assert_eq!(
            scalar(frontmatter, "description").as_deref(),
            Some("Load project context before planning. Skip literal lookups.")
        );
    }

    fn dependency_skill(name: &str, requires: &[&str]) -> CatalogSkill {
        CatalogSkill {
            name: name.to_owned(),
            description: name.to_owned(),
            scope: None,
            installed_name: name.to_owned(),
            global: true,
            requires: requires
                .iter()
                .map(|dependency| (*dependency).to_owned())
                .collect(),
        }
    }

    #[test]
    fn dependency_cycles_report_the_complete_path() {
        let direct = BTreeMap::from([("a".to_owned(), dependency_skill("a", &["a"]))]);
        assert_eq!(
            validate_dependencies(&direct).unwrap_err().to_string(),
            "catalog dependency cycle: a -> a"
        );

        let indirect = BTreeMap::from([
            ("a".to_owned(), dependency_skill("a", &["b"])),
            ("b".to_owned(), dependency_skill("b", &["c"])),
            ("c".to_owned(), dependency_skill("c", &["a"])),
        ]);
        assert_eq!(
            validate_dependencies(&indirect).unwrap_err().to_string(),
            "catalog dependency cycle: a -> b -> c -> a"
        );
    }
}