Okibi can publish each official release of your CLI to npm and Homebrew. Your users install it the way they install other tools, and they get the same build as the Okibi installer:
npm install -g @acme/my-cli brew install acme/tap/my-cli
Everything is published from your own GitHub account under names you own. npm trusts a workflow in your CLI's repository, and the Homebrew formula lives in your own tap, so there is no npm token for you to store or rotate.
Before you start
- Your code is in your GitHub account. Okibi can't publish from an Okibi-hosted repository, because npm ties each package to the repository that publishes it. To move the code, open Settings > Where it lives, click Move to GitHub next to Code, and pick Your GitHub.
- The official release is public. npm and Homebrew are public, so they only get a release whose Access is Anyone with the install line (Pro plan). See Releases and install.
- For npm: an npm account, an npm organization if you want a scoped name like
@acme/my-cli(organizations are free for public packages), and npm 11.15 or later on your machine.
Only workspace owners and admins can change these settings.
Turn it on
- Open the project's Settings. In Where it lives, click Manage next to npm and Homebrew.
- Check the Description and Homepage. They show on npm and in
brew info. An empty homepage uses your repository. - Turn on Publish to npm and enter the Package name, for example
@acme/my-cliormy-cli. - Turn on Publish to Homebrew.
- Click Save.
Homebrew needs nothing else. npm needs the one-time setup below.
Set up npm (once)
-
Merge the setup pull request. Okibi opens it in your CLI's repository. It adds three files and nothing else, so merging it doesn't release a new version:
.github/workflows/release-npm.yml: the release workflow.github/npm/package.mjs: turns a release's binaries into npm packages.github/npm/config.json: your package name and the key that verifies each release
-
Publish the first version from your terminal. npm can only trust a workflow for a package that already exists, so the first publish uses your own npm login:
npm login okibi clis npm setup my-cli
This publishes the current official version, then tells npm to trust the release workflow for each package. npm may ask you to confirm with your second factor. New packages can take a few minutes to appear on npm, so the command waits for them. If it stops waiting first, run
okibi clis npm setup my-cli --skip-publisha little later to finish; it doesn't publish or ask for your second factor again. -
Allow rollbacks. On npmjs.com, open
@acme/my-cli(the package without a platform suffix), go to Settings, and turn on Allow npm dist-tag for its trusted publisher. Rolling back moves npm'slatesttag, which npm only lets the workflow do with this setting, and npm's command line can't turn it on yet.
The npm and Homebrew window shows On npm once it's done. From then on, each official release reaches npm within a few minutes, with no step from you.
Each release is seven packages: @acme/my-cli, which provides the my-cli command, plus one binary package per platform (@acme/my-cli-darwin-arm64, @acme/my-cli-linux-x64, and so on). npm installs only the one that matches the user's machine, so installing with --omit=optional leaves the CLI without its binary.
Trusted publishing adds the workflow as a way to publish; it doesn't remove the others. Your own npm login and tokens can still publish, and so can anyone who can run a workflow named release-npm.yml in your repository, which includes people with write access and the Okibi publisher app. To make the workflow the only way, open each package's Settings on npmjs.com and choose Require two-factor authentication and disallow tokens. Trusted publishing keeps working with that setting.
Homebrew
The first time it publishes, Okibi creates a public homebrew-tap repository in your GitHub account. For each release it uploads the macOS and Linux archives to that repository and updates Formula/my-cli.rb. Your users run:
brew install acme/tap/my-cliShare the full name. Homebrew 7 only loads formulae from taps you trust, and installing by full name trusts that one formula.
If your organization doesn't let apps create public repositories, create homebrew-tap yourself as a public repository, add it to the Okibi publisher app's repositories, and click Check again.
What happens on each release
Okibi follows the official release. Whenever it changes (a merged release pull request, a version you choose, a skill or endpoint update), npm and Homebrew get it within a few minutes. The window shows each channel's status.
- Rollbacks. Choosing an older official version moves npm's
latestback to it and rewrites the formula, sonpm updateandbrew upgrademove users back too. A version npm or the tap already has is reused as it is. One they never got (for example, a release from before you turned publishing on) is published first. - When a CLI stops qualifying. If its audience stops being public, or its code moves to an Okibi-hosted repository, Okibi stops publishing. npm and Homebrew keep serving the last version they got. The window still shows each channel and lets you turn it off.
- Repairs. When Okibi repairs a release, it releases the fix as the next patch version, which npm and Homebrew get like any other release. If a release is rebuilt under the same version, they keep the build they first got, since a published version is never replaced, and the window says so.
- Commits to main. A commit you push to your CLI's default branch ships with the next release.
When something fails
The window says what failed and what fixes it, with a link to the workflow run. The person who turned publishing on also gets an email. Okibi retries on its own unless the fix is a setting on npmjs.com; then click Check again once it's changed.
| What you see | What to do |
|---|---|
| npm turned down the release workflow | Check that the package's trusted publisher is your repository with release-npm.yml, or run okibi clis npm setup again. |
| npm still serves the newer version after a rollback | Turn on Allow npm dist-tag for the package's trusted publisher on npmjs.com (step 3 of the setup). |
| The package name belongs to someone else | Choose another name and save. |
| Approve the new Actions permission | Click Approve on GitHub. Okibi needs to read workflow runs to tell you why a release failed. |
| The tap has a formula Okibi didn't write | Rename or remove that formula, then click Check again. |
Run your own pipeline instead
You can publish with your own workflow instead. This needs the code in your own GitHub account; an Okibi-hosted repository can't hold your secrets.
Okibi pushes a vX.Y.Z tag to your repository when it releases a merged release pull request or an endpoint change. Save this workflow as .github/workflows/package-managers.yml. It waits until that version is the public official release, then downloads its archives and checks every checksum. Set the repository variable OKIBI_CLI_ID to the cli.id that okibi clis hosting status my-cli --json prints.
name: Package managers on: push: tags: ['v*'] workflow_dispatch: inputs: version: description: Official version, X.Y.Z required: true permissions: contents: read id-token: write jobs: publish: runs-on: ubuntu-latest env: TAG: ${{ inputs.version || github.ref_name }} UPDATE_URL: https://okibi.ai/i/cli/${{ vars.OKIBI_CLI_ID }} steps: - uses: actions/checkout@v5 - name: Wait for the official release id: official run: | version=${TAG#v} for _ in $(seq 1 6); do url=$(curl -fsSL -o latest.json -w '%{url_effective}' \ "$UPDATE_URL" || true) current=$(jq -r .releaseVersion latest.json 2>/dev/null || true) if [ "$current" = "$version" ]; then echo "version=$version" >> "$GITHUB_OUTPUT" echo "base=${url%/latest.json}" >> "$GITHUB_OUTPUT" exit 0 fi sleep 20 done echo "::notice::$version isn't the official release; skipping." - name: Download the release archives if: steps.official.outputs.version != '' env: BASE: ${{ steps.official.outputs.base }} run: | mkdir -p release mv latest.json release/ jq -r '.artifacts[] | "\(.url) \(.sha256)"' release/latest.json | while read -r path sum; do mkdir -p "release/$(dirname "$path")" curl -fsSL -o "release/$path" "$BASE/$path" echo "$sum release/$path" | sha256sum -c - done # Your publishing steps go here, using release/ and # steps.official.outputs.version.
Skill-only updates don't push a tag. Run the workflow by hand for those, with the version from okibi clis hosting status my-cli.
Publish to npm yourself
Save this script as scripts/npm-packages.mjs. It turns a release into one package per platform, each limited to its os and cpu so npm installs only the matching one, plus a launcher that depends on all six at the exact version and runs the right binary:
// Turn an Okibi release into npm packages: one per platform, plus a launcher. // node scripts/npm-packages.mjs <release folder or update URL> <out> <package> import { execFileSync } from 'node:child_process' import { createHash } from 'node:crypto' import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' const [source, out, name] = process.argv.slice(2) const remote = /^https?:/.test(source) const base = remote ? (await fetch(source)).url.replace(/\/latest\.json$/, '') : source const read = async (path) => remote ? Buffer.from(await (await fetch(`${base}/${path}`)).arrayBuffer()) : readFileSync(join(source, path)) const manifest = JSON.parse(await read('latest.json')) const { binaryName: cli, releaseVersion: version } = manifest const repo = process.env.GITHUB_REPOSITORY const repository = repo && { type: 'git', url: `git+https://github.com/${repo}.git`, } const write = (dir, pkg) => { mkdirSync(join(out, dir, 'bin'), { recursive: true }) const json = JSON.stringify({ ...pkg, version, repository }, null, 2) writeFileSync(join(out, dir, 'package.json'), json) } const platforms = {} for (const { platform, url, sha256 } of manifest.artifacts) { const archive = await read(url) const sum = createHash('sha256').update(archive).digest('hex') if (sum !== sha256) throw new Error(`${url}: checksum mismatch`) // Okibi says windows where npm says win32. const [os, cpu] = platform.replace(/^windows/, 'win32').split('-') const dir = `${os}-${cpu}` platforms[`${name}-${dir}`] = version write(dir, { name: `${name}-${dir}`, os: [os], cpu: [cpu], files: ['bin'] }) const bin = join(out, dir, 'bin') execFileSync('tar', ['-xz', '--exclude', '.okibi-*', '-C', bin], { input: archive, }) } const LAUNCHER = `#!/usr/bin/env node const { dirname, join } = require('node:path') const { spawnSync } = require('node:child_process') const { name, bin } = require('../package.json') const cli = Object.keys(bin)[0] const pkg = name + '-' + process.platform + '-' + process.arch let dir try { dir = dirname(require.resolve(pkg + '/package.json')) } catch { console.error(cli + ': ' + pkg + ' is missing; reinstall it with optional' + ' dependencies') process.exit(1) } const exe = join(dir, 'bin', process.platform === 'win32' ? cli + '.exe' : cli) const off = cli.toUpperCase().replace(/[^A-Z0-9]+/g, '_') + '_NO_AUTO_UPDATE' const run = spawnSync(exe, process.argv.slice(2), { stdio: 'inherit', env: { ...process.env, [off]: '1' }, }) process.exit(run.status ?? 1) ` // The launcher runs the binary for this machine. npm owns the install, so // the binary's own updater stays off. write('launcher', { name, bin: { [cli]: 'bin/cli.js' }, files: ['bin'], optionalDependencies: platforms, }) writeFileSync(join(out, 'launcher', 'bin', 'cli.js'), LAUNCHER, { mode: 0o755 }) console.log(`Prepared ${name}@${version}`)
Replace the comment at the end of the workflow with these steps. They publish the platform packages first and the launcher last, so the launcher never points at a binary that isn't on npm yet, and skip versions npm already has, so a rerun is safe:
- uses: actions/setup-node@v5 if: steps.official.outputs.version != '' with: node-version: 24 registry-url: https://registry.npmjs.org - name: Publish to npm if: steps.official.outputs.version != '' env: VERSION: ${{ steps.official.outputs.version }} run: | npm install --global npm@latest node scripts/npm-packages.mjs release npm @acme/my-cli for dir in npm/*-*/ npm/launcher/; do name=$(jq -r .name "$dir/package.json") # No token: setup-node's placeholder would fail the lookup. NODE_AUTH_TOKEN='' npm view "$name@$VERSION" version \ >/dev/null 2>&1 && continue npm publish "$dir" --access public --tag latest done
npm can only trust a workflow for a package that already exists, so publish the first version from your machine, then trust the workflow for each of the seven packages:
npm login export GITHUB_REPOSITORY=acme/my-cli node scripts/npm-packages.mjs https://okibi.ai/i/cli/<cli.id> npm @acme/my-cli for dir in npm/*-*/ npm/launcher/; do npm publish "$dir" --access public --tag latest done for pkg in @acme/my-cli @acme/my-cli-{darwin,linux,win32}-{arm64,x64}; do npm trust github "$pkg" --repo acme/my-cli --file package-managers.yml \ --allow-publish --yes done
npm asks you to approve each publish and each trust in your browser; on the first approval, tick "skip two-factor authentication for the next 5 minutes" to approve the rest. To use a token instead of trusted publishing, store an npm access token as the secret NPM_TOKEN and add NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} to the publish step's env.
--tag latest is explicit because npm won't move latest to a version below its highest on its own, which is what publishing an older official version does. Rolling back to a version npm already has needs npm dist-tag add @acme/my-cli@1.2.0 latest. From the workflow, that works only with Allow npm dist-tag turned on for its trusted publisher.
Publish to Homebrew yourself
Your own tap needs a formula per release, built from the same release/ folder: a url and sha256 from latest.json for each macOS and Linux archive. Install the binaries into libexec and make bin/my-cli a wrapper that tells the CLI where its okibi-auth helper is, as Okibi's own formula does, so helpers from two CLIs never collide in Homebrew's bin:
def install libexec.install "my-cli" env = {} if File.exist?("okibi-auth") libexec.install "okibi-auth" env[:MY_CLI_AUTH_HELPER] = libexec/"okibi-auth" end (bin/"my-cli").write_env_script libexec/"my-cli", env end
The variable is the CLI's name in capitals with dashes as underscores, followed by _AUTH_HELPER.
