diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 0e3075f8..b8b1db51 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -1965,7 +1965,7 @@ Jobs can be edited by hand — `fuseraft schedule run` reads the YAML fresh on e ## `fuseraft skills` -Install, list, and remove global skills available to all agent sessions. Skills are stored in `~/.fuseraft/skills/` and registered in an FTS5 search index so fuseraft can automatically identify which ones are relevant to a given task. +Install, list, remove, and validate global skills available to all agent sessions. Skills are stored in `~/.fuseraft/skills/` and registered in an FTS5 search index so fuseraft can automatically identify which ones are relevant to a given task. See [Skills](skills.md) for an overview of how skills work and how to write them. @@ -2008,7 +2008,7 @@ List all installed global skills. fuseraft skills list ``` -Displays a table with the slug and description for each skill found under `~/.fuseraft/skills/`. +Displays a table with the slug, description, `compatibility` field (if any), and Agent Skills specification conformance (`✓`/`✗`) for each skill found under `~/.fuseraft/skills/`. Run `fuseraft skills validate` for details on any `✗` entries. **Examples** @@ -2074,6 +2074,34 @@ See [Configuration → Skill curation](configuration.md#skill-curation) for the --- +### `fuseraft skills validate` + +Validate a `SKILL.md`'s frontmatter against the [Agent Skills specification](https://agentskills.io/specification) — fuseraft's equivalent of the spec's own recommended `skills-ref validate` tool. Checks the `name` field's format, length, and match against its parent directory name; the `description` field's presence and length; and the `compatibility` field's length. Uses the same validator fuseraft's orchestration skills provider applies at load time, so a skill that passes here is guaranteed to load identically in both the REPL and `fuseraft run` sessions. + +``` +fuseraft skills validate [path] +``` + +**Arguments** + +| Argument | Description | +|----------|-------------| +| `[path]` | Path to a skill directory to validate. Omitted: validates every skill under `~/.fuseraft/skills/`. | + +Exits with status `0` when every checked skill is fully conformant, `1` otherwise. + +**Examples** + +```bash +# Validate every installed skill +fuseraft skills validate + +# Validate a skill before installing it +fuseraft skills validate ../skills/sandbox-test +``` + +--- + ## `fuseraft log` View fuseraft log files. Orchestration session logs (`fuseraft log events`) are read from the global `~/.fuseraft/logs/sessions/` directory. REPL and application logs are read from the current project's `.fuseraft/logs/` directory. diff --git a/docs/security.md b/docs/security.md index feb9eddb..9de41123 100644 --- a/docs/security.md +++ b/docs/security.md @@ -434,7 +434,8 @@ If `fuseraft run --work-dir` points at a directory you did not author, any skill - Only run `fuseraft` in working directories you trust. Treat `.agents/skills/` and `.fuseraft/skills/` in a cloned repo the same way you would treat a `Makefile` or `package.json` postinstall script. - For higher assurance, run fuseraft inside a Docker container (`CodeExecution` plugin) where the host environment is not exposed. -- `UseScriptApproval` support is planned — when enabled it will require explicit user confirmation before any skill script executes. Until then, script execution is automatic once a skill is loaded. +- Microsoft Agent Framework's skills provider supports gating `load_skill`/`read_skill_resource`/`run_skill_script` behind an approval step (`AgentSkillsProviderOptions`), but fuseraft explicitly disables it today, since neither the REPL nor orchestration has a pipeline that resolves an approval request — leaving it enabled would make the tools non-functional rather than gated. Script execution is therefore automatic once a skill is loaded; wiring real approval (REPL: a confirmation prompt; orchestration: `IHumanApprovalService`) is a known future improvement, not yet implemented. +- `read_skill_resource` and `run_skill_script` resolve the model-supplied path against the skill directory and reject anything that resolves outside it, including via a symlinked file or subdirectory planted inside the skill folder — this narrows path-based escape from *within* a loaded skill, but a fully malicious skill script still runs as an OS subprocess with the full process environment; it isn't a substitute for only loading trusted skills. --- diff --git a/docs/skills.md b/docs/skills.md index 644720bc..a5eb639f 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -1,6 +1,6 @@ # Skills -Skills give agents specialized knowledge and step-by-step procedures for specific types of tasks. At REPL startup fuseraft scans your skill directories, injects a catalog of available skills into the system prompt, and exposes two tools the model can call to use them. +Skills give agents specialized knowledge and step-by-step procedures for specific types of tasks, following the [Agent Skills specification](https://agentskills.io/specification). At session start fuseraft scans your skill directories, injects a catalog of available skills into the system prompt, and exposes tools the model can call to use them. Discovery, frontmatter parsing/validation, and the skill tools themselves all come from the [Microsoft Agent Framework](https://github.com/microsoft/agent-framework)'s `AgentFileSkillsSource`/`AgentSkillsProvider` — the REPL and `fuseraft run` orchestration sessions share the exact same implementation, so a skill is treated identically in both. --- @@ -36,9 +36,11 @@ At startup, the skill count appears in the compact info line alongside the activ | `read_skill_resource` | Read a supplementary file bundled with a skill (e.g. a file under `references/`), by path relative to the skill directory. | | `run_skill_script` | Run a script bundled with a skill (`.sh`, `.py`, `.js`). | +`read_skill_resource` and `run_skill_script` reject a path that resolves outside the skill directory, including via a symlinked file or subdirectory planted inside it. + If `--no-tools` is passed, skills are disabled for that session. -`fuseraft run` orchestration sessions use the same five discovery locations and the same three tools (`load_skill`, `read_skill_resource`, `run_skill_script`), wired onto every agent automatically whenever at least one skill directory exists — there is no need to add `Skills` to an agent's `Plugins:` list, though doing so as a declaration of intent is harmless. +`fuseraft run` orchestration sessions use the same five discovery locations and the same three tools (`load_skill`, `read_skill_resource`, `run_skill_script`), wired onto every agent automatically whenever at least one skill directory exists — there is no need to add `Skills` to an agent's `Plugins:` list, though doing so as a declaration of intent is harmless. This is the same discovery pipeline the REPL uses, not a separate implementation — a skill either works identically in both, or (if its frontmatter is invalid) in neither. --- @@ -182,6 +184,8 @@ The command accepts a path to a skill directory (containing `SKILL.md`) or direc You can also install skills by placing them directly under `~/.fuseraft/skills/` without using the CLI — skills are loaded from that directory at session start regardless of how they got there. +`fuseraft skills add` canonicalizes the frontmatter as it installs: if the raw `name:` field doesn't already equal the slug it's being installed under (e.g. it had spaces or uppercase letters), the installed copy's `name:` line is rewritten to match. This guarantees an installed skill's `name:` and directory always agree, which orchestration requires (see below). + --- ## Writing a skill @@ -203,13 +207,22 @@ description: What this skill does and when to use it. Step-by-step guidance for the agent... ``` -The `name` field is used by `fuseraft skills add` to derive the destination directory name when installing a skill globally, so keeping it in sync with the directory name is strongly recommended. The `description` is what fuseraft uses to decide whether the skill is relevant to the current task — write it so it covers both what the skill does and the kinds of tasks that should trigger it. +fuseraft follows the [Agent Skills specification](https://agentskills.io/specification) for `SKILL.md` frontmatter: + +| Field | Required | Notes | +|-------|----------|-------| +| `name` | Yes | Lowercase letters, digits, and single hyphens only (no leading/trailing/double hyphens); max 64 characters; must match the parent directory name exactly. | +| `description` | Yes | 1–1024 characters. What fuseraft uses to decide whether the skill is relevant to the current task — write it so it covers both what the skill does and the kinds of tasks that should trigger it. | +| `license` | No | License name, or a reference to a bundled license file. | +| `compatibility` | No | Max 500 characters. Environment requirements (e.g. `Requires docker and jq`) — shown in the REPL's skill catalog as a `[requires: ...]` hint. | +| `metadata` | No | Arbitrary string-to-string map for your own bookkeeping (author, version, etc.). Not surfaced to the model. | +| `allowed-tools` | No | Space-separated list of pre-approved tools (experimental, per spec — fuseraft parses but does not currently act on this field). | -If your instructions are long, move reference material into a `references/` subdirectory inside the skill folder. The agent loads those files on demand — with `read_skill_resource` — rather than all at once. +If your instructions are long, move reference material into a `references/` subdirectory inside the skill folder. The agent loads those files on demand — with `read_skill_resource` — rather than all at once. `scripts/` and `assets/` are supported the same way. -**If two installed skills share the same name**, the one in the higher-precedence location wins and a warning is logged. +**If two installed skills share the same name**, the one in the higher-precedence location wins. -> **Keep `name:` and the directory name identical.** The REPL loader uses the directory name as the slug and never reads `name:` at load time, so a mismatch is harmless there. `fuseraft run` orchestration sessions use a stricter loader that requires `name:` to match the directory name **exactly** (case-sensitive), to be non-empty lowercase kebab-case (letters, digits, single hyphens — no leading/trailing/double hyphens), and requires a non-empty `description:`. A skill that violates any of these is silently dropped from the orchestration catalog — it works fine in the REPL but an agent in a `fuseraft run` session never sees it. Follow the frontmatter format above exactly and both surfaces will pick up the skill identically. +> **`name:` must match the directory name exactly.** Both the REPL and `fuseraft run` require `name:` to match its parent directory name **exactly** (case-sensitive), to be valid lowercase kebab-case, and require a non-empty, correctly-sized `description:` — they use the identical discovery pipeline, so there is no REPL-specific leniency here. A skill that violates any of these is silently excluded from the catalog in **both** surfaces, with the reason logged as a warning or error (visible by default — no `--verbose` needed). Run `fuseraft skills validate [path]` to check a skill (or every installed skill) against the full specification before relying on it. The one exception is `fuseraft skills add`, which stays deliberately lenient — see [Installing skills](#for-all-your-projects) above. --- diff --git a/skills/skill-author/SKILL.md b/skills/skill-author/SKILL.md index 919e498d..5109497a 100644 --- a/skills/skill-author/SKILL.md +++ b/skills/skill-author/SKILL.md @@ -44,7 +44,13 @@ description: --- ``` -**`name`:** A short, lowercase kebab-case slug (e.g. `debug-session`, `mcp-setup`) — letters, digits, and single hyphens only, no leading/trailing/double hyphens. This is used as the install directory name when running `fuseraft skills add`. Keep it to 1–3 words, and **make it identical to the skill's directory name**: the REPL loader ignores `name:` and uses the directory name as the slug, but `fuseraft run` orchestration sessions use a stricter loader that silently drops the skill from the catalog if `name:` doesn't exactly match the directory name (or isn't valid kebab-case, or `description:` is empty). Matching them keeps the skill working identically in both surfaces. +**`name`:** A short, lowercase kebab-case slug (e.g. `debug-session`, `mcp-setup`) — letters, digits, and single hyphens only, no leading/trailing/double hyphens, max 64 characters. This is used as the install directory name when running `fuseraft skills add`. Keep it to 1–3 words, and **make it identical to the skill's directory name**: the REPL and `fuseraft run` orchestration sessions both use the same discovery pipeline and silently exclude the skill from the catalog if `name:` doesn't exactly match the directory name (or isn't valid kebab-case, or `description:` is empty or too long) — there is no REPL-specific leniency once a skill directory exists somewhere fuseraft scans. Run `fuseraft skills validate ` to confirm before installing. + +**Optional fields**, per the [Agent Skills specification](https://agentskills.io/specification) — add only when they earn their keep: +- **`license`:** a license name or reference to a bundled license file. Only relevant for skills you intend to share/distribute. +- **`compatibility`:** environment requirements, max 500 characters (e.g. `Requires docker and jq`, `Designed for fuseraft REPL sessions`). Shown to the agent in the REPL catalog as a `[requires: ...]` hint — add it when the skill assumes a tool or platform that isn't universally available. +- **`metadata`:** a string-to-string map for your own bookkeeping (e.g. `author`, `version`). Not shown to the agent. +- **`allowed-tools`:** experimental per spec; fuseraft parses it but doesn't currently act on it. Skip it. **`description`:** This is the most important field — fuseraft injects only the name and description into the agent's catalog at session start. The agent reads this to decide whether the skill is relevant. Write it so it covers: - What the skill produces or accomplishes @@ -168,7 +174,9 @@ Or write directly to `~/.fuseraft/skills//SKILL.md` — fuseraft loads fro ### Step 7: Verify -For **REPL sessions**, start or restart fuseraft and run `/tools`. The skill should appear under the `Skills` category with its name and description. +First, run `fuseraft skills validate ` (or `fuseraft skills validate` with no argument once installed, to check it alongside every other installed skill). This checks the frontmatter against the full specification — name format and directory match, description presence/length, compatibility length — with the same validator both the REPL and orchestration use, before you burn a session on it. + +For **REPL sessions**, start or restart fuseraft and run `/tools`. The skill should appear under the `Skills` category with its name and description. Watch the startup output for an `[ERR]`/`[WRN]` line naming the SKILL.md path — that means the frontmatter is invalid (most often a name/directory mismatch) and the skill did not load. For **orchestration sessions**, run `fuseraft validate` on the config first, then do a one-turn dry run: @@ -179,7 +187,7 @@ fuseraft run --config --max-iterations 1 "List your available skills." The agent should name the skill in its response. If it does not appear, check: - `SKILL.md` is directly inside the skill directory (not nested deeper) - The install path is one of the five recognized locations (project `.fuseraft/skills/`, project `.agents/skills/`, user `.fuseraft/skills/`, user `.agents/skills/`, or shipped built-in) -- **Orchestration-only:** `name:` in the frontmatter exactly matches the directory name (case-sensitive), is valid lowercase kebab-case, and `description:` is non-empty — a mismatch here loads fine in the REPL but is silently dropped by `fuseraft run`'s stricter loader with no error to the user, only a log entry +- `fuseraft skills validate` passes — a violation it reports means the skill is silently excluded from both the REPL and `fuseraft run` catalogs, with no error to the user beyond a log entry ### Step 8: Refine the Description diff --git a/src/Cli/Commands/Repl/ReplCommand.cs b/src/Cli/Commands/Repl/ReplCommand.cs index e2e12efe..78d3d0d4 100644 --- a/src/Cli/Commands/Repl/ReplCommand.cs +++ b/src/Cli/Commands/Repl/ReplCommand.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Spectre.Console; @@ -182,7 +183,7 @@ protected override async Task ExecuteAsync( var toolsByCategory = new Dictionary>(StringComparer.OrdinalIgnoreCase); using ShellPlugin? shellPlugin = settings.NoTools ? null : new ShellPlugin(shellPolicy: TryLoadDefaultShellPolicy()); SubAgentPlugin? subAgent = null; - SkillsPlugin? skillsPlugin = null; + IReadOnlyList discoveredSkills = []; string? skillsCatalog = null; List? explorerTools = null; TodoPlugin? todoPlugin = null; @@ -220,10 +221,6 @@ protected override async Task ExecuteAsync( toolsByCategory["FileSystem"] = fsFunctions.Where(f => CoreFileSystemTools.Contains(f.Name)).ToList(); toolsByCategory["Shell"] = shellFunctions.Where(f => CoreShellTools.Contains(f.Name)).ToList(); toolsByCategory["Git"] = gitFunctions.Where(f => CoreGitTools.Contains(f.Name)).ToList(); - - (skillsPlugin, skillsCatalog) = ReplSkillsLoader.BuildSkills(); - if (skillsPlugin is not null) - toolsByCategory["Skills"] = PluginRegistry.GetFunctionsFromObject(skillsPlugin).ToList(); } var initialTools = toolsByCategory.Values.SelectMany(v => v).ToList(); @@ -238,6 +235,19 @@ protected override async Task ExecuteAsync( return 1; } + if (!settings.NoTools) + { + // Skill discovery/parsing/validation and the load_skill/read_skill_resource/ + // run_skill_script tools all come from Microsoft.Agents.AI's AgentFileSkillsSource/ + // AgentSkillsProvider — the same classes orchestration uses — via a throwaway + // ChatClientAgent wrapping the client just built above. + var skillsResult = await ReplSkillsLoader.BuildAsync(client, loggerFactory, cancellationToken); + discoveredSkills = skillsResult.Skills; + skillsCatalog = skillsResult.CatalogInstructions; + if (skillsResult.Tools.Count > 0) + toolsByCategory["Skills"] = skillsResult.Tools.ToList(); + } + var cwd = Directory.GetCurrentDirectory(); var eventsPath = FuseraftPaths.ExpandProjectPaths(FuseraftPaths.LocalReplEventsLog, FuseraftPaths.ProjectSlug(cwd)); @@ -373,7 +383,7 @@ protected override async Task ExecuteAsync( MessageRenderer.RenderReplHeader( modelId, cwd, pluginNames, sessionId, memoryCount: memoryEntries.Count, - skillCount: skillsPlugin?.Count ?? 0, + skillCount: discoveredSkills.Count, branch: TryGetGitBranch(cwd), eventsPath: settings.Verbose ? eventsPath : null); } @@ -384,10 +394,10 @@ protected override async Task ExecuteAsync( memoryStore, toolsByCategory, systemPrompt, pendingSave, verbose: settings.Verbose, subAgent: subAgent) { - JsonMode = jsonMode, - SkillsPlugin = skillsPlugin, - Todo = todoPlugin, - KeyStored = keyStored, + JsonMode = jsonMode, + Skills = discoveredSkills, + Todo = todoPlugin, + KeyStored = keyStored, }; if (!settings.NoTools) @@ -406,8 +416,8 @@ protected override async Task ExecuteAsync( .FirstOrDefault(); } - if (skillsPlugin is not null) - ctx.LineReader.SetSkillSlugs([.. skillsPlugin.Slugs]); + if (discoveredSkills.Count > 0) + ctx.LineReader.SetSkillSlugs([.. discoveredSkills.Select(s => s.Frontmatter.Name)]); // Wire the compact_context and get_context_status tools now that ctx is available. replSessionPlugin?.SetCompactDelegate(async (focus, ct) => diff --git a/src/Cli/Commands/Repl/ReplSessionContext.cs b/src/Cli/Commands/Repl/ReplSessionContext.cs index 2aee84bc..52c6a53d 100644 --- a/src/Cli/Commands/Repl/ReplSessionContext.cs +++ b/src/Cli/Commands/Repl/ReplSessionContext.cs @@ -1,3 +1,4 @@ +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using fuseraft.Core.Models; using fuseraft.Infrastructure; @@ -38,7 +39,7 @@ internal sealed class ReplSessionContext public readonly Dictionary> ToolsByCategory; public readonly SubAgentPlugin? SubAgent; public readonly bool Verbose; - public SkillsPlugin? SkillsPlugin { get; set; } + public IReadOnlyList Skills { get; set; } = []; public TodoPlugin? Todo { get; set; } // Mutable provider state (may be replaced by /provider setup) diff --git a/src/Cli/Commands/Repl/ReplSkillsLoader.cs b/src/Cli/Commands/Repl/ReplSkillsLoader.cs index e4795be6..70220dfd 100644 --- a/src/Cli/Commands/Repl/ReplSkillsLoader.cs +++ b/src/Cli/Commands/Repl/ReplSkillsLoader.cs @@ -1,121 +1,76 @@ -using fuseraft.Core; -using fuseraft.Infrastructure.Plugins; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using fuseraft.Core.Skills; namespace fuseraft.Cli.Commands.Repl; +/// Result of wiring up skills for a REPL session. +/// Every skill discovered, for the startup banner count and $slug direct invocation. +/// Catalog text to append to the system prompt, or null when no skills were found. +/// The load_skill/read_skill_resource/run_skill_script tools, or empty when no skills were found. +internal sealed record ReplSkillsResult( + IReadOnlyList Skills, + string? CatalogInstructions, + IReadOnlyList Tools); + /// -/// Scans skill directories, parses SKILL.md frontmatter, and assembles the -/// instance and catalog block injected into the REPL -/// system prompt at startup. +/// Thin REPL-side wiring over Microsoft.Agents.AI's Agent Skills feature. Discovery, frontmatter +/// parsing/validation, and the skill tools themselves all come from +/// / — the same classes +/// orchestration () uses, so a skill is treated +/// identically by both surfaces. This file does not parse or validate anything itself. /// internal static class ReplSkillsLoader { - /// - /// Returns the priority-ordered list of directories to scan for skills in a - /// normal REPL session (project-local → user-global → install-bundled). - /// - internal static string[] GetDefaultSearchDirs() - { - var cwd = Directory.GetCurrentDirectory(); - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - return - [ - Path.Combine(cwd, ".fuseraft", "skills"), - Path.Combine(cwd, ".agents", "skills"), - FuseraftPaths.GlobalSkills, - Path.Combine(home, ".agents", "skills"), - Path.Combine(AppContext.BaseDirectory, "skills"), - ]; - } - - /// - /// Convenience overload used by — searches the default dirs. - /// - internal static (SkillsPlugin? Plugin, string? CatalogBlock) BuildSkills() => - BuildSkills(GetDefaultSearchDirs()); + /// Convenience overload used by — searches the default dirs. + internal static Task BuildAsync( + IChatClient client, ILoggerFactory loggerFactory, CancellationToken cancellationToken) => + BuildAsync(client, loggerFactory, FuseraftSkillsSources.GetDefaultSearchDirs(), cancellationToken); /// - /// Scans for SKILL.md files, builds a - /// slug-to-directory map (first occurrence across dirs wins), and returns a - /// together with a catalog string suitable for - /// appending to the REPL system prompt. - /// - /// Returns (null, null) when no skills are found. - /// - /// Inaccessible directories are silently skipped so a permissions error on - /// one dir does not block skills from other dirs. - /// + /// Discovers skills under using + /// (wrapped in a throwaway — the only role it plays is + /// satisfying the framework's generic "which agent is asking" context, since file-based + /// discovery never invokes it) and returns the discovered skills plus the catalog + /// instructions and tools an would attach to that agent. /// - internal static (SkillsPlugin? Plugin, string? CatalogBlock) BuildSkills(IEnumerable searchDirs) + internal static async Task BuildAsync( + IChatClient client, ILoggerFactory loggerFactory, IEnumerable searchDirs, CancellationToken cancellationToken) { - // slug → directory containing SKILL.md; first occurrence wins. - var skillDirs = new Dictionary(StringComparer.OrdinalIgnoreCase); - var descriptions = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var searchDir in searchDirs.Where(Directory.Exists)) - { - IEnumerable skillMds; - try - { - skillMds = Directory.EnumerateFiles(searchDir, "SKILL.md", SearchOption.AllDirectories); - } - catch (UnauthorizedAccessException) { continue; } - catch (IOException) { continue; } + var fileSource = new AgentFileSkillsSource( + searchDirs, + FuseraftSkillsSources.RunScriptAsync, + loggerFactory: loggerFactory); - foreach (var skillMd in skillMds) - { - var skillDir = Path.GetDirectoryName(skillMd); - if (skillDir is null) continue; - var slug = Path.GetFileName(skillDir); - if (string.IsNullOrEmpty(slug) || skillDirs.ContainsKey(slug)) continue; + // Same caching+dedup pipeline AgentSkillsProvider's own convenience constructor builds + // internally — applied explicitly here so the skill list used for the startup banner + // count and $slug direct invocation agrees with what the catalog/tools below show, + // rather than the raw file source's un-deduplicated, per-search-dir concatenation. + var source = new DeduplicatingAgentSkillsSource(new CachingAgentSkillsSource(fileSource), loggerFactory); - skillDirs[slug] = skillDir; - descriptions[slug] = ParseSkillDescription(skillMd); - } - } + var agent = new ChatClientAgent(client); + IReadOnlyList skills = [.. await source.GetSkillsAsync(new AgentSkillsSourceContext(agent, session: null), cancellationToken)]; - if (skillDirs.Count == 0) return (null, null); + if (skills.Count == 0) + return new ReplSkillsResult(skills, null, []); - var sb = new System.Text.StringBuilder(); - sb.AppendLine("## SKILLS available"); - foreach (var slug in skillDirs.Keys.OrderBy(s => s, StringComparer.OrdinalIgnoreCase)) - { - var desc = descriptions.GetValueOrDefault(slug); - sb.AppendLine(!string.IsNullOrWhiteSpace(desc) ? $"- {slug}: {desc}" : $"- {slug}"); - } - sb.AppendLine(); - sb.Append("Call load_skill(\"\") to get full step-by-step instructions before applying a skill."); - - return (new SkillsPlugin(skillDirs), sb.ToString()); - } + var provider = new AgentSkillsProviderBuilder() + .UseSource(source) + .UseOptions(FuseraftSkillsSources.DisableApproval) + .UseLoggerFactory(loggerFactory) + .Build(); - /// - /// Reads only the description: field from a SKILL.md YAML frontmatter block. - /// Returns null when the field is absent, empty, or the file is unreadable. - /// - internal static string? ParseSkillDescription(string skillMdPath) - { - try - { - var inFrontmatter = false; - foreach (var line in File.ReadLines(skillMdPath)) - { - var trimmed = line.Trim(); - if (trimmed == "---") - { - if (!inFrontmatter) { inFrontmatter = true; continue; } - break; // closing delimiter - } - if (!inFrontmatter) break; // no opening delimiter on first line + // AIContextProvider.InvokingContext is [Experimental] (MAAI001) as of the + // Microsoft.Agents.AI version fuseraft depends on — see the same suppression pattern + // in AgentContextCompactionFilters.cs. This is the only place that touches it. +#pragma warning disable MAAI001 + var aiContext = await provider.InvokingAsync( + new AIContextProvider.InvokingContext(agent, session: null, aiContext: new AIContext()), + cancellationToken); +#pragma warning restore MAAI001 - if (trimmed.StartsWith("description:", StringComparison.OrdinalIgnoreCase)) - { - var value = trimmed["description:".Length..].Trim().Trim('"').Trim('\''); - return string.IsNullOrWhiteSpace(value) ? null : value; - } - } - return null; - } - catch { return null; } + var tools = aiContext.Tools?.OfType().ToList() ?? []; + return new ReplSkillsResult(skills, aiContext.Instructions, tools); } } diff --git a/src/Cli/Commands/Repl/ReplTurn.cs b/src/Cli/Commands/Repl/ReplTurn.cs index b5fdcaa6..51049852 100644 --- a/src/Cli/Commands/Repl/ReplTurn.cs +++ b/src/Cli/Commands/Repl/ReplTurn.cs @@ -322,10 +322,11 @@ await ExecuteAsync( var slug = parts[0][1..]; // strip '$' var args = parts.Length > 1 ? parts[1] : string.Empty; - if (ctx.SkillsPlugin is null || !ctx.SkillsPlugin.HasSkill(slug)) + var skill = ctx.Skills.FirstOrDefault(s => string.Equals(s.Frontmatter.Name, slug, StringComparison.OrdinalIgnoreCase)); + if (skill is null) { - var available = ctx.SkillsPlugin is not null - ? $"Available: {string.Join(", ", ctx.SkillsPlugin.Slugs.Take(10))}" + var available = ctx.Skills.Count > 0 + ? $"Available: {string.Join(", ", ctx.Skills.Select(s => s.Frontmatter.Name).Take(10))}" : "No skills are loaded in this session."; var errMsg = string.IsNullOrEmpty(slug) ? $"Usage: $ [args]. {available}" @@ -337,7 +338,7 @@ await ExecuteAsync( continue; } - var skillContent = await ctx.SkillsPlugin.LoadSkillAsync(slug, cancellationToken); + var skillContent = await skill.GetContentAsync(cancellationToken); var input = string.IsNullOrEmpty(args) ? skillContent : $"{skillContent}\n\n{args}"; await ExecuteAsync(ctx, input, isStepRequest: false, capturePlan: false, activeStep: null, cancellationToken); diff --git a/src/Cli/Commands/Skills/SkillsAddCommand.cs b/src/Cli/Commands/Skills/SkillsAddCommand.cs index 22a3cdf0..dc3d04f9 100644 --- a/src/Cli/Commands/Skills/SkillsAddCommand.cs +++ b/src/Cli/Commands/Skills/SkillsAddCommand.cs @@ -1,7 +1,9 @@ using System.ComponentModel; +using Microsoft.Agents.AI; using Spectre.Console; using Spectre.Console.Cli; using fuseraft.Core; +using fuseraft.Core.Skills; using fuseraft.Orchestration; namespace fuseraft.Cli.Commands.Skills; @@ -54,6 +56,12 @@ protected override async Task ExecuteAsync(CommandContext context, SkillsAd return 1; } + // Guarantee the installed file's 'name:' field matches the directory it's installed + // under — a raw name that needed slugifying (spaces, uppercase, ...) would otherwise + // leave the two disagreeing, which works fine in the REPL's lenient loader but is + // silently dropped by fuseraft's orchestration skills provider. + content = SkillsHelpers.CanonicalizeName(content, slug); + var destDir = Path.Combine(FuseraftPaths.GlobalSkills, slug); var destPath = Path.Combine(destDir, "SKILL.md"); var isUpdate = File.Exists(destPath); @@ -66,10 +74,8 @@ protected override async Task ExecuteAsync(CommandContext context, SkillsAd // skill's own instructions point to (load_skill/read_skill_resource/run_skill_script). SkillsHelpers.CopySkillDirectory(sourceSkillDir, destDir); } - else - { - await File.WriteAllTextAsync(destPath, content, cancellationToken); - } + // Write (or overwrite, if just copied) SKILL.md with the possibly name-canonicalized content. + await File.WriteAllTextAsync(destPath, content, cancellationToken); await using var index = new SkillIndex(); try @@ -87,6 +93,20 @@ protected override async Task ExecuteAsync(CommandContext context, SkillsAd var verb = isUpdate ? "Updated" : "Added"; AnsiConsole.MarkupLine($"[green]✓[/] {verb} [bold]{Markup.Escape(slug)}[/] → {Markup.Escape(destPath)}"); + + // Canonicalizing the name only guarantees name-matches-directory; description/ + // compatibility could still be missing or too long. Confirm with the same + // AgentFileSkillsSource pipeline orchestration and the REPL actually use, rather than + // re-deriving the answer here. + var checkSource = new AgentFileSkillsSource(destDir, FuseraftSkillsSources.RunScriptAsync); + var checkResult = await checkSource.GetSkillsAsync( + new AgentSkillsSourceContext(SkillDiscoveryAgent.Create(), session: null), cancellationToken); + if (checkResult.Count == 0) + AnsiConsole.MarkupLine( + $"[yellow]⚠[/] '{Markup.Escape(slug)}' does not fully conform to the Agent Skills specification " + + $"(name matches its directory, but check description/compatibility). It will work in the REPL but " + + $"'fuseraft run' orchestration sessions will silently drop it — run [bold]fuseraft skills validate {Markup.Escape(slug)}[/] for details."); + return 0; } } diff --git a/src/Cli/Commands/Skills/SkillsHelpers.cs b/src/Cli/Commands/Skills/SkillsHelpers.cs index 157aa748..3066a851 100644 --- a/src/Cli/Commands/Skills/SkillsHelpers.cs +++ b/src/Cli/Commands/Skills/SkillsHelpers.cs @@ -1,32 +1,69 @@ using System.Text.RegularExpressions; +using fuseraft.Core.Skills; namespace fuseraft.Cli.Commands.Skills; +/// +/// Bootstrapping helpers exclusive to fuseraft skills add, which — unlike every other +/// skill-related surface (REPL, orchestration, skills validate, skills list, +/// , all of which use +/// Microsoft.Agents.AI's AgentFileSkillsSource/AgentSkillFrontmatter directly) — +/// intentionally stays lenient: it derives an install slug from a raw title (spaces, uppercase, +/// ...) and rewrites the installed copy's name: field to match, rather than requiring the +/// source to already be spec-compliant. See docs/skills.md. +/// internal static class SkillsHelpers { - private static readonly Regex NameFrontmatter = - new(@"^name:\s*(.+)$", RegexOptions.Multiline | RegexOptions.IgnoreCase); - - private static readonly Regex DescriptionFrontmatter = - new(@"^description:\s*(.+)$", RegexOptions.Multiline | RegexOptions.IgnoreCase); + private static readonly Regex SlugSanitizer = new(@"[^a-z0-9]+", RegexOptions.Compiled); + /// Extracts the slugified name: field, or null when absent/empty. internal static string? ExtractSlug(string content) { - var m = NameFrontmatter.Match(content); - if (!m.Success) return null; - var name = m.Groups[1].Value.Trim().Trim('"').Trim('\''); + var name = FrontmatterFieldReader.ExtractField(content, "name"); return string.IsNullOrWhiteSpace(name) ? null : ToSlug(name); } - internal static string ExtractDescription(string content) + /// Converts an arbitrary title into a spec-valid slug candidate: lowercase, non-alphanumeric runs collapsed to single hyphens, no leading/trailing hyphens. + internal static string ToSlug(string name) => + SlugSanitizer.Replace(name.ToLowerInvariant().Trim(), "-").Trim('-'); + + /// + /// Rewrites 's name: frontmatter field to + /// when it isn't already exactly that value (inserting one if the + /// field was missing entirely). Ensures a skill installed under <slug>/SKILL.md + /// always has a matching name: field — without this, a raw title that needed + /// slugifying would leave the installed file internally inconsistent: fine in the REPL's + /// lenient loader, but rejected by AgentFileSkillsSource's name-matches-directory + /// check, which orchestration and skills validate both enforce. + /// + internal static string CanonicalizeName(string content, string slug) { - var m = DescriptionFrontmatter.Match(content); - if (!m.Success) return string.Empty; - return m.Groups[1].Value.Trim().Trim('"').Trim('\''); - } + var currentName = FrontmatterFieldReader.ExtractField(content, "name"); + if (string.Equals(currentName, slug, StringComparison.Ordinal)) + return content; - internal static string ToSlug(string name) => - Regex.Replace(name.ToLowerInvariant().Trim(), @"[^a-z0-9]+", "-").Trim('-'); + var frontmatterMatch = Regex.Match(content, @"\A^---\s*$(.*?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline); + if (!frontmatterMatch.Success) + return content; + + var yaml = frontmatterMatch.Groups[1].Value; + var nameLine = $"name: {slug}"; + + string newYaml; + var existingNameLine = Regex.Match(yaml, @"^name\s*:[ \t]*.*$", RegexOptions.Multiline | RegexOptions.IgnoreCase); + if (existingNameLine.Success) + { + newYaml = yaml[..existingNameLine.Index] + nameLine + yaml[(existingNameLine.Index + existingNameLine.Length)..]; + } + else + { + // The captured yaml group starts right after "---" and before its own trailing + // newline (the regex's '$' anchor is zero-width), so it always begins with '\n'. + newYaml = "\n" + nameLine + "\n" + yaml.TrimStart('\n'); + } + + return content[..frontmatterMatch.Groups[1].Index] + newYaml + content[(frontmatterMatch.Groups[1].Index + frontmatterMatch.Groups[1].Length)..]; + } /// /// Recursively copies every file under into diff --git a/src/Cli/Commands/Skills/SkillsListCommand.cs b/src/Cli/Commands/Skills/SkillsListCommand.cs index 4bbdde02..7c0972e0 100644 --- a/src/Cli/Commands/Skills/SkillsListCommand.cs +++ b/src/Cli/Commands/Skills/SkillsListCommand.cs @@ -1,7 +1,9 @@ using System.ComponentModel; +using Microsoft.Agents.AI; using Spectre.Console; using Spectre.Console.Cli; using fuseraft.Core; +using fuseraft.Core.Skills; namespace fuseraft.Cli.Commands.Skills; @@ -21,33 +23,45 @@ protected override async Task ExecuteAsync(CommandContext context, SkillsLi return 0; } - var entries = new List<(string Slug, string Description)>(); - foreach (var dir in Directory.EnumerateDirectories(root).OrderBy(d => d)) - { - var mdPath = Path.Combine(dir, "SKILL.md"); - if (!File.Exists(mdPath)) continue; - var content = await File.ReadAllTextAsync(mdPath, cancellationToken); - var slug = Path.GetFileName(dir); - var desc = SkillsHelpers.ExtractDescription(content); - entries.Add((slug, desc)); - } + var dirs = Directory.EnumerateDirectories(root) + .Where(d => File.Exists(Path.Combine(d, "SKILL.md"))) + .OrderBy(d => d, StringComparer.OrdinalIgnoreCase) + .ToList(); - if (entries.Count == 0) + if (dirs.Count == 0) { AnsiConsole.MarkupLine("[dim]No skills installed. Use [bold]fuseraft skills add [/] to add one.[/]"); return 0; } + // The same discovery pipeline the REPL and orchestration use at runtime — a skill shown + // here with a real description/compatibility is guaranteed to load identically in both. + var source = new AgentFileSkillsSource(root, FuseraftSkillsSources.RunScriptAsync); + var skills = await source.GetSkillsAsync(new AgentSkillsSourceContext(SkillDiscoveryAgent.Create(), session: null), cancellationToken); + var bySlug = skills.ToDictionary(s => s.Frontmatter.Name, StringComparer.Ordinal); + var table = new Table() .Border(TableBorder.Simple) .AddColumn(new TableColumn("[bold]Slug[/]")) - .AddColumn(new TableColumn("[bold]Description[/]")); + .AddColumn(new TableColumn("[bold]Description[/]")) + .AddColumn(new TableColumn("[bold]Requires[/]")) + .AddColumn(new TableColumn("[bold]Spec[/]")); - foreach (var (slug, desc) in entries) - table.AddRow(Markup.Escape(slug), Markup.Escape(desc)); + foreach (var dir in dirs) + { + var slug = Path.GetFileName(dir); + var valid = bySlug.TryGetValue(slug, out var skill); + table.AddRow( + Markup.Escape(slug), + Markup.Escape(valid ? skill!.Frontmatter.Description : ""), + Markup.Escape(valid ? skill!.Frontmatter.Compatibility ?? "" : ""), + valid ? "[green]✓[/]" : "[red]✗[/]"); + } AnsiConsole.Write(table); - AnsiConsole.MarkupLine($"[dim]{entries.Count} skill(s) in {Markup.Escape(root)}[/]"); + AnsiConsole.MarkupLine($"[dim]{dirs.Count} skill(s) in {Markup.Escape(root)}[/]"); + if (bySlug.Count < dirs.Count) + AnsiConsole.MarkupLine("[dim]Run [bold]fuseraft skills validate[/] for details on the ✗ entries.[/]"); return 0; } } diff --git a/src/Cli/Commands/Skills/SkillsValidateCommand.cs b/src/Cli/Commands/Skills/SkillsValidateCommand.cs new file mode 100644 index 00000000..f66f9d0b --- /dev/null +++ b/src/Cli/Commands/Skills/SkillsValidateCommand.cs @@ -0,0 +1,133 @@ +using System.ComponentModel; +using Microsoft.Agents.AI; +using Spectre.Console; +using Spectre.Console.Cli; +using fuseraft.Core; +using fuseraft.Core.Skills; + +namespace fuseraft.Cli.Commands.Skills; + +// fuseraft skills validate [path] + +public sealed class SkillsValidateSettings : CommandSettings +{ + [CommandArgument(0, "[path]")] + [Description("Path to a skill directory to validate. Omit to validate every skill in ~/.fuseraft/skills.")] + public string? Path { get; set; } +} + +/// +/// fuseraft's equivalent of the skills-ref validate tool the Agent Skills specification +/// () recommends authors run before +/// shipping a skill. The pass/fail verdict comes from — the +/// same discovery pipeline the REPL and orchestration both use at runtime, so a skill that +/// passes here is guaranteed to load identically in both. Per-failure reasons come from +/// 's own validating constructor, fed by the minimal raw +/// name:/description:/compatibility: extraction in +/// — nothing here re-implements the specification's rules. +/// +public sealed class SkillsValidateCommand : AsyncCommand +{ + protected override async Task ExecuteAsync(CommandContext context, SkillsValidateSettings settings, CancellationToken cancellationToken) + { + string searchRoot; + List candidateDirs; + + if (!string.IsNullOrWhiteSpace(settings.Path)) + { + var dir = FuseraftPaths.ExpandPath(settings.Path); + if (!Directory.Exists(dir)) + { + AnsiConsole.MarkupLine($"[red]✗ Not a directory: {Markup.Escape(settings.Path)}[/]"); + return 1; + } + searchRoot = dir; + candidateDirs = [Normalize(dir)]; + } + else + { + searchRoot = FuseraftPaths.GlobalSkills; + if (!Directory.Exists(searchRoot)) + { + AnsiConsole.MarkupLine("[dim]No skills installed. Use [bold]fuseraft skills add [/] to add one.[/]"); + return 0; + } + candidateDirs = [.. Directory.EnumerateDirectories(searchRoot).Select(Normalize).OrderBy(d => d, StringComparer.OrdinalIgnoreCase)]; + } + + var source = new AgentFileSkillsSource(searchRoot, FuseraftSkillsSources.RunScriptAsync); + var passed = await source.GetSkillsAsync(new AgentSkillsSourceContext(SkillDiscoveryAgent.Create(), session: null), cancellationToken); + var passedByDir = passed + .OfType() + .ToDictionary(s => Normalize(s.Path), s => s, StringComparer.OrdinalIgnoreCase); + + var allValid = true; + foreach (var dir in candidateDirs) + { + var name = Path.GetFileName(dir); + var skillMd = Path.Combine(dir, "SKILL.md"); + + if (!File.Exists(skillMd)) + { + allValid = false; + AnsiConsole.MarkupLine($"[red]✗[/] [bold]{Markup.Escape(name)}[/] — no SKILL.md found"); + continue; + } + + if (passedByDir.ContainsKey(dir)) + { + AnsiConsole.MarkupLine($"[green]✓[/] [bold]{Markup.Escape(name)}[/]"); + continue; + } + + allValid = false; + AnsiConsole.MarkupLine($"[red]✗[/] [bold]{Markup.Escape(name)}[/]"); + foreach (var violation in await DescribeViolationsAsync(skillMd, name, cancellationToken)) + AnsiConsole.MarkupLine($" [red]•[/] {Markup.Escape(violation)}"); + } + + if (!allValid) + AnsiConsole.MarkupLine( + "\n[yellow]A skill listed above works fine in the REPL's lenient loader but is silently dropped " + + "by 'fuseraft run' orchestration sessions, which require full spec conformance.[/]"); + + return allValid ? 0 : 1; + } + + /// + /// Explains why a skill directory that silently dropped + /// failed, by handing the same raw name:/description:/compatibility: + /// values to 's own validating constructor and reporting + /// its exception message (or a name/directory mismatch, the one thing that constructor + /// doesn't check since it has no notion of a directory). + /// + private static async Task> DescribeViolationsAsync(string skillMdPath, string directoryName, CancellationToken cancellationToken) + { + var content = await File.ReadAllTextAsync(skillMdPath, cancellationToken); + var rawName = FrontmatterFieldReader.ExtractField(content, "name"); + var rawDescription = FrontmatterFieldReader.ExtractField(content, "description"); + var rawCompatibility = FrontmatterFieldReader.ExtractField(content, "compatibility"); + + if (rawName is null && rawDescription is null) + return ["No YAML frontmatter block found (SKILL.md must start with a '---' delimited block with 'name:' and 'description:' fields)."]; + + var violations = new List(); + AgentSkillFrontmatter? frontmatter = null; + try + { + frontmatter = new AgentSkillFrontmatter(rawName ?? string.Empty, rawDescription ?? string.Empty, rawCompatibility); + } + catch (ArgumentException ex) + { + violations.Add(ex.Message); + } + + if (frontmatter is not null && !string.Equals(frontmatter.Name, directoryName, StringComparison.Ordinal)) + violations.Add($"'name: {frontmatter.Name}' does not match its directory name '{directoryName}'."); + + return violations.Count > 0 ? violations : ["Does not conform to the Agent Skills specification (reason unknown — check for stray YAML syntax)."]; + } + + private static string Normalize(string path) => + Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); +} diff --git a/src/Cli/OrchestratorBuilder.cs b/src/Cli/OrchestratorBuilder.cs index bc5b83ee..39f02049 100644 --- a/src/Cli/OrchestratorBuilder.cs +++ b/src/Cli/OrchestratorBuilder.cs @@ -16,6 +16,7 @@ using fuseraft.Core; using fuseraft.Core.Interfaces; using fuseraft.Core.Models; +using fuseraft.Core.Skills; using fuseraft.Infrastructure; using fuseraft.Infrastructure.KeyStore; using fuseraft.Infrastructure.Plugins; @@ -1466,18 +1467,8 @@ private static (IOrchestrator Orchestrator, fuseraft.Infrastructure.Repository.R private static AgentSkillsProvider? BuildSkillsProvider(ILoggerFactory loggerFactory) { - // Project-native → project cross-client → user-native → user cross-client → built-in. - var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var dirs = new[] - { - Path.Combine(Directory.GetCurrentDirectory(), ".fuseraft", "skills"), - Path.Combine(Directory.GetCurrentDirectory(), ".agents", "skills"), - FuseraftPaths.GlobalSkills, - Path.Combine(home, ".agents", "skills"), - Path.Combine(AppContext.BaseDirectory, "skills"), - }.Where(Directory.Exists).ToArray(); - - if (dirs.Length == 0) return null; + var dirs = FuseraftSkillsSources.GetDefaultSearchDirs(); + if (!dirs.Any(Directory.Exists)) return null; // Without a logger factory, AgentFileSkillsSource discards its diagnostics (invalid // frontmatter, a skill 'name:' that doesn't match its directory name, symlink/path- @@ -1486,60 +1477,9 @@ private static (IOrchestrator Orchestrator, fuseraft.Infrastructure.Repository.R // pipeline as the rest of the orchestrator. return new AgentSkillsProviderBuilder() .UseFileSkills(dirs) - .UseFileScriptRunner(RunSkillScriptAsync) + .UseFileScriptRunner(FuseraftSkillsSources.RunScriptAsync) + .UseOptions(FuseraftSkillsSources.DisableApproval) .UseLoggerFactory(loggerFactory) .Build(); } - - private static async Task RunSkillScriptAsync( - AgentFileSkill skill, - AgentFileSkillScript script, - JsonElement? arguments, - IServiceProvider? serviceProvider, - CancellationToken cancellationToken) - { - var ext = Path.GetExtension(script.FullPath).ToLowerInvariant(); - var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - var program = ext switch - { - ".py" => isWindows ? "python" : "python3", - ".sh" => "bash", - ".js" => "node", - _ => null - }; - if (program is null) - return $"No runner registered for '{ext}' scripts."; - - var psi = new ProcessStartInfo - { - FileName = program, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; - psi.ArgumentList.Add(script.FullPath); - if (arguments.HasValue && arguments.Value.ValueKind == JsonValueKind.Object) - { - foreach (var prop in arguments.Value.EnumerateObject()) - { - var val = prop.Value.ToString(); - if (!string.IsNullOrEmpty(val)) - psi.ArgumentList.Add(val); - } - } - - using var proc = Process.Start(psi) - ?? throw new InvalidOperationException($"Failed to start {program}"); - - // Read stdout and stderr concurrently — sequential reads deadlock if either pipe fills. - var stdoutTask = proc.StandardOutput.ReadToEndAsync(cancellationToken); - var stderrTask = proc.StandardError.ReadToEndAsync(cancellationToken); - await Task.WhenAll(stdoutTask, stderrTask); - await proc.WaitForExitAsync(cancellationToken); - - var stdout = await stdoutTask; - var stderr = await stderrTask; - return string.IsNullOrWhiteSpace(stderr) ? stdout : $"{stdout}\nstderr: {stderr}"; - } - } diff --git a/src/Core/Skills/FrontmatterFieldReader.cs b/src/Core/Skills/FrontmatterFieldReader.cs new file mode 100644 index 00000000..3c76c861 --- /dev/null +++ b/src/Core/Skills/FrontmatterFieldReader.cs @@ -0,0 +1,61 @@ +using System.Text.RegularExpressions; + +namespace fuseraft.Core.Skills; + +/// +/// Reads a single top-level frontmatter field's raw value from a SKILL.md file's content — +/// nothing more. +/// +/// +/// This is the one remaining piece of hand-written skill-related code in fuseraft, and it +/// deliberately does no validation of its own. Every real spec question (is this name valid +/// kebab-case, does it match its directory, is the description within the length limit, ...) is +/// answered exclusively by Microsoft.Agents.AI's AgentSkillFrontmatter/ +/// AgentFileSkillsSource. Those classes have no public entry point that parses a raw +/// string outside of the full file-discovery pipeline, which itself requires the file to already +/// live at a directory whose name matches its own name: field — a chicken-and-egg problem +/// for the two places that need to know a candidate's intended name before it's placed +/// anywhere: fuseraft skills add (installing a skill whose source directory doesn't yet +/// match) and SkillCurator (writing a freshly-generated skill to disk for the first time). +/// This method exists solely to answer "what does this file currently call itself" for that +/// narrow bootstrapping purpose. +/// +/// +public static class FrontmatterFieldReader +{ + private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(2); + + private static readonly Regex FrontmatterBlock = + new(@"\A^---\s*$(.*?)^---\s*$", RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled, RegexTimeout); + + private static readonly Regex TopLevelKeyValue = + new(@"^([A-Za-z][\w-]*)\s*:[ \t]*(?:""([^""]*)""|'([^']*)'|(\S.*?))?\s*$", RegexOptions.Multiline | RegexOptions.Compiled, RegexTimeout); + + /// + /// Returns the raw value of a top-level line inside + /// 's YAML frontmatter block, or null when the frontmatter + /// block, the key, or its value is absent. + /// + public static string? ExtractField(string? content, string key) + { + if (string.IsNullOrEmpty(content)) return null; + + Match block; + try { block = FrontmatterBlock.Match(content); } + catch (RegexMatchTimeoutException) { return null; } + if (!block.Success) return null; + + foreach (Match m in TopLevelKeyValue.Matches(block.Groups[1].Value)) + { + if (!string.Equals(m.Groups[1].Value, key, StringComparison.OrdinalIgnoreCase)) continue; + + var value = m.Groups[2].Success ? m.Groups[2].Value + : m.Groups[3].Success ? m.Groups[3].Value + : m.Groups[4].Success ? m.Groups[4].Value + : null; + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + return null; + } +} diff --git a/src/Core/Skills/FuseraftSkillsSources.cs b/src/Core/Skills/FuseraftSkillsSources.cs new file mode 100644 index 00000000..1d1d37a8 --- /dev/null +++ b/src/Core/Skills/FuseraftSkillsSources.cs @@ -0,0 +1,158 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.Json; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace fuseraft.Core.Skills; + +/// +/// Shared plumbing for wiring up Microsoft.Agents.AI's Agent Skills feature +/// (/) — the single +/// implementation both the REPL (fuseraft repl) and orchestration (fuseraft run) +/// use for skill discovery, frontmatter parsing/validation, and progressive disclosure. Nothing +/// in this file parses or validates SKILL.md content; that is entirely Microsoft's +/// /. This file only +/// supplies the two things the library deliberately leaves to the host: where to search, and +/// how to execute a script file on this OS. +/// +public static class FuseraftSkillsSources +{ + /// + /// Priority-ordered directories both the REPL and orchestration scan for skills + /// (project-native → project cross-client → user-native → user cross-client → built-in). + /// Non-existent directories are skipped by itself. + /// + public static string[] GetDefaultSearchDirs() + { + var cwd = Directory.GetCurrentDirectory(); + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return + [ + Path.Combine(cwd, ".fuseraft", "skills"), + Path.Combine(cwd, ".agents", "skills"), + fuseraft.Core.FuseraftPaths.GlobalSkills, + Path.Combine(home, ".agents", "skills"), + Path.Combine(AppContext.BaseDirectory, "skills"), + ]; + } + + /// + /// Applies the provider options fuseraft needs regardless of caller: fuseraft has no + /// Microsoft.Agents.AI ToolApprovalAgentOptions pipeline wired up anywhere — + /// neither the REPL (which drives directly via + /// UseFunctionInvocation(), a plain Microsoft.Extensions.AI concept with no + /// approval semantics) nor orchestration (which has its own, unrelated + /// IHumanApprovalService for shell commands, never wired to skill tools). Leaving + /// 's default (approval required for all three tools) would + /// make load_skill/read_skill_resource/run_skill_script silently + /// non-functional rather than "safely gated" — a model's attempt to call them would come + /// back as an unresolved approval request that nothing in fuseraft ever grants. + /// + public static void DisableApproval(AgentSkillsProviderOptions options) + { + options.DisableLoadSkillApproval = true; + options.DisableReadSkillResourceApproval = true; + options.DisableRunSkillScriptApproval = true; + } + + /// + /// Runs a file-based skill script as a local subprocess. Ported from Microsoft's own + /// reference implementation (samples/02-agents/AgentSkills/SubprocessScriptRunner.cs + /// in the agent-framework repo, referenced directly from 's + /// own XML doc example) rather than reimplemented, since the framework does not ship a + /// default script runner — is an intentional + /// extension point the host must supply. + /// + public static async Task RunScriptAsync( + AgentFileSkill skill, + AgentFileSkillScript script, + JsonElement? arguments, + IServiceProvider? serviceProvider, + CancellationToken cancellationToken) + { + if (!File.Exists(script.FullPath)) + return $"Error: Script file not found: {script.FullPath}"; + + var extension = Path.GetExtension(script.FullPath); + var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + string? interpreter = extension switch + { + ".py" => isWindows ? "python" : "python3", + ".js" => "node", + ".sh" => "bash", + ".ps1" => "pwsh", + _ => null, + }; + + var startInfo = new ProcessStartInfo + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = Path.GetDirectoryName(script.FullPath) ?? ".", + }; + + if (interpreter is not null) + { + startInfo.FileName = interpreter; + startInfo.ArgumentList.Add(script.FullPath); + } + else + { + startInfo.FileName = script.FullPath; + } + + if (arguments is { ValueKind: JsonValueKind.Array } json) + { + foreach (var element in json.EnumerateArray()) + { + if (element.ValueKind != JsonValueKind.String) + throw new InvalidOperationException( + $"File-based skill scripts only accept string CLI arguments but received a JSON element of kind '{element.ValueKind}'."); + startInfo.ArgumentList.Add(element.GetString()!); + } + } + else if (arguments is not null && arguments.Value.ValueKind is not (JsonValueKind.Null or JsonValueKind.Undefined)) + { + throw new InvalidOperationException( + $"Expected a JSON array of CLI arguments but received {arguments.Value.ValueKind}."); + } + + Process? process = null; + try + { + process = Process.Start(startInfo); + if (process is null) + return $"Error: Failed to start process for script '{script.Name}'."; + + var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + + var output = await outputTask.ConfigureAwait(false); + var error = await errorTask.ConfigureAwait(false); + + if (!string.IsNullOrEmpty(error)) + output += $"\nStderr:\n{error}"; + if (process.ExitCode != 0) + output += $"\nScript exited with code {process.ExitCode}"; + + return string.IsNullOrEmpty(output) ? "(no output)" : output.Trim(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + process?.Kill(entireProcessTree: true); + throw; + } + catch (Exception ex) + { + return $"Error: Failed to execute script '{script.Name}': {ex.Message}"; + } + finally + { + process?.Dispose(); + } + } +} diff --git a/src/Core/Skills/SkillDiscoveryAgent.cs b/src/Core/Skills/SkillDiscoveryAgent.cs new file mode 100644 index 00000000..13970162 --- /dev/null +++ b/src/Core/Skills/SkillDiscoveryAgent.cs @@ -0,0 +1,38 @@ +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace fuseraft.Core.Skills; + +/// +/// Provides a throwaway for CLI commands that need to call +/// (or ) +/// purely to validate or inspect skill files on disk, with no live model involved +/// (skills add, skills validate, skills list). +/// +/// +/// Both and +/// require a non-null handle, even though the file-based skills source never +/// reads anything from it — the parameter exists generically across all +/// implementations (an MCP-backed source, for instance, might scope skills per agent identity). +/// Where a real is already in hand (the REPL, SkillCurator), +/// wrap that instead of using this — this stub deliberately can never answer a real prompt. +/// +/// +public static class SkillDiscoveryAgent +{ + /// Creates a new throwaway agent backed by a chat client that is never actually invoked. + public static AIAgent Create() => new ChatClientAgent(new NonInvocableChatClient()); + + private sealed class NonInvocableChatClient : IChatClient + { + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotSupportedException($"{nameof(NonInvocableChatClient)} exists only to satisfy an API's AIAgent requirement for offline skill discovery and cannot answer prompts."); + + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotSupportedException($"{nameof(NonInvocableChatClient)} exists only to satisfy an API's AIAgent requirement for offline skill discovery and cannot answer prompts."); + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() { } + } +} diff --git a/src/Infrastructure/Plugins/SkillsPlugin.cs b/src/Infrastructure/Plugins/SkillsPlugin.cs deleted file mode 100644 index 21b249db..00000000 --- a/src/Infrastructure/Plugins/SkillsPlugin.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System.ComponentModel; -using System.Diagnostics; -using System.Runtime.InteropServices; - -namespace fuseraft.Infrastructure.Plugins; - -/// -/// Exposes skills from the library as callable tools. -/// -/// -/// Skills follow the progressive-disclosure pattern: at session start the REPL -/// injects a catalog of skill names and descriptions into the system prompt so the -/// model knows what is available. When the model decides to apply a skill it calls -/// load_skill to retrieve the full step-by-step SKILL.md body, then follows -/// those instructions using its other tools. read_skill_resource is available -/// for skills that ship supplementary reference files (e.g. under references/) -/// alongside their SKILL.md, and run_skill_script for skills that ship -/// executable scripts. -/// -/// -public sealed class SkillsPlugin -{ - // slug → directory that contains SKILL.md (and any scripts) - private readonly IReadOnlyDictionary _skillDirs; - - public int Count => _skillDirs.Count; - - public IEnumerable Slugs => _skillDirs.Keys; - - public bool HasSkill(string slug) => _skillDirs.ContainsKey(slug); - - public SkillsPlugin(IReadOnlyDictionary skillDirs) - { - _skillDirs = skillDirs; - } - - [Description("Load full instructions for a skill by slug.")] - public async Task LoadSkillAsync( - [Description("Skill slug, e.g. 'fetch-remote-api'.")] string name, - CancellationToken cancellationToken = default) - { - if (!_skillDirs.TryGetValue(name, out var dir)) - { - var known = string.Join(", ", _skillDirs.Keys.Take(10)); - return PluginResult.NotFound($"No skill '{name}'. Available: {known}"); - } - - var skillPath = Path.Combine(dir, "SKILL.md"); - if (!File.Exists(skillPath)) - return PluginResult.Error($"SKILL.md missing for '{name}'."); - - try - { - return await File.ReadAllTextAsync(skillPath, cancellationToken); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - return PluginResult.Error($"Could not read skill '{name}': {ex.Message}"); - } - } - - [Description("Read a reference/resource file bundled with a skill, e.g. a file under 'references/'.")] - public async Task ReadSkillResourceAsync( - [Description("Skill slug.")] string skill, - [Description("Resource path relative to the skill directory, e.g. 'references/style-guide.md'.")] string resourcePath, - CancellationToken cancellationToken = default) - { - if (!_skillDirs.TryGetValue(skill, out var dir)) - return PluginResult.NotFound($"No skill '{skill}'."); - - if (string.IsNullOrWhiteSpace(resourcePath)) - return PluginResult.Error("Resource path must not be empty."); - - // Resolve against the skill directory and confirm the result stays inside it — - // resourcePath comes from the model, so an absolute path or "../" sequence must - // not be able to escape to arbitrary files on disk. - var skillRoot = Path.GetFullPath(dir) + Path.DirectorySeparatorChar; - var fullPath = Path.GetFullPath(Path.Combine(dir, resourcePath)); - if (!fullPath.StartsWith(skillRoot, StringComparison.Ordinal)) - return PluginResult.Error($"'{resourcePath}' is outside the skill directory."); - - if (!File.Exists(fullPath)) - return PluginResult.NotFound($"Resource '{resourcePath}' not found in skill '{skill}'."); - - try - { - return await File.ReadAllTextAsync(fullPath, cancellationToken); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - return PluginResult.Error($"Could not read resource '{resourcePath}': {ex.Message}"); - } - } - - [Description("Run a script bundled with a skill.")] - public async Task RunSkillScriptAsync( - [Description("Skill slug.")] string skill, - [Description("Script filename inside the skill directory, e.g. 'transform.py'.")] string script, - [Description("Space-separated arguments to pass to the script.")] string args = "", - CancellationToken cancellationToken = default) - { - if (!_skillDirs.TryGetValue(skill, out var dir)) - return PluginResult.NotFound($"No skill '{skill}'."); - - // Resolve against the skill directory and confirm the result stays inside it — same - // containment check as ReadSkillResourceAsync, since 'script' comes from the model. - var skillRoot = Path.GetFullPath(dir) + Path.DirectorySeparatorChar; - var scriptPath = Path.GetFullPath(Path.Combine(dir, script)); - if (!scriptPath.StartsWith(skillRoot, StringComparison.Ordinal)) - return PluginResult.Error($"'{script}' is outside the skill directory."); - - if (!File.Exists(scriptPath)) - return PluginResult.NotFound($"Script '{script}' not found in skill '{skill}'."); - - var ext = Path.GetExtension(scriptPath).ToLowerInvariant(); - var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); - var program = ext switch - { - ".py" => isWindows ? "python" : "python3", - ".sh" => "bash", - ".js" => "node", - _ => null, - }; - if (program is null) - return PluginResult.Error($"No runner registered for '{ext}' scripts."); - - var psi = new ProcessStartInfo - { - FileName = program, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; - psi.ArgumentList.Add(scriptPath); - foreach (var a in args.Split(' ', StringSplitOptions.RemoveEmptyEntries)) - psi.ArgumentList.Add(a); - - using var proc = Process.Start(psi) - ?? throw new InvalidOperationException($"Failed to start {program}"); - - var stdoutTask = proc.StandardOutput.ReadToEndAsync(cancellationToken); - var stderrTask = proc.StandardError.ReadToEndAsync(cancellationToken); - await Task.WhenAll(stdoutTask, stderrTask); - await proc.WaitForExitAsync(cancellationToken); - - var stdout = await stdoutTask; - var stderr = await stderrTask; - return string.IsNullOrWhiteSpace(stderr) ? stdout : $"{stdout}\nstderr: {stderr}"; - } -} diff --git a/src/Orchestration/Skills/SkillCurator.cs b/src/Orchestration/Skills/SkillCurator.cs index 12dc48c7..78790562 100644 --- a/src/Orchestration/Skills/SkillCurator.cs +++ b/src/Orchestration/Skills/SkillCurator.cs @@ -2,10 +2,12 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; +using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using fuseraft.Core; using fuseraft.Core.Models; +using fuseraft.Core.Skills; namespace fuseraft.Orchestration.Skills; @@ -67,9 +69,6 @@ public sealed class SkillCurator( private static readonly Regex SkillBlock = new(@"(.*?)", RegexOptions.Singleline | RegexOptions.IgnoreCase); - private static readonly Regex NameFrontmatter = - new(@"^name:\s*(.+)$", RegexOptions.Multiline | RegexOptions.IgnoreCase); - private static readonly JsonSerializerOptions LogJsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, @@ -167,24 +166,38 @@ public async Task RunAsync( } var skillContent = match.Groups[1].Value.Trim(); - var nameMatch = NameFrontmatter.Match(skillContent); - if (!nameMatch.Success) + + // Curation writes a brand-new file, so — unlike 'skills add' — there is no existing + // directory name to reconcile a sloppy title against. The system prompt above already + // instructs the model to emit a ready-made kebab-case slug; strict validation here + // (rather than silently slugifying whatever it produced) catches the rare case where it + // didn't, instead of writing something that would look fine here but be silently dropped + // by fuseraft's orchestration skills provider. AgentSkillFrontmatter's own constructor + // is the sole authority on whether the raw name/description/compatibility are valid. + var rawName = FrontmatterFieldReader.ExtractField(skillContent, "name"); + var rawDescription = FrontmatterFieldReader.ExtractField(skillContent, "description"); + var rawCompatibility = FrontmatterFieldReader.ExtractField(skillContent, "compatibility"); + + AgentSkillFrontmatter frontmatter; + try + { + frontmatter = new AgentSkillFrontmatter(rawName ?? string.Empty, rawDescription ?? string.Empty, rawCompatibility); + } + catch (ArgumentException ex) { - const string noNameReason = "SKILL block is missing the 'name:' frontmatter field."; logger.LogWarning( "Skill curation failed — session={Session} reason={Reason}", - checkpoint.SessionId, noNameReason); + checkpoint.SessionId, ex.Message); var failed = new SkillCurationResult( SkillCurationOutcome.Failed, - FailureReason: noNameReason, + FailureReason: ex.Message, TurnsDigested: digestTurns, Model: modelId); await AppendCurationLogAsync(checkpoint.SessionId, failed, source, ct); return failed; } - var name = nameMatch.Groups[1].Value.Trim().Trim('"').Trim('\''); - var slug = ToSlug(name); + var slug = frontmatter.Name; try { @@ -410,9 +423,6 @@ private async Task AppendCurationLogAsync( } } - private static string ToSlug(string name) => - Regex.Replace(name.ToLowerInvariant().Trim(), @"[^a-z0-9]+", "-").Trim('-'); - private sealed record CurationLogEntry( string Ts, string Session, diff --git a/src/Program.cs b/src/Program.cs index e9f1a70f..ccd7020c 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -144,6 +144,7 @@ services.AddTransient(); services.AddTransient(); services.AddTransient(); +services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -331,6 +332,11 @@ .WithExample(["skills", "curation-log"]) .WithExample(["skills", "curation-log", "--last", "20"]) .WithExample(["skills", "curation-log", "--outcome", "failed"]); + + branch.AddCommand("validate") + .WithDescription("Validate a SKILL.md's frontmatter against the Agent Skills specification.") + .WithExample(["skills", "validate"]) + .WithExample(["skills", "validate", "../skills/sandbox-test"]); }); cfg.AddBranch("log", branch => diff --git a/tests/FuseraftCli.Tests/ReplSkillsLoaderTests.cs b/tests/FuseraftCli.Tests/ReplSkillsLoaderTests.cs index 5c9893a8..ff1b8001 100644 --- a/tests/FuseraftCli.Tests/ReplSkillsLoaderTests.cs +++ b/tests/FuseraftCli.Tests/ReplSkillsLoaderTests.cs @@ -1,267 +1,133 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; using fuseraft.Cli.Commands.Repl; namespace FuseraftCli.Tests; /// -/// Tests for : directory scanning, frontmatter parsing, -/// catalog generation, and resilience to bad/missing inputs. +/// Tests for : the thin REPL-side wiring over +/// Microsoft.Agents.AI's AgentFileSkillsSource/AgentSkillsProvider. /// -/// All tests use an isolated temp directory; no real skill library is touched. +/// +/// These tests deliberately do not re-verify frontmatter validation rules (kebab-case format, +/// length limits, name-matches-directory, ...) — that's Microsoft's own, separately-tested +/// behavior. What's fuseraft-specific and worth covering here is the wiring itself: that +/// discovery results, catalog instructions, and tools all come back consistently, and that +/// dedup/precedence across multiple search directories works as the REPL depends on. +/// /// public sealed class ReplSkillsLoaderTests : IDisposable { private readonly string _root; + private static readonly IChatClient StubClient = new NonInvocableStubChatClient(); public ReplSkillsLoaderTests() { - _root = Path.Combine(Path.GetTempPath(), "fuseraft_loader_tests_" + Guid.NewGuid().ToString("N")[..8]); + _root = Path.Combine(Path.GetTempPath(), "fuseraft_repl_loader_tests_" + Guid.NewGuid().ToString("N")[..8]); Directory.CreateDirectory(_root); } public void Dispose() => Directory.Delete(_root, recursive: true); - // ── helpers ─────────────────────────────────────────────────────────────── - - /// Creates a skill dir with a SKILL.md at _root/slug/SKILL.md. - private string WriteSkill(string slug, string content) + private string WriteSkill(string relativeDir, string name, string description, string body = "## Steps\n1. Do it.") { - var dir = Path.Combine(_root, slug); + var dir = Path.Combine(_root, relativeDir); Directory.CreateDirectory(dir); - File.WriteAllText(Path.Combine(dir, "SKILL.md"), content); + File.WriteAllText(Path.Combine(dir, "SKILL.md"), + $"---\nname: {name}\ndescription: \"{description}\"\n---\n\n{body}"); return dir; } - private string ValidSkillMd(string name, string description, string body = "## Steps\n1. Do it.") - => $"---\nname: {name}\ndescription: \"{description}\"\n---\n\n{body}"; - - // ── ParseSkillDescription ───────────────────────────────────────────────── - - [Fact] - public void ParseSkillDescription_WellFormedFrontmatter_ReturnsDescription() - { - var path = Path.Combine(_root, "skill.md"); - File.WriteAllText(path, "---\nname: my-skill\ndescription: \"Use when doing X.\"\n---\n\n# Body"); - - var desc = ReplSkillsLoader.ParseSkillDescription(path); - Assert.Equal("Use when doing X.", desc); - } - - [Fact] - public void ParseSkillDescription_SingleQuotedValue_ReturnsUnquotedDescription() - { - var path = Path.Combine(_root, "skill.md"); - File.WriteAllText(path, "---\ndescription: 'Use when doing Y.'\n---"); - - var desc = ReplSkillsLoader.ParseSkillDescription(path); - Assert.Equal("Use when doing Y.", desc); - } - - [Fact] - public void ParseSkillDescription_UnquotedValue_ReturnsTrimmedDescription() - { - var path = Path.Combine(_root, "skill.md"); - File.WriteAllText(path, "---\ndescription: Use when doing Z.\n---"); - - var desc = ReplSkillsLoader.ParseSkillDescription(path); - Assert.Equal("Use when doing Z.", desc); - } - - [Fact] - public void ParseSkillDescription_DescriptionWithColons_ReturnsFullValue() - { - var path = Path.Combine(_root, "skill.md"); - File.WriteAllText(path, "---\ndescription: \"Use when: A, B, or C.\"\n---"); - - var desc = ReplSkillsLoader.ParseSkillDescription(path); - Assert.Equal("Use when: A, B, or C.", desc); - } - - [Fact] - public void ParseSkillDescription_EmptyValue_ReturnsNull() - { - var path = Path.Combine(_root, "skill.md"); - File.WriteAllText(path, "---\ndescription: \"\"\n---"); - - Assert.Null(ReplSkillsLoader.ParseSkillDescription(path)); - } - - [Fact] - public void ParseSkillDescription_WhitespaceOnlyValue_ReturnsNull() - { - var path = Path.Combine(_root, "skill.md"); - File.WriteAllText(path, "---\ndescription: \n---"); - - Assert.Null(ReplSkillsLoader.ParseSkillDescription(path)); - } - - [Fact] - public void ParseSkillDescription_NoDescriptionField_ReturnsNull() - { - var path = Path.Combine(_root, "skill.md"); - File.WriteAllText(path, "---\nname: my-skill\n---\n\n# Body"); - - Assert.Null(ReplSkillsLoader.ParseSkillDescription(path)); - } - - [Fact] - public void ParseSkillDescription_NoFrontmatter_ReturnsNull() - { - var path = Path.Combine(_root, "skill.md"); - File.WriteAllText(path, "# No frontmatter here\n\nJust body text."); - - Assert.Null(ReplSkillsLoader.ParseSkillDescription(path)); - } - - [Fact] - public void ParseSkillDescription_UnclosedFrontmatter_ReturnsNull() - { - // Opening --- but no closing ---; reads to EOF without finding the field. - var path = Path.Combine(_root, "skill.md"); - File.WriteAllText(path, "---\nname: skill\n\nNo closing delimiter"); - - Assert.Null(ReplSkillsLoader.ParseSkillDescription(path)); - } - - [Fact] - public void ParseSkillDescription_EmptyFile_ReturnsNull() - { - var path = Path.Combine(_root, "skill.md"); - File.WriteAllText(path, ""); - - Assert.Null(ReplSkillsLoader.ParseSkillDescription(path)); - } - - [Fact] - public void ParseSkillDescription_FileDoesNotExist_ReturnsNull() - { - var path = Path.Combine(_root, "nonexistent.md"); - Assert.Null(ReplSkillsLoader.ParseSkillDescription(path)); - } - - // ── BuildSkills — no skills ─────────────────────────────────────────────── - - [Fact] - public void BuildSkills_NoSearchDirs_ReturnsNull() - { - var (plugin, catalog) = ReplSkillsLoader.BuildSkills(Array.Empty()); - Assert.Null(plugin); - Assert.Null(catalog); - } + private static Task Build(params string[] searchDirs) => + ReplSkillsLoader.BuildAsync(StubClient, NullLoggerFactory.Instance, searchDirs, CancellationToken.None); [Fact] - public void BuildSkills_SearchDirDoesNotExist_ReturnsNull() + public async Task BuildAsync_NoSearchDirs_ReturnsEmpty() { - var (plugin, catalog) = ReplSkillsLoader.BuildSkills([Path.Combine(_root, "nonexistent")]); - Assert.Null(plugin); - Assert.Null(catalog); - } - - [Fact] - public void BuildSkills_SearchDirExistsButEmpty_ReturnsNull() - { - var (plugin, catalog) = ReplSkillsLoader.BuildSkills([_root]); - Assert.Null(plugin); - Assert.Null(catalog); - } + var result = await Build(); - [Fact] - public void BuildSkills_DirHasNoSkillMdFiles_ReturnsNull() - { - // A file called something else — should be ignored. - File.WriteAllText(Path.Combine(_root, "README.md"), "not a skill"); - var (plugin, catalog) = ReplSkillsLoader.BuildSkills([_root]); - Assert.Null(plugin); - Assert.Null(catalog); + Assert.Empty(result.Skills); + Assert.Null(result.CatalogInstructions); + Assert.Empty(result.Tools); } - // ── BuildSkills — valid skills ──────────────────────────────────────────── - [Fact] - public void BuildSkills_OneValidSkill_ReturnsPluginAndCatalog() + public async Task BuildAsync_SearchDirDoesNotExist_ReturnsEmpty() { - WriteSkill("fetch-api", ValidSkillMd("fetch-api", "Use when fetching REST data.")); - - var (plugin, catalog) = ReplSkillsLoader.BuildSkills([_root]); + var result = await Build(Path.Combine(_root, "nonexistent")); - Assert.NotNull(plugin); - Assert.NotNull(catalog); - Assert.Equal(1, plugin!.Count); + Assert.Empty(result.Skills); + Assert.Null(result.CatalogInstructions); } [Fact] - public void BuildSkills_CatalogContainsSlugAndDescription() + public async Task BuildAsync_OneValidSkill_ReturnsSkillCatalogAndTools() { - WriteSkill("fetch-api", ValidSkillMd("fetch-api", "Use when fetching REST data.")); + WriteSkill("fetch-api", "fetch-api", "Use when fetching REST data."); - var (_, catalog) = ReplSkillsLoader.BuildSkills([_root]); + var result = await Build(_root); - Assert.Contains("fetch-api", catalog!); - Assert.Contains("Use when fetching REST data.", catalog); + Assert.Single(result.Skills); + Assert.Equal("fetch-api", result.Skills[0].Frontmatter.Name); + Assert.NotNull(result.CatalogInstructions); + Assert.Contains("fetch-api", result.CatalogInstructions); + Assert.Contains("Use when fetching REST data.", result.CatalogInstructions); } [Fact] - public void BuildSkills_CatalogContainsLoadSkillInstruction() + public async Task BuildAsync_ValidSkill_ExposesLoadReadRunSkillTools() { - WriteSkill("my-skill", ValidSkillMd("my-skill", "A skill.")); + WriteSkill("my-skill", "my-skill", "A skill."); - var (_, catalog) = ReplSkillsLoader.BuildSkills([_root]); + var result = await Build(_root); - Assert.Contains("load_skill", catalog!); + var toolNames = result.Tools.Select(t => t.Name).ToList(); + Assert.Contains("load_skill", toolNames); + Assert.Contains("read_skill_resource", toolNames); + Assert.Contains("run_skill_script", toolNames); } [Fact] - public void BuildSkills_MultipleSkills_AllAppearInCatalog() + public async Task BuildAsync_LoadSkillTool_ReturnsFullContent() { - WriteSkill("alpha", ValidSkillMd("alpha", "First skill.")); - WriteSkill("beta", ValidSkillMd("beta", "Second skill.")); + WriteSkill("my-skill", "my-skill", "A skill.", body: "## Do the thing\nStep one."); - var (plugin, catalog) = ReplSkillsLoader.BuildSkills([_root]); + var result = await Build(_root); + var loadSkill = result.Tools.Single(t => t.Name == "load_skill"); - Assert.Equal(2, plugin!.Count); - Assert.Contains("alpha", catalog!); - Assert.Contains("beta", catalog); - } + var content = await loadSkill.InvokeAsync(new AIFunctionArguments { ["skillName"] = "my-skill" }); - [Fact] - public void BuildSkills_CatalogSlugsAreSorted() - { - WriteSkill("zebra", ValidSkillMd("zebra", "Z skill.")); - WriteSkill("alpha", ValidSkillMd("alpha", "A skill.")); - - var (_, catalog) = ReplSkillsLoader.BuildSkills([_root]); - - var alphaPos = catalog!.IndexOf("alpha", StringComparison.Ordinal); - var zebraPos = catalog.IndexOf("zebra", StringComparison.Ordinal); - Assert.True(alphaPos < zebraPos, "Catalog should list skills in alphabetical order"); + Assert.Contains("Do the thing", content?.ToString()); } [Fact] - public void BuildSkills_SkillWithNoDescription_SlugAppearsWithoutTrailingColon() + public async Task BuildAsync_NameDoesNotMatchDirectory_SkillIsSilentlyExcluded() { - // Skill with no description field — just a bare slug in the catalog. - WriteSkill("bare-skill", "# No frontmatter here"); + // AgentFileSkillsSource's own validation, not fuseraft's — covered here only to confirm + // the wiring surfaces that behavior rather than working around it. + WriteSkill("mismatched-dir", "totally-different-name", "A description."); - var (plugin, catalog) = ReplSkillsLoader.BuildSkills([_root]); + var result = await Build(_root); - Assert.NotNull(plugin); - Assert.Contains("bare-skill", catalog!); - Assert.DoesNotContain("bare-skill:", catalog); // no trailing colon + Assert.Empty(result.Skills); + Assert.Null(result.CatalogInstructions); } [Fact] - public void BuildSkills_SkillWithEmptyDescription_SlugAppearsWithoutTrailingColon() + public async Task BuildAsync_MultipleValidSkills_AllDiscovered() { - WriteSkill("empty-desc", "---\ndescription: \"\"\n---\n# Body"); + WriteSkill("alpha", "alpha", "First skill."); + WriteSkill("beta", "beta", "Second skill."); - var (_, catalog) = ReplSkillsLoader.BuildSkills([_root]); + var result = await Build(_root); - Assert.DoesNotContain("empty-desc:", catalog!); + Assert.Equal(2, result.Skills.Count); + Assert.Contains(result.Skills, s => s.Frontmatter.Name == "alpha"); + Assert.Contains(result.Skills, s => s.Frontmatter.Name == "beta"); } - // ── BuildSkills — priority and deduplication ────────────────────────────── - [Fact] - public void BuildSkills_DuplicateSlugAcrossDirs_FirstDirWins() + public async Task BuildAsync_DuplicateSlugAcrossSearchDirs_DeduplicatedAndFirstDirWins() { var dir1 = Path.Combine(_root, "priority1"); var dir2 = Path.Combine(_root, "priority2"); @@ -272,57 +138,39 @@ public void BuildSkills_DuplicateSlugAcrossDirs_FirstDirWins() var skill2 = Path.Combine(dir2, "my-skill"); Directory.CreateDirectory(skill1); Directory.CreateDirectory(skill2); - File.WriteAllText(Path.Combine(skill1, "SKILL.md"), "---\ndescription: \"From dir1\"\n---"); - File.WriteAllText(Path.Combine(skill2, "SKILL.md"), "---\ndescription: \"From dir2\"\n---"); + File.WriteAllText(Path.Combine(skill1, "SKILL.md"), "---\nname: my-skill\ndescription: \"From dir1\"\n---"); + File.WriteAllText(Path.Combine(skill2, "SKILL.md"), "---\nname: my-skill\ndescription: \"From dir2\"\n---"); - var (_, catalog) = ReplSkillsLoader.BuildSkills([dir1, dir2]); + var result = await Build(dir1, dir2); - Assert.Contains("From dir1", catalog!); - Assert.DoesNotContain("From dir2", catalog); - } - - // ── BuildSkills — resilience ────────────────────────────────────────────── - - [Fact] - public void BuildSkills_SkillMdWithGarbageContent_DoesNotThrow() - { - // Completely invalid content — should be indexed with a null description. - WriteSkill("garbage", "\x00\x01\x02 not UTF-8 friendly binary content \xff\xfe"); - - var ex = Record.Exception(() => ReplSkillsLoader.BuildSkills([_root])); - Assert.Null(ex); + // The banner count (result.Skills) must agree with what the catalog actually advertises — + // both must be deduplicated by name, not just the catalog. + Assert.Single(result.Skills); + Assert.Equal("From dir1", result.Skills[0].Frontmatter.Description); + Assert.Contains("From dir1", result.CatalogInstructions!); + Assert.DoesNotContain("From dir2", result.CatalogInstructions!); } [Fact] - public void BuildSkills_MixOfValidAndInvalidSkills_ValidOnesStillLoaded() + public async Task BuildAsync_DirHasNoSkillMdFiles_ReturnsEmpty() { - WriteSkill("good", ValidSkillMd("good", "A well-formed skill.")); - WriteSkill("badfile", "---\n: invalid yaml :\n---"); + File.WriteAllText(Path.Combine(_root, "README.md"), "not a skill"); - var (plugin, _) = ReplSkillsLoader.BuildSkills([_root]); + var result = await Build(_root); - Assert.NotNull(plugin); - Assert.Equal(2, plugin!.Count); // both dirs indexed; bad frontmatter just gives null desc + Assert.Empty(result.Skills); } - [Fact] - public void BuildSkills_EmptySkillMd_DoesNotThrow() + private sealed class NonInvocableStubChatClient : IChatClient { - WriteSkill("empty", ""); + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotSupportedException("Skill discovery should never actually invoke the chat client."); - var ex = Record.Exception(() => ReplSkillsLoader.BuildSkills([_root])); - Assert.Null(ex); - } + public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotSupportedException("Skill discovery should never actually invoke the chat client."); - [Fact] - public void BuildSkills_SkillMdIsDirectory_DoesNotThrow() - { - // Edge case: a path called "SKILL.md" that is actually a directory. - var slugDir = Path.Combine(_root, "weird-skill"); - var fakeMd = Path.Combine(slugDir, "SKILL.md"); - Directory.CreateDirectory(fakeMd); // SKILL.md is a directory, not a file + public object? GetService(Type serviceType, object? serviceKey = null) => null; - var ex = Record.Exception(() => ReplSkillsLoader.BuildSkills([_root])); - Assert.Null(ex); + public void Dispose() { } } } diff --git a/tests/FuseraftCli.Tests/SkillsHelpersTests.cs b/tests/FuseraftCli.Tests/SkillsHelpersTests.cs index af2a6631..61708576 100644 --- a/tests/FuseraftCli.Tests/SkillsHelpersTests.cs +++ b/tests/FuseraftCli.Tests/SkillsHelpersTests.cs @@ -1,4 +1,5 @@ using fuseraft.Cli.Commands.Skills; +using fuseraft.Core.Skills; namespace FuseraftCli.Tests; @@ -94,4 +95,37 @@ public void CopySkillDirectory_CreatesDestDirectory_WhenMissing() Assert.True(Directory.Exists(_destDir)); } + + // ── ExtractSlug / ExtractDescription / CanonicalizeName ──────────────────── + + [Fact] + public void ExtractSlug_SlugifiesRawName() + { + var content = "---\nname: My Bad Skill!!\ndescription: A skill.\n---"; + Assert.Equal("my-bad-skill", SkillsHelpers.ExtractSlug(content)); + } + + [Fact] + public void ExtractSlug_NoNameField_ReturnsNull() + { + Assert.Null(SkillsHelpers.ExtractSlug("---\ndescription: A skill.\n---")); + } + + [Fact] + public void CanonicalizeName_NameAlreadyMatchesSlug_ReturnsContentUnchanged() + { + const string content = "---\nname: my-skill\ndescription: A skill.\n---\n\nBody"; + Assert.Same(content, SkillsHelpers.CanonicalizeName(content, "my-skill")); + } + + [Fact] + public void CanonicalizeName_NameDiffersFromSlug_RewritesNameField() + { + var content = "---\nname: My Bad Skill!!\ndescription: A skill.\n---\n\nBody"; + var rewritten = SkillsHelpers.CanonicalizeName(content, "my-bad-skill"); + + Assert.Equal("my-bad-skill", SkillsHelpers.ExtractSlug(rewritten)); + Assert.Equal("A skill.", FrontmatterFieldReader.ExtractField(rewritten, "description")); + Assert.Contains("Body", rewritten); + } } diff --git a/tests/FuseraftCli.Tests/SkillsPluginTests.cs b/tests/FuseraftCli.Tests/SkillsPluginTests.cs deleted file mode 100644 index dd8e24b2..00000000 --- a/tests/FuseraftCli.Tests/SkillsPluginTests.cs +++ /dev/null @@ -1,265 +0,0 @@ -using fuseraft.Infrastructure.Plugins; - -namespace FuseraftCli.Tests; - -/// -/// Tests for . -/// -/// Each test gets an isolated temp directory. All slug-to-dir entries in the plugin -/// point into that directory so no real skill library is touched. -/// -public sealed class SkillsPluginTests : IDisposable -{ - private readonly string _root; - - public SkillsPluginTests() - { - _root = Path.Combine(Path.GetTempPath(), "fuseraft_skills_tests_" + Guid.NewGuid().ToString("N")[..8]); - Directory.CreateDirectory(_root); - } - - public void Dispose() => Directory.Delete(_root, recursive: true); - - // ── helpers ────────────────────────────────────────────────────────────── - - private string MakeSkillDir(string slug, string? content = null) - { - var dir = Path.Combine(_root, slug); - Directory.CreateDirectory(dir); - if (content is not null) - File.WriteAllText(Path.Combine(dir, "SKILL.md"), content); - return dir; - } - - private SkillsPlugin PluginFor(params (string Slug, string? Content)[] skills) - { - var dirs = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var (slug, content) in skills) - dirs[slug] = MakeSkillDir(slug, content); - return new SkillsPlugin(dirs); - } - - // ── LoadSkillAsync ──────────────────────────────────────────────────────── - - [Fact] - public async Task LoadSkill_UnknownSlug_ReturnsNotFound() - { - var plugin = PluginFor(("my-skill", "content")); - var result = await plugin.LoadSkillAsync("does-not-exist"); - Assert.StartsWith("[NOT FOUND]", result); - Assert.Contains("does-not-exist", result); - } - - [Fact] - public async Task LoadSkill_UnknownSlug_ListsKnownSkillsInMessage() - { - var plugin = PluginFor(("alpha", "body"), ("beta", "body")); - var result = await plugin.LoadSkillAsync("gamma"); - Assert.Contains("alpha", result); - Assert.Contains("beta", result); - } - - [Fact] - public async Task LoadSkill_ValidSlug_ReturnsFileContent() - { - const string body = "## Do the thing\n1. Step one\n2. Step two"; - var plugin = PluginFor(("my-skill", body)); - var result = await plugin.LoadSkillAsync("my-skill"); - Assert.Equal(body, result); - } - - [Fact] - public async Task LoadSkill_EmptySkillFile_ReturnsEmptyString() - { - var plugin = PluginFor(("empty-skill", "")); - var result = await plugin.LoadSkillAsync("empty-skill"); - Assert.Equal(string.Empty, result); - } - - [Fact] - public async Task LoadSkill_SkillMdDeletedAfterInit_ReturnsError() - { - // TOCTOU: file disappears between plugin construction and the load call. - var dir = MakeSkillDir("vanishing", "some content"); - File.Delete(Path.Combine(dir, "SKILL.md")); - - var plugin = new SkillsPlugin(new Dictionary { ["vanishing"] = dir }); - var result = await plugin.LoadSkillAsync("vanishing"); - - Assert.StartsWith("[ERROR]", result); - } - - [Fact] - public async Task LoadSkill_SlugIsCaseInsensitive() - { - var plugin = PluginFor(("My-Skill", "body")); - var result = await plugin.LoadSkillAsync("my-skill"); - Assert.Equal("body", result); - } - - [Fact] - public async Task LoadSkill_DoesNotThrow_ReturnsStringResult() - { - // Any slug → result must be a string, never an unhandled exception. - var plugin = new SkillsPlugin(new Dictionary()); - var ex = await Record.ExceptionAsync(() => plugin.LoadSkillAsync("anything")); - Assert.Null(ex); - } - - // ── ReadSkillResourceAsync ──────────────────────────────────────────────── - - [Fact] - public async Task ReadSkillResource_UnknownSkill_ReturnsNotFound() - { - var plugin = PluginFor(("real-skill", "body")); - var result = await plugin.ReadSkillResourceAsync("ghost", "references/x.md"); - Assert.StartsWith("[NOT FOUND]", result); - } - - [Fact] - public async Task ReadSkillResource_EmptyPath_ReturnsError() - { - var plugin = PluginFor(("my-skill", "body")); - var result = await plugin.ReadSkillResourceAsync("my-skill", ""); - Assert.StartsWith("[ERROR]", result); - } - - [Fact] - public async Task ReadSkillResource_MissingFile_ReturnsNotFound() - { - var plugin = PluginFor(("my-skill", "body")); - var result = await plugin.ReadSkillResourceAsync("my-skill", "references/missing.md"); - Assert.StartsWith("[NOT FOUND]", result); - Assert.Contains("references/missing.md", result); - } - - [Fact] - public async Task ReadSkillResource_NestedFile_ReturnsContent() - { - var dir = MakeSkillDir("my-skill", "body"); - Directory.CreateDirectory(Path.Combine(dir, "references")); - File.WriteAllText(Path.Combine(dir, "references", "style-guide.md"), "# Style Guide\nUse tabs."); - var plugin = new SkillsPlugin(new Dictionary { ["my-skill"] = dir }); - - var result = await plugin.ReadSkillResourceAsync("my-skill", "references/style-guide.md"); - Assert.Equal("# Style Guide\nUse tabs.", result); - } - - [Theory] - [InlineData("../secret.txt")] - [InlineData("references/../../secret.txt")] - public async Task ReadSkillResource_PathTraversal_ReturnsError(string traversalPath) - { - var dir = MakeSkillDir("my-skill", "body"); - File.WriteAllText(Path.Combine(_root, "secret.txt"), "top secret"); - var plugin = new SkillsPlugin(new Dictionary { ["my-skill"] = dir }); - - var result = await plugin.ReadSkillResourceAsync("my-skill", traversalPath); - Assert.StartsWith("[ERROR]", result); - Assert.DoesNotContain("top secret", result); - } - - [Fact] - public async Task ReadSkillResource_AbsolutePathEscape_ReturnsError() - { - var dir = MakeSkillDir("my-skill", "body"); - var outsideFile = Path.Combine(_root, "secret.txt"); - File.WriteAllText(outsideFile, "top secret"); - var plugin = new SkillsPlugin(new Dictionary { ["my-skill"] = dir }); - - var result = await plugin.ReadSkillResourceAsync("my-skill", outsideFile); - Assert.StartsWith("[ERROR]", result); - Assert.DoesNotContain("top secret", result); - } - - // ── RunSkillScriptAsync ─────────────────────────────────────────────────── - - [Fact] - public async Task RunSkillScript_UnknownSkill_ReturnsNotFound() - { - var plugin = PluginFor(("real-skill", "body")); - var result = await plugin.RunSkillScriptAsync("ghost", "run.sh"); - Assert.StartsWith("[NOT FOUND]", result); - } - - [Fact] - public async Task RunSkillScript_PathTraversal_ReturnsError() - { - var dir = MakeSkillDir("my-skill", "body"); - var outsideScript = Path.Combine(_root, "evil.sh"); - File.WriteAllText(outsideScript, "#!/bin/sh\necho pwned\n"); - var plugin = new SkillsPlugin(new Dictionary { ["my-skill"] = dir }); - - var result = await plugin.RunSkillScriptAsync("my-skill", "../evil.sh"); - Assert.StartsWith("[ERROR]", result); - Assert.DoesNotContain("pwned", result); - } - - [Fact] - public async Task RunSkillScript_NestedScriptPath_Runs() - { - var dir = MakeSkillDir("my-skill", "body"); - Directory.CreateDirectory(Path.Combine(dir, "scripts")); - File.WriteAllText(Path.Combine(dir, "scripts", "hello.sh"), "#!/bin/sh\necho nested-ok\n"); - var plugin = new SkillsPlugin(new Dictionary { ["my-skill"] = dir }); - - var result = await plugin.RunSkillScriptAsync("my-skill", "scripts/hello.sh"); - Assert.Contains("nested-ok", result); - } - - [Fact] - public async Task RunSkillScript_ScriptFileMissing_ReturnsNotFound() - { - var plugin = PluginFor(("my-skill", "body")); - var result = await plugin.RunSkillScriptAsync("my-skill", "missing.sh"); - Assert.StartsWith("[NOT FOUND]", result); - Assert.Contains("missing.sh", result); - } - - [Fact] - public async Task RunSkillScript_UnsupportedExtension_ReturnsError() - { - var dir = MakeSkillDir("my-skill", "body"); - File.WriteAllText(Path.Combine(dir, "run.exe"), "binary"); - var plugin = new SkillsPlugin(new Dictionary { ["my-skill"] = dir }); - - var result = await plugin.RunSkillScriptAsync("my-skill", "run.exe"); - Assert.StartsWith("[ERROR]", result); - Assert.Contains(".exe", result); - } - - [Fact] - public async Task RunSkillScript_ShellScript_ReturnsStdout() - { - var dir = MakeSkillDir("my-skill", "body"); - File.WriteAllText(Path.Combine(dir, "hello.sh"), "#!/bin/sh\necho hello-from-skill\n"); - var plugin = new SkillsPlugin(new Dictionary { ["my-skill"] = dir }); - - var result = await plugin.RunSkillScriptAsync("my-skill", "hello.sh"); - Assert.Contains("hello-from-skill", result); - } - - [Fact] - public async Task RunSkillScript_ScriptWritesToStderr_StderrAppendedToResult() - { - var dir = MakeSkillDir("my-skill", "body"); - File.WriteAllText(Path.Combine(dir, "warn.sh"), "#!/bin/sh\necho out\necho err >&2\n"); - var plugin = new SkillsPlugin(new Dictionary { ["my-skill"] = dir }); - - var result = await plugin.RunSkillScriptAsync("my-skill", "warn.sh"); - Assert.Contains("out", result); - Assert.Contains("stderr", result); - Assert.Contains("err", result); - } - - [Fact] - public async Task RunSkillScript_EmptyArgs_DoesNotThrow() - { - var dir = MakeSkillDir("my-skill", "body"); - File.WriteAllText(Path.Combine(dir, "noop.sh"), "#!/bin/sh\necho ok\n"); - var plugin = new SkillsPlugin(new Dictionary { ["my-skill"] = dir }); - - var ex = await Record.ExceptionAsync(() => plugin.RunSkillScriptAsync("my-skill", "noop.sh", args: "")); - Assert.Null(ex); - } -}