@ekanos/cli
v0.16.0
Published
The Ekanos partner toolchain CLI: scaffold, validate, and test an Ekanos integration against the published @ekanos packages. Agent-native — every verb speaks JSON with a stable exit-code taxonomy.
Maintainers
Readme
@ekanos/cli
The Ekanos partner toolchain. Scaffold, validate, and test an Ekanos integration
against the published @ekanos packages — from any Node project, with no
bundler and no dependency on @ekanos/sdk at runtime.
Docs: sdk.ekanos.dev/cli/overview, with every verb at sdk.ekanos.dev/cli/commands.
npx @ekanos/cli init --slug my-integration --yes # scaffolds into ./my-integration (or --dir . to scaffold in place)
npx @ekanos/cli validate
npx @ekanos/cli test
npx @ekanos/cli devVerbs
| Verb | What it does |
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| init | Scaffold ekanos.json, a working defineIntegration, a test, a tsconfig.json, a vitest config, .gitignore, a package.json with real published deps, and the agent-context files (AGENTS.md, CLAUDE.md, a Claude Code skill). Defaults to a new ./<slug> directory; --dir . scaffolds in place, on top of an existing project if there is one. |
| validate | Load the definition via esbuild and validate it with the real @ekanos/integration-schema zod schemas; add project checks (the required vitest SDK-inline block). |
| dev | Scaffold .ekanos/harness/ and run it with your Next, so your widgets, tile, activation form and triggers render in real Ekanos chrome. |
| test | Delegate to the project's own test script via its package manager. |
| login | Authenticate this machine against an Ekanos deployment (Auth0 device flow). |
| logout | Revoke the session at Auth0 and delete the local credentials. |
| whoami | Print the identity the stored session authenticates as, plus a compact seats list (slug + roles) from the same endpoint sources uses — degrades to seats: null rather than failing if that lookup is unavailable. |
| status | The read verb: login state for the resolved host, this project's ekanos.json, and its submissions on the host (GET /api/partner/submissions). Never fails just because you are logged out — it reports authenticated: false instead. |
| sources | List every Ekanos source you hold a developer or source-admin seat on (GET /api/partner/sources), with a marker on this project's current publish target; for a Source admin also products, scopes, roles [create\|update\|delete <role>] (the Source's team roles) and people (everyone on the Source, one row per person). |
| catalog | ekanos catalog [<product-slug>] — what a source can already use (GET /api/partner/catalog): every product, Experiences then Integrations as sources products splits them (the two list the same products), enabled, switched off or available to enable, with its tools and their input schemas, whose connection an agent run uses, agent triggers and generated setup steps. Check it before building something the source may already have. |
| agents | ekanos agents init | list | show | pull | apply | publish | rollback | archive | unarchive — the source's agents, held by Ekanos (/api/partner/agents). init sets up a folder for designing agents with Claude (the build-agent and ekanos-platform skills, the docs MCP server, a CLAUDE.md) without scaffolding an integration. apply stores a new version as a draft, publish makes a version the one every team runs, rollback publishes an earlier one, archive retires one no team has on. Either seat applies; only the source admin publishes or archives.
| teams | A Source admin's teams (/api/partner/teams): members, connections, and each team's agents. ekanos teams agents create <team> --from <dir> [--personal] builds the team's own agent — or, with --personal, your own, which acts for you and only you change — from the same agent.json (+ AGENT.md, + skills/<slug>/) that agents apply reads; teams agents apply <team> <dir> [--base-version <n>] creates or changes it from that file. You become its creator, so you must be an active member of the team. teams triggers add <team> <key> schedule\|webhook … starts it; a webhook token goes to a 0600 file under ~/.ekanos/secrets, never the output. Someone else's personal agent is refused for every write (PERSONAL_AGENT_CREATOR_ONLY). |
| reviewers | ekanos reviewers list \| add <email> --reach source\|team [--team <slug>] \| remove <email> [--team <slug>] — a Source admin names who decides agent reply drafts held under an approve policy (/api/partner/reviewers). A source-reach reviewer decides every team's drafts; a team-reach reviewer one team's. The reviewer must be a member of one of the Source's teams. |
| workflows | ekanos workflows list \| show \| pull \| apply \| enable \| disable \| run \| runs list\|show\|cancel \| approvals list\|approve\|reject --team <slug> — a team's workflows, for its Source admin (/api/partner/teams/<team>/workflows). A workflow is the JSON the visual editor saves; apply creates a draft (or replaces one with --update) through the editor's own checks, including the Custom Code security review and approval gates before writes; enable re-checks it and asks you to approve what Custom Code reaches (--confirm-access in a script); run --dry-run rehearses it with writes recorded, not sent. Needs the Workflows Experience on for the Source; a member's personal workflow is never reachable. |
| use | ekanos use <source-slug> — set this project's publish target to a source you hold a seat on. Refuses (forbidden, with your actual seats in the hint) for a slug you don't hold a seat on, so a typo can't silently repoint a project. |
| publish | Validate, pack and submit the integration to an Ekanos host — refusing (forbidden) a target you hold no seat on, and confirming an ambiguous multi-seat target before submitting. See Publish target verification. |
| upgrade | Move this toolchain to a coherent target version, with verify-and-rollback. --check reports current vs. target and changes nothing; a bare upgrade installs, verifies (validate + the test script), and rolls back automatically on any failure. See Keeping the toolchain current. |
Installing: global is fine
npm i -g @ekanos/cli # convenient — the `ekanos` binary is always on PATHInside a project, validate/dev/test automatically delegate to
that project's own pinned @ekanos/cli devDependency whenever it differs
from the globally invoked binary — the same wrapper pattern as Gradle's
./gradlew or npx. A partner with several integrations on different
@ekanos/sdk versions gets the right toolchain per project without juggling
installs by hand. Machine-level verbs (init, login, logout, whoami,
sources, use, status, upgrade) and publish, which sends your session
token to the host, never delegate — they always run on the binary you invoked.
Set EKANOS_NO_DELEGATE=1 to opt out for one invocation.
Every command a hint or a next step names is written the way you ran the
CLI, so it can be pasted back as-is: ekanos … from the installed binary,
npx @ekanos/cli … when the run came through npx.
Global flags
| Flag | What it does |
| ----------- | ---------------------------------------------------------------- |
| --json | Force the JSON envelope on, even at a terminal. |
| --no-json | Force the human-readable form on, even when piped. |
| --version | Print the CLI version and exit 0. Works with or without a verb. |
| --help | Print usage. Per command (ekanos teams --help) it prints that command's subcommands and every flag with what it does; with --json, as usage, details and options. |
Short flags are not supported — the parser rejects them rather than guessing.
Agent-native envelope
Every verb speaks JSON. JSON mode is inferred from a non-TTY stdout or
CLAUDECODE=1, and an explicit --json / --no-json overrides that inference
in either direction. In JSON mode stdout carries exactly one JSON object —
{ ok, data, error: { code, message, hint } } — and every progress line goes to
stderr. Errors always carry an imperative hint.
On --help, an unknown command and an out-of-date toolchain the CLI also
writes one short claude-code-hint: {…} line to stderr — the command list, the
suggested fix, or the one command's usage, never the whole manual. It is
written only when --json is passed or a coding agent is detected
(CLAUDECODE=1, CODEX_SANDBOX, CURSOR_AGENT=1, GEMINI_CLI=1, AI_AGENT);
a person at a terminal, or piping into another program, gets the readable error
alone. An unknown command suggests the closest one, and a whole command passed
as one argument (ekanos "teams list") is answered with ekanos teams list.
The CLI never prompts, with one deliberate exception: publish asks for a
y/N confirm at a real terminal when the publish target is ambiguous — an
implicit (ekanos.json) source and more than one developer seat. See
Publish target verification. Every other verb,
and publish itself in JSON/non-TTY mode or with an explicit --source, never
prompts. init requires --slug and fails with a usage error naming the flag
rather than asking for it.
validate, dev, publish and status additionally carry an OPTIONAL
data.toolchain field — { status: 'behind' | 'unsupported', current, target,
upgradeCommand: 'ekanos upgrade' } — whenever the background update check
(see Keeping the toolchain current) finds
this CLI is not current. Absent when current, unresolvable, or disabled via
EKANOS_NO_UPDATE_CHECK.
Exit codes (frozen public contract)
0 ok · 1 internal · 2 usage · 3 validation-findings · 4 auth-required ·
5 forbidden · 6 not-found · 7 invalid-state · 8 network ·
9 precondition-failed · 10 gate-failed.
Secrets without a terminal
Every command that takes a secret (apps set, apps connect, teams app set,
teams connect, teams mcp token, teams llm-key set) asks for it with
hidden input at a terminal, and otherwise — a script, CI, --json, a coding
agent — reads it from one of these. Never from a value on the command line.
| Flag | The secret comes from |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| --secret-env <NAME> | the environment variable NAME (its name, never its value) |
| --secret-file <path> | a file of yours readable only by you (chmod 600); symlinks are refused |
| --secret-stdin | stdin, piped in |
| --set-secret <field>=env:<NAME>\|file:<path>\|stdin | one field of a form with several secrets; repeat per field |
| --browser | a one-time form the CLI serves on 127.0.0.1 and opens in your browser; you type it there |
A field that is not a secret is --set <field>=<value>. --browser is never
the default (an unattended run would wait on it), is refused in CI, over SSH or
with no display, and gives up after five minutes. No output, --json envelope
or error carries the secret. Details:
Secrets without a terminal.
ekanos.json
{ "slug": "my-integration", "entry": "src/integration.ts" }More than one integration in a project? Use the list form instead — validate
then cross-checks them against each other (colliding slugs, widget ids, tool
names), which it cannot do when it can only see one:
{
"integrations": [
{ "slug": "acme-crm", "entry": "src/crm.ts" },
{ "slug": "acme-billing", "entry": "src/billing.ts" }
]
}Declare one form or the other, never both. validate also checks that each
slug here agrees with the slug in the definition it points at — they
address the same integration everywhere, so a disagreement is an error rather
than a warning.
Every field
| Field | Required | Where | What |
| ---------------- | ----------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| slug | yes, in the single form | top level | kebab-case; must equal the definition's slug |
| entry | yes, in the single form | top level | path to the module exporting your defineIntegration(...) result |
| integrations[] | yes, in the list form | top level | { slug, entry } per integration, same rules, slugs unique |
| sourceGlobs | no | top level only | extra Tailwind @source globs; project-relative, no .. |
| source | no | top level | the Ekanos source slug this project publishes to; written by the first successful publish |
| host | no | top level | the Ekanos host (http(s) origin) this project publishes to; written by the first successful publish, read by every host-taking verb |
| $schema | no | top level | ignored, for editor tooling |
The file is strict: an unrecognised key is an error naming the key, not a
silent no-op. sourceGlobs is deliberately not per-integration — Tailwind scans
one content set for the whole shell, so splitting it per integration would
imply an isolation that does not exist.
The generated shell
ekanos dev writes exactly this, and rewrites any of it whose bytes drift:
.ekanos/harness/
.ekanos-version { harness, cli, templates } — what generated this
app/layout.tsx stylesheet + metadata + shell re-export
app/harness-shell.tsx 'use client' — injects your registry
app/page.tsx server component — the index
app/[slug]/layout.tsx ─┐
app/[slug]/page.tsx │ (redirects /<slug> to /<slug>/widgets)
app/[slug]/widgets/page.tsx │
app/[slug]/widgets/[widgetId]/page.tsx
app/[slug]/tile/page.tsx ├─ one-line re-exports of @ekanos/harness/routes
app/[slug]/activation/page.tsx
app/[slug]/triggers/page.tsx ─┘
app/api/integrations/[slug]/[...path]/route.ts
server — injects your registry into @ekanos/harness/api
(live-mode OAuth loop, storage bridge, trigger route)
next.config.mjs transpiles @ekanos/harness, aliases the workspace mock
postcss.config.mjs @tailwindcss/postcss
tsconfig.json no baseUrl, no paths
styles/globals.css @import '@ekanos/harness/styles.css' + your @source globs
types/team-accounts.d.ts types the workspace mock through the export map
package.json scripts only — no deps, everything resolves upwardnext dev/next build add next-env.d.ts, .next/, and (Next 16)
AGENTS.md/CLAUDE.md alongside them. None of that is ours and none of it is
tracked — .ekanos/ is gitignored in full.
entry points at the module exporting your defineIntegration(...) result
(default export, or any named export that is an object with a string slug).
ekanos dev
pnpm add -D @ekanos/harness next react react-dom tailwindcss @tailwindcss/postcss
npx @ekanos/cli dev # scaffold, then run on http://localhost:3100
npx @ekanos/cli dev --port 4000
npx @ekanos/cli dev --no-start # scaffold only
npx @ekanos/cli dev --host 0.0.0.0 # expose on your network — read below firstThe harness binds loopback
dev passes --hostname 127.0.0.1 to Next, which by default would bind
0.0.0.0. The harness has no authentication and renders your fixtures — and,
in live mode, real responses from your vendor API — so on a shared network a
default wildcard bind hands all of that to anyone who can reach the port.
--host opts in explicitly (for a phone on the same Wi-Fi, say). It prints a
warning saying what is now reachable, and the JSON envelope carries
server.exposed: true so a script can refuse to run that way.
The shell's API routes (below) are stricter than the pages: they hold your
local OAuth client secret and the tokens onTokens stored, and they run your
handlers on request, so they refuse any request whose Host is not
loopback unless EKANOS_HARNESS_EXPOSED=1. dev --host <non-loopback> sets
that for the server it starts (and prefixes it on the command it prints with
--no-start), after the warning. Starting Next by hand with a wildcard bind
gets the refusal, not the exposure — which is the point.
Local OAuth — the real loop, on localhost
An integration that declares oauth in defineIntegration() can run the whole
authorize → callback → code-exchange → onTokens loop against your own
developer app, in live mode, with no Ekanos host involved:
- Switch the toolbar's Data dropdown to Live API (the entry needs a
liveblock). An integration whose widgets never call the vendor from the browser — the OAuth shape — can declarelive: { default: 'live' }and the harness starts there; a choice made at the toolbar still wins. - Open the Activation surface. The Local OAuth panel starts with a
checklist for the part only a human can do in the provider's developer
console (create the app, add the redirect URI
http://localhost:3100/api/integrations/<slug>/oauth/callback, enable the declared scopes, copy the client id and secret) — derived from your declaration, with a console link and provider-specific notes if your harness entry setsoauth.setup— and then a form with one field per secret name your definition declares (oauth.credentials.*SecretName, every signed webhook'ssecretName). Paste the values and Save credentials. They are written server-side to.ekanos/local-secrets.json— gitignored, never packed, read on every request — and never sent back to the browser: a saved key shows asfilledbeside an empty field, and a blank field keeps what is saved. (Editing the file by hand works too.) - Click Connect. The shell's route builds the authorize URL (PKCE when
declared), the provider sends you back, the route exchanges the code through
ctx.fetch— so an undeclared token-endpoint origin fails withEgressDeniedErrorexactly as on the host — and calls youronTokenson a server-held context.oauth/statusthen reports connected, the SDK'sOAuthActivationFormauto-activates, andonActivateruns on that same context. - Your widgets read what
onTokens,onActivateand your schedules wrote through the ordinary storage route, unchanged.
The connection lives in the dev server's memory: restarting next dev forgets
it and you Connect again. Editing a value in the secrets file rebuilds the
context and forgets the connection too — a different client id invalidates the
tokens anyway. In fixtures mode none of this runs: the status route is
answered from oauth.connected in your harness entry (connected by default),
so widgets render without a round trip and nothing leaves the browser.
Who owns what
| Path | Owner | You edit it? |
| ---------------------------------- | --------- | ----------------------------------------------------------------------- |
| harness.config.ts (project root) | you | yes — the only one. Written once, git-tracked, never touched again. |
| your integration source | you | yes |
| .ekanos/harness/** | generated | no — gitignored, rewritten on every run |
| @ekanos/harness | us | no — it is an ordinary dependency |
That split is the point: a harness bugfix ships as an @ekanos/harness version
bump you pick up with pnpm up, with zero edits to any file you own. Every
file in the generated shell is mechanical — re-exports of
@ekanos/harness/routes, an app shell that injects your registry, and
configs — so none of it ever needs a judgment call from you;
The generated shell above is the exact list.
.ekanos/harness/.ekanos-version records which @ekanos/harness generated it,
and when that disagrees with the installed version the shell is regenerated
automatically and dev says so on stderr.
Re-running is free: files whose bytes already match are not rewritten (so Next's
watcher stays quiet), and .ekanos/ is added to .gitignore exactly once.
--force rewrites the generated shell; nothing rewrites harness.config.ts.
dev refuses to write through a symlink, to write outside your project, or to
overwrite a .ekanos/harness it did not generate (one with no .ekanos-version
marker) unless you pass --force. Each exits 7 having changed nothing.
One constraint on your own code
harness.config.ts is imported from both a server component and a client
component, so everything reachable from it must be importable in both graphs.
Deriving fixture query keys from a 'use client' module compiles and then
fails at request time with Attempted to call … from the server. Move the key
builders into a plain module with no 'use client' directive and have your
hooks import that — which is also how you keep fixtures and hooks from
drifting apart.
Where your source lives
The generated styles/globals.css points Tailwind at the usual places
(src/, app/, lib/, components/, integrations/, packages/). If your
integration lives somewhere else, add globs to ekanos.json — do NOT edit the
stylesheet, which is regenerated on every run:
{
"slug": "acme-crm",
"entry": "src/integration.ts",
"sourceGlobs": ["vendor/acme/**/*.{ts,tsx}"]
}A glob Tailwind is not pointed at costs you a silently unstyled integration
with no build error, so these are validated strictly: project-relative, no
.., and a restricted character set, because they are written verbatim into
generated CSS.
Version compatibility
The shell templates ship with @ekanos/cli; the code they import ships with
@ekanos/harness. Both declare a shell contract — an integer that changes
only when the shell a harness requires changes (an export moving, the
server/client split, the set of files). dev compares them before it writes
anything and names the mismatch — @ekanos/cli 0.1.0 is too old for
@ekanos/harness 0.2.0: that harness needs shell contract 2, and this CLI
generates 1 — rather than letting it surface later as a module-resolution
error. A version bump that does not change the contract never blocks an
upgrade.
.ekanos/harness/.ekanos-version records the harness version, the CLI version
and a hash of the templates, so a change to any of them regenerates the shell
and dev says which one moved.
Contract history: 1 was the original ten-file shell; 2 (CLI 0.3 /
harness 0.2) added app/api/integrations/[slug]/[...path]/route.ts, the shell's
one server route, injecting the registry into @ekanos/harness/api.
For maintainers: propagation is byte-driven — dev rewrites a generated
file whenever its content differs, marker or not. So a change to the SHELL
ships in an @ekanos/cli release, never a harness one: bumping
@ekanos/harness alone cannot deliver a new template, because the templates
are not in that package. Bump SHELL_CONTRACT in
src/compatibility.ts only when the coupling changes, not for every template
edit.
For agents
dev in JSON mode (--json, a pipe, or CLAUDECODE=1) scaffolds and
returns rather than blocking on a dev server that never exits — the envelope's
data.server carries { started: false, command, url, port } so the caller can
start it itself. Pass --start to override.
Keeping the toolchain current
Three independent defenses against a stale toolchain, all landing together because they're the same underlying problem seen from three angles:
The schema-skew gate
validate and publish bundle an EXACT @ekanos/integration-schema version.
If the PROJECT resolves a NEWER one (checked against both a direct install and
whatever @ekanos/sdk itself declares a dependency on), both verbs fail fast
with precondition_failed (exit 9) naming both versions — before loading
the definition at all. This is not pedantry: in that state a field the newer
schema added parses here as an unrecognized_keys zod issue, and the usual
hint for that ("host-assigned fields are never partner-authorable — remove
them") tells you to delete code that is completely correct. A real partner hit
exactly this. Failing loudly beats answering falsely. The opposite skew — the
project's schema is OLDER — only warns; the CLI's own validation is still
correct, you just can't use fields it doesn't know about yet.
ekanos upgrade
Resolves a COHERENT target version set — GET /api/partner/toolchain on your
logged-in host when available, else the npm registry, with an explicit
coherence check (the @ekanos/sdk/@ekanos/ui/@ekanos/integration-schema
trio must share one version, and the sdk release at that version must pin
exactly that schema) so a mid-publish registry state is refused rather than
turned into an uninstallable tree.
ekanos upgrade --check— read-only, reports current vs. target, changes nothing. Safe to run anytime, including from CI or an agent.ekanos upgrade— snapshotspackage.jsonand the lockfile, installs the target versions, then VERIFIES withekanos validateand the project's own test script. Any failure at any step rolls everything back — package.json, lockfile andnode_modulesall restored — and reports exactly which step failed with its output.--yesskips the interactive confirm (required outside a real TTY);--no-verifyskips the post-install verification.- On success, the agent-context files (
AGENTS.md,CLAUDE.md, the Claude Code skill) are regenerated from the newly installed CLI's templates — byte-compared against what the PRE-upgrade CLI would have rendered, so a file you haven't touched is refreshed and one you've edited is left alone and reported as needing manual attention. A stale AGENTS.md teaches an agent yesterday's contract, which is as harmful as a stale binary.
The proactive update notice
validate, dev, publish and status all check (in the background, with a
24h cache and a 1.5s timeout — never blocking, never changing a verb's exit
code) whether a newer @ekanos/cli exists. Human mode gets one stderr line;
JSON mode never prints prose to stdout, but the envelope's data.toolchain
carries { status: 'behind' | 'unsupported', current, target, upgradeCommand
} when you're not current, and (with --json or a detected agent) the same
object rides the claude-code-hint stderr line so an agent can act on it
without parsing prose. unsupported
(below the host's declared minimum) makes publish fail fast — the host would
reject the submission anyway. Set EKANOS_NO_UPDATE_CHECK=1 to disable
entirely.
ekanos init on an existing project
npm init -y first is fine. init merges into a package.json you
already have rather than skipping it: missing dependencies, devDependencies and
scripts are added, and anything you already set is left alone — your pinned
version and your own test script are decisions, not omissions.
Deliberate exceptions:
npm init -y's stubtestscript (echo "Error: no test specified" && exit 1) is replaced. It is npm's placeholder, not a choice, and keeping it leftekanos testfailing on a fresh scaffold."type"is set tomoduleeven if you declare"commonjs", with a loud note saying it changed.npm init -ywrites"commonjs"explicitly, so that is the mainline rather than an edge case — and the files this scaffolder writes are ESM TypeScript that cannot load under it. Leaving it would mean writing files that do not run. If the project has CommonJS sources of its own, revert that line and put your integration in its own package: the two module systems cannot share onepackage.json.
Every verb after init fails fast with precondition_failed (exit 9) if
"type" is not module, naming the file and the fix — so missing the note
costs you one clear error rather than a module-resolution failure from inside
Node or Next.
Anything init does not do is reported with a hint saying what to do about it —
data.skipped[] carries { file, reason, hint }, and data.manifest.notes[]
carries the same for manifest fields it could not resolve.
Authentication
ekanos login --host https://your-ekanos-host # or set EKANOS_HOST
ekanos whoami # host resolves — see below
ekanos logoutHost resolution
logout, whoami, status, sources, catalog, agents, use and publish resolve the Ekanos
host in this order:
- the
--hostflag - the
EKANOS_HOSTenvironment variable - the
hostfield in the project'sekanos.json - the credential store, when it holds exactly one host (which one was chosen is logged to stderr)
login is the exception: it resolves from --host, else EKANOS_HOST, else
the sole stored host — never from ekanos.json. Login is the one verb
that creates credentials, and ekanos.json is a committed file; if login
honored it, anyone could point your device flow at their own tenant just by
editing a file in a repo you clone. Every other verb is safe reading it,
because their bearer tokens are looked up per-host from the credential store —
an unfamiliar host in ekanos.json simply has no session to present. Nor will
the CLI suggest one: a "not logged in" hint names ekanos login --host <url>
only for a host you have logged in to or an Ekanos domain. For any other host
it says where the host came from and asks you to check it before logging in
yourself, because a coding agent follows hints.
A host you have never logged in to is new unless it is loopback or under
Ekanos's own domains (vastlyfusion.site and its subdomains); a Source's
custom domain is new the first time. login to a new host shows the host and
the Auth0 domain it signs in through and asks before the browser step. With
nobody to ask (JSON mode, an agent, no TTY) it exits 7 unless you pass
--trust-host. The sign-in domain must be *.auth0.com.
A remote http:// host is refused (exit 9) by login and every verb that
sends credentials; localhost, 127.0.0.1, [::1] and *.localhost stay
allowed. logout still removes a session stored for one, with a warning.
So the common single-deployment flow is: ekanos login --host <url> once, then
every later verb with no flag at all. The first successful ekanos publish
also persists host (and source) into ekanos.json, so the project itself
remembers its deployment from then on. With two or more stored hosts and no
other answer, the CLI refuses with a usage error listing them — it never picks
one silently. login never writes ekanos.json; it is routinely run outside a
project.
login uses the OAuth 2.0 device authorization flow: it prints a short code
and a URL to stderr, and you approve the request in a browser on any device.
That is deliberate — a loopback/PKCE login needs a browser on the same machine
and a bindable localhost port, which is exactly what you do not have over SSH or
inside a container.
There is nothing to configure on your side. The CLI asks the host for its Auth0
domain, client id and scope (GET /api/auth/cli-config), so one binary works
against a sandbox and production.
Where credentials live
~/.ekanos/credentials.json, mode 0600, inside a ~/.ekanos directory
forced to 0700, keyed by host so several deployments can be held at once.
Set EKANOS_HOME to relocate it (containers, CI).
If either is found group- or world-accessible, the CLI tightens the permissions
and warns on stderr — it does not refuse to run, but it does tell you, and you
should assume the token was exposed and re-run ekanos logout && ekanos login.
The file holds the rotating Auth0 refresh token plus a short-lived Ekanos JWT.
No token is ever printed: not by whoami, not in an error, not in a --json
envelope, not at any log level.
Idempotence
loginwith a session that still works reportsalready-authenticatedand never opens a browser.--forcestarts a fresh login regardless.logouttwice succeeds; the second run reportscleared: false.logoutends the server-side session (sessionRevoked) and revokes at Auth0 (revoked) first, but a failed revocation never stops the local delete — it is reported asfalsewith a warning on stderr.
Rotation safety
Auth0 rotates the refresh token the moment the server's exchange succeeds, so
from that instant the replacement is the only copy in existence — and
/api/auth/cli-refresh can still fail afterwards. It therefore returns the
rotated token on its error responses too, and the CLI persists it the moment it
is seen, before surfacing any error. A transient upstream blip costs you a
retry, not your session.
Two hazards, both handled and worth telling apart:
- Rotate-then-fail (single process): the refresh rotates and a later step fails. Handled as above — retry, no re-login.
- Concurrent rotation (two processes sharing one credentials file): both
present the same token, Auth0 honours the first, the second gets a clean
exit 4. No file lock; re-run
ekanos login.
Exit codes to branch on
4 (auth-required) means "run ekanos login" — no session, an expired one that
could not be renewed, or a revoked one. 8 (network) means the host was
unreachable, or the refresh rotated but could not complete; it is not a
signal to log in again — retry it.
What this does and does not grant
login establishes identity. What that identity is permitted to do is a
separate concern, handled by Ekanos's own authorization; the CLI adds no
permissions of its own and no path to acquire any.
Publish target verification
A logged-in developer often holds a seat on more than one Ekanos source. The owner's requirement this mechanism exists for: it must be impossible to publish to the wrong source by accident.
ekanos sources # which sources do I hold a seat on?
ekanos use <source-slug> # set this project's publish target, seat-checked
ekanos publish # verified before anything is packed or sentekanos sources reads GET /api/partner/sources and lists every source you
hold a developer or source_admin seat on, with a * marking the slug currently saved in
this project's ekanos.json. ekanos use <slug> persists that field, but only
after confirming the slug names a source you actually hold a seat on — a typo
or a stale slug is refused (forbidden) with your real seat list in the hint,
never silently written.
ekanos publish resolves its target exactly as before (--source flag, else
ekanos.json), then — before packing or submitting anything — fetches your
seat list and applies these rules:
| Source came from | Seats held | Behavior |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| any | not among your seats | forbidden (exit 5): refuses before packing/submitting; the hint lists your actual seats |
| --source flag (this run) | any | proceeds immediately — an explicit flag is consent |
| ekanos.json (implicit) | exactly one | proceeds, printing "Publishing to <name> (<slug>) — your only developer seat" |
| ekanos.json (implicit) | more than one, human mode and a real terminal on stdin | prints the target and asks for an explicit y/N confirm; --yes skips it |
| ekanos.json (implicit) | more than one, anything else (JSON mode, --json, piped, an agent, or human mode with non-interactive stdin — a cron job, a wrapper script) | never prompts — proceeds only with --yes; otherwise validation (exit 3) with the seat list and a hint naming --source, --yes, and ekanos use |
A successful publish's envelope carries source: { id, slug, name } — the
verified target, not just the slug string — so a script or an agent can log
exactly which source a submission landed on.
For agents: never pass --yes through an ambiguity you have not resolved.
Either pass --source <slug> explicitly, or run ekanos sources first and
choose deliberately. --yes exists for a human's already-confirmed intent and
for CI pipelines pinned to one source — not as a way to silence the check.
