mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-19 23:01:36 +00:00
acp spec (#155)
This commit is contained in:
parent
70287ec471
commit
e72eb9f611
264 changed files with 18559 additions and 51021 deletions
|
|
@ -1,286 +0,0 @@
|
|||
import * as cheerio from "cheerio";
|
||||
import { fetchWithCache } from "./cache.js";
|
||||
import { createNormalizedSchema, type NormalizedSchema } from "./normalize.js";
|
||||
import type { JSONSchema7 } from "json-schema";
|
||||
|
||||
const AMP_DOCS_URL = "https://ampcode.com/manual/appendix?preview#message-schema";
|
||||
|
||||
// Key types we want to extract
|
||||
const TARGET_TYPES = ["StreamJSONMessage", "AmpOptions", "PermissionRule", "Message", "ToolCall"];
|
||||
|
||||
export async function extractAmpSchema(): Promise<NormalizedSchema> {
|
||||
console.log("Extracting AMP schema from documentation...");
|
||||
|
||||
try {
|
||||
const html = await fetchWithCache(AMP_DOCS_URL);
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Find TypeScript code blocks
|
||||
const codeBlocks: string[] = [];
|
||||
$("pre code").each((_, el) => {
|
||||
const code = $(el).text();
|
||||
// Look for TypeScript interface/type definitions
|
||||
if (
|
||||
code.includes("interface ") ||
|
||||
code.includes("type ") ||
|
||||
code.includes(": {") ||
|
||||
code.includes("export ")
|
||||
) {
|
||||
codeBlocks.push(code);
|
||||
}
|
||||
});
|
||||
|
||||
if (codeBlocks.length === 0) {
|
||||
console.log(" [warn] No TypeScript code blocks found, using fallback schema");
|
||||
return createFallbackSchema();
|
||||
}
|
||||
|
||||
console.log(` [found] ${codeBlocks.length} code blocks`);
|
||||
|
||||
// Parse TypeScript definitions into schemas
|
||||
const definitions = parseTypeScriptToSchema(codeBlocks.join("\n"));
|
||||
|
||||
// Verify target types exist
|
||||
const found = TARGET_TYPES.filter((name) => definitions[name]);
|
||||
const missing = TARGET_TYPES.filter((name) => !definitions[name]);
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.log(` [warn] Missing expected types: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
if (Object.keys(definitions).length === 0) {
|
||||
console.log(" [warn] No types extracted, using fallback schema");
|
||||
return createFallbackSchema();
|
||||
}
|
||||
|
||||
console.log(` [ok] Extracted ${Object.keys(definitions).length} types (${found.length} target types)`);
|
||||
|
||||
return createNormalizedSchema("amp", "AMP Code SDK Schema", definitions);
|
||||
} catch (error) {
|
||||
console.log(` [error] Failed to fetch docs: ${error}`);
|
||||
console.log(" [fallback] Using embedded schema definitions");
|
||||
return createFallbackSchema();
|
||||
}
|
||||
}
|
||||
|
||||
function parseTypeScriptToSchema(code: string): Record<string, JSONSchema7> {
|
||||
const definitions: Record<string, JSONSchema7> = {};
|
||||
|
||||
// Match interface definitions
|
||||
const interfaceRegex = /(?:export\s+)?interface\s+(\w+)\s*(?:extends\s+[\w,\s]+)?\s*\{([^}]+)\}/g;
|
||||
let match;
|
||||
|
||||
while ((match = interfaceRegex.exec(code)) !== null) {
|
||||
const [, name, body] = match;
|
||||
definitions[name] = parseInterfaceBody(body);
|
||||
}
|
||||
|
||||
// Match type definitions (simple object types)
|
||||
const typeRegex = /(?:export\s+)?type\s+(\w+)\s*=\s*\{([^}]+)\}/g;
|
||||
|
||||
while ((match = typeRegex.exec(code)) !== null) {
|
||||
const [, name, body] = match;
|
||||
definitions[name] = parseInterfaceBody(body);
|
||||
}
|
||||
|
||||
// Match union type definitions
|
||||
const unionRegex = /(?:export\s+)?type\s+(\w+)\s*=\s*([^;{]+);/g;
|
||||
|
||||
while ((match = unionRegex.exec(code)) !== null) {
|
||||
const [, name, body] = match;
|
||||
if (body.includes("|")) {
|
||||
definitions[name] = parseUnionType(body);
|
||||
}
|
||||
}
|
||||
|
||||
return definitions;
|
||||
}
|
||||
|
||||
function parseInterfaceBody(body: string): JSONSchema7 {
|
||||
const properties: Record<string, JSONSchema7> = {};
|
||||
const required: string[] = [];
|
||||
|
||||
// Match property definitions
|
||||
const propRegex = /(\w+)(\?)?:\s*([^;]+);/g;
|
||||
let match;
|
||||
|
||||
while ((match = propRegex.exec(body)) !== null) {
|
||||
const [, propName, optional, propType] = match;
|
||||
properties[propName] = typeToSchema(propType.trim());
|
||||
|
||||
if (!optional) {
|
||||
required.push(propName);
|
||||
}
|
||||
}
|
||||
|
||||
const schema: JSONSchema7 = {
|
||||
type: "object",
|
||||
properties,
|
||||
};
|
||||
|
||||
if (required.length > 0) {
|
||||
schema.required = required;
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
function typeToSchema(tsType: string): JSONSchema7 {
|
||||
// Handle union types
|
||||
if (tsType.includes("|")) {
|
||||
return parseUnionType(tsType);
|
||||
}
|
||||
|
||||
// Handle array types
|
||||
if (tsType.endsWith("[]")) {
|
||||
const itemType = tsType.slice(0, -2);
|
||||
return {
|
||||
type: "array",
|
||||
items: typeToSchema(itemType),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle Array<T>
|
||||
const arrayMatch = tsType.match(/^Array<(.+)>$/);
|
||||
if (arrayMatch) {
|
||||
return {
|
||||
type: "array",
|
||||
items: typeToSchema(arrayMatch[1]),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle basic types
|
||||
switch (tsType) {
|
||||
case "string":
|
||||
return { type: "string" };
|
||||
case "number":
|
||||
return { type: "number" };
|
||||
case "boolean":
|
||||
return { type: "boolean" };
|
||||
case "null":
|
||||
return { type: "null" };
|
||||
case "any":
|
||||
case "unknown":
|
||||
return {};
|
||||
case "object":
|
||||
return { type: "object" };
|
||||
default:
|
||||
// Could be a reference to another type
|
||||
if (/^[A-Z]/.test(tsType)) {
|
||||
return { $ref: `#/definitions/${tsType}` };
|
||||
}
|
||||
// String literal
|
||||
if (tsType.startsWith('"') || tsType.startsWith("'")) {
|
||||
return { type: "string", const: tsType.slice(1, -1) };
|
||||
}
|
||||
return { type: "string" };
|
||||
}
|
||||
}
|
||||
|
||||
function parseUnionType(unionStr: string): JSONSchema7 {
|
||||
const parts = unionStr.split("|").map((p) => p.trim());
|
||||
|
||||
// Check if it's a string literal union
|
||||
const allStringLiterals = parts.every((p) => p.startsWith('"') || p.startsWith("'"));
|
||||
|
||||
if (allStringLiterals) {
|
||||
return {
|
||||
type: "string",
|
||||
enum: parts.map((p) => p.slice(1, -1)),
|
||||
};
|
||||
}
|
||||
|
||||
// General union
|
||||
return {
|
||||
oneOf: parts.map((p) => typeToSchema(p)),
|
||||
};
|
||||
}
|
||||
|
||||
function createFallbackSchema(): NormalizedSchema {
|
||||
// Fallback schema based on AMP documentation structure
|
||||
const definitions: Record<string, JSONSchema7> = {
|
||||
StreamJSONMessage: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: {
|
||||
type: "string",
|
||||
enum: ["system", "user", "assistant", "result", "message", "tool_call", "tool_result", "error", "done"],
|
||||
},
|
||||
// Common fields
|
||||
id: { type: "string" },
|
||||
content: { type: "string" },
|
||||
tool_call: { $ref: "#/definitions/ToolCall" },
|
||||
error: { type: "string" },
|
||||
// System message fields
|
||||
subtype: { type: "string" },
|
||||
cwd: { type: "string" },
|
||||
session_id: { type: "string" },
|
||||
tools: { type: "array", items: { type: "string" } },
|
||||
mcp_servers: { type: "array", items: { type: "object" } },
|
||||
// User/Assistant message fields
|
||||
message: { type: "object" },
|
||||
parent_tool_use_id: { type: "string" },
|
||||
// Result fields
|
||||
duration_ms: { type: "number" },
|
||||
is_error: { type: "boolean" },
|
||||
num_turns: { type: "number" },
|
||||
result: { type: "string" },
|
||||
},
|
||||
required: ["type"],
|
||||
},
|
||||
AmpOptions: {
|
||||
type: "object",
|
||||
properties: {
|
||||
model: { type: "string" },
|
||||
apiKey: { type: "string" },
|
||||
baseURL: { type: "string" },
|
||||
maxTokens: { type: "number" },
|
||||
temperature: { type: "number" },
|
||||
systemPrompt: { type: "string" },
|
||||
tools: { type: "array", items: { type: "object" } },
|
||||
workingDirectory: { type: "string" },
|
||||
permissionRules: {
|
||||
type: "array",
|
||||
items: { $ref: "#/definitions/PermissionRule" },
|
||||
},
|
||||
},
|
||||
},
|
||||
PermissionRule: {
|
||||
type: "object",
|
||||
properties: {
|
||||
tool: { type: "string" },
|
||||
action: { type: "string", enum: ["allow", "deny", "ask"] },
|
||||
pattern: { type: "string" },
|
||||
description: { type: "string" },
|
||||
},
|
||||
required: ["tool", "action"],
|
||||
},
|
||||
Message: {
|
||||
type: "object",
|
||||
properties: {
|
||||
role: { type: "string", enum: ["user", "assistant", "system"] },
|
||||
content: { type: "string" },
|
||||
tool_calls: {
|
||||
type: "array",
|
||||
items: { $ref: "#/definitions/ToolCall" },
|
||||
},
|
||||
},
|
||||
required: ["role", "content"],
|
||||
},
|
||||
ToolCall: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
name: { type: "string" },
|
||||
arguments: {
|
||||
oneOf: [{ type: "string" }, { type: "object" }],
|
||||
},
|
||||
},
|
||||
required: ["id", "name", "arguments"],
|
||||
},
|
||||
};
|
||||
|
||||
console.log(` [ok] Using fallback schema with ${Object.keys(definitions).length} definitions`);
|
||||
|
||||
return createNormalizedSchema("amp", "AMP Code SDK Schema", definitions);
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
import { createHash } from "crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const CACHE_DIR = join(
|
||||
import.meta.dirname,
|
||||
"..",
|
||||
"..",
|
||||
"resources",
|
||||
"agent-schemas",
|
||||
".cache"
|
||||
);
|
||||
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
function ensureCacheDir(): void {
|
||||
if (!existsSync(CACHE_DIR)) {
|
||||
mkdirSync(CACHE_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function hashKey(key: string): string {
|
||||
return createHash("sha256").update(key).digest("hex");
|
||||
}
|
||||
|
||||
function getCachePath(key: string): string {
|
||||
return join(CACHE_DIR, `${hashKey(key)}.json`);
|
||||
}
|
||||
|
||||
export function getCached<T>(key: string): T | null {
|
||||
const path = getCachePath(key);
|
||||
|
||||
if (!existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(path, "utf-8");
|
||||
const entry: CacheEntry<T> = JSON.parse(content);
|
||||
|
||||
const now = Date.now();
|
||||
if (now - entry.timestamp > entry.ttl) {
|
||||
// Cache expired
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setCache<T>(key: string, data: T, ttl: number = DEFAULT_TTL_MS): void {
|
||||
ensureCacheDir();
|
||||
|
||||
const entry: CacheEntry<T> = {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
ttl,
|
||||
};
|
||||
|
||||
const path = getCachePath(key);
|
||||
writeFileSync(path, JSON.stringify(entry, null, 2));
|
||||
}
|
||||
|
||||
export async function fetchWithCache(url: string, ttl?: number): Promise<string> {
|
||||
const cached = getCached<string>(url);
|
||||
if (cached !== null) {
|
||||
console.log(` [cache hit] ${url}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
console.log(` [fetching] ${url}`);
|
||||
|
||||
let lastError: Error | null = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
setCache(url, text, ttl);
|
||||
return text;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
if (attempt < 2) {
|
||||
const delay = Math.pow(2, attempt) * 1000;
|
||||
console.log(` [retry ${attempt + 1}] waiting ${delay}ms...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { collectFromCli } from "./claude-event-types.js";
|
||||
|
||||
const promptArg = process.argv.slice(2).find((arg) => arg.startsWith("--prompt="));
|
||||
const timeoutArg = process.argv.slice(2).find((arg) => arg.startsWith("--timeoutMs="));
|
||||
|
||||
const prompt = promptArg?.split("=")[1] ?? "Reply with exactly OK.";
|
||||
const timeoutMs = timeoutArg ? Number(timeoutArg.split("=")[1]) : 20000;
|
||||
|
||||
collectFromCli(prompt, timeoutMs).then((result) => {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
});
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { collectFromDocs } from "./claude-event-types.js";
|
||||
|
||||
const urlsArg = process.argv.slice(2).find((arg) => arg.startsWith("--urls="));
|
||||
const urls = urlsArg ? urlsArg.split("=")[1]!.split(",") : undefined;
|
||||
|
||||
collectFromDocs(urls ?? []).then((result) => {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
});
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
import { collectFromSdkTypes } from "./claude-event-types.js";
|
||||
|
||||
const result = collectFromSdkTypes();
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
|
@ -1,338 +0,0 @@
|
|||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
import ts from "typescript";
|
||||
import { load } from "cheerio";
|
||||
|
||||
type SourceResult = {
|
||||
source: string;
|
||||
types: string[];
|
||||
details?: Record<string, string[]>;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const SDK_POSSIBLE_PATHS = [
|
||||
"node_modules/@anthropic-ai/claude-code/sdk-tools.d.ts",
|
||||
"node_modules/@anthropic-ai/claude-code/dist/index.d.ts",
|
||||
"node_modules/@anthropic-ai/claude-code/dist/types.d.ts",
|
||||
"node_modules/@anthropic-ai/claude-code/index.d.ts",
|
||||
];
|
||||
|
||||
const DEFAULT_DOC_URLS = [
|
||||
"https://platform.claude.com/docs/en/messages-streaming",
|
||||
"https://platform.claude.com/docs/en/api/messages-streaming",
|
||||
"https://docs.anthropic.com/claude/reference/messages-streaming",
|
||||
"https://docs.anthropic.com/claude/reference/messages-streaming#events",
|
||||
"https://docs.anthropic.com/claude/docs/messages-streaming",
|
||||
];
|
||||
|
||||
function moduleDir(): string {
|
||||
const metaDir = (import.meta as { dirname?: string }).dirname;
|
||||
if (typeof metaDir === "string") {
|
||||
return metaDir;
|
||||
}
|
||||
return dirname(fileURLToPath(import.meta.url));
|
||||
}
|
||||
|
||||
function findSdkTypesPath(): string | null {
|
||||
const resourceDir = join(moduleDir(), "..");
|
||||
const repoRoot = join(moduleDir(), "..", "..", "..");
|
||||
const searchRoots = [resourceDir, repoRoot];
|
||||
|
||||
for (const root of searchRoots) {
|
||||
for (const relativePath of SDK_POSSIBLE_PATHS) {
|
||||
const fullPath = join(root, relativePath);
|
||||
if (existsSync(fullPath)) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractStringLiterals(node: ts.TypeNode): string[] {
|
||||
if (ts.isLiteralTypeNode(node) && ts.isStringLiteral(node.literal)) {
|
||||
return [node.literal.text];
|
||||
}
|
||||
if (ts.isUnionTypeNode(node)) {
|
||||
return node.types.flatMap((typeNode) => extractStringLiterals(typeNode));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function containerName(node: ts.Node): string | null {
|
||||
let current: ts.Node | undefined = node;
|
||||
while (current) {
|
||||
if (ts.isInterfaceDeclaration(current) && current.name) {
|
||||
return current.name.text;
|
||||
}
|
||||
if (ts.isTypeAliasDeclaration(current) && current.name) {
|
||||
return current.name.text;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectFromSdkTypes(): SourceResult {
|
||||
const path = findSdkTypesPath();
|
||||
if (!path) {
|
||||
return { source: "sdk", types: [], error: "Claude SDK types not found" };
|
||||
}
|
||||
const content = readFileSync(path, "utf8");
|
||||
const sourceFile = ts.createSourceFile(path, content, ts.ScriptTarget.Latest, true);
|
||||
const types = new Set<string>();
|
||||
const details: Record<string, string[]> = {};
|
||||
|
||||
function visit(node: ts.Node): void {
|
||||
if (ts.isPropertySignature(node)) {
|
||||
const name = node.name && ts.isIdentifier(node.name) ? node.name.text : null;
|
||||
if (name === "type" && node.type) {
|
||||
const literals = extractStringLiterals(node.type);
|
||||
if (literals.length > 0) {
|
||||
const parentName = containerName(node) ?? "anonymous";
|
||||
if (/Event|Stream|Message/i.test(parentName)) {
|
||||
literals.forEach((value) => types.add(value));
|
||||
details[parentName] = (details[parentName] ?? []).concat(literals);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
return { source: "sdk", types: Array.from(types).sort(), details };
|
||||
}
|
||||
|
||||
function collectFromCli(prompt: string, timeoutMs: number): Promise<SourceResult> {
|
||||
return new Promise((resolve) => {
|
||||
const result: SourceResult = { source: "cli", types: [] };
|
||||
const types = new Set<string>();
|
||||
const denoGlobal = (globalThis as {
|
||||
Deno?: {
|
||||
which?: (cmd: string) => string | null;
|
||||
Command?: new (
|
||||
cmd: string,
|
||||
options: { args: string[]; stdout: "piped"; stderr: "piped" },
|
||||
) => { output: () => Promise<{ stdout: Uint8Array; stderr: Uint8Array; code: number }> };
|
||||
};
|
||||
}).Deno;
|
||||
|
||||
if (denoGlobal?.which && !denoGlobal.which("claude")) {
|
||||
result.error = "claude binary not found in PATH";
|
||||
resolve(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (denoGlobal?.Command) {
|
||||
const command = new denoGlobal.Command("claude", {
|
||||
args: ["--print", "--output-format", "stream-json", "--verbose", prompt],
|
||||
stdout: "piped",
|
||||
stderr: "piped",
|
||||
});
|
||||
try {
|
||||
command
|
||||
.output()
|
||||
.then(({ stdout, stderr, code }) => {
|
||||
const text = new TextDecoder().decode(stdout);
|
||||
for (const line of text.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const value = JSON.parse(trimmed);
|
||||
if (value && typeof value.type === "string") {
|
||||
types.add(value.type);
|
||||
}
|
||||
} catch {
|
||||
// ignore non-json
|
||||
}
|
||||
}
|
||||
result.types = Array.from(types).sort();
|
||||
if (code !== 0) {
|
||||
result.error =
|
||||
new TextDecoder().decode(stderr).trim() ||
|
||||
`claude exited with code ${code}`;
|
||||
}
|
||||
resolve(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
result.error = error instanceof Error ? error.message : String(error);
|
||||
resolve(result);
|
||||
});
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.message : String(error);
|
||||
resolve(result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let child;
|
||||
try {
|
||||
child = spawn(
|
||||
"claude",
|
||||
["--print", "--output-format", "stream-json", "--verbose", prompt],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.message : String(error);
|
||||
resolve(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!child.stdout || !child.stderr) {
|
||||
result.error = "claude stdout/stderr not available";
|
||||
resolve(result);
|
||||
return;
|
||||
}
|
||||
|
||||
let stderr = "";
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
const text = chunk.toString("utf8");
|
||||
for (const line of text.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const value = JSON.parse(trimmed);
|
||||
if (value && typeof value.type === "string") {
|
||||
types.add(value.type);
|
||||
}
|
||||
} catch {
|
||||
// ignore non-json
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
result.types = Array.from(types).sort();
|
||||
if (code !== 0) {
|
||||
result.error = stderr.trim() || `claude exited with code ${code}`;
|
||||
}
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function collectFromDocs(urls: string[]): Promise<SourceResult> {
|
||||
if (typeof fetch !== "function") {
|
||||
return { source: "docs", types: [], error: "fetch is not available in this runtime" };
|
||||
}
|
||||
const effectiveUrls = urls.length > 0 ? urls : DEFAULT_DOC_URLS;
|
||||
const types = new Set<string>();
|
||||
const extractFromText = (text: string) => {
|
||||
const typeMatches = text.match(/\"type\"\\s*:\\s*\"([^\"]+)\"/g) ?? [];
|
||||
for (const match of typeMatches) {
|
||||
const value = match.split(":")[1]?.trim().replace(/^\"|\"$/g, "");
|
||||
if (value) types.add(value);
|
||||
}
|
||||
const eventMatches = text.match(/event\\s*:\\s*([a-z_]+)/gi) ?? [];
|
||||
for (const match of eventMatches) {
|
||||
const value = match.split(":")[1]?.trim();
|
||||
if (value) types.add(value);
|
||||
}
|
||||
};
|
||||
for (const url of effectiveUrls) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
continue;
|
||||
}
|
||||
const html = await res.text();
|
||||
const $ = load(html);
|
||||
const blocks = $("pre, code")
|
||||
.map((_, el) => $(el).text())
|
||||
.get();
|
||||
for (const block of blocks) {
|
||||
extractFromText(block);
|
||||
}
|
||||
const nextData = $("#__NEXT_DATA__").text();
|
||||
if (nextData) {
|
||||
extractFromText(nextData);
|
||||
}
|
||||
extractFromText(html);
|
||||
} catch {
|
||||
// ignore per-url errors
|
||||
}
|
||||
}
|
||||
return { source: "docs", types: Array.from(types).sort() };
|
||||
}
|
||||
|
||||
type Args = {
|
||||
source: "all" | "sdk" | "cli" | "docs";
|
||||
prompt: string;
|
||||
timeoutMs: number;
|
||||
urls: string[];
|
||||
json: boolean;
|
||||
};
|
||||
|
||||
function parseArgs(): Args {
|
||||
const args = process.argv.slice(2);
|
||||
const sourceArg = args.find((arg) => arg.startsWith("--source="));
|
||||
const promptArg = args.find((arg) => arg.startsWith("--prompt="));
|
||||
const timeoutArg = args.find((arg) => arg.startsWith("--timeoutMs="));
|
||||
const urlsArg = args.find((arg) => arg.startsWith("--urls="));
|
||||
const json = args.includes("--json");
|
||||
|
||||
return {
|
||||
source: (sourceArg?.split("=")[1] as Args["source"]) ?? "all",
|
||||
prompt: promptArg?.split("=")[1] ?? "Reply with exactly OK.",
|
||||
timeoutMs: timeoutArg ? Number(timeoutArg.split("=")[1]) : 20000,
|
||||
urls: urlsArg ? urlsArg.split("=")[1]!.split(",") : DEFAULT_DOC_URLS,
|
||||
json,
|
||||
};
|
||||
}
|
||||
|
||||
function summarize(results: SourceResult[]): void {
|
||||
const counts = results.map((r) => ({ source: r.source, count: r.types.length }));
|
||||
const max = Math.max(...counts.map((c) => c.count), 0);
|
||||
const best = counts.filter((c) => c.count === max).map((c) => c.source);
|
||||
const union = Array.from(
|
||||
new Set(results.flatMap((r) => r.types))
|
||||
).sort();
|
||||
|
||||
console.log("Claude event type extraction");
|
||||
console.log("============================");
|
||||
for (const result of results) {
|
||||
console.log(`- ${result.source}: ${result.types.length} types${result.error ? " (error)" : ""}`);
|
||||
}
|
||||
console.log(`\nMost comprehensive: ${best.join(", ") || "none"}`);
|
||||
console.log(`Union (${union.length}): ${union.join(", ")}`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs();
|
||||
const results: SourceResult[] = [];
|
||||
|
||||
if (args.source === "all" || args.source === "sdk") {
|
||||
results.push(collectFromSdkTypes());
|
||||
}
|
||||
if (args.source === "all" || args.source === "cli") {
|
||||
results.push(await collectFromCli(args.prompt, args.timeoutMs));
|
||||
}
|
||||
if (args.source === "all" || args.source === "docs") {
|
||||
results.push(await collectFromDocs(args.urls));
|
||||
}
|
||||
|
||||
if (args.json) {
|
||||
console.log(JSON.stringify({ results }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
summarize(results);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
export { collectFromCli, collectFromDocs, collectFromSdkTypes };
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
import { execSync } from "child_process";
|
||||
import { createNormalizedSchema, type NormalizedSchema } from "./normalize.js";
|
||||
import type { JSONSchema7 } from "json-schema";
|
||||
|
||||
export async function extractClaudeSchema(): Promise<NormalizedSchema> {
|
||||
console.log("Extracting Claude Code schema via CLI...");
|
||||
|
||||
try {
|
||||
// Run claude CLI with --json-schema flag to get the schema
|
||||
const output = execSync("claude --output-format json --json-schema", {
|
||||
encoding: "utf-8",
|
||||
timeout: 30000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
// Parse the JSON output
|
||||
const parsed = JSON.parse(output);
|
||||
|
||||
// Extract definitions from the schema
|
||||
const definitions: Record<string, JSONSchema7> = {};
|
||||
|
||||
if (parsed.definitions) {
|
||||
for (const [name, def] of Object.entries(parsed.definitions)) {
|
||||
definitions[name] = def as JSONSchema7;
|
||||
}
|
||||
} else if (parsed.$defs) {
|
||||
for (const [name, def] of Object.entries(parsed.$defs)) {
|
||||
definitions[name] = def as JSONSchema7;
|
||||
}
|
||||
} else {
|
||||
// The output might be a single schema, use it as the root
|
||||
definitions["Schema"] = parsed as JSONSchema7;
|
||||
}
|
||||
|
||||
console.log(` [ok] Extracted ${Object.keys(definitions).length} types from CLI`);
|
||||
|
||||
return createNormalizedSchema("claude", "Claude Code SDK Schema", definitions);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.log(` [warn] CLI extraction failed: ${errorMessage}`);
|
||||
console.log(" [fallback] Using embedded schema definitions");
|
||||
return createFallbackSchema();
|
||||
}
|
||||
}
|
||||
|
||||
function createFallbackSchema(): NormalizedSchema {
|
||||
// Fallback schema based on known SDK structure
|
||||
const definitions: Record<string, JSONSchema7> = {
|
||||
SDKMessage: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string", enum: ["user", "assistant", "result"] },
|
||||
content: { type: "string" },
|
||||
timestamp: { type: "string", format: "date-time" },
|
||||
},
|
||||
required: ["type"],
|
||||
},
|
||||
SDKResultMessage: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string", const: "result" },
|
||||
result: { type: "object" },
|
||||
error: { type: "string" },
|
||||
duration_ms: { type: "number" },
|
||||
},
|
||||
required: ["type"],
|
||||
},
|
||||
Options: {
|
||||
type: "object",
|
||||
properties: {
|
||||
model: { type: "string" },
|
||||
maxTokens: { type: "number" },
|
||||
temperature: { type: "number" },
|
||||
systemPrompt: { type: "string" },
|
||||
tools: { type: "array", items: { type: "string" } },
|
||||
allowedTools: { type: "array", items: { type: "string" } },
|
||||
workingDirectory: { type: "string" },
|
||||
},
|
||||
},
|
||||
BashInput: {
|
||||
type: "object",
|
||||
properties: {
|
||||
command: { type: "string" },
|
||||
timeout: { type: "number" },
|
||||
workingDirectory: { type: "string" },
|
||||
},
|
||||
required: ["command"],
|
||||
},
|
||||
FileEditInput: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
oldText: { type: "string" },
|
||||
newText: { type: "string" },
|
||||
},
|
||||
required: ["path", "oldText", "newText"],
|
||||
},
|
||||
FileReadInput: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
startLine: { type: "number" },
|
||||
endLine: { type: "number" },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
FileWriteInput: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string" },
|
||||
content: { type: "string" },
|
||||
},
|
||||
required: ["path", "content"],
|
||||
},
|
||||
GlobInput: {
|
||||
type: "object",
|
||||
properties: {
|
||||
pattern: { type: "string" },
|
||||
path: { type: "string" },
|
||||
},
|
||||
required: ["pattern"],
|
||||
},
|
||||
GrepInput: {
|
||||
type: "object",
|
||||
properties: {
|
||||
pattern: { type: "string" },
|
||||
path: { type: "string" },
|
||||
include: { type: "string" },
|
||||
},
|
||||
required: ["pattern"],
|
||||
},
|
||||
};
|
||||
|
||||
console.log(` [ok] Using fallback schema with ${Object.keys(definitions).length} definitions`);
|
||||
|
||||
return createNormalizedSchema("claude", "Claude Code SDK Schema", definitions);
|
||||
}
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
import { execSync } from "child_process";
|
||||
import { existsSync, readFileSync, rmSync, readdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { createNormalizedSchema, type NormalizedSchema } from "./normalize.js";
|
||||
import type { JSONSchema7 } from "json-schema";
|
||||
|
||||
function normalizeCodexRefs(value: JSONSchema7): JSONSchema7 {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeCodexRefs(item as JSONSchema7)) as JSONSchema7;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
const next: Record<string, JSONSchema7> = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (key === "$ref" && typeof child === "string") {
|
||||
next[key] = child.replace("#/definitions/v2/", "#/definitions/") as JSONSchema7;
|
||||
continue;
|
||||
}
|
||||
next[key] = normalizeCodexRefs(child as JSONSchema7);
|
||||
}
|
||||
return next as JSONSchema7;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function extractCodexSchema(): Promise<NormalizedSchema> {
|
||||
console.log("Extracting Codex schema via CLI...");
|
||||
|
||||
const tempDir = join(import.meta.dirname, "..", ".temp-codex-schemas");
|
||||
|
||||
try {
|
||||
// Run codex CLI to generate JSON schema
|
||||
execSync(`codex app-server generate-json-schema --out "${tempDir}"`, {
|
||||
encoding: "utf-8",
|
||||
timeout: 30000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
// Read generated schema files from temp directory
|
||||
const definitions: Record<string, JSONSchema7> = {};
|
||||
|
||||
if (existsSync(tempDir)) {
|
||||
const files = readdirSync(tempDir).filter((f) => f.endsWith(".json"));
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(tempDir, file);
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const schema = JSON.parse(content);
|
||||
|
||||
// Extract the name from the file (e.g., "ThreadEvent.json" -> "ThreadEvent")
|
||||
const name = file.replace(".json", "");
|
||||
|
||||
if (schema.definitions) {
|
||||
for (const [defName, def] of Object.entries(schema.definitions)) {
|
||||
definitions[defName] = normalizeCodexRefs(def as JSONSchema7);
|
||||
}
|
||||
} else if (schema.$defs) {
|
||||
for (const [defName, def] of Object.entries(schema.$defs)) {
|
||||
definitions[defName] = normalizeCodexRefs(def as JSONSchema7);
|
||||
}
|
||||
} else {
|
||||
definitions[name] = normalizeCodexRefs(schema as JSONSchema7);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temp directory
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
if (Object.keys(definitions).length === 0) {
|
||||
console.log(" [warn] No schemas extracted from CLI, using fallback");
|
||||
return createFallbackSchema();
|
||||
}
|
||||
|
||||
console.log(` [ok] Extracted ${Object.keys(definitions).length} types from CLI`);
|
||||
|
||||
return createNormalizedSchema("codex", "Codex SDK Schema", definitions);
|
||||
} catch (error) {
|
||||
// Clean up temp directory on error
|
||||
if (existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.log(` [warn] CLI extraction failed: ${errorMessage}`);
|
||||
console.log(" [fallback] Using embedded schema definitions");
|
||||
return createFallbackSchema();
|
||||
}
|
||||
}
|
||||
|
||||
function createFallbackSchema(): NormalizedSchema {
|
||||
// Fallback schema based on known SDK structure
|
||||
const definitions: Record<string, JSONSchema7> = {
|
||||
ThreadEvent: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: {
|
||||
type: "string",
|
||||
enum: ["thread.created", "thread.updated", "item.created", "item.updated", "error"],
|
||||
},
|
||||
thread_id: { type: "string" },
|
||||
item: { $ref: "#/definitions/ThreadItem" },
|
||||
error: { type: "object" },
|
||||
},
|
||||
required: ["type"],
|
||||
},
|
||||
ThreadItem: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
type: { type: "string", enum: ["message", "function_call", "function_result"] },
|
||||
role: { type: "string", enum: ["user", "assistant", "system"] },
|
||||
content: {
|
||||
oneOf: [{ type: "string" }, { type: "array", items: { type: "object" } }],
|
||||
},
|
||||
status: { type: "string", enum: ["pending", "in_progress", "completed", "failed"] },
|
||||
},
|
||||
required: ["id", "type"],
|
||||
},
|
||||
CodexOptions: {
|
||||
type: "object",
|
||||
properties: {
|
||||
apiKey: { type: "string" },
|
||||
model: { type: "string" },
|
||||
baseURL: { type: "string" },
|
||||
maxTokens: { type: "number" },
|
||||
temperature: { type: "number" },
|
||||
},
|
||||
},
|
||||
ThreadOptions: {
|
||||
type: "object",
|
||||
properties: {
|
||||
instructions: { type: "string" },
|
||||
tools: { type: "array", items: { type: "object" } },
|
||||
model: { type: "string" },
|
||||
workingDirectory: { type: "string" },
|
||||
},
|
||||
},
|
||||
Input: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string", enum: ["text", "file", "image"] },
|
||||
content: { type: "string" },
|
||||
path: { type: "string" },
|
||||
mimeType: { type: "string" },
|
||||
},
|
||||
required: ["type"],
|
||||
},
|
||||
ResponseItem: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string" },
|
||||
id: { type: "string" },
|
||||
content: { type: "string" },
|
||||
function_call: { $ref: "#/definitions/FunctionCall" },
|
||||
},
|
||||
},
|
||||
FunctionCall: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
arguments: { type: "string" },
|
||||
call_id: { type: "string" },
|
||||
},
|
||||
required: ["name", "arguments"],
|
||||
},
|
||||
Message: {
|
||||
type: "object",
|
||||
properties: {
|
||||
role: { type: "string", enum: ["user", "assistant", "system"] },
|
||||
content: { type: "string" },
|
||||
},
|
||||
required: ["role", "content"],
|
||||
},
|
||||
};
|
||||
|
||||
console.log(` [ok] Using fallback schema with ${Object.keys(definitions).length} definitions`);
|
||||
|
||||
return createNormalizedSchema("codex", "Codex SDK Schema", definitions);
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
import { writeFileSync, existsSync, mkdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { extractOpenCodeSchema } from "./opencode.js";
|
||||
import { extractClaudeSchema } from "./claude.js";
|
||||
import { extractCodexSchema } from "./codex.js";
|
||||
import { extractAmpSchema } from "./amp.js";
|
||||
import { extractPiSchema } from "./pi.js";
|
||||
import { validateSchema, type NormalizedSchema } from "./normalize.js";
|
||||
|
||||
const RESOURCE_DIR = join(import.meta.dirname, "..");
|
||||
const DIST_DIR = join(RESOURCE_DIR, "artifacts", "json-schema");
|
||||
|
||||
type AgentName = "opencode" | "claude" | "codex" | "amp" | "pi";
|
||||
|
||||
const EXTRACTORS: Record<AgentName, () => Promise<NormalizedSchema>> = {
|
||||
opencode: extractOpenCodeSchema,
|
||||
claude: extractClaudeSchema,
|
||||
codex: extractCodexSchema,
|
||||
amp: extractAmpSchema,
|
||||
pi: extractPiSchema,
|
||||
};
|
||||
|
||||
function parseArgs(): { agents: AgentName[] } {
|
||||
const args = process.argv.slice(2);
|
||||
const agentArg = args.find((arg) => arg.startsWith("--agent="));
|
||||
|
||||
if (agentArg) {
|
||||
const agent = agentArg.split("=")[1] as AgentName;
|
||||
if (!EXTRACTORS[agent]) {
|
||||
console.error(`Unknown agent: ${agent}`);
|
||||
console.error(`Valid agents: ${Object.keys(EXTRACTORS).join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return { agents: [agent] };
|
||||
}
|
||||
|
||||
return { agents: Object.keys(EXTRACTORS) as AgentName[] };
|
||||
}
|
||||
|
||||
function ensureDistDir(): void {
|
||||
if (!existsSync(DIST_DIR)) {
|
||||
mkdirSync(DIST_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function extractAndWrite(agent: AgentName): Promise<boolean> {
|
||||
try {
|
||||
const extractor = EXTRACTORS[agent];
|
||||
const schema = await extractor();
|
||||
|
||||
// Validate schema
|
||||
const validation = validateSchema(schema);
|
||||
if (!validation.valid) {
|
||||
console.error(` [error] Schema validation failed for ${agent}:`);
|
||||
validation.errors.forEach((err) => console.error(` - ${err}`));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write to file
|
||||
const outputPath = join(DIST_DIR, `${agent}.json`);
|
||||
writeFileSync(outputPath, JSON.stringify(schema, null, 2));
|
||||
console.log(` [wrote] ${outputPath}`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(` [error] Failed to extract ${agent}: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log("Agent Schema Extractor");
|
||||
console.log("======================\n");
|
||||
|
||||
const { agents } = parseArgs();
|
||||
ensureDistDir();
|
||||
|
||||
console.log(`Extracting schemas for: ${agents.join(", ")}\n`);
|
||||
|
||||
const results: Record<string, boolean> = {};
|
||||
|
||||
for (const agent of agents) {
|
||||
results[agent] = await extractAndWrite(agent);
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log("Summary");
|
||||
console.log("-------");
|
||||
|
||||
const successful = Object.entries(results)
|
||||
.filter(([, success]) => success)
|
||||
.map(([name]) => name);
|
||||
const failed = Object.entries(results)
|
||||
.filter(([, success]) => !success)
|
||||
.map(([name]) => name);
|
||||
|
||||
if (successful.length > 0) {
|
||||
console.log(`Successful: ${successful.join(", ")}`);
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
console.log(`Failed: ${failed.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("\nDone!");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
import type { JSONSchema7 } from "json-schema";
|
||||
|
||||
export interface NormalizedSchema {
|
||||
$schema: string;
|
||||
$id: string;
|
||||
title: string;
|
||||
definitions: Record<string, JSONSchema7>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts OpenAPI 3.1 schema to JSON Schema draft-07.
|
||||
* OpenAPI 3.1 is largely compatible with JSON Schema draft 2020-12,
|
||||
* but we want draft-07 for broader tool compatibility.
|
||||
*/
|
||||
export function openApiToJsonSchema(schema: Record<string, unknown>): JSONSchema7 {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(schema)) {
|
||||
// Skip OpenAPI-specific fields
|
||||
if (key === "discriminator" || key === "xml" || key === "externalDocs") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle nullable (OpenAPI 3.0 style)
|
||||
if (key === "nullable" && value === true) {
|
||||
continue; // Will be handled by type conversion
|
||||
}
|
||||
|
||||
// Recursively convert nested schemas
|
||||
if (key === "properties" && typeof value === "object" && value !== null) {
|
||||
result[key] = {};
|
||||
for (const [propName, propSchema] of Object.entries(value as Record<string, unknown>)) {
|
||||
(result[key] as Record<string, unknown>)[propName] = openApiToJsonSchema(
|
||||
propSchema as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "items" && typeof value === "object" && value !== null) {
|
||||
result[key] = openApiToJsonSchema(value as Record<string, unknown>);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === "additionalProperties" && typeof value === "object" && value !== null) {
|
||||
result[key] = openApiToJsonSchema(value as Record<string, unknown>);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((key === "oneOf" || key === "anyOf" || key === "allOf") && Array.isArray(value)) {
|
||||
result[key] = value.map((item) =>
|
||||
typeof item === "object" && item !== null
|
||||
? openApiToJsonSchema(item as Record<string, unknown>)
|
||||
: item
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert $ref paths from OpenAPI to local definitions
|
||||
if (key === "$ref" && typeof value === "string") {
|
||||
result[key] = value.replace("#/components/schemas/", "#/definitions/");
|
||||
continue;
|
||||
}
|
||||
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
// Handle nullable by adding null to type array
|
||||
if (schema["nullable"] === true && result["type"]) {
|
||||
const currentType = result["type"];
|
||||
if (Array.isArray(currentType)) {
|
||||
if (!currentType.includes("null")) {
|
||||
result["type"] = [...currentType, "null"];
|
||||
}
|
||||
} else {
|
||||
result["type"] = [currentType as string, "null"];
|
||||
}
|
||||
}
|
||||
|
||||
return result as JSONSchema7;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a normalized schema with consistent metadata.
|
||||
*/
|
||||
export function createNormalizedSchema(
|
||||
id: string,
|
||||
title: string,
|
||||
definitions: Record<string, JSONSchema7>
|
||||
): NormalizedSchema {
|
||||
return {
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
$id: `https://sandbox-agent/schemas/${id}.json`,
|
||||
title,
|
||||
definitions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a schema against JSON Schema draft-07 meta-schema.
|
||||
* Basic validation - checks required fields and structure.
|
||||
*/
|
||||
export function validateSchema(schema: unknown): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (typeof schema !== "object" || schema === null) {
|
||||
return { valid: false, errors: ["Schema must be an object"] };
|
||||
}
|
||||
|
||||
const s = schema as Record<string, unknown>;
|
||||
|
||||
if (s.$schema && typeof s.$schema !== "string") {
|
||||
errors.push("$schema must be a string");
|
||||
}
|
||||
|
||||
if (s.definitions && typeof s.definitions !== "object") {
|
||||
errors.push("definitions must be an object");
|
||||
}
|
||||
|
||||
if (s.definitions && typeof s.definitions === "object") {
|
||||
for (const [name, def] of Object.entries(s.definitions as Record<string, unknown>)) {
|
||||
if (typeof def !== "object" || def === null) {
|
||||
errors.push(`Definition "${name}" must be an object`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { fetchWithCache } from "./cache.js";
|
||||
import { createNormalizedSchema, openApiToJsonSchema, type NormalizedSchema } from "./normalize.js";
|
||||
import type { JSONSchema7 } from "json-schema";
|
||||
|
||||
const OPENAPI_URLS = [
|
||||
"https://raw.githubusercontent.com/anomalyco/opencode/dev/packages/sdk/openapi.json",
|
||||
"https://raw.githubusercontent.com/sst/opencode/dev/packages/sdk/openapi.json",
|
||||
];
|
||||
|
||||
// Key schemas we want to extract
|
||||
const TARGET_SCHEMAS = [
|
||||
"Session",
|
||||
"Message",
|
||||
"Part",
|
||||
"Event",
|
||||
"PermissionRequest",
|
||||
"QuestionRequest",
|
||||
"TextPart",
|
||||
"ToolCallPart",
|
||||
"ToolResultPart",
|
||||
"ErrorPart",
|
||||
];
|
||||
|
||||
const OPENAPI_ARTIFACT_DIR = join(import.meta.dirname, "..", "artifacts", "openapi");
|
||||
const OPENAPI_ARTIFACT_PATH = join(OPENAPI_ARTIFACT_DIR, "opencode.json");
|
||||
|
||||
interface OpenAPISpec {
|
||||
components?: {
|
||||
schemas?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
function writeOpenApiArtifact(specText: string): void {
|
||||
if (!existsSync(OPENAPI_ARTIFACT_DIR)) {
|
||||
mkdirSync(OPENAPI_ARTIFACT_DIR, { recursive: true });
|
||||
}
|
||||
writeFileSync(OPENAPI_ARTIFACT_PATH, specText);
|
||||
console.log(` [wrote] ${OPENAPI_ARTIFACT_PATH}`);
|
||||
}
|
||||
|
||||
export async function extractOpenCodeSchema(): Promise<NormalizedSchema> {
|
||||
console.log("Extracting OpenCode schema from OpenAPI spec...");
|
||||
|
||||
let specText: string | null = null;
|
||||
let lastError: Error | null = null;
|
||||
for (const url of OPENAPI_URLS) {
|
||||
try {
|
||||
specText = await fetchWithCache(url);
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
}
|
||||
}
|
||||
if (!specText) {
|
||||
throw lastError ?? new Error("Failed to fetch OpenCode OpenAPI spec");
|
||||
}
|
||||
writeOpenApiArtifact(specText);
|
||||
const spec: OpenAPISpec = JSON.parse(specText);
|
||||
|
||||
if (!spec.components?.schemas) {
|
||||
throw new Error("OpenAPI spec missing components.schemas");
|
||||
}
|
||||
|
||||
const definitions: Record<string, JSONSchema7> = {};
|
||||
|
||||
// Extract all schemas, not just target ones, to preserve references
|
||||
for (const [name, schema] of Object.entries(spec.components.schemas)) {
|
||||
definitions[name] = openApiToJsonSchema(schema as Record<string, unknown>);
|
||||
}
|
||||
|
||||
// Verify target schemas exist
|
||||
const missing = TARGET_SCHEMAS.filter((name) => !definitions[name]);
|
||||
if (missing.length > 0) {
|
||||
console.warn(` [warn] Missing expected schemas: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
const found = TARGET_SCHEMAS.filter((name) => definitions[name]);
|
||||
console.log(` [ok] Extracted ${Object.keys(definitions).length} schemas (${found.length} target schemas)`);
|
||||
|
||||
return createNormalizedSchema("opencode", "OpenCode SDK Schema", definitions);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue