mirror of
https://github.com/block/buzz.git
synced 2026-11-05 08:51:10 +00:00
704 lines
27 KiB
Rust
704 lines
27 KiB
Rust
//! Buzz Nest — persistent agent workspace at `~/.buzz`.
|
|
//!
|
|
//! Creates a shared knowledge directory on first launch so every
|
|
//! Buzz-spawned agent starts with orientation (AGENTS.md) and a
|
|
//! place to accumulate research, plans, and logs across sessions.
|
|
//!
|
|
//! Static template content in AGENTS.md (above the managed-section markers)
|
|
//! and SKILL.md is refreshed when the embedded template version changes.
|
|
|
|
use super::{load_managed_agents, load_personas, AgentDefinition, ManagedAgentRecord};
|
|
#[cfg(test)]
|
|
use super::{BackendKind, RespondTo};
|
|
use crate::app_state::AppState;
|
|
use crate::relay::relay_ws_url_with_override;
|
|
use std::fs;
|
|
use std::io;
|
|
use std::path::{Path, PathBuf};
|
|
use tauri::{AppHandle, Manager};
|
|
|
|
use crate::managed_agents::discovery::known_skill_dirs;
|
|
#[cfg(unix)]
|
|
use crate::util::create_symlink;
|
|
|
|
/// Subdirectories created inside the nest.
|
|
/// `REPOS` is intentionally absent: it is provisioned by
|
|
/// [`super::repos::ensure_repos_symlink`], which makes it either a real directory (default)
|
|
/// or a symlink to a user-configured `repos_dir`. Creating it here
|
|
/// unconditionally would race a future symlink re-point.
|
|
const NEST_DIRS: &[&str] = &[
|
|
"GUIDES",
|
|
"RESEARCH",
|
|
"PLANS",
|
|
"WORK_LOGS",
|
|
"OUTBOX",
|
|
".scratch",
|
|
];
|
|
|
|
/// Default AGENTS.md content written on first init.
|
|
/// Fully static — no runtime interpolation, no secrets, no user paths.
|
|
pub(crate) const AGENTS_MD: &str = include_str!("nest_agents.md");
|
|
|
|
/// Default SKILL.md content for the buzz-cli skill.
|
|
/// Written to ~/.buzz/.agents/skills/buzz-cli/SKILL.md on first init.
|
|
const BUZZ_CLI_SKILL_MD: &str = include_str!("nest_skill.md");
|
|
|
|
/// Template content version for AGENTS.md static content (above managed markers).
|
|
/// Bump this when changing `nest_agents.md` to trigger refresh on existing installs.
|
|
/// Version 1 is implicitly "before this mechanism existed" (no version file).
|
|
const NEST_AGENTS_VERSION: u32 = 4;
|
|
|
|
/// Template content version for SKILL.md.
|
|
/// Bump this when changing `nest_skill.md` to trigger refresh on existing installs.
|
|
const NEST_SKILL_VERSION: u32 = 4;
|
|
|
|
const BEGIN_MARKER: &str = "<!-- BEGIN BUZZ MANAGED";
|
|
const END_MARKER: &str = "<!-- END BUZZ MANAGED -->";
|
|
|
|
/// Canonical skill directory path relative to the nest root.
|
|
const CANONICAL_SKILL_DIR: &str = ".agents/skills/buzz-cli";
|
|
|
|
/// Nest directory name for production builds.
|
|
const NEST_DIR_PROD: &str = ".buzz";
|
|
|
|
/// Nest directory name for dev builds. Dev builds (those whose Tauri app-data
|
|
/// directory name starts with `"xyz.block.buzz.app.dev"`) use a separate nest
|
|
/// so that the DMG and dev-build instances don't clobber each other's
|
|
/// `.repos-dir` dotfile and `REPOS` symlink.
|
|
const NEST_DIR_DEV: &str = ".buzz-dev";
|
|
|
|
/// Process-lifetime nest directory. Initialized once at startup via
|
|
/// [`init_nest_dir`] before any call to [`nest_dir`].
|
|
///
|
|
/// `None` inside the `OnceLock` means "home dir was unresolvable at init time".
|
|
/// The outer `None` from `OnceLock::get` means "not initialized yet" —
|
|
/// [`nest_dir`] falls back to the prod path in that case, ensuring test code
|
|
/// that never calls [`init_nest_dir`] still works.
|
|
static NEST_DIR: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
|
|
|
|
/// Initialize the process-lifetime nest directory.
|
|
///
|
|
/// Must be called once at app startup (before any call to [`nest_dir`] that
|
|
/// may result in a filesystem operation). Subsequent calls are no-ops — the
|
|
/// `OnceLock` is set exactly once.
|
|
///
|
|
/// `is_dev` should be `true` when the running binary is a dev build — i.e.
|
|
/// when the Tauri app-data directory name starts with `"xyz.block.buzz.app.dev"`.
|
|
/// Pass `false` for production (signed DMG) builds.
|
|
pub fn init_nest_dir(is_dev: bool) {
|
|
let suffix = if is_dev { NEST_DIR_DEV } else { NEST_DIR_PROD };
|
|
let path = dirs::home_dir().map(|h| h.join(suffix));
|
|
// set() is a no-op when already initialized, which is correct: only the
|
|
// first call (at boot, before any filesystem work) should win.
|
|
let _ = NEST_DIR.set(path);
|
|
}
|
|
|
|
/// Returns the nest root path (`~/.buzz` for prod, `~/.buzz-dev` for dev),
|
|
/// or `None` if the home directory cannot be resolved.
|
|
///
|
|
/// If [`init_nest_dir`] has not been called yet (e.g. in unit tests), falls
|
|
/// back to the production path `~/.buzz`.
|
|
pub fn nest_dir() -> Option<PathBuf> {
|
|
match NEST_DIR.get() {
|
|
Some(path) => path.clone(),
|
|
// Not yet initialized — fall back to prod path. Covers test code.
|
|
None => dirs::home_dir().map(|h| h.join(NEST_DIR_PROD)),
|
|
}
|
|
}
|
|
|
|
/// Returns `true` iff `path` ends with the dev-nest directory name (`.buzz-dev`).
|
|
///
|
|
/// Pure function — no globals — so it can be unit-tested without touching the
|
|
/// process-lifetime [`NEST_DIR`] `OnceLock`.
|
|
fn path_is_dev_nest(path: &std::path::Path) -> bool {
|
|
path.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.map(|n| n == NEST_DIR_DEV)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Returns `true` when the running binary is using the dev nest (`~/.buzz-dev`).
|
|
///
|
|
/// This is `true` for all dev builds — `just staging` and `just dev` — because
|
|
/// [`init_nest_dir`] is called with `is_dev = true` when the Tauri app-data
|
|
/// directory starts with `"xyz.block.buzz.app.dev"`.
|
|
///
|
|
/// Returns `false` when:
|
|
/// - The nest is the production nest (`~/.buzz`, signed DMG).
|
|
/// - [`init_nest_dir`] has not been called yet (unit tests, home dir
|
|
/// unresolvable) — the fallback path is always the prod nest.
|
|
pub fn nest_is_dev() -> bool {
|
|
nest_dir().map(|p| path_is_dev_nest(&p)).unwrap_or(false)
|
|
}
|
|
|
|
/// Creates the Buzz nest at `~/.buzz` if it doesn't already exist.
|
|
///
|
|
/// Delegates to [`ensure_nest_at`] with the resolved nest directory.
|
|
/// Returns an error string if the home directory cannot be resolved.
|
|
pub fn ensure_nest() -> Result<(), String> {
|
|
let root = nest_dir().ok_or("cannot resolve home directory for nest")?;
|
|
ensure_nest_at(&root)
|
|
}
|
|
|
|
/// Creates a Buzz nest at the given `root` path.
|
|
///
|
|
/// - Creates the root directory and all subdirectories.
|
|
/// - Writes `AGENTS.md` only if it doesn't already exist.
|
|
/// - Writes `.agents/skills/buzz-cli/SKILL.md` only if it doesn't already exist.
|
|
/// - Creates harness-specific symlinks pointing to the canonical
|
|
/// `.agents/skills/buzz-cli` directory for each known provider.
|
|
/// - Sets 700 permissions on the root, all subdirectories, and the skill
|
|
/// directory tree (Unix).
|
|
///
|
|
/// Idempotent: safe to call on every launch. Static template content in
|
|
/// AGENTS.md (above the managed-section markers) and SKILL.md is refreshed
|
|
/// when the embedded template version changes. The managed section in AGENTS.md
|
|
/// and any user content below it are preserved.
|
|
///
|
|
/// Rejects symlinks at the root path to prevent redirect attacks.
|
|
///
|
|
/// Errors are returned as strings for Tauri compatibility; callers
|
|
/// should log and continue rather than aborting app startup.
|
|
pub fn ensure_nest_at(root: &Path) -> Result<(), String> {
|
|
// Reject symlinks — we want a real directory, not a redirect.
|
|
// Platform-independent: symlink_metadata works on all OS.
|
|
if root
|
|
.symlink_metadata()
|
|
.map(|m| m.file_type().is_symlink())
|
|
.unwrap_or(false)
|
|
{
|
|
return Err(format!(
|
|
"{} is a symlink; refusing to use as nest root",
|
|
root.display()
|
|
));
|
|
}
|
|
|
|
// Create root and all subdirectories. create_dir_all is idempotent —
|
|
// it succeeds silently if the directory already exists.
|
|
fs::create_dir_all(root).map_err(|e| format!("create {}: {e}", root.display()))?;
|
|
|
|
for dir in NEST_DIRS {
|
|
let path = root.join(dir);
|
|
fs::create_dir_all(&path).map_err(|e| format!("create {}: {e}", path.display()))?;
|
|
}
|
|
|
|
// REPOS is provisioned separately from NEST_DIRS: it may be a symlink to a
|
|
// user-configured repos_dir (applied later via apply_workspace), so setup
|
|
// must not clobber an existing configured symlink. See repos.rs.
|
|
super::repos::ensure_repos_setup_default(root)?;
|
|
|
|
// Write AGENTS.md only if it doesn't already exist.
|
|
// Uses create_new (O_CREAT|O_EXCL) to atomically check-and-create,
|
|
// closing the TOCTOU gap that exists() + write() would leave open.
|
|
// Also guarantees we never clobber a user-edited file.
|
|
let agents_md = root.join("AGENTS.md");
|
|
match fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(&agents_md)
|
|
{
|
|
Ok(mut file) => {
|
|
use std::io::Write;
|
|
file.write_all(AGENTS_MD.as_bytes())
|
|
.map_err(|e| format!("write {}: {e}", agents_md.display()))?;
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
|
// File already exists — leave it alone (idempotent).
|
|
}
|
|
Err(e) => {
|
|
return Err(format!("create {}: {e}", agents_md.display()));
|
|
}
|
|
}
|
|
|
|
// Write buzz-cli skill to the harness-agnostic .agents path.
|
|
// The first-init write uses the new canonical path; migration from
|
|
// the old .claude path is handled in refresh_skill_md_if_stale.
|
|
let agents_skill_dir = root.join(CANONICAL_SKILL_DIR);
|
|
fs::create_dir_all(&agents_skill_dir)
|
|
.map_err(|e| format!("create {}: {e}", agents_skill_dir.display()))?;
|
|
|
|
let skill_md = agents_skill_dir.join("SKILL.md");
|
|
match fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(&skill_md)
|
|
{
|
|
Ok(mut file) => {
|
|
use std::io::Write;
|
|
file.write_all(BUZZ_CLI_SKILL_MD.as_bytes())
|
|
.map_err(|e| format!("write {}: {e}", skill_md.display()))?;
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
|
|
Err(e) => {
|
|
return Err(format!("create {}: {e}", skill_md.display()));
|
|
}
|
|
}
|
|
|
|
// Create harness-specific symlinks for all known providers.
|
|
// Migration of the old .claude/skills/buzz-cli real dir is handled in
|
|
// refresh_skill_md_if_stale; ensure_skill_symlinks skips paths that already exist.
|
|
ensure_skill_symlinks(root)?;
|
|
|
|
// Refresh static content if the embedded template version is newer.
|
|
refresh_agents_md_if_stale(root)?;
|
|
refresh_skill_md_if_stale(root)?;
|
|
|
|
// Set owner-only permissions on root and all subdirectories.
|
|
// Skip any path that is a symlink — chmod would affect the target.
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let perms = fs::Permissions::from_mode(0o700);
|
|
fs::set_permissions(root, perms.clone())
|
|
.map_err(|e| format!("set permissions on {}: {e}", root.display()))?;
|
|
for dir in NEST_DIRS {
|
|
let path = root.join(dir);
|
|
let is_symlink = path
|
|
.symlink_metadata()
|
|
.map(|m| m.file_type().is_symlink())
|
|
.unwrap_or(false);
|
|
if !is_symlink {
|
|
fs::set_permissions(&path, perms.clone())
|
|
.map_err(|e| format!("set permissions on {}: {e}", path.display()))?;
|
|
}
|
|
}
|
|
// REPOS is provisioned outside NEST_DIRS (it may be a symlink). Only
|
|
// chmod it when it is a real directory — chmod on a symlink would
|
|
// affect the user's external repos_dir target.
|
|
let repos_path = root.join("REPOS");
|
|
let repos_is_symlink = repos_path
|
|
.symlink_metadata()
|
|
.map(|m| m.file_type().is_symlink())
|
|
.unwrap_or(false);
|
|
if !repos_is_symlink {
|
|
fs::set_permissions(&repos_path, perms.clone())
|
|
.map_err(|e| format!("set permissions on {}: {e}", repos_path.display()))?;
|
|
}
|
|
// Skill directory trees inside root get 700.
|
|
// Build the list from canonical path + all known provider skill dirs.
|
|
let mut skill_perm_dirs = Vec::new();
|
|
{
|
|
let mut accumulated = std::path::PathBuf::new();
|
|
for component in std::path::Path::new(CANONICAL_SKILL_DIR).components() {
|
|
accumulated.push(component);
|
|
skill_perm_dirs.push(root.join(&accumulated));
|
|
}
|
|
}
|
|
for skill_dir in known_skill_dirs() {
|
|
// Ensure every ancestor dir gets 700, not just the leaf.
|
|
let mut accumulated = std::path::PathBuf::new();
|
|
for component in std::path::Path::new(skill_dir).components() {
|
|
accumulated.push(component);
|
|
skill_perm_dirs.push(root.join(&accumulated));
|
|
}
|
|
}
|
|
for dir in skill_perm_dirs {
|
|
let is_symlink = dir
|
|
.symlink_metadata()
|
|
.map(|m| m.file_type().is_symlink())
|
|
.unwrap_or(false);
|
|
if !is_symlink {
|
|
fs::set_permissions(&dir, perms.clone())
|
|
.map_err(|e| format!("set permissions on {}: {e}", dir.display()))?;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Create harness-specific skill symlinks for each known provider.
|
|
/// Idempotent: skips any path where `symlink_metadata` succeeds — real
|
|
/// directories, valid symlinks, and dangling symlinks are all left alone.
|
|
#[cfg(unix)]
|
|
fn ensure_skill_symlinks(root: &Path) -> Result<(), String> {
|
|
for skill_dir in known_skill_dirs() {
|
|
let parent = root.join(skill_dir);
|
|
fs::create_dir_all(&parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
|
|
let link = parent.join("buzz-cli");
|
|
if link.symlink_metadata().is_ok() {
|
|
continue; // symlink or real path exists — skip
|
|
}
|
|
let depth = std::path::Path::new(skill_dir).components().count();
|
|
let prefix = "../".repeat(depth);
|
|
let target = format!("{prefix}{CANONICAL_SKILL_DIR}");
|
|
create_symlink(std::path::Path::new(&target), &link)
|
|
.map_err(|e| format!("symlink {} → {}: {e}", link.display(), target))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn ensure_skill_symlinks(_root: &Path) -> Result<(), String> {
|
|
Ok(())
|
|
}
|
|
|
|
/// Returns the `~/.local/bin` link name for the bundled CLI.
|
|
///
|
|
/// Dev builds (`is_dev = true`) use `"buzz-dev"` so that a running DMG and a
|
|
/// concurrent dev build each own a separate link and never clobber each other —
|
|
/// the same isolation that separates `~/.buzz` (prod) from `~/.buzz-dev` (dev).
|
|
pub fn cli_link_name(is_dev: bool) -> &'static str {
|
|
if is_dev {
|
|
"buzz-dev"
|
|
} else {
|
|
"buzz"
|
|
}
|
|
}
|
|
|
|
/// Ensures `~/.local/bin/buzz` (prod) or `~/.local/bin/buzz-dev` (dev) is a
|
|
/// symlink to the bundled CLI binary.
|
|
///
|
|
/// The link name is split by `is_dev` so that an installed DMG and a
|
|
/// concurrently running dev build each maintain their own symlink and never
|
|
/// overwrite each other's target — the same isolation that separates the
|
|
/// `~/.buzz` and `~/.buzz-dev` nests (see [`NEST_DIR_DEV`]).
|
|
///
|
|
/// On every boot: replaces any existing symlink unconditionally (the `buzz` /
|
|
/// `buzz-dev` name is our namespace), creates a new one if absent, and leaves
|
|
/// regular files alone to avoid clobbering a user-compiled binary.
|
|
///
|
|
/// Non-fatal: callers should ignore errors — the symlink is a convenience
|
|
/// for human Terminal use; agents find the CLI via PATH augmentation.
|
|
#[cfg(unix)]
|
|
pub fn ensure_cli_symlink(exe_parent: &Path, is_dev: bool) -> Result<(), String> {
|
|
let buzz_bin = exe_parent.join("buzz");
|
|
if !buzz_bin.exists() {
|
|
return Ok(()); // CLI not bundled (e.g., dev builds without sidecars).
|
|
}
|
|
|
|
let local_bin = dirs::home_dir()
|
|
.ok_or("cannot resolve home directory")?
|
|
.join(".local")
|
|
.join("bin");
|
|
fs::create_dir_all(&local_bin).map_err(|e| format!("create {}: {e}", local_bin.display()))?;
|
|
|
|
let link = local_bin.join(cli_link_name(is_dev));
|
|
match link.symlink_metadata() {
|
|
Ok(meta) if meta.file_type().is_symlink() => {
|
|
let _ = fs::remove_file(&link);
|
|
create_symlink(&buzz_bin, &link)
|
|
.map_err(|e| format!("symlink {}: {e}", link.display()))?;
|
|
}
|
|
Ok(_) => {
|
|
// Regular file or directory — don't clobber.
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
create_symlink(&buzz_bin, &link)
|
|
.map_err(|e| format!("symlink {}: {e}", link.display()))?;
|
|
}
|
|
Err(e) => {
|
|
return Err(format!("stat {}: {e}", link.display()));
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// No-op on non-Unix platforms — symlink management is macOS/Linux only.
|
|
#[cfg(not(unix))]
|
|
pub fn ensure_cli_symlink(_exe_parent: &Path, _is_dev: bool) -> Result<(), String> {
|
|
Ok(())
|
|
}
|
|
|
|
/// Read a version number from a file. Returns 0 if the file doesn't exist or can't be parsed.
|
|
fn read_version_file(path: &Path) -> u32 {
|
|
fs::read_to_string(path)
|
|
.ok()
|
|
.and_then(|s| s.trim().parse().ok())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// Refresh AGENTS.md static content if the template version has changed.
|
|
///
|
|
/// Preserves everything from the `<!-- BEGIN BUZZ MANAGED` marker onward
|
|
/// (the dynamic section managed by `upsert_managed_section`). Replaces
|
|
/// only the static template content above the marker.
|
|
fn refresh_agents_md_if_stale(root: &Path) -> Result<(), String> {
|
|
let version_path = root.join(".nest-agents-version");
|
|
if read_version_file(&version_path) >= NEST_AGENTS_VERSION {
|
|
return Ok(());
|
|
}
|
|
|
|
let agents_md = root.join("AGENTS.md");
|
|
let current =
|
|
fs::read_to_string(&agents_md).map_err(|e| format!("read {}: {e}", agents_md.display()))?;
|
|
|
|
let new_content = match find_marker_at_line_start(¤t, BEGIN_MARKER) {
|
|
Some(pos) => {
|
|
// Find the start of the marker line (could be preceded by blank lines).
|
|
let marker_line_start = current[..pos].rfind('\n').map(|p| p + 1).unwrap_or(0);
|
|
// Template content up to (but not including) the managed section,
|
|
// then the existing managed section from the marker onward.
|
|
let template_static = match AGENTS_MD.find(BEGIN_MARKER) {
|
|
Some(tmpl_marker_pos) => {
|
|
let tmpl_line_start = AGENTS_MD[..tmpl_marker_pos]
|
|
.rfind('\n')
|
|
.map(|p| p + 1)
|
|
.unwrap_or(0);
|
|
&AGENTS_MD[..tmpl_line_start]
|
|
}
|
|
None => AGENTS_MD,
|
|
};
|
|
format!("{}{}", template_static, ¤t[marker_line_start..])
|
|
}
|
|
None => {
|
|
// No managed section found — write full template.
|
|
AGENTS_MD.to_string()
|
|
}
|
|
};
|
|
|
|
// Atomic write via temp file.
|
|
let parent = agents_md.parent().ok_or("AGENTS.md has no parent dir")?;
|
|
let mut tmp = tempfile::NamedTempFile::new_in(parent)
|
|
.map_err(|e| format!("tempfile in {}: {e}", parent.display()))?;
|
|
{
|
|
use std::io::Write;
|
|
tmp.write_all(new_content.as_bytes())
|
|
.map_err(|e| format!("write tempfile: {e}"))?;
|
|
}
|
|
tmp.persist(&agents_md)
|
|
.map_err(|e| format!("persist {}: {e}", agents_md.display()))?;
|
|
|
|
fs::write(&version_path, format!("{NEST_AGENTS_VERSION}\n"))
|
|
.map_err(|e| format!("write {}: {e}", version_path.display()))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Refresh SKILL.md if the template version has changed.
|
|
///
|
|
/// SKILL.md has no user-editable sections — it is fully overwritten on version bump.
|
|
fn refresh_skill_md_if_stale(root: &Path) -> Result<(), String> {
|
|
let agents_skill_dir = root.join(".agents/skills/buzz-cli");
|
|
let version_path = agents_skill_dir.join(".skill-version");
|
|
if read_version_file(&version_path) >= NEST_SKILL_VERSION {
|
|
return Ok(());
|
|
}
|
|
|
|
// Migration: if .claude/skills/buzz-cli exists as a real directory
|
|
// (pre-migration install), copy user's SKILL.md to the new location
|
|
// then remove the old directory so we can replace it with a symlink.
|
|
let old_skill_dir = root.join(".claude/skills/buzz-cli");
|
|
let old_is_real_dir = old_skill_dir
|
|
.symlink_metadata()
|
|
.map(|m| m.file_type().is_dir())
|
|
.unwrap_or(false);
|
|
|
|
let skill_content = if old_is_real_dir {
|
|
// Preserve user-edited content during migration.
|
|
fs::read_to_string(old_skill_dir.join("SKILL.md"))
|
|
.unwrap_or_else(|_| BUZZ_CLI_SKILL_MD.to_string())
|
|
} else {
|
|
BUZZ_CLI_SKILL_MD.to_string()
|
|
};
|
|
|
|
// Ensure the canonical .agents skill directory exists.
|
|
fs::create_dir_all(&agents_skill_dir)
|
|
.map_err(|e| format!("create {}: {e}", agents_skill_dir.display()))?;
|
|
|
|
// Atomic write via temp file.
|
|
let skill_md = agents_skill_dir.join("SKILL.md");
|
|
let mut tmp = tempfile::NamedTempFile::new_in(&agents_skill_dir)
|
|
.map_err(|e| format!("tempfile in {}: {e}", agents_skill_dir.display()))?;
|
|
{
|
|
use std::io::Write;
|
|
tmp.write_all(skill_content.as_bytes())
|
|
.map_err(|e| format!("write tempfile: {e}"))?;
|
|
}
|
|
tmp.persist(&skill_md)
|
|
.map_err(|e| format!("persist {}: {e}", skill_md.display()))?;
|
|
|
|
// Replace old real directory with a symlink.
|
|
if old_is_real_dir {
|
|
fs::remove_dir_all(&old_skill_dir)
|
|
.map_err(|e| format!("remove {}: {e}", old_skill_dir.display()))?;
|
|
}
|
|
|
|
// Create/replace the .claude/skills/buzz-cli symlink.
|
|
#[cfg(unix)]
|
|
{
|
|
let claude_skills_dir = root.join(".claude/skills");
|
|
fs::create_dir_all(&claude_skills_dir)
|
|
.map_err(|e| format!("create {}: {e}", claude_skills_dir.display()))?;
|
|
let symlink_path = root.join(".claude/skills/buzz-cli");
|
|
// Remove any stale symlink before (re)creating.
|
|
let symlink_exists = symlink_path
|
|
.symlink_metadata()
|
|
.map(|m| m.file_type().is_symlink())
|
|
.unwrap_or(false);
|
|
if symlink_exists {
|
|
fs::remove_file(&symlink_path)
|
|
.map_err(|e| format!("remove symlink {}: {e}", symlink_path.display()))?;
|
|
}
|
|
create_symlink(
|
|
std::path::Path::new("../../.agents/skills/buzz-cli"),
|
|
&symlink_path,
|
|
)
|
|
.map_err(|e| format!("symlink {}: {e}", symlink_path.display()))?;
|
|
}
|
|
|
|
fs::write(&version_path, format!("{NEST_SKILL_VERSION}\n"))
|
|
.map_err(|e| format!("write {}: {e}", version_path.display()))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn escape_md_cell(s: &str) -> String {
|
|
s.replace('|', "\\|").replace('\n', " ")
|
|
}
|
|
|
|
pub fn render_dynamic_section(
|
|
personas: &[AgentDefinition],
|
|
agents: &[ManagedAgentRecord],
|
|
relay_url: &str,
|
|
) -> String {
|
|
let active_agents = if agents.is_empty() {
|
|
"## Active Agents\n\n*(No agents deployed yet. Add agents in the Buzz desktop app.)*"
|
|
.to_string()
|
|
} else {
|
|
let mut table =
|
|
"## Active Agents\n\n| Name | Persona | How to address |\n|------|---------|----------------|"
|
|
.to_string();
|
|
for agent in agents {
|
|
let role = agent
|
|
.persona_id
|
|
.as_deref()
|
|
.and_then(|pid| personas.iter().find(|p| p.id == pid))
|
|
.map(|p| p.display_name.as_str())
|
|
.unwrap_or("—");
|
|
let name = escape_md_cell(&agent.name);
|
|
let role_escaped = escape_md_cell(role);
|
|
table.push_str(&format!("\n| {name} | {role_escaped} | @{name} |"));
|
|
}
|
|
table
|
|
};
|
|
|
|
let relay_url = relay_url.replace(['\n', '\r'], "");
|
|
format!("{active_agents}\n\n## Workspace\n- Relay: {relay_url}")
|
|
}
|
|
|
|
/// Find a marker that appears at the start of a line (position 0 or preceded by `\n`).
|
|
fn find_marker_at_line_start(content: &str, marker: &str) -> Option<usize> {
|
|
let mut search_from = 0;
|
|
while let Some(pos) = content[search_from..].find(marker) {
|
|
let abs_pos = search_from + pos;
|
|
if abs_pos == 0 || content.as_bytes()[abs_pos - 1] == b'\n' {
|
|
return Some(abs_pos);
|
|
}
|
|
search_from = abs_pos + 1;
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Find the first valid ordered BEGIN/END marker pair, both at line starts.
|
|
/// Returns `(begin_line_start, after_end)` byte offsets for slicing.
|
|
fn find_managed_markers(content: &str) -> Option<(usize, usize)> {
|
|
let begin_pos = find_marker_at_line_start(content, BEGIN_MARKER)?;
|
|
let begin_line_start = content[..begin_pos].rfind('\n').map(|p| p + 1).unwrap_or(0);
|
|
let end_pos =
|
|
find_marker_at_line_start(&content[begin_pos..], END_MARKER).map(|p| p + begin_pos)?;
|
|
let end_of_end = end_pos + END_MARKER.len();
|
|
let after_end = if content[end_of_end..].starts_with('\n') {
|
|
end_of_end + 1
|
|
} else {
|
|
end_of_end
|
|
};
|
|
Some((begin_line_start, after_end))
|
|
}
|
|
|
|
/// Remove an orphan BEGIN marker line (one with no matching END after it).
|
|
fn strip_orphan_begin_marker(content: &str) -> String {
|
|
if let Some(pos) = find_marker_at_line_start(content, BEGIN_MARKER) {
|
|
let line_start = content[..pos].rfind('\n').map(|p| p + 1).unwrap_or(0);
|
|
let line_end = content[pos..]
|
|
.find('\n')
|
|
.map(|p| pos + p + 1)
|
|
.unwrap_or(content.len());
|
|
format!(
|
|
"{}{}",
|
|
&content[..line_start],
|
|
content[line_end..]
|
|
.strip_prefix('\n')
|
|
.unwrap_or(&content[line_end..])
|
|
)
|
|
} else {
|
|
content.to_string()
|
|
}
|
|
}
|
|
|
|
pub fn upsert_managed_section(file_path: &Path, new_section_content: &str) -> io::Result<()> {
|
|
let current = fs::read_to_string(file_path)?;
|
|
|
|
let replacement = format!(
|
|
"{BEGIN_MARKER} — regenerated automatically, do not edit below -->\n{new_section_content}\n{END_MARKER}\n"
|
|
);
|
|
|
|
let new_content = match find_managed_markers(¤t) {
|
|
Some((begin_line_start, after_end)) => {
|
|
format!(
|
|
"{}{}{}",
|
|
¤t[..begin_line_start],
|
|
replacement,
|
|
¤t[after_end..]
|
|
)
|
|
}
|
|
None => {
|
|
let cleaned = strip_orphan_begin_marker(¤t);
|
|
format!("{}\n\n{}", cleaned.trim_end_matches('\n'), replacement)
|
|
}
|
|
};
|
|
|
|
// Skip write when content is unchanged — avoids bumping mtime on every launch.
|
|
if new_content == current {
|
|
return Ok(());
|
|
}
|
|
|
|
let parent = file_path.parent().ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
"file path has no parent directory",
|
|
)
|
|
})?;
|
|
let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
|
|
{
|
|
use std::io::Write;
|
|
tmp.write_all(new_content.as_bytes())?;
|
|
}
|
|
tmp.persist(file_path).map_err(|e| e.error)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn regenerate_nest_context(app: &AppHandle) -> Result<(), String> {
|
|
let nest = nest_dir().ok_or("cannot resolve home directory for nest")?;
|
|
let agents_md = nest.join("AGENTS.md");
|
|
|
|
if !agents_md.exists() {
|
|
return Ok(());
|
|
}
|
|
|
|
let personas = load_personas(app)?;
|
|
let agents = load_managed_agents(app)?;
|
|
let state = app.state::<AppState>();
|
|
let relay_url = relay_ws_url_with_override(&state);
|
|
let content = render_dynamic_section(&personas, &agents, &relay_url);
|
|
upsert_managed_section(&agents_md, &content)
|
|
.map_err(|e| format!("regenerate nest context: {e}"))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Convenience wrapper: regenerates nest context, logging a warning on failure.
|
|
///
|
|
/// All call sites treat regeneration as fire-and-forget — agents run fine with
|
|
/// a stale AGENTS.md, so we warn and continue rather than propagating the error.
|
|
pub fn try_regenerate_nest(app: &AppHandle) {
|
|
if let Err(error) = regenerate_nest_context(app) {
|
|
eprintln!("buzz-desktop: nest context regeneration failed: {error}");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|