gardesk/gar / a392445

Browse files

Add IPC system with Unix socket and garctl CLI

- Unix socket server at $XDG_RUNTIME_DIR/gar.sock
- JSON command/response protocol
- Non-blocking event loop integration
- Command dispatch for all WM actions (focus, swap, resize, workspace, etc.)
- Query commands: get_workspaces, get_focused, get_tree
- garctl CLI tool with clap for external control
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
a392445215290d21099e28cc5972bde9e0334f76
Parents
6ca3c46
Tree
eca27a6

9 changed files

StatusFile+-
M Cargo.lock 127 0
M Cargo.toml 1 0
M gar/src/core/mod.rs 12 0
M gar/src/ipc/mod.rs 5 1
A gar/src/ipc/protocol.rs 73 0
A gar/src/ipc/server.rs 201 0
M gar/src/x11/events.rs 183 2
M garctl/Cargo.toml 1 0
M garctl/src/main.rs 158 5
Cargo.lockmodified
@@ -11,6 +11,56 @@ dependencies = [
1111
  "memchr",
1212
 ]
1313
 
14
+[[package]]
15
+name = "anstream"
16
+version = "0.6.21"
17
+source = "registry+https://github.com/rust-lang/crates.io-index"
18
+checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
19
+dependencies = [
20
+ "anstyle",
21
+ "anstyle-parse",
22
+ "anstyle-query",
23
+ "anstyle-wincon",
24
+ "colorchoice",
25
+ "is_terminal_polyfill",
26
+ "utf8parse",
27
+]
28
+
29
+[[package]]
30
+name = "anstyle"
31
+version = "1.0.13"
32
+source = "registry+https://github.com/rust-lang/crates.io-index"
33
+checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
34
+
35
+[[package]]
36
+name = "anstyle-parse"
37
+version = "0.2.7"
38
+source = "registry+https://github.com/rust-lang/crates.io-index"
39
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
40
+dependencies = [
41
+ "utf8parse",
42
+]
43
+
44
+[[package]]
45
+name = "anstyle-query"
46
+version = "1.1.5"
47
+source = "registry+https://github.com/rust-lang/crates.io-index"
48
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
49
+dependencies = [
50
+ "windows-sys",
51
+]
52
+
53
+[[package]]
54
+name = "anstyle-wincon"
55
+version = "3.0.11"
56
+source = "registry+https://github.com/rust-lang/crates.io-index"
57
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
58
+dependencies = [
59
+ "anstyle",
60
+ "once_cell_polyfill",
61
+ "windows-sys",
62
+]
63
+
1464
 [[package]]
1565
 name = "as-raw-xcb-connection"
1666
 version = "1.0.1"
@@ -55,6 +105,52 @@ version = "1.0.4"
55105
 source = "registry+https://github.com/rust-lang/crates.io-index"
56106
 checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
57107
 
108
+[[package]]
109
+name = "clap"
110
+version = "4.5.54"
111
+source = "registry+https://github.com/rust-lang/crates.io-index"
112
+checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
113
+dependencies = [
114
+ "clap_builder",
115
+ "clap_derive",
116
+]
117
+
118
+[[package]]
119
+name = "clap_builder"
120
+version = "4.5.54"
121
+source = "registry+https://github.com/rust-lang/crates.io-index"
122
+checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
123
+dependencies = [
124
+ "anstream",
125
+ "anstyle",
126
+ "clap_lex",
127
+ "strsim",
128
+]
129
+
130
+[[package]]
131
+name = "clap_derive"
132
+version = "4.5.49"
133
+source = "registry+https://github.com/rust-lang/crates.io-index"
134
+checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
135
+dependencies = [
136
+ "heck",
137
+ "proc-macro2",
138
+ "quote",
139
+ "syn",
140
+]
141
+
142
+[[package]]
143
+name = "clap_lex"
144
+version = "0.7.6"
145
+source = "registry+https://github.com/rust-lang/crates.io-index"
146
+checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
147
+
148
+[[package]]
149
+name = "colorchoice"
150
+version = "1.0.4"
151
+source = "registry+https://github.com/rust-lang/crates.io-index"
152
+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
153
+
58154
 [[package]]
