claude_code_cli_acp/compat/
docs_probe.rs1use 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}