featureparity 0.0.19 → 0.0.21

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: dda702b723617dde0db1b5948be499c087f4c314a9ccd6a188d66bf512523328
4
- data.tar.gz: 48104e563fe159fac04b247a51fd0780d060e87042e1416497bb33b5b9530b7f
3
+ metadata.gz: 6ae3f4be081207da9feb300fbdfaad6d2afddf2ffb021ffc8136508da964f0c2
4
+ data.tar.gz: 74d2cf3feb18ea77cac72f38be15c5eb6870d237d76f2a21a3b859853649b02d
5
5
  SHA512:
6
- metadata.gz: a34620d3c364b92651707e89882943a7dd6abb22346192f4f3bce117471708c843bbc9a1606a661f66f4debcabed20fd1e109073ad3b1016aa386be7ffef36bc
7
- data.tar.gz: a4647cc613c7aba590a4611fb20a7b1ba0d1ae2b4f70950cf1230092b40689721a86962a4d3b58ddb261e4b3f5c3fd29e84f2d096bba268cd79adf2704cd7aee
6
+ metadata.gz: f14f50d9f5e6d620341081a55ee98734bafd6893ade078cb424246c6ddb16a2f85a8aff6b5d28dc1d969505708b0e59806628699c47146736b024f90c0e0c6e6
7
+ data.tar.gz: 156d5c4e679017b8d5ec7825eb51d7eb64acec51daf02700a263f58e20251c2136efadab399244856f4ce943fe832825e89e45a75f57a814368e8dbc54a5189e
data/lib/fp/client.rb CHANGED
@@ -22,6 +22,21 @@ module Fp
22
22
  get("/api/projects/#{id}")
23
23
  end
24
24
 
25
+ # POST /projects
26
+ def create_project(params)
27
+ post('/api/projects', params)
28
+ end
29
+
30
+ # PUT /projects/:id
31
+ def update_project(id, params)
32
+ put("/api/projects/#{id}", params)
33
+ end
34
+
35
+ # DELETE /projects/:id
36
+ def delete_project(id)
37
+ delete("/api/projects/#{id}")
38
+ end
39
+
25
40
  # GET /projects/:id/surfaces
26
41
  def list_surfaces(project_id)
27
42
  get("/api/projects/#{project_id}/surfaces")
@@ -57,6 +72,16 @@ module Fp
57
72
  post("/api/projects/#{project_id}/surfaces", params)
58
73
  end
59
74
 
75
+ # PUT /projects/:id/surfaces/:surface_id
76
+ def update_surface(project_id, surface_id, params)
77
+ put("/api/projects/#{project_id}/surfaces/#{surface_id}", params)
78
+ end
79
+
80
+ # DELETE /projects/:id/surfaces/:surface_id
81
+ def delete_surface(project_id, surface_id)
82
+ delete("/api/projects/#{project_id}/surfaces/#{surface_id}")
83
+ end
84
+
60
85
  # GET /requirements
61
86
  def list_requirements(project_id: nil, status: nil, category: nil, tag: nil)
62
87
  params = {}
@@ -14,9 +14,15 @@ module Fp
14
14
  COMMANDS
15
15
  init --project <slug> Onboard this repo: config, CI gate, agent docs
16
16
  projects List projects you have access to
