diff --git a/.changeset/bright-ravens-jog.md b/.changeset/bright-ravens-jog.md new file mode 100644 index 00000000..3dc4a4c5 --- /dev/null +++ b/.changeset/bright-ravens-jog.md @@ -0,0 +1,5 @@ +--- +'@curl.md/claude': patch +--- + +Initial release. diff --git a/.github/README.md b/.github/README.md index 4c52851c..f732fff2 100644 --- a/.github/README.md +++ b/.github/README.md @@ -31,10 +31,13 @@ curl.md example.com md example.com # Add to your agent -opencode plugin @curl.md/opencode -pi install @curl.md/pi +opencode plugin -g @curl.md/opencode +pi install npm:@curl.md/pi npx @curl.md/amp install +claude plugin marketplace add https://curl.md/claude.json +claude plugin install curl-md@curl-md + # Add skills npx skills add https://curl.md --yes diff --git a/.github/TODO.md b/.github/TODO.md index 4e311ff3..da734e7b 100644 --- a/.github/TODO.md +++ b/.github/TODO.md @@ -7,3 +7,4 @@ - Remove the temporary TanStack Router pnpm patches in [pnpm-workspace.yaml](file:///Users/tmm/Developer/curl.md/pnpm-workspace.yaml) once [TanStack/router PR #7116](https://github.com/TanStack/router/pull/7116) is merged and released. Re-run `pnpm test:e2e test/e2e/dashboard.test.ts` after dropping them. - Switch to `cf` CLI for preview workflows https://blog.cloudflare.com/cf-cli-local-explorer/ - Add anchor-aware content narrowing for fetched markdown: narrow by heading slug in `src/md/chunk.ts` using the same slug rules as `src/lib/docs.ts`, apply anchor narrowing before keyword/objective filtering, and add `anchor` to derived-content cache keys once that behavior is enabled. +- Add Claude plugin smoke test (install/bootstrap/startup). diff --git a/.github/actions/setup-playwright/action.yml b/.github/actions/setup-playwright/action.yml new file mode 100644 index 00000000..58d589e9 --- /dev/null +++ b/.github/actions/setup-playwright/action.yml @@ -0,0 +1,17 @@ +name: Setup Playwright +description: Cache and install the Playwright Chromium browser + +runs: + using: composite + steps: + - name: Cache Playwright + id: playwright-cache + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Install Playwright + if: steps.playwright-cache.outputs.cache-hit != 'true' + shell: bash + run: pnpm exec playwright install --with-deps chromium diff --git a/.github/actions/setup-pnpm/action.yml b/.github/actions/setup-pnpm/action.yml index 6ef93206..c7525608 100644 --- a/.github/actions/setup-pnpm/action.yml +++ b/.github/actions/setup-pnpm/action.yml @@ -17,4 +17,4 @@ runs: shell: bash run: | pnpm install --frozen-lockfile --ignore-scripts - node cli/node_modules/bun/install.js + pnpm --dir cli rebuild bun --pending diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 5dc50c2e..44edbe05 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -33,9 +33,12 @@ jobs: with: persist-credentials: false - - name: Setup + - name: Setup pnpm uses: ./.github/actions/setup-pnpm + - name: Setup Playwright + uses: ./.github/actions/setup-playwright + # TODO: Remove `--ignore-registry-errors` # https://github.com/pnpm/pnpm/issues/11265 - name: Audit dependencies @@ -69,6 +72,7 @@ jobs: - name: Generate code run: | + pnpm gen:claude pnpm preconstruct pnpm gen:types pnpm db:codegen @@ -77,7 +81,7 @@ jobs: run: pnpm check:types - name: Test - run: pnpm test --project app --project md --project workers + run: pnpm test --project app --project browser --project md --project workers - name: Build run: pnpm build @@ -91,7 +95,7 @@ jobs: with: persist-credentials: false - - name: Setup + - name: Setup pnpm uses: ./.github/actions/setup-pnpm - name: Check dependencies @@ -103,9 +107,31 @@ jobs: - name: Test run: pnpm test --project cli + - name: Build + run: pnpm --filter curl.md build + + plugins: + name: Plugins + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + persist-credentials: false + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Preconstruct + run: | + pnpm preconstruct + pnpm --dir plugins/claude rebuild @anthropic-ai/claude-code --pending + + - name: Test + run: pnpm test --project plugins:amp --project plugins:claude --project plugins:opencode --project plugins:pi + - name: Build run: | - pnpm --filter curl.md build pnpm --filter @curl.md/amp build e2e: @@ -117,19 +143,11 @@ jobs: with: persist-credentials: false - - name: Setup + - name: Setup pnpm uses: ./.github/actions/setup-pnpm - - name: Cache Playwright - id: playwright-cache - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} - - - name: Install Playwright - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: pnpm exec playwright install --with-deps chromium + - name: Setup Playwright + uses: ./.github/actions/setup-playwright - name: Run E2E tests run: pnpm test:e2e diff --git a/.github/workflows/preview_deploy.yml b/.github/workflows/preview_deploy.yml index dfc44efa..325692a3 100644 --- a/.github/workflows/preview_deploy.yml +++ b/.github/workflows/preview_deploy.yml @@ -212,8 +212,9 @@ jobs: env: DB_URL: ${{ steps.ps.outputs.db_url }} - - name: Generate types + - name: Generate code run: | + pnpm gen:claude pnpm gen:types pnpm db:codegen env: diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 447b70a8..cbbdfb54 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -38,8 +38,9 @@ jobs: env: DB_URL: ${{ secrets.DB_URL }} - - name: Generate types + - name: Generate code run: | + pnpm gen:claude pnpm gen:types pnpm db:codegen env: diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 71e3e1e7..afa63571 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -41,11 +41,9 @@ jobs: uses: ./.github/actions/setup-pnpm - name: Build packages - run: | - pnpm --filter curl.md build - pnpm --filter @curl.md/amp build + run: pnpm --filter curl.md --filter @curl.md/amp build - name: Publish preview run: | node --experimental-strip-types scripts/formatPackage.ts - pnpx pkg-pr-new publish --pnpm ./cli ./plugins/amp ./plugins/pi + pnpx pkg-pr-new publish --pnpm ./cli ./plugins/amp ./plugins/claude ./plugins/opencode ./plugins/pi diff --git a/.gitignore b/.gitignore index 291db66f..d564dbc0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.amp +/.claude /.opencode .env* .vite diff --git a/cli/README.md b/cli/README.md index e72a8137..fd7aa8f6 100644 --- a/cli/README.md +++ b/cli/README.md @@ -9,7 +9,7 @@

