featureparity 0.0.1 → 0.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 695b5f522e34a1e886e07177cbbe5ca87b515caf6845e15b50ca9e1c4fb3735e
4
- data.tar.gz: 18ff57dfb177341dd4a6f2d8dc05e862a53d8e24eb0c2da4172e022986533cc1
3
+ metadata.gz: 7fec4ef9bda94cba09b19e5209ed867ff87d6a5312c6c6441234691e406bd2a2
4
+ data.tar.gz: 7fd7a58df29f9a69ce342ed60e5b7ff60b796f286404b81a56108be1f5c83f1b
5
5
  SHA512:
6
- metadata.gz: 9851ef3ea41669276c356fbe2e5a200ec06d59cea82a9d852eaca71a20297319b4eeb32e5a684921cfba3b298bf1e8931f45bc89ad0c59d802128a5f5940b157
7
- data.tar.gz: 1e075a99c1b02368908639649ff45ba78be33f8e0a03756b0f7782275758f326e01992488508c6930d1f5ac65e47eb6ef7b5e24438482e6eac2b855fe06b2557
6
+ metadata.gz: b198114877fb8ca6695f14c38796334b2cf4269e4164509a97a7b0d12cf4fa7f86ecbd4036028fad15fb80eb00d95da5a410542eda6cdcbd7366b33e6c0dab11
7
+ data.tar.gz: 7a6a248d554a4fa13bc5b33591be0868f2714dfc483c81da0ceafaa4fa951f5f915c229ac8096fbda59efae8319c39e135e4b36e4403944c6ceec80cc7f74832
data/lib/fp/cli.rb CHANGED
@@ -12,6 +12,7 @@ module Fp
12
12
  'report' => Commands::Report,
13
13
  'matrix' => Commands::Matrix,
14
14
  'profile' => Commands::Profile,
15
+ 'config' => Commands::ConfigCmd,
15
16
  'setup' => Commands::Setup,
16
17
  'version' => Commands::Version,
17
18
  'help' => Commands::Help
@@ -32,17 +33,19 @@ module Fp
32
33
  exit 1
33
34
  end
34
35
 
35
- # Profile commands don't need auth
36
- needs_auth = !%w[profile setup version help].include?(command_name)
36
+ # Profile/config commands don't need auth
37
+ needs_auth = !%w[profile config setup version help].include?(command_name)
37
38
 
38
39
  if needs_auth
39
- config = Config.new(profile: global_opts[:profile])
40
+ config = Config.new(profile: global_opts[:profile], api_url: global_opts[:api_url])
40
41
  unless config.valid?
41
42
  output = Output.new(json: global_opts[:json])
42
43
  output.error(config.validation_error.strip)
43
44
  exit 1
44
45
  end
45
46
  client = Client.new(config)
47
+ else
48
+ config = Config.new(profile: global_opts[:profile], api_url: global_opts[:api_url])
46
49
  end
47
50
 
48
51
  output = Output.new(json: global_opts[:json])
@@ -60,7 +63,7 @@ module Fp
60
63
  private
61
64
 
62
65
  def extract_global_options(args)
63
- opts = { json: false, profile: nil }
66
+ opts = { json: false, profile: nil, api_url: nil }
64
67
 
65
68
  # Extract --json flag
66
69
  if args.delete('--json')
@@ -89,6 +92,17 @@ module Fp
89
92
  opts[:profile] = args.delete_at(idx)
90
93
  end
91
94
 
95
+ # Extract --api-url flag
96
+ if (idx = args.index('--api-url'))
97
+ args.delete_at(idx)
98
+ value = args[idx]
99
+ if value.nil? || value.start_with?('-')
100
+ $stderr.puts "Error: --api-url requires a value"
101
+ exit 1
102
+ end
103
+ opts[:api_url] = args.delete_at(idx)
104
+ end
105
+
92
106
  opts
93
107
  end
94
108
  end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp config get|set|unset|list