17
+ projects show <slug> View a project and its surfaces
18
+ projects create <slug> --name ... Create a project (owned by your key's owner)
19
+ projects edit <slug> --name ... Update a project's name or description
20
+ projects delete <slug> [--yes] Delete a project (asks you to type its slug)
17
21
  surfaces --project <slug> List surfaces for a project
18
22
  surfaces show <key> --project ... View a single surface's details
19
23
  surfaces add <key> --project ... Create a surface
24
+ surfaces edit <key> --project ... Change a surface (name, kind, repo, key...)
25
+ surfaces delete <key> --project . Delete a surface (asks first; --yes to skip)
20
26
  list --project <slug> List active requirements
21
27
  show <slug> --project <slug> Show requirement details
22
28
  propose --project <slug> ... Propose a new requirement (as draft)
@@ -151,6 +157,17 @@ module Fp
151
157
  # List projects
152
158
  fp projects
153
159
 
160
+ # Create a project (agents can do this — the project lands on the account of
161
+ # whoever minted the key, and the same key reaches it immediately)
162
+ fp projects create calib-rate --name "Calib Rate" --surfaces api,web
163
+ fp projects show calib-rate
164
+ fp projects edit calib-rate --description "Calibration tooling"
165
+
166
+ # Delete a project. Interactive: type the slug to confirm. Non-interactive
167
+ # sessions must pass --yes, or the command refuses. Owners only.
168
+ fp projects delete calib-rate
169
+ fp projects delete calib-rate --yes
170
+
154
171
  # List the surfaces a project tracks (requirements may only name these)
155
172
  fp surfaces --project stowzilla
156
173
 
@@ -163,6 +180,12 @@ module Fp
163
180
  --kind android \\
164
181
  --repo stowzilla/customer-android
165
182
 
183
+ # Fix up an existing surface (add refuses a key that already exists)
184
+ fp surfaces edit web --project stowzilla --kind web --repo stowzilla/web
185
+
186
+ # Delete a surface (type the key back, or --yes non-interactively)
187
+ fp surfaces delete web --project stowzilla --yes
188
+
166
189
  # List requirements for a project
167
190
  fp list --project stowzilla
168
191
 
@@ -22,7 +22,16 @@ module Fp
22
22
  'change_proposed' => 'change proposed',
23
23
  'removal_proposed' => 'deletion proposed',
24
24
  'change_approved' => 'change approved',
25
- 'change_rejected' => 'change rejected'
25
+ 'change_rejected' => 'change rejected',
26
+ 'decision_recorded' => 'decision recorded'
27
+ }.freeze
28
+
29
+ # A member's own stance on a requirement that already has a status (Fizzy #1478),
30
+ # including a request for changes (Fizzy #1481) — its note says what to change.
31
+ DECISION_LABELS = {
32
+ 'approve' => 'approves',
33
+ 'request_changes' => 'requests changes',
34
+ 'reject' => 'rejects'
26
35
  }.freeze
27
36
 
28
37
  def run(args)
@@ -86,11 +95,13 @@ module Fp
86
95
 
87
96
  def print_entry(change)
88
97
  label = TYPE_LABELS[change['type']] || change['type']
98
+ label = DECISION_LABELS.fetch(change['decision'], label) if change['type'] == 'decision_recorded'
89
99
  actor = change['actor'] || 'unknown'
90
100
  kind = change['actor_kind'] == 'agent' ? 'agent' : 'human'
91
101
 
92
102
  puts "* #{label} — #{actor} (#{kind}) at #{format_time(change['recorded_at'])}"
93
103
  puts " #{change['reason']}" if present?(change['reason'])
104
+ puts " note: #{change['note']}" if change['type'] == 'decision_recorded' && present?(change['note'])
94
105
  print_source(change['source'])
95
106
  print_diff(change['changes'])
96
107
  print_evidence_diff(change['evidence_diff'])
@@ -2,9 +2,57 @@
2
2
 
3
3
  module Fp
4
4
  module Commands
5
- # fp projects - list projects the API key has access to
5
+ # fp projects - list projects the API key can reach
6
+ # fp projects show <slug> - view one project and its surfaces
7
+ # fp projects create <slug> --name "..." - create a project (owned by the key's owner)
8
+ # fp projects edit <slug> --name/--description - update a project's details
9
+ # fp projects delete <slug> [--yes] - delete a project, with confirmation
10
+ #
11
+ # `create` exists because the only way to start a project used to be the web app, so
12
+ # an agent onboarding a repo FeatureParity had never seen was stuck waiting on a human
13
+ # (Fizzy #1473). Keys are account-level (Fizzy #1472): the project lands on the key
14
+ # owner's account, and the same key reaches it immediately.
15
+ #
16
+ # `delete` is modelled on `gh repo delete`: it asks you to type the project's slug,
17
+ # or takes --yes to skip the prompt, and a non-interactive session without --yes is
18
+ # refused rather than guessed at. The server adds its own check — only an owner (or an
19
+ # owner's key) may delete.
6
20
  class Projects < Base
