From 2f2f0c2b255bac92c74543c35abe21afeb0e076b Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 19 Aug 2026 20:31:40 +0545 Subject: [PATCH] Add mediated X API tools --- .github/workflows/test.yml | 79 +++++++++++++++++ AGENTS.md | 9 +- ARCHITECTURE.md | 65 ++++++++++++++ CONTRIBUTING.md | 28 ++++++ README.md | 53 +++++++++++- bun.lock | 17 ++-- bunfig.toml | 3 + index.ts | 1 - package.json | 67 +++++++++++++-- scripts/link-intx.ts | 37 ++++++++ src/client/client.test.ts | 152 ++++++++++++++++++++++++++++++++ src/client/client.ts | 122 ++++++++++++++++++++++++++ src/client/errors.ts | 12 +++ src/client/index.ts | 10 +++ src/index.ts | 23 +++++ src/shims/intx-agent.d.ts | 44 ++++++++++ src/sidecar-bundle.test.ts | 37 ++++++++ src/sidecar-bundle.ts | 32 +++++++ src/tools/create-tools.test.ts | 153 +++++++++++++++++++++++++++++++++ src/tools/create-tools.ts | 123 ++++++++++++++++++++++++++ src/tools/definitions.ts | 32 +++++++ src/tools/index.ts | 10 +++ src/tools/users.ts | 60 +++++++++++++ tsconfig.build.json | 17 ++++ tsconfig.json | 27 ++---- tsconfig.typecheck.json | 8 ++ 26 files changed, 1188 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 ARCHITECTURE.md create mode 100644 CONTRIBUTING.md create mode 100644 bunfig.toml delete mode 100644 index.ts create mode 100644 scripts/link-intx.ts create mode 100644 src/client/client.test.ts create mode 100644 src/client/client.ts create mode 100644 src/client/errors.ts create mode 100644 src/client/index.ts create mode 100644 src/index.ts create mode 100644 src/shims/intx-agent.d.ts create mode 100644 src/sidecar-bundle.test.ts create mode 100644 src/sidecar-bundle.ts create mode 100644 src/tools/create-tools.test.ts create mode 100644 src/tools/create-tools.ts create mode 100644 src/tools/definitions.ts create mode 100644 src/tools/index.ts create mode 100644 src/tools/users.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.typecheck.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..cea5b0e --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,79 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +# This workflow intentionally checks out live Interchange until mediated +# credentials ship in a released package; it is integration CI, not release CI. + +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + path: x-tools + - uses: actions/checkout@v4 + with: + repository: corbitsdev/interchange + path: interchange + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - name: Install Interchange (for @intx/* links) + working-directory: interchange + run: bun install --frozen-lockfile + - name: Install x-tools + working-directory: x-tools + run: bun install --frozen-lockfile + - name: Link Interchange packages + working-directory: x-tools + run: bun run link:intx + - working-directory: x-tools + run: bun run typecheck + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + path: x-tools + - uses: actions/checkout@v4 + with: + repository: corbitsdev/interchange + path: interchange + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - working-directory: interchange + run: bun install --frozen-lockfile + - working-directory: x-tools + run: bun install --frozen-lockfile + - working-directory: x-tools + run: bun run link:intx + - working-directory: x-tools + run: bun run test + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + path: x-tools + - uses: actions/checkout@v4 + with: + repository: corbitsdev/interchange + path: interchange + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + - working-directory: interchange + run: bun install --frozen-lockfile + - working-directory: x-tools + run: bun install --frozen-lockfile + - working-directory: x-tools + run: bun run link:intx + - working-directory: x-tools + run: bun run build diff --git a/AGENTS.md b/AGENTS.md index a7bf5b7..aa9ef08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,8 @@ Instructions for AI agents working in this repository. ## Overview -**Corbits Tools for X** — tooling for X (Twitter). +**Corbits Tools for X** (`@corbits/x-tools`) — X API client and Interchange +tools (credential handle `x-api`). See ARCHITECTURE.md. ## Conventions @@ -12,10 +13,16 @@ Instructions for AI agents working in this repository. - Touch only code related to the task. - Comment *why*, never *what*. - No emojis in code or docs. +- Package never reads `process.env` — hosts pass options explicitly. +- Tool names match X MCP / xmcp names as-is. +- Field defaults mirror xurl shortcuts unless the caller overrides. ## Build & Validation ```bash bun install +bun run link:intx +bun run typecheck bun test +bun run build ``` diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..785c7d3 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,65 @@ +# Architecture + +## What this package is + +`@corbits/x-tools` is an X API client plus an Interchange tool package. Tools +authenticate through Interchange's **mediated credential** rail — the secret +never appears on the tool options API. + +## Interchange credentials + +Declared in `package.json`: + +```json +"interchange": { + "tools": "./dist/sidecar-bundle.js", + "credentials": [{ "handle": "x-api" }] +} +``` + +At runtime the sidecar factory (`src/sidecar-bundle.ts`): + +1. Receives `env.capabilities` from the host (`requires: ["capabilities"]`) +2. Builds a tool runner with the capabilities +3. On the first tool call, resolves `credentials` → handle `x-api` → http + mediated credential and caches the client +4. Uses `createXClient({ fetchImpl: mediated.fetch })` — Bearer is injected by + Interchange per request, pinned to the provider origin (`https://api.x.com`) + +Agent definitions bind the handle: + +```ts +credentialBindings: [{ + package: "@corbits/x-tools", + handle: "x-api", + provider: "x", // tenant provider whose origin is https://api.x.com + locator: "tenant", +}] +``` + +See Interchange `docs/CREDENTIALS.md` and the credential-probe e2e fixture. + +The client accepts only an injected `fetchImpl`; production passes the mediated +credential's fetch and tests provide a stub. + +## Layout + +``` +src/ + client/ # createXClient, XAPIError + tools/ # definitions, domain handlers, createXTools + sidecar-bundle.ts # defineTool export for the Interchange loader + index.ts +``` + +Depends on a sibling `../interchange` checkout. Run `bun run link:intx` to +symlink its packages so we track live `intx-src` — not the stale npm `0.2.2` +cut. +## Tool delivery plan (v1) + +1. Users → 2. Posts → 3. Search → 4. Bookmarks → 5. Media → 6. News / Trends + +## Phase 2 (deferred) + +Lists, DMs, Spaces, Communities, Community Notes, Articles, XChat, compliance / +Account Activity webhooks. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..3c7a7c9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,28 @@ +# Contributing + +A small, deliberately boring codebase: strict TypeScript, no magic. + +Setup and commands are in the [README](./README.md#working-on-it). `bun run +typecheck` must be clean — it is its own CI step, and `any` is not a way past it. + +## Interchange (local) + +`bun run link:intx` symlinks `@intx/*` from a sibling `../interchange` checkout +(live `intx-src`, not npm `0.2.2`). Peer dependencies still declare +`@intx/agent` and `@intx/types`. + +See [ARCHITECTURE.md](./ARCHITECTURE.md) for the delivery plan and non-goals. + +## Tests + +- **Red first.** A bug fix starts with a test that fails for the reason you believe, + and you should watch it fail. +- Assert **behavior a consumer can observe** over internal call shapes. +- Colocate tests next to the code they cover (`*.test.ts`). + +## Pull requests + +- Keep commits focused, and keep the diff to the change you are describing. +- Explain *why* in the commit message; the code already says what. +- CI must be green: typecheck, test, and build. +- Contributions are accepted under the repository's LGPL-2.1-only licence. diff --git a/README.md b/README.md index 7d69f21..6651d57 100644 --- a/README.md +++ b/README.md @@ -1 +1,52 @@ -Corbits Tools for X +# @corbits/x-tools + +X API client and Interchange tools. Auth goes through Interchange credentials +(handle `x-api`). See [ARCHITECTURE.md](./ARCHITECTURE.md). + +## Install + +```bash +bun add github:corbitsdev/x-tools +# or pin a commit: +bun add github:corbitsdev/x-tools# +``` + +Not on npm yet; consume from git or an `npm pack` tarball. The repository root +*is* the package. + +## Interchange + +```json +"interchange": { + "tools": "./dist/sidecar-bundle.js", + "credentials": [{ "handle": "x-api" }] +} +``` + +Bind `x-api` on the agent definition to a tenant credential whose provider +origin is `https://api.x.com`. The sidecar resolves the mediated http handle +and never takes a raw secret on its options. + +## Client + +The internal client accepts an injected `fetchImpl`. The sidecar wires the +Interchange mediated credential, so this package never accepts a raw X token. + +## Working on it + +Requires a sibling checkout of Interchange at `../interchange`. Link its live +packages with `bun run link:intx`. + +```bash +bun install +bun run link:intx +bun run typecheck +bun run test +bun run build +``` + +See [CONTRIBUTING.md](./CONTRIBUTING.md) and [ARCHITECTURE.md](./ARCHITECTURE.md). + +## License + +LGPL-2.1-only. See [LICENSE](./LICENSE). diff --git a/bun.lock b/bun.lock index fd902e4..fb0c271 100644 --- a/bun.lock +++ b/bun.lock @@ -3,24 +3,31 @@ "configVersion": 1, "workspaces": { "": { - "name": "x-tools", + "name": "@corbits/x-tools", "devDependencies": { - "@types/bun": "latest", + "@types/bun": "1.3.14", + "@types/node": "22.10.5", + "typescript": "5.9.3", }, "peerDependencies": { - "typescript": "^5", + "@intx/agent": "*", + "@intx/types": "*", }, + "optionalPeers": [ + "@intx/agent", + "@intx/types", + ], }, }, "packages": { "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - "@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="], + "@types/node": ["@types/node@22.10.5", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], } } diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..a9882ee --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,3 @@ +# Resolve Interchange packages to TypeScript source (same as Interchange itself). +[resolve] +conditions = ["intx-src"] diff --git a/index.ts b/index.ts deleted file mode 100644 index f67b2c6..0000000 --- a/index.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("Hello via Bun!"); \ No newline at end of file diff --git a/package.json b/package.json index e3b1cf8..5f6f02f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,14 @@ "version": "0.1.0", "type": "module", "license": "LGPL-2.1-only", - "description": "Corbits Tools for X", + "description": "Corbits Tools for X — X API client and Interchange tools.", + "keywords": [ + "corbits", + "x", + "twitter", + "tools", + "interchange" + ], "author": "Corbits", "homepage": "https://github.com/corbitsdev/x-tools#readme", "repository": { @@ -13,11 +20,61 @@ "bugs": { "url": "https://github.com/corbitsdev/x-tools/issues" }, - "module": "index.ts", - "devDependencies": { - "@types/bun": "latest" + "engines": { + "node": ">=22" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "bun": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./sidecar-bundle": { + "bun": "./src/sidecar-bundle.ts", + "types": "./dist/sidecar-bundle.d.ts", + "default": "./dist/sidecar-bundle.js" + } + }, + "interchange": { + "tools": "./dist/sidecar-bundle.js", + "credentials": [ + { + "handle": "x-api" + } + ] + }, + "files": [ + "dist", + "src", + "!src/**/*.test.ts", + "LICENSE", + "README.md", + "ARCHITECTURE.md" + ], + "sideEffects": false, + "scripts": { + "link:intx": "bun scripts/link-intx.ts", + "typecheck": "tsc -p tsconfig.typecheck.json --noEmit", + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "prepack": "bun run build", + "test": "bun test --conditions intx-src src", + "test:client": "bun test --conditions intx-src src/client", + "test:tools": "bun test --conditions intx-src src/tools", + "test:coverage": "bun test --conditions intx-src --coverage src" }, "peerDependencies": { - "typescript": "^5" + "@intx/agent": "*", + "@intx/types": "*" + }, + "peerDependenciesMeta": { + "@intx/agent": { "optional": true }, + "@intx/types": { "optional": true } + }, + "devDependencies": { + "@types/bun": "1.3.14", + "@types/node": "22.10.5", + "typescript": "5.9.3" } } diff --git a/scripts/link-intx.ts b/scripts/link-intx.ts new file mode 100644 index 0000000..65fdc2b --- /dev/null +++ b/scripts/link-intx.ts @@ -0,0 +1,37 @@ +/** + * Link sibling Interchange packages into node_modules so we track live + * source (intx-src), not the stale npm 0.2.2 cut. + * + * Expects `../interchange` next to this repo (local Corbits layout). + */ +import { existsSync, mkdirSync, rmSync, symlinkSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const interchangeRoot = resolve(root, "../interchange"); +const intxModules = join(interchangeRoot, "node_modules/@intx"); +const destRoot = join(root, "node_modules/@intx"); + +const packages = ["agent", "types", "inference", "log", "mime", "crypto"] as const; + +if (!existsSync(intxModules)) { + console.warn( + `link-intx: ${intxModules} not found — skip (CI without sibling interchange)`, + ); + process.exit(0); +} + +mkdirSync(destRoot, { recursive: true }); + +for (const name of packages) { + const src = join(intxModules, name); + const dest = join(destRoot, name); + if (!existsSync(src)) { + console.warn(`link-intx: missing ${src} — skip`); + continue; + } + rmSync(dest, { recursive: true, force: true }); + symlinkSync(src, dest); + console.log(`link-intx: @intx/${name} -> ${src}`); +} diff --git a/src/client/client.test.ts b/src/client/client.test.ts new file mode 100644 index 0000000..d21157e --- /dev/null +++ b/src/client/client.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test } from "bun:test"; + +import { createXClient, XAPIError } from "./client.js"; + +describe("createXClient", () => { + test("requires an injected fetch implementation", () => { + expect(() => createXClient({} as never)).toThrow(/fetchImpl/); + }); + + test("GETs a relative path without adding credentials", async () => { + const fetchImpl = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + expect(String(input)).toBe("https://api.x.com/2/users/me"); + expect(init?.method).toBe("GET"); + const headers = init?.headers as Record; + expect(headers.Authorization).toBeUndefined(); + expect(headers.Accept).toBe("application/json"); + return new Response(JSON.stringify({ data: { id: "1", name: "Ada" } }), { + status: 200, + }); + }) as unknown as typeof fetch; + + const client = createXClient({ fetchImpl }); + const body = await client.request({ method: "GET", path: "/2/users/me" }); + expect(body).toEqual({ data: { id: "1", name: "Ada" } }); + }); + + test("applies query params and skips nullish values", async () => { + const fetchImpl = (async (input: string | URL | Request) => { + const url = new URL(String(input)); + expect(url.pathname).toBe("/2/users/me"); + expect(url.searchParams.get("user.fields")).toBe("username"); + expect(url.searchParams.has("cursor")).toBe(false); + return new Response(JSON.stringify({ data: { id: "1" } }), { + status: 200, + }); + }) as unknown as typeof fetch; + + const client = createXClient({ fetchImpl }); + await client.request({ + method: "GET", + path: "/2/users/me", + query: { + "user.fields": "username", + cursor: undefined, + next: null, + }, + }); + }); + + test("POSTs JSON bodies with Content-Type", async () => { + const fetchImpl = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + expect(String(input)).toBe("https://api.x.com/2/tweets"); + expect(init?.method).toBe("POST"); + expect(init?.body).toBe(JSON.stringify({ text: "hello" })); + const headers = init?.headers as Record; + expect(headers["Content-Type"]).toBe("application/json"); + return new Response(JSON.stringify({ data: { id: "t1" } }), { + status: 201, + }); + }) as unknown as typeof fetch; + + const client = createXClient({ fetchImpl }); + const body = await client.request({ + method: "POST", + path: "/2/tweets", + body: { text: "hello" }, + }); + expect(body).toEqual({ data: { id: "t1" } }); + }); + + test("rejects absolute request paths", async () => { + const fetchImpl = (async () => + new Response("{}", { status: 200 })) as unknown as typeof fetch; + const client = createXClient({ fetchImpl }); + + await expect( + client.request({ + method: "GET", + path: "https://upload.example/1.1/media/upload.json", + }), + ).rejects.toThrow(/path must be relative/); + }); + + test("passes the caller abort signal to fetch", async () => { + const controller = new AbortController(); + let observedSignal: AbortSignal | undefined; + const fetchImpl = (async ( + _input: string | URL | Request, + init?: RequestInit, + ) => { + observedSignal = init?.signal ?? undefined; + return new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; + + const client = createXClient({ fetchImpl }); + await client.request({ + method: "GET", + path: "/2/users/me", + signal: controller.signal, + }); + expect(observedSignal).toBeDefined(); + expect(observedSignal?.aborted).toBe(false); + }); + + test("returns undefined for 204 and empty bodies", async () => { + const fetchImpl = (async () => + new Response(null, { status: 204 })) as unknown as typeof fetch; + const client = createXClient({ fetchImpl }); + + await expect( + client.request({ method: "DELETE", path: "/2/tweets/1" }), + ).resolves.toBeUndefined(); + }); + + test("surfaces non-2xx responses as XAPIError with status and body", async () => { + const fetchImpl = (async () => + new Response('{"detail":"unauthorized"}', { + status: 401, + statusText: "Unauthorized", + })) as unknown as typeof fetch; + const client = createXClient({ fetchImpl }); + + await expect( + client.request({ method: "GET", path: "/2/users/me" }), + ).rejects.toThrow(XAPIError); + try { + await client.request({ method: "GET", path: "/2/users/me" }); + throw new Error("expected request to throw"); + } catch (err) { + expect(err).toBeInstanceOf(XAPIError); + const apiErr = err as XAPIError; + expect(apiErr.status).toBe(401); + expect(apiErr.body).toBe('{"detail":"unauthorized"}'); + } + }); + + test("rejects invalid JSON on a 2xx response", async () => { + const fetchImpl = (async () => + new Response("not-json", { status: 200 })) as unknown as typeof fetch; + const client = createXClient({ fetchImpl }); + + await expect( + client.request({ method: "GET", path: "/2/users/me" }), + ).rejects.toThrow(/invalid JSON/); + }); +}); diff --git a/src/client/client.ts b/src/client/client.ts new file mode 100644 index 0000000..227db5b --- /dev/null +++ b/src/client/client.ts @@ -0,0 +1,122 @@ +/** + * Small JSON client for the X API. Authentication belongs to the injected + * fetch implementation, which is the Interchange mediated credential in + * production. + */ +import { XAPIError } from "./errors.js"; + +export { XAPIError } from "./errors.js"; + +export type XQueryValue = string | number | boolean | undefined | null; + +export type XRequest = { + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** Path relative to the X API, e.g. `/2/users/me`. */ + path: string; + query?: Record; + body?: unknown; + headers?: Record; + signal?: AbortSignal; +}; + +export type CreateXClientOptions = { + fetchImpl: typeof fetch; +}; + +export type XClient = { + request(options: XRequest): Promise; +}; + +const X_API_BASE_URL = "https://api.x.com"; +const USER_AGENT = "@corbits/x-tools/0.1.0"; +const REQUEST_TIMEOUT_MS = 30_000; + +function joinUrl(path: string): string { + if (/^https?:\/\//i.test(path)) { + throw new Error("X API request path must be relative"); + } + return `${X_API_BASE_URL}${path.startsWith("/") ? path : `/${path}`}`; +} + +function applyQuery( + url: URL, + query: Record | undefined, +): void { + if (query === undefined) return; + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null) continue; + url.searchParams.set(key, String(value)); + } +} + +export function createXClient({ fetchImpl }: CreateXClientOptions): XClient { + if (typeof fetchImpl !== "function") { + throw new Error("createXClient: provide fetchImpl"); + } + + async function request(requestOptions: XRequest): Promise { + const url = new URL(joinUrl(requestOptions.path)); + applyQuery(url, requestOptions.query); + + const headers: Record = { + Accept: "application/json", + "User-Agent": USER_AGENT, + ...requestOptions.headers, + }; + + let body: string | undefined; + if ( + requestOptions.body !== undefined && + (requestOptions.method === "POST" || + requestOptions.method === "PUT" || + requestOptions.method === "PATCH") + ) { + body = JSON.stringify(requestOptions.body); + headers["Content-Type"] ??= "application/json"; + } + + let response: Response; + try { + response = await fetchImpl(url.toString(), { + method: requestOptions.method, + headers, + body, + signal: + requestOptions.signal === undefined + ? AbortSignal.timeout(REQUEST_TIMEOUT_MS) + : AbortSignal.any([ + requestOptions.signal, + AbortSignal.timeout(REQUEST_TIMEOUT_MS), + ]), + }); + } catch (cause) { + throw new Error( + `X API request failed: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ); + } + + if (!response.ok) { + const errBody = await response.text().catch(() => ""); + throw new XAPIError(response.status, response.statusText, errBody); + } + + if (response.status === 204) return undefined; + + const raw = await response.text(); + if (raw.length === 0) return undefined; + + try { + const parsed: unknown = JSON.parse(raw); + return parsed; + } catch (cause) { + const snippet = raw.slice(0, 200).replace(/\s+/g, " "); + throw new Error( + `X API request failed: invalid JSON body (status ${String(response.status)}, body starts: ${JSON.stringify(snippet)})`, + { cause }, + ); + } + } + + return { request }; +} diff --git a/src/client/errors.ts b/src/client/errors.ts new file mode 100644 index 0000000..3bcfafa --- /dev/null +++ b/src/client/errors.ts @@ -0,0 +1,12 @@ +/** Non-2xx response from the X API — carries status and raw body text. */ +export class XAPIError extends Error { + readonly status: number; + readonly body: string; + + constructor(status: number, statusText: string, body: string) { + super(`X API request failed: ${String(status)} ${statusText}`); + this.name = "XAPIError"; + this.status = status; + this.body = body; + } +} diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..50d410c --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,10 @@ +export { + createXClient, + XAPIError, +} from "./client.js"; +export type { + XClient, + CreateXClientOptions, + XRequest, + XQueryValue, +} from "./client.js"; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f1ee18e --- /dev/null +++ b/src/index.ts @@ -0,0 +1,23 @@ +// @corbits/x-tools — X API client and Interchange tools. See ARCHITECTURE.md. + +export { + createXClient, + XAPIError, +} from "./client/index.js"; +export type { + XClient, + CreateXClientOptions, + XRequest, + XQueryValue, +} from "./client/index.js"; + +export { + X_CREDENTIAL_HANDLE, + TOOL_DEFINITIONS, + USER_LOOKUP_FIELDS, + createXTools, +} from "./tools/index.js"; +export type { + CreateXToolsOptions, + XTools, +} from "./tools/index.js"; diff --git a/src/shims/intx-agent.d.ts b/src/shims/intx-agent.d.ts new file mode 100644 index 0000000..aeb43ed --- /dev/null +++ b/src/shims/intx-agent.d.ts @@ -0,0 +1,44 @@ +import type { + ToolCall, + ToolDefinition, + ToolResult, +} from "@intx/types/runtime"; + +/** Typecheck-only surface for the small `@intx/agent` API this package uses. */ +export interface BaseEnv { + [key: string]: unknown; +} + +export interface ToolDeclaration { + readonly name: string; + readonly approval?: "ask"; +} + +export type ToolHandler = ( + call: ToolCall, + signal: AbortSignal, +) => Promise; + +export interface ToolBundle { + readonly definitions: readonly ToolDefinition[]; + run(call: ToolCall, signal: AbortSignal): Promise; + dispose?(): Promise; +} + +export type ToolFactory = ( + env: EnvReq, +) => ToolBundle; + +export type AnnotatedToolFactory = + ToolFactory & { + readonly id: string; + readonly requires: readonly string[]; + readonly definitions: readonly ToolDeclaration[]; + }; + +export function defineTool(opts: { + id: string; + requires?: readonly string[]; + definitions: readonly ToolDeclaration[]; + factory: ToolFactory; +}): AnnotatedToolFactory; diff --git a/src/sidecar-bundle.test.ts b/src/sidecar-bundle.test.ts new file mode 100644 index 0000000..cd27d12 --- /dev/null +++ b/src/sidecar-bundle.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { createRuntimeCapabilities } from "@intx/types/runtime-capabilities"; + +import { x, type XToolEnv } from "./sidecar-bundle.js"; +import { TOOL_DEFINITIONS } from "./tools/definitions.js"; + +describe("sidecar-bundle", () => { + test("declares the same tool names as the package catalog", () => { + expect(x.definitions.map((definition) => definition.name)).toEqual( + TOOL_DEFINITIONS.map((definition) => definition.name), + ); + }); + + test("creates a runnable bundle through the defineTool boundary", async () => { + const fetchImpl = (async () => + new Response(JSON.stringify({ data: { id: "42" } }), { status: 200 })) as unknown as typeof fetch; + const capabilities = createRuntimeCapabilities({ + credentials: { + resolve: async () => ({ + kind: "http" as const, + fetch: fetchImpl, + dispose() {}, + }), + }, + }); + + // The sidecar factory only reads capabilities; the host supplies the rest of BaseEnv. + const bundle = x({ capabilities } as XToolEnv); + const result = await bundle.run( + { id: "call-1", name: "getUsersMe", arguments: {} }, + new AbortController().signal, + ); + + expect(result.content).toEqual({ data: { id: "42" } }); + await bundle.dispose?.(); + }); +}); diff --git a/src/sidecar-bundle.ts b/src/sidecar-bundle.ts new file mode 100644 index 0000000..b2889f0 --- /dev/null +++ b/src/sidecar-bundle.ts @@ -0,0 +1,32 @@ +/** + * Sidecar-bundle entry for `@corbits/x-tools` — the factory the Interchange + * tool-package loader invokes. + * + * Declares credential handle `x-api` (see package.json `interchange.credentials`). + * At run, resolves that handle from `env.capabilities` into an http mediated + * credential and dispatches the package tool definitions against it. + */ +import { defineTool, type BaseEnv } from "@intx/agent"; +import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; + +import { createXTools } from "./tools/create-tools.js"; +import { TOOL_DEFINITIONS } from "./tools/definitions.js"; + +export interface XToolEnv extends BaseEnv { + capabilities: RuntimeCapabilities; +} + +/** Named export the loader picks up. */ +export const x = defineTool({ + id: "@corbits/x-tools/sidecar-bundle", + requires: ["capabilities"], + definitions: TOOL_DEFINITIONS.map((def) => ({ name: def.name })), + factory: (env) => { + const tools = createXTools({ capabilities: env.capabilities }); + return { + definitions: tools.definitions, + run: (call, signal) => tools.run(call, signal), + dispose: () => tools.dispose(), + }; + }, +}); diff --git a/src/tools/create-tools.test.ts b/src/tools/create-tools.test.ts new file mode 100644 index 0000000..49e90fa --- /dev/null +++ b/src/tools/create-tools.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from "bun:test"; + +import { createRuntimeCapabilities } from "@intx/types/runtime-capabilities"; + +import { createXTools } from "./create-tools.js"; +import { TOOL_DEFINITIONS, X_CREDENTIAL_HANDLE } from "./definitions.js"; + +function createTestCapabilities(opts: { + fetchImpl: typeof fetch; + handle?: string; +}) { + const handle = opts.handle ?? X_CREDENTIAL_HANDLE; + return createRuntimeCapabilities({ + credentials: { + async resolve(requested) { + if (requested !== handle) { + throw new Error(`unbound credential handle: ${requested}`); + } + return { + kind: "http" as const, + fetch: opts.fetchImpl, + dispose() {}, + }; + }, + }, + }); +} + +describe("TOOL_DEFINITIONS", () => { + test("declares the users-domain tools", () => { + expect(TOOL_DEFINITIONS.map((tool) => tool.name).sort()).toEqual([ + "getUsersByUsername", + "getUsersMe", + ]); + }); +}); + +describe("createXTools", () => { + test("runs getUsersMe through the mediated credential", async () => { + const fetchImpl = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + const url = new URL(String(input)); + expect(url.origin + url.pathname).toBe("https://api.x.com/2/users/me"); + expect(url.searchParams.get("user.fields")).toContain("public_metrics"); + const headers = init?.headers as Record; + expect(headers.Authorization).toBeUndefined(); + return new Response( + JSON.stringify({ data: { id: "42", username: "ada" } }), + { status: 200 }, + ); + }) as unknown as typeof fetch; + + const tools = createXTools({ + capabilities: createTestCapabilities({ fetchImpl }), + }); + const result = await tools.run( + { id: "call-1", name: "getUsersMe", arguments: {} }, + new AbortController().signal, + ); + + expect(result).toEqual({ + callId: "call-1", + content: { data: { id: "42", username: "ada" } }, + }); + await tools.dispose(); + }); + + test("looks up a username with the expected fields and URL encoding", async () => { + const fetchImpl = (async (input: string | URL | Request) => { + const url = new URL(String(input)); + expect(url.pathname).toBe("/2/users/by/username/ada%20lovelace"); + expect(url.searchParams.get("user.fields")).toContain("public_metrics"); + return new Response(JSON.stringify({ data: { id: "42" } }), { status: 200 }); + }) as unknown as typeof fetch; + + const tools = createXTools({ + capabilities: createTestCapabilities({ fetchImpl }), + }); + const result = await tools.run( + { + id: "call-2", + name: "getUsersByUsername", + arguments: { username: "@ada lovelace" }, + }, + new AbortController().signal, + ); + + expect(result.content).toEqual({ data: { id: "42" } }); + await tools.dispose(); + }); + + test("returns a validation error for an empty username", async () => { + const fetchImpl = (async () => { + throw new Error("fetch should not run"); + }) as unknown as typeof fetch; + const tools = createXTools({ + capabilities: createTestCapabilities({ fetchImpl }), + }); + + const result = await tools.run( + { + id: "call-3", + name: "getUsersByUsername", + arguments: { username: "@" }, + }, + new AbortController().signal, + ); + + expect(result.isError).toBe(true); + expect(result.content).toEqual({ + error: 'argument "username" must not be empty', + }); + await tools.dispose(); + }); + + test("returns isError for unknown tool names", async () => { + const tools = createXTools({ + capabilities: createTestCapabilities({ + fetchImpl: (async () => new Response("{}", { status: 200 })) as unknown as typeof fetch, + }), + }); + + const result = await tools.run( + { id: "call-4", name: "nope", arguments: {} }, + new AbortController().signal, + ); + expect(result).toEqual({ + callId: "call-4", + content: { error: 'Unknown tool: "nope"' }, + isError: true, + }); + }); + + test("fails closed when the credential handle is unbound", async () => { + const tools = createXTools({ + capabilities: createTestCapabilities({ + handle: "other-handle", + fetchImpl: (async () => new Response("{}", { status: 200 })) as unknown as typeof fetch, + }), + }); + + const result = await tools.run( + { id: "call-5", name: "getUsersMe", arguments: {} }, + new AbortController().signal, + ); + expect(result.isError).toBe(true); + expect(String((result.content as { error: string }).error)).toContain( + X_CREDENTIAL_HANDLE, + ); + }); +}); diff --git a/src/tools/create-tools.ts b/src/tools/create-tools.ts new file mode 100644 index 0000000..879be1a --- /dev/null +++ b/src/tools/create-tools.ts @@ -0,0 +1,123 @@ +import type { ToolHandler } from "@intx/agent"; +import type { + ToolCall, + ToolDefinition, + ToolResult, + ToolRunner, +} from "@intx/types/runtime"; +import type { HttpMediatedCredential } from "@intx/types"; +import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; + +import { + createXClient, + type XClient, +} from "../client/index.js"; +import { + TOOL_DEFINITIONS, + X_CREDENTIAL_HANDLE, +} from "./definitions.js"; +import { + makeGetUsersByUsernameHandler, + makeGetUsersMeHandler, +} from "./users.js"; + +export type CreateXToolsOptions = { + capabilities: RuntimeCapabilities; +}; + +export interface XTools extends ToolRunner { + readonly definitions: ToolDefinition[]; + dispose(): Promise; +} + +type ToolHandlerFactory = (client: XClient) => ToolHandler; + +const HANDLER_FACTORIES: ReadonlyMap = new Map([ + ["getUsersMe", makeGetUsersMeHandler], + ["getUsersByUsername", makeGetUsersByUsernameHandler], +]); + +export function createXTools(opts: CreateXToolsOptions): XTools { + assertCatalogMatchesHandlers(); + let clientPromise: Promise | undefined; + let handlersPromise: Promise> | undefined; + let mediated: HttpMediatedCredential | undefined; + let disposed = false; + + async function getClient(): Promise { + clientPromise ??= (async () => { + const credentials = opts.capabilities.resolve("credentials"); + const resolved = await credentials.resolve(X_CREDENTIAL_HANDLE); + if (resolved.kind !== "http") { + throw new Error( + `x-tools: expected http mediated credential for handle "${X_CREDENTIAL_HANDLE}", got ${resolved.kind}`, + ); + } + mediated = resolved; + return createXClient({ + fetchImpl: ((input, init) => + resolved.fetch(input, init)) as typeof fetch, + }); + })(); + return clientPromise; + } + + async function getHandlers(): Promise> { + handlersPromise ??= getClient().then((client) => + new Map( + [...HANDLER_FACTORIES].map(([name, factory]) => [ + name, + factory(client), + ]), + ), + ); + return handlersPromise; + } + + return { + definitions: TOOL_DEFINITIONS, + async run(call: ToolCall, signal: AbortSignal): Promise { + if (!HANDLER_FACTORIES.has(call.name)) { + return { + callId: call.id, + content: { error: `Unknown tool: "${call.name}"` }, + isError: true, + }; + } + + try { + const handler = (await getHandlers()).get(call.name); + if (handler === undefined) { + throw new Error(`Tool handler not registered: "${call.name}"`); + } + return await handler(call, signal); + } catch (err) { + const message = + err instanceof Error ? err.message : `unknown error: ${String(err)}`; + return { + callId: call.id, + content: { error: message }, + isError: true, + }; + } + }, + async dispose() { + if (disposed) return; + disposed = true; + if (clientPromise !== undefined) { + await clientPromise.catch(() => undefined); + } + await mediated?.dispose(); + }, + }; +} + +function assertCatalogMatchesHandlers(): void { + const definitionNames = TOOL_DEFINITIONS.map((definition) => definition.name).sort(); + const handlerNames = [...HANDLER_FACTORIES.keys()].sort(); + if (JSON.stringify(definitionNames) !== JSON.stringify(handlerNames)) { + throw new Error( + `x-tools: definitions and handlers differ (${definitionNames.join(", ")} vs ${handlerNames.join(", ")})`, + ); + } +} diff --git a/src/tools/definitions.ts b/src/tools/definitions.ts new file mode 100644 index 0000000..68e97d3 --- /dev/null +++ b/src/tools/definitions.ts @@ -0,0 +1,32 @@ +import type { ToolDefinition } from "@intx/types/runtime"; + +export const TOOL_DEFINITIONS: ToolDefinition[] = [ + { + name: "getUsersMe", + description: + "Fetch the authenticated X user (GET /2/users/me). Uses the mediated X credential.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + }, + { + name: "getUsersByUsername", + description: + "Look up an X user by username (GET /2/users/by/username/:username).", + inputSchema: { + type: "object", + properties: { + username: { + type: "string", + description: "X username without the @ prefix", + }, + }, + required: ["username"], + additionalProperties: false, + }, + }, +]; + +export const X_CREDENTIAL_HANDLE = "x-api"; diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..ebf810f --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1,10 @@ +export { + X_CREDENTIAL_HANDLE, + TOOL_DEFINITIONS, +} from "./definitions.js"; +export { createXTools } from "./create-tools.js"; +export { USER_LOOKUP_FIELDS } from "./users.js"; +export type { + CreateXToolsOptions, + XTools, +} from "./create-tools.js"; diff --git a/src/tools/users.ts b/src/tools/users.ts new file mode 100644 index 0000000..85cf7bf --- /dev/null +++ b/src/tools/users.ts @@ -0,0 +1,60 @@ +import type { ToolHandler } from "@intx/agent"; +import type { ToolResult } from "@intx/types/runtime"; + +import type { XClient } from "../client/index.js"; + +export const USER_LOOKUP_FIELDS = + "created_at,description,public_metrics,verified,verified_type,subscription_type,profile_image_url"; + +export function makeGetUsersMeHandler(client: XClient): ToolHandler { + return async (call, signal): Promise => ({ + callId: call.id, + content: toToolContent( + await client.request({ + method: "GET", + path: "/2/users/me", + query: { "user.fields": USER_LOOKUP_FIELDS }, + signal, + }), + ), + }); +} + +export function makeGetUsersByUsernameHandler(client: XClient): ToolHandler { + return async (call, signal): Promise => { + const username = getUsername(call.arguments); + return { + callId: call.id, + content: toToolContent( + await client.request({ + method: "GET", + path: `/2/users/by/username/${encodeURIComponent(username)}`, + query: { "user.fields": USER_LOOKUP_FIELDS }, + signal, + }), + ), + }; + }; +} + +function getUsername(args: Record): string { + const value = args["username"]; + if (typeof value !== "string") { + throw new Error('argument "username" must be a string'); + } + + const username = value.trim().replace(/^@/, ""); + if (username.length === 0) { + throw new Error('argument "username" must not be empty'); + } + return username; +} + +function toToolContent(value: unknown): ToolResult["content"] { + if (typeof value === "string" || isRecord(value)) return value; + return { result: value }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..67308b3 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"], + "paths": { + "@intx/agent": ["./src/shims/intx-agent.d.ts"] + }, + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index b2e7497..eb1ac07 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,30 +1,17 @@ { "compilerOptions": { - // Environment setup & latest features - "lib": ["ESNext"], + "strict": true, "target": "ESNext", - "module": "Preserve", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - "types": ["bun"], - - // Bundler mode + "module": "ESNext", "moduleResolution": "bundler", - "allowImportingTsExtensions": true, + "customConditions": ["intx-src"], "verbatimModuleSyntax": true, - "noEmit": true, - - // Best practices - "strict": true, "skipLibCheck": true, + "noEmit": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, - - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } + "types": ["node", "@types/bun"] + }, + "include": ["src"] } diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json new file mode 100644 index 0000000..ee6cfc9 --- /dev/null +++ b/tsconfig.typecheck.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@intx/agent": ["./src/shims/intx-agent.d.ts"] + } + } +}