6
+ # Manage global CLI settings (stored in ~/.config/fp/config.yml)
7
+ class ConfigCmd < Base
8
+ ALLOWED_SETTINGS = %w[api_url].freeze
9
+
10
+ def run(args)
11
+ subcommand = args.shift
12
+
13
+ case subcommand
14
+ when 'set'
15
+ set_setting(args)
16
+ when 'get'
17
+ get_setting(args)
18
+ when 'unset'
19
+ unset_setting(args)
20
+ when 'list'
21
+ list_settings
22
+ else
23
+ show_usage
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def set_setting(args)
30
+ key = args.shift
31
+ value = args.shift
32
+
33
+ unless key
34
+ output.error('Usage: fp config set <key> <value>')
35
+ exit 1
36
+ end
37
+
38
+ unless ALLOWED_SETTINGS.include?(key)
39
+ output.error("Unknown setting: '#{key}'. Allowed: #{ALLOWED_SETTINGS.join(', ')}")
40
+ exit 1
41
+ end
42
+
43
+ unless value
44
+ output.error("Value is required. Usage: fp config set #{key} <value>")
45
+ exit 1
46
+ end
47
+
48
+ Config.set_setting(key, value)
49
+
50
+ output.success({ key: key, value: value }) do
51
+ puts "Set #{key} = #{value}"
52
+ end
53
+ end
54
+
55
+ def get_setting(args)
56
+ key = args.shift
57
+
58
+ unless key
59
+ output.error('Usage: fp config get <key>')
60
+ exit 1
61
+ end
62
+
63
+ value = Config.get_setting(key)
64
+
65
+ if value
66
+ output.success({ key: key, value: value }) do
67
+ puts value
68
+ end
69
+ else
70
+ output.success({ key: key, value: nil }) do
71
+ puts "(not set)"
72
+ end
73
+ end
74
+ end
75
+
76
+ def unset_setting(args)
77
+ key = args.shift
78
+
79
+ unless key
80
+ output.error('Usage: fp config unset <key>')
81
+ exit 1
82
+ end
83
+
84
+ Config.set_setting(key, nil)
85
+
86
+ output.success({ key: key }) do
87
+ puts "Unset #{key}"
88
+ end
89
+ end
90
+
91
+ def list_settings
92
+ settings = {}
93
+ ALLOWED_SETTINGS.each do |key|
94
+ value = Config.get_setting(key)
95
+ settings[key] = value if value
96
+ end
97
+
98
+ output.success({ settings: settings }) do
99
+ if settings.empty?
100
+ puts "No global settings configured."
101
+ puts
102
+ puts "Set one with:"
103
+ puts " fp config set api_url https://api.dev.featureparity.dev"
104
+ else
105
+ puts "Global settings:"
106
+ settings.each { |k, v| puts " #{k} = #{v}" }
107
+ end
108
+ end
109
+ end
110
+
111
+ def show_usage
112
+ output.error('Usage: fp config <set|get|unset|list>')
113
+ puts
114
+ puts 'Commands:'
115
+ puts ' fp config set <key> <value> Set a global setting'
116
+ puts ' fp config get <key> Get a setting value'
117
+ puts ' fp config unset <key> Remove a setting'
118
+ puts ' fp config list Show all global settings'
119
+ puts
120
+ puts 'Available settings:'
121
+ puts ' api_url Base URL for the FeatureParity API'
122
+ puts
123
+ puts 'Examples:'
124
+ puts ' fp config set api_url https://api.dev.featureparity.dev'
125
+ puts ' fp config get api_url'
126
+ exit 1
127
+ end
128
+ end
129
+ end
130
+ end
@@ -20,6 +20,7 @@ module Fp
20
20
  report <slug> --project <slug>... Report evidence for a requirement
21
21
  matrix --project <slug> Show the parity matrix
22
22
  profile add|list Manage named profiles
23
+ config set|get|unset|list Manage global settings
23
24
  setup Interactive first-time setup
24
25
  version Show version
25
26
  help Show this help
@@ -28,6 +29,7 @@ module Fp
28
29
  --json Output as JSON envelope
29
30
  --profile <name> Use a named profile
30
31
  -p <name> Alias for --profile
32
+ --api-url <url> Override API URL for this command
31
33
 
32
34
  AUTHENTICATION
33
35
  Set FP_API_KEY environment variable, or use a profile:
@@ -52,11 +52,36 @@ module Fp
52
52
  if requirements.empty?
53
53
  puts "No #{status_filter} requirements found."
54
54
  else
55
+ # Group into tree structure: parents first, then children indented
56
+ parents = requirements.select { |r| r['parent_id'].nil? || r['parent_id'] == '' }
57
+ children_by_parent = requirements
58
+ .select { |r| r['parent_id'] && r['parent_id'] != '' }
59
+ .group_by { |r| r['parent_id'] }
60
+
61
+ # Orphaned children (parent not in filtered set) show at top level
62
+ parent_ids = parents.map { |p| p['id'] }.to_set
63
+ orphans = requirements.select { |r| r['parent_id'] && r['parent_id'] != '' && !parent_ids.include?(r['parent_id']) }
64
+
55
65
  headers = %w[SLUG TITLE SURFACES STATUS]
56
- rows = requirements.map do |r|
66
+ rows = []
67
+
68
+ parents.each do |r|
57
69
  surfaces = (r['required_surfaces'] || []).join(', ')
58
- [r['slug'], truncate(r['title'], 40), surfaces.empty? ? '-' : surfaces, r['status']]
70
+ rows << [r['slug'], truncate(r['title'], 40), surfaces.empty? ? '-' : surfaces, r['status']]
71
+
72
+ # Add children indented
73
+ (children_by_parent[r['id']] || []).each do |child|
74
+ child_surfaces = (child['required_surfaces'] || []).join(', ')
75
+ rows << [" └ #{child['slug']}", truncate(child['title'], 36), child_surfaces.empty? ? '-' : child_surfaces, child['status']]
76
+ end
59
77
  end
