1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
|
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 <name>`.
// 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 <name>")
println(" repoman list")
return 0
end cmd_setup
end module
|