figma-cli 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d4174adfd2513d05fd36e0392d1bc2be14d6e495b26131682626cd9082046a7b
4
+ data.tar.gz: dfb6f841bc0f27b7a1bf9603de28b059182ff9add04ef06cf99bf857b6928bb3
5
+ SHA512:
6
+ metadata.gz: 35e687e36c9398fffa59133b460944a4af0904d96b16a711a2f2f215c0e114d5e281e3572da1c61396b61ac4c8461e95dea918ac598052ec799ca643cd6676c8
7
+ data.tar.gz: '028e35e28a4dbed03acc3a0ba39ddda5312c48210be085d2dcf50045a99c4f8dcf516d74e281d8d4176e207a6f8c22f0a0fb7796cac4d386f27114b391b60358'
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,22 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.1
3
+ NewCops: enable
4
+ SuggestExtensions: false
5
+
6
+ Style/StringLiterals:
7
+ EnforcedStyle: double_quotes
8
+
9
+ Style/StringLiteralsInInterpolation:
10
+ EnforcedStyle: double_quotes
11
+
12
+ Style/Documentation:
13
+ Enabled: false
14
+
15
+ Metrics/BlockLength:
16
+ Exclude:
17
+ - "spec/**/*"
18
+ - "*.gemspec"
19
+
20
+ Metrics/MethodLength:
21
+ Exclude:
22
+ - "lib/figma/cli/extractors/**/*"
data/CLAUDE.md ADDED
@@ -0,0 +1,75 @@
1
+ # figma-cli
2
+
3
+ A Ruby gem CLI that extracts design tokens from Figma files via the REST API and writes them as compact YAML files to disk. Optimized for AI agent consumption (token-efficient alternative to Figma MCP).
4
+
5
+ ## Project docs
6
+
7
+ Full design, API research, and session handoff notes are in Obsidian:
8
+ - Path: `/Users/thommeas/Library/Mobile Documents/iCloud~md~obsidian/Documents/My vault/Claude/figma-cli/`
9
+ - `design.md` — full design doc (architecture, commands, output format, gem structure, implementation steps)
10
+ - `figma-api-research.md` — Figma REST API endpoints, JSON structures, rate limits, gotchas
11
+ - `handoff.md` — session context and decisions made
12
+
13
+ ## Stack
14
+
15
+ - Ruby gem (module: `Figma::Cli`)
16
+ - Thor (CLI framework)
17
+ - Faraday + faraday-retry (HTTP client with rate-limit retries)
18
+ - RSpec + WebMock (testing)
19
+ - RuboCop (linting)
20
+
21
+ ## Architecture
22
+
23
+ ```
24
+ lib/figma/cli/
25
+ version.rb # VERSION constant
26
+ errors.rb # Error, AuthError, NotFoundError, RateLimitError, ApiError
27
+ client.rb # Figma REST API client (Faraday)
28
+ command.rb # Thor CLI (version, extract commands)
29
+ extractors/
30
+ color_converter.rb # Figma 0-1 float → hex/rgb conversion
31
+ colors.rb # SOLID fill → {name, hex, rgb, opacity, style_id}
32
+ typography.rb # TEXT node style → {name, font_family, ...}
33
+ effects.rb # Shadows/blurs → {name, type, radius, ...}
34
+ spacing.rb # Auto-layout → {name, padding_*, item_spacing, ...}
35
+ parser.rb # Orchestrates URL→fetch→extract pipeline
36
+ writer.rb # Serializes parser output → 5 YAML files on disk
37
+ ```
38
+
39
+ ### Parser (`Figma::Cli::Parser`)
40
+ - `Parser.new(client:).extract(url, node_id: nil)` → `{ metadata:, colors:, typography:, effects:, spacing: }`
41
+ - Parses Figma URLs (`/file/` and `/design/` formats), converts `?node-id=1-2` → `1:2`
42
+ - Styles pipeline: fetches styles → groups by type → batch-fetches nodes → dispatches extractors
43
+ - Spacing pipeline: full document fetch → recursive tree walk for `layoutMode` nodes
44
+ - `--node-id` scopes spacing extraction to a subtree
45
+
46
+ ### Writer (`Figma::Cli::Writer`)
47
+ - `Writer.write(data, output_dir: ".figma-cli")` → array of 5 written file paths
48
+ - Writes `metadata.yml` (flat), `colors.yml`, `typography.yml`, `effects.yml`, `spacing.yml` (wrapped arrays)
49
+ - YAML output: no `---` header, `line_width: -1`, empty categories → `colors: []`
50
+ - Creates output directory if missing, overwrites silently (idempotent)
51
+
52
+ ### Client (`Figma::Cli::Client`)
53
+ - Auth: `FIGMA_TOKEN` env var or explicit `token:` param
54
+ - Methods: `file(key)`, `file_meta(key)`, `styles(key)`, `nodes(key, ids:)`
55
+ - `nodes()` auto-batches at 50 IDs per request
56
+ - Faraday middleware handles 429 retries (max 3)
57
+
58
+ ## Commands
59
+
60
+ ```bash
61
+ bundle exec figma-cli version
62
+ bundle exec figma-cli extract <figma-url> [--node-id ID] [-o DIR]
63
+ ```
64
+
65
+ ## Development
66
+
67
+ ```bash
68
+ bundle install
69
+ bundle exec rspec # 93 specs
70
+ bundle exec rubocop # 0 offenses
71
+ ```
72
+
73
+ ## Implementation Status
74
+
75
+ All 8 steps complete. 126 specs, 0 RuboCop offenses.
data/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # figma-cli
2
+
3
+ A Ruby gem CLI that extracts design tokens from Figma files via the REST API and writes them as compact YAML files to disk. Optimized for AI agent consumption (token-efficient alternative to Figma MCP).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ gem install figma-cli
9
+ ```
10
+
11
+ Or add to your Gemfile:
12
+
13
+ ```ruby
14
+ gem "figma-cli"
15
+ ```
16
+
17
+ ## Setup
18
+
19
+ Set your Figma personal access token as an environment variable:
20
+
21
+ ```bash
22
+ export FIGMA_TOKEN=your-token-here
23
+ ```
24
+
25
+ You can generate a personal access token in [Figma account settings](https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens).
26
+
27
+ ## Usage
28
+
29
+ ```bash
30
+ # Extract tokens from a Figma file
31
+ figma-cli extract <figma-url>
32
+
33
+ # Scope to a specific node/frame
34
+ figma-cli extract <figma-url> --node-id 123:456
35
+
36
+ # Custom output directory (default: .figma-cli/)
37
+ figma-cli extract <figma-url> -o design-tokens/
38
+
39
+ # Show version
40
+ figma-cli version
41
+ ```
42
+
43
+ ## Output
44
+
45
+ Writes 5 YAML files to `.figma-cli/` (or custom directory via `-o`):
46
+
47
+ ```
48
+ .figma-cli/
49
+ metadata.yml
50
+ colors.yml
51
+ typography.yml
52
+ effects.yml
53
+ spacing.yml
54
+ ```
55
+
56
+ ### metadata.yml
57
+
58
+ ```yaml
59
+ file_name: "Design System"
60
+ file_key: "abc123"
61
+ last_modified: "2026-03-02T12:00:00Z"
62
+ extracted_at: "2026-03-02T15:30:00Z"
63
+ version: "v456"
64
+ ```
65
+
66
+ ### colors.yml
67
+
68
+ ```yaml
69
+ colors:
70
+ - name: "Primary/Blue"
71
+ hex: "#3366CC"
72
+ rgb: [51, 102, 204]
73
+ opacity: 1.0
74
+ style_id: "S:abc123"
75
+ ```
76
+
77
+ ### typography.yml
78
+
79
+ ```yaml
80
+ typography:
81
+ - name: "Heading/XL"
82
+ font_family: "Inter"
83
+ font_weight: 700
84
+ font_size: 32
85
+ line_height: 40
86
+ letter_spacing: 0
87
+ style_id: "S:ghi789"
88
+ ```
89
+
90
+ ### effects.yml
91
+
92
+ ```yaml
93
+ effects:
94
+ - name: "Shadow/Medium"
95
+ type: "DROP_SHADOW"
96
+ offset_x: 0
97
+ offset_y: 2
98
+ radius: 8
99
+ spread: 0
100
+ color_hex: "#0000001A"
101
+ style_id: "S:jkl012"
102
+ ```
103
+
104
+ ### spacing.yml
105
+
106
+ ```yaml
107
+ spacing:
108
+ - name: "Card/Container"
109
+ padding_top: 24
110
+ padding_right: 24
111
+ padding_bottom: 24
112
+ padding_left: 24
113
+ item_spacing: 16
114
+ layout_mode: "VERTICAL"
115
+ node_id: "123:456"
116
+ ```
117
+
118
+ Re-running the same command skips extraction if the Figma file version hasn't changed.
119
+
120
+ ## Development
121
+
122
+ ```bash
123
+ git clone https://github.com/Wethre/figma-cli.git
124
+ cd figma-cli
125
+ bundle install
126
+ bundle exec rspec # run tests
127
+ bundle exec rubocop # lint
128
+ ```
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/exe/figma-cli ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "figma/cli"
5
+
6
+ Figma::Cli::Command.start(ARGV)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+
6
+ module Figma
7
+ module Cli
8
+ class Client
9
+ BASE_URL = "https://api.figma.com"
10
+
11
+ BATCH_SIZE = 50
12
+
13
+ def initialize(token: ENV.fetch("FIGMA_TOKEN", nil))
14
+ raise AuthError, "Missing FIGMA_TOKEN. Set it via: export FIGMA_TOKEN=your-token" if token.nil? || token.empty?
15
+
16
+ @token = token
17
+ end
18
+
19
+ def file(file_key)
20
+ get("/v1/files/#{file_key}")
21
+ end
22
+
23
+ def file_meta(file_key)
24
+ get("/v1/files/#{file_key}", depth: 1)
25
+ end
26
+
27
+ def styles(file_key)
28
+ get("/v1/files/#{file_key}/styles")
29
+ end
30
+
31
+ def nodes(file_key, ids:)
32
+ ids.each_slice(BATCH_SIZE).with_object({}) do |batch, merged|
33
+ response = get("/v1/files/#{file_key}/nodes", ids: batch.join(","))
34
+ merged.merge!(response["nodes"])
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def connection
41
+ @connection ||= Faraday.new(url: BASE_URL) do |f|
42
+ f.headers["X-Figma-Token"] = @token
43
+ f.request :retry, max: 3, interval: 1, retry_statuses: [429]
44
+ f.response :json
45
+ end
46
+ end
47
+
48
+ def get(path, params = {})
49
+ response = connection.get(path, params)
50
+ handle_response(response)
51
+ end
52
+
53
+ def handle_response(response)
54
+ case response.status
55
+ when 200 then response.body
56
+ when 403 then raise AuthError, "Access denied (403). Check your FIGMA_TOKEN permissions."
57
+ when 404 then raise NotFoundError, "Resource not found (404). Check the file key or node IDs."
58
+ when 429 then raise RateLimitError, "Rate limited (429). Retries exhausted."
59
+ else raise ApiError, "Figma API error (#{response.status})"
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "yaml"
5
+
6
+ module Figma
7
+ module Cli
8
+ class Command < Thor
9
+ desc "version", "Print figma-cli version"
10
+ def version
11
+ puts "figma-cli #{VERSION}"
12
+ end
13
+
14
+ desc "extract URL", "Extract design tokens from a Figma file"
15
+ option :node_id, type: :string, desc: "Figma node ID to scope extraction"
16
+ option :output, aliases: "-o", type: :string, default: ".figma-cli", desc: "Output directory"
17
+ def extract(url) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
18
+ client = Client.new
19
+ parser = Parser.new(client: client)
20
+
21
+ # Check cache: skip if local version matches remote
22
+ version_info = parser.file_version(url)
23
+ metadata_path = File.join(options[:output], "metadata.yml")
24
+ if File.exist?(metadata_path)
25
+ local = YAML.safe_load_file(metadata_path)
26
+ if local && local["version"] == version_info[:version]
27
+ warn "Up to date (version #{version_info[:version]}), skipping."
28
+ return
29
+ end
30
+ end
31
+
32
+ data = parser.extract(url, node_id: options[:node_id])
33
+ paths = Writer.write(data, output_dir: options[:output])
34
+ warn "Wrote #{paths.size} files to #{options[:output]}/"
35
+ paths.each { |p| warn " #{p}" }
36
+ rescue Error => e
37
+ warn "Error: #{e.message}"
38
+ exit 1
39
+ end
40
+
41
+ def self.exit_on_failure?
42
+ true
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Figma
4
+ module Cli
5
+ class Error < StandardError; end
6
+ class AuthError < Error; end
7
+ class NotFoundError < Error; end
8
+ class RateLimitError < Error; end
9
+ class ApiError < Error; end
10
+ end
11
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Figma
4
+ module Cli
5
+ module Extractors
6
+ module ColorConverter
7
+ def self.to_hex(color)
8
+ r, g, b = to_rgb(color)
9
+ format("#%<r>02X%<g>02X%<b>02X", r: r, g: g, b: b)
10
+ end
11
+
12
+ def self.to_hex_alpha(color)
13
+ r, g, b = to_rgb(color)
14
+ a = (color["a"] * 255).round
15
+ format("#%<r>02X%<g>02X%<b>02X%<a>02X", r: r, g: g, b: b, a: a)
16
+ end
17
+
18
+ def self.to_rgb(color)
19
+ [(color["r"] * 255).round, (color["g"] * 255).round, (color["b"] * 255).round]
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Figma
4
+ module Cli
5
+ module Extractors
6
+ module Colors
7
+ def self.extract(document, style_name:, style_id:)
8
+ fills = document.fetch("fills", [])
9
+ fills.select { |f| f["type"] == "SOLID" }.map do |fill|
10
+ {
11
+ name: style_name,
12
+ hex: ColorConverter.to_hex(fill["color"]),
13
+ rgb: ColorConverter.to_rgb(fill["color"]),
14
+ opacity: fill.fetch("opacity", 1.0).to_f,
15
+ style_id: style_id
16
+ }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Figma
4
+ module Cli
5
+ module Extractors
6
+ module Effects
7
+ SHADOW_TYPES = %w[DROP_SHADOW INNER_SHADOW].freeze
8
+ BLUR_TYPES = %w[LAYER_BLUR BACKGROUND_BLUR].freeze
9
+
10
+ def self.extract(document, style_name:, style_id:)
11
+ effects = document.fetch("effects", [])
12
+ effects.select { |e| e["visible"] }.map do |effect|
13
+ if SHADOW_TYPES.include?(effect["type"])
14
+ extract_shadow(effect, style_name: style_name, style_id: style_id)
15
+ elsif BLUR_TYPES.include?(effect["type"])
16
+ extract_blur(effect, style_name: style_name, style_id: style_id)
17
+ end
18
+ end.compact
19
+ end
20
+
21
+ def self.extract_shadow(effect, style_name:, style_id:)
22
+ offset = effect.fetch("offset", {})
23
+ {
24
+ name: style_name,
25
+ type: effect["type"],
26
+ offset_x: offset.fetch("x", 0),
27
+ offset_y: offset.fetch("y", 0),
28
+ radius: effect.fetch("radius", 0),
29
+ spread: effect.fetch("spread", 0),
30
+ color_hex: ColorConverter.to_hex_alpha(effect["color"]),
31
+ style_id: style_id
32
+ }
33
+ end
34
+ private_class_method :extract_shadow
35
+
36
+ def self.extract_blur(effect, style_name:, style_id:)
37
+ {
38
+ name: style_name,
39
+ type: effect["type"],
40
+ radius: effect.fetch("radius", 0),
41
+ style_id: style_id
42
+ }
43
+ end
44
+ private_class_method :extract_blur
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Figma
4
+ module Cli
5
+ module Extractors
6
+ module Spacing
7
+ def self.extract(document, node_name:, node_id:)
8
+ return nil unless document["layoutMode"]
9
+
10
+ {
11
+ name: node_name,
12
+ padding_top: document.fetch("paddingTop", 0),
13
+ padding_right: document.fetch("paddingRight", 0),
14
+ padding_bottom: document.fetch("paddingBottom", 0),
15
+ padding_left: document.fetch("paddingLeft", 0),
16
+ item_spacing: document.fetch("itemSpacing", 0),
17
+ layout_mode: document["layoutMode"],
18
+ node_id: node_id
19
+ }
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Figma
4
+ module Cli
5
+ module Extractors
6
+ module Typography
7
+ def self.extract(document, style_name:, style_id:)
8
+ style = document["style"]
9
+ return nil unless style
10
+
11
+ {
12
+ name: style_name,
13
+ font_family: style["fontFamily"],
14
+ font_weight: style.fetch("fontWeight", 0),
15
+ font_size: style.fetch("fontSize", 0),
16
+ line_height: style.fetch("lineHeightPx", 0),
17
+ letter_spacing: style.fetch("letterSpacing", 0),
18
+ style_id: style_id
19
+ }
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "time"
5
+
6
+ module Figma
7
+ module Cli
8
+ class Parser
9
+ URL_PATTERN = %r{\Ahttps?://[^/]+/(file|design)/([^/]+)}
10
+ STYLE_TYPES = %w[FILL TEXT EFFECT].freeze
11
+
12
+ def initialize(client:)
13
+ @client = client
14
+ @cached_meta = nil
15
+ end
16
+
17
+ def extract(url, node_id: nil)
18
+ parsed = parse_url(url)
19
+ file_key = parsed[:file_key]
20
+ node_id ||= parsed[:node_id]
21
+ metadata = fetch_metadata(file_key)
22
+ styles = extract_styles(file_key)
23
+ { metadata: metadata, **styles, spacing: extract_spacing(file_key, node_id: node_id) }
24
+ end
25
+
26
+ def file_version(url)
27
+ parsed = parse_url(url)
28
+ file_key = parsed[:file_key]
29
+ @cached_meta = @client.file_meta(file_key)
30
+ { file_key: file_key, version: @cached_meta["version"] }
31
+ end
32
+
33
+ private
34
+
35
+ def parse_url(url)
36
+ match = URL_PATTERN.match(url)
37
+ raise Error, "Invalid Figma URL: #{url}" unless match
38
+
39
+ { file_key: match[2], node_id: parse_node_id(url) }
40
+ end
41
+
42
+ def parse_node_id(url)
43
+ query = URI.parse(url).query
44
+ return nil unless query
45
+
46
+ URI.decode_www_form(query).to_h["node-id"]&.tr("-", ":")
47
+ end
48
+
49
+ def fetch_metadata(file_key)
50
+ meta = @cached_meta || @client.file_meta(file_key)
51
+ @cached_meta = nil
52
+ { file_name: meta["name"], file_key: file_key, last_modified: meta["lastModified"],
53
+ extracted_at: Time.now.utc.iso8601, version: meta["version"] }
54
+ end
55
+
56
+ def extract_styles(file_key)
57
+ all = @client.styles(file_key).dig("meta", "styles") || []
58
+ grouped = all.group_by { |s| s["style_type"] }.slice(*STYLE_TYPES)
59
+ return empty_styles if grouped.empty?
60
+
61
+ known = grouped.values.flatten
62
+ nodes = fetch_style_nodes(file_key, known)
63
+ build_style_results(grouped, nodes)
64
+ end
65
+
66
+ def fetch_style_nodes(file_key, styles)
67
+ @client.nodes(file_key, ids: styles.map { |s| s["node_id"] }.uniq)
68
+ end
69
+
70
+ def build_style_results(grouped, nodes)
71
+ { colors: run_extractor(grouped["FILL"], nodes),
72
+ typography: run_extractor(grouped["TEXT"], nodes),
73
+ effects: run_extractor(grouped["EFFECT"], nodes) }
74
+ end
75
+
76
+ def run_extractor(styles, nodes)
77
+ return [] unless styles
78
+
79
+ styles.flat_map do |s|
80
+ doc = nodes.dig(s["node_id"], "document")
81
+ doc ? dispatch(s, doc) : nil
82
+ end.compact
83
+ end
84
+
85
+ def dispatch(style, doc)
86
+ n, k = style.values_at("name", "key")
87
+ case style["style_type"]
88
+ when "FILL" then Extractors::Colors.extract(doc, style_name: n, style_id: k)
89
+ when "TEXT" then Extractors::Typography.extract(doc, style_name: n, style_id: k)
90
+ when "EFFECT" then Extractors::Effects.extract(doc, style_name: n, style_id: k)
91
+ end
92
+ end
93
+
94
+ def extract_spacing(file_key, node_id: nil)
95
+ root = @client.file(file_key)["document"]
96
+ tree = node_id ? find_subtree(root, node_id) : root
97
+ tree ? collect_spacing(tree) : []
98
+ end
99
+
100
+ def find_subtree(node, target_id)
101
+ return node if node["id"] == target_id
102
+
103
+ node.fetch("children", []).each do |child|
104
+ found = find_subtree(child, target_id)
105
+ return found if found
106
+ end
107
+ nil
108
+ end
109
+
110
+ def collect_spacing(node)
111
+ results = []
112
+ token = Extractors::Spacing.extract(node, node_name: node["name"], node_id: node["id"])
113
+ results << token if token
114
+ node.fetch("children", []).each { |child| results.concat(collect_spacing(child)) }
115
+ results
116
+ end
117
+
118
+ def empty_styles
119
+ { colors: [], typography: [], effects: [] }
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Figma
4
+ module Cli
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Figma
7
+ module Cli
8
+ module Writer
9
+ CATEGORIES = %i[colors typography effects spacing].freeze
10
+
11
+ def self.write(data, output_dir: ".figma-cli")
12
+ FileUtils.mkdir_p(output_dir)
13
+ paths = []
14
+
15
+ # metadata (flat)
16
+ paths << write_file(File.join(output_dir, "metadata.yml"), format_yaml(stringify_keys(data[:metadata])))
17
+
18
+ # token categories (wrapped)
19
+ CATEGORIES.each do |cat|
20
+ content = { cat.to_s => data[cat].map { |h| stringify_keys(h) } }
21
+ paths << write_file(File.join(output_dir, "#{cat}.yml"), format_yaml(content))
22
+ end
23
+
24
+ paths
25
+ end
26
+
27
+ def self.write_file(path, content)
28
+ File.write(path, content)
29
+ path
30
+ end
31
+
32
+ def self.format_yaml(data)
33
+ YAML.dump(data, line_width: -1).sub(/\A---\n/, "")
34
+ end
35
+
36
+ def self.stringify_keys(hash)
37
+ hash.transform_keys(&:to_s)
38
+ end
39
+
40
+ private_class_method :write_file, :format_yaml, :stringify_keys
41
+ end
42
+ end
43
+ end
data/lib/figma/cli.rb ADDED
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli/version"
4
+ require_relative "cli/errors"
5
+ require_relative "cli/client"
6
+ require_relative "cli/parser"
7
+ require_relative "cli/writer"
8
+ require_relative "cli/command"
9
+ require_relative "cli/extractors/color_converter"
10
+ require_relative "cli/extractors/colors"
11
+ require_relative "cli/extractors/typography"
12
+ require_relative "cli/extractors/effects"
13
+ require_relative "cli/extractors/spacing"
14
+
15
+ module Figma
16
+ module Cli
17
+ end
18
+ end
data/sig/figma/cli.rbs ADDED
@@ -0,0 +1,6 @@
1
+ module Figma
2
+ module Cli
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,111 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: figma-cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Thom Meas
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-03-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday-retry
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: thor
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.0'
55
+ description: A CLI tool that extracts design tokens (colors, typography, spacing,
56
+ effects) from Figma files via the REST API and writes them as compact YAML files
57
+ to disk. Optimized for AI agent consumption.
58
+ email:
59
+ - thom.meas@gmail.com
60
+ executables:
61
+ - figma-cli
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - ".rspec"
66
+ - ".rubocop.yml"
67
+ - CLAUDE.md
68
+ - README.md
69
+ - Rakefile
70
+ - exe/figma-cli
71
+ - lib/figma/cli.rb
72
+ - lib/figma/cli/client.rb
73
+ - lib/figma/cli/command.rb
74
+ - lib/figma/cli/errors.rb
75
+ - lib/figma/cli/extractors/color_converter.rb
76
+ - lib/figma/cli/extractors/colors.rb
77
+ - lib/figma/cli/extractors/effects.rb
78
+ - lib/figma/cli/extractors/spacing.rb
79
+ - lib/figma/cli/extractors/typography.rb
80
+ - lib/figma/cli/parser.rb
81
+ - lib/figma/cli/version.rb
82
+ - lib/figma/cli/writer.rb
83
+ - sig/figma/cli.rbs
84
+ homepage: https://github.com/Wethre/figma-cli
85
+ licenses:
86
+ - MIT
87
+ metadata:
88
+ homepage_uri: https://github.com/Wethre/figma-cli
89
+ source_code_uri: https://github.com/Wethre/figma-cli
90
+ changelog_uri: https://github.com/Wethre/figma-cli/blob/main/CHANGELOG.md
91
+ rubygems_mfa_required: 'true'
92
+ post_install_message:
93
+ rdoc_options: []
94
+ require_paths:
95
+ - lib
96
+ required_ruby_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: 3.1.0
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '0'
106
+ requirements: []
107
+ rubygems_version: 3.5.3
108
+ signing_key:
109
+ specification_version: 4
110
+ summary: Extract Figma design tokens to YAML files on disk
111
+ test_files: []