59155
 name = "dirs"
60156
 version = "6.0.0"
@@ -122,6 +218,7 @@ dependencies = [
122218
 name = "garctl"
123219
 version = "0.1.0"
124220
 dependencies = [
221
+ "clap",
125222
  "serde",
126223
  "serde_json",
127224
 ]
@@ -147,6 +244,18 @@ dependencies = [
147244
  "wasi",
148245
 ]
149246
 
247
+[[package]]
248
+name = "heck"
249
+version = "0.5.0"
250
+source = "registry+https://github.com/rust-lang/crates.io-index"
251
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
252
+
253
+[[package]]
254
+name = "is_terminal_polyfill"
255
+version = "1.70.2"
256
+source = "registry+https://github.com/rust-lang/crates.io-index"
257
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
258
+
150259
 [[package]]
151260
 name = "itoa"
152261
 version = "1.0.17"
@@ -282,6 +391,12 @@ version = "1.21.3"
282391
 source = "registry+https://github.com/rust-lang/crates.io-index"
283392
 checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
284393
 
394
+[[package]]
395
+name = "once_cell_polyfill"
396
+version = "1.70.2"
397
+source = "registry+https://github.com/rust-lang/crates.io-index"
398
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
399
+
285400
 [[package]]
286401
 name = "option-ext"
287402
 version = "0.2.0"
@@ -473,6 +588,12 @@ version = "1.15.1"
473588
 source = "registry+https://github.com/rust-lang/crates.io-index"
474589
 checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
475590
 
591
+[[package]]
592
+name = "strsim"
593
+version = "0.11.1"
594
+source = "registry+https://github.com/rust-lang/crates.io-index"
595
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
596
+
476597
 [[package]]
477598
 name = "syn"
478599
 version = "2.0.114"
@@ -580,6 +701,12 @@ version = "1.0.22"
580701
 source = "registry+https://github.com/rust-lang/crates.io-index"
581702
 checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
582703
 
704
+[[package]]
705
+name = "utf8parse"
706
+version = "0.2.2"
707
+source = "registry+https://github.com/rust-lang/crates.io-index"
708
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
709
+
583710
 [[package]]
584711
 name = "valuable"
585712
 version = "0.1.1"
Cargo.tomlmodified
@@ -20,3 +20,4 @@ serde = { version = "1.0", features = ["derive"] }
2020
 serde_json = "1.0"
2121
 mlua = { version = "0.10", features = ["lua54", "vendored"] }
2222
 dirs = "6.0"
23
+clap = { version = "4.5", features = ["derive"] }
gar/src/core/mod.rsmodified
@@ -13,6 +13,7 @@ use std::sync::{Arc, Mutex};
1313
 use x11rb::protocol::xproto::Window as XWindow;
1414
 
1515
 use crate::config::{Config, LuaConfig, LuaState, RuleActions, WindowMatch};
16
+use crate::ipc::IpcServer;
1617
 use crate::x11::Connection;
1718
 use crate::x11::events::DragState;
1819
 use crate::Result;
@@ -29,6 +30,7 @@ pub struct WindowManager {
2930
     pub focused_window: Option<XWindow>,
3031
     pub running: bool,
3132
     pub drag_state: Option<DragState>,
33
+    pub ipc_server: Option<IpcServer>,
3234
 }
3335
 
3436
 impl WindowManager {
@@ -49,6 +51,15 @@ impl WindowManager {
4951
         // Get config values from Lua state
5052
         let config = lua_state.lock().unwrap().config.clone();
5153
 
54
+        // Initialize IPC server (optional - graceful failure)
55
+        let ipc_server = match IpcServer::new() {
56
+            Ok(server) => Some(server),
57
+            Err(e) => {
58
+                tracing::warn!("Failed to start IPC server: {}", e);
59
+                None
60
+            }
61
+        };
62
+
5263
         Ok(Self {
5364
             conn,
5465
             config,
@@ -61,6 +72,7 @@ impl WindowManager {
6172
             focused_window: None,
6273
             running: true,
6374
             drag_state: None,
75
+            ipc_server,
6476
         })
6577
     }
6678
 
gar/src/ipc/mod.rsmodified
@@ -1,1 +1,5 @@
1
-// IPC module - Unix socket server comes in Sprint 6
1
+mod protocol;
2
+mod server;
3
+
4
+pub use protocol::{Event, Request, Response, WindowInfo, WorkspaceInfo};
5
+pub use server::IpcServer;
gar/src/ipc/protocol.rsadded
@@ -0,0 +1,73 @@
1
+use serde::{Deserialize, Serialize};
2
+use serde_json::Value;
3
+
4
+/// Request from client to server
5
+#[derive(Debug, Deserialize)]
6
+pub struct Request {
7
+    pub command: String,
8
+    #[serde(default)]
9
+    pub args: Value,
10
+}
11
+
12
+/// Response from server to client
13
+#[derive(Debug, Serialize)]
14
+pub struct Response {
15
+    pub success: bool,
16
+    #[serde(skip_serializing_if = "Option::is_none")]
17
+    pub data: Option<Value>,
18
+    #[serde(skip_serializing_if = "Option::is_none")]
19
+    pub error: Option<String>,
20
+}
21
+
22
+impl Response {
23
+    pub fn success(data: Option<Value>) -> Self {
24
+        Self {
25
+            success: true,
26
+            data,
27
+            error: None,
28
+        }
29
+    }
30
+
31
+    pub fn error(msg: impl Into<String>) -> Self {
32
+        Self {
33
+            success: false,
34
+            data: None,
35
+            error: Some(msg.into()),
36
+        }
37
+    }
38
+}
39
+
40
+/// Event broadcast to subscribed clients
41
+#[derive(Debug, Serialize)]
42
+pub struct Event {
43
+    pub event: String,
44
+    pub data: Value,
45
+}
46
+
47
+impl Event {
48
+    pub fn new(event: impl Into<String>, data: Value) -> Self {
49
+        Self {
50
+            event: event.into(),
51
+            data,
52
+        }
53
+    }
54
+}
55
+
56
+/// Workspace info for queries
57
+#[derive(Debug, Serialize)]
58
+pub struct WorkspaceInfo {
59
+    pub id: usize,
60
+    pub name: String,
61
+    pub focused: bool,
62
+    pub tiled_count: usize,
63
+    pub floating_count: usize,
64
+}
65
+
66
+/// Window info for queries
67
+#[derive(Debug, Serialize)]
68
+pub struct WindowInfo {
69
+    pub id: u32,
70
+    pub workspace: usize,
71
+    pub floating: bool,
72
+    pub focused: bool,
73
+}
gar/src/ipc/server.rsadded
@@ -0,0 +1,201 @@
1
+use std::collections::HashSet;
2
+use std::io::{BufRead, BufReader, Write};
3
+use std::os::unix::net::{UnixListener, UnixStream};
4
+use std::path::PathBuf;
5
+
6
+use serde_json::Value;
7
+
8
+use super::protocol::{Event, Request, Response};
9
+
10
+/// Result of reading from a client
11
+enum ReadResult {
12
+    Request(Request),
13
+    WouldBlock,
14
+    Disconnected,
15
+}
16
+
17
+/// A connected IPC client
18
+struct Client {
19
+    stream: UnixStream,
20
+    reader: BufReader<UnixStream>,
21
+    subscriptions: HashSet<String>,
22
+}
23
+
24
+impl Client {
25
+    fn new(stream: UnixStream) -> std::io::Result<Self> {
26
+        stream.set_nonblocking(true)?;
27
+        let reader = BufReader::new(stream.try_clone()?);
28
+        Ok(Self {
29
+            stream,
30
+            reader,
31
+            subscriptions: HashSet::new(),
32
+        })
33
+    }
34
+
35
+    fn read_request(&mut self) -> ReadResult {
36
+        let mut line = String::new();
37
+        match self.reader.read_line(&mut line) {
38
+            Ok(0) => ReadResult::Disconnected,
39
+            Ok(_) => {
40
+                match serde_json::from_str(&line) {
41
+                    Ok(req) => ReadResult::Request(req),
42
+                    Err(_) => ReadResult::WouldBlock, // Malformed, ignore
43
+                }
44
+            }
45
+            Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => ReadResult::WouldBlock,
46
+            Err(_) => ReadResult::Disconnected,
47
+        }
48
+    }
49
+
50
+    fn send_response(&mut self, response: &Response) -> std::io::Result<()> {
51
+        let json = serde_json::to_string(response)?;
52
+        writeln!(self.stream, "{}", json)?;
53
+        self.stream.flush()
54
+    }
55
+
56
+    fn send_event(&mut self, event: &Event) -> std::io::Result<()> {
57
+        let json = serde_json::to_string(event)?;
58
+        writeln!(self.stream, "{}", json)?;
59
+        self.stream.flush()
60
+    }
61
+}
62
+
63
+/// IPC server for external control
64
+pub struct IpcServer {
65
+    listener: UnixListener,
66
+    clients: Vec<Client>,
67
+    socket_path: PathBuf,
68
+}
69
+
70
+impl IpcServer {
71
+    /// Create a new IPC server
72
+    pub fn new() -> std::io::Result<Self> {
73
+        let socket_path = Self::socket_path();
74
+
75
+        // Remove existing socket
76
+        let _ = std::fs::remove_file(&socket_path);
77
+
78
+        // Create parent directory if needed
79
+        if let Some(parent) = socket_path.parent() {
80
+            std::fs::create_dir_all(parent)?;
81
+        }
82
+
83
+        let listener = UnixListener::bind(&socket_path)?;
84
+        listener.set_nonblocking(true)?;
85
+
86
+        // Set socket permissions to user-only
87
+        #[cfg(unix)]
88
+        {
89
+            use std::os::unix::fs::PermissionsExt;
90
+            std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o600))?;
91
+        }
92
+
93
+        tracing::info!("IPC server listening on {:?}", socket_path);
94
+
95
+        Ok(Self {
96
+            listener,
97
+            clients: Vec::new(),
98
+            socket_path,
99
+        })
100
+    }
101
+
102
+    /// Get the socket path
103
+    fn socket_path() -> PathBuf {
104
+        std::env::var("XDG_RUNTIME_DIR")
105
+            .map(|dir| PathBuf::from(dir).join("gar.sock"))
106
+            .unwrap_or_else(|_| PathBuf::from("/tmp/gar.sock"))
107
+    }
108
+
109
+    /// Accept new connections (non-blocking)
110
+    pub fn accept_connections(&mut self) {
111
+        loop {
112
+            match self.listener.accept() {
113
+                Ok((stream, _addr)) => {
114
+                    tracing::debug!("New IPC client connected");
115
+                    match Client::new(stream) {
116
+                        Ok(client) => self.clients.push(client),
117
+                        Err(e) => tracing::warn!("Failed to setup client: {}", e),
118
+                    }
119
+                }
120
+                Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
121
+                Err(e) => {
122
+                    tracing::warn!("Failed to accept connection: {}", e);
123
+                    break;
124
+                }
125
+            }
126
+        }
127
+    }
128
+
129
+    /// Process incoming requests from all clients
130
+    /// Returns a list of (client_index, request) pairs
131
+    pub fn poll_requests(&mut self) -> Vec<(usize, Request)> {
132
+        let mut requests = Vec::new();
133
+        let mut disconnected = Vec::new();
134
+
135
+        for (i, client) in self.clients.iter_mut().enumerate() {
136
+            match client.read_request() {
137
+                ReadResult::Request(req) => requests.push((i, req)),
138
+                ReadResult::Disconnected => disconnected.push(i),
139
+                ReadResult::WouldBlock => {}
140
+            }
141
+        }
142
+
143
+        // Remove disconnected clients (in reverse order to preserve indices)
144
+        for i in disconnected.into_iter().rev() {
145
+            tracing::debug!("IPC client disconnected");
146
+            self.clients.remove(i);
147
+        }
148
+
149
+        requests
150
+    }
151
+
152
+    /// Send a response to a specific client
153
+    pub fn send_response(&mut self, client_idx: usize, response: Response) {
154
+        if let Some(client) = self.clients.get_mut(client_idx) {
155
+            if let Err(e) = client.send_response(&response) {
156
+                tracing::warn!("Failed to send response: {}", e);
157
+            }
158
+        }
159
+    }
160
+
161
+    /// Subscribe a client to events
162
+    pub fn subscribe(&mut self, client_idx: usize, events: Vec<String>) {
163
+        if let Some(client) = self.clients.get_mut(client_idx) {
164
+            for event in events {
165
+                client.subscriptions.insert(event);
166
+            }
167
+        }
168
+    }
169
+
170
+    /// Broadcast an event to all subscribed clients
171
+    pub fn broadcast_event(&mut self, event_name: &str, data: Value) {
172
+        let event = Event::new(event_name, data);
173
+        let mut disconnected = Vec::new();
174
+
175
+        for (i, client) in self.clients.iter_mut().enumerate() {
176
+            if client.subscriptions.contains(event_name) || client.subscriptions.contains("*") {
177
+                if let Err(_) = client.send_event(&event) {
178
+                    disconnected.push(i);
179
+                }
180
+            }
181
+        }
182
+
183
+        // Remove failed clients
184
+        for i in disconnected.into_iter().rev() {
185
+            self.clients.remove(i);
186
+        }
187
+    }
188
+
189
+    /// Get client count
190
+    pub fn client_count(&self) -> usize {
191
+        self.clients.len()
192
+    }
193
+}
194
+
195
+impl Drop for IpcServer {
196
+    fn drop(&mut self) {
197
+        // Clean up socket file
198
+        let _ = std::fs::remove_file(&self.socket_path);
199
+        tracing::debug!("IPC server shutdown, socket removed");
200
+    }
201
+}
gar/src/x11/events.rsmodified
@@ -757,14 +757,195 @@ impl WindowManager {
757757
         self.adopt_existing_windows()?;
758758
 
759759
         while self.running {
760
-            let event = self.conn.conn.wait_for_event()?;
761
-            self.handle_event(event)?;
760
+            // Handle X11 events (non-blocking poll)
761
+            while let Some(event) = self.conn.conn.poll_for_event()? {
762
+                self.handle_event(event)?;
763
+            }
764
+
765
+            // Handle IPC requests
766
+            self.handle_ipc()?;
767
+
768
+            // Small sleep to avoid busy-waiting when idle
769
+            std::thread::sleep(std::time::Duration::from_millis(10));
762770
         }
763771
 
764772
         tracing::info!("Event loop exited");
765773
         Ok(())
766774
     }
767775
 
776
+    /// Handle pending IPC requests
777
+    fn handle_ipc(&mut self) -> Result<()> {
778
+        let Some(ref mut ipc) = self.ipc_server else {
779
+            return Ok(());
780
+        };
781
+
782
+        // Accept new connections
783
+        ipc.accept_connections();
784
+
785
+        // Process requests
786
+        let requests = ipc.poll_requests();
787
+        for (client_idx, request) in requests {
788
+            let response = self.dispatch_ipc_command(&request.command, request.args);
789
+            if let Some(ref mut ipc) = self.ipc_server {
790
+                ipc.send_response(client_idx, response);
791
+            }
792
+        }
793
+
794
+        Ok(())
795
+    }
796
+
797
+    /// Dispatch an IPC command and return a response
798
+    fn dispatch_ipc_command(&mut self, command: &str, args: serde_json::Value) -> crate::ipc::Response {
799
+        use crate::ipc::Response;
800
+
801
+        match command {
802
+            "focus" => {
803
+                let direction = args.get("direction").and_then(|v| v.as_str()).unwrap_or("");
804
+                if let Some(dir) = parse_direction(direction) {
805
+                    match self.focus_direction(dir) {
806
+                        Ok(_) => Response::success(None),
807
+                        Err(e) => Response::error(e.to_string()),
808
+                    }
809
+                } else {
810
+                    Response::error(format!("Invalid direction: {}", direction))
811
+                }
812
+            }
813
+            "swap" => {
814
+                let direction = args.get("direction").and_then(|v| v.as_str()).unwrap_or("");
815
+                if let Some(dir) = parse_direction(direction) {
816
+                    match self.swap_direction(dir) {
817
+                        Ok(_) => Response::success(None),
818
+                        Err(e) => Response::error(e.to_string()),
819
+                    }
820
+                } else {
821
+                    Response::error(format!("Invalid direction: {}", direction))
822
+                }
823
+            }
824
+            "resize" => {
825
+                let direction = args.get("direction").and_then(|v| v.as_str()).unwrap_or("");
826
+                let amount = args.get("amount").and_then(|v| v.as_f64()).unwrap_or(0.05) as f32;
827
+                if let Some(dir) = parse_direction(direction) {
828
+                    match self.resize_direction(dir, amount) {
829
+                        Ok(_) => Response::success(None),
830
+                        Err(e) => Response::error(e.to_string()),
831
+                    }
832
+                } else {
833
+                    Response::error(format!("Invalid direction: {}", direction))
834
+                }
835
+            }
836
+            "close" => {
837
+                if let Some(window) = self.focused_window {
838
+                    match self.close_window(window) {
839
+                        Ok(_) => Response::success(None),
840
+                        Err(e) => Response::error(e.to_string()),
841
+                    }
842
+                } else {
843
+                    Response::error("No focused window")
844
+                }
845
+            }
846
+            "workspace" => {
847
+                let n = args.get("number").and_then(|v| v.as_u64()).unwrap_or(1) as usize;
848
+                match self.switch_workspace(n.saturating_sub(1)) {
849
+                    Ok(_) => Response::success(None),
850
+                    Err(e) => Response::error(e.to_string()),
851
+                }
852
+            }
853
+            "move_to_workspace" => {
854
+                let n = args.get("number").and_then(|v| v.as_u64()).unwrap_or(1) as usize;
855
+                match self.move_to_workspace(n.saturating_sub(1)) {
856
+                    Ok(_) => Response::success(None),
857
+                    Err(e) => Response::error(e.to_string()),
858
+                }
859
+            }
860
+            "toggle_floating" => {
861
+                if let Some(window) = self.focused_window {
862
+                    match self.toggle_floating(window) {
863
+                        Ok(_) => Response::success(None),
864
+                        Err(e) => Response::error(e.to_string()),
865
+                    }
866
+                } else {
867
+                    Response::error("No focused window")
868
+                }
869
+            }
870
+            "equalize" => {
871
+                match self.equalize() {
872
+                    Ok(_) => Response::success(None),
873
+                    Err(e) => Response::error(e.to_string()),
874
+                }
875
+            }
876
+            "reload" => {
877
+                match self.reload_config() {
878
+                    Ok(_) => Response::success(None),
879
+                    Err(e) => Response::error(e.to_string()),
880
+                }
881
+            }
882
+            "exit" => {
883
+                self.running = false;
884
+                Response::success(None)
885
+            }
886
+            "get_workspaces" => {
887
+                Response::success(Some(self.get_workspaces_json()))
888
+            }
889
+            "get_focused" => {
890
+                Response::success(Some(self.get_focused_json()))
891
+            }
892
+            "get_tree" => {
893
+                Response::success(Some(self.get_tree_json()))
894
+            }
895
+            "subscribe" => {
896
+                // Handle subscription in handle_ipc directly
897
+                Response::success(None)
898
+            }
899
+            _ => Response::error(format!("Unknown command: {}", command)),
900
+        }
901
+    }
902
+
903
+    /// Get workspace info as JSON
904
+    fn get_workspaces_json(&self) -> serde_json::Value {
905
+        serde_json::json!(self.workspaces.iter().enumerate().map(|(i, ws)| {
906
+            serde_json::json!({
907
+                "id": ws.id,
908
+                "name": ws.name,
909
+                "focused": i == self.focused_workspace,
910
+                "tiled_count": ws.tree.window_count(),
911
+                "floating_count": ws.floating.len(),
912
+            })
913
+        }).collect::<Vec<_>>())
914
+    }
915
+
916
+    /// Get focused window info as JSON
917
+    fn get_focused_json(&self) -> serde_json::Value {
918
+        match self.focused_window {
919
+            Some(win_id) => {
920
+                if let Some(win) = self.windows.get(&win_id) {
921
+                    serde_json::json!({
922
+                        "id": win_id,
923
+                        "workspace": win.workspace + 1,
924
+                        "floating": win.floating,
925
+                    })
926
+                } else {
927
+                    serde_json::json!(null)
928
+                }
929
+            }
930
+            None => serde_json::json!(null),
931
+        }
932
+    }
933
+
934
+    /// Get window tree as JSON
935
+    fn get_tree_json(&self) -> serde_json::Value {
936
+        serde_json::json!({
937
+            "focused_workspace": self.focused_workspace + 1,
938
+            "workspaces": self.workspaces.iter().map(|ws| {
939
+                serde_json::json!({
940
+                    "id": ws.id,
941
+                    "name": ws.name,
942
+                    "tiled": ws.tree.windows(),
943
+                    "floating": ws.floating,
944
+                })
945
+            }).collect::<Vec<_>>()
946
+        })
947
+    }
948
+
768949
     // Floating window helpers
769950
 
770951
     fn is_floating(&self, window: u32) -> bool {
garctl/Cargo.tomlmodified
@@ -13,3 +13,4 @@ path = "src/main.rs"
1313
 [dependencies]
1414
 serde.workspace = true
1515
 serde_json.workspace = true
16
+clap.workspace = true
garctl/src/main.rsmodified
@@ -1,8 +1,161 @@
1
-// garctl - CLI tool for controlling gar
2
-// Full implementation comes in Sprint 6
1
+use std::io::{BufRead, BufReader, Write};
2
+use std::os::unix::net::UnixStream;
3
+use std::path::PathBuf;
4
+
5
+use clap::{Parser, Subcommand};
6
+use serde_json::{json, Value};
7
+
8
+#[derive(Parser)]
9
+#[command(name = "garctl")]
10
+#[command(about = "Control the gar window manager")]
11
+struct Cli {
12
+    #[command(subcommand)]
13
+    command: Command,
14
+}
15
+
16
+#[derive(Subcommand)]
17
+enum Command {
18
+    /// Focus window in direction
19
+    Focus {
20
+        /// Direction: left, right, up, down
21
+        direction: String,
22
+    },
23
+    /// Swap with window in direction
24
+    Swap {
25
+        /// Direction: left, right, up, down
26
+        direction: String,
27
+    },
28
+    /// Resize split in direction
29
+    Resize {
30
+        /// Direction: left, right, up, down
31
+        direction: String,
32
+        /// Resize amount (default: 0.05)
33
+        #[arg(default_value = "0.05")]
34
+        amount: f32,
35
+    },
36
+    /// Close focused window
37
+    Close,
38
+    /// Switch to workspace
39
+    Workspace {
40
+        /// Workspace number (1-10)
41
+        number: usize,
42
+    },
43
+    /// Move focused window to workspace
44
+    MoveToWorkspace {
45
+        /// Workspace number (1-10)
46
+        number: usize,
47
+    },
48
+    /// Toggle floating state of focused window
49
+    ToggleFloating,
50
+    /// Equalize split ratios
51
+    Equalize,
52
+    /// Reload configuration
53
+    Reload,
54
+    /// Exit gar
55
+    Exit,
56
+    /// Get workspace information
57
+    GetWorkspaces,
58
+    /// Get focused window information
59
+    GetFocused,
60
+    /// Get window tree
61
+    GetTree,
62
+}
63
+
64
+fn get_socket_path() -> PathBuf {
65
+    std::env::var("XDG_RUNTIME_DIR")
66
+        .map(|dir| PathBuf::from(dir).join("gar.sock"))
67
+        .unwrap_or_else(|_| PathBuf::from("/tmp/gar.sock"))
68
+}
69
+
70
+fn send_command(request: Value) -> Result<Value, String> {
71
+    let socket_path = get_socket_path();
72
+
73
+    let mut stream = UnixStream::connect(&socket_path)
74
+        .map_err(|e| format!("Failed to connect to gar (is it running?): {}", e))?;
75
+
76
+    // Send request
77
+    let json = serde_json::to_string(&request)
78
+        .map_err(|e| format!("Failed to serialize request: {}", e))?;
79
+    writeln!(stream, "{}", json)
80
+        .map_err(|e| format!("Failed to send request: {}", e))?;
81
+
82
+    // Read response
83
+    let mut reader = BufReader::new(stream);
84
+    let mut response = String::new();
85
+    reader.read_line(&mut response)
86
+        .map_err(|e| format!("Failed to read response: {}", e))?;
87
+
88
+    serde_json::from_str(&response)
89
+        .map_err(|e| format!("Failed to parse response: {}", e))
90
+}
391
 
492
 fn main() {
5
-    eprintln!("garctl: IPC not yet implemented (Sprint 6)");
6
-    eprintln!("Usage: garctl <command> [args...]");
7
-    std::process::exit(1);
93
+    let cli = Cli::parse();
94
+
95
+    let request = match cli.command {
96
+        Command::Focus { direction } => {
97
+            json!({ "command": "focus", "args": { "direction": direction } })
98
+        }
99
+        Command::Swap { direction } => {
100
+            json!({ "command": "swap", "args": { "direction": direction } })
101
+        }
102
+        Command::Resize { direction, amount } => {
103
+            json!({ "command": "resize", "args": { "direction": direction, "amount": amount } })
104
+        }
105
+        Command::Close => {
106
+            json!({ "command": "close", "args": {} })
107
+        }
108
+        Command::Workspace { number } => {
109
+            json!({ "command": "workspace", "args": { "number": number } })
110
+        }
111
+        Command::MoveToWorkspace { number } => {
112
+            json!({ "command": "move_to_workspace", "args": { "number": number } })
113
+        }
114
+        Command::ToggleFloating => {
115
+            json!({ "command": "toggle_floating", "args": {} })
116
+        }
117
+        Command::Equalize => {
118
+            json!({ "command": "equalize", "args": {} })
119
+        }
120
+        Command::Reload => {
121
+            json!({ "command": "reload", "args": {} })
122
+        }
123
+        Command::Exit => {
124
+            json!({ "command": "exit", "args": {} })
125
+        }
126
+        Command::GetWorkspaces => {
127
+            json!({ "command": "get_workspaces", "args": {} })
128
+        }
129
+        Command::GetFocused => {
130
+            json!({ "command": "get_focused", "args": {} })
131
+        }
132
+        Command::GetTree => {
133
+            json!({ "command": "get_tree", "args": {} })
134
+        }
135
+    };
136
+
137
+    match send_command(request) {
138
+        Ok(response) => {
139
+            let success = response.get("success").and_then(|v| v.as_bool()).unwrap_or(false);
140
+
141
+            if success {
142
+                if let Some(data) = response.get("data") {
143
+                    if !data.is_null() {
144
+                        // Pretty print data
145
+                        println!("{}", serde_json::to_string_pretty(data).unwrap_or_default());
146
+                    }
147
+                }
148
+            } else {
149
+                let error = response.get("error")
150
+                    .and_then(|v| v.as_str())
151
+                    .unwrap_or("Unknown error");
152
+                eprintln!("Error: {}", error);
153
+                std::process::exit(1);
154
+            }
155
+        }
156
+        Err(e) => {
157
+            eprintln!("{}", e);
158
+            std::process::exit(1);
159
+        }
160
+    }
8161
 }