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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ MCP connection.
```bash
npm install -g @kodycodes/cli
kody login
kody login --org acme
```

Or run via `npx @kodycodes/cli` without a global install.
Expand All @@ -59,7 +60,7 @@ 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 login` | Browser OAuth (CIMD + PKCE) for the CLI itself. Stores access and refresh tokens. `kody login --org acme` binds that grant to organization `acme`. |
| `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). |
Expand Down
1 change: 1 addition & 0 deletions skills/kody/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ Kody over MCP.
```bash
npm install -g @kodycodes/cli
kody login
kody login --org acme
kody search "what can you do"
kody search --domain email
kody execute --code "import { kody } from 'kody:runtime'\nexport default async function main() { return await kody.search({ query: 'what can you do' }) }"
Expand Down
6 changes: 5 additions & 1 deletion src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
defaultScopes,
loginTimeoutMs,
} from './defaults.js'
import { createCliOAuthProvider } from './oauth-provider.js'
import { createCliOAuthProvider, orgSlugFromFlag } from './oauth-provider.js'
import { redactError } from './redact.js'
import {
loadCredentials,
Expand All @@ -28,6 +28,8 @@ import {

export type LoginOptions = {
mcpUrl?: string
/** Organization slug. Omit to let the server bind the sole or signup org. */
org?: string
openBrowser?: boolean
backend?: SecretBackend
timeoutMs?: number
Expand Down Expand Up @@ -245,6 +247,7 @@ export async function login(options: LoginOptions = {}): Promise<{
backendPath?: string
}> {
const mcpUrl = options.mcpUrl ?? defaultMcpUrl
const org = options.org === undefined ? undefined : orgSlugFromFlag(options.org)
const redirectUri = cliRedirectUrl()
const expectedState = crypto.randomUUID()
const server = await startCallbackServer(redirectUri)
Expand All @@ -255,6 +258,7 @@ export async function login(options: LoginOptions = {}): Promise<{
loadStoredTokens: false,
openBrowser: options.openBrowser !== false,
expectedState,
...(org ? { org } : {}),
onAuthorizationUrl: (url) => {
authorizationUrl = url
options.onAuthorizationUrl?.(url)
Expand Down
10 changes: 10 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { defaultApiUrl, defaultMcpUrl, modernMcpProtocolVersion } from './defaults.js'
import { usage } from './help.js'
import { ensureFreshCredentials, login } from './auth.js'
import { orgSlugFromFlag } from './oauth-provider.js'
import { deleteCredentials, loadCredentials } from './store.js'
import { callKodyTool, formatToolResult, listKodyTools } from './mcp.js'
import { runInstall } from './install.js'
Expand Down Expand Up @@ -102,6 +103,7 @@ function parseKnown(args: Array<string>) {
'max-lifetime-seconds': { type: 'string' },
project: { type: 'boolean' },
'no-browser': { type: 'boolean' },
org: { type: 'string' },
clients: { type: 'string' },
yes: { type: 'boolean', short: 'y' },
},
Expand Down Expand Up @@ -175,6 +177,9 @@ async function dispatch(
) {
throw new Error('--allow-private-network can only be used with execute --local.')
}
if (parsed.values.org !== undefined && parsed.command !== 'login') {
throw new Error('`--org` can only be used with `kody login`.')
}

switch (parsed.command) {
case 'help':
Expand All @@ -184,9 +189,14 @@ async function dispatch(
write(`${readPackageVersion()}\n`)
return 0
case 'login': {
const org =
typeof parsed.values.org === 'string'
? orgSlugFromFlag(parsed.values.org)
: undefined
write('Opening the Kody login page in your browser…\n')
const result = await login({
mcpUrl,
...(org ? { org } : {}),
openBrowser: parsed.values['no-browser'] !== true,
onAuthorizationUrl: (url) => {
write(`If the browser does not open, visit:\n${url.href}\n`)
Expand Down
7 changes: 6 additions & 1 deletion src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const usage = `Kody CLI ${readPackageVersion()}
Install Kody as a remote MCP server in local agents, or use this CLI as a local client.

