Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/typed-api-proxy-generate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@executor-js/sdk": minor
"executor": minor
---

Add `executor generate`: export the tool catalog as an OpenAPI document or a typed TypeScript client, backed by direct REST tool invocation.

`executor generate` writes an OpenAPI 3.1 document (default
`executor.openapi.json`) describing every visible tool as a REST operation,
so any OpenAPI client generator (openapi-typescript, openapi-generator,
Kiota, ...) produces a fully typed client for your catalog. `--format
typescript` emits a ready-made self-contained TypeScript client instead (or
`both`). The document's operations are real: new `POST
/tools/invoke/{path}` invokes one tool directly over HTTP (404 for unknown
tools, `execution_paused` with resume coordinates for approval-gated calls),
`GET /tools/export/openapi` serves the live document, and `GET /tools/export`
plus `executor.tools.export()` return the whole schema-bearing catalog in one
read. Generation compiles schemas in chunks and stays fast past 10,000 tools
across many integrations.
159 changes: 159 additions & 0 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";

import { ExecutorApi, checkForUpdate } from "@executor-js/api";
import {
ConnectionName,
IntegrationSlug,
getExecutorServerAuthorizationHeader,
normalizeExecutorServerConnection,
normalizeExecutorServerOrigin,
Expand All @@ -77,6 +79,7 @@ import {
type ExecutorServerConnection,
type ExecutorServerConnectionInput,
} from "@executor-js/sdk/shared";
import { generateOpenApiSpec, generateToolProxySource } from "@executor-js/sdk/core";
import {
decodeAccessTokenClaims,
discoverCliLogin,
Expand Down Expand Up @@ -2710,6 +2713,161 @@ const mcpCommand = Command.make(
}),
).pipe(Command.withDescription("Start an MCP server over stdio"));

// ---------------------------------------------------------------------------
// Generate — export the instance's tool catalog for typed clients.
// Default artifact: an OpenAPI 3.1 document (plug it into any client
// generator). Optional: a ready-made self-contained TypeScript client.
// ---------------------------------------------------------------------------

const DEFAULT_GENERATE_OPENAPI_OUTPUT = "executor.openapi.json";
const DEFAULT_GENERATE_TYPESCRIPT_OUTPUT = "executor.gen.ts";

const generateCommand = Command.make(
"generate",
{
format: Options.choice("format", ["openapi", "typescript", "both"] as const).pipe(
Options.withDefault("openapi"),
Options.withDescription(
"What to emit: an OpenAPI 3.1 document (default; feed it to any client generator), a self-contained TypeScript client, or both.",
),
),
output: Options.string("output").pipe(
Options.withAlias("o"),
Options.optional,
Options.withDescription(
`Output path. Defaults to ${DEFAULT_GENERATE_OPENAPI_OUTPUT} / ${DEFAULT_GENERATE_TYPESCRIPT_OUTPUT} by format; with --format both this names the OpenAPI file and the TypeScript file keeps its default.`,
),
),
integration: Options.string("integration").pipe(
Options.optional,
Options.withDescription("Only generate tools from this integration slug."),
),
connection: Options.string("connection").pipe(
Options.optional,
Options.withDescription("Only generate tools from this connection name."),
),
includeStatic: Options.boolean("include-static").pipe(
Options.withDefault(false),
Options.withDescription(
"Include Executor's built-in static tools (executor.*, search, describe.*).",
),
),
baseUrl: serverBaseUrl,
server: serverProfile,
scope,
},
({ format, output, integration, connection, includeStatic, baseUrl, server, scope }) =>
Effect.gen(function* () {
applyScope(scope);
const target = serverTargetFromOptions({ baseUrl, server });
const serverConnection = yield* resolveExecutorServerConnection(target);
const client = yield* makeApiClient(serverConnection);

const catalog = yield* client.tools
.export({
query: {
...(Option.isSome(integration)
? { integration: IntegrationSlug.make(integration.value) }
: {}),
...(Option.isSome(connection)
? { connection: ConnectionName.make(connection.value) }
: {}),
},
})
.pipe(Effect.mapError(toError));

const filtered = includeStatic
? catalog
: {
connections: catalog.connections
.map((entry) => ({
...entry,
tools: entry.tools.filter((tool) => tool.static !== true),
}))
.filter((entry) => entry.tools.length > 0),
};

const totalTools = filtered.connections.reduce((sum, entry) => sum + entry.tools.length, 0);
if (totalTools === 0) {
return yield* Effect.fail(
new Error(
[
"No tools matched, nothing to generate.",
`Server: ${serverConnection.origin}`,
`Check filters, or add an integration first: ${cliPrefix} web`,
].join("\n"),
),
);
}

const fs = yield* FileSystem.FileSystem;
const path = yield* PlatformPath.Path;
const resolveOutput = (candidate: string): string =>
path.isAbsolute(candidate) ? candidate : path.join(process.cwd(), candidate);
const writeArtifact = (candidate: string, contents: string) =>
Effect.gen(function* () {
const outputPath = resolveOutput(candidate);
yield* fs.makeDirectory(path.dirname(outputPath), { recursive: true });
yield* fs.writeFileString(outputPath, contents);
return outputPath;
});
const requestedOutput = Option.getOrUndefined(output);

if (format === "openapi" || format === "both") {
const generated = yield* Effect.try({
try: () => generateOpenApiSpec(filtered, { serverUrl: serverConnection.apiBaseUrl }),
catch: (cause) =>
cause instanceof Error
? cause
: new Error(`Failed to generate the OpenAPI document: ${String(cause)}`),
});
const outputPath = yield* writeArtifact(
requestedOutput ?? DEFAULT_GENERATE_OPENAPI_OUTPUT,
// @effect-diagnostics-next-line preferSchemaOverJson:off
`${JSON.stringify(generated.document, null, 2)}\n`,
);
console.log(
`Generated ${outputPath} (OpenAPI 3.1, ${generated.toolCount} tools, ${generated.connectionCount} connections).`,
);
console.log("");
console.log("Feed it to your client generator, e.g.:");
console.log(` npx openapi-typescript ${path.basename(outputPath)} -o executor-api.ts`);
console.log(
`Live copy: ${serverConnection.apiBaseUrl}/tools/export/openapi (bearer auth).`,
);
}

if (format === "typescript" || format === "both") {
const generated = yield* Effect.try({
try: () => generateToolProxySource(filtered),
catch: (cause) =>
cause instanceof Error
? cause
: new Error(`Failed to generate TypeScript source: ${String(cause)}`),
});
const tsOutput =
format === "typescript"
? (requestedOutput ?? DEFAULT_GENERATE_TYPESCRIPT_OUTPUT)
: DEFAULT_GENERATE_TYPESCRIPT_OUTPUT;
const outputPath = yield* writeArtifact(tsOutput, generated.source);
if (format === "both") console.log("");
console.log(
`Generated ${outputPath} (TypeScript client, ${generated.toolCount} tools, ${generated.connectionCount} connections).`,
);
console.log("");
console.log("Use it:");
console.log(
` import { createExecutorClient } from "./${path.basename(tsOutput, ".ts")}";`,
);
console.log(` const executor = createExecutorClient();`);
}
}).pipe(Effect.mapError(toError)),
).pipe(
Command.withDescription(
"Export this instance's tool catalog as an OpenAPI document (default) or a typed TypeScript client",
),
);

// ---------------------------------------------------------------------------
// Service — register the daemon with the OS so it survives app-quit + restart
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -3112,6 +3270,7 @@ const root = Command.make("executor").pipe(
daemonCommand,
serviceCommand,
mcpCommand,
generateCommand,
openCommand,
docsCommand,
] as const),
Expand Down
41 changes: 41 additions & 0 deletions apps/docs/local/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,44 @@ resume it:
```bash
executor resume --execution-id exec_123
```

## Generate a typed client

`executor generate` exports your tool catalog as an OpenAPI 3.1 document:
one REST operation per tool, with full input/output schemas. Feed it to
whatever client generator you already use (openapi-typescript,
openapi-generator, Kiota, ...) and every call is typed end to end while still
executing through Executor (auth, policies, and approvals included):

```bash
executor generate # writes executor.openapi.json
npx openapi-typescript executor.openapi.json -o executor-api.ts
```

The same document is served live at `GET /api/tools/export/openapi` (bearer
auth), so generators that take a URL can point straight at the instance. The
operations it describes are real endpoints: `POST /api/tools/invoke/{tool
path}` runs the tool and returns `{ ok: true, data }` or `{ ok: false, error }`;
approval-gated calls return `error.code: "execution_paused"` with resume
coordinates.

Prefer a ready-made client instead of running a generator? `--format
typescript` writes a single self-contained TypeScript file with a
dependency-free runtime client:

```bash
executor generate --format typescript # writes executor.gen.ts
executor generate --format both # both artifacts
executor generate --integration github # only one integration
```

```ts
import { createExecutorClient } from "./executor.gen";

const executor = createExecutorClient(); // EXECUTOR_API_KEY / EXECUTOR_AUTH_TOKEN
const created = await executor.github.org.main.issues.create({ title: "Hi" });
if (created.ok) console.log(created.data.number);
```

Calls that pause for approval throw `ExecutorPausedError` with the approval
URL. Re-run `executor generate` whenever your catalog changes.
Loading
Loading