diff --git a/lib/ourocode/plugin/user_level/capability.ex b/lib/ourocode/plugin/user_level/capability.ex new file mode 100644 index 0000000..f157e82 --- /dev/null +++ b/lib/ourocode/plugin/user_level/capability.ex @@ -0,0 +1,124 @@ +defmodule Ourocode.Plugin.UserLevel.Capability do + @moduledoc """ + Normalized identity and command surface for one installed Ouroboros + UserLevel plugin. + + `ourocode` only consumes this struct. Ouroboros remains the source of truth + for installation, trust, and execution; this module describes what was + discovered so the runtime can route, preflight, and render UserLevel plugin + commands without guessing. + """ + + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + + @enforce_keys [:plugin_id, :source] + defstruct plugin_id: nil, + plugin_name: nil, + source: nil, + version: nil, + install_scope: :unknown, + trust_scope: [], + manifest_digest: nil, + commands: [], + discovered_at: nil, + resolution_origin: %{} + + @type install_scope :: :user | :workspace | :unknown + @type trust_scope :: String.t() + @type source :: :ouroboros_cli | :ouroboros_mcp | :fixture + + @type t :: %__MODULE__{ + plugin_id: String.t(), + plugin_name: String.t() | nil, + source: source(), + version: String.t() | nil, + install_scope: install_scope(), + trust_scope: [trust_scope()], + manifest_digest: String.t() | nil, + commands: [CommandCapability.t()], + discovered_at: DateTime.t() | nil, + resolution_origin: map() + } + + @valid_sources [:ouroboros_cli, :ouroboros_mcp, :fixture] + @valid_scopes [:user, :workspace, :unknown] + + @doc """ + Builds a `Capability` from a normalized descriptor produced by a discovery + adapter. + + Required fields: + * `plugin_id` (non-empty string) + * `source` (atom in `#{inspect(@valid_sources)}`) + + Invalid command descriptors are dropped silently so a single bad command + does not lose the whole plugin. Top-level shape violations return + `{:error, :invalid_capability_attrs}`. + """ + @spec new(map()) :: {:ok, t()} | {:error, :invalid_capability_attrs} + def new(%{plugin_id: id, source: source} = attrs) + when is_binary(id) and id != "" and source in @valid_sources do + commands = + attrs + |> Map.get(:commands, []) + |> List.wrap() + |> Enum.flat_map(fn descriptor -> + case CommandCapability.new(descriptor) do + {:ok, command} -> [command] + {:error, _reason} -> [] + end + end) + + install_scope = Map.get(attrs, :install_scope, :unknown) + install_scope = if install_scope in @valid_scopes, do: install_scope, else: :unknown + + {:ok, + %__MODULE__{ + plugin_id: id, + plugin_name: Map.get(attrs, :plugin_name) || id, + source: source, + version: Map.get(attrs, :version), + install_scope: install_scope, + trust_scope: normalize_scopes(Map.get(attrs, :trust_scope, [])), + manifest_digest: Map.get(attrs, :manifest_digest), + commands: commands, + discovered_at: Map.get(attrs, :discovered_at) || DateTime.utc_now(), + resolution_origin: Map.get(attrs, :resolution_origin, %{}) + }} + end + + def new(_attrs), do: {:error, :invalid_capability_attrs} + + @doc """ + Canonical identity tuple used for cache equality and identity stability. + + Two capabilities with the same `{plugin_id, version, manifest_digest}` are + considered the same artifact even across re-discoveries. + """ + @spec identity(t()) :: {String.t(), String.t() | nil, String.t() | nil} + def identity(%__MODULE__{plugin_id: id, version: version, manifest_digest: digest}) do + {id, version, digest} + end + + @doc """ + Finds a command on the capability by canonical name or alias. + + Returns `nil` when neither matches; callers should treat that as + `:unknown` rather than guessing. + """ + @spec find_command(t(), String.t()) :: CommandCapability.t() | nil + def find_command(%__MODULE__{commands: commands}, token) when is_binary(token) do + Enum.find(commands, fn cmd -> + cmd.name == token or token in cmd.aliases + end) + end + + defp normalize_scopes(scopes) do + scopes + |> List.wrap() + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end +end diff --git a/lib/ourocode/plugin/user_level/capability/command.ex b/lib/ourocode/plugin/user_level/capability/command.ex new file mode 100644 index 0000000..28aef96 --- /dev/null +++ b/lib/ourocode/plugin/user_level/capability/command.ex @@ -0,0 +1,129 @@ +defmodule Ourocode.Plugin.UserLevel.Capability.Command do + @moduledoc """ + Per-command capability metadata declared by an installed UserLevel plugin. + + This struct is read-only and never owns trust state, execution, or storage + paths. Trust and execution live in Ouroboros; storage paths are derived from + `expected_artifacts` glob declarations the plugin itself publishes. + """ + + @enforce_keys [:name] + defstruct name: nil, + aliases: [], + summary: nil, + args: [], + risk_class: :unknown, + expected_artifacts: [], + continuation_hint: :none + + @type risk_class :: :read_only | :handoff_producing | :destructive | :unknown + @type continuation_hint :: :none | :suggest_run | :auto_run_when_requested + @type arg :: %{ + required(:name) => String.t(), + required(:required?) => boolean(), + required(:repeatable?) => boolean(), + required(:description) => String.t() + } + + @type t :: %__MODULE__{ + name: String.t(), + aliases: [String.t()], + summary: String.t() | nil, + args: [arg()], + risk_class: risk_class(), + expected_artifacts: [String.t()], + continuation_hint: continuation_hint() + } + + @doc """ + Builds a `Command` capability from a normalized descriptor. + + Returns `{:error, :invalid_command_attrs}` when `name` is missing or blank + so the registry can drop the descriptor without aborting discovery of the + surrounding plugin. + """ + @spec new(map()) :: {:ok, t()} | {:error, :invalid_command_attrs} + def new(%{name: name} = attrs) when is_binary(name) and name != "" do + {:ok, + %__MODULE__{ + name: name, + aliases: normalize_aliases(Map.get(attrs, :aliases, [])), + summary: normalize_summary(Map.get(attrs, :summary)), + args: normalize_args(Map.get(attrs, :args, [])), + risk_class: normalize_risk(Map.get(attrs, :risk_class, :unknown)), + expected_artifacts: normalize_artifacts(Map.get(attrs, :expected_artifacts, [])), + continuation_hint: normalize_continuation(Map.get(attrs, :continuation_hint, :none)) + }} + end + + def new(_attrs), do: {:error, :invalid_command_attrs} + + defp normalize_aliases(aliases) do + aliases + |> List.wrap() + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> Enum.uniq() + end + + defp normalize_summary(nil), do: nil + defp normalize_summary(value) when is_binary(value), do: value + defp normalize_summary(_other), do: nil + + defp normalize_args(args) do + args + |> List.wrap() + |> Enum.map(&normalize_arg/1) + |> Enum.reject(&(&1.name == "")) + end + + defp normalize_arg(%{name: name} = attrs) when is_binary(name) do + %{ + name: name, + required?: truthy?(Map.get(attrs, :required?, Map.get(attrs, :required, false))), + repeatable?: truthy?(Map.get(attrs, :repeatable?, Map.get(attrs, :repeatable, false))), + description: to_description(Map.get(attrs, :description, "")) + } + end + + defp normalize_arg(name) when is_binary(name) do + %{name: name, required?: false, repeatable?: false, description: ""} + end + + defp normalize_arg(_other), + do: %{name: "", required?: false, repeatable?: false, description: ""} + + defp normalize_artifacts(artifacts) do + artifacts + |> List.wrap() + |> Enum.filter(&is_binary/1) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp normalize_risk(value) + when value in [:read_only, :handoff_producing, :destructive, :unknown], + do: value + + defp normalize_risk("read_only"), do: :read_only + defp normalize_risk("handoff_producing"), do: :handoff_producing + defp normalize_risk("destructive"), do: :destructive + defp normalize_risk(_other), do: :unknown + + defp normalize_continuation(value) + when value in [:none, :suggest_run, :auto_run_when_requested], + do: value + + defp normalize_continuation("none"), do: :none + defp normalize_continuation("suggest_run"), do: :suggest_run + defp normalize_continuation("auto_run_when_requested"), do: :auto_run_when_requested + defp normalize_continuation(_other), do: :none + + defp to_description(value) when is_binary(value), do: value + defp to_description(_other), do: "" + + defp truthy?(true), do: true + defp truthy?("true"), do: true + defp truthy?(_other), do: false +end diff --git a/lib/ourocode/plugin/user_level/discovery.ex b/lib/ourocode/plugin/user_level/discovery.ex new file mode 100644 index 0000000..79b2de8 --- /dev/null +++ b/lib/ourocode/plugin/user_level/discovery.ex @@ -0,0 +1,50 @@ +defmodule Ourocode.Plugin.UserLevel.Discovery do + @moduledoc """ + Behaviour for discovering installed Ouroboros UserLevel plugins. + + Discovery adapters are read-only: they ask Ouroboros which plugins are + installed and return one descriptor per plugin. They must never install, + trust, escalate, or execute plugin code. Caching, freshness, and identity + stability live in `Ourocode.Plugin.UserLevel.Registry`, not in the adapter. + + The behaviour is transport-neutral: a CLI adapter and an MCP adapter can + both satisfy it, and the registry treats them interchangeably. + """ + + alias Ourocode.Plugin.UserLevel.Capability + + @type raw_descriptor :: map() + @type discovery_options :: keyword() | map() + @type discovery_result :: + {:ok, [raw_descriptor()]} + | {:error, atom() | {atom(), term()}} + + @callback discover(discovery_options()) :: discovery_result() + + @doc """ + Runs an adapter and normalizes raw descriptors into `Capability` structs. + + Per-descriptor validation failures are reported separately so the registry + can keep the good capabilities while logging the bad ones. Adapter-level + failures bubble up unchanged. + """ + @spec run(module(), discovery_options()) :: + {:ok, [Capability.t()], [{:invalid_descriptor, term()}]} + | {:error, term()} + def run(adapter, opts \\ []) when is_atom(adapter) do + with {:ok, descriptors} <- adapter.discover(opts) do + {capabilities, errors} = + Enum.reduce(descriptors, {[], []}, fn descriptor, {ok_acc, err_acc} -> + case Capability.new(descriptor) do + {:ok, capability} -> + {[capability | ok_acc], err_acc} + + {:error, reason} -> + {ok_acc, [{:invalid_descriptor, {reason, descriptor}} | err_acc]} + end + end) + + {:ok, Enum.reverse(capabilities), Enum.reverse(errors)} + end + end +end diff --git a/lib/ourocode/plugin/user_level/discovery/ouroboros_cli.ex b/lib/ourocode/plugin/user_level/discovery/ouroboros_cli.ex new file mode 100644 index 0000000..c79d61f --- /dev/null +++ b/lib/ourocode/plugin/user_level/discovery/ouroboros_cli.ex @@ -0,0 +1,170 @@ +defmodule Ourocode.Plugin.UserLevel.Discovery.OuroborosCLI do + @moduledoc """ + Discovers installed UserLevel plugins by invoking + `ouroboros plugin list --json`. + + This adapter is the first-class discovery surface until a dedicated MCP + plugin-list tool exists. It is read-only: it never installs, trusts, or + executes plugin code. + + Tests inject a stub runner via the `:command_runner` option so that no + external process is spawned. The runner contract is + `runner.(command, args, opts) :: {:ok, %{status: integer, stdout: binary, + stderr: binary}} | {:error, term()}`. + """ + + @behaviour Ourocode.Plugin.UserLevel.Discovery + + alias Ourocode.Json + + @default_command "ouroboros" + @default_args ["plugin", "list", "--json"] + @default_timeout_ms 5_000 + + @impl true + @spec discover(keyword() | map()) :: {:ok, [map()]} | {:error, term()} + def discover(opts \\ []) do + opts = if is_map(opts), do: opts, else: Map.new(opts) + command = Map.get(opts, :command, @default_command) + args = Map.get(opts, :args, @default_args) + runner = Map.get(opts, :command_runner, &default_runner/3) + timeout_ms = Map.get(opts, :timeout_ms, @default_timeout_ms) + + case runner.(command, args, %{timeout_ms: timeout_ms}) do + {:ok, %{status: 0, stdout: stdout}} -> + parse(stdout) + + {:ok, %{status: status} = result} when status != 0 -> + {:error, + {:ouroboros_cli_failed, + %{exit_status: status, stderr: Map.get(result, :stderr, "")}}} + + {:ok, other} -> + {:error, {:ouroboros_cli_unexpected_result, other}} + + {:error, reason} -> + {:error, {:ouroboros_cli_unavailable, reason}} + end + end + + @doc """ + Parses an `ouroboros plugin list --json` payload into the descriptor shape + expected by `Ourocode.Plugin.UserLevel.Capability.new/1`. + + Public so tests can validate the parser without going through the runner + indirection. + """ + @spec parse(binary()) :: {:ok, [map()]} | {:error, term()} + def parse(stdout) when is_binary(stdout) do + case Json.decode(stdout) do + {:ok, %{"plugins" => plugins}} when is_list(plugins) -> + {:ok, Enum.map(plugins, &normalize_plugin/1)} + + {:ok, plugins} when is_list(plugins) -> + {:ok, Enum.map(plugins, &normalize_plugin/1)} + + {:ok, _other} -> + {:error, :ouroboros_cli_unexpected_shape} + + {:error, reason} -> + {:error, {:ouroboros_cli_invalid_json, reason}} + end + end + + defp normalize_plugin(plugin) when is_map(plugin) do + %{ + plugin_id: read(plugin, ["id", "plugin_id", "name"]), + plugin_name: read(plugin, ["name", "display_name", "id"]), + source: :ouroboros_cli, + version: read(plugin, ["version"]), + install_scope: normalize_scope(read(plugin, ["install_scope", "scope"])), + trust_scope: + plugin + |> read(["trust_scope", "trust_scopes"], []) + |> List.wrap() + |> Enum.filter(&is_binary/1), + manifest_digest: read(plugin, ["manifest_digest", "digest"]), + commands: + plugin + |> read(["commands"], []) + |> List.wrap() + |> Enum.map(&normalize_command/1), + resolution_origin: %{ + adapter: __MODULE__, + call: %{command: @default_command, args: @default_args} + } + } + end + + defp normalize_plugin(_other), do: %{plugin_id: nil, source: :ouroboros_cli} + + defp normalize_command(cmd) when is_map(cmd) do + %{ + name: read(cmd, ["name", "command"]) || "", + aliases: cmd |> read(["aliases"], []) |> List.wrap() |> Enum.filter(&is_binary/1), + summary: read(cmd, ["summary", "description"]), + args: + cmd + |> read(["args", "arguments"], []) + |> List.wrap() + |> Enum.map(&normalize_arg/1), + risk_class: read(cmd, ["risk_class", "risk"]), + expected_artifacts: + cmd + |> read(["expected_artifacts", "artifacts"], []) + |> List.wrap() + |> Enum.filter(&is_binary/1), + continuation_hint: read(cmd, ["continuation_hint", "continuation"]) + } + end + + defp normalize_command(_other), do: %{name: ""} + + defp normalize_arg(arg) when is_map(arg) do + %{ + name: read(arg, ["name", "arg"]) || "", + required?: truthy?(read(arg, ["required", "required?"])), + repeatable?: truthy?(read(arg, ["repeatable", "repeatable?"])), + description: to_string_safe(read(arg, ["description", "summary"])) + } + end + + defp normalize_arg(arg) when is_binary(arg), + do: %{name: arg, required?: false, repeatable?: false, description: ""} + + defp normalize_arg(_other), + do: %{name: "", required?: false, repeatable?: false, description: ""} + + defp normalize_scope("user"), do: :user + defp normalize_scope("workspace"), do: :workspace + defp normalize_scope("project"), do: :workspace + defp normalize_scope(_other), do: :unknown + + defp read(map, keys, default \\ nil) when is_map(map) and is_list(keys) do + Enum.find_value(keys, default, fn key -> + case Map.get(map, key) do + nil -> nil + "" -> nil + value -> value + end + end) + end + + defp to_string_safe(nil), do: "" + defp to_string_safe(value) when is_binary(value), do: value + defp to_string_safe(value), do: to_string(value) + + defp truthy?(true), do: true + defp truthy?("true"), do: true + defp truthy?(_other), do: false + + defp default_runner(command, args, _opts) when is_binary(command) and is_list(args) do + case System.cmd(command, args, stderr_to_stdout: false) do + {output, 0} -> {:ok, %{status: 0, stdout: output, stderr: ""}} + {output, status} -> {:ok, %{status: status, stdout: "", stderr: output}} + end + rescue + error in [ErlangError, File.Error, System.EnvError] -> + {:error, {:command_runner_raised, Exception.message(error)}} + end +end diff --git a/lib/ourocode/plugin/user_level/preflight_result.ex b/lib/ourocode/plugin/user_level/preflight_result.ex new file mode 100644 index 0000000..0864f82 --- /dev/null +++ b/lib/ourocode/plugin/user_level/preflight_result.ex @@ -0,0 +1,72 @@ +defmodule Ourocode.Plugin.UserLevel.PreflightResult do + @moduledoc """ + Read-only resolution result for an `ooo ...`-shaped + prompt. + + A `PreflightResult` records *what would happen* if dispatch proceeded: + which UserLevel plugin and command were matched, which arguments were + parsed, what trust state applies, what artifacts the command is expected + to produce, and which continuation policy governs follow-up workflows. + + The result itself never executes anything. The dispatch adapter consumes + the result; the TUI renders it; the journal records it. + """ + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + + @type kind :: + :unique_match + | :ambiguous + | :unknown + | :not_applicable + + @type trust_state :: :allowed | :missing | :unknown + @type continuation_policy :: :none | :suggest | :auto_when_requested + @type confidence :: :exact | :alias | :none + + @type match_explanation :: %{ + required(:matched_by) => :canonical | :alias | nil, + required(:confidence) => confidence(), + optional(:reason) => atom() + } + + @type t :: %__MODULE__{ + kind: kind(), + task_input: String.t(), + plugin: Capability.t() | nil, + command: CommandCapability.t() | nil, + args: [String.t()], + trust_state: trust_state(), + remediation: String.t() | nil, + risk_class: CommandCapability.risk_class(), + expected_artifacts: [String.t()], + continuation_policy: continuation_policy(), + candidates: [Capability.t()], + match_explanation: match_explanation(), + reason: atom() | nil + } + + defstruct kind: :unknown, + task_input: "", + plugin: nil, + command: nil, + args: [], + trust_state: :unknown, + remediation: nil, + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: [], + match_explanation: %{matched_by: nil, confidence: :none}, + reason: nil + + @doc """ + Convenience constructor used by the resolver to ensure default fields stay + consistent across kinds. + """ + @spec new(keyword()) :: t() + def new(fields) when is_list(fields) do + struct(__MODULE__, fields) + end +end diff --git a/lib/ourocode/plugin/user_level/registry.ex b/lib/ourocode/plugin/user_level/registry.ex new file mode 100644 index 0000000..af4ac6a --- /dev/null +++ b/lib/ourocode/plugin/user_level/registry.ex @@ -0,0 +1,189 @@ +defmodule Ourocode.Plugin.UserLevel.Registry do + @moduledoc """ + In-memory cache of installed Ouroboros UserLevel plugin capabilities. + + The registry is a small Agent that keeps the most recent discovery + snapshot. Lookups are O(N) over a tiny N; the registry exists for + freshness and identity stability, not for high-throughput access. + + Freshness: + * `list/2` returns the cached snapshot, refreshing when it is older than + `:max_age_ms` (default 60 s). + * `refresh/2` is explicit (used by a `/plugins refresh` slash command + and by the plugin config watcher signal handler). + * Discovery failures degrade the snapshot to `:degraded` while keeping + the last good capability list. Boot is never blocked. + + Identity stability: + * Capabilities are deduplicated by + `Ourocode.Plugin.UserLevel.Capability.identity/1`. A re-discovery + without manifest changes returns the same struct instance, so + downstream caches (preflight, panel, journal) do not churn. + """ + + use Agent + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Discovery + alias Ourocode.Plugin.UserLevel.Discovery.OuroborosCLI + + @default_ttl_ms 60_000 + @default_adapter OuroborosCLI + + @type status :: :ready | :degraded | :empty + @type snapshot :: %{ + required(:status) => status(), + required(:capabilities) => [Capability.t()], + required(:errors) => [term()], + required(:refreshed_at) => DateTime.t() | nil, + required(:adapter) => module() + } + + @doc """ + Starts the registry agent. + + Options: + * `:name` — registered process name (defaults to `__MODULE__`). + * `:adapter` — discovery adapter module (defaults to `OuroborosCLI`). + * `:adapter_options` — passed verbatim to `adapter.discover/1`. + * `:eager?` — when `true`, runs an initial discovery synchronously. + Defaults to `false` so boot stays fast and offline-safe. + """ + @spec start_link(keyword()) :: Agent.on_start() + def start_link(opts \\ []) do + name = Keyword.get(opts, :name, __MODULE__) + adapter = Keyword.get(opts, :adapter, @default_adapter) + adapter_options = Keyword.get(opts, :adapter_options, []) + eager? = Keyword.get(opts, :eager?, false) + + initial = %{ + status: :empty, + capabilities: [], + errors: [], + refreshed_at: nil, + adapter: adapter, + adapter_options: adapter_options + } + + case Agent.start_link(fn -> initial end, name: name) do + {:ok, pid} -> + if eager?, do: _ = refresh(name) + {:ok, pid} + + other -> + other + end + end + + @doc """ + Returns the current snapshot, refreshing when older than `:max_age_ms`. + + Options: + * `:max_age_ms` — TTL for the cached snapshot (defaults to + `#{@default_ttl_ms}` ms). Pass `nil` to never auto-refresh — the + cached snapshot is returned unchanged even when it is still empty, + so callers can inspect the initial state without triggering + discovery. To force discovery on first read, pass `max_age_ms: 0` + or call `refresh/2` explicitly. + """ + @spec list(GenServer.server(), keyword()) :: snapshot() + def list(server \\ __MODULE__, opts \\ []) do + max_age_ms = Keyword.get(opts, :max_age_ms, @default_ttl_ms) + snapshot = Agent.get(server, & &1) + + if stale?(snapshot, max_age_ms) do + refresh(server) + else + project(snapshot) + end + end + + @doc """ + Forces re-discovery through the configured adapter and updates the cache. + + Discovery failures preserve the previous capability list and surface as a + `:degraded` snapshot with the error attached. Successful runs reset the + error list and update `refreshed_at`. + """ + @spec refresh(GenServer.server(), keyword()) :: snapshot() + def refresh(server \\ __MODULE__, opts \\ []) do + Agent.get_and_update(server, fn current -> + adapter = Keyword.get(opts, :adapter, current.adapter) + adapter_options = Keyword.get(opts, :adapter_options, current.adapter_options) + + next = run_discovery(current, adapter, adapter_options) + + new_state = + current + |> Map.put(:adapter, adapter) + |> Map.put(:adapter_options, adapter_options) + |> Map.merge(next) + + {project(new_state), new_state} + end) + end + + @doc """ + Looks up a capability by plugin id from the cached snapshot. + + Does not trigger discovery. Callers that need freshness should call + `list/2` first. + """ + @spec fetch(GenServer.server(), String.t()) :: {:ok, Capability.t()} | :error + def fetch(server \\ __MODULE__, plugin_id) when is_binary(plugin_id) do + snapshot = Agent.get(server, & &1) + + case Enum.find(snapshot.capabilities, &(&1.plugin_id == plugin_id)) do + nil -> :error + capability -> {:ok, capability} + end + end + + # `max_age_ms: nil` takes precedence over an empty cache so callers can + # inspect the initial state without triggering discovery. + defp stale?(_snapshot, nil), do: false + defp stale?(%{refreshed_at: nil}, _max_age_ms), do: true + + defp stale?(%{refreshed_at: refreshed_at}, max_age_ms) do + DateTime.diff(DateTime.utc_now(), refreshed_at, :millisecond) > max_age_ms + end + + defp run_discovery(current, adapter, adapter_options) do + case Discovery.run(adapter, adapter_options) do + {:ok, capabilities, descriptor_errors} -> + merged = preserve_identity(current.capabilities, capabilities) + + %{ + status: status_for(merged, descriptor_errors), + capabilities: merged, + errors: descriptor_errors, + refreshed_at: DateTime.utc_now(), + adapter: adapter + } + + {:error, reason} -> + %{ + status: :degraded, + capabilities: current.capabilities, + errors: [{:discovery_failed, reason} | current.errors], + refreshed_at: DateTime.utc_now(), + adapter: adapter + } + end + end + + defp status_for([], []), do: :empty + defp status_for(_capabilities, _errors), do: :ready + + defp preserve_identity(previous, fresh) do + index = Map.new(previous, fn cap -> {Capability.identity(cap), cap} end) + + Enum.map(fresh, fn cap -> + Map.get(index, Capability.identity(cap), cap) + end) + end + + defp project(snapshot) do + Map.take(snapshot, [:status, :capabilities, :errors, :refreshed_at, :adapter]) + end +end diff --git a/lib/ourocode/plugin/user_level/registry_entry.ex b/lib/ourocode/plugin/user_level/registry_entry.ex new file mode 100644 index 0000000..77f7f0d --- /dev/null +++ b/lib/ourocode/plugin/user_level/registry_entry.ex @@ -0,0 +1,128 @@ +defmodule Ourocode.Plugin.UserLevel.RegistryEntry do + @moduledoc """ + Projects `Ourocode.Plugin.UserLevel.Capability` into the normalized + command_entry shape consumed by `Ourocode.Command.Registry`. + + This bridge lets the existing slash command surface and + `Ourocode.Command.CapabilityPreflight` see UserLevel plugins as first-class + registry entries without duplicating projection logic. The metadata shape + intentionally mirrors `Ourocode.Command.Registry.PluginSurfaceEntry` so + `CapabilityPreflight.Trust` and `CapabilityPreflight.Projection` work + unchanged. + + Trust defaults are conservative: the registry assumes `requires_explicit_approval` + unless the discovered capability declares trust scopes. Granting trust + remains an Ouroboros responsibility; `ourocode` only surfaces what was + reported. + """ + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + + @doc """ + Returns a list of registry-shaped maps suitable for + `Ourocode.Command.Registry.merge_normalized_entries/2`. + + One entry per command capability is produced. + """ + @spec entries([Capability.t()] | Capability.t()) :: [map()] + def entries(capabilities) when is_list(capabilities) do + Enum.flat_map(capabilities, &entries/1) + end + + def entries(%Capability{} = capability) do + Enum.map(capability.commands, &entry(capability, &1)) + end + + defp entry(%Capability{} = capability, %CommandCapability{} = command) do + slash = "/" <> capability.plugin_id <> " " <> command.name + + aliases = + Enum.map(command.aliases, fn alias_name -> + "/" <> capability.plugin_id <> " " <> alias_name + end) + + args = + Enum.map(command.args, fn arg -> + %{ + name: Map.get(arg, :name, ""), + required?: Map.get(arg, :required?, false), + description: Map.get(arg, :description, "") + } + end) + + %{ + id: "user_level_plugin:#{capability.plugin_id}:#{command.name}", + name: "#{capability.plugin_id} #{command.name}", + slash: slash, + source: :plugin, + source_id: capability.plugin_id, + source_attribution: source_attribution(capability), + type: :slash_command, + category: :plugins, + summary: command.summary || "", + aliases: aliases, + args: args, + availability: :available, + runnable?: true, + run_spec: %{ + kind: :user_level_plugin_command, + plugin_id: capability.plugin_id, + command: command.name, + risk_class: command.risk_class, + expected_artifacts: command.expected_artifacts, + continuation_hint: command.continuation_hint + }, + metadata: %{ + plugin_id: capability.plugin_id, + plugin_source: capability.source, + plugin_surface: :user_level, + command_namespace: capability.plugin_id, + namespace_owner: :ouroboros, + trust_policy: trust_policy(capability), + trust_evaluation: trust_evaluation(capability), + trust_policy_state: nil, + expected_outputs: command.expected_artifacts, + risk_class: command.risk_class, + capability_version: capability.version, + manifest_digest: capability.manifest_digest + } + } + end + + defp source_attribution(%Capability{} = capability) do + %{ + source: :plugin, + source_id: capability.plugin_id, + plugin_id: capability.plugin_id, + plugin_source: capability.source, + plugin_surface: :user_level, + command_namespace: capability.plugin_id, + namespace_owner: :ouroboros, + capability_version: capability.version, + manifest_digest: capability.manifest_digest + } + end + + defp trust_policy(%Capability{trust_scope: scopes}) when scopes != [] do + %{ + "tier" => "user_level", + "requires_explicit_approval" => false, + "trust_scopes" => scopes + } + end + + defp trust_policy(_capability) do + %{ + "tier" => "user_level", + "requires_explicit_approval" => true + } + end + + defp trust_evaluation(%Capability{trust_scope: scopes}) do + %{ + "trusted" => scopes != [], + "trust_scopes" => scopes + } + end +end diff --git a/lib/ourocode/plugin/user_level/resolver.ex b/lib/ourocode/plugin/user_level/resolver.ex new file mode 100644 index 0000000..ead1b5f --- /dev/null +++ b/lib/ourocode/plugin/user_level/resolver.ex @@ -0,0 +1,263 @@ +defmodule Ourocode.Plugin.UserLevel.Resolver do + @moduledoc """ + Pure resolver from `ooo [args ...]`-shaped input to a + `Ourocode.Plugin.UserLevel.PreflightResult`. + + The resolver is intentionally narrow: + + * Direct command form only — the first token must be `ooo` or + `ouroboros`. Free-form natural language is deferred until the exact + path is stable. + * Exact match only on plugin id and command name/alias. Fuzzy matching + is explicitly out of scope; ambiguity surfaces as `:ambiguous` with + candidate plugins rather than as a guess. + * No execution, no trust mutation. The resolver only describes what a + dispatch step *would* do. + + Trust mapping: + + * A capability whose `trust_scope` is non-empty is considered + `:allowed`. The Ouroboros plugin list is the source of truth — if + Ouroboros declares scopes, the user has granted them. + * A capability whose `trust_scope` is empty surfaces as `:missing` + with a remediation string suggesting `ouroboros plugin trust ...`. + Granting trust remains an Ouroboros responsibility. + """ + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + alias Ourocode.Plugin.UserLevel.PreflightResult + + @ooo_prefixes ["ooo", "ouroboros"] + + @doc """ + Resolves `task_input` against the given capability list. + + Returns a `PreflightResult`. The resolver never raises and never mutates + capabilities; callers can pass a registry snapshot directly. + """ + @spec resolve(String.t(), [Capability.t()]) :: PreflightResult.t() + def resolve(task_input, capabilities) when is_binary(task_input) and is_list(capabilities) do + trimmed = String.trim(task_input) + + case tokenize(trimmed) do + [prefix, plugin_token | rest] -> + if ooo_prefix?(prefix) do + resolve_plugin(trimmed, plugin_token, rest, capabilities) + else + not_applicable(trimmed) + end + + [prefix] -> + if ooo_prefix?(prefix) do + unknown(trimmed, :missing_plugin_token) + else + not_applicable(trimmed) + end + + [] -> + not_applicable(trimmed) + end + end + + def resolve(_task_input, _capabilities), do: not_applicable("") + + defp resolve_plugin(input, plugin_token, rest, capabilities) do + normalized = String.downcase(plugin_token) + + case Enum.filter(capabilities, &(String.downcase(&1.plugin_id) == normalized)) do + [] -> + unknown(input, :unknown_plugin) + + [capability] -> + resolve_command(input, capability, rest) + + [_ | _] = matches -> + ambiguous(input, matches) + end + end + + defp resolve_command(input, capability, []) do + %PreflightResult{ + kind: :unknown, + task_input: input, + plugin: capability, + command: nil, + args: [], + trust_state: trust_state(capability), + remediation: remediation_for(capability), + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: [], + match_explanation: %{matched_by: nil, confidence: :none}, + reason: :missing_command_token + } + end + + defp resolve_command(input, capability, [command_token | args]) do + normalized = String.downcase(command_token) + + case find_command_ci(capability, normalized) do + nil -> + %PreflightResult{ + kind: :unknown, + task_input: input, + plugin: capability, + command: nil, + args: args, + trust_state: trust_state(capability), + remediation: remediation_for(capability), + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: [], + match_explanation: %{matched_by: nil, confidence: :none}, + reason: :unknown_command + } + + %CommandCapability{} = command -> + unique_match(input, capability, command, normalized, args) + end + end + + defp find_command_ci(%Capability{commands: commands}, normalized_token) do + Enum.find(commands, fn cmd -> + String.downcase(cmd.name) == normalized_token or + normalized_token in Enum.map(cmd.aliases, &String.downcase/1) + end) + end + + defp unique_match(input, capability, command, normalized_token, args) do + confidence = + cond do + String.downcase(command.name) == normalized_token -> :exact + normalized_token in Enum.map(command.aliases, &String.downcase/1) -> :alias + true -> :none + end + + matched_by = + cond do + String.downcase(command.name) == normalized_token -> :canonical + normalized_token in Enum.map(command.aliases, &String.downcase/1) -> :alias + true -> nil + end + + %PreflightResult{ + kind: :unique_match, + task_input: input, + plugin: capability, + command: command, + args: args, + trust_state: trust_state(capability), + remediation: remediation_for(capability), + risk_class: command.risk_class, + expected_artifacts: command.expected_artifacts, + continuation_policy: continuation_policy_for(command), + candidates: [], + match_explanation: %{matched_by: matched_by, confidence: confidence}, + reason: nil + } + end + + defp ambiguous(input, candidates) do + %PreflightResult{ + kind: :ambiguous, + task_input: input, + plugin: nil, + command: nil, + args: [], + trust_state: :unknown, + remediation: nil, + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: candidates, + match_explanation: %{matched_by: nil, confidence: :none}, + reason: :duplicate_plugin_ids + } + end + + defp unknown(input, reason) do + %PreflightResult{ + kind: :unknown, + task_input: input, + plugin: nil, + command: nil, + args: [], + trust_state: :unknown, + remediation: nil, + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: [], + match_explanation: %{matched_by: nil, confidence: :none}, + reason: reason + } + end + + defp not_applicable(input) do + %PreflightResult{ + kind: :not_applicable, + task_input: input, + plugin: nil, + command: nil, + args: [], + trust_state: :unknown, + remediation: nil, + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: [], + match_explanation: %{matched_by: nil, confidence: :none}, + reason: :not_user_level_plugin_input + } + end + + @doc """ + Convenience predicate for routing layers: returns `true` when `task_input` + syntactically targets a UserLevel plugin known to `capabilities`. + + Does not evaluate trust or command validity; that is the job of `resolve/2`. + Callers can use this to decide whether to swap the routing decision before + invoking the dispatcher. + """ + @spec applies_to?(String.t(), [Capability.t()]) :: boolean() + def applies_to?(task_input, capabilities) + when is_binary(task_input) and is_list(capabilities) do + case tokenize(String.trim(task_input)) do + [prefix, plugin_token | _rest] -> + if ooo_prefix?(prefix) do + normalized = String.downcase(plugin_token) + Enum.any?(capabilities, &(String.downcase(&1.plugin_id) == normalized)) + else + false + end + + _other -> + false + end + end + + def applies_to?(_task_input, _capabilities), do: false + + defp tokenize(""), do: [] + defp tokenize(input), do: String.split(input, ~r/\s+/u, trim: true) + + defp ooo_prefix?(prefix), do: String.downcase(prefix) in @ooo_prefixes + + defp trust_state(%Capability{trust_scope: scopes}) when scopes != [], do: :allowed + defp trust_state(%Capability{}), do: :missing + + defp remediation_for(%Capability{trust_scope: scopes, plugin_id: id}) when scopes == [] do + "ouroboros plugin trust #{id} --scope " + end + + defp remediation_for(_capability), do: nil + + defp continuation_policy_for(%CommandCapability{continuation_hint: :auto_run_when_requested}), + do: :auto_when_requested + + defp continuation_policy_for(%CommandCapability{continuation_hint: :suggest_run}), do: :suggest + defp continuation_policy_for(%CommandCapability{}), do: :none +end diff --git a/test/fixtures/user_level_plugins/superpowers.json b/test/fixtures/user_level_plugins/superpowers.json new file mode 100644 index 0000000..a190ddf --- /dev/null +++ b/test/fixtures/user_level_plugins/superpowers.json @@ -0,0 +1,62 @@ +{ + "plugins": [ + { + "id": "superpowers", + "name": "superpowers", + "version": "0.4.2", + "install_scope": "user", + "trust_scope": ["filesystem:read", "filesystem:write"], + "manifest_digest": "sha256:abc123def456", + "commands": [ + { + "name": "list", + "aliases": ["ls"], + "summary": "List installed Superpowers skills.", + "args": [], + "risk_class": "read_only", + "expected_artifacts": [], + "continuation_hint": "none" + }, + { + "name": "inspect", + "aliases": [], + "summary": "Print the manifest of one Superpowers skill.", + "args": [ + {"name": "skill", "required": true, "description": "Skill name."} + ], + "risk_class": "read_only", + "expected_artifacts": [], + "continuation_hint": "none" + }, + { + "name": "test-driven-development", + "aliases": ["tdd"], + "summary": "Generate a TDD handoff and seed for the given goal.", + "args": [ + {"name": "goal", "required": true, "description": "User goal."} + ], + "risk_class": "handoff_producing", + "expected_artifacts": [ + ".omx/superpowers/runs/*/seed.md", + ".omx/superpowers/runs/*/handoff.md" + ], + "continuation_hint": "suggest_run" + }, + { + "name": "systematic-debugging", + "aliases": ["debug"], + "summary": "Generate a debugging handoff and seed for the given goal.", + "args": [ + {"name": "goal", "required": true, "description": "User goal."} + ], + "risk_class": "handoff_producing", + "expected_artifacts": [ + ".omx/superpowers/runs/*/seed.md", + ".omx/superpowers/runs/*/handoff.md" + ], + "continuation_hint": "suggest_run" + } + ] + } + ] +} diff --git a/test/ourocode/plugin/user_level/capability_test.exs b/test/ourocode/plugin/user_level/capability_test.exs new file mode 100644 index 0000000..aeb3289 --- /dev/null +++ b/test/ourocode/plugin/user_level/capability_test.exs @@ -0,0 +1,138 @@ +defmodule Ourocode.Plugin.UserLevel.CapabilityTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Capability.Command, as: CommandCapability + + describe "new/1" do + test "builds a capability from a minimal valid descriptor" do + assert {:ok, %Capability{} = capability} = + Capability.new(%{plugin_id: "superpowers", source: :ouroboros_cli}) + + assert capability.plugin_id == "superpowers" + assert capability.plugin_name == "superpowers" + assert capability.source == :ouroboros_cli + assert capability.commands == [] + assert capability.trust_scope == [] + assert capability.install_scope == :unknown + assert %DateTime{} = capability.discovered_at + end + + test "normalizes commands and drops invalid ones" do + assert {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :ouroboros_cli, + commands: [ + %{name: "list"}, + %{name: ""}, + %{name: "tdd", aliases: ["test-driven-development"]} + ] + }) + + assert [%CommandCapability{name: "list"}, %CommandCapability{name: "tdd"} = tdd] = + capability.commands + + assert tdd.aliases == ["test-driven-development"] + end + + test "rejects missing plugin_id" do + assert {:error, :invalid_capability_attrs} = + Capability.new(%{source: :ouroboros_cli}) + end + + test "rejects unknown source" do + assert {:error, :invalid_capability_attrs} = + Capability.new(%{plugin_id: "x", source: :random}) + end + + test "uses caller-provided discovered_at when present" do + stamp = ~U[2026-01-01 00:00:00Z] + + assert {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + discovered_at: stamp + }) + + assert capability.discovered_at == stamp + end + + test "trust_scope drops blanks and dedupes" do + assert {:ok, capability} = + Capability.new(%{ + plugin_id: "x", + source: :ouroboros_cli, + trust_scope: ["filesystem:read", "", "filesystem:read", "filesystem:write"] + }) + + assert capability.trust_scope == ["filesystem:read", "filesystem:write"] + end + end + + describe "identity/1" do + test "is stable when plugin_id, version, and manifest_digest match" do + attrs = %{ + plugin_id: "superpowers", + source: :ouroboros_cli, + version: "0.4.2", + manifest_digest: "sha256:abc" + } + + {:ok, a} = Capability.new(attrs) + {:ok, b} = Capability.new(attrs) + + assert Capability.identity(a) == Capability.identity(b) + assert Capability.identity(a) == {"superpowers", "0.4.2", "sha256:abc"} + end + + test "differs when manifest digest changes" do + {:ok, a} = + Capability.new(%{ + plugin_id: "superpowers", + source: :ouroboros_cli, + manifest_digest: "sha256:old" + }) + + {:ok, b} = + Capability.new(%{ + plugin_id: "superpowers", + source: :ouroboros_cli, + manifest_digest: "sha256:new" + }) + + refute Capability.identity(a) == Capability.identity(b) + end + end + + describe "find_command/2" do + setup do + {:ok, capability} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + commands: [ + %{name: "list"}, + %{name: "test-driven-development", aliases: ["tdd"]} + ] + }) + + %{capability: capability} + end + + test "matches canonical name", %{capability: capability} do + assert %CommandCapability{name: "test-driven-development"} = + Capability.find_command(capability, "test-driven-development") + end + + test "matches alias", %{capability: capability} do + assert %CommandCapability{name: "test-driven-development"} = + Capability.find_command(capability, "tdd") + end + + test "returns nil for unknown command", %{capability: capability} do + assert nil == Capability.find_command(capability, "nope") + end + end +end diff --git a/test/ourocode/plugin/user_level/discovery/ouroboros_cli_test.exs b/test/ourocode/plugin/user_level/discovery/ouroboros_cli_test.exs new file mode 100644 index 0000000..135770c --- /dev/null +++ b/test/ourocode/plugin/user_level/discovery/ouroboros_cli_test.exs @@ -0,0 +1,71 @@ +defmodule Ourocode.Plugin.UserLevel.Discovery.OuroborosCLITest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Discovery.OuroborosCLI + + @fixture_path Path.join([__DIR__, "..", "..", "..", "..", "fixtures", "user_level_plugins", "superpowers.json"]) + + test "parses the superpowers fixture into four commands" do + json = File.read!(@fixture_path) + + assert {:ok, [plugin]} = OuroborosCLI.parse(json) + + assert plugin.plugin_id == "superpowers" + assert plugin.version == "0.4.2" + assert plugin.install_scope == :user + assert plugin.trust_scope == ["filesystem:read", "filesystem:write"] + assert plugin.manifest_digest == "sha256:abc123def456" + + names = Enum.map(plugin.commands, & &1.name) + assert names == ["list", "inspect", "test-driven-development", "systematic-debugging"] + + tdd = Enum.find(plugin.commands, &(&1.name == "test-driven-development")) + assert tdd.aliases == ["tdd"] + assert tdd.risk_class == "handoff_producing" + + assert tdd.expected_artifacts == [ + ".omx/superpowers/runs/*/seed.md", + ".omx/superpowers/runs/*/handoff.md" + ] + end + + test "treats a bare JSON array (no plugins wrapper) the same way" do + json = ~s([{"id":"x","name":"x","commands":[]}]) + assert {:ok, [plugin]} = OuroborosCLI.parse(json) + assert plugin.plugin_id == "x" + end + + test "rejects malformed JSON" do + assert {:error, {:ouroboros_cli_invalid_json, _}} = + OuroborosCLI.parse("not-json") + end + + test "rejects unexpected JSON shape" do + assert {:error, :ouroboros_cli_unexpected_shape} = + OuroborosCLI.parse(~s({"unexpected": true})) + end + + test "runner failure surfaces as command_failed" do + runner = fn _cmd, _args, _opts -> + {:ok, %{status: 1, stdout: "", stderr: "ouroboros: not found"}} + end + + assert {:error, {:ouroboros_cli_failed, %{exit_status: 1, stderr: "ouroboros: not found"}}} = + OuroborosCLI.discover(command_runner: runner) + end + + test "runner error surfaces as unavailable" do + runner = fn _cmd, _args, _opts -> {:error, :enoent} end + + assert {:error, {:ouroboros_cli_unavailable, :enoent}} = + OuroborosCLI.discover(command_runner: runner) + end + + test "happy path runner returns parsed descriptors" do + json = File.read!(@fixture_path) + runner = fn _cmd, _args, _opts -> {:ok, %{status: 0, stdout: json, stderr: ""}} end + + assert {:ok, [plugin]} = OuroborosCLI.discover(command_runner: runner) + assert plugin.plugin_id == "superpowers" + end +end diff --git a/test/ourocode/plugin/user_level/discovery_test.exs b/test/ourocode/plugin/user_level/discovery_test.exs new file mode 100644 index 0000000..dd10427 --- /dev/null +++ b/test/ourocode/plugin/user_level/discovery_test.exs @@ -0,0 +1,47 @@ +defmodule Ourocode.Plugin.UserLevel.DiscoveryTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Discovery + + defmodule StubAdapter do + @behaviour Ourocode.Plugin.UserLevel.Discovery + + @impl true + def discover(opts) do + Map.new(opts) |> Map.get(:stub_result, {:ok, []}) + end + end + + test "normalizes valid descriptors into Capability structs" do + descriptors = [ + %{plugin_id: "superpowers", source: :ouroboros_cli, commands: [%{name: "list"}]}, + %{plugin_id: "other", source: :ouroboros_cli} + ] + + assert {:ok, [a, b], []} = + Discovery.run(StubAdapter, stub_result: {:ok, descriptors}) + + assert %Capability{plugin_id: "superpowers", commands: [_command]} = a + assert %Capability{plugin_id: "other", commands: []} = b + end + + test "reports invalid descriptors without losing valid ones" do + descriptors = [ + %{plugin_id: "good", source: :ouroboros_cli}, + %{source: :ouroboros_cli}, + %{plugin_id: "another_good", source: :ouroboros_cli} + ] + + assert {:ok, capabilities, errors} = + Discovery.run(StubAdapter, stub_result: {:ok, descriptors}) + + assert Enum.map(capabilities, & &1.plugin_id) == ["good", "another_good"] + assert [{:invalid_descriptor, {:invalid_capability_attrs, %{source: :ouroboros_cli}}}] = errors + end + + test "propagates adapter errors unchanged" do + assert {:error, :boom} == + Discovery.run(StubAdapter, stub_result: {:error, :boom}) + end +end diff --git a/test/ourocode/plugin/user_level/preflight_result_test.exs b/test/ourocode/plugin/user_level/preflight_result_test.exs new file mode 100644 index 0000000..421989b --- /dev/null +++ b/test/ourocode/plugin/user_level/preflight_result_test.exs @@ -0,0 +1,25 @@ +defmodule Ourocode.Plugin.UserLevel.PreflightResultTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.PreflightResult + + test "new/1 fills defaults for unspecified fields" do + result = PreflightResult.new(kind: :unknown, task_input: "x") + + assert %PreflightResult{ + kind: :unknown, + task_input: "x", + plugin: nil, + command: nil, + args: [], + trust_state: :unknown, + remediation: nil, + risk_class: :unknown, + expected_artifacts: [], + continuation_policy: :none, + candidates: [], + match_explanation: %{matched_by: nil, confidence: :none}, + reason: nil + } = result + end +end diff --git a/test/ourocode/plugin/user_level/registry_entry_test.exs b/test/ourocode/plugin/user_level/registry_entry_test.exs new file mode 100644 index 0000000..56703e6 --- /dev/null +++ b/test/ourocode/plugin/user_level/registry_entry_test.exs @@ -0,0 +1,98 @@ +defmodule Ourocode.Plugin.UserLevel.RegistryEntryTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.RegistryEntry + + defp capability(opts \\ []) do + {:ok, capability} = + Capability.new(%{ + plugin_id: Keyword.get(opts, :plugin_id, "superpowers"), + source: :ouroboros_cli, + version: Keyword.get(opts, :version, "0.4.2"), + manifest_digest: Keyword.get(opts, :manifest_digest, "sha256:abc"), + trust_scope: Keyword.get(opts, :trust_scope, []), + commands: [ + %{ + name: "test-driven-development", + aliases: ["tdd"], + summary: "TDD handoff.", + args: [%{name: "goal", required: true, description: "User goal."}], + risk_class: "handoff_producing", + expected_artifacts: [".omx/superpowers/runs/*/seed.md"], + continuation_hint: "suggest_run" + } + ] + }) + + capability + end + + test "projects one entry per command capability" do + [entry] = RegistryEntry.entries(capability()) + + assert entry.id == "user_level_plugin:superpowers:test-driven-development" + assert entry.name == "superpowers test-driven-development" + assert entry.slash == "/superpowers test-driven-development" + assert entry.aliases == ["/superpowers tdd"] + assert entry.source == :plugin + assert entry.source_id == "superpowers" + assert entry.category == :plugins + assert entry.summary == "TDD handoff." + assert entry.availability == :available + assert entry.runnable? == true + + assert entry.args == [%{name: "goal", required?: true, description: "User goal."}] + + assert entry.run_spec.kind == :user_level_plugin_command + assert entry.run_spec.plugin_id == "superpowers" + assert entry.run_spec.command == "test-driven-development" + assert entry.run_spec.risk_class == :handoff_producing + assert entry.run_spec.continuation_hint == :suggest_run + + assert entry.metadata.plugin_id == "superpowers" + assert entry.metadata.plugin_surface == :user_level + assert entry.metadata.namespace_owner == :ouroboros + assert entry.metadata.capability_version == "0.4.2" + assert entry.metadata.manifest_digest == "sha256:abc" + end + + test "trust metadata defaults to requires_explicit_approval when no scopes are present" do + [entry] = RegistryEntry.entries(capability(trust_scope: [])) + + assert entry.metadata.trust_policy == %{ + "tier" => "user_level", + "requires_explicit_approval" => true + } + + assert entry.metadata.trust_evaluation == %{ + "trusted" => false, + "trust_scopes" => [] + } + end + + test "trust metadata reflects discovered trust scopes" do + scopes = ["filesystem:read", "filesystem:write"] + [entry] = RegistryEntry.entries(capability(trust_scope: scopes)) + + assert entry.metadata.trust_policy == %{ + "tier" => "user_level", + "requires_explicit_approval" => false, + "trust_scopes" => scopes + } + + assert entry.metadata.trust_evaluation == %{ + "trusted" => true, + "trust_scopes" => scopes + } + end + + test "entries/1 flattens a list of capabilities" do + one = capability(plugin_id: "a", manifest_digest: "sha256:a") + two = capability(plugin_id: "b", manifest_digest: "sha256:b") + + entries = RegistryEntry.entries([one, two]) + assert length(entries) == 2 + assert Enum.map(entries, & &1.source_id) == ["a", "b"] + end +end diff --git a/test/ourocode/plugin/user_level/registry_test.exs b/test/ourocode/plugin/user_level/registry_test.exs new file mode 100644 index 0000000..f4c3363 --- /dev/null +++ b/test/ourocode/plugin/user_level/registry_test.exs @@ -0,0 +1,117 @@ +defmodule Ourocode.Plugin.UserLevel.RegistryTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.Registry + + defmodule StubAdapter do + @behaviour Ourocode.Plugin.UserLevel.Discovery + + @impl true + def discover(opts) do + Map.new(opts) |> Map.get(:stub_result, {:ok, []}) + end + end + + setup do + name = :"user_level_registry_#{System.unique_integer([:positive])}" + {:ok, _pid} = Registry.start_link(name: name, adapter: StubAdapter) + %{registry: name} + end + + test "starts in :empty status with no capabilities", %{registry: registry} do + snapshot = Registry.list(registry, max_age_ms: nil) + assert snapshot.status == :empty + assert snapshot.capabilities == [] + assert snapshot.refreshed_at == nil + end + + test "refresh/2 with successful discovery returns :ready snapshot", %{registry: registry} do + descriptors = [%{plugin_id: "superpowers", source: :fixture, version: "1.0.0"}] + + snapshot = + Registry.refresh(registry, adapter_options: [stub_result: {:ok, descriptors}]) + + assert snapshot.status == :ready + assert [%Capability{plugin_id: "superpowers"}] = snapshot.capabilities + assert %DateTime{} = snapshot.refreshed_at + assert snapshot.errors == [] + end + + test "refresh/2 surface adapter errors as :degraded snapshot", %{registry: registry} do + snapshot = + Registry.refresh(registry, adapter_options: [stub_result: {:error, :boom}]) + + assert snapshot.status == :degraded + assert snapshot.capabilities == [] + assert [{:discovery_failed, :boom}] = snapshot.errors + assert %DateTime{} = snapshot.refreshed_at + end + + test "refresh/2 preserves last good capabilities on subsequent failure", %{registry: registry} do + descriptors = [%{plugin_id: "superpowers", source: :fixture}] + + Registry.refresh(registry, adapter_options: [stub_result: {:ok, descriptors}]) + + after_failure = + Registry.refresh(registry, adapter_options: [stub_result: {:error, :network}]) + + assert after_failure.status == :degraded + assert [%Capability{plugin_id: "superpowers"}] = after_failure.capabilities + assert [{:discovery_failed, :network} | _] = after_failure.errors + end + + test "identity stability: same struct instance is returned across refreshes", %{ + registry: registry + } do + descriptors = [ + %{ + plugin_id: "superpowers", + source: :fixture, + version: "0.4.2", + manifest_digest: "sha256:abc" + } + ] + + %{capabilities: [first]} = + Registry.refresh(registry, adapter_options: [stub_result: {:ok, descriptors}]) + + %{capabilities: [second]} = + Registry.refresh(registry, adapter_options: [stub_result: {:ok, descriptors}]) + + # Same identity -> reused struct + assert first == second + assert Capability.identity(first) == Capability.identity(second) + end + + test "list/2 with TTL=0 triggers a refresh using the cached adapter options", + %{registry: registry} do + descriptors = [%{plugin_id: "x", source: :fixture}] + + # Seed the adapter options once via an explicit refresh. + Registry.refresh(registry, adapter_options: [stub_result: {:ok, descriptors}]) + + # max_age_ms: 0 forces stale; list/2 must re-run discovery with the + # adapter options it was configured with. + snapshot = Registry.list(registry, max_age_ms: 0) + assert [%Capability{plugin_id: "x"}] = snapshot.capabilities + + # max_age_ms: nil returns the cached snapshot unchanged. + cached = Registry.list(registry, max_age_ms: nil) + assert cached.refreshed_at == snapshot.refreshed_at + end + + test "fetch/2 returns capability by plugin_id from cached snapshot", %{registry: registry} do + descriptors = [ + %{plugin_id: "superpowers", source: :fixture}, + %{plugin_id: "other", source: :fixture} + ] + + Registry.refresh(registry, adapter_options: [stub_result: {:ok, descriptors}]) + + assert {:ok, %Capability{plugin_id: "superpowers"}} = + Registry.fetch(registry, "superpowers") + + assert :error == Registry.fetch(registry, "unknown") + end +end diff --git a/test/ourocode/plugin/user_level/resolver_test.exs b/test/ourocode/plugin/user_level/resolver_test.exs new file mode 100644 index 0000000..0ca0052 --- /dev/null +++ b/test/ourocode/plugin/user_level/resolver_test.exs @@ -0,0 +1,216 @@ +defmodule Ourocode.Plugin.UserLevel.ResolverTest do + use ExUnit.Case, async: true + + alias Ourocode.Plugin.UserLevel.Capability + alias Ourocode.Plugin.UserLevel.PreflightResult + alias Ourocode.Plugin.UserLevel.Resolver + + defp superpowers(opts \\ []) do + {:ok, cap} = + Capability.new(%{ + plugin_id: "superpowers", + source: :fixture, + version: "0.4.2", + manifest_digest: "sha256:abc", + trust_scope: Keyword.get(opts, :trust_scope, ["filesystem:read", "filesystem:write"]), + commands: [ + %{name: "list", aliases: ["ls"], risk_class: "read_only"}, + %{ + name: "test-driven-development", + aliases: ["tdd"], + risk_class: "handoff_producing", + expected_artifacts: [".omx/superpowers/runs/*/seed.md"], + continuation_hint: "suggest_run" + } + ] + }) + + cap + end + + describe "resolve/2 — unique match" do + test "exact canonical command match returns :unique_match with confidence :exact" do + result = + Resolver.resolve( + "ooo superpowers test-driven-development --goal retry", + [superpowers()] + ) + + assert %PreflightResult{ + kind: :unique_match, + plugin: %Capability{plugin_id: "superpowers"}, + trust_state: :allowed, + risk_class: :handoff_producing, + expected_artifacts: [".omx/superpowers/runs/*/seed.md"], + continuation_policy: :suggest, + match_explanation: %{matched_by: :canonical, confidence: :exact} + } = result + + assert result.command.name == "test-driven-development" + assert result.args == ["--goal", "retry"] + assert result.reason == nil + end + + test "alias matches with confidence :alias" do + result = Resolver.resolve("ooo superpowers tdd --goal x", [superpowers()]) + + assert %PreflightResult{ + kind: :unique_match, + match_explanation: %{matched_by: :alias, confidence: :alias} + } = result + + assert result.command.name == "test-driven-development" + end + + test "treats `ouroboros` prefix identically to `ooo`" do + result = + Resolver.resolve("ouroboros superpowers tdd --goal x", [superpowers()]) + + assert %PreflightResult{kind: :unique_match} = result + end + + test "preserves argument casing verbatim" do + result = + Resolver.resolve( + "ooo superpowers tdd --Goal MixedCase --Verbose", + [superpowers()] + ) + + assert %PreflightResult{kind: :unique_match, args: args} = result + assert "--Goal" in args + assert "MixedCase" in args + assert "--Verbose" in args + end + + test "matches plugin and command case-insensitively even when typed in mixed case" do + result = Resolver.resolve("OOO Superpowers TDD --goal x", [superpowers()]) + + assert %PreflightResult{ + kind: :unique_match, + match_explanation: %{matched_by: :alias} + } = result + + assert result.command.name == "test-driven-development" + end + + test "preserves shell-injection-like arg tokens as argv (no shell parsing)" do + result = + Resolver.resolve( + ~s(ooo superpowers tdd --goal "; rm -rf /"), + [superpowers()] + ) + + assert result.kind == :unique_match + # tokenization is whitespace-only; quoting is not honored. The point is + # that no shell expansion happens here. + assert "--goal" in result.args + refute Enum.any?(result.args, &String.contains?(&1, "$(")) + end + end + + describe "resolve/2 — trust missing" do + test "capability without trust_scope returns :allowed=false and remediation" do + result = + Resolver.resolve( + "ooo superpowers list", + [superpowers(trust_scope: [])] + ) + + assert %PreflightResult{ + kind: :unique_match, + trust_state: :missing, + remediation: "ouroboros plugin trust superpowers --scope " + } = result + end + end + + describe "resolve/2 — unknown" do + test "unknown plugin returns :unknown with :unknown_plugin reason" do + result = Resolver.resolve("ooo unknownplug tdd", [superpowers()]) + + assert %PreflightResult{ + kind: :unknown, + reason: :unknown_plugin, + plugin: nil, + command: nil + } = result + end + + test "known plugin with unknown command returns :unknown with :unknown_command reason" do + result = Resolver.resolve("ooo superpowers nope", [superpowers()]) + + assert %PreflightResult{ + kind: :unknown, + reason: :unknown_command, + plugin: %Capability{plugin_id: "superpowers"}, + command: nil + } = result + end + + test "missing command token returns :missing_command_token" do + result = Resolver.resolve("ooo superpowers", [superpowers()]) + + assert %PreflightResult{ + kind: :unknown, + reason: :missing_command_token, + plugin: %Capability{plugin_id: "superpowers"} + } = result + end + + test "missing plugin token returns :missing_plugin_token" do + result = Resolver.resolve("ooo", [superpowers()]) + + assert %PreflightResult{kind: :unknown, reason: :missing_plugin_token} = result + end + end + + describe "resolve/2 — ambiguous" do + test "duplicate plugin_ids return :ambiguous with candidates" do + cap_a = superpowers() + {:ok, cap_b} = Capability.new(%{plugin_id: "superpowers", source: :fixture}) + + result = Resolver.resolve("ooo superpowers list", [cap_a, cap_b]) + + assert %PreflightResult{ + kind: :ambiguous, + reason: :duplicate_plugin_ids + } = result + + assert length(result.candidates) == 2 + end + end + + describe "resolve/2 — not applicable" do + test "non-ooo input returns :not_applicable" do + result = Resolver.resolve("interview some goal", [superpowers()]) + + assert %PreflightResult{ + kind: :not_applicable, + reason: :not_user_level_plugin_input + } = result + end + + test "blank input returns :not_applicable" do + result = Resolver.resolve(" ", [superpowers()]) + assert result.kind == :not_applicable + end + end + + describe "applies_to?/2" do + test "true when ooo + known plugin id" do + assert Resolver.applies_to?("ooo superpowers list", [superpowers()]) + end + + test "false when ooo + unknown plugin id" do + refute Resolver.applies_to?("ooo other list", [superpowers()]) + end + + test "false for non-ooo input" do + refute Resolver.applies_to?("interview x", [superpowers()]) + end + + test "false for blank input" do + refute Resolver.applies_to?("", [superpowers()]) + end + end +end