Skip to content
Download PDF

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.

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, 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) ​

FieldTypeRequiredNotes
idstringyesLowercase letters, digits, - only (^[a-z0-9-]+$). Used as the install directory name and registry key.
namestringyesDisplay name.
versionstringyesmajor.minor.patch (semver-like; pre-release/build suffixes are accepted but ignored for ordering).
descriptionstringyesShown by forge extension list/info.
authorstringno
repositorystringno
licensestringno
homepagestringno

requires block (required) ​

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

FieldTypeRequiredNotes
speckit_versionstringone of these twoA 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_versionstringone of these twoGates installation on the running forge version (see Version compatibility below).
toolslistnoInformational: each entry is {name, version?, required?}. Not validated or enforced by the engine.

provides block (required) ​

FieldTypeRequiredNotes
commandslistyes, at least oneSee below.
skillslistnoforge-additive (not part of the original Spec-Kit schema): {name, path?, description?}.
configlistno{name, template?, description?, required?}.

provides.commands[]

FieldTypeRequiredNotes
namestringyesMust match ^(speckit|forge)\.[a-z0-9-]+\.[a-z0-9-]+$ — e.g. forge.my-extension.run or the legacy speckit.<ext>.<cmd> form.
filestringyesPath 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.
descriptionstringno
aliaseslist of stringnoExtra 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 ).
  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.

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 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 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 and 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) — 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 for the full config schema.

See also ​