lakedrops/ai-catalog

Composer Plugin to publish an ARD (Agentic Resource Discovery) capability manifest advertising agent skills and agents.

Maintainers

Package info

gitlab.lakedrops.com/composer/plugin/ai-catalog

Issues

Documentation

Type:composer-plugin

pkg:composer/lakedrops/ai-catalog

Transparency log

Statistics

Installs: 33

Dependents: 1

Suggesters: 0

dev-develop 2026-08-12 08:55 UTC

This package is not auto-updated.

Last update: 2026-08-12 13:39:33 UTC


README

Composer plugin that publishes an Agentic Resource Discovery (ARD) capability manifest for a Drupal site, advertising a curated set of agent skills and agents.

Installation

composer require lakedrops/ai-catalog

Composer plugins must be allowed explicitly. Add this to the consuming site's composer.json:

{
  "config": {
    "allow-plugins": {
      "lakedrops/ai-catalog": true
    }
  }
}

Without that entry Composer silently refuses to activate the plugin and the lakedrops:ai-catalog command never appears.

What it produces

Everything below <webroot>/.well-known/ is generated. It should be git-ignored: it is a build artifact, reproducible from configuration plus the upstream repositories.

PathPurpose
.well-known/ai-catalog.jsonThe ARD capability manifest (spec v0.9 §4.1).
.well-known/skills/index.jsonCompanion index: name, version and file list per skill.
.well-known/skills/<name>/SKILL.mdThe mirrored skill, served as text/markdown.
.well-known/skills/<name>/references/*The skill's reference files.
.well-known/agents/index.jsonCompanion index: name, version and file list per agent.
.well-known/agents/<name>/AGENT.mdThe mirrored agent definition.
.well-known/.htaccessAccess-Control-Allow-Origin: *, .mdtext/markdown, Options -Indexes.

An index is only written when at least one entry of that kind is configured, so a skills-only site grows no empty agents/ directory.

Webroot resolution

Nothing is hardcoded to web/, so this works on any Drupal site regardless of its docroot convention. The webroot is resolved in this order, first hit wins:

  1. the aicatalog.webroot configuration key,
  2. extra.drupal-scaffold.locations.web-root in the site's composer.json,
  3. the literal web.

The value is normalized: surrounding whitespace, a leading ./ and trailing slashes are all removed. An empty result (. or ./) means the site is served straight from its repository root, and .well-known/ is placed there. An absolute path, or one containing .., is a hard error rather than something silently reinterpreted — this component writes and deletes files below the webroot, so it refuses to be pointed outside the project.

Configuration

The selection lives in .lakedrops.yml under the top-level aicatalog key, and it is read through the LakeDrops Config service. That is what makes stage_overwrites and .lakedrops.user.yml apply to the catalogue exactly the way they apply to every other LakeDrops component — a stage can publish a different baseUrl, and a developer can redirect the webroot locally, without either of them editing the shared configuration.

aicatalog:
  host:                     # ARD §4.3 host object; displayName is required.
    displayName: LakeDrops
  publisher: lakedrops.com  # Must be a verifiable FQDN (ARD §4.2.1).
  baseUrl: 'https://www.lakedrops.com'
  gitlab: 'https://gitlab.lakedrops.com'   # Optional, this is the default.
  webroot: web              # Optional; see "Webroot resolution" above.
  skills:
    l3d:                    # The KEY is the canonical skill name.
      project: ai/skills/l3d              # GitLab project path.
      ref: main                           # Branch or tag to pin to.
      displayName: 'L3D'                  # Optional; defaults to the name.
      tags: [drupal, docker]
      capabilities: [run-drush]           # Optional; must be a list.
      representativeQueries:              # Optional; 2 to 5 items if present.
        - 'how do I run drush in a LakeDrops project'
        - 'why does composer fail when run on the host'
  agents:
    ECA:                    # The KEY is the canonical agent name.
      project: ai/agents/eca              # Its own GitLab project path.
      ref: main                           # Branch or tag to pin to.
      displayName: 'ECA Workflow Architect'
      tags: [drupal, eca]
      capabilities: [model-eca]           # Optional; defaults to frontmatter tools.
      representativeQueries:
        - 'how do I build an ECA model'
        - 'which ECA event fires on node save'

A site with no aicatalog section at all is a clean no-op with exit code 0 — not every LakeDrops site publishes a catalogue. But a section that is present while configuring neither skills nor agents is an error: publishing an empty catalogue is worse than publishing none, because a consumer cannot tell the difference. Configuring only one of the two is perfectly valid.

Do not use YAML comments inside .lakedrops.yml: the LakeDrops config tooling re-dumps the whole file with Yaml::dump() and comments are lost. Key names carry the documentation instead.

Skills and agents are the same shape

Both blocks are a map keyed by canonical name, and each entry is one GitLab repository named by its own project and ref.

A skill repository is anchored on a SKILL.md at its root, plus an optional references/ directory. An agent repository is anchored on an AGENT.md: YAML frontmatter (name, description, and optionally mode, model, tools, color, permission) followed by a system prompt, plus the same optional references/. Mirroring rules, the ignorable-file list and the "missing anchor file is fatal" rule are identical.

Skills are typically shared across sites, whereas agents are typically site-specific — but that is a matter of which repository you point at, not a difference in mechanism. A site publishes whichever of the two it has.

The map key is in both cases the canonical name: it is the mirror directory, the terminal segment of the URN, and it must equal the name in the frontmatter. If the two drift apart, generation fails; it never guesses. The project path is a separate field precisely because the two can differ — ai/skills/drupal-work-on-issues (plural) publishes the skill drupal-work-on-issue (singular), and ai/agents/eca publishes the agent ECA.

Agents are mirrored to <name>/AGENT.md rather than to a flat <name>.md so the shape matches skills and an agent can ship reference files alongside its definition.

The four things that actually differ between the two kinds are enumerated in one place, src/Kind.php: the anchor filename, the URN namespace segment, the media type and the mirror directory. Everything else is shared code.

The one behavioural difference: when an agent's frontmatter carries a tools list and no capabilities is configured, tools populates capabilities — a declared tool is the closest thing an agent has to a machine-readable capability. Both the list form and the map form (tools: {read: true, write: false}) are understood, and only enabled tools are surfaced. An explicit configured capabilities always wins. Skills never do this, because a skill declares no tools.

Media types, and one deliberate extension

Skill entries use:

text/markdown; profile="urn:air:agent-skills"

The earlier value application/ai-skill+md is rejected by ARD's own conformance tool (ard-spec issue #37), and the +md structured suffix is being retired in favour of the profile form (ADR-0014, ard-spec PR #78).

Agent entries use:

text/markdown; profile="urn:air:agent"

This agent media type is our own extension and is not sanctioned by the ARD specification. ARD defines no media type for an agent that is defined by a Markdown file: its only agent types are a2a-agent-card+json and the reserved agent-card+json, and both are JSON. The value above follows the reasoning of ADR-0014 exactly — a profile parameter on the artifact's real media type — and it is legal because ARD declares type an open text field rather than an enumeration. If ARD ever standardises a value for this, replace the constant in Catalog::AGENT_MEDIA_TYPE; the same statement is repeated as a comment there.

The urn:air:<publisher>:agent:<name> identifier namespace, by contrast, is not an invention: the ARD specification illustrates it directly with urn:air:acme.com:agent:assistant. Existing skill URNs urn:air:<publisher>:skill:<name> are unchanged.

specVersion stays "1.0": the JSON Schema's enum permits nothing else, even though the prose document is v0.9 Draft.

Validation

Generation is a gate, not a best effort. These are all hard errors:

  • a fetch failure, or an empty source repository;
  • a missing SKILL.md or AGENT.md;
  • a frontmatter name that differs from the configuration key;
  • a representativeQueries list outside 2–5 items (the ARD JSON Schema sets minItems: 2, maxItems: 5), when the key is present at all;
  • a metadata value that is not a scalar — nested objects and arrays are invalid per the schema;
  • a tags, capabilities or representativeQueries value that is not a JSON array;
  • a duplicate identifier, enforced across skills and agents together, since both share one entries array;
  • an identifier that is not a domain-anchored urn:air: URN (ARD §4.2.1);
  • an entry carrying both or neither of url and data (ARD §3.4);
  • a non-HTTPS baseUrl or entry url.

A silently truncated catalogue is the failure mode the whole design guards against, which is why none of the above is ever skipped with a warning.

Usage

# Regenerate
composer lakedrops:ai-catalog

# Fail if the checked-out output is stale — no writes at all
composer lakedrops:ai-catalog --check

# CI mode: warnings (e.g. un-mirrored files in a source repo) become errors
composer lakedrops:ai-catalog --strict

# Machine-readable summary on STDOUT
composer lakedrops:ai-catalog --json

In a LakeDrops project the same three are also reachable through ahoy, which this package contributes as the ai-catalog command group:

ahoy ai-catalog generate
ahoy ai-catalog check
ahoy ai-catalog strict

The group is deliberately named after the package rather than something generic such as test: lakedrops/ahoy merges a package's .ahoy.l3d.yml by assigning whole command groups by name, so a generically named group would silently replace the consuming site's own commands of that name.

Read private repositories by exporting a token:

export AI_CATALOG_GITLAB_TOKEN=glpat-...

Exit code is 1 on a fetch error, a missing SKILL.md/AGENT.md, a frontmatter/config name mismatch, an ARD validation failure, or a stale tree under --check.

Generation is not attached to any Composer script event. It reaches out to a GitLab instance over the network, so hooking it onto post-install or post-update would make every composer install depend on that instance being reachable and authenticated. The command is the only entry point; CI invokes it explicitly where a token exists.

Determinism

version is the first 16 hex characters of a SHA-256 over each mirrored file's path and content digest — the same function for skills and agents. It therefore changes only when the content changes: not when tags or representative queries are edited, and not between runs. Nothing timestamped is emitted (in particular no updatedAt, deliberately, because determinism is worth more here than freshness metadata), so re-running with no upstream change produces byte-identical output and deploys do not churn.

Architecture

The split is load-bearing, not cosmetic:

  • Catalog is static, deterministic and side-effect free. It turns configuration plus file contents into the manifest, both indexes, the .htaccess and the full output tree. No network, no filesystem.
  • Generator owns everything that touches the outside world: the GitLab API and the writing, diffing and pruning of the generated tree.
  • Handler reads configuration through the LakeDrops Config service, resolves the webroot, and orchestrates one run.
  • Kind is the complete, enumerated list of how a skill differs from an agent. Both travel one fetch path, one entry-building path and one mirror path; adding a third kind should mean adding a factory to Kind and nothing else.

Because Catalog is pure, the entire test suite runs offline — it is verified with curl_init, file_put_contents, mkdir and unlink disabled outright.

Tests

composer install
php tests/CatalogTest.php

Plain PHP rather than PHPUnit: PHPUnit is a dev-only dependency, and these tests must also run in the --no-dev production build image. They are verified to pass under composer install --no-dev.

Serving requirements

ARD requires HTTPS, Content-Type: application/json and Access-Control-Allow-Origin: * (see the spec's How to publish, step 2). The generated .htaccess supplies the CORS header and the MIME mappings, but the web server still has to allow dot-paths. On docker4drupal that means .lakedrops.yml setting docker4drupal.webserver.overwriteconfig: true, which makes docker4drupal write apache/vhost.conf and set APACHE_INCLUDE_CONF. That vhost carries the (?!well-known) exemptions; without it the stock wodby php preset returns 403 for everything under /.well-known/.