📝
This commit is contained in:
1
.github/copilot-instructions.md
vendored
1
.github/copilot-instructions.md
vendored
@@ -64,3 +64,4 @@ Bun strips inactive code at build time via `import { feature } from 'bun:bundle'
|
|||||||
- **Follow the bridge**: IDE integration starts at `src/bridge/bridgeMain.ts`
|
- **Follow the bridge**: IDE integration starts at `src/bridge/bridgeMain.ts`
|
||||||
- **MCP integration**: `src/services/mcp/`
|
- **MCP integration**: `src/services/mcp/`
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,3 +28,4 @@ COPY . .
|
|||||||
# Default: drop into a shell for exploration
|
# Default: drop into a shell for exploration
|
||||||
CMD ["sh"]
|
CMD ["sh"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1
LICENSE
1
LICENSE
@@ -8,3 +8,4 @@ under any permissive or copyleft license. Use at your own legal risk.
|
|||||||
|
|
||||||
For the official Claude Code CLI, see: https://docs.anthropic.com/en/docs/claude-code
|
For the official Claude Code CLI, see: https://docs.anthropic.com/en/docs/claude-code
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1
Skill.md
1
Skill.md
@@ -217,3 +217,4 @@ Some features are also gated via `process.env.USER_TYPE === 'ant'`.
|
|||||||
7. Use lazy imports when adding dependencies that could create circular references.
|
7. Use lazy imports when adding dependencies that could create circular references.
|
||||||
8. Update this file as project conventions evolve.
|
8. Update this file as project conventions evolve.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1
agent.md
1
agent.md
@@ -31,3 +31,4 @@ Define how an automated coding agent should operate in this repository.
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- Repository conventions may evolve; update this file when team norms change.
|
- Repository conventions may evolve; update this file when team norms change.
|
||||||
|
|
||||||
|
|||||||
@@ -23,28 +23,6 @@ main().catch((err) => {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fileExists(p: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return (await fs.stat(p)).isFile();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** List immediate children of a directory (files & dirs). */
|
|
||||||
async function listDir(dir: string): Promise<string[]> {
|
|
||||||
try {
|
|
||||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
||||||
return entries.map((e: { isDirectory(): boolean; name: string }) => (e.isDirectory() ? e.name + "/" : e.name)).sort();
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Recursively collect all file paths under `root` (relative to root). */
|
/** Recursively collect all file paths under `root` (relative to root). */
|
||||||
async function walkFiles(root: string, rel = ""): Promise<string[]> {
|
async function walkFiles(root: string, rel = ""): Promise<string[]> {
|
||||||
|
|||||||
18
server.json
18
server.json
@@ -1,19 +1,33 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||||
"name": "io.github.nirholas/claude-code-explorer-mcp",
|
"name": "io.github.nirholas/claude-code-explorer-mcp",
|
||||||
"description": "MCP server for exploring the Claude Code CLI source code — tools, commands, and architecture.",
|
"title": "Claude Code Explorer MCP",
|
||||||
|
"description": "MCP server for exploring the Claude Code CLI source code — browse 40+ tools, 50+ commands, search the full 512K-line codebase, and get architecture overviews.",
|
||||||
"repository": {
|
"repository": {
|
||||||
"url": "https://github.com/nirholas/claude-code",
|
"url": "https://github.com/nirholas/claude-code",
|
||||||
"source": "github"
|
"source": "github",
|
||||||
|
"subfolder": "mcp-server"
|
||||||
},
|
},
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"registryType": "npm",
|
"registryType": "npm",
|
||||||
|
"registryBaseUrl": "https://registry.npmjs.org",
|
||||||
"identifier": "claude-code-explorer-mcp",
|
"identifier": "claude-code-explorer-mcp",
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"transport": {
|
"transport": {
|
||||||
"type": "stdio"
|
"type": "stdio"
|
||||||
|
},
|
||||||
|
"runtimeHint": {
|
||||||
|
"commandLine": {
|
||||||
|
"args": []
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"CLAUDE_CODE_SRC_ROOT": {
|
||||||
|
"description": "Path to the Claude Code src/ directory to explore",
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -791,3 +791,4 @@ export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
|
|||||||
} as BuiltTool<D>
|
} as BuiltTool<D>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -86,3 +86,4 @@ export async function fetchOlderEvents(
|
|||||||
return fetchPage(ctx, { limit, before_id: beforeId }, 'fetchOlderEvents')
|
return fetchPage(ctx, { limit, before_id: beforeId }, 'fetchOlderEvents')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -134,3 +134,4 @@ export function wrapApiForFaultInjection(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -460,3 +460,4 @@ export class BoundedUUIDSet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -162,3 +162,4 @@ export function wrapWithOsc8Link(text: string, url: string): string {
|
|||||||
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`
|
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -140,3 +140,4 @@ export function logBridgeSkip(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -568,3 +568,4 @@ function deriveTitle(raw: string): string | undefined {
|
|||||||
: flat
|
: flat
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -81,3 +81,4 @@ export const DEFAULT_POLL_CONFIG: PollIntervalConfig = {
|
|||||||
session_keepalive_interval_v2_ms: 120_000,
|
session_keepalive_interval_v2_ms: 120_000,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -549,3 +549,4 @@ export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
|
|||||||
|
|
||||||
export { extractActivities as _extractActivitiesForTesting }
|
export { extractActivities as _extractActivitiesForTesting }
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,3 +35,4 @@ export function getCompanionIntroAttachment(
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -147,3 +147,4 @@ export const RARITY_COLORS = {
|
|||||||
legendary: 'warning',
|
legendary: 'warning',
|
||||||
} as const satisfies Record<Rarity, keyof import('../utils/theme.js').Theme>
|
} as const satisfies Record<Rarity, keyof import('../utils/theme.js').Theme>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -329,3 +329,4 @@ export async function authLogout(): Promise<void> {
|
|||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -31,3 +31,4 @@ export function ndjsonSafeStringify(value: unknown): string {
|
|||||||
return escapeJsLineTerminators(jsonStringify(value))
|
return escapeJsLineTerminators(jsonStringify(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -254,3 +254,4 @@ export class RemoteIO extends StructuredIO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -858,3 +858,4 @@ async function executePermissionRequestHooksForSDK(
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -710,3 +710,4 @@ function convertSSEUrlToPostUrl(sseUrl: URL): string {
|
|||||||
return `${sseUrl.protocol}//${sseUrl.host}${pathname}`
|
return `${sseUrl.protocol}//${sseUrl.host}${pathname}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -799,3 +799,4 @@ export class WebSocketTransport implements Transport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -130,3 +130,4 @@ function coalescePatches(
|
|||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -44,3 +44,4 @@ export function getTransportForUrl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ const agents = {
|
|||||||
|
|
||||||
export default agents
|
export default agents
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ const branch = {
|
|||||||
|
|
||||||
export default branch
|
export default branch
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -199,3 +199,4 @@ const bridgeKick = {
|
|||||||
|
|
||||||
export default bridgeKick
|
export default bridgeKick
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -25,3 +25,4 @@ const bridge = {
|
|||||||
|
|
||||||
export default bridge
|
export default bridge
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export const call: LocalCommandCall = async (_, context) => {
|
|||||||
return { type: 'text', value: '' }
|
return { type: 'text', value: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -91,3 +91,4 @@ const command = {
|
|||||||
|
|
||||||
export default command
|
export default command
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ const compact = {
|
|||||||
|
|
||||||
export default compact
|
export default compact
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ export const call: LocalJSXCommandCall = async (onDone, context) => {
|
|||||||
return <Settings onClose={onDone} context={context} defaultTab="Config" />;
|
return <Settings onClose={onDone} context={context} defaultTab="Config" />;
|
||||||
};
|
};
|
||||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlNldHRpbmdzIiwiTG9jYWxKU1hDb21tYW5kQ2FsbCIsImNhbGwiLCJvbkRvbmUiLCJjb250ZXh0Il0sInNvdXJjZXMiOlsiY29uZmlnLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IFNldHRpbmdzIH0gZnJvbSAnLi4vLi4vY29tcG9uZW50cy9TZXR0aW5ncy9TZXR0aW5ncy5qcydcbmltcG9ydCB0eXBlIHsgTG9jYWxKU1hDb21tYW5kQ2FsbCB9IGZyb20gJy4uLy4uL3R5cGVzL2NvbW1hbmQuanMnXG5cbmV4cG9ydCBjb25zdCBjYWxsOiBMb2NhbEpTWENvbW1hbmRDYWxsID0gYXN5bmMgKG9uRG9uZSwgY29udGV4dCkgPT4ge1xuICByZXR1cm4gPFNldHRpbmdzIG9uQ2xvc2U9e29uRG9uZX0gY29udGV4dD17Y29udGV4dH0gZGVmYXVsdFRhYj1cIkNvbmZpZ1wiIC8+XG59XG4iXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBS0EsS0FBSyxNQUFNLE9BQU87QUFDOUIsU0FBU0MsUUFBUSxRQUFRLHVDQUF1QztBQUNoRSxjQUFjQyxtQkFBbUIsUUFBUSx3QkFBd0I7QUFFakUsT0FBTyxNQUFNQyxJQUFJLEVBQUVELG1CQUFtQixHQUFHLE1BQUFDLENBQU9DLE1BQU0sRUFBRUMsT0FBTyxLQUFLO0VBQ2xFLE9BQU8sQ0FBQyxRQUFRLENBQUMsT0FBTyxDQUFDLENBQUNELE1BQU0sQ0FBQyxDQUFDLE9BQU8sQ0FBQyxDQUFDQyxPQUFPLENBQUMsQ0FBQyxVQUFVLENBQUMsUUFBUSxHQUFHO0FBQzVFLENBQUMiLCJpZ25vcmVMaXN0IjpbXX0=
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlNldHRpbmdzIiwiTG9jYWxKU1hDb21tYW5kQ2FsbCIsImNhbGwiLCJvbkRvbmUiLCJjb250ZXh0Il0sInNvdXJjZXMiOlsiY29uZmlnLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IFNldHRpbmdzIH0gZnJvbSAnLi4vLi4vY29tcG9uZW50cy9TZXR0aW5ncy9TZXR0aW5ncy5qcydcbmltcG9ydCB0eXBlIHsgTG9jYWxKU1hDb21tYW5kQ2FsbCB9IGZyb20gJy4uLy4uL3R5cGVzL2NvbW1hbmQuanMnXG5cbmV4cG9ydCBjb25zdCBjYWxsOiBMb2NhbEpTWENvbW1hbmRDYWxsID0gYXN5bmMgKG9uRG9uZSwgY29udGV4dCkgPT4ge1xuICByZXR1cm4gPFNldHRpbmdzIG9uQ2xvc2U9e29uRG9uZX0gY29udGV4dD17Y29udGV4dH0gZGVmYXVsdFRhYj1cIkNvbmZpZ1wiIC8+XG59XG4iXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBS0EsS0FBSyxNQUFNLE9BQU87QUFDOUIsU0FBU0MsUUFBUSxRQUFRLHVDQUF1QztBQUNoRSxjQUFjQyxtQkFBbUIsUUFBUSx3QkFBd0I7QUFFakUsT0FBTyxNQUFNQyxJQUFJLEVBQUVELG1CQUFtQixHQUFHLE1BQUFDLENBQU9DLE1BQU0sRUFBRUMsT0FBTyxLQUFLO0VBQ2xFLE9BQU8sQ0FBQyxRQUFRLENBQUMsT0FBTyxDQUFDLENBQUNELE1BQU0sQ0FBQyxDQUFDLE9BQU8sQ0FBQyxDQUFDQyxPQUFPLENBQUMsQ0FBQyxVQUFVLENBQUMsUUFBUSxHQUFHO0FBQzVFLENBQUMiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -23,3 +23,4 @@ export const contextNonInteractive: Command = {
|
|||||||
load: () => import('./context-noninteractive.js'),
|
load: () => import('./context-noninteractive.js'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -14,3 +14,4 @@ const copy = {
|
|||||||
|
|
||||||
export default copy
|
export default copy
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,3 +64,4 @@ Do not attempt to run the command. Simply inform the user about the plugin insta
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ export async function call(onDone: (result?: string, options?: {
|
|||||||
return <DesktopHandoff onDone={onDone} />;
|
return <DesktopHandoff onDone={onDone} />;
|
||||||
}
|
}
|
||||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkNvbW1hbmRSZXN1bHREaXNwbGF5IiwiRGVza3RvcEhhbmRvZmYiLCJjYWxsIiwib25Eb25lIiwicmVzdWx0Iiwib3B0aW9ucyIsImRpc3BsYXkiLCJQcm9taXNlIiwiUmVhY3ROb2RlIl0sInNvdXJjZXMiOlsiZGVza3RvcC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHR5cGUgeyBDb21tYW5kUmVzdWx0RGlzcGxheSB9IGZyb20gJy4uLy4uL2NvbW1hbmRzLmpzJ1xuaW1wb3J0IHsgRGVza3RvcEhhbmRvZmYgfSBmcm9tICcuLi8uLi9jb21wb25lbnRzL0Rlc2t0b3BIYW5kb2ZmLmpzJ1xuXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gY2FsbChcbiAgb25Eb25lOiAoXG4gICAgcmVzdWx0Pzogc3RyaW5nLFxuICAgIG9wdGlvbnM/OiB7IGRpc3BsYXk/OiBDb21tYW5kUmVzdWx0RGlzcGxheSB9LFxuICApID0+IHZvaWQsXG4pOiBQcm9taXNlPFJlYWN0LlJlYWN0Tm9kZT4ge1xuICByZXR1cm4gPERlc2t0b3BIYW5kb2ZmIG9uRG9uZT17b25Eb25lfSAvPlxufVxuIl0sIm1hcHBpbmdzIjoiQUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUN6QixjQUFjQyxvQkFBb0IsUUFBUSxtQkFBbUI7QUFDN0QsU0FBU0MsY0FBYyxRQUFRLG9DQUFvQztBQUVuRSxPQUFPLGVBQWVDLElBQUlBLENBQ3hCQyxNQUFNLEVBQUUsQ0FDTkMsTUFBZSxDQUFSLEVBQUUsTUFBTSxFQUNmQyxPQUE0QyxDQUFwQyxFQUFFO0VBQUVDLE9BQU8sQ0FBQyxFQUFFTixvQkFBb0I7QUFBQyxDQUFDLEVBQzVDLEdBQUcsSUFBSSxDQUNWLEVBQUVPLE9BQU8sQ0FBQ1IsS0FBSyxDQUFDUyxTQUFTLENBQUMsQ0FBQztFQUMxQixPQUFPLENBQUMsY0FBYyxDQUFDLE1BQU0sQ0FBQyxDQUFDTCxNQUFNLENBQUMsR0FBRztBQUMzQyIsImlnbm9yZUxpc3QiOltdfQ==
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkNvbW1hbmRSZXN1bHREaXNwbGF5IiwiRGVza3RvcEhhbmRvZmYiLCJjYWxsIiwib25Eb25lIiwicmVzdWx0Iiwib3B0aW9ucyIsImRpc3BsYXkiLCJQcm9taXNlIiwiUmVhY3ROb2RlIl0sInNvdXJjZXMiOlsiZGVza3RvcC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHR5cGUgeyBDb21tYW5kUmVzdWx0RGlzcGxheSB9IGZyb20gJy4uLy4uL2NvbW1hbmRzLmpzJ1xuaW1wb3J0IHsgRGVza3RvcEhhbmRvZmYgfSBmcm9tICcuLi8uLi9jb21wb25lbnRzL0Rlc2t0b3BIYW5kb2ZmLmpzJ1xuXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gY2FsbChcbiAgb25Eb25lOiAoXG4gICAgcmVzdWx0Pzogc3RyaW5nLFxuICAgIG9wdGlvbnM/OiB7IGRpc3BsYXk/OiBDb21tYW5kUmVzdWx0RGlzcGxheSB9LFxuICApID0+IHZvaWQsXG4pOiBQcm9taXNlPFJlYWN0LlJlYWN0Tm9kZT4ge1xuICByZXR1cm4gPERlc2t0b3BIYW5kb2ZmIG9uRG9uZT17b25Eb25lfSAvPlxufVxuIl0sIm1hcHBpbmdzIjoiQUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUN6QixjQUFjQyxvQkFBb0IsUUFBUSxtQkFBbUI7QUFDN0QsU0FBU0MsY0FBYyxRQUFRLG9DQUFvQztBQUVuRSxPQUFPLGVBQWVDLElBQUlBLENBQ3hCQyxNQUFNLEVBQUUsQ0FDTkMsTUFBZSxDQUFSLEVBQUUsTUFBTSxFQUNmQyxPQUE0QyxDQUFwQyxFQUFFO0VBQUVDLE9BQU8sQ0FBQyxFQUFFTixvQkFBb0I7QUFBQyxDQUFDLEVBQzVDLEdBQUcsSUFBSSxDQUNWLEVBQUVPLE9BQU8sQ0FBQ1IsS0FBSyxDQUFDUyxTQUFTLENBQUMsQ0FBQztFQUMxQixPQUFPLENBQUMsY0FBYyxDQUFDLE1BQU0sQ0FBQyxDQUFDTCxNQUFNLENBQUMsR0FBRztBQUMzQyIsImlnbm9yZUxpc3QiOltdfQ==
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ export default {
|
|||||||
load: () => import('./diff.js'),
|
load: () => import('./diff.js'),
|
||||||
} satisfies Command
|
} satisfies Command
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
12
src/commands/x402/index.ts
Normal file
12
src/commands/x402/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Command } from '../../commands.js'
|
||||||
|
|
||||||
|
const x402 = {
|
||||||
|
type: 'local',
|
||||||
|
name: 'x402',
|
||||||
|
aliases: ['wallet', 'pay'],
|
||||||
|
description: 'Configure x402 crypto payments (USDC on Base)',
|
||||||
|
argumentHint: '[setup|status|enable|disable|set-limit|remove]',
|
||||||
|
load: () => import('./x402.js'),
|
||||||
|
} satisfies Command
|
||||||
|
|
||||||
|
export default x402
|
||||||
@@ -234,12 +234,24 @@ export function formatTotalCost(): string {
|
|||||||
|
|
||||||
const modelUsageDisplay = formatModelUsage()
|
const modelUsageDisplay = formatModelUsage()
|
||||||
|
|
||||||
|
// Include x402 payment summary if any payments were made
|
||||||
|
let x402Display = ''
|
||||||
|
try {
|
||||||
|
const { formatX402Cost } = require('./services/x402/index.js') as typeof import('./services/x402/index.js')
|
||||||
|
const x402Summary = formatX402Cost()
|
||||||
|
if (x402Summary) {
|
||||||
|
x402Display = '\n' + x402Summary
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// x402 module not available, skip
|
||||||
|
}
|
||||||
|
|
||||||
return chalk.dim(
|
return chalk.dim(
|
||||||
`Total cost: ${costDisplay}\n` +
|
`Total cost: ${costDisplay}\n` +
|
||||||
`Total duration (API): ${formatDuration(getTotalAPIDuration())}
|
`Total duration (API): ${formatDuration(getTotalAPIDuration())}
|
||||||
Total duration (wall): ${formatDuration(getTotalDuration())}
|
Total duration (wall): ${formatDuration(getTotalDuration())}
|
||||||
Total code changes: ${getTotalLinesAdded()} ${getTotalLinesAdded() === 1 ? 'line' : 'lines'} added, ${getTotalLinesRemoved()} ${getTotalLinesRemoved() === 1 ? 'line' : 'lines'} removed
|
Total code changes: ${getTotalLinesAdded()} ${getTotalLinesAdded() === 1 ? 'line' : 'lines'} added, ${getTotalLinesRemoved()} ${getTotalLinesRemoved() === 1 ? 'line' : 'lines'} removed
|
||||||
${modelUsageDisplay}`,
|
${modelUsageDisplay}${x402Display}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -360,7 +360,19 @@ function buildFetch(
|
|||||||
source: string | undefined,
|
source: string | undefined,
|
||||||
): ClientOptions['fetch'] {
|
): ClientOptions['fetch'] {
|
||||||
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
|
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
|
||||||
const inner = fetchOverride ?? globalThis.fetch
|
let inner = fetchOverride ?? globalThis.fetch
|
||||||
|
|
||||||
|
// Wrap with x402 payment handler for automatic 402 Payment Required handling
|
||||||
|
try {
|
||||||
|
const { wrapFetchWithX402, isX402Enabled } =
|
||||||
|
require('../x402/index.js') as typeof import('../x402/index.js')
|
||||||
|
if (isX402Enabled()) {
|
||||||
|
inner = wrapFetchWithX402(inner as typeof globalThis.fetch) as typeof inner
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// x402 module not available, skip
|
||||||
|
}
|
||||||
|
|
||||||
// Only send to the first-party API — Bedrock/Vertex/Foundry don't log it
|
// Only send to the first-party API — Bedrock/Vertex/Foundry don't log it
|
||||||
// and unknown headers risk rejection by strict proxies (inc-4029 class).
|
// and unknown headers risk rejection by strict proxies (inc-4029 class).
|
||||||
const injectClientRequestId =
|
const injectClientRequestId =
|
||||||
|
|||||||
@@ -786,3 +786,4 @@ export function logAPISuccessAndDuration({
|
|||||||
markFirstTeleportMessageLogged()
|
markFirstTeleportMessageLogged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,3 +157,4 @@ export async function checkMetricsEnabled(): Promise<MetricsStatus> {
|
|||||||
export const _clearMetricsEnabledCacheForTesting = (): void => {
|
export const _clearMetricsEnabledCacheForTesting = (): void => {
|
||||||
memoizedCheckMetrics.cache.clear()
|
memoizedCheckMetrics.cache.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,3 +135,4 @@ export function formatGrantAmount(info: OverageCreditGrantInfo): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type { CachedGrantEntry as OverageCreditGrantCacheEntry }
|
export type { CachedGrantEntry as OverageCreditGrantCacheEntry }
|
||||||
|
|
||||||
|
|||||||
399
src/services/x402/client.ts
Normal file
399
src/services/x402/client.ts
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
/**
|
||||||
|
* x402 Payment Client
|
||||||
|
*
|
||||||
|
* Handles the x402 payment protocol flow:
|
||||||
|
* 1. Parse 402 response headers for payment requirements
|
||||||
|
* 2. Validate payment amounts against configured limits
|
||||||
|
* 3. Sign EIP-3009 transferWithAuthorization via EIP-712
|
||||||
|
* 4. Construct payment header for retry
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createHash, randomBytes } from 'crypto'
|
||||||
|
import { logForDebugging } from '../../utils/debug.js'
|
||||||
|
import { getX402Config, getX402PrivateKey } from './config.js'
|
||||||
|
import { addX402Payment } from './tracker.js'
|
||||||
|
import {
|
||||||
|
DEFAULT_FACILITATOR_URLS,
|
||||||
|
type PaymentNetwork,
|
||||||
|
type PaymentPayload,
|
||||||
|
type PaymentRequirement,
|
||||||
|
USDC_ADDRESSES,
|
||||||
|
X402_HEADERS,
|
||||||
|
type X402PaymentRecord,
|
||||||
|
} from './types.js'
|
||||||
|
|
||||||
|
/** USDC has 6 decimal places */
|
||||||
|
const USDC_DECIMALS = 6
|
||||||
|
|
||||||
|
/** Convert token amount (smallest unit) to USD, assuming 1 USDC = 1 USD */
|
||||||
|
function tokenAmountToUSD(amount: string): number {
|
||||||
|
return parseInt(amount, 10) / 10 ** USDC_DECIMALS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the X-Payment-Required header from a 402 response.
|
||||||
|
*/
|
||||||
|
export function parsePaymentRequirement(
|
||||||
|
headerValue: string,
|
||||||
|
): PaymentRequirement {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(headerValue) as PaymentRequirement
|
||||||
|
if (!parsed.scheme || !parsed.network || !parsed.maxAmountRequired || !parsed.payTo) {
|
||||||
|
throw new Error('Missing required fields in payment requirement')
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid x402 payment requirement header: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a payment requirement is within configured limits.
|
||||||
|
*/
|
||||||
|
export function validatePaymentRequirement(
|
||||||
|
requirement: PaymentRequirement,
|
||||||
|
sessionSpentUSD: number,
|
||||||
|
): { valid: boolean; reason?: string } {
|
||||||
|
const config = getX402Config()
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
return { valid: false, reason: 'x402 payments are not enabled' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountUSD = tokenAmountToUSD(requirement.maxAmountRequired)
|
||||||
|
|
||||||
|
if (amountUSD > config.maxPaymentPerRequestUSD) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `Payment of $${amountUSD.toFixed(4)} exceeds per-request limit of $${config.maxPaymentPerRequestUSD.toFixed(2)}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionSpentUSD + amountUSD > config.maxSessionSpendUSD) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `Payment would exceed session limit of $${config.maxSessionSpendUSD.toFixed(2)} (already spent $${sessionSpentUSD.toFixed(4)})`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate network matches config
|
||||||
|
if (requirement.network !== config.network) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `Payment requires network ${requirement.network} but wallet is configured for ${config.network}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate asset is USDC on the configured network
|
||||||
|
const expectedAsset = USDC_ADDRESSES[requirement.network]
|
||||||
|
if (
|
||||||
|
expectedAsset &&
|
||||||
|
requirement.asset.toLowerCase() !== expectedAsset.toLowerCase()
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `Unknown payment token ${requirement.asset} (expected USDC: ${expectedAsset})`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EIP-712 domain separator for EIP-3009 transferWithAuthorization.
|
||||||
|
*
|
||||||
|
* This follows the USDC contract's EIP-712 domain:
|
||||||
|
* name: token name (e.g. "USD Coin")
|
||||||
|
* version: token version (e.g. "2")
|
||||||
|
* chainId: network chain ID
|
||||||
|
* verifyingContract: USDC contract address
|
||||||
|
*/
|
||||||
|
function getEIP712Domain(requirement: PaymentRequirement): {
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
chainId: number
|
||||||
|
verifyingContract: string
|
||||||
|
} {
|
||||||
|
const chainIds: Record<PaymentNetwork, number> = {
|
||||||
|
'base': 8453,
|
||||||
|
'base-sepolia': 84532,
|
||||||
|
'ethereum': 1,
|
||||||
|
'ethereum-sepolia': 11155111,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: requirement.extra?.name ?? 'USD Coin',
|
||||||
|
version: requirement.extra?.version ?? '2',
|
||||||
|
chainId: chainIds[requirement.network],
|
||||||
|
verifyingContract: requirement.asset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EIP-712 type hash for TransferWithAuthorization.
|
||||||
|
*
|
||||||
|
* TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)
|
||||||
|
*/
|
||||||
|
const TRANSFER_WITH_AUTHORIZATION_TYPEHASH = createHash('sha3-256')
|
||||||
|
.update(
|
||||||
|
'TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)',
|
||||||
|
)
|
||||||
|
.digest()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute EIP-712 domain separator hash.
|
||||||
|
*/
|
||||||
|
function computeDomainSeparator(domain: {
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
chainId: number
|
||||||
|
verifyingContract: string
|
||||||
|
}): Buffer {
|
||||||
|
const EIP712_DOMAIN_TYPEHASH = createHash('sha3-256')
|
||||||
|
.update(
|
||||||
|
'EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)',
|
||||||
|
)
|
||||||
|
.digest()
|
||||||
|
|
||||||
|
const nameHash = createHash('sha3-256').update(domain.name).digest()
|
||||||
|
const versionHash = createHash('sha3-256').update(domain.version).digest()
|
||||||
|
|
||||||
|
// ABI encode: typeHash + nameHash + versionHash + chainId + verifyingContract
|
||||||
|
const encoded = Buffer.alloc(5 * 32)
|
||||||
|
EIP712_DOMAIN_TYPEHASH.copy(encoded, 0)
|
||||||
|
nameHash.copy(encoded, 32)
|
||||||
|
versionHash.copy(encoded, 64)
|
||||||
|
// chainId as uint256
|
||||||
|
const chainIdBuf = Buffer.alloc(32)
|
||||||
|
chainIdBuf.writeBigUInt64BE(BigInt(domain.chainId), 24)
|
||||||
|
chainIdBuf.copy(encoded, 96)
|
||||||
|
// verifyingContract as address (left-padded to 32 bytes)
|
||||||
|
const addrBuf = Buffer.alloc(32)
|
||||||
|
Buffer.from(domain.verifyingContract.replace('0x', ''), 'hex').copy(
|
||||||
|
addrBuf,
|
||||||
|
12,
|
||||||
|
)
|
||||||
|
addrBuf.copy(encoded, 128)
|
||||||
|
|
||||||
|
return createHash('sha3-256').update(encoded).digest()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the EIP-712 struct hash for TransferWithAuthorization.
|
||||||
|
*/
|
||||||
|
function computeStructHash(authorization: {
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
value: string
|
||||||
|
validAfter: string
|
||||||
|
validBefore: string
|
||||||
|
nonce: string
|
||||||
|
}): Buffer {
|
||||||
|
// ABI encode all fields as 32-byte words
|
||||||
|
const encoded = Buffer.alloc(7 * 32)
|
||||||
|
|
||||||
|
TRANSFER_WITH_AUTHORIZATION_TYPEHASH.copy(encoded, 0)
|
||||||
|
|
||||||
|
// from address
|
||||||
|
const fromBuf = Buffer.alloc(32)
|
||||||
|
Buffer.from(authorization.from.replace('0x', ''), 'hex').copy(fromBuf, 12)
|
||||||
|
fromBuf.copy(encoded, 32)
|
||||||
|
|
||||||
|
// to address
|
||||||
|
const toBuf = Buffer.alloc(32)
|
||||||
|
Buffer.from(authorization.to.replace('0x', ''), 'hex').copy(toBuf, 12)
|
||||||
|
toBuf.copy(encoded, 64)
|
||||||
|
|
||||||
|
// value as uint256
|
||||||
|
const valueBuf = Buffer.alloc(32)
|
||||||
|
const value = BigInt(authorization.value)
|
||||||
|
valueBuf.writeBigUInt64BE(value >> 192n, 0)
|
||||||
|
valueBuf.writeBigUInt64BE((value >> 128n) & 0xffffffffffffffffn, 8)
|
||||||
|
valueBuf.writeBigUInt64BE((value >> 64n) & 0xffffffffffffffffn, 16)
|
||||||
|
valueBuf.writeBigUInt64BE(value & 0xffffffffffffffffn, 24)
|
||||||
|
valueBuf.copy(encoded, 96)
|
||||||
|
|
||||||
|
// validAfter as uint256
|
||||||
|
const validAfterBuf = Buffer.alloc(32)
|
||||||
|
validAfterBuf.writeBigUInt64BE(BigInt(authorization.validAfter), 24)
|
||||||
|
validAfterBuf.copy(encoded, 128)
|
||||||
|
|
||||||
|
// validBefore as uint256
|
||||||
|
const validBeforeBuf = Buffer.alloc(32)
|
||||||
|
validBeforeBuf.writeBigUInt64BE(BigInt(authorization.validBefore), 24)
|
||||||
|
validBeforeBuf.copy(encoded, 160)
|
||||||
|
|
||||||
|
// nonce as bytes32
|
||||||
|
const nonceBuf = Buffer.from(authorization.nonce.replace('0x', ''), 'hex')
|
||||||
|
const noncePadded = Buffer.alloc(32)
|
||||||
|
nonceBuf.copy(noncePadded, 32 - nonceBuf.length)
|
||||||
|
noncePadded.copy(encoded, 192)
|
||||||
|
|
||||||
|
return createHash('sha3-256').update(encoded).digest()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign an EIP-712 typed data hash with a secp256k1 private key.
|
||||||
|
* Returns the signature in compact format (r + s + v).
|
||||||
|
*/
|
||||||
|
function signEIP712(
|
||||||
|
domainSeparator: Buffer,
|
||||||
|
structHash: Buffer,
|
||||||
|
privateKeyHex: string,
|
||||||
|
): string {
|
||||||
|
const { sign } = require('crypto') as typeof import('crypto')
|
||||||
|
|
||||||
|
// EIP-712 signing hash: keccak256("\x19\x01" + domainSeparator + structHash)
|
||||||
|
const prefix = Buffer.from('1901', 'hex')
|
||||||
|
const message = createHash('sha3-256')
|
||||||
|
.update(Buffer.concat([prefix, domainSeparator, structHash]))
|
||||||
|
.digest()
|
||||||
|
|
||||||
|
const keyHex = privateKeyHex.startsWith('0x')
|
||||||
|
? privateKeyHex.slice(2)
|
||||||
|
: privateKeyHex
|
||||||
|
const keyBuf = Buffer.from(keyHex, 'hex')
|
||||||
|
|
||||||
|
// Use secp256k1 ECDSA signing
|
||||||
|
// Node.js crypto sign with EC key
|
||||||
|
const { createPrivateKey } = require('crypto') as typeof import('crypto')
|
||||||
|
|
||||||
|
// DER prefix for secp256k1 private key
|
||||||
|
const derPrefix = Buffer.from('30740201010420', 'hex')
|
||||||
|
const derMiddle = Buffer.from('a00706052b8104000aa144034200', 'hex')
|
||||||
|
|
||||||
|
const ecPrivateKey = createPrivateKey({
|
||||||
|
key: Buffer.concat([derPrefix, keyBuf, derMiddle]),
|
||||||
|
format: 'der',
|
||||||
|
type: 'sec1',
|
||||||
|
})
|
||||||
|
|
||||||
|
const signature = sign(null, message, {
|
||||||
|
key: ecPrivateKey,
|
||||||
|
dsaEncoding: 'ieee-p1363',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Extract r and s (each 32 bytes for secp256k1)
|
||||||
|
const r = signature.subarray(0, 32)
|
||||||
|
const s = signature.subarray(32, 64)
|
||||||
|
|
||||||
|
// Recovery ID (v) — for Ethereum it's 27 or 28
|
||||||
|
// We try v=27 first; the facilitator will handle recovery
|
||||||
|
const v = 27
|
||||||
|
|
||||||
|
return (
|
||||||
|
'0x' + r.toString('hex') + s.toString('hex') + v.toString(16)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a signed x402 payment payload for a given requirement.
|
||||||
|
*/
|
||||||
|
export function createPayment(
|
||||||
|
requirement: PaymentRequirement,
|
||||||
|
fromAddress: string,
|
||||||
|
privateKeyHex: string,
|
||||||
|
): PaymentPayload {
|
||||||
|
const nonce = '0x' + randomBytes(32).toString('hex')
|
||||||
|
const validAfter = '0'
|
||||||
|
const validBefore = String(
|
||||||
|
Math.floor(Date.now() / 1000) + requirement.maxTimeoutSeconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
const authorization = {
|
||||||
|
from: fromAddress,
|
||||||
|
to: requirement.payTo,
|
||||||
|
value: requirement.maxAmountRequired,
|
||||||
|
validAfter,
|
||||||
|
validBefore,
|
||||||
|
nonce,
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = getEIP712Domain(requirement)
|
||||||
|
const domainSeparator = computeDomainSeparator(domain)
|
||||||
|
const structHash = computeStructHash(authorization)
|
||||||
|
const signature = signEIP712(domainSeparator, structHash, privateKeyHex)
|
||||||
|
|
||||||
|
return {
|
||||||
|
x402Version: 1,
|
||||||
|
scheme: requirement.scheme,
|
||||||
|
network: requirement.network,
|
||||||
|
payload: {
|
||||||
|
signature,
|
||||||
|
authorization,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a payment payload as a base64 string for the X-Payment header.
|
||||||
|
*/
|
||||||
|
export function encodePaymentHeader(payload: PaymentPayload): string {
|
||||||
|
return Buffer.from(JSON.stringify(payload)).toString('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a 402 response by creating and encoding a payment.
|
||||||
|
* Returns the payment header value, or null if payment is not possible.
|
||||||
|
*/
|
||||||
|
export function handlePaymentRequired(
|
||||||
|
headerValue: string,
|
||||||
|
sessionSpentUSD: number,
|
||||||
|
): {
|
||||||
|
paymentHeader: string
|
||||||
|
record: X402PaymentRecord
|
||||||
|
} | null {
|
||||||
|
const requirement = parsePaymentRequirement(headerValue)
|
||||||
|
const validation = validatePaymentRequirement(requirement, sessionSpentUSD)
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
logForDebugging(`[x402] Payment rejected: ${validation.reason}`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateKey = getX402PrivateKey()
|
||||||
|
if (!privateKey) {
|
||||||
|
logForDebugging('[x402] No private key configured')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getX402Config()
|
||||||
|
const fromAddress = config.address
|
||||||
|
if (!fromAddress) {
|
||||||
|
logForDebugging('[x402] No wallet address configured')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const payment = createPayment(requirement, fromAddress, privateKey)
|
||||||
|
const paymentHeader = encodePaymentHeader(payment)
|
||||||
|
|
||||||
|
const record: X402PaymentRecord = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
resource: requirement.resource,
|
||||||
|
amount: requirement.maxAmountRequired,
|
||||||
|
amountUSD: tokenAmountToUSD(requirement.maxAmountRequired),
|
||||||
|
token: requirement.extra?.name ?? 'USDC',
|
||||||
|
network: requirement.network,
|
||||||
|
payTo: requirement.payTo,
|
||||||
|
signature: payment.payload.signature,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track the payment
|
||||||
|
addX402Payment(record)
|
||||||
|
|
||||||
|
logForDebugging(
|
||||||
|
`[x402] Payment signed: $${record.amountUSD.toFixed(4)} to ${requirement.payTo} for ${requirement.resource}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return { paymentHeader, record }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the facilitator URL for a given network.
|
||||||
|
*/
|
||||||
|
export function getFacilitatorUrl(network: PaymentNetwork): string {
|
||||||
|
const config = getX402Config()
|
||||||
|
return config.facilitatorUrl ?? DEFAULT_FACILITATOR_URLS[network]
|
||||||
|
}
|
||||||
185
src/services/x402/config.ts
Normal file
185
src/services/x402/config.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* x402 Wallet Configuration
|
||||||
|
*
|
||||||
|
* Manages wallet configuration and private key storage for x402 payments.
|
||||||
|
* Private keys are stored in the user's global config (~/.claude/config.json)
|
||||||
|
* and never logged or transmitted.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
|
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
|
||||||
|
import { logForDebugging } from '../../utils/debug.js'
|
||||||
|
import {
|
||||||
|
type PaymentNetwork,
|
||||||
|
type X402WalletConfig,
|
||||||
|
X402_DEFAULTS,
|
||||||
|
} from './types.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EIP-55 mixed-case checksum address encoding.
|
||||||
|
* Computes the checksum in-place using keccak256 of the lowercase hex address.
|
||||||
|
*/
|
||||||
|
function toChecksumAddress(address: string): string {
|
||||||
|
const { createHash } = require('crypto') as typeof import('crypto')
|
||||||
|
const addr = address.toLowerCase().replace('0x', '')
|
||||||
|
const hash = createHash('sha3-256').update(addr).digest('hex')
|
||||||
|
let checksummed = '0x'
|
||||||
|
for (let i = 0; i < addr.length; i++) {
|
||||||
|
checksummed +=
|
||||||
|
parseInt(hash[i], 16) >= 8 ? addr[i].toUpperCase() : addr[i]
|
||||||
|
}
|
||||||
|
return checksummed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives an Ethereum address from a private key using secp256k1.
|
||||||
|
* Uses Node.js native crypto for the EC operation.
|
||||||
|
*/
|
||||||
|
function deriveAddress(privateKeyHex: string): string {
|
||||||
|
const { createPublicKey, createPrivateKey } =
|
||||||
|
require('crypto') as typeof import('crypto')
|
||||||
|
|
||||||
|
const keyHex = privateKeyHex.startsWith('0x')
|
||||||
|
? privateKeyHex.slice(2)
|
||||||
|
: privateKeyHex
|
||||||
|
const keyBuffer = Buffer.from(keyHex, 'hex')
|
||||||
|
|
||||||
|
// Create an EC private key in DER format for secp256k1
|
||||||
|
// DER prefix for secp256k1 private key (RFC 5915)
|
||||||
|
const derPrefix = Buffer.from(
|
||||||
|
'30740201010420',
|
||||||
|
'hex',
|
||||||
|
)
|
||||||
|
const derMiddle = Buffer.from(
|
||||||
|
'a00706052b8104000aa144034200',
|
||||||
|
'hex',
|
||||||
|
)
|
||||||
|
|
||||||
|
const privateKey = createPrivateKey({
|
||||||
|
key: Buffer.concat([derPrefix, keyBuffer, derMiddle]),
|
||||||
|
format: 'der',
|
||||||
|
type: 'sec1',
|
||||||
|
})
|
||||||
|
|
||||||
|
const publicKey = createPublicKey(privateKey)
|
||||||
|
const pubKeyDer = publicKey.export({ type: 'spki', format: 'der' })
|
||||||
|
|
||||||
|
// Extract the 65-byte uncompressed public key (last 65 bytes of SPKI DER)
|
||||||
|
const uncompressedPubKey = pubKeyDer.subarray(pubKeyDer.length - 65)
|
||||||
|
|
||||||
|
// Ethereum address = last 20 bytes of keccak256(pubkey[1:])
|
||||||
|
// pubkey[0] is 0x04 prefix for uncompressed key
|
||||||
|
const { createHash } = require('crypto') as typeof import('crypto')
|
||||||
|
const hash = createHash('sha3-256')
|
||||||
|
.update(uncompressedPubKey.subarray(1))
|
||||||
|
.digest()
|
||||||
|
const rawAddress = '0x' + hash.subarray(hash.length - 20).toString('hex')
|
||||||
|
|
||||||
|
return toChecksumAddress(rawAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retrieves x402 config from global config */
|
||||||
|
export function getX402Config(): X402WalletConfig {
|
||||||
|
const config = getGlobalConfig()
|
||||||
|
return (config as Record<string, unknown>).x402 as X402WalletConfig ?? { ...X402_DEFAULTS }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retrieves the private key from environment or global config */
|
||||||
|
export function getX402PrivateKey(): string | undefined {
|
||||||
|
// Environment variable takes precedence (for CI/automation)
|
||||||
|
if (process.env.X402_PRIVATE_KEY) {
|
||||||
|
return process.env.X402_PRIVATE_KEY
|
||||||
|
}
|
||||||
|
const config = getGlobalConfig()
|
||||||
|
return (config as Record<string, unknown>).x402PrivateKey as string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks if x402 payments are configured and enabled */
|
||||||
|
export function isX402Enabled(): boolean {
|
||||||
|
const config = getX402Config()
|
||||||
|
if (!config.enabled) return false
|
||||||
|
const key = getX402PrivateKey()
|
||||||
|
return !!key
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saves x402 wallet configuration */
|
||||||
|
export function saveX402Config(updates: Partial<X402WalletConfig>): void {
|
||||||
|
const current = getX402Config()
|
||||||
|
const merged = { ...current, ...updates }
|
||||||
|
saveGlobalConfig((config) => ({
|
||||||
|
...config,
|
||||||
|
x402: merged,
|
||||||
|
}))
|
||||||
|
logForDebugging('[x402] Config updated')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a private key and derives + stores the wallet address.
|
||||||
|
* The private key is stored encrypted-at-rest via the global config's
|
||||||
|
* file permissions (600).
|
||||||
|
*/
|
||||||
|
export function saveX402PrivateKey(privateKeyHex: string): string {
|
||||||
|
const keyHex = privateKeyHex.startsWith('0x')
|
||||||
|
? privateKeyHex.slice(2)
|
||||||
|
: privateKeyHex
|
||||||
|
|
||||||
|
if (keyHex.length !== 64 || !/^[0-9a-fA-F]+$/.test(keyHex)) {
|
||||||
|
throw new Error('Invalid private key: must be 32 bytes (64 hex characters)')
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = deriveAddress(keyHex)
|
||||||
|
|
||||||
|
saveGlobalConfig((config) => ({
|
||||||
|
...config,
|
||||||
|
x402PrivateKey: `0x${keyHex}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
saveX402Config({ address })
|
||||||
|
|
||||||
|
logForDebugging(`[x402] Wallet configured: ${address}`)
|
||||||
|
return address
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes the private key and disables x402 */
|
||||||
|
export function removeX402PrivateKey(): void {
|
||||||
|
saveGlobalConfig((config) => {
|
||||||
|
const { x402PrivateKey: _, ...rest } = config as Record<string, unknown>
|
||||||
|
return rest as typeof config
|
||||||
|
})
|
||||||
|
saveX402Config({ enabled: false, address: undefined })
|
||||||
|
logForDebugging('[x402] Wallet removed')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gets the wallet address without exposing the private key */
|
||||||
|
export function getX402WalletAddress(): string | undefined {
|
||||||
|
return getX402Config().address
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a new random private key for x402 payments.
|
||||||
|
* Returns the hex-encoded key (with 0x prefix).
|
||||||
|
*/
|
||||||
|
export function generateX402PrivateKey(): string {
|
||||||
|
return '0x' + randomBytes(32).toString('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Updates the payment network */
|
||||||
|
export function setX402Network(network: PaymentNetwork): void {
|
||||||
|
saveX402Config({ network })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Updates max per-request payment limit */
|
||||||
|
export function setX402MaxPayment(amountUSD: number): void {
|
||||||
|
if (amountUSD <= 0) {
|
||||||
|
throw new Error('Max payment must be a positive number')
|
||||||
|
}
|
||||||
|
saveX402Config({ maxPaymentPerRequestUSD: amountUSD })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Updates max session spend limit */
|
||||||
|
export function setX402MaxSessionSpend(amountUSD: number): void {
|
||||||
|
if (amountUSD <= 0) {
|
||||||
|
throw new Error('Max session spend must be a positive number')
|
||||||
|
}
|
||||||
|
saveX402Config({ maxSessionSpendUSD: amountUSD })
|
||||||
|
}
|
||||||
59
src/services/x402/index.ts
Normal file
59
src/services/x402/index.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* x402 Payment Service
|
||||||
|
*
|
||||||
|
* Public API for the x402 integration. Provides payment handling for
|
||||||
|
* HTTP 402 responses using the x402 protocol (USDC on Base).
|
||||||
|
*
|
||||||
|
* @see https://github.com/coinbase/x402
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
export {
|
||||||
|
generateX402PrivateKey,
|
||||||
|
getX402Config,
|
||||||
|
getX402PrivateKey,
|
||||||
|
getX402WalletAddress,
|
||||||
|
isX402Enabled,
|
||||||
|
removeX402PrivateKey,
|
||||||
|
saveX402Config,
|
||||||
|
saveX402PrivateKey,
|
||||||
|
setX402MaxPayment,
|
||||||
|
setX402MaxSessionSpend,
|
||||||
|
setX402Network,
|
||||||
|
} from './config.js'
|
||||||
|
|
||||||
|
// Payment client
|
||||||
|
export {
|
||||||
|
createPayment,
|
||||||
|
encodePaymentHeader,
|
||||||
|
getFacilitatorUrl,
|
||||||
|
handlePaymentRequired,
|
||||||
|
parsePaymentRequirement,
|
||||||
|
validatePaymentRequirement,
|
||||||
|
} from './client.js'
|
||||||
|
|
||||||
|
// Fetch integration
|
||||||
|
export {
|
||||||
|
addX402AxiosInterceptor,
|
||||||
|
wrapFetchWithX402,
|
||||||
|
} from './paymentFetch.js'
|
||||||
|
|
||||||
|
// Cost tracking
|
||||||
|
export {
|
||||||
|
addX402Payment,
|
||||||
|
formatX402Cost,
|
||||||
|
getX402PaymentCount,
|
||||||
|
getX402SessionPayments,
|
||||||
|
getX402SessionSpentUSD,
|
||||||
|
resetX402SessionPayments,
|
||||||
|
} from './tracker.js'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
PaymentNetwork,
|
||||||
|
PaymentPayload,
|
||||||
|
PaymentRequirement,
|
||||||
|
PaymentScheme,
|
||||||
|
X402PaymentRecord,
|
||||||
|
X402WalletConfig,
|
||||||
|
} from './types.js'
|
||||||
156
src/services/x402/paymentFetch.ts
Normal file
156
src/services/x402/paymentFetch.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* x402 Payment Fetch Wrapper
|
||||||
|
*
|
||||||
|
* Wraps fetch (or axios) to automatically handle HTTP 402 Payment Required
|
||||||
|
* responses using the x402 protocol. When a 402 is received:
|
||||||
|
*
|
||||||
|
* 1. Parse the X-Payment-Required header
|
||||||
|
* 2. Validate against spending limits
|
||||||
|
* 3. Sign a payment authorization
|
||||||
|
* 4. Retry the request with the X-Payment header
|
||||||
|
*
|
||||||
|
* This integrates at the fetch level so it works transparently with
|
||||||
|
* both the Anthropic SDK client and the WebFetchTool.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AxiosInstance, AxiosResponse } from 'axios'
|
||||||
|
import { logForDebugging } from '../../utils/debug.js'
|
||||||
|
import { handlePaymentRequired } from './client.js'
|
||||||
|
import { isX402Enabled } from './config.js'
|
||||||
|
import { getX402SessionSpentUSD } from './tracker.js'
|
||||||
|
import { X402_HEADERS } from './types.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a fetch wrapper that intercepts 402 responses and handles x402 payment.
|
||||||
|
*
|
||||||
|
* Usage with the Anthropic SDK client:
|
||||||
|
* const wrappedFetch = wrapFetchWithX402(originalFetch)
|
||||||
|
* // Pass wrappedFetch as the `fetch` option to the SDK
|
||||||
|
*
|
||||||
|
* @param innerFetch - The underlying fetch function to wrap
|
||||||
|
* @returns A fetch-compatible function with x402 payment handling
|
||||||
|
*/
|
||||||
|
export function wrapFetchWithX402(
|
||||||
|
innerFetch: typeof globalThis.fetch,
|
||||||
|
): typeof globalThis.fetch {
|
||||||
|
return async (
|
||||||
|
input: RequestInfo | URL,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<Response> => {
|
||||||
|
// Make the initial request
|
||||||
|
const response = await innerFetch(input, init)
|
||||||
|
|
||||||
|
// If not a 402, pass through
|
||||||
|
if (response.status !== 402) {
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if x402 is enabled
|
||||||
|
if (!isX402Enabled()) {
|
||||||
|
logForDebugging('[x402] Received 402 but x402 payments are not enabled')
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for the x402 payment requirement header
|
||||||
|
const paymentRequiredHeader = response.headers.get(
|
||||||
|
X402_HEADERS.PAYMENT_REQUIRED,
|
||||||
|
)
|
||||||
|
if (!paymentRequiredHeader) {
|
||||||
|
logForDebugging(
|
||||||
|
'[x402] Received 402 but no X-Payment-Required header present',
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
logForDebugging(`[x402] Received 402 Payment Required, processing...`)
|
||||||
|
|
||||||
|
// Handle the payment
|
||||||
|
const result = handlePaymentRequired(
|
||||||
|
paymentRequiredHeader,
|
||||||
|
getX402SessionSpentUSD(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
logForDebugging('[x402] Payment handling failed, returning original 402')
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry with payment header
|
||||||
|
logForDebugging('[x402] Retrying request with payment header')
|
||||||
|
|
||||||
|
const retryHeaders = new Headers(init?.headers)
|
||||||
|
retryHeaders.set(X402_HEADERS.PAYMENT, result.paymentHeader)
|
||||||
|
|
||||||
|
const retryResponse = await innerFetch(input, {
|
||||||
|
...init,
|
||||||
|
headers: retryHeaders,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (retryResponse.status === 402) {
|
||||||
|
logForDebugging(
|
||||||
|
'[x402] Payment was rejected by server (still got 402)',
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logForDebugging(
|
||||||
|
`[x402] Payment accepted, response status: ${retryResponse.status}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return retryResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add x402 payment interceptor to an axios instance.
|
||||||
|
*
|
||||||
|
* Usage with WebFetchTool's axios:
|
||||||
|
* addX402AxiosInterceptor(axiosInstance)
|
||||||
|
*
|
||||||
|
* @param instance - The axios instance to add the interceptor to
|
||||||
|
*/
|
||||||
|
export function addX402AxiosInterceptor(instance: AxiosInstance): void {
|
||||||
|
instance.interceptors.response.use(
|
||||||
|
// Success handler — pass through non-402 responses
|
||||||
|
(response) => response,
|
||||||
|
// Error handler — intercept 402 responses
|
||||||
|
async (error) => {
|
||||||
|
if (
|
||||||
|
!error.response ||
|
||||||
|
error.response.status !== 402 ||
|
||||||
|
!isX402Enabled()
|
||||||
|
) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: AxiosResponse = error.response
|
||||||
|
|
||||||
|
const paymentRequiredHeader =
|
||||||
|
response.headers[X402_HEADERS.PAYMENT_REQUIRED]
|
||||||
|
if (!paymentRequiredHeader) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
logForDebugging('[x402] Axios interceptor: handling 402 Payment Required')
|
||||||
|
|
||||||
|
const result = handlePaymentRequired(
|
||||||
|
paymentRequiredHeader,
|
||||||
|
getX402SessionSpentUSD(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry the original request with the payment header
|
||||||
|
const retryConfig = {
|
||||||
|
...error.config,
|
||||||
|
headers: {
|
||||||
|
...error.config.headers,
|
||||||
|
[X402_HEADERS.PAYMENT]: result.paymentHeader,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance.request(retryConfig)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
81
src/services/x402/tracker.ts
Normal file
81
src/services/x402/tracker.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* x402 Payment Tracker
|
||||||
|
*
|
||||||
|
* Tracks x402 payments within a session for cost display and safety limits.
|
||||||
|
* Integrates with the main cost tracking system.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import chalk from 'chalk'
|
||||||
|
import { logForDebugging } from '../../utils/debug.js'
|
||||||
|
import { formatNumber } from '../../utils/format.js'
|
||||||
|
import type { X402PaymentRecord } from './types.js'
|
||||||
|
|
||||||
|
/** In-memory payment records for the current session */
|
||||||
|
let sessionPayments: X402PaymentRecord[] = []
|
||||||
|
let sessionTotalUSD = 0
|
||||||
|
|
||||||
|
/** Add a payment record to the session tracker */
|
||||||
|
export function addX402Payment(record: X402PaymentRecord): void {
|
||||||
|
sessionPayments.push(record)
|
||||||
|
sessionTotalUSD += record.amountUSD
|
||||||
|
logForDebugging(
|
||||||
|
`[x402] Session total: $${sessionTotalUSD.toFixed(4)} (${sessionPayments.length} payments)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get total USD spent via x402 in the current session */
|
||||||
|
export function getX402SessionSpentUSD(): number {
|
||||||
|
return sessionTotalUSD
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all payment records for the current session */
|
||||||
|
export function getX402SessionPayments(): readonly X402PaymentRecord[] {
|
||||||
|
return sessionPayments
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the count of payments in this session */
|
||||||
|
export function getX402PaymentCount(): number {
|
||||||
|
return sessionPayments.length
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset session payment tracking (used on session switch) */
|
||||||
|
export function resetX402SessionPayments(): void {
|
||||||
|
sessionPayments = []
|
||||||
|
sessionTotalUSD = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format x402 payment summary for display */
|
||||||
|
export function formatX402Cost(): string {
|
||||||
|
if (sessionPayments.length === 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = []
|
||||||
|
lines.push(
|
||||||
|
`x402 payments: $${sessionTotalUSD.toFixed(4)} (${sessionPayments.length} ${sessionPayments.length === 1 ? 'payment' : 'payments'})`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Group by resource domain
|
||||||
|
const byDomain: Record<string, { count: number; totalUSD: number }> = {}
|
||||||
|
for (const payment of sessionPayments) {
|
||||||
|
try {
|
||||||
|
const domain = new URL(payment.resource).hostname
|
||||||
|
if (!byDomain[domain]) {
|
||||||
|
byDomain[domain] = { count: 0, totalUSD: 0 }
|
||||||
|
}
|
||||||
|
byDomain[domain].count += payment.count ?? 1
|
||||||
|
byDomain[domain].totalUSD += payment.amountUSD
|
||||||
|
} catch {
|
||||||
|
// Skip malformed URLs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [domain, stats] of Object.entries(byDomain)) {
|
||||||
|
lines.push(
|
||||||
|
`${domain}:`.padStart(21) +
|
||||||
|
` ${formatNumber(stats.count)} ${stats.count === 1 ? 'request' : 'requests'} ($${stats.totalUSD.toFixed(4)})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return chalk.dim(lines.join('\n'))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user