Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 45 additions & 0 deletions app/controllers/agent_bootstrap_claims_controller.rb
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions app/controllers/agent_bootstrap_skills_controller.rb
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions app/controllers/boards/agent_bootstraps_controller.rb
Original file line number Diff line number Diff line change
@@ -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
49 changes: 49 additions & 0 deletions app/helpers/agent_bootstraps_helper.rb
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +22 to +31
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated setup command interpolates suggested_email/suggested_name directly into a shell command inside double quotes. If the board name contains quotes, backticks, $(), etc., the copy/paste command can break or be unsafe. Consider generating the command using proper shell escaping (e.g., Shellwords.shellescape) for each argument value.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept the setup command on Shellwords.shelljoin, which is the escaping boundary here, and added controller coverage that round-trips a hostile board name through Shellwords.split to confirm the argv stays intact. The helper now only builds the argv pieces; it does not hand-roll a quoted shell string.

Comment on lines +22 to +31
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agent_bootstrap.board.id is a UUID/string, so calling .first(8) will raise NoMethodError (String doesn’t implement first). Use agent_bootstrap.board.id.to_s[0, 8] (or similar) to build the suggested email prefix.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The suggested email is no longer derived from board.id.first(8). It now uses a bootstrap-token-derived suffix, which both avoids the helper bug and ensures each generated setup command gets a fresh default email.


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
1 change: 1 addition & 0 deletions app/models/board.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)") }
Expand Down
76 changes: 76 additions & 0 deletions app/models/board/agent_bootstrap.rb
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions app/views/boards/agent_bootstraps/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<% @page_title = "Agent setup" %>

<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to @board.name, @board, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<% end %>

<article class="panel panel--wide center flex flex-column gap">
<header>
<h1 class="txt-large margin-none font-weight-black">Set up an agent for <%= @board.name %></h1>
<p class="margin-none txt-medium">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.</p>
</header>

<%= 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 %>

<button class="btn">
<span>Generate setup command</span>
</button>
<% end %>
</article>
60 changes: 60 additions & 0 deletions app/views/boards/agent_bootstraps/show.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<% @page_title = "Agent setup" %>

<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to @board.name, @board, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<% end %>

<article class="panel panel--wide center flex flex-column gap">
<header>
<h1 class="txt-large margin-none font-weight-black">Agent setup for <%= @board.name %></h1>
<p class="margin-none txt-medium">Copy the command below into an agent session. It can be used once until <%= @agent_bootstrap.expires_at.to_fs(:long) %>.</p>
</header>

<div class="flex flex-column gap-half full-width txt-align-start">
<strong>Setup command</strong>
<textarea class="input" readonly rows="4"><%= agent_bootstrap_setup_command(@agent_bootstrap) %></textarea>
<div class="flex justify-center">
<%= button_to_copy_to_clipboard(agent_bootstrap_setup_command(@agent_bootstrap)) do %>
<span>Copy setup command</span>
<% end %>
</div>
</div>

<div class="flex flex-column gap-half full-width txt-align-start">
<strong>Skill download URL</strong>
<input type="text" class="input" readonly value="<%= agent_bootstrap_skill_url_for(@agent_bootstrap) %>">
<div class="flex justify-center">
<%= button_to_copy_to_clipboard(agent_bootstrap_skill_url_for(@agent_bootstrap)) do %>
<span>Copy skill URL</span>
<% end %>
</div>
<p class="txt-small txt-subtle margin-none">Give this URL to an agent so it can download the current <%= agent_bootstrap_skill_name %> skill.</p>
</div>

<div class="flex flex-column gap-half full-width txt-align-start">
<strong>Agent prompt</strong>
<textarea class="input" readonly rows="6"><%= agent_bootstrap_skill_block(@agent_bootstrap) %></textarea>
<div class="flex justify-center">
<%= button_to_copy_to_clipboard(agent_bootstrap_skill_block(@agent_bootstrap)) do %>
<span>Copy agent prompt</span>
<% end %>
</div>
<p class="txt-small txt-subtle margin-none">This prompt tells the agent where to fetch the skill, how to bootstrap the CLI, and how to verify access.</p>
</div>

<div class="flex flex-column gap-half full-width txt-align-start">
<strong>Raw bootstrap URL</strong>
<input type="text" class="input" readonly value="<%= agent_bootstrap_claim_url_for(@agent_bootstrap) %>">
<div class="flex justify-center">
<%= button_to_copy_to_clipboard(agent_bootstrap_claim_url_for(@agent_bootstrap)) do %>
<span>Copy bootstrap URL</span>
<% end %>
</div>
</div>

<footer class="txt-small txt-subtle">
<p class="margin-none">This link creates a write token and sets the agent to watch this board.</p>
</footer>
</article>
16 changes: 16 additions & 0 deletions app/views/boards/agent_bootstraps/show.json.jbuilder
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions app/views/boards/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<% content_for :header do %>
<div class="header__actions header__actions--start hide-on-native">
<%= link_to_webhooks(@board) if Current.user.admin? %>
<%= link_to_agent_bootstrap(@board) if Current.user.admin? %>
</div>

<h1 class="header__title divider divider--fade full-width" data-bridge--title-target="header">
Expand Down
5 changes: 5 additions & 0 deletions bin/fizzy
Original file line number Diff line number Diff line change
@@ -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__)
Loading