7
- def run(_args)
21
+ CREATE_FLAGS = {
22
+ name: :string,
23
+ description: :string,
24
+ surfaces: :string
25
+ }.freeze
26
+
27
+ EDIT_FLAGS = {
28
+ name: :string,
29
+ description: :string
30
+ }.freeze
31
+
32
+ DELETE_FLAGS = {
33
+ yes: :boolean
34
+ }.freeze
35
+
36
+ def run(args)
37
+ # Subcommand dispatch. Bare `fp projects` stays the list, so existing callers
38
+ # (the setup wizard's "test access with fp projects") keep working.
39
+ case args.first
40
+ when 'show'
41
+ show(args[1..])
42
+ when 'create'
43
+ create(args[1..])
44
+ when 'edit'
45
+ edit(args[1..])
46
+ when 'delete'
47
+ delete(args[1..])
48
+ else
49
+ list
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def list
8
56
  result = client.list_projects
9
57
 
10
58
  unless result[:ok]
@@ -17,22 +65,240 @@ module Fp
17
65
  output.success(result[:data]) do
18
66
  if projects.empty?
19
67
  puts 'No projects found.'
68
+ puts 'Create one with: fp projects create <slug> --name "<Name>"'
20
69
  else
21
70
  headers = %w[SLUG NAME SURFACES]
22
71
  rows = projects.map do |w|
23
- surfaces_result = client.list_surfaces(w['id'])
24
- surface_keys = if surfaces_result[:ok]
25
- (surfaces_result[:data]['surfaces'] || []).map { |s| s['key'] }
26
- else
27
- []
28
- end
29
- surfaces = surface_keys.join(', ')
72
+ surfaces = surface_keys_for(w['id']).join(', ')
30
73
  [w['slug'], w['name'], surfaces.empty? ? '-' : surfaces]
31
74
  end
32
75
  output.table(headers, rows)
33
76
  end
34
77
  end
35
78
  end
