Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 39 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@ Or run via `npx @kodycodes/cli` without a global install.
| `kody install` | Detect running local MCP clients, write their config, and start host OAuth. **Recommended long-term path.** |
| `kody skill install` | Copies the getting-started skill into Claude Code / Cursor / Agents. |
| `kody login` | Browser OAuth (CIMD + PKCE) for the CLI itself. Stores access and refresh tokens. |
| `kody logout` | Deletes stored CLI credentials. |
| `kody status` | Shows CLI login state without printing secrets. |
| `kody logout` | Deletes stored CLI OAuth credentials and any stored bootstrap/API token. |
| `kody status` | Shows CLI login / stored API token state without printing secrets. |
| `kody auth bootstrap --code` | Redeems a one-shot `kody_bc_…` from MCP `cliCredentialBootstrap` and stores the resulting `kody_at_…` for `execute --local` (never prints the token). |
| `kody whoami` | Confirms the CLI MCP connection and lists tools. With a scoped API token (and no login), shows token identity via the Open API. |
| `kody search [query]` | Calls Kody `search` from the CLI (prefer the host MCP tool). Token-only auth uses Open API `GET /v1/search`. |
| `kody execute` | Calls Kody `execute` from the CLI (`--invoke`, `--code`, `--file`, or stdin via `--file -`). With a scoped API token and no login (or with `--token`), cloud execute goes through CapabilityProxy → `kody.execute` — no `kody login`. Add `--local` to run the module (and static `kody:@…` package modules) on this machine instead. |
Expand All @@ -70,13 +71,21 @@ Or run via `npx @kodycodes/cli` without a global install.

## Token-authenticated execute (no `kody login`)

Scoped API tokens (`kody_at_…`, from the MCP `api` tool `tokenCreate` or
`POST /v1/tokens`) authenticate the Open API and CapabilityProxy. They never
replace MCP OAuth on `/mcp`. The CLI uses that token path when you pass
`--token` / `KODY_API_TOKEN` and are not logged in (or when you pass `--token`
explicitly):
Agents already on Kody MCP should prefer `cliCredentialBootstrap` →
`kody auth bootstrap --code …` (ADR 0056) so a one-shot `kody_bc_…` seeds the
CLI store without pasting `kody_at_…` into chat. Interactive humans can use
`kody login`. Scoped API tokens (`kody_at_…`, from bootstrap redeem,
`tokenCreate`, or `POST /v1/tokens`) authenticate the Open API and
CapabilityProxy. They never replace MCP OAuth on `/mcp`. The CLI uses that
token path when you pass `--token` / `KODY_API_TOKEN` and are not logged in
(or when you pass `--token` explicitly):

```bash
# Preferred for agents on MCP (no tokenCreate, no second OAuth):
# 1. MCP api / kody.cliCredentialBootstrap → { bootstrap_code, cli_command }
npx @kodycodes/cli auth bootstrap --code 'kody_bc_…'
npx @kodycodes/cli execute --local --file ./task.js

export KODY_API_TOKEN=… # scopes: local-execute (+ search:read for search)
# Cloud execute — module runs in Kody's sandbox via CapabilityProxy → kody.execute
npx @kodycodes/cli execute --code 'export default async () => ({ ok: true })'
Expand All @@ -86,23 +95,24 @@ npx @kodycodes/cli search "what can you do"
npx @kodycodes/cli whoami
```

