返回 CodeWhale
hf.rs
根目录 / crates / tui / src / commands / groups / core / hf.rs
1 //! `/hf` - Hugging Face MCP and provider concept helpers.
2
3 use crate::commands::traits::{CommandInfo, RegisterCommand};
4 use crate::localization::MessageId;
5 use crate::mcp::{McpConfig, McpServerConfig};
6 use crate::tui::app::App;
7
8 use super::CommandResult;
9
10 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
11 name: "hf",
12 aliases: &["huggingface"],
13 usage: "/hf [mcp <status|setup>|concepts]",
14 description_id: MessageId::CmdHfDescription,
15 };
16
17 pub(in crate::commands) struct HfCmd;
18
19 impl RegisterCommand for HfCmd {
20 fn info() -> &'static CommandInfo {
21 &COMMAND_INFO
22 }
23
24 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
25 hf(app, arg)
26 }
27 }
28
29 const HF_MCP_SETTINGS_URL: &str = "https://huggingface.co/settings/mcp";
30 const HF_MCP_DOCS_URL: &str = "https://huggingface.co/docs/hub/hf-mcp-server";
31 const HF_MCP_SERVER_URL: &str = "https://huggingface.co/mcp";
32
33 const HF_MCP_CONFIG_SKELETON: &str = r#"{
34 "servers": {
35 "huggingface": {
36 "url": "https://huggingface.co/mcp",
37 "headers": {
38 "Authorization": "Bearer ${HF_TOKEN}"
39 }
40 }
41 }
42 }"#;
43
44 /// Explainer shown by `/hf concepts`.
45 const HF_CONCEPTS: &str = "\
46 Codewhale has three distinct Hugging Face surfaces:
47
48 1. Hugging Face provider route - chat inference
49 Switch the active LLM backend to Hugging Face Inference Providers.
50 Use: /provider huggingface
51 Config: provider = \"huggingface\" or [providers.huggingface]
52 Auth: HF_TOKEN or HUGGINGFACE_API_KEY
53
54 2. Hugging Face MCP - Hub, docs, datasets, Spaces, and community tools
55 Connect Codewhale to Hugging Face's MCP server through mcp.json.
56 Use: /hf mcp status or /hf mcp setup
57 Then: /mcp validate or restart Codewhale so model-visible tools reload.
58
59 3. Hugging Face Hub workflows - publish, upload, or manage repositories
60 Use explicit Hub tooling such as huggingface_hub or git-based flows.
61 Codewhale does not upload to the Hub through /hf.";
62
63 pub fn hf(app: &mut App, args: Option<&str>) -> CommandResult {
64 let raw = args.unwrap_or("").trim();
65 if raw.is_empty() {
66 return usage();
67 }
68
69 let mut parts = raw.split_whitespace();
70 let subcommand = parts.next().unwrap_or_default().to_ascii_lowercase();
71 match subcommand.as_str() {
72 "mcp" => hf_mcp(app, parts.next()),
73 "concepts" | "explain" => CommandResult::message(HF_CONCEPTS),
74 _ => CommandResult::error(format!(
75 "Unknown /hf subcommand: {subcommand}. Use /hf mcp <status|setup> or /hf concepts."
76 )),
77 }
78 }
79
80 fn usage() -> CommandResult {
81 CommandResult::message(
82 "Usage: /hf mcp <status|setup>\n\
83 /hf concepts\n\n\
84 Hugging Face MCP settings: https://huggingface.co/settings/mcp",
85 )
86 }
87
88 fn hf_mcp(app: &mut App, action: Option<&str>) -> CommandResult {
89 match action.unwrap_or("status").to_ascii_lowercase().as_str() {
90 "status" => hf_mcp_status(app),
91 "setup" => CommandResult::message(hf_mcp_setup_message(app)),
92 other => CommandResult::error(format!(
93 "Unknown /hf mcp subcommand: {other}. Use status or setup."
94 )),
95 }
96 }
97
98 fn hf_mcp_status(app: &App) -> CommandResult {
99 match crate::mcp::load_config(&app.mcp_config_path) {
100 Ok(config) => {
101 if let Some(server_name) = configured_hf_mcp_server(&config) {
102 CommandResult::message(format!(
103 "Hugging Face MCP appears configured as `{server_name}` in {}.\n\
104 Run /mcp reload to rebuild the live model-visible tool pool if tools are not visible yet.",
105 app.mcp_config_path.display()
106 ))
107 } else {
108 CommandResult::message(format!(
109 "Hugging Face MCP is not configured in {}.\n\
110 Run /hf mcp setup for the settings-generated config workflow.",
111 app.mcp_config_path.display()
112 ))
113 }
114 }
115 Err(err) => CommandResult::error(format!(
116 "Could not read MCP config {}: {err}",
117 app.mcp_config_path.display()
118 )),
119 }
120 }
121
122 fn hf_mcp_setup_message(app: &App) -> String {
123 format!(
124 "Use Hugging Face's settings-generated MCP configuration when available:\n\
125 1. Open {HF_MCP_SETTINGS_URL} while signed in.\n\
126 2. Choose your MCP client and copy the generated configuration snippet.\n\
127 3. Paste the Hugging Face server entry into {}.\n\
128 4. Run /mcp reload to rebuild the live model-visible tool pool.\n\n\
129 Codewhale-compatible placeholder shape:\n\n\
130 ```json\n{HF_MCP_CONFIG_SKELETON}\n```\n\n\
131 The placeholder is intentionally not runnable until your private MCP config has a real token value. \
132 Do not commit real Hugging Face tokens.\n\n\
133 Docs: {HF_MCP_DOCS_URL}\n\
134 Server: {HF_MCP_SERVER_URL}",
135 app.mcp_config_path.display()
136 )
137 }
138
139 fn configured_hf_mcp_server(config: &McpConfig) -> Option<&str> {
140 config
141 .servers
142 .iter()
143 .find(|(name, server)| looks_like_hf_mcp_server(name, server))
144 .map(|(name, _)| name.as_str())
145 }
146
147 fn looks_like_hf_mcp_server(name: &str, server: &McpServerConfig) -> bool {
148 let compact_name: String = name
149 .chars()
150 .filter(|ch| ch.is_ascii_alphanumeric())
151 .flat_map(|ch| ch.to_lowercase())
152 .collect();
153 if matches!(
154 compact_name.as_str(),
155 "huggingface" | "huggingfacemcp" | "hfmcp" | "hfmcpserver"
156 ) {
157 return true;
158 }
159
160 server.url.as_deref().is_some_and(|url| {
161 let url = url.to_ascii_lowercase();
162 url.contains("huggingface.co/mcp") || url.contains("huggingface.co/api/mcp")
163 })
164 }
165
166 #[cfg(test)]
167 mod tests {
168 use std::fs;
169 use std::path::PathBuf;
170
171 use crate::config::Config;
172 use crate::tui::app::TuiOptions;
173 use tempfile::tempdir;
174
175 use super::*;
176
177 fn app_with_mcp_path(mcp_config_path: PathBuf) -> App {
178 App::new(
179 TuiOptions {
180 use_alt_screen: false,
181 max_subagents: 2,
182 mcp_config_path,
183 ..crate::test_support::test_tui_options(PathBuf::from("."))
184 },
185 &Config::default(),
186 )
187 }
188
189 #[test]
190 fn hf_mcp_config_skeleton_keeps_token_placeholder_only() {
191 assert!(HF_MCP_CONFIG_SKELETON.contains("${HF_TOKEN}"));
192 assert!(!HF_MCP_CONFIG_SKELETON.contains("hf_"));
193 assert!(!HF_MCP_CONFIG_SKELETON.contains("Bearer hf_"));
194 serde_json::from_str::<serde_json::Value>(HF_MCP_CONFIG_SKELETON)
195 .expect("skeleton should be valid JSON");
196 }
197
198 #[test]
199 fn hf_concepts_explains_provider_mcp_and_hub_surfaces() {
200 assert!(HF_CONCEPTS.contains("provider route"));
201 assert!(HF_CONCEPTS.contains("Hugging Face MCP"));
202 assert!(HF_CONCEPTS.contains("Hub workflows"));
203 assert!(HF_CONCEPTS.contains("/provider huggingface"));
204 assert!(HF_CONCEPTS.contains("/hf mcp"));
205 }
206
207 #[test]
208 fn hf_mcp_status_detects_settings_named_server() {
209 let dir = tempdir().expect("tempdir");
210 let path = dir.path().join("mcp.json");
211 fs::write(
212 &path,
213 r#"{"mcpServers":{"hf-mcp-server":{"url":"https://huggingface.co/mcp"}}}"#,
214 )
215 .expect("write mcp config");
216 let app = app_with_mcp_path(path);
217
218 let result = hf_mcp_status(&app);
219
220 assert!(!result.is_error);
221 let message = result.message.expect("status message");
222 assert!(message.contains("appears configured"));
223 assert!(message.contains("hf-mcp-server"));
224 }
225
226 #[test]
227 fn hf_mcp_status_reports_missing_server_without_network() {
228 let dir = tempdir().expect("tempdir");
229 let path = dir.path().join("mcp.json");
230 fs::write(&path, r#"{"servers":{"local":{"command":"node"}}}"#).expect("write mcp config");
231 let app = app_with_mcp_path(path);
232
233 let result = hf_mcp_status(&app);
234
235 assert!(!result.is_error);
236 assert!(
237 result
238 .message
239 .as_deref()
240 .unwrap_or_default()
241 .contains("not configured")
242 );
243 }
244
245 #[test]
246 fn hf_usage_and_setup_do_not_advertise_hub_search() {
247 let app = app_with_mcp_path(PathBuf::from("mcp.json"));
248 let usage = usage().message.expect("usage");
249 let setup = hf_mcp_setup_message(&app);
250
251 assert!(!usage.contains("/hf search"));
252 assert!(!setup.contains("/hf search"));
253 assert!(setup.contains(HF_MCP_SETTINGS_URL));
254 }
255 }
256
256 lines RUST