wiq-cli 0.5.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eb99112754a10af0b80b20d71425f95e864fb28a3db78a1c3d0e7d75340ebf8f
4
- data.tar.gz: ee12d73a7e5053dd48540c73c35e4327bb3f4456998f3182725b2a1d40a99403
3
+ metadata.gz: ec283a11b87c6ba8e2f8db5bf5a56f5e02fbf092aa3fb93fab54d08660553aa3
4
+ data.tar.gz: 1afd0ade5f5de6770c74acdd5d3fb0f2c68721f1537b0344a2ab6a401f32a400
5
5
  SHA512:
6
- metadata.gz: 92e5c6842312a60ad606f711b5c6893c2a2c761e181b0c999dcf0fcb6bb1e92f8cc3b25dadd6c365656b91f7b242666b43131e5680ec440036c67868f04eb794
7
- data.tar.gz: a4e06d43acf373996c31593b6a736555ab9b5aa71a4d7c0baf606f8dc76fa090f3ae7519820ec04ed6ca1167375b977261d12312bca2fe07b70de86f902430e6
6
+ metadata.gz: 93172776f6f73aac7093aa64b04bade0de00cc633f6b8e17b6a4c3a7898c3b8148fc98b9ccb108ab22df639b2e56c26b167e6f85ed22013cadd444f4d1fac574
7
+ data.tar.gz: 075e0ab62fb1098c04a0a7714825eef7ea0b8ad450e1d21703cbf2fe6c60b8d58f8ca2d2616ba6b8ead65ec9237db681737408476692aab9b0443847637d2ebc
data/README.md CHANGED
@@ -7,9 +7,10 @@
7
7
  > recommended for production-critical automation. Pin your gem version
8
8
  > if you build anything load-bearing on top of it.
9
9
 