79
+
80
+ def show(args)
81
+ _opts, positional = parse_flags(args)
82
+ slug = require_slug!(positional, 'fp projects show <slug>')
83
+
84
+ project = find_project!(slug)
85
+ result = client.get_project(project['id'])
86
+
87
+ unless result[:ok]
88
+ output.error(result[:error], status: result[:status])
89
+ exit 1
90
+ end
91
+
92
+ record = result[:data]['project'] || project
93
+ surfaces = surface_keys_for(record['id'])
94
+
95
+ output.success({ project: record, surfaces: surfaces }) do
96
+ print_project(record, surfaces)
97
+ end
98
+ end
99
+
100
+ def create(args)
101
+ opts, positional = parse_flags(args, CREATE_FLAGS)
102
+ slug = require_slug!(positional, 'fp projects create <slug> --name "<Name>" [--description "..."] [--surfaces api,web]')
103
+
104
+ # The slug is the project's permanent key and the name is what people see. Let
105
+ # `fp projects create calib-rate` work on its own: the name defaults to the slug
106
+ # and can be fixed up later with `fp projects edit`.
107
+ params = { slug: slug, name: opts[:name] || slug }
108
+ params[:description] = opts[:description] if opts[:description]
109
+
110
+ surfaces = split_list(opts[:surfaces])
111
+ params[:surfaces] = surfaces unless surfaces.empty?
112
+
113
+ result = client.create_project(params)
114
+
115
+ unless result[:ok]
116
+ output.error(result[:error], status: result[:status])
117
+ exit 1
118
+ end
119
+
120
+ project = result[:data]['project'] || {}
121
+ created_surfaces = (result[:data]['surfaces'] || []).map { |s| s['key'] }
122
+
123
+ output.success(result[:data]) do
124
+ puts "Created project '#{project['slug'] || slug}'."
125
+ puts
126
+ puts " Name: #{project['name']}"
127
+ puts " Slug: #{project['slug'] || slug}"
128
+ puts " Surfaces: #{created_surfaces.empty? ? '(none yet)' : created_surfaces.join(', ')}"
129
+ puts
130
+ puts 'Next:'
131
+ puts " fp surfaces add <key> --project #{project['slug'] || slug} --name \"<Name>\"" if created_surfaces.empty?
132
+ puts " fp init --project #{project['slug'] || slug} --surface <key> # onboard a repo"
133
+ end
134
+ end
135
+
136
+ def edit(args)
137
+ opts, positional = parse_flags(args, EDIT_FLAGS)
138
+ slug = require_slug!(positional, 'fp projects edit <slug> [--name "..."] [--description "..."]')
139
+
140
+ params = {}
141
+ params[:name] = opts[:name] if opts[:name]
142
+ params[:description] = opts[:description] if opts.key?(:description)
143
+
144
+ if params.empty?
145
+ output.error('Nothing to change. Pass --name and/or --description.')
146
+ exit 1
147
+ end
148
+
149
+ project = find_project!(slug)
150
+ result = client.update_project(project['id'], params)
151
+
152
+ unless result[:ok]
153
+ output.error(result[:error], status: result[:status])
154
+ exit 1
155
+ end
156
+
157
+ record = result[:data]['project'] || {}
158
+
159
+ output.success(result[:data]) do
160
+ puts "Updated project '#{record['slug'] || slug}'."
161
+ puts
162
+ puts " Name: #{record['name']}"
163
+ puts " Description: #{blank?(record['description']) ? '(none)' : record['description']}"
164
+ end
165
+ end
166
+
167
+ def delete(args)
168
+ opts, positional = parse_flags(args, DELETE_FLAGS)
169
+ slug = require_slug!(positional, 'fp projects delete <slug> [--yes]')
170
+
171
+ project = find_project!(slug)
172
+ confirm_delete!(project, opts)
173
+
174
+ result = client.delete_project(project['id'])
175
+
176
+ unless result[:ok]
177
+ output.error(result[:error], status: result[:status])
178
+ exit 1
179
+ end
180
+
181
+ output.success({ deleted: true, project: { 'id' => project['id'], 'slug' => project['slug'] } }) do
182
+ puts "Deleted project '#{project['slug']}'."
183
+ end
184
+ end
185
+
186
+ # The gh-style guard. Three ways through:
187
+ # --yes → no prompt (scripts, agents, CI)
188
+ # interactive terminal → type the slug back, exactly
189
+ # neither → refuse; a pipeline must say --yes out loud
190
+ #
191
+ # The prompt shows what is about to go with the project (surfaces, requirements)
192
+ # so "the wrong project" is caught by a human reading, not only by typing.
193
+ def confirm_delete!(project, opts)
194
+ return if opts[:yes]
195
+
196
+ unless interactive?
197
+ output.error(
198
+ "Refusing to delete '#{project['slug']}' without confirmation. " \
199
+ 'Re-run with --yes to confirm from a non-interactive session.'
200
+ )
201
+ exit 1
202
+ end
203
+
204
+ surfaces = surface_keys_for(project['id'])
205
+ requirements = requirement_count_for(project['id'])
206
+
207
+ warn "This will permanently delete project '#{project['slug']}' (#{project['name']})."
208
+ warn " Surfaces: #{surfaces.empty? ? 'none' : surfaces.join(', ')}"
209
+ warn " Requirements: #{requirements.nil? ? 'unknown' : requirements}"
210
+ warn 'Its requirements, evidence, and memberships will no longer be reachable.'
211
+ $stderr.print "Type '#{project['slug']}' to confirm: "
212
+ $stderr.flush
213
+
214
+ typed = $stdin.gets&.strip
215
+ return if typed == project['slug']
216
+
217
+ output.error('Aborted. The slug did not match; nothing was deleted.')
218
+ exit 1
219
+ end
220
+
221
+ def interactive?
222
+ $stdin.respond_to?(:tty?) && $stdin.tty?
223
+ end
224
+
225
+ # --- helpers ----------------------------------------------------------
226
+
227
+ def require_slug!(positional, usage)
228
+ slug = positional.first
229
+ if slug.nil? || slug.strip.empty?
230
+ output.error("Usage: #{usage}")
231
+ exit 1
232
+ end
233
+ slug.strip
234
+ end
235
+
236
+ # The project record (not just its id) by slug or id, or exit 1. Mirrors
237
+ # Base#resolve_project_id, but the commands here print name/slug too.
238
+ def find_project!(slug_or_id)
239
+ result = client.list_projects
240
+ unless result[:ok]
241
+ output.error(result[:error], status: result[:status])
242
+ exit 1
243
+ end
244
+
245
+ projects = result[:data]['projects'] || []
246
+ project = projects.find { |w| w['slug'] == slug_or_id || w['id'] == slug_or_id }
247
+
248
+ unless project
249
+ output.error("Project '#{slug_or_id}' not found")
250
+ exit 1
251
+ end
252
+
253
+ project
254
+ end
255
+
256
+ def surface_keys_for(project_id)
257
+ result = client.list_surfaces(project_id)
258
+ return [] unless result[:ok]
259
+
260
+ (result[:data]['surfaces'] || []).map { |s| s['key'] }
261
+ end
262
+
263
+ # Nil when the read fails — the confirmation prompt says "unknown" rather than
264
+ # claiming a project is empty.
265
+ def requirement_count_for(project_id)
266
+ result = client.list_requirements(project_id: project_id)
267
+ return nil unless result[:ok]
268
+
269
+ (result[:data]['requirements'] || []).size
270
+ end
271
+
272
+ def print_project(project, surfaces)
273
+ name = project['name'] || project['slug'].to_s
274
+ puts name
275
+ puts '=' * name.length
276
+ puts
277
+ puts "Slug: #{project['slug']}"
278
+ puts "Description: #{blank?(project['description']) ? '(none)' : project['description']}"
279
+ puts "Enforcement: #{project['enforcement_level']}" if project['enforcement_level']
280
+ puts "Surfaces: #{surfaces.empty? ? '(none)' : surfaces.join(', ')}"
281
+
282
+ vocabulary = Array(project['test_type_vocabulary'])
283
+ puts "Test types: #{vocabulary.join(', ')}" unless vocabulary.empty?
284
+ defaults = Array(project['default_test_types'])
285
+ puts "Default types: #{defaults.join(', ')}" unless defaults.empty?
286
+
287
+ puts
288
+ puts "ID: #{project['id']}" if project['id']
289
+ puts "Created: #{project['created_at']}" if project['created_at']
290
+ puts "Updated: #{project['updated_at']}" if project['updated_at']
291
+ end
292
+
293
+ def split_list(value)
294
+ return [] if value.nil? || value == true
295
+
296
+ value.to_s.split(',').map(&:strip).reject(&:empty?)
297
+ end
298
+
299
+ def blank?(value)
300
+ value.nil? || value.to_s.strip.empty?
301
+ end
36
302
  end
