Skip to content

Commit 1dd722a

Browse files
Amp AIclaude
andcommitted
feat: Add daemon IPC with JSON-RPC and CLI client wiring (#10)
Implements GitHub Issue #10 - Daemon IPC + CLI client wiring - Added JSON-RPC server with Unix socket support - Created API types for request/response structures - Implemented RPC handlers for: - get_token: Retrieve access tokens for accounts - add_account: Register new accounts with scopes - list_accounts: List configured accounts with optional filtering - resolve: Resolve credential references to values - Custom JSON-RPC server implementation over Unix sockets - Graceful shutdown with signal handling - Structured logging for RPC operations - Created DaemonClient for communicating with daemon over Unix socket - Implemented fallback mode when daemon is unavailable - Updated all CLI commands to use daemon-first approach: - get-token - add-account - list-accounts - resolve - Automatic degradation to direct library usage with warnings - Added jsonrpsee 0.24 for JSON-RPC types - Added tokio-stream for Unix socket handling - Fixed sigilforge-core feature flags to avoid OAuth compilation errors - Created integration tests for RPC round-trips - Tests verify add_account, list_accounts, get_token, and resolve methods - Error handling tests for invalid requests - Integration tests have timing issues that need refinement - Server implementation uses custom Unix socket handling - Core library's OAuth features temporarily disabled to fix compilation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 49761f8 commit 1dd722a

File tree

12 files changed

+1310
-11
lines changed

12 files changed

+1310
-11
lines changed

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ sigilforge-core = { path = "sigilforge-core" }
1919

2020
# Async runtime
2121
tokio = { version = "1.41", features = ["full"] }
22+
tokio-stream = { version = "0.1", features = ["net"] }
2223

2324
# Serialization
2425
serde = { version = "1.0", features = ["derive"] }
@@ -59,3 +60,6 @@ chrono = { version = "0.4", features = ["serde"] }
5960

6061
# Async traits
6162
async-trait = "0.1"
63+
64+
# JSON-RPC
65+
jsonrpsee = { version = "0.24", features = ["server", "client", "macros"] }

sigilforge-cli/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,6 @@ tracing-subscriber = { workspace = true }
3434

3535
# Configuration
3636
directories = { workspace = true }
37+
38+
# JSON-RPC client
39+
jsonrpsee = { workspace = true }

sigilforge-cli/src/client.rs

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
//! Daemon client for communicating with sigilforged.
2+
//!
3+
//! This module provides a client for connecting to the Sigilforge daemon
4+
//! over a Unix socket (or named pipe on Windows) using JSON-RPC.
5+
6+
use anyhow::{Context, Result};
7+
use directories::ProjectDirs;
8+
use serde::{Deserialize, Serialize};
9+
use serde_json::json;
10+
use std::path::{Path, PathBuf};
11+
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
12+
use tokio::net::UnixStream;
13+
use tracing::{debug, warn};
14+
15+
/// Response containing a fresh access token.
16+
#[derive(Debug, Clone, Serialize, Deserialize)]
17+
pub struct GetTokenResponse {
18+
pub token: String,
19+
pub expires_at: Option<String>,
20+
}
21+
22+
/// Information about a configured account.
23+
#[derive(Debug, Clone, Serialize, Deserialize)]
24+
pub struct AccountInfo {
25+
pub service: String,
26+
pub account: String,
27+
pub scopes: Vec<String>,
28+
pub created_at: String,
29+
pub last_used: Option<String>,
30+
}
31+
32+
/// Response containing a list of accounts.
33+
#[derive(Debug, Clone, Serialize, Deserialize)]
34+
pub struct ListAccountsResponse {
35+
pub accounts: Vec<AccountInfo>,
36+
}
37+
38+
/// Response after adding an account.
39+
#[derive(Debug, Clone, Serialize, Deserialize)]
40+
pub struct AddAccountResponse {
41+
pub message: String,
42+
}
43+
44+
/// Response containing a resolved value.
45+
#[derive(Debug, Clone, Serialize, Deserialize)]
46+
pub struct ResolveResponse {
47+
pub value: String,
48+
}
49+
50+
/// Client for communicating with the Sigilforge daemon.
51+
pub struct DaemonClient {
52+
stream: Option<UnixStream>,
53+
socket_path: PathBuf,
54+
next_id: u64,
55+
}
56+
57+
impl DaemonClient {
58+
/// Attempt to connect to the daemon at the given socket path.
59+
pub async fn connect(socket_path: &Path) -> Result<Self> {
60+
debug!("Attempting to connect to daemon at {:?}", socket_path);
61+
62+
// Check if socket exists
63+
if !socket_path.exists() {
64+
debug!("Socket does not exist at {:?}", socket_path);
65+
return Ok(Self {
66+
stream: None,
67+
socket_path: socket_path.to_path_buf(),
68+
next_id: 1,
69+
});
70+
}
71+
72+
// Try to connect to the Unix socket
73+
match UnixStream::connect(socket_path).await {
74+
Ok(stream) => {
75+
debug!("Successfully connected to daemon");
76+
Ok(Self {
77+
stream: Some(stream),
78+
socket_path: socket_path.to_path_buf(),
79+
next_id: 1,
80+
})
81+
}
82+
Err(e) => {
83+
warn!("Failed to connect to daemon: {}", e);
84+
Ok(Self {
85+
stream: None,
86+
socket_path: socket_path.to_path_buf(),
87+
next_id: 1,
88+
})
89+
}
90+
}
91+
}
92+
93+
/// Connect to daemon using default socket path.
94+
pub async fn connect_default() -> Result<Self> {
95+
let socket_path = default_socket_path();
96+
Self::connect(&socket_path).await
97+
}
98+
99+
/// Check if the client is connected to the daemon.
100+
pub fn is_connected(&self) -> bool {
101+
self.stream.is_some()
102+
}
103+
104+
/// Send a JSON-RPC request and receive a response.
105+
async fn send_request<T: for<'de> Deserialize<'de>>(
106+
&mut self,
107+
method: &str,
108+
params: serde_json::Value,
109+
) -> Result<T> {
110+
let stream = self
111+
.stream
112+
.as_mut()
113+
.ok_or_else(|| anyhow::anyhow!("Not connected to daemon"))?;
114+
115+
let id = self.next_id;
116+
self.next_id += 1;
117+
118+
let request = json!({
119+
"jsonrpc": "2.0",
120+
"method": method,
121+
"params": params,
122+
"id": id,
123+
});
124+
125+
let request_str = serde_json::to_string(&request)?;
126+
debug!("Sending request: {}", request_str);
127+
128+
stream.write_all(request_str.as_bytes()).await?;
129+
stream.write_all(b"\n").await?;
130+
stream.flush().await?;
131+
132+
let mut reader = BufReader::new(stream);
133+
let mut response_str = String::new();
134+
reader.read_line(&mut response_str).await?;
135+
136+
debug!("Received response: {}", response_str);
137+
138+
let response: serde_json::Value = serde_json::from_str(&response_str)?;
139+
140+
if let Some(error) = response.get("error") {
141+
anyhow::bail!("RPC error: {}", error);
142+
}
143+
144+
let result = response
145+
.get("result")
146+
.ok_or_else(|| anyhow::anyhow!("No result in response"))?;
147+
148+
Ok(serde_json::from_value(result.clone())?)
149+
}
150+
151+
/// Get a fresh access token for the specified account.
152+
pub async fn get_token(&mut self, service: &str, account: &str) -> Result<GetTokenResponse> {
153+
self.send_request("get_token", json!([service, account]))
154+
.await
155+
}
156+
157+
/// List all configured accounts, optionally filtered by service.
158+
pub async fn list_accounts(
159+
&mut self,
160+
service: Option<&str>,
161+
) -> Result<ListAccountsResponse> {
162+
self.send_request("list_accounts", json!([service]))
163+
.await
164+
}
165+
166+
/// Add a new account with the specified scopes.
167+
pub async fn add_account(
168+
&mut self,
169+
service: &str,
170+
account: &str,
171+
scopes: Vec<String>,
172+
) -> Result<AddAccountResponse> {
173+
self.send_request("add_account", json!([service, account, scopes]))
174+
.await
175+
}
176+
177+
/// Resolve a credential reference to its actual value.
178+
pub async fn resolve(&mut self, reference: &str) -> Result<ResolveResponse> {
179+
self.send_request("resolve", json!([reference])).await
180+
}
181+
}
182+
183+
/// Get the default socket path for the daemon.
184+
pub fn default_socket_path() -> PathBuf {
185+
let dirs = ProjectDirs::from("com", "raibid-labs", "sigilforge");
186+
187+
if cfg!(unix) {
188+
dirs.as_ref()
189+
.map(|d| d.runtime_dir().unwrap_or(d.data_dir()).join("sigilforge.sock"))
190+
.unwrap_or_else(|| PathBuf::from("/tmp/sigilforge.sock"))
191+
} else {
192+
PathBuf::from(r"\\.\pipe\sigilforge")
193+
}
194+
}

sigilforge-cli/src/main.rs

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@
1717
1818
use anyhow::Result;
1919
use clap::{Parser, Subcommand};
20-
use tracing::info;
20+
use tracing::{info, warn};
2121
use tracing_subscriber::FmtSubscriber;
2222

23+
mod client;
24+
2325
#[derive(Parser)]
2426
#[command(name = "sigilforge")]
2527
#[command(about = "Credential management for the raibid-labs ecosystem")]
@@ -124,6 +126,30 @@ async fn main() -> Result<()> {
124126
}
125127

126128
async fn add_account(service: &str, account: &str, scopes: Option<&str>) -> Result<()> {
129+
let mut client = client::DaemonClient::connect_default().await?;
130+
131+
if client.is_connected() {
132+
let scope_vec = scopes
133+
.map(|s| s.split(',').map(|s| s.trim().to_string()).collect())
134+
.unwrap_or_default();
135+
136+
match client.add_account(service, account, scope_vec).await {
137+
Ok(response) => {
138+
println!("{}", response.message);
139+
Ok(())
140+
}
141+
Err(e) => {
142+
warn!("Daemon call failed: {}", e);
143+
fallback_add_account(service, account, scopes).await
144+
}
145+
}
146+
} else {
147+
warn!("Daemon not available, using fallback mode");
148+
fallback_add_account(service, account, scopes).await
149+
}
150+
}
151+
152+
async fn fallback_add_account(service: &str, account: &str, scopes: Option<&str>) -> Result<()> {
127153
use sigilforge_core::{Account, AccountId, AccountStore, ServiceId};
128154

129155
let store = AccountStore::load()?;
@@ -152,6 +178,38 @@ async fn add_account(service: &str, account: &str, scopes: Option<&str>) -> Resu
152178
}
153179

154180
async fn list_accounts(service_filter: Option<&str>) -> Result<()> {
181+
let mut client = client::DaemonClient::connect_default().await?;
182+
183+
if client.is_connected() {
184+
match client.list_accounts(service_filter).await {
185+
Ok(response) => {
186+
if response.accounts.is_empty() {
187+
println!("No accounts configured");
188+
} else {
189+
println!("Configured accounts:");
190+
for account in response.accounts {
191+
println!(" {}/{}", account.service, account.account);
192+
println!(" Scopes: {}", account.scopes.join(", "));
193+
println!(" Created: {}", account.created_at);
194+
if let Some(last_used) = account.last_used {
195+
println!(" Last used: {}", last_used);
196+
}
197+
}
198+
}
199+
Ok(())
200+
}
201+
Err(e) => {
202+
warn!("Daemon call failed: {}", e);
203+
fallback_list_accounts(service_filter).await
204+
}
205+
}
206+
} else {
207+
warn!("Daemon not available, using fallback mode");
208+
fallback_list_accounts(service_filter).await
209+
}
210+
}
211+
212+
async fn fallback_list_accounts(service_filter: Option<&str>) -> Result<()> {
155213
use sigilforge_core::{AccountStore, ServiceId};
156214

157215
let store = AccountStore::load()?;
@@ -168,11 +226,6 @@ async fn list_accounts(service_filter: Option<&str>) -> Result<()> {
168226
}
169227

170228
println!("Configured accounts:");
171-
if let Some(service) = service_filter {
172-
println!(" (filtered by service: {})", service);
173-
}
174-
println!();
175-
176229
for account in accounts {
177230
println!(" {}/{}", account.service, account.id);
178231
if !account.scopes.is_empty() {
@@ -188,6 +241,39 @@ async fn list_accounts(service_filter: Option<&str>) -> Result<()> {
188241
}
189242

190243
async fn get_token(service: &str, account: &str, format: &str) -> Result<()> {
244+
let mut client = client::DaemonClient::connect_default().await?;
245+
246+
if client.is_connected() {
247+
match client.get_token(service, account).await {
248+
Ok(response) => {
249+
match format {
250+
"json" => {
251+
let json_output = serde_json::json!({
252+
"service": service,
253+
"account": account,
254+
"token": response.token,
255+
"expires_at": response.expires_at,
256+
});
257+
println!("{}", serde_json::to_string_pretty(&json_output)?);
258+
}
259+
_ => {
260+
println!("{}", response.token);
261+
}
262+
}
263+
Ok(())
264+
}
265+
Err(e) => {
266+
warn!("Daemon call failed: {}", e);
267+
fallback_get_token(service, account, format).await
268+
}
269+
}
270+
} else {
271+
warn!("Daemon not available, using fallback mode");
272+
fallback_get_token(service, account, format).await
273+
}
274+
}
275+
276+
async fn fallback_get_token(service: &str, account: &str, format: &str) -> Result<()> {
191277
println!("[stub] Getting token for {}/{}", service, account);
192278

193279
match format {
@@ -267,6 +353,26 @@ async fn remove_account(service: &str, account: &str, force: bool) -> Result<()>
267353
}
268354

269355
async fn resolve_reference(reference: &str) -> Result<()> {
356+
let mut client = client::DaemonClient::connect_default().await?;
357+
358+
if client.is_connected() {
359+
match client.resolve(reference).await {
360+
Ok(response) => {
361+
println!("{}", response.value);
362+
Ok(())
363+
}
364+
Err(e) => {
365+
warn!("Daemon call failed: {}", e);
366+
fallback_resolve_reference(reference).await
367+
}
368+
}
369+
} else {
370+
warn!("Daemon not available, using fallback mode");
371+
fallback_resolve_reference(reference).await
372+
}
373+
}
374+
375+
async fn fallback_resolve_reference(reference: &str) -> Result<()> {
270376
use sigilforge_core::CredentialRef;
271377

272378
match CredentialRef::from_auth_uri(reference) {

0 commit comments

Comments
 (0)