featureparity 0.0.1 → 0.0.3

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.
data/lib/fp/config.rb CHANGED
@@ -5,16 +5,33 @@ require 'fileutils'
5
5
 
6
6
  module Fp
7
7
  # Handles configuration: profiles, API keys, API URL.
8
- # Priority: FP_API_KEY env var > --profile flag > FP_PROFILE env var > default profile
8
+ #
9
+ # API URL resolution priority (highest wins):
10
+ # 1. --api-url CLI flag (passed via constructor)
11
+ # 2. FP_API_URL env var
12
+ # 3. Profile-specific api_url (in profile config)
13
+ # 4. Global api_url setting (top-level in config.yml)
14
+ # 5. DEFAULT_API_URL
15
+ #
16
+ # API key resolution priority:
17
+ # 1. FP_API_KEY env var
18
+ # 2. Profile api_key (--profile flag > FP_PROFILE env var > "default" profile)
19
+ #
20
+ # Profile name resolution priority:
21
+ # 1. --profile CLI flag
22
+ # 2. FP_PROFILE env var
23
+ # 3. A profile literally named "default" (if one exists in config.yml)
9
24
  class Config
10
25
  DEFAULT_API_URL = 'https://api.featureparity.dev'
26
+ DEFAULT_PROFILE_NAME = 'default'
11
27
  CONFIG_DIR = File.expand_path('~/.config/fp')
12
28
  CONFIG_FILE = File.join(CONFIG_DIR, 'config.yml')
13
29
 
14
30
  attr_reader :api_key, :api_url, :profile_name
15
31
 
16
- def initialize(profile: nil)
32
+ def initialize(profile: nil, api_url: nil)
17
33
  @profile_name = resolve_profile_name(profile)
34
+ @explicit_api_url = api_url
18
35
  @api_key = resolve_api_key
19
36
  @api_url = resolve_api_url
20
37
  end
@@ -33,34 +50,51 @@ module Fp
33
50
  end
34
51
 
35
52
  class << self
36
- def load_profiles
53
+ def load_config
37
54
  return {} unless File.exist?(CONFIG_FILE)
38
55
 
39
- config = YAML.safe_load_file(CONFIG_FILE, permitted_classes: [Symbol]) || {}
40
- config['profiles'] || {}
56
+ YAML.safe_load_file(CONFIG_FILE, permitted_classes: [Symbol]) || {}
41
57
  rescue StandardError => e
42
58
  warn "Warning: Could not load config file: #{e.message}"
43
59
  {}
44
60
  end
45
61
 
62
+ def load_profiles
63
+ config = load_config
64
+ config['profiles'] || {}
65
+ end
66
+
46
67
  def save_profile(name, api_key:, api_url: nil)
47
68
  FileUtils.mkdir_p(CONFIG_DIR)
69
+ ensure_config_file!
48
70
 
49
- # Ensure config file has restricted permissions
50
- unless File.exist?(CONFIG_FILE)
51
- File.write(CONFIG_FILE, "---\nprofiles: {}\n")
52
- File.chmod(0o600, CONFIG_FILE)
53
- end
54
-
55
- config = YAML.safe_load_file(CONFIG_FILE, permitted_classes: [Symbol]) || {}
71
+ config = load_config
56
72
  config['profiles'] ||= {}
57
- config['profiles'][name] = {
58
- 'api_key' => api_key
59
- }
73
+ config['profiles'][name] = { 'api_key' => api_key }
60
74
  config['profiles'][name]['api_url'] = api_url if api_url
61
75
 
62
- File.write(CONFIG_FILE, YAML.dump(config))
63
- File.chmod(0o600, CONFIG_FILE)
76
+ write_config!(config)
77
+ end
78
+
79
+ # Get a global setting (top-level key in config.yml)
80
+ def get_setting(key)
81
+ config = load_config
82
+ config[key.to_s]
83
+ end
84
+
85
+ # Set a global setting (top-level key in config.yml)
86
+ def set_setting(key, value)
87
+ FileUtils.mkdir_p(CONFIG_DIR)
88
+ ensure_config_file!
89
+
90
+ config = load_config
91
+ if value.nil?
92
+ config.delete(key.to_s)
93
+ else
94
+ config[key.to_s] = value
95
+ end
96
+
97
+ write_config!(config)
64
98
  end
