1use std::io::Write;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use serde_json::json;
6
7const KURA_CLIENT_NAME_HEADER: &str = "x-kura-client-name";
8const KURA_CLIENT_VERSION_HEADER: &str = "x-kura-client-version";
9const KURA_CLIENT_INSTALL_CHANNEL_HEADER: &str = "x-kura-client-install-channel";
10const KURA_CLI_CLIENT_NAME: &str = "kura-cli";
11
12#[derive(Debug, Serialize, Deserialize)]
14pub struct StoredCredentials {
15 pub api_url: String,
16 pub access_token: String,
17 pub refresh_token: String,
18 pub expires_at: DateTime<Utc>,
19}
20
21#[derive(Deserialize)]
22pub struct TokenResponse {
23 pub access_token: String,
24 pub refresh_token: String,
25 pub expires_in: i64,
26}
27
28pub fn client() -> reqwest::Client {
29 reqwest::Client::new()
30}
31
32fn cli_install_channel() -> String {
33 std::env::var("KURA_CLI_INSTALL_CHANNEL")
34 .ok()
35 .map(|value| value.trim().to_ascii_lowercase())
36 .filter(|value| !value.is_empty())
37 .unwrap_or_else(|| "cargo".to_string())
38}
39
40fn with_cli_client_headers(mut req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
41 req = req.header(KURA_CLIENT_NAME_HEADER, KURA_CLI_CLIENT_NAME);
42 req = req.header(KURA_CLIENT_VERSION_HEADER, env!("CARGO_PKG_VERSION"));
43 req.header(KURA_CLIENT_INSTALL_CHANNEL_HEADER, cli_install_channel())
44}
45
46fn extract_user_notice_lines(body: &serde_json::Value) -> Vec<String> {
47 let notices = body
48 .get("user_notices")
49 .and_then(|value| value.as_array())
50 .cloned()
51 .unwrap_or_default();
52
53 let mut lines = Vec::new();
54 for notice in notices {
55 let Some(obj) = notice.as_object() else {
56 continue;
57 };
58 let message = obj
59 .get("message_short")
60 .and_then(|value| value.as_str())
61 .map(str::trim)
62 .filter(|value| !value.is_empty());
63 let cmd = obj
64 .get("upgrade_command")
65 .and_then(|value| value.as_str())
66 .map(str::trim)
67 .filter(|value| !value.is_empty());
68 let docs_hint = obj
69 .get("docs_hint")
70 .and_then(|value| value.as_str())
71 .map(str::trim)
72 .filter(|value| !value.is_empty());
73
74 let mut line = String::from("[kura notice]");
75 if let Some(message) = message {
76 line.push(' ');
77 line.push_str(message);
78 }
79 if let Some(cmd) = cmd {
80 line.push_str(" Update: ");
81 line.push_str(cmd);
82 } else if let Some(docs_hint) = docs_hint {
83 line.push(' ');
84 line.push_str(docs_hint);
85 }
86 if line != "[kura notice]" {
87 lines.push(line);
88 }
89 }
90 lines
91}
92
93pub fn env_flag_enabled(name: &str) -> bool {
94 std::env::var(name)
95 .ok()
96 .map(|value| {
97 let normalized = value.trim().to_ascii_lowercase();
98 matches!(normalized.as_str(), "1" | "true" | "yes" | "on")
99 })
100 .unwrap_or(false)
101}
102
103pub fn admin_surface_enabled() -> bool {
104 env_flag_enabled("KURA_ENABLE_ADMIN_SURFACE")
105}
106
107pub fn is_admin_api_path(path: &str) -> bool {
108 let trimmed = path.trim();
109 if trimmed.is_empty() {
110 return false;
111 }
112
113 let normalized = if trimmed.starts_with('/') {
114 trimmed.to_ascii_lowercase()
115 } else {
116 format!("/{}", trimmed.to_ascii_lowercase())
117 };
118
119 normalized == "/v1/admin" || normalized.starts_with("/v1/admin/")
120}
121
122pub fn exit_error(message: &str, docs_hint: Option<&str>) -> ! {
123 let mut err = json!({
124 "error": "cli_error",
125 "message": message
126 });
127 if let Some(hint) = docs_hint {
128 err["docs_hint"] = json!(hint);
129 }
130 eprintln!("{}", serde_json::to_string_pretty(&err).unwrap());
131 std::process::exit(1);
132}
133
134pub fn config_path() -> std::path::PathBuf {
135 let config_dir = dirs::config_dir()
136 .unwrap_or_else(|| std::path::PathBuf::from("."))
137 .join("kura");
138 config_dir.join("config.json")
139}
140
141pub fn load_credentials() -> Option<StoredCredentials> {
142 let path = config_path();
143 let data = std::fs::read_to_string(&path).ok()?;
144 serde_json::from_str(&data).ok()
145}
146
147pub fn save_credentials(creds: &StoredCredentials) -> Result<(), Box<dyn std::error::Error>> {
148 let path = config_path();
149 if let Some(parent) = path.parent() {
150 std::fs::create_dir_all(parent)?;
151 }
152
153 let data = serde_json::to_string_pretty(creds)?;
154
155 let mut file = std::fs::OpenOptions::new()
157 .write(true)
158 .create(true)
159 .truncate(true)
160 .mode(0o600)
161 .open(&path)?;
162 file.write_all(data.as_bytes())?;
163
164 Ok(())
165}
166
167pub async fn resolve_token(api_url: &str) -> Result<String, Box<dyn std::error::Error>> {
172 if let Ok(key) = std::env::var("KURA_API_KEY") {
174 return Ok(key);
175 }
176
177 if let Some(creds) = load_credentials() {
179 let buffer = chrono::Duration::minutes(5);
181 if Utc::now() + buffer >= creds.expires_at {
182 match refresh_stored_token(api_url, &creds).await {
184 Ok(new_creds) => {
185 save_credentials(&new_creds)?;
186 return Ok(new_creds.access_token);
187 }
188 Err(_) => {
189 return Err(
190 "Access token expired and refresh failed. Run `kura login` again.".into(),
191 );
192 }
193 }
194 }
195 return Ok(creds.access_token);
196 }
197
198 Err("No credentials found. Run `kura login` or set KURA_API_KEY.".into())
199}
200
201async fn refresh_stored_token(
202 api_url: &str,
203 creds: &StoredCredentials,
204) -> Result<StoredCredentials, Box<dyn std::error::Error>> {
205 let resp = client()
206 .post(format!("{api_url}/v1/auth/token"))
207 .json(&json!({
208 "grant_type": "refresh_token",
209 "refresh_token": creds.refresh_token,
210 "client_id": "kura-cli"
211 }))
212 .send()
213 .await?;
214
215 if !resp.status().is_success() {
216 let body: serde_json::Value = resp.json().await?;
217 return Err(format!("Token refresh failed: {}", body).into());
218 }
219
220 let token_resp: TokenResponse = resp.json().await?;
221 Ok(StoredCredentials {
222 api_url: creds.api_url.clone(),
223 access_token: token_resp.access_token,
224 refresh_token: token_resp.refresh_token,
225 expires_at: Utc::now() + chrono::Duration::seconds(token_resp.expires_in),
226 })
227}
228
229pub async fn api_request(
234 api_url: &str,
235 method: reqwest::Method,
236 path: &str,
237 token: Option<&str>,
238 body: Option<serde_json::Value>,
239 query: &[(String, String)],
240 extra_headers: &[(String, String)],
241 raw: bool,
242 include: bool,
243) -> i32 {
244 let url = match reqwest::Url::parse(&format!("{api_url}{path}")) {
245 Ok(mut u) => {
246 if !query.is_empty() {
247 let mut q = u.query_pairs_mut();
248 for (k, v) in query {
249 q.append_pair(k, v);
250 }
251 }
252 u
253 }
254 Err(e) => {
255 let err = json!({
256 "error": "cli_error",
257 "message": format!("Invalid URL: {api_url}{path}: {e}")
258 });
259 eprintln!("{}", serde_json::to_string_pretty(&err).unwrap());
260 return 4;
261 }
262 };
263
264 let mut req = with_cli_client_headers(client().request(method, url));
265
266 if let Some(t) = token {
267 req = req.header("Authorization", format!("Bearer {t}"));
268 }
269
270 for (k, v) in extra_headers {
271 req = req.header(k.as_str(), v.as_str());
272 }
273
274 if let Some(b) = body {
275 req = req.json(&b);
276 }
277
278 let resp = match req.send().await {
279 Ok(r) => r,
280 Err(e) => {
281 let err = json!({
282 "error": "connection_error",
283 "message": format!("{e}"),
284 "docs_hint": "Is the API server running? Check KURA_API_URL."
285 });
286 eprintln!("{}", serde_json::to_string_pretty(&err).unwrap());
287 return 3;
288 }
289 };
290
291 let status = resp.status().as_u16();
292 let exit_code = match status {
293 200..=299 => 0,
294 400..=499 => 1,
295 _ => 2,
296 };
297
298 let headers: serde_json::Map<String, serde_json::Value> = if include {
300 resp.headers()
301 .iter()
302 .map(|(k, v)| (k.to_string(), json!(v.to_str().unwrap_or("<binary>"))))
303 .collect()
304 } else {
305 serde_json::Map::new()
306 };
307
308 let resp_body: serde_json::Value = match resp.bytes().await {
309 Ok(bytes) => {
310 if bytes.is_empty() {
311 serde_json::Value::Null
312 } else {
313 serde_json::from_slice(&bytes).unwrap_or_else(|_| {
314 serde_json::Value::String(String::from_utf8_lossy(&bytes).to_string())
315 })
316 }
317 }
318 Err(e) => json!({"raw_error": format!("Failed to read response body: {e}")}),
319 };
320
321 let user_notice_lines = if exit_code == 0 {
322 extract_user_notice_lines(&resp_body)
323 } else {
324 Vec::new()
325 };
326
327 let output = if include {
328 json!({
329 "status": status,
330 "headers": headers,
331 "body": resp_body
332 })
333 } else {
334 resp_body
335 };
336
337 let formatted = if raw {
338 serde_json::to_string(&output).unwrap()
339 } else {
340 serde_json::to_string_pretty(&output).unwrap()
341 };
342
343 for line in user_notice_lines {
344 eprintln!("{line}");
345 }
346
347 if exit_code == 0 {
348 println!("{formatted}");
349 } else {
350 eprintln!("{formatted}");
351 }
352
353 exit_code
354}
355
356pub async fn raw_api_request(
359 api_url: &str,
360 method: reqwest::Method,
361 path: &str,
362 token: Option<&str>,
363) -> Result<(u16, serde_json::Value), String> {
364 let url = reqwest::Url::parse(&format!("{api_url}{path}"))
365 .map_err(|e| format!("Invalid URL: {e}"))?;
366
367 let mut req = with_cli_client_headers(client().request(method, url));
368 if let Some(t) = token {
369 req = req.header("Authorization", format!("Bearer {t}"));
370 }
371
372 let resp = req.send().await.map_err(|e| format!("{e}"))?;
373 let status = resp.status().as_u16();
374 let body: serde_json::Value = resp
375 .json()
376 .await
377 .unwrap_or(json!({"error": "non-json response"}));
378
379 Ok((status, body))
380}
381
382pub fn check_auth_configured() -> Option<(&'static str, String)> {
385 if let Ok(key) = std::env::var("KURA_API_KEY") {
386 let prefix = if key.len() > 12 { &key[..12] } else { &key };
387 return Some(("api_key (env)", format!("{prefix}...")));
388 }
389
390 if let Some(creds) = load_credentials() {
391 let expired = chrono::Utc::now() >= creds.expires_at;
392 let detail = if expired {
393 format!("expired at {}", creds.expires_at)
394 } else {
395 format!("valid until {}", creds.expires_at)
396 };
397 return Some(("oauth_token (stored)", detail));
398 }
399
400 None
401}
402
403pub fn read_json_from_file(path: &str) -> Result<serde_json::Value, String> {
405 let raw = if path == "-" {
406 let mut buf = String::new();
407 std::io::stdin()
408 .read_line(&mut buf)
409 .map_err(|e| format!("Failed to read stdin: {e}"))?;
410 let mut rest = String::new();
412 while std::io::stdin()
413 .read_line(&mut rest)
414 .map_err(|e| format!("Failed to read stdin: {e}"))?
415 > 0
416 {
417 buf.push_str(&rest);
418 rest.clear();
419 }
420 buf
421 } else {
422 std::fs::read_to_string(path).map_err(|e| format!("Failed to read file '{path}': {e}"))?
423 };
424 serde_json::from_str(&raw).map_err(|e| format!("Invalid JSON in '{path}': {e}"))
425}
426
427#[cfg(unix)]
429use std::os::unix::fs::OpenOptionsExt;
430
431#[cfg(not(unix))]
433trait OpenOptionsExt {
434 fn mode(&mut self, _mode: u32) -> &mut Self;
435}
436
437#[cfg(not(unix))]
438impl OpenOptionsExt for std::fs::OpenOptions {
439 fn mode(&mut self, _mode: u32) -> &mut Self {
440 self
441 }
442}
443
444#[cfg(test)]
445mod tests {
446 use super::{extract_user_notice_lines, is_admin_api_path};
447 use serde_json::json;
448
449 #[test]
450 fn admin_path_detection_matches_v1_admin_namespace_only() {
451 assert!(is_admin_api_path("/v1/admin"));
452 assert!(is_admin_api_path("/v1/admin/invites"));
453 assert!(is_admin_api_path("v1/admin/security/kill-switch"));
454 assert!(!is_admin_api_path("/v1/agent/context"));
455 assert!(!is_admin_api_path("/health"));
456 }
457
458 #[test]
459 fn extract_user_notice_lines_reads_message_and_upgrade_command() {
460 let body = json!({
461 "user_notices": [{
462 "kind": "client_update",
463 "message_short": "Kura CLI update available (0.1.6).",
464 "upgrade_command": "cargo install kura-cli --locked --force"
465 }]
466 });
467 let lines = extract_user_notice_lines(&body);
468 assert_eq!(lines.len(), 1);
469 assert!(lines[0].contains("[kura notice]"));
470 assert!(lines[0].contains("Kura CLI update available"));
471 assert!(lines[0].contains("cargo install kura-cli --locked --force"));
472 }
473
474 #[test]
475 fn extract_user_notice_lines_returns_empty_when_absent() {
476 let lines = extract_user_notice_lines(&json!({"ok": true}));
477 assert!(lines.is_empty());
478 }
479}