1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
|
/******************************************************************************
__ ____ __
/ / ___ ____ _/ __/_____________ _/ /__
/ / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \
/ /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/
/_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/
(C)opyright 2026, Leafscale, LLC - https://www.leafscale.com
Project: repoman
Filename: src/hermes.reef
Authors: Chris Tusa <chris.tusa@leafscale.com>
License: <see LICENSE file included with this source code>
Description: Hermes classify/paths helpers (library only; not wired to a CLI subcommand)
******************************************************************************/
module hermes
import core.str
import core.result_generic as rg
import core.convert as convert
import io.dir as iodir
import sys.process as p
import paths
export
fn state_dir_for(home_dir: string, container_name: string): string
fn default_seed_list(): [string]
fn classify_seed_entry(name: string): int
fn SEED_KIND_COPY(): int
fn SEED_KIND_SYMLINK(): int
fn seed_data_dir(source: string, dest: string, seed: [string]): rg.Result[bool, string]
fn purge_data_dir(dest: string): rg.Result[bool, string]
end export
// Layout under ~/.local/share/repoman/hermes/<container-name>/
fn state_dir_for(home_dir: string, container_name: string): string
let base: string = paths.join(home_dir, ".local/share/repoman/hermes")
return paths.join(base, container_name)
end state_dir_for
// Default selective-seed list: what to copy/symlink from the host's
// ~/.hermes/ into a per-container data dir. Per-instance state
// (sessions/, memories/, state.db, etc.) is *not* in this list.
fn default_seed_list(): [string]
return [
".env",
"config.yaml",
"SOUL.md",
"skills/",
"hooks/"
]
end default_seed_list
// Constants exposed to the applier so it knows whether to copy or symlink
// each seed entry.
fn SEED_KIND_COPY(): int
return 1
end SEED_KIND_COPY
fn SEED_KIND_SYMLINK(): int
return 2
end SEED_KIND_SYMLINK
// Classify a seed entry. v0.3 always returns COPY: an earlier design used
// SYMLINK for runtime dirs (hermes-agent/, node/, bin/) so host upgrades
// would auto-apply, but the symlinks can't resolve through the bind-mount
// boundary inside the container (FilesystemLoop). Spec O-3 anticipated
// this and named copy as the fallback. Cost: container recreation needed
// after host hermes upgrades.
fn classify_seed_entry(name: string): int
return SEED_KIND_COPY()
end classify_seed_entry
// Strip a trailing slash from a string ("hermes-agent/" → "hermes-agent").
// Used to normalize seed-list entries before paths.join.
fn strip_trailing_slash(s: string): string
let n: int = str.length(s)
if n > 0 and s[n - 1] == '/'
return str.substring(s, 0, n - 1)
end if
return s
end strip_trailing_slash
// Recursively copy `src` to `dst`. We don't have a built-in recursive copy
// in the reef stdlib, so we shell out to `cp -a`. argv-list spawn — never
// shell — so user-supplied paths can't escape.
fn cp_recursive(src: string, dst: string): bool
let pid: int = p.process_run("cp", ["-a", src, dst])
if pid < 0
return false
end if
return p.process_wait(pid) == 0
end cp_recursive
// Create a symlink at `link` pointing to `target` (absolute path).
fn make_symlink(target: string, link: string): bool
let pid: int = p.process_run("ln", ["-sfn", target, link])
if pid < 0
return false
end if
return p.process_wait(pid) == 0
end make_symlink
// Selectively seed `source` into `dest` per the seed list.
// Idempotent for COPY entries (cp -a overwrites); idempotent for
// SYMLINK entries (ln -sfn replaces existing links).
// Returns Err on the first failure with a message naming the offending entry.
fn seed_data_dir(source: string, dest: string, seed: [string]): rg.Result[bool, string]
if not iodir.dir_exists(source)
return @Result[bool, string].Err("hermes source dir not found: " + source)
end if
if not iodir.create_dir_all(dest)
return @Result[bool, string].Err("cannot create dest dir: " + dest)
end if
let n: int = seed.length()
mut i: int = 0
while i < n
let entry: string = seed[i]
let normalized: string = strip_trailing_slash(entry)
let src_path: string = paths.join(source, normalized)
let dst_path: string = paths.join(dest, normalized)
let kind: int = classify_seed_entry(entry)
if kind == SEED_KIND_COPY()
if not cp_recursive(src_path, dst_path)
return @Result[bool, string].Err("copy failed: " + entry)
end if
else
if not make_symlink(src_path, dst_path)
return @Result[bool, string].Err("symlink failed: " + entry)
end if
end if
i = i + 1
end while
return @Result[bool, string].Ok(true)
end seed_data_dir
// Recursively delete the per-container hermes data dir. Loud; only called
// from `repoman remove --purge-hermes`.
fn purge_data_dir(dest: string): rg.Result[bool, string]
if not iodir.dir_exists(dest)
return @Result[bool, string].Ok(true) // nothing to do
end if
let pid: int = p.process_run("rm", ["-rf", dest])
if pid < 0
return @Result[bool, string].Err("failed to spawn rm")
end if
let exit: int = p.process_wait(pid)
if exit == 0
return @Result[bool, string].Ok(true)
end if
return @Result[bool, string].Err("rm -rf exited " + convert.to_string(exit))
end purge_data_dir
end module
|