fizzy-cli 0.3.2 → 0.4.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: e138fc3dd09193bc96ddc984ec1a4c68c5d425ab1522f90633cb8013741dcab2
4
- data.tar.gz: ad8e75db7233f1fb6fb6aed72713c0a7876bc5cc94f0e39644b18562e02f3c97
3
+ metadata.gz: eb20ca66466e8b1749b5b02f88107fa31ca0a9cb4cffb51cfeca3e712eb6effa
4
+ data.tar.gz: 63b5c87f28cc3b915f77836f2147e8b1f747e6791c4f1918694df9290940ac16
5
5
  SHA512:
6
- metadata.gz: f9fc0ed67b2f598b0959ba808b1b0b992430f2aba7c1e98f5b29aeb757351575e3700da8864943f385f2ffcd9be3795229cfdbb57a4bea963768559eacd1aac7
7
- data.tar.gz: 4e3d6af1559e5994c1431b1a5681c806c8a3661cf8de8d0e84dbca5f44c7f1df598a4bac10238dada7f05267f48bfbc264ff6f526a8ac2eb4c0f49a5eed514d9
6
+ metadata.gz: 99464b430132e9ea62b098fc031112b8a6cebd8cf2ee350693b67cd189c850ee6f7ad7e8cc2ae6b9898e7377b273cf77db3e64bac1de5e406da70e996a843986
7
+ data.tar.gz: ebf5fae49836fa68aac920a8e309dbf8731002d48fcbf8415fbbeabcf3a1e9be8fc586c4b09115a7f0772d064e4ce176fd92321d429d6a1f78c02da7d12b70fb
data/CHANGELOG.md CHANGED
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [0.4.1] - 2026-02-22
9
+
10
+ ### Fixed
11
+ - Card create and update sent `body` instead of `description`, causing API 400 errors (field silently dropped by Rails strong params)
12
+ - Card create with `--column` now triages via separate API call instead of sending unpermitted `column_id` param
13
+ - HTTP 400 responses now get parsed error messages (like 422) instead of raw JSON dump
14
+ - Cards, steps, and users update commands validate at least one option is provided before sending empty request
15
+
16
+ ### Added
17
+ - `BadRequestError` class for explicit HTTP 400 handling
18
+
19
+ ## [0.4.0] - 2026-02-22
20
+
21
+ ### Added
22
+ - Per-project `.fizzy.yml` config file for account and board defaults
23
+ - `fizzy init` interactive command to create `.fizzy.yml`
24
+ - `ProjectConfig` class that walks up directories to find nearest `.fizzy.yml`
25
+ - Resolution priority: CLI flag > `.fizzy.yml` > global tokens.yml default
26
+ - `Auth.token_data` class method to centralize token file reading
27
+ - `--board` is now optional for columns and card create when set in `.fizzy.yml`
28
+
29
+ ### Fixed
30
+ - Client follows Location header on any 2xx with empty body, not just 201
31
+ - All update commands handle nil response body instead of crashing with NoMethodError
32
+ - Skill file incorrectly documented `fizzy identity` instead of `fizzy auth identity`
33
+ - Guard against empty string in `--board` and `--account` flag values
34
+ - `ProjectConfig` rescues `Psych::SyntaxError` with user-friendly error message
35
+ - Blank or non-hash `.fizzy.yml` files treated as empty config instead of crashing
36
+
8
37
  ## [0.3.2] - 2026-02-22
9
38
 
10
39
  ### Changed
data/README.md CHANGED
@@ -51,6 +51,26 @@ fizzy auth accounts # List available accounts
51
51
  fizzy auth switch SLUG # Change default account
