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