featureparity 0.0.1

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.
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp profile add|list
6
+ # Manage named profiles for API authentication
7
+ class Profile < Base
8
+ KNOWN_FLAGS = {
9
+ api_key: :string,
10
+ api_url: :string
11
+ }.freeze
12
+
13
+ def run(args)
14
+ opts, positional = parse_flags(args, KNOWN_FLAGS)
15
+ subcommand = positional.first
16
+
17
+ case subcommand
18
+ when 'add'
19
+ add_profile(positional[1], opts)
20
+ when 'list'
21
+ list_profiles
22
+ else
23
+ show_usage
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def add_profile(name, opts)
30
+ unless name
31
+ output.error('Profile name is required. Usage: fp profile add <name> --api-key fp_...')
32
+ exit 1
33
+ end
34
+
35
+ unless opts[:api_key]
36
+ output.error('--api-key is required')
37
+ exit 1
38
+ end
39
+
40
+ unless opts[:api_key].start_with?('fp_')
41
+ output.error("API key must start with 'fp_' prefix")
42
+ exit 1
43
+ end
44
+
45
+ Config.save_profile(name, api_key: opts[:api_key], api_url: opts[:api_url])
46
+
47
+ output.success({ profile: name, api_url: opts[:api_url] || Config::DEFAULT_API_URL }) do
48
+ puts "Profile '#{name}' saved."
49
+ puts " API URL: #{opts[:api_url] || Config::DEFAULT_API_URL}"
50
+ puts
51
+ puts 'Use this profile with:'
52
+ puts " fp --profile #{name} projects"
53
+ puts " FP_PROFILE=#{name} fp projects"
54
+ end
55
+ end
56
+
57
+ def list_profiles
58
+ profiles = Config.list_profiles
59
+
60
+ if profiles.empty?
61
+ output.success({ profiles: [] }) do
62
+ puts 'No profiles configured.'
63
+ puts
64
+ puts 'Add a profile with:'
65
+ puts ' fp profile add <name> --api-key fp_...'
66
+ end
67
+ else
68
+ output.success({ profiles: profiles }) do
69
+ puts 'Configured profiles:'
70
+ profiles.each { |name| puts " - #{name}" }
71
+ puts
72
+ puts 'Note: API keys are never displayed. Use --profile <name> to select.'
73
+ end
74
+ end
75
+ end
76
+
77
+ def show_usage
78
+ output.error('Usage: fp profile <add|list>')
79
+ puts
80
+ puts 'Commands:'
81
+ puts ' fp profile add <name> --api-key fp_... Add a named profile'
82
+ puts ' fp profile list List configured profiles'
83
+ exit 1
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp projects - list projects/workspaces the API key has access to
6
+ class Projects < Base
7
+ def run(_args)
8
+ result = client.list_projects
9
+
10
+ unless result[:ok]
11
+ output.error(result[:error], status: result[:status])
12
+ exit 1
13
+ end
14
+
15
+ workspaces = result[:data]['workspaces'] || []
16
+
17
+ output.success(result[:data]) do
18
+ if workspaces.empty?
19
+ puts 'No projects found.'
20
+ else
21
+ headers = %w[SLUG NAME SURFACES]
22
+ rows = workspaces.map do |w|
23
+ surfaces = (w['available_surfaces'] || []).join(', ')
24
+ [w['slug'], w['name'], surfaces.empty? ? '-' : surfaces]
25
+ end
26
+ output.table(headers, rows)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp propose --project <slug> --slug X --name "..." --why "..." --required a,b,c --acceptance "..."
6
+ # Create a new requirement as draft
7
+ class Propose < Base
8
+ KNOWN_FLAGS = {
9
+ project: :string,
10
+ slug: :string,
11
+ name: :string,
12
+ why: :string,
13
+ required: :string,
14
+ acceptance: :string
15
+ }.freeze
16
+
17
+ def run(args)
18
+ opts, = parse_flags(args, KNOWN_FLAGS)
19
+
20
+ require_flag(opts, :project)
21
+ require_flag(opts, :slug, '--slug is required (immutable identifier for the requirement)')
22
+ require_flag(opts, :name, '--name is required (human-readable title)')
23
+
24
+ workspace_id = resolve_workspace_id(opts[:project])
25
+
26
+ # Parse required surfaces (comma-separated)
27
+ required_surfaces = if opts[:required]
28
+ opts[:required].split(',').map(&:strip)
29
+ else
30
+ []
31
+ end
32
+
33
+ params = {
34
+ slug: opts[:slug],
35
+ title: opts[:name],
36
+ workspace_id: workspace_id,
37
+ required_surfaces: required_surfaces
38
+ }
39
+ params[:why] = opts[:why] if opts[:why]
40
+ params[:acceptance] = opts[:acceptance] if opts[:acceptance]
41
+ # Note: status is NOT sent - the API enforces draft for agents
42
+
43
+ result = client.create_requirement(params)
44
+
45
+ unless result[:ok]
46
+ output.error(result[:error], status: result[:status])
47
+ exit 1
48
+ end
49
+
50
+ requirement = result[:data]['requirement']
51
+
52
+ output.success(result[:data]) do
53
+ puts "Created requirement '#{requirement['slug']}' as DRAFT."
54
+ puts
55
+ puts " Title: #{requirement['title']}"
56
+ puts " Status: #{requirement['status']}"
57
+ puts " Surfaces: #{(requirement['required_surfaces'] || []).join(', ')}"
58
+ puts
59
+ puts '⚠️ This requirement is a DRAFT.'
60
+ puts ' A human must activate it in the web app before it appears in the parity matrix.'
61
+ puts ' Agents cannot activate requirements.'
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
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
8
+ class Report < Base
9
+ KNOWN_FLAGS = {
10
+ project: :string,
11
+ surface: :string,
12
+ file: :string,
13
+ repo: :string,
14
+ pr: :string,
15
+ sha: :string,
16
+ work_item: :string,
17
+ ci_url: :string,
18
+ title: :string,
19
+ state: :string
20
+ }.freeze
21
+
22
+ VALID_STATES = %w[present stub].freeze
23
+
24
+ def run(args)
25
+ opts, positional = parse_flags(args, KNOWN_FLAGS)
26
+
27
+ slug = positional.first
28
+ unless slug
29
+ output.error('Requirement slug is required. Usage: fp report <slug> --project <project> --surface <surface> --file <path> --repo <org/repo>')
30
+ exit 1
31
+ end
32
+
33
+ require_flag(opts, :project)
34
+ require_flag(opts, :surface)
35
+ require_flag(opts, :file)
36
+ require_flag(opts, :repo)
37
+
38
+ # Validate state
39
+ state = opts[:state] || 'present'
40
+ unless VALID_STATES.include?(state)
41
+ output.error("Invalid state '#{state}'. Must be one of: #{VALID_STATES.join(', ')}")
42
+ output.error('Note: Agents report present or stub. Only CI (#1222) can report passing or failing.')
43
+ exit 1
44
+ end
45
+
46
+ workspace_id = resolve_workspace_id(opts[:project])
47
+
48
+ # Find the requirement by slug
49
+ req_result = client.find_requirement_by_slug(workspace_id, slug)
50
+ unless req_result[:ok]
51
+ output.error(req_result[:error], status: req_result[:status])
52
+ exit 1
53
+ end
54
+
55
+ requirement = req_result[:data]['requirement']
56
+
57
+ # Build evidence payload
58
+ params = {
59
+ requirement_id: requirement['id'],
60
+ surface: opts[:surface],
61
+ file: opts[:file],
62
+ repo: opts[:repo],
63
+ state: state
64
+ }
65
+ params[:pr_url] = opts[:pr] if opts[:pr]
66
+ params[:sha] = opts[:sha] if opts[:sha]
67
+ params[:work_item_url] = opts[:work_item] if opts[:work_item]
68
+ params[:ci_url] = opts[:ci_url] if opts[:ci_url]
69
+ params[:example_title] = opts[:title] if opts[:title]
70
+
71
+ # Try to report evidence
72
+ result = client.report_evidence(params)
73
+
74
+ # Evidence API may not exist yet (#1214)
75
+ if !result[:ok] && result[:status] == 404
76
+ output.success(params, summary: nil) do
77
+ puts "Evidence for '#{slug}' on surface '#{opts[:surface]}':"
78
+ puts " State: #{state}"
79
+ puts " File: #{opts[:file]}"
80
+ puts " Repo: #{opts[:repo]}"
81
+ puts " PR: #{opts[:pr]}" if opts[:pr]
82
+ puts " SHA: #{opts[:sha]}" if opts[:sha]
83
+ puts " Work Item: #{opts[:work_item]}" if opts[:work_item]
84
+ puts " CI URL: #{opts[:ci_url]}" if opts[:ci_url]
85
+ puts " Title: #{opts[:title]}" if opts[:title]
86
+ puts
87
+ puts '⚠️ Evidence API not available yet. This will be recorded when #1214 is complete.'
88
+ end
89
+ return
90
+ end
91
+
92
+ unless result[:ok]
93
+ output.error(result[:error], status: result[:status])
94
+ exit 1
95
+ end
96
+
97
+ output.success(result[:data]) do
98
+ puts "Evidence reported for '#{slug}' on surface '#{opts[:surface]}'."
99
+ puts " State: #{state}"
100
+ puts " File: #{opts[:file]}"
101
+ if opts[:pr]
102
+ puts " PR: #{opts[:pr]}"
103
+ end
104
+ if opts[:work_item]
105
+ puts " Work: #{opts[:work_item]}"
106
+ end
107
+ if opts[:ci_url]
108
+ puts " CI: #{opts[:ci_url]}"
109
+ end
110
+ if opts[:title]
111
+ puts " Title: #{opts[:title]}"
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp setup
6
+ # Interactive guided setup for new users.
7
+ # Validates the API key, checks connectivity, and saves a profile.
8
+ class Setup < Base
9
+ KNOWN_FLAGS = {
10
+ api_key: :string,
11
+ profile: :string,
12
+ api_url: :string,
13
+ non_interactive: :boolean
14
+ }.freeze
15
+
16
+ def run(args)
17
+ opts, _positional = parse_flags(args, KNOWN_FLAGS)
18
+
19
+ puts "🔧 FeatureParity CLI Setup"
20
+ puts "=" * 40
21
+ puts
22
+
23
+ api_key = resolve_api_key(opts)
24
+ profile_name = resolve_profile_name(opts)
25
+ api_url = opts[:api_url]
26
+
27
+ puts "Validating API key..."
28
+ validate_key!(api_key, api_url)
29
+
30
+ save_profile!(profile_name, api_key, api_url)
31
+
32
+ puts
33
+ puts "✅ Setup complete!"
34
+ puts
35
+ puts "Your profile '#{profile_name}' is ready. Usage:"
36
+ puts
37
+ puts " fp --profile #{profile_name} projects"
38
+ puts " FP_PROFILE=#{profile_name} fp projects"
39
+ puts
40
+ puts "Or set FP_API_KEY to skip profiles:"
41
+ puts " export FP_API_KEY=#{api_key[0..6]}..."
42
+ end
43
+
44
+ private
45
+
46
+ def resolve_api_key(opts)
47
+ if opts[:api_key]
48
+ validate_key_format!(opts[:api_key])
49
+ return opts[:api_key]
50
+ end
51
+
52
+ if opts[:non_interactive]
53
+ output.error("--api-key is required in non-interactive mode")
54
+ exit 1
55
+ end
56
+
57
+ puts "Get your API key from: https://featureparity.dev"
58
+ puts "(Workspace Settings → API Keys → Mint Key)"
59
+ puts
60
+ print "Paste your API key (starts with fp_): "
61
+ $stdout.flush
62
+ key = $stdin.gets&.strip
63
+
64
+ if key.nil? || key.empty?
65
+ output.error("No API key provided")
66
+ exit 1
67
+ end
68
+
69
+ validate_key_format!(key)
70
+ key
71
+ end
72
+
73
+ def resolve_profile_name(opts)
74
+ if opts[:profile]
75
+ return opts[:profile]
76
+ end
77
+
78
+ if opts[:non_interactive]
79
+ return 'default'
80
+ end
81
+
82
+ print "Profile name (default: 'default'): "
83
+ $stdout.flush
84
+ name = $stdin.gets&.strip
85
+
86
+ name.nil? || name.empty? ? 'default' : name
87
+ end
88
+
89
+ def validate_key_format!(key)
90
+ unless key.start_with?('fp_')
91
+ output.error("API key must start with 'fp_' prefix. Got: #{key[0..3]}...")
92
+ exit 1
93
+ end
94
+
95
+ if key.length < 10
96
+ output.error("API key looks too short. Check that you copied the full key.")
97
+ exit 1
98
+ end
99
+ end
100
+
101
+ def validate_key!(api_key, api_url)
102
+ url = (api_url || Config::DEFAULT_API_URL).chomp('/')
103
+ uri = URI.parse("#{url}/api/workspaces")
104
+
105
+ http = Net::HTTP.new(uri.host, uri.port)
106
+ http.use_ssl = uri.scheme == 'https'
107
+ http.open_timeout = 10
108
+ http.read_timeout = 10
109
+
110
+ request = Net::HTTP::Get.new(uri)
111
+ request['Authorization'] = "Bearer #{api_key}"
112
+ request['Accept'] = 'application/json'
113
+ request['User-Agent'] = "fp-cli/#{Fp::VERSION}"
114
+
115
+ response = http.request(request)
116
+ status = response.code.to_i
117
+
118
+ if status == 401 || status == 403
119
+ output.error("API key is invalid or expired (HTTP #{status}). Mint a new one at https://featureparity.dev")
120
+ exit 1
121
+ end
122
+
123
+ if status >= 500
124
+ output.error("API server error (HTTP #{status}). Try again later.")
125
+ exit 1
126
+ end
127
+
128
+ unless status >= 200 && status < 300
129
+ output.error("Unexpected response (HTTP #{status}). Check your API URL.")
130
+ exit 1
131
+ end
132
+
133
+ puts " ✓ API key is valid"
134
+ rescue Net::OpenTimeout, Net::ReadTimeout
135
+ output.error("Connection timed out. Check your network and API URL.")
136
+ exit 1
137
+ rescue SocketError, Errno::ECONNREFUSED => e
138
+ output.error("Cannot connect to API: #{e.message}")
139
+ exit 1
140
+ end
141
+
142
+ def save_profile!(name, api_key, api_url)
143
+ Config.save_profile(name, api_key: api_key, api_url: api_url)
144
+ puts " ✓ Profile '#{name}' saved to ~/.config/fp/config.yml"
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp show <slug> --project <slug>
6
+ # Show requirement details including evidence receipts
7
+ class Show < Base
8
+ def run(args)
9
+ opts, positional = parse_flags(args)
10
+ require_flag(opts, :project)
11
+
12
+ slug = positional.first
13
+ unless slug
14
+ output.error('Requirement slug is required. Usage: fp show <slug> --project <project>')
15
+ exit 1
16
+ end
17
+
18
+ workspace_id = resolve_workspace_id(opts[:project])
19
+ result = client.find_requirement_by_slug(workspace_id, slug)
20
+
21
+ unless result[:ok]
22
+ output.error(result[:error], status: result[:status])
23
+ exit 1
24
+ end
25
+
26
+ requirement = result[:data]['requirement']
27
+
28
+ output.success(result[:data]) do
29
+ print_requirement(requirement)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def print_requirement(req)
36
+ puts "#{req['title']}"
37
+ puts "=" * req['title'].length
38
+ puts
39
+ puts "Slug: #{req['slug']}"
40
+ puts "Status: #{req['status']}"
41
+ puts
42
+
43
+ surfaces = req['required_surfaces'] || []
44
+ puts 'Required Surfaces:'
45
+ if surfaces.empty?
46
+ puts ' (none)'
47
+ else
48
+ surfaces.each { |s| puts " - #{s}" }
49
+ end
50
+ puts
51
+
52
+ if req['why'] && !req['why'].empty?
53
+ puts 'Why:'
54
+ puts " #{req['why']}"
55
+ puts
56
+ end
57
+
58
+ if req['acceptance'] && !req['acceptance'].empty?
59
+ puts 'Acceptance Criteria:'
60
+ puts " #{req['acceptance']}"
61
+ puts
62
+ end
63
+
64
+ # Evidence receipts (will be populated when Evidence model exists)
65
+ evidence = req['evidence'] || []
66
+ if evidence.any?
67
+ puts 'Evidence:'
68
+ evidence.each do |e|
69
+ puts " #{e['surface']}:"
70
+ puts " Status: #{e['status']}"
71
+ puts " File: #{e['file']}" if e['file']
72
+ puts " Repo: #{e['repo']}" if e['repo']
73
+ puts " PR: #{e['pr_url']}" if e['pr_url']
74
+ puts " Work: #{e['work_item_url']}" if e['work_item_url']
75
+ puts " CI: #{e['ci_url']}" if e['ci_url']
76
+ puts " Title: #{e['example_title']}" if e['example_title']
77
+ puts " By: #{e['reported_by']}" if e['reported_by']
78
+ puts
79
+ end
80
+ else
81
+ puts 'Evidence:'
82
+ puts ' (none reported)'
83
+ puts
84
+ end
85
+
86
+ puts "Created: #{req['created_at']}"
87
+ puts "Updated: #{req['updated_at']}"
88
+ puts "By: #{req['created_by']}" if req['created_by']
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp surfaces --project <slug> - list available surfaces for a project
6
+ class Surfaces < Base
7
+ def run(args)
8
+ opts, = parse_flags(args)
9
+ require_flag(opts, :project)
10
+
11
+ workspace_id = resolve_workspace_id(opts[:project])
12
+ result = client.get_project(workspace_id)
13
+
14
+ unless result[:ok]
15
+ output.error(result[:error], status: result[:status])
16
+ exit 1
17
+ end
18
+
19
+ workspace = result[:data]['workspace']
20
+ surfaces = workspace['available_surfaces'] || []
21
+
22
+ output.success({ surfaces: surfaces, project: opts[:project] }) do
23
+ if surfaces.empty?
24
+ puts "No surfaces configured for project '#{opts[:project]}'."
25
+ puts 'Configure available_surfaces in the web app.'
26
+ else
27
+ puts "Surfaces for '#{opts[:project]}':"
28
+ surfaces.each { |s| puts " - #{s}" }
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp version
6
+ class Version < Base
7
+ def run(_args)
8
+ output.success({ version: Fp::VERSION }) do
9
+ puts "fp #{Fp::VERSION}"
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'commands/base'
4
+ require_relative 'commands/projects'
5
+ require_relative 'commands/surfaces'
6
+ require_relative 'commands/list'
7
+ require_relative 'commands/show'
8
+ require_relative 'commands/propose'
9
+ require_relative 'commands/report'
10
+ require_relative 'commands/matrix'
11
+ require_relative 'commands/profile'
12
+ require_relative 'commands/setup'
13
+ require_relative 'commands/version'
14
+ require_relative 'commands/help'
15
+
16
+ module Fp
17
+ module Commands
18
+ end
19
+ end