pi-mono-figma 0.2.0 → 0.2.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # pi-mono-figma
2
2
 
3
+ ## 0.2.2
4
+
5
+ ### Patch Changes
6
+
7
+ ### Maintenance
8
+
9
+ - Update pi core imports and peer dependencies to the new `@earendil-works` package scope.
10
+
11
+ ## 0.2.1
12
+
13
+ ### Patch Changes
14
+
15
+ - Add the all-in-one pi package and bundle the shared pi-common workspace package into distributed packages.
16
+
3
17
  ## 0.2.0
4
18
 
5
19
  ### Minor Changes
package/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { registerFigmaTools } from "./src/figma-tools.js";
3
3
 
4
4
  export default function figmaExtension(pi: ExtensionAPI): void {
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "pi-common",
3
+ "version": "0.1.1",
4
+ "description": "Shared utilities for pi integration extensions",
5
+ "type": "module",
6
+ "private": true,
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./auth": "./src/auth.ts",
10
+ "./auth-config": "./src/auth-config.ts",
11
+ "./http-client": "./src/http-client.ts",
12
+ "./rate-limiter": "./src/rate-limiter.ts",
13
+ "./cache": "./src/cache.ts",
14
+ "./errors": "./src/errors.ts",
15
+ "./tool-result": "./src/tool-result.ts"
16
+ },
17
+ "peerDependencies": {
18
+ "@earendil-works/pi-coding-agent": "*",
19
+ "@earendil-works/pi-tui": "*",
20
+ "@sinclair/typebox": "*"
21
+ }
22
+ }
@@ -0,0 +1,290 @@
1
+ import { execFile } from "node:child_process";
2
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, resolve } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
8
+ import { Type } from "@sinclair/typebox";
9
+ import { ApiError } from "./errors.js";
10
+ import { MissingAuthTokenError, readAuthToken, setAuthTokenOverride, type ReadAuthTokenOptions } from "./auth.js";
11
+
12
+ const execFileAsync = promisify(execFile);
13
+
14
+ export interface AuthConfiguratorOptions extends ReadAuthTokenOptions {
15
+ service: string;
16
+ displayName: string;
17
+ commandName: string;
18
+ toolName: string;
19
+ tokenUrl?: string;
20
+ scopeInstructions: readonly string[];
21
+ }
22
+
23
+ interface ConfigureAuthParams {
24
+ force?: boolean;
25
+ }
26
+
27
+ const ConfigureAuthParamsSchema = Type.Object({
28
+ force: Type.Optional(Type.Boolean({ description: "Prompt even if a token is already configured. Defaults to false." })),
29
+ });
30
+
31
+ export function registerAuthConfigurator(pi: ExtensionAPI, options: AuthConfiguratorOptions): void {
32
+ pi.registerCommand(options.commandName, {
33
+ description: `Configure ${options.displayName} authentication token securely`,
34
+ handler: async (args, ctx) => {
35
+ const force = args.trim().split(/\s+/).includes("--force") || args.trim() === "force";
36
+ try {
37
+ const result = await configureAuthToken(ctx, options, { force });
38
+ ctx.ui.notify(result.message, "info");
39
+ } catch (error) {
40
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
41
+ }
42
+ },
43
+ });
44
+
45
+ pi.registerTool({
46
+ name: options.toolName,
47
+ label: `${options.displayName} Auth`,
48
+ description: `Securely prompt the user for a ${options.displayName} token and store it without exposing it to the model. Use only when auth is missing/expired/invalid, or when the user asks to update the token.`,
49
+ parameters: ConfigureAuthParamsSchema,
50
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
51
+ const result = await configureAuthToken(ctx, options, { force: params.force });
52
+ return {
53
+ content: [{ type: "text", text: result.message }],
54
+ details: {
55
+ service: options.service,
56
+ stored: result.stored,
57
+ authPath: options.authPath.join("."),
58
+ envName: options.envName,
59
+ envWasSet: result.envWasSet,
60
+ },
61
+ };
62
+ },
63
+ });
64
+ }
65
+
66
+ export async function runWithAuthRetry<T>(
67
+ ctx: ExtensionContext,
68
+ options: AuthConfiguratorOptions,
69
+ operation: () => Promise<T>,
70
+ ): Promise<T> {
71
+ try {
72
+ return await operation();
73
+ } catch (error) {
74
+ if (!isAuthError(error)) throw error;
75
+ if (!ctx.hasUI) throw error;
76
+ await configureAuthToken(ctx, options, { force: true });
77
+ return operation();
78
+ }
79
+ }
80
+
81
+ export async function configureAuthToken(
82
+ ctx: ExtensionContext | ExtensionCommandContext,
83
+ options: AuthConfiguratorOptions,
84
+ params: ConfigureAuthParams = {},
85
+ ): Promise<{ stored: boolean; message: string; envWasSet: boolean }> {
86
+ if (!params.force) {
87
+ try {
88
+ await readAuthToken(options);
89
+ return {
90
+ stored: false,
91
+ envWasSet: Boolean(process.env[options.envName]?.trim()),
92
+ message: `${options.displayName} token is already configured. Use /${options.commandName} --force to replace it.`,
93
+ };
94
+ } catch (error) {
95
+ if (!(error instanceof MissingAuthTokenError)) throw error;
96
+ }
97
+ }
98
+
99
+ if (!ctx.hasUI) {
100
+ throw new Error(`${options.displayName} auth setup requires interactive UI.`);
101
+ }
102
+
103
+ const token = await promptSecret(ctx, `${options.displayName} token`);
104
+ if (!token) throw new Error(`${options.displayName} token setup cancelled.`);
105
+
106
+ await writeAuthToken({ authFile: options.authFile, authPath: options.authPath, token });
107
+ setAuthTokenOverride(options, token);
108
+
109
+ const envWasSet = Boolean(process.env[options.envName]?.trim());
110
+ const envNote = envWasSet
111
+ ? ` ${options.envName} is set and normally takes precedence; this pi session will use the new token, but update your environment for future sessions.`
112
+ : "";
113
+
114
+ return {
115
+ stored: true,
116
+ envWasSet,
117
+ message: `${options.displayName} token stored in ~/.pi/agent/auth.json at ${options.authPath.join(".")}.${envNote}`,
118
+ };
119
+ }
120
+
121
+ export async function writeAuthToken(options: { authPath: readonly string[]; token: string; authFile?: string }): Promise<void> {
122
+ const authFile = options.authFile ?? resolve(homedir(), ".pi", "agent", "auth.json");
123
+ await mkdir(dirname(authFile), { recursive: true });
124
+ await safeChmod(dirname(authFile), 0o700);
125
+
126
+ let auth: unknown = {};
127
+ try {
128
+ auth = JSON.parse(await readFile(authFile, "utf8")) as unknown;
129
+ } catch (error) {
130
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
131
+ }
132
+
133
+ const next = auth && typeof auth === "object" ? (auth as Record<string, unknown>) : {};
134
+ setPath(next, options.authPath, options.token);
135
+ await writeFile(authFile, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
136
+ await safeChmod(authFile, 0o600);
137
+ }
138
+
139
+ export function isAuthError(error: unknown): boolean {
140
+ if (error instanceof MissingAuthTokenError) return true;
141
+ if (error instanceof ApiError && (error.status === 401 || error.status === 403)) return true;
142
+ const message = error instanceof Error ? error.message : String(error);
143
+ return /token expired|invalid token|missing token|no .*token|unauthorized|forbidden|authentication|api key/i.test(message);
144
+ }
145
+
146
+ async function promptSecret(ctx: ExtensionContext | ExtensionCommandContext, title: string): Promise<string | null> {
147
+ return ctx.ui.custom<string | null>((tui, _theme, _keybindings, done) => {
148
+ let value = "";
149
+ let cached: string[] | undefined;
150
+
151
+ function refresh(): void {
152
+ cached = undefined;
153
+ tui.requestRender();
154
+ }
155
+
156
+ function row(content: string, contentWidth: number): string {
157
+ const safe = content.length > contentWidth ? content.slice(0, contentWidth) : content;
158
+ return `│ ${safe.padEnd(contentWidth)} │`;
159
+ }
160
+
161
+ function maskedInput(contentWidth: number): string {
162
+ if (value.length === 0) return "> ▌";
163
+
164
+ const inputChromeWidth = 3; // "> " + cursor.
165
+ const availableMaskWidth = Math.max(1, contentWidth - inputChromeWidth);
166
+ const suffix = value.length > availableMaskWidth ? ` ${value.length} chars` : "";
167
+ const bulletCount = Math.max(1, Math.min(value.length, availableMaskWidth - suffix.length));
168
+ return `> ${"•".repeat(bulletCount)}${suffix}▌`;
169
+ }
170
+
171
+ return {
172
+ render(width: number): string[] {
173
+ if (cached) return cached;
174
+ const boxWidth = Math.max(4, Math.min(width, 48));
175
+ const contentWidth = Math.max(0, boxWidth - 4);
176
+ const border = `┌${"─".repeat(Math.max(0, boxWidth - 2))}┐`;
177
+ const bottom = `└${"─".repeat(Math.max(0, boxWidth - 2))}┘`;
178
+ const lines = [border, row(title, contentWidth), row(maskedInput(contentWidth), contentWidth), bottom];
179
+ cached = lines;
180
+ return cached;
181
+ },
182
+ handleInput(data: string): void {
183
+ if (matchesKey(data, Key.escape)) {
184
+ done(null);
185
+ return;
186
+ }
187
+ if (matchesKey(data, Key.enter)) {
188
+ done(value.trim() || null);
189
+ return;
190
+ }
191
+ if (matchesKey(data, Key.backspace)) {
192
+ value = value.slice(0, -1);
193
+ refresh();
194
+ return;
195
+ }
196
+ if (data === "\u0015") {
197
+ value = "";
198
+ refresh();
199
+ return;
200
+ }
201
+ if (data === "\u0016") {
202
+ void readClipboardText().then((text) => {
203
+ const sanitized = sanitizeSecretInput(text);
204
+ if (sanitized) {
205
+ value += sanitized;
206
+ refresh();
207
+ }
208
+ });
209
+ return;
210
+ }
211
+
212
+ const sanitized = sanitizeSecretInput(data);
213
+ if (sanitized) {
214
+ value += sanitized;
215
+ refresh();
216
+ }
217
+ },
218
+ invalidate(): void {
219
+ cached = undefined;
220
+ },
221
+ };
222
+ }, {
223
+ overlay: true,
224
+ overlayOptions: {
225
+ width: 48,
226
+ minWidth: 32,
227
+ maxHeight: 4,
228
+ margin: 1,
229
+ },
230
+ });
231
+ }
232
+
233
+ function sanitizeSecretInput(data: string): string {
234
+ if (!data) return "";
235
+
236
+ // Bracketed paste: ESC [ 200 ~ pasted text ESC [ 201 ~
237
+ if (data.includes("\u001b[200~") || data.includes("\u001b[201~")) {
238
+ return data.replace(/\u001b\[200~/g, "").replace(/\u001b\[201~/g, "").replace(/[\r\n\t]/g, "").trim();
239
+ }
240
+
241
+ // Ignore non-paste escape sequences such as arrows and modified keys.
242
+ if (data.startsWith("\u001b")) return "";
243
+
244
+ return data.replace(/[\x00-\x1f\x7f]/g, "").trim();
245
+ }
246
+
247
+ async function readClipboardText(): Promise<string> {
248
+ try {
249
+ if (process.platform === "darwin") return (await execFileAsync("pbpaste", [])).stdout;
250
+ if (process.platform === "win32") {
251
+ return (await execFileAsync("powershell.exe", ["-NoProfile", "-Command", "Get-Clipboard"])).stdout;
252
+ }
253
+
254
+ for (const [command, args] of [
255
+ ["wl-paste", ["--no-newline"]],
256
+ ["xclip", ["-selection", "clipboard", "-out"]],
257
+ ["xsel", ["--clipboard", "--output"]],
258
+ ] as const) {
259
+ try {
260
+ return (await execFileAsync(command, args)).stdout;
261
+ } catch {
262
+ // Try next clipboard provider.
263
+ }
264
+ }
265
+ } catch {
266
+ // Clipboard access is best-effort; normal terminal paste can still work.
267
+ }
268
+ return "";
269
+ }
270
+
271
+ function setPath(target: Record<string, unknown>, path: readonly string[], value: string): void {
272
+ let current = target;
273
+ for (const [index, segment] of path.entries()) {
274
+ if (index === path.length - 1) {
275
+ current[segment] = value;
276
+ return;
277
+ }
278
+ const next = current[segment];
279
+ if (!next || typeof next !== "object" || Array.isArray(next)) current[segment] = {};
280
+ current = current[segment] as Record<string, unknown>;
281
+ }
282
+ }
283
+
284
+ async function safeChmod(path: string, mode: number): Promise<void> {
285
+ try {
286
+ await chmod(path, mode);
287
+ } catch (error) {
288
+ if (process.platform !== "win32") throw error;
289
+ }
290
+ }
@@ -0,0 +1,63 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { resolve } from "node:path";
4
+
5
+ export interface ReadAuthTokenOptions {
6
+ envName: string;
7
+ authPath: readonly string[];
8
+ authFile?: string;
9
+ }
10
+
11
+ const authTokenOverrides = new Map<string, string>();
12
+
13
+ export function setAuthTokenOverride(options: ReadAuthTokenOptions, token: string): void {
14
+ authTokenOverrides.set(authTokenKey(options), token);
15
+ }
16
+
17
+ export function clearAuthTokenOverride(options: ReadAuthTokenOptions): void {
18
+ authTokenOverrides.delete(authTokenKey(options));
19
+ }
20
+
21
+ export class MissingAuthTokenError extends Error {
22
+ constructor(public readonly envName: string, public readonly authPath: readonly string[]) {
23
+ super(
24
+ `No auth token found. Set ${envName} or store it in ~/.pi/agent/auth.json at ${authPath.join(".")}`,
25
+ );
26
+ this.name = "MissingAuthTokenError";
27
+ }
28
+ }
29
+
30
+ export async function readAuthToken(options: ReadAuthTokenOptions): Promise<string> {
31
+ const override = authTokenOverrides.get(authTokenKey(options));
32
+ if (override) return override;
33
+
34
+ const envValue = process.env[options.envName]?.trim();
35
+ if (envValue) return envValue;
36
+
37
+ const authFile = options.authFile ?? resolve(homedir(), ".pi", "agent", "auth.json");
38
+ try {
39
+ const raw = await readFile(authFile, "utf8");
40
+ const parsed = JSON.parse(raw) as unknown;
41
+ const value = getPath(parsed, options.authPath);
42
+ if (typeof value === "string" && value.trim()) return value.trim();
43
+ } catch (error) {
44
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ throw new MissingAuthTokenError(options.envName, options.authPath);
50
+ }
51
+
52
+ function getPath(value: unknown, path: readonly string[]): unknown {
53
+ let current = value;
54
+ for (const segment of path) {
55
+ if (!current || typeof current !== "object" || !(segment in current)) return undefined;
56
+ current = (current as Record<string, unknown>)[segment];
57
+ }
58
+ return current;
59
+ }
60
+
61
+ function authTokenKey(options: ReadAuthTokenOptions): string {
62
+ return `${options.envName}:${options.authPath.join(".")}:${options.authFile ?? "default"}`;
63
+ }
@@ -0,0 +1,60 @@
1
+ export interface CacheEntry<T> {
2
+ value: T;
3
+ expiresAt: number;
4
+ }
5
+
6
+ export interface TtlCacheOptions {
7
+ defaultTtlMs: number;
8
+ maxEntries?: number;
9
+ }
10
+
11
+ export class TtlCache<T> {
12
+ private readonly entries = new Map<string, CacheEntry<T>>();
13
+
14
+ constructor(private readonly options: TtlCacheOptions) {}
15
+
16
+ get(key: string): T | undefined {
17
+ const entry = this.entries.get(key);
18
+ if (!entry) return undefined;
19
+ if (entry.expiresAt <= Date.now()) {
20
+ this.entries.delete(key);
21
+ return undefined;
22
+ }
23
+ return entry.value;
24
+ }
25
+
26
+ set(key: string, value: T, ttlMs = this.options.defaultTtlMs): void {
27
+ this.entries.set(key, { value, expiresAt: Date.now() + ttlMs });
28
+ this.evictOverflow();
29
+ }
30
+
31
+ delete(key: string): void {
32
+ this.entries.delete(key);
33
+ }
34
+
35
+ clear(): void {
36
+ this.entries.clear();
37
+ }
38
+
39
+ getOrSet(key: string, load: () => Promise<T>, ttlMs = this.options.defaultTtlMs): Promise<T> {
40
+ const cached = this.get(key);
41
+ if (cached !== undefined) return Promise.resolve(cached);
42
+ return load().then((value) => {
43
+ this.set(key, value, ttlMs);
44
+ return value;
45
+ });
46
+ }
47
+
48
+ private evictOverflow(): void {
49
+ const maxEntries = this.options.maxEntries;
50
+ if (!maxEntries || this.entries.size <= maxEntries) return;
51
+ const overflow = this.entries.size - maxEntries;
52
+ for (const key of Array.from(this.entries.keys()).slice(0, overflow)) {
53
+ this.entries.delete(key);
54
+ }
55
+ }
56
+ }
57
+
58
+ export function createTtlCache<T>(options: TtlCacheOptions): TtlCache<T> {
59
+ return new TtlCache<T>(options);
60
+ }
@@ -0,0 +1,47 @@
1
+ export interface NormalizedApiError {
2
+ name: string;
3
+ message: string;
4
+ status?: number;
5
+ service?: string;
6
+ code?: string;
7
+ details?: unknown;
8
+ }
9
+
10
+ export class ApiError extends Error {
11
+ constructor(
12
+ message: string,
13
+ public readonly status?: number,
14
+ public readonly details?: unknown,
15
+ public readonly service?: string,
16
+ public readonly code?: string,
17
+ ) {
18
+ super(message);
19
+ this.name = "ApiError";
20
+ }
21
+ }
22
+
23
+ export function normalizeApiError(error: unknown, service?: string): NormalizedApiError {
24
+ if (error instanceof ApiError) {
25
+ return {
26
+ name: error.name,
27
+ message: error.message,
28
+ status: error.status,
29
+ service: error.service ?? service,
30
+ code: error.code,
31
+ details: error.details,
32
+ };
33
+ }
34
+
35
+ if (error instanceof Error) {
36
+ return { name: error.name, message: error.message, service };
37
+ }
38
+
39
+ return { name: "Error", message: String(error), service };
40
+ }
41
+
42
+ export function errorMessage(error: unknown, service?: string): string {
43
+ const normalized = normalizeApiError(error, service);
44
+ const prefix = normalized.service ? `${normalized.service} API error` : "API error";
45
+ const status = normalized.status ? ` (${normalized.status})` : "";
46
+ return `${prefix}${status}: ${normalized.message}`;
47
+ }
@@ -0,0 +1,118 @@
1
+ import { ApiError } from "./errors.js";
2
+
3
+ type HeadersInput = ConstructorParameters<typeof Headers>[0];
4
+
5
+ export interface HttpClientOptions {
6
+ baseUrl?: string;
7
+ timeoutMs?: number;
8
+ headers?: HeadersInput | (() => HeadersInput | Promise<HeadersInput>);
9
+ service?: string;
10
+ }
11
+
12
+ export interface RequestJsonOptions extends Omit<RequestInit, "body" | "headers"> {
13
+ body?: unknown;
14
+ headers?: HeadersInput;
15
+ timeoutMs?: number;
16
+ }
17
+
18
+ export interface HttpClient {
19
+ request<T = unknown>(path: string, options?: RequestJsonOptions): Promise<T>;
20
+ get<T = unknown>(path: string, options?: RequestJsonOptions): Promise<T>;
21
+ post<T = unknown>(path: string, body?: unknown, options?: RequestJsonOptions): Promise<T>;
22
+ download(url: string, options?: RequestJsonOptions): Promise<ArrayBuffer>;
23
+ }
24
+
25
+ export function createHttpClient(options: HttpClientOptions = {}): HttpClient {
26
+ async function mergedHeaders(extra?: HeadersInput): Promise<Headers> {
27
+ const headers = new Headers(
28
+ typeof options.headers === "function" ? await options.headers() : (options.headers ?? undefined),
29
+ );
30
+ if (extra) {
31
+ new Headers(extra).forEach((value, key) => headers.set(key, value));
32
+ }
33
+ return headers;
34
+ }
35
+
36
+ async function request<T = unknown>(path: string, requestOptions: RequestJsonOptions = {}): Promise<T> {
37
+ const url = buildUrl(options.baseUrl, path);
38
+ const { body, headers: extraHeaders, timeoutMs, ...initOptions } = requestOptions;
39
+ const headers = await mergedHeaders(extraHeaders);
40
+ const init: RequestInit = { ...initOptions, headers };
41
+
42
+ if (body !== undefined) {
43
+ if (!headers.has("content-type")) headers.set("content-type", "application/json");
44
+ init.body = typeof body === "string" ? body : JSON.stringify(body);
45
+ }
46
+
47
+ const response = await fetchWithTimeout(url, init, timeoutMs ?? options.timeoutMs);
48
+ return parseResponse<T>(response, options.service);
49
+ }
50
+
51
+ async function download(url: string, requestOptions: RequestJsonOptions = {}): Promise<ArrayBuffer> {
52
+ const { body: _body, headers: _headers, timeoutMs, ...initOptions } = requestOptions;
53
+ const response = await fetchWithTimeout(url, initOptions, timeoutMs ?? options.timeoutMs);
54
+ if (!response.ok) {
55
+ throw new ApiError(response.statusText || `HTTP ${response.status}`, response.status, await safeBody(response), options.service);
56
+ }
57
+ return response.arrayBuffer();
58
+ }
59
+
60
+ return {
61
+ request,
62
+ get: (path, requestOptions) => request(path, { ...requestOptions, method: "GET" }),
63
+ post: (path, body, requestOptions) => request(path, { ...requestOptions, method: "POST", body }),
64
+ download,
65
+ };
66
+ }
67
+
68
+ function buildUrl(baseUrl: string | undefined, path: string): string {
69
+ if (/^https?:\/\//i.test(path)) return path;
70
+ if (!baseUrl) return path;
71
+ return `${baseUrl.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
72
+ }
73
+
74
+ async function fetchWithTimeout(url: string, init: RequestInit, timeoutMs = 30_000): Promise<Response> {
75
+ const controller = new AbortController();
76
+ const timeout = setTimeout(() => controller.abort(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs);
77
+ const upstream = init.signal;
78
+ const abort = () => controller.abort(upstream?.reason);
79
+ upstream?.addEventListener("abort", abort, { once: true });
80
+
81
+ try {
82
+ return await fetch(url, { ...init, signal: controller.signal });
83
+ } finally {
84
+ clearTimeout(timeout);
85
+ upstream?.removeEventListener("abort", abort);
86
+ }
87
+ }
88
+
89
+ async function parseResponse<T>(response: Response, service?: string): Promise<T> {
90
+ const body = await safeBody(response);
91
+ if (!response.ok) {
92
+ throw new ApiError(extractErrorMessage(body) ?? response.statusText ?? `HTTP ${response.status}`, response.status, body, service);
93
+ }
94
+ return body as T;
95
+ }
96
+
97
+ async function safeBody(response: Response): Promise<unknown> {
98
+ const text = await response.text();
99
+ if (!text) return undefined;
100
+ try {
101
+ return JSON.parse(text) as unknown;
102
+ } catch {
103
+ return text;
104
+ }
105
+ }
106
+
107
+ function extractErrorMessage(body: unknown): string | undefined {
108
+ if (!body || typeof body !== "object") return undefined;
109
+ const record = body as Record<string, unknown>;
110
+ if (typeof record.message === "string") return record.message;
111
+ if (typeof record.err === "string") return record.err;
112
+ const errors = record.errors;
113
+ if (Array.isArray(errors) && errors.length > 0) {
114
+ const first = errors[0] as Record<string, unknown>;
115
+ if (typeof first.message === "string") return first.message;
116
+ }
117
+ return undefined;
118
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./auth.js";
2
+ export * from "./auth-config.js";
3
+ export * from "./http-client.js";
4
+ export * from "./rate-limiter.js";
5
+ export * from "./cache.js";
6
+ export * from "./errors.js";
7
+ export * from "./tool-result.js";
@@ -0,0 +1,32 @@
1
+ export interface RateLimiterOptions {
2
+ minIntervalMs: number;
3
+ }
4
+
5
+ export interface RateLimiter {
6
+ schedule<T>(operation: () => Promise<T>): Promise<T>;
7
+ }
8
+
9
+ export function createRateLimiter(options: RateLimiterOptions): RateLimiter {
10
+ let lastStart = 0;
11
+ let chain: Promise<unknown> = Promise.resolve();
12
+
13
+ return {
14
+ schedule<T>(operation: () => Promise<T>): Promise<T> {
15
+ const run = async (): Promise<T> => {
16
+ const now = Date.now();
17
+ const waitMs = Math.max(0, lastStart + options.minIntervalMs - now);
18
+ if (waitMs > 0) await sleep(waitMs);
19
+ lastStart = Date.now();
20
+ return operation();
21
+ };
22
+
23
+ const next = chain.then(run, run);
24
+ chain = next.catch(() => undefined);
25
+ return next;
26
+ },
27
+ };
28
+ }
29
+
30
+ function sleep(ms: number): Promise<void> {
31
+ return new Promise((resolve) => setTimeout(resolve, ms));
32
+ }
@@ -0,0 +1,27 @@
1
+ export interface ToolResultOptions {
2
+ maxChars?: number;
3
+ }
4
+
5
+ export interface ToolResult {
6
+ content: Array<{ type: "text"; text: string }>;
7
+ details: Record<string, unknown>;
8
+ }
9
+
10
+ const DEFAULT_MAX_CHARS = 40_000;
11
+
12
+ export function textToolResult(text: string, details: Record<string, unknown> = {}): ToolResult {
13
+ return { content: [{ type: "text", text }], details };
14
+ }
15
+
16
+ export function jsonToolResult(data: unknown, options: ToolResultOptions = {}): ToolResult {
17
+ const pretty = JSON.stringify(data, null, 2);
18
+ const maxChars = options.maxChars ?? DEFAULT_MAX_CHARS;
19
+ const truncated = pretty.length > maxChars;
20
+ const text = truncated
21
+ ? `${pretty.slice(0, maxChars)}\n\n[truncated ${pretty.length - maxChars} characters; narrow the query or request specific IDs]`
22
+ : pretty;
23
+ return {
24
+ content: [{ type: "text", text }],
25
+ details: { truncated, characters: pretty.length },
26
+ };
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mono-figma",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Pi extension and skill for Figma design context tools",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -12,8 +12,11 @@
12
12
  "dependencies": {
13
13
  "pi-common": "0.1.1"
14
14
  },
15
+ "bundledDependencies": [
16
+ "pi-common"
17
+ ],
15
18
  "peerDependencies": {
16
- "@mariozechner/pi-coding-agent": "*",
19
+ "@earendil-works/pi-coding-agent": "*",
17
20
  "@sinclair/typebox": "*"
18
21
  },
19
22
  "pi": {
@@ -36,10 +36,17 @@ const FigmaNodeProcessingOptions = {
36
36
  includeComponentInternals: Type.Optional(Type.Boolean({ description: "Expand component instance internals. Defaults to false." })),
37
37
  };
38
38
 
39
+ const RenderFormatSchema = Type.Union([Type.Literal("png"), Type.Literal("jpg"), Type.Literal("svg"), Type.Literal("pdf")], {
40
+ description: "Rendered asset format",
41
+ });
42
+ const FrameworkSchema = Type.Union([Type.Literal("react"), Type.Literal("html"), Type.Literal("vue"), Type.Literal("angular"), Type.Literal("react-native")]);
43
+ const StylingSchema = Type.Union([Type.Literal("css"), Type.Literal("css-modules"), Type.Literal("styled-components"), Type.Literal("tailwind"), Type.Literal("inline")]);
44
+ const AssetTypeSchema = Type.Union([Type.Literal("svgIcons"), Type.Literal("nodeRenders"), Type.Literal("imageFills")]);
45
+
39
46
  const FigmaOptionalRenderOptions = {
40
47
  renderImage: Type.Optional(Type.Boolean({ description: "Render the node and include image URL/local path in the response. Defaults to false." })),
41
48
  outputDir: Type.Optional(Type.String({ description: "Optional directory for downloaded rendered image files. Omit unless the user requested persistent files; by default downloads go to an OS temp directory." })),
42
- format: Type.Optional(Type.Unsafe<"png" | "jpg" | "svg" | "pdf">({ type: "string", enum: ["png", "jpg", "svg", "pdf"] })),
49
+ format: Type.Optional(RenderFormatSchema),
43
50
  scale: Type.Optional(Type.Number({ description: "Render scale for bitmap formats", minimum: 0.01, maximum: 4 })),
44
51
  };
45
52
 
@@ -63,8 +70,8 @@ export const FigmaImplementationContextParams = Type.Object({
63
70
  nodeId: NodeIdSchema,
64
71
  ...FigmaNodeProcessingOptions,
65
72
  ...FigmaOptionalRenderOptions,
66
- framework: Type.Optional(Type.Unsafe<"react" | "html" | "vue" | "angular" | "react-native">({ type: "string", enum: ["react", "html", "vue", "angular", "react-native"] })),
67
- styling: Type.Optional(Type.Unsafe<"css" | "css-modules" | "styled-components" | "tailwind" | "inline">({ type: "string", enum: ["css", "css-modules", "styled-components", "tailwind", "inline"] })),
73
+ framework: Type.Optional(FrameworkSchema),
74
+ styling: Type.Optional(StylingSchema),
68
75
  resolveTokens: Type.Optional(Type.Boolean({ description: "Resolve style and variable IDs into token names when possible. Defaults to true." })),
69
76
  includeCodeSnippets: Type.Optional(Type.Boolean({ description: "Include compact starter snippets for the selected framework/styling target. Defaults to false." })),
70
77
  maxResponseChars: MaxResponseCharsSchema,
@@ -96,7 +103,7 @@ export const FigmaRenderNodesParams = Type.Object({
96
103
  fileKey: FileKeySchema,
97
104
  nodeIds: NodeIdsSchema,
98
105
  outputDir: Type.Optional(Type.String({ description: "Optional directory for downloaded image files. Omit unless the user requested persistent files; if omitted, an OS temp directory is created." })),
99
- format: Type.Optional(Type.Unsafe<"png" | "jpg" | "svg" | "pdf">({ type: "string", enum: ["png", "jpg", "svg", "pdf"] })),
106
+ format: Type.Optional(RenderFormatSchema),
100
107
  scale: Type.Optional(Type.Number({ description: "Render scale for bitmap formats", minimum: 0.01, maximum: 4 })),
101
108
  download: Type.Optional(Type.Boolean({ description: "Download rendered assets locally. Defaults to true." })),
102
109
  maxResponseChars: MaxResponseCharsSchema,
@@ -106,7 +113,7 @@ export const FigmaExtractAssetsParams = Type.Object({
106
113
  fileKey: FileKeySchema,
107
114
  nodeId: NodeIdSchema,
108
115
  depth: Type.Optional(Type.Number({ description: "How many levels of node hierarchy to inspect for assets. Defaults to 3 and is capped at 4.", minimum: 1, maximum: 4 })),
109
- assetTypes: Type.Optional(Type.Array(Type.Unsafe<"svgIcons" | "nodeRenders" | "imageFills">({ type: "string", enum: ["svgIcons", "nodeRenders", "imageFills"] }), { description: "Asset categories to extract. Defaults to all supported categories." })),
116
+ assetTypes: Type.Optional(Type.Array(AssetTypeSchema, { description: "Asset categories to extract. Defaults to all supported categories." })),
110
117
  outputDir: Type.Optional(Type.String({ description: "Optional directory for downloaded asset files. Omit unless the user requested persistent files; by default files go to an OS temp directory." })),
111
118
  includeHidden: Type.Optional(Type.Boolean({ description: "Include hidden nodes while discovering assets. Defaults to false." })),
112
119
  maxAssets: Type.Optional(Type.Number({ description: "Maximum assets to include in the manifest. Defaults to 80.", minimum: 1, maximum: 500 })),
@@ -126,8 +133,8 @@ export const FigmaComponentImplementationHintsParams = Type.Object({
126
133
  fileKey: FileKeySchema,
127
134
  nodeId: NodeIdSchema,
128
135
  ...FigmaNodeProcessingOptions,
129
- framework: Type.Optional(Type.Unsafe<"react" | "html" | "vue" | "angular" | "react-native">({ type: "string", enum: ["react", "html", "vue", "angular", "react-native"] })),
130
- styling: Type.Optional(Type.Unsafe<"css" | "css-modules" | "styled-components" | "tailwind" | "inline">({ type: "string", enum: ["css", "css-modules", "styled-components", "tailwind", "inline"] })),
136
+ framework: Type.Optional(FrameworkSchema),
137
+ styling: Type.Optional(StylingSchema),
131
138
  includeCodeConnect: Type.Optional(Type.Boolean({ description: "Scan the local repo for Figma Code Connect mappings. Defaults to true." })),
132
139
  includeSnippet: Type.Optional(Type.Boolean({ description: "Include starter framework snippet. Defaults to false." })),
133
140
  rootDir: Type.Optional(Type.String({ description: "Optional local repo subdirectory for Code Connect scanning." })),
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { registerAuthConfigurator, runWithAuthRetry, type AuthConfiguratorOptions } from "pi-common/auth-config";
3
3
  import { jsonToolResult, textToolResult } from "pi-common/tool-result";
4
4
  import { FigmaClient, parseFigmaUrl } from "./figma-client.js";