-### URL to markdown for agents. +### URL to markdown for agents Turn websites into **optimized, low token output** to **supercharge your context**. Works with **every agent**. diff --git a/cli/src/cli.test.ts b/cli/src/cli.test.ts index 6528d653..4dca8367 100644 --- a/cli/src/cli.test.ts +++ b/cli/src/cli.test.ts @@ -79,6 +79,7 @@ test('help', async () => { Commands: auth Authenticate with curl.md (login, logout, status) credits Manage prepaid credits (add, status) + fetch Fetch URL as markdown org Manage organizations (create, invite, list, member, switch, view) request Manage requests (list, view) token Manage API tokens (create, list, delete) diff --git a/cli/src/cli.ts b/cli/src/cli.ts index b1c625e1..903ad464 100755 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -34,16 +34,8 @@ const vars = z.object({ session: z.custom(), }) -const cli = Cli.create('curl.md', { - aliases, - description: 'URL to markdown for agents', - version: pkg.version, - env, - vars, - usage: [{ suffix: ' [options]' }], - args: z.object({ - url: z.string().describe('URL to fetch'), - }), +const root = { + args: z.object({ url: z.string().describe('URL to fetch') }), options: z.object({ fresh: z.boolean().optional().describe('Force fresh fetch (bypass cache)'), keywords: z.array(z.string()).optional().describe('Pre-filter by keywords (comma-separated)'), @@ -55,7 +47,7 @@ const cli = Cli.create('curl.md', { objective: z.string().optional().describe('Narrow content to a specific objective'), token: z.string().optional().describe('API token for authentication (env: CURLMD_API_KEY)'), }), - alias: { fresh: 'f', keywords: 'k', mode: 'm', objective: 'o', token: 't' }, + output: z.string().describe('Page content as markdown'), examples: [ { args: { url: 'example.com' } }, { @@ -98,179 +90,19 @@ const cli = Cli.create('curl.md', { }, }, ], - output: z.string().describe('Page content as markdown'), - format: 'md', - async run(c) { - const result = z.safeParse( - z - .string() - .transform((arg) => (arg.includes('://') ? arg : `https://${arg}`)) - .pipe( - z.url({ - hostname: z.regexes.domain, - normalize: true, - protocol: /^https?$/, - }), - ), - c.args.url, - ) - if (!result.success) - return c.error({ - code: 'INVALID_URL', - message: `Invalid URL: ${c.args.url}`, - cta: { - description: 'URL must be valid HTTP(S) address:', - commands: [ - { - command: c.displayName, - args: { url: 'example.com' }, - description: 'domain without protocol', - }, - { - command: c.displayName, - args: { url: 'https://example.com/path' }, - description: 'full URL with protocol', - }, - ...c.var.commands, - ], - }, - }) - - const keywords = c.options.keywords?.flatMap((k: string) => k.split(',')) - const token = c.options.token ?? c.var.apiKey - const spinner = UI.createSpinner('') - const res = await c.var.client.fetch(result.data, { - fresh: c.options.fresh, - keywords, - mode: c.options.mode, - objective: c.options.objective, - token, - }) - - spinner.stop() - - if (res.status === 400) { - const json = await res.json() - return c.error({ - code: json.code.toUpperCase(), - message: formatValidationError(json), - }) - } - - if (res.status === 401) { - const json = await res.json() - return c.error({ - code: json.code.toUpperCase(), - message: json.message, - cta: { - description: 'Create API token:', - commands: [ - { - command: `${c.displayName} token create `, - description: 'create API token', - }, - ...c.var.commands, - ], - }, - }) - } - - if (res.status === 403) { - const json = await res.json() - Session.write({ organization_id: undefined }, c.var.baseUrl) - return c.error({ - code: json.code.toUpperCase(), - message: json.message, - cta: { - description: 'Switch organization:', - commands: [ - { - command: `${c.displayName} org switch`, - description: 'switch organization', - }, - ...c.var.commands, - ], - }, - }) - } - - if (res.status === 429) { - const json = await res.json() - const retryAfter = res.headers.get('retry-after') - const activeSession = c.var.apiKey ? null : Session.read(c.var.baseUrl) - return c.error({ - code: json.code.toUpperCase(), - message: retryAfter ? `${json.message}. Try again in ${retryAfter}s` : json.message, - cta: { - description: activeSession - ? 'Add credits to remove rate limits:' - : 'Authenticate for higher limits:', - commands: [ - ...(activeSession - ? [ - { - command: `${c.displayName} credits add`, - description: 'add credits', - }, - ] - : [ - { - command: `${c.displayName} auth login`, - description: 'log in for higher rate limits', - }, - ]), - ...c.var.commands, - ], - }, - }) - } - - if (res.status === 502) { - const json = await res.json() - return c.error({ code: json.code.toUpperCase(), message: json.message }) - } - - const text = await res.text() - if (!res.ok) { - let json: unknown - try { - json = JSON.parse(text) - } catch {} - const error = (() => { - if (json && typeof json === 'object' && 'code' in json && 'message' in json) { - const obj = json as { code: string; message: string } - return { code: obj.code.toUpperCase(), message: obj.message } - } - return { code: 'FETCH_FAILED', message: text } - })() - return c.error(error) - } - - if (!c.options.objective && text.length > 10_000) - return c.ok(text, { - cta: { - description: 'Narrow results with objective:', - commands: [ - { - command: c.displayName, - args: { url: result.data }, - options: { objective: true }, - description: 'focus on a specific topic', - }, - ...c.var.commands, - ], - }, - }) - - if (!c.options.objective) - return c.ok(text, { - cta: { commands: c.var.commands }, - }) + alias: { fresh: 'f', keywords: 'k', mode: 'm', objective: 'o', token: 't' } as const, + format: 'md' as const, + run, +} - return c.ok(text, { - cta: { commands: c.var.commands }, - }) - }, +const cli = Cli.create('curl.md', { + aliases, + description: 'URL to markdown for agents', + env, + usage: [{ suffix: ' [options]' }], + vars, + version: pkg.version, + ...root, }) cli.use(async (c, next) => { @@ -329,7 +161,7 @@ const requireAuth = middleware((c, next) => { return next() }) -type Command = { command: string; description?: string } +type Command = Cli.Cta type AuthContext = Pick & { var: { apiKey?: string | undefined; baseUrl: string; commands: Command[] } } @@ -1936,9 +1768,200 @@ const request = Cli.create('request', { cli.command(auth) cli.command(credits) +cli.command( + Cli.create('fetch', { + description: 'Fetch URL as markdown', + vars, + ...root, + }), +) cli.command(org.command(invite).command(member)) cli.command(request) cli.command(token) cli.command(update) export default cli + +async function run( + c: Parameters< + NonNullable< + Cli.create.Options< + typeof root.args, + undefined, + typeof root.options, + typeof root.output, + typeof vars + >['run'] + > + >[0], +) { + const result = z.safeParse( + z + .string() + .transform((arg) => (arg.includes('://') ? arg : `https://${arg}`)) + .pipe( + z.url({ + hostname: z.regexes.domain, + normalize: true, + protocol: /^https?$/, + }), + ), + c.args.url, + ) + if (!result.success) + return c.error({ + code: 'INVALID_URL', + message: `Invalid URL: ${c.args.url}`, + cta: { + description: 'URL must be valid HTTP(S) address:', + commands: [ + { + command: c.displayName, + args: { url: 'example.com' }, + description: 'domain without protocol', + }, + { + command: c.displayName, + args: { url: 'https://example.com/path' }, + description: 'full URL with protocol', + }, + ...c.var.commands, + ], + }, + }) + + const keywords = c.options.keywords?.flatMap((k: string) => k.split(',')) + const token = c.options.token ?? c.var.apiKey + const spinner = UI.createSpinner('') + const res = await c.var.client.fetch(result.data, { + fresh: c.options.fresh, + keywords, + mode: c.options.mode, + objective: c.options.objective, + token, + }) + + spinner.stop() + + if (res.status === 400) { + const json = await res.json() + return c.error({ + code: json.code.toUpperCase(), + message: formatValidationError(json), + }) + } + + if (res.status === 401) { + const json = await res.json() + return c.error({ + code: json.code.toUpperCase(), + message: json.message, + cta: { + description: 'Create API token:', + commands: [ + { + command: `${c.displayName} token create `, + description: 'create API token', + }, + ...c.var.commands, + ], + }, + }) + } + + if (res.status === 403) { + const json = await res.json() + Session.write({ organization_id: undefined }, c.var.baseUrl) + return c.error({ + code: json.code.toUpperCase(), + message: json.message, + cta: { + description: 'Switch organization:', + commands: [ + { + command: `${c.displayName} org switch`, + description: 'switch organization', + }, + ...c.var.commands, + ], + }, + }) + } + + if (res.status === 429) { + const json = await res.json() + const retryAfter = res.headers.get('retry-after') + const activeSession = c.var.apiKey ? null : Session.read(c.var.baseUrl) + return c.error({ + code: json.code.toUpperCase(), + message: retryAfter ? `${json.message}. Try again in ${retryAfter}s` : json.message, + cta: { + description: activeSession + ? 'Add credits to remove rate limits:' + : 'Authenticate for higher limits:', + commands: [ + ...(activeSession + ? [ + { + command: `${c.displayName} credits add`, + description: 'add credits', + }, + ] + : [ + { + command: `${c.displayName} auth login`, + description: 'log in for higher rate limits', + }, + ]), + ...c.var.commands, + ], + }, + }) + } + + if (res.status === 502) { + const json = await res.json() + return c.error({ code: json.code.toUpperCase(), message: json.message }) + } + + const text = await res.text() + if (!res.ok) { + let json: unknown + try { + json = JSON.parse(text) + } catch {} + const error = (() => { + if (json && typeof json === 'object' && 'code' in json && 'message' in json) { + const obj = json as { code: string; message: string } + return { code: obj.code.toUpperCase(), message: obj.message } + } + return { code: 'FETCH_FAILED', message: text } + })() + return c.error(error) + } + + if (!c.options.objective && text.length > 10_000) + return c.ok(text, { + cta: { + description: 'Narrow results with objective:', + commands: [ + { + command: c.displayName, + args: { url: result.data }, + options: { objective: true }, + description: 'focus on a specific topic', + }, + ...c.var.commands, + ], + }, + }) + + if (!c.options.objective) + return c.ok(text, { + cta: { commands: c.var.commands }, + }) + + return c.ok(text, { + cta: { commands: c.var.commands }, + }) +} diff --git a/config/knip.json b/config/knip.json index 415d1304..3bbb1eae 100644 --- a/config/knip.json +++ b/config/knip.json @@ -15,6 +15,14 @@ "entry": ["src/install.ts"], "ignore": ["**/*.test.ts"] }, + "plugins/claude": { + "entry": ["scripts/sync.ts", "src/server.ts"], + "ignore": ["**/*.test.ts"], + "ignoreDependencies": ["@anthropic-ai/claude-code"] + }, + "plugins/opencode": { + "ignore": ["**/*.test.ts"] + }, "plugins/pi": { "ignore": ["**/*.test.ts", "test/**"] } diff --git a/docs/dev/develop.mdx b/docs/dev/develop.mdx index 9c957a5b..8fe9f028 100644 --- a/docs/dev/develop.mdx +++ b/docs/dev/develop.mdx @@ -126,7 +126,7 @@ If your change affects a published package or release notes, add a changeset. $ pnpm changeset ``` -That creates a markdown file in `.changeset/` where you select the package and bump type. In this repo, that commonly means `curl.md`, `@curl.md/amp`, `@curl.md/opencode`, or `@curl.md/pi`. +That creates a markdown file in `.changeset/` where you select the package and bump type. In this repo, that commonly means `curl.md`, `@curl.md/amp`, `@curl.md/claude`, `@curl.md/opencode`, or `@curl.md/pi`. Docs-only or purely local dev changes usually do not need a changeset. diff --git a/docs/guide/agent-usage.mdx b/docs/guide/agent-usage.mdx index 5fa20e87..ca87d45f 100644 --- a/docs/guide/agent-usage.mdx +++ b/docs/guide/agent-usage.mdx @@ -20,7 +20,7 @@ If curl.md docs are relevant, prefer .md docs pages or llms.txt over raw page fe This usually gives the agent cleaner, lower-noise context than a raw page fetch or browser snapshot. :::tip[Go deeper] -Install [plugins](/docs/guide/plugins) for Claude, Codex, OpenCode, and more to make curl.md a native part of your agent workflow. +Install a native [plugin](/docs/guide/plugins) (recommended), or use the [CLI skills](/docs/guide/cli#agents) or [MCP mode](/docs/guide/cli#mcp) to integrate more deeply. ::: ## Single-Page Markdown Access diff --git a/docs/guide/api.mdx b/docs/guide/api.mdx index f41cbcb6..84f6be38 100644 --- a/docs/guide/api.mdx +++ b/docs/guide/api.mdx @@ -274,5 +274,5 @@ Rate-limited responses also include `retry-after`. ## When to use the API/SDK - Use the [HTTP API](#http-api) for quick fetches from browsers, scripts, and simple integrations. -- Use the [TypeScript SDK](#typescript-sdk) when you want typed request options and status-aware responses in app code. For example, tool plugins, like the [OpenCode plugin](https://github.com/wevm/curl.md/blob/main/plugins/opencode/server.ts) and [Pi extension](https://github.com/wevm/curl.md/blob/main/plugins/pi/extensions/index.ts). +- Use the [TypeScript SDK](#typescript-sdk) when you want typed request options and status-aware responses in app code. For example, tool plugins, like the [OpenCode plugin](https://github.com/wevm/curl.md/blob/main/plugins/opencode/src/server.ts) and [Pi extension](https://github.com/wevm/curl.md/blob/main/plugins/pi/src/index.ts). - Use the [CLI](/docs/guide/cli) when you want shell workflows, local auth, and token or organization management. diff --git a/docs/guide/cli.mdx b/docs/guide/cli.mdx index f0954b4d..85919bc7 100644 --- a/docs/guide/cli.mdx +++ b/docs/guide/cli.mdx @@ -167,6 +167,24 @@ $ curl.md skills add --no-global It's recommended to use native agent [plugins](/docs/guide/plugins) instead of direct CLI usage when possible. +### MCP + +If your editor or agent supports MCP stdio servers, you can run the CLI directly in MCP mode: + +```sh +$ curl.md --mcp +``` + +That exposes curl.md's `fetch` command as an MCP tool, so hosts without a first-party curl.md integration can still fetch URLs through the normal CLI/API path. + +If your agent supports automatic MCP registration, you can also use: + +```sh +$ curl.md mcp add +``` + +See [`mcp add`](#mcp-add) below for supported options and agent targeting. + ## Commands {/* GENERATED:cli-commands:start */} @@ -270,6 +288,36 @@ Check credits $ curl.md credits status ``` + +### `fetch` + +Fetch URL as markdown + +```sh +$ curl.md fetch [options] +``` + +| Argument | Type | Description | +| -------- | ----- | ------------ | +| `url` | `url` | URL to fetch | + +| Option | Type | Default | Description | +| ----------------- | ------------- | ------- | -------------------------------------------------- | +| `--fresh, -f` | `boolean` | | Force fresh fetch (bypass cache) | +| `--keywords, -k` | `array` | | Pre-filter by keywords (comma-separated) | +| `--mode, -m` | `rush\|smart` | `smart` | Mode when narrowing content with --objective | +| `--objective, -o` | `string` | | Narrow content to a specific objective | +| `--token, -t` | `string` | | API token for authentication (env: CURLMD_API_KEY) | + +```sh +$ curl.md fetch example.com +$ curl.md fetch docs.github.com/en/webhooks/webhook-events-and-payloads --objective pull request webhook event payload and actions --keywords pull_request +$ curl.md fetch developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch --objective streaming response body --keywords ReadableStream,getReader +$ curl.md fetch developers.cloudflare.com/d1/get-started --objective how to query D1 from a worker --keywords D1,bindings +$ curl.md fetch ai-sdk.dev/docs/ai-sdk-core/generating-text --objective how to stream text with the ai sdk --keywords streamText,generateText +$ curl.md fetch zod.dev/error-formatting --objective tree error formatting --keywords treeifyError +``` + ### `org` diff --git a/docs/guide/plugins.mdx b/docs/guide/plugins.mdx index a0af911b..2932c6ec 100644 --- a/docs/guide/plugins.mdx +++ b/docs/guide/plugins.mdx @@ -60,9 +60,9 @@ const client = createClient() const res = await client.fetch('example.com') ``` -Check out the [OpenCode plugin source](https://github.com/wevm/curl.md/blob/main/plugins/opencode/server.ts) and [Pi extension source](https://github.com/wevm/curl.md/blob/main/plugins/pi/extensions/index.ts) for comprehensive examples on how to use the TypeScript SDK. +Check out the [OpenCode plugin source](https://github.com/wevm/curl.md/blob/main/plugins/opencode/src/server.ts) and [Pi extension source](https://github.com/wevm/curl.md/blob/main/plugins/pi/src/index.ts) for comprehensive examples on how to use the TypeScript SDK. -If you aren't using TypeScript, check out the [API docs](#TODO) for more info on how to work with the API. +If you aren't using TypeScript, check out the [API docs](/docs/guide/api) for more info on how to work with the API. ### Use the CLI diff --git a/docs/plugins/amp.mdx b/docs/plugins/amp.mdx index ae9870f7..0d104e21 100644 --- a/docs/plugins/amp.mdx +++ b/docs/plugins/amp.mdx @@ -49,7 +49,7 @@ Amp requires `PLUGINS=all` to be set in order to load plugins. Add this to your ### Use Amp -Now that Amp is running with the curl.md plugin, all you need to do is use Amp. To confirm everything is set up correctly, try this out: +Now that Amp is running with the curl.md plugin, all you need to do is use Amp. To confirm everything is set up, try this out: ```text Read the curl.md Amp plugin docs and summarize how it works. @@ -115,10 +115,20 @@ For non-interactive environments, set [`CURLMD_API_KEY`](/docs/guide/cli#environ The plugin registers the following tools: -| Tool | Description | -| --------------- | ------------------------------------------------------------------------- | -| `curl_md` | Fetches URLs through curl.md and returns markdown optimized for agents. | -| `read_web_page` | Overrides built-in `read_web_page` so it returns curl.md markdown output. | +| Tool | Description | +| --------------- | -------------------------------------------------------- | +| `curl_md` | Fetch a URL as markdown. | +| `read_web_page` | Overrides built-in `read_web_page` with markdown output. | + +Both `curl_md` and the intercepted `read_web_page` tool accept the following inputs: + +| Input | Type | Description | +| ------------ | ---------------- | ------------------------------------------------------------------------------------ | +| `url` | `string` | HTTP(S) URL or bare domain to fetch. Prefer the canonical docs or article URL. | +| `objective?` | `string` | Specific question to answer from the page. Use when only part matters. | +| `keywords?` | `string[]` | Keywords to focus extraction on relevant sections. | +| `mode?` | `rush` / `smart` | Use `rush` for speed or `smart` for better section selection on long or noisy pages. | +| `fresh?` | `boolean` | Bypass cache when freshness matters. | ## Contributing diff --git a/docs/plugins/claude.mdx b/docs/plugins/claude.mdx index 2c4eba00..db67351e 100644 --- a/docs/plugins/claude.mdx +++ b/docs/plugins/claude.mdx @@ -1,16 +1,188 @@ --- -title: Claude -description: Install and use curl.md with Claude +title: Claude Code +description: Install and use curl.md with Claude Code --- +import packageJson from '../../plugins/claude/package.json' + # Claude -:::important[Work in progress] -curl.md doesn't have a first-party Claude plugin yet. In the meantime, we recommend adding the [curl.md skills](/docs/skills) to your agent instead. +First-party [Claude Code](https://code.claude.com) plugin that adds a `curl_md` MCP tool, `/curl-md:fetch` slash skill, and an opt-in `WebFetch` redirect hook. + + + +## Quick Start + +Add the marketplace and plugin, launch Claude Code, and start curling. + +::::steps + +### Add marketplace + +Add the curl.md [plugin marketplace](https://code.claude.com/docs/en/plugin-marketplaces#plugin-marketplace-add) to Claude Code. + +```sh +$ claude plugin marketplace add https://curl.md/claude.json +``` + +### Install plugin + +Install the `curl-md` plugin from the marketplace you just added: + +```sh +$ claude plugin install curl-md@curl-md +``` + +:::tip +If Claude is already running, use the `/reload-plugins` command. ::: -:::tip[Contributions welcome] -If you use Claude and would like to contribute the curl.md Claude plugin, check out the [Contributing guide](/docs/dev/develop) and open a [pull request](https://github.com/wevm/curl.md). +### Use Claude + +Now that Claude is running with the curl.md plugin, all you need to do is use Claude. To confirm everything is set up, try this out: + +```text +Read the curl.md Claude plugin docs and summarize how it works. +https://curl.md/docs/plugins/claude +``` + +Claude will automatically use curl.md to turn URLs you paste, or URLs it decides to fetch on its own, into markdown. -If it meets the quality bar (please no slop) and is merged, we will give you **$100 in curl.md credits**. That's _a lot_ of requests. +:::tip +Optionally, [authenticate](#authentication) with `curl.md auth login` or `CURLMD_API_KEY` to unlock higher usage limits. ::: + +:::: + +## Install + +Claude installs plugins through marketplaces, so the public install path is the hosted marketplace manifest at [`https://curl.md/claude.json`](https://curl.md/claude.json). + +From your terminal: + +```sh +$ claude plugin marketplace add https://curl.md/claude.json +$ claude plugin install curl-md@curl-md +``` + +Or inside Claude: + +```sh +/plugin marketplace add https://curl.md/claude.json +/plugin install curl-md@curl-md +/reload-plugins +``` + +To update when a new version is available, refresh the marketplace, then reinstall the plugin: + +```sh +$ claude plugin marketplace update curl-md +$ claude plugin install curl-md@curl-md +``` + +Or inside Claude: + +```sh +/plugin marketplace update curl-md +/plugin install curl-md@curl-md +/reload-plugins +``` + +## Configuration + +Claude Code's built-in web reader is `WebFetch`. Since Claude plugins cannot transparently replace built-in tool results, the curl.md plugin ships an opt-in redirect hook that blocks `WebFetch` and tells Claude to retry with `curl_md` using the same URL. + +| Option | Type | Default | Description | +| ------------------- | --------- | ------- | -------------------------------------------------------------------------------------------------------------- | +| `webfetch_redirect` | `boolean` | `false` | Block built-in `WebFetch` and tell Claude to retry with `curl_md`, mapping the original prompt to `objective`. | + +:::note +By default, the plugin leaves Claude's built-in `WebFetch` enabled. If you want to turn on the `WebFetch` redirect manually, add the plugin option to your Claude settings: + +```json title="~/.claude/settings.json" +{ + "pluginConfigs": { + "curl-md@curl-md": { + "options": { + "webfetch_redirect": true + } + } + } +} +``` + +Claude also prompts for `webfetch_redirect` when configuring the plugin, and exposes the saved value to the redirect hook automatically. + +::: + +## Usage + +Ask for a page, paste links, or let Claude figure out what it needs to fetch. + +### Prompting + +Inside Claude, prompt like you normally would, for example: + +```text +Read the Cloudflare Agents docs and summarize the core concepts. +https://developers.cloudflare.com/agents +``` + +In this example, Claude will use curl.md to fetch the page and return markdown optimized for agents. + +You can also tell Pi to use a specific [objective](#TODO), [keywords](#TODO), or let it decide what to use on its own (it’s really good at this). + +### Authentication + +The plugin uses the same auth model as the [CLI](/docs/guide/cli#authentication). Log in to connect the plugin to your curl.md account or organization: + +```sh +$ curl.md auth login +``` + +For non-interactive environments, set [`CURLMD_API_KEY`](/docs/guide/cli#environment-variables). + +### Skills + +After installing, Claude registers the following slash skill: + +| Skill | Description | +| ---------------- | -------------------------------------------------- | +| `/curl-md:fetch` | Explicitly tells Claude to use the `curl_md` tool. | + +### Tools + +The plugin also registers the following tool: + +| Tool | Description | +| --------- | ------------------------ | +| `curl_md` | Fetch a URL as markdown. | + +The `curl_md` tool accepts the following inputs: + +| Input | Type | Description | +| ------------ | ---------------- | ------------------------------------------------------------------------------------ | +| `url` | `string` | HTTP(S) URL or bare domain to fetch. Prefer the canonical docs or article URL. | +| `objective?` | `string` | Specific question to answer from the page. Use when only part matters. | +| `keywords?` | `string[]` | Keywords to focus extraction on relevant sections. | +| `mode?` | `rush` / `smart` | Use `rush` for speed or `smart` for better section selection on long or noisy pages. | +| `fresh?` | `boolean` | Bypass cache when freshness matters. | + +### Status/Debugging + +If you want to confirm Claude loaded the plugin correctly: + +```sh +/mcp +/skills +``` + +You should see the `curl_md` MCP server/tool and the `/curl-md:fetch` skill. + +## Contributing + +We welcome improvements to make the Claude plugin better. Feel free to create issues or pull requests [on GitHub](https://github.com/wevm/curl.md) if there are issues or something could be improved. diff --git a/docs/plugins/opencode.mdx b/docs/plugins/opencode.mdx index a130fa85..881fed42 100644 --- a/docs/plugins/opencode.mdx +++ b/docs/plugins/opencode.mdx @@ -41,7 +41,7 @@ $ opencode ### Use OpenCode -Now that OpenCode is running with the curl.md plugin, all you need to do is use OpenCode. To confirm everything is set up correctly, try this out: +Now that OpenCode is running with the curl.md plugin, all you need to do is use OpenCode. To confirm everything is set up, try this out: ```text Read the curl.md OpenCode plugin docs and summarize how it works. @@ -96,7 +96,14 @@ By default, the plugin overrides the built-in `webfetch` tool to ensure maximum ```json title="~/.config/opencode/opencode.json" { "$schema": "https://opencode.ai/config.json", - "plugin": [["@curl.md/opencode", { "webfetch": false }]] + "plugin": [ + [ + "@curl.md/opencode", + { + "webfetch": false + } + ] + ] } ``` @@ -139,25 +146,41 @@ For non-interactive environments, set [`CURLMD_API_KEY`](/docs/guide/cli#environ After installing, OpenCode registers the following commands: -| Command | Description | -| ---------------- | ---------------------------------------------------------------------------- | -| `curl_md_login` | Starts browser-based curl.md login flow and stores resulting session. | -| `curl_md_logout` | Logs out current curl.md session. | -| `curl_md_org` | Switches the active curl.md account or organization OpenCode should use. | -| `curl_md_status` | Shows curl.md auth status, registered tool info, `curl.md` CLI availability. | +| Command | Description | +| ---------------- | -------------------- | +| `curl_md_login` | Log in. | +| `curl_md_logout` | Log out. | +| `curl_md_org` | Switch organization. | +| `curl_md_status` | Show status. | ### Tools The plugin registers the following tools: -| Tool | Description | -| ---------- | ----------------------------------------------------------------------- | -| `curl_md` | Fetches URLs through curl.md and returns markdown optimized for agents. | -| `webfetch` | Overrides built-in `webfetch` so it returns curl.md markdown output. | +| Tool | Description | +| ---------- | --------------------------------------------------- | +| `curl_md` | Fetch a URL as markdown. | +| `webfetch` | Overrides built-in `webfetch` with markdown output. | + +The `curl_md` tool accepts the following inputs: + +| Input | Type | Description | +| ---------- | -------- | ------------------------------------------------------------------------------ | +| `url` | `string` | HTTP(S) URL or bare domain to fetch. Prefer the canonical docs or article URL. | +| `options?` | `object` | Optional fetch settings. | + +The `options` object accepts the following inputs: + +| Input | Type | Description | +| ------------ | ---------------- | ------------------------------------------------------------------------------------ | +| `objective?` | `string` | Specific question to answer from the page. Use when only part matters. | +| `keywords?` | `string[]` | Keywords to focus extraction on relevant sections. | +| `mode?` | `rush` / `smart` | Use `rush` for speed or `smart` for better section selection on long or noisy pages. | +| `fresh?` | `boolean` | Bypass cache when freshness matters. | ### Status/Debugging -Use the `/curl_md_status` command to confirm auth state, tool registration, and `curl.md` CLI availability. +Use the `/curl_md_status` command to confirm auth state, tool registration, and CLI availability. ```text /curl_md_status diff --git a/docs/plugins/pi.mdx b/docs/plugins/pi.mdx index 017fb738..8d00652c 100644 --- a/docs/plugins/pi.mdx +++ b/docs/plugins/pi.mdx @@ -39,7 +39,7 @@ $ pi ### Use Pi -Now that Pi is running with the curl.md extension, all you need to do is use Pi. To confirm everything is set up correctly, try this out: +Now that Pi is running with the curl.md extension, all you need to do is use Pi. To confirm everything is set up, try this out: ```text Read the curl.md Pi extension docs and summarize how it works. @@ -123,25 +123,35 @@ For non-interactive environments, set [`CURLMD_API_KEY`](/docs/guide/cli#environ After installing, Pi registers the following commands: -| Command | Description | -| ---------------- | ---------------------------------------------------------------------------- | -| `curl_md_login` | Starts browser-based curl.md login flow and stores resulting session. | -| `curl_md_logout` | Logs out current curl.md session. | -| `curl_md_org` | Switches the active curl.md account or organization Pi should use. | -| `curl_md_status` | Shows curl.md auth status, registered tool info, `curl.md` CLI availability. | +| Command | Description | +| ---------------- | -------------------- | +| `curl_md_login` | Log in. | +| `curl_md_logout` | Log out. | +| `curl_md_org` | Switch organization. | +| `curl_md_status` | Show status. | ### Tools The plugin also registers the following tools: -| Tool | Description | -| --------------- | ----------------------------------------------------------------------- | -| `curl_md` | Compatibility alias for `read_web_page`. | -| `read_web_page` | Fetches URLs through curl.md and returns markdown optimized for agents. | +| Tool | Description | +| --------------- | ---------------------------------------- | +| `curl_md` | Compatibility alias for `read_web_page`. | +| `read_web_page` | Fetch a URL as markdown. | + +Both `read_web_page` and the `curl_md` alias accept the following inputs: + +| Input | Type | Description | +| ------------ | ---------------- | ------------------------------------------------------------------------------------ | +| `url` | `string` | HTTP(S) URL or bare domain to fetch. Prefer the canonical docs or article URL. | +| `objective?` | `string` | Specific question to answer from the page. Use when only part matters. | +| `keywords?` | `string[]` | Keywords to focus extraction on relevant sections. | +| `mode?` | `rush` / `smart` | Use `rush` for speed or `smart` for better section selection on long or noisy pages. | +| `fresh?` | `boolean` | Bypass cache when freshness matters. | ### Status/Debugging -Use the `curl_md_status` command to confirm auth state, tool registration, and `curl.md` CLI availability. +Use the `curl_md_status` command to confirm auth state, tool registration, and CLI availability. ```text /curl_md_status diff --git a/package.json b/package.json index 50971612..2391e2f3 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,10 @@ "#test/*": "./test/*" }, "scripts": { - "amp": "node --experimental-strip-types --no-warnings scripts/amp.ts", + "amp": "node --experimental-strip-types scripts/plugin.ts amp", "build": "vite build", "build:cli": "pnpm --filter curl.md build", + "claude": "node --experimental-strip-types scripts/plugin.ts claude", "check": "oxlint ${CI:+--deny-warnings} && oxfmt", "check:deps": "knip --config config/knip.json", "check:types": "tsgo -b && pnpm -r check:types", @@ -24,14 +25,16 @@ "deps": "pnpx taze -r --no-ignore-other-workspaces --ignore-paths node_modules", "deps:ci": "pnpx actions-up --dir .github", "dev": "docker compose up -d", + "gen:claude": "node --experimental-strip-types plugins/claude/scripts/sync.ts", "gen:fixtures:md:rules": "node --experimental-strip-types scripts/updateMdRulesFixtures.ts", "gen:types": "cp -n .env.example .env 2>/dev/null; wrangler types src/worker-configuration.d.ts && node --experimental-strip-types scripts/updateWranglerTypes.ts && pnpm --filter curl.md gen:types", - "md": "CURLMD_BASE_URL=https://curl.local NODE_TLS_REJECT_UNAUTHORIZED=0 node --experimental-strip-types --no-warnings cli/src/bin.ts", - "opencode": "node --experimental-strip-types --no-warnings scripts/opencode.ts", - "pi": "CURLMD_BASE_URL=https://curl.local NODE_TLS_REJECT_UNAUTHORIZED=0 pi --no-extensions -e plugins/pi/src/index.ts", + "md": "CURLMD_BASE_URL=https://curl.local NODE_TLS_REJECT_UNAUTHORIZED=0 node --experimental-strip-types cli/src/bin.ts", + "opencode": "node --experimental-strip-types scripts/plugin.ts opencode", + "pi": "node --experimental-strip-types scripts/plugin.ts pi", + "plugin": "node --experimental-strip-types scripts/plugin.ts", "preconstruct": "node --experimental-strip-types scripts/preconstruct.ts", "preinstall": "pnpx only-allow pnpm", - "prepare": "pnpm gen:types && pnpm preconstruct && simple-git-hooks", + "prepare": "pnpm gen:claude && pnpm gen:types && pnpm preconstruct && simple-git-hooks", "preview": "pnpm run build && vite preview", "changeset:publish": "pnpm --filter curl.md build && pnpm --filter @curl.md/amp build && node --experimental-strip-types scripts/formatPackage.ts && pnpm changeset publish && node --experimental-strip-types scripts/restorePackage.ts", "changeset:version": "pnpm changeset version", diff --git a/plugins/amp/README.md b/plugins/amp/README.md index d1cedcfb..9c08c0b0 100644 --- a/plugins/amp/README.md +++ b/plugins/amp/README.md @@ -9,9 +9,9 @@

