Skip to main content

netget/client/npm/
actions.rs

1//! NPM Registry client protocol actions implementation
2
3use 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
14/// NPM client connected event
15pub 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
32/// NPM client package info received event
33pub 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
76/// NPM client search results received event
77pub 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
108/// NPM client protocol action handler
109pub struct NpmClientProtocol;
110
111impl NpmClientProtocol {
112    pub fn new() -> Self {
113        Self
114    }
115}
116
117// Implement Protocol trait (common functionality)
118impl 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    /// Clones of the `LazyLock` statics the client actually raises, so the catalog the
272    /// model is shown and the events it receives cannot diverge.
273    ///
274    /// This used to hand-build a second set of `EventType`s with the same ids, no
275    /// parameters, and `{"type": "placeholder", "event_id": ...}` as the response
276    /// example. `EventType::effective_response_example` cannot repair a placeholder
277    /// without an attached action, so it fell through to `show_message` — which this
278    /// protocol's own `execute_action` rejects as unknown. The model was being taught an
279    /// answer its own client refuses.
280    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            // LLM mode: LLM controls NPM queries
324            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            // Script mode: Code-based package handling
331            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            // Static mode: Fixed package query
345            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
375// Implement Client trait (client-specific functionality)
376impl 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            // The declared startup parameter, honoured. `registry_url` has been in
386            // `get_startup_parameters()` since this client was written and nothing
387            // read it: `connect()` forwarded `ctx.remote_addr` and dropped
388            // `ctx.startup_params` on the floor, so the advertised knob did nothing
389            // when turned. `?`, never `unwrap()` - an undeclared or wrong-typed key
390            // must produce a clean error naming it, not a panic in the connect task.
391            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}