37
303
  end
38
304
  end
@@ -7,10 +7,17 @@ module Fp
7
7
  # fp surfaces --project <slug> - list available surfaces for a project
8
8
  # fp surfaces show <key> --project <slug> - view a single surface's details
9
9
  # fp surfaces add <key> --project <slug> - create a surface
10
+ # fp surfaces edit <key> --project <slug> - change an existing surface
11
+ # fp surfaces delete <key> --project <slug> - delete a surface, with confirmation
10
12
  #
11
13
  # `add` exists because a requirement can only name surfaces that already exist, so an
12
14
  # agent onboarding a project it hasn't seen before would otherwise be blocked waiting
13
15
  # on a human to click through the web app.
16
+ #
17
+ # `edit` and `delete` exist because `add` on an existing key used to make a second
18
+ # surface with the same key, and there was no way to fix that short of a raw API call
19
+ # (Fizzy #1480). The server now refuses a duplicate key with a 409; `add` turns that
20
+ # into a pointer at `edit`. `delete` is guarded like `fp projects delete`.
14
21
  class Surfaces < Base
15
22
  ADD_FLAGS = {
16
23
  project: :string,
@@ -21,6 +28,13 @@ module Fp
21
28
  default_branch: :string
22
29
  }.freeze
23
30
 
