diff --git a/.agents/skills/shift/SKILL.md b/.agents/skills/shift/SKILL.md new file mode 100644 index 000000000..62e91070b --- /dev/null +++ b/.agents/skills/shift/SKILL.md @@ -0,0 +1,74 @@ +--- +name: shift +description: Inspect and work with the font and editor currently open in the live Shift desktop application through its local MCP code-mode API. Use for live Shift sessions, current glyph/source/selection/tool state, font-development investigation, and agent workflows that need the running app rather than a closed file. +--- + +# Live Shift + +Use the live MCP connection when the user refers to the font, glyph, source, selection, tool, or view currently open in Shift. Use `shift-cli` instead for closed documents, batch processing, or CI. + +## Connect + +Configure your agent's native MCP client once with the running app's URL and private token; see the [one-time setup guide](../../../docs/mcp.md). Release, Nightly, Dev, and Nightly Dev have separate named connections. Never check in or disclose the token. No Shift-specific client CLI is needed. + +## Discover the API + +Call the native `shift.describe` MCP tool. The typed API exposes `shift.sessions.list()`, `shift.editor.inspect({ windowId })`, `shift.font.get({ windowId })`, `shift.glyphs.list({ windowId, limit?, cursor?, sourceId? })`, `shift.glyphs.get({ windowId, glyphId })` or `shift.glyphs.get({ windowId, name })`, and `shift.layers.get({ windowId, glyphId, sourceId })` inside `shift.execute`. + +## Execute code + +Call the native `shift.execute` MCP tool, passing an async zero-argument function in its `code` argument: + +```js +async () => { + const sessions = await shift.sessions.list(); + const session = sessions.find(({ sessionId }) => sessionId === "..."); + if (!session) throw new Error("Target Shift session is not open"); + return shift.editor.inspect({ windowId: session.windowId }); +} +``` + +Always target the explicit `windowId` returned by `sessions.list()`. Do not assume focus is stable. `editor.inspect()` reports a point-in-time renderer observation; `font.get()` returns Home-safe metadata, metrics, axes, global master sources, named instances, and glyph count. Individual glyphs may advertise additional authored `sourceIds` for non-master support layers; those IDs are also valid for layer reads. `glyphs.get()` resolves one glyph by exact name or stable ID, without scanning the directory; provide exactly one of `name` or `glyphId`. `glyphs.list()` returns directory entries with an opaque `nextCursor` (pass it back as `cursor` until null). Supply a specific `sourceId` to include authored `structure` for each glyph in a bounded page; `null` means no layer in that source. `layers.get()` returns positions and structure for one authored glyph/source layer, or `null` if the layer is absent. Preview sessions expose font and glyph directory facts but have no authored layers; these operations are read-only. + +For example, count authored anchors in one explicitly chosen source, paging without returning every glyph: + +```js +async () => { + const session = (await shift.sessions.list()).find((session) => session.sessionId === "..."); + if (!session) throw new Error("Target Shift session is not open"); + const font = await shift.font.get({ windowId: session.windowId }); + const source = font.sources.find((source) => source.name === "Regular"); + if (!source) throw new Error("Target source is not open"); + let cursor; + let anchors = 0; + do { + const page = await shift.glyphs.list({ windowId: session.windowId, sourceId: source.id, cursor, limit: 20 }); + for (const glyph of page.items) anchors += glyph.structure?.anchors.length ?? 0; + cursor = page.nextCursor ?? undefined; + } while (cursor); + return { sourceId: source.id, anchors }; +} +``` + +A live directory may change between pages; a cursor is not a frozen snapshot. Large scans may exceed the sandbox deadline: return a partial result and `nextCursor` to continue in another call rather than assuming the entire font fits one execution. + +Keep returned values focused. Filter and map inside code mode instead of returning complete intermediate responses. Generated code has no filesystem, network, environment, Node.js, or Electron access. + +## Saved fonts without Shift + +Use the Rust `shift-cli` binary, not the live MCP, for a file that is not open in the app. From the Shift checkout, install or update it after pulling changes with `cargo install --path crates/shift-cli --bin shift-cli --locked --force` (inside the repository's Nix dev shell). For a one-off query without installation, use `cargo run -p shift-cli --` in place of `shift-cli`. + +```sh +shift-cli inspect --json /absolute/path/Family.shift +shift-cli glyph inspect /absolute/path/Family.ufo A --json +``` + +`inspect --json` exposes `.shift` glyph directories and per-layer counts; `glyph inspect --json` accepts `.shift`, UFO, Designspace, Glyphs, TTF, and OTF and reports glyph structure, source-layer presence, and resolved geometry. Its `--view` flag changes human-readable output, **not** the JSON report. Use `fontTools` for low-level UFO/OpenType format questions where useful, but do not describe its data as Shift's authored source model. Neither CLI JSON command currently returns every authored layer's point and anchor coordinates: ask for a CLI read extension if a saved-file question requires those rather than substituting interpolated/resolved geometry. Do not claim the disk file includes unsaved app edits. + +## Safety and interpretation + +- The connection secret is transport material, not a user login. Never print or include it in conversation output. +- A preview session is read-only; do not imply that it can be edited. +- `dragging: true` or `applyStatus !== "idle"` means the observation may include transient or unsettled editor state. Say so when it affects the answer. +- A missing or closed window is an error. Re-list sessions instead of silently switching targets. +- Do not use this interface as a replacement for regression tests. Reproduce and prove fixes through the repository's normal unit or Electron E2E boundary. diff --git a/.claude/skills/shift/SKILL.md b/.claude/skills/shift/SKILL.md new file mode 100644 index 000000000..62e91070b --- /dev/null +++ b/.claude/skills/shift/SKILL.md @@ -0,0 +1,74 @@ +--- +name: shift +description: Inspect and work with the font and editor currently open in the live Shift desktop application through its local MCP code-mode API. Use for live Shift sessions, current glyph/source/selection/tool state, font-development investigation, and agent workflows that need the running app rather than a closed file. +--- + +# Live Shift + +Use the live MCP connection when the user refers to the font, glyph, source, selection, tool, or view currently open in Shift. Use `shift-cli` instead for closed documents, batch processing, or CI. + +## Connect + +Configure your agent's native MCP client once with the running app's URL and private token; see the [one-time setup guide](../../../docs/mcp.md). Release, Nightly, Dev, and Nightly Dev have separate named connections. Never check in or disclose the token. No Shift-specific client CLI is needed. + +## Discover the API + +Call the native `shift.describe` MCP tool. The typed API exposes `shift.sessions.list()`, `shift.editor.inspect({ windowId })`, `shift.font.get({ windowId })`, `shift.glyphs.list({ windowId, limit?, cursor?, sourceId? })`, `shift.glyphs.get({ windowId, glyphId })` or `shift.glyphs.get({ windowId, name })`, and `shift.layers.get({ windowId, glyphId, sourceId })` inside `shift.execute`. + +## Execute code + +Call the native `shift.execute` MCP tool, passing an async zero-argument function in its `code` argument: + +```js +async () => { + const sessions = await shift.sessions.list(); + const session = sessions.find(({ sessionId }) => sessionId === "..."); + if (!session) throw new Error("Target Shift session is not open"); + return shift.editor.inspect({ windowId: session.windowId }); +} +``` + +Always target the explicit `windowId` returned by `sessions.list()`. Do not assume focus is stable. `editor.inspect()` reports a point-in-time renderer observation; `font.get()` returns Home-safe metadata, metrics, axes, global master sources, named instances, and glyph count. Individual glyphs may advertise additional authored `sourceIds` for non-master support layers; those IDs are also valid for layer reads. `glyphs.get()` resolves one glyph by exact name or stable ID, without scanning the directory; provide exactly one of `name` or `glyphId`. `glyphs.list()` returns directory entries with an opaque `nextCursor` (pass it back as `cursor` until null). Supply a specific `sourceId` to include authored `structure` for each glyph in a bounded page; `null` means no layer in that source. `layers.get()` returns positions and structure for one authored glyph/source layer, or `null` if the layer is absent. Preview sessions expose font and glyph directory facts but have no authored layers; these operations are read-only. + +For example, count authored anchors in one explicitly chosen source, paging without returning every glyph: + +```js +async () => { + const session = (await shift.sessions.list()).find((session) => session.sessionId === "..."); + if (!session) throw new Error("Target Shift session is not open"); + const font = await shift.font.get({ windowId: session.windowId }); + const source = font.sources.find((source) => source.name === "Regular"); + if (!source) throw new Error("Target source is not open"); + let cursor; + let anchors = 0; + do { + const page = await shift.glyphs.list({ windowId: session.windowId, sourceId: source.id, cursor, limit: 20 }); + for (const glyph of page.items) anchors += glyph.structure?.anchors.length ?? 0; + cursor = page.nextCursor ?? undefined; + } while (cursor); + return { sourceId: source.id, anchors }; +} +``` + +A live directory may change between pages; a cursor is not a frozen snapshot. Large scans may exceed the sandbox deadline: return a partial result and `nextCursor` to continue in another call rather than assuming the entire font fits one execution. + +Keep returned values focused. Filter and map inside code mode instead of returning complete intermediate responses. Generated code has no filesystem, network, environment, Node.js, or Electron access. + +## Saved fonts without Shift + +Use the Rust `shift-cli` binary, not the live MCP, for a file that is not open in the app. From the Shift checkout, install or update it after pulling changes with `cargo install --path crates/shift-cli --bin shift-cli --locked --force` (inside the repository's Nix dev shell). For a one-off query without installation, use `cargo run -p shift-cli --` in place of `shift-cli`. + +```sh +shift-cli inspect --json /absolute/path/Family.shift +shift-cli glyph inspect /absolute/path/Family.ufo A --json +``` + +`inspect --json` exposes `.shift` glyph directories and per-layer counts; `glyph inspect --json` accepts `.shift`, UFO, Designspace, Glyphs, TTF, and OTF and reports glyph structure, source-layer presence, and resolved geometry. Its `--view` flag changes human-readable output, **not** the JSON report. Use `fontTools` for low-level UFO/OpenType format questions where useful, but do not describe its data as Shift's authored source model. Neither CLI JSON command currently returns every authored layer's point and anchor coordinates: ask for a CLI read extension if a saved-file question requires those rather than substituting interpolated/resolved geometry. Do not claim the disk file includes unsaved app edits. + +## Safety and interpretation + +- The connection secret is transport material, not a user login. Never print or include it in conversation output. +- A preview session is read-only; do not imply that it can be edited. +- `dragging: true` or `applyStatus !== "idle"` means the observation may include transient or unsettled editor state. Say so when it affects the answer. +- A missing or closed window is an error. Re-list sessions instead of silently switching targets. +- Do not use this interface as a replacement for regression tests. Reproduce and prove fixes through the repository's normal unit or Electron E2E boundary. diff --git a/.codex/skills/shift/SKILL.md b/.codex/skills/shift/SKILL.md new file mode 100644 index 000000000..62e91070b --- /dev/null +++ b/.codex/skills/shift/SKILL.md @@ -0,0 +1,74 @@ +--- +name: shift +description: Inspect and work with the font and editor currently open in the live Shift desktop application through its local MCP code-mode API. Use for live Shift sessions, current glyph/source/selection/tool state, font-development investigation, and agent workflows that need the running app rather than a closed file. +--- + +# Live Shift + +Use the live MCP connection when the user refers to the font, glyph, source, selection, tool, or view currently open in Shift. Use `shift-cli` instead for closed documents, batch processing, or CI. + +## Connect + +Configure your agent's native MCP client once with the running app's URL and private token; see the [one-time setup guide](../../../docs/mcp.md). Release, Nightly, Dev, and Nightly Dev have separate named connections. Never check in or disclose the token. No Shift-specific client CLI is needed. + +## Discover the API + +Call the native `shift.describe` MCP tool. The typed API exposes `shift.sessions.list()`, `shift.editor.inspect({ windowId })`, `shift.font.get({ windowId })`, `shift.glyphs.list({ windowId, limit?, cursor?, sourceId? })`, `shift.glyphs.get({ windowId, glyphId })` or `shift.glyphs.get({ windowId, name })`, and `shift.layers.get({ windowId, glyphId, sourceId })` inside `shift.execute`. + +## Execute code + +Call the native `shift.execute` MCP tool, passing an async zero-argument function in its `code` argument: + +```js +async () => { + const sessions = await shift.sessions.list(); + const session = sessions.find(({ sessionId }) => sessionId === "..."); + if (!session) throw new Error("Target Shift session is not open"); + return shift.editor.inspect({ windowId: session.windowId }); +} +``` + +Always target the explicit `windowId` returned by `sessions.list()`. Do not assume focus is stable. `editor.inspect()` reports a point-in-time renderer observation; `font.get()` returns Home-safe metadata, metrics, axes, global master sources, named instances, and glyph count. Individual glyphs may advertise additional authored `sourceIds` for non-master support layers; those IDs are also valid for layer reads. `glyphs.get()` resolves one glyph by exact name or stable ID, without scanning the directory; provide exactly one of `name` or `glyphId`. `glyphs.list()` returns directory entries with an opaque `nextCursor` (pass it back as `cursor` until null). Supply a specific `sourceId` to include authored `structure` for each glyph in a bounded page; `null` means no layer in that source. `layers.get()` returns positions and structure for one authored glyph/source layer, or `null` if the layer is absent. Preview sessions expose font and glyph directory facts but have no authored layers; these operations are read-only. + +For example, count authored anchors in one explicitly chosen source, paging without returning every glyph: + +```js +async () => { + const session = (await shift.sessions.list()).find((session) => session.sessionId === "..."); + if (!session) throw new Error("Target Shift session is not open"); + const font = await shift.font.get({ windowId: session.windowId }); + const source = font.sources.find((source) => source.name === "Regular"); + if (!source) throw new Error("Target source is not open"); + let cursor; + let anchors = 0; + do { + const page = await shift.glyphs.list({ windowId: session.windowId, sourceId: source.id, cursor, limit: 20 }); + for (const glyph of page.items) anchors += glyph.structure?.anchors.length ?? 0; + cursor = page.nextCursor ?? undefined; + } while (cursor); + return { sourceId: source.id, anchors }; +} +``` + +A live directory may change between pages; a cursor is not a frozen snapshot. Large scans may exceed the sandbox deadline: return a partial result and `nextCursor` to continue in another call rather than assuming the entire font fits one execution. + +Keep returned values focused. Filter and map inside code mode instead of returning complete intermediate responses. Generated code has no filesystem, network, environment, Node.js, or Electron access. + +## Saved fonts without Shift + +Use the Rust `shift-cli` binary, not the live MCP, for a file that is not open in the app. From the Shift checkout, install or update it after pulling changes with `cargo install --path crates/shift-cli --bin shift-cli --locked --force` (inside the repository's Nix dev shell). For a one-off query without installation, use `cargo run -p shift-cli --` in place of `shift-cli`. + +```sh +shift-cli inspect --json /absolute/path/Family.shift +shift-cli glyph inspect /absolute/path/Family.ufo A --json +``` + +`inspect --json` exposes `.shift` glyph directories and per-layer counts; `glyph inspect --json` accepts `.shift`, UFO, Designspace, Glyphs, TTF, and OTF and reports glyph structure, source-layer presence, and resolved geometry. Its `--view` flag changes human-readable output, **not** the JSON report. Use `fontTools` for low-level UFO/OpenType format questions where useful, but do not describe its data as Shift's authored source model. Neither CLI JSON command currently returns every authored layer's point and anchor coordinates: ask for a CLI read extension if a saved-file question requires those rather than substituting interpolated/resolved geometry. Do not claim the disk file includes unsaved app edits. + +## Safety and interpretation + +- The connection secret is transport material, not a user login. Never print or include it in conversation output. +- A preview session is read-only; do not imply that it can be edited. +- `dragging: true` or `applyStatus !== "idle"` means the observation may include transient or unsettled editor state. Say so when it affects the answer. +- A missing or closed window is an error. Re-list sessions instead of silently switching targets. +- Do not use this interface as a replacement for regression tests. Reproduce and prove fixes through the repository's normal unit or Electron E2E boundary. diff --git a/README.md b/README.md index 3f1b670c6..714166daf 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,10 @@ Shift is free and open source, and runs on macOS, Windows, and Linux. It stays r Download Shift for macOS, Windows, and Linux from [shift.graphics](https://shift.graphics) or [GitHub Releases](https://github.com/shift-editor/shift/releases). On Linux, install from the [APT or DNF repositories](docs/releases.md#linux-installation) to get updates. For the latest development build, use [Shift Nightly](https://github.com/shift-editor/shift/releases/tag/nightly). +## AI agents + +Connect Claude Code, Codex, or another MCP client to fonts open in Shift using the [one-time MCP setup guide](docs/mcp.md). The connection is read-only; use `shift-cli` for saved files that are not open in the app. + ## Status | Area | Status | diff --git a/ROADMAP.md b/ROADMAP.md index eba6a1f63..4bc8fc40b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -919,7 +919,7 @@ interface ShiftScriptContext { **MCP Server** -- [ ] Expose Shift as MCP server +- [x] Expose Shift as MCP server - [ ] Tools: getGlyph, movePoint, addContour, exportPreview - [ ] Claude can read/write font data diff --git a/apps/desktop/build.ts b/apps/desktop/build.ts index 071dd7838..1d48ccd4a 100644 --- a/apps/desktop/build.ts +++ b/apps/desktop/build.ts @@ -32,6 +32,23 @@ async function buildMain(): Promise { }); } +async function buildSandbox(): Promise { + await build({ + configFile: path.join(appRoot, "vite.main.config.ts"), + build: { + lib: { + entry: path.join(appRoot, "src/utility/sandbox.ts"), + formats: ["cjs"], + fileName: () => "sandbox.js", + }, + outDir: path.join(appRoot, ".vite/build"), + emptyOutDir: false, + minify: !isE2E, + rollupOptions: { external: nodeExternals }, + }, + }); +} + async function buildWorkspace(): Promise { await build({ configFile: path.join(appRoot, "vite.main.config.ts"), @@ -82,12 +99,13 @@ async function buildRenderer(): Promise { } async function main(): Promise { - console.log(`Building Electron app${isE2E ? " for E2E tests" : ""}...`); + process.stdout.write(`Building Electron app${isE2E ? " for E2E tests" : ""}...\n`); await buildMain(); + await buildSandbox(); await buildWorkspace(); await buildPreload(); await buildRenderer(); - console.log("Electron build complete."); + process.stdout.write("Electron build complete.\n"); } main().catch((error) => { diff --git a/apps/desktop/e2e/live-agent.spec.ts b/apps/desktop/e2e/live-agent.spec.ts new file mode 100644 index 000000000..7b2d9815e --- /dev/null +++ b/apps/desktop/e2e/live-agent.spec.ts @@ -0,0 +1,148 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; +import type { ShiftMcpConnection } from "@shift/mcp"; +import { workspaceTest as test, expect, UFO_FONT_PATH } from "./fixtures/electronApp"; + +async function runShiftCode(testRoot: string, code: string): Promise { + const descriptor = path.join(testRoot, "user-data", "mcp.json"); + const connection = JSON.parse(await readFile(descriptor, "utf8")) as ShiftMcpConnection; + const client = new Client({ name: "shift-e2e", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(connection.url), { + authProvider: { token: async () => connection.token }, + }); + + try { + await client.connect(transport); + const result = await client.callTool({ name: "shift.execute", arguments: { code } }); + const text = result.content + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n"); + if (result.isError) throw new Error(text); + return JSON.parse(text); + } finally { + await client.close(); + } +} + +test.describe("authored font reads from Home", () => { + test.use({ startupFontPath: UFO_FONT_PATH }); + + test("paginates glyphs and reads named source anchors", async ({ + page: workspacePage, + testRoot, + }) => { + await expect(workspacePage).toHaveURL(/#\/home/); + const result = await runShiftCode( + testRoot, + `async () => { + const session = (await shift.sessions.list()).find(({ editorConnected }) => editorConnected); + if (!session) throw new Error("Expected connected font"); + const windowId = session.windowId; + const font = await shift.font.get({ windowId }); + const first = await shift.glyphs.list({ windowId, limit: 1 }); + const second = await shift.glyphs.list({ windowId, limit: 1, cursor: first.nextCursor }); + const glyph = await shift.glyphs.get({ windowId, glyphId: first.items[0].id }); + const directory = await shift.glyphs.list({ windowId, limit: 100 }); + const e = directory.items.find(({ name }) => name === "E"); + if (!e) throw new Error("Fixture glyph E is missing"); + const eByName = await shift.glyphs.get({ windowId, name: "E" }); + const sourceId = font.sources[0].id; + const layer = await shift.layers.get({ windowId, glyphId: e.id, sourceId }); + const sourcePage = await shift.glyphs.list({ windowId, limit: 1, sourceId }); + const a = directory.items.find(({ name }) => name === "A"); + const supportId = a?.sourceIds.find((id) => id !== sourceId); + if (!a || !supportId) throw new Error("Missing support-layer fixture"); + const sparse = directory.items.find(({ sourceIds }) => !sourceIds.includes(supportId)); + if (!sparse) throw new Error("Missing sparse-layer fixture"); + const support = await shift.layers.get({ windowId, glyphId: a.id, sourceId: supportId }); + const absent = await shift.layers.get({ windowId, glyphId: sparse.id, sourceId: supportId }); + const supportPage = await shift.glyphs.list({ windowId, limit: 100, sourceId: supportId }); + return { + supportLayerId: support?.layerId, + absent, + sparseStructure: supportPage.items.find(({ id }) => id === sparse.id)?.structure, + supportStructure: supportPage.items.find(({ id }) => id === a.id)?.structure, + familyName: font.metadata.familyName, + unitsPerEm: font.metrics.unitsPerEm, + glyphCount: font.glyphCount, + mode: font.mode, + first: first.items[0], + nextCursor: first.nextCursor, + second: second.items[0], + glyph, + anchors: layer?.anchors.map(({ name }) => name), + eId: e.id, + eByNameId: eByName.id, + structure: sourcePage.items[0].structure, + }; + }`, + ); + + expect(result).toMatchObject({ + familyName: "MutatorMathTest", + unitsPerEm: 1000, + glyphCount: 48, + mode: "workspace", + anchors: ["top"], + absent: null, + sparseStructure: null, + supportLayerId: expect.any(String), + }); + const page = result as { + first: { id: string }; + nextCursor: string; + second: { id: string }; + glyph: { id: string }; + structure: unknown; + supportStructure: unknown; + eId: string; + eByNameId: string; + }; + expect(page.nextCursor).toEqual(expect.any(String)); + expect(page.second.id).not.toBe(page.first.id); + expect(page.glyph.id).toBe(page.first.id); + expect(page.eByNameId).toBe(page.eId); + expect(page.structure).not.toBeNull(); + expect(page.supportStructure).not.toBeNull(); + }); +}); + +test("inspects the explicitly targeted live editor", async ({ editor, testRoot }) => { + await editor.openGlyphByName("A"); + const point = await editor.selectVisiblePoint(); + + const observation = await runShiftCode( + testRoot, + `async () => { + const sessions = await shift.sessions.list(); + const target = sessions.find((session) => session.editorConnected); + if (!target) throw new Error("Expected connected editor"); + + let missingWindowError = null; + try { + await shift.editor.inspect({ windowId: 2147483647 }); + } catch (error) { + missingWindowError = error.message; + } + + return { + sessions, + editor: await shift.editor.inspect({ windowId: target.windowId }), + missingWindowError, + }; + }`, + ); + + expect(observation).toMatchObject({ + sessions: [{ mode: "workspace", editorConnected: true }], + editor: { + glyph: { name: "A" }, + selectionIds: [point.id], + tool: { id: "select" }, + applyStatus: "idle", + }, + missingWindowError: "Shift window 2147483647 is not open", + }); +}); diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts index 94b88d342..a4b465368 100644 --- a/apps/desktop/forge.config.ts +++ b/apps/desktop/forge.config.ts @@ -15,6 +15,11 @@ const config: ForgeConfig = { config: "vite.preload.config.ts", target: "preload", }, + { + entry: "src/utility/sandbox.ts", + config: "vite.main.config.ts", + target: "main", + }, { entry: "src/utility/workspace.ts", config: "vite.main.config.ts", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5ccc3f367..d5c019ef0 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -33,6 +33,7 @@ }, "license": "MIT OR Apache-2.0", "devDependencies": { + "@modelcontextprotocol/client": "2.2.0", "@electron-forge/cli": "8.0.0-alpha.10", "@electron-forge/plugin-vite": "8.0.0-alpha.10", "@electron-forge/shared-types": "8.0.0-alpha.10", @@ -62,6 +63,9 @@ "@shift/geo": "workspace:*", "@shift/glyph-info": "workspace:*", "@shift/glyph-state": "workspace:*", + "@shift/mcp": "workspace:*", + "@shift/runtime": "workspace:*", + "@shift/sandbox": "workspace:*", "@shift/types": "workspace:*", "@shift/ui": "workspace:*", "@shift/validation": "workspace:*", diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts index 590e2a8a6..f4823d3b5 100644 --- a/apps/desktop/playwright.config.ts +++ b/apps/desktop/playwright.config.ts @@ -17,6 +17,7 @@ export const PLATFORM_SPECS = [ "document-crash.spec.ts", "document-lifecycle.spec.ts", "document-recovery.spec.ts", + "live-agent.spec.ts", "platform-integration.spec.ts", "recent-files.spec.ts", "variable-font-recovery.spec.ts", diff --git a/apps/desktop/src/main/agent/AgentClient.ts b/apps/desktop/src/main/agent/AgentClient.ts new file mode 100644 index 000000000..0d1d8c7d2 --- /dev/null +++ b/apps/desktop/src/main/agent/AgentClient.ts @@ -0,0 +1,70 @@ +import type { + EditorView, + FontOverview, + GlyphPage, + GlyphSelector, + GlyphSummary, + LayerView, +} from "@shift/runtime"; +import type { GlyphId, SourceId } from "@shift/types"; +import type { MessagePortMain } from "electron"; +import type { AgentCallMap, AgentEventMap } from "../../shared/agent/protocol"; +import { Channel, electronPortTransport } from "../../shared/workspace/channel"; +import { createShiftLogger, type ShiftLogger } from "../logging"; + +/** Calls the agent inspection lane served by one renderer window. */ +export class AgentClient { + readonly #log: ShiftLogger; + #channel: Channel | null = null; + + constructor(log: ShiftLogger = createShiftLogger("agent.client")) { + this.#log = log; + } + + get connected(): boolean { + return this.#channel !== null && !this.#channel.closed; + } + + /** Replaces the inspection lane after a renderer connects or reloads. */ + connect(port: MessagePortMain): void { + this.#channel?.dispose(); + this.#channel = new Channel(electronPortTransport(port)); + this.#log.info("agent renderer connected"); + } + + /** Returns a point-in-time view of the editor owned by this renderer. */ + inspectEditor(): Promise { + if (!this.#channel) return Promise.reject(new Error("agent renderer is not connected")); + return this.#channel.call("editor.inspect", undefined); + } + + getFont(): Promise { + return this.#call("font.get", undefined); + } + + listGlyphs(input: { limit?: number; cursor?: string; sourceId?: SourceId }): Promise { + return this.#call("glyphs.list", input); + } + + getGlyph(selector: GlyphSelector): Promise { + return this.#call("glyphs.get", selector); + } + + getLayer(glyphId: GlyphId, sourceId: SourceId): Promise { + return this.#call("layers.get", { glyphId, sourceId }); + } + + #call( + operation: K, + input: AgentCallMap[K]["request"], + ): Promise { + if (!this.#channel) return Promise.reject(new Error("agent renderer is not connected")); + return this.#channel.call(operation, input); + } + + /** Disconnects the renderer and rejects pending inspection calls. */ + dispose(): void { + this.#channel?.dispose(); + this.#channel = null; + } +} diff --git a/apps/desktop/src/main/app/App.ts b/apps/desktop/src/main/app/App.ts index 96618ed82..45d68a618 100644 --- a/apps/desktop/src/main/app/App.ts +++ b/apps/desktop/src/main/app/App.ts @@ -30,8 +30,11 @@ import { shiftProductName } from "../release"; import { AppUpdater } from "../update/AppUpdater"; import { isConvertiblePreviewPath } from "../../shared/workspace/previewConversion"; import { OPEN_FONT_EXTENSIONS } from "../../shared/openFontExtensions"; -import { RecentDocuments } from "../recents/RecentDocuments"; +import { ShiftMcpServer } from "@shift/mcp"; +import type { EditorInspection, ShiftSession } from "@shift/runtime"; import type { RecentDocumentVisit } from "../../shared/recents"; +import { RecentDocuments } from "../recents/RecentDocuments"; +import { SandboxRuntimeProcess } from "../sandbox/SandboxRuntimeProcess"; const SLUG_ATLAS_PROFILING_ENABLED = process.env.SHIFT_PROFILE_SLUG_ATLAS !== undefined && @@ -62,6 +65,8 @@ export class App { readonly #updater: AppUpdater; #commands = new CommandRegistry(); + #mcp: ShiftMcpServer | null = null; + #sandbox: SandboxRuntimeProcess | null = null; #windows = new WindowManager(); #workspaces: WorkspaceManager; #documentsRoot: string | null = null; @@ -221,6 +226,8 @@ export class App { path.join(app.getPath("userData"), "recent-documents.json"), ); this.#recents.onChanged(() => this.#publishRecents()); + await this.#startSandbox(); + await this.#startMcp(); // Taken before recovery, which marks the documents it restores open again. const openAtLastExit = this.#recents.takeOpen(); @@ -265,6 +272,8 @@ export class App { }); app.on("will-quit", () => { this.#log.info("will quit: disposing app services"); + void this.#stopMcp(); + this.#stopSandbox(); // Only an ordinary quit forgets open documents; an update restart reopens them. if (this.#lifecycle.quitReason === "quit") this.#recents?.clearOpen(); for (const session of this.#workspaces.list()) { @@ -302,6 +311,7 @@ export class App { this.#log.info("working window closed"); const session = this.#workspaces.getForBrowserWindow(window.window); this.#workspaces.detachWindow(window); + window.agent.dispose(); if (session?.windows.size === 0) this.#endSession(session); this.#windows.remove(window); this.#applicationMenu.updateCommandStates(); @@ -469,6 +479,12 @@ export class App { } #registerIpcHandlers(): void { + ipc.handle(ipcMain, "agent.connect", (event) => { + const window = this.#requireWindowForWebContents(event.sender); + const { port1, port2 } = new MessageChannelMain(); + window.agent.connect(port1); + event.sender.postMessage("agent.port", null, [port2]); + }); ipc.handle(ipcMain, "commands.run", async (event, id) => { const window = this.#requireWindowForWebContents(event.sender); try { @@ -872,6 +888,144 @@ export class App { launcher.close(); } + /** Starts the app-owned execution process independently of MCP availability. */ + async #startSandbox(): Promise { + const sandbox = new SandboxRuntimeProcess({ + sessions: { + list: () => Promise.resolve(this.#agentSessions()), + }, + editor: { + inspect: ({ windowId }) => this.#inspectEditor(windowId), + }, + font: { + get: ({ windowId }) => this.#windowForAgentRequest(windowId).agent.getFont(), + }, + glyphs: { + list: ({ windowId, limit, cursor, sourceId }) => + this.#windowForAgentRequest(windowId).agent.listGlyphs({ limit, cursor, sourceId }), + get: ({ windowId, ...selector }) => + this.#windowForAgentRequest(windowId).agent.getGlyph(selector), + }, + layers: { + get: ({ windowId, glyphId, sourceId }) => + this.#windowForAgentRequest(windowId).agent.getLayer(glyphId, sourceId), + }, + }); + this.#sandbox = sandbox; + try { + await sandbox.start(); + } catch (error) { + sandbox.stop(); + this.#log.error("failed to start sandbox", error); + } + } + + async #startMcp(): Promise { + const sandbox = this.#sandbox; + if (!sandbox) throw new Error("Sandbox runtime was not initialized"); + + let port: number; + switch (app.getName()) { + case "Shift": + port = 17461; + break; + case "Shift Nightly": + port = 17462; + break; + case "Shift Dev": + port = 17463; + break; + case "Shift Nightly Dev": + port = 17464; + break; + default: + throw new Error(`Unknown Shift distribution: ${app.getName()}`); + } + + const mcp = new ShiftMcpServer({ + execute: (code) => sandbox.execute(code), + descriptorPath: path.join(app.getPath("userData"), "mcp.json"), + port: process.env.NODE_ENV === "test" ? 0 : port, + logger: createShiftLogger("app.mcp"), + }); + + try { + await mcp.start(); + this.#mcp = mcp; + } catch (error) { + try { + await mcp.stop(); + } catch (stopError) { + this.#log.error("failed to clean up MCP server startup", stopError); + } + this.#log.error("failed to start MCP server", error); + } + } + + async #stopMcp(): Promise { + const mcp = this.#mcp; + this.#mcp = null; + + const stoppingMcp = mcp?.stop(); + if (!stoppingMcp) return; + + try { + await stoppingMcp; + } catch (error) { + this.#log.error("failed to stop MCP server", error); + } + } + + #stopSandbox(): void { + this.#sandbox?.stop(); + this.#sandbox = null; + } + + #agentSessions(): ShiftSession[] { + const focusedWindowId = BrowserWindow.getFocusedWindow()?.id ?? null; + const sessions: ShiftSession[] = []; + + for (const window of this.#windows.allWindows()) { + const session = this.#workspaces.getForBrowserWindow(window.window); + if (!session) continue; + + sessions.push({ + windowId: window.window.id, + sessionId: session.sessionId, + mode: session.mode, + focused: window.window.id === focusedWindowId, + editorConnected: window.agent.connected, + }); + } + + return sessions; + } + + async #inspectEditor(windowId: number): Promise { + const window = this.#windows.windowForId(windowId); + if (!window) throw new Error(`Shift window ${windowId} is not open`); + + const session = this.#workspaces.getForBrowserWindow(window.window); + if (!session) throw new Error(`Shift window ${windowId} has no font session`); + + const view = await window.agent.inspectEditor(); + return { + ...view, + windowId, + sessionId: session.sessionId, + mode: session.mode, + }; + } + + #windowForAgentRequest(windowId: number): Window { + const window = this.#windows.windowForId(windowId); + if (!window) throw new Error(`Shift window ${windowId} is not open`); + if (!this.#workspaces.getForBrowserWindow(window.window)) { + throw new Error(`Shift window ${windowId} has no font session`); + } + return window; + } + #fontSessionForSender(sender: WebContents, operation: string): FontSessionHost { const window = this.#requireWindowForWebContents(sender); const session = this.#workspaces.getForBrowserWindow(window.window); diff --git a/apps/desktop/src/main/docs/DOCS.md b/apps/desktop/src/main/docs/DOCS.md index 00d30bae6..d59562a50 100644 --- a/apps/desktop/src/main/docs/DOCS.md +++ b/apps/desktop/src/main/docs/DOCS.md @@ -1,6 +1,6 @@ # Main - + Electron main process: app startup, windows, menus, document dialogs, and workspace session ownership. @@ -25,6 +25,8 @@ Electron main process: app startup, windows, menus, document dialogs, and worksp - **Architecture Invariant:** Disposable Slug pages live under the app-wide `derived-cache/slug-atlases` root beside `working-documents`, never inside authored `.shift` content. Utility processes share the one-GiB byte-budgeted LRU; each process validates an artifact index once and then verifies and decompresses its fixed pages independently. Staging paths use readable `run-{pid}-{id}/page-{index}-{id}.zst` names, and every retry owns a distinct file until publication. The LRU scans after an artifact is opened or published, never after every page stream. Stale, corrupt, and evicted entries rebuild. - **Architecture Invariant:** Recovery discovery prunes only storage that cannot contain authored work: empty workspace directories, document bindings with no working store or recovery overlay, and SQLite sidecars whose primary file is absent. Working stores and recovery overlays are recoverable and never expire by age. A stale binding whose exact recovery overlay is absent is detached from a surviving working store so unsaved-workspace discovery can recover that store. Malformed or unknown artifacts are retained and reported rather than deleted. - **Architecture Invariant:** IPC channels are type-safe. `ipcMain.handle` calls use the typed wrapper from `shared/ipc/main`, and channel names and payload types live in `shared/ipc/contract.ts` and `shared/workspace/protocol.ts`. +- **Architecture Invariant:** Main owns one local MCP server per app instance. It binds only to loopback on the distribution's fixed port (Shift `17461`, Nightly `17462`, Dev `17463`, Nightly Dev `17464`; E2E uses an explicit ephemeral port), retains a private bearer token under the distribution-specific user-data root across launches, resolves every request through an explicit window/session identity, and routes renderer observations over a per-window typed request lane. Port collisions disable MCP without stopping the app-owned sandbox or preventing the app from opening. +- **Architecture Invariant:** Code submitted to the Shift sandbox never executes in Electron main or a renderer. `SandboxRuntimeProcess` is app-owned, supervises a dedicated utility process, serves only typed capabilities back into main, and terminates the process when a hard execution deadline expires; the next execution restarts it. No long-lived interactive plugin execution mode exists yet. ## Codemap @@ -37,6 +39,8 @@ src/main/ AboutWindow.ts -- singleton product information window feedback/ FeedbackWindow.ts -- singleton modeless feedback composer window + agent/ + AgentClient.ts -- main client for one renderer's live inspection lane app/ App.ts -- app service graph, IPC handlers, command context AppLifecycle.ts -- close/quit confirmation flow @@ -54,6 +58,8 @@ src/main/ types.ts -- close reasons and dirty-document choices menu/ ApplicationMenu.ts -- Electron application menu + sandbox/ + SandboxRuntimeProcess.ts -- isolated code runtime lifecycle and capability lane update/ AppUpdater.ts -- update orchestration, scheduling, consent, and restart safety UpdateWindow.ts -- download progress and install prompt window @@ -85,13 +91,16 @@ src/main/ - `UpdateWindow` -- renderer of download progress and ready-to-install choices. - `FeedbackWindow` -- singleton modeless composer that routes user-authored feedback to email, GitHub, or Discord without collecting files or diagnostics. - `UpdateStatus` -- updater lifecycle: idle, checking, available, downloading, ready, or restarting. +- `ShiftMcpServer` -- local code-mode MCP adapter over explicitly targeted live app capabilities. +- `SandboxRuntimeProcess` -- utility-process supervisor for bounded generated-code execution and typed capability callbacks. +- `AgentClient` -- per-window main-process client for renderer-owned editor observations. - `WorkspaceDocumentState` -- utility-owned lifecycle state mirrored into main and renderer. ## How it works ### Startup -`main.ts` constructs `App` and calls `start()`. `App.start()` applies the compiled `SHIFT_DISTRIBUTION` identity before its first log entry or path-dependent service action, so logging, settings, caches, and recovery all resolve beneath the correct app-data root. Startup recovery discovery calls `DocumentStorage.pruneOrphanedStorage()` before enumerating recoverable work; this cleanup is structural rather than age-based and never removes a recognized working store or recovery overlay. The production/E2E build and development-only Forge runner write the `main_window` renderer to `.vite/renderer/main_window`; production resolves that same directory through `MAIN_WINDOW_VITE_NAME`. `App` registers commands and IPC handlers, starts `AppLifecycle`, sets the user-data-backed `working-documents` root, creates the launcher window, and installs the application menu. Development uses `Shift Dev` or `Shift Nightly Dev`; an explicit standard `--user-data-dir` switch takes precedence so E2E runs can own isolated browser and working-document state. +`main.ts` constructs `App` and calls `start()`. `App.start()` applies the compiled `SHIFT_DISTRIBUTION` identity before its first log entry or path-dependent service action, so logging, settings, caches, and recovery all resolve beneath the correct app-data root. Startup recovery discovery calls `DocumentStorage.pruneOrphanedStorage()` before enumerating recoverable work; this cleanup is structural rather than age-based and never removes a recognized working store or recovery overlay. The production/E2E build and development-only Forge runner write the `main_window` renderer to `.vite/renderer/main_window`; production resolves that same directory through `MAIN_WINDOW_VITE_NAME`. `App` registers commands and IPC handlers, starts `AppLifecycle`, starts the sandbox utility process before publishing the MCP descriptor, sets the user-data-backed `working-documents` root, creates the launcher window, and installs the application menu. Development uses `Shift Dev` or `Shift Nightly Dev`; an explicit standard `--user-data-dir` switch takes precedence so E2E runs can own isolated browser and working-document state. The runtime icon follows the same compiled identity: `AppIcon` selects `nightly-macos.png` when `shiftDistribution` is `"nightly"` and `icon-macos.png` otherwise, so Release and Nightly are visually distinct in the development macOS Dock. Packaged installer icons remain owned by electron-builder configuration. The renderer's shared `app-icon.png` supplies the custom About and Update screens. Both distributions use the shared `shift-document` artwork for `.shift` files; association priority, not document appearance, distinguishes their ownership. Packaged macOS builds also declare TTF, OTF, Glyphs, Glyphspackage, UFO, and Designspace sources as alternate viewer types so Finder recommends Shift under **Open With** without making it the default source-font application. @@ -149,7 +158,7 @@ Message lanes reject in-flight calls when their remote port closes. An unexpecte ### IPC -Renderer IPC in `App` is limited to shell capabilities: command execution, clipboard, update-window progress/actions, optional document-lane port transfer, immutable session mode, readiness, and shared session sync-lane port transfer. Font data stays on that sync lane between renderer and utility. +Renderer IPC in `App` is limited to shell capabilities: command execution, clipboard, update-window progress/actions, optional document-lane and agent-lane port transfer, immutable session mode, readiness, and shared session sync-lane port transfer. Font data stays on the sync lane between renderer and utility. The agent lane returns renderer-owned view facts and read-only font projections. Source-specific authored reads obtain workspace snapshots through the existing serialized sync lane rather than loading every glyph into the editor. Main resolves the explicit window and session before routing each MCP call; editor observations also carry their window and session identities. ## Workflow recipes diff --git a/apps/desktop/src/main/sandbox/SandboxRuntimeProcess.ts b/apps/desktop/src/main/sandbox/SandboxRuntimeProcess.ts new file mode 100644 index 000000000..5625cea35 --- /dev/null +++ b/apps/desktop/src/main/sandbox/SandboxRuntimeProcess.ts @@ -0,0 +1,164 @@ +import { utilityProcess, type UtilityProcess } from "electron"; +import path from "node:path"; +import type { ShiftCapabilities } from "@shift/runtime"; +import type { + SandboxCallMap, + SandboxEventMap, + SandboxHostCallMap, + SandboxHostEventMap, +} from "../../shared/sandbox/protocol"; +import { Channel, serveChannel, utilityProcessTransport } from "../../shared/workspace/channel"; +import { createShiftLogger, type ShiftLogger } from "../logging"; + +const HARD_EXECUTION_TIMEOUT_MS = 5_000; + +/** Owns the isolated process used for bounded Shift code execution. */ +export class SandboxRuntimeProcess { + readonly #capabilities: ShiftCapabilities; + readonly #log: ShiftLogger; + #process: UtilityProcess | null = null; + #channel: Channel | null = null; + #ready: Promise | null = null; + + /** + * Creates an unstarted sandbox process controller. + * + * @param capabilities - typed operations the isolated runtime may call back into. + * @param log - destination for process lifecycle and failure diagnostics. + */ + constructor( + capabilities: ShiftCapabilities, + log: ShiftLogger = createShiftLogger("sandbox.process"), + ) { + this.#capabilities = capabilities; + this.#log = log; + } + + /** + * Starts the isolated runtime and resolves after its request lane is ready. + * + * @throws {Error} when the utility process exits before announcing readiness. + */ + async start(): Promise { + if (this.#ready) return this.#ready; + + const entryPoint = path.join(__dirname, "sandbox.js"); + const proc = utilityProcess.fork(entryPoint, [], { + serviceName: "Shift Sandbox", + stdio: "pipe", + }); + const transport = utilityProcessTransport(proc); + const channel = new Channel(transport); + serveChannel(transport, { + "shift.sessions.list": () => this.#capabilities.sessions.list(), + "shift.editor.inspect": (input) => this.#capabilities.editor.inspect(input), + "shift.font.get": (input) => this.#capabilities.font.get(input), + "shift.glyphs.list": (input) => this.#capabilities.glyphs.list(input), + "shift.glyphs.get": (input) => this.#capabilities.glyphs.get(input), + "shift.layers.get": (input) => this.#capabilities.layers.get(input), + }); + + this.#process = proc; + this.#channel = channel; + this.#ready = this.#trackReady(proc, channel); + this.#wire(proc, channel); + await this.#ready; + } + + /** Stops the isolated runtime and rejects its in-flight execution requests. */ + stop(): void { + const proc = this.#process; + this.#process = null; + this.#ready = null; + + this.#channel?.dispose(); + this.#channel = null; + proc?.kill(); + } + + /** + * Executes code in a fresh bounded QuickJS realm inside the isolated process. + * + * @param code - async zero-argument function source accepted by Shift code mode. + * @remarks + * Each call receives a fresh QuickJS realm. A hard deadline terminates the utility process; + * the next call starts a replacement process before executing. + * + * @returns the JSON-compatible value returned by the function. + * @throws {Error} when the process exits, execution fails, or the hard process deadline expires. + */ + async execute(code: string): Promise { + await this.start(); + const channel = this.#channel; + if (!channel) throw new Error("sandbox runtime is not running"); + + let didTimeout = false; + let timeout: NodeJS.Timeout | undefined; + const expired = new Promise((_, reject) => { + timeout = setTimeout(() => { + didTimeout = true; + reject(new Error("sandbox execution exceeded its hard deadline")); + }, HARD_EXECUTION_TIMEOUT_MS); + }); + + try { + return await Promise.race([channel.call("sandbox.execute", { code }), expired]); + } catch (error) { + if (didTimeout) { + this.#log.warn("execution timed out; restarting sandbox process"); + this.stop(); + } + + throw error; + } finally { + clearTimeout(timeout); + } + } + + #trackReady( + proc: UtilityProcess, + channel: Channel, + ): Promise { + const ready = new Promise((resolve, reject) => { + const onExit = (code: number) => { + unlisten(); + reject(new Error(`sandbox process exited with code ${code} before ready`)); + }; + const unlisten = channel.listen("sandbox.ready", () => { + proc.off("exit", onExit); + unlisten(); + this.#log.info("ready"); + resolve(); + }); + proc.once("exit", onExit); + }); + + ready.catch(() => {}); + return ready; + } + + #wire(proc: UtilityProcess, channel: Channel): void { + proc.on("spawn", () => this.#log.info("spawned", proc.pid)); + proc.on("exit", (code) => { + this.#log.info("exited", code); + channel.dispose(); + + if (this.#process !== proc) return; + + this.#process = null; + this.#channel = null; + this.#ready = null; + }); + proc.on("error", (type, location, report) => { + this.#log.error("error", type, location, report); + }); + proc.stdout?.on("data", (chunk) => { + const text = String(chunk).trimEnd(); + if (text) this.#log.info(text); + }); + proc.stderr?.on("data", (chunk) => { + const text = String(chunk).trimEnd(); + if (text) this.#log.error(text); + }); + } +} diff --git a/apps/desktop/src/main/windows/Window.ts b/apps/desktop/src/main/windows/Window.ts index 34701cd88..60a25f4dd 100644 --- a/apps/desktop/src/main/windows/Window.ts +++ b/apps/desktop/src/main/windows/Window.ts @@ -1,6 +1,7 @@ import { BrowserWindow, type BrowserWindowConstructorOptions } from "electron"; import * as ipc from "../../shared/ipc/main"; import type { RendererCommandId } from "../../shared/commands"; +import { AgentClient } from "../agent/AgentClient"; export interface WindowOptions { title?: string; @@ -50,6 +51,8 @@ const BROWSER_WINDOW_DEFAULT_OPTIONS: BrowserWindowConstructorOptions = { }; export class Window { + readonly agent = new AgentClient(); + #window: BrowserWindow; #maximiseOnPresent: boolean; diff --git a/apps/desktop/src/main/windows/WindowManager.ts b/apps/desktop/src/main/windows/WindowManager.ts index 4345732f4..80e786a93 100644 --- a/apps/desktop/src/main/windows/WindowManager.ts +++ b/apps/desktop/src/main/windows/WindowManager.ts @@ -29,6 +29,10 @@ export class WindowManager { return this.#windows.get(window.id) ?? null; } + windowForId(windowId: number): Window | null { + return this.#windows.get(windowId) ?? null; + } + allWindows(): readonly Window[] { return [...this.#windows.values()]; } diff --git a/apps/desktop/src/preload/docs/DOCS.md b/apps/desktop/src/preload/docs/DOCS.md index 8b9864bd7..2b488885d 100644 --- a/apps/desktop/src/preload/docs/DOCS.md +++ b/apps/desktop/src/preload/docs/DOCS.md @@ -1,14 +1,14 @@ # Preload - + -Electron preload script that exposes the typed Shift host API and relays session ports to the renderer. +Electron preload script that exposes the typed Shift host API and relays session, document, and agent ports to the renderer. ## Architecture Invariants - **Architecture Invariant:** Preload exposes only `window.shiftHost`; the native bridge lives in the utility process behind the typed font-session lane. **WHY:** renderer isolation must not expose native font methods directly. - **Architecture Invariant:** Every `shiftHost` method delegates through typed IPC helpers and contains only context-bridge-compatible functions and values. -- **Architecture Invariant:** Session and document `MessagePort`s are relayed with `window.postMessage` because ports cannot cross Electron's context bridge. Renderer listeners authenticate the same window and expected message type before accepting a port. +- **Architecture Invariant:** Session, document, and agent `MessagePort`s are relayed with `window.postMessage` because ports cannot cross Electron's context bridge. Renderer listeners authenticate the same window and expected message type before accepting a port. ## Codemap @@ -45,7 +45,7 @@ The preload runs once before the renderer loads: 1. In the main-side handler, create a `MessageChannelMain` and transfer one half with `event.sender.postMessage(".port", null, [port])` — ports cannot travel through `invoke` responses. 2. In `preload.ts`, relay it into the page: `ipcRenderer.on(".port", ...)` forwarding via `window.postMessage({ type: ".port" }, "*", event.ports)`, mirroring the existing `session.port` and `document.port` relays. -3. In the renderer listener, accept the port only after checking `event.source === window` and the expected message type. +3. In the renderer listener, accept the port only after checking `event.source === window` and the expected message type. The live agent lane follows the same transfer contract as the session and document lanes. 4. Verify: `pnpm --filter @shift/desktop typecheck`, then run the app and confirm the lane connects. ## Gotchas diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 2fb111f27..ebc2e038e 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -8,6 +8,9 @@ import { invoke, listen } from "../shared/ipc/renderer"; const shiftHost: ShiftHost = { platform: process.platform, + agent: { + connect: invoke(ipcRenderer, "agent.connect"), + }, commands: { run: invoke(ipcRenderer, "commands.run"), onRunRendererCommand: listen(ipcRenderer, "commands.runRenderer"), @@ -57,6 +60,10 @@ const shiftHost: ShiftHost = { contextBridge.exposeInMainWorld("shiftHost", shiftHost); // MessagePorts cannot cross the context bridge; relay them into the page. +ipcRenderer.on("agent.port", (event: IpcRendererEvent) => { + window.postMessage({ type: "agent.port" }, "*", event.ports); +}); + ipcRenderer.on("session.port", (event: IpcRendererEvent) => { window.postMessage({ type: "session.port" }, "*", event.ports); }); diff --git a/apps/desktop/src/renderer/src/agent/AgentBridge.ts b/apps/desktop/src/renderer/src/agent/AgentBridge.ts new file mode 100644 index 000000000..10bba8e97 --- /dev/null +++ b/apps/desktop/src/renderer/src/agent/AgentBridge.ts @@ -0,0 +1,219 @@ +import type { + EditorView, + FontOverview, + GlyphPage, + GlyphSelector, + GlyphSummary, + LayerView, +} from "@shift/runtime"; +import type { GlyphId, SourceId } from "@shift/types"; +import { GlyphGeometry } from "@shift/glyph-state"; +import type { ShiftHost } from "@shared/host/ShiftHost"; +import type { AgentCallMap, AgentEventMap } from "@shared/agent/protocol"; +import { domPortTransport, serveChannel, type ChannelServer } from "@shared/workspace/channel"; +import type { FontSession } from "@/types/fontSession"; + +/** Serves bounded editor observations for one live renderer window. */ +export class AgentBridge { + readonly #host: ShiftHost; + readonly #session: FontSession; + #requests: ChannelServer | null = null; + #disposed = false; + + constructor(host: ShiftHost, session: FontSession) { + this.#host = host; + this.#session = session; + } + + /** Connects the renderer lane requested by Electron main. */ + async connect(): Promise { + const port = nextAgentPort(); + + try { + await this.#host.agent.connect(); + const received = await port.received; + if (this.#disposed) { + received.close(); + return; + } + + this.#requests?.dispose(); + this.#requests = serveChannel(domPortTransport(received), { + "editor.inspect": () => this.#inspectEditor(), + "font.get": () => this.#getFont(), + "glyphs.list": (input) => this.#listGlyphs(input), + "glyphs.get": (selector) => this.#getGlyph(selector), + "layers.get": ({ glyphId, sourceId }) => this.#getLayer(glyphId, sourceId), + }); + } catch (error) { + port.cancel(); + throw error; + } + } + + /** Disconnects the request lane and prevents a pending connection from publishing. */ + dispose(): void { + this.#disposed = true; + this.#requests?.dispose(); + this.#requests = null; + } + + #getFont(): FontOverview { + const font = this.#session.font; + return { + mode: this.#session.mode, + metadata: font.metadata, + metrics: font.metrics, + glyphCount: font.glyphEntries().length, + axes: font.getAxes(), + sources: font.sources, + namedInstances: font.namedInstances, + }; + } + + async #listGlyphs({ + limit = 25, + cursor, + sourceId, + }: { + limit?: number; + cursor?: string; + sourceId?: SourceId; + }): Promise { + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + throw new Error("glyphs.list limit must be between 1 and 100"); + } + + const font = this.#session.font; + const entries = font.glyphEntries(); + let start = 0; + if (cursor) { + let previousId: string; + try { + previousId = atob(cursor); + } catch { + throw new Error("Invalid glyph cursor"); + } + + const previousIndex = entries.findIndex(({ id }) => id === previousId); + if (previousIndex < 0) throw new Error("Glyph cursor is no longer in this font"); + start = previousIndex + 1; + } + + const page = entries.slice(start, start + limit); + const items = page.map((entry) => glyphSummary(this.#session, entry.id)); + if (sourceId) { + const layers = await font.readAuthoredLayers({ + glyphIds: page.map(({ id }) => id), + sourceId, + }); + for (const [index, item] of items.entries()) { + item.structure = layers[index]?.state.structure ?? null; + } + } + + const last = page.at(-1); + return { + items, + nextCursor: start + page.length < entries.length && last ? btoa(last.id) : null, + }; + } + + #getGlyph(selector: GlyphSelector): GlyphSummary { + const font = this.#session.font; + const entry = + selector.name !== undefined + ? font.entryForName(selector.name) + : font.entryForId(selector.glyphId); + if (!entry) throw new Error(`Glyph ${selector.name ?? selector.glyphId} is not in this font`); + return glyphSummary(this.#session, entry.id); + } + + async #getLayer(glyphId: GlyphId, sourceId: SourceId): Promise { + const [layer] = await this.#session.font.readAuthoredLayers({ glyphIds: [glyphId], sourceId }); + if (!layer) return null; + + const state = layer.state; + const geometry = GlyphGeometry.fromState(state); + + return { + glyphId, + sourceId, + layerId: state.layerId, + structure: state.structure, + xAdvance: geometry.xAdvance, + bounds: geometry.bounds, + anchors: geometry.anchors.map(({ id, name, x, y }) => ({ id, name: name ?? null, x, y })), + points: geometry.allPoints.map(({ id, x, y, pointType, smooth }) => ({ + id, + x, + y, + pointType, + smooth, + })), + }; + } + + #inspectEditor(): EditorView { + const editor = this.#session.editor; + const node = editor.scene.nodesOfKind("glyph")[0] ?? null; + const record = node ? this.#session.font.recordForId(node.glyphId) : null; + const glyph = + node && record + ? { + glyphId: node.glyphId, + name: record.name, + nodeId: node.id, + sourceId: node.sourceId, + } + : null; + const tool = editor.tool; + + return { + route: window.location.hash.slice(1), + glyph, + activeSourceId: editor.activeSourceId, + editingSourceIds: [...editor.editingSourceIds], + externalLocation: [...editor.externalLocation].map(([axisId, value]) => ({ axisId, value })), + selectionIds: [...editor.selection.ids], + tool: tool ? { id: tool.id, state: tool.state.type } : null, + dragging: editor.isDragging, + editing: editor.isEditing, + applyStatus: this.#session.workspace?.applyStatusCell.peek() ?? null, + }; + } +} + +function glyphSummary(session: FontSession, glyphId: GlyphId): GlyphSummary { + const entry = session.font.entryForId(glyphId); + if (!entry) throw new Error(`Glyph ${glyphId} is not in this font`); + + return { + id: entry.id, + name: entry.name, + unicodes: [...entry.unicodes], + componentBaseGlyphIds: session.font.recordForId(glyphId)?.componentBaseGlyphIds ?? [], + sourceIds: session.font.recordForId(glyphId)?.layers.map(({ sourceId }) => sourceId) ?? [], + }; +} + +function nextAgentPort(): { received: Promise; cancel: () => void } { + let cancel = () => {}; + const received = new Promise((resolve) => { + const listener = (event: MessageEvent) => { + if (event.source !== window) return; + if ((event.data as { type?: string } | null)?.type !== "agent.port") return; + + const port = event.ports[0]; + if (!port) return; + + window.removeEventListener("message", listener); + resolve(port); + }; + + cancel = () => window.removeEventListener("message", listener); + window.addEventListener("message", listener); + }); + + return { received, cancel }; +} diff --git a/apps/desktop/src/renderer/src/components/editor/ObjectsPanel.tsx b/apps/desktop/src/renderer/src/components/editor/ObjectsPanel.tsx index e80926480..c9e0682c5 100644 --- a/apps/desktop/src/renderer/src/components/editor/ObjectsPanel.tsx +++ b/apps/desktop/src/renderer/src/components/editor/ObjectsPanel.tsx @@ -1,6 +1,6 @@ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { computed, track, useSignalState } from "@shift/editor/signals"; -import type { SelectableId } from "@shift/editor/types"; +import type { SelectableId } from "@shift/types"; import { useEditor } from "@/workspace/WorkspaceContext"; import type { ObjectTreeSectionId } from "@/types/objectTree"; import { useListSelection } from "@/hooks/useListSelection"; diff --git a/apps/desktop/src/renderer/src/components/editor/object-tree/VirtualObjectRows.tsx b/apps/desktop/src/renderer/src/components/editor/object-tree/VirtualObjectRows.tsx index eaf592a19..5da1bb73d 100644 --- a/apps/desktop/src/renderer/src/components/editor/object-tree/VirtualObjectRows.tsx +++ b/apps/desktop/src/renderer/src/components/editor/object-tree/VirtualObjectRows.tsx @@ -1,6 +1,6 @@ import { cn } from "@shift/ui"; import { useLayoutEffect, useState } from "react"; -import type { SelectableId } from "@shift/editor/types"; +import type { SelectableId } from "@shift/types"; import type { VirtualObjectRowsProps } from "@/types/objectTree"; import { ObjectRow } from "./ObjectRow"; diff --git a/apps/desktop/src/renderer/src/components/editor/object-tree/flattenVisibleObjectRows.ts b/apps/desktop/src/renderer/src/components/editor/object-tree/flattenVisibleObjectRows.ts index 79c16b411..3d934752d 100644 --- a/apps/desktop/src/renderer/src/components/editor/object-tree/flattenVisibleObjectRows.ts +++ b/apps/desktop/src/renderer/src/components/editor/object-tree/flattenVisibleObjectRows.ts @@ -1,4 +1,4 @@ -import type { SelectableId } from "@shift/editor/types"; +import type { SelectableId } from "@shift/types"; import type { ObjectTreeItem, VisibleObjectRow } from "@/types/objectTree"; export function flattenVisibleObjectRows( diff --git a/apps/desktop/src/renderer/src/lib/model/positions/MultiSourcePositionEdits.test.ts b/apps/desktop/src/renderer/src/lib/model/positions/MultiSourcePositionEdits.test.ts index fabff90c2..b5930ef43 100644 --- a/apps/desktop/src/renderer/src/lib/model/positions/MultiSourcePositionEdits.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/positions/MultiSourcePositionEdits.test.ts @@ -4,7 +4,7 @@ import type { AnchorId, AxisId, PointId, SourceId } from "@shift/types"; import { externalAxisLocationFromRecord } from "@shift/editor/variation"; import type { GlyphLayer } from "@shift/editor/model"; import { TestEditor } from "@/testing/TestEditor"; -import type { SelectableId } from "@shift/editor/types"; +import type { SelectableId } from "@shift/types"; import type { PositionSelection } from "@shift/editor/types"; import { PositionEdits } from "@shift/editor/model"; diff --git a/apps/desktop/src/renderer/src/types/objectTree.ts b/apps/desktop/src/renderer/src/types/objectTree.ts index 911348034..a6a945ac8 100644 --- a/apps/desktop/src/renderer/src/types/objectTree.ts +++ b/apps/desktop/src/renderer/src/types/objectTree.ts @@ -1,4 +1,5 @@ -import type { ListSelectionMode, SelectableId } from "@shift/editor/types"; +import type { ListSelectionMode } from "@shift/editor/types"; +import type { SelectableId } from "@shift/types"; export type ObjectTreeIcon = | "anchor" diff --git a/apps/desktop/src/renderer/src/workspace/FontSessionProvider.tsx b/apps/desktop/src/renderer/src/workspace/FontSessionProvider.tsx index 05f5aea4e..344b61997 100644 --- a/apps/desktop/src/renderer/src/workspace/FontSessionProvider.tsx +++ b/apps/desktop/src/renderer/src/workspace/FontSessionProvider.tsx @@ -5,6 +5,8 @@ import { DocumentErrorScreen } from "@/app/DocumentErrorScreen"; import { reportRendererError } from "@/app/errorReporting"; import { FontSessionContext, WorkspaceContext } from "./WorkspaceContext"; import { getFontSession } from "./runtime"; +import { getShiftHost } from "@/host/shiftHost"; +import { AgentBridge } from "@/agent/AgentBridge"; export function FontSessionProvider({ children }: { children: ReactNode }) { const [session, setSession] = useState(null); @@ -38,7 +40,19 @@ export function FontSessionProvider({ children }: { children: ReactNode }) { const workspace = session.workspace; if (workspace) window.shift = workspace; + const agent = new AgentBridge(getShiftHost(), session); + async function connectAgent(): Promise { + try { + await agent.connect(); + } catch (error) { + console.error("agent bridge failed to connect", error); + reportRendererError("AgentBridge", error); + } + } + void connectAgent(); + return () => { + agent.dispose(); delete window.shift; delete window.shiftSession; }; diff --git a/apps/desktop/src/shared/agent/protocol.ts b/apps/desktop/src/shared/agent/protocol.ts new file mode 100644 index 000000000..6e2a3df47 --- /dev/null +++ b/apps/desktop/src/shared/agent/protocol.ts @@ -0,0 +1,26 @@ +import type { + EditorView, + FontOverview, + GlyphPage, + GlyphSelector, + GlyphSummary, + LayerView, +} from "@shift/runtime"; +import type { GlyphId, SourceId } from "@shift/types"; + +/** Main-to-renderer calls for live agent inspection of one explicit window. */ +export type AgentCallMap = { + "editor.inspect": { request: void; response: EditorView }; + "font.get": { request: void; response: FontOverview }; + "glyphs.list": { + request: { limit?: number; cursor?: string; sourceId?: SourceId }; + response: GlyphPage; + }; + "glyphs.get": { request: GlyphSelector; response: GlyphSummary }; + "layers.get": { + request: { glyphId: GlyphId; sourceId: SourceId }; + response: LayerView | null; + }; +}; + +export type AgentEventMap = Record; diff --git a/apps/desktop/src/shared/host/ShiftHost.ts b/apps/desktop/src/shared/host/ShiftHost.ts index 57001bbe9..f3d888ef4 100644 --- a/apps/desktop/src/shared/host/ShiftHost.ts +++ b/apps/desktop/src/shared/host/ShiftHost.ts @@ -15,6 +15,10 @@ import type { RecentDocument } from "../recents"; export interface ShiftHost { /** Operating system that owns the current application window. */ platform: NodeJS.Platform; + /** Connects this renderer to main-owned live agent requests. */ + agent: { + connect: () => Promise; + }; /** Runs app commands owned by the main process. */ commands: { /** diff --git a/apps/desktop/src/shared/ipc/contract.ts b/apps/desktop/src/shared/ipc/contract.ts index ca1c233c1..7adbe8490 100644 --- a/apps/desktop/src/shared/ipc/contract.ts +++ b/apps/desktop/src/shared/ipc/contract.ts @@ -39,6 +39,7 @@ export type RendererErrorReport = { * channels here only when preload needs a new main-process capability. */ export type RendererToMain = { + "agent.connect": () => void; "commands.run": (id: CommandId) => void; "clipboard.readText": () => string; "clipboard.writeText": (text: string) => void; diff --git a/apps/desktop/src/shared/sandbox/protocol.ts b/apps/desktop/src/shared/sandbox/protocol.ts new file mode 100644 index 000000000..7567dc0ee --- /dev/null +++ b/apps/desktop/src/shared/sandbox/protocol.ts @@ -0,0 +1,47 @@ +import type { + EditorInspection, + FontOverview, + GlyphPage, + GlyphSelector, + GlyphSummary, + LayerView, + ShiftSession, +} from "@shift/runtime"; +import type { GlyphId, SourceId } from "@shift/types"; + +export type SandboxCallMap = { + "sandbox.execute": { + request: { code: string }; + response: unknown; + }; +}; + +export type SandboxEventMap = { + "sandbox.ready": null; +}; + +export type SandboxHostCallMap = { + "shift.sessions.list": { + request: undefined; + response: ShiftSession[]; + }; + "shift.editor.inspect": { + request: { windowId: number }; + response: EditorInspection; + }; + "shift.font.get": { request: { windowId: number }; response: FontOverview }; + "shift.glyphs.list": { + request: { windowId: number; limit?: number; cursor?: string; sourceId?: SourceId }; + response: GlyphPage; + }; + "shift.glyphs.get": { + request: { windowId: number } & GlyphSelector; + response: GlyphSummary; + }; + "shift.layers.get": { + request: { windowId: number; glyphId: GlyphId; sourceId: SourceId }; + response: LayerView | null; + }; +}; + +export type SandboxHostEventMap = Record; diff --git a/apps/desktop/src/utility/sandbox.ts b/apps/desktop/src/utility/sandbox.ts new file mode 100644 index 000000000..6dfa644db --- /dev/null +++ b/apps/desktop/src/utility/sandbox.ts @@ -0,0 +1,35 @@ +import { executeShiftCode } from "@shift/sandbox"; +import type { ShiftCapabilities } from "@shift/runtime"; +import { Channel, parentPortTransport, serveChannel } from "../shared/workspace/channel"; +import type { + SandboxCallMap, + SandboxEventMap, + SandboxHostCallMap, + SandboxHostEventMap, +} from "../shared/sandbox/protocol"; + +const transport = parentPortTransport(); +const host = new Channel(transport); +const capabilities: ShiftCapabilities = { + sessions: { + list: () => host.call("shift.sessions.list", undefined), + }, + editor: { + inspect: (input) => host.call("shift.editor.inspect", input), + }, + font: { + get: (input) => host.call("shift.font.get", input), + }, + glyphs: { + list: (input) => host.call("shift.glyphs.list", input), + get: (input) => host.call("shift.glyphs.get", input), + }, + layers: { + get: (input) => host.call("shift.layers.get", input), + }, +}; +const runtime = serveChannel(transport, { + "sandbox.execute": ({ code }) => executeShiftCode(capabilities, code), +}); + +runtime.emit("sandbox.ready", null); diff --git a/docs/architecture/index.md b/docs/architecture/index.md index 7c64bc625..1e6357793 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -15,7 +15,7 @@ Central routing table for Shift's distributed documentation. Before creating new | Path pattern | Canonical doc | Purpose | | --------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| `crates/fontsrc/**` | [`crates/fontsrc/docs/DOCS.md`](../../crates/fontsrc/docs/DOCS.md) | Shift-independent authored font source reading and writing | +| `crates/fontsrc/**` | [`crates/fontsrc/docs/DOCS.md`](../../crates/fontsrc/docs/DOCS.md) | Shift-independent authored font source reading and writing | | `crates/shift-backends/**` | [`crates/shift-backends/docs/DOCS.md`](../../crates/shift-backends/docs/DOCS.md) | Font format backends for reading/writing various font formats | | `crates/shift-font/**` | [`crates/shift-font/docs/DOCS.md`](../../crates/shift-font/docs/DOCS.md) | First-class Rust font object model and editing behavior | | `crates/shift-slug/**` | [`crates/shift-slug/docs/DOCS.md`](../../crates/shift-slug/docs/DOCS.md) | GPU-independent Slug curves, retained compilation, and packing | @@ -54,6 +54,9 @@ Central routing table for Shift's distributed documentation. Before creating new | `packages/types/**` | [`packages/types/docs/DOCS.md`](../../packages/types/docs/DOCS.md) | Branded IDs, generated bridge DTO facade, and shared domain types | | `packages/geo/**` | [`packages/geo/docs/DOCS.md`](../../packages/geo/docs/DOCS.md) | Geometry utilities (Vec2, Curve, Polygon, Mat) | | `packages/glyph-state/**` | [`packages/glyph-state/docs/DOCS.md`](../../packages/glyph-state/docs/DOCS.md) | Glyph-domain geometry (contour traversal, segment parsing, bounds) | +| `packages/mcp/**` | [`packages/mcp/docs/DOCS.md`](../../packages/mcp/docs/DOCS.md) | Local code-mode access to the live desktop application | +| `packages/runtime/**` | [`packages/runtime/docs/DOCS.md`](../../packages/runtime/docs/DOCS.md) | Shared protocol and plugin capability contracts | +| `packages/sandbox/**` | [`packages/sandbox/docs/DOCS.md`](../../packages/sandbox/docs/DOCS.md) | Bounded code execution independent of protocol adapters | | `packages/ui/**` | [`packages/ui/docs/DOCS.md`](../../packages/ui/docs/DOCS.md) | UI component library wrapping Base UI primitives | | `packages/validation/**` | [`packages/validation/docs/DOCS.md`](../../packages/validation/docs/DOCS.md) | Point sequence validation and persistence schemas | | `packages/rules/**` | [`packages/rules/docs/DOCS.md`](../../packages/rules/docs/DOCS.md) | Point editing rules engine for geometric constraints | diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 000000000..e3f698c73 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,53 @@ +# Connect an AI agent to Shift + +Shift exposes the fonts **currently open in the desktop app** through a local, read-only [MCP](https://modelcontextprotocol.io/) server. Claude Code, Codex, and other HTTP MCP clients can connect directly; no Shift client program or project checkout is required. This is a one-time setup per Shift build and MCP client. + +## Find your connection + +1. Start Shift. It creates a private `mcp.json` in its user-data directory. Open that file **locally** and copy its `url` and `token` fields. Never share the token with an agent, put it in a prompt, or commit it to a repository. +2. Configure your MCP client at **user scope**, not inside a project. Add the HTTP URL and the header `Authorization: Bearer `. You can add just the Shift builds you use. +3. Restart or refresh the client, then call `shift.describe` to confirm the connection. Shift must be running for calls to succeed. + +| Build | MCP name | URL | User-data directory name | +| --- | --- | --- | --- | +| Shift | `shift` | `http://127.0.0.1:17461/mcp` | `Shift` | +| Shift Nightly | `shift-nightly` | `http://127.0.0.1:17462/mcp` | `Shift Nightly` | +| Shift Dev | `shift-dev` | `http://127.0.0.1:17463/mcp` | `Shift Dev` | +| Shift Nightly Dev | `shift-nightly-dev` | `http://127.0.0.1:17464/mcp` | `Shift Nightly Dev` | + +The user-data directory is normally under `~/Library/Application Support/` on macOS, `%APPDATA%\` on Windows, and `${XDG_CONFIG_HOME:-~/.config}/` on Linux. If Shift was launched with `--user-data-dir`, its `mcp.json` is there instead. Each build has a **different token**. The file remains after Shift quits, and the token survives relaunches; its URL is only reachable while that build is running. + +### Claude Code + +Use Claude Code's [user-scoped MCP configuration](https://code.claude.com/docs/en/mcp). On macOS/Linux, the following reads the token from your clipboard without putting it in shell history (paste when `read` waits for input): + +```sh +read -rs SHIFT_MCP_TOKEN +claude mcp add --transport http --scope user shift http://127.0.0.1:17461/mcp \ + --header "Authorization: Bearer $SHIFT_MCP_TOKEN" +unset SHIFT_MCP_TOKEN +``` + +Use the matching name and URL from the table for Nightly or Dev, and copy **that build's token**. Claude Code stores the header in your private user configuration; keep that file private. Check the connection with `claude mcp get shift` or Claude Code's `/mcp` command. + +### Codex + +Add a server to your **personal** `~/.codex/config.toml` (not a repository's `.codex/config.toml`). Replace the placeholder with the token from your private Shift `mcp.json`: + +```toml +[mcp_servers.shift] +url = "http://127.0.0.1:17461/mcp" +http_headers = { Authorization = "Bearer " } +``` + +Add another `[mcp_servers.shift-nightly]` or `[mcp_servers.shift-dev]` entry with its matching URL and token if needed. Protect the token-bearing file from other users (for example, `chmod 600 ~/.codex/config.toml` on macOS/Linux), and never copy it into a project. Codex also supports `bearer_token_env_var` if you prefer to supply the token through your own secret manager instead of storing it in client configuration. + +### Other MCP clients + +Add a **Streamable HTTP** server with the name and URL from the table, and set its `Authorization` header to `Bearer `. Configure it in your personal client settings; Shift does not modify another application's configuration. Keep each build's name distinct so the client doesn't silently switch fonts when you change apps. + +## What the connection can read + +`shift.describe` describes the typed API. `shift.execute` runs bounded, read-only code against explicit live Shift window IDs. Agents can list open sessions and read font metadata, glyph directories, authored source layers, and point-in-time editor observations. Preview geometry is not authored data. An offline file that is not open in Shift should be inspected with [`shift-cli`](../crates/shift-cli/README.md), not this live server. + +If a connection fails, make sure that build is running and its URL matches the file. Shift does **not** switch to another port when the assigned one is occupied; close the conflicting process and restart Shift. An invalid or insecure `mcp.json` is not overwritten automatically. To deliberately rotate a token, quit Shift, move the private `mcp.json` out of its user-data directory, start Shift again, and update your MCP clients with the new token. Keep the old file private or delete it when you no longer need it. diff --git a/knip.json b/knip.json index 4124fad2b..5b986ceaf 100644 --- a/knip.json +++ b/knip.json @@ -19,6 +19,7 @@ "entry": [ "src/main/main.ts", "src/preload/preload.ts", + "src/utility/sandbox.ts", "src/utility/workspace.ts", "src/renderer/renderer.ts", "electron-builder.config.ts", diff --git a/packages/editor/src/lib/clipboard/ClipboardSelection.ts b/packages/editor/src/lib/clipboard/ClipboardSelection.ts index 0eabd47bf..dcfbee549 100644 --- a/packages/editor/src/lib/clipboard/ClipboardSelection.ts +++ b/packages/editor/src/lib/clipboard/ClipboardSelection.ts @@ -1,8 +1,7 @@ -import { isPointId, type PointId } from "@shift/types"; +import { isPointId, type PointId, type SelectableId } from "@shift/types"; import { Validate } from "@shift/validation"; import type { Contour, Point } from "@shift/glyph-state"; import type { ContourContent, PointContent, ShiftContent } from "./types"; -import type { SelectableId } from "../../types/object"; export interface ClipboardContourSource { readonly contours: readonly Contour[]; diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index 92724d21b..fce6afd95 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -1,5 +1,4 @@ import type { CursorType, ToolRegistryItem } from "../../types/editor"; -import type { FontSessionMode } from "@shift/types"; import { isAnchorId, isContourId, @@ -16,11 +15,15 @@ import { type GlyphName, type GlyphRecord, type Unicode, + type FontSessionMode, type LayerId, type LayerMatch, type NodeId, + type SegmentId, + type SelectableId, + type ShiftId, } from "@shift/types"; -import { isSegmentId, type SegmentId } from "@shift/glyph-state"; +import { isSegmentId } from "@shift/glyph-state"; import type { ExternalAxisLocation } from "../../types/variation"; import type { SourceSelectionMode } from "../../types/sourceSelection"; import type { @@ -104,7 +107,7 @@ import type { PointerTarget } from "../../types/target"; import type { ComponentTransformSelection } from "../../types/componentTransform"; import type { ComponentTargets } from "../../types/componentTargets"; import type { PositionSelection } from "../../types/positionEdit"; -import type { SelectableId, ShiftId, ShiftObject } from "../../types/object"; +import type { ShiftObject } from "../../types/object"; import type { ShiftEditorRecord } from "../../types/records"; import type { GlyphNode, NodeKind, ShiftNode } from "../../types/node"; import { diff --git a/packages/editor/src/lib/editor/Hover.ts b/packages/editor/src/lib/editor/Hover.ts index dc37bee38..07204df33 100644 --- a/packages/editor/src/lib/editor/Hover.ts +++ b/packages/editor/src/lib/editor/Hover.ts @@ -1,5 +1,5 @@ import { computed, signal, type Signal, type WritableSignal } from "../signals/index"; -import type { SelectableId } from "../../types/object"; +import type { SelectableId } from "@shift/types"; export type HoverEntry = SelectableId; export type HoverableId = SelectableId; diff --git a/packages/editor/src/lib/editor/Selection.ts b/packages/editor/src/lib/editor/Selection.ts index b7699a979..40243ab58 100644 --- a/packages/editor/src/lib/editor/Selection.ts +++ b/packages/editor/src/lib/editor/Selection.ts @@ -1,7 +1,8 @@ import { computed, type Signal } from "../signals/signal"; import type { ShiftStore } from "../store/ShiftStore"; import { uniqueInOrder } from "../utils/utils"; -import { currentSelectionId, type SelectableId } from "../../types/object"; +import type { SelectableId } from "@shift/types"; +import { currentSelectionId } from "../../types/object"; import type { ShiftEditorRecord } from "../../types/records"; export interface SelectionState { diff --git a/packages/editor/src/lib/editor/docs/DOCS.md b/packages/editor/src/lib/editor/docs/DOCS.md index 25eb66fa9..297fba6c3 100644 --- a/packages/editor/src/lib/editor/docs/DOCS.md +++ b/packages/editor/src/lib/editor/docs/DOCS.md @@ -92,7 +92,7 @@ editor/ - **`Canvas`** -- Thin wrapper around `CanvasRenderingContext2D` that tracks the current units-to-screen transform (`withSceneSpace()`/`withTransform()`/`withScreenSpace()`). Geometry is drawn in the current units; widths, dashes and radii are screen pixels applied in screen space, so nothing converts pixels to units. Carries `CameraTransform` and `EditorRenderTheme`. - **`CameraTransform`** -- Value object: `{ view, zoom, logicalWidth, logicalHeight }`. Snapshot of viewport state passed to rendering code. - **`Selection`** -- Ordered branded-ID selection state. It exposes `stateCell` and unwrapped ID getters; `Editor.selectionBoundsCell` resolves current live objects and their bounds. -- **`SelectableId`** -- Branded identity accepted by selection regardless of the object's concrete kind. +- **`SelectableId`** -- Identity union imported from `@shift/types` and accepted by selection regardless of the object's concrete kind. - **`Coordinates`** -- Pair of `{ screen, scene }` for a single pointer position. Node-local coordinates are derived after hit testing identifies the node being acted on. - **`PositionSelection`** -- One active reference `GlyphLayer` and normalized point/anchor targets plus the corresponding targets on every completely matched editing layer. It contains edit ownership only, not scene placement or pointer coordinates. - **`editingSourceIdsCell`** -- Session-only source selection for multi-source editing. The active source remains the reference; additional selected sources render as comparison outlines. @@ -200,7 +200,7 @@ Glyph geometry exposes domain hit queries for points, anchors, and segments. Too ### Add a new selectable entity kind 1. Define or import its branded identity and guard. -2. Add the identity to `ShiftId` and `SelectableId` in the object type boundary. +2. Add the identity to `ShiftId` and `SelectableId` in `@shift/types`. 3. Resolve it in `Editor.object()` and provide live object bounds. 4. Add editor tests for lookup, selection bounds, and invalidation after edits. diff --git a/packages/editor/src/lib/editor/rendering/overlays/DebugOverlays.ts b/packages/editor/src/lib/editor/rendering/overlays/DebugOverlays.ts index 3e08449ad..76191b4cc 100644 --- a/packages/editor/src/lib/editor/rendering/overlays/DebugOverlays.ts +++ b/packages/editor/src/lib/editor/rendering/overlays/DebugOverlays.ts @@ -1,6 +1,6 @@ import type { Canvas } from "../Canvas"; import type { GlyphRenderModel } from "../../../model/Glyph"; -import type { SegmentId } from "../../../../types/indicator"; +import type { SegmentId } from "@shift/types"; export class DebugOverlays { draw( diff --git a/packages/editor/src/lib/editor/rendering/overlays/Segments.ts b/packages/editor/src/lib/editor/rendering/overlays/Segments.ts index de5d4f8c1..65b52f419 100644 --- a/packages/editor/src/lib/editor/rendering/overlays/Segments.ts +++ b/packages/editor/src/lib/editor/rendering/overlays/Segments.ts @@ -1,5 +1,6 @@ import type { Canvas } from "../Canvas"; -import type { Segment, SegmentId } from "@shift/glyph-state"; +import type { Segment } from "@shift/glyph-state"; +import type { SegmentId } from "@shift/types"; import type { GlyphRenderModel } from "../../../model/Glyph"; export class Segments { diff --git a/packages/editor/src/lib/editor/rendering/overlays/handles/HandleItems.ts b/packages/editor/src/lib/editor/rendering/overlays/handles/HandleItems.ts index baee65fb5..30637238d 100644 --- a/packages/editor/src/lib/editor/rendering/overlays/handles/HandleItems.ts +++ b/packages/editor/src/lib/editor/rendering/overlays/handles/HandleItems.ts @@ -1,6 +1,5 @@ -import type { PointId, ContourId } from "@shift/types"; import { Bounds, type Bounds as BoundsType } from "@shift/geo"; -import type { SelectableId } from "../../../../../types/object"; +import type { ContourId, PointId, SelectableId } from "@shift/types"; import type { HandleState } from "../../../../../types/graphics"; import type { Hover } from "../../../Hover"; import type { Selection } from "../../../Selection"; diff --git a/packages/editor/src/lib/model/ContourBuffer.ts b/packages/editor/src/lib/model/ContourBuffer.ts index e6722884f..420a28904 100644 --- a/packages/editor/src/lib/model/ContourBuffer.ts +++ b/packages/editor/src/lib/model/ContourBuffer.ts @@ -1,12 +1,6 @@ import type { Bounds as BoundsType } from "@shift/geo"; -import type { ContourData, PointId, PointSeed } from "@shift/types"; -import { - Contour, - type GlyphPosition, - Point, - type Segment, - type SegmentId, -} from "@shift/glyph-state"; +import type { ContourData, PointId, PointSeed, SegmentId } from "@shift/types"; +import { Contour, type GlyphPosition, Point, type Segment } from "@shift/glyph-state"; import { batch, computed, diff --git a/packages/editor/src/lib/model/Font.ts b/packages/editor/src/lib/model/Font.ts index 8d4fe3215..b9237262e 100644 --- a/packages/editor/src/lib/model/Font.ts +++ b/packages/editor/src/lib/model/Font.ts @@ -11,6 +11,7 @@ import type { GlyphEntry, GlyphHandle, GlyphId, + GlyphLayerSnapshot, GlyphPreview, GlyphRecord, GlyphSnapshotRequest, @@ -24,6 +25,7 @@ import type { LayerMatch, Location, PointId, + SegmentId, NamedInstance, NamedInstanceDefinition, NamedInstanceId, @@ -36,7 +38,6 @@ import { mintNamedInstanceId, mintSourceId, } from "@shift/types"; -import type { SegmentId } from "@shift/glyph-state"; import { batch, computed, @@ -615,6 +616,54 @@ export class Font { return this.#directoryCell.peek().records; } + /** + * Reads accepted authored layers without loading glyphs into the editor. + * + * @remarks + * Workspace reads follow pending writes; gesture previews are not included. + * The returned snapshots do not publish glyph models or mutate the font. + * + * @param input - Glyph IDs to read in order and an authored source identity. + * @returns One snapshot per glyph ID, or `null` when that glyph lacks a layer in a known source. + * @throws {Error} when workspace authorship is unavailable, an identity is unknown, or an advertised layer cannot be read. + */ + async readAuthoredLayers({ + glyphIds, + sourceId, + }: { + glyphIds: readonly GlyphId[]; + sourceId: SourceId; + }): Promise { + if (!this.#editCoordinator) throw new Error("Authored layers are unavailable in this font"); + + const sourceIsKnown = + this.sources.some((source) => source.id === sourceId) || + this.glyphRecords().some((glyph) => + glyph.layers.some((layer) => layer.sourceId === sourceId), + ); + if (!sourceIsKnown) throw new Error(`Source ${sourceId} is not in this font`); + + const records = glyphIds.map((glyphId) => { + if (!this.entryForId(glyphId)) throw new Error(`Glyph ${glyphId} is not in this font`); + return this.recordForId(glyphId); + }); + const requests = glyphIds + .filter((glyphId) => + this.recordForId(glyphId)?.layers.some((layer) => layer.sourceId === sourceId), + ) + .map((glyphId) => ({ glyphId })); + const snapshots = await this.#editCoordinator.readGlyphSnapshots(requests); + const byId = new Map(snapshots.map((snapshot) => [snapshot.glyphId, snapshot])); + + return records.map((record) => { + if (!record?.layers.some((layer) => layer.sourceId === sourceId)) return null; + + const layer = byId.get(record.id)?.layers.find((layer) => layer.sourceId === sourceId); + if (!layer) throw new Error(`Authored layer for glyph ${record.id} was not returned`); + return layer; + }); + } + /** * Resolves the preferred glyph name for a Unicode scalar. * diff --git a/packages/editor/src/lib/model/FontStore.ts b/packages/editor/src/lib/model/FontStore.ts index 4ee52fae0..0063ad69e 100644 --- a/packages/editor/src/lib/model/FontStore.ts +++ b/packages/editor/src/lib/model/FontStore.ts @@ -15,11 +15,12 @@ import type { LayerId, PointData, PointId, + SegmentId, SourceId, WorkspaceGlyphLayerSnapshot, WorkspaceSnapshot, } from "@shift/types"; -import { segmentIdFor, type SegmentId } from "@shift/glyph-state"; +import { segmentIdFor } from "@shift/glyph-state"; import { Validate } from "@shift/validation"; import { batch, diff --git a/packages/editor/src/lib/model/Glyph.ts b/packages/editor/src/lib/model/Glyph.ts index 45ad3fe14..d7992ec99 100644 --- a/packages/editor/src/lib/model/Glyph.ts +++ b/packages/editor/src/lib/model/Glyph.ts @@ -18,6 +18,7 @@ import type { LayerId, PointId, PointSeed, + SegmentId, Source, SourceId, Unicode, @@ -70,7 +71,6 @@ import { type GeometrySegmentHit, type GlyphHit, Segment, - type SegmentId, type GlyphPosition as GlyphLayerPosition, type GlyphPositions as GlyphLayerPositions, type GlyphPositionTarget as GlyphLayerPositionTarget, diff --git a/packages/editor/src/lib/model/docs/DOCS.md b/packages/editor/src/lib/model/docs/DOCS.md index 8f61c05fa..cc5b44f3e 100644 --- a/packages/editor/src/lib/model/docs/DOCS.md +++ b/packages/editor/src/lib/model/docs/DOCS.md @@ -8,6 +8,7 @@ Reactive TypeScript font, authored glyph-layer, and derived glyph-view surfaces. - **Architecture Invariant:** `FontSession` is the renderer's discriminated union of immutable `PreviewFontSession`, `MemoryFontSession`, and `WorkspaceFontSession` compositions. Every mode uses `FontStore → Font → Editor → Scene → Renderer` and exposes one concrete `GlyphCatalog`. Preview is read-only; memory permits local editing without persistence; workspace owns a non-null `Workspace` and durable mutation coordinator. Desktop presentation and persistence code narrow authoring to the workspace variant until local edit completion no longer requires a workspace; the memory variant has no desktop host or persistence lane. Preview sessions eagerly receive stable session `GlyphId` values but no authored `GlyphRecord` or `GlyphLayer`. - **Architecture Invariant:** `Font.loadGlyph()` is the asynchronous acquisition boundary. It returns one canonical `Glyph` only after every authored layer and transitive component dependency is available; retained calls return that same object without workspace I/O. `Editor.glyphForId()` may synchronously expose that object to runtime and plugin code after acquisition, but never initiates loading. +- **Architecture Invariant:** `Font.readAuthoredLayers({ glyphIds, sourceId })` reads accepted workspace snapshots behind pending writes without loading glyph models or exposing gesture previews. It returns ordered authored layer snapshots, `null` for a glyph without a layer in a known source, and errors for unknown identities or unavailable workspace authorship. Glyph-specific support sources count as known source identities even when absent from global masters. - **Architecture Invariant:** Complete `GlyphInfo` metadata is an optional `Font` construction input owned by the composition root. Without it, existing font mappings still resolve first and unknown codepoints use deterministic `uniXXXX` names; model modules never import the bundled resource payload at runtime. - **Architecture Invariant:** A loaded `Glyph` owns all authored `GlyphLayer` objects and its transitive component-Glyph closure. Its record, layer, and component collections update reactively without replacing the Glyph; its synchronous properties never initiate I/O. - **Architecture Invariant:** `FontStore.#glyphs` contains only completely assembled Glyphs. Failed assembly installs nothing, and workspace replacement clears the complete object graph. diff --git a/packages/editor/src/lib/nodes/GlyphNodeDefinition.ts b/packages/editor/src/lib/nodes/GlyphNodeDefinition.ts index 3dd9c9187..976c5ad23 100644 --- a/packages/editor/src/lib/nodes/GlyphNodeDefinition.ts +++ b/packages/editor/src/lib/nodes/GlyphNodeDefinition.ts @@ -1,6 +1,5 @@ import { Mat } from "@shift/geo"; -import type { SegmentId } from "@shift/glyph-state"; -import type { ComponentId, NodeId, PointId } from "@shift/types"; +import type { ComponentId, NodeId, PointId, SegmentId } from "@shift/types"; import type { LocalBounds, LocalPoint } from "../../types/coordinates"; import { localBounds } from "../editor/spaces"; import { SCREEN_HIT_RADIUS } from "../editor/rendering/constants"; diff --git a/packages/editor/src/lib/objects/SegmentObject.ts b/packages/editor/src/lib/objects/SegmentObject.ts index 3b548854f..11efb5b60 100644 --- a/packages/editor/src/lib/objects/SegmentObject.ts +++ b/packages/editor/src/lib/objects/SegmentObject.ts @@ -1,7 +1,7 @@ -import { localBounds } from "../editor/spaces"; +import type { GlyphGeometry } from "@shift/glyph-state"; +import type { ContourId, PointId, SegmentId } from "@shift/types"; import type { LocalBounds } from "../../types/coordinates"; -import type { GlyphGeometry, SegmentId } from "@shift/glyph-state"; -import type { ContourId, PointId } from "@shift/types"; +import { localBounds } from "../editor/spaces"; import { track } from "../signals/index"; import type { GlyphLayer } from "../model/Glyph"; import type { ShiftObjectOf } from "../../types/object"; diff --git a/packages/editor/src/lib/tools/pen/PenStroke.ts b/packages/editor/src/lib/tools/pen/PenStroke.ts index 88203f4ad..5dda31a64 100644 --- a/packages/editor/src/lib/tools/pen/PenStroke.ts +++ b/packages/editor/src/lib/tools/pen/PenStroke.ts @@ -1,6 +1,6 @@ import type { Point2D } from "@shift/geo"; -import type { ContourId, PointId } from "@shift/types"; -import { Point, type Contour, type SegmentId } from "@shift/glyph-state"; +import type { ContourId, PointId, SegmentId } from "@shift/types"; +import { Point, type Contour } from "@shift/glyph-state"; import type { GlyphLayer } from "../../model/Glyph"; import type { GlyphLayerEdit } from "../../model/GlyphLayerEdit"; import type { MoveEdit } from "../../model/positions/index"; diff --git a/packages/editor/src/lib/tools/pen/PenTargets.ts b/packages/editor/src/lib/tools/pen/PenTargets.ts index ca0865a92..04d2fdd1e 100644 --- a/packages/editor/src/lib/tools/pen/PenTargets.ts +++ b/packages/editor/src/lib/tools/pen/PenTargets.ts @@ -1,6 +1,6 @@ import type { Point2D } from "@shift/geo"; -import { Point, type SegmentId } from "@shift/glyph-state"; -import type { ContourId, PointId } from "@shift/types"; +import { Point } from "@shift/glyph-state"; +import type { ContourId, PointId, SegmentId } from "@shift/types"; import type { GlyphGeometry } from "../../model/Glyph"; export type PenTarget = diff --git a/packages/editor/src/lib/tools/select/behaviors/Marquee.ts b/packages/editor/src/lib/tools/select/behaviors/Marquee.ts index eb9fc4f21..8cb9e3cf8 100644 --- a/packages/editor/src/lib/tools/select/behaviors/Marquee.ts +++ b/packages/editor/src/lib/tools/select/behaviors/Marquee.ts @@ -1,6 +1,6 @@ import { Bounds, Curve, Rect, type Rect2D } from "@shift/geo"; +import type { SelectableId } from "@shift/types"; import { sceneBounds } from "../../../editor/spaces"; -import type { SelectableId } from "../../../../types/object"; import type { ToolContext } from "../../core/Behavior"; import type { DragEndEvent, DragEvent, DragStartEvent } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; diff --git a/packages/editor/src/lib/tools/select/behaviors/Selection.ts b/packages/editor/src/lib/tools/select/behaviors/Selection.ts index d6775f238..1b60a122f 100644 --- a/packages/editor/src/lib/tools/select/behaviors/Selection.ts +++ b/packages/editor/src/lib/tools/select/behaviors/Selection.ts @@ -1,7 +1,7 @@ import type { ToolContext } from "../../core/Behavior"; import type { ClickEvent } from "../../core/GestureDetector"; import type { SelectBehavior, SelectState } from "../types"; -import type { SelectableId } from "../../../../types/object"; +import type { SelectableId } from "@shift/types"; export class Selection implements SelectBehavior { onClick(state: SelectState, ctx: ToolContext, event: ClickEvent): boolean { diff --git a/packages/editor/src/lib/tools/select/types.ts b/packages/editor/src/lib/tools/select/types.ts index d36daebed..b3f18eece 100644 --- a/packages/editor/src/lib/tools/select/types.ts +++ b/packages/editor/src/lib/tools/select/types.ts @@ -1,12 +1,10 @@ import type { Point2D, Rect2D } from "@shift/geo"; -import type { AnchorId, PointId } from "@shift/types"; +import type { AnchorId, PointId, SegmentId, SelectableId } from "@shift/types"; import type { BoundingRectEdge } from "./cursor"; import type { CornerHandle } from "./BoundingBox"; import type { Behavior } from "../core/Behavior"; import type { Select } from "./Select"; -import type { SegmentId } from "../../../types/indicator"; import type { PositionGuide } from "../../../types/positionEdit"; -import type { SelectableId } from "../../../types/object"; import type { LocalBounds } from "../../../types/coordinates"; export interface DragTarget { diff --git a/packages/editor/src/types.ts b/packages/editor/src/types.ts index 79e29ffab..dd3e9834d 100644 --- a/packages/editor/src/types.ts +++ b/packages/editor/src/types.ts @@ -27,7 +27,7 @@ export type { CanvasRef } from "./types/graphics"; export type { CubicHandle } from "./types/handle"; export type { GlyphNode } from "./types/node"; export { NUDGES_VALUES, nudgeMagnitude, type NudgeMagnitude } from "./types/nudge"; -export { currentSelectionId, objectIsKindOf, type SelectableId } from "./types/object"; +export { currentSelectionId, objectIsKindOf } from "./types/object"; export type { ListSelectionMode } from "./types/listSelection"; export type { PositionGuide, PositionSelection } from "./types/positionEdit"; export type { ShiftEditorRecord } from "./types/records"; diff --git a/packages/editor/src/types/glyph.ts b/packages/editor/src/types/glyph.ts index 765af119a..1a8b8f214 100644 --- a/packages/editor/src/types/glyph.ts +++ b/packages/editor/src/types/glyph.ts @@ -11,10 +11,10 @@ import type { GlyphSnapshot, LayerId, PointId, + SegmentId, Source, SourceId, } from "@shift/types"; -import type { SegmentId } from "@shift/glyph-state"; import type { Glyph, GlyphLayer } from "../lib/model/Glyph"; import type { Signal } from "../lib/signals/signal"; import type { DesignAxisLocation } from "./variation"; diff --git a/packages/editor/src/types/indicator.ts b/packages/editor/src/types/indicator.ts index 1dff939bc..43b78cca6 100644 --- a/packages/editor/src/types/indicator.ts +++ b/packages/editor/src/types/indicator.ts @@ -1,8 +1,5 @@ import type { Point2D } from "@shift/geo"; -import type { PointId } from "@shift/types"; -import type { SegmentId } from "@shift/glyph-state"; - -export type { SegmentId }; +import type { PointId, SegmentId } from "@shift/types"; /** * Describes the closest point on a segment to the cursor. diff --git a/packages/editor/src/types/object.ts b/packages/editor/src/types/object.ts index 16bd4b165..1fb66ed4a 100644 --- a/packages/editor/src/types/object.ts +++ b/packages/editor/src/types/object.ts @@ -1,6 +1,14 @@ +import type { GlyphGeometry } from "@shift/glyph-state"; +import type { + AnchorId, + ComponentId, + ContourId, + NodeId, + PointId, + SegmentId, + ShiftId, +} from "@shift/types"; import type { LocalBounds } from "./coordinates"; -import type { GlyphGeometry, SegmentId } from "@shift/glyph-state"; -import type { AnchorId, ComponentId, ContourId, NodeId, PointId } from "@shift/types"; import type { GlyphLayer } from "../lib/model/Glyph"; import type { ComponentGlyph } from "../lib/model/ComponentGlyph"; import type { GlyphNode, ShiftNode } from "./node"; @@ -11,12 +19,6 @@ export type SelectionId = string & { readonly [SelectionIdBrand]: typeof Selecti export const currentSelectionId = "selection:current" as SelectionId; -/** Identifies an editor-addressable scene node or glyph object. */ -export type ShiftId = NodeId | PointId | AnchorId | ContourId | SegmentId | ComponentId; - -/** Identifies objects that can be selected by the editor. */ -export type SelectableId = ShiftId; - /** * Defines the shared contract for a resolved editor object. * diff --git a/packages/editor/src/types/records.ts b/packages/editor/src/types/records.ts index 12729159c..4a2bfcef2 100644 --- a/packages/editor/src/types/records.ts +++ b/packages/editor/src/types/records.ts @@ -1,7 +1,7 @@ -import type { NodeId, RunId } from "@shift/types"; +import type { NodeId, RunId, SelectableId, ShiftId } from "@shift/types"; import type { EditingId } from "./editing"; import type { GlyphNode, TextRunNode } from "./node"; -import type { SelectableId, SelectionId, ShiftId } from "./object"; +import type { SelectionId } from "./object"; import type { TextRunRecord } from "./text"; export type ShiftRecordId = ShiftId | SelectionId | EditingId | RunId; diff --git a/packages/glyph-state/docs/DOCS.md b/packages/glyph-state/docs/DOCS.md index 53b1be051..b427fea31 100644 --- a/packages/glyph-state/docs/DOCS.md +++ b/packages/glyph-state/docs/DOCS.md @@ -97,7 +97,7 @@ Renderer code should keep using cached `GlyphGeometry` instances from the model - The `componentTransformKind` passed to the `GlyphGeometry` constructor must match how the value buffer was packed: `"decomposed"` reads 9 values per component, `"affine"` reads 6. There is no runtime check -- a mismatch silently misreads every component transform. The default is `"decomposed"`. - `withPositionUpdates` copies the entire value buffer per call. Batch a frame's updates into one call; unknown point/anchor ids in the update list are skipped without error. - `allPoints` returns a fresh array copy on every access. Read it once and reuse the result inside loops. -- `SegmentId` is derived from the endpoint point ids (`segment::`), so it is stable across re-parses of unchanged geometry -- but any operation that replaces an endpoint produces a different id. Use `parseSegmentId` to recover the endpoints from an id. +- `SegmentId` is imported from `@shift/types` and derived here from endpoint point ids (`segment::`), so it is stable across re-parses of unchanged geometry -- but any operation that replaces an endpoint produces a different id. Use `parseSegmentId` to recover the endpoints from an id. ## Verification diff --git a/packages/glyph-state/src/GlyphGeometry.ts b/packages/glyph-state/src/GlyphGeometry.ts index aa68c4885..3c0ed4e0d 100644 --- a/packages/glyph-state/src/GlyphGeometry.ts +++ b/packages/glyph-state/src/GlyphGeometry.ts @@ -13,7 +13,8 @@ import type { AnchorHit } from "./Anchor"; import { Component } from "./Component"; import { Contour } from "./Contour"; import { IdIndex } from "./IdIndex"; -import { Segment, type SegmentId } from "./Segment"; +import type { SegmentId } from "@shift/types"; +import { Segment } from "./Segment"; import type { SegmentHit } from "./Segment"; import { Point } from "./Point"; import type { PointHit } from "./Point"; diff --git a/packages/glyph-state/src/Segment.ts b/packages/glyph-state/src/Segment.ts index 87e163d05..2d3f4f3ca 100644 --- a/packages/glyph-state/src/Segment.ts +++ b/packages/glyph-state/src/Segment.ts @@ -1,5 +1,5 @@ import { Curve, Vec2, type Bounds, type CurveType, type Point2D } from "@shift/geo"; -import type { PointId } from "@shift/types"; +import { asSegmentId, type PointId, type SegmentId } from "@shift/types"; import { Point } from "./Point"; import type { ContourGeometry, @@ -17,18 +17,8 @@ export type { CubicSegmentPoints, } from "./types/contour"; -declare const SegmentIdBrand: unique symbol; - -export type SegmentId = string & { - readonly [SegmentIdBrand]: typeof SegmentIdBrand; -}; - const SEGMENT_ID_PREFIX = "segment:"; -export function asSegmentId(id: string): SegmentId { - return id as SegmentId; -} - /** Returns the derived segment id for a segment's endpoint point ids. */ export function segmentIdFor(startPointId: PointId, endPointId: PointId): SegmentId { return asSegmentId(`${SEGMENT_ID_PREFIX}${startPointId}:${endPointId}`); diff --git a/packages/glyph-state/src/index.ts b/packages/glyph-state/src/index.ts index 8bcbe10a5..e6724e889 100644 --- a/packages/glyph-state/src/index.ts +++ b/packages/glyph-state/src/index.ts @@ -24,11 +24,9 @@ export { } from "./GlyphGeometry"; export { Segment, - asSegmentId, isSegmentId, parseSegmentId, segmentIdFor, - type SegmentId, type SegmentHit, type SegmentType, type SegmentPoints, diff --git a/packages/mcp/docs/DOCS.md b/packages/mcp/docs/DOCS.md new file mode 100644 index 000000000..beebc4fa9 --- /dev/null +++ b/packages/mcp/docs/DOCS.md @@ -0,0 +1,102 @@ +# MCP + + + +Local code-mode access to the live Shift desktop application. + +## Architecture Invariants + +- **Architecture Invariant:** `@shift/mcp` is an adapter over `ShiftCapabilities` from `@shift/runtime`. It does not own font, document, editor, window, persistence state, or the reusable plugin contract. +- **Architecture Invariant:** The Fastify MCP adapter binds only to `127.0.0.1`, validates localhost Host and Origin headers, and requires a persistent, private bearer token. Release, Nightly, Dev, and Nightly Dev use distinct fixed ports; a collision leaves MCP unavailable rather than selecting another port. Explicit test instances use port `0`. +- **Architecture Invariant:** `@shift/mcp` owns only the protocol adapter. `@shift/sandbox` owns bounded QuickJS execution against `ShiftCapabilities`; the desktop app owns the utility-process supervisor independently of whether the MCP listener starts. +- **Architecture Invariant:** Every editor request names a window explicitly. Focus changes never retarget an in-flight or subsequent call. +- **Architecture Invariant:** MCP is not Shift's canonical font API. Shared document and editor capabilities remain usable by future plugin and protocol hosts without MCP. The desktop host asks `Font.readAuthoredLayers()` for accepted authored snapshots; agent clients connect through native MCP support rather than a Shift-specific client CLI. + +## Codemap + +```text +src/ + declarations.ts -- loads @shift/runtime's generated declaration for shift.describe + types.ts -- MCP connection contract + server.ts -- MCP tools, Fastify loopback HTTP, persistent token, connection descriptor + index.ts -- main-process-safe public package surface +``` + +## Key Types + +- `ShiftCapabilities` -- host-neutral live operations owned by `@shift/runtime` and supplied by the desktop application. +- `ShiftSession` -- explicit window and font-session identity, mode, focus, and editor connection status. +- `EditorInspection` -- point-in-time renderer observation for one explicitly targeted session. +- `ShiftMcpServer` -- loopback MCP lifecycle, authentication, connection descriptor, and tool registration. +- `ShiftMcpConnection` -- local URL and persistent bearer token written to the private descriptor. + +## How it works + +### `shift.describe` + +Returns the TypeScript declarations available inside code mode. + +### `shift.execute` + +Accepts an async zero-argument JavaScript function and returns its JSON result. For a targeted live session: + +```ts +async () => { + const session = (await shift.sessions.list()).find(({ sessionId }) => sessionId === "..."); + if (!session) throw new Error("Target session closed"); + const font = await shift.font.get({ windowId: session.windowId }); + const page = await shift.glyphs.list({ windowId: session.windowId, limit: 20 }); + return { + family: font.metadata.familyName, + count: font.glyphCount, + names: page.items.map((g) => g.name), + }; +}; +``` + +`shift.sessions.list()` returns one entry per open font window. `shift.editor.inspect()` returns renderer-owned UI facts. `shift.font.get()` returns metadata, metrics, axes, sources, named instances, and glyph count even on Home. `shift.glyphs.list()` returns bounded directory pages with `nextCursor`; passing an explicit `sourceId` includes each glyph's authored structure for code-mode aggregation. `shift.glyphs.get()` returns one directory entry by exact `name` or stable `glyphId` (not both), and `shift.layers.get()` returns authored positions and structure for one glyph/source pair. Untrusted inputs are parsed using `@shift/runtime`'s shared Zod schemas; the code-mode sandbox remains bounded. + +## Desktop ownership + +Electron main starts an app-owned `SandboxRuntimeProcess` and then one `ShiftMcpServer` after `app.whenReady()`. MCP startup failure leaves the sandbox available for other execution hosts. The MCP server writes `mcp.json` under the distribution-specific user-data directory with mode `0600` on POSIX. The descriptor contains the loopback URL and persistent token; an existing valid, private token is reused on restart, while an invalid or insecure descriptor prevents MCP startup. Shutdown leaves the credential in place. The token is local connection material, not a user login. MCP delegates execution to the app-owned sandbox utility process, and main serves only the typed capability requests that return from that process. A hard host deadline terminates the process if its internal QuickJS deadline cannot settle; a later execution restarts it. + +Each renderer serves an agent request lane over a transferred `MessagePort`. Main pairs renderer observations with the explicit window and font-session identities before returning them. Launcher windows are excluded from session discovery. + +## Workflow recipes + +### Add a capability + +1. Put reusable semantic computation in its existing domain owner or shared Rust capability layer. +2. Add the public snapshot or operation to `@shift/runtime` and regenerate its code API. +3. Extend the desktop host implementation and sandbox protocol. +4. Route document truth to utility ownership and view truth to renderer ownership. +5. Add package contract coverage and a real desktop integration test. +6. Update the `shift` agent skill with the intended workflow. + +Do not expose internal `Editor`, `FontStore`, `WorkspaceHost`, NAPI, SQLite rows, or arbitrary IPC through this package. + +## Gotchas + +- The connection descriptor survives shutdown. A client must treat connection failure as authoritative when Shift is not running. The descriptor is checked for file type and, on POSIX, private mode before the token is reused; Windows relies on user-data directory ACLs. +- Focus is descriptive only. Always pass a `windowId` from the same `sessions.list()` result used to choose a target. +- A renderer can exist before its agent lane connects. Check `editorConnected` or retry session discovery rather than substituting another window. +- Code-mode results must be JSON-serializable and remain under the configured output bound. +- Preview fonts have no authored layers; source-scoped structure reads fail explicitly. Directory cursors do not freeze a changing font; scope counts to an explicit source and restart if the directory changes. + +## Verification + +```sh +pnpm --filter @shift/mcp test +pnpm --filter @shift/mcp typecheck +pnpm --filter @shift/mcp lint:check +pnpm typecheck +``` + +## Related + +- [`packages/runtime/docs/DOCS.md`](../../runtime/docs/DOCS.md) -- canonical protocol and plugin capability contracts. +- [`packages/sandbox/docs/DOCS.md`](../../sandbox/docs/DOCS.md) -- reusable execution boundary and process lifecycle. +- [`docs/mcp.md`](../../../docs/mcp.md) -- one-time user-scoped native MCP setup for installed Shift builds. +- [`apps/desktop/src/main/docs/DOCS.md`](../../../apps/desktop/src/main/docs/DOCS.md) -- Electron lifecycle, window/session identity, and renderer lanes. +- [`apps/desktop/src/preload/docs/DOCS.md`](../../../apps/desktop/src/preload/docs/DOCS.md) -- authenticated `MessagePort` transfer into the renderer. +- [`docs/architecture/index.md`](../../../docs/architecture/index.md) -- repository documentation routing and API boundaries. diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 000000000..f3cb8938f --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,39 @@ +{ + "name": "@shift/mcp", + "version": "0.0.1", + "license": "MIT OR Apache-2.0", + "private": true, + "description": "Live MCP access to the Shift font editor", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsgo --noEmit", + "lint": "oxlint --fix src/", + "lint:check": "oxlint --deny-warnings src/", + "test": "vitest run --config vitest.config.ts", + "test:watch": "vitest --config vitest.config.ts" + }, + "dependencies": { + "@modelcontextprotocol/fastify": "2.0.0", + "@modelcontextprotocol/node": "2.1.0", + "@modelcontextprotocol/server": "2.2.0", + "fastify": "^5.12.5", + "@shift/runtime": "workspace:*", + "zod": "^4.1.12" + }, + "devDependencies": { + "@modelcontextprotocol/client": "2.2.0", + "@shift/sandbox": "workspace:*", + "@types/node": "^25.3.0", + "oxlint": "^1.85.0", + "typescript": "^5.5.4", + "vitest": "^4.1.10" + } +} diff --git a/packages/mcp/src/declarations.ts b/packages/mcp/src/declarations.ts new file mode 100644 index 000000000..573e59c40 --- /dev/null +++ b/packages/mcp/src/declarations.ts @@ -0,0 +1,3 @@ +import shiftCodeTypes from "@shift/runtime/code-api?raw"; + +export const SHIFT_CODE_TYPES = shiftCodeTypes; diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 000000000..810237a70 --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,4 @@ +export { SHIFT_CODE_TYPES } from "./declarations"; +export { ShiftMcpServer } from "./server"; +export type { ShiftMcpLogger, ShiftMcpServerOptions } from "./server"; +export type { ShiftMcpConnection } from "./types"; diff --git a/packages/mcp/src/raw.d.ts b/packages/mcp/src/raw.d.ts new file mode 100644 index 000000000..8e6dc861f --- /dev/null +++ b/packages/mcp/src/raw.d.ts @@ -0,0 +1,4 @@ +declare module "*?raw" { + const source: string; + export default source; +} diff --git a/packages/mcp/src/server.test.ts b/packages/mcp/src/server.test.ts new file mode 100644 index 000000000..ff7858acd --- /dev/null +++ b/packages/mcp/src/server.test.ts @@ -0,0 +1,202 @@ +import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { executeShiftCode } from "@shift/sandbox"; +import { ShiftMcpServer } from "./server"; +import type { ShiftCapabilities } from "@shift/runtime"; +import type { ShiftMcpConnection } from "./types"; + +const capabilities: ShiftCapabilities = { + sessions: { + async list() { + return []; + }, + }, + editor: { + async inspect() { + throw new Error("No open Shift window"); + }, + }, + font: { + async get() { + throw new Error("No open Shift window"); + }, + }, + glyphs: { + async list() { + throw new Error("No open Shift window"); + }, + async get() { + throw new Error("No open Shift window"); + }, + }, + layers: { + async get() { + throw new Error("No open Shift window"); + }, + }, +}; + +const execute = (code: string) => executeShiftCode(capabilities, code); +const startedServers: ShiftMcpServer[] = []; +const temporaryDirectories: string[] = []; + +async function mcpRequest( + connection: ShiftMcpConnection, + method: string, + params: unknown, +): Promise { + const response = await fetch(connection.url, { + method: "POST", + headers: { + authorization: `Bearer ${connection.token}`, + accept: "application/json, text/event-stream", + "content-type": "application/json", + "mcp-protocol-version": "2025-06-18", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + expect(response.status).toBe(200); + const body = await response.text(); + return body.startsWith("event:") + ? JSON.parse(body.split("\ndata: ")[1]!.trim()) + : JSON.parse(body); +} + +afterEach(async () => { + await Promise.all(startedServers.splice(0).map((server) => server.stop())); + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +describe("Shift MCP local connection", () => { + it("keeps a private credential across restarts and rejects unauthorized callers", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "shift-mcp-")); + temporaryDirectories.push(directory); + const descriptorPath = path.join(directory, "connection.json"); + const server = new ShiftMcpServer({ execute, descriptorPath, port: 0 }); + startedServers.push(server); + + const connection = await server.start(); + const descriptor = JSON.parse(await readFile(descriptorPath, "utf8")); + const missingSecret = await fetch(connection.url, { method: "POST" }); + const foreignOrigin = await fetch(connection.url, { + method: "POST", + headers: { + authorization: `Bearer ${connection.token}`, + origin: "https://example.com", + }, + }); + + expect(descriptor).toEqual(connection); + expect((await stat(descriptorPath)).mode & 0o777).toBe(0o600); + expect(missingSecret.status).toBe(401); + expect(foreignOrigin.status).toBe(403); + + await server.stop(); + expect(JSON.parse(await readFile(descriptorPath, "utf8"))).toEqual(connection); + + const restarted = await server.start(); + expect(restarted.token).toBe(connection.token); + expect(JSON.parse(await readFile(descriptorPath, "utf8"))).toEqual(restarted); + }); + + it("accepts an MCP initialization with the persistent secret", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "shift-mcp-")); + temporaryDirectories.push(directory); + const server = new ShiftMcpServer({ + execute, + descriptorPath: path.join(directory, "connection.json"), + port: 0, + }); + startedServers.push(server); + const connection = await server.start(); + + const initialized = await mcpRequest(connection, "initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "shift-test", version: "1.0.0" }, + }); + const described = await mcpRequest(connection, "tools/call", { + name: "shift.describe", + arguments: {}, + }); + const executed = await mcpRequest(connection, "tools/call", { + name: "shift.execute", + arguments: { code: "async () => await shift.sessions.list()" }, + }); + + expect(initialized).toMatchObject({ result: { serverInfo: { name: "shift" } } }); + expect(described).toMatchObject({ + result: { content: [{ text: expect.stringContaining("declare global") }] }, + }); + expect(executed).toMatchObject({ result: { content: [{ text: "[]" }] } }); + }); + + it("connects with a native MCP client", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "shift-mcp-")); + temporaryDirectories.push(directory); + const server = new ShiftMcpServer({ + execute, + descriptorPath: path.join(directory, "connection.json"), + port: 0, + }); + startedServers.push(server); + const connection = await server.start(); + const client = new Client({ name: "shift-test", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(connection.url), { + authProvider: { token: async () => connection.token }, + }); + + try { + await client.connect(transport); + const result = await client.callTool({ + name: "shift.execute", + arguments: { code: "async () => await shift.sessions.list()" }, + }); + expect(result.content).toMatchObject([{ type: "text", text: "[]" }]); + } finally { + await client.close(); + } + }); + + it("rejects malformed or insecure credential files without replacing them", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "shift-mcp-")); + temporaryDirectories.push(directory); + const descriptorPath = path.join(directory, "connection.json"); + const server = new ShiftMcpServer({ execute, descriptorPath, port: 0 }); + startedServers.push(server); + await writeFile(descriptorPath, "invalid JSON", { mode: 0o600 }); + await expect(server.start()).rejects.toThrow(); + expect(await readFile(descriptorPath, "utf8")).toBe("invalid JSON"); + + if (process.platform === "win32") return; + await chmod(descriptorPath, 0o644); + await expect(server.start()).rejects.toThrow(/insecure Shift MCP descriptor/); + }); + + it("does not fall back to another port when its port is occupied", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "shift-mcp-")); + temporaryDirectories.push(directory); + const first = new ShiftMcpServer({ + execute, + descriptorPath: path.join(directory, "first.json"), + port: 0, + }); + startedServers.push(first); + const connection = await first.start(); + const secondPath = path.join(directory, "second.json"); + const second = new ShiftMcpServer({ + execute, + descriptorPath: secondPath, + port: Number(new URL(connection.url).port), + }); + startedServers.push(second); + + await expect(second.start()).rejects.toMatchObject({ code: "EADDRINUSE" }); + await expect(stat(secondPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts new file mode 100644 index 000000000..7777bdc15 --- /dev/null +++ b/packages/mcp/src/server.ts @@ -0,0 +1,179 @@ +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { lstat, mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { createMcpFastifyApp } from "@modelcontextprotocol/fastify"; +import { toNodeHandler } from "@modelcontextprotocol/node"; +import { createMcpHandler, McpServer } from "@modelcontextprotocol/server"; +import type { FastifyInstance } from "fastify"; +import * as z from "zod/v4"; +import { SHIFT_CODE_TYPES } from "./declarations"; +import type { ShiftMcpConnection } from "./types"; + +const LOOPBACK_HOST = "127.0.0.1"; +const MCP_PATH = "/mcp"; + +export interface ShiftMcpLogger { + info(message: string, details?: unknown): void; + warn(message: string, details?: unknown): void; + error(message: string, details?: unknown): void; +} + +export interface ShiftMcpServerOptions { + execute(code: string): Promise; + descriptorPath: string; + port: number; + logger?: ShiftMcpLogger; +} + +/** Serves code-mode access to one running Shift application over loopback HTTP. */ +export class ShiftMcpServer { + readonly #execute: (code: string) => Promise; + readonly #descriptorPath: string; + readonly #port: number; + readonly #logger: ShiftMcpLogger | undefined; + #httpServer: FastifyInstance | null = null; + #closeHandler: (() => Promise) | null = null; + #connection: ShiftMcpConnection | null = null; + + /** + * Creates an unstarted server bound to a host-owned isolated executor. + * + * @param options - execution callback, private descriptor path, port, and optional diagnostics sink. + */ + constructor(options: ShiftMcpServerOptions) { + this.#execute = options.execute; + this.#descriptorPath = options.descriptorPath; + this.#port = options.port; + this.#logger = options.logger; + } + + /** Starts the loopback server and publishes persistent same-user connection details. */ + async start(): Promise { + if (this.#connection) return this.#connection; + + const token = await readOrCreateToken(this.#descriptorPath); + const handler = createMcpHandler(() => this.#createProtocolServer()); + const nodeHandler = toNodeHandler(handler, { + maxRequestBodySize: 128 * 1024, + onerror: (error) => this.#logger?.error("MCP request failed", error), + }); + const httpServer = createMcpFastifyApp(); + httpServer.all(MCP_PATH, { bodyLimit: 128 * 1024 }, async (request, reply) => { + if (!hasBearerToken(request.headers.authorization, token)) { + return reply.code(401).send({ error: "invalid Shift MCP connection secret" }); + } + + reply.hijack(); + await nodeHandler(request.raw, reply.raw, request.body); + }); + + try { + await httpServer.listen({ host: LOOPBACK_HOST, port: this.#port }); + const address = httpServer.server.address(); + if (!address || typeof address === "string") + throw new Error("Shift MCP server has no TCP port"); + const connection = { + url: `http://${LOOPBACK_HOST}:${address.port}${MCP_PATH}`, + token, + descriptorPath: this.#descriptorPath, + } satisfies ShiftMcpConnection; + + await publishConnection(connection); + this.#httpServer = httpServer; + this.#closeHandler = handler.close; + this.#connection = connection; + this.#logger?.info("MCP server started", { url: connection.url }); + return connection; + } catch (error) { + await httpServer.close(); + await handler.close(); + throw error; + } + } + + /** Stops accepting requests while retaining the user's connection credential. */ + async stop(): Promise { + const httpServer = this.#httpServer; + const closeHandler = this.#closeHandler; + const connection = this.#connection; + this.#httpServer = null; + this.#closeHandler = null; + this.#connection = null; + + if (httpServer) await httpServer.close(); + if (closeHandler) await closeHandler(); + if (connection) this.#logger?.info("MCP server stopped"); + } + + #createProtocolServer(): McpServer { + const server = new McpServer({ name: "shift", version: "0.1.0" }); + server.registerTool( + "shift.describe", + { + description: "Describe the typed live Shift API available to shift.execute.", + inputSchema: z.object({}), + }, + async () => ({ + content: [{ type: "text", text: SHIFT_CODE_TYPES }], + }), + ); + server.registerTool( + "shift.execute", + { + description: `Execute an async JavaScript function against the live Shift API. Return a JSON value.\n\n${SHIFT_CODE_TYPES}`, + inputSchema: z.object({ + code: z + .string() + .min(1) + .max(16_384) + .describe( + "An async zero-argument function, for example async () => await shift.sessions.list()", + ), + }), + }, + async ({ code }) => { + const result = await this.#execute(code); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + }, + ); + return server; + } +} + +function hasBearerToken(header: string | undefined, token: string): boolean { + if (!header?.startsWith("Bearer ")) return false; + + const supplied = Buffer.from(header.slice("Bearer ".length)); + const expected = Buffer.from(token); + if (supplied.length !== expected.length) return false; + + return timingSafeEqual(supplied, expected); +} + +async function readOrCreateToken(descriptorPath: string): Promise { + try { + const file = await lstat(descriptorPath); + if (!file.isFile() || (process.platform !== "win32" && (file.mode & 0o077) !== 0)) { + throw new Error(`insecure Shift MCP descriptor: ${descriptorPath}`); + } + + const connection = JSON.parse(await readFile(descriptorPath, "utf8")) as ShiftMcpConnection; + if (typeof connection?.token !== "string" || !/^[\w-]{43}$/.test(connection.token)) { + throw new Error(`invalid Shift MCP descriptor: ${descriptorPath}`); + } + return connection.token; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + return randomBytes(32).toString("base64url"); + } +} + +async function publishConnection(connection: ShiftMcpConnection): Promise { + const directory = path.dirname(connection.descriptorPath); + const temporaryPath = `${connection.descriptorPath}.${process.pid}.tmp`; + await mkdir(directory, { recursive: true, mode: 0o700 }); + await writeFile(temporaryPath, `${JSON.stringify(connection)}\n`, { mode: 0o600, flag: "wx" }); + await rename(temporaryPath, connection.descriptorPath); +} diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts new file mode 100644 index 000000000..4c69f2f4e --- /dev/null +++ b/packages/mcp/src/types.ts @@ -0,0 +1,6 @@ +/** Connection details written for same-user local clients while Shift is running. */ +export interface ShiftMcpConnection { + url: string; + token: string; + descriptorPath: string; +} diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 000000000..f53b6d51f --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig/library.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/mcp/vitest.config.ts b/packages/mcp/vitest.config.ts new file mode 100644 index 000000000..ae847ff6d --- /dev/null +++ b/packages/mcp/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/runtime/docs/DOCS.md b/packages/runtime/docs/DOCS.md new file mode 100644 index 000000000..00b3cf670 --- /dev/null +++ b/packages/runtime/docs/DOCS.md @@ -0,0 +1,83 @@ +# @shift/runtime + + + +Host-neutral capability contracts shared by Shift protocol adapters and future plugin hosts. + +## Architecture Invariants + +- **Architecture Invariant:** `@shift/runtime` defines capability contracts only. It owns no Electron, MCP, renderer, utility-process, persistence, or sandbox implementation. +- **Architecture Invariant:** Existing domain identities and session modes come from `@shift/types`. Runtime contracts never redeclare `AxisId`, `GlyphId`, `NodeId`, `SourceId`, or `FontSessionMode` as parallel primitives. +- **Architecture Invariant:** `ShiftCapabilities` is the reusable platform surface. MCP, plugins, browser hosts, and tests adapt that contract without exposing internal `Editor`, `FontStore`, NAPI, IPC, or database objects. +- **Architecture Invariant:** `generated/code-api.d.ts` is generated from `src/capabilities.ts` with canonical `@shift/types` dependencies bundled. It is never edited manually. + +## Codemap + +```text +packages/runtime/ + src/ + capabilities.ts -- canonical capability and observation contracts + inputs.ts -- Zod validation of untrusted code-mode arguments + index.ts -- public capability types and input schemas + scripts/ + generate-code-api.mjs -- deterministic self-contained declaration generator + generated/ + code-api.d.ts -- bundled declaration consumed by code-mode adapters +``` + +## Key Types + +- `ShiftCapabilities` -- nested live operations exposed by a host. +- `ShiftSession` -- explicitly addressable live window/session identity and mode. +- `EditorInspection` -- point-in-time editor observation paired with its explicit target. +- `FontOverview`, `GlyphPage`, `GlyphSummary`, and `LayerView` -- read-only font, paged directory, and source-specific authored geometry views. +- `shiftInputSchemas` -- reusable runtime validation of untrusted capability inputs before IPC. +- `EditorView` -- renderer-owned portion of an editor observation. +- `EditorGlyph` -- active glyph occurrence using canonical domain identifiers. +- `ShiftSessionMode` -- alias of the canonical `FontSessionMode`, including memory hosts. + +## How it works + +A host implements `ShiftCapabilities` by routing each operation to the subsystem that owns the truth. The desktop host lists window/session identity in Electron main and requests editor and font observations from the targeted renderer. Source-scoped glyph listings and layer reads use workspace glyph snapshots, not renderer editor-model loading. `FontOverview.sources` lists global masters; `GlyphSummary.sourceIds` also advertises glyph-specific support layers. `glyphs.get` accepts exactly one of a stable `glyphId` or an exact glyph `name` and uses the font directory's name index. Every advertised layer is readable by `sourceId`, even if it is not a global master. Previews offer font and glyph directory facts, but no authored layer views. A missing layer in a known source returns `null`; unknown targets and preview-authored requests fail explicitly. Directory pages carry an opaque cursor, not a frozen revision; consumers should restart a scan if the font changes between pages. Other hosts may provide memory sessions while preserving the same capability shape. + +The code-API generator bundles only declarations reachable from `capabilities.ts`, including the canonical branded identifiers from `@shift/types`, then adds the code-mode global `shift`. `@shift/mcp` imports that generated file as text for `shift.describe`; it does not maintain another declaration. + +## Workflow recipes + +### Add a capability + +1. Reuse domain types from `@shift/types` or the package that canonically owns them. +2. Add the host-neutral operation and result to `src/capabilities.ts`. +3. Run `pnpm --filter @shift/runtime code-api:generate`. +4. Adapt the operation in each host and protocol boundary. +5. Add contract and host integration coverage. + +### Verify generated declarations + +```sh +pnpm --filter @shift/runtime code-api:check +``` + +`@shift/runtime` typechecking runs this check automatically. + +## Gotchas + +- The generated code API declares a global `shift`; the canonical runtime package does not pollute application globals. +- Branded identifiers serialize as strings but remain distinct types for TypeScript hosts. +- A capability interface describes authority, not ownership. Implementations must still route font truth, editor state, and persistence to their canonical subsystems. + +## Verification + +```sh +pnpm --filter @shift/runtime code-api:check +pnpm --filter @shift/runtime typecheck +pnpm --filter @shift/runtime lint:check +pnpm typecheck +``` + +## Related + +- [`packages/types/docs/DOCS.md`](../../types/docs/DOCS.md) -- canonical domain identities and snapshots. +- [`packages/sandbox/docs/DOCS.md`](../../sandbox/docs/DOCS.md) -- host-neutral code execution over these contracts. +- [`packages/mcp/docs/DOCS.md`](../../mcp/docs/DOCS.md) -- local MCP transport adapter. +- [`apps/desktop/src/main/docs/DOCS.md`](../../../apps/desktop/src/main/docs/DOCS.md) -- desktop capability routing and sandbox process ownership. diff --git a/packages/runtime/generated/code-api.d.ts b/packages/runtime/generated/code-api.d.ts new file mode 100644 index 000000000..bb32dfcd3 --- /dev/null +++ b/packages/runtime/generated/code-api.d.ts @@ -0,0 +1,420 @@ +//#region ../geo/src/types.d.ts +type Point2D = { + x: number; + y: number; +}; +type Rect2D = { + x: number; + y: number; + width: number; + height: number; + left: number; + top: number; + right: number; + bottom: number; +}; +//#endregion +//#region ../geo/src/Bounds.d.ts +/** + * Axis-aligned bounding box defined by its minimum and maximum corners. + * + * In screen/canvas space, `min` is the top-left corner and `max` is the + * bottom-right. In UPM space (y-up), `min.y` is the bottom and `max.y` + * is the top. + */ +interface Bounds { + readonly min: { + readonly x: number; + readonly y: number; + }; + readonly max: { + readonly x: number; + readonly y: number; + }; +} +declare const Bounds: { + /** Create a bounds from explicit min and max corners. */ + readonly create: (min: Point2D, max: Point2D) => Bounds; + /** Create a zero-area bounds located at a single point. */ + readonly fromPoint: (p: Point2D) => Bounds; + /** + * Compute the tightest bounds enclosing all given points. + * @returns `null` when the array is empty. + */ + readonly fromPoints: (points: readonly Point2D[]) => Bounds | null; + /** Create bounds from an origin and dimensions (x, y, width, height). */ + readonly fromXYWH: (x: number, y: number, w: number, h: number) => Bounds; + /** Return the smallest bounds that contains both `a` and `b`. */ + readonly union: (a: Bounds, b: Bounds) => Bounds; + /** + * Merge an array of nullable bounds into one. Skips `null` entries. + * @returns `null` when every entry is `null`. + */ + readonly unionAll: (bounds: readonly (Bounds | null)[]) => Bounds | null; + /** Expand bounds just enough to include the given point. */ + readonly includePoint: (b: Bounds, p: Point2D) => Bounds; + readonly width: (b: Bounds) => number; + readonly height: (b: Bounds) => number; + readonly center: (b: Bounds) => Point2D; + /** Test whether a point lies inside or on the edge of the bounds. */ + readonly containsPoint: (b: Bounds, p: Point2D) => boolean; + /** Test whether two bounds overlap (inclusive of touching edges). */ + readonly overlaps: (a: Bounds, b: Bounds) => boolean; + /** Grow the bounds outward by `padding` on every side. Use a negative value to shrink. */ + readonly expand: (b: Bounds, padding: number) => Bounds; + /** Convert to a {@link Rect2D} with x/y/width/height and edge accessors. */ + readonly toRect: (b: Bounds) => Rect2D; +}; +//#endregion +//#region ../types/src/ids.d.ts +/** + * Branded ID types for type-safe identification of font entities. + * + * These types ensure compile-time safety when working with IDs across the + * TS/Rust boundary. Most ids are prefixed strings (`point_`). The + * renderer MINTS ids for entities it creates (client-minted ids: verbs return + * identity synchronously; Rust validates and honors them); all other ids come + * from Rust. + */ +declare const PointIdBrand: unique symbol; +declare const ContourIdBrand: unique symbol; +declare const AnchorIdBrand: unique symbol; +declare const AxisIdBrand: unique symbol; +declare const AxisLabelIdBrand: unique symbol; +declare const ComponentIdBrand: unique symbol; +declare const GlyphIdBrand: unique symbol; +declare const LayerIdBrand: unique symbol; +declare const MetricIdBrand: unique symbol; +declare const NamedInstanceIdBrand: unique symbol; +declare const NodeIdBrand: unique symbol; +declare const SegmentIdBrand: unique symbol; +declare const SourceIdBrand: unique symbol; +/** + * A point identifier from Rust. + * Branded string type - can't be confused with ContourId or plain strings. + */ +type PointId = string & { + readonly [PointIdBrand]: typeof PointIdBrand; +}; +/** + * A contour identifier from Rust. + * Branded string type - can't be confused with PointId or plain strings. + */ +type ContourId = string & { + readonly [ContourIdBrand]: typeof ContourIdBrand; +}; +/** + * An anchor identifier from Rust. + * Branded string type - can't be confused with PointId/ContourId or plain strings. + */ +type AnchorId = string & { + readonly [AnchorIdBrand]: typeof AnchorIdBrand; +}; +/** + * An axis identifier from Rust. + * Branded string type - can't be confused with OpenType axis tags. + */ +type AxisId = string & { + readonly [AxisIdBrand]: typeof AxisIdBrand; +}; +/** A stable identifier for one user-space axis label. */ +type AxisLabelId = string & { + readonly [AxisLabelIdBrand]: typeof AxisLabelIdBrand; +}; +/** + * A component identifier from Rust. + * Branded string type - can't be confused with other IDs or plain strings. + */ +type ComponentId = string & { + readonly [ComponentIdBrand]: typeof ComponentIdBrand; +}; +/** + * A glyph identifier from Rust. + * Branded string type - can't be confused with names or other IDs. + */ +type GlyphId = string & { + readonly [GlyphIdBrand]: typeof GlyphIdBrand; +}; +/** + * A layer identifier from Rust. + * Branded string type - can't be confused with other IDs or plain strings. + */ +type LayerId = string & { + readonly [LayerIdBrand]: typeof LayerIdBrand; +}; +/** Stable identity of one font-owned metric definition. */ +type MetricId = string & { + readonly [MetricIdBrand]: typeof MetricIdBrand; +}; +/** A stable identifier for one authored product preset. */ +type NamedInstanceId = string & { + readonly [NamedInstanceIdBrand]: typeof NamedInstanceIdBrand; +}; +/** + * A scene node identifier minted by the renderer. + * + * Node ids identify placed editor nodes. They are placement identity only; + * commands that mutate authored glyph geometry must resolve the glyph layer + * separately from document glyph identity and designspace location. + */ +type NodeId = string & { + readonly [NodeIdBrand]: typeof NodeIdBrand; +}; +/** Stable identity of one segment derived from its endpoint identities. */ +type SegmentId = string & { + readonly [SegmentIdBrand]: typeof SegmentIdBrand; +}; +/** + * A source identifier from Rust. + * Branded string type - can't be confused with other IDs or plain strings. + */ +type SourceId = string & { + readonly [SourceIdBrand]: typeof SourceIdBrand; +}; +/** Identifies an editor-addressable scene node or glyph object. */ +type ShiftId = NodeId | PointId | AnchorId | ContourId | SegmentId | ComponentId; +/** Identifies objects that can be selected by the editor. */ +type SelectableId = ShiftId; +//#endregion +//#region ../types/src/bridge/generated.d.ts +type GlyphName = string; +interface AnchorData { + id: AnchorId; + name?: string; +} +interface Axis { + id: AxisId; + tag: string; + name: string; + role: AxisRole; + axisType: AxisType; + minimum?: number; + default: number; + maximum?: number; + values?: Array; + labels: Array; + hidden: boolean; +} +interface AxisLabel { + id: AxisLabelId; + name: string; + value: number; + minimum?: number; + maximum?: number; + linkedValue?: number; + elidable: boolean; +} +type AxisRole = "external" | "internal"; +type AxisType = "continuous" | "discrete"; +interface ComponentData { + id: ComponentId; + baseGlyphId: GlyphId; + baseGlyphName: GlyphName; +} +interface ContourData { + id: ContourId; + points: Array; + closed: boolean; +} +interface FontMetadata { + familyName?: string; + styleName?: string; + versionMajor?: number; + versionMinor?: number; + copyright?: string; + trademark?: string; + designer?: string; + designerUrl?: string; + manufacturer?: string; + manufacturerUrl?: string; + license?: string; + licenseUrl?: string; + description?: string; + note?: string; +} +interface FontMetrics { + unitsPerEm: number; +} +interface GlyphStructure { + contours: Array; + anchors: Array; + components: Array; +} +interface Location { + values: Record; +} +/** NAPI projection of one explicit named product preset. */ +interface NamedInstance { + id: NamedInstanceId; + name: string; + location: Location; + postscriptName?: string; +} +interface PointData { + id: PointId; + pointType: PointType; + smooth: boolean; +} +type PointType = "onCurve" | "offCurve" | "qCurve"; +interface Source { + id: SourceId; + name: string; + location: Location; + filename?: string; + metricValues: Array; + italicAngle?: number; + lineGap?: number; + underlinePosition?: number; + underlineThickness?: number; +} +interface SourceMetricValue { + metricId: MetricId; + position: number; + overshoot: number; +} +//#endregion +//#region ../types/src/workspace.d.ts +/** Immutable product mode for one live font session. */ +type FontSessionMode = "preview" | "memory" | "workspace"; +//#endregion +//#region src/capabilities.d.ts +export type ShiftSessionMode = FontSessionMode; +/** One open Shift window that can be addressed through runtime capabilities. */ +export interface ShiftSession { + windowId: number; + sessionId: string; + mode: ShiftSessionMode; + focused: boolean; + editorConnected: boolean; +} +/** One external/user-space axis coordinate displayed by the editor. */ +export interface AxisCoordinate { + axisId: AxisId; + value: number; +} +/** Glyph occurrence currently placed in the editor, when a glyph route is open. */ +export interface EditorGlyph { + glyphId: GlyphId; + name: string; + nodeId: NodeId; + sourceId: SourceId; +} +/** Active editor tool and its published state discriminator. */ +export interface EditorTool { + id: string; + state: string; +} +/** Renderer-owned facts for one live Shift window. */ +export interface EditorView { + route: string; + glyph: EditorGlyph | null; + activeSourceId: SourceId | null; + editingSourceIds: SourceId[]; + externalLocation: AxisCoordinate[]; + selectionIds: SelectableId[]; + tool: EditorTool | null; + dragging: boolean; + editing: boolean; + applyStatus: "idle" | "queued" | "applying" | null; +} +/** Editor facts paired with the explicit window and font-session target. */ +export interface EditorInspection extends EditorView { + windowId: number; + sessionId: string; + mode: ShiftSessionMode; +} +/** Current font facts from one explicitly targeted live session, including Home. */ +export interface FontOverview { + mode: ShiftSessionMode; + metadata: FontMetadata; + metrics: FontMetrics; + glyphCount: number; + axes: Axis[]; + /** Global font masters; glyph-specific supplementary sources are advertised on glyphs. */ + sources: Source[]; + namedInstances: NamedInstance[]; +} +/** Directory identity; optional structure is for one requested authored source. */ +export interface GlyphSummary { + id: GlyphId; + name: string; + unicodes: number[]; + componentBaseGlyphIds: GlyphId[]; + /** Authored layer sources, including glyph-specific non-master sources. */ + sourceIds: SourceId[]; + structure?: GlyphStructure | null; +} +/** Select one glyph by stable ID or exact name, never by both. */ +export type GlyphSelector = + | { + glyphId: GlyphId; + name?: never; + } + | { + name: GlyphName; + glyphId?: never; + }; +/** One bounded page of glyphs in font directory order. */ +export interface GlyphPage { + items: GlyphSummary[]; + nextCursor: string | null; +} +/** Authored geometry of one glyph in one source, never an interpolated preview. */ +export interface LayerView { + glyphId: GlyphId; + sourceId: SourceId; + layerId: LayerId; + structure: GlyphStructure; + xAdvance: number; + bounds: Bounds | null; + anchors: { + id: AnchorId; + name: string | null; + x: number; + y: number; + }[]; + points: { + id: PointId; + x: number; + y: number; + pointType: PointType; + smooth: boolean; + }[]; +} +/** Live application capabilities shared by protocol and plugin hosts. */ +export interface ShiftCapabilities { + sessions: { + list(): Promise; + }; + editor: { + inspect(input: { windowId: number }): Promise; + }; + font: { + get(input: { windowId: number }): Promise; + }; + glyphs: { + list(input: { + windowId: number; + limit?: number; + cursor?: string; + sourceId?: SourceId; + }): Promise; + get( + input: { + windowId: number; + } & GlyphSelector, + ): Promise; + }; + layers: { + get(input: { + windowId: number; + glyphId: GlyphId; + sourceId: SourceId; + }): Promise; + }; +} +//#endregion + +declare global { + const shift: ShiftCapabilities; +} diff --git a/packages/runtime/package.json b/packages/runtime/package.json new file mode 100644 index 000000000..8f6a65fc5 --- /dev/null +++ b/packages/runtime/package.json @@ -0,0 +1,35 @@ +{ + "name": "@shift/runtime", + "version": "0.0.1", + "license": "MIT OR Apache-2.0", + "private": true, + "description": "Typed capabilities shared by Shift protocol and plugin hosts", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./code-api": "./generated/code-api.d.ts" + }, + "scripts": { + "code-api:generate": "node scripts/generate-code-api.mjs", + "code-api:check": "node scripts/generate-code-api.mjs --check", + "typecheck": "pnpm code-api:check && tsgo --noEmit", + "lint": "oxlint --fix src/", + "lint:check": "oxlint --deny-warnings src/" + }, + "dependencies": { + "@shift/geo": "workspace:*", + "@shift/types": "workspace:*", + "zod": "^4.1.12" + }, + "devDependencies": { + "oxfmt": "^0.49.0", + "oxlint": "^1.85.0", + "tsdown": "0.23.0", + "typescript": "^5.5.4" + } +} diff --git a/packages/runtime/scripts/generate-code-api.mjs b/packages/runtime/scripts/generate-code-api.mjs new file mode 100644 index 000000000..b24abba37 --- /dev/null +++ b/packages/runtime/scripts/generate-code-api.mjs @@ -0,0 +1,47 @@ +import { spawnSync } from "node:child_process"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const temporaryRoot = path.join(packageRoot, ".code-api"); +const bundledPath = path.join(temporaryRoot, "capabilities.d.ts"); +const formattedPath = path.join(temporaryRoot, "code-api.d.ts"); +const outputPath = path.join(packageRoot, "generated", "code-api.d.ts"); + +try { + run("pnpm", ["exec", "tsdown", "--config", "tsdown.config.ts"]); + const declarations = await readFile(bundledPath, "utf8"); + const withoutSourceMap = declarations.replace(/^\/\/# sourceMappingURL=.*$/m, "").trim(); + const generated = `${withoutSourceMap}\n\ndeclare global {\n const shift: ShiftCapabilities;\n}\n`; + + await writeFile(formattedPath, generated); + run("pnpm", ["exec", "oxfmt", ".code-api/code-api.d.ts"]); + const formatted = await readFile(formattedPath, "utf8"); + + if (process.argv.includes("--check")) { + const existing = await readFile(outputPath, "utf8").catch(() => ""); + if (existing !== formatted) { + console.error( + "Generated runtime code API is stale. Run pnpm --filter @shift/runtime code-api:generate.", + ); + process.exitCode = 1; + } + } else { + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, formatted); + } +} finally { + await rm(temporaryRoot, { recursive: true, force: true }); +} + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: packageRoot, + env: process.env, + stdio: "inherit", + }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed with status ${result.status}`); + } +} diff --git a/packages/runtime/src/capabilities.ts b/packages/runtime/src/capabilities.ts new file mode 100644 index 000000000..5135270f1 --- /dev/null +++ b/packages/runtime/src/capabilities.ts @@ -0,0 +1,147 @@ +import type { Bounds } from "@shift/geo"; +import type { + AnchorId, + Axis, + AxisId, + FontMetadata, + FontMetrics, + FontSessionMode, + GlyphId, + GlyphName, + GlyphStructure, + LayerId, + NamedInstance, + NodeId, + PointId, + PointType, + SelectableId, + Source, + SourceId, +} from "@shift/types"; + +export type ShiftSessionMode = FontSessionMode; + +/** One open Shift window that can be addressed through runtime capabilities. */ +export interface ShiftSession { + windowId: number; + sessionId: string; + mode: ShiftSessionMode; + focused: boolean; + editorConnected: boolean; +} + +/** One external/user-space axis coordinate displayed by the editor. */ +export interface AxisCoordinate { + axisId: AxisId; + value: number; +} + +/** Glyph occurrence currently placed in the editor, when a glyph route is open. */ +export interface EditorGlyph { + glyphId: GlyphId; + name: string; + nodeId: NodeId; + sourceId: SourceId; +} + +/** Active editor tool and its published state discriminator. */ +export interface EditorTool { + id: string; + state: string; +} + +/** Renderer-owned facts for one live Shift window. */ +export interface EditorView { + route: string; + glyph: EditorGlyph | null; + activeSourceId: SourceId | null; + editingSourceIds: SourceId[]; + externalLocation: AxisCoordinate[]; + selectionIds: SelectableId[]; + tool: EditorTool | null; + dragging: boolean; + editing: boolean; + applyStatus: "idle" | "queued" | "applying" | null; +} + +/** Editor facts paired with the explicit window and font-session target. */ +export interface EditorInspection extends EditorView { + windowId: number; + sessionId: string; + mode: ShiftSessionMode; +} + +/** Current font facts from one explicitly targeted live session, including Home. */ +export interface FontOverview { + mode: ShiftSessionMode; + metadata: FontMetadata; + metrics: FontMetrics; + glyphCount: number; + axes: Axis[]; + /** Global font masters; glyph-specific supplementary sources are advertised on glyphs. */ + sources: Source[]; + namedInstances: NamedInstance[]; +} + +/** Directory identity; optional structure is for one requested authored source. */ +export interface GlyphSummary { + id: GlyphId; + name: string; + unicodes: number[]; + componentBaseGlyphIds: GlyphId[]; + /** Authored layer sources, including glyph-specific non-master sources. */ + sourceIds: SourceId[]; + structure?: GlyphStructure | null; +} + +/** Select one glyph by stable ID or exact name, never by both. */ +export type GlyphSelector = + | { glyphId: GlyphId; name?: never } + | { name: GlyphName; glyphId?: never }; + +/** One bounded page of glyphs in font directory order. */ +export interface GlyphPage { + items: GlyphSummary[]; + nextCursor: string | null; +} + +/** Authored geometry of one glyph in one source, never an interpolated preview. */ +export interface LayerView { + glyphId: GlyphId; + sourceId: SourceId; + layerId: LayerId; + structure: GlyphStructure; + xAdvance: number; + bounds: Bounds | null; + anchors: { id: AnchorId; name: string | null; x: number; y: number }[]; + points: { id: PointId; x: number; y: number; pointType: PointType; smooth: boolean }[]; +} + +/** Live application capabilities shared by protocol and plugin hosts. */ +export interface ShiftCapabilities { + sessions: { + list(): Promise; + }; + editor: { + inspect(input: { windowId: number }): Promise; + }; + font: { + get(input: { windowId: number }): Promise; + }; + glyphs: { + list(input: { + windowId: number; + limit?: number; + cursor?: string; + sourceId?: SourceId; + }): Promise; + get(input: { windowId: number } & GlyphSelector): Promise; + }; + layers: { + get(input: { + windowId: number; + glyphId: GlyphId; + sourceId: SourceId; + }): Promise; + }; +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts new file mode 100644 index 000000000..4a3a8fb29 --- /dev/null +++ b/packages/runtime/src/index.ts @@ -0,0 +1,17 @@ +export { shiftInputSchemas } from "./inputs"; + +export type { + AxisCoordinate, + EditorGlyph, + EditorInspection, + EditorTool, + EditorView, + FontOverview, + GlyphPage, + GlyphSelector, + GlyphSummary, + LayerView, + ShiftCapabilities, + ShiftSession, + ShiftSessionMode, +} from "./capabilities"; diff --git a/packages/runtime/src/inputs.ts b/packages/runtime/src/inputs.ts new file mode 100644 index 000000000..0bbcaf46c --- /dev/null +++ b/packages/runtime/src/inputs.ts @@ -0,0 +1,22 @@ +import { asGlyphId, asSourceId } from "@shift/types"; +import * as z from "zod/v4"; + +const windowId = z.number().int().positive(); +const glyphId = z.string().min(1).transform(asGlyphId); +const sourceId = z.string().min(1).transform(asSourceId); + +/** Validates untrusted scripting inputs before they enter a host capability. */ +export const shiftInputSchemas = { + "font.get": z.strictObject({ windowId }), + "glyphs.list": z.strictObject({ + windowId, + limit: z.number().int().min(1).max(100).optional(), + cursor: z.string().min(1).max(1024).optional(), + sourceId: sourceId.optional(), + }), + "glyphs.get": z.union([ + z.strictObject({ windowId, glyphId }), + z.strictObject({ windowId, name: z.string().min(1) }), + ]), + "layers.get": z.strictObject({ windowId, glyphId, sourceId }), +} as const; diff --git a/packages/runtime/tsconfig.json b/packages/runtime/tsconfig.json new file mode 100644 index 000000000..98bcaccb3 --- /dev/null +++ b/packages/runtime/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig/library.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"] +} diff --git a/packages/runtime/tsdown.config.ts b/packages/runtime/tsdown.config.ts new file mode 100644 index 000000000..340831405 --- /dev/null +++ b/packages/runtime/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: { capabilities: "src/capabilities.ts" }, + outDir: ".code-api", + format: "esm", + platform: "neutral", + dts: { + eager: true, + generator: "tsc", + tsconfig: "tsconfig.json", + }, + sourcemap: false, + clean: true, + deps: { + alwaysBundle: ["@shift/types", "@shift/geo"], + }, +}); diff --git a/packages/sandbox/docs/DOCS.md b/packages/sandbox/docs/DOCS.md new file mode 100644 index 000000000..f74ce4c3a --- /dev/null +++ b/packages/sandbox/docs/DOCS.md @@ -0,0 +1,62 @@ +# Sandbox + + + +Bounded Shift code execution over host-supplied typed capabilities, independent of MCP transport. + +## Architecture Invariants + +- **Architecture Invariant:** `@shift/sandbox` executes against `ShiftCapabilities` from `@shift/runtime`. It owns no MCP endpoint, Electron app state, renderer, document, or font persistence. Hosts decide how to supply capabilities and when execution is allowed. +- **Architecture Invariant:** Each `executeShiftCode()` call gets a fresh QuickJS realm with only the explicitly installed `shift` read API. Code has no Node.js, filesystem, environment, Electron, or network globals; output must be JSON-compatible. +- **Architecture Invariant:** Source, memory, stack, result, and execution time are bounded. The Electron host additionally supervises execution in a utility process and stops that process on its hard deadline. Neither this one-shot API nor the current read-only capability contract promises persistent plugin state or mutation authority. + +## Codemap + +```text +src/ + execute.ts -- QuickJS realm, capability installation, quotas, and JSON result + execute.test.ts -- sandbox and capability-boundary behavior + index.ts -- host-neutral execution entrypoint +``` + +## Key Types + +- `ShiftCapabilities` -- host-supplied, explicitly targeted read operations; defined by `@shift/runtime`. +- `executeShiftCode()` -- one-shot execution of an async function against those capabilities. +- `SandboxRuntimeProcess` -- Electron host supervisor, not part of this package. + +## How it works + +### Desktop ownership + +`App` owns `SandboxRuntimeProcess` independently of MCP. The process starts when the app is ready; a timeout or crash stops it, and its next `execute()` starts a replacement. App quit stops it. An MCP listener failure leaves the app-owned sandbox available for another host. The utility process routes capability requests through typed main-process calls; main resolves the explicitly targeted renderer and workspace data. + +This is the first **one-shot** execution mode, not a full interactive plugin lifecycle. A future scripting host can use the same executor and capability contract. Long-lived tool contributions would need explicit registration, cancellation, preview, and disposal semantics rather than inheriting the lifetime of an MCP request. + +## Workflow recipes + +### Add a capability + +1. Add its contract and input schema in `@shift/runtime`. +2. Install a validated callback in `executeShiftCode()` without exposing host objects or globals. +3. Wire the desktop utility/main host and test the capability through the executor and a real desktop boundary. + +## Gotchas + +- A fresh realm means scripts cannot keep globals, subscriptions, or tool instances across calls. +- The runtime package owns capability contracts, not the QuickJS engine. Electron owns the supervised process, not this package. +- Authored layers and preview geometry have different semantics; the sandbox does not decide which is authoritative. + +## Verification + +```sh +pnpm --filter @shift/sandbox test +pnpm --filter @shift/sandbox typecheck +pnpm --filter @shift/sandbox lint:check +``` + +## Related + +- [`packages/runtime/docs/DOCS.md`](../../runtime/docs/DOCS.md) -- canonical capability contracts. +- [`packages/mcp/docs/DOCS.md`](../../mcp/docs/DOCS.md) -- first transport adapter using the sandbox. +- [`apps/desktop/src/main/docs/DOCS.md`](../../../apps/desktop/src/main/docs/DOCS.md) -- desktop sandbox process ownership. diff --git a/packages/sandbox/package.json b/packages/sandbox/package.json new file mode 100644 index 000000000..54ffbc583 --- /dev/null +++ b/packages/sandbox/package.json @@ -0,0 +1,35 @@ +{ + "name": "@shift/sandbox", + "version": "0.0.1", + "license": "MIT OR Apache-2.0", + "private": true, + "description": "Bounded Shift code execution over typed capabilities", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsgo --noEmit", + "lint": "oxlint --fix src/", + "lint:check": "oxlint --deny-warnings src/", + "test": "vitest run --config vitest.config.ts", + "test:watch": "vitest --config vitest.config.ts" + }, + "dependencies": { + "@jitl/quickjs-singlefile-cjs-release-sync": "0.32.0", + "@shift/runtime": "workspace:*", + "quickjs-emscripten-core": "0.32.0" + }, + "devDependencies": { + "@shift/types": "workspace:*", + "@types/node": "^25.3.0", + "oxlint": "^1.85.0", + "typescript": "^5.5.4", + "vitest": "^4.1.10" + } +} diff --git a/packages/sandbox/src/execute.test.ts b/packages/sandbox/src/execute.test.ts new file mode 100644 index 000000000..505a5cc41 --- /dev/null +++ b/packages/sandbox/src/execute.test.ts @@ -0,0 +1,154 @@ +import { asGlyphId, asNodeId, asPointId, asSourceId } from "@shift/types"; +import type { ShiftCapabilities } from "@shift/runtime"; +import { describe, expect, it } from "vitest"; +import { executeShiftCode } from "./execute"; + +const capabilities: ShiftCapabilities = { + sessions: { + async list() { + return [ + { + windowId: 7, + sessionId: "session-a", + mode: "workspace", + focused: true, + editorConnected: true, + }, + ]; + }, + }, + editor: { + async inspect({ windowId }) { + return { + windowId, + sessionId: "session-a", + mode: "workspace", + route: "/editor/glyph-a", + glyph: { + glyphId: asGlyphId("glyph-a"), + name: "A", + nodeId: asNodeId("node-a"), + sourceId: asSourceId("source-a"), + }, + activeSourceId: asSourceId("source-a"), + editingSourceIds: [asSourceId("source-a")], + externalLocation: [], + selectionIds: [asPointId("point-a")], + tool: { id: "select", state: "idle" }, + dragging: false, + editing: true, + applyStatus: "idle", + }; + }, + }, + font: { + async get() { + return { + mode: "workspace", + metadata: { familyName: "Example" }, + metrics: { unitsPerEm: 1000 }, + glyphCount: 1, + axes: [], + sources: [], + namedInstances: [], + }; + }, + }, + glyphs: { + async list() { + return { + items: [ + { + id: asGlyphId("glyph-a"), + name: "A", + unicodes: [65], + componentBaseGlyphIds: [], + sourceIds: [], + }, + ], + nextCursor: null, + }; + }, + async get({ glyphId, name }) { + if (name !== undefined && name !== "A") throw new Error(`Glyph ${name} is not in this font`); + return { + id: glyphId ?? asGlyphId("glyph-a"), + name: "A", + unicodes: [65], + componentBaseGlyphIds: [], + sourceIds: [], + }; + }, + }, + layers: { + async get() { + return null; + }, + }, +}; + +describe("Shift sandbox executes bounded code over live capabilities", () => { + it("composes session discovery and editor inspection", async () => { + const result = await executeShiftCode( + capabilities, + "async () => { const [session] = await shift.sessions.list(); return shift.editor.inspect({ windowId: session.windowId }); }", + ); + + expect(result).toMatchObject({ windowId: 7, glyph: { name: "A" }, selectionIds: ["point-a"] }); + }); + + it("composes font and glyph reads through the typed capabilities", async () => { + const result = await executeShiftCode( + capabilities, + "async () => { const font = await shift.font.get({ windowId: 7 }); const page = await shift.glyphs.list({ windowId: 7, limit: 1 }); const glyph = await shift.glyphs.get({ windowId: 7, glyphId: page.items[0].id }); const layer = await shift.layers.get({ windowId: 7, glyphId: glyph.id, sourceId: 'source-a' }); return { family: font.metadata.familyName, glyph: glyph.name, nextCursor: page.nextCursor, layer }; }", + ); + + expect(result).toEqual({ family: "Example", glyph: "A", nextCursor: null, layer: null }); + }); + + it("gets a glyph by exact name and rejects ambiguous selectors", async () => { + const result = await executeShiftCode( + capabilities, + "async () => shift.glyphs.get({ windowId: 7, name: 'A' })", + ); + expect(result).toMatchObject({ id: "glyph-a", name: "A" }); + + await expect( + executeShiftCode( + capabilities, + "async () => shift.glyphs.get({ windowId: 7, name: 'A', glyphId: 'glyph-a' })", + ), + ).rejects.toThrow(); + }); + + it("rejects an invalid glyph page before it reaches the host", async () => { + await expect( + executeShiftCode(capabilities, "async () => shift.glyphs.list({ windowId: 7, limit: 0 })"), + ).rejects.toThrow(); + }); + + it("cannot access Node process globals", async () => { + await expect(executeShiftCode(capabilities, "async () => typeof process")).resolves.toBe( + "undefined", + ); + }); + + it("starts each execution in a fresh realm", async () => { + await executeShiftCode(capabilities, "async () => { globalThis.ephemeral = 1; return null; }"); + await expect( + executeShiftCode(capabilities, "async () => typeof globalThis.ephemeral"), + ).resolves.toBe("undefined"); + }); + + it("requires a JSON-compatible result", async () => { + await expect(executeShiftCode(capabilities, "async () => undefined")).rejects.toThrow( + "Shift script must return a JSON value", + ); + }); + + it("stops code that never settles", async () => { + await expect( + executeShiftCode(capabilities, "async () => await new Promise(() => {})"), + ).rejects.toThrow("Shift script timed out"); + }); +}); diff --git a/packages/sandbox/src/execute.ts b/packages/sandbox/src/execute.ts new file mode 100644 index 000000000..027c00ad7 --- /dev/null +++ b/packages/sandbox/src/execute.ts @@ -0,0 +1,177 @@ +import RELEASE_SYNC from "@jitl/quickjs-singlefile-cjs-release-sync"; +import { + newQuickJSWASMModuleFromVariant, + shouldInterruptAfterDeadline, + type QuickJSContext, + type QuickJSWASMModule, +} from "quickjs-emscripten-core"; +import { shiftInputSchemas, type ShiftCapabilities } from "@shift/runtime"; + +const EXECUTION_TIMEOUT_MS = 3_000; +const MEMORY_LIMIT_BYTES = 16 * 1024 * 1024; +const MAX_CODE_BYTES = 16 * 1024; +const MAX_RESULT_BYTES = 64 * 1024; +let quickJsPromise: Promise | null = null; + +/** + * Executes one-shot JavaScript against only the supplied Shift capabilities. + * + * @param capabilities - Host-owned operations exposed as `shift` in a fresh realm. + * @param code - Async zero-argument function source with a bounded size. + * @returns the JSON-compatible result; no realm state survives the call. + * @throws {Error} when execution fails, times out, or exceeds its resource limits. + */ +export async function executeShiftCode( + capabilities: ShiftCapabilities, + code: string, +): Promise { + if (Buffer.byteLength(code, "utf8") > MAX_CODE_BYTES) { + throw new Error(`Shift script exceeds ${MAX_CODE_BYTES} bytes`); + } + + const QuickJS = await loadQuickJS(); + const deadline = Date.now() + EXECUTION_TIMEOUT_MS; + const runtime = QuickJS.newRuntime(); + runtime.setMemoryLimit(MEMORY_LIMIT_BYTES); + runtime.setMaxStackSize(512 * 1024); + runtime.setInterruptHandler(shouldInterruptAfterDeadline(deadline)); + + const vm = runtime.newContext(); + installAsyncJsonFunction(vm, "__shiftListSessions", deadline, () => capabilities.sessions.list()); + installAsyncJsonFunction(vm, "__shiftInspectEditor", deadline, (windowId) => { + if (typeof windowId !== "number" || !Number.isInteger(windowId)) { + throw new Error("editor.inspect requires an integer windowId"); + } + + return capabilities.editor.inspect({ windowId }); + }); + installAsyncJsonFunction(vm, "__shiftGetFont", deadline, (input) => + capabilities.font.get(shiftInputSchemas["font.get"].parse(input)), + ); + installAsyncJsonFunction(vm, "__shiftListGlyphs", deadline, (input) => + capabilities.glyphs.list(shiftInputSchemas["glyphs.list"].parse(input)), + ); + installAsyncJsonFunction(vm, "__shiftGetGlyph", deadline, (input) => + capabilities.glyphs.get(shiftInputSchemas["glyphs.get"].parse(input)), + ); + installAsyncJsonFunction(vm, "__shiftGetLayer", deadline, (input) => + capabilities.layers.get(shiftInputSchemas["layers.get"].parse(input)), + ); + + const bootstrap = ` + "use strict"; + const shift = Object.freeze({ + sessions: Object.freeze({ + list: async () => JSON.parse(await __shiftListSessions()), + }), + editor: Object.freeze({ + inspect: async ({ windowId }) => JSON.parse(await __shiftInspectEditor(windowId)), + }), + font: Object.freeze({ + get: async (input) => JSON.parse(await __shiftGetFont(input)), + }), + glyphs: Object.freeze({ + list: async (input) => JSON.parse(await __shiftListGlyphs(input)), + get: async (input) => JSON.parse(await __shiftGetGlyph(input)), + }), + layers: Object.freeze({ + get: async (input) => JSON.parse(await __shiftGetLayer(input)), + }), + }); + (async () => { + const entry = (${code}); + if (typeof entry !== "function") throw new Error("Shift script must evaluate to a function"); + const result = await entry(); + const json = JSON.stringify(result); + if (json === undefined) throw new Error("Shift script must return a JSON value"); + return json; + })(); + `; + + try { + const evaluation = vm.evalCode(bootstrap, "shift-script.js"); + const promiseHandle = vm.unwrapResult(evaluation); + const settledPromise = vm.resolvePromise(promiseHandle); + vm.runtime.executePendingJobs(); + + let settled: Awaited; + try { + settled = await withDeadline(settledPromise, deadline); + } finally { + promiseHandle.dispose(); + } + + const resultHandle = vm.unwrapResult(settled); + const json = vm.getString(resultHandle); + resultHandle.dispose(); + + if (Buffer.byteLength(json, "utf8") > MAX_RESULT_BYTES) { + throw new Error(`Shift script result exceeds ${MAX_RESULT_BYTES} bytes`); + } + + return JSON.parse(json) as unknown; + } finally { + vm.dispose(); + runtime.dispose(); + } +} + +function loadQuickJS(): Promise { + quickJsPromise ??= newQuickJSWASMModuleFromVariant(RELEASE_SYNC); + return quickJsPromise; +} + +function installAsyncJsonFunction( + vm: QuickJSContext, + name: string, + deadline: number, + call: (...args: unknown[]) => unknown | Promise, +): void { + const functionHandle = vm.newFunction(name, (...argumentHandles) => { + const args = argumentHandles.map((handle) => vm.dump(handle)); + const promise = vm.newPromise(); + + try { + const result = call(...args); + withDeadline(Promise.resolve(result), deadline).then( + (value) => { + const resultHandle = vm.newString(JSON.stringify(value)); + promise.resolve(resultHandle); + resultHandle.dispose(); + }, + (error) => { + const errorHandle = vm.newError(errorMessage(error)); + promise.reject(errorHandle); + errorHandle.dispose(); + }, + ); + } catch (error) { + const errorHandle = vm.newError(errorMessage(error)); + promise.reject(errorHandle); + errorHandle.dispose(); + } + + void promise.settled.then(() => vm.runtime.executePendingJobs()); + return promise.handle; + }); + + functionHandle.consume((handle) => vm.setProp(vm.global, name, handle)); +} + +async function withDeadline(promise: Promise, deadline: number): Promise { + const timeoutMs = Math.max(0, deadline - Date.now()); + let timeout: NodeJS.Timeout | undefined; + const expired = new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error("Shift script timed out")), timeoutMs); + }); + + try { + return await Promise.race([promise, expired]); + } finally { + clearTimeout(timeout); + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts new file mode 100644 index 000000000..645fff6e6 --- /dev/null +++ b/packages/sandbox/src/index.ts @@ -0,0 +1 @@ +export { executeShiftCode } from "./execute"; diff --git a/packages/sandbox/tsconfig.json b/packages/sandbox/tsconfig.json new file mode 100644 index 000000000..f53b6d51f --- /dev/null +++ b/packages/sandbox/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig/library.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/sandbox/vitest.config.ts b/packages/sandbox/vitest.config.ts new file mode 100644 index 000000000..ae847ff6d --- /dev/null +++ b/packages/sandbox/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 9f634c8a0..2ea21fdbf 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -70,6 +70,10 @@ export function FontEditor({ source }: { source: MemoryFontSource }) { The source owns its loaded font data. If it has a `dispose` operation—for example, a future worker-backed WASM source—the host disposes it separately from the editor session. +## Host capabilities + +`ShiftCapabilities` and its observation types describe the host-neutral live application API shared by protocol adapters and future plugin hosts. Browser integrations may implement the same contract with `"memory"` sessions; Electron and MCP are not part of the SDK contract. + ## Custom chrome Use individual primitives when the standard desktop-like shell is not appropriate: diff --git a/packages/sdk/api/index.api.md b/packages/sdk/api/index.api.md index 1ee73fb0a..e2215f4c5 100644 --- a/packages/sdk/api/index.api.md +++ b/packages/sdk/api/index.api.md @@ -4,6 +4,16 @@ ```ts +// @public +export interface AxisCoordinate { + // Warning: (ae-forgotten-export) The symbol "AxisId" needs to be exported by the entry point index.d.ts + // + // (undocumented) + axisId: AxisId; + // (undocumented) + value: number; +} + // Warning: (ae-forgotten-export) The symbol "ComputedOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ComputedSignal" needs to be exported by the entry point index.d.ts // @@ -45,7 +55,6 @@ export class Editor { get camera(): Camera; canDecomposeSelection(): boolean; collapseEditingSources(): boolean; - // Warning: (ae-forgotten-export) The symbol "SelectableId" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ComponentTransformSelection" needs to be exported by the entry point index.d.ts componentTransformSelection(ids: readonly SelectableId[]): ComponentTransformSelection | null; // Warning: (ae-forgotten-export) The symbol "ShiftContent" needs to be exported by the entry point index.d.ts @@ -181,7 +190,6 @@ export class Editor { // (undocumented) nodeDefinition(kind: NodeKind): NodeDefinition; notifyPreviewMutationAttempt(): void; - // Warning: (ae-forgotten-export) The symbol "ShiftId" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "ShiftObject" needs to be exported by the entry point index.d.ts object(id: ShiftId): ShiftObject | null; objects(ids: readonly ShiftId[]): readonly ShiftObject[]; @@ -325,6 +333,62 @@ export class Editor { zoomToSelection(): void; } +// @public +export interface EditorGlyph { + // (undocumented) + glyphId: GlyphId; + // (undocumented) + name: string; + // Warning: (ae-forgotten-export) The symbol "NodeId" needs to be exported by the entry point index.d.ts + // + // (undocumented) + nodeId: NodeId; + // (undocumented) + sourceId: SourceId; +} + +// @public +export interface EditorInspection extends EditorView { + // (undocumented) + mode: ShiftSessionMode; + // (undocumented) + sessionId: string; + // (undocumented) + windowId: number; +} + +// @public +export interface EditorTool { + // (undocumented) + id: string; + // (undocumented) + state: string; +} + +// @public +export interface EditorView { + // (undocumented) + activeSourceId: SourceId | null; + // (undocumented) + applyStatus: "idle" | "queued" | "applying" | null; + // (undocumented) + dragging: boolean; + // (undocumented) + editing: boolean; + // (undocumented) + editingSourceIds: SourceId[]; + // (undocumented) + externalLocation: AxisCoordinate[]; + // (undocumented) + glyph: EditorGlyph | null; + // (undocumented) + route: string; + // (undocumented) + selectionIds: SelectableId[]; + // (undocumented) + tool: EditorTool | null; +} + // Warning: (ae-forgotten-export) The symbol "EffectOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "Effect" needs to be exported by the entry point index.d.ts // @@ -351,7 +415,6 @@ export class Font { contourIdForPoint(pointId: PointId): ContourId | null; contourIdForSegment(segmentId: SegmentId): ContourId | null; // Warning: (ae-forgotten-export) The symbol "AxisDefinition" needs to be exported by the entry point index.d.ts - // Warning: (ae-forgotten-export) The symbol "AxisId" needs to be exported by the entry point index.d.ts createAxis(axis: AxisDefinition): AxisId; createGlyph(name: GlyphName): GlyphRecord; createGlyphForUnicode(unicode: Unicode): GlyphRecord; @@ -402,7 +465,6 @@ export class Font { layerIdForAnchor(anchorId: AnchorId): LayerId | null; layerIdForContour(contourId: ContourId): LayerId | null; layerIdForPoint(pointId: PointId): LayerId | null; - // Warning: (ae-forgotten-export) The symbol "SegmentId" needs to be exported by the entry point index.d.ts layerIdForSegment(segmentId: SegmentId): LayerId | null; // Warning: (ae-forgotten-export) The symbol "GlyphLayerState" needs to be exported by the entry point index.d.ts layerState(layerId: LayerId): GlyphLayerState | null; @@ -438,6 +500,10 @@ export class Font { nextAvailableGlyphName(name: GlyphName): GlyphName; pointIdsForSegment(segmentId: SegmentId): readonly PointId[] | null; primaryUnicodeForName(name: GlyphName): Unicode | null; + readAuthoredLayers(input: { + glyphIds: readonly GlyphId[]; + sourceId: SourceId; + }): Promise; // (undocumented) recordForId(glyphId: GlyphId): GlyphRecord | null; // (undocumented) @@ -464,6 +530,23 @@ export class Font { updateSource(source: Source): Promise; } +// @public +export interface FontOverview { + // (undocumented) + axes: Axis[]; + // (undocumented) + glyphCount: number; + // (undocumented) + metadata: FontMetadata; + // (undocumented) + metrics: FontMetrics; + // (undocumented) + mode: ShiftSessionMode; + // (undocumented) + namedInstances: NamedInstance[]; + sources: Source[]; +} + // @public (undocumented) export interface FontSnapshot { // (undocumented) @@ -568,6 +651,24 @@ export type GlyphId = string & { readonly [GlyphIdBrand]: typeof GlyphIdBrand; }; +// @public (undocumented) +export interface GlyphLayerSnapshot { + // (undocumented) + glyphId: GlyphId; + // (undocumented) + sourceId: SourceId; + // (undocumented) + state: GlyphState; +} + +// @public +export interface GlyphPage { + // (undocumented) + items: GlyphSummary[]; + // (undocumented) + nextCursor: string | null; +} + // @public export interface GlyphPreview { // (undocumented) @@ -602,12 +703,19 @@ export interface GlyphRecord { unicodes: Array; } +// @public +export type GlyphSelector = { + glyphId: GlyphId; + name?: never; +} | { + name: GlyphName; + glyphId?: never; +}; + // @public (undocumented) export interface GlyphSnapshot { // (undocumented) glyphId: GlyphId; - // Warning: (ae-forgotten-export) The symbol "GlyphLayerSnapshot" needs to be exported by the entry point index.d.ts - // // (undocumented) layers: Array; // Warning: (ae-forgotten-export) The symbol "GlyphProjection" needs to be exported by the entry point index.d.ts @@ -616,6 +724,54 @@ export interface GlyphSnapshot { projection?: GlyphProjection; } +// @public +export interface GlyphSummary { + // (undocumented) + componentBaseGlyphIds: GlyphId[]; + // (undocumented) + id: GlyphId; + // (undocumented) + name: string; + sourceIds: SourceId[]; + // Warning: (ae-forgotten-export) The symbol "GlyphStructure" needs to be exported by the entry point index.d.ts + // + // (undocumented) + structure?: GlyphStructure | null; + // (undocumented) + unicodes: number[]; +} + +// @public +export interface LayerView { + // (undocumented) + anchors: { + id: AnchorId; + name: string | null; + x: number; + y: number; + }[]; + // (undocumented) + bounds: Bounds | null; + // (undocumented) + glyphId: GlyphId; + // (undocumented) + layerId: LayerId; + // (undocumented) + points: { + id: PointId; + x: number; + y: number; + pointType: PointType; + smooth: boolean; + }[]; + // (undocumented) + sourceId: SourceId; + // (undocumented) + structure: GlyphStructure; + // (undocumented) + xAdvance: number; +} + // Warning: (ae-forgotten-export) The symbol "PointIn" needs to be exported by the entry point index.d.ts // // @public @@ -672,6 +828,74 @@ export type ScreenPoint = PointIn<"screen">; // @public export function screenPoint(x: number, y: number): ScreenPoint; +// @public +export type SegmentId = string & { + readonly [SegmentIdBrand]: typeof SegmentIdBrand; +}; + +// @public +export type SelectableId = ShiftId; + +// @public +export interface ShiftCapabilities { + // (undocumented) + editor: { + inspect(input: { + windowId: number; + }): Promise; + }; + // (undocumented) + font: { + get(input: { + windowId: number; + }): Promise; + }; + // (undocumented) + glyphs: { + list(input: { + windowId: number; + limit?: number; + cursor?: string; + sourceId?: SourceId; + }): Promise; + get(input: { + windowId: number; + } & GlyphSelector): Promise; + }; + // (undocumented) + layers: { + get(input: { + windowId: number; + glyphId: GlyphId; + sourceId: SourceId; + }): Promise; + }; + // (undocumented) + sessions: { + list(): Promise; + }; +} + +// @public +export type ShiftId = NodeId | PointId | AnchorId | ContourId | SegmentId | ComponentId; + +// @public +export interface ShiftSession { + // (undocumented) + editorConnected: boolean; + // (undocumented) + focused: boolean; + // (undocumented) + mode: ShiftSessionMode; + // (undocumented) + sessionId: string; + // (undocumented) + windowId: number; +} + +// @public (undocumented) +export type ShiftSessionMode = FontSessionMode; + // @public export interface Signal { // Warning: (ae-forgotten-export) The symbol "SignalDebugSnapshot" needs to be exported by the entry point index.d.ts @@ -696,7 +920,8 @@ export function useSignalState(signal: Signal, options?: UseSignalOptions) // Warnings were encountered during analysis: // -// dist/Editor-DorHl-Q6.d.ts:4062:5 - (ae-forgotten-export) The symbol "Segment" needs to be exported by the entry point index.d.ts +// dist/Editor-C5I4hn8G.d.ts:4063:5 - (ae-forgotten-export) The symbol "Segment" needs to be exported by the entry point index.d.ts +// dist/index.d.ts:181:5 - (ae-forgotten-export) The symbol "PointType" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 186ad46e7..ffa439dc0 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -46,6 +46,7 @@ "@microsoft/api-extractor": "^7.59.2", "@playwright/test": "^1.59.1", "@shift/editor": "workspace:*", + "@shift/runtime": "workspace:*", "@shift/types": "workspace:*", "@shift/ui": "workspace:*", "@tailwindcss/vite": "^4.1.11", diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 70b5bf4a3..b1c68662f 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -16,4 +16,29 @@ export { localPoint, scenePoint, screenPoint } from "@shift/editor/spaces"; export type { LocalPoint, ScenePoint, ScreenPoint } from "@shift/editor/spaces"; export { externalAxisLocationFromRecord } from "@shift/editor/variation"; export type { DesignAxisLocation, ExternalAxisLocation } from "@shift/editor/variation"; -export type { FontSnapshot, GlyphId, GlyphPreview, GlyphRecord, GlyphSnapshot } from "@shift/types"; +export type { + AxisCoordinate, + EditorGlyph, + EditorInspection, + EditorTool, + EditorView, + FontOverview, + GlyphPage, + GlyphSelector, + GlyphSummary, + LayerView, + ShiftCapabilities, + ShiftSession, + ShiftSessionMode, +} from "@shift/runtime"; +export type { + FontSnapshot, + GlyphId, + GlyphLayerSnapshot, + GlyphPreview, + GlyphRecord, + GlyphSnapshot, + SegmentId, + SelectableId, + ShiftId, +} from "@shift/types"; diff --git a/packages/sdk/tsdown.dts.config.ts b/packages/sdk/tsdown.dts.config.ts index 70f9935f9..156a530b4 100644 --- a/packages/sdk/tsdown.dts.config.ts +++ b/packages/sdk/tsdown.dts.config.ts @@ -21,6 +21,7 @@ export default defineConfig({ "@shift/geo", "@shift/glyph-state", "@shift/rules", + "@shift/runtime", "@shift/types", "@shift/validation", "regl", diff --git a/packages/types/docs/DOCS.md b/packages/types/docs/DOCS.md index aed98f274..c4d3410a9 100644 --- a/packages/types/docs/DOCS.md +++ b/packages/types/docs/DOCS.md @@ -46,6 +46,7 @@ Import from `@shift/types`. - `LayerReplaced` -- one replaced glyph layer in an applied change. - `AddComponentIntent` / `SetComponentTransformsIntent` / `RemoveComponentsIntent` / `DecomposeComponentsIntent` -- generated component-authoring DTOs using branded layer, component, and glyph identities plus ordered decomposed-transform values. - `PointType` -- bridge point type union: `"onCurve" | "offCurve" | "qCurve"`. Quadratic endpoints remain distinct across transport even though anchor predicates accept both on-curve variants. +- `SegmentId` / `ShiftId` / `SelectableId` -- canonical identities for derived segments and editor-addressable objects. Geometry packages construct and interpret segment identities, but all identity brands and unions are owned here. ## How it works diff --git a/packages/types/src/bridge/index.ts b/packages/types/src/bridge/index.ts index cc29e988b..7a248b1c1 100644 --- a/packages/types/src/bridge/index.ts +++ b/packages/types/src/bridge/index.ts @@ -70,6 +70,7 @@ export type { GlyphChangedEntities, GlyphComponents, GlyphEntry, + GlyphLayerSnapshot, GlyphLayerRecord, GlyphLayerShape, GlyphName, diff --git a/packages/types/src/ids.ts b/packages/types/src/ids.ts index b50ff1226..372545ee4 100644 --- a/packages/types/src/ids.ts +++ b/packages/types/src/ids.ts @@ -23,6 +23,7 @@ declare const MetricIdBrand: unique symbol; declare const NamedInstanceIdBrand: unique symbol; declare const NodeIdBrand: unique symbol; declare const RunIdBrand: unique symbol; +declare const SegmentIdBrand: unique symbol; declare const SourceIdBrand: unique symbol; /** @@ -116,6 +117,11 @@ export type NodeId = string & { readonly [NodeIdBrand]: typeof NodeIdBrand }; */ export type RunId = string & { readonly [RunIdBrand]: typeof RunIdBrand }; +/** Stable identity of one segment derived from its endpoint identities. */ +export type SegmentId = string & { + readonly [SegmentIdBrand]: typeof SegmentIdBrand; +}; + /** * A source identifier from Rust. * Branded string type - can't be confused with other IDs or plain strings. @@ -124,6 +130,12 @@ export type SourceId = string & { readonly [SourceIdBrand]: typeof SourceIdBrand; }; +/** Identifies an editor-addressable scene node or glyph object. */ +export type ShiftId = NodeId | PointId | AnchorId | ContourId | SegmentId | ComponentId; + +/** Identifies objects that can be selected by the editor. */ +export type SelectableId = ShiftId; + /** * Convert a string ID from Rust to a typed PointId. * Use this when receiving IDs from Rust snapshots. @@ -224,6 +236,16 @@ export function asRunId(id: string): RunId { return id as RunId; } +/** + * Converts a derived segment identity string to a typed SegmentId. + * + * @param id - identity produced from one segment's endpoint identities. + * @returns the same string with its segment identity brand. + */ +export function asSegmentId(id: string): SegmentId { + return id as SegmentId; +} + /** * Convert a string ID from Rust to a typed SourceId. * Use this when receiving IDs from Rust snapshots. diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 5ba7ad99b..4f33d1558 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -18,6 +18,9 @@ export type { NamedInstanceId, NodeId, RunId, + SegmentId, + SelectableId, + ShiftId, SourceId, } from "./ids"; export { @@ -35,6 +38,7 @@ export { asNamedInstanceId, asNodeId, asRunId, + asSegmentId, asSourceId, isPointId, isContourId, @@ -148,6 +152,7 @@ export type { GlyphEntry, GlyphHandle, GlyphLayerRecord, + GlyphLayerSnapshot, GlyphName, GlyphInterpolation, GlyphLayerShape, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b08a14566..f3d3c750c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,15 @@ importers: '@shift/glyph-state': specifier: workspace:* version: link:../../packages/glyph-state + '@shift/mcp': + specifier: workspace:* + version: link:../../packages/mcp + '@shift/runtime': + specifier: workspace:* + version: link:../../packages/runtime + '@shift/sandbox': + specifier: workspace:* + version: link:../../packages/sandbox '@shift/types': specifier: workspace:* version: link:../../packages/types @@ -127,6 +136,9 @@ importers: '@electron-toolkit/tsconfig': specifier: ^1.0.1 version: 1.0.1(@types/node@25.3.0) + '@modelcontextprotocol/client': + specifier: 2.2.0 + version: 2.2.0 '@playwright/test': specifier: ^1.59.1 version: 1.59.1 @@ -301,6 +313,46 @@ importers: specifier: ^4.1.10 version: 4.1.10(@types/node@25.3.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.49.0)(tsx@4.21.0)) + packages/mcp: + dependencies: + '@modelcontextprotocol/fastify': + specifier: 2.0.0 + version: 2.0.0(@modelcontextprotocol/server@2.2.0)(fastify@5.12.5) + '@modelcontextprotocol/node': + specifier: 2.1.0 + version: 2.1.0(@modelcontextprotocol/server@2.2.0)(hono@4.13.12) + '@modelcontextprotocol/server': + specifier: 2.2.0 + version: 2.2.0 + '@shift/runtime': + specifier: workspace:* + version: link:../runtime + fastify: + specifier: ^5.12.5 + version: 5.12.5 + zod: + specifier: ^4.1.12 + version: 4.3.6 + devDependencies: + '@modelcontextprotocol/client': + specifier: 2.2.0 + version: 2.2.0 + '@shift/sandbox': + specifier: workspace:* + version: link:../sandbox + '@types/node': + specifier: ^25.3.0 + version: 25.3.0 + oxlint: + specifier: ^1.85.0 + version: 1.85.0 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@25.3.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.49.0)(tsx@4.21.0)) + packages/rules: dependencies: '@shift/geo': @@ -317,6 +369,59 @@ importers: specifier: ^4.1.10 version: 4.1.10(@types/node@25.3.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.49.0)(tsx@4.21.0)) + packages/runtime: + dependencies: + '@shift/geo': + specifier: workspace:* + version: link:../geo + '@shift/types': + specifier: workspace:* + version: link:../types + zod: + specifier: ^4.1.12 + version: 4.3.6 + devDependencies: + oxfmt: + specifier: ^0.49.0 + version: 0.49.0 + oxlint: + specifier: ^1.85.0 + version: 1.85.0 + tsdown: + specifier: 0.23.0 + version: 0.23.0(@typescript/native-preview@7.0.0-dev.20260707.2)(oxc-resolver@11.17.1)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.5.4 + version: 5.9.3 + + packages/sandbox: + dependencies: + '@jitl/quickjs-singlefile-cjs-release-sync': + specifier: 0.32.0 + version: 0.32.0 + '@shift/runtime': + specifier: workspace:* + version: link:../runtime + quickjs-emscripten-core: + specifier: 0.32.0 + version: 0.32.0 + devDependencies: + '@shift/types': + specifier: workspace:* + version: link:../types + '@types/node': + specifier: ^25.3.0 + version: 25.3.0 + oxlint: + specifier: ^1.85.0 + version: 1.85.0 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@25.3.0)(jsdom@26.1.0)(vite@6.4.3(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.49.0)(tsx@4.21.0)) + packages/sdk: dependencies: '@base-ui-components/react': @@ -332,6 +437,9 @@ importers: '@shift/editor': specifier: workspace:* version: link:../editor + '@shift/runtime': + specifier: workspace:* + version: link:../runtime '@shift/types': specifier: workspace:* version: link:../types @@ -1046,6 +1154,24 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fastify/ajv-compiler@4.0.6': + resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} + + '@fastify/error@4.2.0': + resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} + + '@fastify/fast-json-stringify-compiler@5.1.0': + resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==} + + '@fastify/forwarded@3.0.2': + resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==} + + '@fastify/merge-json-schemas@0.2.1': + resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + + '@fastify/proxy-addr@5.1.1': + resolution: {integrity: sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==} + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -1061,6 +1187,12 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@hono/node-server@1.19.17': + resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@inquirer/ansi@2.0.4': resolution: {integrity: sha512-DpcZrQObd7S0R/U3bFdkcT5ebRwbTTC4D3tCc1vsJizmgPLxNJBo+AAFmrZwe8zk30P2QzgzGWZ3Q9uJwWuhIg==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} @@ -1199,6 +1331,12 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} + '@jitl/quickjs-ffi-types@0.32.0': + resolution: {integrity: sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==} + + '@jitl/quickjs-singlefile-cjs-release-sync@0.32.0': + resolution: {integrity: sha512-NjUUcw26PoeJHND6nmflAH8nIvAJvxJ2qkSPi95wfiBqPim80GtcdWommroiWb8hh1/7fVettEwodAsGt2Mrsg==} + '@jridgewell/gen-mapping@0.3.12': resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} @@ -1241,6 +1379,35 @@ packages: '@microsoft/tsdoc@0.17.0': resolution: {integrity: sha512-p68VexhnH7ojf3U27RdryUDqnKJgKaWUbCL96vJR7N0mqDuhKG5OIECk3sCPPdynxjk+yoVhRkScqtv5KQjzsw==} + '@modelcontextprotocol/client@2.2.0': + resolution: {integrity: sha512-LxCou/CSYQ6dwEnjhLZY0KnEuc8V4UJ3IEQCl/uR2yHobSQIEdJKUlKLRYRF5i5FqRGHy1eK7une3aAWDHLtig==} + engines: {node: '>=20'} + + '@modelcontextprotocol/core@2.2.0': + resolution: {integrity: sha512-iLhmprRmWI8EcosOA3wVvww22z02NkgqhV4fBH6f/odQBsi7xnJc0HGmi21yWO+/iKw2yzqVE127Zhna5/+JXw==} + engines: {node: '>=20'} + + '@modelcontextprotocol/fastify@2.0.0': + resolution: {integrity: sha512-x41AmyQ+olgAjX8EBkVpCztxIcWaInUlVpEatUBSWINZAmTnQYSYqOp9eigb7dxE2W+CtzhP1H+9uUgHRxJPVg==} + engines: {node: '>=20'} + peerDependencies: + '@modelcontextprotocol/server': ^2.0.0 + fastify: ^5.2.0 + + '@modelcontextprotocol/node@2.1.0': + resolution: {integrity: sha512-6xg3iWVcOfiL8Y7rI6xUqXD0lI2AY5q3djsa/cOi1IJUSwx06t0gvhSwU/6mIIohrFlt40/ANUG56DQ0HLhILQ==} + engines: {node: '>=20'} + peerDependencies: + '@modelcontextprotocol/server': ^2.1.0 + hono: ^4.11.4 + peerDependenciesMeta: + hono: + optional: true + + '@modelcontextprotocol/server@2.2.0': + resolution: {integrity: sha512-qFnltjus6Gk8Lx6J1w2RXmr3YPN22MNT6qD6zjAkaiWIlFIFxWClz0rXHNuJF/KSUyn5YGhkSvXkWDbQHVLFEQ==} + engines: {node: '>=20'} + '@napi-rs/cli@3.6.2': resolution: {integrity: sha512-jy5rABUh9tbE/vPRzw9kGzGuqZiVslyDQUV8LkvjzqVX/oJMN7g0U1uhtr9L3W1H+iRM/urXHXUf+CE4n8FvLA==} engines: {node: '>= 16'} @@ -2160,6 +2327,9 @@ packages: resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} engines: {node: '>=14.18.0'} + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.59.1': resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} engines: {node: '>=18'} @@ -3006,6 +3176,9 @@ packages: resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} engines: {node: ^20.17.0 || >=22.9.0} + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + acorn@8.18.0: resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} @@ -3106,6 +3279,13 @@ packages: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} engines: {node: '>= 4.0.0'} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + avvio@9.3.0: + resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} + aws4@1.13.2: resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} @@ -3291,6 +3471,10 @@ packages: cookie-es@3.1.1: resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -3590,6 +3774,14 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -3600,6 +3792,9 @@ packages: fast-content-type-parse@3.0.0: resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} + fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3607,6 +3802,12 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-json-stringify@7.0.1: + resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==} + + fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -3626,6 +3827,9 @@ packages: resolution: {integrity: sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==} hasBin: true + fastify@5.12.5: + resolution: {integrity: sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==} + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -3656,6 +3860,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-my-way@9.9.0: + resolution: {integrity: sha512-sJsgZ1sQH2UDuowPuMKg8az7Qc8F0jnj+SKkFWU/+T0xcFlgV5skgXOGUqmQzOdmW6ALA7AhJINWx3qFBkbLHA==} + engines: {node: '>=20'} + flora-colossus@3.0.2: resolution: {integrity: sha512-Jk78K/Tzt6saxQPGChlJw69xuFGpWyTSAS8EdU0h/FyXwD2K46yNOXmo6nRHcZ9ooekyBAzMkwmiGNt7wOC5zg==} engines: {node: '>=22.12.0'} @@ -3789,6 +3997,10 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hono@4.13.12: + resolution: {integrity: sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==} + engines: {node: '>=16.9.0'} + hookable@6.1.2: resolution: {integrity: sha512-+abwxtiEA52GCVIsQqut3S/uKTbUwYIp4Pe/vv+6py5XiXBCqMZHg6pA6Y5qhgLSEys0/cYuPbO0z1QFj5ZCmg==} @@ -3850,6 +4062,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ipaddr.js@2.5.0: + resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} + engines: {node: '>= 10'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -3921,6 +4137,9 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3948,6 +4167,9 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-ref-resolver@3.0.0: + resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -3986,6 +4208,9 @@ packages: lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + light-my-request@6.6.0: + resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} @@ -4255,6 +4480,10 @@ packages: resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} engines: {node: '>=12.20.0'} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -4367,6 +4596,20 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkijs@3.4.0: resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} engines: {node: '>=16.0.0'} @@ -4405,6 +4648,12 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} @@ -4436,10 +4685,16 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + quickjs-emscripten-core@0.32.0: + resolution: {integrity: sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==} + react-dom@19.2.8: resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: @@ -4485,6 +4740,13 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -4533,6 +4795,10 @@ packages: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + ret@0.5.0: + resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==} + engines: {node: '>=10'} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -4594,6 +4860,14 @@ packages: safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-regex2@5.1.1: + resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} + hasBin: true + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -4611,6 +4885,9 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} + semver-compare@1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} @@ -4631,6 +4908,9 @@ packages: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -4664,6 +4944,9 @@ packages: snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4675,6 +4958,10 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -4776,6 +5063,10 @@ packages: engines: {node: '>=10'} hasBin: true + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} @@ -4827,6 +5118,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toad-cache@3.7.4: + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} + engines: {node: '>=20'} + tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -5807,6 +6102,29 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 + '@fastify/ajv-compiler@4.0.6': + dependencies: + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.5 + + '@fastify/error@4.2.0': {} + + '@fastify/fast-json-stringify-compiler@5.1.0': + dependencies: + fast-json-stringify: 7.0.1 + + '@fastify/forwarded@3.0.2': {} + + '@fastify/merge-json-schemas@0.2.1': + dependencies: + dequal: 2.0.3 + + '@fastify/proxy-addr@5.1.1': + dependencies: + '@fastify/forwarded': 3.0.2 + ipaddr.js: 2.5.0 + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -5824,6 +6142,10 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@hono/node-server@1.19.17(hono@4.13.12)': + dependencies: + hono: 4.13.12 + '@inquirer/ansi@2.0.4': {} '@inquirer/checkbox@5.1.2(@types/node@25.3.0)': @@ -5947,6 +6269,12 @@ snapshots: dependencies: minipass: 7.1.2 + '@jitl/quickjs-ffi-types@0.32.0': {} + + '@jitl/quickjs-singlefile-cjs-release-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + '@jridgewell/gen-mapping@0.3.12': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -6020,6 +6348,37 @@ snapshots: '@microsoft/tsdoc@0.17.0': {} + '@modelcontextprotocol/client@2.2.0': + dependencies: + '@modelcontextprotocol/core': 2.2.0 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + jose: 6.2.12 + pkce-challenge: 5.0.1 + zod: 4.3.6 + + '@modelcontextprotocol/core@2.2.0': + dependencies: + zod: 4.3.6 + + '@modelcontextprotocol/fastify@2.0.0(@modelcontextprotocol/server@2.2.0)(fastify@5.12.5)': + dependencies: + '@modelcontextprotocol/server': 2.2.0 + fastify: 5.12.5 + + '@modelcontextprotocol/node@2.1.0(@modelcontextprotocol/server@2.2.0)(hono@4.13.12)': + dependencies: + '@hono/node-server': 1.19.17(hono@4.13.12) + '@modelcontextprotocol/server': 2.2.0 + optionalDependencies: + hono: 4.13.12 + + '@modelcontextprotocol/server@2.2.0': + dependencies: + '@modelcontextprotocol/core': 2.2.0 + zod: 4.3.6 + '@napi-rs/cli@3.6.2(@emnapi/runtime@1.8.1)(@types/node@25.3.0)(supports-color@8.1.1)': dependencies: '@inquirer/prompts': 8.3.2(@types/node@25.3.0) @@ -6606,6 +6965,8 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 + '@pinojs/redact@0.4.0': {} + '@playwright/test@1.59.1': dependencies: playwright: 1.59.1 @@ -7259,6 +7620,8 @@ snapshots: abbrev@4.0.0: {} + abstract-logging@2.0.1: {} + acorn@8.18.0: optional: true @@ -7380,6 +7743,13 @@ snapshots: at-least-node@1.0.0: {} + atomic-sleep@1.0.0: {} + + avvio@9.3.0: + dependencies: + '@fastify/error': 4.2.0 + fastq: 1.19.1 + aws4@1.13.2: {} balanced-match@4.0.3: {} @@ -7543,6 +7913,8 @@ snapshots: cookie-es@3.1.1: {} + cookie@1.1.1: {} + core-util-is@1.0.3: {} cosmiconfig@8.3.6(typescript@5.9.3): @@ -7910,12 +8282,20 @@ snapshots: eventemitter3@5.0.1: {} + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + expect-type@1.3.0: {} exponential-backoff@3.1.2: {} fast-content-type-parse@3.0.0: {} + fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -7926,6 +8306,19 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-json-stringify@7.0.1: + dependencies: + '@fastify/merge-json-schemas': 0.2.1 + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + fast-uri: 3.1.5 + json-schema-ref-resolver: 3.0.0 + rfdc: 1.4.1 + + fast-querystring@1.1.2: + dependencies: + fast-decode-uri-component: 1.0.1 + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -7952,6 +8345,24 @@ snapshots: strnum: 2.4.1 xml-naming: 0.3.0 + fastify@5.12.5: + dependencies: + '@fastify/ajv-compiler': 4.0.6 + '@fastify/error': 4.2.0 + '@fastify/fast-json-stringify-compiler': 5.1.0 + '@fastify/proxy-addr': 5.1.1 + abstract-logging: 2.0.1 + avvio: 9.3.0 + fast-json-stringify: 7.0.1 + find-my-way: 9.9.0 + light-my-request: 6.6.0 + pino: 10.3.1 + process-warning: 5.1.0 + rfdc: 1.4.1 + secure-json-parse: 4.1.0 + semver: 7.7.4 + toad-cache: 3.7.4 + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -7982,6 +8393,12 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-my-way@9.9.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 5.1.1 + flora-colossus@3.0.2(supports-color@8.1.1): dependencies: debug: 4.4.1(supports-color@8.1.1) @@ -8154,6 +8571,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hono@4.13.12: {} + hookable@6.1.2: {} hosted-git-info@4.1.0: @@ -8229,6 +8648,8 @@ snapshots: internmap@2.0.3: {} + ipaddr.js@2.5.0: {} + is-arrayish@0.2.1: {} is-core-module@2.17.0: @@ -8275,6 +8696,8 @@ snapshots: jju@1.4.0: {} + jose@6.2.12: {} + js-tokens@4.0.0: {} js-yaml@4.3.1: @@ -8342,6 +8765,10 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-ref-resolver@3.0.0: + dependencies: + dequal: 2.0.3 + json-schema-traverse@1.0.0: {} json-stringify-safe@5.0.1: @@ -8386,6 +8813,12 @@ snapshots: lazy-val@1.0.5: {} + light-my-request@6.6.0: + dependencies: + cookie: 1.1.1 + process-warning: 4.0.1 + set-cookie-parser: 2.7.2 + lightningcss-darwin-arm64@1.30.1: optional: true @@ -8603,6 +9036,8 @@ snapshots: obug@3.0.0: {} + on-exit-leak-free@2.1.2: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -8759,6 +9194,28 @@ snapshots: picomatch@4.0.7: {} + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + pkce-challenge@5.0.1: {} + pkijs@3.4.0: dependencies: '@noble/hashes': 1.4.0 @@ -8802,6 +9259,10 @@ snapshots: process-nextick-args@2.0.1: {} + process-warning@4.0.1: {} + + process-warning@5.1.0: {} + progress@2.0.3: {} promise-retry@2.0.1: @@ -8832,8 +9293,14 @@ snapshots: queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + quick-lru@5.1.1: {} + quickjs-emscripten-core@0.32.0: + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + react-dom@19.2.8(react@19.2.8): dependencies: react: 19.2.8 @@ -8877,6 +9344,10 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + real-require@0.2.0: {} + + real-require@1.0.0: {} + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -8920,6 +9391,8 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + ret@0.5.0: {} + retry@0.12.0: {} reusify@1.1.0: {} @@ -9015,6 +9488,12 @@ snapshots: safe-buffer@5.1.2: {} + safe-regex2@5.1.1: + dependencies: + ret: 0.5.0 + + safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} sanitize-filename@1.6.4: @@ -9029,6 +9508,8 @@ snapshots: scheduler@0.27.0: {} + secure-json-parse@4.1.0: {} + semver-compare@1.0.0: optional: true @@ -9043,6 +9524,8 @@ snapshots: type-fest: 0.13.1 optional: true + set-cookie-parser@2.7.2: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -9071,6 +9554,10 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -9080,6 +9567,8 @@ snapshots: source-map@0.6.1: {} + split2@4.2.0: {} + sprintf-js@1.0.3: {} sprintf-js@1.1.3: @@ -9181,6 +9670,10 @@ snapshots: source-map-support: 0.5.21 optional: true + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + tiny-async-pool@1.3.0: dependencies: semver: 5.7.2 @@ -9223,6 +9716,8 @@ snapshots: dependencies: is-number: 7.0.0 + toad-cache@3.7.4: {} + tough-cookie@5.1.2: dependencies: tldts: 6.1.86 diff --git a/vitest.config.ts b/vitest.config.ts index cb1679d91..3e1e59e77 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ projects: [ "apps/desktop/vitest.config.ts", "packages/geo/vitest.config.ts", + "packages/mcp/vitest.config.ts", "packages/validation/vitest.config.ts", "packages/ui/vitest.config.ts", "packages/glyph-info/vitest.config.ts",