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#
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 publiccreate 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) |
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).
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) |
setup | Runs it in a test host. 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:
{
"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.
{
"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: 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).
| 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:
- Fetches the source into a temporary folder: a shallow
git clone, or the tarball whengitis missing, or the npm tarball. - Installs
dependencieswith the first of bun, npm, pnpm or yarn on thePATH. Install scripts never run. - Imports the extension and validates it. An extension that does not validate, or one for a newer zidane, installs nothing.
- Copies it to
<scope>/extensions/<id>/, named after itsid, and writes.install.jsonwith the source and the commit for updates.
The scope is you (~/.zidane/extensions/) or this project (<project>/.zidane/extensions/, loaded only with 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.
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, orGITHUB_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, andgitdoes not readGITHUB_TOKEN. Rungh auth loginand thengh auth setup-git, or add an SSH key to your GitHub account. Withoutgit, 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.
{
"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/extensionsfor 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: falsekeeps 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:
{
"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 apps read it before the module: an apiVersion other than 1 there refuses the extension without running it.
Pitfalls#
- A
versioninpackage.jsonthat differs from the definition's blockspublish. Bump both. zidaneindependenciesinstalls a second copy that the apps ignore, and slows every install. UsepeerDependencies.- 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
idis refused at load.packwarns about it, the install names the folder for you. - Do not leave
.tsfiles the extension writes at run time in its folder: the apps reload the extension when its sources change. Write data underctx.storage.path. - Without a
peerDependencies.zidanerange, a user on an older zidane gets an extension that fails at load instead of a clear message.