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
3 changes: 2 additions & 1 deletion crates/buzz-acp/src/base_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ
| `buzz feed` | `get` |
| `buzz social` | `publish`, `notes` |
| `buzz repos` | `create`, `get`, `list` |
| `buzz projects` | `create`, `get`, `list`, `add-repo` |
| `buzz projects` | `create`, `get`, `list`, `add-repo`, `add-channel` |
| `buzz issues` | `create`, `get`, `list`, `status`, `assign` |
| `buzz pr` | `open`, `update`, `get`, `list`, `status` |
| `buzz upload` | `file` |
Expand All @@ -39,6 +39,7 @@ A project is a named grouping (`kind:30621`) with a home channel. Creating a sec
- If you are in a project's home channel, or a project with that name/slug already exists, do **not** run `buzz projects create`. `[Context]` includes a Project block when this channel is a project home — tasks, repositories, and files you create belong to that project.
- To add a codebase: `buzz repos create --id <id> --name "…" --channel <current-channel-uuid>`. `mkdir` in `REPOS/` is not a Buzz repository.
- To add tasks: `buzz issues create --channel <current-channel-uuid> --subject "…" --content "…"`. That uses this project's repository and creates one bound to the channel if none exists. `--repo-owner` / `--repo-id` remain valid once a repository exists. Session todos and markdown plans do not appear on the project.
- To add another channel to this project: `buzz projects add-channel --home-channel <current-channel-uuid> --name "…" [--template "…"]`. This opens an owner-reviewed request in Buzz Desktop and uses the project-aware channel primitive after approval. Do **not** use `buzz channels create` for a channel that should belong to the current project, and do not claim the channel exists until the owner approves it.

