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.
data/lib/wiq/cli.rb ADDED
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Wiq
6
+ class CLI < Thor
7
+ package_name "wiq"
8
+
9
+ def self.exit_on_failure?
10
+ true
11
+ end
12
+
13
+ desc "version", "Print the CLI version"
14
+ def version
15
+ puts Wiq::VERSION
16
+ end
17
+ map %w[--version -v] => :version
18
+
19
+ desc "commands", "Dump the full command tree as JSON for agent discovery"
20
+ long_desc <<~DESC
21
+ Walks the Thor command registry and emits the entire CLI surface as
22
+ structured JSON. Schema:
23
+
24
+ { name, version, global_options[], top_level_commands[],
25
+ groups: [ { name, description, commands: [
26
+ { name, description, long_description, usage,
27
+ options: [ { name, type, required, default?, enum?, description } ]
28
+ }
29
+ ] } ] }
30
+
31
+ Recommended first call for any agent: pipe to a file, cache it, then
32
+ pick a command and invoke it directly. No HTTP, no auth required.
33
+
34
+ `global_options` lists --host, --as, --json, --agent — these apply
35
+ uniformly to every command and aren't repeated per-command.
36
+ DESC
37
+ def commands
38
+ puts JSON.pretty_generate(Wiq::Introspection.dump_tree)
39
+ end
40
+
41
+ desc "doctor", "Diagnose env, network, token, and config sources"
42
+ subcommand "doctor", Wiq::Commands::Doctor
43
+
44
+ desc "auth SUBCOMMAND", "Manage authentication (login, status, logout, list)"
45
+ subcommand "auth", Wiq::Commands::Auth
46
+
47
+ desc "check_ins SUBCOMMAND", "Attendance + check-ins (event, wrestler, summary)"
48
+ subcommand "check_ins", Wiq::Commands::CheckIns
49
+
50
+ desc "reports SUBCOMMAND", "Reports (run, show, types)"
51
+ subcommand "reports", Wiq::Commands::Reports
52
+
53
+ desc "paid_sessions SUBCOMMAND", "Paid sessions / registration data (list, show)"
54
+ subcommand "paid_sessions", Wiq::Commands::PaidSessions
55
+
56
+ desc "registrations SUBCOMMAND", "Registration questions + answers"
57
+ subcommand "registrations", Wiq::Commands::Registrations
58
+
59
+ desc "metrics SUBCOMMAND", "Dashboard metrics (list, show)"
60
+ subcommand "metrics", Wiq::Commands::Metrics
61
+
62
+ desc "events SUBCOMMAND", "Calendar events (list, show)"
63
+ subcommand "events", Wiq::Commands::Events
64
+
65
+ desc "rosters SUBCOMMAND", "Rosters (list, show)"
66
+ subcommand "rosters", Wiq::Commands::Rosters
67
+
68
+ desc "prospects SUBCOMMAND", "Prospect pipeline — individual kids (list, show, summary)"
69
+ subcommand "prospects", Wiq::Commands::Prospects
70
+
71
+ desc "prospect_families SUBCOMMAND", "Prospect pipeline — households (list, show, notes)"
72
+ subcommand "prospect_families", Wiq::Commands::ProspectFamilies
73
+
74
+ desc "workflows SUBCOMMAND", "Named multi-step recipes for common club-admin questions (list, show)"
75
+ subcommand "workflows", Wiq::Commands::Workflows
76
+
77
+ desc "setup SUBCOMMAND", "Install integration files (Claude Code skill, future: Cursor, etc.)"
78
+ subcommand "setup", Wiq::Commands::Setup
79
+
80
+ desc "wrestlers SUBCOMMAND", "Search wrestlers — narrow ID-discovery surface (list, show)"
81
+ subcommand "wrestlers", Wiq::Commands::Wrestlers
82
+
83
+ desc "charges SUBCOMMAND", "Payment history — list charges by family, status, type, date (admin only)"
84
+ subcommand "charges", Wiq::Commands::Charges
85
+
86
+ desc "billing_profiles SUBCOMMAND", "Look up a parent or coach's billing profile (admin only)"
87
+ subcommand "billing_profiles", Wiq::Commands::BillingProfiles
88
+ end
89
+ end
data/lib/wiq/client.rb ADDED
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "json"
6
+ require "uri"
7
+
8
+ module Wiq
9
+ # Thin wrapper around Faraday that:
10
+ # - Adds Authorization: Bearer <pat>
11
+ # - Parses JSON requests and responses
12
+ # - Retries on 429 and 5xx (Retry-After honored)
13
+ # - Translates non-2xx responses into Wiq::APIError
14
+ # - Exposes paginate(path, params) that walks the Link: rel=next chain
15
+ class Client
16
+ USER_AGENT = "wiq-cli/#{Wiq::VERSION}"
17
+
18
+ attr_reader :host
19
+
20
+ def initialize(config)
21
+ @config = config
22
+ config.require_host!
23
+ @host = config.host
24
+ @token = config.token
25
+ end
26
+
27
+ def get(path, params = {})
28
+ request(:get, path, params: params)
29
+ end
30
+
31
+ def post(path, body = {})
32
+ request(:post, path, body: body)
33
+ end
34
+
35
+ def put(path, body = {})
36
+ request(:put, path, body: body)
37
+ end
38
+
39
+ def delete(path, params = {})
40
+ request(:delete, path, params: params)
41
+ end
42
+
43
+ # Yields each page's records until rel=next runs out.
44
+ # All /api/v1 index endpoints wrap their array under the resource name
45
+ # (e.g. `{"rosters": [...]}`) — callers must pass `key:` so we can unwrap.
46
+ # Yields (records_array, response, total_count).
47
+ def paginate(path, params = {}, key:)
48
+ url = absolute(path)
49
+ first = true
50
+ loop do
51
+ resp = first ? request(:get, path, params: params, raw: true)
52
+ : request(:get, url, raw: true)
53
+ first = false
54
+ records = extract_records(resp.body, key)
55
+ total = Pagination.total_count(resp.headers)
56
+ yield records, resp, total
57
+ url = Pagination.next_url(resp.headers["Link"] || resp.headers["link"])
58
+ break unless url
59
+ end
60
+ end
61
+
62
+ # Collects every page of an index into a single array.
63
+ def collect_all(path, params = {}, key:)
64
+ all = []
65
+ total = nil
66
+ paginate(path, params, key: key) do |records, _resp, t|
67
+ total ||= t
68
+ all.concat(records)
69
+ end
70
+ [all, total]
71
+ end
72
+
73
+ private
74
+
75
+ def extract_records(body, key)
76
+ return Array(body) if body.is_a?(Array)
77
+ return [] if body.nil?
78
+ raise Error.new("Expected wrapped index response with key #{key.inspect}, got #{body.class}",
79
+ code: "unexpected_response") unless body.is_a?(Hash)
80
+
81
+ Array(body[key.to_s] || body[key.to_sym])
82
+ end
83
+
84
+ def request(method, path_or_url, params: {}, body: nil, raw: false)
85
+ url = path_or_url.start_with?("http") ? path_or_url : absolute(path_or_url)
86
+
87
+ response = connection.public_send(method) do |req|
88
+ req.url(url)
89
+ req.params.update(params) if params && !params.empty?
90
+ req.body = body if body
91
+ end
92
+
93
+ if response.status >= 400
94
+ raise APIError.new(
95
+ status: response.status,
96
+ body: response.body,
97
+ request_id: response.headers["X-Request-Id"]
98
+ )
99
+ end
100
+
101
+ raw ? response : response.body
102
+ end
103
+
104
+ def absolute(path)
105
+ base = @host.sub(%r{/+\z}, "")
106
+ path = path.start_with?("/") ? path : "/#{path}"
107
+ "#{base}#{path}"
108
+ end
109
+
110
+ def connection
111
+ @connection ||= Faraday.new do |f|
112
+ f.request :json
113
+ f.request :retry,
114
+ max: 3,
115
+ interval: 0.5,
116
+ backoff_factor: 2,
117
+ retry_statuses: [429, 500, 502, 503, 504],
118
+ methods: %i[get head]
119
+ f.response :json, content_type: /\bjson\z/
120
+ f.headers["Accept"] = "application/json"
121
+ f.headers["User-Agent"] = USER_AGENT
122
+ f.headers["Authorization"] = "Bearer #{@token}" if @token
123
+ f.adapter Faraday.default_adapter
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+
5
+ module Wiq
6
+ module Commands
7
+ class Auth < Base
8
+ desc "login", "Store a personal access token for a WIQ host"
9
+ long_desc <<~DESC
10
+ Stores a PAT in ~/.config/wiq/credentials.json (mode 0600) keyed by
11
+ host + alias.
12
+
13
+ First-time login slots into "default" automatically. If "default" is
14
+ already taken, pass --as <alias> to create a separate slot — this is
15
+ how the CLI supports multiple WIQ accounts (different clubs, different
16
+ roles) on the same host.
17
+
18
+ Customers mint PATs at <host>/settings/personal_access_tokens. The
19
+ plaintext is shown once at creation; paste it into this command (or
20
+ use --token).
21
+
22
+ Verification: the CLI hits `GET /api/v1/personal_access_tokens` to
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.
26
+ DESC
27
+ method_option :token, type: :string, desc: "PAT value (otherwise prompted interactively)"
28
+ method_option :force, type: :boolean, default: false,
29
+ desc: "Overwrite an existing entry at this host+alias slot"
30
+ def login
31
+ default_host = Wiq::Config::PRODUCTION_HOST
32
+ host = options[:host] || prompt("WIQ host URL [#{default_host}]: ")
33
+ host = host.to_s.strip
34
+ host = default_host if host.empty?
35
+ unless host.start_with?("http")
36
+ raise Wiq::ConfigError.new("Host must be an absolute URL, got #{host.inspect}.",
37
+ code: "invalid_host")
38
+ end
39
+
40
+ token = options[:token] || prompt_secret("Personal access token: ")
41
+ token = token.to_s.strip
42
+ unless token.start_with?("wiq_pat_")
43
+ raise Wiq::ConfigError.new("Token does not start with `wiq_pat_`.",
44
+ code: "invalid_token",
45
+ hint: "Mint a PAT at <host>/settings/personal_access_tokens.")
46
+ end
47
+
48
+ alias_name = pick_login_alias(host, options[:as])
49
+
50
+ if Wiq::Credentials.for_host(host, alias_name) && !options[:force]
51
+ raise Wiq::AliasConflictError.new(host, alias_name)
52
+ end
53
+
54
+ cfg = Wiq::Config.new
55
+ cfg.instance_variable_set(:@host, host)
56
+ cfg.instance_variable_set(:@token, token)
57
+ client = Wiq::Client.new(cfg)
58
+
59
+ metadata = lookup_token_metadata(client, token)
60
+
61
+ Wiq::Credentials.store(
62
+ host: host,
63
+ alias_name: alias_name,
64
+ token: token,
65
+ token_prefix: metadata[:token_prefix],
66
+ name: metadata[:name],
67
+ profile: metadata[:profile]
68
+ )
69
+
70
+ render(
71
+ {
72
+ "host" => host,
73
+ "alias" => alias_name,
74
+ "token_prefix" => metadata[:token_prefix],
75
+ "name" => metadata[:name],
76
+ "profile" => metadata[:profile],
77
+ "stored_at" => Wiq::Credentials.for_host(host, alias_name)["stored_at"]
78
+ }.compact,
79
+ summary: profile_summary("Stored PAT for #{host} as #{alias_name.inspect}",
80
+ metadata[:profile]),
81
+ breadcrumbs: [
82
+ { "cmd" => "wiq auth status --as #{alias_name}",
83
+ "description" => "Verify the stored token works" },
84
+ { "cmd" => "wiq auth list", "description" => "See all stored credentials" }
85
+ ]
86
+ )
87
+ end
88
+
89
+ desc "status", "Show the configured host, alias, and bound profile"
90
+ long_desc <<~DESC
91
+ Resolves the configuration chain (--host/--as flags → env vars →
92
+ .wiq/config.json → credentials store) and reports which source won.
93
+
94
+ Performs a best-effort live probe against
95
+ `/api/v1/personal_access_tokens` to confirm the token still works
96
+ and surface the live last_used_at. If the probe fails (network,
97
+ revoked token, host unreachable), the error appears in `live_error`
98
+ rather than aborting the command — local state is always shown.
99
+
100
+ Useful as the first call when an agent inherits a configured shell:
101
+ verifies host, alias, profile binding, and team in one shot.
102
+ DESC
103
+ def status
104
+ cfg = Wiq::Config.load(symbolized_options)
105
+ raise Wiq::HostUnsetError unless cfg.host
106
+
107
+ store_entry = cfg.alias_name ? Wiq::Credentials.for_host(cfg.host, cfg.alias_name) || {} : {}
108
+ live_metadata = nil
109
+ live_error = nil
110
+ if cfg.token
111
+ begin
112
+ client = Wiq::Client.new(cfg)
113
+ live_metadata = lookup_token_metadata(client, cfg.token)
114
+ rescue Wiq::APIError, Faraday::Error => e
115
+ live_error = e.message
116
+ end
117
+ end
118
+
119
+ data = {
120
+ "host" => cfg.host,
121
+ "host_source" => cfg.sources[:host],
122
+ "alias" => cfg.alias_name,
123
+ "alias_source" => cfg.sources[:alias],
124
+ "token_present" => !cfg.token.nil?,
125
+ "token_source" => cfg.sources[:token],
126
+ "token_prefix" => store_entry["token_prefix"],
127
+ "stored_name" => store_entry["name"],
128
+ "stored_profile" => store_entry["profile"],
129
+ "live_name" => live_metadata&.dig(:name),
130
+ "live_last_used_at" => live_metadata&.dig(:last_used_at),
131
+ "live_profile" => live_metadata&.dig(:profile),
132
+ "live_error" => live_error
133
+ }.compact
134
+
135
+ summary =
136
+ if !cfg.token
137
+ cfg.alias_name ? "No token stored for alias #{cfg.alias_name.inspect}." \
138
+ : "No alias resolvable for this host."
139
+ else
140
+ profile_summary("Token reachable (alias=#{cfg.alias_name})",
141
+ live_metadata&.dig(:profile) || store_entry["profile"])
142
+ end
143
+
144
+ render(data, summary: summary)
145
+ end
146
+
147
+ desc "logout", "Remove a stored credential slot"
148
+ long_desc <<~DESC
149
+ Removes a single stored credential by host + alias. The PAT is NOT
150
+ revoked server-side — to revoke, use the web UI at
151
+ <host>/settings/personal_access_tokens. Logout just clears the
152
+ local file entry.
153
+ DESC
154
+ def logout
155
+ cfg = Wiq::Config.load(symbolized_options)
156
+ raise Wiq::HostUnsetError unless cfg.host
157
+ unless cfg.alias_name
158
+ aliases = Wiq::Credentials.aliases_for(cfg.host)
159
+ raise Wiq::AmbiguousAliasError.new(cfg.host, aliases) unless aliases.empty?
160
+
161
+ render({ "host" => cfg.host, "removed" => false },
162
+ summary: "No credentials stored for #{cfg.host}.")
163
+ return
164
+ end
165
+
166
+ removed = Wiq::Credentials.remove(cfg.host, cfg.alias_name)
167
+ render(
168
+ { "host" => cfg.host, "alias" => cfg.alias_name, "removed" => removed },
169
+ summary: removed ? "Cleared #{cfg.alias_name.inspect} for #{cfg.host}." \
170
+ : "No credential stored at #{cfg.host} / #{cfg.alias_name.inspect}."
171
+ )
172
+ end
173
+
174
+ desc "list", "List all stored credentials across hosts and aliases"
175
+ long_desc <<~DESC
176
+ Dumps every stored credential across all hosts and aliases. Never
177
+ includes the raw token (only token_prefix); safe to share or log.
178
+
179
+ Useful for agents inheriting a shared shell — they can see what's
180
+ available before invoking commands.
181
+ DESC
182
+ def list
183
+ entries = Wiq::Credentials.all_entries
184
+ render_index(
185
+ entries,
186
+ summary: "Stored credentials: #{entries.size}.",
187
+ breadcrumbs: [
188
+ { "cmd" => "wiq auth status --as <alias>", "description" => "Inspect a specific slot" }
189
+ ]
190
+ )
191
+ end
192
+
193
+ no_commands do
194
+ def prompt(label)
195
+ $stderr.print(label)
196
+ $stdin.gets.to_s.chomp
197
+ end
198
+
199
+ def prompt_secret(label)
200
+ $stderr.print(label)
201
+ val = $stdin.noecho(&:gets).to_s.chomp
202
+ $stderr.puts ""
203
+ val
204
+ rescue Errno::ENOTTY, IOError
205
+ $stdin.gets.to_s.chomp
206
+ end
207
+
208
+ # Decide which slot to write to. Explicit --as wins. Otherwise:
209
+ # - empty bucket → "default"
210
+ # - "default" free → "default"
211
+ # - "default" taken → demand --as
212
+ def pick_login_alias(host, requested)
213
+ return requested if requested && !requested.empty?
214
+
215
+ existing = Wiq::Credentials.aliases_for(host)
216
+ return Wiq::Credentials::DEFAULT_ALIAS if existing.empty?
217
+ return Wiq::Credentials::DEFAULT_ALIAS unless existing.include?(Wiq::Credentials::DEFAULT_ALIAS)
218
+
219
+ raise Wiq::ConfigError.new(
220
+ "An entry already exists at #{host} under alias \"default\".",
221
+ code: "alias_required",
222
+ hint: "Pass --as <name> to store under a different slot, or --force to overwrite default."
223
+ )
224
+ end
225
+
226
+ def lookup_token_metadata(client, token)
227
+ prefix = token[0, 12]
228
+ rows, = client.collect_all(
229
+ "/api/v1/personal_access_tokens",
230
+ { "per_page" => 100 },
231
+ key: "personal_access_tokens"
232
+ )
233
+ match = rows.find { |r| r["token_prefix"] == prefix }
234
+ return { token_prefix: prefix, name: nil, last_used_at: nil, profile: nil } unless match
235
+
236
+ {
237
+ token_prefix: match["token_prefix"],
238
+ name: match["name"],
239
+ last_used_at: match["last_used_at"],
240
+ profile: match["profile"]
241
+ }
242
+ end
243
+
244
+ def profile_summary(prefix, profile)
245
+ return "#{prefix}." unless profile
246
+
247
+ who = profile["display_name"]
248
+ where = profile["team_name"]
249
+ kind = profile["type"]
250
+ tail = [who, where].compact.reject(&:empty?).join(" @ ")
251
+ tail += " (#{kind})" if kind && !kind.empty?
252
+ tail.empty? ? "#{prefix}." : "#{prefix} — #{tail}."
253
+ end
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Wiq
6
+ module Commands
7
+ # Parent class for every subcommand group. Defines the cross-cutting
8
+ # --host / --json / --agent options and the helpers each command uses.
9
+ class Base < Thor
10
+ class_option :host, type: :string,
11
+ desc: "WIQ host URL (overrides env + config + credential store)"
12
+ class_option :as, type: :string,
13
+ desc: "Credential alias to use (overrides env + config + sole/default)"
14
+ class_option :json, type: :boolean, default: false,
15
+ desc: "Output the full JSON envelope"
16
+ class_option :agent, type: :boolean, default: false,
17
+ desc: "Output bare JSON of `data` with no envelope or colors"
18
+
19
+ def self.exit_on_failure?
20
+ true
21
+ end
22
+
23
+ no_commands do
24
+ def config
25
+ @config ||= Wiq::Config.load(symbolized_options)
26
+ end
27
+
28
+ def client
29
+ @client ||= begin
30
+ config.require_token!
31
+ Wiq::Client.new(config)
32
+ end
33
+ end
34
+
35
+ def symbolized_options
36
+ @symbolized_options ||= options.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
37
+ end
38
+
39
+ def render(data, summary: nil, breadcrumbs: [], meta: {})
40
+ Wiq::Output.render(data, summary: summary, breadcrumbs: breadcrumbs,
41
+ meta: meta.merge("host" => config.host).compact,
42
+ options: symbolized_options)
43
+ end
44
+
45
+ def render_index(records, total: nil, page: nil, summary: nil, breadcrumbs: [])
46
+ meta = { "count" => records.size }
47
+ meta["total"] = total if total
48
+ meta["page"] = page if page
49
+ render(records, summary: summary, breadcrumbs: breadcrumbs, meta: meta)
50
+ end
51
+
52
+ def fetch_index(path, params, key:)
53
+ if options[:all]
54
+ records, total = client.collect_all(path, params, key: key)
55
+ [records, total, nil]
56
+ else
57
+ records = []
58
+ total = nil
59
+ client.paginate(path, params, key: key) do |page_records, _resp, t|
60
+ records = page_records
61
+ total = t
62
+ break
63
+ end
64
+ [records, total, nil]
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wiq
4
+ module Commands
5
+ class BillingProfiles < Base
6
+ VALID_PROFILE_TYPES = %w[ParentProfile CoachProfile].freeze
7
+
8
+ desc "show PROFILE_ID", "Look up a parent or coach's billing profile"
9
+ long_desc <<~DESC
10
+ Fetches the billing profile (payment-method metadata + Stripe/Justifi
11
+ linkage) for a specific parent or coach profile.
12
+
13
+ Path: GET /api/v1/billing_profiles/<profile_type>/<profile_id>.
14
+
15
+ WrestlerProfile is not supported — wrestlers themselves don't have
16
+ billing profiles in WIQ. To find which family paid for a given
17
+ wrestler, walk via `wiq wrestlers show <id>` to find the parents
18
+ array, then call this for one of the parent profile ids.
19
+
20
+ Returns: id, email, billing_partner (stripe | justifi),
21
+ default_payment_method_{last4, brand, exp_month, exp_year}, and
22
+ the embedded billable profile (full_name, type).
23
+
24
+ Pass --for-update to get a setup_intent for card-on-file updates;
25
+ not relevant for read-only agent flows.
26
+ DESC
27
+ method_option :profile_type, type: :string, required: true,
28
+ enum: VALID_PROFILE_TYPES,
29
+ desc: "ParentProfile | CoachProfile (WrestlerProfile not supported)"
30
+ method_option :for_update, type: :boolean, default: false,
31
+ desc: "Include setup_intent + billing_partner_key for card updates"
32
+ def show(profile_id)
33
+ params = {}
34
+ params["for_update"] = true if options[:for_update]
35
+
36
+ billing_profile = client.get(
37
+ "/api/v1/billing_profiles/#{options[:profile_type]}/#{profile_id}",
38
+ params
39
+ )
40
+ render(billing_profile,
41
+ summary: "Billing profile #{billing_profile["id"]} for " \
42
+ "#{options[:profile_type]} #{profile_id}.",
43
+ breadcrumbs: [
44
+ { "cmd" => "wiq charges list --billing-profile #{billing_profile["id"]}",
45
+ "description" => "Payment history for this family" },
46
+ { "cmd" => "wiq charges list --billing-profile #{billing_profile["id"]} --status failed --since <date>",
47
+ "description" => "Recent failed charges" }
48
+ ])
49
+ end
50
+ end
51
+ end
52
+ end