Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,11 @@ jobs:
- name: MCP tests
run: bun run mcp:test

- name: CLI tests
run: bun run cli:test

- name: CLI build
run: bun run --cwd apps/cli build

- name: Build
run: bun run build
22 changes: 22 additions & 0 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# OOXML CLI

Use the OOXML reference from a terminal or an agent skill. Sign in with Clerk to query the hosted ooxml.dev service.

While the package is private, run it from the repository root:

```bash
bun run ooxml login
bun run ooxml search "paragraph spacing"
bun run ooxml element w:p
bun run ooxml children w:p
bun run ooxml attributes w:p
bun run ooxml logout
```

During this private test, the CLI stores sign-in tokens as plain text in your application data directory. Do not use it from a shared account. The CLI does not store queries or results.

The bundled [`research-ooxml`](../../skills/research-ooxml/SKILL.md) skill tells agents which commands to use and how to combine schema and specification evidence.

MCP is an internal transport detail. It is not part of the CLI or skill interface.

The package remains private while we test it. Secure credential storage and npm publishing are separate release steps.
32 changes: 32 additions & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@ooxml-dev/cli",
"version": "0.1.0",
"description": "Query the ooxml.dev reference from a terminal or agent skill.",
"private": true,
"type": "module",
"bin": {
"ooxml": "./dist/cli.mjs"
},
"files": [
"dist"
],
"engines": {
"node": ">=20"
},
"scripts": {
"build": "vp pack --clean",
"dev": "bun src/cli.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@modelcontextprotocol/client": "2.0.0",
"open": "11.0.0",
"proper-lockfile": "4.1.2"
},
"devDependencies": {
"@types/node": "^20.19.0",
"@types/proper-lockfile": "^4.1.4",
"typescript": "catalog:",
"vite-plus": "catalog:"
}
}
169 changes: 169 additions & 0 deletions apps/cli/src/arguments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
export type OoxmlCommand =
| { name: "help" }
| { name: "version" }
| { name: "login" }
| { name: "logout" }
| { name: "query"; tool: string; input: Record<string, unknown> };

interface ParsedOptions {
positionals: string[];
values: Map<string, string>;
}

function parseOptions(args: string[], allowed: string[]): ParsedOptions {
const positionals: string[] = [];
const values = new Map<string, string>();
for (let index = 0; index < args.length; index += 1) {
const argument = args[index];
if (!argument.startsWith("--")) {
positionals.push(argument);
continue;
}
if (!allowed.includes(argument)) throw new Error(`Unknown option: ${argument}`);
const value = args[index + 1];
if (!value || value.startsWith("--")) throw new Error(`${argument} needs a value`);
values.set(argument, value);
index += 1;
}
return { positionals, values };
}

function singleValue(args: string[], usage: string, options: string[] = []): ParsedOptions {
const parsed = parseOptions(args, options);
if (parsed.positionals.length !== 1) throw new Error(`Usage: ${usage}`);
return parsed;
}

function optionalNumber(
value: string | undefined,
option: string,
minimum: number,
maximum: number,
) {
if (value === undefined) return undefined;
const number = Number(value);
if (!Number.isInteger(number) || number < minimum || number > maximum) {
throw new Error(`${option} must be an integer between ${minimum} and ${maximum}`);
}
return number;
}

function withProfile(qname: string, profile: string | undefined): Record<string, unknown> {
return profile ? { qname, profile } : { qname };
}

export function parseArguments(args: string[]): OoxmlCommand {
const [command, ...rest] = args;
if (!command || command === "help" || command === "--help" || command === "-h") {
return { name: "help" };
}
if (command === "--version" || command === "-v" || command === "version") {
if (rest.length) throw new Error("The version command does not accept arguments");
return { name: "version" };
}
if (command === "login" || command === "logout") {
if (rest.length) throw new Error(`The ${command} command does not accept arguments`);
return { name: command };
}

if (command === "search") {
const parsed = singleValue(rest, "ooxml search <query> [--part <1-4>] [--limit <1-20>]", [
"--part",
"--limit",
]);
const part = optionalNumber(parsed.values.get("--part"), "--part", 1, 4);
const limit = optionalNumber(parsed.values.get("--limit"), "--limit", 1, 20);
return {
name: "query",
tool: "ooxml_search",
input: { query: parsed.positionals[0], ...(part && { part }), ...(limit && { limit }) },
};
}

if (command === "section") {
const parsed = singleValue(rest, "ooxml section <section-id> [--part <1-4>]", ["--part"]);
const part = optionalNumber(parsed.values.get("--part"), "--part", 1, 4);
return {
name: "query",
tool: "ooxml_section",
input: { section_id: parsed.positionals[0], ...(part && { part }) },
};
}

if (command === "parts") {
const parsed = parseOptions(rest, ["--part"]);
if (parsed.positionals.length) throw new Error("Usage: ooxml parts [--part <1-4>]");
const part = optionalNumber(parsed.values.get("--part"), "--part", 1, 4);
return { name: "query", tool: "ooxml_parts", input: part ? { part } : {} };
}

const qnameTools: Record<string, string> = {
element: "ooxml_element",
type: "ooxml_type",
children: "ooxml_children",
attributes: "ooxml_attributes",
enum: "ooxml_enum",
};
if (Object.hasOwn(qnameTools, command)) {
const parsed = singleValue(rest, `ooxml ${command} <qname> [--profile <profile>]`, [
"--profile",
]);
return {
name: "query",
tool: qnameTools[command],
input: withProfile(parsed.positionals[0], parsed.values.get("--profile")),
};
}

if (command === "namespace") {
const parsed = parseOptions(rest, ["--uri"]);
if (
parsed.positionals.length > 1 ||
(parsed.positionals.length && parsed.values.has("--uri"))
) {
throw new Error("Usage: ooxml namespace [query] [--uri <exact-uri>]");
}
return {
name: "query",
tool: "ooxml_namespace",
input: parsed.values.has("--uri")
? { uri: parsed.values.get("--uri") }
: parsed.positionals.length
? { query: parsed.positionals[0] }
: {},
};
}

if (command === "package-part") {
const parsed = parseOptions(rest, ["--content-type", "--relationship-type"]);
const modes = [
parsed.positionals.length ? "query" : undefined,
parsed.values.has("--content-type") ? "content_type" : undefined,
parsed.values.has("--relationship-type") ? "relationship_type" : undefined,
].filter(Boolean);
if (parsed.positionals.length > 1 || modes.length > 1) {
throw new Error(
"Usage: ooxml package-part [query] [--content-type <type> | --relationship-type <uri>]",
);
}
const input = parsed.positionals.length
? { query: parsed.positionals[0] }
: parsed.values.has("--content-type")
? { content_type: parsed.values.get("--content-type") }
: parsed.values.has("--relationship-type")
? { relationship_type: parsed.values.get("--relationship-type") }
: {};
return { name: "query", tool: "ooxml_package_part", input };
}

if (command === "preset-shape") {
const parsed = singleValue(rest, "ooxml preset-shape <shape>");
return {
name: "query",
tool: "ooxml_preset_shape",
input: { shape: parsed.positionals[0] },
};
}

throw new Error(`Unknown command: ${command}`);
}
143 changes: 143 additions & 0 deletions apps/cli/src/browser-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import open from "open";
import type { CliOAuthProvider } from "./oauth-provider.js";

const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000;

export function isSafeAuthorizationUrl(url: URL): boolean {
return (
url.protocol === "https:" ||
(url.protocol === "http:" && ["127.0.0.1", "::1", "localhost"].includes(url.hostname))
);
}

export function signInMessage(url: URL): string {
return `Opening your browser to sign in…\nIf it does not open, visit:\n${url}`;
}

function callbackPage(success: boolean): string {
const title = success ? "Signed in to ooxml.dev" : "Sign-in failed";
const detail = success
? "You can close this window and return to the terminal."
: "Return to the terminal and try again.";
const closeWindow = success ? "setTimeout(()=>window.close(),2000);" : "";
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title><style>body{margin:0;background:#f8f7f4;color:#292524;font:16px system-ui,sans-serif;display:grid;min-height:100vh;place-items:center}main{max-width:420px;padding:40px;text-align:center}h1{font-size:26px}p{color:#78716c}</style></head><body><main><h1>${title}</h1><p>${detail}</p></main><script>history.replaceState(null,"","/complete");${closeWindow}</script></body></html>`;
}

interface OAuthCallback {
port: number;
result: Promise<URLSearchParams>;
}

export async function startOAuthCallback(
port: number,
validatesState: (state: string | null) => boolean,
timeoutMs = CALLBACK_TIMEOUT_MS,
): Promise<OAuthCallback> {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | undefined;
let resolveResult!: (params: URLSearchParams) => void;
let rejectResult!: (error: Error) => void;
const result = new Promise<URLSearchParams>((resolve, reject) => {
resolveResult = resolve;
rejectResult = reject;
});
result.catch(() => {});

const server = createServer((request, response) => {
if (settled) {
response.writeHead(409).end("Sign-in callback already handled");
return;
}
const address = server.address() as AddressInfo;
Comment thread
caio-pizzol marked this conversation as resolved.
const expectedHost = `127.0.0.1:${address.port}`;
if (request.headers.host !== expectedHost) {
response.writeHead(400).end("Invalid sign-in callback");
return;
}

const url = new URL(request.url ?? "/", `http://${expectedHost}`);
if (url.pathname !== "/callback") {
response.writeHead(404).end();
return;
}
if (!validatesState(url.searchParams.get("state"))) {
response.writeHead(400, { "Cache-Control": "no-store" }).end("Invalid sign-in state");
return;
}
if (!url.searchParams.has("code") && !url.searchParams.has("error")) {
response.writeHead(400, { "Cache-Control": "no-store" }).end("Invalid sign-in callback");
return;
}

settled = true;
if (timeout) clearTimeout(timeout);
const success = Boolean(url.searchParams.get("code")) && !url.searchParams.get("error");
response.writeHead(success ? 200 : 400, {
"Cache-Control": "no-store",
"Content-Type": "text/html; charset=utf-8",
"Referrer-Policy": "no-referrer",
});
response.end(callbackPage(success));
server.close();
resolveResult(url.searchParams);
});

try {
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, "127.0.0.1", () => {
server.removeAllListeners("error");
resolve();
});
});
} catch (error) {
settled = true;
const callbackError = new Error(`Could not start the local sign-in callback on port ${port}`, {
cause: error,
});
rejectResult(callbackError);
throw callbackError;
}

server.once("error", (error) => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
rejectResult(new Error("The local sign-in callback stopped", { cause: error }));
});
timeout = setTimeout(() => {
if (settled) return;
settled = true;
server.close();
rejectResult(new Error("Timed out waiting for browser sign-in"));
}, timeoutMs);

return { port: (server.address() as AddressInfo).port, result };
}

export async function authorizeInBrowser(
provider: CliOAuthProvider,
port: number,
finishAuth: (params: URLSearchParams) => Promise<void>,
): Promise<void> {
const authorizationUrl = provider.pendingAuthorizationUrl;
if (!authorizationUrl) throw new Error("The OOXML service did not provide a sign-in URL");
if (!isSafeAuthorizationUrl(authorizationUrl)) {
throw new Error("The OOXML service returned an unsafe sign-in URL");
}

const callback = await startOAuthCallback(port, (state) => provider.validatesState(state));
console.error(signInMessage(authorizationUrl));
try {
await open(authorizationUrl.toString());
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
} catch {}

const params = await callback.result;
if (params.get("error")) throw new Error("Sign-in was canceled or denied");
if (!provider.validatesState(params.get("state"))) {
throw new Error("Sign-in could not be verified. Try again.");
}
await finishAuth(params);
}
Loading