31
+ EDIT_FLAGS = ADD_FLAGS.merge(key: :string).freeze
32
+
33
+ DELETE_FLAGS = {
34
+ project: :string,
35
+ yes: :boolean
36
+ }.freeze
37
+
24
38
  VALID_KINDS = %w[api web android ios other].freeze
25
39
  VALID_AUDIENCES = %w[customer ops both internal].freeze
26
40
 
@@ -32,6 +46,10 @@ module Fp
32
46
  add(args[1..])
33
47
  when 'show'
34
48
  show(args[1..])
49
+ when 'edit'
50
+ edit(args[1..])
51
+ when 'delete'
52
+ delete(args[1..])
35
53
  else
36
54
  list(args)
37
55
  end
@@ -116,6 +134,15 @@ module Fp
116
134
 
117
135
  result = client.create_surface(project_id, params)
118
136
 
137
+ if !result[:ok] && result[:status] == 409
138
+ output.error(
139
+ "Surface '#{key}' already exists in project '#{opts[:project]}'. " \
140
+ "Change it with: fp surfaces edit #{key} --project #{opts[:project]} [--name ...] [--kind ...] [--repo ...]",
141
+ status: 409
142
+ )
143
+ exit 1
144
+ end
145
+
119
146
  unless result[:ok]
120
147
  output.error(result[:error], status: result[:status])
121
148
  exit 1
@@ -133,6 +160,124 @@ module Fp
133
160
  end
134
161
  end
135
162
 
