Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions docs/PluginDevelopment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> 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
<ItemGroup>
<Content Include="ExternalSkills\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
```

`oneware agent init` copies these directories to `~/.agents/skills/` and tracks their origin and
contents in `<OneWare documents directory>/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<T>()` 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
Expand Down
11 changes: 10 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
14 changes: 14 additions & 0 deletions src/OneWare.Essentials/Services/IExternalAgentSkillProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace OneWare.Essentials.Services;

/// <summary>
/// Provides skills for external coding agents independently of the Studio chat.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public interface IExternalAgentSkillProvider
{
IReadOnlyCollection<string> GetSkillDirectories();
}
36 changes: 36 additions & 0 deletions studio/OneWare.Studio.Desktop/Cli/AgentCliModule.cs
Original file line number Diff line number Diff line change
@@ -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<Command> 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);
83 changes: 81 additions & 2 deletions studio/OneWare.Studio.Desktop/Cli/CliModuleLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,27 @@ namespace OneWare.Studio.Desktop.Cli;
internal sealed class CliModuleLoader(CliHostBuilderContext cliHostBuilder)
{
private readonly Dictionary<string, Version> _pluginAssemblies = new(StringComparer.OrdinalIgnoreCase);
private readonly List<(Assembly Assembly, string PluginPath)> _skillAssemblies = [];
private readonly List<(string Directory, string Id, string? Version, Func<IExternalAgentSkillProvider> Create)> _builtInSkillProviders = [];
private readonly List<string> _pluginDiscoveryErrors = [];
private bool _pluginDiscoveryComplete = true;

public void RegisterBuiltInCliModules(Func<CancellationToken, Task<int>> 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<StudioExternalSkillProvider>();
}

public void RegisterBuiltInSkillProvider<T>() 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()
Expand All @@ -34,7 +49,10 @@ public void LoadPluginCliModules()
try
{
if (!IsCompatiblePlugin(pluginPath))
{
_pluginDiscoveryComplete = false;
continue;
}

var loadedModules = LoadCliModulesFromPath(pluginPath);
foreach (var module in loadedModules)
Expand All @@ -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<ExternalSkillProviderSource> 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");
Expand Down Expand Up @@ -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);
}

Expand All @@ -103,7 +169,13 @@ private IEnumerable<string> 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;
}
}
Expand All @@ -126,7 +198,8 @@ private IReadOnlyList<IOneWareCliModule> 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)
{
Expand All @@ -153,6 +226,8 @@ private IReadOnlyList<IOneWareCliModule> 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));
}
}
Expand All @@ -167,7 +242,11 @@ private IReadOnlyList<IOneWareCliModule> LoadCliModulesFromFiles(

var added = new List<IOneWareCliModule>();
foreach (var assembly in assemblies)
{
if (pluginPath is not null)
_skillAssemblies.Add((assembly, pluginPath));
added.AddRange(cliHostBuilder.ModuleCatalog.AddModulesFromAssembly(assembly));
}

return added;
}
Expand Down
26 changes: 22 additions & 4 deletions studio/OneWare.Studio.Desktop/Cli/ExtensionStoreCliModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -123,11 +124,28 @@ public override IReadOnlyList<Command> RegisterCommands(IServiceProvider service
public static bool IsExtensionStoreCommand(IEnumerable<string> 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<string>(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<int> SearchAsync(IServiceProvider services, string? query, CancellationToken cancellationToken)
Expand Down
Loading
Loading