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}