163
+ def edit(args)
164
+ opts, positional = parse_flags(args, EDIT_FLAGS)
165
+ require_project!(opts)
166
+
167
+ key = positional.first
168
+ if key.nil? || key.strip.empty?
169
+ output.error('Usage: fp surfaces edit <key> --project <slug> [--name "Name"] [--kind ...] ' \
170
+ '[--audience ...] [--repo org/repo] [--default-branch main] [--key new_key]')
171
+ exit 1
172
+ end
173
+
174
+ validate_choice!(opts[:kind], VALID_KINDS, '--kind')
175
+ validate_choice!(opts[:audience], VALID_AUDIENCES, '--audience')
176
+
177
+ # Only what was passed is sent, so an edit never blanks a field it wasn't asked about.
178
+ params = {}
179
+ %i[key name kind audience repo default_branch].each do |field|
180
+ params[field] = opts[field] if opts.key?(field)
181
+ end
182
+
183
+ if params.empty?
184
+ output.error('Nothing to change. Pass --name, --kind, --audience, --repo, --default-branch, or --key.')
185
+ exit 1
186
+ end
187
+
188
+ project_id = resolve_project_id(opts[:project])
189
+ surface = find_surface!(project_id, key)
190
+
191
+ result = client.update_surface(project_id, surface['id'], params)
192
+
193
+ unless result[:ok]
194
+ output.error(result[:error], status: result[:status])
195
+ exit 1
196
+ end
197
+
198
+ # The update action answers with update!'s return value, not the record, so read
199
+ # the surface back rather than trusting the response body.
200
+ updated = client.get_surface(project_id, surface['id'])
201
+ record = (updated[:ok] && updated[:data]['surface']) || surface.merge(params.transform_keys(&:to_s))
202
+
203
+ output.success({ surface: record, project: opts[:project] }) do
204
+ puts "Updated surface '#{record['key']}' in project '#{opts[:project]}'."
205
+ puts
206
+ puts " Name: #{record['name']}"
207
+ puts " Kind: #{record['kind']}"
208
+ puts " Audience: #{record['audience']}"
209
+ puts " Repo: #{record['repo'] || '(none)'}"
210
+ puts " Branch: #{record['default_branch'] || '(none)'}"
211
+ end
212
+ end
213
+
214
+ def delete(args)
215
+ opts, positional = parse_flags(args, DELETE_FLAGS)
216
+ require_project!(opts)
217
+
218
+ key = positional.first
219
+ if key.nil? || key.strip.empty?
220
+ output.error('Usage: fp surfaces delete <key> --project <slug> [--yes]')
221
+ exit 1
222
+ end
223
+
224
+ project_id = resolve_project_id(opts[:project])
225
+ surface = find_surface!(project_id, key)
226
+ confirm_delete!(surface, opts)
227
+
228
+ result = client.delete_surface(project_id, surface['id'])
229
+
230
+ unless result[:ok]
231
+ output.error(result[:error], status: result[:status])
232
+ exit 1
233
+ end
234
+
235
+ output.success({ deleted: true, surface: { 'id' => surface['id'], 'key' => surface['key'] },
236
+ project: opts[:project] }) do
237
+ puts "Deleted surface '#{surface['key']}' from project '#{opts[:project]}'."
238
+ end
239
+ end
240
+
241
+ # Same guard as `fp projects delete`: --yes skips the prompt, an interactive
242
+ # terminal types the key back, and anything else is refused.
243
+ def confirm_delete!(surface, opts)
244
+ return if opts[:yes]
245
+
246
+ unless interactive?
247
+ output.error(
248
+ "Refusing to delete surface '#{surface['key']}' without confirmation. " \
249
+ 'Re-run with --yes to confirm from a non-interactive session.'
250
+ )
251
+ exit 1
252
+ end
253
+
254
+ warn "This will delete surface '#{surface['key']}' (#{surface['name']}) from project '#{opts[:project]}'."
255
+ warn 'Evidence filed against it stays behind with nothing to measure.'
256
+ $stderr.print "Type '#{surface['key']}' to confirm: "
257
+ $stderr.flush
258
+
259
+ typed = $stdin.gets&.strip
260
+ return if typed == surface['key']
261
+
262
+ output.error('Aborted. The key did not match; nothing was deleted.')
263
+ exit 1
264
+ end
265
+
266
+ def interactive?
267
+ $stdin.respond_to?(:tty?) && $stdin.tty?
268
+ end
269
+
270
+ def find_surface!(project_id, key)
271
+ result = client.find_surface_by_key(project_id, key)
272
+
273
+ unless result[:ok]
274
+ output.error(result[:error], status: result[:status])
275
+ exit 1
276
+ end
277
+
278
+ result[:data]['surface']
279
+ end
280
+
136
281
  def validate_choice!(value, allowed, flag)
137
282
  return if value.nil? || allowed.include?(value)
138
283
 
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.19'
4
+ VERSION = '0.0.21'
5
5
  end
data/skills/fp/SKILL.md CHANGED
@@ -352,6 +352,19 @@ FeatureParity section in `AGENTS.md`):
352
352
  fp init --project <slug> --surface <surface>
