sync from monorepo @ 2452e92e
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
# Package: dirigent_zed
|
||||
|
||||
Zed editor integration for Dirigent -- detection, agent discovery, binary resolution.
|
||||
|
||||
## Quick Facts
|
||||
- **Type**: Library
|
||||
- **Main Entry**: src/lib.rs
|
||||
- **Dependencies**: dirigent_config, dirs, serde, serde_json, thiserror, tracing
|
||||
- **Status**: Initial implementation
|
||||
|
||||
## Purpose
|
||||
|
||||
Detects Zed editor installations on the current system, discovers configured
|
||||
ACP agents from Zed's `settings.json`, and resolves downloaded binary paths
|
||||
from the Zed data directory.
|
||||
|
||||
## Key Types
|
||||
|
||||
- `ZedChannel` -- Release channel enum (Stable, Preview, Nightly, Dev)
|
||||
- `ZedAgent` -- Discovered agent with name, type, binary path, env overrides
|
||||
- `AgentServerType` -- Registry, Custom, or Extension
|
||||
- `ZedInstallation` -- Detected installation with channel, paths, and agents
|
||||
|
||||
## Module Organization
|
||||
|
||||
- **`paths.rs`** -- Platform path resolution for Zed config/data directories
|
||||
- **`agents.rs`** -- Agent discovery from settings.json, JSONC stripping, binary resolution
|
||||
- **`detection.rs`** -- High-level installation detection combining paths and agents
|
||||
|
||||
## Platform Paths
|
||||
|
||||
| Platform | Config Dir | Data Dir |
|
||||
|----------|-----------|----------|
|
||||
| Linux | `$XDG_CONFIG_HOME/zed` | `$XDG_DATA_HOME/zed` |
|
||||
| macOS | `~/.config/zed` | `~/Library/Application Support/Zed` |
|
||||
| Windows | `%APPDATA%\Zed` | `%LOCALAPPDATA%\Zed` |
|
||||
|
||||
## Usage
|
||||
|
||||
```rust
|
||||
let installations = dirigent_zed::detect_installations();
|
||||
for inst in &installations {
|
||||
for agent in &inst.agents {
|
||||
if let Some(ref binary) = agent.binary_path {
|
||||
println!("{}: {}", agent.name, binary.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cargo test -p dirigent_zed
|
||||
```
|
||||
|
||||
## Related Packages
|
||||
|
||||
- **dirigent_config** -- Dirigent's own path resolution (dependency)
|
||||
- **dirigent_core** -- Will consume this crate for Zed connector integration (future)
|
||||
|
||||
## Research
|
||||
|
||||
See `docs/research/zed-integration.md` for detailed platform paths and detection strategies.
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "dirigent_zed"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Zed editor integration for Dirigent — detection, agent discovery, binary resolution"
|
||||
|
||||
[dependencies]
|
||||
dirigent_config = { path = "../dirigent_config" }
|
||||
dirs = "5"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "2.0"
|
||||
tracing = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
//! Detection of Zed editor installations on the current system.
|
||||
//!
|
||||
//! Checks for the existence of Zed configuration directories and discovers
|
||||
//! configured agents within each installation.
|
||||
|
||||
use crate::agents::{self, ZedAgent};
|
||||
use crate::paths::{self, ZedChannel};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A detected Zed editor installation with its configuration and agents.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ZedInstallation {
|
||||
/// Release channel (Stable, Preview, Nightly, Dev).
|
||||
pub channel: ZedChannel,
|
||||
/// Path to the configuration directory (contains `settings.json`).
|
||||
pub config_dir: PathBuf,
|
||||
/// Path to the data directory (contains `external_agents/`).
|
||||
pub data_dir: PathBuf,
|
||||
/// Agents discovered from settings and resolved binary paths.
|
||||
pub agents: Vec<ZedAgent>,
|
||||
}
|
||||
|
||||
/// Detect Zed installations on this system.
|
||||
///
|
||||
/// Checks for the existence of `settings.json` in the Zed config directory.
|
||||
/// Currently, Zed uses a single config directory for all channels (unlike
|
||||
/// some editors that have per-channel directories). We report one installation
|
||||
/// per detected config directory.
|
||||
///
|
||||
/// For each found installation:
|
||||
/// 1. Discovers agents from `settings.json` (`agent_servers` key)
|
||||
/// 2. Resolves downloaded binary paths from the data directory
|
||||
pub fn detect_installations() -> Vec<ZedInstallation> {
|
||||
let config_dir = match paths::zed_config_dir() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
tracing::debug!("Could not determine Zed config directory for this platform");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
let data_dir = match paths::zed_data_dir() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
tracing::debug!("Could not determine Zed data directory for this platform");
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
let settings_path = config_dir.join("settings.json");
|
||||
if !settings_path.exists() {
|
||||
tracing::debug!(
|
||||
path = %settings_path.display(),
|
||||
"Zed settings.json not found — no installation detected"
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
config = %config_dir.display(),
|
||||
data = %data_dir.display(),
|
||||
"Detected Zed installation"
|
||||
);
|
||||
|
||||
let mut found_agents = agents::discover_agents_from_settings(&config_dir);
|
||||
|
||||
// Also discover agents from external_agents/ that aren't in settings.
|
||||
let extra_agents = agents::discover_agents_from_external_dir(&data_dir, &found_agents);
|
||||
found_agents.extend(extra_agents);
|
||||
|
||||
agents::resolve_binary_paths(&mut found_agents, &data_dir);
|
||||
|
||||
// Enrich agents with registry metadata (display names, descriptions, args, icons).
|
||||
let registry = crate::registry::parse_registry(&data_dir);
|
||||
agents::enrich_agents_from_registry(&mut found_agents, ®istry);
|
||||
|
||||
// Zed currently uses a single config dir for all channels. We report it
|
||||
// as Stable by default. If we later learn how to distinguish channels
|
||||
// (e.g., via a marker file or binary path), we can refine this.
|
||||
vec![ZedInstallation {
|
||||
channel: ZedChannel::Stable,
|
||||
config_dir,
|
||||
data_dir,
|
||||
agents: found_agents,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Detect installations using explicit paths (useful for testing or overrides).
|
||||
pub fn detect_installation_at(config_dir: PathBuf, data_dir: PathBuf) -> Option<ZedInstallation> {
|
||||
let settings_path = config_dir.join("settings.json");
|
||||
if !settings_path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut found_agents = agents::discover_agents_from_settings(&config_dir);
|
||||
let extra_agents = agents::discover_agents_from_external_dir(&data_dir, &found_agents);
|
||||
found_agents.extend(extra_agents);
|
||||
agents::resolve_binary_paths(&mut found_agents, &data_dir);
|
||||
|
||||
let registry = crate::registry::parse_registry(&data_dir);
|
||||
agents::enrich_agents_from_registry(&mut found_agents, ®istry);
|
||||
|
||||
Some(ZedInstallation {
|
||||
channel: ZedChannel::Stable,
|
||||
config_dir,
|
||||
data_dir,
|
||||
agents: found_agents,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_detect_installation_at_missing_settings() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let result = detect_installation_at(dir.path().to_path_buf(), dir.path().to_path_buf());
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_installation_at_with_settings() {
|
||||
let config_dir = tempfile::tempdir().unwrap();
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let settings = r#"{
|
||||
"agent_servers": {
|
||||
"claude-acp": { "type": "registry" }
|
||||
}
|
||||
}"#;
|
||||
std::fs::write(config_dir.path().join("settings.json"), settings).unwrap();
|
||||
|
||||
let installation =
|
||||
detect_installation_at(config_dir.path().to_path_buf(), data_dir.path().to_path_buf());
|
||||
assert!(installation.is_some());
|
||||
|
||||
let inst = installation.unwrap();
|
||||
assert_eq!(inst.channel, ZedChannel::Stable);
|
||||
assert_eq!(inst.agents.len(), 1);
|
||||
assert_eq!(inst.agents[0].name, "claude-acp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_installation_at_empty_settings() {
|
||||
let config_dir = tempfile::tempdir().unwrap();
|
||||
let data_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
std::fs::write(config_dir.path().join("settings.json"), "{}").unwrap();
|
||||
|
||||
let installation =
|
||||
detect_installation_at(config_dir.path().to_path_buf(), data_dir.path().to_path_buf());
|
||||
assert!(installation.is_some());
|
||||
|
||||
let inst = installation.unwrap();
|
||||
assert!(inst.agents.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Zed editor integration for Dirigent.
|
||||
//!
|
||||
//! Provides detection of Zed installations, discovery of configured ACP agents,
|
||||
//! and binary path resolution for agent servers managed by Zed.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use dirigent_zed::{detect_installations, ZedAgent, AgentServerType};
|
||||
//!
|
||||
//! let installations = detect_installations();
|
||||
//! for inst in &installations {
|
||||
//! println!("Zed {} at {}", inst.channel, inst.config_dir.display());
|
||||
//! for agent in &inst.agents {
|
||||
//! println!(" Agent: {} ({})", agent.name, agent.agent_type);
|
||||
//! if let Some(ref path) = agent.binary_path {
|
||||
//! println!(" Binary: {}", path.display());
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod agents;
|
||||
pub mod detection;
|
||||
pub mod paths;
|
||||
pub mod registry;
|
||||
|
||||
pub use agents::{AgentServerType, ZedAgent};
|
||||
pub use detection::{detect_installation_at, detect_installations, ZedInstallation};
|
||||
pub use paths::ZedChannel;
|
||||
pub use registry::{parse_registry, find_registry_match, RegistryAgentInfo};
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Platform path resolution for Zed editor directories.
|
||||
//!
|
||||
//! Resolves configuration and data directories for each Zed release channel
|
||||
//! across Linux, macOS, and Windows.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Zed release channel. Each channel has independent config and data directories.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ZedChannel {
|
||||
Stable,
|
||||
Preview,
|
||||
Nightly,
|
||||
Dev,
|
||||
}
|
||||
|
||||
impl ZedChannel {
|
||||
/// All known release channels.
|
||||
pub fn all() -> &'static [ZedChannel] {
|
||||
&[
|
||||
ZedChannel::Stable,
|
||||
ZedChannel::Preview,
|
||||
ZedChannel::Nightly,
|
||||
ZedChannel::Dev,
|
||||
]
|
||||
}
|
||||
|
||||
/// Socket/identifier name used in IPC paths.
|
||||
pub fn socket_name(&self) -> &'static str {
|
||||
match self {
|
||||
ZedChannel::Stable => "stable",
|
||||
ZedChannel::Preview => "preview",
|
||||
ZedChannel::Nightly => "nightly",
|
||||
ZedChannel::Dev => "dev",
|
||||
}
|
||||
}
|
||||
|
||||
/// Display name for the channel.
|
||||
pub fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
ZedChannel::Stable => "Stable",
|
||||
ZedChannel::Preview => "Preview",
|
||||
ZedChannel::Nightly => "Nightly",
|
||||
ZedChannel::Dev => "Dev",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ZedChannel {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.display_name())
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the Zed configuration directory for this platform.
|
||||
///
|
||||
/// | Platform | Path |
|
||||
/// |----------|------|
|
||||
/// | Linux | `$XDG_CONFIG_HOME/zed` (default: `~/.config/zed`) |
|
||||
/// | macOS | `~/.config/zed` |
|
||||
/// | Windows | `%APPDATA%\Zed` |
|
||||
pub fn zed_config_dir() -> Option<PathBuf> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
dirs::home_dir().map(|d| d.join(".config").join("zed"))
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
dirs::config_dir().map(|d| d.join("Zed"))
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
dirs::config_dir().map(|d| d.join("zed"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the Zed data directory for this platform.
|
||||
///
|
||||
/// | Platform | Path |
|
||||
/// |----------|------|
|
||||
/// | Linux | `$XDG_DATA_HOME/zed` (default: `~/.local/share/zed`) |
|
||||
/// | macOS | `~/Library/Application Support/Zed` |
|
||||
/// | Windows | `%LOCALAPPDATA%\Zed` |
|
||||
pub fn zed_data_dir() -> Option<PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
dirs::data_local_dir().map(|d| d.join("Zed"))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
dirs::data_dir().map(|d| d.join("zed"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_all_channels() {
|
||||
let channels = ZedChannel::all();
|
||||
assert_eq!(channels.len(), 4);
|
||||
assert!(channels.contains(&ZedChannel::Stable));
|
||||
assert!(channels.contains(&ZedChannel::Preview));
|
||||
assert!(channels.contains(&ZedChannel::Nightly));
|
||||
assert!(channels.contains(&ZedChannel::Dev));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_socket_names() {
|
||||
assert_eq!(ZedChannel::Stable.socket_name(), "stable");
|
||||
assert_eq!(ZedChannel::Preview.socket_name(), "preview");
|
||||
assert_eq!(ZedChannel::Nightly.socket_name(), "nightly");
|
||||
assert_eq!(ZedChannel::Dev.socket_name(), "dev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_names() {
|
||||
assert_eq!(ZedChannel::Stable.to_string(), "Stable");
|
||||
assert_eq!(ZedChannel::Dev.to_string(), "Dev");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_dir_is_some() {
|
||||
// On any supported platform, this should resolve.
|
||||
let dir = zed_config_dir();
|
||||
assert!(dir.is_some(), "zed_config_dir should resolve on this platform");
|
||||
let path = dir.unwrap();
|
||||
let path_str = path.to_string_lossy();
|
||||
assert!(
|
||||
path_str.contains("zed") || path_str.contains("Zed"),
|
||||
"config dir should contain 'zed': {path_str}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_dir_is_some() {
|
||||
let dir = zed_data_dir();
|
||||
assert!(dir.is_some(), "zed_data_dir should resolve on this platform");
|
||||
let path = dir.unwrap();
|
||||
let path_str = path.to_string_lossy();
|
||||
assert!(
|
||||
path_str.contains("zed") || path_str.contains("Zed"),
|
||||
"data dir should contain 'zed': {path_str}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
//! Registry metadata parsing for Zed ACP agents.
|
||||
//!
|
||||
//! Reads the local `registry.json` file that Zed caches from the ACP registry CDN.
|
||||
//! Provides enrichment data (display names, descriptions, command args, icon paths)
|
||||
//! for discovered agents.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Metadata about an agent from the ACP registry.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegistryAgentInfo {
|
||||
/// Registry identifier (e.g. "claude-acp", "codex-acp").
|
||||
pub id: String,
|
||||
/// Human-friendly display name (e.g. "Claude Agent", "Codex CLI").
|
||||
pub display_name: String,
|
||||
/// Short description of the agent.
|
||||
pub description: String,
|
||||
/// Icon URL from the CDN.
|
||||
pub icon_url: Option<String>,
|
||||
/// Path to the locally cached icon file (SVG), if it exists.
|
||||
pub icon_local_path: Option<PathBuf>,
|
||||
/// Version string from the registry.
|
||||
pub version: String,
|
||||
/// Command arguments from the distribution config.
|
||||
///
|
||||
/// For npx-distributed agents this may include flags like `["--acp"]`.
|
||||
/// For binary-distributed agents this is the platform-specific `cmd` value.
|
||||
pub args: Vec<String>,
|
||||
/// The command to run, extracted from the platform-appropriate distribution.
|
||||
///
|
||||
/// For binary distributions this is the `cmd` field (e.g. `"./codex-acp"`).
|
||||
/// For npx distributions this is the npx package specifier.
|
||||
pub command: Option<String>,
|
||||
/// Environment variables from the distribution config.
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw serde models for registry.json
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RegistryFile {
|
||||
#[serde(default)]
|
||||
agents: Vec<RawRegistryAgent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawRegistryAgent {
|
||||
id: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
version: String,
|
||||
#[serde(default)]
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
icon: Option<String>,
|
||||
#[serde(default)]
|
||||
distribution: Option<RawDistribution>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawDistribution {
|
||||
#[serde(default)]
|
||||
binary: Option<HashMap<String, RawBinaryTarget>>,
|
||||
#[serde(default)]
|
||||
npx: Option<RawNpxDistribution>,
|
||||
#[serde(default)]
|
||||
uvx: Option<RawUvxDistribution>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawBinaryTarget {
|
||||
#[serde(default)]
|
||||
cmd: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawNpxDistribution {
|
||||
#[serde(default)]
|
||||
package: Option<String>,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawUvxDistribution {
|
||||
#[serde(default)]
|
||||
package: Option<String>,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Determine the current platform key used in the registry's binary distribution map.
|
||||
///
|
||||
/// Returns keys like `"windows-x86_64"`, `"linux-aarch64"`, `"darwin-aarch64"`, etc.
|
||||
fn current_platform_key() -> Option<&'static str> {
|
||||
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
|
||||
{
|
||||
Some("windows-x86_64")
|
||||
}
|
||||
#[cfg(all(target_os = "windows", target_arch = "aarch64"))]
|
||||
{
|
||||
Some("windows-aarch64")
|
||||
}
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
{
|
||||
Some("darwin-aarch64")
|
||||
}
|
||||
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
|
||||
{
|
||||
Some("darwin-x86_64")
|
||||
}
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
{
|
||||
Some("linux-x86_64")
|
||||
}
|
||||
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
|
||||
{
|
||||
Some("linux-aarch64")
|
||||
}
|
||||
#[cfg(not(any(
|
||||
all(target_os = "windows", target_arch = "x86_64"),
|
||||
all(target_os = "windows", target_arch = "aarch64"),
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "macos", target_arch = "x86_64"),
|
||||
all(target_os = "linux", target_arch = "x86_64"),
|
||||
all(target_os = "linux", target_arch = "aarch64"),
|
||||
)))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the local Zed registry.json and return a map of agent id -> metadata.
|
||||
///
|
||||
/// The registry file lives at `{data_dir}/external_agents/registry/registry.json`.
|
||||
/// Icons are cached at `{data_dir}/external_agents/registry/icons/{id}.svg`.
|
||||
///
|
||||
/// Returns an empty map if the file doesn't exist or can't be parsed.
|
||||
pub fn parse_registry(data_dir: &Path) -> HashMap<String, RegistryAgentInfo> {
|
||||
let registry_dir = data_dir.join("external_agents").join("registry");
|
||||
let registry_path = registry_dir.join("registry.json");
|
||||
|
||||
let content = match std::fs::read_to_string(®istry_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
path = %registry_path.display(),
|
||||
error = %e,
|
||||
"Could not read Zed registry.json"
|
||||
);
|
||||
return HashMap::new();
|
||||
}
|
||||
};
|
||||
|
||||
let registry: RegistryFile = match serde_json::from_str(&content) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
path = %registry_path.display(),
|
||||
error = %e,
|
||||
"Failed to parse Zed registry.json"
|
||||
);
|
||||
return HashMap::new();
|
||||
}
|
||||
};
|
||||
|
||||
let icons_dir = registry_dir.join("icons");
|
||||
let platform_key = current_platform_key();
|
||||
|
||||
let mut map = HashMap::with_capacity(registry.agents.len());
|
||||
|
||||
for agent in registry.agents {
|
||||
let icon_local_path = {
|
||||
let candidate = icons_dir.join(format!("{}.svg", agent.id));
|
||||
if candidate.exists() {
|
||||
Some(candidate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let (command, args, env) =
|
||||
extract_distribution_info(agent.distribution.as_ref(), platform_key);
|
||||
|
||||
let info = RegistryAgentInfo {
|
||||
id: agent.id.clone(),
|
||||
display_name: agent.name,
|
||||
description: agent.description,
|
||||
icon_url: agent.icon,
|
||||
icon_local_path,
|
||||
version: agent.version,
|
||||
args,
|
||||
command,
|
||||
env,
|
||||
};
|
||||
|
||||
map.insert(agent.id, info);
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
count = map.len(),
|
||||
"Parsed Zed registry with {} agents",
|
||||
map.len()
|
||||
);
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
/// Extract command, args, and env from the distribution config.
|
||||
///
|
||||
/// Priority: binary (platform-specific) > npx > uvx.
|
||||
fn extract_distribution_info(
|
||||
distribution: Option<&RawDistribution>,
|
||||
platform_key: Option<&str>,
|
||||
) -> (Option<String>, Vec<String>, HashMap<String, String>) {
|
||||
let dist = match distribution {
|
||||
Some(d) => d,
|
||||
None => return (None, Vec::new(), HashMap::new()),
|
||||
};
|
||||
|
||||
// Prefer binary distribution for the current platform.
|
||||
if let Some(ref binary) = dist.binary {
|
||||
if let Some(key) = platform_key {
|
||||
if let Some(target) = binary.get(key) {
|
||||
let cmd = target.cmd.clone();
|
||||
return (cmd, Vec::new(), HashMap::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to npx distribution.
|
||||
if let Some(ref npx) = dist.npx {
|
||||
return (
|
||||
npx.package.clone(),
|
||||
npx.args.clone(),
|
||||
npx.env.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
// Fall back to uvx distribution.
|
||||
if let Some(ref uvx) = dist.uvx {
|
||||
return (
|
||||
uvx.package.clone(),
|
||||
uvx.args.clone(),
|
||||
uvx.env.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
(None, Vec::new(), HashMap::new())
|
||||
}
|
||||
|
||||
/// Look up a registry entry by matching an agent name or directory name to a registry id.
|
||||
///
|
||||
/// The matching strategy:
|
||||
/// 1. Exact match on registry id
|
||||
/// 2. Substring: registry id contained in agent name, or vice versa
|
||||
/// 3. Core-name match: strip common ACP suffixes and compare base names
|
||||
/// (e.g. "claude-agent-acp" and "claude-acp" both have core name "claude")
|
||||
pub fn find_registry_match<'a>(
|
||||
agent_name: &str,
|
||||
registry: &'a HashMap<String, RegistryAgentInfo>,
|
||||
) -> Option<&'a RegistryAgentInfo> {
|
||||
let name_lower = agent_name.to_lowercase();
|
||||
|
||||
// 1. Exact match on registry id.
|
||||
if let Some(info) = registry.get(&name_lower) {
|
||||
return Some(info);
|
||||
}
|
||||
|
||||
// 2. Substring match: registry id contained in agent name, or vice versa.
|
||||
let mut best: Option<&'a RegistryAgentInfo> = None;
|
||||
let mut best_len = 0;
|
||||
|
||||
for (id, info) in registry {
|
||||
let id_lower = id.to_lowercase();
|
||||
if name_lower.contains(&id_lower) || id_lower.contains(&name_lower) {
|
||||
if id_lower.len() > best_len {
|
||||
best = Some(info);
|
||||
best_len = id_lower.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if best.is_some() {
|
||||
return best;
|
||||
}
|
||||
|
||||
// 3. Core-name match: strip ACP-related suffixes and compare.
|
||||
let agent_core = strip_acp_suffixes(&name_lower);
|
||||
for (id, info) in registry {
|
||||
let id_lower = id.to_lowercase();
|
||||
let id_core = strip_acp_suffixes(&id_lower);
|
||||
if !agent_core.is_empty() && agent_core == id_core {
|
||||
return Some(info);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Strip common ACP-related suffixes to extract the core agent name.
|
||||
///
|
||||
/// For example:
|
||||
/// - "claude-agent-acp" -> "claude"
|
||||
/// - "claude-acp" -> "claude"
|
||||
/// - "claude-code-acp" -> "claude"
|
||||
/// - "codex" -> "codex"
|
||||
pub fn strip_acp_suffixes(name: &str) -> &str {
|
||||
// Strip known suffixes in order of specificity (longest first).
|
||||
for suffix in &["-agent-acp", "-code-acp", "-acp", "-cli"] {
|
||||
if let Some(core) = name.strip_suffix(suffix) {
|
||||
return core;
|
||||
}
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
fn sample_registry_json() -> &'static str {
|
||||
r#"{
|
||||
"version": "1.0.0",
|
||||
"agents": [
|
||||
{
|
||||
"id": "claude-acp",
|
||||
"name": "Claude Agent",
|
||||
"version": "0.24.2",
|
||||
"description": "ACP wrapper for Anthropic's Claude",
|
||||
"icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/claude-acp.svg",
|
||||
"distribution": {
|
||||
"npx": {
|
||||
"package": "@agentclientprotocol/claude-agent-acp@0.24.2"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "codex-acp",
|
||||
"name": "Codex CLI",
|
||||
"version": "0.10.0",
|
||||
"description": "ACP adapter for OpenAI's coding assistant",
|
||||
"icon": "https://cdn.agentclientprotocol.com/registry/v1/latest/codex-acp.svg",
|
||||
"distribution": {
|
||||
"binary": {
|
||||
"linux-x86_64": {
|
||||
"archive": "https://example.com/codex.tar.gz",
|
||||
"cmd": "./codex-acp"
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"archive": "https://example.com/codex.zip",
|
||||
"cmd": "./codex-acp.exe"
|
||||
},
|
||||
"darwin-aarch64": {
|
||||
"archive": "https://example.com/codex-mac.tar.gz",
|
||||
"cmd": "./codex-acp"
|
||||
}
|
||||
},
|
||||
"npx": {
|
||||
"package": "@zed-industries/codex-acp@0.10.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "auggie",
|
||||
"name": "Auggie CLI",
|
||||
"version": "0.21.0",
|
||||
"description": "Augment Code's powerful software agent",
|
||||
"distribution": {
|
||||
"npx": {
|
||||
"package": "@augmentcode/auggie@0.21.0",
|
||||
"args": ["--acp"],
|
||||
"env": { "AUGMENT_DISABLE_AUTO_UPDATE": "1" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_registry_basic() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let registry_dir = dir
|
||||
.path()
|
||||
.join("external_agents")
|
||||
.join("registry");
|
||||
std::fs::create_dir_all(®istry_dir).unwrap();
|
||||
|
||||
let mut f =
|
||||
std::fs::File::create(registry_dir.join("registry.json")).unwrap();
|
||||
f.write_all(sample_registry_json().as_bytes()).unwrap();
|
||||
|
||||
let map = parse_registry(dir.path());
|
||||
assert_eq!(map.len(), 3);
|
||||
|
||||
let claude = map.get("claude-acp").unwrap();
|
||||
assert_eq!(claude.display_name, "Claude Agent");
|
||||
assert_eq!(claude.description, "ACP wrapper for Anthropic's Claude");
|
||||
assert!(claude.icon_url.is_some());
|
||||
// No local icon file created in test.
|
||||
assert!(claude.icon_local_path.is_none());
|
||||
|
||||
let auggie = map.get("auggie").unwrap();
|
||||
assert_eq!(auggie.args, vec!["--acp"]);
|
||||
assert_eq!(
|
||||
auggie.env.get("AUGMENT_DISABLE_AUTO_UPDATE"),
|
||||
Some(&"1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_registry_with_local_icon() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let registry_dir = dir
|
||||
.path()
|
||||
.join("external_agents")
|
||||
.join("registry");
|
||||
let icons_dir = registry_dir.join("icons");
|
||||
std::fs::create_dir_all(&icons_dir).unwrap();
|
||||
|
||||
std::fs::File::create(registry_dir.join("registry.json"))
|
||||
.unwrap()
|
||||
.write_all(sample_registry_json().as_bytes())
|
||||
.unwrap();
|
||||
|
||||
// Create a fake icon file.
|
||||
std::fs::write(icons_dir.join("claude-acp.svg"), "<svg/>").unwrap();
|
||||
|
||||
let map = parse_registry(dir.path());
|
||||
let claude = map.get("claude-acp").unwrap();
|
||||
assert!(claude.icon_local_path.is_some());
|
||||
assert!(claude
|
||||
.icon_local_path
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.contains("claude-acp.svg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_registry_missing_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let map = parse_registry(dir.path());
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_registry_invalid_json() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let registry_dir = dir
|
||||
.path()
|
||||
.join("external_agents")
|
||||
.join("registry");
|
||||
std::fs::create_dir_all(®istry_dir).unwrap();
|
||||
std::fs::write(registry_dir.join("registry.json"), "not json").unwrap();
|
||||
|
||||
let map = parse_registry(dir.path());
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_registry_match_exact() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let registry_dir = dir
|
||||
.path()
|
||||
.join("external_agents")
|
||||
.join("registry");
|
||||
std::fs::create_dir_all(®istry_dir).unwrap();
|
||||
std::fs::File::create(registry_dir.join("registry.json"))
|
||||
.unwrap()
|
||||
.write_all(sample_registry_json().as_bytes())
|
||||
.unwrap();
|
||||
|
||||
let map = parse_registry(dir.path());
|
||||
|
||||
// Exact match.
|
||||
let info = find_registry_match("claude-acp", &map).unwrap();
|
||||
assert_eq!(info.display_name, "Claude Agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_registry_match_fuzzy() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let registry_dir = dir
|
||||
.path()
|
||||
.join("external_agents")
|
||||
.join("registry");
|
||||
std::fs::create_dir_all(®istry_dir).unwrap();
|
||||
std::fs::File::create(registry_dir.join("registry.json"))
|
||||
.unwrap()
|
||||
.write_all(sample_registry_json().as_bytes())
|
||||
.unwrap();
|
||||
|
||||
let map = parse_registry(dir.path());
|
||||
|
||||
// Agent name "claude" should fuzzy-match "claude-acp".
|
||||
let info = find_registry_match("claude", &map).unwrap();
|
||||
assert_eq!(info.display_name, "Claude Agent");
|
||||
|
||||
// Directory name "claude-agent-acp" should fuzzy-match "claude-acp".
|
||||
let info2 = find_registry_match("claude-agent-acp", &map).unwrap();
|
||||
assert_eq!(info2.display_name, "Claude Agent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_registry_match_no_match() {
|
||||
let map = HashMap::new();
|
||||
assert!(find_registry_match("nonexistent", &map).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_binary_distribution_platform_cmd() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let registry_dir = dir
|
||||
.path()
|
||||
.join("external_agents")
|
||||
.join("registry");
|
||||
std::fs::create_dir_all(®istry_dir).unwrap();
|
||||
std::fs::File::create(registry_dir.join("registry.json"))
|
||||
.unwrap()
|
||||
.write_all(sample_registry_json().as_bytes())
|
||||
.unwrap();
|
||||
|
||||
let map = parse_registry(dir.path());
|
||||
let codex = map.get("codex-acp").unwrap();
|
||||
|
||||
// On any supported platform, the binary distribution should produce a command.
|
||||
// The exact value depends on the compile target.
|
||||
if current_platform_key().is_some() {
|
||||
assert!(
|
||||
codex.command.is_some(),
|
||||
"codex-acp should have a command from binary distribution"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user