diff --git a/.github/upstream-pi.json b/.github/upstream-pi.json index 75cfc2d00..13c8da810 100644 --- a/.github/upstream-pi.json +++ b/.github/upstream-pi.json @@ -2,5 +2,34 @@ "remote": "https://github.com/earendil-works/pi.git", "branch": "main", "lastReviewedSha": "7e94d36a44479326a6b2ee4227fc41f10ea978aa", - "docsSource": "packages/coding-agent/docs" + "docsSource": "packages/coding-agent/docs", + "packagePolicy": { + "bundlePackage": "@casemark/linc", + "sourceUpstream": "https://github.com/earendil-works/pi.git", + "packages": { + "agent-core": { + "upstreamPath": "packages/agent", + "localPath": "packages/agent", + "lincSubpath": "@casemark/linc/agent-core", + "policy": "track-upstream-unmodified" + }, + "ai": { + "upstreamPath": "packages/ai", + "localPath": "packages/ai", + "lincSubpath": "@casemark/linc/ai", + "policy": "track-upstream-with-linc-auth-provider-delta" + }, + "tui": { + "upstreamPath": "packages/tui", + "localPath": "packages/tui", + "lincSubpath": "@casemark/linc/tui", + "policy": "track-upstream-with-linc-theme-provider-delta" + }, + "web-ui": { + "upstreamPath": "packages/web-ui", + "localPath": "packages/web-ui", + "policy": "track-upstream-with-linc-web-ui-delta" + } + } + } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e07a83c9b..40b16000a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, dev] pull_request: - branches: [main] + branches: [main, dev] concurrency: group: ci-${{ github.ref }} diff --git a/.github/workflows/npm-publish-check.yml b/.github/workflows/npm-publish-check.yml index f2f5dcec9..877ad142e 100644 --- a/.github/workflows/npm-publish-check.yml +++ b/.github/workflows/npm-publish-check.yml @@ -2,7 +2,7 @@ name: NPM Publish Check on: pull_request: - branches: [main] + branches: [main, dev] paths: - "package.json" - "package-lock.json" @@ -10,7 +10,7 @@ on: - "scripts/**" - ".github/workflows/npm-publish-check.yml" push: - branches: [main] + branches: [main, dev] paths: - "package.json" - "package-lock.json" diff --git a/biome.json b/biome.json index 147d081a3..e10c4c64a 100644 --- a/biome.json +++ b/biome.json @@ -33,6 +33,7 @@ "!**/node_modules/**/*", "!**/test-sessions.ts", "!**/models.generated.ts", + "!**/.claude/**/*", "!packages/web-ui/src/app.css", "!packages/mom/data/**/*", "!!**/node_modules" diff --git a/docs/linc/development.md b/docs/linc/development.md index 709441319..9be561789 100644 --- a/docs/linc/development.md +++ b/docs/linc/development.md @@ -33,3 +33,5 @@ npm run docs:preview ``` Linc is based on Pi and selectively tracks upstream changes while keeping case.dev as the provider path. + +For release mechanics, see [Release](release.md). diff --git a/docs/linc/release.md b/docs/linc/release.md new file mode 100644 index 000000000..1be1c6cd6 --- /dev/null +++ b/docs/linc/release.md @@ -0,0 +1,50 @@ +# Release + +Linc uses `dev` as the stable pre-release branch and `main` as the release branch. + +## Branch Contract + +- Develop on feature branches. +- Merge feature branches into `dev`. +- Keep `dev` green and usable for pre-release validation. +- Promote `dev` to `main` only when the current pre-release set is ready to publish. +- Keep `main` releasable at all times. +- Do not merge `pi/main` directly into Linc. Review upstream with `npm run upstream:pi`, then port selected changes explicitly. + +## Release Pipeline + +The intended release path is branch promotion plus GitHub Actions: + +1. Merge feature branches into `dev`. +2. Run checks and pre-release smoke from `dev`. +3. Promote `dev` to `main` with a normal merge when ready to publish. +4. Run the `Release Prep` workflow on `main` with `patch`, `minor`, `major`, or an exact version. +5. The workflow runs `scripts/prepare-release.mjs`, updates workspace versions, syncs package versions, promotes changelog `Unreleased` sections, runs `npm run check`, commits, and tags `vX.Y.Z`. +6. The `NPM Publish` workflow runs from the tag and publishes the public packages. +7. The `NPM Publish Check` workflow dry-runs package contents on PRs and pushes when the local version is not already published. + +The local `scripts/release.mjs` path is legacy. Prefer the GitHub workflow so versioning, tagging, and npm publishing happen from a clean `main` checkout. + +## Published Packages + +The bundle package is `@casemark/linc`. It exposes selected package surfaces under Linc subpaths: + +- `@casemark/linc/agent-core` +- `@casemark/linc/ai` +- `@casemark/linc/ai/oauth` +- `@casemark/linc/tui` + +Linc still carries the internal workspace packages because upstream Pi is the source of truth for most implementation code. + +## case.dev Runtime Coupling + +case.dev `/linc/v1` does not consume the local worktree. It consumes the published `@casemark/linc` package pinned in the case.dev Linc runtime image. + +Before bumping that pin in case.dev: + +1. Confirm `linc --mode rpc --provider casedev --model ` still starts. +2. Confirm RPC command bodies remain native Pi/Linc JSON and are accepted unchanged. +3. Confirm event frame names stay compatible with C3: `message_update`, `message_end`, `turn_end`, `agent_end`, and `tool_execution_*`. +4. Confirm `turn_end` with `stopReason: "toolUse"` is not treated as final completion by downstream clients. +5. Confirm headless auth still honors `CASEDEV_API_KEY`. +6. Smoke case.dev preview and C3 before promoting the case.dev runtime image or package pin. diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index cf56671e1..c340caebd 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added first-class CaseMark Core and case.dev OAuth provider exports for Linc authentication. + ## [0.2.0] - 2026-05-15 ### Fixed diff --git a/packages/ai/src/utils/oauth/casedev.ts b/packages/ai/src/utils/oauth/casedev.ts new file mode 100644 index 000000000..dcb2b2d92 --- /dev/null +++ b/packages/ai/src/utils/oauth/casedev.ts @@ -0,0 +1,99 @@ +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js"; + +const env = typeof process === "undefined" ? undefined : process.env; +const CASEDEV_API_BASE = env?.CASEDEV_API_BASE_URL || "https://api.case.dev"; +const NEVER_EXPIRES = 253402300799000; + +interface DeviceFlowStartResponse { + deviceCode: string; + userCode: string; + verificationUri: string; + verificationUriComplete: string; + interval: number; + expiresIn: number; + expiresAt: string; +} + +interface DeviceFlowPollResponse { + error?: string; + interval?: number; + tokenType?: string; + apiKey?: string; + expiresAt?: string | null; + scope?: { services: Array<{ service: string; scopes: string[] }> }; +} + +export async function loginCasedev(callbacks: OAuthLoginCallbacks): Promise { + callbacks.onProgress?.("Starting case.dev device authorization..."); + + const startRes = await fetch(`${CASEDEV_API_BASE}/auth/cli/start`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + scopes: { services: [{ service: "all", scopes: ["read", "write"] }] }, + }), + signal: callbacks.signal, + }); + + if (!startRes.ok) { + throw new Error(`Failed to start case.dev device flow: ${startRes.status} ${startRes.statusText}`); + } + + const start = (await startRes.json()) as DeviceFlowStartResponse; + callbacks.onAuth({ + url: start.verificationUriComplete, + instructions: `Approve the code ${start.userCode}.`, + }); + callbacks.onProgress?.("Waiting for approval..."); + + const pollInterval = (start.interval || 3) * 1000; + const expiresAt = new Date(start.expiresAt).getTime(); + + while (Date.now() < expiresAt) { + if (callbacks.signal?.aborted) { + throw new Error("Login cancelled"); + } + + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + + const pollRes = await fetch(`${CASEDEV_API_BASE}/auth/cli/poll`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deviceCode: start.deviceCode }), + signal: callbacks.signal, + }); + + if (pollRes.status === 429 || pollRes.status === 202) { + continue; + } + + if (pollRes.status === 200) { + const result = (await pollRes.json()) as DeviceFlowPollResponse; + if (result.apiKey) { + return { + refresh: "", + access: result.apiKey, + expires: NEVER_EXPIRES, + }; + } + } + + if (pollRes.status === 403 || pollRes.status === 410) { + throw new Error("case.dev authorization denied or expired"); + } + } + + throw new Error("case.dev authorization timed out"); +} + +export const casedevOAuthProvider: OAuthProviderInterface = { + id: "casedev", + name: "case.dev", + login: loginCasedev, + async refreshToken(credentials) { + return credentials; + }, + getApiKey(credentials) { + return credentials.access; + }, +}; diff --git a/packages/ai/src/utils/oauth/casemark-core.ts b/packages/ai/src/utils/oauth/casemark-core.ts new file mode 100644 index 000000000..0626341c2 --- /dev/null +++ b/packages/ai/src/utils/oauth/casemark-core.ts @@ -0,0 +1,153 @@ +import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js"; + +const env = typeof process === "undefined" ? undefined : process.env; +const CORE_API_BASE = env?.CORE_API_BASE_URL || "https://core.case.dev"; +const CORE_OAUTH_CLIENT_ID = env?.LINC_CORE_OAUTH_CLIENT_ID || "linc"; +const CORE_OAUTH_SCOPE = env?.LINC_CORE_OAUTH_SCOPE || "core:chat"; + +interface CoreDeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + interval?: number; + expires_in: number; +} + +interface CoreTokenResponse { + access_token?: string; + refresh_token?: string; + token_type?: string; + expires_in?: number; + scope?: string; + error?: string; + error_description?: string; +} + +async function readJsonSafe(response: Response): Promise { + try { + return (await response.json()) as T; + } catch { + return null; + } +} + +function toCredentials(result: CoreTokenResponse): OAuthCredentials { + if (!result.access_token) { + throw new Error("Core OAuth response did not include an access token"); + } + + return { + refresh: result.refresh_token || "", + access: result.access_token, + expires: Date.now() + Math.max(1, result.expires_in || 3600) * 1000, + scope: result.scope, + tokenType: result.token_type, + }; +} + +export async function loginCasemarkCore(callbacks: OAuthLoginCallbacks): Promise { + callbacks.onProgress?.("Starting CaseMark Core device authorization..."); + + const startRes = await fetch(`${CORE_API_BASE}/oauth/device/code`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_id: CORE_OAUTH_CLIENT_ID, + scope: CORE_OAUTH_SCOPE, + }), + signal: callbacks.signal, + }); + + const start = await readJsonSafe(startRes); + if (!startRes.ok || !start?.device_code || !start.verification_uri_complete) { + throw new Error(`Failed to start Core device flow: ${startRes.status} ${startRes.statusText}`); + } + + callbacks.onAuth({ + url: start.verification_uri_complete, + instructions: `Approve the code ${start.user_code}.`, + }); + callbacks.onProgress?.("Waiting for approval..."); + + let pollIntervalMs = Math.max(1, start.interval || 5) * 1000; + const expiresAt = Date.now() + start.expires_in * 1000; + + while (Date.now() < expiresAt) { + if (callbacks.signal?.aborted) { + throw new Error("Login cancelled"); + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + + const pollRes = await fetch(`${CORE_API_BASE}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: CORE_OAUTH_CLIENT_ID, + device_code: start.device_code, + }), + signal: callbacks.signal, + }); + + const result = await readJsonSafe(pollRes); + if (pollRes.ok && result?.access_token) { + return toCredentials(result); + } + + const errorCode = result?.error; + if (!errorCode || errorCode === "authorization_pending") { + continue; + } + if (errorCode === "slow_down") { + pollIntervalMs += 1000; + continue; + } + if (errorCode === "access_denied" || errorCode === "invalid_grant") { + throw new Error("Core authorization denied or expired"); + } + + const description = result?.error_description ? `: ${result.error_description}` : ""; + throw new Error(`Core OAuth error: ${errorCode}${description}`); + } + + throw new Error("Core authorization timed out"); +} + +export async function refreshCasemarkCoreToken(credentials: OAuthCredentials): Promise { + if (!credentials.refresh) { + throw new Error("Core OAuth credentials do not include a refresh token"); + } + + const res = await fetch(`${CORE_API_BASE}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + grant_type: "refresh_token", + client_id: CORE_OAUTH_CLIENT_ID, + refresh_token: credentials.refresh, + }), + }); + + const result = await readJsonSafe(res); + if (!res.ok || !result?.access_token) { + throw new Error(`Failed to refresh Core OAuth token: ${res.status} ${res.statusText}`); + } + + return { + ...credentials, + ...toCredentials(result), + refresh: result.refresh_token || credentials.refresh, + }; +} + +export const casemarkCoreOAuthProvider: OAuthProviderInterface = { + id: "casemark-core", + name: "CaseMark Core", + login: loginCasemarkCore, + refreshToken: refreshCasemarkCoreToken, + getApiKey(credentials) { + return credentials.access; + }, +}; diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts index 256562a85..7ec8c5819 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/utils/oauth/index.ts @@ -2,15 +2,19 @@ * OAuth credential management for AI providers. * * This module handles login, token refresh, and credential storage - * for OAuth-based providers: - * - Anthropic (Claude Pro/Max) - * - GitHub Copilot - * - Google Cloud Code Assist (Gemini CLI) - * - Antigravity (Gemini 3, Claude, GPT-OSS via Google Cloud) + * for OAuth-based providers. */ // Anthropic export { anthropicOAuthProvider, loginAnthropic, refreshAnthropicToken } from "./anthropic.js"; +// case.dev +export { casedevOAuthProvider, loginCasedev } from "./casedev.js"; +// CaseMark Core +export { + casemarkCoreOAuthProvider, + loginCasemarkCore, + refreshCasemarkCoreToken, +} from "./casemark-core.js"; // GitHub Copilot export { getGitHubCopilotBaseUrl, @@ -33,6 +37,7 @@ export * from "./types.js"; // ============================================================================ import { anthropicOAuthProvider } from "./anthropic.js"; +import { casemarkCoreOAuthProvider } from "./casemark-core.js"; import { githubCopilotOAuthProvider } from "./github-copilot.js"; import { antigravityOAuthProvider } from "./google-antigravity.js"; import { geminiCliOAuthProvider } from "./google-gemini-cli.js"; @@ -40,6 +45,7 @@ import { openaiCodexOAuthProvider } from "./openai-codex.js"; import type { OAuthCredentials, OAuthProviderId, OAuthProviderInfo, OAuthProviderInterface } from "./types.js"; const BUILT_IN_OAUTH_PROVIDERS: OAuthProviderInterface[] = [ + casemarkCoreOAuthProvider, anthropicOAuthProvider, githubCopilotOAuthProvider, geminiCliOAuthProvider, diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 12eff13bf..5a1af9988 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Added + +- Added `/login` support for CaseMark Core OAuth and manual case.dev API key auth while preserving multi-provider credentials. +- Added bundled Linc subpath exports for agent core, AI, AI OAuth, and TUI package surfaces. + ## [0.2.0] - 2026-05-15 ### Added diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index cbe65010a..6a92d2fe8 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -20,6 +20,22 @@ "./hooks": { "types": "./dist/core/hooks/index.d.ts", "import": "./dist/core/hooks/index.js" + }, + "./agent-core": { + "types": "./dist/agent-core.d.ts", + "import": "./dist/agent-core.js" + }, + "./ai": { + "types": "./dist/ai.d.ts", + "import": "./dist/ai.js" + }, + "./ai/oauth": { + "types": "./dist/ai-oauth.d.ts", + "import": "./dist/ai-oauth.js" + }, + "./tui": { + "types": "./dist/tui.d.ts", + "import": "./dist/tui.js" } }, "files": [ diff --git a/packages/coding-agent/src/agent-core.ts b/packages/coding-agent/src/agent-core.ts new file mode 100644 index 000000000..e846141c1 --- /dev/null +++ b/packages/coding-agent/src/agent-core.ts @@ -0,0 +1 @@ +export * from "@casemark/linc-agent-core"; diff --git a/packages/coding-agent/src/ai-oauth.ts b/packages/coding-agent/src/ai-oauth.ts new file mode 100644 index 000000000..35ac0d511 --- /dev/null +++ b/packages/coding-agent/src/ai-oauth.ts @@ -0,0 +1 @@ +export * from "@casemark/linc-ai/oauth"; diff --git a/packages/coding-agent/src/ai.ts b/packages/coding-agent/src/ai.ts new file mode 100644 index 000000000..fe9760cf9 --- /dev/null +++ b/packages/coding-agent/src/ai.ts @@ -0,0 +1 @@ +export * from "@casemark/linc-ai"; diff --git a/packages/coding-agent/src/cli/login.ts b/packages/coding-agent/src/cli/login.ts index d0b299439..aee236930 100644 --- a/packages/coding-agent/src/cli/login.ts +++ b/packages/coding-agent/src/cli/login.ts @@ -2,6 +2,7 @@ * linc login — authenticate with Core OAuth tokens or case.dev API keys. */ +import type { OAuthCredentials } from "@casemark/linc-ai/oauth"; import chalk from "chalk"; import { execSync } from "child_process"; import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; @@ -56,6 +57,8 @@ interface CoreTokenResponse { } type LoginTarget = "core" | "casedev" | "manual"; +const CASEDEV_PROVIDER_ID = "casedev"; +const CASEMARK_CORE_PROVIDER_ID = "casemark-core"; function ask(prompt: string): Promise { const rl = createInterface({ input: process.stdin, output: process.stderr }); @@ -109,6 +112,19 @@ async function readJsonSafe(response: Response): Promise { } } +function coreResponseToCredentials(result: CoreTokenResponse): OAuthCredentials | null { + if (!result.access_token) { + return null; + } + return { + refresh: result.refresh_token || "", + access: result.access_token, + expires: Date.now() + Math.max(1, result.expires_in || 3600) * 1000, + scope: result.scope, + tokenType: result.token_type, + }; +} + async function deviceFlowLoginCasedev(): Promise { console.error(chalk.dim(" Starting device authorization...\n")); @@ -183,7 +199,7 @@ async function deviceFlowLoginCasedev(): Promise { return null; } -async function deviceFlowLoginCore(): Promise { +async function deviceFlowLoginCore(): Promise { console.error(chalk.dim(" Starting Core device authorization...\n")); let startRes: Response; @@ -236,7 +252,7 @@ async function deviceFlowLoginCore(): Promise { const result = await readJsonSafe(pollRes); if (pollRes.ok && result?.access_token) { - return result.access_token; + return coreResponseToCredentials(result); } const errorCode = result?.error; @@ -294,7 +310,8 @@ const CASEDEV_CONFIG_PATH = join(homedir(), ".config", "case", "config.json"); function saveKey(apiKey: string): void { // Save to linc auth storage (~/.linc/agent/auth.json) const authStorage = AuthStorage.create(); - authStorage.set("casedev", { type: "api_key", key: apiKey }); + const providerId = isCoreAccessToken(apiKey) ? CASEMARK_CORE_PROVIDER_ID : CASEDEV_PROVIDER_ID; + authStorage.set(providerId, { type: "api_key", key: apiKey }); // Only case.dev sk_case_* keys should be mirrored to casedev CLI config. if (!apiKey.startsWith("sk_case_")) { @@ -319,6 +336,11 @@ function saveKey(apiKey: string): void { } } +function saveCoreCredentials(credentials: OAuthCredentials): void { + const authStorage = AuthStorage.create(); + authStorage.set(CASEMARK_CORE_PROVIDER_ID, { type: "oauth", ...credentials }); +} + function readCasedevCliKey(): string | undefined { try { if (existsSync(CASEDEV_CONFIG_PATH)) { @@ -346,24 +368,31 @@ export async function runLogin(): Promise { console.error(""); - let apiKey: string | null = null; + let authToken: string | null = null; if (target === "core") { - apiKey = await deviceFlowLoginCore(); + const credentials = await deviceFlowLoginCore(); + if (!credentials) { + return false; + } + saveCoreCredentials(credentials); + authToken = credentials.access; } else if (target === "casedev") { - apiKey = await deviceFlowLoginCasedev(); + authToken = await deviceFlowLoginCasedev(); } else { - apiKey = await manualTokenLogin(); + authToken = await manualTokenLogin(); } - if (!apiKey) { + if (!authToken) { return false; } - saveKey(apiKey); + if (target !== "core") { + saveKey(authToken); + } // Also set token in current process for model loading. - setProcessAuthToken(apiKey); + setProcessAuthToken(authToken); console.error(chalk.green("\n Authenticated! Token saved to ~/.linc/agent/auth.json\n")); return true; @@ -392,10 +421,16 @@ export function isAuthenticated(): boolean { // 4. Check linc auth.json (~/.linc/agent/auth.json) try { const authStorage = AuthStorage.create(); - const cred = authStorage.get("casedev"); - if (cred?.type === "api_key" && cred.key) { - setProcessAuthToken(cred.key); - return true; + for (const providerId of [CASEMARK_CORE_PROVIDER_ID, CASEDEV_PROVIDER_ID]) { + const cred = authStorage.get(providerId); + if (cred?.type === "api_key" && cred.key) { + setProcessAuthToken(cred.key); + return true; + } + if (cred?.type === "oauth" && cred.access && Date.now() < cred.expires) { + setProcessAuthToken(cred.access); + return true; + } } } catch { // auth.json doesn't exist or is corrupt @@ -420,6 +455,17 @@ export async function ensureAuthenticated(): Promise { return true; } + try { + const authStorage = AuthStorage.create(); + const token = await authStorage.getApiKey(CASEDEV_PROVIDER_ID); + if (token) { + setProcessAuthToken(token); + return true; + } + } catch { + // Fall through to interactive login. + } + console.error(chalk.yellow("\n No auth token found. Let's get you set up.\n")); return runLogin(); } diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 0367ac553..c742c2ba8 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -27,6 +27,14 @@ export type AuthCredential = ApiKeyCredential | OAuthCredential; export type AuthStorageData = Record; +const CASEDEV_PROVIDER_ID = "casedev"; +const CASEMARK_CORE_PROVIDER_ID = "casemark-core"; +const GLOBAL_LLM_AUTH_PROVIDERS = [CASEMARK_CORE_PROVIDER_ID, CASEDEV_PROVIDER_ID] as const; + +function isGlobalLlmAuthProvider(providerId: string): boolean { + return providerId === CASEMARK_CORE_PROVIDER_ID || providerId === CASEDEV_PROVIDER_ID; +} + type LockResult = { result: T; next?: string; @@ -319,6 +327,8 @@ export class AuthStorage { hasAuth(provider: string): boolean { if (this.runtimeOverrides.has(provider)) return true; if (this.data[provider]) return true; + if (GLOBAL_LLM_AUTH_PROVIDERS.some((authProvider) => this.runtimeOverrides.has(authProvider))) return true; + if (GLOBAL_LLM_AUTH_PROVIDERS.some((authProvider) => this.data[authProvider])) return true; if (getEnvApiKey(provider)) return true; if (this.fallbackResolver?.(provider)) return true; return false; @@ -361,10 +371,7 @@ export class AuthStorage { * Refresh OAuth token with backend locking to prevent race conditions. * Multiple pi instances may try to refresh simultaneously when tokens expire. * - * Retained for upstream parity. The case.dev fork resolves a single case.dev - * API key in getApiKey(), so this path is currently not invoked. */ - // biome-ignore lint/correctness/noUnusedPrivateClassMembers: Retained intentionally for upstream parity while auth remains case.dev-only. private async refreshOAuthTokenWithLock( providerId: OAuthProviderId, ): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> { @@ -415,21 +422,70 @@ export class AuthStorage { * Get API key / bearer token for the configured LLM backend. * All providers route through a single OpenAI-compatible endpoint. */ - async getApiKey(_providerId?: string): Promise { - // Runtime override takes highest priority - const runtimeKey = this.runtimeOverrides.get("casedev"); + private getStoredCredentialProvider(providerId?: string): string | undefined { + if (providerId === CASEMARK_CORE_PROVIDER_ID && this.data[providerId]) { + return providerId; + } + if (providerId && !isGlobalLlmAuthProvider(providerId) && this.data[providerId]) { + return providerId; + } + return GLOBAL_LLM_AUTH_PROVIDERS.find((provider) => this.data[provider]); + } + + private getRuntimeCredentialProvider(providerId?: string): string | undefined { + if (providerId === CASEMARK_CORE_PROVIDER_ID && this.runtimeOverrides.has(providerId)) { + return providerId; + } + if (providerId && !isGlobalLlmAuthProvider(providerId) && this.runtimeOverrides.has(providerId)) { + return providerId; + } + return GLOBAL_LLM_AUTH_PROVIDERS.find((provider) => this.runtimeOverrides.has(provider)); + } + + /** + * Get API key / bearer token for the configured LLM backend. + * Core and case.dev are first-class auth providers for the shared LLM endpoint. + */ + async getApiKey(providerId?: string): Promise { + // Runtime override takes highest priority. + const runtimeProvider = this.getRuntimeCredentialProvider(providerId); + const runtimeKey = runtimeProvider ? this.runtimeOverrides.get(runtimeProvider) : undefined; if (runtimeKey) { return runtimeKey; } - // Check auth.json for stored key - const cred = this.data.casedev; + const envKey = getEnvApiKey(providerId); + if (envKey) { + return envKey; + } + + // Check auth.json for stored key/token. + const storedProvider = this.getStoredCredentialProvider(providerId); + const cred = storedProvider ? this.data[storedProvider] : undefined; if (cred?.type === "api_key") { return resolveConfigValue(cred.key); } + if (cred?.type === "oauth" && storedProvider) { + if (Date.now() < cred.expires) { + const provider = getOAuthProvider(storedProvider); + return provider?.getApiKey(cred); + } + + try { + const refreshed = await this.refreshOAuthTokenWithLock(storedProvider); + return refreshed?.apiKey; + } catch (error) { + this.recordError(error); + return undefined; + } + } + + const fallbackKey = providerId ? this.fallbackResolver?.(providerId) : undefined; + if (fallbackKey) { + return resolveConfigValue(fallbackKey); + } - // Fall back to environment variable - return getEnvApiKey(); + return undefined; } /** diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 2f08d4270..8e035cf5f 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -753,6 +753,7 @@ export async function main(args: string[]) { if (args[0] === "logout") { const authStorage = AuthStorage.create(); authStorage.remove("casedev"); + authStorage.remove("casemark-core"); delete process.env.CASEDEV_API_KEY; delete process.env.CORE_ACCESS_TOKEN; console.log("Logged out. Auth token removed from ~/.linc/agent/auth.json"); diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index a1adb5886..367392256 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -35,7 +35,7 @@ export class LoginDialogComponent extends Container implements Focusable { this.tui = tui; const providerInfo = getOAuthProviders().find((p) => p.id === providerId); - const providerName = providerInfo?.name || providerId; + const providerName = providerId === "casedev-api-key" ? "case.dev API key" : providerInfo?.name || providerId; // Top border this.addChild(new DynamicBorder()); diff --git a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts index 9d08fe406..cacee28ce 100644 --- a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts @@ -1,16 +1,23 @@ -import type { OAuthProviderInterface } from "@casemark/linc-ai"; import { getOAuthProviders } from "@casemark/linc-ai/oauth"; import { Container, getKeybindings, Spacer, TruncatedText } from "@casemark/linc-tui"; import type { AuthStorage } from "../../../core/auth-storage.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; +export const CASEDEV_API_KEY_LOGIN_ID = "casedev-api-key"; + +interface AuthOption { + id: string; + name: string; + type: "oauth" | "api_key"; +} + /** * Component that renders an OAuth provider selector */ export class OAuthSelectorComponent extends Container { private listContainer: Container; - private allProviders: OAuthProviderInterface[] = []; + private allProviders: AuthOption[] = []; private selectedIndex: number = 0; private mode: "login" | "logout"; private authStorage: AuthStorage; @@ -56,7 +63,23 @@ export class OAuthSelectorComponent extends Container { } private loadProviders(): void { - this.allProviders = getOAuthProviders(); + const oauthProviders = getOAuthProviders() + .filter((provider) => provider.id !== "casedev") + .map((provider): AuthOption => ({ id: provider.id, name: provider.name, type: "oauth" })); + + if (this.mode === "logout") { + const options = oauthProviders.filter((provider) => this.authStorage.get(provider.id)?.type === "oauth"); + if (this.authStorage.get("casedev")?.type === "api_key") { + options.unshift({ id: CASEDEV_API_KEY_LOGIN_ID, name: "case.dev API key", type: "api_key" }); + } + this.allProviders = options; + return; + } + + this.allProviders = [ + { id: CASEDEV_API_KEY_LOGIN_ID, name: "case.dev API key", type: "api_key" }, + ...oauthProviders, + ]; } private updateList(): void { @@ -68,9 +91,12 @@ export class OAuthSelectorComponent extends Container { const isSelected = i === this.selectedIndex; - // Check if user is logged in for this provider - const credentials = this.authStorage.get(provider.id); - const isLoggedIn = credentials?.type === "oauth"; + const credentials = + provider.id === CASEDEV_API_KEY_LOGIN_ID + ? this.authStorage.get("casedev") + : this.authStorage.get(provider.id); + const isLoggedIn = + provider.type === "api_key" ? credentials?.type === "api_key" : credentials?.type === "oauth"; const statusIndicator = isLoggedIn ? theme.fg("success", " ✓ logged in") : ""; let line = ""; @@ -89,7 +115,7 @@ export class OAuthSelectorComponent extends Container { // Show "no providers" if empty if (this.allProviders.length === 0) { const message = - this.mode === "login" ? "No OAuth providers available" : "No OAuth providers logged in. Use /login first."; + this.mode === "login" ? "No auth providers available" : "No auth credentials stored. Use /login first."; this.listContainer.addChild(new TruncatedText(theme.fg("muted", ` ${message}`), 0, 0)); } } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index de4035f0e..1301558fb 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -87,7 +87,7 @@ import { FooterComponent } from "./components/footer.js"; import { keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.js"; import { LoginDialogComponent } from "./components/login-dialog.js"; import { ModelSelectorComponent } from "./components/model-selector.js"; -import { OAuthSelectorComponent } from "./components/oauth-selector.js"; +import { CASEDEV_API_KEY_LOGIN_ID, OAuthSelectorComponent } from "./components/oauth-selector.js"; import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.js"; import { SessionSelectorComponent } from "./components/session-selector.js"; import { SettingsSelectorComponent } from "./components/settings-selector.js"; @@ -117,6 +117,12 @@ interface Expandable { setExpanded(expanded: boolean): void; } +const CASEDEV_PROVIDER_ID = "casedev"; +const CASEMARK_CORE_PROVIDER_ID = "casemark-core"; +const CASEDEV_API_BASE = process.env.CASEDEV_API_BASE_URL || "https://api.case.dev"; +const CASEDEV_LLM_BASE = `${CASEDEV_API_BASE.replace(/\/+$/, "")}/llm/v1`; +const CASEDEV_CONFIG_PATH = path.join(os.homedir(), ".config", "case", "config.json"); + function isExpandable(obj: unknown): obj is Expandable { return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function"; } @@ -3783,11 +3789,12 @@ export class InteractiveMode { private async showOAuthSelector(mode: "login" | "logout"): Promise { if (mode === "logout") { const providers = this.session.modelRegistry.authStorage.list(); - const loggedInProviders = providers.filter( - (p) => this.session.modelRegistry.authStorage.get(p)?.type === "oauth", - ); + const loggedInProviders = providers.filter((p) => { + const credential = this.session.modelRegistry.authStorage.get(p); + return credential?.type === "oauth" || (p === CASEDEV_PROVIDER_ID && credential?.type === "api_key"); + }); if (loggedInProviders.length === 0) { - this.showStatus("No OAuth providers logged in. Use /login first."); + this.showStatus("No auth credentials stored. Use /login first."); return; } } @@ -3800,16 +3807,23 @@ export class InteractiveMode { done(); if (mode === "login") { - await this.showLoginDialog(providerId); + if (providerId === CASEDEV_API_KEY_LOGIN_ID) { + await this.showCasedevApiKeyDialog(); + } else { + await this.showLoginDialog(providerId); + } } else { // Logout flow const providerInfo = this.session.modelRegistry.authStorage .getOAuthProviders() .find((p) => p.id === providerId); - const providerName = providerInfo?.name || providerId; + const providerName = + providerId === CASEDEV_API_KEY_LOGIN_ID ? "case.dev API key" : providerInfo?.name || providerId; try { - this.session.modelRegistry.authStorage.logout(providerId); + this.session.modelRegistry.authStorage.logout( + providerId === CASEDEV_API_KEY_LOGIN_ID ? CASEDEV_PROVIDER_ID : providerId, + ); this.session.modelRegistry.refresh(); await this.updateAvailableProviderCount(); this.showStatus(`Logged out of ${providerName}`); @@ -3827,6 +3841,82 @@ export class InteractiveMode { }); } + private saveCasedevCliKey(apiKey: string): void { + try { + const dir = path.dirname(CASEDEV_CONFIG_PATH); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + let config: Record = {}; + if (fs.existsSync(CASEDEV_CONFIG_PATH)) { + config = JSON.parse(fs.readFileSync(CASEDEV_CONFIG_PATH, "utf-8")) as Record; + } + config.apiKey = apiKey; + fs.writeFileSync(CASEDEV_CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8"); + fs.chmodSync(CASEDEV_CONFIG_PATH, 0o600); + } catch { + // The linc auth file is the source of truth; casedev CLI mirroring is best-effort. + } + } + + private async verifyCasedevApiKey(apiKey: string): Promise { + const res = await fetch(`${CASEDEV_LLM_BASE}/models`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + if (!res.ok) { + throw new Error(`Token verification failed: ${res.status} ${res.statusText}`); + } + } + + private async showCasedevApiKeyDialog(): Promise { + const dialog = new LoginDialogComponent(this.ui, CASEDEV_API_KEY_LOGIN_ID, (_success, _message) => { + // Completion handled below. + }); + + this.editorContainer.clear(); + this.editorContainer.addChild(dialog); + this.ui.setFocus(dialog); + this.ui.requestRender(); + + const restoreEditor = () => { + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.ui.setFocus(this.editor); + this.ui.requestRender(); + }; + + try { + const apiKey = (await dialog.showPrompt("Paste your case.dev API key:", "sk_case_...")).trim(); + if (!apiKey) { + throw new Error("No API key provided"); + } + if (!apiKey.startsWith("sk_case_")) { + throw new Error("Expected a case.dev API key starting with sk_case_"); + } + + dialog.showProgress("Verifying case.dev API key..."); + await this.verifyCasedevApiKey(apiKey); + + const authStorage = this.session.modelRegistry.authStorage; + authStorage.set(CASEDEV_PROVIDER_ID, { type: "api_key", key: apiKey }); + authStorage.setRuntimeApiKey(CASEDEV_PROVIDER_ID, apiKey); + process.env.CASEDEV_API_KEY = apiKey; + delete process.env.CORE_ACCESS_TOKEN; + this.saveCasedevCliKey(apiKey); + + restoreEditor(); + this.session.modelRegistry.refresh(); + await this.updateAvailableProviderCount(); + this.showStatus(`Logged in to case.dev. API key saved to ${getAuthPath()}`); + } catch (error: unknown) { + restoreEditor(); + const errorMsg = error instanceof Error ? error.message : String(error); + if (errorMsg !== "Login cancelled") { + this.showError(`Failed to save case.dev API key: ${errorMsg}`); + } + } + } + private async showLoginDialog(providerId: string): Promise { const providerInfo = this.session.modelRegistry.authStorage.getOAuthProviders().find((p) => p.id === providerId); const providerName = providerInfo?.name || providerId; @@ -3902,6 +3992,15 @@ export class InteractiveMode { signal: dialog.signal, }); + if (providerId === CASEMARK_CORE_PROVIDER_ID) { + const coreToken = await this.session.modelRegistry.authStorage.getApiKey(CASEMARK_CORE_PROVIDER_ID); + if (coreToken) { + this.session.modelRegistry.authStorage.setRuntimeApiKey(CASEDEV_PROVIDER_ID, coreToken); + process.env.CORE_ACCESS_TOKEN = coreToken; + delete process.env.CASEDEV_API_KEY; + } + } + // Success restoreEditor(); this.session.modelRegistry.refresh(); diff --git a/packages/coding-agent/src/tui.ts b/packages/coding-agent/src/tui.ts new file mode 100644 index 000000000..42e76dc4e --- /dev/null +++ b/packages/coding-agent/src/tui.ts @@ -0,0 +1 @@ +export * from "@casemark/linc-tui"; diff --git a/packages/coding-agent/test/auth-storage.test.ts b/packages/coding-agent/test/auth-storage.test.ts index 405421e08..7a259681c 100644 --- a/packages/coding-agent/test/auth-storage.test.ts +++ b/packages/coding-agent/test/auth-storage.test.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { registerOAuthProvider } from "@casemark/linc-ai/oauth"; +import { registerOAuthProvider, resetOAuthProviders } from "@casemark/linc-ai/oauth"; import lockfile from "proper-lockfile"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.js"; @@ -23,6 +23,7 @@ describe("AuthStorage", () => { rmSync(tempDir, { recursive: true }); } clearConfigValueCache(); + resetOAuthProviders(); vi.restoreAllMocks(); }); @@ -242,6 +243,91 @@ describe("AuthStorage", () => { expect(keyB).toBe("key-openai"); }); + test("casemark core auth is used for routed provider models", async () => { + writeAuthJson({ + "casemark-core": { type: "api_key", key: "core_at_test-token" }, + }); + + authStorage = AuthStorage.create(authJsonPath); + + expect(authStorage.hasAuth("casedev")).toBe(true); + await expect(authStorage.getApiKey("casedev")).resolves.toBe("core_at_test-token"); + await expect(authStorage.getApiKey("anthropic")).resolves.toBe("core_at_test-token"); + }); + + test("case.dev auth is used when core auth is absent", async () => { + writeAuthJson({ + casedev: { type: "api_key", key: "sk_case_test-token" }, + }); + + authStorage = AuthStorage.create(authJsonPath); + + expect(authStorage.hasAuth("casedev")).toBe(true); + await expect(authStorage.getApiKey("casedev")).resolves.toBe("sk_case_test-token"); + await expect(authStorage.getApiKey("anthropic")).resolves.toBe("sk_case_test-token"); + }); + + test("expired casemark core oauth refreshes for routed provider models", async () => { + registerOAuthProvider({ + id: "casemark-core", + name: "Test CaseMark Core", + async login() { + throw new Error("Not used in this test"); + }, + async refreshToken(credentials) { + return { + ...credentials, + access: "core_at_refreshed-token", + expires: Date.now() + 60_000, + }; + }, + getApiKey(credentials) { + return credentials.access; + }, + }); + writeAuthJson({ + "casemark-core": { + type: "oauth", + refresh: "refresh-token", + access: "core_at_expired-token", + expires: Date.now() - 10_000, + }, + }); + + authStorage = AuthStorage.create(authJsonPath); + + await expect(authStorage.getApiKey("casedev")).resolves.toBe("core_at_refreshed-token"); + }); + + test("environment auth takes priority over stored global auth", async () => { + const originalCoreToken = process.env.CORE_ACCESS_TOKEN; + const originalCasedevKey = process.env.CASEDEV_API_KEY; + + try { + process.env.CASEDEV_API_KEY = "sk_case_env-token"; + delete process.env.CORE_ACCESS_TOKEN; + writeAuthJson({ + "casemark-core": { type: "api_key", key: "core_at_stored-token" }, + casedev: { type: "api_key", key: "sk_case_stored-token" }, + }); + + authStorage = AuthStorage.create(authJsonPath); + + await expect(authStorage.getApiKey("casedev")).resolves.toBe("sk_case_env-token"); + } finally { + if (originalCoreToken === undefined) { + delete process.env.CORE_ACCESS_TOKEN; + } else { + process.env.CORE_ACCESS_TOKEN = originalCoreToken; + } + if (originalCasedevKey === undefined) { + delete process.env.CASEDEV_API_KEY; + } else { + process.env.CASEDEV_API_KEY = originalCasedevKey; + } + } + }); + test("failed commands are cached (not retried)", async () => { const counterFile = join(tempDir, "counter"); writeFileSync(counterFile, "0"); diff --git a/scripts/preview-linc-docs.mjs b/scripts/preview-linc-docs.mjs index b5f7f43c6..cee5336d5 100644 --- a/scripts/preview-linc-docs.mjs +++ b/scripts/preview-linc-docs.mjs @@ -18,7 +18,7 @@ import { fileURLToPath } from "node:url"; const repoRoot = fileURLToPath(new URL("..", import.meta.url)); const docsRoot = join(repoRoot, "docs", "linc"); const defaultPort = 4317; -const docOrder = ["overview", "quickstart", "web-ui", "gateway", "configuration", "development"]; +const docOrder = ["overview", "quickstart", "web-ui", "gateway", "configuration", "development", "release"]; function parsePort(argv) { const index = argv.indexOf("--port"); diff --git a/scripts/upstream-pi-report.mjs b/scripts/upstream-pi-report.mjs index 8672d01bd..8e6eb11f0 100644 --- a/scripts/upstream-pi-report.mjs +++ b/scripts/upstream-pi-report.mjs @@ -94,6 +94,9 @@ function buildPathSummary(diffNameStatus) { function buildRiskNotes(diffNameStatus) { const paths = diffNameStatus.split("\n").filter(Boolean).map((line) => line.split("\t").at(-1) ?? ""); const notes = []; + if (paths.some((path) => path.startsWith("packages/agent/"))) { + notes.push("- `packages/agent` should track `@earendil-works/pi-agent-core` without Linc-specific changes."); + } if (paths.some((path) => path.includes("providers") || path.includes("models"))) { notes.push("- Provider/model changes need manual Linc filtering because Linc routes through case.dev only."); } @@ -112,6 +115,27 @@ function buildRiskNotes(diffNameStatus) { return notes.length > 0 ? notes : ["- No obvious Linc-specific risk markers found in changed paths."]; } +function buildPackagePolicyLines(config) { + const packagePolicy = config.packagePolicy; + if (!packagePolicy?.packages) { + return ["No package policy configured."]; + } + + const lines = [ + `Source upstream: ${packagePolicy.sourceUpstream ?? config.remote}`, + `Bundle package: ${packagePolicy.bundlePackage ?? "(unspecified)"}`, + "", + ]; + for (const [name, policy] of Object.entries(packagePolicy.packages)) { + lines.push(`- ${name}: ${policy.upstreamPath} -> ${policy.localPath}`); + if (policy.lincSubpath) { + lines.push(` Linc import: \`${policy.lincSubpath}\``); + } + lines.push(` Policy: ${policy.policy}`); + } + return lines; +} + function writeGithubOutput(values) { const outputPath = process.env.GITHUB_OUTPUT; if (!outputPath) return; @@ -164,6 +188,10 @@ function main() { "", ...riskNotes, "", + "## Package Policy", + "", + ...buildPackagePolicyLines(config), + "", "## Commits", "", ...formatCommitLines(commitLog),