featureparity 0.0.2 → 0.0.4

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.
@@ -2,9 +2,18 @@
2
2
 
3
3
  module Fp
4
4
  module Commands
5
- # fp report <slug> --project <slug> --surface X --file path --repo org/repo
6
- # [--pr URL] [--sha X] [--work-item URL] [--ci-url URL] [--title "..."] [--state present|stub]
7
- # Report evidence for a requirement
5
+ # Single requirement:
6
+ # fp report <slug> --project <slug> --surface X --file path --repo org/repo
7
+ # [--pr URL] [--sha X] [--work-item URL] [--ci-url URL] [--title "..."] [--state present|stub]
8
+ #
9
+ # Batch from a JUnit XML report (upload evidence without CI):
10
+ # fp report --junit <path> --project <slug> --surface X --repo org/repo
11
+ # [--base-dir DIR] [--pr URL] [--sha X] [--work-item URL] [--ci-url URL] [--state present|stub]
12
+ #
13
+ # In --junit mode the CLI parses the report, discovers the fp:<slug> markers
14
+ # in the referenced test files, and uploads evidence for every requirement
15
+ # the suite covered. This lets an agent run the suite locally and report
16
+ # evidence without waiting on CI.
8
17
  class Report < Base
9
18
  KNOWN_FLAGS = {
10
19
  project: :string,
@@ -16,7 +25,9 @@ module Fp
16
25
  work_item: :string,
17
26
  ci_url: :string,
18
27
  title: :string,
19
- state: :string
28
+ state: :string,
29
+ junit: :string,
30
+ base_dir: :string
20
31
  }.freeze
21
32
 
22
33
  VALID_STATES = %w[present stub].freeze
@@ -24,6 +35,8 @@ module Fp
24
35
  def run(args)
25
36
  opts, positional = parse_flags(args, KNOWN_FLAGS)
26
37
 
38
+ return run_junit(opts) if opts[:junit]
39
+
27
40
  slug = positional.first
28
41
  unless slug
29
42
  output.error('Requirement slug is required. Usage: fp report <slug> --project <project> --surface <surface> --file <path> --repo <org/repo>')
@@ -43,20 +56,19 @@ module Fp
43
56
  exit 1
44
57
  end
45
58
 
46
- workspace_id = resolve_workspace_id(opts[:project])
59
+ project_id = resolve_project_id(opts[:project])
47
60
 
48
- # Find the requirement by slug
49
- req_result = client.find_requirement_by_slug(workspace_id, slug)
61
+ # Verify the requirement exists before reporting evidence
62
+ req_result = client.find_requirement_by_slug(project_id, slug)
50
63
  unless req_result[:ok]
51
64
  output.error(req_result[:error], status: req_result[:status])
52
65
  exit 1
53
66
  end
54
67
 
55
- requirement = req_result[:data]['requirement']
56
-
57
68
  # Build evidence payload
