Add an install path: LaunchAgent, repos config, and documented defaults !10

merged merged by cmc on 2026-09-04 14:36 UTC · krz/ambient-companions:feat/install-path into main

6 files changed, +287 −12

README.md +61
@@ -224,6 +224,67 @@ and — for the differential test — `zpty` modules ship with zsh).
224224- **Phase 5** — live wallpaper (homelab, Path A) + e-ink/poster reuse.
225225 *Out of scope.*
226226
227## Install
228
229```sh
230brew install krz/tap/ambient-companions
231brew services start ambient-companions
232```
233
234Name the repositories to watch — signald takes none by default, and a login
235agent has no useful working directory:
236
237```sh
238mkdir -p ~/.config/signald
239cat > ~/.config/signald/repos <<'EOF'
240# one repository path per line; # comments and blank lines are ignored
241~/git/some-repo
242EOF
243brew services restart ambient-companions
244```
245
246Then add one line to `.zshrc` for the terminal collector:
247
248```sh
249source "$(brew --prefix)/share/ambient-companions/signald-hooks.zsh"
250```
251
252Open a new shell and watch the garden:
253
254```sh
255terminal-garden
256```
257
258`brew services` logs to `$(brew --prefix)/var/log/`. To run the agent by hand
259instead, `packaging/net.krz.signald.plist` is a launchd template; its header
260comment carries the `sed` line that fills in the paths and the `launchctl load`
261that starts it.
262
263### Paths
264
265Defaults, resolved the same way by `signald`, `terminal-garden`, and
266`shell-hooks/signald-hooks.zsh`. This table is the single place they are
267written down; unit tests in both binaries pin the code to it.
268
269| | `$XDG_RUNTIME_DIR` set | otherwise |
270|---|---|---|
271| socket | `$XDG_RUNTIME_DIR/signald.sock` | `~/.local/state/signald/sock` |
272| history db | `$XDG_RUNTIME_DIR/signald.sqlite` | `~/.local/state/signald/signald.sqlite` |
273| terminal spool | `$XDG_RUNTIME_DIR/terminal.spool` | `~/.local/state/signald/terminal.spool` |
274
275The db and the spool are derived from the socket's directory, so `--socket`
276moves all three together. Override individually with `--db` and `--spool`, and
277the spool from the shell side with `$SIGNALD_SPOOL` — it must match whatever
278the daemon uses, or the terminal metrics stay silently empty. launchd sets no
279`XDG_RUNTIME_DIR`, so an installed agent lands in `~/.local/state/signald`.
280
281The repository list is `$XDG_CONFIG_HOME/signald/repos`, else
282`~/.config/signald/repos`. Positional arguments to `signald` override it; with
283neither, the working directory is watched.
284
285Unix socket paths are limited to about 104 bytes. A deep scratch directory
286will hit `SUN_LEN`; use `mktemp -d` when testing by hand.
287
227288## Build & test
228289
229290```sh
crates/signald/Cargo.toml +1 −1
@@ -11,7 +11,7 @@ path = "src/main.rs"
1111
1212[dependencies]
1313signal-schema = { path = "../signal-schema" }
14# History store (spec §1.3): SQLite in WAL mode. `bundled` compiles SQLite in-
14# History store: SQLite in WAL mode. `bundled` compiles SQLite in-
1515# tree so there is no system-library dependency. This is the one new dependency
1616# added in v0.2; signal-schema stays dependency-free by design.
1717rusqlite = { version = "0.40", features = ["bundled"] }
crates/signald/src/main.rs +125 −6
@@ -26,7 +26,9 @@
2626//! [--collector <path>] [--interval-ms <n>] [--retention-days <n>]
2727//! [<repo-path> ...]
2828//! ```
29//! With no repo paths, the current directory is watched. The socket defaults to
29//! With no repo paths, `$XDG_CONFIG_HOME/signald/repos` (else
30//! `~/.config/signald/repos`) is read — one path per line, `#` comments
31//! ignored — and failing that the current directory is watched. The socket defaults to
3032//! `$XDG_RUNTIME_DIR/signald.sock` (fallback `~/.local/state/signald/sock`); the
3133//! history db and terminal spool default alongside it. `--collector` names the
3234//! `macos-collector` binary; by default it is looked up on `PATH` and skipped,
@@ -140,6 +142,9 @@ fn parse_args() -> Config {
140142 }
141143 }
142144
145 if repos.is_empty() {
146 repos = repos_from_config_file();
147 }
143148 if repos.is_empty() {
144149 repos.push(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
145150 }
@@ -156,13 +161,65 @@ fn parse_args() -> Config {
156161 }
157162}
158163
159/// `$XDG_RUNTIME_DIR/signald.sock`, else `~/.local/state/signald/sock`.
160fn default_socket_path() -> PathBuf {
161 if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
164/// `$XDG_RUNTIME_DIR/signald.sock`, else `$HOME/.local/state/signald/sock`.
165///
166/// Split from the environment so it can be tested without mutating it. The
167/// same resolution is duplicated in the other binary and in
168/// `shell-hooks/signald-hooks.zsh`; the README's "Paths" table is the one
169/// place they are written down, and the tests below pin them to it.
170fn socket_path_from(xdg_runtime_dir: Option<&str>, home: Option<&str>) -> PathBuf {
171 if let Some(dir) = xdg_runtime_dir {
162172 return PathBuf::from(dir).join("signald.sock");
163173 }
164 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
165 PathBuf::from(home).join(".local/state/signald/sock")
174 PathBuf::from(home.unwrap_or(".")).join(".local/state/signald/sock")
175}
176
177fn default_socket_path() -> PathBuf {
178 socket_path_from(
179 std::env::var("XDG_RUNTIME_DIR").ok().as_deref(),
180 std::env::var("HOME").ok().as_deref(),
181 )
182}
183
184/// `$XDG_CONFIG_HOME/signald/repos`, else `$HOME/.config/signald/repos`.
185fn repos_config_path(xdg_config_home: Option<&str>, home: Option<&str>) -> PathBuf {
186 match xdg_config_home {
187 Some(dir) => PathBuf::from(dir).join("signald/repos"),
188 None => PathBuf::from(home.unwrap_or(".")).join(".config/signald/repos"),
189 }
190}
191
192/// One repository path per line. Blank lines and `#` comments are ignored.
193fn parse_repos_file(contents: &str) -> Vec<PathBuf> {
194 contents
195 .lines()
196 .map(|l| l.trim())
197 .filter(|l| !l.is_empty() && !l.starts_with('#'))
198 .map(PathBuf::from)
199 .collect()
200}
201
202/// Repositories to watch when none were given on the command line.
203///
204/// A login agent has no useful working directory — launchd starts it in `/` —
205/// so falling straight through to the cwd would leave `brew services start
206/// signald` collecting no git signals at all. A missing file is not an error;
207/// the cwd fallback still applies.
208fn repos_from_config_file() -> Vec<PathBuf> {
209 let path = repos_config_path(
210 std::env::var("XDG_CONFIG_HOME").ok().as_deref(),
211 std::env::var("HOME").ok().as_deref(),
212 );
213 match std::fs::read_to_string(&path) {
214 Ok(contents) => {
215 let repos = parse_repos_file(&contents);
216 if !repos.is_empty() {
217 eprintln!("signald: watching {} repo(s) from {}", repos.len(), path.display());
218 }
219 repos
220 }
221 Err(_) => Vec::new(),
222 }
166223}
167224
168225/// Log enabled collectors and assert none holds an input-tap capability. A real
@@ -188,3 +245,65 @@ fn print_self_attestation(cfg: &Config) {
188245 eprintln!(" collector {source:?}: input-tap capability = NONE — {state}");
189246 }
190247}
248
249#[cfg(test)]
250mod path_tests {
251 use super::*;
252
253 /// Pinned to the README "Paths" table. If this changes, the table and
254 /// `shell-hooks/signald-hooks.zsh` change with it.
255 #[test]
256 fn socket_default_follows_xdg_then_home() {
257 assert_eq!(
258 socket_path_from(Some("/run/user/501"), Some("/Users/x")),
259 PathBuf::from("/run/user/501/signald.sock")
260 );
261 assert_eq!(
262 socket_path_from(None, Some("/Users/x")),
263 PathBuf::from("/Users/x/.local/state/signald/sock")
264 );
265 }
266
267 #[test]
268 fn repos_file_skips_blanks_and_comments() {
269 let repos = parse_repos_file(
270 "# what to watch\n\n/Users/x/git/one\n /Users/x/git/two \n\n# trailing\n",
271 );
272 assert_eq!(
273 repos,
274 vec![
275 PathBuf::from("/Users/x/git/one"),
276 PathBuf::from("/Users/x/git/two")
277 ]
278 );
279 }
280
281 #[test]
282 fn repos_config_follows_xdg_then_home() {
283 assert_eq!(
284 repos_config_path(Some("/Users/x/.config"), Some("/Users/x")),
285 PathBuf::from("/Users/x/.config/signald/repos")
286 );
287 assert_eq!(
288 repos_config_path(None, Some("/Users/x")),
289 PathBuf::from("/Users/x/.config/signald/repos")
290 );
291 }
292
293 /// The db and the spool are derived from the socket's directory, so all
294 /// three move together when --socket is given.
295 #[test]
296 fn db_and_spool_sit_beside_the_socket() {
297 let socket = socket_path_from(None, Some("/Users/x"));
298 let base = socket.parent().unwrap();
299 assert_eq!(
300 base.join("signald.sqlite"),
301 PathBuf::from("/Users/x/.local/state/signald/signald.sqlite")
302 );
303 assert_eq!(
304 base.join("terminal.spool"),
305 PathBuf::from("/Users/x/.local/state/signald/terminal.spool"),
306 "must equal SIGNALD_SPOOL's default in shell-hooks/signald-hooks.zsh"
307 );
308 }
309}
crates/terminal-garden/Cargo.toml +1 −1
@@ -3,7 +3,7 @@ name = "terminal-garden"
33version.workspace = true
44edition.workspace = true
55license.workspace = true
6description = "First renderer (spec build order §3): a TUI garden that grows with commits and wilts on stale branches. A thin subscriber to signald — it reads git aggregates off the socket and never touches a sensor."
6description = "First renderer: a TUI garden that grows with commits and wilts on stale branches. A thin subscriber to signald — it reads git aggregates off the socket and never touches a sensor."
77
88[[bin]]
99name = "terminal-garden"
crates/terminal-garden/src/main.rs +35 −4
@@ -88,10 +88,41 @@ fn parse_socket() -> PathBuf {
8888 default_socket_path()
8989}
9090
91fn default_socket_path() -> PathBuf {
92 if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
91/// `$XDG_RUNTIME_DIR/signald.sock`, else `$HOME/.local/state/signald/sock`.
92///
93/// Split from the environment so it can be tested without mutating it. The
94/// same resolution is duplicated in the other binary and in
95/// `shell-hooks/signald-hooks.zsh`; the README's "Paths" table is the one
96/// place they are written down, and the tests below pin them to it.
97fn socket_path_from(xdg_runtime_dir: Option<&str>, home: Option<&str>) -> PathBuf {
98 if let Some(dir) = xdg_runtime_dir {
9399 return PathBuf::from(dir).join("signald.sock");
94100 }
95 let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
96 PathBuf::from(home).join(".local/state/signald/sock")
101 PathBuf::from(home.unwrap_or(".")).join(".local/state/signald/sock")
102}
103
104fn default_socket_path() -> PathBuf {
105 socket_path_from(
106 std::env::var("XDG_RUNTIME_DIR").ok().as_deref(),
107 std::env::var("HOME").ok().as_deref(),
108 )
109}
110
111#[cfg(test)]
112mod path_tests {
113 use super::*;
114
115 /// Pinned to the README "Paths" table. If this changes, the table and
116 /// `shell-hooks/signald-hooks.zsh` change with it.
117 #[test]
118 fn socket_default_follows_xdg_then_home() {
119 assert_eq!(
120 socket_path_from(Some("/run/user/501"), Some("/Users/x")),
121 PathBuf::from("/run/user/501/signald.sock")
122 );
123 assert_eq!(
124 socket_path_from(None, Some("/Users/x")),
125 PathBuf::from("/Users/x/.local/state/signald/sock")
126 );
127 }
97128}
packaging/net.krz.signald.plist added +64
@@ -0,0 +1,64 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!--
3 User LaunchAgent for signald.
4
5 This is the template for a manual install. `brew services start signald`
6 does not use it — the formula's own service block generates an equivalent
7 plist under Homebrew's control.
8
9 To install by hand:
10
11 mkdir -p ~/Library/Logs/signald ~/Library/LaunchAgents
12 sed -e "s|__HOME__|$HOME|g" -e "s|__PREFIX__|$(brew --prefix)|g" \
13 packaging/net.krz.signald.plist > ~/Library/LaunchAgents/net.krz.signald.plist
14 launchctl load ~/Library/LaunchAgents/net.krz.signald.plist
15
16 Edit ProgramArguments to name the repositories to watch: signald takes them
17 as positional paths, and with none it watches the working directory, which
18 is not useful for a login agent. Create the log directory first — launchd
19 creates the log files but not the directory holding them.
20-->
21<plist version="1.0">
22<dict>
23 <key>Label</key>
24 <string>net.krz.signald</string>
25
26 <key>ProgramArguments</key>
27 <array>
28 <string>__PREFIX__/bin/signald</string>
29 <!-- The hardware collector runs as a child of signald. Naming it here
30 avoids depending on launchd's PATH, which is not the shell's. -->
31 <string>--collector</string>
32 <string>__PREFIX__/bin/macos-collector</string>
33 <!-- Repositories to watch. Replace with your own; add one string per repo. -->
34 <string>__HOME__/git</string>
35 </array>
36
37 <key>RunAtLoad</key>
38 <true/>
39 <key>KeepAlive</key>
40 <dict>
41 <key>SuccessfulExit</key>
42 <false/>
43 </dict>
44
45 <key>StandardOutPath</key>
46 <string>__HOME__/Library/Logs/signald/signald.log</string>
47 <key>StandardErrorPath</key>
48 <string>__HOME__/Library/Logs/signald/signald.err.log</string>
49
50 <!-- signald resolves its socket, db and spool under $XDG_RUNTIME_DIR when
51 set and ~/.local/state/signald otherwise. launchd sets neither, so the
52 agent lands on the ~/.local/state/signald defaults the zsh hook also
53 uses. Keep it that way: a spool the daemon and the hook disagree about
54 is a silently empty terminal metric. -->
55 <key>EnvironmentVariables</key>
56 <dict>
57 <key>HOME</key>
58 <string>__HOME__</string>
59 </dict>
60
61 <key>ProcessType</key>
62 <string>Background</string>
63</dict>
64</plist>