1use crate::llm::actions::{
4 client_trait::{Client, ClientActionResult},
5 protocol_trait::Protocol,
6 ActionDefinition, Parameter, ParameterDefinition,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14pub static NPM_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16 EventType::new(
17 "npm_connected",
18 "NPM Registry client initialized and ready to query packages",
19 json!({
20 "type": "get_package_info",
21 "package_name": "express"
22 }),
23 )
24 .with_parameters(vec![Parameter {
25 name: "registry_url".to_string(),
26 type_hint: "string".to_string(),
27 description: "NPM registry URL".to_string(),
28 required: true,
29 }])
30});
31
32pub static NPM_CLIENT_PACKAGE_INFO_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
34 EventType::new(
35 "npm_package_info_received",
36 "Package information received from NPM registry",
37 json!({
38 "type": "search_packages",
39 "query": "express middleware"
40 }),
41 )
42 .with_parameters(vec![
43 Parameter {
44 name: "package_name".to_string(),
45 type_hint: "string".to_string(),
46 description: "Package name".to_string(),
47 required: true,
48 },
49 Parameter {
50 name: "version".to_string(),
51 type_hint: "string".to_string(),
52 description: "Package version (latest or specific)".to_string(),
53 required: false,
54 },
55 Parameter {
56 name: "description".to_string(),
57 type_hint: "string".to_string(),
58 description: "Package description".to_string(),
59 required: false,
60 },
61 Parameter {
62 name: "versions".to_string(),
63 type_hint: "array".to_string(),
64 description: "Available versions".to_string(),
65 required: false,
66 },
67 Parameter {
68 name: "dist".to_string(),
69 type_hint: "object".to_string(),
70 description: "Distribution metadata (tarball URL, shasum)".to_string(),
71 required: false,
72 },
73 ])
74});
75
76pub static NPM_CLIENT_SEARCH_RESULTS_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
78 EventType::new(
79 "npm_search_results_received",
80 "Search results received from NPM registry",
81 json!({
82 "type": "get_package_info",
83 "package_name": "express"
84 }),
85 )
86 .with_parameters(vec![
87 Parameter {
88 name: "query".to_string(),
89 type_hint: "string".to_string(),
90 description: "Search query".to_string(),
91 required: true,
92 },
93 Parameter {
94 name: "results".to_string(),
95 type_hint: "array".to_string(),
96 description: "Array of matching packages".to_string(),
97 required: true,
98 },
99 Parameter {
100 name: "total".to_string(),
101 type_hint: "number".to_string(),
102 description: "Total number of results".to_string(),
103 required: false,
104 },
105 ])
106});
107
108pub struct NpmClientProtocol;
110
111impl NpmClientProtocol {
112 pub fn new() -> Self {
113 Self
114 }
115}
116
117impl Protocol for NpmClientProtocol {
119 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
120 vec![ParameterDefinition {
121 name: "registry_url".to_string(),
122 description: "NPM registry URL, overriding remote_addr. There is no default: \
123 with neither, the client refuses to start rather than reaching the \
124 public npm registry by omission. Name it explicitly to use it."
125 .to_string(),
126 type_hint: "string".to_string(),
127 required: false,
128 example: json!("https://registry.npmjs.org"),
129 default: None,
130 }]
131 }
132 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
133 vec![
134 ActionDefinition {
135 name: "get_package_info".to_string(),
136 description: "Get information about a specific package".to_string(),
137 parameters: vec![
138 Parameter {
139 name: "package_name".to_string(),
140 type_hint: "string".to_string(),
141 description: "NPM package name (e.g., 'express', '@types/node')"
142 .to_string(),
143 required: true,
144 },
145 Parameter {
146 name: "version".to_string(),
147 type_hint: "string".to_string(),
148 description: "Specific version or 'latest' (default: latest)".to_string(),
149 required: false,
150 },
151 ],
152 example: json!({
153 "type": "get_package_info",
154 "package_name": "express",
155 "version": "latest"
156 }),
157 log_template: None,
158 },
159 ActionDefinition {
160 name: "search_packages".to_string(),
161 description: "Search for packages by keyword".to_string(),
162 parameters: vec![
163 Parameter {
164 name: "query".to_string(),
165 type_hint: "string".to_string(),
166 description: "Search query".to_string(),
167 required: true,
168 },
169 Parameter {
170 name: "limit".to_string(),
171 type_hint: "number".to_string(),
172 description: "Maximum number of results (default: 20)".to_string(),
173 required: false,
174 },
175 ],
176 example: json!({
177 "type": "search_packages",
178 "query": "http server",
179 "limit": 10
180 }),
181 log_template: None,
182 },
183 ActionDefinition {
184 name: "download_tarball".to_string(),
185 description: "Download package tarball to local file".to_string(),
186 parameters: vec![
187 Parameter {
188 name: "package_name".to_string(),
189 type_hint: "string".to_string(),
190 description: "NPM package name".to_string(),
191 required: true,
192 },
193 Parameter {
194 name: "version".to_string(),
195 type_hint: "string".to_string(),
196 description: "Package version (default: latest)".to_string(),
197 required: false,
198 },
199 Parameter {
200 name: "output_path".to_string(),
201 type_hint: "string".to_string(),
202 description: "Local file path to save tarball".to_string(),
203 required: true,
204 },
205 ],
206 example: json!({
207 "type": "download_tarball",
208 "package_name": "lodash",
209 "version": "4.17.21",
210 "output_path": "./lodash.tgz"
211 }),
212 log_template: None,
213 },
214 ActionDefinition {
215 name: "disconnect".to_string(),
216 description: "Disconnect from the NPM registry".to_string(),
217 parameters: vec![],
218 example: json!({
219 "type": "disconnect"
220 }),
221 log_template: None,
222 },
223 ]
224 }
225 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
226 vec![
227 ActionDefinition {
228 name: "get_package_info".to_string(),
229 description: "Get information about another package in response to received data"
230 .to_string(),
231 parameters: vec![
232 Parameter {
233 name: "package_name".to_string(),
234 type_hint: "string".to_string(),
235 description: "NPM package name".to_string(),
236 required: true,
237 },
238 Parameter {
239 name: "version".to_string(),
240 type_hint: "string".to_string(),
241 description: "Specific version or 'latest'".to_string(),
242 required: false,
243 },
244 ],
245 example: json!({
246 "type": "get_package_info",
247 "package_name": "express"
248 }),
249 log_template: None,
250 },
251 ActionDefinition {
252 name: "search_packages".to_string(),
253 description: "Search for related packages in response to received data".to_string(),
254 parameters: vec![Parameter {
255 name: "query".to_string(),
256 type_hint: "string".to_string(),
257 description: "Search query".to_string(),
258 required: true,
259 }],
260 example: json!({
261 "type": "search_packages",
262 "query": "express middleware"
263 }),
264 log_template: None,
265 },
266 ]
267 }
268 fn protocol_name(&self) -> &'static str {
269 "NPM"
270 }
271 fn get_event_types(&self) -> Vec<EventType> {
281 vec![
282 NPM_CLIENT_CONNECTED_EVENT.clone(),
283 NPM_CLIENT_PACKAGE_INFO_RECEIVED_EVENT.clone(),
284 NPM_CLIENT_SEARCH_RESULTS_RECEIVED_EVENT.clone(),
285 ]
286 }
287 fn stack_name(&self) -> &'static str {
288 "ETH>IP>TCP>TLS>HTTP>NPM"
289 }
290 fn keywords(&self) -> Vec<&'static str> {
291 vec![
292 "npm",
293 "npm client",
294 "npm registry",
295 "package manager",
296 "nodejs",
297 ]
298 }
299 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
300 use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
301
302 ProtocolMetadataV2::builder()
303 .state(DevelopmentState::Experimental)
304 .implementation("reqwest HTTP client for NPM Registry API")
305 .llm_control("Full control over package queries, searches, and downloads")
306 .e2e_testing("Public NPM registry (registry.npmjs.org)")
307 .build()
308 }
309 fn description(&self) -> &'static str {
310 "NPM Registry client for package information and downloads"
311 }
312 fn example_prompt(&self) -> &'static str {
313 "Connect to NPM registry and search for express packages"
314 }
315 fn group_name(&self) -> &'static str {
316 "Package Managers"
317 }
318 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
319 use crate::llm::actions::StartupExamples;
320 use serde_json::json;
321
322 StartupExamples::new(
323 json!({
325 "type": "open_client",
326 "remote_addr": "registry.npmjs.org",
327 "base_stack": "npm",
328 "instruction": "Search for express packages and get info on the most popular one"
329 }),
330 json!({
332 "type": "open_client",
333 "remote_addr": "registry.npmjs.org",
334 "base_stack": "npm",
335 "event_handlers": [{
336 "event_pattern": "npm_package_info_received",
337 "handler": {
338 "type": "script",
339 "language": "python",
340 "code": "<npm_client_handler>"
341 }
342 }]
343 }),
344 json!({
346 "type": "open_client",
347 "remote_addr": "registry.npmjs.org",
348 "base_stack": "npm",
349 "event_handlers": [
350 {
351 "event_pattern": "npm_connected",
352 "handler": {
353 "type": "static",
354 "actions": [{
355 "type": "get_package_info",
356 "package_name": "express"
357 }]
358 }
359 },
360 {
361 "event_pattern": "npm_package_info_received",
362 "handler": {
363 "type": "static",
364 "actions": [{
365 "type": "disconnect"
366 }]
367 }
368 }
369 ]
370 }),
371 )
372 }
373}
374
375impl Client for NpmClientProtocol {
377 fn connect(
378 &self,
379 ctx: crate::protocol::ConnectContext,
380 ) -> std::pin::Pin<
381 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
382 > {
383 Box::pin(async move {
384 use crate::client::npm::NpmClient;
385 let remote_addr = match ctx.startup_params.as_ref() {
392 Some(params) => params
393 .get_optional_string("registry_url")?
394 .unwrap_or(ctx.remote_addr),
395 None => ctx.remote_addr,
396 };
397 NpmClient::connect_with_llm_actions(
398 remote_addr,
399 ctx.llm_client,
400 ctx.state,
401 ctx.status_tx,
402 ctx.client_id,
403 )
404 .await
405 })
406 }
407 fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
408 let action_type = action
409 .get("type")
410 .and_then(|v| v.as_str())
411 .context("Missing 'type' field in action")?;
412
413 match action_type {
414 "get_package_info" => {
415 let package_name = action
416 .get("package_name")
417 .and_then(|v| v.as_str())
418 .context("Missing 'package_name' field")?
419 .to_string();
420
421 let version = action
422 .get("version")
423 .and_then(|v| v.as_str())
424 .map(|s| s.to_string())
425 .unwrap_or_else(|| "latest".to_string());
426
427 Ok(ClientActionResult::Custom {
428 name: "npm_get_package".to_string(),
429 data: json!({
430 "package_name": package_name,
431 "version": version,
432 }),
433 })
434 }
435 "search_packages" => {
436 let query = action
437 .get("query")
438 .and_then(|v| v.as_str())
439 .context("Missing 'query' field")?
440 .to_string();
441
442 let limit = action.get("limit").and_then(|v| v.as_u64()).unwrap_or(20);
443
444 Ok(ClientActionResult::Custom {
445 name: "npm_search".to_string(),
446 data: json!({
447 "query": query,
448 "limit": limit,
449 }),
450 })
451 }
452 "download_tarball" => {
453 let package_name = action
454 .get("package_name")
455 .and_then(|v| v.as_str())
456 .context("Missing 'package_name' field")?
457 .to_string();
458
459 let version = action
460 .get("version")
461 .and_then(|v| v.as_str())
462 .map(|s| s.to_string())
463 .unwrap_or_else(|| "latest".to_string());
464
465 let output_path = action
466 .get("output_path")
467 .and_then(|v| v.as_str())
468 .context("Missing 'output_path' field")?
469 .to_string();
470
471 Ok(ClientActionResult::Custom {
472 name: "npm_download_tarball".to_string(),
473 data: json!({
474 "package_name": package_name,
475 "version": version,
476 "output_path": output_path,
477 }),
478 })
479 }
480 "disconnect" => Ok(ClientActionResult::Disconnect),
481 _ => Err(anyhow::anyhow!(
482 "Unknown NPM client action: {}",
483 action_type
484 )),
485 }
486 }
487}