Skip to main content

clawdentity_core/providers/
nanoclaw.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::process::Command;
4
5use serde_json::json;
6
7use crate::error::{CoreError, Result};
8use crate::http::blocking_client;
9use crate::provider::{
10    DetectionResult, InboundMessage, InboundRequest, InstallOptions, InstallResult,
11    PlatformProvider, ProviderDoctorCheckStatus, ProviderDoctorOptions, ProviderDoctorResult,
12    ProviderRelayRuntimeConfig, ProviderRelayTestOptions, ProviderRelayTestResult,
13    ProviderRelayTestStatus, ProviderSetupOptions, ProviderSetupResult, VerifyResult,
14    check_connector_runtime, command_exists, default_webhook_url, doctor_status_from_checks,
15    health_check, join_url_path, load_provider_runtime_config, now_iso, push_doctor_check,
16    read_provider_agent_marker, read_text, resolve_state_dir, save_provider_runtime_config,
17    upsert_env_var, write_provider_agent_marker, write_text,
18};
19
20const PROVIDER_NAME: &str = "nanoclaw";
21const PROVIDER_DISPLAY_NAME: &str = "NanoClaw";
22const NANOCLAW_BINARY: &str = "nanoclaw";
23const NANOCLAW_WEBHOOK_PATH: &str = "/v1/inbound";
24const NANOCLAW_SKILL_COMMAND: [&str; 3] = [
25    "tsx",
26    "scripts/apply-skill.ts",
27    ".claude/skills/add-webhook",
28];
29
30#[derive(Debug, Clone, Default)]
31pub struct NanoclawProvider {
32    project_root_override: Option<PathBuf>,
33    path_override: Option<Vec<PathBuf>>,
34}
35
36impl NanoclawProvider {
37    fn project_root(&self) -> Option<PathBuf> {
38        self.project_root_override
39            .clone()
40            .or_else(|| std::env::current_dir().ok())
41    }
42
43    fn install_project_root(&self, opts: &InstallOptions) -> Result<PathBuf> {
44        if let Some(project_root) = opts.home_dir.as_ref() {
45            return Ok(project_root.clone());
46        }
47
48        self.project_root().ok_or(CoreError::InvalidInput(
49            "unable to resolve project root".to_string(),
50        ))
51    }
52
53    fn resolve_webhook_url(&self, opts: &InstallOptions) -> Result<String> {
54        if let Some(connector_url) = opts
55            .connector_url
56            .as_deref()
57            .map(str::trim)
58            .filter(|value| !value.is_empty())
59        {
60            return join_url_path(connector_url, NANOCLAW_WEBHOOK_PATH, "connectorUrl");
61        }
62
63        let host = opts
64            .webhook_host
65            .as_deref()
66            .unwrap_or(self.default_webhook_host());
67        let port = opts.webhook_port.unwrap_or(self.default_webhook_port());
68        default_webhook_url(host, port, NANOCLAW_WEBHOOK_PATH)
69    }
70
71    #[cfg(test)]
72    fn with_test_context(project_root: PathBuf, path_override: Vec<PathBuf>) -> Self {
73        Self {
74            project_root_override: Some(project_root),
75            path_override: Some(path_override),
76        }
77    }
78}
79
80impl PlatformProvider for NanoclawProvider {
81    fn name(&self) -> &str {
82        PROVIDER_NAME
83    }
84
85    fn display_name(&self) -> &str {
86        PROVIDER_DISPLAY_NAME
87    }
88
89    fn detect(&self) -> DetectionResult {
90        let mut evidence = Vec::new();
91        let mut confidence: f32 = 0.0;
92
93        if let Some(project_root) = self.project_root() {
94            let claude_dir = project_root.join(".claude");
95            if claude_dir.is_dir() {
96                evidence.push(format!("found {}/", claude_dir.display()));
97                confidence += 0.45;
98            }
99
100            let skills_dir = claude_dir.join("skills");
101            if skills_dir.is_dir() {
102                evidence.push(format!("found {}/", skills_dir.display()));
103                confidence += 0.3;
104            }
105
106            if project_root.join("scripts/apply-skill.ts").is_file() {
107                evidence.push("found scripts/apply-skill.ts".to_string());
108                confidence += 0.1;
109            }
110        }
111
112        if command_exists(NANOCLAW_BINARY, self.path_override.as_deref()) {
113            evidence.push("nanoclaw binary in PATH".to_string());
114            confidence += 0.15;
115        }
116
117        DetectionResult {
118            detected: confidence > 0.0,
119            confidence: confidence.min(1.0),
120            evidence,
121        }
122    }
123
124    fn format_inbound(&self, message: &InboundMessage) -> InboundRequest {
125        InboundRequest {
126            headers: HashMap::new(),
127            body: json!({
128                "userId": message.sender_did,
129                "content": message.content,
130            }),
131        }
132    }
133
134    fn default_webhook_port(&self) -> u16 {
135        18794
136    }
137
138    fn config_path(&self) -> Option<PathBuf> {
139        self.project_root()
140            .map(|project_root| project_root.join(".env"))
141    }
142
143    #[allow(clippy::too_many_lines)]
144    fn install(&self, opts: &InstallOptions) -> Result<InstallResult> {
145        let project_root = self.install_project_root(opts)?;
146        let env_path = project_root.join(".env");
147
148        let command_output = Command::new("npx")
149            .args(NANOCLAW_SKILL_COMMAND)
150            .current_dir(&project_root)
151            .output()
152            .map_err(|error| {
153                CoreError::InvalidInput(format!("failed to run nanoclaw skill installer: {error}"))
154            })?;
155
156        if !command_output.status.success() {
157            let stderr = String::from_utf8_lossy(&command_output.stderr)
158                .trim()
159                .to_string();
160            return Err(CoreError::InvalidInput(format!(
161                "nanoclaw skill installer failed: {}",
162                if stderr.is_empty() {
163                    "unknown error".to_string()
164                } else {
165                    stderr
166                }
167            )));
168        }
169
170        let webhook_url = self.resolve_webhook_url(opts)?;
171
172        let mut env_contents = read_text(&env_path)?.unwrap_or_default();
173        env_contents = upsert_env_var(&env_contents, "CLAWDENTITY_WEBHOOK_URL", &webhook_url);
174
175        if let Some(token) = opts
176            .webhook_token
177            .as_deref()
178            .map(str::trim)
179            .filter(|value| !value.is_empty())
180        {
181            env_contents = upsert_env_var(&env_contents, "CLAWDENTITY_WEBHOOK_TOKEN", token);
182        }
183
184        if let Some(connector_url) = opts
185            .connector_url
186            .as_deref()
187            .map(str::trim)
188            .filter(|value| !value.is_empty())
189        {
190            env_contents =
191                upsert_env_var(&env_contents, "CLAWDENTITY_CONNECTOR_URL", connector_url);
192        }
193
194        write_text(&env_path, &env_contents)?;
195
196        Ok(InstallResult {
197            platform: self.name().to_string(),
198            config_updated: true,
199            service_installed: true,
200            notes: vec![
201                "applied .claude skill add-webhook".to_string(),
202                format!("updated {}", env_path.display()),
203            ],
204        })
205    }
206
207    fn verify(&self) -> Result<VerifyResult> {
208        let (healthy, detail) =
209            health_check(self.default_webhook_host(), self.default_webhook_port())?;
210        Ok(VerifyResult {
211            healthy,
212            checks: vec![("health".to_string(), healthy, detail)],
213        })
214    }
215
216    #[allow(clippy::too_many_lines)]
217    fn doctor(&self, opts: &ProviderDoctorOptions) -> Result<ProviderDoctorResult> {
218        let mut checks = Vec::new();
219        let state_dir = resolve_state_dir(opts.home_dir.clone())?;
220
221        let config_path = self.config_path();
222        if let Some(config_path) = config_path {
223            if config_path.exists() {
224                push_doctor_check(
225                    &mut checks,
226                    "config.exists",
227                    "Config file",
228                    ProviderDoctorCheckStatus::Pass,
229                    format!("found {}", config_path.display()),
230                    None,
231                    None,
232                );
233            } else {
234                push_doctor_check(
235                    &mut checks,
236                    "config.exists",
237                    "Config file",
238                    ProviderDoctorCheckStatus::Fail,
239                    format!("missing {}", config_path.display()),
240                    Some("Run `clawdentity provider setup --for nanoclaw`.".to_string()),
241                    None,
242                );
243            }
244        }
245
246        let binary_found = command_exists(NANOCLAW_BINARY, self.path_override.as_deref());
247        push_doctor_check(
248            &mut checks,
249            "binary.path",
250            "Provider binary",
251            if binary_found {
252                ProviderDoctorCheckStatus::Pass
253            } else {
254                ProviderDoctorCheckStatus::Fail
255            },
256            if binary_found {
257                "nanoclaw binary found in PATH".to_string()
258            } else {
259                "nanoclaw binary not found in PATH".to_string()
260            },
261            if binary_found {
262                None
263            } else {
264                Some("Install NanoClaw and ensure `nanoclaw` is in PATH.".to_string())
265            },
266            None,
267        );
268
269        let (webhook_ok, webhook_detail) =
270            health_check(self.default_webhook_host(), self.default_webhook_port())?;
271        push_doctor_check(
272            &mut checks,
273            "webhook.health",
274            "Webhook endpoint",
275            if webhook_ok {
276                ProviderDoctorCheckStatus::Pass
277            } else {
278                ProviderDoctorCheckStatus::Fail
279            },
280            webhook_detail,
281            if webhook_ok {
282                None
283            } else {
284                Some("Start local webhook runtime and verify configured port.".to_string())
285            },
286            None,
287        );
288
289        let runtime = load_provider_runtime_config(&state_dir, self.name())?;
290        match read_provider_agent_marker(&state_dir, self.name())? {
291            Some(agent_name) => push_doctor_check(
292                &mut checks,
293                "state.selectedAgent",
294                "Selected agent",
295                ProviderDoctorCheckStatus::Pass,
296                format!("selected agent is `{agent_name}`"),
297                None,
298                None,
299            ),
300            None => push_doctor_check(
301                &mut checks,
302                "state.selectedAgent",
303                "Selected agent",
304                ProviderDoctorCheckStatus::Fail,
305                "selected agent marker is missing".to_string(),
306                Some("Run provider setup and choose an agent name.".to_string()),
307                None,
308            ),
309        }
310        let connector_base_url = opts.connector_base_url.clone().or_else(|| {
311            runtime
312                .as_ref()
313                .and_then(|cfg| cfg.connector_base_url.clone())
314        });
315        if opts.include_connector_runtime_check {
316            if let Some(connector_base_url) = connector_base_url {
317                let (connected, detail) = check_connector_runtime(&connector_base_url)?;
318                push_doctor_check(
319                    &mut checks,
320                    "connector.runtime",
321                    "Connector runtime",
322                    if connected {
323                        ProviderDoctorCheckStatus::Pass
324                    } else {
325                        ProviderDoctorCheckStatus::Fail
326                    },
327                    detail,
328                    if connected {
329                        None
330                    } else {
331                        Some("Start connector runtime and retry provider doctor.".to_string())
332                    },
333                    Some(serde_json::json!({ "connectorBaseUrl": connector_base_url })),
334                );
335            } else {
336                push_doctor_check(
337                    &mut checks,
338                    "connector.runtime",
339                    "Connector runtime",
340                    ProviderDoctorCheckStatus::Fail,
341                    "connector base URL is not configured".to_string(),
342                    Some("Run setup with `--connector-base-url` or pass it to doctor.".to_string()),
343                    None,
344                );
345            }
346        }
347
348        Ok(ProviderDoctorResult {
349            platform: self.name().to_string(),
350            status: doctor_status_from_checks(&checks),
351            checks,
352        })
353    }
354
355    fn setup(&self, opts: &ProviderSetupOptions) -> Result<ProviderSetupResult> {
356        let install_options = InstallOptions {
357            home_dir: opts.home_dir.clone(),
358            webhook_port: opts.webhook_port,
359            webhook_host: opts.webhook_host.clone(),
360            webhook_token: opts.webhook_token.clone(),
361            connector_url: opts
362                .connector_url
363                .clone()
364                .or_else(|| opts.connector_base_url.clone()),
365        };
366        let install_result = self.install(&install_options)?;
367        let state_dir = resolve_state_dir(opts.home_dir.clone())?;
368        let agent_name = opts
369            .agent_name
370            .as_deref()
371            .map(str::trim)
372            .filter(|value| !value.is_empty())
373            .unwrap_or("default");
374        let marker_path = write_provider_agent_marker(&state_dir, self.name(), agent_name)?;
375        let webhook_endpoint = self.resolve_webhook_url(&install_options)?;
376        let runtime_path = save_provider_runtime_config(
377            &state_dir,
378            self.name(),
379            ProviderRelayRuntimeConfig {
380                webhook_endpoint,
381                connector_base_url: opts.connector_base_url.clone(),
382                webhook_token: opts.webhook_token.clone(),
383                platform_base_url: opts.platform_base_url.clone(),
384                relay_transform_peers_path: opts.relay_transform_peers_path.clone(),
385                updated_at: now_iso(),
386            },
387        )?;
388
389        let mut notes = install_result.notes;
390        notes.push(format!("saved selected agent marker `{agent_name}`"));
391        notes.push("saved provider relay runtime".to_string());
392        Ok(ProviderSetupResult {
393            platform: self.name().to_string(),
394            notes,
395            updated_paths: vec![
396                marker_path.display().to_string(),
397                runtime_path.display().to_string(),
398            ],
399        })
400    }
401
402    #[allow(clippy::too_many_lines)]
403    fn relay_test(&self, opts: &ProviderRelayTestOptions) -> Result<ProviderRelayTestResult> {
404        let checked_at = now_iso();
405        let state_dir = resolve_state_dir(opts.home_dir.clone())?;
406        let runtime = load_provider_runtime_config(&state_dir, self.name())?;
407
408        let preflight = if opts.skip_preflight {
409            None
410        } else {
411            Some(self.doctor(&ProviderDoctorOptions {
412                home_dir: opts.home_dir.clone(),
413                platform_state_dir: opts.platform_state_dir.clone(),
414                selected_agent: None,
415                peer_alias: opts.peer_alias.clone(),
416                connector_base_url: opts.connector_base_url.clone(),
417                include_connector_runtime_check: true,
418            })?)
419        };
420        if preflight
421            .as_ref()
422            .map(|result| result.status == crate::provider::ProviderDoctorStatus::Unhealthy)
423            .unwrap_or(false)
424        {
425            return Ok(ProviderRelayTestResult {
426                platform: self.name().to_string(),
427                status: ProviderRelayTestStatus::Failure,
428                checked_at,
429                endpoint: runtime
430                    .as_ref()
431                    .map(|cfg| cfg.webhook_endpoint.clone())
432                    .unwrap_or_else(|| "unknown".to_string()),
433                peer_alias: opts.peer_alias.clone(),
434                http_status: None,
435                message: "Preflight checks failed".to_string(),
436                remediation_hint: Some(
437                    "Run provider doctor and resolve failed checks.".to_string(),
438                ),
439                preflight,
440                details: None,
441            });
442        }
443
444        let endpoint = if let Some(runtime) = runtime {
445            runtime.webhook_endpoint
446        } else {
447            self.resolve_webhook_url(&InstallOptions {
448                home_dir: opts.home_dir.clone(),
449                webhook_port: None,
450                webhook_host: None,
451                webhook_token: None,
452                connector_url: opts.connector_base_url.clone(),
453            })?
454        };
455
456        let message = opts
457            .message
458            .as_deref()
459            .map(str::trim)
460            .filter(|value| !value.is_empty())
461            .unwrap_or("clawdentity relay probe");
462        let session_id = opts
463            .session_id
464            .as_deref()
465            .map(str::trim)
466            .filter(|value| !value.is_empty())
467            .map(ToOwned::to_owned)
468            .unwrap_or_else(|| {
469                format!(
470                    "clawdentity-nanoclaw-probe-{}",
471                    chrono::Utc::now().timestamp()
472                )
473            });
474
475        let mut request = blocking_client()?
476            .post(&endpoint)
477            .header("content-type", "application/json")
478            .json(&serde_json::json!({
479                "provider": self.name(),
480                "sessionId": session_id,
481                "message": message,
482                "peer": opts.peer_alias,
483            }));
484        if let Some(token) = opts
485            .webhook_token
486            .as_deref()
487            .map(str::trim)
488            .filter(|value| !value.is_empty())
489        {
490            request = request.header("x-clawdentity-token", token);
491        }
492
493        let response = match request.send() {
494            Ok(response) => response,
495            Err(error) => {
496                return Ok(ProviderRelayTestResult {
497                    platform: self.name().to_string(),
498                    status: ProviderRelayTestStatus::Failure,
499                    checked_at,
500                    endpoint,
501                    peer_alias: opts.peer_alias.clone(),
502                    http_status: None,
503                    message: format!("relay probe request failed: {error}"),
504                    remediation_hint: Some(
505                        "Verify webhook endpoint is running and reachable from this machine."
506                            .to_string(),
507                    ),
508                    preflight,
509                    details: None,
510                });
511            }
512        };
513
514        let status = response.status().as_u16();
515        if response.status().is_success() {
516            Ok(ProviderRelayTestResult {
517                platform: self.name().to_string(),
518                status: ProviderRelayTestStatus::Success,
519                checked_at,
520                endpoint,
521                peer_alias: opts.peer_alias.clone(),
522                http_status: Some(status),
523                message: "relay probe accepted".to_string(),
524                remediation_hint: None,
525                preflight,
526                details: None,
527            })
528        } else {
529            Ok(ProviderRelayTestResult {
530                platform: self.name().to_string(),
531                status: ProviderRelayTestStatus::Failure,
532                checked_at,
533                endpoint,
534                peer_alias: opts.peer_alias.clone(),
535                http_status: Some(status),
536                message: format!("relay probe returned HTTP {status}"),
537                remediation_hint: Some(
538                    "Check provider webhook configuration and connector runtime.".to_string(),
539                ),
540                preflight,
541                details: None,
542            })
543        }
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use std::collections::HashMap;
550
551    use tempfile::TempDir;
552
553    use crate::provider::{InboundMessage, PlatformProvider};
554
555    use super::NanoclawProvider;
556
557    #[test]
558    fn detection_checks_project_structure_and_path() {
559        let project_root = TempDir::new().expect("temp project");
560        std::fs::create_dir_all(project_root.path().join(".claude/skills")).expect("skills dir");
561        std::fs::create_dir_all(project_root.path().join("scripts")).expect("scripts dir");
562        std::fs::write(
563            project_root.path().join("scripts/apply-skill.ts"),
564            "// noop\n",
565        )
566        .expect("script");
567
568        let bin_dir = TempDir::new().expect("temp bin");
569        std::fs::write(bin_dir.path().join("nanoclaw"), "#!/bin/sh\n").expect("binary");
570
571        let provider = NanoclawProvider::with_test_context(
572            project_root.path().to_path_buf(),
573            vec![bin_dir.path().to_path_buf()],
574        );
575        let detection = provider.detect();
576
577        assert!(detection.detected);
578        assert!(detection.confidence > 0.8);
579        assert!(
580            detection
581                .evidence
582                .iter()
583                .any(|entry| entry.contains("found scripts/apply-skill.ts"))
584        );
585    }
586
587    #[test]
588    fn format_inbound_uses_body_payload_shape() {
589        let provider = NanoclawProvider::default();
590
591        let request = provider.format_inbound(&InboundMessage {
592            sender_did: "did:cdi:registry.clawdentity.com:agent:01HF7YAT00W6W7CM7N3W5FDXTB"
593                .to_string(),
594            recipient_did: "did:cdi:registry.clawdentity.com:agent:01HF7YAT00W6W7CM7N3W5FDXTC"
595                .to_string(),
596            content: "hello".to_string(),
597            request_id: Some("req-123".to_string()),
598            metadata: HashMap::new(),
599        });
600
601        assert!(request.headers.is_empty());
602        assert_eq!(
603            request.body.get("userId").and_then(|value| value.as_str()),
604            Some("did:cdi:registry.clawdentity.com:agent:01HF7YAT00W6W7CM7N3W5FDXTB")
605        );
606        assert_eq!(
607            request.body.get("content").and_then(|value| value.as_str()),
608            Some("hello")
609        );
610    }
611
612    #[test]
613    fn config_path_points_to_project_env_file() {
614        let project_root = TempDir::new().expect("temp project");
615        let provider =
616            NanoclawProvider::with_test_context(project_root.path().to_path_buf(), Vec::new());
617
618        assert_eq!(
619            provider.config_path(),
620            Some(project_root.path().join(".env"))
621        );
622    }
623}