Skip to main content

claude_code_cli_acp/compat/
docs_probe.rs

1use std::process::Command;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::compat::claude_probe::ClaudeCli;
7
8#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
9#[serde(tag = "status", rename_all = "snake_case")]
10pub enum LiveProbe<T> {
11    Available { value: T },
12    Unavailable { reason: String },
13}
14
15#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
16pub struct NpmPackageInfo {
17    pub package: String,
18    pub latest: Option<String>,
19    pub stable: Option<String>,
20    pub next: Option<String>,
21    pub modified: Option<String>,
22}
23
24#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
25pub struct LiveDocsReport {
26    pub npm: LiveProbe<NpmPackageInfo>,
27    pub docs: LiveProbe<DocsProbeInfo>,
28    pub docs_urls: Vec<String>,
29}
30
31#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
32pub struct DocsProbeInfo {
33    pub checked_urls: Vec<String>,
34    pub missing_required_flags: Vec<String>,
35    pub removed_flags_still_documented: Vec<String>,
36}
37
38impl LiveDocsReport {
39    pub fn summary(&self) -> String {
40        let npm = match &self.npm {
41            LiveProbe::Available { value } => format!(
42                "npm latest={}, stable={}",
43                value.latest.as_deref().unwrap_or("unknown"),
44                value.stable.as_deref().unwrap_or("unknown")
45            ),
46            LiveProbe::Unavailable { reason } => format!("npm unavailable: {reason}"),
47        };
48        let docs = match &self.docs {
49            LiveProbe::Available { value } => {
50                if value.missing_required_flags.is_empty() {
51                    "docs required flags ok".to_string()
52                } else {
53                    format!("docs drift: missing={:?}", value.missing_required_flags)
54                }
55            }
56            LiveProbe::Unavailable { reason } => format!("docs unavailable: {reason}"),
57        };
58        format!("{npm}; {docs}")
59    }
60}
61
62pub async fn probe_live() -> anyhow::Result<LiveDocsReport> {
63    let docs_urls = official_docs_urls()
64        .into_iter()
65        .map(str::to_string)
66        .collect::<Vec<_>>();
67    Ok(LiveDocsReport {
68        npm: npm_claude_code_metadata(),
69        docs: official_docs_probe(&docs_urls),
70        docs_urls,
71    })
72}
73
74pub fn npm_claude_code_metadata() -> LiveProbe<NpmPackageInfo> {
75    npm_package_metadata("@anthropic-ai/claude-code")
76}
77
78pub fn npm_package_metadata(package: &str) -> LiveProbe<NpmPackageInfo> {
79    let output = match Command::new("npm")
80        .args(["view", package, "--json"])
81        .output()
82    {
83        Ok(output) => output,
84        Err(error) => {
85            return LiveProbe::Unavailable {
86                reason: format!("npm unavailable: {error}"),
87            };
88        }
89    };
90
91    if !output.status.success() {
92        return LiveProbe::Unavailable {
93            reason: format!(
94                "npm view failed with status {}: {}",
95                output.status,
96                String::from_utf8_lossy(&output.stderr).trim()
97            ),
98        };
99    }
100
101    let value: Value = match serde_json::from_slice(&output.stdout) {
102        Ok(value) => value,
103        Err(error) => {
104            return LiveProbe::Unavailable {
105                reason: format!("npm returned invalid json: {error}"),
106            };
107        }
108    };
109
110    let tags = value.get("dist-tags").and_then(Value::as_object);
111    let time = value.get("time").and_then(Value::as_object);
112    LiveProbe::Available {
113        value: NpmPackageInfo {
114            package: package.to_string(),
115            latest: tags.and_then(|tags| string_field(tags.get("latest"))),
116            stable: tags.and_then(|tags| string_field(tags.get("stable"))),
117            next: tags.and_then(|tags| string_field(tags.get("next"))),
118            modified: time.and_then(|time| string_field(time.get("modified"))),
119        },
120    }
121}
122
123pub fn official_docs_urls() -> Vec<&'static str> {
124    vec![
125        "https://code.claude.com/docs/en/cli-reference",
126        "https://code.claude.com/docs/en/interactive-mode",
127        "https://code.claude.com/docs/en/claude-directory",
128    ]
129}
130
131fn official_docs_probe(urls: &[String]) -> LiveProbe<DocsProbeInfo> {
132    let mut combined = String::new();
133    for url in urls {
134        let output = match Command::new("curl").args(["-fsSL", url]).output() {
135            Ok(output) => output,
136            Err(error) => {
137                return LiveProbe::Unavailable {
138                    reason: format!("curl unavailable: {error}"),
139                };
140            }
141        };
142        if !output.status.success() {
143            return LiveProbe::Unavailable {
144                reason: format!(
145                    "curl failed for {url} with status {}: {}",
146                    output.status,
147                    String::from_utf8_lossy(&output.stderr).trim()
148                ),
149            };
150        }
151        combined.push_str(&String::from_utf8_lossy(&output.stdout));
152        combined.push('\n');
153    }
154
155    let missing_required_flags = ClaudeCli::required_flags()
156        .into_iter()
157        .filter_map(|flag| {
158            if combined.contains(&flag.name) {
159                None
160            } else {
161                Some(flag.name)
162            }
163        })
164        .collect();
165    let removed_flags_still_documented = ClaudeCli::removed_flags()
166        .into_iter()
167        .filter_map(|flag| {
168            if combined.contains(&flag.name) {
169                Some(flag.name)
170            } else {
171                None
172            }
173        })
174        .collect();
175
176    LiveProbe::Available {
177        value: DocsProbeInfo {
178            checked_urls: urls.to_vec(),
179            missing_required_flags,
180            removed_flags_still_documented,
181        },
182    }
183}
184
185fn string_field(value: Option<&Value>) -> Option<String> {
186    value.and_then(Value::as_str).map(str::to_string)
187}