`buzz pr open`, `buzz issues create`, `buzz repos create`, and `buzz projects create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, repo, or project in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references.

Expand Down
110 changes: 103 additions & 7 deletions crates/buzz-cli/src/agent_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ use serde::Serialize;

use crate::error::CliError;

const REQUEST_KIND: &str = "agent_management_request";
const AGENT_REQUEST_KIND: &str = "agent_management_request";
const PROJECT_CHANNEL_REQUEST_KIND: &str = "project_channel_request";
const MAX_NAME_CHARS: usize = 120;
const MAX_PROMPT_CHARS: usize = 20_000;

Expand Down Expand Up @@ -37,6 +38,20 @@ pub struct UpdateAgentDraft {
pub respond_to: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateProjectChannelDraft {
pub home_channel_id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub visibility: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ttl_seconds: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub template_name: Option<String>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ManagementRequest<T> {
Expand Down Expand Up @@ -88,20 +103,21 @@ fn build<T: Serialize>(
keys: &Keys,
owner: &PublicKey,
channel_id: String,
request_kind: &'static str,
action: &'static str,
request: T,
) -> Result<BuiltDraftRequest, CliError> {
let request_id = uuid::Uuid::new_v4().to_string();
let payload = ObserverEvent {
seq: 0,
timestamp: chrono::Utc::now().to_rfc3339(),
kind: REQUEST_KIND,
kind: request_kind,
agent_index: None,
channel_id: Some(channel_id),
session_id: None,
turn_id: None,
payload: ManagementRequest {
request_type: REQUEST_KIND,
request_type: request_kind,
action,
request_id: request_id.clone(),
request,
Expand Down Expand Up @@ -138,7 +154,14 @@ pub fn build_create(
display_name: required(draft.display_name, "display name", MAX_NAME_CHARS)?,
system_prompt: required(draft.system_prompt, "system prompt", MAX_PROMPT_CHARS)?,
};
build(keys, owner, channel_id, "create", request)
build(
keys,
owner,
channel_id,
AGENT_REQUEST_KIND,
"create",
request,
)
}

pub fn build_update(
Expand Down Expand Up @@ -182,7 +205,50 @@ pub fn build_update(
"include at least one field to update".into(),
));
}
build(keys, owner, channel_id, "update", request)
build(
keys,
owner,
channel_id,
AGENT_REQUEST_KIND,
"update",
request,
)
}

pub fn build_project_channel(
keys: &Keys,
owner: &PublicKey,
draft: CreateProjectChannelDraft,
) -> Result<BuiltDraftRequest, CliError> {
let home_channel_id = required(draft.home_channel_id, "home channel", 128)?;
uuid::Uuid::parse_str(&home_channel_id)
.map_err(|_| CliError::Usage(format!("invalid channel UUID: {home_channel_id}")))?;
let visibility = required(draft.visibility, "visibility", 16)?;
if visibility != "open" && visibility != "private" {
return Err(CliError::Usage("visibility must be open or private".into()));
}
if draft.ttl_seconds == Some(0) {
return Err(CliError::Usage("ttl must be greater than zero".into()));
}
let request = CreateProjectChannelDraft {
home_channel_id: home_channel_id.clone(),
name: required(draft.name, "name", MAX_NAME_CHARS)?,
description: draft
.description
.map(|value| required(value, "description", 2_048))
.transpose()?,
visibility,
ttl_seconds: draft.ttl_seconds,
template_name: optional(draft.template_name, "template")?,
};
build(
keys,
owner,
home_channel_id,
PROJECT_CHANNEL_REQUEST_KIND,
"create",
request,
)
}

#[cfg(test)]
Expand Down Expand Up @@ -228,9 +294,9 @@ mod tests {
.any(|tag| tag.first().map(String::as_str) == Some("h")));

let payload: serde_json::Value = decrypt_observer_payload(&owner, &built.event).unwrap();
assert_eq!(payload["kind"], REQUEST_KIND);
assert_eq!(payload["kind"], AGENT_REQUEST_KIND);
assert_eq!(payload["channelId"], CHANNEL);
assert_eq!(payload["payload"]["type"], REQUEST_KIND);
assert_eq!(payload["payload"]["type"], AGENT_REQUEST_KIND);
assert_eq!(payload["payload"]["action"], "create");
assert_eq!(
payload["payload"]["request"]["displayName"],
Expand Down Expand Up @@ -274,4 +340,34 @@ mod tests {
.unwrap_err();
assert!(error.to_string().contains("invalid channel UUID"));
}

#[test]
fn project_channel_request_is_owner_encrypted() {
let agent = Keys::generate();
let owner = Keys::generate();
let built = build_project_channel(
&agent,
&owner.public_key(),
CreateProjectChannelDraft {
home_channel_id: CHANNEL.into(),
name: "release-planning".into(),
description: Some("Coordinate the next release.".into()),
visibility: "open".into(),
ttl_seconds: None,
template_name: Some("Release team".into()),
},
)
.unwrap();

let payload: serde_json::Value = decrypt_observer_payload(&owner, &built.event).unwrap();
assert_eq!(payload["kind"], PROJECT_CHANNEL_REQUEST_KIND);
assert_eq!(payload["channelId"], CHANNEL);
assert_eq!(payload["payload"]["type"], PROJECT_CHANNEL_REQUEST_KIND);
assert_eq!(payload["payload"]["action"], "create");
assert_eq!(payload["payload"]["request"]["homeChannelId"], CHANNEL);
assert_eq!(
payload["payload"]["request"]["templateName"],
"Release team"
);
}
}
65 changes: 64 additions & 1 deletion crates/buzz-cli/src/commands/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,58 @@ use buzz_sdk::{
build_delete_addressable, build_project, build_project_with_tags, ProjectMemberCoord,
PROJECT_D_MAX_LEN,
};
use nostr::{Event, EventBuilder, Tag, Timestamp};
use nostr::{Event, EventBuilder, PublicKey, Tag, Timestamp};

use crate::agent_management::{build_project_channel, CreateProjectChannelDraft};
use crate::client::BuzzClient;
use crate::commands::parse_write_response;
use crate::commands::project_channel::repo_id_from_project_slug;
use crate::commands::repos::{build_create_announcement, fetch_own_repo_announcement};
use crate::error::CliError;

async fn cmd_add_channel_draft(
client: &BuzzClient,
home_channel: String,
name: String,
description: Option<String>,
visibility: String,
ttl_seconds: Option<u64>,
template_name: Option<String>,
) -> Result<(), CliError> {
let owner_hex = client
.auth_tag_owner_hex()
.ok_or_else(|| CliError::Auth("project channel requests require BUZZ_AUTH_TAG".into()))?;
let owner = PublicKey::parse(&owner_hex)
.map_err(|error| CliError::Auth(format!("invalid owner attestation: {error}")))?;
let built = build_project_channel(
client.keys(),
&owner,
CreateProjectChannelDraft {
home_channel_id: home_channel,
name,
description,
visibility,
ttl_seconds,
template_name,
},
)?;
let response = client.publish_ephemeral_event(built.event).await?;
let mut output: serde_json::Value = serde_json::from_str(&response)
.map_err(|error| CliError::Other(format!("invalid relay response: {error}")))?;
if let Some(object) = output.as_object_mut() {
object.insert("request_id".into(), built.request_id.into());
object.insert("action".into(), "add-channel".into());
object.insert("saved".into(), false.into());
object.insert(
"message".into(),
"Project channel draft sent to Buzz Desktop for owner review. The channel is not created until the owner approves it."
.into(),
);
}
println!("{output}");
Ok(())
}

// ── Buzz repo-ID grammar (bare --repo shorthand) ─────────────────────────────

/// Pattern for a Buzz-hosted repo identifier (bare `--repo` shorthand).
Expand Down Expand Up @@ -739,6 +783,25 @@ pub async fn dispatch(cmd: crate::ProjectsCmd, client: &BuzzClient) -> Result<()
ProjectsCmd::Get { slug, owner } => cmd_get(client, &slug, owner.as_deref()).await,
ProjectsCmd::List { owner, limit } => cmd_list(client, owner.as_deref(), limit).await,
ProjectsCmd::AddRepo { slug, repo } => cmd_add_repo(client, &slug, &repo).await,
ProjectsCmd::AddChannel {
home_channel,
name,
description,
visibility,
ttl,
template,
} => {
cmd_add_channel_draft(
client,
home_channel,
name,
description,
visibility.to_string(),
ttl,
template,
)
.await
}
ProjectsCmd::RemoveRepo { slug, repo } => cmd_remove_repo(client, &slug, &repo).await,
ProjectsCmd::Update {
slug,
Expand Down
44 changes: 43 additions & 1 deletion crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1334,6 +1334,28 @@ pub enum ProjectsCmd {
#[arg(long = "repo", required = true)]
repo: Vec<String>,
},
/// Draft a project-linked channel for owner review in Buzz Desktop
#[command(name = "add-channel")]
AddChannel {
/// Project home channel UUID from the current ACP [Context]
#[arg(long)]
home_channel: String,
/// New channel name
#[arg(long)]
name: String,
/// Optional channel description
#[arg(long)]
description: Option<String>,
/// Channel visibility
#[arg(long, value_enum, default_value = "open")]
visibility: ChannelVisibility,
/// Optional temporary-channel lifetime in seconds
#[arg(long)]
ttl: Option<u64>,
/// Optional Desktop channel-template name
#[arg(long)]
template: Option<String>,
},
/// Remove one or more member repositories from a project
#[command(name = "remove-repo")]
RemoveRepo {
Expand Down Expand Up @@ -2375,6 +2397,7 @@ mod tests {
assert_eq!(
names(&cmd, "projects"),
vec![
"add-channel",
"add-repo",
"create",
"delete",
Expand Down Expand Up @@ -2421,7 +2444,7 @@ mod tests {
("pack", 2),
("patches", 4),
("pr", 5),
("projects", 7),
("projects", 8),
("reactions", 3),
("repos", 5),
("social", 7),
Expand Down Expand Up @@ -2492,6 +2515,25 @@ mod tests {

// ── projects update mutation group ────────────────────────────────────────

/// Project-channel requests accept the owner-review metadata.
#[test]
fn projects_add_channel_accepts_owner_review_fields() {
assert!(Cli::try_parse_from([
"buzz",
"projects",
"add-channel",
"--home-channel",
"11111111-1111-4111-8111-111111111111",
"--name",
"release-planning",
"--visibility",
"private",
"--template",
"Release team",
])
.is_ok());
}

/// Multiple independent fields must be accepted in the same invocation.
#[test]
fn projects_update_multi_field_is_accepted() {
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/app/navigation/useAppNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export function useAppNavigation() {
projectId: string,
behavior?: NavigationBehavior & {
commitHash?: string;
filePath?: string;
pullRequestId?: string;
issueId?: string;
repositoryId?: string;
Expand All @@ -128,6 +129,7 @@ export function useAppNavigation() {
...(behavior?.commitHash
? { commitHash: behavior.commitHash }
: {}),
...(behavior?.filePath ? { filePath: behavior.filePath } : {}),
...(behavior?.pullRequestId
? { pullRequestId: behavior.pullRequestId }
: {}),
Expand Down
Loading
Loading