# kylon-cli

Local runtime CLI for running external agents for a P2 workspace.

No preinstalled Node.js is needed for the hosted install below — a machine
without one gets a private, checksum-verified runtime that only Kylon uses.
Only the `npx` spellings require your own Node.js 22.15.0 or newer.

## Install

The recommended install is the hosted script (macOS/Linux):

```bash
curl -fsSL https://api.kylon.io/install.sh | sh
```

It installs the pinned CLI bundle under `~/.kylon` (no sudo; a machine
without Node.js gets a private, checksum-verified runtime under
`~/.kylon/runtime` that only Kylon uses), puts `kylon` on your PATH, records
the serving environment as the default `--server-url`, and chains straight
into `kylon login`. Rerunning it is safe. Non-production deployments serve
the same script from their own API origin (for example
`https://api.purework.ai/install.sh` installs `@next` and logs into dev).
`KYLON_INSTALL_NO_LOGIN=1` installs without starting the login;
`KYLON_INSTALL_NO_MODIFY_PATH=1` leaves shell profiles untouched.

The CLI is also published to npm as
[`kylon-cli`](https://www.npmjs.com/package/kylon-cli). The Web UI provides
two distinct copy-paste entry points:

- **Settings → Developer tools → Kylon CLI** shows the hosted install
  one-liner above; installing chains straight into the browser sign-in, and
  on an installed machine `kylon login` with no flags does the same and lets
  the browser approval page choose the workspace. An **Advanced** expander
  keeps the setup-token and `npx --yes kylon-cli@<tag> auth login` fallbacks
  for machines that cannot run the script. Its **Use with Your Agents**
  prompt teaches Claude Code, Codex, Grok Build, Hermes, or OpenClaw to discover and use
  `kylon workspace` commands without assuming a provider-specific interface.
- **An external agent's Profile** shows
  `npx --yes kylon-cli@<version> agent link --url '<one-time link>'`. Custom
  ACP agents append `-- <launch command> [args]` to that same line. The link
  embeds a short-lived credential, so the CLI does not open a browser or reuse
  a human workspace credential. It checks that the selected Claude Code or
  Codex CLI is installed and authenticated, shows the exact agent and
  workspace for confirmation, connects the agent, and starts its local
  runtime. For ACP, the same operation also saves the selected launch command.
  The credential expires after 30 minutes and can be redeemed only once;
  rerunning the same installation's interrupted redemption is idempotent.
  Because the link carries the credential, treat the command as a secret until
  it is used or expires.

A local runtime may be connected with one Kylon server and workspace at a time.
Equivalent spellings of the same server (`https://dev.purework.ai/api` and
`https://api.purework.ai`, `https://app.kylon.io/api` and
`https://api.kylon.io`) count as the same registration; preview environments
never do, even though they serve the same workspace ids as dev. Run
`kylon disconnect` before connecting it somewhere else. This forgets only the
local runtime registration; it revokes nothing or changes any workspace-side
agent configuration.

`kylon login` (alias `auth login`) browser-authenticates the human operator for
`kylon workspace` commands. `--workspace <id>` pins the workspace up front;
without it the browser approval page asks the user to choose one, defaulting to
the workspace they most recently worked in. The default API server resolves
from `--server-url`, then the server env vars, then the URL recorded by the
hosted install script in `~/.kylon/server-url`, then production.
`agent link` authenticates only the link operation with its embedded
credential; it never creates a human CLI session. Linking mints or reuses an
installation credential stored locally with mode `0600` and starts the local
agent runtime. In release builds on supported environments a user-level
`launchd` / `systemd --user` service is installed so the daemon survives
terminal exit, reboot, and sleep/wake; otherwise the daemon runs in the
foreground until `Ctrl+C`.

The dist-tag tracks the environment: production uses `@latest`, dev uses
`@next` (newest prerelease). To pin a specific build, use `kylon-cli@X.Y.Z`.

Requirements: Linux / macOS only (Windows operators should use WSL). The
hosted script needs no preinstalled Node.js; the `npx` spellings need
Node.js 22.15.0+ (npm ships with Node).

### One managed `kylon` for humans and agents

The bootstrap commands (`agent link` and `auth login`) pin the CLI under
`~/.kylon/bin`: the exact bundle the daemon and its agents execute, exposed as
the `kylon` command. After the bootstrap they offer to add that directory to
your shell PATH (a marker-wrapped `. "$HOME/.kylon/env"` line in your shell rc,
or a fish `conf.d` drop-in; decline with `--no-modify-path`). From then on the
human terminal, the agents, and the daemon all run the same file — one
artifact, one version, one upgrade.

A leftover npm-global or npx-cached copy stays harmless: when it starts and
finds a strictly newer pinned install, it defers to it automatically
(installer commands — `agent …` and `gateway …` — always run as invoked, so a
fresh `npx` can still install itself over an older pinned copy; set
`KYLON_PINNED_TRAMPOLINE=0` to disable the deferral).

### Upgrading a local runtime

```bash
npx --yes kylon-cli@latest gateway service upgrade
```

(`@next` on dev.) The daemon and the pinned agent `kylon` both run the
installed bundle under `~/.kylon/bin`, which only this command (or a fresh
`agent link`) refreshes: the freshly-resolved CLI installs its own bundle and
drain-restarts the running daemon onto it, moving both in lockstep.
`npm install -g` alone updates the operator's terminal binary and leaves the
runtime service on the old build. The update notice the CLI prints for local
runtimes uses exactly this command; `kylon gateway service repair` is the same
reconciliation under its recovery-oriented name.

`repair` goes one step further than `upgrade`: after reconciling the Kylon
side it installs any provider CLI this machine's placements need but do not
have, so the commonest broken placement — a Codex agent on a host with no
`codex` — is fixed by the command whose name says it fixes things. The
boundary is deliberate. It installs the npm globals this product already tells
people to install (`@openai/codex`, `@anthropic-ai/claude-code`), echoing each
command before it runs, and it does nothing else on the operator's behalf: it
never runs a preset's third-party installer script, never removes a `kylon`
install this product did not create, and never re-registers an agent. Those
stay reported findings with a command to run deliberately. Pass
`--no-provider-install` to reconcile Kylon only.

Nothing installs a provider CLI in the background. A daemon that finds its
provider missing reports it and waits — installing software on someone's
machine is something they ask for, not something a health check decides.

The drain waits up to five minutes for in-flight agent work, and a drain that
exhausts its window rolls the upgrade back — so a persistently busy host would
never upgrade without an operator interrupt. Two are available:

- `kylon gateway service upgrade --force` skips the drain, or escalates one
  already in progress: in-flight assignments are aborted with the same
  recovery semantics as a daemon crash (the durable inbox re-drives
  interrupted work on the restarted daemon), and the new version activates
  immediately.
- `kylon gateway service cancel` abandons an in-progress upgrade: the daemon
  rolls back to the previous bundle and resumes accepting work on the old
  version.

### Local development (contributors)

```bash
pnpm install
pnpm --filter kylon-cli build
```

See [Development → Exposing `kylon` to provider subprocesses](#exposing-kylon-to-provider-subprocesses)
for the debugger-friendly tsx-based dev loop.

### Release channels

npm dist-tags are the version pointer — there is no server-side version
policy. Every merge to `develop` publishes a prerelease to the `next` tag
(`.github/workflows/cli-publish.yml`). A production deployment dispatched from
`main` automatically invokes the same workflow in stable mode and publishes the
exact `packages/cli/package.json` version to `latest`; it is idempotent when that
version already exists. A CLI change intended for production must therefore bump
the package version before the production deploy, or the stable publish will
correctly no-op and `latest` will remain on the existing build.

A matching `kylon-cli-vX.Y.Z` tag is an alternative supported stable-release
trigger, not a required extra step after a production deploy. Do not run
`npm publish` ad hoc: both paths use the CI workflow and its OIDC publisher.
Development installs `@next` and production installs `@latest`, so each
environment tracks its own release train.

## Usage

### Sign in for workspace commands

For a person using `kylon workspace ...` from their own terminal, open the
workspace's **Settings → Kylon CLI** section, expand it, and copy its setup
command, or run:

```bash
npx --yes kylon-cli@latest auth login --workspace <WORKSPACE_ID>
```

(or, once `kylon` is installed, just `kylon login` — the browser page then asks
which workspace to authorize). The CLI opens Kylon in your browser. After you
sign in, confirm the installation and workspace shown on the authorization
page, and verify that its confirmation code matches the one printed in your
terminal. The browser approves a short-lived
request but never receives the credential. The CLI proves possession of its PKCE
verifier while polling Kylon over HTTPS, receives the credential directly from
the API, and saves it in the versioned credential collection at
`~/.kylon/workspace-auth.json` with mode `0600`. No loopback listener, incoming
port, or browser request to localhost is used. On success the CLI also pins
itself under `~/.kylon/bin` and offers to put that directory on your PATH, so
subsequent commands are plain `kylon workspace ...` with no npx.
The collection keeps credentials separately by server and workspace, and
`--scope-workspace` selects the matching entry.

Each installation credential expires after 90 days. Re-running `kylon auth
login` for the same installation and workspace rotates that credential, making
the previous value invalid. Use `kylon status` to inspect the active user,
workspace name and ID, workspace role, credential source, and expiration time.
The active saved credential supplies the workspace automatically, so no
workspace ID is required. `kylon auth status` is an equivalent alias. Both
forms accept `--json` for machine-readable output. Status verifies and enriches
the locally stored identity against the API when available; if the API cannot
be reached, it retains the cached IDs and reports that remote details are
unavailable.

Browser authorization includes the API origin in the saved workspace profile,
so workspace commands do not require `--server-url` again. API keys are
write-only Connections with explicit agent grants. Create one by asking the
workspace's Kylon agent to start the secure Connection setup flow;
enter the Connection name, key, value, and optional icon in the app. The
workspace CLI can list granted key names and inject them into one child command:

```bash
kylon workspace secret list --scope-workspace <WORKSPACE_ID>
kylon workspace secret run -- curl https://api.example.com/resource
```

`kylon workspace secret set` is retired. It accepts no secret value and only
prints guidance for the secure Connection flow.

There is intentionally no top-level `kylon secret` command. The namespace
selects the resource and identity without inspecting unrelated local credential
state:

- Use `kylon workspace secret list|share|unshare|delete|run` for canonical
  workspace secret grants with browser authorization. Create API keys through
  the secure Connection flow in Kylon.
- Use `kylon gateway secret list|set|delete --agent <agent-id>` for agent-local
  records, authenticated by this installation's saved runtime credential
  through agent-scoped runtime routes.

This separation prevents browser credentials from reaching the
daemon-authenticated secret endpoints and avoids state-dependent command
routing.

```bash
kylon auth logout --workspace <WORKSPACE_ID>
```

Logout revokes the selected installation credential remotely before removing
that entry from the local collection. With no `--workspace`, it selects the most
recently authorized entry. If Kylon cannot confirm revocation, it keeps the local
credential so you can retry. `kylon auth logout --local-only` deliberately skips
the remote revocation. This logout is deliberately credential-only: it does not
disconnect the local agent runtime or change connected agents. If that installation
still runs external agents, it remains listed as an **Authorized device** so its
runtime access can still be removed without signing the CLI in again.

You can instead remove an **Authorized device** from **Settings → Kylon CLI**.
After warning about the impact, that workspace-scoped action revokes any active
installation credential, removes the local runtime access associated with the
same installation, and disconnects every external agent using it. The device's
authorizations and runtime access in other workspaces, browser
sessions, and workspace service keys are unaffected.

Credentials created by the earlier browser flow appear as **Legacy user API
key** in Settings. They can be revoked there, but they are not bound to a single
workspace; sign in again to replace one with the per-installation flow.

### Run

`gateway run` starts the local runtime service from this environment's saved
connection. It is the command the installed `launchd` / `systemd --user`
service execs; run it manually to keep connected agents available in the foreground.

```bash
kylon gateway run
```

It takes no connection flags: link an agent first with `kylon agent link`.
Without a saved runtime connection it exits with guidance pointing there.

### Sleep, wake, and keep-awake (macOS)

By default the daemon holds a macOS sleep assertion (the same mechanism as
`caffeinate`) whenever it runs: on AC power the machine can turn its display
off without ever going to sleep, so its agents stay online and dispatchable
from the web at any time. Sleep is only prevented on AC power — on battery,
and when the lid closes, normal sleep policy always wins. No effect on Linux.

```bash
kylon keep-awake            # show the current mode
kylon keep-awake while-busy # prevent sleep only while assignments run
kylon keep-awake off        # never prevent sleep
```

Modes: `always` (default), `while-busy`, `off`. The setting persists in
`~/.kylon/gateway-settings.json`; a running daemon hot-applies changes within
seconds, no service restart required.

When the machine does sleep (battery, lid closed, manual sleep), assignments
dispatched to its agents queue server-side. The daemon detects the wake
instantly (a wall-clock jump across its monitor tick) and recovers in one
pass: it force-reconnects the runtime event stream with reset backoff,
re-asserts presence on the fresh connection, and claims the queued work —
instead of waiting out inactivity timeouts and heartbeat intervals.

### Commands

| Command | Description |
|---|---|
| `kylon agent link --url <link> [-- <launch command>]` | **Recommended.** Connect one agent and start its local runtime. ACP launch commands are saved in the same operation. |
| `kylon disconnect` | Forget this environment's saved runtime connection. Local only: workspace-side agent configuration is unchanged. |
| `kylon gateway run` | Run the local runtime service from the saved connection (service entrypoint). |
| `kylon keep-awake [mode]` | Show or set macOS sleep prevention (`always` (default) / `while-busy` / `off`). |

### Supported Providers

- `codex` — OpenAI Codex CLI
- `claude-code` — Anthropic Claude Code CLI
- `grok-build` — Grok Build over ACP. Uses the explicitly installed
  `grok agent stdio`; Kylon does not download or execute Grok implicitly during
  a readiness probe or assignment. Provider authentication and model selection
  remain in Grok's local configuration.
- `hermes` — Hermes over ACP, using `hermes-acp` or `hermes acp`.
- `openclaw` — OpenClaw CLI using `openclaw agent --message <prompt> --json`.
  A message too large for the command line is delivered as a file via
  `--message-file <path>` instead (requires OpenClaw 2026.6.11 or newer; the
  OpenClaw CLI has no stdin message channel).
- `generic` - provider-neutral wrapper. Runs a `kylon-provider`
  executable on `PATH` and expects newline-delimited JSON events matching
  `docs/journal_docs/05_25_external_agent_provider_adapter_contract.md`.
  Delivery: `--prompt <text> --workdir <dir> [--system-prompt <text>]
  [--resume <id>]`. When the payload is too large for the platform's
  command-line budget (cmd.exe caps its input line at 8,191 chars; Linux caps
  one argv string at 128 KiB), the gateway instead writes the payloads to
  owner-only temp files and passes `--prompt-file <path>`
  (`--system-prompt-file <path>` for the system prompt), with the same paths
  exported as `KYLON_PROMPT_FILE`/`KYLON_SYSTEM_PROMPT_FILE`. File delivery is
  used only when the wrapper opts in by advertising
  `kylon-provider-caps: prompt-file, system-prompt-file` anywhere in its
  `--version` output (the one probe every wrapper already supports); a wrapper
  without the advertisement fails over-budget assignments with a named error
  instead of receiving flags it would misparse.

## State Model

The CLI uses a per-agent installation profile and three runtime-state layers:

- **InstalledAgentProfile** — non-secret agent identity, provider, and exclusive workdir ownership
- **GatewaySession** — authenticated connection to the P2 server for one agent
- **LogicalSessionState** — per-`(gateway session, agent)` binding holding the current workdir and provider
- **ProviderRuntimeEntry** — per-conversation provider resume cache, keyed by `(gateway session, channel, agent, scope, provider, workdir)` (disposable)

Switching workdir updates the logical session without creating a new one. Provider runtimes are cached per workdir + conversation scope — switching back resumes the old runtime.

Agent state is persisted to `~/.kylon/agents/<agent-id>/` (or
`$XDG_CONFIG_HOME/kylon/agents/<agent-id>/`). Human workspace authentication
remains in the top-level Kylon config directory.

## Development

> This section assumes you cloned `fre-so/p2` and ran `pnpm install` at the
> repo root. All commands are run from anywhere in the monorepo unless
> otherwise noted.

### Inner loop

```bash
# type-check without emitting
pnpm --filter kylon-cli typecheck

# tsc build — emits dist/bin/kylon.js and other .js files
pnpm --filter kylon-cli build

# unit tests (no network, no DB)
pnpm --filter kylon-cli test

# run the locally built CLI
node packages/cli/dist/bin/kylon.js --help
```

The Web agent-settings "Local dev" block generates the same
`node packages/cli/dist/bin/kylon.js …` invocation from the logged-in
agent's API key. If you change `src/bin/kylon.ts` or anything it
imports, rerun `pnpm --filter kylon-cli build` before re-executing.

### Exposing `kylon` to provider subprocesses

`node packages/cli/dist/bin/kylon.js gateway run …` starts the daemon
but leaves **no `kylon` binary on PATH**. When the provider subprocess
(claude-code / codex) then tries `kylon workspace …` via its Bash tool,
the shell fails with `command not found`. Two ways to fix this:

**Option 1 — `pnpm link` (persistent).** Symlink kylon's published
`bin.kylon` into pnpm's global bin dir:

```bash
pnpm --filter kylon-cli bundle            # or bundle:minify for a prod-shaped build
pnpm --filter kylon-cli link --global

which kylon
# → ~/Library/pnpm/kylon (or similar) → packages/cli/dist/kylon-bundle.mjs
```

From here on, start the daemon via the linked binary instead of the
raw `node dist/bin/kylon.js`:

```bash
kylon gateway run --server-url http://localhost:5173/api --provider codex --api-key pak_…
```

Every provider call to `kylon workspace …` resolves to the linked
binary. Edit source → `pnpm --filter kylon-cli bundle` → next provider
call picks it up (each workspace invocation is a fresh process;
restart the daemon only for *daemon*-side edits). Clean up with
`pnpm --filter kylon-cli unlink --global`.

**Option 2 — `--dev-cli-shim` (ephemeral, IDE-friendly).** The daemon
can install a temporary bash shim that execs the TS source through
tsx. Pass `--dev-cli-shim <abs-path-to-p2-repo>` on `gateway run` or
`gateway start`:

```bash
node packages/cli/dist/bin/kylon.js gateway run \
  --server-url http://localhost:5173/api \
  --provider codex \
  --api-key pak_… \
  --dev-cli-shim "$(pwd)"
```

The daemon prints the shim location on startup:

```text
[dev] kylon shim: /tmp/kylon-dev-shim-abc123/kylon
[dev] provider calls will exec: node --import tsx /abs/path/packages/cli/src/bin/kylon.ts
```

The tmpdir is prepended to the daemon's `PATH`, so the provider
subprocess (and the bash shell it spawns) resolves `kylon` to the
shim. The shim execs `node --import tsx <src>`, so:

- Every `kylon workspace …` invocation reads the current `src/*.ts` —
  no bundle rebuild needed between edits.
- A Node debugger attached to the daemon is inherited by each shim
  invocation (they exec `node`, so `NODE_OPTIONS` and VS Code's
  Auto-Attach loader pass through).
- On SIGINT/SIGTERM the tmpdir is wiped.

This flag is **only compiled into local (non-minified) builds**. The
npm package and any `bundle:release` output reject `--dev-cli-shim` as
an unknown argument and omit it from `--help`, so it can never
accidentally ship.

### Debugging both the daemon and `kylon workspace` calls

Combine `--dev-cli-shim` with a Node debugger to step through the
full chain — daemon → provider subprocess → `kylon workspace …` — in
one IDE session.

**VS Code**:

1. Enable `Debug: Toggle Auto Attach → Always` (or `Only With Flag`).
   VS Code prepends its `js-debug` bootloader to `NODE_OPTIONS`, which
   every child Node process — including the ones launched by the
   shim — inherits.
2. Open an integrated terminal and run:

   ```bash
   node packages/cli/dist/bin/kylon.js gateway run \
     --server-url http://localhost:5173/api \
     --provider codex \
     --api-key pak_… \
     --dev-cli-shim "$(pwd)"
   ```

3. Set breakpoints in both `packages/cli/src/commands/gateway-start.ts`
   (daemon) and `packages/cli/src/commands/workspace/*.ts` (child
   commands). Both fire the next time the provider issues a workspace
   call.

**JetBrains / others**: export `NODE_OPTIONS=--inspect=0.0.0.0:0`
before launching the daemon. Every subsequent Node process — daemon
and every `kylon workspace …` invocation — opens its own inspector
port. Attach your IDE to the process list.

No debugger attach? The shim still works — source edits are picked up
on the next provider call, but breakpoints just don't fire.

### Bundles

The release artifact is a single-file ESM bundle produced by
`scripts/bundle.mjs`. Three variants:

| Script | Output | Passes to `bundle.mjs` | Use when |
|---|---|---|---|
| `pnpm --filter kylon-cli bundle` | `dist/kylon-bundle.mjs` | _(none)_ | debugging the bundled shape while keeping readable names — not shipped |
| `pnpm --filter kylon-cli bundle:minify` | `dist/kylon-bundle.mjs` | `--minify` | reproducing the pre-obfuscation size and behavior for a bisect |
| `pnpm --filter kylon-cli bundle:release` | `dist/kylon-bundle.mjs` | `--minify --obfuscate` | what ships on npm |

The `bundle:release` path runs esbuild with `--minify`, then passes the
output through `javascript-obfuscator`. It's also what the `prepack` hook
runs, so `pnpm pack` / `npm publish` always produce the obfuscated shape
even if you forget to call `bundle:release` explicitly.

Run the bundle directly to sanity-check it:

```bash
pnpm --filter kylon-cli bundle:release
node packages/cli/dist/kylon-bundle.mjs --help
```

### Testing

Unit tests run against Node's built-in test runner (`node:test`) plus
`tsx`. They touch the filesystem inside temp dirs but never the
network or a database, so `pnpm --filter kylon-cli test` is safe to
run anywhere.

E2E tests drive the CLI as a subprocess against a configurable mock
or live server:

```bash
# headless e2e (mock provider processes spawned from scripts/mock-*.mjs)
pnpm --filter kylon-cli test:e2e

# live e2e against a real P2 environment — requires doppler secrets
doppler run --project p2 --config prd -- pnpm --filter kylon-cli test:e2e:live
```

Live E2E tests provision a throwaway workspace via the REST API, so
expect them to take several minutes and to leave audit trail rows in
the target environment. Do not point them at production casually.

Before shipping a release, also smoke-test the packaged artifact
exactly as operators will receive it:

```bash
# 1. produce the release bundle + tarball
pnpm --filter kylon-cli bundle:release
cd packages/cli && pnpm pack --pack-destination /tmp/kylon-out

# 2. install the tarball in a clean directory
WORK=$(mktemp -d) && cd "$WORK"
npm init -y >/dev/null
npm install /tmp/kylon-out/kylon-cli-*.tgz

# 3. the published shape should have only three entries
ls node_modules/kylon-cli               # dist/  package.json  README.md
ls node_modules/kylon-cli/dist          # kylon-bundle.mjs

# 4. verify the binary is runnable
node_modules/.bin/kylon --help

# 5. confirm the bundle is actually obfuscated
head -c 200 node_modules/kylon-cli/dist/kylon-bundle.mjs
# expect `#!/usr/bin/env node` followed by hexadecimal identifier soup,
# not recognizable function names or source strings
```

### Obfuscation

`bundle:release` and `prepack` run `javascript-obfuscator` with a
conservative preset chosen for runtime safety:

- **On:** `compact`, `identifierNamesGenerator: "hexadecimal"`,
  `stringArray` with `base64` encoding + rotate + shuffle, two
  wrapper function layers.
- **Off:** `controlFlowFlattening`, `deadCodeInjection`,
  `selfDefending`, `debugProtection`, `unicodeEscapeSequence`. These
  trade correctness and startup latency for marginal protection — do
  not turn them on without measuring startup and rerunning the full
  test:e2e suite.
- `renameGlobals` stays off so Node built-ins keep their names.

Startup stays under 100 ms on modern hardware; bundle grows from
~85 KB (minify only) to ~230 KB (obfuscated). Obfuscation is a
tampering and casual-reading deterrent, not a security control — the
API server is the security boundary.

## Release

The CLI ships via **npm dist-tags** — there is no server-side version policy.
`cli-verify.yml` validates on PRs and pushes; `cli-publish.yml` publishes via
OIDC (no token): a prerelease to the `next` tag on every qualifying merge to
`develop`, and the package version to `latest` when the production deploy runs
from `main`. A matching `kylon-cli-vX.Y.Z` tag can also publish that stable
version. Development installs `@next`; production installs `@latest`.

### Versioning

Use explicit semver in `packages/cli/package.json`:

| Bump | When |
|---|---|
| patch (`0.1.0 → 0.1.1`) | bug fix, no new flags, no behavior change |
| minor (`0.1.x → 0.2.0`) | backward-compatible capability (new command, new flag) |
| major (`0.x.x → 1.0.0`) | breaking change to CLI contract or runtime behavior |

Prerelease builds are versioned `<package-version>-next.<run>` automatically by
`cli-publish.yml`. Stable mode publishes the exact package version and skips
idempotently if npm already has it. Bump `packages/cli/package.json` whenever a
new production CLI build must be published; otherwise the production deploy
will intentionally leave `latest` unchanged.

### Publishing

- **Prerelease (automatic):** every merge to `develop` touching the CLI publishes
  `<package-version>-next.<run>` to the `next` tag via `cli-publish.yml` (OIDC, no
  `NPM_TOKEN`). dev tracks this tag, so dev always dogfoods the newest build.
- **Stable (automatic with a production deploy):** a production deployment
  dispatched from `main` calls `cli-publish.yml` in stable mode. It publishes
  the package version only when that exact version is absent from npm. CLI
  changes therefore need a package-version bump before the production deploy.
- **Stable tag (alternative):** after bumping the package version, a matching
  tag on `main` invokes the same stable workflow directly:

  ```bash
  git pull
  git tag kylon-cli-v0.1.1
  git push origin kylon-cli-v0.1.1   # cli-publish.yml publishes it to `latest`
  npm view kylon-cli dist-tags        # verify
  ```

Do not `npm publish` ad hoc: production deploys and release tags both go through
the workflow so the OIDC provenance path is exercised end to end.

### Rollback

npm publishes are immutable; roll back by re-pointing the tag, not by
unpublishing:

```bash
npm dist-tag add kylon-cli@<last-good> latest   # move the channel back
npm deprecate kylon-cli@<bad> "Broken release — use <last-good>."
```

Operators on `@latest` pick up the re-pointed version the next time they run
`npx --yes kylon-cli@latest gateway service upgrade` (the command the update
notice prints for local runtimes); in-flight daemons keep their current pinned
bundle until that drain-restart. Do not `npm unpublish`.
