feat(cli): add 'paperclipai channels' to show release lanes and the current one (#11210)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The release channels (canary → nightly → beta → stable) select by
install target, and users discover them today only through
maintainer-oriented docs
> - A user who wants to know "which lane am I on, and what else is
there" has no self-serve answer
> - The channel rollout planned a read-only CLI command for exactly this
> - This pull request adds `paperclipai channels`: every lane with the
version its dist-tag resolves to, the install command for it, and which
lane the running install follows
> - The benefit is self-serve lane discovery without reading release
documentation

## Linked Issues or Issue Description

Refs #11008 — the user-facing discovery surface for the channel model
completed there.

**Subsystem affected**

CLI: `cli/src/commands/channels.ts` (new), `cli/src/index.ts`,
`doc/CHANNELS.md`, tests.

**Problem or motivation**

Channel selection is install-based (`@latest` / `@beta` / `@nightly` /
`@canary`), but nothing in the product tells a user which channel their
install follows or what the other lanes currently resolve to. The
information lives in `doc/CHANNELS.md` and the npm registry, neither of
which a running install surfaces.

**Proposed solution**

A read-only `paperclipai channels` command: prints each channel with the
version its dist-tag currently resolves to (per-lane registry lookups
that degrade to `unavailable` individually), the install command for
each, and the running install's lane parsed from its version suffix —
source checkouts carry the repository's placeholder version and are
reported as unmapped rather than guessed. `--json` emits the same data
for scripting.

## What Changed

- `cli/src/commands/channels.ts` (new): channel table, lane parsing,
registry resolution, human and `--json` output
- `cli/src/index.ts`: registers `channels`
- `doc/CHANNELS.md`: "Seeing where you are" section
- `cli/src/__tests__/channels.test.ts` (new): lane parsing including
unknown versions, full resolution against a fake runner, per-lane
degradation, table/dist-tag sync

## Verification

- `vitest run cli/src/__tests__/channels.test.ts`: 5 pass
- `pnpm typecheck` in `cli/`
- Live run against the real registry shows all four lanes with their
current versions (`2026.722.0` / `2026.811.0-beta.0` /
`2026.811.0-nightly.0` / canary) and correctly reports a source checkout
as unmapped

## Risks

- Low. Read-only command reusing the existing `resolvePublishedVersion`
registry helper; no state, no auth, no publish surface

## Model Used

Claude Fable 5 (`claude-fable-5`, Anthropic) in Claude Code, with
extended thinking and full tool use. All changes model-authored under
human direction.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending — will confirm before
merge)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending — will confirm before merge)
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Devin Foley 2026-08-11 08:47:32 -07:00 committed by GitHub
parent c4abecb2c4
commit 1ea2f0e2d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 219 additions and 0 deletions

View File

@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
channelForVersion,
collectChannelState,
RELEASE_CHANNELS,
} from "../commands/channels.js";
import type { CommandRunner } from "../commands/install.js";
describe("channelForVersion", () => {
it("maps published versions to their lane", () => {
expect(channelForVersion("2026.811.0")).toBe("stable");
expect(channelForVersion("2026.811.0-beta.0")).toBe("beta");
expect(channelForVersion("2026.811.0-nightly.0")).toBe("nightly");
expect(channelForVersion("2026.811.0-canary.3")).toBe("canary");
});
it("treats non-CalVer versions as unknown instead of guessing", () => {
expect(channelForVersion("0.3.1")).toBe("unknown");
expect(channelForVersion("2026.811.0-rc.1")).toBe("unknown");
expect(channelForVersion("garbage")).toBe("unknown");
});
});
describe("collectChannelState", () => {
const versionsByTag: Record<string, string> = {
latest: "2026.722.0",
beta: "2026.811.0-beta.0",
nightly: "2026.811.0-nightly.0",
canary: "2026.811.0-canary.3",
};
const fakeRunner: CommandRunner = async (_command, args) => {
const spec = (args ?? []).find((arg) => arg.startsWith("paperclipai@"));
const tag = spec?.slice("paperclipai@".length) ?? "";
const version = versionsByTag[tag];
if (!version) throw new Error(`unexpected dist-tag: ${tag}`);
return { stdout: JSON.stringify(version), stderr: "" };
};
it("resolves every channel's dist-tag from the registry", async () => {
const state = await collectChannelState(fakeRunner);
expect(state.map((entry) => entry.channel)).toEqual([
"stable",
"beta",
"nightly",
"canary",
]);
expect(state.map((entry) => entry.version)).toEqual([
"2026.722.0",
"2026.811.0-beta.0",
"2026.811.0-nightly.0",
"2026.811.0-canary.3",
]);
});
it("degrades a single unavailable channel to null without failing the rest", async () => {
const flakyRunner: CommandRunner = async (command, args, options) => {
const spec = (args ?? []).find((arg) => arg.startsWith("paperclipai@"));
if (spec === "paperclipai@nightly") throw new Error("registry timeout");
return fakeRunner(command, args, options);
};
const state = await collectChannelState(flakyRunner);
const byChannel = Object.fromEntries(state.map((entry) => [entry.channel, entry.version]));
expect(byChannel.nightly).toBeNull();
expect(byChannel.stable).toBe("2026.722.0");
expect(byChannel.beta).toBe("2026.811.0-beta.0");
expect(byChannel.canary).toBe("2026.811.0-canary.3");
});
it("keeps the channel table and dist-tags in sync", () => {
expect(RELEASE_CHANNELS.map((entry) => entry.distTag)).toEqual([
"latest",
"beta",
"nightly",
"canary",
]);
});
});