65
99
 
66
100
  def list_profiles
@@ -70,13 +104,101 @@ module Fp
70
104
  def profile_exists?(name)
71
105
  load_profiles.key?(name)
72
106
  end
107
+
108
+ # --- Repo → surface mappings (per-machine, local) ---
109
+ #
110
+ # Stored under a top-level `repos` key in config.yml, scoped by project slug:
111
+ #
112
+ # repos:
113
+ # stowzilla:
114
+ # customer_android:
115
+ # path: /home/dev/code/customer-android
116
+ # repo: stowzilla/customer-android
117
+ #
118
+ # This is intentionally local (per fp installation) — it maps FeatureParity
119
+ # surfaces to wherever the code lives on THIS machine, so an agent working a
120
+ # requirement for a given surface knows which directory to open. Analogous to
121
+ # how `brainiac projects` maps repos to project names.
122
+
123
+ # Return the full repos mapping ({ project => { surface => {path, repo} } }).
124
+ def repos_map
125
+ config = load_config
126
+ config['repos'] || {}
127
+ end
128
+
129
+ # Return the surface => {path, repo} mapping for a single project.
130
+ def repos_for_project(project)
131
+ repos_map[project.to_s] || {}
132
+ end
133
+
134
+ # Look up the mapping for a single surface within a project.
135
+ # Returns a hash like { 'path' => ..., 'repo' => ... } or nil if unmapped.
136
+ def repo_for_surface(project, surface)
137
+ repos_for_project(project)[surface.to_s]
138
+ end
139
+
140
+ # Map a surface (within a project) to a local path and optional org/repo.
141
+ def set_repo(project, surface, path:, repo: nil)
142
+ FileUtils.mkdir_p(CONFIG_DIR)
143
+ ensure_config_file!
144
+
145
+ config = load_config
146
+ config['repos'] ||= {}
147
+ config['repos'][project.to_s] ||= {}
148
+
149
+ entry = { 'path' => path }
150
+ entry['repo'] = repo if repo && !repo.to_s.empty?
151
+ config['repos'][project.to_s][surface.to_s] = entry
152
+
153
+ write_config!(config)
154
+ entry
155
+ end
156
+
157
+ # Remove a surface mapping within a project. Prunes empty project hashes.
158
+ # Returns true if something was removed, false otherwise.
159
+ def unset_repo(project, surface)
160
+ config = load_config
161
+ return false unless config['repos'].is_a?(Hash)
162
+
163
+ project_map = config['repos'][project.to_s]
164
+ return false unless project_map.is_a?(Hash) && project_map.key?(surface.to_s)
165
+
166
+ project_map.delete(surface.to_s)
167
+ config['repos'].delete(project.to_s) if project_map.empty?
168
+ config.delete('repos') if config['repos'].empty?
169
+
170
+ write_config!(config)
171
+ true
172
+ end
173
+
174
+ private
175
+
176
+ def ensure_config_file!
177
+ return if File.exist?(CONFIG_FILE)
178
+
179
+ File.write(CONFIG_FILE, "---\nprofiles: {}\n")
180
+ File.chmod(0o600, CONFIG_FILE)
181
+ end
182
+
183
+ def write_config!(config)
184
+ File.write(CONFIG_FILE, YAML.dump(config))
185
+ File.chmod(0o600, CONFIG_FILE)
186
+ end
73
187
  end
74
188
 
75
189
  private
76
190
 
77
191
  def resolve_profile_name(explicit_profile)
78
- # Explicit --profile flag takes precedence over FP_PROFILE env var
79
- explicit_profile || ENV['FP_PROFILE']
192
+ # 1. Explicit --profile flag 2. FP_PROFILE env var
193
+ name = explicit_profile || ENV['FP_PROFILE']
194
+ return name if name && !name.empty?
195
+
196
+ # 3. Fall back to a profile literally named "default", if one exists.
197
+ # This mirrors AWS/git/gcloud, which all consult a default profile when
198
+ # nothing else is specified.
199
+ return DEFAULT_PROFILE_NAME if self.class.profile_exists?(DEFAULT_PROFILE_NAME)
200
+
201
+ nil
80
202
  end
