diff --git a/.gitignore b/.gitignore index c298390..de4cf28 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ .DS_Store /dist /apps/web/dist +/apps/web/dist-mcp diff --git a/Cargo.lock b/Cargo.lock index 7a5e2ff..4405246 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -711,6 +711,7 @@ dependencies = [ "anyhow", "assert_cmd", "axum", + "base64 0.22.1", "blake3", "clap", "distill-core", @@ -725,6 +726,7 @@ dependencies = [ "tempfile", "tokio", "tokio-stream", + "tower", "ts-rs", "ureq", "walkdir", diff --git a/apps/web/CHANGELOG.md b/apps/web/CHANGELOG.md index 2ee6571..2a18825 100644 --- a/apps/web/CHANGELOG.md +++ b/apps/web/CHANGELOG.md @@ -9,6 +9,8 @@ notes, topics, tags and stats pages are gone; their content moved into these two. ### Added +- MCP App build with embedded fonts and an SDK bridge to the existing UI API. The same + notes, Review and annotation pages run inside the host, with memory navigation and host theme. - Web UI served by `distill ui`: home, notes search, note page, topics with timelines, tags, stats and a problems page. See `README.md`. - Annotations can be added, edited and deleted on the note page; topics can be renamed and diff --git a/apps/web/README.md b/apps/web/README.md index 51f2537..91a1e6d 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -53,3 +53,6 @@ Run from the repo root with `pnpm -C apps/web + + diff --git a/apps/web/package.json b/apps/web/package.json index 3787ea2..5991ed3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "vite build && vite build --mode mcp", "typecheck": "tsc --noEmit", "lint": "biome check .", "format": "biome check --write .", @@ -15,6 +15,7 @@ "@fontsource/ia-writer-quattro": "5.3.0", "@fontsource/ibm-plex-serif": "5.3.0", "@fontsource/lilex": "5.3.0", + "@modelcontextprotocol/ext-apps": "2.0.3", "@tanstack/react-query": "5.104.0", "@tanstack/react-router": "1.170.39", "cmdk": "1.1.1", @@ -38,6 +39,7 @@ "tailwindcss": "4.3.3", "typescript": "7.0.2", "vite": "8.3.1", + "vite-plugin-singlefile": "2.3.3", "vitest": "5.0.2" } } diff --git a/apps/web/src/api/bridge.ts b/apps/web/src/api/bridge.ts index aa16240..3135656 100644 --- a/apps/web/src/api/bridge.ts +++ b/apps/web/src/api/bridge.ts @@ -1,10 +1,10 @@ -// The one place the UI talks to `distill ui`. Tests swap in a fake with `setBridge`; a -// future desktop shell would swap in its own transport (spec D7). +// HTTP and MCP transports share the same API paths and response types. export type Method = "GET" | "POST" | "PUT" | "DELETE"; export interface Bridge { request(method: Method, path: string, body?: unknown): Promise; + subscribe(refresh: () => void): () => void; } /** An API response that was not 2xx. `message` is the server's `error` text. */ @@ -19,6 +19,12 @@ export class ApiError extends Error { } export const httpBridge: Bridge = { + subscribe(refresh) { + const events = new EventSource("/api/events"); + events.addEventListener("changed", refresh); + events.addEventListener("open", refresh); + return () => events.close(); + }, async request(method: Method, path: string, body?: unknown): Promise { const res = await fetch(`/api${path}`, { method, diff --git a/apps/web/src/api/generated/McpResponse.ts b/apps/web/src/api/generated/McpResponse.ts new file mode 100644 index 0000000..03033c8 --- /dev/null +++ b/apps/web/src/api/generated/McpResponse.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type McpResponse = { status: number, data: unknown, }; diff --git a/apps/web/src/api/mcp.test.ts b/apps/web/src/api/mcp.test.ts new file mode 100644 index 0000000..ffb4a04 --- /dev/null +++ b/apps/web/src/api/mcp.test.ts @@ -0,0 +1,83 @@ +import type { App } from "@modelcontextprotocol/ext-apps"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ApiError } from "./bridge"; +import { createMcpBridge } from "./mcp"; + +const result = (status: number, data: unknown) => ({ + content: [], + structuredContent: { status, data }, +}); + +function host(response: Awaited>) { + return { callServerTool: vi.fn(async () => response) }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("MCP bridge", () => { + it("reads through the app-only tool and preserves API data", async () => { + const app = host(result(200, { notes: 3 })); + await expect(createMcpBridge(app).request("GET", "/stats")).resolves.toEqual({ notes: 3 }); + expect(app.callServerTool).toHaveBeenCalledWith({ + name: "distill_ui_read", + arguments: { path: "/stats" }, + }); + }); + + it("writes through the app-only tool and accepts empty responses", async () => { + const app = host(result(204, null)); + await expect(createMcpBridge(app).request("DELETE", "/annotations/1")).resolves.toBeUndefined(); + expect(app.callServerTool).toHaveBeenCalledWith({ + name: "distill_ui_write", + arguments: { method: "DELETE", path: "/annotations/1" }, + }); + }); + + it("preserves API status for authentication and retry decisions", async () => { + const app = host(result(404, { error: "unknown note 01X" })); + const error = await createMcpBridge(app) + .request("GET", "/notes/01X") + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).status).toBe(404); + expect((error as Error).message).toBe("unknown note 01X"); + }); + + it("reports MCP errors and malformed host responses", async () => { + const app = host({ + isError: true, + content: [{ type: "text", text: "cannot open vault /missing" }], + }); + await expect(createMcpBridge(app).request("GET", "/stats")).rejects.toThrow( + "cannot open vault /missing", + ); + app.callServerTool.mockResolvedValue({ content: [] }); + await expect(createMcpBridge(app).request("GET", "/stats")).rejects.toThrow( + "Invalid Distill MCP response", + ); + }); + + it("refreshes visible apps and removes timers and listeners on close", () => { + vi.useFakeTimers(); + const browser = new EventTarget(); + const page = Object.assign(new EventTarget(), { visibilityState: "visible" }); + vi.stubGlobal("window", Object.assign(browser, { setInterval, clearInterval })); + vi.stubGlobal("document", page); + const refresh = vi.fn(); + const close = createMcpBridge(host(result(200, {}))).subscribe(refresh); + vi.advanceTimersByTime(5000); + expect(refresh).toHaveBeenCalledTimes(1); + page.visibilityState = "hidden"; + vi.advanceTimersByTime(5000); + expect(refresh).toHaveBeenCalledTimes(1); + browser.dispatchEvent(new Event("focus")); + expect(refresh).toHaveBeenCalledTimes(2); + close(); + vi.advanceTimersByTime(5000); + browser.dispatchEvent(new Event("focus")); + expect(refresh).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/api/mcp.ts b/apps/web/src/api/mcp.ts new file mode 100644 index 0000000..0687108 --- /dev/null +++ b/apps/web/src/api/mcp.ts @@ -0,0 +1,49 @@ +import type { App } from "@modelcontextprotocol/ext-apps"; +import { ApiError, type Bridge, type Method } from "./bridge"; +import type { McpResponse } from "./generated/McpResponse"; + +export function createMcpBridge(app: Pick): Bridge { + return { + async request(method: Method, path: string, body?: unknown): Promise { + const result = await app.callServerTool({ + name: method === "GET" ? "distill_ui_read" : "distill_ui_write", + arguments: + method === "GET" ? { path } : { method, path, ...(body === undefined ? {} : { body }) }, + }); + if (result.isError) { + throw new Error( + result.content + .filter((c) => c.type === "text") + .map((c) => c.text) + .join("\n"), + ); + } + const response = result.structuredContent as McpResponse | undefined; + if (!response || typeof response.status !== "number" || !("data" in response)) { + throw new Error(`Invalid Distill MCP response for ${method} ${path}`); + } + if (response.status >= 400) { + const data = response.data as { error?: unknown } | null; + throw new ApiError( + response.status, + typeof data?.error === "string" ? data.error : `UI API ${response.status} for ${path}`, + ); + } + return (response.status === 204 ? undefined : response.data) as T; + }, + subscribe(refresh) { + // MCP Apps have no HTTP event stream; refetch while visible and on return to the tab. + const onTick = () => { + if (document.visibilityState === "visible") refresh(); + }; + const timer = window.setInterval(onTick, 5000); + window.addEventListener("focus", refresh); + document.addEventListener("visibilitychange", onTick); + return () => { + window.clearInterval(timer); + window.removeEventListener("focus", refresh); + document.removeEventListener("visibilitychange", onTick); + }; + }, + }; +} diff --git a/apps/web/src/api/queries.ts b/apps/web/src/api/queries.ts index 5840e01..a3cdc5b 100644 --- a/apps/web/src/api/queries.ts +++ b/apps/web/src/api/queries.ts @@ -6,14 +6,14 @@ import { useQueryClient, } from "@tanstack/react-query"; import { useEffect } from "react"; -import { ApiError } from "./bridge"; +import { ApiError, bridge } from "./bridge"; import { api } from "./client"; import type { NotesQuery } from "./generated/NotesQuery"; export const queryClient = new QueryClient({ defaultOptions: { queries: { - // Freshness comes from the server's change events, not from polling. + // The transport owns refresh notifications. staleTime: Number.POSITIVE_INFINITY, retry: (count, error) => !(error instanceof ApiError && error.status < 500) && count < 2, }, @@ -49,14 +49,10 @@ export function useVaultEvents(enabled: boolean): void { if (!enabled) { return; } - const events = new EventSource("/api/events"); const refresh = () => { void client.invalidateQueries({ predicate: (query) => query.queryKey[0] !== "session" }); }; - events.addEventListener("changed", refresh); - // After a reconnect (server restarted), anything may have changed. - events.addEventListener("open", refresh); - return () => events.close(); + return bridge().subscribe(refresh); }, [client, enabled]); } diff --git a/apps/web/src/components/ThemeToggle.tsx b/apps/web/src/components/ThemeToggle.tsx index 8f0f757..d68018a 100644 --- a/apps/web/src/components/ThemeToggle.tsx +++ b/apps/web/src/components/ThemeToggle.tsx @@ -4,6 +4,12 @@ import { cx } from "./ui"; type Theme = "system" | "light" | "dark"; const KEY = "distill-theme"; +let hostTheme: "light" | "dark" | undefined; + +export function setHostTheme(theme: "light" | "dark" | undefined): void { + hostTheme = theme; + applyTheme(); +} function stored(): Theme { const value = localStorage.getItem(KEY); @@ -14,7 +20,10 @@ function stored(): Theme { export function applyTheme(theme: Theme = stored()): void { const dark = theme === "dark" || - (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches); + (theme === "system" && + (hostTheme + ? hostTheme === "dark" + : window.matchMedia("(prefers-color-scheme: dark)").matches)); document.documentElement.dataset.theme = dark ? "dark" : "light"; } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index a7e9683..7226933 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,22 +1,3 @@ -import { QueryClientProvider } from "@tanstack/react-query"; -import { RouterProvider } from "@tanstack/react-router"; -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import { queryClient } from "./api/queries"; -import { applyTheme } from "./components/ThemeToggle"; -import { router } from "./router"; -import "./styles.css"; +import { mountApp } from "./render"; -applyTheme(); - -const container = document.getElementById("root"); -if (!container) { - throw new Error("index.html has no #root element"); -} -createRoot(container).render( - - - - - , -); +mountApp(); diff --git a/apps/web/src/mcp.ts b/apps/web/src/mcp.ts new file mode 100644 index 0000000..2f23a95 --- /dev/null +++ b/apps/web/src/mcp.ts @@ -0,0 +1,27 @@ +import { App } from "@modelcontextprotocol/ext-apps"; +import { setBridge } from "./api/bridge"; +import { createMcpBridge } from "./api/mcp"; +import { setHostTheme } from "./components/ThemeToggle"; +import { mountApp } from "./render"; + +const app = new App( + { name: "Distill", version: "0.0.1" }, + { availableDisplayModes: ["fullscreen"] }, +); +app.onhostcontextchanged = (context) => setHostTheme(context.theme); +app.ontoolresult = () => { + document.title = app.getHostContext()?.toolInfo?.tool.title ?? "Distill"; +}; + +async function start(): Promise { + await app.connect(); + setHostTheme(app.getHostContext()?.theme); + document.title = app.getHostContext()?.toolInfo?.tool.title ?? "Distill"; + setBridge(createMcpBridge(app)); + mountApp(); +} + +void start().catch((error: unknown) => { + const root = document.getElementById("root"); + if (root) root.textContent = `Distill could not connect to the host: ${String(error)}`; +}); diff --git a/apps/web/src/render.tsx b/apps/web/src/render.tsx new file mode 100644 index 0000000..7f62879 --- /dev/null +++ b/apps/web/src/render.tsx @@ -0,0 +1,24 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { RouterProvider } from "@tanstack/react-router"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { queryClient } from "./api/queries"; +import { applyTheme } from "./components/ThemeToggle"; +import { router } from "./router"; +import "./styles.css"; + +export function mountApp(): void { + applyTheme(); + + const container = document.getElementById("root"); + if (!container) { + throw new Error("index.html has no #root element"); + } + createRoot(container).render( + + + + + , + ); +} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index c86adbc..f1d680e 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -1,4 +1,9 @@ -import { createRootRoute, createRoute, createRouter } from "@tanstack/react-router"; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from "@tanstack/react-router"; import { Layout } from "./components/Layout"; import { Empty } from "./components/ui"; import { DistillPage } from "./pages/DistillPage"; @@ -27,7 +32,13 @@ const routeTree = root.addChildren([ createRoute({ getParentRoute: () => root, path: "/problems", component: ProblemsPage }), ]); -export const router = createRouter({ routeTree, scrollRestoration: true }); +export const router = createRouter({ + routeTree, + scrollRestoration: true, + ...(document.documentElement.dataset.transport === "mcp" + ? { history: createMemoryHistory({ initialEntries: ["/"] }) } + : {}), +}); declare module "@tanstack/react-router" { interface Register { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 20ef64e..0f7b292 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -2,13 +2,19 @@ import babel from "@rolldown/plugin-babel"; import tailwindcss from "@tailwindcss/vite"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import { defineConfig } from "vite"; +import { viteSingleFile } from "vite-plugin-singlefile"; // `pnpm dev` proxies the API to a running `distill ui`. The server only accepts requests // from its own origin, so the proxy rewrites Origin to match. See docs/web-ui.md. const target = `http://distill.localhost:${process.env.DISTILL_UI_PORT ?? "4777"}`; -export default defineConfig({ - plugins: [react(), babel({ presets: [reactCompilerPreset()] }), tailwindcss()], +export default defineConfig(({ mode }) => ({ + plugins: [ + react(), + babel({ presets: [reactCompilerPreset()] }), + tailwindcss(), + ...(mode === "mcp" ? [viteSingleFile()] : []), + ], server: { proxy: { "/api": { @@ -20,5 +26,10 @@ export default defineConfig({ }, }, // Served from loopback by `distill ui`, so one ~600 kB bundle loads instantly. - build: { outDir: "dist", emptyOutDir: true, chunkSizeWarningLimit: 800 }, -}); + build: { + outDir: mode === "mcp" ? "dist-mcp" : "dist", + emptyOutDir: true, + chunkSizeWarningLimit: 1200, + ...(mode === "mcp" ? { rolldownOptions: { input: "mcp.html" } } : {}), + }, +})); diff --git a/crates/distill-cli/CHANGELOG.md b/crates/distill-cli/CHANGELOG.md index 9cee12f..c4da92c 100644 --- a/crates/distill-cli/CHANGELOG.md +++ b/crates/distill-cli/CHANGELOG.md @@ -8,6 +8,8 @@ running binary, otherwise the binary's quoted path, as for a copy the plugin downloaded. ### Added +- `distill mcp --ui`: embedded MCP App, global/thread entrypoints, monochrome icon and + app-only read/write tools over the existing UI API. Plain `mcp` keeps four agent tools. - `distill` commands: `init`, `save`, `recall`, `search`, `stats`, `tags`, `annotate`, `reindex`, `vault show|use|move`, `config list|get|set`, `doctor`. All accept `--json`. - `init` proposes iCloud Drive on macOS or the documents folder elsewhere, accepts `~/…` and diff --git a/crates/distill-cli/Cargo.toml b/crates/distill-cli/Cargo.toml index 3645bc3..9f3fb83 100644 --- a/crates/distill-cli/Cargo.toml +++ b/crates/distill-cli/Cargo.toml @@ -12,6 +12,7 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.104" axum = "0.8.9" +base64 = "0.22.1" blake3 = "1.8.7" clap = { version = "4.6.7", features = ["derive"] } distill-core = { version = "0.0.1", path = "../distill-core", features = ["schema"] } @@ -25,6 +26,7 @@ serde = "1.0.229" serde_json = "1.0.151" tokio = { version = "1.53.1", features = ["rt", "macros", "io-std", "rt-multi-thread", "net", "signal", "sync", "time"] } tokio-stream = { version = "0.1.19", features = ["sync"] } +tower = { version = "0.5.3", features = ["util"] } ts-rs = "12.0.1" ureq = { version = "3.4.2", default-features = false } diff --git a/crates/distill-cli/README.md b/crates/distill-cli/README.md index 56d252a..b6f6fd4 100644 --- a/crates/distill-cli/README.md +++ b/crates/distill-cli/README.md @@ -37,7 +37,7 @@ existing vault. | `doctor` | Paths, plugin install, conflicts, unreadable files; exits non-zero when something needs attention | | `ui [--no-open] [--port ]` | Start the web UI and open an authorized browser tab ([docs/web-ui.md](../../docs/web-ui.md)) | | `ui stop` | Stop the running web UI | -| `mcp` | Stdio MCP server for the plugin ([docs/plugin.md](../../docs/plugin.md)) | +| `mcp [--ui]` | Stdio MCP server; `--ui` adds the embedded MCP App for the plugin ([docs/plugin.md](../../docs/plugin.md)) | | `hook user-prompt-submit` | Hook entry point for the plugin; always exits 0 | The `SaveRequest` fields and tag rules are in the diff --git a/crates/distill-cli/src/main.rs b/crates/distill-cli/src/main.rs index 69e73e6..fc6ed38 100644 --- a/crates/distill-cli/src/main.rs +++ b/crates/distill-cli/src/main.rs @@ -92,7 +92,11 @@ enum Command { /// Open the web UI in a browser, starting its local server if needed. Ui(UiArgs), /// Run the MCP server on stdio. Started by the Distill plugin, one per agent session. - Mcp, + Mcp { + /// Expose the embedded MCP App and Codex sidebar entrypoints. + #[arg(long)] + ui: bool, + }, /// Hook entry points. Run by the Distill plugin; always exit 0. #[command(subcommand)] Hook(HookEvent), @@ -242,10 +246,10 @@ fn run(cli: Cli) -> Result<()> { open_browser: !args.no_open, }), }, - Command::Mcp => tokio::runtime::Builder::new_current_thread() + Command::Mcp { ui } => tokio::runtime::Builder::new_current_thread() .enable_all() .build()? - .block_on(mcp::serve()), + .block_on(mcp::serve(ui)), Command::Hook(HookEvent::UserPromptSubmit) => { hook::user_prompt_submit(); Ok(()) diff --git a/crates/distill-cli/src/mcp.rs b/crates/distill-cli/src/mcp.rs index 0017286..2228d06 100644 --- a/crates/distill-cli/src/mcp.rs +++ b/crates/distill-cli/src/mcp.rs @@ -5,6 +5,8 @@ //! Validation failures come back as tool errors with the message the agent needs to retry; //! only transport problems are protocol errors. +mod app; + use std::path::Path; use distill_core::Distill; @@ -47,15 +49,27 @@ pub struct SearchParams { } pub struct DistillServer { - #[expect(dead_code, reason = "read by the code #[tool_handler] generates")] + ui_enabled: bool, tool_router: ToolRouter, } #[tool_router] impl DistillServer { - pub fn new() -> Self { + pub fn new(ui_enabled: bool) -> Self { + let mut tool_router = Self::tool_router(); + if ui_enabled { + let mut ui_router = Self::ui_router(); + let entry = ui_router + .map + .get_mut("distill_open_ui") + .expect("UI entrypoint exists"); + entry.attr.title = Some(app::title().into()); + entry.attr.icons = Some(vec![app::icon()]); + tool_router += ui_router; + } Self { - tool_router: Self::tool_router(), + ui_enabled, + tool_router, } } @@ -162,21 +176,55 @@ impl DistillServer { impl Default for DistillServer { fn default() -> Self { - Self::new() + Self::new(false) } } -#[tool_handler] +#[tool_handler(router = self.tool_router)] impl ServerHandler for DistillServer { fn get_info(&self) -> ServerConfig { - ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) - .with_server_info(Implementation::new("distill", env!("CARGO_PKG_VERSION"))) - .with_instructions(INSTRUCTIONS) + let capabilities = if self.ui_enabled { + ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .build() + } else { + ServerCapabilities::builder().enable_tools().build() + }; + ServerConfig::new(capabilities) + .with_server_info(Implementation::new("distill", env!("CARGO_PKG_VERSION")) + .with_title(app::title()).with_icons(vec![app::icon()])) + .with_instructions(if std::env::var_os("DISTILL_DEV").is_some() { + "Distill dev uses an isolated local vault. Read the distill-dev skill and use only this server for dev context." + } else { INSTRUCTIONS }) + } + async fn list_resources( + &self, + _: Option, + _: rmcp::service::RequestContext, + ) -> Result { + Ok(rmcp::model::ListResourcesResult::with_all_items( + if self.ui_enabled { + vec![app::resource()] + } else { + vec![] + }, + )) + } + + async fn read_resource( + &self, + request: rmcp::model::ReadResourceRequestParams, + _: rmcp::service::RequestContext, + ) -> Result { + app::read_resource(&request.uri, self.ui_enabled).map(Into::into) } } -pub async fn serve() -> anyhow::Result<()> { - let service = DistillServer::new().serve(rmcp::transport::stdio()).await?; +pub async fn serve(ui_enabled: bool) -> anyhow::Result<()> { + let service = DistillServer::new(ui_enabled) + .serve(rmcp::transport::stdio()) + .await?; service.waiting().await?; Ok(()) } diff --git a/crates/distill-cli/src/mcp/app.rs b/crates/distill-cli/src/mcp/app.rs new file mode 100644 index 0000000..8af41a8 --- /dev/null +++ b/crates/distill-cli/src/mcp/app.rs @@ -0,0 +1,140 @@ +//! Codex discovers the global/thread entrypoints from tool metadata, then reads this App. + +use axum::http::Method; +use base64::{Engine, engine::general_purpose::STANDARD}; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{ + CallToolResult, ContentBlock, Icon, MetaObject, ReadResourceResult, Resource, ResourceContents, +}; +use rmcp::{ErrorData as McpError, schemars, tool, tool_router}; +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::DistillServer; + +pub const URI: &str = "ui://distill/library.html"; +const MIME: &str = "text/html;profile=mcp-app"; + +#[derive(rust_embed::Embed)] +#[folder = "$CARGO_MANIFEST_DIR/../../apps/web/dist-mcp/"] +#[allow_missing = true] +struct Assets; + +pub fn title() -> &'static str { + if std::env::var_os("DISTILL_DEV").is_some() { + "Distill dev" + } else { + "Distill" + } +} + +pub fn icon() -> Icon { + let svg = include_str!("../../../../plugins/distill/codex/assets/distill-sidebar.svg"); + Icon::new(format!( + "data:image/svg+xml;base64,{}", + STANDARD.encode(svg) + )) + .with_mime_type("image/svg+xml") + .with_sizes(vec!["any".into()]) +} + +pub fn resource() -> Resource { + Resource::new(URI, "distill-library") + .with_title(title()) + .with_mime_type(MIME) + .with_description("Distill notes, Review and annotations") + .with_icons(vec![icon()]) +} + +pub fn read_resource(uri: &str, enabled: bool) -> Result { + if !enabled || uri != URI { + return Err(McpError::resource_not_found( + format!("unknown UI resource {uri}"), + None, + )); + } + let file = Assets::get("mcp.html").ok_or_else(|| { + McpError::internal_error( + "Distill MCP App is missing. Run mise run web:build and rebuild distill.", + None, + ) + })?; + let html = String::from_utf8(file.data.into_owned()) + .map_err(|e| McpError::internal_error(format!("invalid MCP App HTML: {e}"), None))?; + Ok(ReadResourceResult::new(vec![ResourceContents::text(html, URI) + .with_mime_type(MIME).with_meta(metadata(json!({ + "ui": {"csp": {"connectDomains": [], "resourceDomains": []}}, + "openai/ui": {"availableDisplayModes": ["fullscreen"], "preferredDisplayMode": "fullscreen"} + })))])) +} + +#[derive(Deserialize, schemars::JsonSchema)] +pub struct ReadParams { + pub path: String, +} + +#[derive(Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "UPPERCASE")] +pub enum WriteMethod { + Post, + Put, + Delete, +} + +#[derive(Deserialize, schemars::JsonSchema)] +pub struct WriteParams { + pub method: WriteMethod, + pub path: String, + pub body: Option, +} + +#[tool_router(router = ui_router, vis = "pub(super)")] +impl DistillServer { + #[tool(name = "distill_open_ui", description = "Open the Distill notes library and Review dashboard.", + annotations(read_only_hint = true, destructive_hint = false, open_world_hint = false), + meta = metadata(json!({"ui": {"resourceUri": URI}, "openai/ui": {"entrypoints": [{"type": "global"}, {"type": "thread"}]}})))] + async fn open_ui(&self) -> Result { + response(crate::ui::mcp_request(Method::GET, "/session", None).await) + } + + #[tool(name = "distill_ui_read", description = "Read Distill UI API data. Paths include /session, /timeline, /notes, /topics, /tags, /stats and /problems.", + annotations(read_only_hint = true, destructive_hint = false, open_world_hint = false), + meta = metadata(json!({"ui": {"visibility": ["app"]}})))] + async fn ui_read( + &self, + Parameters(p): Parameters, + ) -> Result { + response(crate::ui::mcp_request(Method::GET, &p.path, None).await) + } + + #[tool(name = "distill_ui_write", description = "Edit annotations, rename or merge topics, dismiss duplicates and resolve conflicts through the Distill UI API.", + annotations(read_only_hint = false, destructive_hint = true, open_world_hint = false), + meta = metadata(json!({"ui": {"visibility": ["app"]}})))] + async fn ui_write( + &self, + Parameters(p): Parameters, + ) -> Result { + let method = match p.method { + WriteMethod::Post => Method::POST, + WriteMethod::Put => Method::PUT, + WriteMethod::Delete => Method::DELETE, + }; + response(crate::ui::mcp_request(method, &p.path, p.body).await) + } +} + +fn response(result: anyhow::Result) -> Result { + match result { + Ok(value) => Ok(CallToolResult::structured( + serde_json::to_value(value) + .map_err(|e| McpError::internal_error(format!("encoding UI result: {e}"), None))?, + )), + Err(error) => Ok(CallToolResult::error(vec![ContentBlock::text(format!( + "{error:#}" + ))])), + } +} + +fn metadata(value: Value) -> MetaObject { + MetaObject(value.as_object().expect("metadata object").clone()) +} diff --git a/crates/distill-cli/src/ui/mcp.rs b/crates/distill-cli/src/ui/mcp.rs new file mode 100644 index 0000000..4e0ce56 --- /dev/null +++ b/crates/distill-cli/src/ui/mcp.rs @@ -0,0 +1,77 @@ +//! The same API handlers over MCP, without HTTP authentication or a loopback server. + +use anyhow::{Context, Result, ensure}; +use axum::body::{Body, to_bytes}; +use axum::http::{Method, Request, Uri, header}; +use axum::{Json, Router}; +use distill_core::config::{Dirs, LocalConfig}; +use serde::Serialize; +use serde_json::{Value, json}; +use tower::ServiceExt; + +use super::{api, server::AppState}; + +#[derive(Serialize, ts_rs::TS)] +#[ts(export)] +pub struct McpResponse { + pub status: u16, + #[ts(type = "unknown")] + pub data: Value, +} + +pub async fn request(method: Method, path: &str, body: Option) -> Result { + let uri: Uri = path + .parse() + .with_context(|| format!("invalid UI path {path}"))?; + ensure!( + path.starts_with('/') + && !path.starts_with("//") + && uri.scheme().is_none() + && uri.authority().is_none(), + "UI path {path} must be an absolute API path, such as /notes" + ); + let dirs = Dirs::discover()?; + let port = LocalConfig::load(&dirs)?.ui.port; + let state = AppState::new(dirs, port)?; + let app = api::routes() + .fallback(|| async { + ( + axum::http::StatusCode::NOT_FOUND, + Json(json!({"error": "unknown UI API path"})), + ) + }) + .method_not_allowed_fallback(|| async { + ( + axum::http::StatusCode::METHOD_NOT_ALLOWED, + Json(json!({"error": "method not allowed for UI API path"})), + ) + }) + .with_state(state); + let app: Router = app; + let body = match body { + Some(value) => Body::from(serde_json::to_vec(&value)?), + None => Body::empty(), + }; + let response = app + .oneshot( + Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json") + .body(body)?, + ) + .await?; + let status = response.status().as_u16(); + let bytes = to_bytes(response.into_body(), 16 * 1024 * 1024).await?; + let data = if bytes.is_empty() { + Value::Null + } else { + // Axum's request extractors return plain text for malformed JSON or query values. + match serde_json::from_slice(&bytes) { + Ok(value) => value, + Err(_) if status >= 400 => json!({"error": String::from_utf8(bytes.to_vec())?}), + Err(error) => return Err(error).context("UI API returned invalid JSON"), + } + }; + Ok(McpResponse { status, data }) +} diff --git a/crates/distill-cli/src/ui/mod.rs b/crates/distill-cli/src/ui/mod.rs index a131a93..d9e691b 100644 --- a/crates/distill-cli/src/ui/mod.rs +++ b/crates/distill-cli/src/ui/mod.rs @@ -8,6 +8,7 @@ mod api; mod auth; +mod mcp; mod server; use std::process::{Command, Stdio}; @@ -17,6 +18,8 @@ use anyhow::{Context, Result, bail}; use distill_core::Distill; use distill_core::config::{Dirs, LocalConfig}; +pub(crate) use self::mcp::{McpResponse, request as mcp_request}; + use self::api::Health; use self::auth::Auth; diff --git a/crates/distill-cli/src/ui/server.rs b/crates/distill-cli/src/ui/server.rs index 39496b1..7189311 100644 --- a/crates/distill-cli/src/ui/server.rs +++ b/crates/distill-cli/src/ui/server.rs @@ -40,6 +40,19 @@ pub struct AppState { } impl AppState { + pub(super) fn new(dirs: Dirs, port: u16) -> Result { + let auth = Auth::load_or_create(&dirs.data_dir.join("ui"))?; + let (changes, _) = broadcast::channel(16); + let (shutdown, _) = watch::channel(false); + Ok(Self { + dirs, + auth, + port, + changes, + shutdown, + }) + } + pub fn notify_changed(&self) { // No receivers just means no page is open. let _ = self.changes.send(()); @@ -72,16 +85,7 @@ pub async fn bind(port: u16) -> std::io::Result { /// Serves until Ctrl-C or `POST /api/shutdown`. pub async fn serve(bound: Bound, dirs: Dirs, vault: &Path, port: u16) -> Result<()> { - let auth = Auth::load_or_create(&dirs.data_dir.join("ui"))?; - let (changes, _) = broadcast::channel(16); - let (shutdown, _) = watch::channel(false); - let state = AppState { - dirs, - auth, - port, - changes, - shutdown, - }; + let state = AppState::new(dirs, port)?; let _watcher = watch_vault(vault, state.clone())?; let app = router(state.clone()); diff --git a/crates/distill-cli/tests/mcp.rs b/crates/distill-cli/tests/mcp.rs index 0d84231..62d6015 100644 --- a/crates/distill-cli/tests/mcp.rs +++ b/crates/distill-cli/tests/mcp.rs @@ -78,8 +78,12 @@ struct Mcp { impl Mcp { fn start(env: &Env) -> Self { + Self::start_args(env, &["mcp"]) + } + + fn start_args(env: &Env, args: &[&str]) -> Self { let mut child = env - .command(&["mcp"]) + .command(args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) @@ -342,3 +346,94 @@ fn hook_injects_source_and_points_at_the_skill() { let garbage = hook(&env, "not json", None); assert!(!garbage.contains("distill-source")); } + +#[test] +fn ui_entrypoints_advertise_a_self_contained_app_and_icon() { + let env = Env::new(); + let mut mcp = Mcp::start_args(&env, &["mcp", "--ui"]); + let tools = mcp.request("tools/list", json!({})); + let entry = tools["tools"] + .as_array() + .unwrap() + .iter() + .find(|tool| tool["name"] == "distill_open_ui") + .unwrap(); + assert_eq!(entry["title"], "Distill"); + assert_eq!( + entry["_meta"]["openai/ui"]["entrypoints"], + json!([{"type":"global"}, {"type":"thread"}]) + ); + assert_eq!( + entry["_meta"]["ui"]["resourceUri"], + "ui://distill/library.html" + ); + assert!( + entry["icons"][0]["src"] + .as_str() + .unwrap() + .starts_with("data:image/svg+xml;base64,") + ); + let (err, result) = mcp.call("distill_open_ui", json!({})); + assert!(!err, "{result}"); + assert_eq!(result["status"], 200); + assert_eq!( + result["data"]["vault"], + env.dir.path().join("vault").to_str().unwrap() + ); + let list = mcp.request("resources/list", json!({})); + let uri = list["resources"][0]["uri"].as_str().unwrap(); + let resource = mcp.request("resources/read", json!({"uri": uri})); + let content = &resource["contents"][0]; + assert_eq!(content["mimeType"], "text/html;profile=mcp-app"); + assert_eq!(content["_meta"]["ui"]["csp"]["resourceDomains"], json!([])); + assert_eq!( + content["_meta"]["openai/ui"]["preferredDisplayMode"], + "fullscreen" + ); + let html = content["text"].as_str().unwrap(); + assert!(html.contains("data-transport=\"mcp\"")); + assert!(html.contains("data:font/"), "fonts must be embedded"); + assert!(!html.contains("src=\"/assets/")); + for name in ["distill_ui_read", "distill_ui_write"] { + let tool = tools["tools"] + .as_array() + .unwrap() + .iter() + .find(|t| t["name"] == name) + .unwrap(); + assert_eq!(tool["_meta"]["ui"]["visibility"], json!(["app"])); + } +} + +#[test] +fn ui_bridge_edits_the_same_vault_and_excludes_server_control_routes() { + let env = Env::new(); + let mut mcp = Mcp::start_args(&env, &["mcp", "--ui"]); + let (_, saved) = mcp.call("distill_save", save_args("new", SESSION)); + let id = saved["note_id"].as_str().unwrap(); + let (_, result) = mcp.call("distill_ui_write", json!({"method":"POST", "path":format!("/notes/{id}/annotations"), "body":{"text":"Through MCP App"}})); + assert_eq!(result["status"], 200, "{result}"); + let annotation = result["data"]["note"]["annotations"][0]["id"] + .as_str() + .unwrap(); + let (_, note) = mcp.call("distill_ui_read", json!({"path":format!("/notes/{id}")})); + assert_eq!( + note["data"]["note"]["annotations"][0]["body"], + "Through MCP App" + ); + let (_, deleted) = mcp.call( + "distill_ui_write", + json!({"method":"DELETE", "path":format!("/annotations/{annotation}")}), + ); + assert_eq!(deleted["status"], 204); + for path in ["/shutdown", "/auth", "/events"] { + let (_, result) = mcp.call("distill_ui_read", json!({"path":path})); + assert_eq!(result["status"], 404, "{path}: {result}"); + } + for path in ["https://example.com/notes", "//example.com/notes", "notes"] { + let (error, _) = mcp.call("distill_ui_read", json!({"path":path})); + assert!(error, "{path} must not cross the API boundary"); + } + let (_, result) = mcp.call("distill_ui_write", json!({"method":"POST","path":"/stats"})); + assert_eq!(result["status"], 405); +} diff --git a/crates/distill-cli/tests/plugin.rs b/crates/distill-cli/tests/plugin.rs index 0868c16..bf10ba0 100644 --- a/crates/distill-cli/tests/plugin.rs +++ b/crates/distill-cli/tests/plugin.rs @@ -121,6 +121,31 @@ fn each_output_is_a_native_marketplace() { assert_eq!(codex["mcpServers"], "./.mcp.json"); } +#[test] +fn codex_brand_paths_resolve_and_only_codex_enables_the_app() { + let out = build(&[]); + let plugin = out.path().join("codex/plugins/distill"); + let manifest = json(&plugin.join(".codex-plugin/plugin.json")); + for key in ["composerIcon", "logo"] { + let path = manifest["interface"][key].as_str().unwrap(); + assert!(plugin.join(path).is_file(), "missing brand asset {path}"); + } + assert!( + manifest["interface"]["capabilities"] + .as_array() + .unwrap() + .contains(&Value::from("Interactive")) + ); + assert_eq!( + json(&plugin.join(".mcp.json"))["mcpServers"]["distill"]["args"], + serde_json::json!(["mcp", "--ui"]) + ); + assert_eq!( + json(&out.path().join("claude-code/plugins/distill/.mcp.json"))["mcpServers"]["distill"]["args"], + serde_json::json!(["mcp"]) + ); +} + #[test] fn outputs_hold_only_their_agents_files() { let out = build(&[]); diff --git a/crates/distill-cli/tests/plugin_dev.rs b/crates/distill-cli/tests/plugin_dev.rs new file mode 100644 index 0000000..3d5373b --- /dev/null +++ b/crates/distill-cli/tests/plugin_dev.rs @@ -0,0 +1,223 @@ +#![allow(clippy::unwrap_used)] // Test fixtures panic on setup failure. +#![cfg(unix)] + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use serde_json::{Value, json}; + +struct Dev { + dir: tempfile::TempDir, +} + +impl Dev { + fn new() -> Self { + let dev = Self { + dir: tempfile::tempdir().unwrap(), + }; + dev.build(); + dev + } + + fn output(&self) -> PathBuf { + self.dir.path().join("output") + } + + fn home(&self) -> PathBuf { + self.dir.path().join("dev data's home") + } + + fn plugin(&self, agent: &str) -> PathBuf { + self.output().join(agent).join("plugins/distill-dev") + } + + fn build(&self) { + let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let status = Command::new(repo.join("scripts/build-plugins.sh")) + .arg("--dev") + .env("DISTILL_PLUGIN_OUT", self.output()) + .stdout(Stdio::null()) + .status() + .unwrap(); + assert!(status.success()); + for agent in ["codex", "claude-code"] { + let bin = self.plugin(agent).join("bin"); + std::fs::write(bin.join("distill-dev-home"), self.home().to_str().unwrap()).unwrap(); + std::fs::write( + bin.join("distill-dev-binary"), + assert_cmd::cargo::cargo_bin("distill").to_str().unwrap(), + ) + .unwrap(); + } + } + + fn command(&self, args: &[&str]) -> Command { + let mut cmd = Command::new(self.plugin("codex").join("bin/distill-launch")); + cmd.args(args) + .env("DISTILL_HOME", self.dir.path().join("regular")) + .env("DISTILL_VAULT", self.dir.path().join("regular-vault")) + .env("DISTILL_BIN", "/does/not/exist") + .env("PORT", "4777") + .env_remove("CLAUDECODE"); + cmd + } + + fn run(&self, args: &[&str]) -> Value { + let out = self.command(args).output().unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + serde_json::from_slice(&out.stdout).unwrap() + } +} + +fn read_json(path: PathBuf) -> Value { + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap() +} + +#[test] +fn dev_marketplaces_have_their_own_identity_and_tools() { + let dev = Dev::new(); + for (agent, manifest, marketplace) in [ + ( + "codex", + ".codex-plugin/plugin.json", + ".agents/plugins/marketplace.json", + ), + ( + "claude-code", + ".claude-plugin/plugin.json", + ".claude-plugin/marketplace.json", + ), + ] { + let plugin = dev.plugin(agent); + let manifest = read_json(plugin.join(manifest)); + assert_eq!(manifest["name"], "distill-dev"); + assert_eq!(manifest["interface"]["displayName"], "Distill dev"); + assert_eq!(manifest["version"], "0.0.1"); + let market = read_json(dev.output().join(agent).join(marketplace)); + assert_eq!(market["name"], "distill-dev"); + assert_eq!(market["plugins"][0]["name"], "distill-dev"); + let source = &market["plugins"][0]["source"]; + assert_eq!( + source["path"].as_str().or(source.as_str()).unwrap(), + "./plugins/distill-dev" + ); + let mcp = read_json(plugin.join(".mcp.json")); + assert!(mcp["mcpServers"].get("distill").is_none()); + assert_eq!(mcp["mcpServers"]["distill-dev"]["title"], "Distill dev"); + assert!(!plugin.join("bin/distill-version").exists()); + assert!(!plugin.join("skills/distill").exists()); + let skill = std::fs::read_to_string(plugin.join("skills/distill-dev/SKILL.md")).unwrap(); + assert!(skill.contains("name: distill-dev\n")); + assert!(skill.contains("distill-dev-source:")); + assert!(skill.contains("Use only tools from the `distill-dev` MCP server")); + assert!( + plugin + .join("skills/distill-dev/references/note-format.md") + .exists() + ); + } +} + +#[test] +fn dev_launcher_isolates_notes_and_keeps_them_across_rebuilds_and_cache_copies() { + let dev = Dev::new(); + assert_eq!(dev.run(&["stats", "--json"])["notes"], 0); + let request = json!({ + "title": "Dev note", "question": "Does the dev vault stay isolated?", + "conclusion": "The dev launcher pins its own home and vault.", + "topic": "new", "new_tags": ["debugging"], + "source": { "agent": "codex", "session_id": "01a0d530-42ae-7731-8a1d-b4e07d9b837d", "cwd": "/test" } + }); + let input = dev.dir.path().join("note.json"); + std::fs::write(&input, request.to_string()).unwrap(); + let saved = dev.run(&["save", "--json", "--input", input.to_str().unwrap()]); + assert!(Path::new(saved["path"].as_str().unwrap()).starts_with(dev.home().join("vault"))); + let config = std::fs::read_to_string(dev.home().join("config/config.toml")).unwrap(); + assert!(config.contains("port = 4778")); + assert!(dev.home().join("data/index").is_dir()); + assert!(!dev.dir.path().join("regular").exists()); + assert!(!dev.dir.path().join("regular-vault").exists()); + + dev.build(); + assert_eq!(dev.run(&["stats", "--json"])["notes"], 1); + let cached = dev.dir.path().join("agent cache's plugin"); + for entry in walkdir::WalkDir::new(dev.plugin("codex")) { + let entry = entry.unwrap(); + let target = cached.join(entry.path().strip_prefix(dev.plugin("codex")).unwrap()); + if entry.file_type().is_dir() { + std::fs::create_dir_all(target).unwrap(); + } else { + std::fs::copy(entry.path(), target).unwrap(); + } + } + let out = Command::new(cached.join("bin/distill-launch")) + .args(["stats", "--json"]) + .output() + .unwrap(); + assert!(out.status.success()); + assert_eq!( + serde_json::from_slice::(&out.stdout).unwrap()["notes"], + 1 + ); +} + +#[test] +fn dev_hook_uses_the_dev_skill_and_does_not_initialize() { + let dev = Dev::new(); + let out = dev + .command(&["hook", "user-prompt-submit"]) + .output() + .unwrap(); + assert!(out.status.success()); + assert!(out.stdout.is_empty()); + assert!(!dev.home().exists()); + + dev.run(&["stats", "--json"]); + let mut child = dev + .command(&["hook", "user-prompt-submit"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + use std::io::Write; + child + .stdin + .take() + .unwrap() + .write_all(br#"{"session_id":"test","turn_id":"test","cwd":"/test"}"#) + .unwrap(); + let output = child.wait_with_output().unwrap(); + let output: Value = serde_json::from_slice(&output.stdout).unwrap(); + let context = output["hookSpecificOutput"]["additionalContext"] + .as_str() + .unwrap(); + assert!(context.contains("distill-dev-source: codex test /test")); + assert!(context.contains("distill-dev-suggest: on")); + assert!(context.contains("read the distill-dev skill")); + assert!(context.contains("Use only the distill-dev MCP server")); +} + +#[test] +fn missing_local_binary_never_falls_back_to_an_installed_release() { + let dev = Dev::new(); + std::fs::write( + dev.plugin("codex").join("bin/distill-dev-binary"), + "/missing/dev/distill", + ) + .unwrap(); + let out = dev.command(&["mcp"]).output().unwrap(); + assert_eq!(out.status.code(), Some(127)); + assert!(String::from_utf8_lossy(&out.stderr).contains("mise run build:plugins:dev")); + assert!(out.stdout.is_empty()); + let hook = dev + .command(&["hook", "user-prompt-submit"]) + .output() + .unwrap(); + assert!(hook.status.success()); + assert!(hook.stdout.is_empty()); + assert!(!dev.home().exists()); +} diff --git a/docs/development.md b/docs/development.md index 50c3504..feb94bb 100644 --- a/docs/development.md +++ b/docs/development.md @@ -17,6 +17,7 @@ and a production build for `apps/web`. A change is not done until it passes. |---|---| | `mise run web:build` | Builds `apps/web/dist` | | `mise run install` | Builds the web UI, then `cargo install --path crates/distill-cli --locked` | +| `mise run build:plugins:dev` | Builds the web UI, debug binary and isolated Distill dev marketplace for Codex ([local debugging](plugin.md#local-debugging)) | | `mise run fmt` | Formats Rust and web code | ## Layout diff --git a/docs/plugin.md b/docs/plugin.md index 815e0c4..966419f 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -1,8 +1,8 @@ # The agent plugin `plugins/distill` is the source of one plugin that installs in both Codex and Claude Code. It -has three parts: a hook that runs on every prompt, an MCP server with four tools, and the -`distill` skill that tells the agent when to use them. Agents install a per-agent build from a +has a prompt hook, four agent tools and the `distill` skill. The Codex build also exposes +an MCP App with sidebar and chat entrypoints. Agents install a per-agent build from a marketplace branch, not the source directory. Installing it: [INSTALL.md](../INSTALL.md). ## Source layout @@ -52,6 +52,78 @@ replaces the old source. A new file under `plugins/distill` reaches both agents unless it sits in an agent directory. `plugin.rs::outputs_hold_only_their_agents_files` checks the outputs against the sources. +## Local debugging + +Run `mise run build:plugins:dev` to build the web UI, the local debug binary and the Codex +**Distill dev** plugin. Add the output as its own marketplace: + +```bash +codex plugin marketplace add dist/plugins-dev/codex +codex plugin add distill-dev@distill-dev +``` + +The plugin and marketplace are both named `distill-dev`. They can be installed alongside +`distill@distill`. Its MCP server is `distill-dev`, and its skill is `distill-dev`. The dev +hook injects `distill-dev-source` and `distill-dev-suggest`, and directs the agent to that skill. + +| Path | Holds | +|---|---| +| `dist/plugins-dev/codex` | The generated Codex marketplace and plugin | +| `dist/dev/config` | Dev config, including its device id and UI port | +| `dist/dev/data` | Dev index, model, UI secret and logs | +| `dist/dev/vault` | Dev notes, topics, tags and annotations | + +The launcher pins absolute paths to the checkout's debug binary and `dist/dev`, so an agent +cache copy uses the same setup. `CARGO_TARGET_DIR` is respected at build time. It overrides +inherited `DISTILL_HOME` and `DISTILL_VAULT`, ignores `DISTILL_BIN`, and never searches for an +installed binary or downloads a release. A missing debug binary tells you to rebuild. + +On the first non-hook invocation, the launcher initializes the dev vault and sets its UI port +to **4778**. The hook never initializes a vault. Use the launcher for commands against dev data: + +```bash +dist/plugins-dev/codex/plugins/distill-dev/bin/distill-launch stats +dist/plugins-dev/codex/plugins/distill-dev/bin/distill-launch ui +dist/plugins-dev/codex/plugins/distill-dev/bin/distill-launch config set ui.port 4779 +``` + +Choose another port with the last command if 4778 is occupied. The launcher ignores `PORT`; +`ui --port` remains available. A plain `distill` command uses the regular setup. + +Rebuilding replaces only the marketplace output. It preserves `dist/dev/` and its port. The +task adds a timestamp build suffix to the generated version so the host can cache each build +separately. Source package versions stay unchanged. Run `codex plugin add distill-dev@distill-dev` +again to install the new copy, then restart the dev MCP connection. + +`scripts/build-plugins.sh --dev claude-code` stages the same overlay for Claude Code after +building the debug binary and web UI. `DISTILL_PLUGIN_OUT` overrides the marketplace output +root without moving dev data. + +## Codex sidebar and chat UI + +The Codex manifest declares `interface.composerIcon`, `logo`, `brandColor` and the +`Interactive` capability. Asset paths are relative to the installed plugin root. The +composer uses `assets/distill-sidebar.svg`; the plugin page uses the flask wordmark PNG. + +Codex starts `distill mcp --ui`. This adds three tools to the four agent tools: + +| Tool | Contract | +|---|---| +| `distill_open_ui` | Accepts `{}`; associates `ui://distill/library.html` with `global` and `thread` entrypoints | +| `distill_ui_read` | App-only; GET against the UI API paths | +| `distill_ui_write` | App-only; POST, PUT or DELETE against the UI API paths | + +The entrypoint and server advertise a monochrome SVG icon through MCP `icons`. +`DISTILL_DEV=1`, set by the dev launcher, titles the entrypoint **Distill dev**. The host owns +sidebar placement and Pin controls. The plugin declares a global entrypoint; it does not set +or persist a user's pin preference. Host behavior follows the +[OpenAI MCP extensions specification](https://developers.openai.com/plugins/build/extensions). + +`resources/read` returns `text/html;profile=mcp-app` with fullscreen display metadata and +an empty external-domain CSP. The same UI runs in the sidebar or a chat tab. Its transport +is documented in [web-ui.md](web-ui.md#mcp-app-transport). Claude Code starts plain +`distill mcp` and keeps its four agent tools. + ## Binary releases Pushing a tag `v` runs [the release workflow](../.github/workflows/release.yml). Running diff --git a/docs/web-ui.md b/docs/web-ui.md index d29aaf4..ba34471 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -54,6 +54,23 @@ notes or links, since the vault syncs. The CLI sends the secret as `Authorizatio A browser without the cookie gets 401 from the API; the page then tells the user to run `distill ui`. +## MCP App transport + +`mise run web:build` builds the HTTP UI into `apps/web/dist` and the MCP App into +`apps/web/dist-mcp/mcp.html`. The latter embeds JavaScript, CSS and fonts in one file. +Rebuild the binary after building either UI to embed it in a release. + +The MCP App connects to the host with `@modelcontextprotocol/ext-apps`. Its API bridge calls +`distill_ui_read` and `distill_ui_write`. The server dispatches these requests to the same +Axum handlers as HTTP, in process. It does not pass a UI secret to the iframe or require a +running HTTP server. Only business API routes are available; `/auth`, `/events` and +`/shutdown` are excluded. HTTP security and authentication stay as described above. + +The App uses memory navigation because its resource URI is not a browser route. System theme +follows the host theme when supplied. It refreshes visible pages every five seconds and on +focus; the HTTP UI continues using server-sent events. Editing a note's annotation or topic +updates the same vault files through either transport. + ## API JSON over HTTP. Request and response types are Rust types in diff --git a/mise.toml b/mise.toml index 0b68ab9..0e796b6 100644 --- a/mise.toml +++ b/mise.toml @@ -13,13 +13,13 @@ description = "Install the web UI's dependencies" run = "pnpm install --frozen-lockfile" [tasks."web:build"] -description = "Build the web UI into apps/web/dist, which `distill` embeds" +description = "Build the HTTP and MCP web UIs, which distill embeds" depends = ["web:install"] run = "pnpm -C apps/web build" [tasks.check] description = "Everything a change must pass before it is done" -depends = ["web:install"] +depends = ["web:build"] # cargo test regenerates the ts-rs API types before the web checks read them. run = [ "cargo fmt --all --check", @@ -28,7 +28,6 @@ run = [ "pnpm -C apps/web lint", "pnpm -C apps/web typecheck", "pnpm -C apps/web test", - "pnpm -C apps/web build", ] [tasks.install] @@ -39,3 +38,8 @@ run = "cargo install --path crates/distill-cli --locked" [tasks."build:plugins"] description = "Build per-agent plugin directories under dist/plugins (args: claude-code, codex)" run = "scripts/build-plugins.sh" + +[tasks."build:plugins:dev"] +description = "Build Distill dev for Codex with the local debug binary and an isolated vault" +depends = ["web:build"] +run = ["cargo build -p distill-cli", 'scripts/build-plugins.sh --dev --build "dev.$(date +%Y%m%d%H%M%S)" codex'] diff --git a/plugins/distill/BUGFIX.md b/plugins/distill/BUGFIX.md index 976a586..c8db97f 100644 --- a/plugins/distill/BUGFIX.md +++ b/plugins/distill/BUGFIX.md @@ -1,5 +1,21 @@ # Bug fixes +## 2026-09-30 — Codex build has no recognized branding or sidebar entrypoint + +**Symptom.** The installed Distill dev plugin shows no branded icon or logo and has no +MCP sidebar UI to pin. +**Root cause.** Brand images were packaged without manifest references. The Codex server +exposed only four agent tools, with no UI resource or `global` entrypoint metadata. +**Fix.** The manifest points to packaged assets. Codex launches `mcp --ui`, which advertises +an embedded MCP App and global/thread entrypoints with SVG icons. The App reuses the UI API. +**Guard.** `tests/plugin.rs::codex_brand_paths_resolve_and_only_codex_enables_the_app`, +`tests/mcp.rs::ui_entrypoints_advertise_a_self_contained_app_and_icon`, +`ui_bridge_edits_the_same_vault_and_excludes_server_control_routes`; +`apps/web/src/api/mcp.test.ts` covers transport errors and subscription cleanup. +**Touches.** The 2026-09-29 launcher fix: plain MCP and Claude Code retain four tools. +The dev launcher still pins local binary and data paths; it additionally supplies the dev UI title. +Pin preferences belong to the host and are not written by the plugin. + ## 2026-09-29 — Plugin from the marketplace cannot start: binary not found **Symptom.** After installing `distill@distill` from `marketplace-claude` or diff --git a/plugins/distill/CHANGELOG.md b/plugins/distill/CHANGELOG.md index 92cec20..fa62dcf 100644 --- a/plugins/distill/CHANGELOG.md +++ b/plugins/distill/CHANGELOG.md @@ -3,6 +3,14 @@ ## [Unreleased] ### Added +- Codex branding paths now resolve to the packaged SVG icon and flask wordmark logo. +- Codex MCP App with global sidebar and thread entrypoints; Distill dev has its own entry title. +- `mise run build:plugins:dev` builds **Distill dev** for Codex in `dist/plugins-dev/codex`. + Its `distill-dev` marketplace, MCP server and skill are separate from the regular plugin. + The launcher uses the checkout's debug binary and keeps config, index and vault in `dist/dev/`. +- Round flask brand assets in `codex/assets/`: a teal and gold icon, a Distill wordmark, + and a transparent black outline icon for the Codex sidebar. +- Transparent icon concepts in `codex/assets/`, including stacked bookmarks. - When no `distill` is installed, `bin/distill-launch` downloads the release pinned in `bin/distill-version` from GitHub releases, checks its SHA-256, and keeps it under `/bin//`. Only the MCP server downloads; the hook still exits 0 at once. @@ -22,4 +30,3 @@ annotations at the note's page. - Marketplace branches `marketplace-codex` and `marketplace-claude`, published by CI on every push to `main`. Published versions carry a build suffix such as `0.0.1+codex.`. - diff --git a/plugins/distill/README.md b/plugins/distill/README.md index 4c6876d..e083324 100644 --- a/plugins/distill/README.md +++ b/plugins/distill/README.md @@ -6,6 +6,7 @@ none is installed, the MCP server's launcher downloads the release the build pin `bin/distill-version` from GitHub releases. Install: [INSTALL.md](../../INSTALL.md). How it works: [docs/plugin.md](../../docs/plugin.md). +For an isolated local build, use [Distill dev](../../docs/plugin.md#local-debugging). ## Contract diff --git a/plugins/distill/codex/.codex-plugin/plugin.json b/plugins/distill/codex/.codex-plugin/plugin.json index 35bc243..4a2531f 100644 --- a/plugins/distill/codex/.codex-plugin/plugin.json +++ b/plugins/distill/codex/.codex-plugin/plugin.json @@ -2,9 +2,16 @@ "name": "distill", "version": "0.0.1", "description": "Save what you learn in agent sessions as notes, and notice when you ask the same question again.", - "author": { "name": "Yanlin" }, + "author": { + "name": "Yanlin" + }, "license": "MIT", - "keywords": ["notes", "learning", "recall", "knowledge"], + "keywords": [ + "notes", + "learning", + "recall", + "knowledge" + ], "skills": "./skills/", "mcpServers": "./.mcp.json", "interface": { @@ -12,11 +19,18 @@ "shortDescription": "Keep what you learn from agents; notice repeat questions.", "developerName": "Yanlin", "category": "Productivity", - "capabilities": ["Read", "Write"], + "capabilities": [ + "Read", + "Write", + "Interactive" + ], "defaultPrompt": [ "Distill what we just figured out.", "Have I asked about this before?", "Which questions do I keep asking?" - ] + ], + "composerIcon": "./assets/distill-sidebar.svg", + "logo": "./assets/distill-flask-logo-v1.png", + "brandColor": "#27756C" } } diff --git a/plugins/distill/codex/.mcp.json b/plugins/distill/codex/.mcp.json index 5fd651e..0bf615b 100644 --- a/plugins/distill/codex/.mcp.json +++ b/plugins/distill/codex/.mcp.json @@ -5,7 +5,10 @@ "description": "Recall past notes, save distilled notes, search and stats.", "cwd": ".", "command": "./bin/distill-launch", - "args": ["mcp"] + "args": [ + "mcp", + "--ui" + ] } } } diff --git a/plugins/distill/codex/assets/distill-bookmark-outline-v1.png b/plugins/distill/codex/assets/distill-bookmark-outline-v1.png new file mode 100644 index 0000000..7fae9fc Binary files /dev/null and b/plugins/distill/codex/assets/distill-bookmark-outline-v1.png differ diff --git a/plugins/distill/codex/assets/distill-bookmark-refined-v1.png b/plugins/distill/codex/assets/distill-bookmark-refined-v1.png new file mode 100644 index 0000000..0974ec2 Binary files /dev/null and b/plugins/distill/codex/assets/distill-bookmark-refined-v1.png differ diff --git a/plugins/distill/codex/assets/distill-bookmarks-v1.png b/plugins/distill/codex/assets/distill-bookmarks-v1.png new file mode 100644 index 0000000..775fe4e Binary files /dev/null and b/plugins/distill/codex/assets/distill-bookmarks-v1.png differ diff --git a/plugins/distill/codex/assets/distill-flask-icon-v1.png b/plugins/distill/codex/assets/distill-flask-icon-v1.png new file mode 100644 index 0000000..cc46def Binary files /dev/null and b/plugins/distill/codex/assets/distill-flask-icon-v1.png differ diff --git a/plugins/distill/codex/assets/distill-flask-logo-v1.png b/plugins/distill/codex/assets/distill-flask-logo-v1.png new file mode 100644 index 0000000..05b67fb Binary files /dev/null and b/plugins/distill/codex/assets/distill-flask-logo-v1.png differ diff --git a/plugins/distill/codex/assets/distill-flask-prompts-v1.txt b/plugins/distill/codex/assets/distill-flask-prompts-v1.txt new file mode 100644 index 0000000..3d754da --- /dev/null +++ b/plugins/distill/codex/assets/distill-flask-prompts-v1.txt @@ -0,0 +1,32 @@ +Generated with the built-in image_gen tool. + +ICON +Use case: logo-brand +Asset type: standalone Distill Codex plugin icon, square transparent PNG. +Primary request: A thoughtfully designed minimal flat brand symbol: a round spherical laboratory flask with liquid bubbling into stars. Color is a restrained accent, not a rainbow effect. +Style: clean 2D vector-like logo, precise simple geometry, consistent dark charcoal outlines with rounded joins. Editorial and quietly playful, handsome and restrained. ABSOLUTELY NO GRADIENTS, NO 3D, NO GLOSS, NO LIGHTING, NO SHADOWS, NO NEON, NO GLOW. +Subject: true circular flask bulb, short straight narrow neck, simple open lip. One gently curved liquid surface across the lower half of the bulb, a single solid muted teal fill (#4EAAA0). The transparent empty upper half contains one small outline bubble and one small four-point star that rises from the liquid. A second four-point star in solid warm golden ochre (#E5B34C) just above the flask mouth. Dark charcoal (#23272A) outline. Only these two accent colors. All glass interior is transparent, including the air and any unfilled portions. No white fill anywhere. +Composition: one compact centered mark, ample clear margins, simple recognizable silhouette readable at 32px. Round body visually dominant, short neck, no exaggerated elongated neck. Minimal details. +Text: none. +Constraints: genuinely transparent alpha background. No badge or rounded-square background, no extra decoration, no watermark. Star count exactly two, one small inside the bulb/neck and one above the mouth. The liquid looks like liquid in a flask, not ocean waves. + +LOGO +Use case: logo-brand +Asset type: horizontal Distill wordmark with flask icon, transparent PNG. +Reference image 1: the brand icon. Preserve its exact shape and restrained style. +Primary request: Put this spherical laboratory flask icon on the left, with the exact word "Distill" on the right, in a refined medium-bold rounded geometric sans serif. +Text (verbatim): "Distill" (capital D, lowercase i s t i l l), dark charcoal #23272A, ONE SOLID COLOR. +Flask: dark charcoal even rounded outlines, lower half filled with one solid muted teal #4EAAA0, one golden ochre four-point star above the mouth, one tiny teal four-point star and one outlined bubble inside. Keep the reference geometry, no new objects. +Composition: horizontal 3:1 canvas, icon roughly one quarter of width, word visually balanced against the flask body, generous clear gap and outer margins. One final lockup, no variants. +Style: clean flat 2D vector-like branding, every region one SOLID FLAT COLOR. No gradients anywhere, no rainbow letters, no glows, no gloss, no bevel, no 3D, no shading, no shadows or texture. +Background: genuine transparent alpha, also transparent inside glass except teal liquid; no white fill, no fake checkerboard. +Avoid: slogans, captions, badge, watermark, extra text, background. + +SIDEBAR +Use case: logo-brand +Asset type: Codex sidebar small icon, pure black line art on genuinely transparent alpha background. +Input image 1: brand flask shape reference; simplify it into monochrome line art. +Primary request: The same spherical round-bottom laboratory flask with liquid bubbling into two four-point stars, drawn ONLY with clean pure black #000000 outlines. +Composition: square centered icon, circular bulb, short narrow upright neck and simple open lip. One curved line across the lower half indicates the liquid level. One small four-point star outline inside the bulb above the liquid. One four-point star outline just above the mouth. Remove the round bubble for clarity at 20–24px. +Style: minimal clean vector-like outline icon, a consistent medium stroke weight, rounded caps and joins, strong silhouette, no tiny detail. Keep every line bold enough for a small sidebar icon. +Constraints: black strokes only. ALL interiors genuinely transparent including the liquid region and inside the stars, no solid fills anywhere. NO WHITE, NO GRAY, NO COLOR, NO GRADIENTS, NO SHADING, NO SHADOW, NO GLOW, NO 3D, NO TEXTURE. No wordmark or text. No background tile, no fake transparency checkerboard. Comfortable 12% margins, no clipping. diff --git a/plugins/distill/codex/assets/distill-flask-sidebar-v1.png b/plugins/distill/codex/assets/distill-flask-sidebar-v1.png new file mode 100644 index 0000000..30866cd Binary files /dev/null and b/plugins/distill/codex/assets/distill-flask-sidebar-v1.png differ diff --git a/plugins/distill/codex/assets/distill-icon-v2.png b/plugins/distill/codex/assets/distill-icon-v2.png new file mode 100644 index 0000000..760b258 Binary files /dev/null and b/plugins/distill/codex/assets/distill-icon-v2.png differ diff --git a/plugins/distill/codex/assets/distill-sidebar.svg b/plugins/distill/codex/assets/distill-sidebar.svg new file mode 100644 index 0000000..c564aa7 --- /dev/null +++ b/plugins/distill/codex/assets/distill-sidebar.svg @@ -0,0 +1,3 @@ + + + diff --git a/plugins/distill/codex/assets/distill-thinker-black-v1.png b/plugins/distill/codex/assets/distill-thinker-black-v1.png new file mode 100644 index 0000000..5434936 Binary files /dev/null and b/plugins/distill/codex/assets/distill-thinker-black-v1.png differ diff --git a/plugins/distill/codex/assets/distill-thinker-line-v1.png b/plugins/distill/codex/assets/distill-thinker-line-v1.png new file mode 100644 index 0000000..b9ed1f1 Binary files /dev/null and b/plugins/distill/codex/assets/distill-thinker-line-v1.png differ diff --git a/plugins/distill/codex/assets/distill-thinker-preview-v1.png b/plugins/distill/codex/assets/distill-thinker-preview-v1.png new file mode 100644 index 0000000..1843cf3 Binary files /dev/null and b/plugins/distill/codex/assets/distill-thinker-preview-v1.png differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 814c71f..6f50485 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,9 @@ importers: '@fontsource/lilex': specifier: 5.3.0 version: 5.3.0 + '@modelcontextprotocol/ext-apps': + specifier: 2.0.3 + version: 2.0.3(@modelcontextprotocol/client@2.2.0)(@modelcontextprotocol/core@2.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5) '@tanstack/react-query': specifier: 5.104.0 version: 5.104.0(react@19.3.0) @@ -83,6 +86,9 @@ importers: vite: specifier: 8.3.1 version: 8.3.1(@types/node@22.20.4)(jiti@2.7.0) + vite-plugin-singlefile: + specifier: 2.3.3 + version: 2.3.3(vite@8.3.1(@types/node@22.20.4)(jiti@2.7.0)) vitest: specifier: 5.0.2 version: 5.0.2(@types/node@22.20.4)(vite@8.3.1(@types/node@22.20.4)(jiti@2.7.0)) @@ -248,6 +254,32 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@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/ext-apps@2.0.3': + resolution: {integrity: sha512-Tn+4+cyhO4f4cQSDxNyLiIfEE+qlTTuIbWBJB8JTinEXOhDYgCT4wDTQplxD9MBYZH5pCYGC59GaxiwIT4bu8w==} + engines: {node: '>=20'} + peerDependencies: + '@modelcontextprotocol/client': ^2.0.0 + '@modelcontextprotocol/core': ^2.0.0 + '@modelcontextprotocol/server': ^2.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + zod: ^4.2.0 + peerDependenciesMeta: + '@modelcontextprotocol/server': + optional: true + react: + optional: true + react-dom: + optional: true + '@oxc-project/types@0.151.0': resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} @@ -529,6 +561,9 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.3.3': resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} @@ -886,6 +921,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + browserslist@4.29.1: resolution: {integrity: sha512-AUdjuRyCNGUYtqpqfTmWyM4fXay8yIQhmLnvYe/THMGfT9B/34X7xQd3ifKxwyNPPpowVBjLb+64BN9Rn1mizw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -928,6 +967,10 @@ packages: cookie-es@3.1.1: resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -985,6 +1028,14 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + 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.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -1001,6 +1052,10 @@ packages: picomatch: optional: true + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + flru@1.0.2: resolution: {integrity: sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==} engines: {node: '>=6'} @@ -1048,6 +1103,10 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -1056,10 +1115,16 @@ packages: resolution: {integrity: sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==} engines: {node: '>=18'} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -1370,6 +1435,10 @@ packages: micromark@4.0.3: resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==} + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1389,13 +1458,25 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.7: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} @@ -1478,6 +1559,14 @@ packages: resolution: {integrity: sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ==} engines: {node: '>=10'} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1516,6 +1605,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -1592,6 +1685,16 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-plugin-singlefile@2.3.3: + resolution: {integrity: sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==} + engines: {node: '>18.0.0'} + peerDependencies: + rollup: ^4.59.0 + vite: ^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + rollup: + optional: true + vite@8.3.1: resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1676,11 +1779,19 @@ packages: jsdom: optional: true + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@3.2.2: resolution: {integrity: sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==} engines: {node: '>=20.11'} hasBin: true + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -1845,6 +1956,30 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.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.6.5 + + '@modelcontextprotocol/core@2.2.0': + dependencies: + zod: 4.6.5 + + '@modelcontextprotocol/ext-apps@2.0.3(@modelcontextprotocol/client@2.2.0)(@modelcontextprotocol/core@2.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(zod@4.6.5)': + dependencies: + '@modelcontextprotocol/client': 2.2.0 + '@modelcontextprotocol/core': 2.2.0 + '@standard-schema/spec': 1.1.0 + zod: 4.6.5 + optionalDependencies: + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + '@oxc-project/types@0.151.0': {} '@radix-ui/primitive@1.1.7': {} @@ -2039,6 +2174,8 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.3.3': dependencies: '@jridgewell/remapping': 2.3.5 @@ -2304,6 +2441,10 @@ snapshots: baseline-browser-mapping@2.11.26: {} + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + browserslist@4.29.1: dependencies: baseline-browser-mapping: 2.11.26 @@ -2344,6 +2485,12 @@ snapshots: cookie-es@3.1.1: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + csstype@3.2.3: {} debug@4.4.3: @@ -2385,6 +2532,12 @@ snapshots: dependencies: '@types/estree': 1.0.9 + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + expect-type@1.4.0: {} extend@3.0.2: {} @@ -2393,6 +2546,10 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + flru@1.0.2: {} fsevents@2.3.3: @@ -2445,12 +2602,18 @@ snapshots: is-hexadecimal@2.0.1: {} + is-number@7.0.0: {} + is-plain-obj@4.1.0: {} isbot@5.2.2: {} + isexe@2.0.0: {} + jiti@2.7.0: {} + jose@6.2.12: {} + js-tokens@10.0.0: {} jsesc@3.1.0: {} @@ -2921,6 +3084,11 @@ snapshots: transitivePeerDependencies: - supports-color + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + ms@2.1.3: {} nanoid@3.3.19: {} @@ -2939,10 +3107,16 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + path-key@3.1.1: {} + picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.7: {} + pkce-challenge@5.0.1: {} + postcss@8.5.28: dependencies: nanoid: 3.3.19 @@ -3066,6 +3240,12 @@ snapshots: seroval@1.6.7: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + source-map-js@1.2.1: {} space-separated-tokens@2.0.2: {} @@ -3098,6 +3278,10 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -3199,6 +3383,11 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 + vite-plugin-singlefile@2.3.3(vite@8.3.1(@types/node@22.20.4)(jiti@2.7.0)): + dependencies: + micromatch: 4.0.8 + vite: 8.3.1(@types/node@22.20.4)(jiti@2.7.0) + vite@8.3.1(@types/node@22.20.4)(jiti@2.7.0): dependencies: lightningcss: 1.33.0 @@ -3232,6 +3421,12 @@ snapshots: transitivePeerDependencies: - msw + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@3.2.2: {} + zod@4.6.5: {} + zwitch@2.0.4: {} diff --git a/scripts/build-dev-plugin.sh b/scripts/build-dev-plugin.sh new file mode 100755 index 0000000..c782ab6 --- /dev/null +++ b/scripts/build-dev-plugin.sh @@ -0,0 +1,51 @@ +#!/bin/sh +# Applies the local debugging overlay to a staged plugin. Called by build-plugins.sh. +set -eu + +agent=$1 +dest=$2 +dev_home=$3 +dev_binary=$4 +root=$(cd "$(dirname "$0")/.." && pwd) +plugin="$dest/plugins/distill-dev" +case "$agent" in + codex) + manifest=.codex-plugin/plugin.json + marketplace=.agents/plugins/marketplace.json + ;; + claude-code) + manifest=.claude-plugin/plugin.json + marketplace=.claude-plugin/marketplace.json + ;; +esac + +jq '.name = "distill-dev" | .description = "Local Distill development with an isolated vault." | + .interface.displayName = "Distill dev"' "$plugin/$manifest" >"$plugin/$manifest.tmp" +mv "$plugin/$manifest.tmp" "$plugin/$manifest" +jq '.name = "distill-dev" | .interface.displayName = "Distill dev" | + .plugins[0].name = "distill-dev" | + if (.plugins[0].source | type) == "object" then + .plugins[0].source.path = "./plugins/distill-dev" + else .plugins[0].source = "./plugins/distill-dev" end' \ + "$dest/$marketplace" >"$dest/$marketplace.tmp" +mv "$dest/$marketplace.tmp" "$dest/$marketplace" +jq '.mcpServers["distill-dev"] = .mcpServers.distill | + del(.mcpServers.distill) | .mcpServers["distill-dev"].title = "Distill dev" | + .mcpServers["distill-dev"].description = "Distill dev: local debugging tools; isolated from saved Distill notes."' \ + "$plugin/.mcp.json" >"$plugin/.mcp.json.tmp" +mv "$plugin/.mcp.json.tmp" "$plugin/.mcp.json" + +mv "$plugin/skills/distill" "$plugin/skills/distill-dev" +sed -e 's/^name: distill$/name: distill-dev/' -e 's/^# Distill$/# Distill dev/' \ + -e 's/distill-source/distill-dev-source/g' -e 's/distill-suggest/distill-dev-suggest/g' \ + -e '/^Read this once/i\ +Use only tools from the `distill-dev` MCP server for this skill. Its vault is for local\ +debugging. Use the plugin launcher for CLI commands; a plain `distill` command uses the\ +regular setup. The launcher initializes the dev vault when the MCP server starts.\ +' "$plugin/skills/distill-dev/SKILL.md" >"$plugin/skills/distill-dev/SKILL.md.tmp" +mv "$plugin/skills/distill-dev/SKILL.md.tmp" "$plugin/skills/distill-dev/SKILL.md" +cp "$root/scripts/distill-dev-launch.sh" "$plugin/bin/distill-launch" +chmod +x "$plugin/bin/distill-launch" +printf '%s\n' "$dev_home" >"$plugin/bin/distill-dev-home" +printf '%s\n' "$dev_binary" >"$plugin/bin/distill-dev-binary" +rm "$plugin/bin/distill-version" diff --git a/scripts/build-plugins.sh b/scripts/build-plugins.sh index bcb9ec4..56507c4 100755 --- a/scripts/build-plugins.sh +++ b/scripts/build-plugins.sh @@ -4,9 +4,11 @@ # branches; locally, add an output with `claude plugin marketplace add dist/plugins/claude-code` # or `codex plugin marketplace add dist/plugins/codex`. # -# Usage: scripts/build-plugins.sh [--build ] [claude-code | codex]... (no agent: both) +# Usage: scripts/build-plugins.sh [--dev] [--build ] [claude-code | codex]... # --build Sets the output manifest version to +., so agents that # cache plugins by version pick up a new build. Needs jq. +# --dev Builds distill-dev under dist/plugins-dev, using the local debug binary +# and dist/dev for config, index and vault. Needs jq and cargo. # Output goes to $DISTILL_PLUGIN_OUT/ (default dist/plugins/), replaced on each run. # # Sources, all copied as is: @@ -27,10 +29,23 @@ distill_version=$(sed -n 's/^version = "\(.*\)"$/\1/p' "$root/Cargo.toml" | head [ -n "$distill_version" ] || { echo "build-plugins: no version in $root/Cargo.toml" >&2; exit 2; } build_id="" -if [ "${1:-}" = "--build" ]; then - build_id="${2:?--build needs an id, such as a commit hash}" - shift 2 - command -v jq >/dev/null || { echo "build-plugins: --build needs jq on PATH" >&2; exit 2; } +dev=false +while [ $# -gt 0 ]; do + case "$1" in + --dev) dev=true; shift ;; + --build) build_id="${2:?--build needs an id, such as a commit hash}"; shift 2 ;; + *) break ;; + esac +done +if [ -n "$build_id" ] || "$dev"; then + command -v jq >/dev/null || { echo "build-plugins: --build and --dev need jq on PATH" >&2; exit 2; } +fi +plugin_name=distill +if "$dev"; then + plugin_name=distill-dev + out="${DISTILL_PLUGIN_OUT:-$root/dist/plugins-dev}" + dev_home="$root/dist/dev" + dev_binary=$(cargo metadata --manifest-path "$root/Cargo.toml" --no-deps --format-version 1 | jq -er '.target_directory + "/debug/distill"') fi build() { # @@ -39,7 +54,7 @@ build() { # codex) manifest=".codex-plugin/plugin.json" ;; esac dest="$out/$1" - plugin="$dest/plugins/distill" + plugin="$dest/plugins/$plugin_name" rm -rf "$dest" mkdir -p "$plugin" for f in $shared; do @@ -48,6 +63,9 @@ build() { # cp -Rp "$src/$1/." "$plugin/" echo "$distill_version" >"$plugin/bin/distill-version" cp -Rp "$root/packaging/$1/." "$dest/" + if "$dev"; then + "$root/scripts/build-dev-plugin.sh" "$1" "$dest" "$dev_home" "$dev_binary" + fi if [ -n "$build_id" ]; then jq --arg id "$1.$build_id" '.version += "+" + $id' "$plugin/$manifest" >"$plugin/$manifest.tmp" mv "$plugin/$manifest.tmp" "$plugin/$manifest" diff --git a/scripts/distill-dev-launch.sh b/scripts/distill-dev-launch.sh new file mode 100755 index 0000000..6846615 --- /dev/null +++ b/scripts/distill-dev-launch.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# The dev package pins local paths so installation into an agent cache keeps the same vault. +set -eu + +here=$(cd "$(dirname "$0")" && pwd) +export DISTILL_HOME="$(cat "$here/distill-dev-home")" +export DISTILL_VAULT="$DISTILL_HOME/vault" +bin=$(cat "$here/distill-dev-binary") +export DISTILL_DEV=1 +unset PORT + +if [ ! -x "$bin" ]; then + [ "${1:-}" != hook ] || exit 0 + echo "Distill dev: local binary $bin is missing. Run mise run build:plugins:dev in the source checkout." >&2 + exit 127 +fi + +if [ "${1:-}" = hook ]; then + # A prompt never initializes a vault or opens an index. + [ -f "$DISTILL_HOME/config/config.toml" ] || exit 0 + "$bin" "$@" | sed -e 's/distill-source:/distill-dev-source:/g' \ + -e 's/distill-suggest:/distill-dev-suggest:/g' \ + -e 's/Distill is installed/Distill dev is installed/g' \ + -e 's/the distill skill/the distill-dev skill/g' \ + -e 's/to distill\./to distill. Use only the distill-dev MCP server for this dev context./g' + exit 0 +fi + +if [ ! -f "$DISTILL_HOME/config/config.toml" ]; then + "$bin" init --vault "$DISTILL_VAULT" >/dev/null + "$bin" config set ui.port 4778 >/dev/null +fi +exec "$bin" "$@"