@antasphere/cli-core
The shared foundation every Antasphere tool CLI builds on. One config home for
the whole ecosystem, per-tool profile namespaces where a profile is an
instance, the flag → env → profile → cloud URL resolution order, the
per-command organization override, the instance email-OTP auth client, the
hub → tool connect exchange with its connect-on-demand seam and the
stranded-key recovery, and the CliIo prompt seam. Tool commands, wire types,
and API surfaces live in the tools — this package carries only config/login
plumbing, never secrets.
The model: one identity at the hub (antasphere login, a user-scoped ant_
key); per-tool credentials minted from it by the exchange
(POST {hub}/api/v1/sso/tool-token, then POST {tool}/api/v1/sso/cli-connect
— the exchange names no organization, hub ADR 014); the organization a
per-request parameter (the hub's server-side default organization, or
--org <hub org id or name> on one command, which the tool maps to its own
workspace through central_account_id). Every tool CLI ships its cloud URL and
resolves it last, so antasphere login then slideless list works on a clean
machine.
Consumers today: the antasphere account CLI (the hub's packages/cli), the
slideless CLI and the hackathon CLI. The design decisions live in the hub
architecture brief (hub-architecture.md §8, CLI family; §4c, the CLI token
flow).
The config home
$XDG_CONFIG_HOME/antasphere/ (default ~/.config/antasphere/), directories
0700, files 0600. One file per tool namespace — tools never collide, and a
new tool is a new file, never a schema change:
antasphere/ 0700
├── config.json 0600 the `hub` tool — the `antasphere`
│ account CLI (its pre-extraction
│ location, kept for compatibility)
└── tools/ 0700
└── <tool>.json 0600 every other tool, e.g. tools/slideless.json
Each file holds one tool's profiles. A tool file, e.g. tools/slideless.json:
{
"activeProfile": "cloud",
"profiles": {
"cloud": {
"baseUrl": "https://slideless.antasphere.com",
"connectKeys": {
"default": {
"apiKey": "slk_…",
"email": "[email protected]",
"mintedAt": "2026-09-30T09:12:44.000Z"
}
}
},
"share.antasphere.com": {
"baseUrl": "https://share.antasphere.com",
"apiKey": "slk_…"
},
"localhost:3400": {
"baseUrl": "http://localhost:3400",
"apiKey": "slk_…"
}
}
}
baseUrl is the instance the profile is; a profile with no baseUrl is at the
tool's cloud URL. apiKey is the tool's own login; connectKeys holds the keys
the hub exchange minted, one per hub profile (the slot name, here default, is
the hub profile's name).
The hub's config.json:
{
"activeProfile": "default",
"profiles": {
"default": {
"apiKey": "ant_…",
"baseUrl": "https://account.antasphere.com",
"email": "[email protected]",
"workspaceId": "…"
}
}
}
workspaceId is the default organization at login — informational; the hub's
server-side default is what applies. All config I/O is env-injected (no
process.env reads) — tests point the whole config world at a temp dir via
XDG_CONFIG_HOME.
Profiles
A profile is an instance, named for its host by default (cloud for the tool's
cloud URL, share.antasphere.com, localhost:3400). selectProfile applies
one rule, in this order:
--profile <name>wins, and the profile must exist.--api-url, else the tool's URL env var, selects the profile of that instance; when several name it, the active one first, thencloud, then the first declared. When none does, a profile is made in memory, named for the host —cloudwhen it is the cloud URL — under a name no profile on disk has (a taken name falls back to the host, then<host>-2,<host>-3). The chassis passesapiUrl: firstSet(flags.apiUrl, env[URL_VAR]), the same mergeresolveBaseUrlmakes, so a blank flag never hides the env var.- The active profile — the choice the person makes with
<tool> use <profile>. - The profile at the tool's cloud URL,
cloudby default: the one on disk at that instance whatever its name (a profile namedcloudthat lives at another instance is not it), else made in memory under a free name —cloud, or the host (then<host>-2, …) whencloudis taken elsewhere.
created: true means the selected profile is not on disk yet (made in memory
for an instance URL, or the implicit cloud profile) and its name is free. A
profile with no baseUrl is at the cloud URL, for the selection and for the
connect seam alike (the chassis hands connectOnDemand the same cloudUrl).
Selection never writes;
storeConnectKey is the only thing that writes such a profile, on first
contact, and makes it active only when the tool had no profile at all. It never
renames or repoints an existing profile, and never changes which profile is
active once the tool has one.
An active profile pointing at an unreachable instance is still the selection —
there is no silent fallback to cloud. The tool's error names the profile and
says the hub login exists; that wording is the tool's. default is an ordinary
profile name now, nothing pins it.
An illustration of what a tool can print for the three profiles above (a
slideless profiles-style listing, not a command this package provides):
PROFILE INSTANCE SIGNS IN WITH
* cloud https://slideless.antasphere.com hub
share.antasphere.com https://share.antasphere.com key
localhost:3400 http://localhost:3400 key
The last column is profileCredential: key (the profile's own apiKey, the
tool's login), else hub (a key the exchange minted), else none.
Surface
configPath / loadConfig / saveConfig / clearConfig (env, tool)— the tool-namespaced store.HUB_TOOLnames the hub namespace.redactKeyrenders keys safely for terminals.parseConfig(raw)is the tolerant parse (corrupt or foreign shapes → empty config);assertToolNamespace(tool)refuses anything but a lowercase slug.resolveProfile(config, requested, { unknownHint })— the named (or active) profile; an explicitly named missing profile throwsCliUsageError.selectProfile({ config, requested, apiUrl, cloudUrl, unknownHint })— the selection rule of Profiles; returns{ name, profile, source, created },sourcebeingflag,url,activeorcloud. Nothing is written, no profile is repointed.CLOUD_PROFILE—'cloud', the implicit profile at the tool's cloud URL.normalizeUrl(url) / sameInstance(a, b) / profileNameFor(url, cloudUrl)— the comparable form of an instance URL (scheme, host with port, path; no trailing slash, query or fragment), equality on it, and the name an instance's profile gets (cloud, else the host with its port).profileUrl(profile, cloudUrl)is the instance a profile is at (itsbaseUrl, a blank one counting as none, else the cloud URL);firstSet(...values)the first non-blank value, trimmed — the one rule every resolver applies to flags, env vars and profile fields, the hub profile'sapiKeyandbaseUrlincluded.resolveBaseUrl({ flag, env, envVar, profile, cloudUrl, missingMessage })— flag → tool env var → profile → cloud URL, trailing slashes stripped; a blank value counts as unset (SLIDELESS_URL=""never hides the cloud URL); throwsmissingMessageas aCliUsageErroronly when nothing resolves and the tool has no cloud URL.resolveApiKey({ flag, env, envVar, profile })— flag → env → profile, blank values skipped.resolveOrg({ flag, env, envVar })— the organization override for one command:--org, else the tool's org env var, elseundefined(the hub's server-side default applies); blank values skipped. Nothing is stored; it never reaches the exchange.CliUsageError(message, { exitCode })— a usage-level error; runners print it and exit withexitCode(default 1).CliAuthClient({ baseUrl, fetch? })— the browserless email-OTP flow every instance speaks:request({ email }),complete({ email, otp, keyName?, expiresInDays?, workspaceId? })(the full key appears exactly once),revoke(apiKey)(self-revocation = logout). Errors areCliAuthErrorwithstatus/code/message.CliIo+ask+printJson— the injectable I/O seam (env, streams, fetch, interactive prompt) so tool CLIs run in-process under test.CliConnectClient({ hubBaseUrl, toolBaseUrl, fetch? })— the cross-tool connect exchange (binding patterns §7, gcloud model — one hub login serves every federated tool):toolToken(hubApiKey, { resource })asks the hub for a 120 s USER-scoped JWT plus the tool's one-time offline grant (hubRefreshToken; the exchange names NO org — hub ADR 014),cliConnect(token, hubRefreshToken?)trades the pair at the tool for a user-scoped tool-local key (no auth header — the JWT is the credential; the grant is relayed so a fresh user's connect is never refusedhub_grant_missing),exchange(hubApiKey, body)composes both; the hub key never reaches the tool, the grant never reaches anything but the tool, and neither is ever persisted here. Errors areCliConnectError(aCliAuthError) tagged with the failingstageplus four remedies:needsHubLogin(hub 401, or 403insufficient_scopefrom a pre-sso:exchangekey),needsReExchange(tool 401 on an expired/replayed JWT),refusal(a 403 that is not the scope case, from either side — the account was refused, not the key) andexitCode(3 for a refusal, else 1).lookupConnectKey / storeConnectKey / removeConnectKey / profileCredential— the cache of exchange-minted user-scoped keys, held on the profile of the instance they were minted for, in itsconnectKeysmap (slot = the hub profile name;selectHubProfile(env)resolves it). ONE key per hub account serves every org — the org is never part of the credential or the slot.storeConnectKey(env, tool, profileName, hubProfileName, entry, { baseUrl })writesbaseUrlonly on a profile that has none, a blank one counting as none (a profile is never repointed), creates a profile not on disk yet, and makes it active only when the tool had no profile at all.removeConnectKeynever touches the tool's ownapiKey.profileCredential(profile, hubProfileName?)answerskey/hub/none.cachedConnectKey(profile, hubProfileName, hubEmail)is the slot read the seam serves from: the key only when both e-mails are there and match.connectOnDemand({ tool, toolBaseUrl, cloudUrl?, hubCloudUrl?, profileName, env, resource?, fetch?, notify?, messages?, cached? })— the seam a tool CLI runs when NO direct credential resolved.profileNameis REQUIRED: the profileselectProfilechose;cloudUrlis what a profile with nobaseUrlis at, the same value the selection got. The cache is read first, on that profile only when it is at this instance (a hit costs zero network);cached: falseskips it. On a miss the anonymousGET /api/v1/instanceprobe gates the flow (a self-hosted or unreachable instance answers{ outcome: 'not_cloud' }and the tool raises its classic no-key error), the hub credential is read from theHUB_TOOLprofile,exchangeruns with one re-exchange retry on a dead JWT (a missing or rejected hub login → a guidedantasphere loginCliUsageError, on either attempt; a refusal propagates as itsCliConnectError), and the key is stored on the selected profile — never on one that is at another instance. The hub profile the exchange uses is the oneANTASPHERE_URLnames (the profile at that instance, the wayantasphere loginpicks it;hubCloudUrllets that URL name thecloudslot, places a hub profile with nobaseUrlat the hub and is the hub URL its exchange goes to; a profile's ownbaseUrlalways wins), else the active one, and none when no hub profile is active (the account CLI would then take its cloud profile; a tool CLI asks forantasphere login). A cached tool key is served only when the hub profile's e-mail and the cached key's e-mail match, and never when either is missing. Success:{ outcome: 'connected', key, cached, result?, stored? }—cached: true= served from the cache.notifyreceives the "Connected to …" notice on fresh exchanges (cache hits are silent);messagesoverrides the copy.probeToolInstance(toolBaseUrl, fetch?)is the discovery probe on its own;selectHubProfile(env, { cloudUrl? })is that hub profile, whose name is the cache slot (HUB_URL_ENVisANTASPHERE_URL;activeHubProfile(env)is kept, deprecated, asselectHubProfile(env)).refreshConnectKey(opts)— the stranded-key recovery, same options asconnectOnDemand. When a request answers 401 with a key servedcached: true, the chassis calls it once: it evicts that key (only from a profile at this instance; a key of another instance is never touched) and exchanges again with the cache skipped. The chassis retries its request once with the new key, and a second 401 fails;not_cloudmeans nothing to recover with.migrateLegacyConfig(env, { tool, legacyDir })— one-shot, non-destructive import of a tool's pre-cli-core$XDG_CONFIG_HOME/<legacyDir>/config.jsoninto the shared home. Imports only when the tool has no profiles in the shared home yet and the legacy file holds at least one; the legacy file is left in place.
Exit codes
1— a usage or wire error.3— a refusal: a 403 at the exchange that is not the scope case; the message is the server's own sentence, printed as is.
Runners read exitCode off CliUsageError and CliConnectError.
Consuming it
"dependencies": { "@antasphere/cli-core": "^0.6.0" }
Registry tarballs ship a prebuilt dist/, no build script runs on install.
Release
Releases are tokenless via npm trusted publishing (OIDC):
.github/workflows/publish.yml fires on a vX.Y.Z tag, verifies the tag
matches package.json#version, runs lint/typecheck/test/build, and publishes
with no npm token — npm authenticates the workflow's OIDC identity directly.
# bump package.json#version, commit, then:
git tag v0.6.0 && git push origin v0.6.0
The first publish was done by hand, and the trusted publisher is configured on npmjs.com; every release is the tag.
| Field | Value |
|---|---|
| Organization or user | antasphere |
| Repository | cli-core |
| Workflow filename | publish.yml |
| Environment | (leave empty) |
Development
pnpm install
pnpm lint && pnpm typecheck && pnpm test && pnpm build
Branches per the workspace rule: prod (default, releases/tags) + dev
(day-to-day work). License: MIT (permissive on purpose — this is plumbing
consumed by public CLIs).