-# @curl.md/amp +# @curl.md/amp - URL to markdown for Amp -Turn websites into **optimized, low token output** inside **Amp**. +Turn websites into **optimized, low token output** to **supercharge your context**. ## Install diff --git a/plugins/amp/src/plugin.test.ts b/plugins/amp/src/plugin.test.ts index 4474cf02..0a156515 100644 --- a/plugins/amp/src/plugin.test.ts +++ b/plugins/amp/src/plugin.test.ts @@ -85,7 +85,7 @@ test('tool.call hook intercepts read_web_page and synthesizes result', async () expect(result).toEqual({ action: 'synthesize', result: { - output: '# Example Page', + output: '# Example Page\n\n---\n\nPowered by [curl.md](https://curl.md)', }, }) }) @@ -185,13 +185,36 @@ test('rejects non-http(s) URLs', async () => { ).rejects.toThrow('URL must use http or https') }) +test('preserves URL fragments', async () => { + const requests: CapturedRequest[] = [] + server.use( + http.get('*', async ({ request }) => { + const url = new URL(request.url) + if (url.origin !== new URL(defaultBaseUrl).origin) return passthrough() + requests.push(captureRequest(request)) + return HttpResponse.json({ content: '# Fragment' }) + }), + ) + + const { tools } = loadPlugin() + const result = await tools[0]!.execute({ url: 'https://example.com/docs?q=1#section' }, { + logger: { log() {} }, + } as any) + + expect(requests[0]?.url).toContain('anchor=section') + expect((result as any).url).toBe('https://example.com/docs?q=1#section') +}) + // --- Anonymous fetch --- test('fetches anonymously and returns expected shape', async () => { + const requests: CapturedRequest[] = [] + server.use( http.get('*', async ({ request }) => { const url = new URL(request.url) if (url.origin !== new URL(defaultBaseUrl).origin) return passthrough() + requests.push(captureRequest(request)) return HttpResponse.json( { content: '# Example\n\n---\n\nPowered by [curl.md](https://curl.md)' }, { @@ -209,23 +232,31 @@ test('fetches anonymously and returns expected shape', async () => { const { tools } = loadPlugin() const result = await tools[0]!.execute( - { url: 'https://example.com', objective: 'test', keywords: ['a'], mode: 'rush', fresh: true }, + { + url: 'https://example.com/docs#intro', + objective: 'test', + keywords: ['a'], + mode: 'rush', + fresh: true, + }, { logger: { log() {} } } as any, ) + expect(requests[0]?.url).toContain(`${defaultBaseUrl}/api/https://example.com/docs`) + expect(requests[0]?.url).toContain('anchor=intro') expect(result).toEqual({ auth: 'anon', cache: 'HIT', credits_remaining: 42, fresh: true, keywords: ['a'], - markdown: '# Example', + markdown: '# Example\n\n---\n\nPowered by [curl.md](https://curl.md)', mode: 'rush', objective: 'test', request_id: 'req_abc', tokens_count: 100, tokens_saved: 50, - url: 'https://example.com/', + url: 'https://example.com/docs#intro', }) }) @@ -334,22 +365,26 @@ test('caches session auth headers in memory', async () => { // --- Retry on session 401 --- -test('retries once on session 401', async () => { +test('retries once on session 401 with forced auth refresh', async () => { + const requests: CapturedRequest[] = [] let fetchCount = 0 + let headersCalls = 0 server.use( http.post('*', async ({ request }) => { const url = new URL(request.url) if (url.origin !== new URL(defaultBaseUrl).origin || url.pathname !== '/api/auth/headers') return passthrough() + headersCalls++ return HttpResponse.json({ - authorization: 'Bearer access-token-fresh', + authorization: `Bearer access-token-${headersCalls === 1 ? 'stale' : 'fresh'}`, expires_at: '2099-01-01T00:00:00.000Z', }) }), http.get('*', async ({ request }) => { const url = new URL(request.url) if (url.origin !== new URL(defaultBaseUrl).origin) return passthrough() + requests.push(captureRequest(request)) fetchCount++ if (fetchCount === 1) return HttpResponse.json({ message: 'Unauthorized' }, { status: 401 }) return HttpResponse.json({ content: '# Retried' }) @@ -366,7 +401,12 @@ test('retries once on session 401', async () => { logger: { log() {} }, } as any) + expect(headersCalls).toBe(2) expect(fetchCount).toBe(2) + expect(requests.map((request) => request.headers.authorization)).toEqual([ + 'Bearer access-token-stale', + 'Bearer access-token-fresh', + ]) expect((result as any).auth).toBe('session') expect((result as any).markdown).toBe('# Retried') diff --git a/plugins/amp/src/plugin.ts b/plugins/amp/src/plugin.ts index 1c561cb1..02fc5ae0 100644 --- a/plugins/amp/src/plugin.ts +++ b/plugins/amp/src/plugin.ts @@ -41,37 +41,32 @@ export default function (amp: PluginAPI) { amp.registerTool({ name: 'curl_md', - description: - 'Read the contents of a web page at a given URL via curl.md and return markdown optimized for coding agents. Fallback for read_web_page interception.', + description: 'Fetch a URL as markdown. Used for read_web_page interception.', inputSchema: { type: 'object', properties: { url: { type: 'string', description: - 'HTTP(S) URL or bare domain to fetch via curl.md. Prefer the canonical docs or article URL you want summarized.', + 'HTTP(S) URL or bare domain to fetch. Prefer the canonical docs or article URL.', }, objective: { type: 'string', - description: - 'Specific question or goal to answer from the page. Prefer concrete objectives like "compare pricing tiers" or "find auth header requirements".', + description: 'Specific question to answer from the page. Use when only part matters.', }, keywords: { type: 'array', items: { type: 'string' }, - description: - 'Keywords to pre-filter sections by. Prefer 2-5 distinct terms when only part of a long page matters.', + description: 'Keywords to focus extraction on relevant sections.', }, mode: { type: 'string', enum: ['rush', 'smart'], - description: - 'rush: lower-latency, best when you already know the section. smart: higher-quality narrowing for long or noisy pages.', + description: 'rush: faster. smart: better section selection on long or noisy pages.', }, fresh: { type: 'boolean', - description: - 'Bypass curl.md cache when freshness matters, such as changelogs, release notes, or recently updated docs.', + description: 'Bypass cache when freshness matters.', }, }, required: ['url'], @@ -120,7 +115,7 @@ export default function (amp: PluginAPI) { let res = await client.fetch(url, { ...fetchParams, token: apiKey }) if (res.status === 401 && authType === 'session') { - authHeaders = await resolver() + authHeaders = await resolver({ forceRefresh: true }) if (!authHeaders) authType = 'anon' const retryClient = createClient(baseUrl, { headers: apiKey ? createHeaders(null) : createHeaders(authHeaders), @@ -195,10 +190,7 @@ export default function (amp: PluginAPI) { credits_remaining: parseNumberHeader(res.headers.get('x-credits-remaining')), fresh: input.fresh || undefined, keywords: input.keywords, - markdown: json.content.replace( - /\n\n---\n\nPowered by \[curl\.md\]\(https:\/\/curl\.md\)$/, - '', - ), + markdown: json.content, mode: input.mode, objective: input.objective, request_id: res.headers.get('x-request-id') || undefined, diff --git a/plugins/claude/.claude-plugin/plugin.json b/plugins/claude/.claude-plugin/plugin.json new file mode 100644 index 00000000..69aeb07b --- /dev/null +++ b/plugins/claude/.claude-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "author": { + "name": "curl.md" + }, + "description": "Use curl.md inside Claude Code for low-token web fetches.", + "homepage": "https://curl.md/docs/plugins/claude", + "hooks": "./hooks/hooks.json", + "license": "MIT", + "name": "curl-md", + "repository": "https://github.com/wevm/curl.md/tree/main/plugins/claude", + "userConfig": { + "webfetch_redirect": { + "default": false, + "description": "Block Claude Code's built-in WebFetch tool and tell Claude to retry with curl_md.", + "title": "Redirect WebFetch to curl_md", + "type": "boolean" + } + }, + "version": "0.0.0" +} diff --git a/plugins/claude/.mcp.json b/plugins/claude/.mcp.json new file mode 100644 index 00000000..95ca6a85 --- /dev/null +++ b/plugins/claude/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "curl_md": { + "command": "sh", + "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/start.sh"] + } + } +} diff --git a/plugins/claude/README.md b/plugins/claude/README.md new file mode 100644 index 00000000..6a4706e7 --- /dev/null +++ b/plugins/claude/README.md @@ -0,0 +1,40 @@ +

+ + + + + curl.md + + +
+

