Skip to content

Latest commit

 

History

History
313 lines (243 loc) · 12.2 KB

File metadata and controls

313 lines (243 loc) · 12.2 KB

Plugin Development

This guide describes how AIHelper plugins integrate with the in-process runtime.

Plugin Types

  • Built-in plugins: compiled into ah (current domains use this mode).
  • Dynamic plugins: shared libraries loaded at runtime from plugins directory next to ah executable.

Runtime Discovery

At startup, runtime scans:

  • <exe-dir>/plugins/*.dll (Windows)
  • <exe-dir>/plugins/*.so (Linux)
  • <exe-dir>/plugins/*.dylib (macOS)

Plugins with duplicate domain names override built-in plugins. If a dynamic plugin fails to load, the runtime skips it and continues with remaining plugins. Plugin-domain state (enabled/disabled) is managed by host command ah plugins ... and persisted in global plugins.json.

ABI Contract

Dynamic plugins must expose symbol:

  • ah_plugin_entry_v1
  • optional: ah_plugin_manual_json_v1
  • optional typed-command capability (all symbols are required together):
    • ah_plugin_command_catalog_json_v1
    • ah_plugin_invoke_command_json_v1
    • ah_plugin_cancel_command_v1

Entry returns pointer to:

  • AhPluginApiV1

Required fields:

  • abi_version
  • plugin_name
  • domain
  • description
  • invoke_json
  • free_c_string

Optional symbol behavior:

  • ah_plugin_manual_json_v1 returns JSON for PluginManual
  • host uses it for ah ai info
  • if absent, plugin is still valid and loaded normally

The host validates abi_version against AH_PLUGIN_ABI_VERSION.

--credential SLOT=ID uses one mechanism on both the CLI and MCP paths. The host resolves each mapping through its configured SecretResolver and delivers the values in InvocationRequest::resolved_secrets, keyed by slot; the argv the plugin parses never contains a credential. --credential is accepted only for a domain whose command catalog declares a SecretSlot, so no host-side allowlist needs updating when a plugin gains one.

A plugin binds those values by implementing BindResolvedSecrets for its parsed CLI model, which the entrypoint macro calls between parsing and execution:

impl ah_plugin_api::BindResolvedSecrets for MyCli {
    fn bind_resolved_secrets(
        &mut self,
        secrets: &BTreeMap<String, ResolvedSecret>,
    ) -> Result<(), InvocationResponse> {
        self.connection.token = token_from_resolved_secrets(secrets)?;
        Ok(())
    }
}

The default implementation rejects every slot, so a plugin that has not opted in fails loudly instead of silently dropping a credential. Validate the slot name and the secret kind in this method: the host resolves the id but does not know which command the argv selects. A plugin with no credential slots implements the trait with an empty body.

Typed plugins must advertise typed_commands_v1 in compatibility metadata. The catalog declares one CommandDescriptor per operation with object-root input/output JSON Schemas and conservative CommandEffects. The runtime rejects duplicate ids, reserved input properties, invalid schemas, incomplete sidecars, and responses that do not satisfy the declared output schema.

Typed input schemas must use a canonical root object so the host can inject the optional MCP context property safely. Root keywords are limited to $schema, $id, $defs, definitions, title, description, default, examples, deprecated, readOnly, writeOnly, type, properties, required, and additionalProperties. Nested schemas remain unrestricted. Root composition, conditionals, property dependencies, object-count constraints, enum, const, patternProperties, propertyNames, and unevaluatedProperties are rejected. Document cross-field rules in the command and property descriptions and enforce them again in the typed handler, because those rules cannot use root composition.

The runtime compiles compatible input and output validators once per plugin definition revision. An incompatible catalog fails before MCP starts serving tools; ordinary invocation uses the cached registry instead of rebuilding schemas or the full command catalog.

The application reserves dynamic domains ai, plugins, and mcp for host control-plane commands. A shared library declaring one of these domains is skipped during discovery with a deterministic diagnostic.

Use the extended entrypoint macro form:

ah_plugin_api::define_plugin_entrypoint_v1!(
    plugin_name_c: PLUGIN_NAME_C,
    domain_c: DOMAIN_C,
    description_c: DESCRIPTION_C,
    domain: DOMAIN,
    parse_fn: parse_args,
    execute_fn: execute,
    manual_fn: plugin_manual,
    typed_catalog_fn: typed::command_catalog,
    typed_execute_fn: typed::invoke,
    typed_cancel_fn: typed::cancel,
);

Keep these contracts transport-neutral. Dynamic plugins must not depend on the MCP SDK.

Invocation Model

Host sends JSON request:

  • InvocationRequest
    • domain
    • argv
    • globals (json, quiet, limit)

Plugin returns JSON response:

  • InvocationResponse
    • success
    • optional message
    • optional error_code
    • optional error_message

Generated legacy invocation entrypoints contain unwinding panics from plugin argument parsing and execution. A caught panic returns PLUGIN_PANIC without exposing its payload, while preserving the ABI version, layout, and exported symbols. Plugins compiled with abort-on-panic cannot be recovered this way.

Typed invocation uses:

  • TypedInvocationRequest
    • stable command id
    • validated JSON arguments
    • ExecutionContextWire (request_id, cwd, limit, remaining deadline)
  • TypedInvocationResponse
    • structured data on success
    • structured CommandError on failure

Relative paths and child process working directories must derive from the request context. Do not read or change the process-global current directory. Bound network and child-process work by the remaining request deadline.

Concurrency contract

The MCP runtime invokes accepted typed commands concurrently. This includes multiple calls to the same command with the same cwd. Cancellation can run on another thread while invocation is still active.

  • Synchronize mutable caches, configuration, connection pools, and cancellation registries.
  • Never rely on process-global current-directory changes; use the request context and child-process current_dir.
  • Make cancellation idempotent and safe before, during, or just after handler completion.
  • Do not retain borrowed request data after the invocation function returns.
  • If a plugin serializes access to its own external resource, that is plugin policy; the host does not create a queue or per-plugin lane.

The C ABI layout is unchanged. Its invoke and cancel entrypoints must nevertheless be safe when called concurrently.

Semantic Text Formatting

Dynamic plugins can use the shared formatter from ah-plugin-api:

use ah_plugin_api::{TextFormatter, TextStyle};

let formatter = TextFormatter::stdout();
let rendered = formatter.paint(TextStyle::Success, "success");

TextFormatter::stdout() and TextFormatter::stderr() enable ANSI styles only when the corresponding stream is an interactive terminal and NO_COLOR is not set. Piped, redirected, and captured output therefore stays plain without changing the plugin invocation contract.

The formatter is an additive Rust helper compiled into each plugin. It does not change GlobalOptionsWire, InvocationRequest, InvocationResponse, exported C symbols, or AH_PLUGIN_ABI_VERSION; existing plugin binaries that return plain text remain compatible. Plugins render their own successful text responses, so no semantic-span protocol is added to the invocation wire contract.

Use semantic styles for structured metadata and statuses. Do not format raw file content, HTTP bodies, model responses, SQL result payloads, CI logs, or other content intended for downstream processing. JSON output must never contain ANSI sequences.

Renderer tests can use TextFormatter::with_color(true) and TextFormatter::with_color(false) to verify styled and plain contracts deterministically.

See Text Output Formatting for the shared stream policy and the semantic mappings used by bundled plugins.

Managed External Tool Commands

Dynamic plugins that depend on third-party command-line tools must expose a predictable tool command group instead of plugin-specific verbs such as install.

Standard commands:

  • ah <domain> tool status
    • Show the selected binary or toolchain path, resolver source, detected version, minimum/target version, cache path, companion executable availability, and warnings.
  • ah <domain> tool download [--version VERSION] [--force]
    • Download and extract a portable/vendor-provided archive into AIHelper's per-user managed cache.
    • This must not perform a system installation, register services, modify registry, or mutate global PATH.
  • ah <domain> tool use --path PATH
    • Persist an explicit user-selected binary or toolchain path for the plugin domain.
  • ah <domain> tool cleanup [--version VERSION]
    • Remove managed cached tool versions for that plugin domain.
    • This must not delete explicit/user-provided tool paths.

Operational commands may offer --ensure-tool to lazily perform the same download/extract flow when the managed toolchain is missing. Without this flag or an explicit plugin setting, commands should fail with a clear diagnostic and suggest ah <domain> tool download.

When no acceptable tool is resolved, operational commands must fail before doing domain work and return a stable missing-tool error such as TOOL_UNAVAILABLE or a domain-specific code like POSTGRES_TOOL_UNAVAILABLE. Text output should include concrete remediation commands, usually ah <domain> tool download and ah <domain> tool use --path PATH. JSON output should include the searched locations, rejected candidate when available, detected version when available, required minimum/target version, and a remediation command.

If a user provides an explicit path through CLI, environment, or persisted tool use, the plugin must not silently fall back to another tool when that explicit path is invalid. Explicit user intent should either work or fail with a clear diagnostic. If the managed cache is missing or corrupt and --ensure-tool is present, the plugin may download or repair the managed toolchain atomically before continuing.

Tool resolvers should use this order:

  1. Explicit CLI path flag.
  2. Domain-specific environment variable.
  3. Persisted domain setting from tool use.
  4. Managed AIHelper cache.
  5. System PATH, only if the detected version is acceptable.

External tool handling must not pass secrets in process argv. It must not modify global PATH; update only the child process environment when a tool needs adjacent DLL/runtime lookup. Downloads must use an allowlisted HTTPS source, verify archive integrity when a pinned checksum is available, and extract atomically under a lock to avoid corrupted caches.

Best Practices

  • Keep plugin command behavior deterministic.
  • Do not print partial/broken output on parse failures.
  • Use stable error codes for machine handling.
  • Treat ABI changes as versioned events (bump API version intentionally).
  • Describe worst-case effects when behavior depends on input flags.
  • Make active cancellation wake polling loops or terminate child process groups where practical.
  • Protect shared caches and configuration for concurrent invocation; handlers are not globally serialized.
  • Model upstream response fields explicitly. Unknown fields may be accepted on input, but must not be flattened into stable public JSON responses.

Example Dynamic Plugins

Repository includes dynamic plugin sources at:

  • plugins/ah-plugin-github
  • plugins/ah-plugin-gitlab
  • plugins/ah-plugin-ollama
  • plugins/ah-plugin-postgres

Build and install one plugin (Windows):

cargo build --release -p ah-plugin-github
New-Item -ItemType Directory -Force plugins | Out-Null
Copy-Item target/release/ah_plugin_github.dll plugins/ah-plugin-github.dll

Build and install one plugin (Linux):

cargo build --release -p ah-plugin-github
mkdir -p plugins
cp target/release/libah_plugin_github.so plugins/ah-plugin-github.so

Build and install one plugin (macOS):

cargo build --release -p ah-plugin-github
mkdir -p plugins
cp target/release/libah_plugin_github.dylib plugins/ah-plugin-github.dylib