Skip to content
Merged
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
18 changes: 18 additions & 0 deletions .changeset/tagged-adapter-asset-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@agent-facets/adapter': minor
'@agent-facets/adapter-claude-code': minor
'@agent-facets/adapter-opencode': minor
'@agent-facets/adapter-codex': minor
---

**BREAKING (pre-1.0 minor):** the adapter asset contract is now tagged request/result unions instead of positional parameters, and the adapter API identifier advances from `0.0` to `0.1`.

`installAsset`, `readAsset`, and `deleteAsset` each take a single request object tagged by `assetType` and return a discriminated result — expected failures (`not-found`, `invalid-companion-path`, `unsupported-scope`, `not-implemented`, `io-failed`) are structured values, never thrown errors. Skill requests carry a companion byte map plus the caller-verified owned companion path set for atomic multi-file skill bundles; agent and command requests structurally cannot carry companions. `defineAdapter` stubs for omitted methods now return `not-implemented` failures instead of throwing.

The SDK's canonical `ADAPTER_API_VERSION` is now `0.1`, identifying this tagged contract; `defineAdapter()` stamps it and first-party packages publish `"facetAdapterApiVersion": "0.1"`. `0.0` named the earlier positional contract: a CLI that supports only `0.1` classifies a `0.0` adapter as well-formed but unsupported and fails closed (before any contract method or project write) with reinstall guidance. There is no positional/tagged compatibility bridge — an adapter built against `0.0` must be rebuilt against a `0.1` SDK release and reinstalled.

New SDK helpers: `installSkillBundle` / `readSkillBundle` / `deleteSkillBundle` (staged all-or-nothing bundle replacement with rollback, ownership-set-based deletion, and empty-directory pruning), `installSingleFileAsset` / `readSingleFileAsset` / `deleteSingleFileAsset` (result-shaped single-file operations), and `validateContainedRelativePath` (pre-filesystem containment validation applied to every supplied companion path).

Every adapter implementing the previous positional contract must migrate. The first-party claude-code, opencode, and codex adapters are migrated in their matching minor releases; codex delete operations now prune emptied directories consistently with the other adapters.

Release ordering: this SDK release and the three first-party adapter releases publish `0.1` to npm **before** any `agent-facets` CLI release requires `0.1`. Until that CLI ships, existing `0.0` CLIs keep selecting the highest compatible `0.0` adapter release, so this changeset intentionally carries **no** `agent-facets` bump — the CLI change that makes `0.1` the supported set lands in a later release cycle gated on all three first-party adapters having published `facetAdapterApiVersion: 0.1`.
2 changes: 1 addition & 1 deletion docs/cli/adapters/install.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Version selectors use the Facet grammar: exact `1.2.3`, major wildcard `1.*`, mi

## Compatible resolution

Every adapter declares the adapter API contract it was built against, and each CLI release supports an exact set of adapter APIs (currently `0.0`). For npm installs, the CLI reads the package's version metadata (the `facetAdapterApiVersion` field each release publishes) and selects the **highest stable release** that both satisfies your version selector and declares a supported API:
Every adapter declares the adapter API contract it was built against, and each CLI release supports an exact set of adapter APIs (currently `0.1`, the tagged request/result contract). A CLI that supports only `0.1` treats an adapter still declaring the earlier positional `0.0` as unsupported; an older `0.0` CLI conversely keeps selecting the highest compatible `0.0` release, so the two lines advance independently. For npm installs, the CLI reads the package's version metadata (the `facetAdapterApiVersion` field each release publishes) and selects the **highest stable release** that both satisfies your version selector and declares a supported API:

