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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 695b5f522e34a1e886e07177cbbe5ca87b515caf6845e15b50ca9e1c4fb3735e
4
+ data.tar.gz: 18ff57dfb177341dd4a6f2d8dc05e862a53d8e24eb0c2da4172e022986533cc1
5
+ SHA512:
6
+ metadata.gz: 9851ef3ea41669276c356fbe2e5a200ec06d59cea82a9d852eaca71a20297319b4eeb32e5a684921cfba3b298bf1e8931f45bc89ad0c59d802128a5f5940b157
7
+ data.tar.gz: 1e075a99c1b02368908639649ff45ba78be33f8e0a03756b0f7782275758f326e01992488508c6930d1f5ac65e47eb6ef7b5e24438482e6eac2b855fe06b2557
data/bin/fp ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/fp'
5
+
6
+ Fp::CLI.run(ARGV)
data/lib/fp/cli.rb ADDED
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ # Main CLI entry point - parses global flags and dispatches to commands
5
+ class CLI
6
+ COMMANDS = {
7
+ 'projects' => Commands::Projects,
8
+ 'surfaces' => Commands::Surfaces,
9
+ 'list' => Commands::List,
10
+ 'show' => Commands::Show,
11
+ 'propose' => Commands::Propose,
12
+ 'report' => Commands::Report,
13
+ 'matrix' => Commands::Matrix,
14
+ 'profile' => Commands::Profile,
15
+ 'setup' => Commands::Setup,
16
+ 'version' => Commands::Version,
17
+ 'help' => Commands::Help
18
+ }.freeze
19
+
20
+ class << self
21
+ def run(argv)
22
+ args = argv.dup
23
+ global_opts = extract_global_options(args)
24
+
25
+ command_name = args.shift || 'help'
26
+ command_class = COMMANDS[command_name]
27
+
28
+ unless command_class
29
+ output = Output.new(json: global_opts[:json])
30
+ output.error("Unknown command: #{command_name}")
31
+ puts "\nRun 'fp help' for usage."
32
+ exit 1
33
+ end
34
+
35
+ # Profile commands don't need auth
36
+ needs_auth = !%w[profile setup version help].include?(command_name)
37
+
38
+ if needs_auth
39
+ config = Config.new(profile: global_opts[:profile])
40
+ unless config.valid?
41
+ output = Output.new(json: global_opts[:json])
42
+ output.error(config.validation_error.strip)
43
+ exit 1
44
+ end
45
+ client = Client.new(config)
46
+ end
47
+
48
+ output = Output.new(json: global_opts[:json])
49
+ command = command_class.new(client: client, output: output, config: config)
50
+ command.run(args)
51
+ rescue Interrupt
52
+ warn "\nAborted."
53
+ exit 130
54
+ rescue StandardError => e
55
+ output = Output.new(json: global_opts&.dig(:json))
56
+ output.error(e.message)
57
+ exit 1
58
+ end
59
+
60
+ private
61
+
62
+ def extract_global_options(args)
63
+ opts = { json: false, profile: nil }
64
+
65
+ # Extract --json flag
66
+ if args.delete('--json')
67
+ opts[:json] = true
68
+ end
69
+
70
+ # Extract --profile flag
71
+ if (idx = args.index('--profile'))
72
+ args.delete_at(idx)
73
+ value = args[idx]
74
+ if value.nil? || value.start_with?('-')
75
+ $stderr.puts "Error: --profile requires a value"
76
+ exit 1
77
+ end
78
+ opts[:profile] = args.delete_at(idx)
79
+ end
80
+
81
+ # Also check short form -p
82
+ if (idx = args.index('-p'))
83
+ args.delete_at(idx)
84
+ value = args[idx]
85
+ if value.nil? || value.start_with?('-')
86
+ $stderr.puts "Error: -p requires a profile name"
87
+ exit 1
88
+ end
89
+ opts[:profile] = args.delete_at(idx)
90
+ end
91
+
92
+ opts
93
+ end
94
+ end
95
+ end
96
+ end
data/lib/fp/client.rb ADDED
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+
7
+ module Fp
8
+ # HTTP client for the FeatureParity API
9
+ class Client
10
+ def initialize(config)
11
+ @config = config
12
+ @base_url = config.api_url.chomp('/')
13
+ end
14
+
15
+ # GET /workspaces (called "projects" in the CLI)
16
+ def list_projects
17
+ get('/api/workspaces')
18
+ end
19
+
20
+ # GET /workspaces/:id
21
+ def get_project(id)
22
+ get("/api/workspaces/#{id}")
23
+ end
24
+
25
+ # GET /requirements
26
+ def list_requirements(workspace_id: nil, status: nil)
27
+ params = {}
28
+ params[:workspace_id] = workspace_id if workspace_id
29
+ params[:status] = status if status
30
+ get('/api/requirements', params)
31
+ end
32
+
33
+ # GET /requirements/:id
34
+ def get_requirement(id)
35
+ get("/api/requirements/#{id}")
36
+ end
37
+
38
+ # GET /requirements by slug within a workspace
39
+ def find_requirement_by_slug(workspace_id, slug)
40
+ # List all requirements for the workspace and find by slug
41
+ result = list_requirements(workspace_id: workspace_id)
42
+ return result unless result[:ok]
43
+
44
+ requirements = result[:data]['requirements'] || []
45
+ requirement = requirements.find { |r| r['slug'] == slug }
46
+
47
+ if requirement
48
+ { ok: true, data: { 'requirement' => requirement } }
49
+ else
50
+ { ok: false, error: "Requirement '#{slug}' not found in workspace", status: 404 }
51
+ end
52
+ end
53
+
54
+ # POST /requirements
55
+ def create_requirement(params)
56
+ post('/api/requirements', params)
57
+ end
58
+
59
+ # PUT /requirements/:id
60
+ def update_requirement(id, params)
61
+ put("/api/requirements/#{id}", params)
62
+ end
63
+
64
+ # POST /evidence (when API is ready)
65
+ def report_evidence(params)
66
+ post('/api/evidence', params)
67
+ end
68
+
69
+ # GET /matrix (when API is ready)
70
+ def get_matrix(workspace_id, format: :json)
71
+ path = format == :csv ? '/api/matrix.csv' : '/api/matrix'
72
+ get(path, { workspace_id: workspace_id })
73
+ end
74
+
75
+ # GET /gaps (when API is ready)
76
+ def get_gaps(workspace_id, surface: nil)
77
+ params = { workspace_id: workspace_id }
78
+ params[:surface] = surface if surface
79
+ get('/api/gaps', params)
80
+ end
81
+
82
+ private
83
+
84
+ def get(path, params = {})
85
+ uri = build_uri(path, params)
86
+ request = Net::HTTP::Get.new(uri)
87
+ execute(uri, request)
88
+ end
89
+
90
+ def post(path, body)
91
+ uri = build_uri(path)
92
+ request = Net::HTTP::Post.new(uri)
93
+ request.body = body.to_json
94
+ request['Content-Type'] = 'application/json'
95
+ execute(uri, request)
96
+ end
97
+
98
+ def put(path, body)
99
+ uri = build_uri(path)
100
+ request = Net::HTTP::Put.new(uri)
101
+ request.body = body.to_json
102
+ request['Content-Type'] = 'application/json'
103
+ execute(uri, request)
104
+ end
105
+
106
+ def build_uri(path, params = {})
107
+ uri = URI.parse("#{@base_url}#{path}")
108
+ uri.query = URI.encode_www_form(params) unless params.empty?
109
+ uri
110
+ end
111
+
112
+ def execute(uri, request)
113
+ request['Authorization'] = "Bearer #{@config.api_key}"
114
+ request['Accept'] = 'application/json'
115
+ request['User-Agent'] = "fp-cli/#{Fp::VERSION}"
116
+
117
+ http = Net::HTTP.new(uri.host, uri.port)
118
+ http.use_ssl = uri.scheme == 'https'
119
+ http.open_timeout = 10
120
+ http.read_timeout = 30
121
+
122
+ response = http.request(request)
123
+ parse_response(response)
124
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
125
+ { ok: false, error: "Request timed out: #{e.message}", status: 0 }
126
+ rescue SocketError, Errno::ECONNREFUSED => e
127
+ { ok: false, error: "Connection failed: #{e.message}", status: 0 }
128
+ rescue StandardError => e
129
+ { ok: false, error: "Request failed: #{e.message}", status: 0 }
130
+ end
131
+
132
+ def parse_response(response)
133
+ body = response.body
134
+ status = response.code.to_i
135
+
136
+ # Try to parse as JSON
137
+ data = JSON.parse(body) rescue nil
138
+
139
+ if status >= 200 && status < 300
140
+ { ok: true, data: data, status: status }
141
+ else
142
+ error_message = if data.is_a?(Hash)
143
+ data['error'] || data['message'] || "HTTP #{status}"
144
+ else
145
+ body.to_s.empty? ? "HTTP #{status}" : body.to_s[0..200]
146
+ end
147
+ { ok: false, error: error_message, status: status }
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # Base class for all commands
6
+ class Base
7
+ def initialize(client: nil, output: nil, config: nil)
8
+ @client = client
9
+ @output = output
10
+ @config = config
11
+ end
12
+
13
+ def run(_args)
14
+ raise NotImplementedError, "#{self.class}#run must be implemented"
15
+ end
16
+
17
+ protected
18
+
19
+ attr_reader :client, :output, :config
20
+
21
+ # Parse command-line flags into a hash
22
+ # Supports: --flag value, --flag=value, --boolean-flag
23
+ def parse_flags(args, known_flags = {})
24
+ opts = {}
25
+ positional = []
26
+ i = 0
27
+
28
+ while i < args.length
29
+ arg = args[i]
30
+
31
+ if arg.start_with?('--')
32
+ if arg.include?('=')
33
+ key, value = arg[2..].split('=', 2)
34
+ opts[key.tr('-', '_').to_sym] = value
35
+ elsif known_flags[arg[2..].tr('-', '_').to_sym] == :boolean
36
+ opts[arg[2..].tr('-', '_').to_sym] = true
37
+ elsif i + 1 < args.length && !args[i + 1].start_with?('--')
38
+ opts[arg[2..].tr('-', '_').to_sym] = args[i + 1]
39
+ i += 1
40
+ else
41
+ opts[arg[2..].tr('-', '_').to_sym] = true
42
+ end
43
+ else
44
+ positional << arg
45
+ end
46
+
47
+ i += 1
48
+ end
49
+
50
+ [opts, positional]
51
+ end
52
+
53
+ # Resolve workspace ID from --project flag (can be slug or ID)
54
+ def resolve_workspace_id(project_slug_or_id)
55
+ return nil unless project_slug_or_id
56
+
57
+ # First try to find by listing workspaces
58
+ result = client.list_projects
59
+ unless result[:ok]
60
+ output.error(result[:error], status: result[:status])
61
+ exit 1
62
+ end
63
+
64
+ workspaces = result[:data]['workspaces'] || []
65
+ workspace = workspaces.find { |w| w['slug'] == project_slug_or_id || w['id'] == project_slug_or_id }
66
+
67
+ unless workspace
68
+ output.error("Project '#{project_slug_or_id}' not found")
69
+ exit 1
70
+ end
71
+
72
+ workspace['id']
73
+ end
74
+
75
+ def require_flag(opts, flag, message = nil)
76
+ unless opts[flag]
77
+ message ||= "--#{flag.to_s.tr('_', '-')} is required"
78
+ output.error(message)
79
+ exit 1
80
+ end
81
+ end
82
+
83
+ def truncate(str, max)
84
+ return str if str.nil? || str.length <= max
85
+
86
+ str[0...max - 3] + '...'
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp help
6
+ class Help < Base
7
+ def run(_args)
8
+ puts <<~HELP
9
+ fp - FeatureParity CLI for agents
10
+
11
+ USAGE
12
+ fp <command> [flags]
13
+
14
+ COMMANDS
15
+ projects List projects you have access to
16
+ surfaces --project <slug> List surfaces for a project
17
+ list --project <slug> List active requirements
18
+ show <slug> --project <slug> Show requirement details
19
+ propose --project <slug> ... Propose a new requirement (as draft)
20
+ report <slug> --project <slug>... Report evidence for a requirement
21
+ matrix --project <slug> Show the parity matrix
22
+ profile add|list Manage named profiles
23
+ setup Interactive first-time setup
24
+ version Show version
25
+ help Show this help
26
+
27
+ GLOBAL FLAGS
28
+ --json Output as JSON envelope
29
+ --profile <name> Use a named profile
30
+ -p <name> Alias for --profile
31
+
32
+ AUTHENTICATION
33
+ Set FP_API_KEY environment variable, or use a profile:
34
+
35
+ export FP_API_KEY=fp_...
36
+ fp projects
37
+
38
+ Or run interactive setup (validates key and saves profile):
39
+
40
+ fp setup
41
+ fp setup --api-key fp_... --non-interactive
42
+
43
+ Or add a named profile manually:
44
+
45
+ fp profile add galen --api-key fp_...
46
+ fp --profile galen projects
47
+ FP_PROFILE=galen fp projects
48
+
49
+ If both FP_API_KEY and a profile are set, FP_API_KEY wins.
50
+
51
+ EXAMPLES
52
+ # First-time setup (interactive)
53
+ fp setup
54
+
55
+ # Non-interactive setup for CI/automation
56
+ fp setup --api-key fp_... --profile ci-agent --non-interactive
57
+
58
+ # List projects
59
+ fp projects
60
+
61
+ # List requirements for a project
62
+ fp list --project stowzilla
63
+
64
+ # Show gaps (requirements without evidence)
65
+ fp list --project stowzilla --gaps --surface customer_android
66
+
67
+ # Show a specific requirement
68
+ fp show print_container_qr --project stowzilla
69
+
70
+ # Propose a new requirement (creates as draft)
71
+ fp propose --project stowzilla \\
72
+ --slug print_container_qr \\
73
+ --name "Print QR code on container label" \\
74
+ --why "Customers scan to track containers" \\
75
+ --required api,customer_android,customer_ios \\
76
+ --acceptance "QR code is printed and scannable"
77
+
78
+ # Report evidence for a requirement
79
+ fp report print_container_qr --project stowzilla \\
80
+ --surface customer_android \\
81
+ --file app/src/test/java/PrintQrTest.kt \\
82
+ --repo stowzilla/customer-android \\
83
+ --pr https://github.com/stowzilla/customer-android/pull/123 \\
84
+ --work-item https://app.fizzy.do/123/cards/456 \\
85
+ --title "prints a QR code onto the container label"
86
+
87
+ # Show the parity matrix
88
+ fp matrix --project stowzilla
89
+
90
+ # Export matrix as CSV
91
+ fp matrix --project stowzilla --csv > matrix.csv
92
+
93
+ MARKER CONVENTION
94
+ Tests are bound to requirements using the fp:<slug> marker in the test file.
95
+ The CLI does not rewrite test files - you add the marker yourself.
96
+
97
+ Example (Ruby/RSpec):
98
+ # fp:print_container_qr
99
+ it 'prints a QR code onto the container label' do
100
+ ...
101
+ end
102
+
103
+ Example (Kotlin):
104
+ // fp:print_container_qr
105
+ @Test
106
+ fun `prints a QR code onto the container label`() {
107
+ ...
108
+ }
109
+
110
+ Example (Swift/XCTest):
111
+ // fp:print_container_qr
112
+ func testPrintsQRCodeOntoContainerLabel() {
113
+ ...
114
+ }
115
+
116
+ MORE INFORMATION
117
+ https://featureparity.dev/docs
118
+ HELP
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp list --project <slug> [--surface X] [--gaps] [--status draft]
6
+ # List requirements, optionally filtered
7
+ class List < Base
8
+ KNOWN_FLAGS = {
9
+ project: :string,
10
+ surface: :string,
11
+ status: :string,
12
+ gaps: :boolean
13
+ }.freeze
14
+
15
+ def run(args)
16
+ opts, = parse_flags(args, KNOWN_FLAGS)
17
+ require_flag(opts, :project)
18
+
19
+ workspace_id = resolve_workspace_id(opts[:project])
20
+
21
+ if opts[:gaps]
22
+ list_gaps(workspace_id, opts)
23
+ else
24
+ list_requirements(workspace_id, opts)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def list_requirements(workspace_id, opts)
31
+ result = client.list_requirements(workspace_id: workspace_id)
32
+
33
+ unless result[:ok]
34
+ output.error(result[:error], status: result[:status])
35
+ exit 1
36
+ end
37
+
38
+ requirements = result[:data]['requirements'] || []
39
+
40
+ # Filter by status (default: active only)
41
+ status_filter = opts[:status] || 'active'
42
+ requirements = requirements.select { |r| r['status'] == status_filter }
43
+
44
+ # Filter by surface if specified
45
+ if opts[:surface]
46
+ requirements = requirements.select do |r|
47
+ (r['required_surfaces'] || []).include?(opts[:surface])
48
+ end
49
+ end
50
+
51
+ output.success({ requirements: requirements, project: opts[:project] }) do
52
+ if requirements.empty?
53
+ puts "No #{status_filter} requirements found."
54
+ else
55
+ headers = %w[SLUG TITLE SURFACES STATUS]
56
+ rows = requirements.map do |r|
57
+ surfaces = (r['required_surfaces'] || []).join(', ')
58
+ [r['slug'], truncate(r['title'], 40), surfaces.empty? ? '-' : surfaces, r['status']]
59
+ end
60
+ output.table(headers, rows)
61
+ end
62
+ end
63
+ end
64
+
65
+ def list_gaps(workspace_id, opts)
66
+ # Gaps endpoint may not exist yet - fall back to computing from requirements
67
+ # For now, list requirements that don't have evidence for a surface
68
+ result = client.list_requirements(workspace_id: workspace_id)
69
+
70
+ unless result[:ok]
71
+ output.error(result[:error], status: result[:status])
72
+ exit 1
73
+ end
74
+
75
+ # Only show active requirements for gaps
76
+ requirements = (result[:data]['requirements'] || []).select { |r| r['status'] == 'active' }
77
+
78
+ # Filter by surface if specified
79
+ if opts[:surface]
80
+ requirements = requirements.select do |r|
81
+ (r['required_surfaces'] || []).include?(opts[:surface])
82
+ end
83
+ end
84
+
85
+ # For now, gaps = all requirements (evidence API is #1214)
86
+ # Once evidence API exists, filter to only those without evidence
87
+ output.success({ gaps: requirements, project: opts[:project], surface: opts[:surface] }) do
88
+ if requirements.empty?
89
+ puts 'No gaps found.'
90
+ else
91
+ puts "Gaps for project '#{opts[:project]}'" + (opts[:surface] ? " (surface: #{opts[:surface]})" : '') + ':'
92
+ headers = %w[SLUG TITLE SURFACES]
93
+ rows = requirements.map do |r|
94
+ surfaces = (r['required_surfaces'] || []).join(', ')
95
+ [r['slug'], truncate(r['title'], 40), surfaces]
96
+ end
97
+ output.table(headers, rows)
98
+ end
99
+ end
100
+ end
101
+
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp matrix --project <slug> [--csv]
6
+ # Show the requirements traceability matrix
7
+ class Matrix < Base
8
+ KNOWN_FLAGS = {
9
+ project: :string,
10
+ csv: :boolean
11
+ }.freeze
12
+
13
+ def run(args)
14
+ opts, = parse_flags(args, KNOWN_FLAGS)
15
+ require_flag(opts, :project)
16
+
17
+ workspace_id = resolve_workspace_id(opts[:project])
18
+
19
+ # Get workspace for surfaces
20
+ ws_result = client.get_project(workspace_id)
21
+ unless ws_result[:ok]
22
+ output.error(ws_result[:error], status: ws_result[:status])
23
+ exit 1
24
+ end
25
+
26
+ workspace = ws_result[:data]['workspace']
27
+ surfaces = workspace['available_surfaces'] || []
28
+
29
+ # Get active requirements
30
+ req_result = client.list_requirements(workspace_id: workspace_id)
31
+ unless req_result[:ok]
32
+ output.error(req_result[:error], status: req_result[:status])
33
+ exit 1
34
+ end
35
+
36
+ requirements = (req_result[:data]['requirements'] || []).select { |r| r['status'] == 'active' }
37
+
38
+ if requirements.empty?
39
+ output.success({ matrix: [], surfaces: surfaces }) do
40
+ puts 'No active requirements. The matrix is empty.'
41
+ end
42
+ return
43
+ end
44
+
45
+ # Build matrix data
46
+ # Columns: SLUG, TITLE, then one column per surface
47
+ headers = %w[SLUG TITLE] + surfaces
48
+
49
+ rows = requirements.map do |req|
50
+ required = req['required_surfaces'] || []
51
+ # Evidence status per surface (placeholder until Evidence API exists)
52
+ surface_cells = surfaces.map do |s|
53
+ if required.include?(s)
54
+ '⬜' # Required but no evidence yet
55
+ else
56
+ '-' # Not required
57
+ end
58
+ end
59
+ [req['slug'], truncate(req['title'], 30)] + surface_cells
60
+ end
61
+
62
+ if opts[:csv]
63
+ output.csv(headers, rows)
64
+ else
65
+ output.success({ requirements: requirements, surfaces: surfaces, project: opts[:project] }) do
66
+ puts "Parity Matrix: #{opts[:project]}"
67
+ puts
68
+ output.table(headers, rows)
69
+ puts
70
+ puts 'Legend: ✅ = passing, ⬜ = required (no evidence), - = not required'
71
+ puts
72
+ puts '(Evidence status will show when #1214 is complete)'
73
+ end
74
+ end
75
+ end
76
+
77
+ end
78
+ end
79
+ end