Skip to content
Open
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
124 changes: 124 additions & 0 deletions lib/ourocode/plugin/user_level/capability.ex
Original file line number Diff line number Diff line change
@@ -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
129 changes: 129 additions & 0 deletions lib/ourocode/plugin/user_level/capability/command.ex
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions lib/ourocode/plugin/user_level/discovery.ex
Original file line number Diff line number Diff line change
@@ -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
Loading