- A bare name, `*`, or `latest` selects the highest compatible release — independent of npm's `latest` dist-tag.
- A wildcard selector (`1.*`, `1.2.*`) selects the highest compatible release in the range.
Expand Down
5 changes: 3 additions & 2 deletions docs/cli/adapters/list.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ Lists all installed adapters by inspecting the adapter base directory, `$FACET_D

```text
Installed adapters:
claude-code api 0.0 supported
claude-code api 0.1 supported
codex api 0.0 unsupported — reinstall: facet adapter install codex
opencode api missing unsupported — reinstall: facet adapter install opencode
my-tool api unknown broken (bundle failed to load) — reinstall: facet adapter install my-tool
```

## Output columns

- **API** — the adapter's declared adapter API (`api 0.0`), or `api missing` (no declaration — typically a bundle installed before API versioning), `api malformed ("…")` (a declaration that isn't a valid API identifier), or `api unknown` (the bundle could not be read).
- **API** — the adapter's declared adapter API (`api 0.1`), or `api missing` (no declaration — typically a bundle installed before API versioning), `api malformed ("…")` (a declaration that isn't a valid API identifier), or `api unknown` (the bundle could not be read). An adapter declaring the earlier positional `0.0` is shown as `api 0.0` with an `unsupported` status — rebuild it against a `0.1` SDK release and reinstall.
- **Status** — `supported` (usable by this CLI), `unsupported — reinstall: <command>` (the adapter's API declaration is missing, malformed, or names an API this CLI does not support), or `broken (<reason>) — reinstall: <command>` (invalid installation metadata, a missing active bundle, or a bundle that failed to load).

Listing stays available when entries are incompatible or broken — run it to find the best available `facet adapter install <specifier>` command next to each failing entry.
Expand Down
130 changes: 90 additions & 40 deletions docs/guides/custom-adapters.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,27 @@ An adapter is a small TypeScript library. You write one file, build it, and inst
> - `buildAssetMetadata(data)` (required) — validate/enrich per-asset
> manifest metadata; return `Validated<AdapterMetadata>`.
> - `supportsInstall: true` + `installAsset` / `readAsset` / `deleteAsset` —
> the filesystem I/O that materializes assets. Without these the adapter
> is metadata-only and hidden from the install picker.
> the filesystem I/O that materializes assets. Each takes a single request
> object **tagged by `assetType`** and returns a discriminated result
> (`{ ok: true, … }` or `{ ok: false, failure }`) — expected failures are
> values, never thrown. Without these the adapter is metadata-only and
> hidden from the install picker.
>
> Use the SDK's `installAssetFile` / `readAssetFile` / `deleteAssetFile`
> helpers for front-matter-aware file I/O. `Scope` is `'system' | 'user' |
> 'project'`; `AssetType` is `'skill' | 'agent' | 'command'`.
> Use the SDK's skill-bundle helpers (`installSkillBundle` /
> `readSkillBundle` / `deleteSkillBundle`) for the multi-file skill variant,
> and the single-file helpers (`installSingleFileAsset` /
> `readSingleFileAsset` / `deleteSingleFileAsset`) for agents and commands.
> `Scope` is `'system' | 'user' | 'project'`; `AssetType` is
> `'skill' | 'agent' | 'command'`.
>
> `defineAdapter()` stamps the adapter API version (`apiVersion`, currently
> `0.0`) onto the returned adapter — do NOT set it yourself; the input type
> excludes it. When publishing to npm, the published `package.json` MUST
> declare `"facetAdapterApiVersion": "0.0"` (the canonical constants live at
> `@agent-facets/adapter/api-version`) or the CLI will never select the
> release.
> `0.1`) onto the returned adapter — do NOT set it yourself; the input type
> excludes it. `0.1` identifies the tagged request/result method contract;
> the earlier positional contract (`0.0`) is unsupported by a `0.1` CLI and
> must be migrated. When publishing to npm, the published `package.json`
> MUST declare `"facetAdapterApiVersion": "0.1"` (the canonical constants
> live at `@agent-facets/adapter/api-version`) or the CLI will never select
> the release.
>
> Build with `tsdown`/`tsc`, then install:
>
Expand Down Expand Up @@ -69,25 +77,33 @@ The SDK is a leaf library with no heavy dependencies -- `@agent-facets/common`

<Step title="Write the adapter">

Create `src/index.ts` and default-export a [`defineAdapter`](https://github.com/agent-facets/facets/tree/main/packages/adapter) call. Here is a minimal, working adapter that writes each asset to `~/.my-tool/<type>s/<name>.md`:
Create `src/index.ts` and default-export a [`defineAdapter`](https://github.com/agent-facets/facets/tree/main/packages/adapter) call. Each I/O method takes a single request **tagged by `assetType`** and returns a discriminated result. Skills are multi-file bundles (a primary `SKILL.md` plus companion files); agents and commands are single files. The SDK's bundle and single-file helpers do the heavy lifting:

```ts expandable src/index.ts
import {
defineAdapter,
installAssetFile,
readAssetFile,
deleteAssetFile,
type Scope,
type AssetType,
installSkillBundle,
readSkillBundle,
deleteSkillBundle,
installSingleFileAsset,
readSingleFileAsset,
deleteSingleFileAsset,
type InstallAssetRequest,
type ReadAssetRequest,
type DeleteAssetRequest,
type SkillBundlePaths,
type Validated,
type AdapterMetadata,
} from '@agent-facets/adapter'
import { homedir } from 'node:os'
import { join } from 'node:path'

