Skip to main content

chub_cli/commands/
get.rs

1use std::fs;
2use std::path::Path;
3
4use chub_core::annotations::read_annotation;
5use chub_core::error::Result;
6use chub_core::fetch::{fetch_doc, fetch_doc_full};
7use chub_core::registry::{
8    get_entry, resolve_doc_path, resolve_entry_file, MergedRegistry, ResolvedPath,
9};
10use chub_core::team::analytics;
11use clap::Args;
12
13use crate::output;
14
15#[derive(Args)]
16pub struct GetArgs {
17    /// Entry IDs (e.g. "openai/chat", "stripe/api")
18    ids: Vec<String>,
19
20    /// Language variant
21    #[arg(long)]
22    lang: Option<String>,
23
24    /// Specific version
25    #[arg(long)]
26    version: Option<String>,
27
28    /// Write to file or directory
29    #[arg(short, long)]
30    output: Option<String>,
31
32    /// Fetch all files (not just entry point)
33    #[arg(long)]
34    full: bool,
35
36    /// Fetch specific file(s) by path (comma-separated)
37    #[arg(long)]
38    file: Option<String>,
39
40    /// Fetch all pinned docs at once
41    #[arg(long)]
42    pinned: bool,
43
44    /// Auto-detect version from project dependencies (package.json, requirements.txt, etc.)
45    #[arg(long)]
46    match_env: bool,
47}
48
49struct FetchedEntry {
50    id: String,
51    entry_type: String,
52    content: Option<String>,
53    files: Option<Vec<(String, String)>>,
54    path: String,
55    additional_files: Vec<String>,
56}
57
58pub async fn run(args: GetArgs, json: bool, merged: &MergedRegistry) -> Result<()> {
59    // Handle --pinned: fetch all pinned docs
60    if args.pinned {
61        let pins = chub_core::team::pins::list_pins();
62        if pins.is_empty() {
63            output::error("No pinned docs. Use `chub pin add <id>` to pin docs.", json);
64            std::process::exit(1);
65        }
66        let pin_ids: Vec<String> = pins.iter().map(|p| p.id.clone()).collect();
67        let pinned_args = GetArgs {
68            ids: pin_ids,
69            lang: args.lang.clone(),
70            version: args.version.clone(),
71            output: args.output.clone(),
72            full: args.full,
73            file: args.file.clone(),
74            pinned: false,
75            match_env: args.match_env,
76        };
77        return Box::pin(run(pinned_args, json, merged)).await;
78    }
79
80    if args.ids.is_empty() {
81        output::error("Missing required argument: <ids>", json);
82        std::process::exit(1);
83    }
84
85    let mut results: Vec<FetchedEntry> = Vec::new();
86
87    for id in &args.ids {
88        // Handle project context docs (project/<name>)
89        if let Some(ctx_name) = id.strip_prefix("project/") {
90            if let Some((_doc, content)) = chub_core::team::context::get_context_doc(ctx_name) {
91                results.push(FetchedEntry {
92                    id: id.clone(),
93                    entry_type: "context".to_string(),
94                    content: Some(content),
95                    files: None,
96                    path: format!(".chub/context/{}.md", ctx_name),
97                    additional_files: vec![],
98                });
99                continue;
100            } else {
101                output::error(
102                    &format!(
103                        "Project context doc \"{}\" not found in .chub/context/",
104                        ctx_name
105                    ),
106                    json,
107                );
108                std::process::exit(1);
109            }
110        }
111        let result = get_entry(id, merged);
112
113        if result.ambiguous {
114            let alts = result.alternatives.join(", ");
115            output::error(
116                &format!(
117                    "Multiple entries match \"{}\". Use a source prefix: {}",
118                    id, alts
119                ),
120                json,
121            );
122            std::process::exit(1);
123        }
124
125        let entry = match result.entry {
126            Some(e) => e,
127            None => {
128                output::error(&format!("No doc or skill found with id \"{}\".", id), json);
129                std::process::exit(1);
130            }
131        };
132
133        let entry_type = entry.entry_type.to_string();
134
135        // Apply pin overrides: if the entry is pinned, use pinned lang/version as defaults
136        let mut effective_lang = args.lang.clone();
137        let mut effective_version = args.version.clone();
138        if let Some(pin) = chub_core::team::pins::get_pin(entry.id()) {
139            if effective_lang.is_none() {
140                effective_lang = pin.lang.clone();
141            }
142            if effective_version.is_none() {
143                effective_version = pin.version.clone();
144            }
145        }
146
147        // Auto-detect version from project dependencies if --match-env is set
148        if args.match_env && effective_version.is_none() {
149            let cwd = std::env::current_dir().unwrap_or_default();
150            let deps = chub_core::team::detect::detect_dependencies(&cwd);
151            if let Some(dep) = find_matching_dep_for_entry(entry.id(), &deps) {
152                if let Some(ref ver) = dep.version {
153                    let clean = ver.trim_start_matches(|c: char| {
154                        c == '^' || c == '~' || c == '=' || c == '>' || c == '<' || c == 'v'
155                    });
156                    if !clean.is_empty() {
157                        effective_version = Some(clean.to_string());
158                    }
159                }
160            }
161        }
162
163        let mut resolved = resolve_doc_path(
164            &entry,
165            effective_lang.as_deref(),
166            effective_version.as_deref(),
167        );
168
169        // If the requested language isn't available, fall back to no language preference
170        // (auto-select single language or prompt for multiple)
171        if resolved.is_none() && effective_lang.is_some() {
172            resolved = resolve_doc_path(&entry, None, effective_version.as_deref());
173        }
174
175        let resolved = match resolved {
176            Some(r) => r,
177            None => {
178                output::error(&format!("No content found for \"{}\".", id), json);
179                std::process::exit(1);
180            }
181        };
182
183        match &resolved {
184            ResolvedPath::VersionNotFound {
185                requested,
186                available,
187            } => {
188                output::error(
189                    &format!(
190                        "Version \"{}\" not found for \"{}\". Available: {}",
191                        requested,
192                        id,
193                        available.join(", ")
194                    ),
195                    json,
196                );
197                std::process::exit(1);
198            }
199            ResolvedPath::NeedsLanguage { available } => {
200                output::error(
201                    &format!(
202                        "Multiple languages available for \"{}\": {}. Specify --lang.",
203                        id,
204                        available.join(", ")
205                    ),
206                    json,
207                );
208                std::process::exit(1);
209            }
210            ResolvedPath::Ok { .. } => {}
211        }
212
213        let (file_path, base_path, files) = match resolve_entry_file(&resolved, &entry_type) {
214            Some(r) => r,
215            None => {
216                output::error(
217                    &format!("No content available for \"{}\". Run `chub update`.", id),
218                    json,
219                );
220                std::process::exit(1);
221            }
222        };
223
224        let entry_file_name = if entry_type == "skill" {
225            "SKILL.md"
226        } else {
227            "DOC.md"
228        };
229        let ref_files: Vec<String> = files
230            .iter()
231            .filter(|f| f.as_str() != entry_file_name)
232            .cloned()
233            .collect();
234
235        let source = match &resolved {
236            ResolvedPath::Ok { source, .. } => source.clone(),
237            _ => unreachable!(),
238        };
239
240        if let Some(ref file_arg) = args.file {
241            let requested: Vec<&str> = file_arg.split(',').map(|f| f.trim()).collect();
242            let invalid: Vec<&&str> = requested
243                .iter()
244                .filter(|f| !files.contains(&f.to_string()))
245                .collect();
246            if !invalid.is_empty() {
247                let available = if ref_files.is_empty() {
248                    "(none)".to_string()
249                } else {
250                    ref_files.join(", ")
251                };
252                output::error(
253                    &format!(
254                        "File \"{}\" not found in {}. Available: {}",
255                        invalid[0], id, available
256                    ),
257                    json,
258                );
259                std::process::exit(1);
260            }
261            if requested.len() == 1 {
262                let path = format!("{}/{}", base_path, requested[0]);
263                let content = fetch_doc(&source, &path).await?;
264                results.push(FetchedEntry {
265                    id: entry.id().to_string(),
266                    entry_type: entry_type.clone(),
267                    content: Some(content),
268                    files: None,
269                    path,
270                    additional_files: vec![],
271                });
272            } else {
273                let req_strings: Vec<String> = requested.iter().map(|s| s.to_string()).collect();
274                let all_files = fetch_doc_full(&source, &base_path, &req_strings).await?;
275                results.push(FetchedEntry {
276                    id: entry.id().to_string(),
277                    entry_type: entry_type.clone(),
278                    content: None,
279                    files: Some(all_files),
280                    path: base_path,
281                    additional_files: vec![],
282                });
283            }
284        } else if args.full && !files.is_empty() {
285            let all_files = fetch_doc_full(&source, &base_path, &files).await?;
286            results.push(FetchedEntry {
287                id: entry.id().to_string(),
288                entry_type: entry_type.clone(),
289                content: None,
290                files: Some(all_files),
291                path: base_path,
292                additional_files: vec![],
293            });
294        } else {
295            let content = fetch_doc(&source, &file_path).await?;
296            results.push(FetchedEntry {
297                id: entry.id().to_string(),
298                entry_type: entry_type.clone(),
299                content: Some(content),
300                files: None,
301                path: file_path,
302                additional_files: ref_files,
303            });
304        }
305    }
306
307    // Record fetch events
308    for r in &results {
309        analytics::record_fetch(&r.id, None);
310    }
311
312    // Output
313    if let Some(ref output_path) = args.output {
314        write_output(&results, output_path, &args, json)?;
315    } else {
316        print_output(&results, &args, json);
317    }
318
319    Ok(())
320}
321
322fn write_output(
323    results: &[FetchedEntry],
324    output_path: &str,
325    args: &GetArgs,
326    json: bool,
327) -> Result<()> {
328    if args.full {
329        for r in results {
330            if let Some(ref files) = r.files {
331                let base_dir = if results.len() > 1 {
332                    Path::new(output_path).join(&r.id)
333                } else {
334                    Path::new(output_path).to_path_buf()
335                };
336                fs::create_dir_all(&base_dir)?;
337                for (name, content) in files {
338                    let out_path = base_dir.join(name);
339                    if let Some(parent) = out_path.parent() {
340                        fs::create_dir_all(parent)?;
341                    }
342                    fs::write(&out_path, content)?;
343                }
344                output::info(&format!(
345                    "Written {} files to {}",
346                    files.len(),
347                    base_dir.display()
348                ));
349            } else if let Some(ref content) = r.content {
350                let out_path = Path::new(output_path).join(format!("{}.md", r.id));
351                if let Some(parent) = out_path.parent() {
352                    fs::create_dir_all(parent)?;
353                }
354                fs::write(&out_path, content)?;
355                output::info(&format!("Written to {}", out_path.display()));
356            }
357        }
358    } else {
359        let is_dir = output_path.ends_with('/') || output_path.ends_with('\\');
360        if is_dir && results.len() > 1 {
361            fs::create_dir_all(output_path)?;
362            for r in results {
363                if let Some(ref content) = r.content {
364                    let out_path = Path::new(output_path).join(format!("{}.md", r.id));
365                    if let Some(parent) = out_path.parent() {
366                        fs::create_dir_all(parent)?;
367                    }
368                    fs::write(&out_path, content)?;
369                    output::info(&format!("Written to {}", out_path.display()));
370                }
371            }
372        } else {
373            let out_path = if is_dir {
374                Path::new(output_path).join(format!("{}.md", results[0].id))
375            } else {
376                Path::new(output_path).to_path_buf()
377            };
378            if let Some(parent) = out_path.parent() {
379                fs::create_dir_all(parent)?;
380            }
381            let combined: String = results
382                .iter()
383                .filter_map(|r| r.content.as_deref())
384                .collect::<Vec<_>>()
385                .join("\n\n---\n\n");
386            fs::write(&out_path, &combined)?;
387            output::info(&format!("Written to {}", out_path.display()));
388        }
389    }
390
391    if json {
392        let json_out: Vec<serde_json::Value> = results
393            .iter()
394            .map(|r| {
395                serde_json::json!({
396                    "id": r.id,
397                    "type": r.entry_type,
398                    "path": output_path,
399                })
400            })
401            .collect();
402        println!("{}", serde_json::to_string(&json_out).unwrap_or_default());
403    }
404
405    Ok(())
406}
407
408fn print_output(results: &[FetchedEntry], args: &GetArgs, json: bool) {
409    if results.len() == 1 && results[0].files.is_none() {
410        let r = &results[0];
411        let content = r.content.as_deref().unwrap_or("");
412        let annotation = read_annotation(&r.id);
413
414        if json {
415            let mut data = serde_json::json!({
416                "id": r.id,
417                "type": r.entry_type,
418                "content": content,
419                "path": r.path,
420            });
421            if !r.additional_files.is_empty() {
422                data["additionalFiles"] = serde_json::json!(r.additional_files);
423            }
424            if let Some(ref ann) = annotation {
425                data["annotation"] = serde_json::json!(ann);
426            }
427            println!(
428                "{}",
429                serde_json::to_string_pretty(&data).unwrap_or_default()
430            );
431        } else {
432            print!("{}", content);
433            if let Some(ref ann) = annotation {
434                print!(
435                    "\n\n---\n⚠ USER-CONTRIBUTED ANNOTATION (not part of official documentation):\n[Agent note — {}]\n{}\n",
436                    ann.updated_at, ann.note
437                );
438            }
439            let lang_flag = args
440                .lang
441                .as_ref()
442                .map(|l| format!(" --lang {}", l))
443                .unwrap_or_default();
444            println!("\n\n---\nAfter using this doc, share your experience:");
445            println!("  chub feedback {} up{}", r.id, lang_flag);
446            println!(
447                "  chub feedback {} down{} --label outdated",
448                r.id, lang_flag
449            );
450            println!("Available labels: accurate, well-structured, helpful, good-examples, outdated, inaccurate, incomplete, wrong-examples, wrong-version, poorly-structured");
451            println!("Do NOT include any code, architecture details, or project-specific information in your feedback.");
452            if !r.additional_files.is_empty() {
453                let file_list = r
454                    .additional_files
455                    .iter()
456                    .map(|f| format!("  {}", f))
457                    .collect::<Vec<_>>()
458                    .join("\n");
459                let example = format!("chub get {} --file {}", r.id, r.additional_files[0]);
460                print!(
461                    "\n\n---\nAdditional files available (use --file to fetch):\n{}\nExample: {}\n",
462                    file_list, example
463                );
464            }
465        }
466    } else {
467        let parts: Vec<String> = results
468            .iter()
469            .flat_map(|r| {
470                if let Some(ref files) = r.files {
471                    files
472                        .iter()
473                        .map(|(name, content)| format!("# FILE: {}\n\n{}", name, content))
474                        .collect()
475                } else {
476                    vec![r.content.clone().unwrap_or_default()]
477                }
478            })
479            .collect();
480        let combined = parts.join("\n\n---\n\n");
481
482        if json {
483            let json_out: Vec<serde_json::Value> = results
484                .iter()
485                .map(|r| {
486                    let mut obj = serde_json::json!({
487                        "id": r.id,
488                        "type": r.entry_type,
489                        "path": r.path,
490                    });
491                    if let Some(ref content) = r.content {
492                        obj["content"] = serde_json::json!(content);
493                    }
494                    if let Some(ref files) = r.files {
495                        obj["files"] = serde_json::json!(files);
496                    }
497                    obj
498                })
499                .collect();
500            println!(
501                "{}",
502                serde_json::to_string_pretty(&json_out).unwrap_or_default()
503            );
504        } else {
505            print!("{}", combined);
506        }
507    }
508}
509
510/// Find a matching dependency for a registry entry ID.
511/// Matches the first segment of the entry ID (e.g., "openai" from "openai/chat")
512/// against detected dependency names.
513fn find_matching_dep_for_entry<'a>(
514    entry_id: &str,
515    deps: &'a [chub_core::team::detect::DetectedDep],
516) -> Option<&'a chub_core::team::detect::DetectedDep> {
517    let id_parts: Vec<&str> = entry_id.split('/').collect();
518    let search_name = if !id_parts.is_empty() {
519        id_parts[0].to_lowercase()
520    } else {
521        entry_id.to_lowercase()
522    };
523    deps.iter().find(|d| d.name.to_lowercase() == search_name)
524}