diff --git a/app/controllers/my/access_tokens_controller.rb b/app/controllers/my/access_tokens_controller.rb
index 99c87893f7..5d68a33f03 100644
--- a/app/controllers/my/access_tokens_controller.rb
+++ b/app/controllers/my/access_tokens_controller.rb
@@ -27,7 +27,7 @@ def destroy
private
def my_access_tokens
- Current.identity.access_tokens
+ Current.identity.access_tokens.personal
end
def access_token_params
diff --git a/app/controllers/my/connected_apps_controller.rb b/app/controllers/my/connected_apps_controller.rb
new file mode 100644
index 0000000000..322451db7e
--- /dev/null
+++ b/app/controllers/my/connected_apps_controller.rb
@@ -0,0 +1,28 @@
+class My::ConnectedAppsController < ApplicationController
+ before_action :set_connected_apps, only: :index
+ before_action :set_oauth_client, only: :destroy
+
+ def index
+ end
+
+ def destroy
+ @tokens.destroy_all
+
+ redirect_to my_connected_apps_path, notice: "#{@client.name} has been disconnected"
+ end
+
+ private
+ def set_connected_apps
+ tokens = oauth_tokens.includes(:oauth_client).order(:created_at)
+ @connected_apps = tokens.group_by(&:oauth_client).sort_by { |client, _| client.name.downcase }
+ end
+
+ def set_oauth_client
+ @tokens = oauth_tokens.where(oauth_client_id: params.require(:id))
+ @client = @tokens.first&.oauth_client or raise ActiveRecord::RecordNotFound
+ end
+
+ def oauth_tokens
+ Current.identity.access_tokens.oauth
+ end
+end
diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb
new file mode 100644
index 0000000000..3ddc00b43a
--- /dev/null
+++ b/app/controllers/oauth/authorizations_controller.rb
@@ -0,0 +1,125 @@
+class Oauth::AuthorizationsController < Oauth::BaseController
+ # Allow form submission to the client's redirect_uri for OAuth callbacks
+ # Only widen CSP if the client allows this redirect_uri (validated in before_action)
+ content_security_policy only: :new do |policy|
+ if (redirect_uri = params[:redirect_uri]).present? && (client_id = params[:client_id]).present?
+ client = Oauth::Client.find_by(client_id: client_id)
+ if client&.allows_redirect?(redirect_uri)
+ begin
+ uri = URI.parse(redirect_uri)
+ origin = "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port}"
+ policy.form_action :self, origin
+ rescue URI::InvalidURIError
+ # Invalid URI - don't widen CSP
+ end
+ end
+ end
+ end
+
+ before_action :save_oauth_return_url
+ before_action :require_authentication
+
+ before_action :set_client
+ before_action :validate_redirect_uri
+ before_action :validate_response_type
+ before_action :validate_pkce
+ before_action :validate_scope
+ before_action :validate_state
+
+ def new
+ @scope = params[:scope].presence || "read"
+ @redirect_uri = params[:redirect_uri]
+ @state = params[:state]
+ @code_challenge = params[:code_challenge]
+ end
+
+ def create
+ if params[:error] == "access_denied"
+ redirect_to error_redirect_uri("access_denied", "User denied the request"), allow_other_host: true
+ else
+ code = Oauth::AuthorizationCode.generate \
+ client_id: @client.client_id,
+ identity_id: Current.identity.id,
+ code_challenge: params[:code_challenge],
+ redirect_uri: params[:redirect_uri],
+ scope: params[:scope].presence || "read"
+
+ redirect_to success_redirect_uri(code), allow_other_host: true
+ end
+ end
+
+ private
+ def save_oauth_return_url
+ session[:return_to_after_authenticating] = request.url if request.get? && !authenticated?
+ end
+
+ def set_client
+ @client = Oauth::Client.find_by(client_id: params[:client_id])
+ oauth_error("invalid_request", "Unknown client") unless @client
+ end
+
+ def validate_redirect_uri
+ unless performed? || @client.allows_redirect?(params[:redirect_uri])
+ redirect_with_error "invalid_request", "Invalid redirect_uri"
+ end
+ end
+
+ def validate_response_type
+ unless performed? || params[:response_type] == "code"
+ redirect_with_error "unsupported_response_type", "Only 'code' response_type is supported"
+ end
+ end
+
+ def validate_pkce
+ unless performed? || params[:code_challenge].present?
+ redirect_with_error "invalid_request", "code_challenge is required"
+ end
+
+ unless performed? || params[:code_challenge_method] == "S256"
+ redirect_with_error "invalid_request", "code_challenge_method must be S256"
+ end
+ end
+
+ def validate_scope
+ unless performed? || @client.allows_scope?(params[:scope].presence || "read")
+ redirect_with_error "invalid_scope", "Requested scope is not allowed"
+ end
+ end
+
+ def validate_state
+ unless performed? || params[:state].present?
+ redirect_with_error "invalid_request", "state is required"
+ end
+ end
+
+ def redirect_with_error(error, description)
+ if params[:redirect_uri].present? && @client&.allows_redirect?(params[:redirect_uri])
+ redirect_to error_redirect_uri(error, description), allow_other_host: true
+ else
+ @error = error
+ @error_description = description
+ render :error, status: :bad_request
+ end
+ end
+
+ def success_redirect_uri(code)
+ build_redirect_uri params[:redirect_uri],
+ code: code,
+ state: params[:state].presence
+ end
+
+ def error_redirect_uri(error, description)
+ build_redirect_uri params[:redirect_uri],
+ error: error,
+ error_description: description,
+ state: params[:state].presence
+ end
+
+ def build_redirect_uri(base, **query_params)
+ uri = URI.parse(base)
+ query = URI.decode_www_form(uri.query || "")
+ query_params.compact.each { |k, v| query << [ k.to_s, v ] }
+ uri.query = URI.encode_www_form(query)
+ uri.to_s
+ end
+end
diff --git a/app/controllers/oauth/base_controller.rb b/app/controllers/oauth/base_controller.rb
new file mode 100644
index 0000000000..658d1e2d48
--- /dev/null
+++ b/app/controllers/oauth/base_controller.rb
@@ -0,0 +1,12 @@
+class Oauth::BaseController < ApplicationController
+ disallow_account_scope
+
+ private
+ def oauth_error(error, description = nil, status: :bad_request)
+ render json: { error: error, error_description: description }.compact, status: status
+ end
+
+ def oauth_rate_limit_exceeded
+ oauth_error "slow_down", "Too many requests", status: :too_many_requests
+ end
+end
diff --git a/app/controllers/oauth/clients_controller.rb b/app/controllers/oauth/clients_controller.rb
new file mode 100644
index 0000000000..df0c13f1a6
--- /dev/null
+++ b/app/controllers/oauth/clients_controller.rb
@@ -0,0 +1,75 @@
+class Oauth::ClientsController < Oauth::BaseController
+ allow_unauthenticated_access
+
+ rate_limit to: 10, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded
+
+ before_action :validate_redirect_uris
+ before_action :validate_loopback_uris
+ before_action :validate_auth_method
+
+ def create
+ client = Oauth::Client.create! \
+ name: params[:client_name] || "MCP Client",
+ redirect_uris: Array(params[:redirect_uris]),
+ scopes: validated_scopes,
+ dynamically_registered: true
+
+ render json: dynamic_client_registration_response(client), status: :created
+ rescue ActiveRecord::RecordInvalid => e
+ oauth_error "invalid_client_metadata", e.message
+ end
+
+ private
+ def validate_redirect_uris
+ unless performed? || params[:redirect_uris].present?
+ oauth_error "invalid_client_metadata", "redirect_uris is required"
+ end
+ end
+
+ def validate_loopback_uris
+ unless performed? || all_loopback_uris?(params[:redirect_uris])
+ oauth_error "invalid_redirect_uri", "Only loopback redirect URIs are allowed for dynamic registration"
+ end
+ end
+
+ def validate_auth_method
+ unless performed? || params[:token_endpoint_auth_method].blank? || params[:token_endpoint_auth_method] == "none"
+ oauth_error "invalid_client_metadata", "Only 'none' token_endpoint_auth_method is supported"
+ end
+ end
+
+ def all_loopback_uris?(uris)
+ uris.is_a?(Array) &&
+ uris.all? { |uri| uri.is_a?(String) && valid_loopback_uri?(uri) }
+ end
+
+ def valid_loopback_uri?(uri)
+ parsed = URI.parse(uri)
+ parsed.scheme == "http" &&
+ Oauth::LOOPBACK_HOSTS.include?(parsed.host) &&
+ parsed.fragment.nil?
+ rescue URI::InvalidURIError
+ false
+ end
+
+ def validated_scopes
+ requested = case params[:scope]
+ when String then params[:scope].split
+ when Array then params[:scope].select { |s| s.is_a?(String) }
+ else []
+ end
+ requested.select { |s| s.presence_in %w[ read write ] }.presence || %w[ read ]
+ end
+
+ def dynamic_client_registration_response(client)
+ {
+ client_id: client.client_id,
+ client_name: client.name,
+ redirect_uris: client.redirect_uris,
+ token_endpoint_auth_method: "none",
+ grant_types: %w[ authorization_code ],
+ response_types: %w[ code ],
+ scope: client.scopes.join(" ")
+ }
+ end
+end
diff --git a/app/controllers/oauth/metadata_controller.rb b/app/controllers/oauth/metadata_controller.rb
new file mode 100644
index 0000000000..9bc84ea15e
--- /dev/null
+++ b/app/controllers/oauth/metadata_controller.rb
@@ -0,0 +1,18 @@
+class Oauth::MetadataController < Oauth::BaseController
+ allow_unauthenticated_access
+
+ def show
+ render json: {
+ issuer: root_url(script_name: nil),
+ authorization_endpoint: new_oauth_authorization_url,
+ token_endpoint: oauth_token_url,
+ registration_endpoint: oauth_clients_url,
+ revocation_endpoint: oauth_revocation_url,
+ response_types_supported: %w[ code ],
+ grant_types_supported: %w[ authorization_code ],
+ token_endpoint_auth_methods_supported: %w[ none ],
+ code_challenge_methods_supported: %w[ S256 ],
+ scopes_supported: %w[ read write ]
+ }
+ end
+end
diff --git a/app/controllers/oauth/protected_resource_metadata_controller.rb b/app/controllers/oauth/protected_resource_metadata_controller.rb
new file mode 100644
index 0000000000..d7f6d4895d
--- /dev/null
+++ b/app/controllers/oauth/protected_resource_metadata_controller.rb
@@ -0,0 +1,12 @@
+class Oauth::ProtectedResourceMetadataController < Oauth::BaseController
+ allow_unauthenticated_access
+
+ def show
+ render json: {
+ resource: root_url(script_name: nil),
+ authorization_servers: [ root_url(script_name: nil) ],
+ bearer_methods_supported: %w[ header ],
+ scopes_supported: %w[ read write ]
+ }
+ end
+end
diff --git a/app/controllers/oauth/revocations_controller.rb b/app/controllers/oauth/revocations_controller.rb
new file mode 100644
index 0000000000..6dca9777d3
--- /dev/null
+++ b/app/controllers/oauth/revocations_controller.rb
@@ -0,0 +1,16 @@
+class Oauth::RevocationsController < Oauth::BaseController
+ allow_unauthenticated_access
+
+ before_action :set_access_token
+
+ def create
+ @access_token&.destroy
+
+ head :ok # Don't behave as oracle, per RFC 7009
+ end
+
+ private
+ def set_access_token
+ @access_token = Identity::AccessToken.find_by(token: params.require(:token))
+ end
+end
diff --git a/app/controllers/oauth/tokens_controller.rb b/app/controllers/oauth/tokens_controller.rb
new file mode 100644
index 0000000000..11cfefce0b
--- /dev/null
+++ b/app/controllers/oauth/tokens_controller.rb
@@ -0,0 +1,61 @@
+class Oauth::TokensController < Oauth::BaseController
+ allow_unauthenticated_access
+
+ rate_limit to: 20, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded
+
+ before_action :validate_grant_type
+ before_action :set_auth_code
+ before_action :set_client
+ before_action :validate_pkce
+ before_action :validate_redirect_uri
+ before_action :set_identity
+
+ def create
+ granted = @auth_code.scope.to_s.split
+ permission = granted.include?("write") ? "write" : "read"
+ access_token = @identity.access_tokens.create! oauth_client: @client, permission: permission
+
+ render json: {
+ access_token: access_token.token,
+ token_type: "Bearer",
+ scope: granted.join(" ")
+ }
+ end
+
+ private
+ def validate_grant_type
+ unless params[:grant_type] == "authorization_code"
+ oauth_error "unsupported_grant_type", "Only authorization_code grant is supported"
+ end
+ end
+
+ def set_auth_code
+ unless @auth_code = Oauth::AuthorizationCode.parse(params[:code])
+ oauth_error "invalid_grant", "Invalid or expired authorization code"
+ end
+ end
+
+ def set_client
+ unless @client = Oauth::Client.find_by(client_id: @auth_code.client_id)
+ oauth_error "invalid_grant", "Unknown client"
+ end
+ end
+
+ def validate_pkce
+ unless Oauth::AuthorizationCode.valid_pkce?(@auth_code, params[:code_verifier])
+ oauth_error "invalid_grant", "PKCE verification failed"
+ end
+ end
+
+ def validate_redirect_uri
+ unless @auth_code.redirect_uri == params[:redirect_uri]
+ oauth_error "invalid_grant", "redirect_uri mismatch"
+ end
+ end
+
+ def set_identity
+ unless @identity = Identity.find_by(id: @auth_code.identity_id)
+ oauth_error "invalid_grant", "Identity not found"
+ end
+ end
+end
diff --git a/app/models/identity/access_token.rb b/app/models/identity/access_token.rb
index abdf37eba8..6cd1fc3b24 100644
--- a/app/models/identity/access_token.rb
+++ b/app/models/identity/access_token.rb
@@ -1,5 +1,9 @@
class Identity::AccessToken < ApplicationRecord
belongs_to :identity
+ belongs_to :oauth_client, class_name: "Oauth::Client", optional: true
+
+ scope :personal, -> { where oauth_client_id: nil }
+ scope :oauth, -> { where.not oauth_client_id: nil }
has_secure_token
enum :permission, %w[ read write ].index_by(&:itself), default: :read
diff --git a/app/models/oauth.rb b/app/models/oauth.rb
new file mode 100644
index 0000000000..5770882e85
--- /dev/null
+++ b/app/models/oauth.rb
@@ -0,0 +1,7 @@
+module Oauth
+ LOOPBACK_HOSTS = %w[ 127.0.0.1 localhost ::1 [::1] ]
+
+ def self.table_name_prefix
+ "oauth_"
+ end
+end
diff --git a/app/models/oauth/authorization_code.rb b/app/models/oauth/authorization_code.rb
new file mode 100644
index 0000000000..32460b57f1
--- /dev/null
+++ b/app/models/oauth/authorization_code.rb
@@ -0,0 +1,38 @@
+module Oauth::AuthorizationCode
+ Details = ::Data.define(:client_id, :identity_id, :code_challenge, :redirect_uri, :scope)
+
+ class << self
+ def generate(client_id:, identity_id:, code_challenge:, redirect_uri:, scope:)
+ payload = { client_id:, identity_id:, code_challenge:, redirect_uri:, scope: }
+ encryptor.encrypt_and_sign(payload, expires_in: 60.seconds)
+ end
+
+ def parse(code)
+ if code.present? && data = encryptor.decrypt_and_verify(code)
+ Details.new \
+ client_id: data["client_id"],
+ identity_id: data["identity_id"],
+ code_challenge: data["code_challenge"],
+ redirect_uri: data["redirect_uri"],
+ scope: data["scope"]
+ end
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature
+ nil
+ end
+
+ def valid_pkce?(code_data, code_verifier)
+ code_data && code_verifier.present? &&
+ ActiveSupport::SecurityUtils.secure_compare(pkce_challenge(code_verifier), code_data.code_challenge)
+ end
+
+ private
+ def encryptor
+ @encryptor ||= ActiveSupport::MessageEncryptor.new \
+ Rails.application.key_generator.generate_key("oauth/authorization_codes", 32)
+ end
+
+ def pkce_challenge(verifier)
+ Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
+ end
+ end
+end
diff --git a/app/models/oauth/client.rb b/app/models/oauth/client.rb
new file mode 100644
index 0000000000..dd29156169
--- /dev/null
+++ b/app/models/oauth/client.rb
@@ -0,0 +1,74 @@
+class Oauth::Client < ApplicationRecord
+ has_many :access_tokens, class_name: "Identity::AccessToken"
+
+ has_secure_token :client_id, length: 32
+
+ validates :name, presence: true
+ validates :client_id, uniqueness: true, allow_nil: true
+ validates :redirect_uris, presence: true
+ validate :redirect_uris_are_valid
+
+ attribute :redirect_uris, default: -> { [] }
+ attribute :scopes, default: -> { %w[ read ] }
+
+ scope :trusted, -> { where trusted: true }
+ scope :dynamically_registered, -> { where dynamically_registered: true }
+
+
+ def loopback?
+ redirect_uris.all? { |uri| loopback_uri?(uri) }
+ end
+
+ def allows_redirect?(uri)
+ redirect_uris.include?(uri) || (loopback? && loopback_uri?(uri) && matching_loopback?(uri))
+ end
+
+ def allows_scope?(requested_scope)
+ requested = requested_scope.to_s.split
+ requested.present? && requested.all? { |s| scopes.include?(s) }
+ end
+
+ private
+ def redirect_uris_are_valid
+ redirect_uris.each { |uri| validate_redirect_uri(uri) }
+ end
+
+ def validate_redirect_uri(uri)
+ parsed = URI.parse(uri)
+
+ if parsed.fragment.present?
+ errors.add :redirect_uris, "must not contain fragments"
+ end
+
+ if dynamically_registered? && !valid_loopback_uri?(parsed)
+ errors.add :redirect_uris, "must be a local loopback URI for dynamically registered clients"
+ end
+ rescue URI::InvalidURIError
+ errors.add :redirect_uris, "includes an invalid URI"
+ end
+
+ def loopback_uri?(uri)
+ Oauth::LOOPBACK_HOSTS.include?(URI.parse(uri).host)
+ rescue URI::InvalidURIError
+ false
+ end
+
+ def valid_loopback_uri?(parsed)
+ parsed.scheme == "http" && parsed.host.in?(Oauth::LOOPBACK_HOSTS)
+ end
+
+ def matching_loopback?(uri)
+ parsed = URI.parse(uri)
+
+ redirect_uris.any? do |redirect_uri|
+ redirect = URI.parse(redirect_uri)
+
+ redirect.scheme == parsed.scheme &&
+ redirect.host.in?(Oauth::LOOPBACK_HOSTS) &&
+ parsed.host.in?(Oauth::LOOPBACK_HOSTS) &&
+ redirect.path == parsed.path
+ end
+ rescue URI::InvalidURIError
+ false
+ end
+end
diff --git a/app/views/my/connected_apps/_connected_app.html.erb b/app/views/my/connected_apps/_connected_app.html.erb
new file mode 100644
index 0000000000..33d63ce439
--- /dev/null
+++ b/app/views/my/connected_apps/_connected_app.html.erb
@@ -0,0 +1,13 @@
+
+ | <%= client.name %> |
+ <%= tokens.map { |t| t.permission.humanize }.uniq.sort.join(", ") %> |
+ <%= local_datetime_tag tokens.map(&:created_at).min, style: :datetime %> |
+
+ <%= button_to my_connected_app_path(client), method: :delete,
+ class: "btn txt-negative btn--circle txt-x-small borderless fill-transparent",
+ data: { turbo_confirm: "Disconnect #{client.name}? This will revoke all access." } do %>
+ <%= icon_tag "trash" %>
+ Disconnect this app
+ <% end %>
+ |
+
diff --git a/app/views/my/connected_apps/index.html.erb b/app/views/my/connected_apps/index.html.erb
new file mode 100644
index 0000000000..75db574ba6
--- /dev/null
+++ b/app/views/my/connected_apps/index.html.erb
@@ -0,0 +1,32 @@
+<% @page_title = "Connected apps" %>
+
+<% content_for :header do %>
+
+
+
+<% end %>
+
+
+ <% if @connected_apps.any? %>
+ Apps you've authorized to access Fizzy on your behalf.
+
+
+
+ | App |
+ Permissions |
+ Connected |
+ |
+
+
+
+ <% @connected_apps.each do |client, tokens| %>
+ <%= render "my/connected_apps/connected_app", client: client, tokens: tokens %>
+ <% end %>
+
+
+ <% else %>
+ No apps are connected to your account. When you authorize an app to access Fizzy, it will appear here.
+ <% end %>
+
diff --git a/app/views/oauth/authorizations/error.html.erb b/app/views/oauth/authorizations/error.html.erb
new file mode 100644
index 0000000000..3493d9a7c2
--- /dev/null
+++ b/app/views/oauth/authorizations/error.html.erb
@@ -0,0 +1,15 @@
+
+
+
Authorization Error
+
+
+ <%= @error_description %>
+
+
+
+ Error code: <%= @error %>
+
+
+ <%= link_to "Go back", request.referer || root_path, class: "btn btn--outline center txt-medium" %>
+
+
diff --git a/app/views/oauth/authorizations/new.html.erb b/app/views/oauth/authorizations/new.html.erb
new file mode 100644
index 0000000000..c1ce809dc8
--- /dev/null
+++ b/app/views/oauth/authorizations/new.html.erb
@@ -0,0 +1,40 @@
+<% @page_title = "Authorize #{@client.name}" %>
+
+<% content_for :header do %>
+
+<% end %>
+
+
+ <%= form_with url: oauth_authorization_path, method: :post, data: { turbo: false }, html: { class: "flex flex-column gap" } do |f| %>
+ <%= f.hidden_field :client_id, value: @client.client_id %>
+ <%= f.hidden_field :redirect_uri, value: @redirect_uri %>
+ <%= f.hidden_field :state, value: @state %>
+ <%= f.hidden_field :code_challenge, value: @code_challenge %>
+ <%= f.hidden_field :code_challenge_method, value: "S256" %>
+ <%= f.hidden_field :response_type, value: "code" %>
+
+
+
Application
+
<%= @client.name %>
+ <% if @client.dynamically_registered? %>
+
Registered by a local tool. Only authorize if you trust it.
+ <% end %>
+
+
+
+ <%= f.label :scope, "Permission" %>
+ <% scope_options = @client.scopes.include?("write") ? { "Read" => "read", "Read + Write" => "write" } : { "Read" => "read" } %>
+ <%= f.select :scope, options_for_select(scope_options, @scope), {}, class: "input input--select" %>
+
+
+
+ <%= f.button type: :submit, class: "btn btn--link txt-medium" do %>
+ Authorize
+ <% end %>
+
+ <%= f.button type: :submit, name: :error, value: "access_denied", class: "btn btn--outline txt-medium" do %>
+ Deny
+ <% end %>
+
+ <% end %>
+
diff --git a/app/views/users/_access_tokens.html.erb b/app/views/users/_access_tokens.html.erb
index 285f87149e..36c6531162 100644
--- a/app/views/users/_access_tokens.html.erb
+++ b/app/views/users/_access_tokens.html.erb
@@ -1,4 +1,4 @@
-
+
Developer
Manage <%= link_to "personal access tokens", my_access_tokens_path, class: "btn btn--plain txt-link" %> used with the Fizzy developer API.
\ No newline at end of file
diff --git a/app/views/users/_connected_apps.html.erb b/app/views/users/_connected_apps.html.erb
new file mode 100644
index 0000000000..fd9411198d
--- /dev/null
+++ b/app/views/users/_connected_apps.html.erb
@@ -0,0 +1,4 @@
+
+
Connected Apps
+
Manage <%= link_to "apps you've authorized", my_connected_apps_path, class: "btn btn--plain txt-link" %> to access your account.
+
diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb
index 7cf5bdf7c1..2f3bfeda4b 100644
--- a/app/views/users/show.html.erb
+++ b/app/views/users/show.html.erb
@@ -52,6 +52,7 @@
<%= render "users/theme" %>
<%= render "users/transfer", user: @user %>
+ <%= render "users/connected_apps" if Current.identity.access_tokens.oauth.exists? %>
<%= render "users/access_tokens" %>
<% end %>
diff --git a/cli/PLAN.md b/cli/PLAN.md
new file mode 100644
index 0000000000..36d8964016
--- /dev/null
+++ b/cli/PLAN.md
@@ -0,0 +1,1083 @@
+# Fizzy CLI (`fizzy`) - Agent-First Design
+
+**fizzy** — the Fizzy CLI — a tool designed primarily for AI coding agents while remaining intuitive for humans. The name is the product name: direct, memorable, and easy to script.
+
+> **Reference implementation**: `~/Work/basecamp/bcq` — port architecture, patterns, and test approach.
+> **Dev server**: `http://fizzy.localhost:3006` — smoke testing target.
+
+## Agent Compatibility
+
+`fizzy` is designed to work with **any AI agent that can execute shell commands**:
+
+| Agent | Integration Level | Notes |
+|-------|-------------------|-------|
+| **Claude Code** | Full | Hooks, skills, MCP (advanced features) |
+| **OpenCode** | Full | CLI + JSON output |
+| **Codex** | Full | CLI + JSON output |
+| **Any shell-capable agent** | Full | Standard CLI interface |
+
+**Philosophy**: The CLI is the universal foundation. Agent-specific enhancements (Claude Code hooks, skills, MCP) are optional layers on top. No agent is privileged over another for core functionality.
+
+---
+
+## Naming: `fizzy`
+
+
+```bash
+fizzy boards | jq '.data[0]' # Extract from envelope
+fizzy boards -q | jq '.[0]' # Quiet mode: raw data
+fizzy cards --assignee me --json # Pipeline-friendly JSON
+```
+
+---
+
+## Core Philosophy: Agent-First Design
+
+### What Would an AI Agent Want?
+
+1. **Instant orientation** — `fizzy` with no args → everything needed to start
+2. **Predictable patterns** — Learn once, apply everywhere
+3. **Rich context** — Equivalent to web UI, in agent-digestible form
+4. **Token efficiency** — Dense output, no fluff
+5. **Breadcrumbs** — "What can I do next?" after every action
+6. **Error recovery** — Errors that help fix the problem
+7. **ID/URL-based writes** — Unambiguous, explicit operations
+
+### Output Philosophy
+
+| Context | Format | Reason |
+|---------|--------|--------|
+| Piped / scripted | JSON envelope | Structured, with context and breadcrumbs |
+| TTY / interactive | Markdown | Rich, readable, scannable |
+| `--json` | JSON envelope | Force JSON output |
+| `--md` | Markdown | Force Markdown output |
+| `--quiet` / `--data` | Raw data only | Just `.data`, no envelope |
+
+### JSON Output Contract
+
+**Default (envelope):**
+```bash
+fizzy boards # Returns envelope with data, summary, breadcrumbs
+fizzy boards --json # Forces JSON envelope even in TTY
+```
+
+**Quiet mode (data-only):**
+```bash
+fizzy boards --quiet # Returns raw data array, no envelope
+fizzy boards -q # Same as --quiet
+fizzy boards --data # Alias for --quiet
+```
+
+**Piping examples:**
+```bash
+fizzy boards | jq '.data[0]' # Extract from envelope
+fizzy boards --quiet | jq '.[0]' # Direct array access
+fizzy boards -q | jq '.[] | .name' # Iterate raw data
+```
+
+---
+
+## S-Tier Outcome (Bar)
+
+- Exhaustive coverage of public Fizzy API endpoints with parity checks
+- Zero ambiguity on writes (IDs/URLs only) with clear, corrective errors
+- High-confidence tests: happy paths, error paths, and output formats
+- Agent ergonomics: consistent verbs, rich JSON envelopes, breadcrumbs
+- Fast by default with resilient auth, retries, and rate-limit handling
+
+### Quality Gates
+
+Every task must pass before commit:
+1. **Unit tests pass** — `bats test/*.bats`
+2. **Smoke test pass** — Manual verification against `http://fizzy.localhost:3006`
+3. **No regressions** — Existing tests continue to pass
+
+Atomic commits: one logical change per commit, tests green before moving on.
+
+## Layered Configuration
+
+Configuration builds up from global → local, like git config:
+
+```
+~/.config/fizzy/
+├── config.json # Global defaults (all repos)
+├── credentials.json # OAuth tokens
+├── client.json # DCR client registration
+└── accounts.json # Discovered accounts
+
+.fizzy/ # Per-directory/repo configuration
+├── config.json # Local overrides
+└── cache/ # Local cache (optional)
+```
+
+### Config Hierarchy
+
+```
+Global (~/.config/fizzy/config.json)
+ └─ Local (.fizzy/config.json)
+ └─ Environment variables
+ └─ Command-line flags
+```
+
+Each layer overrides the previous.
+
+### Configuration Options
+
+```json
+// ~/.config/fizzy/config.json (global)
+{
+ "default_account_id": 12345,
+ "output_format": "auto", // auto | json | markdown
+ "color": true,
+ "pager": "less -R"
+}
+
+// .fizzy/config.json (per-directory)
+{
+ "board_id": 67890,
+ "board_name": "Launch Board", // For display
+ "column_id": 11111, // Default column
+ "column_name": "In Progress",
+ "team": ["@jeremy", "@david"] // Quick @-mention completion
+}
+```
+
+### Config Commands
+
+```bash
+fizzy config # Show effective config
+fizzy config --global # Show global only
+fizzy config --local # Show local only
+
+fizzy config init # Interactive: create .fizzy/config.json
+fizzy config set board 67890 # Set locally
+fizzy config set board 67890 --global # Set globally
+fizzy config unset board # Remove local override
+
+fizzy config board # Interactive board picker
+fizzy config column # Interactive column picker
+```
+
+---
+
+## Quick-Start Mode (No Arguments)
+
+```
+$ fizzy
+
+fizzy v0.1.0 — Fizzy CLI
+Auth: ✓ dev@example.com @ Acme
+
+QUICK START
+ fizzy boards List boards
+ fizzy cards Your assigned cards
+ fizzy search "query" Find anything
+
+COMMON TASKS
+ fizzy card "title" Create card
+ fizzy triage
--to Triage card to column
+ fizzy close Close card
+ fizzy comment "text" --on Add comment
+
+CONTEXT
+ Account: Acme (897362094)
+ Board: Fizzy 4 (67890) ← from .fizzy/config.json
+ Column: Development (11111)
+
+NEXT: fizzy cards
+```
+
+### Fast Mode (No State Fetching)
+
+Quick-start can optionally fetch live counts (cards assigned, unread messages, etc.), but this requires API calls and can be slow for agents:
+
+```bash
+fizzy # Default: no API calls, just orientation
+fizzy --state # Include live counts (requires API calls)
+fizzy --fast # Explicit: no state fetching (same as default)
+```
+
+The default prioritizes speed for agents. Use `--state` when you want live statistics.
+
+---
+
+## Response Structure
+
+### Universal Envelope (JSON)
+
+```json
+{
+ "ok": true,
+ "data": [ ... ],
+ "summary": "47 cards assigned to you",
+ "context": {
+ "account": {"id": 12345, "name": "Acme"},
+ "board": {"id": 67890, "name": "Fizzy 4"},
+ "column": {"id": 11111, "name": "Development"}
+ },
+ "breadcrumbs": [
+ {"action": "create", "cmd": "fizzy card \"content\""},
+ {"action": "filter", "cmd": "fizzy cards --status closed"},
+ {"action": "search", "cmd": "fizzy search \"query\""}
+ ],
+ "meta": {
+ "total": 47,
+ "showing": 25,
+ "page": 1,
+ "next": "fizzy cards --page 2"
+ }
+}
+```
+
+### Markdown Mode
+
+```markdown
+## Your Cards (47)
+
+| # | Content | Due | Assignee |
+|---|---------|-----|----------|
+| 123 | Fix login bug | Jan 15 | @jeremy |
+| 124 | Update docs | Jan 20 | @david |
+
+*Showing 25 of 47* — `fizzy cards --page 2` for more
+
+### Actions
+- Create: `fizzy card "content"`
+- Close: `fizzy close `
+- Filter: `fizzy cards --status closed`
+```
+
+---
+
+## Commands
+
+### Query Commands (Read)
+
+```bash
+fizzy # Quick-start
+fizzy boards # List boards
+fizzy cards # Assigned cards (uses context)
+fizzy cards --all # All cards in board
+fizzy cards --in "Fizzy 4" # In specific board
+fizzy cards --column "In Progress" # In specific column
+fizzy search "query" # Global search
+fizzy show card 123 # Full card details
+fizzy show board "Fizzy 4" # Full board details
+fizzy columns --in "Fizzy 4" # List columns
+fizzy people # List people
+```
+
+### Action Commands (Write)
+
+**Write commands require explicit IDs or full Fizzy URLs. No name resolution for writes.**
+
+```bash
+fizzy card "Fix the login bug" # Create (uses context board/column)
+fizzy card "Fix bug" --board 67890 # Create with explicit board ID
+fizzy card "Fix bug" --board 67890 --column 11111 # Create with explicit column ID
+fizzy close 123 # Close card by number
+fizzy close 123 124 125 # Close multiple
+fizzy reopen 123 # Reopen by number
+fizzy triage 123 --to 456 # Triage to column ID
+fizzy postpone 123 # Move to "Not Now"
+fizzy comment "LGTM" --on 123 # Add comment by card number
+fizzy assign 123 --to 456 # Toggle assignment by person ID
+fizzy gild 123 # Mark as golden
+```
+
+**Why ID-only for writes?**
+- Prevents accidental writes to wrong resources
+- Eliminates ambiguity errors during write operations
+- Name resolution can fail; writes should be deterministic
+- Agents should resolve names in read operations, then use IDs for writes
+
+**Fizzy URLs work too:**
+```bash
+fizzy close http://fizzy.localhost:3006/1234567/boards/67890/cards/123
+```
+
+### Utility Commands
+
+```bash
+fizzy config # Show/set configuration
+fizzy auth login # OAuth flow (browser)
+fizzy auth login --no-browser # OAuth flow (manual code entry)
+fizzy auth logout # Clear credentials
+fizzy auth status # Auth info
+fizzy version # Version info
+```
+
+### Global Flags
+
+```bash
+--json, -j # Force JSON envelope output
+--md, -m # Force Markdown output
+--quiet, -q # Raw data only (no envelope)
+--data # Alias for --quiet
+--verbose, -v # Debug output
+--board, -b ID # Override board context
+--account, -a ID # Override account
+--help, -h # Help
+```
+
+---
+
+## Command → API Mapping
+
+### API URL Structure
+
+All API calls are scoped to an **account slug** (numeric external_account_id):
+
+```
+http://fizzy.localhost:3006/{account_slug}/boards
+http://fizzy.localhost:3006/{account_slug}/cards
+http://fizzy.localhost:3006/897362094/cards/123
+```
+
+The account slug is a 7+ digit numeric ID (e.g., `897362094`).
+
+### Query Commands → API Endpoints
+
+| Command | API Endpoint | Notes |
+|---------|--------------|-------|
+| `fizzy boards` | `GET /:slug/boards` | List accessible boards |
+| `fizzy show board ` | `GET /:slug/boards/:id` | Board details |
+| `fizzy columns --board ` | `GET /:slug/boards/:id/columns` | List columns |
+| `fizzy cards` | `GET /:slug/cards` | List cards (with filters) |
+| `fizzy cards --search "q"` | `GET /:slug/cards?terms[]=q` | Search via `terms[]` param |
+| `fizzy show card ` | `GET /:slug/cards/:num` | Card details (includes steps) |
+| `fizzy comments --on ` | `GET /:slug/cards/:num/comments` | List comments |
+| `fizzy people` | `GET /:slug/users` | List users |
+| `fizzy tags` | `GET /:slug/tags` | List tags |
+| `fizzy notifications` | `GET /:slug/notifications` | List notifications |
+
+### Action Commands → API Endpoints
+
+| Command | API Endpoint | HTTP | Notes |
+|---------|--------------|------|-------|
+| `fizzy card "title"` | `/:slug/boards/:bid/cards` | POST | Create card |
+| `fizzy close ` | `/:slug/cards/:num/closure` | POST | Close card |
+| `fizzy reopen ` | `/:slug/cards/:num/closure` | DELETE | Reopen card |
+| `fizzy triage --to ` | `/:slug/cards/:num/triage` | POST | Move to column (`column_id` param) |
+| `fizzy untriage ` | `/:slug/cards/:num/triage` | DELETE | Send back to triage |
+| `fizzy postpone ` | `/:slug/cards/:num/not_now` | POST | Move to "Not Now" |
+| `fizzy comment "text" --on ` | `/:slug/cards/:num/comments` | POST | Add comment |
+| `fizzy assign --to ` | `/:slug/cards/:num/assignments` | POST | Toggle assignment |
+| `fizzy tag --with "name"` | `/:slug/cards/:num/taggings` | POST | Toggle tag |
+| `fizzy watch ` | `/:slug/cards/:num/watch` | POST | Subscribe |
+| `fizzy unwatch ` | `/:slug/cards/:num/watch` | DELETE | Unsubscribe |
+| `fizzy gild ` | `/:slug/cards/:num/goldness` | POST | Mark golden |
+| `fizzy ungild ` | `/:slug/cards/:num/goldness` | DELETE | Remove golden |
+| `fizzy step "text" --on ` | `/:slug/cards/:num/steps` | POST | Add step |
+| `fizzy react "👍" --on ` | `/:slug/cards/:num/comments/:cid/reactions` | POST | Add reaction |
+
+### Notification Commands → API Endpoints
+
+| Command | API Endpoint | HTTP | Notes |
+|---------|--------------|------|-------|
+| `fizzy notifications` | `/:slug/notifications` | GET | List notifications |
+| `fizzy notifications read ` | `/:slug/notifications/:id/reading` | POST | Mark as read |
+| `fizzy notifications unread ` | `/:slug/notifications/:id/reading` | DELETE | Mark as unread |
+| `fizzy notifications read --all` | `/:slug/notifications/bulk_reading` | POST | Mark all as read |
+
+### Card Query Parameters (Filters)
+
+The `GET /:slug/cards` endpoint supports rich filtering:
+
+| Filter | Parameter | Example |
+|--------|-----------|---------|
+| By board | `board_ids[]` | `?board_ids[]=abc123` |
+| By tag | `tag_ids[]` | `?tag_ids[]=xyz789` |
+| By assignee | `assignee_ids[]` | `?assignee_ids[]=user1` |
+| By creator | `creator_ids[]` | `?creator_ids[]=user2` |
+| By status | `indexed_by` | `?indexed_by=closed` (values: `all`, `closed`, `not_now`, `stalled`, `postponing_soon`, `golden`) |
+| By search | `terms[]` | `?terms[]=bug&terms[]=login` |
+| Sort | `sorted_by` | `?sorted_by=newest` (values: `latest`, `newest`, `oldest`) |
+| Assignment | `assignment_status` | `?assignment_status=unassigned` |
+| Created date | `creation` | `?creation=thisweek` |
+| Closed date | `closure` | `?closure=today` |
+
+### Important: Card Numbers vs IDs
+
+Cards have both:
+- **`number`**: Sequential per-account (e.g., `1`, `2`, `123`) — used in URLs and display
+- **`id`**: UUID (e.g., `03f5vaeq985jlvwv3arl4srq2`) — internal identifier
+
+API endpoints use **card number** in URLs: `GET /:slug/cards/123` (not the UUID).
+
+---
+
+## Authentication
+
+### OAuth 2.1 with Dynamic Client Registration
+
+Fizzy implements **full OAuth 2.1** (verified in `config/routes.rb` and `app/controllers/oauth/`):
+
+| Feature | Endpoint | Notes |
+|---------|----------|-------|
+| **Discovery** | `GET /.well-known/oauth-authorization-server` | RFC 8414 metadata |
+| **DCR** | `POST /oauth/clients` | Dynamic client registration |
+| **Authorization** | `GET /oauth/authorization/new` | Authorization code flow |
+| **Token** | `POST /oauth/token` | Token exchange |
+| **Revocation** | `POST /oauth/revocation` | Token revocation |
+
+**OAuth Metadata** (from `Oauth::MetadataController`):
+```json
+{
+ "issuer": "http://fizzy.localhost:3006",
+ "authorization_endpoint": "http://fizzy.localhost:3006/oauth/authorization/new",
+ "token_endpoint": "http://fizzy.localhost:3006/oauth/token",
+ "registration_endpoint": "http://fizzy.localhost:3006/oauth/clients",
+ "revocation_endpoint": "http://fizzy.localhost:3006/oauth/revocation",
+ "response_types_supported": ["code"],
+ "grant_types_supported": ["authorization_code"],
+ "token_endpoint_auth_methods_supported": ["none"],
+ "code_challenge_methods_supported": ["S256"],
+ "scopes_supported": ["read", "write"]
+}
+```
+
+**Key points**:
+- Public clients only (`token_endpoint_auth_methods_supported: ["none"]`)
+- PKCE required (`S256`)
+- Two scopes: `read` (read-only), `write` (read+write)
+
+### Auth Flow
+
+```bash
+# Standard flow (opens browser)
+fizzy auth login
+
+# Headless/remote flow (manual code entry)
+fizzy auth login --no-browser
+# Prints: Visit http://fizzy.localhost:3006/oauth/authorization/new?...
+# Prints: Enter authorization code: [user pastes code]
+```
+
+### Alternative: Personal Access Tokens
+
+For simple scripts, users can generate tokens manually:
+1. Go to profile → API section → Personal access tokens
+2. Generate token with `read` or `read+write` scope
+3. Use via `FIZZY_TOKEN` environment variable
+
+### Token File Security
+
+Credentials stored at `~/.config/fizzy/credentials.json` with permissions `0600` (owner read/write only).
+
+### Account Resolution
+
+Account ID is required for all API calls. Resolution order:
+
+1. `--account` flag
+2. `FIZZY_ACCOUNT_ID` environment variable
+3. `.fizzy/config.json` → `account_id`
+4. `~/.config/fizzy/config.json` → `account_id`
+5. Auto-discovered from token (if only one account)
+
+**Fail fast with clear hints:**
+```json
+{
+ "ok": false,
+ "error": "No account configured",
+ "code": "auth_required",
+ "hint": "Run: fizzy auth login",
+ "accounts": [
+ {"id": 12345, "name": "37signals"},
+ {"id": 67890, "name": "Side Board"}
+ ]
+}
+```
+
+---
+
+## Name Resolution (Read Commands Only)
+
+**Names are supported for read operations; write operations require IDs.**
+
+```bash
+# Read commands support names:
+fizzy cards --in "Fizzy 4" # By name
+fizzy cards --in 67890 # By ID
+fizzy show board "Fizzy 4" # By name
+fizzy people --search "@david" # By @handle
+
+# Write commands require IDs:
+fizzy close 123 # ID required
+fizzy comment "Done!" --on 123 # ID required
+fizzy assign 123 --to 456 # IDs required
+```
+
+### Workflow: Resolve Then Act
+
+```bash
+# Step 1: Find the resource (read, supports names)
+fizzy show board "Fizzy 4" --quiet | jq '.id'
+# → 67890
+
+# Step 2: Act on it (write, requires ID)
+fizzy card "Fix bug" --board 67890
+```
+
+### Ambiguous Name Handling
+
+When a name matches multiple resources:
+```json
+{
+ "ok": false,
+ "error": "Ambiguous board name",
+ "code": "ambiguous",
+ "matches": [
+ {"id": 67890, "name": "Fizzy 4"},
+ {"id": 11111, "name": "Fizzy Classic"}
+ ],
+ "hint": "Use --board 67890 or more specific name"
+}
+```
+
+---
+
+## Rich Detail Views
+
+```bash
+$ fizzy show card 123 --md
+```
+
+```markdown
+## Card #123: Fix the login bug
+
+| Field | Value |
+|-------|-------|
+| Board | Fizzy 4 > Development |
+| Status | Active |
+| Assignee | @jeremy |
+| Due | January 15, 2024 (in 5 days) |
+| Created | January 10 by @david |
+
+### Description
+Login form throws 500 when email contains "+".
+
+### Comments (2)
+| When | Who | Comment |
+|------|-----|---------|
+| Jan 11 | @jeremy | On it, fix by EOD. |
+| Jan 10 | @david | Can you look at this? |
+
+### Actions
+- `fizzy close 123` — Close
+- `fizzy comment "text" --on 123` — Comment
+- `fizzy assign 123 --to @name` — Reassign
+```
+
+---
+
+## Error Design
+
+### Helpful, Actionable Errors
+
+```bash
+$ fizzy show card 99999
+```
+
+```json
+{
+ "ok": false,
+ "error": "Card not found",
+ "code": "not_found",
+ "searched": {"type": "card", "id": 99999},
+ "suggestions": [
+ {"id": 999, "content": "Fix header", "board": "Fizzy 4"},
+ {"id": 9999, "content": "Update docs", "board": "Marketing"}
+ ],
+ "hint": "Try: fizzy search \"your keywords\""
+}
+```
+
+### Error Codes
+
+| Code | Exit | Meaning |
+|------|------|---------|
+| `success` | 0 | OK |
+| `usage` | 1 | Bad arguments |
+| `not_found` | 2 | Resource not found |
+| `auth_required` | 3 | Need to login |
+| `forbidden` | 4 | Permission denied |
+| `rate_limit` | 5 | Too many requests |
+| `network` | 6 | Connection failed |
+| `api_error` | 7 | Server error |
+| `ambiguous` | 8 | Multiple matches for name |
+
+---
+
+## Architecture
+
+```
+fizzy/cli/
+├── bin/
+│ └── fizzy # Entry point (~130 lines)
+├── lib/
+│ ├── core.sh # Output, formatting, utilities
+│ ├── config.sh # 7-layer config management
+│ ├── api.sh # HTTP client, caching, retries
+│ ├── auth.sh # OAuth 2.1 + DCR + PKCE
+│ ├── names.sh # Name → ID resolution
+│ └── commands/
+│ ├── quick_start.sh # No-args orientation
+│ ├── boards.sh # boards, show board
+│ ├── cards.sh # cards, show card, card (create)
+│ ├── actions.sh # close, reopen, move, assign, etc.
+│ ├── comments.sh # comment, react
+│ ├── search.sh # search
+│ ├── people.sh # people
+│ ├── config.sh # config management
+│ └── auth.sh # auth login/logout/status
+├── test/
+│ ├── run.sh # Test runner
+│ ├── helpers.sh # Test utilities & assertions
+│ ├── fixtures/ # Mock API responses
+│ └── *.bats # bats-core test files
+├── completions/
+│ ├── fizzy.bash
+│ └── fizzy.zsh
+└── docs/
+ └── README.md
+```
+
+### Key bcq Patterns to Port
+
+These patterns from bcq make it "S-Tier" — port them to fizzy:
+
+#### 1. OAuth 2.1 Flow (`lib/auth.sh`)
+```
+_discover_oauth_config() → Fetch /.well-known/oauth-authorization-server
+_register_client() → DCR to get client_id (stored in client.json)
+_authorize() → PKCE flow with code_verifier/challenge
+_exchange_code() → Token exchange, store in credentials.json
+ensure_auth() → Auto-refresh expired tokens
+```
+
+#### 2. 7-Layer Config (`lib/config.sh`)
+```
+1. /etc/fizzy/config.json (system-wide)
+2. ~/.config/fizzy/config.json (user global)
+3. /.fizzy/config.json (repo-level)
+4. .fizzy/config.json (current dir, walks up)
+5. Environment: FIZZY_*
+6. Flags: --account, --board
+7. Global flags parsed early
+```
+
+#### 3. HTTP Client (`lib/api.sh`)
+```
+api_get(), api_post(), api_put(), api_delete()
+├── ETag-based caching (If-None-Match → 304)
+├── Exponential backoff (1s → 2s → 4s → 8s → 16s)
+├── Rate limit handling (429 + Retry-After)
+├── Token refresh on 401
+└── Semantic error mapping (curl codes → exit codes)
+```
+
+#### 4. Output System (`lib/core.sh`)
+```
+output(data, summary, breadcrumbs, md_renderer)
+├── Auto-detect TTY vs pipe
+├── JSON envelope with context
+├── Markdown for humans
+└── --quiet for raw data
+```
+
+#### 5. Test Infrastructure (`test/`)
+```
+bats-core with:
+├── setup/teardown (temp dirs, isolated home)
+├── Assertions (assert_success, assert_json_value)
+├── Fixtures (mock API responses)
+└── Helpers (create_credentials, init_git_repo)
+```
+
+---
+
+## Implementation Phases
+
+Each phase must have passing tests before moving to the next.
+
+### Phase 1: Foundation (Core Infrastructure)
+
+**Goal**: Skeleton that can authenticate and make API calls.
+
+- [ ] `bin/fizzy` — Entry point with command dispatch
+- [ ] `lib/core.sh` — `output()`, `die()`, format detection
+- [ ] `lib/config.sh` — 7-layer config loading
+- [ ] `lib/api.sh` — `api_get()`, `api_post()`, caching, retries
+- [ ] `lib/auth.sh` — OAuth 2.1 discovery, DCR, PKCE, token management
+- [ ] `cmd_auth()` — login, logout, status subcommands
+- [ ] Quick-start mode (no args → orientation)
+
+**Tests**: `test/auth.bats`, `test/config.bats`, `test/core.bats`
+**Smoke test**: `fizzy auth login` against dev server
+
+### Phase 2: Core Queries
+
+**Goal**: Read operations for boards, cards, users.
+
+- [ ] `fizzy boards` — List boards
+- [ ] `fizzy show board ` — Board details
+- [ ] `fizzy columns --board ` — List columns
+- [ ] `fizzy cards` — List with filters
+- [ ] `fizzy show card ` — Card details with steps/comments
+- [ ] `fizzy people` — List users
+- [ ] `fizzy tags` — List tags
+
+**Tests**: `test/boards.bats`, `test/cards.bats`, `test/people.bats`
+**Smoke test**: All queries return valid JSON against dev server
+
+### Phase 3: Core Actions
+
+**Goal**: Write operations for cards.
+
+- [ ] `fizzy card "title"` — Create card
+- [ ] `fizzy close ` — Close card
+- [ ] `fizzy reopen ` — Reopen card
+- [ ] `fizzy triage --to ` — Triage to column
+- [ ] `fizzy untriage ` — Send back to triage
+- [ ] `fizzy postpone ` — Move to Not Now
+- [ ] `fizzy comment "text" --on ` — Add comment
+- [ ] `fizzy assign --to ` — Toggle assignment
+- [ ] `fizzy tag --with "name"` — Toggle tag
+- [ ] `fizzy watch ` / `unwatch` — Toggle subscription
+- [ ] `fizzy gild ` / `ungild` — Toggle golden
+- [ ] `fizzy step "text" --on ` — Add step
+- [ ] `fizzy notifications` — List notifications
+- [ ] `fizzy notifications read ` — Mark as read
+- [ ] `fizzy notifications read --all` — Mark all as read
+
+**Tests**: `test/actions.bats`, `test/comments.bats`, `test/notifications.bats`
+**Smoke test**: Create → triage → close → reopen → comment flow
+
+### Phase 4: Agent Ergonomics
+
+**Goal**: Make it delightful for AI agents.
+
+- [ ] Name resolution for read commands (boards, people)
+- [ ] Breadcrumb generation in JSON envelope
+- [ ] Error suggestions (similar cards, hint text)
+- [ ] `fizzy config` — show/set/init
+- [ ] Pagination handling
+
+**Tests**: `test/names.bats`, `test/errors.bats`
+**Smoke test**: Name resolution works, errors are helpful
+
+### Phase 5: Polish
+
+**Goal**: Production-ready.
+
+- [ ] Tab completion (bash, zsh)
+- [ ] Rate limiting + retry (429 handling)
+- [ ] Comprehensive test coverage
+- [ ] README documentation
+- [ ] MCP server mode (`fizzy mcp serve`) — optional
+
+---
+
+## Test Requirements
+
+### Test Categories
+
+Every command must have:
+
+1. **Happy path test** — Normal usage works
+2. **Error path tests** — Proper error codes and messages
+3. **Output format tests** — JSON and Markdown both correct
+4. **Context tests** — Respects config hierarchy
+
+### Test Infrastructure (from bcq)
+
+```bash
+# test/run.sh — Test runner
+#!/usr/bin/env bash
+bats test/*.bats
+
+# test/helpers.sh — Shared test utilities
+setup() {
+ TEST_DIR="$(mktemp -d)"
+ export HOME="$TEST_DIR/home"
+ export XDG_CONFIG_HOME="$HOME/.config"
+ mkdir -p "$XDG_CONFIG_HOME/fizzy"
+}
+
+teardown() {
+ rm -rf "$TEST_DIR"
+}
+
+# Custom assertions
+assert_json_value() {
+ local path="$1" expected="$2"
+ actual=$(echo "$output" | jq -r "$path")
+ [[ "$actual" == "$expected" ]] || fail "Expected $path to be '$expected', got '$actual'"
+}
+
+assert_exit_code() {
+ [[ "$status" -eq "$1" ]] || fail "Expected exit code $1, got $status"
+}
+```
+
+### Example Tests
+
+```bash
+# test/cards.bats
+
+load helpers
+
+@test "fizzy cards returns JSON envelope when piped" {
+ run bash -c 'fizzy cards | jq -r .ok'
+ assert_success
+ assert_output 'true'
+}
+
+@test "fizzy cards returns raw array with --quiet" {
+ run bash -c 'fizzy cards --quiet | jq -r type'
+ assert_success
+ assert_output 'array'
+}
+
+@test "fizzy cards respects .fizzy/config.json board" {
+ mkdir -p .fizzy
+ echo '{"board_id": "abc123"}' > .fizzy/config.json
+ run fizzy cards --json
+ assert_success
+ assert_json_value '.context.board.id' 'abc123'
+}
+
+@test "fizzy card requires title" {
+ run fizzy card
+ assert_failure
+ assert_exit_code 1
+ assert_json_value '.code' 'usage'
+}
+
+@test "fizzy close requires card number" {
+ run fizzy close
+ assert_failure
+ assert_exit_code 1
+}
+
+@test "fizzy auth status shows logged out when no credentials" {
+ run fizzy auth status --json
+ assert_success
+ assert_json_value '.data.authenticated' 'false'
+}
+```
+
+### Running Tests
+
+```bash
+# Run all tests
+./test/run.sh
+
+# Run specific test file
+bats test/auth.bats
+
+# Run with verbose output
+bats --verbose-run test/cards.bats
+
+# Run single test by name
+bats test/cards.bats --filter "returns JSON envelope"
+```
+
+---
+
+## Success Criteria
+
+### Universal (All Agents)
+1. **Agent orients in 1 command**: `fizzy` → knows what to do
+2. **Task completion in 1-2 commands**: Create card, close card
+3. **Error recovery**: Errors suggest fixes with actionable hints
+4. **Web UI parity**: Detail views show everything humans see
+5. **Token efficiency**: Dense output, no fluff
+6. **Breadcrumbs**: Every response guides next action
+7. **Config layering**: Global + local settings work correctly
+
+### Agent-Specific Enhancements (Optional)
+8. **Claude Code**: Hooks for commit linking, `/fizzy` skill
+9. **MCP**: Structured tool definitions for advanced agents
+10. **Future agents**: Extensible design accommodates new capabilities
+
+---
+
+## Interoperability Design
+
+### Universal Interface (Required)
+Every agent gets these capabilities via standard CLI:
+- **JSON output**: `fizzy cards --json` → parseable data
+- **Exit codes**: Semantic success/failure
+- **Error format**: JSON errors with `code`, `hint`
+- **Help text**: `fizzy --help`, `fizzy --help`
+- **Piping**: Works with jq, grep, xargs, etc.
+
+### Claude Code Enhancements (Optional)
+For Claude Code users who want deeper integration:
+- **Hooks**: Auto-link commits to Fizzy cards
+- **Skills**: `/fizzy` slash command with context awareness
+- **MCP server**: Structured tools with rich schemas
+
+### OpenCode / Codex / Others
+These agents use the same CLI interface. As they add features (hooks, plugins, MCP), we can add support. The foundation is agent-agnostic.
+
+---
+
+## MCP Server: `fizzy mcp serve`
+
+When MCP integration is compelling and delightful, `fizzy` itself can serve as an MCP server:
+
+```bash
+# Start MCP server on stdio (for local agents)
+fizzy mcp serve
+
+# Start MCP server on port (for remote agents)
+fizzy mcp serve --port 8080
+
+# Configure in .mcp.json
+{
+ "fizzy": {
+ "command": "fizzy",
+ "args": ["mcp", "serve"]
+ }
+}
+```
+
+**Why built-in?**
+- Single tool to install and maintain
+- CLI and MCP share the same code, auth, config
+- No separate MCP server package to manage
+- `fizzy` commands become MCP tools automatically
+
+### Command Schema (Source of Truth)
+
+Commands are defined in `lib/schema.json`, which drives:
+1. CLI argument parsing and validation
+2. CLI help text generation
+3. MCP tool definitions
+
+```json
+// lib/schema.json
+{
+ "commands": {
+ "cards": {
+ "description": "List cards",
+ "category": "query",
+ "args": {
+ "board": {
+ "type": "integer",
+ "aliases": ["-b", "--board", "--in"],
+ "description": "Board ID"
+ },
+ "assignee": {
+ "type": "string",
+ "aliases": ["-a", "--assignee"],
+ "description": "Filter by assignee (ID, @handle, or 'me')"
+ },
+ "status": {
+ "type": "string",
+ "enum": ["active", "closed"],
+ "aliases": ["-s", "--status"],
+ "description": "Filter by status"
+ }
+ }
+ },
+ "close": {
+ "description": "Close card(s)",
+ "category": "action",
+ "args": {
+ "id": {
+ "type": "integer",
+ "required": true,
+ "positional": true,
+ "variadic": true,
+ "description": "Card ID(s) to close"
+ }
+ }
+ }
+ }
+}
+```
+
+### MCP Output Mode
+
+In MCP mode, all responses use the JSON envelope format:
+
+```bash
+fizzy mcp serve # Forces --json for all tool responses
+```
+
+**When to use MCP vs CLI?**
+| Scenario | CLI | MCP |
+|----------|-----|-----|
+| Quick queries | ✓ | |
+| Piping/scripting | ✓ | |
+| Structured tool calling | | ✓ |
+| Rich type schemas | | ✓ |
+| Resource subscriptions | | ✓ |
+| Real-time updates | | ✓ |
+
+The CLI remains the foundation. `fizzy mcp serve` is an adapter for MCP-native agents.
+
+---
+
+## Open Questions
+
+Decisions to finalize before/during implementation:
+
+### 1. Card Identifiers
+
+Cards have both `number` (sequential, human-friendly) and `id` (UUID). The API uses `number` in URLs.
+
+**Recommendation**: Use card numbers everywhere in CLI (matches web UI and API URLs).
+
+### 2. Verb Naming
+
+**DECIDED**: Use Fizzy's ubiquitous language (from domain models):
+
+| CLI Command | Model Method | API Endpoint |
+|-------------|--------------|--------------|
+| `close` | `card.close` | POST /closure |
+| `reopen` | `card.reopen` | DELETE /closure |
+| `triage` | `card.triage_into(column)` | POST /triage |
+| `postpone` | `card.postpone` | POST /not_now |
+| `gild` | `card.gild` | POST /goldness |
+| `ungild` | — | DELETE /goldness |
+
+Source: `app/models/card/closeable.rb`, `triageable.rb`, `postponable.rb`, `golden.rb`
+
+### 3. Search Interface
+
+Options:
+- `fizzy search "query"` — Dedicated search command
+- `fizzy cards --search "query"` — Filter on cards command
+- Both?
+
+**Recommendation**: Support both. Search is common enough to warrant its own command.
+
+### 4. Identity Endpoint
+
+`GET /my/identity` returns accounts list. Use this for:
+- Account discovery after OAuth
+- Populating `accounts.json`
+- `fizzy auth status` output
+
+### 5. Notifications
+
+**DECIDED**: Include in scope. Full API coverage is the goal.
+
+Commands:
+- `fizzy notifications` — List notifications
+- `fizzy notifications read ` — Mark as read
+- `fizzy notifications read --all` — Mark all as read
+
+---
+
+## References
+
+- **Fizzy API docs**: `docs/API.md`
+- **bcq reference**: `~/Work/basecamp/bcq/`
+- **Dev server**: `http://fizzy.localhost:3006`
+- **OAuth metadata**: `GET /.well-known/oauth-authorization-server`
diff --git a/cli/README.md b/cli/README.md
new file mode 100644
index 0000000000..61df85582e
--- /dev/null
+++ b/cli/README.md
@@ -0,0 +1,275 @@
+# fizzy
+
+Fizzy CLI — an agent-first interface for the Fizzy API.
+
+## Install
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/basecamp/fizzy/main/cli/install.sh | bash
+```
+
+This installs to `~/.local/share/fizzy` and creates `~/.local/bin/fizzy`. Run again to update.
+
+**Requirements:** `bash 4+`, `curl`, `jq`
+
+### macOS
+
+macOS ships with bash 3.2. Install modern bash first:
+
+```bash
+brew install bash jq
+curl -fsSL https://raw.githubusercontent.com/basecamp/fizzy/main/cli/install.sh | bash
+```
+
+### Updating
+
+Run the installer again to update to the latest version.
+
+## Quick Start
+
+```bash
+# Authenticate (opens browser)
+fizzy auth login
+
+# Request read-only access (least-privilege)
+fizzy auth login --scope read
+
+# Headless mode (manual code entry)
+fizzy auth login --no-browser
+
+# Check auth status
+fizzy auth status
+
+# Orient yourself
+fizzy
+
+# List boards
+fizzy boards
+
+# List cards on a board
+fizzy cards --in "My Board"
+
+# Show card details
+fizzy show 42
+
+# Create a card
+fizzy card "Fix the login bug" --in "My Board"
+
+# Close a card
+fizzy close 42
+
+# Search cards
+fizzy search "login bug"
+```
+
+## Output Contract
+
+**Default**: JSON envelope when piped, markdown when TTY.
+
+```bash
+# JSON envelope (piped or --json)
+fizzy boards | jq '.data[0]'
+
+# Raw data only (--quiet or --data)
+fizzy --quiet boards | jq '.[0]'
+
+# Force markdown
+fizzy --md boards
+```
+
+**Note**: Global flags (`--quiet`, `--json`, `--md`) must come *before* the command name.
+
+### JSON Envelope Structure
+
+```json
+{
+ "ok": true,
+ "data": [...],
+ "summary": "5 boards",
+ "breadcrumbs": [
+ {"action": "show", "cmd": "fizzy show board "}
+ ]
+}
+```
+
+## Commands
+
+### Query Commands
+
+| Command | Description |
+|---------|-------------|
+| `boards` | List boards in the account |
+| `cards` | List or filter cards (supports `--page` pagination) |
+| `columns` | List columns on a board |
+| `comments` | List comments on a card |
+| `reactions` | List reactions on a comment |
+| `notifications` | List notifications |
+| `people` | List users in account |
+| `search` | Search cards |
+| `show` | Show card or board details |
+| `tags` | List tags |
+
+### Action Commands
+
+| Command | Description |
+|---------|-------------|
+| `card` | Manage cards (create/update/delete/image) |
+| `board` | Manage boards (create/update/delete/show) |
+| `column` | Manage columns on a board |
+| `close` | Close a card |
+| `reopen` | Reopen a closed card |
+| `triage` | Move card to a column |
+| `untriage` | Move card back to triage |
+| `postpone` | Move card to "not now" |
+| `comment` | Manage comments (add/edit/delete) |
+| `assign` | Assign a card to someone |
+| `tag` | Add a tag to a card |
+| `watch` | Subscribe to card notifications |
+| `unwatch` | Unsubscribe from card |
+| `gild` | Mark card as golden |
+| `ungild` | Remove golden status |
+| `step` | Manage steps (add/show/update/delete) |
+| `react` | Manage reactions (add/delete) |
+
+#### Card Subcommands
+
+| Command | Description |
+|---------|-------------|
+| `card "title"` | Create a new card (default) |
+| `card update ` | Update card title/description |
+| `card delete ` | Permanently delete a card |
+| `card image delete ` | Remove header image from card |
+
+## Global Flags
+
+Global flags must appear **before** the command name (e.g., `fizzy --json boards`, not `fizzy boards --json`).
+
+| Flag | Description |
+|------|-------------|
+| `--json`, `-j` | Force JSON output |
+| `--md`, `-m` | Force markdown output |
+| `--quiet`, `-q` | Raw data only (no envelope) |
+| `--verbose`, `-v` | Debug output |
+| `--board`, `-b`, `--in` | Board ID or name (can also go after command) |
+| `--account`, `-a` | Account slug |
+
+## Authentication
+
+fizzy uses OAuth 2.1 with Dynamic Client Registration (DCR). On first login, it registers itself as an OAuth client and opens your browser for authorization.
+
+**Scope options:**
+- `write` (default): Read and write access to all resources
+- `read`: Read-only access — cannot create, update, or delete
+
+```bash
+fizzy auth login # Write access (default)
+fizzy auth login --scope read # Read-only access
+fizzy auth status # Shows current scope
+```
+
+Fizzy issues long-lived tokens that don't expire, so you only need to re-authenticate if you explicitly logout or revoke your token.
+
+### Token for CI/Scripts
+
+For non-interactive environments, set `FIZZY_TOKEN`:
+
+```bash
+export FIZZY_URL=http://fizzy.localhost:3006/897362094
+export FIZZY_TOKEN=fzt_...
+fizzy cards --in "My Board"
+```
+
+Generate tokens at Profile → API → Personal access tokens.
+
+## Configuration
+
+```
+~/.config/fizzy/
+├── config.json # Global defaults
+├── credentials.json # OAuth tokens (0600)
+├── client.json # DCR client registration
+└── accounts.json # Discovered accounts
+
+.fizzy/
+└── config.json # Per-directory overrides
+```
+
+Config hierarchy: system → user → repo → local → environment → flags
+
+### Common Configuration
+
+```bash
+# Set default board for a project directory
+cd ~/projects/my-app
+fizzy config set board_id abc123
+
+# View all configuration
+fizzy config list
+
+# View config sources
+fizzy config path
+```
+
+## Environment
+
+| Variable | Description |
+|----------|-------------|
+| `FIZZY_URL` | Base URL + account slug (e.g., `http://fizzy.localhost:3006/897362094`) |
+| `FIZZY_BASE_URL` | Base URL only (default: `http://fizzy.localhost:3006`) |
+| `FIZZY_ACCOUNT_SLUG` | Account ID (7+ digit number) |
+| `FIZZY_TOKEN` | Personal access token for CI/scripts |
+
+`FIZZY_URL` is a convenience — it sets both `FIZZY_BASE_URL` and `FIZZY_ACCOUNT_SLUG` from a single URL. Explicit vars take precedence.
+
+```bash
+# Local development (default: http://fizzy.localhost:3006)
+fizzy boards
+
+# Production
+FIZZY_BASE_URL=https://fizzy.37signals.com fizzy auth login
+```
+
+OAuth endpoints are discovered automatically via `.well-known/oauth-authorization-server` (RFC 8414).
+
+## Name Resolution
+
+Commands accept names, not just IDs:
+
+```bash
+# By name
+fizzy cards --in "Fizzy 4"
+fizzy triage 42 --to "In Progress"
+fizzy assign 42 --to "Jane Doe"
+fizzy tag 42 --with "bug"
+
+# By ID (always works)
+fizzy cards --in abc123xyz
+```
+
+Name resolution is case-insensitive and supports partial matching. Ambiguous matches show suggestions.
+
+## Tab Completion
+
+```bash
+# Bash (add to ~/.bashrc)
+source ~/.local/share/fizzy/completions/fizzy.bash
+
+# Zsh (add to ~/.zshrc)
+fpath=(~/.local/share/fizzy/completions $fpath)
+autoload -Uz compinit && compinit
+```
+
+Provides completion for commands, subcommands, and flags.
+
+## Testing
+
+```bash
+./test/run.sh # Run all tests
+bats test/*.bats # Alternative: run bats directly
+```
+
+Tests use [bats-core](https://github.com/bats-core/bats-core). Install with `apt install bats` or `brew install bats-core`.
+
+## License
+
+[MIT](LICENSE.md)
diff --git a/cli/bin/fizzy b/cli/bin/fizzy
new file mode 100755
index 0000000000..97307b93f4
--- /dev/null
+++ b/cli/bin/fizzy
@@ -0,0 +1,118 @@
+#!/usr/bin/env bash
+# fizzy - Fizzy CLI
+# Agent-first CLI for Fizzy API interaction
+#
+# Usage: fizzy [options]
+# Run 'fizzy' with no arguments for quick-start guide
+
+set -euo pipefail
+
+# Require bash 4+ for associative arrays
+if ((BASH_VERSINFO[0] < 4)); then
+ echo "fizzy requires bash 4.0 or later (found: $BASH_VERSION)" >&2
+ echo "" >&2
+ if [[ "$(uname)" == "Darwin" ]]; then
+ echo "macOS ships with bash 3.2. Install modern bash via Homebrew:" >&2
+ echo " brew install bash" >&2
+ echo "" >&2
+ echo "Then run fizzy with:" >&2
+ echo " /opt/homebrew/bin/bash $(realpath "$0") [args]" >&2
+ echo "" >&2
+ echo "Or add to your shell config (~/.zshrc or ~/.bash_profile):" >&2
+ echo " alias fizzy='/opt/homebrew/bin/bash \$HOME/Work/basecamp/fizzy/cli/bin/fizzy'" >&2
+ else
+ echo "Install bash 4+ from your package manager." >&2
+ fi
+ exit 1
+fi
+
+# Determine script location for loading libs
+FIZZY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+FIZZY_VERSION="0.1.0"
+
+# Load libraries
+source "$FIZZY_ROOT/lib/core.sh"
+source "$FIZZY_ROOT/lib/config.sh"
+source "$FIZZY_ROOT/lib/api.sh"
+source "$FIZZY_ROOT/lib/auth.sh"
+source "$FIZZY_ROOT/lib/names.sh"
+
+# Load command handlers
+for cmd_file in "$FIZZY_ROOT/lib/commands"/*.sh; do
+ [[ -f "$cmd_file" ]] && source "$cmd_file"
+done
+
+
+main() {
+ # Parse global flags first
+ parse_global_flags "$@"
+ shift $GLOBAL_FLAGS_CONSUMED
+
+ # Reload config to pick up command-line flag overrides
+ load_config
+
+ # No arguments → quick-start
+ if [[ $# -eq 0 ]]; then
+ cmd_quick_start
+ exit 0
+ fi
+
+ local command="$1"
+ shift
+
+ case "$command" in
+ # Core query commands
+ boards) cmd_boards "$@" ;;
+ cards) cmd_cards "$@" ;;
+ columns) cmd_columns "$@" ;;
+ comments) cmd_comments "$@" ;;
+ reactions) cmd_reactions "$@" ;;
+ notifications) cmd_notifications "$@" ;;
+ people) cmd_people "$@" ;;
+ search) cmd_search "$@" ;;
+ show) cmd_show "$@" ;;
+ tags) cmd_tags "$@" ;;
+
+ # Action commands (using Fizzy's ubiquitous language)
+ card) cmd_card "$@" ;;
+ board) cmd_board "$@" ;;
+ column) cmd_column "$@" ;;
+ close) cmd_close "$@" ;;
+ reopen) cmd_reopen "$@" ;;
+ triage) cmd_triage "$@" ;;
+ untriage) cmd_untriage "$@" ;;
+ postpone) cmd_postpone "$@" ;;
+ comment) cmd_comment "$@" ;;
+ assign) cmd_assign "$@" ;;
+ tag) cmd_tag "$@" ;;
+ watch) cmd_watch "$@" ;;
+ unwatch) cmd_unwatch "$@" ;;
+ gild) cmd_gild "$@" ;;
+ ungild) cmd_ungild "$@" ;;
+ step) cmd_step "$@" ;;
+ react) cmd_react "$@" ;;
+
+ # Identity & users
+ identity) cmd_identity "$@" ;;
+ user) cmd_user "$@" ;;
+
+ # Auth & config
+ auth) cmd_auth "$@" ;;
+ config) cmd_config "$@" ;;
+
+ # Meta
+ version) echo "fizzy $FIZZY_VERSION" ;;
+ help|--help|-h)
+ cmd_help "$@"
+ ;;
+
+ *)
+ json_error "Unknown command: $command" "usage" \
+ "Run 'fizzy' for available commands"
+ exit 1
+ ;;
+ esac
+}
+
+# Run main with all arguments
+main "$@"
diff --git a/cli/completions/_fizzy b/cli/completions/_fizzy
new file mode 100644
index 0000000000..cb957dfd01
--- /dev/null
+++ b/cli/completions/_fizzy
@@ -0,0 +1,149 @@
+#compdef fizzy
+# fizzy zsh completion
+# Place in a directory in your $fpath (e.g., ~/.zsh/completions/)
+
+_fizzy() {
+ local -a commands
+ commands=(
+ 'auth:Authentication (login, logout, status)'
+ 'config:Configuration management'
+ 'help:Show help'
+ 'version:Show version'
+ 'boards:List boards in the account'
+ 'cards:List or filter cards'
+ 'columns:List columns on a board'
+ 'comments:List comments on a card'
+ 'reactions:List reactions on a comment'
+ 'notifications:List notifications'
+ 'people:List users in account'
+ 'search:Search cards'
+ 'show:Show card or board details'
+ 'tags:List tags'
+ 'card:Manage cards (create, update, delete, image)'
+ 'board:Manage boards (create, update, delete, show)'
+ 'column:Manage columns on a board'
+ 'close:Close a card'
+ 'reopen:Reopen a closed card'
+ 'triage:Move card to a column'
+ 'untriage:Move card back to triage'
+ 'postpone:Move card to "not now"'
+ 'comment:Add, edit, or delete comments'
+ 'assign:Assign a card to someone'
+ 'tag:Add a tag to a card'
+ 'watch:Subscribe to card notifications'
+ 'unwatch:Unsubscribe from card'
+ 'gild:Mark card as golden'
+ 'ungild:Remove golden status'
+ 'step:Manage steps (checklist items) on a card'
+ 'react:Manage reactions on comments'
+ )
+
+ local -a auth_commands
+ auth_commands=(
+ 'login:Authenticate via OAuth'
+ 'logout:Clear credentials'
+ 'status:Show auth status'
+ 'refresh:Check token status'
+ )
+
+ local -a config_commands
+ config_commands=(
+ 'list:List all configuration'
+ 'get:Get a configuration value'
+ 'set:Set a configuration value'
+ 'unset:Remove a configuration value'
+ 'path:Show configuration paths'
+ )
+
+ _arguments -C \
+ '(-j --json)'{-j,--json}'[Force JSON output]' \
+ '(-m --md)'{-m,--md}'[Force Markdown output]' \
+ '(-q --quiet --data)'{-q,--quiet,--data}'[Minimal output]' \
+ '(-v --verbose)'{-v,--verbose}'[Debug output]' \
+ '(-b --board --in)'{-b,--board,--in}'[Board ID or name]:board:' \
+ '(-a --account)'{-a,--account}'[Account slug]:account:' \
+ '(-h --help)'{-h,--help}'[Show help]' \
+ '1: :->command' \
+ '*::arg:->args'
+
+ case "$state" in
+ command)
+ _describe -t commands 'fizzy command' commands
+ ;;
+ args)
+ # In args state, $words is shifted so $words[1] is the subcommand
+ case "$words[1]" in
+ auth)
+ _describe -t auth_commands 'auth subcommand' auth_commands
+ ;;
+ config)
+ _describe -t config_commands 'config subcommand' config_commands
+ ;;
+ card)
+ # Handle nested card subcommands
+ if [[ ${#words[@]} -ge 3 && "$words[2]" == "image" ]]; then
+ local card_image_commands=(
+ 'delete:Remove header image from card'
+ )
+ _describe -t card_image_commands 'card image subcommand' card_image_commands
+ else
+ local card_commands=(
+ 'update:Update a card'
+ 'delete:Permanently delete a card'
+ 'image:Manage card header image'
+ )
+ _describe -t card_commands 'card subcommand' card_commands
+ fi
+ ;;
+ board)
+ local board_commands=(
+ 'create:Create a new board'
+ 'update:Update a board'
+ 'delete:Delete a board'
+ 'show:Show board details'
+ )
+ _describe -t board_commands 'board subcommand' board_commands
+ ;;
+ column)
+ local column_commands=(
+ 'create:Create a column'
+ 'update:Update a column'
+ 'delete:Delete a column'
+ 'show:Show column details'
+ )
+ _describe -t column_commands 'column subcommand' column_commands
+ ;;
+ notifications)
+ local notification_commands=(
+ 'read:Mark notification as read'
+ 'unread:Mark notification as unread'
+ )
+ _describe -t notification_commands 'notifications subcommand' notification_commands
+ ;;
+ comment)
+ local comment_commands=(
+ 'edit:Update a comment'
+ 'delete:Delete a comment'
+ )
+ _describe -t comment_commands 'comment subcommand' comment_commands
+ ;;
+ step)
+ local step_commands=(
+ 'show:Show step details'
+ 'update:Update a step'
+ 'delete:Delete a step'
+ )
+ _describe -t step_commands 'step subcommand' step_commands
+ ;;
+ react)
+ local react_commands=(
+ 'delete:Delete a reaction'
+ )
+ _describe -t react_commands 'react subcommand' react_commands
+ ;;
+ esac
+ ;;
+ esac
+}
+
+_fizzy "$@"
diff --git a/cli/completions/fizzy.bash b/cli/completions/fizzy.bash
new file mode 100644
index 0000000000..35d1a2c411
--- /dev/null
+++ b/cli/completions/fizzy.bash
@@ -0,0 +1,99 @@
+# fizzy bash completion
+# Source this file or place in /etc/bash_completion.d/
+
+_fizzy_completions() {
+ local cur prev words cword
+ _init_completion || return
+
+ local commands="
+ auth config help version
+ boards cards columns comments reactions notifications people search show tags
+ card board column close reopen triage untriage postpone comment assign tag watch unwatch gild ungild step react
+ identity user
+ "
+
+ local auth_subcommands="login logout status refresh"
+ local config_subcommands="list get set unset path"
+ local card_subcommands="update delete image"
+ local board_subcommands="create update delete show"
+ local column_subcommands="create update delete show"
+ local card_image_subcommands="delete"
+ local comment_subcommands="edit delete"
+ local step_subcommands="show update delete"
+ local react_subcommands="delete"
+ local user_subcommands="show update delete"
+
+ # Handle two-level deep subcommands (card image)
+ if [[ ${#words[@]} -ge 3 && "${words[1]}" == "card" && "${words[2]}" == "image" ]]; then
+ COMPREPLY=($(compgen -W "$card_image_subcommands" -- "$cur"))
+ return
+ fi
+
+ case "$prev" in
+ fizzy)
+ COMPREPLY=($(compgen -W "$commands" -- "$cur"))
+ return
+ ;;
+ auth)
+ COMPREPLY=($(compgen -W "$auth_subcommands" -- "$cur"))
+ return
+ ;;
+ config)
+ COMPREPLY=($(compgen -W "$config_subcommands" -- "$cur"))
+ return
+ ;;
+ card)
+ COMPREPLY=($(compgen -W "$card_subcommands" -- "$cur"))
+ return
+ ;;
+ board)
+ COMPREPLY=($(compgen -W "$board_subcommands" -- "$cur"))
+ return
+ ;;
+ column)
+ COMPREPLY=($(compgen -W "$column_subcommands" -- "$cur"))
+ return
+ ;;
+ comment)
+ COMPREPLY=($(compgen -W "$comment_subcommands" -- "$cur"))
+ return
+ ;;
+ step)
+ COMPREPLY=($(compgen -W "$step_subcommands" -- "$cur"))
+ return
+ ;;
+ react)
+ COMPREPLY=($(compgen -W "$react_subcommands" -- "$cur"))
+ return
+ ;;
+ user)
+ COMPREPLY=($(compgen -W "$user_subcommands" -- "$cur"))
+ return
+ ;;
+ --board|-b|--in)
+ # Could complete board names if cached, for now just return
+ return
+ ;;
+ --status)
+ COMPREPLY=($(compgen -W "all closed not_now stalled golden postponing_soon" -- "$cur"))
+ return
+ ;;
+ --scope)
+ COMPREPLY=($(compgen -W "write read" -- "$cur"))
+ return
+ ;;
+ --sort)
+ COMPREPLY=($(compgen -W "latest newest oldest" -- "$cur"))
+ return
+ ;;
+ esac
+
+ # Handle flags
+ if [[ "$cur" == -* ]]; then
+ local flags="--json -j --md -m --quiet -q --data --verbose -v --board -b --in --account -a --help -h"
+ COMPREPLY=($(compgen -W "$flags" -- "$cur"))
+ return
+ fi
+}
+
+complete -F _fizzy_completions fizzy
diff --git a/cli/install.sh b/cli/install.sh
new file mode 100755
index 0000000000..7656f84ff7
--- /dev/null
+++ b/cli/install.sh
@@ -0,0 +1,96 @@
+#!/usr/bin/env bash
+# fizzy CLI installer
+# Usage: curl -fsSL https://raw.githubusercontent.com/basecamp/fizzy/main/cli/install.sh | bash
+
+set -euo pipefail
+
+REPO="basecamp/fizzy"
+INSTALL_DIR="${FIZZY_INSTALL_DIR:-$HOME/.local/share/fizzy}"
+BIN_DIR="${FIZZY_BIN_DIR:-$HOME/.local/bin}"
+
+info() { echo "==> $*"; }
+warn() { echo "Warning: $*" >&2; }
+die() { echo "Error: $*" >&2; exit 1; }
+
+# Check dependencies
+check_deps() {
+ local missing=()
+
+ # Check bash version (need 4+)
+ if [[ "${BASH_VERSINFO[0]}" -lt 4 ]]; then
+ missing+=("bash 4+ (found ${BASH_VERSION})")
+ fi
+
+ command -v curl &>/dev/null || missing+=("curl")
+ command -v jq &>/dev/null || missing+=("jq")
+
+ if [[ ${#missing[@]} -gt 0 ]]; then
+ die "Missing dependencies: ${missing[*]}"
+ fi
+}
+
+# Download and install
+install_fizzy() {
+ info "Installing fizzy to $INSTALL_DIR"
+
+ # Create directories
+ mkdir -p "$INSTALL_DIR" "$BIN_DIR"
+
+ # Download latest from main branch
+ local tmp
+ tmp=$(mktemp -d)
+ trap "rm -rf $tmp" EXIT
+
+ info "Downloading from GitHub..."
+ curl -fsSL "https://github.com/$REPO/archive/refs/heads/main.tar.gz" | \
+ tar -xz -C "$tmp" --strip-components=2 "fizzy-main/cli"
+
+ # Copy files
+ cp -r "$tmp"/* "$INSTALL_DIR/"
+
+ # Make executable
+ chmod +x "$INSTALL_DIR/bin/fizzy"
+
+ # Create symlink in bin
+ ln -sf "$INSTALL_DIR/bin/fizzy" "$BIN_DIR/fizzy"
+
+ info "Installed fizzy to $BIN_DIR/fizzy"
+}
+
+# Setup completions hint
+setup_hint() {
+ echo
+ info "Setup shell completions (optional):"
+ echo
+ echo " # Bash (add to ~/.bashrc)"
+ echo " source $INSTALL_DIR/completions/fizzy.bash"
+ echo
+ echo " # Zsh (add to ~/.zshrc)"
+ echo " fpath=($INSTALL_DIR/completions \$fpath)"
+ echo " autoload -Uz compinit && compinit"
+ echo
+}
+
+# Check PATH
+check_path() {
+ if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then
+ warn "$BIN_DIR is not in your PATH"
+ echo " Add to your shell profile:"
+ echo " export PATH=\"$BIN_DIR:\$PATH\""
+ fi
+}
+
+main() {
+ info "fizzy CLI installer"
+ echo
+
+ check_deps
+ install_fizzy
+ setup_hint
+ check_path
+
+ echo
+ info "Done! Run 'fizzy auth login' to get started."
+}
+
+main "$@"
diff --git a/cli/lib/api.sh b/cli/lib/api.sh
new file mode 100644
index 0000000000..f7f0a2313a
--- /dev/null
+++ b/cli/lib/api.sh
@@ -0,0 +1,690 @@
+#!/usr/bin/env bash
+# api.sh - HTTP helpers for Fizzy API
+# Handles authentication, rate limiting, caching, retries
+
+
+# Configuration
+
+FIZZY_USER_AGENT="fizzy/$FIZZY_VERSION (https://github.com/basecamp/fizzy)"
+FIZZY_MAX_RETRIES="${FIZZY_MAX_RETRIES:-5}"
+FIZZY_BASE_DELAY="${FIZZY_BASE_DELAY:-1}"
+
+
+# ETag Cache Helpers
+
+_cache_dir() {
+ local configured
+ configured=$(get_config "cache_dir" "")
+ if [[ -n "$configured" ]]; then
+ echo "$configured"
+ else
+ echo "${XDG_CACHE_HOME:-$HOME/.cache}/fizzy"
+ fi
+}
+
+_cache_key() {
+ local account_slug="$1" url="$2" token="$3"
+ local token_hash=""
+ if [[ -n "$token" ]]; then
+ if command -v shasum &>/dev/null; then
+ token_hash=$(echo -n "$token" | shasum -a 256 | cut -c1-16)
+ else
+ token_hash=$(echo -n "$token" | sha256sum | cut -c1-16)
+ fi
+ fi
+ local cache_input="${FIZZY_BASE_URL:-}:${account_slug}:${token_hash}:${url}"
+ if command -v shasum &>/dev/null; then
+ echo -n "$cache_input" | shasum -a 256 | cut -d' ' -f1
+ else
+ echo -n "$cache_input" | sha256sum | cut -d' ' -f1
+ fi
+}
+
+_cache_get_etag() {
+ local key="$1"
+ local etags_file="$(_cache_dir)/etags.json"
+ if [[ -f "$etags_file" ]]; then
+ jq -r --arg k "$key" '.[$k] // empty' "$etags_file" 2>/dev/null || true
+ fi
+}
+
+_cache_get_body() {
+ local key="$1"
+ local body_file="$(_cache_dir)/responses/${key}.body"
+ if [[ -f "$body_file" ]]; then
+ cat "$body_file"
+ fi
+}
+
+_cache_set() {
+ local key="$1" body="$2" etag="$3" headers="$4"
+ local cache_dir="$(_cache_dir)"
+ local etags_file="$cache_dir/etags.json"
+ local body_file="$cache_dir/responses/${key}.body"
+ local headers_file="$cache_dir/responses/${key}.headers"
+
+ mkdir -p "$cache_dir/responses"
+
+ echo "$body" > "${body_file}.tmp" && mv "${body_file}.tmp" "$body_file"
+
+ if [[ -n "$headers" ]]; then
+ echo "$headers" > "${headers_file}.tmp" && mv "${headers_file}.tmp" "$headers_file"
+ fi
+
+ if [[ -f "$etags_file" ]]; then
+ jq --arg k "$key" --arg v "$etag" '.[$k] = $v' "$etags_file" > "${etags_file}.tmp" \
+ && mv "${etags_file}.tmp" "$etags_file"
+ else
+ jq -n --arg k "$key" --arg v "$etag" '{($k): $v}' > "$etags_file"
+ fi
+}
+
+
+# Authentication
+
+ensure_auth() {
+ local token
+ token=$(get_access_token) || {
+ die "Not authenticated. Run: fizzy auth login" $EXIT_AUTH
+ }
+
+ if is_token_expired && [[ -z "${FIZZY_TOKEN:-}" ]]; then
+ debug "Token expired, refreshing..."
+ if ! refresh_token; then
+ die "Token expired and refresh failed. Run: fizzy auth login" $EXIT_AUTH
+ fi
+ token=$(get_access_token)
+ fi
+
+ echo "$token"
+}
+
+ensure_account_slug() {
+ local account_slug
+ account_slug=$(get_account_slug)
+ if [[ -z "$account_slug" ]]; then
+ die "No account configured. Run: fizzy config set account_slug " $EXIT_USAGE \
+ "Or set FIZZY_ACCOUNT_SLUG environment variable"
+ fi
+ echo "$account_slug"
+}
+
+
+# HTTP Request Helpers
+
+api_get() {
+ local path="$1"
+ shift
+
+ local token account_slug
+ token=$(ensure_auth) || exit $?
+ account_slug=$(ensure_account_slug) || exit $?
+
+ # Strip leading slash from path and add it explicitly to avoid double slashes
+ path="${path#/}"
+ local url="$FIZZY_BASE_URL/$account_slug/$path"
+ _api_request GET "$url" "$token" "" "$@"
+}
+
+api_post() {
+ local path="$1"
+ local body="${2:-}"
+ shift 2 || shift
+
+ local token account_slug
+ token=$(ensure_auth) || exit $?
+ account_slug=$(ensure_account_slug) || exit $?
+
+ # Strip leading slash from path and add it explicitly to avoid double slashes
+ path="${path#/}"
+ local url="$FIZZY_BASE_URL/$account_slug/$path"
+ _api_request POST "$url" "$token" "$body" "$@"
+}
+
+api_put() {
+ local path="$1"
+ local body="${2:-}"
+ shift 2 || shift
+
+ local token account_slug
+ token=$(ensure_auth) || exit $?
+ account_slug=$(ensure_account_slug) || exit $?
+
+ # Strip leading slash from path and add it explicitly to avoid double slashes
+ path="${path#/}"
+ local url="$FIZZY_BASE_URL/$account_slug/$path"
+ _api_request PUT "$url" "$token" "$body" "$@"
+}
+
+api_patch() {
+ local path="$1"
+ local body="${2:-}"
+ shift 2 || shift
+
+ local token account_slug
+ token=$(ensure_auth) || exit $?
+ account_slug=$(ensure_account_slug) || exit $?
+
+ # Strip leading slash from path and add it explicitly to avoid double slashes
+ path="${path#/}"
+ local url="$FIZZY_BASE_URL/$account_slug/$path"
+ _api_request PATCH "$url" "$token" "$body" "$@"
+}
+
+api_delete() {
+ local path="$1"
+ shift
+
+ local token account_slug
+ token=$(ensure_auth) || exit $?
+ account_slug=$(ensure_account_slug) || exit $?
+
+ # Strip leading slash from path and add it explicitly to avoid double slashes
+ path="${path#/}"
+ local url="$FIZZY_BASE_URL/$account_slug/$path"
+ _api_request DELETE "$url" "$token" "" "$@"
+}
+
+# Unauthenticated request (for OAuth discovery)
+api_get_unauth() {
+ local url="$1"
+ shift
+
+ _api_request GET "$url" "" "" "$@"
+}
+
+api_post_unauth() {
+ local url="$1"
+ local body="${2:-}"
+ shift 2 || shift
+
+ _api_request POST "$url" "" "$body" "$@"
+}
+
+_api_request() {
+ local method="$1"
+ local url="$2"
+ local token="$3"
+ local body="${4:-}"
+ shift 4 || shift 3 || shift 2 || shift
+
+ local attempt=1
+ local delay=$FIZZY_BASE_DELAY
+ local response http_code headers_file
+
+ headers_file=$(mktemp)
+ trap "rm -f '$headers_file'" RETURN
+
+ # ETag cache setup (GET requests only)
+ local cache_key="" cached_etag=""
+ if [[ "$method" == "GET" ]] && [[ "${FIZZY_CACHE_ENABLED:-true}" == "true" ]] && [[ -n "$token" ]]; then
+ local account_slug
+ account_slug=$(get_account_slug 2>/dev/null || echo "")
+ if [[ -n "$account_slug" ]]; then
+ cache_key=$(_cache_key "$account_slug" "$url" "$token")
+ cached_etag=$(_cache_get_etag "$cache_key")
+ fi
+ fi
+
+ while (( attempt <= FIZZY_MAX_RETRIES )); do
+ debug "API $method $url (attempt $attempt)"
+
+ local curl_args=(
+ -s
+ -X "$method"
+ -H "User-Agent: $FIZZY_USER_AGENT"
+ -H "Content-Type: application/json"
+ -H "Accept: application/json"
+ -D "$headers_file"
+ -w '\n%{http_code}'
+ )
+
+ # Add Authorization header if token provided
+ if [[ -n "$token" ]]; then
+ curl_args+=(-H "Authorization: Bearer $token")
+ fi
+
+ # Add If-None-Match header for cached responses
+ if [[ -n "$cached_etag" ]]; then
+ curl_args+=(-H "If-None-Match: $cached_etag")
+ debug "Cache: If-None-Match $cached_etag"
+ fi
+
+ if [[ -n "$body" ]]; then
+ curl_args+=(-d "$body")
+ fi
+
+ curl_args+=("$@")
+ curl_args+=("$url")
+
+ # Log curl command in verbose mode (with redacted token)
+ if [[ "$FIZZY_VERBOSE" == "true" ]]; then
+ local curl_cmd="curl"
+ local prev_was_H=false
+ for arg in "${curl_args[@]}"; do
+ if [[ "$arg" == "-H" ]]; then
+ prev_was_H=true
+ continue
+ elif $prev_was_H; then
+ prev_was_H=false
+ if [[ "$arg" == "Authorization: Bearer"* ]]; then
+ curl_cmd+=" -H 'Authorization: Bearer [REDACTED]'"
+ else
+ curl_cmd+=" -H '$arg'"
+ fi
+ elif [[ "$arg" == *" "* ]]; then
+ curl_cmd+=" '$arg'"
+ else
+ curl_cmd+=" $arg"
+ fi
+ done
+ echo "[curl] $curl_cmd" >&2
+ fi
+
+ local output curl_exit
+ output=$(curl "${curl_args[@]}") || curl_exit=$?
+
+ if [[ -n "${curl_exit:-}" ]]; then
+ case "$curl_exit" in
+ 6) die "Could not resolve host" $EXIT_NETWORK ;;
+ 7) die "Connection refused" $EXIT_NETWORK ;;
+ 28) die "Connection timed out" $EXIT_NETWORK ;;
+ 35) die "SSL/TLS handshake failed" $EXIT_NETWORK ;;
+ *) die "Network error (curl exit $curl_exit)" $EXIT_NETWORK ;;
+ esac
+ fi
+
+ http_code=$(echo "$output" | tail -n1)
+ response=$(echo "$output" | sed '$d')
+
+ debug "HTTP $http_code"
+
+ case "$http_code" in
+ 304)
+ # Not Modified - return cached response
+ if [[ -n "$cache_key" ]]; then
+ debug "Cache hit: 304 Not Modified"
+ _cache_get_body "$cache_key"
+ return 0
+ fi
+ die "304 received but no cached response available" $EXIT_API
+ ;;
+ 200|201|204)
+ # Cache successful GET responses with ETag
+ if [[ "$method" == "GET" ]] && [[ -n "$cache_key" ]]; then
+ local etag
+ etag=$(grep -i '^ETag:' "$headers_file" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r\n')
+ if [[ -n "$etag" ]]; then
+ local cached_headers
+ cached_headers=$(cat "$headers_file")
+ _cache_set "$cache_key" "$response" "$etag" "$cached_headers"
+ debug "Cache: stored with ETag $etag"
+ fi
+ fi
+
+ # Follow Location header on 201 with empty body (RESTful create pattern)
+ if [[ "$http_code" == "201" ]] && [[ -z "$response" ]]; then
+ local location
+ location=$(grep -i '^location:' "$headers_file" 2>/dev/null | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r\n' || true)
+ if [[ -n "$location" ]]; then
+ # Convert relative URL to absolute
+ if [[ "$location" != http* ]]; then
+ location="$FIZZY_BASE_URL$location"
+ fi
+ # Strip .json suffix if present (API returns /cards/1.json but endpoint is /cards/1)
+ location="${location%.json}"
+ debug "Following Location header: $location"
+ response=$(curl -s -H "Authorization: Bearer $token" -H "Accept: application/json" "$location")
+ fi
+ fi
+
+ echo "$response"
+ return 0
+ ;;
+ 429)
+ local retry_after
+ retry_after=$(grep -i "Retry-After:" "$headers_file" | awk '{print $2}' | tr -d '\r')
+ delay=${retry_after:-$((FIZZY_BASE_DELAY * 2 ** (attempt - 1)))}
+ info "Rate limited, waiting ${delay}s..."
+ sleep "$delay"
+ ((attempt++))
+ ;;
+ 401)
+ if [[ $attempt -eq 1 ]] && [[ -z "${FIZZY_TOKEN:-}" ]] && [[ -n "$token" ]]; then
+ debug "401 received, attempting token refresh"
+ if refresh_token; then
+ token=$(get_access_token)
+ ((attempt++))
+ continue
+ fi
+ fi
+ die "Authentication failed" $EXIT_AUTH "Run: fizzy auth login"
+ ;;
+ 403)
+ if [[ "$method" =~ ^(POST|PUT|PATCH|DELETE)$ ]]; then
+ local current_scope
+ current_scope=$(get_token_scope 2>/dev/null || echo "unknown")
+ if [[ "$current_scope" == "read" ]]; then
+ die "Permission denied: read-only token cannot perform write operations" $EXIT_FORBIDDEN \
+ "Re-authenticate with write scope: fizzy auth login --scope write"
+ fi
+ fi
+ die "Permission denied" $EXIT_FORBIDDEN \
+ "You don't have access to this resource"
+ ;;
+ 404)
+ die "Not found" $EXIT_NOT_FOUND
+ ;;
+ 422)
+ # Unprocessable Entity - validation error
+ local error_msg
+ error_msg=$(echo "$response" | jq -r '.errors | to_entries | map("\(.key): \(.value | join(", "))") | join("; ")' 2>/dev/null || echo "Validation failed")
+ die "Validation error: $error_msg" $EXIT_USAGE
+ ;;
+ 500)
+ die "Server error (500)" $EXIT_API \
+ "The server encountered an internal error"
+ ;;
+ 502|503|504)
+ delay=$((FIZZY_BASE_DELAY * 2 ** (attempt - 1)))
+ info "Gateway error ($http_code), retrying in ${delay}s..."
+ sleep "$delay"
+ ((attempt++))
+ ;;
+ *)
+ local error_msg
+ error_msg=$(echo "$response" | jq -r '.error // .message // "Unknown error"' 2>/dev/null || echo "Request failed")
+ die "$error_msg (HTTP $http_code)" $EXIT_API
+ ;;
+ esac
+ done
+
+ die "Request failed after $FIZZY_MAX_RETRIES retries" $EXIT_API
+}
+
+
+# Multipart Upload Helper
+# Executes curl with retry logic for 429/5xx errors
+# Usage: api_multipart_request http_code_var response_var curl_args...
+
+api_multipart_request() {
+ local -n _http_code_ref=$1
+ local -n _response_ref=$2
+ shift 2
+
+ local attempt=1
+ local delay=$FIZZY_BASE_DELAY
+ local max_retries=3 # Fewer retries for uploads
+
+ while (( attempt <= max_retries )); do
+ debug "Multipart upload (attempt $attempt)"
+
+ local output curl_exit
+ output=$(curl "$@") || curl_exit=$?
+
+ if [[ -n "${curl_exit:-}" ]]; then
+ case "$curl_exit" in
+ 6) die "Could not resolve host" $EXIT_NETWORK ;;
+ 7) die "Connection refused" $EXIT_NETWORK ;;
+ 28) die "Connection timed out" $EXIT_NETWORK ;;
+ 35) die "SSL/TLS handshake failed" $EXIT_NETWORK ;;
+ *) die "Network error (curl exit $curl_exit)" $EXIT_NETWORK ;;
+ esac
+ fi
+
+ _http_code_ref=$(echo "$output" | tail -n1)
+ _response_ref=$(echo "$output" | sed '$d')
+
+ debug "HTTP $_http_code_ref"
+
+ case "$_http_code_ref" in
+ 200|201|204)
+ return 0
+ ;;
+ 429)
+ delay=$((FIZZY_BASE_DELAY * 2 ** (attempt - 1)))
+ info "Rate limited, waiting ${delay}s..."
+ sleep "$delay"
+ ((attempt++))
+ ;;
+ 502|503|504)
+ delay=$((FIZZY_BASE_DELAY * 2 ** (attempt - 1)))
+ info "Gateway error ($_http_code_ref), retrying in ${delay}s..."
+ sleep "$delay"
+ ((attempt++))
+ ;;
+ *)
+ # Non-retryable error - return and let caller handle
+ return 0
+ ;;
+ esac
+ done
+
+ # Exhausted retries - return last response for caller to handle
+ return 0
+}
+
+
+# Token Refresh
+
+refresh_token() {
+ local creds
+ creds=$(load_credentials)
+
+ local refresh_tok
+ refresh_tok=$(echo "$creds" | jq -r '.refresh_token // empty')
+
+ if [[ -z "$refresh_tok" ]]; then
+ debug "No refresh token available"
+ return 1
+ fi
+
+ local client_id client_secret
+ client_id=$(get_client_id)
+ client_secret=$(get_client_secret)
+
+ if [[ -z "$client_id" ]]; then
+ debug "No client credentials found"
+ return 1
+ fi
+
+ # Preserve original scope for new credentials
+ local original_scope
+ original_scope=$(echo "$creds" | jq -r '.scope // empty')
+
+ debug "Refreshing token..."
+
+ # Use discovered token endpoint instead of hardcoded path
+ local token_endpoint
+ token_endpoint=$(_token_endpoint)
+
+ if [[ -z "$token_endpoint" ]]; then
+ debug "Could not discover token endpoint"
+ return 1
+ fi
+
+ # Build curl command with --data-urlencode for proper URL encoding
+ # Include client_secret only if present (for confidential clients)
+ local response curl_exit
+ if [[ -n "$client_secret" ]]; then
+ response=$(curl -s -X POST \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ --data-urlencode "grant_type=refresh_token" \
+ --data-urlencode "refresh_token=$refresh_tok" \
+ --data-urlencode "client_id=$client_id" \
+ --data-urlencode "client_secret=$client_secret" \
+ "$token_endpoint") || curl_exit=$?
+ else
+ response=$(curl -s -X POST \
+ -H "Content-Type: application/x-www-form-urlencoded" \
+ --data-urlencode "grant_type=refresh_token" \
+ --data-urlencode "refresh_token=$refresh_tok" \
+ --data-urlencode "client_id=$client_id" \
+ "$token_endpoint") || curl_exit=$?
+ fi
+
+ if [[ -n "${curl_exit:-}" ]]; then
+ debug "Token refresh network error (curl exit $curl_exit)"
+ return 1
+ fi
+
+ local new_access_token new_refresh_token expires_in new_scope
+ new_access_token=$(echo "$response" | jq -r '.access_token // empty')
+ new_refresh_token=$(echo "$response" | jq -r '.refresh_token // empty')
+ expires_in=$(echo "$response" | jq -r '.expires_in // empty')
+ new_scope=$(echo "$response" | jq -r '.scope // empty')
+
+ if [[ -z "$new_access_token" ]]; then
+ debug "Token refresh failed: $response"
+ return 1
+ fi
+
+ # Use scope from response if provided, otherwise preserve original scope
+ local final_scope="${new_scope:-$original_scope}"
+
+ # Build credentials - only include expires_at if server provided expires_in
+ local new_creds
+ if [[ -n "$expires_in" ]]; then
+ local expires_at
+ expires_at=$(($(date +%s) + expires_in))
+ new_creds=$(jq -n \
+ --arg access_token "$new_access_token" \
+ --arg refresh_token "${new_refresh_token:-$refresh_tok}" \
+ --argjson expires_at "$expires_at" \
+ --arg scope "$final_scope" \
+ '{access_token: $access_token, refresh_token: $refresh_token, expires_at: $expires_at, scope: $scope}')
+ else
+ # No expiration - token is long-lived
+ new_creds=$(jq -n \
+ --arg access_token "$new_access_token" \
+ --arg refresh_token "${new_refresh_token:-$refresh_tok}" \
+ --arg scope "$final_scope" \
+ '{access_token: $access_token, refresh_token: $refresh_token, expires_at: null, scope: $scope}')
+ fi
+
+ save_credentials "$new_creds"
+ debug "Token refreshed successfully"
+ return 0
+}
+
+
+# Pagination
+
+api_get_all() {
+ local path="$1"
+ local max_pages="${2:-100}"
+
+ local token account_slug
+ token=$(ensure_auth) || exit $?
+ account_slug=$(ensure_account_slug) || exit $?
+
+ # Strip leading slash from path and add it explicitly to avoid double slashes
+ path="${path#/}"
+
+ local all_results="[]"
+ local page=1
+ local base_url="$FIZZY_BASE_URL/$account_slug/$path"
+ local url="$base_url"
+ local use_link_headers=true
+
+ local headers_file
+ headers_file=$(mktemp)
+ trap "rm -f '$headers_file'" RETURN
+
+ while (( page <= max_pages )); do
+ debug "Fetching page $page: $url"
+
+ local output http_code response curl_exit
+ output=$(curl -s \
+ -H "Authorization: Bearer $token" \
+ -H "User-Agent: $FIZZY_USER_AGENT" \
+ -H "Content-Type: application/json" \
+ -H "Accept: application/json" \
+ -D "$headers_file" \
+ -w '\n%{http_code}' \
+ "$url") || curl_exit=$?
+
+ if [[ -n "${curl_exit:-}" ]]; then
+ case "$curl_exit" in
+ 6) die "Could not resolve host" $EXIT_NETWORK ;;
+ 7) die "Connection refused" $EXIT_NETWORK ;;
+ 28) die "Connection timed out" $EXIT_NETWORK ;;
+ *) die "Network error (curl exit $curl_exit)" $EXIT_NETWORK ;;
+ esac
+ fi
+
+ http_code=$(echo "$output" | tail -n1)
+ response=$(echo "$output" | sed '$d')
+
+ if [[ "$http_code" != "200" ]]; then
+ die "API request failed (HTTP $http_code)" $EXIT_API
+ fi
+
+ # Check for empty response (end of pagination)
+ local count
+ count=$(echo "$response" | jq 'if type == "array" then length else 1 end' 2>/dev/null || echo "1")
+ if [[ "$count" -eq 0 ]]; then
+ debug "Empty response, pagination complete"
+ break
+ fi
+
+ if [[ "$all_results" == "[]" ]]; then
+ all_results="$response"
+ else
+ all_results=$(echo "$all_results" "$response" | jq -s '.[0] + .[1]')
+ fi
+
+ # Parse Link header for next page (RFC 5988)
+ local next_url
+ next_url=$(grep -i '^Link:' "$headers_file" | sed -n 's/.*<\([^>]*\)>; rel="next".*/\1/p' | tr -d '\r')
+
+ if [[ -n "$next_url" ]]; then
+ url="$next_url"
+ ((page++))
+ elif [[ "$use_link_headers" == "true" ]] && [[ "$page" -eq 1 ]]; then
+ # No Link header on page 1 - fall back to ?page=N pagination
+ debug "No Link header, falling back to ?page=N"
+ use_link_headers=false
+ ((page++))
+ if [[ "$base_url" == *"?"* ]]; then
+ url="${base_url}&page=$page"
+ else
+ url="${base_url}?page=$page"
+ fi
+ elif [[ "$use_link_headers" == "false" ]]; then
+ # Using ?page=N fallback, try next page
+ ((page++))
+ if [[ "$base_url" == *"?"* ]]; then
+ url="${base_url}&page=$page"
+ else
+ url="${base_url}?page=$page"
+ fi
+ else
+ # No more pages
+ break
+ fi
+ done
+
+ echo "$all_results"
+}
+
+
+# URL helpers
+
+board_path() {
+ local resource="$1"
+ local board_id="${2:-$(get_board_id)}"
+
+ if [[ -z "$board_id" ]]; then
+ die "No board specified. Use --board or set in .fizzy/config.json" $EXIT_USAGE
+ fi
+
+ echo "/boards/$board_id$resource"
+}
+
+card_path() {
+ local card_number="$1"
+ local resource="${2:-}"
+
+ echo "/cards/$card_number$resource"
+}
diff --git a/cli/lib/auth.sh b/cli/lib/auth.sh
new file mode 100644
index 0000000000..b877a99206
--- /dev/null
+++ b/cli/lib/auth.sh
@@ -0,0 +1,814 @@
+#!/usr/bin/env bash
+# auth.sh - OAuth 2.1 authentication with Dynamic Client Registration
+# Uses .well-known/oauth-authorization-server discovery (RFC 8414)
+
+
+# OAuth Configuration
+
+FIZZY_REDIRECT_PORT="${FIZZY_REDIRECT_PORT:-8976}"
+FIZZY_REDIRECT_URI="http://127.0.0.1:$FIZZY_REDIRECT_PORT/callback"
+
+FIZZY_CLIENT_NAME="fizzy-cli"
+FIZZY_CLIENT_URI="https://github.com/basecamp/fizzy"
+
+# Cached OAuth server metadata (populated by _ensure_oauth_config)
+declare -g _FIZZY_OAUTH_CONFIG=""
+
+
+# OAuth Discovery (RFC 8414)
+
+_discover_oauth_config() {
+ # Fetch OAuth 2.1 server metadata from .well-known endpoint
+ local discovery_url="$FIZZY_BASE_URL/.well-known/oauth-authorization-server"
+
+ debug "Discovering OAuth config from: $discovery_url"
+
+ local response
+ response=$(curl -s -f "$discovery_url") || {
+ die "Failed to discover OAuth configuration from $discovery_url" $EXIT_AUTH \
+ "Ensure FIZZY_BASE_URL points to a valid Fizzy instance"
+ }
+
+ # Validate required fields
+ local authorization_endpoint token_endpoint
+ authorization_endpoint=$(echo "$response" | jq -r '.authorization_endpoint // empty')
+ token_endpoint=$(echo "$response" | jq -r '.token_endpoint // empty')
+
+ if [[ -z "$authorization_endpoint" ]] || [[ -z "$token_endpoint" ]]; then
+ debug "Discovery response: $response"
+ die "Invalid OAuth discovery response - missing required endpoints" $EXIT_AUTH
+ fi
+
+ _FIZZY_OAUTH_CONFIG="$response"
+ debug "OAuth config discovered successfully"
+}
+
+_ensure_oauth_config() {
+ # Lazily fetch and cache OAuth config
+ if [[ -z "$_FIZZY_OAUTH_CONFIG" ]]; then
+ _discover_oauth_config
+ fi
+}
+
+_get_oauth_endpoint() {
+ local key="$1"
+ _ensure_oauth_config
+ echo "$_FIZZY_OAUTH_CONFIG" | jq -r ".$key // empty"
+}
+
+# Convenience accessors for OAuth endpoints
+_authorization_endpoint() { _get_oauth_endpoint "authorization_endpoint"; }
+_token_endpoint() { _get_oauth_endpoint "token_endpoint"; }
+_registration_endpoint() { _get_oauth_endpoint "registration_endpoint"; }
+_revocation_endpoint() { _get_oauth_endpoint "revocation_endpoint"; }
+
+
+# Auth Commands
+
+cmd_auth() {
+ local action="${1:-status}"
+ shift || true
+
+ case "$action" in
+ login) _auth_login "$@" ;;
+ logout) _auth_logout "$@" ;;
+ status) _auth_status "$@" ;;
+ refresh) _auth_refresh "$@" ;;
+ --help|-h) _help_auth ;;
+ *)
+ die "Unknown auth action: $action" $EXIT_USAGE "Run: fizzy auth --help"
+ ;;
+ esac
+}
+
+
+# Login Flow
+
+_auth_login() {
+ local no_browser=false
+ local scope="write" # Default to write (read+write) scope
+
+ # Parse login-specific flags
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --no-browser)
+ no_browser=true
+ shift
+ ;;
+ --scope)
+ shift
+ case "${1:-}" in
+ write|read)
+ scope="$1"
+ shift
+ ;;
+ *)
+ die "Invalid scope: ${1:-}. Use 'write' or 'read'" $EXIT_USAGE
+ ;;
+ esac
+ ;;
+ *)
+ shift
+ ;;
+ esac
+ done
+
+ info "Starting authentication..."
+
+ # Pre-fetch OAuth config (avoids repeated discovery in subshells)
+ _ensure_oauth_config
+
+ # Get or register client
+ local client_id client_secret
+ if ! _load_client; then
+ info "Registering OAuth client..."
+ _register_client || die "Failed to register OAuth client" $EXIT_AUTH
+ fi
+ _load_client
+
+ # Generate PKCE challenge
+ local code_verifier code_challenge
+ code_verifier=$(_generate_code_verifier)
+ code_challenge=$(_generate_code_challenge "$code_verifier")
+ debug "Generated code_verifier: $code_verifier"
+ debug "Generated code_challenge: $code_challenge"
+
+ # Generate state for CSRF protection
+ local state
+ state=$(_generate_state)
+
+ # Build authorization URL (using discovered endpoint)
+ local auth_endpoint auth_url
+ auth_endpoint=$(_authorization_endpoint)
+ auth_url="$auth_endpoint?response_type=code"
+ auth_url+="&client_id=$client_id"
+ auth_url+="&redirect_uri=$(urlencode "$FIZZY_REDIRECT_URI")"
+ auth_url+="&code_challenge=$code_challenge"
+ auth_url+="&code_challenge_method=S256"
+ auth_url+="&scope=$scope"
+ auth_url+="&state=$state"
+
+ local auth_code
+
+ if [[ "$no_browser" == "true" ]]; then
+ # Headless mode: user manually visits URL and enters code
+ echo "Visit this URL to authorize:"
+ echo
+ echo " $auth_url"
+ echo
+ read -rp "Enter the authorization code: " auth_code
+ if [[ -z "$auth_code" ]]; then
+ die "No authorization code provided" $EXIT_AUTH
+ fi
+ else
+ # Browser mode: open browser and wait for callback
+ info "Opening browser for authorization..."
+ info "If browser doesn't open, visit: $auth_url"
+
+ # Open browser
+ _open_browser "$auth_url"
+
+ # Start local server to receive callback
+ auth_code=$(_wait_for_callback "$state") || die "Authorization failed" $EXIT_AUTH
+ fi
+
+ # Exchange code for tokens
+ info "Exchanging authorization code..."
+ _exchange_code "$auth_code" "$code_verifier" "$client_id" "$client_secret" || \
+ die "Token exchange failed" $EXIT_AUTH
+
+ # Discover accounts
+ info "Discovering accounts..."
+ _discover_accounts || warn "Could not discover accounts"
+
+ # Select account if multiple
+ _select_account
+
+ info "Authentication successful!"
+ _auth_status
+}
+
+_auth_logout() {
+ local creds
+ creds=$(load_credentials)
+ if [[ -n "$(echo "$creds" | jq -r '.access_token // empty')" ]]; then
+ clear_credentials
+ info "Logged out from $FIZZY_BASE_URL"
+ else
+ info "Not logged in to $FIZZY_BASE_URL"
+ fi
+
+ # Warn if env var will still override
+ if [[ -n "${FIZZY_TOKEN:-}" ]]; then
+ warn "FIZZY_TOKEN environment variable is still set and will be used for authentication"
+ fi
+}
+
+_auth_status() {
+ local format
+ format=$(get_format)
+
+ local auth_status="unauthenticated"
+ local auth_type="none"
+ local account_slug=""
+ local account_name=""
+ local expires_at=""
+ local token_status="none"
+ local scope=""
+ local has_stored_creds=false
+
+ if get_access_token &>/dev/null; then
+ auth_status="authenticated"
+ auth_type=$(get_auth_type)
+
+ local creds
+ creds=$(load_credentials)
+ local stored_token
+ stored_token=$(echo "$creds" | jq -r '.access_token // empty')
+ [[ -n "$stored_token" ]] && has_stored_creds=true
+
+ # Only use stored creds metadata when not using env token
+ if [[ "$auth_type" != "token_env" ]]; then
+ expires_at=$(echo "$creds" | jq -r '.expires_at // "null"')
+ scope=$(echo "$creds" | jq -r '.scope // empty')
+ fi
+
+ # Env var tokens are always long-lived
+ if [[ "$auth_type" == "token_env" ]]; then
+ token_status="env"
+ elif [[ "$expires_at" == "null" ]] || [[ "$expires_at" == "0" ]]; then
+ token_status="long-lived"
+ elif is_token_expired; then
+ token_status="expired"
+ else
+ token_status="valid"
+ fi
+
+ local accounts
+ accounts=$(load_accounts)
+ account_slug=$(get_account_slug)
+
+ if [[ -n "$account_slug" ]] && [[ "$accounts" != "[]" ]]; then
+ account_name=$(echo "$accounts" | jq -r --arg slug "$account_slug" \
+ '.[] | select(.slug == ("/"+$slug)) | .name // empty')
+ fi
+ fi
+
+ if [[ "$format" == "json" ]]; then
+ jq -n \
+ --arg status "$auth_status" \
+ --arg auth "$auth_type" \
+ --arg token_status "$token_status" \
+ --arg account_slug "$account_slug" \
+ --arg account_name "$account_name" \
+ --arg expires_at "$expires_at" \
+ --arg scope "$scope" \
+ --argjson has_stored "$has_stored_creds" \
+ '{
+ status: $status,
+ auth: (if $auth == "token_env" then "env" else (if $auth == "oauth" then "oauth" else null end) end),
+ token: $token_status,
+ scope: (if $scope != "" then $scope else null end),
+ has_stored_credentials: (if $auth == "token_env" then $has_stored else null end),
+ account: {
+ slug: (if $account_slug != "" then $account_slug else null end),
+ name: (if $account_name != "" then $account_name else null end)
+ },
+ expires_at: (if $expires_at != "" and $expires_at != "0" and $expires_at != "null" then ($expires_at | tonumber) else null end)
+ }'
+ else
+ echo "## Authentication Status"
+ echo
+ if [[ "$auth_status" == "authenticated" ]]; then
+ if [[ "$auth_type" == "token_env" ]]; then
+ echo "Status: ✓ Authenticated (FIZZY_TOKEN)"
+ echo "Source: Environment variable (takes precedence)"
+ if [[ "$has_stored_creds" == "true" ]]; then
+ echo "Note: Stored OAuth credentials also exist (ignored while FIZZY_TOKEN is set)"
+ fi
+ else
+ echo "Status: ✓ Authenticated"
+ fi
+ [[ -n "$account_name" ]] && echo "Account: $account_name ($account_slug)" || true
+ if [[ -n "$scope" ]]; then
+ if [[ "$scope" == "read" ]]; then
+ echo "Scope: $scope (read-only)"
+ else
+ echo "Scope: $scope (read+write)"
+ fi
+ fi
+ if [[ "$token_status" == "long-lived" ]]; then
+ echo "Token: ✓ Long-lived (no expiration)"
+ elif [[ "$token_status" == "expired" ]]; then
+ echo "Token: ⚠ Expired (run: fizzy auth login)"
+ fi
+ else
+ echo "Status: ✗ Not authenticated"
+ echo
+ echo "Run: fizzy auth login"
+ echo " or export FIZZY_TOKEN="
+ fi
+ fi
+}
+
+_auth_refresh() {
+ # First check if we're authenticated at all
+ if ! get_access_token &>/dev/null; then
+ die "Not authenticated. Run: fizzy auth login" $EXIT_AUTH
+ fi
+
+ # Env var tokens cannot be refreshed
+ local auth_type
+ auth_type=$(get_auth_type)
+ if [[ "$auth_type" == "token_env" ]]; then
+ die "FIZZY_TOKEN does not support refresh" $EXIT_AUTH \
+ "Environment variable tokens are managed externally"
+ fi
+
+ # Check if we have a refresh token before attempting
+ local creds
+ creds=$(load_credentials)
+ local refresh_tok
+ refresh_tok=$(echo "$creds" | jq -r '.refresh_token // empty')
+
+ if [[ -z "$refresh_tok" ]]; then
+ # Fizzy issues long-lived tokens without refresh capability
+ local expires_at
+ expires_at=$(echo "$creds" | jq -r '.expires_at // "null"')
+ # Treat null and 0 as long-lived (consistent with is_token_expired and auth status)
+ if [[ "$expires_at" == "null" ]] || [[ "$expires_at" == "0" ]]; then
+ info "Your token is long-lived and doesn't require refresh."
+ _auth_status
+ return 0
+ else
+ die "No refresh token available. Run: fizzy auth login" $EXIT_AUTH
+ fi
+ fi
+
+ if refresh_token; then
+ info "Token refreshed successfully"
+ _auth_status
+ else
+ die "Token refresh failed. Run: fizzy auth login" $EXIT_AUTH
+ fi
+}
+
+
+# OAuth Helpers
+
+_register_client() {
+ # Dynamic Client Registration (DCR) using discovered endpoint
+ local registration_endpoint
+ registration_endpoint=$(_registration_endpoint)
+
+ if [[ -z "$registration_endpoint" ]]; then
+ die "OAuth server does not support Dynamic Client Registration" $EXIT_AUTH \
+ "The server's .well-known/oauth-authorization-server does not include registration_endpoint"
+ fi
+
+ debug "Registering client at: $registration_endpoint"
+
+ # DCR clients typically only get authorization_code grant
+ local grant_types='["authorization_code"]'
+
+ # Fizzy DCR only supports public clients (no client_secret)
+ # Request both read and write scopes so CLI can perform all operations
+ local response
+ response=$(curl -s -X POST \
+ -H "Content-Type: application/json" \
+ -d "$(jq -n \
+ --arg name "$FIZZY_CLIENT_NAME" \
+ --arg uri "$FIZZY_CLIENT_URI" \
+ --arg redirect "$FIZZY_REDIRECT_URI" \
+ --argjson grants "$grant_types" \
+ '{
+ client_name: $name,
+ client_uri: $uri,
+ redirect_uris: [$redirect],
+ grant_types: $grants,
+ response_types: ["code"],
+ token_endpoint_auth_method: "none",
+ scope: "read write"
+ }')" \
+ "$registration_endpoint")
+
+ local client_id client_secret
+ client_id=$(echo "$response" | jq -r '.client_id // empty')
+ client_secret=$(echo "$response" | jq -r '.client_secret // empty')
+
+ if [[ -z "$client_id" ]]; then
+ debug "DCR response: $response"
+ return 1
+ fi
+
+ debug "Registered client_id: $client_id"
+
+ # Save using multi-origin aware save_client from config.sh
+ local client_json
+ client_json=$(jq -n \
+ --arg client_id "$client_id" \
+ --arg client_secret "${client_secret:-}" \
+ '{client_id: $client_id, client_secret: $client_secret}')
+ save_client "$client_json"
+}
+
+_load_client() {
+ # Use multi-origin aware load_client from config.sh
+ local client_data
+ client_data=$(load_client)
+
+ client_id=$(echo "$client_data" | jq -r '.client_id // empty')
+ client_secret=$(echo "$client_data" | jq -r '.client_secret // ""')
+
+ # Only client_id is required (public clients may not have secret)
+ [[ -n "$client_id" ]]
+}
+
+_generate_code_verifier() {
+ # Generate random 43-128 character string for PKCE (RFC 7636)
+ # Use extra bytes to ensure we have enough after removing invalid chars
+ # Valid chars: [A-Za-z0-9._~-]
+ local verifier
+ while true; do
+ verifier=$(openssl rand -base64 48 | tr '+/' '-_' | tr -d '=' | cut -c1-43)
+ if [[ ${#verifier} -ge 43 ]]; then
+ echo "$verifier"
+ return
+ fi
+ done
+}
+
+_generate_code_challenge() {
+ local verifier="$1"
+ # S256: BASE64URL(SHA256(verifier))
+ echo -n "$verifier" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '='
+}
+
+_generate_state() {
+ openssl rand -hex 16
+}
+
+_open_browser() {
+ local url="$1"
+
+ case "$(uname -s)" in
+ Darwin) open "$url" ;;
+ Linux)
+ if command -v xdg-open &>/dev/null; then
+ xdg-open "$url"
+ elif command -v gnome-open &>/dev/null; then
+ gnome-open "$url"
+ else
+ warn "Could not open browser automatically"
+ fi
+ ;;
+ MINGW*|CYGWIN*) start "$url" ;;
+ *) warn "Could not open browser automatically" ;;
+ esac
+}
+
+_wait_for_callback() {
+ local expected_state="$1"
+ local timeout_secs=120
+
+ # Check dependencies
+ if ! command -v nc &>/dev/null; then
+ die "netcat (nc) is required for OAuth callback" $EXIT_USAGE \
+ "Install: brew install netcat (macOS) or apt install netcat (Linux)"
+ fi
+
+ if ! command -v timeout &>/dev/null && ! command -v gtimeout &>/dev/null; then
+ die "timeout is required for OAuth callback" $EXIT_USAGE \
+ "Install: brew install coreutils (macOS, provides gtimeout)"
+ fi
+
+ local timeout_cmd="timeout"
+ command -v timeout &>/dev/null || timeout_cmd="gtimeout"
+
+ info "Waiting for authorization (timeout: ${timeout_secs}s)..."
+
+ # Create temp file for HTTP response (piping to nc doesn't block on macOS BSD nc)
+ local http_response_file
+ http_response_file=$(mktemp)
+ printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\nAuthorization successful!
You can close this window.
' > "$http_response_file"
+
+ local response exit_code=0
+ response=$("$timeout_cmd" "$timeout_secs" bash -c '
+ response_file="'"$http_response_file"'"
+ port="'"$FIZZY_REDIRECT_PORT"'"
+ request_file=$(mktemp)
+ trap "rm -f $request_file" EXIT
+
+ while true; do
+ # Open response file for reading on fd3
+ exec 3<"$response_file" || exit 1
+
+ # nc -l PORT: listen for one connection
+ # <&3 redirects stdin from fd3 (response file) - nc sends this to client
+ # nc outputs what client sends (the HTTP request) to stdout
+ # Capture to file to avoid SIGPIPE from head -1 killing nc before response is sent
+ nc -l "$port" <&3 > "$request_file" 2>/dev/null
+
+ # Close the file descriptor
+ exec 3<&-
+
+ # Read the first line (HTTP request line) from captured output
+ request=$(head -1 "$request_file")
+
+ if [[ "$request" == *"GET /callback"* ]]; then
+ echo "$request"
+ break
+ fi
+
+ # Small delay before retry to avoid tight loop
+ sleep 0.1
+ done
+ ') || exit_code=$?
+
+ rm -f "$http_response_file"
+
+ if [[ -z "$response" ]] || [[ $exit_code -ne 0 ]]; then
+ die "Authorization timed out" $EXIT_AUTH
+ fi
+
+ # Parse callback URL
+ local query_string
+ query_string=$(echo "$response" | sed -n 's/.*GET \/callback?\([^ ]*\).*/\1/p')
+
+ local code state
+ code=$(echo "$query_string" | tr '&' '\n' | grep '^code=' | cut -d= -f2)
+ state=$(echo "$query_string" | tr '&' '\n' | grep '^state=' | cut -d= -f2)
+
+ # URL decode the code (may contain encoded characters)
+ code=$(printf '%b' "${code//%/\\x}")
+
+ debug "Received auth code: $code"
+ debug "Received state: $state"
+
+ if [[ "$state" != "$expected_state" ]]; then
+ die "State mismatch - possible CSRF attack" $EXIT_AUTH
+ fi
+
+ if [[ -z "$code" ]]; then
+ local error
+ error=$(echo "$query_string" | tr '&' '\n' | grep '^error=' | cut -d= -f2)
+ die "Authorization failed: ${error:-unknown error}" $EXIT_AUTH
+ fi
+
+ echo "$code"
+}
+
+_exchange_code() {
+ local code="$1"
+ local code_verifier="$2"
+ local client_id="$3"
+ local client_secret="$4"
+
+ local token_endpoint
+ token_endpoint=$(_token_endpoint)
+
+ debug "Exchanging code at: $token_endpoint"
+ debug "Code verifier: $code_verifier"
+ debug "Code verifier length: ${#code_verifier}"
+
+ # Build curl args - only include client_secret for confidential clients
+ local curl_args=(
+ -s -X POST
+ -H "Content-Type: application/x-www-form-urlencoded"
+ --data-urlencode "grant_type=authorization_code"
+ --data-urlencode "code=$code"
+ --data-urlencode "redirect_uri=$FIZZY_REDIRECT_URI"
+ --data-urlencode "client_id=$client_id"
+ --data-urlencode "code_verifier=$code_verifier"
+ )
+
+ # Only include client_secret for confidential clients (non-empty secret)
+ if [[ -n "$client_secret" ]]; then
+ curl_args+=(--data-urlencode "client_secret=$client_secret")
+ fi
+
+ local response
+ response=$(curl "${curl_args[@]}" "$token_endpoint")
+
+ local access_token refresh_token expires_in scope
+ access_token=$(echo "$response" | jq -r '.access_token // empty')
+ refresh_token=$(echo "$response" | jq -r '.refresh_token // empty')
+ expires_in=$(echo "$response" | jq -r '.expires_in // empty')
+ scope=$(echo "$response" | jq -r '.scope // empty')
+
+ if [[ -z "$access_token" ]]; then
+ debug "Token response: $response"
+ return 1
+ fi
+
+ # Build credentials - only include expires_at if server provided expires_in
+ # Fizzy issues long-lived tokens without expiration
+ local creds
+ if [[ -n "$expires_in" ]]; then
+ local expires_at
+ expires_at=$(($(date +%s) + expires_in))
+ creds=$(jq -n \
+ --arg access_token "$access_token" \
+ --arg refresh_token "$refresh_token" \
+ --argjson expires_at "$expires_at" \
+ --arg scope "$scope" \
+ '{access_token: $access_token, refresh_token: $refresh_token, expires_at: $expires_at, scope: $scope}')
+ else
+ # No expiration - token is long-lived
+ creds=$(jq -n \
+ --arg access_token "$access_token" \
+ --arg refresh_token "$refresh_token" \
+ --arg scope "$scope" \
+ '{access_token: $access_token, refresh_token: $refresh_token, expires_at: null, scope: $scope}')
+ fi
+
+ save_credentials "$creds"
+}
+
+_discover_accounts() {
+ local token
+ token=$(get_access_token) || return 1
+
+ # Fizzy uses /my/identity to return accounts the user has access to
+ local identity_endpoint="$FIZZY_BASE_URL/my/identity"
+
+ debug "Fetching identity from: $identity_endpoint"
+
+ local response http_code
+ response=$(curl -s -w '\n%{http_code}' \
+ -H "Authorization: Bearer $token" \
+ -H "User-Agent: $FIZZY_USER_AGENT" \
+ -H "Accept: application/json" \
+ "$identity_endpoint")
+
+ http_code=$(echo "$response" | tail -n1)
+ response=$(echo "$response" | sed '$d')
+
+ if [[ "$http_code" != "200" ]]; then
+ debug "Identity fetch failed (HTTP $http_code): $response"
+ return 1
+ fi
+
+ local accounts
+ # Extract accounts from identity response; slug is the URL prefix (e.g., "/897362094")
+ accounts=$(echo "$response" | jq '[.accounts[] | {id: .id, name: .name, slug: .slug, user: .user}]')
+
+ if [[ "$accounts" != "[]" ]] && [[ "$accounts" != "null" ]]; then
+ save_accounts "$accounts"
+ return 0
+ fi
+
+ return 1
+}
+
+_select_account() {
+ local accounts
+ accounts=$(load_accounts)
+
+ local count
+ count=$(echo "$accounts" | jq 'length')
+
+ if [[ "$count" -eq 0 ]]; then
+ warn "No Fizzy accounts found"
+ return
+ fi
+
+ local account_slug=""
+
+ if [[ "$count" -eq 1 ]]; then
+ local account_name
+ # Extract slug without leading slash
+ account_slug=$(echo "$accounts" | jq -r '.[0].slug | ltrimstr("/")')
+ account_name=$(echo "$accounts" | jq -r '.[0].name')
+ info "Selected account: $account_name ($account_slug)"
+ elif [[ "$count" -gt 1 ]]; then
+ # Multiple accounts - let user choose
+ echo "Multiple Fizzy accounts found:"
+ echo
+ echo "$accounts" | jq -r 'to_entries | .[] | " \(.key + 1). \(.value.name) (\(.value.slug | ltrimstr("/")))"'
+ echo
+
+ local choice
+ read -rp "Select account (1-$count): " choice
+
+ if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= count )); then
+ local account_name
+ account_slug=$(echo "$accounts" | jq -r ".[$((choice - 1))].slug | ltrimstr(\"/\")")
+ account_name=$(echo "$accounts" | jq -r ".[$((choice - 1))].name")
+ info "Selected account: $account_name ($account_slug)"
+ else
+ warn "Invalid choice, using first account"
+ account_slug=$(echo "$accounts" | jq -r '.[0].slug | ltrimstr("/")')
+ fi
+ fi
+
+ if [[ -n "$account_slug" ]]; then
+ # Store in per-origin credentials (primary) - ensures switching origins works
+ set_credential_account_slug "$account_slug"
+ # Also store in global config for backwards compatibility
+ set_global_config "account_slug" "$account_slug"
+ fi
+
+ # Save the base URL so fizzy knows which server to talk to
+ # This ensures tokens obtained from dev servers talk to dev servers
+ set_global_config "base_url" "$FIZZY_BASE_URL"
+ debug "Saved base URL: $FIZZY_BASE_URL"
+}
+
+
+# Help
+
+_help_auth() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy auth",
+ description: "Manage authentication",
+ subcommands: [
+ {name: "login", description: "Authenticate with Fizzy via OAuth 2.1"},
+ {name: "logout", description: "Remove stored credentials"},
+ {name: "status", description: "Show current authentication status"},
+ {name: "refresh", description: "Check token status (Fizzy uses long-lived tokens)"}
+ ],
+ login_options: [
+ {flag: "--scope", values: ["write", "read"], description: "Token scope (default: write)"},
+ {flag: "--no-browser", description: "Manual auth code entry mode"}
+ ],
+ environment: [
+ {name: "FIZZY_TOKEN", description: "Access token (takes precedence over stored credentials)"},
+ {name: "FIZZY_ACCOUNT_SLUG", description: "Account slug from URL (e.g., 897362094)"}
+ ],
+ precedence: {
+ description: "When both FIZZY_TOKEN and stored OAuth credentials exist",
+ rules: [
+ "FIZZY_TOKEN is used for all API requests",
+ "Stored credentials are ignored (not deleted)",
+ "logout clears stored creds but warns about FIZZY_TOKEN",
+ "refresh errors (env tokens are externally managed)",
+ "Unset FIZZY_TOKEN to fall back to stored credentials"
+ ]
+ },
+ notes: ["Fizzy issues long-lived access tokens that do not expire"]
+ }'
+ else
+ cat <<'EOF'
+## fizzy auth
+
+Manage authentication.
+
+### Subcommands
+
+- `login` - Authenticate with Fizzy via OAuth 2.1
+- `logout` - Remove stored credentials
+- `status` - Show current authentication status
+- `refresh` - Check token status (Fizzy uses long-lived tokens)
+
+### Login Options
+
+- `--scope write|read` - Token scope (default: write)
+- `--no-browser` - Manual auth code entry mode
+
+### Environment Variables
+
+ FIZZY_TOKEN Access token (takes precedence over stored credentials)
+ FIZZY_ACCOUNT_SLUG Account slug from URL (e.g., 897362094)
+
+### Precedence
+
+When both `FIZZY_TOKEN` and stored OAuth credentials exist:
+
+1. `FIZZY_TOKEN` is used for all API requests
+2. Stored credentials are ignored (not deleted)
+3. `fizzy auth logout` clears stored credentials but warns about FIZZY_TOKEN
+4. `fizzy auth refresh` errors (env tokens are externally managed)
+5. Unset `FIZZY_TOKEN` to fall back to stored credentials
+
+### Notes
+
+Fizzy issues long-lived access tokens that do not expire. You only need
+to re-authenticate if you explicitly logout or revoke your token.
+
+### Examples
+
+```bash
+# Interactive login (opens browser)
+fizzy auth login
+
+# Headless/SSH login
+fizzy auth login --no-browser
+
+# Read-only access
+fizzy auth login --scope read
+
+# For CI/scripts: use env vars
+export FIZZY_TOKEN=fzt_...
+export FIZZY_ACCOUNT_SLUG=897362094
+
+# Check status (shows which auth method is active)
+fizzy auth status
+```
+EOF
+ fi
+}
diff --git a/cli/lib/commands/actions.sh b/cli/lib/commands/actions.sh
new file mode 100644
index 0000000000..74d07c02ec
--- /dev/null
+++ b/cli/lib/commands/actions.sh
@@ -0,0 +1,3270 @@
+#!/usr/bin/env bash
+# actions.sh - Card action commands (Phase 3)
+
+
+# fizzy card "title" [options] - create card
+# fizzy card update [options] - update card
+# fizzy card delete [nums...] - delete card(s)
+# fizzy card image delete - remove header image
+
+cmd_card() {
+ case "${1:-}" in
+ update)
+ shift
+ _card_update "$@"
+ ;;
+ delete)
+ shift
+ _card_delete "$@"
+ ;;
+ image)
+ shift
+ _card_image "$@"
+ ;;
+ *)
+ # Default: create card
+ _card_create "$@"
+ ;;
+ esac
+}
+
+_card_image() {
+ case "${1:-}" in
+ delete)
+ shift
+ _card_image_delete "$@"
+ ;;
+ --help|-h|"")
+ _card_image_help
+ ;;
+ *)
+ die "Unknown subcommand: card image $1" $EXIT_USAGE \
+ "Available: fizzy card image delete "
+ ;;
+ esac
+}
+
+_card_image_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy card image",
+ description: "Manage card header images",
+ subcommands: [
+ {name: "delete", description: "Remove header image from a card"}
+ ]
+ }'
+ else
+ cat <<'EOF'
+## fizzy card image
+
+Manage card header images.
+
+### Subcommands
+
+ delete Remove header image from a card
+
+### Examples
+
+ fizzy card image delete 123 Remove image from card #123
+EOF
+ fi
+}
+
+_card_create() {
+ local title=""
+ local description=""
+ local board_id=""
+ local column_id=""
+ local image_path=""
+ local tag_names=()
+ local assignee_ids=()
+ local show_help=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --board|-b|--in)
+ if [[ -z "${2:-}" ]]; then
+ die "--board requires a board name or ID" $EXIT_USAGE
+ fi
+ board_id="$2"
+ shift 2
+ ;;
+ --column|-c)
+ if [[ -z "${2:-}" ]]; then
+ die "--column requires a column name or ID" $EXIT_USAGE
+ fi
+ column_id="$2"
+ shift 2
+ ;;
+ --description|-d)
+ if [[ -z "${2:-}" ]]; then
+ die "--description requires text" $EXIT_USAGE
+ fi
+ description="$2"
+ shift 2
+ ;;
+ --image|-i)
+ if [[ -z "${2:-}" ]]; then
+ die "--image requires a file path" $EXIT_USAGE
+ fi
+ image_path="$2"
+ shift 2
+ ;;
+ --tag)
+ if [[ -z "${2:-}" ]]; then
+ die "--tag requires a tag name" $EXIT_USAGE
+ fi
+ # Store tag name (strip leading # if present) for taggings API
+ tag_names+=("${2#\#}")
+ shift 2
+ ;;
+ --assign)
+ if [[ -z "${2:-}" ]]; then
+ die "--assign requires a user name or ID" $EXIT_USAGE
+ fi
+ assignee_ids+=("$2")
+ shift 2
+ ;;
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy card --help"
+ ;;
+ *)
+ # First positional arg is title
+ if [[ -z "$title" ]]; then
+ title="$1"
+ shift
+ else
+ die "Unexpected argument: $1" $EXIT_USAGE
+ fi
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _card_create_help
+ return 0
+ fi
+
+ if [[ -z "$title" ]]; then
+ die "Card title required" $EXIT_USAGE "Usage: fizzy card \"title\""
+ fi
+
+ # Validate image file exists
+ if [[ -n "$image_path" ]] && [[ ! -f "$image_path" ]]; then
+ die "File not found: $image_path" $EXIT_USAGE
+ fi
+
+ # Use board from config if not specified
+ if [[ -z "$board_id" ]]; then
+ board_id=$(get_board_id 2>/dev/null || true)
+ fi
+
+ if [[ -z "$board_id" ]]; then
+ die "No board specified. Use --board or set in .fizzy/config.json" $EXIT_USAGE
+ fi
+
+ # Resolve board name to ID
+ local resolved_board
+ if resolved_board=$(resolve_board_id "$board_id"); then
+ board_id="$resolved_board"
+ else
+ die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy boards"
+ fi
+
+ # Resolve column name to ID if provided (for triage follow-up)
+ if [[ -n "$column_id" ]]; then
+ local resolved_column
+ if resolved_column=$(resolve_column_id "$column_id" "$board_id"); then
+ column_id="$resolved_column"
+ else
+ die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy columns --board $board_id"
+ fi
+ fi
+
+ # Resolve assignee names to IDs (for assignment follow-up)
+ local resolved_assignee_ids=()
+ for assignee in "${assignee_ids[@]}"; do
+ local resolved_user
+ if resolved_user=$(resolve_user_id "$assignee"); then
+ resolved_assignee_ids+=("$resolved_user")
+ else
+ die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy people"
+ fi
+ done
+ assignee_ids=("${resolved_assignee_ids[@]}")
+
+ local response
+
+ if [[ -n "$image_path" ]]; then
+ # Multipart POST for card with image
+ local account_slug
+ account_slug=$(get_account_slug)
+ if [[ -z "$account_slug" ]]; then
+ die "Account not configured" $EXIT_USAGE \
+ "Run: fizzy config set account_slug "
+ fi
+
+ local token
+ token=$(ensure_auth)
+
+ local curl_args=(
+ -s -w '\n%{http_code}'
+ -X POST
+ -H "Authorization: Bearer $token"
+ -H "User-Agent: $FIZZY_USER_AGENT"
+ -H "Accept: application/json"
+ --form-string "card[title]=$title"
+ )
+
+ if [[ -n "$description" ]]; then
+ curl_args+=(--form-string "card[description]=$description")
+ fi
+ curl_args+=(-F "card[image]=@$image_path")
+ curl_args+=("$FIZZY_BASE_URL/$account_slug/boards/$board_id/cards.json")
+
+ local http_code
+ api_multipart_request http_code response "${curl_args[@]}"
+
+ case "$http_code" in
+ 200|201) ;;
+ 401|403)
+ die "Not authorized to create card" $EXIT_FORBIDDEN
+ ;;
+ 404)
+ die "Board not found" $EXIT_NOT_FOUND
+ ;;
+ 422)
+ die "Validation error" $EXIT_API "Check the provided values"
+ ;;
+ *)
+ die "API error: HTTP $http_code" $EXIT_API
+ ;;
+ esac
+ else
+ # JSON POST for card without image (uses api_post with retry/auth)
+ local body
+ body=$(jq -n \
+ --arg title "$title" \
+ --arg description "${description:-}" \
+ '{title: $title} +
+ (if $description != "" then {description: $description} else {} end)')
+
+ response=$(api_post "/boards/$board_id/cards" "$body")
+ fi
+
+ local number
+ number=$(echo "$response" | jq -r '.number')
+
+ # Chain follow-up actions after card creation
+ local actions_taken=()
+
+ # Triage to column if specified
+ if [[ -n "$column_id" ]]; then
+ local triage_body
+ triage_body=$(jq -n --arg column_id "$column_id" '{column_id: $column_id}')
+ api_post "/cards/$number/triage" "$triage_body" > /dev/null
+ actions_taken+=("triaged")
+ fi
+
+ # Add tags if specified
+ for tag_name in "${tag_names[@]}"; do
+ local tag_body
+ tag_body=$(jq -n --arg tag_title "$tag_name" '{tag_title: $tag_title}')
+ api_post "/cards/$number/taggings" "$tag_body" > /dev/null
+ actions_taken+=("tagged #$tag_name")
+ done
+
+ # Add assignments if specified
+ for assignee_id in "${assignee_ids[@]}"; do
+ local assign_body
+ assign_body=$(jq -n --arg assignee_id "$assignee_id" '{assignee_id: $assignee_id}')
+ api_post "/cards/$number/assignments" "$assign_body" > /dev/null
+ actions_taken+=("assigned")
+ done
+
+ # Fetch updated card if we made any follow-up changes
+ if [[ ${#actions_taken[@]} -gt 0 ]]; then
+ response=$(api_get "/cards/$number")
+ fi
+
+ local summary="Created card #$number"
+ if [[ ${#actions_taken[@]} -gt 0 ]]; then
+ summary="Created card #$number (${actions_taken[*]})"
+ fi
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "show" "fizzy show $number" "View card details")" \
+ "$(breadcrumb "triage" "fizzy triage $number --to " "Move to column")" \
+ "$(breadcrumb "comment" "fizzy comment \"text\" --on $number" "Add comment")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_card_created_md"
+}
+
+_card_created_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local number title board_name
+ number=$(echo "$data" | jq -r '.number')
+ title=$(echo "$data" | jq -r '.title // .description[0:50]')
+ board_name=$(echo "$data" | jq -r '.board.name')
+
+ md_heading 2 "Card Created"
+ echo
+ md_kv "Number" "#$number" \
+ "Title" "$title" \
+ "Board" "$board_name"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_card_create_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy card",
+ description: "Create a new card with optional follow-up actions",
+ usage: "fizzy card \"title\" [options]",
+ options: [
+ {flag: "--board, -b, --in", description: "Board name or ID (required if not in config)"},
+ {flag: "--column, -c", description: "Column name or ID (chains triage after create)"},
+ {flag: "--description, -d", description: "Card description"},
+ {flag: "--image, -i", description: "Path to header image file"},
+ {flag: "--tag", description: "Tag name (chains tagging after create, repeatable)"},
+ {flag: "--assign", description: "Assignee name/email/ID (chains assignment after create, repeatable)"}
+ ],
+ notes: "--column, --tag, and --assign execute follow-up API calls after card creation",
+ examples: [
+ "fizzy card \"Fix login bug\"",
+ "fizzy card \"New feature\" --board \"My Board\" --column \"In Progress\"",
+ "fizzy card \"Task\" --tag bug --assign \"Jane Doe\""
+ ]
+ }'
+ else
+ cat <<'EOF'
+## fizzy card
+
+Create a new card with optional follow-up actions.
+
+### Usage
+
+ fizzy card "title" [options]
+
+### Options
+
+ --board, -b, --in Board name or ID (required if not in config)
+ --column, -c Column name or ID (chains triage after create)
+ --description, -d Card description
+ --image, -i Path to header image file
+ --tag Tag name (chains tagging, repeatable)
+ --assign Assignee name/email/ID (chains assignment, repeatable)
+ --help, -h Show this help
+
+Note: --column, --tag, and --assign trigger follow-up API calls after the
+card is created, since the create endpoint only accepts title and description.
+
+### Examples
+
+ fizzy card "Fix login bug"
+ fizzy card "New feature" --board "My Board" --column "In Progress"
+ fizzy card "Task" --tag bug --tag urgent --assign "Jane Doe"
+EOF
+ fi
+}
+
+
+# fizzy card update [options]
+# Update a card's title, description, or image
+
+_card_update() {
+ local card_number=""
+ local title=""
+ local description=""
+ local description_file=""
+ local image_path=""
+ local show_help=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --title|-t)
+ if [[ -z "${2:-}" ]]; then
+ die "--title requires a value" $EXIT_USAGE
+ fi
+ title="$2"
+ shift 2
+ ;;
+ --description|-d)
+ if [[ -z "${2:-}" ]]; then
+ die "--description requires text" $EXIT_USAGE
+ fi
+ description="$2"
+ shift 2
+ ;;
+ --description-file)
+ if [[ -z "${2:-}" ]]; then
+ die "--description-file requires a file path" $EXIT_USAGE
+ fi
+ description_file="$2"
+ shift 2
+ ;;
+ --image|-i)
+ if [[ -z "${2:-}" ]]; then
+ die "--image requires a file path" $EXIT_USAGE
+ fi
+ image_path="$2"
+ shift 2
+ ;;
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy card update --help"
+ ;;
+ *)
+ # First positional arg is card number
+ if [[ -z "$card_number" ]]; then
+ card_number="$1"
+ shift
+ else
+ die "Unexpected argument: $1" $EXIT_USAGE
+ fi
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _card_update_help
+ return 0
+ fi
+
+ if [[ -z "$card_number" ]]; then
+ die "Card number required" $EXIT_USAGE "Usage: fizzy card update [options]"
+ fi
+
+ # Read description from file if specified
+ if [[ -n "$description_file" ]]; then
+ if [[ ! -f "$description_file" ]]; then
+ die "File not found: $description_file" $EXIT_USAGE
+ fi
+ description=$(cat "$description_file")
+ fi
+
+ # Validate image file exists
+ if [[ -n "$image_path" ]] && [[ ! -f "$image_path" ]]; then
+ die "File not found: $image_path" $EXIT_USAGE
+ fi
+
+ # Must specify at least one thing to update
+ if [[ -z "$title" && -z "$description" && -z "$image_path" ]]; then
+ die "Nothing to update. Specify --title, --description, or --image" $EXIT_USAGE
+ fi
+
+ local response http_code
+
+ if [[ -n "$image_path" ]]; then
+ # Multipart upload for image requires account_slug
+ local account_slug
+ account_slug=$(get_account_slug)
+ if [[ -z "$account_slug" ]]; then
+ die "Account not configured" $EXIT_USAGE \
+ "Run: fizzy config set account_slug "
+ fi
+
+ local token
+ token=$(ensure_auth)
+
+ local curl_args=(
+ -s -w '\n%{http_code}'
+ -X PATCH
+ -H "Authorization: Bearer $token"
+ -H "User-Agent: $FIZZY_USER_AGENT"
+ -H "Accept: application/json"
+ )
+
+ if [[ -n "$title" ]]; then
+ curl_args+=(--form-string "card[title]=$title")
+ fi
+ if [[ -n "$description" ]]; then
+ curl_args+=(--form-string "card[description]=$description")
+ fi
+ curl_args+=(-F "card[image]=@$image_path")
+ curl_args+=("$FIZZY_BASE_URL/$account_slug/cards/$card_number.json")
+
+ api_multipart_request http_code response "${curl_args[@]}"
+
+ case "$http_code" in
+ 200) ;;
+ 401|403)
+ die "Not authorized to update this card" $EXIT_FORBIDDEN
+ ;;
+ 404)
+ die "Card not found: #$card_number" $EXIT_NOT_FOUND
+ ;;
+ 422)
+ die "Validation error" $EXIT_API "Check the provided values"
+ ;;
+ *)
+ die "API error: HTTP $http_code" $EXIT_API
+ ;;
+ esac
+ else
+ # JSON update for title/description only (api_patch handles auth)
+ local body
+ body=$(jq -n \
+ --arg title "$title" \
+ --arg description "$description" \
+ '{card: ((if $title != "" then {title: $title} else {} end) +
+ (if $description != "" then {description: $description} else {} end))}')
+
+ response=$(api_patch "/cards/$card_number" "$body")
+ fi
+
+ local summary="Card #$card_number updated"
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "show" "fizzy show $card_number" "View card details")" \
+ "$(breadcrumb "triage" "fizzy triage $card_number --to " "Move to column")" \
+ "$(breadcrumb "comment" "fizzy comment \"text\" --on $card_number" "Add comment")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_card_updated_md"
+}
+
+_card_updated_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local number title board_name
+ number=$(echo "$data" | jq -r '.number')
+ title=$(echo "$data" | jq -r '.title // .description[0:50]')
+ board_name=$(echo "$data" | jq -r '.board.name')
+
+ md_heading 2 "Card Updated"
+ echo
+ md_kv "Number" "#$number" \
+ "Title" "$title" \
+ "Board" "$board_name"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_card_update_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy card update",
+ description: "Update a card'\''s title, description, or image",
+ usage: "fizzy card update [options]",
+ options: [
+ {flag: "--title, -t", description: "New card title"},
+ {flag: "--description, -d", description: "New card description (HTML)"},
+ {flag: "--description-file", description: "Read description from file"},
+ {flag: "--image, -i", description: "Path to header image file"}
+ ],
+ examples: [
+ "fizzy card update 123 --title \"New title\"",
+ "fizzy card update 123 --description \"Updated content
\"",
+ "fizzy card update 123 --image ~/header.png"
+ ]
+ }'
+ else
+ cat <<'EOF'
+## fizzy card update
+
+Update a card's title, description, or header image.
+
+### Usage
+
+ fizzy card update [options]
+
+### Options
+
+ --title, -t New card title
+ --description, -d New card description (HTML)
+ --description-file Read description from file
+ --image, -i Path to header image file
+ --help, -h Show this help
+
+### Examples
+
+ fizzy card update 123 --title "New title"
+ fizzy card update 123 --description "Updated content
"
+ fizzy card update 123 --image ~/header.png
+ fizzy card update 123 --title "New" --image cover.jpg
+EOF
+ fi
+}
+
+
+# fizzy close
+# Close a card
+
+cmd_close() {
+ local show_help=false
+ local card_numbers=()
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy close --help"
+ ;;
+ *)
+ card_numbers+=("$1")
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _close_help
+ return 0
+ fi
+
+ if [[ ${#card_numbers[@]} -eq 0 ]]; then
+ die "Card number required" $EXIT_USAGE "Usage: fizzy close [numbers...]"
+ fi
+
+ local results=()
+ local num
+ for num in "${card_numbers[@]}"; do
+ # POST closure returns 204 No Content, so fetch card after
+ api_post "/cards/$num/closure" > /dev/null
+ local response
+ response=$(api_get "/cards/$num")
+ results+=("$(echo "$response" | jq '{number: .number, title: .title, closed: .closed}')")
+ done
+
+ local response_data
+ if [[ ${#results[@]} -eq 1 ]]; then
+ response_data="${results[0]}"
+ local summary="Closed card #${card_numbers[0]}"
+ else
+ response_data=$(printf '%s\n' "${results[@]}" | jq -s '.')
+ local summary="Closed ${#card_numbers[@]} cards"
+ fi
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "reopen" "fizzy reopen ${card_numbers[0]}" "Reopen card")" \
+ "$(breadcrumb "show" "fizzy show ${card_numbers[0]}" "View card")"
+ )
+
+ output "$response_data" "$summary" "$breadcrumbs" "_close_md"
+}
+
+_close_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ md_heading 2 "Card Closed"
+ echo "*$summary*"
+ echo
+
+ # Check if single card or array
+ local is_array
+ is_array=$(echo "$data" | jq 'if type == "array" then "yes" else "no" end' -r)
+
+ if [[ "$is_array" == "yes" ]]; then
+ echo "| # | Title | Status |"
+ echo "|---|-------|--------|"
+ echo "$data" | jq -r '.[] | "| #\(.number) | \(.title // "-")[0:40] | Closed |"'
+ else
+ local number title
+ number=$(echo "$data" | jq -r '.number')
+ title=$(echo "$data" | jq -r '.title // "-"')
+ md_kv "Card" "#$number" "Title" "$title" "Status" "Closed"
+ fi
+
+ echo
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_close_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy close",
+ description: "Close card(s)",
+ usage: "fizzy close [numbers...]",
+ examples: ["fizzy close 123", "fizzy close 123 124 125"]
+ }'
+ else
+ cat <<'EOF'
+## fizzy close
+
+Close card(s).
+
+### Usage
+
+ fizzy close [numbers...]
+
+### Examples
+
+ fizzy close 123 Close card #123
+ fizzy close 123 124 125 Close multiple cards
+EOF
+ fi
+}
+
+
+# fizzy reopen
+# Reopen a closed card
+
+cmd_reopen() {
+ local show_help=false
+ local card_numbers=()
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy reopen --help"
+ ;;
+ *)
+ card_numbers+=("$1")
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _reopen_help
+ return 0
+ fi
+
+ if [[ ${#card_numbers[@]} -eq 0 ]]; then
+ die "Card number required" $EXIT_USAGE "Usage: fizzy reopen "
+ fi
+
+ local results=()
+ local num
+ for num in "${card_numbers[@]}"; do
+ # DELETE closure returns 204 No Content, so fetch card after
+ api_delete "/cards/$num/closure" > /dev/null
+ local response
+ response=$(api_get "/cards/$num")
+ results+=("$(echo "$response" | jq '{number: .number, title: .title, closed: .closed}')")
+ done
+
+ local response_data
+ if [[ ${#results[@]} -eq 1 ]]; then
+ response_data="${results[0]}"
+ local summary="Reopened card #${card_numbers[0]}"
+ else
+ response_data=$(printf '%s\n' "${results[@]}" | jq -s '.')
+ local summary="Reopened ${#card_numbers[@]} cards"
+ fi
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "close" "fizzy close ${card_numbers[0]}" "Close card")" \
+ "$(breadcrumb "show" "fizzy show ${card_numbers[0]}" "View card")" \
+ "$(breadcrumb "triage" "fizzy triage ${card_numbers[0]} --to " "Move to column")"
+ )
+
+ output "$response_data" "$summary" "$breadcrumbs" "_reopen_md"
+}
+
+_reopen_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ md_heading 2 "Card Reopened"
+ echo "*$summary*"
+ echo
+
+ local is_array
+ is_array=$(echo "$data" | jq 'if type == "array" then "yes" else "no" end' -r)
+
+ if [[ "$is_array" == "yes" ]]; then
+ echo "| # | Title | Status |"
+ echo "|---|-------|--------|"
+ echo "$data" | jq -r '.[] | "| #\(.number) | \(.title // "-")[0:40] | Active |"'
+ else
+ local number title
+ number=$(echo "$data" | jq -r '.number')
+ title=$(echo "$data" | jq -r '.title // "-"')
+ md_kv "Card" "#$number" "Title" "$title" "Status" "Active"
+ fi
+
+ echo
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_reopen_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy reopen",
+ description: "Reopen closed card(s)",
+ usage: "fizzy reopen [numbers...]",
+ examples: ["fizzy reopen 123", "fizzy reopen 123 124"]
+ }'
+ else
+ cat <<'EOF'
+## fizzy reopen
+
+Reopen closed card(s).
+
+### Usage
+
+ fizzy reopen [numbers...]
+
+### Examples
+
+ fizzy reopen 123 Reopen card #123
+ fizzy reopen 123 124 Reopen multiple cards
+EOF
+ fi
+}
+
+
+# fizzy card delete
+# Permanently delete a card
+
+_card_delete() {
+ local show_help=false
+ local card_numbers=()
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy card delete --help"
+ ;;
+ *)
+ card_numbers+=("$1")
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _card_delete_help
+ return 0
+ fi
+
+ if [[ ${#card_numbers[@]} -eq 0 ]]; then
+ die "Card number required" $EXIT_USAGE "Usage: fizzy card delete "
+ fi
+
+ # Validate all card numbers are positive integers (1+)
+ local num
+ for num in "${card_numbers[@]}"; do
+ if ! [[ "$num" =~ ^[1-9][0-9]*$ ]]; then
+ die "Invalid card number: $num" $EXIT_USAGE "Card numbers must be positive integers"
+ fi
+ done
+
+ local results=()
+ for num in "${card_numbers[@]}"; do
+ # DELETE returns 204 No Content
+ api_delete "/cards/$num" > /dev/null
+ results+=("$(jq -n --arg num "$num" '{number: ($num | tonumber), deleted: true}')")
+ done
+
+ local response_data
+ if [[ ${#results[@]} -eq 1 ]]; then
+ response_data="${results[0]}"
+ else
+ response_data=$(printf '%s\n' "${results[@]}" | jq -s '.')
+ fi
+
+ local summary
+ if [[ ${#card_numbers[@]} -eq 1 ]]; then
+ summary="Deleted card #${card_numbers[0]}"
+ else
+ summary="Deleted ${#card_numbers[@]} cards"
+ fi
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "cards" "fizzy cards" "List cards")" \
+ "$(breadcrumb "card" "fizzy card \"title\" --in " "Create new card")"
+ )
+
+ output "$response_data" "$summary" "$breadcrumbs" "_card_delete_md"
+}
+
+_card_delete_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ md_heading 2 "Card Deleted"
+ echo
+
+ # Single or multiple?
+ if echo "$data" | jq -e 'type == "array"' > /dev/null 2>&1; then
+ echo "| # | Status |"
+ echo "|---|--------|"
+ echo "$data" | jq -r '.[] | "| #\(.number) | Deleted |"'
+ else
+ local card_number
+ card_number=$(echo "$data" | jq -r '.number')
+ md_kv "Card" "#$card_number" \
+ "Status" "Deleted"
+ fi
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_card_delete_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy card delete",
+ description: "Permanently delete card(s)",
+ usage: "fizzy card delete [numbers...]",
+ warning: "This action cannot be undone",
+ examples: ["fizzy card delete 123", "fizzy card delete 123 124"]
+ }'
+ else
+ cat <<'EOF'
+## fizzy card delete
+
+Permanently delete card(s).
+
+**Warning:** This action cannot be undone.
+
+### Usage
+
+ fizzy card delete [numbers...]
+
+### Examples
+
+ fizzy card delete 123 Delete card #123
+ fizzy card delete 123 124 Delete multiple cards
+EOF
+ fi
+}
+
+
+# fizzy card image delete
+# Remove header image from card
+
+_card_image_delete() {
+ local show_help=false
+ local card_number=""
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy card image delete --help"
+ ;;
+ *)
+ if [[ -z "$card_number" ]]; then
+ card_number="$1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _card_image_delete_help
+ return 0
+ fi
+
+ if [[ -z "$card_number" ]]; then
+ die "Card number required" $EXIT_USAGE "Usage: fizzy card image delete "
+ fi
+
+ # DELETE returns 204 No Content, fetch card after for response
+ api_delete "/cards/$card_number/image" > /dev/null
+ local response
+ response=$(api_get "/cards/$card_number")
+
+ local summary="Removed image from card #$card_number"
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "show" "fizzy show $card_number" "View card")" \
+ "$(breadcrumb "update" "fizzy card update $card_number" "Update card")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_card_image_delete_md"
+}
+
+_card_image_delete_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local card_number title image_url
+ card_number=$(echo "$data" | jq -r '.number')
+ title=$(echo "$data" | jq -r '.title')
+ image_url=$(echo "$data" | jq -r '.image_url // "none"')
+
+ md_heading 2 "Image Removed"
+ echo
+ md_kv "Card" "#$card_number" \
+ "Title" "$title" \
+ "Image" "$image_url"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_card_image_delete_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy card image delete",
+ description: "Remove header image from a card",
+ usage: "fizzy card image delete ",
+ examples: ["fizzy card image delete 123"]
+ }'
+ else
+ cat <<'EOF'
+## fizzy card image delete
+
+Remove header image from a card.
+
+### Usage
+
+ fizzy card image delete
+
+### Examples
+
+ fizzy card image delete 123 Remove header image from card #123
+EOF
+ fi
+}
+
+
+# fizzy triage --to
+# Move card to a column
+
+cmd_triage() {
+ local card_number=""
+ local column_id=""
+ local show_help=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --to)
+ if [[ -z "${2:-}" ]]; then
+ die "--to requires a column ID" $EXIT_USAGE
+ fi
+ column_id="$2"
+ shift 2
+ ;;
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy triage --help"
+ ;;
+ *)
+ if [[ -z "$card_number" ]]; then
+ card_number="$1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _triage_help
+ return 0
+ fi
+
+ if [[ -z "$card_number" ]]; then
+ die "Card number required" $EXIT_USAGE "Usage: fizzy triage --to "
+ fi
+
+ if [[ -z "$column_id" ]]; then
+ # Try to get default column from config
+ column_id=$(get_column_id 2>/dev/null || true)
+ if [[ -z "$column_id" ]]; then
+ die "--to column ID required" $EXIT_USAGE "Usage: fizzy triage --to "
+ fi
+ fi
+
+ # Resolve column name to ID if needed
+ # First, try to get board_id from config
+ local board_id
+ board_id=$(get_board_id 2>/dev/null || true)
+ if [[ -n "$board_id" ]]; then
+ local resolved_board
+ if resolved_board=$(resolve_board_id "$board_id"); then
+ board_id="$resolved_board"
+ fi
+ fi
+
+ # If no board context, fetch the card to get its board
+ if [[ -z "$board_id" ]]; then
+ local card_data
+ if card_data=$(api_get "/cards/$card_number" 2>/dev/null); then
+ board_id=$(echo "$card_data" | jq -r '.board.id // empty')
+ fi
+ fi
+
+ # If we have a board context, try to resolve column name
+ if [[ -n "$board_id" ]]; then
+ local resolved_column
+ if resolved_column=$(resolve_column_id "$column_id" "$board_id"); then
+ column_id="$resolved_column"
+ else
+ # Only fail if it looks like a name (not UUID)
+ if [[ ! "$column_id" =~ ^[a-z0-9]{20,}$ ]]; then
+ die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy columns --board $board_id"
+ fi
+ fi
+ else
+ # No board context and column looks like a name - warn user
+ if [[ ! "$column_id" =~ ^[a-z0-9]{20,}$ ]]; then
+ die "Cannot resolve column name without board context" $EXIT_USAGE \
+ "Use column ID directly, or set board context: fizzy config set board_id "
+ fi
+ fi
+
+ local body
+ body=$(jq -n --arg column_id "$column_id" '{column_id: $column_id}')
+
+ # POST triage returns 204 No Content, so fetch card after
+ api_post "/cards/$card_number/triage" "$body" > /dev/null
+ local response
+ response=$(api_get "/cards/$card_number")
+
+ local column_name
+ column_name=$(echo "$response" | jq -r '.column.name // "column"')
+ local summary="Card #$card_number triaged to $column_name"
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "untriage" "fizzy untriage $card_number" "Send back to triage")" \
+ "$(breadcrumb "show" "fizzy show $card_number" "View card")" \
+ "$(breadcrumb "close" "fizzy close $card_number" "Close card")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_triage_md"
+}
+
+_triage_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local number title column_name board_name
+ number=$(echo "$data" | jq -r '.number')
+ title=$(echo "$data" | jq -r '.title // .description[0:40]')
+ column_name=$(echo "$data" | jq -r '.column.name // "Triage"')
+ board_name=$(echo "$data" | jq -r '.board.name')
+
+ md_heading 2 "Card Triaged"
+ echo
+ md_kv "Card" "#$number" \
+ "Title" "$title" \
+ "Board" "$board_name" \
+ "Column" "$column_name"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_triage_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy triage",
+ description: "Move card to a column",
+ usage: "fizzy triage --to ",
+ options: [{flag: "--to", description: "Column name or ID to triage to"}],
+ examples: ["fizzy triage 123 --to \"In Progress\"", "fizzy triage 123 --to abc456"]
+ }'
+ else
+ cat <<'EOF'
+## fizzy triage
+
+Move card to a column.
+
+### Usage
+
+ fizzy triage --to
+
+### Options
+
+ --to Column name or ID to triage to (required)
+ --help, -h Show this help
+
+### Examples
+
+ fizzy triage 123 --to "In Progress" Move by column name
+ fizzy triage 123 --to abc456 Move by column ID
+EOF
+ fi
+}
+
+
+# fizzy untriage
+# Send card back to triage
+
+cmd_untriage() {
+ local card_number=""
+ local show_help=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy untriage --help"
+ ;;
+ *)
+ if [[ -z "$card_number" ]]; then
+ card_number="$1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _untriage_help
+ return 0
+ fi
+
+ if [[ -z "$card_number" ]]; then
+ die "Card number required" $EXIT_USAGE "Usage: fizzy untriage "
+ fi
+
+ # DELETE triage returns 204 No Content, so fetch card after
+ api_delete "/cards/$card_number/triage" > /dev/null
+ local response
+ response=$(api_get "/cards/$card_number")
+
+ local summary="Card #$card_number sent back to triage"
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "triage" "fizzy triage $card_number --to " "Move to column")" \
+ "$(breadcrumb "show" "fizzy show $card_number" "View card")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_untriage_md"
+}
+
+_untriage_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local number title board_name
+ number=$(echo "$data" | jq -r '.number')
+ title=$(echo "$data" | jq -r '.title // .description[0:40]')
+ board_name=$(echo "$data" | jq -r '.board.name')
+
+ md_heading 2 "Card Untriaged"
+ echo
+ md_kv "Card" "#$number" \
+ "Title" "$title" \
+ "Board" "$board_name" \
+ "Status" "Back in Triage"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_untriage_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy untriage",
+ description: "Send card back to triage",
+ usage: "fizzy untriage ",
+ examples: ["fizzy untriage 123"]
+ }'
+ else
+ cat <<'EOF'
+## fizzy untriage
+
+Send card back to triage.
+
+### Usage
+
+ fizzy untriage
+
+### Examples
+
+ fizzy untriage 123 Send card #123 back to triage
+EOF
+ fi
+}
+
+
+# fizzy postpone
+# Move card to "Not Now"
+
+cmd_postpone() {
+ local card_number=""
+ local show_help=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy postpone --help"
+ ;;
+ *)
+ if [[ -z "$card_number" ]]; then
+ card_number="$1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _postpone_help
+ return 0
+ fi
+
+ if [[ -z "$card_number" ]]; then
+ die "Card number required" $EXIT_USAGE "Usage: fizzy postpone "
+ fi
+
+ # POST not_now returns 204 No Content, so fetch card after
+ api_post "/cards/$card_number/not_now" > /dev/null
+ local response
+ response=$(api_get "/cards/$card_number")
+
+ local summary="Card #$card_number moved to Not Now"
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "triage" "fizzy triage $card_number --to " "Move to column")" \
+ "$(breadcrumb "show" "fizzy show $card_number" "View card")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_postpone_md"
+}
+
+_postpone_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local number title board_name
+ number=$(echo "$data" | jq -r '.number')
+ title=$(echo "$data" | jq -r '.title // .description[0:40]')
+ board_name=$(echo "$data" | jq -r '.board.name')
+
+ md_heading 2 "Card Postponed"
+ echo
+ md_kv "Card" "#$number" \
+ "Title" "$title" \
+ "Board" "$board_name" \
+ "Status" "Not Now"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_postpone_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy postpone",
+ description: "Move card to Not Now",
+ usage: "fizzy postpone ",
+ examples: ["fizzy postpone 123"]
+ }'
+ else
+ cat <<'EOF'
+## fizzy postpone
+
+Move card to "Not Now".
+
+### Usage
+
+ fizzy postpone
+
+### Examples
+
+ fizzy postpone 123 Move card #123 to Not Now
+EOF
+ fi
+}
+
+
+# fizzy comment "text" --on
+# fizzy comment edit --on "new text"
+# fizzy comment delete --on
+
+cmd_comment() {
+ # Check for subcommand
+ case "${1:-}" in
+ edit)
+ shift
+ _comment_edit "$@"
+ return
+ ;;
+ delete)
+ shift
+ _comment_delete "$@"
+ return
+ ;;
+ esac
+
+ # Default: create comment
+ local content=""
+ local card_number=""
+ local show_help=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --on)
+ if [[ -z "${2:-}" ]]; then
+ die "--on requires a card number" $EXIT_USAGE
+ fi
+ card_number="$2"
+ shift 2
+ ;;
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy comment --help"
+ ;;
+ *)
+ if [[ -z "$content" ]]; then
+ content="$1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _comment_create_help
+ return 0
+ fi
+
+ if [[ -z "$content" ]]; then
+ die "Comment content required" $EXIT_USAGE "Usage: fizzy comment \"text\" --on "
+ fi
+
+ if [[ -z "$card_number" ]]; then
+ die "--on card number required" $EXIT_USAGE "Usage: fizzy comment \"text\" --on "
+ fi
+
+ local body
+ body=$(jq -n --arg body "$content" '{comment: {body: $body}}')
+
+ local response
+ response=$(api_post "/cards/$card_number/comments" "$body")
+
+ local comment_id
+ comment_id=$(echo "$response" | jq -r '.id')
+ local summary="Comment added to card #$card_number"
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "show" "fizzy show $card_number" "View card")" \
+ "$(breadcrumb "comments" "fizzy comments --on $card_number" "View all comments")" \
+ "$(breadcrumb "react" "fizzy react \"👍\" --comment $comment_id" "Add reaction")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_comment_created_md"
+}
+
+_comment_edit() {
+ local comment_id=""
+ local card_number=""
+ local content=""
+ local show_help=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --on)
+ if [[ -z "${2:-}" ]]; then
+ die "--on requires a card number" $EXIT_USAGE
+ fi
+ card_number="$2"
+ shift 2
+ ;;
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy comment edit --help"
+ ;;
+ *)
+ if [[ -z "$comment_id" ]]; then
+ comment_id="$1"
+ elif [[ -z "$content" ]]; then
+ content="$1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _comment_edit_help
+ return 0
+ fi
+
+ if [[ -z "$comment_id" ]]; then
+ die "Comment ID required" $EXIT_USAGE "Usage: fizzy comment edit --on \"new text\""
+ fi
+
+ if [[ -z "$card_number" ]]; then
+ die "--on card number required" $EXIT_USAGE "Usage: fizzy comment edit --on \"new text\""
+ fi
+
+ if [[ -z "$content" ]]; then
+ die "New comment text required" $EXIT_USAGE "Usage: fizzy comment edit --on \"new text\""
+ fi
+
+ local body
+ body=$(jq -n --arg body "$content" '{comment: {body: $body}}')
+
+ # PATCH returns 204 No Content, so fetch the comment after to show updated state
+ api_patch "/cards/$card_number/comments/$comment_id" "$body" > /dev/null
+ local response
+ response=$(api_get "/cards/$card_number/comments/$comment_id")
+
+ local summary="Comment updated on card #$card_number"
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "show" "fizzy show $card_number" "View card")" \
+ "$(breadcrumb "comments" "fizzy comments --on $card_number" "View all comments")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_comment_edited_md"
+}
+
+_comment_edited_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local comment_id creator_name updated_at
+ comment_id=$(echo "$data" | jq -r '.id')
+ creator_name=$(echo "$data" | jq -r '.creator.name // "You"')
+ updated_at=$(echo "$data" | jq -r '.updated_at | split("T")[0]')
+
+ md_heading 2 "Comment Updated"
+ echo
+ md_kv "ID" "$comment_id" \
+ "Author" "$creator_name" \
+ "Updated" "$updated_at"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_comment_edit_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy comment edit",
+ description: "Update a comment",
+ usage: "fizzy comment edit --on \"new text\"",
+ options: [{flag: "--on", description: "Card number the comment belongs to"}],
+ examples: ["fizzy comment edit abc123 --on 123 \"Updated text\""]
+ }'
+ else
+ cat <<'EOF'
+## fizzy comment edit
+
+Update a comment.
+
+### Usage
+
+ fizzy comment edit --on "new text"
+
+### Options
+
+ --on Card number the comment belongs to (required)
+ --help, -h Show this help
+
+### Examples
+
+ fizzy comment edit abc123 --on 123 "Updated comment text"
+EOF
+ fi
+}
+
+_comment_delete() {
+ local comment_id=""
+ local card_number=""
+ local show_help=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --on)
+ if [[ -z "${2:-}" ]]; then
+ die "--on requires a card number" $EXIT_USAGE
+ fi
+ card_number="$2"
+ shift 2
+ ;;
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy comment delete --help"
+ ;;
+ *)
+ if [[ -z "$comment_id" ]]; then
+ comment_id="$1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _comment_delete_help
+ return 0
+ fi
+
+ if [[ -z "$comment_id" ]]; then
+ die "Comment ID required" $EXIT_USAGE "Usage: fizzy comment delete --on "
+ fi
+
+ if [[ -z "$card_number" ]]; then
+ die "--on card number required" $EXIT_USAGE "Usage: fizzy comment delete --on "
+ fi
+
+ # DELETE returns 204 No Content
+ api_delete "/cards/$card_number/comments/$comment_id" > /dev/null
+
+ local response
+ response=$(jq -n --arg comment_id "$comment_id" --arg card_number "$card_number" \
+ '{deleted: true, comment_id: $comment_id, card_number: $card_number}')
+
+ local summary="Comment deleted from card #$card_number"
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "show" "fizzy show $card_number" "View card")" \
+ "$(breadcrumb "comments" "fizzy comments --on $card_number" "View all comments")" \
+ "$(breadcrumb "comment" "fizzy comment \"text\" --on $card_number" "Add new comment")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_comment_deleted_md"
+}
+
+_comment_deleted_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local comment_id card_number
+ comment_id=$(echo "$data" | jq -r '.comment_id')
+ card_number=$(echo "$data" | jq -r '.card_number')
+
+ md_heading 2 "Comment Deleted"
+ echo
+ md_kv "Comment ID" "$comment_id" \
+ "Card" "#$card_number" \
+ "Status" "Deleted"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_comment_delete_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy comment delete",
+ description: "Delete a comment",
+ usage: "fizzy comment delete --on ",
+ options: [{flag: "--on", description: "Card number the comment belongs to"}],
+ examples: ["fizzy comment delete abc123 --on 123"]
+ }'
+ else
+ cat <<'EOF'
+## fizzy comment delete
+
+Delete a comment.
+
+### Usage
+
+ fizzy comment delete --on
+
+### Options
+
+ --on Card number the comment belongs to (required)
+ --help, -h Show this help
+
+### Examples
+
+ fizzy comment delete abc123 --on 123
+EOF
+ fi
+}
+
+_comment_created_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local comment_id creator_name created_at
+ comment_id=$(echo "$data" | jq -r '.id')
+ creator_name=$(echo "$data" | jq -r '.creator.name // "You"')
+ created_at=$(echo "$data" | jq -r '.created_at | split("T")[0]')
+
+ md_heading 2 "Comment Added"
+ echo
+ md_kv "ID" "$comment_id" \
+ "Author" "$creator_name" \
+ "Date" "$created_at"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_comment_create_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy comment",
+ description: "Add comment to a card",
+ usage: "fizzy comment \"text\" --on ",
+ options: [{flag: "--on", description: "Card number to comment on"}],
+ examples: ["fizzy comment \"LGTM!\" --on 123"]
+ }'
+ else
+ cat <<'EOF'
+## fizzy comment
+
+Add comment to a card.
+
+### Usage
+
+ fizzy comment "text" --on
+
+### Options
+
+ --on Card number to comment on (required)
+ --help, -h Show this help
+
+### Examples
+
+ fizzy comment "LGTM!" --on 123 Add comment to card #123
+EOF
+ fi
+}
+
+
+# fizzy assign --to
+# Toggle assignment
+
+cmd_assign() {
+ local card_number=""
+ local user_id=""
+ local show_help=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --to)
+ if [[ -z "${2:-}" ]]; then
+ die "--to requires a user ID" $EXIT_USAGE
+ fi
+ user_id="$2"
+ shift 2
+ ;;
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy assign --help"
+ ;;
+ *)
+ if [[ -z "$card_number" ]]; then
+ card_number="$1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _assign_help
+ return 0
+ fi
+
+ if [[ -z "$card_number" ]]; then
+ die "Card number required" $EXIT_USAGE "Usage: fizzy assign --to "
+ fi
+
+ if [[ -z "$user_id" ]]; then
+ die "--to user ID required" $EXIT_USAGE "Usage: fizzy assign --to "
+ fi
+
+ # Resolve user name/email to ID
+ local resolved_user
+ if resolved_user=$(resolve_user_id "$user_id"); then
+ user_id="$resolved_user"
+ else
+ die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy people"
+ fi
+
+ local body
+ body=$(jq -n --arg assignee_id "$user_id" '{assignee_id: $assignee_id}')
+
+ # POST assignments returns 204 No Content, so fetch card after
+ api_post "/cards/$card_number/assignments" "$body" > /dev/null
+ local response
+ response=$(api_get "/cards/$card_number")
+
+ local summary="Assignment toggled on card #$card_number"
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "show" "fizzy show $card_number" "View card")" \
+ "$(breadcrumb "people" "fizzy people" "List users")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_assign_md"
+}
+
+_assign_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local number title
+ number=$(echo "$data" | jq -r '.number')
+ title=$(echo "$data" | jq -r '.title // .description[0:40]')
+
+ md_heading 2 "Assignment Toggled"
+ echo
+ md_kv "Card" "#$number" \
+ "Title" "$title"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_assign_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy assign",
+ description: "Toggle assignment on a card",
+ usage: "fizzy assign --to ",
+ options: [{flag: "--to", description: "User name, email, or ID to toggle assignment"}],
+ examples: ["fizzy assign 123 --to \"Jane Doe\"", "fizzy assign 123 --to jane@example.com"]
+ }'
+ else
+ cat <<'EOF'
+## fizzy assign
+
+Toggle assignment on a card (adds if not assigned, removes if assigned).
+
+### Usage
+
+ fizzy assign --to
+
+### Options
+
+ --to User name, email, or ID to toggle assignment (required)
+ --help, -h Show this help
+
+### Examples
+
+ fizzy assign 123 --to "Jane Doe" Assign by name
+ fizzy assign 123 --to jane@example.com Assign by email
+EOF
+ fi
+}
+
+
+# fizzy tag --with "name"
+# Toggle tag on card
+
+cmd_tag() {
+ local card_number=""
+ local tag_name=""
+ local show_help=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --with)
+ if [[ -z "${2:-}" ]]; then
+ die "--with requires a tag name" $EXIT_USAGE
+ fi
+ tag_name="$2"
+ shift 2
+ ;;
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy tag --help"
+ ;;
+ *)
+ if [[ -z "$card_number" ]]; then
+ card_number="$1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _tag_help
+ return 0
+ fi
+
+ if [[ -z "$card_number" ]]; then
+ die "Card number required" $EXIT_USAGE "Usage: fizzy tag --with "
+ fi
+
+ if [[ -z "$tag_name" ]]; then
+ die "--with tag name required" $EXIT_USAGE "Usage: fizzy tag --with "
+ fi
+
+ # Strip leading # if present (users may type #bug or bug)
+ tag_name="${tag_name#\#}"
+
+ # API expects tag_title (the tag name), not tag_id
+ local body
+ body=$(jq -n --arg tag_title "$tag_name" '{tag_title: $tag_title}')
+
+ # POST taggings returns 204 No Content, so fetch card after
+ api_post "/cards/$card_number/taggings" "$body" > /dev/null
+ local response
+ response=$(api_get "/cards/$card_number")
+
+ local summary="Tag toggled on card #$card_number"
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "show" "fizzy show $card_number" "View card")" \
+ "$(breadcrumb "tags" "fizzy tags" "List tags")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_tag_md"
+}
+
+_tag_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local number title tags
+ number=$(echo "$data" | jq -r '.number')
+ title=$(echo "$data" | jq -r '.title // .description[0:40]')
+ tags=$(echo "$data" | jq -r '.tags | join(", ")')
+
+ md_heading 2 "Tag Toggled"
+ echo
+ md_kv "Card" "#$number" \
+ "Title" "$title" \
+ "Tags" "${tags:-None}"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_tag_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy tag",
+ description: "Toggle tag on a card",
+ usage: "fizzy tag --with ",
+ options: [{flag: "--with", description: "Tag name to toggle"}],
+ examples: ["fizzy tag 123 --with \"bug\"", "fizzy tag 123 --with \"feature\""]
+ }'
+ else
+ cat <<'EOF'
+## fizzy tag
+
+Toggle tag on a card (adds if not tagged, removes if tagged).
+
+### Usage
+
+ fizzy tag --with
+
+### Options
+
+ --with Tag name to toggle (required)
+ --help, -h Show this help
+
+### Examples
+
+ fizzy tag 123 --with "bug" Toggle "bug" tag
+ fizzy tag 123 --with "feature" Toggle "feature" tag
+EOF
+ fi
+}
+
+
+# fizzy watch
+# Subscribe to card notifications
+
+cmd_watch() {
+ local card_number=""
+ local show_help=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy watch --help"
+ ;;
+ *)
+ if [[ -z "$card_number" ]]; then
+ card_number="$1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _watch_help
+ return 0
+ fi
+
+ if [[ -z "$card_number" ]]; then
+ die "Card number required" $EXIT_USAGE "Usage: fizzy watch "
+ fi
+
+ # POST watch returns 204 No Content, so fetch card after
+ api_post "/cards/$card_number/watch" > /dev/null
+ local response
+ response=$(api_get "/cards/$card_number")
+
+ local summary="Subscribed to card #$card_number"
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "unwatch" "fizzy unwatch $card_number" "Unsubscribe")" \
+ "$(breadcrumb "show" "fizzy show $card_number" "View card")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_watch_md"
+}
+
+_watch_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local number title
+ number=$(echo "$data" | jq -r '.number')
+ title=$(echo "$data" | jq -r '.title // .description[0:40]')
+
+ md_heading 2 "Subscribed"
+ echo
+ md_kv "Card" "#$number" \
+ "Title" "$title" \
+ "Watching" "Yes"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_watch_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy watch",
+ description: "Subscribe to card notifications",
+ usage: "fizzy watch ",
+ examples: ["fizzy watch 123"]
+ }'
+ else
+ cat <<'EOF'
+## fizzy watch
+
+Subscribe to card notifications.
+
+### Usage
+
+ fizzy watch
+
+### Examples
+
+ fizzy watch 123 Subscribe to card #123
+EOF
+ fi
+}
+
+
+# fizzy unwatch
+# Unsubscribe from card
+
+cmd_unwatch() {
+ local card_number=""
+ local show_help=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy unwatch --help"
+ ;;
+ *)
+ if [[ -z "$card_number" ]]; then
+ card_number="$1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _unwatch_help
+ return 0
+ fi
+
+ if [[ -z "$card_number" ]]; then
+ die "Card number required" $EXIT_USAGE "Usage: fizzy unwatch "
+ fi
+
+ # DELETE watch returns 204 No Content, so fetch card after
+ api_delete "/cards/$card_number/watch" > /dev/null
+ local response
+ response=$(api_get "/cards/$card_number")
+
+ local summary="Unsubscribed from card #$card_number"
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "watch" "fizzy watch $card_number" "Subscribe")" \
+ "$(breadcrumb "show" "fizzy show $card_number" "View card")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_unwatch_md"
+}
+
+_unwatch_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local number title
+ number=$(echo "$data" | jq -r '.number')
+ title=$(echo "$data" | jq -r '.title // .description[0:40]')
+
+ md_heading 2 "Unsubscribed"
+ echo
+ md_kv "Card" "#$number" \
+ "Title" "$title" \
+ "Watching" "No"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_unwatch_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy unwatch",
+ description: "Unsubscribe from card notifications",
+ usage: "fizzy unwatch ",
+ examples: ["fizzy unwatch 123"]
+ }'
+ else
+ cat <<'EOF'
+## fizzy unwatch
+
+Unsubscribe from card notifications.
+
+### Usage
+
+ fizzy unwatch
+
+### Examples
+
+ fizzy unwatch 123 Unsubscribe from card #123
+EOF
+ fi
+}
+
+
+# fizzy gild
+# Mark card as golden
+
+cmd_gild() {
+ local card_number=""
+ local show_help=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy gild --help"
+ ;;
+ *)
+ if [[ -z "$card_number" ]]; then
+ card_number="$1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _gild_help
+ return 0
+ fi
+
+ if [[ -z "$card_number" ]]; then
+ die "Card number required" $EXIT_USAGE "Usage: fizzy gild "
+ fi
+
+ # POST goldness returns 204 No Content, so fetch card after
+ api_post "/cards/$card_number/goldness" > /dev/null
+ local response
+ response=$(api_get "/cards/$card_number")
+
+ local summary="Card #$card_number marked as golden"
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "ungild" "fizzy ungild $card_number" "Remove golden")" \
+ "$(breadcrumb "show" "fizzy show $card_number" "View card")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_gild_md"
+}
+
+_gild_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local number title
+ number=$(echo "$data" | jq -r '.number')
+ title=$(echo "$data" | jq -r '.title // .description[0:40]')
+
+ md_heading 2 "Card Gilded"
+ echo
+ md_kv "Card" "#$number" \
+ "Title" "$title" \
+ "Golden" "Yes ✨"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_gild_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy gild",
+ description: "Mark card as golden",
+ usage: "fizzy gild ",
+ examples: ["fizzy gild 123"]
+ }'
+ else
+ cat <<'EOF'
+## fizzy gild
+
+Mark card as golden (protects from auto-postponement).
+
+### Usage
+
+ fizzy gild
+
+### Examples
+
+ fizzy gild 123 Mark card #123 as golden
+EOF
+ fi
+}
+
+
+# fizzy ungild
+# Remove golden status
+
+cmd_ungild() {
+ local card_number=""
+ local show_help=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy ungild --help"
+ ;;
+ *)
+ if [[ -z "$card_number" ]]; then
+ card_number="$1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _ungild_help
+ return 0
+ fi
+
+ if [[ -z "$card_number" ]]; then
+ die "Card number required" $EXIT_USAGE "Usage: fizzy ungild "
+ fi
+
+ # DELETE goldness returns 204 No Content, so fetch card after
+ api_delete "/cards/$card_number/goldness" > /dev/null
+ local response
+ response=$(api_get "/cards/$card_number")
+
+ local summary="Golden status removed from card #$card_number"
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "gild" "fizzy gild $card_number" "Mark as golden")" \
+ "$(breadcrumb "show" "fizzy show $card_number" "View card")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_ungild_md"
+}
+
+_ungild_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local number title
+ number=$(echo "$data" | jq -r '.number')
+ title=$(echo "$data" | jq -r '.title // .description[0:40]')
+
+ md_heading 2 "Card Ungilded"
+ echo
+ md_kv "Card" "#$number" \
+ "Title" "$title" \
+ "Golden" "No"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_ungild_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy ungild",
+ description: "Remove golden status from card",
+ usage: "fizzy ungild ",
+ examples: ["fizzy ungild 123"]
+ }'
+ else
+ cat <<'EOF'
+## fizzy ungild
+
+Remove golden status from card.
+
+### Usage
+
+ fizzy ungild
+
+### Examples
+
+ fizzy ungild 123 Remove golden from card #123
+EOF
+ fi
+}
+
+
+# fizzy step "text" --on
+# Add step to card
+
+# fizzy step "text" --on [--completed]
+# fizzy step show --on
+# fizzy step update --on [--content "text"] [--completed|--uncompleted]
+# fizzy step delete --on
+
+cmd_step() {
+ if [[ "${1:-}" == "show" ]]; then
+ shift
+ _step_show "$@"
+ return
+ elif [[ "${1:-}" == "update" ]]; then
+ shift
+ _step_update "$@"
+ return
+ elif [[ "${1:-}" == "delete" ]]; then
+ shift
+ _step_delete "$@"
+ return
+ fi
+
+ # Default: create step
+ _step_create "$@"
+}
+
+_step_create() {
+ local content=""
+ local card_number=""
+ local completed=""
+ local show_help=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --on)
+ if [[ -z "${2:-}" ]]; then
+ die "--on requires a card number" $EXIT_USAGE
+ fi
+ card_number="$2"
+ shift 2
+ ;;
+ --completed)
+ completed="true"
+ shift
+ ;;
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy step --help"
+ ;;
+ *)
+ if [[ -z "$content" ]]; then
+ content="$1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _step_help
+ return 0
+ fi
+
+ if [[ -z "$content" ]]; then
+ die "Step content required" $EXIT_USAGE "Usage: fizzy step \"text\" --on "
+ fi
+
+ if [[ -z "$card_number" ]]; then
+ die "--on card number required" $EXIT_USAGE "Usage: fizzy step \"text\" --on "
+ fi
+
+ # Build request body - Rails expects params[:step]
+ local body
+ if [[ "$completed" == "true" ]]; then
+ body=$(jq -n --arg content "$content" '{step: {content: $content, completed: true}}')
+ else
+ body=$(jq -n --arg content "$content" '{step: {content: $content}}')
+ fi
+
+ # POST returns 201 with Location header - api_post follows it automatically
+ local response
+ response=$(api_post "/cards/$card_number/steps" "$body")
+
+ local summary="Step added to card #$card_number"
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "show" "fizzy show $card_number" "View card")" \
+ "$(breadcrumb "step" "fizzy step \"text\" --on $card_number" "Add another step")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_step_md"
+}
+
+_step_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local step_id content completed
+ step_id=$(echo "$data" | jq -r '.id')
+ content=$(echo "$data" | jq -r '.content')
+ completed=$(echo "$data" | jq -r 'if .completed then "Yes" else "No" end')
+
+ md_heading 2 "Step Added"
+ echo
+ md_kv "ID" "$step_id" \
+ "Content" "$content" \
+ "Completed" "$completed"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_step_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy step",
+ description: "Manage steps (checklist items) on a card",
+ usage: "fizzy step \"text\" --on [--completed]",
+ subcommands: [
+ {name: "show", description: "Show step details"},
+ {name: "update", description: "Update a step"},
+ {name: "delete", description: "Delete a step"}
+ ],
+ options: [
+ {flag: "--on", description: "Card number (required)"},
+ {flag: "--completed", description: "Mark step as completed on creation"}
+ ],
+ examples: [
+ "fizzy step \"Review PR\" --on 123",
+ "fizzy step \"Done task\" --on 123 --completed",
+ "fizzy step show abc123 --on 123",
+ "fizzy step update abc123 --on 123 --completed",
+ "fizzy step delete abc123 --on 123"
+ ]
+ }'
+ else
+ cat <<'EOF'
+## fizzy step
+
+Manage steps (checklist items) on a card.
+
+### Usage
+
+ fizzy step "text" --on [--completed] Create step
+ fizzy step show --on View step
+ fizzy step update --on [opts] Update step
+ fizzy step delete --on Delete step
+
+### Options (create)
+
+ --on Card number (required)
+ --completed Mark step as completed
+ --help, -h Show this help
+
+### Options (update)
+
+ --content New step text
+ --completed Mark as completed
+ --uncompleted Mark as not completed
+
+### Examples
+
+ fizzy step "Review PR" --on 123 Add uncompleted step
+ fizzy step "Done" --on 123 --completed Add completed step
+ fizzy step show abc123 --on 123 View step details
+ fizzy step update abc123 --on 123 --completed Mark step done
+ fizzy step delete abc123 --on 123 Remove step
+EOF
+ fi
+}
+
+# fizzy step show --on
+_step_show() {
+ local step_id=""
+ local card_number=""
+ local show_help=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --on)
+ if [[ -z "${2:-}" ]]; then
+ die "--on requires a card number" $EXIT_USAGE
+ fi
+ card_number="$2"
+ shift 2
+ ;;
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy step show --help"
+ ;;
+ *)
+ if [[ -z "$step_id" ]]; then
+ step_id="$1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _step_show_help
+ return 0
+ fi
+
+ if [[ -z "$step_id" ]]; then
+ die "Step ID required" $EXIT_USAGE "Usage: fizzy step show --on "
+ fi
+
+ if [[ -z "$card_number" ]]; then
+ die "--on card number required" $EXIT_USAGE "Usage: fizzy step show --on "
+ fi
+
+ local response
+ response=$(api_get "/cards/$card_number/steps/$step_id")
+
+ local summary="Step on card #$card_number"
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "show" "fizzy show $card_number" "View card")" \
+ "$(breadcrumb "update" "fizzy step update $step_id --on $card_number" "Update step")" \
+ "$(breadcrumb "delete" "fizzy step delete $step_id --on $card_number" "Delete step")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_step_show_md"
+}
+
+_step_show_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local step_id content completed
+ step_id=$(echo "$data" | jq -r '.id')
+ content=$(echo "$data" | jq -r '.content')
+ completed=$(echo "$data" | jq -r 'if .completed then "Yes" else "No" end')
+
+ md_heading 2 "Step"
+ echo
+ md_kv "ID" "$step_id" \
+ "Content" "$content" \
+ "Completed" "$completed"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_step_show_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy step show",
+ description: "Show step details",
+ usage: "fizzy step show --on ",
+ examples: ["fizzy step show abc123 --on 123"]
+ }'
+ else
+ cat <<'EOF'
+## fizzy step show
+
+Show step details.
+
+### Usage
+
+ fizzy step show --on
+
+### Examples
+
+ fizzy step show abc123 --on 123 View step abc123 on card #123
+EOF
+ fi
+}
+
+# fizzy step update --on [--content "text"] [--completed|--uncompleted]
+_step_update() {
+ local step_id=""
+ local card_number=""
+ local content=""
+ local completed=""
+ local show_help=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --on)
+ if [[ -z "${2:-}" ]]; then
+ die "--on requires a card number" $EXIT_USAGE
+ fi
+ card_number="$2"
+ shift 2
+ ;;
+ --content)
+ if [[ -z "${2:-}" ]]; then
+ die "--content requires text" $EXIT_USAGE
+ fi
+ content="$2"
+ shift 2
+ ;;
+ --completed)
+ completed="true"
+ shift
+ ;;
+ --uncompleted)
+ completed="false"
+ shift
+ ;;
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy step update --help"
+ ;;
+ *)
+ if [[ -z "$step_id" ]]; then
+ step_id="$1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _step_update_help
+ return 0
+ fi
+
+ if [[ -z "$step_id" ]]; then
+ die "Step ID required" $EXIT_USAGE "Usage: fizzy step update --on [options]"
+ fi
+
+ if [[ -z "$card_number" ]]; then
+ die "--on card number required" $EXIT_USAGE "Usage: fizzy step update --on [options]"
+ fi
+
+ if [[ -z "$content" && -z "$completed" ]]; then
+ die "Nothing to update. Specify --content, --completed, or --uncompleted" $EXIT_USAGE
+ fi
+
+ # Build request body - Rails expects params[:step]
+ local body
+ body=$(jq -n \
+ --arg content "$content" \
+ --arg completed "$completed" \
+ '{step: ((if $content != "" then {content: $content} else {} end) +
+ (if $completed != "" then {completed: ($completed == "true")} else {} end))}')
+
+ local response
+ response=$(api_patch "/cards/$card_number/steps/$step_id" "$body")
+
+ local summary="Step updated on card #$card_number"
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "show" "fizzy step show $step_id --on $card_number" "View step")" \
+ "$(breadcrumb "card" "fizzy show $card_number" "View card")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_step_updated_md"
+}
+
+_step_updated_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local step_id content completed
+ step_id=$(echo "$data" | jq -r '.id')
+ content=$(echo "$data" | jq -r '.content')
+ completed=$(echo "$data" | jq -r 'if .completed then "Yes" else "No" end')
+
+ md_heading 2 "Step Updated"
+ echo
+ md_kv "ID" "$step_id" \
+ "Content" "$content" \
+ "Completed" "$completed"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_step_update_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy step update",
+ description: "Update a step",
+ usage: "fizzy step update --on [options]",
+ options: [
+ {flag: "--content", description: "New step text"},
+ {flag: "--completed", description: "Mark as completed"},
+ {flag: "--uncompleted", description: "Mark as not completed"}
+ ],
+ examples: [
+ "fizzy step update abc123 --on 123 --completed",
+ "fizzy step update abc123 --on 123 --content \"Updated text\""
+ ]
+ }'
+ else
+ cat <<'EOF'
+## fizzy step update
+
+Update a step.
+
+### Usage
+
+ fizzy step update --on [options]
+
+### Options
+
+ --content New step text
+ --completed Mark step as completed
+ --uncompleted Mark step as not completed
+ --help, -h Show this help
+
+### Examples
+
+ fizzy step update abc123 --on 123 --completed Mark done
+ fizzy step update abc123 --on 123 --content "New text" Update text
+EOF
+ fi
+}
+
+# fizzy step delete --on
+_step_delete() {
+ local step_id=""
+ local card_number=""
+ local show_help=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --on)
+ if [[ -z "${2:-}" ]]; then
+ die "--on requires a card number" $EXIT_USAGE
+ fi
+ card_number="$2"
+ shift 2
+ ;;
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy step delete --help"
+ ;;
+ *)
+ if [[ -z "$step_id" ]]; then
+ step_id="$1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _step_delete_help
+ return 0
+ fi
+
+ if [[ -z "$step_id" ]]; then
+ die "Step ID required" $EXIT_USAGE "Usage: fizzy step delete --on "
+ fi
+
+ if [[ -z "$card_number" ]]; then
+ die "--on card number required" $EXIT_USAGE "Usage: fizzy step delete --on "
+ fi
+
+ # DELETE returns 204 No Content
+ api_delete "/cards/$card_number/steps/$step_id" > /dev/null
+
+ local response
+ response=$(jq -n --arg id "$step_id" '{id: $id, deleted: true}')
+
+ local summary="Step deleted from card #$card_number"
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "show" "fizzy show $card_number" "View card")" \
+ "$(breadcrumb "step" "fizzy step \"text\" --on $card_number" "Add step")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_step_deleted_md"
+}
+
+_step_deleted_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local step_id
+ step_id=$(echo "$data" | jq -r '.id')
+
+ md_heading 2 "Step Deleted"
+ echo
+ md_kv "ID" "$step_id" \
+ "Status" "Deleted"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_step_delete_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy step delete",
+ description: "Delete a step from a card",
+ usage: "fizzy step delete --on ",
+ examples: ["fizzy step delete abc123 --on 123"]
+ }'
+ else
+ cat <<'EOF'
+## fizzy step delete
+
+Delete a step from a card.
+
+### Usage
+
+ fizzy step delete --on
+
+### Examples
+
+ fizzy step delete abc123 --on 123 Delete step from card #123
+EOF
+ fi
+}
+
+
+# fizzy react "emoji" --card --comment
+# fizzy react delete --card --comment
+
+cmd_react() {
+ if [[ "${1:-}" == "delete" ]]; then
+ shift
+ _react_delete "$@"
+ return
+ fi
+
+ # Default: add reaction
+ _react_add "$@"
+}
+
+_react_add() {
+ local emoji=""
+ local comment_id=""
+ local card_number=""
+ local show_help=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --comment)
+ if [[ -z "${2:-}" ]]; then
+ die "--comment requires a comment ID" $EXIT_USAGE
+ fi
+ comment_id="$2"
+ shift 2
+ ;;
+ --card)
+ if [[ -z "${2:-}" ]]; then
+ die "--card requires a card number" $EXIT_USAGE
+ fi
+ card_number="$2"
+ shift 2
+ ;;
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy react --help"
+ ;;
+ *)
+ if [[ -z "$emoji" ]]; then
+ emoji="$1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _react_help
+ return 0
+ fi
+
+ if [[ -z "$emoji" ]]; then
+ die "Emoji required" $EXIT_USAGE "Usage: fizzy react \"👍\" --card --comment "
+ fi
+
+ if [[ -z "$card_number" ]]; then
+ die "--card number required" $EXIT_USAGE "Usage: fizzy react \"👍\" --card --comment "
+ fi
+
+ if [[ -z "$comment_id" ]]; then
+ die "--comment ID required" $EXIT_USAGE "Usage: fizzy react \"👍\" --card --comment "
+ fi
+
+ local body
+ body=$(jq -n --arg content "$emoji" '{reaction: {content: $content}}')
+
+ # POST reactions returns 201 with no body/Location, construct response
+ api_post "/cards/$card_number/comments/$comment_id/reactions" "$body" > /dev/null
+ local response
+ response=$(jq -n --arg emoji "$emoji" --arg card_number "$card_number" --arg comment_id "$comment_id" \
+ '{emoji: $emoji, card_number: $card_number, comment_id: $comment_id}')
+
+ local summary="Reaction added to comment"
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "reactions" "fizzy reactions --card $card_number --comment $comment_id" "View reactions")" \
+ "$(breadcrumb "comments" "fizzy comments --on $card_number" "View comments")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_react_md"
+}
+
+_react_delete() {
+ local reaction_id=""
+ local comment_id=""
+ local card_number=""
+ local show_help=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --comment)
+ if [[ -z "${2:-}" ]]; then
+ die "--comment requires a comment ID" $EXIT_USAGE
+ fi
+ comment_id="$2"
+ shift 2
+ ;;
+ --card)
+ if [[ -z "${2:-}" ]]; then
+ die "--card requires a card number" $EXIT_USAGE
+ fi
+ card_number="$2"
+ shift 2
+ ;;
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ -*)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy react delete --help"
+ ;;
+ *)
+ if [[ -z "$reaction_id" ]]; then
+ reaction_id="$1"
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _react_delete_help
+ return 0
+ fi
+
+ if [[ -z "$reaction_id" ]]; then
+ die "Reaction ID required" $EXIT_USAGE "Usage: fizzy react delete --card --comment "
+ fi
+
+ if [[ -z "$card_number" ]]; then
+ die "--card number required" $EXIT_USAGE "Usage: fizzy react delete --card --comment "
+ fi
+
+ if [[ -z "$comment_id" ]]; then
+ die "--comment ID required" $EXIT_USAGE "Usage: fizzy react delete --card --comment "
+ fi
+
+ # DELETE returns 204 No Content
+ api_delete "/cards/$card_number/comments/$comment_id/reactions/$reaction_id" > /dev/null
+
+ local response
+ response=$(jq -n --arg id "$reaction_id" '{id: $id, deleted: true}')
+
+ local summary="Reaction deleted"
+
+ local breadcrumbs
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "reactions" "fizzy reactions --card $card_number --comment $comment_id" "View reactions")" \
+ "$(breadcrumb "react" "fizzy react \"👍\" --card $card_number --comment $comment_id" "Add reaction")"
+ )
+
+ output "$response" "$summary" "$breadcrumbs" "_react_deleted_md"
+}
+
+_react_deleted_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local reaction_id
+ reaction_id=$(echo "$data" | jq -r '.id')
+
+ md_heading 2 "Reaction Deleted"
+ echo
+ md_kv "ID" "$reaction_id" \
+ "Status" "Deleted"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_react_delete_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy react delete",
+ description: "Delete a reaction from a comment",
+ usage: "fizzy react delete --card --comment ",
+ options: [
+ {flag: "--card", description: "Card number"},
+ {flag: "--comment", description: "Comment ID"}
+ ],
+ examples: ["fizzy react delete xyz789 --card 123 --comment abc456"]
+ }'
+ else
+ cat <<'EOF'
+## fizzy react delete
+
+Delete a reaction from a comment.
+
+### Usage
+
+ fizzy react delete --card --comment
+
+### Options
+
+ --card Card number (required)
+ --comment Comment ID (required)
+ --help, -h Show this help
+
+### Examples
+
+ fizzy react delete xyz789 --card 123 --comment abc456
+EOF
+ fi
+}
+
+_react_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ local emoji card_number comment_id
+ emoji=$(echo "$data" | jq -r '.emoji')
+ card_number=$(echo "$data" | jq -r '.card_number')
+ comment_id=$(echo "$data" | jq -r '.comment_id')
+
+ md_heading 2 "Reaction Added"
+ echo
+ md_kv "Emoji" "$emoji" \
+ "Card" "#$card_number" \
+ "Comment" "$comment_id"
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_react_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy react",
+ description: "Manage reactions on comments",
+ usage: "fizzy react \"emoji\" --card --comment ",
+ subcommands: [
+ {name: "delete", description: "Delete a reaction"}
+ ],
+ options: [
+ {flag: "--card", description: "Card number"},
+ {flag: "--comment", description: "Comment ID to react to"}
+ ],
+ examples: [
+ "fizzy react \"👍\" --card 123 --comment abc456",
+ "fizzy react delete xyz789 --card 123 --comment abc456"
+ ]
+ }'
+ else
+ cat <<'EOF'
+## fizzy react
+
+Manage reactions on comments.
+
+### Usage
+
+ fizzy react "emoji" --card --comment Add reaction
+ fizzy react delete --card --comment Delete reaction
+
+### Options
+
+ --card Card number (required)
+ --comment Comment ID (required)
+ --help, -h Show this help
+
+### Examples
+
+ fizzy react "👍" --card 123 --comment abc456 Add thumbs up
+ fizzy react delete xyz789 --card 123 --comment abc456 Remove reaction
+EOF
+ fi
+}
diff --git a/cli/lib/commands/boards.sh b/cli/lib/commands/boards.sh
new file mode 100644
index 0000000000..c392b1d3d6
--- /dev/null
+++ b/cli/lib/commands/boards.sh
@@ -0,0 +1,1470 @@
+#!/usr/bin/env bash
+# boards.sh - Board and column query commands
+
+
+# fizzy boards [options]
+# List boards in the account
+
+cmd_boards() {
+ local show_help=false
+ local page=""
+ local fetch_all=false
+
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --all|-a)
+ fetch_all=true
+ shift
+ ;;
+ --page|-p)
+ if [[ -z "${2:-}" ]]; then
+ die "--page requires a value" $EXIT_USAGE
+ fi
+ if ! [[ "$2" =~ ^[0-9]+$ ]] || [[ "$2" -lt 1 ]]; then
+ die "--page must be a positive integer" $EXIT_USAGE
+ fi
+ page="$2"
+ shift 2
+ ;;
+ --help|-h)
+ show_help=true
+ shift
+ ;;
+ *)
+ die "Unknown option: $1" $EXIT_USAGE "Run: fizzy boards --help"
+ ;;
+ esac
+ done
+
+ if [[ "$show_help" == "true" ]]; then
+ _boards_help
+ return 0
+ fi
+
+ local response
+ if [[ "$fetch_all" == "true" ]]; then
+ response=$(api_get_all "/boards")
+ else
+ local path="/boards"
+ if [[ -n "$page" ]]; then
+ path="$path?page=$page"
+ fi
+ response=$(api_get "$path")
+ fi
+
+ local count
+ count=$(echo "$response" | jq 'length')
+
+ local summary="$count boards"
+ [[ -n "$page" ]] && summary="$count boards (page $page)"
+ [[ "$fetch_all" == "true" ]] && summary="$count boards (all)"
+
+ local next_page=$((${page:-1} + 1))
+ local breadcrumbs
+ if [[ "$fetch_all" == "true" ]]; then
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "show" "fizzy show board " "View board details")" \
+ "$(breadcrumb "cards" "fizzy cards --board " "List cards on board")" \
+ "$(breadcrumb "columns" "fizzy columns --board " "List board columns")"
+ )
+ else
+ breadcrumbs=$(breadcrumbs \
+ "$(breadcrumb "show" "fizzy show board " "View board details")" \
+ "$(breadcrumb "cards" "fizzy cards --board " "List cards on board")" \
+ "$(breadcrumb "columns" "fizzy columns --board " "List board columns")" \
+ "$(breadcrumb "next" "fizzy boards --page $next_page" "Next page")"
+ )
+ fi
+
+ output "$response" "$summary" "$breadcrumbs" "_boards_md"
+}
+
+_boards_md() {
+ local data="$1"
+ local summary="$2"
+ local breadcrumbs="$3"
+
+ md_heading 2 "Boards ($summary)"
+
+ local count
+ count=$(echo "$data" | jq 'length')
+
+ if [[ "$count" -eq 0 ]]; then
+ echo "No boards found."
+ echo
+ else
+ echo "| ID | Name | Access | Created |"
+ echo "|----|------|--------|---------|"
+ echo "$data" | jq -r '.[] | "| \(.id) | \(.name) | \(if .all_access then "All" else "Selective" end) | \(.created_at | split("T")[0]) |"'
+ echo
+ fi
+
+ md_breadcrumbs "$breadcrumbs"
+}
+
+_boards_help() {
+ local format
+ format=$(get_format)
+
+ if [[ "$format" == "json" ]]; then
+ jq -n '{
+ command: "fizzy boards",
+ description: "List boards in the account",
+ options: [
+ {flag: "--all, -a", description: "Fetch all pages"},
+ {flag: "--page, -p", description: "Page number for pagination"}
+ ],
+ examples: [
+ "fizzy boards",
+ "fizzy boards --all",
+ "fizzy boards --page 2"
+ ]
+ }'
+ else
+ cat <<'EOF'
+## fizzy boards
+
+List boards in the account.
+
+### Usage
+
+ fizzy boards [options]
+
+### Options
+
+ --all, -a Fetch all pages
+ --page, -p Page number for pagination
+ --help, -h Show this help
+
+### Examples
+
+ fizzy boards List boards (first page)
+ fizzy boards --all Fetch all boards
+ fizzy boards --page 2 Get second page
+EOF
+ fi
+}
+
+
+# fizzy board