Skip to main content

scv_client/
layout.rs

1//! Where an SCV instance keeps everything, in one place.
2//!
3//! An instance home (`SCV_HOME`, default `~/.scv`) holds exactly:
4//!
5//! - `config.toml`: every setting a person edits, including channel accounts;
6//! - `credentials/`: sign-ins SCV writes itself (channel logins);
7//! - `agents/<name>/`: the private homes of delegated agent CLIs, which keep
8//!   their own sign-ins and configuration there;
9//! - `skills/`: the user's SCV skills;
10//! - `history/`: the chat log of the owner's conversations, by channel,
11//!   account, and conversation, and files the owner asked to keep (see
12//!   [`crate::history`]);
13//! - `state/`: runtime data SCV writes: the daemon socket and lock, delegated
14//!   run records, conversation markers, import records, the model and effort
15//!   values each agent offers, channel delivery state and locks, and chat
16//!   media.
17//!
18//! Anything else in the home is not read by SCV; [`Layout::strays`] lists it.
19//!
20//! A process selects its instance once, with [`Layout::from_env`], and hands
21//! the `Layout` to everything that needs a path; the environment only carries
22//! the selection on to child processes.
23
24use anyhow::{Context, Result};
25use sha2::{Digest, Sha256};
26use std::path::{Path, PathBuf};
27
28/// Top-level entries of an instance home, in display order.
29pub(crate) const ENTRIES: [&str; 6] = [
30    "config.toml",
31    "credentials",
32    "agents",
33    "skills",
34    "history",
35    "state",
36];
37
38/// Paths earlier releases used, which SCV no longer reads.
39const LEGACY: [&str; 7] = [
40    "adapters",
41    "channels",
42    "run",
43    "server.sock",
44    "server.lock",
45    "clawbot",
46    "clawbot.toml",
47];
48
49/// The paths of one SCV instance.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Layout {
52    home: PathBuf,
53    /// Selected by default (`~/.scv`) rather than by `SCV_HOME`.
54    default: bool,
55}
56
57/// Something in an instance home that SCV does not read.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Stray {
60    pub path: PathBuf,
61    /// A path an earlier SCV release used, rather than an unknown file.
62    pub legacy: bool,
63}
64
65impl Layout {
66    /// The instance at `home`, selected explicitly as `SCV_HOME` selects one.
67    pub fn new(home: impl Into<PathBuf>) -> Self {
68        Self {
69            home: home.into(),
70            default: false,
71        }
72    }
73
74    /// The instance selected by `SCV_HOME`, or `~/.scv`, with its home
75    /// resolved: canonical when it exists, otherwise made absolute. Service
76    /// unit names and delegation records hash this path, so every process of
77    /// an instance must resolve it the same way.
78    pub fn from_env() -> Result<Self> {
79        let selected = std::env::var_os("SCV_HOME").map(PathBuf::from);
80        let default = selected.is_none();
81        let home = selected
82            .or_else(|| dirs::home_dir().map(|path| path.join(".scv")))
83            .context("cannot determine SCV_HOME")?;
84        Ok(Self {
85            home: resolve(home)?,
86            default,
87        })
88    }
89
90    pub fn home(&self) -> &Path {
91        &self.home
92    }
93
94    /// Whether this is the default instance, which `SCV_HOME` did not select.
95    pub fn is_default(&self) -> bool {
96        self.default
97    }
98
99    /// The instance's systemd user unit: `scv.service` for the default
100    /// instance, otherwise `scv-<hash of the home>.service`, so instances
101    /// never share a unit and a release finds the unit an earlier one wrote.
102    pub fn service_name(&self) -> String {
103        if self.default {
104            return "scv.service".into();
105        }
106        let digest = Sha256::digest(self.home.to_string_lossy().as_bytes());
107        let suffix = digest[..8]
108            .iter()
109            .map(|byte| format!("{byte:02x}"))
110            .collect::<String>();
111        format!("scv-{suffix}.service")
112    }
113
114    /// The settings file a person edits.
115    pub fn config(&self) -> PathBuf {
116        self.home.join("config.toml")
117    }
118
119    /// Sign-ins SCV writes itself.
120    pub fn credentials(&self) -> PathBuf {
121        self.home.join("credentials")
122    }
123
124    /// One channel's account credentials, `<account>.json` each.
125    pub fn channel_credentials(&self, channel: &str) -> PathBuf {
126        self.credentials().join(channel)
127    }
128
129    /// The private homes of delegated agent CLIs.
130    pub fn agents(&self) -> PathBuf {
131        self.home.join("agents")
132    }
133
134    pub fn agent_home(&self, agent: &str) -> PathBuf {
135        self.agents().join(agent)
136    }
137
138    pub fn skills(&self) -> PathBuf {
139        self.home.join("skills")
140    }
141
142    /// The chat log, `<channel>/<account>/<conversation>/` (see
143    /// [`crate::history`]), and by default the files the owner kept.
144    pub fn history(&self) -> PathBuf {
145        self.home.join("history")
146    }
147
148    /// Runtime data SCV writes and reads back; never edited by hand.
149    pub fn state(&self) -> PathBuf {
150        self.home.join("state")
151    }
152
153    pub fn socket(&self) -> PathBuf {
154        self.state().join("server.sock")
155    }
156
157    /// Records of running delegated agents.
158    pub fn delegations(&self) -> PathBuf {
159        self.state().join("delegations")
160    }
161
162    /// Markers of live delegated conversations, for `scv agents gc`.
163    pub fn conversations(&self) -> PathBuf {
164        self.state().join("conversations")
165    }
166
167    /// The model and effort values `agent`'s ACP server last offered.
168    pub fn agent_options(&self, agent: &str) -> PathBuf {
169        self.state()
170            .join("agent-options")
171            .join(format!("{agent}.json"))
172    }
173
174    /// What each `scv agents import` copied, and from where.
175    pub fn imports(&self) -> PathBuf {
176        self.state().join("imports")
177    }
178
179    /// One channel's delivery state and account locks.
180    pub fn channel_state(&self, channel: &str) -> PathBuf {
181        self.state().join("channels").join(channel)
182    }
183
184    /// Files chat users sent, under `<channel>/<account>`, and copies of files
185    /// the model sends back, under `outbox`.
186    pub fn media(&self) -> PathBuf {
187        self.state().join("media")
188    }
189
190    /// Copies of files the model attached, waiting to be sent to a chat.
191    pub fn outbox(&self) -> PathBuf {
192        self.media().join("outbox")
193    }
194
195    /// A planned restart in progress.
196    pub fn update_plan(&self) -> PathBuf {
197        self.state().join("update.json")
198    }
199
200    /// The chat the account owner last wrote from.
201    pub fn last_owner(&self) -> PathBuf {
202        self.state().join("last-owner.json")
203    }
204
205    /// Present while a daemon runs; one left behind means it did not shut
206    /// down cleanly.
207    pub fn daemon_marker(&self) -> PathBuf {
208        self.state().join("daemon.json")
209    }
210
211    /// Serializes SCV's own edits of `config.toml`.
212    pub fn config_lock(&self) -> PathBuf {
213        self.state().join("config.lock")
214    }
215
216    /// Entries of the home that SCV does not read, sorted by name.
217    pub fn strays(&self) -> Result<Vec<Stray>> {
218        let entries = match std::fs::read_dir(&self.home) {
219            Ok(entries) => entries,
220            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
221            Err(error) => {
222                return Err(error).with_context(|| format!("read {}", self.home.display()));
223            }
224        };
225        let mut strays = Vec::new();
226        for entry in entries {
227            let name = entry?.file_name();
228            let Some(text) = name.to_str() else {
229                strays.push(Stray {
230                    path: self.home.join(&name),
231                    legacy: false,
232                });
233                continue;
234            };
235            if ENTRIES.contains(&text) {
236                continue;
237            }
238            strays.push(Stray {
239                path: self.home.join(text),
240                legacy: LEGACY.contains(&text),
241            });
242        }
243        strays.sort_by(|a, b| a.path.cmp(&b.path));
244        Ok(strays)
245    }
246}
247
248/// `path` resolved as an instance home: canonical when it exists, otherwise
249/// made absolute against the working directory.
250fn resolve(path: PathBuf) -> Result<PathBuf> {
251    if path.exists() {
252        Ok(std::fs::canonicalize(&path).unwrap_or(path))
253    } else if path.is_absolute() {
254        Ok(path)
255    } else {
256        Ok(std::env::current_dir()
257            .context("cannot determine SCV instance home")?
258            .join(path))
259    }
260}
261
262#[cfg(test)]
263mod tests;