/****************************************************************************** __ ____ __ / / ___ ____ _/ __/_____________ _/ /__ / / / _ \/ __ `/ /_/ ___/ ___/ __ `/ / _ \ / /___/ __/ /_/ / __(__ ) /__/ /_/ / / __/ /_____/\___/\__,_/_/ /____/\___/\__,_/_/\___/ (C)opyright 2026, Leafscale, LLC - https://www.leafscale.com Project: repoman Filename: src/hermes.reef Authors: Chris Tusa License: 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// 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