fizzy-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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8f3929ca84a2457054103a3f63addd79d594e2a1b10c35335a0de3ec0255144b
4
+ data.tar.gz: ed75704d968deb083d4535ce85d6f68827877e65bc735b36c23ac22f0b7f01dc
5
+ SHA512:
6
+ metadata.gz: 5dd289c5a61f833fb5f90f6efa6a2bbf9d7532f5a6650157debaa918347e94bc9336490dd352a3d9985fa7a875b2730a74a3ee291444b09035220a48365d489a
7
+ data.tar.gz: 3bf5e77238da1f4f1d49dfb4d74874ab7305be00bf6478ad3e0b2090145d2259922ca928548d58af9fa5325db2295255f2dac3e073eb4590a19c624cba0ecc1c
data/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/).
7
+
8
+ ## [0.1.0] - 2025-02-21
9
+
10
+ ### Added
11
+ - Initial release
12
+ - Board, card, column, step, comment, reaction, tag, user, notification, and pin management
13
+ - Thor-based CLI with subcommands
14
+ - Token auth with multi-account support (`--account`)
15
+ - JSON output mode (`--json`) for all commands
16
+ - Link-header pagination support
17
+ - Table, detail, and JSON output formatters
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 David Paluy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,194 @@
1
+ # Fizzy CLI
2
+
3
+ A Ruby command-line client for [Fizzy](https://fizzy.do) project management.
4
+
5
+ [![Gem Version](https://badge.fury.io/rb/fizzy-cli.svg)](https://badge.fury.io/rb/fizzy-cli)
6
+ [![CI](https://github.com/dpaluy/fizzy-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/dpaluy/fizzy-cli/actions/workflows/ci.yml)
7
+
8
+ ## Install
9
+
10
+ Requires Ruby >= 3.2.
11
+
12
+ ```sh
13
+ gem install fizzy
14
+ ```
15
+
16
+ ## Authentication
17
+
18
+ ### Getting a Personal Access Token
19
+
20
+ 1. Log in to [app.fizzy.do](https://app.fizzy.do)
21
+ 2. Open your profile settings (click your avatar)
22
+ 3. Scroll to the **Developer** section
23
+ 4. Click **personal access tokens**
24
+ 5. Create a new token and copy it
25
+
26
+ ![Fizzy Developer Settings](fizzy-token.png)
27
+
28
+ ### Logging in
29
+
30
+ ```sh
31
+ fizzy auth login --token YOUR_TOKEN
32
+ ```
33
+
34
+ Tokens are stored at `~/.config/fizzy-cli/tokens.json`. You can also set `FIZZY_TOKEN` as an environment variable with `--account` to skip the file.
35
+
36
+ ```sh
37
+ fizzy auth status # Show current auth
38
+ fizzy auth accounts # List available accounts
39
+ fizzy auth switch SLUG # Change default account
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ All commands support `--json` for JSON output and `--account SLUG` to override the default account.
45
+
46
+ ### Boards
47
+
48
+ ```sh
49
+ fizzy boards list
50
+ fizzy boards get BOARD_ID
51
+ fizzy boards create "My Board"
52
+ fizzy boards update BOARD_ID --name "New Name"
53
+ fizzy boards delete BOARD_ID
54
+ ```
55
+
56
+ ### Cards
57
+
58
+ Cards are addressed by number (not ID).
59
+
60
+ ```sh
61
+ fizzy cards list
62
+ fizzy cards list --board BOARD_ID --status open
63
+ fizzy cards get 42
64
+ fizzy cards create "Card title" --board BOARD_ID
65
+ fizzy cards update 42 --title "New title"
66
+ fizzy cards delete 42
67
+ ```
68
+
69
+ Card actions:
70
+
71
+ ```sh
72
+ fizzy cards close 42
73
+ fizzy cards reopen 42
74
+ fizzy cards not-now 42
75
+ fizzy cards triage 42 --column COLUMN_ID
76
+ fizzy cards untriage 42
77
+ fizzy cards tag 42 "bug"
78
+ fizzy cards assign 42 USER_ID
79
+ fizzy cards watch 42
80
+ fizzy cards unwatch 42
81
+ fizzy cards golden 42
82
+ fizzy cards ungolden 42
83
+ ```
84
+
85
+ ### Columns
86
+
87
+ Columns are scoped to a board.
88
+
89
+ ```sh
90
+ fizzy columns list --board BOARD_ID
91
+ fizzy columns get COLUMN_ID --board BOARD_ID
92
+ fizzy columns create "To Do" --board BOARD_ID
93
+ fizzy columns update COLUMN_ID --board BOARD_ID --name "Done"
94
+ fizzy columns delete COLUMN_ID --board BOARD_ID
95
+ ```
96
+
97
+ ### Steps
98
+
99
+ Steps are scoped to a card (no list endpoint; steps appear in `cards get`).
100
+
101
+ ```sh
102
+ fizzy steps create "Write tests" --card 42
103
+ fizzy steps get STEP_ID --card 42
104
+ fizzy steps update STEP_ID --card 42 --completed
105
+ fizzy steps delete STEP_ID --card 42
106
+ ```
107
+
108
+ ### Comments
109
+
110
+ ```sh
111
+ fizzy comments list --card 42
112
+ fizzy comments get COMMENT_ID --card 42
113
+ fizzy comments create "Looks good" --card 42
114
+ fizzy comments update COMMENT_ID --card 42 --body "Updated"
115
+ fizzy comments delete COMMENT_ID --card 42
116
+ ```
117
+
118
+ ### Reactions
119
+
120
+ Works on cards and comments.
121
+
122
+ ```sh
123
+ fizzy reactions list --card 42
124
+ fizzy reactions list --card 42 --comment COMMENT_ID
125
+ fizzy reactions create "thumbsup" --card 42
126
+ fizzy reactions delete REACTION_ID --card 42
127
+ ```
128
+
129
+ ### Tags, Users, Notifications, Pins
130
+
131
+ ```sh
132
+ fizzy tags list
133
+ fizzy users list
134
+ fizzy users get USER_ID
135
+ fizzy notifications list
136
+ fizzy notifications read NOTIFICATION_ID
137
+ fizzy notifications mark-all-read
138
+ fizzy pins pin 42
139
+ fizzy pins unpin 42
140
+ ```
141
+
142
+ ### Other
143
+
144
+ ```sh
145
+ fizzy identity # Show accounts and user info
146
+ fizzy version # Print version
147
+ fizzy help # List all commands
148
+ ```
149
+
150
+ ## Error Handling
151
+
152
+ ```ruby
153
+ begin
154
+ # any fizzy command
155
+ rescue Fizzy::AuthError => e
156
+ # 401 - invalid or expired token
157
+ rescue Fizzy::ForbiddenError => e
158
+ # 403 - insufficient permissions
159
+ rescue Fizzy::NotFoundError => e
160
+ # 404 - resource not found
161
+ rescue Fizzy::ValidationError => e
162
+ # 422 - invalid input
163
+ rescue Fizzy::RateLimitError => e
164
+ # 429 - too many requests, retry with backoff
165
+ rescue Fizzy::ServerError => e
166
+ # 500+ - server error
167
+ rescue Fizzy::NetworkError => e
168
+ # Timeout or connection failure
169
+ end
170
+ ```
171
+
172
+ ## Configuration
173
+
174
+ | Source | Purpose |
175
+ |--------|---------|
176
+ | `~/.config/fizzy-cli/tokens.json` | Stored auth tokens and default account |
177
+ | `FIZZY_TOKEN` env var | Token override (requires `--account`) |
178
+
179
+ ## Development
180
+
181
+ ```
182
+ bundle install
183
+ bundle exec rake # runs tests + rubocop
184
+ bundle exec rake test # tests only
185
+ bundle exec rubocop # lint only
186
+ ```
187
+
188
+ ## Contributing
189
+
190
+ Bug reports and pull requests are welcome on GitHub at https://github.com/dpaluy/fizzy-cli.
191
+
192
+ ## License
193
+
194
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/bin/fizzy ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/fizzy"
5
+
6
+ begin
7
+ Fizzy::CLI.start(ARGV)
8
+ rescue Fizzy::Error => e
9
+ warn "Error: #{e.message}"
10
+ exit 1
11
+ rescue Interrupt
12
+ exit 130
13
+ end
data/lib/fizzy/auth.rb ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fizzy
4
+ class Auth
5
+ CONFIG_DIR = File.expand_path("~/.config/fizzy-cli")
6
+ TOKEN_FILE = File.join(CONFIG_DIR, "tokens.json")
7
+
8
+ def self.resolve(account_slug = nil)
9
+ if ENV["FIZZY_TOKEN"]
10
+ raise AuthError, "--account required with FIZZY_TOKEN" unless account_slug
11
+
12
+ return { "access_token" => ENV["FIZZY_TOKEN"], "account_slug" => normalize_slug(account_slug) }
13
+ end
14
+
15
+ resolve_from_file(account_slug)
16
+ end
17
+
18
+ def self.normalize_slug(slug)
19
+ slug&.delete_prefix("/")
20
+ end
21
+
22
+ def self.resolve_from_file(account_slug)
23
+ unless File.exist?(TOKEN_FILE)
24
+ raise AuthError,
25
+ "No tokens file at #{TOKEN_FILE}. Run: fizzy auth login --token TOKEN"
26
+ end
27
+
28
+ data = JSON.parse(File.read(TOKEN_FILE))
29
+ slug = normalize_slug(account_slug || data["default_account"])
30
+ account = data["accounts"]&.find { |a| normalize_slug(a["account_slug"]) == slug }
31
+ raise AuthError, "No account found for #{slug}" unless account
32
+
33
+ account
34
+ end
35
+ private_class_method :resolve_from_file
36
+ end
37
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Fizzy
6
+ class CLI < Thor
7
+ class AuthCommands < Thor
8
+ include Base
9
+
10
+ namespace "auth"
11
+
12
+ desc "identity", "Show current identity"
13
+ def identity
14
+ resp = client.get("/my/identity")
15
+ if json?
16
+ Formatter.json(resp.body)
17
+ else
18
+ resp.body["accounts"].each do |a|
19
+ puts "#{a["name"]} (#{a["slug"]})"
20
+ puts " #{a["user"]["name"]} <#{a["user"]["email_address"]}> — #{a["user"]["role"]}"
21
+ end
22
+ end
23
+ end
24
+
25
+ desc "login", "Authenticate with a Personal Access Token"
26
+ option :token, required: true, desc: "Personal Access Token"
27
+ def login
28
+ token = options[:token]
29
+
30
+ # Verify token by fetching identity
31
+ c = Client.new(token: token, account_slug: "")
32
+ resp = c.get("/my/identity")
33
+ accounts = resp.body["accounts"]
34
+
35
+ raise AuthError, "No accounts found for this token" if accounts.empty?
36
+
37
+ # Build tokens data
38
+ token_accounts = accounts.map do |a|
39
+ {
40
+ "account_slug" => a["slug"],
41
+ "account_name" => a["name"],
42
+ "account_id" => a["id"],
43
+ "access_token" => token,
44
+ "user" => {
45
+ "id" => a["user"]["id"],
46
+ "name" => a["user"]["name"],
47
+ "email_address" => a["user"]["email_address"],
48
+ "role" => a["user"]["role"]
49
+ },
50
+ "created_at" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ")
51
+ }
52
+ end
53
+
54
+ data = {
55
+ "accounts" => token_accounts,
56
+ "default_account" => accounts.first["slug"]
57
+ }
58
+
59
+ FileUtils.mkdir_p(Auth::CONFIG_DIR)
60
+ File.write(Auth::TOKEN_FILE, JSON.pretty_generate(data))
61
+ File.chmod(0o600, Auth::TOKEN_FILE)
62
+
63
+ puts "Authenticated as #{accounts.first.dig("user", "name")}"
64
+ accounts.each do |a|
65
+ marker = a["slug"] == data["default_account"] ? " (default)" : ""
66
+ puts " #{a["name"]} (#{a["slug"]})#{marker}"
67
+ end
68
+ end
69
+
70
+ desc "status", "Show current auth status"
71
+ def status
72
+ acct = Auth.resolve
73
+ c = Client.new(token: acct["access_token"], account_slug: acct["account_slug"])
74
+ resp = c.get("/my/identity")
75
+
76
+ puts "Token: #{Auth::TOKEN_FILE}"
77
+ puts "Account: #{acct["account_name"]} (#{acct["account_slug"]})"
78
+ puts "User: #{acct.dig("user", "name")} <#{acct.dig("user", "email_address")}>"
79
+
80
+ accounts_count = resp.body["accounts"].size
81
+ puts "Accounts: #{accounts_count}" if accounts_count > 1
82
+ rescue Fizzy::AuthError => e
83
+ puts "Not authenticated: #{e.message}"
84
+ end
85
+
86
+ desc "accounts", "List available accounts"
87
+ def accounts
88
+ data = JSON.parse(File.read(Auth::TOKEN_FILE))
89
+ data["accounts"].each do |a|
90
+ marker = a["account_slug"] == data["default_account"] ? " *" : ""
91
+ puts "#{a["account_name"]} (#{a["account_slug"]})#{marker}"
92
+ puts " #{a.dig("user", "name")} <#{a.dig("user", "email_address")}>"
93
+ end
94
+ end
95
+
96
+ desc "switch ACCOUNT_SLUG", "Set default account"
97
+ def switch(account_slug)
98
+ data = JSON.parse(File.read(Auth::TOKEN_FILE))
99
+ normalized = Auth.normalize_slug(account_slug)
100
+ account = data["accounts"].find { |a| Auth.normalize_slug(a["account_slug"]) == normalized }
101
+ raise AuthError, "No account found for #{account_slug}" unless account
102
+
103
+ data["default_account"] = normalized
104
+ File.write(Auth::TOKEN_FILE, JSON.pretty_generate(data))
105
+
106
+ puts "Switched to #{account["account_name"]} (#{normalized})"
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Fizzy
6
+ class CLI < Thor
7
+ module Base
8
+ def self.included(base)
9
+ base.class_eval do
10
+ def self.exit_on_failure? = true
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ def global_options
17
+ parent_options || options
18
+ end
19
+
20
+ def account
21
+ @account ||= Auth.resolve(global_options[:account])
22
+ end
23
+
24
+ def client
25
+ @client ||= Client.new(
26
+ token: account["access_token"],
27
+ account_slug: account["account_slug"]
28
+ )
29
+ end
30
+
31
+ def slug
32
+ "/#{client.account_slug}"
33
+ end
34
+
35
+ def paginator
36
+ @paginator ||= Paginator.new(client)
37
+ end
38
+
39
+ def json?
40
+ global_options[:json]
41
+ end
42
+
43
+ def build_body(*keys)
44
+ keys.each_with_object({}) do |key, body|
45
+ val = options[key]
46
+ body[key] = val unless val.nil?
47
+ end
48
+ end
49
+
50
+ def output_list(data, headers:, &row_mapper)
51
+ if json?
52
+ Formatter.json(data)
53
+ else
54
+ rows = Array(data).map(&row_mapper)
55
+ Formatter.table(rows, headers: headers)
56
+ end
57
+ end
58
+
59
+ def output_detail(data, pairs:)
60
+ if json?
61
+ Formatter.json(data)
62
+ else
63
+ Formatter.detail(pairs)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fizzy
4
+ class CLI < Thor
5
+ class Boards < Thor
6
+ include Base
7
+
8
+ desc "list", "List all boards"
9
+ def list
10
+ data = paginator.all("#{slug}/boards")
11
+ output_list(data, headers: %w[ID Name Cards Columns]) do |b|
12
+ [b["id"], b["name"], b["open_cards_count"], b["columns_count"]]
13
+ end
14
+ end
15
+
16
+ desc "get BOARD_ID", "Show a board"
17
+ def get(board_id)
18
+ resp = client.get("#{slug}/boards/#{board_id}")
19
+ b = resp.body
20
+ output_detail(b, pairs: [
21
+ ["ID", b["id"]],
22
+ ["Name", b["name"]],
23
+ ["Open cards", b["open_cards_count"]],
24
+ ["Columns", b["columns_count"]],
25
+ ["Created", b["created_at"]]
26
+ ])
27
+ end
28
+
29
+ desc "create NAME", "Create a board"
30
+ def create(name)
31
+ resp = client.post("#{slug}/boards", body: { name: name })
32
+ b = resp.body
33
+ output_detail(b, pairs: [
34
+ ["ID", b["id"]],
35
+ ["Name", b["name"]]
36
+ ])
37
+ end
38
+
39
+ desc "update BOARD_ID", "Update a board"
40
+ option :name, required: true, desc: "New board name"
41
+ def update(board_id)
42
+ resp = client.put("#{slug}/boards/#{board_id}", body: { name: options[:name] })
43
+ b = resp.body
44
+ output_detail(b, pairs: [
45
+ ["ID", b["id"]],
46
+ ["Name", b["name"]]
47
+ ])
48
+ end
49
+
50
+ desc "delete BOARD_ID", "Delete a board"
51
+ def delete(board_id)
52
+ client.delete("#{slug}/boards/#{board_id}")
53
+ puts "Board #{board_id} deleted."
54
+ end
55
+ end
56
+ end
57
+ end