Skip to main content

agentbridge/
dir_registry.rs

1use std::process::{Command, Stdio};
2
3use serde_json::Value;
4use time::format_description::well_known::Rfc3339;
5use time::OffsetDateTime;
6
7// --- DIR publish / search via dirctl ----------------------------------------
8
9/// Wrap an A2A `AgentCard` (as JSON) into the OASF record shape DIR expects.
10///
11/// An OASF record requires `schema_version`, `name`, `version`, `description`,
12/// `authors`, `created_at`, and `skills` at its *top level* — the AgentCard
13/// itself doesn't carry a DID or live at the record's top level, so this
14/// hoists `name`/`version`/`description`/`skills` out of the card, adds the
15/// agent's DID as the `authors` entry (no other AgentCard field carries a
16/// DID) and a fresh `created_at`, and carries the full card verbatim in a
17/// well-known `integration/a2a` module so `dirctl export --format=a2a` and
18/// `dirctl search --author <did>` both work against it.
19pub fn wrap_agent_card(card_json: &Value, did: Option<&str>) -> Value {
20    let authors: Vec<&str> = did.into_iter().collect();
21    let name = card_json
22        .get("name")
23        .and_then(Value::as_str)
24        .unwrap_or("agent");
25    let description = card_json
26        .get("description")
27        .and_then(Value::as_str)
28        .unwrap_or("");
29    let version = card_json
30        .get("version")
31        .and_then(Value::as_str)
32        .unwrap_or("0.0.0");
33    let skills: Vec<Value> = card_json
34        .get("skills")
35        .and_then(Value::as_array)
36        .map(|skills| {
37            skills
38                .iter()
39                .filter_map(|s| s.get("id").and_then(Value::as_str))
40                .map(|id| serde_json::json!({"name": id}))
41                .collect()
42        })
43        .unwrap_or_default();
44    let created_at = OffsetDateTime::now_utc()
45        .format(&Rfc3339)
46        .unwrap_or_default();
47
48    serde_json::json!({
49        "name": name,
50        "schema_version": "1.0.0",
51        "version": version,
52        "description": description,
53        "authors": authors,
54        "created_at": created_at,
55        "skills": skills,
56        "modules": [{
57            "name": "integration/a2a",
58            "data": {
59                "card_data": card_json,
60                "card_schema_version": "v1.0.0",
61            }
62        }]
63    })
64}
65
66const DIRCTL_HINT: &str =
67    "Install dirctl:  brew tap agntcy/dir https://github.com/agntcy/dir/ && brew install dirctl";
68
69/// Error type for DIR registry operations.
70#[derive(Debug)]
71pub enum DirError {
72    DirctlNotFound,
73    PublishFailed(String),
74    Serialize(String),
75}
76
77impl std::fmt::Display for DirError {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        match self {
80            Self::DirctlNotFound => write!(f, "dirctl not found in PATH. {DIRCTL_HINT}"),
81            Self::PublishFailed(e) => write!(f, "dirctl publish failed: {e}"),
82            Self::Serialize(e) => write!(f, "OASF serialization failed: {e}"),
83        }
84    }
85}
86
87/// Publish an OASF record (e.g. built via [`wrap_agent_card`]) to the agntcy
88/// Agent Directory.
89///
90/// Writes the record to a temp file then calls `dirctl push <path>
91/// --server-addr <addr> --output raw`. The CID printed by dirctl is returned
92/// on success. `dirctl push` takes the record file as a positional argument
93/// (there is no `--file` flag).
94pub fn publish_record(
95    record: &serde_json::Value,
96    server_addr: &str,
97    github_token: Option<&str>,
98) -> Result<String, DirError> {
99    let json = serde_json::to_vec_pretty(record).map_err(|e| DirError::Serialize(e.to_string()))?;
100
101    // Write to a temp file — dirctl expects a file path.
102    let tmp = tempfile_path();
103    std::fs::write(&tmp, &json)
104        .map_err(|e| DirError::PublishFailed(format!("write temp file: {e}")))?;
105
106    let mut cmd = Command::new(dirctl_binary());
107    cmd.arg("push")
108        .arg(&tmp)
109        .arg("--server-addr")
110        .arg(server_addr)
111        .arg("--output")
112        .arg("raw")
113        .stdout(Stdio::piped())
114        .stderr(Stdio::piped());
115
116    if let Some(token) = github_token {
117        cmd.env("DIRECTORY_CLIENT_AUTH_MODE", "github")
118            .env("DIRECTORY_CLIENT_GITHUB_TOKEN", token);
119    }
120
121    let output = match cmd.output() {
122        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
123            let _ = std::fs::remove_file(&tmp);
124            return Err(DirError::DirctlNotFound);
125        }
126        Err(e) => {
127            let _ = std::fs::remove_file(&tmp);
128            return Err(DirError::PublishFailed(e.to_string()));
129        }
130        Ok(o) => o,
131    };
132    let _ = std::fs::remove_file(&tmp);
133
134    if !output.status.success() {
135        let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
136        return Err(DirError::PublishFailed(stderr));
137    }
138
139    let cid = String::from_utf8_lossy(&output.stdout).trim().to_string();
140    Ok(cid)
141}
142
143// --- Helpers -----------------------------------------------------------------
144
145pub(crate) fn dirctl_binary() -> String {
146    std::env::var("SHADI_DIRCTL_BINARY").unwrap_or_else(|_| "dirctl".to_string())
147}
148
149/// Crate-wide lock serializing `SHADI_DIRCTL_BINARY` mutation across every
150/// test module in this crate — `std::env::set_var` is process-global, so
151/// tests in `dir_registry` and `member_source` that fake out `dirctl` must
152/// not run concurrently with each other.
153#[cfg(test)]
154pub(crate) fn dirctl_env_lock() -> &'static std::sync::Mutex<()> {
155    static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
156    LOCK.get_or_init(|| std::sync::Mutex::new(()))
157}
158
159fn tempfile_path() -> std::path::PathBuf {
160    use std::time::{SystemTime, UNIX_EPOCH};
161    let ts = SystemTime::now()
162        .duration_since(UNIX_EPOCH)
163        .map(|d| d.as_nanos())
164        .unwrap_or(0);
165    std::env::temp_dir().join(format!("agentbridge-oasf-{ts}.json"))
166}
167
168#[cfg(test)]
169mod tests {
170    use super::dirctl_env_lock as env_lock;
171    use super::*;
172
173    fn sample_card() -> serde_json::Value {
174        serde_json::json!({
175            "name": "claude-code",
176            "description": "agentbridge adapter for 'claude-code'.",
177            "version": "0.1.0",
178            "skills": [
179                {"id": "agent_orchestration/task_decomposition", "name": "agent_orchestration/task_decomposition"},
180                {"id": "agent_orchestration/agent_coordination", "name": "agent_orchestration/agent_coordination"},
181            ],
182        })
183    }
184
185    #[test]
186    fn wrap_agent_card_embeds_card_in_a2a_module_with_did_author() {
187        let card = sample_card();
188        let record = wrap_agent_card(&card, Some("did:key:z6Mk..."));
189        assert_eq!(record["authors"], serde_json::json!(["did:key:z6Mk..."]));
190        assert_eq!(record["modules"][0]["name"], "integration/a2a");
191        assert_eq!(
192            record["modules"][0]["data"]["card_schema_version"],
193            "v1.0.0"
194        );
195        assert_eq!(record["modules"][0]["data"]["card_data"], card);
196    }
197
198    #[test]
199    fn wrap_agent_card_omits_authors_entry_without_a_did() {
200        let card = sample_card();
201        let record = wrap_agent_card(&card, None);
202        assert_eq!(record["authors"], serde_json::json!([]));
203    }
204
205    #[test]
206    fn wrap_agent_card_hoists_required_oasf_top_level_fields() {
207        let card = sample_card();
208        let record = wrap_agent_card(&card, Some("did:key:z6Mk..."));
209        assert_eq!(record["name"], "claude-code");
210        assert_eq!(record["schema_version"], "1.0.0");
211        assert_eq!(record["version"], "0.1.0");
212        assert_eq!(
213            record["description"],
214            "agentbridge adapter for 'claude-code'."
215        );
216        assert!(record["created_at"].as_str().unwrap().contains('T'));
217        assert_eq!(
218            record["skills"],
219            serde_json::json!([
220                {"name": "agent_orchestration/task_decomposition"},
221                {"name": "agent_orchestration/agent_coordination"},
222            ])
223        );
224    }
225
226    #[test]
227    fn wrap_agent_card_falls_back_on_missing_card_fields() {
228        let record = wrap_agent_card(&serde_json::json!({}), None);
229        assert_eq!(record["name"], "agent");
230        assert_eq!(record["version"], "0.0.0");
231        assert_eq!(record["description"], "");
232        assert_eq!(record["skills"], serde_json::json!([]));
233    }
234
235    #[test]
236    fn publish_record_returns_dirctl_not_found_when_missing() {
237        let _g = env_lock().lock().expect("lock");
238        std::env::set_var("SHADI_DIRCTL_BINARY", "/nonexistent/dirctl");
239        let record = wrap_agent_card(&serde_json::json!({"name": "test"}), None);
240        let result = publish_record(&record, "localhost:9999", None);
241        std::env::remove_var("SHADI_DIRCTL_BINARY");
242        assert!(matches!(result, Err(DirError::DirctlNotFound)));
243    }
244}