module setup import core.str import core.result_generic as rg import core.convert as convert import io.console as console import io.file as iofile import io.dir as iodir import sys.env import sys.flag as flag import sys.process as p import sys.fd as fd import paths import incus import hermes import config export type Environment fn detect_environment(home_dir: string): Environment fn detect_host_lan_ip(): string fn render_llm_share_template(host_lan_ip: string, user: string): string fn template_contains_placeholder(s: string): bool type Stage fn plan_stages(env: Environment, with_llm: bool): [Stage] fn apply_stage(stage: Stage, env: Environment, reg: config.Registry): rg.Result[config.Registry, string] fn cmd_setup(argv: [string]): int end export // Snapshot of the host state that `setup` cares about. 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 // Stage is a planned action with a description for the user. type Stage = struct id: string description: string is_change: bool end Stage // (function bodies follow in subsequent tasks) // Probe whether a binary is in PATH by running `command -v `. // Returns the path on success, empty string on failure. fn which_binary(name: string): string let cap = incus.process_run_capture("sh", ["-c", "command -v \"$1\"", "_", name]) if cap.exit_code != 0 return "" end if return str.trim_ws(cap.stdout) end which_binary // Returns true if anything is listening on the given TCP host:port. // Uses curl with a 1-second connect timeout — succeeds (exit 0) if a server // responds even with an HTTP error, fails if the connection itself can't be made. fn ollama_listening_at(host: string): bool let url: string = "http://" + host + ":11434" let pid: int = p.process_run("curl", [ "-sS", "-o", "/dev/null", "--connect-timeout", "1", url ]) if pid < 0 return false end if return p.process_wait(pid) == 0 end ollama_listening_at 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 + claude-share profile (only if incus is up) mut project_ok: bool = false mut claude_share_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 let cs = incus.profile_exists("default", "claude-share") if rg.is_ok(cs) claude_share_ok = rg.unwrap_ok(cs) end if end if let ollama_path: string = which_binary("ollama") let hermes_path: string = which_binary("hermes") let hermes_data_dir: string = paths.join(home_dir, ".hermes") let hermes_data_ok: bool = iodir.dir_exists(hermes_data_dir) mut ollama_lan_ok: bool = false if str.length(lan_ip) > 0 ollama_lan_ok = ollama_listening_at(lan_ip) end if return Environment { home_dir: home_dir, user: user, host_lan_ip: lan_ip, incus_reachable: incus_ok, repoman_project_present: project_ok, claude_share_present: claude_share_ok, ollama_binary: ollama_path, ollama_lan_ok: ollama_lan_ok, hermes_binary: hermes_path, hermes_data_present: hermes_data_ok } end detect_environment fn detect_host_lan_ip(): string let cap = incus.process_run_capture("ip", ["-4", "addr", "show", "br0"]) if cap.exit_code != 0 return "" end if // Look for the first occurrence of "inet " and grab the IPv4 token after it // until the next slash or space. `cap.stdout` is small, hand-roll the scan. let s: string = cap.stdout let n: int = str.length(s) let needle: string = "inet " let needle_len: int = 5 mut i: int = 0 while i + needle_len <= n if str.substring(s, i, needle_len) == needle let start: int = i + needle_len mut end_idx: int = start while end_idx < n and s[end_idx] != '/' and s[end_idx] != ' ' end_idx = end_idx + 1 end while return str.substring(s, start, end_idx - start) end if i = i + 1 end while return "" end detect_host_lan_ip fn render_llm_share_template(host_lan_ip: string, user: string): string let base: string = "name: llm-share\n" + "description: |\n" + " Local LLM client tools (ollama client) and host-daemon wiring.\n" + " Created by repoman setup; do not hand-edit (changes will be overwritten).\n" + "config:\n" + " environment.OLLAMA_HOST: \"http://{HOST_LAN_IP}:11434\"\n" + "devices:\n" + " ollama-bin:\n" + " type: disk\n" + " source: /usr/local/bin/ollama\n" + " path: /usr/local/bin/ollama\n" + " readonly: \"true\"\n" let tail: string = " ollama-state:\n" + " type: disk\n" + " source: /home/{USER}/.ollama\n" + " path: /home/{USER}/.ollama\n" + " shift: \"true\"\n" let combined: string = base + tail let s1: string = str.replace(combined, "{HOST_LAN_IP}", host_lan_ip) let s2: string = str.replace(s1, "{USER}", user) return s2 end render_llm_share_template fn template_contains_placeholder(s: string): bool return str.contains(s, "{HOST_LAN_IP}") or str.contains(s, "{USER}") end template_contains_placeholder fn plan_stages(env: Environment, with_llm: bool): [Stage] mut count: int = 3 // incus_project, claude_share_check, registry_defaults if with_llm count = 4 // + llm_share_profile (between claude_share and registry) end if mut stages: [Stage] = new [Stage](count) stages[0] = Stage { id: "incus_project", description: "ensure Incus project 'repoman' exists", is_change: not env.repoman_project_present } stages[1] = Stage { id: "claude_share_check", description: "verify 'claude-share' profile exists in default project", is_change: false // we never modify claude-share; failure→exit } if with_llm let llm_change: bool = (str.length(env.host_lan_ip) > 0) and env.ollama_lan_ok stages[2] = Stage { id: "llm_share_profile", description: "create/refresh 'llm-share' profile in repoman project", is_change: llm_change } stages[3] = Stage { id: "registry_defaults", description: "write registry defaults (schema 2, llm.enabled, profiles list)", is_change: true } else stages[2] = Stage { id: "registry_defaults", description: "write registry defaults (schema 2, llm.enabled=false)", is_change: true } end if return stages end plan_stages 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) // no-op 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 == "claude_share_check" if env.claude_share_present return @Result[config.Registry, string].Ok(reg) end if return @Result[config.Registry, string].Err( "claude-share profile not found.\n" + "Create it with:\n" + " incus profile create claude-share\n" + " incus profile edit claude-share # add your bind-mounts (~/.claude, ~/.local/bin, ...)" ) end if if stage.id == "llm_share_profile" if not stage.is_change return @Result[config.Registry, string].Ok(reg) end if if str.length(env.host_lan_ip) == 0 return @Result[config.Registry, string].Err( "no br0 IP detected — cannot wire llm-share without a stable LAN address.\n" + "Set up br0 first, then re-run 'repoman setup --with-llm'." ) end if if not env.ollama_lan_ok return @Result[config.Registry, string].Err( "ollama daemon not reachable on " + env.host_lan_ip + ":11434.\n" + "Add OLLAMA_HOST=" + env.host_lan_ip + ":11434 to your systemd unit and restart ollama." ) end if let yaml: string = render_llm_share_template(env.host_lan_ip, env.user) let r = incus.profile_create_or_edit("repoman", "llm-share", yaml) 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" // Compute desired LlmDefaults let want_enabled: bool = (str.length(env.host_lan_ip) > 0) and env.ollama_lan_ok and (str.length(env.hermes_binary) > 0) mut ollama_url: string = "" if want_enabled ollama_url = "http://" + env.host_lan_ip + ":11434" end if let new_llm = config.LlmDefaults { enabled: want_enabled, hermes_default: false, ollama_url: ollama_url, hermes_seed: hermes.default_seed_list() } // Compute desired profiles list mut new_profiles: [string] = ["default", "claude-share"] if want_enabled new_profiles = ["default", "claude-share", "llm-share"] end if let new_defaults = config.Defaults { repos_root: reg.defaults.repos_root, backup_root: reg.defaults.backup_root, logdir: reg.defaults.logdir, incus_project: reg.defaults.incus_project, default_image: reg.defaults.default_image, profiles: new_profiles, llm: new_llm } let new_reg = config.Registry { schema: 2, output: reg.output, defaults: new_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 fn cmd_setup(argv: [string]): int let parser: flag.FlagParser = flag.flag_parser_from(argv) flag.application(parser, "repoman setup") flag.description(parser, "First-time host bootstrap: incus project, profiles, registry") let _f1 = flag.bool_flag(parser, "non-interactive", '\0', false, "accept all defaults without prompting") let _f2 = flag.bool_flag(parser, "with-llm", '\0', false, "include the LLM stack (llm-share profile, ollama wiring)") let _f3 = flag.bool_flag(parser, "without-llm", '\0', false, "skip the LLM stack") if not flag.parse(parser) console.printErr("repoman: error: " + flag.error(parser)) return 2 end if let non_interactive: bool = flag.get_bool(parser, "non-interactive") 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 console.printErr("repoman: error: --with-llm and --without-llm are mutually exclusive") return 2 end if let home: string = env.get_env_or("HOME", "") if str.length(home) == 0 console.printErr("repoman: error: HOME is not set") return 3 end if let env_snap = detect_environment(home) if not env_snap.incus_reachable console.printErr("repoman: error: 'incus' is not installed or not on PATH") return 3 end if // Decide LLM inclusion mut with_llm: bool = false if with_llm_flag with_llm = true else if without_llm_flag with_llm = false else if non_interactive with_llm = false // safe default in non-interactive mode else with_llm = console.confirm_default_no("Include local LLM stack (ollama + hermes wiring)?") end if end if end if // Plan & display let stages = plan_stages(env_snap, with_llm) println("") println("repoman setup plan:") let n: int = stages.length() mut i: int = 0 while i < n let st = stages[i] mut marker: string = " " if st.is_change marker = " * " end if println(marker + "[" + st.id + "] " + st.description) i = i + 1 end while println("") println("* = will change; otherwise no-op") println("") if not non_interactive let proceed = console.confirm("proceed?") if not proceed println("aborted") return 4 end if end if // Load registry (or init) let reg_r = config.load_or_init(home) if rg.is_err(reg_r) console.printErr("repoman: error: " + rg.unwrap_err(reg_r)) return 3 end if mut reg: config.Registry = rg.unwrap_ok(reg_r) // Apply stages mut k: int = 0 while k < n let st = stages[k] println("==> [" + st.id + "] " + st.description) let r = apply_stage(st, env_snap, reg) if rg.is_err(r) console.printErr("repoman: error: " + rg.unwrap_err(r)) return 1 end if reg = rg.unwrap_ok(r) k = k + 1 end while // Persist registry let cfg_path: string = config.registry_path(home) let saved = config.save(reg, cfg_path) if rg.is_err(saved) console.printErr("repoman: error: " + rg.unwrap_err(saved)) return 1 end if println("") println("setup complete.") println("") println(" next: repoman new ") println(" repoman list") return 0 end cmd_setup end module