- Neither `kody login` nor a token → the error tells you to mint one with the
MCP `api` tool `tokenCreate` (scopes: `local-execute` plus the capability
scopes the module will call) and pass `--token` / `KODY_API_TOKEN`, or run
`kody login` (for cloud MCP commands and for login-backed `execute --local`).
- Neither bootstrap store, `kody login`, nor a token → the error prefers
`cliCredentialBootstrap` → `auth bootstrap`, then `kody login`, then
`tokenCreate` for CI/headless (`--token` / `KODY_API_TOKEN`).
- For `execute --local` specifically: `--token` / `KODY_API_TOKEN` wins when
set; otherwise the CLI uses the stored `kody login` OAuth access token as
the Bearer (no under-the-hood `tokenCreate`). The Open API must accept that
OAuth bearer on CapabilityProxy / package-graph
set; else a stored bootstrap/API token from `auth bootstrap`; else the
stored `kody login` OAuth access token as Bearer (no under-the-hood
`tokenCreate`). The Open API must accept that OAuth bearer on
CapabilityProxy / package-graph
([kentcdodds/kody#2812](https://github.com/kentcdodds/kody/issues/2812));
until then mint a scoped `kody_at_…` token.
until then use bootstrap or a scoped `kody_at_…` token.
- Wrong/expired token → 401 with a mint-fresh-token message (or the OAuth
platform-gap message when the bearer is login OAuth).
- Wrong scopes → the error includes `insufficient_scope` and the required
scope when Kody sends one.
- Account flag off → the error includes `feature_disabled` and the
`local-execute` feature flag. Another token does not bypass that flag.
- Prefer the env var so the token stays out of shell history and `ps`.
- Never scavenge host MCP OAuth tokens from disk (ADR 0053).

## Local execute

Expand All @@ -121,19 +131,24 @@ CapabilityProxy → `kody.execute` defer). There is no author-facing
npx @kodycodes/cli login
npx @kodycodes/cli execute --local --file ./task.js --params '{"to":"me@example.com"}'

# Or a scoped API token (still wins over login when set):
# Agents on MCP: bootstrap code → store (no tokenCreate, no second OAuth):
npx @kodycodes/cli auth bootstrap --code 'kody_bc_…'
npx @kodycodes/cli execute --local --file ./task.js --params '{"to":"me@example.com"}'

# Or a scoped API token (still wins over login / bootstrap store when set):
export KODY_API_TOKEN=… # minted through the Kody `api` tool
npx @kodycodes/cli execute --local --file ./task.js --params '{"to":"me@example.com"}'
```

- **Auth:** `--token` / `KODY_API_TOKEN` when set; else a valid `kody login`
session (OAuth access token as Bearer — never printed, never exchanged via
`tokenCreate`). Prefer the env var for API tokens so they stay out of shell
history and `ps`. The bearer stays in the CLI process; the sandbox only
talks to a loopback bridge. Host MCP OAuth from other clients is never
read. Until [kentcdodds/kody#2812](https://github.com/kentcdodds/kody/issues/2812)
ships, login-only Bearer is rejected by the Open API — use a `kody_at_…`
token in that case.
- **Auth:** `--token` / `KODY_API_TOKEN` when set; else stored `auth bootstrap`
API token; else a valid `kody login` session (OAuth access token as Bearer —
never printed, never exchanged via `tokenCreate`). Prefer the env var for
API tokens so they stay out of shell history and `ps`. The bearer stays in
the CLI process; the sandbox only talks to a loopback bridge. Host MCP OAuth
from other clients is never read. Until
[kentcdodds/kody#2812](https://github.com/kentcdodds/kody/issues/2812)
ships, login-only Bearer is rejected by the Open API — use bootstrap or a
`kody_at_…` token in that case.
- **Node.js:** 22 or newer (`package.json` `engines` is `>=22`). Older Node
fails immediately with that requirement, before workerd is downloaded or
started.
Expand Down
27 changes: 18 additions & 9 deletions skills/kody/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,19 +81,28 @@ whole-module cloud defer). If that package-graph API is missing, `--local`
fails clearly rather than silently billing a remote execute.

For `execute --local` auth: `--token` / `KODY_API_TOKEN` wins when set;
otherwise use a valid `kody login` session (OAuth access token as Bearer —
never print it, never mint via `tokenCreate` under the hood). Until the Open
API accepts MCP OAuth on CapabilityProxy / package-graph
(https://github.com/kentcdodds/kody/issues/2812), mint a scoped token with
the MCP `api` tool `tokenCreate` — include `local-execute` plus the capability
scopes the module calls — and pass `--token` or `KODY_API_TOKEN`. If neither
login nor a token is set, the CLI error says that. `insufficient_scope` means
the token is missing a scope; `feature_disabled` means the account
otherwise a stored token from `kody auth bootstrap --code` (after MCP
`cliCredentialBootstrap`); otherwise a valid `kody login` session (OAuth
access token as Bearer — never print it, never mint via `tokenCreate` under
the hood). Prefer bootstrap over `tokenCreate` for agents already on MCP so
`kody_at_…` never appears in chat. Until the Open API accepts MCP OAuth on
CapabilityProxy / package-graph
(https://github.com/kentcdodds/kody/issues/2812), use bootstrap or mint a
scoped token with the MCP `api` tool `tokenCreate` — include `local-execute`
plus the capability scopes the module calls — and pass `--token` or
`KODY_API_TOKEN`. If none of those are available, the CLI error says that
(bootstrap → login → tokenCreate for CI). `insufficient_scope` means the
token is missing a scope; `feature_disabled` means the account
`local-execute` flag is off. Never read MCP OAuth tokens from the host.

```bash
# Agents on MCP:
# cliCredentialBootstrap → { bootstrap_code, cli_command }
npx @kodycodes/cli auth bootstrap --code 'kody_bc_…'
npx @kodycodes/cli execute --local --file ./task.js --params '{"q":"email"}'

npx @kodycodes/cli login
npx @kodycodes/cli execute --local --file ./task.js --params '{"q":"email"}'
# Or with an explicit scoped token (wins over login):
# Or with an explicit scoped token (wins over login / bootstrap store):
KODY_API_TOKEN=… npx @kodycodes/cli execute --local --file ./task.js --params '{"q":"email"}'
```
172 changes: 172 additions & 0 deletions src/api-token-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { Entry } from '@napi-rs/keyring'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { defaultApiUrl, keyringService } from './defaults.js'
import {
createFileBackend,
secretServiceKeyringOptions,
type SecretBackend,
type StoreResolution,
} from './store.js'

/**
* Scoped API token stored by `auth bootstrap` (ADR 0056) for
* `execute --local`. Separate from `kody login` OAuth credentials.
*/
export type StoredApiToken = {
version: 1
apiUrl: string
token: string
tokenId: string
name?: string
scopes?: Array<string>
expiresAt?: string | null
maxExpiresAt?: string | null
createdVia?: string
}

export function accountForApiUrl(apiUrl: string): string {
return `cli-api-token:${new URL(apiUrl).origin}`
}

export function apiTokenFileStorePath(
apiUrl: string,
home: string = homedir(),
): string {
const origin = new URL(apiUrl).host.replace(/[^a-zA-Z0-9.-]/g, '_')
const base =
process.platform === 'win32'
? join(process.env.APPDATA || join(home, 'AppData', 'Roaming'), 'kody')
: process.platform === 'darwin'
? join(home, 'Library', 'Application Support', 'kody')
: join(process.env.XDG_CONFIG_HOME || join(home, '.config'), 'kody')
return join(base, `api-token-${origin}.json`)
}

export function createApiTokenKeyringBackend(apiUrl: string): SecretBackend {
const entry = new Entry(
keyringService,
accountForApiUrl(apiUrl),
secretServiceKeyringOptions,
)
return {
kind: 'keyring',
get() {
try {
return entry.getPassword()
} catch {
return null
}
},
set(value: string) {
entry.setPassword(value)
},
delete() {
try {
return entry.deleteCredential()
} catch {
return false
}
},
}
}

function fileBackendFor(
apiUrl: string,
resolution?: StoreResolution,
): SecretBackend {
const path =
resolution?.fileStorePath?.(apiUrl) ?? apiTokenFileStorePath(apiUrl)
return createFileBackend(path)
}

function resolveApiTokenBackend(
apiUrl: string,
preferred?: SecretBackend,
resolution?: StoreResolution,
): SecretBackend {
if (preferred) return preferred
try {
return (resolution?.createKeyring ?? createApiTokenKeyringBackend)(apiUrl)
} catch {
return fileBackendFor(apiUrl, resolution)
}
}

export function parseStoredApiToken(raw: string): StoredApiToken {
const parsed = JSON.parse(raw) as StoredApiToken
if (
parsed.version !== 1 ||
typeof parsed.token !== 'string' ||
!parsed.token.startsWith('kody_at_') ||
typeof parsed.apiUrl !== 'string' ||
typeof parsed.tokenId !== 'string'
) {
throw new Error(
'Stored Kody API token is invalid. Run `kody auth bootstrap --code …` again.',
)
}
return parsed
}

function readParsed(store: SecretBackend): StoredApiToken | null {
const raw = store.get()
if (!raw) return null
return parseStoredApiToken(raw)
}

export function loadStoredApiToken(
apiUrl: string = defaultApiUrl,
backend?: SecretBackend,
resolution?: StoreResolution,
): StoredApiToken | null {
const store = resolveApiTokenBackend(apiUrl, backend, resolution)
try {
const loaded = readParsed(store)
if (loaded) return loaded
} catch (error) {
if (!(store.kind === 'keyring' && !backend)) throw error
}
if (store.kind === 'keyring' && !backend) {
return readParsed(fileBackendFor(apiUrl, resolution))
}
return null
}

export function saveStoredApiToken(
credentials: StoredApiToken,
backend?: SecretBackend,
resolution?: StoreResolution,
): { backend: SecretBackend } {
const store = resolveApiTokenBackend(credentials.apiUrl, backend, resolution)
try {
store.set(JSON.stringify(credentials))
return { backend: store }
} catch (error) {
if (store.kind === 'keyring' && !backend) {
const fallback = fileBackendFor(credentials.apiUrl, resolution)
fallback.set(JSON.stringify(credentials))
return { backend: fallback }
}
throw error
}
}

export function deleteStoredApiToken(
apiUrl: string = defaultApiUrl,
backend?: SecretBackend,
resolution?: StoreResolution,
): { deleted: boolean; backend: SecretBackend } {
const store = resolveApiTokenBackend(apiUrl, backend, resolution)
let deleted = false
try {
deleted = store.delete()
} catch {
deleted = false
}
if (store.kind === 'keyring' && !backend) {
const file = fileBackendFor(apiUrl, resolution)
deleted = file.delete() || deleted
}
return { deleted, backend: store }
}
18 changes: 12 additions & 6 deletions src/api-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,30 +22,36 @@ export function isScopedApiToken(token: string): boolean {
}

/**
* How to mint the scoped token this CLI already accepts. There is no second
* auth flow here — callers use the MCP `api` tool they already have.
* How to mint a scoped token for CI/headless. Interactive agents on MCP should
* prefer `cliCredentialBootstrap` → `auth bootstrap` instead.
*/
export function apiTokenMintInstructions(): string {
return `Mint one with the Kody MCP \`api\` tool \`tokenCreate\` (include the \`local-execute\` scope plus the capability scopes this command needs) and pass --token or set ${apiTokenEnvVar}.`
}