78
+
79
+ # Show orphans at top level
80
+ orphans.each do |r|
81
+ surfaces = (r['required_surfaces'] || []).join(', ')
82
+ rows << [r['slug'], truncate(r['title'], 40), surfaces.empty? ? '-' : surfaces, r['status']]
83
+ end
84
+
60
85
  output.table(headers, rows)
61
86
  end
62
87
  end
@@ -68,8 +68,6 @@ module Fp
68
68
  output.table(headers, rows)
69
69
  puts
70
70
  puts 'Legend: ✅ = passing, ⬜ = required (no evidence), - = not required'
71
- puts
72
- puts '(Evidence status will show when #1214 is complete)'
73
71
  end
74
72
  end
75
73
  end
@@ -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>
6
6
  # Create a new requirement as draft
7
7
  class Propose < Base
8
8
  KNOWN_FLAGS = {
@@ -11,7 +11,8 @@ module Fp
11
11
  name: :string,
12
12
  why: :string,
13
13
  required: :string,
14
- acceptance: :string
14
+ acceptance: :string,
15
+ parent: :string
15
16
  }.freeze
16
17
 
17
18
  def run(args)
@@ -38,6 +39,13 @@ module Fp
38
39
  }
39
40
  params[:why] = opts[:why] if opts[:why]
40
41
  params[:acceptance] = opts[:acceptance] if opts[:acceptance]
42
+
43
+ # Resolve parent slug to parent_id if provided
44
+ if opts[:parent]
45
+ parent_id = resolve_parent_id(workspace_id, opts[:parent])
46
+ params[:parent_id] = parent_id
47
+ end
48
+
41
49
  # Note: status is NOT sent - the API enforces draft for agents
42
50
 
43
51
  result = client.create_requirement(params)
@@ -55,12 +63,34 @@ module Fp
55
63
  puts " Title: #{requirement['title']}"
56
64
  puts " Status: #{requirement['status']}"
57
65
  puts " Surfaces: #{(requirement['required_surfaces'] || []).join(', ')}"
66
+ puts " Parent: #{opts[:parent] || '(none)'}" if opts[:parent]
58
67
  puts
59
68
  puts '⚠️ This requirement is a DRAFT.'
60
69
  puts ' A human must activate it in the web app before it appears in the parity matrix.'
61
70
  puts ' Agents cannot activate requirements.'
62
71
  end
63
72
  end
73
+
74
+ private
75
+
76
+ # Resolve a parent requirement slug to its ID
77
+ def resolve_parent_id(workspace_id, parent_slug)
78
+ result = client.list_requirements(workspace_id: workspace_id)
79
+ unless result[:ok]
80
+ output.error("Failed to resolve parent slug '#{parent_slug}': #{result[:error]}")
81
+ exit 1
82
+ end
83
+
84
+ requirements = result[:data]['requirements'] || []
85
+ parent = requirements.find { |r| r['slug'] == parent_slug }
86
+
87
+ unless parent
88
+ output.error("Parent requirement '#{parent_slug}' not found in this workspace")
89
+ exit 1
90
+ end
91
+
92
+ parent['id']
93
+ end
64
94
  end
65
95
  end
66
96
  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,7 +57,11 @@ module Fp
54
57
  exit 1
55
58
  end
56
59
 
57
- puts "Get your API key from: https://featureparity.dev"
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}"
58
65
  puts "(Workspace Settings → API Keys → Mint Key)"
59
66
  puts
60
67
  print "Paste your API key (starts with fp_): "
@@ -38,6 +38,7 @@ module Fp
38
38
  puts
39
39
  puts "Slug: #{req['slug']}"
40
40
  puts "Status: #{req['status']}"
41
+ puts "Parent: #{req['parent_id'] || '(none)'}"
41
42
  puts
42
43
 
43
44
  surfaces = req['required_surfaces'] || []
data/lib/fp/commands.rb CHANGED
@@ -9,6 +9,7 @@ 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'
12
13
  require_relative 'commands/setup'
13
14
  require_relative 'commands/version'
14
15
  require_relative 'commands/help'
data/lib/fp/config.rb CHANGED
@@ -5,7 +5,17 @@ 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)
9
19
  class Config
10
20
  DEFAULT_API_URL = 'https://api.featureparity.dev'
11
21
  CONFIG_DIR = File.expand_path('~/.config/fp')
@@ -13,8 +23,9 @@ module Fp
13
23
 
14
24
  attr_reader :api_key, :api_url, :profile_name
15
25
 
