Revamp cljs expression evaluation to full-blown REPL

This commit is contained in:
Dominik Jain 2026-04-28 15:59:07 +02:00
parent 66d518f15d
commit f1affdbadc
5 changed files with 143 additions and 99 deletions

View File

@ -1,43 +1,6 @@
# ClojureScript REPL Access via shadow-cljs # ClojureScript REPL Access via shadow-cljs
## Overview Execute code in the REPL via the Penpot MCP's `cljs_repl` tool.
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 '<CLJ_EXPRESSION>\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 \"<CLJS_CODE>\" {})\n' | \
timeout 10 npx shadow-cljs clj-eval --stdin 2>&1"
```
Return format: `{:results ["<result1>" ...] :out "" :err "" :ns cljs.user}`
You can target a specific runtime by client-id:
```
(shadow.cljs.devtools.api/cljs-eval :main "<code>" {: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.**
## Accessing App State ## 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) ### Top-level store keys (subset)
`:current-page-id`, `:current-file-id`, `:workspace-local`, `:workspace-global`, `:current-page-id`, `:current-file-id`, `:workspace-local`, `:workspace-global`,
`:workspace-trimmed-page`, `:workspace-undo`, `:workspace-guides`, `:workspace-layout`, `: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 **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. `(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". Note `:rect` in CLJS corresponds to "rectangle" in the JS Plugin API, and `:frame` corresponds to "board".
## Notes ## 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. - 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` - `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 - 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 ## Troubleshooting
The REPL may occasionally not connect to the right runtime. `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".
Run `(.-title js/document)` to verify — it should show your file name (e.g. "New File 1 - Penpot"), 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 "<cljs-code>" {:client-id 5})\n' | timeout 10 npx shadow-cljs clj-eval --stdin
```

View File

@ -1,4 +1,5 @@
import nreplClient from "nrepl-client"; import nreplClient from "nrepl-client";
import type { NreplConnection, NreplMessage } from "nrepl-client";
import { createLogger } from "./logger"; import { createLogger } from "./logger";
/** /**
@ -18,8 +19,9 @@ export interface NreplEvalResult {
/** /**
* A client for communicating with a shadow-cljs nREPL server. * A client for communicating with a shadow-cljs nREPL server.
* *
* This client wraps the nrepl-client library, providing a typed, promise-based * This client maintains a persistent nREPL session, so that definitions,
* interface for evaluating Clojure and ClojureScript expressions. * requires, and other state are preserved across evaluations providing
* a full REPL experience.
*/ */
export class NreplClient { export class NreplClient {
private static readonly NREPL_PORT = 3447; private static readonly NREPL_PORT = 3447;
@ -28,30 +30,39 @@ export class NreplClient {
private readonly logger = createLogger("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. * Evaluates a Clojure expression on the nREPL server within the persistent session.
*
* A new connection is established for each evaluation and closed afterwards.
* *
* @param code - the Clojure expression to evaluate * @param code - the Clojure expression to evaluate
* @returns the evaluation result * @returns the evaluation result
*/ */
async eval(code: string): Promise<NreplEvalResult> { async eval(code: string): Promise<NreplEvalResult> {
this.logger.debug("Evaluating Clojure expression: %s", code); this.logger.debug("Evaluating Clojure expression: %s", code);
return this.withConnection((conn) => { const conn = await this.ensureConnection();
return new Promise<NreplEvalResult>((resolve, reject) => { const sessionId = await this.ensureSession(conn);
const timeout = setTimeout(() => {
reject(new Error(`nREPL evaluation timed out after ${NreplClient.EVAL_TIMEOUT_MS}ms`));
}, NreplClient.EVAL_TIMEOUT_MS);
conn.eval(code, (err: Error | null, result: any[]) => { return new Promise<NreplEvalResult>((resolve, reject) => {
clearTimeout(timeout); const timeout = setTimeout(() => {
if (err) { reject(new Error(`nREPL evaluation timed out after ${NreplClient.EVAL_TIMEOUT_MS}ms`));
reject(err); }, NreplClient.EVAL_TIMEOUT_MS);
return;
} conn.send({ op: "eval", code, session: sessionId }, (err: Error | null, result: NreplMessage[]) => {
clearTimeout(timeout);
if (err) {
reject(err);
return;
}
try {
resolve(this.parseEvalResult(result)); 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<T>(operation: (conn: any) => Promise<T>): Promise<T> { async close(): Promise<void> {
const conn = nreplClient.connect({ if (this.connection) {
port: NreplClient.NREPL_PORT, this.logger.info("Closing nREPL connection");
host: NreplClient.NREPL_HOST, this.connection.end();
}); this.connection = null;
this.sessionId = null;
}
}
return new Promise<T>((resolve, reject) => { /**
conn.once("connect", async () => { * Ensures a connection to the nREPL server is established, creating one if necessary.
try { *
const result = await operation(conn); * If the existing connection has been closed or errored, a new one is created.
resolve(result); */
} catch (err) { private async ensureConnection(): Promise<NreplConnection> {
reject(err); if (this.connection && !this.connection.destroyed) {
} finally { return this.connection;
conn.end(); }
}
// 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<NreplConnection>((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) => { conn.once("error", (err: Error) => {
this.logger.error("nREPL connection error: %s", err);
reject( reject(
new Error( new Error(
`Failed to connect to nREPL server at ${NreplClient.NREPL_HOST}:${NreplClient.NREPL_PORT}: ${err.message}` `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<string> {
if (this.sessionId) {
return this.sessionId;
}
this.logger.info("Cloning new nREPL session");
return new Promise<string>((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. * Parses the raw nREPL response messages into a structured result.
*/ */
private parseEvalResult(messages: any[]): NreplEvalResult { private parseEvalResult(messages: NreplMessage[]): NreplEvalResult {
const values: string[] = []; const values: string[] = [];
const outParts: string[] = []; const outParts: string[] = [];
const errParts: string[] = []; const errParts: string[] = [];

View File

@ -11,7 +11,7 @@ import { HighLevelOverviewTool } from "./tools/HighLevelOverviewTool";
import { PenpotApiInfoTool } from "./tools/PenpotApiInfoTool"; import { PenpotApiInfoTool } from "./tools/PenpotApiInfoTool";
import { ExportShapeTool } from "./tools/ExportShapeTool"; import { ExportShapeTool } from "./tools/ExportShapeTool";
import { ImportImageTool } from "./tools/ImportImageTool"; import { ImportImageTool } from "./tools/ImportImageTool";
import { EvalCljsExpressionTool } from "./tools/EvalCljsExpressionTool"; import { CljsReplTool } from "./tools/CljsReplTool";
import { NreplClient } from "./NreplClient"; import { NreplClient } from "./NreplClient";
import { ReplServer } from "./ReplServer"; import { ReplServer } from "./ReplServer";
import { ApiDocs } from "./ApiDocs"; import { ApiDocs } from "./ApiDocs";
@ -190,7 +190,7 @@ export class PenpotMcpServer {
toolInstances.push(new ImportImageTool(this)); toolInstances.push(new ImportImageTool(this));
} }
if (this.isDevEnv()) { if (this.isDevEnv()) {
toolInstances.push(new EvalCljsExpressionTool(this, new NreplClient())); toolInstances.push(new CljsReplTool(this, new NreplClient()));
} }
return toolInstances.map((instance) => { return toolInstances.map((instance) => {

View File

@ -7,53 +7,54 @@ import { PenpotMcpServer } from "../PenpotMcpServer";
import { NreplClient } from "../NreplClient"; import { NreplClient } from "../NreplClient";
/** /**
* Arguments for the EvalCljsExpressionTool. * Arguments for the CljsReplTool.
*/ */
export class EvalCljsExpressionArgs { export class CljsReplArgs {
static schema = { 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 * This tool provides a persistent REPL session connected to the shadow-cljs nREPL server.
* ClojureScript expression in the context of the running browser application, * Definitions, requires, and other state are preserved across calls, enabling iterative
* providing direct access to the frontend application state and APIs. * exploration and manipulation of the running Penpot application.
*/ */
export class EvalCljsExpressionTool extends Tool<EvalCljsExpressionArgs> { export class CljsReplTool extends Tool<CljsReplArgs> {
private readonly nreplClient: NreplClient; private readonly nreplClient: NreplClient;
/** /**
* Creates a new EvalCljsExpressionTool instance. * Creates a new CljsReplTool instance.
* *
* @param mcpServer - the MCP server instance * @param mcpServer - the MCP server instance
* @param nreplClient - the nREPL client for communicating with shadow-cljs * @param nreplClient - the nREPL client for communicating with shadow-cljs
*/ */
constructor(mcpServer: PenpotMcpServer, nreplClient: NreplClient) { constructor(mcpServer: PenpotMcpServer, nreplClient: NreplClient) {
super(mcpServer, EvalCljsExpressionArgs.schema); super(mcpServer, CljsReplArgs.schema);
this.nreplClient = nreplClient; this.nreplClient = nreplClient;
} }
public getToolName(): string { public getToolName(): string {
return "eval_cljs_expression"; return "cljs_repl";
} }
public getToolDescription(): string { public getToolDescription(): string {
return ( return (
"Evaluates a ClojureScript expression in the Penpot frontend runtime via the shadow-cljs nREPL server. " + "Persistent ClojureScript REPL in the Penpot frontend runtime (via shadow-cljs nREPL). " +
"The expression is evaluated in the browser context, providing access to the application state and ClojureScript APIs." "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<ToolResponse> { protected async executeCore(args: CljsReplArgs): Promise<ToolResponse> {
const result = await this.nreplClient.evalCljs(args.expression); const result = await this.nreplClient.evalCljs(args.code);
const parts: string[] = []; const parts: string[] = [];
if (result.values.length > 0) { if (result.values.length > 0) {

View File

@ -16,7 +16,7 @@ declare module "nrepl-client" {
send(message: Record<string, unknown>, callback: (err: Error | null, result: NreplMessage[]) => void): void; send(message: Record<string, unknown>, 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; clone(callback: (err: Error | null, result: NreplMessage[]) => void): void;
@ -29,6 +29,7 @@ declare module "nrepl-client" {
interface NreplMessage { interface NreplMessage {
id?: string; id?: string;
session?: string; session?: string;
"new-session"?: string;
ns?: string; ns?: string;
value?: string; value?: string;
out?: string; out?: string;
@ -48,4 +49,5 @@ declare module "nrepl-client" {
function connect(options: ConnectOptions): NreplConnection; function connect(options: ConnectOptions): NreplConnection;
export default { connect }; export default { connect };
export type { NreplConnection, NreplMessage, ConnectOptions };
} }