Skip to main content

st/
daemon_client.rs

1//! Daemon Client - CLI interface to the Smart Tree daemon
2//!
3//! This module provides a client for communicating with the Smart Tree daemon.
4//! It handles:
5//! - Health checks to see if daemon is running
6//! - Auto-starting the daemon if not running
7//! - Sending commands to the daemon via HTTP
8//! - Managing daemon lifecycle (start/stop/status)
9//!
10//! "The messenger between CLI and brain!" - Cheet
11
12use anyhow::{Context, Result};
13use serde::{Deserialize, Serialize};
14use std::process::Command;
15#[cfg(unix)]
16use std::process::Stdio;
17use std::time::Duration;
18
19#[cfg(windows)]
20use std::os::windows::process::CommandExt;
21
22/// Simple percent-encoding for URL query parameters
23fn percent_encode(s: &str) -> String {
24    s.chars()
25        .map(|c| match c {
26            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => c.to_string(),
27            ' ' => "+".to_string(),
28            _ => format!("%{:02X}", c as u8),
29        })
30        .collect()
31}
32
33/// Default daemon port (Foken's magic number!)
34pub const DEFAULT_DAEMON_PORT: u16 = 28428;
35
36/// Daemon client configuration
37#[derive(Debug, Clone)]
38pub struct DaemonClient {
39    /// The port the daemon is running on
40    port: u16,
41    /// Base URL for daemon API
42    base_url: String,
43    /// HTTP client with timeout
44    client: reqwest::Client,
45}
46
47/// Response from daemon info endpoint
48#[derive(Debug, Deserialize, Serialize)]
49pub struct DaemonInfo {
50    pub name: String,
51    pub version: String,
52    pub description: String,
53}
54
55/// Response from daemon context endpoint
56#[derive(Debug, Deserialize, Serialize)]
57pub struct ContextResponse {
58    pub projects_count: usize,
59    pub directories_count: usize,
60    pub last_scan: Option<String>,
61    pub credits_balance: f64,
62}
63
64/// Response from daemon credits endpoint
65#[derive(Debug, Deserialize, Serialize)]
66pub struct CreditsResponse {
67    pub balance: f64,
68    pub total_earned: f64,
69    pub total_spent: f64,
70    pub recent_transactions: Vec<Transaction>,
71}
72
73#[derive(Debug, Deserialize, Serialize)]
74pub struct Transaction {
75    pub timestamp: String,
76    pub amount: f64,
77    pub description: String,
78}
79
80/// Project info from daemon
81#[derive(Debug, Deserialize, Serialize, Clone)]
82pub struct ProjectInfo {
83    pub path: String,
84    pub name: String,
85    pub project_type: String,
86    pub key_files: Vec<String>,
87    pub essence: String,
88}
89
90/// Tool call request
91#[derive(Debug, Serialize)]
92pub struct ToolCallRequest {
93    pub name: String,
94    pub arguments: serde_json::Value,
95}
96
97/// Status of the daemon
98#[derive(Debug)]
99pub enum DaemonStatus {
100    /// Daemon is running and healthy
101    Running(DaemonInfo),
102    /// Daemon is not running
103    NotRunning,
104    /// Daemon is starting up
105    Starting,
106    /// Error checking daemon status
107    Error(String),
108}
109
110impl DaemonClient {
111    /// Create a new daemon client, loading auth token from ~/.st/daemon.token
112    pub fn new(port: u16) -> Self {
113        let token = crate::daemon::load_token();
114
115        let mut builder = reqwest::Client::builder()
116            .timeout(Duration::from_secs(5));
117
118        if let Some(ref tok) = token {
119            let mut headers = reqwest::header::HeaderMap::new();
120            if let Ok(val) = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", tok)) {
121                headers.insert(reqwest::header::AUTHORIZATION, val);
122            }
123            builder = builder.default_headers(headers);
124        }
125
126        let client = builder.build().unwrap_or_default();
127
128        Self {
129            port,
130            base_url: format!("http://127.0.0.1:{}", port),
131            client,
132        }
133    }
134
135    /// Create with default port (28428)
136    pub fn default_port() -> Self {
137        Self::new(DEFAULT_DAEMON_PORT)
138    }
139
140    /// Check if the daemon is running
141    pub async fn check_status(&self) -> DaemonStatus {
142        match self.health_check().await {
143            Ok(true) => {
144                // Daemon is healthy, get info
145                match self.get_info().await {
146                    Ok(info) => DaemonStatus::Running(info),
147                    Err(_) => DaemonStatus::Running(DaemonInfo {
148                        name: "smart-tree-daemon".to_string(),
149                        version: "unknown".to_string(),
150                        description: "Running".to_string(),
151                    }),
152                }
153            }
154            Ok(false) => DaemonStatus::NotRunning,
155            Err(e) => {
156                // Check if it's a connection error (daemon not running)
157                let err_str = e.to_string().to_lowercase();
158                if err_str.contains("connection refused")
159                    || err_str.contains("tcp connect error")
160                    || err_str.contains("connect error")
161                    || err_str.contains("error sending request")
162                {
163                    DaemonStatus::NotRunning
164                } else {
165                    DaemonStatus::Error(e.to_string())
166                }
167            }
168        }
169    }
170
171    /// Health check - returns true if daemon is responsive
172    pub async fn health_check(&self) -> Result<bool> {
173        let url = format!("{}/health", self.base_url);
174        match self.client.get(&url).send().await {
175            Ok(resp) => Ok(resp.status().is_success()),
176            Err(e) => Err(anyhow::anyhow!("Health check failed: {}", e)),
177        }
178    }
179
180    /// Get daemon info
181    pub async fn get_info(&self) -> Result<DaemonInfo> {
182        let url = format!("{}/info", self.base_url);
183        let resp = self
184            .client
185            .get(&url)
186            .send()
187            .await
188            .context("Failed to connect to daemon")?;
189
190        resp.json::<DaemonInfo>()
191            .await
192            .context("Failed to parse daemon info")
193    }
194
195    /// Get system context summary
196    pub async fn get_context(&self) -> Result<ContextResponse> {
197        let url = format!("{}/context", self.base_url);
198        let resp = self
199            .client
200            .get(&url)
201            .send()
202            .await
203            .context("Failed to connect to daemon")?;
204
205        resp.json::<ContextResponse>()
206            .await
207            .context("Failed to parse context response")
208    }
209
210    /// Get list of detected projects
211    pub async fn get_projects(&self) -> Result<Vec<ProjectInfo>> {
212        let url = format!("{}/context/projects", self.base_url);
213        let resp = self
214            .client
215            .get(&url)
216            .send()
217            .await
218            .context("Failed to connect to daemon")?;
219
220        resp.json::<Vec<ProjectInfo>>()
221            .await
222            .context("Failed to parse projects response")
223    }
224
225    /// Query context by keyword
226    pub async fn query_context(&self, query: &str) -> Result<serde_json::Value> {
227        let url = format!("{}/context/query", self.base_url);
228        let resp = self
229            .client
230            .post(&url)
231            .json(&serde_json::json!({ "query": query }))
232            .send()
233            .await
234            .context("Failed to connect to daemon")?;
235
236        resp.json::<serde_json::Value>()
237            .await
238            .context("Failed to parse query response")
239    }
240
241    /// List files in a directory via daemon
242    pub async fn list_files(
243        &self,
244        path: Option<&str>,
245        pattern: Option<&str>,
246        depth: Option<usize>,
247    ) -> Result<Vec<String>> {
248        let mut url = format!("{}/context/files?", self.base_url);
249
250        if let Some(p) = path {
251            url.push_str(&format!("path={}&", percent_encode(p)));
252        }
253        if let Some(pat) = pattern {
254            url.push_str(&format!("pattern={}&", percent_encode(pat)));
255        }
256        if let Some(d) = depth {
257            url.push_str(&format!("depth={}", d));
258        }
259
260        let resp = self
261            .client
262            .get(&url)
263            .send()
264            .await
265            .context("Failed to connect to daemon")?;
266
267        resp.json::<Vec<String>>()
268            .await
269            .context("Failed to parse files response")
270    }
271
272    /// Get Foken credits
273    pub async fn get_credits(&self) -> Result<CreditsResponse> {
274        let url = format!("{}/credits", self.base_url);
275        let resp = self
276            .client
277            .get(&url)
278            .send()
279            .await
280            .context("Failed to connect to daemon")?;
281
282        resp.json::<CreditsResponse>()
283            .await
284            .context("Failed to parse credits response")
285    }
286
287    /// Record token savings for credits
288    pub async fn record_savings(
289        &self,
290        tokens_saved: u64,
291        description: &str,
292    ) -> Result<CreditsResponse> {
293        let url = format!("{}/credits/record", self.base_url);
294        let resp = self
295            .client
296            .post(&url)
297            .json(&serde_json::json!({
298                "tokens_saved": tokens_saved,
299                "description": description
300            }))
301            .send()
302            .await
303            .context("Failed to connect to daemon")?;
304
305        resp.json::<CreditsResponse>()
306            .await
307            .context("Failed to parse credits response")
308    }
309
310    /// Call a daemon tool
311    pub async fn call_tool(
312        &self,
313        name: &str,
314        arguments: serde_json::Value,
315    ) -> Result<serde_json::Value> {
316        let url = format!("{}/tools/call", self.base_url);
317        let req = ToolCallRequest {
318            name: name.to_string(),
319            arguments,
320        };
321
322        let resp = self
323            .client
324            .post(&url)
325            .json(&req)
326            .send()
327            .await
328            .context("Failed to connect to daemon")?;
329
330        resp.json::<serde_json::Value>()
331            .await
332            .context("Failed to parse tool response")
333    }
334
335    /// List available daemon tools
336    pub async fn list_tools(&self) -> Result<Vec<serde_json::Value>> {
337        let url = format!("{}/tools", self.base_url);
338        let resp = self
339            .client
340            .get(&url)
341            .send()
342            .await
343            .context("Failed to connect to daemon")?;
344
345        resp.json::<Vec<serde_json::Value>>()
346            .await
347            .context("Failed to parse tools list")
348    }
349
350    /// Execute a CLI scan via the daemon
351    ///
352    /// This is the main entry point for the thin-client architecture.
353    /// All scanning and formatting happens in the daemon.
354    pub async fn cli_scan(
355        &self,
356        request: crate::daemon_cli::CliScanRequest,
357    ) -> Result<crate::daemon_cli::CliScanResponse> {
358        let url = format!("{}/cli/scan", self.base_url);
359
360        // Use a longer timeout for scan operations, with auth token
361        let mut builder = reqwest::Client::builder()
362            .timeout(std::time::Duration::from_secs(120));
363
364        if let Some(tok) = crate::daemon::load_token() {
365            let mut headers = reqwest::header::HeaderMap::new();
366            if let Ok(val) = reqwest::header::HeaderValue::from_str(&format!("Bearer {}", tok)) {
367                headers.insert(reqwest::header::AUTHORIZATION, val);
368            }
369            builder = builder.default_headers(headers);
370        }
371
372        let client = builder.build().unwrap_or_default();
373
374        let resp = client
375            .post(&url)
376            .json(&request)
377            .send()
378            .await
379            .context("Failed to connect to daemon for CLI scan")?;
380
381        if !resp.status().is_success() {
382            let status = resp.status();
383            let error_body = resp.text().await.unwrap_or_default();
384            return Err(anyhow::anyhow!(
385                "CLI scan failed with status {}: {}",
386                status,
387                error_body
388            ));
389        }
390
391        resp.json::<crate::daemon_cli::CliScanResponse>()
392            .await
393            .context("Failed to parse CLI scan response")
394    }
395
396    /// Start the daemon in the background
397    ///
398    /// Returns Ok(true) if daemon was started, Ok(false) if already running
399    pub async fn start_daemon(&self) -> Result<bool> {
400        // First check if already running
401        if matches!(self.check_status().await, DaemonStatus::Running(_)) {
402            return Ok(false);
403        }
404
405        // Get the path to our own executable
406        let exe_path = std::env::current_exe().context("Failed to get current executable path")?;
407
408        // Start daemon as a background process
409        // We use setsid on Unix to detach from the terminal
410        #[cfg(unix)]
411        {
412            Command::new(&exe_path)
413                .args(["--daemon", "--daemon-port", &self.port.to_string()])
414                .stdin(Stdio::null())
415                .stdout(Stdio::null())
416                .stderr(Stdio::null())
417                .spawn()
418                .context("Failed to start daemon process")?;
419        }
420
421        #[cfg(windows)]
422        {
423            Command::new(&exe_path)
424                .args(["--daemon", "--daemon-port", &self.port.to_string()])
425                .creation_flags(0x00000008) // DETACHED_PROCESS
426                .spawn()
427                .context("Failed to start daemon process")?;
428        }
429
430        // Wait a moment for daemon to start
431        tokio::time::sleep(Duration::from_millis(500)).await;
432
433        // Wait up to 5 seconds for daemon to become healthy
434        for _ in 0..10 {
435            if self.health_check().await.unwrap_or(false) {
436                return Ok(true);
437            }
438            tokio::time::sleep(Duration::from_millis(500)).await;
439        }
440
441        Err(anyhow::anyhow!(
442            "Daemon started but failed to become healthy within 5 seconds"
443        ))
444    }
445
446    /// Stop the daemon
447    ///
448    /// Note: This requires the daemon to have a shutdown endpoint or we use a signal
449    pub async fn stop_daemon(&self) -> Result<bool> {
450        // Check if running first
451        if !matches!(self.check_status().await, DaemonStatus::Running(_)) {
452            return Ok(false);
453        }
454
455        // Try to send a shutdown request (we'll add this endpoint to daemon)
456        let url = format!("{}/shutdown", self.base_url);
457        match self.client.post(&url).send().await {
458            Ok(_) => {
459                // Wait for daemon to stop
460                tokio::time::sleep(Duration::from_millis(500)).await;
461                Ok(true)
462            }
463            Err(_) => {
464                // If endpoint doesn't exist, try finding and killing the process
465                #[cfg(unix)]
466                {
467                    // Find process listening on our port and kill it
468                    let output = Command::new("lsof")
469                        .args(["-ti", &format!(":{}", self.port)])
470                        .output();
471
472                    if let Ok(output) = output {
473                        if let Ok(pid_str) = String::from_utf8(output.stdout) {
474                            for pid in pid_str.lines() {
475                                if let Ok(pid) = pid.trim().parse::<i32>() {
476                                    let _ = Command::new("kill").arg(pid.to_string()).output();
477                                }
478                            }
479                            return Ok(true);
480                        }
481                    }
482                }
483
484                Err(anyhow::anyhow!("Failed to stop daemon"))
485            }
486        }
487    }
488
489    /// Ensure daemon is running, starting it if necessary
490    ///
491    /// This is the main entry point for daemon-first architecture.
492    /// Returns the daemon info if running/started successfully.
493    pub async fn ensure_running(&self) -> Result<DaemonInfo> {
494        match self.check_status().await {
495            DaemonStatus::Running(info) => Ok(info),
496            DaemonStatus::NotRunning => {
497                eprintln!("🌳 Starting Smart Tree daemon on port {}...", self.port);
498                self.start_daemon().await?;
499
500                // Retry with exponential backoff to get daemon info
501                let mut delay = Duration::from_millis(100);
502                for attempt in 1..=5 {
503                    match self.get_info().await {
504                        Ok(info) => {
505                            eprintln!("✅ Daemon started successfully!");
506                            return Ok(info);
507                        }
508                        Err(_e) if attempt < 5 => {
509                            eprintln!(
510                                "⏳ Waiting for daemon to become ready... (attempt {}/5)",
511                                attempt
512                            );
513                            tokio::time::sleep(delay).await;
514                            delay *= 2; // Exponential backoff
515                        }
516                        Err(e) => {
517                            return Err(anyhow::anyhow!(
518                                "Daemon started but failed to respond after 5 attempts: {}",
519                                e
520                            ));
521                        }
522                    }
523                }
524                unreachable!("Loop should always return")
525            }
526            DaemonStatus::Starting => {
527                eprintln!("⏳ Daemon is starting, waiting...");
528                // Wait for it to finish starting with retry logic
529                let mut delay = Duration::from_millis(500);
530                for attempt in 1..=6 {
531                    tokio::time::sleep(delay).await;
532                    match self.check_status().await {
533                        DaemonStatus::Running(info) => {
534                            eprintln!("✅ Daemon is now running!");
535                            return Ok(info);
536                        }
537                        DaemonStatus::Starting if attempt < 6 => {
538                            eprintln!("⏳ Still starting... (attempt {}/6)", attempt);
539                            delay *= 2; // Exponential backoff
540                        }
541                        DaemonStatus::NotRunning => {
542                            return Err(anyhow::anyhow!(
543                                "Daemon stopped during startup; it did not remain in Starting state"
544                            ));
545                        }
546                        DaemonStatus::Error(e) => {
547                            return Err(anyhow::anyhow!("Daemon startup failed: {}", e));
548                        }
549                        DaemonStatus::Starting => {
550                            // This occurs when attempt == 6 and the daemon is still starting.
551                            return Err(anyhow::anyhow!("Daemon failed to start within timeout"));
552                        }
553                    }
554                }
555                unreachable!("Loop should always return")
556            }
557            DaemonStatus::Error(e) => Err(anyhow::anyhow!(
558                "Daemon error: {}. Try running 'st --daemon-stop' and then 'st --daemon-start' to restart.",
559                e
560            )),
561        }
562    }
563}
564
565/// Print daemon status in a nice format
566pub fn print_daemon_status(status: &DaemonStatus) {
567    match status {
568        DaemonStatus::Running(info) => {
569            println!("╔═══════════════════════════════════════════════════════════╗");
570            println!("║        🌳 SMART TREE DAEMON STATUS: RUNNING 🌳           ║");
571            println!("╠═══════════════════════════════════════════════════════════╣");
572            println!("║  Name:        {:<45} ║", info.name);
573            println!("║  Version:     {:<45} ║", info.version);
574            println!(
575                "║  Description: {:<45} ║",
576                truncate_str(&info.description, 45)
577            );
578            println!("╚═══════════════════════════════════════════════════════════╝");
579        }
580        DaemonStatus::NotRunning => {
581            println!("╔═══════════════════════════════════════════════════════════╗");
582            println!("║        🌳 SMART TREE DAEMON STATUS: STOPPED 🛑            ║");
583            println!("╠═══════════════════════════════════════════════════════════╣");
584            println!("║  The daemon is not running.                               ║");
585            println!("║  Start with: st --daemon-start                            ║");
586            println!("╚═══════════════════════════════════════════════════════════╝");
587        }
588        DaemonStatus::Starting => {
589            println!("╔═══════════════════════════════════════════════════════════╗");
590            println!("║        🌳 SMART TREE DAEMON STATUS: STARTING ⏳           ║");
591            println!("╠═══════════════════════════════════════════════════════════╣");
592            println!("║  The daemon is starting up...                             ║");
593            println!("╚═══════════════════════════════════════════════════════════╝");
594        }
595        DaemonStatus::Error(e) => {
596            println!("╔═══════════════════════════════════════════════════════════╗");
597            println!("║        🌳 SMART TREE DAEMON STATUS: ERROR ❌              ║");
598            println!("╠═══════════════════════════════════════════════════════════╣");
599            println!("║  Error: {:<50} ║", truncate_str(e, 50));
600            println!("╚═══════════════════════════════════════════════════════════╝");
601        }
602    }
603}
604
605/// Print context summary from daemon
606pub fn print_context_summary(ctx: &ContextResponse) {
607    println!("╔═══════════════════════════════════════════════════════════╗");
608    println!("║           📊 SYSTEM CONTEXT SUMMARY 📊                    ║");
609    println!("╠═══════════════════════════════════════════════════════════╣");
610    println!("║  Projects detected:    {:<35} ║", ctx.projects_count);
611    println!("║  Directories tracked:  {:<35} ║", ctx.directories_count);
612    println!(
613        "║  Last scan:            {:<35} ║",
614        ctx.last_scan.as_deref().unwrap_or("Never")
615    );
616    println!("║  Foken balance:        {:<35.2} ║", ctx.credits_balance);
617    println!("╚═══════════════════════════════════════════════════════════╝");
618}
619
620/// Print credits summary
621pub fn print_credits(credits: &CreditsResponse) {
622    println!("╔═══════════════════════════════════════════════════════════╗");
623    println!("║           💰 FOKEN CREDITS SUMMARY 💰                     ║");
624    println!("╠═══════════════════════════════════════════════════════════╣");
625    println!("║  Current Balance:  {:<38.2} ║", credits.balance);
626    println!("║  Total Earned:     {:<38.2} ║", credits.total_earned);
627    println!("║  Total Spent:      {:<38.2} ║", credits.total_spent);
628    if !credits.recent_transactions.is_empty() {
629        println!("╠═══════════════════════════════════════════════════════════╣");
630        println!("║  Recent Transactions:                                     ║");
631        for tx in credits.recent_transactions.iter().take(5) {
632            println!(
633                "║    +{:>8.0} - {:<43} ║",
634                tx.amount,
635                truncate_str(&tx.description, 43)
636            );
637        }
638    }
639    println!("╚═══════════════════════════════════════════════════════════╝");
640}
641
642/// Print projects list
643pub fn print_projects(projects: &[ProjectInfo]) {
644    println!("╔═══════════════════════════════════════════════════════════╗");
645    println!("║           📁 DETECTED PROJECTS 📁                         ║");
646    println!("╠═══════════════════════════════════════════════════════════╣");
647    if projects.is_empty() {
648        println!("║  No projects detected yet.                                ║");
649        println!("║  Add directories to watch with: st --daemon-watch <path>  ║");
650    } else {
651        for p in projects.iter().take(10) {
652            println!("║  📦 {:<53} ║", truncate_str(&p.name, 53));
653            println!("║     Type: {:<47} ║", p.project_type);
654            println!("║     Path: {:<47} ║", truncate_str(&p.path, 47));
655            if !p.key_files.is_empty() {
656                println!(
657                    "║     Files: {:<46} ║",
658                    truncate_str(&p.key_files.join(", "), 46)
659                );
660            }
661        }
662        if projects.len() > 10 {
663            println!(
664                "║  ... and {} more projects                                ║",
665                projects.len() - 10
666            );
667        }
668    }
669    println!("╚═══════════════════════════════════════════════════════════╝");
670}
671
672/// Helper to truncate strings for display
673fn truncate_str(s: &str, max_len: usize) -> String {
674    if s.len() <= max_len {
675        s.to_string()
676    } else {
677        format!("{}...", &s[..max_len - 3])
678    }
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    #[test]
686    fn test_client_creation() {
687        let client = DaemonClient::new(28428);
688        assert_eq!(client.port, 28428);
689        assert_eq!(client.base_url, "http://127.0.0.1:28428");
690    }
691
692    #[test]
693    fn test_default_port() {
694        let client = DaemonClient::default_port();
695        assert_eq!(client.port, DEFAULT_DAEMON_PORT);
696    }
697
698    #[tokio::test]
699    async fn test_status_when_not_running() {
700        // Use a random high port unlikely to have anything
701        let client = DaemonClient::new(59999);
702        let status = client.check_status().await;
703        assert!(matches!(
704            status,
705            DaemonStatus::NotRunning | DaemonStatus::Error(_)
706        ));
707    }
708}