Skip to main content

oxios_kernel/tools/builtin/
skill_forge_tool.rs

1//! Agent tool for authoring, validating, and packaging skills.
2//!
3//! Wraps the [`ExtensionApi`] / [`SkillManager`] domain of the [`KernelHandle`].
4//! Mirrors Anthropic's `skill-creator` model split along the knowledge/capability
5//! seam: this tool is the *capability* (deterministic create/validate/package),
6//! the bundled `skill-creator` skill is the *methodology* (how to write a good
7//! skill). The tool is self-discoverable — its description carries enough
8//! authoring guidance that an agent can produce well-formed skills even when the
9//! methodology skill is not loaded (e.g. in an installed binary before default
10//! skills are embedded).
11//!
12//! ## Actions
13//!
14//! | Action    | Description                                  | Required params       | Optional params |
15//! |-----------|----------------------------------------------|-----------------------|-----------------|
16//! | `list`    | List all installed skills                    | —                     | —               |
17//! | `get`     | Get a skill's full content + metadata        | `name`                | —               |
18//! | `create`  | Create a skill (synthesized frontmatter)     | `name`, `description` | `content`       |
19//! | `write`   | Write raw `SKILL.md` (rich frontmatter kept) | `name`, `content`     | —               |
20//! | `validate`| Validate a skill's structure                 | —                     | `name`, `content` |
21//! | `package` | Package a skill into a `.skill` zip          | `name`                | —               |
22//! | `import`  | Import a skill from raw `SKILL.md` text      | `content`             | `name`          |
23//! | `delete`  | Delete a skill                               | `name`                | —               |
24//! | `enable`  | Enable a skill                               | `name`                | —               |
25//! | `disable` | Disable a skill                              | `name`                | —               |
26
27use std::fs;
28use std::io::{self, Write};
29use std::path::{Path, PathBuf};
30
31use anyhow::Context as _;
32use async_trait::async_trait;
33use oxi_sdk::{AgentTool, AgentToolResult, ToolContext};
34use serde::{Deserialize, Serialize};
35use serde_json::{Value, json};
36
37use crate::kernel_handle::KernelHandle;
38use crate::skill::SkillManager;
39use crate::skill::frontmatter::parse_skill;
40
41/// Agent tool for skill authoring, validation, and packaging.
42///
43/// Holds an [`Arc<SkillManager>`] cloned from the [`KernelHandle`] extensions
44/// facade. All mutations go through the manager so the in-memory index and the
45/// on-disk tree stay consistent.
46pub struct SkillForgeTool {
47    skill_manager: std::sync::Arc<SkillManager>,
48}
49
50impl SkillForgeTool {
51    /// Build a [`SkillForgeTool`] from a [`KernelHandle`].
52    pub fn from_kernel(kernel: &KernelHandle) -> Self {
53        Self {
54            skill_manager: kernel.extensions.skill_manager().clone(),
55        }
56    }
57}
58
59impl std::fmt::Debug for SkillForgeTool {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.debug_struct("SkillForgeTool").finish()
62    }
63}
64
65#[async_trait]
66impl AgentTool for SkillForgeTool {
67    fn name(&self) -> &str {
68        "skill_forge"
69    }
70
71    fn label(&self) -> &str {
72        "Skill Forge"
73    }
74
75    fn description(&self) -> &'static str {
76        "Create, validate, package, import, and manage skills. \
77         A skill is a folder with a SKILL.md file: YAML frontmatter with `name` and `description` \
78         (the description is the PRIMARY trigger — write it to fire when the user wants this \
79         capability, and make it a little pushy), followed by markdown instructions. \
80         Skills use progressive disclosure: (1) name+description always in context, (2) the SKILL.md \
81         body loaded when the skill triggers (keep under ~500 lines), (3) optional bundled \
82         resources — `scripts/`, `references/`, `assets/` — read on demand via their absolute path. \
83         Use `create` to scaffold a new skill, `write` to author rich SKILL.md content, `validate` \
84         to check structure before shipping, and `package` to export a distributable `.skill` zip. \
85         Use this tool whenever the user wants to build, edit, test, package, or ship a skill."
86    }
87
88    fn parameters_schema(&self) -> Value {
89        json!({
90            "type": "object",
91            "properties": {
92                "action": {
93                    "type": "string",
94                    "enum": ["list", "get", "create", "write", "validate", "package",
95                             "import", "delete", "enable", "disable", "benchmark", "view"],
96                    "description": "Skill operation. Authoring: create/write/validate/package/import/list/get/delete/enable/disable. Eval (deterministic post-processing): `benchmark` aggregates grading.json files in an eval workspace iteration into benchmark.json+benchmark.md (mean±stddev, delta); `view` generates a static self-contained HTML viewer of an eval iteration."
97                },
98                "name": {
99                    "type": "string",
100                    "description": "Skill name (lowercase, hyphens). Required for get/create/write/validate/package/delete/enable/disable. For `import`, an optional hint when frontmatter has no name."
101                },
102                "description": {
103                    "type": "string",
104                    "description": "One-line description of when the skill should trigger (create action)"
105                },
106                "content": {
107                    "type": "string",
108                    "description": "create: the markdown body (frontmatter synthesized for you). write/import: the FULL raw SKILL.md including frontmatter. validate: full raw SKILL.md when `name` is omitted."
109                },
110                "workspace": {
111                    "type": "string",
112                    "description": "benchmark/view: absolute path to an eval workspace ITERATION directory (contains per-eval subdirs each with with_skill/ and a baseline run holding grading.json + timing.json)."
113                },
114                "skill_name": {
115                    "type": "string",
116                    "description": "benchmark/view: skill name for labeling the report (optional)."
117                },
118                "output": {
119                    "type": "string",
120                    "description": "view: where to write the static HTML file (optional; defaults to <workspace>/review.html)."
121                }
122            },
123            "required": ["action"]
124        })
125    }
126
127    async fn execute(
128        &self,
129        _tool_call_id: &str,
130        params: Value,
131        _signal: Option<tokio::sync::oneshot::Receiver<()>>,
132        _ctx: &ToolContext,
133    ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
134        let action = params
135            .get("action")
136            .and_then(|v| v.as_str())
137            .ok_or_else(|| "Missing required parameter: action".to_string())?;
138        let name = params.get("name").and_then(|v| v.as_str());
139        let description = params.get("description").and_then(|v| v.as_str());
140        let content = params.get("content").and_then(|v| v.as_str());
141        let workspace = params.get("workspace").and_then(|v| v.as_str());
142        let skill_name = params.get("skill_name").and_then(|v| v.as_str());
143        let output = params.get("output").and_then(|v| v.as_str());
144
145        match action {
146            "list" => self.act_list().await,
147            "get" => {
148                let name = name.ok_or("'get' requires 'name'")?;
149                self.act_get(name).await
150            }
151            "create" => {
152                let name = name.ok_or("'create' requires 'name'")?;
153                let description = description.unwrap_or("");
154                let content = content.unwrap_or("");
155                self.act_create(name, description, content).await
156            }
157            "write" => {
158                let name = name.ok_or("'write' requires 'name'")?;
159                let content = content.ok_or("'write' requires 'content' (full raw SKILL.md)")?;
160                self.act_write(name, content).await
161            }
162            "validate" => self.act_validate(name, content).await,
163            "package" => {
164                let name = name.ok_or("'package' requires 'name'")?;
165                self.act_package(name).await
166            }
167            "import" => {
168                let content = content.ok_or("'import' requires 'content' (raw SKILL.md text)")?;
169                self.act_import(content, name).await
170            }
171            "delete" => {
172                let name = name.ok_or("'delete' requires 'name'")?;
173                self.act_delete(name).await
174            }
175            "enable" => {
176                let name = name.ok_or("'enable' requires 'name'")?;
177                self.act_set_enabled(name, true).await
178            }
179            "disable" => {
180                let name = name.ok_or("'disable' requires 'name'")?;
181                self.act_set_enabled(name, false).await
182            }
183            "benchmark" => {
184                let workspace =
185                    workspace.ok_or("'benchmark' requires 'workspace' (iteration dir)")?;
186                self.act_benchmark(workspace, skill_name).await
187            }
188            "view" => {
189                let workspace = workspace.ok_or("'view' requires 'workspace' (iteration dir)")?;
190                self.act_view(workspace, skill_name, output).await
191            }
192            other => Ok(AgentToolResult::error(format!("Unknown action: {other}"))),
193        }
194    }
195}
196
197impl SkillForgeTool {
198    async fn act_list(&self) -> Result<AgentToolResult, oxi_sdk::ToolError> {
199        let entries = self.skill_manager.list_skills().await;
200        let rows: Vec<Value> = entries
201            .iter()
202            .map(|e| {
203                json!({
204                    "name": e.skill.name,
205                    "description": e.skill.description,
206                    "status": e.status.to_string(),
207                    "format": e.format.to_string(),
208                    "bundled": e.bundled,
209                })
210            })
211            .collect();
212        Ok(AgentToolResult::success(
213            serde_json::to_string_pretty(&json!({ "skills": rows, "count": rows.len() }))
214                .unwrap_or_default(),
215        ))
216    }
217
218    async fn act_get(&self, name: &str) -> Result<AgentToolResult, oxi_sdk::ToolError> {
219        match self.skill_manager.get_skill(name).await {
220            Some(e) => {
221                let meta = e.metadata.as_ref();
222                Ok(AgentToolResult::success(
223                    serde_json::to_string_pretty(&json!({
224                        "name": e.skill.name,
225                        "description": e.skill.description,
226                        "status": e.status.to_string(),
227                        "format": e.format.to_string(),
228                        "bundled": e.bundled,
229                        "skill_dir": e.skill.path.parent().map(|p| p.display().to_string()),
230                        "file_path": e.skill.file_path.display().to_string(),
231                        "author": meta.and_then(|m| m.author.clone()),
232                        "version": meta.and_then(|m| m.version.clone()),
233                        "content": e.skill.content,
234                    }))
235                    .unwrap_or_default(),
236                ))
237            }
238            None => Ok(AgentToolResult::error(format!("Skill not found: {name}"))),
239        }
240    }
241
242    async fn act_create(
243        &self,
244        name: &str,
245        description: &str,
246        content: &str,
247    ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
248        // Pre-validate the proposed shape so agents get fast feedback.
249        let synthetic = format!("---\nname: {name}\ndescription: {description}\n---\n\n{content}");
250        let report = validate_skill_content(&synthetic, Some(name));
251        if report.has_errors() {
252            return Ok(AgentToolResult::error(format!(
253                "Refusing to create skill with structural errors:\n{}",
254                report.render()
255            )));
256        }
257        match self
258            .skill_manager
259            .create_skill(name, description, content)
260            .await
261        {
262            Ok(()) => Ok(AgentToolResult::success(
263                serde_json::to_string_pretty(&json!({
264                    "ok": true,
265                    "name": name,
266                    "path": self.skill_dir_of(name).await,
267                    "warnings": report.warnings(),
268                }))
269                .unwrap_or_default(),
270            )),
271            Err(e) => Ok(AgentToolResult::error(format!("Create failed: {e}"))),
272        }
273    }
274
275    async fn act_write(
276        &self,
277        name: &str,
278        content: &str,
279    ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
280        let report = validate_skill_content(content, Some(name));
281        if report.has_errors() {
282            return Ok(AgentToolResult::error(format!(
283                "Refusing to write skill with structural errors:\n{}",
284                report.render()
285            )));
286        }
287        match self.skill_manager.write_skill_raw(name, content).await {
288            Ok(entry) => Ok(AgentToolResult::success(
289                serde_json::to_string_pretty(&json!({
290                    "ok": true,
291                    "name": entry.skill.name,
292                    "description": entry.skill.description,
293                    "path": entry.skill.file_path.display().to_string(),
294                    "warnings": report.warnings(),
295                }))
296                .unwrap_or_default(),
297            )),
298            Err(e) => Ok(AgentToolResult::error(format!("Write failed: {e}"))),
299        }
300    }
301
302    async fn act_validate(
303        &self,
304        name: Option<&str>,
305        content: Option<&str>,
306    ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
307        let report = match (name, content) {
308            (Some(n), Some(c)) => {
309                // Prefer the on-disk skill if it exists; fall back to supplied content.
310                match self.skill_manager.get_skill(n).await {
311                    Some(e) => {
312                        let raw = read_skill_md(&e.skill.file_path);
313                        validate_skill_content(&raw, Some(n))
314                    }
315                    None => validate_skill_content(c, Some(n)),
316                }
317            }
318            (Some(n), None) => {
319                let e = self.skill_manager.get_skill(n).await.ok_or_else(|| {
320                    format!("Skill not found: {n} (pass `content` to validate raw text)")
321                })?;
322                let raw = read_skill_md(&e.skill.file_path);
323                validate_skill_content(&raw, Some(n))
324            }
325            (None, Some(c)) => validate_skill_content(c, None),
326            (None, None) => {
327                return Ok(AgentToolResult::error(
328                    "validate requires `name` (of an installed skill) or `content` (raw SKILL.md)",
329                ));
330            }
331        };
332        Ok(AgentToolResult::success(
333            serde_json::to_string_pretty(&report.to_json()).unwrap_or_default(),
334        ))
335    }
336
337    async fn act_package(&self, name: &str) -> Result<AgentToolResult, oxi_sdk::ToolError> {
338        let entry = self
339            .skill_manager
340            .get_skill(name)
341            .await
342            .ok_or_else(|| format!("Skill not found: {name}"))?;
343        let skill_dir = entry
344            .skill
345            .path
346            .parent()
347            .ok_or("skill has no directory")?
348            .to_path_buf();
349        let out = self.skill_manager.path().join(format!("{name}.skill"));
350        match package_skill_dir(&skill_dir, name, &out) {
351            Ok(path) => Ok(AgentToolResult::success(
352                serde_json::to_string_pretty(&json!({
353                    "ok": true,
354                    "name": name,
355                    "archive": path.display().to_string(),
356                }))
357                .unwrap_or_default(),
358            )),
359            Err(e) => Ok(AgentToolResult::error(format!("Package failed: {e}"))),
360        }
361    }
362
363    async fn act_import(
364        &self,
365        content: &str,
366        name_hint: Option<&str>,
367    ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
368        match self
369            .skill_manager
370            .import_skill_text(content, name_hint)
371            .await
372        {
373            Ok(entry) => Ok(AgentToolResult::success(
374                serde_json::to_string_pretty(&json!({
375                    "ok": true,
376                    "name": entry.skill.name,
377                    "description": entry.skill.description,
378                    "path": entry.skill.file_path.display().to_string(),
379                }))
380                .unwrap_or_default(),
381            )),
382            Err(e) => Ok(AgentToolResult::error(format!("Import failed: {e}"))),
383        }
384    }
385
386    async fn act_delete(&self, name: &str) -> Result<AgentToolResult, oxi_sdk::ToolError> {
387        match self.skill_manager.delete_skill(name).await {
388            Ok(()) => Ok(AgentToolResult::success(
389                serde_json::to_string_pretty(&json!({ "ok": true, "deleted": name }))
390                    .unwrap_or_default(),
391            )),
392            Err(e) => Ok(AgentToolResult::error(format!("Delete failed: {e}"))),
393        }
394    }
395
396    async fn act_set_enabled(
397        &self,
398        name: &str,
399        enabled: bool,
400    ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
401        let op = if enabled { "enable" } else { "disable" };
402        match self.skill_manager.set_enabled(name, enabled).await {
403            Ok(()) => Ok(AgentToolResult::success(
404                serde_json::to_string_pretty(&json!({
405                    "ok": true, "name": name, "enabled": enabled
406                }))
407                .unwrap_or_default(),
408            )),
409            Err(e) => Ok(AgentToolResult::error(format!("{op} failed: {e}"))),
410        }
411    }
412
413    async fn skill_dir_of(&self, name: &str) -> String {
414        self.skill_manager
415            .get_skill(name)
416            .await
417            .and_then(|e| e.skill.path.parent().map(|p| p.display().to_string()))
418            .unwrap_or_default()
419    }
420    async fn act_benchmark(
421        &self,
422        workspace: &str,
423        skill_name: Option<&str>,
424    ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
425        let name = skill_name.unwrap_or("skill");
426        match aggregate_benchmark(Path::new(workspace), name) {
427            Ok(bench) => {
428                let json_path = Path::new(workspace).join("benchmark.json");
429                let md_path = Path::new(workspace).join("benchmark.md");
430                let json_err = std::fs::write(&json_path, bench.to_json_string()).err();
431                let md_err = std::fs::write(&md_path, bench.to_markdown()).err();
432                if let Some(e) = json_err.or(md_err) {
433                    return Ok(AgentToolResult::error(format!(
434                        "Wrote partial benchmark but failed to persist: {e}"
435                    )));
436                }
437                Ok(AgentToolResult::success(
438                    serde_json::to_string_pretty(&json!({
439                        "ok": true,
440                        "benchmark_json": json_path.display().to_string(),
441                        "benchmark_md": md_path.display().to_string(),
442                        "configs": bench.config_names(),
443                        "evals": bench.metadata.eval_count,
444                        "delta_pass_rate": bench.delta.pass_rate,
445                    }))
446                    .unwrap_or_default(),
447                ))
448            }
449            Err(e) => Ok(AgentToolResult::error(format!("Benchmark failed: {e}"))),
450        }
451    }
452
453    async fn act_view(
454        &self,
455        workspace: &str,
456        skill_name: Option<&str>,
457        output: Option<&str>,
458    ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
459        let name = skill_name.unwrap_or("skill");
460        let out = output
461            .map(PathBuf::from)
462            .unwrap_or_else(|| Path::new(workspace).join("review.html"));
463        match generate_review_html(Path::new(workspace), name, &out) {
464            Ok(path) => Ok(AgentToolResult::success(
465                serde_json::to_string_pretty(&json!({
466                    "ok": true,
467                    "html": path.display().to_string(),
468                    "note": "Open this file in a browser. Use the Outputs tab to review each eval and leave feedback; the Benchmark tab shows the quantitative comparison."
469                }))
470                .unwrap_or_default(),
471            )),
472            Err(e) => Ok(AgentToolResult::error(format!("View failed: {e}"))),
473        }
474    }
475}
476
477fn read_skill_md(path: &Path) -> String {
478    fs::read_to_string(path).unwrap_or_default()
479}
480
481// ─── Validation (port of Anthropic quick_validate) ──────────────────────────
482
483/// One structural finding from validation.
484#[derive(Debug, Clone)]
485pub struct Finding {
486    pub severity: Severity,
487    pub message: String,
488}
489
490#[derive(Debug, Clone, Copy, PartialEq, Eq)]
491pub enum Severity {
492    /// Blocks create/write — the skill is malformed.
493    Error,
494    /// Worth fixing before shipping — progressive-disclosure guidance, etc.
495    Warning,
496}
497
498impl Finding {
499    fn warn(msg: impl Into<String>) -> Self {
500        Self {
501            severity: Severity::Warning,
502            message: msg.into(),
503        }
504    }
505    fn error(msg: impl Into<String>) -> Self {
506        Self {
507            severity: Severity::Error,
508            message: msg.into(),
509        }
510    }
511}
512
513/// Result of validating a skill's structure.
514#[derive(Debug, Clone, Default)]
515pub struct ValidationReport {
516    pub findings: Vec<Finding>,
517    pub name: String,
518    pub description: String,
519    pub body_lines: usize,
520    pub format: String,
521}
522
523impl ValidationReport {
524    pub fn has_errors(&self) -> bool {
525        self.findings.iter().any(|f| f.severity == Severity::Error)
526    }
527
528    pub fn warnings(&self) -> Vec<String> {
529        self.findings
530            .iter()
531            .filter(|f| f.severity == Severity::Warning)
532            .map(|f| f.message.clone())
533            .collect()
534    }
535
536    pub fn render(&self) -> String {
537        self.findings
538            .iter()
539            .map(|f| {
540                let lvl = match f.severity {
541                    Severity::Error => "ERROR",
542                    Severity::Warning => "WARN",
543                };
544                format!("  [{lvl}] {}", f.message)
545            })
546            .collect::<Vec<_>>()
547            .join("\n")
548    }
549
550    pub fn to_json(&self) -> Value {
551        json!({
552            "valid": !self.has_errors(),
553            "name": self.name,
554            "description_chars": self.description.chars().count(),
555            "body_lines": self.body_lines,
556            "format": self.format,
557            "findings": self.findings.iter().map(|f| {
558                let lvl = match f.severity {
559                    Severity::Error => "error",
560                    Severity::Warning => "warning",
561                };
562                json!({ "severity": lvl, "message": f.message })
563            }).collect::<Vec<_>>(),
564        })
565    }
566}
567
568/// Validate a raw `SKILL.md` (frontmatter + body).
569///
570/// `dir_name` is the expected skill directory name; when supplied, a mismatch
571/// with the frontmatter `name` is flagged (matters for packaged `.skill`
572/// archives where the top-level folder must equal the skill name).
573pub fn validate_skill_content(content: &str, dir_name: Option<&str>) -> ValidationReport {
574    let mut findings = Vec::new();
575
576    // Frontmatter presence.
577    let trimmed = content.trim_start();
578    if !trimmed.starts_with("---") {
579        findings.push(Finding::error(
580            "Missing YAML frontmatter (must start with `---`).",
581        ));
582        return ValidationReport {
583            findings,
584            ..Default::default()
585        };
586    }
587
588    // Parse via the shared pipeline — reuses format detection + body sanitization.
589    let (parsed, body) = match parse_skill(content, &PathBuf::new()) {
590        Ok(v) => v,
591        Err(e) => {
592            findings.push(Finding::error(format!("Frontmatter does not parse: {e}")));
593            return ValidationReport {
594                findings,
595                ..Default::default()
596            };
597        }
598    };
599
600    let name = parsed.name.trim().to_string();
601    let description = parsed.description.trim().to_string();
602    let body_lines = body.lines().count();
603
604    // name checks.
605    if name.is_empty() {
606        findings.push(Finding::error("Frontmatter is missing `name`."));
607    } else {
608        if !is_valid_skill_name(&name) {
609            findings.push(Finding::error(format!(
610                "Skill name `{name}` is invalid — use lowercase ascii letters, digits, and hyphens only."
611            )));
612        }
613        if let Some(dir) = dir_name
614            && dir != name
615        {
616            findings.push(Finding::error(format!(
617                "Frontmatter name `{name}` does not match the skill directory `{dir}`. They must be identical for a packaged skill."
618            )));
619        }
620    }
621
622    // description checks (the primary trigger).
623    if description.is_empty() {
624        findings.push(Finding::error(
625            "Frontmatter is missing `description` — this is the PRIMARY trigger and must state when to use the skill.",
626        ));
627    } else if description.chars().count() < 15 {
628        findings.push(Finding::warn(
629            "Description is very short. It is the primary trigger — describe both what the skill does AND when to use it. A little pushy is good.",
630        ));
631    }
632
633    // body checks (progressive disclosure level 2).
634    if body.trim().is_empty() {
635        findings.push(Finding::error(
636            "SKILL.md body is empty — the skill has no instructions.",
637        ));
638    } else if body_lines > 500 {
639        findings.push(Finding::warn(format!(
640            "Body is {body_lines} lines. Progressive disclosure aims for <500; consider moving detail into `references/` and pointing to it."
641        )));
642    }
643
644    ValidationReport {
645        findings,
646        name,
647        description,
648        body_lines,
649        format: parsed.format.to_string(),
650    }
651}
652
653/// A skill name is lowercase ascii letters, digits, and hyphens; non-empty;
654/// no leading/trailing hyphen.
655pub fn is_valid_skill_name(name: &str) -> bool {
656    !name.is_empty()
657        && !name.starts_with('-')
658        && !name.ends_with('-')
659        && name
660            .chars()
661            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
662}
663
664// ─── Packaging (port of Anthropic package_skill) ────────────────────────────
665
666/// Patterns excluded everywhere in a packaged skill tree.
667const EXCLUDE_DIRS: &[&str] = &["__pycache__", "node_modules", ".git"];
668const EXCLUDE_FILES: &[&str] = &[".DS_Store"];
669/// Directories excluded only at the skill root.
670const ROOT_EXCLUDE_DIRS: &[&str] = &["evals"];
671
672/// Package `skill_dir` into a `.skill` zip at `out`, with the skill folder as
673/// the top-level entry (so extraction lands at `<name>/...`).
674///
675/// Excludes build artifacts (`__pycache__`, `node_modules`, `.git`, `.pyc`,
676/// `.DS_Store`) and the root-level `evals/` directory (test cases are not
677/// shipped in the distributable archive).
678pub fn package_skill_dir(
679    skill_dir: &Path,
680    skill_name: &str,
681    out: &Path,
682) -> anyhow::Result<PathBuf> {
683    use zip::ZipWriter;
684    use zip::write::SimpleFileOptions;
685
686    anyhow::ensure!(
687        skill_dir.is_dir(),
688        "skill directory not found: {}",
689        skill_dir.display()
690    );
691    anyhow::ensure!(
692        skill_dir.join("SKILL.md").exists(),
693        "SKILL.md not found in {}",
694        skill_dir.display()
695    );
696
697    if let Some(parent) = out.parent() {
698        fs::create_dir_all(parent)?;
699    }
700    let file = fs::File::create(out)?;
701    let mut zip = ZipWriter::new(file);
702    let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
703
704    let mut stack: Vec<(PathBuf, String)> = vec![(skill_dir.to_path_buf(), skill_name.to_string())];
705    while let Some((dir, prefix)) = stack.pop() {
706        let entries = match fs::read_dir(&dir) {
707            Ok(e) => e,
708            Err(e) => {
709                tracing::warn!(dir = %dir.display(), error = %e, "skipping unreadable dir");
710                continue;
711            }
712        };
713        for entry in entries.flatten() {
714            let path = entry.path();
715            let file_name = match path.file_name().and_then(|n| n.to_str()) {
716                Some(n) => n.to_string(),
717                None => continue,
718            };
719            // Use symlink_metadata so we DON'T follow symlinks — a malicious
720            // skill could otherwise symlink to ~/.ssh/id_rsa etc. and have its
721            // contents embedded in the .skill archive (information disclosure).
722            let file_type = match fs::symlink_metadata(&path) {
723                Ok(m) => m.file_type(),
724                Err(e) => {
725                    tracing::warn!(path = %path.display(), error = %e, "skipping unreadable entry");
726                    continue;
727                }
728            };
729            if file_type.is_symlink() {
730                tracing::warn!(path = %path.display(), "skipping symlink in skill tree");
731                continue;
732            }
733
734            // Root-only excludes: compare against the top-level prefix depth.
735            let is_root = prefix == skill_name;
736            if file_type.is_dir() {
737                if EXCLUDE_DIRS.contains(&file_name.as_str()) {
738                    continue;
739                }
740                if is_root && ROOT_EXCLUDE_DIRS.contains(&file_name.as_str()) {
741                    continue;
742                }
743                stack.push((path, format!("{prefix}/{file_name}")));
744            } else if file_type.is_file() {
745                if EXCLUDE_FILES.contains(&file_name.as_str()) {
746                    continue;
747                }
748                if file_name.ends_with(".pyc") {
749                    continue;
750                }
751                let arcname = format!("{prefix}/{file_name}");
752                if let Err(e) = add_file_to_zip(&mut zip, &path, &arcname, options) {
753                    tracing::warn!(file = %arcname, error = %e, "skipping file");
754                }
755            }
756        }
757    }
758
759    zip.finish()?;
760    Ok(out.to_path_buf())
761}
762
763fn add_file_to_zip(
764    zip: &mut zip::ZipWriter<fs::File>,
765    path: &Path,
766    arcname: &str,
767    options: zip::write::SimpleFileOptions,
768) -> io::Result<()> {
769    let mut f = fs::File::open(path)?;
770    zip.start_file(arcname, options)?;
771    io::copy(&mut f, zip)?;
772    Ok(())
773}
774
775// silence unused import when `Write`/`Read` paths are pruned by the compiler
776#[allow(dead_code)]
777fn _ensure_write_in_scope() -> Option<Box<dyn Write>> {
778    None
779}
780// ─── Eval harness (port of Anthropic aggregate_benchmark + generate_review) ──
781
782/// Aggregate pass-rate / time / token stats for the configurations found in an
783/// eval workspace iteration directory.
784///
785/// Layout expected (per `skill-creator` SKILL.md):
786/// ```text
787/// <iteration-dir>/
788///   <eval-name>/
789///     with_skill/{grading.json, timing.json, outputs/...}
790///     without_skill/{grading.json, timing.json, outputs/...}   # or old_skill/
791///     eval_metadata.json
792/// ```
793/// `with_skill` is the treatment; the first of `without_skill` / `old_skill`
794/// that exists is the baseline. Missing files are skipped (the eval is still
795/// counted, but contributes no data to that configuration).
796pub fn aggregate_benchmark(
797    iteration_dir: &Path,
798    skill_name: &str,
799) -> anyhow::Result<BenchmarkData> {
800    use std::collections::HashMap;
801
802    let mut runs: Vec<BenchmarkRun> = Vec::new();
803    // per-config metric vectors
804    let mut buckets: HashMap<String, MetricVec> = HashMap::new();
805
806    let eval_dirs = list_eval_dirs(iteration_dir)?;
807    for (eval_name, eval_dir) in &eval_dirs {
808        for cfg in ["with_skill", "without_skill", "old_skill"] {
809            let run_dir = eval_dir.join(cfg);
810            if !run_dir.join("grading.json").exists() {
811                continue;
812            }
813            let grading = read_grading(&run_dir.join("grading.json"));
814            let timing = read_timing(&run_dir.join("timing.json"));
815            let pass_rate = grading
816                .as_ref()
817                .and_then(|g| g.summary.as_ref())
818                .map(|s| s.pass_rate)
819                .unwrap_or(0.0);
820            let time_seconds = timing.duration_seconds();
821            let tokens = timing.total_tokens.unwrap_or(0.0);
822
823            // Normalize the baseline label: old_skill → without_skill for the summary.
824            let label = if cfg == "old_skill" {
825                "without_skill"
826            } else {
827                cfg
828            };
829            let mv = buckets.entry(label.to_string()).or_default();
830            mv.pass_rate.push(pass_rate);
831            mv.time_seconds.push(time_seconds);
832            mv.tokens.push(tokens);
833
834            let expectations = grading
835                .as_ref()
836                .map(|g| g.expectations.clone())
837                .unwrap_or_default();
838            runs.push(BenchmarkRun {
839                eval_name: eval_name.clone(),
840                configuration: label.to_string(),
841                pass_rate,
842                time_seconds,
843                tokens,
844                expectations,
845            });
846        }
847    }
848
849    if runs.is_empty() {
850        anyhow::bail!(
851            "no grading.json files found under {}. Run the eval cases (with_skill + baseline) first; \
852             each run dir must contain a grading.json (see references/schemas.md).",
853            iteration_dir.display()
854        );
855    }
856
857    let mut run_summary: HashMap<String, ConfigSummary> = HashMap::new();
858    for (label, mv) in &buckets {
859        run_summary.insert(
860            label.clone(),
861            ConfigSummary {
862                pass_rate: stats(&mv.pass_rate),
863                time_seconds: stats(&mv.time_seconds),
864                tokens: stats(&mv.tokens),
865            },
866        );
867    }
868
869    let delta = {
870        let with = run_summary.get("with_skill");
871        let without = run_summary.get("without_skill");
872        DeltaSummary {
873            pass_rate: diff(
874                with.map(|c| c.pass_rate.mean),
875                without.map(|c| c.pass_rate.mean),
876            ),
877            time_seconds: diff(
878                with.map(|c| c.time_seconds.mean),
879                without.map(|c| c.time_seconds.mean),
880            ),
881            tokens: diff(with.map(|c| c.tokens.mean), without.map(|c| c.tokens.mean)),
882        }
883    };
884
885    let mut notes = Vec::new();
886    // Non-discriminating assertion heuristic: passes 100% in BOTH configs.
887    analyze_results(&runs, &mut notes);
888
889    Ok(BenchmarkData {
890        metadata: BenchmarkMeta {
891            skill_name: skill_name.to_string(),
892            timestamp: chrono::Utc::now().to_rfc3339(),
893            eval_count: eval_dirs.len(),
894            configs: buckets.keys().cloned().collect(),
895        },
896        runs,
897        run_summary,
898        delta,
899        notes,
900    })
901}
902
903#[derive(Default)]
904struct MetricVec {
905    pass_rate: Vec<f64>,
906    time_seconds: Vec<f64>,
907    tokens: Vec<f64>,
908}
909
910/// Mean / sample-stddev / min / max over a slice of f64.
911fn stats(xs: &[f64]) -> ConfigStats {
912    if xs.is_empty() {
913        return ConfigStats {
914            mean: 0.0,
915            stddev: 0.0,
916            min: 0.0,
917            max: 0.0,
918        };
919    }
920    let mean = xs.iter().sum::<f64>() / xs.len() as f64;
921    let variance = if xs.len() > 1 {
922        xs.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (xs.len() - 1) as f64
923    } else {
924        0.0
925    };
926    ConfigStats {
927        mean,
928        stddev: variance.sqrt(),
929        min: xs.iter().cloned().fold(f64::INFINITY, f64::min),
930        max: xs.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
931    }
932}
933
934/// Signed difference `a - b` formatted to a string, or "n/a" if either side is missing.
935fn diff(a: Option<f64>, b: Option<f64>) -> String {
936    match (a, b) {
937        (Some(a), Some(b)) => format!("{:+.2}", a - b),
938        _ => "n/a".to_string(),
939    }
940}
941
942/// Surface a few analyst observations hidden by the aggregate stats.
943fn analyze_results(runs: &[BenchmarkRun], notes: &mut Vec<String>) {
944    use std::collections::HashMap;
945    // Per-eval pass_rate grouped by eval → detect 100%-in-both (non-discriminating).
946    let mut by_eval: HashMap<String, Vec<(&str, f64)>> = HashMap::new();
947    for r in runs {
948        by_eval
949            .entry(r.eval_name.clone())
950            .or_default()
951            .push((r.configuration.as_str(), r.pass_rate));
952    }
953    for (eval, cfgs) in &by_eval {
954        let all_full = cfgs.iter().all(|(_, p)| (*p - 1.0).abs() < 1e-9);
955        let has_multiple = cfgs.len() > 1;
956        if all_full && has_multiple {
957            notes.push(format!(
958                "Eval '{eval}' passes 100% in every configuration — the assertions may not discriminate skill value."
959            ));
960        }
961        if has_multiple {
962            let vals: Vec<f64> = cfgs.iter().map(|(_, p)| *p).collect();
963            let lo = vals.iter().cloned().fold(f64::INFINITY, f64::min);
964            let hi = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
965            let spread = hi - lo;
966            if spread > 0.4 {
967                notes.push(format!(
968                    "Eval '{eval}' shows large pass-rate spread ({spread:.2}) across configurations — inspect for flakiness."
969                ));
970            }
971        }
972    }
973}
974
975fn list_eval_dirs(iteration_dir: &Path) -> anyhow::Result<Vec<(String, PathBuf)>> {
976    let mut out = Vec::new();
977    let entries = fs::read_dir(iteration_dir)
978        .with_context(|| format!("reading iteration dir {}", iteration_dir.display()))?;
979    for entry in entries.flatten() {
980        let path = entry.path();
981        if !path.is_dir() {
982            continue;
983        }
984        // An eval dir contains at least one run subdir with grading.json.
985        let is_eval = ["with_skill", "without_skill", "old_skill"]
986            .iter()
987            .any(|c| path.join(c).join("grading.json").exists());
988        if is_eval {
989            let name = path
990                .file_name()
991                .and_then(|n| n.to_str())
992                .unwrap_or("eval")
993                .to_string();
994            out.push((name, path));
995        }
996    }
997    // Stable order by name.
998    out.sort_by(|a, b| a.0.cmp(&b.0));
999    Ok(out)
1000}
1001
1002fn read_grading(path: &Path) -> Option<GradingFile> {
1003    let raw = fs::read_to_string(path).ok()?;
1004    serde_json::from_str(&raw).ok()
1005}
1006
1007fn read_timing(path: &Path) -> TimingFile {
1008    fs::read_to_string(path)
1009        .ok()
1010        .and_then(|raw| serde_json::from_str(&raw).ok())
1011        .unwrap_or_default()
1012}
1013
1014// ── eval data types ────────────────────────────────────────────────────────
1015
1016#[derive(Debug, Clone, Deserialize)]
1017struct GradingSummary {
1018    #[serde(default)]
1019    pass_rate: f64,
1020}
1021
1022#[derive(Debug, Clone, Default, Deserialize)]
1023struct GradingFile {
1024    #[serde(default)]
1025    summary: Option<GradingSummary>,
1026    #[serde(default)]
1027    expectations: Vec<ExpectationRow>,
1028}
1029
1030#[derive(Debug, Clone, Serialize, Deserialize)]
1031pub struct ExpectationRow {
1032    #[serde(default)]
1033    text: String,
1034    #[serde(default)]
1035    passed: bool,
1036}
1037
1038#[derive(Debug, Clone, Default, Deserialize)]
1039struct TimingFile {
1040    #[serde(default)]
1041    total_tokens: Option<f64>,
1042    #[serde(default)]
1043    total_duration_seconds: Option<f64>,
1044    #[serde(default)]
1045    duration_ms: Option<f64>,
1046}
1047
1048impl TimingFile {
1049    fn duration_seconds(&self) -> f64 {
1050        self.total_duration_seconds
1051            .or_else(|| self.duration_ms.map(|ms| ms / 1000.0))
1052            .unwrap_or(0.0)
1053    }
1054}
1055
1056#[derive(Debug, Clone, Serialize)]
1057pub struct BenchmarkRun {
1058    pub eval_name: String,
1059    pub configuration: String,
1060    pub pass_rate: f64,
1061    pub time_seconds: f64,
1062    pub tokens: f64,
1063    pub expectations: Vec<ExpectationRow>,
1064}
1065
1066#[derive(Debug, Clone, Serialize)]
1067pub struct ConfigStats {
1068    pub mean: f64,
1069    pub stddev: f64,
1070    pub min: f64,
1071    pub max: f64,
1072}
1073
1074/// Serialized as a nested object: `{ mean, stddev, min, max }`.
1075#[derive(Debug, Clone, Serialize)]
1076pub struct ConfigSummary {
1077    pub pass_rate: ConfigStats,
1078    pub time_seconds: ConfigStats,
1079    pub tokens: ConfigStats,
1080}
1081
1082#[derive(Debug, Clone, Serialize)]
1083pub struct DeltaSummary {
1084    pub pass_rate: String,
1085    pub time_seconds: String,
1086    pub tokens: String,
1087}
1088
1089#[derive(Debug, Clone, Serialize)]
1090pub struct BenchmarkMeta {
1091    pub skill_name: String,
1092    pub timestamp: String,
1093    pub eval_count: usize,
1094    pub configs: Vec<String>,
1095}
1096
1097#[derive(Debug, Clone, Serialize)]
1098pub struct BenchmarkData {
1099    pub metadata: BenchmarkMeta,
1100    pub runs: Vec<BenchmarkRun>,
1101    pub run_summary: std::collections::HashMap<String, ConfigSummary>,
1102    pub delta: DeltaSummary,
1103    pub notes: Vec<String>,
1104}
1105
1106impl BenchmarkData {
1107    pub fn config_names(&self) -> Vec<String> {
1108        self.metadata.configs.clone()
1109    }
1110
1111    pub fn to_json_string(&self) -> String {
1112        serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
1113    }
1114
1115    pub fn to_markdown(&self) -> String {
1116        let mut md = String::new();
1117        md.push_str(&format!("# Benchmark — {}\n\n", self.metadata.skill_name));
1118        md.push_str(&format!(
1119            "_{} evals · {} · configs: {}_\n\n",
1120            self.metadata.eval_count,
1121            self.metadata.timestamp,
1122            self.metadata.configs.join(", ")
1123        ));
1124        md.push_str("| config | pass_rate (mean ± σ) | time_s (mean ± σ) | tokens (mean ± σ) |\n");
1125        md.push_str("|---|---|---|---|\n");
1126        for cfg in &self.metadata.configs {
1127            if let Some(c) = self.run_summary.get(cfg) {
1128                md.push_str(&format!(
1129                    "| {} | {:.2} ± {:.2} | {:.1} ± {:.1} | {:.0} ± {:.0} |\n",
1130                    cfg,
1131                    c.pass_rate.mean,
1132                    c.pass_rate.stddev,
1133                    c.time_seconds.mean,
1134                    c.time_seconds.stddev,
1135                    c.tokens.mean,
1136                    c.tokens.stddev
1137                ));
1138            }
1139        }
1140        md.push_str(&format!(
1141            "\n**Δ (with_skill − baseline):** pass_rate {}, time {}s, tokens {}\n",
1142            self.delta.pass_rate, self.delta.time_seconds, self.delta.tokens
1143        ));
1144        if !self.notes.is_empty() {
1145            md.push_str("\n## Notes\n\n");
1146            for n in &self.notes {
1147                md.push_str(&format!("- {n}\n"));
1148            }
1149        }
1150        md
1151    }
1152}
1153
1154// ── static HTML viewer (port of generate_review --static) ──────────────────
1155
1156/// Generate a self-contained static HTML review of an eval iteration.
1157///
1158/// Two sections: per-eval outputs (prompt + produced files + formal grades +
1159/// a feedback textbox) and the benchmark summary (pass-rate/time/tokens per
1160/// config, with the delta and analyst notes). A "Download feedback" button
1161/// serializes all textareas to `feedback.json` client-side — no server needed.
1162pub fn generate_review_html(
1163    iteration_dir: &Path,
1164    skill_name: &str,
1165    out: &Path,
1166) -> anyhow::Result<PathBuf> {
1167    let eval_dirs = list_eval_dirs(iteration_dir)?;
1168    let bench = aggregate_benchmark(iteration_dir, skill_name).ok();
1169
1170    let mut eval_cards = String::new();
1171    for (eval_name, eval_dir) in &eval_dirs {
1172        let prompt = fs::read_to_string(eval_dir.join("eval_metadata.json"))
1173            .ok()
1174            .and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
1175            .and_then(|v| v.get("prompt").and_then(|p| p.as_str()).map(str::to_string))
1176            .unwrap_or_default();
1177        eval_cards.push_str(&render_eval_card(eval_name, &prompt, eval_dir));
1178    }
1179
1180    let benchmark_html = match &bench {
1181        Some(b) => render_benchmark_section(b),
1182        None => "<p><em>No benchmark data yet. Run <code>skill_forge</code> action <code>benchmark</code> after grading.</em></p>".to_string(),
1183    };
1184    let html = HTML_TEMPLATE
1185        .replace("__SKILL_NAME__", &html_escape(skill_name))
1186        .replace("__EVAL_CARDS__", &eval_cards)
1187        .replace("__BENCHMARK__", &benchmark_html);
1188
1189    if let Some(parent) = out.parent() {
1190        fs::create_dir_all(parent).ok();
1191    }
1192    fs::write(out, html)?;
1193    Ok(out.to_path_buf())
1194}
1195
1196fn render_eval_card(eval_name: &str, prompt: &str, eval_dir: &Path) -> String {
1197    let mut card = String::new();
1198    card.push_str(&format!(
1199        "<section class=\"eval\" data-eval=\"{name}\">\n",
1200        name = html_escape(eval_name)
1201    ));
1202    card.push_str(&format!("<h2>{}</h2>\n", html_escape(eval_name)));
1203    if !prompt.is_empty() {
1204        card.push_str(&format!(
1205            "<details open><summary>Prompt</summary><pre>{}</pre></details>\n",
1206            html_escape(prompt)
1207        ));
1208    }
1209    for cfg in ["with_skill", "without_skill", "old_skill"] {
1210        let run_dir = eval_dir.join(cfg);
1211        if !run_dir.exists() {
1212            continue;
1213        }
1214        let label = if cfg == "old_skill" {
1215            "baseline (old_skill)"
1216        } else {
1217            cfg
1218        };
1219        card.push_str(&format!(
1220            "<details><summary><b>{}</b></summary>\n",
1221            html_escape(label)
1222        ));
1223        // outputs
1224        let outputs_dir = run_dir.join("outputs");
1225        if outputs_dir.is_dir() {
1226            card.push_str("<div class=\"outputs\">");
1227            if let Ok(entries) = fs::read_dir(&outputs_dir) {
1228                for e in entries.flatten() {
1229                    if let Some(text) = read_output_text(&e.path()) {
1230                        card.push_str(&format!(
1231                            "<h4>{}</h4><pre>{}</pre>\n",
1232                            html_escape(&e.file_name().to_string_lossy()),
1233                            html_escape(&text)
1234                        ));
1235                    }
1236                }
1237            }
1238            card.push_str("</div>");
1239        }
1240        // formal grades
1241        if let Some(g) = read_grading(&run_dir.join("grading.json")) {
1242            card.push_str("<details><summary>Formal grades</summary><table><tr><th>expectation</th><th>verdict</th></tr>");
1243            for row in &g.expectations {
1244                let verdict = if row.passed { "✅ pass" } else { "❌ fail" };
1245                card.push_str(&format!(
1246                    "<tr><td>{}</td><td>{verdict}</td></tr>",
1247                    html_escape(&row.text)
1248                ));
1249            }
1250            card.push_str("</table></details>");
1251        }
1252        card.push_str(&format!(
1253            "<textarea data-run=\"{cfg}\" placeholder=\"Leave feedback for {label}…\"></textarea>\n"
1254        ));
1255        card.push_str("</details>\n");
1256    }
1257    card.push_str("</section>\n");
1258    card
1259}
1260
1261fn render_benchmark_section(b: &BenchmarkData) -> String {
1262    let mut s = String::new();
1263    s.push_str("<table><tr><th>config</th><th>pass_rate</th><th>time (s)</th><th>tokens</th></tr>");
1264    for cfg in &b.metadata.configs {
1265        if let Some(c) = b.run_summary.get(cfg) {
1266            s.push_str(&format!(
1267                "<tr><td>{}</td><td>{:.2} ± {:.2}</td><td>{:.1} ± {:.1}</td><td>{:.0} ± {:.0}</td></tr>",
1268                html_escape(cfg), c.pass_rate.mean, c.pass_rate.stddev,
1269                c.time_seconds.mean, c.time_seconds.stddev, c.tokens.mean, c.tokens.stddev
1270            ));
1271        }
1272    }
1273    s.push_str("</table>");
1274    s.push_str(&format!(
1275        "<p><b>Δ (with_skill − baseline):</b> pass_rate {}, time {}s, tokens {}</p>",
1276        html_escape(&b.delta.pass_rate),
1277        html_escape(&b.delta.time_seconds),
1278        html_escape(&b.delta.tokens)
1279    ));
1280    if !b.notes.is_empty() {
1281        s.push_str("<ul>");
1282        for n in &b.notes {
1283            s.push_str(&format!("<li>{}</li>", html_escape(n)));
1284        }
1285        s.push_str("</ul>");
1286    }
1287    s
1288}
1289
1290fn read_output_text(path: &Path) -> Option<String> {
1291    // Only inline text-ish files; skip binaries/large.
1292    if path.metadata().ok()?.len() > 200_000 {
1293        return Some(format!("[binary or large file: {}]", path.display()));
1294    }
1295    let bytes = fs::read(path).ok()?;
1296    let text = String::from_utf8_lossy(&bytes);
1297    if text.chars().any(|c| c == '\u{0}') {
1298        return Some(format!("[binary file: {}]", path.display()));
1299    }
1300    Some(text.into_owned())
1301}
1302
1303fn html_escape(s: &str) -> String {
1304    s.replace('&', "&amp;")
1305        .replace('<', "&lt;")
1306        .replace('>', "&gt;")
1307        .replace('"', "&quot;")
1308}
1309
1310const HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
1311<html lang="en">
1312<head>
1313<meta charset="utf-8">
1314<meta name="viewport" content="width=device-width, initial-scale=1">
1315<title>Skill review — __SKILL_NAME__</title>
1316<style>
1317  :root { color-scheme: light dark; --bg:#fff; --fg:#111; --muted:#666; --line:#ddd; }
1318  @media (prefers-color-scheme: dark){ :root{ --bg:#0d1117; --fg:#e6edf3; --muted:#8b949e; --line:#30363d; } }
1319  body{ font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--fg); margin:0 auto; max-width:960px; padding:24px; }
1320  h1,h2{ line-height:1.25; } details{ margin:.5em 0; padding:.5em .75em; border:1px solid var(--line); border-radius:6px; }
1321  summary{ cursor:pointer; font-weight:600; } pre{ background:rgba(127,127,127,.1); padding:.75em; border-radius:6px; overflow:auto; white-space:pre-wrap; word-break:break-word; }
1322  table{ border-collapse:collapse; width:100%; margin:.5em 0; } th,td{ border:1px solid var(--line); padding:.35em .5em; text-align:left; }
1323  textarea{ width:100%; min-height:60px; margin:.5em 0; font:inherit; box-sizing:border-box; background:transparent; color:var(--fg); border:1px solid var(--line); border-radius:6px; padding:.5em; }
1324  nav button{ font:inherit; padding:.4em .9em; margin-right:.5em; border:1px solid var(--line); border-radius:6px; background:transparent; color:var(--fg); cursor:pointer; }
1325  nav button.active{ background:var(--fg); color:var(--bg); } .hidden{ display:none; }
1326</style>
1327</head>
1328<body>
1329<h1>Skill review — __SKILL_NAME__</h1>
1330<nav><button id="tab-outputs" class="active" onclick="show('outputs')">Outputs</button><button id="tab-bench" onclick="show('benchmark')">Benchmark</button><button onclick="download()">Download feedback</button></nav>
1331<div id="outputs">__EVAL_CARDS__</div>
1332<div id="benchmark" class="hidden">__BENCHMARK__</div>
1333<script>
1334function show(id){ document.getElementById('outputs').classList.toggle('hidden',id!=='outputs'); document.getElementById('benchmark').classList.toggle('hidden',id!=='benchmark'); document.getElementById('tab-outputs').classList.toggle('active',id==='outputs'); document.getElementById('tab-bench').classList.toggle('active',id==='benchmark'); }
1335function download(){ const reviews=[]; document.querySelectorAll('section.eval').forEach(s=>{ s.querySelectorAll('textarea').forEach(t=>{ if(t.value && t.value.trim()){ reviews.push({run_id:s.getAttribute('data-eval')+'-'+t.getAttribute('data-run'), feedback:t.value, timestamp:new Date().toISOString()}); } }); }); const blob=new Blob([JSON.stringify({reviews,status:'complete'},null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='feedback.json'; a.click(); }
1336</script>
1337</body>
1338</html>"#;
1339
1340#[cfg(test)]
1341mod tests {
1342    use super::*;
1343
1344    const GOOD: &str = "---\nname: my-skill\ndescription: Does X when the user wants Y. Fires on mentions of Y or Z.\n---\n\n# My Skill\n\nInstructions here.\n";
1345
1346    #[test]
1347    fn validate_accepts_well_formed_skill() {
1348        let report = validate_skill_content(GOOD, Some("my-skill"));
1349        assert!(!report.has_errors(), "{}", report.render());
1350        assert_eq!(report.name, "my-skill");
1351    }
1352
1353    #[test]
1354    fn validate_flags_missing_frontmatter() {
1355        let report = validate_skill_content("# just a body\n", None);
1356        assert!(report.has_errors());
1357        assert!(report.render().contains("frontmatter"));
1358    }
1359
1360    #[test]
1361    fn validate_flags_missing_name() {
1362        let bad = "---\ndescription: A skill.\n---\n\nbody\n";
1363        let report = validate_skill_content(bad, None);
1364        assert!(report.has_errors());
1365        assert!(report.render().contains("name"));
1366    }
1367
1368    #[test]
1369    fn validate_flags_name_dir_mismatch() {
1370        let report = validate_skill_content(GOOD, Some("other-name"));
1371        assert!(report.has_errors());
1372        assert!(report.render().contains("does not match"));
1373    }
1374
1375    #[test]
1376    fn validate_flags_invalid_name() {
1377        let bad = "---\nname: My Skill!\ndescription: A skill that does the thing when needed.\n---\n\nbody\n";
1378        let report = validate_skill_content(bad, None);
1379        assert!(report.has_errors());
1380    }
1381
1382    #[test]
1383    fn validate_warns_on_short_description() {
1384        let bad = "---\nname: x\ndescription: short\n---\n\nbody\n";
1385        let report = validate_skill_content(bad, None);
1386        assert!(!report.has_errors());
1387        assert!(!report.warnings().is_empty());
1388    }
1389
1390    #[test]
1391    fn validate_warns_on_long_body() {
1392        let long_body: String = "line\n".repeat(600);
1393        let bad = format!(
1394            "---\nname: big\ndescription: A skill that does the thing when the user asks.\n---\n\n{long_body}"
1395        );
1396        let report = validate_skill_content(&bad, None);
1397        assert!(!report.has_errors());
1398        assert!(report.warnings().iter().any(|w| w.contains("500")));
1399    }
1400
1401    #[test]
1402    fn validate_flags_empty_body() {
1403        let bad = "---\nname: empty\ndescription: A skill that does the thing when the user asks.\n---\n\n";
1404        let report = validate_skill_content(bad, None);
1405        assert!(report.has_errors());
1406    }
1407
1408    #[test]
1409    fn is_valid_skill_name_rules() {
1410        assert!(is_valid_skill_name("my-skill"));
1411        assert!(is_valid_skill_name("a"));
1412        assert!(is_valid_skill_name("skill-2"));
1413        assert!(!is_valid_skill_name(""));
1414        assert!(!is_valid_skill_name("-leading"));
1415        assert!(!is_valid_skill_name("trailing-"));
1416        assert!(!is_valid_skill_name("UPPER"));
1417        assert!(!is_valid_skill_name("with space"));
1418        assert!(!is_valid_skill_name("under_score"));
1419    }
1420
1421    #[test]
1422    fn package_round_trip() {
1423        let tmp = tempfile::tempdir().unwrap();
1424        let skill_dir = tmp.path().join("demo-skill");
1425        fs::create_dir_all(&skill_dir).unwrap();
1426        fs::write(
1427            skill_dir.join("SKILL.md"),
1428            "---\nname: demo-skill\ndescription: A demo skill.\n---\n\n# Demo\n",
1429        )
1430        .unwrap();
1431        fs::create_dir_all(skill_dir.join("references")).unwrap();
1432        fs::write(skill_dir.join("references/api.md"), "# API\n").unwrap();
1433        // excluded at root
1434        fs::create_dir_all(skill_dir.join("evals")).unwrap();
1435        fs::write(skill_dir.join("evals/evals.json"), "{}").unwrap();
1436        // excluded everywhere
1437        fs::create_dir_all(skill_dir.join("__pycache__")).unwrap();
1438        fs::write(skill_dir.join("__pycache__/x.pyc"), "x").unwrap();
1439        fs::write(skill_dir.join(".DS_Store"), "x").unwrap();
1440
1441        let out = tmp.path().join("demo-skill.skill");
1442        let path = package_skill_dir(&skill_dir, "demo-skill", &out).unwrap();
1443        assert!(path.exists());
1444
1445        // Re-open and verify contents.
1446        let f = fs::File::open(&path).unwrap();
1447        let mut archive = zip::ZipArchive::new(f).unwrap();
1448        let names: Vec<String> = (0..archive.len())
1449            .map(|i| archive.by_index(i).unwrap().name().to_string())
1450            .collect();
1451        assert!(names.iter().any(|n| n == "demo-skill/SKILL.md"));
1452        assert!(names.iter().any(|n| n == "demo-skill/references/api.md"));
1453        // evals/ excluded at root
1454        assert!(names.iter().all(|n| !n.contains("evals/")));
1455        // build artifacts excluded
1456        assert!(names.iter().all(|n| !n.contains("__pycache__")));
1457        assert!(names.iter().all(|n| !n.contains(".DS_Store")));
1458        assert!(names.iter().all(|n| !n.contains(".pyc")));
1459    }
1460    #[cfg(unix)]
1461    #[test]
1462    fn package_skill_skips_symlinks() {
1463        use std::os::unix::fs::symlink;
1464        let tmp = tempfile::tempdir().unwrap();
1465        // A secret outside the skill tree that a symlink must NOT pull in.
1466        let secret = tmp.path().join("secret.txt");
1467        fs::write(&secret, "PRIVATE_KEY_CONTENTS").unwrap();
1468
1469        let skill_dir = tmp.path().join("leak-skill");
1470        fs::create_dir_all(&skill_dir).unwrap();
1471        fs::write(
1472            skill_dir.join("SKILL.md"),
1473            "---\nname: leak-skill\ndescription: A skill that tries to smuggle a symlink.\n---\n\n# x\n",
1474        )
1475        .unwrap();
1476        // Malicious symlink inside the skill tree → must be skipped, not followed.
1477        symlink(&secret, skill_dir.join("id_rsa")).unwrap();
1478
1479        let out = tmp.path().join("leak-skill.skill");
1480        package_skill_dir(&skill_dir, "leak-skill", &out).unwrap();
1481
1482        let f = fs::File::open(&out).unwrap();
1483        let mut archive = zip::ZipArchive::new(f).unwrap();
1484        for i in 0..archive.len() {
1485            let name = archive.by_index(i).unwrap().name().to_string();
1486            assert!(
1487                !name.contains("id_rsa"),
1488                "symlink leaked into archive: {name}"
1489            );
1490        }
1491        // And the secret contents must not appear anywhere in the archive bytes.
1492        let bytes = fs::read(&out).unwrap();
1493        assert!(
1494            !String::from_utf8_lossy(&bytes).contains("PRIVATE_KEY_CONTENTS"),
1495            "symlink target contents leaked into .skill archive"
1496        );
1497    }
1498    #[test]
1499    fn aggregate_benchmark_computes_stats_and_delta() {
1500        let tmp = tempfile::tempdir().unwrap();
1501        let iter = tmp.path().join("iteration-1");
1502        let mk_run = |eval: &str, cfg: &str, pass_rate: f64, tokens: f64| {
1503            let dir = iter.join(eval).join(cfg);
1504            fs::create_dir_all(dir.join("outputs")).unwrap();
1505            fs::write(
1506                dir.join("grading.json"),
1507                serde_json::json!({
1508                    "summary": {"passed": 2, "failed": 0, "total": 2, "pass_rate": pass_rate},
1509                    "expectations": [
1510                        {"text": "has X", "passed": true},
1511                        {"text": "has Y", "passed": pass_rate > 0.5}
1512                    ]
1513                })
1514                .to_string(),
1515            )
1516            .unwrap();
1517            fs::write(
1518                dir.join("timing.json"),
1519                serde_json::json!({"total_tokens": tokens, "total_duration_seconds": 10.0})
1520                    .to_string(),
1521            )
1522            .unwrap();
1523        };
1524        // Two evals × two configs.
1525        mk_run("eval-0", "with_skill", 1.0, 3800.0);
1526        mk_run("eval-0", "without_skill", 0.5, 2100.0);
1527        mk_run("eval-1", "with_skill", 0.9, 4000.0);
1528        mk_run("eval-1", "without_skill", 0.3, 1900.0);
1529
1530        let bench = aggregate_benchmark(&iter, "demo").unwrap();
1531        assert_eq!(bench.metadata.eval_count, 2);
1532        assert!(bench.metadata.configs.contains(&"with_skill".to_string()));
1533        assert!(
1534            bench
1535                .metadata
1536                .configs
1537                .contains(&"without_skill".to_string())
1538        );
1539        // with_skill mean pass_rate = (1.0 + 0.9)/2 = 0.95
1540        let with = bench.run_summary.get("with_skill").unwrap();
1541        assert!((with.pass_rate.mean - 0.95).abs() < 1e-9);
1542        // delta is a formatted signed string: 0.95 - 0.4 = +0.55
1543        assert!(
1544            bench.delta.pass_rate.starts_with('+'),
1545            "delta={}",
1546            bench.delta.pass_rate
1547        );
1548
1549        // benchmark.md is non-empty and mentions both configs.
1550        let md = bench.to_markdown();
1551        assert!(md.contains("with_skill"));
1552        assert!(md.contains("without_skill"));
1553    }
1554
1555    #[test]
1556    fn aggregate_benchmark_errors_on_empty() {
1557        let tmp = tempfile::tempdir().unwrap();
1558        let iter = tmp.path().join("iteration-1");
1559        fs::create_dir_all(&iter).unwrap();
1560        assert!(aggregate_benchmark(&iter, "demo").is_err());
1561    }
1562
1563    #[test]
1564    fn generate_review_html_writes_self_contained_file() {
1565        let tmp = tempfile::tempdir().unwrap();
1566        let iter = tmp.path().join("iteration-1");
1567        let dir = iter.join("eval-0").join("with_skill");
1568        fs::create_dir_all(dir.join("outputs")).unwrap();
1569        fs::write(dir.join("outputs/result.txt"), "hello world").unwrap();
1570        fs::write(
1571            dir.join("grading.json"),
1572            serde_json::json!({
1573                "summary": {"pass_rate": 1.0},
1574                "expectations": [{"text": "says hello", "passed": true}]
1575            })
1576            .to_string(),
1577        )
1578        .unwrap();
1579        fs::write(
1580            dir.join("timing.json"),
1581            serde_json::json!({"total_tokens": 100.0, "total_duration_seconds": 5.0}).to_string(),
1582        )
1583        .unwrap();
1584        // baseline so benchmark has two configs
1585        let bdir = iter.join("eval-0").join("without_skill");
1586        fs::create_dir_all(bdir.join("outputs")).unwrap();
1587        fs::write(
1588            bdir.join("grading.json"),
1589            serde_json::json!({"summary": {"pass_rate": 0.0}, "expectations": []}).to_string(),
1590        )
1591        .unwrap();
1592        fs::write(
1593            bdir.join("timing.json"),
1594            serde_json::json!({"total_tokens": 80.0, "total_duration_seconds": 4.0}).to_string(),
1595        )
1596        .unwrap();
1597
1598        let out = tmp.path().join("review.html");
1599        let path = generate_review_html(&iter, "demo", &out).unwrap();
1600        assert!(path.exists());
1601        let html = fs::read_to_string(&path).unwrap();
1602        assert!(html.contains("hello world"));
1603        assert!(html.contains("says hello"));
1604        assert!(html.contains("with_skill"));
1605        // feedback download mechanism present
1606        assert!(html.contains("download()"));
1607    }
1608}