Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 9 additions & 17 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,16 +22,19 @@ PluginOS takes a fundamentally different approach:

### 1. Install for your agent

Pick whichever tool you're using. The Bridge Plugin (step 2) is the same for all of them.

**Claude Desktop (recommended for designers — one click):**
**Recommended — Claude Desktop (one click, no terminal):**

1. Download [`pluginos.dxt`](https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt) from the latest GitHub Release.
2. Double-click the downloaded file. Claude Desktop opens an install dialog.
3. Confirm. PluginOS appears in Claude Desktop's connector list.

No JSON editing, no terminal. Note: Claude.ai web is **not** supported — it cannot reach local MCP servers.

Using Cursor or Claude Code CLI? Open the section below for the right config.

<details>
<summary><strong>Other AI tools</strong> (Cursor, Claude Code CLI, manual config)</summary>

**Cursor (`.cursor/mcp.json`):**

```json
Expand All@@ -56,22 +59,11 @@ Then paste the Tier 1 rules below into `.cursorrules` so Cursor prefers PluginOS

Installs the MCP server registration and the `pluginos-figma` skill in one step.

**Manual (advanced — edit `claude_desktop_config.json` directly):**

```json
{
"mcpServers": {
"pluginos": {
"command": "npx",
"args": ["-y", "pluginos@latest"]
}
}
}
```
**Manual (other MCP-capable clients):** Use the same `mcpServers` JSON block as Cursor above and paste the Tier 1 rules below into your project's custom instructions.

Then paste the Tier 1 rules below into your project's custom instructions.
</details>

**Tier 1 rules (Cursor / Claude Desktop):**
**Tier 1 rules (paste into Cursor / Claude Desktop / other clients):**

```
When working with Figma, always use PluginOS tools exclusively:
Expand Down
2 changes: 2 additions & 0 deletions eslint.config.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@ export default tseslint.config(
"**/*.cjs",
"**/*.mjs",
"!eslint.config.js",
".claude/",
".worktrees/",
],
},
{
Expand Down
83 changes: 48 additions & 35 deletions packages/bridge-plugin/src/bootloader.html
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,12 +2,25 @@
<html>
<head>
<style>
/* Tokens aligned with ui.html — keep in sync. */
:root {
--cream: #FAF9F5;
--card-bg: #FFFFFE;
--card-border: #EDE6D6;
--text-primary: #2D2A22;
--text-secondary: #5C5346;
--text-muted: #7A7164;
--accent: #D97757;
--radius: 14px;
--shadow: 0 1px 2px rgba(60, 40, 20, 0.04);
--font-stack: "Styrene B", ui-sans-serif, system-ui, -apple-system, "Helvetica Neue", sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Inter, system-ui, sans-serif;
font-size: 11px;
background: var(--figma-color-bg);
color: var(--figma-color-text);
font-family: var(--font-stack);
font-size: 12.5px;
background: var(--cream);
color: var(--text-primary);
display: flex;
flex-direction: column;
height: 100vh;
Expand All@@ -21,25 +34,25 @@
.dot-green { background: #1bc47d; }
.dot-yellow { background: #f5a623; animation: pulse 1.2s ease-in-out infinite; }
@keyframes pulse { 0%,100%{opacity:1}50%{opacity:.35} }
.status-text { font-weight: 600; font-size: 11px; }
.status-sub { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-top: 1px; }
.divider { height: 1px; background: var(--figma-color-border, #e5e5e5); }
.setup-section { padding: 10px 12px 0; display: none; }
.setup-cards { display: flex; flex-direction: column; gap: 8px; }
.card { padding: 10px 12px; background: var(--figma-color-bg-secondary, #f5f5f5); border: 1px solid var(--figma-color-border, #e5e5e5); border-radius: 6px; }
.card-title { font-size: 11px; font-weight: 600; color: var(--figma-color-text); margin-bottom: 4px; }
.card-desc { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-bottom: 8px; }
.card-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.btn { font-family: inherit; font-size: 10px; padding: 4px 10px; border: 1px solid var(--figma-color-border, #e5e5e5); border-radius: 4px; background: var(--figma-color-bg, #fff); color: var(--figma-color-text, #111); cursor: pointer; transition: background 0.15s; }
.btn:hover { background: var(--figma-color-bg-hover, #ebebeb); }
.btn.copied { color: #1bc47d; border-color: #1bc47d; }
.btn-primary { background: var(--figma-color-text, #111); color: var(--figma-color-bg, #fff); border-color: var(--figma-color-text, #111); text-decoration: none; display: inline-block; }
.status-text { font-weight: 600; font-size: 12px; }
.status-sub { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
.divider { height: 1px; background: var(--card-border); }
.setup-section { padding: 12px; display: none; }
.setup-cards { display: flex; flex-direction: column; gap: 12px; }
.card { padding: 16px; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow); }
.card-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.card-desc { font-size: 12.5px; color: var(--text-secondary); line-height: 1.4; margin-bottom: 12px; }
.card-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.btn { font: inherit; font-size: 12px; font-weight: 500; padding: 6px 12px; border: 1px solid var(--card-border); border-radius: 8px; background: transparent; color: var(--text-primary); cursor: pointer; transition: background 0.15s; }
.btn:hover { background: var(--cream); }
.btn.copied { background: var(--accent); color: #FFF; border-color: var(--accent); }
.btn-primary { background: var(--text-primary); color: var(--card-bg); border-color: var(--text-primary); text-decoration: none; display: inline-block; }
.btn-primary:hover { opacity: 0.9; }
a.btn { text-decoration: none; }
.progress-section { padding: 8px 12px 10px; display: none; }
.progress-bar { height: 2px; background: var(--figma-color-border, #e5e5e5); border-radius: 1px; overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: #f5a623; border-radius: 1px; }
.countdown-text { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-top: 5px; text-align: center; }
.progress-bar { height: 2px; background: var(--card-border); border-radius: 1px; overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: var(--accent); border-radius: 1px; }
.countdown-text { font-size: 11px; color: var(--text-muted); margin-top: 5px; text-align: center; }
</style>
</head>
<body>
Expand DownExpand Up@@ -130,21 +143,27 @@
}, 1000);
}

var MCP_CONFIG_JSON = JSON.stringify({ mcpServers: { pluginos: { command: "npx", args: ["-y", "pluginos@0.4.2"] } } }, null, 2);
var MCP_CONFIG_JSON = JSON.stringify({ mcpServers: { pluginos: { command: "npx", args: ["-y", "pluginos@<%= MCP_VERSION %>"] } } }, null, 2);
var TIER_1_RULES = "When working with Figma, always use PluginOS tools exclusively:\n- Use `list_operations` (pluginos) first to discover available Figma operations.\n- Use `run_operation` (pluginos) to execute them.\n- Use `execute_figma` (pluginos) only for one-off custom logic not covered by built-in ops.\n- Do NOT use `mcp__Figma__*` tools — they bypass the plugin and return raw, token-heavy data. PluginOS returns pre-summarized, structured results at ~230 tokens/call.\n- If PluginOS returns \"No plugin connected\", open the PluginOS Bridge plugin in Figma before retrying.\n\nAudit/lint/check operations default to `scope: \"selection\"`. Pass `scope: \"page\"` explicitly (and `confirm: true` for pages over 500 nodes) to scan the whole page. Responses carry `_hint` and `_next_hints` fields — respect them when deciding what to do next.";
var INSTALL_COMMAND = "/plugin marketplace add github:LSDimi/pluginos\n/plugin install pluginos";
var DXT_URL = "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt";
var DXT_URL = "<%= DXT_URL %>";

function flashButton(btn, label) {
if (btn.classList.contains("copied")) return;
var orig = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}
Comment on lines +151 to +160

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The flashButton function has a race condition if triggered multiple times on the same button within the 2.5s window. The orig variable will capture the temporary label (e.g., "Copied!") on subsequent clicks, causing the button text to become stuck on that label once the timeouts resolve. Adding a guard clause to ignore clicks while the button is already in the "copied" state prevents this.

Suggested change
function flashButton(btn, label) {
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}
function flashButton(btn, label) {
if (btn.classList.contains("copied")) return;
var orig = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 97ab1ae — added a guard so a re-trigger inside the 2.5s window is a no-op (if (btn.classList.contains('copied')) return;). Same guard added to flashCopied in ui-entry.ts for symmetry.


function wireCopy(btnId, text) {
var btn = document.getElementById(btnId);
if (!btn) return;
btn.addEventListener("click", function() {
function onOk() {
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = "Copied!";
setTimeout(function() { btn.textContent = orig; btn.classList.remove("copied"); }, 2500);
}
function onOk() { flashButton(btn, "Copied!"); }
function onFail() { btn.textContent = "Copy failed"; }
function fallback() {
try {
Expand All@@ -170,13 +189,7 @@
{ pluginMessage: { type: "open-external", url: DXT_URL } },
"*"
);
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = "✓ Opening in browser…";
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
flashButton(btn, "✓ Opening in browser…");
});
})();
wireCopy("btn-copy-install", INSTALL_COMMAND);
Expand Down
3 changes: 3 additions & 0 deletions packages/bridge-plugin/src/constants.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
{
"DXT_URL": "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt"
}
9 changes: 6 additions & 3 deletions packages/bridge-plugin/src/ui-entry.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
import { DXT_URL } from "./constants.json";

declare const __MCP_VERSION__: string;

const TIER_1_RULES = `When working with Figma, always use PluginOS tools exclusively:
- Use \`list_operations\` (pluginos) first to discover available Figma operations.
- Use \`run_operation\` (pluginos) to execute them.
Expand All@@ -11,16 +15,14 @@ const MCP_CONFIG_JSON = `{
"mcpServers": {
"pluginos": {
"command": "npx",
"args": ["-y", "pluginos@0.4.2"]
"args": ["-y", "pluginos@${__MCP_VERSION__}"]
}
}
}`;

const INSTALL_COMMAND = `/plugin marketplace add github:LSDimi/pluginos
/plugin install pluginos`;

const DXT_URL = "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt";

const PORT_MIN = 9500;
const PORT_MAX = 9510;
const RECONNECT_DELAY = 3000;
Expand DownExpand Up@@ -49,6 +51,7 @@ function showView(view: "setup" | "connected") {
}

function flashCopied(btn: HTMLButtonElement, label = "✓ Copied") {
if (btn.classList.contains("copied")) return;
const original = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
Expand Down
1 change: 1 addition & 0 deletions packages/bridge-plugin/tsconfig.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
"rootDir": "src",
"outDir": "dist",
"module": "ESNext",
"resolveJsonModule": true,
"lib": ["ES2017", "DOM"],
"typeRoots": ["../../node_modules/@types", "../../node_modules/@figma"],
"types": ["plugin-typings"]
Expand Down
8 changes: 8 additions & 0 deletions packages/bridge-plugin/webpack.config.js
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
const path = require("path");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const HtmlInlineScriptPlugin = require("html-inline-script-webpack-plugin");

const { DXT_URL } = require("./src/constants.json");
const { version: MCP_VERSION } = require("../mcp-server/package.json");

const noModernSyntax = {
arrowFunction: true,
const: true,
Expand DownExpand Up@@ -41,6 +45,9 @@ module.exports = (env, argv) => [
resolve: { extensions: [".ts", ".js"] },
devtool: false,
plugins: [
new webpack.DefinePlugin({
__MCP_VERSION__: JSON.stringify(MCP_VERSION),
}),
new HtmlWebpackPlugin({
template: "./src/ui.html",
filename: "ui.html",
Expand All@@ -60,6 +67,7 @@ module.exports = (env, argv) => [
template: "./src/bootloader.html",
filename: "bootloader.html",
inject: false,
templateParameters: { DXT_URL, MCP_VERSION },
}),
],
mode: argv.mode || "production",
Expand Down
11 changes: 6 additions & 5 deletions packages/claude-plugin/skills/pluginos-figma/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,17 +15,18 @@ You have `pluginos` MCP tools available AND the user is doing Figma work: design

## Tool routing (iron rule)

Always try `pluginos.*` first:
**Step 0: always call `pluginos.get_status` first** to confirm the bridge plugin is connected before any Figma work. If it returns disconnected, follow the Connection troubleshooting steps below — do NOT silently fall back to `mcp__Figma__*`.

Then prefer `pluginos.*`:

- `pluginos.list_operations` — discover what's available (only needed once per session; see quick-list below).
- `pluginos.run_operation` — execute a registered operation.
- `pluginos.execute_figma` — arbitrary plugin JS, only when no registered op fits.

Fall back to `mcp__Figma__*` tools ONLY when:
**Avoid `mcp__Figma__*` tools** (`get_design_context`, `get_variable_defs`, `get_screenshot`, etc.). They bypass the plugin and return raw, token-heavy node dumps; PluginOS returns pre-summarized, structured results at ~230 tokens/call. The only acceptable fallbacks to `mcp__Figma__*` are:

- PluginOS explicitly returns `no_operation_available`.
- The user requests Figma Code Connect mapping / `get_design_context`-style code generation.
- PluginOS is unreachable (see Connection troubleshooting below).
- PluginOS explicitly returns `no_operation_available` AND `execute_figma` cannot reasonably do the job.
- The user explicitly requests Figma Code Connect mapping or `get_design_context`-style code generation.

Never mix: one-shot a Figma task with either PluginOS or Figma MCP, don't interleave.

Expand Down
26 changes: 6 additions & 20 deletions scripts/bump-lockstep.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,9 +2,12 @@
/**
* Postversion hook for packages/mcp-server. When `npm version` bumps the
* MCP server, propagate the same version string to every file that pins the
* pluginos version: peer package.json manifests, the DXT manifest (both the
* top-level `version` and its `server.mcp_config.args` pin), and the
* hardcoded npx args inside the Figma plugin's UI sources.
* pluginos version: peer package.json manifests and the DXT manifest (both
* the top-level `version` and its `server.mcp_config.args` pin).
*
* Note: the Figma plugin UI (`bridge-plugin/src/ui-entry.ts` and
* `bootloader.html`) reads `mcp-server/package.json#version` at webpack
* build time, so no source-file rewrite is needed there.
*/
const fs = require("node:fs");
const path = require("node:path");
Expand DownExpand Up@@ -40,20 +43,3 @@ if (Array.isArray(dxt?.server?.mcp_config?.args)) {
}
fs.writeFileSync(dxtAbs, JSON.stringify(dxt, null, 2) + "\n");
console.log(`Bumped ${dxtManifestRel} → ${newVersion}`);

// Sources that hardcode `pluginos@<version>` in copy-paste MCP config snippets.
// These use a regex swap so formatting is preserved.
const sourceTargets = [
"packages/bridge-plugin/src/ui-entry.ts",
"packages/bridge-plugin/src/bootloader.html",
];

for (const rel of sourceTargets) {
const abs = path.join(repoRoot, rel);
const before = fs.readFileSync(abs, "utf8");
const after = before.replace(/pluginos@\d+\.\d+\.\d+/g, `pluginos@${newVersion}`);
if (after !== before) {
fs.writeFileSync(abs, after);
console.log(`Bumped ${rel} → pluginos@${newVersion}`);
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 9 additions & 17 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,16 +22,19 @@ PluginOS takes a fundamentally different approach:

### 1. Install for your agent

Pick whichever tool you're using. The Bridge Plugin (step 2) is the same for all of them.

**Claude Desktop (recommended for designers — one click):**
**Recommended — Claude Desktop (one click, no terminal):**

1. Download [`pluginos.dxt`](https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt) from the latest GitHub Release.
2. Double-click the downloaded file. Claude Desktop opens an install dialog.
3. Confirm. PluginOS appears in Claude Desktop's connector list.

No JSON editing, no terminal. Note: Claude.ai web is **not** supported — it cannot reach local MCP servers.

Using Cursor or Claude Code CLI? Open the section below for the right config.

<details>
<summary><strong>Other AI tools</strong> (Cursor, Claude Code CLI, manual config)</summary>

**Cursor (`.cursor/mcp.json`):**

```json
Expand All@@ -56,22 +59,11 @@ Then paste the Tier 1 rules below into `.cursorrules` so Cursor prefers PluginOS

Installs the MCP server registration and the `pluginos-figma` skill in one step.

**Manual (advanced — edit `claude_desktop_config.json` directly):**

```json
{
"mcpServers": {
"pluginos": {
"command": "npx",
"args": ["-y", "pluginos@latest"]
}
}
}
```
**Manual (other MCP-capable clients):** Use the same `mcpServers` JSON block as Cursor above and paste the Tier 1 rules below into your project's custom instructions.

Then paste the Tier 1 rules below into your project's custom instructions.
</details>

**Tier 1 rules (Cursor / Claude Desktop):**
**Tier 1 rules (paste into Cursor / Claude Desktop / other clients):**

```
When working with Figma, always use PluginOS tools exclusively:
Expand Down
2 changes: 2 additions & 0 deletions eslint.config.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@ export default tseslint.config(
"**/*.cjs",
"**/*.mjs",
"!eslint.config.js",
".claude/",
".worktrees/",
],
},
{
Expand Down
83 changes: 48 additions & 35 deletions packages/bridge-plugin/src/bootloader.html
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,12 +2,25 @@
<html>
<head>
<style>
/* Tokens aligned with ui.html — keep in sync. */
:root {
--cream: #FAF9F5;
--card-bg: #FFFFFE;
--card-border: #EDE6D6;
--text-primary: #2D2A22;
--text-secondary: #5C5346;
--text-muted: #7A7164;
--accent: #D97757;
--radius: 14px;
--shadow: 0 1px 2px rgba(60, 40, 20, 0.04);
--font-stack: "Styrene B", ui-sans-serif, system-ui, -apple-system, "Helvetica Neue", sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Inter, system-ui, sans-serif;
font-size: 11px;
background: var(--figma-color-bg);
color: var(--figma-color-text);
font-family: var(--font-stack);
font-size: 12.5px;
background: var(--cream);
color: var(--text-primary);
display: flex;
flex-direction: column;
height: 100vh;
Expand All@@ -21,25 +34,25 @@
.dot-green { background: #1bc47d; }
.dot-yellow { background: #f5a623; animation: pulse 1.2s ease-in-out infinite; }
@keyframes pulse { 0%,100%{opacity:1}50%{opacity:.35} }
.status-text { font-weight: 600; font-size: 11px; }
.status-sub { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-top: 1px; }
.divider { height: 1px; background: var(--figma-color-border, #e5e5e5); }
.setup-section { padding: 10px 12px 0; display: none; }
.setup-cards { display: flex; flex-direction: column; gap: 8px; }
.card { padding: 10px 12px; background: var(--figma-color-bg-secondary, #f5f5f5); border: 1px solid var(--figma-color-border, #e5e5e5); border-radius: 6px; }
.card-title { font-size: 11px; font-weight: 600; color: var(--figma-color-text); margin-bottom: 4px; }
.card-desc { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-bottom: 8px; }
.card-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.btn { font-family: inherit; font-size: 10px; padding: 4px 10px; border: 1px solid var(--figma-color-border, #e5e5e5); border-radius: 4px; background: var(--figma-color-bg, #fff); color: var(--figma-color-text, #111); cursor: pointer; transition: background 0.15s; }
.btn:hover { background: var(--figma-color-bg-hover, #ebebeb); }
.btn.copied { color: #1bc47d; border-color: #1bc47d; }
.btn-primary { background: var(--figma-color-text, #111); color: var(--figma-color-bg, #fff); border-color: var(--figma-color-text, #111); text-decoration: none; display: inline-block; }
.status-text { font-weight: 600; font-size: 12px; }
.status-sub { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
.divider { height: 1px; background: var(--card-border); }
.setup-section { padding: 12px; display: none; }
.setup-cards { display: flex; flex-direction: column; gap: 12px; }
.card { padding: 16px; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow); }
.card-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.card-desc { font-size: 12.5px; color: var(--text-secondary); line-height: 1.4; margin-bottom: 12px; }
.card-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.btn { font: inherit; font-size: 12px; font-weight: 500; padding: 6px 12px; border: 1px solid var(--card-border); border-radius: 8px; background: transparent; color: var(--text-primary); cursor: pointer; transition: background 0.15s; }
.btn:hover { background: var(--cream); }
.btn.copied { background: var(--accent); color: #FFF; border-color: var(--accent); }
.btn-primary { background: var(--text-primary); color: var(--card-bg); border-color: var(--text-primary); text-decoration: none; display: inline-block; }
.btn-primary:hover { opacity: 0.9; }
a.btn { text-decoration: none; }
.progress-section { padding: 8px 12px 10px; display: none; }
.progress-bar { height: 2px; background: var(--figma-color-border, #e5e5e5); border-radius: 1px; overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: #f5a623; border-radius: 1px; }
.countdown-text { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-top: 5px; text-align: center; }
.progress-bar { height: 2px; background: var(--card-border); border-radius: 1px; overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: var(--accent); border-radius: 1px; }
.countdown-text { font-size: 11px; color: var(--text-muted); margin-top: 5px; text-align: center; }
</style>
</head>
<body>
Expand DownExpand Up@@ -130,21 +143,27 @@
}, 1000);
}

var MCP_CONFIG_JSON = JSON.stringify({ mcpServers: { pluginos: { command: "npx", args: ["-y", "pluginos@0.4.2"] } } }, null, 2);
var MCP_CONFIG_JSON = JSON.stringify({ mcpServers: { pluginos: { command: "npx", args: ["-y", "pluginos@<%= MCP_VERSION %>"] } } }, null, 2);
var TIER_1_RULES = "When working with Figma, always use PluginOS tools exclusively:\n- Use `list_operations` (pluginos) first to discover available Figma operations.\n- Use `run_operation` (pluginos) to execute them.\n- Use `execute_figma` (pluginos) only for one-off custom logic not covered by built-in ops.\n- Do NOT use `mcp__Figma__*` tools — they bypass the plugin and return raw, token-heavy data. PluginOS returns pre-summarized, structured results at ~230 tokens/call.\n- If PluginOS returns \"No plugin connected\", open the PluginOS Bridge plugin in Figma before retrying.\n\nAudit/lint/check operations default to `scope: \"selection\"`. Pass `scope: \"page\"` explicitly (and `confirm: true` for pages over 500 nodes) to scan the whole page. Responses carry `_hint` and `_next_hints` fields — respect them when deciding what to do next.";
var INSTALL_COMMAND = "/plugin marketplace add github:LSDimi/pluginos\n/plugin install pluginos";
var DXT_URL = "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt";
var DXT_URL = "<%= DXT_URL %>";

function flashButton(btn, label) {
if (btn.classList.contains("copied")) return;
var orig = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}
Comment on lines +151 to +160

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The flashButton function has a race condition if triggered multiple times on the same button within the 2.5s window. The orig variable will capture the temporary label (e.g., "Copied!") on subsequent clicks, causing the button text to become stuck on that label once the timeouts resolve. Adding a guard clause to ignore clicks while the button is already in the "copied" state prevents this.

Suggested change
function flashButton(btn, label) {
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}
function flashButton(btn, label) {
if (btn.classList.contains("copied")) return;
var orig = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 97ab1ae — added a guard so a re-trigger inside the 2.5s window is a no-op (if (btn.classList.contains('copied')) return;). Same guard added to flashCopied in ui-entry.ts for symmetry.


function wireCopy(btnId, text) {
var btn = document.getElementById(btnId);
if (!btn) return;
btn.addEventListener("click", function() {
function onOk() {
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = "Copied!";
setTimeout(function() { btn.textContent = orig; btn.classList.remove("copied"); }, 2500);
}
function onOk() { flashButton(btn, "Copied!"); }
function onFail() { btn.textContent = "Copy failed"; }
function fallback() {
try {
Expand All@@ -170,13 +189,7 @@
{ pluginMessage: { type: "open-external", url: DXT_URL } },
"*"
);
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = "✓ Opening in browser…";
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
flashButton(btn, "✓ Opening in browser…");
});
})();
wireCopy("btn-copy-install", INSTALL_COMMAND);
Expand Down
3 changes: 3 additions & 0 deletions packages/bridge-plugin/src/constants.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
{
"DXT_URL": "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt"
}
9 changes: 6 additions & 3 deletions packages/bridge-plugin/src/ui-entry.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
import { DXT_URL } from "./constants.json";

declare const __MCP_VERSION__: string;

const TIER_1_RULES = `When working with Figma, always use PluginOS tools exclusively:
- Use \`list_operations\` (pluginos) first to discover available Figma operations.
- Use \`run_operation\` (pluginos) to execute them.
Expand All@@ -11,16 +15,14 @@ const MCP_CONFIG_JSON = `{
"mcpServers": {
"pluginos": {
"command": "npx",
"args": ["-y", "pluginos@0.4.2"]
"args": ["-y", "pluginos@${__MCP_VERSION__}"]
}
}
}`;

const INSTALL_COMMAND = `/plugin marketplace add github:LSDimi/pluginos
/plugin install pluginos`;

const DXT_URL = "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt";

const PORT_MIN = 9500;
const PORT_MAX = 9510;
const RECONNECT_DELAY = 3000;
Expand DownExpand Up@@ -49,6 +51,7 @@ function showView(view: "setup" | "connected") {
}

function flashCopied(btn: HTMLButtonElement, label = "✓ Copied") {
if (btn.classList.contains("copied")) return;
const original = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
Expand Down
1 change: 1 addition & 0 deletions packages/bridge-plugin/tsconfig.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
"rootDir": "src",
"outDir": "dist",
"module": "ESNext",
"resolveJsonModule": true,
"lib": ["ES2017", "DOM"],
"typeRoots": ["../../node_modules/@types", "../../node_modules/@figma"],
"types": ["plugin-typings"]
Expand Down
8 changes: 8 additions & 0 deletions packages/bridge-plugin/webpack.config.js
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
const path = require("path");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const HtmlInlineScriptPlugin = require("html-inline-script-webpack-plugin");

const { DXT_URL } = require("./src/constants.json");
const { version: MCP_VERSION } = require("../mcp-server/package.json");

const noModernSyntax = {
arrowFunction: true,
const: true,
Expand DownExpand Up@@ -41,6 +45,9 @@ module.exports = (env, argv) => [
resolve: { extensions: [".ts", ".js"] },
devtool: false,
plugins: [
new webpack.DefinePlugin({
__MCP_VERSION__: JSON.stringify(MCP_VERSION),
}),
new HtmlWebpackPlugin({
template: "./src/ui.html",
filename: "ui.html",
Expand All@@ -60,6 +67,7 @@ module.exports = (env, argv) => [
template: "./src/bootloader.html",
filename: "bootloader.html",
inject: false,
templateParameters: { DXT_URL, MCP_VERSION },
}),
],
mode: argv.mode || "production",
Expand Down
11 changes: 6 additions & 5 deletions packages/claude-plugin/skills/pluginos-figma/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,17 +15,18 @@ You have `pluginos` MCP tools available AND the user is doing Figma work: design

## Tool routing (iron rule)

Always try `pluginos.*` first:
**Step 0: always call `pluginos.get_status` first** to confirm the bridge plugin is connected before any Figma work. If it returns disconnected, follow the Connection troubleshooting steps below — do NOT silently fall back to `mcp__Figma__*`.

Then prefer `pluginos.*`:

- `pluginos.list_operations` — discover what's available (only needed once per session; see quick-list below).
- `pluginos.run_operation` — execute a registered operation.
- `pluginos.execute_figma` — arbitrary plugin JS, only when no registered op fits.

Fall back to `mcp__Figma__*` tools ONLY when:
**Avoid `mcp__Figma__*` tools** (`get_design_context`, `get_variable_defs`, `get_screenshot`, etc.). They bypass the plugin and return raw, token-heavy node dumps; PluginOS returns pre-summarized, structured results at ~230 tokens/call. The only acceptable fallbacks to `mcp__Figma__*` are:

- PluginOS explicitly returns `no_operation_available`.
- The user requests Figma Code Connect mapping / `get_design_context`-style code generation.
- PluginOS is unreachable (see Connection troubleshooting below).
- PluginOS explicitly returns `no_operation_available` AND `execute_figma` cannot reasonably do the job.
- The user explicitly requests Figma Code Connect mapping or `get_design_context`-style code generation.

Never mix: one-shot a Figma task with either PluginOS or Figma MCP, don't interleave.

Expand Down
26 changes: 6 additions & 20 deletions scripts/bump-lockstep.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,9 +2,12 @@
/**
* Postversion hook for packages/mcp-server. When `npm version` bumps the
* MCP server, propagate the same version string to every file that pins the
* pluginos version: peer package.json manifests, the DXT manifest (both the
* top-level `version` and its `server.mcp_config.args` pin), and the
* hardcoded npx args inside the Figma plugin's UI sources.
* pluginos version: peer package.json manifests and the DXT manifest (both
* the top-level `version` and its `server.mcp_config.args` pin).
*
* Note: the Figma plugin UI (`bridge-plugin/src/ui-entry.ts` and
* `bootloader.html`) reads `mcp-server/package.json#version` at webpack
* build time, so no source-file rewrite is needed there.
*/
const fs = require("node:fs");
const path = require("node:path");
Expand DownExpand Up@@ -40,20 +43,3 @@ if (Array.isArray(dxt?.server?.mcp_config?.args)) {
}
fs.writeFileSync(dxtAbs, JSON.stringify(dxt, null, 2) + "\n");
console.log(`Bumped ${dxtManifestRel} → ${newVersion}`);

// Sources that hardcode `pluginos@<version>` in copy-paste MCP config snippets.
// These use a regex swap so formatting is preserved.
const sourceTargets = [
"packages/bridge-plugin/src/ui-entry.ts",
"packages/bridge-plugin/src/bootloader.html",
];

for (const rel of sourceTargets) {
const abs = path.join(repoRoot, rel);
const before = fs.readFileSync(abs, "utf8");
const after = before.replace(/pluginos@\d+\.\d+\.\d+/g, `pluginos@${newVersion}`);
if (after !== before) {
fs.writeFileSync(abs, after);
console.log(`Bumped ${rel} → pluginos@${newVersion}`);
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 9 additions & 17 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,16 +22,19 @@ PluginOS takes a fundamentally different approach:

### 1. Install for your agent

Pick whichever tool you're using. The Bridge Plugin (step 2) is the same for all of them.

**Claude Desktop (recommended for designers — one click):**
**Recommended — Claude Desktop (one click, no terminal):**

1. Download [`pluginos.dxt`](https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt) from the latest GitHub Release.
2. Double-click the downloaded file. Claude Desktop opens an install dialog.
3. Confirm. PluginOS appears in Claude Desktop's connector list.

No JSON editing, no terminal. Note: Claude.ai web is **not** supported — it cannot reach local MCP servers.

Using Cursor or Claude Code CLI? Open the section below for the right config.

<details>
<summary><strong>Other AI tools</strong> (Cursor, Claude Code CLI, manual config)</summary>

**Cursor (`.cursor/mcp.json`):**

```json
Expand All@@ -56,22 +59,11 @@ Then paste the Tier 1 rules below into `.cursorrules` so Cursor prefers PluginOS

Installs the MCP server registration and the `pluginos-figma` skill in one step.

**Manual (advanced — edit `claude_desktop_config.json` directly):**

```json
{
"mcpServers": {
"pluginos": {
"command": "npx",
"args": ["-y", "pluginos@latest"]
}
}
}
```
**Manual (other MCP-capable clients):** Use the same `mcpServers` JSON block as Cursor above and paste the Tier 1 rules below into your project's custom instructions.

Then paste the Tier 1 rules below into your project's custom instructions.
</details>

**Tier 1 rules (Cursor / Claude Desktop):**
**Tier 1 rules (paste into Cursor / Claude Desktop / other clients):**

```
When working with Figma, always use PluginOS tools exclusively:
Expand Down
2 changes: 2 additions & 0 deletions eslint.config.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@ export default tseslint.config(
"**/*.cjs",
"**/*.mjs",
"!eslint.config.js",
".claude/",
".worktrees/",
],
},
{
Expand Down
83 changes: 48 additions & 35 deletions packages/bridge-plugin/src/bootloader.html
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,12 +2,25 @@
<html>
<head>
<style>
/* Tokens aligned with ui.html — keep in sync. */
:root {
--cream: #FAF9F5;
--card-bg: #FFFFFE;
--card-border: #EDE6D6;
--text-primary: #2D2A22;
--text-secondary: #5C5346;
--text-muted: #7A7164;
--accent: #D97757;
--radius: 14px;
--shadow: 0 1px 2px rgba(60, 40, 20, 0.04);
--font-stack: "Styrene B", ui-sans-serif, system-ui, -apple-system, "Helvetica Neue", sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Inter, system-ui, sans-serif;
font-size: 11px;
background: var(--figma-color-bg);
color: var(--figma-color-text);
font-family: var(--font-stack);
font-size: 12.5px;
background: var(--cream);
color: var(--text-primary);
display: flex;
flex-direction: column;
height: 100vh;
Expand All@@ -21,25 +34,25 @@
.dot-green { background: #1bc47d; }
.dot-yellow { background: #f5a623; animation: pulse 1.2s ease-in-out infinite; }
@keyframes pulse { 0%,100%{opacity:1}50%{opacity:.35} }
.status-text { font-weight: 600; font-size: 11px; }
.status-sub { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-top: 1px; }
.divider { height: 1px; background: var(--figma-color-border, #e5e5e5); }
.setup-section { padding: 10px 12px 0; display: none; }
.setup-cards { display: flex; flex-direction: column; gap: 8px; }
.card { padding: 10px 12px; background: var(--figma-color-bg-secondary, #f5f5f5); border: 1px solid var(--figma-color-border, #e5e5e5); border-radius: 6px; }
.card-title { font-size: 11px; font-weight: 600; color: var(--figma-color-text); margin-bottom: 4px; }
.card-desc { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-bottom: 8px; }
.card-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.btn { font-family: inherit; font-size: 10px; padding: 4px 10px; border: 1px solid var(--figma-color-border, #e5e5e5); border-radius: 4px; background: var(--figma-color-bg, #fff); color: var(--figma-color-text, #111); cursor: pointer; transition: background 0.15s; }
.btn:hover { background: var(--figma-color-bg-hover, #ebebeb); }
.btn.copied { color: #1bc47d; border-color: #1bc47d; }
.btn-primary { background: var(--figma-color-text, #111); color: var(--figma-color-bg, #fff); border-color: var(--figma-color-text, #111); text-decoration: none; display: inline-block; }
.status-text { font-weight: 600; font-size: 12px; }
.status-sub { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
.divider { height: 1px; background: var(--card-border); }
.setup-section { padding: 12px; display: none; }
.setup-cards { display: flex; flex-direction: column; gap: 12px; }
.card { padding: 16px; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow); }
.card-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.card-desc { font-size: 12.5px; color: var(--text-secondary); line-height: 1.4; margin-bottom: 12px; }
.card-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.btn { font: inherit; font-size: 12px; font-weight: 500; padding: 6px 12px; border: 1px solid var(--card-border); border-radius: 8px; background: transparent; color: var(--text-primary); cursor: pointer; transition: background 0.15s; }
.btn:hover { background: var(--cream); }
.btn.copied { background: var(--accent); color: #FFF; border-color: var(--accent); }
.btn-primary { background: var(--text-primary); color: var(--card-bg); border-color: var(--text-primary); text-decoration: none; display: inline-block; }
.btn-primary:hover { opacity: 0.9; }
a.btn { text-decoration: none; }
.progress-section { padding: 8px 12px 10px; display: none; }
.progress-bar { height: 2px; background: var(--figma-color-border, #e5e5e5); border-radius: 1px; overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: #f5a623; border-radius: 1px; }
.countdown-text { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-top: 5px; text-align: center; }
.progress-bar { height: 2px; background: var(--card-border); border-radius: 1px; overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: var(--accent); border-radius: 1px; }
.countdown-text { font-size: 11px; color: var(--text-muted); margin-top: 5px; text-align: center; }
</style>
</head>
<body>
Expand DownExpand Up@@ -130,21 +143,27 @@
}, 1000);
}

var MCP_CONFIG_JSON = JSON.stringify({ mcpServers: { pluginos: { command: "npx", args: ["-y", "pluginos@0.4.2"] } } }, null, 2);
var MCP_CONFIG_JSON = JSON.stringify({ mcpServers: { pluginos: { command: "npx", args: ["-y", "pluginos@<%= MCP_VERSION %>"] } } }, null, 2);
var TIER_1_RULES = "When working with Figma, always use PluginOS tools exclusively:\n- Use `list_operations` (pluginos) first to discover available Figma operations.\n- Use `run_operation` (pluginos) to execute them.\n- Use `execute_figma` (pluginos) only for one-off custom logic not covered by built-in ops.\n- Do NOT use `mcp__Figma__*` tools — they bypass the plugin and return raw, token-heavy data. PluginOS returns pre-summarized, structured results at ~230 tokens/call.\n- If PluginOS returns \"No plugin connected\", open the PluginOS Bridge plugin in Figma before retrying.\n\nAudit/lint/check operations default to `scope: \"selection\"`. Pass `scope: \"page\"` explicitly (and `confirm: true` for pages over 500 nodes) to scan the whole page. Responses carry `_hint` and `_next_hints` fields — respect them when deciding what to do next.";
var INSTALL_COMMAND = "/plugin marketplace add github:LSDimi/pluginos\n/plugin install pluginos";
var DXT_URL = "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt";
var DXT_URL = "<%= DXT_URL %>";

function flashButton(btn, label) {
if (btn.classList.contains("copied")) return;
var orig = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}
Comment on lines +151 to +160

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The flashButton function has a race condition if triggered multiple times on the same button within the 2.5s window. The orig variable will capture the temporary label (e.g., "Copied!") on subsequent clicks, causing the button text to become stuck on that label once the timeouts resolve. Adding a guard clause to ignore clicks while the button is already in the "copied" state prevents this.

Suggested change
function flashButton(btn, label) {
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}
function flashButton(btn, label) {
if (btn.classList.contains("copied")) return;
var orig = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 97ab1ae — added a guard so a re-trigger inside the 2.5s window is a no-op (if (btn.classList.contains('copied')) return;). Same guard added to flashCopied in ui-entry.ts for symmetry.


function wireCopy(btnId, text) {
var btn = document.getElementById(btnId);
if (!btn) return;
btn.addEventListener("click", function() {
function onOk() {
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = "Copied!";
setTimeout(function() { btn.textContent = orig; btn.classList.remove("copied"); }, 2500);
}
function onOk() { flashButton(btn, "Copied!"); }
function onFail() { btn.textContent = "Copy failed"; }
function fallback() {
try {
Expand All@@ -170,13 +189,7 @@
{ pluginMessage: { type: "open-external", url: DXT_URL } },
"*"
);
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = "✓ Opening in browser…";
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
flashButton(btn, "✓ Opening in browser…");
});
})();
wireCopy("btn-copy-install", INSTALL_COMMAND);
Expand Down
3 changes: 3 additions & 0 deletions packages/bridge-plugin/src/constants.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
{
"DXT_URL": "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt"
}
9 changes: 6 additions & 3 deletions packages/bridge-plugin/src/ui-entry.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
import { DXT_URL } from "./constants.json";

declare const __MCP_VERSION__: string;

const TIER_1_RULES = `When working with Figma, always use PluginOS tools exclusively:
- Use \`list_operations\` (pluginos) first to discover available Figma operations.
- Use \`run_operation\` (pluginos) to execute them.
Expand All@@ -11,16 +15,14 @@ const MCP_CONFIG_JSON = `{
"mcpServers": {
"pluginos": {
"command": "npx",
"args": ["-y", "pluginos@0.4.2"]
"args": ["-y", "pluginos@${__MCP_VERSION__}"]
}
}
}`;

const INSTALL_COMMAND = `/plugin marketplace add github:LSDimi/pluginos
/plugin install pluginos`;

const DXT_URL = "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt";

const PORT_MIN = 9500;
const PORT_MAX = 9510;
const RECONNECT_DELAY = 3000;
Expand DownExpand Up@@ -49,6 +51,7 @@ function showView(view: "setup" | "connected") {
}

function flashCopied(btn: HTMLButtonElement, label = "✓ Copied") {
if (btn.classList.contains("copied")) return;
const original = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
Expand Down
1 change: 1 addition & 0 deletions packages/bridge-plugin/tsconfig.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
"rootDir": "src",
"outDir": "dist",
"module": "ESNext",
"resolveJsonModule": true,
"lib": ["ES2017", "DOM"],
"typeRoots": ["../../node_modules/@types", "../../node_modules/@figma"],
"types": ["plugin-typings"]
Expand Down
8 changes: 8 additions & 0 deletions packages/bridge-plugin/webpack.config.js
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
const path = require("path");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const HtmlInlineScriptPlugin = require("html-inline-script-webpack-plugin");

const { DXT_URL } = require("./src/constants.json");
const { version: MCP_VERSION } = require("../mcp-server/package.json");

const noModernSyntax = {
arrowFunction: true,
const: true,
Expand DownExpand Up@@ -41,6 +45,9 @@ module.exports = (env, argv) => [
resolve: { extensions: [".ts", ".js"] },
devtool: false,
plugins: [
new webpack.DefinePlugin({
__MCP_VERSION__: JSON.stringify(MCP_VERSION),
}),
new HtmlWebpackPlugin({
template: "./src/ui.html",
filename: "ui.html",
Expand All@@ -60,6 +67,7 @@ module.exports = (env, argv) => [
template: "./src/bootloader.html",
filename: "bootloader.html",
inject: false,
templateParameters: { DXT_URL, MCP_VERSION },
}),
],
mode: argv.mode || "production",
Expand Down
11 changes: 6 additions & 5 deletions packages/claude-plugin/skills/pluginos-figma/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,17 +15,18 @@ You have `pluginos` MCP tools available AND the user is doing Figma work: design

## Tool routing (iron rule)

Always try `pluginos.*` first:
**Step 0: always call `pluginos.get_status` first** to confirm the bridge plugin is connected before any Figma work. If it returns disconnected, follow the Connection troubleshooting steps below — do NOT silently fall back to `mcp__Figma__*`.

Then prefer `pluginos.*`:

- `pluginos.list_operations` — discover what's available (only needed once per session; see quick-list below).
- `pluginos.run_operation` — execute a registered operation.
- `pluginos.execute_figma` — arbitrary plugin JS, only when no registered op fits.

Fall back to `mcp__Figma__*` tools ONLY when:
**Avoid `mcp__Figma__*` tools** (`get_design_context`, `get_variable_defs`, `get_screenshot`, etc.). They bypass the plugin and return raw, token-heavy node dumps; PluginOS returns pre-summarized, structured results at ~230 tokens/call. The only acceptable fallbacks to `mcp__Figma__*` are:

- PluginOS explicitly returns `no_operation_available`.
- The user requests Figma Code Connect mapping / `get_design_context`-style code generation.
- PluginOS is unreachable (see Connection troubleshooting below).
- PluginOS explicitly returns `no_operation_available` AND `execute_figma` cannot reasonably do the job.
- The user explicitly requests Figma Code Connect mapping or `get_design_context`-style code generation.

Never mix: one-shot a Figma task with either PluginOS or Figma MCP, don't interleave.

Expand Down
26 changes: 6 additions & 20 deletions scripts/bump-lockstep.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,9 +2,12 @@
/**
* Postversion hook for packages/mcp-server. When `npm version` bumps the
* MCP server, propagate the same version string to every file that pins the
* pluginos version: peer package.json manifests, the DXT manifest (both the
* top-level `version` and its `server.mcp_config.args` pin), and the
* hardcoded npx args inside the Figma plugin's UI sources.
* pluginos version: peer package.json manifests and the DXT manifest (both
* the top-level `version` and its `server.mcp_config.args` pin).
*
* Note: the Figma plugin UI (`bridge-plugin/src/ui-entry.ts` and
* `bootloader.html`) reads `mcp-server/package.json#version` at webpack
* build time, so no source-file rewrite is needed there.
*/
const fs = require("node:fs");
const path = require("node:path");
Expand DownExpand Up@@ -40,20 +43,3 @@ if (Array.isArray(dxt?.server?.mcp_config?.args)) {
}
fs.writeFileSync(dxtAbs, JSON.stringify(dxt, null, 2) + "\n");
console.log(`Bumped ${dxtManifestRel} → ${newVersion}`);

// Sources that hardcode `pluginos@<version>` in copy-paste MCP config snippets.
// These use a regex swap so formatting is preserved.
const sourceTargets = [
"packages/bridge-plugin/src/ui-entry.ts",
"packages/bridge-plugin/src/bootloader.html",
];

for (const rel of sourceTargets) {
const abs = path.join(repoRoot, rel);
const before = fs.readFileSync(abs, "utf8");
const after = before.replace(/pluginos@\d+\.\d+\.\d+/g, `pluginos@${newVersion}`);
if (after !== before) {
fs.writeFileSync(abs, after);
console.log(`Bumped ${rel} → pluginos@${newVersion}`);
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 9 additions & 17 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,16 +22,19 @@ PluginOS takes a fundamentally different approach:

### 1. Install for your agent

Pick whichever tool you're using. The Bridge Plugin (step 2) is the same for all of them.

**Claude Desktop (recommended for designers — one click):**
**Recommended — Claude Desktop (one click, no terminal):**

1. Download [`pluginos.dxt`](https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt) from the latest GitHub Release.
2. Double-click the downloaded file. Claude Desktop opens an install dialog.
3. Confirm. PluginOS appears in Claude Desktop's connector list.

No JSON editing, no terminal. Note: Claude.ai web is **not** supported — it cannot reach local MCP servers.

Using Cursor or Claude Code CLI? Open the section below for the right config.

<details>
<summary><strong>Other AI tools</strong> (Cursor, Claude Code CLI, manual config)</summary>

**Cursor (`.cursor/mcp.json`):**

```json
Expand All@@ -56,22 +59,11 @@ Then paste the Tier 1 rules below into `.cursorrules` so Cursor prefers PluginOS

Installs the MCP server registration and the `pluginos-figma` skill in one step.

**Manual (advanced — edit `claude_desktop_config.json` directly):**

```json
{
"mcpServers": {
"pluginos": {
"command": "npx",
"args": ["-y", "pluginos@latest"]
}
}
}
```
**Manual (other MCP-capable clients):** Use the same `mcpServers` JSON block as Cursor above and paste the Tier 1 rules below into your project's custom instructions.

Then paste the Tier 1 rules below into your project's custom instructions.
</details>

**Tier 1 rules (Cursor / Claude Desktop):**
**Tier 1 rules (paste into Cursor / Claude Desktop / other clients):**

```
When working with Figma, always use PluginOS tools exclusively:
Expand Down
2 changes: 2 additions & 0 deletions eslint.config.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@ export default tseslint.config(
"**/*.cjs",
"**/*.mjs",
"!eslint.config.js",
".claude/",
".worktrees/",
],
},
{
Expand Down
83 changes: 48 additions & 35 deletions packages/bridge-plugin/src/bootloader.html
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,12 +2,25 @@
<html>
<head>
<style>
/* Tokens aligned with ui.html — keep in sync. */
:root {
--cream: #FAF9F5;
--card-bg: #FFFFFE;
--card-border: #EDE6D6;
--text-primary: #2D2A22;
--text-secondary: #5C5346;
--text-muted: #7A7164;
--accent: #D97757;
--radius: 14px;
--shadow: 0 1px 2px rgba(60, 40, 20, 0.04);
--font-stack: "Styrene B", ui-sans-serif, system-ui, -apple-system, "Helvetica Neue", sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Inter, system-ui, sans-serif;
font-size: 11px;
background: var(--figma-color-bg);
color: var(--figma-color-text);
font-family: var(--font-stack);
font-size: 12.5px;
background: var(--cream);
color: var(--text-primary);
display: flex;
flex-direction: column;
height: 100vh;
Expand All@@ -21,25 +34,25 @@
.dot-green { background: #1bc47d; }
.dot-yellow { background: #f5a623; animation: pulse 1.2s ease-in-out infinite; }
@keyframes pulse { 0%,100%{opacity:1}50%{opacity:.35} }
.status-text { font-weight: 600; font-size: 11px; }
.status-sub { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-top: 1px; }
.divider { height: 1px; background: var(--figma-color-border, #e5e5e5); }
.setup-section { padding: 10px 12px 0; display: none; }
.setup-cards { display: flex; flex-direction: column; gap: 8px; }
.card { padding: 10px 12px; background: var(--figma-color-bg-secondary, #f5f5f5); border: 1px solid var(--figma-color-border, #e5e5e5); border-radius: 6px; }
.card-title { font-size: 11px; font-weight: 600; color: var(--figma-color-text); margin-bottom: 4px; }
.card-desc { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-bottom: 8px; }
.card-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.btn { font-family: inherit; font-size: 10px; padding: 4px 10px; border: 1px solid var(--figma-color-border, #e5e5e5); border-radius: 4px; background: var(--figma-color-bg, #fff); color: var(--figma-color-text, #111); cursor: pointer; transition: background 0.15s; }
.btn:hover { background: var(--figma-color-bg-hover, #ebebeb); }
.btn.copied { color: #1bc47d; border-color: #1bc47d; }
.btn-primary { background: var(--figma-color-text, #111); color: var(--figma-color-bg, #fff); border-color: var(--figma-color-text, #111); text-decoration: none; display: inline-block; }
.status-text { font-weight: 600; font-size: 12px; }
.status-sub { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
.divider { height: 1px; background: var(--card-border); }
.setup-section { padding: 12px; display: none; }
.setup-cards { display: flex; flex-direction: column; gap: 12px; }
.card { padding: 16px; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow); }
.card-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.card-desc { font-size: 12.5px; color: var(--text-secondary); line-height: 1.4; margin-bottom: 12px; }
.card-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.btn { font: inherit; font-size: 12px; font-weight: 500; padding: 6px 12px; border: 1px solid var(--card-border); border-radius: 8px; background: transparent; color: var(--text-primary); cursor: pointer; transition: background 0.15s; }
.btn:hover { background: var(--cream); }
.btn.copied { background: var(--accent); color: #FFF; border-color: var(--accent); }
.btn-primary { background: var(--text-primary); color: var(--card-bg); border-color: var(--text-primary); text-decoration: none; display: inline-block; }
.btn-primary:hover { opacity: 0.9; }
a.btn { text-decoration: none; }
.progress-section { padding: 8px 12px 10px; display: none; }
.progress-bar { height: 2px; background: var(--figma-color-border, #e5e5e5); border-radius: 1px; overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: #f5a623; border-radius: 1px; }
.countdown-text { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-top: 5px; text-align: center; }
.progress-bar { height: 2px; background: var(--card-border); border-radius: 1px; overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: var(--accent); border-radius: 1px; }
.countdown-text { font-size: 11px; color: var(--text-muted); margin-top: 5px; text-align: center; }
</style>
</head>
<body>
Expand DownExpand Up@@ -130,21 +143,27 @@
}, 1000);
}

var MCP_CONFIG_JSON = JSON.stringify({ mcpServers: { pluginos: { command: "npx", args: ["-y", "pluginos@0.4.2"] } } }, null, 2);
var MCP_CONFIG_JSON = JSON.stringify({ mcpServers: { pluginos: { command: "npx", args: ["-y", "pluginos@<%= MCP_VERSION %>"] } } }, null, 2);
var TIER_1_RULES = "When working with Figma, always use PluginOS tools exclusively:\n- Use `list_operations` (pluginos) first to discover available Figma operations.\n- Use `run_operation` (pluginos) to execute them.\n- Use `execute_figma` (pluginos) only for one-off custom logic not covered by built-in ops.\n- Do NOT use `mcp__Figma__*` tools — they bypass the plugin and return raw, token-heavy data. PluginOS returns pre-summarized, structured results at ~230 tokens/call.\n- If PluginOS returns \"No plugin connected\", open the PluginOS Bridge plugin in Figma before retrying.\n\nAudit/lint/check operations default to `scope: \"selection\"`. Pass `scope: \"page\"` explicitly (and `confirm: true` for pages over 500 nodes) to scan the whole page. Responses carry `_hint` and `_next_hints` fields — respect them when deciding what to do next.";
var INSTALL_COMMAND = "/plugin marketplace add github:LSDimi/pluginos\n/plugin install pluginos";
var DXT_URL = "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt";
var DXT_URL = "<%= DXT_URL %>";

function flashButton(btn, label) {
if (btn.classList.contains("copied")) return;
var orig = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}
Comment on lines +151 to +160

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The flashButton function has a race condition if triggered multiple times on the same button within the 2.5s window. The orig variable will capture the temporary label (e.g., "Copied!") on subsequent clicks, causing the button text to become stuck on that label once the timeouts resolve. Adding a guard clause to ignore clicks while the button is already in the "copied" state prevents this.

Suggested change
function flashButton(btn, label) {
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}
function flashButton(btn, label) {
if (btn.classList.contains("copied")) return;
var orig = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 97ab1ae — added a guard so a re-trigger inside the 2.5s window is a no-op (if (btn.classList.contains('copied')) return;). Same guard added to flashCopied in ui-entry.ts for symmetry.


function wireCopy(btnId, text) {
var btn = document.getElementById(btnId);
if (!btn) return;
btn.addEventListener("click", function() {
function onOk() {
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = "Copied!";
setTimeout(function() { btn.textContent = orig; btn.classList.remove("copied"); }, 2500);
}
function onOk() { flashButton(btn, "Copied!"); }
function onFail() { btn.textContent = "Copy failed"; }
function fallback() {
try {
Expand All@@ -170,13 +189,7 @@
{ pluginMessage: { type: "open-external", url: DXT_URL } },
"*"
);
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = "✓ Opening in browser…";
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
flashButton(btn, "✓ Opening in browser…");
});
})();
wireCopy("btn-copy-install", INSTALL_COMMAND);
Expand Down
3 changes: 3 additions & 0 deletions packages/bridge-plugin/src/constants.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
{
"DXT_URL": "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt"
}
9 changes: 6 additions & 3 deletions packages/bridge-plugin/src/ui-entry.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
import { DXT_URL } from "./constants.json";

declare const __MCP_VERSION__: string;

const TIER_1_RULES = `When working with Figma, always use PluginOS tools exclusively:
- Use \`list_operations\` (pluginos) first to discover available Figma operations.
- Use \`run_operation\` (pluginos) to execute them.
Expand All@@ -11,16 +15,14 @@ const MCP_CONFIG_JSON = `{
"mcpServers": {
"pluginos": {
"command": "npx",
"args": ["-y", "pluginos@0.4.2"]
"args": ["-y", "pluginos@${__MCP_VERSION__}"]
}
}
}`;

const INSTALL_COMMAND = `/plugin marketplace add github:LSDimi/pluginos
/plugin install pluginos`;

const DXT_URL = "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt";

const PORT_MIN = 9500;
const PORT_MAX = 9510;
const RECONNECT_DELAY = 3000;
Expand DownExpand Up@@ -49,6 +51,7 @@ function showView(view: "setup" | "connected") {
}

function flashCopied(btn: HTMLButtonElement, label = "✓ Copied") {
if (btn.classList.contains("copied")) return;
const original = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
Expand Down
1 change: 1 addition & 0 deletions packages/bridge-plugin/tsconfig.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
"rootDir": "src",
"outDir": "dist",
"module": "ESNext",
"resolveJsonModule": true,
"lib": ["ES2017", "DOM"],
"typeRoots": ["../../node_modules/@types", "../../node_modules/@figma"],
"types": ["plugin-typings"]
Expand Down
8 changes: 8 additions & 0 deletions packages/bridge-plugin/webpack.config.js
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
const path = require("path");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const HtmlInlineScriptPlugin = require("html-inline-script-webpack-plugin");

const { DXT_URL } = require("./src/constants.json");
const { version: MCP_VERSION } = require("../mcp-server/package.json");

const noModernSyntax = {
arrowFunction: true,
const: true,
Expand DownExpand Up@@ -41,6 +45,9 @@ module.exports = (env, argv) => [
resolve: { extensions: [".ts", ".js"] },
devtool: false,
plugins: [
new webpack.DefinePlugin({
__MCP_VERSION__: JSON.stringify(MCP_VERSION),
}),
new HtmlWebpackPlugin({
template: "./src/ui.html",
filename: "ui.html",
Expand All@@ -60,6 +67,7 @@ module.exports = (env, argv) => [
template: "./src/bootloader.html",
filename: "bootloader.html",
inject: false,
templateParameters: { DXT_URL, MCP_VERSION },
}),
],
mode: argv.mode || "production",
Expand Down
11 changes: 6 additions & 5 deletions packages/claude-plugin/skills/pluginos-figma/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,17 +15,18 @@ You have `pluginos` MCP tools available AND the user is doing Figma work: design

## Tool routing (iron rule)

Always try `pluginos.*` first:
**Step 0: always call `pluginos.get_status` first** to confirm the bridge plugin is connected before any Figma work. If it returns disconnected, follow the Connection troubleshooting steps below — do NOT silently fall back to `mcp__Figma__*`.

Then prefer `pluginos.*`:

- `pluginos.list_operations` — discover what's available (only needed once per session; see quick-list below).
- `pluginos.run_operation` — execute a registered operation.
- `pluginos.execute_figma` — arbitrary plugin JS, only when no registered op fits.

Fall back to `mcp__Figma__*` tools ONLY when:
**Avoid `mcp__Figma__*` tools** (`get_design_context`, `get_variable_defs`, `get_screenshot`, etc.). They bypass the plugin and return raw, token-heavy node dumps; PluginOS returns pre-summarized, structured results at ~230 tokens/call. The only acceptable fallbacks to `mcp__Figma__*` are:

- PluginOS explicitly returns `no_operation_available`.
- The user requests Figma Code Connect mapping / `get_design_context`-style code generation.
- PluginOS is unreachable (see Connection troubleshooting below).
- PluginOS explicitly returns `no_operation_available` AND `execute_figma` cannot reasonably do the job.
- The user explicitly requests Figma Code Connect mapping or `get_design_context`-style code generation.

Never mix: one-shot a Figma task with either PluginOS or Figma MCP, don't interleave.

Expand Down
26 changes: 6 additions & 20 deletions scripts/bump-lockstep.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,9 +2,12 @@
/**
* Postversion hook for packages/mcp-server. When `npm version` bumps the
* MCP server, propagate the same version string to every file that pins the
* pluginos version: peer package.json manifests, the DXT manifest (both the
* top-level `version` and its `server.mcp_config.args` pin), and the
* hardcoded npx args inside the Figma plugin's UI sources.
* pluginos version: peer package.json manifests and the DXT manifest (both
* the top-level `version` and its `server.mcp_config.args` pin).
*
* Note: the Figma plugin UI (`bridge-plugin/src/ui-entry.ts` and
* `bootloader.html`) reads `mcp-server/package.json#version` at webpack
* build time, so no source-file rewrite is needed there.
*/
const fs = require("node:fs");
const path = require("node:path");
Expand DownExpand Up@@ -40,20 +43,3 @@ if (Array.isArray(dxt?.server?.mcp_config?.args)) {
}
fs.writeFileSync(dxtAbs, JSON.stringify(dxt, null, 2) + "\n");
console.log(`Bumped ${dxtManifestRel} → ${newVersion}`);

// Sources that hardcode `pluginos@<version>` in copy-paste MCP config snippets.
// These use a regex swap so formatting is preserved.
const sourceTargets = [
"packages/bridge-plugin/src/ui-entry.ts",
"packages/bridge-plugin/src/bootloader.html",
];

for (const rel of sourceTargets) {
const abs = path.join(repoRoot, rel);
const before = fs.readFileSync(abs, "utf8");
const after = before.replace(/pluginos@\d+\.\d+\.\d+/g, `pluginos@${newVersion}`);
if (after !== before) {
fs.writeFileSync(abs, after);
console.log(`Bumped ${rel} → pluginos@${newVersion}`);
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 9 additions & 17 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,16 +22,19 @@ PluginOS takes a fundamentally different approach:

### 1. Install for your agent

Pick whichever tool you're using. The Bridge Plugin (step 2) is the same for all of them.

**Claude Desktop (recommended for designers — one click):**
**Recommended — Claude Desktop (one click, no terminal):**

1. Download [`pluginos.dxt`](https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt) from the latest GitHub Release.
2. Double-click the downloaded file. Claude Desktop opens an install dialog.
3. Confirm. PluginOS appears in Claude Desktop's connector list.

No JSON editing, no terminal. Note: Claude.ai web is **not** supported — it cannot reach local MCP servers.

Using Cursor or Claude Code CLI? Open the section below for the right config.

<details>
<summary><strong>Other AI tools</strong> (Cursor, Claude Code CLI, manual config)</summary>

**Cursor (`.cursor/mcp.json`):**

```json
Expand All@@ -56,22 +59,11 @@ Then paste the Tier 1 rules below into `.cursorrules` so Cursor prefers PluginOS

Installs the MCP server registration and the `pluginos-figma` skill in one step.

**Manual (advanced — edit `claude_desktop_config.json` directly):**

```json
{
"mcpServers": {
"pluginos": {
"command": "npx",
"args": ["-y", "pluginos@latest"]
}
}
}
```
**Manual (other MCP-capable clients):** Use the same `mcpServers` JSON block as Cursor above and paste the Tier 1 rules below into your project's custom instructions.

Then paste the Tier 1 rules below into your project's custom instructions.
</details>

**Tier 1 rules (Cursor / Claude Desktop):**
**Tier 1 rules (paste into Cursor / Claude Desktop / other clients):**

```
When working with Figma, always use PluginOS tools exclusively:
Expand Down
2 changes: 2 additions & 0 deletions eslint.config.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@ export default tseslint.config(
"**/*.cjs",
"**/*.mjs",
"!eslint.config.js",
".claude/",
".worktrees/",
],
},
{
Expand Down
83 changes: 48 additions & 35 deletions packages/bridge-plugin/src/bootloader.html
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,12 +2,25 @@
<html>
<head>
<style>
/* Tokens aligned with ui.html — keep in sync. */
:root {
--cream: #FAF9F5;
--card-bg: #FFFFFE;
--card-border: #EDE6D6;
--text-primary: #2D2A22;
--text-secondary: #5C5346;
--text-muted: #7A7164;
--accent: #D97757;
--radius: 14px;
--shadow: 0 1px 2px rgba(60, 40, 20, 0.04);
--font-stack: "Styrene B", ui-sans-serif, system-ui, -apple-system, "Helvetica Neue", sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Inter, system-ui, sans-serif;
font-size: 11px;
background: var(--figma-color-bg);
color: var(--figma-color-text);
font-family: var(--font-stack);
font-size: 12.5px;
background: var(--cream);
color: var(--text-primary);
display: flex;
flex-direction: column;
height: 100vh;
Expand All@@ -21,25 +34,25 @@
.dot-green { background: #1bc47d; }
.dot-yellow { background: #f5a623; animation: pulse 1.2s ease-in-out infinite; }
@keyframes pulse { 0%,100%{opacity:1}50%{opacity:.35} }
.status-text { font-weight: 600; font-size: 11px; }
.status-sub { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-top: 1px; }
.divider { height: 1px; background: var(--figma-color-border, #e5e5e5); }
.setup-section { padding: 10px 12px 0; display: none; }
.setup-cards { display: flex; flex-direction: column; gap: 8px; }
.card { padding: 10px 12px; background: var(--figma-color-bg-secondary, #f5f5f5); border: 1px solid var(--figma-color-border, #e5e5e5); border-radius: 6px; }
.card-title { font-size: 11px; font-weight: 600; color: var(--figma-color-text); margin-bottom: 4px; }
.card-desc { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-bottom: 8px; }
.card-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.btn { font-family: inherit; font-size: 10px; padding: 4px 10px; border: 1px solid var(--figma-color-border, #e5e5e5); border-radius: 4px; background: var(--figma-color-bg, #fff); color: var(--figma-color-text, #111); cursor: pointer; transition: background 0.15s; }
.btn:hover { background: var(--figma-color-bg-hover, #ebebeb); }
.btn.copied { color: #1bc47d; border-color: #1bc47d; }
.btn-primary { background: var(--figma-color-text, #111); color: var(--figma-color-bg, #fff); border-color: var(--figma-color-text, #111); text-decoration: none; display: inline-block; }
.status-text { font-weight: 600; font-size: 12px; }
.status-sub { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
.divider { height: 1px; background: var(--card-border); }
.setup-section { padding: 12px; display: none; }
.setup-cards { display: flex; flex-direction: column; gap: 12px; }
.card { padding: 16px; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow); }
.card-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.card-desc { font-size: 12.5px; color: var(--text-secondary); line-height: 1.4; margin-bottom: 12px; }
.card-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.btn { font: inherit; font-size: 12px; font-weight: 500; padding: 6px 12px; border: 1px solid var(--card-border); border-radius: 8px; background: transparent; color: var(--text-primary); cursor: pointer; transition: background 0.15s; }
.btn:hover { background: var(--cream); }
.btn.copied { background: var(--accent); color: #FFF; border-color: var(--accent); }
.btn-primary { background: var(--text-primary); color: var(--card-bg); border-color: var(--text-primary); text-decoration: none; display: inline-block; }
.btn-primary:hover { opacity: 0.9; }
a.btn { text-decoration: none; }
.progress-section { padding: 8px 12px 10px; display: none; }
.progress-bar { height: 2px; background: var(--figma-color-border, #e5e5e5); border-radius: 1px; overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: #f5a623; border-radius: 1px; }
.countdown-text { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-top: 5px; text-align: center; }
.progress-bar { height: 2px; background: var(--card-border); border-radius: 1px; overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: var(--accent); border-radius: 1px; }
.countdown-text { font-size: 11px; color: var(--text-muted); margin-top: 5px; text-align: center; }
</style>
</head>
<body>
Expand DownExpand Up@@ -130,21 +143,27 @@
}, 1000);
}

var MCP_CONFIG_JSON = JSON.stringify({ mcpServers: { pluginos: { command: "npx", args: ["-y", "pluginos@0.4.2"] } } }, null, 2);
var MCP_CONFIG_JSON = JSON.stringify({ mcpServers: { pluginos: { command: "npx", args: ["-y", "pluginos@<%= MCP_VERSION %>"] } } }, null, 2);
var TIER_1_RULES = "When working with Figma, always use PluginOS tools exclusively:\n- Use `list_operations` (pluginos) first to discover available Figma operations.\n- Use `run_operation` (pluginos) to execute them.\n- Use `execute_figma` (pluginos) only for one-off custom logic not covered by built-in ops.\n- Do NOT use `mcp__Figma__*` tools — they bypass the plugin and return raw, token-heavy data. PluginOS returns pre-summarized, structured results at ~230 tokens/call.\n- If PluginOS returns \"No plugin connected\", open the PluginOS Bridge plugin in Figma before retrying.\n\nAudit/lint/check operations default to `scope: \"selection\"`. Pass `scope: \"page\"` explicitly (and `confirm: true` for pages over 500 nodes) to scan the whole page. Responses carry `_hint` and `_next_hints` fields — respect them when deciding what to do next.";
var INSTALL_COMMAND = "/plugin marketplace add github:LSDimi/pluginos\n/plugin install pluginos";
var DXT_URL = "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt";
var DXT_URL = "<%= DXT_URL %>";

function flashButton(btn, label) {
if (btn.classList.contains("copied")) return;
var orig = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}
Comment on lines +151 to +160

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The flashButton function has a race condition if triggered multiple times on the same button within the 2.5s window. The orig variable will capture the temporary label (e.g., "Copied!") on subsequent clicks, causing the button text to become stuck on that label once the timeouts resolve. Adding a guard clause to ignore clicks while the button is already in the "copied" state prevents this.

Suggested change
function flashButton(btn, label) {
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}
function flashButton(btn, label) {
if (btn.classList.contains("copied")) return;
var orig = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 97ab1ae — added a guard so a re-trigger inside the 2.5s window is a no-op (if (btn.classList.contains('copied')) return;). Same guard added to flashCopied in ui-entry.ts for symmetry.


function wireCopy(btnId, text) {
var btn = document.getElementById(btnId);
if (!btn) return;
btn.addEventListener("click", function() {
function onOk() {
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = "Copied!";
setTimeout(function() { btn.textContent = orig; btn.classList.remove("copied"); }, 2500);
}
function onOk() { flashButton(btn, "Copied!"); }
function onFail() { btn.textContent = "Copy failed"; }
function fallback() {
try {
Expand All@@ -170,13 +189,7 @@
{ pluginMessage: { type: "open-external", url: DXT_URL } },
"*"
);
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = "✓ Opening in browser…";
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
flashButton(btn, "✓ Opening in browser…");
});
})();
wireCopy("btn-copy-install", INSTALL_COMMAND);
Expand Down
3 changes: 3 additions & 0 deletions packages/bridge-plugin/src/constants.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
{
"DXT_URL": "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt"
}
9 changes: 6 additions & 3 deletions packages/bridge-plugin/src/ui-entry.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
import { DXT_URL } from "./constants.json";

declare const __MCP_VERSION__: string;

const TIER_1_RULES = `When working with Figma, always use PluginOS tools exclusively:
- Use \`list_operations\` (pluginos) first to discover available Figma operations.
- Use \`run_operation\` (pluginos) to execute them.
Expand All@@ -11,16 +15,14 @@ const MCP_CONFIG_JSON = `{
"mcpServers": {
"pluginos": {
"command": "npx",
"args": ["-y", "pluginos@0.4.2"]
"args": ["-y", "pluginos@${__MCP_VERSION__}"]
}
}
}`;

const INSTALL_COMMAND = `/plugin marketplace add github:LSDimi/pluginos
/plugin install pluginos`;

const DXT_URL = "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt";

const PORT_MIN = 9500;
const PORT_MAX = 9510;
const RECONNECT_DELAY = 3000;
Expand DownExpand Up@@ -49,6 +51,7 @@ function showView(view: "setup" | "connected") {
}

function flashCopied(btn: HTMLButtonElement, label = "✓ Copied") {
if (btn.classList.contains("copied")) return;
const original = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
Expand Down
1 change: 1 addition & 0 deletions packages/bridge-plugin/tsconfig.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
"rootDir": "src",
"outDir": "dist",
"module": "ESNext",
"resolveJsonModule": true,
"lib": ["ES2017", "DOM"],
"typeRoots": ["../../node_modules/@types", "../../node_modules/@figma"],
"types": ["plugin-typings"]
Expand Down
8 changes: 8 additions & 0 deletions packages/bridge-plugin/webpack.config.js
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
const path = require("path");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const HtmlInlineScriptPlugin = require("html-inline-script-webpack-plugin");

const { DXT_URL } = require("./src/constants.json");
const { version: MCP_VERSION } = require("../mcp-server/package.json");

const noModernSyntax = {
arrowFunction: true,
const: true,
Expand DownExpand Up@@ -41,6 +45,9 @@ module.exports = (env, argv) => [
resolve: { extensions: [".ts", ".js"] },
devtool: false,
plugins: [
new webpack.DefinePlugin({
__MCP_VERSION__: JSON.stringify(MCP_VERSION),
}),
new HtmlWebpackPlugin({
template: "./src/ui.html",
filename: "ui.html",
Expand All@@ -60,6 +67,7 @@ module.exports = (env, argv) => [
template: "./src/bootloader.html",
filename: "bootloader.html",
inject: false,
templateParameters: { DXT_URL, MCP_VERSION },
}),
],
mode: argv.mode || "production",
Expand Down
11 changes: 6 additions & 5 deletions packages/claude-plugin/skills/pluginos-figma/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,17 +15,18 @@ You have `pluginos` MCP tools available AND the user is doing Figma work: design

## Tool routing (iron rule)

Always try `pluginos.*` first:
**Step 0: always call `pluginos.get_status` first** to confirm the bridge plugin is connected before any Figma work. If it returns disconnected, follow the Connection troubleshooting steps below — do NOT silently fall back to `mcp__Figma__*`.

Then prefer `pluginos.*`:

- `pluginos.list_operations` — discover what's available (only needed once per session; see quick-list below).
- `pluginos.run_operation` — execute a registered operation.
- `pluginos.execute_figma` — arbitrary plugin JS, only when no registered op fits.

Fall back to `mcp__Figma__*` tools ONLY when:
**Avoid `mcp__Figma__*` tools** (`get_design_context`, `get_variable_defs`, `get_screenshot`, etc.). They bypass the plugin and return raw, token-heavy node dumps; PluginOS returns pre-summarized, structured results at ~230 tokens/call. The only acceptable fallbacks to `mcp__Figma__*` are:

- PluginOS explicitly returns `no_operation_available`.
- The user requests Figma Code Connect mapping / `get_design_context`-style code generation.
- PluginOS is unreachable (see Connection troubleshooting below).
- PluginOS explicitly returns `no_operation_available` AND `execute_figma` cannot reasonably do the job.
- The user explicitly requests Figma Code Connect mapping or `get_design_context`-style code generation.

Never mix: one-shot a Figma task with either PluginOS or Figma MCP, don't interleave.

Expand Down
26 changes: 6 additions & 20 deletions scripts/bump-lockstep.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,9 +2,12 @@
/**
* Postversion hook for packages/mcp-server. When `npm version` bumps the
* MCP server, propagate the same version string to every file that pins the
* pluginos version: peer package.json manifests, the DXT manifest (both the
* top-level `version` and its `server.mcp_config.args` pin), and the
* hardcoded npx args inside the Figma plugin's UI sources.
* pluginos version: peer package.json manifests and the DXT manifest (both
* the top-level `version` and its `server.mcp_config.args` pin).
*
* Note: the Figma plugin UI (`bridge-plugin/src/ui-entry.ts` and
* `bootloader.html`) reads `mcp-server/package.json#version` at webpack
* build time, so no source-file rewrite is needed there.
*/
const fs = require("node:fs");
const path = require("node:path");
Expand DownExpand Up@@ -40,20 +43,3 @@ if (Array.isArray(dxt?.server?.mcp_config?.args)) {
}
fs.writeFileSync(dxtAbs, JSON.stringify(dxt, null, 2) + "\n");
console.log(`Bumped ${dxtManifestRel} → ${newVersion}`);

// Sources that hardcode `pluginos@<version>` in copy-paste MCP config snippets.
// These use a regex swap so formatting is preserved.
const sourceTargets = [
"packages/bridge-plugin/src/ui-entry.ts",
"packages/bridge-plugin/src/bootloader.html",
];

for (const rel of sourceTargets) {
const abs = path.join(repoRoot, rel);
const before = fs.readFileSync(abs, "utf8");
const after = before.replace(/pluginos@\d+\.\d+\.\d+/g, `pluginos@${newVersion}`);
if (after !== before) {
fs.writeFileSync(abs, after);
console.log(`Bumped ${rel} → pluginos@${newVersion}`);
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 9 additions & 17 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,16 +22,19 @@ PluginOS takes a fundamentally different approach:

### 1. Install for your agent

Pick whichever tool you're using. The Bridge Plugin (step 2) is the same for all of them.

**Claude Desktop (recommended for designers — one click):**
**Recommended — Claude Desktop (one click, no terminal):**

1. Download [`pluginos.dxt`](https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt) from the latest GitHub Release.
2. Double-click the downloaded file. Claude Desktop opens an install dialog.
3. Confirm. PluginOS appears in Claude Desktop's connector list.

No JSON editing, no terminal. Note: Claude.ai web is **not** supported — it cannot reach local MCP servers.

Using Cursor or Claude Code CLI? Open the section below for the right config.

<details>
<summary><strong>Other AI tools</strong> (Cursor, Claude Code CLI, manual config)</summary>

**Cursor (`.cursor/mcp.json`):**

```json
Expand All@@ -56,22 +59,11 @@ Then paste the Tier 1 rules below into `.cursorrules` so Cursor prefers PluginOS

Installs the MCP server registration and the `pluginos-figma` skill in one step.

**Manual (advanced — edit `claude_desktop_config.json` directly):**

```json
{
"mcpServers": {
"pluginos": {
"command": "npx",
"args": ["-y", "pluginos@latest"]
}
}
}
```
**Manual (other MCP-capable clients):** Use the same `mcpServers` JSON block as Cursor above and paste the Tier 1 rules below into your project's custom instructions.

Then paste the Tier 1 rules below into your project's custom instructions.
</details>

**Tier 1 rules (Cursor / Claude Desktop):**
**Tier 1 rules (paste into Cursor / Claude Desktop / other clients):**

```
When working with Figma, always use PluginOS tools exclusively:
Expand Down
2 changes: 2 additions & 0 deletions eslint.config.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@ export default tseslint.config(
"**/*.cjs",
"**/*.mjs",
"!eslint.config.js",
".claude/",
".worktrees/",
],
},
{
Expand Down
83 changes: 48 additions & 35 deletions packages/bridge-plugin/src/bootloader.html
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,12 +2,25 @@
<html>
<head>
<style>
/* Tokens aligned with ui.html — keep in sync. */
:root {
--cream: #FAF9F5;
--card-bg: #FFFFFE;
--card-border: #EDE6D6;
--text-primary: #2D2A22;
--text-secondary: #5C5346;
--text-muted: #7A7164;
--accent: #D97757;
--radius: 14px;
--shadow: 0 1px 2px rgba(60, 40, 20, 0.04);
--font-stack: "Styrene B", ui-sans-serif, system-ui, -apple-system, "Helvetica Neue", sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Inter, system-ui, sans-serif;
font-size: 11px;
background: var(--figma-color-bg);
color: var(--figma-color-text);
font-family: var(--font-stack);
font-size: 12.5px;
background: var(--cream);
color: var(--text-primary);
display: flex;
flex-direction: column;
height: 100vh;
Expand All@@ -21,25 +34,25 @@
.dot-green { background: #1bc47d; }
.dot-yellow { background: #f5a623; animation: pulse 1.2s ease-in-out infinite; }
@keyframes pulse { 0%,100%{opacity:1}50%{opacity:.35} }
.status-text { font-weight: 600; font-size: 11px; }
.status-sub { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-top: 1px; }
.divider { height: 1px; background: var(--figma-color-border, #e5e5e5); }
.setup-section { padding: 10px 12px 0; display: none; }
.setup-cards { display: flex; flex-direction: column; gap: 8px; }
.card { padding: 10px 12px; background: var(--figma-color-bg-secondary, #f5f5f5); border: 1px solid var(--figma-color-border, #e5e5e5); border-radius: 6px; }
.card-title { font-size: 11px; font-weight: 600; color: var(--figma-color-text); margin-bottom: 4px; }
.card-desc { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-bottom: 8px; }
.card-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.btn { font-family: inherit; font-size: 10px; padding: 4px 10px; border: 1px solid var(--figma-color-border, #e5e5e5); border-radius: 4px; background: var(--figma-color-bg, #fff); color: var(--figma-color-text, #111); cursor: pointer; transition: background 0.15s; }
.btn:hover { background: var(--figma-color-bg-hover, #ebebeb); }
.btn.copied { color: #1bc47d; border-color: #1bc47d; }
.btn-primary { background: var(--figma-color-text, #111); color: var(--figma-color-bg, #fff); border-color: var(--figma-color-text, #111); text-decoration: none; display: inline-block; }
.status-text { font-weight: 600; font-size: 12px; }
.status-sub { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
.divider { height: 1px; background: var(--card-border); }
.setup-section { padding: 12px; display: none; }
.setup-cards { display: flex; flex-direction: column; gap: 12px; }
.card { padding: 16px; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow); }
.card-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.card-desc { font-size: 12.5px; color: var(--text-secondary); line-height: 1.4; margin-bottom: 12px; }
.card-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.btn { font: inherit; font-size: 12px; font-weight: 500; padding: 6px 12px; border: 1px solid var(--card-border); border-radius: 8px; background: transparent; color: var(--text-primary); cursor: pointer; transition: background 0.15s; }
.btn:hover { background: var(--cream); }
.btn.copied { background: var(--accent); color: #FFF; border-color: var(--accent); }
.btn-primary { background: var(--text-primary); color: var(--card-bg); border-color: var(--text-primary); text-decoration: none; display: inline-block; }
.btn-primary:hover { opacity: 0.9; }
a.btn { text-decoration: none; }
.progress-section { padding: 8px 12px 10px; display: none; }
.progress-bar { height: 2px; background: var(--figma-color-border, #e5e5e5); border-radius: 1px; overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: #f5a623; border-radius: 1px; }
.countdown-text { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-top: 5px; text-align: center; }
.progress-bar { height: 2px; background: var(--card-border); border-radius: 1px; overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: var(--accent); border-radius: 1px; }
.countdown-text { font-size: 11px; color: var(--text-muted); margin-top: 5px; text-align: center; }
</style>
</head>
<body>
Expand DownExpand Up@@ -130,21 +143,27 @@
}, 1000);
}

var MCP_CONFIG_JSON = JSON.stringify({ mcpServers: { pluginos: { command: "npx", args: ["-y", "pluginos@0.4.2"] } } }, null, 2);
var MCP_CONFIG_JSON = JSON.stringify({ mcpServers: { pluginos: { command: "npx", args: ["-y", "pluginos@<%= MCP_VERSION %>"] } } }, null, 2);
var TIER_1_RULES = "When working with Figma, always use PluginOS tools exclusively:\n- Use `list_operations` (pluginos) first to discover available Figma operations.\n- Use `run_operation` (pluginos) to execute them.\n- Use `execute_figma` (pluginos) only for one-off custom logic not covered by built-in ops.\n- Do NOT use `mcp__Figma__*` tools — they bypass the plugin and return raw, token-heavy data. PluginOS returns pre-summarized, structured results at ~230 tokens/call.\n- If PluginOS returns \"No plugin connected\", open the PluginOS Bridge plugin in Figma before retrying.\n\nAudit/lint/check operations default to `scope: \"selection\"`. Pass `scope: \"page\"` explicitly (and `confirm: true` for pages over 500 nodes) to scan the whole page. Responses carry `_hint` and `_next_hints` fields — respect them when deciding what to do next.";
var INSTALL_COMMAND = "/plugin marketplace add github:LSDimi/pluginos\n/plugin install pluginos";
var DXT_URL = "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt";
var DXT_URL = "<%= DXT_URL %>";

function flashButton(btn, label) {
if (btn.classList.contains("copied")) return;
var orig = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}
Comment on lines +151 to +160

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The flashButton function has a race condition if triggered multiple times on the same button within the 2.5s window. The orig variable will capture the temporary label (e.g., "Copied!") on subsequent clicks, causing the button text to become stuck on that label once the timeouts resolve. Adding a guard clause to ignore clicks while the button is already in the "copied" state prevents this.

Suggested change
function flashButton(btn, label) {
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}
function flashButton(btn, label) {
if (btn.classList.contains("copied")) return;
var orig = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 97ab1ae — added a guard so a re-trigger inside the 2.5s window is a no-op (if (btn.classList.contains('copied')) return;). Same guard added to flashCopied in ui-entry.ts for symmetry.


function wireCopy(btnId, text) {
var btn = document.getElementById(btnId);
if (!btn) return;
btn.addEventListener("click", function() {
function onOk() {
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = "Copied!";
setTimeout(function() { btn.textContent = orig; btn.classList.remove("copied"); }, 2500);
}
function onOk() { flashButton(btn, "Copied!"); }
function onFail() { btn.textContent = "Copy failed"; }
function fallback() {
try {
Expand All@@ -170,13 +189,7 @@
{ pluginMessage: { type: "open-external", url: DXT_URL } },
"*"
);
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = "✓ Opening in browser…";
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
flashButton(btn, "✓ Opening in browser…");
});
})();
wireCopy("btn-copy-install", INSTALL_COMMAND);
Expand Down
3 changes: 3 additions & 0 deletions packages/bridge-plugin/src/constants.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
{
"DXT_URL": "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt"
}
9 changes: 6 additions & 3 deletions packages/bridge-plugin/src/ui-entry.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
import { DXT_URL } from "./constants.json";

declare const __MCP_VERSION__: string;

const TIER_1_RULES = `When working with Figma, always use PluginOS tools exclusively:
- Use \`list_operations\` (pluginos) first to discover available Figma operations.
- Use \`run_operation\` (pluginos) to execute them.
Expand All@@ -11,16 +15,14 @@ const MCP_CONFIG_JSON = `{
"mcpServers": {
"pluginos": {
"command": "npx",
"args": ["-y", "pluginos@0.4.2"]
"args": ["-y", "pluginos@${__MCP_VERSION__}"]
}
}
}`;

const INSTALL_COMMAND = `/plugin marketplace add github:LSDimi/pluginos
/plugin install pluginos`;

const DXT_URL = "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt";

const PORT_MIN = 9500;
const PORT_MAX = 9510;
const RECONNECT_DELAY = 3000;
Expand DownExpand Up@@ -49,6 +51,7 @@ function showView(view: "setup" | "connected") {
}

function flashCopied(btn: HTMLButtonElement, label = "✓ Copied") {
if (btn.classList.contains("copied")) return;
const original = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
Expand Down
1 change: 1 addition & 0 deletions packages/bridge-plugin/tsconfig.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
"rootDir": "src",
"outDir": "dist",
"module": "ESNext",
"resolveJsonModule": true,
"lib": ["ES2017", "DOM"],
"typeRoots": ["../../node_modules/@types", "../../node_modules/@figma"],
"types": ["plugin-typings"]
Expand Down
8 changes: 8 additions & 0 deletions packages/bridge-plugin/webpack.config.js
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
const path = require("path");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const HtmlInlineScriptPlugin = require("html-inline-script-webpack-plugin");

const { DXT_URL } = require("./src/constants.json");
const { version: MCP_VERSION } = require("../mcp-server/package.json");

const noModernSyntax = {
arrowFunction: true,
const: true,
Expand DownExpand Up@@ -41,6 +45,9 @@ module.exports = (env, argv) => [
resolve: { extensions: [".ts", ".js"] },
devtool: false,
plugins: [
new webpack.DefinePlugin({
__MCP_VERSION__: JSON.stringify(MCP_VERSION),
}),
new HtmlWebpackPlugin({
template: "./src/ui.html",
filename: "ui.html",
Expand All@@ -60,6 +67,7 @@ module.exports = (env, argv) => [
template: "./src/bootloader.html",
filename: "bootloader.html",
inject: false,
templateParameters: { DXT_URL, MCP_VERSION },
}),
],
mode: argv.mode || "production",
Expand Down
11 changes: 6 additions & 5 deletions packages/claude-plugin/skills/pluginos-figma/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,17 +15,18 @@ You have `pluginos` MCP tools available AND the user is doing Figma work: design

## Tool routing (iron rule)

Always try `pluginos.*` first:
**Step 0: always call `pluginos.get_status` first** to confirm the bridge plugin is connected before any Figma work. If it returns disconnected, follow the Connection troubleshooting steps below — do NOT silently fall back to `mcp__Figma__*`.

Then prefer `pluginos.*`:

- `pluginos.list_operations` — discover what's available (only needed once per session; see quick-list below).
- `pluginos.run_operation` — execute a registered operation.
- `pluginos.execute_figma` — arbitrary plugin JS, only when no registered op fits.

Fall back to `mcp__Figma__*` tools ONLY when:
**Avoid `mcp__Figma__*` tools** (`get_design_context`, `get_variable_defs`, `get_screenshot`, etc.). They bypass the plugin and return raw, token-heavy node dumps; PluginOS returns pre-summarized, structured results at ~230 tokens/call. The only acceptable fallbacks to `mcp__Figma__*` are:

- PluginOS explicitly returns `no_operation_available`.
- The user requests Figma Code Connect mapping / `get_design_context`-style code generation.
- PluginOS is unreachable (see Connection troubleshooting below).
- PluginOS explicitly returns `no_operation_available` AND `execute_figma` cannot reasonably do the job.
- The user explicitly requests Figma Code Connect mapping or `get_design_context`-style code generation.

Never mix: one-shot a Figma task with either PluginOS or Figma MCP, don't interleave.

Expand Down
26 changes: 6 additions & 20 deletions scripts/bump-lockstep.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,9 +2,12 @@
/**
* Postversion hook for packages/mcp-server. When `npm version` bumps the
* MCP server, propagate the same version string to every file that pins the
* pluginos version: peer package.json manifests, the DXT manifest (both the
* top-level `version` and its `server.mcp_config.args` pin), and the
* hardcoded npx args inside the Figma plugin's UI sources.
* pluginos version: peer package.json manifests and the DXT manifest (both
* the top-level `version` and its `server.mcp_config.args` pin).
*
* Note: the Figma plugin UI (`bridge-plugin/src/ui-entry.ts` and
* `bootloader.html`) reads `mcp-server/package.json#version` at webpack
* build time, so no source-file rewrite is needed there.
*/
const fs = require("node:fs");
const path = require("node:path");
Expand DownExpand Up@@ -40,20 +43,3 @@ if (Array.isArray(dxt?.server?.mcp_config?.args)) {
}
fs.writeFileSync(dxtAbs, JSON.stringify(dxt, null, 2) + "\n");
console.log(`Bumped ${dxtManifestRel} → ${newVersion}`);

// Sources that hardcode `pluginos@<version>` in copy-paste MCP config snippets.
// These use a regex swap so formatting is preserved.
const sourceTargets = [
"packages/bridge-plugin/src/ui-entry.ts",
"packages/bridge-plugin/src/bootloader.html",
];

for (const rel of sourceTargets) {
const abs = path.join(repoRoot, rel);
const before = fs.readFileSync(abs, "utf8");
const after = before.replace(/pluginos@\d+\.\d+\.\d+/g, `pluginos@${newVersion}`);
if (after !== before) {
fs.writeFileSync(abs, after);
console.log(`Bumped ${rel} → pluginos@${newVersion}`);
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 9 additions & 17 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,16 +22,19 @@ PluginOS takes a fundamentally different approach:

### 1. Install for your agent

Pick whichever tool you're using. The Bridge Plugin (step 2) is the same for all of them.

**Claude Desktop (recommended for designers — one click):**
**Recommended — Claude Desktop (one click, no terminal):**

1. Download [`pluginos.dxt`](https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt) from the latest GitHub Release.
2. Double-click the downloaded file. Claude Desktop opens an install dialog.
3. Confirm. PluginOS appears in Claude Desktop's connector list.

No JSON editing, no terminal. Note: Claude.ai web is **not** supported — it cannot reach local MCP servers.

Using Cursor or Claude Code CLI? Open the section below for the right config.

<details>
<summary><strong>Other AI tools</strong> (Cursor, Claude Code CLI, manual config)</summary>

**Cursor (`.cursor/mcp.json`):**

```json
Expand All@@ -56,22 +59,11 @@ Then paste the Tier 1 rules below into `.cursorrules` so Cursor prefers PluginOS

Installs the MCP server registration and the `pluginos-figma` skill in one step.

**Manual (advanced — edit `claude_desktop_config.json` directly):**

```json
{
"mcpServers": {
"pluginos": {
"command": "npx",
"args": ["-y", "pluginos@latest"]
}
}
}
```
**Manual (other MCP-capable clients):** Use the same `mcpServers` JSON block as Cursor above and paste the Tier 1 rules below into your project's custom instructions.

Then paste the Tier 1 rules below into your project's custom instructions.
</details>

**Tier 1 rules (Cursor / Claude Desktop):**
**Tier 1 rules (paste into Cursor / Claude Desktop / other clients):**

```
When working with Figma, always use PluginOS tools exclusively:
Expand Down
2 changes: 2 additions & 0 deletions eslint.config.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@ export default tseslint.config(
"**/*.cjs",
"**/*.mjs",
"!eslint.config.js",
".claude/",
".worktrees/",
],
},
{
Expand Down
83 changes: 48 additions & 35 deletions packages/bridge-plugin/src/bootloader.html
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,12 +2,25 @@
<html>
<head>
<style>
/* Tokens aligned with ui.html — keep in sync. */
:root {
--cream: #FAF9F5;
--card-bg: #FFFFFE;
--card-border: #EDE6D6;
--text-primary: #2D2A22;
--text-secondary: #5C5346;
--text-muted: #7A7164;
--accent: #D97757;
--radius: 14px;
--shadow: 0 1px 2px rgba(60, 40, 20, 0.04);
--font-stack: "Styrene B", ui-sans-serif, system-ui, -apple-system, "Helvetica Neue", sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Inter, system-ui, sans-serif;
font-size: 11px;
background: var(--figma-color-bg);
color: var(--figma-color-text);
font-family: var(--font-stack);
font-size: 12.5px;
background: var(--cream);
color: var(--text-primary);
display: flex;
flex-direction: column;
height: 100vh;
Expand All@@ -21,25 +34,25 @@
.dot-green { background: #1bc47d; }
.dot-yellow { background: #f5a623; animation: pulse 1.2s ease-in-out infinite; }
@keyframes pulse { 0%,100%{opacity:1}50%{opacity:.35} }
.status-text { font-weight: 600; font-size: 11px; }
.status-sub { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-top: 1px; }
.divider { height: 1px; background: var(--figma-color-border, #e5e5e5); }
.setup-section { padding: 10px 12px 0; display: none; }
.setup-cards { display: flex; flex-direction: column; gap: 8px; }
.card { padding: 10px 12px; background: var(--figma-color-bg-secondary, #f5f5f5); border: 1px solid var(--figma-color-border, #e5e5e5); border-radius: 6px; }
.card-title { font-size: 11px; font-weight: 600; color: var(--figma-color-text); margin-bottom: 4px; }
.card-desc { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-bottom: 8px; }
.card-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.btn { font-family: inherit; font-size: 10px; padding: 4px 10px; border: 1px solid var(--figma-color-border, #e5e5e5); border-radius: 4px; background: var(--figma-color-bg, #fff); color: var(--figma-color-text, #111); cursor: pointer; transition: background 0.15s; }
.btn:hover { background: var(--figma-color-bg-hover, #ebebeb); }
.btn.copied { color: #1bc47d; border-color: #1bc47d; }
.btn-primary { background: var(--figma-color-text, #111); color: var(--figma-color-bg, #fff); border-color: var(--figma-color-text, #111); text-decoration: none; display: inline-block; }
.status-text { font-weight: 600; font-size: 12px; }
.status-sub { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
.divider { height: 1px; background: var(--card-border); }
.setup-section { padding: 12px; display: none; }
.setup-cards { display: flex; flex-direction: column; gap: 12px; }
.card { padding: 16px; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow); }
.card-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.card-desc { font-size: 12.5px; color: var(--text-secondary); line-height: 1.4; margin-bottom: 12px; }
.card-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.btn { font: inherit; font-size: 12px; font-weight: 500; padding: 6px 12px; border: 1px solid var(--card-border); border-radius: 8px; background: transparent; color: var(--text-primary); cursor: pointer; transition: background 0.15s; }
.btn:hover { background: var(--cream); }
.btn.copied { background: var(--accent); color: #FFF; border-color: var(--accent); }
.btn-primary { background: var(--text-primary); color: var(--card-bg); border-color: var(--text-primary); text-decoration: none; display: inline-block; }
.btn-primary:hover { opacity: 0.9; }
a.btn { text-decoration: none; }
.progress-section { padding: 8px 12px 10px; display: none; }
.progress-bar { height: 2px; background: var(--figma-color-border, #e5e5e5); border-radius: 1px; overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: #f5a623; border-radius: 1px; }
.countdown-text { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-top: 5px; text-align: center; }
.progress-bar { height: 2px; background: var(--card-border); border-radius: 1px; overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: var(--accent); border-radius: 1px; }
.countdown-text { font-size: 11px; color: var(--text-muted); margin-top: 5px; text-align: center; }
</style>
</head>
<body>
Expand DownExpand Up@@ -130,21 +143,27 @@
}, 1000);
}

var MCP_CONFIG_JSON = JSON.stringify({ mcpServers: { pluginos: { command: "npx", args: ["-y", "pluginos@0.4.2"] } } }, null, 2);
var MCP_CONFIG_JSON = JSON.stringify({ mcpServers: { pluginos: { command: "npx", args: ["-y", "pluginos@<%= MCP_VERSION %>"] } } }, null, 2);
var TIER_1_RULES = "When working with Figma, always use PluginOS tools exclusively:\n- Use `list_operations` (pluginos) first to discover available Figma operations.\n- Use `run_operation` (pluginos) to execute them.\n- Use `execute_figma` (pluginos) only for one-off custom logic not covered by built-in ops.\n- Do NOT use `mcp__Figma__*` tools — they bypass the plugin and return raw, token-heavy data. PluginOS returns pre-summarized, structured results at ~230 tokens/call.\n- If PluginOS returns \"No plugin connected\", open the PluginOS Bridge plugin in Figma before retrying.\n\nAudit/lint/check operations default to `scope: \"selection\"`. Pass `scope: \"page\"` explicitly (and `confirm: true` for pages over 500 nodes) to scan the whole page. Responses carry `_hint` and `_next_hints` fields — respect them when deciding what to do next.";
var INSTALL_COMMAND = "/plugin marketplace add github:LSDimi/pluginos\n/plugin install pluginos";
var DXT_URL = "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt";
var DXT_URL = "<%= DXT_URL %>";

function flashButton(btn, label) {
if (btn.classList.contains("copied")) return;
var orig = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}
Comment on lines +151 to +160

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The flashButton function has a race condition if triggered multiple times on the same button within the 2.5s window. The orig variable will capture the temporary label (e.g., "Copied!") on subsequent clicks, causing the button text to become stuck on that label once the timeouts resolve. Adding a guard clause to ignore clicks while the button is already in the "copied" state prevents this.

Suggested change
function flashButton(btn, label) {
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}
function flashButton(btn, label) {
if (btn.classList.contains("copied")) return;
var orig = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 97ab1ae — added a guard so a re-trigger inside the 2.5s window is a no-op (if (btn.classList.contains('copied')) return;). Same guard added to flashCopied in ui-entry.ts for symmetry.


function wireCopy(btnId, text) {
var btn = document.getElementById(btnId);
if (!btn) return;
btn.addEventListener("click", function() {
function onOk() {
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = "Copied!";
setTimeout(function() { btn.textContent = orig; btn.classList.remove("copied"); }, 2500);
}
function onOk() { flashButton(btn, "Copied!"); }
function onFail() { btn.textContent = "Copy failed"; }
function fallback() {
try {
Expand All@@ -170,13 +189,7 @@
{ pluginMessage: { type: "open-external", url: DXT_URL } },
"*"
);
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = "✓ Opening in browser…";
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
flashButton(btn, "✓ Opening in browser…");
});
})();
wireCopy("btn-copy-install", INSTALL_COMMAND);
Expand Down
3 changes: 3 additions & 0 deletions packages/bridge-plugin/src/constants.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
{
"DXT_URL": "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt"
}
9 changes: 6 additions & 3 deletions packages/bridge-plugin/src/ui-entry.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
import { DXT_URL } from "./constants.json";

declare const __MCP_VERSION__: string;

const TIER_1_RULES = `When working with Figma, always use PluginOS tools exclusively:
- Use \`list_operations\` (pluginos) first to discover available Figma operations.
- Use \`run_operation\` (pluginos) to execute them.
Expand All@@ -11,16 +15,14 @@ const MCP_CONFIG_JSON = `{
"mcpServers": {
"pluginos": {
"command": "npx",
"args": ["-y", "pluginos@0.4.2"]
"args": ["-y", "pluginos@${__MCP_VERSION__}"]
}
}
}`;

const INSTALL_COMMAND = `/plugin marketplace add github:LSDimi/pluginos
/plugin install pluginos`;

const DXT_URL = "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt";

const PORT_MIN = 9500;
const PORT_MAX = 9510;
const RECONNECT_DELAY = 3000;
Expand DownExpand Up@@ -49,6 +51,7 @@ function showView(view: "setup" | "connected") {
}

function flashCopied(btn: HTMLButtonElement, label = "✓ Copied") {
if (btn.classList.contains("copied")) return;
const original = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
Expand Down
1 change: 1 addition & 0 deletions packages/bridge-plugin/tsconfig.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
"rootDir": "src",
"outDir": "dist",
"module": "ESNext",
"resolveJsonModule": true,
"lib": ["ES2017", "DOM"],
"typeRoots": ["../../node_modules/@types", "../../node_modules/@figma"],
"types": ["plugin-typings"]
Expand Down
8 changes: 8 additions & 0 deletions packages/bridge-plugin/webpack.config.js
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
const path = require("path");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const HtmlInlineScriptPlugin = require("html-inline-script-webpack-plugin");

const { DXT_URL } = require("./src/constants.json");
const { version: MCP_VERSION } = require("../mcp-server/package.json");

const noModernSyntax = {
arrowFunction: true,
const: true,
Expand DownExpand Up@@ -41,6 +45,9 @@ module.exports = (env, argv) => [
resolve: { extensions: [".ts", ".js"] },
devtool: false,
plugins: [
new webpack.DefinePlugin({
__MCP_VERSION__: JSON.stringify(MCP_VERSION),
}),
new HtmlWebpackPlugin({
template: "./src/ui.html",
filename: "ui.html",
Expand All@@ -60,6 +67,7 @@ module.exports = (env, argv) => [
template: "./src/bootloader.html",
filename: "bootloader.html",
inject: false,
templateParameters: { DXT_URL, MCP_VERSION },
}),
],
mode: argv.mode || "production",
Expand Down
11 changes: 6 additions & 5 deletions packages/claude-plugin/skills/pluginos-figma/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,17 +15,18 @@ You have `pluginos` MCP tools available AND the user is doing Figma work: design

## Tool routing (iron rule)

Always try `pluginos.*` first:
**Step 0: always call `pluginos.get_status` first** to confirm the bridge plugin is connected before any Figma work. If it returns disconnected, follow the Connection troubleshooting steps below — do NOT silently fall back to `mcp__Figma__*`.

Then prefer `pluginos.*`:

- `pluginos.list_operations` — discover what's available (only needed once per session; see quick-list below).
- `pluginos.run_operation` — execute a registered operation.
- `pluginos.execute_figma` — arbitrary plugin JS, only when no registered op fits.

Fall back to `mcp__Figma__*` tools ONLY when:
**Avoid `mcp__Figma__*` tools** (`get_design_context`, `get_variable_defs`, `get_screenshot`, etc.). They bypass the plugin and return raw, token-heavy node dumps; PluginOS returns pre-summarized, structured results at ~230 tokens/call. The only acceptable fallbacks to `mcp__Figma__*` are:

- PluginOS explicitly returns `no_operation_available`.
- The user requests Figma Code Connect mapping / `get_design_context`-style code generation.
- PluginOS is unreachable (see Connection troubleshooting below).
- PluginOS explicitly returns `no_operation_available` AND `execute_figma` cannot reasonably do the job.
- The user explicitly requests Figma Code Connect mapping or `get_design_context`-style code generation.

Never mix: one-shot a Figma task with either PluginOS or Figma MCP, don't interleave.

Expand Down
26 changes: 6 additions & 20 deletions scripts/bump-lockstep.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,9 +2,12 @@
/**
* Postversion hook for packages/mcp-server. When `npm version` bumps the
* MCP server, propagate the same version string to every file that pins the
* pluginos version: peer package.json manifests, the DXT manifest (both the
* top-level `version` and its `server.mcp_config.args` pin), and the
* hardcoded npx args inside the Figma plugin's UI sources.
* pluginos version: peer package.json manifests and the DXT manifest (both
* the top-level `version` and its `server.mcp_config.args` pin).
*
* Note: the Figma plugin UI (`bridge-plugin/src/ui-entry.ts` and
* `bootloader.html`) reads `mcp-server/package.json#version` at webpack
* build time, so no source-file rewrite is needed there.
*/
const fs = require("node:fs");
const path = require("node:path");
Expand DownExpand Up@@ -40,20 +43,3 @@ if (Array.isArray(dxt?.server?.mcp_config?.args)) {
}
fs.writeFileSync(dxtAbs, JSON.stringify(dxt, null, 2) + "\n");
console.log(`Bumped ${dxtManifestRel} → ${newVersion}`);

// Sources that hardcode `pluginos@<version>` in copy-paste MCP config snippets.
// These use a regex swap so formatting is preserved.
const sourceTargets = [
"packages/bridge-plugin/src/ui-entry.ts",
"packages/bridge-plugin/src/bootloader.html",
];

for (const rel of sourceTargets) {
const abs = path.join(repoRoot, rel);
const before = fs.readFileSync(abs, "utf8");
const after = before.replace(/pluginos@\d+\.\d+\.\d+/g, `pluginos@${newVersion}`);
if (after !== before) {
fs.writeFileSync(abs, after);
console.log(`Bumped ${rel} → pluginos@${newVersion}`);
}
}
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 9 additions & 17 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,16 +22,19 @@ PluginOS takes a fundamentally different approach:

### 1. Install for your agent

Pick whichever tool you're using. The Bridge Plugin (step 2) is the same for all of them.

**Claude Desktop (recommended for designers — one click):**
**Recommended — Claude Desktop (one click, no terminal):**

1. Download [`pluginos.dxt`](https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt) from the latest GitHub Release.
2. Double-click the downloaded file. Claude Desktop opens an install dialog.
3. Confirm. PluginOS appears in Claude Desktop's connector list.

No JSON editing, no terminal. Note: Claude.ai web is **not** supported — it cannot reach local MCP servers.

Using Cursor or Claude Code CLI? Open the section below for the right config.

<details>
<summary><strong>Other AI tools</strong> (Cursor, Claude Code CLI, manual config)</summary>

**Cursor (`.cursor/mcp.json`):**

```json
Expand All@@ -56,22 +59,11 @@ Then paste the Tier 1 rules below into `.cursorrules` so Cursor prefers PluginOS

Installs the MCP server registration and the `pluginos-figma` skill in one step.

**Manual (advanced — edit `claude_desktop_config.json` directly):**

```json
{
"mcpServers": {
"pluginos": {
"command": "npx",
"args": ["-y", "pluginos@latest"]
}
}
}
```
**Manual (other MCP-capable clients):** Use the same `mcpServers` JSON block as Cursor above and paste the Tier 1 rules below into your project's custom instructions.

Then paste the Tier 1 rules below into your project's custom instructions.
</details>

**Tier 1 rules (Cursor / Claude Desktop):**
**Tier 1 rules (paste into Cursor / Claude Desktop / other clients):**

```
When working with Figma, always use PluginOS tools exclusively:
Expand Down
2 changes: 2 additions & 0 deletions eslint.config.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,8 @@ export default tseslint.config(
"**/*.cjs",
"**/*.mjs",
"!eslint.config.js",
".claude/",
".worktrees/",
],
},
{
Expand Down
83 changes: 48 additions & 35 deletions packages/bridge-plugin/src/bootloader.html
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,12 +2,25 @@
<html>
<head>
<style>
/* Tokens aligned with ui.html — keep in sync. */
:root {
--cream: #FAF9F5;
--card-bg: #FFFFFE;
--card-border: #EDE6D6;
--text-primary: #2D2A22;
--text-secondary: #5C5346;
--text-muted: #7A7164;
--accent: #D97757;
--radius: 14px;
--shadow: 0 1px 2px rgba(60, 40, 20, 0.04);
--font-stack: "Styrene B", ui-sans-serif, system-ui, -apple-system, "Helvetica Neue", sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Inter, system-ui, sans-serif;
font-size: 11px;
background: var(--figma-color-bg);
color: var(--figma-color-text);
font-family: var(--font-stack);
font-size: 12.5px;
background: var(--cream);
color: var(--text-primary);
display: flex;
flex-direction: column;
height: 100vh;
Expand All@@ -21,25 +34,25 @@
.dot-green { background: #1bc47d; }
.dot-yellow { background: #f5a623; animation: pulse 1.2s ease-in-out infinite; }
@keyframes pulse { 0%,100%{opacity:1}50%{opacity:.35} }
.status-text { font-weight: 600; font-size: 11px; }
.status-sub { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-top: 1px; }
.divider { height: 1px; background: var(--figma-color-border, #e5e5e5); }
.setup-section { padding: 10px 12px 0; display: none; }
.setup-cards { display: flex; flex-direction: column; gap: 8px; }
.card { padding: 10px 12px; background: var(--figma-color-bg-secondary, #f5f5f5); border: 1px solid var(--figma-color-border, #e5e5e5); border-radius: 6px; }
.card-title { font-size: 11px; font-weight: 600; color: var(--figma-color-text); margin-bottom: 4px; }
.card-desc { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-bottom: 8px; }
.card-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.btn { font-family: inherit; font-size: 10px; padding: 4px 10px; border: 1px solid var(--figma-color-border, #e5e5e5); border-radius: 4px; background: var(--figma-color-bg, #fff); color: var(--figma-color-text, #111); cursor: pointer; transition: background 0.15s; }
.btn:hover { background: var(--figma-color-bg-hover, #ebebeb); }
.btn.copied { color: #1bc47d; border-color: #1bc47d; }
.btn-primary { background: var(--figma-color-text, #111); color: var(--figma-color-bg, #fff); border-color: var(--figma-color-text, #111); text-decoration: none; display: inline-block; }
.status-text { font-weight: 600; font-size: 12px; }
.status-sub { font-size: 11px; color: var(--text-muted); margin-top: 1px; }
.divider { height: 1px; background: var(--card-border); }
.setup-section { padding: 12px; display: none; }
.setup-cards { display: flex; flex-direction: column; gap: 12px; }
.card { padding: 16px; background: var(--card-bg); border: 1px solid var(--card-border); border-radius: var(--radius); box-shadow: var(--shadow); }
.card-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.card-desc { font-size: 12.5px; color: var(--text-secondary); line-height: 1.4; margin-bottom: 12px; }
.card-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.btn { font: inherit; font-size: 12px; font-weight: 500; padding: 6px 12px; border: 1px solid var(--card-border); border-radius: 8px; background: transparent; color: var(--text-primary); cursor: pointer; transition: background 0.15s; }
.btn:hover { background: var(--cream); }
.btn.copied { background: var(--accent); color: #FFF; border-color: var(--accent); }
.btn-primary { background: var(--text-primary); color: var(--card-bg); border-color: var(--text-primary); text-decoration: none; display: inline-block; }
.btn-primary:hover { opacity: 0.9; }
a.btn { text-decoration: none; }
.progress-section { padding: 8px 12px 10px; display: none; }
.progress-bar { height: 2px; background: var(--figma-color-border, #e5e5e5); border-radius: 1px; overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: #f5a623; border-radius: 1px; }
.countdown-text { font-size: 10px; color: var(--figma-color-text-secondary, #999); margin-top: 5px; text-align: center; }
.progress-bar { height: 2px; background: var(--card-border); border-radius: 1px; overflow: hidden; }
.progress-fill { height: 100%; width: 0%; background: var(--accent); border-radius: 1px; }
.countdown-text { font-size: 11px; color: var(--text-muted); margin-top: 5px; text-align: center; }
</style>
</head>
<body>
Expand DownExpand Up@@ -130,21 +143,27 @@
}, 1000);
}

var MCP_CONFIG_JSON = JSON.stringify({ mcpServers: { pluginos: { command: "npx", args: ["-y", "pluginos@0.4.2"] } } }, null, 2);
var MCP_CONFIG_JSON = JSON.stringify({ mcpServers: { pluginos: { command: "npx", args: ["-y", "pluginos@<%= MCP_VERSION %>"] } } }, null, 2);
var TIER_1_RULES = "When working with Figma, always use PluginOS tools exclusively:\n- Use `list_operations` (pluginos) first to discover available Figma operations.\n- Use `run_operation` (pluginos) to execute them.\n- Use `execute_figma` (pluginos) only for one-off custom logic not covered by built-in ops.\n- Do NOT use `mcp__Figma__*` tools — they bypass the plugin and return raw, token-heavy data. PluginOS returns pre-summarized, structured results at ~230 tokens/call.\n- If PluginOS returns \"No plugin connected\", open the PluginOS Bridge plugin in Figma before retrying.\n\nAudit/lint/check operations default to `scope: \"selection\"`. Pass `scope: \"page\"` explicitly (and `confirm: true` for pages over 500 nodes) to scan the whole page. Responses carry `_hint` and `_next_hints` fields — respect them when deciding what to do next.";
var INSTALL_COMMAND = "/plugin marketplace add github:LSDimi/pluginos\n/plugin install pluginos";
var DXT_URL = "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt";
var DXT_URL = "<%= DXT_URL %>";

function flashButton(btn, label) {
if (btn.classList.contains("copied")) return;
var orig = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}
Comment on lines +151 to +160

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The flashButton function has a race condition if triggered multiple times on the same button within the 2.5s window. The orig variable will capture the temporary label (e.g., "Copied!") on subsequent clicks, causing the button text to become stuck on that label once the timeouts resolve. Adding a guard clause to ignore clicks while the button is already in the "copied" state prevents this.

Suggested change
function flashButton(btn, label) {
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}
function flashButton(btn, label) {
if (btn.classList.contains("copied")) return;
var orig = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
}

Copy link
Copy Markdown
OwnerAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 97ab1ae — added a guard so a re-trigger inside the 2.5s window is a no-op (if (btn.classList.contains('copied')) return;). Same guard added to flashCopied in ui-entry.ts for symmetry.


function wireCopy(btnId, text) {
var btn = document.getElementById(btnId);
if (!btn) return;
btn.addEventListener("click", function() {
function onOk() {
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = "Copied!";
setTimeout(function() { btn.textContent = orig; btn.classList.remove("copied"); }, 2500);
}
function onOk() { flashButton(btn, "Copied!"); }
function onFail() { btn.textContent = "Copy failed"; }
function fallback() {
try {
Expand All@@ -170,13 +189,7 @@
{ pluginMessage: { type: "open-external", url: DXT_URL } },
"*"
);
btn.classList.add("copied");
var orig = btn.textContent;
btn.textContent = "✓ Opening in browser…";
setTimeout(function() {
btn.textContent = orig;
btn.classList.remove("copied");
}, 2500);
flashButton(btn, "✓ Opening in browser…");
});
})();
wireCopy("btn-copy-install", INSTALL_COMMAND);
Expand Down
3 changes: 3 additions & 0 deletions packages/bridge-plugin/src/constants.json
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
{
"DXT_URL": "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt"
}
9 changes: 6 additions & 3 deletions packages/bridge-plugin/src/ui-entry.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
import { DXT_URL } from "./constants.json";

declare const __MCP_VERSION__: string;

const TIER_1_RULES = `When working with Figma, always use PluginOS tools exclusively:
- Use \`list_operations\` (pluginos) first to discover available Figma operations.
- Use \`run_operation\` (pluginos) to execute them.
Expand All@@ -11,16 +15,14 @@ const MCP_CONFIG_JSON = `{
"mcpServers": {
"pluginos": {
"command": "npx",
"args": ["-y", "pluginos@0.4.2"]
"args": ["-y", "pluginos@${__MCP_VERSION__}"]
}
}
}`;

const INSTALL_COMMAND = `/plugin marketplace add github:LSDimi/pluginos
/plugin install pluginos`;

const DXT_URL = "https://github.com/LSDimi/pluginos/releases/latest/download/pluginos.dxt";

const PORT_MIN = 9500;
const PORT_MAX = 9510;
const RECONNECT_DELAY = 3000;
Expand DownExpand Up@@ -49,6 +51,7 @@ function showView(view: "setup" | "connected") {
}

function flashCopied(btn: HTMLButtonElement, label = "✓ Copied") {
if (btn.classList.contains("copied")) return;
const original = btn.textContent;
btn.classList.add("copied");
btn.textContent = label;
Expand Down
1 change: 1 addition & 0 deletions packages/bridge-plugin/tsconfig.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,6 +5,7 @@
"rootDir": "src",
"outDir": "dist",
"module": "ESNext",
"resolveJsonModule": true,
"lib": ["ES2017", "DOM"],
"typeRoots": ["../../node_modules/@types", "../../node_modules/@figma"],
"types": ["plugin-typings"]
Expand Down
8 changes: 8 additions & 0 deletions packages/bridge-plugin/webpack.config.js
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
const path = require("path");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const HtmlInlineScriptPlugin = require("html-inline-script-webpack-plugin");

const { DXT_URL } = require("./src/constants.json");
const { version: MCP_VERSION } = require("../mcp-server/package.json");

const noModernSyntax = {
arrowFunction: true,
const: true,
Expand DownExpand Up@@ -41,6 +45,9 @@ module.exports = (env, argv) => [
resolve: { extensions: [".ts", ".js"] },
devtool: false,
plugins: [
new webpack.DefinePlugin({
__MCP_VERSION__: JSON.stringify(MCP_VERSION),
}),
new HtmlWebpackPlugin({
template: "./src/ui.html",
filename: "ui.html",
Expand All@@ -60,6 +67,7 @@ module.exports = (env, argv) => [
template: "./src/bootloader.html",
filename: "bootloader.html",
inject: false,
templateParameters: { DXT_URL, MCP_VERSION },
}),
],
mode: argv.mode || "production",
Expand Down
11 changes: 6 additions & 5 deletions packages/claude-plugin/skills/pluginos-figma/SKILL.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,17 +15,18 @@ You have `pluginos` MCP tools available AND the user is doing Figma work: design

## Tool routing (iron rule)

Always try `pluginos.*` first:
**Step 0: always call `pluginos.get_status` first** to confirm the bridge plugin is connected before any Figma work. If it returns disconnected, follow the Connection troubleshooting steps below — do NOT silently fall back to `mcp__Figma__*`.

Then prefer `pluginos.*`:

- `pluginos.list_operations` — discover what's available (only needed once per session; see quick-list below).
- `pluginos.run_operation` — execute a registered operation.
- `pluginos.execute_figma` — arbitrary plugin JS, only when no registered op fits.

Fall back to `mcp__Figma__*` tools ONLY when:
**Avoid `mcp__Figma__*` tools** (`get_design_context`, `get_variable_defs`, `get_screenshot`, etc.). They bypass the plugin and return raw, token-heavy node dumps; PluginOS returns pre-summarized, structured results at ~230 tokens/call. The only acceptable fallbacks to `mcp__Figma__*` are:

- PluginOS explicitly returns `no_operation_available`.
- The user requests Figma Code Connect mapping / `get_design_context`-style code generation.
- PluginOS is unreachable (see Connection troubleshooting below).
- PluginOS explicitly returns `no_operation_available` AND `execute_figma` cannot reasonably do the job.
- The user explicitly requests Figma Code Connect mapping or `get_design_context`-style code generation.

Never mix: one-shot a Figma task with either PluginOS or Figma MCP, don't interleave.

Expand Down
26 changes: 6 additions & 20 deletions scripts/bump-lockstep.cjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,9 +2,12 @@
/**
* Postversion hook for packages/mcp-server. When `npm version` bumps the
* MCP server, propagate the same version string to every file that pins the
* pluginos version: peer package.json manifests, the DXT manifest (both the
* top-level `version` and its `server.mcp_config.args` pin), and the
* hardcoded npx args inside the Figma plugin's UI sources.
* pluginos version: peer package.json manifests and the DXT manifest (both
* the top-level `version` and its `server.mcp_config.args` pin).
*
* Note: the Figma plugin UI (`bridge-plugin/src/ui-entry.ts` and
* `bootloader.html`) reads `mcp-server/package.json#version` at webpack
* build time, so no source-file rewrite is needed there.
*/
const fs = require("node:fs");
const path = require("node:path");
Expand DownExpand Up@@ -40,20 +43,3 @@ if (Array.isArray(dxt?.server?.mcp_config?.args)) {
}
fs.writeFileSync(dxtAbs, JSON.stringify(dxt, null, 2) + "\n");
console.log(`Bumped ${dxtManifestRel} → ${newVersion}`);

// Sources that hardcode `pluginos@<version>` in copy-paste MCP config snippets.
// These use a regex swap so formatting is preserved.
const sourceTargets = [
"packages/bridge-plugin/src/ui-entry.ts",
"packages/bridge-plugin/src/bootloader.html",
];

for (const rel of sourceTargets) {
const abs = path.join(repoRoot, rel);
const before = fs.readFileSync(abs, "utf8");
const after = before.replace(/pluginos@\d+\.\d+\.\d+/g, `pluginos@${newVersion}`);
if (after !== before) {
fs.writeFileSync(abs, after);
console.log(`Bumped ${rel} → pluginos@${newVersion}`);
}
}
Loading