Skip to main content

context7_cli/
cli.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! CLI argument definitions and command dispatchers.
3//!
4//! Defines [`Cli`], [`Command`], and [`KeysOperation`] via `clap` derives,
5//! plus the async dispatcher functions that call into [`crate::api`],
6//! [`crate::storage`], and [`crate::output`].
7use anyhow::{Context, Result};
8use clap::{Parser, Subcommand};
9use tracing::info;
10
11use crate::api::{
12    search_library, fetch_documentation, fetch_documentation_text, create_http_client,
13    run_with_retry,
14};
15use crate::errors::Context7Error;
16use crate::i18n::{t, Message};
17use crate::output::{
18    print_libraries_formatted, print_library_not_found_hint,
19    print_documentation_formatted, print_json_results, print_plain_text,
20};
21use crate::storage::{
22    load_api_keys, cmd_keys_add, cmd_keys_clear, cmd_keys_export, cmd_keys_import,
23    cmd_keys_list, cmd_keys_path, cmd_keys_remove,
24};
25
26// ─── CLI STRUCTS ─────────────────────────────────────────────────────────────
27
28/// Top-level CLI entry point parsed by `clap`.
29#[derive(Debug, Parser)]
30#[command(
31    name = "context7",
32    version,
33    about = "CLI client for the Context7 API (bilingual EN/PT)",
34    long_about = None,
35)]
36pub struct Cli {
37    /// UI language: `en` or `pt`. Default: auto-detect from system locale.
38    #[arg(long, global = true, env = "CONTEXT7_LANG")]
39    pub lang: Option<String>,
40
41    /// Output raw JSON instead of formatted text.
42    #[arg(long, global = true)]
43    pub json: bool,
44
45    /// Disable colored output (also respected via NO_COLOR env var).
46    #[arg(long, global = true)]
47    pub no_color: bool,
48
49    /// Plain text output without ANSI formatting (incompatible with --json).
50    #[arg(long, global = true, conflicts_with = "json")]
51    pub plain: bool,
52
53    /// Increase verbosity (-v info, -vv debug, -vvv trace).
54    #[arg(short, long, global = true, action = clap::ArgAction::Count)]
55    pub verbose: u8,
56
57    /// Suppress all output except errors.
58    #[arg(long, global = true)]
59    pub quiet: bool,
60
61    /// Subcommand to execute.
62    #[command(subcommand)]
63    pub command: Command,
64}
65
66/// Top-level subcommands.
67#[derive(Debug, Subcommand)]
68pub enum Command {
69    /// Search libraries by name.
70    #[command(alias = "lib", alias = "search")]
71    Library {
72        /// Library name to search for.
73        name: String,
74        /// Optional context for relevance ranking (e.g. "effect hooks").
75        query: Option<String>,
76    },
77
78    /// Fetch documentation for a library.
79    #[command(alias = "doc", alias = "context")]
80    Docs {
81        /// Library identifier (e.g. `/rust-lang/rust`).
82        library_id: String,
83
84        /// Topic or search query.
85        #[arg(short = 'q', long)]
86        query: Option<String>,
87
88        /// Output plain text instead of formatted output (incompatible with `--json`).
89        #[arg(long, conflicts_with = "json")]
90        text: bool,
91    },
92
93    /// Manage locally stored API keys.
94    #[command(alias = "key")]
95    Keys {
96        /// Key management operation.
97        #[command(subcommand)]
98        operation: KeysOperation,
99    },
100
101    /// Generate shell completions for bash, zsh, fish, or PowerShell.
102    #[command(alias = "completion")]
103    Completions {
104        /// Shell to generate completions for.
105        shell: clap_complete::Shell,
106    },
107
108    /// Validate config, keys, and API reachability.
109    Health,
110}
111
112/// Operations available under the `keys` subcommand.
113#[derive(Debug, Subcommand)]
114pub enum KeysOperation {
115    /// Add an API key to XDG storage.
116    Add {
117        /// API key to add (e.g. `ctx7sk-abc123…`).
118        key: String,
119    },
120    /// List all stored keys (masked).
121    List,
122    /// Remove a key by 1-based index (use `keys list` to see indices).
123    Remove {
124        /// Index of the key to remove (starting at 1).
125        index: usize,
126    },
127    /// Remove all stored keys.
128    Clear {
129        /// Confirm removal without an interactive prompt.
130        #[arg(long)]
131        yes: bool,
132    },
133    /// Print the XDG config file path.
134    Path,
135    /// Import keys from a `.env` file (reads `CONTEXT7_API=` entries).
136    Import {
137        /// Path to the `.env` file to import.
138        file: std::path::PathBuf,
139    },
140    /// Export all keys to stdout (one per line, unmasked).
141    Export,
142}
143
144// ─── INTERNAL HELPERS ────────────────────────────────────────────────────────
145
146/// Shows a hint if the error is `LibraryNotFound`.
147fn check_and_show_not_found_hint<T>(result: &anyhow::Result<T>) {
148    if let Err(ref e) = result {
149        if let Some(Context7Error::LibraryNotFound { .. }) = e.downcast_ref::<Context7Error>()
150        {
151            print_library_not_found_hint();
152        }
153    }
154}
155
156// ─── DISPATCHERS ─────────────────────────────────────────────────────────────
157
158/// Dispatches `keys` subcommand operations — no HTTP client or API keys needed.
159#[must_use]
160pub fn run_keys(operation: KeysOperation, json: bool) -> Result<()> {
161    match operation {
162        KeysOperation::Add { key } => cmd_keys_add(&key),
163        KeysOperation::List => cmd_keys_list(json),
164        KeysOperation::Remove { index } => cmd_keys_remove(index),
165        KeysOperation::Clear { yes } => cmd_keys_clear(yes),
166        KeysOperation::Path => cmd_keys_path(),
167        KeysOperation::Import { file } => cmd_keys_import(&file),
168        KeysOperation::Export => cmd_keys_export(),
169    }
170}
171
172/// Dispatches the `library` subcommand — searches libraries via the API.
173#[must_use]
174pub async fn run_library(name: String, query: Option<String>, json: bool) -> Result<()> {
175    info!("Searching library: {}", name);
176
177    let keys = load_api_keys()?;
178    let client = create_http_client()?;
179
180    info!(
181        "Starting context7 with {} API keys available",
182        keys.len()
183    );
184
185    // API requires the query parameter; fall back to the library name itself
186    let context_query = query.as_deref().unwrap_or(&name).to_string();
187
188    let client_arc = std::sync::Arc::new(client);
189    // Double-clone needed: outer clone moves ownership to Fn closure,
190    // inner clone creates a copy for each retry iteration (closure called N times).
191    let name_clone = name.clone();
192    let query_clone = context_query.clone();
193    let result = run_with_retry(&keys, move |key| {
194        let c = std::sync::Arc::clone(&client_arc);
195        let n = name_clone.clone();
196        let q = query_clone.clone();
197        async move { search_library(&c, &key, &n, &q).await }
198    })
199    .await;
200
201    // Show hint before propagating LibraryNotFound
202    check_and_show_not_found_hint(&result);
203
204    let result =
205        result.with_context(|| format!("{} '{}'", t(Message::LibrarySearchFailure), name))?;
206
207    if json {
208        print_json_results(
209            &serde_json::to_string_pretty(&result.results)
210                .with_context(|| t(Message::JsonSerialiseFailure))?,
211        );
212    } else {
213        print_libraries_formatted(&result.results);
214    }
215    Ok(())
216}
217
218/// Dispatches the `docs` subcommand — fetches library documentation via the API.
219#[must_use]
220pub async fn run_docs(
221    library_id: String,
222    query: Option<String>,
223    text: bool,
224    json: bool,
225) -> Result<()> {
226    info!("Fetching documentation for: {}", library_id);
227
228    let keys = load_api_keys()?;
229    let client = create_http_client()?;
230
231    info!(
232        "Starting context7 with {} API keys available",
233        keys.len()
234    );
235
236    let client_arc = std::sync::Arc::new(client);
237    // Double-clone needed: outer clone moves ownership to Fn closure,
238    // inner clone creates a copy for each retry iteration (closure called N times).
239    let id_clone = library_id.clone();
240    let query_clone = query.clone();
241
242    if text {
243        // Plain-text mode: use txt endpoint, print raw markdown
244        let text_result = run_with_retry(&keys, move |key| {
245            let c = std::sync::Arc::clone(&client_arc);
246            let id = id_clone.clone();
247            let q = query_clone.clone();
248            async move { fetch_documentation_text(&c, &key, &id, q.as_deref()).await }
249        })
250        .await;
251
252        // Show hint before propagating LibraryNotFound
253        check_and_show_not_found_hint(&text_result);
254
255        let text_result = text_result.with_context(|| {
256            format!("{} '{}'", t(Message::DocsFetchFailure), library_id)
257        })?;
258
259        print_plain_text(&text_result);
260        return Ok(());
261    }
262
263    // JSON or formatted mode: use json endpoint
264    let result = run_with_retry(&keys, move |key| {
265        let c = std::sync::Arc::clone(&client_arc);
266        let id = id_clone.clone();
267        let q = query_clone.clone();
268        async move { fetch_documentation(&c, &key, &id, q.as_deref()).await }
269    })
270    .await;
271
272    // Show hint before propagating LibraryNotFound
273    check_and_show_not_found_hint(&result);
274
275    let result = result
276        .with_context(|| format!("{} '{}'", t(Message::DocsFetchFailure), library_id))?;
277
278    if json {
279        print_json_results(
280            &serde_json::to_string_pretty(&result)
281                .with_context(|| t(Message::DocsSerialiseFailure))?,
282        );
283    } else {
284        print_documentation_formatted(&result);
285    }
286    Ok(())
287}