Skip to main content

kura_cli/commands/
exec.rs

1use clap::Args;
2use serde::Deserialize;
3use serde_json::Value;
4
5use crate::util::{
6    admin_surface_enabled, api_request, exit_error, is_admin_api_path, read_json_from_file,
7    resolve_token,
8};
9
10#[derive(Args)]
11pub struct ExecArgs {
12    /// JSON envelope file path (use '-' for stdin)
13    #[arg(long, default_value = "-")]
14    request_file: String,
15}
16
17#[derive(Debug, Deserialize)]
18struct ExecEnvelope {
19    method: String,
20    path: String,
21    #[serde(default)]
22    body: Option<Value>,
23    #[serde(default)]
24    query: Option<Value>,
25    #[serde(default)]
26    headers: Option<Value>,
27    #[serde(default)]
28    include: bool,
29    #[serde(default)]
30    raw: bool,
31    #[serde(default)]
32    no_auth: Option<bool>,
33    #[serde(default)]
34    allow_async_analysis_job: bool,
35}
36
37pub async fn run(api_url: &str, cli_no_auth: bool, args: ExecArgs) -> i32 {
38    let payload = match read_json_from_file(&args.request_file) {
39        Ok(value) => value,
40        Err(err) => exit_error(
41            &err,
42            Some("Provide a valid JSON envelope via --request-file (or '-' for stdin)."),
43        ),
44    };
45
46    let envelope: ExecEnvelope = match serde_json::from_value(payload) {
47        Ok(value) => value,
48        Err(err) => exit_error(
49            &format!("Invalid exec envelope JSON: {err}"),
50            Some(
51                "Envelope fields: method, path, body?, query?, headers?, include?, raw?, no_auth?, allow_async_analysis_job?",
52            ),
53        ),
54    };
55
56    let method = parse_method(&envelope.method);
57    let path = normalize_path(&envelope.path);
58
59    if is_admin_api_path(&path) && !admin_surface_enabled() {
60        exit_error(
61            "Admin API paths are disabled in CLI by default.",
62            Some("Set KURA_ENABLE_ADMIN_SURFACE=1 only in trusted developer/admin sessions."),
63        );
64    }
65
66    if is_async_analysis_job_create_path(&path)
67        && method == reqwest::Method::POST
68        && !envelope.allow_async_analysis_job
69    {
70        exit_error(
71            "Direct async analysis-job creation via POST /v1/analysis/jobs is blocked by default.",
72            Some(
73                "Use `kura analyze --objective \"...\"` for user-facing analyses. Set allow_async_analysis_job=true only for explicit background-job workflows.",
74            ),
75        );
76    }
77
78    let query = parse_key_value_entries(envelope.query, "query").unwrap_or_else(|err| {
79        exit_error(
80            &err,
81            Some(
82                "Use either an object {\"a\":\"b\"} or an array of {\"key\":\"a\",\"value\":\"b\"} entries.",
83            ),
84        )
85    });
86
87    let headers = parse_key_value_entries(envelope.headers, "headers").unwrap_or_else(|err| {
88        exit_error(
89            &err,
90            Some(
91                "Use either an object {\"Header\":\"Value\"} or an array of {\"key\":\"Header\",\"value\":\"Value\"} entries.",
92            ),
93        )
94    });
95
96    let no_auth = envelope.no_auth.unwrap_or(cli_no_auth);
97    let token = if no_auth {
98        None
99    } else {
100        match resolve_token(api_url).await {
101            Ok(token) => Some(token),
102            Err(err) => exit_error(
103                &err.to_string(),
104                Some("Run `kura login`, set KURA_API_KEY, or set no_auth=true in the envelope."),
105            ),
106        }
107    };
108
109    api_request(
110        api_url,
111        method,
112        &path,
113        token.as_deref(),
114        envelope.body,
115        &query,
116        &headers,
117        envelope.raw,
118        envelope.include,
119    )
120    .await
121}
122
123fn parse_method(raw: &str) -> reqwest::Method {
124    match raw.trim().to_ascii_uppercase().as_str() {
125        "GET" => reqwest::Method::GET,
126        "POST" => reqwest::Method::POST,
127        "PUT" => reqwest::Method::PUT,
128        "DELETE" => reqwest::Method::DELETE,
129        "PATCH" => reqwest::Method::PATCH,
130        "HEAD" => reqwest::Method::HEAD,
131        "OPTIONS" => reqwest::Method::OPTIONS,
132        other => exit_error(
133            &format!("Unknown HTTP method in exec envelope: {other}"),
134            Some("Supported methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"),
135        ),
136    }
137}
138
139fn normalize_path(raw: &str) -> String {
140    let trimmed = raw.trim();
141    if trimmed.is_empty() {
142        exit_error(
143            "Exec envelope field 'path' must not be empty.",
144            Some("Example: \"path\": \"/v1/events\""),
145        );
146    }
147    if trimmed.starts_with('/') {
148        trimmed.to_string()
149    } else {
150        format!("/{trimmed}")
151    }
152}
153
154fn parse_key_value_entries(
155    input: Option<Value>,
156    field_name: &str,
157) -> Result<Vec<(String, String)>, String> {
158    let Some(input) = input else {
159        return Ok(Vec::new());
160    };
161
162    match input {
163        Value::Null => Ok(Vec::new()),
164        Value::Object(map) => {
165            let mut out = Vec::with_capacity(map.len());
166            for (key, value) in map {
167                let value = value.as_str().ok_or_else(|| {
168                    format!(
169                        "Exec envelope field '{field_name}.{key}' must be a string, got {value}."
170                    )
171                })?;
172                out.push((key, value.to_string()));
173            }
174            Ok(out)
175        }
176        Value::Array(items) => {
177            let mut out = Vec::with_capacity(items.len());
178            for (index, item) in items.into_iter().enumerate() {
179                match item {
180                    Value::Object(obj) => {
181                        let key = obj
182                            .get("key")
183                            .and_then(|value| value.as_str())
184                            .ok_or_else(|| {
185                                format!(
186                                    "Exec envelope field '{field_name}[{index}].key' must be a string."
187                                )
188                            })?;
189                        let value = obj
190                            .get("value")
191                            .and_then(|value| value.as_str())
192                            .ok_or_else(|| {
193                                format!(
194                                    "Exec envelope field '{field_name}[{index}].value' must be a string."
195                                )
196                            })?;
197                        out.push((key.to_string(), value.to_string()));
198                    }
199                    Value::Array(pair) if pair.len() == 2 => {
200                        let key = pair[0].as_str().ok_or_else(|| {
201                            format!(
202                                "Exec envelope field '{field_name}[{index}][0]' must be a string."
203                            )
204                        })?;
205                        let value = pair[1].as_str().ok_or_else(|| {
206                            format!(
207                                "Exec envelope field '{field_name}[{index}][1]' must be a string."
208                            )
209                        })?;
210                        out.push((key.to_string(), value.to_string()));
211                    }
212                    _ => {
213                        return Err(format!(
214                            "Exec envelope field '{field_name}' supports object entries, {{\"key\":...,\"value\":...}} objects, or [key,value] pairs."
215                        ));
216                    }
217                }
218            }
219            Ok(out)
220        }
221        other => Err(format!(
222            "Exec envelope field '{field_name}' must be an object or array, got {other}."
223        )),
224    }
225}
226
227fn is_async_analysis_job_create_path(path: &str) -> bool {
228    let trimmed = path.trim();
229    if trimmed.is_empty() {
230        return false;
231    }
232
233    let normalized = if trimmed.starts_with('/') {
234        trimmed.to_ascii_lowercase()
235    } else {
236        format!("/{trimmed}").to_ascii_lowercase()
237    };
238
239    normalized == "/v1/analysis/jobs" || normalized == "/v1/analysis/jobs/"
240}
241
242#[cfg(test)]
243mod tests {
244    use super::{is_async_analysis_job_create_path, parse_key_value_entries};
245    use serde_json::json;
246
247    #[test]
248    fn parse_key_value_entries_accepts_object_shape() {
249        let parsed = parse_key_value_entries(Some(json!({"a": "b", "x": "y"})), "query").unwrap();
250        assert_eq!(
251            parsed,
252            vec![
253                ("a".to_string(), "b".to_string()),
254                ("x".to_string(), "y".to_string())
255            ]
256        );
257    }
258
259    #[test]
260    fn parse_key_value_entries_accepts_object_array_shape() {
261        let parsed = parse_key_value_entries(
262            Some(json!([
263                {"key": "a", "value": "b"},
264                {"key": "x", "value": "y"}
265            ])),
266            "headers",
267        )
268        .unwrap();
269        assert_eq!(
270            parsed,
271            vec![
272                ("a".to_string(), "b".to_string()),
273                ("x".to_string(), "y".to_string())
274            ]
275        );
276    }
277
278    #[test]
279    fn parse_key_value_entries_rejects_non_string_values() {
280        let err = parse_key_value_entries(Some(json!({"a": 1})), "query").unwrap_err();
281        assert!(err.contains("must be a string"));
282    }
283
284    #[test]
285    fn async_analysis_job_create_path_detection_is_exact() {
286        assert!(is_async_analysis_job_create_path("/v1/analysis/jobs"));
287        assert!(is_async_analysis_job_create_path("v1/analysis/jobs"));
288        assert!(!is_async_analysis_job_create_path("/v1/analysis/jobs/run"));
289        assert!(!is_async_analysis_job_create_path("/v1/agent/context"));
290    }
291}