figmanage 0.2.1 → 1.0.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.
@@ -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": "1.0.0",
5
5
  "description": "MCP server for managing your Figma workspace from the terminal.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -43,6 +43,7 @@
43
43
  "@modelcontextprotocol/sdk": "^1.25.0",
44
44
  "axios": "^1.7.0",
45
45
  "axios-retry": "^4.4.0",
46
+ "commander": "^14.0.3",
46
47
  "zod": "^3.23.0"
47
48
  },
48
49
  "devDependencies": {