lakedrops / ai-catalog
Composer Plugin to publish an ARD (Agentic Resource Discovery) capability manifest advertising agent skills and agents.
Package info
gitlab.lakedrops.com/composer/plugin/ai-catalog
Type:composer-plugin
pkg:composer/lakedrops/ai-catalog
Requires
- php: >=8.1
- composer-plugin-api: ^2
- ext-json: *
- lakedrops/composer-json-utils: ^2.5||dev-develop
- symfony/yaml: *
Requires (Dev)
- composer/composer: ^2
- roave/security-advisories: dev-latest
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.
| Path | Purpose |
|---|---|
.well-known/ai-catalog.json | The ARD capability manifest (spec v0.9 §4.1). |
.well-known/skills/index.json | Companion index: name, version and file list per skill. |
.well-known/skills/<name>/SKILL.md | The mirrored skill, served as text/markdown. |
.well-known/skills/<name>/references/* | The skill's reference files. |
.well-known/agents/index.json | Companion index: name, version and file list per agent. |
.well-known/agents/<name>/AGENT.md | The mirrored agent definition. |
.well-known/.htaccess | Access-Control-Allow-Origin: *, .md → text/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:
- the
aicatalog.webrootconfiguration key, extra.drupal-scaffold.locations.web-rootin the site'scomposer.json,- 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+jsonand the reservedagent-card+json, and both are JSON. The value above follows the reasoning of ADR-0014 exactly — aprofileparameter on the artifact's real media type — and it is legal because ARD declarestypean open text field rather than an enumeration. If ARD ever standardises a value for this, replace the constant inCatalog::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.mdorAGENT.md; - a frontmatter
namethat differs from the configuration key; - a
representativeQuerieslist outside 2–5 items (the ARD JSON Schema setsminItems: 2, maxItems: 5), when the key is present at all; - a
metadatavalue that is not a scalar — nested objects and arrays are invalid per the schema; - a
tags,capabilitiesorrepresentativeQueriesvalue that is not a JSON array; - a duplicate
identifier, enforced across skills and agents together, since both share oneentriesarray; - an identifier that is not a domain-anchored
urn:air:URN (ARD §4.2.1); - an entry carrying both or neither of
urlanddata(ARD §3.4); - a non-HTTPS
baseUrlor entryurl.
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:
Catalogis static, deterministic and side-effect free. It turns configuration plus file contents into the manifest, both indexes, the.htaccessand the full output tree. No network, no filesystem.Generatorowns everything that touches the outside world: the GitLab API and the writing, diffing and pruning of the generated tree.Handlerreads configuration through the LakeDropsConfigservice, resolves the webroot, and orchestrates one run.Kindis 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 toKindand 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/.