diff --git a/.serena/memories/devenv/cljs-repl.md b/.serena/memories/devenv/cljs-repl.md index f50d337ef4..73f126088b 100644 --- a/.serena/memories/devenv/cljs-repl.md +++ b/.serena/memories/devenv/cljs-repl.md @@ -1,43 +1,6 @@ # ClojureScript REPL Access via shadow-cljs -## Overview -The penpot frontend uses shadow-cljs with `:target :esm` and multi-module code splitting. The CLJS REPL evaluates code in the browser runtime via a websocket connection. - -## Method 1: Interactive REPL (inside container) -```bash -docker exec -it penpot-devenv-main bash -cd /home/penpot/penpot/frontend -npx shadow-cljs cljs-repl main -``` -Requires an active browser session with penpot open. Type `:cljs/quit` to exit. - -## Method 2: Scriptable eval via clj-eval (preferred for automation) -```bash -docker exec penpot-devenv-main bash -c "cd /home/penpot/penpot/frontend && \ - printf '\n' | timeout 10 npx shadow-cljs clj-eval --stdin 2>&1" -``` - -For CLJS evaluation, wrap in `shadow.cljs.devtools.api/cljs-eval`: -```bash -docker exec penpot-devenv-main bash -c "cd /home/penpot/penpot/frontend && \ - printf '(shadow.cljs.devtools.api/cljs-eval :main \"\" {})\n' | \ - timeout 10 npx shadow-cljs clj-eval --stdin 2>&1" -``` - -Return format: `{:results ["" ...] :out "" :err "" :ns cljs.user}` - -You can target a specific runtime by client-id: -``` -(shadow.cljs.devtools.api/cljs-eval :main "" {:client-id 5}) -``` - -To list connected runtimes and their client-ids: -``` -(shadow.cljs.devtools.api/repl-runtimes :main) -``` - -## Method 3: nREPL client (tools/nrepl_eval.py) -A custom Python nREPL client exists at `tools/nrepl_eval.py`. However, it uses `(shadow/repl :main)` to switch to CLJS mode, which doesn't reliably select the correct runtime. **Prefer Method 2 for automation.** +Execute code in the REPL via the Penpot MCP's `cljs_repl` tool. ## Accessing App State @@ -47,7 +10,7 @@ However, **page objects are NOT in the main store atom**. They live behind deriv ### Top-level store keys (subset) `:current-page-id`, `:current-file-id`, `:workspace-local`, `:workspace-global`, `:workspace-trimmed-page`, `:workspace-undo`, `:workspace-guides`, `:workspace-layout`, -`:workspace-drawing`, `:workspace-presence`, `:workspace-ready`, `:profile`, `:route`, etc. +`:workspace-presence`, `:workspace-ready`, `:profile`, `:route`, etc. **Notable absence:** There is no `:workspace-data` key in the store. The old path `(get-in state [:workspace-data :pages-index page-id :objects])` does NOT work. @@ -87,14 +50,17 @@ The shape `:type` is a keyword like `:rect`, `:path`, `:text`, `:ellipse`, `:ima Note `:rect` in CLJS corresponds to "rectangle" in the JS Plugin API, and `:frame` corresponds to "board". ## Notes -- nREPL server runs on port 3447 inside the container, mapped to host - The `:main` build has multiple modules: shared, main, main-workspace, rasterizer, etc. - `app.main.store/state` is a potok store (wrapping an okulary atom) created via `defonce` -- Ignore the "WARNING: shadow-cljs not installed in project" message — it works via the running server - Use `timeout` to avoid hanging if the browser is disconnected -- `DO NOT` call `shadow.cljs.devtools.api/repl-runtime-select` with a runtime that can't eval — it will jam the REPL until restart ## Troubleshooting -The REPL may occasionally not connect to the right runtime. -Run `(.-title js/document)` to verify — it should show your file name (e.g. "New File 1 - Penpot"), not "Penpot - Rasterizer". +`cljs_repl` may not connect to the right runtime when several are attached (e.g. workspace tab + rasterizer). Verify with `(.-title js/document)` — it should show your file name, not "Penpot - Rasterizer". + +To list runtimes or target one by client-id, use `npx shadow-cljs clj-eval` from `/home/penpot/penpot/frontend`. It talks to the shadow-cljs JVM process, so unlike `cljs_repl` it has access to `shadow.cljs.devtools.api`: + +```bash +printf '(shadow.cljs.devtools.api/repl-runtimes :main)\n' | timeout 10 npx shadow-cljs clj-eval --stdin +printf '(shadow.cljs.devtools.api/cljs-eval :main "" {:client-id 5})\n' | timeout 10 npx shadow-cljs clj-eval --stdin +``` \ No newline at end of file diff --git a/mcp/packages/server/src/NreplClient.ts b/mcp/packages/server/src/NreplClient.ts index 372bc9dcd0..d550812594 100644 --- a/mcp/packages/server/src/NreplClient.ts +++ b/mcp/packages/server/src/NreplClient.ts @@ -1,4 +1,5 @@ import nreplClient from "nrepl-client"; +import type { NreplConnection, NreplMessage } from "nrepl-client"; import { createLogger } from "./logger"; /** @@ -18,8 +19,9 @@ export interface NreplEvalResult { /** * A client for communicating with a shadow-cljs nREPL server. * - * This client wraps the nrepl-client library, providing a typed, promise-based - * interface for evaluating Clojure and ClojureScript expressions. + * This client maintains a persistent nREPL session, so that definitions, + * requires, and other state are preserved across evaluations — providing + * a full REPL experience. */ export class NreplClient { private static readonly NREPL_PORT = 3447; @@ -28,30 +30,39 @@ export class NreplClient { private readonly logger = createLogger("NreplClient"); + /** the persistent connection to the nREPL server, established lazily */ + private connection: NreplConnection | null = null; + + /** the cloned session ID that persists state across evaluations */ + private sessionId: string | null = null; + /** - * Evaluates a Clojure expression on the nREPL server. - * - * A new connection is established for each evaluation and closed afterwards. + * Evaluates a Clojure expression on the nREPL server within the persistent session. * * @param code - the Clojure expression to evaluate * @returns the evaluation result */ async eval(code: string): Promise { this.logger.debug("Evaluating Clojure expression: %s", code); - return this.withConnection((conn) => { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error(`nREPL evaluation timed out after ${NreplClient.EVAL_TIMEOUT_MS}ms`)); - }, NreplClient.EVAL_TIMEOUT_MS); + const conn = await this.ensureConnection(); + const sessionId = await this.ensureSession(conn); - conn.eval(code, (err: Error | null, result: any[]) => { - clearTimeout(timeout); - if (err) { - reject(err); - return; - } + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`nREPL evaluation timed out after ${NreplClient.EVAL_TIMEOUT_MS}ms`)); + }, NreplClient.EVAL_TIMEOUT_MS); + + conn.send({ op: "eval", code, session: sessionId }, (err: Error | null, result: NreplMessage[]) => { + clearTimeout(timeout); + if (err) { + reject(err); + return; + } + try { resolve(this.parseEvalResult(result)); - }); + } catch (parseErr) { + reject(parseErr); + } }); }); } @@ -74,28 +85,59 @@ export class NreplClient { } /** - * Opens a connection, executes the given operation, and ensures the connection is closed afterwards. + * Closes the persistent connection and session, releasing all resources. */ - private async withConnection(operation: (conn: any) => Promise): Promise { - const conn = nreplClient.connect({ - port: NreplClient.NREPL_PORT, - host: NreplClient.NREPL_HOST, - }); + async close(): Promise { + if (this.connection) { + this.logger.info("Closing nREPL connection"); + this.connection.end(); + this.connection = null; + this.sessionId = null; + } + } - return new Promise((resolve, reject) => { - conn.once("connect", async () => { - try { - const result = await operation(conn); - resolve(result); - } catch (err) { - reject(err); - } finally { - conn.end(); - } + /** + * Ensures a connection to the nREPL server is established, creating one if necessary. + * + * If the existing connection has been closed or errored, a new one is created. + */ + private async ensureConnection(): Promise { + if (this.connection && !this.connection.destroyed) { + return this.connection; + } + + // reset state since the old connection is gone + this.connection = null; + this.sessionId = null; + + this.logger.info("Connecting to nREPL server at %s:%d", NreplClient.NREPL_HOST, NreplClient.NREPL_PORT); + + return new Promise((resolve, reject) => { + const conn = nreplClient.connect({ + port: NreplClient.NREPL_PORT, + host: NreplClient.NREPL_HOST, + }); + + conn.once("connect", () => { + this.connection = conn; + + // handle unexpected disconnects so the next eval reconnects + conn.once("close", () => { + this.logger.warn("nREPL connection closed unexpectedly"); + this.connection = null; + this.sessionId = null; + }); + + conn.once("error", (err: Error) => { + this.logger.error("nREPL connection error: %s", err); + this.connection = null; + this.sessionId = null; + }); + + resolve(conn); }); conn.once("error", (err: Error) => { - this.logger.error("nREPL connection error: %s", err); reject( new Error( `Failed to connect to nREPL server at ${NreplClient.NREPL_HOST}:${NreplClient.NREPL_PORT}: ${err.message}` @@ -105,10 +147,43 @@ export class NreplClient { }); } + /** + * Ensures a persistent nREPL session exists, cloning one from the server if necessary. + * + * A cloned session maintains its own state (namespace bindings, definitions, etc.) + * independently of other sessions. + */ + private async ensureSession(conn: NreplConnection): Promise { + if (this.sessionId) { + return this.sessionId; + } + + this.logger.info("Cloning new nREPL session"); + + return new Promise((resolve, reject) => { + conn.clone((err: Error | null, result: NreplMessage[]) => { + if (err) { + reject(new Error(`Failed to clone nREPL session: ${err.message}`)); + return; + } + + const sessionMsg = result.find((msg) => msg["new-session"] !== undefined) as any; + if (!sessionMsg) { + reject(new Error("nREPL clone response did not contain a new session ID")); + return; + } + + this.sessionId = sessionMsg["new-session"]; + this.logger.info("Cloned nREPL session: %s", this.sessionId); + resolve(this.sessionId!); + }); + }); + } + /** * Parses the raw nREPL response messages into a structured result. */ - private parseEvalResult(messages: any[]): NreplEvalResult { + private parseEvalResult(messages: NreplMessage[]): NreplEvalResult { const values: string[] = []; const outParts: string[] = []; const errParts: string[] = []; diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index f29ec36302..44eabf1863 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -11,7 +11,7 @@ import { HighLevelOverviewTool } from "./tools/HighLevelOverviewTool"; import { PenpotApiInfoTool } from "./tools/PenpotApiInfoTool"; import { ExportShapeTool } from "./tools/ExportShapeTool"; import { ImportImageTool } from "./tools/ImportImageTool"; -import { EvalCljsExpressionTool } from "./tools/EvalCljsExpressionTool"; +import { CljsReplTool } from "./tools/CljsReplTool"; import { NreplClient } from "./NreplClient"; import { ReplServer } from "./ReplServer"; import { ApiDocs } from "./ApiDocs"; @@ -190,7 +190,7 @@ export class PenpotMcpServer { toolInstances.push(new ImportImageTool(this)); } if (this.isDevEnv()) { - toolInstances.push(new EvalCljsExpressionTool(this, new NreplClient())); + toolInstances.push(new CljsReplTool(this, new NreplClient())); } return toolInstances.map((instance) => { diff --git a/mcp/packages/server/src/tools/EvalCljsExpressionTool.ts b/mcp/packages/server/src/tools/CljsReplTool.ts similarity index 50% rename from mcp/packages/server/src/tools/EvalCljsExpressionTool.ts rename to mcp/packages/server/src/tools/CljsReplTool.ts index 324cc1ff08..bd894caef3 100644 --- a/mcp/packages/server/src/tools/EvalCljsExpressionTool.ts +++ b/mcp/packages/server/src/tools/CljsReplTool.ts @@ -7,53 +7,54 @@ import { PenpotMcpServer } from "../PenpotMcpServer"; import { NreplClient } from "../NreplClient"; /** - * Arguments for the EvalCljsExpressionTool. + * Arguments for the CljsReplTool. */ -export class EvalCljsExpressionArgs { +export class CljsReplArgs { static schema = { - expression: z.string().min(1, "Expression cannot be empty"), + code: z.string().min(1, "Code cannot be empty"), }; /** - * The ClojureScript expression to evaluate in the frontend runtime. + * The ClojureScript code to evaluate in the frontend runtime. */ - expression!: string; + code!: string; } /** - * Tool for evaluating ClojureScript expressions in the Penpot frontend runtime. + * A ClojureScript REPL for the Penpot frontend runtime. * - * This tool connects to the shadow-cljs nREPL server and evaluates the given - * ClojureScript expression in the context of the running browser application, - * providing direct access to the frontend application state and APIs. + * This tool provides a persistent REPL session connected to the shadow-cljs nREPL server. + * Definitions, requires, and other state are preserved across calls, enabling iterative + * exploration and manipulation of the running Penpot application. */ -export class EvalCljsExpressionTool extends Tool { +export class CljsReplTool extends Tool { private readonly nreplClient: NreplClient; /** - * Creates a new EvalCljsExpressionTool instance. + * Creates a new CljsReplTool instance. * * @param mcpServer - the MCP server instance * @param nreplClient - the nREPL client for communicating with shadow-cljs */ constructor(mcpServer: PenpotMcpServer, nreplClient: NreplClient) { - super(mcpServer, EvalCljsExpressionArgs.schema); + super(mcpServer, CljsReplArgs.schema); this.nreplClient = nreplClient; } public getToolName(): string { - return "eval_cljs_expression"; + return "cljs_repl"; } public getToolDescription(): string { return ( - "Evaluates a ClojureScript expression in the Penpot frontend runtime via the shadow-cljs nREPL server. " + - "The expression is evaluated in the browser context, providing access to the application state and ClojureScript APIs." + "Persistent ClojureScript REPL in the Penpot frontend runtime (via shadow-cljs nREPL). " + + "Definitions, requires, and state are preserved across calls — use it to build up helpers incrementally. " + + "Multiple top-level expressions per call are supported; each produces a result line." ); } - protected async executeCore(args: EvalCljsExpressionArgs): Promise { - const result = await this.nreplClient.evalCljs(args.expression); + protected async executeCore(args: CljsReplArgs): Promise { + const result = await this.nreplClient.evalCljs(args.code); const parts: string[] = []; if (result.values.length > 0) { diff --git a/mcp/packages/server/src/types/nrepl-client.d.ts b/mcp/packages/server/src/types/nrepl-client.d.ts index 79fbd4db4e..21ed53fb72 100644 --- a/mcp/packages/server/src/types/nrepl-client.d.ts +++ b/mcp/packages/server/src/types/nrepl-client.d.ts @@ -16,7 +16,7 @@ declare module "nrepl-client" { send(message: Record, callback: (err: Error | null, result: NreplMessage[]) => void): void; /** - * Clones the current session. + * Clones the current session, creating a new session that inherits the current state. */ clone(callback: (err: Error | null, result: NreplMessage[]) => void): void; @@ -29,6 +29,7 @@ declare module "nrepl-client" { interface NreplMessage { id?: string; session?: string; + "new-session"?: string; ns?: string; value?: string; out?: string; @@ -48,4 +49,5 @@ declare module "nrepl-client" { function connect(options: ConnectOptions): NreplConnection; export default { connect }; + export type { NreplConnection, NreplMessage, ConnectOptions }; }