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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Fp
4
4
  module Commands
5
- # fp propose --project <slug> --slug X --name "..." --why "..." --required a,b,c --acceptance "..."
5
+ # fp propose --project <slug> --slug X --name "..." --why "..." --required a,b,c --acceptance "..." --parent <slug> --category <cat>
6
6
  # Create a new requirement as draft
7
7
  class Propose < Base
8
8
  KNOWN_FLAGS = {
@@ -11,9 +11,13 @@ module Fp
11
11
  name: :string,
12
12
  why: :string,
13
13
  required: :string,
14
- acceptance: :string
14
+ acceptance: :string,
15
+ parent: :string,
16
+ category: :string
15
17
  }.freeze
16
18
 
19
+ VALID_CATEGORIES = %w[functional non-functional ux performance security compliance].freeze
20
+
17
21
  def run(args)
18
22
  opts, = parse_flags(args, KNOWN_FLAGS)
19
23
 
@@ -21,7 +25,13 @@ module Fp
21
25
  require_flag(opts, :slug, '--slug is required (immutable identifier for the requirement)')
22
26
  require_flag(opts, :name, '--name is required (human-readable title)')
23
27
 
24
- workspace_id = resolve_workspace_id(opts[:project])
28
+ # Validate category client-side for better UX (fail fast with helpful message)
29
+ if opts[:category] && !VALID_CATEGORIES.include?(opts[:category])
30
+ output.error("Invalid category '#{opts[:category]}'. Must be one of: #{VALID_CATEGORIES.join(', ')}")
31
+ exit 1
32
+ end
33
+
34
+ project_id = resolve_project_id(opts[:project])
25
35
 
26
36
  # Parse required surfaces (comma-separated)
27
37
  required_surfaces = if opts[:required]
@@ -33,11 +43,19 @@ module Fp
33
43
  params = {
34
44
  slug: opts[:slug],
35
45
  title: opts[:name],
36
- workspace_id: workspace_id,
46
+ project_id: project_id,
37
47
  required_surfaces: required_surfaces
38
48
  }
39
49
  params[:why] = opts[:why] if opts[:why]
40
50
  params[:acceptance] = opts[:acceptance] if opts[:acceptance]
51
+ params[:category] = opts[:category] if opts[:category]
52
+
53
+ # Resolve parent slug to parent_id if provided
54
+ if opts[:parent]
55
+ parent_id = resolve_parent_id(project_id, opts[:parent])
56
+ params[:parent_id] = parent_id
57
+ end
58
+
41
59
  # Note: status is NOT sent - the API enforces draft for agents
42
60
 
43
61
  result = client.create_requirement(params)
@@ -54,13 +72,36 @@ module Fp
54
72
  puts
55
73
  puts " Title: #{requirement['title']}"
56
74
  puts " Status: #{requirement['status']}"
75
+ puts " Category: #{requirement['category'] || '(none)'}"
57
76
  puts " Surfaces: #{(requirement['required_surfaces'] || []).join(', ')}"
77
+ puts " Parent: #{opts[:parent] || '(none)'}" if opts[:parent]
58
78
  puts
59
79
  puts '⚠️ This requirement is a DRAFT.'
60
80
  puts ' A human must activate it in the web app before it appears in the parity matrix.'
61
81
  puts ' Agents cannot activate requirements.'
62
82
  end
63
83
  end
84
+
85
+ private
86
+
87
+ # Resolve a parent requirement slug to its ID
88
+ def resolve_parent_id(project_id, parent_slug)
89
+ result = client.list_requirements(project_id: project_id)
90
+ unless result[:ok]
91
+ output.error("Failed to resolve parent slug '#{parent_slug}': #{result[:error]}")
92
+ exit 1
93
+ end
94
+
95
+ requirements = result[:data]['requirements'] || []
96
+ parent = requirements.find { |r| r['slug'] == parent_slug }
97
+
98
+ unless parent
99
+ output.error("Parent requirement '#{parent_slug}' not found in this project")
100
+ exit 1
101
+ end
102
+
103
+ parent['id']
104
+ end
64
105
  end
65
106
  end
66
107
  end
@@ -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
@@ -22,13 +22,16 @@ module Fp
22
22
 
23
23
  api_key = resolve_api_key(opts)
24
24
  profile_name = resolve_profile_name(opts)
25
- api_url = opts[:api_url]
25
+ api_url = opts[:api_url] || Config.get_setting('api_url')
26
26
 
27
27
  puts "Validating API key..."
28
28
  validate_key!(api_key, api_url)
29
29
 
30
30
  save_profile!(profile_name, api_key, api_url)
31
31
 
32
+ effective_url = api_url || Config::DEFAULT_API_URL
33
+ domain = URI.parse(effective_url).host.sub(/^api\./, '') rescue 'featureparity.dev'
34
+
32
35
  puts
33
36
  puts "✅ Setup complete!"
34
37
  puts
@@ -54,8 +57,12 @@ module Fp
54
57
  exit 1
55
58
  end
56
59
 
57
- puts "Get your API key from: https://featureparity.dev"
58
- puts "(Workspace Settings API Keys → Mint Key)"
60
+ api_url = opts[:api_url] || Config.get_setting('api_url')
61
+ effective_url = api_url || Config::DEFAULT_API_URL
62
+ domain = URI.parse(effective_url).host.sub(/^api\./, '') rescue 'featureparity.dev'
63
+
64
+ puts "Get your API key from: https://#{domain}"
65
+ puts "(Project Settings → API Keys → Mint Key)"
59
66
  puts
60
67
  print "Paste your API key (starts with fp_): "
61
68
  $stdout.flush
@@ -100,7 +107,7 @@ module Fp
100
107
 
101
108
  def validate_key!(api_key, api_url)
102
109
  url = (api_url || Config::DEFAULT_API_URL).chomp('/')
103
- uri = URI.parse("#{url}/api/workspaces")
110
+ uri = URI.parse("#{url}/api/projects")
104
111
 
105
112
  http = Net::HTTP.new(uri.host, uri.port)
106
113
  http.use_ssl = uri.scheme == 'https'
@@ -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,8 @@ module Fp
38
38
  puts
39
39
  puts "Slug: #{req['slug']}"
40
40
  puts "Status: #{req['status']}"
41
+ puts "Category: #{req['category'] || '(none)'}"
42
+ puts "Parent: #{req['parent_id'] || '(none)'}"
41
43
  puts
42
44
 
43
45
  surfaces = req['required_surfaces'] || []
@@ -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}" }
data/lib/fp/commands.rb CHANGED
@@ -9,6 +9,8 @@ require_relative 'commands/propose'
9
9
  require_relative 'commands/report'
10
10
  require_relative 'commands/matrix'
11
11
  require_relative 'commands/profile'
12
+ require_relative 'commands/config_cmd'
13
+ require_relative 'commands/repos'
12
14
  require_relative 'commands/setup'
13
15
  require_relative 'commands/version'
14
16
  require_relative 'commands/help'