Skip to main content

hypha/api/
mod.rs

1// Agent-First Data output layer. All CLI code emits through Output methods.
2
3use serde::Serialize;
4use std::process::ExitCode;
5
6/// Result type that handles output formatting and exit codes.
7///
8/// Builds and emits Agent-First Data protocol events through [`agent_first_data::CliEmitter`].
9pub struct Output {
10    format: agent_first_data::OutputFormat,
11}
12
13fn built_event(
14    result: Result<agent_first_data::Event, agent_first_data::BuildError>,
15) -> agent_first_data::Event {
16    result.unwrap_or_else(|err| agent_first_data::build_cli_error(&err.to_string(), None))
17}
18
19impl Output {
20    pub fn new(format: agent_first_data::OutputFormat) -> Self {
21        Self { format }
22    }
23
24    /// Best-effort emit for mid-stream events (progress/log/warn): a failed
25    /// write (including a hung-up reader) is intentionally ignored so
26    /// diagnostics never abort the command.
27    fn emit(&self, event: agent_first_data::Event) {
28        let stdout = std::io::stdout();
29        let mut emitter = agent_first_data::CliEmitter::new(stdout.lock(), self.format);
30        let _ = emitter.emit(event);
31    }
32
33    /// Emit a terminal event (`result`/`error`) and resolve the process exit
34    /// code the broken-pipe-safe way, mirroring agent-first-data's one-shot CLI
35    /// contract ([`agent_first_data::CliEmitter::finish`]): `success_code` on a
36    /// clean write, `0` if the reader hung up (broken pipe), `4` on any other
37    /// write/validation failure. Replaces the old "emit-then-return-a-fixed-code"
38    /// dance, which reported success even when the terminal event failed to write.
39    fn finish(&self, event: agent_first_data::Event, success_code: u8) -> ExitCode {
40        let stdout = std::io::stdout();
41        let mut emitter = agent_first_data::CliEmitter::new(stdout.lock(), self.format);
42        ExitCode::from(emitter.finish(event, success_code))
43    }
44
45    /// `{kind: "result", result: ..., trace: {}}` → stdout.
46    pub fn ok<T: Serialize>(&self, result: T) -> ExitCode {
47        let result_value = serde_json::to_value(&result).unwrap_or_default();
48        self.finish(agent_first_data::json_result(result_value).build(), 0)
49    }
50
51    /// `{kind: "result", result: ..., trace: ...}` → stdout.
52    pub fn ok_trace<T: Serialize>(&self, result: T, trace: impl Serialize) -> ExitCode {
53        let result_value = serde_json::to_value(&result).unwrap_or_default();
54        let trace_value = serde_json::to_value(&trace).unwrap_or_default();
55        self.finish(
56            agent_first_data::json_result(result_value)
57                .trace(trace_value)
58                .build(),
59            0,
60        )
61    }
62
63    /// `{kind: "result", result: ...}` → stdout, with a caller-chosen exit code.
64    ///
65    /// For machine-checkable "is there drift" style results (e.g. `hatch bond
66    /// sync --check`) where the outcome is a normal, well-formed result (not an
67    /// error) but CI still needs a distinct non-zero exit to gate on.
68    pub fn ok_with_code<T: Serialize>(&self, result: T, code: u8) -> ExitCode {
69        let result_value = serde_json::to_value(&result).unwrap_or_default();
70        self.finish(agent_first_data::json_result(result_value).build(), code)
71    }
72
73    /// `{kind: "error", error: {code, message, hint, retryable}, trace}` → stdout.
74    pub fn error(&self, error_code: &str, message: &str) -> ExitCode {
75        self.error_hint(error_code, message, None)
76    }
77
78    /// Like [`error`] but with an actionable hint for remediation.
79    pub fn error_hint(&self, error_code: &str, message: &str, hint: Option<&str>) -> ExitCode {
80        self.finish(
81            built_event(
82                agent_first_data::json_error(error_code, message)
83                    .hint(actionable_hint(error_code, hint))
84                    .trace(serde_json::json!({"duration_ms": 0}))
85                    .build(),
86            ),
87            1,
88        )
89    }
90
91    /// Output error from anyhow::Error
92    pub fn error_from(&self, error_code: &str, err: &anyhow::Error) -> ExitCode {
93        self.error(error_code, &err.to_string())
94    }
95
96    /// Output error from [`crate::HyphaError`] (includes hint when present).
97    pub fn error_hypha(&self, err: &crate::HyphaError) -> ExitCode {
98        self.error_hint(&err.code, &err.message, err.hint.as_deref())
99    }
100
101    /// Agent-First Data progress step → stdout
102    /// `{kind: "progress", progress: {current, total, message, ...}, trace}`.
103    pub fn progress(&self, step: u32, total: u32, message: &str, data: serde_json::Value) {
104        let mut fields = match data {
105            serde_json::Value::Object(map) => map,
106            _ => serde_json::Map::new(),
107        };
108        fields.insert("current".into(), step.into());
109        fields.insert("total".into(), total.into());
110        fields.insert("message".into(), message.into());
111        self.emit(agent_first_data::json_progress(serde_json::Value::Object(fields)).build());
112    }
113
114    /// Byte-level protocol-v1 download progress → stdout.
115    /// `{kind: "progress", progress: {event, downloaded_bytes, total_bytes}}`.
116    pub fn download_progress(&self, downloaded_bytes: u64, total_bytes: Option<u64>) {
117        self.emit(
118            agent_first_data::json_progress(serde_json::json!({
119                "event": "download_progress",
120                "downloaded_bytes": downloaded_bytes,
121                "total_bytes": total_bytes,
122            }))
123            .build(),
124        );
125    }
126
127    /// Non-fatal warning → stdout
128    pub fn warn(&self, code: &str, message: &str) {
129        self.emit(
130            agent_first_data::json_log(serde_json::json!({
131                "event": code,
132                "message": message,
133            }))
134            .build(),
135        );
136    }
137
138    /// Emit a protocol-v1 startup log event to stdout.
139    pub fn startup(&self, args: serde_json::Value) {
140        let (config, config_error) = match crate::config::HyphaConfig::load() {
141            Ok(cfg) => (serde_json::to_value(&cfg).unwrap_or_default(), None),
142            Err(err) => (
143                serde_json::Value::Null,
144                Some(serde_json::json!({
145                    "code": err.code,
146                    "message": err.message,
147                    "hint": err.hint,
148                })),
149            ),
150        };
151
152        let env = serde_json::json!({
153            "CMN_HOME": std::env::var("CMN_HOME").ok(),
154            "SYNAPSE_TOKEN_SECRET": std::env::var("SYNAPSE_TOKEN_SECRET").ok(),
155        });
156        self.emit(
157            agent_first_data::json_log(serde_json::json!({
158                "category": "startup",
159                "event": "startup",
160                "hypha_version": env!("CARGO_PKG_VERSION"),
161                "config": config,
162                "config_error": config_error,
163                "args": args,
164                "env": env
165            }))
166            .build(),
167        );
168    }
169}
170
171fn actionable_hint<'a>(error_code: &str, hint: Option<&'a str>) -> &'a str {
172    if let Some(hint) = hint.filter(|h| !h.trim().is_empty()) {
173        return hint;
174    }
175    match error_code {
176        "invalid_args" | "invalid_value" | "unknown_key" | "missing_domain" => {
177            "run hypha --help or the relevant subcommand --help, then retry with valid inputs"
178        }
179        "invalid_uri" | "uri_error" | "cmn_invalid" => {
180            "use a CMN URI in the form cmn://domain or cmn://domain/b3.hash"
181        }
182        "synapse_error" | "network_error" | "NETWORK_ERR" => {
183            "check the synapse URL, network connectivity, and any configured auth token"
184        }
185        "not_found" | "missing_spore" | "not_cached" | "spore_not_found" => {
186            "verify the domain/hash and run hypha sense or hypha cache list before retrying"
187        }
188        "spore_security_rejected" => {
189            "received content targets protected control paths; inspect the spore and only relax cache.spore_reject_path_components if you accept that risk"
190        }
191        "skill_error" => "run hypha skill --help and retry with the suggested options",
192        _ => "read the error field, check hypha --help for the expected input, and retry",
193    }
194}
195
196/// Bridges [`crate::EventSink`] to an existing [`Output`].
197///
198/// Used in `handle_*` CLI wrappers so inner lib functions (which now take
199/// `&dyn EventSink`) can still emit warnings through the normal CLI output.
200pub struct OutSink<'a>(pub &'a Output);
201
202impl crate::EventSink for OutSink<'_> {
203    fn emit(&self, event: crate::HyphaEvent) {
204        match event {
205            crate::HyphaEvent::Progress {
206                current,
207                total,
208                message,
209            } => {
210                self.0
211                    .progress(current, total, &message, serde_json::Value::Null);
212            }
213            crate::HyphaEvent::DownloadProgress {
214                downloaded_bytes,
215                total_bytes,
216            } => {
217                self.0.download_progress(downloaded_bytes, total_bytes);
218            }
219            crate::HyphaEvent::Log { message } => {
220                self.0.warn("log", &message);
221            }
222            crate::HyphaEvent::Warn { message } => {
223                self.0.warn("warn", &message);
224            }
225        }
226    }
227}