16
- def initialize(profile: nil)
26
+ def initialize(profile: nil, api_url: nil)
17
27
  @profile_name = resolve_profile_name(profile)
28
+ @explicit_api_url = api_url
18
29
  @api_key = resolve_api_key
19
30
  @api_url = resolve_api_url
20
31
  end
@@ -33,34 +44,51 @@ module Fp
33
44
  end
34
45
 
35
46
  class << self
36
- def load_profiles
47
+ def load_config
37
48
  return {} unless File.exist?(CONFIG_FILE)
38
49
 
39
- config = YAML.safe_load_file(CONFIG_FILE, permitted_classes: [Symbol]) || {}
40
- config['profiles'] || {}
50
+ YAML.safe_load_file(CONFIG_FILE, permitted_classes: [Symbol]) || {}
41
51
  rescue StandardError => e
42
52
  warn "Warning: Could not load config file: #{e.message}"
43
53
  {}
44
54
  end
45
55
 
56
+ def load_profiles
57
+ config = load_config
58
+ config['profiles'] || {}
59
+ end
60
+
46
61
  def save_profile(name, api_key:, api_url: nil)
47
62
  FileUtils.mkdir_p(CONFIG_DIR)
63
+ ensure_config_file!
48
64
 
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]) || {}
65
+ config = load_config
56
66
  config['profiles'] ||= {}
57
- config['profiles'][name] = {
58
- 'api_key' => api_key
59
- }
67
+ config['profiles'][name] = { 'api_key' => api_key }
60
68
  config['profiles'][name]['api_url'] = api_url if api_url
61
69
 
62
- File.write(CONFIG_FILE, YAML.dump(config))
63
- File.chmod(0o600, CONFIG_FILE)
70
+ write_config!(config)
71
+ end
72
+
73
+ # Get a global setting (top-level key in config.yml)
74
+ def get_setting(key)
75
+ config = load_config
76
+ config[key.to_s]
77
+ end
78
+
79
+ # Set a global setting (top-level key in config.yml)
80
+ def set_setting(key, value)
81
+ FileUtils.mkdir_p(CONFIG_DIR)
82
+ ensure_config_file!
83
+
84
+ config = load_config
85
+ if value.nil?
86
+ config.delete(key.to_s)
87
+ else
88
+ config[key.to_s] = value
89
+ end
90
+
91
+ write_config!(config)
64
92
  end
65
93
 
66
94
  def list_profiles
@@ -70,12 +98,25 @@ module Fp
70
98
  def profile_exists?(name)
71
99
  load_profiles.key?(name)
72
100
  end
101
+
102
+ private
103
+
104
+ def ensure_config_file!
105
+ return if File.exist?(CONFIG_FILE)
106
+
107
+ File.write(CONFIG_FILE, "---\nprofiles: {}\n")
108
+ File.chmod(0o600, CONFIG_FILE)
109
+ end
110
+
111
+ def write_config!(config)
112
+ File.write(CONFIG_FILE, YAML.dump(config))
113
+ File.chmod(0o600, CONFIG_FILE)
114
+ end
73
115
  end
74
116
 
75
117
  private
76
118
 
77
119
  def resolve_profile_name(explicit_profile)
78
- # Explicit --profile flag takes precedence over FP_PROFILE env var
79
120
  explicit_profile || ENV['FP_PROFILE']
80
121
  end
81
122
 
@@ -94,17 +135,24 @@ module Fp
94
135
  end
95
136
 
96
137
  def resolve_api_url
97
- # FP_API_URL env var takes precedence
138
+ # 1. Explicit --api-url flag
139
+ return @explicit_api_url if @explicit_api_url && !@explicit_api_url.empty?
140
+
141
+ # 2. FP_API_URL env var
98
142
  return ENV['FP_API_URL'] if ENV['FP_API_URL'] && !ENV['FP_API_URL'].empty?
99
143
 
100
- # Then profile-specific URL
144
+ # 3. Profile-specific URL
101
145
  if @profile_name
102
146
  profiles = self.class.load_profiles
103
147
  profile = profiles[@profile_name]
104
148
  return profile['api_url'] if profile && profile['api_url']
105
149
  end
106
150
 
107
- # Finally, default
151
+ # 4. Global api_url setting
152
+ global_url = self.class.get_setting('api_url')
153
+ return global_url if global_url && !global_url.empty?
154
+
155
+ # 5. Default
108
156
  DEFAULT_API_URL
109
157
  end
110
158
  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.2'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featureparity
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -95,6 +95,7 @@ files:
95
95
  - lib/fp/client.rb
96
96
  - lib/fp/commands.rb
97
97
  - lib/fp/commands/base.rb
98
+ - lib/fp/commands/config_cmd.rb
98
99
  - lib/fp/commands/help.rb
99
100
  - lib/fp/commands/list.rb
100
101
  - lib/fp/commands/matrix.rb