wiq-cli 0.1.0

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.
@@ -0,0 +1,510 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wiq
4
+ module Commands
5
+ class Reports < Base
6
+ # Curated allowlist surfaced in `wiq reports types`. Users can still pass
7
+ # any type string to `run`; this map governs documentation, arg hints,
8
+ # and agent-facing recommendations. Per-entry keys:
9
+ # args: permitted args.* keys for this type
10
+ # dates: :required | :optional — whether start_at/end_at are needed
11
+ # desc: one-line description
12
+ # recommended: true | false — agents should prefer/avoid this type
13
+ # (omit when neutral)
14
+ # prefer: array of better alternatives (only when recommended: false)
15
+ # notes: agent-facing guidance, surfaced in `wiq reports types --agent`
16
+ # example: concrete `wiq reports run ...` invocation
17
+ # admin_only: true for the 9 finance reports gated by Pundit's
18
+ # acceptable_finance_permissions? (= is_admin?). Non-finance
19
+ # reports require a CoachProfile PAT on the team. PATs bound
20
+ # to ParentProfile or WrestlerProfile 403 on any report POST.
21
+ #
22
+ # Note on auth: "elite" is a Team#account_type (gates UI tab rendering),
23
+ # not a per-user permission — the report POST doesn't check it. Same for
24
+ # "payments_enabled" — that's a team attribute, not a permission flag.
25
+ # The only API-level gate beyond CoachProfile is admin? for finance reports.
26
+ TYPES = {
27
+ # ── Roster tab ───────────────────────────────────────────────
28
+ "RosterReport" => {
29
+ args: %w[roster_id append_property_ids include_archived_roster_tags],
30
+ dates: :optional,
31
+ desc: "Roster snapshot — name, weight class, academic class, age",
32
+ recommended: true,
33
+ notes: "Supports custom columns via --append-properties <q_ids>: pass " \
34
+ "registration_question ids (discoverable via " \
35
+ "`wiq registrations questions --visibility public`, plus " \
36
+ "--visibility private if admin) to surface intake data as " \
37
+ "extra columns. Skip questions with a deleted_at — they're " \
38
+ "still in the index payload but won't render. roster_id=0 " \
39
+ "means \"all rosters\". Each appended question becomes a column.",
40
+ example: "wiq reports run RosterReport --roster 42 --append-properties 17 23"
41
+ },
42
+ "FullExportWrestlerReport" => {
43
+ args: %w[],
44
+ dates: :optional,
45
+ desc: "Full wrestler export — every known field for every wrestler",
46
+ notes: "Very slow on large teams. Prefer RosterReport with " \
47
+ "--append-properties unless you actually need the exhaustive dump."
48
+ },
49
+
50
+ # ── Invite tab ───────────────────────────────────────────────
51
+ "InviteStatusReport" => {
52
+ args: %w[],
53
+ dates: :optional,
54
+ desc: "Wrestlers / parents / coaches invited but not yet account-created",
55
+ recommended: true,
56
+ notes: "Mostly useful for high school teams or anywhere a coach is " \
57
+ "manually inviting accounts. Club teams should prefer the " \
58
+ "registration flow — parents register, then invite their own " \
59
+ "kids — so this report stays empty for them."
60
+ },
61
+
62
+ # ── USAW / AAU tab ───────────────────────────────────────────
63
+ "UsawReport" => {
64
+ args: %w[roster_id],
65
+ dates: :optional,
66
+ desc: "All USA Wrestling card info on file, one row per wrestler",
67
+ recommended: true,
68
+ notes: "UI hides this tab when USAW collection is off (team setting)."
69
+ },
70
+ "UsawExpiredReport" => {
71
+ args: %w[roster_id],
72
+ dates: :optional,
73
+ desc: "Wrestlers missing or with expired USAW memberships",
74
+ notes: "Output is also a bulk-purchase upload format for USAW's system."
75
+ },
76
+ "UsawExportReport" => {
77
+ args: %w[roster_id paid_session_id],
78
+ dates: :optional,
79
+ desc: "USAW bulk-purchase upload format",
80
+ notes: "Only report that accepts paid_session_id=0 to mean " \
81
+ "\"all sessions\"."
82
+ },
83
+ "AauReport" => {
84
+ args: %w[roster_id],
85
+ dates: :optional,
86
+ desc: "All AAU card info on file, one row per wrestler",
87
+ recommended: true,
88
+ notes: "UI hides this tab when AAU collection is off (team setting)."
89
+ },
90
+ "AauExpiredReport" => {
91
+ args: %w[roster_id],
92
+ dates: :optional,
93
+ desc: "Wrestlers missing or with expired AAU memberships"
94
+ },
95
+ "AauExportReport" => {
96
+ args: %w[roster_id],
97
+ dates: :optional,
98
+ desc: "AAU bulk-purchase upload format"
99
+ },
100
+
101
+ # ── Stats tab ────────────────────────────────────────────────
102
+ "WinLossReport" => {
103
+ args: %w[roster_id],
104
+ dates: :required,
105
+ desc: "Wins and losses per wrestler",
106
+ recommended: true
107
+ },
108
+ "RosterStatsReport" => {
109
+ args: %w[roster_id],
110
+ dates: :required,
111
+ desc: "Wrestling stats per wrestler (takedowns, nearfall, etc.)",
112
+ recommended: true
113
+ },
114
+ "EventStatsReport" => {
115
+ args: %w[event_id],
116
+ dates: :optional,
117
+ desc: "Per-wrestler stats for a single event",
118
+ notes: "Single-event drill-down. For a date range use RosterStatsReport. " \
119
+ "Discover event_id via `wiq events list --start ... --end ...`."
120
+ },
121
+
122
+ # ── Attendance tab ───────────────────────────────────────────
123
+ "CheckInSummaryReport" => {
124
+ args: %w[roster_id],
125
+ dates: :required,
126
+ desc: "Summarized check-ins for a date range — per-wrestler totals",
127
+ recommended: true,
128
+ notes: "One row per wrestler over the date range — totals across all " \
129
+ "their check-ins in the window. If you want one row per " \
130
+ "check-in event use CheckInFeedReport. The UI doesn't expose " \
131
+ "a roster picker; counts span the whole team.",
132
+ example: "wiq reports run CheckInSummaryReport --start 2026-05-01 --end 2026-05-31"
133
+ },
134
+ "CheckInFeedReport" => {
135
+ args: %w[roster_id],
136
+ dates: :required,
137
+ desc: "Raw check-in feed — one row per check-in, sorted by time",
138
+ recommended: true,
139
+ notes: "Useful for \"who came to the club today and what registrations " \
140
+ "did they have at check-in time.\" Larger payload than the " \
141
+ "summary.",
142
+ example: "wiq reports run CheckInFeedReport --start 2026-05-01 --end 2026-05-31"
143
+ },
144
+ "PracticeAttendanceReport" => {
145
+ args: %w[roster_id],
146
+ dates: :required,
147
+ desc: "Practice-event attendance roll-up across a date range",
148
+ recommended: false,
149
+ prefer: %w[CheckInSummaryReport CheckInFeedReport],
150
+ notes: "Primarily used by high school coaches who need a report for " \
151
+ "their athletic director about who was present vs absent. " \
152
+ "Only pulls practice events. Not recommended for club teams — " \
153
+ "use CheckInSummaryReport or CheckInFeedReport instead."
154
+ },
155
+ "LastPracticeAttendedReport" => {
156
+ args: %w[roster_id],
157
+ dates: :optional,
158
+ desc: "Days since last practice attended, per wrestler",
159
+ recommended: true,
160
+ notes: "Useful for following up with people who haven't shown up to " \
161
+ "practice in a while — particularly for seasonal clubs."
162
+ },
163
+ "ChurnRiskReport" => {
164
+ args: %w[roster_id days_threshold],
165
+ dates: :optional,
166
+ desc: "Active recurring subscribers who haven't checked in within a window",
167
+ recommended: true,
168
+ notes: "Useful for subscription-based clubs to identify who might " \
169
+ "cancel soon, based on no check-in within a chosen window. " \
170
+ "Pass --days-threshold (7|14|30|60|90); any other value " \
171
+ "falls back to the model's 30-day default.",
172
+ example: "wiq reports run ChurnRiskReport --roster 0 --days-threshold 30"
173
+ },
174
+ "CheckInReport" => {
175
+ args: %w[roster_id],
176
+ dates: :required,
177
+ desc: "Extended attendance — one row per check-in INCLUDING Q&A responses",
178
+ notes: "UI labels this \"Attendance Extended (with questions).\" Same " \
179
+ "shape as CheckInFeedReport PLUS the registration_answer " \
180
+ "values collected at check-in time. Use when Q&A capture " \
181
+ "matters; otherwise prefer CheckInFeedReport."
182
+ },
183
+
184
+ # ── Subscription tab (admin-gated UI; non-finance reports below
185
+ # only require coach access at the API level) ────────────────
186
+ "MembershipSummaryReport" => {
187
+ args: %w[],
188
+ dates: :optional,
189
+ desc: "Every subscription ever created, one row each",
190
+ recommended: true,
191
+ admin_only: true
192
+ },
193
+ "CancelledSubscriptionsReport" => {
194
+ args: %w[],
195
+ dates: :optional,
196
+ desc: "Canceled subscriptions, ordered by cancellation date"
197
+ },
198
+ "CurrentlyPausedSubscriptionsReport" => {
199
+ args: %w[],
200
+ dates: :optional,
201
+ desc: "Currently paused subscriptions, ordered by paused date"
202
+ },
203
+ "ExpiringSubscriptionsReport" => {
204
+ args: %w[],
205
+ dates: :required,
206
+ desc: "Subscriptions expiring in a date range (past or future)",
207
+ recommended: true,
208
+ notes: "Past dates audit churn; future dates plan retention outreach."
209
+ },
210
+ "DiscountedSubscriptionsReport" => {
211
+ args: %w[],
212
+ dates: :optional,
213
+ desc: "Subscriptions (active or canceled) with a scholarship applied"
214
+ },
215
+ "WrestlersWithoutSubscriptionsReport" => {
216
+ args: %w[],
217
+ dates: :optional,
218
+ desc: "Wrestlers without an active recurring subscription"
219
+ },
220
+
221
+ # ── Registration tab ────────────────────────────────────────
222
+ "SessionRegistrationAnswerReport" => {
223
+ args: %w[paid_session_id],
224
+ dates: :optional,
225
+ desc: "Full Q&A export of info submitted by parents at signup",
226
+ recommended: true,
227
+ notes: "Pass --paid-session <id> — required."
228
+ },
229
+ "PaidSessionAccountingReport" => {
230
+ args: %w[paid_session_id],
231
+ dates: :optional,
232
+ desc: "Line-item charges for a registration session",
233
+ recommended: true,
234
+ admin_only: true,
235
+ notes: "Pass --paid-session <id> — required."
236
+ },
237
+ "RegistrationFinanceSummaryReport" => {
238
+ args: %w[],
239
+ dates: :required,
240
+ desc: "Financial summary, one row per session"
241
+ },
242
+ "OverdueRegistrationReport" => {
243
+ args: %w[],
244
+ dates: :optional,
245
+ desc: "Overdue installment registrations",
246
+ recommended: true,
247
+ admin_only: true,
248
+ notes: "Standard AR follow-up tool."
249
+ },
250
+ "InProgressRegistrationReport" => {
251
+ args: %w[],
252
+ dates: :optional,
253
+ desc: "Carts that haven't finished signup (abandoned-cart audit)",
254
+ admin_only: true
255
+ },
256
+
257
+ # ── Scholarship tab ──────────────────────────────────────────
258
+ "ScholarshipAuditReport" => {
259
+ args: %w[],
260
+ dates: :required,
261
+ desc: "Scholarship code usage across registrations + subscriptions",
262
+ recommended: true,
263
+ admin_only: true
264
+ },
265
+
266
+ # ── Donor tab ────────────────────────────────────────────────
267
+ "RecurringDonorReport" => {
268
+ args: %w[],
269
+ dates: :optional,
270
+ desc: "Active recurring donors",
271
+ recommended: true,
272
+ admin_only: true
273
+ },
274
+ "DonationTransactionReport" => {
275
+ args: %w[],
276
+ dates: :required,
277
+ desc: "All payments that included a donation",
278
+ recommended: true,
279
+ admin_only: true
280
+ },
281
+
282
+ # ── Fundraiser tab ───────────────────────────────────────────
283
+ "FundraiserSummaryReport" => {
284
+ args: %w[fundraiser_id],
285
+ dates: :optional,
286
+ desc: "Fundraiser results, one row per contributor",
287
+ recommended: true,
288
+ admin_only: true,
289
+ notes: "Pass --fundraiser <id> — required."
290
+ },
291
+ "FundraiserAccountingReport" => {
292
+ args: %w[fundraiser_id],
293
+ dates: :optional,
294
+ desc: "Fundraiser line items, one row per item purchased",
295
+ admin_only: true,
296
+ notes: "Pass --fundraiser <id> — required. Drill-down of " \
297
+ "FundraiserSummaryReport."
298
+ },
299
+
300
+ # ── Online Store tab ─────────────────────────────────────────
301
+ "OnlineStoreSummaryReport" => {
302
+ args: %w[online_store_id],
303
+ dates: :optional,
304
+ desc: "Summary of store orders",
305
+ recommended: true,
306
+ notes: "Pass --online-store <id> — required."
307
+ },
308
+ "OnlineStoreDetailReport" => {
309
+ args: %w[online_store_id],
310
+ dates: :optional,
311
+ desc: "Detailed store orders, one row per line item",
312
+ notes: "Pass --online-store <id> — required. Useful to hand off to a " \
313
+ "gear or printing partner who needs sku-level data."
314
+ }
315
+ }.freeze
316
+
317
+ desc "run TYPE", "Create + (by default) poll a report"
318
+ long_desc <<~DESC
319
+ Submits a report to `POST /api/v1/reports` and (by default) polls
320
+ until status=ready. Returns the full report row including
321
+ `result` (jsonb), which is type-specific output.
322
+
323
+ TYPE can be any WIQ report class name. See `wiq reports types` for
324
+ the curated allowlist with recommendations, required args, and
325
+ per-type notes (admin_only flag, special arg semantics, etc.).
326
+
327
+ Common args (only those documented in TYPES are honored
328
+ server-side):
329
+ --roster <id> roster_id (0 = all rosters)
330
+ --paid-session <id> paid_session_id (0 = all, UsawExport only)
331
+ --event <id> event_id (EventStatsReport)
332
+ --fundraiser <id> fundraiser_id (Fundraiser* reports)
333
+ --online-store <id> online_store_id (OnlineStore* reports)
334
+ --append-properties <ids...> RosterReport custom columns
335
+ --include-archived-roster-tags RosterReport tag inclusion
336
+ --days-threshold <7|14|30|60|90> ChurnRiskReport window
337
+ --season <year> Resolves to a paid_session_id via the
338
+ overlap rule; errors if multiple match
339
+
340
+ Polling: 2-second initial interval, exponential backoff to 30s
341
+ cap, default 5-minute timeout. Pass --no-wait to return the
342
+ report row immediately after submission (status=queued or
343
+ processing) and poll later with `wiq reports show <id> --wait`.
344
+ DESC
345
+ method_option :start, type: :string, desc: "YYYY-MM-DD"
346
+ method_option :end, type: :string, desc: "YYYY-MM-DD"
347
+ method_option :roster, type: :numeric, desc: "args.roster_id (0 = all rosters)"
348
+ method_option :paid_session, type: :numeric, desc: "args.paid_session_id"
349
+ method_option :event, type: :numeric, desc: "args.event_id"
350
+ method_option :fundraiser, type: :numeric, desc: "args.fundraiser_id"
351
+ method_option :online_store, type: :numeric, desc: "args.online_store_id"
352
+ method_option :append_properties, type: :array,
353
+ desc: "Registration question ids to surface as columns (RosterReport)"
354
+ method_option :include_archived_roster_tags, type: :boolean, default: false,
355
+ desc: "Include archived roster tags (RosterReport)"
356
+ method_option :days_threshold, type: :numeric,
357
+ enum: [7, 14, 30, 60, 90],
358
+ desc: "args.days_threshold (ChurnRiskReport)"
359
+ method_option :name, type: :string, desc: "Display name (default: CLI <type> <range>)"
360
+ method_option :season, type: :numeric, desc: "Resolve to paid_session_id via overlap with calendar year"
361
+ method_option :wait, type: :boolean, default: true
362
+ method_option :timeout, type: :numeric, default: 300
363
+ map "run" => :run_report
364
+ def run_report(type)
365
+ args = build_args(type)
366
+
367
+ body = {
368
+ report: {
369
+ type: type,
370
+ version: "v1",
371
+ name: options[:name] || default_name(type),
372
+ start_at: options[:start],
373
+ end_at: options[:end],
374
+ args: args
375
+ }
376
+ }
377
+
378
+ report = client.post("/api/v1/reports", body)
379
+ report = self.class.poll(client, report["id"], timeout: options[:timeout]) if options[:wait]
380
+
381
+ render(report,
382
+ summary: "Report ##{report["id"]} status=#{report["status"]}.",
383
+ breadcrumbs: [
384
+ { "cmd" => "wiq reports show #{report["id"]}", "description" => "Refetch the result later" }
385
+ ])
386
+ end
387
+
388
+ desc "show ID", "Fetch a single report (optionally poll)"
389
+ long_desc <<~DESC
390
+ Fetches a report by id. Pass --wait to poll until status=ready,
391
+ useful for "I kicked this off earlier, is it done yet?" flows.
392
+ DESC
393
+ method_option :wait, type: :boolean, default: false
394
+ method_option :timeout, type: :numeric, default: 300
395
+ def show(id)
396
+ report = options[:wait] ? self.class.poll(client, id, timeout: options[:timeout])
397
+ : client.get("/api/v1/reports/#{id}")
398
+ render(report,
399
+ summary: "Report ##{report["id"]} status=#{report["status"]}.",
400
+ breadcrumbs: [
401
+ { "cmd" => "wiq reports show #{id} --wait", "description" => "Poll until ready" }
402
+ ])
403
+ end
404
+
405
+ desc "types", "Print the curated report-type allowlist with recommendations"
406
+ long_desc <<~DESC
407
+ Dumps the curated TYPES allowlist with per-entry metadata:
408
+ description, permitted args, whether dates are required, the
409
+ recommended/preferred status, and an example invocation where
410
+ applicable.
411
+
412
+ Recommended starting point for any agent trying to pick a report
413
+ for a user question — read this once, then call
414
+ `wiq reports run <TYPE>` with the documented args.
415
+
416
+ Entries with `admin_only: true` (the 9 finance reports) require
417
+ an admin CoachProfile PAT. Non-admin coach PATs 403 on those.
418
+ DESC
419
+ def types
420
+ rows = TYPES.map do |type, info|
421
+ row = {
422
+ "type" => type,
423
+ "description" => info[:desc],
424
+ "args" => info[:args],
425
+ "dates" => info[:dates].to_s
426
+ }
427
+ row["recommended"] = info[:recommended] if info.key?(:recommended)
428
+ row["prefer"] = info[:prefer] if info[:prefer]
429
+ row["admin_only"] = info[:admin_only] if info[:admin_only]
430
+ row["notes"] = info[:notes] if info[:notes]
431
+ row["example"] = info[:example] if info[:example]
432
+ row
433
+ end
434
+ render_index(
435
+ rows,
436
+ summary: "Documented report types. Auth: every report POST requires a " \
437
+ "CoachProfile-bound PAT on the team. The 9 entries with " \
438
+ "admin_only: true additionally require admin? — " \
439
+ "non-admin coach PATs 403 on those. recommended: true rows " \
440
+ "are WIQ-blessed picks; prefer: lists alternatives for " \
441
+ "de-emphasized types.",
442
+ breadcrumbs: [
443
+ { "cmd" => "wiq reports run <TYPE>", "description" => "Submit a report" },
444
+ { "cmd" => "wiq registrations questions",
445
+ "description" => "Discover question ids for RosterReport --append-properties" }
446
+ ]
447
+ )
448
+ end
449
+
450
+ # Class-level poller used by reports + check_ins summary.
451
+ def self.poll(client, id, timeout: 300, initial: 2, max_interval: 30)
452
+ deadline = Time.now + timeout
453
+ interval = initial
454
+ report = nil
455
+ loop do
456
+ report = client.get("/api/v1/reports/#{id}")
457
+ case report["status"]
458
+ when "ready"
459
+ return report
460
+ when "failed"
461
+ raise Wiq::ReportFailedError, report
462
+ end
463
+ if Time.now >= deadline
464
+ raise Wiq::ReportTimeoutError.new(report, timeout)
465
+ end
466
+ sleep(interval)
467
+ interval = [interval * 2, max_interval].min
468
+ end
469
+ end
470
+
471
+ no_commands do
472
+ def build_args(type)
473
+ if options[:season]
474
+ resolver = Wiq::SeasonResolver.new(client)
475
+ matches = resolver.paid_sessions_for(options[:season])
476
+ raise Wiq::SeasonNotFoundError, options[:season] if matches.empty?
477
+
478
+ spec = TYPES[type]
479
+ wants_paid_session = spec.nil? || spec[:args].include?("paid_session_id")
480
+ unless wants_paid_session
481
+ raise Wiq::SeasonUnsupportedError.new(type, matches)
482
+ end
483
+ if matches.size > 1 && !options[:paid_session]
484
+ raise Wiq::SeasonUnsupportedError.new(type, matches)
485
+ end
486
+ options[:paid_session] ||= matches.first["id"]
487
+ end
488
+
489
+ args = {}
490
+ args["roster_id"] = options[:roster] if options[:roster] || options[:roster] == 0
491
+ args["paid_session_id"] = options[:paid_session] if options[:paid_session] || options[:paid_session] == 0
492
+ args["event_id"] = options[:event] if options[:event]
493
+ args["fundraiser_id"] = options[:fundraiser] if options[:fundraiser]
494
+ args["online_store_id"] = options[:online_store] if options[:online_store]
495
+ if options[:append_properties] && !options[:append_properties].empty?
496
+ args["append_property_ids"] = options[:append_properties].map(&:to_i)
497
+ end
498
+ args["include_archived_roster_tags"] = true if options[:include_archived_roster_tags]
499
+ args["days_threshold"] = options[:days_threshold] if options[:days_threshold]
500
+ args
501
+ end
502
+
503
+ def default_name(type)
504
+ range = [options[:start], options[:end]].compact.join("..")
505
+ range.empty? ? "CLI #{type}" : "CLI #{type} #{range}"
506
+ end
507
+ end
508
+ end
509
+ end
510
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wiq
4
+ module Commands
5
+ class Rosters < Base
6
+ desc "list", "List rosters"
7
+ long_desc <<~DESC
8
+ Returns every roster on the calling profile's team, paginated.
9
+
10
+ Season filtering is a CLI-side projection (WIQ has no first-class
11
+ Season entity):
12
+ --season <year> Resolves to paid_session ids whose date
13
+ window overlaps that calendar year, then
14
+ filters rosters whose roster_syncers point
15
+ at any of those paid sessions.
16
+ --season-tag <tag> Filters rosters carrying a tag with that
17
+ exact name. Some teams tag rosters with
18
+ conventions like "2025-26".
19
+
20
+ Each roster row embeds roster_syncers and taggings, which is what
21
+ --season uses to filter without needing extra calls.
22
+ DESC
23
+ method_option :season, type: :numeric,
24
+ desc: "Filter to rosters whose syncers point at paid sessions overlapping this year"
25
+ method_option :season_tag, type: :string, desc: "Filter to rosters carrying this tag"
26
+ method_option :archived, type: :boolean, desc: "Show only archived (true) or active (false)"
27
+ method_option :all, type: :boolean, default: false
28
+ def list
29
+ params = { "per_page" => 100 }
30
+ unless options[:archived].nil?
31
+ params["q[archived_eq]"] = options[:archived]
32
+ end
33
+
34
+ records, total = fetch_index("/api/v1/rosters", params, key: "rosters")
35
+
36
+ if options[:season]
37
+ resolver = Wiq::SeasonResolver.new(client)
38
+ ids = resolver.paid_session_ids_for(options[:season])
39
+ raise Wiq::SeasonNotFoundError, options[:season] if ids.empty?
40
+
41
+ records = resolver.filter_rosters_by_ids(records, ids)
42
+ end
43
+
44
+ if options[:season_tag]
45
+ tag = options[:season_tag]
46
+ records = records.select do |r|
47
+ (r["taggings"] || []).any? { |t| t.dig("tag", "name") == tag }
48
+ end
49
+ end
50
+
51
+ render_index(
52
+ records, total: total,
53
+ summary: "Listed #{records.size} rosters.",
54
+ breadcrumbs: [
55
+ { "cmd" => "wiq rosters show <id>", "description" => "Inspect a single roster" },
56
+ { "cmd" => "wiq reports run RosterReport --roster <id>",
57
+ "description" => "Export the roster as a report" }
58
+ ]
59
+ )
60
+ end
61
+
62
+ desc "show ID", "Fetch a single roster"
63
+ long_desc <<~DESC
64
+ Full roster payload: name, archived flag, age_division_id, taggings
65
+ (with tag metadata), roster_syncers (linkage to paid sessions), and
66
+ an embedded stats.roster_memberships_count.
67
+ DESC
68
+ def show(id)
69
+ roster = client.get("/api/v1/rosters/#{id}")
70
+ render(roster,
71
+ summary: "Roster #{roster["id"]} — #{roster["name"]}",
72
+ breadcrumbs: [
73
+ { "cmd" => "wiq reports run RosterStatsReport --roster #{roster["id"]} --start <date> --end <date>",
74
+ "description" => "Pull stats for this roster" },
75
+ { "cmd" => "wiq check_ins summary --roster #{roster["id"]} --start <date> --end <date>",
76
+ "description" => "Attendance summary for this roster" }
77
+ ])
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Wiq
6
+ module Commands
7
+ class Setup < Base
8
+ SKILL_FILENAME = "SKILL.md"
9
+ BUNDLED_SKILL_PATH = File.expand_path("../../../../share/skills/wiq/#{SKILL_FILENAME}", __FILE__)
10
+
11
+ desc "claude", "Install the wiq Claude Code skill"
12
+ long_desc <<~DESC
13
+ Copies the bundled SKILL.md into the right location for Claude Code
14
+ to auto-detect.
15
+
16
+ Default install (user-global):
17
+ ~/.claude/skills/wiq/SKILL.md
18
+
19
+ Per-project install (--project):
20
+ ./.claude/skills/wiq/SKILL.md
21
+
22
+ Claude Code watches both locations live during a session — no
23
+ restart needed. Re-run with --force to overwrite an existing
24
+ install (useful when upgrading the gem).
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.
28
+ DESC
29
+ method_option :project, type: :boolean, default: false,
30
+ desc: "Install per-project (./.claude/skills/) instead of user-global"
31
+ method_option :force, type: :boolean, default: false,
32
+ desc: "Overwrite an existing SKILL.md at the target path"
33
+ method_option :print, type: :boolean, default: false,
34
+ desc: "Print SKILL.md to stdout instead of installing"
35
+ def claude
36
+ unless File.exist?(BUNDLED_SKILL_PATH)
37
+ raise Wiq::Error.new(
38
+ "Bundled SKILL.md not found at #{BUNDLED_SKILL_PATH}.",
39
+ code: "skill_missing",
40
+ hint: "Reinstall wiq-cli — the gem package may be incomplete."
41
+ )
42
+ end
43
+
44
+ if options[:print]
45
+ puts File.read(BUNDLED_SKILL_PATH)
46
+ return
47
+ end
48
+
49
+ target_dir = options[:project] ? File.join(Dir.pwd, ".claude", "skills", "wiq")
50
+ : File.join(Dir.home, ".claude", "skills", "wiq")
51
+ target_path = File.join(target_dir, SKILL_FILENAME)
52
+
53
+ if File.exist?(target_path) && !options[:force]
54
+ raise Wiq::Error.new(
55
+ "#{target_path} already exists.",
56
+ code: "skill_exists",
57
+ hint: "Pass --force to overwrite, or --print to inspect the bundled version first."
58
+ )
59
+ end
60
+
61
+ FileUtils.mkdir_p(target_dir)
62
+ FileUtils.cp(BUNDLED_SKILL_PATH, target_path)
63
+
64
+ render(
65
+ {
66
+ "scope" => options[:project] ? "project" : "user-global",
67
+ "target_path" => target_path,
68
+ "size_bytes" => File.size(target_path),
69
+ "claude_will_auto_detect" => true
70
+ },
71
+ summary: "Installed Claude Code skill to #{target_path}.",
72
+ breadcrumbs: [
73
+ { "cmd" => "wiq setup claude --print",
74
+ "description" => "Inspect the bundled skill content" },
75
+ { "cmd" => "wiq setup claude --force",
76
+ "description" => "Re-install (e.g. after upgrading wiq-cli)" }
77
+ ]
78
+ )
79
+ end
80
+ end
81
+ end
82
+ end