# Extensions

An extension is a self-contained bundle of slash commands (and, additively,
skills) that installs into a project and registers itself with every
already-configured coding agent. This page is for **authoring** one. For the
user-facing install/list/search/remove workflow, see the
[`forge extension` CLI reference](/reference/cli#extension).

::: info There is no compile step
An extension is data, not code: an `extension.yml` manifest plus Markdown
command files (and optional skill files). `forge extension add` validates the
manifest, copies the source directory as-is into the project, and renders each
declared command into every configured agent's native command format. Nothing
is compiled — there is no `forge extension build` command and no binary
artifact produced.
:::

## Two kinds of extension

- **Manifest-based** — the standard kind, described on this page: an
  `extension.yml` plus the files it references, installed with
  `forge extension add`.
- **Agent-built** — the output of [`forge agent-build`](/reference/cli#agent-build),
  which generates a skill from a natural-language description and registers
  it under the same `.registry` index (so it shows up in
  `forge extension list`/`remove`) without ever having an `extension.yml`. It
  is out of scope for authoring by hand and not covered further here.

## Directory layout

A minimal extension source directory looks like this:

```text
my-extension/
├── extension.yml
└── commands/
    └── run.md
```

`extension.yml` must sit directly inside the source directory you pass to
`forge extension add` — it is read as `<source>/extension.yml`. Every other
path a manifest declares (`provides.commands[].file`,
`provides.skills[].path`, `provides.config[].template`) is resolved relative
to that same source directory.

This **source** directory can live anywhere on disk — a sibling folder, a
separate Git repo you're developing, `/tmp`, wherever. It has no required
location relative to the project you're installing into.

### Where it ends up

`forge extension add` copies that source directory into the **target**
project at:

```text
<root>/.forge/extensions/<id>/
```

`<root>` is resolved from `--cwd` if you pass it, otherwise it's simply the
directory you ran `forge extension add` from — **not** a discovered project
root. There is no upward search for a `.forge/` directory or a `.git` root:
running the command one level below your actual project creates a second,
sibling `.forge/extensions/` there. Always pass `--cwd <project-root>`
(or run the command from the project root itself) when you're not already
sitting in it.

Alongside `.forge/extensions/<id>/` (the copied manifest + files),
`.forge/extensions/.registry` is a single JSON file tracking every installed
extension's version, source, manifest hash, install time, enabled/disabled
state, and exactly which command files were written per agent.

## The `extension.yml` manifest

`schema_version` must currently be exactly `"1.0"` — any other value is
rejected. Unknown top-level or nested fields are **silently ignored**, not an
error, so a manifest can carry forward-compatible extra fields safely.

### `extension` block (required)

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | string | yes | Lowercase letters, digits, `-` only (`^[a-z0-9-]+$`). Used as the install directory name and registry key. |
| `name` | string | yes | Display name. |
| `version` | string | yes | `major.minor.patch` (semver-like; pre-release/build suffixes are accepted but ignored for ordering). |
| `description` | string | yes | Shown by `forge extension list`/`info`. |
| `author` | string | no | |
| `repository` | string | no | |
| `license` | string | no | |
| `homepage` | string | no | |

### `requires` block (required)

At least one of `speckit_version` or `forge_version` must be set — an empty
`requires: {}` is rejected.

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `speckit_version` | string | one of these two | A Spec-Kit `extension.yml` v1.0 constraint, kept for wire-compatibility with existing Spec-Kit extensions. Never used to gate installation on forge itself. |
| `forge_version` | string | one of these two | Gates installation on the running `forge` version (see [Version compatibility](#version-compatibility) below). |
| `tools` | list | no | Informational: each entry is `{name, version?, required?}`. Not validated or enforced by the engine. |

### `provides` block (required)

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `commands` | list | **yes, at least one** | See below. |
| `skills` | list | no | forge-additive (not part of the original Spec-Kit schema): `{name, path?, description?}`. |
| `config` | list | no | `{name, template?, description?, required?}`. |

**`provides.commands[]`**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | string | yes | Must match `^(speckit\|forge)\.[a-z0-9-]+\.[a-z0-9-]+$` — e.g. `forge.my-extension.run` or the legacy `speckit.<ext>.<cmd>` form. |
| `file` | string | yes | Path to the command's Markdown file, relative to the extension source directory. Must not be absolute and must not contain a `..` segment that escapes the source directory. |
| `description` | string | no | |
| `aliases` | list of string | no | Extra command names the same file is also registered under. Each alias must be a bare filename token — lowercase letters, digits, `.`, `_`, `-` only. No `/`, no leading `/`, so an alias can never write outside the target commands directory. |

`provides.skills[].path` and `provides.config[].template` are validated with
the same relative-path/no-traversal rule as `commands[].file`.

### Top-level `hooks` and `tags`

- `hooks` is a map of event name (e.g. `after_tasks`) to
  `{command, optional?, prompt?, description?, condition?}`. It is parsed and
  stored, but **nothing currently executes a hook** — there is no lifecycle
  dispatch that looks a command up here and runs it. Treat it as reserved.
- `tags` is a list of strings used for catalog search/discovery.
- `defaults` is an arbitrary map, round-tripped through the manifest for a
  future config-merge feature; no merge logic is wired up today.

### Path-traversal validation

Every manifest-supplied path (`commands[].file`, `commands[].aliases[]`,
`skills[].path`, `config[].template`) is checked before the extension is ever
installed: an absolute path, a `..` segment that would escape the extension's
own directory, or (for aliases) any path separator at all, fails validation
with a parse error. This is enforced twice — once in the manifest parser and
again at the point each path is actually read from or written to disk.

## A complete example

```yaml
schema_version: "1.0"

extension:
  id: "my-extension"
  name: "My Extension"
  version: "1.0.0"
  description: "Adds a /forge.my-extension.run command"
  author: "Your Name"
  repository: "https://github.com/you/my-extension"
  license: "MIT"

requires:
  forge_version: ">=1.0.0,<2.0.0"

provides:
  commands:
    - name: "forge.my-extension.run"
      file: "commands/run.md"
      description: "Run the extension's main command"
      aliases: ["my-ext-run"]

tags:
  - "example"
```

With the referenced command file:

```markdown
---
description: Run the extension's main command
---

Run: $ARGUMENTS
```

This mirrors the shape of the fixtures forge-kit's own test suite validates
against (`internal/extensions/testdata/compatible-extension`,
`internal/extensions/testdata/speckit-v1-extension`).

## Commands: how they get registered

`forge extension add` renders each `provides.commands[]` entry (and each of
its aliases) into the native command format of **every coding agent that is
already configured in the project** — detected by whether that agent's
top-level directory (e.g. `.claude/`, `.cursor/`) already exists at the repo
root. An agent with no existing directory is skipped entirely; run
`forge init` (or otherwise configure the agent) before installing an
extension you expect it to pick up commands for.

The rendering does three things to each command's Markdown source:

1. Splits YAML frontmatter (`description: ...`) from the Markdown body.
2. Replaces `$ARGUMENTS` in the body with the target agent's own placeholder
   token (most agents also use `$ARGUMENTS`; Gemini CLI and Qwen Code use
   `{{args}}`).
3. Appends an HTML-comment footer recording which extension the command came
   from and where its config lives:
   `<!-- Extension: <id> -->` / `<!-- Config: .forge/extensions/<id>/ -->`.

Markdown-format agents (Claude Code, Cursor, Copilot, Windsurf, and others)
get the rendered frontmatter + body written as `<name>.md`. TOML-format
agents (Gemini CLI, Qwen Code) get a `description = "..."` / `prompt = """..."""`
TOML file instead.

::: warning Command registration covers 16 agents, not forge's full 25
Command registration uses a fixed, narrower agent table ported from Spec-Kit.
Nine agents forge otherwise supports — Codex, Antigravity, Kimi, Kiro IDE,
OpenClaw, Pi, Trae IDE, Hermes, and the generic adapter — do not currently get
extension commands registered even if their directory exists in the repo.
:::

If a command file it needs to write already exists (created by something
other than this extension), its prior content is captured before being
overwritten and restored automatically if you later `forge extension remove`
the extension — the extension's own footprint is removed, and whatever it
clobbered is put back rather than deleted.

## Installing

```sh
forge extension add ./my-extension
```

`forge extension add <arg>` accepts three forms, checked in this order:

1. **A URL** (contains `://`) — downloaded as a zip archive and extracted,
   bypassing the catalog entirely.
2. **An existing local directory** — used as-is. This is the form to use
   while developing an extension.
3. **Anything else** — looked up as a catalog ID (see
   [Distribution](#distribution) below).

On success it prints:

```text
installed extension "my-extension" (My Extension) v1.0.0
```

The extension is copied into `.forge/extensions/<id>/`, its commands are
registered for every configured agent, and an entry is written to
`.forge/extensions/.registry` recording its version, source, a
`sha256:<hex>` hash of the installed manifest, install timestamp, and exactly
which command files were written per agent (so `forge extension remove` can
undo precisely that).

Re-adding an already-installed ID fails with
`extension "<id>" is already installed; remove it first`.

## Local development workflow

There is no `--dev`/watch/symlink mode — `Install` always does a full byte
copy of the source directory (`internal/extensions/engine.go`'s `copyDir`),
and `forge extension update` only works against a catalog entry (see
[Version compatibility](#version-compatibility) note below), so a local
extension can't self-update. The loop is: edit source → remove → add.

1. **Scaffold the source directory** anywhere on disk:

   ```sh
   mkdir -p my-extension/commands
   ```

2. **Write `extension.yml`** at `my-extension/extension.yml` (see
   [the manifest reference](#the-extension-yml-manifest) and
   [complete example](#a-complete-example) above).

3. **Write each command file** it references, e.g.
   `my-extension/commands/run.md`.

4. **Install it into the project you want to test it in**, passing an
   explicit `--cwd` if you're not standing in that project's root:

   ```sh
   forge extension add ./my-extension --cwd /path/to/target-project
   ```

5. **Confirm what actually landed** — the copied files, the registry entry,
   and which agents picked up the command:

   ```sh
   forge extension info my-extension --cwd /path/to/target-project
   ```

   Cross-check the rendered command file directly, e.g.
   `/path/to/target-project/.claude/commands/forge.my-extension.run.md`, to
   see the exact frontmatter/body/footer your agent will load.

6. **Exercise the command** in the target agent (e.g. run
   `/forge.my-extension.run` in Claude Code) to confirm it behaves as
   expected.

7. **Iterate**: edit the source files under `my-extension/`, then reinstall —
   there's no in-place update for a local install:

   ```sh
   forge extension remove my-extension --cwd /path/to/target-project
   forge extension add ./my-extension --cwd /path/to/target-project
   ```

8. **Bump `extension.version`** in the manifest once the extension is stable
   enough to distribute (see [Distribution](#distribution)) — the version
   string is display-only for a local install (nothing compares it), but it's
   what `forge extension update` and `forge extension info` will show
   consumers once you publish it.

## Version compatibility

`requires.forge_version` is a comma-separated list of AND'd clauses against
`major.minor.patch`, e.g. `>=1.0.0,<2.0.0`. Supported operators: `>=`, `<=`,
`>`, `<`, `==`/`=`, `!=`.

- **Set and not satisfied** — install fails with:
  `extension "<id>" requires forge <constraint>, but <version> is installed`
- **Malformed constraint** — install fails with:
  `extension "<id>" has invalid requires.forge_version "<constraint>": <parse error>`
- **Unset** (an extension that only declares `requires.speckit_version`) —
  accepted unconditionally; a forge version is never compared against a
  Spec-Kit constraint.
- **Running forge build has no known version** (an unbuilt `go run`/dev
  build, where the version resolves to `"dev"`) — the check is skipped and
  the extension is accepted regardless of its constraint.

## Distribution

Today there are exactly two ways to distribute an extension: a **local
directory** (`forge extension add ./path`) or a **downloadable zip at a URL**
(`forge extension add https://.../my-extension.zip`, or a catalog entry whose
`download_url` points at one). The zip is extracted and `extension.yml` is
located either at the archive root or its first immediate subdirectory
(matching how GitHub's own "Download ZIP" wraps a repo in a single
`<repo>-<ref>/` folder).

`forge extension list|search|info` read from an **embedded** catalog snapshot
compiled into the `forge` binary — no network access is required for those to
work. No project- or maintainer-facing process for submitting an extension to
that catalog exists in the codebase today; distributing an extension outside
your own project currently means sharing a URL or a Git repository people
`add` directly.

## Referencing an extension in `.forge/config.yaml`

`.forge/config.yaml` has its own, separate `extensions:` list:

```yaml
extensions:
  - id: my-extension
    version: 1.0.0
```

This is a plain `{id, version}` record, merged across config layers and shown
by `forge config explain` — it is **not** what actually installs, enables, or
removes anything. The source of truth for what's really on disk is
`.forge/extensions/.registry`, maintained entirely by
`forge extension add|remove|enable|disable`. Nothing was found that
auto-installs an extension from this config list; treat it as a declarative
note of intent, not a trigger. See the
[Configuration guide's `extensions` section](/guide/configuration#extensions)
for the full config schema.

## See also

- [`forge extension` CLI reference](/reference/cli#extension) — the
  list/add/remove/search/info/update/enable/disable subcommands.
- [Configuration guide](/guide/configuration#extensions) — the
  `extensions:` config key.
