Skip to main content

hypha/api/
mod.rs

1#![allow(clippy::print_stdout)]
2// Agent-First Data output layer — the ONLY place println! should appear.
3// All other code must use Output methods (ok/error/warn/progress/startup).
4
5use serde::Serialize;
6use std::process::ExitCode;
7
8/// Result type that handles output formatting and exit codes.
9///
10/// Builds Agent-First Data-compliant JSON (code/result/trace/error), redacts secrets,
11/// formats via agent_first_data, and prints protocol/log events to stdout.
12pub struct Output {
13    format: agent_first_data::OutputFormat,
14}
15
16#[allow(clippy::print_stdout)]
17impl Output {
18    pub fn new(format: agent_first_data::OutputFormat) -> Self {
19        Self { format }
20    }
21
22    fn format(&self, value: &serde_json::Value) -> String {
23        agent_first_data::cli_output_with_options(
24            value,
25            self.format,
26            &agent_first_data::OutputOptions::default(),
27        )
28    }
29
30    /// Emit a pre-built AFDATA value whose top-level `code` is already set.
31    pub fn value(&self, mut value: serde_json::Value) -> ExitCode {
32        agent_first_data::redact_secrets_in_place(&mut value);
33        println!("{}", self.format(&value));
34        ExitCode::SUCCESS
35    }
36
37    /// {code: "ok", result: ...} → stdout
38    pub fn ok<T: Serialize>(&self, result: T) -> ExitCode {
39        let result_value = serde_json::to_value(&result).unwrap_or_default();
40        let mut resp = agent_first_data::build_json_ok(result_value, None);
41        agent_first_data::redact_secrets_in_place(&mut resp);
42        println!("{}", self.format(&resp));
43        ExitCode::SUCCESS
44    }
45
46    /// {code: "ok", result: ..., trace: ...} → stdout
47    pub fn ok_trace<T: Serialize>(&self, result: T, trace: impl Serialize) -> ExitCode {
48        let result_value = serde_json::to_value(&result).unwrap_or_default();
49        let trace_value = serde_json::to_value(&trace).unwrap_or_default();
50        let mut resp = agent_first_data::build_json_ok(result_value, Some(trace_value));
51        agent_first_data::redact_secrets_in_place(&mut resp);
52        println!("{}", self.format(&resp));
53        ExitCode::SUCCESS
54    }
55
56    /// {code: "<error_code>", error: "msg", hint: "...", trace: {duration_ms: 0}} → stdout
57    pub fn error(&self, error_code: &str, message: &str) -> ExitCode {
58        self.error_hint(error_code, message, None)
59    }
60
61    /// Like [`error`] but with an actionable hint for remediation.
62    pub fn error_hint(&self, error_code: &str, message: &str, hint: Option<&str>) -> ExitCode {
63        let mut fields = serde_json::Map::new();
64        fields.insert(
65            "error".into(),
66            serde_json::Value::String(message.to_string()),
67        );
68        fields.insert(
69            "hint".into(),
70            serde_json::Value::String(actionable_hint(error_code, hint).to_string()),
71        );
72        let mut resp = agent_first_data::build_json(
73            error_code,
74            serde_json::Value::Object(fields),
75            Some(serde_json::json!({"duration_ms": 0})),
76        );
77        agent_first_data::redact_secrets_in_place(&mut resp);
78        println!("{}", self.format(&resp));
79        ExitCode::FAILURE
80    }
81
82    /// Output error from anyhow::Error
83    pub fn error_from(&self, error_code: &str, err: &anyhow::Error) -> ExitCode {
84        self.error(error_code, &err.to_string())
85    }
86
87    /// Output error from [`crate::HyphaError`] (includes hint when present).
88    pub fn error_hypha(&self, err: &crate::HyphaError) -> ExitCode {
89        self.error_hint(&err.code, &err.message, err.hint.as_deref())
90    }
91
92    /// Agent-First Data progress step → stdout
93    /// {"code": "progress", "current": N, "total": M, "message": "...", ...}
94    pub fn progress(&self, step: u32, total: u32, message: &str, data: serde_json::Value) {
95        let mut fields = match data {
96            serde_json::Value::Object(map) => map,
97            _ => serde_json::Map::new(),
98        };
99        fields.insert("current".into(), step.into());
100        fields.insert("total".into(), total.into());
101        fields.insert("message".into(), message.into());
102        let mut resp =
103            agent_first_data::build_json("progress", serde_json::Value::Object(fields), None);
104        agent_first_data::redact_secrets_in_place(&mut resp);
105        println!("{}", self.format(&resp));
106    }
107
108    /// Byte-level download progress → stdout
109    /// {"code": "download_progress", "downloaded_bytes": N, "total_bytes": M}
110    pub fn download_progress(&self, downloaded_bytes: u64, total_bytes: Option<u64>) {
111        let mut resp = agent_first_data::build_json(
112            "download_progress",
113            serde_json::json!({
114                "downloaded_bytes": downloaded_bytes,
115                "total_bytes": total_bytes,
116            }),
117            None,
118        );
119        agent_first_data::redact_secrets_in_place(&mut resp);
120        println!("{}", self.format(&resp));
121    }
122
123    /// Non-fatal warning → stdout
124    pub fn warn(&self, code: &str, message: &str) {
125        let mut resp =
126            agent_first_data::build_json(code, serde_json::json!({"message": message}), None);
127        agent_first_data::redact_secrets_in_place(&mut resp);
128        println!("{}", self.format(&resp));
129    }
130
131    /// {code: "log", event: "startup", args: ..., config: ..., env: ...} → stdout
132    pub fn startup(&self, args: serde_json::Value) {
133        let (config, config_error) = match crate::config::HyphaConfig::load() {
134            Ok(cfg) => (serde_json::to_value(&cfg).unwrap_or_default(), None),
135            Err(err) => (
136                serde_json::Value::Null,
137                Some(serde_json::json!({
138                    "code": err.code,
139                    "message": err.message,
140                    "hint": err.hint,
141                })),
142            ),
143        };
144
145        let env = serde_json::json!({
146            "CMN_HOME": std::env::var("CMN_HOME").ok(),
147            "SYNAPSE_TOKEN_SECRET": std::env::var("SYNAPSE_TOKEN_SECRET").ok(),
148        });
149        let mut resp = agent_first_data::build_json(
150            "log",
151            serde_json::json!({
152                "category": "startup",
153                "event": "startup",
154                "hypha_version": env!("CARGO_PKG_VERSION"),
155                "config": config,
156                "config_error": config_error,
157                "args": args,
158                "env": env
159            }),
160            None,
161        );
162        agent_first_data::redact_secrets_in_place(&mut resp);
163        println!("{}", self.format(&resp));
164    }
165}
166
167fn actionable_hint<'a>(error_code: &str, hint: Option<&'a str>) -> &'a str {
168    if let Some(hint) = hint.filter(|h| !h.trim().is_empty()) {
169        return hint;
170    }
171    match error_code {
172        "invalid_args" | "invalid_value" | "unknown_key" | "missing_domain" => {
173            "run hypha --help or the relevant subcommand --help, then retry with valid inputs"
174        }
175        "invalid_uri" | "uri_error" | "cmn_invalid" => {
176            "use a CMN URI in the form cmn://domain or cmn://domain/b3.hash"
177        }
178        "synapse_error" | "network_error" | "NETWORK_ERR" => {
179            "check the synapse URL, network connectivity, and any configured auth token"
180        }
181        "not_found" | "missing_spore" | "not_cached" | "spore_not_found" => {
182            "verify the domain/hash and run hypha sense or hypha cache list before retrying"
183        }
184        "spore_security_rejected" => {
185            "received content targets protected control paths; inspect the spore and only relax cache.spore_reject_path_components if you accept that risk"
186        }
187        "skill_error" => "run hypha skill --help and retry with the suggested options",
188        _ => "read the error field, check hypha --help for the expected input, and retry",
189    }
190}
191
192/// Bridges [`crate::EventSink`] to an existing [`Output`].
193///
194/// Used in `handle_*` CLI wrappers so inner lib functions (which now take
195/// `&dyn EventSink`) can still emit warnings through the normal CLI output.
196pub struct OutSink<'a>(pub &'a Output);
197
198impl crate::EventSink for OutSink<'_> {
199    fn emit(&self, event: crate::HyphaEvent) {
200        match event {
201            crate::HyphaEvent::Progress {
202                current,
203                total,
204                message,
205            } => {
206                self.0
207                    .progress(current, total, &message, serde_json::Value::Null);
208            }
209            crate::HyphaEvent::DownloadProgress {
210                downloaded_bytes,
211                total_bytes,
212            } => {
213                self.0.download_progress(downloaded_bytes, total_bytes);
214            }
215            crate::HyphaEvent::Log { message } => {
216                self.0.warn("log", &message);
217            }
218            crate::HyphaEvent::Warn { message } => {
219                self.0.warn("warn", &message);
220            }
221        }
222    }
223}