From 10206b09fd4fc43c802eb46bcb5aaa0b862f7d65 Mon Sep 17 00:00:00 2001 From: Nils-Paul Korte <192386710+NPK111@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:07:05 +0100 Subject: [PATCH 1/2] CLI: Add "agent init" Command. --- docs/PluginDevelopment.md | 51 +++ docs/README.md | 11 +- .../Services/IExternalAgentSkillProvider.cs | 14 + .../Cli/AgentCliModule.cs | 36 ++ .../Cli/CliModuleLoader.cs | 83 +++- .../Cli/ExtensionStoreCliModule.cs | 26 +- .../Cli/ExternalSkillSynchronizer.cs | 409 ++++++++++++++++++ .../Cli/StudioExternalSkillProvider.cs | 16 + .../ExternalSkills/oneware-cli/SKILL.md | 37 ++ .../OneWare.Studio.Desktop.csproj | 5 + 10 files changed, 681 insertions(+), 7 deletions(-) create mode 100644 src/OneWare.Essentials/Services/IExternalAgentSkillProvider.cs create mode 100644 studio/OneWare.Studio.Desktop/Cli/AgentCliModule.cs create mode 100644 studio/OneWare.Studio.Desktop/Cli/ExternalSkillSynchronizer.cs create mode 100644 studio/OneWare.Studio.Desktop/Cli/StudioExternalSkillProvider.cs create mode 100644 studio/OneWare.Studio.Desktop/ExternalSkills/oneware-cli/SKILL.md diff --git a/docs/PluginDevelopment.md b/docs/PluginDevelopment.md index 0361b47c..e54ae4f1 100644 --- a/docs/PluginDevelopment.md +++ b/docs/PluginDevelopment.md @@ -1007,6 +1007,57 @@ Names must be unique; registering an existing name replaces the previous agent o directories generated from `RegisterSkill` are cleaned up automatically when a skill is renamed or no longer registered. +### Skills for external agents + +Studio chat skills and functions are not automatically available to external agents. To contribute +skills for external agents, implement `IExternalAgentSkillProvider` in a public, non-abstract plugin +class with a public parameterless constructor. It is discovered independently of CLI and GUI modules: + +```csharp +public sealed class ExternalSkills : IExternalAgentSkillProvider +{ + public IReadOnlyCollection GetSkillDirectories() + { + var assemblyDirectory = Path.GetDirectoryName(typeof(ExternalSkills).Assembly.Location) + ?? throw new InvalidOperationException("The plugin assembly has no directory."); + return [Path.Combine(assemblyDirectory, "ExternalSkills")]; + } +} +``` + +Return absolute paths to existing discovery roots. Each direct child directory is a skill with a +`SKILL.md`; its name must match the `name` in the front matter and contain only lowercase letters, +digits, and single hyphens. The front matter must also contain a `description`. For example: + +```text +ExternalSkills/ +└── my-plugin-workflow/ + ├── SKILL.md + └── references/ + └── guide.md +``` + +Include all files in the plugin package, not just Markdown: + +```xml + + + +``` + +`oneware agent init` copies these directories to `~/.agents/skills/` and tracks their origin and +contents in `/oneware-skills.json`. Duplicate skill names are rejected; +unmanaged or locally edited skills are never overwritten or deleted. Unchanged skills are removed +after their extension is uninstalled and the command runs again. Skills from folders loaded with +`--modules` are also removed when that option is omitted on a subsequent run. Symbolic links are not +supported within skill directories. Design the skills for external agents: Studio chat functions +such as `fpga_*` are not made available by copying a skill. + +For a provider compiled into Studio rather than shipped as an installed plugin, register it +explicitly with `CliModuleLoader.RegisterBuiltInSkillProvider()` in +`studio/OneWare.Studio.Desktop/Cli/CliModuleLoader.cs`. Built-in skills are not automatically +removed as a result of a plugin uninstall. + ### Combining agents and skills `OneWareAiAgent.Skills` behaves differently from on-demand loading: the listed skills are diff --git a/docs/README.md b/docs/README.md index c1e6d16e..c8cabac2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -139,12 +139,21 @@ OneWare Studio arguments after `--`, see `./run-fresh.sh --help`). ### CLI commands -The Studio executable also hosts headless commands (`stop`, `extensions …`, `cloud login|logout`, plus any +The Studio executable also hosts headless commands (`stop`, `agent init`, `extensions …`, `cloud login|logout`, plus any `IOneWareCliModule` from installed plugins). `studio/OneWare.Studio.Desktop/Cli/StudioCli.cs` decides before Avalonia starts: if the first positional argument looks like a command name (letters, digits, `-`, `_`) and is not an existing file or folder, or `--help`/`--version` is passed, the CLI runs and the GUI is never started. Anything else (no arguments, a path or a `oneware://` URL) starts the GUI as before. +`oneware agent init` installs the built-in `oneware-cli` skill and synchronizes skills explicitly +contributed by installed extensions for external coding agents into `~/.agents/skills/`. It copies the +complete skill directory, including supporting files, and records ownership and file hashes in +`oneware-skills.json` in the OneWare documents directory. Unchanged +skills from uninstalled extensions and omitted `--modules` directories are removed on the next run. +Existing unmanaged or locally modified skills are preserved and reported as conflicts. A missing package +database prevents cleanup of package extension skills; an unreadable database or failed extension +discovery prevents synchronization. The command does not export the Studio chat's own skills or functions. + On Windows, `OneWareStudio.exe` is a GUI-subsystem app, so the publish step also creates `oneware.exe`, a copy with the console subsystem. Use it from terminals; without a command it starts `OneWareStudio.exe` detached. diff --git a/src/OneWare.Essentials/Services/IExternalAgentSkillProvider.cs b/src/OneWare.Essentials/Services/IExternalAgentSkillProvider.cs new file mode 100644 index 00000000..a2bc1bd3 --- /dev/null +++ b/src/OneWare.Essentials/Services/IExternalAgentSkillProvider.cs @@ -0,0 +1,14 @@ +namespace OneWare.Essentials.Services; + +/// +/// Provides skills for external coding agents independently of the Studio chat. +/// +/// +/// Implement this on a public, non-abstract plugin class with a public parameterless constructor. +/// Each returned discovery root contains skill directories, each with a SKILL.md and optional +/// supporting files. The directories must exist in the installed plugin. +/// +public interface IExternalAgentSkillProvider +{ + IReadOnlyCollection GetSkillDirectories(); +} diff --git a/studio/OneWare.Studio.Desktop/Cli/AgentCliModule.cs b/studio/OneWare.Studio.Desktop/Cli/AgentCliModule.cs new file mode 100644 index 00000000..b145ddde --- /dev/null +++ b/studio/OneWare.Studio.Desktop/Cli/AgentCliModule.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using OneWare.Essentials.Services; + +namespace OneWare.Studio.Desktop.Cli; + +internal sealed class AgentCliModule(CliModuleLoader loader, IPaths paths, string? homeDirectory = null) : OneWareCliModuleBase +{ + public override IReadOnlyList RegisterCommands(IServiceProvider serviceProvider) + { + var agent = new Command("agent", "Manage skills for external coding agents"); + var init = new Command("init", "Synchronize extension skills for external agents"); + init.SetAction(_ => + { + try + { + var synchronizer = new ExternalSkillSynchronizer( + paths.DocumentsDirectory, paths.PluginsDirectory, paths.PackagesDirectory, + paths.AppName, homeDirectory ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + Console.Out, Console.Error); + return synchronizer.Synchronize(loader.GetExternalSkillProviders()); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Unable to synchronize external agent skills: {ex.Message}"); + return 1; + } + }); + agent.Subcommands.Add(init); + return [agent]; + } +} + +internal sealed record ExternalSkillProviderSource( + string PluginPath, IExternalAgentSkillProvider Provider, string? BuiltInId = null, string? Version = null); diff --git a/studio/OneWare.Studio.Desktop/Cli/CliModuleLoader.cs b/studio/OneWare.Studio.Desktop/Cli/CliModuleLoader.cs index 98ef1aca..dab24f53 100644 --- a/studio/OneWare.Studio.Desktop/Cli/CliModuleLoader.cs +++ b/studio/OneWare.Studio.Desktop/Cli/CliModuleLoader.cs @@ -19,12 +19,27 @@ namespace OneWare.Studio.Desktop.Cli; internal sealed class CliModuleLoader(CliHostBuilderContext cliHostBuilder) { private readonly Dictionary _pluginAssemblies = new(StringComparer.OrdinalIgnoreCase); + private readonly List<(Assembly Assembly, string PluginPath)> _skillAssemblies = []; + private readonly List<(string Directory, string Id, string? Version, Func Create)> _builtInSkillProviders = []; + private readonly List _pluginDiscoveryErrors = []; + private bool _pluginDiscoveryComplete = true; public void RegisterBuiltInCliModules(Func> stopStudio) { cliHostBuilder.ModuleCatalog.AddModule(new StudioCliModule(stopStudio)); cliHostBuilder.ModuleCatalog.AddModule(new ExtensionStoreCliModule()); cliHostBuilder.ModuleCatalog.AddModule(new OneWareCloudCliModule()); + cliHostBuilder.ModuleCatalog.AddModule(new AgentCliModule(this, cliHostBuilder.Paths)); + RegisterBuiltInSkillProvider(); + } + + public void RegisterBuiltInSkillProvider() where T : IExternalAgentSkillProvider, new() + { + var directory = Path.GetDirectoryName(typeof(T).Assembly.Location) + ?? throw new InvalidOperationException($"Assembly for '{typeof(T).FullName}' has no directory."); + var id = typeof(T).FullName + ?? throw new InvalidOperationException("The built-in skill provider has no type name."); + _builtInSkillProviders.Add((directory, id, typeof(T).Assembly.GetName().Version?.ToString(), () => new T())); } public void LoadPluginCliModules() @@ -34,7 +49,10 @@ public void LoadPluginCliModules() try { if (!IsCompatiblePlugin(pluginPath)) + { + _pluginDiscoveryComplete = false; continue; + } var loadedModules = LoadCliModulesFromPath(pluginPath); foreach (var module in loadedModules) @@ -45,10 +63,57 @@ public void LoadPluginCliModules() } catch (Exception ex) { + _pluginDiscoveryComplete = false; + _pluginDiscoveryErrors.Add($"Could not load '{pluginPath}': {ex.Message}"); cliHostBuilder.Logger.LogWarning(ex, "Failed loading CLI plugin modules from '{PluginPath}'.", pluginPath); } } + public IReadOnlyList GetExternalSkillProviders() + { + if (!_pluginDiscoveryComplete) + throw new InvalidOperationException("Some extensions could not be loaded; skill synchronization was cancelled. " + + string.Join(" ", _pluginDiscoveryErrors)); + + var providers = _builtInSkillProviders + .Select(provider => new ExternalSkillProviderSource( + provider.Directory, provider.Create(), provider.Id, provider.Version)) + .ToList(); + foreach (var (assembly, pluginPath) in _skillAssemblies) + { + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + throw new InvalidOperationException($"Could not inspect extension assembly '{assembly.Location}'.", ex); + } + + foreach (var type in types.Where(type => type.IsPublic && !type.IsAbstract && + typeof(IExternalAgentSkillProvider).IsAssignableFrom(type))) + { + if (type.GetConstructor(Type.EmptyTypes) is null) + throw new InvalidOperationException($"Skill provider '{type.FullName}' needs a public parameterless constructor."); + IExternalAgentSkillProvider provider; + try + { + provider = (IExternalAgentSkillProvider)Activator.CreateInstance(type)!; + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Could not create skill provider '{type.FullName}' from '{pluginPath}': " + + $"{ex.InnerException?.Message ?? ex.Message}", ex); + } + providers.Add(new ExternalSkillProviderSource(pluginPath, provider)); + } + } + + return providers; + } + private bool IsCompatiblePlugin(string pluginPath) { var compatibilityFile = Path.Combine(pluginPath, "compatibility.txt"); @@ -81,6 +146,7 @@ private bool IsCompatiblePlugin(string pluginPath) private void ReportIncompatiblePlugin(string pluginPath, string reason) { var message = $"Skipping incompatible CLI plugin '{pluginPath}': {reason}"; + _pluginDiscoveryErrors.Add(message); cliHostBuilder.Logger.LogWarning("{Message}", message); } @@ -103,7 +169,13 @@ private IEnumerable GetCliPluginDirectories() foreach (var modulePath in moduleValue.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var fullPath = Path.GetFullPath(modulePath); - if (Directory.Exists(fullPath) && seen.Add(fullPath)) + if (!Directory.Exists(fullPath)) + { + _pluginDiscoveryComplete = false; + _pluginDiscoveryErrors.Add($"Module directory not found: '{fullPath}'."); + cliHostBuilder.Logger.LogWarning("CLI module directory not found: '{PluginPath}'.", fullPath); + } + else if (seen.Add(fullPath)) yield return fullPath; } } @@ -126,7 +198,8 @@ private IReadOnlyList LoadCliModulesFromFiles( var loadedAssembliesByName = AppDomain.CurrentDomain.GetAssemblies() .Select(assembly => new { Assembly = assembly, FullName = assembly.GetName().FullName }) .Where(x => !string.IsNullOrWhiteSpace(x.FullName)) - .ToDictionary(x => x.FullName!, x => x.Assembly, StringComparer.OrdinalIgnoreCase); + .GroupBy(x => x.FullName!, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First().Assembly, StringComparer.OrdinalIgnoreCase); foreach (var file in assemblyFiles) { @@ -153,6 +226,8 @@ private IReadOnlyList LoadCliModulesFromFiles( } catch (Exception ex) { + _pluginDiscoveryComplete = false; + _pluginDiscoveryErrors.Add($"Could not load assembly '{file}': {ex.Message}"); cliHostBuilder.Logger.LogWarning(ex, "Skipping CLI plugin assembly '{AssemblyFile}'.", Path.GetFileName(file)); } } @@ -167,7 +242,11 @@ private IReadOnlyList LoadCliModulesFromFiles( var added = new List(); foreach (var assembly in assemblies) + { + if (pluginPath is not null) + _skillAssemblies.Add((assembly, pluginPath)); added.AddRange(cliHostBuilder.ModuleCatalog.AddModulesFromAssembly(assembly)); + } return added; } diff --git a/studio/OneWare.Studio.Desktop/Cli/ExtensionStoreCliModule.cs b/studio/OneWare.Studio.Desktop/Cli/ExtensionStoreCliModule.cs index 0da67f8f..cec96b45 100644 --- a/studio/OneWare.Studio.Desktop/Cli/ExtensionStoreCliModule.cs +++ b/studio/OneWare.Studio.Desktop/Cli/ExtensionStoreCliModule.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using OneWare.CloudIntegration; +using OneWare.Core.Services; using OneWare.Essentials.Enums; using OneWare.Essentials.Models; using OneWare.Essentials.PackageManager; @@ -123,11 +124,28 @@ public override IReadOnlyList RegisterCommands(IServiceProvider service public static bool IsExtensionStoreCommand(IEnumerable args) { var arguments = args.ToArray(); - if (arguments.Any(arg => string.Equals(arg, "oneai", StringComparison.OrdinalIgnoreCase))) - return false; + var symbols = OneWareStartupCommandLine.CreateSymbols(); + var valueOptions = new HashSet(StringComparer.Ordinal); + foreach (var option in OneWareStartupCommandLine.CreateRootCommand(symbols).Options) + { + valueOptions.Add(option.Name); + foreach (var alias in option.Aliases) + valueOptions.Add(alias); + } - return arguments.Any(arg => string.Equals(arg, "extensions", StringComparison.OrdinalIgnoreCase) || - string.Equals(arg, "extension", StringComparison.OrdinalIgnoreCase)); + for (var i = 0; i < arguments.Length; i++) + { + var token = arguments[i]; + if (token == "--") return false; + if (token.StartsWith('-')) + { + if (valueOptions.Contains(token)) i++; + continue; + } + return string.Equals(token, "extensions", StringComparison.OrdinalIgnoreCase) || + string.Equals(token, "extension", StringComparison.OrdinalIgnoreCase); + } + return false; } private static async Task SearchAsync(IServiceProvider services, string? query, CancellationToken cancellationToken) diff --git a/studio/OneWare.Studio.Desktop/Cli/ExternalSkillSynchronizer.cs b/studio/OneWare.Studio.Desktop/Cli/ExternalSkillSynchronizer.cs new file mode 100644 index 00000000..9059837b --- /dev/null +++ b/studio/OneWare.Studio.Desktop/Cli/ExternalSkillSynchronizer.cs @@ -0,0 +1,409 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using OneWare.Essentials.PackageManager; + +namespace OneWare.Studio.Desktop.Cli; + +internal sealed class ExternalSkillSynchronizer( + string documentsDirectory, + string pluginsDirectory, + string packagesDirectory, + string appName, + string homeDirectory, + TextWriter output, + TextWriter errors) +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + WriteIndented = true + }; + + private static readonly StringComparer PathComparer = + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + + private string ManifestPath => Path.Combine(documentsDirectory, "oneware-skills.json"); + private string SkillsRoot => Path.Combine(homeDirectory, ".agents", "skills"); + + public int Synchronize(IReadOnlyList providers) + { + if (string.IsNullOrWhiteSpace(homeDirectory) || !Path.IsPathFullyQualified(homeDirectory)) + throw new InvalidOperationException("The user home directory could not be determined."); + var agentsRoot = Path.Combine(homeDirectory, ".agents"); + Directory.CreateDirectory(agentsRoot); + EnsureNotLink(agentsRoot); + var lockPath = Path.Combine(agentsRoot, ".oneware-skills.lock"); + if (File.Exists(lockPath)) EnsureNotLink(lockPath); + using var syncLock = new FileStream(lockPath, + FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + var manifest = LoadManifest(); + var packages = LoadPackages(); + var desired = DiscoverSkills(providers, packages); + if (desired.Count == 0) output.WriteLine("No external agent skills found."); + Directory.CreateDirectory(SkillsRoot); + EnsureNotLink(SkillsRoot); + + var conflicts = false; + foreach (var skill in desired.Values.OrderBy(x => x.Name, StringComparer.Ordinal)) + { + var destination = Path.Combine(SkillsRoot, skill.Name); + manifest.Skills.TryGetValue(skill.Name, out var previous); + if (previous is not null && !string.Equals(previous.Source, skill.Source, StringComparison.Ordinal)) + { + errors.WriteLine($"Skill '{skill.Name}' belongs to another extension; skipping."); + conflicts = true; + continue; + } + + if (Directory.Exists(destination) || File.Exists(destination)) + { + if (previous is null || !Directory.Exists(destination) || !Matches(destination, previous.Files)) + { + errors.WriteLine($"Skill '{skill.Name}' is not managed by OneWare or has local changes; skipping."); + conflicts = true; + continue; + } + } + else if (previous is not null) + { + errors.WriteLine($"Managed skill '{skill.Name}' is missing; skipping."); + conflicts = true; + continue; + } + + var newFiles = Snapshot(skill.Directory); + if (previous is not null && SameFiles(previous.Files, newFiles)) + { + if (previous.Version != skill.Version) + { + previous.Version = skill.Version; + SaveManifest(manifest); + } + output.WriteLine($"Unchanged: {skill.Name}"); + continue; + } + + InstallSkill(skill, destination, newFiles, manifest, previous); + output.WriteLine($"{(previous is null ? "Installed" : "Updated")}: {skill.Name}"); + } + + var cleanupBlocked = false; + foreach (var (name, entry) in manifest.Skills.ToArray()) + { + if (desired.ContainsKey(name) || entry.Source.StartsWith("builtin:", StringComparison.Ordinal)) + continue; + + if (entry.Source.StartsWith("package:", StringComparison.Ordinal)) + { + var packageId = entry.Source["package:".Length..]; + // A missing database cannot prove that a package was uninstalled. + if (packages is null) + { + cleanupBlocked = true; + continue; + } + if (packages.ContainsKey(packageId) || Directory.Exists(Path.Combine(pluginsDirectory, packageId))) + continue; + } + + var destination = Path.Combine(SkillsRoot, name); + if (Directory.Exists(destination) && Matches(destination, entry.Files)) + { + RemoveSkill(destination, name, manifest, entry); + output.WriteLine($"Removed: {name}"); + } + else + { + errors.WriteLine($"Obsolete skill '{name}' is missing or has local changes; skipping."); + conflicts = true; + } + } + + if (cleanupBlocked) + { + errors.WriteLine("Package database is missing; obsolete package extension skills were not removed."); + conflicts = true; + } + + return conflicts ? 1 : 0; + } + + private Dictionary DiscoverSkills( + IReadOnlyList providers, + IReadOnlyDictionary? packages) + { + var skills = new Dictionary(PathComparer); + var pluginRoot = Path.GetFullPath(pluginsDirectory); + foreach (var providerSource in providers) + { + var pluginPath = Path.GetFullPath(providerSource.PluginPath); + var parent = Path.GetDirectoryName(pluginPath); + var isPackage = parent is not null && PathComparer.Equals(parent, pluginRoot); + var packageId = Path.GetFileName(pluginPath); + var source = providerSource.BuiltInId is not null + ? "builtin:" + providerSource.BuiltInId + : isPackage ? "package:" + packageId : "path:" + pluginPath; + var version = providerSource.Version; + if (providerSource.BuiltInId is null && isPackage && packages is not null && + packages.TryGetValue(packageId, out var package)) + version = package.InstalledVersion; + + var roots = providerSource.Provider.GetSkillDirectories() + ?? throw new InvalidDataException($"Skill provider in '{pluginPath}' returned no directory list."); + foreach (var root in roots) + { + if (string.IsNullOrWhiteSpace(root) || !Path.IsPathFullyQualified(root) || !Directory.Exists(root)) + throw new InvalidDataException($"Skill directory '{root}' from '{pluginPath}' is missing or not an absolute path."); + EnsureNotLink(root); + foreach (var directory in Directory.EnumerateDirectories(root)) + { + EnsureNotLink(directory); + var name = Path.GetFileName(directory); + ValidateName(name); + var skillFile = Path.Combine(directory, "SKILL.md"); + if (!File.Exists(skillFile)) + throw new InvalidDataException($"Skill '{name}' has no SKILL.md."); + EnsureNotLink(skillFile); + ValidateSkillFile(skillFile, name); + if (!skills.TryAdd(name, new SkillSource(name, directory, source, version))) + throw new InvalidDataException($"Multiple extensions provide the skill '{name}'."); + } + } + } + return skills; + } + + private SkillManifest LoadManifest() + { + if (!File.Exists(ManifestPath)) + return new SkillManifest { Version = 1 }; + EnsureNotLink(ManifestPath); + var manifest = JsonSerializer.Deserialize(File.ReadAllText(ManifestPath), JsonOptions) + ?? throw new InvalidDataException("The skill manifest is empty."); + if (manifest.Version != 1 || manifest.Skills is null) + throw new InvalidDataException("The skill manifest has an unsupported format."); + var validated = new Dictionary(PathComparer); + foreach (var (name, entry) in manifest.Skills) + { + ValidateName(name); + if (entry is null || string.IsNullOrWhiteSpace(entry.Source) || entry.Files is null || + !entry.Files.ContainsKey("SKILL.md") || + !validated.TryAdd(name, entry)) + throw new InvalidDataException($"Invalid skill manifest entry '{name}'."); + if (entry.Source.StartsWith("package:", StringComparison.Ordinal)) + { + var packageId = entry.Source["package:".Length..]; + if (string.IsNullOrWhiteSpace(packageId) || packageId is "." or ".." || + packageId.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || + packageId.Contains('/') || packageId.Contains('\\')) + throw new InvalidDataException($"Invalid package ID in skill manifest entry '{name}'."); + } + else if (!entry.Source.StartsWith("path:", StringComparison.Ordinal) && + !(entry.Source.StartsWith("builtin:", StringComparison.Ordinal) && + entry.Source.Length > "builtin:".Length)) + throw new InvalidDataException($"Invalid source in skill manifest entry '{name}'."); + } + manifest.Skills = validated; + return manifest; + } + + private IReadOnlyDictionary? LoadPackages() + { + var path = Path.Combine(packagesDirectory, $"{appName.ToLowerInvariant().Replace(" ", "")}-packages.json"); + if (!File.Exists(path)) + return null; + EnsureNotLink(path); + var packages = JsonSerializer.Deserialize(File.ReadAllText(path), JsonOptions) + ?? throw new InvalidDataException("The package database is empty."); + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var package in packages) + { + if (package is null || string.IsNullOrWhiteSpace(package.Id) || !result.TryAdd(package.Id, package)) + throw new InvalidDataException("The package database contains an invalid or duplicate package ID."); + } + return result; + } + + private void InstallSkill(SkillSource skill, string destination, Dictionary files, + SkillManifest manifest, SkillEntry? previous) + { + var temporary = Path.Combine(SkillsRoot, ".oneware-" + Guid.NewGuid().ToString("N")); + var backup = Path.Combine(SkillsRoot, ".oneware-" + Guid.NewGuid().ToString("N")); + var hadPrevious = previous is not null; + var movedOld = false; + var movedNew = false; + try + { + CopyTree(skill.Directory, temporary); + if (!SameFiles(files, Snapshot(temporary))) + throw new IOException($"Skill '{skill.Name}' changed during installation."); + if (hadPrevious) + { + if (!Matches(destination, previous!.Files)) + throw new IOException($"Skill '{skill.Name}' changed during installation."); + Directory.Move(destination, backup); + movedOld = true; + } + Directory.Move(temporary, destination); + movedNew = true; + manifest.Skills[skill.Name] = new SkillEntry { Source = skill.Source, Version = skill.Version, Files = files }; + SaveManifest(manifest); + } + catch + { + if (movedNew && Directory.Exists(destination)) + Directory.Move(destination, temporary); + if (movedOld && Directory.Exists(backup)) + Directory.Move(backup, destination); + if (previous is null) manifest.Skills.Remove(skill.Name); + else manifest.Skills[skill.Name] = previous; + throw; + } + finally + { + if (Directory.Exists(temporary)) Directory.Delete(temporary, true); + } + if (movedOld) Directory.Delete(backup, true); + } + + private void RemoveSkill(string destination, string name, SkillManifest manifest, SkillEntry entry) + { + var backup = Path.Combine(SkillsRoot, ".oneware-" + Guid.NewGuid().ToString("N")); + Directory.Move(destination, backup); + try + { + if (!Matches(backup, entry.Files)) + throw new IOException($"Skill '{name}' changed during removal."); + manifest.Skills.Remove(name); + SaveManifest(manifest); + } + catch + { + manifest.Skills[name] = entry; + Directory.Move(backup, destination); + throw; + } + Directory.Delete(backup, true); + } + + private void SaveManifest(SkillManifest manifest) + { + Directory.CreateDirectory(documentsDirectory); + var temporary = Path.Combine(documentsDirectory, ".oneware-skills-" + Guid.NewGuid().ToString("N") + ".tmp"); + try + { + File.WriteAllText(temporary, JsonSerializer.Serialize(manifest, JsonOptions)); + File.Move(temporary, ManifestPath, true); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + } + + private static Dictionary Snapshot(string root) + { + var result = new Dictionary(StringComparer.Ordinal); + Collect(root, root, result); + return result; + } + + private static void Collect(string root, string directory, Dictionary result) + { + EnsureNotLink(directory); + foreach (var path in Directory.EnumerateFileSystemEntries(directory)) + { + EnsureNotLink(path); + var relative = Path.GetRelativePath(root, path).Replace('\\', '/'); + if (Directory.Exists(path)) + { + result.Add(relative + "/", "directory"); + Collect(root, path, result); + } + else + { + using var stream = File.OpenRead(path); + var hash = Convert.ToHexString(SHA256.HashData(stream)); + if (!OperatingSystem.IsWindows()) + hash += ":" + (int)File.GetUnixFileMode(path); + result.Add(relative, hash); + } + } + } + + private static void CopyTree(string source, string destination) + { + EnsureNotLink(source); + Directory.CreateDirectory(destination); + foreach (var path in Directory.EnumerateFileSystemEntries(source)) + { + EnsureNotLink(path); + var target = Path.Combine(destination, Path.GetFileName(path)); + if (Directory.Exists(path)) CopyTree(path, target); + else + { + File.Copy(path, target); + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(target, File.GetUnixFileMode(path)); + } + } + } + + private static bool Matches(string directory, Dictionary expected) => + SameFiles(expected, Snapshot(directory)); + + private static bool SameFiles(Dictionary first, Dictionary second) => + first.Count == second.Count && first.All(pair => second.TryGetValue(pair.Key, out var value) && pair.Value == value); + + private static void EnsureNotLink(string path) + { + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + throw new InvalidDataException($"Symbolic links are not supported: '{path}'."); + } + + private static void ValidateName(string name) + { + if (name.Length is < 1 or > 64 || !Regex.IsMatch(name, @"^[a-z0-9]+(?:-[a-z0-9]+)*$")) + throw new InvalidDataException($"Invalid skill name '{name}'. Use lowercase letters, digits and single hyphens."); + } + + private static void ValidateSkillFile(string path, string name) + { + var content = File.ReadAllText(path); + var match = Regex.Match(content, @"\A---\s*\r?\n(?.*?)\r?\n---(?:\s*\r?\n|\s*$)", + RegexOptions.Singleline); + if (!match.Success) + throw new InvalidDataException($"Skill '{name}' has invalid YAML front matter."); + var nameMatch = Regex.Match(match.Groups["front"].Value, + @"(?m)^name[ \t]*:[ \t]*(?:'(?[a-z0-9-]+)'|""(?[a-z0-9-]+)""|(?[a-z0-9-]+))[ \t]*(?:#.*)?\r?$"); + var descriptionMatch = Regex.Match(match.Groups["front"].Value, @"(?m)^description[ \t]*:[ \t]*\S"); + if (!nameMatch.Success || nameMatch.Groups["name"].Value != name || !descriptionMatch.Success) + throw new InvalidDataException($"Skill '{name}' needs a matching name and a description in SKILL.md."); + } + + private sealed record SkillSource(string Name, string Directory, string Source, string? Version); + + private sealed class SkillManifest + { + [JsonRequired] + public int Version { get; set; } + [JsonRequired] + public Dictionary Skills { get; set; } = new(PathComparer); + } + + private sealed class SkillEntry + { + [JsonRequired] + public string Source { get; set; } = ""; + public string? Version { get; set; } + [JsonRequired] + public Dictionary Files { get; set; } = new(StringComparer.Ordinal); + } +} diff --git a/studio/OneWare.Studio.Desktop/Cli/StudioExternalSkillProvider.cs b/studio/OneWare.Studio.Desktop/Cli/StudioExternalSkillProvider.cs new file mode 100644 index 00000000..de9d61d8 --- /dev/null +++ b/studio/OneWare.Studio.Desktop/Cli/StudioExternalSkillProvider.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.IO; +using OneWare.Essentials.Services; + +namespace OneWare.Studio.Desktop.Cli; + +internal sealed class StudioExternalSkillProvider : IExternalAgentSkillProvider +{ + public StudioExternalSkillProvider() + { + } + + public IReadOnlyCollection GetSkillDirectories() => + [Path.Combine(AppContext.BaseDirectory, "ExternalSkills")]; +} diff --git a/studio/OneWare.Studio.Desktop/ExternalSkills/oneware-cli/SKILL.md b/studio/OneWare.Studio.Desktop/ExternalSkills/oneware-cli/SKILL.md new file mode 100644 index 00000000..c18233a0 --- /dev/null +++ b/studio/OneWare.Studio.Desktop/ExternalSkills/oneware-cli/SKILL.md @@ -0,0 +1,37 @@ +--- +name: oneware-cli +description: Use the OneWare Studio command-line interface to discover commands, manage extensions, synchronize agent skills, sign in to OneWare Cloud, or stop Studio. +--- + +# OneWare Studio CLI + +OneWare Studio provides a headless CLI. Use `oneware` in a terminal; on Windows, `oneware.exe` +is the console executable. If the command is not on PATH, locate the installed executable rather +than assuming a location. Run `oneware --help` to discover the commands available in this +installation, then use `oneware --help` for current arguments and options. Extensions +can contribute additional commands, so do not assume every installation has the same commands. +Running `oneware` without a command starts the Studio GUI. + +## Built-in commands + +- `oneware extensions search [query]` searches the extension catalog. +- `oneware extensions list` lists installed extensions. +- `oneware extensions install ` installs an extension; use `--version ` when needed. +- `oneware extensions update [id]` updates one extension, or all installed extensions if no ID is given. +- `oneware extensions uninstall ` removes an extension. +- `oneware agent init` synchronizes external agent skills into `~/.agents/skills/`. +- `oneware cloud login` opens a browser to sign in; `oneware cloud logout` signs out. +- `oneware stop` stops the running Studio. + +Extension catalog operations can require network access. If the extension ID is unknown, search or list +extensions rather than guessing it. Do not bypass compatibility checks unless explicitly requested. +Ask before installing, updating or uninstalling extensions, synchronizing skills, signing in or out, +or stopping Studio unless the user already requested the action. An update without an ID affects all +installed extensions. + +`agent init` writes skills and may remove unchanged skills from uninstalled extensions or omitted +`--modules` directories. It preserves unmanaged or locally modified skills and reports conflicts. +Rerun it after changing extensions to synchronize their external skills. + +The CLI can also have extension-provided commands such as `oneware oneai`. Discover them with +`oneware --help` and inspect their help before use; they may not be installed. diff --git a/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj b/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj index ab7df4fe..3b1255c8 100644 --- a/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj +++ b/studio/OneWare.Studio.Desktop/OneWare.Studio.Desktop.csproj @@ -50,6 +50,11 @@ + + + + From 29b1cbee658ac6ee64bbbd1b6ed4d87a8cd78275 Mon Sep 17 00:00:00 2001 From: Nils-Paul Korte <192386710+NPK111@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:07:20 +0100 Subject: [PATCH 2/2] Add some Tests --- .../ExtensionStoreCliModuleTests.cs | 3 + .../ExternalSkillProviderDiscoveryTests.cs | 163 ++++++++++++ .../ExternalSkillSynchronizerTests.cs | 233 ++++++++++++++++++ .../StudioCliTests.cs | 1 + 4 files changed, 400 insertions(+) create mode 100644 tests/OneWare.Studio.Desktop.UnitTests/ExternalSkillProviderDiscoveryTests.cs create mode 100644 tests/OneWare.Studio.Desktop.UnitTests/ExternalSkillSynchronizerTests.cs diff --git a/tests/OneWare.Studio.Desktop.UnitTests/ExtensionStoreCliModuleTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/ExtensionStoreCliModuleTests.cs index bbbe357f..32570984 100644 --- a/tests/OneWare.Studio.Desktop.UnitTests/ExtensionStoreCliModuleTests.cs +++ b/tests/OneWare.Studio.Desktop.UnitTests/ExtensionStoreCliModuleTests.cs @@ -26,6 +26,9 @@ public void RegisterCommands_RegistersExtensionStoreOperations() [InlineData(new[] { "extensions", "update" }, true)] [InlineData(new[] { "extension", "uninstall", "sample" }, true)] [InlineData(new[] { "oneai", "train", "project.oneai", "extensions" }, false)] + [InlineData(new[] { "--oneware-dir", "extensions", "agent", "init" }, false)] + [InlineData(new[] { "--oneware-dir", "example", "extensions", "list" }, true)] + [InlineData(new[] { "agent", "init", "--modules", "extensions" }, false)] [InlineData(new[] { "studio", "start" }, false)] public void IsExtensionStoreCommand_IdentifiesStoreCommands(string[] args, bool expected) { diff --git a/tests/OneWare.Studio.Desktop.UnitTests/ExternalSkillProviderDiscoveryTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/ExternalSkillProviderDiscoveryTests.cs new file mode 100644 index 00000000..c5a8d781 --- /dev/null +++ b/tests/OneWare.Studio.Desktop.UnitTests/ExternalSkillProviderDiscoveryTests.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OneWare.Core.ModuleLogic; +using OneWare.Essentials.Services; +using OneWare.Studio.Desktop.Cli; +using Xunit; + +namespace OneWare.Studio.Desktop.UnitTests; + +public class ExternalSkillProviderDiscoveryTests +{ + [Fact] + public void AgentInitInstallsBuiltInCliSkillWithoutPlugins() + { + var root = Path.Combine(Path.GetTempPath(), "oneware-builtin-skill-test-" + Guid.NewGuid().ToString("N")); + try + { + var paths = new TestPaths(root); + var catalog = new OneWareCliModuleCatalog(); + var services = new ServiceCollection(); + var loader = new CliModuleLoader(new CliHostBuilderContext(services, paths, catalog, + new OneWareCliModuleManager(catalog), new TestLogger())); + loader.RegisterBuiltInCliModules(_ => Task.FromResult(0)); + var provider = Assert.Single(loader.GetExternalSkillProviders()); + Assert.IsType(provider.Provider); + + var home = Path.Combine(root, "home"); + var command = new RootCommand(); + using var serviceProvider = services.BuildServiceProvider(); + command.Subcommands.Add(Assert.Single(new AgentCliModule(loader, paths, home).RegisterCommands(serviceProvider))); + Assert.Equal(0, command.Parse(["agent", "init"]).Invoke()); + + var skill = Path.Combine(home, ".agents", "skills", "oneware-cli", "SKILL.md"); + Assert.Contains("oneware --help", File.ReadAllText(skill)); + using var manifest = JsonDocument.Parse(File.ReadAllText(Path.Combine(root, "oneware-skills.json"))); + var entry = manifest.RootElement.GetProperty("Skills").GetProperty("oneware-cli"); + Assert.StartsWith("builtin:", entry.GetProperty("Source").GetString()); + Assert.False(string.IsNullOrEmpty(entry.GetProperty("Version").GetString())); + Assert.Equal(0, command.Parse(["agent", "init"]).Invoke()); + File.AppendAllText(skill, "\nLocal changes\n"); + Assert.Equal(1, command.Parse(["agent", "init"]).Invoke()); + Assert.EndsWith("Local changes\n", File.ReadAllText(skill)); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, true); + } + } + + [Fact] + public void IncompatiblePluginMakesDiscoveryFailRatherThanReportAnIncompleteList() + { + var root = Path.Combine(Path.GetTempPath(), "oneware-provider-test-" + Guid.NewGuid().ToString("N")); + try + { + var paths = new TestPaths(root); + Directory.CreateDirectory(Path.Combine(paths.PluginsDirectory, "broken")); + var catalog = new OneWareCliModuleCatalog(); + var loader = new CliModuleLoader(new CliHostBuilderContext(new ServiceCollection(), paths, catalog, + new OneWareCliModuleManager(catalog), new TestLogger())); + + loader.LoadPluginCliModules(); + var error = Assert.Throws(() => loader.GetExternalSkillProviders()); + Assert.Contains("broken", error.Message); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, true); + } + } + + [Fact] + public void DiscoversProviderWithoutCliModule() + { + var root = Path.Combine(Path.GetTempPath(), "oneware-provider-test-" + Guid.NewGuid().ToString("N")); + try + { + var paths = new TestPaths(root); + var plugin = Path.Combine(paths.PluginsDirectory, "sample"); + Directory.CreateDirectory(plugin); + var skillRoot = Path.Combine(plugin, "ExternalSkills"); + var skill = Path.Combine(skillRoot, "example-skill"); + Directory.CreateDirectory(skill); + File.WriteAllText(Path.Combine(skill, "SKILL.md"), + "---\nname: example-skill\ndescription: Example\n---\nInstructions\n"); + ExternalSkillTestProvider.SkillRoot = skillRoot; + File.Copy(typeof(ExternalSkillTestProvider).Assembly.Location, + Path.Combine(plugin, "TestPlugin.dll")); + File.WriteAllText(Path.Combine(plugin, "compatibility.txt"), "UnavailableTestDependency: 1.0.0"); + var catalog = new OneWareCliModuleCatalog(); + var logger = new TestLogger(); + var context = new CliHostBuilderContext(new ServiceCollection(), paths, catalog, + new OneWareCliModuleManager(catalog), logger); + var loader = new CliModuleLoader(context); + + loader.LoadPluginCliModules(); + IReadOnlyList discovered; + try { discovered = loader.GetExternalSkillProviders(); } + catch (Exception ex) { throw new InvalidOperationException(logger.Messages, ex); } + var provider = Assert.Single(discovered); + + Assert.IsType(provider.Provider); + Assert.Equal(plugin, provider.PluginPath); + Assert.Empty(catalog.Modules); + + var home = Path.Combine(root, "home"); + var synchronizer = new ExternalSkillSynchronizer(paths.DocumentsDirectory, paths.PluginsDirectory, + paths.PackagesDirectory, paths.AppName, home, TextWriter.Null, TextWriter.Null); + Assert.Equal(0, synchronizer.Synchronize(discovered)); + Assert.True(File.Exists(Path.Combine(home, ".agents", "skills", "example-skill", "SKILL.md"))); + var command = new RootCommand(); + using var services = context.Services.BuildServiceProvider(); + command.Subcommands.Add(Assert.Single(new AgentCliModule(loader, paths, home).RegisterCommands(services))); + Assert.Equal(0, command.Parse(["agent", "init"]).Invoke()); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, true); + } + } + + private sealed class TestPaths(string root) : IPaths + { + public string AppName => "OneWare Studio"; + public string AppIconPath => ""; + public string AppFolderName => "OneWareStudio"; + public string AppDataDirectory => root; + public string TempDirectory => root; + public string SessionDirectory => root; + public string LayoutDirectory => root; + public string SettingsPath => Path.Combine(root, "settings.json"); + public string DocumentsDirectory => root; + public string CrashReportsDirectory => root; + public string ProjectsDirectory => root; + public string PackagesDirectory => Path.Combine(root, "Packages"); + public string NativeToolsDirectory => root; + public string OnnxRuntimesDirectory => root; + public string PluginsDirectory => Path.Combine(PackagesDirectory, "Plugins"); + public string ChangelogUrl => ""; + public string UpdateInfoUrl => ""; + } + + private sealed class TestLogger : ILogger + { + public string Messages { get; private set; } = ""; + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) => Messages += formatter(state, exception) + " " + exception; + } +} + +public sealed class ExternalSkillTestProvider : IExternalAgentSkillProvider +{ + public static string SkillRoot { get; set; } = ""; + public IReadOnlyCollection GetSkillDirectories() => [SkillRoot]; +} diff --git a/tests/OneWare.Studio.Desktop.UnitTests/ExternalSkillSynchronizerTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/ExternalSkillSynchronizerTests.cs new file mode 100644 index 00000000..e05e1a74 --- /dev/null +++ b/tests/OneWare.Studio.Desktop.UnitTests/ExternalSkillSynchronizerTests.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using OneWare.Essentials.Services; +using OneWare.Studio.Desktop.Cli; +using Xunit; + +namespace OneWare.Studio.Desktop.UnitTests; + +public class ExternalSkillSynchronizerTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "oneware-skills-test-" + Guid.NewGuid().ToString("N")); + private readonly StringWriter _output = new(); + private readonly StringWriter _errors = new(); + + private string Documents => Path.Combine(_root, "documents"); + private string Packages => Path.Combine(Documents, "Packages"); + private string Plugins => Path.Combine(Packages, "Plugins"); + private string Plugin => Path.Combine(Plugins, "sample-extension"); + private string SourceRoot => Path.Combine(Plugin, "ExternalSkills"); + private string Source => Path.Combine(SourceRoot, "sample-skill"); + private string Destination => Path.Combine(_root, "home", ".agents", "skills", "sample-skill"); + private string Manifest => Path.Combine(Documents, "oneware-skills.json"); + + public ExternalSkillSynchronizerTests() + { + Directory.CreateDirectory(Source); + File.WriteAllText(Path.Combine(Source, "SKILL.md"), + "---\nname: sample-skill\ndescription: A sample skill\n---\nInstructions\n"); + Directory.CreateDirectory(Path.Combine(Source, "references")); + File.WriteAllText(Path.Combine(Source, "references", "format.txt"), "reference"); + WritePackages("sample-extension", "1.0.0"); + } + + [Fact] + public void InstallsUpdatesAndRemovesAnUninstalledExtensionsSkill() + { + Assert.Equal(0, Sync()); + Assert.Equal("reference", File.ReadAllText(Path.Combine(Destination, "references", "format.txt"))); + using (var json = JsonDocument.Parse(File.ReadAllText(Manifest))) + { + var entry = json.RootElement.GetProperty("Skills").GetProperty("sample-skill"); + Assert.Equal("package:sample-extension", entry.GetProperty("Source").GetString()); + Assert.Equal("1.0.0", entry.GetProperty("Version").GetString()); + } + + File.WriteAllText(Path.Combine(Source, "references", "format.txt"), "updated"); + WritePackages("sample-extension", "2.0.0"); + Assert.Equal(0, Sync()); + Assert.Equal("updated", File.ReadAllText(Path.Combine(Destination, "references", "format.txt"))); + Assert.Contains("2.0.0", File.ReadAllText(Manifest)); + + Directory.Delete(Plugin, true); + WritePackages(null, null); + Assert.Equal(0, Sync([])); + Assert.False(Directory.Exists(Destination)); + Assert.DoesNotContain("sample-skill", File.ReadAllText(Manifest)); + } + + [Theory] + [InlineData("changed")] + [InlineData("added")] + [InlineData("removed")] + public void PreservesLocallyModifiedSkillsDuringUpdateAndUninstall(string change) + { + Assert.Equal(0, Sync()); + var file = Path.Combine(Destination, "references", "format.txt"); + if (change == "changed") File.WriteAllText(file, "local"); + else if (change == "added") File.WriteAllText(Path.Combine(Destination, "local.txt"), "local"); + else File.Delete(file); + + File.WriteAllText(Path.Combine(Source, "references", "format.txt"), "upstream"); + Assert.Equal(1, Sync()); + Directory.Delete(Plugin, true); + WritePackages(null, null); + Assert.Equal(1, Sync([])); + Assert.True(Directory.Exists(Destination)); + Assert.Contains("local changes", _errors.ToString()); + } + + [Fact] + public void DoesNotOverwriteUnmanagedSkills() + { + Directory.CreateDirectory(Destination); + File.WriteAllText(Path.Combine(Destination, "SKILL.md"), "user content"); + Assert.Equal(1, Sync()); + Assert.Equal("user content", File.ReadAllText(Path.Combine(Destination, "SKILL.md"))); + Assert.False(File.Exists(Manifest)); + } + + [Fact] + public void MissingOrInvalidPackageDatabaseNeverCausesCleanup() + { + Assert.Equal(0, Sync()); + Directory.Delete(Plugin, true); + var database = Path.Combine(Packages, "onewarestudio-packages.json"); + File.Delete(database); + Assert.Equal(1, Sync([])); + Assert.True(Directory.Exists(Destination)); + File.WriteAllText(database, "{invalid"); + Assert.Throws(() => Sync([])); + Assert.True(Directory.Exists(Destination)); + } + + [Fact] + public void DuplicateNamesFailBeforeWritingAnything() + { + var other = Path.Combine(_root, "another", "sample-skill"); + Directory.CreateDirectory(other); + File.Copy(Path.Combine(Source, "SKILL.md"), Path.Combine(other, "SKILL.md")); + var providers = new[] + { + new ExternalSkillProviderSource(Plugin, new Provider(SourceRoot)), + new ExternalSkillProviderSource(Path.Combine(_root, "another"), new Provider(Path.GetDirectoryName(other)!)) + }; + Assert.Throws(() => Sync(providers)); + Assert.False(Directory.Exists(Destination)); + } + + [Fact] + public void CorruptManifestDoesNotReplaceExistingSkills() + { + Assert.Equal(0, Sync()); + File.WriteAllText(Manifest, "{}"); + File.WriteAllText(Path.Combine(Source, "references", "format.txt"), "upstream"); + Assert.Throws(() => Sync()); + Assert.Equal("reference", File.ReadAllText(Path.Combine(Destination, "references", "format.txt"))); + } + + [Fact] + public void RemovesUnchangedSkillWhenManualProviderIsOmitted() + { + var providers = new[] { new ExternalSkillProviderSource(_root, new Provider(SourceRoot)) }; + Assert.Equal(0, Sync(providers)); + using (var json = JsonDocument.Parse(File.ReadAllText(Manifest))) + Assert.StartsWith("path:", json.RootElement.GetProperty("Skills") + .GetProperty("sample-skill").GetProperty("Source").GetString()); + Assert.Equal(0, Sync(providers)); + Assert.True(Directory.Exists(Destination)); + + File.Delete(Path.Combine(Packages, "onewarestudio-packages.json")); + Assert.Equal(0, Sync([])); + Assert.False(Directory.Exists(Destination)); + Assert.DoesNotContain("sample-skill", File.ReadAllText(Manifest)); + Assert.Contains("Removed: sample-skill", _output.ToString()); + } + + [Theory] + [InlineData("changed")] + [InlineData("added")] + [InlineData("removed")] + public void PreservesLocallyModifiedSkillWhenManualProviderIsOmitted(string change) + { + var providers = new[] { new ExternalSkillProviderSource(_root, new Provider(SourceRoot)) }; + Assert.Equal(0, Sync(providers)); + var file = Path.Combine(Destination, "references", "format.txt"); + if (change == "changed") File.WriteAllText(file, "local"); + else if (change == "added") File.WriteAllText(Path.Combine(Destination, "local.txt"), "local"); + else File.Delete(file); + + Assert.Equal(1, Sync([])); + Assert.True(Directory.Exists(Destination)); + Assert.Contains("sample-skill", File.ReadAllText(Manifest)); + Assert.Contains("local changes", _errors.ToString()); + } + + [Fact] + public void DoesNotRemoveBuiltInSkillWhenProviderIsOmitted() + { + var providers = new[] { new ExternalSkillProviderSource(Plugin, new Provider(SourceRoot), "studio", "1.0") }; + Assert.Equal(0, Sync(providers)); + Assert.Equal(0, Sync([])); + Assert.True(Directory.Exists(Destination)); + } + + [Fact] + public void BuiltInSkillUpdatesWhenStudioMovesToAnotherDirectory() + { + var provider = new[] { new ExternalSkillProviderSource(Plugin, new Provider(SourceRoot), "studio", "1.0") }; + Assert.Equal(0, Sync(provider)); + + var movedRoot = Path.Combine(_root, "new-install", "ExternalSkills"); + var movedSkill = Path.Combine(movedRoot, "sample-skill"); + Directory.CreateDirectory(movedSkill); + File.Copy(Path.Combine(Source, "SKILL.md"), Path.Combine(movedSkill, "SKILL.md")); + File.WriteAllText(Path.Combine(movedSkill, "new.txt"), "new version"); + var movedProvider = new[] + { + new ExternalSkillProviderSource(Path.Combine(_root, "new-install"), new Provider(movedRoot), "studio", "2.0") + }; + Assert.Equal(0, Sync(movedProvider)); + Assert.Equal("new version", File.ReadAllText(Path.Combine(Destination, "new.txt"))); + Assert.False(File.Exists(Path.Combine(Destination, "references", "format.txt"))); + Assert.Contains("2.0", File.ReadAllText(Manifest)); + } + + [Fact] + public void RejectsMismatchedSkillName() + { + File.WriteAllText(Path.Combine(Source, "SKILL.md"), "---\nname: different\ndescription: Example\n---\n"); + Assert.Throws(() => Sync()); + Assert.False(Directory.Exists(Destination)); + } + + private int Sync(IReadOnlyList? providers = null) + { + var synchronizer = new ExternalSkillSynchronizer( + Documents, Plugins, Packages, "OneWare Studio", Path.Combine(_root, "home"), _output, _errors); + return synchronizer.Synchronize(providers ?? [new ExternalSkillProviderSource(Plugin, new Provider(SourceRoot))]); + } + + private void WritePackages(string? id, string? version) + { + Directory.CreateDirectory(Packages); + var json = id is null + ? "[]" + : JsonSerializer.Serialize(new[] { new { Id = id, Type = "Plugin", Name = id, InstalledVersion = version } }); + File.WriteAllText(Path.Combine(Packages, "onewarestudio-packages.json"), json); + } + + public void Dispose() + { + if (Directory.Exists(_root)) Directory.Delete(_root, true); + _output.Dispose(); + _errors.Dispose(); + } + + private sealed class Provider(string root) : IExternalAgentSkillProvider + { + public IReadOnlyCollection GetSkillDirectories() => [root]; + } +} diff --git a/tests/OneWare.Studio.Desktop.UnitTests/StudioCliTests.cs b/tests/OneWare.Studio.Desktop.UnitTests/StudioCliTests.cs index 7cef4684..edd3f5c0 100644 --- a/tests/OneWare.Studio.Desktop.UnitTests/StudioCliTests.cs +++ b/tests/OneWare.Studio.Desktop.UnitTests/StudioCliTests.cs @@ -13,6 +13,7 @@ public class StudioCliTests [InlineData(new[] { "--oneware-dir", "extensions" }, false)] [InlineData(new[] { "--autolaunch", "action" }, false)] [InlineData(new[] { "extensions", "list" }, true)] + [InlineData(new[] { "agent", "init" }, true)] [InlineData(new[] { "--oneware-dir", "/tmp/x", "stop" }, true)] [InlineData(new[] { "-C", "/tmp", "cloud", "login" }, true)] [InlineData(new[] { "--help" }, true)]