@ttsalpha/mirra 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -6
- package/dist/index.js +250 -46
- package/dist/plugin/main.js +40 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/ttsalpha/mirra/refs/heads/main/assets/icon.png" width="96" height="96" alt="" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">Mirra</h1>
|
|
2
6
|
|
|
3
7
|
Two-way MCP server for Figma: an agent reads a design and renders one back through the
|
|
4
8
|
Figma Plugin API, so it needs no REST token and has no per-month call limit.
|
|
@@ -61,11 +65,13 @@ skips all of it. The command is written with the real path of the Node binary th
|
|
|
61
65
|
because a version manager puts `node` behind a path that changes with every shell and a
|
|
62
66
|
GUI client never sees it.
|
|
63
67
|
|
|
64
|
-
|
|
68
|
+
It also registers the plugin with Figma Desktop by writing into Figma's own settings,
|
|
69
|
+
which only works while Figma is **closed** — that file is rewritten from memory on quit.
|
|
70
|
+
If Figma is running, or the format turns out to be one Mirra does not recognise, `init` says
|
|
71
|
+
so and falls back to the manual route: Figma Desktop → Plugins → Development → Import plugin
|
|
72
|
+
from manifest… → `~/.mirra/plugin/manifest.json`.
|
|
65
73
|
|
|
66
|
-
|
|
67
|
-
`~/.mirra/plugin/manifest.json`
|
|
68
|
-
2. **Restart the clients**, or they will not see the tools
|
|
74
|
+
Either way, **restart the clients**, or they will not see the tools.
|
|
69
75
|
|
|
70
76
|
And once per working session: open a file in Figma, run the Mirra plugin, leave its panel
|
|
71
77
|
open. Figma has no background plugins, so closing the panel ends the connection.
|
|
@@ -104,7 +110,7 @@ the tool when `MIRRA_ALLOW_EXEC` is set, and the plugin only runs it while **All
|
|
|
104
110
|
execution** is ticked in its panel. The tick resets every time the plugin runs.
|
|
105
111
|
|
|
106
112
|
```sh
|
|
107
|
-
claude mcp add -s user
|
|
113
|
+
claude mcp add -s user Mirra-exec -e MIRRA_ALLOW_EXEC=1 -- npx -y @ttsalpha/mirra serve
|
|
108
114
|
```
|
|
109
115
|
|
|
110
116
|
The socket listens on loopback and refuses any browser origin other than Figma's, so a page
|
package/dist/index.js
CHANGED
|
@@ -32495,15 +32495,19 @@ var PatchResult = external_exports.object({
|
|
|
32495
32495
|
keys: external_exports.record(external_exports.string(), external_exports.string())
|
|
32496
32496
|
});
|
|
32497
32497
|
var ScreenshotParams = external_exports.object({
|
|
32498
|
-
/** Defaults to the current selection
|
|
32498
|
+
/** Defaults to the current selection. */
|
|
32499
32499
|
target: Target.optional(),
|
|
32500
|
-
scale: external_exports.number().min(0.1).max(4).optional()
|
|
32500
|
+
scale: external_exports.number().min(0.1).max(4).optional(),
|
|
32501
|
+
/** Longest edge in pixels; the scale is lowered to fit, not refused. */
|
|
32502
|
+
maxEdge: external_exports.number().int().min(64).max(4096).optional()
|
|
32501
32503
|
});
|
|
32502
32504
|
var ScreenshotResult = external_exports.object({
|
|
32503
32505
|
id: external_exports.string(),
|
|
32504
32506
|
name: external_exports.string(),
|
|
32505
32507
|
width: external_exports.number(),
|
|
32506
32508
|
height: external_exports.number(),
|
|
32509
|
+
/** What the export used, once maxEdge had its say. */
|
|
32510
|
+
scale: external_exports.number(),
|
|
32507
32511
|
/** base64 PNG. */
|
|
32508
32512
|
png: external_exports.string()
|
|
32509
32513
|
});
|
|
@@ -32620,7 +32624,9 @@ var ReadParams = external_exports.object({
|
|
|
32620
32624
|
/** Defaults to the current selection. */
|
|
32621
32625
|
nodeId: external_exports.string().optional(),
|
|
32622
32626
|
/** How many levels of children to walk. */
|
|
32623
|
-
depth: external_exports.number().int().min(0).max(20).optional()
|
|
32627
|
+
depth: external_exports.number().int().min(0).max(20).optional(),
|
|
32628
|
+
/** Most nodes to return, so one call cannot fill the context. */
|
|
32629
|
+
limit: external_exports.number().int().min(1).max(2e3).optional()
|
|
32624
32630
|
});
|
|
32625
32631
|
var ReadResult = external_exports.object({
|
|
32626
32632
|
nodes: external_exports.array(external_exports.object({ id: external_exports.string(), tree: UINode })),
|
|
@@ -32631,7 +32637,8 @@ var PingResult = external_exports.object({
|
|
|
32631
32637
|
fileName: external_exports.string(),
|
|
32632
32638
|
fileKey: external_exports.string().nullable(),
|
|
32633
32639
|
currentPage: external_exports.string(),
|
|
32634
|
-
selectionCount: external_exports.number()
|
|
32640
|
+
selectionCount: external_exports.number(),
|
|
32641
|
+
pluginVersion: external_exports.string()
|
|
32635
32642
|
});
|
|
32636
32643
|
|
|
32637
32644
|
// src/clients.ts
|
|
@@ -32640,7 +32647,8 @@ import { access, copyFile, readFile, rename, writeFile } from "node:fs/promises"
|
|
|
32640
32647
|
import { homedir } from "node:os";
|
|
32641
32648
|
import { dirname, join } from "node:path";
|
|
32642
32649
|
import { realpath } from "node:fs/promises";
|
|
32643
|
-
var SERVER_NAME = "
|
|
32650
|
+
var SERVER_NAME = "Mirra";
|
|
32651
|
+
var LEGACY_NAME = "mirra";
|
|
32644
32652
|
async function stableNodePath() {
|
|
32645
32653
|
try {
|
|
32646
32654
|
return await realpath(process.execPath);
|
|
@@ -32684,6 +32692,7 @@ async function writeEntry(target, node2, entryPoint) {
|
|
|
32684
32692
|
const raw = await readFile(target.file, "utf8");
|
|
32685
32693
|
const config2 = JSON.parse(raw);
|
|
32686
32694
|
const servers = config2[target.key] ?? {};
|
|
32695
|
+
delete servers[LEGACY_NAME];
|
|
32687
32696
|
servers[SERVER_NAME] = {
|
|
32688
32697
|
...target.typed ? { type: "stdio" } : {},
|
|
32689
32698
|
command: node2,
|
|
@@ -32708,6 +32717,7 @@ function claudeCode(node2, entryPoint) {
|
|
|
32708
32717
|
const probe = spawnSync("claude", ["--version"], { stdio: "ignore" });
|
|
32709
32718
|
if (probe.error)
|
|
32710
32719
|
return { client: "Claude Code", status: "skipped", detail: "claude not on PATH" };
|
|
32720
|
+
spawnSync("claude", ["mcp", "remove", "-s", "user", LEGACY_NAME], { stdio: "ignore" });
|
|
32711
32721
|
const result = spawnSync(
|
|
32712
32722
|
"claude",
|
|
32713
32723
|
["mcp", "add", "-s", "user", SERVER_NAME, "--", node2, entryPoint, "serve"],
|
|
@@ -32722,9 +32732,16 @@ function claudeCode(node2, entryPoint) {
|
|
|
32722
32732
|
};
|
|
32723
32733
|
}
|
|
32724
32734
|
|
|
32735
|
+
// src/figma-settings.ts
|
|
32736
|
+
import { execFile } from "node:child_process";
|
|
32737
|
+
import { readFile as readFile3, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
|
|
32738
|
+
import { homedir as homedir3 } from "node:os";
|
|
32739
|
+
import { dirname as dirname2, join as join3, sep as sep2 } from "node:path";
|
|
32740
|
+
import { promisify } from "node:util";
|
|
32741
|
+
|
|
32725
32742
|
// src/install.ts
|
|
32726
32743
|
import { spawn } from "node:child_process";
|
|
32727
|
-
import { access as access2, cp, mkdir, readFile as readFile2 } from "node:fs/promises";
|
|
32744
|
+
import { access as access2, cp, mkdir, readFile as readFile2, rm } from "node:fs/promises";
|
|
32728
32745
|
import { homedir as homedir2, platform } from "node:os";
|
|
32729
32746
|
import { join as join2, resolve, sep } from "node:path";
|
|
32730
32747
|
import { fileURLToPath } from "node:url";
|
|
@@ -32733,9 +32750,9 @@ var PLUGIN_HOME = join2(MIRRA_HOME, "plugin");
|
|
|
32733
32750
|
var SERVER_HOME = join2(MIRRA_HOME, "server");
|
|
32734
32751
|
var DIST = fileURLToPath(new URL(".", import.meta.url));
|
|
32735
32752
|
var BUNDLED_PLUGIN = resolve(DIST, "plugin");
|
|
32736
|
-
async function installPlugin() {
|
|
32753
|
+
async function installPlugin(from = BUNDLED_PLUGIN) {
|
|
32737
32754
|
await mkdir(PLUGIN_HOME, { recursive: true });
|
|
32738
|
-
await cp(
|
|
32755
|
+
await cp(from, PLUGIN_HOME, { recursive: true });
|
|
32739
32756
|
const manifest = join2(PLUGIN_HOME, "manifest.json");
|
|
32740
32757
|
return { manifest, version: await installedVersion() };
|
|
32741
32758
|
}
|
|
@@ -32755,11 +32772,18 @@ async function isInstalled() {
|
|
|
32755
32772
|
return false;
|
|
32756
32773
|
}
|
|
32757
32774
|
}
|
|
32758
|
-
|
|
32759
|
-
|
|
32760
|
-
|
|
32775
|
+
function isTransientInstall(directory) {
|
|
32776
|
+
return directory.includes(`${sep}_npx${sep}`);
|
|
32777
|
+
}
|
|
32778
|
+
async function installServer(from = DIST) {
|
|
32779
|
+
if (!isTransientInstall(from)) return resolve(from, "index.js");
|
|
32780
|
+
const plugin = resolve(from, "plugin");
|
|
32781
|
+
await rm(SERVER_HOME, { recursive: true, force: true });
|
|
32761
32782
|
await mkdir(SERVER_HOME, { recursive: true });
|
|
32762
|
-
await cp(
|
|
32783
|
+
await cp(from, SERVER_HOME, {
|
|
32784
|
+
recursive: true,
|
|
32785
|
+
filter: (source) => source !== plugin && !source.startsWith(plugin + sep)
|
|
32786
|
+
});
|
|
32763
32787
|
return join2(SERVER_HOME, "index.js");
|
|
32764
32788
|
}
|
|
32765
32789
|
function reveal(path) {
|
|
@@ -32767,6 +32791,106 @@ function reveal(path) {
|
|
|
32767
32791
|
spawn("open", ["-R", path], { stdio: "ignore", detached: true }).unref();
|
|
32768
32792
|
}
|
|
32769
32793
|
|
|
32794
|
+
// src/figma-settings.ts
|
|
32795
|
+
var FIGMA_SETTINGS = settingsPath();
|
|
32796
|
+
function settingsPath() {
|
|
32797
|
+
if (process.platform === "darwin") {
|
|
32798
|
+
return join3(homedir3(), "Library/Application Support/Figma/settings.json");
|
|
32799
|
+
}
|
|
32800
|
+
if (process.platform === "win32") {
|
|
32801
|
+
const local = process.env["LOCALAPPDATA"] ?? join3(homedir3(), "AppData/Local");
|
|
32802
|
+
return join3(local, "Figma/settings.json");
|
|
32803
|
+
}
|
|
32804
|
+
return join3(homedir3(), ".config/Figma/settings.json");
|
|
32805
|
+
}
|
|
32806
|
+
var PLUGIN_ID = "mirra-figma-bridge";
|
|
32807
|
+
async function registerPluginWithFigma(pluginDir) {
|
|
32808
|
+
if (await isFigmaRunning()) {
|
|
32809
|
+
return { status: "skipped", reason: "Figma Desktop is running; quit it first" };
|
|
32810
|
+
}
|
|
32811
|
+
let raw;
|
|
32812
|
+
try {
|
|
32813
|
+
raw = await readFile3(FIGMA_SETTINGS, "utf8");
|
|
32814
|
+
} catch {
|
|
32815
|
+
return { status: "skipped", reason: `no settings file at ${FIGMA_SETTINGS}` };
|
|
32816
|
+
}
|
|
32817
|
+
try {
|
|
32818
|
+
const settings = JSON.parse(raw);
|
|
32819
|
+
const entries = settings["localFileExtensions"];
|
|
32820
|
+
if (!Array.isArray(entries)) {
|
|
32821
|
+
return { status: "skipped", reason: "unfamiliar settings format" };
|
|
32822
|
+
}
|
|
32823
|
+
const next = rebuild(entries, pluginDir);
|
|
32824
|
+
if (!next) return { status: "already" };
|
|
32825
|
+
settings["localFileExtensions"] = next;
|
|
32826
|
+
const temporary = join3(dirname2(FIGMA_SETTINGS), `.mirra-${process.pid}.json`);
|
|
32827
|
+
await writeFile2(temporary, JSON.stringify(settings, null, 2));
|
|
32828
|
+
await rename2(temporary, FIGMA_SETTINGS);
|
|
32829
|
+
return { status: "registered" };
|
|
32830
|
+
} catch (error62) {
|
|
32831
|
+
return { status: "skipped", reason: error62 instanceof Error ? error62.message : String(error62) };
|
|
32832
|
+
}
|
|
32833
|
+
}
|
|
32834
|
+
function rebuild(entries, pluginDir) {
|
|
32835
|
+
const mine = entries.filter((entry) => isMine(entry, entries));
|
|
32836
|
+
if (isCleanTrio(mine, pluginDir)) return null;
|
|
32837
|
+
const others = entries.filter((entry) => !mine.includes(entry));
|
|
32838
|
+
return [...others, ...entriesFor(pluginDir, nextId(others))];
|
|
32839
|
+
}
|
|
32840
|
+
function isCleanTrio(mine, pluginDir) {
|
|
32841
|
+
const manifest = mine.find((entry) => entry.fileMetadata.type === "manifest");
|
|
32842
|
+
if (mine.length !== 3 || !manifest || manifest.fileMetadata.type !== "manifest") return false;
|
|
32843
|
+
if (manifest.manifestPath !== join3(pluginDir, "manifest.json")) return false;
|
|
32844
|
+
const { codeFileId, uiFileIds } = manifest.fileMetadata;
|
|
32845
|
+
const paths = new Map(mine.map((entry) => [entry.id, entry.manifestPath]));
|
|
32846
|
+
return paths.get(codeFileId) === join3(pluginDir, "main.js") && uiFileIds.length === 1 && paths.get(uiFileIds[0]) === join3(pluginDir, "ui.html");
|
|
32847
|
+
}
|
|
32848
|
+
function isMine(entry, all) {
|
|
32849
|
+
if (entry.lastKnownPluginId === PLUGIN_ID) return true;
|
|
32850
|
+
if (entry.manifestPath.startsWith(`${MIRRA_HOME}${sep2}`)) return true;
|
|
32851
|
+
const metadata = entry.fileMetadata;
|
|
32852
|
+
return metadata.type !== "manifest" && !all.some((e) => e.id === metadata.manifestFileId);
|
|
32853
|
+
}
|
|
32854
|
+
async function isFigmaRunning() {
|
|
32855
|
+
const run = promisify(execFile);
|
|
32856
|
+
try {
|
|
32857
|
+
if (process.platform === "win32") {
|
|
32858
|
+
const { stdout } = await run("tasklist", ["/fi", "imagename eq Figma.exe", "/nh"]);
|
|
32859
|
+
return stdout.includes("Figma.exe");
|
|
32860
|
+
}
|
|
32861
|
+
await run("pgrep", ["-x", "Figma"]);
|
|
32862
|
+
return true;
|
|
32863
|
+
} catch {
|
|
32864
|
+
return false;
|
|
32865
|
+
}
|
|
32866
|
+
}
|
|
32867
|
+
function nextId(entries) {
|
|
32868
|
+
return entries.reduce((highest, entry) => Math.max(highest, entry.id), 0) + 1;
|
|
32869
|
+
}
|
|
32870
|
+
function entriesFor(pluginDir, first) {
|
|
32871
|
+
const [manifestId, codeId, uiId] = [first, first + 1, first + 2];
|
|
32872
|
+
return [
|
|
32873
|
+
{
|
|
32874
|
+
id: manifestId,
|
|
32875
|
+
manifestPath: join3(pluginDir, "manifest.json"),
|
|
32876
|
+
lastKnownName: "Mirra",
|
|
32877
|
+
lastKnownPluginId: PLUGIN_ID,
|
|
32878
|
+
cachedContainsWidget: false,
|
|
32879
|
+
fileMetadata: { type: "manifest", codeFileId: codeId, uiFileIds: [uiId] }
|
|
32880
|
+
},
|
|
32881
|
+
{
|
|
32882
|
+
id: codeId,
|
|
32883
|
+
manifestPath: join3(pluginDir, "main.js"),
|
|
32884
|
+
fileMetadata: { type: "code", manifestFileId: manifestId }
|
|
32885
|
+
},
|
|
32886
|
+
{
|
|
32887
|
+
id: uiId,
|
|
32888
|
+
manifestPath: join3(pluginDir, "ui.html"),
|
|
32889
|
+
fileMetadata: { type: "ui", manifestFileId: manifestId }
|
|
32890
|
+
}
|
|
32891
|
+
];
|
|
32892
|
+
}
|
|
32893
|
+
|
|
32770
32894
|
// ../../node_modules/.pnpm/zod@4.6.7/node_modules/zod/v3/helpers/util.js
|
|
32771
32895
|
var util;
|
|
32772
32896
|
(function(util2) {
|
|
@@ -40803,6 +40927,22 @@ var EMPTY_COMPLETION_RESULT = {
|
|
|
40803
40927
|
}
|
|
40804
40928
|
};
|
|
40805
40929
|
|
|
40930
|
+
// src/version.ts
|
|
40931
|
+
var SERVER_VERSION = "1.3.0";
|
|
40932
|
+
|
|
40933
|
+
// src/tools/annotations.ts
|
|
40934
|
+
var READS = { readOnlyHint: true, openWorldHint: false };
|
|
40935
|
+
var ADDS = {
|
|
40936
|
+
readOnlyHint: false,
|
|
40937
|
+
destructiveHint: false,
|
|
40938
|
+
openWorldHint: false
|
|
40939
|
+
};
|
|
40940
|
+
var CHANGES = {
|
|
40941
|
+
readOnlyHint: false,
|
|
40942
|
+
destructiveHint: true,
|
|
40943
|
+
openWorldHint: false
|
|
40944
|
+
};
|
|
40945
|
+
|
|
40806
40946
|
// src/errors.ts
|
|
40807
40947
|
var MirraError = class extends Error {
|
|
40808
40948
|
constructor(message, code, hint) {
|
|
@@ -40833,7 +40973,12 @@ value keyed by mode name sets one value per mode. Both halves are optional.`;
|
|
|
40833
40973
|
function registerDefine(server, bridge) {
|
|
40834
40974
|
server.registerTool(
|
|
40835
40975
|
"mirra_define_tokens",
|
|
40836
|
-
{
|
|
40976
|
+
{
|
|
40977
|
+
annotations: CHANGES,
|
|
40978
|
+
title: "Define Figma variables",
|
|
40979
|
+
description: DESCRIPTION,
|
|
40980
|
+
inputSchema: DefineParams.shape
|
|
40981
|
+
},
|
|
40837
40982
|
async (params) => {
|
|
40838
40983
|
try {
|
|
40839
40984
|
const result = DefineResult.parse(await bridge.request("define", params));
|
|
@@ -40859,7 +41004,12 @@ The body is an async function receiving \`figma\`; use it only for what the type
|
|
|
40859
41004
|
function registerExec(server, bridge) {
|
|
40860
41005
|
server.registerTool(
|
|
40861
41006
|
"mirra_exec",
|
|
40862
|
-
{
|
|
41007
|
+
{
|
|
41008
|
+
annotations: CHANGES,
|
|
41009
|
+
title: "Run Plugin API code",
|
|
41010
|
+
description: DESCRIPTION2,
|
|
41011
|
+
inputSchema: ExecParams.shape
|
|
41012
|
+
},
|
|
40863
41013
|
async (params) => {
|
|
40864
41014
|
try {
|
|
40865
41015
|
const result = ExecResult.parse(await bridge.request("exec", params));
|
|
@@ -40872,11 +41022,11 @@ function registerExec(server, bridge) {
|
|
|
40872
41022
|
}
|
|
40873
41023
|
|
|
40874
41024
|
// src/tools/export.ts
|
|
40875
|
-
import { mkdir as mkdir2, writeFile as
|
|
41025
|
+
import { mkdir as mkdir2, writeFile as writeFile3 } from "node:fs/promises";
|
|
40876
41026
|
import { resolve as resolve3 } from "node:path";
|
|
40877
41027
|
|
|
40878
41028
|
// src/tools/paths.ts
|
|
40879
|
-
import { resolve as resolve2, sep as
|
|
41029
|
+
import { resolve as resolve2, sep as sep3 } from "node:path";
|
|
40880
41030
|
function rootDirectory() {
|
|
40881
41031
|
const configured = process.env["MIRRA_ROOT"];
|
|
40882
41032
|
return configured ? resolve2(configured) : process.cwd();
|
|
@@ -40885,7 +41035,7 @@ function resolveInRoot(path) {
|
|
|
40885
41035
|
const root = rootDirectory();
|
|
40886
41036
|
const full = resolve2(root, path);
|
|
40887
41037
|
if (!process.env["MIRRA_ROOT"]) return full;
|
|
40888
|
-
if (full !== root && !full.startsWith(root +
|
|
41038
|
+
if (full !== root && !full.startsWith(root + sep3)) {
|
|
40889
41039
|
throw new MirraError(
|
|
40890
41040
|
`${path} is outside MIRRA_ROOT`,
|
|
40891
41041
|
"outside_root",
|
|
@@ -40902,6 +41052,7 @@ function registerExport(server, bridge) {
|
|
|
40902
41052
|
server.registerTool(
|
|
40903
41053
|
"mirra_export_assets",
|
|
40904
41054
|
{
|
|
41055
|
+
annotations: CHANGES,
|
|
40905
41056
|
title: "Export Figma layers to files",
|
|
40906
41057
|
description: DESCRIPTION3,
|
|
40907
41058
|
inputSchema: ExportParams.shape
|
|
@@ -40914,7 +41065,7 @@ function registerExport(server, bridge) {
|
|
|
40914
41065
|
const written = [];
|
|
40915
41066
|
for (const asset2 of result.assets) {
|
|
40916
41067
|
const file2 = resolve3(directory, `${slug(asset2.name)}.${asset2.extension}`);
|
|
40917
|
-
await
|
|
41068
|
+
await writeFile3(file2, Buffer.from(asset2.data, "base64"));
|
|
40918
41069
|
written.push(file2);
|
|
40919
41070
|
}
|
|
40920
41071
|
return text([`wrote ${written.length} file(s):`, ...written]);
|
|
@@ -40937,7 +41088,12 @@ tool works on the current page, so start here when the layer you want is elsewhe
|
|
|
40937
41088
|
function registerFind(server, bridge) {
|
|
40938
41089
|
server.registerTool(
|
|
40939
41090
|
"mirra_find",
|
|
40940
|
-
{
|
|
41091
|
+
{
|
|
41092
|
+
annotations: READS,
|
|
41093
|
+
title: "Find Figma layers",
|
|
41094
|
+
description: FIND,
|
|
41095
|
+
inputSchema: FindParams.shape
|
|
41096
|
+
},
|
|
40941
41097
|
async (params) => {
|
|
40942
41098
|
try {
|
|
40943
41099
|
const result = FindResult.parse(await bridge.request("find", params));
|
|
@@ -40955,7 +41111,13 @@ function registerFind(server, bridge) {
|
|
|
40955
41111
|
);
|
|
40956
41112
|
server.registerTool(
|
|
40957
41113
|
"mirra_pages",
|
|
40958
|
-
{
|
|
41114
|
+
{
|
|
41115
|
+
// Not read-only: it can add a page and change which one is current.
|
|
41116
|
+
annotations: ADDS,
|
|
41117
|
+
title: "List, add or switch Figma pages",
|
|
41118
|
+
description: PAGES,
|
|
41119
|
+
inputSchema: PagesParams.shape
|
|
41120
|
+
},
|
|
40959
41121
|
async (params) => {
|
|
40960
41122
|
try {
|
|
40961
41123
|
const result = PagesResult.parse(await bridge.request("pages", params));
|
|
@@ -40970,7 +41132,7 @@ function registerFind(server, bridge) {
|
|
|
40970
41132
|
}
|
|
40971
41133
|
|
|
40972
41134
|
// src/tools/assets.ts
|
|
40973
|
-
import { readFile as
|
|
41135
|
+
import { readFile as readFile4 } from "node:fs/promises";
|
|
40974
41136
|
import { extname } from "node:path";
|
|
40975
41137
|
var MIME = /* @__PURE__ */ new Map([
|
|
40976
41138
|
[".png", "image/png"],
|
|
@@ -41000,13 +41162,13 @@ function isPath(src) {
|
|
|
41000
41162
|
return true;
|
|
41001
41163
|
}
|
|
41002
41164
|
async function readSvg(path) {
|
|
41003
|
-
return
|
|
41165
|
+
return readFile4(resolveInRoot(path), "utf8");
|
|
41004
41166
|
}
|
|
41005
41167
|
async function readImage(path) {
|
|
41006
41168
|
const file2 = resolveInRoot(path);
|
|
41007
41169
|
const type = MIME.get(extname(file2).toLowerCase());
|
|
41008
41170
|
if (!type) throw new Error(`Unsupported image type: ${extname(file2) || file2}`);
|
|
41009
|
-
return `data:${type};base64,${(await
|
|
41171
|
+
return `data:${type};base64,${(await readFile4(file2)).toString("base64")}`;
|
|
41010
41172
|
}
|
|
41011
41173
|
|
|
41012
41174
|
// src/tools/patch.ts
|
|
@@ -41018,6 +41180,7 @@ function registerPatch(server, bridge) {
|
|
|
41018
41180
|
server.registerTool(
|
|
41019
41181
|
"mirra_patch",
|
|
41020
41182
|
{
|
|
41183
|
+
annotations: CHANGES,
|
|
41021
41184
|
title: "Patch existing Figma layers",
|
|
41022
41185
|
description: DESCRIPTION4,
|
|
41023
41186
|
inputSchema: PatchParams.shape
|
|
@@ -41042,17 +41205,25 @@ function registerPing(server, bridge) {
|
|
|
41042
41205
|
server.registerTool(
|
|
41043
41206
|
"mirra_ping",
|
|
41044
41207
|
{
|
|
41208
|
+
annotations: READS,
|
|
41045
41209
|
title: "Ping the Figma plugin",
|
|
41046
41210
|
description: "Check that the Mirra plugin is connected, and report the open file, current page and selection size."
|
|
41047
41211
|
},
|
|
41048
41212
|
async () => {
|
|
41049
41213
|
try {
|
|
41050
41214
|
const result = PingResult.parse(await bridge.request("ping"));
|
|
41215
|
+
const drifted = result.pluginVersion !== SERVER_VERSION;
|
|
41051
41216
|
return text([
|
|
41052
41217
|
`file: ${result.fileName}${result.fileKey ? ` (${result.fileKey})` : ""}`,
|
|
41053
41218
|
`page: ${result.currentPage}`,
|
|
41054
41219
|
`selection: ${result.selectionCount} node(s)`,
|
|
41055
|
-
`port: ${bridge.status.port}
|
|
41220
|
+
`port: ${bridge.status.port}`,
|
|
41221
|
+
`server ${SERVER_VERSION}, plugin ${result.pluginVersion}`,
|
|
41222
|
+
...drifted ? [
|
|
41223
|
+
"",
|
|
41224
|
+
"The two halves are different versions. Run `npx @ttsalpha/mirra init`, then run",
|
|
41225
|
+
"the plugin again in Figma so both come from the same release."
|
|
41226
|
+
] : []
|
|
41056
41227
|
]);
|
|
41057
41228
|
} catch (error62) {
|
|
41058
41229
|
return text([describeError(error62)], true);
|
|
@@ -41064,11 +41235,13 @@ function registerPing(server, bridge) {
|
|
|
41064
41235
|
// src/tools/read.ts
|
|
41065
41236
|
var DESCRIPTION5 = `Read layers out of the open Figma file as the same tree mirra_render takes,
|
|
41066
41237
|
so a design can be edited or turned into code without learning a second format.
|
|
41067
|
-
Reads the current selection unless a nodeId is given
|
|
41238
|
+
Reads the current selection unless a nodeId is given, and at most 200 nodes unless limit
|
|
41239
|
+
says otherwise, so a large design cannot fill the context in one call.`;
|
|
41068
41240
|
function registerRead(server, bridge) {
|
|
41069
41241
|
server.registerTool(
|
|
41070
41242
|
"mirra_read",
|
|
41071
41243
|
{
|
|
41244
|
+
annotations: READS,
|
|
41072
41245
|
title: "Read Figma layers as a UI tree",
|
|
41073
41246
|
description: DESCRIPTION5,
|
|
41074
41247
|
inputSchema: ReadParams.shape
|
|
@@ -41076,7 +41249,9 @@ function registerRead(server, bridge) {
|
|
|
41076
41249
|
async (params) => {
|
|
41077
41250
|
try {
|
|
41078
41251
|
const result = ReadResult.parse(await bridge.request("read", params));
|
|
41079
|
-
const note = result.skipped ? [
|
|
41252
|
+
const note = result.skipped ? [
|
|
41253
|
+
`${result.skipped} node(s) left out: unsupported type, or past the depth or node limit`
|
|
41254
|
+
] : [];
|
|
41080
41255
|
return text([...note, JSON.stringify(result.nodes, null, 2)]);
|
|
41081
41256
|
} catch (error62) {
|
|
41082
41257
|
return text([describeError(error62)], true);
|
|
@@ -41096,6 +41271,7 @@ function registerRender(server, bridge) {
|
|
|
41096
41271
|
server.registerTool(
|
|
41097
41272
|
"mirra_render",
|
|
41098
41273
|
{
|
|
41274
|
+
annotations: ADDS,
|
|
41099
41275
|
title: "Render a UI tree into Figma",
|
|
41100
41276
|
description: DESCRIPTION6,
|
|
41101
41277
|
inputSchema: RenderParams.shape
|
|
@@ -41119,11 +41295,13 @@ function registerRender(server, bridge) {
|
|
|
41119
41295
|
|
|
41120
41296
|
// src/tools/screenshot.ts
|
|
41121
41297
|
var DESCRIPTION7 = `Export a layer as a PNG and return the image, so the rendered result can be
|
|
41122
|
-
checked by eye and corrected with mirra_patch. Defaults to the current selection
|
|
41298
|
+
checked by eye and corrected with mirra_patch. Defaults to the current selection. The scale
|
|
41299
|
+
is lowered when it has to be, so the longest edge stays within maxEdge, 1200px by default.`;
|
|
41123
41300
|
function registerScreenshot(server, bridge) {
|
|
41124
41301
|
server.registerTool(
|
|
41125
41302
|
"mirra_screenshot",
|
|
41126
41303
|
{
|
|
41304
|
+
annotations: READS,
|
|
41127
41305
|
title: "Screenshot a Figma layer",
|
|
41128
41306
|
description: DESCRIPTION7,
|
|
41129
41307
|
inputSchema: ScreenshotParams.shape
|
|
@@ -41135,7 +41313,7 @@ function registerScreenshot(server, bridge) {
|
|
|
41135
41313
|
content: [
|
|
41136
41314
|
{
|
|
41137
41315
|
type: "text",
|
|
41138
|
-
text: `${shot.name} (${shot.width}x${shot.height}, ${shot.id})`
|
|
41316
|
+
text: `${shot.name} (${shot.width}x${shot.height}, ${shot.id})` + (shot.scale === 1 ? "" : ` at ${shot.scale.toFixed(2)}x`)
|
|
41139
41317
|
},
|
|
41140
41318
|
{ type: "image", data: shot.png, mimeType: "image/png" }
|
|
41141
41319
|
]
|
|
@@ -41154,7 +41332,7 @@ bound to the design system instead of to a hardcoded value.`;
|
|
|
41154
41332
|
function registerTokens(server, bridge) {
|
|
41155
41333
|
server.registerTool(
|
|
41156
41334
|
"mirra_tokens",
|
|
41157
|
-
{ title: "List design tokens", description: DESCRIPTION8 },
|
|
41335
|
+
{ annotations: READS, title: "List design tokens", description: DESCRIPTION8 },
|
|
41158
41336
|
async () => {
|
|
41159
41337
|
try {
|
|
41160
41338
|
const result = TokensResult.parse(await bridge.request("tokens"));
|
|
@@ -41167,9 +41345,8 @@ function registerTokens(server, bridge) {
|
|
|
41167
41345
|
}
|
|
41168
41346
|
|
|
41169
41347
|
// src/mcp.ts
|
|
41170
|
-
var SERVER_VERSION = "1.1.0";
|
|
41171
41348
|
function createMcpServer(bridge) {
|
|
41172
|
-
const server = new McpServer({ name: "
|
|
41349
|
+
const server = new McpServer({ name: "Mirra", version: SERVER_VERSION });
|
|
41173
41350
|
registerPing(server, bridge);
|
|
41174
41351
|
registerRender(server, bridge);
|
|
41175
41352
|
registerRead(server, bridge);
|
|
@@ -41203,6 +41380,15 @@ var import_websocket_server = __toESM(require_websocket_server(), 1);
|
|
|
41203
41380
|
// src/transport.ts
|
|
41204
41381
|
var HOSTS = ["127.0.0.1", "::1"];
|
|
41205
41382
|
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
41383
|
+
var TIMEOUT_MS = {
|
|
41384
|
+
ping: 5e3,
|
|
41385
|
+
pages: 1e4,
|
|
41386
|
+
render: 12e4,
|
|
41387
|
+
patch: 12e4,
|
|
41388
|
+
export: 12e4,
|
|
41389
|
+
define: 6e4,
|
|
41390
|
+
exec: 12e4
|
|
41391
|
+
};
|
|
41206
41392
|
function originAllowed(origin) {
|
|
41207
41393
|
if (!origin || origin === "null") return true;
|
|
41208
41394
|
if (process.env["MIRRA_ALLOW_ORIGIN"] === origin) return true;
|
|
@@ -41213,16 +41399,22 @@ function originAllowed(origin) {
|
|
|
41213
41399
|
return false;
|
|
41214
41400
|
}
|
|
41215
41401
|
}
|
|
41402
|
+
var UPDATE_HINT = "Run `npx @ttsalpha/mirra init` to install both halves from the same version, then run the plugin again in Figma.";
|
|
41216
41403
|
var OPEN_PLUGIN_HINT = "Open the file in Figma Desktop, then Plugins -> Development -> Mirra, and keep the panel open.";
|
|
41217
41404
|
var PluginBridge = class {
|
|
41218
41405
|
#servers = [];
|
|
41219
41406
|
#socket = null;
|
|
41220
41407
|
#hello = null;
|
|
41408
|
+
#protocolMismatch = null;
|
|
41221
41409
|
#port = null;
|
|
41222
41410
|
#pending = /* @__PURE__ */ new Map();
|
|
41223
41411
|
get status() {
|
|
41224
41412
|
return { port: this.#port, connected: this.#socket !== null, hello: this.#hello };
|
|
41225
41413
|
}
|
|
41414
|
+
/** Set while a connected plugin speaks a protocol this server does not. */
|
|
41415
|
+
get protocolMismatch() {
|
|
41416
|
+
return this.#protocolMismatch;
|
|
41417
|
+
}
|
|
41226
41418
|
async listen(preferredPort2) {
|
|
41227
41419
|
const first = preferredPort2 ?? DEFAULT_PORT;
|
|
41228
41420
|
const last = preferredPort2 ?? PORT_RANGE_END;
|
|
@@ -41265,11 +41457,14 @@ var PluginBridge = class {
|
|
|
41265
41457
|
this.#servers = [];
|
|
41266
41458
|
this.#port = null;
|
|
41267
41459
|
}
|
|
41268
|
-
async request(op, params, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
41460
|
+
async request(op, params, timeoutMs = TIMEOUT_MS[op] ?? DEFAULT_TIMEOUT_MS) {
|
|
41269
41461
|
const socket = this.#socket;
|
|
41270
41462
|
if (!socket) {
|
|
41271
41463
|
throw new MirraError("No Figma plugin is connected", "plugin_offline", OPEN_PLUGIN_HINT);
|
|
41272
41464
|
}
|
|
41465
|
+
if (this.#protocolMismatch && op !== "ping") {
|
|
41466
|
+
throw new MirraError(this.#protocolMismatch, "protocol_mismatch", UPDATE_HINT);
|
|
41467
|
+
}
|
|
41273
41468
|
const id = randomUUID();
|
|
41274
41469
|
const envelope = { id, op, params };
|
|
41275
41470
|
return new Promise((resolve4, reject) => {
|
|
@@ -41313,11 +41508,8 @@ var PluginBridge = class {
|
|
|
41313
41508
|
if ("type" in data) {
|
|
41314
41509
|
if (data.type === "log") return log(`[plugin:${data.level}] ${data.message}`);
|
|
41315
41510
|
this.#hello = data;
|
|
41316
|
-
|
|
41317
|
-
|
|
41318
|
-
`protocol mismatch: plugin speaks v${data.protocolVersion}, server speaks v${PROTOCOL_VERSION}. Rebuild and re-import the plugin.`
|
|
41319
|
-
);
|
|
41320
|
-
}
|
|
41511
|
+
this.#protocolMismatch = data.protocolVersion === PROTOCOL_VERSION ? null : `The plugin speaks protocol v${data.protocolVersion}, this server speaks v${PROTOCOL_VERSION}`;
|
|
41512
|
+
if (this.#protocolMismatch) log(this.#protocolMismatch);
|
|
41321
41513
|
return log(`plugin connected: ${data.fileName} (plugin v${data.pluginVersion})`);
|
|
41322
41514
|
}
|
|
41323
41515
|
const pending = this.#pending.get(data.id);
|
|
@@ -41377,7 +41569,7 @@ var USAGE = `mirra <command>
|
|
|
41377
41569
|
doctor Bind the plugin port and wait for the Figma plugin to connect
|
|
41378
41570
|
|
|
41379
41571
|
Flags:
|
|
41380
|
-
--no-reveal init: do not open the folder in Finder
|
|
41572
|
+
--no-reveal init: do not open the folder in Finder on a manual import
|
|
41381
41573
|
--no-config init: do not touch any client's configuration
|
|
41382
41574
|
|
|
41383
41575
|
Environment:
|
|
@@ -41433,14 +41625,26 @@ async function init() {
|
|
|
41433
41625
|
}
|
|
41434
41626
|
lines.push("", "A client that was already running has to be restarted to see the tools.", "");
|
|
41435
41627
|
}
|
|
41436
|
-
|
|
41437
|
-
|
|
41438
|
-
|
|
41439
|
-
|
|
41440
|
-
|
|
41441
|
-
|
|
41442
|
-
|
|
41443
|
-
|
|
41628
|
+
const figma = await registerPluginWithFigma(PLUGIN_HOME);
|
|
41629
|
+
const manual = figma.status === "skipped";
|
|
41630
|
+
if (manual) {
|
|
41631
|
+
lines.push(
|
|
41632
|
+
`Could not register the plugin with Figma Desktop: ${figma.reason}`,
|
|
41633
|
+
"Then, in Figma Desktop:",
|
|
41634
|
+
" 1. Plugins -> Development -> Import plugin from manifest...",
|
|
41635
|
+
` and choose ${manifest}`,
|
|
41636
|
+
" 2. Open a file, run Mirra, and leave the panel open",
|
|
41637
|
+
" 3. Ask the agent for mirra_ping; the panel badge should turn green"
|
|
41638
|
+
);
|
|
41639
|
+
} else {
|
|
41640
|
+
lines.push(
|
|
41641
|
+
figma.status === "registered" ? "Registered the plugin with Figma Desktop." : "Figma Desktop already knows the plugin.",
|
|
41642
|
+
"Then, in Figma Desktop:",
|
|
41643
|
+
" 1. Open a file, run Plugins -> Development -> Mirra, and leave the panel open",
|
|
41644
|
+
" 2. Ask the agent for mirra_ping; the panel badge should turn green"
|
|
41645
|
+
);
|
|
41646
|
+
}
|
|
41647
|
+
if (manual && !process.argv.includes("--no-reveal")) reveal(manifest);
|
|
41444
41648
|
console.log(lines.join("\n"));
|
|
41445
41649
|
}
|
|
41446
41650
|
async function doctor() {
|
package/dist/plugin/main.js
CHANGED
|
@@ -949,30 +949,39 @@
|
|
|
949
949
|
return node;
|
|
950
950
|
}
|
|
951
951
|
|
|
952
|
+
// src/version.ts
|
|
953
|
+
var PLUGIN_VERSION = "1.3.0";
|
|
954
|
+
|
|
952
955
|
// src/ops/ping.ts
|
|
953
956
|
function ping() {
|
|
954
957
|
return {
|
|
955
958
|
fileName: figma.root.name,
|
|
956
959
|
fileKey: readFileKey(),
|
|
957
960
|
currentPage: figma.currentPage.name,
|
|
958
|
-
selectionCount: figma.currentPage.selection.length
|
|
961
|
+
selectionCount: figma.currentPage.selection.length,
|
|
962
|
+
pluginVersion: PLUGIN_VERSION
|
|
959
963
|
};
|
|
960
964
|
}
|
|
961
965
|
|
|
962
966
|
// src/figma/read.ts
|
|
963
|
-
function readNode(node, depth, names = EMPTY_NAMES) {
|
|
964
|
-
const
|
|
965
|
-
const tree = toSpec(node, depth,
|
|
966
|
-
return { tree, skipped:
|
|
967
|
-
}
|
|
968
|
-
function toSpec(node, depth,
|
|
967
|
+
function readNode(node, depth, names = EMPTY_NAMES, limit = Number.MAX_SAFE_INTEGER) {
|
|
968
|
+
const budget = { count: 0, left: limit };
|
|
969
|
+
const tree = toSpec(node, depth, budget, names);
|
|
970
|
+
return { tree, skipped: budget.count };
|
|
971
|
+
}
|
|
972
|
+
function toSpec(node, depth, budget, names) {
|
|
973
|
+
if (budget.left <= 0) {
|
|
974
|
+
budget.count += 1;
|
|
975
|
+
return null;
|
|
976
|
+
}
|
|
977
|
+
budget.left -= 1;
|
|
969
978
|
const svg = node.getPluginData(SVG_FIELD);
|
|
970
979
|
if (svg) return { type: "svg", ...common(node), src: svg };
|
|
971
980
|
const src = node.getPluginData(SRC_FIELD);
|
|
972
981
|
if (src) return { type: "image", ...common(node), ...painted(node, names), src };
|
|
973
982
|
switch (node.type) {
|
|
974
983
|
case "FRAME":
|
|
975
|
-
return frameSpec(node, depth,
|
|
984
|
+
return frameSpec(node, depth, budget, names);
|
|
976
985
|
case "TEXT":
|
|
977
986
|
return textSpec(node, names);
|
|
978
987
|
case "RECTANGLE":
|
|
@@ -982,19 +991,19 @@
|
|
|
982
991
|
case "INSTANCE":
|
|
983
992
|
return instanceSpec(node);
|
|
984
993
|
default:
|
|
985
|
-
|
|
994
|
+
budget.count += 1;
|
|
986
995
|
return null;
|
|
987
996
|
}
|
|
988
997
|
}
|
|
989
|
-
function frameSpec(node, depth,
|
|
998
|
+
function frameSpec(node, depth, budget, names) {
|
|
990
999
|
const children = [];
|
|
991
1000
|
if (depth > 0) {
|
|
992
1001
|
for (const child of node.children) {
|
|
993
|
-
const spec = toSpec(child, depth - 1,
|
|
1002
|
+
const spec = toSpec(child, depth - 1, budget, names);
|
|
994
1003
|
if (spec) children.push(spec);
|
|
995
1004
|
}
|
|
996
1005
|
} else if (node.children.length) {
|
|
997
|
-
|
|
1006
|
+
budget.count += node.children.length;
|
|
998
1007
|
}
|
|
999
1008
|
return {
|
|
1000
1009
|
type: "frame",
|
|
@@ -1151,6 +1160,7 @@
|
|
|
1151
1160
|
|
|
1152
1161
|
// src/ops/read.ts
|
|
1153
1162
|
var DEFAULT_DEPTH = 6;
|
|
1163
|
+
var DEFAULT_LIMIT2 = 200;
|
|
1154
1164
|
async function read(params) {
|
|
1155
1165
|
const targets = params.nodeId ? [await resolve(params.nodeId)] : [...figma.currentPage.selection];
|
|
1156
1166
|
if (!targets.length) {
|
|
@@ -1160,10 +1170,14 @@
|
|
|
1160
1170
|
const nodes = [];
|
|
1161
1171
|
let skipped = 0;
|
|
1162
1172
|
for (const target of targets) {
|
|
1163
|
-
const outcome = readNode(
|
|
1173
|
+
const outcome = readNode(
|
|
1174
|
+
target,
|
|
1175
|
+
params.depth ?? DEFAULT_DEPTH,
|
|
1176
|
+
names,
|
|
1177
|
+
params.limit ?? DEFAULT_LIMIT2
|
|
1178
|
+
);
|
|
1164
1179
|
skipped += outcome.skipped;
|
|
1165
1180
|
if (outcome.tree) nodes.push({ id: target.id, tree: outcome.tree });
|
|
1166
|
-
else skipped += 1;
|
|
1167
1181
|
}
|
|
1168
1182
|
return { nodes, skipped };
|
|
1169
1183
|
}
|
|
@@ -1211,21 +1225,29 @@
|
|
|
1211
1225
|
}
|
|
1212
1226
|
|
|
1213
1227
|
// src/ops/screenshot.ts
|
|
1228
|
+
var DEFAULT_MAX_EDGE = 1200;
|
|
1229
|
+
function fittingScale(wanted, box, maxEdge) {
|
|
1230
|
+
const longest = Math.max(box.width, box.height) * wanted;
|
|
1231
|
+
if (longest <= maxEdge) return wanted;
|
|
1232
|
+
return Math.max(0.01, wanted * maxEdge / longest);
|
|
1233
|
+
}
|
|
1214
1234
|
async function screenshot(params) {
|
|
1215
1235
|
const node = params.target ? await new NodeIndex().resolve(params.target) : figma.currentPage.selection[0];
|
|
1216
1236
|
if (!node) {
|
|
1217
1237
|
throw new Error("Nothing is selected: select a layer in Figma, or pass a target");
|
|
1218
1238
|
}
|
|
1239
|
+
const box = node;
|
|
1240
|
+
const scale = fittingScale(params.scale ?? 1, box, params.maxEdge ?? DEFAULT_MAX_EDGE);
|
|
1219
1241
|
const bytes = await node.exportAsync({
|
|
1220
1242
|
format: "PNG",
|
|
1221
|
-
constraint: { type: "SCALE", value:
|
|
1243
|
+
constraint: { type: "SCALE", value: scale }
|
|
1222
1244
|
});
|
|
1223
|
-
const box = node;
|
|
1224
1245
|
return {
|
|
1225
1246
|
id: node.id,
|
|
1226
1247
|
name: node.name,
|
|
1227
|
-
width: Math.round(box.width),
|
|
1228
|
-
height: Math.round(box.height),
|
|
1248
|
+
width: Math.round(box.width * scale),
|
|
1249
|
+
height: Math.round(box.height * scale),
|
|
1250
|
+
scale,
|
|
1229
1251
|
png: figma.base64Encode(bytes)
|
|
1230
1252
|
};
|
|
1231
1253
|
}
|
|
@@ -1269,7 +1291,6 @@
|
|
|
1269
1291
|
}
|
|
1270
1292
|
|
|
1271
1293
|
// src/main.ts
|
|
1272
|
-
var PLUGIN_VERSION = "1.1.0";
|
|
1273
1294
|
figma.showUI(__html__, { width: 300, height: 300, themeColors: true });
|
|
1274
1295
|
var queue = Promise.resolve();
|
|
1275
1296
|
figma.ui.onmessage = (message2) => {
|