81
203
 
82
204
  def resolve_api_key
@@ -94,17 +216,24 @@ module Fp
94
216
  end
95
217
 
96
218
  def resolve_api_url
97
- # FP_API_URL env var takes precedence
219
+ # 1. Explicit --api-url flag
220
+ return @explicit_api_url if @explicit_api_url && !@explicit_api_url.empty?
221
+
222
+ # 2. FP_API_URL env var
98
223
  return ENV['FP_API_URL'] if ENV['FP_API_URL'] && !ENV['FP_API_URL'].empty?
99
224
 
100
- # Then profile-specific URL
225
+ # 3. Profile-specific URL
101
226
  if @profile_name
102
227
  profiles = self.class.load_profiles
103
228
  profile = profiles[@profile_name]
104
229
  return profile['api_url'] if profile && profile['api_url']
105
230
  end
106
231
 
107
- # Finally, default
232
+ # 4. Global api_url setting
233
+ global_url = self.class.get_setting('api_url')
234
+ return global_url if global_url && !global_url.empty?
235
+
236
+ # 5. Default
108
237
  DEFAULT_API_URL
109
238
  end
110
239
  end
data/lib/fp/junit.rb ADDED
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rexml/document'
4
+
5
+ module Fp
6
+ # Parses JUnit XML reports and binds testcases to fp:<slug> markers found in
7
+ # the referenced source files.
8
+ #
9
+ # This is what powers "upload evidence without CI": an agent runs the test
10
+ # suite locally, produces a JUnit XML report, and `fp report --junit` walks
11
+ # the results, discovers the fp:<slug> markers in the test files, and uploads
12
+ # evidence for each requirement the suite covered.
13
+ module JUnit
14
+ # A single parsed testcase.
15
+ TestCase = Struct.new(:name, :classname, :file, :line, :status, keyword_init: true) do
16
+ # Agents only ever report present/stub — never passing/failing (that's CI).
17
+ # A skipped test is treated as a stub; anything else counts as present.
18
+ def stub?
19
+ status == :skipped
20
+ end
21
+ end
22
+
23
+ # Evidence discovered by binding a testcase to a marker.
24
+ Binding = Struct.new(:slug, :surface, :file, :title, :state, keyword_init: true)
25
+
26
+ # Matches `fp:<slug>` inside a comment, with an optional `@surface` (or
27
+ # `@surface1,surface2`) suffix to target one or more specific surfaces:
28
+ #
29
+ # fp:print_qr # uses the --surface flag as the default
30
+ # fp:print_qr@api # reports evidence for the `api` surface
31
+ # fp:print_qr@api,web # reports for both `api` and `web`
32
+ #
33
+ # Slugs and surfaces are lowercase letters, digits, underscores and hyphens
34
+ # (matching the propose/report convention).
35
+ MARKER_RE = /fp:([a-z0-9][a-z0-9_-]*)(?:@([a-z0-9_-]+(?:,[a-z0-9_-]+)*))?/.freeze
36
+
37
+ module_function
38
+
39
+ # Parse a JUnit XML file into an array of TestCase structs.
40
+ # Raises ArgumentError if the file is missing or unparseable.
41
+ def parse_file(path)
42
+ raise ArgumentError, "JUnit file not found: #{path}" unless File.file?(path)
43
+
44
+ xml = File.read(path)
45
+ parse_string(xml)
46
+ rescue REXML::ParseException => e
47
+ raise ArgumentError, "Could not parse JUnit XML (#{path}): #{e.message}"
48
+ end
49
+
50
+ def parse_string(xml)
51
+ doc = REXML::Document.new(xml)
52
+ cases = []
53
+
54
+ doc.each_element('//testcase') do |el|
55
+ cases << TestCase.new(
56
+ name: el.attributes['name'],
57
+ classname: el.attributes['classname'],
58
+ file: el.attributes['file'],
59
+ line: (el.attributes['line'] && el.attributes['line'].to_i),
60
+ status: testcase_status(el)
61
+ )
62
+ end
63
+
64
+ cases
65
+ end
66
+
67
+ # Determine present/skipped for a testcase element.
68
+ # Failures/errors still count as "present" for agent evidence — the test
69
+ # exists and was executed. Only skipped/pending tests become stubs.
70
+ def testcase_status(el)
71
+ return :skipped if el.get_elements('skipped').any?
72
+
73
+ :present
74
+ end
75
+
76
+ # Bind parsed testcases to fp:<slug> markers found in their source files.
77
+ #
78
+ # A marker binds to the test it annotates — the testcase whose line is the
79
+ # smallest line strictly greater than the marker's line (i.e. the next test
80
+ # below the marker in the same file). This mirrors the convention that the
81
+ # `fp:<slug>` comment sits immediately above its test. Tests with no marker
82
+ # directly above them are left unmatched rather than being bound to an
83
+ # unrelated marker.
84
+ #
85
+ # When testcases carry no line information, a file with exactly one marker
86
+ # binds that marker to every testcase in the file (common for small,
87
+ # single-requirement test files); files with multiple markers and no line
88
+ # info can't be disambiguated and are reported as unmatched.
89
+ #
90
+ # A marker may pin one or more surfaces via `@surface` / `@a,b` (e.g. a
91
+ # backend test that satisfies several surfaces). Each surface yields its own
92
+ # binding. Markers without a surface fall back to `default_surface`.
93
+ #
94
+ # base_dir: directory to resolve relative testcase file paths against.
95
+ # default_surface: surface applied to markers that don't pin their own.
96
+ #
97
+ # Returns a hash:
98
+ # {
99
+ # bindings: [Binding, ...], # unique (slug, surface, file) evidence
100
+ # unmatched: [TestCase, ...], # testcases with no discoverable marker
101
+ # missing_surface: [Binding-ish], # markers with no surface and no default
102
+ # }
103
+ def bind_markers(testcases, base_dir: Dir.pwd, default_surface: nil)
104
+ marker_cache = {}
105
+ bindings = {}
106
+ matched = {}
107
+ missing_surface = []
108
+
109
+ # Group testcases by their source file so we can reason about ordering.
110
+ by_file = testcases.group_by(&:file)
111
+
112
+ by_file.each do |file, cases|
113
+ markers = markers_for(cases.first, base_dir, marker_cache)
114
+ next if markers.empty?
115
+
116
+ cases.each do |tc|
117
+ marker = marker_for(tc, cases, markers)
118
+ next unless marker
119
+
120
+ matched[tc.object_id] = true
121
+
122
+ surfaces = marker[:surfaces]
123
+ surfaces = [default_surface] if surfaces.empty?
124
+
125
+ surfaces.each do |surface|
126
+ if surface.nil? || surface.empty?
127
+ missing_surface << { slug: marker[:slug], file: tc.file }
128
+ else
129
+ record_binding(bindings, marker[:slug], surface, tc)
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ unmatched = testcases.reject { |tc| matched[tc.object_id] }
136
+ { bindings: bindings.values, unmatched: unmatched, missing_surface: missing_surface.uniq }
137
+ end
138
+
139
+ # Determine the marker (slug + surfaces) that annotates a testcase, or nil.
140
+ def marker_for(testcase, sibling_cases, markers)
141
+ if testcase.line
142
+ marker_for_line(testcase.line, sibling_cases, markers)
143
+ elsif markers.length == 1 && sibling_cases.none?(&:line)
144
+ # No line info anywhere and a single marker: unambiguous.
145
+ markers.first
146
+ end
147
+ end
148
+
149
+ # A marker annotates the testcase that is the first test below it. Given a
150
+ # testcase line, find the marker that sits directly above it with no other
151
+ # testcase in between.
152
+ def marker_for_line(line, sibling_cases, markers)
153
+ candidate = markers.select { |m| m[:line] < line }.max_by { |m| m[:line] }
154
+ return nil unless candidate
155
+
156
+ # Reject if another testcase falls between the marker and this test —
157
+ # that means the marker belongs to the intervening test, not this one.
158
+ intervening = sibling_cases.any? do |other|
159
+ other.line && other.line > candidate[:line] && other.line < line
160
+ end
161
+ return nil if intervening
162
+
163
+ candidate
164
+ end
165
+
166
+ def record_binding(bindings, slug, surface, testcase)
167
+ rel_file = testcase.file
168
+ key = [slug, surface, rel_file]
169
+ state = testcase.stub? ? 'stub' : 'present'
170
+
171
+ existing = bindings[key]
172
+ # Prefer a concrete 'present' state over 'stub' when a slug has both.
173
+ return if existing && !(existing.state == 'stub' && state == 'present')
174
+
175
+ bindings[key] = Binding.new(slug: slug, surface: surface, file: rel_file,
176
+ title: testcase.name, state: state)
177
+ end
178
+
179
+ # Load and cache the fp:<slug> markers (with line numbers) for a testcase's
180
+ # source file. Returns [] when the file is unknown or unreadable.
181
+ def markers_for(testcase, base_dir, cache)
182
+ file = testcase.file
183
+ return [] if file.nil? || file.empty?
184
+
185
+ return cache[file] if cache.key?(file)
186
+
187
+ resolved = File.expand_path(file, base_dir)
188
+ markers =
189
+ if File.file?(resolved)
190
+ scan_markers(File.read(resolved))
191
+ else
192
+ []
193
+ end
194
+
195
+ cache[file] = markers
196
+ end
197
+
198
+ # Scan file contents for fp:<slug>[@surface[,surface...]] markers, returning
199
+ # [{ slug:, surfaces: [..], line: }, ...] (1-based line numbers).
200
+ # `surfaces` is [] when the marker pins none (use the default surface).
201
+ def scan_markers(contents)
202
+ markers = []
203
+ contents.each_line.with_index(1) do |line, num|
204
+ line.scan(MARKER_RE) do |(slug, surface_list)|
205
+ surfaces = surface_list ? surface_list.split(',').map(&:strip).reject(&:empty?) : []
206
+ markers << { slug: slug, surfaces: surfaces, line: num }
207
+ end
208
+ end
209
+ markers
210
+ end
211
+ end
212
+ end
data/lib/fp/output.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
- require 'csv'
5
4
 
