faux-studio 0.4.7 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -6
- package/dist/index.js +383 -105
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -14,10 +14,17 @@ On first run, a browser window opens for Figma OAuth. After authentication, the
|
|
|
14
14
|
|
|
15
15
|
### Claude Code
|
|
16
16
|
|
|
17
|
+
```bash
|
|
18
|
+
claude mcp add --scope user faux-studio -- npx faux-studio@latest
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or manually add to your MCP settings:
|
|
22
|
+
|
|
17
23
|
```json
|
|
18
24
|
{
|
|
19
25
|
"mcpServers": {
|
|
20
26
|
"faux-studio": {
|
|
27
|
+
"type": "stdio",
|
|
21
28
|
"command": "npx",
|
|
22
29
|
"args": ["-y", "faux-studio"]
|
|
23
30
|
}
|
|
@@ -25,21 +32,38 @@ On first run, a browser window opens for Figma OAuth. After authentication, the
|
|
|
25
32
|
}
|
|
26
33
|
```
|
|
27
34
|
|
|
28
|
-
###
|
|
35
|
+
### OpenCode
|
|
29
36
|
|
|
30
|
-
|
|
37
|
+
manually add to your MCP settings (~/.config/opencode/opencode.json):
|
|
31
38
|
|
|
32
39
|
```json
|
|
33
40
|
{
|
|
34
|
-
"
|
|
41
|
+
"mcp": {
|
|
35
42
|
"faux-studio": {
|
|
36
|
-
"
|
|
37
|
-
"
|
|
43
|
+
"type": "local",
|
|
44
|
+
"command": [
|
|
45
|
+
"npx",
|
|
46
|
+
"faux-studio@latest"
|
|
47
|
+
]
|
|
38
48
|
}
|
|
39
49
|
}
|
|
40
50
|
}
|
|
41
51
|
```
|
|
42
52
|
|
|
53
|
+
### Codex
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
codex mcp add faux-studio -- npx -y faux-studio@latest
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or manually add to your MCP settings (~/.codex/config.toml):
|
|
60
|
+
|
|
61
|
+
```yml
|
|
62
|
+
[mcp_servers.faux-studio]
|
|
63
|
+
command = "npx"
|
|
64
|
+
args = ["-y", "faux-studio@latest"]
|
|
65
|
+
```
|
|
66
|
+
|
|
43
67
|
## How It Works
|
|
44
68
|
|
|
45
69
|
faux-studio runs locally and bridges your AI client to Figma Desktop:
|
|
@@ -58,7 +82,6 @@ faux-studio runs locally and bridges your AI client to Figma Desktop:
|
|
|
58
82
|
|
|
59
83
|
| Variable | Description |
|
|
60
84
|
|----------|-------------|
|
|
61
|
-
| `FAUX_API_KEY` | Skip OAuth — use an API key instead |
|
|
62
85
|
| `FAUX_TRANSPORT` | Force transport: `cdp` or `plugin` |
|
|
63
86
|
|
|
64
87
|
## Links
|
package/dist/index.js
CHANGED
|
@@ -10563,17 +10563,72 @@ var SecurityCliKeychainStore = class {
|
|
|
10563
10563
|
}
|
|
10564
10564
|
}
|
|
10565
10565
|
};
|
|
10566
|
+
var KeyringCredentialStore = class {
|
|
10567
|
+
name = "keychain";
|
|
10568
|
+
entry;
|
|
10569
|
+
cached = void 0;
|
|
10570
|
+
constructor(entry) {
|
|
10571
|
+
this.entry = entry;
|
|
10572
|
+
}
|
|
10573
|
+
async load() {
|
|
10574
|
+
if (this.cached !== void 0) return this.cached;
|
|
10575
|
+
try {
|
|
10576
|
+
const raw = await this.entry.getPassword();
|
|
10577
|
+
if (!raw) {
|
|
10578
|
+
this.cached = null;
|
|
10579
|
+
return null;
|
|
10580
|
+
}
|
|
10581
|
+
const creds = JSON.parse(raw);
|
|
10582
|
+
if (!creds.jwt || !creds.refreshToken || !creds.user) {
|
|
10583
|
+
this.cached = null;
|
|
10584
|
+
return null;
|
|
10585
|
+
}
|
|
10586
|
+
this.cached = creds;
|
|
10587
|
+
return creds;
|
|
10588
|
+
} catch {
|
|
10589
|
+
this.cached = null;
|
|
10590
|
+
return null;
|
|
10591
|
+
}
|
|
10592
|
+
}
|
|
10593
|
+
async save(creds) {
|
|
10594
|
+
try {
|
|
10595
|
+
await this.entry.setPassword(JSON.stringify(creds));
|
|
10596
|
+
this.cached = creds;
|
|
10597
|
+
} catch (err) {
|
|
10598
|
+
warn(`Could not save to keychain: ${err instanceof Error ? err.message : err}`);
|
|
10599
|
+
}
|
|
10600
|
+
}
|
|
10601
|
+
async clear() {
|
|
10602
|
+
try {
|
|
10603
|
+
await this.entry.deletePassword();
|
|
10604
|
+
this.cached = null;
|
|
10605
|
+
} catch {
|
|
10606
|
+
}
|
|
10607
|
+
}
|
|
10608
|
+
};
|
|
10566
10609
|
async function tryCreateKeychainStore() {
|
|
10567
|
-
if (process.platform
|
|
10568
|
-
|
|
10569
|
-
|
|
10610
|
+
if (process.platform === "darwin") {
|
|
10611
|
+
try {
|
|
10612
|
+
log("[keychain] probing macOS security CLI...");
|
|
10613
|
+
const t0 = Date.now();
|
|
10614
|
+
await securityExec("help");
|
|
10615
|
+
log(`[keychain] security CLI available (${Date.now() - t0}ms)`);
|
|
10616
|
+
return new SecurityCliKeychainStore();
|
|
10617
|
+
} catch (err) {
|
|
10618
|
+
warn(
|
|
10619
|
+
`OS keychain not available, using file-based credential storage: ${err instanceof Error ? err.message : err}`
|
|
10620
|
+
);
|
|
10621
|
+
return null;
|
|
10622
|
+
}
|
|
10570
10623
|
}
|
|
10571
10624
|
try {
|
|
10572
|
-
log("[keychain]
|
|
10625
|
+
log("[keychain] importing @napi-rs/keyring...");
|
|
10573
10626
|
const t0 = Date.now();
|
|
10574
|
-
await
|
|
10575
|
-
|
|
10576
|
-
|
|
10627
|
+
const { AsyncEntry } = await import("@napi-rs/keyring");
|
|
10628
|
+
const entry = new AsyncEntry(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
|
|
10629
|
+
await entry.getPassword();
|
|
10630
|
+
log(`[keychain] keyring available (${Date.now() - t0}ms)`);
|
|
10631
|
+
return new KeyringCredentialStore(entry);
|
|
10577
10632
|
} catch (err) {
|
|
10578
10633
|
warn(
|
|
10579
10634
|
`OS keychain not available, using file-based credential storage: ${err instanceof Error ? err.message : err}`
|
|
@@ -10602,7 +10657,7 @@ async function initStore() {
|
|
|
10602
10657
|
const fileCreds = await fileStore.load();
|
|
10603
10658
|
if (fileCreds) {
|
|
10604
10659
|
await keychainStore.save(fileCreds);
|
|
10605
|
-
keychainStore
|
|
10660
|
+
keychainStore.cached = void 0;
|
|
10606
10661
|
const verified = await keychainStore.load();
|
|
10607
10662
|
if (verified) {
|
|
10608
10663
|
await fileStore.clear();
|
|
@@ -10626,24 +10681,33 @@ function isExpiringSoon(creds) {
|
|
|
10626
10681
|
const expiresAt = new Date(creds.expiresAt).getTime();
|
|
10627
10682
|
return Date.now() > expiresAt - REFRESH_BUFFER_MS;
|
|
10628
10683
|
}
|
|
10684
|
+
var inflightRefresh = null;
|
|
10629
10685
|
async function refreshJwt(creds) {
|
|
10630
|
-
|
|
10631
|
-
|
|
10632
|
-
headers: { "Content-Type": "application/json" },
|
|
10633
|
-
body: JSON.stringify({ refreshToken: creds.refreshToken })
|
|
10634
|
-
});
|
|
10635
|
-
if (!res.ok) {
|
|
10636
|
-
const text = await res.text().catch(() => "");
|
|
10637
|
-
throw new Error(`Token refresh failed (HTTP ${res.status})${text ? ": " + text : ""}`);
|
|
10686
|
+
if (inflightRefresh) {
|
|
10687
|
+
return inflightRefresh;
|
|
10638
10688
|
}
|
|
10639
|
-
const
|
|
10640
|
-
|
|
10641
|
-
|
|
10642
|
-
|
|
10643
|
-
|
|
10644
|
-
|
|
10645
|
-
|
|
10689
|
+
const doRefresh = async () => {
|
|
10690
|
+
const res = await fetch(`${AUTH_BASE}/auth/refresh`, {
|
|
10691
|
+
method: "POST",
|
|
10692
|
+
headers: { "Content-Type": "application/json" },
|
|
10693
|
+
body: JSON.stringify({ refreshToken: creds.refreshToken })
|
|
10694
|
+
});
|
|
10695
|
+
if (!res.ok) {
|
|
10696
|
+
const text = await res.text().catch(() => "");
|
|
10697
|
+
throw new Error(`Token refresh failed (HTTP ${res.status})${text ? ": " + text : ""}`);
|
|
10698
|
+
}
|
|
10699
|
+
const data = await res.json();
|
|
10700
|
+
return {
|
|
10701
|
+
jwt: data.jwt,
|
|
10702
|
+
refreshToken: data.refreshToken,
|
|
10703
|
+
expiresAt: new Date(Date.now() + data.expiresIn * 1e3).toISOString(),
|
|
10704
|
+
user: creds.user
|
|
10705
|
+
};
|
|
10646
10706
|
};
|
|
10707
|
+
inflightRefresh = doRefresh().finally(() => {
|
|
10708
|
+
inflightRefresh = null;
|
|
10709
|
+
});
|
|
10710
|
+
return inflightRefresh;
|
|
10647
10711
|
}
|
|
10648
10712
|
function openBrowser(url2) {
|
|
10649
10713
|
let child;
|
|
@@ -10919,6 +10983,124 @@ async function generateScript(jwt2, toolName, params) {
|
|
|
10919
10983
|
return { script: data.script, signature: data.signature };
|
|
10920
10984
|
}
|
|
10921
10985
|
|
|
10986
|
+
// src/icons.ts
|
|
10987
|
+
function parseIconName(iconName) {
|
|
10988
|
+
if (iconName.includes(":")) {
|
|
10989
|
+
const [prefix, name] = iconName.split(":");
|
|
10990
|
+
return { prefix, name };
|
|
10991
|
+
}
|
|
10992
|
+
const parts = iconName.split("-");
|
|
10993
|
+
if (parts.length > 1) {
|
|
10994
|
+
return { prefix: parts[0], name: parts.slice(1).join("-") };
|
|
10995
|
+
}
|
|
10996
|
+
return { prefix: "lucide", name: iconName };
|
|
10997
|
+
}
|
|
10998
|
+
async function fetchIconSvg(iconName, size = 24) {
|
|
10999
|
+
const { prefix, name } = parseIconName(iconName);
|
|
11000
|
+
const url2 = `https://api.iconify.design/${prefix}/${name}.svg?width=${size}&height=${size}`;
|
|
11001
|
+
const response = await fetch(url2);
|
|
11002
|
+
if (!response.ok) {
|
|
11003
|
+
if (response.status === 404) throw new Error(`Icon not found: ${iconName}`);
|
|
11004
|
+
throw new Error(`Failed to fetch icon: HTTP ${response.status}`);
|
|
11005
|
+
}
|
|
11006
|
+
return await response.text();
|
|
11007
|
+
}
|
|
11008
|
+
function detectIconType(svgContent) {
|
|
11009
|
+
if (svgContent.includes('stroke="currentColor"') || svgContent.includes("stroke='currentColor'")) {
|
|
11010
|
+
return "stroke";
|
|
11011
|
+
}
|
|
11012
|
+
if (svgContent.includes('opacity="0.') || svgContent.includes("opacity='0.")) {
|
|
11013
|
+
return "duotone";
|
|
11014
|
+
}
|
|
11015
|
+
return "fill";
|
|
11016
|
+
}
|
|
11017
|
+
async function searchSingleQuery(query, limit, prefix, category) {
|
|
11018
|
+
let url2 = `https://api.iconify.design/search?query=${encodeURIComponent(query)}&limit=${limit}`;
|
|
11019
|
+
if (prefix) url2 += `&prefix=${encodeURIComponent(prefix)}`;
|
|
11020
|
+
if (category) url2 += `&category=${encodeURIComponent(category)}`;
|
|
11021
|
+
const response = await fetch(url2);
|
|
11022
|
+
if (!response.ok) {
|
|
11023
|
+
if (response.status === 404) throw new Error("Icon search is currently disabled");
|
|
11024
|
+
throw new Error(`Search failed: HTTP ${response.status}`);
|
|
11025
|
+
}
|
|
11026
|
+
const data = await response.json();
|
|
11027
|
+
const icons = data.icons || [];
|
|
11028
|
+
return { query, total: icons.length, icons };
|
|
11029
|
+
}
|
|
11030
|
+
async function searchIcons(params) {
|
|
11031
|
+
const { limit = 3, prefix, category } = params;
|
|
11032
|
+
const effectiveLimit = Math.min(limit, 100);
|
|
11033
|
+
if (params.queries && params.queries.length > 0) {
|
|
11034
|
+
const results = await Promise.all(
|
|
11035
|
+
params.queries.map((q) => searchSingleQuery(q.trim(), effectiveLimit, prefix, category))
|
|
11036
|
+
);
|
|
11037
|
+
return { results, totalQueries: results.length };
|
|
11038
|
+
}
|
|
11039
|
+
const query = params.query;
|
|
11040
|
+
if (!query || query.trim().length === 0) {
|
|
11041
|
+
throw new Error("Search query is required (provide `query` or `queries`)");
|
|
11042
|
+
}
|
|
11043
|
+
return searchSingleQuery(query.trim(), effectiveLimit, prefix, category);
|
|
11044
|
+
}
|
|
11045
|
+
async function resolveIconsInNode(node) {
|
|
11046
|
+
if (node.$icon && typeof node.$icon === "string") {
|
|
11047
|
+
const searchTerm = node.$icon;
|
|
11048
|
+
const size = typeof node.size === "number" ? node.size : 24;
|
|
11049
|
+
try {
|
|
11050
|
+
const searchResult = await searchIcons({ query: searchTerm, limit: 1 });
|
|
11051
|
+
if (searchResult.icons.length === 0) {
|
|
11052
|
+
warn(`No icons found for: ${searchTerm}`);
|
|
11053
|
+
const { $icon: $icon2, ...rest2 } = node;
|
|
11054
|
+
return { ...rest2, $iconError: `No icons found for: ${searchTerm}` };
|
|
11055
|
+
}
|
|
11056
|
+
const iconName = searchResult.icons[0];
|
|
11057
|
+
const svgContent = await fetchIconSvg(iconName, size);
|
|
11058
|
+
const iconType = detectIconType(svgContent);
|
|
11059
|
+
const { $icon, ...rest } = node;
|
|
11060
|
+
return {
|
|
11061
|
+
...rest,
|
|
11062
|
+
$iconResolved: { svgContent, iconType, iconName, searchTerm }
|
|
11063
|
+
};
|
|
11064
|
+
} catch (error3) {
|
|
11065
|
+
error(`Failed to resolve icon "${searchTerm}": ${error3 instanceof Error ? error3.message : String(error3)}`);
|
|
11066
|
+
const { $icon, ...rest } = node;
|
|
11067
|
+
return { ...rest, $iconError: `Failed to load icon: ${error3 instanceof Error ? error3.message : "Unknown error"}` };
|
|
11068
|
+
}
|
|
11069
|
+
}
|
|
11070
|
+
let result = { ...node };
|
|
11071
|
+
if (node.children && Array.isArray(node.children)) {
|
|
11072
|
+
result.children = await Promise.all(
|
|
11073
|
+
node.children.map(async (child) => {
|
|
11074
|
+
if (child && typeof child === "object") {
|
|
11075
|
+
return resolveIconsInNode(child);
|
|
11076
|
+
}
|
|
11077
|
+
return child;
|
|
11078
|
+
})
|
|
11079
|
+
);
|
|
11080
|
+
}
|
|
11081
|
+
if (node.template && typeof node.template === "object") {
|
|
11082
|
+
result.template = await resolveIconsInNode(node.template);
|
|
11083
|
+
}
|
|
11084
|
+
return result;
|
|
11085
|
+
}
|
|
11086
|
+
var ICON_TOOLS = /* @__PURE__ */ new Set(["create_from_schema", "modify_via_schema"]);
|
|
11087
|
+
async function preprocessIconsInParams(toolName, params) {
|
|
11088
|
+
if (!ICON_TOOLS.has(toolName)) return params;
|
|
11089
|
+
const result = { ...params };
|
|
11090
|
+
if (result.schema && typeof result.schema === "object") {
|
|
11091
|
+
result.schema = await resolveIconsInNode(result.schema);
|
|
11092
|
+
}
|
|
11093
|
+
if (result.modifications && Array.isArray(result.modifications)) {
|
|
11094
|
+
result.modifications = await Promise.all(
|
|
11095
|
+
result.modifications.map(async (mod) => ({
|
|
11096
|
+
...mod,
|
|
11097
|
+
schema: await resolveIconsInNode(mod.schema)
|
|
11098
|
+
}))
|
|
11099
|
+
);
|
|
11100
|
+
}
|
|
11101
|
+
return result;
|
|
11102
|
+
}
|
|
11103
|
+
|
|
10922
11104
|
// node_modules/ws/wrapper.mjs
|
|
10923
11105
|
var import_stream = __toESM(require_stream(), 1);
|
|
10924
11106
|
var import_receiver = __toESM(require_receiver(), 1);
|
|
@@ -11396,7 +11578,7 @@ var PORT_RANGE = 8;
|
|
|
11396
11578
|
var SCRIPT_TIMEOUT_MS = 6e4;
|
|
11397
11579
|
var CONNECT_WAIT_MS = 3e4;
|
|
11398
11580
|
var FAUX_DIR3 = join4(homedir3(), ".faux");
|
|
11399
|
-
var PluginWsServer = class {
|
|
11581
|
+
var PluginWsServer = class _PluginWsServer {
|
|
11400
11582
|
name = "plugin-ws";
|
|
11401
11583
|
wss = null;
|
|
11402
11584
|
connections = /* @__PURE__ */ new Map();
|
|
@@ -11404,6 +11586,17 @@ var PluginWsServer = class {
|
|
|
11404
11586
|
connectWaiters = [];
|
|
11405
11587
|
_activeFileId = null;
|
|
11406
11588
|
_port = 0;
|
|
11589
|
+
// File tracking state (mirrors CdpFileTracker API)
|
|
11590
|
+
_lastAcknowledgedFileId = null;
|
|
11591
|
+
_lastAcknowledgedFileName = null;
|
|
11592
|
+
_lastAcknowledgedAt = 0;
|
|
11593
|
+
_lastFocusEventAt = 0;
|
|
11594
|
+
/** How long before focus events are considered stale for high-confidence detection. */
|
|
11595
|
+
static FOCUS_FRESH_MS = 12e4;
|
|
11596
|
+
// 2 minutes
|
|
11597
|
+
/** How long before last-known file is considered stale and untrusted (matches CDP). */
|
|
11598
|
+
static LAST_KNOWN_STALE_MS = 5 * 6e4;
|
|
11599
|
+
// 5 minutes
|
|
11407
11600
|
// -------------------------------------------------------------------------
|
|
11408
11601
|
// Public Getters
|
|
11409
11602
|
// -------------------------------------------------------------------------
|
|
@@ -11422,6 +11615,79 @@ var PluginWsServer = class {
|
|
|
11422
11615
|
get hasConnections() {
|
|
11423
11616
|
return this.connections.size > 0;
|
|
11424
11617
|
}
|
|
11618
|
+
get activeFileName() {
|
|
11619
|
+
if (!this._activeFileId) return null;
|
|
11620
|
+
return this.connections.get(this._activeFileId)?.fileName ?? null;
|
|
11621
|
+
}
|
|
11622
|
+
get lastAcknowledgedFileName() {
|
|
11623
|
+
return this._lastAcknowledgedFileName;
|
|
11624
|
+
}
|
|
11625
|
+
// -------------------------------------------------------------------------
|
|
11626
|
+
// File Tracking
|
|
11627
|
+
// -------------------------------------------------------------------------
|
|
11628
|
+
/**
|
|
11629
|
+
* Detect the active file for the plugin transport.
|
|
11630
|
+
* Unlike CDP (which introspects shell.html), this is synchronous — the
|
|
11631
|
+
* plugin tells us via file-focus events. The cacheTtlMs parameter is
|
|
11632
|
+
* accepted for API compatibility but detection is always in-memory.
|
|
11633
|
+
*/
|
|
11634
|
+
detectActiveFile(_cacheTtlMs = 2e3) {
|
|
11635
|
+
const allFiles = this.getAllFileInfos();
|
|
11636
|
+
if (this.connections.size === 0) {
|
|
11637
|
+
return { activeFile: null, allFiles, method: "none", confidence: "none" };
|
|
11638
|
+
}
|
|
11639
|
+
if (this.connections.size === 1) {
|
|
11640
|
+
const conn = this.connections.values().next().value;
|
|
11641
|
+
return { activeFile: this.connToFileInfo(conn), allFiles, method: "single-file", confidence: "high" };
|
|
11642
|
+
}
|
|
11643
|
+
if (this._activeFileId && this.connections.has(this._activeFileId)) {
|
|
11644
|
+
const conn = this.connections.get(this._activeFileId);
|
|
11645
|
+
const info = this.connToFileInfo(conn);
|
|
11646
|
+
const focusAge = Date.now() - this._lastFocusEventAt;
|
|
11647
|
+
if (this._lastFocusEventAt > 0 && focusAge < _PluginWsServer.FOCUS_FRESH_MS) {
|
|
11648
|
+
return { activeFile: info, allFiles, method: "focus-event", confidence: "high" };
|
|
11649
|
+
}
|
|
11650
|
+
if (this.isLastKnownStale()) {
|
|
11651
|
+
log("File tracker: focus and acknowledgment both stale \u2014 cannot detect active file");
|
|
11652
|
+
return { activeFile: null, allFiles, method: "none", confidence: "none" };
|
|
11653
|
+
}
|
|
11654
|
+
return { activeFile: info, allFiles, method: "last-known", confidence: "low" };
|
|
11655
|
+
}
|
|
11656
|
+
return { activeFile: null, allFiles, method: "none", confidence: "none" };
|
|
11657
|
+
}
|
|
11658
|
+
/** Whether the active file has changed since the last acknowledged execution. */
|
|
11659
|
+
hasFileChanged() {
|
|
11660
|
+
return this._activeFileId !== null && this._lastAcknowledgedFileId !== null && this._activeFileId !== this._lastAcknowledgedFileId;
|
|
11661
|
+
}
|
|
11662
|
+
/**
|
|
11663
|
+
* Acknowledge the current active file as the intended target.
|
|
11664
|
+
* Call AFTER successful script execution.
|
|
11665
|
+
*/
|
|
11666
|
+
acknowledgeActiveFile() {
|
|
11667
|
+
if (this._activeFileId) {
|
|
11668
|
+
this._lastAcknowledgedFileId = this._activeFileId;
|
|
11669
|
+
this._lastAcknowledgedFileName = this.connections.get(this._activeFileId)?.fileName ?? null;
|
|
11670
|
+
this._lastAcknowledgedAt = Date.now();
|
|
11671
|
+
}
|
|
11672
|
+
}
|
|
11673
|
+
/** True if the last acknowledged execution was more than 5 minutes ago. */
|
|
11674
|
+
isLastKnownStale() {
|
|
11675
|
+
if (this._lastAcknowledgedAt === 0) return false;
|
|
11676
|
+
return Date.now() - this._lastAcknowledgedAt > _PluginWsServer.LAST_KNOWN_STALE_MS;
|
|
11677
|
+
}
|
|
11678
|
+
/** All connected files as FigmaFileInfo[]. */
|
|
11679
|
+
getAllFileInfos() {
|
|
11680
|
+
return Array.from(this.connections.values()).map((c) => this.connToFileInfo(c));
|
|
11681
|
+
}
|
|
11682
|
+
/** Build FigmaFileInfo from a PluginConnection. */
|
|
11683
|
+
connToFileInfo(conn) {
|
|
11684
|
+
return {
|
|
11685
|
+
fileKey: conn.fileId,
|
|
11686
|
+
fileName: conn.fileName,
|
|
11687
|
+
targetId: conn.fileId,
|
|
11688
|
+
wsUrl: ""
|
|
11689
|
+
};
|
|
11690
|
+
}
|
|
11425
11691
|
// -------------------------------------------------------------------------
|
|
11426
11692
|
// Server Lifecycle
|
|
11427
11693
|
// -------------------------------------------------------------------------
|
|
@@ -11516,6 +11782,7 @@ var PluginWsServer = class {
|
|
|
11516
11782
|
this.connections.set(fileId, { ws, fileId, fileName, connectedAt: Date.now() });
|
|
11517
11783
|
if (this.connections.size === 1 || !this._activeFileId) {
|
|
11518
11784
|
this._activeFileId = fileId;
|
|
11785
|
+
this._lastFocusEventAt = Date.now();
|
|
11519
11786
|
}
|
|
11520
11787
|
log(`Plugin connected: "${fileName}" (${fileId}) [${this.connections.size} file(s)]`);
|
|
11521
11788
|
for (const waiter of this.connectWaiters) {
|
|
@@ -11576,6 +11843,7 @@ var PluginWsServer = class {
|
|
|
11576
11843
|
const focusFileId = msg.fileId || fileId;
|
|
11577
11844
|
if (this.connections.has(focusFileId)) {
|
|
11578
11845
|
this._activeFileId = focusFileId;
|
|
11846
|
+
this._lastFocusEventAt = Date.now();
|
|
11579
11847
|
}
|
|
11580
11848
|
break;
|
|
11581
11849
|
}
|
|
@@ -25988,10 +26256,10 @@ var RESOURCES = [
|
|
|
25988
26256
|
var INSTRUCTIONS = `You are connected to Figma Desktop via faux-studio. You can create, modify, and inspect designs using the tools below.
|
|
25989
26257
|
|
|
25990
26258
|
## Transport
|
|
25991
|
-
faux-studio connects to Figma Desktop via **
|
|
26259
|
+
faux-studio connects to Figma Desktop via the **faux-studio plugin** by default \u2014 install and run the plugin from Figma's Plugins menu. The plugin communicates via local WebSocket with real-time file focus tracking. If the plugin is not available, faux-studio falls back to CDP (Chrome DevTools Protocol) which requires launching Figma with a debug port. The plugin is the preferred transport.
|
|
25992
26260
|
|
|
25993
26261
|
## Workflow
|
|
25994
|
-
1. Call \`setup_figma\` first to ensure Figma is running and connected (via
|
|
26262
|
+
1. Call \`setup_figma\` first to ensure Figma is running and connected (via plugin or CDP).
|
|
25995
26263
|
2. Call \`get_page_structure\` or \`get_screenshot\` to understand what's on the canvas.
|
|
25996
26264
|
3. Use \`create_from_schema\` for all creation \u2014 it accepts declarative JSON schemas.
|
|
25997
26265
|
4. Use \`modify_via_schema\` to change existing nodes.
|
|
@@ -26031,7 +26299,7 @@ Resources provide quick read-only access to Figma state without tool calls:
|
|
|
26031
26299
|
- Create components for reusable UI patterns.`;
|
|
26032
26300
|
function createMcpServer(deps) {
|
|
26033
26301
|
const server2 = new Server(
|
|
26034
|
-
{ name: "faux-studio", version: "0.
|
|
26302
|
+
{ name: "faux-studio", version: "0.5.1" },
|
|
26035
26303
|
{
|
|
26036
26304
|
capabilities: { tools: { listChanged: true }, resources: {}, logging: {} },
|
|
26037
26305
|
instructions: INSTRUCTIONS
|
|
@@ -26046,7 +26314,7 @@ function createMcpServer(deps) {
|
|
|
26046
26314
|
},
|
|
26047
26315
|
{
|
|
26048
26316
|
name: "setup_figma",
|
|
26049
|
-
description: "Ensure Figma Desktop is running and connected via
|
|
26317
|
+
description: "Ensure Figma Desktop is running and connected via plugin WebSocket (preferred) or CDP (fallback). Call this before any design work. Detects open files and the active tab. Returns connection status, transport mode, active file, and list of all open design files. Idempotent \u2014 safe to call multiple times.",
|
|
26050
26318
|
inputSchema: {
|
|
26051
26319
|
type: "object",
|
|
26052
26320
|
properties: {},
|
|
@@ -26133,6 +26401,13 @@ function createMcpServer(deps) {
|
|
|
26133
26401
|
throw err;
|
|
26134
26402
|
}
|
|
26135
26403
|
}
|
|
26404
|
+
if (name === "search_icons") {
|
|
26405
|
+
log("search_icons called (server-side)");
|
|
26406
|
+
const result2 = await searchIcons(params);
|
|
26407
|
+
return {
|
|
26408
|
+
content: [{ type: "text", text: JSON.stringify(result2, null, 2) }]
|
|
26409
|
+
};
|
|
26410
|
+
}
|
|
26136
26411
|
const intents = {
|
|
26137
26412
|
meso: typeof params._intent_meso === "string" ? params._intent_meso : void 0,
|
|
26138
26413
|
macro: typeof params._intent_macro === "string" ? params._intent_macro : void 0
|
|
@@ -26296,6 +26571,24 @@ async function tryConnectCdp() {
|
|
|
26296
26571
|
}
|
|
26297
26572
|
}
|
|
26298
26573
|
async function executeScript(script, intents, opts) {
|
|
26574
|
+
if (forceTransport !== "cdp" && pluginServer.hasConnections) {
|
|
26575
|
+
const detection = pluginServer.detectActiveFile(opts?.cacheTtlMs);
|
|
26576
|
+
if (!detection.activeFile && detection.allFiles.length > 1) {
|
|
26577
|
+
throw new FileUnknownError(detection.allFiles);
|
|
26578
|
+
}
|
|
26579
|
+
if (pluginServer.hasFileChanged()) {
|
|
26580
|
+
throw new FileChangedError(
|
|
26581
|
+
pluginServer.lastAcknowledgedFileName,
|
|
26582
|
+
pluginServer.activeFileName,
|
|
26583
|
+
pluginServer.activeFileId,
|
|
26584
|
+
detection.allFiles
|
|
26585
|
+
);
|
|
26586
|
+
}
|
|
26587
|
+
log(`Executing via plugin (file: ${pluginServer.activeFileId || "active"})`);
|
|
26588
|
+
const result = await pluginServer.executeScript(script, void 0, intents);
|
|
26589
|
+
pluginServer.acknowledgeActiveFile();
|
|
26590
|
+
return result;
|
|
26591
|
+
}
|
|
26299
26592
|
if (forceTransport !== "plugin") {
|
|
26300
26593
|
const client = await tryConnectCdp();
|
|
26301
26594
|
if (client) {
|
|
@@ -26332,10 +26625,6 @@ async function executeScript(script, intents, opts) {
|
|
|
26332
26625
|
}
|
|
26333
26626
|
}
|
|
26334
26627
|
}
|
|
26335
|
-
if (forceTransport !== "cdp" && pluginServer.hasConnections) {
|
|
26336
|
-
log(`Executing via plugin (file: ${pluginServer.activeFileId || "active"})`);
|
|
26337
|
-
return pluginServer.executeScript(script, void 0, intents);
|
|
26338
|
-
}
|
|
26339
26628
|
log("No transport available, waiting for plugin...");
|
|
26340
26629
|
return pluginServer.executeScript(script, void 0, intents);
|
|
26341
26630
|
}
|
|
@@ -26361,6 +26650,7 @@ async function recoverCdp(client, script) {
|
|
|
26361
26650
|
async function generateWithAuth(toolName, params) {
|
|
26362
26651
|
await lazyInit();
|
|
26363
26652
|
auth = await refreshIfNeeded(auth);
|
|
26653
|
+
params = await preprocessIconsInParams(toolName, params);
|
|
26364
26654
|
let result;
|
|
26365
26655
|
try {
|
|
26366
26656
|
result = await generateScript(auth.jwt, toolName, params);
|
|
@@ -26398,16 +26688,23 @@ async function waitForPlugin() {
|
|
|
26398
26688
|
return false;
|
|
26399
26689
|
}
|
|
26400
26690
|
function pluginReadyResult() {
|
|
26691
|
+
const detection = pluginServer.detectActiveFile();
|
|
26692
|
+
const activeName = detection.activeFile?.fileName;
|
|
26693
|
+
const fileCount = detection.allFiles.length;
|
|
26401
26694
|
return {
|
|
26402
26695
|
status: "ready",
|
|
26403
26696
|
transport: "plugin",
|
|
26404
|
-
message: `Connected via plugin.${
|
|
26405
|
-
activeFile:
|
|
26697
|
+
message: `Connected via plugin.${activeName ? ` Active file: "${activeName}".` : ""} ${fileCount} file(s) open. Ready to design.`,
|
|
26698
|
+
activeFile: activeName || void 0,
|
|
26406
26699
|
pluginFiles: pluginServer.connectedFiles,
|
|
26700
|
+
openFiles: detection.allFiles.map((f) => ({ fileKey: f.fileKey, fileName: f.fileName })),
|
|
26407
26701
|
port: pluginServer.port
|
|
26408
26702
|
};
|
|
26409
26703
|
}
|
|
26410
26704
|
async function setupFigma(params) {
|
|
26705
|
+
if (forceTransport !== "cdp" && pluginServer.hasConnections) {
|
|
26706
|
+
return pluginReadyResult();
|
|
26707
|
+
}
|
|
26411
26708
|
if (forceTransport !== "plugin" && cdpClient?.connected && cdpClient.hasContext) {
|
|
26412
26709
|
if (fileTracker) {
|
|
26413
26710
|
const detection = await fileTracker.detectActiveFile();
|
|
@@ -26425,40 +26722,6 @@ async function setupFigma(params) {
|
|
|
26425
26722
|
message: "Connected via CDP. Ready to design."
|
|
26426
26723
|
};
|
|
26427
26724
|
}
|
|
26428
|
-
if (forceTransport !== "cdp" && pluginServer.hasConnections) {
|
|
26429
|
-
return pluginReadyResult();
|
|
26430
|
-
}
|
|
26431
|
-
if (forceTransport !== "plugin") {
|
|
26432
|
-
const existing = await probeCdpPorts();
|
|
26433
|
-
if (existing) {
|
|
26434
|
-
const target = findFigmaDesignTarget(existing.targets);
|
|
26435
|
-
if (target) {
|
|
26436
|
-
try {
|
|
26437
|
-
cdpClient?.close();
|
|
26438
|
-
cdpClient = null;
|
|
26439
|
-
const client = new CdpClient();
|
|
26440
|
-
await client.connect(target.webSocketDebuggerUrl);
|
|
26441
|
-
await client.discoverFigmaContext();
|
|
26442
|
-
cdpClient = client;
|
|
26443
|
-
log(`CDP connected: ${target.title}`);
|
|
26444
|
-
if (!fileTracker) {
|
|
26445
|
-
fileTracker = new CdpFileTracker(existing.port);
|
|
26446
|
-
}
|
|
26447
|
-
const detection = await fileTracker.detectActiveFile();
|
|
26448
|
-
fileTracker.acknowledgeActiveFile();
|
|
26449
|
-
return {
|
|
26450
|
-
status: "ready",
|
|
26451
|
-
transport: "cdp",
|
|
26452
|
-
message: `Connected via CDP. Active file: "${detection.activeFile?.fileName ?? target.title}". ${detection.allFiles.length} file(s) open. Ready to design.`,
|
|
26453
|
-
activeFile: detection.activeFile?.fileName ?? target.title,
|
|
26454
|
-
openFiles: detection.allFiles.map((f) => ({ fileKey: f.fileKey, fileName: f.fileName })),
|
|
26455
|
-
port: existing.port
|
|
26456
|
-
};
|
|
26457
|
-
} catch {
|
|
26458
|
-
}
|
|
26459
|
-
}
|
|
26460
|
-
}
|
|
26461
|
-
}
|
|
26462
26725
|
if (!findFigmaPath()) {
|
|
26463
26726
|
return {
|
|
26464
26727
|
status: "not_installed",
|
|
@@ -26467,17 +26730,7 @@ async function setupFigma(params) {
|
|
|
26467
26730
|
};
|
|
26468
26731
|
}
|
|
26469
26732
|
if (!isFigmaRunning()) {
|
|
26470
|
-
if (forceTransport === "
|
|
26471
|
-
try {
|
|
26472
|
-
launchFigma();
|
|
26473
|
-
} catch (err) {
|
|
26474
|
-
return {
|
|
26475
|
-
status: "not_installed",
|
|
26476
|
-
transport: "none",
|
|
26477
|
-
message: err instanceof Error ? err.message : "Failed to launch Figma."
|
|
26478
|
-
};
|
|
26479
|
-
}
|
|
26480
|
-
} else {
|
|
26733
|
+
if (forceTransport === "cdp") {
|
|
26481
26734
|
try {
|
|
26482
26735
|
const connection = await launchFigmaWithCdp();
|
|
26483
26736
|
const target = findFigmaDesignTarget(connection.targets);
|
|
@@ -26514,33 +26767,58 @@ async function setupFigma(params) {
|
|
|
26514
26767
|
message: err instanceof Error ? err.message : "Failed to launch Figma."
|
|
26515
26768
|
};
|
|
26516
26769
|
}
|
|
26770
|
+
} else {
|
|
26771
|
+
try {
|
|
26772
|
+
launchFigma();
|
|
26773
|
+
} catch (err) {
|
|
26774
|
+
return {
|
|
26775
|
+
status: "not_installed",
|
|
26776
|
+
transport: "none",
|
|
26777
|
+
message: err instanceof Error ? err.message : "Failed to launch Figma."
|
|
26778
|
+
};
|
|
26779
|
+
}
|
|
26517
26780
|
}
|
|
26518
26781
|
}
|
|
26519
|
-
if (forceTransport !== "
|
|
26520
|
-
|
|
26521
|
-
|
|
26522
|
-
|
|
26523
|
-
|
|
26524
|
-
|
|
26525
|
-
|
|
26526
|
-
|
|
26527
|
-
|
|
26528
|
-
|
|
26529
|
-
Alternatively, install the faux-studio plugin:
|
|
26530
|
-
1. Download: ${PLUGIN_URL}
|
|
26531
|
-
2. In Figma: Plugins \u2192 Development \u2192 Import plugin from manifest
|
|
26532
|
-
3. Run: Plugins \u2192 Development \u2192 faux-studio
|
|
26533
|
-
4. Call setup_figma again once the plugin shows "Ready".`,
|
|
26534
|
-
port: pluginServer.port
|
|
26535
|
-
};
|
|
26782
|
+
if (forceTransport !== "cdp") {
|
|
26783
|
+
log("Waiting for plugin connection...");
|
|
26784
|
+
const connected = await waitForPlugin();
|
|
26785
|
+
if (connected) {
|
|
26786
|
+
return {
|
|
26787
|
+
...pluginReadyResult(),
|
|
26788
|
+
status: isFigmaRunning() ? "ready" : "launched"
|
|
26789
|
+
};
|
|
26790
|
+
}
|
|
26536
26791
|
}
|
|
26537
|
-
|
|
26538
|
-
|
|
26539
|
-
|
|
26540
|
-
|
|
26541
|
-
|
|
26542
|
-
|
|
26543
|
-
|
|
26792
|
+
if (forceTransport !== "plugin") {
|
|
26793
|
+
const existing = await probeCdpPorts();
|
|
26794
|
+
if (existing) {
|
|
26795
|
+
const target = findFigmaDesignTarget(existing.targets);
|
|
26796
|
+
if (target) {
|
|
26797
|
+
try {
|
|
26798
|
+
cdpClient?.close();
|
|
26799
|
+
cdpClient = null;
|
|
26800
|
+
const client = new CdpClient();
|
|
26801
|
+
await client.connect(target.webSocketDebuggerUrl);
|
|
26802
|
+
await client.discoverFigmaContext();
|
|
26803
|
+
cdpClient = client;
|
|
26804
|
+
log(`CDP connected: ${target.title}`);
|
|
26805
|
+
if (!fileTracker) {
|
|
26806
|
+
fileTracker = new CdpFileTracker(existing.port);
|
|
26807
|
+
}
|
|
26808
|
+
const detection = await fileTracker.detectActiveFile();
|
|
26809
|
+
fileTracker.acknowledgeActiveFile();
|
|
26810
|
+
return {
|
|
26811
|
+
status: "ready",
|
|
26812
|
+
transport: "cdp",
|
|
26813
|
+
message: `Connected via CDP. Active file: "${detection.activeFile?.fileName ?? target.title}". ${detection.allFiles.length} file(s) open. Ready to design.`,
|
|
26814
|
+
activeFile: detection.activeFile?.fileName ?? target.title,
|
|
26815
|
+
openFiles: detection.allFiles.map((f) => ({ fileKey: f.fileKey, fileName: f.fileName })),
|
|
26816
|
+
port: existing.port
|
|
26817
|
+
};
|
|
26818
|
+
} catch {
|
|
26819
|
+
}
|
|
26820
|
+
}
|
|
26821
|
+
}
|
|
26544
26822
|
}
|
|
26545
26823
|
return {
|
|
26546
26824
|
status: "waiting_for_plugin",
|
|
@@ -26561,7 +26839,7 @@ Call setup_figma again once the plugin shows "Ready".`,
|
|
|
26561
26839
|
}
|
|
26562
26840
|
async function main() {
|
|
26563
26841
|
const startupT0 = Date.now();
|
|
26564
|
-
log(`faux-studio v${"0.
|
|
26842
|
+
log(`faux-studio v${"0.5.1"} \u2014 process started (PID ${process.pid}, PPID ${process.ppid})`);
|
|
26565
26843
|
try {
|
|
26566
26844
|
const port = await pluginServer.start();
|
|
26567
26845
|
if (forceTransport === "plugin") {
|
|
@@ -26569,7 +26847,7 @@ async function main() {
|
|
|
26569
26847
|
} else if (forceTransport === "cdp") {
|
|
26570
26848
|
log(`Transport: CDP-only mode (FAUX_TRANSPORT=cdp) \u2014 plugin WS on port ${port} (standby)`);
|
|
26571
26849
|
} else {
|
|
26572
|
-
log(`Transport: auto \u2014
|
|
26850
|
+
log(`Transport: auto \u2014 plugin preferred (port ${port}), CDP fallback`);
|
|
26573
26851
|
}
|
|
26574
26852
|
} catch (err) {
|
|
26575
26853
|
warn(`Plugin WS server failed to start: ${err instanceof Error ? err.message : err}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faux-studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "AI-powered Figma design via MCP — connect any AI client to Figma Desktop",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -48,5 +48,8 @@
|
|
|
48
48
|
"darwin",
|
|
49
49
|
"linux",
|
|
50
50
|
"win32"
|
|
51
|
-
]
|
|
51
|
+
],
|
|
52
|
+
"optionalDependencies": {
|
|
53
|
+
"@napi-rs/keyring": "^1.2.0"
|
|
54
|
+
}
|
|
52
55
|
}
|