agentbridge/
dir_registry.rs1use std::process::{Command, Stdio};
2
3use serde_json::Value;
4use time::format_description::well_known::Rfc3339;
5use time::OffsetDateTime;
6
7pub 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#[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
87pub 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 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
143pub(crate) fn dirctl_binary() -> String {
146 std::env::var("SHADI_DIRCTL_BINARY").unwrap_or_else(|_| "dirctl".to_string())
147}
148
149#[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}