From a5d32ca5a542f0dfc285939e76d4dd6f60d10327 Mon Sep 17 00:00:00 2001
From: tmm
Date: Thu, 23 Apr 2026 13:50:51 -0400
Subject: [PATCH 01/14] feat(plugins): claude
---
.github/README.md | 3 +
.gitignore | 1 +
docs/plugins/amp.mdx | 12 +-
docs/plugins/claude.mdx | 159 +++++-
docs/plugins/opencode.mdx | 18 +-
docs/plugins/pi.mdx | 12 +-
package.json | 10 +-
plugins/claude/.claude-plugin/plugin.json | 11 +
plugins/claude/.mcp.json | 8 +
plugins/claude/CHANGELOG.md | 5 +
plugins/claude/README.md | 50 ++
plugins/claude/package.json | 44 ++
plugins/claude/scripts/start.sh | 14 +
plugins/claude/skills/fetch/SKILL.md | 15 +
plugins/claude/src/server.ts | 215 ++++++++
plugins/claude/tsconfig.json | 14 +
pnpm-lock.yaml | 607 +++++++++++++++++++++-
public/claude.json | 20 +
scripts/amp.ts | 30 --
scripts/deployWaf.ts | 2 +-
scripts/opencode.ts | 75 ---
scripts/plugin.ts | 147 ++++++
scripts/updateWranglerTypes.ts | 10 +-
23 files changed, 1348 insertions(+), 134 deletions(-)
create mode 100644 plugins/claude/.claude-plugin/plugin.json
create mode 100644 plugins/claude/.mcp.json
create mode 100644 plugins/claude/CHANGELOG.md
create mode 100644 plugins/claude/README.md
create mode 100644 plugins/claude/package.json
create mode 100644 plugins/claude/scripts/start.sh
create mode 100644 plugins/claude/skills/fetch/SKILL.md
create mode 100644 plugins/claude/src/server.ts
create mode 100644 plugins/claude/tsconfig.json
create mode 100644 public/claude.json
delete mode 100644 scripts/amp.ts
delete mode 100644 scripts/opencode.ts
create mode 100644 scripts/plugin.ts
diff --git a/.github/README.md b/.github/README.md
index 4c52851c..6f23c1d7 100644
--- a/.github/README.md
+++ b/.github/README.md
@@ -34,6 +34,9 @@ md example.com
opencode plugin @curl.md/opencode
pi install @curl.md/pi
npx @curl.md/amp install
+# Claude marketplace: URL is `claude.json`, install name is `curl-md`
+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/.gitignore b/.gitignore
index 291db66f..d564dbc0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
/.amp
+/.claude
/.opencode
.env*
.vite
diff --git a/docs/plugins/amp.mdx b/docs/plugins/amp.mdx
index ae9870f7..e17d2971 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.
@@ -120,6 +120,16 @@ The plugin registers the following tools:
| `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. |
+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 through curl.md. |
+| `objective?` | `string` | Ask a specific question about the page instead of fetching everything. |
+| `keywords?` | `string[]` | Focus extraction on a few specific terms or sections. |
+| `mode?` | `rush` / `smart` | Use `rush` for speed or `smart` for better section selection on long pages. |
+| `fresh?` | `boolean` | Bypass curl.md cache for recently updated pages like release notes or changelogs |
+
## Contributing
We welcome contributions to make the Amp 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/claude.mdx b/docs/plugins/claude.mdx
index 2c4eba00..294d43a4 100644
--- a/docs/plugins/claude.mdx
+++ b/docs/plugins/claude.mdx
@@ -1,16 +1,161 @@
---
-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 and `/curl-md:fetch` slash skill.
+
+
+
+## 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:
-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.
+```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.
+
+:::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
+```
+
+## 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 for a URL fetch. |
+
+### Tools
+
+The plugin also registers the following tool:
+
+| Tool | Description |
+| --------- | ----------------------------------------------------------------------- |
+| `curl_md` | Fetches URLs through curl.md and returns markdown optimized for agents. |
+
+The `curl_md` tool accepts the following inputs:
+
+| Input | Type | Description |
+| ------------ | ---------------- | -------------------------------------------------------------------------------- |
+| `url` | `string` | HTTP(S) URL or bare domain to fetch through curl.md. |
+| `objective?` | `string` | Ask a specific question about the page instead of fetching everything. |
+| `keywords?` | `string[]` | Focus extraction on a few specific terms or sections. |
+| `mode?` | `rush` / `smart` | Use `rush` for speed or `smart` for better section selection on long pages. |
+| `fresh?` | `boolean` | Bypass curl.md cache for recently updated pages like release notes or changelogs |
+
+### 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..c1725858 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.
@@ -155,6 +155,22 @@ The plugin registers the following tools:
| `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. |
+The `curl_md` tool accepts the following inputs:
+
+| Input | Type | Description |
+| ---------- | -------- | --------------------------------------------------------------------- |
+| `url` | `string` | HTTP(S) URL or bare domain to fetch through curl.md. |
+| `options?` | `object` | Optional fetch settings for `fresh`, `keywords`, `mode`, `objective`. |
+
+The `options` object accepts the following inputs:
+
+| Input | Type | Description |
+| ------------ | ---------------- | -------------------------------------------------------------------------------- |
+| `objective?` | `string` | Ask a specific question about the page instead of fetching everything. |
+| `keywords?` | `string[]` | Focus extraction on a few specific terms or sections. |
+| `mode?` | `rush` / `smart` | Use `rush` for speed or `smart` for better section selection on long pages. |
+| `fresh?` | `boolean` | Bypass curl.md cache for recently updated pages like release notes or changelogs |
+
### Status/Debugging
Use the `/curl_md_status` command to confirm auth state, tool registration, and `curl.md` CLI availability.
diff --git a/docs/plugins/pi.mdx b/docs/plugins/pi.mdx
index 017fb738..0f00dec5 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.
@@ -139,6 +139,16 @@ The plugin also registers the following tools:
| `curl_md` | Compatibility alias for `read_web_page`. |
| `read_web_page` | Fetches URLs through curl.md and returns markdown optimized for agents. |
+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 through curl.md. |
+| `objective?` | `string` | Ask a specific question about the page instead of fetching everything. |
+| `keywords?` | `string[]` | Focus extraction on a few specific terms or sections. |
+| `mode?` | `rush` / `smart` | Use `rush` for speed or `smart` for better section selection on long pages. |
+| `fresh?` | `boolean` | Bypass curl.md cache for recently updated pages like release notes or changelogs |
+
### Status/Debugging
Use the `curl_md_status` command to confirm auth state, tool registration, and `curl.md` CLI availability.
diff --git a/package.json b/package.json
index 50971612..6685abd1 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",
@@ -26,9 +27,10 @@
"dev": "docker compose up -d",
"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",
diff --git a/plugins/claude/.claude-plugin/plugin.json b/plugins/claude/.claude-plugin/plugin.json
new file mode 100644
index 00000000..127e4105
--- /dev/null
+++ b/plugins/claude/.claude-plugin/plugin.json
@@ -0,0 +1,11 @@
+{
+ "name": "curl-md",
+ "description": "Use curl.md inside Claude Code for low-token web fetches.",
+ "version": "0.0.1",
+ "author": {
+ "name": "curl.md"
+ },
+ "homepage": "https://curl.md/docs/plugins/claude",
+ "repository": "https://github.com/wevm/curl.md/tree/main/plugins/claude",
+ "license": "MIT"
+}
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/CHANGELOG.md b/plugins/claude/CHANGELOG.md
new file mode 100644
index 00000000..06597c46
--- /dev/null
+++ b/plugins/claude/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Changelog
+
+## 0.0.1
+
+- Initial Claude Code plugin with the `curl_md` MCP tool and `/curl-md:fetch` skill.
diff --git a/plugins/claude/README.md b/plugins/claude/README.md
new file mode 100644
index 00000000..e995498e
--- /dev/null
+++ b/plugins/claude/README.md
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+# @curl.md/claude
+
+Turn websites into **optimized, low token output** inside **Claude Code**.
+
+## Install
+
+```sh
+claude plugin marketplace add https://curl.md/claude.json
+claude plugin install curl-md@curl-md
+```
+
+Use `curl-md` as the marketplace/plugin name in install commands, `/curl-md:fetch` as the slash skill, and `curl_md` as the MCP tool name shown in Claude.
+
+Then reload plugins or restart Claude Code.
+
+```text
+/reload-plugins
+```
+
+## Use
+
+Use Claude normally and paste a URL, or run the plugin skill directly:
+
+```text
+/curl-md:fetch https://curl.md/docs/plugins/claude
+```
+
+## Notes
+
+- First launch installs plugin runtime dependencies with `npm`, so Node/npm must be on your `PATH`.
+- For higher limits, set `CURLMD_API_KEY` or run `curl.md auth login`.
+
+## Documentation
+
+For full documentation, visit [curl.md/docs](https://curl.md/docs/plugins/claude)
+
+## License
+
+[MIT](https://github.com/wevm/curl.md/blob/main/LICENSE)
diff --git a/plugins/claude/package.json b/plugins/claude/package.json
new file mode 100644
index 00000000..1fabb5a1
--- /dev/null
+++ b/plugins/claude/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "@curl.md/claude",
+ "version": "0.0.1",
+ "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",
+ "README.md",
+ "scripts/**/*.sh",
+ "skills/**",
+ "src/**/*.ts"
+ ],
+ "type": "module",
+ "scripts": {
+ "check:types": "tsgo --noEmit"
+ },
+ "dependencies": {
+ "@modelcontextprotocol/sdk": "1.29.0",
+ "curl.md": "0.0.20",
+ "zod": "^4.3.6"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+}
diff --git a/plugins/claude/scripts/start.sh b/plugins/claude/scripts/start.sh
new file mode 100644
index 00000000..ea140da1
--- /dev/null
+++ b/plugins/claude/scripts/start.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+set -eu
+
+plugin_root=${CLAUDE_PLUGIN_ROOT:-$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)}
+
+if [ ! -d "$plugin_root/node_modules/@modelcontextprotocol" ] || [ ! -d "$plugin_root/node_modules/curl.md" ] || [ ! -d "$plugin_root/node_modules/zod" ]; then
+ echo "Installing curl.md Claude plugin dependencies..." >&2
+ (
+ cd "$plugin_root"
+ npm install --ignore-scripts --no-audit --no-fund --omit=dev --silent
+ )
+fi
+
+exec node --experimental-strip-types --no-warnings "$plugin_root/src/server.ts"
diff --git a/plugins/claude/skills/fetch/SKILL.md b/plugins/claude/skills/fetch/SKILL.md
new file mode 100644
index 00000000..95c59aa2
--- /dev/null
+++ b/plugins/claude/skills/fetch/SKILL.md
@@ -0,0 +1,15 @@
+---
+description: Use curl.md when you need to read a public URL, docs page, changelog, article, or blog post with low-token markdown output.
+---
+
+Use the `curl_md` tool to fetch the URL or domain in `$ARGUMENTS` or in 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/server.ts b/plugins/claude/src/server.ts
new file mode 100644
index 00000000..682694e1
--- /dev/null
+++ b/plugins/claude/src/server.ts
@@ -0,0 +1,215 @@
+import process from 'node:process'
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
+import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
+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: 'Read a web page through curl.md and return markdown optimized for coding agents.',
+ inputSchema: {
+ url: z
+ .string()
+ .describe(
+ 'HTTP(S) URL or bare domain to fetch via curl.md. Prefer the canonical docs or article URL you want summarized.',
+ ),
+ objective: z
+ .string()
+ .optional()
+ .describe(
+ 'Specific question or goal to answer from the page. Prefer concrete objectives like "compare pricing tiers" or "find auth header requirements".',
+ ),
+ keywords: z
+ .array(z.string())
+ .optional()
+ .describe(
+ 'Keywords to pre-filter sections by. Prefer 2-5 distinct terms when only part of a long page matters.',
+ ),
+ mode: z
+ .enum(['rush', 'smart'])
+ .optional()
+ .describe(
+ 'rush: lower-latency, best when you already know the section. smart: higher-quality narrowing for long or noisy pages.',
+ ),
+ fresh: z
+ .boolean()
+ .optional()
+ .describe(
+ 'Bypass curl.md cache when freshness matters, such as changelogs, release notes, or recently updated docs.',
+ ),
+ },
+ },
+ 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.replace(/\n\n---\n\nPowered by \[curl\.md\]\(https:\/\/curl\.md\)$/, ''),
+ 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/pnpm-lock.yaml b/pnpm-lock.yaml
index 53c84b19..fdf59f4d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -305,6 +305,18 @@ importers:
specifier: 0.0.0-dev
version: 0.0.0-dev
+ plugins/claude:
+ dependencies:
+ '@modelcontextprotocol/sdk':
+ specifier: 1.29.0
+ version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)
+ curl.md:
+ specifier: 0.0.20
+ version: 0.0.20
+ zod:
+ specifier: ^4.3.6
+ version: 4.3.6
+
plugins/opencode:
dependencies:
'@opencode-ai/plugin':
@@ -322,10 +334,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
@@ -1458,6 +1470,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 +3273,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 +3474,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 +3530,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 +3542,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 +3684,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 +3713,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'}
@@ -3691,6 +3753,11 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
+ curl.md@0.0.20:
+ resolution: {integrity: sha512-2uO17rpTwXMxevVLf57ZrTVT/0+oj6cZcgkjZeWQ/h6/6W1w+MSK6cMdDTmSxjZDTa/osq3djnjUCsY/W+l/QA==}
+ engines: {node: '>=22.0.0'}
+ hasBin: true
+
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
@@ -3768,6 +3835,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 +3901,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 +3933,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 +3973,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 +4011,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 +4063,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 +4081,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 +4177,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 +4205,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 +4234,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 +4257,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 +4312,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 +4337,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 +4419,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 +4487,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 +4537,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 +4569,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 +4604,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 +4824,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 +4882,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 +5110,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 +5255,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 +5268,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 +5395,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 +5425,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 +5456,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==}
@@ -5316,6 +5522,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 +5539,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 +5753,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 +5784,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 +5798,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 +5821,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 +6117,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 +6200,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 +6279,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 +6341,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==}
@@ -7297,12 +7566,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 +7857,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 +7869,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 +7893,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 +7985,30 @@ 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
+
'@modelcontextprotocol/server@2.0.0-alpha.2(@cfworker/json-schema@4.1.1)':
dependencies:
zod: 4.3.6
@@ -9368,6 +9663,11 @@ snapshots:
dependencies:
event-target-shim: 5.0.1
+ accepts@2.0.0:
+ dependencies:
+ mime-types: 3.0.2
+ negotiator: 1.0.0
+
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0
@@ -9546,6 +9846,20 @@ 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
+
boolbase@1.0.0: {}
bowser@2.14.1: {}
@@ -9606,6 +9920,8 @@ snapshots:
byline@5.0.0: {}
+ bytes@3.1.2: {}
+
c12@3.3.3:
dependencies:
chokidar: 5.0.0
@@ -9621,6 +9937,16 @@ 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
+
+ call-bound@1.0.4:
+ dependencies:
+ call-bind-apply-helpers: 1.0.2
+ get-intrinsic: 1.3.0
+
callsites@3.1.0: {}
camelcase@6.3.0: {}
@@ -9761,14 +10087,27 @@ snapshots:
consola@3.4.2: {}
+ content-disposition@1.1.0: {}
+
+ content-type@1.0.5: {}
+
convert-source-map@2.0.0: {}
cookie-es@2.0.0: {}
+ cookie-signature@1.2.2: {}
+
+ cookie@0.7.2: {}
+
cookie@1.1.1: {}
core-util-is@1.0.3: {}
+ cors@2.8.6:
+ dependencies:
+ object-assign: 4.1.1
+ vary: 1.1.2
+
cosmiconfig@8.3.6(typescript@5.9.3):
dependencies:
import-fresh: 3.3.1
@@ -9809,6 +10148,12 @@ snapshots:
csstype@3.2.3: {}
+ curl.md@0.0.20:
+ dependencies:
+ hono: 4.12.14
+ incur: 0.4.2
+ picocolors: 1.1.1
+
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
@@ -9871,6 +10216,8 @@ snapshots:
escodegen: 2.1.0
esprima: 4.0.1
+ depd@2.0.0: {}
+
dequal@2.0.3: {}
destr@2.0.5: {}
@@ -9941,12 +10288,20 @@ 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
+
eastasianwidth@0.2.0: {}
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
+ ee-first@1.1.1: {}
+
effect@4.0.0-beta.48:
dependencies:
'@standard-schema/spec': 1.1.0
@@ -9977,6 +10332,8 @@ snapshots:
transitivePeerDependencies:
- hono
+ encodeurl@2.0.0: {}
+
encoding-sniffer@0.2.1:
dependencies:
iconv-lite: 0.6.3
@@ -10010,8 +10367,16 @@ snapshots:
error-stack-parser-es@1.0.5: {}
+ es-define-property@1.0.1: {}
+
+ es-errors@1.3.0: {}
+
es-module-lexer@2.0.0: {}
+ es-object-atoms@1.1.1:
+ dependencies:
+ es-errors: 1.3.0
+
es-toolkit@1.45.1: {}
esast-util-from-estree@2.0.0:
@@ -10088,6 +10453,8 @@ snapshots:
escalade@3.2.0: {}
+ escape-html@1.0.3: {}
+
escape-string-regexp@5.0.0: {}
escodegen@2.1.0:
@@ -10143,6 +10510,8 @@ snapshots:
esutils@2.0.3: {}
+ etag@1.8.1: {}
+
event-target-shim@5.0.1: {}
eventemitter3@5.0.4: {}
@@ -10155,8 +10524,52 @@ snapshots:
events@3.3.0: {}
+ eventsource-parser@3.0.8: {}
+
+ eventsource@3.0.7:
+ dependencies:
+ eventsource-parser: 3.0.8
+
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
+
+ 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
+
exsolve@1.0.8: {}
extend@3.0.2: {}
@@ -10242,6 +10655,17 @@ 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
+
find-my-way-ts@0.1.6: {}
find-up@4.1.0:
@@ -10264,6 +10688,10 @@ snapshots:
dependencies:
fetch-blob: 3.2.0
+ forwarded@0.2.0: {}
+
+ fresh@2.0.0: {}
+
fs-constants@1.0.0: {}
fs-extra@7.0.1:
@@ -10284,6 +10712,8 @@ snapshots:
fsevents@2.3.3:
optional: true
+ function-bind@1.1.2: {}
+
gaxios@7.1.4:
dependencies:
extend: 3.0.2
@@ -10306,8 +10736,26 @@ 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
+
get-port@7.1.0: {}
+ get-proto@1.0.1:
+ dependencies:
+ dunder-proto: 1.0.1
+ es-object-atoms: 1.1.1
+
get-stream@5.2.0:
dependencies:
pump: 3.0.3
@@ -10376,6 +10824,8 @@ snapshots:
google-logging-utils@1.1.3: {}
+ gopd@1.2.0: {}
+
graceful-fs@4.2.11: {}
graphql@16.13.1: {}
@@ -10387,6 +10837,12 @@ snapshots:
has-flag@4.0.0: {}
+ has-symbols@1.1.0: {}
+
+ hasown@2.0.3:
+ dependencies:
+ function-bind: 1.1.2
+
hast-util-embedded@3.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -10562,6 +11018,14 @@ 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
+
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
@@ -10620,6 +11084,8 @@ snapshots:
ip-address@10.1.0: {}
+ ipaddr.js@1.9.1: {}
+
is-alphabetical@2.0.1: {}
is-alphanumerical@2.0.1:
@@ -10655,6 +11121,8 @@ snapshots:
is-plain-obj@4.1.0: {}
+ is-promise@4.0.0: {}
+
is-stream@2.0.1: {}
is-subdir@1.2.0:
@@ -10677,6 +11145,8 @@ snapshots:
jiti@2.6.1: {}
+ jose@6.2.2: {}
+
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
@@ -10705,6 +11175,8 @@ snapshots:
json-schema-traverse@1.0.0: {}
+ json-schema-typed@8.0.2: {}
+
json-with-bigint@3.5.3: {}
json5@2.2.3: {}
@@ -10909,6 +11381,8 @@ snapshots:
marked@15.0.12: {}
+ math-intrinsics@1.1.0: {}
+
mdast-util-directive@3.1.0:
dependencies:
'@types/mdast': 4.0.4
@@ -11097,6 +11571,10 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
+ media-typer@1.1.0: {}
+
+ merge-descriptors@2.0.0: {}
+
merge2@1.4.1: {}
micromark-core-commonmark@2.0.3:
@@ -11498,6 +11976,8 @@ snapshots:
nanoid@5.1.6: {}
+ negotiator@1.0.0: {}
+
netmask@2.1.0: {}
no-case@3.0.4:
@@ -11542,6 +12022,8 @@ snapshots:
object-assign@4.1.1: {}
+ object-inspect@1.13.4: {}
+
obug@2.1.1: {}
ofetch@1.5.1:
@@ -11552,6 +12034,10 @@ snapshots:
ohash@2.0.11: {}
+ on-finished@2.4.1:
+ dependencies:
+ ee-first: 1.1.1
+
once@1.4.0:
dependencies:
wrappy: 1.0.2
@@ -11755,6 +12241,8 @@ snapshots:
dependencies:
entities: 6.0.1
+ parseurl@1.3.3: {}
+
partial-json@0.1.7: {}
path-exists@4.0.0: {}
@@ -11775,6 +12263,8 @@ snapshots:
path-to-regexp@6.3.0: {}
+ path-to-regexp@8.4.0: {}
+
path-type@4.0.0: {}
pathe@2.0.3: {}
@@ -11791,6 +12281,8 @@ snapshots:
pify@4.0.1: {}
+ pkce-challenge@5.0.1: {}
+
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
@@ -11865,6 +12357,11 @@ 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
+
proxy-agent@6.5.0:
dependencies:
agent-base: 7.1.4
@@ -11887,10 +12384,23 @@ snapshots:
pure-rand@8.4.0: {}
+ qs@6.15.1:
+ dependencies:
+ side-channel: 1.1.0
+
quansync@0.2.11: {}
queue-microtask@1.2.3: {}
+ range-parser@1.2.1: {}
+
+ raw-body@3.0.2:
+ dependencies:
+ bytes: 3.1.2
+ http-errors: 2.0.1
+ iconv-lite: 0.7.2
+ unpipe: 1.0.0
+
rc9@2.1.2:
dependencies:
defu: 6.1.5
@@ -12217,6 +12727,16 @@ 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
+
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -12235,12 +12755,39 @@ 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
+
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
+
+ setprototypeof@1.2.0: {}
+
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
@@ -12289,6 +12836,34 @@ 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
+
+ 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
+
+ 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
+
+ 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
+
siginfo@2.0.0: {}
signal-exit@3.0.7: {}
@@ -12599,6 +13174,8 @@ snapshots:
dependencies:
is-number: 7.0.0
+ toidentifier@1.0.1: {}
+
token-types@6.1.2:
dependencies:
'@borewit/text-codec': 0.2.2
@@ -12651,6 +13228,12 @@ 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
+
typescript@5.9.3:
optional: true
@@ -12747,6 +13330,8 @@ snapshots:
universalify@0.1.2: {}
+ unpipe@1.0.0: {}
+
unplugin-auto-import@21.0.0:
dependencies:
local-pkg: 1.1.2
@@ -12794,6 +13379,8 @@ snapshots:
uuid@14.0.0: {}
+ vary@1.1.2: {}
+
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3
diff --git a/public/claude.json b/public/claude.json
new file mode 100644
index 00000000..c9f9f0f7
--- /dev/null
+++ b/public/claude.json
@@ -0,0 +1,20 @@
+{
+ "name": "curl-md",
+ "owner": {
+ "name": "curl.md"
+ },
+ "plugins": [
+ {
+ "name": "curl-md",
+ "source": {
+ "source": "npm",
+ "package": "@curl.md/claude"
+ },
+ "description": "Use curl.md inside Claude Code for low-token web fetches.",
+ "version": "0.0.1",
+ "author": {
+ "name": "curl.md"
+ }
+ }
+ ]
+}
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/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/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)
From 194e1446936eff01bb586af4a98f43defa73f135 Mon Sep 17 00:00:00 2001
From: tmm
Date: Thu, 23 Apr 2026 13:55:59 -0400
Subject: [PATCH 02/14] chore: knip
---
.github/README.md | 6 +++---
config/knip.json | 6 ++++++
plugins/claude/package.json | 2 +-
3 files changed, 10 insertions(+), 4 deletions(-)
diff --git a/.github/README.md b/.github/README.md
index 6f23c1d7..f732fff2 100644
--- a/.github/README.md
+++ b/.github/README.md
@@ -31,10 +31,10 @@ 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 marketplace: URL is `claude.json`, install name is `curl-md`
+
claude plugin marketplace add https://curl.md/claude.json
claude plugin install curl-md@curl-md
diff --git a/config/knip.json b/config/knip.json
index 415d1304..ef92e534 100644
--- a/config/knip.json
+++ b/config/knip.json
@@ -15,6 +15,12 @@
"entry": ["src/install.ts"],
"ignore": ["**/*.test.ts"]
},
+ "plugins/claude": {
+ "entry": ["src/server.ts"]
+ },
+ "plugins/opencode": {
+ "ignore": ["**/*.test.ts"]
+ },
"plugins/pi": {
"ignore": ["**/*.test.ts", "test/**"]
}
diff --git a/plugins/claude/package.json b/plugins/claude/package.json
index 1fabb5a1..f3d66732 100644
--- a/plugins/claude/package.json
+++ b/plugins/claude/package.json
@@ -1,6 +1,6 @@
{
"name": "@curl.md/claude",
- "version": "0.0.1",
+ "version": "0.0.0",
"description": "curl.md plugin for Claude Code",
"contributors": [
"tmm ",
From ea19c26c7900f850ea1db0a80d3bb93b39e3e46f Mon Sep 17 00:00:00 2001
From: tmm
Date: Thu, 23 Apr 2026 22:52:49 -0400
Subject: [PATCH 03/14] chore: up
---
.github/workflows/check.yml | 3 +++
.github/workflows/pull_request.yml | 2 +-
docs/dev/develop.mdx | 2 +-
docs/guide/api.mdx | 2 +-
docs/guide/plugins.mdx | 2 +-
plugins/claude/CHANGELOG.md | 5 -----
plugins/claude/README.md | 22 ++++------------------
plugins/pi/src/extension.test.ts | 4 ++--
scripts/formatPackage.ts | 2 +-
scripts/restorePackage.ts | 2 +-
test/vitest.config.ts | 7 +++++++
11 files changed, 22 insertions(+), 31 deletions(-)
delete mode 100644 plugins/claude/CHANGELOG.md
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 5dc50c2e..5cb33ee1 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -103,6 +103,9 @@ jobs:
- name: Test
run: pnpm test --project cli
+ - name: Plugin tests
+ run: pnpm test --project plugins:amp --project plugins:claude --project plugins:opencode --project plugins:pi
+
- name: Build
run: |
pnpm --filter curl.md build
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
index 71e3e1e7..22484857 100644
--- a/.github/workflows/pull_request.yml
+++ b/.github/workflows/pull_request.yml
@@ -48,4 +48,4 @@ jobs:
- 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/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/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/plugins.mdx b/docs/guide/plugins.mdx
index a0af911b..c1c67aa9 100644
--- a/docs/guide/plugins.mdx
+++ b/docs/guide/plugins.mdx
@@ -60,7 +60,7 @@ 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.
diff --git a/plugins/claude/CHANGELOG.md b/plugins/claude/CHANGELOG.md
deleted file mode 100644
index 06597c46..00000000
--- a/plugins/claude/CHANGELOG.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# Changelog
-
-## 0.0.1
-
-- Initial Claude Code plugin with the `curl_md` MCP tool and `/curl-md:fetch` skill.
diff --git a/plugins/claude/README.md b/plugins/claude/README.md
index e995498e..5667af86 100644
--- a/plugins/claude/README.md
+++ b/plugins/claude/README.md
@@ -20,27 +20,13 @@ claude plugin marketplace add https://curl.md/claude.json
claude plugin install curl-md@curl-md
```
-Use `curl-md` as the marketplace/plugin name in install commands, `/curl-md:fetch` as the slash skill, and `curl_md` as the MCP tool name shown in Claude.
+To update:
-Then reload plugins or restart Claude Code.
-
-```text
-/reload-plugins
-```
-
-## Use
-
-Use Claude normally and paste a URL, or run the plugin skill directly:
-
-```text
-/curl-md:fetch https://curl.md/docs/plugins/claude
+```sh
+claude plugin marketplace update curl-md
+claude plugin install curl-md@curl-md
```
-## Notes
-
-- First launch installs plugin runtime dependencies with `npm`, so Node/npm must be on your `PATH`.
-- For higher limits, set `CURLMD_API_KEY` or run `curl.md auth login`.
-
## Documentation
For full documentation, visit [curl.md/docs](https://curl.md/docs/plugins/claude)
diff --git a/plugins/pi/src/extension.test.ts b/plugins/pi/src/extension.test.ts
index 5b914646..c483944e 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))
@@ -658,7 +658,7 @@ test('fetches markdown from curl.md anonymously', async () => {
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('fresh=')
expect(requests[0]?.url).toContain('keywords=pricing%2Cbilling')
expect(requests[0]?.url).toContain('mode=rush')
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/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/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',
From 575b5e1fcbef495aab1a9ed8e1f04c6e281072ef Mon Sep 17 00:00:00 2001
From: tmm
Date: Thu, 23 Apr 2026 23:56:39 -0400
Subject: [PATCH 04/14] refactor: setup
---
.github/workflows/check.yml | 28 +-
.github/workflows/preview_deploy.yml | 3 +-
.github/workflows/production.yml | 3 +-
docs/plugins/claude.mdx | 29 +-
docs/plugins/opencode.mdx | 9 +-
package.json | 3 +-
plugins/claude/.claude-plugin/plugin.json | 29 +-
plugins/claude/README.md | 4 +
plugins/claude/package.json | 5 +-
plugins/claude/scripts/redirect-webfetch.sh | 17 +
plugins/claude/src/plugin.test.ts | 122 +++++++
plugins/claude/src/server.test.ts | 345 ++++++++++++++++++++
plugins/claude/src/server.ts | 24 +-
plugins/opencode/src/server.ts | 4 +-
pnpm-lock.yaml | 6 +-
pnpm-workspace.yaml | 1 +
public/claude.json | 17 +-
scripts/syncClaude.ts | 64 ++++
src/routes/docs/-render.tsx | 2 +
19 files changed, 672 insertions(+), 43 deletions(-)
create mode 100644 plugins/claude/scripts/redirect-webfetch.sh
create mode 100644 plugins/claude/src/plugin.test.ts
create mode 100644 plugins/claude/src/server.test.ts
create mode 100644 scripts/syncClaude.ts
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 5cb33ee1..6574d362 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -69,6 +69,7 @@ jobs:
- name: Generate code
run: |
+ pnpm gen:claude
pnpm preconstruct
pnpm gen:types
pnpm db:codegen
@@ -103,13 +104,32 @@ jobs:
- name: Test
run: pnpm test --project cli
- - name: Plugin tests
+ - 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
+ uses: ./.github/actions/setup-pnpm
+
+ - name: Test
run: pnpm test --project plugins:amp --project plugins:claude --project plugins:opencode --project plugins:pi
- - name: Build
+ - name: Validate Claude plugin manifest
run: |
- pnpm --filter curl.md build
- pnpm --filter @curl.md/amp build
+ cd plugins/claude
+ node node_modules/@anthropic-ai/claude-code/install.cjs
+ ./node_modules/.bin/claude plugin validate .
+
+ - name: Build
+ run: pnpm --filter @curl.md/amp build
e2e:
name: 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/docs/plugins/claude.mdx b/docs/plugins/claude.mdx
index 294d43a4..0953a856 100644
--- a/docs/plugins/claude.mdx
+++ b/docs/plugins/claude.mdx
@@ -7,7 +7,7 @@ import packageJson from '../../plugins/claude/package.json'
# Claude
-First-party [Claude Code](https://code.claude.com) plugin that adds a `curl_md` MCP tool and `/curl-md:fetch` slash skill.
+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.
/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 cli/src/bin.ts",
@@ -33,7 +34,7 @@
"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/claude/.claude-plugin/plugin.json b/plugins/claude/.claude-plugin/plugin.json
index 127e4105..2b5611b7 100644
--- a/plugins/claude/.claude-plugin/plugin.json
+++ b/plugins/claude/.claude-plugin/plugin.json
@@ -1,11 +1,32 @@
{
- "name": "curl-md",
- "description": "Use curl.md inside Claude Code for low-token web fetches.",
- "version": "0.0.1",
"author": {
"name": "curl.md"
},
+ "description": "Use curl.md inside Claude Code for low-token web fetches.",
"homepage": "https://curl.md/docs/plugins/claude",
+ "hooks": {
+ "PreToolUse": [
+ {
+ "hooks": [
+ {
+ "command": "sh \"${CLAUDE_PLUGIN_ROOT}/scripts/redirect-webfetch.sh\"",
+ "type": "command"
+ }
+ ],
+ "matcher": "WebFetch"
+ }
+ ]
+ },
+ "license": "MIT",
+ "name": "curl-md",
"repository": "https://github.com/wevm/curl.md/tree/main/plugins/claude",
- "license": "MIT"
+ "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/README.md b/plugins/claude/README.md
index 5667af86..4146ab56 100644
--- a/plugins/claude/README.md
+++ b/plugins/claude/README.md
@@ -31,6 +31,10 @@ claude plugin install curl-md@curl-md
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/package.json b/plugins/claude/package.json
index f3d66732..2f6ce0fe 100644
--- a/plugins/claude/package.json
+++ b/plugins/claude/package.json
@@ -34,11 +34,14 @@
"check:types": "tsgo --noEmit"
},
"dependencies": {
- "@modelcontextprotocol/sdk": "1.29.0",
+ "@modelcontextprotocol/server": "2.0.0-alpha.2",
"curl.md": "0.0.20",
"zod": "^4.3.6"
},
"engines": {
"node": ">=22.0.0"
+ },
+ "devDependencies": {
+ "@anthropic-ai/claude-code": "^2.1.118"
}
}
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/src/plugin.test.ts b/plugins/claude/src/plugin.test.ts
new file mode 100644
index 00000000..b01952da
--- /dev/null
+++ b/plugins/claude/src/plugin.test.ts
@@ -0,0 +1,122 @@
+import { execFileSync } from 'node:child_process'
+import { readFileSync } from 'node:fs'
+import path from 'node:path'
+import { expect, test } from 'vitest'
+
+const pluginRoot = path.resolve(import.meta.dirname, '..')
+const marketplaceJsonPath = path.resolve(pluginRoot, '../../public/claude.json')
+const packageJsonPath = path.join(pluginRoot, 'package.json')
+const pluginJsonPath = path.join(pluginRoot, '.claude-plugin', 'plugin.json')
+const redirectScriptPath = path.join(pluginRoot, 'scripts', 'redirect-webfetch.sh')
+
+test('marketplace manifest stays in sync with package and plugin metadata', () => {
+ 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('plugin manifest registers the opt-in WebFetch redirect hook', () => {
+ const pluginJson = JSON.parse(readFileSync(pluginJsonPath, 'utf8')) as {
+ hooks?: {
+ PreToolUse?: Array<{
+ hooks?: Array<{ command?: string; type?: string }>
+ matcher?: string
+ }>
+ }
+ userConfig?: {
+ webfetch_redirect?: {
+ default?: boolean
+ description?: string
+ title?: string
+ type?: string
+ }
+ }
+ }
+
+ expect(pluginJson.hooks?.PreToolUse).toEqual([
+ {
+ hooks: [
+ {
+ command: 'sh "${CLAUDE_PLUGIN_ROOT}/scripts/redirect-webfetch.sh"',
+ type: 'command',
+ },
+ ],
+ matcher: 'WebFetch',
+ },
+ ])
+ expect(pluginJson.userConfig?.webfetch_redirect).toEqual({
+ 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',
+ })
+})
+
+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.',
+ },
+ })
+})
diff --git a/plugins/claude/src/server.test.ts b/plugins/claude/src/server.test.ts
new file mode 100644
index 00000000..3a69ba34
--- /dev/null
+++ b/plugins/claude/src/server.test.ts
@@ -0,0 +1,345 @@
+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', 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
index 682694e1..6c854313 100644
--- a/plugins/claude/src/server.ts
+++ b/plugins/claude/src/server.ts
@@ -1,6 +1,5 @@
import process from 'node:process'
-import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
-import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
+import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'
import { createClient, defaultBaseUrl } from 'curl.md'
import { Auth, Session } from 'curl.md/internal'
import { z } from 'zod'
@@ -19,7 +18,7 @@ server.registerTool(
{
title: 'curl.md',
description: 'Read a web page through curl.md and return markdown optimized for coding agents.',
- inputSchema: {
+ inputSchema: z.object({
url: z
.string()
.describe(
@@ -49,27 +48,18 @@ server.registerTool(
.describe(
'Bypass curl.md cache when freshness matters, such as changelogs, release notes, or recently updated docs.',
),
- },
+ }),
},
async (input) => {
try {
const result = await fetchPage(input)
-
return {
- content: [
- {
- type: 'text' as const,
- text: result.markdown,
- },
- ],
+ content: [{ type: 'text' as const, text: result.markdown }],
}
} catch (error) {
return {
content: [
- {
- type: 'text' as const,
- text: error instanceof Error ? error.message : String(error),
- },
+ { type: 'text' as const, text: error instanceof Error ? error.message : String(error) },
],
isError: true,
}
@@ -86,7 +76,7 @@ async function fetchPage(input: {
objective?: string
url: string
}) {
- const url = normalizeUrl(input.url)
+ const url = normalizeURL(input.url)
let authHeaders = await resolver()
let authType: 'anon' | 'api_key' | 'session' = (() => {
@@ -183,7 +173,7 @@ function createHeaders(auth: Auth.Headers | null) {
return headers
}
-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/server.ts b/plugins/opencode/src/server.ts
index a776ff12..df702236 100644
--- a/plugins/opencode/src/server.ts
+++ b/plugins/opencode/src/server.ts
@@ -143,7 +143,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' = (() => {
@@ -254,7 +254,7 @@ async function fetchPage(input: {
}
}
-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/pnpm-lock.yaml b/pnpm-lock.yaml
index fdf59f4d..70779d65 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -307,9 +307,9 @@ importers:
plugins/claude:
dependencies:
- '@modelcontextprotocol/sdk':
- specifier: 1.29.0
- version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)
+ '@modelcontextprotocol/server':
+ specifier: 2.0.0-alpha.2
+ version: 2.0.0-alpha.2(@cfworker/json-schema@4.1.1)
curl.md:
specifier: 0.0.20
version: 0.0.20
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index c4ec075e..4c318fc9 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -24,6 +24,7 @@ minimumReleaseAgeExclude:
- incur
onlyBuiltDependencies:
+ - '@anthropic-ai/claude-code'
- bun
- msw
- opencode-ai
diff --git a/public/claude.json b/public/claude.json
index c9f9f0f7..c09f71f7 100644
--- a/public/claude.json
+++ b/public/claude.json
@@ -5,16 +5,19 @@
},
"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": {
- "source": "npm",
- "package": "@curl.md/claude"
+ "package": "@curl.md/claude",
+ "source": "npm"
},
- "description": "Use curl.md inside Claude Code for low-token web fetches.",
- "version": "0.0.1",
- "author": {
- "name": "curl.md"
- }
+ "version": "0.0.0"
}
]
}
diff --git a/scripts/syncClaude.ts b/scripts/syncClaude.ts
new file mode 100644
index 00000000..cdb59db4
--- /dev/null
+++ b/scripts/syncClaude.ts
@@ -0,0 +1,64 @@
+import fs from 'node:fs/promises'
+import path from 'node:path'
+
+console.log('Syncing Claude plugin manifests.')
+
+const packageJsonPath = path.join(process.cwd(), 'plugins/claude/package.json')
+const pluginJsonPath = path.join(process.cwd(), 'plugins/claude/.claude-plugin/plugin.json')
+const marketplaceJsonPath = path.join(process.cwd(), '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/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
From b63a82e84d9d9627549e096cc9cdecc9d5d5ffa1 Mon Sep 17 00:00:00 2001
From: tmm
Date: Fri, 24 Apr 2026 00:00:13 -0400
Subject: [PATCH 05/14] chore: lock
---
plugins/claude/package.json | 3 +-
pnpm-lock.yaml | 221 ++++++++++++++++++++++++++++++------
2 files changed, 188 insertions(+), 36 deletions(-)
diff --git a/plugins/claude/package.json b/plugins/claude/package.json
index 2f6ce0fe..8630890f 100644
--- a/plugins/claude/package.json
+++ b/plugins/claude/package.json
@@ -27,7 +27,8 @@
"README.md",
"scripts/**/*.sh",
"skills/**",
- "src/**/*.ts"
+ "src/**/*.ts",
+ "!src/**/*.test.ts"
],
"type": "module",
"scripts": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 70779d65..b4d13bce 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -316,6 +316,10 @@ importers:
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:
@@ -350,6 +354,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
@@ -6619,6 +6672,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
@@ -8008,6 +8096,7 @@ snapshots:
'@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:
@@ -9667,6 +9756,7 @@ snapshots:
dependencies:
mime-types: 3.0.2
negotiator: 1.0.0
+ optional: true
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
@@ -9859,6 +9949,7 @@ snapshots:
type-is: 2.0.1
transitivePeerDependencies:
- supports-color
+ optional: true
boolbase@1.0.0: {}
@@ -9920,7 +10011,8 @@ snapshots:
byline@5.0.0: {}
- bytes@3.1.2: {}
+ bytes@3.1.2:
+ optional: true
c12@3.3.3:
dependencies:
@@ -9941,11 +10033,13 @@ snapshots:
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: {}
@@ -10087,17 +10181,21 @@ snapshots:
consola@3.4.2: {}
- content-disposition@1.1.0: {}
+ content-disposition@1.1.0:
+ optional: true
- content-type@1.0.5: {}
+ content-type@1.0.5:
+ optional: true
convert-source-map@2.0.0: {}
cookie-es@2.0.0: {}
- cookie-signature@1.2.2: {}
+ cookie-signature@1.2.2:
+ optional: true
- cookie@0.7.2: {}
+ cookie@0.7.2:
+ optional: true
cookie@1.1.1: {}
@@ -10107,6 +10205,7 @@ snapshots:
dependencies:
object-assign: 4.1.1
vary: 1.1.2
+ optional: true
cosmiconfig@8.3.6(typescript@5.9.3):
dependencies:
@@ -10216,7 +10315,8 @@ snapshots:
escodegen: 2.1.0
esprima: 4.0.1
- depd@2.0.0: {}
+ depd@2.0.0:
+ optional: true
dequal@2.0.3: {}
@@ -10293,6 +10393,7 @@ snapshots:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
+ optional: true
eastasianwidth@0.2.0: {}
@@ -10300,7 +10401,8 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
- ee-first@1.1.1: {}
+ ee-first@1.1.1:
+ optional: true
effect@4.0.0-beta.48:
dependencies:
@@ -10332,7 +10434,8 @@ snapshots:
transitivePeerDependencies:
- hono
- encodeurl@2.0.0: {}
+ encodeurl@2.0.0:
+ optional: true
encoding-sniffer@0.2.1:
dependencies:
@@ -10367,15 +10470,18 @@ snapshots:
error-stack-parser-es@1.0.5: {}
- es-define-property@1.0.1: {}
+ es-define-property@1.0.1:
+ optional: true
- es-errors@1.3.0: {}
+ 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: {}
@@ -10453,7 +10559,8 @@ snapshots:
escalade@3.2.0: {}
- escape-html@1.0.3: {}
+ escape-html@1.0.3:
+ optional: true
escape-string-regexp@5.0.0: {}
@@ -10510,7 +10617,8 @@ snapshots:
esutils@2.0.3: {}
- etag@1.8.1: {}
+ etag@1.8.1:
+ optional: true
event-target-shim@5.0.1: {}
@@ -10524,11 +10632,13 @@ snapshots:
events@3.3.0: {}
- eventsource-parser@3.0.8: {}
+ eventsource-parser@3.0.8:
+ optional: true
eventsource@3.0.7:
dependencies:
eventsource-parser: 3.0.8
+ optional: true
expect-type@1.3.0: {}
@@ -10536,6 +10646,7 @@ snapshots:
dependencies:
express: 5.2.1
ip-address: 10.1.0
+ optional: true
express@5.2.1:
dependencies:
@@ -10569,6 +10680,7 @@ snapshots:
vary: 1.1.2
transitivePeerDependencies:
- supports-color
+ optional: true
exsolve@1.0.8: {}
@@ -10665,6 +10777,7 @@ snapshots:
statuses: 2.0.2
transitivePeerDependencies:
- supports-color
+ optional: true
find-my-way-ts@0.1.6: {}
@@ -10688,9 +10801,11 @@ snapshots:
dependencies:
fetch-blob: 3.2.0
- forwarded@0.2.0: {}
+ forwarded@0.2.0:
+ optional: true
- fresh@2.0.0: {}
+ fresh@2.0.0:
+ optional: true
fs-constants@1.0.0: {}
@@ -10712,7 +10827,8 @@ snapshots:
fsevents@2.3.3:
optional: true
- function-bind@1.1.2: {}
+ function-bind@1.1.2:
+ optional: true
gaxios@7.1.4:
dependencies:
@@ -10748,6 +10864,7 @@ snapshots:
has-symbols: 1.1.0
hasown: 2.0.3
math-intrinsics: 1.1.0
+ optional: true
get-port@7.1.0: {}
@@ -10755,6 +10872,7 @@ snapshots:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
+ optional: true
get-stream@5.2.0:
dependencies:
@@ -10824,7 +10942,8 @@ snapshots:
google-logging-utils@1.1.3: {}
- gopd@1.2.0: {}
+ gopd@1.2.0:
+ optional: true
graceful-fs@4.2.11: {}
@@ -10837,11 +10956,13 @@ snapshots:
has-flag@4.0.0: {}
- has-symbols@1.1.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:
@@ -11025,6 +11146,7 @@ snapshots:
setprototypeof: 1.2.0
statuses: 2.0.2
toidentifier: 1.0.1
+ optional: true
http-proxy-agent@7.0.2:
dependencies:
@@ -11084,7 +11206,8 @@ snapshots:
ip-address@10.1.0: {}
- ipaddr.js@1.9.1: {}
+ ipaddr.js@1.9.1:
+ optional: true
is-alphabetical@2.0.1: {}
@@ -11121,7 +11244,8 @@ snapshots:
is-plain-obj@4.1.0: {}
- is-promise@4.0.0: {}
+ is-promise@4.0.0:
+ optional: true
is-stream@2.0.1: {}
@@ -11145,7 +11269,8 @@ snapshots:
jiti@2.6.1: {}
- jose@6.2.2: {}
+ jose@6.2.2:
+ optional: true
js-tokens@4.0.0: {}
@@ -11175,7 +11300,8 @@ snapshots:
json-schema-traverse@1.0.0: {}
- json-schema-typed@8.0.2: {}
+ json-schema-typed@8.0.2:
+ optional: true
json-with-bigint@3.5.3: {}
@@ -11381,7 +11507,8 @@ snapshots:
marked@15.0.12: {}
- math-intrinsics@1.1.0: {}
+ math-intrinsics@1.1.0:
+ optional: true
mdast-util-directive@3.1.0:
dependencies:
@@ -11571,9 +11698,11 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
- media-typer@1.1.0: {}
+ media-typer@1.1.0:
+ optional: true
- merge-descriptors@2.0.0: {}
+ merge-descriptors@2.0.0:
+ optional: true
merge2@1.4.1: {}
@@ -11976,7 +12105,8 @@ snapshots:
nanoid@5.1.6: {}
- negotiator@1.0.0: {}
+ negotiator@1.0.0:
+ optional: true
netmask@2.1.0: {}
@@ -12022,7 +12152,8 @@ snapshots:
object-assign@4.1.1: {}
- object-inspect@1.13.4: {}
+ object-inspect@1.13.4:
+ optional: true
obug@2.1.1: {}
@@ -12037,6 +12168,7 @@ snapshots:
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
+ optional: true
once@1.4.0:
dependencies:
@@ -12241,7 +12373,8 @@ snapshots:
dependencies:
entities: 6.0.1
- parseurl@1.3.3: {}
+ parseurl@1.3.3:
+ optional: true
partial-json@0.1.7: {}
@@ -12263,7 +12396,8 @@ snapshots:
path-to-regexp@6.3.0: {}
- path-to-regexp@8.4.0: {}
+ path-to-regexp@8.4.0:
+ optional: true
path-type@4.0.0: {}
@@ -12281,7 +12415,8 @@ snapshots:
pify@4.0.1: {}
- pkce-challenge@5.0.1: {}
+ pkce-challenge@5.0.1:
+ optional: true
pkg-types@1.3.1:
dependencies:
@@ -12361,6 +12496,7 @@ snapshots:
dependencies:
forwarded: 0.2.0
ipaddr.js: 1.9.1
+ optional: true
proxy-agent@6.5.0:
dependencies:
@@ -12387,12 +12523,14 @@ snapshots:
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: {}
+ range-parser@1.2.1:
+ optional: true
raw-body@3.0.2:
dependencies:
@@ -12400,6 +12538,7 @@ snapshots:
http-errors: 2.0.1
iconv-lite: 0.7.2
unpipe: 1.0.0
+ optional: true
rc9@2.1.2:
dependencies:
@@ -12736,6 +12875,7 @@ snapshots:
path-to-regexp: 8.4.0
transitivePeerDependencies:
- supports-color
+ optional: true
run-parallel@1.2.0:
dependencies:
@@ -12770,6 +12910,7 @@ snapshots:
statuses: 2.0.2
transitivePeerDependencies:
- supports-color
+ optional: true
seroval-plugins@1.5.1(seroval@1.5.1):
dependencies:
@@ -12785,8 +12926,10 @@ snapshots:
send: 1.2.1
transitivePeerDependencies:
- supports-color
+ optional: true
- setprototypeof@1.2.0: {}
+ setprototypeof@1.2.0:
+ optional: true
sharp@0.34.5:
dependencies:
@@ -12840,6 +12983,7 @@ snapshots:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
+ optional: true
side-channel-map@1.0.1:
dependencies:
@@ -12847,6 +12991,7 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
+ optional: true
side-channel-weakmap@1.0.2:
dependencies:
@@ -12855,6 +13000,7 @@ snapshots:
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-map: 1.0.1
+ optional: true
side-channel@1.1.0:
dependencies:
@@ -12863,6 +13009,7 @@ snapshots:
side-channel-list: 1.0.1
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
+ optional: true
siginfo@2.0.0: {}
@@ -13174,7 +13321,8 @@ snapshots:
dependencies:
is-number: 7.0.0
- toidentifier@1.0.1: {}
+ toidentifier@1.0.1:
+ optional: true
token-types@6.1.2:
dependencies:
@@ -13233,6 +13381,7 @@ snapshots:
content-type: 1.0.5
media-typer: 1.1.0
mime-types: 3.0.2
+ optional: true
typescript@5.9.3:
optional: true
@@ -13330,7 +13479,8 @@ snapshots:
universalify@0.1.2: {}
- unpipe@1.0.0: {}
+ unpipe@1.0.0:
+ optional: true
unplugin-auto-import@21.0.0:
dependencies:
@@ -13379,7 +13529,8 @@ snapshots:
uuid@14.0.0: {}
- vary@1.1.2: {}
+ vary@1.1.2:
+ optional: true
vfile-location@5.0.3:
dependencies:
From b288799fed1fda00929a148786245c4cecb4a7c8 Mon Sep 17 00:00:00 2001
From: tmm
Date: Fri, 24 Apr 2026 10:34:59 -0400
Subject: [PATCH 06/14] chore: changeset
---
.changeset/bright-ravens-jog.md | 5 +++++
.github/workflows/check.yml | 3 +++
config/knip.json | 4 +++-
3 files changed, 11 insertions(+), 1 deletion(-)
create mode 100644 .changeset/bright-ravens-jog.md
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/workflows/check.yml b/.github/workflows/check.yml
index 6574d362..f0feb859 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -119,6 +119,9 @@ jobs:
- name: Setup
uses: ./.github/actions/setup-pnpm
+ - name: Preconstruct
+ run: pnpm preconstruct
+
- name: Test
run: pnpm test --project plugins:amp --project plugins:claude --project plugins:opencode --project plugins:pi
diff --git a/config/knip.json b/config/knip.json
index ef92e534..5f2e0711 100644
--- a/config/knip.json
+++ b/config/knip.json
@@ -16,7 +16,9 @@
"ignore": ["**/*.test.ts"]
},
"plugins/claude": {
- "entry": ["src/server.ts"]
+ "entry": ["src/server.ts"],
+ "ignore": ["**/*.test.ts"],
+ "ignoreDependencies": ["@anthropic-ai/claude-code"]
},
"plugins/opencode": {
"ignore": ["**/*.test.ts"]
From cee4f99556f8ecd056505ceafd8fa99c1c624e13 Mon Sep 17 00:00:00 2001
From: tmm
Date: Fri, 24 Apr 2026 11:05:08 -0400
Subject: [PATCH 07/14] ci: up
---
.github/actions/setup-playwright/action.yml | 17 +++
.github/actions/setup-pnpm/action.yml | 2 +-
.github/workflows/check.yml | 35 ++----
plugins/claude/src/plugin.test.ts | 125 ++++++++------------
4 files changed, 83 insertions(+), 96 deletions(-)
create mode 100644 .github/actions/setup-playwright/action.yml
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 f0feb859..c8234da2 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
@@ -78,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
@@ -92,7 +95,7 @@ jobs:
with:
persist-credentials: false
- - name: Setup
+ - name: Setup pnpm
uses: ./.github/actions/setup-pnpm
- name: Check dependencies
@@ -116,21 +119,17 @@ jobs:
with:
persist-credentials: false
- - name: Setup
+ - name: Setup pnpm
uses: ./.github/actions/setup-pnpm
- name: Preconstruct
- run: pnpm 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: Validate Claude plugin manifest
- run: |
- cd plugins/claude
- node node_modules/@anthropic-ai/claude-code/install.cjs
- ./node_modules/.bin/claude plugin validate .
-
- name: Build
run: pnpm --filter @curl.md/amp build
@@ -143,19 +142,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/plugins/claude/src/plugin.test.ts b/plugins/claude/src/plugin.test.ts
index b01952da..3769fb73 100644
--- a/plugins/claude/src/plugin.test.ts
+++ b/plugins/claude/src/plugin.test.ts
@@ -4,12 +4,61 @@ import path from 'node:path'
import { expect, test } from 'vitest'
const pluginRoot = path.resolve(import.meta.dirname, '..')
-const marketplaceJsonPath = path.resolve(pluginRoot, '../../public/claude.json')
-const packageJsonPath = path.join(pluginRoot, 'package.json')
-const pluginJsonPath = path.join(pluginRoot, '.claude-plugin', 'plugin.json')
const redirectScriptPath = path.join(pluginRoot, 'scripts', 'redirect-webfetch.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 }
@@ -50,73 +99,3 @@ test('marketplace manifest stays in sync with package and plugin metadata', () =
],
})
})
-
-test('plugin manifest registers the opt-in WebFetch redirect hook', () => {
- const pluginJson = JSON.parse(readFileSync(pluginJsonPath, 'utf8')) as {
- hooks?: {
- PreToolUse?: Array<{
- hooks?: Array<{ command?: string; type?: string }>
- matcher?: string
- }>
- }
- userConfig?: {
- webfetch_redirect?: {
- default?: boolean
- description?: string
- title?: string
- type?: string
- }
- }
- }
-
- expect(pluginJson.hooks?.PreToolUse).toEqual([
- {
- hooks: [
- {
- command: 'sh "${CLAUDE_PLUGIN_ROOT}/scripts/redirect-webfetch.sh"',
- type: 'command',
- },
- ],
- matcher: 'WebFetch',
- },
- ])
- expect(pluginJson.userConfig?.webfetch_redirect).toEqual({
- 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',
- })
-})
-
-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.',
- },
- })
-})
From 5c6e8b12343a2b1702e5d5a0347a7618dfff15e5 Mon Sep 17 00:00:00 2001
From: tmm
Date: Fri, 24 Apr 2026 17:15:19 -0400
Subject: [PATCH 08/14] chore: up
---
cli/src/cli.test.ts | 1 +
cli/src/cli.ts | 391 +++++++++---------
docs/guide/agent-usage.mdx | 2 +-
docs/guide/cli.mdx | 48 +++
docs/guide/plugins.mdx | 2 +-
package.json | 2 +-
plugins/amp/src/plugin.test.ts | 48 ++-
plugins/amp/src/plugin.ts | 2 +-
plugins/claude/package.json | 2 +-
.../claude/scripts/sync.ts | 9 +-
plugins/opencode/src/server.test.ts | 9 +-
plugins/opencode/src/tui.ts | 4 +-
plugins/pi/src/extension.test.ts | 5 +-
plugins/pi/src/index.ts | 17 +-
pnpm-lock.yaml | 15 +-
15 files changed, 332 insertions(+), 225 deletions(-)
rename scripts/syncClaude.ts => plugins/claude/scripts/sync.ts (81%)
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/docs/guide/agent-usage.mdx b/docs/guide/agent-usage.mdx
index 5fa20e87..cce70c77 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) when one exists, or use the [CLI's skills](/docs/guide/cli#agents) and [MCP mode](/docs/guide/cli#mcp) to integrate more deeply.
:::
## Single-Page Markdown Access
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 c1c67aa9..2932c6ec 100644
--- a/docs/guide/plugins.mdx
+++ b/docs/guide/plugins.mdx
@@ -62,7 +62,7 @@ const res = await client.fetch('example.com')
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/package.json b/package.json
index f7d7bef3..2391e2f3 100644
--- a/package.json
+++ b/package.json
@@ -25,7 +25,7 @@
"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 scripts/syncClaude.ts",
+ "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 cli/src/bin.ts",
diff --git a/plugins/amp/src/plugin.test.ts b/plugins/amp/src/plugin.test.ts
index 4474cf02..1c5f82d7 100644
--- a/plugins/amp/src/plugin.test.ts
+++ b/plugins/amp/src/plugin.test.ts
@@ -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,10 +232,18 @@ 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',
@@ -225,7 +256,7 @@ test('fetches anonymously and returns expected shape', async () => {
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..17a7fac9 100644
--- a/plugins/amp/src/plugin.ts
+++ b/plugins/amp/src/plugin.ts
@@ -120,7 +120,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),
diff --git a/plugins/claude/package.json b/plugins/claude/package.json
index 8630890f..6c7111c0 100644
--- a/plugins/claude/package.json
+++ b/plugins/claude/package.json
@@ -36,7 +36,7 @@
},
"dependencies": {
"@modelcontextprotocol/server": "2.0.0-alpha.2",
- "curl.md": "0.0.20",
+ "curl.md": "workspace:*",
"zod": "^4.3.6"
},
"engines": {
diff --git a/scripts/syncClaude.ts b/plugins/claude/scripts/sync.ts
similarity index 81%
rename from scripts/syncClaude.ts
rename to plugins/claude/scripts/sync.ts
index cdb59db4..3ff04a52 100644
--- a/scripts/syncClaude.ts
+++ b/plugins/claude/scripts/sync.ts
@@ -3,9 +3,12 @@ import path from 'node:path'
console.log('Syncing Claude plugin manifests.')
-const packageJsonPath = path.join(process.cwd(), 'plugins/claude/package.json')
-const pluginJsonPath = path.join(process.cwd(), 'plugins/claude/.claude-plugin/plugin.json')
-const marketplaceJsonPath = path.join(process.cwd(), 'public/claude.json')
+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
diff --git a/plugins/opencode/src/server.test.ts b/plugins/opencode/src/server.test.ts
index 806cdab4..b833cd5c 100644
--- a/plugins/opencode/src/server.test.ts
+++ b/plugins/opencode/src/server.test.ts
@@ -75,12 +75,13 @@ 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')
@@ -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',
})
})
diff --git a/plugins/opencode/src/tui.ts b/plugins/opencode/src/tui.ts
index 45cea5ac..a41fa591 100644
--- a/plugins/opencode/src/tui.ts
+++ b/plugins/opencode/src/tui.ts
@@ -165,7 +165,7 @@ 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.',
@@ -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/src/extension.test.ts b/plugins/pi/src/extension.test.ts
index c483944e..a159e482 100644
--- a/plugins/pi/src/extension.test.ts
+++ b/plugins/pi/src/extension.test.ts
@@ -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%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..af45abee 100644
--- a/plugins/pi/src/index.ts
+++ b/plugins/pi/src/index.ts
@@ -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,
@@ -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}`,
@@ -490,7 +491,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 b4d13bce..f6f78cd9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -311,8 +311,8 @@ importers:
specifier: 2.0.0-alpha.2
version: 2.0.0-alpha.2(@cfworker/json-schema@4.1.1)
curl.md:
- specifier: 0.0.20
- version: 0.0.20
+ specifier: workspace:*
+ version: link:../../cli
zod:
specifier: ^4.3.6
version: 4.3.6
@@ -3806,11 +3806,6 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
- curl.md@0.0.20:
- resolution: {integrity: sha512-2uO17rpTwXMxevVLf57ZrTVT/0+oj6cZcgkjZeWQ/h6/6W1w+MSK6cMdDTmSxjZDTa/osq3djnjUCsY/W+l/QA==}
- engines: {node: '>=22.0.0'}
- hasBin: true
-
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
@@ -10247,12 +10242,6 @@ snapshots:
csstype@3.2.3: {}
- curl.md@0.0.20:
- dependencies:
- hono: 4.12.14
- incur: 0.4.2
- picocolors: 1.1.1
-
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
From daf3535e8d343ea18a0ef953bec3e5e49de5baaf Mon Sep 17 00:00:00 2001
From: tmm
Date: Fri, 24 Apr 2026 18:03:01 -0400
Subject: [PATCH 09/14] chore: copy tweaks
---
.github/workflows/check.yml | 4 +-
.github/workflows/pull_request.yml | 1 +
docs/guide/agent-usage.mdx | 2 +-
package.json | 2 +-
plugins/amp/src/plugin.test.ts | 4 +-
plugins/amp/src/plugin.ts | 22 ++++------
plugins/claude/package.json | 12 +++---
plugins/claude/scripts/build.ts | 31 ++++++++++++++
plugins/claude/scripts/start.sh | 15 +++----
plugins/claude/skills/fetch/SKILL.md | 4 +-
plugins/claude/src/plugin.test.ts | 62 +++++++++++++++++++++++++++-
plugins/claude/src/server.test.ts | 4 +-
plugins/claude/src/server.ts | 30 ++++----------
plugins/opencode/src/server.test.ts | 4 +-
plugins/opencode/src/server.ts | 27 +++++-------
plugins/opencode/src/tui.test.ts | 28 ++++++-------
plugins/opencode/src/tui.ts | 42 +++++++++----------
plugins/pi/src/e2e.test.ts | 2 +-
plugins/pi/src/index.ts | 31 ++++++--------
src/routes/-home.tsx | 16 +++----
20 files changed, 201 insertions(+), 142 deletions(-)
create mode 100644 plugins/claude/scripts/build.ts
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index c8234da2..a58c1ff9 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -131,7 +131,9 @@ jobs:
run: pnpm test --project plugins:amp --project plugins:claude --project plugins:opencode --project plugins:pi
- name: Build
- run: pnpm --filter @curl.md/amp build
+ run: |
+ pnpm --filter @curl.md/amp build
+ pnpm --filter @curl.md/claude build
e2e:
name: E2E
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
index 22484857..03852404 100644
--- a/.github/workflows/pull_request.yml
+++ b/.github/workflows/pull_request.yml
@@ -44,6 +44,7 @@ jobs:
run: |
pnpm --filter curl.md build
pnpm --filter @curl.md/amp build
+ pnpm --filter @curl.md/claude build
- name: Publish preview
run: |
diff --git a/docs/guide/agent-usage.mdx b/docs/guide/agent-usage.mdx
index cce70c77..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 a native [plugin](/docs/guide/plugins) when one exists, or use the [CLI's skills](/docs/guide/cli#agents) and [MCP mode](/docs/guide/cli#mcp) to integrate more deeply.
+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/package.json b/package.json
index 2391e2f3..441d063f 100644
--- a/package.json
+++ b/package.json
@@ -36,7 +36,7 @@
"preinstall": "pnpx only-allow pnpm",
"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:publish": "pnpm --filter curl.md build && pnpm --filter @curl.md/amp build && pnpm --filter @curl.md/claude build && node --experimental-strip-types scripts/formatPackage.ts && pnpm changeset publish && node --experimental-strip-types scripts/restorePackage.ts",
"changeset:version": "pnpm changeset version",
"test": "vitest --config test/vitest.config.ts",
"test:e2e": "playwright test --config test/e2e.config.ts"
diff --git a/plugins/amp/src/plugin.test.ts b/plugins/amp/src/plugin.test.ts
index 1c5f82d7..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)',
},
})
})
@@ -250,7 +250,7 @@ test('fetches anonymously and returns expected shape', async () => {
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',
diff --git a/plugins/amp/src/plugin.ts b/plugins/amp/src/plugin.ts
index 17a7fac9..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'],
@@ -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/package.json b/plugins/claude/package.json
index 6c7111c0..4ae1e6af 100644
--- a/plugins/claude/package.json
+++ b/plugins/claude/package.json
@@ -25,24 +25,22 @@
".mcp.json",
"CHANGELOG.md",
"README.md",
+ "dist/**",
"scripts/**/*.sh",
- "skills/**",
- "src/**/*.ts",
- "!src/**/*.test.ts"
+ "skills/**"
],
"type": "module",
"scripts": {
+ "build": "node --experimental-strip-types scripts/build.ts",
"check:types": "tsgo --noEmit"
},
- "dependencies": {
+ "devDependencies": {
+ "@anthropic-ai/claude-code": "^2.1.118",
"@modelcontextprotocol/server": "2.0.0-alpha.2",
"curl.md": "workspace:*",
"zod": "^4.3.6"
},
"engines": {
"node": ">=22.0.0"
- },
- "devDependencies": {
- "@anthropic-ai/claude-code": "^2.1.118"
}
}
diff --git a/plugins/claude/scripts/build.ts b/plugins/claude/scripts/build.ts
new file mode 100644
index 00000000..4cfd5f2b
--- /dev/null
+++ b/plugins/claude/scripts/build.ts
@@ -0,0 +1,31 @@
+import { execFileSync } from 'node:child_process'
+import fs from 'node:fs/promises'
+import path from 'node:path'
+
+console.log('Building Claude plugin bundle.')
+
+const pluginRoot = path.resolve(import.meta.dirname, '..')
+const repoRoot = path.resolve(pluginRoot, '../..')
+const outdir = path.join(pluginRoot, 'dist')
+
+await fs.rm(outdir, { force: true, recursive: true })
+
+execFileSync(
+ process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
+ [
+ 'exec',
+ 'esbuild',
+ path.join(pluginRoot, 'src', 'server.ts'),
+ '--alias:curl.md/internal=' + path.join(repoRoot, 'cli', 'src', 'exports', 'internal.ts'),
+ '--alias:curl.md=' + path.join(repoRoot, 'cli', 'src', 'exports', 'index.ts'),
+ '--bundle',
+ '--format=esm',
+ '--legal-comments=none',
+ '--outfile=' + path.join(outdir, 'server.js'),
+ '--platform=node',
+ '--target=node22',
+ ],
+ { stdio: 'inherit' },
+)
+
+console.log('Done.')
diff --git a/plugins/claude/scripts/start.sh b/plugins/claude/scripts/start.sh
index ea140da1..51c9de1e 100644
--- a/plugins/claude/scripts/start.sh
+++ b/plugins/claude/scripts/start.sh
@@ -3,12 +3,13 @@ set -eu
plugin_root=${CLAUDE_PLUGIN_ROOT:-$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)}
-if [ ! -d "$plugin_root/node_modules/@modelcontextprotocol" ] || [ ! -d "$plugin_root/node_modules/curl.md" ] || [ ! -d "$plugin_root/node_modules/zod" ]; then
- echo "Installing curl.md Claude plugin dependencies..." >&2
- (
- cd "$plugin_root"
- npm install --ignore-scripts --no-audit --no-fund --omit=dev --silent
- )
+if [ -f "$plugin_root/src/server.ts" ]; then
+ exec node --experimental-strip-types --no-warnings "$plugin_root/src/server.ts"
fi
-exec node --experimental-strip-types --no-warnings "$plugin_root/src/server.ts"
+if [ -f "$plugin_root/dist/server.js" ]; then
+ exec node "$plugin_root/dist/server.js"
+fi
+
+echo "curl.md Claude plugin entrypoint not found. Expected src/server.ts for local development or dist/server.js for published installs." >&2
+exit 1
diff --git a/plugins/claude/skills/fetch/SKILL.md b/plugins/claude/skills/fetch/SKILL.md
index 95c59aa2..ab976aea 100644
--- a/plugins/claude/skills/fetch/SKILL.md
+++ b/plugins/claude/skills/fetch/SKILL.md
@@ -1,8 +1,8 @@
---
-description: Use curl.md when you need to read a public URL, docs page, changelog, article, or blog post with low-token markdown output.
+description: Use when you need markdown from a public URL, docs page, article, or changelog.
---
-Use the `curl_md` tool to fetch the URL or domain in `$ARGUMENTS` or in the surrounding conversation.
+Use `curl_md` to fetch the URL or domain in `$ARGUMENTS` or the surrounding conversation.
Guidelines:
diff --git a/plugins/claude/src/plugin.test.ts b/plugins/claude/src/plugin.test.ts
index 3769fb73..68daf77f 100644
--- a/plugins/claude/src/plugin.test.ts
+++ b/plugins/claude/src/plugin.test.ts
@@ -1,10 +1,12 @@
import { execFileSync } from 'node:child_process'
-import { readFileSync } from 'node:fs'
+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(
@@ -99,3 +101,61 @@ test('marketplace manifest stays in sync with package and plugin metadata', () =
],
})
})
+
+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, 'dist'), { recursive: true })
+ 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, 'dist', 'server.js'), 'console.log("dist")\n')
+ 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 falls back to bundled dist for published installs', () => {
+ const fixtureDir = fs.mkdtempSync(path.join(os.tmpdir(), 'curlmd-claude-start-'))
+
+ try {
+ const pluginDir = path.join(fixtureDir, 'plugin')
+ fs.mkdirSync(path.join(pluginDir, 'dist'), { recursive: true })
+ fs.mkdirSync(path.join(pluginDir, 'scripts'), { recursive: true })
+ fs.copyFileSync(startScriptPath, path.join(pluginDir, 'scripts', 'start.sh'))
+ fs.writeFileSync(path.join(pluginDir, 'dist', 'server.js'), 'console.log("dist")\n')
+
+ expect(runStartScript(pluginDir)).toEqual([path.join(pluginDir, 'dist', 'server.js')])
+ } finally {
+ fs.rmSync(fixtureDir, { force: true, recursive: true })
+ }
+})
+
+function runStartScript(pluginDir: string) {
+ 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,
+ 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
index 3a69ba34..68b44ec8 100644
--- a/plugins/claude/src/server.test.ts
+++ b/plugins/claude/src/server.test.ts
@@ -118,7 +118,9 @@ test('returns stripped markdown and forwards fetch options', async () => {
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', type: 'text' }] })
+ 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 () => {
diff --git a/plugins/claude/src/server.ts b/plugins/claude/src/server.ts
index 6c854313..8b811a8a 100644
--- a/plugins/claude/src/server.ts
+++ b/plugins/claude/src/server.ts
@@ -17,37 +17,24 @@ server.registerTool(
'curl_md',
{
title: 'curl.md',
- description: 'Read a web page through curl.md and return markdown optimized for coding agents.',
+ description: 'Fetch a URL as markdown.',
inputSchema: z.object({
url: z
.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.'),
objective: z
.string()
.optional()
- .describe(
- 'Specific question or goal to answer from the page. Prefer concrete objectives like "compare pricing tiers" or "find auth header requirements".',
- ),
+ .describe('Specific question to answer from the page. Use when only part matters.'),
keywords: z
.array(z.string())
.optional()
- .describe(
- 'Keywords to pre-filter sections by. Prefer 2-5 distinct terms when only part of a long page matters.',
- ),
+ .describe('Keywords to focus extraction on relevant sections.'),
mode: z
.enum(['rush', 'smart'])
.optional()
- .describe(
- 'rush: lower-latency, best when you already know the section. smart: higher-quality narrowing for long or noisy pages.',
- ),
- fresh: z
- .boolean()
- .optional()
- .describe(
- 'Bypass curl.md cache when freshness matters, such as changelogs, release notes, or recently updated docs.',
- ),
+ .describe('rush: faster. smart: better section selection on long or noisy pages.'),
+ fresh: z.boolean().optional().describe('Bypass cache when freshness matters.'),
}),
},
async (input) => {
@@ -160,10 +147,7 @@ async function fetchPage(input: {
const json = await res.json()
- return {
- markdown: json.content.replace(/\n\n---\n\nPowered by \[curl\.md\]\(https:\/\/curl\.md\)$/, ''),
- url,
- }
+ return { markdown: json.content, url }
}
function createHeaders(auth: Auth.Headers | null) {
diff --git a/plugins/opencode/src/server.test.ts b/plugins/opencode/src/server.test.ts
index b833cd5c..99326d13 100644
--- a/plugins/opencode/src/server.test.ts
+++ b/plugins/opencode/src/server.test.ts
@@ -86,7 +86,7 @@ test('returns markdown and tool metadata for curl_md by default', async () => {
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',
@@ -135,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 df702236..13baf2d6 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.',
+ ? 'Built-in webfetch routed to 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
@@ -247,7 +240,7 @@ 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,
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 a41fa591..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',
})
@@ -168,7 +168,7 @@ export const tuiPlugin: opencodePluginTui.TuiPluginModule = {
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() {
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/index.ts b/plugins/pi/src/index.ts
index af45abee..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) {
@@ -224,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 = (() => {
@@ -310,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) {
@@ -361,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)}`
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',
From 00fece4fa31c7acf7551508f37921a58e3adf8d2 Mon Sep 17 00:00:00 2001
From: tmm
Date: Fri, 24 Apr 2026 18:07:46 -0400
Subject: [PATCH 10/14] chore: up
---
.github/workflows/pull_request.yml | 5 +---
docs/plugins/amp.mdx | 22 ++++++++--------
docs/plugins/claude.mdx | 26 +++++++++---------
docs/plugins/opencode.mdx | 42 +++++++++++++++---------------
docs/plugins/pi.mdx | 36 ++++++++++++-------------
5 files changed, 64 insertions(+), 67 deletions(-)
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
index 03852404..4193779c 100644
--- a/.github/workflows/pull_request.yml
+++ b/.github/workflows/pull_request.yml
@@ -41,10 +41,7 @@ jobs:
uses: ./.github/actions/setup-pnpm
- name: Build packages
- run: |
- pnpm --filter curl.md build
- pnpm --filter @curl.md/amp build
- pnpm --filter @curl.md/claude build
+ run: pnpm --filter curl.md --filter @curl.md/amp --filter @curl.md/claude build
- name: Publish preview
run: |
diff --git a/docs/plugins/amp.mdx b/docs/plugins/amp.mdx
index e17d2971..88df1b2e 100644
--- a/docs/plugins/amp.mdx
+++ b/docs/plugins/amp.mdx
@@ -115,20 +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` | Built-in `read_web_page` routed to 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 through curl.md. |
-| `objective?` | `string` | Ask a specific question about the page instead of fetching everything. |
-| `keywords?` | `string[]` | Focus extraction on a few specific terms or sections. |
-| `mode?` | `rush` / `smart` | Use `rush` for speed or `smart` for better section selection on long pages. |
-| `fresh?` | `boolean` | Bypass curl.md cache for recently updated pages like release notes or changelogs |
+| 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 0953a856..db67351e 100644
--- a/docs/plugins/claude.mdx
+++ b/docs/plugins/claude.mdx
@@ -150,27 +150,27 @@ For non-interactive environments, set [`CURLMD_API_KEY`](/docs/guide/cli#environ
After installing, Claude registers the following slash skill:
-| Skill | Description |
-| ---------------- | ------------------------------------------------------------------ |
-| `/curl-md:fetch` | Explicitly tells Claude to use the `curl_md` tool for a URL fetch. |
+| 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` | Fetches URLs through curl.md and returns markdown optimized for agents. |
+| 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 through curl.md. |
-| `objective?` | `string` | Ask a specific question about the page instead of fetching everything. |
-| `keywords?` | `string[]` | Focus extraction on a few specific terms or sections. |
-| `mode?` | `rush` / `smart` | Use `rush` for speed or `smart` for better section selection on long pages. |
-| `fresh?` | `boolean` | Bypass curl.md cache for recently updated pages like release notes or changelogs |
+| 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
diff --git a/docs/plugins/opencode.mdx b/docs/plugins/opencode.mdx
index e36d0901..1617c55a 100644
--- a/docs/plugins/opencode.mdx
+++ b/docs/plugins/opencode.mdx
@@ -146,41 +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` | Built-in `webfetch` routed to markdown output. |
The `curl_md` tool accepts the following inputs:
-| Input | Type | Description |
-| ---------- | -------- | --------------------------------------------------------------------- |
-| `url` | `string` | HTTP(S) URL or bare domain to fetch through curl.md. |
-| `options?` | `object` | Optional fetch settings for `fresh`, `keywords`, `mode`, `objective`. |
+| 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` | Ask a specific question about the page instead of fetching everything. |
-| `keywords?` | `string[]` | Focus extraction on a few specific terms or sections. |
-| `mode?` | `rush` / `smart` | Use `rush` for speed or `smart` for better section selection on long pages. |
-| `fresh?` | `boolean` | Bypass curl.md cache for recently updated pages like release notes or changelogs |
+| 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 0f00dec5..8d00652c 100644
--- a/docs/plugins/pi.mdx
+++ b/docs/plugins/pi.mdx
@@ -123,35 +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 through curl.md. |
-| `objective?` | `string` | Ask a specific question about the page instead of fetching everything. |
-| `keywords?` | `string[]` | Focus extraction on a few specific terms or sections. |
-| `mode?` | `rush` / `smart` | Use `rush` for speed or `smart` for better section selection on long pages. |
-| `fresh?` | `boolean` | Bypass curl.md cache for recently updated pages like release notes or changelogs |
+| 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
From 2b9a412209be2e998c5a95e212f9ccc0bcdad3d0 Mon Sep 17 00:00:00 2001
From: tmm
Date: Fri, 24 Apr 2026 18:55:46 -0400
Subject: [PATCH 11/14] chore: up
---
docs/plugins/amp.mdx | 8 ++---
docs/plugins/opencode.mdx | 8 ++---
package.json | 2 +-
plugins/claude/.claude-plugin/plugin.json | 14 +-------
plugins/claude/hooks/hooks.json | 25 +++++++++++++++
plugins/claude/package.json | 11 ++++---
plugins/claude/scripts/bootstrap-deps.sh | 29 +++++++++++++++++
plugins/claude/scripts/build.ts | 31 ------------------
plugins/claude/scripts/start.sh | 13 ++++----
plugins/claude/src/plugin.test.ts | 39 +++++++++++++++++++----
plugins/opencode/src/server.ts | 2 +-
pnpm-lock.yaml | 9 +++---
12 files changed, 115 insertions(+), 76 deletions(-)
create mode 100644 plugins/claude/hooks/hooks.json
create mode 100644 plugins/claude/scripts/bootstrap-deps.sh
delete mode 100644 plugins/claude/scripts/build.ts
diff --git a/docs/plugins/amp.mdx b/docs/plugins/amp.mdx
index 88df1b2e..0d104e21 100644
--- a/docs/plugins/amp.mdx
+++ b/docs/plugins/amp.mdx
@@ -115,10 +115,10 @@ For non-interactive environments, set [`CURLMD_API_KEY`](/docs/guide/cli#environ
The plugin registers the following tools:
-| Tool | Description |
-| --------------- | --------------------------------------------------- |
-| `curl_md` | Fetch a URL as markdown. |
-| `read_web_page` | Built-in `read_web_page` routed to 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:
diff --git a/docs/plugins/opencode.mdx b/docs/plugins/opencode.mdx
index 1617c55a..881fed42 100644
--- a/docs/plugins/opencode.mdx
+++ b/docs/plugins/opencode.mdx
@@ -157,10 +157,10 @@ After installing, OpenCode registers the following commands:
The plugin registers the following tools:
-| Tool | Description |
-| ---------- | ---------------------------------------------- |
-| `curl_md` | Fetch a URL as markdown. |
-| `webfetch` | Built-in `webfetch` routed to 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:
diff --git a/package.json b/package.json
index 441d063f..2391e2f3 100644
--- a/package.json
+++ b/package.json
@@ -36,7 +36,7 @@
"preinstall": "pnpx only-allow pnpm",
"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 && pnpm --filter @curl.md/claude build && node --experimental-strip-types scripts/formatPackage.ts && pnpm changeset publish && node --experimental-strip-types scripts/restorePackage.ts",
+ "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",
"test": "vitest --config test/vitest.config.ts",
"test:e2e": "playwright test --config test/e2e.config.ts"
diff --git a/plugins/claude/.claude-plugin/plugin.json b/plugins/claude/.claude-plugin/plugin.json
index 2b5611b7..69aeb07b 100644
--- a/plugins/claude/.claude-plugin/plugin.json
+++ b/plugins/claude/.claude-plugin/plugin.json
@@ -4,19 +4,7 @@
},
"description": "Use curl.md inside Claude Code for low-token web fetches.",
"homepage": "https://curl.md/docs/plugins/claude",
- "hooks": {
- "PreToolUse": [
- {
- "hooks": [
- {
- "command": "sh \"${CLAUDE_PLUGIN_ROOT}/scripts/redirect-webfetch.sh\"",
- "type": "command"
- }
- ],
- "matcher": "WebFetch"
- }
- ]
- },
+ "hooks": "./hooks/hooks.json",
"license": "MIT",
"name": "curl-md",
"repository": "https://github.com/wevm/curl.md/tree/main/plugins/claude",
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
index 4ae1e6af..307ca8d4 100644
--- a/plugins/claude/package.json
+++ b/plugins/claude/package.json
@@ -24,22 +24,25 @@
".claude-plugin/**",
".mcp.json",
"CHANGELOG.md",
+ "hooks/**",
"README.md",
- "dist/**",
"scripts/**/*.sh",
+ "src/**/*.ts",
+ "!src/**/*.test.ts",
"skills/**"
],
"type": "module",
"scripts": {
- "build": "node --experimental-strip-types scripts/build.ts",
"check:types": "tsgo --noEmit"
},
- "devDependencies": {
- "@anthropic-ai/claude-code": "^2.1.118",
+ "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/build.ts b/plugins/claude/scripts/build.ts
deleted file mode 100644
index 4cfd5f2b..00000000
--- a/plugins/claude/scripts/build.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { execFileSync } from 'node:child_process'
-import fs from 'node:fs/promises'
-import path from 'node:path'
-
-console.log('Building Claude plugin bundle.')
-
-const pluginRoot = path.resolve(import.meta.dirname, '..')
-const repoRoot = path.resolve(pluginRoot, '../..')
-const outdir = path.join(pluginRoot, 'dist')
-
-await fs.rm(outdir, { force: true, recursive: true })
-
-execFileSync(
- process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
- [
- 'exec',
- 'esbuild',
- path.join(pluginRoot, 'src', 'server.ts'),
- '--alias:curl.md/internal=' + path.join(repoRoot, 'cli', 'src', 'exports', 'internal.ts'),
- '--alias:curl.md=' + path.join(repoRoot, 'cli', 'src', 'exports', 'index.ts'),
- '--bundle',
- '--format=esm',
- '--legal-comments=none',
- '--outfile=' + path.join(outdir, 'server.js'),
- '--platform=node',
- '--target=node22',
- ],
- { stdio: 'inherit' },
-)
-
-console.log('Done.')
diff --git a/plugins/claude/scripts/start.sh b/plugins/claude/scripts/start.sh
index 51c9de1e..a2abf028 100644
--- a/plugins/claude/scripts/start.sh
+++ b/plugins/claude/scripts/start.sh
@@ -2,14 +2,15 @@
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
- exec node --experimental-strip-types --no-warnings "$plugin_root/src/server.ts"
+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 [ -f "$plugin_root/dist/server.js" ]; then
- exec node "$plugin_root/dist/server.js"
+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
-echo "curl.md Claude plugin entrypoint not found. Expected src/server.ts for local development or dist/server.js for published installs." >&2
-exit 1
+exec node --experimental-strip-types --no-warnings "$plugin_root/src/server.ts"
diff --git a/plugins/claude/src/plugin.test.ts b/plugins/claude/src/plugin.test.ts
index 68daf77f..016afd4f 100644
--- a/plugins/claude/src/plugin.test.ts
+++ b/plugins/claude/src/plugin.test.ts
@@ -107,11 +107,9 @@ test('start script prefers source files for local development', () => {
try {
const pluginDir = path.join(fixtureDir, 'plugin')
- fs.mkdirSync(path.join(pluginDir, 'dist'), { recursive: true })
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, 'dist', 'server.js'), 'console.log("dist")\n')
fs.writeFileSync(path.join(pluginDir, 'src', 'server.ts'), 'console.log("src")\n')
expect(runStartScript(pluginDir)).toEqual([
@@ -124,23 +122,49 @@ test('start script prefers source files for local development', () => {
}
})
-test('start script falls back to bundled dist for published installs', () => {
+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, 'dist'), { recursive: true })
fs.mkdirSync(path.join(pluginDir, 'scripts'), { recursive: true })
fs.copyFileSync(startScriptPath, path.join(pluginDir, 'scripts', 'start.sh'))
- fs.writeFileSync(path.join(pluginDir, 'dist', 'server.js'), 'console.log("dist")\n')
- expect(runStartScript(pluginDir)).toEqual([path.join(pluginDir, 'dist', 'server.js')])
+ 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) {
+function runStartScript(pluginDir: string, env: Record = {}) {
const binDir = path.join(path.dirname(pluginDir), 'bin')
const nodePath = path.join(binDir, 'node')
@@ -152,6 +176,7 @@ function runStartScript(pluginDir: string) {
encoding: 'utf8',
env: {
...process.env,
+ ...env,
CLAUDE_PLUGIN_ROOT: pluginDir,
PATH: `${binDir}:${process.env.PATH || ''}`,
},
diff --git a/plugins/opencode/src/server.ts b/plugins/opencode/src/server.ts
index 13baf2d6..e69c35ba 100644
--- a/plugins/opencode/src/server.ts
+++ b/plugins/opencode/src/server.ts
@@ -63,7 +63,7 @@ function createFetchTool(input: {
return opencodePlugin.tool({
description:
input.toolName === 'webfetch'
- ? 'Built-in webfetch routed to markdown output.'
+ ? 'Overrides built-in webfetch with markdown output.'
: 'Fetch a URL as markdown.',
args: {
...(input.toolName === 'webfetch' ? optionArgs : {}),
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f6f78cd9..be87d014 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -306,7 +306,10 @@ importers:
version: 0.0.0-dev
plugins/claude:
- dependencies:
+ devDependencies:
+ '@anthropic-ai/claude-code':
+ specifier: ^2.1.118
+ version: 2.1.118
'@modelcontextprotocol/server':
specifier: 2.0.0-alpha.2
version: 2.0.0-alpha.2(@cfworker/json-schema@4.1.1)
@@ -316,10 +319,6 @@ importers:
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:
From 9eb331906871ff9cbb53c0685fd14b44beede134 Mon Sep 17 00:00:00 2001
From: tmm
Date: Fri, 24 Apr 2026 19:07:17 -0400
Subject: [PATCH 12/14] chore: up
---
.github/TODO.md | 1 +
.github/workflows/check.yml | 1 -
.github/workflows/pull_request.yml | 2 +-
pnpm-lock.yaml | 9 +++++----
4 files changed, 7 insertions(+), 6 deletions(-)
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/workflows/check.yml b/.github/workflows/check.yml
index a58c1ff9..44edbe05 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -133,7 +133,6 @@ jobs:
- name: Build
run: |
pnpm --filter @curl.md/amp build
- pnpm --filter @curl.md/claude build
e2e:
name: E2E
diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml
index 4193779c..afa63571 100644
--- a/.github/workflows/pull_request.yml
+++ b/.github/workflows/pull_request.yml
@@ -41,7 +41,7 @@ jobs:
uses: ./.github/actions/setup-pnpm
- name: Build packages
- run: pnpm --filter curl.md --filter @curl.md/amp --filter @curl.md/claude build
+ run: pnpm --filter curl.md --filter @curl.md/amp build
- name: Publish preview
run: |
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index be87d014..f6f78cd9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -306,10 +306,7 @@ importers:
version: 0.0.0-dev
plugins/claude:
- devDependencies:
- '@anthropic-ai/claude-code':
- specifier: ^2.1.118
- version: 2.1.118
+ dependencies:
'@modelcontextprotocol/server':
specifier: 2.0.0-alpha.2
version: 2.0.0-alpha.2(@cfworker/json-schema@4.1.1)
@@ -319,6 +316,10 @@ importers:
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:
From 9d4f4017c61d9e64f8eae6a087e228cfde8f9eeb Mon Sep 17 00:00:00 2001
From: tmm
Date: Fri, 24 Apr 2026 19:13:12 -0400
Subject: [PATCH 13/14] chore: up
---
config/knip.json | 2 +-
pnpm-lock.yaml | 9 +++++----
pnpm-workspace.yaml | 1 +
3 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/config/knip.json b/config/knip.json
index 5f2e0711..3bbb1eae 100644
--- a/config/knip.json
+++ b/config/knip.json
@@ -16,7 +16,7 @@
"ignore": ["**/*.test.ts"]
},
"plugins/claude": {
- "entry": ["src/server.ts"],
+ "entry": ["scripts/sync.ts", "src/server.ts"],
"ignore": ["**/*.test.ts"],
"ignoreDependencies": ["@anthropic-ai/claude-code"]
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f6f78cd9..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
@@ -5528,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:
@@ -12429,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
@@ -13557,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 4c318fc9..f64235a3 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -47,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
From 08a9339e53421191bfc8b40ede5f1f2be41819f6 Mon Sep 17 00:00:00 2001
From: tmm
Date: Sun, 26 Apr 2026 13:08:34 -0400
Subject: [PATCH 14/14] chore: readme
---
cli/README.md | 2 +-
plugins/amp/README.md | 4 ++--
plugins/claude/README.md | 4 ++--
plugins/opencode/README.md | 4 ++--
plugins/pi/README.md | 4 ++--
5 files changed, 9 insertions(+), 9 deletions(-)
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/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/claude/README.md b/plugins/claude/README.md
index 4146ab56..6a4706e7 100644
--- a/plugins/claude/README.md
+++ b/plugins/claude/README.md
@@ -9,9 +9,9 @@
-# @curl.md/claude
+# @curl.md/amp - URL to markdown for Claude
-Turn websites into **optimized, low token output** inside **Claude Code**.
+Turn websites into **optimized, low token output** to **supercharge your context**.
## Install
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/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