aperture_cli/
agent.rs

1use crate::cache::models::{
2    CachedApertureSecret, CachedCommand, CachedParameter, CachedRequestBody, CachedSpec,
3};
4use crate::config::models::GlobalConfig;
5use crate::config::url_resolver::BaseUrlResolver;
6use crate::error::Error;
7use openapiv3::{OpenAPI, Operation, Parameter as OpenApiParameter, ReferenceOr, SecurityScheme};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// JSON manifest describing all available commands and parameters for an API context.
12/// This is output when the `--describe-json` flag is used.
13#[derive(Debug, Serialize, Deserialize)]
14pub struct ApiCapabilityManifest {
15    /// Basic API metadata
16    pub api: ApiInfo,
17    /// Available command groups organized by tags
18    pub commands: HashMap<String, Vec<CommandInfo>>,
19    /// Security schemes available for this API
20    pub security_schemes: HashMap<String, SecuritySchemeInfo>,
21}
22
23#[derive(Debug, Serialize, Deserialize)]
24pub struct ApiInfo {
25    /// API name
26    pub name: String,
27    /// API version
28    pub version: String,
29    /// API description
30    pub description: Option<String>,
31    /// Base URL for the API
32    pub base_url: String,
33}
34
35#[derive(Debug, Serialize, Deserialize)]
36pub struct CommandInfo {
37    /// Command name (kebab-case operation ID)
38    pub name: String,
39    /// HTTP method
40    pub method: String,
41    /// API path with parameters
42    pub path: String,
43    /// Operation description
44    pub description: Option<String>,
45    /// Operation summary
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub summary: Option<String>,
48    /// Operation ID from the `OpenAPI` spec
49    pub operation_id: String,
50    /// Parameters for this operation
51    pub parameters: Vec<ParameterInfo>,
52    /// Request body information if applicable
53    pub request_body: Option<RequestBodyInfo>,
54    /// Security requirements for this operation
55    #[serde(skip_serializing_if = "Vec::is_empty", default)]
56    pub security_requirements: Vec<String>,
57    /// Tags associated with this operation
58    #[serde(skip_serializing_if = "Vec::is_empty", default)]
59    pub tags: Vec<String>,
60    /// Whether this operation is deprecated
61    #[serde(skip_serializing_if = "std::ops::Not::not", default)]
62    pub deprecated: bool,
63    /// External documentation URL
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub external_docs_url: Option<String>,
66}
67
68#[derive(Debug, Serialize, Deserialize)]
69pub struct ParameterInfo {
70    /// Parameter name
71    pub name: String,
72    /// Parameter location (path, query, header)
73    pub location: String,
74    /// Whether the parameter is required
75    pub required: bool,
76    /// Parameter type
77    pub param_type: String,
78    /// Parameter description
79    pub description: Option<String>,
80    /// Parameter format (e.g., int32, int64, date-time)
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub format: Option<String>,
83    /// Default value if specified
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub default_value: Option<String>,
86    /// Enumeration of valid values
87    #[serde(skip_serializing_if = "Vec::is_empty", default)]
88    pub enum_values: Vec<String>,
89    /// Example value
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub example: Option<String>,
92}
93
94#[derive(Debug, Serialize, Deserialize)]
95pub struct RequestBodyInfo {
96    /// Whether the request body is required
97    pub required: bool,
98    /// Content type (e.g., "application/json")
99    pub content_type: String,
100    /// Description of the request body
101    pub description: Option<String>,
102    /// Example of the request body
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub example: Option<String>,
105}
106
107/// Detailed, parsable security scheme description
108#[derive(Debug, Serialize, Deserialize)]
109pub struct SecuritySchemeInfo {
110    /// Type of security scheme (http, apiKey)
111    #[serde(rename = "type")]
112    pub scheme_type: String,
113    /// Optional description of the security scheme
114    pub description: Option<String>,
115    /// Detailed scheme configuration
116    #[serde(flatten)]
117    pub details: SecuritySchemeDetails,
118    /// Aperture-specific secret mapping
119    #[serde(rename = "x-aperture-secret", skip_serializing_if = "Option::is_none")]
120    pub aperture_secret: Option<CachedApertureSecret>,
121}
122
123/// Detailed security scheme configuration
124#[derive(Debug, Serialize, Deserialize)]
125#[serde(tag = "scheme", rename_all = "camelCase")]
126pub enum SecuritySchemeDetails {
127    /// HTTP authentication scheme (e.g., bearer, basic)
128    #[serde(rename = "bearer")]
129    HttpBearer {
130        /// Optional bearer token format
131        #[serde(skip_serializing_if = "Option::is_none")]
132        bearer_format: Option<String>,
133    },
134    /// HTTP basic authentication
135    #[serde(rename = "basic")]
136    HttpBasic,
137    /// API Key authentication
138    #[serde(rename = "apiKey")]
139    ApiKey {
140        /// Location of the API key (header, query, cookie)
141        #[serde(rename = "in")]
142        location: String,
143        /// Name of the parameter/header
144        name: String,
145    },
146}
147
148/// Converts a kebab-case string from operationId to a CLI command name
149fn to_kebab_case(s: &str) -> String {
150    let mut result = String::new();
151    let mut prev_lowercase = false;
152
153    for (i, ch) in s.chars().enumerate() {
154        if ch.is_uppercase() && i > 0 && prev_lowercase {
155            result.push('-');
156        }
157        result.push(ch.to_ascii_lowercase());
158        prev_lowercase = ch.is_lowercase();
159    }
160
161    result
162}
163
164/// Generates a capability manifest from an `OpenAPI` specification.
165///
166/// This function creates a comprehensive JSON description of all available commands,
167/// parameters, and security requirements directly from the original `OpenAPI` spec,
168/// preserving all metadata that might be lost in the cached representation.
169///
170/// # Arguments
171/// * `api_name` - The name of the API context
172/// * `spec` - The original `OpenAPI` specification
173/// * `global_config` - Optional global configuration for URL resolution
174///
175/// # Returns
176/// * `Ok(String)` - JSON-formatted capability manifest
177/// * `Err(Error)` - If JSON serialization fails
178///
179/// # Errors
180/// Returns an error if JSON serialization fails
181pub fn generate_capability_manifest_from_openapi(
182    api_name: &str,
183    spec: &OpenAPI,
184    global_config: Option<&GlobalConfig>,
185) -> Result<String, Error> {
186    // First, convert the OpenAPI spec to a temporary CachedSpec for URL resolution
187    let base_url = spec.servers.first().map(|s| s.url.clone());
188    let servers: Vec<String> = spec.servers.iter().map(|s| s.url.clone()).collect();
189
190    let temp_cached_spec = CachedSpec {
191        name: api_name.to_string(),
192        version: spec.info.version.clone(),
193        commands: vec![], // We'll generate commands directly from OpenAPI
194        base_url,
195        servers,
196        security_schemes: HashMap::new(), // We'll extract these directly too
197    };
198
199    // Resolve base URL using the same priority hierarchy as executor
200    let resolver = BaseUrlResolver::new(&temp_cached_spec);
201    let resolver = if let Some(config) = global_config {
202        resolver.with_global_config(config)
203    } else {
204        resolver
205    };
206    let resolved_base_url = resolver.resolve(None);
207
208    // Extract commands directly from OpenAPI spec
209    let mut command_groups: HashMap<String, Vec<CommandInfo>> = HashMap::new();
210
211    for (path, path_item) in &spec.paths.paths {
212        if let ReferenceOr::Item(item) = path_item {
213            // Process each HTTP method
214            for (method, operation) in crate::spec::http_methods_iter(item) {
215                if let Some(op) = operation {
216                    let command_info =
217                        convert_openapi_operation_to_info(method, path, op, spec.security.as_ref());
218
219                    // Group by first tag or "default"
220                    let group_name = op
221                        .tags
222                        .first()
223                        .cloned()
224                        .unwrap_or_else(|| "default".to_string());
225
226                    command_groups
227                        .entry(group_name)
228                        .or_default()
229                        .push(command_info);
230                }
231            }
232        }
233    }
234
235    // Extract security schemes directly from OpenAPI
236    let security_schemes = extract_security_schemes_from_openapi(spec);
237
238    // Create the manifest
239    let manifest = ApiCapabilityManifest {
240        api: ApiInfo {
241            name: spec.info.title.clone(),
242            version: spec.info.version.clone(),
243            description: spec.info.description.clone(),
244            base_url: resolved_base_url,
245        },
246        commands: command_groups,
247        security_schemes,
248    };
249
250    // Serialize to JSON
251    serde_json::to_string_pretty(&manifest).map_err(Error::Json)
252}
253
254/// Generates a capability manifest from a cached API specification.
255///
256/// This function creates a comprehensive JSON description of all available commands,
257/// parameters, and security requirements for the given API context.
258///
259/// # Arguments
260/// * `spec` - The cached API specification
261/// * `global_config` - Optional global configuration for URL resolution
262///
263/// # Returns
264/// * `Ok(String)` - JSON-formatted capability manifest
265/// * `Err(Error)` - If JSON serialization fails
266///
267/// # Errors
268/// Returns an error if JSON serialization fails
269pub fn generate_capability_manifest(
270    spec: &CachedSpec,
271    global_config: Option<&GlobalConfig>,
272) -> Result<String, Error> {
273    let mut command_groups: HashMap<String, Vec<CommandInfo>> = HashMap::new();
274
275    // Group commands by their tag (namespace) and convert to CommandInfo
276    for cached_command in &spec.commands {
277        let group_name = if cached_command.name.is_empty() {
278            "default".to_string()
279        } else {
280            cached_command.name.clone()
281        };
282
283        let command_info = convert_cached_command_to_info(cached_command);
284        command_groups
285            .entry(group_name)
286            .or_default()
287            .push(command_info);
288    }
289
290    // Resolve base URL using the same priority hierarchy as executor
291    let resolver = BaseUrlResolver::new(spec);
292    let resolver = if let Some(config) = global_config {
293        resolver.with_global_config(config)
294    } else {
295        resolver
296    };
297    let base_url = resolver.resolve(None);
298
299    // Create the manifest
300    let manifest = ApiCapabilityManifest {
301        api: ApiInfo {
302            name: spec.name.clone(),
303            version: spec.version.clone(),
304            description: None, // Not available in cached spec
305            base_url,
306        },
307        commands: command_groups,
308        security_schemes: extract_security_schemes(spec),
309    };
310
311    // Serialize to JSON
312    serde_json::to_string_pretty(&manifest).map_err(Error::Json)
313}
314
315/// Converts a `CachedCommand` to `CommandInfo` for the manifest
316fn convert_cached_command_to_info(cached_command: &CachedCommand) -> CommandInfo {
317    let command_name = if cached_command.operation_id.is_empty() {
318        cached_command.method.to_lowercase()
319    } else {
320        to_kebab_case(&cached_command.operation_id)
321    };
322
323    let parameters: Vec<ParameterInfo> = cached_command
324        .parameters
325        .iter()
326        .map(convert_cached_parameter_to_info)
327        .collect();
328
329    let request_body = cached_command
330        .request_body
331        .as_ref()
332        .map(convert_cached_request_body_to_info);
333
334    CommandInfo {
335        name: command_name,
336        method: cached_command.method.clone(),
337        path: cached_command.path.clone(),
338        description: cached_command.description.clone(),
339        summary: cached_command.summary.clone(),
340        operation_id: cached_command.operation_id.clone(),
341        parameters,
342        request_body,
343        security_requirements: cached_command.security_requirements.clone(),
344        tags: cached_command.tags.clone(),
345        deprecated: cached_command.deprecated,
346        external_docs_url: cached_command.external_docs_url.clone(),
347    }
348}
349
350/// Converts a `CachedParameter` to `ParameterInfo` for the manifest
351fn convert_cached_parameter_to_info(cached_param: &CachedParameter) -> ParameterInfo {
352    ParameterInfo {
353        name: cached_param.name.clone(),
354        location: cached_param.location.clone(),
355        required: cached_param.required,
356        param_type: cached_param
357            .schema_type
358            .clone()
359            .unwrap_or_else(|| "string".to_string()),
360        description: cached_param.description.clone(),
361        format: cached_param.format.clone(),
362        default_value: cached_param.default_value.clone(),
363        enum_values: cached_param.enum_values.clone(),
364        example: cached_param.example.clone(),
365    }
366}
367
368/// Converts a `CachedRequestBody` to `RequestBodyInfo` for the manifest
369fn convert_cached_request_body_to_info(cached_body: &CachedRequestBody) -> RequestBodyInfo {
370    RequestBodyInfo {
371        required: cached_body.required,
372        content_type: cached_body.content_type.clone(),
373        description: cached_body.description.clone(),
374        example: cached_body.example.clone(),
375    }
376}
377
378/// Extracts security schemes from the cached spec for the capability manifest
379fn extract_security_schemes(spec: &CachedSpec) -> HashMap<String, SecuritySchemeInfo> {
380    let mut security_schemes = HashMap::new();
381
382    for (name, scheme) in &spec.security_schemes {
383        let details = match scheme.scheme_type.as_str() {
384            "http" => {
385                scheme.scheme.as_ref().map_or(
386                    SecuritySchemeDetails::HttpBearer {
387                        bearer_format: None,
388                    },
389                    |http_scheme| match http_scheme.as_str() {
390                        "bearer" => SecuritySchemeDetails::HttpBearer {
391                            bearer_format: scheme.bearer_format.clone(),
392                        },
393                        "basic" => SecuritySchemeDetails::HttpBasic,
394                        _ => {
395                            // For other HTTP schemes, default to bearer
396                            SecuritySchemeDetails::HttpBearer {
397                                bearer_format: None,
398                            }
399                        }
400                    },
401                )
402            }
403            "apiKey" => SecuritySchemeDetails::ApiKey {
404                location: scheme
405                    .location
406                    .clone()
407                    .unwrap_or_else(|| "header".to_string()),
408                name: scheme
409                    .parameter_name
410                    .clone()
411                    .unwrap_or_else(|| "Authorization".to_string()),
412            },
413            _ => {
414                // Default to bearer for unknown types
415                SecuritySchemeDetails::HttpBearer {
416                    bearer_format: None,
417                }
418            }
419        };
420
421        let scheme_info = SecuritySchemeInfo {
422            scheme_type: scheme.scheme_type.clone(),
423            description: scheme.description.clone(),
424            details,
425            aperture_secret: scheme.aperture_secret.clone(),
426        };
427
428        security_schemes.insert(name.clone(), scheme_info);
429    }
430
431    security_schemes
432}
433
434/// Converts an `OpenAPI` operation to `CommandInfo` with full metadata
435fn convert_openapi_operation_to_info(
436    method: &str,
437    path: &str,
438    operation: &Operation,
439    global_security: Option<&Vec<openapiv3::SecurityRequirement>>,
440) -> CommandInfo {
441    let command_name = operation
442        .operation_id
443        .as_ref()
444        .map_or_else(|| method.to_lowercase(), |op_id| to_kebab_case(op_id));
445
446    // Extract parameters with full metadata
447    let parameters: Vec<ParameterInfo> = operation
448        .parameters
449        .iter()
450        .filter_map(|param_ref| {
451            if let ReferenceOr::Item(param) = param_ref {
452                Some(convert_openapi_parameter_to_info(param))
453            } else {
454                None
455            }
456        })
457        .collect();
458
459    // Extract request body info
460    let request_body = operation.request_body.as_ref().and_then(|rb_ref| {
461        if let ReferenceOr::Item(body) = rb_ref {
462            // Prefer JSON content if available
463            let content_type = if body.content.contains_key("application/json") {
464                "application/json"
465            } else {
466                body.content.keys().next().map(String::as_str)?
467            };
468
469            let media_type = body.content.get(content_type)?;
470            let example = media_type
471                .example
472                .as_ref()
473                .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()));
474
475            Some(RequestBodyInfo {
476                required: body.required,
477                content_type: content_type.to_string(),
478                description: body.description.clone(),
479                example,
480            })
481        } else {
482            None
483        }
484    });
485
486    // Extract security requirements
487    let security_requirements = operation.security.as_ref().map_or_else(
488        || {
489            global_security.map_or(vec![], |reqs| {
490                reqs.iter().flat_map(|req| req.keys().cloned()).collect()
491            })
492        },
493        |op_security| {
494            op_security
495                .iter()
496                .flat_map(|req| req.keys().cloned())
497                .collect()
498        },
499    );
500
501    CommandInfo {
502        name: command_name,
503        method: method.to_uppercase(),
504        path: path.to_string(),
505        description: operation.description.clone(),
506        summary: operation.summary.clone(),
507        operation_id: operation.operation_id.clone().unwrap_or_default(),
508        parameters,
509        request_body,
510        security_requirements,
511        tags: operation.tags.clone(),
512        deprecated: operation.deprecated,
513        external_docs_url: operation
514            .external_docs
515            .as_ref()
516            .map(|docs| docs.url.clone()),
517    }
518}
519
520/// Converts an `OpenAPI` parameter to `ParameterInfo` with full metadata
521fn convert_openapi_parameter_to_info(param: &OpenApiParameter) -> ParameterInfo {
522    let (param_data, location_str) = match param {
523        OpenApiParameter::Query { parameter_data, .. } => (parameter_data, "query"),
524        OpenApiParameter::Header { parameter_data, .. } => (parameter_data, "header"),
525        OpenApiParameter::Path { parameter_data, .. } => (parameter_data, "path"),
526        OpenApiParameter::Cookie { parameter_data, .. } => (parameter_data, "cookie"),
527    };
528
529    // Extract schema information
530    let (schema_type, format, default_value, enum_values, example) =
531        if let openapiv3::ParameterSchemaOrContent::Schema(schema_ref) = &param_data.format {
532            match schema_ref {
533                ReferenceOr::Item(schema) => {
534                    let (schema_type, format, enums) = match &schema.schema_kind {
535                        openapiv3::SchemaKind::Type(type_val) => match type_val {
536                            openapiv3::Type::String(string_type) => {
537                                let enum_values: Vec<String> = string_type
538                                    .enumeration
539                                    .iter()
540                                    .filter_map(|v| v.as_ref())
541                                    .map(|v| {
542                                        serde_json::to_string(v).unwrap_or_else(|_| v.to_string())
543                                    })
544                                    .collect();
545                                ("string".to_string(), None, enum_values)
546                            }
547                            openapiv3::Type::Number(_) => ("number".to_string(), None, vec![]),
548                            openapiv3::Type::Integer(_) => ("integer".to_string(), None, vec![]),
549                            openapiv3::Type::Boolean(_) => ("boolean".to_string(), None, vec![]),
550                            openapiv3::Type::Array(_) => ("array".to_string(), None, vec![]),
551                            openapiv3::Type::Object(_) => ("object".to_string(), None, vec![]),
552                        },
553                        _ => ("string".to_string(), None, vec![]),
554                    };
555
556                    let default_value = schema
557                        .schema_data
558                        .default
559                        .as_ref()
560                        .map(|v| serde_json::to_string(v).unwrap_or_else(|_| v.to_string()));
561
562                    (Some(schema_type), format, default_value, enums, None)
563                }
564                ReferenceOr::Reference { .. } => {
565                    (Some("string".to_string()), None, None, vec![], None)
566                }
567            }
568        } else {
569            (Some("string".to_string()), None, None, vec![], None)
570        };
571
572    // Extract example from parameter data
573    let example = param_data
574        .example
575        .as_ref()
576        .map(|ex| serde_json::to_string(ex).unwrap_or_else(|_| ex.to_string()))
577        .or(example);
578
579    ParameterInfo {
580        name: param_data.name.clone(),
581        location: location_str.to_string(),
582        required: param_data.required,
583        param_type: schema_type.unwrap_or_else(|| "string".to_string()),
584        description: param_data.description.clone(),
585        format,
586        default_value,
587        enum_values,
588        example,
589    }
590}
591
592/// Extracts security schemes directly from `OpenAPI` spec
593fn extract_security_schemes_from_openapi(spec: &OpenAPI) -> HashMap<String, SecuritySchemeInfo> {
594    let mut security_schemes = HashMap::new();
595
596    if let Some(components) = &spec.components {
597        for (name, scheme_ref) in &components.security_schemes {
598            if let ReferenceOr::Item(scheme) = scheme_ref {
599                if let Some(scheme_info) = convert_openapi_security_scheme(name, scheme) {
600                    security_schemes.insert(name.clone(), scheme_info);
601                }
602            }
603        }
604    }
605
606    security_schemes
607}
608
609/// Converts an `OpenAPI` security scheme to `SecuritySchemeInfo`
610fn convert_openapi_security_scheme(
611    _name: &str,
612    scheme: &SecurityScheme,
613) -> Option<SecuritySchemeInfo> {
614    match scheme {
615        SecurityScheme::APIKey {
616            location,
617            name: param_name,
618            description,
619            ..
620        } => {
621            let location_str = match location {
622                openapiv3::APIKeyLocation::Query => "query",
623                openapiv3::APIKeyLocation::Header => "header",
624                openapiv3::APIKeyLocation::Cookie => "cookie",
625            };
626
627            let aperture_secret = extract_aperture_secret_from_extensions(scheme);
628
629            Some(SecuritySchemeInfo {
630                scheme_type: "apiKey".to_string(),
631                description: description.clone(),
632                details: SecuritySchemeDetails::ApiKey {
633                    location: location_str.to_string(),
634                    name: param_name.clone(),
635                },
636                aperture_secret,
637            })
638        }
639        SecurityScheme::HTTP {
640            scheme: http_scheme,
641            bearer_format,
642            description,
643            ..
644        } => {
645            let details = match http_scheme.as_str() {
646                "bearer" => SecuritySchemeDetails::HttpBearer {
647                    bearer_format: bearer_format.clone(),
648                },
649                "basic" => SecuritySchemeDetails::HttpBasic,
650                _ => SecuritySchemeDetails::HttpBearer {
651                    bearer_format: None,
652                },
653            };
654
655            let aperture_secret = extract_aperture_secret_from_extensions(scheme);
656
657            Some(SecuritySchemeInfo {
658                scheme_type: "http".to_string(),
659                description: description.clone(),
660                details,
661                aperture_secret,
662            })
663        }
664        SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => None,
665    }
666}
667
668/// Extracts x-aperture-secret extension from a security scheme's extensions
669fn extract_aperture_secret_from_extensions(
670    scheme: &SecurityScheme,
671) -> Option<CachedApertureSecret> {
672    let extensions = match scheme {
673        SecurityScheme::APIKey { extensions, .. } | SecurityScheme::HTTP { extensions, .. } => {
674            extensions
675        }
676        SecurityScheme::OAuth2 { .. } | SecurityScheme::OpenIDConnect { .. } => return None,
677    };
678
679    extensions.get("x-aperture-secret").and_then(|value| {
680        if let Some(obj) = value.as_object() {
681            let source = obj.get("source")?.as_str()?;
682            let name = obj.get("name")?.as_str()?;
683
684            if source == "env" {
685                return Some(CachedApertureSecret {
686                    source: source.to_string(),
687                    name: name.to_string(),
688                });
689            }
690        }
691        None
692    })
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698    use crate::cache::models::{CachedCommand, CachedParameter, CachedSpec};
699
700    #[test]
701    fn test_to_kebab_case() {
702        assert_eq!(to_kebab_case("getUserById"), "get-user-by-id");
703        assert_eq!(to_kebab_case("createUser"), "create-user");
704        assert_eq!(to_kebab_case("list"), "list");
705        assert_eq!(to_kebab_case("GET"), "get");
706    }
707
708    #[test]
709    fn test_generate_capability_manifest() {
710        use crate::cache::models::{CachedApertureSecret, CachedSecurityScheme};
711
712        let mut security_schemes = HashMap::new();
713        security_schemes.insert(
714            "bearerAuth".to_string(),
715            CachedSecurityScheme {
716                name: "bearerAuth".to_string(),
717                scheme_type: "http".to_string(),
718                scheme: Some("bearer".to_string()),
719                location: Some("header".to_string()),
720                parameter_name: Some("Authorization".to_string()),
721                description: None,
722                bearer_format: None,
723                aperture_secret: Some(CachedApertureSecret {
724                    source: "env".to_string(),
725                    name: "API_TOKEN".to_string(),
726                }),
727            },
728        );
729
730        let spec = CachedSpec {
731            name: "Test API".to_string(),
732            version: "1.0.0".to_string(),
733            commands: vec![CachedCommand {
734                name: "users".to_string(),
735                description: Some("Get user by ID".to_string()),
736                summary: None,
737                operation_id: "getUserById".to_string(),
738                method: "GET".to_string(),
739                path: "/users/{id}".to_string(),
740                parameters: vec![CachedParameter {
741                    name: "id".to_string(),
742                    location: "path".to_string(),
743                    required: true,
744                    description: None,
745                    schema: Some("string".to_string()),
746                    schema_type: Some("string".to_string()),
747                    format: None,
748                    default_value: None,
749                    enum_values: vec![],
750                    example: None,
751                }],
752                request_body: None,
753                responses: vec![],
754                security_requirements: vec!["bearerAuth".to_string()],
755                tags: vec!["users".to_string()],
756                deprecated: false,
757                external_docs_url: None,
758            }],
759            base_url: Some("https://test-api.example.com".to_string()),
760            servers: vec!["https://test-api.example.com".to_string()],
761            security_schemes,
762        };
763
764        let manifest_json = generate_capability_manifest(&spec, None).unwrap();
765        let manifest: ApiCapabilityManifest = serde_json::from_str(&manifest_json).unwrap();
766
767        assert_eq!(manifest.api.name, "Test API");
768        assert_eq!(manifest.api.version, "1.0.0");
769        assert!(manifest.commands.contains_key("users"));
770
771        let users_commands = &manifest.commands["users"];
772        assert_eq!(users_commands.len(), 1);
773        assert_eq!(users_commands[0].name, "get-user-by-id");
774        assert_eq!(users_commands[0].method, "GET");
775        assert_eq!(users_commands[0].parameters.len(), 1);
776        assert_eq!(users_commands[0].parameters[0].name, "id");
777
778        // Test security information extraction
779        assert!(!manifest.security_schemes.is_empty());
780        assert!(manifest.security_schemes.contains_key("bearerAuth"));
781        let bearer_auth = &manifest.security_schemes["bearerAuth"];
782        assert_eq!(bearer_auth.scheme_type, "http");
783        assert!(matches!(
784            &bearer_auth.details,
785            SecuritySchemeDetails::HttpBearer { .. }
786        ));
787        assert!(bearer_auth.aperture_secret.is_some());
788        let aperture_secret = bearer_auth.aperture_secret.as_ref().unwrap();
789        assert_eq!(aperture_secret.name, "API_TOKEN");
790        assert_eq!(aperture_secret.source, "env");
791    }
792}