Usage:
kody login [--mcp-url <url>] [--no-browser]
kody login [--mcp-url <url>] [--org <slug>] [--no-browser]
kody logout [--mcp-url <url>] [--api-url <url>]
kody status [--mcp-url <url>] [--api-url <url>]
kody auth bootstrap --code <kody_bc_…> (--lifetime short|long | --idle-ttl-seconds <n> --max-lifetime-seconds <n>) [--api-url <url>]
Expand All @@ -23,6 +23,11 @@ Usage:
VS Code, Goose, and others). For web-based clients (ChatGPT, Claude.ai, Grok),
see ${onboardingUrl(defaultMcpUrl)}

--org <slug>
Bind this login to an organization.
Example: \`kody login --org acme\`
Omit \`--org\` and the server binds your only organization.

--clients Comma-separated ids: ${hostIds.join(', ')}

auth bootstrap
Expand Down
20 changes: 20 additions & 0 deletions src/oauth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,30 @@ export function buildCliClientMetadata(): OAuthClientMetadata {
}
}

/** Public org slug: trim and lowercase, matching the server's `?org=` rules. */
export function orgSlugFromFlag(value: string): string {
const slug = value.trim().toLowerCase()
if (!slug) {
throw new Error(
'`--org` requires an organization slug. Example: `kody login --org acme`.',
)
}
return slug
}

export function createCliOAuthProvider(input: {
mcpUrl: string
redirectUri: URL
existing?: StoredCredentials
loadStoredTokens: boolean
openBrowser: boolean
expectedState: string
/**
* Bind the grant to this org. Set on the authorize URL (`?org=`).
* Do not put it on the OAuth `resource`: production rejects a resource
* that adds a query (`?org=` or `?profile=`).
*/
org?: string
onAuthorizationUrl?: (url: URL) => void
}): OAuthClientProvider {
const clientMetadataUrl = cliClientMetadataUrl(input.mcpUrl)
Expand Down Expand Up @@ -91,6 +108,9 @@ export function createCliOAuthProvider(input: {
tokens = next
},
async redirectToAuthorization(authorizationUrl) {
if (input.org !== undefined) {
authorizationUrl.searchParams.set('org', orgSlugFromFlag(input.org))
}
input.onAuthorizationUrl?.(authorizationUrl)
if (input.openBrowser) {
await openUrl(authorizationUrl.href)
Expand Down
136 changes: 136 additions & 0 deletions test/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { test } from 'node:test'
import {
credentialsFromTokens,
isAccessTokenExpired,
login,
} from '../src/auth.js'
import {
cliClientMetadataUrl,
Expand All @@ -14,6 +15,7 @@ import {
import {
buildCliClientMetadata,
createCliOAuthProvider,
orgSlugFromFlag,
} from '../src/oauth-provider.js'
import type { StoredCredentials } from '../src/store.js'

Expand Down Expand Up @@ -121,3 +123,137 @@ test('CLI OAuth identity is CIMD with a fixed loopback redirect', async () => {
assert.equal(client?.client_id, provider.clientMetadataUrl)
assert.equal(provider.clientMetadata.scope, 'openid profile email')
})

test('orgSlugFromFlag lowercases and rejects a blank slug', () => {
assert.equal(orgSlugFromFlag('Acme'), 'acme')
assert.equal(orgSlugFromFlag(' KentCDodds '), 'kentcdodds')
assert.throws(() => orgSlugFromFlag(' '), /kody login --org acme/)
})

test('login authorize URL gets ?org= and keeps the canonical resource', async () => {
const seen: Array<URL> = []
const provider = createCliOAuthProvider({
mcpUrl: 'https://kody.codes/mcp',
redirectUri: cliRedirectUrl(),
loadStoredTokens: false,
openBrowser: false,
expectedState: 'state',
org: 'Acme',
onAuthorizationUrl: (url) => {
seen.push(new URL(url.href))
},
})
const authorizationUrl = new URL(
'https://kody.codes/oauth/authorize?response_type=code&resource=https%3A%2F%2Fkody.codes%2Fmcp&profile=CI+Bot',
)
await provider.redirectToAuthorization(authorizationUrl)
assert.equal(seen.length, 1)
const url = seen[0]
assert.ok(url)
assert.equal(url.searchParams.get('org'), 'acme')
assert.equal(url.searchParams.get('profile'), 'CI Bot')
assert.equal(url.searchParams.get('resource'), 'https://kody.codes/mcp')
assert.equal(new URL(url.searchParams.get('resource') ?? '').search, '')
})

function oauthDiscoveryFetch(origin: string): typeof fetch {
const metadata = {
issuer: origin,
authorization_endpoint: `${origin}/oauth/authorize`,
token_endpoint: `${origin}/oauth/token`,
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['none'],
client_id_metadata_document_supported: true,
}
const resource = {
resource: `${origin}/mcp`,
authorization_servers: [origin],
scopes_supported: ['openid', 'profile', 'email'],
bearer_methods_supported: ['header'],
}
return async (input) => {
const url = new URL(
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input.url,
)
if (url.pathname.includes('oauth-protected-resource')) {
return Response.json(resource)
}
if (
url.pathname.includes('oauth-authorization-server') ||
url.pathname.includes('openid-configuration')
) {
return Response.json(metadata)
}
return new Response('not found', { status: 404 })
}
}

test('login appends ?org= on the authorize URL and omits it otherwise', async () => {
const origin = 'https://oauth.test'
const fetchFn = oauthDiscoveryFetch(origin)
const withOrg: Array<URL> = []
await assert.rejects(
() =>
login({
mcpUrl: `${origin}/mcp`,
org: 'Acme',
openBrowser: false,
timeoutMs: 200,
fetchFn,
onAuthorizationUrl: (url) => {
withOrg.push(new URL(url.href))
},
}),
/Timed out waiting for the browser login/,
)
assert.equal(withOrg.length, 1)
const url = withOrg[0]
assert.ok(url)
assert.equal(url.origin + url.pathname, `${origin}/oauth/authorize`)
assert.equal(url.searchParams.get('org'), 'acme')
assert.equal(url.searchParams.get('resource'), `${origin}/mcp`)

const withoutOrg: Array<URL> = []
await assert.rejects(
() =>
login({
mcpUrl: `${origin}/mcp`,
openBrowser: false,
timeoutMs: 200,
fetchFn,
onAuthorizationUrl: (url) => {
withoutOrg.push(new URL(url.href))
},
}),
/Timed out waiting for the browser login/,
)
assert.equal(withoutOrg.length, 1)
assert.equal(withoutOrg[0]?.searchParams.get('org'), null)
assert.equal(withoutOrg[0]?.searchParams.get('resource'), `${origin}/mcp`)
})

test('authorize URL omits org when the flag is absent', async () => {
let seen: URL | undefined
const provider = createCliOAuthProvider({
mcpUrl: 'https://kody.codes/mcp',
redirectUri: cliRedirectUrl(),
loadStoredTokens: false,
openBrowser: false,
expectedState: 'state',
onAuthorizationUrl: (url) => {
seen = new URL(url.href)
},
})
const authorizationUrl = new URL(
'https://kody.codes/oauth/authorize?response_type=code&resource=https%3A%2F%2Fkody.codes%2Fmcp',
)
await provider.redirectToAuthorization(authorizationUrl)
assert.equal(seen?.searchParams.get('org'), null)
assert.equal(seen?.searchParams.get('resource'), 'https://kody.codes/mcp')
})
23 changes: 23 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ test('resolveCommand maps subcommands and flags', () => {
resolveCommand(['execute', '--file', 'mod.js']).values.file,
'mod.js',
)
assert.equal(resolveCommand(['login', '--org', 'Acme']).values.org, 'Acme')
assert.equal(
resolveCommand(['execute', '--invoke', 'kody:@scope/pkg/export']).values.invoke,
'kody:@scope/pkg/export',
Expand Down Expand Up @@ -447,6 +448,28 @@ test('help documents the api command', async () => {
assert.equal(code, 0)
assert.match(stdout, /kody api <operationId>/)
assert.match(stdout, /usageGet/)
assert.match(stdout, /kody login \[--mcp-url <url>\] \[--org <slug>\]/)
assert.match(stdout, /kody login --org acme/)
})

test('--org is login-only and requires a slug', async () => {
let stderr = ''
const other = await runCli(['search', '--org', 'acme'], {
stderr: (text) => {
stderr += text
},
})
assert.equal(other, 1)
assert.match(stderr, /`--org` can only be used with `kody login`/)

stderr = ''
const blank = await runCli(['login', '--org', ' '], {
stderr: (text) => {
stderr += text
},
})
assert.equal(blank, 1)
assert.match(stderr, /kody login --org acme/)
})

test('execute with --token (no --local) uses CapabilityProxy and never requires login', async () => {
Expand Down
Loading