diff --git a/README.md b/README.md index 867c2dba74..b438dd2e48 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,34 @@ If you want more flexibility to customize your Fizzy installation by changing it You are welcome -- and encouraged -- to modify Fizzy to your liking. Please see our [Development guide](docs/development.md) for how to get Fizzy set up for local development. +### CLI tool + +From this checkout, install the standalone CLI with: + +```bash +./bin/install-fizzy-cli +``` + +This also accepts the compatibility path: + +```bash +./bin/install/fizzy/cli +``` + +Then run: + +```bash +fizzy --version +``` + +If the command is not immediately on PATH, the installer writes a launcher at `~/.local/bin/fizzy` and updates your shell startup file when possible. + +You can also use the in-repo wrapper without installing: + +```bash +./bin/fizzy --help +``` + ## Contributing diff --git a/app/controllers/agent_bootstrap_claims_controller.rb b/app/controllers/agent_bootstrap_claims_controller.rb new file mode 100644 index 0000000000..545c92451d --- /dev/null +++ b/app/controllers/agent_bootstrap_claims_controller.rb @@ -0,0 +1,45 @@ +class AgentBootstrapClaimsController < ApplicationController + disallow_account_scope + allow_unauthenticated_access + wrap_parameters :agent_bootstrap, include: %i[ email_address name profile_name ] + + def create + bootstrap = Board::AgentBootstrap.find_by!(token: params.expect(:token)) + claim = bootstrap.claim!(**claim_params.to_h.symbolize_keys) + + render json: { + token: claim[:access_token].token, + permission: claim[:access_token].permission, + account: { + id: bootstrap.account.id, + name: bootstrap.account.name, + slug: bootstrap.account.slug + }, + board: { + id: bootstrap.board.id, + name: bootstrap.board.name, + url: board_url(bootstrap.board, script_name: bootstrap.account.slug) + }, + user: { + id: claim[:user].id, + name: claim[:user].name, + email_address: claim[:identity].email_address + }, + profile: { + base_url: root_url(script_name: nil).delete_suffix("/"), + account_slug: bootstrap.account.slug, + default_board_id: bootstrap.board.id + } + }, status: :created + rescue ActiveRecord::RecordNotFound + head :gone + rescue ActiveRecord::RecordInvalid => error + render json: { errors: error.record.errors.full_messages }, status: :unprocessable_entity + end + + private + def claim_params + params.expect(agent_bootstrap: %i[ email_address name profile_name ]) + .with_defaults(profile_name: nil) + end +end diff --git a/app/controllers/agent_bootstrap_skills_controller.rb b/app/controllers/agent_bootstrap_skills_controller.rb new file mode 100644 index 0000000000..75eeb6169c --- /dev/null +++ b/app/controllers/agent_bootstrap_skills_controller.rb @@ -0,0 +1,21 @@ +class AgentBootstrapSkillsController < ApplicationController + disallow_account_scope + allow_unauthenticated_access + + def show + bootstrap = Board::AgentBootstrap.find_by!(token: params.expect(:token)) + raise ActiveRecord::RecordNotFound unless bootstrap.claimable? + + send_data skill_source, + filename: "fizzy-cli.SKILL.md", + type: "text/markdown; charset=utf-8", + disposition: "inline" + rescue ActiveRecord::RecordNotFound + head :gone + end + + private + def skill_source + Rails.root.join("skills", "fizzy-cli", "SKILL.md").read + end +end diff --git a/app/controllers/boards/agent_bootstraps_controller.rb b/app/controllers/boards/agent_bootstraps_controller.rb new file mode 100644 index 0000000000..02df619b02 --- /dev/null +++ b/app/controllers/boards/agent_bootstraps_controller.rb @@ -0,0 +1,36 @@ +class Boards::AgentBootstrapsController < ApplicationController + include BoardScoped + + before_action :ensure_admin + before_action :set_agent_bootstrap, only: :show + + def new + end + + def show + end + + def create + @agent_bootstrap = @board.agent_bootstraps.create!( + account: @board.account, + creator: Current.user, + expires_at: expires_in_minutes.minutes.from_now, + permission: params.fetch(:permission, :write), + involvement: params.fetch(:involvement, :watching) + ) + + respond_to do |format| + format.html { redirect_to board_agent_bootstrap_path(@board, @agent_bootstrap) } + format.json { render :show, status: :created, location: board_agent_bootstrap_url(@board, @agent_bootstrap, format: :json) } + end + end + + private + def set_agent_bootstrap + @agent_bootstrap = @board.agent_bootstraps.find(params[:id]) + end + + def expires_in_minutes + params.fetch(:expires_in_minutes, 30).to_i.clamp(5, 24.hours.in_minutes) + end +end diff --git a/app/helpers/agent_bootstraps_helper.rb b/app/helpers/agent_bootstraps_helper.rb new file mode 100644 index 0000000000..61b5b3e2a2 --- /dev/null +++ b/app/helpers/agent_bootstraps_helper.rb @@ -0,0 +1,49 @@ +module AgentBootstrapsHelper + require "shellwords" + + AGENT_BOOTSTRAP_SKILL_NAME = "fizzy-cli" + + def link_to_agent_bootstrap(board) + link_to new_board_agent_bootstrap_path(board), + class: "btn btn--circle-mobile", + data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Agent setup" } do + icon_tag("settings") + tag.span("Agent setup", class: "for-screen-reader") + end + end + + def agent_bootstrap_claim_url_for(agent_bootstrap) + agent_bootstrap_claim_url(token: agent_bootstrap.token, script_name: nil) + end + + def agent_bootstrap_skill_url_for(agent_bootstrap) + agent_bootstrap_skill_url(token: agent_bootstrap.token, script_name: nil) + end + + def agent_bootstrap_setup_command(agent_bootstrap) + suggested_email = "agent+#{agent_bootstrap.token.to_s[0, 8]}@example.com" + suggested_name = "#{agent_bootstrap.board.name} Agent" + + Shellwords.shelljoin([ + "fizzy", "auth", "bootstrap", agent_bootstrap_claim_url_for(agent_bootstrap), + "--email", suggested_email, + "--name", suggested_name + ]) + end + + def agent_bootstrap_skill_name + AGENT_BOOTSTRAP_SKILL_NAME + end + + def agent_bootstrap_skill_block(agent_bootstrap) + <<~TEXT.strip + Download the Fizzy CLI skill from: + #{agent_bootstrap_skill_url_for(agent_bootstrap)} + + Load that skill into your agent, then run: + #{agent_bootstrap_setup_command(agent_bootstrap)} + + Verify the bootstrap with: + fizzy whoami --json + TEXT + end +end diff --git a/app/models/board.rb b/app/models/board.rb index ada49f1f2e..e9d8a8c215 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -8,6 +8,7 @@ class Board < ApplicationRecord has_many :tags, -> { distinct }, through: :cards has_many :events + has_many :agent_bootstraps, class_name: "Board::AgentBootstrap", dependent: :destroy has_many :webhooks, dependent: :destroy scope :alphabetically, -> { order("lower(name)") } diff --git a/app/models/board/agent_bootstrap.rb b/app/models/board/agent_bootstrap.rb new file mode 100644 index 0000000000..caedf5aee1 --- /dev/null +++ b/app/models/board/agent_bootstrap.rb @@ -0,0 +1,76 @@ +class Board::AgentBootstrap < ApplicationRecord + belongs_to :account + belongs_to :board + belongs_to :creator, class_name: "User" + belongs_to :claimed_by_identity, class_name: "Identity", optional: true + + has_secure_token + + enum :permission, %w[ read write ].index_by(&:itself), default: :write + enum :involvement, Access.involvements, default: :watching + + scope :active, -> { where(claimed_at: nil, expires_at: Time.current...) } + + validates :expires_at, presence: true + + def expired? + expires_at <= Time.current + end + + def claimed? + claimed_at.present? + end + + def claimable? + !claimed? && !expired? + end + + def claim!(email_address:, name:, profile_name: nil) + raise ActiveRecord::RecordNotFound unless claimable? + + transaction do + lock! + raise ActiveRecord::RecordNotFound unless claimable? + + identity = find_or_create_claim_identity!(email_address) + user = identity.users.find_or_initialize_by(account: account) + if user.new_record? + user.name = name + user.role = :member + user.save! + elsif !user.active? + user.update!(identity:, active: true) + end + + board.accesses.find_or_create_by!(user: user) do |access| + access.account = account + access.involvement = involvement + end.update!(involvement: involvement) + + access_token = identity.access_tokens.create!( + description: "Fizzy CLI#{profile_name.present? ? " (#{profile_name})" : " (#{name})"}", + permission: permission + ) + + update!(claimed_at: Time.current, claimed_by_identity: identity) + + { identity:, user:, access_token: } + end + end + + private + def find_or_create_claim_identity!(email_address) + identity = Identity.find_or_initialize_by(email_address:) + ensure_claimable_identity!(identity) + identity.save! if identity.new_record? + identity + end + + def ensure_claimable_identity!(identity) + return if identity.new_record? + return if identity.users.where(account: account).exists? && identity.users.where.not(account: account).none? + + errors.add(:base, "Bootstrap claims cannot reuse an identity from another account") + raise ActiveRecord::RecordInvalid, self + end +end diff --git a/app/views/boards/agent_bootstraps/new.html.erb b/app/views/boards/agent_bootstraps/new.html.erb new file mode 100644 index 0000000000..7696f8d1ce --- /dev/null +++ b/app/views/boards/agent_bootstraps/new.html.erb @@ -0,0 +1,24 @@ +<% @page_title = "Agent setup" %> + +<% content_for :header do %> +
+ <%= back_link_to @board.name, @board, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+<% end %> + +
+
+