View File

@ -0,0 +1,119 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import pc from "picocolors";
import { resolvePublishedVersion, type CommandRunner } from "./install.js";
import { packageVersion } from "../version.js";
const execFileAsync = promisify(execFile);
const defaultRunCommand: CommandRunner = (command, args, options) =>
execFileAsync(command, args, { ...options, encoding: "utf8" });
export type ChannelName = "stable" | "beta" | "nightly" | "canary";
export type ChannelDescriptor = {
channel: ChannelName;
distTag: string;
cadence: string;
audience: string;
};
// Ordered from most to least stable — the order users should consider them.
export const RELEASE_CHANNELS: readonly ChannelDescriptor[] = [
{
channel: "stable",
distTag: "latest",
cadence: "manual, soaked in beta for 3+ days",
audience: "the recommended release for almost everyone",
},
{
channel: "beta",
distTag: "beta",
cadence: "manual promotion behind an approval gate",
audience: "release candidates: what stable becomes a few days later",
},
{
channel: "nightly",
distTag: "nightly",
cadence: "once a night, smoke-gated",
audience: "yesterday's merges, tested as a unit",
},
{
channel: "canary",
distTag: "canary",
cadence: "every merge to master",
audience: "the bleeding edge",
},
];
const CALVER_RE = /^\d{4}\.\d{1,4}\.\d+$/;
const PRERELEASE_RE = /^\d{4}\.\d{1,4}\.\d+-(canary|nightly|beta)\.\d+$/;
// Published versions carry their lane in the version string; the source
// checkout's package.json holds a placeholder that matches neither form.
export function channelForVersion(version: string): ChannelName | "unknown" {
const prerelease = version.match(PRERELEASE_RE);
if (prerelease) return prerelease[1] as ChannelName;
if (CALVER_RE.test(version)) return "stable";
return "unknown";
}
export type ChannelState = ChannelDescriptor & { version: string | null };
export async function collectChannelState(
runCommand: CommandRunner = defaultRunCommand,
): Promise<ChannelState[]> {
const resolved = await Promise.allSettled(
RELEASE_CHANNELS.map((entry) => resolvePublishedVersion(entry.distTag, runCommand)),
);
return RELEASE_CHANNELS.map((entry, index) => {
const outcome = resolved[index];
return {
...entry,
version: outcome.status === "fulfilled" ? outcome.value : null,
};
});
}
export type ChannelsOptions = { json?: boolean };
export async function channelsCommand(
options: ChannelsOptions = {},
runCommand: CommandRunner = defaultRunCommand,
): Promise<void> {
const state = await collectChannelState(runCommand);
const currentChannel = channelForVersion(packageVersion);
if (options.json) {
console.log(
JSON.stringify(
{
current: { version: packageVersion, channel: currentChannel },
channels: state,
},
null,
2,
),
);
return;
}
console.log(pc.bold("Paperclip release channels"));
console.log("");
for (const entry of state) {
const version = entry.version ?? pc.yellow("unavailable");
console.log(` ${pc.bold(entry.channel.padEnd(8))} ${version}`);
console.log(` ${" ".repeat(8)} ${pc.dim(`${entry.cadence}${entry.audience}`)}`);
console.log(` ${" ".repeat(8)} ${pc.dim(`npx paperclipai@${entry.distTag} onboard`)}`);
console.log("");
}
if (currentChannel === "unknown") {
console.log(
`This install reports version ${pc.bold(packageVersion)}, which does not map to a published channel (source checkouts report the repository placeholder).`,
);
} else {
console.log(`This install is version ${pc.bold(packageVersion)} on the ${pc.bold(currentChannel)} channel.`);
}
console.log(`Docker images use the same names: ghcr.io/paperclipai/paperclip:{latest,beta,nightly,canary}`);
}

View File

@ -2,6 +2,7 @@ import { Command } from "commander";
import { onboard } from "./commands/onboard.js";
import { doctor } from "./commands/doctor.js";
import { envCommand } from "./commands/env.js";
import { channelsCommand } from "./commands/channels.js";
import { configure } from "./commands/configure.js";
import { addAllowedHostname } from "./commands/allowed-hostname.js";
import { heartbeatRun } from "./commands/heartbeat-run.js";
@ -130,6 +131,14 @@ program
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.action(envCommand);
program
.command("channels")
.description("Show the release channels and which one this install follows")
.option("--json", "Machine-readable output")
.action(async (opts) => {
await channelsCommand(opts);
});
program
.command("configure")
.description("Update configuration sections")

View File

@ -56,6 +56,16 @@ docker pull ghcr.io/paperclipai/paperclip:canary
Every image is also published as `:sha-<short-sha>` for exact pinning, and
stable images additionally get `:YYYY.MDD.P` version tags.
## Seeing where you are
```bash
npx paperclipai channels
```
prints every channel with the version it currently resolves to, the install
command for each, and which channel your install follows (with `--json` for
scripting).
## Switching channels
Channel choice is per-install: install from a different tag and you're on that