353
353
  ```
354
354
 
355
+ If the project itself doesn't exist yet, create it first — you don't need a human to do
356
+ this in the web app. The project lands on the account of whoever minted your key, and
357
+ your key reaches it immediately:
358
+
359
+ ```bash
360
+ fp projects create <slug> --name "<Name>" --surfaces api,web
361
+ ```
362
+
363
+ Creation is subject to the owner's plan allowance; a free account gets one project, and
364
+ the command relays the upgrade message if that's used up. `fp projects delete` exists but
365
+ asks a human to type the slug back (or `--yes` in scripts) and is owner-only — don't reach
366
+ for it to tidy up; a project's requirements, evidence, and history go with it.
367
+
355
368
  ### Triage requirement quality (`fp analyze`)
356
369
 
357
370
  `fp analyze` checks how well requirements are written and whether they belong in the
@@ -404,6 +417,8 @@ not as the fix — you still write the proposal.
404
417
  | View matrix | `fp matrix --project X` |
405
418
  | List surfaces | `fp surfaces --project X` |
406
419
  | List projects | `fp projects` |
420
+ | Create a project | `fp projects create <slug> --name "..." --surfaces api,web` |
421
+ | View / edit a project | `fp projects show <slug>`, `fp projects edit <slug> --name "..."` |
407
422
 
408
423
  All commands support `--json` for machine-readable output.
409
424
 
@@ -29,22 +29,51 @@ fp setup --api-key fp_... --profile ci # Named profile
29
29
 
30
30
  ### fp projects
31
31
 
32
- List accessible projects.
32
+ List accessible projects, or view, create, edit and delete one.
33
33
 
34
34
  ```bash
35
- fp projects
35
+ fp projects # list
36
36
  fp projects --json
37
+ fp projects show <slug> # details + surfaces
38
+ fp projects create <slug> --name "Name" [--description "..."] [--surfaces api,web]
39
+ fp projects edit <slug> [--name "..."] [--description "..."]
40
+ fp projects delete <slug> [--yes]
37
41
  ```
38
42
 
43
+ `create` is open to agents: the project is owned by the human who minted the key
44
+ (it counts against *their* plan allowance — a free account gets one project, and the
45
+ command relays the server's upgrade message if that's used up), and the same key reaches
46
+ the new project on its next call. `--name` defaults to the slug. `--surfaces` takes
47
+ surface names, comma-separated; each becomes a surface with a derived key and default
48
+ kind/audience, the same as `fp surfaces add` without flags.
49
+
50
+ `delete` is guarded the way `gh repo delete` is. In a terminal it shows the project's
51
+ surfaces and requirement count and asks you to type the slug back; a mismatch aborts.
52
+ Outside a terminal it refuses unless `--yes` is passed. Server-side, only a project
53
+ owner — or a key minted by one — may delete; a member's key gets 403.
54
+
39
55
  ### fp surfaces
40
56
 
41
- List surfaces for a project.
57
+ List, view, create, edit and delete a project's surfaces.
42
58
 
43
59
  ```bash
44
60
  fp surfaces --project stowzilla
45
61
  fp surfaces --project stowzilla --json
62
+ fp surfaces show <key> --project stowzilla
63
+ fp surfaces add <key> --project stowzilla [--name "..."] [--kind api|web|android|ios|other] [--audience ...] [--repo org/repo] [--default-branch main]
64
+ fp surfaces edit <key> --project stowzilla [--name "..."] [--kind ...] [--audience ...] [--repo ...] [--default-branch ...] [--key new_key]
65
+ fp surfaces delete <key> --project stowzilla [--yes]
46
66
  ```
47
67
 
68
+ A key is unique within a project. `add` on a key that already exists fails and points
69
+ you at `edit` — use `edit` to fill in the kind or repo of a surface that `fp projects
70
+ create --surfaces` made with defaults. `edit` only sends the flags you pass. `delete`
71
+ asks you to type the key back in a terminal and refuses without `--yes` otherwise.
72
+
73
+ A surface that requirements still require can't be deleted or re-keyed — the server
74
+ refuses and names them. Taking a surface off a requirement is a human's call, so ask
75
+ one rather than working around it.
76
+
48
77
  ### fp list
49
78
 
50
79
  List requirements.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featureparity
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.19
4
+ version: 0.0.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-17 00:00:00.000000000 Z
11
+ date: 2026-10-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: net-http