featureparity 0.0.18 → 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: c4c40ac26de064785c94120334d42b1e5e906803db09ad49e9954be91aa85ed3
4
- data.tar.gz: a83874973a35ed0335c58f9fe49b71fb66f4a5ba97e028a806c34929f4e5718f
3
+ metadata.gz: 65fc8c40a37a8d05f6fd566fd6362a5b5f489c3ee516ea4643c37afedb0d4534
4
+ data.tar.gz: 2a05968f741537fbc82fc0cc12cb67d7f4bf9fa2c5cebd11bc3514a9637e1745
5
5
  SHA512:
6
- metadata.gz: 75f833ce8cbf39c68a0ba50c12bee2f369d3d374ab3d8f2ce3468af433e18af6190b0d17a57a1e102fb1c58da80c8a0dbfea5d0f96bab78f0101f4f47c607aeb
7
- data.tar.gz: 30c2e791fdefc1be0b83b13b4823b520d457dcd9c44adcd79b3f403525c338fc25dd73486f4b4388a7a43de641b7b81244116cdced8db5101330bc8b983b4888
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
@@ -112,9 +116,9 @@ module Fp
112
116
 
113
117
  Or add a named profile manually:
114
118
 
115
- fp profile add galen --api-key fp_...
116
- fp --profile galen projects
117
- FP_PROFILE=galen fp projects
119
+ fp profile add pari --api-key fp_...
120
+ fp --profile pari projects
121
+ FP_PROFILE=pari fp projects
118
122
 
119
123
  If both FP_API_KEY and a profile are set, FP_API_KEY wins.
120
124
 
@@ -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
@@ -69,7 +69,7 @@ module Fp
69
69
  domain = URI.parse(effective_url).host.sub(/^api\./, '') rescue 'featureparity.dev'
70
70
 
71
71
  puts "Get your API key from: https://#{domain}"
72
- puts "(Project Settings → API Keys → Mint Key)"
72
+ puts "(Account → Agents & API keys → Mint Key)"
73
73
  puts
74
74
  print "Paste your API key (starts with fp_): "
75
75
  $stdout.flush
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.18'
4
+ VERSION = '0.0.20'
5
5
  end
data/skills/fp/SKILL.md CHANGED
@@ -37,7 +37,9 @@ echo $FP_API_KEY
37
37
  fp profile list
38
38
  ```
39
39
 
40
- If not set, ask the user for their API key or have them run `fp setup`.
40
+ If not set, ask the user for their API key or have them run `fp setup`. Keys are
41
+ minted under **Account → Agents & API keys** in the web app. One key works across
42
+ every project the user belongs to; never ask for a per-project key.
41
43
 
42
44
  ## Core workflows
43
45
 
@@ -350,6 +352,19 @@ FeatureParity section in `AGENTS.md`):
350
352
  fp init --project <slug> --surface <surface>
351
353
  ```
352
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
+
353
368
  ### Triage requirement quality (`fp analyze`)
354
369
 
355
370
  `fp analyze` checks how well requirements are written and whether they belong in the
@@ -402,6 +417,8 @@ not as the fix — you still write the proposal.
402
417
  | View matrix | `fp matrix --project X` |
403
418
  | List surfaces | `fp surfaces --project X` |
404
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 "..."` |
405
422
 
406
423
  All commands support `--json` for machine-readable output.
407
424
 
@@ -17,7 +17,9 @@ These work with any command:
17
17
 
18
18
  ### fp setup
19
19
 
20
- Interactive first-run configuration. Validates API key and saves a profile.
20
+ Interactive first-run configuration. Validates API key and saves a profile. Mint a
21
+ key under **Account → Agents & API keys** in the web app; one key works across every
22
+ project you belong to.
21
23
 
22
24
  ```bash
23
25
  fp setup # Interactive
@@ -27,13 +29,29 @@ fp setup --api-key fp_... --profile ci # Named profile
27
29
 
28
30
  ### fp projects
29
31
 
30
- List accessible projects.
32
+ List accessible projects, or view, create, edit and delete one.
31
33
 
32
34
  ```bash
33
- fp projects
35
+ fp projects # list
34
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]
35
41
  ```
36
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
+
37
55
  ### fp surfaces
38
56
 
39
57
  List surfaces for a project.
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.18
4
+ version: 0.0.20
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-16 00:00:00.000000000 Z
11
+ date: 2026-10-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: net-http