58
69
  params = {
59
- requirement_id: requirement['id'],
70
+ project_id: project_id,
71
+ slug: slug,
60
72
  surface: opts[:surface],
61
73
  file: opts[:file],
62
74
  repo: opts[:repo],
@@ -66,13 +78,14 @@ module Fp
66
78
  params[:sha] = opts[:sha] if opts[:sha]
67
79
  params[:work_item_url] = opts[:work_item] if opts[:work_item]
68
80
  params[:ci_url] = opts[:ci_url] if opts[:ci_url]
69
- params[:example_title] = opts[:title] if opts[:title]
81
+ params[:title] = opts[:title] if opts[:title]
70
82
 
71
83
  # Try to report evidence
72
84
  result = client.report_evidence(params)
73
85
 
74
86
  # Evidence API may not exist yet (#1214)
75
- if !result[:ok] && result[:status] == 404
87
+ # API Gateway returns 403 (not 404) for unmatched routes
88
+ if !result[:ok] && [403, 404].include?(result[:status])
76
89
  output.success(params, summary: nil) do
77
90
  puts "Evidence for '#{slug}' on surface '#{opts[:surface]}':"
78
91
  puts " State: #{state}"
@@ -112,6 +125,142 @@ module Fp
112
125
  end
113
126
  end
114
127
  end
128
+
129
+ private
130
+
131
+ # Batch-upload evidence from a JUnit XML report.
132
+ def run_junit(opts)
133
+ require_flag(opts, :project)
134
+ require_flag(opts, :repo)
135
+ # --surface is the default for markers that don't pin their own surface
136
+ # (e.g. `fp:slug@api,web`). It's only strictly required when at least one
137
+ # matched marker omits a surface — validated below once we know.
138
+
139
+ # State override applies to every non-skipped case. Skipped tests always
140
+ # become stubs regardless. Default lets the report decide per-testcase.
141
+ state_override = opts[:state]
142
+ if state_override && !VALID_STATES.include?(state_override)
143
+ output.error("Invalid state '#{state_override}'. Must be one of: #{VALID_STATES.join(', ')}")
144
+ output.error('Note: Agents report present or stub. Only CI (#1222) can report passing or failing.')
145
+ exit 1
146
+ end
147
+
148
+ begin
149
+ testcases = Fp::JUnit.parse_file(opts[:junit])
150
+ rescue ArgumentError => e
151
+ output.error(e.message)
152
+ exit 1
153
+ end
154
+
155
+ if testcases.empty?
156
+ output.error("No <testcase> entries found in #{opts[:junit]}")
157
+ exit 1
158
+ end
159
+
160
+ base_dir = opts[:base_dir] || Dir.pwd
161
+ result = Fp::JUnit.bind_markers(testcases, base_dir: base_dir, default_surface: opts[:surface])
162
+ bindings = result[:bindings]
163
+ unmatched = result[:unmatched]
164
+ missing_surface = result[:missing_surface] || []
165
+
166
+ # Markers that pinned no surface AND no --surface default was given.
167
+ unless missing_surface.empty?
168
+ slugs = missing_surface.map { |m| m[:slug] }.uniq.join(', ')
169
+ output.error("--surface is required: these markers pin no surface of their own: #{slugs}")
170
+ output.error('Either pass --surface, or annotate the marker (e.g. fp:my_slug@api,web).')
171
+ exit 1
172
+ end
173
+
174
+ if bindings.empty?
175
+ output.error("No fp:<slug> markers found for any of the #{testcases.length} testcase(s) in #{opts[:junit]}.")
176
+ output.error('Ensure your test files contain fp:<slug> markers and that the JUnit report includes file paths.')
177
+ exit 1
178
+ end
179
+
180
+ project_id = resolve_project_id(opts[:project])
181
+
182
+ reported = []
183
+ skipped_unknown = []
184
+ api_unavailable = false
185
+
186
+ bindings.each do |binding|
187
+ state = state_override || binding.state
188
+
189
+ # Verify the requirement exists before reporting (skip unknown slugs
190
+ # with a warning rather than aborting the whole batch).
191
+ req_result = client.find_requirement_by_slug(project_id, binding.slug)
192
+ unless req_result[:ok]
193
+ skipped_unknown << binding
194
+ next
195
+ end
196
+
197
+ params = {
198
+ project_id: project_id,
199
+ slug: binding.slug,
200
+ surface: binding.surface,
201
+ file: binding.file,
202
+ repo: opts[:repo],
203
+ state: state
204
+ }
205
+ params[:pr_url] = opts[:pr] if opts[:pr]
206
+ params[:sha] = opts[:sha] if opts[:sha]
207
+ params[:work_item_url] = opts[:work_item] if opts[:work_item]
208
+ params[:ci_url] = opts[:ci_url] if opts[:ci_url]
209
+ params[:title] = binding.title if binding.title
210
+
211
+ report_result = client.report_evidence(params)
212
+
213
+ # Evidence API may not exist yet (#1214). API Gateway returns 403
214
+ # (not 404) for unmatched routes.
215
+ if !report_result[:ok] && [403, 404].include?(report_result[:status])
216
+ api_unavailable = true
217
+ reported << { binding: binding, state: state, recorded: false }
218
+ next
219
+ end
220
+
221
+ unless report_result[:ok]
222
+ output.error("Failed to report '#{binding.slug}' (#{binding.surface}): #{report_result[:error]}", status: report_result[:status])
223
+ exit 1
224
+ end
225
+
226
+ reported << { binding: binding, state: state, recorded: true }
227
+ end
228
+
229
+ emit_junit_summary(opts, reported, skipped_unknown, unmatched, api_unavailable)
230
+ end
231
+
232
+ def emit_junit_summary(opts, reported, skipped_unknown, unmatched, api_unavailable)
233
+ data = {
234
+ repo: opts[:repo],
235
+ reported: reported.map { |r| { slug: r[:binding].slug, surface: r[:binding].surface, file: r[:binding].file, state: r[:state], recorded: r[:recorded] } },
236
+ skipped_unknown: skipped_unknown.map { |b| { slug: b.slug, surface: b.surface, file: b.file } },
237
+ unmatched_testcases: unmatched.map { |tc| { name: tc.name, file: tc.file } }
238
+ }
239
+
240
+ output.success(data) do
241
+ puts "Reported evidence from #{opts[:junit]}:"
242
+ reported.each do |r|
243
+ marker = r[:recorded] ? '✓' : '•'
244
+ puts " #{marker} #{r[:binding].slug} → #{r[:binding].surface} (#{r[:state]}) #{r[:binding].file}"
245
+ end
246
+
247
+ unless skipped_unknown.empty?
248
+ puts
249
+ puts "⚠️ Skipped #{skipped_unknown.length} marker(s) with no matching requirement in this project:"
250
+ skipped_unknown.each { |b| puts " - #{b.slug} → #{b.surface} (#{b.file})" }
251
+ end
252
+
253
+ unless unmatched.empty?
254
+ puts
255
+ puts "ℹ️ #{unmatched.length} testcase(s) had no fp:<slug> marker and were skipped."
256
+ end
257
+
258
+ if api_unavailable
259
+ puts
260
+ puts '⚠️ Evidence API not available yet. These will be recorded when #1214 is complete.'
261
+ end
262
+ end
263
+ end
115
264
  end
116
265
  end
117
266
  end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp repos set|get|list|unset
6
+ #
7
+ # Maps FeatureParity surfaces to local repo paths on THIS machine. Stored in
8
+ # ~/.config/fp/config.yml under `repos`, scoped by project slug. This is a
9
+ # per-installation convenience (not stored in the FP web app): when an agent
10
+ # picks up a requirement affecting, say, the `customer_android` surface, it can
11
+ # run `fp repos get customer_android --project stowzilla` to learn which local
12
+ # directory to work in. Analogous to how `brainiac projects` maps repos to names.
13
+ #
14
+ # fp repos set <surface> <path> --project <slug> [--repo org/repo]
15
+ # fp repos get <surface> --project <slug>
16
+ # fp repos list [--project <slug>]
17
+ # fp repos unset <surface> --project <slug>
18
+ class Repos < Base
19
+ KNOWN_FLAGS = {
20
+ project: :string,
21
+ repo: :string
22
+ }.freeze
23
+
24
+ def run(args)
25
+ subcommand = args.shift
26
+
27
+ case subcommand
28
+ when 'set' then set_repo(args)
29
+ when 'get' then get_repo(args)
30
+ when 'list' then list_repos(args)
31
+ when 'unset' then unset_repo(args)
32
+ else show_usage
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def set_repo(args)
39
+ opts, positional = parse_flags(args, KNOWN_FLAGS)
40
+ require_flag(opts, :project)
41
+
42
+ surface = positional[0]
43
+ path = positional[1]
44
+
45
+ unless surface && path
46
+ output.error('Usage: fp repos set <surface> <path> --project <slug> [--repo org/repo]')
47
+ exit 1
48
+ end
49
+
50
+ # Store an absolute, tilde-expanded path so agents get a directly usable location.
51
+ expanded = File.expand_path(path)
52
+ unless File.directory?(expanded)
53
+ output.error("Path does not exist or is not a directory: #{expanded}")
54
+ exit 1
55
+ end
56
+
57
+ entry = Config.set_repo(opts[:project], surface, path: expanded, repo: opts[:repo])
58
+
59
+ output.success({ project: opts[:project], surface: surface }.merge(entry)) do
60
+ puts "Mapped surface '#{surface}' (project '#{opts[:project]}') to:"
61
+ puts " path: #{entry['path']}"
62
+ puts " repo: #{entry['repo']}" if entry['repo']
63
+ end
64
+ end
65
+
66
+ def get_repo(args)
67
+ opts, positional = parse_flags(args, KNOWN_FLAGS)
68
+ require_flag(opts, :project)
69
+
70
+ surface = positional[0]
71
+ unless surface
72
+ output.error('Usage: fp repos get <surface> --project <slug>')
73
+ exit 1
74
+ end
75
+
76
+ entry = Config.repo_for_surface(opts[:project], surface)
77
+
78
+ unless entry
79
+ output.error("No repo mapped for surface '#{surface}' in project '#{opts[:project]}'.")
80
+ output.error("Map one with: fp repos set #{surface} /path/to/repo --project #{opts[:project]}")
81
+ exit 1
82
+ end
83
+
84
+ output.success({ project: opts[:project], surface: surface }.merge(entry)) do
85
+ # Print just the path so this composes in shell: cd "$(fp repos get ... )"
86
+ puts entry['path']
87
+ end
88
+ end
89
+
90
+ def list_repos(args)
91
+ opts, = parse_flags(args, KNOWN_FLAGS)
92
+
93
+ repos_map = if opts[:project]
94
+ { opts[:project] => Config.repos_for_project(opts[:project]) }
95
+ else
96
+ Config.repos_map
97
+ end
98
+
99
+ # Drop empty projects (e.g. an explicit --project with no mappings yet).
100
+ repos_map = repos_map.reject { |_project, surfaces| surfaces.nil? || surfaces.empty? }
101
+
102
+ output.success({ repos: repos_map }) do
103
+ if repos_map.empty?
104
+ if opts[:project]
105
+ puts "No repos mapped for project '#{opts[:project]}'."
106
+ else
107
+ puts 'No repos mapped on this machine.'
108
+ end
109
+ puts
110
+ puts 'Map one with:'
111
+ puts ' fp repos set <surface> /path/to/repo --project <slug> [--repo org/repo]'
112
+ else
113
+ repos_map.each do |project, surfaces|
114
+ puts "#{project}:"
115
+ surfaces.sort.each do |surface, entry|
116
+ repo = entry['repo'] ? " (#{entry['repo']})" : ''
117
+ puts " #{surface} -> #{entry['path']}#{repo}"
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+
124
+ def unset_repo(args)
125
+ opts, positional = parse_flags(args, KNOWN_FLAGS)
126
+ require_flag(opts, :project)
127
+
128
+ surface = positional[0]
129
+ unless surface
130
+ output.error('Usage: fp repos unset <surface> --project <slug>')
131
+ exit 1
132
+ end
133
+
134
+ removed = Config.unset_repo(opts[:project], surface)
135
+
136
+ unless removed
137
+ output.error("No repo mapped for surface '#{surface}' in project '#{opts[:project]}'.")
138
+ exit 1
139
+ end
140
+
141
+ output.success({ project: opts[:project], surface: surface, removed: true }) do
142
+ puts "Unmapped surface '#{surface}' from project '#{opts[:project]}'."
143
+ end
144
+ end
145
+
146
+ def show_usage
147
+ output.error('Usage: fp repos <set|get|list|unset>')
148
+ puts
149
+ puts 'Commands:'
150
+ puts ' fp repos set <surface> <path> --project <slug> [--repo org/repo]'
151
+ puts ' Map a surface to a local repo path'
152
+ puts ' fp repos get <surface> --project <slug>'
153
+ puts ' Print the local path for a surface'
154
+ puts ' fp repos list [--project <slug>] List surface -> path mappings'
155
+ puts ' fp repos unset <surface> --project <slug>'
156
+ puts ' Remove a mapping'
157
+ puts
158
+ puts 'Mappings are local to this machine (stored in ~/.config/fp/config.yml).'
159
+ puts
160
+ puts 'Examples:'
161
+ puts ' fp repos set customer_android ~/code/customer-android \\'
162
+ puts ' --project stowzilla --repo stowzilla/customer-android'
163
+ puts ' fp repos get customer_android --project stowzilla'
164
+ puts ' cd "$(fp repos get customer_android --project stowzilla)"'
165
+ exit 1
166
+ end
167
+ end
168
+ end
169
+ end
@@ -10,6 +10,7 @@ module Fp
10
10
  api_key: :string,
11
11
  profile: :string,
12
12
  api_url: :string,
13
+ environment: :string,
13
14
  non_interactive: :boolean
14
15
  }.freeze
15
16
 
@@ -22,19 +23,22 @@ module Fp
22
23
 
23
24
  api_key = resolve_api_key(opts)
24
25
  profile_name = resolve_profile_name(opts)
25
- api_url = opts[:api_url] || Config.get_setting('api_url')
26
+ api_url = resolve_api_url(opts)
27
+ environment = opts[:environment] || Config.environment_for(api_url || Config::DEFAULT_API_URL)
26
28
 
27
29
  puts "Validating API key..."
28
30
  validate_key!(api_key, api_url)
29
31
 
30
- save_profile!(profile_name, api_key, api_url)
32
+ save_profile!(profile_name, api_key, api_url, environment)
31
33
 
32
34
  effective_url = api_url || Config::DEFAULT_API_URL
33
- domain = URI.parse(effective_url).host.sub(/^api\./, '') rescue 'featureparity.dev'
34
35
 
35
36
  puts
36
37
  puts "✅ Setup complete!"
37
38
  puts
39
+ puts " API URL: #{effective_url}"
40
+ puts " Environment: #{environment}" if environment
41
+ puts
38
42
  puts "Your profile '#{profile_name}' is ready. Usage:"
39
43
  puts
40
44
  puts " fp --profile #{profile_name} projects"
@@ -42,6 +46,9 @@ module Fp
42
46
  puts