Set up an agent for <%= @board.name %>

+

This creates a one-time setup link for a CLI agent. The agent will join this account, get a write token, and start watching this board.

+
+ + <%= form_with url: board_agent_bootstraps_path(@board), method: :post, html: { class: "flex flex-column gap full-width" } do |form| %> + <%= form.hidden_field :permission, value: :write %> + <%= form.hidden_field :involvement, value: :watching %> + <%= form.hidden_field :expires_in_minutes, value: 30 %> + + + <% end %> +
diff --git a/app/views/boards/agent_bootstraps/show.html.erb b/app/views/boards/agent_bootstraps/show.html.erb new file mode 100644 index 0000000000..3d1768592c --- /dev/null +++ b/app/views/boards/agent_bootstraps/show.html.erb @@ -0,0 +1,60 @@ +<% @page_title = "Agent setup" %> + +<% content_for :header do %> +
+ <%= back_link_to @board.name, @board, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %> +
+<% end %> + +
+
+

Agent setup for <%= @board.name %>

+

Copy the command below into an agent session. It can be used once until <%= @agent_bootstrap.expires_at.to_fs(:long) %>.

+
+ +
+ Setup command + +
+ <%= button_to_copy_to_clipboard(agent_bootstrap_setup_command(@agent_bootstrap)) do %> + Copy setup command + <% end %> +
+
+ +
+ Skill download URL + +
+ <%= button_to_copy_to_clipboard(agent_bootstrap_skill_url_for(@agent_bootstrap)) do %> + Copy skill URL + <% end %> +
+

Give this URL to an agent so it can download the current <%= agent_bootstrap_skill_name %> skill.

+
+ +
+ Agent prompt + +
+ <%= button_to_copy_to_clipboard(agent_bootstrap_skill_block(@agent_bootstrap)) do %> + Copy agent prompt + <% end %> +
+

This prompt tells the agent where to fetch the skill, how to bootstrap the CLI, and how to verify access.

+
+ +
+ Raw bootstrap URL + +
+ <%= button_to_copy_to_clipboard(agent_bootstrap_claim_url_for(@agent_bootstrap)) do %> + Copy bootstrap URL + <% end %> +
+
+ + +
diff --git a/app/views/boards/agent_bootstraps/show.json.jbuilder b/app/views/boards/agent_bootstraps/show.json.jbuilder new file mode 100644 index 0000000000..5b6a19897e --- /dev/null +++ b/app/views/boards/agent_bootstraps/show.json.jbuilder @@ -0,0 +1,16 @@ +json.id @agent_bootstrap.id +json.token @agent_bootstrap.token +json.expires_at @agent_bootstrap.expires_at.utc.iso8601 +json.bootstrap_url agent_bootstrap_claim_url_for(@agent_bootstrap) +json.skill_url agent_bootstrap_skill_url_for(@agent_bootstrap) +json.setup_command agent_bootstrap_setup_command(@agent_bootstrap) +json.skill_name agent_bootstrap_skill_name +json.skill_block agent_bootstrap_skill_block(@agent_bootstrap) +json.permission @agent_bootstrap.permission +json.involvement @agent_bootstrap.involvement + +json.account do + json.(@agent_bootstrap.account, :id, :name, :slug) +end + +json.board @agent_bootstrap.board, partial: "boards/board", as: :board diff --git a/app/views/boards/show.html.erb b/app/views/boards/show.html.erb index 93c1447bb4..a6d8019eec 100644 --- a/app/views/boards/show.html.erb +++ b/app/views/boards/show.html.erb @@ -7,6 +7,7 @@ <% content_for :header do %>
<%= link_to_webhooks(@board) if Current.user.admin? %> + <%= link_to_agent_bootstrap(@board) if Current.user.admin? %>

