@bitspark-ai/ba
v0.6.0
Published
ba — the bitagent family CLI runner. Mounts each service's plugin under its namespace.
Readme
ba — the bitagent family CLI
bais one CLI for the wholebitagentfamily of services. Each service contributes a top-level namespace (ba runtime …,ba gateway …, …) so users learn, install, and update a single tool. The runner is TypeScript; the services it drives may be any language —batalks to each over that service's SDK/HTTP API, never in-process.
This README is the contributor guide: if you own a bitagent service and want it
to appear under ba, this is how you add your subcommand.
Table of contents
- The model in 60 seconds
- Quickstart: add your subcommand
- The
BaPlugincontract - Authoring a plugin
- Registering your plugin with
ba - Naming & packaging conventions
- One service, multiple plugins — the gateway case
- Adapting an existing commander CLI
- Adapting a service with its own command model
- Cross-repo local development
- Contributor checklist
- Repo layout & commands
- Roadmap
The model in 60 seconds
ba (runner) ── owns the cross-cutting UX:
│ dispatch · help aggregation · --version ·
│ global --target context · session & credentials
├── auth plugin → @bitspark-ai/accounts-cli (human identity — login/out)
├── commons plugin → @bitspark-ai/ba-commons-plugin (coordination — roster/inbox/lease/…)
├── demo plugin → @bitspark-ai/ba-plugin-demo (reference plugin)
├── echo plugin → @bitspark-ai/demo-cli (demo service — identity slice e2e)
├── gateway plugin → @bitspark-ai/gateway-cli (Claude gateway — developer)
├── runtime plugin → @bitspark-ai/bitagent-runtime-cli (durable agents)
├── gateway-admin plugin → @bitspark-ai/gateway-admin-cli (Claude gateway — operator; planned, P3)
└── <service> plugin → @bitspark-ai/<service>-cliMounted today:
auth,commons,demo,echo,gateway,runtime(seepackages/core/src/plugins.ts).gateway-adminis planned (P3); completion + config are P4.ba gatewayinvokes the agw binary at runtime viaGATEWAY_AGW_BIN→ an optional co-installedbitspark-agw→PATH.
- A plugin is a package that exports a single
BaPluginvalue. - Each plugin owns exactly one top-level namespace (
ba <namespace> …). bamounts plugins at compile time (see Registering), esbuild-bundles them into one self-contained binary, and hands each plugin a sharedBaContextso the runner — not the plugin — owns version, help, the global--target, and the session/credentials context. (Completion, richer help, and config are roadmap P4.)
Why compile-time? It gives one bundled, fully type-safe
bawith zero runtime deps. TheBaPlugincontract is intentionally independent of how plugins are discovered, so if we later add installed-plugin discovery (ba plugin add …), your plugin does not change — only the runner's mount step does.
Quickstart: add your subcommand
There are two halves: author a plugin in your service's repo, then register it here.
1. In your service repo, add a CLI package (convention: @bitspark-ai/<service>-cli)
that exports a BaPlugin:
// packages/cli/src/index.ts
import type { BaPlugin } from "@bitspark-ai/ba-plugin-api";
export const plugin: BaPlugin = {
namespace: "myservice",
summary: "what my service does (one line, shown in `ba --help`)",
register(program, ctx) {
const ns = program.command("myservice").description("what my service does");
ns.command("ping")
.description("check the service is reachable")
.option("--json", "machine-readable output")
.action(async (opts: { json?: boolean }) => {
const ok = await myClient(ctx).ping();
ctx.printer.out(opts.json ? JSON.stringify({ ok }) : ok ? "ok" : "unreachable");
});
},
};2. In this repo (ba-cli), wire it in — two lines:
// packages/core/package.json
"dependencies": {
"@bitspark-ai/myservice-cli": "workspace:*" // or a published ^version
}// packages/core/src/plugins.ts
import { plugin as myservice } from "@bitspark-ai/myservice-cli";
export const PLUGINS: BaPlugin[] = [demo, myservice];Done. ba myservice ping works and myservice shows up in ba --help.
The fastest way to start is to copy packages/plugin-demo —
it is the reference implementation and exists purely to be copied.
The BaPlugin contract
The full contract lives in @bitspark-ai/ba-plugin-api.
It is small on purpose:
export interface BaPlugin {
/** Top-level subcommand — lowercase/hyphenated, e.g. "runtime" | "gateway-admin" ("help" is reserved). */
readonly namespace: string;
/** One-line summary shown in `ba --help`. Non-empty. */
readonly summary: string;
/** Attach your command subtree to the root program. MUST be synchronous (build it before returning). */
register(program: Command, ctx: BaContext): void;
}
export interface BaContext {
/** The active target/context (e.g. an env) if the user passed `--target`. Lazy. */
readonly target: string | undefined;
/** Output sink — use this instead of console.*. */
readonly printer: Printer;
/** The `ba` runner version. */
readonly version: string;
/** The logged-in session for the active target, if any (convenience for `credentials.get()`). */
readonly session: Session | undefined;
/** Read/write the local session store, scoped to the active target. */
readonly credentials: CredentialStore;
}
export interface Printer {
out(s: string): void;
err(s: string): void;
}Rules the runner relies on:
- One plugin = one namespace. Two plugins claiming the same
namespaceis a hard error at startup (caught inbuildProgram). - Keep the module side-effect-free. The file that exports your
BaPluginshould do no top-level work (no network, no reading argv, noconsole), so the runner can import it cheaply and esbuild can tree-shake cleanly. Do all work inside.action(...)handlers. registeris synchronous. Build the command tree synchronously; your actions can beasync.- The contract is commander-native (
programis a commanderCommand). This is deliberate — see Adapting an existing commander CLI.
Authoring a plugin
Anatomy of a command
register(program, ctx) {
const ns = program.command("myservice").description("…"); // your namespace
ns.command("run") // a verb under it
.description("spawn a thing; returns its id")
.argument("<task>", "the task prompt") // required positional
.argument("[name]", "optional name", "default") // optional positional w/ default
.option("--count <n>", "how many", "1") // option with value
.option("--json", "machine-readable output") // boolean flag
.action(async (task: string, name: string, opts: { count: string; json?: boolean }) => {
// … do the work, print via ctx.printer …
});
}Conventions across the family (please follow them so ba feels like one tool):
- Namespace = your service, lowercase, hyphenated (
gateway,gateway-admin). - Verbs are short and imperative:
run,ps,logs,stop,send,get,list. Match sibling services where it makes sense. - Every command that prints data supports
--jsonfor scripting. Humans get a terse default;--jsongetsJSON.stringify. - Prefer flags with values (
--control-url <url>) over positional soup.
Talking to your service
ba is a thin client. Your plugin should call your service over its SDK / HTTP
API, never reach into its process. Build your client inside the action (or a tiny
factory) so it stays injectable for tests:
import { MyServiceClient } from "@bitspark-ai/myservice-client";
function makeClient(ctx: BaContext) {
return new MyServiceClient({
baseUrl: process.env.MYSERVICE_URL ?? "http://127.0.0.1:4500",
apiKey: process.env.MYSERVICE_API_KEY,
// resolve env-specific config from ctx.target if you support named targets
});
}The gateway plugins reuse the gateway's existing SDK packages —
@bitspark-ai/gateway-client (transport) and @bitspark-ai/gateway-cli-targets
(named env/target resolution) — rather than re-implementing transport.
Output: always use ctx.printer
Do not call console.log / console.error directly. Use ctx.printer.out and
ctx.printer.err. The runner injects the printer, which is what makes your commands
testable (capture output) and lets the runner control formatting/streams centrally.
The global --target context
--target <name> is a runner-global flag (e.g. select an environment). Read it
via ctx.target — it is lazy, so it reflects the value parsed from argv at action
time:
.action(() => {
const env = ctx.target ?? "local";
// …resolve config for `env`, then talk to that env's service…
});Don't define your own --target/--env/--version on your namespace; inherit the
runner's. Service-specific connection flags (--control-url) are fine to add.
Errors
Throw — don't process.exit inside an action. The runner's top-level catch in
main.ts prints the message and sets exit code 1.
Throw Error with a clear, user-facing message:
if (!agentId) throw new Error("myservice run: no agent id returned");Testing your plugin
Because output and clients are injectable, you can unit-test a command without spawning a process. Mirror the runner's own smoke test:
import { describe, it, expect } from "vitest";
import { Command } from "commander";
import { plugin } from "../src/index";
function harness() {
const lines: string[] = [];
const program = new Command();
program.exitOverride(); // don't process.exit in tests
const ctx = {
target: undefined,
version: "test",
printer: { out: (s: string) => lines.push(s), err: () => {} },
session: undefined, // no logged-in session in this test
credentials: { get: () => undefined, set: () => {}, clear: () => {} }, // no-op store
};
plugin.register(program, ctx);
return { program, lines };
}
it("pings", async () => {
const { program, lines } = harness();
await program.parseAsync(["node", "ba", "myservice", "ping", "--json"]);
expect(lines).toEqual([JSON.stringify({ ok: true })]);
});(For real I/O, inject a fake client via a deps param on your plugin factory, the
way the runtime CLI does with CliDeps.makeClient.)
Registering your plugin with ba
Mounting is compile-time and explicit — there is exactly one place to look:
- Export a
BaPluginfrom your CLI package (above). - Depend on it in
packages/core/package.json(workspace:*for local dev, or a published^versiononce your package ships). - Add it to
PLUGINSinpackages/core/src/plugins.ts:
import { plugin as myservice } from "@bitspark-ai/myservice-cli";
// The mounted set today (packages/core/src/plugins.ts) is [demo, auth, echo, runtime];
// append yours. (gateway/gateway-admin are commented "planned additions" there.)
export const PLUGINS: BaPlugin[] = [demo, auth, echo, runtime, myservice];The runner sorts namespaces for stable help and refuses duplicates. After wiring,
pnpm build bundles your plugin into the one ba binary.
Naming & packaging conventions
| Thing | Convention | Example |
|---|---|---|
| CLI package name | @bitspark-ai/<service>-cli | @bitspark-ai/gateway-cli |
| Plugin export | a named/plugin export of type BaPlugin | export const plugin: BaPlugin |
| Namespace | lowercase, hyphenated, = the service | gateway, gateway-admin |
| Client dep | @bitspark-ai/<service>-client (the SDK) | @bitspark-ai/gateway-client |
The package name need not equal the namespace — the first-party family diverges for product naming:
@bitspark-ai/accounts-cliships theauthnamespace and@bitspark-ai/demo-clishipsecho.isBaPluginenforces only the namespace grammar (lowercase, hyphenated, leading letter;helpis reserved), not the package name.
Keep the plugin package thin: command wiring + your SDK. No business logic that belongs in the service.
One service, multiple plugins — the gateway case
A single service repo can contribute more than one plugin (more than one
namespace). The Claude gateway (claude-gateway, soon bitagent/gateway) is the
canonical example — it contributes two:
| Namespace | Package | Audience | Contains |
|---|---|---|---|
| gateway | @bitspark-ai/gateway-cli | developer (public) | auth/setup, connection, projects — day-to-day developer use |
| gateway-admin | @bitspark-ai/gateway-admin-cli | operator | deploy, ops, capacity, profiles, routes, retention, audit |
Why two plugins instead of one role-aware command:
- Structural boundary. Operator code lives in a package the developer plugin
never depends on. The public developer surface cannot ship operator/deploy
logic by construction — it's a dependency-graph fact, not a build-time strip.
(This replaces the gateway's old single
agwbinary with its--publicesbuild stub + content audit.) - Two distinct UXs. Developers and operators are different audiences; each gets
a focused namespace, help, and setup flow. Someone who is both installs a
bathat has both namespaces.
Both gateway plugins are built on the same gateway SDK packages
(@bitspark-ai/gateway-client, @bitspark-ai/gateway-cli-targets), so the API
layer is shared, not duplicated — only the command surfaces differ.
If your service has a similar split (a public surface and a privileged one), follow this pattern: two packages, two namespaces, one shared SDK.
Adapting an existing commander CLI
If your service already has a commander CLI
(like the runtime CLI, which already does program.name("ba").command("runtime")…),
adapting it is nearly mechanical: move the body that builds your program.command(ns)
subtree into register, and take program + ctx as inputs instead of creating
your own Command and reading process.env/console directly.
// before: buildProgram(): Command { const program = new Command(); const rt = program.command("runtime")…; return program; }
// after:
export const plugin: BaPlugin = {
namespace: "runtime",
summary: "durable, decoupled, movable agents",
register(program, ctx) {
const rt = program.command("runtime").description("durable, decoupled, movable agents");
rt.command("run") /* …unchanged… */;
// swap console.log → ctx.printer.out; drop your own --version.
},
};Keep your service's own standalone bin during the transition if you want; it can import the same command-building code the plugin uses.
Adapting a service with its own command model
If your service describes commands as data (e.g. the gateway's declarative
command registry with role/capability gating), don't fight it — write a small
adapter in register that walks your data and calls program.command(...),
mapping your gating to a pre-action check:
register(program, ctx) {
const ns = program.command("gateway-admin").description("…");
for (const entry of OPERATOR_COMMANDS) { // your declarative registry
const cmd = ns.command(entry.name).description(entry.summary);
for (const opt of entry.options ?? []) cmd.option(opt.flag, opt.desc);
cmd.action((...args) => {
assertCapability(entry.group, ctx); // your gating, surfaced as an error
return entry.dispatch({ ctx, args });
});
}
}The runner stays oblivious to your internal model; it only sees commander commands.
Cross-repo local development
The family is federated — ba-cli, runtime, gateway, etc. are separate
repos in sibling directories under C:\Development\bitspark\bitagent\, each its own
pnpm workspace. Two ways to develop a plugin against a live ba:
- Published version (steady state): your
*-clipackage is published;ba-clidepends on a^version. Bump to upgrade. - Local link (while iterating): point the dependency at your working copy via a
pnpm
overridesentry orpnpm link, sobabundles your local plugin source. (If the family later consolidates into one workspace, this becomes plainworkspace:*.)
Today the mounted sibling plugins are wired as workspace:* members in
pnpm-workspace.yaml (../runtime/*, ../commons/*).
Build prerequisite for dist-first plugins. Most family packages are source-first (
main→./src/index.ts), so esbuild/tsc read their TypeScript directly — no prebuild. A few are dist-first (main→./dist), notably the commons plugin chain (@bitspark-ai/ba-commons-plugin→@bitspark-ai/commons-client→@bitspark-ai/commons). For those, the sibling workspace must be built beforebais type-checked or bundled here, orpnpm check/pnpm buildcan't resolve their./dist. In a fresh checkout (or after commons changes):pnpm -C ../commons install --frozen-lockfile && pnpm -C ../commons build # then, in this repo: pnpm install && pnpm check && pnpm test && pnpm build
Run the runner without building:
pnpm exec tsx packages/core/src/main.ts <args> # e.g. … myservice ping --jsonGotcha:
pnpm run dev -- --flagforwards a literal--into argv, which commander treats as end-of-options. Usepnpm exec tsx …(above) when passing top-level flags like--help/--version/--target.
Contributor checklist
Before you open a PR to add your plugin:
- [ ] Plugin module is side-effect-free; all work is inside
.action. - [ ] Exactly one namespace, lowercase/hyphenated, named after your service.
- [ ] All output goes through
ctx.printer(noconsole.*). - [ ] Data commands support
--json. - [ ] No custom
--version/--target; inherit the runner's. - [ ] Actions throw on failure (no
process.exit). - [ ] You talk to your service via its SDK, with an injectable client.
- [ ] At least one vitest test that parses argv and asserts on captured output.
- [ ] Registered in
packages/core(dependency +PLUGINSentry). - [ ]
pnpm check,pnpm test, andpnpm buildare green.
Repo layout & commands
packages/
plugin-api/ @bitspark-ai/ba-plugin-api — the BaPlugin contract (the linchpin)
core/ @bitspark-ai/ba — the runner + bin `ba` + compile-time mount
plugin-demo/ @bitspark-ai/ba-plugin-demo — reference plugin / copy-me template
accounts-client/ @bitspark-ai/accounts-client — isomorphic accounts SDK (login + session keys)
accounts-cli/ @bitspark-ai/accounts-cli — the `ba auth` plugin (human identity)
demo-cli/ @bitspark-ai/demo-cli — the `ba echo` plugin (identity slice, end to end)pnpm install
pnpm exec tsx packages/core/src/main.ts --help # run the runner via tsx
pnpm exec tsx packages/core/src/main.ts demo greet Ada
pnpm check # tsc --noEmit across packages
pnpm test # vitest
pnpm build # esbuild → one self-contained `ba`Toolchain: Node 24+, Corepack-pinned [email protected], TypeScript 6, commander 12, vitest 4 — matching the rest of the bitagent family.
Distribution (publishing)
ba ships as a single self-contained bundle — packages/core/dist/main.js with
zero runtime dependencies (esbuild inlines every plugin + commander at build time).
End users install one package and need no sibling runtime/commons/gateway checkouts.
The source packages/core/package.json stays private and keeps its workspace:*
plugins as devDependencies (build-time only). The publishable manifest is derived
by the dry-run packer (no runtime deps, not private, bin ba → ./dist/main.js,
files: ["dist/main.js"], publishConfig.access: public).
Dry-run packaging gate (proves the tarball without publishing):
# family prerequisites (fresh checkout): commons is dist-first, so build it first
pnpm -C ../commons install --frozen-lockfile && pnpm -C ../commons build
pnpm install && pnpm check && pnpm test && pnpm build
# pack → install OUTSIDE the monorepo → smoke the bundled CLI:
pnpm --filter @bitspark-ai/ba publish:dryrunpackages/core/scripts/publish-dryrun.mjs
packs the derived manifest and asserts the packed tarball (name, non-0.0.0
version, not private, no workspace:*, empty runtime deps, bin, file allowlist, no
gateway-admin), installs it in a temp dir outside the workspace, and smokes
ba --help, ba gateway|runtime|commons --help, and the missing-agw setup hint.
Version: the dry-run stamps 0.0.0-dryrun; a real release sets BA_PUBLISH_VERSION.
No real npm publish happens in the dry-run.
Real publish
The real publish reuses the dry-run packer, so what ships is exactly what the gate
verified. It is heavily guarded — scripts/publish.mjs
refuses unless BA_PUBLISH_VERSION is a real semver (not 0.0.0/-dryrun), only
publishes when BA_PUBLISH_CONFIRM=1 (otherwise it runs the gate and prints a preview),
and needs an npm auth token. In CI it emits build provenance via OIDC.
Preferred path — the manual workflow .github/workflows/publish.yml
(Actions → publish → Run workflow): it checks out the sibling family, builds commons
(dist-first), gates, then runs the guarded publish. It is a dry preview unless you
type publish in the confirm box. It uses the org-wide secrets NPM_TOKEN (publish
rights to @bitspark-ai) and BITSPARK_CI_TOKEN (read access to the sibling repos for
the family checkout — the default GITHUB_TOKEN is this-repo only), both already
provided on the Bitspark org, so there is nothing extra to configure.
Local equivalent (from a built family workspace, with an npm token configured):
BA_PUBLISH_VERSION=0.1.0 pnpm publish:npm # preview (runs the gate, no publish)
BA_PUBLISH_VERSION=0.1.0 BA_PUBLISH_CONFIRM=1 pnpm publish:npm # actually publishesRoadmap
- P0 — runner foundation (done):
BaPlugincontract, compile-time mount, reference plugin, smoke tests, esbuild bundle. - P1 — runtime plugin (done): adapted
@bitspark-ai/bitagent-runtime-cli(a commanderprogram.command("runtime")…) to export aBaPlugin; mounted asruntime. - Identity slice (done):
ba authover@bitspark-ai/accounts-client(login → session-key delegation), a runner-owned, target-scoped session/credential store, andba echoproving login → service-use end to end. - Commons plugin (done): mounted
@bitspark-ai/ba-commons-pluginascommons(the first external service to adopt the frame) — roster/inbox/lease/message/broadcast/decision. - P2 — gateway developer plugin (done): mounted
@bitspark-ai/gateway-cliasgateway(developer command surface: status, target, projects, key, runs, usage, auth). Consumer-safe per gateway #2771; the agw binary is resolved at runtime (GATEWAY_AGW_BIN→ optional co-installedbitspark-agw→PATH). - P3 — gateway operator plugin: extract the operator surface + deploy/ops into
@bitspark-ai/gateway-admin-cli; mount asgateway-admin. - P4 — shared runner UX: lift completion, richer help, and config from the gateway CLI into the runner so every plugin inherits them. (Runner-owned session/credentials already landed with the identity slice.)
- P5/P6 — distribution (packaging done; first publish pending): a dry-run gate
(
pnpm publish:dryrun) proves a clean, self-contained, zero-runtime-dep tarball that installs + runs outside the monorepo, and a guarded real-publish path (pnpm publish:npm/.github/workflows/publish.yml, OIDC provenance, npm-token + explicit-confirm gated) is in place, wired to the org-wideNPM_TOKEN+BITSPARK_CI_TOKENsecrets. See Distribution. Pending: merge the publish workflow + run the first real publish; then retire the standalone per-service bins.