43
47
  puts "Or set FP_API_KEY to skip profiles:"
44
48
  puts " export FP_API_KEY=#{api_key[0..6]}..."
49
+ puts
50
+ puts "Verify the resolved configuration any time with:"
51
+ puts " fp whoami"
45
52
  end
46
53
 
47
54
  private
@@ -57,12 +64,12 @@ module Fp
57
64
  exit 1
58
65
  end
59
66
 
60
- api_url = opts[:api_url] || Config.get_setting('api_url')
67
+ api_url = opts[:api_url] || config&.explicit_api_url || Config.get_setting('api_url')
61
68
  effective_url = api_url || Config::DEFAULT_API_URL
62
69
  domain = URI.parse(effective_url).host.sub(/^api\./, '') rescue 'featureparity.dev'
63
70
 
64
71
  puts "Get your API key from: https://#{domain}"
65
- puts "(Workspace Settings → API Keys → Mint Key)"
72
+ puts "(Project Settings → API Keys → Mint Key)"
66
73
  puts
67
74
  print "Paste your API key (starts with fp_): "
68
75
  $stdout.flush
@@ -78,10 +85,17 @@ module Fp
78
85
  end
79
86
 
80
87
  def resolve_profile_name(opts)
88
+ # --profile / FP_PROFILE may be consumed by the global parser; honor it
89
+ # via config, but only when explicitly provided (not the implicit
90
+ # "default" profile fallback) so interactive setup still prompts.
81
91
  if opts[:profile]