diff --git a/bin/fizzy b/bin/fizzy new file mode 100755 index 0000000000..5a4049915d --- /dev/null +++ b/bin/fizzy @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby + +$LOAD_PATH.unshift(File.expand_path("../cli/lib", __dir__)) + +load File.expand_path("../cli/exe/fizzy", __dir__) diff --git a/bin/install-fizzy-cli b/bin/install-fizzy-cli new file mode 100755 index 0000000000..7d54c1e2c8 --- /dev/null +++ b/bin/install-fizzy-cli @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CLI_DIR="$ROOT_DIR/cli" +USER_BIN_DIR="$HOME/.local/bin" +USER_FIZZY_BIN="$USER_BIN_DIR/fizzy" + +if ! command -v bundle >/dev/null; then + echo "Bundler is required. Install Ruby dependencies first (Ruby + Bundler)." >&2 + exit 1 +fi + +if [ ! -d "$CLI_DIR" ]; then + echo "CLI directory not found: $CLI_DIR" >&2 + exit 1 +fi + +cd "$CLI_DIR" + +echo "-> Installing CLI gems" +bundle install + +echo "-> Building fizzy-cli gem" +build_output=$(bundle exec gem build fizzy-cli.gemspec) +echo "$build_output" +gem_file=$(echo "$build_output" | awk '/File:/ {print $2}' | tail -n 1) + +if [ -z "$gem_file" ]; then + echo "Could not determine built gem file." >&2 + exit 1 +fi + +echo "-> Installing $gem_file for current user" +gem install --user-install "$gem_file" + +mkdir -p "$USER_BIN_DIR" +ln -sfn "$ROOT_DIR/bin/fizzy" "$USER_FIZZY_BIN" + +if [[ ":$PATH:" != *":$USER_BIN_DIR:"* ]]; then + if [ -f "$HOME/.bashrc" ] || [ -f "$HOME/.zshrc" ]; then + if [ -f "$HOME/.bashrc" ] && ! grep -qF "Added by fizzy-cli installer" "$HOME/.bashrc" 2>/dev/null; then + { + echo "" + echo "# Added by fizzy-cli installer" + echo "export PATH=\"$PATH:$USER_BIN_DIR\"" + } >> "$HOME/.bashrc" + echo "-> Added $USER_BIN_DIR to PATH in $HOME/.bashrc" + fi + if [ -f "$HOME/.zshrc" ] && ! grep -qF "Added by fizzy-cli installer" "$HOME/.zshrc" 2>/dev/null; then + { + echo "" + echo "# Added by fizzy-cli installer" + echo "export PATH=\"$PATH:$USER_BIN_DIR\"" + } >> "$HOME/.zshrc" + echo "-> Added $USER_BIN_DIR to PATH in $HOME/.zshrc" + fi + fi +fi + +echo +echo "Installation complete." +if command -v fizzy >/dev/null; then + echo "Verify with: fizzy --version" +else + echo "Verify with: $USER_FIZZY_BIN --version" +fi +"$USER_FIZZY_BIN" --version >/dev/null diff --git a/bin/install/fizzy/cli b/bin/install/fizzy/cli new file mode 100755 index 0000000000..13c8c703e3 --- /dev/null +++ b/bin/install/fizzy/cli @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +exec "$SCRIPT_DIR/bin/install-fizzy-cli" "$@" diff --git a/cli/Gemfile b/cli/Gemfile new file mode 100644 index 0000000000..b4e2a20bb6 --- /dev/null +++ b/cli/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" + +gemspec diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000000..4494883e18 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,34 @@ +# Fizzy CLI + +This directory contains the standalone `fizzy` CLI tool. + +## Install (one command) + +From the repo root: + +```bash +./bin/install-fizzy-cli +``` + +That script will: +1. install CLI dependencies, +2. build the `fizzy-cli` gem, +3. install it for the current user. +4. install a launcher at `~/.local/bin/fizzy`. +5. add `~/.local/bin` to your shell startup file when present (`.bashrc` or `.zshrc`), so you can use `fizzy` directly next shell session. + +## Development usage (no install) + +If you are testing against a local checkout, you can run the CLI without installing: + +```bash +cd cli +bundle exec exe/fizzy --version +``` + +You can also use the repo wrapper directly: + +```bash +cd /path/to/fizzy +./bin/fizzy --version +``` diff --git a/cli/exe/fizzy b/cli/exe/fizzy new file mode 100755 index 0000000000..bbdc70f6cf --- /dev/null +++ b/cli/exe/fizzy @@ -0,0 +1,12 @@ +#!/usr/bin/env ruby + +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) + +require "fizzy/cli" + +begin + Fizzy::CLI.start(ARGV) +rescue Fizzy::Error, Thor::Error => error + warn error.message + exit(1) +end diff --git a/cli/fizzy-cli.gemspec b/cli/fizzy-cli.gemspec new file mode 100644 index 0000000000..35aabc98fb --- /dev/null +++ b/cli/fizzy-cli.gemspec @@ -0,0 +1,15 @@ +require_relative "lib/fizzy/version" + +Gem::Specification.new do |spec| + spec.name = "fizzy-cli" + spec.version = Fizzy::VERSION + spec.authors = [ "OpenAI Codex" ] + spec.summary = "Standalone CLI for the Fizzy developer API" + spec.description = "A standalone command-line interface for authenticating with Fizzy and performing CRUD operations against boards, cards, comments, and related resources." + spec.files = Dir["exe/*", "lib/**/*.rb"] + spec.bindir = "exe" + spec.executables = [ "fizzy" ] + spec.require_paths = [ "lib" ] + spec.license = "MIT" + spec.add_dependency "thor" +end diff --git a/cli/lib/fizzy/cli.rb b/cli/lib/fizzy/cli.rb new file mode 100644 index 0000000000..69596074f0 --- /dev/null +++ b/cli/lib/fizzy/cli.rb @@ -0,0 +1,618 @@ +require "json" +require "thor" + +require_relative "client" +require_relative "config_store" +require_relative "version" + +module Fizzy + module CLIHelpers + private + def config_store + @config_store ||= ConfigStore.new + end + + def selected_profile_name + options[:profile] || ENV["FIZZY_PROFILE"] || config_store.current_profile_name + end + + def profile_settings(require_token: true, require_account: true) + profile = config_store.profile(selected_profile_name) || {} + + base_url = ENV["FIZZY_BASE_URL"] || profile["base_url"] + token = ENV["FIZZY_TOKEN"] || profile["token"] + account_slug = ENV["FIZZY_ACCOUNT"] || profile["account_slug"] + default_board_id = profile["default_board_id"] + + raise Error, "No active profile. Run `fizzy auth bootstrap ...` first." if base_url.to_s.empty? + raise Error, "No API token configured for the active profile." if require_token && token.to_s.empty? + raise Error, "No account configured for the active profile." if require_account && account_slug.to_s.empty? + + { + "base_url" => base_url, + "token" => token, + "account_slug" => account_slug, + "default_board_id" => default_board_id + } + end + + def client + settings = profile_settings(require_account: false) + @client ||= Client.new(base_url: settings.fetch("base_url"), token: settings["token"]) + end + + def account_scoped_path(path) + settings = profile_settings + slug = settings.fetch("account_slug") + path = path.delete_prefix("/") + "/#{slug}/#{path}" + end + + def request(method, path, params: nil, account_scope: true) + target = account_scope ? account_scoped_path(path) : path + client.request(method, target, params:) + end + + def request_with_full_url(method, url, params: nil) + client.request(method, url, params:) + end + + def default_board_id + profile_settings["default_board_id"] || raise(Error, "No default board configured; pass --board.") + end + + def compact_hash(hash) + hash.each_with_object({}) do |(key, value), compacted| + compacted[key] = value unless value.nil? + end + end + + def parse_json(value) + JSON.parse(value) + rescue JSON::ParserError => error + raise Error, "Invalid JSON payload: #{error.message}" + end + + def render_output(payload, empty_message: "OK") + if options[:json] + puts(JSON.pretty_generate(payload.nil? ? { "ok" => true } : payload)) + return + end + + case payload + when nil + puts empty_message + when Array + payload.each { |item| puts human_line(item) } + when Hash + puts human_hash(payload) + else + puts payload + end + end + + def human_line(item) + return item unless item.is_a?(Hash) + + compact = [ item["id"], item["number"], item["name"], item["title"], item["url"] ].compact + compact.empty? ? item.inspect : compact.join(" ") + end + + def human_hash(payload) + summary_keys = %w[id number name title status token permission url] + summary = payload.slice(*summary_keys).compact + return summary.map { |key, value| "#{key}: #{value}" }.join("\n") if summary.any? + + JSON.pretty_generate(payload) + end + end + + class AuthCommand < Thor + include CLIHelpers + + class_option :profile, type: :string, desc: "CLI profile to use" + class_option :json, type: :boolean, default: false, desc: "Print JSON output" + + desc "bootstrap URL", "Claim a one-time bootstrap URL and save a local profile" + option :email, type: :string, required: true, desc: "Identity email address for this agent" + option :name, type: :string, required: true, desc: "Display name for this agent" + option :profile_name, type: :string, desc: "Name embedded in the access token description" + def bootstrap(url) + bootstrap_client = Client.new(base_url: ENV["FIZZY_BASE_URL"] || url) + response = bootstrap_client.request(:post, url, params: { + email_address: options[:email], + name: options[:name], + profile_name: options[:profile_name] + }) + + profile_name = options[:profile] || options[:profile_name] || "#{response.dig("account", "slug")}-#{options[:email].split("@").first}" + + config_store.save_profile(profile_name, { + "base_url" => response.dig("profile", "base_url"), + "account_slug" => response.dig("profile", "account_slug"), + "default_board_id" => response.dig("profile", "default_board_id"), + "token" => response.fetch("token"), + "user_name" => response.dig("user", "name"), + "user_email" => response.dig("user", "email_address") + }) + + render_output(response.merge("profile_name" => profile_name), empty_message: "Saved profile #{profile_name}") + end + + desc "profiles", "List saved CLI profiles" + def profiles + payload = { + "current_profile" => config_store.current_profile_name, + "profiles" => config_store.profiles + } + render_output(payload) + end + + desc "use NAME", "Select the active CLI profile" + def use(name) + config_store.set_current_profile(name) + render_output({ "current_profile" => name }, empty_message: "Using profile #{name}") + end + end + + class AccountsCommand < Thor + include CLIHelpers + + class_option :profile, type: :string, desc: "CLI profile to use" + class_option :json, type: :boolean, default: false, desc: "Print JSON output" + + desc "list", "List the active identity's Fizzy accounts" + def list + response = request(:get, "/my/identity", account_scope: false) + render_output(response.fetch("accounts")) + end + end + + class BoardsCommand < Thor + include CLIHelpers + + class_option :profile, type: :string, desc: "CLI profile to use" + class_option :json, type: :boolean, default: false, desc: "Print JSON output" + + desc "list", "List boards" + def list + render_output(request(:get, "boards")) + end + + desc "get BOARD_ID", "Fetch a board" + def get(board_id) + render_output(request(:get, "boards/#{board_id}")) + end + + desc "create NAME", "Create a board" + option :all_access, type: :boolean, default: true + option :auto_postpone_days, type: :numeric + option :public_description, type: :string + def create(name) + render_output(request(:post, "boards", params: compact_hash( + name:, + all_access: options[:all_access], + auto_postpone_period_in_days: options[:auto_postpone_days], + public_description: options[:public_description] + ))) + end + + desc "update BOARD_ID", "Update a board" + option :name, type: :string + option :all_access, type: :boolean + option :auto_postpone_days, type: :numeric + option :public_description, type: :string + def update(board_id) + render_output(request(:put, "boards/#{board_id}", params: compact_hash( + name: options[:name], + all_access: options[:all_access], + auto_postpone_period_in_days: options[:auto_postpone_days], + public_description: options[:public_description] + ))) + end + + desc "delete BOARD_ID", "Delete a board" + def delete(board_id) + render_output(request(:delete, "boards/#{board_id}")) + end + + desc "watch BOARD_ID", "Watch a board for new cards" + def watch(board_id) + render_output(request(:put, "boards/#{board_id}/involvement", params: { involvement: "watching" })) + end + + desc "unwatch BOARD_ID", "Stop watching a board" + def unwatch(board_id) + render_output(request(:put, "boards/#{board_id}/involvement", params: { involvement: "access_only" })) + end + end + + class ColumnsCommand < Thor + include CLIHelpers + + class_option :profile, type: :string, desc: "CLI profile to use" + class_option :json, type: :boolean, default: false, desc: "Print JSON output" + + desc "list [BOARD_ID]", "List columns on a board" + def list(board_id = nil) + render_output(request(:get, "boards/#{board_id || default_board_id}/columns")) + end + + desc "get BOARD_ID COLUMN_ID", "Fetch a column" + def get(board_id, column_id) + render_output(request(:get, "boards/#{board_id}/columns/#{column_id}")) + end + + desc "create NAME", "Create a column" + option :board, type: :string + def create(name) + render_output(request(:post, "boards/#{options[:board] || default_board_id}/columns", params: { name: })) + end + + desc "update BOARD_ID COLUMN_ID", "Rename a column" + option :name, type: :string, required: true + def update(board_id, column_id) + render_output(request(:put, "boards/#{board_id}/columns/#{column_id}", params: { name: options[:name] })) + end + + desc "delete BOARD_ID COLUMN_ID", "Delete a column" + def delete(board_id, column_id) + render_output(request(:delete, "boards/#{board_id}/columns/#{column_id}")) + end + end + + class CardsCommand < Thor + include CLIHelpers + map "self-assign" => :self_assign + + class_option :profile, type: :string, desc: "CLI profile to use" + class_option :json, type: :boolean, default: false, desc: "Print JSON output" + + desc "list", "List cards visible to the current user" + def list + render_output(request(:get, "cards")) + end + + desc "get CARD_NUMBER", "Fetch a card" + def get(card_number) + render_output(request(:get, "cards/#{card_number}")) + end + + desc "create TITLE", "Create a card" + option :board, type: :string + option :description, type: :string + def create(title) + board_id = options[:board] || default_board_id + render_output(request(:post, "boards/#{board_id}/cards", params: compact_hash(title:, description: options[:description]))) + end + + desc "update CARD_NUMBER", "Update a card" + option :title, type: :string + option :description, type: :string + def update(card_number) + render_output(request(:put, "cards/#{card_number}", params: compact_hash(title: options[:title], description: options[:description]))) + end + + desc "delete CARD_NUMBER", "Delete a card" + def delete(card_number) + render_output(request(:delete, "cards/#{card_number}")) + end + + desc "move CARD_NUMBER", "Move a card to another board" + option :board, type: :string, required: true + def move(card_number) + render_output(request(:put, "cards/#{card_number}/board", params: { board_id: options[:board] })) + end + + desc "close CARD_NUMBER", "Move a card to Done" + def close(card_number) + render_output(request(:post, "cards/#{card_number}/closure")) + end + + desc "reopen CARD_NUMBER", "Reopen a closed card" + def reopen(card_number) + render_output(request(:delete, "cards/#{card_number}/closure")) + end + + desc "postpone CARD_NUMBER", "Move a card to Not Now" + def postpone(card_number) + render_output(request(:post, "cards/#{card_number}/not_now")) + end + + desc "triage CARD_NUMBER", "Move a card from triage into a column" + option :column, type: :string, required: true + def triage(card_number) + render_output(request(:post, "cards/#{card_number}/triage", params: { column_id: options[:column] })) + end + + desc "watch CARD_NUMBER", "Watch a card" + def watch(card_number) + render_output(request(:post, "cards/#{card_number}/watch")) + end + + desc "unwatch CARD_NUMBER", "Stop watching a card" + def unwatch(card_number) + render_output(request(:delete, "cards/#{card_number}/watch")) + end + + desc "pin CARD_NUMBER", "Pin a card" + def pin(card_number) + render_output(request(:post, "cards/#{card_number}/pin")) + end + + desc "unpin CARD_NUMBER", "Unpin a card" + def unpin(card_number) + render_output(request(:delete, "cards/#{card_number}/pin")) + end + + desc "assign CARD_NUMBER USER_ID", "Toggle assignment for a user on a card" + def assign(card_number, user_id) + render_output(request(:post, "cards/#{card_number}/assignments", params: { assignee_id: user_id })) + end + + desc "self_assign CARD_NUMBER", "Toggle self-assignment on a card" + def self_assign(card_number) + render_output(request(:post, "cards/#{card_number}/self_assignment")) + end + + desc "tag CARD_NUMBER TAG_TITLE", "Toggle a tag on a card" + def tag(card_number, tag_title) + render_output(request(:post, "cards/#{card_number}/taggings", params: { tag_title: })) + end + end + + class CommentsCommand < Thor + include CLIHelpers + + class_option :profile, type: :string, desc: "CLI profile to use" + class_option :json, type: :boolean, default: false, desc: "Print JSON output" + + desc "list CARD_NUMBER", "List comments on a card" + def list(card_number) + render_output(request(:get, "cards/#{card_number}/comments")) + end + + desc "get CARD_NUMBER COMMENT_ID", "Fetch a comment" + def get(card_number, comment_id) + render_output(request(:get, "cards/#{card_number}/comments/#{comment_id}")) + end + + desc "create CARD_NUMBER BODY", "Create a comment" + def create(card_number, body) + render_output(request(:post, "cards/#{card_number}/comments", params: { body: })) + end + + desc "update CARD_NUMBER COMMENT_ID BODY", "Update a comment" + def update(card_number, comment_id, body) + render_output(request(:put, "cards/#{card_number}/comments/#{comment_id}", params: { body: })) + end + + desc "delete CARD_NUMBER COMMENT_ID", "Delete a comment" + def delete(card_number, comment_id) + render_output(request(:delete, "cards/#{card_number}/comments/#{comment_id}")) + end + end + + class StepsCommand < Thor + include CLIHelpers + + class_option :profile, type: :string, desc: "CLI profile to use" + class_option :json, type: :boolean, default: false, desc: "Print JSON output" + + desc "list CARD_NUMBER", "List steps on a card" + def list(card_number) + render_output(request(:get, "cards/#{card_number}/steps")) + end + + desc "get CARD_NUMBER STEP_ID", "Fetch a step" + def get(card_number, step_id) + render_output(request(:get, "cards/#{card_number}/steps/#{step_id}")) + end + + desc "create CARD_NUMBER CONTENT", "Create a step" + def create(card_number, content) + render_output(request(:post, "cards/#{card_number}/steps", params: { content: })) + end + + desc "update CARD_NUMBER STEP_ID CONTENT", "Update a step" + def update(card_number, step_id, content) + render_output(request(:put, "cards/#{card_number}/steps/#{step_id}", params: { content: })) + end + + desc "delete CARD_NUMBER STEP_ID", "Delete a step" + def delete(card_number, step_id) + render_output(request(:delete, "cards/#{card_number}/steps/#{step_id}")) + end + end + + class TagsCommand < Thor + include CLIHelpers + + class_option :profile, type: :string, desc: "CLI profile to use" + class_option :json, type: :boolean, default: false, desc: "Print JSON output" + + desc "list", "List tags" + def list + render_output(request(:get, "tags")) + end + end + + class UsersCommand < Thor + include CLIHelpers + + class_option :profile, type: :string, desc: "CLI profile to use" + class_option :json, type: :boolean, default: false, desc: "Print JSON output" + + desc "list", "List users in the current account" + def list + render_output(request(:get, "users")) + end + + desc "get USER_ID", "Fetch a user" + def get(user_id) + render_output(request(:get, "users/#{user_id}")) + end + + desc "update USER_ID", "Update a user" + option :name, type: :string, required: true + def update(user_id) + render_output(request(:put, "users/#{user_id}", params: { name: options[:name] })) + end + + desc "delete USER_ID", "Delete a user" + def delete(user_id) + render_output(request(:delete, "users/#{user_id}")) + end + end + + class NotificationsCommand < Thor + include CLIHelpers + map "read-all" => :read_all + + class_option :profile, type: :string, desc: "CLI profile to use" + class_option :json, type: :boolean, default: false, desc: "Print JSON output" + + desc "list", "List notifications" + def list + render_output(request(:get, "notifications")) + end + + desc "read NOTIFICATION_ID", "Mark a notification as read" + def read(notification_id) + render_output(request(:post, "notifications/#{notification_id}/reading")) + end + + desc "unread NOTIFICATION_ID", "Mark a notification as unread" + def unread(notification_id) + render_output(request(:delete, "notifications/#{notification_id}/reading")) + end + + desc "read_all", "Mark all notifications as read" + def read_all + render_output(request(:post, "notifications/bulk_reading")) + end + end + + class WebhooksCommand < Thor + include CLIHelpers + + class_option :profile, type: :string, desc: "CLI profile to use" + class_option :json, type: :boolean, default: false, desc: "Print JSON output" + + desc "list BOARD_ID", "List webhooks on a board" + def list(board_id) + render_output(request(:get, "boards/#{board_id}/webhooks")) + end + + desc "get BOARD_ID WEBHOOK_ID", "Fetch a webhook" + def get(board_id, webhook_id) + render_output(request(:get, "boards/#{board_id}/webhooks/#{webhook_id}")) + end + + desc "create BOARD_ID NAME URL", "Create a webhook" + option :actions, type: :string, required: true, desc: "Comma-separated subscribed actions" + def create(board_id, name, url) + render_output(request(:post, "boards/#{board_id}/webhooks", params: { + name:, + url:, + subscribed_actions: options[:actions].split(",").map(&:strip) + })) + end + + desc "update BOARD_ID WEBHOOK_ID", "Update a webhook" + option :name, type: :string + option :url, type: :string + option :actions, type: :string, desc: "Comma-separated subscribed actions" + def update(board_id, webhook_id) + render_output(request(:patch, "boards/#{board_id}/webhooks/#{webhook_id}", params: compact_hash( + name: options[:name], + url: options[:url], + subscribed_actions: options[:actions]&.split(",")&.map(&:strip) + ))) + end + + desc "delete BOARD_ID WEBHOOK_ID", "Delete a webhook" + def delete(board_id, webhook_id) + render_output(request(:delete, "boards/#{board_id}/webhooks/#{webhook_id}")) + end + + desc "activate BOARD_ID WEBHOOK_ID", "Reactivate an inactive webhook" + def activate(board_id, webhook_id) + render_output(request(:post, "boards/#{board_id}/webhooks/#{webhook_id}/activation")) + end + end + + class CLI < Thor + include CLIHelpers + + def self.exit_on_failure? + true + end + + class_option :profile, type: :string, desc: "CLI profile to use" + class_option :json, type: :boolean, default: false, desc: "Print JSON output" + + desc "whoami", "Show the active identity and accounts" + def whoami + render_output(request(:get, "/my/identity", account_scope: false)) + end + + desc "api METHOD PATH", "Call a raw Fizzy API path" + option :data, type: :string, desc: "JSON request body" + option :account_scope, type: :boolean, default: false, desc: "Prefix the current account slug to PATH" + def api(method, path) + payload = options[:data] ? parse_json(options[:data]) : nil + render_output(request(method, path, params: payload, account_scope: options[:account_scope])) + end + + desc "version", "Print the CLI version" + def version + puts VERSION + end + + desc "auth SUBCOMMAND ...ARGS", "Authentication and profile commands" + subcommand "auth", AuthCommand + + desc "accounts SUBCOMMAND ...ARGS", "Account discovery commands" + subcommand "accounts", AccountsCommand + + desc "boards SUBCOMMAND ...ARGS", "Board CRUD commands" + subcommand "boards", BoardsCommand + + desc "columns SUBCOMMAND ...ARGS", "Column CRUD commands" + subcommand "columns", ColumnsCommand + + desc "cards SUBCOMMAND ...ARGS", "Card CRUD and action commands" + subcommand "cards", CardsCommand + + desc "comments SUBCOMMAND ...ARGS", "Comment CRUD commands" + subcommand "comments", CommentsCommand + + desc "steps SUBCOMMAND ...ARGS", "Checklist step CRUD commands" + subcommand "steps", StepsCommand + + desc "tags SUBCOMMAND ...ARGS", "Tag read commands" + subcommand "tags", TagsCommand + + desc "users SUBCOMMAND ...ARGS", "User CRUD commands" + subcommand "users", UsersCommand + + desc "notifications SUBCOMMAND ...ARGS", "Notification commands" + subcommand "notifications", NotificationsCommand + + desc "webhooks SUBCOMMAND ...ARGS", "Webhook CRUD commands" + subcommand "webhooks", WebhooksCommand + + def self.handle_argument_error(command, error, _args, _arity) + warn error.message + exit(1) + end + + def self.handle_no_command_error(command, has_namespace = $thor_runner) + super + rescue Thor::Error => error + warn error.message + exit(1) + end + end +end diff --git a/cli/lib/fizzy/client.rb b/cli/lib/fizzy/client.rb new file mode 100644 index 0000000000..a8db3dbb59 --- /dev/null +++ b/cli/lib/fizzy/client.rb @@ -0,0 +1,84 @@ +require "json" +require "net/http" +require "uri" +require_relative "error" + +module Fizzy + class ApiError < Error + attr_reader :status, :body + + def initialize(status, body) + @status = status + @body = body + super("HTTP #{status}: #{body_summary}") + end + + def body_summary + case body + when Hash + body["message"] || body["error"] || body.inspect + when String + body + else + body.to_s + end + end + end + + class Client + def initialize(base_url:, token: nil) + @base_url = base_url.end_with?("/") ? base_url : "#{base_url}/" + @token = token + end + + def request(method, path_or_url, params: nil) + uri = absolute_uri(path_or_url) + request = request_class(method).new(uri) + request["Accept"] = "application/json" + request["Authorization"] = "Bearer #{@token}" if @token + + if params + request["Content-Type"] = "application/json" + request.body = JSON.dump(params) + end + + response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http| + http.request(request) + end + + body = parse_body(response) + raise ApiError.new(response.code.to_i, body) unless response.code.to_i.between?(200, 299) + + body + end + + private + def absolute_uri(path_or_url) + return URI(path_or_url) if path_or_url.start_with?("http://", "https://") + + URI.join(@base_url, path_or_url.delete_prefix("/")) + end + + def parse_body(response) + return nil if response.body.nil? || response.body.empty? + + content_type = response["Content-Type"].to_s + return JSON.parse(response.body) if content_type.include?("json") + + response.body + rescue JSON::ParserError + response.body + end + + def request_class(method) + case method.to_s.upcase + when "GET" then Net::HTTP::Get + when "POST" then Net::HTTP::Post + when "PUT" then Net::HTTP::Put + when "PATCH" then Net::HTTP::Patch + when "DELETE" then Net::HTTP::Delete + else raise Error, "Unsupported HTTP method #{method}" + end + end + end +end diff --git a/cli/lib/fizzy/config_store.rb b/cli/lib/fizzy/config_store.rb new file mode 100644 index 0000000000..8867af2695 --- /dev/null +++ b/cli/lib/fizzy/config_store.rb @@ -0,0 +1,59 @@ +require "fileutils" +require "yaml" +require_relative "error" + +module Fizzy + class ConfigStore + DEFAULT_PATH = File.expand_path("~/.config/fizzy/config.yml") + + attr_reader :path + + def initialize(path: ENV["FIZZY_CONFIG"] || DEFAULT_PATH) + @path = path + end + + def load + return { "profiles" => {} } unless File.exist?(path) + + YAML.safe_load(File.read(path), permitted_classes: [], aliases: false) || { "profiles" => {} } + end + + def save_profile(name, attributes, set_current: true) + data = load + data["profiles"] ||= {} + data["profiles"][name] = attributes + data["current_profile"] = name if set_current + persist(data) + end + + def set_current_profile(name) + data = load + raise Error, "Unknown profile #{name}" unless data.fetch("profiles", {}).key?(name) + + data["current_profile"] = name + persist(data) + end + + def current_profile_name + ENV["FIZZY_PROFILE"] || load["current_profile"] + end + + def profile(name = current_profile_name) + return unless name + + load.fetch("profiles", {})[name] + end + + def profiles + load.fetch("profiles", {}) + end + + private + def persist(data) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, YAML.dump(data)) + File.chmod(0o600, path) + data + end + end +end diff --git a/cli/lib/fizzy/error.rb b/cli/lib/fizzy/error.rb new file mode 100644 index 0000000000..a1d6abeefa --- /dev/null +++ b/cli/lib/fizzy/error.rb @@ -0,0 +1,3 @@ +module Fizzy + class Error < StandardError; end +end diff --git a/cli/lib/fizzy/version.rb b/cli/lib/fizzy/version.rb new file mode 100644 index 0000000000..73e55387d0 --- /dev/null +++ b/cli/lib/fizzy/version.rb @@ -0,0 +1,3 @@ +module Fizzy + VERSION = "0.1.0" +end diff --git a/config/routes.rb b/config/routes.rb index a21a0c4575..ec2fd2a9ea 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -27,6 +27,7 @@ resources :boards do scope module: :boards do + resources :agent_bootstraps, only: %i[ new create show ] resource :subscriptions resource :involvement resource :publication @@ -142,6 +143,9 @@ resources :qr_codes + get "agent_bootstrap/:token/skill", to: "agent_bootstrap_skills#show", as: :agent_bootstrap_skill + post "agent_bootstrap/:token/claim", to: "agent_bootstrap_claims#create", as: :agent_bootstrap_claim + get "join/:code", to: "join_codes#new", as: :join post "join/:code", to: "join_codes#create" diff --git a/db/migrate/20260325120000_create_board_agent_bootstraps.rb b/db/migrate/20260325120000_create_board_agent_bootstraps.rb new file mode 100644 index 0000000000..897de22de3 --- /dev/null +++ b/db/migrate/20260325120000_create_board_agent_bootstraps.rb @@ -0,0 +1,24 @@ +class CreateBoardAgentBootstraps < ActiveRecord::Migration[8.2] + def change + create_table :board_agent_bootstraps, id: :uuid do |t| + t.uuid :account_id, null: false + t.uuid :board_id, null: false + t.uuid :creator_id, null: false + t.uuid :claimed_by_identity_id + t.string :token + t.string :permission, null: false + t.string :involvement, null: false + t.datetime :expires_at, null: false + t.datetime :claimed_at + + t.timestamps + + t.index :account_id + t.index :board_id + t.index :creator_id + t.index :claimed_by_identity_id + t.index :expires_at + t.index :token, unique: true + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 99e6be953e..ed6a32af87 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: 2026_02_18_120000) do +ActiveRecord::Schema[8.2].define(version: 2026_03_25_120000) 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 @@ -157,6 +157,26 @@ t.index ["card_id"], name: "index_assignments_on_card_id" end + create_table "board_agent_bootstraps", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "board_id", null: false + t.datetime "claimed_at" + t.uuid "claimed_by_identity_id" + t.datetime "created_at", null: false + t.uuid "creator_id", null: false + t.datetime "expires_at", null: false + t.string "involvement", null: false + t.string "permission", null: false + t.string "token" + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_board_agent_bootstraps_on_account_id" + t.index ["board_id"], name: "index_board_agent_bootstraps_on_board_id" + t.index ["claimed_by_identity_id"], name: "index_board_agent_bootstraps_on_claimed_by_identity_id" + t.index ["creator_id"], name: "index_board_agent_bootstraps_on_creator_id" + t.index ["expires_at"], name: "index_board_agent_bootstraps_on_expires_at" + t.index ["token"], name: "index_board_agent_bootstraps_on_token", unique: true + end + create_table "board_publications", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "board_id", null: false diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 00125d1d6a..92a5741fa4 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: 2026_02_18_120000) do +ActiveRecord::Schema[8.2].define(version: 2026_03_25_120000) do create_table "accesses", id: :uuid, force: :cascade do |t| t.datetime "accessed_at" t.uuid "account_id", null: false @@ -42,7 +42,7 @@ t.uuid "account_id" t.datetime "completed_at" t.datetime "created_at", null: false - t.string "failure_reason", limit: 255 + t.string "failure_reason" t.uuid "identity_id", null: false t.string "status", limit: 255, default: "pending", null: false t.datetime "updated_at", null: false @@ -157,6 +157,26 @@ t.index ["card_id"], name: "index_assignments_on_card_id" end + create_table "board_agent_bootstraps", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "board_id", null: false + t.datetime "claimed_at" + t.uuid "claimed_by_identity_id" + t.datetime "created_at", null: false + t.uuid "creator_id", null: false + t.datetime "expires_at", null: false + t.string "involvement", limit: 255, null: false + t.string "permission", limit: 255, null: false + t.string "token", limit: 255 + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_board_agent_bootstraps_on_account_id" + t.index ["board_id"], name: "index_board_agent_bootstraps_on_board_id" + t.index ["claimed_by_identity_id"], name: "index_board_agent_bootstraps_on_claimed_by_identity_id" + t.index ["creator_id"], name: "index_board_agent_bootstraps_on_creator_id" + t.index ["expires_at"], name: "index_board_agent_bootstraps_on_expires_at" + t.index ["token"], name: "index_board_agent_bootstraps_on_token", unique: true + end + create_table "board_publications", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "board_id", null: false @@ -316,7 +336,7 @@ t.datetime "completed_at" t.datetime "created_at", null: false t.string "status", limit: 255, default: "pending", null: false - t.string "type", limit: 255 + t.string "type" t.datetime "updated_at", null: false t.uuid "user_id", null: false t.index ["account_id"], name: "index_exports_on_account_id" @@ -512,7 +532,7 @@ t.string "operation", limit: 255, null: false t.uuid "recordable_id" t.string "recordable_type", limit: 255 - t.string "request_id", limit: 255 + t.string "request_id" t.uuid "user_id" t.index ["account_id"], name: "index_storage_entries_on_account_id" t.index ["blob_id"], name: "index_storage_entries_on_blob_id" diff --git a/docs/API.md b/docs/API.md index e8b60697c2..739b4d3a0b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -176,6 +176,91 @@ HTTP/1.1 201 Created Store the `token` value securely — it won't be retrievable again. Use it as a Bearer token for subsequent API requests. +#### Create an agent bootstrap link via the API + +If you want an external agent to join a specific board, get its own write token, and start watching that board immediately, create a board-scoped bootstrap: + +```bash +curl -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -H "Authorization: Bearer put-your-access-token-here" \ + https://app.fizzy.do/1234567/boards/abc123/agent_bootstraps +``` + +__Response:__ + +```json +{ + "id": "9l7v2wbw5s6r7ryz6q4m2m7ps", + "token": "6dFq9yY9fA5vP4a9QqG4gC8R", + "expires_at": "2026-03-25T11:30:00Z", + "bootstrap_url": "https://app.fizzy.do/agent_bootstrap/6dFq9yY9fA5vP4a9QqG4gC8R/claim", + "skill_url": "https://app.fizzy.do/agent_bootstrap/6dFq9yY9fA5vP4a9QqG4gC8R/skill", + "setup_command": "fizzy auth bootstrap \"https://app.fizzy.do/agent_bootstrap/6dFq9yY9fA5vP4a9QqG4gC8R/claim\" --email \"agent+abc123@example.com\" --name \"Writebook Agent\"", + "skill_name": "fizzy-cli", + "skill_block": "Download the Fizzy CLI skill from:\nhttps://app.fizzy.do/agent_bootstrap/6dFq9yY9fA5vP4a9QqG4gC8R/skill\n\nLoad that skill into your agent, then run:\nfizzy auth bootstrap \"https://app.fizzy.do/agent_bootstrap/6dFq9yY9fA5vP4a9QqG4gC8R/claim\" --email \"agent+abc123@example.com\" --name \"Writebook Agent\"\n\nVerify the bootstrap with:\nfizzy whoami --json", + "permission": "write", + "involvement": "watching" +} +``` + +This endpoint requires account admin or owner privileges. The token is single-use and expires automatically. + +#### Download the bootstrap skill + +Agents can download the exact skill instructions associated with a bootstrap before running the CLI bootstrap command: + +```bash +curl -H "Accept: text/markdown" \ + https://app.fizzy.do/agent_bootstrap/6dFq9yY9fA5vP4a9QqG4gC8R/skill +``` + +This returns the current `fizzy-cli` `SKILL.md` content for that bootstrap token. Once the bootstrap has expired or been claimed, the endpoint returns `410 Gone`. + +#### Claim an agent bootstrap link + +Agents should claim the bootstrap URL directly. This creates or reuses the identity, ensures account membership, grants access to the target board when needed, creates a personal access token, and sets the agent to watch the board. + +```bash +curl -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d '{"email_address":"agent@example.com","name":"Writebook Agent","profile_name":"openclaw"}' \ + https://app.fizzy.do/agent_bootstrap/6dFq9yY9fA5vP4a9QqG4gC8R/claim +``` + +__Response:__ + +```json +{ + "token": "4f9Q6d2wXr8Kp1Ls0Vz3BnTa", + "permission": "write", + "account": { + "slug": "1234567" + }, + "board": { + "id": "abc123", + "name": "Writebook" + }, + "user": { + "email_address": "agent@example.com", + "name": "Writebook Agent" + }, + "profile": { + "base_url": "https://app.fizzy.do", + "account_slug": "1234567", + "default_board_id": "abc123" + } +} +``` + +Error responses: + +| Status Code | Description | +|--------|-------------| +| `410 Gone` | The bootstrap token is invalid, expired, or already used | + ## Caching Most endpoints return [ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag) and [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control) headers. You can use these to avoid re-downloading unchanged data. @@ -543,6 +628,21 @@ Returns `204 No Content` on success. ### `DELETE /:account_slug/boards/:board_id` +### `PUT /:account_slug/boards/:board_id/involvement` + +Set your board involvement. Use `watching` to subscribe the current user to new cards on the board and `access_only` to unsubscribe. + +```bash +curl -X PUT \ + -H "Authorization: Bearer put-your-access-token-here" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d '{"involvement":"watching"}' \ + https://app.fizzy.do/1234567/boards/abc123/involvement +``` + +Returns `204 No Content` on success. + Deletes a Board. Only board administrators can delete a board. __Response:__ diff --git a/skills/fizzy-cli/SKILL.md b/skills/fizzy-cli/SKILL.md new file mode 100644 index 0000000000..0751144f70 --- /dev/null +++ b/skills/fizzy-cli/SKILL.md @@ -0,0 +1,40 @@ +--- +name: fizzy-cli +description: Use the standalone Fizzy CLI for board, card, comment, user, webhook, and notification work. Trigger when an agent is asked to operate on a Fizzy instance and has a bootstrap link/command or an existing CLI profile. Prefer the CLI over raw curl. Each agent must use its own bootstrap/profile and should watch the target board. +--- + +# Fizzy CLI + +Use this skill when working against a Fizzy instance through the standalone `fizzy` CLI. + +## Workflow + +1. If the user gives you a Fizzy bootstrap command or bootstrap URL, run `fizzy auth bootstrap ...` first. +2. Immediately verify context with `fizzy whoami --json`. +3. Use `fizzy ... --json` for resource operations. +4. Prefer board-scoped work using the profile's default board. +5. Use `fizzy api` only when the wrapper command you need does not exist. + +## Rules + +- Each agent must use its own bootstrap link and its own CLI profile. +- Never reuse or share another agent's token. +- Assume bootstrap already subscribed the agent to the board by setting board involvement to `watching`. +- If a task depends on a different board, switch explicitly by passing `--board` or use a different profile. +- Prefer machine-readable output: add `--json` unless the user explicitly wants human-formatted output. + +## Quick start + +```bash +fizzy auth bootstrap "https://app.fizzy.do/agent_bootstrap/..." --email "agent@example.com" --name "Board Agent" +fizzy whoami --json +fizzy boards list --json +fizzy cards create "Investigate bug" --description "Initial triage notes" --json +fizzy comments create 42 "Looking at this now." --json +``` + +## Command map + +- Auth/bootstrap/profile use: see `references/commands.md` +- Board and card operations: see `references/commands.md` +- Raw escape hatch: `fizzy api METHOD PATH --account-scope --data '{...}'` diff --git a/skills/fizzy-cli/agents/openai.yaml b/skills/fizzy-cli/agents/openai.yaml new file mode 100644 index 0000000000..c6373f1ed8 --- /dev/null +++ b/skills/fizzy-cli/agents/openai.yaml @@ -0,0 +1,3 @@ +display_name: Fizzy CLI +short_description: Use the standalone Fizzy CLI with isolated agent credentials and board-scoped bootstrap links. +default_prompt: Use the Fizzy CLI instead of raw HTTP. Bootstrap first if a setup link is available, verify with `fizzy whoami --json`, and keep each agent on its own profile and token. diff --git a/skills/fizzy-cli/references/commands.md b/skills/fizzy-cli/references/commands.md new file mode 100644 index 0000000000..46ccc5ae1d --- /dev/null +++ b/skills/fizzy-cli/references/commands.md @@ -0,0 +1,60 @@ +# Fizzy CLI Commands + +## Bootstrap and profiles + +```bash +fizzy auth bootstrap "" --email "agent@example.com" --name "Board Agent" +fizzy auth profiles --json +fizzy auth use my-profile +fizzy whoami --json +fizzy accounts list --json +``` + +## Boards and columns + +```bash +fizzy boards list --json +fizzy boards get --json +fizzy boards create "New board" --json +fizzy boards update --name "Renamed" --json +fizzy boards watch --json +fizzy columns list --json +fizzy columns create "In progress" --board --json +``` + +## Cards, comments, and steps + +```bash +fizzy cards list --json +fizzy cards get --json +fizzy cards create "Fix login bug" --description "Repro and notes" --board --json +fizzy cards update --title "Fix auth bug" --json +fizzy cards move --board --json +fizzy cards close --json +fizzy cards reopen --json +fizzy cards postpone --json +fizzy cards triage --column --json +fizzy cards watch --json +fizzy cards assign --json +fizzy cards tag bug --json +fizzy comments list --json +fizzy comments create "Starting on this." --json +fizzy steps create "Write regression test" --json +``` + +## Users, notifications, and webhooks + +```bash +fizzy users list --json +fizzy notifications list --json +fizzy notifications read-all --json +fizzy webhooks list --json +fizzy webhooks create "Campfire" "https://example.com/hook" --actions card_published,card_closed --json +``` + +## Raw API + +```bash +fizzy api GET cards --account-scope --json +fizzy api POST boards --account-scope --data '{"name":"Experimental"}' --json +``` diff --git a/test/controllers/agent_bootstrap_claims_controller_test.rb b/test/controllers/agent_bootstrap_claims_controller_test.rb new file mode 100644 index 0000000000..8f4b5dfafc --- /dev/null +++ b/test/controllers/agent_bootstrap_claims_controller_test.rb @@ -0,0 +1,140 @@ +require "test_helper" + +class AgentBootstrapClaimsControllerTest < ActionDispatch::IntegrationTest + test "claim creates identity user access token and board watching access" do + bootstrap = boards(:private).agent_bootstraps.create!( + account: accounts("37s"), + creator: users(:kevin), + expires_at: 30.minutes.from_now + ) + + email = "agent-#{SecureRandom.hex(4)}@example.com" + + untenanted do + assert_difference [ -> { Identity.count }, -> { User.count }, -> { Identity::AccessToken.count } ], +1 do + post agent_bootstrap_claim_path(token: bootstrap.token), + params: { email_address: email, name: "Board Agent", profile_name: "openclaw" }, + as: :json + end + end + + assert_response :created + bootstrap.reload + identity = Identity.find_by!(email_address: email) + user = identity.users.find_by!(account: bootstrap.account) + + assert bootstrap.claimed? + assert_equal identity, bootstrap.claimed_by_identity + assert_nil user.verified_at + assert_equal "watching", bootstrap.board.access_for(user).involvement + assert_equal bootstrap.account.slug, @response.parsed_body.dig("account", "slug") + assert_equal bootstrap.board.id, @response.parsed_body.dig("board", "id") + assert @response.parsed_body["token"].present? + end + + test "claim reuses an existing user in the same account" do + bootstrap = boards(:private).agent_bootstraps.create!( + account: accounts("37s"), + creator: users(:kevin), + expires_at: 30.minutes.from_now + ) + user = users(:jz) + bootstrap.board.accesses.create!(user:, account: bootstrap.account, involvement: :access_only) + + untenanted do + assert_no_difference [ -> { Identity.count }, -> { User.count } ] do + assert_difference -> { user.identity.access_tokens.count }, +1 do + post agent_bootstrap_claim_path(token: bootstrap.token), + params: { email_address: user.identity.email_address, name: user.name }, + as: :json + end + end + end + + assert_response :created + assert_equal "watching", bootstrap.board.access_for(user).reload.involvement + assert_equal user.identity, bootstrap.reload.claimed_by_identity + end + + test "claim rejects an existing identity from another account" do + bootstrap = boards(:writebook).agent_bootstraps.create!( + account: accounts("37s"), + creator: users(:kevin), + expires_at: 30.minutes.from_now + ) + + untenanted do + assert_no_difference -> { Identity::AccessToken.count } do + post agent_bootstrap_claim_path(token: bootstrap.token), + params: { email_address: identities(:mike).email_address, name: "Mike Agent" }, + as: :json + end + end + + assert_response :unprocessable_entity + assert_includes @response.parsed_body.fetch("errors"), "Bootstrap claims cannot reuse an identity from another account" + assert_not bootstrap.reload.claimed? + end + + test "claim returns a token that can access the board while the user remains unverified" do + bootstrap = boards(:private).agent_bootstraps.create!( + account: accounts("37s"), + creator: users(:kevin), + expires_at: 30.minutes.from_now + ) + + body = nil + + untenanted do + post agent_bootstrap_claim_path(token: bootstrap.token), + params: { email_address: "agent-#{SecureRandom.hex(4)}@example.com", name: "Board Agent" }, + as: :json + assert_response :created + body = @response.parsed_body + end + + user = Identity.find_by!(email_address: body.dig("user", "email_address")).users.find_by!(account: bootstrap.account) + assert_not user.verified? + + get body.dig("board", "url"), as: :json, env: { "HTTP_AUTHORIZATION" => "Bearer #{body.fetch("token")}" } + + assert_response :success + end + + test "claim returns gone for expired bootstrap" do + bootstrap = boards(:writebook).agent_bootstraps.create!( + account: accounts("37s"), + creator: users(:kevin), + expires_at: 1.minute.ago + ) + + untenanted do + post agent_bootstrap_claim_path(token: bootstrap.token), + params: { email_address: "expired@example.com", name: "Expired Agent" }, + as: :json + end + + assert_response :gone + end + + test "claim returns gone once already used" do + bootstrap = boards(:writebook).agent_bootstraps.create!( + account: accounts("37s"), + creator: users(:kevin), + expires_at: 30.minutes.from_now + ) + + untenanted do + post agent_bootstrap_claim_path(token: bootstrap.token), + params: { email_address: "used@example.com", name: "Used Agent" }, + as: :json + assert_response :created + + post agent_bootstrap_claim_path(token: bootstrap.token), + params: { email_address: "used-again@example.com", name: "Used Again" }, + as: :json + end + + assert_response :gone + end +end diff --git a/test/controllers/agent_bootstrap_skills_controller_test.rb b/test/controllers/agent_bootstrap_skills_controller_test.rb new file mode 100644 index 0000000000..74f51950d4 --- /dev/null +++ b/test/controllers/agent_bootstrap_skills_controller_test.rb @@ -0,0 +1,50 @@ +require "test_helper" + +class AgentBootstrapSkillsControllerTest < ActionDispatch::IntegrationTest + test "show returns the skill for an active bootstrap" do + bootstrap = boards(:writebook).agent_bootstraps.create!( + account: accounts("37s"), + creator: users(:kevin), + expires_at: 30.minutes.from_now + ) + + untenanted do + get agent_bootstrap_skill_path(token: bootstrap.token) + end + + assert_response :success + assert_equal "text/markdown; charset=utf-8", response.media_type + "; charset=#{response.charset}" + assert_in_body "name: fizzy-cli" + assert_in_body "fizzy auth bootstrap" + end + + test "show returns gone for an expired bootstrap" do + bootstrap = boards(:writebook).agent_bootstraps.create!( + account: accounts("37s"), + creator: users(:kevin), + expires_at: 1.minute.ago + ) + + untenanted do + get agent_bootstrap_skill_path(token: bootstrap.token) + end + + assert_response :gone + end + + test "show returns gone once a bootstrap has been claimed" do + bootstrap = boards(:writebook).agent_bootstraps.create!( + account: accounts("37s"), + creator: users(:kevin), + expires_at: 30.minutes.from_now, + claimed_at: Time.current, + claimed_by_identity: identities(:david) + ) + + untenanted do + get agent_bootstrap_skill_path(token: bootstrap.token) + end + + assert_response :gone + end +end diff --git a/test/controllers/boards/agent_bootstraps_controller_test.rb b/test/controllers/boards/agent_bootstraps_controller_test.rb new file mode 100644 index 0000000000..09464016fd --- /dev/null +++ b/test/controllers/boards/agent_bootstraps_controller_test.rb @@ -0,0 +1,133 @@ +require "test_helper" +require "shellwords" + +class Boards::AgentBootstrapsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "new" do + get new_board_agent_bootstrap_path(boards(:writebook)) + assert_response :success + assert_in_body "Generate setup command" + end + + test "create as JSON" do + board = boards(:writebook) + + assert_difference -> { board.agent_bootstraps.count }, +1 do + post board_agent_bootstraps_path(board), as: :json + end + + assert_response :created + body = @response.parsed_body + assert body["bootstrap_url"].present? + assert body["skill_url"].present? + assert body["setup_command"].present? + assert_equal "fizzy-cli", body["skill_name"] + assert_match %r{/agent_bootstrap/[^/]+/claim\z}, body["bootstrap_url"] + assert_match %r{/agent_bootstrap/[^/]+/skill\z}, body["skill_url"] + assert_not_includes body["bootstrap_url"], "/#{board.account.slug}/" + assert_not_includes body["skill_url"], "/#{board.account.slug}/" + assert_includes body["skill_block"], body["skill_url"] + assert_includes body["skill_block"], body["setup_command"] + assert_equal "watching", body["involvement"] + assert_equal board.id, body.dig("board", "id") + end + + test "setup command shell-escapes board-derived arguments" do + board = boards(:writebook) + board.update!(name: %(Danger "$(touch /tmp/nope)" `rm -rf /`)) + + post board_agent_bootstraps_path(board), as: :json + + assert_response :created + command = @response.parsed_body.fetch("setup_command") + argv = Shellwords.split(command) + + assert_equal "fizzy", argv[0] + assert_equal "auth", argv[1] + assert_equal "bootstrap", argv[2] + assert_equal "--email", argv[4] + assert_match(/\Aagent\+[A-Za-z0-9]{8}@example\.com\z/, argv[5]) + assert_equal "#{board.name} Agent", argv.last + end + + test "new requires account admin" do + logout_and_sign_in_as :jz + + get new_board_agent_bootstrap_path(boards(:writebook)) + + assert_response :forbidden + end + + test "show" do + bootstrap = boards(:writebook).agent_bootstraps.create!( + account: accounts("37s"), + creator: users(:kevin), + expires_at: 30.minutes.from_now + ) + + get board_agent_bootstrap_path(bootstrap.board, bootstrap) + assert_response :success + assert_in_body bootstrap.token + assert_in_body "Copy skill URL" + assert_in_body "Copy agent prompt" + end + + test "show requires account admin" do + bootstrap = boards(:writebook).agent_bootstraps.create!( + account: accounts("37s"), + creator: users(:kevin), + expires_at: 30.minutes.from_now + ) + + logout_and_sign_in_as :jz + get board_agent_bootstrap_path(bootstrap.board, bootstrap) + + assert_response :forbidden + end + + test "board page includes agent setup link for account admins" do + get board_path(boards(:writebook)) + assert_response :success + assert_select "a[href='#{new_board_agent_bootstrap_path(boards(:writebook))}']" + end + + test "board creator who is not an account admin cannot see bootstrap link" do + logout_and_sign_in_as :jz + board = Current.set(account: accounts("37s"), user: users(:jz)) do + Board.create!(name: "Creator board", creator: users(:jz), all_access: false) + end + + get board_path(board) + + assert_response :success + assert_select "a[href='#{new_board_agent_bootstrap_path(board)}']", count: 0 + end + + test "board creator who is not an account admin cannot create bootstrap" do + logout_and_sign_in_as :jz + board = Current.set(account: accounts("37s"), user: users(:jz)) do + Board.create!(name: "Creator board", creator: users(:jz), all_access: false) + end + + assert users(:jz).can_administer_board?(board) + + assert_no_difference -> { board.agent_bootstraps.count } do + post board_agent_bootstraps_path(board), as: :json + end + + assert_response :forbidden + end + + test "non-admin cannot create bootstrap for board they do not administer" do + logout_and_sign_in_as :jz + + assert_no_difference -> { boards(:writebook).agent_bootstraps.count } do + post board_agent_bootstraps_path(boards(:writebook)), as: :json + end + + assert_response :forbidden + end +end diff --git a/test/lib/fizzy/cli_test.rb b/test/lib/fizzy/cli_test.rb new file mode 100644 index 0000000000..2b147f1ffe --- /dev/null +++ b/test/lib/fizzy/cli_test.rb @@ -0,0 +1,108 @@ +require "test_helper" +require "tmpdir" +require_relative "../../../cli/lib/fizzy/cli" + +class FizzyCliTest < ActiveSupport::TestCase + private + def with_env(overrides) + original = {} + overrides.each do |key, value| + original[key] = ENV[key] + value.nil? ? ENV.delete(key) : ENV[key] = value + end + yield + ensure + original.each do |key, value| + value.nil? ? ENV.delete(key) : ENV[key] = value + end + end + + public + test "auth bootstrap saves a profile and prints JSON" do + Dir.mktmpdir do |dir| + config_path = File.join(dir, "config.yml") + response = { + "token" => "secret-token", + "account" => { "slug" => "1234567" }, + "user" => { "name" => "Board Agent", "email_address" => "agent@example.com" }, + "profile" => { + "base_url" => "https://app.example.test", + "account_slug" => "1234567", + "default_board_id" => "board-1" + } + } + + Fizzy::Client.any_instance.expects(:request).with( + :post, + "https://bootstrap.example.test/claim", + params: { email_address: "agent@example.com", name: "Board Agent", profile_name: nil } + ).returns(response) + + stdout, = capture_io do + with_env("FIZZY_CONFIG" => config_path) do + Fizzy::AuthCommand.start(%w[bootstrap https://bootstrap.example.test/claim --email agent@example.com --name Board\ Agent --profile agent --json]) + end + end + + body = JSON.parse(stdout) + store = Fizzy::ConfigStore.new(path: config_path) + + assert_equal "agent", body["profile_name"] + assert_equal "secret-token", store.profile("agent")["token"] + assert_equal "board-1", store.profile("agent")["default_board_id"] + end + end + + test "whoami uses the active profile and prints JSON" do + Dir.mktmpdir do |dir| + config_path = File.join(dir, "config.yml") + store = Fizzy::ConfigStore.new(path: config_path) + store.save_profile("agent", { + "base_url" => "https://app.example.test", + "account_slug" => "1234567", + "token" => "secret-token" + }) + + Fizzy::Client.any_instance.expects(:request).with(:get, "/my/identity", params: nil).returns( + { "id" => "identity-1", "accounts" => [ { "slug" => "1234567" } ] } + ) + + stdout, = capture_io do + with_env("FIZZY_CONFIG" => config_path) do + Fizzy::CLI.start(%w[whoami --json]) + end + end + + body = JSON.parse(stdout) + assert_equal "identity-1", body["id"] + assert_equal "1234567", body["accounts"].first["slug"] + end + end + + test "api command prefixes account path when requested" do + Dir.mktmpdir do |dir| + config_path = File.join(dir, "config.yml") + store = Fizzy::ConfigStore.new(path: config_path) + store.save_profile("agent", { + "base_url" => "https://app.example.test", + "account_slug" => "1234567", + "token" => "secret-token" + }) + + Fizzy::Client.any_instance.expects(:request).with( + "POST", + "/1234567/boards", + params: { "name" => "Experimental" } + ).returns({ "id" => "board-1", "name" => "Experimental" }) + + stdout, = capture_io do + with_env("FIZZY_CONFIG" => config_path) do + Fizzy::CLI.start(%w[api POST boards --account-scope --data {"name":"Experimental"} --json]) + end + end + + body = JSON.parse(stdout) + assert_equal "board-1", body["id"] + end + end +end diff --git a/test/lib/fizzy/client_test.rb b/test/lib/fizzy/client_test.rb new file mode 100644 index 0000000000..043bb559eb --- /dev/null +++ b/test/lib/fizzy/client_test.rb @@ -0,0 +1,51 @@ +require "test_helper" +require_relative "../../../cli/lib/fizzy/client" + +class FizzyClientTest < ActiveSupport::TestCase + Response = Struct.new(:code, :body, :headers) do + def [](key) + headers[key] + end + end + + test "request sends JSON and bearer token" do + response = Response.new("200", '{"ok":true}', { "Content-Type" => "application/json" }) + http = mock("http") + http.expects(:request).with do |request| + assert_equal "Bearer secret", request["Authorization"] + assert_equal "application/json", request["Content-Type"] + assert_equal({ "name" => "Agent" }, JSON.parse(request.body)) + true + end.returns(response) + Net::HTTP.expects(:start).with("app.example.test", 443, use_ssl: true).yields(http).returns(response) + + client = Fizzy::Client.new(base_url: "https://app.example.test", token: "secret") + payload = client.request(:post, "/boards", params: { name: "Agent" }) + + assert_equal({ "ok" => true }, payload) + end + + test "request raises api error with parsed JSON body" do + response = Response.new("422", '{"message":"Nope"}', { "Content-Type" => "application/json" }) + http = stub(request: response) + Net::HTTP.stubs(:start).yields(http).returns(response) + + error = assert_raises(Fizzy::ApiError) do + Fizzy::Client.new(base_url: "https://app.example.test").request(:get, "/bad") + end + + assert_equal 422, error.status + assert_equal({ "message" => "Nope" }, error.body) + assert_match "HTTP 422: Nope", error.message + end + + test "request returns raw string for non-json response" do + response = Response.new("200", "plain text", { "Content-Type" => "text/plain" }) + http = stub(request: response) + Net::HTTP.stubs(:start).yields(http).returns(response) + + payload = Fizzy::Client.new(base_url: "https://app.example.test").request(:get, "/plain") + + assert_equal "plain text", payload + end +end diff --git a/test/lib/fizzy/config_store_test.rb b/test/lib/fizzy/config_store_test.rb new file mode 100644 index 0000000000..adcf4ae36b --- /dev/null +++ b/test/lib/fizzy/config_store_test.rb @@ -0,0 +1,85 @@ +require "test_helper" +require "open3" +require "rbconfig" +require "tmpdir" +require_relative "../../../cli/lib/fizzy/config_store" + +class FizzyConfigStoreTest < ActiveSupport::TestCase + private + def with_env(overrides) + original = {} + overrides.each do |key, value| + original[key] = ENV[key] + value.nil? ? ENV.delete(key) : ENV[key] = value + end + yield + ensure + original.each do |key, value| + value.nil? ? ENV.delete(key) : ENV[key] = value + end + end + + public + test "save_profile persists profiles and current selection" do + Dir.mktmpdir do |dir| + path = File.join(dir, "config.yml") + store = Fizzy::ConfigStore.new(path:) + + store.save_profile("agent", { "base_url" => "https://app.example.test", "token" => "secret" }) + + assert_equal "agent", store.current_profile_name + assert_equal "https://app.example.test", store.profile("agent")["base_url"] + assert_equal "secret", store.profiles["agent"]["token"] + assert_equal 0o600, File.stat(path).mode & 0o777 + end + end + + test "set_current_profile switches to another saved profile" do + Dir.mktmpdir do |dir| + path = File.join(dir, "config.yml") + store = Fizzy::ConfigStore.new(path:) + + store.save_profile("one", { "base_url" => "https://one.example.test" }) + store.save_profile("two", { "base_url" => "https://two.example.test" }, set_current: false) + + store.set_current_profile("two") + + assert_equal "two", store.current_profile_name + end + end + + test "current_profile_name prefers environment override" do + Dir.mktmpdir do |dir| + path = File.join(dir, "config.yml") + store = Fizzy::ConfigStore.new(path:) + store.save_profile("saved", { "base_url" => "https://saved.example.test" }) + + with_env("FIZZY_PROFILE" => "env-profile") do + assert_equal "env-profile", store.current_profile_name + end + end + end + + test "standalone config_store raises Fizzy::Error without requiring client first" do + stdout, stderr, status = Open3.capture3( + RbConfig.ruby, + "-e", + <<~RUBY, + require_relative "cli/lib/fizzy/config_store" + + begin + Fizzy::ConfigStore.new(path: "tmp/fizzy-config.yml").set_current_profile("missing") + rescue => error + puts error.class.name + puts error.message + end + RUBY + chdir: Rails.root.to_s + ) + + assert status.success?, stderr + lines = stdout.lines.map(&:chomp) + assert_equal "Fizzy::Error", lines.first + assert_equal "Unknown profile missing", lines.second + end +end diff --git a/test/system/agent_bootstraps_test.rb b/test/system/agent_bootstraps_test.rb new file mode 100644 index 0000000000..2faf283b40 --- /dev/null +++ b/test/system/agent_bootstraps_test.rb @@ -0,0 +1,23 @@ +require "application_system_test_case" + +class AgentBootstrapsTest < ApplicationSystemTestCase + test "account admin can generate an agent bootstrap from a board" do + sign_in_as(users(:kevin)) + + visit board_url(boards(:writebook)) + find("a[href='#{new_board_agent_bootstrap_path(boards(:writebook))}']").click + + assert_current_path new_board_agent_bootstrap_path(boards(:writebook)) + assert_text "Set up an agent for Writebook" + + click_on "Generate setup command" + + assert_current_path %r{/boards/.*/agent_bootstraps/.*} + assert_text "Agent setup for Writebook" + assert_field(type: "textarea", with: /fizzy auth bootstrap/) + assert_text "Copy skill URL" + assert_text "Copy agent prompt" + assert_field(with: /agent_bootstrap\/.*\/skill/) + assert_field(with: /agent_bootstrap\/.*\/claim/) + end +end