Skip to content

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#

Terminal
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#

CommandFlagsDoes
create <name>--ui, --tui, --no-test, --dir <path>, --scope user|projectWrites 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-setupChecks 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#

CheckFails on
The definitionNo entry file, or a definition that does not validate (apiVersion: 1, a valid id, valid settings)
The folder nameWarns when it differs from the id
ui.tsxA bundle error, such as a refused import (components)
setupRuns 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.jsonWarns 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:

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.

package.json
{
  "zidane": {
    "displayName": "Deploy board",
    "description": "Shows the deploys of this project.",
    "icon": { "gui": "i-ph-rocket" }
  }
}
KeyMeans
displayNameThe name on the row
descriptionOne 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 zidaneResult
In the rangeLoads
Older than the rangeDoes not load. Settings shows "Needs zidane <range>", and an install refuses it
Newer than the rangeLoads, with a warning on its row in Settings
No range, or one that does not parseLoads, 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).

SourceMeans
owner/repoA GitHub repository, its default branch
github:owner/repo@v1.2.0 or owner/repo#mainA tag or a branch
owner/repo#main:packages/extA folder in the repository. A pasted GitHub /tree/<ref>/<path> URL works too
owner/repo nameOne extension of a repository that holds several under extensions/<name>/
pkg, pkg@1.2.0, @scope/pkg@nextAn 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). 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, 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.

.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" }
  ]
}
FieldTypeDoes
sourcestringRequired. 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
extensionstringPicks 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
enabledbooleanfalse 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:

.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 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.
  • Without a peerDependencies.zidane range, a user on an older zidane gets an extension that fails at load instead of a clear message.