/** Preferred interactive path for agents already on Kody MCP (ADR 0056). */
export function cliBootstrapInstructions(): string {
return `From MCP, call \`cliCredentialBootstrap\` (MCP \`api\` / \`kody.cliCredentialBootstrap\`), then run \`npx @kodycodes/cli auth bootstrap --code <kody_bc_…>\``
}

/** Token-only Open API paths (search / whoami / cloud token execute) with no token. */
export function missingApiTokenMessage(purpose: string): string {
return `${purpose} needs a scoped Kody API token. ${apiTokenMintInstructions()}`
return `${purpose} needs a scoped Kody API token. ${cliBootstrapInstructions()}, or ${apiTokenMintInstructions()}`
}

/**
* `execute --local` with neither `--token` / `KODY_API_TOKEN` nor `kody login`.
* `execute --local` with neither env/`--token`, stored bootstrap token, nor
* `kody login`. Prefer bootstrap (MCP) → login → tokenCreate (CI).
*/
export function missingLocalExecuteAuthMessage(
purpose: string = 'execute --local',
): string {
return `${purpose} needs auth. Run \`kody login\`, or ${apiTokenMintInstructions()}`
return `${purpose} needs auth. ${cliBootstrapInstructions()}; or run \`kody login\`; or for CI/headless, ${apiTokenMintInstructions()}`
}

/** Cloud search / whoami / execute when the process has neither a session nor a token. */
export function missingCliAuthMessage(): string {
return `Not logged in, and no API token is set. ${apiTokenMintInstructions()} Or run \`kody login\` for browser OAuth (search, whoami, cloud execute, and login-backed \`execute --local\`).`
return `Not logged in, and no API token is set. ${cliBootstrapInstructions()}; or run \`kody login\` for browser OAuth (search, whoami, cloud execute, and login-backed \`execute --local\`); or for CI/headless, ${apiTokenMintInstructions()}`
}

/** 401 when CapabilityProxy rejected a non-`kody_at_` bearer (typically CLI OAuth). */
Expand Down
Loading
Loading