+ +# @curl.md/amp - URL to markdown for Claude + +Turn websites into **optimized, low token output** to **supercharge your context**. + +## Install + +```sh +claude plugin marketplace add https://curl.md/claude.json +claude plugin install curl-md@curl-md +``` + +To update: + +```sh +claude plugin marketplace update curl-md +claude plugin install curl-md@curl-md +``` + +## Documentation + +For full documentation, visit [curl.md/docs](https://curl.md/docs/plugins/claude) + +## Optional WebFetch Redirect + +Enable the plugin's `webfetch_redirect` setting to block built-in `WebFetch` calls and nudge Claude to retry with the plugin's `curl_md` tool. + +## License + +[MIT](https://github.com/wevm/curl.md/blob/main/LICENSE) diff --git a/plugins/claude/hooks/hooks.json b/plugins/claude/hooks/hooks.json new file mode 100644 index 00000000..248dea78 --- /dev/null +++ b/plugins/claude/hooks/hooks.json @@ -0,0 +1,25 @@ +{ + "hooks": { + "PreToolUse": [ + { + "hooks": [ + { + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/scripts/redirect-webfetch.sh\"", + "type": "command" + } + ], + "matcher": "WebFetch" + } + ], + "SessionStart": [ + { + "hooks": [ + { + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/scripts/bootstrap-deps.sh\"", + "type": "command" + } + ] + } + ] + } +} diff --git a/plugins/claude/package.json b/plugins/claude/package.json new file mode 100644 index 00000000..307ca8d4 --- /dev/null +++ b/plugins/claude/package.json @@ -0,0 +1,49 @@ +{ + "name": "@curl.md/claude", + "version": "0.0.0", + "description": "curl.md plugin for Claude Code", + "contributors": [ + "tmm ", + "jxom " + ], + "funding": "https://github.com/sponsors/wevm", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/wevm/curl.md.git", + "directory": "plugins/claude" + }, + "keywords": [ + "claude", + "claude-code", + "curl.md", + "markdown", + "mcp" + ], + "files": [ + ".claude-plugin/**", + ".mcp.json", + "CHANGELOG.md", + "hooks/**", + "README.md", + "scripts/**/*.sh", + "src/**/*.ts", + "!src/**/*.test.ts", + "skills/**" + ], + "type": "module", + "scripts": { + "check:types": "tsgo --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/server": "2.0.0-alpha.2", + "curl.md": "workspace:*", + "zod": "^4.3.6" + }, + "devDependencies": { + "@anthropic-ai/claude-code": "^2.1.118" + }, + "engines": { + "node": ">=22.0.0" + } +} diff --git a/plugins/claude/scripts/bootstrap-deps.sh b/plugins/claude/scripts/bootstrap-deps.sh new file mode 100644 index 00000000..ce576d69 --- /dev/null +++ b/plugins/claude/scripts/bootstrap-deps.sh @@ -0,0 +1,29 @@ +#!/bin/sh +set -eu + +plugin_root=${CLAUDE_PLUGIN_ROOT:-$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)} +plugin_data=${CLAUDE_PLUGIN_DATA:-} + +if [ -z "$plugin_data" ]; then + exit 0 +fi + +plugin_package="$plugin_root/package.json" +data_package="$plugin_data/package.json" +plugin_node_modules="$plugin_root/node_modules" +data_node_modules="$plugin_data/node_modules" + +mkdir -p "$plugin_data" + +if [ ! -f "$data_package" ] || ! diff -q "$plugin_package" "$data_package" >/dev/null 2>&1; then + cp "$plugin_package" "$data_package" + + if ! (cd "$plugin_data" && npm install --omit=dev); then + rm -f "$data_package" + exit 1 + fi +fi + +if [ ! -e "$plugin_node_modules" ] && [ -d "$data_node_modules" ]; then + ln -s "$data_node_modules" "$plugin_node_modules" +fi diff --git a/plugins/claude/scripts/redirect-webfetch.sh b/plugins/claude/scripts/redirect-webfetch.sh new file mode 100644 index 00000000..98680b9b --- /dev/null +++ b/plugins/claude/scripts/redirect-webfetch.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -eu + +case "${CLAUDE_PLUGIN_OPTION_webfetch_redirect:-}" in + 1|[Tt][Rr][Uu][Ee]) ;; + *) exit 0 ;; +esac + +cat <<'EOF' +{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": "Use curl_md instead of WebFetch for URL reads. Retry this request with curl_md using the same url, and map the WebFetch prompt to curl_md objective." + } +} +EOF diff --git a/plugins/claude/scripts/start.sh b/plugins/claude/scripts/start.sh new file mode 100644 index 00000000..a2abf028 --- /dev/null +++ b/plugins/claude/scripts/start.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -eu + +plugin_root=${CLAUDE_PLUGIN_ROOT:-$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)} +plugin_data=${CLAUDE_PLUGIN_DATA:-} + +if [ ! -f "$plugin_root/src/server.ts" ]; then + echo "curl.md Claude plugin entrypoint not found. Expected src/server.ts." >&2 + exit 1 +fi + +if [ -n "$plugin_data" ] && [ ! -e "$plugin_root/node_modules" ] && [ -d "$plugin_data/node_modules" ]; then + ln -s "$plugin_data/node_modules" "$plugin_root/node_modules" +fi + +exec node --experimental-strip-types --no-warnings "$plugin_root/src/server.ts" diff --git a/plugins/claude/scripts/sync.ts b/plugins/claude/scripts/sync.ts new file mode 100644 index 00000000..3ff04a52 --- /dev/null +++ b/plugins/claude/scripts/sync.ts @@ -0,0 +1,67 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +console.log('Syncing Claude plugin manifests.') + +const pluginRoot = path.resolve(import.meta.dirname, '..') +const repoRoot = path.resolve(pluginRoot, '../..') + +const packageJsonPath = path.join(pluginRoot, 'package.json') +const pluginJsonPath = path.join(pluginRoot, '.claude-plugin', 'plugin.json') +const marketplaceJsonPath = path.join(repoRoot, 'public/claude.json') + +const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')) as { + name: string + version: string +} + +const pluginJson = JSON.parse(await fs.readFile(pluginJsonPath, 'utf8')) as PluginManifest + +const pluginManifest = { + ...pluginJson, + version: packageJson.version, +} satisfies PluginManifest + +const marketplaceManifest = { + name: pluginManifest.name, + owner: pluginManifest.author, + plugins: [ + { + author: pluginManifest.author, + description: pluginManifest.description, + homepage: pluginManifest.homepage, + license: pluginManifest.license, + name: pluginManifest.name, + repository: pluginManifest.repository, + source: { + package: packageJson.name, + source: 'npm', + }, + version: packageJson.version, + }, + ], +} + +await fs.writeFile(pluginJsonPath, `${JSON.stringify(pluginManifest, undefined, 2)}\n`, 'utf8') +await fs.writeFile( + marketplaceJsonPath, + `${JSON.stringify(marketplaceManifest, undefined, 2)}\n`, + 'utf8', +) + +console.log('Done.') + +type PluginManifest = { + author?: { + email?: string | undefined + name: string + } + description: string + homepage?: string | undefined + hooks?: Record + license?: string | undefined + name: string + repository?: string | undefined + userConfig?: Record + version: string +} diff --git a/plugins/claude/skills/fetch/SKILL.md b/plugins/claude/skills/fetch/SKILL.md new file mode 100644 index 00000000..ab976aea --- /dev/null +++ b/plugins/claude/skills/fetch/SKILL.md @@ -0,0 +1,15 @@ +--- +description: Use when you need markdown from a public URL, docs page, article, or changelog. +--- + +Use `curl_md` to fetch the URL or domain in `$ARGUMENTS` or the surrounding conversation. + +Guidelines: + +- Prefer the canonical docs or article URL. +- Set `objective` when the user asks a specific question. +- Set `keywords` for long pages when only a few sections matter. +- Use `mode: "smart"` for long or noisy pages, `mode: "rush"` when the relevant section is already obvious. +- Set `fresh: true` when freshness matters, such as changelogs, release notes, pricing, or recently updated docs. + +After fetching, answer the user directly instead of restating the tool output verbatim. diff --git a/plugins/claude/src/plugin.test.ts b/plugins/claude/src/plugin.test.ts new file mode 100644 index 00000000..016afd4f --- /dev/null +++ b/plugins/claude/src/plugin.test.ts @@ -0,0 +1,186 @@ +import { execFileSync } from 'node:child_process' +import fs, { readFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { expect, test } from 'vitest' + +const pluginRoot = path.resolve(import.meta.dirname, '..') +const redirectScriptPath = path.join(pluginRoot, 'scripts', 'redirect-webfetch.sh') +const startScriptPath = path.join(pluginRoot, 'scripts', 'start.sh') + +test('plugin manifest passes Claude validation', () => { + const claudeBinPath = path.join( + pluginRoot, + 'node_modules', + '.bin', + process.platform === 'win32' ? 'claude.cmd' : 'claude', + ) + + const stdout = execFileSync(claudeBinPath, ['plugin', 'validate', '.'], { + cwd: pluginRoot, + encoding: 'utf8', + }) + + expect(stdout).toContain('Validation passed') +}) + +test('WebFetch redirect hook stays inert by default', () => { + const stdout = execFileSync('sh', [redirectScriptPath], { + encoding: 'utf8', + env: process.env, + input: + '{"tool_name":"WebFetch","tool_input":{"prompt":"Summarize the page","url":"https://example.com"}}\n', + }) + + expect(stdout).toBe('') +}) + +test('WebFetch redirect hook blocks WebFetch when opt-in is enabled', () => { + const stdout = execFileSync('sh', [redirectScriptPath], { + encoding: 'utf8', + env: { + ...process.env, + CLAUDE_PLUGIN_OPTION_webfetch_redirect: 'true', + }, + input: + '{"tool_name":"WebFetch","tool_input":{"prompt":"Summarize the page","url":"https://example.com"}}\n', + }) + + expect(JSON.parse(stdout)).toEqual({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: + 'Use curl_md instead of WebFetch for URL reads. Retry this request with curl_md using the same url, and map the WebFetch prompt to curl_md objective.', + }, + }) +}) + +test('marketplace manifest stays in sync with package and plugin metadata', () => { + const packageJsonPath = path.join(pluginRoot, 'package.json') + const marketplaceJsonPath = path.resolve(pluginRoot, '../../public/claude.json') + const pluginJsonPath = path.join(pluginRoot, '.claude-plugin', 'plugin.json') + + const marketplaceJson = JSON.parse(readFileSync(marketplaceJsonPath, 'utf8')) as { + name?: string + owner?: { name: string } + plugins?: Array> + } + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { + name?: string + version?: string + } + const pluginJson = JSON.parse(readFileSync(pluginJsonPath, 'utf8')) as { + author?: { name: string } + description?: string + homepage?: string + license?: string + name?: string + repository?: string + version?: string + } + + expect(pluginJson.version).toBe(packageJson.version) + expect(marketplaceJson).toEqual({ + name: pluginJson.name, + owner: pluginJson.author, + plugins: [ + { + author: pluginJson.author, + description: pluginJson.description, + homepage: pluginJson.homepage, + license: pluginJson.license, + name: pluginJson.name, + repository: pluginJson.repository, + source: { + package: packageJson.name, + source: 'npm', + }, + version: packageJson.version, + }, + ], + }) +}) + +test('start script prefers source files for local development', () => { + const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'curlmd-claude-start-')) + + try { + const pluginDir = path.join(fixtureDir, 'plugin') + fs.mkdirSync(path.join(pluginDir, 'scripts'), { recursive: true }) + fs.mkdirSync(path.join(pluginDir, 'src'), { recursive: true }) + fs.copyFileSync(startScriptPath, path.join(pluginDir, 'scripts', 'start.sh')) + fs.writeFileSync(path.join(pluginDir, 'src', 'server.ts'), 'console.log("src")\n') + + expect(runStartScript(pluginDir)).toEqual([ + '--experimental-strip-types', + '--no-warnings', + path.join(pluginDir, 'src', 'server.ts'), + ]) + } finally { + fs.rmSync(fixtureDir, { force: true, recursive: true }) + } +}) + +test('start script links persisted node_modules when available', () => { + const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'curlmd-claude-start-')) + + try { + const pluginDir = path.join(fixtureDir, 'plugin') + const pluginDataDir = path.join(fixtureDir, 'data') + fs.mkdirSync(path.join(pluginDir, 'scripts'), { recursive: true }) + fs.mkdirSync(path.join(pluginDir, 'src'), { recursive: true }) + fs.mkdirSync(path.join(pluginDataDir, 'node_modules'), { recursive: true }) + fs.copyFileSync(startScriptPath, path.join(pluginDir, 'scripts', 'start.sh')) + fs.writeFileSync(path.join(pluginDir, 'src', 'server.ts'), 'console.log("src")\n') + + expect(runStartScript(pluginDir, { CLAUDE_PLUGIN_DATA: pluginDataDir })).toEqual([ + '--experimental-strip-types', + '--no-warnings', + path.join(pluginDir, 'src', 'server.ts'), + ]) + expect(fs.lstatSync(path.join(pluginDir, 'node_modules')).isSymbolicLink()).toBe(true) + expect(fs.realpathSync(path.join(pluginDir, 'node_modules'))).toBe( + fs.realpathSync(path.join(pluginDataDir, 'node_modules')), + ) + } finally { + fs.rmSync(fixtureDir, { force: true, recursive: true }) + } +}) + +test('start script errors when the source entrypoint is missing', () => { + const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'curlmd-claude-start-')) + + try { + const pluginDir = path.join(fixtureDir, 'plugin') + fs.mkdirSync(path.join(pluginDir, 'scripts'), { recursive: true }) + fs.copyFileSync(startScriptPath, path.join(pluginDir, 'scripts', 'start.sh')) + + expect(() => runStartScript(pluginDir)).toThrowError( + 'curl.md Claude plugin entrypoint not found. Expected src/server.ts.', + ) + } finally { + fs.rmSync(fixtureDir, { force: true, recursive: true }) + } +}) + +function runStartScript(pluginDir: string, env: Record = {}) { + const binDir = path.join(path.dirname(pluginDir), 'bin') + const nodePath = path.join(binDir, 'node') + + fs.mkdirSync(binDir, { recursive: true }) + fs.writeFileSync(nodePath, '#!/bin/sh\nprintf "%s\\n" "$@"\n') + fs.chmodSync(nodePath, 0o755) + + return execFileSync('sh', [path.join(pluginDir, 'scripts', 'start.sh')], { + encoding: 'utf8', + env: { + ...process.env, + ...env, + CLAUDE_PLUGIN_ROOT: pluginDir, + PATH: `${binDir}:${process.env.PATH || ''}`, + }, + }) + .trim() + .split('\n') +} diff --git a/plugins/claude/src/server.test.ts b/plugins/claude/src/server.test.ts new file mode 100644 index 00000000..68b44ec8 --- /dev/null +++ b/plugins/claude/src/server.test.ts @@ -0,0 +1,347 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { defaultBaseUrl } from 'curl.md' +import { Session } from 'curl.md/internal' +import { HttpResponse, http, passthrough } from 'msw' +import { setupServer } from 'msw/node' +import { afterEach, beforeAll, beforeEach, expect, test, vi } from 'vitest' + +const mcpState = vi.hoisted(() => ({ + connect: vi.fn(async () => undefined), + infos: [] as Array<{ name: string; version: string }>, + tools: [] as Array<{ + config: { inputSchema: { safeParse: (input: unknown) => { success: boolean } }; title: string } + handler: (input: Record) => Promise + name: string + }>, + transports: [] as unknown[], +})) + +vi.mock('@modelcontextprotocol/server', () => ({ + McpServer: class { + connect = mcpState.connect + + constructor(info: { name: string; version: string }) { + mcpState.infos.push(info) + } + + registerTool( + name: string, + config: { + inputSchema: { safeParse: (input: unknown) => { success: boolean } } + title: string + }, + handler: (input: Record) => Promise, + ) { + mcpState.tools.push({ config, handler, name }) + } + }, + StdioServerTransport: class { + constructor() { + mcpState.transports.push(this) + } + }, +})) + +const server = setupServer() + +let xdgDataHome = '' + +beforeAll(() => { + server.listen({ onUnhandledRequest: 'error' }) + return () => server.close() +}) + +beforeEach(() => { + xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'curlmd-claude-test-')) + vi.stubEnv('XDG_DATA_HOME', xdgDataHome) +}) + +afterEach(() => { + Session.delete(defaultBaseUrl) + fs.rmSync(xdgDataHome, { force: true, recursive: true }) + mcpState.connect.mockClear() + mcpState.infos.length = 0 + mcpState.tools.length = 0 + mcpState.transports.length = 0 + server.resetHandlers() + vi.restoreAllMocks() + vi.resetModules() + vi.unstubAllEnvs() +}) + +test('registers the Claude MCP tool and connects stdio transport', async () => { + const tool = await loadTool() + + expect(mcpState.infos).toEqual([{ name: 'curl_md', version: '0.0.1' }]) + expect(tool.name).toBe('curl_md') + expect( + tool.config.inputSchema.safeParse({ + fresh: true, + keywords: ['plugin'], + mode: 'smart', + objective: 'Summarize the docs', + url: 'example.com', + }).success, + ).toBe(true) + expect(mcpState.transports).toHaveLength(1) + expect(mcpState.connect).toHaveBeenCalledWith(mcpState.transports[0]) +}) + +test('returns stripped markdown and forwards fetch options', async () => { + const requests: CapturedRequest[] = [] + + server.use( + http.get('*', async ({ request }) => { + const url = new URL(request.url) + if (url.origin !== new URL(defaultBaseUrl).origin) return passthrough() + requests.push(captureRequest(request)) + return HttpResponse.json({ + content: '# Example\n\n---\n\nPowered by [curl.md](https://curl.md)', + }) + }), + ) + + const tool = await loadTool() + const result = await tool.handler({ + fresh: true, + keywords: ['plugin'], + mode: 'smart', + objective: 'Summarize the docs', + url: 'example.com', + }) + + expect(requests[0]?.url).toContain(`${defaultBaseUrl}/api/https://example.com/`) + expect(requests[0]?.url).toContain('fresh=') + expect(requests[0]?.url).toContain('keywords=plugin') + expect(requests[0]?.url).toContain('mode=smart') + expect(requests[0]?.url).toContain('objective=Summarize+the+docs') + expect(requests[0]?.headers).toEqual({ accept: 'application/json' }) + expect(result).toEqual({ + content: [{ text: '# Example\n\n---\n\nPowered by [curl.md](https://curl.md)', type: 'text' }], + }) +}) + +test('returns MCP error content for invalid URLs', async () => { + const tool = await loadTool() + const result = await tool.handler({ url: 'ftp://example.com' }) + + expect(result).toEqual({ + content: [{ text: 'URL must use http or https', type: 'text' }], + isError: true, + }) +}) + +test('retries once on session 401 with forced auth refresh', async () => { + const requests: CapturedRequest[] = [] + let fetchCount = 0 + let headersCount = 0 + + server.use( + http.post('*', async ({ request }) => { + const url = new URL(request.url) + if (url.origin !== new URL(defaultBaseUrl).origin || url.pathname !== '/api/auth/headers') + return passthrough() + headersCount++ + return HttpResponse.json({ + authorization: `Bearer access-token-${headersCount}`, + expires_at: '2099-01-01T00:00:00.000Z', + }) + }), + http.get('*', async ({ request }) => { + const url = new URL(request.url) + if (url.origin !== new URL(defaultBaseUrl).origin) return passthrough() + fetchCount++ + requests.push(captureRequest(request)) + if (fetchCount === 1) return HttpResponse.json({ message: 'Unauthorized' }, { status: 401 }) + return HttpResponse.json({ content: '# Retried' }) + }), + ) + + Session.write( + { + refresh_token: 'rt_test', + refresh_token_expires_at: '2099-01-01T00:00:00.000Z', + }, + defaultBaseUrl, + ) + + const tool = await loadTool() + const result = await tool.handler({ url: 'https://example.com' }) + + expect(headersCount).toBe(2) + expect(fetchCount).toBe(2) + expect(requests[0]?.headers.authorization).toBe('Bearer access-token-1') + expect(requests[1]?.headers.authorization).toBe('Bearer access-token-2') + expect(result).toEqual({ content: [{ text: '# Retried', type: 'text' }] }) +}) + +test('clears session organization and returns guidance on 403', async () => { + server.use( + http.post('*', async ({ request }) => { + const url = new URL(request.url) + if (url.origin !== new URL(defaultBaseUrl).origin || url.pathname !== '/api/auth/headers') + return passthrough() + return HttpResponse.json({ + authorization: 'Bearer access-token-1', + expires_at: '2099-01-01T00:00:00.000Z', + }) + }), + http.get('*', async ({ request }) => { + const url = new URL(request.url) + if (url.origin !== new URL(defaultBaseUrl).origin) return passthrough() + return HttpResponse.json({ message: 'Organization access denied' }, { status: 403 }) + }), + ) + + Session.write( + { + organization_id: 'org_bad', + refresh_token: 'rt_test', + refresh_token_expires_at: '2099-01-01T00:00:00.000Z', + }, + defaultBaseUrl, + ) + + const tool = await loadTool() + const result = await tool.handler({ url: 'https://example.com' }) + + expect(Session.read(defaultBaseUrl)?.organization_id).toBeUndefined() + expectErrorText(result).toBe( + 'Organization access denied. Set CURLMD_API_KEY or run `curl.md auth login`.', + ) +}) + +test('surfaces anonymous authentication guidance on 401', async () => { + server.use( + http.get('*', async ({ request }) => { + const url = new URL(request.url) + if (url.origin !== new URL(defaultBaseUrl).origin) return passthrough() + return HttpResponse.json({ message: 'Unauthorized' }, { status: 401 }) + }), + ) + + const tool = await loadTool() + const result = await tool.handler({ url: 'https://example.com' }) + + expectErrorText(result).toBe( + 'curl.md authentication required. Set CURLMD_API_KEY or run `curl.md auth login`.', + ) +}) + +test('uses CURLMD_API_KEY and surfaces invalid API key guidance on 401', async () => { + const requests: CapturedRequest[] = [] + + server.use( + http.get('*', async ({ request }) => { + const url = new URL(request.url) + if (url.origin !== new URL(defaultBaseUrl).origin) return passthrough() + requests.push(captureRequest(request)) + return HttpResponse.json( + { code: 'invalid_api_key', message: 'Invalid API key' }, + { status: 401 }, + ) + }), + ) + vi.stubEnv('CURLMD_API_KEY', 'curlmd_test_token') + + const tool = await loadTool() + const result = await tool.handler({ url: 'https://example.com' }) + + expect(requests[0]?.headers).toEqual({ + accept: 'application/json', + authorization: 'Bearer curlmd_test_token', + }) + expectErrorText(result).toBe('curl.md authentication failed. Fix CURLMD_API_KEY.') +}) + +test('formats validation issues for 400 responses', async () => { + server.use( + http.get('*', async ({ request }) => { + const url = new URL(request.url) + if (url.origin !== new URL(defaultBaseUrl).origin) return passthrough() + return HttpResponse.json( + { + issues: [ + { message: 'Invalid URL', path: 'url' }, + { message: 'Required', path: 'objective' }, + ], + }, + { status: 400 }, + ) + }), + ) + + const tool = await loadTool() + const result = await tool.handler({ url: 'https://example.com' }) + + expectErrorText(result).toBe('url: Invalid URL\nobjective: Required') +}) + +test('surfaces anonymous rate limit guidance on 429', async () => { + server.use( + http.get('*', async ({ request }) => { + const url = new URL(request.url) + if (url.origin !== new URL(defaultBaseUrl).origin) return passthrough() + return HttpResponse.json( + { code: 'rate_limit_exceeded', message: 'Rate limit exceeded' }, + { headers: { 'retry-after': '12' }, status: 429 }, + ) + }), + ) + + const tool = await loadTool() + const result = await tool.handler({ url: 'https://example.com' }) + + expectErrorText(result).toBe( + 'Rate limit exceeded. Try again in 12s. Set CURLMD_API_KEY or run `curl.md auth login` for higher limits.', + ) +}) + +test('surfaces API error codes for non-ok responses', async () => { + server.use( + http.get('*', async ({ request }) => { + const url = new URL(request.url) + if (url.origin !== new URL(defaultBaseUrl).origin) return passthrough() + return HttpResponse.json({ code: 'ai_failed', message: 'error code: 1031' }, { status: 500 }) + }), + ) + + const tool = await loadTool() + const result = await tool.handler({ url: 'https://example.com' }) + + expectErrorText(result).toBe('(AI_FAILED) error code: 1031') +}) + +async function loadTool() { + await import('./server.ts') + const tool = mcpState.tools.find((tool) => tool.name === 'curl_md') + if (!tool) throw new Error('Expected curl_md tool to be registered') + return tool +} + +function captureRequest(request: Request): CapturedRequest { + return { + headers: Object.fromEntries(request.headers.entries()), + method: request.method, + url: request.url, + } +} + +function expectErrorText(result: unknown) { + expect(result).toEqual( + expect.objectContaining({ + content: [expect.objectContaining({ type: 'text' })], + isError: true, + }), + ) + + return expect((result as { content: Array<{ text: string }> }).content[0]?.text) +} + +type CapturedRequest = { + headers: Record + method: string + url: string +} diff --git a/plugins/claude/src/server.ts b/plugins/claude/src/server.ts new file mode 100644 index 00000000..8b811a8a --- /dev/null +++ b/plugins/claude/src/server.ts @@ -0,0 +1,189 @@ +import process from 'node:process' +import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server' +import { createClient, defaultBaseUrl } from 'curl.md' +import { Auth, Session } from 'curl.md/internal' +import { z } from 'zod' + +const baseUrl = process.env.CURLMD_BASE_URL || defaultBaseUrl +const apiKey = process.env.CURLMD_API_KEY +const resolver = Auth.createResolver(baseUrl, apiKey) + +const server = new McpServer({ + name: 'curl_md', + version: '0.0.1', +}) + +server.registerTool( + 'curl_md', + { + title: 'curl.md', + description: 'Fetch a URL as markdown.', + inputSchema: z.object({ + url: z + .string() + .describe('HTTP(S) URL or bare domain to fetch. Prefer the canonical docs or article URL.'), + objective: z + .string() + .optional() + .describe('Specific question to answer from the page. Use when only part matters.'), + keywords: z + .array(z.string()) + .optional() + .describe('Keywords to focus extraction on relevant sections.'), + mode: z + .enum(['rush', 'smart']) + .optional() + .describe('rush: faster. smart: better section selection on long or noisy pages.'), + fresh: z.boolean().optional().describe('Bypass cache when freshness matters.'), + }), + }, + async (input) => { + try { + const result = await fetchPage(input) + return { + content: [{ type: 'text' as const, text: result.markdown }], + } + } catch (error) { + return { + content: [ + { type: 'text' as const, text: error instanceof Error ? error.message : String(error) }, + ], + isError: true, + } + } + }, +) + +await server.connect(new StdioServerTransport()) + +async function fetchPage(input: { + fresh?: boolean + keywords?: string[] + mode?: 'rush' | 'smart' + objective?: string + url: string +}) { + const url = normalizeURL(input.url) + + let authHeaders = await resolver() + let authType: 'anon' | 'api_key' | 'session' = (() => { + if (apiKey) return 'api_key' + if (authHeaders) return 'session' + return 'anon' + })() + + const client = createClient(baseUrl, { + headers: apiKey ? createHeaders(null) : createHeaders(authHeaders), + }) + let res = await client.fetch(url, { + fresh: input.fresh, + keywords: input.keywords, + mode: input.mode, + objective: input.objective, + token: apiKey, + }) + + if (res.status === 401 && authType === 'session') { + authHeaders = await resolver({ forceRefresh: true }) + if (!authHeaders) authType = 'anon' + const retryClient = createClient(baseUrl, { + headers: apiKey ? createHeaders(null) : createHeaders(authHeaders), + }) + res = await retryClient.fetch(url, { + fresh: input.fresh, + keywords: input.keywords, + mode: input.mode, + objective: input.objective, + token: apiKey, + }) + } + + if (res.status === 400) { + const json = await res.json() + throw new Error(formatBadRequest(json)) + } + + if (res.status === 401) { + if (authType === 'api_key') + throw new Error('curl.md authentication failed. Fix CURLMD_API_KEY.') + if (authType === 'session') + throw new Error('curl.md authentication failed. Run `curl.md auth login` again.') + throw new Error( + 'curl.md authentication required. Set CURLMD_API_KEY or run `curl.md auth login`.', + ) + } + + if (res.status === 403) { + const json = await res.json() + Session.write({ organization_id: undefined }, baseUrl) + if (authType === 'api_key') throw new Error(`${json.message}. Check CURLMD_API_KEY access.`) + throw new Error(`${json.message}. Set CURLMD_API_KEY or run \`curl.md auth login\`.`) + } + + if (res.status === 429) { + const json = await res.json() + const retryAfter = res.headers.get('retry-after') + const message = retryAfter ? `${json.message}. Try again in ${retryAfter}s` : json.message + + if (authType === 'anon') + throw new Error( + `${message}. Set CURLMD_API_KEY or run \`curl.md auth login\` for higher limits.`, + ) + + throw new Error(`${message}. Add credits with \`curl.md credits add\` if needed.`) + } + + if (!res.ok) { + const json = await res + .clone() + .json() + .catch(() => undefined) + const error = parseApiError(json) + if (error) throw new Error(formatApiError(error)) + + const text = await res.text() + throw new Error(text || `curl.md request failed with status ${res.status}`) + } + + const json = await res.json() + + return { markdown: json.content, url } +} + +function createHeaders(auth: Auth.Headers | null) { + const headers: Record = { accept: 'application/json' } + if (auth?.authorization) headers.authorization = auth.authorization + if (auth?.organization_id) headers['x-organization-id'] = auth.organization_id + return headers +} + +function normalizeURL(value: string) { + const url = new URL(value.includes('://') ? value : `https://${value}`) + if (!/^https?:$/.test(url.protocol)) throw new Error('URL must use http or https') + return url.toString() +} + +function formatBadRequest(json: unknown) { + if (typeof json !== 'object' || json === null) return 'Bad request' + if (!('issues' in json) || !Array.isArray(json.issues)) + return 'message' in json && typeof json.message === 'string' ? json.message : 'Bad request' + + return json.issues + .map((issue: { path: string; message: string }) => `${issue.path}: ${issue.message}`) + .join('\n') +} + +function parseApiError(json: unknown) { + if (typeof json !== 'object' || json === null) return undefined + if (!('message' in json) || typeof json.message !== 'string') return undefined + + return { + code: + 'code' in json && typeof json.code === 'string' ? json.code.toUpperCase() : 'REQUEST_FAILED', + message: json.message, + } +} + +function formatApiError(error: { code: string; message: string }) { + return `(${error.code}) ${error.message}` +} diff --git a/plugins/claude/tsconfig.json b/plugins/claude/tsconfig.json new file mode 100644 index 00000000..8ca738d9 --- /dev/null +++ b/plugins/claude/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "jsx": "react-jsx", + "module": "esnext", + "moduleResolution": "bundler", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "target": "es2022", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/plugins/opencode/README.md b/plugins/opencode/README.md index 6e413f84..6999d279 100644 --- a/plugins/opencode/README.md +++ b/plugins/opencode/README.md @@ -9,9 +9,9 @@

