figmanage 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  MCP server for managing your Figma workspace.
4
4
 
5
- Manages Figma workspaces through AI assistants. 79 tools covering files, projects, teams, permissions, comments, versions, components, webhooks, org admin, and more. Works with Claude Code, Claude Desktop, ChatGPT, and any MCP-compatible client.
5
+ Manages Figma workspaces through AI assistants. 85 tools covering files, projects, teams, permissions, comments, versions, components, webhooks, org admin, and more. Works with Claude Code, Claude Desktop, ChatGPT, and any MCP-compatible client.
6
6
 
7
7
  ## quick start
8
8
 
@@ -16,13 +16,13 @@ claude mcp add figmanage -s user -e FIGMA_PAT=figd_xxx -- npx -y figmanage
16
16
 
17
17
  Restart Claude Code. Gives you 30+ tools (comments, reading, export, components, versions, webhooks).
18
18
 
19
- ### full setup (2 minutes, all 79 tools)
19
+ ### full setup (2 minutes, all 85 tools)
20
20
 
21
21
  ```bash
22
22
  npx -y figmanage --setup
23
23
  ```
24
24
 
25
- Extracts your Chrome cookie, prompts for a PAT, registers with Claude Code automatically. Unlocks all 79 tools including workspace management, permissions, and org admin. Restart Claude Code.
25
+ Extracts your Chrome cookie, prompts for a PAT, registers with Claude Code automatically. Unlocks all 85 tools including workspace management, permissions, and org admin. Restart Claude Code.
26
26
 
27
27
  Use `--no-prompt --pat figd_xxx` for non-interactive setup.
28
28
 
@@ -260,7 +260,7 @@ Tools are automatically filtered at startup based on available credentials. If y
260
260
  |------|------|-------------|
261
261
  | `list_org_libraries` | cookie | All design system libraries in the org with sharing group info |
262
262
 
263
- ### compound (6 tools)
263
+ ### compound (12 tools)
264
264
 
265
265
  Multi-step operations that aggregate data from several API calls.
266
266
 
@@ -272,6 +272,12 @@ Multi-step operations that aggregate data from several API calls.
272
272
  | `cleanup_stale_files` | either | Find files not modified in N days, optionally trash them (dry run by default) |
273
273
  | `organize_project` | cookie | Batch-move files into a target project |
274
274
  | `setup_project_structure` | cookie | Create multiple projects in a team from a plan |
275
+ | `seat_optimization` | cookie | Identify inactive paid seats and calculate potential savings |
276
+ | `permission_audit` | cookie | Audit who has access to a team or project, flag oversharing |
277
+ | `branch_cleanup` | either | Find stale branches across a project, optionally archive (dry run by default) |
278
+ | `offboard_user` | cookie | Audit + execute user departure: transfer files, revoke access, downgrade seat |
279
+ | `onboard_user` | cookie | Batch invite to teams, share files, set seat type in one call |
280
+ | `quarterly_design_ops_report` | cookie | Org-wide snapshot: seat utilization, billing, teams, library adoption |
275
281
 
276
282
  ## transport modes
277
283
 
@@ -329,7 +335,8 @@ src/
329
335
  org.ts Org admin, billing, seats, domains via internal API (9 tools)
330
336
  teams.ts Team CRUD (3 tools)
331
337
  libraries.ts Design system libraries via internal API (1 tool)
332
- compound.ts Multi-step aggregation tools (6 tools)
338
+ compound.ts Multi-step aggregation tools (9 tools)
339
+ compound-manager.ts Manager workflow tools (3 tools)
333
340
  types/
334
341
  figma.ts Shared types (Toolset, AuthConfig, etc.)