6
5
  module Fp
7
6
  # Output formatting for CLI responses
@@ -61,6 +60,7 @@ module Fp
61
60
  end
62
61
 
63
62
  def csv(headers, rows)
63
+ require 'csv'
64
64
  csv_string = CSV.generate do |csv|
65
65
  csv << headers
66
66
  rows.each { |row| csv << row }
data/lib/fp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fp
4
- VERSION = '0.0.1'
4
+ VERSION = '0.0.3'
5
5
  end
data/lib/fp.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative 'fp/version'
4
4
  require_relative 'fp/config'
5
5
  require_relative 'fp/client'
6
+ require_relative 'fp/junit'
6
7
  require_relative 'fp/output'
7
8
  require_relative 'fp/commands'
8
9
  require_relative 'fp/cli'
@@ -0,0 +1,140 @@
1
+ ---
2
+ name: fp
3
+ description: "Use the fp CLI to track feature parity: report evidence linking tests to requirements, propose new requirements as drafts, view the parity matrix, and upload test results from JUnit XML when CI isn't available."
4
+ license: MIT
5
+ compatibility: Requires the fp executable on PATH (gem install featureparity)
6
+ metadata:
7
+ gem: featureparity
8
+ binary: fp
9
+ ---
10
+
11
+ # fp — FeatureParity CLI
12
+
13
+ Prefer the installed `fp` binary over inventing equivalent Ruby. Confirm it exists first:
14
+
15
+ ```bash
16
+ command -v fp && fp --version
17
+ ```
18
+
19
+ If missing: `gem install featureparity` (or `bundle exec fp` inside an app that already depends on the gem).
20
+
21
+ ## Non-interactive rules
22
+
23
+ - Always pass flags. Never rely on prompts or TTY menus.
24
+ - Use `--json` when parsing output programmatically.
25
+ - Use `fp help <command>` before destructive commands.
26
+ - Treat non-zero exit as failure; read stderr.
27
+
28
+ ## Authentication
29
+
30
+ `fp` needs an API key. Check if configured:
31
+
32
+ ```bash
33
+ # Environment variable (preferred for agents)
34
+ echo $FP_API_KEY
35
+
36
+ # Or via profile
37
+ fp profile list
38
+ ```
39
+
40
+ If not set, ask the user for their API key or have them run `fp setup`.
41
+
42
+ ## Core workflows
43
+
44
+ ### 1. Check existing requirements
45
+
46
+ ```bash
47
+ fp list --project stowzilla # All active requirements
48
+ fp list --project stowzilla --gaps # Requirements without evidence
49
+ fp show <slug> --project stowzilla # Requirement details
50
+ fp matrix --project stowzilla # ASCII parity matrix
51
+ fp matrix --project stowzilla --csv # Export as CSV
52
+ ```
53
+
54
+ ### 2. Add the fp:<slug> marker to tests
55
+
56
+ Place a comment immediately before the test to bind it to a requirement:
57
+
58
+ ```ruby
59
+ # fp:print_container_qr
60
+ it 'prints a QR code onto the container label' do
61
+ expect(label.qr_code).to be_present
62
+ end
63
+ ```
64
+
65
+ **Rules:**
66
+ - Use `fp:<slug>` where `<slug>` is the requirement's slug
67
+ - Keep test names human-readable — the marker handles binding
68
+ - Pin surfaces with `@suffix`: `fp:print_qr@api,web` reports for both surfaces
69
+
70
+ ### 3. Report evidence (single test)
71
+
72
+ ```bash
73
+ fp report <slug> \
74
+ --project stowzilla \
75
+ --surface api \
76
+ --file spec/qr_spec.rb \
77
+ --repo stowzilla/marketplace \
78
+ --work-item https://app.fizzy.do/123/cards/456
79
+ ```
80
+
81
+ **Required:** `<slug>`, `--project`, `--surface`, `--file`, `--repo`
82
+ **Optional:** `--pr`, `--sha`, `--work-item`, `--ci-url`, `--title`
83
+
84
+ **Agents report `present` (default) or `stub` only. Never `passing` or `failing`.**
85
+
86
+ ### 4. Report evidence from JUnit XML (batch upload without CI)
87
+
88
+ When CI isn't available, run the suite locally and upload the report:
89
+
90
+ ```bash
91
+ # 1. Generate JUnit XML
92
+ rspec --format RspecJunitFormatter --out junit.xml
93
+
94
+ # 2. Upload — one call covers every marked test
95
+ fp report --junit junit.xml \
96
+ --project stowzilla \
97
+ --surface api \
98
+ --repo stowzilla/marketplace \
99
+ --work-item https://app.fizzy.do/123/cards/456
100
+ ```
101
+
102
+ - No `<slug>` positional needed — slugs come from the `fp:<slug>` markers
103
+ - Skipped tests become `stub`; all others `present`
104
+ - Unknown slugs and unmarked tests are skipped with a warning
105
+ - `--surface` is the default; markers can override with `@suffix`
106
+ - `--base-dir DIR` sets where relative paths resolve from
107
+
108
+ ### 5. Propose new requirements
109
+
110
+ ```bash
111
+ fp propose --project stowzilla \
112
+ --slug print_container_qr \
113
+ --name "Print QR on container label" \
114
+ --why "Enables scanning containers in the warehouse" \
115
+ --required api,customer_android \
116
+ --acceptance "Label shows scannable QR code"
117
+ ```
118
+
119
+ **Agents always create requirements as draft.** A human must activate in the web app.
120
+
121
+ ## Quick reference
122
+
123
+ | Task | Command |
124
+ |------|---------|
125
+ | List requirements | `fp list --project X` |
126
+ | Show gaps | `fp list --project X --gaps` |
127
+ | Show details | `fp show <slug> --project X` |
128
+ | Report evidence | `fp report <slug> --project X --surface Y --file Z --repo A/B` |
129
+ | Report from JUnit | `fp report --junit file.xml --project X --surface Y --repo A/B` |
130
+ | Propose requirement | `fp propose --project X --slug Y --name "..."` |
131
+ | View matrix | `fp matrix --project X` |
132
+ | List surfaces | `fp surfaces --project X` |
133
+ | List projects | `fp projects` |
134
+
135
+ All commands support `--json` for machine-readable output.
136
+
137
+ ## When to read more
138
+
139
+ - Full flag reference and exit codes → `references/cli.md`
140
+ - Marker syntax and examples → `references/markers.md`