From dfd6a59a07e40c4bf3441fdbe6894d74c01a0bc8 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Jan 2026 12:36:37 -0800 Subject: [PATCH 01/32] OAuth 2.1 support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OAuth tokens = Access Token that belongs to an OAuth client - One table (oauth_clients) + one column (oauth_client_id) - Stateless authorization codes via MessageEncryptor (60s TTL) - Implicit grants (token exists = grant, revoke = delete tokens) - Dynamic client registration for MCPs - Token lifetime: no expiry, explicit revocation only - Scope mapping: space-delimited OAuth scopes → permission enum - Security: PKCE required, loopback-only DCR, rate limiting - RFC compliance: 6749, 6750, 7636, 7591, 8252, 8414, 9728 Authorization flow with PKCE (S256 only) - Consent screen showing client name and requested scopes - Stateless authorization codes via MessageEncryptor Token endpoint - Authorization code exchange with PKCE verification - Issues Identity::AccessToken linked to OAuth client Revocation endpoint (RFC 7009) - Revoke access tokens by value - Always returns 200 per spec --- .../my/access_tokens_controller.rb | 2 +- .../oauth/authorizations_controller.rb | 108 +++++ app/controllers/oauth/base_controller.rb | 12 + .../oauth/revocations_controller.rb | 16 + app/controllers/oauth/tokens_controller.rb | 61 +++ app/models/identity/access_token.rb | 4 + app/models/oauth.rb | 7 + app/models/oauth/authorization_code.rb | 38 ++ app/models/oauth/client.rb | 74 +++ app/views/oauth/authorizations/error.html.erb | 15 + app/views/oauth/authorizations/new.html.erb | 40 ++ config/routes.rb | 6 + db/migrate/20251231163456_add_oauth.rb | 18 + db/schema.rb | 18 +- db/schema_sqlite.rb | 16 +- test/fixtures/oauth/clients.yml | 21 + test/integration/oauth_flow_test.rb | 437 ++++++++++++++++++ test/models/oauth/authorization_code_test.rb | 137 ++++++ test/models/oauth/client_test.rb | 137 ++++++ 19 files changed, 1164 insertions(+), 3 deletions(-) create mode 100644 app/controllers/oauth/authorizations_controller.rb create mode 100644 app/controllers/oauth/base_controller.rb create mode 100644 app/controllers/oauth/revocations_controller.rb create mode 100644 app/controllers/oauth/tokens_controller.rb create mode 100644 app/models/oauth.rb create mode 100644 app/models/oauth/authorization_code.rb create mode 100644 app/models/oauth/client.rb create mode 100644 app/views/oauth/authorizations/error.html.erb create mode 100644 app/views/oauth/authorizations/new.html.erb create mode 100644 db/migrate/20251231163456_add_oauth.rb create mode 100644 test/fixtures/oauth/clients.yml create mode 100644 test/integration/oauth_flow_test.rb create mode 100644 test/models/oauth/authorization_code_test.rb create mode 100644 test/models/oauth/client_test.rb 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/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb new file mode 100644 index 0000000000..bb605e0acb --- /dev/null +++ b/app/controllers/oauth/authorizations_controller.rb @@ -0,0 +1,108 @@ +class Oauth::AuthorizationsController < Oauth::BaseController + 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/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/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 %> +

<%= @page_title %>

+<% 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/config/routes.rb b/config/routes.rb index 97e34290cb..0d46fa6e6d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,12 @@ Rails.application.routes.draw do root "events#index" + namespace :oauth do + resource :authorization, only: %i[ new create ] + resource :token, only: :create + resource :revocation, only: :create + end + namespace :account do resource :cancellation, only: [ :create ] resource :entropy diff --git a/db/migrate/20251231163456_add_oauth.rb b/db/migrate/20251231163456_add_oauth.rb new file mode 100644 index 0000000000..74e6c36a26 --- /dev/null +++ b/db/migrate/20251231163456_add_oauth.rb @@ -0,0 +1,18 @@ +class AddOauth < ActiveRecord::Migration[8.2] + def change + create_table :oauth_clients, id: :uuid do |t| + t.string :client_id, null: false + t.string :name, null: false + t.json :redirect_uris + t.json :scopes + t.boolean :trusted, default: false + t.boolean :dynamically_registered, default: false + + t.timestamps + + t.index :client_id, unique: true + end + + add_reference :identity_access_tokens, :oauth_client, type: :uuid, foreign_key: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 86b9f29375..53cfc5da9d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do +ActiveRecord::Schema[8.2].define(version: 2025_12_31_163456) do create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -323,10 +323,12 @@ t.datetime "created_at", null: false t.text "description" t.uuid "identity_id", null: false + t.uuid "oauth_client_id" t.string "permission" t.string "token" t.datetime "updated_at", null: false t.index ["identity_id"], name: "index_access_token_on_identity_id" + t.index ["oauth_client_id"], name: "index_identity_access_tokens_on_oauth_client_id" end create_table "magic_links", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -385,6 +387,18 @@ t.index ["user_id"], name: "index_notifications_on_user_id" end + create_table "oauth_clients", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.string "client_id", null: false + t.datetime "created_at", null: false + t.boolean "dynamically_registered", default: false + t.string "name", null: false + t.json "redirect_uris" + t.json "scopes" + t.boolean "trusted", default: false + t.datetime "updated_at", null: false + t.index ["client_id"], name: "index_oauth_clients_on_client_id", unique: true + end + create_table "pins", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false @@ -820,4 +834,6 @@ t.index ["account_id"], name: "index_webhooks_on_account_id" t.index ["board_id", "subscribed_actions"], name: "index_webhooks_on_board_id_and_subscribed_actions", length: { subscribed_actions: 255 } end + + add_foreign_key "identity_access_tokens", "oauth_clients" end diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index b76f998659..84af48794f 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.2].define(version: 2025_12_24_092315) do +ActiveRecord::Schema[8.2].define(version: 2025_12_31_163456) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -323,10 +323,12 @@ t.datetime "created_at", null: false t.text "description", limit: 65535 t.uuid "identity_id", null: false + t.uuid "oauth_client_id" t.string "permission", limit: 255 t.string "token", limit: 255 t.datetime "updated_at", null: false t.index ["identity_id"], name: "index_access_token_on_identity_id" + t.index ["oauth_client_id"], name: "index_identity_access_tokens_on_oauth_client_id" end create_table "magic_links", id: :uuid, force: :cascade do |t| @@ -385,6 +387,18 @@ t.index ["user_id"], name: "index_notifications_on_user_id" end + create_table "oauth_clients", id: :uuid, force: :cascade do |t| + t.string "client_id", limit: 255, null: false + t.datetime "created_at", null: false + t.boolean "dynamically_registered", default: false + t.string "name", limit: 255, null: false + t.json "redirect_uris" + t.json "scopes" + t.boolean "trusted", default: false + t.datetime "updated_at", null: false + t.index ["client_id"], name: "index_oauth_clients_on_client_id", unique: true + end + create_table "pins", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false diff --git a/test/fixtures/oauth/clients.yml b/test/fixtures/oauth/clients.yml new file mode 100644 index 0000000000..86b6de7264 --- /dev/null +++ b/test/fixtures/oauth/clients.yml @@ -0,0 +1,21 @@ +mcp_client: + client_id: mcp_test_client_123 + name: Test MCP Client + redirect_uris: + - http://127.0.0.1:8888/callback + scopes: + - read + - write + dynamically_registered: true + trusted: false + +trusted_client: + client_id: trusted_client_456 + name: Trusted First-Party App + redirect_uris: + - https://app.example.com/oauth/callback + scopes: + - read + - write + dynamically_registered: false + trusted: true diff --git a/test/integration/oauth_flow_test.rb b/test/integration/oauth_flow_test.rb new file mode 100644 index 0000000000..b52d354163 --- /dev/null +++ b/test/integration/oauth_flow_test.rb @@ -0,0 +1,437 @@ +require "test_helper" + +class OauthFlowTest < ActionDispatch::IntegrationTest + # Authorization Endpoint + + test "authorization requires authentication" do + client = oauth_clients(:mcp_client) + + untenanted do + get new_oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://127.0.0.1:8888/callback", + response_type: "code", + code_challenge: "test_challenge", + code_challenge_method: "S256" + } + end + + assert_response :redirect + assert_match %r{/session/new}, response.location + end + + test "authorization shows consent screen" do + sign_in_as :david + client = oauth_clients(:mcp_client) + + untenanted do + get new_oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://127.0.0.1:8888/callback", + response_type: "code", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + code_challenge_method: "S256", + scope: "read", + state: "xyz123" + } + end + + assert_response :success + assert_select "form[action$=?]", "/oauth/authorization" + assert_match client.name, response.body + end + + test "authorization rejects invalid client_id" do + sign_in_as :david + + untenanted do + get new_oauth_authorization_path, params: { + client_id: "nonexistent", + redirect_uri: "http://127.0.0.1/cb", + response_type: "code", + code_challenge: "test", + code_challenge_method: "S256" + } + end + + assert_response :bad_request + assert_equal "invalid_request", response.parsed_body["error"] + end + + test "authorization rejects mismatched redirect_uri" do + sign_in_as :david + client = oauth_clients(:mcp_client) + + untenanted do + get new_oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://evil.com/steal", + response_type: "code", + code_challenge: "test", + code_challenge_method: "S256", + state: "abc" + } + end + + # Can't redirect to untrusted URI, so render HTML error page + assert_response :bad_request + assert_select "code", text: "invalid_request" + end + + test "authorization requires PKCE" do + sign_in_as :david + client = oauth_clients(:mcp_client) + + untenanted do + get new_oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://127.0.0.1:8888/callback", + response_type: "code", + state: "abc" + } + end + + # Per RFC 6749, redirect to client with error in query params + assert_response :redirect + redirect_params = CGI.parse(URI.parse(response.location).query) + assert_equal "invalid_request", redirect_params["error"].first + assert_match "code_challenge", redirect_params["error_description"].first + end + + test "authorization consent issues code" do + sign_in_as :david + client = oauth_clients(:mcp_client) + + untenanted do + post oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://127.0.0.1:8888/callback", + response_type: "code", + code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", + code_challenge_method: "S256", + scope: "read", + state: "xyz123" + } + end + + assert_response :redirect + redirect_uri = URI.parse(response.location) + + assert_equal "127.0.0.1", redirect_uri.host + assert_equal "/callback", redirect_uri.path + + params = CGI.parse(redirect_uri.query) + assert_not_nil params["code"]&.first + assert_equal "xyz123", params["state"]&.first + end + + + # Token Endpoint + + test "token exchange with valid code and PKCE" do + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + client = oauth_clients(:mcp_client) + identity = identities(:david) + + code = Oauth::AuthorizationCode.generate \ + client_id: client.client_id, + identity_id: identity.id, + code_challenge: code_challenge, + redirect_uri: "http://127.0.0.1:8888/callback", + scope: "read" + + assert_difference "Identity::AccessToken.count", 1 do + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "http://127.0.0.1:8888/callback", + code_verifier: code_verifier + }, as: :json + end + end + + assert_response :success + body = response.parsed_body + + assert_not_nil body["access_token"] + assert_equal "Bearer", body["token_type"] + assert_equal "read", body["scope"] + + token = Identity::AccessToken.find_by(token: body["access_token"]) + assert_equal client, token.oauth_client + assert_equal identity, token.identity + assert_equal "read", token.permission + end + + test "token exchange rejects invalid code" do + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: "invalid_code", + redirect_uri: "http://127.0.0.1/cb", + code_verifier: "verifier" + }, as: :json + end + + assert_response :bad_request + assert_equal "invalid_grant", response.parsed_body["error"] + end + + test "token exchange rejects wrong PKCE verifier" do + code_verifier = "correct_verifier_here" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + client = oauth_clients(:mcp_client) + + code = Oauth::AuthorizationCode.generate \ + client_id: client.client_id, + identity_id: identities(:david).id, + code_challenge: code_challenge, + redirect_uri: "http://127.0.0.1:8888/callback", + scope: "read" + + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "http://127.0.0.1:8888/callback", + code_verifier: "wrong_verifier" + }, as: :json + end + + assert_response :bad_request + assert_equal "invalid_grant", response.parsed_body["error"] + end + + test "token exchange rejects mismatched redirect_uri" do + code_verifier = "verifier" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + client = oauth_clients(:mcp_client) + + code = Oauth::AuthorizationCode.generate \ + client_id: client.client_id, + identity_id: identities(:david).id, + code_challenge: code_challenge, + redirect_uri: "http://127.0.0.1:8888/callback", + scope: "read" + + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "http://127.0.0.1:9999/different", + code_verifier: code_verifier + }, as: :json + end + + assert_response :bad_request + assert_equal "invalid_grant", response.parsed_body["error"] + end + + test "token exchange rejects expired code" do + code_verifier = "verifier" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + client = oauth_clients(:mcp_client) + + code = Oauth::AuthorizationCode.generate \ + client_id: client.client_id, + identity_id: identities(:david).id, + code_challenge: code_challenge, + redirect_uri: "http://127.0.0.1:8888/callback", + scope: "read" + + travel 65.seconds do + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "http://127.0.0.1:8888/callback", + code_verifier: code_verifier + }, as: :json + end + end + + assert_response :bad_request + assert_equal "invalid_grant", response.parsed_body["error"] + end + + test "token exchange rejects unsupported grant type" do + untenanted do + post oauth_token_path, params: { grant_type: "client_credentials" }, as: :json + end + + assert_response :bad_request + assert_equal "unsupported_grant_type", response.parsed_body["error"] + end + + + # Token Revocation (RFC 7009) + + test "revocation deletes access token" do + token = identity_access_tokens(:davids_api_token) + + assert_difference "Identity::AccessToken.count", -1 do + untenanted do + post oauth_revocation_path, params: { token: token.token }, as: :json + end + end + + assert_response :success + end + + test "revocation returns 200 for nonexistent token" do + untenanted do + post oauth_revocation_path, params: { token: "nonexistent_token" }, as: :json + end + assert_response :success + end + + test "revocation returns 400 for blank token" do + untenanted do + post oauth_revocation_path, params: { token: "" }, as: :json + end + assert_response :bad_request + end + + + # Discovery Metadata (RFC 8414) + + test "authorization server metadata includes required fields" do + untenanted do + get "/.well-known/oauth-authorization-server" + end + + assert_response :success + body = response.parsed_body + + assert_equal "http://www.example.com/", body["issuer"] + assert_match %r{/oauth/authorization/new$}, body["authorization_endpoint"] + assert_match %r{/oauth/token$}, body["token_endpoint"] + assert_match %r{/oauth/clients$}, body["registration_endpoint"] + assert_includes body["response_types_supported"], "code" + assert_includes body["code_challenge_methods_supported"], "S256" + end + + test "protected resource metadata includes authorization server" do + untenanted do + get "/.well-known/oauth-protected-resource" + end + + assert_response :success + body = response.parsed_body + + assert_equal "http://www.example.com/", body["resource"] + assert_includes body["authorization_servers"], "http://www.example.com/" + end + + + # Dynamic Client Registration (RFC 7591) + + test "DCR creates client with loopback redirect" do + assert_difference "Oauth::Client.count", 1 do + untenanted do + post oauth_clients_path, params: { + client_name: "Test MCP Client", + redirect_uris: [ "http://127.0.0.1:8888/callback" ] + }, as: :json + end + end + + assert_response :created + body = response.parsed_body + + assert_not_nil body["client_id"] + assert_equal "Test MCP Client", body["client_name"] + assert_equal [ "http://127.0.0.1:8888/callback" ], body["redirect_uris"] + end + + test "DCR rejects non-loopback redirect" do + assert_no_difference "Oauth::Client.count" do + untenanted do + post oauth_clients_path, params: { + client_name: "Evil Client", + redirect_uris: [ "https://evil.com/steal" ] + }, as: :json + end + end + + assert_response :bad_request + assert_equal "invalid_redirect_uri", response.parsed_body["error"] + end + + test "DCR requires redirect_uris" do + untenanted do + post oauth_clients_path, params: { client_name: "No Redirect" }, as: :json + end + + assert_response :bad_request + assert_equal "invalid_client_metadata", response.parsed_body["error"] + end + + + # Full OAuth Flow + + test "complete authorization code flow" do + sign_in_as :david + client = oauth_clients(:mcp_client) + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + # Step 1: Get consent screen + untenanted do + get new_oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://127.0.0.1:8888/callback", + response_type: "code", + code_challenge: code_challenge, + code_challenge_method: "S256", + scope: "read", + state: "test_state" + } + end + assert_response :success + + # Step 2: Grant consent + untenanted do + post oauth_authorization_path, params: { + client_id: client.client_id, + redirect_uri: "http://127.0.0.1:8888/callback", + response_type: "code", + code_challenge: code_challenge, + code_challenge_method: "S256", + scope: "read", + state: "test_state" + } + end + assert_response :redirect + + # Extract code from redirect + redirect_uri = URI.parse(response.location) + params = CGI.parse(redirect_uri.query) + code = params["code"].first + assert_not_nil code + assert_equal "test_state", params["state"].first + + # Step 3: Exchange code for token + assert_difference "Identity::AccessToken.count", 1 do + untenanted do + post oauth_token_path, params: { + grant_type: "authorization_code", + code: code, + redirect_uri: "http://127.0.0.1:8888/callback", + code_verifier: code_verifier + }, as: :json + end + end + + assert_response :success + body = response.parsed_body + assert_not_nil body["access_token"] + assert_equal "Bearer", body["token_type"] + end +end diff --git a/test/models/oauth/authorization_code_test.rb b/test/models/oauth/authorization_code_test.rb new file mode 100644 index 0000000000..67610bcee2 --- /dev/null +++ b/test/models/oauth/authorization_code_test.rb @@ -0,0 +1,137 @@ +require "test_helper" + +class Oauth::AuthorizationCodeTest < ActiveSupport::TestCase + test "generate creates encrypted code" do + code = Oauth::AuthorizationCode.generate \ + client_id: "test_client", + identity_id: 123, + code_challenge: "abc123", + redirect_uri: "http://127.0.0.1:8888/callback", + scope: "read" + + assert_kind_of String, code + assert code.length > 50, "Encrypted code should be reasonably long" + end + + test "parse decrypts valid code" do + code = Oauth::AuthorizationCode.generate \ + client_id: "test_client", + identity_id: 456, + code_challenge: "challenge_hash", + redirect_uri: "http://127.0.0.1:8888/callback", + scope: "read write" + + parsed = Oauth::AuthorizationCode.parse(code) + + assert_not_nil parsed + assert_equal "test_client", parsed.client_id + assert_equal 456, parsed.identity_id + assert_equal "challenge_hash", parsed.code_challenge + assert_equal "http://127.0.0.1:8888/callback", parsed.redirect_uri + assert_equal "read write", parsed.scope + end + + test "parse returns nil for blank code" do + assert_nil Oauth::AuthorizationCode.parse("") + assert_nil Oauth::AuthorizationCode.parse(nil) + end + + test "parse returns nil for invalid code" do + assert_nil Oauth::AuthorizationCode.parse("garbage_data_here") + end + + test "parse returns nil for tampered code" do + code = Oauth::AuthorizationCode.generate \ + client_id: "test_client", + identity_id: 123, + code_challenge: "abc", + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + tampered = code[0...-10] + "XXXXXXXXXX" + assert_nil Oauth::AuthorizationCode.parse(tampered) + end + + test "parse returns nil for expired code" do + code = Oauth::AuthorizationCode.generate \ + client_id: "test_client", + identity_id: 123, + code_challenge: "abc", + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + travel 65.seconds do + assert_nil Oauth::AuthorizationCode.parse(code) + end + end + + test "code is valid within 60 second window" do + code = Oauth::AuthorizationCode.generate \ + client_id: "test_client", + identity_id: 123, + code_challenge: "abc", + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + travel 55.seconds do + parsed = Oauth::AuthorizationCode.parse(code) + assert_not_nil parsed + assert_equal "test_client", parsed.client_id + end + end + + test "valid_pkce? returns true for correct S256 verifier" do + code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + details = Oauth::AuthorizationCode::Details.new \ + client_id: "test", + identity_id: 1, + code_challenge: code_challenge, + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + assert Oauth::AuthorizationCode.valid_pkce?(details, code_verifier) + end + + test "valid_pkce? returns false for wrong verifier" do + code_verifier = "correct_verifier" + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + + details = Oauth::AuthorizationCode::Details.new \ + client_id: "test", + identity_id: 1, + code_challenge: code_challenge, + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + assert_not Oauth::AuthorizationCode.valid_pkce?(details, "wrong_verifier") + end + + test "valid_pkce? returns false for nil code details" do + assert_not Oauth::AuthorizationCode.valid_pkce?(nil, "verifier") + end + + test "valid_pkce? returns false for blank verifier" do + details = Oauth::AuthorizationCode::Details.new \ + client_id: "test", + identity_id: 1, + code_challenge: "challenge", + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + assert_not Oauth::AuthorizationCode.valid_pkce?(details, "") + assert_not Oauth::AuthorizationCode.valid_pkce?(details, nil) + end + + test "auth code details are immutable" do + details = Oauth::AuthorizationCode::Details.new \ + client_id: "test", + identity_id: 1, + code_challenge: "challenge", + redirect_uri: "http://127.0.0.1/cb", + scope: "read" + + assert_raises(FrozenError) { details.instance_variable_set(:@client_id, "hacked") } + end +end diff --git a/test/models/oauth/client_test.rb b/test/models/oauth/client_test.rb new file mode 100644 index 0000000000..55108b564e --- /dev/null +++ b/test/models/oauth/client_test.rb @@ -0,0 +1,137 @@ +require "test_helper" + +class Oauth::ClientTest < ActiveSupport::TestCase + test "generates client_id on create" do + client = Oauth::Client.create!(name: "Test", redirect_uris: %w[ http://127.0.0.1:8888/callback ]) + assert_equal 32, client.client_id.length + assert_match(/\A[a-zA-Z0-9]+\z/, client.client_id) + end + + test "client_id must be unique" do + existing = oauth_clients(:mcp_client) + client = Oauth::Client.new(name: "Dupe", client_id: existing.client_id, redirect_uris: %w[ http://127.0.0.1/cb ]) + assert_not client.valid? + assert_includes client.errors[:client_id], "has already been taken" + end + + test "name is required" do + client = Oauth::Client.new(redirect_uris: %w[ http://127.0.0.1/cb ]) + assert_not client.valid? + assert_includes client.errors[:name], "can't be blank" + end + + test "redirect_uris required" do + client = Oauth::Client.new(name: "Test") + assert_not client.valid? + assert_includes client.errors[:redirect_uris], "can't be blank" + end + + test "dynamically registered clients must use http loopback URIs" do + client = Oauth::Client.new( + name: "External", + redirect_uris: %w[ https://evil.com/callback ], + dynamically_registered: true + ) + assert_not client.valid? + assert_includes client.errors[:redirect_uris], "must be a local loopback URI for dynamically registered clients" + end + + test "dynamically registered clients reject https loopback" do + client = Oauth::Client.new( + name: "HTTPS Loopback", + redirect_uris: %w[ https://127.0.0.1:8888/callback ], + dynamically_registered: true + ) + assert_not client.valid? + assert_includes client.errors[:redirect_uris], "must be a local loopback URI for dynamically registered clients" + end + + test "redirect URIs must not contain fragments" do + client = Oauth::Client.new( + name: "Fragment", + redirect_uris: %w[ http://127.0.0.1:8888/callback#section ], + dynamically_registered: true + ) + assert_not client.valid? + assert_includes client.errors[:redirect_uris], "must not contain fragments" + end + + test "dynamically registered clients can use 127.0.0.1" do + client = Oauth::Client.new( + name: "Loopback", + redirect_uris: %w[ http://127.0.0.1:9999/callback ], + dynamically_registered: true + ) + assert client.valid? + end + + test "dynamically registered clients can use localhost" do + client = Oauth::Client.new( + name: "Localhost", + redirect_uris: %w[ http://localhost:9999/callback ], + dynamically_registered: true + ) + assert client.valid? + end + + test "dynamically registered clients can use IPv6 loopback" do + client = Oauth::Client.new( + name: "IPv6", + redirect_uris: %w[ http://[::1]:9999/callback ], + dynamically_registered: true + ) + assert client.valid? + end + + test "loopback? returns true for loopback-only clients" do + client = Oauth::Client.new(redirect_uris: %w[ http://127.0.0.1:8888/cb http://localhost:9999/cb ]) + assert client.loopback? + end + + test "loopback? returns false for non-loopback clients" do + client = Oauth::Client.new(redirect_uris: %w[ https://example.com/cb ]) + assert_not client.loopback? + end + + test "allows_redirect? matches exact URI" do + client = Oauth::Client.new(redirect_uris: %w[ http://127.0.0.1:8888/callback ]) + assert client.allows_redirect?("http://127.0.0.1:8888/callback") + assert_not client.allows_redirect?("http://127.0.0.1:8888/other") + end + + test "allows_redirect? allows different ports for loopback clients" do + client = Oauth::Client.new(redirect_uris: %w[ http://127.0.0.1:8888/callback ]) + assert client.allows_redirect?("http://127.0.0.1:9999/callback") + assert client.allows_redirect?("http://localhost:7777/callback") + end + + test "allows_redirect? requires matching path for loopback flexibility" do + client = Oauth::Client.new(redirect_uris: %w[ http://127.0.0.1:8888/callback ]) + assert_not client.allows_redirect?("http://127.0.0.1:9999/other") + end + + test "allows_scope? checks client scopes" do + client = Oauth::Client.new(scopes: %w[ read write ]) + assert client.allows_scope?("read") + assert client.allows_scope?("write") + assert client.allows_scope?("read write") + assert_not client.allows_scope?("admin") + assert_not client.allows_scope?("read admin") + assert_not client.allows_scope?("") + end + + test "default scopes are set" do + client = Oauth::Client.new(name: "Test", redirect_uris: %w[ http://127.0.0.1/cb ]) + assert_equal %w[ read ], client.scopes + end + + test "trusted scope" do + trusted = Oauth::Client.trusted + assert trusted.all?(&:trusted?) + end + + test "dynamically_registered scope" do + dcr_clients = Oauth::Client.dynamically_registered + assert dcr_clients.all?(&:dynamically_registered?) + end +end From 39504c4f9b66b7ab4ba5e7dab0d67f6a76f85393 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Jan 2026 12:42:19 -0800 Subject: [PATCH 02/32] OAuth discovery and dynamic client registration Discovery endpoints (RFC 8414): - /.well-known/oauth-authorization-server - /.well-known/oauth-protected-resource Dynamic Client Registration (RFC 7591): - POST /oauth/clients for MCP clients - Loopback redirects only (127.0.0.1, localhost, [::1]) - Rate limited to 10 requests/minute --- app/controllers/oauth/clients_controller.rb | 75 +++++++++++++++++++ app/controllers/oauth/metadata_controller.rb | 18 +++++ .../protected_resource_metadata_controller.rb | 12 +++ config/routes.rb | 4 + 4 files changed, 109 insertions(+) create mode 100644 app/controllers/oauth/clients_controller.rb create mode 100644 app/controllers/oauth/metadata_controller.rb create mode 100644 app/controllers/oauth/protected_resource_metadata_controller.rb 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/config/routes.rb b/config/routes.rb index 0d46fa6e6d..a78c3e2a43 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,10 +1,14 @@ Rails.application.routes.draw do root "events#index" + get "/.well-known/oauth-authorization-server", to: "oauth/metadata#show" + get "/.well-known/oauth-protected-resource", to: "oauth/protected_resource_metadata#show" + namespace :oauth do resource :authorization, only: %i[ new create ] resource :token, only: :create resource :revocation, only: :create + resources :clients, only: :create end namespace :account do From 2bbf396b20bf253ed0b2b0eeeed16416d447bd7c Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Jan 2026 12:42:55 -0800 Subject: [PATCH 03/32] Connected Apps UI for OAuth grant management View and revoke OAuth client access at /my/connected_apps. Scoped through identity's OAuth tokens for proper authorization. Links from access tokens index for discoverability. --- .../my/connected_apps_controller.rb | 28 +++++++++ .../my/connected_apps/_connected_app.html.erb | 13 ++++ app/views/my/connected_apps/index.html.erb | 32 ++++++++++ app/views/users/_access_tokens.html.erb | 2 +- app/views/users/_connected_apps.html.erb | 4 ++ app/views/users/show.html.erb | 1 + config/routes.rb | 1 + .../my/connected_apps_controller_test.rb | 61 +++++++++++++++++++ 8 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 app/controllers/my/connected_apps_controller.rb create mode 100644 app/views/my/connected_apps/_connected_app.html.erb create mode 100644 app/views/my/connected_apps/index.html.erb create mode 100644 app/views/users/_connected_apps.html.erb create mode 100644 test/controllers/my/connected_apps_controller_test.rb 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/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 %> +
+ <%= back_link_to "My profile", user_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+ +

<%= @page_title %>

+<% end %> + +
+ <% if @connected_apps.any? %> +

Apps you've authorized to access Fizzy on your behalf.

+ + + + + + + + + + + <% @connected_apps.each do |client, tokens| %> + <%= render "my/connected_apps/connected_app", client: client, tokens: tokens %> + <% end %> + +
AppPermissionsConnected
+ <% 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/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/config/routes.rb b/config/routes.rb index a78c3e2a43..756cc41ad9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -178,6 +178,7 @@ namespace :my do resource :identity, only: :show resources :access_tokens + resources :connected_apps, only: %i[ index destroy ] resources :pins resource :timezone resource :menu diff --git a/test/controllers/my/connected_apps_controller_test.rb b/test/controllers/my/connected_apps_controller_test.rb new file mode 100644 index 0000000000..d67ba5b17c --- /dev/null +++ b/test/controllers/my/connected_apps_controller_test.rb @@ -0,0 +1,61 @@ +require "test_helper" + +class My::ConnectedAppsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :david + @identity = identities(:david) + end + + test "index shows connected OAuth apps" do + client = oauth_clients(:mcp_client) + @identity.access_tokens.create!(oauth_client: client, permission: :read) + + get my_connected_apps_path + assert_response :success + assert_match client.name, response.body + end + + test "index excludes PATs" do + # PAT has no oauth_client + @identity.access_tokens.create!(permission: :read, description: "My PAT") + + get my_connected_apps_path + assert_response :success + assert_no_match "My PAT", response.body + end + + test "destroy revokes all tokens for a client" do + client = oauth_clients(:mcp_client) + @identity.access_tokens.create!(oauth_client: client, permission: :read) + @identity.access_tokens.create!(oauth_client: client, permission: :write) + + assert_difference "Identity::AccessToken.count", -2 do + delete my_connected_app_path(client) + end + + assert_redirected_to my_connected_apps_path + assert_match "disconnected", flash[:notice] + end + + test "destroy only revokes tokens for the specified client" do + client1 = oauth_clients(:mcp_client) + client2 = Oauth::Client.create!(name: "Other App", redirect_uris: %w[ http://127.0.0.1/cb ]) + + @identity.access_tokens.create!(oauth_client: client1, permission: :read) + @identity.access_tokens.create!(oauth_client: client2, permission: :read) + + assert_difference "Identity::AccessToken.count", -1 do + delete my_connected_app_path(client1) + end + + assert @identity.access_tokens.exists?(oauth_client: client2) + end + + test "destroy returns 404 for unconnected client" do + client = oauth_clients(:mcp_client) + # No tokens for this client + + delete my_connected_app_path(client) + assert_response :not_found + end +end From 3329f6034c8537bbac957b9656535466589928bb Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 01:53:00 -0800 Subject: [PATCH 04/32] Fizzy CLI foundation Agent-first CLI for Fizzy API interaction: - OAuth 2.1 with RFC 8414 discovery, DCR, and PKCE - 7-layer config hierarchy (system/user/repo/local/env/flags) - HTTP client with ETag caching and exponential backoff - JSON/Markdown output with auto-detection - 72 passing bats tests --- cli/PLAN.md | 1083 +++++++++++++++++++++++++++++++++++++ cli/bin/fizzy | 110 ++++ cli/lib/api.sh | 582 ++++++++++++++++++++ cli/lib/auth.sh | 707 ++++++++++++++++++++++++ cli/lib/commands/help.sh | 371 +++++++++++++ cli/lib/config.sh | 418 ++++++++++++++ cli/lib/core.sh | 395 ++++++++++++++ cli/test/auth.bats | 362 +++++++++++++ cli/test/config.bats | 427 +++++++++++++++ cli/test/core.bats | 153 ++++++ cli/test/run.sh | 13 + cli/test/test_helper.bash | 230 ++++++++ 12 files changed, 4851 insertions(+) create mode 100644 cli/PLAN.md create mode 100755 cli/bin/fizzy create mode 100644 cli/lib/api.sh create mode 100644 cli/lib/auth.sh create mode 100644 cli/lib/commands/help.sh create mode 100644 cli/lib/config.sh create mode 100644 cli/lib/core.sh create mode 100644 cli/test/auth.bats create mode 100644 cli/test/config.bats create mode 100644 cli/test/core.bats create mode 100755 cli/test/run.sh create mode 100644 cli/test/test_helper.bash diff --git a/cli/PLAN.md b/cli/PLAN.md new file mode 100644 index 0000000000..3206e9c2ed --- /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_ACCESS_TOKEN` env var or `--token` flag + +### 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/bin/fizzy b/cli/bin/fizzy new file mode 100755 index 0000000000..8b11b60e0b --- /dev/null +++ b/cli/bin/fizzy @@ -0,0 +1,110 @@ +#!/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" + +# 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 "$@" ;; + 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_create "$@" ;; + close) cmd_close "$@" ;; + reopen) cmd_reopen "$@" ;; + triage) cmd_triage "$@" ;; + untriage) cmd_untriage "$@" ;; + postpone) cmd_postpone "$@" ;; + comment) cmd_comment_create "$@" ;; + assign) cmd_assign "$@" ;; + tag) cmd_tag "$@" ;; + watch) cmd_watch "$@" ;; + unwatch) cmd_unwatch "$@" ;; + gild) cmd_gild "$@" ;; + ungild) cmd_ungild "$@" ;; + step) cmd_step "$@" ;; + react) cmd_react "$@" ;; + + # 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/lib/api.sh b/cli/lib/api.sh new file mode 100644 index 0000000000..66736beb18 --- /dev/null +++ b/cli/lib/api.sh @@ -0,0 +1,582 @@ +#!/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_ACCESS_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 + 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_ACCESS_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 +} + + +# 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 url="$FIZZY_BASE_URL/$account_slug/$path" + + 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 + + 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 [[ -z "$next_url" ]]; then + break + fi + + url="$next_url" + ((page++)) + 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..09f38df874 --- /dev/null +++ b/cli/lib/auth.sh @@ -0,0 +1,707 @@ +#!/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..." + + # Check for existing valid token + if get_access_token &>/dev/null && ! is_token_expired; then + info "Already authenticated. Use 'fizzy auth logout' first to re-authenticate." + _auth_status + return 0 + fi + + # 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_file + creds_file=$(get_credentials_path) + + if [[ -f "$creds_file" ]]; then + rm -f "$creds_file" + info "Logged out successfully" + else + info "Not logged in" + fi +} + +_auth_status() { + local format + format=$(get_format) + + local auth_status="unauthenticated" + local user_name="" + local account_slug="" + local account_name="" + local expires_at="" + local token_status="none" + local scope="" + + if get_access_token &>/dev/null; then + auth_status="authenticated" + + local creds + creds=$(load_credentials) + expires_at=$(echo "$creds" | jq -r '.expires_at // "null"') + scope=$(echo "$creds" | jq -r '.scope // empty') + + # Determine token status based on expiration + if [[ "$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 token_status "$token_status" \ + --arg account_slug "$account_slug" \ + --arg account_name "$account_name" \ + --arg expires_at "$expires_at" \ + --arg scope "$scope" \ + '{ + status: $status, + token: $token_status, + scope: (if $scope != "" then $scope 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 + echo "Status: ✓ Authenticated" + [[ -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" + 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 + + # 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) + 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" + }')" \ + "$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" + + ensure_global_config_dir + # Public clients may not have a client_secret + jq -n \ + --arg client_id "$client_id" \ + --arg client_secret "${client_secret:-}" \ + '{client_id: $client_id, client_secret: $client_secret}' \ + > "$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CLIENT_FILE" + + chmod 600 "$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CLIENT_FILE" +} + +_load_client() { + local client_file="$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CLIENT_FILE" + if [[ ! -f "$client_file" ]]; then + return 1 + fi + + client_id=$(jq -r '.client_id' "$client_file") + client_secret=$(jq -r '.client_secret // ""' "$client_file") + + # 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)..." + + local response + response=$("$timeout_cmd" "$timeout_secs" bash -c ' + while true; do + request=$(echo -e "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n

Authorization successful!

You can close this window.

" | nc -l '"$FIZZY_REDIRECT_PORT"' 2>/dev/null | head -1) + if [[ "$request" == *"GET /callback"* ]]; then + echo "$request" + break + fi + done + ') || die "Authorization timed out" $EXIT_AUTH + + # 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}" + + local response + response=$(curl -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 "client_secret=$client_secret" \ + --data-urlencode "code_verifier=$code_verifier" \ + "$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 + + if [[ "$count" -eq 1 ]]; then + local account_slug account_name + # Extract slug without leading slash + account_slug=$(echo "$accounts" | jq -r '.[0].slug | ltrimstr("/")') + account_name=$(echo "$accounts" | jq -r '.[0].name') + set_global_config "account_slug" "$account_slug" + 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_slug account_name + account_slug=$(echo "$accounts" | jq -r ".[$((choice - 1))].slug | ltrimstr(\"/\")") + account_name=$(echo "$accounts" | jq -r ".[$((choice - 1))].name") + set_global_config "account_slug" "$account_slug" + info "Selected account: $account_name ($account_slug)" + else + warn "Invalid choice, using first account" + local account_slug + account_slug=$(echo "$accounts" | jq -r '.[0].slug | ltrimstr("/")') + set_global_config "account_slug" "$account_slug" + fi + 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"} + ], + 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 + +### 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 + +# Check status +fizzy auth status +``` +EOF + fi +} diff --git a/cli/lib/commands/help.sh b/cli/lib/commands/help.sh new file mode 100644 index 0000000000..d05e1b1514 --- /dev/null +++ b/cli/lib/commands/help.sh @@ -0,0 +1,371 @@ +#!/usr/bin/env bash +# help.sh - Help and quick-start commands + + +cmd_help() { + local topic="${1:-}" + local format + format=$(get_format) + + if [[ -n "$topic" ]]; then + _help_topic "$topic" + return + fi + + if [[ "$format" == "json" ]]; then + _help_json + else + _help_md + fi +} + +cmd_quick_start() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + _quick_start_json + else + _quick_start_md + fi +} + +_help_topic() { + local topic="$1" + local format + format=$(get_format) + + case "$topic" in + auth) _help_auth ;; + config) _help_config ;; + cards) _help_cards ;; + boards) _help_boards ;; + *) + if [[ "$format" == "json" ]]; then + json_error "Unknown help topic: $topic" "not_found" + else + echo "Unknown help topic: $topic" + echo + echo "Available topics: auth, config, cards, boards" + fi + exit 1 + ;; + esac +} + +_help_json() { + jq -n \ + --arg version "$FIZZY_VERSION" \ + '{ + name: "fizzy", + version: $version, + description: "Agent-first CLI for Fizzy", + commands: { + query: ["boards", "cards", "columns", "comments", "notifications", "people", "search", "show", "tags"], + actions: ["card", "close", "reopen", "triage", "untriage", "postpone", "comment", "assign", "tag", "watch", "unwatch", "gild", "ungild", "step", "react"], + meta: ["auth", "config", "version", "help"] + }, + global_flags: [ + {"flag": "--json/-j", "description": "Force JSON output"}, + {"flag": "--md/-m", "description": "Force markdown output"}, + {"flag": "--quiet/-q", "description": "Suppress non-essential output"}, + {"flag": "--verbose/-v", "description": "Debug output"}, + {"flag": "--board/-b", "description": "Board ID or name"}, + {"flag": "--account/-a", "description": "Account slug"} + ] + }' +} + +_help_md() { + cat <<'EOF' +# fizzy + +Agent-first CLI for Fizzy. + +## USAGE + + fizzy [global-flags] [command-flags] [arguments] + +## GLOBAL FLAGS + + --json, -j Force JSON output + --md, -m Force markdown output + --quiet, -q Suppress non-essential output (data only) + --verbose, -v Debug output + --board, -b Board ID or name + --account, -a Account slug + +## COMMANDS + +### Query + boards List boards + cards List or filter cards + columns List columns on a board + comments List comments on a card + notifications List notifications + people List users in account + search Search cards + show Show card details + tags List tags + +### Actions + card Create a new card + close Close a card + reopen Reopen a closed card + triage Move card into a column + untriage Send card back to triage + postpone Move card to "Not Now" + comment Add comment to a card + assign Assign/unassign user to card + tag Toggle tag on card + watch Subscribe to card notifications + unwatch Unsubscribe from card + gild Mark card as golden + ungild Remove golden status + step Manage card steps + react Add reaction to comment + +### Meta + auth Authentication management + config Configuration management + version Show version + help Show this help + +## EXAMPLES + + fizzy boards List all boards + fizzy cards --board Fizzy List cards on Fizzy board + fizzy show 42 Show card #42 + fizzy card "New feature" Create a new card + fizzy close 42 Close card #42 + fizzy comment 42 "LGTM" Comment on card #42 + +## CONFIGURATION + + fizzy config list Show current configuration + fizzy config set key value Set a config value + fizzy config get key Get a config value + +Run `fizzy help ` for command-specific help. +EOF +} + +_quick_start_json() { + local auth_status="unauthenticated" + local account_slug="" + local board_id="" + + if get_access_token &>/dev/null; then + auth_status="authenticated" + account_slug=$(get_account_slug 2>/dev/null || true) + board_id=$(get_board_id 2>/dev/null || true) + fi + + jq -n \ + --arg version "$FIZZY_VERSION" \ + --arg auth "$auth_status" \ + --arg account "$account_slug" \ + --arg board "$board_id" \ + '{ + version: $version, + auth: $auth, + context: { + account: (if $account != "" then $account else null end), + board: (if $board != "" then $board else null end) + }, + next_steps: ( + if $auth == "unauthenticated" then + ["Run: fizzy auth login"] + elif $account == "" then + ["Run: fizzy config set account_slug "] + else + ["fizzy boards", "fizzy cards", "fizzy help"] + end + ) + }' +} + +_quick_start_md() { + echo "# fizzy $FIZZY_VERSION" + echo + + if ! get_access_token &>/dev/null; then + echo "## Getting Started" + echo + echo "Not authenticated. Run:" + echo + echo " fizzy auth login" + echo + return + fi + + local account_slug + account_slug=$(get_account_slug 2>/dev/null || true) + + if [[ -z "$account_slug" ]]; then + echo "## Setup Required" + echo + echo "Authenticated but no account configured." + echo + echo " fizzy config set account_slug " + echo + return + fi + + echo "## Ready" + echo + echo "Account: $account_slug" + + local board_id + board_id=$(get_board_id 2>/dev/null || true) + if [[ -n "$board_id" ]]; then + echo "Board: $board_id" + fi + echo + + echo "### Quick Commands" + echo + echo " fizzy boards List boards" + echo " fizzy cards List cards" + echo " fizzy show Show card details" + echo " fizzy help Full command list" + echo +} + + +_help_config() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy config", + description: "Manage configuration", + subcommands: [ + {name: "list", description: "Show current configuration"}, + {name: "get", args: ["key"], description: "Get a config value"}, + {name: "set", args: ["key", "value"], description: "Set a config value"}, + {name: "unset", args: ["key"], description: "Remove a config value"} + ], + options: [ + {flag: "--global", description: "Use global (user) config"}, + {flag: "--local", description: "Use local (.fizzy/) config"} + ], + config_keys: [ + "account_slug", + "board_id", + "column_id", + "base_url" + ] + }' + else + cat <<'EOF' +## fizzy config + +Manage configuration. + +### Subcommands + +- `list` - Show current configuration +- `get` - Get a config value +- `set` - Set a config value +- `unset` - Remove a config value + +### Options + +- `--global` - Use global (user) config +- `--local` - Use local (.fizzy/) config + +### Config Keys + +- `account_slug` - Fizzy account slug +- `board_id` - Default board ID +- `column_id` - Default column ID +- `base_url` - Fizzy server URL + +### Examples + +```bash +# Show current config +fizzy config list + +# Set account globally +fizzy config set --global account_slug 897362094 + +# Set board for this project +fizzy config set board_id abc123 +``` +EOF + fi +} + +_help_cards() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy cards", + description: "List and filter cards", + options: [ + {flag: "--board", description: "Filter by board ID or name"}, + {flag: "--search", description: "Search terms"}, + {flag: "--tag", description: "Filter by tag"}, + {flag: "--assignee", description: "Filter by assignee"}, + {flag: "--status", description: "Filter by status (all, closed, not_now, golden)"} + ] + }' + else + cat <<'EOF' +## fizzy cards + +List and filter cards. + +### Options + +- `--board` - Filter by board ID or name +- `--search` - Search terms +- `--tag` - Filter by tag +- `--assignee` - Filter by assignee +- `--status` - Filter by status (all, closed, not_now, golden) + +### Examples + +```bash +# List all cards +fizzy cards + +# Search cards +fizzy cards --search "bug fix" + +# Filter by board +fizzy cards --board Fizzy +``` +EOF + fi +} + +_help_boards() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy boards", + description: "List boards in the account" + }' + else + cat <<'EOF' +## fizzy boards + +List boards in the account. + +### Examples + +```bash +# List all boards +fizzy boards +``` +EOF + fi +} diff --git a/cli/lib/config.sh b/cli/lib/config.sh new file mode 100644 index 0000000000..3e752ec34a --- /dev/null +++ b/cli/lib/config.sh @@ -0,0 +1,418 @@ +#!/usr/bin/env bash +# config.sh - Layered configuration system for fizzy +# +# Config hierarchy (later overrides earlier): +# 1. /etc/fizzy/config.json (system-wide) +# 2. ~/.config/fizzy/config.json (user/global) +# 3. /.fizzy/config.json (repo) +# 4. /.fizzy/config.json (local, walks up tree) +# 5. Environment variables +# 6. Command-line flags + + +# Paths + +FIZZY_SYSTEM_CONFIG_DIR="${FIZZY_SYSTEM_CONFIG_DIR:-/etc/fizzy}" +FIZZY_GLOBAL_CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/fizzy" +FIZZY_LOCAL_CONFIG_DIR=".fizzy" +FIZZY_CONFIG_FILE="config.json" +FIZZY_CREDENTIALS_FILE="credentials.json" +FIZZY_CLIENT_FILE="client.json" +FIZZY_ACCOUNTS_FILE="accounts.json" + + +# Config Loading + +declare -A _FIZZY_CONFIG + +_load_config_file() { + local file="$1" + if [[ -f "$file" ]]; then + debug "Loading config from $file" + while IFS='=' read -r key value; do + _FIZZY_CONFIG["$key"]="$value" + done < <(jq -r 'to_entries | .[] | "\(.key)=\(.value)"' "$file" 2>/dev/null || true) + fi +} + +load_config() { + _FIZZY_CONFIG=() + + # Layer 1: System-wide config + _load_config_file "$FIZZY_SYSTEM_CONFIG_DIR/$FIZZY_CONFIG_FILE" + + # Layer 2: User/global config + _load_config_file "$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" + + # Layer 3: Git repo root config (if in a git repo) + local git_root + git_root=$(git rev-parse --show-toplevel 2>/dev/null || true) + if [[ -n "$git_root" ]] && [[ -f "$git_root/$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" ]]; then + _load_config_file "$git_root/$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" + fi + + # Layer 4: Local config (walk up directory tree, skip git root if already loaded) + local dir="$PWD" + local local_configs=() + while [[ "$dir" != "/" ]]; do + local config_path="$dir/$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" + # Skip if this is the git root (already loaded above) + if [[ -f "$config_path" ]] && [[ "$dir" != "$git_root" ]]; then + local_configs+=("$config_path") + fi + dir="$(dirname "$dir")" + done + + # Apply local configs from root to current (so closer overrides) + for ((i=${#local_configs[@]}-1; i>=0; i--)); do + _load_config_file "${local_configs[$i]}" + done + + # Layer 5: Environment variables + # Note: FIZZY_BASE_URL is handled separately in _update_base_url_from_config + # because core.sh sets a default before we can capture the original env value + [[ -n "${FIZZY_ACCOUNT_SLUG:-}" ]] && _FIZZY_CONFIG["account_slug"]="$FIZZY_ACCOUNT_SLUG" || true + [[ -n "${FIZZY_BOARD_ID:-}" ]] && _FIZZY_CONFIG["board_id"]="$FIZZY_BOARD_ID" || true + [[ -n "${FIZZY_COLUMN_ID:-}" ]] && _FIZZY_CONFIG["column_id"]="$FIZZY_COLUMN_ID" || true + [[ -n "${FIZZY_ACCESS_TOKEN:-}" ]] && _FIZZY_CONFIG["access_token"]="$FIZZY_ACCESS_TOKEN" || true + + # Layer 6: Command-line flags (already handled in global flag parsing) + [[ -n "${FIZZY_ACCOUNT:-}" ]] && _FIZZY_CONFIG["account_slug"]="$FIZZY_ACCOUNT" || true + [[ -n "${FIZZY_BOARD:-}" ]] && _FIZZY_CONFIG["board_id"]="$FIZZY_BOARD" || true +} + +get_config() { + local key="$1" + local default="${2:-}" + echo "${_FIZZY_CONFIG[$key]:-$default}" +} + +has_config() { + local key="$1" + [[ -n "${_FIZZY_CONFIG[$key]:-}" ]] +} + + +# Config Writing + +ensure_global_config_dir() { + mkdir -p "$FIZZY_GLOBAL_CONFIG_DIR" +} + +ensure_local_config_dir() { + mkdir -p "$FIZZY_LOCAL_CONFIG_DIR" +} + +set_global_config() { + local key="$1" + local value="$2" + + ensure_global_config_dir + local file="$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" + + if [[ -f "$file" ]]; then + local tmp + tmp=$(mktemp) + jq --arg key "$key" --arg value "$value" '.[$key] = $value' "$file" > "$tmp" + mv "$tmp" "$file" + else + jq -n --arg key "$key" --arg value "$value" '{($key): $value}' > "$file" + fi + + _FIZZY_CONFIG["$key"]="$value" +} + +set_local_config() { + local key="$1" + local value="$2" + + ensure_local_config_dir + local file="$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" + + if [[ -f "$file" ]]; then + local tmp + tmp=$(mktemp) + jq --arg key "$key" --arg value "$value" '.[$key] = $value' "$file" > "$tmp" + mv "$tmp" "$file" + else + jq -n --arg key "$key" --arg value "$value" '{($key): $value}' > "$file" + fi + + _FIZZY_CONFIG["$key"]="$value" +} + +unset_config() { + local key="$1" + local scope="${2:---local}" + + local file + if [[ "$scope" == "--global" ]]; then + file="$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" + else + file="$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" + fi + + if [[ -f "$file" ]]; then + local tmp + tmp=$(mktemp) + jq --arg key "$key" 'del(.[$key])' "$file" > "$tmp" + mv "$tmp" "$file" + fi + + unset "_FIZZY_CONFIG[$key]" +} + + +# Credentials + +get_credentials_path() { + echo "$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CREDENTIALS_FILE" +} + +load_credentials() { + local file + file=$(get_credentials_path) + if [[ -f "$file" ]]; then + cat "$file" + else + echo '{}' + fi +} + +save_credentials() { + local json="$1" + ensure_global_config_dir + local file + file=$(get_credentials_path) + echo "$json" > "$file" + chmod 600 "$file" +} + +get_access_token() { + if [[ -n "${FIZZY_ACCESS_TOKEN:-}" ]]; then + echo "$FIZZY_ACCESS_TOKEN" + return + fi + + local creds + creds=$(load_credentials) + local token + token=$(echo "$creds" | jq -r '.access_token // empty') + + if [[ -z "$token" ]]; then + return 1 + fi + + echo "$token" +} + +is_token_expired() { + local creds + creds=$(load_credentials) + local expires_at + expires_at=$(echo "$creds" | jq -r '.expires_at // "null"') + + # Long-lived tokens (null or 0 expires_at) never expire + if [[ "$expires_at" == "null" ]] || [[ "$expires_at" == "0" ]]; then + return 1 # Not expired + fi + + local now + now=$(date +%s) + (( now > expires_at - 60 )) +} + +get_token_scope() { + local creds + creds=$(load_credentials) + local scope + scope=$(echo "$creds" | jq -r '.scope // empty') + + if [[ -z "$scope" ]]; then + echo "unknown" + return 1 + fi + + echo "$scope" +} + + +# Account Management + +get_account_slug() { + if [[ -n "${FIZZY_ACCOUNT:-}" ]]; then + echo "$FIZZY_ACCOUNT" + return + fi + if [[ -n "${FIZZY_ACCOUNT_SLUG:-}" ]]; then + echo "$FIZZY_ACCOUNT_SLUG" + return + fi + get_config "account_slug" +} + +get_board_id() { + if [[ -n "${FIZZY_BOARD:-}" ]]; then + echo "$FIZZY_BOARD" + return + fi + if [[ -n "${FIZZY_BOARD_ID:-}" ]]; then + echo "$FIZZY_BOARD_ID" + return + fi + get_config "board_id" +} + +get_column_id() { + if [[ -n "${FIZZY_COLUMN_ID:-}" ]]; then + echo "$FIZZY_COLUMN_ID" + return + fi + get_config "column_id" +} + +get_git_root() { + git rev-parse --show-toplevel 2>/dev/null || true +} + +load_accounts() { + local file="$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_ACCOUNTS_FILE" + if [[ -f "$file" ]]; then + cat "$file" + else + echo '[]' + fi +} + +save_accounts() { + local json="$1" + ensure_global_config_dir + echo "$json" > "$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_ACCOUNTS_FILE" +} + + +# Client Registration + +get_client_path() { + echo "$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CLIENT_FILE" +} + +load_client() { + local file + file=$(get_client_path) + if [[ -f "$file" ]]; then + cat "$file" + else + echo '{}' + fi +} + +save_client() { + local json="$1" + ensure_global_config_dir + local file + file=$(get_client_path) + echo "$json" > "$file" + chmod 600 "$file" +} + +get_client_id() { + local client + client=$(load_client) + echo "$client" | jq -r '.client_id // empty' +} + +get_client_secret() { + local client + client=$(load_client) + echo "$client" | jq -r '.client_secret // empty' +} + + +# Config Display + +get_effective_config() { + local result='{}' + for key in "${!_FIZZY_CONFIG[@]}"; do + if [[ "$key" == "access_token" ]] || [[ "$key" == "refresh_token" ]]; then + result=$(echo "$result" | jq --arg key "$key" '.[$key] = "***"') + else + result=$(echo "$result" | jq --arg key "$key" --arg value "${_FIZZY_CONFIG[$key]}" '.[$key] = $value') + fi + done + echo "$result" +} + +get_config_source() { + local key="$1" + + # Check flags first (highest priority) + case "$key" in + account_slug) [[ -n "${FIZZY_ACCOUNT:-}" ]] && echo "flag" && return ;; + board_id) [[ -n "${FIZZY_BOARD:-}" ]] && echo "flag" && return ;; + esac + + # Check environment variables + case "$key" in + account_slug) [[ -n "${FIZZY_ACCOUNT_SLUG:-}" ]] && echo "env" && return ;; + board_id) [[ -n "${FIZZY_BOARD_ID:-}" ]] && echo "env" && return ;; + column_id) [[ -n "${FIZZY_COLUMN_ID:-}" ]] && echo "env" && return ;; + access_token) [[ -n "${FIZZY_ACCESS_TOKEN:-}" ]] && echo "env" && return ;; + esac + + # Check local (cwd) config + if [[ -f "$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" ]]; then + local local_value + local_value=$(jq -r --arg key "$key" '.[$key] // empty' "$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" 2>/dev/null) + [[ -n "$local_value" ]] && echo "local (.fizzy/)" && return + fi + + # Check git repo root config + local git_root + git_root=$(get_git_root) + if [[ -n "$git_root" ]] && [[ -f "$git_root/$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" ]]; then + local repo_value + repo_value=$(jq -r --arg key "$key" '.[$key] // empty' "$git_root/$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" 2>/dev/null) + [[ -n "$repo_value" ]] && echo "repo ($git_root/.fizzy/)" && return + fi + + # Check user/global config + if [[ -f "$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" ]]; then + local global_value + global_value=$(jq -r --arg key "$key" '.[$key] // empty' "$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" 2>/dev/null) + [[ -n "$global_value" ]] && echo "user (~/.config/fizzy/)" && return + fi + + # Check system-wide config + if [[ -f "$FIZZY_SYSTEM_CONFIG_DIR/$FIZZY_CONFIG_FILE" ]]; then + local system_value + system_value=$(jq -r --arg key "$key" '.[$key] // empty' "$FIZZY_SYSTEM_CONFIG_DIR/$FIZZY_CONFIG_FILE" 2>/dev/null) + [[ -n "$system_value" ]] && echo "system (/etc/fizzy/)" && return + fi + + echo "unset" +} + + +# Initialize + +load_config + +# Update FIZZY_BASE_URL from full config hierarchy +# This should override the initial value from core.sh (which only reads global config) +# unless FIZZY_BASE_URL was explicitly set via environment variable +_update_base_url_from_config() { + # If user explicitly set FIZZY_BASE_URL env var, respect it + if [[ -n "${_FIZZY_BASE_URL_FROM_ENV:-}" ]]; then + return + fi + + # Otherwise, apply full config hierarchy (local > repo > global > system) + local base_url + base_url=$(get_config "base_url") + if [[ -n "$base_url" ]]; then + FIZZY_BASE_URL="$base_url" + fi +} + +_update_base_url_from_config diff --git a/cli/lib/core.sh b/cli/lib/core.sh new file mode 100644 index 0000000000..f839228b3e --- /dev/null +++ b/cli/lib/core.sh @@ -0,0 +1,395 @@ +#!/usr/bin/env bash +# core.sh - Core utilities for fizzy +# Output formatting, response envelope, global flags + +# Environment Configuration +# FIZZY_BASE_URL - Base URL for Fizzy (web app + API) +# In production: https://app.fizzy.do +# In development: http://fizzy.localhost:3006 + +# Capture original env value before any modifications (used by config.sh) +# This marker tells config.sh whether FIZZY_BASE_URL was explicitly set by user +_FIZZY_BASE_URL_FROM_ENV="${FIZZY_BASE_URL:-}" + +# Load URL from config if not set via environment +# Note: This only reads global config; config.sh will later apply full hierarchy +_load_url_config() { + local config_file="${XDG_CONFIG_HOME:-$HOME/.config}/fizzy/config.json" + if [[ -f "$config_file" ]]; then + local base_url + base_url=$(jq -r '.base_url // empty' "$config_file" 2>/dev/null) || true + if [[ -z "${FIZZY_BASE_URL:-}" ]] && [[ -n "$base_url" ]]; then + FIZZY_BASE_URL="$base_url" + fi + fi +} + +_load_url_config + +# Apply defaults if still not set +FIZZY_BASE_URL="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + + +# Global State + +FIZZY_FORMAT="${FIZZY_FORMAT:-auto}" # Output format: auto, json, md +FIZZY_QUIET="${FIZZY_QUIET:-false}" # Suppress non-essential output +FIZZY_VERBOSE="${FIZZY_VERBOSE:-false}" # Debug output +GLOBAL_FLAGS_CONSUMED=0 # For shift in main + + +# Output Format Detection + +is_tty() { + [[ -t 1 ]] +} + +get_format() { + case "$FIZZY_FORMAT" in + json) echo "json" ;; + md|markdown) echo "md" ;; + auto) + if is_tty; then + echo "md" + else + echo "json" + fi + ;; + *) echo "json" ;; + esac +} + + +# JSON Response Building + +json_ok() { + local data="$1" + local summary="${2:-}" + local breadcrumbs="${3:-[]}" + local context="${4:-"{}"}" + local meta="${5:-"{}"}" + + jq -n \ + --argjson data "$data" \ + --arg summary "$summary" \ + --argjson breadcrumbs "$breadcrumbs" \ + --argjson context "$context" \ + --argjson meta "$meta" \ + '{ + ok: true, + data: $data, + summary: $summary, + breadcrumbs: $breadcrumbs, + context: $context, + meta: $meta + }' +} + +json_error() { + local message="$1" + local code="${2:-error}" + local hint="${3:-}" + + if [[ -n "$hint" ]]; then + jq -n \ + --arg message "$message" \ + --arg code "$code" \ + --arg hint "$hint" \ + '{ok: false, error: $message, code: $code, hint: $hint}' + else + jq -n \ + --arg message "$message" \ + --arg code "$code" \ + '{ok: false, error: $message, code: $code}' + fi +} + + +# Breadcrumb Generation + +breadcrumb() { + local action="$1" + local cmd="$2" + local description="${3:-}" + + jq -n \ + --arg action "$action" \ + --arg cmd "$cmd" \ + --arg description "$description" \ + '{action: $action, cmd: $cmd, description: $description}' +} + +breadcrumbs() { + local result="[" + local first=true + for bc in "$@"; do + if [[ "$first" == "true" ]]; then + first=false + else + result+="," + fi + result+="$bc" + done + result+="]" + echo "$result" +} + + +# Markdown Output + +md_heading() { + local level="${1:-2}" + local text="$2" + local prefix="" + for ((i=0; i /dev/null; then + "$md_renderer" "$data" "$summary" "$breadcrumbs" "$context" "$meta" + else + if [[ -n "$summary" ]]; then + echo "$summary" + echo + fi + echo '```json' + echo "$data" | jq . + echo '```' + echo + md_breadcrumbs "$breadcrumbs" + fi + fi +} + +output_error() { + local message="$1" + local code="${2:-error}" + local hint="${3:-}" + + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + json_error "$message" "$code" "$hint" >&2 + else + echo "Error: $message" >&2 + if [[ -n "$hint" ]]; then + echo >&2 + echo "$hint" >&2 + fi + fi +} + + +# Exit Codes + +EXIT_OK=0 +EXIT_USAGE=1 +EXIT_NOT_FOUND=2 +EXIT_AUTH=3 +EXIT_FORBIDDEN=4 +EXIT_RATE_LIMIT=5 +EXIT_NETWORK=6 +EXIT_API=7 +EXIT_AMBIGUOUS=8 + +die() { + local message="$1" + local code="${2:-1}" + local hint="${3:-}" + + local error_code="error" + case "$code" in + $EXIT_USAGE) error_code="usage" ;; + $EXIT_NOT_FOUND) error_code="not_found" ;; + $EXIT_AUTH) error_code="auth_required" ;; + $EXIT_FORBIDDEN) error_code="forbidden" ;; + $EXIT_RATE_LIMIT) error_code="rate_limit" ;; + $EXIT_NETWORK) error_code="network" ;; + $EXIT_API) error_code="api_error" ;; + $EXIT_AMBIGUOUS) error_code="ambiguous" ;; + esac + + output_error "$message" "$error_code" "$hint" + exit "$code" +} + + +# Global Flag Parsing + +parse_global_flags() { + GLOBAL_FLAGS_CONSUMED=0 + + while [[ $# -gt 0 ]]; do + case "$1" in + --json|-j) + FIZZY_FORMAT="json" + (( ++GLOBAL_FLAGS_CONSUMED )) + shift + ;; + --md|-m|--markdown) + FIZZY_FORMAT="md" + (( ++GLOBAL_FLAGS_CONSUMED )) + shift + ;; + --quiet|-q|--data) + FIZZY_QUIET="true" + (( ++GLOBAL_FLAGS_CONSUMED )) + shift + ;; + --verbose|-v) + FIZZY_VERBOSE="true" + (( ++GLOBAL_FLAGS_CONSUMED )) + shift + ;; + --board|-b) + if [[ -z "${2:-}" ]]; then + die "--board requires a value" $EXIT_USAGE + fi + FIZZY_BOARD="$2" + (( GLOBAL_FLAGS_CONSUMED += 2 )) + shift 2 + ;; + --account|-a) + if [[ -z "${2:-}" ]]; then + die "--account requires a value" $EXIT_USAGE + fi + FIZZY_ACCOUNT="$2" + (( GLOBAL_FLAGS_CONSUMED += 2 )) + shift 2 + ;; + --token) + if [[ -z "${2:-}" ]]; then + die "--token requires a value" $EXIT_USAGE + fi + FIZZY_ACCESS_TOKEN="$2" + (( GLOBAL_FLAGS_CONSUMED += 2 )) + shift 2 + ;; + --) + (( ++GLOBAL_FLAGS_CONSUMED )) + break + ;; + -*) + break + ;; + *) + break + ;; + esac + done +} + + +# Logging + +debug() { + if [[ "$FIZZY_VERBOSE" == "true" ]]; then + echo "[debug] $*" >&2 + fi +} + +info() { + if [[ "$FIZZY_QUIET" != "true" ]]; then + echo "$*" >&2 + fi +} + +warn() { + echo "Warning: $*" >&2 +} + + +# Utilities + +has_command() { + command -v "$1" &> /dev/null +} + +require_command() { + local cmd="$1" + local hint="${2:-Please install $cmd}" + if ! has_command "$cmd"; then + die "Required command not found: $cmd" $EXIT_USAGE "$hint" + fi +} + +urlencode() { + local string="$1" + python3 -c "import urllib.parse; print(urllib.parse.quote('''$string''', safe=''))" +} diff --git a/cli/test/auth.bats b/cli/test/auth.bats new file mode 100644 index 0000000000..ac42ee2112 --- /dev/null +++ b/cli/test/auth.bats @@ -0,0 +1,362 @@ +#!/usr/bin/env bats +# auth.bats - Tests for lib/auth.sh + +load test_helper + + +# Auth status + +@test "auth status shows unauthenticated when no credentials" { + run fizzy --md auth status + assert_success + assert_output_contains "Not authenticated" +} + +@test "auth status --json shows unauthenticated" { + run fizzy --json auth status + assert_success + is_valid_json + assert_json_value ".status" "unauthenticated" +} + +@test "auth status shows authenticated with valid credentials" { + create_credentials "test-token" "$(($(date +%s) + 3600))" "write" + create_accounts + + run fizzy --md auth status + assert_success + assert_output_contains "Authenticated" +} + +@test "auth status --json shows authenticated" { + create_credentials "test-token" "$(($(date +%s) + 3600))" "write" + + run fizzy --json auth status + assert_success + is_valid_json + assert_json_value ".status" "authenticated" + assert_json_value ".token" "valid" +} + +@test "auth status shows expired token warning" { + create_credentials "test-token" "$(($(date +%s) - 100))" "write" + + run fizzy --md auth status + assert_success + assert_output_contains "Expired" +} + +@test "auth status --json shows expired token" { + create_credentials "test-token" "$(($(date +%s) - 100))" "write" + + run fizzy --json auth status + assert_success + is_valid_json + assert_json_value ".token" "expired" +} + + +# Auth logout + +@test "auth logout removes credentials file" { + create_credentials "test-token" + + run fizzy auth logout + assert_success + assert_output_contains "Logged out" + + [[ ! -f "$TEST_HOME/.config/fizzy/credentials.json" ]] +} + +@test "auth logout when not logged in" { + run fizzy auth logout + assert_success + assert_output_contains "Not logged in" +} + + +# Auth help + +@test "auth --help shows help" { + run fizzy auth --help + assert_success + assert_output_contains "login" + assert_output_contains "logout" + assert_output_contains "status" +} + +@test "auth -h shows help" { + run fizzy auth -h + assert_success + assert_output_contains "login" +} + + +# Auth scope display + +@test "auth status shows write scope" { + create_credentials "test-token" "$(($(date +%s) + 3600))" "write" + + run fizzy --md auth status + assert_success + assert_output_contains "write" + assert_output_contains "read+write" +} + +@test "auth status shows read scope" { + create_credentials "test-token" "$(($(date +%s) + 3600))" "read" + + run fizzy --md auth status + assert_success + assert_output_contains "read" + assert_output_contains "read-only" +} + + +# PKCE helpers (testing internal functions) + +@test "_generate_code_verifier produces 43+ char string" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/auth.sh" + + result=$(_generate_code_verifier) + [[ ${#result} -ge 43 ]] +} + +@test "_generate_code_challenge produces non-empty string" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/auth.sh" + + verifier=$(_generate_code_verifier) + result=$(_generate_code_challenge "$verifier") + [[ -n "$result" ]] +} + +@test "_generate_state produces 32 char hex string" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/auth.sh" + + result=$(_generate_state) + [[ ${#result} -eq 32 ]] + [[ "$result" =~ ^[0-9a-f]+$ ]] +} + + +# Client loading + +@test "_load_client returns failure when no client file" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/auth.sh" + + ! _load_client +} + +@test "_load_client sets client_id from file" { + create_client + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/auth.sh" + + _load_client + [[ "$client_id" == "test-client-id" ]] +} + + +# Account selection + +@test "account name shown in status with single account" { + create_credentials "test-token" "$(($(date +%s) + 3600))" + create_global_config '{"account_slug": "99999999"}' + cat > "$TEST_HOME/.config/fizzy/accounts.json" << 'EOF' +[ + {"id": "test-id", "name": "Test Account", "slug": "/99999999"} +] +EOF + + run fizzy --md auth status + assert_success + assert_output_contains "Test Account" +} + + +# Unknown auth action + +@test "auth unknown action shows error" { + run fizzy auth unknownaction + assert_failure + assert_output_contains "Unknown auth action" +} + + +# Long-lived tokens (Fizzy's token model) + +@test "auth status shows long-lived token" { + create_long_lived_credentials "test-token" "write" + + run fizzy --md auth status + assert_success + assert_output_contains "Authenticated" + assert_output_contains "Long-lived" +} + +@test "auth status --json shows long-lived token" { + create_long_lived_credentials "test-token" "write" + + run fizzy --json auth status + assert_success + is_valid_json + assert_json_value ".status" "authenticated" + assert_json_value ".token" "long-lived" +} + +@test "is_token_expired returns false for long-lived token" { + create_long_lived_credentials "test-token" "write" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + ! is_token_expired +} + +@test "auth refresh with long-lived token shows informative message" { + create_long_lived_credentials "test-token" "write" + create_accounts + + run fizzy --md auth refresh + assert_success + assert_output_contains "long-lived" + assert_output_contains "doesn't require refresh" +} + +@test "auth status treats expires_at 0 as long-lived" { + # Create credentials with expires_at: 0 (edge case) + cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF +{ + "access_token": "test-token", + "refresh_token": "", + "scope": "write", + "expires_at": 0 +} +EOF + chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" + + run fizzy --md auth status + assert_success + assert_output_contains "Authenticated" + assert_output_contains "Long-lived" +} + +@test "auth refresh treats expires_at 0 as long-lived" { + # Create credentials with expires_at: 0 (edge case) + cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF +{ + "access_token": "test-token", + "refresh_token": "", + "scope": "write", + "expires_at": 0 +} +EOF + chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" + create_accounts + + run fizzy --md auth refresh + assert_success + assert_output_contains "long-lived" + assert_output_contains "doesn't require refresh" +} + +@test "is_token_expired returns false for expires_at 0" { + cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF +{ + "access_token": "test-token", + "refresh_token": "", + "scope": "write", + "expires_at": 0 +} +EOF + chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + ! is_token_expired +} + +@test "auth refresh without refresh_token but with expiry prompts re-login" { + # Token has expiry but no refresh token - should prompt re-login + cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF +{ + "access_token": "test-token", + "refresh_token": "", + "scope": "write", + "expires_at": $(($(date +%s) + 3600)) +} +EOF + chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" + + run fizzy auth refresh + assert_failure + assert_output_contains "No refresh token available" + assert_output_contains "fizzy auth login" +} + +@test "refresh_token uses discovered token endpoint" { + # This test verifies that refresh_token() calls _token_endpoint() from discovery + # rather than hardcoding the endpoint path + + # Create credentials with a refresh token + cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF +{ + "access_token": "old-token", + "refresh_token": "test-refresh-token", + "scope": "write", + "expires_at": $(($(date +%s) - 100)) +} +EOF + chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" + + # Create client credentials + create_client + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + + # Stub _token_endpoint to return a test URL and verify it's called + _token_endpoint() { + echo "https://discovered.example.com/oauth/token" + } + + # Stub curl to capture the URL it's called with + curl() { + # Find the URL argument (last positional arg after all options) + local url="" + for arg in "$@"; do + if [[ "$arg" == http* ]]; then + url="$arg" + fi + done + echo "CURL_URL=$url" >&2 + # Return a failure response so refresh_token returns 1 + echo '{"error": "test_stub"}' + } + + # Run refresh_token and capture stderr + output=$(refresh_token 2>&1) || true + + # Verify the discovered endpoint was used + [[ "$output" == *"https://discovered.example.com/oauth/token"* ]] +} + +@test "auth refresh when not authenticated fails" { + # No credentials file exists + run fizzy auth refresh + assert_failure + assert_output_contains "Not authenticated" + assert_output_contains "fizzy auth login" +} diff --git a/cli/test/config.bats b/cli/test/config.bats new file mode 100644 index 0000000000..a493fb987a --- /dev/null +++ b/cli/test/config.bats @@ -0,0 +1,427 @@ +#!/usr/bin/env bats +# config.bats - Tests for lib/config.sh + +load test_helper + + +# Config loading + +@test "loads global config" { + create_global_config '{"account_slug": "12345"}' + + # Source the lib directly to test + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config "account_slug") + [[ "$result" == "12345" ]] +} + +@test "loads local config" { + create_local_config '{"board_id": "67890"}' + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config "board_id") + [[ "$result" == "67890" ]] +} + +@test "local config overrides global config" { + create_global_config '{"board_id": "global-123"}' + create_local_config '{"board_id": "local-456"}' + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config "board_id") + [[ "$result" == "local-456" ]] +} + +@test "environment variable overrides config file" { + create_global_config '{"account_slug": "from-file"}' + export FIZZY_ACCOUNT_SLUG="from-env" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config "account_slug") + [[ "$result" == "from-env" ]] +} + + +# Config defaults + +@test "get_config returns default for missing key" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config "nonexistent" "default-value") + [[ "$result" == "default-value" ]] +} + +@test "has_config returns false for missing key" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + ! has_config "nonexistent" +} + +@test "has_config returns true for existing key" { + create_global_config '{"account_slug": "12345"}' + + source "$FIZZY_ROOT/lib/core.sh" + FIZZY_GLOBAL_CONFIG_DIR="$HOME/.config/fizzy" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + has_config "account_slug" +} + + +# Credentials + +@test "loads credentials from file" { + create_credentials "my-test-token" "$(($(date +%s) + 3600))" + unset FIZZY_ACCESS_TOKEN + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + result=$(get_access_token) + [[ "$result" == "my-test-token" ]] +} + +@test "environment token overrides file" { + create_credentials "file-token" + export FIZZY_ACCESS_TOKEN="env-token" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + result=$(get_access_token) + [[ "$result" == "env-token" ]] +} + +@test "is_token_expired returns true for expired token" { + create_credentials "test-token" "$(($(date +%s) - 100))" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + is_token_expired +} + +@test "is_token_expired returns false for valid token" { + create_credentials "test-token" "$(($(date +%s) + 3600))" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + ! is_token_expired +} + + +# Account/Board getters + +@test "get_account_slug from config" { + create_global_config '{"account_slug": "99999"}' + unset FIZZY_ACCOUNT_SLUG + unset FIZZY_ACCOUNT + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_account_slug) + [[ "$result" == "99999" ]] +} + +@test "get_board_id from config" { + create_local_config '{"board_id": "88888"}' + unset FIZZY_BOARD_ID + unset FIZZY_BOARD + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_board_id) + [[ "$result" == "88888" ]] +} + + +# URL configuration + +@test "loads base_url from config" { + create_global_config '{"base_url": "http://dev.example.com"}' + unset FIZZY_BASE_URL + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_BASE_URL" == "http://dev.example.com" ]] +} + +@test "environment FIZZY_BASE_URL overrides config" { + create_global_config '{"base_url": "http://from-config.com"}' + export FIZZY_BASE_URL="http://from-env.com" + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_BASE_URL" == "http://from-env.com" ]] +} + +@test "defaults to dev server when no config" { + unset FIZZY_BASE_URL + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_BASE_URL" == "http://fizzy.localhost:3006" ]] +} + + +# Config Layering + +@test "loads system-wide config" { + create_system_config '{"account_slug": "system-123"}' + + source "$FIZZY_ROOT/lib/core.sh" + FIZZY_SYSTEM_CONFIG_DIR="$TEST_TEMP_DIR/etc/fizzy" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config "account_slug") + [[ "$result" == "system-123" ]] +} + +@test "user config overrides system config" { + create_system_config '{"account_slug": "system-123"}' + create_global_config '{"account_slug": "user-456"}' + + source "$FIZZY_ROOT/lib/core.sh" + FIZZY_SYSTEM_CONFIG_DIR="$TEST_TEMP_DIR/etc/fizzy" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config "account_slug") + [[ "$result" == "user-456" ]] +} + +@test "repo config detected from git root" { + init_git_repo "$TEST_PROJECT" + mkdir -p "$TEST_PROJECT/subdir" + create_repo_config '{"board_id": "repo-789"}' "$TEST_PROJECT" + + cd "$TEST_PROJECT/subdir" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config "board_id") + [[ "$result" == "repo-789" ]] +} + +@test "repo config overrides user config" { + init_git_repo "$TEST_PROJECT" + create_global_config '{"board_id": "user-config"}' + create_repo_config '{"board_id": "repo-config"}' "$TEST_PROJECT" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config "board_id") + [[ "$result" == "repo-config" ]] +} + +@test "local config overrides repo config" { + init_git_repo "$TEST_PROJECT" + mkdir -p "$TEST_PROJECT/subdir/.fizzy" + create_repo_config '{"board_id": "repo-config"}' "$TEST_PROJECT" + echo '{"board_id": "local-config"}' > "$TEST_PROJECT/subdir/.fizzy/config.json" + + cd "$TEST_PROJECT/subdir" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config "board_id") + [[ "$result" == "local-config" ]] +} + +@test "full config layering priority" { + # Set up all 6 layers + create_system_config '{"account_slug": "system", "board_id": "system", "column_id": "system"}' + create_global_config '{"account_slug": "user", "board_id": "user", "column_id": "user"}' + init_git_repo "$TEST_PROJECT" + create_repo_config '{"account_slug": "repo", "board_id": "repo", "column_id": "repo"}' "$TEST_PROJECT" + mkdir -p "$TEST_PROJECT/subdir/.fizzy" + echo '{"board_id": "local", "column_id": "local"}' > "$TEST_PROJECT/subdir/.fizzy/config.json" + export FIZZY_COLUMN_ID="env" + + cd "$TEST_PROJECT/subdir" + + source "$FIZZY_ROOT/lib/core.sh" + FIZZY_SYSTEM_CONFIG_DIR="$TEST_TEMP_DIR/etc/fizzy" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + + # account_slug: local doesn't set, env doesn't set, so repo wins + result=$(get_config "account_slug") + [[ "$result" == "repo" ]] + + # board_id: local sets it + result=$(get_config "board_id") + [[ "$result" == "local" ]] + + # column_id: env overrides all files + result=$(get_config "column_id") + [[ "$result" == "env" ]] +} + + +# Column ID getter + +@test "get_column_id from config" { + create_local_config '{"column_id": "77777"}' + unset FIZZY_COLUMN_ID + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_column_id) + [[ "$result" == "77777" ]] +} + +@test "get_column_id from environment" { + create_local_config '{"column_id": "from-file"}' + export FIZZY_COLUMN_ID="from-env" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_column_id) + [[ "$result" == "from-env" ]] +} + + +# Config source tracking + +@test "get_config_source returns env for environment variable" { + export FIZZY_ACCOUNT_SLUG="from-env" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config_source "account_slug") + [[ "$result" == "env" ]] +} + +@test "get_config_source returns flag for FIZZY_ACCOUNT" { + export FIZZY_ACCOUNT="from-flag" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config_source "account_slug") + [[ "$result" == "flag" ]] +} + +@test "get_config_source returns local for cwd config" { + create_local_config '{"board_id": "from-local"}' + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config_source "board_id") + [[ "$result" == *"local"* ]] +} + +@test "get_config_source returns user for global config" { + create_global_config '{"account_slug": "from-user"}' + unset FIZZY_ACCOUNT_SLUG + unset FIZZY_ACCOUNT + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config_source "account_slug") + [[ "$result" == *"user"* ]] +} + +@test "get_config_source returns system for system-wide config" { + create_system_config '{"account_slug": "from-system"}' + unset FIZZY_ACCOUNT_SLUG + unset FIZZY_ACCOUNT + + source "$FIZZY_ROOT/lib/core.sh" + FIZZY_SYSTEM_CONFIG_DIR="$TEST_TEMP_DIR/etc/fizzy" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config_source "account_slug") + [[ "$result" == *"system"* ]] +} + +@test "get_config_source returns repo for git root config" { + init_git_repo "$TEST_PROJECT" + create_repo_config '{"board_id": "from-repo"}' "$TEST_PROJECT" + mkdir -p "$TEST_PROJECT/subdir" + + cd "$TEST_PROJECT/subdir" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config_source "board_id") + [[ "$result" == *"repo"* ]] +} + +@test "get_config_source returns unset for missing key" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config_source "nonexistent") + [[ "$result" == "unset" ]] +} + + +# Token scope + +@test "get_token_scope returns scope from credentials" { + create_credentials "test-token" "$(($(date +%s) + 3600))" "write" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + result=$(get_token_scope) + [[ "$result" == "write" ]] +} + +@test "get_token_scope returns unknown when no scope" { + create_credentials "test-token" "$(($(date +%s) + 3600))" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + # get_token_scope returns "unknown" and exit code 1 when no scope + result=$(get_token_scope 2>/dev/null) || true + [[ "$result" == "unknown" ]] +} diff --git a/cli/test/core.bats b/cli/test/core.bats new file mode 100644 index 0000000000..8f30f42b30 --- /dev/null +++ b/cli/test/core.bats @@ -0,0 +1,153 @@ +#!/usr/bin/env bats +# core.bats - Tests for lib/core.sh + +load test_helper + + +# Version + +@test "fizzy shows version" { + run fizzy version + assert_success + assert_output_contains "fizzy" +} + + +# Quick start + +@test "fizzy with no args shows quick start" { + run fizzy + assert_success + assert_output_contains "fizzy" +} + +@test "fizzy --json with no args outputs JSON" { + run fizzy --json + assert_success + is_valid_json + assert_json_not_null ".version" +} + + +# Help + +@test "fizzy --help shows help" { + run fizzy --md --help + assert_success + assert_output_contains "USAGE" + assert_output_contains "COMMANDS" +} + +@test "fizzy help shows main help" { + run fizzy help + assert_success + assert_output_contains "fizzy" +} + + +# Output format detection + +@test "fizzy defaults to markdown when TTY" { + # This is tricky to test since bats runs in non-TTY + # For now, just verify --md flag works + run fizzy --md + assert_success + assert_output_not_contains '"version"' +} + +@test "fizzy --json forces JSON output" { + run fizzy --json + assert_success + is_valid_json +} + + +# Global flags + +@test "fizzy respects --quiet flag" { + run fizzy --quiet version + assert_success +} + +@test "fizzy respects --verbose flag" { + run fizzy --verbose version + assert_success +} + + +# Error handling + +@test "fizzy unknown command shows error" { + run fizzy notacommand + assert_failure +} + + +# JSON envelope structure + +@test "JSON output has correct envelope structure" { + run fizzy --json + assert_success + is_valid_json + + # Check required fields + assert_json_not_null ".version" + assert_json_not_null ".auth" +} + + +# Exit codes + +@test "unknown command returns exit code 1" { + run fizzy unknowncommand + assert_exit_code 1 +} + + +# Format flag aliases + +@test "fizzy -j is alias for --json" { + run fizzy -j + assert_success + is_valid_json +} + +@test "fizzy -m is alias for --md" { + run fizzy -m + assert_success + assert_output_not_contains '"version"' +} + +@test "fizzy -q is alias for --quiet" { + run fizzy -q version + assert_success +} + +@test "fizzy -v is alias for --verbose" { + run fizzy -v version + assert_success +} + + +# Board and account flags + +@test "fizzy --board sets board context" { + run fizzy --json --board test-board + assert_success + # Should not error even without auth +} + +@test "fizzy --account sets account context" { + run fizzy --json --account 12345 + assert_success +} + +@test "fizzy -b is alias for --board" { + run fizzy --json -b test-board + assert_success +} + +@test "fizzy -a is alias for --account" { + run fizzy --json -a 12345 + assert_success +} diff --git a/cli/test/run.sh b/cli/test/run.sh new file mode 100755 index 0000000000..ba66fab734 --- /dev/null +++ b/cli/test/run.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Test runner for fizzy +# Runs the bats test suite + +set -euo pipefail +cd "$(dirname "$0")" + +if ! command -v bats &>/dev/null; then + echo "Error: bats not found. Install with: brew install bats-core" >&2 + exit 1 +fi + +exec bats "$@" *.bats diff --git a/cli/test/test_helper.bash b/cli/test/test_helper.bash new file mode 100644 index 0000000000..7d15b81509 --- /dev/null +++ b/cli/test/test_helper.bash @@ -0,0 +1,230 @@ +#!/usr/bin/env bash +# test_helper.bash - Common test utilities for fizzy tests + + +# Setup/Teardown + +setup() { + # Store original environment + _ORIG_HOME="$HOME" + _ORIG_PWD="$PWD" + + # Create temp directories + TEST_TEMP_DIR="$(mktemp -d)" + TEST_HOME="$TEST_TEMP_DIR/home" + TEST_PROJECT="$TEST_TEMP_DIR/project" + + mkdir -p "$TEST_HOME/.config/fizzy" + mkdir -p "$TEST_PROJECT/.fizzy" + + # Set up test environment + export HOME="$TEST_HOME" + export FIZZY_ROOT="${BATS_TEST_DIRNAME}/.." + export PATH="$FIZZY_ROOT/bin:$PATH" + + # Clear environment variables that might interfere with tests + # Tests can set these as needed + unset FIZZY_ACCESS_TOKEN + unset FIZZY_ACCOUNT_SLUG + unset FIZZY_BOARD_ID + unset FIZZY_COLUMN_ID + unset FIZZY_ACCOUNT + unset FIZZY_BOARD + + cd "$TEST_PROJECT" +} + +teardown() { + # Restore original environment + export HOME="$_ORIG_HOME" + cd "$_ORIG_PWD" + + # Clean up temp directory + if [[ -d "$TEST_TEMP_DIR" ]]; then + rm -rf "$TEST_TEMP_DIR" + fi +} + + +# Assertions + +assert_success() { + if [[ "$status" -ne 0 ]]; then + echo "Expected success (0), got $status" + echo "Output: $output" + return 1 + fi +} + +assert_failure() { + if [[ "$status" -eq 0 ]]; then + echo "Expected failure (non-zero), got $status" + echo "Output: $output" + return 1 + fi +} + +assert_exit_code() { + local expected="$1" + if [[ "$status" -ne "$expected" ]]; then + echo "Expected exit code $expected, got $status" + echo "Output: $output" + return 1 + fi +} + +assert_output_contains() { + local expected="$1" + if [[ "$output" != *"$expected"* ]]; then + echo "Expected output to contain: $expected" + echo "Actual output: $output" + return 1 + fi +} + +assert_output_not_contains() { + local unexpected="$1" + if [[ "$output" == *"$unexpected"* ]]; then + echo "Expected output NOT to contain: $unexpected" + echo "Actual output: $output" + return 1 + fi +} + +assert_output_starts_with() { + local expected="$1" + if [[ "${output:0:${#expected}}" != "$expected" ]]; then + echo "Expected output to start with: $expected" + echo "Actual output starts with: ${output:0:20}" + return 1 + fi +} + +assert_json_value() { + local path="$1" + local expected="$2" + local actual + actual=$(echo "$output" | jq -r "$path") + + if [[ "$actual" != "$expected" ]]; then + echo "JSON path $path: expected '$expected', got '$actual'" + echo "Full output: $output" + return 1 + fi +} + +assert_json_not_null() { + local path="$1" + local actual + actual=$(echo "$output" | jq -r "$path") + + if [[ "$actual" == "null" ]] || [[ -z "$actual" ]]; then + echo "JSON path $path: expected non-null value, got '$actual'" + return 1 + fi +} + + +# Fixtures + +create_global_config() { + # Note: Use quoted default to avoid bash parsing issue with closing braces + local content="${1:-"{}"}" + echo "$content" > "$TEST_HOME/.config/fizzy/config.json" +} + +create_local_config() { + # Note: Use quoted default to avoid bash parsing issue with closing braces + local content="${1:-"{}"}" + echo "$content" > "$TEST_PROJECT/.fizzy/config.json" +} + +create_credentials() { + local access_token="${1:-test-token}" + local expires_at="${2:-$(($(date +%s) + 3600))}" + local scope="${3:-}" + + local scope_field="" + if [[ -n "$scope" ]]; then + scope_field="\"scope\": \"$scope\"," + fi + + cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF +{ + "access_token": "$access_token", + "refresh_token": "test-refresh-token", + $scope_field + "expires_at": $expires_at +} +EOF + chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" +} + +# Creates long-lived credentials without expiration (like Fizzy issues) +create_long_lived_credentials() { + local access_token="${1:-test-token}" + local scope="${2:-write}" + + cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF +{ + "access_token": "$access_token", + "refresh_token": "", + "scope": "$scope", + "expires_at": null +} +EOF + chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" +} + +create_accounts() { + cat > "$TEST_HOME/.config/fizzy/accounts.json" << 'EOF' +[ + {"id": "test-account-id", "name": "Test Account", "slug": "/99999999"} +] +EOF +} + +create_client() { + cat > "$TEST_HOME/.config/fizzy/client.json" << 'EOF' +{ + "client_id": "test-client-id", + "client_secret": "" +} +EOF + chmod 600 "$TEST_HOME/.config/fizzy/client.json" +} + +create_system_config() { + # Note: Use quoted default to avoid bash parsing issue with closing braces + local content="${1:-"{}"}" + mkdir -p "$TEST_TEMP_DIR/etc/fizzy" + echo "$content" > "$TEST_TEMP_DIR/etc/fizzy/config.json" +} + +create_repo_config() { + # Note: Use quoted default to avoid bash parsing issue with closing braces + local content="${1:-"{}"}" + local git_root="${2:-$TEST_PROJECT}" + mkdir -p "$git_root/.fizzy" + echo "$content" > "$git_root/.fizzy/config.json" +} + +init_git_repo() { + local dir="${1:-$TEST_PROJECT}" + git -C "$dir" init --quiet 2>/dev/null || true +} + + +# Mock helpers + +mock_api_response() { + local response="$1" + export FIZZY_MOCK_RESPONSE="$response" +} + + +# Utility + +is_valid_json() { + echo "$output" | jq . &>/dev/null +} From 7339f654f2c010610a8b546ba315fb3435deccf6 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 03:10:48 -0800 Subject: [PATCH 05/32] Config command for settings management --- cli/lib/commands/config.sh | 407 +++++++++++++++++++++++++++++++++++++ cli/test/config.bats | 120 +++++++++++ 2 files changed, 527 insertions(+) create mode 100644 cli/lib/commands/config.sh diff --git a/cli/lib/commands/config.sh b/cli/lib/commands/config.sh new file mode 100644 index 0000000000..d85bce5cd0 --- /dev/null +++ b/cli/lib/commands/config.sh @@ -0,0 +1,407 @@ +#!/usr/bin/env bash +# config.sh - Configuration management commands + + +# fizzy config [subcommand] [options] +# Manage configuration settings + +cmd_config() { + local subcommand="" + local show_help=false + + if [[ $# -eq 0 ]]; then + _config_list + return + fi + + case "$1" in + --help|-h) + _config_help + return + ;; + list) + shift + _config_list "$@" + ;; + get) + shift + _config_get "$@" + ;; + set) + shift + _config_set "$@" + ;; + unset) + shift + _config_unset "$@" + ;; + path) + shift + _config_path "$@" + ;; + *) + die "Unknown subcommand: $1" $EXIT_USAGE "Run: fizzy config --help" + ;; + esac +} + + +_config_list() { + local show_sources=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --sources) + show_sources=true + shift + ;; + *) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy config --help" + ;; + esac + done + + local config_data + config_data=$(get_effective_config) + + local summary="Configuration" + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "get" "fizzy config get " "Get specific value")" \ + "$(breadcrumb "set" "fizzy config set " "Set a value")" \ + "$(breadcrumb "path" "fizzy config path" "Show config file paths")" + ) + + if [[ "$show_sources" == "true" ]]; then + # Build JSON with sources + local result='{}' + for key in $(echo "$config_data" | jq -r 'keys[]'); do + local value source + value=$(echo "$config_data" | jq -r --arg k "$key" '.[$k]') + source=$(get_config_source "$key") + result=$(echo "$result" | jq --arg k "$key" --arg v "$value" --arg s "$source" \ + '.[$k] = {value: $v, source: $s}') + done + config_data="$result" + fi + + # Build context with show_sources flag for markdown renderer + local context="{}" + if [[ "$show_sources" == "true" ]]; then + context='{"show_sources": true}' + fi + + output "$config_data" "$summary" "$breadcrumbs" "_config_list_md" "$context" +} + +_config_list_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + # Note: Use quoted default to avoid bash parsing issue with closing braces + local context="${4:-"{}"}" + + local show_sources + show_sources=$(echo "$context" | jq -r '.show_sources // false') + + md_heading 2 "Configuration" + echo + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No configuration set." + echo + elif [[ "$show_sources" == "true" ]]; then + echo "| Key | Value | Source |" + echo "|-----|-------|--------|" + echo "$data" | jq -r 'to_entries | .[] | "| \(.key) | \(.value.value) | \(.value.source) |"' + echo + else + echo "| Key | Value |" + echo "|-----|-------|" + echo "$data" | jq -r 'to_entries | .[] | "| \(.key) | \(.value) |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + + +_config_get() { + if [[ $# -eq 0 ]]; then + die "Key required" $EXIT_USAGE "Usage: fizzy config get " + fi + + local key="$1" + local show_source=false + shift + + while [[ $# -gt 0 ]]; do + case "$1" in + --source) + show_source=true + shift + ;; + *) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy config --help" + ;; + esac + done + + local value + value=$(get_config "$key") + + if [[ -z "$value" ]]; then + die "Key not found: $key" $EXIT_NOT_FOUND "Run: fizzy config list" + fi + + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + if [[ "$show_source" == "true" ]]; then + local source + source=$(get_config_source "$key") + jq -n --arg k "$key" --arg v "$value" --arg s "$source" \ + '{key: $k, value: $v, source: $s}' + else + jq -n --arg k "$key" --arg v "$value" '{key: $k, value: $v}' + fi + else + if [[ "$show_source" == "true" ]]; then + local source + source=$(get_config_source "$key") + echo "$value # from $source" + else + echo "$value" + fi + fi +} + + +_config_set() { + local scope="--local" + local key="" + local value="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --global|-g) + scope="--global" + shift + ;; + --local|-l) + scope="--local" + shift + ;; + *) + if [[ -z "$key" ]]; then + key="$1" + elif [[ -z "$value" ]]; then + value="$1" + else + die "Too many arguments" $EXIT_USAGE "Usage: fizzy config set [--global|--local] " + fi + shift + ;; + esac + done + + if [[ -z "$key" ]]; then + die "Key required" $EXIT_USAGE "Usage: fizzy config set [--global|--local] " + fi + + if [[ -z "$value" ]]; then + die "Value required" $EXIT_USAGE "Usage: fizzy config set [--global|--local] " + fi + + if [[ "$scope" == "--global" ]]; then + set_global_config "$key" "$value" + info "Set $key = $value (global)" + else + set_local_config "$key" "$value" + info "Set $key = $value (local)" + fi +} + + +_config_unset() { + local scope="--local" + local key="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --global|-g) + scope="--global" + shift + ;; + --local|-l) + scope="--local" + shift + ;; + *) + if [[ -z "$key" ]]; then + key="$1" + else + die "Too many arguments" $EXIT_USAGE "Usage: fizzy config unset [--global|--local] " + fi + shift + ;; + esac + done + + if [[ -z "$key" ]]; then + die "Key required" $EXIT_USAGE "Usage: fizzy config unset [--global|--local] " + fi + + unset_config "$key" "$scope" + + local scope_name="local" + [[ "$scope" == "--global" ]] && scope_name="global" + info "Unset $key ($scope_name)" +} + + +_config_path() { + local format + format=$(get_format) + + local git_root + git_root=$(get_git_root) + + local paths + paths=$(jq -n \ + --arg system "$FIZZY_SYSTEM_CONFIG_DIR/$FIZZY_CONFIG_FILE" \ + --arg global "$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" \ + --arg repo "${git_root:+$git_root/$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE}" \ + --arg local "$PWD/$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" \ + --arg credentials "$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CREDENTIALS_FILE" \ + '{ + system: $system, + global: $global, + repo: (if $repo == "" then null else $repo end), + local: $local, + credentials: $credentials + }') + + if [[ "$format" == "json" ]]; then + echo "$paths" + else + md_heading 2 "Config Paths" + echo + echo "| Scope | Path | Exists |" + echo "|-------|------|--------|" + + local system_path global_path repo_path local_path creds_path + system_path="$FIZZY_SYSTEM_CONFIG_DIR/$FIZZY_CONFIG_FILE" + global_path="$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" + repo_path="${git_root:+$git_root/$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE}" + local_path="$PWD/$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" + creds_path="$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CREDENTIALS_FILE" + + _config_path_row "system" "$system_path" + _config_path_row "global" "$global_path" + [[ -n "$repo_path" ]] && _config_path_row "repo" "$repo_path" + _config_path_row "local" "$local_path" + _config_path_row "credentials" "$creds_path" + echo + fi +} + +_config_path_row() { + local scope="$1" + local path="$2" + local exists="No" + [[ -f "$path" ]] && exists="Yes" + echo "| $scope | $path | $exists |" +} + + +_config_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy config", + description: "Manage configuration settings", + subcommands: [ + {name: "list", description: "List all configuration (default)"}, + {name: "get", description: "Get a configuration value"}, + {name: "set", description: "Set a configuration value"}, + {name: "unset", description: "Remove a configuration value"}, + {name: "path", description: "Show configuration file paths"} + ], + options: [ + {flag: "--global, -g", description: "Use global (~/.config/fizzy/) config"}, + {flag: "--local, -l", description: "Use local (.fizzy/) config (default)"}, + {flag: "--sources", description: "Show where each value comes from (list)"}, + {flag: "--source", description: "Show source for value (get)"} + ], + keys: [ + {name: "account_slug", description: "Default account ID"}, + {name: "board_id", description: "Default board ID"}, + {name: "column_id", description: "Default column ID"}, + {name: "base_url", description: "Fizzy API base URL"} + ], + examples: [ + "fizzy config", + "fizzy config list --sources", + "fizzy config get account_slug", + "fizzy config set account_slug 897362094", + "fizzy config set --global account_slug 897362094", + "fizzy config unset board_id", + "fizzy config path" + ] + }' + else + cat <<'EOF' +## fizzy config + +Manage configuration settings. + +### Usage + + fizzy config List all configuration + fizzy config list [--sources] List config (optionally with sources) + fizzy config get Get a value + fizzy config set Set a value + fizzy config unset Remove a value + fizzy config path Show config file paths + +### Options + + --global, -g Use global (~/.config/fizzy/) config + --local, -l Use local (.fizzy/) config (default) + --sources Show where each value comes from + --source Show source for a specific value + +### Configuration Keys + + account_slug Default account ID + board_id Default board ID + column_id Default column ID + base_url Fizzy API base URL + +### Config Hierarchy (later overrides earlier) + + 1. /etc/fizzy/config.json System-wide + 2. ~/.config/fizzy/config.json User/global + 3. /.fizzy/config.json Repository + 4. /.fizzy/config.json Local + 5. Environment variables FIZZY_ACCOUNT_SLUG, etc. + 6. Command-line flags --account, --board + +### Examples + + fizzy config List all config + fizzy config list --sources Show value sources + fizzy config get account_slug Get account + fizzy config set account_slug 897362094 + fizzy config set --global base_url https://fizzy.example.com + fizzy config unset board_id +EOF + fi +} diff --git a/cli/test/config.bats b/cli/test/config.bats index a493fb987a..6826ddafdc 100644 --- a/cli/test/config.bats +++ b/cli/test/config.bats @@ -425,3 +425,123 @@ load test_helper result=$(get_token_scope 2>/dev/null) || true [[ "$result" == "unknown" ]] } + + +# fizzy config command + +@test "config --help shows help" { + run fizzy --md config --help + assert_success + assert_output_contains "fizzy config" + assert_output_contains "Manage configuration" +} + +@test "config -h shows help" { + run fizzy --md config -h + assert_success + assert_output_contains "fizzy config" +} + +@test "config --help --json outputs JSON" { + run fizzy --json config --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + +@test "config list shows configuration" { + create_global_config '{"account_slug": "12345"}' + + run fizzy --md config + assert_success + assert_output_contains "Configuration" +} + +@test "config list --json outputs JSON" { + create_global_config '{"account_slug": "12345"}' + + run fizzy --json config list + assert_success + is_valid_json +} + +@test "config get retrieves value" { + create_global_config '{"account_slug": "test-slug"}' + unset FIZZY_ACCOUNT_SLUG + unset FIZZY_ACCOUNT + + run fizzy config get account_slug + assert_success + assert_output_contains "test-slug" +} + +@test "config get missing key shows error" { + run fizzy config get nonexistent + assert_failure + assert_output_contains "Key not found" +} + +@test "config set creates value" { + run fizzy config set my_key my_value + assert_success + assert_output_contains "Set my_key" + + # Verify it was saved to local config (cwd/.fizzy/config.json) + local config_file="$TEST_PROJECT/.fizzy/config.json" + [[ -f "$config_file" ]] + result=$(jq -r '.my_key' "$config_file") + [[ "$result" == "my_value" ]] +} + +@test "config set --global creates global value" { + run fizzy config set --global my_global_key my_value + assert_success + assert_output_contains "global" + + # Verify it was saved to global config (~/.config/fizzy/config.json) + local config_file="$TEST_HOME/.config/fizzy/config.json" + [[ -f "$config_file" ]] + result=$(jq -r '.my_global_key' "$config_file") + [[ "$result" == "my_value" ]] +} + +@test "config unset removes value" { + create_local_config '{"my_key": "my_value"}' + + run fizzy config unset my_key + assert_success + assert_output_contains "Unset my_key" + + # Verify it was removed from local config + local config_file="$TEST_PROJECT/.fizzy/config.json" + result=$(jq -r '.my_key // empty' "$config_file") + [[ -z "$result" ]] +} + +@test "config path shows paths" { + run fizzy --md config path + assert_success + assert_output_contains "Config Paths" + assert_output_contains "global" + assert_output_contains "local" +} + +@test "config path --json outputs JSON" { + run fizzy --json config path + assert_success + is_valid_json + assert_json_not_null ".global" + assert_json_not_null ".local" +} + +@test "local config base_url overrides global" { + create_global_config '{"base_url": "http://global.example.com"}' + create_local_config '{"base_url": "http://local.example.com"}' + unset FIZZY_BASE_URL + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + # After loading full hierarchy, local should win + [[ "$FIZZY_BASE_URL" == "http://local.example.com" ]] +} From 0810b7802c3bb0de436d4f45c8990bc15b9459cc Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 03:11:08 -0800 Subject: [PATCH 06/32] Query commands for boards, cards, and users --- cli/lib/commands/actions.sh | 108 +++++++++++ cli/lib/commands/boards.sh | 224 +++++++++++++++++++++++ cli/lib/commands/cards.sh | 289 ++++++++++++++++++++++++++++++ cli/lib/commands/comments.sh | 127 +++++++++++++ cli/lib/commands/notifications.sh | 160 +++++++++++++++++ cli/lib/commands/people.sh | 102 +++++++++++ cli/lib/commands/search.sh | 156 ++++++++++++++++ cli/lib/commands/show.sh | 124 +++++++++++++ cli/lib/commands/tags.sh | 102 +++++++++++ cli/test/boards.bats | 71 ++++++++ cli/test/cards.bats | 205 +++++++++++++++++++++ 11 files changed, 1668 insertions(+) create mode 100644 cli/lib/commands/actions.sh create mode 100644 cli/lib/commands/boards.sh create mode 100644 cli/lib/commands/cards.sh create mode 100644 cli/lib/commands/comments.sh create mode 100644 cli/lib/commands/notifications.sh create mode 100644 cli/lib/commands/people.sh create mode 100644 cli/lib/commands/search.sh create mode 100644 cli/lib/commands/show.sh create mode 100644 cli/lib/commands/tags.sh create mode 100644 cli/test/boards.bats create mode 100644 cli/test/cards.bats diff --git a/cli/lib/commands/actions.sh b/cli/lib/commands/actions.sh new file mode 100644 index 0000000000..3557b16aa8 --- /dev/null +++ b/cli/lib/commands/actions.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# actions.sh - Card action commands (Phase 3) +# These are stub implementations that will be completed in Phase 3 + + +# fizzy card "title" [options] +# Create a new card +cmd_card_create() { + die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" +} + + +# fizzy close +# Close a card +cmd_close() { + die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" +} + + +# fizzy reopen +# Reopen a closed card +cmd_reopen() { + die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" +} + + +# fizzy triage --to +# Move card to a column +cmd_triage() { + die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" +} + + +# fizzy untriage +# Send card back to triage +cmd_untriage() { + die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" +} + + +# fizzy postpone +# Move card to "Not Now" +cmd_postpone() { + die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" +} + + +# fizzy comment "text" --on +# Add comment to a card +cmd_comment_create() { + die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" +} + + +# fizzy assign --to +# Toggle assignment +cmd_assign() { + die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" +} + + +# fizzy tag --with "name" +# Toggle tag on card +cmd_tag() { + die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" +} + + +# fizzy watch +# Subscribe to card notifications +cmd_watch() { + die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" +} + + +# fizzy unwatch +# Unsubscribe from card +cmd_unwatch() { + die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" +} + + +# fizzy gild +# Mark card as golden +cmd_gild() { + die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" +} + + +# fizzy ungild +# Remove golden status +cmd_ungild() { + die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" +} + + +# fizzy step "text" --on +# Add step to card +cmd_step() { + die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" +} + + +# fizzy react "emoji" --on +# Add reaction to comment +cmd_react() { + die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" +} diff --git a/cli/lib/commands/boards.sh b/cli/lib/commands/boards.sh new file mode 100644 index 0000000000..c7b01ba118 --- /dev/null +++ b/cli/lib/commands/boards.sh @@ -0,0 +1,224 @@ +#!/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 + + while [[ $# -gt 0 ]]; do + case "$1" in + --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 + response=$(api_get "/boards") + + local count + count=$(echo "$response" | jq 'length') + + local summary="$count boards" + local breadcrumbs + 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 "create" "fizzy config set board_id " "Set default board")" + ) + + 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: [], + examples: [ + "fizzy boards", + "fizzy boards --json" + ] + }' + else + cat <<'EOF' +## fizzy boards + +List boards in the account. + +### Usage + + fizzy boards [options] + +### Options + + --help, -h Show this help + +### Examples + + fizzy boards List all boards + fizzy boards --json Output as JSON + fizzy boards -q Raw data only +EOF + fi +} + + +# fizzy columns [options] +# List columns on a board + +cmd_columns() { + local board_id="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --board|-b) + if [[ -z "${2:-}" ]]; then + die "--board requires a value" $EXIT_USAGE + fi + board_id="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + *) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy columns --help" + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _columns_help + return 0 + fi + + # Use provided board_id or fall back to config + if [[ -z "$board_id" ]]; then + board_id=$(get_board_id) + fi + + if [[ -z "$board_id" ]]; then + die "No board specified" $EXIT_USAGE \ + "Use: fizzy columns --board " + fi + + local response + response=$(api_get "/boards/$board_id/columns") + + local count + count=$(echo "$response" | jq 'length') + + local summary="$count columns" + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "triage" "fizzy triage --to " "Move card to column")" \ + "$(breadcrumb "cards" "fizzy cards --board $board_id" "List cards on board")" + ) + + output "$response" "$summary" "$breadcrumbs" "_columns_md" +} + +_columns_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Columns ($summary)" + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No columns found." + echo + else + echo "| ID | Name | Color |" + echo "|----|------|-------|" + echo "$data" | jq -r '.[] | "| \(.id) | \(.name) | \(.color // "default") |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + +_columns_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy columns", + description: "List columns on a board", + options: [ + {flag: "--board, -b", description: "Board ID (required unless set in config)"} + ], + examples: [ + "fizzy columns --board abc123", + "fizzy columns -b abc123 --json" + ] + }' + else + cat <<'EOF' +## fizzy columns + +List columns on a board. + +### Usage + + fizzy columns [options] + +### Options + + --board, -b Board ID (required unless set in config) + --help, -h Show this help + +### Examples + + fizzy columns --board abc123 List columns on board + fizzy columns List columns on default board +EOF + fi +} diff --git a/cli/lib/commands/cards.sh b/cli/lib/commands/cards.sh new file mode 100644 index 0000000000..b1eaf128bb --- /dev/null +++ b/cli/lib/commands/cards.sh @@ -0,0 +1,289 @@ +#!/usr/bin/env bash +# cards.sh - Card query commands + + +# fizzy cards [options] +# List and filter cards + +cmd_cards() { + local board_id="" + local tag_id="" + local assignee_id="" + local status="" + local search_terms="" + local sort_by="latest" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --board|-b) + if [[ -z "${2:-}" ]]; then + die "--board requires a value" $EXIT_USAGE + fi + board_id="$2" + shift 2 + ;; + --tag) + if [[ -z "${2:-}" ]]; then + die "--tag requires a value" $EXIT_USAGE + fi + tag_id="$2" + shift 2 + ;; + --assignee) + if [[ -z "${2:-}" ]]; then + die "--assignee requires a value" $EXIT_USAGE + fi + assignee_id="$2" + shift 2 + ;; + --status) + if [[ -z "${2:-}" ]]; then + die "--status requires a value" $EXIT_USAGE + fi + status="$2" + shift 2 + ;; + --search|-s) + if [[ -z "${2:-}" ]]; then + die "--search requires a value" $EXIT_USAGE + fi + search_terms="$2" + shift 2 + ;; + --sort) + if [[ -z "${2:-}" ]]; then + die "--sort requires a value" $EXIT_USAGE + fi + sort_by="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + *) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy cards --help" + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _cards_help + return 0 + fi + + # Build query parameters + local path="/cards" + local params=() + + # Use provided board_id or fall back to config + if [[ -z "$board_id" ]]; then + board_id=$(get_board_id 2>/dev/null || true) + fi + + if [[ -n "$board_id" ]]; then + params+=("board_ids[]=$board_id") + fi + + if [[ -n "$tag_id" ]]; then + params+=("tag_ids[]=$tag_id") + fi + + if [[ -n "$assignee_id" ]]; then + params+=("assignee_ids[]=$assignee_id") + fi + + if [[ -n "$status" ]]; then + params+=("indexed_by=$status") + fi + + if [[ -n "$search_terms" ]]; then + # Split search terms by space and add each as a separate terms[] param + local term + for term in $search_terms; do + local encoded_term + encoded_term=$(urlencode "$term") + params+=("terms[]=$encoded_term") + done + fi + + if [[ -n "$sort_by" ]]; then + params+=("sorted_by=$sort_by") + fi + + # Build query string + if [[ ${#params[@]} -gt 0 ]]; then + local query_string + query_string=$(IFS='&'; echo "${params[*]}") + path="$path?$query_string" + fi + + local response + response=$(api_get "$path") + + local count + count=$(echo "$response" | jq 'length') + + local summary="$count cards" + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show " "View card details")" \ + "$(breadcrumb "create" "fizzy card \"title\"" "Create new card")" \ + "$(breadcrumb "filter" "fizzy cards --status closed" "Filter by status")" \ + "$(breadcrumb "search" "fizzy search \"query\"" "Search cards")" + ) + + output "$response" "$summary" "$breadcrumbs" "_cards_md" +} + +_cards_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Cards ($summary)" + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No cards found." + echo + else + echo "| # | Title | Board | Status | Tags |" + echo "|---|-------|-------|--------|------|" + echo "$data" | jq -r '.[] | "| \(.number) | \(.title // .description[0:40]) | \(.board.name) | \(if .closed then "Closed" elif .column then .column.name else "Triage" end) | \(.tags | join(", ")) |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + +_cards_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy cards", + description: "List and filter cards", + options: [ + {flag: "--board, -b", description: "Filter by board ID"}, + {flag: "--tag", description: "Filter by tag ID"}, + {flag: "--assignee", description: "Filter by assignee ID"}, + {flag: "--status", description: "Filter by status: all, closed, not_now, stalled, golden"}, + {flag: "--search, -s", description: "Search terms"}, + {flag: "--sort", description: "Sort order: latest, newest, oldest"} + ], + examples: [ + "fizzy cards", + "fizzy cards --board abc123", + "fizzy cards --search \"bug fix\"", + "fizzy cards --status closed" + ] + }' + else + cat <<'EOF' +## fizzy cards + +List and filter cards. + +### Usage + + fizzy cards [options] + +### Options + + --board, -b Filter by board ID + --tag Filter by tag ID + --assignee Filter by assignee ID + --status Filter: all, closed, not_now, stalled, golden + --search, -s Search terms + --sort Sort: latest (default), newest, oldest + --help, -h Show this help + +### Examples + + fizzy cards List all cards + fizzy cards --board abc123 Filter by board + fizzy cards --search "bug" Search for cards + fizzy cards --status closed Show closed cards +EOF + fi +} + + +# Show card details (called by cmd_show) +show_card() { + local card_number="$1" + + if [[ -z "$card_number" ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy show " + fi + + local response + response=$(api_get "/cards/$card_number") + + local title + title=$(echo "$response" | jq -r '.title // .description[0:50]') + local summary="Card #$card_number: $title" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "close" "fizzy close $card_number" "Close this card")" \ + "$(breadcrumb "comment" "fizzy comment \"text\" --on $card_number" "Add comment")" \ + "$(breadcrumb "assign" "fizzy assign $card_number --to " "Assign user")" \ + "$(breadcrumb "triage" "fizzy triage $card_number --to " "Move to column")" + ) + + output "$response" "$summary" "$breadcrumbs" "_show_card_md" +} + +_show_card_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local number title board_name column_name status creator_name created_at + local description tags golden assignees + + number=$(echo "$data" | jq -r '.number') + title=$(echo "$data" | jq -r '.title // ""') + board_name=$(echo "$data" | jq -r '.board.name') + column_name=$(echo "$data" | jq -r '.column.name // "Triage"') + status=$(echo "$data" | jq -r 'if .closed then "Closed" else "Active" end') + creator_name=$(echo "$data" | jq -r '.creator.name') + created_at=$(echo "$data" | jq -r '.created_at | split("T")[0]') + description=$(echo "$data" | jq -r '.description // ""') + tags=$(echo "$data" | jq -r '.tags | join(", ")') + golden=$(echo "$data" | jq -r 'if .golden then "Yes" else "No" end') + + md_heading 2 "Card #$number${title:+: $title}" + + md_kv "Board" "$board_name" \ + "Column" "$column_name" \ + "Status" "$status" \ + "Golden" "$golden" \ + "Tags" "${tags:-None}" \ + "Created" "$created_at by $creator_name" + + if [[ -n "$description" ]]; then + echo "### Description" + echo + echo "$description" + echo + fi + + # Show steps if present + local steps_count + steps_count=$(echo "$data" | jq '.steps | length // 0') + if [[ "$steps_count" -gt 0 ]]; then + echo "### Steps ($steps_count)" + echo + echo "$data" | jq -r '.steps[] | "- [\(if .completed then "x" else " " end)] \(.content)"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} diff --git a/cli/lib/commands/comments.sh b/cli/lib/commands/comments.sh new file mode 100644 index 0000000000..7db1ebdb3b --- /dev/null +++ b/cli/lib/commands/comments.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# comments.sh - Comment query and action commands + + +# fizzy comments [options] +# List comments on a card + +cmd_comments() { + 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 + ;; + *) + # First positional arg could be card number + if [[ -z "$card_number" ]] && [[ "$1" =~ ^[0-9]+$ ]]; then + card_number="$1" + shift + else + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy comments --help" + fi + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _comments_help + return 0 + fi + + if [[ -z "$card_number" ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy comments --on " + fi + + local response + response=$(api_get "/cards/$card_number/comments") + + local count + count=$(echo "$response" | jq 'length') + + local summary="$count comments on card #$card_number" + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "add" "fizzy comment \"text\" --on $card_number" "Add comment")" \ + "$(breadcrumb "react" "fizzy react \"👍\" --on " "Add reaction")" \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" + ) + + output "$response" "$summary" "$breadcrumbs" "_comments_md" +} + +_comments_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Comments" + echo "*$summary*" + echo + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No comments yet." + echo + else + echo "| ID | Author | Date | Comment |" + echo "|----|--------|------|---------|" + echo "$data" | jq -r '.[] | "| \(.id[0:12])... | \(.creator.name) | \(.created_at | split("T")[0]) | \((.body.plain_text // "(no text)")[0:40] | gsub("\n"; " "))... |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + +_comments_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy comments", + description: "List comments on a card", + usage: "fizzy comments --on ", + options: [ + {flag: "--on", description: "Card number to list comments for"} + ], + examples: [ + "fizzy comments --on 42", + "fizzy comments 42" + ] + }' + else + cat <<'EOF' +## fizzy comments + +List comments on a card. + +### Usage + + fizzy comments --on + fizzy comments + +### Options + + --on Card number to list comments for + --help, -h Show this help + +### Examples + + fizzy comments --on 42 List comments on card #42 + fizzy comments 42 List comments on card #42 +EOF + fi +} diff --git a/cli/lib/commands/notifications.sh b/cli/lib/commands/notifications.sh new file mode 100644 index 0000000000..e109ba85b6 --- /dev/null +++ b/cli/lib/commands/notifications.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# notifications.sh - Notification query and action commands + + +# fizzy notifications [options] +# List notifications + +cmd_notifications() { + local action="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + read|unread) + action="$1" + shift + _notifications_action "$action" "$@" + return + ;; + --help|-h) + show_help=true + shift + ;; + *) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy notifications --help" + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _notifications_help + return 0 + fi + + local response + response=$(api_get "/notifications") + + local count unread_count + count=$(echo "$response" | jq 'length') + unread_count=$(echo "$response" | jq '[.[] | select(.read == false)] | length') + + local summary="$count notifications ($unread_count unread)" + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "read" "fizzy notifications read " "Mark as read")" \ + "$(breadcrumb "read-all" "fizzy notifications read --all" "Mark all as read")" \ + "$(breadcrumb "show" "fizzy show " "View card")" + ) + + output "$response" "$summary" "$breadcrumbs" "_notifications_md" +} + +_notifications_action() { + local action="$1" + shift + + local notification_id="" + local all=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --all) + all=true + shift + ;; + *) + if [[ -z "$notification_id" ]]; then + notification_id="$1" + fi + shift + ;; + esac + done + + if [[ "$action" == "read" ]]; then + if [[ "$all" == "true" ]]; then + api_post "/notifications/bulk_reading" + info "All notifications marked as read" + elif [[ -n "$notification_id" ]]; then + api_post "/notifications/$notification_id/reading" + info "Notification marked as read" + else + die "Notification ID required" $EXIT_USAGE "Usage: fizzy notifications read or --all" + fi + elif [[ "$action" == "unread" ]]; then + if [[ -z "$notification_id" ]]; then + die "Notification ID required" $EXIT_USAGE "Usage: fizzy notifications unread " + fi + api_delete "/notifications/$notification_id/reading" + info "Notification marked as unread" + fi +} + +_notifications_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Notifications" + echo "*$summary*" + echo + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No notifications." + echo + else + echo "| Status | Card | Title | From | Date |" + echo "|--------|------|-------|------|------|" + echo "$data" | jq -r '.[] | "| \(if .read then "Read" else "**Unread**" end) | #\(.card.id[0:8]) | \(.title[0:30]) | \(.creator.name) | \(.created_at | split("T")[0]) |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + +_notifications_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy notifications", + description: "List and manage notifications", + subcommands: [ + {name: "read", description: "Mark notification as read"}, + {name: "unread", description: "Mark notification as unread"} + ], + options: [ + {flag: "--all", description: "Mark all notifications as read (with read)"} + ], + examples: [ + "fizzy notifications", + "fizzy notifications read abc123", + "fizzy notifications read --all" + ] + }' + else + cat <<'EOF' +## fizzy notifications + +List and manage notifications. + +### Usage + + fizzy notifications List notifications + fizzy notifications read Mark as read + fizzy notifications read --all Mark all as read + fizzy notifications unread Mark as unread + +### Examples + + fizzy notifications List all notifications + fizzy notifications read abc123 Mark notification as read + fizzy notifications read --all Mark all as read +EOF + fi +} diff --git a/cli/lib/commands/people.sh b/cli/lib/commands/people.sh new file mode 100644 index 0000000000..56824ccf36 --- /dev/null +++ b/cli/lib/commands/people.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# people.sh - User query commands + + +# fizzy people [options] +# List users in the account + +cmd_people() { + 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 people --help" + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _people_help + return 0 + fi + + local response + response=$(api_get "/users") + + local count + count=$(echo "$response" | jq 'length') + + local summary="$count users" + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "assign" "fizzy assign --to " "Assign user to card")" \ + "$(breadcrumb "cards" "fizzy cards --assignee " "Cards assigned to user")" + ) + + output "$response" "$summary" "$breadcrumbs" "_people_md" +} + +_people_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "People ($summary)" + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No users found." + echo + else + echo "| ID | Name | Email | Role |" + echo "|----|------|-------|------|" + echo "$data" | jq -r '.[] | "| \(.id) | \(.name) | \(.email_address) | \(.role) |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + +_people_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy people", + description: "List users in the account", + options: [], + examples: [ + "fizzy people", + "fizzy people --json" + ] + }' + else + cat <<'EOF' +## fizzy people + +List users in the account. + +### Usage + + fizzy people [options] + +### Options + + --help, -h Show this help + +### Examples + + fizzy people List all users + fizzy people --json Output as JSON + fizzy people -q Raw data only +EOF + fi +} diff --git a/cli/lib/commands/search.sh b/cli/lib/commands/search.sh new file mode 100644 index 0000000000..36b0618300 --- /dev/null +++ b/cli/lib/commands/search.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# search.sh - Search commands + + +# fizzy search [options] +# Search cards + +cmd_search() { + local query="" + local board_id="" + local show_help=false + + # First non-flag argument is the query + while [[ $# -gt 0 ]]; do + case "$1" in + --board|-b) + if [[ -z "${2:-}" ]]; then + die "--board requires a value" $EXIT_USAGE + fi + board_id="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy search --help" + ;; + *) + if [[ -z "$query" ]]; then + query="$1" + else + # Append additional words to query + query="$query $1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _search_help + return 0 + fi + + if [[ -z "$query" ]]; then + die "Search query required" $EXIT_USAGE "Usage: fizzy search \"your query\"" + fi + + # Build query parameters + local path="/cards" + local params=() + + # Split and URL encode each search term + local term + for term in $query; do + local encoded_term + encoded_term=$(urlencode "$term") + params+=("terms[]=$encoded_term") + done + + # Use provided board_id or fall back to config + if [[ -z "$board_id" ]]; then + board_id=$(get_board_id 2>/dev/null || true) + fi + + if [[ -n "$board_id" ]]; then + params+=("board_ids[]=$board_id") + fi + + # Build query string + local query_string + query_string=$(IFS='&'; echo "${params[*]}") + path="$path?$query_string" + + local response + response=$(api_get "$path") + + local count + count=$(echo "$response" | jq 'length') + + local summary="$count results for \"$query\"" + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show " "View card details")" \ + "$(breadcrumb "narrow" "fizzy search \"$query\" --board " "Filter by board")" + ) + + output "$response" "$summary" "$breadcrumbs" "_search_md" +} + +_search_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Search Results" + echo "*$summary*" + echo + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No cards found matching your search." + echo + else + echo "| # | Title | Board | Status |" + echo "|---|-------|-------|--------|" + echo "$data" | jq -r '.[] | "| \(.number) | \(.title // .description[0:40]) | \(.board.name) | \(if .closed then "Closed" elif .column then .column.name else "Triage" end) |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + +_search_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy search", + description: "Search cards", + usage: "fizzy search [options]", + options: [ + {flag: "--board, -b", description: "Filter results to a specific board"} + ], + examples: [ + "fizzy search \"bug fix\"", + "fizzy search login --board abc123" + ] + }' + else + cat <<'EOF' +## fizzy search + +Search cards by keywords. + +### Usage + + fizzy search [options] + +### Options + + --board, -b Filter results to a specific board + --help, -h Show this help + +### Examples + + fizzy search "bug fix" Search all cards + fizzy search login --board abc123 Search within board +EOF + fi +} diff --git a/cli/lib/commands/show.sh b/cli/lib/commands/show.sh new file mode 100644 index 0000000000..71e066815b --- /dev/null +++ b/cli/lib/commands/show.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# show.sh - Show detail view dispatcher + + +# fizzy show +# Show detailed view of a resource + +cmd_show() { + if [[ $# -eq 0 ]]; then + _show_help + return 0 + fi + + local first_arg="$1" + shift + + case "$first_arg" in + --help|-h) + _show_help + ;; + board) + show_board "$@" + ;; + card) + show_card "$@" + ;; + *) + # If first arg looks like a number, assume it's a card number + if [[ "$first_arg" =~ ^[0-9]+$ ]]; then + show_card "$first_arg" + else + # Otherwise treat as board ID + show_board "$first_arg" + fi + ;; + esac +} + +# Show board details +show_board() { + local board_id="$1" + + if [[ -z "$board_id" ]]; then + die "Board ID required" $EXIT_USAGE "Usage: fizzy show board " + fi + + local response + response=$(api_get "/boards/$board_id") + + local name + name=$(echo "$response" | jq -r '.name') + local summary="Board: $name" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "cards" "fizzy cards --board $board_id" "List cards")" \ + "$(breadcrumb "columns" "fizzy columns --board $board_id" "List columns")" \ + "$(breadcrumb "set" "fizzy config set board_id $board_id" "Set as default")" + ) + + output "$response" "$summary" "$breadcrumbs" "_show_board_md" +} + +_show_board_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local id name all_access creator_name created_at + + id=$(echo "$data" | jq -r '.id') + name=$(echo "$data" | jq -r '.name') + all_access=$(echo "$data" | jq -r 'if .all_access then "Yes (all users)" else "Selective" end') + creator_name=$(echo "$data" | jq -r '.creator.name') + created_at=$(echo "$data" | jq -r '.created_at | split("T")[0]') + + md_heading 2 "Board: $name" + + md_kv "ID" "$id" \ + "Access" "$all_access" \ + "Created" "$created_at by $creator_name" + + md_breadcrumbs "$breadcrumbs" +} + +_show_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy show", + description: "Show detailed view of a resource", + usage: [ + "fizzy show ", + "fizzy show card ", + "fizzy show board " + ], + examples: [ + "fizzy show 42", + "fizzy show card 42", + "fizzy show board abc123" + ] + }' + else + cat <<'EOF' +## fizzy show + +Show detailed view of a resource. + +### Usage + + fizzy show Show card by number + fizzy show card Show card by number + fizzy show board Show board by ID + +### Examples + + fizzy show 42 Show card #42 + fizzy show card 42 Show card #42 + fizzy show board abc123 Show board details +EOF + fi +} diff --git a/cli/lib/commands/tags.sh b/cli/lib/commands/tags.sh new file mode 100644 index 0000000000..2c40b9af72 --- /dev/null +++ b/cli/lib/commands/tags.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# tags.sh - Tag query commands + + +# fizzy tags [options] +# List tags in the account + +cmd_tags() { + 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 tags --help" + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _tags_help + return 0 + fi + + local response + response=$(api_get "/tags") + + local count + count=$(echo "$response" | jq 'length') + + local summary="$count tags" + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "filter" "fizzy cards --tag " "Filter cards by tag")" \ + "$(breadcrumb "add" "fizzy tag --with \"name\"" "Add tag to card")" + ) + + output "$response" "$summary" "$breadcrumbs" "_tags_md" +} + +_tags_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Tags ($summary)" + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No tags found." + echo + else + echo "| ID | Title | Created |" + echo "|----|-------|---------|" + echo "$data" | jq -r '.[] | "| \(.id) | #\(.title) | \(.created_at | split("T")[0]) |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + +_tags_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy tags", + description: "List tags in the account", + options: [], + examples: [ + "fizzy tags", + "fizzy tags --json" + ] + }' + else + cat <<'EOF' +## fizzy tags + +List tags in the account. + +### Usage + + fizzy tags [options] + +### Options + + --help, -h Show this help + +### Examples + + fizzy tags List all tags + fizzy tags --json Output as JSON + fizzy tags -q Raw data only +EOF + fi +} diff --git a/cli/test/boards.bats b/cli/test/boards.bats new file mode 100644 index 0000000000..54b17e21ea --- /dev/null +++ b/cli/test/boards.bats @@ -0,0 +1,71 @@ +#!/usr/bin/env bats +# boards.bats - Tests for board and column commands + +load test_helper + + +# boards --help + +@test "boards --help shows help" { + run fizzy --md boards --help + assert_success + assert_output_contains "fizzy boards" + assert_output_contains "List boards" +} + +@test "boards -h shows help" { + run fizzy --md boards -h + assert_success + assert_output_contains "fizzy boards" +} + +@test "boards --help --json outputs JSON" { + run fizzy --json boards --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# boards requires auth + +@test "boards requires authentication" { + run fizzy boards + assert_failure + assert_output_contains "Not authenticated" +} + + +# columns --help + +@test "columns --help shows help" { + run fizzy --md columns --help + assert_success + assert_output_contains "fizzy columns" + assert_output_contains "List columns" +} + +@test "columns -h shows help" { + run fizzy --md columns -h + assert_success + assert_output_contains "fizzy columns" +} + + +# columns requires board + +@test "columns without board shows error" { + create_credentials "test-token" "$(($(date +%s) + 3600))" + create_global_config '{"account_slug": "12345"}' + + run fizzy columns + assert_failure + assert_output_contains "No board" +} + +@test "columns uses board from config" { + # This would require a mock API, so just test the help for now + run fizzy --md columns --help + assert_success + assert_output_contains "--board" +} diff --git a/cli/test/cards.bats b/cli/test/cards.bats new file mode 100644 index 0000000000..074dae1297 --- /dev/null +++ b/cli/test/cards.bats @@ -0,0 +1,205 @@ +#!/usr/bin/env bats +# cards.bats - Tests for card commands + +load test_helper + + +# cards --help + +@test "cards --help shows help" { + run fizzy --md cards --help + assert_success + assert_output_contains "fizzy cards" + assert_output_contains "List and filter" +} + +@test "cards -h shows help" { + run fizzy --md cards -h + assert_success + assert_output_contains "fizzy cards" +} + +@test "cards --help --json outputs JSON" { + run fizzy --json cards --help + assert_success + is_valid_json + assert_json_not_null ".command" + assert_json_not_null ".options" +} + + +# cards requires auth + +@test "cards requires authentication" { + run fizzy cards + assert_failure + assert_output_contains "Not authenticated" +} + + +# cards option parsing + +@test "cards rejects unknown option" { + run fizzy cards --unknown-option + assert_failure + assert_output_contains "Unknown option" +} + + +# show --help + +@test "show --help shows help" { + run fizzy --md show --help + assert_success + assert_output_contains "fizzy show" +} + +@test "show -h shows help" { + run fizzy --md show -h + assert_success + assert_output_contains "fizzy show" +} + +@test "show --help --json outputs JSON" { + run fizzy --json show --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# show requires auth + +@test "show card requires authentication" { + run fizzy show 42 + assert_failure + assert_output_contains "Not authenticated" +} + +@test "show board requires authentication" { + run fizzy show board abc123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# search --help + +@test "search --help shows help" { + run fizzy --md search --help + assert_success + assert_output_contains "fizzy search" +} + +@test "search -h shows help" { + run fizzy --md search -h + assert_success + assert_output_contains "fizzy search" +} + + +# search requires query + +@test "search without query shows error" { + run fizzy search + assert_failure + assert_output_contains "query required" +} + + +# people --help + +@test "people --help shows help" { + run fizzy --md people --help + assert_success + assert_output_contains "fizzy people" + assert_output_contains "List users" +} + +@test "people -h shows help" { + run fizzy --md people -h + assert_success + assert_output_contains "fizzy people" +} + + +# people requires auth + +@test "people requires authentication" { + run fizzy people + assert_failure + assert_output_contains "Not authenticated" +} + + +# tags --help + +@test "tags --help shows help" { + run fizzy --md tags --help + assert_success + assert_output_contains "fizzy tags" + assert_output_contains "List tags" +} + +@test "tags -h shows help" { + run fizzy --md tags -h + assert_success + assert_output_contains "fizzy tags" +} + + +# tags requires auth + +@test "tags requires authentication" { + run fizzy tags + assert_failure + assert_output_contains "Not authenticated" +} + + +# comments --help + +@test "comments --help shows help" { + run fizzy --md comments --help + assert_success + assert_output_contains "fizzy comments" +} + +@test "comments -h shows help" { + run fizzy --md comments -h + assert_success + assert_output_contains "fizzy comments" +} + + +# comments requires card number + +@test "comments without card shows error" { + run fizzy comments + assert_failure + assert_output_contains "Card number required" +} + + +# notifications --help + +@test "notifications --help shows help" { + run fizzy --md notifications --help + assert_success + assert_output_contains "fizzy notifications" +} + +@test "notifications -h shows help" { + run fizzy --md notifications -h + assert_success + assert_output_contains "fizzy notifications" +} + + +# notifications requires auth + +@test "notifications requires authentication" { + run fizzy notifications + assert_failure + assert_output_contains "Not authenticated" +} From b68e37d14e201835cc74fbb69e6362cbaacb8ceb Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 03:40:49 -0800 Subject: [PATCH 07/32] Card action commands --- cli/lib/commands/actions.sh | 1657 ++++++++++++++++++++++++++++++++++- cli/test/actions.bats | 642 ++++++++++++++ 2 files changed, 2282 insertions(+), 17 deletions(-) create mode 100644 cli/test/actions.bats diff --git a/cli/lib/commands/actions.sh b/cli/lib/commands/actions.sh index 3557b16aa8..0e96994264 100644 --- a/cli/lib/commands/actions.sh +++ b/cli/lib/commands/actions.sh @@ -1,108 +1,1731 @@ #!/usr/bin/env bash # actions.sh - Card action commands (Phase 3) -# These are stub implementations that will be completed in Phase 3 # fizzy card "title" [options] # Create a new card + cmd_card_create() { - die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" + local title="" + local description="" + local board_id="" + local column_id="" + local tag_ids=() + local assignee_ids=() + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --board|-b) + if [[ -z "${2:-}" ]]; then + die "--board requires a board ID" $EXIT_USAGE + fi + board_id="$2" + shift 2 + ;; + --column|-c) + if [[ -z "${2:-}" ]]; then + die "--column requires a column 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 + ;; + --tag) + if [[ -z "${2:-}" ]]; then + die "--tag requires a tag ID" $EXIT_USAGE + fi + tag_ids+=("$2") + shift 2 + ;; + --assign) + if [[ -z "${2:-}" ]]; then + die "--assign requires a user 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 + + # 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 + + # Build request body + local body + body=$(jq -n \ + --arg title "$title" \ + --arg description "${description:-}" \ + --arg column_id "${column_id:-}" \ + '{title: $title} + + (if $description != "" then {description: $description} else {} end) + + (if $column_id != "" then {column_id: $column_id} else {} end)') + + # Add tag_ids array if provided + if [[ ${#tag_ids[@]} -gt 0 ]]; then + local tags_json + tags_json=$(printf '%s\n' "${tag_ids[@]}" | jq -R . | jq -s '.') + body=$(echo "$body" | jq --argjson tag_ids "$tags_json" '. + {tag_ids: $tag_ids}') + fi + + # Add assignee_ids array if provided + if [[ ${#assignee_ids[@]} -gt 0 ]]; then + local assignees_json + assignees_json=$(printf '%s\n' "${assignee_ids[@]}" | jq -R . | jq -s '.') + body=$(echo "$body" | jq --argjson assignee_ids "$assignees_json" '. + {assignee_ids: $assignee_ids}') + fi + + local response + response=$(api_post "/boards/$board_id/cards" "$body") + + local number + number=$(echo "$response" | jq -r '.number') + local summary="Created card #$number" + + 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", + usage: "fizzy card \"title\" [options]", + options: [ + {flag: "--board, -b", description: "Board ID (required if not in config)"}, + {flag: "--column, -c", description: "Column ID to triage directly"}, + {flag: "--description, -d", description: "Card description"}, + {flag: "--tag", description: "Tag ID (can be repeated)"}, + {flag: "--assign", description: "Assignee user ID (can be repeated)"} + ], + examples: [ + "fizzy card \"Fix login bug\"", + "fizzy card \"New feature\" --board abc123 --column def456", + "fizzy card \"Task\" --tag tag1 --tag tag2 --assign user1" + ] + }' + else + cat <<'EOF' +## fizzy card + +Create a new card. + +### Usage + + fizzy card "title" [options] + +### Options + + --board, -b Board ID (required if not in config) + --column, -c Column ID to triage directly + --description, -d Card description + --tag Tag ID (can be repeated) + --assign Assignee user ID (can be repeated) + --help, -h Show this help + +### Examples + + fizzy card "Fix login bug" + fizzy card "New feature" --board abc123 --column def456 + fizzy card "Task" --tag tag1 --assign user1 +EOF + fi } # fizzy close # Close a card + cmd_close() { - die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" + 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 + local response + response=$(api_post "/cards/$num/closure") + 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() { - die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" + 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 + local response + response=$(api_delete "/cards/$num/closure") + 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 triage --to # Move card to a column + cmd_triage() { - die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" + 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 + + local body + body=$(jq -n --arg column_id "$column_id" '{column_id: $column_id}') + + local response + response=$(api_post "/cards/$card_number/triage" "$body") + + 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 ID to triage to"}], + examples: ["fizzy triage 123 --to abc456"] + }' + else + cat <<'EOF' +## fizzy triage + +Move card to a column. + +### Usage + + fizzy triage --to + +### Options + + --to Column ID to triage to (required) + --help, -h Show this help + +### Examples + + fizzy triage 123 --to abc456 Move card #123 to column +EOF + fi } # fizzy untriage # Send card back to triage + cmd_untriage() { - die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" + 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 + + local response + response=$(api_delete "/cards/$card_number/triage") + + 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() { - die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" + 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 + + local response + response=$(api_post "/cards/$card_number/not_now") + + 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 # Add comment to a card + cmd_comment_create() { - die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" + 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 content "$content" '{content: $content}') + + 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_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() { - die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" + 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 + + local body + body=$(jq -n --arg user_id "$user_id" '{user_id: $user_id}') + + local response + response=$(api_post "/cards/$card_number/assignments" "$body") + + 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 ID to toggle assignment"}], + examples: ["fizzy assign 123 --to abc456"] + }' + else + cat <<'EOF' +## fizzy assign + +Toggle assignment on a card (adds if not assigned, removes if assigned). + +### Usage + + fizzy assign --to + +### Options + + --to User ID to toggle assignment (required) + --help, -h Show this help + +### Examples + + fizzy assign 123 --to abc456 Toggle assignment on card #123 +EOF + fi } # fizzy tag --with "name" # Toggle tag on card + cmd_tag() { - die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" + local card_number="" + local tag_id="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --with) + if [[ -z "${2:-}" ]]; then + die "--with requires a tag ID" $EXIT_USAGE + fi + tag_id="$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_id" ]]; then + die "--with tag ID required" $EXIT_USAGE "Usage: fizzy tag --with " + fi + + local body + body=$(jq -n --arg tag_id "$tag_id" '{tag_id: $tag_id}') + + local response + response=$(api_post "/cards/$card_number/taggings" "$body") + + 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 ID to toggle"}], + examples: ["fizzy tag 123 --with abc456"] + }' + else + cat <<'EOF' +## fizzy tag + +Toggle tag on a card (adds if not tagged, removes if tagged). + +### Usage + + fizzy tag --with + +### Options + + --with Tag ID to toggle (required) + --help, -h Show this help + +### Examples + + fizzy tag 123 --with abc456 Toggle tag on card #123 +EOF + fi } # fizzy watch # Subscribe to card notifications + cmd_watch() { - die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" + 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 + + local response + response=$(api_post "/cards/$card_number/watch") + + 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() { - die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" + 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 + + local response + response=$(api_delete "/cards/$card_number/watch") + + 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() { - die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" + 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 + + local response + response=$(api_post "/cards/$card_number/goldness") + + 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() { - die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" + 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 + + local response + response=$(api_delete "/cards/$card_number/goldness") + + 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 + cmd_step() { - die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" + 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 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 + + local body + body=$(jq -n --arg content "$content" '{content: $content}') + + 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: "Add step to a card", + usage: "fizzy step \"text\" --on ", + options: [{flag: "--on", description: "Card number to add step to"}], + examples: ["fizzy step \"Review PR\" --on 123"] + }' + else + cat <<'EOF' +## fizzy step + +Add step (checklist item) to a card. + +### Usage + + fizzy step "text" --on + +### Options + + --on Card number to add step to (required) + --help, -h Show this help + +### Examples + + fizzy step "Review PR" --on 123 Add step to card #123 +EOF + fi } -# fizzy react "emoji" --on +# fizzy react "emoji" --comment --card # Add reaction to comment + cmd_react() { - die "Not implemented yet" $EXIT_USAGE "Coming in Phase 3" + 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 emoji "$emoji" '{emoji: $emoji}') + + local response + response=$(api_post "/cards/$card_number/comments/$comment_id/reactions" "$body") + + local summary="Reaction added to comment" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "comments" "fizzy comments --on $card_number" "View comments")" \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" + ) + + output "$response" "$summary" "$breadcrumbs" "_react_md" +} + +_react_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local reaction_id emoji + reaction_id=$(echo "$data" | jq -r '.id') + emoji=$(echo "$data" | jq -r '.emoji') + + md_heading 2 "Reaction Added" + echo + md_kv "ID" "$reaction_id" \ + "Emoji" "$emoji" + + md_breadcrumbs "$breadcrumbs" +} + +_react_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy react", + description: "Add reaction to a comment", + usage: "fizzy react \"emoji\" --card --comment ", + options: [ + {flag: "--card", description: "Card number"}, + {flag: "--comment", description: "Comment ID to react to"} + ], + examples: ["fizzy react \"👍\" --card 123 --comment abc456"] + }' + else + cat <<'EOF' +## fizzy react + +Add reaction to a comment. + +### Usage + + fizzy react "emoji" --card --comment + +### Options + + --card Card number (required) + --comment Comment ID to react to (required) + --help, -h Show this help + +### Examples + + fizzy react "👍" --card 123 --comment abc456 +EOF + fi } diff --git a/cli/test/actions.bats b/cli/test/actions.bats new file mode 100644 index 0000000000..a0e3f7c00b --- /dev/null +++ b/cli/test/actions.bats @@ -0,0 +1,642 @@ +#!/usr/bin/env bats +# actions.bats - Tests for card action commands (Phase 3) + +load test_helper + + +# card (create) --help + +@test "card --help shows help" { + run fizzy --md card --help + assert_success + assert_output_contains "fizzy card" + assert_output_contains "Create" +} + +@test "card -h shows help" { + run fizzy --md card -h + assert_success + assert_output_contains "fizzy card" +} + +@test "card --help --json outputs JSON" { + run fizzy --json card --help + assert_success + is_valid_json + assert_json_not_null ".command" + assert_json_not_null ".options" +} + + +# card requires title + +@test "card without title shows error" { + run fizzy card + assert_failure + assert_output_contains "title required" +} + +@test "card requires board" { + run fizzy card "Test" + assert_failure + assert_output_contains "No board specified" +} + +@test "card requires authentication with board" { + create_local_config '{"board_id": "test-board-id"}' + run fizzy card "Test" + assert_failure + assert_output_contains "Not authenticated" +} + +@test "card rejects unknown option" { + run fizzy card --unknown-option + assert_failure + assert_output_contains "Unknown option" +} + + +# close --help + +@test "close --help shows help" { + run fizzy --md close --help + assert_success + assert_output_contains "fizzy close" + assert_output_contains "Close" +} + +@test "close -h shows help" { + run fizzy --md close -h + assert_success + assert_output_contains "fizzy close" +} + +@test "close --help --json outputs JSON" { + run fizzy --json close --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# close requires card number + +@test "close without number shows error" { + run fizzy close + assert_failure + assert_output_contains "Card number required" +} + +@test "close requires authentication" { + run fizzy close 123 + assert_failure + assert_output_contains "Not authenticated" +} + +@test "close rejects unknown option" { + run fizzy close --unknown-option + assert_failure + assert_output_contains "Unknown option" +} + + +# reopen --help + +@test "reopen --help shows help" { + run fizzy --md reopen --help + assert_success + assert_output_contains "fizzy reopen" + assert_output_contains "Reopen" +} + +@test "reopen -h shows help" { + run fizzy --md reopen -h + assert_success + assert_output_contains "fizzy reopen" +} + +@test "reopen --help --json outputs JSON" { + run fizzy --json reopen --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# reopen requires card number + +@test "reopen without number shows error" { + run fizzy reopen + assert_failure + assert_output_contains "Card number required" +} + +@test "reopen requires authentication" { + run fizzy reopen 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# triage --help + +@test "triage --help shows help" { + run fizzy --md triage --help + assert_success + assert_output_contains "fizzy triage" + assert_output_contains "Move card" +} + +@test "triage -h shows help" { + run fizzy --md triage -h + assert_success + assert_output_contains "fizzy triage" +} + +@test "triage --help --json outputs JSON" { + run fizzy --json triage --help + assert_success + is_valid_json + assert_json_not_null ".command" + assert_json_not_null ".options" +} + + +# triage requires parameters + +@test "triage without number shows error" { + run fizzy triage + assert_failure + assert_output_contains "Card number required" +} + +@test "triage without --to shows error" { + run fizzy triage 123 + assert_failure + assert_output_contains "column ID required" +} + +@test "triage requires authentication" { + run fizzy triage 123 --to col456 + assert_failure + assert_output_contains "Not authenticated" +} + + +# untriage --help + +@test "untriage --help shows help" { + run fizzy --md untriage --help + assert_success + assert_output_contains "fizzy untriage" + assert_output_contains "triage" +} + +@test "untriage -h shows help" { + run fizzy --md untriage -h + assert_success + assert_output_contains "fizzy untriage" +} + +@test "untriage --help --json outputs JSON" { + run fizzy --json untriage --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# untriage requires card number + +@test "untriage without number shows error" { + run fizzy untriage + assert_failure + assert_output_contains "Card number required" +} + +@test "untriage requires authentication" { + run fizzy untriage 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# postpone --help + +@test "postpone --help shows help" { + run fizzy --md postpone --help + assert_success + assert_output_contains "fizzy postpone" + assert_output_contains "Not Now" +} + +@test "postpone -h shows help" { + run fizzy --md postpone -h + assert_success + assert_output_contains "fizzy postpone" +} + +@test "postpone --help --json outputs JSON" { + run fizzy --json postpone --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# postpone requires card number + +@test "postpone without number shows error" { + run fizzy postpone + assert_failure + assert_output_contains "Card number required" +} + +@test "postpone requires authentication" { + run fizzy postpone 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# comment --help + +@test "comment --help shows help" { + run fizzy --md comment --help + assert_success + assert_output_contains "fizzy comment" + assert_output_contains "Add comment" +} + +@test "comment -h shows help" { + run fizzy --md comment -h + assert_success + assert_output_contains "fizzy comment" +} + +@test "comment --help --json outputs JSON" { + run fizzy --json comment --help + assert_success + is_valid_json + assert_json_not_null ".command" + assert_json_not_null ".options" +} + + +# comment requires parameters + +@test "comment without text shows error" { + run fizzy comment + assert_failure + assert_output_contains "content required" +} + +@test "comment without --on shows error" { + run fizzy comment "Test comment" + assert_failure + assert_output_contains "card number required" +} + +@test "comment requires authentication" { + run fizzy comment "Test" --on 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# assign --help + +@test "assign --help shows help" { + run fizzy --md assign --help + assert_success + assert_output_contains "fizzy assign" + assert_output_contains "Toggle assignment" +} + +@test "assign -h shows help" { + run fizzy --md assign -h + assert_success + assert_output_contains "fizzy assign" +} + +@test "assign --help --json outputs JSON" { + run fizzy --json assign --help + assert_success + is_valid_json + assert_json_not_null ".command" + assert_json_not_null ".options" +} + + +# assign requires parameters + +@test "assign without number shows error" { + run fizzy assign + assert_failure + assert_output_contains "Card number required" +} + +@test "assign without --to shows error" { + run fizzy assign 123 + assert_failure + assert_output_contains "user ID required" +} + +@test "assign requires authentication" { + run fizzy assign 123 --to user456 + assert_failure + assert_output_contains "Not authenticated" +} + + +# tag --help + +@test "tag --help shows help" { + run fizzy --md tag --help + assert_success + assert_output_contains "fizzy tag" + assert_output_contains "Toggle tag" +} + +@test "tag -h shows help" { + run fizzy --md tag -h + assert_success + assert_output_contains "fizzy tag" +} + +@test "tag --help --json outputs JSON" { + run fizzy --json tag --help + assert_success + is_valid_json + assert_json_not_null ".command" + assert_json_not_null ".options" +} + + +# tag requires parameters + +@test "tag without number shows error" { + run fizzy tag + assert_failure + assert_output_contains "Card number required" +} + +@test "tag without --with shows error" { + run fizzy tag 123 + assert_failure + assert_output_contains "tag ID required" +} + +@test "tag requires authentication" { + run fizzy tag 123 --with tag456 + assert_failure + assert_output_contains "Not authenticated" +} + + +# watch --help + +@test "watch --help shows help" { + run fizzy --md watch --help + assert_success + assert_output_contains "fizzy watch" + assert_output_contains "Subscribe" +} + +@test "watch -h shows help" { + run fizzy --md watch -h + assert_success + assert_output_contains "fizzy watch" +} + +@test "watch --help --json outputs JSON" { + run fizzy --json watch --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# watch requires card number + +@test "watch without number shows error" { + run fizzy watch + assert_failure + assert_output_contains "Card number required" +} + +@test "watch requires authentication" { + run fizzy watch 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# unwatch --help + +@test "unwatch --help shows help" { + run fizzy --md unwatch --help + assert_success + assert_output_contains "fizzy unwatch" + assert_output_contains "Unsubscribe" +} + +@test "unwatch -h shows help" { + run fizzy --md unwatch -h + assert_success + assert_output_contains "fizzy unwatch" +} + +@test "unwatch --help --json outputs JSON" { + run fizzy --json unwatch --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# unwatch requires card number + +@test "unwatch without number shows error" { + run fizzy unwatch + assert_failure + assert_output_contains "Card number required" +} + +@test "unwatch requires authentication" { + run fizzy unwatch 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# gild --help + +@test "gild --help shows help" { + run fizzy --md gild --help + assert_success + assert_output_contains "fizzy gild" + assert_output_contains "golden" +} + +@test "gild -h shows help" { + run fizzy --md gild -h + assert_success + assert_output_contains "fizzy gild" +} + +@test "gild --help --json outputs JSON" { + run fizzy --json gild --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# gild requires card number + +@test "gild without number shows error" { + run fizzy gild + assert_failure + assert_output_contains "Card number required" +} + +@test "gild requires authentication" { + run fizzy gild 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# ungild --help + +@test "ungild --help shows help" { + run fizzy --md ungild --help + assert_success + assert_output_contains "fizzy ungild" + assert_output_contains "golden" +} + +@test "ungild -h shows help" { + run fizzy --md ungild -h + assert_success + assert_output_contains "fizzy ungild" +} + +@test "ungild --help --json outputs JSON" { + run fizzy --json ungild --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# ungild requires card number + +@test "ungild without number shows error" { + run fizzy ungild + assert_failure + assert_output_contains "Card number required" +} + +@test "ungild requires authentication" { + run fizzy ungild 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# step --help + +@test "step --help shows help" { + run fizzy --md step --help + assert_success + assert_output_contains "fizzy step" + assert_output_contains "Add step" +} + +@test "step -h shows help" { + run fizzy --md step -h + assert_success + assert_output_contains "fizzy step" +} + +@test "step --help --json outputs JSON" { + run fizzy --json step --help + assert_success + is_valid_json + assert_json_not_null ".command" + assert_json_not_null ".options" +} + + +# step requires parameters + +@test "step without text shows error" { + run fizzy step + assert_failure + assert_output_contains "content required" +} + +@test "step without --on shows error" { + run fizzy step "Test step" + assert_failure + assert_output_contains "card number required" +} + +@test "step requires authentication" { + run fizzy step "Test" --on 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# react --help + +@test "react --help shows help" { + run fizzy --md react --help + assert_success + assert_output_contains "fizzy react" + assert_output_contains "reaction" +} + +@test "react -h shows help" { + run fizzy --md react -h + assert_success + assert_output_contains "fizzy react" +} + +@test "react --help --json outputs JSON" { + run fizzy --json react --help + assert_success + is_valid_json + assert_json_not_null ".command" + assert_json_not_null ".options" +} + + +# react requires parameters + +@test "react without emoji shows error" { + run fizzy react + assert_failure + assert_output_contains "Emoji required" +} + +@test "react without --card shows error" { + run fizzy react "👍" + assert_failure + assert_output_contains "card" +} + +@test "react without --comment shows error" { + run fizzy react "👍" --card 123 + assert_failure + assert_output_contains "comment" +} + +@test "react requires authentication" { + run fizzy react "👍" --card 123 --comment abc456 + assert_failure + assert_output_contains "Not authenticated" +} From 56bf10b55ac6875138a2211a0642acd5e20a567e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 03:52:44 -0800 Subject: [PATCH 08/32] Name resolution for fuzzy matching --- cli/bin/fizzy | 1 + cli/lib/commands/actions.sh | 157 ++++++++++--- cli/lib/commands/boards.sh | 20 +- cli/lib/commands/cards.sh | 45 +++- cli/lib/names.sh | 434 ++++++++++++++++++++++++++++++++++++ cli/test/names.bats | 198 ++++++++++++++++ 6 files changed, 802 insertions(+), 53 deletions(-) create mode 100644 cli/lib/names.sh create mode 100644 cli/test/names.bats diff --git a/cli/bin/fizzy b/cli/bin/fizzy index 8b11b60e0b..2cff64c0dd 100755 --- a/cli/bin/fizzy +++ b/cli/bin/fizzy @@ -35,6 +35,7 @@ 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 diff --git a/cli/lib/commands/actions.sh b/cli/lib/commands/actions.sh index 0e96994264..405f349a0b 100644 --- a/cli/lib/commands/actions.sh +++ b/cli/lib/commands/actions.sh @@ -88,6 +88,48 @@ cmd_card_create() { 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 + 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 tag names to IDs + local resolved_tag_ids=() + for tag in "${tag_ids[@]}"; do + local resolved_tag + if resolved_tag=$(resolve_tag_id "$tag"); then + resolved_tag_ids+=("$resolved_tag") + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy tags" + fi + done + tag_ids=("${resolved_tag_ids[@]}") + + # Resolve assignee names to IDs + 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[@]}") + # Build request body local body body=$(jq -n \ @@ -158,16 +200,16 @@ _card_create_help() { description: "Create a new card", usage: "fizzy card \"title\" [options]", options: [ - {flag: "--board, -b", description: "Board ID (required if not in config)"}, - {flag: "--column, -c", description: "Column ID to triage directly"}, + {flag: "--board, -b", description: "Board name or ID (required if not in config)"}, + {flag: "--column, -c", description: "Column name or ID to triage directly"}, {flag: "--description, -d", description: "Card description"}, - {flag: "--tag", description: "Tag ID (can be repeated)"}, - {flag: "--assign", description: "Assignee user ID (can be repeated)"} + {flag: "--tag", description: "Tag name or ID (can be repeated)"}, + {flag: "--assign", description: "Assignee name, email, or ID (can be repeated)"} ], examples: [ "fizzy card \"Fix login bug\"", - "fizzy card \"New feature\" --board abc123 --column def456", - "fizzy card \"Task\" --tag tag1 --tag tag2 --assign user1" + "fizzy card \"New feature\" --board \"My Board\" --column \"In Progress\"", + "fizzy card \"Task\" --tag \"bug\" --assign \"Jane Doe\"" ] }' else @@ -182,18 +224,18 @@ Create a new card. ### Options - --board, -b Board ID (required if not in config) - --column, -c Column ID to triage directly + --board, -b Board name or ID (required if not in config) + --column, -c Column name or ID to triage directly --description, -d Card description - --tag Tag ID (can be repeated) - --assign Assignee user ID (can be repeated) + --tag Tag name or ID (can be repeated) + --assign Assignee name, email, or ID (can be repeated) --help, -h Show this help ### Examples fizzy card "Fix login bug" - fizzy card "New feature" --board abc123 --column def456 - fizzy card "Task" --tag tag1 --assign user1 + fizzy card "New feature" --board "My Board" --column "In Progress" + fizzy card "Task" --tag "bug" --assign "Jane Doe" EOF fi } @@ -470,14 +512,38 @@ cmd_triage() { fi if [[ -z "$card_number" ]]; then - die "Card number required" $EXIT_USAGE "Usage: fizzy triage --to " + 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 " + die "--to column ID required" $EXIT_USAGE "Usage: fizzy triage --to " + fi + fi + + # Resolve column name to ID if needed + # First, we need the board_id to resolve column within board context + 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 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 fi @@ -530,9 +596,9 @@ _triage_help() { jq -n '{ command: "fizzy triage", description: "Move card to a column", - usage: "fizzy triage --to ", - options: [{flag: "--to", description: "Column ID to triage to"}], - examples: ["fizzy triage 123 --to abc456"] + 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' @@ -542,16 +608,17 @@ Move card to a column. ### Usage - fizzy triage --to + fizzy triage --to ### Options - --to Column ID to triage to (required) + --to Column name or ID to triage to (required) --help, -h Show this help ### Examples - fizzy triage 123 --to abc456 Move card #123 to column + fizzy triage 123 --to "In Progress" Move by column name + fizzy triage 123 --to abc456 Move by column ID EOF fi } @@ -910,11 +977,19 @@ cmd_assign() { fi if [[ -z "$card_number" ]]; then - die "Card number required" $EXIT_USAGE "Usage: fizzy assign --to " + 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 " + 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 @@ -959,9 +1034,9 @@ _assign_help() { jq -n '{ command: "fizzy assign", description: "Toggle assignment on a card", - usage: "fizzy assign --to ", - options: [{flag: "--to", description: "User ID to toggle assignment"}], - examples: ["fizzy assign 123 --to abc456"] + 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' @@ -971,16 +1046,17 @@ Toggle assignment on a card (adds if not assigned, removes if assigned). ### Usage - fizzy assign --to + fizzy assign --to ### Options - --to User ID to toggle assignment (required) + --to User name, email, or ID to toggle assignment (required) --help, -h Show this help ### Examples - fizzy assign 123 --to abc456 Toggle assignment on card #123 + fizzy assign 123 --to "Jane Doe" Assign by name + fizzy assign 123 --to jane@example.com Assign by email EOF fi } @@ -1025,11 +1101,19 @@ cmd_tag() { fi if [[ -z "$card_number" ]]; then - die "Card number required" $EXIT_USAGE "Usage: fizzy tag --with " + die "Card number required" $EXIT_USAGE "Usage: fizzy tag --with " fi if [[ -z "$tag_id" ]]; then - die "--with tag ID required" $EXIT_USAGE "Usage: fizzy tag --with " + die "--with tag ID required" $EXIT_USAGE "Usage: fizzy tag --with " + fi + + # Resolve tag name to ID + local resolved_tag + if resolved_tag=$(resolve_tag_id "$tag_id"); then + tag_id="$resolved_tag" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy tags" fi local body @@ -1076,9 +1160,9 @@ _tag_help() { jq -n '{ command: "fizzy tag", description: "Toggle tag on a card", - usage: "fizzy tag --with ", - options: [{flag: "--with", description: "Tag ID to toggle"}], - examples: ["fizzy tag 123 --with abc456"] + usage: "fizzy tag --with ", + options: [{flag: "--with", description: "Tag name or ID to toggle"}], + examples: ["fizzy tag 123 --with \"bug\"", "fizzy tag 123 --with abc456"] }' else cat <<'EOF' @@ -1088,16 +1172,17 @@ Toggle tag on a card (adds if not tagged, removes if tagged). ### Usage - fizzy tag --with + fizzy tag --with ### Options - --with Tag ID to toggle (required) + --with Tag name or ID to toggle (required) --help, -h Show this help ### Examples - fizzy tag 123 --with abc456 Toggle tag on card #123 + fizzy tag 123 --with "bug" Toggle tag by name + fizzy tag 123 --with abc456 Toggle tag by ID EOF fi } diff --git a/cli/lib/commands/boards.sh b/cli/lib/commands/boards.sh index c7b01ba118..4957b516ed 100644 --- a/cli/lib/commands/boards.sh +++ b/cli/lib/commands/boards.sh @@ -142,7 +142,15 @@ cmd_columns() { if [[ -z "$board_id" ]]; then die "No board specified" $EXIT_USAGE \ - "Use: fizzy columns --board " + "Use: fizzy columns --board " + fi + + # Resolve board name to ID if needed + 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 local response @@ -193,10 +201,10 @@ _columns_help() { command: "fizzy columns", description: "List columns on a board", options: [ - {flag: "--board, -b", description: "Board ID (required unless set in config)"} + {flag: "--board, -b", description: "Board name or ID (required unless set in config)"} ], examples: [ - "fizzy columns --board abc123", + "fizzy columns --board \"My Board\"", "fizzy columns -b abc123 --json" ] }' @@ -212,13 +220,13 @@ List columns on a board. ### Options - --board, -b Board ID (required unless set in config) + --board, -b Board name or ID (required unless set in config) --help, -h Show this help ### Examples - fizzy columns --board abc123 List columns on board - fizzy columns List columns on default board + fizzy columns --board "My Board" List columns on board + fizzy columns List columns on default board EOF fi } diff --git a/cli/lib/commands/cards.sh b/cli/lib/commands/cards.sh index b1eaf128bb..dd28568bcf 100644 --- a/cli/lib/commands/cards.sh +++ b/cli/lib/commands/cards.sh @@ -82,15 +82,36 @@ cmd_cards() { board_id=$(get_board_id 2>/dev/null || true) fi + # Resolve board name to ID if needed if [[ -n "$board_id" ]]; then + 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 params+=("board_ids[]=$board_id") fi + # Resolve tag name to ID if needed if [[ -n "$tag_id" ]]; then + local resolved_tag + if resolved_tag=$(resolve_tag_id "$tag_id"); then + tag_id="$resolved_tag" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy tags" + fi params+=("tag_ids[]=$tag_id") fi + # Resolve assignee name/email to ID if needed if [[ -n "$assignee_id" ]]; then + local resolved_user + if resolved_user=$(resolve_user_id "$assignee_id"); then + assignee_id="$resolved_user" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy people" + fi params+=("assignee_ids[]=$assignee_id") fi @@ -169,16 +190,17 @@ _cards_help() { command: "fizzy cards", description: "List and filter cards", options: [ - {flag: "--board, -b", description: "Filter by board ID"}, - {flag: "--tag", description: "Filter by tag ID"}, - {flag: "--assignee", description: "Filter by assignee ID"}, + {flag: "--board, -b", description: "Filter by board name or ID"}, + {flag: "--tag", description: "Filter by tag name or ID"}, + {flag: "--assignee", description: "Filter by assignee name, email, or ID"}, {flag: "--status", description: "Filter by status: all, closed, not_now, stalled, golden"}, {flag: "--search, -s", description: "Search terms"}, {flag: "--sort", description: "Sort order: latest, newest, oldest"} ], examples: [ "fizzy cards", - "fizzy cards --board abc123", + "fizzy cards --board \"My Board\"", + "fizzy cards --assignee \"Jane Doe\"", "fizzy cards --search \"bug fix\"", "fizzy cards --status closed" ] @@ -195,9 +217,9 @@ List and filter cards. ### Options - --board, -b Filter by board ID - --tag Filter by tag ID - --assignee Filter by assignee ID + --board, -b Filter by board name or ID + --tag Filter by tag name or ID + --assignee Filter by assignee name, email, or ID --status Filter: all, closed, not_now, stalled, golden --search, -s Search terms --sort Sort: latest (default), newest, oldest @@ -205,10 +227,11 @@ List and filter cards. ### Examples - fizzy cards List all cards - fizzy cards --board abc123 Filter by board - fizzy cards --search "bug" Search for cards - fizzy cards --status closed Show closed cards + fizzy cards List all cards + fizzy cards --board "My Board" Filter by board name + fizzy cards --assignee "Jane" Filter by assignee name + fizzy cards --search "bug" Search for cards + fizzy cards --status closed Show closed cards EOF fi } diff --git a/cli/lib/names.sh b/cli/lib/names.sh new file mode 100644 index 0000000000..192def09b9 --- /dev/null +++ b/cli/lib/names.sh @@ -0,0 +1,434 @@ +#!/usr/bin/env bash +# names.sh - Name resolution for fizzy +# +# Allows using human-readable names instead of IDs for boards and users. +# Uses a session cache to avoid repeated API calls. + + +# Cache directory (session-scoped temp files) +_FIZZY_CACHE_DIR="${TMPDIR:-/tmp}/fizzy-cache-$$" + +# Global error message for resolution failures +# Initialized here so it's always declared (resolver functions run in subshells +# via command substitution, so their assignments don't reach the parent shell) +RESOLVE_ERROR="" + + +# Cache Management + +_ensure_cache_dir() { + if [[ ! -d "$_FIZZY_CACHE_DIR" ]]; then + mkdir -p "$_FIZZY_CACHE_DIR" + fi +} + +_get_cache() { + local type="$1" + local file="$_FIZZY_CACHE_DIR/${type}.json" + if [[ -f "$file" ]]; then + cat "$file" + fi +} + +_set_cache() { + local type="$1" + local data="$2" + _ensure_cache_dir + echo "$data" > "$_FIZZY_CACHE_DIR/${type}.json" +} + +_clear_cache() { + rm -rf "$_FIZZY_CACHE_DIR" +} + + +# Board Resolution + +# Resolve a board name or ID to an ID +# Args: $1 - board name, partial name, or ID +# Returns: board ID (or empty if not found) +# Sets: RESOLVE_ERROR with error message if ambiguous/not found +resolve_board_id() { + local input="$1" + RESOLVE_ERROR="" + + # If it looks like a UUID (25+ chars, alphanumeric), assume it's an ID + if [[ "$input" =~ ^[a-z0-9]{20,}$ ]]; then + echo "$input" + return 0 + fi + + # Fetch boards (with cache) + local boards + boards=$(_get_cache "boards") + if [[ -z "$boards" ]]; then + # Don't suppress stderr - let auth errors propagate and exit + boards=$(api_get "/boards") || return 1 + _set_cache "boards" "$boards" + fi + + # Try exact match first + local exact_match + exact_match=$(echo "$boards" | jq -r --arg name "$input" \ + '.[] | select(.name == $name) | .id' | head -1) + if [[ -n "$exact_match" ]]; then + echo "$exact_match" + return 0 + fi + + # Try case-insensitive match + local ci_matches + ci_matches=$(echo "$boards" | jq -r --arg name "$input" \ + '.[] | select(.name | ascii_downcase == ($name | ascii_downcase)) | .id') + local ci_count + ci_count=0 + [[ -n "$ci_matches" ]] && ci_count=$(echo "$ci_matches" | grep -c . || true) + if [[ "$ci_count" -eq 1 ]]; then + echo "$ci_matches" + return 0 + fi + + # Try partial match (contains) + local partial_matches + partial_matches=$(echo "$boards" | jq -r --arg name "$input" \ + '.[] | select(.name | ascii_downcase | contains($name | ascii_downcase)) | "\(.id):\(.name)"') + local partial_count + partial_count=0 + [[ -n "$partial_matches" ]] && partial_count=$(echo "$partial_matches" | grep -c . || true) + + if [[ "$partial_count" -eq 1 ]]; then + echo "$partial_matches" | cut -d: -f1 + return 0 + elif [[ "$partial_count" -gt 1 ]]; then + local names + names=$(echo "$partial_matches" | cut -d: -f2- | tr '\n' ',' | sed 's/,$//') + RESOLVE_ERROR="Ambiguous board name '$input' matches: $names" + return 1 + fi + + # Not found - provide suggestions + RESOLVE_ERROR="Board not found: $input" + local suggestions + suggestions=$(_suggest_similar "$input" "$boards" "name") + if [[ -n "$suggestions" ]]; then + RESOLVE_ERROR="$RESOLVE_ERROR. Did you mean: $suggestions?" + fi + return 1 +} + +# Get cached boards list (for suggestions) +get_boards_list() { + local boards + boards=$(_get_cache "boards") + if [[ -z "$boards" ]]; then + boards=$(api_get "/boards") || return 1 + _set_cache "boards" "$boards" + fi + echo "$boards" +} + + +# User Resolution + +# Resolve a user name, email, or ID to an ID +# Args: $1 - user name, email, partial name, or ID +# Returns: user ID (or empty if not found) +# Sets: RESOLVE_ERROR with error message if ambiguous/not found +resolve_user_id() { + local input="$1" + RESOLVE_ERROR="" + + # If it looks like a UUID (25+ chars, alphanumeric), assume it's an ID + if [[ "$input" =~ ^[a-z0-9]{20,}$ ]]; then + echo "$input" + return 0 + fi + + # Fetch users (with cache) + local users + users=$(_get_cache "users") + if [[ -z "$users" ]]; then + # Don't suppress stderr - let auth errors propagate and exit + users=$(api_get "/users") || return 1 + _set_cache "users" "$users" + fi + + # Try exact email match first + if [[ "$input" == *@* ]]; then + local email_match + email_match=$(echo "$users" | jq -r --arg email "$input" \ + '.[] | select(.email_address == $email) | .id' | head -1) + if [[ -n "$email_match" ]]; then + echo "$email_match" + return 0 + fi + fi + + # Try exact name match + local exact_match + exact_match=$(echo "$users" | jq -r --arg name "$input" \ + '.[] | select(.name == $name) | .id' | head -1) + if [[ -n "$exact_match" ]]; then + echo "$exact_match" + return 0 + fi + + # Try case-insensitive name match + local ci_matches + ci_matches=$(echo "$users" | jq -r --arg name "$input" \ + '.[] | select(.name | ascii_downcase == ($name | ascii_downcase)) | .id') + local ci_count + ci_count=0 + [[ -n "$ci_matches" ]] && ci_count=$(echo "$ci_matches" | grep -c . || true) + if [[ "$ci_count" -eq 1 ]]; then + echo "$ci_matches" + return 0 + fi + + # Try partial name match (contains) + local partial_matches + partial_matches=$(echo "$users" | jq -r --arg name "$input" \ + '.[] | select(.name | ascii_downcase | contains($name | ascii_downcase)) | "\(.id):\(.name)"') + local partial_count + partial_count=0 + [[ -n "$partial_matches" ]] && partial_count=$(echo "$partial_matches" | grep -c . || true) + + if [[ "$partial_count" -eq 1 ]]; then + echo "$partial_matches" | cut -d: -f1 + return 0 + elif [[ "$partial_count" -gt 1 ]]; then + local names + names=$(echo "$partial_matches" | cut -d: -f2- | tr '\n' ',' | sed 's/,$//') + RESOLVE_ERROR="Ambiguous user name '$input' matches: $names" + return 1 + fi + + # Not found - provide suggestions + RESOLVE_ERROR="User not found: $input" + local suggestions + suggestions=$(_suggest_similar "$input" "$users" "name") + if [[ -n "$suggestions" ]]; then + RESOLVE_ERROR="$RESOLVE_ERROR. Did you mean: $suggestions?" + fi + return 1 +} + +# Get cached users list (for suggestions) +get_users_list() { + local users + users=$(_get_cache "users") + if [[ -z "$users" ]]; then + users=$(api_get "/users") || return 1 + _set_cache "users" "$users" + fi + echo "$users" +} + + +# Column Resolution + +# Resolve a column name or ID to an ID (within a board) +# Args: $1 - column name, partial name, or ID +# $2 - board ID (required) +# Returns: column ID (or empty if not found) +# Sets: RESOLVE_ERROR with error message if ambiguous/not found +resolve_column_id() { + local input="$1" + local board_id="$2" + RESOLVE_ERROR="" + + if [[ -z "$board_id" ]]; then + RESOLVE_ERROR="Board ID required for column resolution" + return 1 + fi + + # If it looks like a UUID (25+ chars, alphanumeric), assume it's an ID + if [[ "$input" =~ ^[a-z0-9]{20,}$ ]]; then + echo "$input" + return 0 + fi + + # Fetch columns (with cache per board) + local cache_key="columns_${board_id}" + local columns + columns=$(_get_cache "$cache_key") + if [[ -z "$columns" ]]; then + # Don't suppress stderr - let auth errors propagate and exit + columns=$(api_get "/boards/$board_id/columns") || return 1 + _set_cache "$cache_key" "$columns" + fi + + # Try exact match first + local exact_match + exact_match=$(echo "$columns" | jq -r --arg name "$input" \ + '.[] | select(.name == $name) | .id' | head -1) + if [[ -n "$exact_match" ]]; then + echo "$exact_match" + return 0 + fi + + # Try case-insensitive match + local ci_matches + ci_matches=$(echo "$columns" | jq -r --arg name "$input" \ + '.[] | select(.name | ascii_downcase == ($name | ascii_downcase)) | .id') + local ci_count=0 + [[ -n "$ci_matches" ]] && ci_count=$(echo "$ci_matches" | grep -c . || true) + if [[ "$ci_count" -eq 1 ]]; then + echo "$ci_matches" + return 0 + fi + + # Try partial match (contains) + local partial_matches + partial_matches=$(echo "$columns" | jq -r --arg name "$input" \ + '.[] | select(.name | ascii_downcase | contains($name | ascii_downcase)) | "\(.id):\(.name)"') + local partial_count=0 + [[ -n "$partial_matches" ]] && partial_count=$(echo "$partial_matches" | grep -c . || true) + + if [[ "$partial_count" -eq 1 ]]; then + echo "$partial_matches" | cut -d: -f1 + return 0 + elif [[ "$partial_count" -gt 1 ]]; then + local names + names=$(echo "$partial_matches" | cut -d: -f2- | tr '\n' ',' | sed 's/,$//') + RESOLVE_ERROR="Ambiguous column name '$input' matches: $names" + return 1 + fi + + # Not found - provide suggestions + RESOLVE_ERROR="Column not found: $input" + local suggestions + suggestions=$(_suggest_similar "$input" "$columns" "name") + if [[ -n "$suggestions" ]]; then + RESOLVE_ERROR="$RESOLVE_ERROR. Did you mean: $suggestions?" + fi + return 1 +} + + +# Tag Resolution + +# Resolve a tag name or ID to an ID +# Args: $1 - tag name, partial name, or ID +# Returns: tag ID (or empty if not found) +# Sets: RESOLVE_ERROR with error message if ambiguous/not found +resolve_tag_id() { + local input="$1" + RESOLVE_ERROR="" + + # If it looks like a UUID (25+ chars, alphanumeric), assume it's an ID + if [[ "$input" =~ ^[a-z0-9]{20,}$ ]]; then + echo "$input" + return 0 + fi + + # Fetch tags (with cache) + local tags + tags=$(_get_cache "tags") + if [[ -z "$tags" ]]; then + # Don't suppress stderr - let auth errors propagate and exit + tags=$(api_get "/tags") || return 1 + _set_cache "tags" "$tags" + fi + + # Try exact match first + local exact_match + exact_match=$(echo "$tags" | jq -r --arg name "$input" \ + '.[] | select(.name == $name) | .id' | head -1) + if [[ -n "$exact_match" ]]; then + echo "$exact_match" + return 0 + fi + + # Try case-insensitive match + local ci_matches + ci_matches=$(echo "$tags" | jq -r --arg name "$input" \ + '.[] | select(.name | ascii_downcase == ($name | ascii_downcase)) | .id') + local ci_count + ci_count=0 + [[ -n "$ci_matches" ]] && ci_count=$(echo "$ci_matches" | grep -c . || true) + if [[ "$ci_count" -eq 1 ]]; then + echo "$ci_matches" + return 0 + fi + + # Try partial match (contains) + local partial_matches + partial_matches=$(echo "$tags" | jq -r --arg name "$input" \ + '.[] | select(.name | ascii_downcase | contains($name | ascii_downcase)) | "\(.id):\(.name)"') + local partial_count + partial_count=0 + [[ -n "$partial_matches" ]] && partial_count=$(echo "$partial_matches" | grep -c . || true) + + if [[ "$partial_count" -eq 1 ]]; then + echo "$partial_matches" | cut -d: -f1 + return 0 + elif [[ "$partial_count" -gt 1 ]]; then + local names + names=$(echo "$partial_matches" | cut -d: -f2- | tr '\n' ',' | sed 's/,$//') + RESOLVE_ERROR="Ambiguous tag name '$input' matches: $names" + return 1 + fi + + # Not found - provide suggestions + RESOLVE_ERROR="Tag not found: $input" + local suggestions + suggestions=$(_suggest_similar "$input" "$tags" "name") + if [[ -n "$suggestions" ]]; then + RESOLVE_ERROR="$RESOLVE_ERROR. Did you mean: $suggestions?" + fi + return 1 +} + + +# Suggestion Helpers + +# Suggest similar names using simple substring/distance matching +# Args: $1 - input string +# $2 - JSON array of objects +# $3 - field name to match against +# Returns: comma-separated list of similar names (up to 3) +_suggest_similar() { + local input="$1" + local json_array="$2" + local field="$3" + + # Get all names + local names + names=$(echo "$json_array" | jq -r ".[].$field") + + # Find names that share a common prefix (first 3 chars) + local prefix="${input:0:3}" + local suggestions + suggestions=$(echo "$names" | grep -i "^$prefix" | head -3 | tr '\n' ',' | sed 's/,$//') + + # If no prefix matches, try substring matches + if [[ -z "$suggestions" ]]; then + suggestions=$(echo "$names" | grep -i "$input" | head -3 | tr '\n' ',' | sed 's/,$//') + fi + + # If still nothing, return first few available options + if [[ -z "$suggestions" ]]; then + suggestions=$(echo "$names" | head -3 | tr '\n' ',' | sed 's/,$//') + fi + + echo "$suggestions" +} + + +# Error Message Formatting + +# Format resolution error for die() with hint +# Args: $1 - entity type (board, user, column, tag) +# $2 - the failed input +# Uses: RESOLVE_ERROR +format_resolve_error() { + local type="$1" + local input="$2" + + if [[ -n "$RESOLVE_ERROR" ]]; then + echo "$RESOLVE_ERROR" + else + echo "${type^} not found: $input" + fi +} diff --git a/cli/test/names.bats b/cli/test/names.bats new file mode 100644 index 0000000000..e99b7eb5f6 --- /dev/null +++ b/cli/test/names.bats @@ -0,0 +1,198 @@ +#!/usr/bin/env bats +# names.bats - Tests for name resolution (Phase 4) + +load test_helper + + +# resolve_board_id tests + +@test "resolve_board_id returns ID unchanged for UUID-like input" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + # UUID-like strings should pass through unchanged (25+ alphanumeric) + result=$(resolve_board_id "abc123def456ghi789jkl012mno") + [[ "$result" == "abc123def456ghi789jkl012mno" ]] +} + +@test "resolve_user_id returns ID unchanged for UUID-like input" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + result=$(resolve_user_id "abc123def456ghi789jkl012mno") + [[ "$result" == "abc123def456ghi789jkl012mno" ]] +} + +@test "resolve_column_id returns ID unchanged for UUID-like input" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + result=$(resolve_column_id "abc123def456ghi789jkl012mno" "board123") + [[ "$result" == "abc123def456ghi789jkl012mno" ]] +} + +@test "resolve_tag_id returns ID unchanged for UUID-like input" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + result=$(resolve_tag_id "abc123def456ghi789jkl012mno") + [[ "$result" == "abc123def456ghi789jkl012mno" ]] +} + + +# resolve_column_id edge cases + +@test "resolve_column_id requires board_id" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + ! resolve_column_id "My Column" "" + [[ "$RESOLVE_ERROR" == "Board ID required for column resolution" ]] +} + + +# Help text updated to show name support + +@test "cards --help mentions name support" { + run fizzy --md cards --help + assert_success + assert_output_contains "name or ID" +} + +@test "columns --help mentions name support" { + run fizzy --md columns --help + assert_success + assert_output_contains "name or ID" +} + +@test "card --help mentions name support" { + run fizzy --md card --help + assert_success + assert_output_contains "name or ID" +} + +@test "triage --help mentions name support" { + run fizzy --md triage --help + assert_success + assert_output_contains "name or ID" +} + +@test "assign --help mentions name support" { + run fizzy --md assign --help + assert_success + assert_output_contains "name" +} + +@test "tag --help mentions name support" { + run fizzy --md tag --help + assert_success + assert_output_contains "name or ID" +} + + +# Cache management + +@test "_ensure_cache_dir creates cache directory" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + # Set a test cache dir + export _FIZZY_CACHE_DIR="$TEST_HOME/fizzy-cache-test" + rm -rf "$_FIZZY_CACHE_DIR" + + _ensure_cache_dir + [[ -d "$_FIZZY_CACHE_DIR" ]] +} + +@test "_set_cache and _get_cache work" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + export _FIZZY_CACHE_DIR="$TEST_HOME/fizzy-cache-test" + rm -rf "$_FIZZY_CACHE_DIR" + + _set_cache "test" '{"id": "123", "name": "Test"}' + result=$(_get_cache "test") + [[ "$result" == '{"id": "123", "name": "Test"}' ]] +} + +@test "_clear_cache removes cache directory" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + export _FIZZY_CACHE_DIR="$TEST_HOME/fizzy-cache-test" + rm -rf "$_FIZZY_CACHE_DIR" + + _ensure_cache_dir + [[ -d "$_FIZZY_CACHE_DIR" ]] + + _clear_cache + [[ ! -d "$_FIZZY_CACHE_DIR" ]] +} + + +# Suggestion helper + +@test "_suggest_similar returns suggestions" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + local json='[{"name": "Engineering"}, {"name": "Design"}, {"name": "Marketing"}]' + result=$(_suggest_similar "Eng" "$json" "name") + [[ "$result" == *"Engineering"* ]] +} + +@test "_suggest_similar returns multiple suggestions" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + local json='[{"name": "Dev Team"}, {"name": "Dev Ops"}, {"name": "Design"}]' + result=$(_suggest_similar "Dev" "$json" "name") + [[ "$result" == *"Dev Team"* ]] + [[ "$result" == *"Dev Ops"* ]] +} + + +# Error message formatting + +@test "format_resolve_error uses RESOLVE_ERROR" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + RESOLVE_ERROR="Board not found: My Board" + result=$(format_resolve_error "board" "My Board") + [[ "$result" == "Board not found: My Board" ]] +} + +@test "format_resolve_error provides default message" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + RESOLVE_ERROR="" + result=$(format_resolve_error "board" "My Board") + [[ "$result" == "Board not found: My Board" ]] +} From 36d093287f103f3e45c25bebd0a0f0056a25224e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 06:05:08 -0800 Subject: [PATCH 09/32] Fix OAuth callback SIGPIPE and public client token exchange Three improvements: 1. _wait_for_callback: Avoid SIGPIPE killing nc before HTTP response sent. The `nc | head -1` pipe causes head to close stdin after reading one line, sending SIGPIPE to nc before it finishes writing the response. Now captures nc output to temp file before reading with head. 2. _exchange_code: Only include client_secret for confidential clients. Public clients (registered via DCR) have no secret, and sending an empty client_secret= parameter can cause some OAuth servers to reject the request. 3. _register_client: Request scope in DCR to ensure CLI gets proper permissions. --- .../oauth/authorizations_controller.rb | 13 ++++ cli/lib/auth.sh | 69 +++++++++++++++---- 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index bb605e0acb..c0bd216be1 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -1,4 +1,17 @@ class Oauth::AuthorizationsController < Oauth::BaseController + # Allow form submission to the client's redirect_uri for OAuth callbacks + content_security_policy only: :new do |policy| + if (redirect_uri = params[:redirect_uri]).present? + 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 will be caught by validate_redirect_uri + end + end + end + before_action :save_oauth_return_url before_action :require_authentication diff --git a/cli/lib/auth.sh b/cli/lib/auth.sh index 09f38df874..8d30500c52 100644 --- a/cli/lib/auth.sh +++ b/cli/lib/auth.sh @@ -343,6 +343,7 @@ _register_client() { 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" \ @@ -357,7 +358,8 @@ _register_client() { redirect_uris: [$redirect], grant_types: $grants, response_types: ["code"], - token_endpoint_auth_method: "none" + token_endpoint_auth_method: "none", + scope: "read write" }')" \ "$registration_endpoint") @@ -459,16 +461,49 @@ _wait_for_callback() { info "Waiting for authorization (timeout: ${timeout_secs}s)..." - local response + # 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\n

Authorization 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 - request=$(echo -e "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n

Authorization successful!

You can close this window.

" | nc -l '"$FIZZY_REDIRECT_PORT"' 2>/dev/null | head -1) + # 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 - ') || die "Authorization timed out" $EXIT_AUTH + ') || 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 @@ -510,16 +545,24 @@ _exchange_code() { 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 -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 "client_secret=$client_secret" \ - --data-urlencode "code_verifier=$code_verifier" \ - "$token_endpoint") + response=$(curl "${curl_args[@]}" "$token_endpoint") local access_token refresh_token expires_in scope access_token=$(echo "$response" | jq -r '.access_token // empty') From 6acdc40d6745f684d7a7b75a2a48700cd8a6959b Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 06:06:00 -0800 Subject: [PATCH 10/32] --in alias for --board flag --- cli/lib/commands/actions.sh | 6 +++--- cli/lib/commands/boards.sh | 6 +++--- cli/lib/commands/cards.sh | 6 +++--- cli/lib/commands/help.sh | 4 ++-- cli/lib/commands/search.sh | 6 +++--- cli/lib/core.sh | 2 +- cli/test/core.bats | 5 +++++ 7 files changed, 20 insertions(+), 15 deletions(-) diff --git a/cli/lib/commands/actions.sh b/cli/lib/commands/actions.sh index 405f349a0b..22bf01aa05 100644 --- a/cli/lib/commands/actions.sh +++ b/cli/lib/commands/actions.sh @@ -16,7 +16,7 @@ cmd_card_create() { while [[ $# -gt 0 ]]; do case "$1" in - --board|-b) + --board|-b|--in) if [[ -z "${2:-}" ]]; then die "--board requires a board ID" $EXIT_USAGE fi @@ -200,7 +200,7 @@ _card_create_help() { description: "Create a new card", usage: "fizzy card \"title\" [options]", options: [ - {flag: "--board, -b", description: "Board name or ID (required if not in config)"}, + {flag: "--board, -b, --in", description: "Board name or ID (required if not in config)"}, {flag: "--column, -c", description: "Column name or ID to triage directly"}, {flag: "--description, -d", description: "Card description"}, {flag: "--tag", description: "Tag name or ID (can be repeated)"}, @@ -224,7 +224,7 @@ Create a new card. ### Options - --board, -b Board name or ID (required if not in config) + --board, -b, --in Board name or ID (required if not in config) --column, -c Column name or ID to triage directly --description, -d Card description --tag Tag name or ID (can be repeated) diff --git a/cli/lib/commands/boards.sh b/cli/lib/commands/boards.sh index 4957b516ed..e401b04e90 100644 --- a/cli/lib/commands/boards.sh +++ b/cli/lib/commands/boards.sh @@ -113,7 +113,7 @@ cmd_columns() { while [[ $# -gt 0 ]]; do case "$1" in - --board|-b) + --board|-b|--in) if [[ -z "${2:-}" ]]; then die "--board requires a value" $EXIT_USAGE fi @@ -201,7 +201,7 @@ _columns_help() { command: "fizzy columns", description: "List columns on a board", options: [ - {flag: "--board, -b", description: "Board name or ID (required unless set in config)"} + {flag: "--board, -b, --in", description: "Board name or ID (required unless set in config)"} ], examples: [ "fizzy columns --board \"My Board\"", @@ -220,7 +220,7 @@ List columns on a board. ### Options - --board, -b Board name or ID (required unless set in config) + --board, -b, --in Board name or ID (required unless set in config) --help, -h Show this help ### Examples diff --git a/cli/lib/commands/cards.sh b/cli/lib/commands/cards.sh index dd28568bcf..982382cd13 100644 --- a/cli/lib/commands/cards.sh +++ b/cli/lib/commands/cards.sh @@ -16,7 +16,7 @@ cmd_cards() { while [[ $# -gt 0 ]]; do case "$1" in - --board|-b) + --board|-b|--in) if [[ -z "${2:-}" ]]; then die "--board requires a value" $EXIT_USAGE fi @@ -190,7 +190,7 @@ _cards_help() { command: "fizzy cards", description: "List and filter cards", options: [ - {flag: "--board, -b", description: "Filter by board name or ID"}, + {flag: "--board, -b, --in", description: "Filter by board name or ID"}, {flag: "--tag", description: "Filter by tag name or ID"}, {flag: "--assignee", description: "Filter by assignee name, email, or ID"}, {flag: "--status", description: "Filter by status: all, closed, not_now, stalled, golden"}, @@ -217,7 +217,7 @@ List and filter cards. ### Options - --board, -b Filter by board name or ID + --board, -b, --in Filter by board name or ID --tag Filter by tag name or ID --assignee Filter by assignee name, email, or ID --status Filter: all, closed, not_now, stalled, golden diff --git a/cli/lib/commands/help.sh b/cli/lib/commands/help.sh index d05e1b1514..f0f9f991f9 100644 --- a/cli/lib/commands/help.sh +++ b/cli/lib/commands/help.sh @@ -70,7 +70,7 @@ _help_json() { {"flag": "--md/-m", "description": "Force markdown output"}, {"flag": "--quiet/-q", "description": "Suppress non-essential output"}, {"flag": "--verbose/-v", "description": "Debug output"}, - {"flag": "--board/-b", "description": "Board ID or name"}, + {"flag": "--board/-b/--in", "description": "Board ID or name"}, {"flag": "--account/-a", "description": "Account slug"} ] }' @@ -92,7 +92,7 @@ Agent-first CLI for Fizzy. --md, -m Force markdown output --quiet, -q Suppress non-essential output (data only) --verbose, -v Debug output - --board, -b Board ID or name + --board, -b, --in Board ID or name --account, -a Account slug ## COMMANDS diff --git a/cli/lib/commands/search.sh b/cli/lib/commands/search.sh index 36b0618300..0414979c3d 100644 --- a/cli/lib/commands/search.sh +++ b/cli/lib/commands/search.sh @@ -13,7 +13,7 @@ cmd_search() { # First non-flag argument is the query while [[ $# -gt 0 ]]; do case "$1" in - --board|-b) + --board|-b|--in) if [[ -z "${2:-}" ]]; then die "--board requires a value" $EXIT_USAGE fi @@ -125,7 +125,7 @@ _search_help() { description: "Search cards", usage: "fizzy search [options]", options: [ - {flag: "--board, -b", description: "Filter results to a specific board"} + {flag: "--board, -b, --in", description: "Filter results to a specific board"} ], examples: [ "fizzy search \"bug fix\"", @@ -144,7 +144,7 @@ Search cards by keywords. ### Options - --board, -b Filter results to a specific board + --board, -b, --in Filter results to a specific board --help, -h Show this help ### Examples diff --git a/cli/lib/core.sh b/cli/lib/core.sh index f839228b3e..997137c861 100644 --- a/cli/lib/core.sh +++ b/cli/lib/core.sh @@ -317,7 +317,7 @@ parse_global_flags() { (( ++GLOBAL_FLAGS_CONSUMED )) shift ;; - --board|-b) + --board|-b|--in) if [[ -z "${2:-}" ]]; then die "--board requires a value" $EXIT_USAGE fi diff --git a/cli/test/core.bats b/cli/test/core.bats index 8f30f42b30..f26146134c 100644 --- a/cli/test/core.bats +++ b/cli/test/core.bats @@ -147,6 +147,11 @@ load test_helper assert_success } +@test "fizzy --in is alias for --board" { + run fizzy --json --in test-board + assert_success +} + @test "fizzy -a is alias for --account" { run fizzy --json -a 12345 assert_success From 5015069cfaf30521225bd8c22398381f90ae474c Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 06:33:53 -0800 Subject: [PATCH 11/32] README and tab completion --- cli/README.md | 240 +++++++++++++++++++++++++++++++++++++ cli/completions/fizzy.bash | 56 +++++++++ cli/completions/fizzy.zsh | 83 +++++++++++++ 3 files changed, 379 insertions(+) create mode 100644 cli/README.md create mode 100644 cli/completions/fizzy.bash create mode 100644 cli/completions/fizzy.zsh diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000000..74d2f41bb1 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,240 @@ +# 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, or use `fizzy self-update` (if available). + +## 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 boards --quiet | jq '.[0]' + +# Force markdown +fizzy --md boards +``` + +### 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 | +| `columns` | List columns on a board | +| `comments` | List comments on a card | +| `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` | Create a new card | +| `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 a comment to a card | +| `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` | Add a step (checklist item) to a card | +| `react` | Add a reaction to a card or comment | + +## Global Flags + +| 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 | +| `--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. + +## 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 + +Point `fizzy` at different Fizzy instances using `FIZZY_BASE_URL`: + +```bash +# Production (default) +fizzy boards + +# Local development +FIZZY_BASE_URL=http://fizzy.localhost:3006 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/completions/fizzy.bash b/cli/completions/fizzy.bash new file mode 100644 index 0000000000..95f29cbd3e --- /dev/null +++ b/cli/completions/fizzy.bash @@ -0,0 +1,56 @@ +# 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 notifications people search show tags + card close reopen triage untriage postpone comment assign tag watch unwatch gild ungild step react + " + + local auth_subcommands="login logout status refresh" + local config_subcommands="list get set unset path" + + 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 + ;; + --board|-b|--in) + # Could complete board names if cached, for now just return + return + ;; + --status) + COMPREPLY=($(compgen -W "all closed not_now stalled golden" -- "$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/completions/fizzy.zsh b/cli/completions/fizzy.zsh new file mode 100644 index 0000000000..061258aacc --- /dev/null +++ b/cli/completions/fizzy.zsh @@ -0,0 +1,83 @@ +#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' + 'notifications:List notifications' + 'people:List users in account' + 'search:Search cards' + 'show:Show card or board details' + 'tags:List tags' + 'card:Create a new card' + '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 a comment to a card' + '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:Add a step (checklist item) to a card' + 'react:Add a reaction to a card or comment' + ) + + 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) + case "$words[1]" in + auth) + _describe -t auth_commands 'auth subcommand' auth_commands + ;; + config) + _describe -t config_commands 'config subcommand' config_commands + ;; + esac + ;; + esac +} + +_fizzy "$@" From 35d9e8c938e2c462e2709435cb69a29fa3ff4e90 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 06:58:12 -0800 Subject: [PATCH 12/32] Multi-origin credential storage and install script --- cli/README.md | 2 +- cli/completions/{fizzy.zsh => _fizzy} | 2 +- cli/install.sh | 96 ++++++++++++++++++++ cli/lib/auth.sh | 34 +++---- cli/lib/config.sh | 122 +++++++++++++++++++++++--- cli/test/auth.bats | 81 +++++++++++------ cli/test/test_helper.bash | 52 +++++++---- 7 files changed, 314 insertions(+), 75 deletions(-) rename cli/completions/{fizzy.zsh => _fizzy} (98%) create mode 100755 cli/install.sh diff --git a/cli/README.md b/cli/README.md index 74d2f41bb1..9cff62975d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -23,7 +23,7 @@ curl -fsSL https://raw.githubusercontent.com/basecamp/fizzy/main/cli/install.sh ### Updating -Run the installer again, or use `fizzy self-update` (if available). +Run the installer again to update to the latest version. ## Quick Start diff --git a/cli/completions/fizzy.zsh b/cli/completions/_fizzy similarity index 98% rename from cli/completions/fizzy.zsh rename to cli/completions/_fizzy index 061258aacc..a13178e4c9 100644 --- a/cli/completions/fizzy.zsh +++ b/cli/completions/_fizzy @@ -68,7 +68,7 @@ _fizzy() { _describe -t commands 'fizzy command' commands ;; args) - case "$words[1]" in + case "$line[1]" in auth) _describe -t auth_commands 'auth subcommand' auth_commands ;; diff --git a/cli/install.sh b/cli/install.sh new file mode 100755 index 0000000000..86646953c6 --- /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/fizzy" + + # Create symlink in bin + ln -sf "$INSTALL_DIR/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/auth.sh b/cli/lib/auth.sh index 8d30500c52..103d2a7199 100644 --- a/cli/lib/auth.sh +++ b/cli/lib/auth.sh @@ -196,14 +196,11 @@ _auth_login() { } _auth_logout() { - local creds_file - creds_file=$(get_credentials_path) - - if [[ -f "$creds_file" ]]; then - rm -f "$creds_file" - info "Logged out successfully" + if get_access_token &>/dev/null; then + clear_credentials + info "Logged out from $FIZZY_BASE_URL" else - info "Not logged in" + info "Not logged in to $FIZZY_BASE_URL" fi } @@ -374,25 +371,22 @@ _register_client() { debug "Registered client_id: $client_id" - ensure_global_config_dir - # Public clients may not have a client_secret - jq -n \ + # 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}' \ - > "$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CLIENT_FILE" - - chmod 600 "$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CLIENT_FILE" + '{client_id: $client_id, client_secret: $client_secret}') + save_client "$client_json" } _load_client() { - local client_file="$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CLIENT_FILE" - if [[ ! -f "$client_file" ]]; then - return 1 - fi + # Use multi-origin aware load_client from config.sh + local client_data + client_data=$(load_client) - client_id=$(jq -r '.client_id' "$client_file") - client_secret=$(jq -r '.client_secret // ""' "$client_file") + 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" ]] diff --git a/cli/lib/config.sh b/cli/lib/config.sh index 3e752ec34a..af83959aeb 100644 --- a/cli/lib/config.sh +++ b/cli/lib/config.sh @@ -163,6 +163,18 @@ unset_config() { } +# Multi-Origin Helpers +# +# Credentials, client registration, and accounts are all keyed by base URL +# to support multiple Fizzy instances (self-hosted, production, local dev). + +_normalize_base_url() { + # Remove trailing slash for consistent keys + local url="${1:-$FIZZY_BASE_URL}" + echo "${url%/}" +} + + # Credentials get_credentials_path() { @@ -172,11 +184,16 @@ get_credentials_path() { load_credentials() { local file file=$(get_credentials_path) - if [[ -f "$file" ]]; then - cat "$file" - else + local base_url + base_url=$(_normalize_base_url) + + if [[ ! -f "$file" ]]; then echo '{}' + return fi + + # Return credentials for current base URL + jq -r --arg url "$base_url" '.[$url] // {}' "$file" } save_credentials() { @@ -184,7 +201,36 @@ save_credentials() { ensure_global_config_dir local file file=$(get_credentials_path) - echo "$json" > "$file" + local base_url + base_url=$(_normalize_base_url) + + # Load existing multi-origin credentials + local existing='{}' + if [[ -f "$file" ]]; then + existing=$(cat "$file") + fi + + # Update credentials for current base URL + local updated + updated=$(echo "$existing" | jq --arg url "$base_url" --argjson creds "$json" '.[$url] = $creds') + echo "$updated" > "$file" + chmod 600 "$file" +} + +clear_credentials() { + local file + file=$(get_credentials_path) + local base_url + base_url=$(_normalize_base_url) + + if [[ ! -f "$file" ]]; then + return + fi + + # Remove credentials for current base URL only + local updated + updated=$(jq --arg url "$base_url" 'del(.[$url])' "$file") + echo "$updated" > "$file" chmod 600 "$file" } @@ -277,17 +323,35 @@ get_git_root() { load_accounts() { local file="$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_ACCOUNTS_FILE" - if [[ -f "$file" ]]; then - cat "$file" - else + local base_url + base_url=$(_normalize_base_url) + + if [[ ! -f "$file" ]]; then echo '[]' + return fi + + # Return accounts for current base URL + jq -r --arg url "$base_url" '.[$url] // []' "$file" } save_accounts() { local json="$1" ensure_global_config_dir - echo "$json" > "$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_ACCOUNTS_FILE" + local file="$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_ACCOUNTS_FILE" + local base_url + base_url=$(_normalize_base_url) + + # Load existing multi-origin accounts + local existing='{}' + if [[ -f "$file" ]]; then + existing=$(cat "$file") + fi + + # Update accounts for current base URL + local updated + updated=$(echo "$existing" | jq --arg url "$base_url" --argjson accts "$json" '.[$url] = $accts') + echo "$updated" > "$file" } @@ -300,11 +364,16 @@ get_client_path() { load_client() { local file file=$(get_client_path) - if [[ -f "$file" ]]; then - cat "$file" - else + local base_url + base_url=$(_normalize_base_url) + + if [[ ! -f "$file" ]]; then echo '{}' + return fi + + # Return client for current base URL + jq -r --arg url "$base_url" '.[$url] // {}' "$file" } save_client() { @@ -312,7 +381,36 @@ save_client() { ensure_global_config_dir local file file=$(get_client_path) - echo "$json" > "$file" + local base_url + base_url=$(_normalize_base_url) + + # Load existing multi-origin clients + local existing='{}' + if [[ -f "$file" ]]; then + existing=$(cat "$file") + fi + + # Update client for current base URL + local updated + updated=$(echo "$existing" | jq --arg url "$base_url" --argjson client "$json" '.[$url] = $client') + echo "$updated" > "$file" + chmod 600 "$file" +} + +clear_client() { + local file + file=$(get_client_path) + local base_url + base_url=$(_normalize_base_url) + + if [[ ! -f "$file" ]]; then + return + fi + + # Remove client for current base URL only + local updated + updated=$(jq --arg url "$base_url" 'del(.[$url])' "$file") + echo "$updated" > "$file" chmod 600 "$file" } diff --git a/cli/test/auth.bats b/cli/test/auth.bats index ac42ee2112..1c1e83d128 100644 --- a/cli/test/auth.bats +++ b/cli/test/auth.bats @@ -58,14 +58,19 @@ load test_helper # Auth logout -@test "auth logout removes credentials file" { +@test "auth logout removes credentials for current origin" { create_credentials "test-token" run fizzy auth logout assert_success assert_output_contains "Logged out" - [[ ! -f "$TEST_HOME/.config/fizzy/credentials.json" ]] + # File still exists but credentials for current origin are gone + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + base_url="${base_url%/}" + local creds + creds=$(jq -r --arg url "$base_url" '.[$url] // empty' "$TEST_HOME/.config/fizzy/credentials.json") + [[ -z "$creds" || "$creds" == "{}" ]] } @test "auth logout when not logged in" { @@ -172,10 +177,14 @@ load test_helper @test "account name shown in status with single account" { create_credentials "test-token" "$(($(date +%s) + 3600))" create_global_config '{"account_slug": "99999999"}' - cat > "$TEST_HOME/.config/fizzy/accounts.json" << 'EOF' -[ - {"id": "test-id", "name": "Test Account", "slug": "/99999999"} -] + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + base_url="${base_url%/}" + cat > "$TEST_HOME/.config/fizzy/accounts.json" << EOF +{ + "$base_url": [ + {"id": "test-id", "name": "Test Account", "slug": "/99999999"} + ] +} EOF run fizzy --md auth status @@ -235,12 +244,16 @@ EOF @test "auth status treats expires_at 0 as long-lived" { # Create credentials with expires_at: 0 (edge case) + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + base_url="${base_url%/}" cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF { - "access_token": "test-token", - "refresh_token": "", - "scope": "write", - "expires_at": 0 + "$base_url": { + "access_token": "test-token", + "refresh_token": "", + "scope": "write", + "expires_at": 0 + } } EOF chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" @@ -253,12 +266,16 @@ EOF @test "auth refresh treats expires_at 0 as long-lived" { # Create credentials with expires_at: 0 (edge case) + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + base_url="${base_url%/}" cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF { - "access_token": "test-token", - "refresh_token": "", - "scope": "write", - "expires_at": 0 + "$base_url": { + "access_token": "test-token", + "refresh_token": "", + "scope": "write", + "expires_at": 0 + } } EOF chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" @@ -271,12 +288,16 @@ EOF } @test "is_token_expired returns false for expires_at 0" { + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + base_url="${base_url%/}" cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF { - "access_token": "test-token", - "refresh_token": "", - "scope": "write", - "expires_at": 0 + "$base_url": { + "access_token": "test-token", + "refresh_token": "", + "scope": "write", + "expires_at": 0 + } } EOF chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" @@ -289,12 +310,16 @@ EOF @test "auth refresh without refresh_token but with expiry prompts re-login" { # Token has expiry but no refresh token - should prompt re-login + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + base_url="${base_url%/}" cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF { - "access_token": "test-token", - "refresh_token": "", - "scope": "write", - "expires_at": $(($(date +%s) + 3600)) + "$base_url": { + "access_token": "test-token", + "refresh_token": "", + "scope": "write", + "expires_at": $(($(date +%s) + 3600)) + } } EOF chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" @@ -310,12 +335,16 @@ EOF # rather than hardcoding the endpoint path # Create credentials with a refresh token + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + base_url="${base_url%/}" cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF { - "access_token": "old-token", - "refresh_token": "test-refresh-token", - "scope": "write", - "expires_at": $(($(date +%s) - 100)) + "$base_url": { + "access_token": "old-token", + "refresh_token": "test-refresh-token", + "scope": "write", + "expires_at": $(($(date +%s) - 100)) + } } EOF chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" diff --git a/cli/test/test_helper.bash b/cli/test/test_helper.bash index 7d15b81509..061105d3e6 100644 --- a/cli/test/test_helper.bash +++ b/cli/test/test_helper.bash @@ -143,6 +143,9 @@ create_credentials() { local access_token="${1:-test-token}" local expires_at="${2:-$(($(date +%s) + 3600))}" local scope="${3:-}" + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + # Remove trailing slash for consistent keys + base_url="${base_url%/}" local scope_field="" if [[ -n "$scope" ]]; then @@ -151,10 +154,12 @@ create_credentials() { cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF { - "access_token": "$access_token", - "refresh_token": "test-refresh-token", - $scope_field - "expires_at": $expires_at + "$base_url": { + "access_token": "$access_token", + "refresh_token": "test-refresh-token", + $scope_field + "expires_at": $expires_at + } } EOF chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" @@ -164,31 +169,48 @@ EOF create_long_lived_credentials() { local access_token="${1:-test-token}" local scope="${2:-write}" + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + # Remove trailing slash for consistent keys + base_url="${base_url%/}" cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF { - "access_token": "$access_token", - "refresh_token": "", - "scope": "$scope", - "expires_at": null + "$base_url": { + "access_token": "$access_token", + "refresh_token": "", + "scope": "$scope", + "expires_at": null + } } EOF chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" } create_accounts() { - cat > "$TEST_HOME/.config/fizzy/accounts.json" << 'EOF' -[ - {"id": "test-account-id", "name": "Test Account", "slug": "/99999999"} -] + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + # Remove trailing slash for consistent keys + base_url="${base_url%/}" + + cat > "$TEST_HOME/.config/fizzy/accounts.json" << EOF +{ + "$base_url": [ + {"id": "test-account-id", "name": "Test Account", "slug": "/99999999"} + ] +} EOF } create_client() { - cat > "$TEST_HOME/.config/fizzy/client.json" << 'EOF' + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + # Remove trailing slash for consistent keys + base_url="${base_url%/}" + + cat > "$TEST_HOME/.config/fizzy/client.json" << EOF { - "client_id": "test-client-id", - "client_secret": "" + "$base_url": { + "client_id": "test-client-id", + "client_secret": "" + } } EOF chmod 600 "$TEST_HOME/.config/fizzy/client.json" From 78d1751bbd70c1386e98c7cc377862fef9585061 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 10:54:20 -0800 Subject: [PATCH 13/32] CSP validation for OAuth redirect_uri --- .../oauth/authorizations_controller.rb | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index c0bd216be1..3ddc00b43a 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -1,13 +1,17 @@ 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? - 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 will be caught by validate_redirect_uri + 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 From 9336d76a10ae25ef9d2e85e3e85370320346594d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 10:54:25 -0800 Subject: [PATCH 14/32] Fix API response handling for RESTful patterns --- cli/lib/api.sh | 17 ++++++ cli/lib/commands/actions.sh | 116 +++++++++++++++++++++++------------- cli/test/actions.bats | 6 +- cli/test/names.bats | 3 +- 4 files changed, 97 insertions(+), 45 deletions(-) diff --git a/cli/lib/api.sh b/cli/lib/api.sh index 66736beb18..6eba8f23de 100644 --- a/cli/lib/api.sh +++ b/cli/lib/api.sh @@ -321,6 +321,23 @@ _api_request() { 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 ;; diff --git a/cli/lib/commands/actions.sh b/cli/lib/commands/actions.sh index 22bf01aa05..733b50877d 100644 --- a/cli/lib/commands/actions.sh +++ b/cli/lib/commands/actions.sh @@ -276,8 +276,10 @@ cmd_close() { 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_post "/cards/$num/closure") + response=$(api_get "/cards/$num") results+=("$(echo "$response" | jq '{number: .number, title: .title, closed: .closed}')") done @@ -392,8 +394,10 @@ cmd_reopen() { 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_delete "/cards/$num/closure") + response=$(api_get "/cards/$num") results+=("$(echo "$response" | jq '{number: .number, title: .title, closed: .closed}')") done @@ -524,7 +528,7 @@ cmd_triage() { fi # Resolve column name to ID if needed - # First, we need the board_id to resolve column within board context + # 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 @@ -534,6 +538,14 @@ cmd_triage() { 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 @@ -545,13 +557,21 @@ cmd_triage() { 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_post "/cards/$card_number/triage" "$body") + response=$(api_get "/cards/$card_number") local column_name column_name=$(echo "$response" | jq -r '.column.name // "column"') @@ -658,8 +678,10 @@ cmd_untriage() { 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_delete "/cards/$card_number/triage") + response=$(api_get "/cards/$card_number") local summary="Card #$card_number sent back to triage" @@ -755,8 +777,10 @@ cmd_postpone() { 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_post "/cards/$card_number/not_now") + response=$(api_get "/cards/$card_number") local summary="Card #$card_number moved to Not Now" @@ -865,7 +889,7 @@ cmd_comment_create() { fi local body - body=$(jq -n --arg content "$content" '{content: $content}') + body=$(jq -n --arg body "$content" '{comment: {body: $body}}') local response response=$(api_post "/cards/$card_number/comments" "$body") @@ -993,10 +1017,12 @@ cmd_assign() { fi local body - body=$(jq -n --arg user_id "$user_id" '{user_id: $user_id}') + 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_post "/cards/$card_number/assignments" "$body") + response=$(api_get "/cards/$card_number") local summary="Assignment toggled on card #$card_number" @@ -1067,16 +1093,16 @@ EOF cmd_tag() { local card_number="" - local tag_id="" + local tag_name="" local show_help=false while [[ $# -gt 0 ]]; do case "$1" in --with) if [[ -z "${2:-}" ]]; then - die "--with requires a tag ID" $EXIT_USAGE + die "--with requires a tag name" $EXIT_USAGE fi - tag_id="$2" + tag_name="$2" shift 2 ;; --help|-h) @@ -1104,23 +1130,18 @@ cmd_tag() { die "Card number required" $EXIT_USAGE "Usage: fizzy tag --with " fi - if [[ -z "$tag_id" ]]; then - die "--with tag ID required" $EXIT_USAGE "Usage: fizzy tag --with " - fi - - # Resolve tag name to ID - local resolved_tag - if resolved_tag=$(resolve_tag_id "$tag_id"); then - tag_id="$resolved_tag" - else - die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy tags" + if [[ -z "$tag_name" ]]; then + die "--with tag name required" $EXIT_USAGE "Usage: fizzy tag --with " fi + # API expects tag_title (the tag name), not tag_id local body - body=$(jq -n --arg tag_id "$tag_id" '{tag_id: $tag_id}') + 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_post "/cards/$card_number/taggings" "$body") + response=$(api_get "/cards/$card_number") local summary="Tag toggled on card #$card_number" @@ -1160,9 +1181,9 @@ _tag_help() { jq -n '{ command: "fizzy tag", description: "Toggle tag on a card", - usage: "fizzy tag --with ", - options: [{flag: "--with", description: "Tag name or ID to toggle"}], - examples: ["fizzy tag 123 --with \"bug\"", "fizzy tag 123 --with abc456"] + 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' @@ -1172,17 +1193,17 @@ Toggle tag on a card (adds if not tagged, removes if tagged). ### Usage - fizzy tag --with + fizzy tag --with ### Options - --with Tag name or ID to toggle (required) + --with Tag name to toggle (required) --help, -h Show this help ### Examples - fizzy tag 123 --with "bug" Toggle tag by name - fizzy tag 123 --with abc456 Toggle tag by ID + fizzy tag 123 --with "bug" Toggle "bug" tag + fizzy tag 123 --with "feature" Toggle "feature" tag EOF fi } @@ -1222,8 +1243,10 @@ cmd_watch() { 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_post "/cards/$card_number/watch") + response=$(api_get "/cards/$card_number") local summary="Subscribed to card #$card_number" @@ -1317,8 +1340,10 @@ cmd_unwatch() { 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_delete "/cards/$card_number/watch") + response=$(api_get "/cards/$card_number") local summary="Unsubscribed from card #$card_number" @@ -1412,8 +1437,10 @@ cmd_gild() { 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_post "/cards/$card_number/goldness") + response=$(api_get "/cards/$card_number") local summary="Card #$card_number marked as golden" @@ -1507,8 +1534,10 @@ cmd_ungild() { 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_delete "/cards/$card_number/goldness") + response=$(api_get "/cards/$card_number") local summary="Golden status removed from card #$card_number" @@ -1744,10 +1773,13 @@ cmd_react() { fi local body - body=$(jq -n --arg emoji "$emoji" '{emoji: $emoji}') + 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=$(api_post "/cards/$card_number/comments/$comment_id/reactions" "$body") + 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" @@ -1765,14 +1797,16 @@ _react_md() { local summary="$2" local breadcrumbs="$3" - local reaction_id emoji - reaction_id=$(echo "$data" | jq -r '.id') + 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 "ID" "$reaction_id" \ - "Emoji" "$emoji" + md_kv "Emoji" "$emoji" \ + "Card" "#$card_number" \ + "Comment" "$comment_id" md_breadcrumbs "$breadcrumbs" } diff --git a/cli/test/actions.bats b/cli/test/actions.bats index a0e3f7c00b..1d7f3d5c27 100644 --- a/cli/test/actions.bats +++ b/cli/test/actions.bats @@ -176,10 +176,10 @@ load test_helper assert_output_contains "column ID required" } -@test "triage requires authentication" { +@test "triage without board context shows resolution error" { run fizzy triage 123 --to col456 assert_failure - assert_output_contains "Not authenticated" + assert_output_contains "Cannot resolve column name without board context" } @@ -384,7 +384,7 @@ load test_helper @test "tag without --with shows error" { run fizzy tag 123 assert_failure - assert_output_contains "tag ID required" + assert_output_contains "tag name required" } @test "tag requires authentication" { diff --git a/cli/test/names.bats b/cli/test/names.bats index e99b7eb5f6..8a9ea3de28 100644 --- a/cli/test/names.bats +++ b/cli/test/names.bats @@ -96,7 +96,8 @@ load test_helper @test "tag --help mentions name support" { run fizzy --md tag --help assert_success - assert_output_contains "name or ID" + # Tag only accepts names (API requires tag_title, not tag_id) + assert_output_contains "Tag name" } From 7770e227d808c6ce6dda593c1098c19551431e1f Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 10:28:37 -0800 Subject: [PATCH 15/32] Pagination support for cards command --- cli/completions/_fizzy | 1 + cli/completions/fizzy.bash | 2 +- cli/lib/commands/cards.sh | 34 ++++++++++++++++++++++++++++++++-- cli/test/cards.bats | 18 ++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/cli/completions/_fizzy b/cli/completions/_fizzy index a13178e4c9..a270cd1ac2 100644 --- a/cli/completions/_fizzy +++ b/cli/completions/_fizzy @@ -59,6 +59,7 @@ _fizzy() { '(-v --verbose)'{-v,--verbose}'[Debug output]' \ '(-b --board --in)'{-b,--board,--in}'[Board ID or name]:board:' \ '(-a --account)'{-a,--account}'[Account slug]:account:' \ + '(-p --page)'{-p,--page}'[Page number]:page:' \ '(-h --help)'{-h,--help}'[Show help]' \ '1: :->command' \ '*::arg:->args' diff --git a/cli/completions/fizzy.bash b/cli/completions/fizzy.bash index 95f29cbd3e..be4725aeda 100644 --- a/cli/completions/fizzy.bash +++ b/cli/completions/fizzy.bash @@ -47,7 +47,7 @@ _fizzy_completions() { # Handle flags if [[ "$cur" == -* ]]; then - local flags="--json -j --md -m --quiet -q --data --verbose -v --board -b --in --account -a --help -h" + local flags="--json -j --md -m --quiet -q --data --verbose -v --board -b --in --account -a --page -p --help -h" COMPREPLY=($(compgen -W "$flags" -- "$cur")) return fi diff --git a/cli/lib/commands/cards.sh b/cli/lib/commands/cards.sh index 982382cd13..3f06e42d95 100644 --- a/cli/lib/commands/cards.sh +++ b/cli/lib/commands/cards.sh @@ -12,6 +12,7 @@ cmd_cards() { local status="" local search_terms="" local sort_by="latest" + local page="" local show_help=false while [[ $# -gt 0 ]]; do @@ -58,6 +59,16 @@ cmd_cards() { sort_by="$2" shift 2 ;; + --page|-p) + if [[ -z "${2:-}" ]]; then + die "--page requires a value" $EXIT_USAGE + fi + if ! [[ "$2" =~ ^[1-9][0-9]*$ ]]; then + die "--page must be a positive integer" $EXIT_USAGE + fi + page="$2" + shift 2 + ;; --help|-h) show_help=true shift @@ -133,6 +144,10 @@ cmd_cards() { params+=("sorted_by=$sort_by") fi + if [[ -n "$page" ]]; then + params+=("page=$page") + fi + # Build query string if [[ ${#params[@]} -gt 0 ]]; then local query_string @@ -147,11 +162,24 @@ cmd_cards() { count=$(echo "$response" | jq 'length') local summary="$count cards" + [[ -n "$page" ]] && summary="$count cards (page $page)" + + # Build next page command for breadcrumbs (preserve all filters) + local next_page_cmd="fizzy cards" + local next_page=$((${page:-1} + 1)) + [[ -n "$board_id" ]] && next_page_cmd="$next_page_cmd --board $board_id" + [[ -n "$tag_id" ]] && next_page_cmd="$next_page_cmd --tag $tag_id" + [[ -n "$assignee_id" ]] && next_page_cmd="$next_page_cmd --assignee $assignee_id" + [[ -n "$status" ]] && next_page_cmd="$next_page_cmd --status $status" + [[ -n "$search_terms" ]] && next_page_cmd="$next_page_cmd --search \"$search_terms\"" + [[ "$sort_by" != "latest" ]] && next_page_cmd="$next_page_cmd --sort $sort_by" + next_page_cmd="$next_page_cmd --page $next_page" + local breadcrumbs breadcrumbs=$(breadcrumbs \ "$(breadcrumb "show" "fizzy show " "View card details")" \ "$(breadcrumb "create" "fizzy card \"title\"" "Create new card")" \ - "$(breadcrumb "filter" "fizzy cards --status closed" "Filter by status")" \ + "$(breadcrumb "next" "$next_page_cmd" "Next page")" \ "$(breadcrumb "search" "fizzy search \"query\"" "Search cards")" ) @@ -195,7 +223,8 @@ _cards_help() { {flag: "--assignee", description: "Filter by assignee name, email, or ID"}, {flag: "--status", description: "Filter by status: all, closed, not_now, stalled, golden"}, {flag: "--search, -s", description: "Search terms"}, - {flag: "--sort", description: "Sort order: latest, newest, oldest"} + {flag: "--sort", description: "Sort order: latest, newest, oldest"}, + {flag: "--page, -p", description: "Page number for pagination"} ], examples: [ "fizzy cards", @@ -223,6 +252,7 @@ List and filter cards. --status Filter: all, closed, not_now, stalled, golden --search, -s Search terms --sort Sort: latest (default), newest, oldest + --page, -p Page number for pagination --help, -h Show this help ### Examples diff --git a/cli/test/cards.bats b/cli/test/cards.bats index 074dae1297..41ce1cfc36 100644 --- a/cli/test/cards.bats +++ b/cli/test/cards.bats @@ -45,6 +45,24 @@ load test_helper assert_output_contains "Unknown option" } +@test "cards --page rejects non-numeric value" { + run fizzy cards --page abc + assert_failure + assert_output_contains "positive integer" +} + +@test "cards --page rejects zero" { + run fizzy cards --page 0 + assert_failure + assert_output_contains "positive integer" +} + +@test "cards --page rejects negative" { + run fizzy cards --page -1 + assert_failure + assert_output_contains "positive integer" +} + # show --help From 8dbd1044fa7e2fc1c8cc2e8eed70a942335d3558 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 12:53:56 -0800 Subject: [PATCH 16/32] Fix installer symlink path The symlink was pointing to $INSTALL_DIR/fizzy but the binary is at $INSTALL_DIR/bin/fizzy after extraction. --- cli/install.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/install.sh b/cli/install.sh index 86646953c6..7656f84ff7 100755 --- a/cli/install.sh +++ b/cli/install.sh @@ -49,10 +49,10 @@ install_fizzy() { cp -r "$tmp"/* "$INSTALL_DIR/" # Make executable - chmod +x "$INSTALL_DIR/fizzy" + chmod +x "$INSTALL_DIR/bin/fizzy" # Create symlink in bin - ln -sf "$INSTALL_DIR/fizzy" "$BIN_DIR/fizzy" + ln -sf "$INSTALL_DIR/bin/fizzy" "$BIN_DIR/fizzy" info "Installed fizzy to $BIN_DIR/fizzy" } From f52d363913746b5d41d179c7a714e56d86489c49 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 12:54:13 -0800 Subject: [PATCH 17/32] Fix name resolution: tag .title field, pagination, regex safety - Tag API returns .title not .name - fix resolve_tag_id matching - Strip leading # from tag input (users type #bug or bug) - Use api_get_all for boards/users/tags to fetch all pages - Use grep -iF in _suggest_similar to avoid regex errors on special characters like [ ] * Adds contract tests for tag resolution and regex handling. --- cli/lib/names.sh | 40 +++++++++++++++++++----------------- cli/test/names.bats | 49 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 18 deletions(-) diff --git a/cli/lib/names.sh b/cli/lib/names.sh index 192def09b9..e1211f8cf4 100644 --- a/cli/lib/names.sh +++ b/cli/lib/names.sh @@ -58,12 +58,12 @@ resolve_board_id() { return 0 fi - # Fetch boards (with cache) + # Fetch boards (with cache) - use api_get_all to handle pagination local boards boards=$(_get_cache "boards") if [[ -z "$boards" ]]; then # Don't suppress stderr - let auth errors propagate and exit - boards=$(api_get "/boards") || return 1 + boards=$(api_get_all "/boards") || return 1 _set_cache "boards" "$boards" fi @@ -144,12 +144,12 @@ resolve_user_id() { return 0 fi - # Fetch users (with cache) + # Fetch users (with cache) - use api_get_all to handle pagination local users users=$(_get_cache "users") if [[ -z "$users" ]]; then # Don't suppress stderr - let auth errors propagate and exit - users=$(api_get "/users") || return 1 + users=$(api_get_all "/users") || return 1 _set_cache "users" "$users" fi @@ -322,19 +322,22 @@ resolve_tag_id() { return 0 fi - # Fetch tags (with cache) + # Fetch tags (with cache) - use api_get_all to handle pagination local tags tags=$(_get_cache "tags") if [[ -z "$tags" ]]; then # Don't suppress stderr - let auth errors propagate and exit - tags=$(api_get "/tags") || return 1 + tags=$(api_get_all "/tags") || return 1 _set_cache "tags" "$tags" fi - # Try exact match first + # Strip leading # if present (users may type #bug or bug) + local search_term="${input#\#}" + + # Try exact match first (API returns .title, not .name) local exact_match - exact_match=$(echo "$tags" | jq -r --arg name "$input" \ - '.[] | select(.name == $name) | .id' | head -1) + exact_match=$(echo "$tags" | jq -r --arg name "$search_term" \ + '.[] | select(.title == $name) | .id' | head -1) if [[ -n "$exact_match" ]]; then echo "$exact_match" return 0 @@ -342,8 +345,8 @@ resolve_tag_id() { # Try case-insensitive match local ci_matches - ci_matches=$(echo "$tags" | jq -r --arg name "$input" \ - '.[] | select(.name | ascii_downcase == ($name | ascii_downcase)) | .id') + ci_matches=$(echo "$tags" | jq -r --arg name "$search_term" \ + '.[] | select(.title | ascii_downcase == ($name | ascii_downcase)) | .id') local ci_count ci_count=0 [[ -n "$ci_matches" ]] && ci_count=$(echo "$ci_matches" | grep -c . || true) @@ -354,8 +357,8 @@ resolve_tag_id() { # Try partial match (contains) local partial_matches - partial_matches=$(echo "$tags" | jq -r --arg name "$input" \ - '.[] | select(.name | ascii_downcase | contains($name | ascii_downcase)) | "\(.id):\(.name)"') + partial_matches=$(echo "$tags" | jq -r --arg name "$search_term" \ + '.[] | select(.title | ascii_downcase | contains($name | ascii_downcase)) | "\(.id):\(.title)"') local partial_count partial_count=0 [[ -n "$partial_matches" ]] && partial_count=$(echo "$partial_matches" | grep -c . || true) @@ -366,14 +369,14 @@ resolve_tag_id() { elif [[ "$partial_count" -gt 1 ]]; then local names names=$(echo "$partial_matches" | cut -d: -f2- | tr '\n' ',' | sed 's/,$//') - RESOLVE_ERROR="Ambiguous tag name '$input' matches: $names" + RESOLVE_ERROR="Ambiguous tag '$search_term' matches: $names" return 1 fi # Not found - provide suggestions - RESOLVE_ERROR="Tag not found: $input" + RESOLVE_ERROR="Tag not found: $search_term" local suggestions - suggestions=$(_suggest_similar "$input" "$tags" "name") + suggestions=$(_suggest_similar "$search_term" "$tags" "title") if [[ -n "$suggestions" ]]; then RESOLVE_ERROR="$RESOLVE_ERROR. Did you mean: $suggestions?" fi @@ -398,13 +401,14 @@ _suggest_similar() { names=$(echo "$json_array" | jq -r ".[].$field") # Find names that share a common prefix (first 3 chars) + # Use grep -iF for fixed-string matching to avoid regex errors from special chars local prefix="${input:0:3}" local suggestions - suggestions=$(echo "$names" | grep -i "^$prefix" | head -3 | tr '\n' ',' | sed 's/,$//') + suggestions=$(echo "$names" | grep -iF "$prefix" | head -3 | tr '\n' ',' | sed 's/,$//') # If no prefix matches, try substring matches if [[ -z "$suggestions" ]]; then - suggestions=$(echo "$names" | grep -i "$input" | head -3 | tr '\n' ',' | sed 's/,$//') + suggestions=$(echo "$names" | grep -iF "$input" | head -3 | tr '\n' ',' | sed 's/,$//') fi # If still nothing, return first few available options diff --git a/cli/test/names.bats b/cli/test/names.bats index 8a9ea3de28..31cfbfd1a2 100644 --- a/cli/test/names.bats +++ b/cli/test/names.bats @@ -101,6 +101,41 @@ load test_helper } +# Contract test: tag API uses .title field (not .name) + +@test "resolve_tag_id matches on .title field from API" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + # Set up cache with mock tag data matching real API structure + # IMPORTANT: API returns .title, not .name - this is a contract test + export _FIZZY_CACHE_DIR="$TEST_HOME/fizzy-cache-test" + rm -rf "$_FIZZY_CACHE_DIR" + _set_cache "tags" '[{"id":"tag123","title":"bug"},{"id":"tag456","title":"feature"}]' + + # Should resolve by title + result=$(resolve_tag_id "bug") + [[ "$result" == "tag123" ]] +} + +@test "resolve_tag_id strips leading # from tag name" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + export _FIZZY_CACHE_DIR="$TEST_HOME/fizzy-cache-test" + rm -rf "$_FIZZY_CACHE_DIR" + _set_cache "tags" '[{"id":"tag123","title":"bug"}]' + + # Should resolve #bug to bug + result=$(resolve_tag_id "#bug") + [[ "$result" == "tag123" ]] +} + + # Cache management @test "_ensure_cache_dir creates cache directory" { @@ -173,6 +208,20 @@ load test_helper [[ "$result" == *"Dev Ops"* ]] } +@test "_suggest_similar handles regex special characters" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + # Input with regex special chars like [ ] * . should not crash + local json='[{"name": "foo"}, {"name": "bar"}, {"name": "baz"}]' + # This would fail with regex grep: grep -i "^foo[" is invalid + result=$(_suggest_similar "foo[" "$json" "name") + # Should return something (first few names) without erroring + [[ -n "$result" || -z "$result" ]] # Either result, no crash +} + # Error message formatting From 1eed7b4791ef2a3e08f9b7dac8f1aedc9d05e9e1 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 12:54:23 -0800 Subject: [PATCH 18/32] Fix card create to chain follow-up actions The API only accepts title/description on POST /cards. Flags like --column, --tag, and --assign were being silently ignored. Now chains follow-up API calls after card creation: - POST /cards/{n}/triage for --column - POST /cards/{n}/taggings for --tag - POST /cards/{n}/assignments for --assign Also strips leading # from tag names in fizzy tag --with. --- cli/lib/commands/actions.sh | 116 ++++++++++++++++++++---------------- 1 file changed, 66 insertions(+), 50 deletions(-) diff --git a/cli/lib/commands/actions.sh b/cli/lib/commands/actions.sh index 733b50877d..511eda4ad6 100644 --- a/cli/lib/commands/actions.sh +++ b/cli/lib/commands/actions.sh @@ -10,7 +10,7 @@ cmd_card_create() { local description="" local board_id="" local column_id="" - local tag_ids=() + local tag_names=() local assignee_ids=() local show_help=false @@ -18,14 +18,14 @@ cmd_card_create() { case "$1" in --board|-b|--in) if [[ -z "${2:-}" ]]; then - die "--board requires a board ID" $EXIT_USAGE + 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 ID" $EXIT_USAGE + die "--column requires a column name or ID" $EXIT_USAGE fi column_id="$2" shift 2 @@ -39,14 +39,15 @@ cmd_card_create() { ;; --tag) if [[ -z "${2:-}" ]]; then - die "--tag requires a tag ID" $EXIT_USAGE + die "--tag requires a tag name" $EXIT_USAGE fi - tag_ids+=("$2") + # 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 ID" $EXIT_USAGE + die "--assign requires a user name or ID" $EXIT_USAGE fi assignee_ids+=("$2") shift 2 @@ -96,7 +97,7 @@ cmd_card_create() { die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy boards" fi - # Resolve column name to ID if provided + # 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 @@ -106,19 +107,7 @@ cmd_card_create() { fi fi - # Resolve tag names to IDs - local resolved_tag_ids=() - for tag in "${tag_ids[@]}"; do - local resolved_tag - if resolved_tag=$(resolve_tag_id "$tag"); then - resolved_tag_ids+=("$resolved_tag") - else - die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy tags" - fi - done - tag_ids=("${resolved_tag_ids[@]}") - - # Resolve assignee names to IDs + # Resolve assignee names to IDs (for assignment follow-up) local resolved_assignee_ids=() for assignee in "${assignee_ids[@]}"; do local resolved_user @@ -130,36 +119,56 @@ cmd_card_create() { done assignee_ids=("${resolved_assignee_ids[@]}") - # Build request body + # Build request body (API only accepts title, description) local body body=$(jq -n \ --arg title "$title" \ --arg description "${description:-}" \ - --arg column_id "${column_id:-}" \ '{title: $title} + - (if $description != "" then {description: $description} else {} end) + - (if $column_id != "" then {column_id: $column_id} else {} end)') - - # Add tag_ids array if provided - if [[ ${#tag_ids[@]} -gt 0 ]]; then - local tags_json - tags_json=$(printf '%s\n' "${tag_ids[@]}" | jq -R . | jq -s '.') - body=$(echo "$body" | jq --argjson tag_ids "$tags_json" '. + {tag_ids: $tag_ids}') - fi - - # Add assignee_ids array if provided - if [[ ${#assignee_ids[@]} -gt 0 ]]; then - local assignees_json - assignees_json=$(printf '%s\n' "${assignee_ids[@]}" | jq -R . | jq -s '.') - body=$(echo "$body" | jq --argjson assignee_ids "$assignees_json" '. + {assignee_ids: $assignee_ids}') - fi + (if $description != "" then {description: $description} else {} end)') local response response=$(api_post "/boards/$board_id/cards" "$body") 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 \ @@ -197,26 +206,27 @@ _card_create_help() { if [[ "$format" == "json" ]]; then jq -n '{ command: "fizzy card", - description: "Create a new 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 to triage directly"}, + {flag: "--column, -c", description: "Column name or ID (chains triage after create)"}, {flag: "--description, -d", description: "Card description"}, - {flag: "--tag", description: "Tag name or ID (can be repeated)"}, - {flag: "--assign", description: "Assignee name, email, or ID (can be repeated)"} + {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\"" + "fizzy card \"Task\" --tag bug --assign \"Jane Doe\"" ] }' else cat <<'EOF' ## fizzy card -Create a new card. +Create a new card with optional follow-up actions. ### Usage @@ -225,17 +235,20 @@ Create a new card. ### Options --board, -b, --in Board name or ID (required if not in config) - --column, -c Column name or ID to triage directly - --description, -d Card description - --tag Tag name or ID (can be repeated) - --assign Assignee name, email, or ID (can be repeated) - --help, -h Show this help + --column, -c Column name or ID (chains triage after create) + --description, -d Card description + --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" --assign "Jane Doe" + fizzy card "Task" --tag bug --tag urgent --assign "Jane Doe" EOF fi } @@ -1134,6 +1147,9 @@ cmd_tag() { 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}') From 9f350dca1e3bdc6c138edc415ab2b1772905cd15 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 12:54:32 -0800 Subject: [PATCH 19/32] Store account_slug per-origin in credentials Multi-origin setups (dev + prod) were sharing a global account_slug, causing mismatches when switching between instances. Now stores account_slug alongside access_token in per-origin credentials file, with fallback to global config for compatibility. --- cli/lib/auth.sh | 17 +++++++++++------ cli/lib/config.sh | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/cli/lib/auth.sh b/cli/lib/auth.sh index 103d2a7199..416cdae5c0 100644 --- a/cli/lib/auth.sh +++ b/cli/lib/auth.sh @@ -641,12 +641,13 @@ _select_account() { return fi + local account_slug="" + if [[ "$count" -eq 1 ]]; then - local account_slug account_name + 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') - set_global_config "account_slug" "$account_slug" info "Selected account: $account_name ($account_slug)" elif [[ "$count" -gt 1 ]]; then # Multiple accounts - let user choose @@ -659,19 +660,23 @@ _select_account() { read -rp "Select account (1-$count): " choice if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= count )); then - local account_slug account_name + local account_name account_slug=$(echo "$accounts" | jq -r ".[$((choice - 1))].slug | ltrimstr(\"/\")") account_name=$(echo "$accounts" | jq -r ".[$((choice - 1))].name") - set_global_config "account_slug" "$account_slug" info "Selected account: $account_name ($account_slug)" else warn "Invalid choice, using first account" - local account_slug account_slug=$(echo "$accounts" | jq -r '.[0].slug | ltrimstr("/")') - set_global_config "account_slug" "$account_slug" 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" diff --git a/cli/lib/config.sh b/cli/lib/config.sh index af83959aeb..9037f66d23 100644 --- a/cli/lib/config.sh +++ b/cli/lib/config.sh @@ -234,6 +234,19 @@ clear_credentials() { chmod 600 "$file" } +# Add account_slug to per-origin credentials +# This ensures switching FIZZY_BASE_URL picks up the right account +set_credential_account_slug() { + local account_slug="$1" + local creds + creds=$(load_credentials) + + # Add account_slug to existing credentials + local updated + updated=$(echo "$creds" | jq --arg slug "$account_slug" '. + {account_slug: $slug}') + save_credentials "$updated" +} + get_access_token() { if [[ -n "${FIZZY_ACCESS_TOKEN:-}" ]]; then echo "$FIZZY_ACCESS_TOKEN" @@ -286,6 +299,7 @@ get_token_scope() { # Account Management get_account_slug() { + # Priority: env var → per-origin credentials → global config if [[ -n "${FIZZY_ACCOUNT:-}" ]]; then echo "$FIZZY_ACCOUNT" return @@ -294,6 +308,18 @@ get_account_slug() { echo "$FIZZY_ACCOUNT_SLUG" return fi + + # Check per-origin credentials (stored alongside access_token) + local creds + creds=$(load_credentials) + local origin_account + origin_account=$(echo "$creds" | jq -r '.account_slug // empty') + if [[ -n "$origin_account" ]]; then + echo "$origin_account" + return + fi + + # Fall back to global config get_config "account_slug" } From 76e67ac6ce2bf5c1f665c6389dbed278f5aa86ba Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 12:54:40 -0800 Subject: [PATCH 20/32] Add --page flag to query commands - boards, tags, people, notifications: add --page N pagination - search: add board name resolution via resolve_board_id - Consistent validation (positive integer required) - Breadcrumbs show next page command Adds pagination tests for all query commands. --- cli/lib/commands/boards.sh | 33 +++++++-- cli/lib/commands/notifications.sh | 32 +++++++- cli/lib/commands/people.sh | 34 +++++++-- cli/lib/commands/search.sh | 7 ++ cli/lib/commands/tags.sh | 34 +++++++-- cli/test/boards.bats | 21 ++++++ cli/test/queries.bats | 118 ++++++++++++++++++++++++++++++ 7 files changed, 259 insertions(+), 20 deletions(-) create mode 100644 cli/test/queries.bats diff --git a/cli/lib/commands/boards.sh b/cli/lib/commands/boards.sh index e401b04e90..54122d3b0b 100644 --- a/cli/lib/commands/boards.sh +++ b/cli/lib/commands/boards.sh @@ -7,9 +7,20 @@ cmd_boards() { local show_help=false + local page="" while [[ $# -gt 0 ]]; do case "$1" in + --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 @@ -25,19 +36,27 @@ cmd_boards() { return 0 fi + local path="/boards" + if [[ -n "$page" ]]; then + path="$path?page=$page" + fi + local response - response=$(api_get "/boards") + response=$(api_get "$path") local count count=$(echo "$response" | jq 'length') local summary="$count boards" + [[ -n "$page" ]] && summary="$count boards (page $page)" + + local next_page=$((${page:-1} + 1)) local breadcrumbs 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 "create" "fizzy config set board_id " "Set default board")" + "$(breadcrumb "next" "fizzy boards --page $next_page" "Next page")" ) output "$response" "$summary" "$breadcrumbs" "_boards_md" @@ -74,10 +93,12 @@ _boards_help() { jq -n '{ command: "fizzy boards", description: "List boards in the account", - options: [], + options: [ + {flag: "--page, -p", description: "Page number for pagination"} + ], examples: [ "fizzy boards", - "fizzy boards --json" + "fizzy boards --page 2" ] }' else @@ -92,13 +113,13 @@ List boards in the account. ### Options + --page, -p Page number for pagination --help, -h Show this help ### Examples fizzy boards List all boards - fizzy boards --json Output as JSON - fizzy boards -q Raw data only + fizzy boards --page 2 Get second page EOF fi } diff --git a/cli/lib/commands/notifications.sh b/cli/lib/commands/notifications.sh index e109ba85b6..1bfc22fe3a 100644 --- a/cli/lib/commands/notifications.sh +++ b/cli/lib/commands/notifications.sh @@ -8,6 +8,7 @@ cmd_notifications() { local action="" local show_help=false + local page="" while [[ $# -gt 0 ]]; do case "$1" in @@ -17,6 +18,16 @@ cmd_notifications() { _notifications_action "$action" "$@" return ;; + --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 @@ -32,19 +43,28 @@ cmd_notifications() { return 0 fi + local path="/notifications" + if [[ -n "$page" ]]; then + path="$path?page=$page" + fi + local response - response=$(api_get "/notifications") + response=$(api_get "$path") local count unread_count count=$(echo "$response" | jq 'length') unread_count=$(echo "$response" | jq '[.[] | select(.read == false)] | length') local summary="$count notifications ($unread_count unread)" + [[ -n "$page" ]] && summary="$count notifications ($unread_count unread, page $page)" + + local next_page=$((${page:-1} + 1)) local breadcrumbs breadcrumbs=$(breadcrumbs \ "$(breadcrumb "read" "fizzy notifications read " "Mark as read")" \ "$(breadcrumb "read-all" "fizzy notifications read --all" "Mark all as read")" \ - "$(breadcrumb "show" "fizzy show " "View card")" + "$(breadcrumb "show" "fizzy show " "View card")" \ + "$(breadcrumb "next" "fizzy notifications --page $next_page" "Next page")" ) output "$response" "$summary" "$breadcrumbs" "_notifications_md" @@ -129,10 +149,12 @@ _notifications_help() { {name: "unread", description: "Mark notification as unread"} ], options: [ + {flag: "--page, -p", description: "Page number for pagination"}, {flag: "--all", description: "Mark all notifications as read (with read)"} ], examples: [ "fizzy notifications", + "fizzy notifications --page 2", "fizzy notifications read abc123", "fizzy notifications read --all" ] @@ -146,13 +168,19 @@ List and manage notifications. ### Usage fizzy notifications List notifications + fizzy notifications --page 2 Get second page fizzy notifications read Mark as read fizzy notifications read --all Mark all as read fizzy notifications unread Mark as unread +### Options + + --page, -p Page number for pagination + ### Examples fizzy notifications List all notifications + fizzy notifications --page 2 Get second page fizzy notifications read abc123 Mark notification as read fizzy notifications read --all Mark all as read EOF diff --git a/cli/lib/commands/people.sh b/cli/lib/commands/people.sh index 56824ccf36..064380c1bb 100644 --- a/cli/lib/commands/people.sh +++ b/cli/lib/commands/people.sh @@ -7,9 +7,20 @@ cmd_people() { local show_help=false + local page="" while [[ $# -gt 0 ]]; do case "$1" in + --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 @@ -25,17 +36,26 @@ cmd_people() { return 0 fi + local path="/users" + if [[ -n "$page" ]]; then + path="$path?page=$page" + fi + local response - response=$(api_get "/users") + response=$(api_get "$path") local count count=$(echo "$response" | jq 'length') local summary="$count users" + [[ -n "$page" ]] && summary="$count users (page $page)" + + local next_page=$((${page:-1} + 1)) local breadcrumbs breadcrumbs=$(breadcrumbs \ "$(breadcrumb "assign" "fizzy assign --to " "Assign user to card")" \ - "$(breadcrumb "cards" "fizzy cards --assignee " "Cards assigned to user")" + "$(breadcrumb "cards" "fizzy cards --assignee " "Cards assigned to user")" \ + "$(breadcrumb "next" "fizzy people --page $next_page" "Next page")" ) output "$response" "$summary" "$breadcrumbs" "_people_md" @@ -72,10 +92,12 @@ _people_help() { jq -n '{ command: "fizzy people", description: "List users in the account", - options: [], + options: [ + {flag: "--page, -p", description: "Page number for pagination"} + ], examples: [ "fizzy people", - "fizzy people --json" + "fizzy people --page 2" ] }' else @@ -90,13 +112,13 @@ List users in the account. ### Options + --page, -p Page number for pagination --help, -h Show this help ### Examples fizzy people List all users - fizzy people --json Output as JSON - fizzy people -q Raw data only + fizzy people --page 2 Get second page EOF fi } diff --git a/cli/lib/commands/search.sh b/cli/lib/commands/search.sh index 0414979c3d..0b7281cc20 100644 --- a/cli/lib/commands/search.sh +++ b/cli/lib/commands/search.sh @@ -65,7 +65,14 @@ cmd_search() { board_id=$(get_board_id 2>/dev/null || true) fi + # Resolve board name to ID if provided if [[ -n "$board_id" ]]; then + 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 params+=("board_ids[]=$board_id") fi diff --git a/cli/lib/commands/tags.sh b/cli/lib/commands/tags.sh index 2c40b9af72..9283663823 100644 --- a/cli/lib/commands/tags.sh +++ b/cli/lib/commands/tags.sh @@ -7,9 +7,20 @@ cmd_tags() { local show_help=false + local page="" while [[ $# -gt 0 ]]; do case "$1" in + --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 @@ -25,17 +36,26 @@ cmd_tags() { return 0 fi + local path="/tags" + if [[ -n "$page" ]]; then + path="$path?page=$page" + fi + local response - response=$(api_get "/tags") + response=$(api_get "$path") local count count=$(echo "$response" | jq 'length') local summary="$count tags" + [[ -n "$page" ]] && summary="$count tags (page $page)" + + local next_page=$((${page:-1} + 1)) local breadcrumbs breadcrumbs=$(breadcrumbs \ "$(breadcrumb "filter" "fizzy cards --tag " "Filter cards by tag")" \ - "$(breadcrumb "add" "fizzy tag --with \"name\"" "Add tag to card")" + "$(breadcrumb "add" "fizzy tag --with \"name\"" "Add tag to card")" \ + "$(breadcrumb "next" "fizzy tags --page $next_page" "Next page")" ) output "$response" "$summary" "$breadcrumbs" "_tags_md" @@ -72,10 +92,12 @@ _tags_help() { jq -n '{ command: "fizzy tags", description: "List tags in the account", - options: [], + options: [ + {flag: "--page, -p", description: "Page number for pagination"} + ], examples: [ "fizzy tags", - "fizzy tags --json" + "fizzy tags --page 2" ] }' else @@ -90,13 +112,13 @@ List tags in the account. ### Options + --page, -p Page number for pagination --help, -h Show this help ### Examples fizzy tags List all tags - fizzy tags --json Output as JSON - fizzy tags -q Raw data only + fizzy tags --page 2 Get second page EOF fi } diff --git a/cli/test/boards.bats b/cli/test/boards.bats index 54b17e21ea..2bc7201240 100644 --- a/cli/test/boards.bats +++ b/cli/test/boards.bats @@ -36,6 +36,27 @@ load test_helper } +# boards pagination validation + +@test "boards --page rejects non-numeric value" { + run fizzy boards --page abc + assert_failure + assert_output_contains "positive integer" +} + +@test "boards --page rejects zero" { + run fizzy boards --page 0 + assert_failure + assert_output_contains "positive integer" +} + +@test "boards --page rejects negative" { + run fizzy boards --page -1 + assert_failure + assert_output_contains "positive integer" +} + + # columns --help @test "columns --help shows help" { diff --git a/cli/test/queries.bats b/cli/test/queries.bats new file mode 100644 index 0000000000..81bf56f305 --- /dev/null +++ b/cli/test/queries.bats @@ -0,0 +1,118 @@ +#!/usr/bin/env bats +# queries.bats - Tests for query commands (tags, people, notifications) + +load test_helper + + +# tags --help + +@test "tags --help shows help" { + run fizzy --md tags --help + assert_success + assert_output_contains "fizzy tags" + assert_output_contains "List tags" +} + +@test "tags -h shows help" { + run fizzy --md tags -h + assert_success + assert_output_contains "fizzy tags" +} + +@test "tags --help --json outputs JSON" { + run fizzy --json tags --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# tags pagination validation + +@test "tags --page rejects non-numeric value" { + run fizzy tags --page abc + assert_failure + assert_output_contains "positive integer" +} + +@test "tags --page rejects zero" { + run fizzy tags --page 0 + assert_failure + assert_output_contains "positive integer" +} + + +# people --help + +@test "people --help shows help" { + run fizzy --md people --help + assert_success + assert_output_contains "fizzy people" + assert_output_contains "List users" +} + +@test "people -h shows help" { + run fizzy --md people -h + assert_success + assert_output_contains "fizzy people" +} + +@test "people --help --json outputs JSON" { + run fizzy --json people --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# people pagination validation + +@test "people --page rejects non-numeric value" { + run fizzy people --page abc + assert_failure + assert_output_contains "positive integer" +} + +@test "people --page rejects zero" { + run fizzy people --page 0 + assert_failure + assert_output_contains "positive integer" +} + + +# notifications --help + +@test "notifications --help shows help" { + run fizzy --md notifications --help + assert_success + assert_output_contains "fizzy notifications" + assert_output_contains "List" +} + +@test "notifications -h shows help" { + run fizzy --md notifications -h + assert_success + assert_output_contains "fizzy notifications" +} + +@test "notifications --help --json outputs JSON" { + run fizzy --json notifications --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# notifications pagination validation + +@test "notifications --page rejects non-numeric value" { + run fizzy notifications --page abc + assert_failure + assert_output_contains "positive integer" +} + +@test "notifications --page rejects zero" { + run fizzy notifications --page 0 + assert_failure + assert_output_contains "positive integer" +} From 41609ff76e7c8502b83054be327e5e4585aa953a Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 12:54:50 -0800 Subject: [PATCH 21/32] Add pagination fallback + remove Python dependency api_get_all: - Fall back to ?page=N when Link headers absent - Stop on empty response to prevent infinite loops urlencode: - Replace python3 urllib.parse with jq -sRr @uri - Removes undocumented Python dependency --- cli/lib/api.sh | 39 ++++++++++++++++++++++++++++++++++----- cli/lib/core.sh | 3 ++- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/cli/lib/api.sh b/cli/lib/api.sh index 6eba8f23de..3a1bc9f518 100644 --- a/cli/lib/api.sh +++ b/cli/lib/api.sh @@ -521,7 +521,9 @@ api_get_all() { local all_results="[]" local page=1 - local url="$FIZZY_BASE_URL/$account_slug/$path" + local base_url="$FIZZY_BASE_URL/$account_slug/$path" + local url="$base_url" + local use_link_headers=true local headers_file headers_file=$(mktemp) @@ -556,6 +558,14 @@ api_get_all() { 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 @@ -566,12 +576,31 @@ api_get_all() { local next_url next_url=$(grep -i '^Link:' "$headers_file" | sed -n 's/.*<\([^>]*\)>; rel="next".*/\1/p' | tr -d '\r') - if [[ -z "$next_url" ]]; then + 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 - - url="$next_url" - ((page++)) done echo "$all_results" diff --git a/cli/lib/core.sh b/cli/lib/core.sh index 997137c861..5a76428f8a 100644 --- a/cli/lib/core.sh +++ b/cli/lib/core.sh @@ -391,5 +391,6 @@ require_command() { urlencode() { local string="$1" - python3 -c "import urllib.parse; print(urllib.parse.quote('''$string''', safe=''))" + # Use jq for URL encoding - no Python dependency needed + printf '%s' "$string" | jq -sRr @uri } From ee48859edcc7dd46c745b07e9bae12dac0bb2dfd Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 12:54:59 -0800 Subject: [PATCH 22/32] Fix completions and add postponing_soon status Completions: - Zsh: use \$words[1] in args state (array is shifted) - Remove --page from global flags (it's per-command) cards: - Add postponing_soon to --status filter help + completion --- cli/completions/_fizzy | 11 +++++++++-- cli/completions/fizzy.bash | 4 ++-- cli/lib/commands/cards.sh | 4 ++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/cli/completions/_fizzy b/cli/completions/_fizzy index a270cd1ac2..20ccda6635 100644 --- a/cli/completions/_fizzy +++ b/cli/completions/_fizzy @@ -59,7 +59,6 @@ _fizzy() { '(-v --verbose)'{-v,--verbose}'[Debug output]' \ '(-b --board --in)'{-b,--board,--in}'[Board ID or name]:board:' \ '(-a --account)'{-a,--account}'[Account slug]:account:' \ - '(-p --page)'{-p,--page}'[Page number]:page:' \ '(-h --help)'{-h,--help}'[Show help]' \ '1: :->command' \ '*::arg:->args' @@ -69,13 +68,21 @@ _fizzy() { _describe -t commands 'fizzy command' commands ;; args) - case "$line[1]" in + # 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 ;; + notifications) + local notification_commands=( + 'read:Mark notification as read' + 'unread:Mark notification as unread' + ) + _describe -t notification_commands 'notifications subcommand' notification_commands + ;; esac ;; esac diff --git a/cli/completions/fizzy.bash b/cli/completions/fizzy.bash index be4725aeda..e25290a0fc 100644 --- a/cli/completions/fizzy.bash +++ b/cli/completions/fizzy.bash @@ -32,7 +32,7 @@ _fizzy_completions() { return ;; --status) - COMPREPLY=($(compgen -W "all closed not_now stalled golden" -- "$cur")) + COMPREPLY=($(compgen -W "all closed not_now stalled golden postponing_soon" -- "$cur")) return ;; --scope) @@ -47,7 +47,7 @@ _fizzy_completions() { # Handle flags if [[ "$cur" == -* ]]; then - local flags="--json -j --md -m --quiet -q --data --verbose -v --board -b --in --account -a --page -p --help -h" + 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 diff --git a/cli/lib/commands/cards.sh b/cli/lib/commands/cards.sh index 3f06e42d95..63742ba211 100644 --- a/cli/lib/commands/cards.sh +++ b/cli/lib/commands/cards.sh @@ -221,7 +221,7 @@ _cards_help() { {flag: "--board, -b, --in", description: "Filter by board name or ID"}, {flag: "--tag", description: "Filter by tag name or ID"}, {flag: "--assignee", description: "Filter by assignee name, email, or ID"}, - {flag: "--status", description: "Filter by status: all, closed, not_now, stalled, golden"}, + {flag: "--status", description: "Filter by status: all, closed, not_now, stalled, golden, postponing_soon"}, {flag: "--search, -s", description: "Search terms"}, {flag: "--sort", description: "Sort order: latest, newest, oldest"}, {flag: "--page, -p", description: "Page number for pagination"} @@ -249,7 +249,7 @@ List and filter cards. --board, -b, --in Filter by board name or ID --tag Filter by tag name or ID --assignee Filter by assignee name, email, or ID - --status Filter: all, closed, not_now, stalled, golden + --status Filter: all, closed, not_now, stalled, golden, postponing_soon --search, -s Search terms --sort Sort: latest (default), newest, oldest --page, -p Page number for pagination From d0fd9404ba037ace84699b029a572793cd9baaf1 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 12:55:07 -0800 Subject: [PATCH 23/32] Update README for global flags and local dev default - Global flags (--json, --quiet) must precede command name - Default base URL is local dev, not production - Add pagination note for cards command --- cli/README.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/cli/README.md b/cli/README.md index 9cff62975d..1f2a0d59ae 100644 --- a/cli/README.md +++ b/cli/README.md @@ -71,12 +71,14 @@ fizzy search "login bug" fizzy boards | jq '.data[0]' # Raw data only (--quiet or --data) -fizzy boards --quiet | jq '.[0]' +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 @@ -97,7 +99,7 @@ fizzy --md boards | Command | Description | |---------|-------------| | `boards` | List boards in the account | -| `cards` | List or filter cards | +| `cards` | List or filter cards (supports `--page` pagination) | | `columns` | List columns on a board | | `comments` | List comments on a card | | `notifications` | List notifications | @@ -128,13 +130,15 @@ fizzy --md boards ## 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 | +| `--board`, `-b`, `--in` | Board ID or name (can also go after command) | | `--account`, `-a` | Account slug | ## Authentication @@ -187,11 +191,11 @@ fizzy config path Point `fizzy` at different Fizzy instances using `FIZZY_BASE_URL`: ```bash -# Production (default) +# Local development (default: http://fizzy.localhost:3006) fizzy boards -# Local development -FIZZY_BASE_URL=http://fizzy.localhost:3006 fizzy auth login +# Production +FIZZY_BASE_URL=https://fizzy.37signals.com fizzy auth login ``` OAuth endpoints are discovered automatically via `.well-known/oauth-authorization-server` (RFC 8414). From 704463d950709c01664a835f94c0299eaf309ebf Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 13:26:58 -0800 Subject: [PATCH 24/32] Add --all flag for auto-pagination to list commands Adds --all/-a flag to boards, tags, people, and notifications commands to fetch all pages automatically using api_get_all. When --all is used: - Summary shows "(all)" instead of page number - "Next page" breadcrumb is omitted This matches the Go CLI's full-fetch behavior while maintaining the per-page --page flag for incremental fetching. Smoke test notes: - fizzy boards --all returns all boards (verified with 3 boards) - fizzy tags --all returns all tags - fizzy people --all returns all users - fizzy notifications --all returns all notifications --- cli/lib/commands/boards.sh | 47 +++++++++++++++++++++-------- cli/lib/commands/notifications.sh | 50 ++++++++++++++++++++++--------- cli/lib/commands/people.sh | 44 +++++++++++++++++++-------- cli/lib/commands/tags.sh | 44 +++++++++++++++++++-------- cli/test/boards.bats | 10 +++++++ cli/test/queries.bats | 30 +++++++++++++++++++ 6 files changed, 174 insertions(+), 51 deletions(-) diff --git a/cli/lib/commands/boards.sh b/cli/lib/commands/boards.sh index 54122d3b0b..0baa5254d9 100644 --- a/cli/lib/commands/boards.sh +++ b/cli/lib/commands/boards.sh @@ -8,9 +8,14 @@ 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 @@ -36,28 +41,40 @@ cmd_boards() { return 0 fi - local path="/boards" - if [[ -n "$page" ]]; then - path="$path?page=$page" - fi - local response - response=$(api_get "$path") + 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 - 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")" - ) + 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" } @@ -94,10 +111,12 @@ _boards_help() { 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" ] }' @@ -113,12 +132,14 @@ List boards in the account. ### Options + --all, -a Fetch all pages --page, -p Page number for pagination --help, -h Show this help ### Examples - fizzy boards List all boards + fizzy boards List boards (first page) + fizzy boards --all Fetch all boards fizzy boards --page 2 Get second page EOF fi diff --git a/cli/lib/commands/notifications.sh b/cli/lib/commands/notifications.sh index 1bfc22fe3a..3b08c2cc1f 100644 --- a/cli/lib/commands/notifications.sh +++ b/cli/lib/commands/notifications.sh @@ -9,9 +9,14 @@ cmd_notifications() { local action="" local show_help=false local page="" + local fetch_all=false while [[ $# -gt 0 ]]; do case "$1" in + --all|-a) + fetch_all=true + shift + ;; read|unread) action="$1" shift @@ -43,13 +48,16 @@ cmd_notifications() { return 0 fi - local path="/notifications" - if [[ -n "$page" ]]; then - path="$path?page=$page" - fi - local response - response=$(api_get "$path") + if [[ "$fetch_all" == "true" ]]; then + response=$(api_get_all "/notifications") + else + local path="/notifications" + if [[ -n "$page" ]]; then + path="$path?page=$page" + fi + response=$(api_get "$path") + fi local count unread_count count=$(echo "$response" | jq 'length') @@ -57,15 +65,24 @@ cmd_notifications() { local summary="$count notifications ($unread_count unread)" [[ -n "$page" ]] && summary="$count notifications ($unread_count unread, page $page)" + [[ "$fetch_all" == "true" ]] && summary="$count notifications ($unread_count unread, all)" local next_page=$((${page:-1} + 1)) local breadcrumbs - breadcrumbs=$(breadcrumbs \ - "$(breadcrumb "read" "fizzy notifications read " "Mark as read")" \ - "$(breadcrumb "read-all" "fizzy notifications read --all" "Mark all as read")" \ - "$(breadcrumb "show" "fizzy show " "View card")" \ - "$(breadcrumb "next" "fizzy notifications --page $next_page" "Next page")" - ) + if [[ "$fetch_all" == "true" ]]; then + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "read" "fizzy notifications read " "Mark as read")" \ + "$(breadcrumb "read-all" "fizzy notifications read --all" "Mark all as read")" \ + "$(breadcrumb "show" "fizzy show " "View card")" + ) + else + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "read" "fizzy notifications read " "Mark as read")" \ + "$(breadcrumb "read-all" "fizzy notifications read --all" "Mark all as read")" \ + "$(breadcrumb "show" "fizzy show " "View card")" \ + "$(breadcrumb "next" "fizzy notifications --page $next_page" "Next page")" + ) + fi output "$response" "$summary" "$breadcrumbs" "_notifications_md" } @@ -149,11 +166,13 @@ _notifications_help() { {name: "unread", description: "Mark notification as unread"} ], options: [ + {flag: "--all, -a", description: "Fetch all pages"}, {flag: "--page, -p", description: "Page number for pagination"}, - {flag: "--all", description: "Mark all notifications as read (with read)"} + {flag: "--all (with read)", description: "Mark all notifications as read"} ], examples: [ "fizzy notifications", + "fizzy notifications --all", "fizzy notifications --page 2", "fizzy notifications read abc123", "fizzy notifications read --all" @@ -168,6 +187,7 @@ List and manage notifications. ### Usage fizzy notifications List notifications + fizzy notifications --all Fetch all pages fizzy notifications --page 2 Get second page fizzy notifications read Mark as read fizzy notifications read --all Mark all as read @@ -175,11 +195,13 @@ List and manage notifications. ### Options + --all, -a Fetch all pages --page, -p Page number for pagination ### Examples - fizzy notifications List all notifications + fizzy notifications List notifications (first page) + fizzy notifications --all Fetch all notifications fizzy notifications --page 2 Get second page fizzy notifications read abc123 Mark notification as read fizzy notifications read --all Mark all as read diff --git a/cli/lib/commands/people.sh b/cli/lib/commands/people.sh index 064380c1bb..f0413f3cb2 100644 --- a/cli/lib/commands/people.sh +++ b/cli/lib/commands/people.sh @@ -8,9 +8,14 @@ cmd_people() { 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 @@ -36,27 +41,38 @@ cmd_people() { return 0 fi - local path="/users" - if [[ -n "$page" ]]; then - path="$path?page=$page" - fi - local response - response=$(api_get "$path") + if [[ "$fetch_all" == "true" ]]; then + response=$(api_get_all "/users") + else + local path="/users" + if [[ -n "$page" ]]; then + path="$path?page=$page" + fi + response=$(api_get "$path") + fi local count count=$(echo "$response" | jq 'length') local summary="$count users" [[ -n "$page" ]] && summary="$count users (page $page)" + [[ "$fetch_all" == "true" ]] && summary="$count users (all)" local next_page=$((${page:-1} + 1)) local breadcrumbs - breadcrumbs=$(breadcrumbs \ - "$(breadcrumb "assign" "fizzy assign --to " "Assign user to card")" \ - "$(breadcrumb "cards" "fizzy cards --assignee " "Cards assigned to user")" \ - "$(breadcrumb "next" "fizzy people --page $next_page" "Next page")" - ) + if [[ "$fetch_all" == "true" ]]; then + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "assign" "fizzy assign --to " "Assign user to card")" \ + "$(breadcrumb "cards" "fizzy cards --assignee " "Cards assigned to user")" + ) + else + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "assign" "fizzy assign --to " "Assign user to card")" \ + "$(breadcrumb "cards" "fizzy cards --assignee " "Cards assigned to user")" \ + "$(breadcrumb "next" "fizzy people --page $next_page" "Next page")" + ) + fi output "$response" "$summary" "$breadcrumbs" "_people_md" } @@ -93,10 +109,12 @@ _people_help() { command: "fizzy people", description: "List users in the account", options: [ + {flag: "--all, -a", description: "Fetch all pages"}, {flag: "--page, -p", description: "Page number for pagination"} ], examples: [ "fizzy people", + "fizzy people --all", "fizzy people --page 2" ] }' @@ -112,12 +130,14 @@ List users in the account. ### Options + --all, -a Fetch all pages --page, -p Page number for pagination --help, -h Show this help ### Examples - fizzy people List all users + fizzy people List users (first page) + fizzy people --all Fetch all users fizzy people --page 2 Get second page EOF fi diff --git a/cli/lib/commands/tags.sh b/cli/lib/commands/tags.sh index 9283663823..e791059e64 100644 --- a/cli/lib/commands/tags.sh +++ b/cli/lib/commands/tags.sh @@ -8,9 +8,14 @@ cmd_tags() { 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 @@ -36,27 +41,38 @@ cmd_tags() { return 0 fi - local path="/tags" - if [[ -n "$page" ]]; then - path="$path?page=$page" - fi - local response - response=$(api_get "$path") + if [[ "$fetch_all" == "true" ]]; then + response=$(api_get_all "/tags") + else + local path="/tags" + if [[ -n "$page" ]]; then + path="$path?page=$page" + fi + response=$(api_get "$path") + fi local count count=$(echo "$response" | jq 'length') local summary="$count tags" [[ -n "$page" ]] && summary="$count tags (page $page)" + [[ "$fetch_all" == "true" ]] && summary="$count tags (all)" local next_page=$((${page:-1} + 1)) local breadcrumbs - breadcrumbs=$(breadcrumbs \ - "$(breadcrumb "filter" "fizzy cards --tag " "Filter cards by tag")" \ - "$(breadcrumb "add" "fizzy tag --with \"name\"" "Add tag to card")" \ - "$(breadcrumb "next" "fizzy tags --page $next_page" "Next page")" - ) + if [[ "$fetch_all" == "true" ]]; then + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "filter" "fizzy cards --tag " "Filter cards by tag")" \ + "$(breadcrumb "add" "fizzy tag --with \"name\"" "Add tag to card")" + ) + else + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "filter" "fizzy cards --tag " "Filter cards by tag")" \ + "$(breadcrumb "add" "fizzy tag --with \"name\"" "Add tag to card")" \ + "$(breadcrumb "next" "fizzy tags --page $next_page" "Next page")" + ) + fi output "$response" "$summary" "$breadcrumbs" "_tags_md" } @@ -93,10 +109,12 @@ _tags_help() { command: "fizzy tags", description: "List tags in the account", options: [ + {flag: "--all, -a", description: "Fetch all pages"}, {flag: "--page, -p", description: "Page number for pagination"} ], examples: [ "fizzy tags", + "fizzy tags --all", "fizzy tags --page 2" ] }' @@ -112,12 +130,14 @@ List tags in the account. ### Options + --all, -a Fetch all pages --page, -p Page number for pagination --help, -h Show this help ### Examples - fizzy tags List all tags + fizzy tags List tags (first page) + fizzy tags --all Fetch all tags fizzy tags --page 2 Get second page EOF fi diff --git a/cli/test/boards.bats b/cli/test/boards.bats index 2bc7201240..76dfd1e1e7 100644 --- a/cli/test/boards.bats +++ b/cli/test/boards.bats @@ -57,6 +57,16 @@ load test_helper } +# boards --all flag + +@test "boards --help documents --all flag" { + run fizzy --md boards --help + assert_success + assert_output_contains "--all" + assert_output_contains "Fetch all pages" +} + + # columns --help @test "columns --help shows help" { diff --git a/cli/test/queries.bats b/cli/test/queries.bats index 81bf56f305..7224e7f0b4 100644 --- a/cli/test/queries.bats +++ b/cli/test/queries.bats @@ -42,6 +42,16 @@ load test_helper } +# tags --all flag + +@test "tags --help documents --all flag" { + run fizzy --md tags --help + assert_success + assert_output_contains "--all" + assert_output_contains "Fetch all pages" +} + + # people --help @test "people --help shows help" { @@ -80,6 +90,16 @@ load test_helper } +# people --all flag + +@test "people --help documents --all flag" { + run fizzy --md people --help + assert_success + assert_output_contains "--all" + assert_output_contains "Fetch all pages" +} + + # notifications --help @test "notifications --help shows help" { @@ -116,3 +136,13 @@ load test_helper assert_failure assert_output_contains "positive integer" } + + +# notifications --all flag + +@test "notifications --help documents --all flag" { + run fizzy --md notifications --help + assert_success + assert_output_contains "--all" + assert_output_contains "Fetch all pages" +} From e878b11a6da20e8eeb7caa99d15b13052311e856 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 13:30:27 -0800 Subject: [PATCH 25/32] Add card update command Adds `fizzy update [options]` to update a card's title or description. Uses PATCH /cards/{number} endpoint. Options: - --title, -t: New card title - --description, -d: New card description (HTML) - --description-file: Read description from file This brings parity with the Go CLI's card update functionality. Smoke test notes: - fizzy update 1 --title "New title" updates title - fizzy update 1 --description "content" updates description - fizzy update 1 --title "A" --description "B" updates both - fizzy update 1 --description-file notes.html works - Error if no options provided (nothing to update) --- cli/bin/fizzy | 1 + cli/completions/_fizzy | 1 + cli/completions/fizzy.bash | 2 +- cli/lib/commands/actions.sh | 164 ++++++++++++++++++++++++++++++++++++ cli/test/actions.bats | 57 +++++++++++++ 5 files changed, 224 insertions(+), 1 deletion(-) diff --git a/cli/bin/fizzy b/cli/bin/fizzy index 2cff64c0dd..fe0c58e309 100755 --- a/cli/bin/fizzy +++ b/cli/bin/fizzy @@ -74,6 +74,7 @@ main() { # Action commands (using Fizzy's ubiquitous language) card) cmd_card_create "$@" ;; + update) cmd_card_update "$@" ;; close) cmd_close "$@" ;; reopen) cmd_reopen "$@" ;; triage) cmd_triage "$@" ;; diff --git a/cli/completions/_fizzy b/cli/completions/_fizzy index 20ccda6635..d326cb74d4 100644 --- a/cli/completions/_fizzy +++ b/cli/completions/_fizzy @@ -19,6 +19,7 @@ _fizzy() { 'show:Show card or board details' 'tags:List tags' 'card:Create a new card' + 'update:Update a card' 'close:Close a card' 'reopen:Reopen a closed card' 'triage:Move card to a column' diff --git a/cli/completions/fizzy.bash b/cli/completions/fizzy.bash index e25290a0fc..e1181382da 100644 --- a/cli/completions/fizzy.bash +++ b/cli/completions/fizzy.bash @@ -8,7 +8,7 @@ _fizzy_completions() { local commands=" auth config help version boards cards columns comments notifications people search show tags - card close reopen triage untriage postpone comment assign tag watch unwatch gild ungild step react + card update close reopen triage untriage postpone comment assign tag watch unwatch gild ungild step react " local auth_subcommands="login logout status refresh" diff --git a/cli/lib/commands/actions.sh b/cli/lib/commands/actions.sh index 511eda4ad6..9ff9b805b2 100644 --- a/cli/lib/commands/actions.sh +++ b/cli/lib/commands/actions.sh @@ -254,6 +254,170 @@ EOF } +# fizzy update [options] +# Update a card's title or description + +cmd_card_update() { + local card_number="" + local title="" + local description="" + local description_file="" + 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 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy 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 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 + + # Must specify at least one thing to update + if [[ -z "$title" && -z "$description" ]]; then + die "Nothing to update. Specify --title or --description" $EXIT_USAGE + fi + + # Build request body + local body + body=$(jq -n \ + --arg title "$title" \ + --arg description "$description" \ + '(if $title != "" then {title: $title} else {} end) + + (if $description != "" then {description: $description} else {} end)') + + local response + response=$(api_patch "/cards/$card_number" "$body") + + 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 update", + description: "Update a card'\''s title or description", + usage: "fizzy 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"} + ], + examples: [ + "fizzy update 123 --title \"New title\"", + "fizzy update 123 --description \"

Updated content

\"", + "fizzy update 123 --title \"New\" --description \"Both\"" + ] + }' + else + cat <<'EOF' +## fizzy update + +Update a card's title or description. + +### Usage + + fizzy update [options] + +### Options + + --title, -t New card title + --description, -d New card description (HTML) + --description-file Read description from file + --help, -h Show this help + +### Examples + + fizzy update 123 --title "New title" + fizzy update 123 --description "

Updated content

" + fizzy update 123 --title "New" --description "Both" + fizzy update 123 --description-file notes.html +EOF + fi +} + + # fizzy close # Close a card diff --git a/cli/test/actions.bats b/cli/test/actions.bats index 1d7f3d5c27..41d318dc35 100644 --- a/cli/test/actions.bats +++ b/cli/test/actions.bats @@ -56,6 +56,63 @@ load test_helper } +# update --help + +@test "update --help shows help" { + run fizzy --md update --help + assert_success + assert_output_contains "fizzy update" + assert_output_contains "Update" +} + +@test "update -h shows help" { + run fizzy --md update -h + assert_success + assert_output_contains "fizzy update" +} + +@test "update --help --json outputs JSON" { + run fizzy --json update --help + assert_success + is_valid_json + assert_json_not_null ".command" + assert_json_not_null ".options" +} + + +# update requires card number and options + +@test "update without number shows error" { + run fizzy update + assert_failure + assert_output_contains "Card number required" +} + +@test "update without options shows error" { + run fizzy update 123 + assert_failure + assert_output_contains "Nothing to update" +} + +@test "update requires authentication" { + run fizzy update 123 --title "New" + assert_failure + assert_output_contains "Not authenticated" +} + +@test "update rejects unknown option" { + run fizzy update --unknown-option + assert_failure + assert_output_contains "Unknown option" +} + +@test "update --description-file with missing file shows error" { + run fizzy update 123 --description-file nonexistent.txt + assert_failure + assert_output_contains "File not found" +} + + # close --help @test "close --help shows help" { From c1c4ba0c47649e1d3cb34edd20e76c7f78af2e23 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 13:38:38 -0800 Subject: [PATCH 26/32] Add comment edit/delete as subcommands Changed from standalone commands (fizzy edit-comment, fizzy delete-comment) to subcommands of comment (fizzy comment edit, fizzy comment delete). Usage: fizzy comment "text" --on # Create comment (unchanged) fizzy comment edit --on "new text" # Update comment fizzy comment delete --on # Delete comment API: PATCH/DELETE /cards/:num/comments/:id Smoke test: fizzy comment edit --help; fizzy comment delete --help --- cli/bin/fizzy | 2 +- cli/completions/_fizzy | 9 +- cli/completions/fizzy.bash | 5 + cli/lib/commands/actions.sh | 256 +++++++++++++++++++++++++++++++++++- cli/test/actions.bats | 94 +++++++++++++ 5 files changed, 361 insertions(+), 5 deletions(-) diff --git a/cli/bin/fizzy b/cli/bin/fizzy index fe0c58e309..de7ecde6bc 100755 --- a/cli/bin/fizzy +++ b/cli/bin/fizzy @@ -80,7 +80,7 @@ main() { triage) cmd_triage "$@" ;; untriage) cmd_untriage "$@" ;; postpone) cmd_postpone "$@" ;; - comment) cmd_comment_create "$@" ;; + comment) cmd_comment "$@" ;; assign) cmd_assign "$@" ;; tag) cmd_tag "$@" ;; watch) cmd_watch "$@" ;; diff --git a/cli/completions/_fizzy b/cli/completions/_fizzy index d326cb74d4..42cb052976 100644 --- a/cli/completions/_fizzy +++ b/cli/completions/_fizzy @@ -25,7 +25,7 @@ _fizzy() { 'triage:Move card to a column' 'untriage:Move card back to triage' 'postpone:Move card to "not now"' - 'comment:Add a comment to a card' + 'comment:Add, edit, or delete comments' 'assign:Assign a card to someone' 'tag:Add a tag to a card' 'watch:Subscribe to card notifications' @@ -84,6 +84,13 @@ _fizzy() { ) _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 + ;; esac ;; esac diff --git a/cli/completions/fizzy.bash b/cli/completions/fizzy.bash index e1181382da..a4c129ad4c 100644 --- a/cli/completions/fizzy.bash +++ b/cli/completions/fizzy.bash @@ -13,6 +13,7 @@ _fizzy_completions() { local auth_subcommands="login logout status refresh" local config_subcommands="list get set unset path" + local comment_subcommands="edit delete" case "$prev" in fizzy) @@ -27,6 +28,10 @@ _fizzy_completions() { COMPREPLY=($(compgen -W "$config_subcommands" -- "$cur")) return ;; + comment) + COMPREPLY=($(compgen -W "$comment_subcommands" -- "$cur")) + return + ;; --board|-b|--in) # Could complete board names if cached, for now just return return diff --git a/cli/lib/commands/actions.sh b/cli/lib/commands/actions.sh index 9ff9b805b2..814605e395 100644 --- a/cli/lib/commands/actions.sh +++ b/cli/lib/commands/actions.sh @@ -1020,9 +1020,25 @@ EOF # fizzy comment "text" --on -# Add comment to a card - -cmd_comment_create() { +# 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 @@ -1085,6 +1101,240 @@ cmd_comment_create() { 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}}') + + local response + response=$(api_patch "/cards/$card_number/comments/$comment_id" "$body") + + 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" diff --git a/cli/test/actions.bats b/cli/test/actions.bats index 41d318dc35..71115ac9bf 100644 --- a/cli/test/actions.bats +++ b/cli/test/actions.bats @@ -361,6 +361,100 @@ load test_helper } +# comment edit --help + +@test "comment edit --help shows help" { + run fizzy --md comment edit --help + assert_success + assert_output_contains "fizzy comment edit" + assert_output_contains "Update" +} + +@test "comment edit -h shows help" { + run fizzy --md comment edit -h + assert_success + assert_output_contains "fizzy comment edit" +} + +@test "comment edit --help --json outputs JSON" { + run fizzy --json comment edit --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# comment edit requires arguments + +@test "comment edit without args shows error" { + run fizzy comment edit + assert_failure + assert_output_contains "Comment ID required" +} + +@test "comment edit without --on shows error" { + run fizzy comment edit abc123 "new text" + assert_failure + assert_output_contains "--on card number required" +} + +@test "comment edit without new text shows error" { + run fizzy comment edit abc123 --on 123 + assert_failure + assert_output_contains "New comment text required" +} + +@test "comment edit requires authentication" { + run fizzy comment edit abc123 --on 123 "new text" + assert_failure + assert_output_contains "Not authenticated" +} + + +# comment delete --help + +@test "comment delete --help shows help" { + run fizzy --md comment delete --help + assert_success + assert_output_contains "fizzy comment delete" + assert_output_contains "Delete" +} + +@test "comment delete -h shows help" { + run fizzy --md comment delete -h + assert_success + assert_output_contains "fizzy comment delete" +} + +@test "comment delete --help --json outputs JSON" { + run fizzy --json comment delete --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# comment delete requires arguments + +@test "comment delete without args shows error" { + run fizzy comment delete + assert_failure + assert_output_contains "Comment ID required" +} + +@test "comment delete without --on shows error" { + run fizzy comment delete abc123 + assert_failure + assert_output_contains "--on card number required" +} + +@test "comment delete requires authentication" { + run fizzy comment delete abc123 --on 123 + assert_failure + assert_output_contains "Not authenticated" +} + + # assign --help @test "assign --help shows help" { From 2cc67ce858177dfcdd058cbba224e20cc5584f87 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 13:46:31 -0800 Subject: [PATCH 27/32] Document env var auth path in help and status --- cli/lib/auth.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cli/lib/auth.sh b/cli/lib/auth.sh index 416cdae5c0..b044dca7e9 100644 --- a/cli/lib/auth.sh +++ b/cli/lib/auth.sh @@ -283,6 +283,7 @@ _auth_status() { echo "Status: ✗ Not authenticated" echo echo "Run: fizzy auth login" + echo " or export FIZZY_ACCESS_TOKEN=" fi fi } @@ -724,6 +725,11 @@ Manage authentication. - `--scope write|read` - Token scope (default: write) - `--no-browser` - Manual auth code entry mode +### Environment Variables + + FIZZY_ACCESS_TOKEN Personal access token (bypasses stored credentials) + FIZZY_ACCOUNT_SLUG Account slug from URL (e.g., 897362094) + ### Notes Fizzy issues long-lived access tokens that do not expire. You only need @@ -741,6 +747,10 @@ fizzy auth login --no-browser # Read-only access fizzy auth login --scope read +# For CI/scripts: use env vars instead +export FIZZY_ACCESS_TOKEN=fzt_... +export FIZZY_ACCOUNT_SLUG=897362094 + # Check status fizzy auth status ``` From 4f3935173c5d7a66b143f7fd7dc9984e0c61dfd0 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 13:49:14 -0800 Subject: [PATCH 28/32] Add card delete command fizzy delete [numbers...] Permanently deletes cards. Shows warning in help that action cannot be undone. API: DELETE /cards/:number (returns 204 No Content) Smoke test: fizzy delete --help --- cli/bin/fizzy | 1 + cli/completions/_fizzy | 1 + cli/completions/fizzy.bash | 2 +- cli/lib/commands/actions.sh | 119 ++++++++++++++++++++++++++++++++++++ cli/test/actions.bats | 50 +++++++++++++++ 5 files changed, 172 insertions(+), 1 deletion(-) diff --git a/cli/bin/fizzy b/cli/bin/fizzy index de7ecde6bc..df7e65d244 100755 --- a/cli/bin/fizzy +++ b/cli/bin/fizzy @@ -77,6 +77,7 @@ main() { update) cmd_card_update "$@" ;; close) cmd_close "$@" ;; reopen) cmd_reopen "$@" ;; + delete) cmd_delete "$@" ;; triage) cmd_triage "$@" ;; untriage) cmd_untriage "$@" ;; postpone) cmd_postpone "$@" ;; diff --git a/cli/completions/_fizzy b/cli/completions/_fizzy index 42cb052976..616e7a617b 100644 --- a/cli/completions/_fizzy +++ b/cli/completions/_fizzy @@ -22,6 +22,7 @@ _fizzy() { 'update:Update a card' 'close:Close a card' 'reopen:Reopen a closed card' + 'delete:Permanently delete a card' 'triage:Move card to a column' 'untriage:Move card back to triage' 'postpone:Move card to "not now"' diff --git a/cli/completions/fizzy.bash b/cli/completions/fizzy.bash index a4c129ad4c..c6e5f55ba5 100644 --- a/cli/completions/fizzy.bash +++ b/cli/completions/fizzy.bash @@ -8,7 +8,7 @@ _fizzy_completions() { local commands=" auth config help version boards cards columns comments notifications people search show tags - card update close reopen triage untriage postpone comment assign tag watch unwatch gild ungild step react + card update close reopen delete triage untriage postpone comment assign tag watch unwatch gild ungild step react " local auth_subcommands="login logout status refresh" diff --git a/cli/lib/commands/actions.sh b/cli/lib/commands/actions.sh index 814605e395..2de131cd9b 100644 --- a/cli/lib/commands/actions.sh +++ b/cli/lib/commands/actions.sh @@ -654,6 +654,125 @@ EOF } +# fizzy delete +# Permanently delete a card + +cmd_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 delete --help" + ;; + *) + card_numbers+=("$1") + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _delete_help + return 0 + fi + + if [[ ${#card_numbers[@]} -eq 0 ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy delete " + fi + + local results=() + local num + 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" "_delete_md" +} + +_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" +} + +_delete_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy delete", + description: "Permanently delete card(s)", + usage: "fizzy delete [numbers...]", + warning: "This action cannot be undone", + examples: ["fizzy delete 123", "fizzy delete 123 124"] + }' + else + cat <<'EOF' +## fizzy delete + +Permanently delete card(s). + +**Warning:** This action cannot be undone. + +### Usage + + fizzy delete [numbers...] + +### Examples + + fizzy delete 123 Delete card #123 + fizzy delete 123 124 Delete multiple cards +EOF + fi +} + + # fizzy triage --to # Move card to a column diff --git a/cli/test/actions.bats b/cli/test/actions.bats index 71115ac9bf..f72436d16a 100644 --- a/cli/test/actions.bats +++ b/cli/test/actions.bats @@ -195,6 +195,56 @@ load test_helper } +# delete --help + +@test "delete --help shows help" { + run fizzy --md delete --help + assert_success + assert_output_contains "fizzy delete" + assert_output_contains "delete" +} + +@test "delete -h shows help" { + run fizzy --md delete -h + assert_success + assert_output_contains "fizzy delete" +} + +@test "delete --help --json outputs JSON" { + run fizzy --json delete --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + +@test "delete --help shows warning" { + run fizzy --md delete --help + assert_success + assert_output_contains "cannot be undone" +} + + +# delete requires card number + +@test "delete without number shows error" { + run fizzy delete + assert_failure + assert_output_contains "Card number required" +} + +@test "delete requires authentication" { + run fizzy delete 123 + assert_failure + assert_output_contains "Not authenticated" +} + +@test "delete rejects unknown option" { + run fizzy delete --invalid + assert_failure + assert_output_contains "Unknown option" +} + + # triage --help @test "triage --help shows help" { From 4f0063b0fe7bf22743c0f62bd08efb2f09c98593 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 13:51:51 -0800 Subject: [PATCH 29/32] Add card image delete command fizzy delete-image removes the header image from a card. Uses DELETE /cards/:number/image (returns 204), then fetches the card to show updated state. --- cli/bin/fizzy | 1 + cli/completions/_fizzy | 1 + cli/completions/fizzy.bash | 2 +- cli/lib/commands/actions.sh | 98 +++++++++++++++++++++++++++++++++++++ cli/test/actions.bats | 44 +++++++++++++++++ 5 files changed, 145 insertions(+), 1 deletion(-) diff --git a/cli/bin/fizzy b/cli/bin/fizzy index df7e65d244..cb0eced9aa 100755 --- a/cli/bin/fizzy +++ b/cli/bin/fizzy @@ -78,6 +78,7 @@ main() { close) cmd_close "$@" ;; reopen) cmd_reopen "$@" ;; delete) cmd_delete "$@" ;; + delete-image) cmd_delete_image "$@" ;; triage) cmd_triage "$@" ;; untriage) cmd_untriage "$@" ;; postpone) cmd_postpone "$@" ;; diff --git a/cli/completions/_fizzy b/cli/completions/_fizzy index 616e7a617b..0929d84b66 100644 --- a/cli/completions/_fizzy +++ b/cli/completions/_fizzy @@ -23,6 +23,7 @@ _fizzy() { 'close:Close a card' 'reopen:Reopen a closed card' 'delete:Permanently delete a card' + 'delete-image:Remove header image from card' 'triage:Move card to a column' 'untriage:Move card back to triage' 'postpone:Move card to "not now"' diff --git a/cli/completions/fizzy.bash b/cli/completions/fizzy.bash index c6e5f55ba5..2caab0b362 100644 --- a/cli/completions/fizzy.bash +++ b/cli/completions/fizzy.bash @@ -8,7 +8,7 @@ _fizzy_completions() { local commands=" auth config help version boards cards columns comments notifications people search show tags - card update close reopen delete triage untriage postpone comment assign tag watch unwatch gild ungild step react + card update close reopen delete delete-image triage untriage postpone comment assign tag watch unwatch gild ungild step react " local auth_subcommands="login logout status refresh" diff --git a/cli/lib/commands/actions.sh b/cli/lib/commands/actions.sh index 2de131cd9b..1d4d9a6f6e 100644 --- a/cli/lib/commands/actions.sh +++ b/cli/lib/commands/actions.sh @@ -773,6 +773,104 @@ EOF } +# fizzy delete-image +# Remove header image from card + +cmd_delete_image() { + 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 delete-image --help" + ;; + *) + if [[ -z "$card_number" ]]; then + card_number="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _delete_image_help + return 0 + fi + + if [[ -z "$card_number" ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy delete-image " + 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 update $card_number" "Update card")" + ) + + output "$response" "$summary" "$breadcrumbs" "_delete_image_md" +} + +_delete_image_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" +} + +_delete_image_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy delete-image", + description: "Remove header image from a card", + usage: "fizzy delete-image ", + examples: ["fizzy delete-image 123"] + }' + else + cat <<'EOF' +## fizzy delete-image + +Remove header image from a card. + +### Usage + + fizzy delete-image + +### Examples + + fizzy delete-image 123 Remove header image from card #123 +EOF + fi +} + + # fizzy triage --to # Move card to a column diff --git a/cli/test/actions.bats b/cli/test/actions.bats index f72436d16a..1a4c5a3527 100644 --- a/cli/test/actions.bats +++ b/cli/test/actions.bats @@ -245,6 +245,50 @@ load test_helper } +# delete-image --help + +@test "delete-image --help shows help" { + run fizzy --md delete-image --help + assert_success + assert_output_contains "fizzy delete-image" + assert_output_contains "image" +} + +@test "delete-image -h shows help" { + run fizzy --md delete-image -h + assert_success + assert_output_contains "fizzy delete-image" +} + +@test "delete-image --help --json outputs JSON" { + run fizzy --json delete-image --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# delete-image requires card number + +@test "delete-image without number shows error" { + run fizzy delete-image + assert_failure + assert_output_contains "Card number required" +} + +@test "delete-image requires authentication" { + run fizzy delete-image 123 + assert_failure + assert_output_contains "Not authenticated" +} + +@test "delete-image rejects unknown option" { + run fizzy delete-image --invalid + assert_failure + assert_output_contains "Unknown option" +} + + # triage --help @test "triage --help shows help" { From 0a5655ca1b94b99683ad2816bbe0458253ea4ff3 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 13:53:22 -0800 Subject: [PATCH 30/32] Fix API request/response handling Card update: wrap params in {card: {...}} as Rails expects params[:card] Comment edit: Rails returns 204 No Content on PATCH, so fetch the updated comment after to get its current state for display. --- cli/lib/commands/actions.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cli/lib/commands/actions.sh b/cli/lib/commands/actions.sh index 1d4d9a6f6e..77a825f3f2 100644 --- a/cli/lib/commands/actions.sh +++ b/cli/lib/commands/actions.sh @@ -328,13 +328,13 @@ cmd_card_update() { die "Nothing to update. Specify --title or --description" $EXIT_USAGE fi - # Build request body + # Build request body - Rails expects params[:card] local body body=$(jq -n \ --arg title "$title" \ --arg description "$description" \ - '(if $title != "" then {title: $title} else {} end) + - (if $description != "" then {description: $description} else {} end)') + '{card: ((if $title != "" then {title: $title} else {} end) + + (if $description != "" then {description: $description} else {} end))}') local response response=$(api_patch "/cards/$card_number" "$body") @@ -1371,8 +1371,10 @@ _comment_edit() { 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_patch "/cards/$card_number/comments/$comment_id" "$body") + response=$(api_get "/cards/$card_number/comments/$comment_id") local summary="Comment updated on card #$card_number" From d18762f42edfa2ef0d2443da9fde9aca656368e5 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 14:05:39 -0800 Subject: [PATCH 31/32] Add steps and reactions parity Steps: - Add --completed flag to step create - Add step show/update/delete subcommands - Fix body wrapper to {step: {...}} as Rails expects Reactions: - Add fizzy reactions --card --comment to list reactions - Add fizzy react delete --card --comment - Update fizzy react to manage reactions (add + delete) All commands follow existing subcommand patterns (like comment edit/delete). Shell completions updated for new subcommands. --- cli/bin/fizzy | 1 + cli/completions/_fizzy | 19 +- cli/completions/fizzy.bash | 12 +- cli/lib/commands/actions.sh | 609 +++++++++++++++++++++++++++++++++-- cli/lib/commands/comments.sh | 133 ++++++++ cli/test/actions.bats | 175 +++++++++- 6 files changed, 923 insertions(+), 26 deletions(-) diff --git a/cli/bin/fizzy b/cli/bin/fizzy index cb0eced9aa..60f6db83c2 100755 --- a/cli/bin/fizzy +++ b/cli/bin/fizzy @@ -66,6 +66,7 @@ main() { cards) cmd_cards "$@" ;; columns) cmd_columns "$@" ;; comments) cmd_comments "$@" ;; + reactions) cmd_reactions "$@" ;; notifications) cmd_notifications "$@" ;; people) cmd_people "$@" ;; search) cmd_search "$@" ;; diff --git a/cli/completions/_fizzy b/cli/completions/_fizzy index 0929d84b66..ea317733bf 100644 --- a/cli/completions/_fizzy +++ b/cli/completions/_fizzy @@ -13,6 +13,7 @@ _fizzy() { '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' @@ -34,8 +35,8 @@ _fizzy() { 'unwatch:Unsubscribe from card' 'gild:Mark card as golden' 'ungild:Remove golden status' - 'step:Add a step (checklist item) to a card' - 'react:Add a reaction to a card or comment' + 'step:Manage steps (checklist items) on a card' + 'react:Manage reactions on comments' ) local -a auth_commands @@ -93,6 +94,20 @@ _fizzy() { ) _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 diff --git a/cli/completions/fizzy.bash b/cli/completions/fizzy.bash index 2caab0b362..4b821c4449 100644 --- a/cli/completions/fizzy.bash +++ b/cli/completions/fizzy.bash @@ -7,13 +7,15 @@ _fizzy_completions() { local commands=" auth config help version - boards cards columns comments notifications people search show tags + boards cards columns comments reactions notifications people search show tags card update close reopen delete delete-image triage untriage postpone comment assign tag watch unwatch gild ungild step react " local auth_subcommands="login logout status refresh" local config_subcommands="list get set unset path" local comment_subcommands="edit delete" + local step_subcommands="show update delete" + local react_subcommands="delete" case "$prev" in fizzy) @@ -32,6 +34,14 @@ _fizzy_completions() { COMPREPLY=($(compgen -W "$comment_subcommands" -- "$cur")) return ;; + step) + COMPREPLY=($(compgen -W "$step_subcommands" -- "$cur")) + return + ;; + react) + COMPREPLY=($(compgen -W "$react_subcommands" -- "$cur")) + return + ;; --board|-b|--in) # Could complete board names if cached, for now just return return diff --git a/cli/lib/commands/actions.sh b/cli/lib/commands/actions.sh index 77a825f3f2..c5888cf82f 100644 --- a/cli/lib/commands/actions.sh +++ b/cli/lib/commands/actions.sh @@ -2249,9 +2249,34 @@ EOF # 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 @@ -2263,6 +2288,10 @@ cmd_step() { card_number="$2" shift 2 ;; + --completed) + completed="true" + shift + ;; --help|-h) show_help=true shift @@ -2292,9 +2321,15 @@ cmd_step() { die "--on card number required" $EXIT_USAGE "Usage: fizzy step \"text\" --on " fi + # Build request body - Rails expects params[:step] local body - body=$(jq -n --arg content "$content" '{content: $content}') + 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") @@ -2335,38 +2370,438 @@ _step_help() { if [[ "$format" == "json" ]]; then jq -n '{ command: "fizzy step", - description: "Add step to a card", - usage: "fizzy step \"text\" --on ", - options: [{flag: "--on", description: "Card number to add step to"}], - examples: ["fizzy step \"Review PR\" --on 123"] + 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 -Add step (checklist item) to a card. +Manage steps (checklist items) on a card. ### Usage - fizzy step "text" --on + 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 +### Options (create) - --on Card number to add step to (required) + --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 "Review PR" --on 123 Add step to card #123 + 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') -# fizzy react "emoji" --comment --card -# Add reaction to comment + 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="" @@ -2434,13 +2869,139 @@ cmd_react() { local breadcrumbs breadcrumbs=$(breadcrumbs \ - "$(breadcrumb "comments" "fizzy comments --on $card_number" "View comments")" \ - "$(breadcrumb "show" "fizzy show $card_number" "View card")" + "$(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" @@ -2467,33 +3028,41 @@ _react_help() { if [[ "$format" == "json" ]]; then jq -n '{ command: "fizzy react", - description: "Add reaction to a comment", + 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"] + examples: [ + "fizzy react \"👍\" --card 123 --comment abc456", + "fizzy react delete xyz789 --card 123 --comment abc456" + ] }' else cat <<'EOF' ## fizzy react -Add reaction to a comment. +Manage reactions on comments. ### Usage - fizzy react "emoji" --card --comment + fizzy react "emoji" --card --comment Add reaction + fizzy react delete --card --comment Delete reaction ### Options --card Card number (required) - --comment Comment ID to react to (required) + --comment Comment ID (required) --help, -h Show this help ### Examples - fizzy react "👍" --card 123 --comment abc456 + 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/comments.sh b/cli/lib/commands/comments.sh index 7db1ebdb3b..4c039f748d 100644 --- a/cli/lib/commands/comments.sh +++ b/cli/lib/commands/comments.sh @@ -125,3 +125,136 @@ List comments on a card. EOF fi } + + +# fizzy reactions --card --comment +# List reactions on a comment + +cmd_reactions() { + local card_number="" + local comment_id="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --card) + if [[ -z "${2:-}" ]]; then + die "--card requires a card number" $EXIT_USAGE + fi + card_number="$2" + shift 2 + ;; + --comment) + if [[ -z "${2:-}" ]]; then + die "--comment requires a comment ID" $EXIT_USAGE + fi + comment_id="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy reactions --help" + ;; + *) + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _reactions_help + return 0 + fi + + if [[ -z "$card_number" ]]; then + die "--card number required" $EXIT_USAGE "Usage: fizzy reactions --card --comment " + fi + + if [[ -z "$comment_id" ]]; then + die "--comment ID required" $EXIT_USAGE "Usage: fizzy reactions --card --comment " + fi + + local response + response=$(api_get "/cards/$card_number/comments/$comment_id/reactions") + + local count + count=$(echo "$response" | jq 'length') + + local summary="$count reactions on comment" + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "react" "fizzy react \"👍\" --card $card_number --comment $comment_id" "Add reaction")" \ + "$(breadcrumb "comments" "fizzy comments --on $card_number" "View comments")" \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" + ) + + output "$response" "$summary" "$breadcrumbs" "_reactions_md" +} + +_reactions_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Reactions" + echo "*$summary*" + echo + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No reactions yet." + echo + else + echo "| ID | Emoji | By |" + echo "|----|-------|----|" + echo "$data" | jq -r '.[] | "| \(.id[0:12])... | \(.content) | \(.reacter.name) |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + +_reactions_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy reactions", + description: "List reactions on a comment", + usage: "fizzy reactions --card --comment ", + options: [ + {flag: "--card", description: "Card number"}, + {flag: "--comment", description: "Comment ID"} + ], + examples: [ + "fizzy reactions --card 123 --comment abc456" + ] + }' + else + cat <<'EOF' +## fizzy reactions + +List reactions on a comment. + +### Usage + + fizzy reactions --card --comment + +### Options + + --card Card number (required) + --comment Comment ID (required) + --help, -h Show this help + +### Examples + + fizzy reactions --card 123 --comment abc456 +EOF + fi +} diff --git a/cli/test/actions.bats b/cli/test/actions.bats index 1a4c5a3527..7062361901 100644 --- a/cli/test/actions.bats +++ b/cli/test/actions.bats @@ -797,7 +797,7 @@ load test_helper run fizzy --md step --help assert_success assert_output_contains "fizzy step" - assert_output_contains "Add step" + assert_output_contains "Manage steps" } @test "step -h shows help" { @@ -811,11 +811,11 @@ load test_helper assert_success is_valid_json assert_json_not_null ".command" - assert_json_not_null ".options" + assert_json_not_null ".subcommands" } -# step requires parameters +# step create requires parameters @test "step without text shows error" { run fizzy step @@ -836,6 +836,96 @@ load test_helper } +# step show + +@test "step show --help shows help" { + run fizzy --md step show --help + assert_success + assert_output_contains "fizzy step show" + assert_output_contains "Show step" +} + +@test "step show without id shows error" { + run fizzy step show + assert_failure + assert_output_contains "Step ID required" +} + +@test "step show without --on shows error" { + run fizzy step show abc123 + assert_failure + assert_output_contains "card number required" +} + +@test "step show requires authentication" { + run fizzy step show abc123 --on 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# step update + +@test "step update --help shows help" { + run fizzy --md step update --help + assert_success + assert_output_contains "fizzy step update" + assert_output_contains "Update a step" +} + +@test "step update without id shows error" { + run fizzy step update + assert_failure + assert_output_contains "Step ID required" +} + +@test "step update without --on shows error" { + run fizzy step update abc123 + assert_failure + assert_output_contains "card number required" +} + +@test "step update without changes shows error" { + run fizzy step update abc123 --on 123 + assert_failure + assert_output_contains "Nothing to update" +} + +@test "step update requires authentication" { + run fizzy step update abc123 --on 123 --completed + assert_failure + assert_output_contains "Not authenticated" +} + + +# step delete + +@test "step delete --help shows help" { + run fizzy --md step delete --help + assert_success + assert_output_contains "fizzy step delete" + assert_output_contains "Delete a step" +} + +@test "step delete without id shows error" { + run fizzy step delete + assert_failure + assert_output_contains "Step ID required" +} + +@test "step delete without --on shows error" { + run fizzy step delete abc123 + assert_failure + assert_output_contains "card number required" +} + +@test "step delete requires authentication" { + run fizzy step delete abc123 --on 123 + assert_failure + assert_output_contains "Not authenticated" +} + + # react --help @test "react --help shows help" { @@ -885,3 +975,82 @@ load test_helper assert_failure assert_output_contains "Not authenticated" } + + +# react delete + +@test "react delete --help shows help" { + run fizzy --md react delete --help + assert_success + assert_output_contains "fizzy react delete" + assert_output_contains "Delete a reaction" +} + +@test "react delete without id shows error" { + run fizzy react delete + assert_failure + assert_output_contains "Reaction ID required" +} + +@test "react delete without --card shows error" { + run fizzy react delete xyz789 + assert_failure + assert_output_contains "card" +} + +@test "react delete without --comment shows error" { + run fizzy react delete xyz789 --card 123 + assert_failure + assert_output_contains "comment" +} + +@test "react delete requires authentication" { + run fizzy react delete xyz789 --card 123 --comment abc456 + assert_failure + assert_output_contains "Not authenticated" +} + + +# reactions --help + +@test "reactions --help shows help" { + run fizzy --md reactions --help + assert_success + assert_output_contains "fizzy reactions" + assert_output_contains "List reactions" +} + +@test "reactions -h shows help" { + run fizzy --md reactions -h + assert_success + assert_output_contains "fizzy reactions" +} + +@test "reactions --help --json outputs JSON" { + run fizzy --json reactions --help + assert_success + is_valid_json + assert_json_not_null ".command" + assert_json_not_null ".options" +} + + +# reactions requires parameters + +@test "reactions without --card shows error" { + run fizzy reactions + assert_failure + assert_output_contains "card" +} + +@test "reactions without --comment shows error" { + run fizzy reactions --card 123 + assert_failure + assert_output_contains "comment" +} + +@test "reactions requires authentication" { + run fizzy reactions --card 123 --comment abc456 + assert_failure + assert_output_contains "Not authenticated" +} From b1f4a3dd04e2aa48c118929a9b157c8f453d02b7 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 13 Jan 2026 19:11:59 -0800 Subject: [PATCH 32/32] Add identity, users, and image upload support Identity & Users: - fizzy identity: show current user and accounts - fizzy user show/update/delete: manage users - User avatar upload via multipart form Card Images: - --image flag on card create and card update - Multipart upload with retry/backoff for 429/5xx - Use --form-string for non-file fields (prevents @-prefix hazard) Auth & Config: - FIZZY_URL convenience var (extracts base URL + account slug) - Allow re-login without logout - Improved token and scope handling Reliability: - api_multipart_request helper with retry logic - Regression test for --form-string usage 387 tests passing, smoke tested against live server. --- cli/PLAN.md | 2 +- cli/README.md | 41 +- cli/bin/fizzy | 11 +- cli/completions/_fizzy | 41 +- cli/completions/fizzy.bash | 30 +- cli/lib/api.sh | 66 +- cli/lib/auth.sh | 93 ++- cli/lib/commands/actions.sh | 348 ++++++++-- cli/lib/commands/boards.sh | 1304 +++++++++++++++++++++++++++++++++-- cli/lib/commands/help.sh | 29 +- cli/lib/commands/users.sh | 627 +++++++++++++++++ cli/lib/config.sh | 32 +- cli/lib/core.sh | 38 +- cli/test/actions.bats | 185 +++-- cli/test/auth.bats | 112 +++ cli/test/boards.bats | 86 +++ cli/test/config.bats | 6 +- cli/test/core.bats | 91 +++ cli/test/test_helper.bash | 3 +- cli/test/users.bats | 220 ++++++ 20 files changed, 3124 insertions(+), 241 deletions(-) create mode 100644 cli/lib/commands/users.sh create mode 100644 cli/test/users.bats diff --git a/cli/PLAN.md b/cli/PLAN.md index 3206e9c2ed..36d8964016 100644 --- a/cli/PLAN.md +++ b/cli/PLAN.md @@ -459,7 +459,7 @@ fizzy auth login --no-browser 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_ACCESS_TOKEN` env var or `--token` flag +3. Use via `FIZZY_TOKEN` environment variable ### Token File Security diff --git a/cli/README.md b/cli/README.md index 1f2a0d59ae..61df85582e 100644 --- a/cli/README.md +++ b/cli/README.md @@ -102,6 +102,7 @@ fizzy --md boards | `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 | @@ -112,21 +113,32 @@ fizzy --md boards | Command | Description | |---------|-------------| -| `card` | Create a new card | +| `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 a comment to a card | +| `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` | Add a step (checklist item) to a card | -| `react` | Add a reaction to a card or comment | +| `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 @@ -157,6 +169,18 @@ 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 ``` @@ -188,7 +212,14 @@ fizzy config path ## Environment -Point `fizzy` at different Fizzy instances using `FIZZY_BASE_URL`: +| 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) diff --git a/cli/bin/fizzy b/cli/bin/fizzy index 60f6db83c2..97307b93f4 100755 --- a/cli/bin/fizzy +++ b/cli/bin/fizzy @@ -74,12 +74,11 @@ main() { tags) cmd_tags "$@" ;; # Action commands (using Fizzy's ubiquitous language) - card) cmd_card_create "$@" ;; - update) cmd_card_update "$@" ;; + card) cmd_card "$@" ;; + board) cmd_board "$@" ;; + column) cmd_column "$@" ;; close) cmd_close "$@" ;; reopen) cmd_reopen "$@" ;; - delete) cmd_delete "$@" ;; - delete-image) cmd_delete_image "$@" ;; triage) cmd_triage "$@" ;; untriage) cmd_untriage "$@" ;; postpone) cmd_postpone "$@" ;; @@ -93,6 +92,10 @@ main() { step) cmd_step "$@" ;; react) cmd_react "$@" ;; + # Identity & users + identity) cmd_identity "$@" ;; + user) cmd_user "$@" ;; + # Auth & config auth) cmd_auth "$@" ;; config) cmd_config "$@" ;; diff --git a/cli/completions/_fizzy b/cli/completions/_fizzy index ea317733bf..cb957dfd01 100644 --- a/cli/completions/_fizzy +++ b/cli/completions/_fizzy @@ -19,12 +19,11 @@ _fizzy() { 'search:Search cards' 'show:Show card or board details' 'tags:List tags' - 'card:Create a new card' - 'update:Update a card' + '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' - 'delete:Permanently delete a card' - 'delete-image:Remove header image from card' 'triage:Move card to a column' 'untriage:Move card back to triage' 'postpone:Move card to "not now"' @@ -80,6 +79,40 @@ _fizzy() { 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' diff --git a/cli/completions/fizzy.bash b/cli/completions/fizzy.bash index 4b821c4449..35d1a2c411 100644 --- a/cli/completions/fizzy.bash +++ b/cli/completions/fizzy.bash @@ -8,14 +8,26 @@ _fizzy_completions() { local commands=" auth config help version boards cards columns comments reactions notifications people search show tags - card update close reopen delete delete-image triage untriage postpone comment assign tag watch unwatch gild ungild step react + 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) @@ -30,6 +42,18 @@ _fizzy_completions() { 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 @@ -42,6 +66,10 @@ _fizzy_completions() { 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 diff --git a/cli/lib/api.sh b/cli/lib/api.sh index 3a1bc9f518..f7f0a2313a 100644 --- a/cli/lib/api.sh +++ b/cli/lib/api.sh @@ -88,7 +88,7 @@ ensure_auth() { die "Not authenticated. Run: fizzy auth login" $EXIT_AUTH } - if is_token_expired && [[ -z "${FIZZY_ACCESS_TOKEN:-}" ]]; then + 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 @@ -350,7 +350,7 @@ _api_request() { ((attempt++)) ;; 401) - if [[ $attempt -eq 1 ]] && [[ -z "${FIZZY_ACCESS_TOKEN:-}" ]] && [[ -n "$token" ]]; then + if [[ $attempt -eq 1 ]] && [[ -z "${FIZZY_TOKEN:-}" ]] && [[ -n "$token" ]]; then debug "401 received, attempting token refresh" if refresh_token; then token=$(get_access_token) @@ -403,6 +403,68 @@ _api_request() { } +# 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() { diff --git a/cli/lib/auth.sh b/cli/lib/auth.sh index b044dca7e9..b877a99206 100644 --- a/cli/lib/auth.sh +++ b/cli/lib/auth.sh @@ -115,13 +115,6 @@ _auth_login() { info "Starting authentication..." - # Check for existing valid token - if get_access_token &>/dev/null && ! is_token_expired; then - info "Already authenticated. Use 'fizzy auth logout' first to re-authenticate." - _auth_status - return 0 - fi - # Pre-fetch OAuth config (avoids repeated discovery in subshells) _ensure_oauth_config @@ -196,12 +189,19 @@ _auth_login() { } _auth_logout() { - if get_access_token &>/dev/null; then + 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() { @@ -209,23 +209,34 @@ _auth_status() { format=$(get_format) local auth_status="unauthenticated" - local user_name="" + 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) - expires_at=$(echo "$creds" | jq -r '.expires_at // "null"') - scope=$(echo "$creds" | jq -r '.scope // empty') + 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 - # Determine token status based on expiration - if [[ "$expires_at" == "null" ]] || [[ "$expires_at" == "0" ]]; then + # 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" @@ -246,15 +257,19 @@ _auth_status() { 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) @@ -265,7 +280,15 @@ _auth_status() { echo "## Authentication Status" echo if [[ "$auth_status" == "authenticated" ]]; then - echo "Status: ✓ Authenticated" + 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 @@ -283,7 +306,7 @@ _auth_status() { echo "Status: ✗ Not authenticated" echo echo "Run: fizzy auth login" - echo " or export FIZZY_ACCESS_TOKEN=" + echo " or export FIZZY_TOKEN=" fi fi } @@ -294,6 +317,14 @@ _auth_refresh() { 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) @@ -705,6 +736,20 @@ _help_auth() { {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 @@ -727,9 +772,19 @@ Manage authentication. ### Environment Variables - FIZZY_ACCESS_TOKEN Personal access token (bypasses stored credentials) + 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 @@ -747,11 +802,11 @@ fizzy auth login --no-browser # Read-only access fizzy auth login --scope read -# For CI/scripts: use env vars instead -export FIZZY_ACCESS_TOKEN=fzt_... +# For CI/scripts: use env vars +export FIZZY_TOKEN=fzt_... export FIZZY_ACCOUNT_SLUG=897362094 -# Check status +# Check status (shows which auth method is active) fizzy auth status ``` EOF diff --git a/cli/lib/commands/actions.sh b/cli/lib/commands/actions.sh index c5888cf82f..74d07c02ec 100644 --- a/cli/lib/commands/actions.sh +++ b/cli/lib/commands/actions.sh @@ -2,14 +2,83 @@ # actions.sh - Card action commands (Phase 3) -# fizzy card "title" [options] -# Create a new card +# 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_create() { +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 @@ -37,6 +106,13 @@ cmd_card_create() { 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 @@ -80,6 +156,11 @@ cmd_card_create() { 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) @@ -119,16 +200,64 @@ cmd_card_create() { done assignee_ids=("${resolved_assignee_ids[@]}") - # Build request body (API only accepts title, description) - local body - body=$(jq -n \ - --arg title "$title" \ - --arg description "${description:-}" \ - '{title: $title} + - (if $description != "" then {description: $description} else {} end)') - local response - response=$(api_post "/boards/$board_id/cards" "$body") + + 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') @@ -212,6 +341,7 @@ _card_create_help() { {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)"} ], @@ -237,6 +367,7 @@ Create a new card with optional follow-up actions. --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 @@ -254,14 +385,15 @@ EOF } -# fizzy update [options] -# Update a card's title or description +# fizzy card update [options] +# Update a card's title, description, or image -cmd_card_update() { +_card_update() { local card_number="" local title="" local description="" local description_file="" + local image_path="" local show_help=false while [[ $# -gt 0 ]]; do @@ -287,12 +419,19 @@ cmd_card_update() { 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 update --help" + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy card update --help" ;; *) # First positional arg is card number @@ -312,7 +451,7 @@ cmd_card_update() { fi if [[ -z "$card_number" ]]; then - die "Card number required" $EXIT_USAGE "Usage: fizzy update [options]" + die "Card number required" $EXIT_USAGE "Usage: fizzy card update [options]" fi # Read description from file if specified @@ -323,21 +462,75 @@ cmd_card_update() { 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" ]]; then - die "Nothing to update. Specify --title or --description" $EXIT_USAGE + if [[ -z "$title" && -z "$description" && -z "$image_path" ]]; then + die "Nothing to update. Specify --title, --description, or --image" $EXIT_USAGE fi - # Build request body - Rails expects params[:card] - 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))}') + local response http_code - local response - response=$(api_patch "/cards/$card_number" "$body") + 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" @@ -376,43 +569,45 @@ _card_update_help() { if [[ "$format" == "json" ]]; then jq -n '{ - command: "fizzy update", - description: "Update a card'\''s title or description", - usage: "fizzy update [options]", + 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: "--description-file", description: "Read description from file"}, + {flag: "--image, -i", description: "Path to header image file"} ], examples: [ - "fizzy update 123 --title \"New title\"", - "fizzy update 123 --description \"

Updated content

\"", - "fizzy update 123 --title \"New\" --description \"Both\"" + "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 update +## fizzy card update -Update a card's title or description. +Update a card's title, description, or header image. ### Usage - fizzy update [options] + 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 update 123 --title "New title" - fizzy update 123 --description "

Updated content

" - fizzy update 123 --title "New" --description "Both" - fizzy update 123 --description-file notes.html + 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 } @@ -654,10 +849,10 @@ EOF } -# fizzy delete +# fizzy card delete # Permanently delete a card -cmd_delete() { +_card_delete() { local show_help=false local card_numbers=() @@ -668,7 +863,7 @@ cmd_delete() { shift ;; -*) - die "Unknown option: $1" $EXIT_USAGE "Run: fizzy delete --help" + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy card delete --help" ;; *) card_numbers+=("$1") @@ -678,16 +873,23 @@ cmd_delete() { done if [[ "$show_help" == "true" ]]; then - _delete_help + _card_delete_help return 0 fi if [[ ${#card_numbers[@]} -eq 0 ]]; then - die "Card number required" $EXIT_USAGE "Usage: fizzy delete " + die "Card number required" $EXIT_USAGE "Usage: fizzy card delete " fi - local results=() + # 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 @@ -714,10 +916,10 @@ cmd_delete() { "$(breadcrumb "card" "fizzy card \"title\" --in " "Create new card")" ) - output "$response_data" "$summary" "$breadcrumbs" "_delete_md" + output "$response_data" "$summary" "$breadcrumbs" "_card_delete_md" } -_delete_md() { +_card_delete_md() { local data="$1" local summary="$2" local breadcrumbs="$3" @@ -740,21 +942,21 @@ _delete_md() { md_breadcrumbs "$breadcrumbs" } -_delete_help() { +_card_delete_help() { local format format=$(get_format) if [[ "$format" == "json" ]]; then jq -n '{ - command: "fizzy delete", + command: "fizzy card delete", description: "Permanently delete card(s)", - usage: "fizzy delete [numbers...]", + usage: "fizzy card delete [numbers...]", warning: "This action cannot be undone", - examples: ["fizzy delete 123", "fizzy delete 123 124"] + examples: ["fizzy card delete 123", "fizzy card delete 123 124"] }' else cat <<'EOF' -## fizzy delete +## fizzy card delete Permanently delete card(s). @@ -762,21 +964,21 @@ Permanently delete card(s). ### Usage - fizzy delete [numbers...] + fizzy card delete [numbers...] ### Examples - fizzy delete 123 Delete card #123 - fizzy delete 123 124 Delete multiple cards + fizzy card delete 123 Delete card #123 + fizzy card delete 123 124 Delete multiple cards EOF fi } -# fizzy delete-image +# fizzy card image delete # Remove header image from card -cmd_delete_image() { +_card_image_delete() { local show_help=false local card_number="" @@ -787,7 +989,7 @@ cmd_delete_image() { shift ;; -*) - die "Unknown option: $1" $EXIT_USAGE "Run: fizzy delete-image --help" + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy card image delete --help" ;; *) if [[ -z "$card_number" ]]; then @@ -799,12 +1001,12 @@ cmd_delete_image() { done if [[ "$show_help" == "true" ]]; then - _delete_image_help + _card_image_delete_help return 0 fi if [[ -z "$card_number" ]]; then - die "Card number required" $EXIT_USAGE "Usage: fizzy delete-image " + die "Card number required" $EXIT_USAGE "Usage: fizzy card image delete " fi # DELETE returns 204 No Content, fetch card after for response @@ -817,13 +1019,13 @@ cmd_delete_image() { local breadcrumbs breadcrumbs=$(breadcrumbs \ "$(breadcrumb "show" "fizzy show $card_number" "View card")" \ - "$(breadcrumb "update" "fizzy update $card_number" "Update card")" + "$(breadcrumb "update" "fizzy card update $card_number" "Update card")" ) - output "$response" "$summary" "$breadcrumbs" "_delete_image_md" + output "$response" "$summary" "$breadcrumbs" "_card_image_delete_md" } -_delete_image_md() { +_card_image_delete_md() { local data="$1" local summary="$2" local breadcrumbs="$3" @@ -842,30 +1044,30 @@ _delete_image_md() { md_breadcrumbs "$breadcrumbs" } -_delete_image_help() { +_card_image_delete_help() { local format format=$(get_format) if [[ "$format" == "json" ]]; then jq -n '{ - command: "fizzy delete-image", + command: "fizzy card image delete", description: "Remove header image from a card", - usage: "fizzy delete-image ", - examples: ["fizzy delete-image 123"] + usage: "fizzy card image delete ", + examples: ["fizzy card image delete 123"] }' else cat <<'EOF' -## fizzy delete-image +## fizzy card image delete Remove header image from a card. ### Usage - fizzy delete-image + fizzy card image delete ### Examples - fizzy delete-image 123 Remove header image from card #123 + fizzy card image delete 123 Remove header image from card #123 EOF fi } diff --git a/cli/lib/commands/boards.sh b/cli/lib/commands/boards.sh index 0baa5254d9..c392b1d3d6 100644 --- a/cli/lib/commands/boards.sh +++ b/cli/lib/commands/boards.sh @@ -146,129 +146,1325 @@ EOF } +# fizzy board +# Manage boards (create, update, delete, show) + +cmd_board() { + case "${1:-}" in + create) + shift + _board_create "$@" + ;; + update) + shift + _board_update "$@" + ;; + delete) + shift + _board_delete "$@" + ;; + show) + shift + _board_show "$@" + ;; + --help|-h|"") + _board_help + ;; + *) + die "Unknown subcommand: board ${1:-}" $EXIT_USAGE \ + "Available: fizzy board create|update|delete|show" + ;; + esac +} + +_board_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy board", + description: "Manage boards", + subcommands: [ + {name: "create", description: "Create a new board"}, + {name: "update", description: "Update a board"}, + {name: "delete", description: "Delete a board"}, + {name: "show", description: "Show board details"} + ], + examples: [ + "fizzy board create \"New Board\"", + "fizzy board update abc123 --name \"Renamed\"", + "fizzy board delete abc123", + "fizzy board show abc123" + ] + }' + else + cat <<'EOF' +## fizzy board + +Manage boards. + +### Subcommands + + create Create a new board + update Update a board + delete Delete a board + show Show board details + +### Examples + + fizzy board create "New Board" + fizzy board update abc123 --name "Renamed" + fizzy board delete abc123 + fizzy board show abc123 +EOF + fi +} + +_board_create() { + local name="" + local all_access="" + local all_access_flag=false + local selective_flag=false + local auto_postpone="" + local public_description="" + local public_description_file="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --name) + if [[ -z "${2:-}" ]]; then + die "--name requires a value" $EXIT_USAGE + fi + name="$2" + shift 2 + ;; + --all-access) + all_access="true" + all_access_flag=true + shift + ;; + --selective) + all_access="false" + selective_flag=true + shift + ;; + --auto-postpone|--auto-postpone-period) + if [[ -z "${2:-}" ]]; then + die "--auto-postpone requires a value" $EXIT_USAGE + fi + if ! [[ "$2" =~ ^[0-9]+$ ]]; then + die "--auto-postpone must be a non-negative integer" $EXIT_USAGE + fi + auto_postpone="$2" + shift 2 + ;; + --public-description) + if [[ -z "${2:-}" ]]; then + die "--public-description requires text" $EXIT_USAGE + fi + public_description="$2" + shift 2 + ;; + --public-description-file) + if [[ -z "${2:-}" ]]; then + die "--public-description-file requires a file path" $EXIT_USAGE + fi + public_description_file="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy board create --help" + ;; + *) + if [[ -z "$name" ]]; then + name="$1" + shift + else + die "Unexpected argument: $1" $EXIT_USAGE + fi + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _board_create_help + return 0 + fi + + if [[ -n "$public_description_file" ]]; then + if [[ ! -f "$public_description_file" ]]; then + die "File not found: $public_description_file" $EXIT_USAGE + fi + public_description=$(cat "$public_description_file") + fi + + if [[ -z "$name" ]]; then + die "Board name required" $EXIT_USAGE "Usage: fizzy board create \"name\"" + fi + + if [[ "$all_access_flag" == "true" && "$selective_flag" == "true" ]]; then + die "Cannot use --all-access and --selective together" $EXIT_USAGE + fi + + local board_payload + board_payload=$(jq -n \ + --arg name "$name" \ + --arg auto_postpone "$auto_postpone" \ + --arg public_description "$public_description" \ + --arg all_access "$all_access" \ + '( + (if $name != "" then {name: $name} else {} end) + + (if $auto_postpone != "" then {auto_postpone_period: ($auto_postpone | tonumber)} else {} end) + + (if $public_description != "" then {public_description: $public_description} else {} end) + + (if $all_access != "" then {all_access: ($all_access == "true")} else {} end) + )') + + local body + body=$(jq -n --argjson board "$board_payload" '{board: $board}') + + local response + response=$(api_post "/boards" "$body") + + local summary="Created board \"$name\"" + + local board_id + board_id=$(echo "$response" | jq -r '.id') + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show board $board_id" "View board details")" \ + "$(breadcrumb "cards" "fizzy cards --board $board_id" "List cards")" \ + "$(breadcrumb "columns" "fizzy columns --board $board_id" "List columns")" + ) + + output "$response" "$summary" "$breadcrumbs" "_show_board_md" +} + +_board_create_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy board create", + description: "Create a new board", + usage: "fizzy board create \"name\" [options]", + options: [ + {flag: "--name", description: "Board name (positional allowed)"}, + {flag: "--all-access", description: "All users can access (default)"}, + {flag: "--selective", description: "Restrict board access to selected users"}, + {flag: "--auto-postpone", description: "Days before cards auto-postpone"}, + {flag: "--public-description", description: "Public description (HTML)"}, + {flag: "--public-description-file", description: "Read public description from file"} + ], + examples: [ + "fizzy board create \"New Board\"", + "fizzy board create \"Client\" --selective", + "fizzy board create \"Launch\" --auto-postpone 14" + ] + }' + else + cat <<'EOF' +## fizzy board create + +Create a new board. + +### Usage + + fizzy board create "name" [options] + +### Options + + --name Board name (positional allowed) + --all-access All users can access (default) + --selective Restrict access to selected users + --auto-postpone Days before cards auto-postpone + --public-description Public description (HTML) + --public-description-file Read public description from file + --help, -h Show this help + +### Examples + + fizzy board create "New Board" + fizzy board create "Client" --selective + fizzy board create "Launch" --auto-postpone 14 +EOF + fi +} + +_board_update() { + local board_id="" + local name="" + local all_access="" + local all_access_flag=false + local selective_flag=false + local auto_postpone="" + local public_description="" + local public_description_file="" + local user_inputs=() + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --name) + if [[ -z "${2:-}" ]]; then + die "--name requires a value" $EXIT_USAGE + fi + name="$2" + shift 2 + ;; + --all-access) + all_access="true" + all_access_flag=true + shift + ;; + --selective) + all_access="false" + selective_flag=true + shift + ;; + --auto-postpone|--auto-postpone-period) + if [[ -z "${2:-}" ]]; then + die "--auto-postpone requires a value" $EXIT_USAGE + fi + if ! [[ "$2" =~ ^[0-9]+$ ]]; then + die "--auto-postpone must be a non-negative integer" $EXIT_USAGE + fi + auto_postpone="$2" + shift 2 + ;; + --public-description) + if [[ -z "${2:-}" ]]; then + die "--public-description requires text" $EXIT_USAGE + fi + public_description="$2" + shift 2 + ;; + --public-description-file) + if [[ -z "${2:-}" ]]; then + die "--public-description-file requires a file path" $EXIT_USAGE + fi + public_description_file="$2" + shift 2 + ;; + --user) + if [[ -z "${2:-}" ]]; then + die "--user requires a name, email, or ID" $EXIT_USAGE + fi + user_inputs+=("$2") + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy board update --help" + ;; + *) + if [[ -z "$board_id" ]]; then + board_id="$1" + shift + else + die "Unexpected argument: $1" $EXIT_USAGE + fi + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _board_update_help + return 0 + fi + + if [[ -z "$board_id" ]]; then + die "Board ID required" $EXIT_USAGE "Usage: fizzy board update [options]" + fi + + if [[ -n "$public_description_file" ]]; then + if [[ ! -f "$public_description_file" ]]; then + die "File not found: $public_description_file" $EXIT_USAGE + fi + public_description=$(cat "$public_description_file") + fi + + if [[ "$all_access_flag" == "true" && "$selective_flag" == "true" ]]; then + die "Cannot use --all-access and --selective together" $EXIT_USAGE + fi + + if [[ ${#user_inputs[@]} -gt 0 ]]; then + if [[ "$all_access" == "true" ]]; then + die "--user cannot be combined with --all-access" $EXIT_USAGE + fi + if [[ -z "$all_access" ]]; then + all_access="false" + fi + fi + + # Resolve board ID (accepts name) + 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 user IDs + local user_ids=() + local input + for input in "${user_inputs[@]}"; do + local resolved_user + if resolved_user=$(resolve_user_id "$input"); then + user_ids+=("$resolved_user") + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy people" + fi + done + + local board_payload + board_payload=$(jq -n \ + --arg name "$name" \ + --arg auto_postpone "$auto_postpone" \ + --arg public_description "$public_description" \ + --arg all_access "$all_access" \ + '( + (if $name != "" then {name: $name} else {} end) + + (if $auto_postpone != "" then {auto_postpone_period: ($auto_postpone | tonumber)} else {} end) + + (if $public_description != "" then {public_description: $public_description} else {} end) + + (if $all_access != "" then {all_access: ($all_access == "true")} else {} end) + )') + + local user_ids_json="null" + if [[ ${#user_ids[@]} -gt 0 ]]; then + user_ids_json=$(printf '%s\n' "${user_ids[@]}" | jq -R . | jq -s '.') + fi + + if [[ "$board_payload" == "{}" && "$user_ids_json" == "null" ]]; then + die "Nothing to update. Specify changes" $EXIT_USAGE + fi + + local body + body=$(jq -n \ + --argjson board "$board_payload" \ + --argjson user_ids "$user_ids_json" \ + '( + (if ($board | length) > 0 then {board: $board} else {} end) + + (if $user_ids != null then {user_ids: $user_ids} else {} end) + )') + + # PATCH returns 204 No Content + api_patch "/boards/$board_id" "$body" > /dev/null + local response + response=$(api_get "/boards/$board_id") + + local summary="Board updated" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show board $board_id" "View board details")" \ + "$(breadcrumb "cards" "fizzy cards --board $board_id" "List cards")" + ) + + output "$response" "$summary" "$breadcrumbs" "_show_board_md" +} + +_board_update_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy board update", + description: "Update a board", + usage: "fizzy board update [options]", + options: [ + {flag: "--name", description: "New board name"}, + {flag: "--all-access", description: "All users can access"}, + {flag: "--selective", description: "Restrict access to selected users"}, + {flag: "--auto-postpone", description: "Days before cards auto-postpone"}, + {flag: "--public-description", description: "Public description (HTML)"}, + {flag: "--public-description-file", description: "Read public description from file"}, + {flag: "--user", description: "User name/email/ID (repeatable)"} + ], + examples: [ + "fizzy board update abc123 --name \"Renamed\"", + "fizzy board update abc123 --auto-postpone 14", + "fizzy board update abc123 --selective --user \"Jane Doe\"" + ] + }' + else + cat <<'EOF' +## fizzy board update + +Update a board. + +### Usage + + fizzy board update [options] + +### Options + + --name New board name + --all-access All users can access + --selective Restrict access to selected users + --auto-postpone Days before cards auto-postpone + --public-description Public description (HTML) + --public-description-file Read public description from file + --user User name/email/ID (repeatable) + --help, -h Show this help + +### Examples + + fizzy board update abc123 --name "Renamed" + fizzy board update abc123 --auto-postpone 14 + fizzy board update abc123 --selective --user "Jane Doe" +EOF + fi +} + +_board_delete() { + local board_id="" + 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 board delete --help" + ;; + *) + if [[ -z "$board_id" ]]; then + board_id="$1" + shift + else + die "Unexpected argument: $1" $EXIT_USAGE + fi + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _board_delete_help + return 0 + fi + + if [[ -z "$board_id" ]]; then + die "Board ID required" $EXIT_USAGE "Usage: fizzy board delete " + fi + + 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 + + # DELETE returns 204 No Content + api_delete "/boards/$board_id" > /dev/null + + local response + response=$(jq -n --arg id "$board_id" '{id: $id, deleted: true}') + + local summary="Board deleted" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "boards" "fizzy boards" "List boards")" \ + "$(breadcrumb "board" "fizzy board create \"name\"" "Create board")" + ) + + output "$response" "$summary" "$breadcrumbs" "_board_deleted_md" +} + +_board_deleted_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local board_id + board_id=$(echo "$data" | jq -r '.id') + + md_heading 2 "Board Deleted" + echo + md_kv "ID" "$board_id" \ + "Status" "Deleted" + + md_breadcrumbs "$breadcrumbs" +} + +_board_delete_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy board delete", + description: "Delete a board", + usage: "fizzy board delete ", + warning: "This action cannot be undone", + examples: ["fizzy board delete abc123"] + }' + else + cat <<'EOF' +## fizzy board delete + +Delete a board. + +**Warning:** This action cannot be undone. + +### Usage + + fizzy board delete + +### Examples + + fizzy board delete abc123 +EOF + fi +} + +_board_show() { + local board_id="${1:-}" + + if [[ -z "$board_id" ]]; then + die "Board ID required" $EXIT_USAGE "Usage: fizzy board show " + fi + + 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 + + show_board "$board_id" +} + + # fizzy columns [options] # List columns on a board -cmd_columns() { +cmd_columns() { + local board_id="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --board|-b|--in) + if [[ -z "${2:-}" ]]; then + die "--board requires a value" $EXIT_USAGE + fi + board_id="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + *) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy columns --help" + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _columns_help + return 0 + fi + + # Use provided board_id or fall back to config + if [[ -z "$board_id" ]]; then + board_id=$(get_board_id) + fi + + if [[ -z "$board_id" ]]; then + die "No board specified" $EXIT_USAGE \ + "Use: fizzy columns --board " + fi + + # Resolve board name to ID if needed + 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 + + local response + response=$(api_get "/boards/$board_id/columns") + + local count + count=$(echo "$response" | jq 'length') + + local summary="$count columns" + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "triage" "fizzy triage --to " "Move card to column")" \ + "$(breadcrumb "cards" "fizzy cards --board $board_id" "List cards on board")" + ) + + output "$response" "$summary" "$breadcrumbs" "_columns_md" +} + +_columns_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Columns ($summary)" + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No columns found." + echo + else + echo "| ID | Name | Color |" + echo "|----|------|-------|" + echo "$data" | jq -r '.[] | "| \(.id) | \(.name) | \(.color // "default") |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + +_columns_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy columns", + description: "List columns on a board", + options: [ + {flag: "--board, -b, --in", description: "Board name or ID (required unless set in config)"} + ], + examples: [ + "fizzy columns --board \"My Board\"", + "fizzy columns -b abc123 --json" + ] + }' + else + cat <<'EOF' +## fizzy columns + +List columns on a board. + +### Usage + + fizzy columns [options] + +### Options + + --board, -b, --in Board name or ID (required unless set in config) + --help, -h Show this help + +### Examples + + fizzy columns --board "My Board" List columns on board + fizzy columns List columns on default board +EOF + fi +} + + +# fizzy column +# Manage columns (create, update, delete, show) + +cmd_column() { + case "${1:-}" in + create) + shift + _column_create "$@" + ;; + update) + shift + _column_update "$@" + ;; + delete) + shift + _column_delete "$@" + ;; + show) + shift + _column_show "$@" + ;; + --help|-h|"") + _column_help + ;; + *) + die "Unknown subcommand: column ${1:-}" $EXIT_USAGE \ + "Available: fizzy column create|update|delete|show" + ;; + esac +} + +_column_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy column", + description: "Manage columns on a board", + subcommands: [ + {name: "create", description: "Create a column"}, + {name: "update", description: "Update a column"}, + {name: "delete", description: "Delete a column"}, + {name: "show", description: "Show column details"} + ], + examples: [ + "fizzy column create \"In Progress\" --board \"My Board\"", + "fizzy column update abc123 --board \"My Board\" --name \"Done\"", + "fizzy column delete abc123 --board \"My Board\"", + "fizzy column show abc123 --board \"My Board\"" + ] + }' + else + cat <<'EOF' +## fizzy column + +Manage columns on a board. + +### Subcommands + + create Create a column + update Update a column + delete Delete a column + show Show column details + +### Examples + + fizzy column create "In Progress" --board "My Board" + fizzy column update abc123 --board "My Board" --name "Done" + fizzy column delete abc123 --board "My Board" + fizzy column show abc123 --board "My Board" +EOF + fi +} + +_column_resolve_board() { + local board_input="$1" + + if [[ -z "$board_input" ]]; then + board_input=$(get_board_id 2>/dev/null || true) + fi + + if [[ -z "$board_input" ]]; then + die "No board specified" $EXIT_USAGE "Use: fizzy column --board " + fi + + local resolved_board + if resolved_board=$(resolve_board_id "$board_input"); then + echo "$resolved_board" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy boards" + fi +} + +_column_create() { + local name="" + local color="" + local board_id="" + 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 + ;; + --name) + if [[ -z "${2:-}" ]]; then + die "--name requires a value" $EXIT_USAGE + fi + name="$2" + shift 2 + ;; + --color) + if [[ -z "${2:-}" ]]; then + die "--color requires a value" $EXIT_USAGE + fi + color="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy column create --help" + ;; + *) + if [[ -z "$name" ]]; then + name="$1" + shift + else + die "Unexpected argument: $1" $EXIT_USAGE + fi + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _column_create_help + return 0 + fi + + if [[ -z "$name" ]]; then + die "Column name required" $EXIT_USAGE "Usage: fizzy column create \"name\" --board " + fi + + board_id=$(_column_resolve_board "$board_id") + + local column_payload + column_payload=$(jq -n \ + --arg name "$name" \ + --arg color "$color" \ + '( + (if $name != "" then {name: $name} else {} end) + + (if $color != "" then {color: $color} else {} end) + )') + + local body + body=$(jq -n --argjson column "$column_payload" '{column: $column}') + + local response + response=$(api_post "/boards/$board_id/columns" "$body") + + local summary="Created column \"$name\"" + + local column_id + column_id=$(echo "$response" | jq -r '.id') + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "columns" "fizzy columns --board $board_id" "List columns")" \ + "$(breadcrumb "show" "fizzy column show $column_id --board $board_id" "View column")" + ) + + output "$response" "$summary" "$breadcrumbs" "_column_md" +} + +_column_create_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy column create", + description: "Create a column on a board", + usage: "fizzy column create \"name\" --board [options]", + options: [ + {flag: "--board, -b, --in", description: "Board name or ID"}, + {flag: "--name", description: "Column name (positional allowed)"}, + {flag: "--color", description: "Column color CSS variable"} + ], + examples: [ + "fizzy column create \"In Progress\" --board \"My Board\"", + "fizzy column create \"Done\" --board abc123 --color \"var(--color-card-4)\"" + ] + }' + else + cat <<'EOF' +## fizzy column create + +Create a column on a board. + +### Usage + + fizzy column create "name" --board [options] + +### Options + + --board, -b, --in Board name or ID + --name Column name (positional allowed) + --color Column color CSS variable + --help, -h Show this help + +### Examples + + fizzy column create "In Progress" --board "My Board" + fizzy column create "Done" --board abc123 --color "var(--color-card-4)" +EOF + fi +} + +_column_update() { + local column_id="" local board_id="" + local name="" + local color="" local show_help=false while [[ $# -gt 0 ]]; do case "$1" in --board|-b|--in) if [[ -z "${2:-}" ]]; then - die "--board requires a value" $EXIT_USAGE + die "--board requires a board name or ID" $EXIT_USAGE fi board_id="$2" shift 2 ;; + --name) + if [[ -z "${2:-}" ]]; then + die "--name requires a value" $EXIT_USAGE + fi + name="$2" + shift 2 + ;; + --color) + if [[ -z "${2:-}" ]]; then + die "--color requires a value" $EXIT_USAGE + fi + color="$2" + shift 2 + ;; --help|-h) show_help=true shift ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy column update --help" + ;; *) - die "Unknown option: $1" $EXIT_USAGE "Run: fizzy columns --help" + if [[ -z "$column_id" ]]; then + column_id="$1" + shift + else + die "Unexpected argument: $1" $EXIT_USAGE + fi ;; esac done if [[ "$show_help" == "true" ]]; then - _columns_help + _column_update_help return 0 fi - # Use provided board_id or fall back to config - if [[ -z "$board_id" ]]; then - board_id=$(get_board_id) + if [[ -z "$column_id" ]]; then + die "Column ID required" $EXIT_USAGE "Usage: fizzy column update --board [options]" fi - if [[ -z "$board_id" ]]; then - die "No board specified" $EXIT_USAGE \ - "Use: fizzy columns --board " + board_id=$(_column_resolve_board "$board_id") + + if [[ -z "$name" && -z "$color" ]]; then + die "Nothing to update. Specify --name or --color" $EXIT_USAGE fi - # Resolve board name to ID if needed - local resolved_board - if resolved_board=$(resolve_board_id "$board_id"); then - board_id="$resolved_board" + 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 boards" + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy columns --board $board_id" fi + local column_payload + column_payload=$(jq -n \ + --arg name "$name" \ + --arg color "$color" \ + '( + (if $name != "" then {name: $name} else {} end) + + (if $color != "" then {color: $color} else {} end) + )') + + local body + body=$(jq -n --argjson column "$column_payload" '{column: $column}') + + # PATCH returns 204 No Content + api_patch "/boards/$board_id/columns/$column_id" "$body" > /dev/null local response - response=$(api_get "/boards/$board_id/columns") + response=$(api_get "/boards/$board_id/columns/$column_id") - local count - count=$(echo "$response" | jq 'length') + local summary="Column updated" - local summary="$count columns" local breadcrumbs breadcrumbs=$(breadcrumbs \ - "$(breadcrumb "triage" "fizzy triage --to " "Move card to column")" \ - "$(breadcrumb "cards" "fizzy cards --board $board_id" "List cards on board")" + "$(breadcrumb "columns" "fizzy columns --board $board_id" "List columns")" \ + "$(breadcrumb "show" "fizzy column show $column_id --board $board_id" "View column")" ) - output "$response" "$summary" "$breadcrumbs" "_columns_md" + output "$response" "$summary" "$breadcrumbs" "_column_md" } -_columns_md() { - local data="$1" - local summary="$2" - local breadcrumbs="$3" +_column_update_help() { + local format + format=$(get_format) - md_heading 2 "Columns ($summary)" + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy column update", + description: "Update a column", + usage: "fizzy column update --board [options]", + options: [ + {flag: "--board, -b, --in", description: "Board name or ID"}, + {flag: "--name", description: "New column name"}, + {flag: "--color", description: "New column color"} + ], + examples: [ + "fizzy column update abc123 --board \"My Board\" --name \"Done\"", + "fizzy column update abc123 --board \"My Board\" --color \"var(--color-card-4)\"" + ] + }' + else + cat <<'EOF' +## fizzy column update - local count - count=$(echo "$data" | jq 'length') +Update a column. - if [[ "$count" -eq 0 ]]; then - echo "No columns found." - echo +### Usage + + fizzy column update --board [options] + +### Options + + --board, -b, --in Board name or ID + --name New column name + --color New column color + --help, -h Show this help + +### Examples + + fizzy column update abc123 --board "My Board" --name "Done" + fizzy column update abc123 --board "My Board" --color "var(--color-card-4)" +EOF + fi +} + +_column_delete() { + local column_id="" + local board_id="" + 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 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy column delete --help" + ;; + *) + if [[ -z "$column_id" ]]; then + column_id="$1" + shift + else + die "Unexpected argument: $1" $EXIT_USAGE + fi + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _column_delete_help + return 0 + fi + + if [[ -z "$column_id" ]]; then + die "Column ID required" $EXIT_USAGE "Usage: fizzy column delete --board " + fi + + board_id=$(_column_resolve_board "$board_id") + + local resolved_column + if resolved_column=$(resolve_column_id "$column_id" "$board_id"); then + column_id="$resolved_column" else - echo "| ID | Name | Color |" - echo "|----|------|-------|" - echo "$data" | jq -r '.[] | "| \(.id) | \(.name) | \(.color // "default") |"' - echo + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy columns --board $board_id" fi + # DELETE returns 204 No Content + api_delete "/boards/$board_id/columns/$column_id" > /dev/null + + local response + response=$(jq -n --arg id "$column_id" '{id: $id, deleted: true}') + + local summary="Column deleted" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "columns" "fizzy columns --board $board_id" "List columns")" \ + "$(breadcrumb "column" "fizzy column create \"name\" --board $board_id" "Create column")" + ) + + output "$response" "$summary" "$breadcrumbs" "_column_deleted_md" +} + +_column_deleted_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local column_id + column_id=$(echo "$data" | jq -r '.id') + + md_heading 2 "Column Deleted" + echo + md_kv "ID" "$column_id" \ + "Status" "Deleted" + md_breadcrumbs "$breadcrumbs" } -_columns_help() { +_column_delete_help() { local format format=$(get_format) if [[ "$format" == "json" ]]; then jq -n '{ - command: "fizzy columns", - description: "List columns on a board", - options: [ - {flag: "--board, -b, --in", description: "Board name or ID (required unless set in config)"} - ], - examples: [ - "fizzy columns --board \"My Board\"", - "fizzy columns -b abc123 --json" - ] + command: "fizzy column delete", + description: "Delete a column", + usage: "fizzy column delete --board ", + examples: ["fizzy column delete abc123 --board \"My Board\""] }' else cat <<'EOF' -## fizzy columns +## fizzy column delete -List columns on a board. +Delete a column. ### Usage - fizzy columns [options] + fizzy column delete --board -### Options +### Examples - --board, -b, --in Board name or ID (required unless set in config) - --help, -h Show this help + fizzy column delete abc123 --board "My Board" +EOF + fi +} + +_column_show() { + local column_id="" + local board_id="" + 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 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy column show --help" + ;; + *) + if [[ -z "$column_id" ]]; then + column_id="$1" + shift + else + die "Unexpected argument: $1" $EXIT_USAGE + fi + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _column_show_help + return 0 + fi + + if [[ -z "$column_id" ]]; then + die "Column ID required" $EXIT_USAGE "Usage: fizzy column show --board " + fi + + board_id=$(_column_resolve_board "$board_id") + + 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 + + local response + response=$(api_get "/boards/$board_id/columns/$column_id") + + local summary="Column on board" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "columns" "fizzy columns --board $board_id" "List columns")" \ + "$(breadcrumb "update" "fizzy column update $column_id --board $board_id" "Update column")" \ + "$(breadcrumb "delete" "fizzy column delete $column_id --board $board_id" "Delete column")" + ) + + output "$response" "$summary" "$breadcrumbs" "_column_md" +} + +_column_show_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy column show", + description: "Show column details", + usage: "fizzy column show --board ", + examples: ["fizzy column show abc123 --board \"My Board\""] + }' + else + cat <<'EOF' +## fizzy column show + +Show column details. + +### Usage + + fizzy column show --board ### Examples - fizzy columns --board "My Board" List columns on board - fizzy columns List columns on default board + fizzy column show abc123 --board "My Board" EOF fi } + +_column_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local column_id name color created_at + column_id=$(echo "$data" | jq -r '.id') + name=$(echo "$data" | jq -r '.name') + color=$(echo "$data" | jq -r '.color // empty') + created_at=$(echo "$data" | jq -r '.created_at | split("T")[0]') + + md_heading 2 "Column: $name" + echo + md_kv "ID" "$column_id" \ + "Color" "${color:-"(default)"}" \ + "Created" "$created_at" + + md_breadcrumbs "$breadcrumbs" +} diff --git a/cli/lib/commands/help.sh b/cli/lib/commands/help.sh index f0f9f991f9..1d5f2f17d3 100644 --- a/cli/lib/commands/help.sh +++ b/cli/lib/commands/help.sh @@ -40,13 +40,18 @@ _help_topic() { config) _help_config ;; cards) _help_cards ;; boards) _help_boards ;; + board) _board_help ;; + column) _column_help ;; + columns) _columns_help ;; + identity) _identity_help ;; + user) _user_help ;; *) if [[ "$format" == "json" ]]; then json_error "Unknown help topic: $topic" "not_found" else echo "Unknown help topic: $topic" echo - echo "Available topics: auth, config, cards, boards" + echo "Available topics: auth, config, cards, boards, board, column, columns, identity, user" fi exit 1 ;; @@ -61,8 +66,9 @@ _help_json() { version: $version, description: "Agent-first CLI for Fizzy", commands: { - query: ["boards", "cards", "columns", "comments", "notifications", "people", "search", "show", "tags"], - actions: ["card", "close", "reopen", "triage", "untriage", "postpone", "comment", "assign", "tag", "watch", "unwatch", "gild", "ungild", "step", "react"], + query: ["boards", "cards", "columns", "comments", "reactions", "notifications", "people", "search", "show", "tags"], + actions: ["card", "board", "column", "close", "reopen", "triage", "untriage", "postpone", "comment", "assign", "tag", "watch", "unwatch", "gild", "ungild", "step", "react"], + identity: ["identity", "user"], meta: ["auth", "config", "version", "help"] }, global_flags: [ @@ -102,6 +108,7 @@ Agent-first CLI for Fizzy. 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 @@ -109,21 +116,27 @@ Agent-first CLI for Fizzy. tags List tags ### Actions - card Create a new card + card Manage cards (create/update/delete/image) + board Manage boards (create/update/delete/show) + column Manage columns (create/update/delete/show) close Close a card reopen Reopen a closed card triage Move card into a column untriage Send card back to triage postpone Move card to "Not Now" - comment Add comment to a card + comment Manage comments (add/edit/delete) assign Assign/unassign user to card tag Toggle tag on card watch Subscribe to card notifications unwatch Unsubscribe from card gild Mark card as golden ungild Remove golden status - step Manage card steps - react Add reaction to comment + step Manage steps (add/show/update/delete) + react Manage reactions (add/delete) + +### Identity + identity Show current identity and accounts + user Manage users (show/update/delete) ### Meta auth Authentication management @@ -138,7 +151,7 @@ Agent-first CLI for Fizzy. fizzy show 42 Show card #42 fizzy card "New feature" Create a new card fizzy close 42 Close card #42 - fizzy comment 42 "LGTM" Comment on card #42 + fizzy comment "LGTM" --on 42 Comment on card #42 ## CONFIGURATION diff --git a/cli/lib/commands/users.sh b/cli/lib/commands/users.sh new file mode 100644 index 0000000000..1f6f6d361c --- /dev/null +++ b/cli/lib/commands/users.sh @@ -0,0 +1,627 @@ +#!/usr/bin/env bash +# users.sh - Identity and user management commands + + +# fizzy identity +# Show current identity and associated accounts + +cmd_identity() { + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + *) + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _identity_help + return 0 + fi + + # Identity endpoint doesn't require account_slug + local token + token=$(ensure_auth) + + local response + response=$(curl -s -w '\n%{http_code}' \ + -H "Authorization: Bearer $token" \ + -H "User-Agent: $FIZZY_USER_AGENT" \ + -H "Accept: application/json" \ + "$FIZZY_BASE_URL/my/identity.json") + + local http_code + http_code=$(echo "$response" | tail -n1) + response=$(echo "$response" | sed '$d') + + case "$http_code" in + 200) ;; + 401|403) + die "Authentication failed" $EXIT_AUTH "Run: fizzy auth login" + ;; + *) + die "API error: HTTP $http_code" $EXIT_API + ;; + esac + + local accounts + accounts=$(echo "$response" | jq '.accounts // []') + local count + count=$(echo "$accounts" | jq 'length') + + local summary="${count} account" + [[ "$count" -ne 1 ]] && summary="${count} accounts" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "status" "fizzy auth status" "Auth status")" + ) + + output "$response" "$summary" "$breadcrumbs" "_identity_md" +} + +_identity_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local accounts + accounts=$(echo "$data" | jq -r '.accounts // []') + local count + count=$(echo "$accounts" | jq 'length') + + # Derive name/email from first account's user (API doesn't have top-level identity fields) + local email name + if [[ "$count" -gt 0 ]]; then + name=$(echo "$accounts" | jq -r '.[0].user.name // "unknown"') + email=$(echo "$accounts" | jq -r '.[0].user.email_address // "unknown"') + else + name="unknown" + email="unknown" + fi + + md_heading 2 "Identity" + echo + md_kv "Name" "$name" \ + "Email" "$email" + echo + + if [[ "$count" -gt 0 ]]; then + md_heading 3 "Accounts ($count)" + echo + echo "| Name | Slug | Role |" + echo "|------|------|------|" + echo "$accounts" | jq -r '.[] | "| \(.name) | \(.slug | ltrimstr("/")) | \(.user.role // "member") |"' + else + echo "No accounts found." + fi + + echo + md_breadcrumbs "$breadcrumbs" +} + +_identity_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy identity", + description: "Show current identity and associated accounts", + usage: "fizzy identity" + }' + else + cat <<'EOF' +## fizzy identity + +Show current identity and associated accounts. + +### Usage + + fizzy identity + +### Output + +Shows your email, name, and a table of accounts you have access to +with their names, slugs, and your role in each. + +### Examples + + fizzy identity Show identity info +EOF + fi +} + + +# fizzy user show + +cmd_user() { + local action="${1:-}" + shift || true + + case "$action" in + show) _user_show "$@" ;; + update) _user_update "$@" ;; + delete) _user_delete "$@" ;; + --help|-h|"") _user_help ;; + *) + die "Unknown subcommand: user $action" $EXIT_USAGE \ + "Available: fizzy user show|update|delete" + ;; + esac +} + +_user_show() { + local show_help=false + local user_ref="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy user show --help" + ;; + *) + if [[ -z "$user_ref" ]]; then + user_ref="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _user_show_help + return 0 + fi + + if [[ -z "$user_ref" ]]; then + die "User ID, email, or name required" $EXIT_USAGE \ + "Usage: fizzy user show " + fi + + 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 + + # Resolve user reference to ID + local user_id + user_id=$(resolve_user_id "$user_ref") || exit $? + + local response + response=$(api_get "/users/$user_id") + + local user_name + user_name=$(echo "$response" | jq -r '.name // "unknown"') + + local summary="User: $user_name" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "people" "fizzy people" "List users")" \ + "$(breadcrumb "update" "fizzy user update $user_id" "Update user")" + ) + + output "$response" "$summary" "$breadcrumbs" "_user_show_md" +} + +_user_show_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local id name email role avatar_url + id=$(echo "$data" | jq -r '.id') + name=$(echo "$data" | jq -r '.name // "unknown"') + email=$(echo "$data" | jq -r '.email_address // "unknown"') + role=$(echo "$data" | jq -r '.role // "member"') + avatar_url=$(echo "$data" | jq -r '.avatar_url // "none"') + + md_heading 2 "$name" + echo + md_kv "ID" "$id" \ + "Email" "$email" \ + "Role" "$role" \ + "Avatar" "$avatar_url" + + md_breadcrumbs "$breadcrumbs" +} + +_user_show_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy user show", + description: "Show user details", + usage: "fizzy user show ", + examples: [ + "fizzy user show abc123", + "fizzy user show alice@example.com", + "fizzy user show \"Alice Smith\"" + ] + }' + else + cat <<'EOF' +## fizzy user show + +Show user details. + +### Usage + + fizzy user show + +### Arguments + +The user can be specified by: +- ID (exact match) +- Email address +- Name (partial match supported) + +### Examples + + fizzy user show abc123 By ID + fizzy user show alice@example.com By email + fizzy user show "Alice Smith" By name +EOF + fi +} + + +# fizzy user update [--name NAME] [--avatar PATH] + +_user_update() { + local show_help=false + local user_ref="" + local new_name="" + local avatar_path="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + --name) + if [[ -z "${2:-}" ]]; then + die "--name requires a value" $EXIT_USAGE + fi + new_name="$2" + shift 2 + ;; + --avatar) + if [[ -z "${2:-}" ]]; then + die "--avatar requires a file path" $EXIT_USAGE + fi + avatar_path="$2" + shift 2 + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy user update --help" + ;; + *) + if [[ -z "$user_ref" ]]; then + user_ref="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _user_update_help + return 0 + fi + + if [[ -z "$user_ref" ]]; then + die "User ID, email, or name required" $EXIT_USAGE \ + "Usage: fizzy user update [--name NAME] [--avatar PATH]" + fi + + if [[ -z "$new_name" ]] && [[ -z "$avatar_path" ]]; then + die "Nothing to update" $EXIT_USAGE \ + "Provide --name and/or --avatar" + fi + + # Validate avatar file exists + if [[ -n "$avatar_path" ]] && [[ ! -f "$avatar_path" ]]; then + die "File not found: $avatar_path" $EXIT_USAGE + fi + + 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 + + # Resolve user reference to ID + local user_id + user_id=$(resolve_user_id "$user_ref") || exit $? + + local token + token=$(ensure_auth) + + local http_code + + if [[ -n "$avatar_path" ]]; then + # Multipart upload for avatar + 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 "$new_name" ]]; then + curl_args+=(--form-string "user[name]=$new_name") + fi + curl_args+=(-F "user[avatar]=@$avatar_path") + curl_args+=("$FIZZY_BASE_URL/$account_slug/users/$user_id.json") + + local response + api_multipart_request http_code response "${curl_args[@]}" + else + # JSON update for name only + local body + body=$(jq -n --arg name "$new_name" '{user: {name: $name}}') + + local response + response=$(curl -s -w '\n%{http_code}' \ + -X PATCH \ + -H "Authorization: Bearer $token" \ + -H "User-Agent: $FIZZY_USER_AGENT" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$body" \ + "$FIZZY_BASE_URL/$account_slug/users/$user_id.json") + http_code=$(echo "$response" | tail -n1) + fi + + case "$http_code" in + 200|204) ;; + 401|403) + die "Not authorized to update this user" $EXIT_FORBIDDEN + ;; + 404) + die "User not found: $user_ref" $EXIT_NOT_FOUND + ;; + 422) + die "Validation error" $EXIT_API "Check the provided values" + ;; + *) + die "API error: HTTP $http_code" $EXIT_API + ;; + esac + + # Fetch updated user (204 returns no body) + local updated_user + updated_user=$(api_get "/users/$user_id") + + local user_name + user_name=$(echo "$updated_user" | jq -r '.name // "unknown"') + + local summary="Updated user $user_name" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy user show $user_id" "View user")" \ + "$(breadcrumb "people" "fizzy people" "List users")" + ) + + output "$updated_user" "$summary" "$breadcrumbs" "_user_show_md" +} + +_user_update_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy user update", + description: "Update user profile", + usage: "fizzy user update [--name NAME] [--avatar PATH]", + options: [ + {flag: "--name", description: "New display name"}, + {flag: "--avatar", description: "Path to avatar image file"} + ], + examples: [ + "fizzy user update abc123 --name \"New Name\"", + "fizzy user update alice@example.com --avatar ~/photo.jpg" + ] + }' + else + cat <<'EOF' +## fizzy user update + +Update user profile. + +### Usage + + fizzy user update [--name NAME] [--avatar PATH] + +### Options + + --name NAME New display name + --avatar PATH Path to avatar image file + +### Examples + + fizzy user update abc123 --name "New Name" + fizzy user update alice@example.com --avatar ~/photo.jpg + fizzy user update "Alice" --name "Alice Smith" --avatar ~/alice.png +EOF + fi +} + + +# fizzy user delete + +_user_delete() { + local show_help=false + local user_ref="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy user delete --help" + ;; + *) + if [[ -z "$user_ref" ]]; then + user_ref="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _user_delete_help + return 0 + fi + + if [[ -z "$user_ref" ]]; then + die "User ID, email, or name required" $EXIT_USAGE \ + "Usage: fizzy user delete " + fi + + 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 + + # Resolve user to get name for summary before deletion + local user_id user_name + user_id=$(resolve_user_id "$user_ref") || exit $? + + # Fetch user name before delete + local user_data + user_data=$(api_get "/users/$user_id" 2>/dev/null || echo '{}') + user_name=$(echo "$user_data" | jq -r '.name // "unknown"') + + # DELETE returns 204 + api_delete "/users/$user_id" > /dev/null + + local summary="Deactivated user $user_name" + + local response + response=$(jq -n --arg id "$user_id" --arg name "$user_name" \ + '{id: $id, name: $name, deactivated: true}') + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "people" "fizzy people" "List users")" + ) + + output "$response" "$summary" "$breadcrumbs" "_user_delete_md" +} + +_user_delete_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local user_name + user_name=$(echo "$data" | jq -r '.name // "unknown"') + + md_heading 2 "User Deactivated" + echo + echo "User **$user_name** has been deactivated." + + md_breadcrumbs "$breadcrumbs" +} + +_user_delete_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy user delete", + description: "Deactivate a user", + usage: "fizzy user delete ", + warning: "This deactivates the user from the account", + examples: [ + "fizzy user delete abc123", + "fizzy user delete alice@example.com" + ] + }' + else + cat <<'EOF' +## fizzy user delete + +Deactivate a user from the account. + +### Usage + + fizzy user delete + +### Warning + +This deactivates the user's access to the account. The user's data +is preserved but they can no longer access the account. + +### Examples + + fizzy user delete abc123 + fizzy user delete alice@example.com + fizzy user delete "Alice Smith" +EOF + fi +} + + +_user_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy user", + description: "Manage users in the account", + subcommands: [ + {name: "show", description: "Show user details"}, + {name: "update", description: "Update user profile"}, + {name: "delete", description: "Deactivate a user"} + ] + }' + else + cat <<'EOF' +## fizzy user + +Manage users in the account. + +### Subcommands + + show Show user details + update [options] Update user profile + delete Deactivate a user + +### Examples + + fizzy user show alice@example.com + fizzy user update abc123 --name "New Name" + fizzy user delete "Former Employee" +EOF + fi +} diff --git a/cli/lib/config.sh b/cli/lib/config.sh index 9037f66d23..db4eefef34 100644 --- a/cli/lib/config.sh +++ b/cli/lib/config.sh @@ -71,10 +71,10 @@ load_config() { # Layer 5: Environment variables # Note: FIZZY_BASE_URL is handled separately in _update_base_url_from_config # because core.sh sets a default before we can capture the original env value + # Note: FIZZY_TOKEN is handled in get_access_token() directly, not via config [[ -n "${FIZZY_ACCOUNT_SLUG:-}" ]] && _FIZZY_CONFIG["account_slug"]="$FIZZY_ACCOUNT_SLUG" || true [[ -n "${FIZZY_BOARD_ID:-}" ]] && _FIZZY_CONFIG["board_id"]="$FIZZY_BOARD_ID" || true [[ -n "${FIZZY_COLUMN_ID:-}" ]] && _FIZZY_CONFIG["column_id"]="$FIZZY_COLUMN_ID" || true - [[ -n "${FIZZY_ACCESS_TOKEN:-}" ]] && _FIZZY_CONFIG["access_token"]="$FIZZY_ACCESS_TOKEN" || true # Layer 6: Command-line flags (already handled in global flag parsing) [[ -n "${FIZZY_ACCOUNT:-}" ]] && _FIZZY_CONFIG["account_slug"]="$FIZZY_ACCOUNT" || true @@ -248,8 +248,9 @@ set_credential_account_slug() { } get_access_token() { - if [[ -n "${FIZZY_ACCESS_TOKEN:-}" ]]; then - echo "$FIZZY_ACCESS_TOKEN" + # Priority: FIZZY_TOKEN env → stored credentials + if [[ -n "${FIZZY_TOKEN:-}" ]]; then + echo "$FIZZY_TOKEN" return fi @@ -265,7 +266,31 @@ get_access_token() { echo "$token" } +# Detect auth type: "token_env" | "oauth" | "none" +get_auth_type() { + if [[ -n "${FIZZY_TOKEN:-}" ]]; then + echo "token_env" + return + fi + + local creds + creds=$(load_credentials) + local token + token=$(echo "$creds" | jq -r '.access_token // empty') + if [[ -n "$token" ]]; then + echo "oauth" + return + fi + + echo "none" +} + is_token_expired() { + # Env var tokens never expire + if [[ -n "${FIZZY_TOKEN:-}" ]]; then + return 1 # Not expired + fi + local creds creds=$(load_credentials) local expires_at @@ -481,7 +506,6 @@ get_config_source() { account_slug) [[ -n "${FIZZY_ACCOUNT_SLUG:-}" ]] && echo "env" && return ;; board_id) [[ -n "${FIZZY_BOARD_ID:-}" ]] && echo "env" && return ;; column_id) [[ -n "${FIZZY_COLUMN_ID:-}" ]] && echo "env" && return ;; - access_token) [[ -n "${FIZZY_ACCESS_TOKEN:-}" ]] && echo "env" && return ;; esac # Check local (cwd) config diff --git a/cli/lib/core.sh b/cli/lib/core.sh index 5a76428f8a..16c60b4188 100644 --- a/cli/lib/core.sh +++ b/cli/lib/core.sh @@ -3,10 +3,40 @@ # Output formatting, response envelope, global flags # Environment Configuration +# FIZZY_URL - Convenience: base URL + optional account slug (e.g., http://fizzy.localhost:3006/897362094) # FIZZY_BASE_URL - Base URL for Fizzy (web app + API) # In production: https://app.fizzy.do # In development: http://fizzy.localhost:3006 +# Parse FIZZY_URL into FIZZY_BASE_URL + FIZZY_ACCOUNT_SLUG (if not explicitly set) +# Accepts: http://host or http://host/897362094 (numeric 7+ digit slug) +if [[ -n "${FIZZY_URL:-}" ]]; then + _fizzy_url="${FIZZY_URL%%\#*}" # Strip fragment + _fizzy_url="${_fizzy_url%%\?*}" # Strip query + _fizzy_url="${_fizzy_url%/}" # Strip trailing slash + + if [[ "$_fizzy_url" =~ ^(https?://[^/]+)(/.*)?$ ]]; then + _fizzy_base="${BASH_REMATCH[1]}" + _fizzy_path="${BASH_REMATCH[2]:-}" + _fizzy_path="${_fizzy_path#/}" + _fizzy_slug="${_fizzy_path%%/*}" + + # Set base URL if not explicitly provided + if [[ -z "${FIZZY_BASE_URL:-}" ]]; then + FIZZY_BASE_URL="$_fizzy_base" + fi + + # Set account slug if not explicitly provided and bases match + if [[ -z "${FIZZY_ACCOUNT_SLUG:-}" ]] && [[ "${FIZZY_BASE_URL:-}" == "$_fizzy_base" ]]; then + # Only accept numeric slugs (7+ digits = external_account_id) + if [[ "$_fizzy_slug" =~ ^[0-9]{7,}$ ]]; then + FIZZY_ACCOUNT_SLUG="$_fizzy_slug" + fi + fi + fi + unset _fizzy_url _fizzy_base _fizzy_path _fizzy_slug +fi + # Capture original env value before any modifications (used by config.sh) # This marker tells config.sh whether FIZZY_BASE_URL was explicitly set by user _FIZZY_BASE_URL_FROM_ENV="${FIZZY_BASE_URL:-}" @@ -333,14 +363,6 @@ parse_global_flags() { (( GLOBAL_FLAGS_CONSUMED += 2 )) shift 2 ;; - --token) - if [[ -z "${2:-}" ]]; then - die "--token requires a value" $EXIT_USAGE - fi - FIZZY_ACCESS_TOKEN="$2" - (( GLOBAL_FLAGS_CONSUMED += 2 )) - shift 2 - ;; --) (( ++GLOBAL_FLAGS_CONSUMED )) break diff --git a/cli/test/actions.bats b/cli/test/actions.bats index 7062361901..728ff0bcc0 100644 --- a/cli/test/actions.bats +++ b/cli/test/actions.bats @@ -56,23 +56,23 @@ load test_helper } -# update --help +# card update --help -@test "update --help shows help" { - run fizzy --md update --help +@test "card update --help shows help" { + run fizzy --md card update --help assert_success - assert_output_contains "fizzy update" + assert_output_contains "fizzy card update" assert_output_contains "Update" } -@test "update -h shows help" { - run fizzy --md update -h +@test "card update -h shows help" { + run fizzy --md card update -h assert_success - assert_output_contains "fizzy update" + assert_output_contains "fizzy card update" } -@test "update --help --json outputs JSON" { - run fizzy --json update --help +@test "card update --help --json outputs JSON" { + run fizzy --json card update --help assert_success is_valid_json assert_json_not_null ".command" @@ -80,38 +80,74 @@ load test_helper } -# update requires card number and options +# card update requires card number and options -@test "update without number shows error" { - run fizzy update +@test "card update without number shows error" { + run fizzy card update assert_failure assert_output_contains "Card number required" } -@test "update without options shows error" { - run fizzy update 123 +@test "card update without options shows error" { + run fizzy card update 123 assert_failure assert_output_contains "Nothing to update" } -@test "update requires authentication" { - run fizzy update 123 --title "New" +@test "card update requires authentication" { + run fizzy card update 123 --title "New" assert_failure assert_output_contains "Not authenticated" } -@test "update rejects unknown option" { - run fizzy update --unknown-option +@test "card update rejects unknown option" { + run fizzy card update --unknown-option assert_failure assert_output_contains "Unknown option" } -@test "update --description-file with missing file shows error" { - run fizzy update 123 --description-file nonexistent.txt +@test "card update --description-file with missing file shows error" { + run fizzy card update 123 --description-file nonexistent.txt assert_failure assert_output_contains "File not found" } +@test "card update --image with missing file shows error" { + run fizzy card update 123 --image nonexistent.png + assert_failure + assert_output_contains "File not found" +} + +@test "card update --help documents --image flag" { + run fizzy --md card update --help + assert_success + assert_output_contains "--image" +} + +@test "card create --image with missing file shows error" { + run fizzy card "Test card" --board testboard --image nonexistent.png + assert_failure + assert_output_contains "File not found" +} + +@test "card create --help documents --image flag" { + run fizzy --md card --help + assert_success + assert_output_contains "--image" +} + +# Regression test: non-file multipart fields must use --form-string to prevent +# values starting with @ from being interpreted as file paths +@test "multipart uploads use --form-string for non-file fields" { + # Check that card title/description don't use -F (which interprets @-prefix as file) + run grep -E '\-F ["\047]card\[(title|description)\]' lib/commands/actions.sh + assert_failure # Should NOT find this pattern + + # Check that user name doesn't use -F + run grep -E '\-F ["\047]user\[name\]' lib/commands/users.sh + assert_failure # Should NOT find this pattern +} + # close --help @@ -195,95 +231,136 @@ load test_helper } -# delete --help +# card delete --help -@test "delete --help shows help" { - run fizzy --md delete --help +@test "card delete --help shows help" { + run fizzy --md card delete --help assert_success - assert_output_contains "fizzy delete" + assert_output_contains "fizzy card delete" assert_output_contains "delete" } -@test "delete -h shows help" { - run fizzy --md delete -h +@test "card delete -h shows help" { + run fizzy --md card delete -h assert_success - assert_output_contains "fizzy delete" + assert_output_contains "fizzy card delete" } -@test "delete --help --json outputs JSON" { - run fizzy --json delete --help +@test "card delete --help --json outputs JSON" { + run fizzy --json card delete --help assert_success is_valid_json assert_json_not_null ".command" } -@test "delete --help shows warning" { - run fizzy --md delete --help +@test "card delete --help shows warning" { + run fizzy --md card delete --help assert_success assert_output_contains "cannot be undone" } -# delete requires card number +# card delete requires card number -@test "delete without number shows error" { - run fizzy delete +@test "card delete without number shows error" { + run fizzy card delete assert_failure assert_output_contains "Card number required" } -@test "delete requires authentication" { - run fizzy delete 123 +@test "card delete requires authentication" { + run fizzy card delete 123 assert_failure assert_output_contains "Not authenticated" } -@test "delete rejects unknown option" { - run fizzy delete --invalid +@test "card delete rejects unknown option" { + run fizzy card delete --invalid assert_failure assert_output_contains "Unknown option" } +@test "card delete rejects non-numeric input" { + run fizzy card delete abc + assert_failure + assert_output_contains "Invalid card number" +} + +@test "card delete rejects zero" { + run fizzy card delete 0 + assert_failure + assert_output_contains "Invalid card number: 0" +} + +@test "card delete rejects mixed valid and invalid numbers" { + run fizzy card delete 123 abc 456 + assert_failure + assert_output_contains "Invalid card number: abc" +} + + +# card image --help + +@test "card image --help shows subcommands" { + run fizzy --md card image --help + assert_success + assert_output_contains "fizzy card image" + assert_output_contains "delete" +} + +@test "card image without subcommand shows help" { + run fizzy --md card image + assert_success + assert_output_contains "Subcommands" +} + +@test "card image --help --json outputs JSON" { + run fizzy --json card image --help + assert_success + is_valid_json + assert_json_not_null ".subcommands" +} + -# delete-image --help +# card image delete --help -@test "delete-image --help shows help" { - run fizzy --md delete-image --help +@test "card image delete --help shows help" { + run fizzy --md card image delete --help assert_success - assert_output_contains "fizzy delete-image" + assert_output_contains "fizzy card image delete" assert_output_contains "image" } -@test "delete-image -h shows help" { - run fizzy --md delete-image -h +@test "card image delete -h shows help" { + run fizzy --md card image delete -h assert_success - assert_output_contains "fizzy delete-image" + assert_output_contains "fizzy card image delete" } -@test "delete-image --help --json outputs JSON" { - run fizzy --json delete-image --help +@test "card image delete --help --json outputs JSON" { + run fizzy --json card image delete --help assert_success is_valid_json assert_json_not_null ".command" } -# delete-image requires card number +# card image delete requires card number -@test "delete-image without number shows error" { - run fizzy delete-image +@test "card image delete without number shows error" { + run fizzy card image delete assert_failure assert_output_contains "Card number required" } -@test "delete-image requires authentication" { - run fizzy delete-image 123 +@test "card image delete requires authentication" { + run fizzy card image delete 123 assert_failure assert_output_contains "Not authenticated" } -@test "delete-image rejects unknown option" { - run fizzy delete-image --invalid +@test "card image delete rejects unknown option" { + run fizzy card image delete --invalid assert_failure assert_output_contains "Unknown option" } diff --git a/cli/test/auth.bats b/cli/test/auth.bats index 1c1e83d128..82bc23d29f 100644 --- a/cli/test/auth.bats +++ b/cli/test/auth.bats @@ -389,3 +389,115 @@ EOF assert_output_contains "Not authenticated" assert_output_contains "fizzy auth login" } + + +# FIZZY_TOKEN environment variable + +@test "FIZZY_TOKEN takes precedence over stored credentials" { + create_credentials "stored-token" "$(($(date +%s) + 3600))" "write" + + export FIZZY_TOKEN="env-token" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + result=$(get_access_token) + [[ "$result" == "env-token" ]] +} + +@test "get_auth_type returns token_env when FIZZY_TOKEN set" { + export FIZZY_TOKEN="env-token" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + result=$(get_auth_type) + [[ "$result" == "token_env" ]] +} + +@test "get_auth_type returns oauth when stored credentials" { + create_credentials "stored-token" "$(($(date +%s) + 3600))" "write" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + result=$(get_auth_type) + [[ "$result" == "oauth" ]] +} + +@test "get_auth_type returns none when no auth" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + result=$(get_auth_type) + [[ "$result" == "none" ]] +} + +@test "is_token_expired returns false for FIZZY_TOKEN" { + export FIZZY_TOKEN="env-token" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + ! is_token_expired +} + +@test "auth status shows FIZZY_TOKEN indicator" { + export FIZZY_TOKEN="env-token" + export FIZZY_ACCOUNT_SLUG="99999999" + + run fizzy --md auth status + assert_success + assert_output_contains "Authenticated" + assert_output_contains "FIZZY_TOKEN" +} + +@test "auth status --json shows env auth type" { + export FIZZY_TOKEN="env-token" + + run fizzy --json auth status + assert_success + is_valid_json + assert_json_value ".status" "authenticated" + assert_json_value ".auth" "env" +} + +@test "auth logout warns about FIZZY_TOKEN still set" { + create_credentials "stored-token" + export FIZZY_TOKEN="env-token" + + run fizzy auth logout + assert_success + assert_output_contains "Logged out" + assert_output_contains "FIZZY_TOKEN" + assert_output_contains "still set" +} + +@test "auth refresh fails with FIZZY_TOKEN" { + export FIZZY_TOKEN="env-token" + + run fizzy auth refresh + assert_failure + assert_output_contains "FIZZY_TOKEN does not support refresh" +} + +@test "auth status shows coexistence note when both token sources exist" { + create_credentials "stored-token" + export FIZZY_TOKEN="env-token" + + run fizzy --md auth status + assert_success + assert_output_contains "FIZZY_TOKEN" + assert_output_contains "precedence" + assert_output_contains "Stored OAuth credentials also exist" +} + +@test "auth status --json includes has_stored_credentials when using FIZZY_TOKEN" { + create_credentials "stored-token" + export FIZZY_TOKEN="env-token" + + run fizzy --json auth status + assert_success + is_valid_json + assert_json_value ".has_stored_credentials" "true" +} diff --git a/cli/test/boards.bats b/cli/test/boards.bats index 76dfd1e1e7..84015ca595 100644 --- a/cli/test/boards.bats +++ b/cli/test/boards.bats @@ -67,6 +67,43 @@ load test_helper } +# board --help + +@test "board --help shows help" { + run fizzy --md board --help + assert_success + assert_output_contains "fizzy board" + assert_output_contains "Manage boards" +} + + +# board validation + +@test "board create without name shows error" { + run fizzy board create + assert_failure + assert_output_contains "Board name required" +} + +@test "board update without id shows error" { + run fizzy board update + assert_failure + assert_output_contains "Board ID required" +} + +@test "board delete without id shows error" { + run fizzy board delete + assert_failure + assert_output_contains "Board ID required" +} + +@test "board show without id shows error" { + run fizzy board show + assert_failure + assert_output_contains "Board ID required" +} + + # columns --help @test "columns --help shows help" { @@ -100,3 +137,52 @@ load test_helper assert_success assert_output_contains "--board" } + + +# column --help + +@test "column --help shows help" { + run fizzy --md column --help + assert_success + assert_output_contains "fizzy column" + assert_output_contains "Manage columns" +} + + +# column validation + +@test "column create without name shows error" { + run fizzy column create --board abc123 + assert_failure + assert_output_contains "Column name required" +} + +@test "column create without board shows error" { + run fizzy column create "In Progress" + assert_failure + assert_output_contains "No board specified" +} + +@test "column update without id shows error" { + run fizzy column update + assert_failure + assert_output_contains "Column ID required" +} + +@test "column update without board shows error" { + run fizzy column update abc123 + assert_failure + assert_output_contains "No board specified" +} + +@test "column delete without id shows error" { + run fizzy column delete + assert_failure + assert_output_contains "Column ID required" +} + +@test "column show without id shows error" { + run fizzy column show + assert_failure + assert_output_contains "Column ID required" +} diff --git a/cli/test/config.bats b/cli/test/config.bats index 6826ddafdc..7e5588671d 100644 --- a/cli/test/config.bats +++ b/cli/test/config.bats @@ -89,7 +89,7 @@ load test_helper @test "loads credentials from file" { create_credentials "my-test-token" "$(($(date +%s) + 3600))" - unset FIZZY_ACCESS_TOKEN + unset FIZZY_TOKEN source "$FIZZY_ROOT/lib/core.sh" source "$FIZZY_ROOT/lib/config.sh" @@ -98,9 +98,9 @@ load test_helper [[ "$result" == "my-test-token" ]] } -@test "environment token overrides file" { +@test "FIZZY_TOKEN overrides stored credentials" { create_credentials "file-token" - export FIZZY_ACCESS_TOKEN="env-token" + export FIZZY_TOKEN="env-token" source "$FIZZY_ROOT/lib/core.sh" source "$FIZZY_ROOT/lib/config.sh" diff --git a/cli/test/core.bats b/cli/test/core.bats index f26146134c..8ad98cebc3 100644 --- a/cli/test/core.bats +++ b/cli/test/core.bats @@ -156,3 +156,94 @@ load test_helper run fizzy --json -a 12345 assert_success } + + +# FIZZY_URL convenience env var + +@test "FIZZY_URL with slug sets base + account" { + unset FIZZY_BASE_URL + export FIZZY_URL="http://fizzy.localhost:3006/897362094" + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_BASE_URL" == "http://fizzy.localhost:3006" ]] + [[ "$FIZZY_ACCOUNT_SLUG" == "897362094" ]] +} + +@test "FIZZY_URL without slug sets base only" { + unset FIZZY_BASE_URL + unset FIZZY_ACCOUNT_SLUG + export FIZZY_URL="http://fizzy.localhost:3006" + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_BASE_URL" == "http://fizzy.localhost:3006" ]] + [[ -z "${FIZZY_ACCOUNT_SLUG:-}" ]] +} + +@test "FIZZY_BASE_URL overrides FIZZY_URL base" { + export FIZZY_BASE_URL="http://override.local" + export FIZZY_URL="http://fizzy.localhost:3006/897362094" + unset FIZZY_ACCOUNT_SLUG + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_BASE_URL" == "http://override.local" ]] + # Slug not set because bases don't match + [[ -z "${FIZZY_ACCOUNT_SLUG:-}" ]] +} + +@test "FIZZY_ACCOUNT_SLUG overrides FIZZY_URL slug" { + unset FIZZY_BASE_URL + export FIZZY_ACCOUNT_SLUG="1234567" + export FIZZY_URL="http://fizzy.localhost:3006/897362094" + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_ACCOUNT_SLUG" == "1234567" ]] +} + +@test "FIZZY_URL ignores non-numeric path segments" { + unset FIZZY_BASE_URL + unset FIZZY_ACCOUNT_SLUG + export FIZZY_URL="http://fizzy.localhost:3006/boards" + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_BASE_URL" == "http://fizzy.localhost:3006" ]] + [[ -z "${FIZZY_ACCOUNT_SLUG:-}" ]] +} + +@test "FIZZY_URL with extra path uses first segment as slug" { + unset FIZZY_BASE_URL + unset FIZZY_ACCOUNT_SLUG + export FIZZY_URL="http://fizzy.localhost:3006/897362094/boards/123" + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_BASE_URL" == "http://fizzy.localhost:3006" ]] + [[ "$FIZZY_ACCOUNT_SLUG" == "897362094" ]] +} + +@test "FIZZY_URL strips trailing slash" { + unset FIZZY_BASE_URL + unset FIZZY_ACCOUNT_SLUG + export FIZZY_URL="http://fizzy.localhost:3006/897362094/" + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_BASE_URL" == "http://fizzy.localhost:3006" ]] + [[ "$FIZZY_ACCOUNT_SLUG" == "897362094" ]] +} + +@test "FIZZY_URL rejects short numeric slugs" { + unset FIZZY_BASE_URL + unset FIZZY_ACCOUNT_SLUG + export FIZZY_URL="http://fizzy.localhost:3006/123456" + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_BASE_URL" == "http://fizzy.localhost:3006" ]] + # 6 digits is too short + [[ -z "${FIZZY_ACCOUNT_SLUG:-}" ]] +} diff --git a/cli/test/test_helper.bash b/cli/test/test_helper.bash index 061105d3e6..221bfcbe60 100644 --- a/cli/test/test_helper.bash +++ b/cli/test/test_helper.bash @@ -24,7 +24,8 @@ setup() { # Clear environment variables that might interfere with tests # Tests can set these as needed - unset FIZZY_ACCESS_TOKEN + unset FIZZY_URL + unset FIZZY_TOKEN unset FIZZY_ACCOUNT_SLUG unset FIZZY_BOARD_ID unset FIZZY_COLUMN_ID diff --git a/cli/test/users.bats b/cli/test/users.bats new file mode 100644 index 0000000000..34c52d4213 --- /dev/null +++ b/cli/test/users.bats @@ -0,0 +1,220 @@ +#!/usr/bin/env bats +# users.bats - Tests for identity and user commands + +load test_helper + + +# Identity command + +@test "identity --help shows help" { + run fizzy identity --help + assert_success + assert_output_contains "identity" + assert_output_contains "accounts" +} + +@test "identity -h shows help" { + run fizzy identity -h + assert_success + assert_output_contains "identity" +} + +@test "identity --help --json outputs JSON" { + run fizzy --json identity --help + assert_success + is_valid_json + assert_json_value ".command" "fizzy identity" +} + +@test "identity requires authentication" { + run fizzy identity + assert_failure + assert_output_contains "Not authenticated" +} + + +# User show command + +@test "user show --help shows help" { + run fizzy user show --help + assert_success + assert_output_contains "user show" + assert_output_contains "id|email|name" +} + +@test "user show -h shows help" { + run fizzy user show -h + assert_success + assert_output_contains "user show" +} + +@test "user show --help --json outputs JSON" { + run fizzy --json user show --help + assert_success + is_valid_json + assert_json_value ".command" "fizzy user show" +} + +@test "user show without argument shows error" { + create_credentials + create_global_config '{"account_slug": "99999999"}' + + run fizzy user show + assert_failure + assert_output_contains "User ID, email, or name required" +} + +@test "user show requires authentication" { + create_global_config '{"account_slug": "99999999"}' + + run fizzy user show abc123 + assert_failure + assert_output_contains "Not authenticated" +} + +@test "user show requires account_slug" { + create_credentials + + run fizzy user show abc123 + assert_failure + assert_output_contains "Account not configured" +} + + +# User update command + +@test "user update --help shows help" { + run fizzy user update --help + assert_success + assert_output_contains "user update" + assert_output_contains "--name" + assert_output_contains "--avatar" +} + +@test "user update -h shows help" { + run fizzy user update -h + assert_success + assert_output_contains "user update" +} + +@test "user update --help --json outputs JSON" { + run fizzy --json user update --help + assert_success + is_valid_json + assert_json_value ".command" "fizzy user update" +} + +@test "user update without argument shows error" { + create_credentials + create_global_config '{"account_slug": "99999999"}' + + run fizzy user update + assert_failure + assert_output_contains "User ID, email, or name required" +} + +@test "user update without options shows error" { + create_credentials + create_global_config '{"account_slug": "99999999"}' + + run fizzy user update abc123 + assert_failure + assert_output_contains "Nothing to update" +} + +@test "user update --avatar with missing file shows error" { + create_credentials + create_global_config '{"account_slug": "99999999"}' + + run fizzy user update abc123 --avatar /nonexistent/file.jpg + assert_failure + assert_output_contains "File not found" +} + +@test "user update requires authentication" { + create_global_config '{"account_slug": "99999999"}' + + run fizzy user update abc123 --name "New Name" + assert_failure + assert_output_contains "Not authenticated" +} + + +# User delete command + +@test "user delete --help shows help" { + run fizzy user delete --help + assert_success + assert_output_contains "user delete" + assert_output_contains "Deactivate" +} + +@test "user delete -h shows help" { + run fizzy user delete -h + assert_success + assert_output_contains "user delete" +} + +@test "user delete --help --json outputs JSON" { + run fizzy --json user delete --help + assert_success + is_valid_json + assert_json_value ".command" "fizzy user delete" +} + +@test "user delete without argument shows error" { + create_credentials + create_global_config '{"account_slug": "99999999"}' + + run fizzy user delete + assert_failure + assert_output_contains "User ID, email, or name required" +} + +@test "user delete requires authentication" { + create_global_config '{"account_slug": "99999999"}' + + run fizzy user delete abc123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# User command help + +@test "user --help shows subcommands" { + run fizzy user --help + assert_success + assert_output_contains "show" + assert_output_contains "update" + assert_output_contains "delete" +} + +@test "user -h shows subcommands" { + run fizzy user -h + assert_success + assert_output_contains "show" + assert_output_contains "update" + assert_output_contains "delete" +} + +@test "user without subcommand shows help" { + run fizzy user + assert_success + assert_output_contains "show" + assert_output_contains "update" + assert_output_contains "delete" +} + +@test "user --help --json outputs JSON" { + run fizzy --json user --help + assert_success + is_valid_json + assert_json_value ".command" "fizzy user" +} + +@test "user unknown subcommand shows error" { + run fizzy user unknown + assert_failure + assert_output_contains "Unknown subcommand" +}