82
92
  return opts[:profile]
83
93
  end
84
94
 
95
+ if config && %w[flag env\ (FP_PROFILE)].include?(config.profile_source)
96
+ return config.profile_name
97
+ end
98
+
85
99
  if opts[:non_interactive]
86
100
  return 'default'
87
101
  end
@@ -93,6 +107,31 @@ module Fp
93
107
  name.nil? || name.empty? ? 'default' : name
94
108
  end
95
109
 
110
+ # Resolve the API URL for setup. Priority: --api-url flag > global setting.
111
+ # When neither is set and we're interactive, prompt the user so they can
112
+ # target a non-default environment (self-hosted, staging, ephemeral PR env)
113
+ # instead of silently defaulting to production.
114
+ def resolve_api_url(opts)
115
+ return opts[:api_url] if opts[:api_url] && !opts[:api_url].empty?
116
+
117
+ # The global CLI parser consumes --api-url before setup runs; honor it.
118
+ flag_url = config&.explicit_api_url
119
+ return flag_url if flag_url
120
+
121
+ existing = Config.get_setting('api_url')
122
+ return existing if existing && !existing.empty?
123
+
124
+ # Non-interactive: fall back to the default (nil => DEFAULT_API_URL).
125
+ return nil if opts[:non_interactive]
126
+
127
+ default = Config::DEFAULT_API_URL
128
+ print "API URL (default: #{default}): "
129
+ $stdout.flush
130
+ url = $stdin.gets&.strip
131
+
132
+ url.nil? || url.empty? ? nil : url
133
+ end
134
+
96
135
  def validate_key_format!(key)
97
136
  unless key.start_with?('fp_')
98
137
  output.error("API key must start with 'fp_' prefix. Got: #{key[0..3]}...")
@@ -107,7 +146,7 @@ module Fp
107
146
 
108
147
  def validate_key!(api_key, api_url)
109
148
  url = (api_url || Config::DEFAULT_API_URL).chomp('/')
110
- uri = URI.parse("#{url}/api/workspaces")
149
+ uri = URI.parse("#{url}/api/projects")
111
150
 
112
151
  http = Net::HTTP.new(uri.host, uri.port)
113
152
  http.use_ssl = uri.scheme == 'https'
@@ -146,8 +185,8 @@ module Fp
146
185
  exit 1
147
186
  end
148
187
 
149
- def save_profile!(name, api_key, api_url)
150
- Config.save_profile(name, api_key: api_key, api_url: api_url)
188
+ def save_profile!(name, api_key, api_url, environment = nil)
189
+ Config.save_profile(name, api_key: api_key, api_url: api_url, environment: environment)
151
190
  puts " ✓ Profile '#{name}' saved to ~/.config/fp/config.yml"
152
191
  end
153
192
  end
@@ -15,8 +15,8 @@ module Fp
15
15
  exit 1
16
16
  end
17
17
 
18
- workspace_id = resolve_workspace_id(opts[:project])
19
- result = client.find_requirement_by_slug(workspace_id, slug)
18
+ project_id = resolve_project_id(opts[:project])
19
+ result = client.find_requirement_by_slug(project_id, slug)
20
20
 
21
21
  unless result[:ok]
22
22
  output.error(result[:error], status: result[:status])
@@ -38,6 +38,7 @@ module Fp
38
38
  puts
39
39
  puts "Slug: #{req['slug']}"
40
40
  puts "Status: #{req['status']}"