-# @curl.md/opencode +# @curl.md/amp - URL to markdown for OpenCode -Turn websites into **optimized, low token output** inside **OpenCode**. +Turn websites into **optimized, low token output** to **supercharge your context**. ## Install diff --git a/plugins/opencode/src/server.test.ts b/plugins/opencode/src/server.test.ts index 806cdab4..99326d13 100644 --- a/plugins/opencode/src/server.test.ts +++ b/plugins/opencode/src/server.test.ts @@ -75,17 +75,18 @@ test('returns markdown and tool metadata for curl_md by default', async () => { objective: 'Summarize the docs', timeout: 30, }, - url: 'https://example.com', + url: 'https://example.com/docs#intro', }, createToolContext(metadata), ) - expect(requests[0]?.url).toContain(`${defaultBaseUrl}/api/https://example.com/`) + expect(requests[0]?.url).toContain(`${defaultBaseUrl}/api/https://example.com/docs`) + expect(requests[0]?.url).toContain('anchor=intro') expect(requests[0]?.url).toContain('fresh=') expect(requests[0]?.url).toContain('keywords=plugin') expect(requests[0]?.url).toContain('mode=smart') expect(requests[0]?.url).toContain('objective=Summarize+the+docs') - expect(result).toBe('# Example') + expect(result).toBe('# Example\n\n---\n\nPowered by [curl.md](https://curl.md)') expect(metadata).toHaveBeenCalledWith({ metadata: { auth: 'anon', @@ -93,9 +94,9 @@ test('returns markdown and tool metadata for curl_md by default', async () => { fresh: true, request_id: 'req_123', tokens_saved: 128, - url: 'https://example.com/', + url: 'https://example.com/docs#intro', }, - title: 'https://example.com/', + title: 'https://example.com/docs#intro', }) }) @@ -134,7 +135,7 @@ test('returns markdown and tool metadata for webfetch when enabled', async () => createToolContext(metadata), ) - expect(result).toBe('# Example') + expect(result).toBe('# Example\n\n---\n\nPowered by [curl.md](https://curl.md)') expect(metadata).toHaveBeenCalledWith({ metadata: { auth: 'anon', diff --git a/plugins/opencode/src/server.ts b/plugins/opencode/src/server.ts index a776ff12..e69c35ba 100644 --- a/plugins/opencode/src/server.ts +++ b/plugins/opencode/src/server.ts @@ -30,30 +30,25 @@ function createFetchTool(input: { format: opencodePlugin.tool.schema .enum(['html', 'markdown', 'text']) .optional() - .describe( - 'Compatibility option for OpenCode built-in webfetch calls. curl.md always returns markdown.', - ), - fresh: opencodePlugin.tool.schema - .boolean() - .optional() - .describe('Bypass the curl.md cache and fetch the page live.'), + .describe('Compatibility option for built-in webfetch calls. Output is always markdown.'), + fresh: opencodePlugin.tool.schema.boolean().optional().describe('Bypass cache and fetch live.'), keywords: opencodePlugin.tool.schema .array(opencodePlugin.tool.schema.string()) .optional() - .describe('Optional keywords to focus extraction on specific sections of the page.'), + .describe('Keywords to focus extraction on relevant sections.'), mode: opencodePlugin.tool.schema .enum(['rush', 'smart']) .optional() - .describe('Extraction mode. Use smart for better section selection on long pages.'), + .describe('rush: faster. smart: better section selection on long or noisy pages.'), objective: opencodePlugin.tool.schema .string() .optional() - .describe('Optional objective describing what to extract from the page.'), + .describe('Specific question to answer from the page. Use when only part matters.'), timeout: opencodePlugin.tool.schema .number() .optional() .describe( - 'Compatibility option for OpenCode built-in webfetch calls. curl.md manages fetch timing internally.', + 'Compatibility option for built-in webfetch calls. Fetch timing is managed internally.', ), } type FetchToolArgs = FetchOptionArgs & { @@ -68,8 +63,8 @@ function createFetchTool(input: { return opencodePlugin.tool({ description: input.toolName === 'webfetch' - ? 'Override OpenCode built-in webfetch with curl.md markdown output.' - : 'Fetch a web page through curl.md and return markdown optimized for coding agents.', + ? 'Overrides built-in webfetch with markdown output.' + : 'Fetch a URL as markdown.', args: { ...(input.toolName === 'webfetch' ? optionArgs : {}), options: opencodePlugin.tool.schema @@ -78,9 +73,7 @@ function createFetchTool(input: { .describe('Optional fetch settings.'), url: opencodePlugin.tool.schema .string() - .describe( - 'HTTP(S) URL or bare domain to fetch via curl.md. Prefer the canonical docs or article URL you want summarized.', - ), + .describe('HTTP(S) URL or bare domain to fetch. Prefer the canonical docs or article URL.'), }, async execute(args, ctx) { const toolArgs = args as FetchToolArgs @@ -143,7 +136,7 @@ async function fetchPage(input: { signal?: AbortSignal url: string }) { - const url = normalizeUrl(input.url) + const url = normalizeURL(input.url) let authHeaders = await input.resolver() let authType: 'anon' | 'api_key' | 'session' = (() => { @@ -247,14 +240,14 @@ async function fetchPage(input: { auth: authType, cache: res.headers.get('x-cache') || undefined, fresh: input.fresh || undefined, - markdown: json.content.replace(/\n\n---\n\nPowered by \[curl\.md\]\(https:\/\/curl\.md\)$/, ''), + markdown: json.content, request_id: res.headers.get('x-request-id') || undefined, tokens_saved: parseNumberHeader(res.headers.get('x-tokens-saved')), url, } } -function normalizeUrl(value: string) { +function normalizeURL(value: string) { const url = new URL(value.includes('://') ? value : `https://${value}`) if (!/^https?:$/.test(url.protocol)) throw new Error('URL must use http or https') return url.toString() diff --git a/plugins/opencode/src/tui.test.ts b/plugins/opencode/src/tui.test.ts index 95c71c93..b5faccea 100644 --- a/plugins/opencode/src/tui.test.ts +++ b/plugins/opencode/src/tui.test.ts @@ -73,27 +73,27 @@ test('registers a login command and completes device flow', async () => { expect(commands).toEqual( expect.arrayContaining([ expect.objectContaining({ - description: 'Log in to curl.md with browser.', + description: 'Log in', slash: { name: 'curl_md_login' }, - title: 'Login to curl.md', + title: 'Log in', value: 'curlmd.login', }), expect.objectContaining({ - description: 'Log out of curl.md.', + description: 'Log out', slash: { name: 'curl_md_logout' }, - title: 'Logout of curl.md', + title: 'Log out', value: 'curlmd.logout', }), expect.objectContaining({ - description: 'Switch active curl.md organization.', + description: 'Switch organization', slash: { name: 'curl_md_org' }, - title: 'Switch curl.md organization', + title: 'Switch organization', value: 'curlmd.org', }), expect.objectContaining({ - description: 'Show curl.md status.', + description: 'Show status', slash: { name: 'curl_md_status' }, - title: 'Show curl.md status', + title: 'Show status', value: 'curlmd.status', }), ]), @@ -120,7 +120,7 @@ test('registers a login command and completes device flow', async () => { expect(api.clear).toHaveBeenCalledTimes(1) expect(api.toast).toHaveBeenLastCalledWith({ duration: 6_000, - message: 'Logged in as tmm to curl.md.', + message: 'Logged in as tmm.', title: 'curl.md', variant: 'success', }) @@ -143,7 +143,7 @@ test('keeps already-authenticated toast visible longer', async () => { expect(api.clear).toHaveBeenCalledTimes(1) expect(api.toast).toHaveBeenCalledWith({ duration: 6_000, - message: 'Already logged in as tmm to curl.md.', + message: 'Already logged in as tmm.', title: 'curl.md', variant: 'info', }) @@ -165,7 +165,7 @@ test('logs out of curl.md', async () => { expect(Auth.logout).toHaveBeenCalledWith(defaultBaseUrl) expect(api.toast).toHaveBeenCalledWith({ duration: 6_000, - message: 'Logged out of tmm from curl.md.', + message: 'Logged out of tmm.', title: 'curl.md', variant: 'info', }) @@ -184,7 +184,7 @@ test('shows already logged out when no session exists', async () => { expect(logoutSpy).not.toHaveBeenCalled() expect(api.toast).toHaveBeenCalledWith({ duration: 6_000, - message: 'Already logged out of curl.md.', + message: 'Already logged out.', title: 'curl.md', variant: 'info', }) @@ -227,7 +227,7 @@ test('switches curl.md organization', async () => { await findCommand(api.commands(), 'curlmd.org').onSelect?.() expect(api.dialogSelect).toHaveBeenCalledWith( - expect.objectContaining({ title: 'Switch curl.md organization' }), + expect.objectContaining({ title: 'Switch organization' }), ) const selectProps = api.lastDialogSelectProps() @@ -237,7 +237,7 @@ test('switches curl.md organization', async () => { expect(api.clear).toHaveBeenCalledTimes(1) expect(api.toast).toHaveBeenCalledWith({ duration: 6_000, - message: 'Switched curl.md organization to acme.', + message: 'Switched organization to acme.', title: 'curl.md', variant: 'info', }) diff --git a/plugins/opencode/src/tui.ts b/plugins/opencode/src/tui.ts index 45cea5ac..0747d45e 100644 --- a/plugins/opencode/src/tui.ts +++ b/plugins/opencode/src/tui.ts @@ -22,7 +22,7 @@ export const tuiPlugin: opencodePluginTui.TuiPluginModule = { api.command.register(() => [ { category: 'curl.md', - description: 'Log in to curl.md with browser.', + description: 'Log in', onSelect: async () => { if (loginAbortController) { api.ui.toast({ @@ -103,12 +103,12 @@ export const tuiPlugin: opencodePluginTui.TuiPluginModule = { }, slash: { name: 'curl_md_login' }, suggested: true, - title: 'Login to curl.md', + title: 'Log in', value: 'curlmd.login', }, { category: 'curl.md', - description: 'Log out of curl.md.', + description: 'Log out', onSelect: async () => { api.ui.dialog.clear() @@ -140,19 +140,19 @@ export const tuiPlugin: opencodePluginTui.TuiPluginModule = { }) }, slash: { name: 'curl_md_logout' }, - title: 'Logout of curl.md', + title: 'Log out', value: 'curlmd.logout', }, { category: 'curl.md', - description: 'Switch active curl.md organization.', + description: 'Switch organization', onSelect: async () => { const authHeaders = await resolver() if (!authHeaders) { api.ui.dialog.clear() api.ui.toast({ duration: authStatusToastDurationMs, - message: 'Not authenticated with curl.md. Run /curl_md_login first.', + message: 'Not authenticated. Run /curl_md_login first.', title: 'curl.md', variant: 'error', }) @@ -165,10 +165,10 @@ export const tuiPlugin: opencodePluginTui.TuiPluginModule = { client.api.auth.me.$get(), ]) - if (!orgsRes.ok || !meRes.ok) { + if (orgsRes.status !== 200 || meRes.status !== 200) { api.ui.dialog.clear() api.ui.toast({ - message: 'Failed to fetch curl.md organizations.', + message: 'Failed to fetch organizations.', title: 'curl.md', variant: 'error', }) @@ -181,7 +181,7 @@ export const tuiPlugin: opencodePluginTui.TuiPluginModule = { api.ui.dialog.clear() api.ui.toast({ duration: authStatusToastDurationMs, - message: 'Not authenticated with curl.md. Run /curl_md_login first.', + message: 'Not authenticated. Run /curl_md_login first.', title: 'curl.md', variant: 'error', }) @@ -228,17 +228,17 @@ export const tuiPlugin: opencodePluginTui.TuiPluginModule = { }, options, placeholder: 'Choose account or organization', - title: 'Switch curl.md organization', + title: 'Switch organization', }), ) }, slash: { name: 'curl_md_org' }, - title: 'Switch curl.md organization', + title: 'Switch organization', value: 'curlmd.org', }, { category: 'curl.md', - description: 'Show curl.md status.', + description: 'Show status', onSelect: async () => { api.ui.dialog.clear() @@ -289,7 +289,7 @@ export const tuiPlugin: opencodePluginTui.TuiPluginModule = { }) }, slash: { name: 'curl_md_status' }, - title: 'Show curl.md status', + title: 'Show status', value: 'curlmd.status', }, ]) @@ -305,17 +305,17 @@ type OrgChoice = { } function buildAlreadyAuthenticatedMessage(login: string | null) { - if (!login) return withApiKeyNote('Already logged in to curl.md.') - return withApiKeyNote(`Already logged in as ${login} to curl.md.`) + if (!login) return withApiKeyNote('Already logged in.') + return withApiKeyNote(`Already logged in as ${login}.`) } function buildAlreadyLoggedOutMessage() { - return withApiKeyNote('Already logged out of curl.md.') + return withApiKeyNote('Already logged out.') } function buildOrgSwitchMessage(kind: 'account' | 'organization', label: string) { - if (kind === 'account') return withApiKeyNote(`Switched curl.md account to ${label}.`) - return withApiKeyNote(`Switched curl.md organization to ${label}.`) + if (kind === 'account') return withApiKeyNote(`Switched account to ${label}.`) + return withApiKeyNote(`Switched organization to ${label}.`) } function buildLoginPrompt(url: string, userCode: string) { @@ -330,13 +330,13 @@ function buildLoginPrompt(url: string, userCode: string) { } function buildLoginSuccessMessage(login: string | null) { - if (!login) return withApiKeyNote('Logged in to curl.md.') - return withApiKeyNote(`Logged in as ${login} to curl.md.`) + if (!login) return withApiKeyNote('Logged in.') + return withApiKeyNote(`Logged in as ${login}.`) } function buildLogoutSuccessMessage(login: string | null) { - if (!login) return withApiKeyNote('Logged out of curl.md.') - return withApiKeyNote(`Logged out of ${login} from curl.md.`) + if (!login) return withApiKeyNote('Logged out.') + return withApiKeyNote(`Logged out of ${login}.`) } function findCliPath() { @@ -389,7 +389,7 @@ async function readStatus(baseUrl: string, authorization: string, organizationId }), }) const res = await client.api.auth.me.$get() - if (!res.ok) { + if (res.status !== 200) { const json = await res.json().catch(() => undefined) const error = parseApiError(json) return { diff --git a/plugins/pi/README.md b/plugins/pi/README.md index ef2eab5b..88239a68 100644 --- a/plugins/pi/README.md +++ b/plugins/pi/README.md @@ -9,9 +9,9 @@

