npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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 dev

Verbs

| 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 PATH

Inside 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 upward

next 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 first

The 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:

  1. Switch the toolbar's Data dropdown to Live API (the entry needs a live block). An integration whose widgets never call the vendor from the browser — the OAuth shape — can declare live: { default: 'live' } and the harness starts there; a choice made at the toolbar still wins.
  2. 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 sets oauth.setup — and then a form with one field per secret name your definition declares (oauth.credentials.*SecretName, every signed webhook's secretName). 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 as filled beside an empty field, and a blank field keeps what is saved. (Editing the file by hand works too.)
  3. 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 with EgressDeniedError exactly as on the host — and calls your onTokens on a server-held context. oauth/status then reports connected, the SDK's OAuthActivationForm auto-activates, and onActivate runs on that same context.
  4. Your widgets read what onTokens, onActivate and 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 — snapshots package.json and the lockfile, installs the target versions, then VERIFIES with ekanos validate and the project's own test script. Any failure at any step rolls everything back — package.json, lockfile and node_modules all restored — and reports exactly which step failed with its output. --yes skips the interactive confirm (required outside a real TTY); --no-verify skips 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 stub test script (echo "Error: no test specified" && exit 1) is replaced. It is npm's placeholder, not a choice, and keeping it left ekanos test failing on a fresh scaffold.
  • "type" is set to module even if you declare "commonjs", with a loud note saying it changed. npm init -y writes "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 one package.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 logout

Host resolution

logout, whoami, status, sources, catalog, agents, use and publish resolve the Ekanos host in this order:

  1. the --host flag
  2. the EKANOS_HOST environment variable
  3. the host field in the project's ekanos.json
  4. 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

  • login with a session that still works reports already-authenticated and never opens a browser. --force starts a fresh login regardless.
  • logout twice succeeds; the second run reports cleared: false.
  • logout ends the server-side session (sessionRevoked) and revokes at Auth0 (revoked) first, but a failed revocation never stops the local delete — it is reported as false with 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 sent

ekanos 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.