41
+ puts "Category: #{req['category'] || '(none)'}"
41
42
  puts "Parent: #{req['parent_id'] || '(none)'}"
42
43
  puts
43
44
 
@@ -8,21 +8,21 @@ module Fp
8
8
  opts, = parse_flags(args)
9
9
  require_flag(opts, :project)
10
10
 
11
- workspace_id = resolve_workspace_id(opts[:project])
12
- result = client.get_project(workspace_id)
11
+ project_id = resolve_project_id(opts[:project])
12
+ result = client.list_surfaces(project_id)
13
13
 
14
14
  unless result[:ok]
15
15
  output.error(result[:error], status: result[:status])
16
16
  exit 1
17
17
  end
18
18
 
19
- workspace = result[:data]['workspace']
20
- surfaces = workspace['available_surfaces'] || []
19
+ surface_records = result[:data]['surfaces'] || []
20
+ surfaces = surface_records.map { |s| s['key'] }
21
21
 
22
22
  output.success({ surfaces: surfaces, project: opts[:project] }) do
23
23
  if surfaces.empty?
24
24
  puts "No surfaces configured for project '#{opts[:project]}'."
25
- puts 'Configure available_surfaces in the web app.'
25
+ puts 'Configure surfaces in the web app.'
26
26
  else
27
27
  puts "Surfaces for '#{opts[:project]}':"
28
28
  surfaces.each { |s| puts " - #{s}" }
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp whoami
6
+ #
7
+ # Show the *resolved* configuration the CLI would actually use for this
8
+ # invocation: which profile is active, the API URL and where it came from,
9
+ # the environment that URL targets, and whether an API key is present
10
+ # (masked — never the full token). Purely local; makes no API calls, so it
11
+ # works even without a valid key. Honors --profile / --api-url / --json.
12
+ class Whoami < Base
13
+ def run(_args)
14
+ r = config.resolved
15
+
16
+ output.success(r) do
17
+ puts 'Resolved FeatureParity CLI configuration:'
18
+ puts
19
+ puts " Profile: #{format_value(r[:profile], r[:profile_source])}"
20
+ puts " Environment: #{r[:environment] || '(unknown)'}"
21
+ puts " API URL: #{format_value(r[:api_url], r[:api_url_source])}"
22
+ puts " API key: #{key_line(r)}"
23
+ puts " Config file: #{r[:config_file]}"
24
+
25
+ if r[:profile_environment] && r[:profile_environment] != r[:environment]
26
+ puts
27
+ puts " ⚠ Profile is tagged for environment '#{r[:profile_environment]}' " \
28
+ "but the resolved API URL points at '#{r[:environment]}'."
29
+ end
30
+
31
+ unless r[:authenticated]
32
+ puts
33
+ puts 'No API key resolved. Set one with:'
34
+ puts ' export FP_API_KEY=fp_...'
35
+ puts ' fp setup'
36
+ puts ' fp profile add <name> --api-key fp_...'
37
+ end
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def format_value(value, source)
44
+ display = value.nil? || value.to_s.empty? ? '(none)' : value
45
+ source && source != 'none' ? "#{display} [from #{source}]" : display.to_s
46
+ end
47
+
48
+ def key_line(r)
49
+ return '(not set)' unless r[:api_key_present]
50
+
51
+ "#{r[:api_key_hint]} [from #{r[:api_key_source]}]"
52
+ end
53
+ end
54
+ end
55
+ end
data/lib/fp/commands.rb CHANGED
@@ -10,7 +10,9 @@ require_relative 'commands/report'
10
10
  require_relative 'commands/matrix'
11
11
  require_relative 'commands/profile'
12
12
  require_relative 'commands/config_cmd'
13
+ require_relative 'commands/repos'
13
14
  require_relative 'commands/setup'
15
+ require_relative 'commands/whoami'
14
16
  require_relative 'commands/version'
15
17
  require_relative 'commands/help'
16
18