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.
+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 %> +
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.
+Copy the command below into an agent session. It can be used once until <%= @agent_bootstrap.expires_at.to_fs(:long) %>.
+Give this URL to an agent so it can download the current <%= agent_bootstrap_skill_name %> skill.
+This prompt tells the agent where to fetch the skill, how to bootstrap the CLI, and how to verify access.
+