mirror of
https://github.com/penpot/penpot.git
synced 2026-06-01 13:10:21 +00:00
✨ Revamp cljs expression evaluation to full-blown REPL
This commit is contained in:
parent
66d518f15d
commit
f1affdbadc
@ -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
|
||||||
|
```
|
||||||
@ -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[] = [];
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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) {
|
||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user