repoman v0.4 — Profile library + scope trim Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Introduce the profile subcommand family backed by a vendor/user profile library, trim the v0.3 LLM-stack-specific surface from setup, and migrate the registry to schema 3.
Architecture: New src/profile.reef module owns profile lifecycle (lookup → render → install/remove/diff/show) with vendor profiles at /usr/local/share/repoman/profiles/ and user shadows at ~/.config/repoman/profiles.d/. Schema-3 [host].lan_ip field replaces [defaults].llm.ollama_url; the [defaults].llm block is removed entirely. setup shrinks from 4 stages to 2 (incus_project + registry_defaults); cmd_new gains pre-launch profile validation.
Tech Stack: reef-lang 0.5.20 (no new stdlib requirements), encoding.toml, core.result_generic, sys.process.process_spawn / process_run, io.dir.list_dir (new caller for an existing API), test.framework.TestRunner.
Reference: spec and source
- Design spec:
docs/superpowers/specs/2026-05-08-repoman-v0.4-profile-library.md(locked) - v0.3 plan (style/depth reference):
docs/superpowers/plans/2026-05-06-repoman-v0.3-llm-and-setup.md - Existing modules to mirror:
src/setup.reef— for module skeleton style + Environment/Stage struct patternsrc/incus.reef— forprocess_run_capture/process_run_silentpatternssrc/config.reef— for parse/serialize/migrate idiom (T1–T6 of v0.3 plan are direct precedent for schema migrations)
- Reef stdlib API references already verified for v0.4:
core.str.replace(s, old, new)— global replaceio.dir.list_dir(path: string, entries: [string], max: int): int— populates buffer, returns countio.dir.dir_exists,io.dir.create_dir_all— already in useio.file.fileExists,io.file.readFile,io.file.writeFile— already in usecore.str.starts_with,str.ends_with,str.substring,str.length,str.contains— all already usedsys.env.get_env_or— already in use
File structure
~/repos/repoman/
├── reef.toml # bump version 0.3.0 → 0.4.0
├── README.md # add ## Profile library + ## Migrating from v0.3 sections
├── VISION.md # update setup row (no --with-llm); add profile management row
├── Makefile # install profiles/*.yml to /usr/local/share/repoman/profiles/
├── profiles/ # NEW vendor library directory
│ ├── claude-share.yml
│ ├── llm-share.yml
│ └── dotfiles.yml
├── src/
│ ├── cli.reef # +cmd_profile dispatch, +pre-launch validation, -setup --with-llm flags
│ ├── config.reef # +Host substruct, -LlmDefaults, schema 2→3 migration
│ ├── hermes.reef # (unchanged) library-only, kept from v0.3
│ ├── incus.reef # +profile_get (capture stdout)
│ ├── log.reef # (unchanged)
│ ├── main.reef # (unchanged)
│ ├── paths.reef # (unchanged)
│ ├── profile.reef # NEW
│ ├── setup.reef # trim Environment, drop llm helpers, shrink plan/apply
│ └── sync.reef # (unchanged)
└── tests/
├── test_config_schema_v3.reef # NEW — schema 3 acceptance + serialize round-trip
├── test_config_migrate.reef # NEW — v1→v3 and v2-with-llm→v3 migration
├── test_profile_paths.reef # NEW — vendor_dir, user_dir
├── test_profile_render.reef # NEW — ${VAR} substitution
└── test_*.reef # existing tests; some updated, some deleted
Module-boundary rules (carried from v0.1):
- Each module file declares
module <name>matching its filename, ends withend module. main.reefhas no module declaration.- Tests are standalone reef programs each with their own
proc main(). make testruns everytests/test_*.reef, stops on first failure.
Task 1: Host substruct + drop LlmDefaults
Files:
- Modify:
src/config.reef(export block, type definitions, Defaults literal sites)
Defaults loses its llm: LlmDefaults field. Registry gains a host: Host field. LlmDefaults type is removed.
- Step 1: Read the current Defaults / Registry / LlmDefaults declarations
sed -n '11,80p' src/config.reef
Expected: LlmDefaults type definition and llm: LlmDefaults field in Defaults.
- Step 2: Add Host type and Registry.host field; drop LlmDefaults type and Defaults.llm field
In src/config.reef's export block, remove the line type LlmDefaults and add type Host:
export
type Defaults
type Host
type Project
type Override
type Mount
type Registry
type EffectiveConfig
...
end export
Add a new type definition above Defaults:
type Host = struct
lan_ip: string
end Host
Remove the entire type LlmDefaults = struct ... end LlmDefaults block.
In Defaults, remove the llm: LlmDefaults field. The struct goes back to its v0.2 shape:
type Defaults = struct
repos_root: string
backup_root: string
logdir: string
incus_project: string
default_image: string
profiles: [string]
end Defaults
In Registry, add a host: Host field as the second field (after schema, before output):
type Registry = struct
schema: int
host: Host
output: string
defaults: Defaults
projects: [Project]
end Registry
- Step 3: Update existing Defaults/Registry construction sites — remove
llm: LlmDefaults {...}blocks, addhost: Host {...}
Sites today (per v0.3 + revert state):
parse_registry(around line 153):Defaults { ..., llm: LlmDefaults {...} }. Drop thellm:line.default_registry(around line 434): same.- Any test that builds a
Defaultsliteral:tests/test_config_serialize.reef,tests/test_config_roundtrip.reef,tests/test_config_merge.reef,tests/test_config_mutate.reef— dropllm:field.
Then add host: Host { lan_ip: "" } to every Registry literal:
parse_registry(the final return literal)default_registry- Tests that build a
Registryliteral directly
For parse_registry's final literal, change:
let reg: Registry = Registry {
schema: 2,
output: output,
defaults: defaults,
projects: projects
}
to:
let host: Host = Host {
lan_ip: "" // populated below for schema 2 migration; left empty otherwise
}
let reg: Registry = Registry {
schema: 2,
host: host,
output: output,
defaults: defaults,
projects: projects
}
(The host.lan_ip value will be properly populated by the schema-2 migration logic in T2; this task just adds the field with a default of empty string.)
For default_registry:
return Registry {
schema: 2,
host: Host { lan_ip: "" },
output: "quiet",
defaults: Defaults { ... }, // no llm field
projects: new [Project](0)
}
(Schema constant stays at 2 in this task; T5 bumps to 3.)
- Step 4: Run existing tests; expect compile failures in test files that reference LlmDefaults or
defaults.llm
make test
Expected: compile errors on tests that use LlmDefaults or reg.defaults.llm. These tests are about to be deleted (T7) but for now patch them minimally to compile:
tests/test_config_serialize.reef,test_config_roundtrip.reef,test_config_merge.reef,test_config_mutate.reef— dropllm:field fromDefaultsliterals; addhost: Host { lan_ip: "" }toRegistryliterals.
Run grep -rln 'LlmDefaults\|defaults.llm' src/ tests/ to find every site.
- Step 5: Build clean and re-run tests
make build && make test
Expected: clean build. Some tests may still fail because parse/serialize haven't been updated yet (T2/T3); that's fine for now. The build must compile.
- Step 6: Commit
hg add src/config.reef tests/
hg commit -m "config: add Host substruct on Registry, drop LlmDefaults (schema unchanged)"
Task 2: parse_registry reads [host].lan_ip and migrates from schema 2's [defaults.llm.ollama_url]
Files:
-
Modify:
src/config.reef(parse_registry function) -
Step 1: Add a private helper
extract_lan_ip_from_url
Inside src/config.reef, before parse_registry, add:
// Extract the host portion of an `http://<host>:<port>` URL string.
// Returns "" if the input doesn't match that shape.
fn extract_lan_ip_from_url(url: string): string
let n: int = str.length(url)
if n == 0
return ""
end if
let prefix: string = "http://"
let prefix_len: int = 7
if not str.starts_with(url, prefix)
return ""
end if
// Find ':' after the prefix
mut i: int = prefix_len
while i < n and url[i] != ':'
i = i + 1
end while
if i <= prefix_len
return ""
end if
return str.substring(url, prefix_len, i - prefix_len)
end extract_lan_ip_from_url
- Step 2: Modify
parse_registryto read[host].lan_ip(schema 3) or extract from[defaults.llm.ollama_url](schema 2 migration)
In parse_registry, find the schema check (currently if schema != 1 and schema != 2). Update to accept schema 3:
if schema != 1 and schema != 2 and schema != 3
return @Result[Registry, string].Err("unsupported schema (expected 1, 2, or 3)")
end if
Right before the final Registry { ... } literal (where Task 1 added host: host), populate the host value:
// [host].lan_ip — schema 3 reads it directly; schema 2 migration extracts from
// the now-removed [defaults.llm.ollama_url]; schema 1 has no such value (empty).
mut lan_ip: string = toml.toml_get_doc(doc, "host.lan_ip")
if str.length(lan_ip) == 0
// Fall back: schema 2 migration via [defaults.llm.ollama_url]
let old_url: string = toml.toml_get_doc(doc, "defaults.llm.ollama_url")
if str.length(old_url) > 0
lan_ip = extract_lan_ip_from_url(old_url)
end if
end if
let host: Host = Host {
lan_ip: lan_ip
}
Replace Task 1's placeholder let host: Host = Host { lan_ip: "" } with this block.
- Step 3: Build to verify
make build
Expected: clean build.
- Step 4: Commit
hg add src/config.reef
hg commit -m "config: parse_registry accepts schema 3 + migrates lan_ip from schema 2 ollama_url"
Task 3: serialize_registry writes [host] block; never writes [defaults.llm]
Files:
- Modify:
src/config.reef(serialize_registry function)
The serializer is currently writing [defaults.llm] (per v0.3 T4). Remove that, add [host].
- Step 1: Read the current serialize_registry
grep -n 'fn serialize_registry\|toml.toml_begin_table\|defaults.llm' src/config.reef
Identify the toml_begin_table(b, "defaults.llm") block and the surrounding section.
- Step 2: Remove
[defaults.llm]writes; add[host]write
In serialize_registry, after toml_set_string(b, "output", reg.output) (the [repoman] block) and BEFORE toml_begin_table(b, "defaults"), insert the [host] block:
toml.toml_begin_table(b, "host")
toml.toml_set_string(b, "lan_ip", reg.host.lan_ip)
In the [defaults] section, remove the entire [defaults.llm] block:
// DELETE these lines:
toml.toml_begin_table(b, "defaults.llm")
toml.toml_set_bool(b, "enabled", reg.defaults.llm.enabled)
toml.toml_set_bool(b, "hermes_default", reg.defaults.llm.hermes_default)
toml.toml_set_string(b, "ollama_url", reg.defaults.llm.ollama_url)
toml.toml_set_string_array(b, "hermes_seed", reg.defaults.llm.hermes_seed)
The [defaults] body retains all other field writes (repos_root, backup_root, logdir, incus_project, default_image, profiles).
- Step 3: Build to verify
make build
Expected: clean build.
- Step 4: Commit
hg add src/config.reef
hg commit -m "config: serialize_registry emits [host] block, drops [defaults.llm]"
Task 4: Force in-memory schema to 3 in parse_registry
Files:
- Modify:
src/config.reef(parse_registry's final Registry literal)
Same pattern as v0.3's T5: any registry, regardless of disk format, becomes schema 3 in memory.
- Step 1: Update parse_registry's final literal
In parse_registry, find the final Registry literal (currently schema: 2,):
let reg: Registry = Registry {
schema: 2,
host: host,
output: output,
defaults: defaults,
projects: projects
}
Change schema: 2, to schema: 3,.
- Step 2: Update existing test assertions about post-parse schema
grep -rn 'reg.schema, 2\|.schema, 2\|reg2.schema, 2' tests/ to find tests asserting schema=2 after parse. Update to 3.
Likely sites: tests/test_config_parse.reef, tests/test_config_roundtrip.reef, tests/test_config_save.reef, tests/test_config_migrate_v1.reef. Also possibly test_config_io.reef for the parse-not-init path.
For each, change assertions like:
runner.assert_eq_int(reg.schema, 2, "schema after parse = 2")
to:
runner.assert_eq_int(reg.schema, 3, "schema after parse = 3 (migrated)")
Don't change tests that build a Registry literal directly with schema: 2 and serialize without parsing (those are testing serializer fidelity at the input schema, and still pass).
- Step 3: Run all tests
make test
Expected: all suites pass. Some tests will need additional updates due to LlmDefaults removal (already done in T1's step 4). If any still fail, they'll likely be in test_config_llm_parse.reef or test_config_llm_serialize.reef — both will be deleted in T7. For now, comment out their failing assertions or skip the test.
- Step 4: Commit
hg add src/config.reef tests/
hg commit -m "config: in-memory schema always 3 after parse (v1/v2 migrate implicitly)"
Task 5: default_registry returns schema 3
Files:
-
Modify:
src/config.reef(default_registry function) -
Tests:
tests/test_config_io.reef(init-path schema assertion) -
Step 1: Update default_registry
In src/config.reef, find:
fn default_registry(home_dir: string): Registry
...
return Registry {
schema: 2,
host: Host { lan_ip: "" },
...
}
end default_registry
Change schema: 2, to schema: 3,.
- Step 2: Update test_config_io.reef's init-path assertion
grep -n 'schema' tests/test_config_io.reef. The init path (where load_or_init is called against an empty temp dir) currently asserts reg.schema == 2. Change to 3.
- Step 3: Run tests
make test
Expected: all green (modulo the to-be-deleted tests).
- Step 4: Commit
hg add src/config.reef tests/test_config_io.reef
hg commit -m "config: default_registry writes schema 3"
Task 6: Schema 3 acceptance test + migration test
Files:
-
Test:
tests/test_config_schema_v3.reef(new) -
Test:
tests/test_config_migrate.reef(new) -
Step 1: Write test_config_schema_v3.reef — schema 3 round-trip
Create tests/test_config_schema_v3.reef:
import config
import test.framework
import core.result_generic as rg
proc main()
let runner = new framework.TestRunner()
// Build a schema-3 registry, serialize, parse round-trip
let reg = config.Registry {
schema: 3,
host: config.Host { lan_ip: "192.168.168.124" },
output: "quiet",
defaults: config.Defaults {
repos_root: "~/repos",
backup_root: "/nfs/repos",
logdir: "~/.local/state/repoman",
incus_project: "repoman",
default_image: "images:ubuntu/26.04/cloud",
profiles: ["default"]
},
projects: new [config.Project](0)
}
let s = config.serialize_registry(reg)
runner.assert_contains_string(s, "schema = 3", "writes schema = 3")
runner.assert_contains_string(s, "[host]", "writes [host] block")
runner.assert_contains_string(s, "lan_ip = \"192.168.168.124\"", "writes lan_ip")
let neg = str.contains(s, "[defaults.llm]")
runner.assert_eq_bool(neg, false, "does NOT write [defaults.llm]")
let r2 = config.parse_registry(s)
runner.assert_eq_bool(rg.is_ok(r2), true, "round-trip parses ok")
if rg.is_ok(r2)
let reg2 = rg.unwrap_ok(r2)
runner.assert_eq_int(reg2.schema, 3, "round-trip schema = 3")
runner.assert_eq_string(reg2.host.lan_ip, "192.168.168.124", "round-trip host.lan_ip")
end if
runner.report()
end main
(Note: import core.str for str.contains if needed — check tests/test_config_llm_serialize.reef for the import pattern.)
- Step 2: Write test_config_migrate.reef — v1→v3 and v2→v3 migration
Create tests/test_config_migrate.reef:
import config
import test.framework
import core.result_generic as rg
proc main()
let runner = new framework.TestRunner()
// v1 (no llm block, no host block) → v3 with empty lan_ip
let v1: string = "[repoman]\nschema = 1\noutput = \"quiet\"\n\n[defaults]\nrepos_root = \"~/repos\"\nbackup_root = \"/nfs/repos\"\nlogdir = \"~/.local/state/repoman\"\nincus_project = \"repoman\"\ndefault_image = \"images:ubuntu/26.04/cloud\"\nprofiles = [\"default\", \"claude-share\"]\n"
let r1 = config.parse_registry(v1)
runner.assert_eq_bool(rg.is_ok(r1), true, "v1 parses ok")
if rg.is_ok(r1)
let reg = rg.unwrap_ok(r1)
runner.assert_eq_int(reg.schema, 3, "v1 schema migrates to 3")
runner.assert_eq_string(reg.host.lan_ip, "", "v1 has no lan_ip")
end if
// v2 with [defaults.llm.ollama_url] → v3 with extracted lan_ip
let v2: string = "[repoman]\nschema = 2\noutput = \"quiet\"\n\n[defaults]\nrepos_root = \"~/repos\"\nbackup_root = \"/nfs/repos\"\nlogdir = \"~/.local/state/repoman\"\nincus_project = \"repoman\"\ndefault_image = \"images:ubuntu/26.04/cloud\"\nprofiles = [\"default\"]\n\n[defaults.llm]\nenabled = true\nhermes_default = false\nollama_url = \"http://192.168.168.124:11434\"\nhermes_seed = []\n"
let r2 = config.parse_registry(v2)
runner.assert_eq_bool(rg.is_ok(r2), true, "v2 parses ok")
if rg.is_ok(r2)
let reg = rg.unwrap_ok(r2)
runner.assert_eq_int(reg.schema, 3, "v2 schema migrates to 3")
runner.assert_eq_string(reg.host.lan_ip, "192.168.168.124", "v2 lan_ip extracted from ollama_url")
end if
// v2 without [defaults.llm.ollama_url] (LLM was disabled) → v3 with empty lan_ip
let v2_no_llm: string = "[repoman]\nschema = 2\noutput = \"quiet\"\n\n[defaults]\nrepos_root = \"~/repos\"\nbackup_root = \"/nfs/repos\"\nlogdir = \"~/.local/state/repoman\"\nincus_project = \"repoman\"\ndefault_image = \"images:ubuntu/26.04/cloud\"\nprofiles = [\"default\"]\n"
let r3 = config.parse_registry(v2_no_llm)
runner.assert_eq_bool(rg.is_ok(r3), true, "v2 without llm block parses ok")
if rg.is_ok(r3)
let reg = rg.unwrap_ok(r3)
runner.assert_eq_string(reg.host.lan_ip, "", "v2 without ollama_url has empty lan_ip")
end if
// v3 native (with [host].lan_ip) round-trip
let v3: string = "[repoman]\nschema = 3\noutput = \"quiet\"\n\n[host]\nlan_ip = \"10.0.0.5\"\n\n[defaults]\nrepos_root = \"~/repos\"\nbackup_root = \"/nfs/repos\"\nlogdir = \"~/.local/state/repoman\"\nincus_project = \"repoman\"\ndefault_image = \"images:ubuntu/26.04/cloud\"\nprofiles = [\"default\"]\n"
let r4 = config.parse_registry(v3)
runner.assert_eq_bool(rg.is_ok(r4), true, "v3 parses ok")
if rg.is_ok(r4)
let reg = rg.unwrap_ok(r4)
runner.assert_eq_string(reg.host.lan_ip, "10.0.0.5", "v3 native lan_ip read directly")
end if
runner.report()
end main
- Step 3: Run the new tests
reefc run tests/test_config_schema_v3.reef
reefc run tests/test_config_migrate.reef
Expected: all assertions pass.
- Step 4: Run full test suite for regression check
make test
Expected: all green (modulo the to-be-deleted tests in T7).
- Step 5: Commit
hg add tests/test_config_schema_v3.reef tests/test_config_migrate.reef
hg commit -m "config: tests for schema 3 round-trip and v1/v2 migration"
Task 7: Delete v0.3-only LLM-block tests
Files:
- Delete:
tests/test_config_llm_parse.reef - Delete:
tests/test_config_llm_serialize.reef - Delete:
tests/test_config_migrate_v1.reef
After T6's new tests, these v0.3-era tests are superseded:
-
test_config_llm_parse.reef— tested parsing of[defaults.llm]block which no longer exists -
test_config_llm_serialize.reef— tested serializing the same -
test_config_migrate_v1.reef— tested v1→v2 migration; T6'stest_config_migrate.reefcovers v1→v3 and v2→v3 -
Step 1: Delete the three test files
hg rm tests/test_config_llm_parse.reef
hg rm tests/test_config_llm_serialize.reef
hg rm tests/test_config_migrate_v1.reef
- Step 2: Run all tests to confirm clean state
make test
Expected: clean. The remaining test count is lower than v0.3.
- Step 3: Commit
hg commit -m "tests: drop v0.3 [defaults.llm] tests; superseded by schema_v3 + migrate tests"
Task 8: incus.profile_get — capture-mode wrapper for incus profile show
Files:
-
Modify:
src/incus.reef(export block + new function) -
Step 1: Add to export block
In src/incus.reef's export block, add:
fn profile_get(project: string, name: string): rg.Result[string, string]
- Step 2: Implement profile_get
After the existing profile_create_or_edit function, add:
// Returns the YAML body of the named profile (the same output as
// `incus profile show <project> <name>`). Errors if the profile doesn't
// exist or the subprocess fails.
fn profile_get(project: string, name: string): rg.Result[string, string]
let cap: CaptureResult = process_run_capture("incus", [
"profile", "show", "--project", project, name
])
if cap.exit_code != 0
return @Result[string, string].Err("incus profile show exited " + convert.to_string(cap.exit_code))
end if
return @Result[string, string].Ok(cap.stdout)
end profile_get
- Step 3: Build
make build
Expected: clean build.
- Step 4: Commit
hg add src/incus.reef
hg commit -m "incus: add profile_get (capture incus profile show stdout)"
Task 9: profile.reef skeleton + types
Files:
-
Create:
src/profile.reef -
Step 1: Create the module skeleton
Create src/profile.reef:
module profile
import core.str
import core.result_generic as rg
import core.convert as convert
import io.file as iofile
import io.dir as iodir
import sys.env
import paths
import incus
export
type ProfileEntry
type HostFacts
fn vendor_dir(): string
fn user_dir(home_dir: string): string
fn render(yaml: string, host: HostFacts): string
fn lookup(name: string, home_dir: string): rg.Result[string, string]
fn list_all(home_dir: string): [ProfileEntry]
fn install(name: string, home_dir: string, host: HostFacts): rg.Result[bool, string]
fn remove_profile(name: string): rg.Result[bool, string]
fn show(name: string, home_dir: string, host: HostFacts): rg.Result[string, string]
fn diff(name: string, home_dir: string, host: HostFacts): rg.Result[string, string]
end export
// One entry from the profile library, populated by list_all.
type ProfileEntry = struct
name: string // e.g., "claude-share"
source: string // "user", "vendor", or "user (shadows vendor)"
file_path: string // resolved file path
installed: bool // present in incus state
end ProfileEntry
// Substitution context for templated profile YAML.
type HostFacts = struct
lan_ip: string
user: string
home: string
end HostFacts
// (function bodies follow in subsequent tasks)
fn vendor_dir(): string
return ""
end vendor_dir
fn user_dir(home_dir: string): string
return ""
end user_dir
fn render(yaml: string, host: HostFacts): string
return yaml
end render
fn lookup(name: string, home_dir: string): rg.Result[string, string]
return @Result[string, string].Err("not implemented")
end lookup
fn list_all(home_dir: string): [ProfileEntry]
return new [ProfileEntry](0)
end list_all
fn install(name: string, home_dir: string, host: HostFacts): rg.Result[bool, string]
return @Result[bool, string].Err("not implemented")
end install
fn remove_profile(name: string): rg.Result[bool, string]
return @Result[bool, string].Err("not implemented")
end remove_profile
fn show(name: string, home_dir: string, host: HostFacts): rg.Result[string, string]
return @Result[string, string].Err("not implemented")
end show
fn diff(name: string, home_dir: string, host: HostFacts): rg.Result[string, string]
return @Result[string, string].Err("not implemented")
end diff
end module
(Note: function name is remove_profile not remove — remove would conflict with the existing reef stdlib. Caller in cli.reef uses profile.remove_profile.)
- Step 2: Build
make build
Expected: clean build (stubs compile).
- Step 3: Commit
hg add src/profile.reef
hg commit -m "profile: module skeleton with types and stubbed entry points"
Task 10: profile.vendor_dir, profile.user_dir, profile.render + tests
Files:
-
Modify:
src/profile.reef -
Test:
tests/test_profile_paths.reef -
Test:
tests/test_profile_render.reef -
Step 1: Write failing test for vendor_dir / user_dir
Create tests/test_profile_paths.reef:
import profile
import test.framework
proc main()
let runner = new framework.TestRunner()
runner.assert_eq_string(
profile.vendor_dir(),
"/usr/local/share/repoman/profiles",
"vendor_dir is the install layout"
)
runner.assert_eq_string(
profile.user_dir("/home/ctusa"),
"/home/ctusa/.config/repoman/profiles.d",
"user_dir under XDG config"
)
runner.assert_eq_string(
profile.user_dir(""),
"/.config/repoman/profiles.d",
"user_dir with empty home (still composes path)"
)
runner.report()
end main
- Step 2: Run test, see failure
reefc run tests/test_profile_paths.reef
Expected: 3 assertion failures (stub returns "").
- Step 3: Implement vendor_dir + user_dir
In src/profile.reef, replace the stubs:
fn vendor_dir(): string
return "/usr/local/share/repoman/profiles"
end vendor_dir
fn user_dir(home_dir: string): string
return paths.join(home_dir, ".config/repoman/profiles.d")
end user_dir
- Step 4: Run test, expect pass
reefc run tests/test_profile_paths.reef
Expected: 3/3 pass.
- Step 5: Write failing test for render
Create tests/test_profile_render.reef:
import profile
import test.framework
proc main()
let runner = new framework.TestRunner()
let host = profile.HostFacts {
lan_ip: "192.168.168.124",
user: "ctusa",
home: "/home/ctusa"
}
// All three substitutions
let yaml: string = "config:\n environment.OLLAMA_HOST: \"http://${HOST_LAN_IP}:11434\"\ndevices:\n state:\n source: ${HOME}/.ollama\n user: ${USER}\n"
let out = profile.render(yaml, host)
runner.assert_contains_string(out, "http://192.168.168.124:11434", "${HOST_LAN_IP} substituted")
runner.assert_contains_string(out, "/home/ctusa/.ollama", "${HOME} substituted")
runner.assert_contains_string(out, "user: ctusa", "${USER} substituted")
runner.assert_eq_bool(false, str.contains(out, "${HOST_LAN_IP}"), "no leftover ${HOST_LAN_IP}")
runner.assert_eq_bool(false, str.contains(out, "${USER}"), "no leftover ${USER}")
runner.assert_eq_bool(false, str.contains(out, "${HOME}"), "no leftover ${HOME}")
// No substitutions present — input is returned unchanged
let plain: string = "name: foo\nconfig: {}\n"
runner.assert_eq_string(profile.render(plain, host), plain, "no-substitution input passes through")
// Empty input
runner.assert_eq_string(profile.render("", host), "", "empty input")
runner.report()
end main
(Add import core.str at the top — str.contains requires it.)
- Step 6: Run, see failure
reefc run tests/test_profile_render.reef
Expected: assertion failures (stub returns input unchanged, but the substitution-required assertions fail).
- Step 7: Implement render
Replace the stub:
fn render(yaml: string, host: HostFacts): string
let s1: string = str.replace(yaml, "${HOST_LAN_IP}", host.lan_ip)
let s2: string = str.replace(s1, "${USER}", host.user)
let s3: string = str.replace(s2, "${HOME}", host.home)
return s3
end render
- Step 8: Run tests
reefc run tests/test_profile_render.reef
make test
Expected: render test 9/9, full suite green.
- Step 9: Commit
hg add src/profile.reef tests/test_profile_paths.reef tests/test_profile_render.reef
hg commit -m "profile: vendor_dir, user_dir, render — pure helpers + tests"
Task 11: profile.lookup — search user dir then vendor dir
Files:
-
Modify:
src/profile.reef(replace lookup stub) -
Step 1: Implement lookup
Replace the stub fn lookup(...):
// Search user dir first, then vendor dir. Returns the path to the YAML file.
// User shadows vendor: a file in user dir wins.
fn lookup(name: string, home_dir: string): rg.Result[string, string]
let user_path: string = paths.join(user_dir(home_dir), name + ".yml")
if iofile.fileExists(user_path)
return @Result[string, string].Ok(user_path)
end if
let vendor_path: string = paths.join(vendor_dir(), name + ".yml")
if iofile.fileExists(vendor_path)
return @Result[string, string].Ok(vendor_path)
end if
return @Result[string, string].Err("profile not found: " + name + " (looked in " + user_path + " and " + vendor_path + ")")
end lookup
- Step 2: Build
make build
Expected: clean build.
- Step 3: Smoke-test by inspection
Create a temp dir + file:
mkdir -p /tmp/repoman-lookup-test/.config/repoman/profiles.d
echo 'name: test' > /tmp/repoman-lookup-test/.config/repoman/profiles.d/test.yml
cat > /tmp/test_lookup.reef <<'EOF'
import profile
import core.result_generic as rg
import io.console as console
proc main()
let r = profile.lookup("test", "/tmp/repoman-lookup-test")
if rg.is_ok(r)
console.print("found: " + rg.unwrap_ok(r) + "\n")
else
console.print("err: " + rg.unwrap_err(r) + "\n")
end if
EOF
reefc run /tmp/test_lookup.reef
Expected: found: /tmp/repoman-lookup-test/.config/repoman/profiles.d/test.yml.
(If console.print doesn't compile, use println instead — same pattern as the v0.3 setup smoke test.)
- Step 4: Commit
hg add src/profile.reef
hg commit -m "profile: lookup — user-shadows-vendor file resolution"
Task 12: profile.list_all — enumerate both dirs with shadow + install status
Files:
-
Modify:
src/profile.reef(replace list_all stub) -
Step 1: Implement list_all
Replace the stub. The function enumerates user dir + vendor dir, builds a name → ProfileEntry map (user shadows vendor), and queries incus for install status.
fn list_all(home_dir: string): [ProfileEntry]
// Buffers for filename lists. Cap at 256 per dir; profile libraries
// are not expected to be enormous.
let max_files: int = 256
mut user_buf: [string] = new [string](max_files)
mut vendor_buf: [string] = new [string](max_files)
let u_dir: string = user_dir(home_dir)
mut user_count: int = 0
if iodir.dir_exists(u_dir)
user_count = iodir.list_dir(u_dir, user_buf, max_files)
end if
let v_dir: string = vendor_dir()
mut vendor_count: int = 0
if iodir.dir_exists(v_dir)
vendor_count = iodir.list_dir(v_dir, vendor_buf, max_files)
end if
// Pre-allocate result buffer at worst-case size.
let cap: int = user_count + vendor_count
mut entries_buf: [ProfileEntry] = new [ProfileEntry](cap)
mut e_count: int = 0
// Pass 1: add all user-dir *.yml files
mut i: int = 0
while i < user_count
let fname: string = user_buf[i]
if str.ends_with(fname, ".yml")
let name: string = str.substring(fname, 0, str.length(fname) - 4)
let path: string = paths.join(u_dir, fname)
let installed_r = incus.profile_exists("default", name)
mut installed: bool = false
if rg.is_ok(installed_r)
installed = rg.unwrap_ok(installed_r)
end if
entries_buf[e_count] = ProfileEntry {
name: name,
source: "user",
file_path: path,
installed: installed
}
e_count = e_count + 1
end if
i = i + 1
end while
// Pass 2: add vendor-dir *.yml files NOT already present (so user wins)
mut j: int = 0
while j < vendor_count
let fname: string = vendor_buf[j]
if str.ends_with(fname, ".yml")
let name: string = str.substring(fname, 0, str.length(fname) - 4)
// Check if name already in entries_buf
mut shadowed: bool = false
mut k: int = 0
while k < e_count
if entries_buf[k].name == name
shadowed = true
// Update the existing user entry's source label to indicate shadow
entries_buf[k] = ProfileEntry {
name: entries_buf[k].name,
source: "user (shadows vendor)",
file_path: entries_buf[k].file_path,
installed: entries_buf[k].installed
}
end if
k = k + 1
end while
if not shadowed
let path: string = paths.join(v_dir, fname)
let installed_r = incus.profile_exists("default", name)
mut installed: bool = false
if rg.is_ok(installed_r)
installed = rg.unwrap_ok(installed_r)
end if
entries_buf[e_count] = ProfileEntry {
name: name,
source: "vendor",
file_path: path,
installed: installed
}
e_count = e_count + 1
end if
end if
j = j + 1
end while
// Compact to actual size
mut entries: [ProfileEntry] = new [ProfileEntry](e_count)
mut m: int = 0
while m < e_count
entries[m] = entries_buf[m]
m = m + 1
end while
return entries
end list_all
- Step 2: Build
make build
Expected: clean build.
- Step 3: Commit
hg add src/profile.reef
hg commit -m "profile: list_all — enumerate user + vendor dirs with shadow handling"
Task 13: profile.install — render + apply via incus.profile_create_or_edit
Files:
-
Modify:
src/profile.reef(replace install stub) -
Step 1: Implement install
Replace the stub:
fn install(name: string, home_dir: string, host: HostFacts): rg.Result[bool, string]
// Resolve the file (user or vendor)
let path_r = lookup(name, home_dir)
if rg.is_err(path_r)
return @Result[bool, string].Err(rg.unwrap_err(path_r))
end if
let path: string = rg.unwrap_ok(path_r)
// Read the file
let yaml: string = iofile.readFile(path)
if str.length(yaml) == 0
return @Result[bool, string].Err("empty or unreadable profile file: " + path)
end if
// Render — but if HOST_LAN_IP is needed and host.lan_ip is empty, fail with a hint
if str.contains(yaml, "${HOST_LAN_IP}") and str.length(host.lan_ip) == 0
return @Result[bool, string].Err("profile " + name + " requires ${HOST_LAN_IP} but [host].lan_ip is empty in registry. Run 'repoman setup' to detect and store the host LAN IP.")
end if
let rendered: string = render(yaml, host)
// Apply via incus
return incus.profile_create_or_edit("default", name, rendered)
end install
- Step 2: Build
make build
Expected: clean build.
- Step 3: Commit
hg add src/profile.reef
hg commit -m "profile: install — resolve, render, apply via incus profile_create_or_edit"
Task 14: profile.remove_profile and profile.show
Files:
-
Modify:
src/profile.reef -
Step 1: Implement remove_profile and show
Replace the stubs:
fn remove_profile(name: string): rg.Result[bool, string]
let pid: int = process_run_silent_via_incus(name) // helper below
// Note: we use incus.run_incus directly for clarity here.
return incus_delete_profile(name)
end remove_profile
Actually, simpler: avoid the indirection. Replace with a direct call:
fn remove_profile(name: string): rg.Result[bool, string]
return incus_run_remove(name)
end remove_profile
Hmm, neither is cleanest. Use the existing incus public surface or extend it.
Let me reconsider — src/incus.reef already has delete_container. Add a sibling delete_profile:
(This step requires a small addition in src/incus.reef. Take this detour now.)
In src/incus.reef, add to the export block:
fn delete_profile(project: string, name: string): rg.Result[bool, string]
And add the function near delete_container:
fn delete_profile(project: string, name: string): rg.Result[bool, string]
return run_incus(["profile", "delete", "--project", project, name])
end delete_profile
Now back to src/profile.reef:
fn remove_profile(name: string): rg.Result[bool, string]
return incus.delete_profile("default", name)
end remove_profile
fn show(name: string, home_dir: string, host: HostFacts): rg.Result[string, string]
let path_r = lookup(name, home_dir)
if rg.is_err(path_r)
return @Result[string, string].Err(rg.unwrap_err(path_r))
end if
let path: string = rg.unwrap_ok(path_r)
let yaml: string = iofile.readFile(path)
let rendered: string = render(yaml, host)
return @Result[string, string].Ok(rendered)
end show
- Step 2: Build
make build
Expected: clean build.
- Step 3: Commit
hg add src/profile.reef src/incus.reef
hg commit -m "profile+incus: remove_profile, show; add incus.delete_profile wrapper"
Task 15: profile.diff — render file vs incus state
Files:
-
Modify:
src/profile.reef -
Step 1: Implement diff
Replace the stub:
// Compute and return a string describing differences between the rendered
// file and the live incus profile state. v0.4 uses a simple line-by-line
// presentation: " same line" / "- removed line" / "+ added line". A more
// sophisticated unified diff is out of scope; the use case is "show me
// what's drifted" not "produce a patch."
fn diff(name: string, home_dir: string, host: HostFacts): rg.Result[string, string]
// Render the file
let path_r = lookup(name, home_dir)
if rg.is_err(path_r)
return @Result[string, string].Err(rg.unwrap_err(path_r))
end if
let path: string = rg.unwrap_ok(path_r)
let yaml: string = iofile.readFile(path)
let rendered: string = render(yaml, host)
// Fetch incus state
let incus_r = incus.profile_get("default", name)
if rg.is_err(incus_r)
// Profile not installed — diff shows the rendered file as "would install"
let msg: string = "profile " + name + " is not installed. Rendered would-be content:\n\n" + rendered
return @Result[string, string].Ok(msg)
end if
let live: string = rg.unwrap_ok(incus_r)
if rendered == live
return @Result[string, string].Ok("(no drift)")
end if
// Simple line-mark diff: file lines marked '-', incus lines marked '+'.
// This is not a unified diff; it's a side-by-side hint.
let header: string = "--- profile file (rendered): " + path + "\n+++ incus state\n"
let body: string = "-- file --\n" + rendered + "\n-- incus --\n" + live
return @Result[string, string].Ok(header + body)
end diff
- Step 2: Build
make build
Expected: clean build.
- Step 3: Commit
hg add src/profile.reef
hg commit -m "profile: diff — show rendered file vs live incus state"
Task 16: Trim setup.reef part 1 — Environment struct, detect_environment, drop helpers
Files:
-
Modify:
src/setup.reef -
Delete:
tests/test_setup_template.reef -
Step 1: Trim Environment struct
In src/setup.reef, change:
type Environment = struct
home_dir: string
user: string
host_lan_ip: string
incus_reachable: bool
repoman_project_present: bool
claude_share_present: bool
ollama_binary: string
ollama_lan_ok: bool
hermes_binary: string
hermes_data_present: bool
end Environment
to:
type Environment = struct
home_dir: string
user: string
host_lan_ip: string
incus_reachable: bool
repoman_project_present: bool
end Environment
- Step 2: Trim detect_environment
Replace the body to only populate the 5 fields:
fn detect_environment(home_dir: string): Environment
let user: string = env.get_env_or("USER", "")
let lan_ip: string = detect_host_lan_ip()
// incus reachable?
let incus_pid: int = incus.process_run_silent("incus", ["version"])
mut incus_ok: bool = false
if incus_pid >= 0
incus_ok = p.process_wait(incus_pid) == 0
end if
// repoman project (only if incus is up)
mut project_ok: bool = false
if incus_ok
let pe = incus.project_present("repoman")
if rg.is_ok(pe)
project_ok = rg.unwrap_ok(pe)
end if
end if
return Environment {
home_dir: home_dir,
user: user,
host_lan_ip: lan_ip,
incus_reachable: incus_ok,
repoman_project_present: project_ok
}
end detect_environment
- Step 3: Remove helper functions which_binary, ollama_listening_at
Both helpers are no longer used. Delete them entirely from src/setup.reef.
- Step 4: Remove render_llm_share_template and template_contains_placeholder
Delete both functions and remove them from the export block.
- Step 5: Delete the test that exercised the now-removed render
hg rm tests/test_setup_template.reef
- Step 6: Build and test
make build
make test
Expected: clean build. The setup_planner test will likely still pass (its 3 fixtures construct minimal Environments). If any test references the removed Environment fields, fix the test.
- Step 7: Commit
hg add src/setup.reef tests/
hg commit -m "setup: trim Environment to 5 fields; drop render_llm_share_template + which_binary + ollama_listening_at"
Task 17: Trim setup.reef part 2 — plan_stages, apply_stage, planner test
Files:
-
Modify:
src/setup.reef -
Test:
tests/test_setup_planner.reef(update) -
Step 1: Update test_setup_planner.reef to expect 2 stages
The test currently asserts 3-or-4 stages. Replace with 2 stages always:
import setup
import test.framework
proc main()
let runner = new framework.TestRunner()
// Fresh host
let fresh = setup.Environment {
home_dir: "/home/ctusa", user: "ctusa",
host_lan_ip: "192.168.168.42",
incus_reachable: true,
repoman_project_present: false
}
let stages = setup.plan_stages(fresh)
runner.assert_eq_int(stages.length(), 2, "always 2 stages: incus_project + registry_defaults")
runner.assert_eq_string(stages[0].id, "incus_project", "stage 0 = incus_project")
runner.assert_eq_bool(stages[0].is_change, true, "incus_project will change on fresh host")
runner.assert_eq_string(stages[1].id, "registry_defaults", "stage 1 = registry_defaults")
runner.assert_eq_bool(stages[1].is_change, true, "registry_defaults always writes")
// Already-set-up host
let done = setup.Environment {
home_dir: "/home/ctusa", user: "ctusa",
host_lan_ip: "192.168.168.42",
incus_reachable: true,
repoman_project_present: true
}
let s2 = setup.plan_stages(done)
runner.assert_eq_int(s2.length(), 2, "still 2 stages")
runner.assert_eq_bool(s2[0].is_change, false, "incus_project no-op when present")
runner.report()
end main
- Step 2: Update plan_stages signature and body
plan_stages no longer takes with_llm (the flag is gone). Trim:
fn plan_stages(env: Environment): [Stage]
mut stages: [Stage] = new [Stage](2)
stages[0] = Stage {
id: "incus_project",
description: "ensure Incus project 'repoman' exists",
is_change: not env.repoman_project_present
}
stages[1] = Stage {
id: "registry_defaults",
description: "write registry defaults (schema 3, [host].lan_ip)",
is_change: true
}
return stages
end plan_stages
Update the export-block declaration to match:
fn plan_stages(env: Environment): [Stage]
- Step 3: Trim apply_stage to handle only incus_project and registry_defaults
In src/setup.reef, replace apply_stage's body:
fn apply_stage(stage: Stage, env: Environment, reg: config.Registry): rg.Result[config.Registry, string]
if stage.id == "incus_project"
if env.repoman_project_present
return @Result[config.Registry, string].Ok(reg)
end if
let r = incus.project_ensure("repoman", false)
if rg.is_err(r)
return @Result[config.Registry, string].Err(rg.unwrap_err(r))
end if
return @Result[config.Registry, string].Ok(reg)
end if
if stage.id == "registry_defaults"
// Update [host].lan_ip from detected env
let new_host = config.Host {
lan_ip: env.host_lan_ip
}
let new_reg = config.Registry {
schema: 3,
host: new_host,
output: reg.output,
defaults: reg.defaults,
projects: reg.projects
}
return @Result[config.Registry, string].Ok(new_reg)
end if
return @Result[config.Registry, string].Err("unknown stage id: " + stage.id)
end apply_stage
The previous llm_share_profile and claude_share_check stages are gone.
- Step 4: Build and test
make build
make test
Expected: clean. test_setup_planner now passes its updated assertions.
- Step 5: Commit
hg add src/setup.reef tests/test_setup_planner.reef
hg commit -m "setup: plan_stages always emits 2 stages; apply_stage handles only incus_project + registry_defaults"
Task 18: Trim setup.reef part 3 — cmd_setup flags and prompt
Files:
-
Modify:
src/setup.reef(cmd_setup function) -
Step 1: Drop --with-llm and --without-llm flags from cmd_setup
In src/setup.reef, find cmd_setup. Remove these lines:
let _f2 = flag.bool_flag(parser, "with-llm", '\0', false, "include the LLM stack ...")
let _f3 = flag.bool_flag(parser, "without-llm", '\0', false, "skip the LLM stack")
And remove the LLM-decision block:
let with_llm_flag: bool = flag.get_bool(parser, "with-llm")
let without_llm_flag: bool = flag.get_bool(parser, "without-llm")
if with_llm_flag and without_llm_flag
...
end if
mut with_llm: bool = false
if with_llm_flag
...
end if
...prompt for LLM...
- Step 2: Update calls to plan_stages and the success message
Wherever cmd_setup currently calls plan_stages(env_snap, with_llm), change to plan_stages(env_snap) (no second arg).
Remove the conditional success-hint about --hermes:
// DELETE this block:
if reg.defaults.llm.enabled
println(" repoman new <name> --hermes")
end if
(Note: reg.defaults.llm doesn't exist anymore; this would be a compile error after T1 anyway.)
The final success message becomes:
println("")
println("setup complete.")
println("")
println(" next: repoman profile install --all (install vendor profile library)")
println(" repoman new <name>")
println(" repoman list")
- Step 3: Update cmd_setup's flag.description and the print_usage entry in cli.reef
In src/setup.reef cmd_setup, update:
flag.description(parser, "First-time host bootstrap: incus project, registry, host LAN IP detection")
(cli.reef print_usage update is in T20.)
- Step 4: Build and test
make build
make test
Expected: clean.
- Step 5: Commit
hg add src/setup.reef
hg commit -m "setup: cmd_setup drops --with-llm/--without-llm flags; success hint points to profile install"
Task 19: cmd_new pre-launch profile validation
Files:
-
Modify:
src/cli.reef(cmd_new) -
Step 1: Insert validation block before
incus.launch
In cmd_new, find the line:
log.write("==> incus launch " + eff.image + " " + name)
let lr = incus.launch(reg.defaults.incus_project, name, eff.image, eff.profiles)
Immediately before log.write("==> incus launch ..."), add:
// Pre-launch profile validation: every name in eff.profiles must either be
// the magic incus 'default' profile, or installed in the 'default' project.
// (Repoman-managed profiles all live in 'default' per the v0.4 architecture.)
let pn2: int = eff.profiles.length()
mut pi: int = 0
while pi < pn2
let pname: string = eff.profiles[pi]
if pname != "default"
let exists_r = incus.profile_exists("default", pname)
if rg.is_ok(exists_r) and not rg.unwrap_ok(exists_r)
log.write("repoman: error: container references profile '" + pname + "' but it's not installed in incus.")
log.write("hint: repoman profile install " + pname)
log.write("hint: repoman profile install --all (to install the vendor library)")
return 4
end if
end if
pi = pi + 1
end while
- Step 2: Build
make build
Expected: clean build.
- Step 3: Test by inspection
Set up a fixture: registry with [defaults].profiles = ["default", "missing-profile"]. Run repoman new test. Expect exit 4 with the hint.
(Manual smoke; no unit test for cmd_new because it's effectful.)
- Step 4: Commit
hg add src/cli.reef
hg commit -m "cli: cmd_new pre-launch validation — error early if profile not installed"
Task 20: cli.cmd_profile dispatch for 5 verbs
Files:
-
Modify:
src/cli.reef -
Step 1: Add cmd_profile to imports and exports
In src/cli.reef, add to imports near the top:
import profile
Add cmd_profile to the export block:
fn cmd_profile(argv: [string]): int
- Step 2: Implement cmd_profile (dispatches on verb)
After cmd_setup, add cmd_profile. Each verb is its own helper:
fn cmd_profile(argv: [string]): int
if argv.length() == 0
console.printErr("repoman: error: 'profile' requires a subcommand: list | install | diff | remove | show")
return 2
end if
let verb: string = argv[0]
// Slice argv[1..] for the verb's own parser
let n: int = argv.length()
mut rest: [string] = new [string](n - 1)
mut i: int = 0
while i < n - 1
rest[i] = argv[i + 1]
i = i + 1
end while
if verb == "list"
return cmd_profile_list(rest)
end if
if verb == "install"
return cmd_profile_install(rest)
end if
if verb == "diff"
return cmd_profile_diff(rest)
end if
if verb == "remove"
return cmd_profile_remove(rest)
end if
if verb == "show"
return cmd_profile_show(rest)
end if
console.printErr("repoman: error: unknown profile subcommand: " + verb)
return 2
end cmd_profile
fn build_host_facts(): profile.HostFacts
let home: string = env.get_env_or("HOME", "")
let user: string = env.get_env_or("USER", "")
let reg_r = config.load_or_init(home)
mut lan_ip: string = ""
if rg.is_ok(reg_r)
lan_ip = rg.unwrap_ok(reg_r).host.lan_ip
end if
return profile.HostFacts {
lan_ip: lan_ip,
user: user,
home: home
}
end build_host_facts
fn cmd_profile_list(argv: [string]): int
let home: string = env.get_env_or("HOME", "")
let entries = profile.list_all(home)
println("NAME SOURCE INSTALLED")
let n: int = entries.length()
mut i: int = 0
while i < n
let e = entries[i]
mut inst: string = "no"
if e.installed
inst = "yes"
end if
println(e.name + " " + e.source + " " + inst)
i = i + 1
end while
return 0
end cmd_profile_list
fn cmd_profile_install(argv: [string]): int
let host = build_host_facts()
let home: string = host.home
if argv.length() == 0
console.printErr("repoman: error: 'profile install' requires <name> or --all")
return 2
end if
if argv[0] == "--all"
let entries = profile.list_all(home)
let n: int = entries.length()
mut i: int = 0
mut errs: int = 0
while i < n
let e = entries[i]
println("==> install " + e.name + " (source: " + e.source + ")")
let r = profile.install(e.name, home, host)
if rg.is_err(r)
console.printErr(" error: " + rg.unwrap_err(r))
errs = errs + 1
end if
i = i + 1
end while
if errs > 0
return 1
end if
return 0
end if
let name: string = argv[0]
let r = profile.install(name, home, host)
if rg.is_err(r)
console.printErr("repoman: error: " + rg.unwrap_err(r))
return 1
end if
println("==> installed " + name)
return 0
end cmd_profile_install
fn cmd_profile_diff(argv: [string]): int
if argv.length() == 0
console.printErr("repoman: error: 'profile diff' requires <name>")
return 2
end if
let host = build_host_facts()
let r = profile.diff(argv[0], host.home, host)
if rg.is_err(r)
console.printErr("repoman: error: " + rg.unwrap_err(r))
return 3
end if
println(rg.unwrap_ok(r))
return 0
end cmd_profile_diff
fn cmd_profile_remove(argv: [string]): int
if argv.length() == 0
console.printErr("repoman: error: 'profile remove' requires <name>")
return 2
end if
let r = profile.remove_profile(argv[0])
if rg.is_err(r)
console.printErr("repoman: error: " + rg.unwrap_err(r))
return 1
end if
println("==> removed " + argv[0] + " from incus")
return 0
end cmd_profile_remove
fn cmd_profile_show(argv: [string]): int
if argv.length() == 0
console.printErr("repoman: error: 'profile show' requires <name>")
return 2
end if
let host = build_host_facts()
let r = profile.show(argv[0], host.home, host)
if rg.is_err(r)
console.printErr("repoman: error: " + rg.unwrap_err(r))
return 3
end if
println(rg.unwrap_ok(r))
return 0
end cmd_profile_show
- Step 3: Wire into the dispatcher
Find the dispatch function. Add a profile arm in alphabetical position (between new and remove, or wherever fits):
if cmd == "profile"
return cmd_profile(rest)
end if
- Step 4: Update print_usage
Add profile to the help text:
console.printErr(" profile {list|install|diff|remove|show} [<name>] [--all]")
console.printErr(" Manage Incus profiles from the repoman library.")
console.printErr("")
Place it after new's entry, before remove's entry (alphabetical).
Also update setup's help text to drop the --with-llm mention:
console.printErr(" setup [--non-interactive]")
console.printErr(" First-time host bootstrap: incus project + registry.")
- Step 5: Build and verify
make build
./build/repoman --help
./build/repoman profile
Expected: --help shows the profile line; profile (no verb) prints the error message.
- Step 6: Commit
hg add src/cli.reef
hg commit -m "cli: cmd_profile dispatch for list/install/diff/remove/show; update print_usage"
Task 21: Author the three vendor profile YAML files
Files:
-
Create:
profiles/claude-share.yml -
Create:
profiles/llm-share.yml -
Create:
profiles/dotfiles.yml -
Step 1: Create profiles directory and the three YAML files
mkdir -p profiles
Create profiles/claude-share.yml:
name: claude-share
description: Share host's Claude CLI state (auth, history, plugins) into containers.
config: {}
devices:
claude-state:
type: disk
source: ${HOME}/.claude
path: ${HOME}/.claude
shift: "true"
claude-bin:
type: disk
source: ${HOME}/.local/bin/claude
path: /usr/local/bin/claude
readonly: "true"
shift: "true"
Create profiles/llm-share.yml:
name: llm-share
description: Wire containers to the host ollama daemon over LAN.
config:
environment.OLLAMA_HOST: "http://${HOST_LAN_IP}:11434"
devices:
ollama-bin:
type: disk
source: /usr/local/bin/ollama
path: /usr/local/bin/ollama
readonly: "true"
ollama-state:
type: disk
source: ${HOME}/.ollama
path: ${HOME}/.ollama
shift: "true"
Create profiles/dotfiles.yml:
name: dotfiles
description: Bind common host dotfiles (.gitconfig, .hgrc) into containers.
config: {}
devices:
gitconfig:
type: disk
source: ${HOME}/.gitconfig
path: ${HOME}/.gitconfig
readonly: "true"
shift: "true"
hgrc:
type: disk
source: ${HOME}/.hgrc
path: ${HOME}/.hgrc
readonly: "true"
shift: "true"
- Step 2: Commit
hg add profiles/claude-share.yml profiles/llm-share.yml profiles/dotfiles.yml
hg commit -m "profiles: vendor library — claude-share, llm-share, dotfiles"
Task 22: Makefile — install profiles to /usr/local/share/repoman/profiles/
Files:
-
Modify:
Makefile -
Step 1: Update install/uninstall targets
Replace the existing install: and uninstall: targets:
PREFIX ?= /usr/local
BINDIR = $(PREFIX)/bin
SHAREDIR = $(PREFIX)/share/repoman
PROFILES_DIR = $(SHAREDIR)/profiles
DESTDIR ?=
.PHONY: all build test clean install uninstall
all: build
build:
reefc build
test:
@for t in tests/test_*.reef; do \
echo "== $$t =="; \
reefc run "$$t" || exit 1; \
done
clean:
reefc clean
install: build
install -d $(DESTDIR)$(BINDIR)
install -m 0755 build/repoman $(DESTDIR)$(BINDIR)/repoman
install -d $(DESTDIR)$(PROFILES_DIR)
install -m 0644 profiles/*.yml $(DESTDIR)$(PROFILES_DIR)/
uninstall:
rm -f $(DESTDIR)$(BINDIR)/repoman
rm -rf $(DESTDIR)$(PROFILES_DIR)
- Step 2: Test the install path locally
sudo make install
ls /usr/local/share/repoman/profiles/
Expected: claude-share.yml, dotfiles.yml, llm-share.yml.
- Step 3: Commit
hg add Makefile
hg commit -m "Makefile: install profiles/*.yml to /usr/local/share/repoman/profiles/"
Task 23: README + VISION + reef.toml + version_string updates
Files:
-
Modify:
reef.toml -
Modify:
src/cli.reef(version_string) -
Modify:
README.md -
Modify:
VISION.md -
Step 1: Bump version
In reef.toml, change version = "0.3.0" to version = "0.4.0".
In src/cli.reef, find version_string() and change "repoman 0.3.0" to "repoman 0.4.0".
- Step 2: README — add profile library section, drop --with-llm
In README.md, find the ## Setup wizard and ## Local LLM stack sections from v0.3. Replace them with:
## Setup wizard
First-time host bootstrap (idempotent — safe to re-run):
repoman setup # interactive
repoman setup --non-interactive # accept defaults
The wizard creates the Incus project `repoman`, detects your host LAN IP (used by
profiles that wire containers to host services), and writes the initial registry.
After setup, install the vendor profile library:
repoman profile install --all
## Profile library
repoman ships a vendor profile library at `/usr/local/share/repoman/profiles/`.
v0.4 includes three profiles:
- **`claude-share`** — bind `~/.claude` and `~/.local/bin/claude` into containers
so they share the host's Claude CLI auth, history, and plugins.
- **`llm-share`** — bind `/usr/local/bin/ollama` and `~/.ollama` into containers,
set `OLLAMA_HOST=http://<host-lan-ip>:11434` so containers reach the host's
ollama daemon over LAN.
- **`dotfiles`** — bind `~/.gitconfig` and `~/.hgrc` (extend with your own).
Manage profiles via `repoman profile`:
repoman profile list
repoman profile install <name>
repoman profile install --all
repoman profile diff <name> # show drift between file and incus state
repoman profile show <name> # print rendered YAML
repoman profile remove <name> # remove from incus (file untouched)
### Customizing profiles
To override a vendor profile, copy it to your user dir and edit:
cp /usr/local/share/repoman/profiles/dotfiles.yml ~/.config/repoman/profiles.d/
$EDITOR ~/.config/repoman/profiles.d/dotfiles.yml
repoman profile install dotfiles # applies the user version (shadows vendor)
User profiles in `~/.config/repoman/profiles.d/` always win over vendor profiles
of the same name. `repoman profile list` shows the active source for each.
## Migrating from v0.3
If you ran `repoman setup --with-llm` on v0.3, your registry is at schema 2 and
includes a `[defaults.llm]` block. v0.4 reads that block on first load, extracts
the ollama_url's host portion into `[host].lan_ip`, and writes the registry as
schema 3 on the next save. No action required — the migration is automatic and
lossless for the one fact that's still useful.
The `--hermes` / `--no-hermes` / `--purge-hermes` flags are gone. Hermes (and
similar agents that need per-container installs) is now the user's responsibility:
shell into the container with `repoman shell <name>` and run the install yourself.
v0.5+ may revisit this if real demand surfaces.
- Step 3: VISION.md — update setup row, add profile management
In VISION.md, find the subcommand table. Update the repoman setup row description and add a repoman profile row:
| `repoman setup` | First-time host setup. Creates Incus project `repoman`, detects host LAN IP, writes initial registry. Idempotent. **Shipped in v0.3, trimmed in v0.4 (no longer touches profiles or LLM stack).** |
| `repoman profile {list, install, diff, remove, show}` | Manage Incus profiles from the vendor library at `/usr/local/share/repoman/profiles/` and the user override dir at `~/.config/repoman/profiles.d/`. **Shipped in v0.4.** |
- Step 4: Build and test
make build
./build/repoman --version # repoman 0.4.0
./build/repoman --help # shows setup (no --with-llm), profile, etc.
make test
Expected: clean.
- Step 5: Commit
hg add reef.toml src/cli.reef README.md VISION.md
hg commit -m "docs: v0.4 — profile library section, migration guide; bump version"
Task 24: End-to-end smoke test
Manual gate before tagging v0.4.0.
Files: (no code changes — manual verification)
- Step 1: Install and verify version
cd ~/repos/repoman
sudo make install
repoman --version # repoman 0.4.0
ls /usr/local/share/repoman/profiles/ # three YAML files
- Step 2: Run setup
repoman setup --non-interactive
cat ~/.config/repoman/repoman.toml | grep -E 'schema|host|lan_ip'
Expected: schema = 3, [host] block, lan_ip = "<your br0 IP>".
- Step 3: Install vendor profile library
repoman profile install --all
incus profile list --project default | grep -E 'claude-share|llm-share|dotfiles'
Expected: all three profiles present in incus.
- Step 4: Verify rendered profiles
incus profile show --project default llm-share | grep OLLAMA_HOST
# expect: environment.OLLAMA_HOST: http://<your-lan-ip>:11434
incus profile show --project default claude-share | grep '/home/'
# expect: /home/<your-user>/.claude paths
- Step 5: Test profile diff and show
repoman profile show claude-share | head -20
repoman profile diff claude-share
# expect "(no drift)" since we just installed it
- Step 6: Test pre-launch validation
Edit ~/.config/repoman/repoman.toml to add "missing-profile" to [defaults].profiles. Then:
mkdir -p ~/repos/smoke-test
echo "smoke" > ~/repos/smoke-test/README.md
repoman new smoke-test
Expected: error with hint repoman profile install missing-profile. Container is NOT launched.
Revert the registry change.
- Step 7: Successful container creation with profiles
# Edit registry to use ["default", "claude-share", "llm-share", "dotfiles"] in [defaults].profiles
# OR write an override at ~/.config/repoman/repos.d/smoke-test.toml
repoman new smoke-test
repoman shell smoke-test
# Inside container:
ls -la ~/.claude # bind-mounted from host
ls -la ~/.gitconfig # readable
ollama list # talks to host daemon
exit
Expected: all three profiles working.
- Step 8: Test user-shadow customization
mkdir -p ~/.config/repoman/profiles.d
cp /usr/local/share/repoman/profiles/dotfiles.yml ~/.config/repoman/profiles.d/
# Edit to add a custom bind, e.g., ~/.zshrc
repoman profile install dotfiles
repoman profile list | grep dotfiles
# expect: source = "user (shadows vendor)"
- Step 9: Cleanup and tag
repoman remove smoke-test --yes
rm -rf ~/repos/smoke-test
hg tag v0.4.0
hg log -r tip --template "{node|short} {desc|firstline}\n"
If everything in steps 1-8 worked, v0.4.0 ships.
Self-review
Spec coverage check
- §1 In-scope items:
- Profile library layout (vendor + user) → T9, T10, T11, T12
repoman profile {list, install, diff, remove, show}→ T13, T14, T15, T20- Templated YAML with
${VAR}→ T10 [host].lan_ipfield → T1, T2, T17- Schema 2 → 3 migration → T2, T4, T5, T6
- Three vendor profiles → T21
- Pre-launch validation → T19
- §1 Trims:
--with-llm/--without-llmflags removed → T18- ollama LAN-listening check removed → T16
[defaults].llmblock removed → T1, T3- String template embedded in setup.reef removed → T16
- apply_stage llm_share_profile and registry_defaults trim → T17
- §2 Architecture:
src/profile.reefnew module → T9–T15setup.reefedits → T16, T17, T18cli.reefedits → T19, T20config.reefedits → T1, T2, T3, T4, T5incus.reefprofile_getanddelete_profile→ T8, T14
- §3 Data shapes — all covered (Schema 3, profile YAMLs, ProfileEntry, HostFacts)
- §4 Subcommand flows — all covered
- §5 Testing — pure tests in T6, T10; smoke in T24
- §7 Decisions — locked into spec, not implementation tasks
Placeholder scan
No "TBD"/"TODO" in the plan. Each step has either runnable shell or complete reef code.
Type-consistency check
Hoststruct:lan_ip: stringconsistently across T1, T2, T6, T17.Registryfield order:schema, host, output, defaults, projects— used consistently in T1, T6, T17.ProfileEntryfields: name, source, file_path, installed — consistent T9, T12, T20.HostFactsfields: lan_ip, user, home — consistent T9, T10, T13, T14, T15, T20.profile.remove_profile(NOTprofile.remove) — consistent T9, T14, T20.incus.delete_profile(new in T14) used in T14.incus.profile_get(new in T8) used in T15.plan_stages(env)(nowith_llmarg after T17) — used in T17, T18.apply_stage(stage, env, reg)signature — unchanged, used in T17.
Task ordering / dependency check
T1–T7 (config schema): linear sequence; each builds on the prior. ✓
T8 (incus.profile_get): independent; can land anywhere before T15. ✓
T9–T15 (profile module): T9 stub first, T10 paths/render, T11 lookup uses paths, T12 list_all uses lookup, T13 install uses lookup+render, T14 remove (small) + show (uses lookup+render), T15 diff (uses everything). ✓
T16–T18 (setup trim): each builds on the prior — T16 first (struct + helpers), T17 next (planner), T18 last (CLI integration). ✓
T19 (cmd_new validation): independent; needs T8 indirectly via incus.profile_exists which already exists. ✓
T20 (cmd_profile dispatch): depends on profile module (T9–T15) being complete. ✓
T21 (vendor YAMLs): independent file authoring. ✓
T22 (Makefile): depends on T21 (files must exist). ✓
T23 (docs + version): can land anywhere; placed near end as polish. ✓
T24 (smoke): final gate. ✓
No circular deps.
Deferred (v0.5+ candidates)
repoman profile diff --vendor(shadow-vs-vendor diff): noted in spec §7 as resolved decision; implementation deferred.optional: trueon bind-mount sources for dotfiles (so missing files don't break launch): blocked on incus version support; reassess at v0.5.- Smarter unified diff in
profile diffinstead of side-by-side: low value for v0.4 use cases. - Setup wizard configuring ollama (installer + systemd override): explicitly rejected as out of mission scope.
- Pre-built incus images (
repoman image {build, refresh, list, prune}): rejected as maintenance overhead. - Per-project
[install]block in override files: rejected — shell scripts in user repos serve this need.