// Where an asset of a given type lives on disk for this tool.
function assetPath(assetType: AssetType, name: string): { file: string } {
return { file: join(homedir(), '.my-tool', `${assetType}s`, `${name}.md`) }
const baseDir = join(homedir(), '.my-tool')

// A skill lives in its own directory: skills/<name>/SKILL.md (+ companions).
function skillPaths(name: string): SkillBundlePaths {
const root = join(baseDir, 'skills', name)
return { root, primaryFile: join(root, 'SKILL.md'), pruneBoundary: baseDir }
}

export default defineAdapter({
Expand All @@ -102,25 +118,59 @@ export default defineAdapter({
return { ok: true, data: meta }
},

// Write the asset. `content` is the asset body; `metadata` becomes
// YAML front-matter. installAssetFile handles the front-matter assembly,
// directory creation, and idempotent overwrite for you.
async installAsset(scope: Scope, assetType: AssetType, name: string, content: string, metadata: unknown) {
return installAssetFile(assetPath(assetType, name), content, metadata as AdapterMetadata)
// Branch on request.assetType. Skills carry a companion byte map plus the
// engine-verified owned-companion path set; the bundle helper stages,
// rolls back, and prunes atomically. Agents and commands are single files.
async installAsset(request: InstallAssetRequest) {
switch (request.assetType) {
case 'skill':
return installSkillBundle(skillPaths(request.name), {
content: request.content,
metadata: request.metadata as Record<string, unknown>,
companions: request.companions,
ownedCompanionPaths: request.ownedCompanionPaths,
})
case 'agent':
return installSingleFileAsset(
{ file: join(baseDir, 'agents', `${request.name}.md`) },
request.content,
request.metadata as Record<string, unknown>,
)
case 'command':
return installSingleFileAsset(
{ file: join(baseDir, 'commands', `${request.name}.md`) },
request.content,
request.metadata as Record<string, unknown>,
)
}
},

async readAsset(scope: Scope, assetType: AssetType, name: string) {
return readAssetFile(assetPath(assetType, name))
async readAsset(request: ReadAssetRequest) {
switch (request.assetType) {
case 'skill':
return readSkillBundle(skillPaths(request.name), request.ownedCompanionPaths)
case 'agent':
return readSingleFileAsset({ file: join(baseDir, 'agents', `${request.name}.md`) }, 'agent')
case 'command':
return readSingleFileAsset({ file: join(baseDir, 'commands', `${request.name}.md`) }, 'command')
}
},

async deleteAsset(scope: Scope, assetType: AssetType, name: string) {
return deleteAssetFile(assetPath(assetType, name))
async deleteAsset(request: DeleteAssetRequest) {
switch (request.assetType) {
case 'skill':
return deleteSkillBundle(skillPaths(request.name), request.ownedCompanionPaths)
case 'agent':
return deleteSingleFileAsset({ file: join(baseDir, 'agents', `${request.name}.md`), pruneBoundary: baseDir })
case 'command':
return deleteSingleFileAsset({ file: join(baseDir, 'commands', `${request.name}.md`), pruneBoundary: baseDir })
}
},
})
```

<Note>
`installAssetFile`, `readAssetFile`, and `deleteAssetFile` are SDK helpers that manage YAML front-matter, create parent directories, and stay byte-stable across a write→read round-trip. Use them instead of hand-rolling file I/O so re-installs don't report phantom drift.
The bundle helpers (`installSkillBundle` / `readSkillBundle` / `deleteSkillBundle`) and single-file helpers (`installSingleFileAsset` / …) manage YAML front-matter, directory creation, atomic staging with rollback, and byte-stable round-trips. Skill helpers only ever touch the primary file plus the caller-supplied owned companion paths, so unowned user files are never read, deleted, or swept into a result. Use them instead of hand-rolling file I/O so re-installs don't report phantom drift.
</Note>

</Step>
Expand All @@ -136,7 +186,7 @@ Everything an adapter can implement, from [`@agent-facets/adapter`](https://gith
</ResponseField>

<ResponseField name="apiVersion" type="string">
The adapter API contract the adapter was built against — **stamped automatically by `defineAdapter()`** (currently `0.0`). You cannot set it in your definition; the input type excludes it. The CLI refuses to load adapters whose declared API it doesn't support.
The adapter API contract the adapter was built against — **stamped automatically by `defineAdapter()`** (currently `0.1`, the tagged request/result contract). You cannot set it in your definition; the input type excludes it. The CLI refuses to load adapters whose declared API it doesn't support, so an adapter built against the earlier positional contract (`0.0`) must be rebuilt against a `0.1` SDK release and reinstalled.
</ResponseField>

<ResponseField name="buildAssetMetadata(data)" type="Validated<AdapterMetadata>" required>
Expand All @@ -147,19 +197,19 @@ Everything an adapter can implement, from [`@agent-facets/adapter`](https://gith
Set to `true` only when all three I/O methods below are implemented. It makes the adapter selectable in the install picker; a metadata-only adapter omits it and stays hidden.
</ResponseField>

<ResponseField name="installAsset(scope, assetType, name, content, metadata)" type="Promise<string | undefined>">
Write the asset to disk. Return the absolute path (for verbose logs) or `undefined`.
<ResponseField name="installAsset(request: InstallAssetRequest)" type="Promise<InstallAssetResult>">
Install (or replace) an asset. `request` is tagged by `assetType`: the `skill` variant carries `content`, `metadata`, a `companions` byte map, and the engine-verified `ownedCompanionPaths`; `agent`/`command` variants carry `content` and `metadata` only. Return `{ ok: true, primaryPath }` or `{ ok: false, failure }`.
</ResponseField>

<ResponseField name="readAsset(scope, assetType, name)" type="Promise<{ content, metadata }>">
Read an asset back from disk.
<ResponseField name="readAsset(request: ReadAssetRequest)" type="Promise<ReadAssetResult>">
Read an asset back. The `skill` variant carries `ownedCompanionPaths` (read exactly those, never enumerate the directory). Return `{ ok: true, asset }` or `{ ok: false, failure }` (`failure.code === 'not-found'` when absent).
</ResponseField>

<ResponseField name="deleteAsset(scope, assetType, name)" type="Promise<string | undefined>">
Remove an asset. Return its path or `undefined`.
<ResponseField name="deleteAsset(request: DeleteAssetRequest)" type="Promise<DeleteAssetResult>">
Remove an asset. The `skill` variant carries `ownedCompanionPaths`; deletion removes the primary plus exactly those, preserving unowned files. Return `{ ok: true, existed, deletedPaths }` or `{ ok: false, failure }`.
</ResponseField>

Two shared argument types run through the I/O methods: `scope` is `'system'`, `'user'`, or `'project'` (decide your on-disk layout per scope), and `assetType` is `'skill'`, `'agent'`, or `'command'`.
Every request carries `scope` (`'system'`, `'user'`, or `'project'` decide your on-disk layout per scope) and `name`, tagged by `assetType` (`'skill'`, `'agent'`, or `'command'`). Expected failures are structured values (`not-found`, `invalid-companion-path`, `unsupported-scope`, `not-implemented`, `io-failed`), never thrown errors.

<Tip>
You can ship a **metadata-only** adapter: implement just `name` and `buildAssetMetadata`, and omit `supportsInstall`. It validates manifest config during builds without materializing anything -- useful while you're still figuring out a tool's on-disk layout.
Expand Down Expand Up @@ -224,15 +274,15 @@ Installing by bare name (`facet adapter install my-adapter`) resolves through np

```json package.json
{
"facetAdapterApiVersion": "0.0"
"facetAdapterApiVersion": "0.1"
}
```

Declare the adapter API version your SDK release stamps at runtime (currently `0.0`). The CLI skips releases where this field is missing, malformed, or unsupported — a release without it is never selected, and a field that disagrees with the runtime `apiVersion` stamped by `defineAdapter()` fails verification after download. The canonical values are exported from `@agent-facets/adapter/api-version` (`ADAPTER_API_VERSION` and `ADAPTER_API_VERSION_PACKAGE_FIELD`), so release tooling can inject the field instead of hardcoding it.
Declare the adapter API version your SDK release stamps at runtime (currently `0.1`). The CLI skips releases where this field is missing, malformed, or unsupported — a release without it is never selected, and a field that disagrees with the runtime `apiVersion` stamped by `defineAdapter()` fails verification after download. A CLI that supports only `0.1` skips releases still declaring the earlier positional `0.0`, and an older `0.0` CLI conversely keeps selecting your highest compatible `0.0` release — so publish your `0.1` release before requiring a `0.1`-only CLI. The canonical values are exported from `@agent-facets/adapter/api-version` (`ADAPTER_API_VERSION` and `ADAPTER_API_VERSION_PACKAGE_FIELD`), so release tooling can inject the field instead of hardcoding it.

## Share it upstream

Comment thread
eXamadeus marked this conversation as resolved.
Built an adapter for a major AI coding assistant? Contributions are welcome. If your adapter targets a widely used tool, open a pull request on the [`facets repository`](https://github.com/agent-facets/facets) to share it upstream as a first-party adapter.
Built an adapter for a major AI coding assistant? Contributions are welcome. If your adapter targets a widely used tool, open a pull request on the [facets repository](https://github.com/agent-facets/facets) to share it upstream as a first-party adapter.

A first-party adapter is installable by name (`facet adapter install <name>`) via the CLI, so everyone using that tool benefits.

Expand Down
Loading