52
52
  ```
53
53
 
54
+ ## Project Configuration
55
+
56
+ Set project-level defaults so you don't need `--account` and `--board` on every command:
57
+
58
+ ```sh
59
+ fizzy init
60
+ ```
61
+
62
+ This creates a `.fizzy.yml` in the current directory:
63
+
64
+ ```yaml
65
+ account: acme
66
+ board: b1
67
+ ```
68
+
69
+ **Resolution priority** (highest wins):
70
+ 1. CLI flag (`--account` / `--board`)
71
+ 2. `.fizzy.yml` (nearest ancestor directory)
72
+ 3. Global `default_account` from `~/.config/fizzy-cli/tokens.yml`
73
+
54
74
  ## Usage
55
75
 
56
76
  All commands support `--json` for JSON output and `--account SLUG` to override the default account.
@@ -190,6 +210,7 @@ end
190
210
 
191
211
  | Source | Purpose |
192
212
  |--------|---------|
213
+ | `.fizzy.yml` | Per-project account and board defaults |
193
214
  | `~/.config/fizzy-cli/tokens.yml` | Stored auth tokens and default account |
194
215
  | `FIZZY_TOKEN` env var | Token override (requires `--account`) |
195
216
 
data/lib/fizzy/auth.rb CHANGED
@@ -19,13 +19,17 @@ module Fizzy
19
19
  slug&.delete_prefix("/")
20
20
  end
21
21
 
22
- def self.resolve_from_file(account_slug)
22
+ def self.token_data
23
23
  unless File.exist?(TOKEN_FILE)
24
24
  raise AuthError,
25
25
  "No tokens file at #{TOKEN_FILE}. Run: fizzy auth login --token TOKEN"
26
26
  end
27
27
 
28
- data = YAML.safe_load_file(TOKEN_FILE, permitted_classes: [Time])
28
+ YAML.safe_load_file(TOKEN_FILE, permitted_classes: [Time])
29
+ end
30
+
31
+ def self.resolve_from_file(account_slug)
32
+ data = token_data
29
33
  slug = normalize_slug(account_slug || data["default_account"])
30
34
  account = data["accounts"]&.find { |a| normalize_slug(a["account_slug"]) == slug }
31
35
  raise AuthError, "No account found for #{slug}" unless account
@@ -17,8 +17,24 @@ module Fizzy
17
17
  parent_options || options
18
18
  end
19
19
 
20
+ def project_config
21
+ @project_config ||= ProjectConfig.new
22
+ end
23
+
20
24
  def account
21
- @account ||= Auth.resolve(global_options[:account])
25
+ slug = global_options[:account]
26
+ slug = nil if slug&.empty?
27
+ @account ||= Auth.resolve(slug || project_config.account)
28
+ end
29
+
30
+ def board
31
+ b = options[:board]
32
+ b && !b.empty? ? b : project_config.board
33
+ end
34
+
35
+ def require_board!
36
+ board || raise(Thor::Error,
37
+ "No value provided for option '--board'. Set via --board, .fizzy.yml, or: fizzy init")
22
38
  end
23
39
 
24
40
  def client
@@ -41,10 +41,14 @@ module Fizzy
41
41
  def update(board_id)
42
42
  resp = client.put("boards/#{board_id}", body: { name: options[:name] })
43
43
  b = resp.body
44
- output_detail(b, pairs: [
45
- ["ID", b["id"]],
46
- ["Name", b["name"]]
47
- ])
44
+ if b
45
+ output_detail(b, pairs: [
46
+ ["ID", b["id"]],
47
+ ["Name", b["name"]]
48
+ ])
49
+ else
50
+ puts "Board #{board_id} updated."
51
+ end
48
52
  end
49
53
 
50
54
  desc "delete BOARD_ID", "Delete a board"
@@ -51,15 +51,15 @@ module Fizzy
51
51
  end
52
52
 
53
53
  desc "create TITLE", "Create a card"
54
- option :board, required: true, desc: "Board ID"
54
+ option :board, desc: "Board ID"
55
55
  option :body, desc: "Card body (HTML)"
56
56
  option :column, desc: "Column ID"
57
57
  def create(title)
58
- body = { title: title }
59
- body[:body] = options[:body] if options[:body]
60
- body[:column_id] = options[:column] if options[:column]
61
- resp = client.post("boards/#{options[:board]}/cards", body: body)
58
+ payload = { title: title }
59
+ payload[:description] = options[:body] if options[:body]
60
+ resp = client.post("boards/#{require_board!}/cards", body: payload)
62
61
  c = resp.body
62
+ client.post("cards/#{c["number"]}/triage", body: { column_id: options[:column] }) if options[:column]
63
63
  output_detail(c, pairs: [
64
64
  ["Number", "##{c["number"]}"],
65
65
  ["Title", c["title"]],
@@ -71,12 +71,21 @@ module Fizzy
71
71
  option :title, desc: "New title"
72
72
  option :body, desc: "New body (HTML)"
73
73
  def update(number)
74
- resp = client.put("cards/#{number}", body: build_body(:title, :body))
74
+ payload = {}
75
+ payload[:title] = options[:title] if options[:title]
76
+ payload[:description] = options[:body] if options[:body]
77
+ raise Thor::Error, "Nothing to update. Provide --title or --body" if payload.empty?
78
+
79
+ resp = client.put("cards/#{number}", body: payload)
75
80
  c = resp.body
76
- output_detail(c, pairs: [
77
- ["Number", "##{c["number"]}"],
78
- ["Title", c["title"]]
79
- ])
81
+ if c
82
+ output_detail(c, pairs: [
83
+ ["Number", "##{c["number"]}"],
84
+ ["Title", c["title"]]
85
+ ])
86
+ else
87
+ puts "Card ##{number} updated."
88
+ end
80
89
  end
81
90
 
82
91
  desc "delete NUMBER", "Delete a card"
@@ -6,18 +6,18 @@ module Fizzy
6
6
  include Base
7
7
 
8
8
  desc "list", "List columns for a board"
9
- option :board, required: true, desc: "Board ID"
9
+ option :board, desc: "Board ID"
10
10
  def list
11
- data = paginator.all("boards/#{options[:board]}/columns")
11
+ data = paginator.all("boards/#{require_board!}/columns")
12
12
  output_list(data, headers: %w[ID Name Position]) do |c|
13
13
  [c["id"], c["name"], c["position"]]
14
14
  end
15
15
  end
16
16
 
17
17
  desc "get COLUMN_ID", "Show a column"
18
- option :board, required: true, desc: "Board ID"
18
+ option :board, desc: "Board ID"
19
19
  def get(column_id)
20
- resp = client.get("boards/#{options[:board]}/columns/#{column_id}")
20
+ resp = client.get("boards/#{require_board!}/columns/#{column_id}")
21
21
  c = resp.body
22
22
  output_detail(c, pairs: [
23
23
  ["ID", c["id"]],
@@ -28,9 +28,9 @@ module Fizzy
28
28
  end
29
29
 
30
30
  desc "create NAME", "Create a column"
31
- option :board, required: true, desc: "Board ID"
31
+ option :board, desc: "Board ID"
32
32
  def create(name)
33
- resp = client.post("boards/#{options[:board]}/columns", body: { name: name })
33
+ resp = client.post("boards/#{require_board!}/columns", body: { name: name })
34
34
  c = resp.body
35
35
  output_detail(c, pairs: [
36
36
  ["ID", c["id"]],
@@ -39,21 +39,25 @@ module Fizzy
39
39
  end
40
40
 
41
41
  desc "update COLUMN_ID", "Update a column"
42
- option :board, required: true, desc: "Board ID"
42
+ option :board, desc: "Board ID"
43
43
  option :name, required: true, desc: "New column name"
44
44
  def update(column_id)
45
- resp = client.put("boards/#{options[:board]}/columns/#{column_id}", body: { name: options[:name] })
45
+ resp = client.put("boards/#{require_board!}/columns/#{column_id}", body: { name: options[:name] })
46
46
  c = resp.body
47
- output_detail(c, pairs: [
48
- ["ID", c["id"]],
49
- ["Name", c["name"]]
50
- ])
47
+ if c
48
+ output_detail(c, pairs: [
49
+ ["ID", c["id"]],
50
+ ["Name", c["name"]]
51
+ ])
52
+ else
53
+ puts "Column #{column_id} updated."
54
+ end
51
55
  end
52
56
 
53
57
  desc "delete COLUMN_ID", "Delete a column"
54
- option :board, required: true, desc: "Board ID"
58
+ option :board, desc: "Board ID"
55
59
  def delete(column_id)
56
- client.delete("boards/#{options[:board]}/columns/#{column_id}")
60
+ client.delete("boards/#{require_board!}/columns/#{column_id}")
57
61
  puts "Column #{column_id} deleted."
58
62
  end
59
63
  end
@@ -44,10 +44,14 @@ module Fizzy
44
44
  def update(comment_id)
45
45
  resp = client.put("cards/#{options[:card]}/comments/#{comment_id}", body: build_body(:body))
46
46
  c = resp.body
47
- output_detail(c, pairs: [
48
- ["ID", c["id"]],
49
- ["Body", c["body"]]
50
- ])
47
+ if c
48
+ output_detail(c, pairs: [
49
+ ["ID", c["id"]],
50
+ ["Body", c["body"]]
51
+ ])
52
+ else
53
+ puts "Comment #{comment_id} updated."
54
+ end
51
55
  end
52
56
 
53
57
  desc "delete COMMENT_ID", "Delete a comment"
@@ -34,14 +34,21 @@ module Fizzy
34
34
  option :description, desc: "New description"
35
35
  option :completed, type: :boolean, desc: "Mark completed"
36
36
  def update(step_id)
37
+ body = build_body(:description, :completed)
38
+ raise Thor::Error, "Nothing to update. Provide --description or --completed" if body.empty?
39
+
37
40
  path = "cards/#{options[:card]}/steps/#{step_id}"
38
- resp = client.put(path, body: build_body(:description, :completed))
41
+ resp = client.put(path, body: body)
39
42
  s = resp.body
40
- output_detail(s, pairs: [
41
- ["ID", s["id"]],
42
- ["Description", s["description"]],
43
- ["Completed", s["completed"]]
44
- ])
43
+ if s
44
+ output_detail(s, pairs: [
45
+ ["ID", s["id"]],
46
+ ["Description", s["description"]],
47
+ ["Completed", s["completed"]]
48
+ ])
49
+ else
50
+ puts "Step #{step_id} updated."
51
+ end
45
52
  end
46
53
 
47
54
  desc "delete STEP_ID", "Delete a step"
@@ -31,13 +31,20 @@ module Fizzy
31
31
  option :name, desc: "New name"
32
32
  option :role, desc: "New role"
33
33
  def update(user_id)
34
- resp = client.put("users/#{user_id}", body: build_body(:name, :role))
34
+ body = build_body(:name, :role)
35
+ raise Thor::Error, "Nothing to update. Provide --name or --role" if body.empty?
36
+
37
+ resp = client.put("users/#{user_id}", body: body)
35
38
  u = resp.body
36
- output_detail(u, pairs: [
37
- ["ID", u["id"]],
38
- ["Name", u["name"]],
39
- ["Role", u["role"]]
40
- ])
39
+ if u
40
+ output_detail(u, pairs: [
41
+ ["ID", u["id"]],
42
+ ["Name", u["name"]],
43
+ ["Role", u["role"]]
44
+ ])
45
+ else
46
+ puts "User #{user_id} updated."
47
+ end
41
48
  end
42
49
 
43
50
  desc "deactivate USER_ID", "Deactivate a user"
data/lib/fizzy/cli.rb CHANGED
@@ -25,6 +25,19 @@ module Fizzy
25
25
  puts "fizzy-cli #{VERSION}"
26
26
  end
27
27
 
28
+ desc "init", "Create .fizzy.yml in current directory"
29
+ def init
30
+ config_path = File.join(Dir.pwd, ProjectConfig::FILENAME)
31
+ return if File.exist?(config_path) && !yes?("#{config_path} already exists. Overwrite?")
32
+
33
+ selected = pick_account
34
+ config = { "account" => selected["account_slug"] }
35
+ config["board"] = pick_board(selected) if yes?("Set a default board?")
36
+
37
+ File.write(config_path, YAML.dump(config))
38
+ say "Wrote #{config_path}"
39
+ end
40
+
28
41
  desc "boards SUBCOMMAND ...ARGS", "Manage boards"
29
42
  subcommand "boards", CLI::Boards
30
43
 
@@ -62,5 +75,47 @@ module Fizzy
62
75
  subcommand "skill", CLI::Skill
63
76
 
64
77
  def self.exit_on_failure? = true
78
+
79
+ private
80
+
81
+ def pick_account
82
+ data = Auth.token_data
83
+ accounts = Array(data["accounts"])
84
+ raise AuthError, "No accounts found. Run: fizzy auth login --token TOKEN" if accounts.empty?
85
+
86
+ say "Available accounts:"
87
+ accounts.each_with_index do |a, i|
88
+ marker = a["account_slug"] == data["default_account"] ? " (default)" : ""
89
+ say " #{i + 1}. #{a["account_name"]} (#{a["account_slug"]})#{marker}"
90
+ end
91
+
92
+ choice = ask("Select account number [1]:").strip
93
+ choice = "1" if choice.empty?
94
+ idx = choice.to_i - 1
95
+ raise Thor::Error, "Invalid selection" unless idx >= 0 && idx < accounts.size
96
+
97
+ accounts[idx]
98
+ end
99
+
100
+ def pick_board(account)
101
+ c = Client.new(token: account["access_token"], account_slug: account["account_slug"])
102
+ boards = c.get("boards").body
103
+
104
+ if boards.empty?
105
+ say "No boards found."
106
+ return nil
107
+ end
108
+
109
+ say "Boards:"
110
+ boards.each_with_index do |b, i|
111
+ say " #{i + 1}. #{b["name"]} (#{b["id"]})"
112
+ end
113
+
114
+ board_idx = ask("Select board number:").strip.to_i - 1
115
+ return boards[board_idx]["id"] if board_idx >= 0 && board_idx < boards.size
116
+
117
+ say "Invalid selection, skipping board."
118
+ nil
119
+ end
65
120
  end
66
121
  end
data/lib/fizzy/client.rb CHANGED
@@ -81,13 +81,15 @@ module Fizzy
81
81
 
82
82
  case status
83
83
  when 200..299
84
- # Follow Location header on 201 to fetch the created resource
85
- if status == 201 && parsed_body.nil? && response["location"]
84
+ # Follow Location header to fetch the resource when body is empty
85
+ if parsed_body.nil? && response["location"]
86
86
  location = response["location"].sub(/\.json$/, "")
87
87
  return get(location)
88
88
  end
89
89
 
90
90
  Response.new(body: parsed_body, headers: response.to_hash, status: status)
91
+ when 400
92
+ raise BadRequestError.new(parse_error(response), status: 400, body: parsed_body)
91
93
  when 301, 302
92
94
  raise AuthError.new("Redirected to #{response["location"]} — endpoint may require session auth",
93
95
  status: status, body: parsed_body)
data/lib/fizzy/errors.rb CHANGED
@@ -11,6 +11,7 @@ module Fizzy
11
11
  end
12
12
  end
13
13
 
14
+ class BadRequestError < Error; end
14
15
  class AuthError < Error; end
15
16
  class NotFoundError < Error; end
16
17
  class ValidationError < Error; end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fizzy
4
+ class ProjectConfig
5
+ FILENAME = ".fizzy.yml"
6
+
7
+ attr_reader :path
8
+
9
+ def initialize(start_dir = Dir.pwd)
10
+ @path = find_config(start_dir)
11
+ data = @path ? YAML.safe_load_file(@path) : {}
12
+ @data = data.is_a?(Hash) ? data : {}
13
+ rescue Psych::SyntaxError => e
14
+ raise Thor::Error, "Bad .fizzy.yml at #{@path}: #{e.message}"
15
+ end
16
+
17
+ def found? = !@path.nil?
18
+
19
+ def account = @data["account"]
20
+
21
+ def board = @data["board"]
22
+
23
+ private
24
+
25
+ def find_config(dir)
26
+ candidate = File.join(dir, FILENAME)
27
+ return candidate if File.exist?(candidate)
28
+
29
+ parent = File.dirname(dir)
30
+ return nil if parent == dir
31
+
32
+ find_config(parent)
33
+ end
34
+ end
35
+ end
data/lib/fizzy/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fizzy
4
- VERSION = "0.3.2"
4
+ VERSION = "0.4.1"
5
5
  end
data/lib/fizzy.rb CHANGED
@@ -8,6 +8,7 @@ require "yaml"
8
8
  require_relative "fizzy/version"
9
9
  require_relative "fizzy/errors"
10
10
  require_relative "fizzy/auth"
11
+ require_relative "fizzy/project_config"
11
12
  require_relative "fizzy/client"
12
13
  require_relative "fizzy/paginator"
13
14
  require_relative "fizzy/formatter"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fizzy-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Paluy
@@ -59,6 +59,7 @@ files:
59
59
  - lib/fizzy/errors.rb
60
60
  - lib/fizzy/formatter.rb
61
61
  - lib/fizzy/paginator.rb
62
+ - lib/fizzy/project_config.rb
62
63
  - lib/fizzy/version.rb
63
64
  homepage: https://github.com/dpaluy/fizzy-cli
64
65
  licenses: