# Publishing extensions

> Scaffold, check, pack and publish an extension with the zidane extension command, install it from GitHub or npm, and pin it in a config file.

An extension is a folder with an `index.ts`. To share one, push the folder to GitHub or publish it to npm. The apps install from both. `zidane extension` scaffolds a new extension, runs its tests, checks and packs it, and publishes it to npm.

## A minimal flow

```sh
zidane extension create deploy-board --ui --tui
cd deploy-board
zidane extension test
zidane extension pack
zidane extension publish --dry-run
zidane extension publish --access public
```

`create` writes a working extension with a test. `pack` checks it the way an install would. `publish` runs the same checks, then `npm publish`.

## `zidane extension`

| Command | Flags | Does |
| - | - | - |
| `create <name>` | `--ui`, `--tui`, `--no-test`, `--dir <path>`, `--scope user\|project` | Writes `index.ts`, `index.test.ts`, `package.json`, `tsconfig.json` and a README into `./<name>`. `--ui` adds `ui.tsx`, `--tui` adds `tui.tsx`. `--scope` writes it where the apps load extensions instead (`~/.zidane/extensions` or `<repo>/.zidane/extensions`), and cannot go with `--dir` |
| `test [dir]` | `-- <bun test args>` | Runs `bun test` in the folder, with `zidane` mapped to the running zidane ([testing](https://zidane.si/docs/extensions-testing.md#zidane-extension-test)) |
| `pack [dir]` | `--out <path>`, `--tarball`, `--no-setup` | Checks the extension and copies what it ships into `<dir>/.pack/<id>`. `--tarball` also writes an npm tarball |
| `publish [dir]` | `--dry-run`, `--tag <tag>`, `--access public\|restricted`, `--no-setup`, `-- <npm args>` | The `pack` checks, then `npm publish` in the folder |

A name must start with a lowercase letter, then lowercase letters, digits and single dashes: it becomes the extension's `id`. The apps' Settings also create extensions, and open a session with the `zidane-extension` skill active so the agent knows the API ([create from the apps](https://zidane.si/docs/extensions.md#create-one-from-the-apps)).

### What `pack` checks

| Check | Fails on |
| - | - |
| The definition | No entry file, or a definition that does not validate (`apiVersion: 1`, a valid `id`, valid settings) |
| The folder name | Warns when it differs from the `id` |
| `ui.tsx` | A bundle error, such as a refused import ([components](https://zidane.si/docs/extensions-components.md#imports)) |
| `setup` | Runs it in a [test host](https://zidane.si/docs/extensions-testing.md). It must reach `active`. Every `Webview` with a `file` source must point inside the folder, at a file that exists. Errors it logs become warnings. `--no-setup` skips this |
| `package.json` | Warns on a missing `name`, a `version` that differs from the definition's, a missing `zidane-extension` keyword, a `files` list that leaves out source files |

The copy follows `files` in `package.json` (paths, folders, `*` patterns and `!` exclusions). It never copies `node_modules`, `.git`, `.pack`, lock files or tests. Without a `package.json`, `pack` writes one.

`publish` also refuses a package with no `package.json`, `private: true`, no `name` or `version`, or a `version` that differs from the definition's. Its exit code is npm's.

## `package.json`

The scaffold writes:

```json title="package.json"
{
  "name": "deploy-board",
  "version": "0.1.0",
  "description": "A zidane extension.",
  "type": "module",
  "keywords": ["zidane", "zidane-extension"],
  "main": "./index.ts",
  "files": ["index.ts", "ui.tsx", "tui.tsx", "README.md"],
  "scripts": {
    "test": "zidane extension test",
    "pack": "zidane extension pack",
    "publish:extension": "zidane extension publish"
  },
  "peerDependencies": { "zidane": ">=X.Y.0" }
}
```

`X.Y` is the major and minor version of the zidane that wrote the scaffold, so that zidane and every later one load it. `zidane extension pack` writes the same range when the folder has no `package.json`.

Ship the TypeScript sources. The apps compile them when they load the extension: no build step.

Keep `zidane` in `peerDependencies`, never in `dependencies`: each app hands the extension its own copy. Packages the extension needs go in `dependencies`. The install puts them in the extension's own `node_modules`.

### The `zidane` field

Add a `zidane` field so the extension browsers in Settings can list it with a name, a line of text and an icon. The browsers read this field and never run your code, so without it a row shows only the folder name.

```json title="package.json"
{
  "zidane": {
    "displayName": "Deploy board",
    "description": "Shows the deploys of this project.",
    "icon": { "gui": "i-ph-rocket" }
  }
}
```

| Key | Means |
| - | - |
| `displayName` | The name on the row |
| `description` | One short line under the name |
| `icon` | `{ gui?, tui? }`, like the definition's [`icon`](https://zidane.si/docs/extensions.md#the-definition): an icon class for the desktop and web apps, a glyph for the terminal app |

Keep these values the same as the definition's. Once the extension loads, the apps show what the definition says. An `extension.json` file with the same keys works too.

### The zidane version range

`peerDependencies.zidane` says which zidane the extension is made for. The apps read it before they load the module:

| Running zidane | Result |
| - | - |
| In the range | Loads |
| Older than the range | Does not load. Settings shows "Needs zidane `<range>`", and an install refuses it |
| Newer than the range | Loads, with a warning on its row in Settings |
| No range, or one that does not parse | Loads, with no check |

The range takes npm syntax: `>=10.0.0`, `^10.2`, `~10.1.0`, `10.x`, `>=10 <12`, `||`. Raise the floor when you start using a newer API.

## Install

Both apps install extensions from Settings: Settings › Extensions & Plugins in the terminal app, Settings › Extensions in the desktop app ([install](https://zidane.si/docs/extensions.md#install)).

| Source | Means |
| - | - |
| `owner/repo` | A GitHub repository, its default branch |
| `github:owner/repo@v1.2.0` or `owner/repo#main` | A tag or a branch |
| `owner/repo#main:packages/ext` | A folder in the repository. A pasted GitHub `/tree/<ref>/<path>` URL works too |
| `owner/repo name` | One extension of a repository that holds several under `extensions/<name>/` |
| `pkg`, `pkg@1.2.0`, `@scope/pkg@next` | An npm package, an exact version, or a dist-tag |

The install then:

1. Fetches the source into a temporary folder: a shallow `git clone`, or the tarball when `git` is missing, or the npm tarball.
2. Installs `dependencies` with the first of bun, npm, pnpm or yarn on the `PATH`. Install scripts never run.
3. Imports the extension and validates it. An extension that does not validate, or one for a newer zidane, installs nothing.
4. Copies it to `<scope>/extensions/<id>/`, named after its `id`, and writes `.install.json` with the source and the commit for updates.

The scope is you (`~/.zidane/extensions/`) or this project (`<project>/.zidane/extensions/`, loaded only with [project trust](#project-trust)). Installing over an existing extension is all or nothing: a failure puts the old one back.

A repository with one extension holds `index.ts` at its root. A repository with several holds `extensions/<name>/index.ts` each, like the zidane repository's own [native extensions](https://zidane.si/docs/extensions.md#native-extensions).

### Private repositories

An extension can live in a private GitHub repository. Only people with access to the repository can list it and install it.

- **List.** The browser reads the repository with your GitHub login: `gh auth login`, or `GITHUB_TOKEN`. Without a login, GitHub answers that the repository does not exist, so the browser says that it may be private.
- **Install.** The install clones with `git`, and `git` does not read `GITHUB_TOKEN`. Run `gh auth login` and then `gh auth setup-git`, or add an SSH key to your GitHub account. Without `git`, only a public repository installs.

On a server, these steps apply to the server's user, not to you.

## Declare extensions in a config file

An `extensions` array in a zidane config file declares extensions and their settings. The user file is `~/.zidane/config.json`. The project file is `<project>/.zidane/config.json`.

```json title=".zidane/config.json"
{
  "extensions": [
    { "source": "owner/repo" },
    { "source": "github:owner/repo@v1.2.0:extensions/goal", "settings": { "model": "opus" } },
    { "source": "owner/collection name", "enabled": false },
    { "source": "@scope/pkg@1.0.0" },
    { "source": "./tools/my-extension" }
  ]
}
```

| Field | Type | Does |
| - | - | - |
| `source` | `string` | Required. An install source from the table above, with its version or ref in it. Or a local path (`./`, `../`, `/`, `~/`, `file:`), loaded in place and never copied |
| `extension` | `string` | Picks one extension of a collection. Same as a trailing word in `source` |
| `settings` | `{ [key]: string \| number \| boolean \| string[] }` | Values for the extension's settings. Each one shows locked in Settings. Secret settings are refused here: they belong in the auth store |
| `enabled` | `boolean` | `false` keeps the extension installed but not loaded. Default `true` |

A bad entry is dropped with an error. An unknown field, key or value is dropped with a warning. When two entries name the same source, the first one counts.

### When the apps apply it

The app that runs your extensions applies the file: the daemon, or the terminal and desktop apps when they run without it. It does this in the background, so the app starts at its usual speed. It applies the file again each time you save it.

- A new remote entry is installed into `~/.zidane/extensions` (`<project>/.zidane/extensions` for the project file) and loads. A removed entry is uninstalled, if the lock says the config file installed it.
- A local path loads in place. Removing the entry unloads it and leaves the folder alone.
- `enabled: false` keeps the extension installed and loads it turned off.
- The project file applies only when "Load project extensions" is on: installing runs the extension's code.
- An entry that fails shows in Settings › Extensions with the reason, under its `source`. Without a network, an entry already installed keeps its version and its lock entry.

The apps do not update a locked entry by themselves: change its version or ref in `source` to move it.

### The lock file

`extensions.lock.json` sits next to each config file, so a project can commit its own. It records what each entry resolved to:

```json title=".zidane/extensions.lock.json"
{
  "lockfileVersion": 1,
  "extensions": {
    "github:owner/repo:extensions/goal": {
      "source": "github:owner/repo@v1.2.0:extensions/goal",
      "id": "goal",
      "resolved": "github:owner/repo@v1.2.0",
      "commit": "4f1c2d9",
      "integrity": "sha256-…",
      "installedAt": "2026-10-20T09:12:00.000Z"
    }
  }
}
```

An entry's key is its source without the version, so changing a version updates the same entry. A locked entry installs its pinned version until its `source` changes. `integrity` hashes the installed files.

## Project trust

Extensions in a project folder (`<project>/.zidane/extensions/`, `<project>/.agents/extensions/`) run code from the repository you opened. They stay off until you turn on "Load project extensions" in Settings › Agent, or set `ZIDANE_TRUST_PROJECT_EXTENSIONS=1`. Until then each one shows in Settings with the reason. Extensions in your home folder always load.

An extension asks for no permissions. Once loaded, it can do what your user can do. Install only what you would run yourself.

## Where the apps look

The apps load `extensions/<id>/index.ts` (or `.tsx`, `.mjs`, `.js`) from `<project>/.agents/`, `<project>/.zidane/`, `~/.agents/` and `~/.zidane/`, in that order. The first folder for an id wins. `ZIDANE_EXTENSION_DIRS` adds more folders. The folder name must equal the definition's `id`.

An optional `extension.json`, or a `zidane` field in `package.json`, may give `apiVersion`, `name`, `displayName`, `description` and `icon` ([the `zidane` field](#the-zidane-field)). The apps read it before the module: an `apiVersion` other than `1` there refuses the extension without running it.

## Pitfalls

- A `version` in `package.json` that differs from the definition's blocks `publish`. Bump both.
- `zidane` in `dependencies` installs a second copy that the apps ignore, and slows every install. Use `peerDependencies`.
- A dependency that needs an install script does not get it. Ship prebuilt files, or pick a package without one.
- A folder named differently from the `id` is refused at load. `pack` warns about it, the install names the folder for you.
- Do not leave `.ts` files the extension writes at run time in its folder: the apps reload the extension when its sources change. Write data under [`ctx.storage.path`](https://zidane.si/docs/extensions-storage.md).
- Without a `peerDependencies.zidane` range, a user on an older zidane gets an extension that fails at load instead of a clear message.