10
- Read-only command-line interface for [WrestlingIQ](https://www.wrestlingiq.com).
10
+ Command-line interface for [WrestlingIQ](https://www.wrestlingiq.com).
11
11
  Designed to be driven by both humans and AI agents (Claude Code, Cursor,
12
- etc.) via per-user personal access tokens (PATs).
12
+ etc.) via per-user personal access tokens (PATs). Every token can read;
13
+ tokens minted with a write scope can also work the leads pipeline.
13
14
 
14
15
  ## Getting started
15
16
 
@@ -39,6 +40,13 @@ coach, your CLI will be able to run admin reports; if you're not, it
39
40
  won't. Either way, the token can be revoked anytime from the same
40
41
  page.
41
42
 
43
+ Tokens are read-only unless you tick a write permission when minting
44
+ one. Today that's **Create and edit leads** (`prospects:write`) and
45
+ **Create reports** (`reports:write`). A team admin has to enable each
46
+ capability for the club first under **Settings → API Access**; the
47
+ token picker only offers what the team allows. Scopes are fixed for the
48
+ life of a token — to change them, revoke it and mint a new one.
49
+
42
50
  ### 3. Log in
43
51
 
44
52
  ```bash
@@ -81,6 +89,7 @@ Now you can ask Claude things like:
81
89
  ```bash
82
90
  wiq rosters list # Browse rosters
83
91
  wiq prospects summary # Pipeline dashboard
92
+ wiq prospects advance 123 trial_scheduled # Move a lead forward (needs prospects:write)
84
93
  wiq events list --start 2026-05-01 --end 2026-05-31 # Calendar
85
94
  wiq workflows list # Curated multi-step recipes
86
95
  wiq reports types # 36 report types w/ recommendations
@@ -135,9 +144,11 @@ output (the CLI never logs your token — paste the full JSON safely).
135
144
 
136
145
  ## Status
137
146
 
138
- v0.1.029 commands across 16 groups, 117 RSpec smoke examples.
139
- Reads-only except for report submission (the API's canonical async
140
- pattern). Write commands deferred see `docs/deferred.md`.
147
+ v0.6.1 — reads across every group, plus report submission and a
148
+ scoped write surface for the prospects pipeline (`prospect_families
149
+ create/update/note`, `prospects create/update/advance`) gated by the
150
+ `prospects:write` token scope. Other write commands deferred — see
151
+ `docs/deferred.md`.
141
152
 
142
153
  ## License
143
154
 
data/docs/deferred.md CHANGED
@@ -81,17 +81,14 @@ Last updated: 2026-05-12 (after L1 ships).
81
81
  - **`wiq url parse <url>`** — extract team/roster/event IDs from WIQ web
82
82
  URLs. Initial-plan item; useful for agents pasted URLs by humans.
83
83
  - **`wiq paid_sessions create/update`**, **`wiq rosters create/update`**,
84
- etc. — every write path on every resource. v1 is reads-only except
85
- report submission. Cherry-pick as customers ask.
86
- - **Prospect write commands.** API supports `POST /prospect_families`,
87
- `PATCH /prospect_families/:id` (incl. assigned_coach), `DELETE`,
88
- `POST /prospect_families/:id/prospects`, `PATCH /prospects/:id`
89
- (incl. stage transitions via `advance_to!`), and
90
- `POST /prospect_families/:id/notes` (with `clear_follow_up_for[]` /
91
- `add_follow_up_for[]` side-effect params). Reads-only in v1 — most
92
- pipeline edits happen in the drawer UI today, and exposing
93
- stage-transition writes without first watching agents use them is
94
- asking for trouble.
84
+ etc. — every other write path. Each needs a server-side capability in
85
+ `ApiCapability::REGISTRY` first (writes fail closed without one), then
86
+ a CLI command. Cherry-pick as customers ask.
87
+ - **Prospect deletes.** Shipped in v0.6.0: `prospect_families
88
+ create/update/note` and `prospects create/update/advance` behind the
89
+ `prospects:write` scope. `DELETE /prospect_families/:id` and
90
+ `DELETE /prospects/:id` remain out of reach — destroy actions are in
91
+ no API capability server-side, by design.
95
92
 
96
93
  ## Packaging / distribution
97
94
 
@@ -133,16 +130,10 @@ Last updated: 2026-05-12 (after L1 ships).
133
130
 
134
131
  ## Backend asks (push back to WIQ app team)
135
132
 
136
- - **`GET /api/v1/prospects?query=<str>` 500s on ambiguous ORDER BY.**
137
- Found during the Phase 5 agent telemetry pass. The controller does
138
- `Prospect.joins(:prospect_family).merge(ProspectFamily.search(query))
139
- .order(created_at: :desc)` both `prospects` and `prospect_families`
140
- have `created_at`, so the join makes the order ambiguous. One-line
141
- fix on the WIQ-app side: change to
142
- `.order("prospects.created_at DESC")` (or
143
- `.order(Prospect.arel_table[:created_at].desc)`). The CLI documents
144
- the workaround (use `wiq prospect_families list --query` — same
145
- matches, returns families with prospects embedded) until this ships.
133
+ - ~~**`GET /api/v1/prospects?query=<str>` 500s on ambiguous ORDER BY.**~~ —
134
+ SHIPPED (WIQ-app commit 7ac9d90a5, 2026-05-21, qualifies the ORDER BY
135
+ as `prospects.created_at`). `wiq prospects list --query` works; the
136
+ KNOWN BUG note was removed from its long_desc in v0.6.0.
146
137
 
147
138
 
148
139
  - ~~**`days_threshold` permit fix on `Api::V1::ReportsController`**~~ —
@@ -288,13 +288,49 @@ file with the WIQ team.
288
288
  permit list as of the May 2026 WIQ-app fix. The CLI's
289
289
  `--days-threshold` flag lands unchanged on the model.
290
290
 
291
+ **PAT write capabilities (`Api::V1::BaseController#enforce_pat_restrictions`):**
292
+
293
+ PAT traffic is read-only by default. A non-GET request succeeds only when
294
+ `ApiCapability.for(controller, action)` resolves to a capability that
295
+ (a) the team has enabled (`Team#api_write_capabilities`, editable by an
296
+ admin at `/settings/team/api_access`) and (b) the token carries
297
+ (`PersonalAccessToken#scopes`, immutable after mint, coach tokens only).
298
+ Registry today:
299
+
300
+ | Capability | Actions |
301
+ | --- | --- |
302
+ | `prospects:write` | `prospect_families#create/#update`, `prospects#create/#update`, `prospect_family_notes#create` |
303
+ | `reports:write` | `reports#create` |
304
+
305
+ Unregistered writes (every `destroy`, everything else) fail closed with
306
+ `"This endpoint is not writable with a personal access token."` The other
307
+ two denials name the capability verbatim — the CLI regexes on them:
308
+
309
+ | Body | CLI `code` |
310
+ | --- | --- |
311
+ | `This endpoint is not writable with a personal access token.` | `pat_write_unsupported` |
312
+ | `This team has not enabled <cap> for API access. …` | `capability_disabled_for_team` |
313
+ | `This token lacks the <cap> scope. Revoke it and mint a new token …` | `token_missing_scope` |
314
+
315
+ Team policy is re-checked per request, so an admin turning a capability
316
+ off disables every token holding it immediately. Every PAT call writes an
317
+ audit row (`PersonalAccessTokenAuditJob`) carrying the capability + scopes.
318
+ `GET /api/v1/personal_access_tokens` now includes `scopes: [...]` per
319
+ token; the CLI stores it at login and shows it in `auth status` / `doctor`.
320
+
321
+ Prospect-specific PAT rule (`ProspectsController#update`): stage changes
322
+ are not forced for token callers, so `advance_to!` refuses downgrades and
323
+ moves out of a terminal stage. The controller turns that silent no-op
324
+ into a 422 `"Stage cannot move from <a> to <b> with a personal access
325
+ token …"` → CLI code `stage_transition_refused`.
326
+
291
327
  **Auth model (POST /api/v1/reports):**
292
328
 
293
329
  - Every report POST requires a `CoachProfile`-bound PAT on the same team
294
- (`coach_belongs_to_team?` in `ReportPolicy#create?`). Parent/wrestler
295
- PATs return 403 with body `"Personal access tokens are read-only..."`
296
- via `enforce_pat_restrictions` reports is the one write currently on
297
- the PAT allowlist.
330
+ (`coach_belongs_to_team?` in `ReportPolicy#create?`) AND the
331
+ `reports:write` capability (backfilled onto every existing team and
332
+ token when per-token scopes shipped, so nothing changed for existing
333
+ scripts). See "PAT write capabilities" below.
298
334
  - A subset of report types — the 9 listed in `Report.finance_types`
299
335
  (Membership, PaidSessionAccounting, RecurringDonor, DonationTransaction,
300
336
  FundraiserSummary, FundraiserAccounting, InProgressRegistration,
@@ -343,7 +379,7 @@ DiscountedSubscriptionsReport, **DonationTransactionReport**,
343
379
  PaidSessionAddOnDetailReport, PaidSessionAddOnSummaryReport,
344
380
  **PracticeAttendanceReport**, **RecurringDonorReport**, RegistrationAnswerReport,
345
381
  **RegistrationFinanceSummaryReport**, **RosterReport**, **RosterStatsReport**,
346
- **ScholarshipAuditReport**, SessionRegistrationAnswerReport,
382
+ **ScholarshipAuditReport**, **SessionRegistrationAnswerReport**,
347
383
  TeamRegistrationRosterReport, UsawExpiredReport, UsawExportReport, UsawReport,
348
384
  **WinLossReport**, **WrestlersWithoutSubscriptionsReport**`
349
385
 
data/lib/wiq/cli.rb CHANGED
@@ -68,10 +68,10 @@ module Wiq
68
68
  desc "locations SUBCOMMAND", "Team locations / sites — ID discovery for --location flags (list, show)"
69
69
  subcommand "locations", Wiq::Commands::Locations
70
70
 
71
- desc "prospects SUBCOMMAND", "Prospect pipeline — individual kids (list, show, summary)"
71
+ desc "prospects SUBCOMMAND", "Prospect pipeline — individual kids (list, show, summary; create, update, advance need prospects:write)"
72
72
  subcommand "prospects", Wiq::Commands::Prospects
73
73
 
74
- desc "prospect_families SUBCOMMAND", "Prospect pipeline — households (list, show, notes)"
74
+ desc "prospect_families SUBCOMMAND", "Prospect pipeline — households (list, show, notes, stage_changes, linked_answers; create, update, note need prospects:write)"
75
75
  subcommand "prospect_families", Wiq::Commands::ProspectFamilies
76
76
 
77
77
  desc "workflows SUBCOMMAND", "Named multi-step recipes for common club-admin questions (list, show)"
data/lib/wiq/client.rb CHANGED
@@ -9,7 +9,8 @@ module Wiq
9
9
  # Thin wrapper around Faraday that:
10
10
  # - Adds Authorization: Bearer <pat>
11
11
  # - Parses JSON requests and responses
12
- # - Retries on 429 and 5xx (Retry-After honored)
12
+ # - Retries on 429 and 5xx (Retry-After honored) — GET/HEAD only, so a
13
+ # write is never replayed
13
14
  # - Translates non-2xx responses into Wiq::APIError
14
15
  # - Exposes paginate(path, params) that walks the Link: rel=next chain
15
16
  class Client
@@ -36,6 +37,10 @@ module Wiq
36
37
  request(:put, path, body: body)
37
38
  end
38
39
 
40
+ def patch(path, body = {})
41
+ request(:patch, path, body: body)
42
+ end
43
+
39
44
  def delete(path, params = {})
40
45
  request(:delete, path, params: params)
41
46
  end
@@ -21,8 +21,14 @@ module Wiq
21
21
 
22
22
  Verification: the CLI hits `GET /api/v1/personal_access_tokens` to
23
23
  confirm the token works AND fetch the bound profile (display_name,
24
- team_name, type) in one round trip. That metadata is shown after
25
- login and stored alongside the token.
24
+ team_name, type) plus the token's write `scopes` in one round trip.
25
+ That metadata is shown after login and stored alongside the token.
26
+
27
+ Scopes: every PAT can read. Writes need a capability (currently
28
+ prospects:write, reports:write) that the team admin has enabled
29
+ under Settings → API Access AND that the token was minted with.
30
+ Scopes are immutable — to change them, revoke and mint a new
31
+ token, then re-run this command with --force.
26
32
  DESC
27
33
  method_option :token, type: :string, desc: "PAT value (otherwise prompted interactively)"
28
34
  method_option :force, type: :boolean, default: false,
@@ -64,7 +70,8 @@ module Wiq
64
70
  token: token,
65
71
  token_prefix: metadata[:token_prefix],
66
72
  name: metadata[:name],
67
- profile: metadata[:profile]
73
+ profile: metadata[:profile],
74
+ scopes: metadata[:scopes]
68
75
  )
69
76
 
70
77
  render(
@@ -74,10 +81,11 @@ module Wiq
74
81
  "token_prefix" => metadata[:token_prefix],
75
82
  "name" => metadata[:name],
76
83
  "profile" => metadata[:profile],
84
+ "scopes" => metadata[:scopes],
77
85
  "stored_at" => Wiq::Credentials.for_host(host, alias_name)["stored_at"]
78
86
  }.compact,
79
87
  summary: profile_summary("Stored PAT for #{host} as #{alias_name.inspect}",
80
- metadata[:profile]),
88
+ metadata[:profile], metadata[:scopes]),
81
89
  breadcrumbs: [
82
90
  { "cmd" => "wiq auth status --as #{alias_name}",
83
91
  "description" => "Verify the stored token works" },
@@ -93,7 +101,9 @@ module Wiq
93
101
 
94
102
  Performs a best-effort live probe against
95
103
  `/api/v1/personal_access_tokens` to confirm the token still works
96
- and surface the live last_used_at. If the probe fails (network,
104
+ and surface the live last_used_at and write `scopes` (empty =
105
+ read-only). Check `live_scopes` before attempting a write command
106
+ such as `wiq prospects advance`. If the probe fails (network,
97
107
  revoked token, host unreachable), the error appears in `live_error`
98
108
  rather than aborting the command — local state is always shown.
99
109
 
@@ -126,9 +136,11 @@ module Wiq
126
136
  "token_prefix" => store_entry["token_prefix"],
127
137
  "stored_name" => store_entry["name"],
128
138
  "stored_profile" => store_entry["profile"],
139
+ "stored_scopes" => store_entry["scopes"],
129
140
  "live_name" => live_metadata&.dig(:name),
130
141
  "live_last_used_at" => live_metadata&.dig(:last_used_at),
131
142
  "live_profile" => live_metadata&.dig(:profile),
143
+ "live_scopes" => live_metadata&.dig(:scopes),
132
144
  "live_error" => live_error
133
145
  }.compact
134
146
 
@@ -138,7 +150,8 @@ module Wiq
138
150
  : "No alias resolvable for this host."
139
151
  else
140
152
  profile_summary("Token reachable (alias=#{cfg.alias_name})",
141
- live_metadata&.dig(:profile) || store_entry["profile"])
153
+ live_metadata&.dig(:profile) || store_entry["profile"],
154
+ live_metadata ? live_metadata[:scopes] : store_entry["scopes"])
142
155
  end
143
156
 
144
157
  render(data, summary: summary)
@@ -231,25 +244,36 @@ module Wiq
231
244
  key: "personal_access_tokens"
232
245
  )
233
246
  match = rows.find { |r| r["token_prefix"] == prefix }
234
- return { token_prefix: prefix, name: nil, last_used_at: nil, profile: nil } unless match
247
+ return { token_prefix: prefix, name: nil, last_used_at: nil, profile: nil, scopes: nil } unless match
235
248
 
236
249
  {
237
250
  token_prefix: match["token_prefix"],
238
251
  name: match["name"],
239
252
  last_used_at: match["last_used_at"],
240
- profile: match["profile"]
253
+ profile: match["profile"],
254
+ # Servers predating per-token scopes omit the key; treat as read-only.
255
+ scopes: Array(match["scopes"])
241
256
  }
242
257
  end
243
258
 
244
- def profile_summary(prefix, profile)
245
- return "#{prefix}." unless profile
259
+ def profile_summary(prefix, profile, scopes = nil)
260
+ scope_note =
261
+ if scopes.nil?
262
+ nil
263
+ elsif scopes.empty?
264
+ "scopes: read-only"
265
+ else
266
+ "scopes: #{scopes.join(", ")}"
267
+ end
268
+ return [prefix, scope_note].compact.join("; ") + "." unless profile
246
269
 
247
270
  who = profile["display_name"]
248
271
  where = profile["team_name"]
249
272
  kind = profile["type"]
250
273
  tail = [who, where].compact.reject(&:empty?).join(" @ ")
251
274
  tail += " (#{kind})" if kind && !kind.empty?
252
- tail.empty? ? "#{prefix}." : "#{prefix} — #{tail}."
275
+ head = tail.empty? ? prefix : "#{prefix} — #{tail}"
276
+ [head, scope_note].compact.join("; ") + "."
253
277
  end
254
278
  end
255
279
  end
@@ -15,6 +15,9 @@ module Wiq
15
15
  6. Live reachability + auth probe via
16
16
  `GET /api/v1/personal_access_tokens`
17
17
  7. Bound profile (display_name, type, team) for the calling token
18
+ 8. Write scopes on the token (empty = read-only; writes such as
19
+ `wiq prospects advance` need the matching capability enabled
20
+ by the team AND present on the token)
18
21
 
19
22
  Exits non-zero if any check fails. Agents should run this first
20
23
  when handed an unfamiliar shell to confirm they can actually call
@@ -59,6 +62,12 @@ module Wiq
59
62
  checks << check("Bound profile",
60
63
  "#{profile["display_name"]} (#{profile["type"]}) @ #{profile["team_name"]}",
61
64
  ok: !profile["display_name"].nil?)
65
+ scopes = Array(match["scopes"])
66
+ checks << check("Write scopes",
67
+ scopes.empty? ? "none (read-only)" : scopes.join(", "),
68
+ ok: true,
69
+ hint: scopes.empty? ? "Reads only. Mint a token with prospects:write to use " \
70
+ "prospect write commands." : nil)
62
71
  else
63
72
  checks << check("Reachability + auth", "200 OK", ok: true)
64
73
  checks << check("Bound profile", "(token not in own user's PAT list?)",
@@ -4,6 +4,7 @@ module Wiq
4
4
  module Commands
5
5
  class ProspectFamilies < Base
6
6
  SORT_OPTIONS = %w[newest oldest_followup oldest_contact next_trial].freeze
7
+ ACTIVITY_TYPES = %w[phone_call sms email in_person other].freeze
7
8
 
8
9
  desc "list", "List prospect families (one row per household)"
9
10
  long_desc <<~DESC
@@ -124,6 +125,231 @@ module Wiq
124
125
  ]
125
126
  )
126
127
  end
128
+
129
+ desc "stage_changes FAMILY_ID", "Stage-transition audit log for every prospect in a family"
130
+ long_desc <<~DESC
131
+ Append-only history of every stage move for every prospect (kid)
132
+ in the family, newest first. Each row: prospect_id + child_name,
133
+ from_stage (null on the initial create), to_stage, changed_at,
134
+ changed_via, and changed_by (profile, or null for system moves).
135
+
136
+ changed_via values:
137
+ manual A person moved it (drawer, note shortcut, or a
138
+ PAT write — changed_by names the coach)
139
+ trial_registration Family bought a trial session
140
+ check_in Kid checked in to a trial practice
141
+ trial_expired Trial passes ran out
142
+ paid_registration Registered for a paid session (→ converted)
143
+ subscription Started a recurring membership (→ converted)
144
+ backfill Historical import
145
+ bulk_archive The stale-lead archive task
146
+
147
+ Use this to answer "did this lead actually trial or skip straight
148
+ to converted?" — the prospect row only carries its CURRENT stage —
149
+ and to review what an agent or coach did before attempting another
150
+ `wiq prospects advance` (moves are forward-only via PAT).
151
+ DESC
152
+ method_option :all, type: :boolean, default: false
153
+ def stage_changes(family_id)
154
+ records, total = fetch_index("/api/v1/prospect_families/#{family_id}/stage_changes",
155
+ { "per_page" => 50 },
156
+ key: "stage_changes")
157
+ render_index(
158
+ records, total: total,
159
+ summary: "Listed #{records.size} stage changes for family #{family_id}.",
160
+ breadcrumbs: [
161
+ { "cmd" => "wiq prospect_families show #{family_id}", "description" => "Back to the family" },
162
+ { "cmd" => "wiq prospect_families notes #{family_id}", "description" => "Contact log alongside the stage history" }
163
+ ]
164
+ )
165
+ end
166
+
167
+ desc "linked_answers FAMILY_ID", "Registration answers on the member profiles linked to a family"
168
+ long_desc <<~DESC
169
+ Trial-purchase leads skip the interest form, so their phone number
170
+ and other intake details usually exist only as registration answers
171
+ on the profiles the family is linked to: the guardian (parent or
172
+ coach) and each prospect's wrestler_profile. This returns one entry
173
+ per linked profile (profile_id, profile_type, full_name, relation =
174
+ guardian | wrestler) with its registration_answers, deduped to the
175
+ most recent answer per question and ordered by the question's
176
+ display order.
177
+
178
+ Not paginated. Coach visibility is enforced server-side, so
179
+ admin-only questions are omitted for non-admin tokens. A family
180
+ with no linked profiles returns an empty list.
181
+
182
+ Cheaper first stop: `wiq prospect_families show` already surfaces
183
+ `phone_suggestions` for blank-phone families. Use this when you
184
+ need everything known about the household before a call.
185
+ DESC
186
+ def linked_answers(family_id)
187
+ data = client.get("/api/v1/prospect_families/#{family_id}/linked_answers")
188
+ profiles = Array(data["linked_profiles"])
189
+ answer_count = profiles.sum { |p| Array(p["registration_answers"]).size }
190
+ render_index(
191
+ profiles,
192
+ summary: "#{profiles.size} linked profiles with #{answer_count} visible registration answers for family #{family_id}.",
193
+ breadcrumbs: [
194
+ { "cmd" => "wiq prospect_families show #{family_id}", "description" => "Back to the family" },
195
+ { "cmd" => "wiq prospect_families update #{family_id} --phone <number>",
196
+ "description" => "Copy a found phone onto the family record" }
197
+ ]
198
+ )
199
+ end
200
+
201
+ desc "create", "Create a prospect family (household) [prospects:write]"
202
+ long_desc <<~DESC
203
+ POSTs to /api/v1/prospect_families. Requires a coach PAT minted
204
+ with the prospects:write scope on a team that has enabled it
205
+ (Settings → API Access). Only --first-name is required; --email
206
+ must be a valid address when given.
207
+
208
+ A family is the household record — add the kid(s) afterwards with
209
+ `wiq prospects create <family_id> --first-name ...`. Check for an
210
+ existing household first with `wiq prospect_families list --query
211
+ <name or email or phone>` to avoid duplicates.
212
+
213
+ --source is the machine-readable origin (e.g. web_form, walk_in,
214
+ referral); --hear-about-us is the family's free-text answer to
215
+ "How did you hear about us?". --assigned-coach takes a
216
+ coach_profile id. --guardian-id/--guardian-type link an existing
217
+ ParentProfile or CoachProfile as the guardian.
218
+ DESC
219
+ method_option :first_name, type: :string, required: true, desc: "contact_first_name (required)"
220
+ method_option :last_name, type: :string, desc: "contact_last_name"
221
+ method_option :email, type: :string, desc: "contact_email"
222
+ method_option :phone, type: :string, desc: "contact_phone"
223
+ method_option :hear_about_us, type: :string, desc: "Free-text 'How did you hear about us?'"
224
+ method_option :source, type: :string, desc: "Lead source tag (free text)"
225
+ method_option :assigned_coach, type: :numeric, desc: "assigned_coach_id (coach_profile id)"
226
+ method_option :guardian_id, type: :numeric, desc: "Existing profile id to link as guardian"
227
+ method_option :guardian_type, type: :string, enum: %w[ParentProfile CoachProfile],
228
+ desc: "Profile type for --guardian-id"
229
+ def create
230
+ family = client.post("/api/v1/prospect_families", { "prospect_family" => build_family_attrs })
231
+ render(family,
232
+ summary: "Created prospect family #{family["id"]} — #{family["contact_name"]}.",
233
+ breadcrumbs: [
234
+ { "cmd" => "wiq prospects create #{family["id"]} --first-name <child>",
235
+ "description" => "Add the kid(s) to this household" },
236
+ { "cmd" => "wiq prospect_families note #{family["id"]} --activity-type phone_call --content \"...\"",
237
+ "description" => "Log the first contact" }
238
+ ])
239
+ end
240
+
241
+ desc "update ID", "Edit a prospect family's contact info or assignment [prospects:write]"
242
+ long_desc <<~DESC
243
+ PATCHes /api/v1/prospect_families/:id with only the flags you
244
+ pass. Requires the prospects:write scope (team-enabled + on the
245
+ token). Typical uses: fill in a missing phone (see
246
+ `phone_suggestions` on `wiq prospect_families show`), reassign a
247
+ coach, or correct a misspelled name.
248
+ DESC
249
+ method_option :first_name, type: :string, desc: "contact_first_name"
250
+ method_option :last_name, type: :string, desc: "contact_last_name"
251
+ method_option :email, type: :string, desc: "contact_email"
252
+ method_option :phone, type: :string, desc: "contact_phone"
253
+ method_option :hear_about_us, type: :string, desc: "Free-text 'How did you hear about us?'"
254
+ method_option :source, type: :string, desc: "Lead source tag (free text)"
255
+ method_option :assigned_coach, type: :numeric, desc: "assigned_coach_id (coach_profile id)"
256
+ method_option :guardian_id, type: :numeric, desc: "Existing profile id to link as guardian"
257
+ method_option :guardian_type, type: :string, enum: %w[ParentProfile CoachProfile],
258
+ desc: "Profile type for --guardian-id"
259
+ def update(id)
260
+ attrs = build_family_attrs
261
+ if attrs.empty?
262
+ raise Wiq::Error.new("Nothing to update — pass at least one field flag.",
263
+ code: "no_fields",
264
+ hint: "See `wiq prospect_families update --help` for the editable fields.")
265
+ end
266
+
267
+ family = client.patch("/api/v1/prospect_families/#{id}", { "prospect_family" => attrs })
268
+ render(family,
269
+ summary: "Updated prospect family #{family["id"]} — #{family["contact_name"]}.",
270
+ breadcrumbs: [
271
+ { "cmd" => "wiq prospect_families show #{family["id"]}", "description" => "Refetch the family" }
272
+ ])
273
+ end
274
+
275
+ desc "note FAMILY_ID", "Log a contact / add a note to a prospect family [prospects:write]"
276
+ long_desc <<~DESC
277
+ POSTs to /api/v1/prospect_families/:family_id/notes. Requires the
278
+ prospects:write scope (team-enabled + on the token). The note is
279
+ authored by the coach who minted the token.
280
+
281
+ --activity-type marks the note as a logged contact: the server
282
+ bumps last_contacted_at on every ACTIVE prospect in the family,
283
+ which is what clears "stale contact" follow-up flags. Omit it for
284
+ an internal note that shouldn't count as contact.
285
+
286
+ Side effects in the same call (prospect ids, comma-separated):
287
+ --clear-follow-up 12,34 Clear the needs_follow_up flag
288
+ --add-follow-up 56 Flag for follow-up (reason=manual)
289
+ Ids outside this family are ignored server-side.
290
+
291
+ Content is sent as plain text; @mentions are processed server-side.
292
+ DESC
293
+ method_option :content, type: :string, required: true, desc: "Note body (plain text)"
294
+ method_option :activity_type, type: :string, enum: ACTIVITY_TYPES,
295
+ desc: "phone_call | sms | email | in_person | other (omit for a plain note)"
296
+ method_option :clear_follow_up, type: :string,
297
+ desc: "Comma-separated prospect ids to un-flag for follow-up"
298
+ method_option :add_follow_up, type: :string,
299
+ desc: "Comma-separated prospect ids to flag for follow-up"
300
+ def note(family_id)
301
+ body = {
302
+ "note" => {
303
+ "content" => options[:content],
304
+ "plain_content" => options[:content]
305
+ }
306
+ }
307
+ body["note"]["activity_type"] = options[:activity_type] if options[:activity_type]
308
+ clear_ids = parse_id_list(options[:clear_follow_up])
309
+ add_ids = parse_id_list(options[:add_follow_up])
310
+ body["clear_follow_up_for"] = clear_ids unless clear_ids.empty?
311
+ body["add_follow_up_for"] = add_ids unless add_ids.empty?
312
+
313
+ note = client.post("/api/v1/prospect_families/#{family_id}/notes", body)
314
+ kind = options[:activity_type] ? "Logged #{options[:activity_type]} contact" : "Added note"
315
+ render(note,
316
+ summary: "#{kind} ##{note["id"]} on family #{family_id}.",
317
+ breadcrumbs: [
318
+ { "cmd" => "wiq prospect_families notes #{family_id}", "description" => "Full contact log" },
319
+ { "cmd" => "wiq prospect_families show #{family_id}", "description" => "Back to the family" }
320
+ ])
321
+ end
322
+
323
+ no_commands do
324
+ # Maps CLI flags → the `prospect_family` permit list on
325
+ # Api::V1::ProspectFamiliesController.
326
+ FAMILY_FIELD_MAP = {
327
+ first_name: "contact_first_name",
328
+ last_name: "contact_last_name",
329
+ email: "contact_email",
330
+ phone: "contact_phone",
331
+ hear_about_us: "hear_about_us",
332
+ source: "source",
333
+ assigned_coach: "assigned_coach_id",
334
+ guardian_id: "guardian_id",
335
+ guardian_type: "guardian_type"
336
+ }.freeze
337
+
338
+ def build_family_attrs
339
+ attrs = {}
340
+ FAMILY_FIELD_MAP.each do |flag, param|
341
+ value = options[flag]
342
+ attrs[param] = value unless value.nil?
343
+ end
344
+ attrs
345
+ end
346
+
347
+ def parse_id_list(raw)
348
+ return [] if raw.nil? || raw.to_s.strip.empty?
349
+
350
+ raw.to_s.split(",").map(&:strip).reject(&:empty?).map(&:to_i).reject(&:zero?)
351
+ end
352
+ end
127
353
  end
128
354
  end
129
355
  end
@@ -4,8 +4,14 @@ module Wiq
4
4
  module Commands
5
5
  class Prospects < Base
6
6
  STAGES = %w[inquiry trial_scheduled trialing trial_complete converted didnt_join archived].freeze
7
+ TERMINAL_STAGES = %w[converted didnt_join archived].freeze
7
8
  ATTENTION_MODES = %w[needs_attention handled].freeze
8
9
 
10
+ # Write capability every create/update/advance below needs. The server
11
+ # checks it per request: the team must have it enabled AND the token
12
+ # must have been minted with it. See `wiq auth status` for scopes.
13
+ WRITE_CAPABILITY = "prospects:write"
14
+
9
15
  desc "list", "List individual prospects (one row per kid)"
10
16
  long_desc <<~DESC
11
17
  Returns one row per prospect (kid), sorted newest-first.
@@ -21,15 +27,6 @@ module Wiq
21
27
  scope which unions both via a subquery.
22
28
  When set, ALL other filters are bypassed
23
29
  server-side.
24
-
25
- KNOWN BUG: `wiq prospects list --query …`
26
- currently returns HTTP 500 due to an
27
- ambiguous-column ORDER BY on the
28
- prospect↔prospect_family join. Workaround:
29
- use `wiq prospect_families list --query …`
30
- instead (same search scope, same matches,
31
- returns the family with its prospects
32
- nested inline).
33
30
  --attention needs_attention | handled
34
31
  --stage One funnel stage
35
32
  --assigned-to-me Only families assigned to the calling coach
@@ -92,6 +89,138 @@ module Wiq
92
89
  ])
93
90
  end
94
91
 
92
+ desc "create FAMILY_ID", "Add a prospect (kid) to an existing prospect family [prospects:write]"
93
+ long_desc <<~DESC
94
+ POSTs to /api/v1/prospect_families/:family_id/prospects. Requires a
95
+ coach PAT minted with the prospects:write scope on a team that has
96
+ enabled it (Settings → API Access). Only --first-name is required.
97
+
98
+ New prospects start at stage `inquiry` unless --stage is given.
99
+ The stage audit row is attributed to the coach who minted the token.
100
+
101
+ Create the household first with `wiq prospect_families create` if
102
+ the family doesn't exist yet; use `wiq prospect_families list
103
+ --query <name>` to check.
104
+
105
+ Errors you may see:
106
+ capability_disabled_for_team Team hasn't enabled prospects:write
107
+ token_missing_scope Token wasn't minted with it — mint a new one
108
+ validation_failed Server rejected a field (see details)
109
+ DESC
110
+ method_option :first_name, type: :string, required: true, desc: "child_first_name (required)"
111
+ method_option :last_name, type: :string, desc: "child_last_name"
112
+ method_option :dob, type: :string, desc: "child_date_of_birth (YYYY-MM-DD)"
113
+ method_option :academic_class, type: :string, desc: "child_academic_class (free text, e.g. 5th)"
114
+ method_option :experience_level, type: :string, desc: "experience_level (free text, e.g. none / 1 season)"
115
+ method_option :stage, type: :string, enum: STAGES, desc: "Initial stage (default: inquiry)"
116
+ method_option :paid_session, type: :numeric, desc: "paid_session_id of the trial session"
117
+ method_option :trial_event, type: :numeric, desc: "trial_event_id — the practice/event they'll try"
118
+ method_option :trial_scheduled_at, type: :string, desc: "ISO-8601 timestamp"
119
+ method_option :needs_follow_up, type: :boolean, desc: "Flag (or --no-needs-follow-up to clear) for follow-up"
120
+ def create(family_id)
121
+ body = { "prospect" => build_prospect_attrs }
122
+ prospect = client.post("/api/v1/prospect_families/#{family_id}/prospects", body)
123
+ render(prospect,
124
+ summary: "Created prospect #{prospect["id"]} — #{prospect["child_first_name"]} " \
125
+ "#{prospect["child_last_name"]} (stage=#{prospect["stage"]}) on family #{family_id}.",
126
+ breadcrumbs: [
127
+ { "cmd" => "wiq prospects show #{prospect["id"]}", "description" => "Refetch the prospect" },
128
+ { "cmd" => "wiq prospect_families note #{family_id} --activity-type phone_call --content \"...\"",
129
+ "description" => "Log the first contact" }
130
+ ])
131
+ end
132
+
133
+ desc "update ID", "Edit a prospect's details and/or move its stage [prospects:write]"
134
+ long_desc <<~DESC
135
+ PATCHes /api/v1/prospects/:id with only the flags you pass. Requires
136
+ the prospects:write scope (team-enabled + on the token).
137
+
138
+ Stage changes via a PAT are FORWARD-ONLY and cannot leave a terminal
139
+ stage (converted, didnt_join, archived). The server answers 422
140
+ (`stage_transition_refused`) rather than silently no-op'ing; a coach
141
+ can force the move in the web app. `wiq prospects advance` is the
142
+ same call with a clearer signature for stage-only moves.
143
+
144
+ --needs-follow-up / --no-needs-follow-up also syncs follow_up_set_at
145
+ and follow_up_reason (=manual) server-side so the flag stays
146
+ internally consistent.
147
+
148
+ Funnel timestamps (--trial-scheduled-at, --trial-completed-at,
149
+ --converted-at, --archived-at, --last-contacted-at) accept ISO-8601
150
+ and are normally stamped by the stage change itself — pass them
151
+ only to backfill history.
152
+ DESC
153
+ method_option :first_name, type: :string, desc: "child_first_name"
154
+ method_option :last_name, type: :string, desc: "child_last_name"
155
+ method_option :dob, type: :string, desc: "child_date_of_birth (YYYY-MM-DD)"
156
+ method_option :academic_class, type: :string, desc: "child_academic_class"
157
+ method_option :experience_level, type: :string, desc: "experience_level"
158
+ method_option :stage, type: :string, enum: STAGES, desc: "Move to this stage (forward-only via PAT)"
159
+ method_option :lost_reason, type: :string, desc: "Why they didn't join (pairs with --stage didnt_join)"
160
+ method_option :paid_session, type: :numeric, desc: "paid_session_id of the trial session"
161
+ method_option :trial_event, type: :numeric, desc: "trial_event_id"
162
+ method_option :wrestler_profile, type: :numeric, desc: "wrestler_profile_id to link (on conversion)"
163
+ method_option :needs_follow_up, type: :boolean, desc: "Flag (or --no-needs-follow-up to clear) for follow-up"
164
+ method_option :trial_scheduled_at, type: :string, desc: "ISO-8601 timestamp"
165
+ method_option :trial_completed_at, type: :string, desc: "ISO-8601 timestamp"
166
+ method_option :converted_at, type: :string, desc: "ISO-8601 timestamp"
167
+ method_option :archived_at, type: :string, desc: "ISO-8601 timestamp"
168
+ method_option :last_contacted_at, type: :string, desc: "ISO-8601 timestamp"
169
+ def update(id)
170
+ attrs = build_prospect_attrs
171
+ if attrs.empty?
172
+ raise Wiq::Error.new("Nothing to update — pass at least one field flag.",
173
+ code: "no_fields",
174
+ hint: "See `wiq prospects update --help` for the editable fields.")
175
+ end
176
+
177
+ prospect = client.patch("/api/v1/prospects/#{id}", { "prospect" => attrs })
178
+ render(prospect,
179
+ summary: "Updated prospect #{prospect["id"]} — #{prospect["child_first_name"]} " \
180
+ "#{prospect["child_last_name"]} (stage=#{prospect["stage"]}).",
181
+ breadcrumbs: [
182
+ { "cmd" => "wiq prospects show #{prospect["id"]}", "description" => "Refetch the prospect" },
183
+ { "cmd" => "wiq prospect_families show #{prospect["prospect_family_id"]}",
184
+ "description" => "Family this prospect belongs to" }
185
+ ])
186
+ end
187
+
188
+ desc "advance ID STAGE", "Move a prospect forward to STAGE [prospects:write]"
189
+ long_desc <<~DESC
190
+ Shorthand for `wiq prospects update ID --stage STAGE`. Stage order:
191
+
192
+ inquiry → trial_scheduled → trialing → trial_complete
193
+ → converted | didnt_join | archived (terminal)
194
+
195
+ Via a personal access token the move must go FORWARD in that order
196
+ and cannot start from a terminal stage. Skipping ahead is fine
197
+ (inquiry → converted). Anything else returns 422 with code
198
+ `stage_transition_refused`; a coach can override in the web app.
199
+
200
+ --lost-reason is stored alongside a move to didnt_join.
201
+ DESC
202
+ method_option :lost_reason, type: :string, desc: "Why they didn't join (with didnt_join)"
203
+ def advance(id, stage)
204
+ unless STAGES.include?(stage)
205
+ raise Wiq::Error.new("Unknown stage #{stage.inspect}.",
206
+ code: "invalid_stage",
207
+ hint: "Valid stages: #{STAGES.join(", ")}")
208
+ end
209
+
210
+ attrs = { "stage" => stage }
211
+ attrs["lost_reason"] = options[:lost_reason] if options[:lost_reason]
212
+ prospect = client.patch("/api/v1/prospects/#{id}", { "prospect" => attrs })
213
+ render(prospect,
214
+ summary: "Prospect #{prospect["id"]} — #{prospect["child_first_name"]} " \
215
+ "#{prospect["child_last_name"]} is now #{prospect["stage"]}.",
216
+ breadcrumbs: [
217
+ { "cmd" => "wiq prospect_families note #{prospect["prospect_family_id"]} " \
218
+ "--activity-type other --content \"...\"",
219
+ "description" => "Log why, so the contact history matches the stage" },
220
+ { "cmd" => "wiq prospects show #{prospect["id"]}", "description" => "Refetch the prospect" }
221
+ ])
222
+ end
223
+
95
224
  desc "summary", "Pipeline dashboard: counts per stage + conversion rate"
96
225
  long_desc <<~DESC
97
226
  Single-call dashboard. Returns an unwrapped object (not paginated)
@@ -147,6 +276,40 @@ module Wiq
147
276
  { "cmd" => "wiq prospect_families list", "description" => "List by family instead of by kid" }
148
277
  ]
149
278
  end
279
+
280
+ # Maps CLI flags → the `prospect` param permit list on
281
+ # Api::V1::ProspectsController. Only flags actually passed land in
282
+ # the body so PATCH stays a partial update.
283
+ FIELD_MAP = {
284
+ first_name: "child_first_name",
285
+ last_name: "child_last_name",
286
+ dob: "child_date_of_birth",
287
+ academic_class: "child_academic_class",
288
+ experience_level: "experience_level",
289
+ stage: "stage",
290
+ lost_reason: "lost_reason",
291
+ paid_session: "paid_session_id",
292
+ trial_event: "trial_event_id",
293
+ wrestler_profile: "wrestler_profile_id",
294
+ trial_scheduled_at: "trial_scheduled_at",
295
+ trial_completed_at: "trial_completed_at",
296
+ converted_at: "converted_at",
297
+ archived_at: "archived_at",
298
+ last_contacted_at: "last_contacted_at"
299
+ }.freeze
300
+
301
+ def build_prospect_attrs
302
+ attrs = {}
303
+ FIELD_MAP.each do |flag, param|
304
+ value = options[flag]
305
+ attrs[param] = value unless value.nil?
306
+ end
307
+ # Boolean: Thor sets nil when the flag isn't passed, true/false otherwise.
308
+ unless options[:needs_follow_up].nil?
309
+ attrs["needs_follow_up"] = options[:needs_follow_up]
310
+ end
311
+ attrs
312
+ end
150
313
  end
151
314
  end
152
315
  end
@@ -234,9 +234,29 @@ module Wiq
234
234
  "SessionRegistrationAnswerReport" => {
235
235
  args: %w[paid_session_id],
236
236
  dates: :optional,
237
- desc: "Full Q&A export of info submitted by parents at signup",
237
+ desc: "Full Q&A export of info submitted by parents at signup, one row per wrestler " \
238
+ "with stable ids + registration status",
238
239
  recommended: true,
239
- notes: "Pass --paid-session <id> — required."
240
+ notes: "Pass --paid-session <id> — required. The default (vrow) row shape " \
241
+ "leads with seven registration columns: \"WIQ ID #\" (wrestler_profile " \
242
+ "id — same header/value as RosterReport column A, so the two reports " \
243
+ "join on it), \"Registration ID\" (the stable key for external syncs), " \
244
+ "\"Registration status\" (paid | partially_paid | pending | overdue | " \
245
+ "canceled | awaiting_approval), \"Good standing\" (true/false: status in " \
246
+ "paid/partially_paid/pending), \"Registered at\", \"Registration updated " \
247
+ "at\", \"Registration canceled at\" (timestamps in the team's zone; blank " \
248
+ "unless canceled). Then Last/First name, Email, Account type, Academic " \
249
+ "class, Weight class, DOB, Age, created-at, USAW/AAU membership columns " \
250
+ "(unless disabled for the team), each wrestler registration question, " \
251
+ "and per-guardian name/email/type + guardian questions. One row per " \
252
+ "wrestler: when a wrestler has several registrations for the session " \
253
+ "(canceled then re-registered) the latest non-canceled one is " \
254
+ "reported, else the latest canceled one; blank registration columns " \
255
+ "mean no registration row exists. Anonymous sessions emit only " \
256
+ "Last name / First name / Email. --v1 returns structured JSON " \
257
+ "(wrestler_profiles + questions + answers) without the " \
258
+ "registration columns.",
259
+ example: "wiq reports run SessionRegistrationAnswerReport --paid-session 42"
240
260
  },
241
261
  "PaidSessionAccountingReport" => {
242
262
  args: %w[paid_session_id],
@@ -5,7 +5,9 @@ module Wiq
5
5
  class Rosters < Base
6
6
  desc "list", "List rosters"
7
7
  long_desc <<~DESC
8
- Returns every roster on the calling profile's team, paginated.
8
+ Returns the team's active rosters by default, paginated. Archived
9
+ rosters are hidden unless you pass --include-archived (returns both)
10
+ or --archived true (returns only archived).
9
11
 
10
12
  Season filtering is a CLI-side projection (WIQ has no first-class
11
13
  Season entity):
@@ -31,9 +33,12 @@ module Wiq
31
33
  method_option :season_tag, type: :string, desc: "Filter to rosters carrying this tag"
32
34
  method_option :location, type: :numeric, desc: "Filter to rosters at one location id"
33
35
  method_option :archived, type: :boolean, desc: "Show only archived (true) or active (false)"
36
+ method_option :include_archived, type: :boolean, default: false,
37
+ desc: "Include archived rosters alongside active ones"
34
38
  method_option :all, type: :boolean, default: false
35
39
  def list
36
40
  params = { "per_page" => 100 }
41
+ params["include_archived"] = true if options[:include_archived]
37
42
  unless options[:archived].nil?
38
43
  params["q[archived_eq]"] = options[:archived]
39
44
  end
@@ -23,8 +23,9 @@ module Wiq
23
23
  restart needed. Re-run with --force to overwrite an existing
24
24
  install (useful when upgrading the gem).
25
25
 
26
- The skill is read-only on the WIQ side: it teaches Claude how to
27
- drive `wiq`, but doesn't touch any WIQ data.
26
+ Installing the skill doesn't touch any WIQ data: it only teaches
27
+ Claude how to drive `wiq`. What Claude can then change is bounded
28
+ by the token's write scopes (see `wiq auth status`).
28
29
  DESC
29
30
  method_option :project, type: :boolean, default: false,
30
31
  desc: "Install per-project (./.claude/skills/) instead of user-global"
@@ -10,7 +10,8 @@ module Wiq
10
10
  # {
11
11
  # "<host>": {
12
12
  # "<alias>": { "token": "...", "token_prefix": "...", "name": "...",
13
- # "profile": {...}, "stored_at": "..." },
13
+ # "profile": {...}, "scopes": ["prospects:write"],
14
+ # "stored_at": "..." },
14
15
  # "<alias>": { ... }
15
16
  # }
16
17
  # }
@@ -55,13 +56,14 @@ module Wiq
55
56
  "token_prefix" => entry["token_prefix"],
56
57
  "name" => entry["name"],
57
58
  "profile" => entry["profile"],
59
+ "scopes" => entry["scopes"],
58
60
  "stored_at" => entry["stored_at"]
59
61
  }
60
62
  end
61
63
  end
62
64
  end
63
65
 
64
- def store(host:, alias_name:, token:, token_prefix: nil, name: nil, profile: nil)
66
+ def store(host:, alias_name:, token:, token_prefix: nil, name: nil, profile: nil, scopes: nil)
65
67
  data = load_all
66
68
  data[host] ||= {}
67
69
  data[host][alias_name] = {
@@ -69,6 +71,7 @@ module Wiq
69
71
  "token_prefix" => token_prefix,
70
72
  "name" => name,
71
73
  "profile" => profile,
74
+ "scopes" => scopes,
72
75
  "stored_at" => Time.now.utc.iso8601
73
76
  }.compact
74
77
  write(data)
data/lib/wiq/errors.rb CHANGED
@@ -76,13 +76,28 @@ module Wiq
76
76
  end
77
77
 
78
78
  # Mirror of the HTTP error envelope from /api/v1.
79
+ #
80
+ # PAT write policy (Api::V1::BaseController#enforce_pat_restrictions):
81
+ # a write goes through only when it maps to a capability in the server's
82
+ # ApiCapability registry that the team has enabled AND the token carries.
83
+ # The server emits three distinct 403 bodies for the three failure modes,
84
+ # naming the capability verbatim; we key off that wording to give each
85
+ # its own `code` + fix-it hint. Keep the regexes in sync with
86
+ # `pat_denial_message` in the Rails app.
79
87
  class APIError < Error
80
- attr_reader :status, :response_body, :request_id
88
+ attr_reader :status, :response_body, :request_id, :capability
89
+
90
+ PAT_NOT_WRITABLE = /not writable with a personal access token/i
91
+ PAT_TEAM_DISABLED = /team has not enabled (\S+) for API access/i
92
+ PAT_TOKEN_MISSING_SCOPE = /token lacks the (\S+) scope/i
93
+ PAT_LEGACY_READ_ONLY = /personal access tokens are read-only/i
94
+ PAT_STAGE_REFUSED = /stage cannot move from (\S+) to (\S+) with a personal access token/i
81
95
 
82
96
  def initialize(status:, body:, request_id: nil)
83
97
  @status = status
84
98
  @response_body = body
85
99
  @request_id = request_id
100
+ @capability = nil
86
101
 
87
102
  code, message, hint = derive(status, body)
88
103
  super(message, code: code, hint: hint, exit_code: 1, details: body)
@@ -97,12 +112,11 @@ module Wiq
97
112
  ["unauthorized", "Token rejected by server (401).",
98
113
  "Run `wiq auth status` to inspect the active token; `wiq auth login` to replace it."]
99
114
  when 403
100
- ["forbidden", "Server denied access (403): #{msgs}",
101
- "PATs inherit the user's permissions. Confirm the minting user can see this resource in the web app."]
115
+ derive_forbidden(msgs)
102
116
  when 404
103
117
  ["not_found", "Resource not found (404).", nil]
104
118
  when 422
105
- ["validation_failed", "Validation error (422): #{msgs}", nil]
119
+ derive_unprocessable(msgs)
106
120
  when 429
107
121
  ["rate_limited", "Rate limited (429): #{msgs}",
108
122
  "WIQ enforces 100 req/3s per IP. Back off and retry."]
@@ -111,6 +125,38 @@ module Wiq
111
125
  end
112
126
  end
113
127
 
128
+ def derive_forbidden(msgs)
129
+ if msgs =~ PAT_NOT_WRITABLE || msgs =~ PAT_LEGACY_READ_ONLY
130
+ ["pat_write_unsupported", "Server denied access (403): #{msgs}",
131
+ "This endpoint has no API write capability, so no personal access token can call it. " \
132
+ "Make the change in the WIQ web app."]
133
+ elsif (m = msgs.match(PAT_TEAM_DISABLED))
134
+ @capability = m[1]
135
+ ["capability_disabled_for_team", "Server denied access (403): #{msgs}",
136
+ "A team admin must enable #{@capability} under Settings → API Access " \
137
+ "(<host>/settings/team/api_access). Existing tokens minted with that scope start working immediately."]
138
+ elsif (m = msgs.match(PAT_TOKEN_MISSING_SCOPE))
139
+ @capability = m[1]
140
+ ["token_missing_scope", "Server denied access (403): #{msgs}",
141
+ "Token scopes are immutable. Mint a new token that includes #{@capability} at " \
142
+ "<host>/settings/personal_access_tokens, then `wiq auth login --force` to replace the stored one. " \
143
+ "Run `wiq auth status` to see the scopes on the current token."]
144
+ else
145
+ ["forbidden", "Server denied access (403): #{msgs}",
146
+ "PATs inherit the user's permissions. Confirm the minting user can see this resource in the web app."]
147
+ end
148
+ end
149
+
150
+ def derive_unprocessable(msgs)
151
+ if msgs =~ PAT_STAGE_REFUSED
152
+ ["stage_transition_refused", "Validation error (422): #{msgs}",
153
+ "Stage changes via a personal access token are forward-only and cannot leave a terminal stage " \
154
+ "(converted, didnt_join, archived). A coach can force the move in the WIQ web app."]
155
+ else
156
+ ["validation_failed", "Validation error (422): #{msgs}", nil]
157
+ end
158
+ end
159
+
114
160
  # /api/v1 always returns { "errors": <array|hash> }. Flatten to a human string.
115
161
  def extract_messages(body)
116
162
  return body.to_s unless body.is_a?(Hash)
data/lib/wiq/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wiq
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.1"
5
5
  end
@@ -1,14 +1,16 @@
1
1
  ---
2
2
  name: wiq
3
- description: Use this skill when the user asks about their WrestlingIQ data — rosters, attendance, check-ins, paid sessions and registrations, the prospects/leads pipeline, financial metrics, payouts/bank deposits, reports, USAW/AAU memberships, fundraising, online store orders, or per-location/site breakdowns for multi-gym clubs. The `wiq` CLI provides read-only access to /api/v1 via personal access tokens. Recognize phrasings like "how is our pipeline?", "who came to practice this week?", "show me the roster", "what's our MRR?", "which kids need USAW renewal?", "how is the Eastside gym doing?", or anything that maps to a wrestling club's admin workflows.
3
+ description: Use this skill when the user asks about their WrestlingIQ data — rosters, attendance, check-ins, paid sessions and registrations, the prospects/leads pipeline, financial metrics, payouts/bank deposits, reports, USAW/AAU memberships, fundraising, online store orders, or per-location/site breakdowns for multi-gym clubs. The `wiq` CLI reads /api/v1 via personal access tokens and, when the token carries the prospects:write scope, can also create/edit leads, log contact notes, and advance prospects through the pipeline. Recognize phrasings like "how is our pipeline?", "who came to practice this week?", "show me the roster", "what's our MRR?", "which kids need USAW renewal?", "how is the Eastside gym doing?", or anything that maps to a wrestling club's admin workflows.
4
4
  ---
5
5
 
6
6
  # WrestlingIQ CLI Skill
7
7
 
8
- `wiq` is a read-only Ruby CLI for WrestlingIQ data, designed to be driven
9
- by both humans and AI agents. It hits `/api/v1` using per-user personal
10
- access tokens (PATs). This skill orients you to its shape; the CLI itself
11
- is the source of truth for what's available right now.
8
+ `wiq` is a Ruby CLI for WrestlingIQ data, designed to be driven by both
9
+ humans and AI agents. It hits `/api/v1` using per-user personal access
10
+ tokens (PATs). Every token can read; a token additionally minted with a
11
+ write scope (see "Writes" below) can create and edit leads. This skill
12
+ orients you to its shape; the CLI itself is the source of truth for
13
+ what's available right now.
12
14
 
13
15
  ## Bootstrap (always do this first)
14
16
 
@@ -49,9 +51,17 @@ per situation.
49
51
  CoachProfile + ParentProfile on different teams mints a separate token
50
52
  per role. The CLI does not switch profiles at runtime.
51
53
  - **Everything is gated by the bound profile's permissions.** Parent or
52
- wrestler PATs cannot run reports (`enforce_pat_restrictions` returns 403
53
- with body `"Personal access tokens are read-only..."` — reports is the
54
- one write currently on the PAT allowlist).
54
+ wrestler PATs cannot run reports or touch prospects (Pundit requires a
55
+ CoachProfile on the team).
56
+ - **Writes need a capability on BOTH the team and the token.** Reads are
57
+ implicit for every token. A write (`POST`/`PATCH`) succeeds only when it
58
+ maps to a capability in the server's registry (`prospects:write`,
59
+ `reports:write`) that a team admin has enabled under Settings → API
60
+ Access AND that the token was minted with. Token scopes are immutable:
61
+ to add one, revoke + mint a new token, then `wiq auth login --force`.
62
+ `wiq auth status` / `wiq doctor` show the live scopes — check them
63
+ before attempting a write. `reports:write` was backfilled onto every
64
+ existing team and token, so report submission keeps working.
55
65
  - **The 9 finance reports require `admin?`.** They're flagged
56
66
  `admin_only: true` in `wiq reports types`. Workflows that submit them
57
67
  carry the same flag. Non-admin coach PATs 403 on these.
@@ -106,8 +116,12 @@ Common codes and the right response:
106
116
  | `alias_not_found` | `--as` named a slot that doesn't exist | Check `wiq auth list` |
107
117
  | `unauthorized` (401) | Token rejected by server | Re-login; could be revoked |
108
118
  | `forbidden` (403) | Pundit denial (often admin gate on a finance report) | Check the report's `admin_only:` flag in `wiq reports types` |
119
+ | `capability_disabled_for_team` (403) | Team hasn't enabled the write capability named in the message | Ask a team admin to enable it at `<host>/settings/team/api_access`; nothing to change on the token |
120
+ | `token_missing_scope` (403) | Team allows the write, but this token was minted without the scope | Mint a new token WITH the scope, `wiq auth login --force`; scopes can't be edited in place |
121
+ | `pat_write_unsupported` (403) | Endpoint has no API write capability at all | Use the WIQ web app; not a permissions problem |
109
122
  | `not_found` (404) | Resource doesn't exist for this profile | Confirm the id; cross-team enumeration returns 404 to avoid leaking existence |
110
123
  | `validation_failed` (422) | Server-side input rejection | Read `details` for per-field messages |
124
+ | `stage_transition_refused` (422) | PAT tried to move a prospect backwards or out of a terminal stage | Don't retry; tell the user a coach must make that move in the web app |
111
125
  | `rate_limited` (429) | 100 req / 3 sec per IP exceeded | Back off |
112
126
  | `season_not_found` | `--season <year>` matched zero paid sessions | `wiq paid_sessions list` to see configured periods |
113
127
  | `report_failed` | The report ran but errored server-side | Inspect `details` (the report's result jsonb) |
@@ -181,6 +195,14 @@ Key reports an agent should know by heart:
181
195
  while?"
182
196
  - **`PaidSessionAccountingReport`** (admin_only) — Line-item charges for
183
197
  a paid session.
198
+ - **`SessionRegistrationAnswerReport`** — Signup Q&A for one
199
+ `--paid-session <id>`, one row per wrestler. Rows lead with **"WIQ ID
200
+ #"** (wrestler_profile id, identical to RosterReport's first column, so
201
+ the two join on it), **"Registration ID"** (the stable key to hand an
202
+ external sync), "Registration status", "Good standing", "Registered
203
+ at", "Registration updated at", "Registration canceled at" — then the
204
+ name/DOB/USAW/AAU columns and every registration question. Use this,
205
+ not name+DOB matching, when a customer syncs registrations elsewhere.
184
206
 
185
207
  For anything else, `wiq reports types` is faster than guessing.
186
208
 
@@ -426,20 +448,66 @@ Three gotchas an agent must know:
426
448
  activity. Rosters/events/paid_sessions embed their `location` object
427
449
  (or null) in list payloads, so you can check coverage cheaply.
428
450
 
429
- ## What's NOT available (yet)
451
+ ## Writes the prospects pipeline (`prospects:write`)
430
452
 
431
- The CLI is read-only by design except for report submission. You
432
- cannot via this CLI:
453
+ The only writable surface besides report submission is the leads
454
+ pipeline. Six commands, all requiring the `prospects:write` scope:
433
455
 
434
- - Create/edit prospects, families, notes, check-ins, events, paid
435
- sessions, rosters, locations, or any other resource
456
+ ```bash
457
+ wiq prospect_families create --first-name Dana --last-name Lee --email dana@x.com --phone 5551234
458
+ wiq prospect_families update <family_id> --phone 5551234 --assigned-coach <coach_id>
459
+ wiq prospect_families note <family_id> --activity-type phone_call --content "Left voicemail" \
460
+ [--clear-follow-up <prospect_ids>] [--add-follow-up <prospect_ids>]
461
+ wiq prospects create <family_id> --first-name Sam --dob 2016-03-04 --academic-class 4th
462
+ wiq prospects update <prospect_id> --stage trial_scheduled --trial-event <event_id>
463
+ wiq prospects advance <prospect_id> trialing
464
+ ```
465
+
466
+ Rules the server enforces on PAT callers (and you should respect up
467
+ front rather than discover via errors):
468
+
469
+ 1. **Check scopes first.** `wiq auth status` → `live_scopes` must include
470
+ `prospects:write`. If it doesn't, stop and tell the user: either the
471
+ team admin needs to enable it (Settings → API Access) or they need a
472
+ new token minted with it. Don't loop on 403s.
473
+ 2. **Stage moves are forward-only and never leave a terminal stage.**
474
+ Order: inquiry → trial_scheduled → trialing → trial_complete →
475
+ converted | didnt_join | archived. Skipping ahead is fine; going back
476
+ or moving a converted/archived lead returns `stage_transition_refused`
477
+ (422). A coach can force it in the web app. Deletes are never allowed
478
+ through the API.
479
+ 3. **Search before you create.** `wiq prospect_families list --query
480
+ <name|email|phone>` finds existing households so you don't duplicate
481
+ a family that came in through the web interest form.
482
+ 4. **Log contact when you act.** A note WITH `--activity-type` counts as
483
+ contact: it stamps `last_contacted_at` on every active prospect in
484
+ the family and is what clears stale-contact follow-up flags. A note
485
+ without it is an internal comment only.
486
+ 5. **Attribution.** Every write is audited against the token; stage
487
+ changes and notes are attributed to the coach who minted it.
488
+ `wiq prospect_families stage_changes <family_id>` is the audit log
489
+ (from/to stage, changed_at, changed_via, changed_by) — use it to
490
+ answer "who moved this lead?" and to check history before advancing.
491
+ 6. **Trial-purchase leads have thin family records.** They skipped the
492
+ interest form, so contact details often live only as registration
493
+ answers on the linked wrestler/guardian profiles. `wiq
494
+ prospect_families linked_answers <family_id>` returns them; copy a
495
+ phone back with `wiq prospect_families update --phone`.
496
+
497
+ ## What's NOT available
498
+
499
+ You cannot via this CLI:
500
+
501
+ - Create/edit check-ins, events, paid sessions, rosters, locations, or
502
+ any resource other than prospects/families/notes and reports
503
+ - Delete anything (destroy actions are outside every capability)
436
504
  - Mint, list, or revoke PATs (use the web UI at
437
505
  `<host>/settings/personal_access_tokens`)
438
- - Mark attendance, advance prospect stages, log contact notes
439
- - Trigger event-change notifications or roster re-sync jobs
506
+ - Mark attendance or trigger event-change notifications / roster
507
+ re-sync jobs
440
508
 
441
- If the user asks for a write operation, tell them it's not yet exposed
442
- and point them at the WIQ web UI for now.
509
+ If the user asks for one of these, say it's not exposed and point them
510
+ at the WIQ web UI.
443
511
 
444
512
  ## When to compose vs run a workflow
445
513
 
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wiq-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - WrestlingIQ
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-08-19 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: thor
@@ -122,7 +121,6 @@ metadata:
122
121
  bug_tracker_uri: https://github.com/wrestlingiq/wiq-cli/issues
123
122
  documentation_uri: https://github.com/wrestlingiq/wiq-cli#readme
124
123
  rubygems_mfa_required: 'true'
125
- post_install_message:
126
124
  rdoc_options: []
127
125
  require_paths:
128
126
  - lib
@@ -137,8 +135,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
137
135
  - !ruby/object:Gem::Version
138
136
  version: '0'
139
137
  requirements: []
140
- rubygems_version: 3.3.27
141
- signing_key:
138
+ rubygems_version: 4.0.16
142
139
  specification_version: 4
143
140
  summary: Command-line interface for the WrestlingIQ API
144
141
  test_files: []