-# @curl.md/pi +# @curl.md/pi - URL to markdown for Pi -Turn websites into **optimized, low token output** inside **Pi**. +Turn websites into **optimized, low token output** to **supercharge your context**. ## Install diff --git a/plugins/pi/src/e2e.test.ts b/plugins/pi/src/e2e.test.ts index 307c9ea4..aac34092 100644 --- a/plugins/pi/src/e2e.test.ts +++ b/plugins/pi/src/e2e.test.ts @@ -275,7 +275,7 @@ async function expectStatusCommand(rpc: ReturnType) { expect(getCommandsResponse.success).toBe(true) expect(getCommandsResponse.data.commands).toContainEqual( expect.objectContaining({ - description: 'Show curl.md status', + description: 'Show status', name: 'curl_md_status', source: 'extension', }), diff --git a/plugins/pi/src/extension.test.ts b/plugins/pi/src/extension.test.ts index 5b914646..a159e482 100644 --- a/plugins/pi/src/extension.test.ts +++ b/plugins/pi/src/extension.test.ts @@ -628,7 +628,7 @@ test('fetches markdown from curl.md anonymously', async () => { const url = new URL(request.url) if ( url.origin !== new URL(defaultBaseUrl).origin || - url.pathname !== '/api/https://example.com/docs' + url.pathname !== '/api/https://example.com/docs%3Fq%3D1' ) return passthrough() requests.push(captureRequest(request)) @@ -653,12 +653,13 @@ test('fetches markdown from curl.md anonymously', async () => { keywords: ['pricing', 'billing'], mode: 'rush', objective: 'compare plans', - url: 'example.com/docs?q=1', + url: 'example.com/docs?q=1#plans', }) const result = await tools[0]!.execute('call_1', params) expect(requests).toHaveLength(1) - expect(requests[0]?.url).toContain(`${defaultBaseUrl}/api/https://example.com/docs?q=1`) + expect(requests[0]?.url).toContain(`${defaultBaseUrl}/api/https://example.com/docs%3Fq%3D1`) + expect(requests[0]?.url).toContain('anchor=plans') expect(requests[0]?.url).toContain('fresh=') expect(requests[0]?.url).toContain('keywords=pricing%2Cbilling') expect(requests[0]?.url).toContain('mode=rush') @@ -680,7 +681,7 @@ test('fetches markdown from curl.md anonymously', async () => { request_id: 'req_123', tokens_count: 42, tokens_saved: 128, - url: 'https://example.com/docs?q=1', + url: 'https://example.com/docs?q=1#plans', }, }) diff --git a/plugins/pi/src/index.ts b/plugins/pi/src/index.ts index 1ee0a5a6..fe27d674 100644 --- a/plugins/pi/src/index.ts +++ b/plugins/pi/src/index.ts @@ -20,7 +20,7 @@ export default function (pi: ExtensionAPI) { const resolver = Auth.createResolver(baseUrl, apiKey) pi.registerCommand('curl_md_login', { - description: 'Log in to curl.md', + description: 'Log in', async handler(_args, ctx) { const start = await Auth.startLogin(baseUrl) if (!start.ok) { @@ -96,7 +96,7 @@ export default function (pi: ExtensionAPI) { }) pi.registerCommand('curl_md_logout', { - description: 'Log out of curl.md', + description: 'Log out', async handler(_args, ctx) { if (!Session.read(baseUrl)) { ctx.ui.notify('Already logged out of curl.md', 'info') @@ -115,7 +115,7 @@ export default function (pi: ExtensionAPI) { }) pi.registerCommand('curl_md_org', { - description: 'Switch active curl.md organization', + description: 'Switch organization', async handler(args, ctx) { const authHeaders = await resolver() if (!authHeaders) { @@ -128,7 +128,7 @@ export default function (pi: ExtensionAPI) { client.api.orgs.$get(), client.api.auth.me.$get(), ]) - if (!orgsRes.ok || !meRes.ok) { + if (orgsRes.status !== 200 || meRes.status !== 200) { ctx.ui.notify('Failed to fetch curl.md organizations.', 'error') return } @@ -158,10 +158,10 @@ export default function (pi: ExtensionAPI) { } const choices = [ - ...orgsJson.organizations.map((o) => ({ - id: o.id, + ...orgsJson.organizations.map((organization) => ({ + id: organization.id, kind: 'organization' as const, - label: o.login, + label: organization.login, })), { id: undefined, @@ -187,6 +187,7 @@ export default function (pi: ExtensionAPI) { emptyText: ' No matching organizations', footerText: '(escape/ctrl+c to cancel)', formatItem: (choice, props) => { + if (!choice) return '' const { isSelected, theme } = props const prefix = isSelected ? theme.fg('accent', '→ ') : ' ' const label = isSelected ? theme.fg('accent', choice.label) : choice.label @@ -195,7 +196,7 @@ export default function (pi: ExtensionAPI) { return `${prefix}${label}${badge}${check}` }, placeholder: 'Type to filter. Use arrows to move, enter to select.', - searchText: (choice) => `${choice.label} ${choice.kind}`, + searchText: (choice) => (choice ? `${choice.label} ${choice.kind}` : ''), title: 'Switch curl.md organization', }, done, @@ -223,7 +224,7 @@ export default function (pi: ExtensionAPI) { }) pi.registerCommand('curl_md_status', { - description: 'Show curl.md status', + description: 'Show status', async handler(_args, ctx) { const lines = [`${packageJson.name} v${packageJson.version}`] const cliPath = (() => { @@ -261,8 +262,8 @@ export default function (pi: ExtensionAPI) { }), }) const res = await client.api.auth.me.$get() - if (!res.ok) { - const json = await res.json().catch((_error) => undefined) + if (res.status !== 200) { + const json = await res.json().catch(() => undefined) const error = parseApiError(json) return { message: error ? formatApiError(error) : `status ${res.status}`, @@ -309,45 +310,40 @@ export default function (pi: ExtensionAPI) { }) const readWebPageTool = defineTool({ - description: 'Fetch a URL through curl.md and return markdown optimized for coding agents.', + description: 'Fetch a URL as markdown.', label: 'curl.md Fetch', name: 'read_web_page', parameters: Type.Object({ fresh: Type.Optional( Type.Boolean({ - description: - 'Bypass curl.md cache when freshness matters, such as changelogs, release notes, or recently updated docs.', + description: 'Bypass cache when freshness matters.', }), ), keywords: Type.Optional( Type.Array( Type.String({ - description: - 'Keyword to pre-filter sections by. Prefer 2-5 distinct terms when only part of a long page matters.', + description: 'Keyword to focus extraction on relevant sections.', }), ), ), mode: Type.Optional( Type.Union([ Type.Literal('rush', { - description: - 'Lower-latency mode. Best when you already know the section or answer you want.', + description: 'Faster mode.', }), Type.Literal('smart', { - description: - 'Higher-quality narrowing mode. Best for long or noisy pages where better extraction matters more than speed.', + description: 'Better section selection on long or noisy pages.', }), ]), ), objective: Type.Optional( Type.String({ - description: - 'Specific question or goal to answer from the page. Prefer concrete objectives like "compare pricing tiers" or "find auth header requirements".', + description: 'Specific question to answer from the page. Use when only part matters.', }), ), url: Type.String({ description: - 'HTTP(S) URL or bare domain to fetch via curl.md. Prefer the canonical docs or article URL you want summarized.', + 'HTTP(S) URL or bare domain to fetch. Prefer the canonical docs or article URL.', }), }), prepareArguments(args) { @@ -360,12 +356,12 @@ export default function (pi: ExtensionAPI) { return { ...rawArgs, url: url.toString() } }, promptGuidelines: [ - 'Use read_web_page for documentation pages, changelogs, articles, and other web URLs when you want markdown back from curl.md.', + 'Use read_web_page for docs, changelogs, articles, and other web URLs when you want markdown back.', 'Set objective to the exact question you need answered when only part of the page matters.', 'Add keywords for long pages when you know the relevant terms, and choose rush for speed or smart for higher-quality narrowing.', ], promptSnippet: - 'Fetch a web page via curl.md. Use objective for a concrete question, keywords for long pages, rush for speed, smart for better narrowing.', + 'Fetch a URL as markdown. Use objective for a concrete question, keywords for long pages, rush for speed, smart for better narrowing.', renderCall(args, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text('', 0, 0) let content = `${theme.fg('toolTitle', theme.bold('read_web_page'))} ${theme.fg('accent', args.url)}` @@ -490,7 +486,7 @@ export default function (pi: ExtensionAPI) { const json = await res .clone() .json() - .catch((_error) => undefined) + .catch(() => undefined) const error = parseApiError(json) if (error) throw new Error(formatApiError(error)) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53c84b19..8a1e6ee4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,7 @@ overrides: miniflare>undici: 7.18.2 path-to-regexp@>=8.0.0 <8.4.0: 8.4.0 picomatch@>=4.0.0 <4.0.4: 4.0.4 + postcss@<8.5.10: 8.5.10 protobufjs@<7.5.5: 7.5.5 uuid@<14.0.0: 14.0.0 @@ -305,6 +306,22 @@ importers: specifier: 0.0.0-dev version: 0.0.0-dev + plugins/claude: + dependencies: + '@modelcontextprotocol/server': + specifier: 2.0.0-alpha.2 + version: 2.0.0-alpha.2(@cfworker/json-schema@4.1.1) + curl.md: + specifier: workspace:* + version: link:../../cli + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@anthropic-ai/claude-code': + specifier: ^2.1.118 + version: 2.1.118 + plugins/opencode: dependencies: '@opencode-ai/plugin': @@ -322,10 +339,10 @@ importers: devDependencies: '@mariozechner/pi-ai': specifier: ^0.65.2 - version: 0.65.2(ws@8.20.0)(zod@4.3.6) + version: 0.65.2(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@mariozechner/pi-coding-agent': specifier: ^0.65.2 - version: 0.65.2(ws@8.20.0)(zod@4.3.6) + version: 0.65.2(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@mariozechner/pi-tui': specifier: ^0.65.2 version: 0.65.2 @@ -338,6 +355,55 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@anthropic-ai/claude-code-darwin-arm64@2.1.118': + resolution: {integrity: sha512-Qg1LR1XfRUCUqoGUq5p8BlQ764F+7ReEFW1WATFoNsW6Gv7D39epzFYbHw59acdIICcOzRaUbSGNarssrv/y/Q==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-code-darwin-x64@2.1.118': + resolution: {integrity: sha512-SgAvTCMnuLmD+LBqX8n+dBbSoED49GT5IioRD7z1P+JSBpIqCzE07TiBZ5mEOGssRNpsMw+GIHL6l1702ra5xg==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-code-linux-arm64-musl@2.1.118': + resolution: {integrity: sha512-eoALUO6vVEcwquLS2kiIvF1f8eCeX9gs8ZGkNUkiKp0pasC5l1BTDpxi+ww1Efuh8L6u2iVzhVp0YgYXVf1eVw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-code-linux-arm64@2.1.118': + resolution: {integrity: sha512-XYVJwJulEKmlbaYZnYQiRaoZTdmBL+A94cRJO4J50txbHe9+T7TErgxFCLTd7AVZ8GX+PGulSBnKmiuzJhkcGg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-code-linux-x64-musl@2.1.118': + resolution: {integrity: sha512-dGb2cVsruUY5HJNYdgVxitVN/pWZqLKqLr2bqBswZPlS8ieg1OQDaU5JaKtrMvjZzeyh33Vs4idhegDZJ84fhw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-code-linux-x64@2.1.118': + resolution: {integrity: sha512-t6aNIvNa1T+ZR5IkJARqjTy+U5LH59FuWok4QoXa/RpT4C0njeeE/SdUzvvhwmH3ji/Rh6EX4zgbx/v5yGtG8Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-code-win32-arm64@2.1.118': + resolution: {integrity: sha512-MWX6Fc8QnDaHin1dmQ9SEdOcW1XLTRJ9N+8i4CbB0awzpJ4yPz4MnxsBFWgO2QWV4NgCbMUHLWTFFAocjs3/2w==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-code-win32-x64@2.1.118': + resolution: {integrity: sha512-xRR4ohswGXyE1QW2ZrhLrWjX0x9agMX0fotuQZ11g2pZtrmfjFr73BBhQM4QfGpJQf9qQ/lX5/L6Afmbe0DJPA==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-code@2.1.118': + resolution: {integrity: sha512-wQw3aWRmDc6+OB2JQ/FyIxyQOLbdX7doPEsmIRklB/N6hjFDDqquIG7y78yofHFx1YKbyrkfvmLWecwxNL8fZg==} + engines: {node: '>=18.0.0'} + hasBin: true + '@anthropic-ai/sdk@0.73.0': resolution: {integrity: sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw==} hasBin: true @@ -1458,6 +1524,16 @@ packages: '@mistralai/mistralai@1.14.1': resolution: {integrity: sha512-IiLmmZFCCTReQgPAT33r7KQ1nYo5JPdvGkrkZqA8qQ2qB1GHgs5LoP5K2ICyrjnpw2n8oSxMM/VP+liiKcGNlQ==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@modelcontextprotocol/server@2.0.0-alpha.2': resolution: {integrity: sha512-gmLgdHzlYM8L7Aw/+VE0kxjT25WKamtUSLNhdOgrJq5CrESvqVSoAfWSJJeNPUXNTluQ+dYDGFbKVitdsJtbPA==} engines: {node: '>=20'} @@ -3251,6 +3327,10 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -3448,6 +3528,10 @@ packages: blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -3500,6 +3584,10 @@ packages: resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==} engines: {node: '>=0.10.0'} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + c12@3.3.3: resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==} peerDependencies: @@ -3508,6 +3596,14 @@ packages: magicast: optional: true + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -3642,12 +3738,28 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} cookie-es@2.0.0: resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -3655,6 +3767,10 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cosmiconfig@8.3.6: resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} engines: {node: '>=14'} @@ -3768,6 +3884,10 @@ packages: resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} engines: {node: '>= 14'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -3830,12 +3950,19 @@ packages: resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} engines: {node: '>=12'} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + effect@4.0.0-beta.48: resolution: {integrity: sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==} @@ -3855,6 +3982,10 @@ packages: resolution: {integrity: sha512-96CEZB96M7fC5na1lFl9sL7AS5d2lRgJkZVb/e8ufbLMLLxEy2oHhN4E9d7+eJbtABne/CeumSRX2j7A9UDlEQ==} hasBin: true + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + encoding-sniffer@0.2.1: resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} @@ -3891,9 +4022,21 @@ packages: error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + es-toolkit@1.45.1: resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} @@ -3917,6 +4060,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} @@ -3966,6 +4112,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + event-target-shim@5.0.1: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} @@ -3980,10 +4130,28 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.3.2: + resolution: {integrity: sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} @@ -4058,6 +4226,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-my-way-ts@0.1.6: resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} @@ -4082,6 +4254,14 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -4103,6 +4283,9 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gaxios@7.1.4: resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} engines: {node: '>=18'} @@ -4123,10 +4306,18 @@ packages: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + get-port@7.1.0: resolution: {integrity: sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==} engines: {node: '>=16'} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-stream@5.2.0: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} @@ -4170,6 +4361,10 @@ packages: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -4191,6 +4386,14 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + hast-util-embedded@3.0.0: resolution: {integrity: sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==} @@ -4265,6 +4468,10 @@ packages: htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -4329,6 +4536,10 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -4375,6 +4586,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -4404,6 +4618,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4436,6 +4653,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-with-bigint@3.5.3: resolution: {integrity: sha512-QObKu6nxy7NsxqR0VK4rkXnsNr5L9ElJaGEg+ucJ6J7/suoKZ0n+p76cu9aCqowytxEbwYNzvrMerfMkXneF5A==} @@ -4653,6 +4873,10 @@ packages: engines: {node: '>= 18'} hasBin: true + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdast-util-directive@3.1.0: resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==} @@ -4707,6 +4931,14 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -4927,6 +5159,10 @@ packages: engines: {node: ^18 || >=20} hasBin: true + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + netmask@2.1.0: resolution: {integrity: sha512-z9sZrk6wyf8/NDKKqe+Tyl58XtgkYrV4kgt1O8xrzYvpl1LvPacPo0imMLHfpStk3kgCIq1ksJ2bmJn9hue2lQ==} engines: {node: '>= 0.4.0'} @@ -5068,6 +5304,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -5077,6 +5317,10 @@ packages: ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -5200,6 +5444,10 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + partial-json@0.1.7: resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} @@ -5226,6 +5474,9 @@ packages: path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-to-regexp@8.4.0: + resolution: {integrity: sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -5254,6 +5505,10 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -5274,8 +5529,8 @@ packages: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} postgres@3.4.8: @@ -5316,6 +5571,10 @@ packages: resolution: {integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==} engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + proxy-agent@6.5.0: resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} engines: {node: '>= 14'} @@ -5329,12 +5588,24 @@ packages: pure-rand@8.4.0: resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -5531,6 +5802,10 @@ packages: rou3@0.8.1: resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -5558,6 +5833,10 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + seroval-plugins@1.5.1: resolution: {integrity: sha512-4FbuZ/TMl02sqv0RTFexu0SP6V+ywaIe5bAWCCEik0fk17BhALgwvUDVF7e3Uvf9pxmwCEJsRPmlkUE6HdzLAw==} engines: {node: '>=10'} @@ -5568,6 +5847,13 @@ packages: resolution: {integrity: sha512-OwrZRZAfhHww0WEnKHDY8OM0U/Qs8OTfIDWhUD4BLpNJUfXK4cGmjiagGze086m+mhI+V2nD0gfbHEnJjb9STA==} engines: {node: '>=10'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -5584,6 +5870,22 @@ packages: resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} engines: {node: '>=20'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -5864,6 +6166,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + token-types@6.1.2: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} @@ -5943,6 +6249,10 @@ packages: resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==} engines: {node: '>=20'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -6018,6 +6328,10 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unplugin-auto-import@21.0.0: resolution: {integrity: sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==} engines: {node: '>=20.19.0'} @@ -6076,6 +6390,10 @@ packages: resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} hasBin: true + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -6350,6 +6668,41 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 + '@anthropic-ai/claude-code-darwin-arm64@2.1.118': + optional: true + + '@anthropic-ai/claude-code-darwin-x64@2.1.118': + optional: true + + '@anthropic-ai/claude-code-linux-arm64-musl@2.1.118': + optional: true + + '@anthropic-ai/claude-code-linux-arm64@2.1.118': + optional: true + + '@anthropic-ai/claude-code-linux-x64-musl@2.1.118': + optional: true + + '@anthropic-ai/claude-code-linux-x64@2.1.118': + optional: true + + '@anthropic-ai/claude-code-win32-arm64@2.1.118': + optional: true + + '@anthropic-ai/claude-code-win32-x64@2.1.118': + optional: true + + '@anthropic-ai/claude-code@2.1.118': + optionalDependencies: + '@anthropic-ai/claude-code-darwin-arm64': 2.1.118 + '@anthropic-ai/claude-code-darwin-x64': 2.1.118 + '@anthropic-ai/claude-code-linux-arm64': 2.1.118 + '@anthropic-ai/claude-code-linux-arm64-musl': 2.1.118 + '@anthropic-ai/claude-code-linux-x64': 2.1.118 + '@anthropic-ai/claude-code-linux-x64-musl': 2.1.118 + '@anthropic-ai/claude-code-win32-arm64': 2.1.118 + '@anthropic-ai/claude-code-win32-x64': 2.1.118 + '@anthropic-ai/sdk@0.73.0(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 @@ -7297,12 +7650,14 @@ snapshots: '@fontsource/silkscreen@5.2.8': {} - '@google/genai@1.48.0': + '@google/genai@1.48.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))': dependencies: google-auth-library: 10.6.2 p-retry: 4.6.2 protobufjs: 7.5.5 ws: 8.20.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) transitivePeerDependencies: - bufferutil - supports-color @@ -7586,9 +7941,9 @@ snapshots: std-env: 3.10.0 yoctocolors: 2.1.2 - '@mariozechner/pi-agent-core@0.65.2(ws@8.20.0)(zod@4.3.6)': + '@mariozechner/pi-agent-core@0.65.2(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: - '@mariozechner/pi-ai': 0.65.2(ws@8.20.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.65.2(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) transitivePeerDependencies: - '@modelcontextprotocol/sdk' - aws-crt @@ -7598,11 +7953,11 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.65.2(ws@8.20.0)(zod@4.3.6)': + '@mariozechner/pi-ai@0.65.2(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@anthropic-ai/sdk': 0.73.0(zod@4.3.6) '@aws-sdk/client-bedrock-runtime': 3.1026.0 - '@google/genai': 1.48.0 + '@google/genai': 1.48.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)) '@mistralai/mistralai': 1.14.1 '@sinclair/typebox': 0.34.49 ajv: 8.18.0 @@ -7622,11 +7977,11 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.65.2(ws@8.20.0)(zod@4.3.6)': + '@mariozechner/pi-coding-agent@0.65.2(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ws@8.20.0)(zod@4.3.6)': dependencies: '@mariozechner/jiti': 2.6.5 - '@mariozechner/pi-agent-core': 0.65.2(ws@8.20.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.65.2(ws@8.20.0)(zod@4.3.6) + '@mariozechner/pi-agent-core': 0.65.2(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.65.2(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(ws@8.20.0)(zod@4.3.6) '@mariozechner/pi-tui': 0.65.2 '@silvia-odwyer/photon-node': 0.3.4 ajv: 8.18.0 @@ -7714,6 +8069,31 @@ snapshots: - bufferutil - utf-8-validate + '@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.13(hono@4.12.14) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + express: 5.2.1 + express-rate-limit: 8.3.2(express@5.2.1) + hono: 4.12.14 + jose: 6.2.2 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.2(zod@4.3.6) + optionalDependencies: + '@cfworker/json-schema': 4.1.1 + transitivePeerDependencies: + - supports-color + optional: true + '@modelcontextprotocol/server@2.0.0-alpha.2(@cfworker/json-schema@4.1.1)': dependencies: zod: 4.3.6 @@ -9368,6 +9748,12 @@ snapshots: dependencies: event-target-shim: 5.0.1 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + optional: true + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -9546,6 +9932,21 @@ snapshots: blake3-wasm@2.1.5: {} + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.1 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + optional: true + boolbase@1.0.0: {} bowser@2.14.1: {} @@ -9606,6 +10007,9 @@ snapshots: byline@5.0.0: {} + bytes@3.1.2: + optional: true + c12@3.3.3: dependencies: chokidar: 5.0.0 @@ -9621,6 +10025,18 @@ snapshots: pkg-types: 2.3.0 rc9: 2.1.2 + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + optional: true + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + optional: true + callsites@3.1.0: {} camelcase@6.3.0: {} @@ -9761,14 +10177,32 @@ snapshots: consola@3.4.2: {} + content-disposition@1.1.0: + optional: true + + content-type@1.0.5: + optional: true + convert-source-map@2.0.0: {} cookie-es@2.0.0: {} + cookie-signature@1.2.2: + optional: true + + cookie@0.7.2: + optional: true + cookie@1.1.1: {} core-util-is@1.0.3: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + optional: true + cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.1 @@ -9871,6 +10305,9 @@ snapshots: escodegen: 2.1.0 esprima: 4.0.1 + depd@2.0.0: + optional: true + dequal@2.0.3: {} destr@2.0.5: {} @@ -9941,12 +10378,22 @@ snapshots: dotenv@17.3.1: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + optional: true + eastasianwidth@0.2.0: {} ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 + ee-first@1.1.1: + optional: true + effect@4.0.0-beta.48: dependencies: '@standard-schema/spec': 1.1.0 @@ -9977,6 +10424,9 @@ snapshots: transitivePeerDependencies: - hono + encodeurl@2.0.0: + optional: true + encoding-sniffer@0.2.1: dependencies: iconv-lite: 0.6.3 @@ -10010,8 +10460,19 @@ snapshots: error-stack-parser-es@1.0.5: {} + es-define-property@1.0.1: + optional: true + + es-errors@1.3.0: + optional: true + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + optional: true + es-toolkit@1.45.1: {} esast-util-from-estree@2.0.0: @@ -10088,6 +10549,9 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: + optional: true + escape-string-regexp@5.0.0: {} escodegen@2.1.0: @@ -10143,6 +10607,9 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: + optional: true + event-target-shim@5.0.1: {} eventemitter3@5.0.4: {} @@ -10155,8 +10622,56 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.0.8: + optional: true + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.8 + optional: true + expect-type@1.3.0: {} + express-rate-limit@8.3.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + optional: true + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.1 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + optional: true + exsolve@1.0.8: {} extend@3.0.2: {} @@ -10242,6 +10757,18 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + optional: true + find-my-way-ts@0.1.6: {} find-up@4.1.0: @@ -10264,6 +10791,12 @@ snapshots: dependencies: fetch-blob: 3.2.0 + forwarded@0.2.0: + optional: true + + fresh@2.0.0: + optional: true + fs-constants@1.0.0: {} fs-extra@7.0.1: @@ -10284,6 +10817,9 @@ snapshots: fsevents@2.3.3: optional: true + function-bind@1.1.2: + optional: true + gaxios@7.1.4: dependencies: extend: 3.0.2 @@ -10306,8 +10842,28 @@ snapshots: get-east-asian-width@1.5.0: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + optional: true + get-port@7.1.0: {} + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + optional: true + get-stream@5.2.0: dependencies: pump: 3.0.3 @@ -10376,6 +10932,9 @@ snapshots: google-logging-utils@1.1.3: {} + gopd@1.2.0: + optional: true + graceful-fs@4.2.11: {} graphql@16.13.1: {} @@ -10387,6 +10946,14 @@ snapshots: has-flag@4.0.0: {} + has-symbols@1.1.0: + optional: true + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + optional: true + hast-util-embedded@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -10562,6 +11129,15 @@ snapshots: domutils: 3.2.2 entities: 7.0.1 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + optional: true + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -10620,6 +11196,9 @@ snapshots: ip-address@10.1.0: {} + ipaddr.js@1.9.1: + optional: true + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -10655,6 +11234,9 @@ snapshots: is-plain-obj@4.1.0: {} + is-promise@4.0.0: + optional: true + is-stream@2.0.1: {} is-subdir@1.2.0: @@ -10677,6 +11259,9 @@ snapshots: jiti@2.6.1: {} + jose@6.2.2: + optional: true + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -10705,6 +11290,9 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: + optional: true + json-with-bigint@3.5.3: {} json5@2.2.3: {} @@ -10909,6 +11497,9 @@ snapshots: marked@15.0.12: {} + math-intrinsics@1.1.0: + optional: true + mdast-util-directive@3.1.0: dependencies: '@types/mdast': 4.0.4 @@ -11097,6 +11688,12 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + media-typer@1.1.0: + optional: true + + merge-descriptors@2.0.0: + optional: true + merge2@1.4.1: {} micromark-core-commonmark@2.0.3: @@ -11498,6 +12095,9 @@ snapshots: nanoid@5.1.6: {} + negotiator@1.0.0: + optional: true + netmask@2.1.0: {} no-case@3.0.4: @@ -11542,6 +12142,9 @@ snapshots: object-assign@4.1.1: {} + object-inspect@1.13.4: + optional: true + obug@2.1.1: {} ofetch@1.5.1: @@ -11552,6 +12155,11 @@ snapshots: ohash@2.0.11: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + optional: true + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -11755,6 +12363,9 @@ snapshots: dependencies: entities: 6.0.1 + parseurl@1.3.3: + optional: true + partial-json@0.1.7: {} path-exists@4.0.0: {} @@ -11775,6 +12386,9 @@ snapshots: path-to-regexp@6.3.0: {} + path-to-regexp@8.4.0: + optional: true + path-type@4.0.0: {} pathe@2.0.3: {} @@ -11791,6 +12405,9 @@ snapshots: pify@4.0.1: {} + pkce-challenge@5.0.1: + optional: true + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -11813,7 +12430,7 @@ snapshots: pngjs@7.0.0: {} - postcss@8.5.8: + postcss@8.5.10: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -11865,6 +12482,12 @@ snapshots: '@types/node': 25.3.2 long: 5.3.2 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + optional: true + proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 @@ -11887,10 +12510,26 @@ snapshots: pure-rand@8.4.0: {} + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + optional: true + quansync@0.2.11: {} queue-microtask@1.2.3: {} + range-parser@1.2.1: + optional: true + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + optional: true + rc9@2.1.2: dependencies: defu: 6.1.5 @@ -12217,6 +12856,17 @@ snapshots: rou3@0.8.1: {} + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.0 + transitivePeerDependencies: + - supports-color + optional: true + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -12235,12 +12885,42 @@ snapshots: semver@7.7.4: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + optional: true + seroval-plugins@1.5.1(seroval@1.5.1): dependencies: seroval: 1.5.1 seroval@1.5.1: {} + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + optional: true + + setprototypeof@1.2.0: + optional: true + sharp@0.34.5: dependencies: '@img/colour': 1.1.0 @@ -12289,6 +12969,38 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + optional: true + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + optional: true + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + optional: true + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + optional: true + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -12599,6 +13311,9 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: + optional: true + token-types@6.1.2: dependencies: '@borewit/text-codec': 0.2.2 @@ -12651,6 +13366,13 @@ snapshots: dependencies: tagged-tag: 1.0.0 + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + optional: true + typescript@5.9.3: optional: true @@ -12747,6 +13469,9 @@ snapshots: universalify@0.1.2: {} + unpipe@1.0.0: + optional: true + unplugin-auto-import@21.0.0: dependencies: local-pkg: 1.1.2 @@ -12794,6 +13519,9 @@ snapshots: uuid@14.0.0: {} + vary@1.1.2: + optional: true + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -12830,7 +13558,7 @@ snapshots: dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.8 + postcss: 8.5.10 rolldown: 1.0.0-rc.12(@emnapi/core@1.8.1)(@emnapi/runtime@1.9.2) tinyglobby: 0.2.15 optionalDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c4ec075e..f64235a3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,6 +24,7 @@ minimumReleaseAgeExclude: - incur onlyBuiltDependencies: + - '@anthropic-ai/claude-code' - bun - msw - opencode-ai @@ -46,6 +47,7 @@ overrides: miniflare>undici: 7.18.2 path-to-regexp@>=8.0.0 <8.4.0: 8.4.0 picomatch@>=4.0.0 <4.0.4: 4.0.4 + postcss@<8.5.10: 8.5.10 protobufjs@<7.5.5: 7.5.5 uuid@<14.0.0: 14.0.0 diff --git a/public/claude.json b/public/claude.json new file mode 100644 index 00000000..c09f71f7 --- /dev/null +++ b/public/claude.json @@ -0,0 +1,23 @@ +{ + "name": "curl-md", + "owner": { + "name": "curl.md" + }, + "plugins": [ + { + "author": { + "name": "curl.md" + }, + "description": "Use curl.md inside Claude Code for low-token web fetches.", + "homepage": "https://curl.md/docs/plugins/claude", + "license": "MIT", + "name": "curl-md", + "repository": "https://github.com/wevm/curl.md/tree/main/plugins/claude", + "source": { + "package": "@curl.md/claude", + "source": "npm" + }, + "version": "0.0.0" + } + ] +} diff --git a/scripts/amp.ts b/scripts/amp.ts deleted file mode 100644 index b891e2c5..00000000 --- a/scripts/amp.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { mkdirSync, rmSync, symlinkSync } from 'node:fs' -import path from 'node:path' - -console.log('Launching amp plugin.') - -const root = path.resolve(import.meta.dirname, '..') -const pluginsDir = path.join(root, '.amp', 'plugins') -const legacyShimPath = path.join(pluginsDir, 'curlmd.js') -const shimPath = path.join(pluginsDir, 'curlmd.ts') -const pluginSourcePath = path.join(root, 'plugins', 'amp', 'plugin.ts') - -mkdirSync(pluginsDir, { recursive: true }) -rmSync(legacyShimPath, { force: true }) -rmSync(shimPath, { force: true }) -symlinkSync(path.relative(pluginsDir, pluginSourcePath), shimPath) - -execFileSync('amp', process.argv.slice(2), { - cwd: root, - env: { - ...process.env, - AI_AGENT: process.env.AI_AGENT || 'amp', - CURLMD_BASE_URL: process.env.CURLMD_BASE_URL || 'https://curl.local', - NODE_TLS_REJECT_UNAUTHORIZED: process.env.NODE_TLS_REJECT_UNAUTHORIZED || '0', - PLUGINS: process.env.PLUGINS || 'all', - }, - stdio: 'inherit', -}) - -console.log('Done.') diff --git a/scripts/deployWaf.ts b/scripts/deployWaf.ts index 37d7bc93..a9582a0f 100644 --- a/scripts/deployWaf.ts +++ b/scripts/deployWaf.ts @@ -49,7 +49,7 @@ const botPaths = [ ] const excludePrefixes = ['/assets/', '/.well-known/'] -const excludePaths = ['/api/og.png', '/dark.svg', '/favicon.svg', '/light.svg'] +const excludePaths = ['/api/og.png', '/claude.json', '/dark.svg', '/favicon.svg', '/light.svg'] const expression = [ `(${[ diff --git a/scripts/formatPackage.ts b/scripts/formatPackage.ts index d6699a91..3939fd6f 100644 --- a/scripts/formatPackage.ts +++ b/scripts/formatPackage.ts @@ -5,7 +5,7 @@ import path from 'node:path' console.log('Formatting packages.') -const packageDirs = ['cli', 'plugins/amp', 'plugins/opencode', 'plugins/pi'] +const packageDirs = ['cli', 'plugins/amp', 'plugins/claude', 'plugins/opencode', 'plugins/pi'] for (const dir of packageDirs) { await fs.copyFile('LICENSE', path.join(dir, 'LICENSE')) diff --git a/scripts/opencode.ts b/scripts/opencode.ts deleted file mode 100644 index 32e7f31b..00000000 --- a/scripts/opencode.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { pathToFileURL } from 'node:url' - -console.log('Launching opencode plugin.') - -const root = path.resolve(import.meta.dirname, '..') -const opencodeBinPath = path.join( - root, - 'plugins', - 'opencode', - 'node_modules', - '.bin', - process.platform === 'win32' ? 'opencode.cmd' : 'opencode', -) -const pluginsDir = path.join(root, '.opencode', 'plugins') -const shimPath = path.join(pluginsDir, 'curlmd.ts') -const pluginSourcePath = path.join(root, 'plugins', 'opencode', 'src', 'server.ts') -const importPath = path.relative(pluginsDir, pluginSourcePath).split(path.sep).join('/') -const projectTuiConfigPath = path.join(root, 'tui.json') -const tempTuiConfigDir = mkdtempSync(path.join(os.tmpdir(), 'curlmd-opencode-')) -const tempTuiConfigPath = path.join(tempTuiConfigDir, 'tui.json') -const tuiPluginPath = pathToFileURL(path.join(root, 'plugins', 'opencode', 'src', 'tui.ts')).href - -mkdirSync(pluginsDir, { recursive: true }) -writeFileSync(shimPath, `export { plugin } from ${JSON.stringify(importPath)}\n`) -writeFileSync(tempTuiConfigPath, `${JSON.stringify(buildTuiConfig(), null, 2)}\n`) - -try { - execFileSync(opencodeBinPath, process.argv.slice(2), { - cwd: root, - env: { - ...process.env, - CURLMD_BASE_URL: process.env.CURLMD_BASE_URL || 'https://curl.local', - NODE_TLS_REJECT_UNAUTHORIZED: process.env.NODE_TLS_REJECT_UNAUTHORIZED || '0', - OPENCODE_TUI_CONFIG: tempTuiConfigPath, - }, - stdio: 'inherit', - }) -} finally { - rmSync(tempTuiConfigDir, { force: true, recursive: true }) -} - -console.log('Done.') - -function buildTuiConfig() { - const config = readJsonFile(projectTuiConfigPath) - const plugin = Array.isArray(config?.plugin) ? [...config.plugin] : [] - - if (!plugin.includes(tuiPluginPath)) plugin.push(tuiPluginPath) - - if (!config) - return { - $schema: 'https://opencode.ai/tui.json', - plugin, - } - - return { - ...config, - $schema: 'https://opencode.ai/tui.json', - plugin, - } -} - -function readJsonFile(filePath: string): Record | null { - if (!existsSync(filePath)) return null - - try { - return JSON.parse(readFileSync(filePath, 'utf8')) as Record - } catch { - return null - } -} diff --git a/scripts/plugin.ts b/scripts/plugin.ts new file mode 100644 index 00000000..8044f194 --- /dev/null +++ b/scripts/plugin.ts @@ -0,0 +1,147 @@ +import { execFileSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +const providers = getProviders() +const root = path.resolve(import.meta.dirname, '..') +const providerName = (() => { + const value = process.argv[2] + if (value === 'amp' || value === 'claude' || value === 'opencode' || value === 'pi') return value + console.error(`Usage: node scripts/plugin.ts <${Object.keys(providers).join('|')}> [args...]`) + process.exit(1) +})() + +console.log(`Launching ${providerName} plugin.`) + +const runtime = providers[providerName](root) as ProviderRuntime + +try { + execFileSync(runtime.command, [...(runtime.args || []), ...process.argv.slice(3)], { + cwd: root, + env: { + ...process.env, + ...runtime.env, + }, + stdio: 'inherit', + }) +} finally { + runtime.cleanup?.() +} + +console.log('Done.') + +//////////////////////////////////////////////////////////////////////////////////////////////// + +function getProviders() { + return { + amp(root) { + const pluginsDir = path.join(root, '.amp', 'plugins') + const legacyShimPath = path.join(pluginsDir, 'curlmd.js') + const pluginSourcePath = path.join(root, 'plugins', 'amp', 'src', 'plugin.ts') + const shimPath = path.join(pluginsDir, 'curlmd.ts') + + mkdirSync(pluginsDir, { recursive: true }) + rmSync(legacyShimPath, { force: true }) + rmSync(shimPath, { force: true }) + symlinkSync(path.relative(pluginsDir, pluginSourcePath), shimPath) + + return { + command: 'amp', + env: { + AI_AGENT: process.env.AI_AGENT || 'amp', + CURLMD_BASE_URL: process.env.CURLMD_BASE_URL || 'https://curl.local', + NODE_TLS_REJECT_UNAUTHORIZED: process.env.NODE_TLS_REJECT_UNAUTHORIZED || '0', + PLUGINS: process.env.PLUGINS || 'all', + }, + } + }, + claude(root) { + const pluginDir = path.join(root, 'plugins', 'claude') + return { + args: ['--plugin-dir', pluginDir], + command: 'claude', + env: { + CURLMD_BASE_URL: process.env.CURLMD_BASE_URL || 'https://curl.local', + NODE_TLS_REJECT_UNAUTHORIZED: process.env.NODE_TLS_REJECT_UNAUTHORIZED || '0', + }, + } + }, + opencode(root) { + const pluginsDir = path.join(root, '.opencode', 'plugins') + const pluginSourcePath = path.join(root, 'plugins', 'opencode', 'src', 'server.ts') + const projectTuiConfigPath = path.join(root, 'tui.json') + const shimPath = path.join(pluginsDir, 'curlmd.ts') + const tempTuiConfigDir = mkdtempSync(path.join(os.tmpdir(), 'curlmd-opencode-')) + const tempTuiConfigPath = path.join(tempTuiConfigDir, 'tui.json') + const tuiPluginPath = pathToFileURL( + path.join(root, 'plugins', 'opencode', 'src', 'tui.ts'), + ).href + const importPath = path.relative(pluginsDir, pluginSourcePath).split(path.sep).join('/') + const tuiConfig = (() => { + const config = (() => { + if (!existsSync(projectTuiConfigPath)) return null + try { + return JSON.parse(readFileSync(projectTuiConfigPath, 'utf8')) as Record + } catch { + return null + } + })() + const plugin = Array.isArray(config?.plugin) ? [...config.plugin] : [] + if (!plugin.includes(tuiPluginPath)) plugin.push(tuiPluginPath) + if (!config) + return { + $schema: 'https://opencode.ai/tui.json', + plugin, + } + return { + ...config, + $schema: 'https://opencode.ai/tui.json', + plugin, + } + })() + + mkdirSync(pluginsDir, { recursive: true }) + writeFileSync(shimPath, `export { plugin } from ${JSON.stringify(importPath)}\n`) + writeFileSync(tempTuiConfigPath, `${JSON.stringify(tuiConfig)}\n`) + + return { + cleanup: () => rmSync(tempTuiConfigDir, { force: true, recursive: true }), + command: 'opencode', + env: { + CURLMD_BASE_URL: process.env.CURLMD_BASE_URL || 'https://curl.local', + NODE_TLS_REJECT_UNAUTHORIZED: process.env.NODE_TLS_REJECT_UNAUTHORIZED || '0', + OPENCODE_TUI_CONFIG: tempTuiConfigPath, + }, + } + }, + pi(root) { + const extensionPath = path.join(root, 'plugins', 'pi', 'src', 'index.ts') + return { + args: ['--no-extensions', '-e', extensionPath], + command: 'pi', + env: { + CURLMD_BASE_URL: process.env.CURLMD_BASE_URL || 'https://curl.local', + NODE_TLS_REJECT_UNAUTHORIZED: process.env.NODE_TLS_REJECT_UNAUTHORIZED || '0', + }, + } + }, + } satisfies Record ProviderRuntime> +} + +type ProviderName = 'amp' | 'claude' | 'opencode' | 'pi' +type ProviderRuntime = { + args?: string[] + cleanup?: () => void + command: string + env: Record +} diff --git a/scripts/restorePackage.ts b/scripts/restorePackage.ts index 63fe894b..5c69a945 100644 --- a/scripts/restorePackage.ts +++ b/scripts/restorePackage.ts @@ -5,7 +5,7 @@ import path from 'node:path' console.log('Restoring packages.') -const packageDirs = ['cli', 'plugins/amp', 'plugins/opencode', 'plugins/pi'] +const packageDirs = ['cli', 'plugins/amp', 'plugins/claude', 'plugins/opencode', 'plugins/pi'] for (const dir of packageDirs) { const packagePath = path.join(dir, 'package.json') diff --git a/scripts/updateWranglerTypes.ts b/scripts/updateWranglerTypes.ts index 8641fc51..70325a46 100644 --- a/scripts/updateWranglerTypes.ts +++ b/scripts/updateWranglerTypes.ts @@ -1,13 +1,15 @@ // Post-processes generated worker-configuration.d.ts: -// 1. Strips `GlobalProps` (TODO: remove once fixed upstream) -// https://github.com/cloudflare/workers-sdk/issues/11454 -// 2. Strips `KV` properties from Cloudflare.Env interfaces so env.d.ts -// can provide strongly-typed KV via TypedKV without declaration conflicts. import { readFileSync, writeFileSync } from 'node:fs' const file = 'src/worker-configuration.d.ts' let content = readFileSync(file, 'utf8') + +// Strips `GlobalProps` (TODO: remove once fixed upstream) +// https://github.com/cloudflare/workers-sdk/issues/11454 content = content.replace(/\tinterface GlobalProps \{[^}]*\}\n/, '') + +// Strips `KV` properties from Cloudflare.Env interfaces so env.d.ts can provide strongly-typed KV via TypedKV without declaration conflicts. content = content.replace(/^\t+KV: KVNamespace;?\n/gm, '') + if (content !== readFileSync(file, 'utf8')) writeFileSync(file, content) diff --git a/src/routes/-home.tsx b/src/routes/-home.tsx index 6afe38ed..f120ba73 100644 --- a/src/routes/-home.tsx +++ b/src/routes/-home.tsx @@ -205,22 +205,20 @@ const faqs = [ ] const showcaseUrls = [ + 'anthropic.com', + 'astral.sh', 'bun.sh', + 'cloudflare.com', + 'deno.com', 'developer.mozilla.org', - 'developers.cloudflare.com', - 'developers.openai.com', - 'docs.anthropic.com', - 'docs.astral.sh', - 'docs.deno.com', - 'docs.github.com', - 'docs.stripe.com', - 'docs.tempo.xyz', 'expressjs.com', 'ghostty.org', + 'github.com', 'hono.dev', 'laravel.com', 'nextjs.org', 'nodejs.org', + 'openai.com', 'orm.drizzle.team', 'oxc.rs', 'planetscale.com', @@ -229,9 +227,11 @@ const showcaseUrls = [ 'react.dev', 'resend.com', 'rspack.rs', + 'stripe.com', 'svelte.dev', 'tailwindcss.com', 'tanstack.com', + 'tempo.xyz', 'typescriptlang.org', 'ui.shadcn.com', 'vercel.com', diff --git a/src/routes/docs/-render.tsx b/src/routes/docs/-render.tsx index 648d7221..6944afee 100644 --- a/src/routes/docs/-render.tsx +++ b/src/routes/docs/-render.tsx @@ -2399,6 +2399,7 @@ function clearDocSearchPreviewHighlights(container: HTMLElement) { function getCodeGroupTabIcon(label: string, language?: string) { const normalized = label.trim().toLowerCase() + if (language === 'json' && normalized.endsWith('/.claude/settings.json')) return claudeCodeIcon if (language === 'json' && normalized.endsWith('/.pi/agent/settings.json')) return piCodeIcon if ( @@ -2417,6 +2418,7 @@ function getCodeGroupTabIcon(label: string, language?: string) { return undefined } +const claudeCodeIcon = { className: 'scale-95', Component: IconSimpleIconsClaude } as const const opencodeCodeIcon = { className: 'scale-90', Component: IconBrandOpencode } as const const piCodeIcon = { className: 'scale-125', Component: IconBrandPi } as const diff --git a/test/vitest.config.ts b/test/vitest.config.ts index 46dc6f57..5ba804dd 100644 --- a/test/vitest.config.ts +++ b/test/vitest.config.ts @@ -141,6 +141,13 @@ export default defineConfig({ root, }, }, + { + test: { + name: 'plugins:claude', + include: ['plugins/claude/**/*.test.ts'], + root, + }, + }, { test: { name: 'plugins:opencode',