featureparity 0.0.19 → 0.0.20

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: 65fc8c40a37a8d05f6fd566fd6362a5b5f489c3ee516ea4643c37afedb0d4534
4
+ data.tar.gz: 2a05968f741537fbc82fc0cc12cb67d7f4bf9fa2c5cebd11bc3514a9637e1745
5
5
  SHA512:
6
- metadata.gz: a34620d3c364b92651707e89882943a7dd6abb22346192f4f3bce117471708c843bbc9a1606a661f66f4debcabed20fd1e109073ad3b1016aa386be7ffef36bc
7
- data.tar.gz: a4647cc613c7aba590a4611fb20a7b1ba0d1ae2b4f70950cf1230092b40689721a86962a4d3b58ddb261e4b3f5c3fd29e84f2d096bba268cd79adf2704cd7aee
6
+ metadata.gz: a9dc847a7281c8b018547b97fb3c06b9bbcf0bcaee596cf1afb098428f025a48af5b01acaad7b76583ed18f0385602dbc9ccae47f5a9605220bacfe31f543723
7
+ data.tar.gz: 95909562928c8f9780eafed047018e8d9e752d6a3e10a5feeb09cdd716ac14945e39948d692aa04102d233f1403570f8e4e926225257093e56a7ed08c1af7b1e
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")
@@ -14,6 +14,10 @@ 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
@@ -151,6 +155,17 @@ module Fp
151
155
  # List projects
152
156
  fp projects
153
157
 
158
+ # Create a project (agents can do this — the project lands on the account of
159
+ # whoever minted the key, and the same key reaches it immediately)
160
+ fp projects create calib-rate --name "Calib Rate" --surfaces api,web
161
+ fp projects show calib-rate
162
+ fp projects edit calib-rate --description "Calibration tooling"
163
+
164
+ # Delete a project. Interactive: type the slug to confirm. Non-interactive
165
+ # sessions must pass --yes, or the command refuses. Owners only.
166
+ fp projects delete calib-rate
167
+ fp projects delete calib-rate --yes
168
+
154
169
  # List the surfaces a project tracks (requirements may only name these)
155
170
  fp surfaces --project stowzilla
156
171
 
@@ -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
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.20'
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,13 +29,29 @@ 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
57
  List surfaces for a project.
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.19
4
+ version: 0.0.20
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla