From 9b5084d65f89e45b75cf6c23cfe6c2f5f73f2aee Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 00:57:28 +0000 Subject: [PATCH] feat(mcp): TypeScript SDK + i18n shim for /oauth/success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 of the MCP UX roadmap. ## packages/sdk-ts — @superagentskill/sdk Thin, strongly-typed client for the public MCP endpoint: import { SuperAgentSkill, idempotencyKey } from "@superagentskill/sdk"; const sas = new SuperAgentSkill({ token: process.env.SAS_TOKEN }); const out = await sas.call("search_registry", { query: "cardiology" }); Handles the three friction points integrators hit by hand: * Headers — sets Accept: application/json, text/event-stream so callers don't trip the 406 footgun. * Auth — Bearer token + typed MCPUnauthorizedError carrying the authorization_url / tokens_url hints the server emits. * Retries — 3 attempts by default, honours Retry-After on 429s, exponential backoff on 5xx / network errors. Also surfaces X-RateLimit-* headers as `sas.lastRateLimit` so callers can self-regulate before hitting the ceiling, and exposes a tiny `idempotencyKey()` helper for stable write-tool retries. ToolMap covers every tool the server currently exposes; adding a tool means updating one file so the SDK never drifts from the wire format. ## src/lib/i18n + /oauth/success in EN/PT-BR Minimal locale shim (no i18next, no provider) because the rest of the site is engineering-targeted English and only a handful of pages deserve translation today. `useLang` resolves from localStorage, falls back to navigator.language, falls back to en; renders a small EN / PT switcher on /oauth/success so PT-BR users (the bulk of our inbound) finally see "Conectado a Cursor" instead of "Connected to". Notifications/resources/list_changed deferred — mcp-tanstack-start exposes server→client push only in stateful mode (not currently configured); we'll wire it in a follow-up once we decide on a session store. https://claude.ai/code/session_019gMoupKKTVydpNwiiACQRd --- packages/sdk-ts/README.md | 80 ++++++++ packages/sdk-ts/package-lock.json | 33 ++++ packages/sdk-ts/package.json | 32 ++++ packages/sdk-ts/src/index.ts | 307 ++++++++++++++++++++++++++++++ packages/sdk-ts/src/types.ts | 154 +++++++++++++++ packages/sdk-ts/tsconfig.json | 23 +++ src/hooks/use-lang.ts | 50 +++++ src/lib/i18n/dictionaries.ts | 66 +++++++ src/lib/i18n/index.ts | 44 +++++ src/routes/oauth.success.tsx | 65 +++++-- 10 files changed, 833 insertions(+), 21 deletions(-) create mode 100644 packages/sdk-ts/README.md create mode 100644 packages/sdk-ts/package-lock.json create mode 100644 packages/sdk-ts/package.json create mode 100644 packages/sdk-ts/src/index.ts create mode 100644 packages/sdk-ts/src/types.ts create mode 100644 packages/sdk-ts/tsconfig.json create mode 100644 src/hooks/use-lang.ts create mode 100644 src/lib/i18n/dictionaries.ts create mode 100644 src/lib/i18n/index.ts diff --git a/packages/sdk-ts/README.md b/packages/sdk-ts/README.md new file mode 100644 index 00000000..b83c4438 --- /dev/null +++ b/packages/sdk-ts/README.md @@ -0,0 +1,80 @@ +# @superagentskill/sdk + +Official TypeScript SDK for the [Super Agent Skill](https://superagentskill.com) MCP server. + +```bash +npm install @superagentskill/sdk +``` + +## Usage + +```ts +import { SuperAgentSkill, idempotencyKey } from "@superagentskill/sdk"; + +const sas = new SuperAgentSkill({ + token: process.env.SAS_TOKEN, // optional for read tools, required for writes +}); + +// Strongly-typed tool calls +const out = await sas.call("search_registry", { query: "cardiology", limit: 5 }); +// ^? { query: string; count: number; items: Package[] } + +// Idempotent writes — retry-safe +await sas.call("upload_packages", { + files: [{ name: "triage.md", content: "# Cardiology triage" }], + idempotency_key: idempotencyKey(["triage", "v1"]), +}); + +// Rate-limit aware (parsed from X-RateLimit-* headers) +console.log(sas.lastRateLimit); +// → { limit: 100, remaining: 87, resetAtUnix: 1716508800, window: "day" } +``` + +## What's handled for you + +- **Headers** — sets `Accept: application/json, text/event-stream` (the 406 footgun) automatically. +- **Auth** — Bearer header from `token`. On 401, throws `MCPUnauthorizedError` with the recovery URLs from the server hint. +- **Retries** — 3 attempts by default. 429s honour `Retry-After`; 5xx and network errors use exponential backoff. +- **Rate-limit visibility** — `sas.lastRateLimit` is populated after every call so you can self-regulate before hitting the ceiling. +- **Idempotency keys** — pass `idempotency_key` to write tools (`upload_packages`, `request_primitive`) and retries replay the original response instead of double-charging quota or duplicating drafts. The `idempotencyKey()` helper builds a stable key from any tuple. +- **Strongly typed** — every tool has typed input + output via `ToolMap`. Adding a new tool means updating one file. + +## Tools + +| Tool | Read | Auth | +|---------------------|:----:|:----:| +| `list_packages` | ✓ | | +| `search_registry` | ✓ | | +| `get_package` | ✓ | | +| `get_methodology` | ✓ | | +| `review_skill` | ✓ | | +| `report_execution` | ✓ | | +| `upload_packages` | ✗ | ✓ | +| `request_primitive` | ✗ | ✓ | + +## Errors + +```ts +import { MCPUnauthorizedError, MCPRateLimitedError } from "@superagentskill/sdk"; + +try { + await sas.call("upload_packages", { files: [...] }); +} catch (e) { + if (e instanceof MCPUnauthorizedError) { + console.error("Authorize at", e.authorizationUrl); + } else if (e instanceof MCPRateLimitedError) { + console.warn("Retry after", e.retryAfterSeconds, "s"); + } else throw e; +} +``` + +## Health check + +```ts +const h = await sas.health(); +// → { ok: true, version: "1.5.0", uptime_seconds: 41234, db: { ok: true, ping_ms: 12 }, ... } +``` + +## License + +MIT diff --git a/packages/sdk-ts/package-lock.json b/packages/sdk-ts/package-lock.json new file mode 100644 index 00000000..b7362e84 --- /dev/null +++ b/packages/sdk-ts/package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "@superagentskill/sdk", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@superagentskill/sdk", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/packages/sdk-ts/package.json b/packages/sdk-ts/package.json new file mode 100644 index 00000000..eea3a097 --- /dev/null +++ b/packages/sdk-ts/package.json @@ -0,0 +1,32 @@ +{ + "name": "@superagentskill/sdk", + "version": "0.1.0", + "description": "Official TypeScript SDK for the Super Agent Skill MCP server. Strongly-typed tool calls, automatic retries with exponential backoff, idempotency keys, rate-limit aware, Bearer-token auth.", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": ["dist", "README.md"], + "scripts": { + "build": "tsc -p tsconfig.json", + "prepublishOnly": "npm run build" + }, + "keywords": ["mcp", "claude", "agent", "skill", "ai", "superagentskill"], + "license": "MIT", + "engines": { "node": ">=18" }, + "homepage": "https://superagentskill.com/docs/mcp", + "repository": { + "type": "git", + "url": "https://github.com/criptogus/agent-evolve-network", + "directory": "packages/sdk-ts" + }, + "devDependencies": { + "typescript": "^5.6.0" + } +} diff --git a/packages/sdk-ts/src/index.ts b/packages/sdk-ts/src/index.ts new file mode 100644 index 00000000..3579c6df --- /dev/null +++ b/packages/sdk-ts/src/index.ts @@ -0,0 +1,307 @@ +/** + * @superagentskill/sdk — typed client for the Super Agent Skill MCP server. + * + * import { SuperAgentSkill } from "@superagentskill/sdk"; + * + * const sas = new SuperAgentSkill({ token: process.env.SAS_TOKEN }); + * const out = await sas.call("search_registry", { query: "cardiology" }); + * // ^? typed as SearchRegistryOutput + * + * Why a thin SDK and not a giant framework? Because the MCP endpoint is + * already JSON-RPC over HTTP and the friction is in three things: + * 1. Headers (`Accept: application/json, text/event-stream` — a 406 + * footgun every new caller hits at least once). + * 2. Auth (Bearer header + the unauthorized hint payload format). + * 3. Retries (network blips + 429s with Retry-After, plus the + * idempotency_key contract for write tools so retries don't + * double-upload). + * + * This SDK handles all three, plus parses the rate-limit headers into + * an object you can read after every call (`sas.lastRateLimit`). + */ +import { + MCPError, + MCPRateLimitedError, + MCPUnauthorizedError, + ToolInput, + ToolName, + ToolOutput, + type RateLimitInfo, +} from "./types.js"; + +export * from "./types.js"; + +export type SuperAgentSkillOptions = { + /** Bearer token (personal access token or OAuth access token). Required for write tools. */ + token?: string; + /** Override the endpoint. Defaults to the public hosted server. */ + endpoint?: string; + /** Custom fetch (e.g. for tests or for environments without global fetch). */ + fetch?: typeof fetch; + /** + * Max retry attempts on 5xx / 429 / network errors. Each retry waits + * `Retry-After` if present, otherwise exponential backoff. + * Default: 3. Set to 0 to disable. + */ + maxRetries?: number; + /** Initial backoff in ms (doubled each retry). Default 500. */ + backoffMs?: number; + /** User-Agent suffix. Default reports SDK version. */ + userAgentSuffix?: string; +}; + +const DEFAULT_ENDPOINT = "https://superagentskill.com/api/mcp"; +const VERSION = "0.1.0"; + +export class SuperAgentSkill { + private readonly endpoint: string; + private readonly token: string | undefined; + private readonly fetchImpl: typeof fetch; + private readonly maxRetries: number; + private readonly backoffMs: number; + private readonly userAgent: string; + /** Populated after every successful call. */ + public lastRateLimit: RateLimitInfo = {}; + + constructor(opts: SuperAgentSkillOptions = {}) { + this.endpoint = opts.endpoint ?? DEFAULT_ENDPOINT; + this.token = opts.token; + this.fetchImpl = opts.fetch ?? globalThis.fetch; + if (!this.fetchImpl) { + throw new Error( + "SuperAgentSkill: no fetch() available in this environment. Pass `fetch` in options.", + ); + } + this.maxRetries = opts.maxRetries ?? 3; + this.backoffMs = opts.backoffMs ?? 500; + this.userAgent = + `@superagentskill/sdk/${VERSION}` + + (opts.userAgentSuffix ? ` ${opts.userAgentSuffix}` : ""); + } + + /** + * Call any MCP tool by name. Output is typed via the ToolMap. + * + * await sas.call("search_registry", { query: "cold outreach" }); + */ + async call( + name: T, + args: ToolInput, + init: { signal?: AbortSignal } = {}, + ): Promise> { + const body = { + jsonrpc: "2.0", + id: cryptoId(), + method: "tools/call", + params: { name, arguments: args }, + }; + const raw = await this.rawRequest(body, init); + // MCP tools/call results come back as `{ content: [{ type: "text", text: }] }` + const content = raw?.result?.content; + if (Array.isArray(content) && content[0]?.type === "text") { + const text = content[0].text as string; + try { + return JSON.parse(text) as ToolOutput; + } catch { + return text as unknown as ToolOutput; + } + } + return raw?.result as ToolOutput; + } + + /** List every tool the server exposes (with JSON schemas). */ + async listTools(init: { signal?: AbortSignal } = {}): Promise { + const r = await this.rawRequest( + { jsonrpc: "2.0", id: cryptoId(), method: "tools/list" }, + init, + ); + return r?.result; + } + + /** MCP `initialize` lifecycle call — exposes server `instructions` field. */ + async initialize(init: { signal?: AbortSignal } = {}): Promise { + const r = await this.rawRequest( + { + jsonrpc: "2.0", + id: cryptoId(), + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "@superagentskill/sdk", version: VERSION }, + }, + }, + init, + ); + return r?.result; + } + + /** Hit the public health endpoint. Useful for status dashboards. */ + async health(init: { signal?: AbortSignal } = {}): Promise { + const url = this.endpoint.replace(/\/?$/, "") + "/health"; + const res = await this.fetchImpl(url, { method: "GET", signal: init.signal }); + return res.json(); + } + + // ------------------ Internals ----------------------------------------- + + private async rawRequest( + body: unknown, + init: { signal?: AbortSignal }, + ): Promise { + let attempt = 0; + let lastErr: unknown = null; + while (attempt <= this.maxRetries) { + try { + const res = await this.fetchImpl(this.endpoint, { + method: "POST", + headers: this.buildHeaders(), + body: JSON.stringify(body), + signal: init.signal, + }); + this.absorbRateLimit(res); + + if (res.status === 401) { + const data = await safeJson(res); + throw new MCPUnauthorizedError( + data?.error?.message ?? "Unauthorized", + data?.error?.data, + ); + } + if (res.status === 429) { + const retryAfter = parseRetryAfter(res); + if (attempt < this.maxRetries) { + await sleep(retryAfter * 1000); + attempt++; + continue; + } + const data = await safeJson(res); + throw new MCPRateLimitedError( + data?.error?.message ?? "Rate limited", + retryAfter, + data?.error?.data, + ); + } + if (res.status >= 500 && attempt < this.maxRetries) { + await sleep(this.backoffMs * Math.pow(2, attempt)); + attempt++; + continue; + } + const text = await res.text(); + const parsed = text ? tryParse(text) : null; + if (!res.ok) { + throw new MCPError(parsed?.error?.message ?? `HTTP ${res.status}`, { + code: parsed?.error?.code ?? -32000, + status: res.status, + data: parsed?.error?.data, + }); + } + if (parsed?.error) { + throw new MCPError(parsed.error.message ?? "tool_error", { + code: parsed.error.code ?? -32000, + status: 200, + data: parsed.error.data, + }); + } + return parsed; + } catch (e) { + lastErr = e; + // Retry only network-level errors. MCPError / MCPUnauthorizedError / MCPRateLimitedError + // should bubble up. + if ( + e instanceof MCPError || + e instanceof MCPUnauthorizedError || + e instanceof MCPRateLimitedError + ) { + throw e; + } + if (attempt >= this.maxRetries) throw e; + await sleep(this.backoffMs * Math.pow(2, attempt)); + attempt++; + } + } + throw lastErr ?? new Error("unknown error"); + } + + private buildHeaders(): Record { + const h: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "User-Agent": this.userAgent, + }; + if (this.token) h.Authorization = `Bearer ${this.token}`; + return h; + } + + private absorbRateLimit(res: Response) { + const limit = res.headers.get("X-RateLimit-Limit"); + const remaining = res.headers.get("X-RateLimit-Remaining"); + const reset = res.headers.get("X-RateLimit-Reset"); + const window_ = res.headers.get("X-RateLimit-Window"); + if (limit || remaining || reset) { + this.lastRateLimit = { + limit: limit != null ? Number(limit) : undefined, + remaining: remaining != null ? Number(remaining) : undefined, + resetAtUnix: reset != null ? Number(reset) : undefined, + window: window_ ?? undefined, + }; + } + } +} + +// ------------------ Helpers ------------------------------------------- + +function cryptoId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function parseRetryAfter(res: Response): number { + const ra = res.headers.get("Retry-After"); + if (!ra) return 1; + const n = Number(ra); + if (Number.isFinite(n)) return Math.max(0, n); + const date = Date.parse(ra); + if (!Number.isNaN(date)) return Math.max(0, Math.floor((date - Date.now()) / 1000)); + return 1; +} + +async function safeJson(res: Response): Promise { + try { + return await res.json(); + } catch { + return null; + } +} + +function tryParse(text: string): any { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +/** + * Convenience: generate a stable idempotency_key for a logical write + * operation. Pass anything that uniquely identifies the action — a + * file hash, a user-action UUID, the SHA of the local repo state, etc. + * + * const key = idempotencyKey([filePath, sha256(content)]); + * await sas.call("upload_packages", { files: [...], idempotency_key: key }); + */ +export function idempotencyKey(parts: ReadonlyArray): string { + const joined = parts.map((p) => String(p)).join(":"); + // 24-char prefix + djb2 hash → stable, short, no crypto dep needed. + let hash = 5381; + for (let i = 0; i < joined.length; i++) { + hash = ((hash << 5) + hash + joined.charCodeAt(i)) | 0; + } + return `sas-idem-${joined.slice(0, 24).replace(/[^a-zA-Z0-9_-]/g, "_")}-${(hash >>> 0).toString(36)}`; +} diff --git a/packages/sdk-ts/src/types.ts b/packages/sdk-ts/src/types.ts new file mode 100644 index 00000000..54f212e5 --- /dev/null +++ b/packages/sdk-ts/src/types.ts @@ -0,0 +1,154 @@ +// Closed type catalog for every Super Agent Skill MCP tool. Generated +// once, by hand, from src/lib/mcp/tools/skills.ts. When the server +// adds a tool we update this file in the same PR so the SDK never +// drifts from the wire format. + +export type PrimitiveType = "skill" | "playbook" | "soul" | "guardrail"; + +export type Package = { + slug: string; + name: string; + type: PrimitiveType; + description: string; + latest_version: string; + author_handle?: string; + install_count?: number; +}; + +export type PackageVersion = { + version: string; + status: "stable" | "beta"; + system_prompt: string; + rules: Record; + examples: Array<{ title: string; input: string; expected_output: string }>; + compatibility: unknown[]; + notes: string; +}; + +export type ListPackagesInput = { + type?: PrimitiveType; + query?: string; + limit?: number; +}; +export type ListPackagesOutput = { count: number; items: Package[] }; + +export type SearchRegistryInput = { query: string; limit?: number }; +export type SearchRegistryOutput = { query: string; count: number; items: Package[] }; + +export type GetPackageInput = { slug: string }; +export type GetPackageOutput = { package: Package; version: PackageVersion }; + +export type RequestPrimitiveInput = { + type: PrimitiveType; + brief: string; + industry?: string; + /** Opaque string generated once per logical request. Retries within 24h replay the original response. */ + idempotency_key?: string; +}; +export type RequestPrimitiveOutput = { + request_id: string; + status: "queued" | "running" | "done" | "failed"; + note: string; + replayed?: true; +}; + +export type UploadFile = { name: string; content: string; type?: PrimitiveType }; +export type UploadPackagesInput = { + files: UploadFile[]; + /** Repeats within 24h replay the original response and DO NOT re-process the files. */ + idempotency_key?: string; +}; +export type UploadResultItem = { + ok: boolean; + slug?: string; + name?: string; + type?: PrimitiveType; + error?: string; +}; +export type UploadPackagesOutput = { + uploaded: number; + failed: number; + visibility: "private_draft"; + next_step: string; + results: UploadResultItem[]; + replayed?: true; +}; + +export type ReportExecutionInput = { + slug: string; + success: boolean; + model?: string; + version?: string; + latency_ms?: number; + tokens_in?: number; + tokens_out?: number; + agent_fp?: string; + notes?: string; +}; +export type ReportExecutionOutput = { recorded: boolean }; + +export type GetMethodologyOutput = { engine: string; methodology: unknown }; +export type ReviewSkillInput = { content: string; type?: PrimitiveType }; +export type ReviewSkillOutput = { + overall_score: number; + band: string; + top_actions: string[]; + per_pillar: Record; +}; + +export type ToolMap = { + list_packages: { input: ListPackagesInput; output: ListPackagesOutput }; + search_registry: { input: SearchRegistryInput; output: SearchRegistryOutput }; + get_package: { input: GetPackageInput; output: GetPackageOutput }; + request_primitive: { input: RequestPrimitiveInput; output: RequestPrimitiveOutput }; + upload_packages: { input: UploadPackagesInput; output: UploadPackagesOutput }; + report_execution: { input: ReportExecutionInput; output: ReportExecutionOutput }; + get_methodology: { input: Record; output: GetMethodologyOutput }; + review_skill: { input: ReviewSkillInput; output: ReviewSkillOutput }; +}; + +export type ToolName = keyof ToolMap; +export type ToolInput = ToolMap[T]["input"]; +export type ToolOutput = ToolMap[T]["output"]; + +// ----- Errors -------------------------------------------------------- + +export class MCPError extends Error { + public readonly code: number; + public readonly status: number; + public readonly data: unknown; + constructor(message: string, opts: { code: number; status: number; data?: unknown }) { + super(message); + this.name = "MCPError"; + this.code = opts.code; + this.status = opts.status; + this.data = opts.data; + } +} + +export class MCPUnauthorizedError extends MCPError { + public readonly authorizationUrl?: string; + public readonly tokensUrl?: string; + constructor(message: string, data?: any) { + super(message, { code: -32001, status: 401, data }); + this.name = "MCPUnauthorizedError"; + this.authorizationUrl = data?.authorization_url; + this.tokensUrl = data?.tokens_url; + } +} + +export class MCPRateLimitedError extends MCPError { + public readonly retryAfterSeconds: number; + constructor(message: string, retryAfterSeconds: number, data?: unknown) { + super(message, { code: -32029, status: 429, data }); + this.name = "MCPRateLimitedError"; + this.retryAfterSeconds = retryAfterSeconds; + } +} + +export type RateLimitInfo = { + limit?: number; + remaining?: number; + resetAtUnix?: number; + window?: string; +}; diff --git a/packages/sdk-ts/tsconfig.json b/packages/sdk-ts/tsconfig.json new file mode 100644 index 00000000..482ebc47 --- /dev/null +++ b/packages/sdk-ts/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/src/hooks/use-lang.ts b/src/hooks/use-lang.ts new file mode 100644 index 00000000..05d5d467 --- /dev/null +++ b/src/hooks/use-lang.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useState } from "react"; +import { detectLang, t, tf, type Lang, type TKey } from "@/lib/i18n"; + +const LS_KEY = "sas_lang"; + +// Hook that resolves the active language (localStorage override > browser +// preference > en) and gives you `t` + `tf` already bound to that language. +// Components render `en` on the server (no navigator) and re-render once +// the client hydrates and resolves the real language — no flash for +// English readers, and Portuguese readers see English for one frame +// before the swap, which is the standard trade-off for client-side i18n +// without a per-request cookie. +export function useLang(): { + lang: Lang; + setLang: (lang: Lang) => void; + t: (key: TKey) => string; + tf: (key: TKey, vars: Record) => string; +} { + const [lang, setLangState] = useState("en"); + + useEffect(() => { + try { + const stored = window.localStorage.getItem(LS_KEY) as Lang | null; + if (stored === "en" || stored === "pt-BR") { + setLangState(stored); + return; + } + } catch { + /* localStorage may be unavailable in some browsers/modes */ + } + setLangState(detectLang()); + }, []); + + const setLang = useCallback((next: Lang) => { + setLangState(next); + try { + window.localStorage.setItem(LS_KEY, next); + } catch { + /* ignore */ + } + }, []); + + const boundT = useCallback((key: TKey) => t(key, lang), [lang]); + const boundTf = useCallback( + (key: TKey, vars: Record) => tf(key, lang, vars), + [lang], + ); + + return { lang, setLang, t: boundT, tf: boundTf }; +} diff --git a/src/lib/i18n/dictionaries.ts b/src/lib/i18n/dictionaries.ts new file mode 100644 index 00000000..6f06fc1d --- /dev/null +++ b/src/lib/i18n/dictionaries.ts @@ -0,0 +1,66 @@ +// Translation dictionaries. ALWAYS update `en` and `pt-BR` together — +// the type system here is intentionally derived from `en`, so any +// untranslated key in `pt-BR` is a compile error. + +export const en = { + // /oauth/success + oauth_success_eyebrow: "Authorization complete", + oauth_success_connected_to_prefix: "Connected to ", + oauth_success_returning: "Returning you to {client}…", + oauth_success_close_tab: "You can close this tab and return to {client}. The connection is already live.", + oauth_success_didnt_connect: "Didn't see your client connect?", + oauth_success_loopback_explain: + "The local listener may have closed before we delivered the code. Paste the code below into your CLI or client when prompted.", + oauth_success_scheme_explain: "If {client} didn't open automatically, paste this code into the client when prompted.", + oauth_success_copy: "Copy", + oauth_success_copied: "Copied", + oauth_success_retry_loopback: "Or retry sending to your local client →", + oauth_success_open_in_client: "Open in {client} →", + oauth_success_manage_at: "Manage this connection at", + oauth_success_account_connections: "Account → Connections", + oauth_success_mcp_docs: "MCP docs ↗", + oauth_success_no_pending_title: "No pending connection", + oauth_success_no_pending_body: + "This page completes an MCP client authorization. Start the flow from your client (Claude, Cursor, Codex, Lovable, OpenClaw, Hermes, …) or run the CLI.", + oauth_success_read_docs: "Read the MCP docs", + + // language switcher + lang_switch_to_pt: "Português", + lang_switch_to_en: "English", +}; + +export const ptBR: typeof en = { + // /oauth/success + oauth_success_eyebrow: "Autorização concluída", + oauth_success_connected_to_prefix: "Conectado a ", + oauth_success_returning: "Te enviando de volta para {client}…", + oauth_success_close_tab: + "Você já pode fechar esta aba e voltar para o {client}. A conexão está ativa.", + oauth_success_didnt_connect: "Não viu seu cliente conectar?", + oauth_success_loopback_explain: + "O ouvinte local pode ter fechado antes de entregarmos o código. Cole o código abaixo no seu CLI ou cliente quando ele pedir.", + oauth_success_scheme_explain: + "Se o {client} não abriu sozinho, cole este código no cliente quando ele pedir.", + oauth_success_copy: "Copiar", + oauth_success_copied: "Copiado", + oauth_success_retry_loopback: "Ou tentar reenviar para seu cliente local →", + oauth_success_open_in_client: "Abrir no {client} →", + oauth_success_manage_at: "Gerencie esta conexão em", + oauth_success_account_connections: "Conta → Conexões", + oauth_success_mcp_docs: "Docs do MCP ↗", + oauth_success_no_pending_title: "Nenhuma conexão pendente", + oauth_success_no_pending_body: + "Esta página completa a autorização de um cliente MCP. Inicie o fluxo a partir do seu cliente (Claude, Cursor, Codex, Lovable, OpenClaw, Hermes, …) ou rode o CLI.", + oauth_success_read_docs: "Ler a documentação do MCP", + + lang_switch_to_pt: "Português", + lang_switch_to_en: "English", +}; + +export type TKey = keyof typeof en; +export type Lang = "en" | "pt-BR"; + +export const dictionaries: Record = { + en, + "pt-BR": ptBR, +}; diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts new file mode 100644 index 00000000..b919e847 --- /dev/null +++ b/src/lib/i18n/index.ts @@ -0,0 +1,44 @@ +/** + * Tiny locale shim — picks pt-BR vs en based on the browser's + * navigator.language, falls back to en. No i18next, no provider, no + * bundle bloat — just a `t(key, lang)` function we call from a few + * priority pages that every new user hits. + * + * Why so minimal: the rest of the site is engineering-targeted English + * and adding a heavy i18n framework for a handful of strings is + * premature. If/when we localise the full app we can swap this for + * i18next and the dictionaries already live in `dictionaries.ts`. + */ +import { dictionaries, type Lang, type TKey } from "./dictionaries"; + +export type { Lang, TKey } from "./dictionaries"; + +const SUPPORTED: Lang[] = ["en", "pt-BR"]; + +export function detectLang(): Lang { + if (typeof navigator === "undefined") return "en"; + const navList: string[] = + (navigator.languages && Array.from(navigator.languages)) || + (navigator.language ? [navigator.language] : []); + for (const raw of navList) { + if (!raw) continue; + if (raw.startsWith("pt")) return "pt-BR"; + if (SUPPORTED.includes(raw as Lang)) return raw as Lang; + } + return "en"; +} + +export function t(key: K, lang: Lang = "en"): string { + const dict = dictionaries[lang] ?? dictionaries.en; + return dict[key] ?? dictionaries.en[key] ?? key; +} + +/** Format a string template with `{name}` placeholders. */ +export function tf( + key: K, + lang: Lang, + vars: Record, +): string { + const tpl = t(key, lang); + return tpl.replace(/\{(\w+)\}/g, (_, name) => String(vars[name] ?? "")); +} diff --git a/src/routes/oauth.success.tsx b/src/routes/oauth.success.tsx index 2c5b4972..69fc7528 100644 --- a/src/routes/oauth.success.tsx +++ b/src/routes/oauth.success.tsx @@ -1,6 +1,8 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useEffect, useMemo, useRef, useState } from "react"; import { Nav } from "@/components/site/Nav"; +import { useLang } from "@/hooks/use-lang"; +import type { Lang } from "@/lib/i18n"; // We stash the sensitive payload (redirect_to URL + raw auth code) in // sessionStorage rather than putting it in the URL bar, so the auth code @@ -48,10 +50,30 @@ function classifyRedirect(uri: string): "loopback" | "private-scheme" | "https" return "private-scheme"; } +function LangSwitcher({ lang, onChange }: { lang: Lang; onChange: (l: Lang) => void }) { + return ( +
+ + +
+ ); +} + function SuccessPage() { const [handoff, setHandoff] = useState(null); const [copied, setCopied] = useState(false); const deliveredRef = useRef(false); + const { lang, setLang, t, tf } = useLang(); useEffect(() => { const h = readHandoff(); @@ -114,12 +136,14 @@ function SuccessPage() { return (