|
root / docs / superpowers / plans / 2026-05-08-repoman-v0.4-profile-library.md
2026-05-08-repoman-v0.4-profile-library.md markdown 2349 lines 70.0 KB

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 pattern
    • src/incus.reef — for process_run_capture/process_run_silent patterns
    • src/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 replace
    • io.dir.list_dir(path: string, entries: [string], max: int): int — populates buffer, returns count
    • io.dir.dir_exists, io.dir.create_dir_all — already in use
    • io.file.fileExists, io.file.readFile, io.file.writeFile — already in use
    • core.str.starts_with, str.ends_with, str.substring, str.length, str.contains — all already used
    • sys.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 with end module.
  • main.reef has no module declaration.
  • Tests are standalone reef programs each with their own proc main().
  • make test runs every tests/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, add host: Host {...}

Sites today (per v0.3 + revert state):

  • parse_registry (around line 153): Defaults { ..., llm: LlmDefaults {...} }. Drop the llm: line.
  • default_registry (around line 434): same.
  • Any test that builds a Defaults literal: tests/test_config_serialize.reef, tests/test_config_roundtrip.reef, tests/test_config_merge.reef, tests/test_config_mutate.reef — drop llm: field.

Then add host: Host { lan_ip: "" } to every Registry literal:

  • parse_registry (the final return literal)
  • default_registry
  • Tests that build a Registry literal 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 — drop llm: field from Defaults literals; add host: Host { lan_ip: "" } to Registry literals.

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_registry to 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's test_config_migrate.reef covers 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 removeremove 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_ip field → T1, T2, T17
    • Schema 2 → 3 migration → T2, T4, T5, T6
    • Three vendor profiles → T21
    • Pre-launch validation → T19
  • §1 Trims:
    • --with-llm / --without-llm flags removed → T18
    • ollama LAN-listening check removed → T16
    • [defaults].llm block 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.reef new module → T9–T15
    • setup.reef edits → T16, T17, T18
    • cli.reef edits → T19, T20
    • config.reef edits → T1, T2, T3, T4, T5
    • incus.reef profile_get and delete_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

  • Host struct: lan_ip: string consistently across T1, T2, T6, T17.
  • Registry field order: schema, host, output, defaults, projects — used consistently in T1, T6, T17.
  • ProfileEntry fields: name, source, file_path, installed — consistent T9, T12, T20.
  • HostFacts fields: lan_ip, user, home — consistent T9, T10, T13, T14, T15, T20.
  • profile.remove_profile (NOT profile.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) (no with_llm arg 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: true on 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 diff instead 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.