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;