faux-studio 0.4.5 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -6
- package/dist/index.js +454 -43
- package/package.json +6 -1
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
|
@@ -10447,6 +10447,7 @@ function error(message) {
|
|
|
10447
10447
|
|
|
10448
10448
|
// src/credential-store.ts
|
|
10449
10449
|
import { readFile, writeFile, mkdir, unlink } from "fs/promises";
|
|
10450
|
+
import { execFile } from "child_process";
|
|
10450
10451
|
import { join } from "path";
|
|
10451
10452
|
import { homedir } from "os";
|
|
10452
10453
|
var FAUX_DIR = join(homedir(), ".faux");
|
|
@@ -10480,7 +10481,89 @@ var FileCredentialStore = class {
|
|
|
10480
10481
|
}
|
|
10481
10482
|
}
|
|
10482
10483
|
};
|
|
10483
|
-
|
|
10484
|
+
function securityExec(...args) {
|
|
10485
|
+
return new Promise((resolve, reject) => {
|
|
10486
|
+
execFile("/usr/bin/security", args, { timeout: 1e4 }, (err, stdout, stderr) => {
|
|
10487
|
+
if (err) return reject(err);
|
|
10488
|
+
resolve({ stdout, stderr });
|
|
10489
|
+
});
|
|
10490
|
+
});
|
|
10491
|
+
}
|
|
10492
|
+
var SecurityCliKeychainStore = class {
|
|
10493
|
+
name = "keychain";
|
|
10494
|
+
cached = void 0;
|
|
10495
|
+
async load() {
|
|
10496
|
+
if (this.cached !== void 0) return this.cached;
|
|
10497
|
+
try {
|
|
10498
|
+
const { stdout } = await securityExec(
|
|
10499
|
+
"find-generic-password",
|
|
10500
|
+
"-s",
|
|
10501
|
+
KEYCHAIN_SERVICE,
|
|
10502
|
+
"-a",
|
|
10503
|
+
KEYCHAIN_ACCOUNT,
|
|
10504
|
+
"-w"
|
|
10505
|
+
);
|
|
10506
|
+
const raw = stdout.trim();
|
|
10507
|
+
if (!raw) {
|
|
10508
|
+
this.cached = null;
|
|
10509
|
+
return null;
|
|
10510
|
+
}
|
|
10511
|
+
const creds = JSON.parse(raw);
|
|
10512
|
+
if (!creds.jwt || !creds.refreshToken || !creds.user) {
|
|
10513
|
+
this.cached = null;
|
|
10514
|
+
return null;
|
|
10515
|
+
}
|
|
10516
|
+
this.cached = creds;
|
|
10517
|
+
return creds;
|
|
10518
|
+
} catch {
|
|
10519
|
+
this.cached = null;
|
|
10520
|
+
return null;
|
|
10521
|
+
}
|
|
10522
|
+
}
|
|
10523
|
+
async save(creds) {
|
|
10524
|
+
const value = JSON.stringify(creds);
|
|
10525
|
+
try {
|
|
10526
|
+
await securityExec(
|
|
10527
|
+
"delete-generic-password",
|
|
10528
|
+
"-s",
|
|
10529
|
+
KEYCHAIN_SERVICE,
|
|
10530
|
+
"-a",
|
|
10531
|
+
KEYCHAIN_ACCOUNT
|
|
10532
|
+
);
|
|
10533
|
+
} catch {
|
|
10534
|
+
}
|
|
10535
|
+
try {
|
|
10536
|
+
await securityExec(
|
|
10537
|
+
"add-generic-password",
|
|
10538
|
+
"-s",
|
|
10539
|
+
KEYCHAIN_SERVICE,
|
|
10540
|
+
"-a",
|
|
10541
|
+
KEYCHAIN_ACCOUNT,
|
|
10542
|
+
"-w",
|
|
10543
|
+
value,
|
|
10544
|
+
"-A",
|
|
10545
|
+
"-U"
|
|
10546
|
+
);
|
|
10547
|
+
this.cached = creds;
|
|
10548
|
+
} catch (err) {
|
|
10549
|
+
warn(`Could not save to keychain: ${err instanceof Error ? err.message : err}`);
|
|
10550
|
+
}
|
|
10551
|
+
}
|
|
10552
|
+
async clear() {
|
|
10553
|
+
try {
|
|
10554
|
+
await securityExec(
|
|
10555
|
+
"delete-generic-password",
|
|
10556
|
+
"-s",
|
|
10557
|
+
KEYCHAIN_SERVICE,
|
|
10558
|
+
"-a",
|
|
10559
|
+
KEYCHAIN_ACCOUNT
|
|
10560
|
+
);
|
|
10561
|
+
this.cached = null;
|
|
10562
|
+
} catch {
|
|
10563
|
+
}
|
|
10564
|
+
}
|
|
10565
|
+
};
|
|
10566
|
+
var KeyringCredentialStore = class {
|
|
10484
10567
|
name = "keychain";
|
|
10485
10568
|
entry;
|
|
10486
10569
|
cached = void 0;
|
|
@@ -10524,11 +10607,28 @@ var KeychainCredentialStore = class {
|
|
|
10524
10607
|
}
|
|
10525
10608
|
};
|
|
10526
10609
|
async function tryCreateKeychainStore() {
|
|
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
|
+
}
|
|
10623
|
+
}
|
|
10527
10624
|
try {
|
|
10625
|
+
log("[keychain] importing @napi-rs/keyring...");
|
|
10626
|
+
const t0 = Date.now();
|
|
10528
10627
|
const { AsyncEntry } = await import("@napi-rs/keyring");
|
|
10529
10628
|
const entry = new AsyncEntry(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT);
|
|
10530
10629
|
await entry.getPassword();
|
|
10531
|
-
|
|
10630
|
+
log(`[keychain] keyring available (${Date.now() - t0}ms)`);
|
|
10631
|
+
return new KeyringCredentialStore(entry);
|
|
10532
10632
|
} catch (err) {
|
|
10533
10633
|
warn(
|
|
10534
10634
|
`OS keychain not available, using file-based credential storage: ${err instanceof Error ? err.message : err}`
|
|
@@ -10544,9 +10644,12 @@ function createCredentialStore() {
|
|
|
10544
10644
|
return initPromise;
|
|
10545
10645
|
}
|
|
10546
10646
|
async function initStore() {
|
|
10647
|
+
log("[credential-store] initializing...");
|
|
10648
|
+
const t0 = Date.now();
|
|
10547
10649
|
const fileStore = new FileCredentialStore();
|
|
10548
10650
|
const keychainStore = await tryCreateKeychainStore();
|
|
10549
10651
|
if (!keychainStore) {
|
|
10652
|
+
log(`[credential-store] using file store (${Date.now() - t0}ms)`);
|
|
10550
10653
|
return fileStore;
|
|
10551
10654
|
}
|
|
10552
10655
|
const keychainCreds = await keychainStore.load();
|
|
@@ -10554,6 +10657,7 @@ async function initStore() {
|
|
|
10554
10657
|
const fileCreds = await fileStore.load();
|
|
10555
10658
|
if (fileCreds) {
|
|
10556
10659
|
await keychainStore.save(fileCreds);
|
|
10660
|
+
keychainStore.cached = void 0;
|
|
10557
10661
|
const verified = await keychainStore.load();
|
|
10558
10662
|
if (verified) {
|
|
10559
10663
|
await fileStore.clear();
|
|
@@ -10564,6 +10668,7 @@ async function initStore() {
|
|
|
10564
10668
|
}
|
|
10565
10669
|
}
|
|
10566
10670
|
}
|
|
10671
|
+
log(`[credential-store] using keychain store (${Date.now() - t0}ms)`);
|
|
10567
10672
|
return keychainStore;
|
|
10568
10673
|
}
|
|
10569
10674
|
|
|
@@ -10660,6 +10765,8 @@ async function authenticate() {
|
|
|
10660
10765
|
);
|
|
10661
10766
|
}
|
|
10662
10767
|
async function ensureAuth() {
|
|
10768
|
+
log("[auth] ensureAuth() started");
|
|
10769
|
+
const t0 = Date.now();
|
|
10663
10770
|
const apiKey = process.env.FAUX_API_KEY;
|
|
10664
10771
|
if (apiKey) {
|
|
10665
10772
|
log("Using FAUX_API_KEY from environment");
|
|
@@ -10670,7 +10777,9 @@ async function ensureAuth() {
|
|
|
10670
10777
|
source: "api-key"
|
|
10671
10778
|
};
|
|
10672
10779
|
}
|
|
10780
|
+
log("[auth] creating credential store...");
|
|
10673
10781
|
const credStore = await createCredentialStore();
|
|
10782
|
+
log(`[auth] credential store ready (${credStore.name}) in ${Date.now() - t0}ms`);
|
|
10674
10783
|
const saved = await credStore.load();
|
|
10675
10784
|
if (saved) {
|
|
10676
10785
|
if (!isExpiringSoon(saved)) {
|
|
@@ -10865,6 +10974,124 @@ async function generateScript(jwt2, toolName, params) {
|
|
|
10865
10974
|
return { script: data.script, signature: data.signature };
|
|
10866
10975
|
}
|
|
10867
10976
|
|
|
10977
|
+
// src/icons.ts
|
|
10978
|
+
function parseIconName(iconName) {
|
|
10979
|
+
if (iconName.includes(":")) {
|
|
10980
|
+
const [prefix, name] = iconName.split(":");
|
|
10981
|
+
return { prefix, name };
|
|
10982
|
+
}
|
|
10983
|
+
const parts = iconName.split("-");
|
|
10984
|
+
if (parts.length > 1) {
|
|
10985
|
+
return { prefix: parts[0], name: parts.slice(1).join("-") };
|
|
10986
|
+
}
|
|
10987
|
+
return { prefix: "lucide", name: iconName };
|
|
10988
|
+
}
|
|
10989
|
+
async function fetchIconSvg(iconName, size = 24) {
|
|
10990
|
+
const { prefix, name } = parseIconName(iconName);
|
|
10991
|
+
const url2 = `https://api.iconify.design/${prefix}/${name}.svg?width=${size}&height=${size}`;
|
|
10992
|
+
const response = await fetch(url2);
|
|
10993
|
+
if (!response.ok) {
|
|
10994
|
+
if (response.status === 404) throw new Error(`Icon not found: ${iconName}`);
|
|
10995
|
+
throw new Error(`Failed to fetch icon: HTTP ${response.status}`);
|
|
10996
|
+
}
|
|
10997
|
+
return await response.text();
|
|
10998
|
+
}
|
|
10999
|
+
function detectIconType(svgContent) {
|
|
11000
|
+
if (svgContent.includes('stroke="currentColor"') || svgContent.includes("stroke='currentColor'")) {
|
|
11001
|
+
return "stroke";
|
|
11002
|
+
}
|
|
11003
|
+
if (svgContent.includes('opacity="0.') || svgContent.includes("opacity='0.")) {
|
|
11004
|
+
return "duotone";
|
|
11005
|
+
}
|
|
11006
|
+
return "fill";
|
|
11007
|
+
}
|
|
11008
|
+
async function searchSingleQuery(query, limit, prefix, category) {
|
|
11009
|
+
let url2 = `https://api.iconify.design/search?query=${encodeURIComponent(query)}&limit=${limit}`;
|
|
11010
|
+
if (prefix) url2 += `&prefix=${encodeURIComponent(prefix)}`;
|
|
11011
|
+
if (category) url2 += `&category=${encodeURIComponent(category)}`;
|
|
11012
|
+
const response = await fetch(url2);
|
|
11013
|
+
if (!response.ok) {
|
|
11014
|
+
if (response.status === 404) throw new Error("Icon search is currently disabled");
|
|
11015
|
+
throw new Error(`Search failed: HTTP ${response.status}`);
|
|
11016
|
+
}
|
|
11017
|
+
const data = await response.json();
|
|
11018
|
+
const icons = data.icons || [];
|
|
11019
|
+
return { query, total: icons.length, icons };
|
|
11020
|
+
}
|
|
11021
|
+
async function searchIcons(params) {
|
|
11022
|
+
const { limit = 3, prefix, category } = params;
|
|
11023
|
+
const effectiveLimit = Math.min(limit, 100);
|
|
11024
|
+
if (params.queries && params.queries.length > 0) {
|
|
11025
|
+
const results = await Promise.all(
|
|
11026
|
+
params.queries.map((q) => searchSingleQuery(q.trim(), effectiveLimit, prefix, category))
|
|
11027
|
+
);
|
|
11028
|
+
return { results, totalQueries: results.length };
|
|
11029
|
+
}
|
|
11030
|
+
const query = params.query;
|
|
11031
|
+
if (!query || query.trim().length === 0) {
|
|
11032
|
+
throw new Error("Search query is required (provide `query` or `queries`)");
|
|
11033
|
+
}
|
|
11034
|
+
return searchSingleQuery(query.trim(), effectiveLimit, prefix, category);
|
|
11035
|
+
}
|
|
11036
|
+
async function resolveIconsInNode(node) {
|
|
11037
|
+
if (node.$icon && typeof node.$icon === "string") {
|
|
11038
|
+
const searchTerm = node.$icon;
|
|
11039
|
+
const size = typeof node.size === "number" ? node.size : 24;
|
|
11040
|
+
try {
|
|
11041
|
+
const searchResult = await searchIcons({ query: searchTerm, limit: 1 });
|
|
11042
|
+
if (searchResult.icons.length === 0) {
|
|
11043
|
+
warn(`No icons found for: ${searchTerm}`);
|
|
11044
|
+
const { $icon: $icon2, ...rest2 } = node;
|
|
11045
|
+
return { ...rest2, $iconError: `No icons found for: ${searchTerm}` };
|
|
11046
|
+
}
|
|
11047
|
+
const iconName = searchResult.icons[0];
|
|
11048
|
+
const svgContent = await fetchIconSvg(iconName, size);
|
|
11049
|
+
const iconType = detectIconType(svgContent);
|
|
11050
|
+
const { $icon, ...rest } = node;
|
|
11051
|
+
return {
|
|
11052
|
+
...rest,
|
|
11053
|
+
$iconResolved: { svgContent, iconType, iconName, searchTerm }
|
|
11054
|
+
};
|
|
11055
|
+
} catch (error3) {
|
|
11056
|
+
error(`Failed to resolve icon "${searchTerm}": ${error3 instanceof Error ? error3.message : String(error3)}`);
|
|
11057
|
+
const { $icon, ...rest } = node;
|
|
11058
|
+
return { ...rest, $iconError: `Failed to load icon: ${error3 instanceof Error ? error3.message : "Unknown error"}` };
|
|
11059
|
+
}
|
|
11060
|
+
}
|
|
11061
|
+
let result = { ...node };
|
|
11062
|
+
if (node.children && Array.isArray(node.children)) {
|
|
11063
|
+
result.children = await Promise.all(
|
|
11064
|
+
node.children.map(async (child) => {
|
|
11065
|
+
if (child && typeof child === "object") {
|
|
11066
|
+
return resolveIconsInNode(child);
|
|
11067
|
+
}
|
|
11068
|
+
return child;
|
|
11069
|
+
})
|
|
11070
|
+
);
|
|
11071
|
+
}
|
|
11072
|
+
if (node.template && typeof node.template === "object") {
|
|
11073
|
+
result.template = await resolveIconsInNode(node.template);
|
|
11074
|
+
}
|
|
11075
|
+
return result;
|
|
11076
|
+
}
|
|
11077
|
+
var ICON_TOOLS = /* @__PURE__ */ new Set(["create_from_schema", "modify_via_schema"]);
|
|
11078
|
+
async function preprocessIconsInParams(toolName, params) {
|
|
11079
|
+
if (!ICON_TOOLS.has(toolName)) return params;
|
|
11080
|
+
const result = { ...params };
|
|
11081
|
+
if (result.schema && typeof result.schema === "object") {
|
|
11082
|
+
result.schema = await resolveIconsInNode(result.schema);
|
|
11083
|
+
}
|
|
11084
|
+
if (result.modifications && Array.isArray(result.modifications)) {
|
|
11085
|
+
result.modifications = await Promise.all(
|
|
11086
|
+
result.modifications.map(async (mod) => ({
|
|
11087
|
+
...mod,
|
|
11088
|
+
schema: await resolveIconsInNode(mod.schema)
|
|
11089
|
+
}))
|
|
11090
|
+
);
|
|
11091
|
+
}
|
|
11092
|
+
return result;
|
|
11093
|
+
}
|
|
11094
|
+
|
|
10868
11095
|
// node_modules/ws/wrapper.mjs
|
|
10869
11096
|
var import_stream = __toESM(require_stream(), 1);
|
|
10870
11097
|
var import_receiver = __toESM(require_receiver(), 1);
|
|
@@ -11227,6 +11454,31 @@ async function probeCdpPorts() {
|
|
|
11227
11454
|
}
|
|
11228
11455
|
return null;
|
|
11229
11456
|
}
|
|
11457
|
+
async function launchFigmaWithCdp() {
|
|
11458
|
+
const figmaPath = findFigmaPath();
|
|
11459
|
+
if (!figmaPath) {
|
|
11460
|
+
throw new Error(
|
|
11461
|
+
"Figma Desktop is not installed. Download from https://figma.com/downloads"
|
|
11462
|
+
);
|
|
11463
|
+
}
|
|
11464
|
+
const port = await findAvailablePort();
|
|
11465
|
+
log(`Launching Figma Desktop (port ${port})...`);
|
|
11466
|
+
launchFigmaProcess(figmaPath, port);
|
|
11467
|
+
const startTime = Date.now();
|
|
11468
|
+
while (Date.now() - startTime < CDP_WAIT_TIMEOUT_MS) {
|
|
11469
|
+
await new Promise((r) => setTimeout(r, CDP_POLL_INTERVAL_MS));
|
|
11470
|
+
const { alive, isFigma } = await isCdpAlive(port);
|
|
11471
|
+
if (alive && isFigma) {
|
|
11472
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
11473
|
+
const targets = await listTargets(port);
|
|
11474
|
+
log("Figma Desktop started");
|
|
11475
|
+
return { port, targets };
|
|
11476
|
+
}
|
|
11477
|
+
}
|
|
11478
|
+
throw new Error(
|
|
11479
|
+
`Figma did not respond on port ${port} within ${CDP_WAIT_TIMEOUT_MS / 1e3}s. Try again.`
|
|
11480
|
+
);
|
|
11481
|
+
}
|
|
11230
11482
|
function launchFigmaProcess(figmaPath, port) {
|
|
11231
11483
|
if (process.platform === "darwin") {
|
|
11232
11484
|
const binary = `${figmaPath}/Contents/MacOS/Figma`;
|
|
@@ -11317,7 +11569,7 @@ var PORT_RANGE = 8;
|
|
|
11317
11569
|
var SCRIPT_TIMEOUT_MS = 6e4;
|
|
11318
11570
|
var CONNECT_WAIT_MS = 3e4;
|
|
11319
11571
|
var FAUX_DIR3 = join4(homedir3(), ".faux");
|
|
11320
|
-
var PluginWsServer = class {
|
|
11572
|
+
var PluginWsServer = class _PluginWsServer {
|
|
11321
11573
|
name = "plugin-ws";
|
|
11322
11574
|
wss = null;
|
|
11323
11575
|
connections = /* @__PURE__ */ new Map();
|
|
@@ -11325,6 +11577,17 @@ var PluginWsServer = class {
|
|
|
11325
11577
|
connectWaiters = [];
|
|
11326
11578
|
_activeFileId = null;
|
|
11327
11579
|
_port = 0;
|
|
11580
|
+
// File tracking state (mirrors CdpFileTracker API)
|
|
11581
|
+
_lastAcknowledgedFileId = null;
|
|
11582
|
+
_lastAcknowledgedFileName = null;
|
|
11583
|
+
_lastAcknowledgedAt = 0;
|
|
11584
|
+
_lastFocusEventAt = 0;
|
|
11585
|
+
/** How long before focus events are considered stale for high-confidence detection. */
|
|
11586
|
+
static FOCUS_FRESH_MS = 12e4;
|
|
11587
|
+
// 2 minutes
|
|
11588
|
+
/** How long before last-known file is considered stale and untrusted (matches CDP). */
|
|
11589
|
+
static LAST_KNOWN_STALE_MS = 5 * 6e4;
|
|
11590
|
+
// 5 minutes
|
|
11328
11591
|
// -------------------------------------------------------------------------
|
|
11329
11592
|
// Public Getters
|
|
11330
11593
|
// -------------------------------------------------------------------------
|
|
@@ -11343,6 +11606,79 @@ var PluginWsServer = class {
|
|
|
11343
11606
|
get hasConnections() {
|
|
11344
11607
|
return this.connections.size > 0;
|
|
11345
11608
|
}
|
|
11609
|
+
get activeFileName() {
|
|
11610
|
+
if (!this._activeFileId) return null;
|
|
11611
|
+
return this.connections.get(this._activeFileId)?.fileName ?? null;
|
|
11612
|
+
}
|
|
11613
|
+
get lastAcknowledgedFileName() {
|
|
11614
|
+
return this._lastAcknowledgedFileName;
|
|
11615
|
+
}
|
|
11616
|
+
// -------------------------------------------------------------------------
|
|
11617
|
+
// File Tracking
|
|
11618
|
+
// -------------------------------------------------------------------------
|
|
11619
|
+
/**
|
|
11620
|
+
* Detect the active file for the plugin transport.
|
|
11621
|
+
* Unlike CDP (which introspects shell.html), this is synchronous — the
|
|
11622
|
+
* plugin tells us via file-focus events. The cacheTtlMs parameter is
|
|
11623
|
+
* accepted for API compatibility but detection is always in-memory.
|
|
11624
|
+
*/
|
|
11625
|
+
detectActiveFile(_cacheTtlMs = 2e3) {
|
|
11626
|
+
const allFiles = this.getAllFileInfos();
|
|
11627
|
+
if (this.connections.size === 0) {
|
|
11628
|
+
return { activeFile: null, allFiles, method: "none", confidence: "none" };
|
|
11629
|
+
}
|
|
11630
|
+
if (this.connections.size === 1) {
|
|
11631
|
+
const conn = this.connections.values().next().value;
|
|
11632
|
+
return { activeFile: this.connToFileInfo(conn), allFiles, method: "single-file", confidence: "high" };
|
|
11633
|
+
}
|
|
11634
|
+
if (this._activeFileId && this.connections.has(this._activeFileId)) {
|
|
11635
|
+
const conn = this.connections.get(this._activeFileId);
|
|
11636
|
+
const info = this.connToFileInfo(conn);
|
|
11637
|
+
const focusAge = Date.now() - this._lastFocusEventAt;
|
|
11638
|
+
if (this._lastFocusEventAt > 0 && focusAge < _PluginWsServer.FOCUS_FRESH_MS) {
|
|
11639
|
+
return { activeFile: info, allFiles, method: "focus-event", confidence: "high" };
|
|
11640
|
+
}
|
|
11641
|
+
if (this.isLastKnownStale()) {
|
|
11642
|
+
log("File tracker: focus and acknowledgment both stale \u2014 cannot detect active file");
|
|
11643
|
+
return { activeFile: null, allFiles, method: "none", confidence: "none" };
|
|
11644
|
+
}
|
|
11645
|
+
return { activeFile: info, allFiles, method: "last-known", confidence: "low" };
|
|
11646
|
+
}
|
|
11647
|
+
return { activeFile: null, allFiles, method: "none", confidence: "none" };
|
|
11648
|
+
}
|
|
11649
|
+
/** Whether the active file has changed since the last acknowledged execution. */
|
|
11650
|
+
hasFileChanged() {
|
|
11651
|
+
return this._activeFileId !== null && this._lastAcknowledgedFileId !== null && this._activeFileId !== this._lastAcknowledgedFileId;
|
|
11652
|
+
}
|
|
11653
|
+
/**
|
|
11654
|
+
* Acknowledge the current active file as the intended target.
|
|
11655
|
+
* Call AFTER successful script execution.
|
|
11656
|
+
*/
|
|
11657
|
+
acknowledgeActiveFile() {
|
|
11658
|
+
if (this._activeFileId) {
|
|
11659
|
+
this._lastAcknowledgedFileId = this._activeFileId;
|
|
11660
|
+
this._lastAcknowledgedFileName = this.connections.get(this._activeFileId)?.fileName ?? null;
|
|
11661
|
+
this._lastAcknowledgedAt = Date.now();
|
|
11662
|
+
}
|
|
11663
|
+
}
|
|
11664
|
+
/** True if the last acknowledged execution was more than 5 minutes ago. */
|
|
11665
|
+
isLastKnownStale() {
|
|
11666
|
+
if (this._lastAcknowledgedAt === 0) return false;
|
|
11667
|
+
return Date.now() - this._lastAcknowledgedAt > _PluginWsServer.LAST_KNOWN_STALE_MS;
|
|
11668
|
+
}
|
|
11669
|
+
/** All connected files as FigmaFileInfo[]. */
|
|
11670
|
+
getAllFileInfos() {
|
|
11671
|
+
return Array.from(this.connections.values()).map((c) => this.connToFileInfo(c));
|
|
11672
|
+
}
|
|
11673
|
+
/** Build FigmaFileInfo from a PluginConnection. */
|
|
11674
|
+
connToFileInfo(conn) {
|
|
11675
|
+
return {
|
|
11676
|
+
fileKey: conn.fileId,
|
|
11677
|
+
fileName: conn.fileName,
|
|
11678
|
+
targetId: conn.fileId,
|
|
11679
|
+
wsUrl: ""
|
|
11680
|
+
};
|
|
11681
|
+
}
|
|
11346
11682
|
// -------------------------------------------------------------------------
|
|
11347
11683
|
// Server Lifecycle
|
|
11348
11684
|
// -------------------------------------------------------------------------
|
|
@@ -11437,6 +11773,7 @@ var PluginWsServer = class {
|
|
|
11437
11773
|
this.connections.set(fileId, { ws, fileId, fileName, connectedAt: Date.now() });
|
|
11438
11774
|
if (this.connections.size === 1 || !this._activeFileId) {
|
|
11439
11775
|
this._activeFileId = fileId;
|
|
11776
|
+
this._lastFocusEventAt = Date.now();
|
|
11440
11777
|
}
|
|
11441
11778
|
log(`Plugin connected: "${fileName}" (${fileId}) [${this.connections.size} file(s)]`);
|
|
11442
11779
|
for (const waiter of this.connectWaiters) {
|
|
@@ -11497,6 +11834,7 @@ var PluginWsServer = class {
|
|
|
11497
11834
|
const focusFileId = msg.fileId || fileId;
|
|
11498
11835
|
if (this.connections.has(focusFileId)) {
|
|
11499
11836
|
this._activeFileId = focusFileId;
|
|
11837
|
+
this._lastFocusEventAt = Date.now();
|
|
11500
11838
|
}
|
|
11501
11839
|
break;
|
|
11502
11840
|
}
|
|
@@ -25908,8 +26246,11 @@ var RESOURCES = [
|
|
|
25908
26246
|
];
|
|
25909
26247
|
var INSTRUCTIONS = `You are connected to Figma Desktop via faux-studio. You can create, modify, and inspect designs using the tools below.
|
|
25910
26248
|
|
|
26249
|
+
## Transport
|
|
26250
|
+
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.
|
|
26251
|
+
|
|
25911
26252
|
## Workflow
|
|
25912
|
-
1. Call \`setup_figma\` first to ensure Figma is running and connected.
|
|
26253
|
+
1. Call \`setup_figma\` first to ensure Figma is running and connected (via plugin or CDP).
|
|
25913
26254
|
2. Call \`get_page_structure\` or \`get_screenshot\` to understand what's on the canvas.
|
|
25914
26255
|
3. Use \`create_from_schema\` for all creation \u2014 it accepts declarative JSON schemas.
|
|
25915
26256
|
4. Use \`modify_via_schema\` to change existing nodes.
|
|
@@ -25949,7 +26290,7 @@ Resources provide quick read-only access to Figma state without tool calls:
|
|
|
25949
26290
|
- Create components for reusable UI patterns.`;
|
|
25950
26291
|
function createMcpServer(deps) {
|
|
25951
26292
|
const server2 = new Server(
|
|
25952
|
-
{ name: "faux-studio", version: "0.
|
|
26293
|
+
{ name: "faux-studio", version: "0.5.0" },
|
|
25953
26294
|
{
|
|
25954
26295
|
capabilities: { tools: { listChanged: true }, resources: {}, logging: {} },
|
|
25955
26296
|
instructions: INSTRUCTIONS
|
|
@@ -25964,7 +26305,7 @@ function createMcpServer(deps) {
|
|
|
25964
26305
|
},
|
|
25965
26306
|
{
|
|
25966
26307
|
name: "setup_figma",
|
|
25967
|
-
description: "Ensure Figma Desktop is running and connected. Call this before any design work.
|
|
26308
|
+
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.",
|
|
25968
26309
|
inputSchema: {
|
|
25969
26310
|
type: "object",
|
|
25970
26311
|
properties: {},
|
|
@@ -26051,6 +26392,13 @@ function createMcpServer(deps) {
|
|
|
26051
26392
|
throw err;
|
|
26052
26393
|
}
|
|
26053
26394
|
}
|
|
26395
|
+
if (name === "search_icons") {
|
|
26396
|
+
log("search_icons called (server-side)");
|
|
26397
|
+
const result2 = await searchIcons(params);
|
|
26398
|
+
return {
|
|
26399
|
+
content: [{ type: "text", text: JSON.stringify(result2, null, 2) }]
|
|
26400
|
+
};
|
|
26401
|
+
}
|
|
26054
26402
|
const intents = {
|
|
26055
26403
|
meso: typeof params._intent_meso === "string" ? params._intent_meso : void 0,
|
|
26056
26404
|
macro: typeof params._intent_macro === "string" ? params._intent_macro : void 0
|
|
@@ -26178,13 +26526,19 @@ function lazyInit() {
|
|
|
26178
26526
|
return initPromise2;
|
|
26179
26527
|
}
|
|
26180
26528
|
async function doInit() {
|
|
26529
|
+
log("[init] lazyInit triggered \u2014 starting auth + tool fetch");
|
|
26530
|
+
const t0 = Date.now();
|
|
26181
26531
|
auth = await ensureAuth();
|
|
26532
|
+
log(`[init] auth complete in ${Date.now() - t0}ms`);
|
|
26182
26533
|
try {
|
|
26534
|
+
const t1 = Date.now();
|
|
26183
26535
|
tools = await getTools(auth.jwt);
|
|
26536
|
+
log(`[init] tools fetched (${tools.length}) in ${Date.now() - t1}ms`);
|
|
26184
26537
|
} catch (err) {
|
|
26185
26538
|
error(err instanceof Error ? err.message : String(err));
|
|
26186
26539
|
process.exit(1);
|
|
26187
26540
|
}
|
|
26541
|
+
log(`[init] full initialization complete in ${Date.now() - t0}ms`);
|
|
26188
26542
|
}
|
|
26189
26543
|
async function tryConnectCdp() {
|
|
26190
26544
|
try {
|
|
@@ -26209,8 +26563,22 @@ async function tryConnectCdp() {
|
|
|
26209
26563
|
}
|
|
26210
26564
|
async function executeScript(script, intents, opts) {
|
|
26211
26565
|
if (forceTransport !== "cdp" && pluginServer.hasConnections) {
|
|
26566
|
+
const detection = pluginServer.detectActiveFile(opts?.cacheTtlMs);
|
|
26567
|
+
if (!detection.activeFile && detection.allFiles.length > 1) {
|
|
26568
|
+
throw new FileUnknownError(detection.allFiles);
|
|
26569
|
+
}
|
|
26570
|
+
if (pluginServer.hasFileChanged()) {
|
|
26571
|
+
throw new FileChangedError(
|
|
26572
|
+
pluginServer.lastAcknowledgedFileName,
|
|
26573
|
+
pluginServer.activeFileName,
|
|
26574
|
+
pluginServer.activeFileId,
|
|
26575
|
+
detection.allFiles
|
|
26576
|
+
);
|
|
26577
|
+
}
|
|
26212
26578
|
log(`Executing via plugin (file: ${pluginServer.activeFileId || "active"})`);
|
|
26213
|
-
|
|
26579
|
+
const result = await pluginServer.executeScript(script, void 0, intents);
|
|
26580
|
+
pluginServer.acknowledgeActiveFile();
|
|
26581
|
+
return result;
|
|
26214
26582
|
}
|
|
26215
26583
|
if (forceTransport !== "plugin") {
|
|
26216
26584
|
const client = await tryConnectCdp();
|
|
@@ -26273,6 +26641,7 @@ async function recoverCdp(client, script) {
|
|
|
26273
26641
|
async function generateWithAuth(toolName, params) {
|
|
26274
26642
|
await lazyInit();
|
|
26275
26643
|
auth = await refreshIfNeeded(auth);
|
|
26644
|
+
params = await preprocessIconsInParams(toolName, params);
|
|
26276
26645
|
let result;
|
|
26277
26646
|
try {
|
|
26278
26647
|
result = await generateScript(auth.jwt, toolName, params);
|
|
@@ -26310,17 +26679,21 @@ async function waitForPlugin() {
|
|
|
26310
26679
|
return false;
|
|
26311
26680
|
}
|
|
26312
26681
|
function pluginReadyResult() {
|
|
26682
|
+
const detection = pluginServer.detectActiveFile();
|
|
26683
|
+
const activeName = detection.activeFile?.fileName;
|
|
26684
|
+
const fileCount = detection.allFiles.length;
|
|
26313
26685
|
return {
|
|
26314
26686
|
status: "ready",
|
|
26315
26687
|
transport: "plugin",
|
|
26316
|
-
message: `Connected via plugin.${
|
|
26317
|
-
activeFile:
|
|
26688
|
+
message: `Connected via plugin.${activeName ? ` Active file: "${activeName}".` : ""} ${fileCount} file(s) open. Ready to design.`,
|
|
26689
|
+
activeFile: activeName || void 0,
|
|
26318
26690
|
pluginFiles: pluginServer.connectedFiles,
|
|
26691
|
+
openFiles: detection.allFiles.map((f) => ({ fileKey: f.fileKey, fileName: f.fileName })),
|
|
26319
26692
|
port: pluginServer.port
|
|
26320
26693
|
};
|
|
26321
26694
|
}
|
|
26322
26695
|
async function setupFigma(params) {
|
|
26323
|
-
if (pluginServer.hasConnections) {
|
|
26696
|
+
if (forceTransport !== "cdp" && pluginServer.hasConnections) {
|
|
26324
26697
|
return pluginReadyResult();
|
|
26325
26698
|
}
|
|
26326
26699
|
if (forceTransport !== "plugin" && cdpClient?.connected && cdpClient.hasContext) {
|
|
@@ -26340,6 +26713,73 @@ async function setupFigma(params) {
|
|
|
26340
26713
|
message: "Connected via CDP. Ready to design."
|
|
26341
26714
|
};
|
|
26342
26715
|
}
|
|
26716
|
+
if (!findFigmaPath()) {
|
|
26717
|
+
return {
|
|
26718
|
+
status: "not_installed",
|
|
26719
|
+
transport: "none",
|
|
26720
|
+
message: "Figma Desktop is not installed.\n\n1. Download Figma: https://figma.com/downloads\n2. Install and open it\n3. Call setup_figma again"
|
|
26721
|
+
};
|
|
26722
|
+
}
|
|
26723
|
+
if (!isFigmaRunning()) {
|
|
26724
|
+
if (forceTransport === "cdp") {
|
|
26725
|
+
try {
|
|
26726
|
+
const connection = await launchFigmaWithCdp();
|
|
26727
|
+
const target = findFigmaDesignTarget(connection.targets);
|
|
26728
|
+
if (target) {
|
|
26729
|
+
const client = new CdpClient();
|
|
26730
|
+
await client.connect(target.webSocketDebuggerUrl);
|
|
26731
|
+
await client.discoverFigmaContext();
|
|
26732
|
+
cdpClient = client;
|
|
26733
|
+
log(`CDP connected after launch: ${target.title}`);
|
|
26734
|
+
if (!fileTracker) {
|
|
26735
|
+
fileTracker = new CdpFileTracker(connection.port);
|
|
26736
|
+
}
|
|
26737
|
+
const detection = await fileTracker.detectActiveFile();
|
|
26738
|
+
fileTracker.acknowledgeActiveFile();
|
|
26739
|
+
return {
|
|
26740
|
+
status: "launched",
|
|
26741
|
+
transport: "cdp",
|
|
26742
|
+
message: `Launched Figma and connected via CDP. Active file: "${detection.activeFile?.fileName ?? target.title}". ${detection.allFiles.length} file(s) open. Ready to design.`,
|
|
26743
|
+
activeFile: detection.activeFile?.fileName ?? target.title,
|
|
26744
|
+
openFiles: detection.allFiles.map((f) => ({ fileKey: f.fileKey, fileName: f.fileName })),
|
|
26745
|
+
port: connection.port
|
|
26746
|
+
};
|
|
26747
|
+
}
|
|
26748
|
+
return {
|
|
26749
|
+
status: "launched",
|
|
26750
|
+
transport: "cdp",
|
|
26751
|
+
message: "Launched Figma with CDP enabled but no design file is open.\n\nOpen or create a design file in Figma, then call setup_figma again.",
|
|
26752
|
+
port: connection.port
|
|
26753
|
+
};
|
|
26754
|
+
} catch (err) {
|
|
26755
|
+
return {
|
|
26756
|
+
status: "not_installed",
|
|
26757
|
+
transport: "none",
|
|
26758
|
+
message: err instanceof Error ? err.message : "Failed to launch Figma."
|
|
26759
|
+
};
|
|
26760
|
+
}
|
|
26761
|
+
} else {
|
|
26762
|
+
try {
|
|
26763
|
+
launchFigma();
|
|
26764
|
+
} catch (err) {
|
|
26765
|
+
return {
|
|
26766
|
+
status: "not_installed",
|
|
26767
|
+
transport: "none",
|
|
26768
|
+
message: err instanceof Error ? err.message : "Failed to launch Figma."
|
|
26769
|
+
};
|
|
26770
|
+
}
|
|
26771
|
+
}
|
|
26772
|
+
}
|
|
26773
|
+
if (forceTransport !== "cdp") {
|
|
26774
|
+
log("Waiting for plugin connection...");
|
|
26775
|
+
const connected = await waitForPlugin();
|
|
26776
|
+
if (connected) {
|
|
26777
|
+
return {
|
|
26778
|
+
...pluginReadyResult(),
|
|
26779
|
+
status: isFigmaRunning() ? "ready" : "launched"
|
|
26780
|
+
};
|
|
26781
|
+
}
|
|
26782
|
+
}
|
|
26343
26783
|
if (forceTransport !== "plugin") {
|
|
26344
26784
|
const existing = await probeCdpPorts();
|
|
26345
26785
|
if (existing) {
|
|
@@ -26371,37 +26811,6 @@ async function setupFigma(params) {
|
|
|
26371
26811
|
}
|
|
26372
26812
|
}
|
|
26373
26813
|
}
|
|
26374
|
-
if (!findFigmaPath()) {
|
|
26375
|
-
return {
|
|
26376
|
-
status: "not_installed",
|
|
26377
|
-
transport: "none",
|
|
26378
|
-
message: `Figma Desktop is not installed.
|
|
26379
|
-
|
|
26380
|
-
1. Download Figma: https://figma.com/downloads
|
|
26381
|
-
2. Install and open it
|
|
26382
|
-
3. Install the faux-studio plugin: ${PLUGIN_URL}
|
|
26383
|
-
4. Call setup_figma again`
|
|
26384
|
-
};
|
|
26385
|
-
}
|
|
26386
|
-
if (!isFigmaRunning()) {
|
|
26387
|
-
try {
|
|
26388
|
-
launchFigma();
|
|
26389
|
-
} catch (err) {
|
|
26390
|
-
return {
|
|
26391
|
-
status: "not_installed",
|
|
26392
|
-
transport: "none",
|
|
26393
|
-
message: err instanceof Error ? err.message : "Failed to launch Figma."
|
|
26394
|
-
};
|
|
26395
|
-
}
|
|
26396
|
-
}
|
|
26397
|
-
log("Waiting for plugin connection...");
|
|
26398
|
-
const connected = await waitForPlugin();
|
|
26399
|
-
if (connected) {
|
|
26400
|
-
return {
|
|
26401
|
-
...pluginReadyResult(),
|
|
26402
|
-
status: isFigmaRunning() ? "ready" : "launched"
|
|
26403
|
-
};
|
|
26404
|
-
}
|
|
26405
26814
|
return {
|
|
26406
26815
|
status: "waiting_for_plugin",
|
|
26407
26816
|
transport: "none",
|
|
@@ -26420,7 +26829,8 @@ Call setup_figma again once the plugin shows "Ready".`,
|
|
|
26420
26829
|
};
|
|
26421
26830
|
}
|
|
26422
26831
|
async function main() {
|
|
26423
|
-
|
|
26832
|
+
const startupT0 = Date.now();
|
|
26833
|
+
log(`faux-studio v${"0.5.0"} \u2014 process started (PID ${process.pid}, PPID ${process.ppid})`);
|
|
26424
26834
|
try {
|
|
26425
26835
|
const port = await pluginServer.start();
|
|
26426
26836
|
if (forceTransport === "plugin") {
|
|
@@ -26428,7 +26838,7 @@ async function main() {
|
|
|
26428
26838
|
} else if (forceTransport === "cdp") {
|
|
26429
26839
|
log(`Transport: CDP-only mode (FAUX_TRANSPORT=cdp) \u2014 plugin WS on port ${port} (standby)`);
|
|
26430
26840
|
} else {
|
|
26431
|
-
log(`Transport: auto \u2014 plugin
|
|
26841
|
+
log(`Transport: auto \u2014 plugin preferred (port ${port}), CDP fallback`);
|
|
26432
26842
|
}
|
|
26433
26843
|
} catch (err) {
|
|
26434
26844
|
warn(`Plugin WS server failed to start: ${err instanceof Error ? err.message : err}`);
|
|
@@ -26472,6 +26882,7 @@ async function main() {
|
|
|
26472
26882
|
});
|
|
26473
26883
|
await startServer(server2);
|
|
26474
26884
|
setServer(server2);
|
|
26885
|
+
log(`[startup] MCP server ready in ${Date.now() - startupT0}ms \u2014 handshake complete`);
|
|
26475
26886
|
if (forceTransport !== "plugin") {
|
|
26476
26887
|
try {
|
|
26477
26888
|
await tryConnectCdp();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "faux-studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "AI-powered Figma design via MCP — connect any AI client to Figma Desktop",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -44,6 +44,11 @@
|
|
|
44
44
|
"url": "https://github.com/uxfreak/faux-studio.git"
|
|
45
45
|
},
|
|
46
46
|
"homepage": "https://faux.design",
|
|
47
|
+
"os": [
|
|
48
|
+
"darwin",
|
|
49
|
+
"linux",
|
|
50
|
+
"win32"
|
|
51
|
+
],
|
|
47
52
|
"optionalDependencies": {
|
|
48
53
|
"@napi-rs/keyring": "^1.2.0"
|
|
49
54
|
}
|