335
342
  ```
package/dist/index.d.ts CHANGED
@@ -16,4 +16,5 @@ import './tools/org.js';
16
16
  import './tools/libraries.js';
17
17
  import './tools/teams.js';
18
18
  import './tools/compound.js';
19
+ import './tools/compound-manager.js';
19
20
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -28,6 +28,7 @@ import './tools/org.js';
28
28
  import './tools/libraries.js';
29
29
  import './tools/teams.js';
30
30
  import './tools/compound.js';
31
+ import './tools/compound-manager.js';
31
32
  const ALL_TOOLSETS = [
32
33
  'navigate', 'files', 'projects', 'permissions', 'org',
33
34
  'versions', 'branching', 'comments', 'export',
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=compound-manager.d.ts.map
@@ -0,0 +1,719 @@
1
+ import { z } from 'zod';
2
+ import { internalClient } from '../clients/internal-api.js';
3
+ import { defineTool, toolResult, toolError, figmaId, requireOrgId } from './register.js';
4
+ const BATCH_SIZE = 5;
5
+ const LEVEL_NAMES = { 999: 'owner', 300: 'editor', 100: 'viewer' };
6
+ function levelName(level) {
7
+ return LEVEL_NAMES[level] || `level:${level}`;
8
+ }
9
+ /** Process items in batches of BATCH_SIZE using Promise.allSettled */
10
+ async function batchProcess(items, fn) {
11
+ const results = [];
12
+ for (let i = 0; i < items.length; i += BATCH_SIZE) {
13
+ const batch = items.slice(i, i + BATCH_SIZE);
14
+ const batchResults = await Promise.allSettled(batch.map(fn));
15
+ results.push(...batchResults);
16
+ }
17
+ return results;
18
+ }
19
+ // -- offboard_user --
20
+ const SEAT_KEY_TO_TYPE = {
21
+ expert: 'full',
22
+ developer: 'dev',
23
+ collaborator: 'collab',
24
+ };
25
+ const MAX_REVOCATIONS = 25;
26
+ defineTool({
27
+ toolset: 'compound',
28
+ auth: 'cookie',
29
+ mutates: true,
30
+ destructive: true,
31
+ register(server, config) {
32
+ server.registerTool('offboard_user', {
33
+ description: 'Audit and optionally execute user offboarding. Default: read-only audit. With execute=true: transfers file ownership, revokes access, downgrades seat.',
34
+ inputSchema: {
35
+ user_identifier: z.string().describe('Email or user_id of the user to offboard'),
36
+ execute: z.boolean().optional().default(false).describe('Execute the offboarding (default: false, audit only)'),
37
+ transfer_to: z.string().optional().describe('Email or user_id to transfer file ownership to (required if user owns files and execute=true)'),
38
+ org_id: figmaId.optional().describe('Org ID override (defaults to current workspace)'),
39
+ },
40
+ }, async ({ user_identifier, execute: rawExecute, transfer_to, org_id }) => {
41
+ try {
42
+ let orgId;
43
+ try {
44
+ orgId = requireOrgId(config, org_id);
45
+ }
46
+ catch (e) {
47
+ return toolError(e.message);
48
+ }
49
+ const api = internalClient(config);
50
+ // Step 1: Resolve user
51
+ const userRes = await api.get(`/api/v2/orgs/${orgId}/org_users`, {
52
+ params: user_identifier.includes('@') ? { search_query: user_identifier } : {},
53
+ });
54
+ const rawUsers = userRes.data?.meta?.users || userRes.data?.meta || userRes.data || [];
55
+ const members = Array.isArray(rawUsers) ? rawUsers : [];
56
+ const member = members.find((m) => user_identifier.includes('@')
57
+ ? m.user?.email === user_identifier
58
+ : String(m.user_id) === String(user_identifier));
59
+ if (!member) {
60
+ return toolError(`User not found: ${user_identifier}`);
61
+ }
62
+ const userId = String(member.user_id);
63
+ // Block self-offboarding
64
+ if (userId === String(config.userId)) {
65
+ return toolError('Cannot audit yourself for offboarding.');
66
+ }
67
+ const user = {
68
+ org_user_id: String(member.id),
69
+ user_id: userId,
70
+ name: member.user?.handle || null,
71
+ email: member.user?.email || null,
72
+ seat_type: member.active_seat_type?.key || null,
73
+ permission: member.permission || null,
74
+ };
75
+ // Step 2: Fetch all org teams
76
+ const teamsRes = await api.get(`/api/orgs/${orgId}/teams`);
77
+ const allTeams = (teamsRes.data?.meta || teamsRes.data || []);
78
+ const cappedTeams = allTeams.slice(0, 30);
79
+ // Step 3: Check team membership (batched)
80
+ const teamMemberships = [];
81
+ const memberResults = await batchProcess(cappedTeams, async (team) => {
82
+ const res = await api.get(`/api/teams/${team.id}/members`);
83
+ const teamMembers = res.data?.meta || res.data || [];
84
+ const match = teamMembers.find((m) => String(m.id) === userId);
85
+ return { team, match };
86
+ });
87
+ for (const r of memberResults) {
88
+ if (r.status === 'fulfilled' && r.value.match) {
89
+ const { team, match } = r.value;
90
+ teamMemberships.push({
91
+ team_id: String(team.id),
92
+ team_name: team.name,
93
+ role: match.team_role?.level ? levelName(match.team_role.level) : 'member',
94
+ });
95
+ }
96
+ }
97
+ // Step 4: For each team the user belongs to (cap 10), get projects
98
+ const userTeams = teamMemberships.slice(0, 10);
99
+ const allProjects = [];
100
+ const projectResults = await batchProcess(userTeams, async (tm) => {
101
+ const res = await api.get(`/api/teams/${tm.team_id}/folders`);
102
+ const raw = res.data?.meta?.folder_rows || res.data?.meta || res.data || [];
103
+ const folders = Array.isArray(raw) ? raw : [];
104
+ return { team_name: tm.team_name, folders };
105
+ });
106
+ for (const r of projectResults) {
107
+ if (r.status === 'fulfilled') {
108
+ for (const f of r.value.folders) {
109
+ if (allProjects.length >= 50)
110
+ break;
111
+ allProjects.push({
112
+ project_id: String(f.id),
113
+ project_name: f.name,
114
+ team_name: r.value.team_name,
115
+ });
116
+ }
117
+ }
118
+ }
119
+ // Step 5: Check project roles (batched)
120
+ const projectPermissions = [];
121
+ const roleResults = await batchProcess(allProjects, async (proj) => {
122
+ const res = await api.get(`/api/roles/folder/${proj.project_id}`);
123
+ const roles = res.data?.meta || [];
124
+ const match = roles.find((r) => String(r.user_id) === userId);
125
+ return { proj, match };
126
+ });
127
+ for (const r of roleResults) {
128
+ if (r.status === 'fulfilled' && r.value.match) {
129
+ const { proj, match } = r.value;
130
+ projectPermissions.push({
131
+ project_id: proj.project_id,
132
+ project_name: proj.project_name,
133
+ team_name: proj.team_name,
134
+ role: levelName(match.level),
135
+ });
136
+ }
137
+ }
138
+ // Step 6: Sample files from projects where user has access
139
+ const projectsWithAccess = projectPermissions.slice(0, 10);
140
+ const fileOwnership = [];
141
+ let totalFilesChecked = 0;
142
+ for (const proj of projectsWithAccess) {
143
+ if (totalFilesChecked >= 50)
144
+ break;
145
+ try {
146
+ const filesRes = await api.get(`/api/folders/${proj.project_id}/paginated_files`, {
147
+ params: { folderId: proj.project_id, page_size: 10 },
148
+ });
149
+ const meta = filesRes.data?.meta || filesRes.data;
150
+ const files = meta?.files || meta || [];
151
+ const fileRoleResults = await batchProcess(files.slice(0, Math.min(10, 50 - totalFilesChecked)), async (file) => {
152
+ const res = await api.get(`/api/roles/file/${file.key}`);
153
+ const roles = res.data?.meta || [];
154
+ const match = roles.find((r) => String(r.user_id) === userId && r.level === 999);
155
+ return { file, match };
156
+ });
157
+ for (const r of fileRoleResults) {
158
+ totalFilesChecked++;
159
+ if (r.status === 'fulfilled' && r.value.match) {
160
+ fileOwnership.push({
161
+ file_key: r.value.file.key,
162
+ file_name: r.value.file.name,
163
+ project_name: proj.project_name,
164
+ team_name: proj.team_name,
165
+ });
166
+ }
167
+ }
168
+ }
169
+ catch {
170
+ // Skip projects where file listing fails
171
+ }
172
+ }
173
+ const execute = rawExecute ?? false;
174
+ const summary = {
175
+ teams: teamMemberships.length,
176
+ projects_with_access: projectPermissions.length,
177
+ files_owned: fileOwnership.length,
178
+ };
179
+ const transferPlan = [];
180
+ if (fileOwnership.length > 0) {
181
+ transferPlan.push(`${fileOwnership.length} file(s) need ownership transfer before removal.`);
182
+ }
183
+ if (teamMemberships.length > 0) {
184
+ transferPlan.push(`Remove from ${teamMemberships.length} team(s).`);
185
+ }
186
+ if (projectPermissions.length > 0) {
187
+ transferPlan.push(`Revoke access to ${projectPermissions.length} project(s).`);
188
+ }
189
+ if (user.seat_type) {
190
+ transferPlan.push(`Downgrade or remove ${user.seat_type} seat.`);
191
+ }
192
+ // Audit-only mode
193
+ if (!execute) {
194
+ const result = {
195
+ user,
196
+ team_memberships: teamMemberships,
197
+ project_permissions: projectPermissions,
198
+ file_ownership: fileOwnership,
199
+ summary,
200
+ transfer_plan: transferPlan,
201
+ note: 'Run with execute=true to perform offboarding. Provide transfer_to if the user owns files.',
202
+ };
203
+ return toolResult(JSON.stringify(result, null, 2));
204
+ }
205
+ // --- Execute mode ---
206
+ // Validate: if user owns files, transfer_to is required
207
+ if (fileOwnership.length > 0 && !transfer_to) {
208
+ return toolError(`User owns ${fileOwnership.length} file(s). Provide transfer_to (email or user_id) to transfer ownership before revoking access.`);
209
+ }
210
+ // Resolve transfer_to user
211
+ let transferToUserId;
212
+ if (transfer_to) {
213
+ const tRes = await api.get(`/api/v2/orgs/${orgId}/org_users`, {
214
+ params: transfer_to.includes('@') ? { search_query: transfer_to } : {},
215
+ });
216
+ const tUsers = tRes.data?.meta?.users || tRes.data?.meta || tRes.data || [];
217
+ const tList = Array.isArray(tUsers) ? tUsers : [];
218
+ const tMatch = tList.find((m) => transfer_to.includes('@')
219
+ ? m.user?.email === transfer_to
220
+ : String(m.user_id) === String(transfer_to));
221
+ if (!tMatch)
222
+ return toolError(`Transfer target not found: ${transfer_to}`);
223
+ transferToUserId = String(tMatch.user_id);
224
+ }
225
+ // Cap total mutations
226
+ const totalMutations = fileOwnership.length + teamMemberships.length + projectPermissions.length + 1;
227
+ if (totalMutations > MAX_REVOCATIONS) {
228
+ return toolError(`Offboarding would require ${totalMutations} mutations (cap: ${MAX_REVOCATIONS}). ` +
229
+ `Reduce scope or execute manually with revoke_access and set_permissions.`);
230
+ }
231
+ const actions = [];
232
+ // Step A: Transfer file ownership
233
+ if (fileOwnership.length > 0 && transferToUserId) {
234
+ for (const file of fileOwnership) {
235
+ try {
236
+ // Get the role entry for the file
237
+ const rolesRes = await api.get(`/api/roles/file/${file.file_key}`);
238
+ const roles = rolesRes.data?.meta || [];
239
+ const ownerRole = roles.find((r) => String(r.user_id) === userId && r.level === 999);
240
+ const transfereeRole = roles.find((r) => String(r.user_id) === transferToUserId);
241
+ // Give transfer target owner access
242
+ if (transfereeRole) {
243
+ await api.put(`/api/roles/${transfereeRole.id}`, { level: 999 });
244
+ }
245
+ else {
246
+ // Invite as editor first, then promote -- can't directly set owner on uninvited user
247
+ await api.post('/api/invites', {
248
+ resource_type: 'file',
249
+ resource_id_or_key: file.file_key,
250
+ emails: [transfer_to],
251
+ level: 300,
252
+ });
253
+ // Re-fetch roles to get the new role ID
254
+ const rolesRes2 = await api.get(`/api/roles/file/${file.file_key}`);
255
+ const roles2 = rolesRes2.data?.meta || [];
256
+ const newRole = roles2.find((r) => String(r.user_id) === transferToUserId);
257
+ if (newRole) {
258
+ await api.put(`/api/roles/${newRole.id}`, { level: 999 });
259
+ }
260
+ }
261
+ // Downgrade departing user from owner to editor (can't revoke owner directly)
262
+ if (ownerRole) {
263
+ await api.put(`/api/roles/${ownerRole.id}`, { level: 300 });
264
+ }
265
+ actions.push({ action: 'transfer_ownership', status: 'done', detail: `${file.file_name} -> ${transfer_to}` });
266
+ }
267
+ catch (e) {
268
+ actions.push({ action: 'transfer_ownership', status: 'failed', detail: `${file.file_name}: ${e.response?.status || e.message}` });
269
+ }
270
+ }
271
+ }
272
+ // Step B: Revoke file access (now that they're no longer owner)
273
+ for (const file of fileOwnership) {
274
+ try {
275
+ const rolesRes = await api.get(`/api/roles/file/${file.file_key}`);
276
+ const roles = rolesRes.data?.meta || [];
277
+ const role = roles.find((r) => String(r.user_id) === userId);
278
+ if (role) {
279
+ await api.delete(`/api/roles/${role.id}`);
280
+ actions.push({ action: 'revoke_file', status: 'done', detail: file.file_name });
281
+ }
282
+ }
283
+ catch (e) {
284
+ actions.push({ action: 'revoke_file', status: 'failed', detail: `${file.file_name}: ${e.response?.status || e.message}` });
285
+ }
286
+ }
287
+ // Step C: Revoke project access
288
+ const projResults = await batchProcess(projectPermissions, async (proj) => {
289
+ const rolesRes = await api.get(`/api/roles/folder/${proj.project_id}`);
290
+ const roles = rolesRes.data?.meta || [];
291
+ const role = roles.find((r) => String(r.user_id) === userId);
292
+ if (role)
293
+ await api.delete(`/api/roles/${role.id}`);
294
+ return proj;
295
+ });
296
+ for (let i = 0; i < projectPermissions.length; i++) {
297
+ const r = projResults[i];
298
+ actions.push({
299
+ action: 'revoke_project',
300
+ status: r.status === 'fulfilled' ? 'done' : 'failed',
301
+ detail: projectPermissions[i].project_name,
302
+ });
303
+ }
304
+ // Step D: Revoke team memberships
305
+ const teamResults = await batchProcess(teamMemberships, async (tm) => {
306
+ const rolesRes = await api.get(`/api/roles/team/${tm.team_id}`);
307
+ const roles = rolesRes.data?.meta || [];
308
+ const role = roles.find((r) => String(r.user_id) === userId);
309
+ if (role)
310
+ await api.delete(`/api/roles/${role.id}`);
311
+ return tm;
312
+ });
313
+ for (let i = 0; i < teamMemberships.length; i++) {
314
+ const r = teamResults[i];
315
+ actions.push({
316
+ action: 'revoke_team',
317
+ status: r.status === 'fulfilled' ? 'done' : 'failed',
318
+ detail: teamMemberships[i].team_name,
319
+ });
320
+ }
321
+ // Step E: Downgrade seat to viewer
322
+ if (user.seat_type) {
323
+ try {
324
+ const viewStatuses = { collaborator: 'starter', developer: 'starter', expert: 'starter' };
325
+ await api.put(`/api/orgs/${orgId}/org_users`, {
326
+ org_user_ids: [user.org_user_id],
327
+ paid_statuses: viewStatuses,
328
+ entry_point: 'members_tab',
329
+ seat_increase_authorized: 'true',
330
+ seat_swap_intended: 'false',
331
+ latest_ou_update: member.updated_at,
332
+ showing_billing_groups: 'true',
333
+ }, {
334
+ 'axios-retry': { retries: 0 },
335
+ });
336
+ actions.push({ action: 'downgrade_seat', status: 'done', detail: `${user.seat_type} -> viewer` });
337
+ }
338
+ catch (e) {
339
+ actions.push({ action: 'downgrade_seat', status: 'failed', detail: e.response?.status || e.message });
340
+ }
341
+ }
342
+ const succeeded = actions.filter(a => a.status === 'done').length;
343
+ const failed = actions.filter(a => a.status === 'failed').length;
344
+ const result = {
345
+ user,
346
+ executed: true,
347
+ actions,
348
+ summary: { succeeded, failed, total: actions.length },
349
+ note: failed > 0
350
+ ? `${failed} action(s) failed. Review and retry manually.`
351
+ : 'Offboarding complete.',
352
+ };
353
+ return toolResult(JSON.stringify(result, null, 2));
354
+ }
355
+ catch (e) {
356
+ return toolError(`Failed to audit user for offboarding: ${e.response?.status || e.message}`);
357
+ }
358
+ });
359
+ },
360
+ });
361
+ // -- onboard_user --
362
+ const PAID_STATUSES = {
363
+ full: { expert: 'full' },
364
+ dev: { developer: 'full' },
365
+ collab: { collaborator: 'full' },
366
+ view: { collaborator: 'starter', developer: 'starter', expert: 'starter' },
367
+ };
368
+ const LEVEL_MAP = { editor: 300, viewer: 100 };
369
+ defineTool({
370
+ toolset: 'compound',
371
+ auth: 'cookie',
372
+ mutates: true,
373
+ register(server, config) {
374
+ server.registerTool('onboard_user', {
375
+ description: 'Invite a user to teams and optionally share files and set seat type. Sends invite emails per team.',
376
+ inputSchema: {
377
+ email: z.string().email().describe('Email address to invite'),
378
+ team_ids: z.array(figmaId).min(1).describe('Team IDs to invite the user to'),
379
+ role: z.enum(['editor', 'viewer']).optional().default('editor').describe('Role for team access (default: editor)'),
380
+ share_files: z.array(figmaId).optional().describe('File keys to share with the user (viewer access)'),
381
+ seat_type: z.enum(['full', 'dev', 'collab', 'view']).optional().describe('Seat type to assign after invite'),
382
+ confirm: z.boolean().optional().describe('Required to execute seat change'),
383
+ org_id: figmaId.optional().describe('Org ID override (defaults to current workspace)'),
384
+ },
385
+ }, async ({ email, team_ids, role, share_files, seat_type, confirm, org_id }) => {
386
+ try {
387
+ let orgId;
388
+ try {
389
+ orgId = requireOrgId(config, org_id);
390
+ }
391
+ catch (e) {
392
+ return toolError(e.message);
393
+ }
394
+ // Enforce caps
395
+ if (team_ids.length > 10) {
396
+ return toolError(`Too many teams: ${team_ids.length}. Maximum is 10.`);
397
+ }
398
+ if (share_files && share_files.length > 20) {
399
+ return toolError(`Too many files: ${share_files.length}. Maximum is 20.`);
400
+ }
401
+ const api = internalClient(config);
402
+ const level = LEVEL_MAP[role || 'editor'];
403
+ // Validate team_ids exist
404
+ const teamsRes = await api.get(`/api/orgs/${orgId}/teams`);
405
+ const orgTeams = teamsRes.data?.meta || teamsRes.data || [];
406
+ const orgTeamMap = new Map();
407
+ for (const t of orgTeams) {
408
+ orgTeamMap.set(String(t.id), t.name);
409
+ }
410
+ const invalidTeams = team_ids.filter(id => !orgTeamMap.has(id));
411
+ if (invalidTeams.length > 0) {
412
+ return toolError(`Team(s) not found in org: ${invalidTeams.join(', ')}`);
413
+ }
414
+ // Invite to teams (batched)
415
+ const teamsJoined = [];
416
+ const teamInviteResults = await batchProcess(team_ids, async (teamId) => {
417
+ const res = await api.post('/api/invites', {
418
+ resource_type: 'team',
419
+ resource_id_or_key: teamId,
420
+ emails: [email],
421
+ level,
422
+ });
423
+ return { teamId, res };
424
+ });
425
+ for (let i = 0; i < team_ids.length; i++) {
426
+ const r = teamInviteResults[i];
427
+ teamsJoined.push({
428
+ team_id: team_ids[i],
429
+ team_name: orgTeamMap.get(team_ids[i]) || 'unknown',
430
+ role: role || 'editor',
431
+ status: r.status === 'fulfilled' ? 'invited' : 'failed',
432
+ });
433
+ }
434
+ // Share files (batched, viewer access)
435
+ const filesShared = [];
436
+ if (share_files && share_files.length > 0) {
437
+ const fileShareResults = await batchProcess(share_files, async (fileKey) => {
438
+ await api.post('/api/invites', {
439
+ resource_type: 'file',
440
+ resource_id_or_key: fileKey,
441
+ emails: [email],
442
+ level: 100, // viewer
443
+ });
444
+ return fileKey;
445
+ });
446
+ for (let i = 0; i < share_files.length; i++) {
447
+ const r = fileShareResults[i];
448
+ filesShared.push({
449
+ file_key: share_files[i],
450
+ role: 'viewer',
451
+ status: r.status === 'fulfilled' ? 'shared' : 'failed',
452
+ });
453
+ }
454
+ }
455
+ // Seat change
456
+ let seatChange = { status: 'skipped', note: 'No seat_type specified.' };
457
+ if (seat_type && confirm) {
458
+ try {
459
+ const userRes = await api.get(`/api/v2/orgs/${orgId}/org_users`, {
460
+ params: { search_query: email },
461
+ });
462
+ const rawU = userRes.data?.meta?.users || userRes.data?.meta || userRes.data || [];
463
+ const users = Array.isArray(rawU) ? rawU : [];
464
+ const found = users.find((m) => m.user?.email === email);
465
+ if (found) {
466
+ await api.put(`/api/orgs/${orgId}/org_users`, {
467
+ org_user_ids: [String(found.id)],
468
+ paid_statuses: PAID_STATUSES[seat_type],
469
+ entry_point: 'members_tab',
470
+ seat_increase_authorized: 'true',
471
+ seat_swap_intended: 'false',
472
+ latest_ou_update: found.updated_at,
473
+ showing_billing_groups: 'true',
474
+ }, {
475
+ 'axios-retry': { retries: 0 },
476
+ });
477
+ seatChange = { status: 'changed', note: `Set to ${seat_type}.` };
478
+ }
479
+ else {
480
+ seatChange = { status: 'skipped', note: 'User not yet in org (invite pending). Seat change will need to be applied after they accept.' };
481
+ }
482
+ }
483
+ catch (e) {
484
+ seatChange = { status: 'failed', note: `Seat change failed: ${e.response?.status || e.message}` };
485
+ }
486
+ }
487
+ else if (seat_type && !confirm) {
488
+ seatChange = { status: 'skipped', note: `Set confirm: true to apply ${seat_type} seat change.` };
489
+ }
490
+ const result = {
491
+ user_email: email,
492
+ setup_results: {
493
+ teams_joined: teamsJoined,
494
+ files_shared: filesShared,
495
+ seat_change: seatChange,
496
+ },
497
+ next_steps: [
498
+ 'User will receive invite emails for each team.',
499
+ 'Team-level projects are automatically accessible once they accept.',
500
+ ],
501
+ };
502
+ return toolResult(JSON.stringify(result, null, 2));
503
+ }
504
+ catch (e) {
505
+ return toolError(`Failed to onboard user: ${e.response?.status || e.message}`);
506
+ }
507
+ });
508
+ },
509
+ });
510
+ // -- quarterly_design_ops_report --
511
+ defineTool({
512
+ toolset: 'compound',
513
+ auth: 'cookie',
514
+ register(server, config) {
515
+ server.registerTool('quarterly_design_ops_report', {
516
+ description: 'Org-wide design ops snapshot: seat utilization, team activity, billing, and library adoption over a given period.',
517
+ inputSchema: {
518
+ org_id: figmaId.optional().describe('Org ID override (defaults to current workspace)'),
519
+ days: z.number().min(1).max(365).optional().default(90).describe('Lookback period in days (default: 90)'),
520
+ },
521
+ }, async ({ org_id, days: rawDays }) => {
522
+ try {
523
+ let orgId;
524
+ try {
525
+ orgId = requireOrgId(config, org_id);
526
+ }
527
+ catch (e) {
528
+ return toolError(e.message);
529
+ }
530
+ const days = rawDays ?? 90;
531
+ const api = internalClient(config);
532
+ const now = new Date();
533
+ const periodStart = new Date(now.getTime() - days * 86400000);
534
+ // Phase 1: Parallel fetches
535
+ const [teamsResult, seatsResult, billingResult, upcomingResult, ratesResult] = await Promise.allSettled([
536
+ api.get(`/api/orgs/${orgId}/teams`, {
537
+ params: { include_member_count: true, include_project_count: true },
538
+ }),
539
+ api.get(`/api/orgs/${orgId}/org_users/filter_counts`),
540
+ api.get(`/api/orgs/${orgId}/billing_data`),
541
+ api.get(`/api/plans/organization/${orgId}/invoices/upcoming`),
542
+ api.get(`/api/pricing/contract_rates`, {
543
+ params: { plan_parent_id: orgId, plan_type: 'organization' },
544
+ }),
545
+ ]);
546
+ // Paginate all members (cursor-based, max 500)
547
+ const allMembers = [];
548
+ let cursor;
549
+ const maxPages = 20;
550
+ for (let page = 0; page < maxPages; page++) {
551
+ try {
552
+ const params = { page_size: 25 };
553
+ if (cursor)
554
+ params.cursor = cursor;
555
+ const res = await api.get(`/api/v2/orgs/${orgId}/org_users`, { params });
556
+ const meta = res.data?.meta || {};
557
+ const batch = meta.users || [];
558
+ if (!Array.isArray(batch) || batch.length === 0)
559
+ break;
560
+ allMembers.push(...batch);
561
+ cursor = Array.isArray(meta.cursor) ? meta.cursor[0] : meta.cursor;
562
+ if (!cursor || batch.length < 25)
563
+ break;
564
+ }
565
+ catch {
566
+ break;
567
+ }
568
+ }
569
+ // Process teams
570
+ const teamsRaw = teamsResult.status === 'fulfilled'
571
+ ? (teamsResult.value.data?.meta || teamsResult.value.data || [])
572
+ : [];
573
+ const teamsArray = Array.isArray(teamsRaw) ? teamsRaw : [];
574
+ const teams = teamsArray.map((t) => ({
575
+ team_name: t.name,
576
+ members: t.member_count || 0,
577
+ projects: t.project_count || 0,
578
+ }));
579
+ // Seat utilization from member list
580
+ const paidKeys = new Set(['expert', 'developer', 'collaborator']);
581
+ const cutoffMs = now.getTime() - days * 86400000;
582
+ let totalPaid = 0;
583
+ let inactivePaid = 0;
584
+ const seatCounts = {};
585
+ for (const m of allMembers) {
586
+ const seatKey = m.active_seat_type?.key;
587
+ if (seatKey && paidKeys.has(seatKey)) {
588
+ totalPaid++;
589
+ seatCounts[seatKey] = (seatCounts[seatKey] || 0) + 1;
590
+ const lastSeen = m.last_seen ? new Date(m.last_seen).getTime() : 0;
591
+ if (!m.last_seen || lastSeen < cutoffMs) {
592
+ inactivePaid++;
593
+ }
594
+ }
595
+ }
596
+ const activePaid = totalPaid - inactivePaid;
597
+ const utilizationRate = totalPaid > 0
598
+ ? Number(((activePaid / totalPaid) * 100).toFixed(1))
599
+ : 0;
600
+ // Billing
601
+ let billing = null;
602
+ const errors = [];
603
+ if (ratesResult.status === 'fulfilled') {
604
+ const prices = ratesResult.value.data?.meta?.product_prices || [];
605
+ const seatProducts = new Set(['expert', 'developer', 'collaborator']);
606
+ let monthlySpendCents = 0;
607
+ for (const p of prices) {
608
+ if (seatProducts.has(p.billable_product_key)) {
609
+ const count = seatCounts[p.billable_product_key] || 0;
610
+ monthlySpendCents += count * (p.amount || 0);
611
+ }
612
+ }
613
+ const monthlySpendDollars = Number((monthlySpendCents / 100).toFixed(2));
614
+ const costPerActiveUser = activePaid > 0
615
+ ? Number((monthlySpendDollars / activePaid).toFixed(2))
616
+ : 0;
617
+ billing = {
618
+ monthly_spend_dollars: monthlySpendDollars,
619
+ cost_per_active_user: costPerActiveUser,
620
+ };
621
+ if (upcomingResult.status === 'fulfilled') {
622
+ const upcoming = upcomingResult.value.data;
623
+ billing.upcoming_invoice_date = upcoming?.date || upcoming?.period_end || null;
624
+ billing.upcoming_amount = upcoming?.amount_due ?? upcoming?.total ?? null;
625
+ }
626
+ }
627
+ else {
628
+ errors.push('billing: rates unavailable (may require admin)');
629
+ }
630
+ if (billingResult.status === 'fulfilled' && billing) {
631
+ const rawBilling = billingResult.value.data?.meta || billingResult.value.data;
632
+ if (rawBilling) {
633
+ const { shipping_address, ...safeBilling } = rawBilling;
634
+ billing.plan_name = safeBilling.plan_name || safeBilling.name || null;
635
+ }
636
+ }
637
+ else if (billingResult.status === 'rejected') {
638
+ errors.push('billing_data: 403 (admin required)');
639
+ }
640
+ // Phase 2: Library adoption
641
+ const libraryAdoption = [];
642
+ try {
643
+ const libRes = await api.get('/api/design_systems/libraries', {
644
+ params: { org_id: orgId },
645
+ });
646
+ const libraries = libRes.data?.libraries || libRes.data?.meta?.libraries || [];
647
+ const cappedLibraries = libraries.slice(0, 5);
648
+ const endTs = Math.floor(now.getTime() / 1000);
649
+ const startTs = Math.floor(periodStart.getTime() / 1000);
650
+ const libResults = await batchProcess(cappedLibraries, async (lib) => {
651
+ const res = await api.get(`/api/dsa/library/${lib.file_key || lib.key}/team_usage`, {
652
+ params: { start_ts: startTs, end_ts: endTs },
653
+ });
654
+ return { lib, data: res.data };
655
+ });
656
+ for (const r of libResults) {
657
+ if (r.status === 'fulfilled') {
658
+ const { lib, data } = r.value;
659
+ const teamUsages = data?.rows || data?.teams || [];
660
+ let totalInsertions = 0;
661
+ for (const tu of teamUsages) {
662
+ totalInsertions += tu.insertions || tu.num_insertions || 0;
663
+ }
664
+ libraryAdoption.push({
665
+ library_name: lib.name || lib.file_name || 'unknown',
666
+ file_key: lib.file_key || lib.key,
667
+ insertions: totalInsertions,
668
+ });
669
+ }
670
+ }
671
+ }
672
+ catch {
673
+ errors.push('libraries: failed to fetch (may be 404)');
674
+ }
675
+ // Highlights
676
+ const highlights = [];
677
+ highlights.push(`${utilizationRate}% seat utilization (${activePaid} active of ${totalPaid} paid).`);
678
+ if (inactivePaid > 0) {
679
+ highlights.push(`${inactivePaid} paid seat(s) inactive for ${days}+ days.`);
680
+ }
681
+ if (teams.length > 0) {
682
+ highlights.push(`${teams.length} team(s), ${allMembers.length} total member(s).`);
683
+ }
684
+ if (libraryAdoption.length > 0) {
685
+ const totalInsertions = libraryAdoption.reduce((sum, l) => sum + l.insertions, 0);
686
+ highlights.push(`${totalInsertions} library component insertions across ${libraryAdoption.length} library/libraries.`);
687
+ }
688
+ const result = {
689
+ period: {
690
+ start: periodStart.toISOString().split('T')[0],
691
+ end: now.toISOString().split('T')[0],
692
+ days,
693
+ },
694
+ org_overview: {
695
+ total_teams: teams.length,
696
+ total_members: allMembers.length,
697
+ total_paid_seats: totalPaid,
698
+ },
699
+ seat_utilization: {
700
+ active_paid: activePaid,
701
+ inactive_paid: inactivePaid,
702
+ utilization_rate: utilizationRate,
703
+ },
704
+ teams,
705
+ billing,
706
+ library_adoption: libraryAdoption,
707
+ highlights,
708
+ };
709
+ if (errors.length > 0)
710
+ result.errors = errors;
711
+ return toolResult(JSON.stringify(result, null, 2));
712
+ }
713
+ catch (e) {
714
+ return toolError(`Failed to generate design ops report: ${e.response?.status || e.message}`);
715
+ }
716
+ });
717
+ },
718
+ });
719
+ //# sourceMappingURL=compound-manager.js.map
@@ -331,4 +331,434 @@ defineTool({
331
331
  });
332
332
  },
333
333
  });
334
+ // -- seat_optimization --
335
+ const SEAT_KEY_MAP = {
336
+ expert: 'full',
337
+ developer: 'dev',
338
+ collaborator: 'collab',
339
+ };
340
+ defineTool({
341
+ toolset: 'compound',
342
+ auth: 'cookie',
343
+ register(server, config) {
344
+ server.registerTool('seat_optimization', {
345
+ description: 'Identify inactive paid seats and calculate potential savings. Fetches members, seat counts, and pricing to find optimization opportunities.',
346
+ inputSchema: {
347
+ org_id: figmaId.optional().describe('Org ID override (defaults to current workspace)'),
348
+ days_inactive: z.number().min(1).max(365).optional().default(90).describe('Days without activity to flag as inactive (default: 90)'),
349
+ include_cost: z.boolean().optional().default(true).describe('Include cost analysis from contract rates (default: true)'),
350
+ },
351
+ }, async ({ org_id, days_inactive: rawDaysInactive, include_cost: rawIncludeCost }) => {
352
+ try {
353
+ const days_inactive = rawDaysInactive ?? 90;
354
+ const include_cost = rawIncludeCost ?? true;
355
+ let orgId;
356
+ try {
357
+ orgId = requireOrgId(config, org_id);
358
+ }
359
+ catch (e) {
360
+ return toolError(e.message);
361
+ }
362
+ const api = internalClient(config);
363
+ const cutoff = Date.now() - days_inactive * 86400000;
364
+ const paidKeys = new Set(['expert', 'developer', 'collaborator']);
365
+ // Paginate org members (cursor-based, max 500)
366
+ const allMembers = [];
367
+ const MAX_PAGES = 20;
368
+ let cursor;
369
+ for (let page = 0; page < MAX_PAGES; page++) {
370
+ const params = { page_size: 25 };
371
+ if (cursor)
372
+ params.cursor = cursor;
373
+ const res = await api.get(`/api/v2/orgs/${orgId}/org_users`, { params });
374
+ const meta = res.data?.meta || {};
375
+ const members = meta.users || [];
376
+ if (!Array.isArray(members) || members.length === 0)
377
+ break;
378
+ allMembers.push(...members);
379
+ cursor = Array.isArray(meta.cursor) ? meta.cursor[0] : meta.cursor;
380
+ if (!cursor || members.length < 25)
381
+ break;
382
+ }
383
+ // Fetch seat breakdown and optionally contract rates in parallel
384
+ const parallelCalls = [
385
+ api.get(`/api/orgs/${orgId}/org_users/filter_counts`),
386
+ ];
387
+ if (include_cost) {
388
+ parallelCalls.push(api.get('/api/pricing/contract_rates', {
389
+ params: { plan_parent_id: orgId, plan_type: 'organization' },
390
+ }));
391
+ }
392
+ const [seatsResult, ratesResult] = await Promise.allSettled(parallelCalls);
393
+ const seats = seatsResult.status === 'fulfilled' ? (seatsResult.value.data?.meta || seatsResult.value.data) : null;
394
+ // Build cost lookup: seat key -> monthly cents
395
+ const costMap = {};
396
+ if (include_cost && ratesResult?.status === 'fulfilled') {
397
+ const prices = ratesResult.value.data?.meta?.product_prices || [];
398
+ for (const p of prices) {
399
+ if (paidKeys.has(p.billable_product_key)) {
400
+ costMap[p.billable_product_key] = p.amount;
401
+ }
402
+ }
403
+ }
404
+ // Filter inactive paid members
405
+ const inactiveUsers = [];
406
+ let totalPaid = 0;
407
+ for (const m of allMembers) {
408
+ const seatKey = m.active_seat_type?.key;
409
+ if (!seatKey || !paidKeys.has(seatKey))
410
+ continue;
411
+ totalPaid++;
412
+ const lastSeen = m.last_seen;
413
+ const lastSeenMs = lastSeen ? new Date(lastSeen).getTime() : 0;
414
+ const isInactive = !lastSeen || (lastSeenMs > 0 && lastSeenMs < cutoff) || isNaN(lastSeenMs);
415
+ if (isInactive) {
416
+ inactiveUsers.push({
417
+ org_user_id: String(m.id),
418
+ user_id: m.user_id,
419
+ email: m.user?.email,
420
+ name: m.user?.handle,
421
+ seat_type: SEAT_KEY_MAP[seatKey] || seatKey,
422
+ seat_key: seatKey,
423
+ last_active: lastSeen || null,
424
+ monthly_cost_cents: costMap[seatKey] || null,
425
+ });
426
+ }
427
+ }
428
+ const monthlyWasteCents = inactiveUsers.reduce((sum, u) => sum + (u.monthly_cost_cents || 0), 0);
429
+ const recommendations = [];
430
+ if (inactiveUsers.length > 0) {
431
+ recommendations.push(`${inactiveUsers.length} paid seat(s) inactive for ${days_inactive}+ days. Review for downgrade to viewer.`);
432
+ }
433
+ const neverActive = inactiveUsers.filter((u) => !u.last_active);
434
+ if (neverActive.length > 0) {
435
+ recommendations.push(`${neverActive.length} paid user(s) have never been active. Likely unused invites.`);
436
+ }
437
+ if (monthlyWasteCents > 0) {
438
+ recommendations.push(`Potential monthly savings: $${(monthlyWasteCents / 100).toFixed(2)} ($${((monthlyWasteCents * 12) / 100).toFixed(2)}/yr).`);
439
+ }
440
+ const result = {
441
+ summary: {
442
+ total_paid: totalPaid,
443
+ inactive_paid: inactiveUsers.length,
444
+ monthly_waste_cents: monthlyWasteCents,
445
+ annual_savings_cents: monthlyWasteCents * 12,
446
+ },
447
+ seat_breakdown: seats,
448
+ inactive_users: inactiveUsers,
449
+ recommendations,
450
+ };
451
+ return toolResult(JSON.stringify(result, null, 2));
452
+ }
453
+ catch (e) {
454
+ return toolError(`Failed to analyze seat optimization: ${e.response?.status || e.message}`);
455
+ }
456
+ });
457
+ },
458
+ });
459
+ // -- permission_audit --
460
+ defineTool({
461
+ toolset: 'compound',
462
+ auth: 'cookie',
463
+ register(server, config) {
464
+ server.registerTool('permission_audit', {
465
+ description: 'Audit permissions across a team or project. Scans files for external editors, open link access, and elevated individual permissions.',
466
+ inputSchema: {
467
+ scope_type: z.enum(['project', 'team']).describe('Scope to audit'),
468
+ scope_id: figmaId.describe('Team ID or project ID'),
469
+ flag_external: z.boolean().optional().default(true).describe('Flag external users (default: true)'),
470
+ org_id: figmaId.optional().describe('Org ID override (defaults to current workspace)'),
471
+ },
472
+ }, async ({ scope_type, scope_id, flag_external: rawFlagExternal, org_id }) => {
473
+ try {
474
+ const flag_external = rawFlagExternal ?? true;
475
+ const api = internalClient(config);
476
+ // Resolve org for domain lookup
477
+ let orgId;
478
+ let domainCheckSkipped = false;
479
+ if (flag_external) {
480
+ try {
481
+ orgId = requireOrgId(config, org_id);
482
+ }
483
+ catch {
484
+ domainCheckSkipped = true;
485
+ }
486
+ }
487
+ // Fetch org verified domains for external detection
488
+ let verifiedDomains = new Set();
489
+ if (flag_external && orgId) {
490
+ try {
491
+ const domRes = await api.get(`/api/orgs/${orgId}/domains`);
492
+ const domains = domRes.data?.meta || [];
493
+ if (Array.isArray(domains)) {
494
+ for (const d of domains) {
495
+ if (d.domain)
496
+ verifiedDomains.add(d.domain.toLowerCase());
497
+ }
498
+ }
499
+ }
500
+ catch { /* domain lookup optional, continue without */ }
501
+ }
502
+ // Collect file keys to scan
503
+ let fileKeys = [];
504
+ if (scope_type === 'team') {
505
+ // Fetch team projects, cap at 10
506
+ const projectsRes = await api.get(`/api/teams/${scope_id}/folders`);
507
+ const rows = projectsRes.data?.meta?.folder_rows || projectsRes.data || [];
508
+ const projects = (Array.isArray(rows) ? rows : []).slice(0, 10);
509
+ for (const proj of projects) {
510
+ if (fileKeys.length >= 25)
511
+ break;
512
+ try {
513
+ const filesRes = await api.get(`/api/folders/${proj.id}/paginated_files`, {
514
+ params: { folderId: String(proj.id), page_size: 25, sort_column: 'touched_at', sort_order: 'desc', file_type: '' },
515
+ });
516
+ const meta = filesRes.data?.meta || filesRes.data;
517
+ const files = meta?.files || meta || [];
518
+ for (const f of (Array.isArray(files) ? files : [])) {
519
+ if (fileKeys.length >= 25)
520
+ break;
521
+ fileKeys.push({ key: f.key, name: f.name });
522
+ }
523
+ }
524
+ catch { /* skip inaccessible projects */ }
525
+ }
526
+ }
527
+ else {
528
+ // Project scope
529
+ const filesRes = await api.get(`/api/folders/${scope_id}/paginated_files`, {
530
+ params: { folderId: scope_id, page_size: 25, sort_column: 'touched_at', sort_order: 'desc', file_type: '' },
531
+ });
532
+ const meta = filesRes.data?.meta || filesRes.data;
533
+ const files = meta?.files || meta || [];
534
+ for (const f of (Array.isArray(files) ? files : [])) {
535
+ if (fileKeys.length >= 25)
536
+ break;
537
+ fileKeys.push({ key: f.key, name: f.name });
538
+ }
539
+ }
540
+ // Fetch permissions and file metadata in parallel, batched 5 at a time
541
+ const allUsers = new Map();
542
+ const flags = [];
543
+ let filesScanned = 0;
544
+ for (let i = 0; i < fileKeys.length; i += 5) {
545
+ const batch = fileKeys.slice(i, i + 5);
546
+ const results = await Promise.allSettled(batch.map(async (file) => {
547
+ const [rolesRes, fileMetaRes] = await Promise.allSettled([
548
+ api.get(`/api/roles/file/${file.key}`),
549
+ api.get(`/api/files/${file.key}`),
550
+ ]);
551
+ const roles = rolesRes.status === 'fulfilled'
552
+ ? (Array.isArray(rolesRes.value.data?.meta) ? rolesRes.value.data.meta : [])
553
+ : [];
554
+ const fileMeta = fileMetaRes.status === 'fulfilled'
555
+ ? (fileMetaRes.value.data?.meta || fileMetaRes.value.data || {})
556
+ : {};
557
+ return { file, roles, fileMeta };
558
+ }));
559
+ for (const r of results) {
560
+ if (r.status === 'rejected')
561
+ continue;
562
+ filesScanned++;
563
+ const { file, roles, fileMeta } = r.value;
564
+ // Check link access
565
+ const linkAccess = fileMeta.link_access;
566
+ if (linkAccess === 'edit' || linkAccess === 'org_edit') {
567
+ flags.push({
568
+ severity: 'high',
569
+ type: 'open_link_access',
570
+ details: `${file.name} (${file.key}) has link_access="${linkAccess}"`,
571
+ });
572
+ }
573
+ // Process roles
574
+ for (const role of roles) {
575
+ const email = role.user?.email || role.pending_email;
576
+ const userId = role.user_id ? String(role.user_id) : email;
577
+ const level = role.level;
578
+ const levelName = level >= 999 ? 'owner' : level >= 300 ? 'editor' : 'viewer';
579
+ if (userId && !allUsers.has(userId)) {
580
+ allUsers.set(userId, {
581
+ user_id: userId,
582
+ email,
583
+ name: role.user?.handle,
584
+ files_accessed: [],
585
+ });
586
+ }
587
+ if (userId) {
588
+ const user = allUsers.get(userId);
589
+ user.files_accessed.push({
590
+ file_key: file.key,
591
+ file_name: file.name,
592
+ role: levelName,
593
+ });
594
+ }
595
+ // External editor detection
596
+ if (flag_external && email && verifiedDomains.size > 0) {
597
+ const domain = email.split('@')[1]?.toLowerCase();
598
+ if (domain && !verifiedDomains.has(domain) && (levelName === 'editor' || levelName === 'owner')) {
599
+ flags.push({
600
+ severity: 'high',
601
+ type: 'external_editor',
602
+ details: `${email} (external) has ${levelName} access to ${file.name} (${file.key})`,
603
+ });
604
+ }
605
+ }
606
+ }
607
+ }
608
+ }
609
+ if (domainCheckSkipped) {
610
+ flags.push({
611
+ severity: 'info',
612
+ type: 'domain_check_skipped',
613
+ details: 'Could not resolve org ID; external user detection was skipped. Provide org_id or set FIGMA_ORG_ID.',
614
+ });
615
+ }
616
+ const result = {
617
+ scope: { type: scope_type, id: scope_id },
618
+ summary: {
619
+ unique_users: allUsers.size,
620
+ files_scanned: filesScanned,
621
+ total_files: fileKeys.length,
622
+ flags_found: flags.length,
623
+ },
624
+ users: Array.from(allUsers.values()),
625
+ flags,
626
+ };
627
+ return toolResult(JSON.stringify(result, null, 2));
628
+ }
629
+ catch (e) {
630
+ return toolError(`Failed to audit permissions: ${e.response?.status || e.message}`);
631
+ }
632
+ });
633
+ },
634
+ });
635
+ // -- branch_cleanup --
636
+ defineTool({
637
+ toolset: 'compound',
638
+ auth: 'either',
639
+ mutates: true,
640
+ destructive: true,
641
+ register(server, config) {
642
+ server.registerTool('branch_cleanup', {
643
+ description: 'Find stale branches across a project and optionally archive them. Defaults to dry run.',
644
+ inputSchema: {
645
+ project_id: figmaId.describe('Project ID'),
646
+ days_stale: z.number().min(1).max(365).optional().default(60).describe('Days since last modification to flag as stale (default: 60)'),
647
+ dry_run: z.boolean().optional().default(true).describe('Preview only, no archiving (default: true)'),
648
+ },
649
+ }, async ({ project_id, days_stale: rawDaysStale, dry_run: rawDryRun }) => {
650
+ try {
651
+ const days_stale = rawDaysStale ?? 60;
652
+ const dry_run = rawDryRun ?? true;
653
+ if (!dry_run && !hasCookie(config)) {
654
+ return toolError('Cookie auth required to archive branches. Run with dry_run=true to preview, or configure cookie auth.');
655
+ }
656
+ // Fetch project files
657
+ const MAX_FILES = 20;
658
+ let files;
659
+ if (hasPat(config)) {
660
+ const res = await publicClient(config).get(`/v1/projects/${project_id}/files`);
661
+ files = res.data?.files || [];
662
+ }
663
+ else {
664
+ const res = await internalClient(config).get(`/api/folders/${project_id}/paginated_files`, { params: { folderId: project_id, sort_column: 'touched_at', sort_order: 'desc', page_size: MAX_FILES, file_type: '' } });
665
+ const meta = res.data?.meta || res.data;
666
+ files = meta?.files || meta || [];
667
+ }
668
+ // Cap at 20 files
669
+ const capped = files.length > 20;
670
+ files = files.slice(0, 20);
671
+ // Fetch branch data for each file in parallel
672
+ const cutoff = Date.now() - days_stale * 86400000;
673
+ const staleBranches = [];
674
+ const activeBranches = [];
675
+ let filesScanned = 0;
676
+ let totalBranches = 0;
677
+ const branchResults = await Promise.allSettled(files.map(async (file) => {
678
+ let branches;
679
+ if (hasPat(config)) {
680
+ const res = await publicClient(config).get(`/v1/files/${file.key}`, {
681
+ params: { branch_data: 'true', depth: '0' },
682
+ });
683
+ branches = res.data?.branches || [];
684
+ }
685
+ else {
686
+ const res = await internalClient(config).get(`/api/files/${file.key}`);
687
+ const f = res.data?.meta || res.data;
688
+ branches = f.branches || [];
689
+ }
690
+ return { file, branches };
691
+ }));
692
+ for (const r of branchResults) {
693
+ if (r.status === 'rejected')
694
+ continue;
695
+ filesScanned++;
696
+ const { file, branches } = r.value;
697
+ for (const branch of branches) {
698
+ totalBranches++;
699
+ const lastModified = branch.last_modified;
700
+ const lastModifiedMs = lastModified ? new Date(lastModified).getTime() : 0;
701
+ const isStale = !lastModified || (lastModifiedMs > 0 && lastModifiedMs < cutoff) || isNaN(lastModifiedMs);
702
+ const entry = {
703
+ branch_key: branch.key,
704
+ branch_name: branch.name,
705
+ parent_file_key: file.key,
706
+ parent_file_name: file.name,
707
+ last_modified: lastModified || null,
708
+ };
709
+ if (isStale) {
710
+ staleBranches.push(entry);
711
+ }
712
+ else {
713
+ activeBranches.push(entry);
714
+ }
715
+ }
716
+ }
717
+ const MAX_ARCHIVE_BATCH = 25;
718
+ let archived = false;
719
+ if (!dry_run && staleBranches.length > 0) {
720
+ if (staleBranches.length > MAX_ARCHIVE_BATCH) {
721
+ return toolError(`${staleBranches.length} stale branches exceeds safety limit of ${MAX_ARCHIVE_BATCH}. ` +
722
+ `Run with dry_run=true to review, then archive in smaller batches using delete_branch.`);
723
+ }
724
+ await internalClient(config).delete('/api/files_batch', {
725
+ data: {
726
+ files: staleBranches.map(b => ({ key: b.branch_key })),
727
+ trashed: true,
728
+ },
729
+ });
730
+ archived = true;
731
+ }
732
+ const recommendations = [];
733
+ if (staleBranches.length > 0) {
734
+ recommendations.push(`${staleBranches.length} branch(es) stale for ${days_stale}+ days. ${dry_run ? 'Set dry_run=false to archive.' : 'Archived.'}`);
735
+ }
736
+ if (capped) {
737
+ recommendations.push(`Project has more than 20 files; only the first 20 were scanned.`);
738
+ }
739
+ if (staleBranches.length === 0 && activeBranches.length === 0) {
740
+ recommendations.push('No branches found in scanned files.');
741
+ }
742
+ const result = {
743
+ project_id,
744
+ summary: {
745
+ files_scanned: filesScanned,
746
+ total_branches: totalBranches,
747
+ stale: staleBranches.length,
748
+ active: activeBranches.length,
749
+ },
750
+ stale_branches: staleBranches,
751
+ active_branches: activeBranches,
752
+ dry_run,
753
+ archived,
754
+ recommendations,
755
+ };
756
+ return toolResult(JSON.stringify(result, null, 2));
757
+ }
758
+ catch (e) {
759
+ return toolError(`Failed to cleanup branches: ${e.response?.status || e.message}`);
760
+ }
761
+ });
762
+ },
763
+ });
334
764
  //# sourceMappingURL=compound.js.map
package/dist/tools/org.js CHANGED
@@ -332,14 +332,15 @@ defineTool({
332
332
  if (search_query)
333
333
  params.search_query = search_query;
334
334
  const res = await internalClient(config).get(`/api/v2/orgs/${orgId}/org_users`, { params });
335
- const members = (res.data?.meta || res.data || []).map((m) => ({
335
+ const users = res.data?.meta?.users || res.data?.meta || res.data || [];
336
+ const members = (Array.isArray(users) ? users : []).map((m) => ({
336
337
  org_user_id: String(m.id),
337
338
  user_id: m.user_id,
338
339
  email: m.user?.email,
339
340
  name: m.user?.handle,
340
341
  permission: m.permission,
341
342
  seat_type: m.active_seat_type?.key || null,
342
- last_active: m.last_active,
343
+ last_active: m.last_seen || null,
343
344
  }));
344
345
  return toolResult(JSON.stringify(members, null, 2));
345
346
  }
@@ -436,7 +437,8 @@ defineTool({
436
437
  const res = await internalClient(config).get(`/api/v2/orgs/${orgId}/org_users`, {
437
438
  params: user_id.includes('@') ? { search_query: user_id } : {},
438
439
  });
439
- const members = res.data?.meta || res.data || [];
440
+ const users = res.data?.meta?.users || res.data?.meta || res.data || [];
441
+ const members = Array.isArray(users) ? users : [];
440
442
  const member = members.find((m) => user_id.includes('@')
441
443
  ? m.user?.email === user_id
442
444
  : String(m.user_id) === String(user_id));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "figmanage",
3
3
  "mcpName": "io.github.dannykeane/figmanage",
4
- "version": "0.2.1",
4
+ "version": "0.3.0",
5
5
  "description": "MCP server for managing your Figma workspace from the terminal.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",