cubething-occonf 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
build.py — build stamped config from src/ templates into out/.
|
|
4
|
+
|
|
5
|
+
Copies src/ → out/ (excluding profiles/), then applies all stamps:
|
|
6
|
+
- model field in opencode.json
|
|
7
|
+
- model + external_directory in agent frontmatter
|
|
8
|
+
- {{CONFIG_DIR}} placeholder resolution in agent files
|
|
9
|
+
|
|
10
|
+
Source files in src/ are NEVER modified.
|
|
11
|
+
|
|
12
|
+
On first run (build.json absent), prompts for model config interactively
|
|
13
|
+
and writes build.json to the repo root (gitignored — not checked in).
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
python3 scripts/build.py # build using existing build.json
|
|
17
|
+
python3 scripts/build.py --reconfigure # re-prompt for model config
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import shutil
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import IO, Any
|
|
27
|
+
|
|
28
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
29
|
+
REPO_ROOT = SCRIPT_DIR.parent
|
|
30
|
+
SRC_DIR = REPO_ROOT / "src"
|
|
31
|
+
OUT_DIR = REPO_ROOT / "out"
|
|
32
|
+
CONFIG_PATH = REPO_ROOT / "build.json"
|
|
33
|
+
|
|
34
|
+
# Default config written on first run
|
|
35
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
36
|
+
"global": {
|
|
37
|
+
"model": "github-copilot/claude-opus-4.6",
|
|
38
|
+
"external_directory": [
|
|
39
|
+
"{env:AGENT_REPOS}/**",
|
|
40
|
+
"{env:AGENT_VAULT}/**",
|
|
41
|
+
"{env:OPENCODE_CONFIG_SRC}/**",
|
|
42
|
+
"/tmp/**",
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
"tiers": {
|
|
46
|
+
"design": {"model": None},
|
|
47
|
+
"execute": {"model": "github-copilot/claude-sonnet-4.6"},
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# TTY prompt helpers
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def open_tty() -> IO[str] | None:
|
|
58
|
+
"""Return a file object for /dev/tty, or None if unavailable."""
|
|
59
|
+
try:
|
|
60
|
+
return open("/dev/tty")
|
|
61
|
+
except OSError:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def tty_prompt(tty: IO[str], message: str) -> str:
|
|
66
|
+
"""Write prompt to stdout and read a line from tty."""
|
|
67
|
+
print(message, end="", flush=True)
|
|
68
|
+
return tty.readline().rstrip("\n")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# First-run / reconfigure: generate build.json interactively
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def prompt_config(tty: IO[str]) -> dict[str, Any]:
|
|
77
|
+
"""Prompt the user for model configuration and return a build config dict."""
|
|
78
|
+
config: dict[str, Any] = json.loads(json.dumps(DEFAULT_CONFIG))
|
|
79
|
+
|
|
80
|
+
default_global = str(DEFAULT_CONFIG["global"]["model"])
|
|
81
|
+
val = tty_prompt(tty, f"Global model [{default_global}]: ")
|
|
82
|
+
if val:
|
|
83
|
+
config["global"]["model"] = val
|
|
84
|
+
|
|
85
|
+
default_execute = str(DEFAULT_CONFIG["tiers"]["execute"]["model"])
|
|
86
|
+
val = tty_prompt(tty, f"Execute-tier model [{default_execute}]: ")
|
|
87
|
+
if val:
|
|
88
|
+
config["tiers"]["execute"]["model"] = val
|
|
89
|
+
|
|
90
|
+
print("Design tier inherits global model (no override). Edit build.json to change.")
|
|
91
|
+
|
|
92
|
+
return config
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def ensure_config(reconfigure: bool = False) -> dict[str, Any]:
|
|
96
|
+
"""Load or create build.json. Prompts interactively on first run or --reconfigure."""
|
|
97
|
+
if CONFIG_PATH.is_file() and not reconfigure:
|
|
98
|
+
return dict(json.loads(CONFIG_PATH.read_text(encoding="utf-8")))
|
|
99
|
+
|
|
100
|
+
# Need interactive prompts
|
|
101
|
+
tty = open_tty()
|
|
102
|
+
if tty is None:
|
|
103
|
+
if CONFIG_PATH.is_file():
|
|
104
|
+
print("No TTY available, using existing build.json.", file=sys.stderr)
|
|
105
|
+
return dict(json.loads(CONFIG_PATH.read_text(encoding="utf-8")))
|
|
106
|
+
# No config and no TTY — write defaults
|
|
107
|
+
print("No TTY available, writing default build.json.", file=sys.stderr)
|
|
108
|
+
config = json.loads(json.dumps(DEFAULT_CONFIG))
|
|
109
|
+
_ = CONFIG_PATH.write_text(
|
|
110
|
+
json.dumps(config, indent=2) + "\n", encoding="utf-8"
|
|
111
|
+
)
|
|
112
|
+
return dict(config)
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
if reconfigure:
|
|
116
|
+
print("Reconfiguring build.json...")
|
|
117
|
+
else:
|
|
118
|
+
print("No build.json found — running first-time setup.")
|
|
119
|
+
print()
|
|
120
|
+
config = prompt_config(tty)
|
|
121
|
+
finally:
|
|
122
|
+
tty.close()
|
|
123
|
+
|
|
124
|
+
_ = CONFIG_PATH.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
125
|
+
print(f"\nWrote {CONFIG_PATH}")
|
|
126
|
+
return config
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# Helpers
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def read_model_from_jsonc(text: str) -> str:
|
|
135
|
+
"""Extract the top-level "model" value from JSONC text."""
|
|
136
|
+
m = re.search(r'"model"\s*:\s*"([^"]*)"', text)
|
|
137
|
+
return m.group(1) if m else ""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def write_model_to_jsonc(text: str, new_model: str) -> str:
|
|
141
|
+
"""Replace the top-level "model" value in JSONC text, preserving everything else."""
|
|
142
|
+
return re.sub(
|
|
143
|
+
r'("model"\s*:\s*)"[^"]*"',
|
|
144
|
+
lambda _: f'"model": "{new_model}"',
|
|
145
|
+
text,
|
|
146
|
+
count=1,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def extract_frontmatter(content: str) -> tuple[str, str] | None:
|
|
151
|
+
"""
|
|
152
|
+
Extract raw frontmatter text and the rest of the markdown content.
|
|
153
|
+
Returns (fm_str, rest) or None if no frontmatter found.
|
|
154
|
+
"""
|
|
155
|
+
m = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
|
|
156
|
+
if not m:
|
|
157
|
+
return None
|
|
158
|
+
return m.group(1), content[m.end() :]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def fm_get(fm_str: str, key: str) -> str | None:
|
|
162
|
+
"""Extract a simple key: value from frontmatter text. Returns None if absent."""
|
|
163
|
+
m = re.search(rf"^{re.escape(key)}:\s*(.+)$", fm_str, re.MULTILINE)
|
|
164
|
+
return m.group(1).strip() if m else None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def rebuild_content(fm_lines: list[str], rest: str) -> str:
|
|
168
|
+
"""Reconstruct markdown content from a list of frontmatter lines and the body."""
|
|
169
|
+
return "---\n" + "\n".join(fm_lines) + "\n---" + rest
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# Build: copy src/ → out/ then stamp
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def copy_src_to_out(src_dir: Path, out_dir: Path) -> None:
|
|
178
|
+
"""Copy src/ tree to out/, excluding profiles/."""
|
|
179
|
+
if out_dir.exists():
|
|
180
|
+
shutil.rmtree(out_dir)
|
|
181
|
+
_ = shutil.copytree(
|
|
182
|
+
src_dir,
|
|
183
|
+
out_dir,
|
|
184
|
+
ignore=shutil.ignore_patterns("profiles"),
|
|
185
|
+
)
|
|
186
|
+
print(f"Copied {src_dir}/ → {out_dir}/ (excluding profiles/)")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def stamp_opencode_json(out_dir: Path, global_model: str) -> None:
|
|
190
|
+
"""Stamp the model field in out/opencode.json."""
|
|
191
|
+
oc_path = out_dir / "opencode.json"
|
|
192
|
+
oc_text = oc_path.read_text(encoding="utf-8")
|
|
193
|
+
current_model = read_model_from_jsonc(oc_text)
|
|
194
|
+
|
|
195
|
+
if current_model != global_model:
|
|
196
|
+
new_text = write_model_to_jsonc(oc_text, global_model)
|
|
197
|
+
_ = oc_path.write_text(new_text, encoding="utf-8")
|
|
198
|
+
print(f"opencode.json: model {current_model} → {global_model}")
|
|
199
|
+
else:
|
|
200
|
+
print(f"opencode.json: model already {global_model} (no change)")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def stamp_agent_models(agents_dir: Path, config: dict[str, Any]) -> dict[Path, str]:
|
|
204
|
+
"""Stamp model fields in agent frontmatter. Returns {path: updated content}."""
|
|
205
|
+
tiers_config = dict(config.get("tiers", {}))
|
|
206
|
+
|
|
207
|
+
for agent_file in sorted(agents_dir.glob("*.md")):
|
|
208
|
+
agent_name = agent_file.stem
|
|
209
|
+
content = agent_file.read_text(encoding="utf-8")
|
|
210
|
+
|
|
211
|
+
parsed = extract_frontmatter(content)
|
|
212
|
+
if parsed is None:
|
|
213
|
+
print(f"{agent_name}: no frontmatter, skipping model")
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
fm_str, rest = parsed
|
|
217
|
+
fm_lines = fm_str.splitlines()
|
|
218
|
+
tier = fm_get(fm_str, "tier")
|
|
219
|
+
|
|
220
|
+
if not tier:
|
|
221
|
+
print(f"{agent_name}: no tier set, skipping model")
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
tier_entry = tiers_config.get(tier)
|
|
225
|
+
tier_config = dict(tier_entry) if tier_entry else {}
|
|
226
|
+
tier_model: str | None = tier_config.get("model")
|
|
227
|
+
current_model = fm_get(fm_str, "model")
|
|
228
|
+
|
|
229
|
+
if tier_model is None:
|
|
230
|
+
# Tier inherits global — remove model line if present
|
|
231
|
+
if current_model is not None:
|
|
232
|
+
fm_lines = [line for line in fm_lines if not line.startswith("model:")]
|
|
233
|
+
_ = agent_file.write_text(
|
|
234
|
+
rebuild_content(fm_lines, rest), encoding="utf-8"
|
|
235
|
+
)
|
|
236
|
+
print(
|
|
237
|
+
f"{agent_name} (tier: {tier}): removed model override"
|
|
238
|
+
+ " (inherits global)"
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
print(f"{agent_name} (tier: {tier}): no model override (no change)")
|
|
242
|
+
elif current_model != tier_model:
|
|
243
|
+
has_model = any(line.startswith("model:") for line in fm_lines)
|
|
244
|
+
if has_model:
|
|
245
|
+
fm_lines = [
|
|
246
|
+
f"model: {tier_model}" if line.startswith("model:") else line
|
|
247
|
+
for line in fm_lines
|
|
248
|
+
]
|
|
249
|
+
else:
|
|
250
|
+
inserted: list[str] = []
|
|
251
|
+
for line in fm_lines:
|
|
252
|
+
inserted.append(line)
|
|
253
|
+
if line.startswith("tier:"):
|
|
254
|
+
inserted.append(f"model: {tier_model}")
|
|
255
|
+
fm_lines = inserted
|
|
256
|
+
_ = agent_file.write_text(rebuild_content(fm_lines, rest), encoding="utf-8")
|
|
257
|
+
print(f"{agent_name} (tier: {tier}): model → {tier_model}")
|
|
258
|
+
else:
|
|
259
|
+
print(
|
|
260
|
+
f"{agent_name} (tier: {tier}): model already"
|
|
261
|
+
+ f" {tier_model} (no change)"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
return {}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def stamp_external_dirs(agents_dir: Path, ext_dirs: list[str]) -> None:
|
|
268
|
+
"""Stamp the external_directory block in all agent frontmatter."""
|
|
269
|
+
canonical: list[str] = [" external_directory:"]
|
|
270
|
+
for d in ext_dirs:
|
|
271
|
+
canonical.append(f' "{d}": allow')
|
|
272
|
+
|
|
273
|
+
for agent_file in sorted(agents_dir.glob("*.md")):
|
|
274
|
+
agent_name = agent_file.stem
|
|
275
|
+
content = agent_file.read_text(encoding="utf-8")
|
|
276
|
+
|
|
277
|
+
parsed = extract_frontmatter(content)
|
|
278
|
+
if parsed is None:
|
|
279
|
+
print(f"{agent_name}: external_directory no frontmatter")
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
fm_str, rest = parsed
|
|
283
|
+
fm_lines = fm_str.splitlines()
|
|
284
|
+
|
|
285
|
+
new_lines: list[str] = []
|
|
286
|
+
skip = False
|
|
287
|
+
found = False
|
|
288
|
+
|
|
289
|
+
for line in fm_lines:
|
|
290
|
+
if re.match(r"^ external_directory:\s*$", line):
|
|
291
|
+
found = True
|
|
292
|
+
skip = True
|
|
293
|
+
new_lines.extend(canonical)
|
|
294
|
+
continue
|
|
295
|
+
if skip:
|
|
296
|
+
if re.match(r"^ ", line):
|
|
297
|
+
continue
|
|
298
|
+
else:
|
|
299
|
+
skip = False
|
|
300
|
+
new_lines.append(line)
|
|
301
|
+
|
|
302
|
+
if not found:
|
|
303
|
+
insert_idx = len(new_lines)
|
|
304
|
+
for i, line in enumerate(new_lines):
|
|
305
|
+
if re.match(r"^ task:", line):
|
|
306
|
+
insert_idx = i
|
|
307
|
+
break
|
|
308
|
+
new_lines = new_lines[:insert_idx] + canonical + new_lines[insert_idx:]
|
|
309
|
+
|
|
310
|
+
new_content = rebuild_content(new_lines, rest)
|
|
311
|
+
|
|
312
|
+
if new_content != content:
|
|
313
|
+
_ = agent_file.write_text(new_content, encoding="utf-8")
|
|
314
|
+
result = "updated"
|
|
315
|
+
else:
|
|
316
|
+
result = "no change"
|
|
317
|
+
|
|
318
|
+
print(f"{agent_name}: external_directory {result}")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def resolve_config_dir(agents_dir: Path, config_dir_value: str) -> None:
|
|
322
|
+
"""Resolve {{CONFIG_DIR}} placeholders in all agent files."""
|
|
323
|
+
count = 0
|
|
324
|
+
for f in sorted(agents_dir.glob("*.md")):
|
|
325
|
+
text = f.read_text(encoding="utf-8")
|
|
326
|
+
if "{{CONFIG_DIR}}" in text:
|
|
327
|
+
_ = f.write_text(
|
|
328
|
+
text.replace("{{CONFIG_DIR}}", config_dir_value), encoding="utf-8"
|
|
329
|
+
)
|
|
330
|
+
print(f" resolved: {f.name}")
|
|
331
|
+
count += 1
|
|
332
|
+
print(f" {count} agent file(s) updated.")
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def build(config: dict[str, Any], config_dir_value: str = "") -> Path:
|
|
336
|
+
"""Full build: copy src/ → out/, apply all stamps. Returns out_dir path.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
config: Loaded build.json dict.
|
|
340
|
+
config_dir_value: Value to substitute for {{CONFIG_DIR}} in agent files.
|
|
341
|
+
If empty, uses OPENCODE_CONFIG_SRC env var, falling
|
|
342
|
+
back to the default ~/.config/opencode.
|
|
343
|
+
"""
|
|
344
|
+
if not config_dir_value:
|
|
345
|
+
import os
|
|
346
|
+
|
|
347
|
+
config_dir_value = os.environ.get(
|
|
348
|
+
"OPENCODE_CONFIG_SRC", str(Path.home() / ".config" / "opencode")
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
global_section = dict(config["global"])
|
|
352
|
+
global_model: str = str(global_section["model"])
|
|
353
|
+
ext_dirs: list[str] = list(global_section["external_directory"])
|
|
354
|
+
|
|
355
|
+
# Step 1: Copy
|
|
356
|
+
copy_src_to_out(SRC_DIR, OUT_DIR)
|
|
357
|
+
|
|
358
|
+
agents_dir = OUT_DIR / "agents"
|
|
359
|
+
|
|
360
|
+
# Step 2: Stamp opencode.json model
|
|
361
|
+
stamp_opencode_json(OUT_DIR, global_model)
|
|
362
|
+
|
|
363
|
+
# Step 3: Stamp agent models
|
|
364
|
+
_ = stamp_agent_models(agents_dir, config)
|
|
365
|
+
|
|
366
|
+
# Step 4: Stamp agent external_directory
|
|
367
|
+
stamp_external_dirs(agents_dir, ext_dirs)
|
|
368
|
+
|
|
369
|
+
# Step 5: Resolve {{CONFIG_DIR}} placeholders
|
|
370
|
+
print(f"Resolving {{{{CONFIG_DIR}}}} → {config_dir_value} in agent files...")
|
|
371
|
+
resolve_config_dir(agents_dir, config_dir_value)
|
|
372
|
+
|
|
373
|
+
print()
|
|
374
|
+
print(f"Done. Build output in {OUT_DIR}/")
|
|
375
|
+
return OUT_DIR
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ---------------------------------------------------------------------------
|
|
379
|
+
# CLI entry point
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def main() -> None:
|
|
384
|
+
parser = argparse.ArgumentParser(
|
|
385
|
+
description="Build stamped config from src/ templates into out/."
|
|
386
|
+
)
|
|
387
|
+
_ = parser.add_argument(
|
|
388
|
+
"--reconfigure",
|
|
389
|
+
action="store_true",
|
|
390
|
+
help="Re-prompt for model configuration even if build.json exists.",
|
|
391
|
+
)
|
|
392
|
+
_ = parser.add_argument(
|
|
393
|
+
"--config-dir",
|
|
394
|
+
default="",
|
|
395
|
+
metavar="<path>",
|
|
396
|
+
dest="config_dir_value",
|
|
397
|
+
help=(
|
|
398
|
+
"Value to substitute for {{CONFIG_DIR}} in agent files."
|
|
399
|
+
" Defaults to $OPENCODE_CONFIG_SRC or ~/.config/opencode."
|
|
400
|
+
),
|
|
401
|
+
)
|
|
402
|
+
args = parser.parse_args()
|
|
403
|
+
|
|
404
|
+
config = ensure_config(reconfigure=bool(args.reconfigure))
|
|
405
|
+
_ = build(config, config_dir_value=str(args.config_dir_value))
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
if __name__ == "__main__":
|
|
409
|
+
main()
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""install.py — deploy built config from out/ to the target config directory
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python3 scripts/install.py [--profile <name>] [--config-dir <path>] [--help]
|
|
6
|
+
|
|
7
|
+
Options:
|
|
8
|
+
--profile <name> Profile to use (default: host). Loads src/profiles/<name>.env.
|
|
9
|
+
--config-dir <path> Override CONFIG_DIR from the profile.
|
|
10
|
+
--help Show this help message and exit.
|
|
11
|
+
|
|
12
|
+
Prerequisites:
|
|
13
|
+
Run build.py first to produce the out/ directory:
|
|
14
|
+
python3 scripts/build.py [--config-dir <path>]
|
|
15
|
+
|
|
16
|
+
What it does:
|
|
17
|
+
1. Loads the selected profile to set CONFIG_DIR and OPENCODE_CONFIG_SRC.
|
|
18
|
+
2. Verifies out/ exists (must run build.py first).
|
|
19
|
+
3. Refuses to run if out/ == CONFIG_DIR (would be a no-op or destructive).
|
|
20
|
+
4. rsyncs out/ contents to CONFIG_DIR.
|
|
21
|
+
5. Deploys AoE config (resolving {{AGENT_VAULT}} and {{OPENCODE_CONFIG_SRC}})
|
|
22
|
+
from src/aoe-config.toml.
|
|
23
|
+
6. Prints a deployment summary.
|
|
24
|
+
|
|
25
|
+
Separation of concerns:
|
|
26
|
+
- build.py: src/ → out/ copy + all stamping (model, external_directory, {{CONFIG_DIR}})
|
|
27
|
+
- install.py: out/ → CONFIG_DIR rsync + AoE config deployment
|
|
28
|
+
- Source files in src/ are NEVER modified.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import os
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# .env parser
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_env_file(env_path: Path) -> dict[str, str]:
|
|
43
|
+
"""Parse a shell-style KEY=value env file into a dict.
|
|
44
|
+
|
|
45
|
+
Handles:
|
|
46
|
+
- Comment lines (starting with #) and blank lines — skipped.
|
|
47
|
+
- ``export KEY=value`` — ``export`` prefix is stripped.
|
|
48
|
+
- Single- and double-quoted values — outer quotes are removed.
|
|
49
|
+
- $VAR / ${VAR} references — expanded via os.path.expandvars().
|
|
50
|
+
- ~ prefix — expanded via os.path.expanduser().
|
|
51
|
+
"""
|
|
52
|
+
result: dict[str, str] = {}
|
|
53
|
+
|
|
54
|
+
for raw_line in env_path.read_text().splitlines():
|
|
55
|
+
line = raw_line.strip()
|
|
56
|
+
|
|
57
|
+
if not line or line.startswith("#"):
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
if line.startswith("export "):
|
|
61
|
+
line = line[len("export ") :]
|
|
62
|
+
|
|
63
|
+
if "=" not in line:
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
key, _, raw_value = line.partition("=")
|
|
67
|
+
key = key.strip()
|
|
68
|
+
|
|
69
|
+
value = raw_value.strip()
|
|
70
|
+
if (value.startswith('"') and value.endswith('"')) or (
|
|
71
|
+
value.startswith("'") and value.endswith("'")
|
|
72
|
+
):
|
|
73
|
+
value = value[1:-1]
|
|
74
|
+
|
|
75
|
+
value = os.path.expandvars(value)
|
|
76
|
+
value = os.path.expanduser(value)
|
|
77
|
+
|
|
78
|
+
result[key] = value
|
|
79
|
+
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Argument parsing
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_arg_parser() -> argparse.ArgumentParser:
|
|
89
|
+
parser = argparse.ArgumentParser(
|
|
90
|
+
prog="install.py",
|
|
91
|
+
description=__doc__,
|
|
92
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
93
|
+
add_help=True,
|
|
94
|
+
)
|
|
95
|
+
_ = parser.add_argument(
|
|
96
|
+
"--profile",
|
|
97
|
+
default="host",
|
|
98
|
+
metavar="<name>",
|
|
99
|
+
help="Profile to use (default: host). Loads src/profiles/<name>.env.",
|
|
100
|
+
)
|
|
101
|
+
_ = parser.add_argument(
|
|
102
|
+
"--config-dir",
|
|
103
|
+
default="",
|
|
104
|
+
metavar="<path>",
|
|
105
|
+
dest="config_dir_override",
|
|
106
|
+
help="Override CONFIG_DIR from the profile.",
|
|
107
|
+
)
|
|
108
|
+
return parser
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# Main
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main() -> None:
|
|
117
|
+
parser = build_arg_parser()
|
|
118
|
+
args = parser.parse_args()
|
|
119
|
+
|
|
120
|
+
script_dir = Path(__file__).resolve().parent
|
|
121
|
+
repo_root = script_dir.parent
|
|
122
|
+
src_dir = repo_root / "src"
|
|
123
|
+
out_dir = repo_root / "out"
|
|
124
|
+
|
|
125
|
+
profile: str = str(args.profile)
|
|
126
|
+
config_dir_override: str = str(args.config_dir_override)
|
|
127
|
+
|
|
128
|
+
# --- Load profile ---
|
|
129
|
+
profile_file = src_dir / "profiles" / f"{profile}.env"
|
|
130
|
+
if not profile_file.is_file():
|
|
131
|
+
print(f"Error: profile file not found: {profile_file}", file=sys.stderr)
|
|
132
|
+
print("Available profiles:", file=sys.stderr)
|
|
133
|
+
profiles_dir = src_dir / "profiles"
|
|
134
|
+
for p in sorted(profiles_dir.glob("*.env")):
|
|
135
|
+
print(f" {p.stem}", file=sys.stderr)
|
|
136
|
+
sys.exit(1)
|
|
137
|
+
|
|
138
|
+
env_vars = parse_env_file(profile_file)
|
|
139
|
+
|
|
140
|
+
config_dir_str: str = env_vars.get("CONFIG_DIR", "")
|
|
141
|
+
opencode_config_src: str = env_vars.get("OPENCODE_CONFIG_SRC", "")
|
|
142
|
+
agent_vault: str = env_vars.get("AGENT_VAULT", os.environ.get("AGENT_VAULT", ""))
|
|
143
|
+
|
|
144
|
+
if not config_dir_str:
|
|
145
|
+
print("Error: CONFIG_DIR not set in profile.", file=sys.stderr)
|
|
146
|
+
sys.exit(1)
|
|
147
|
+
|
|
148
|
+
if not opencode_config_src:
|
|
149
|
+
print("Error: OPENCODE_CONFIG_SRC not set in profile.", file=sys.stderr)
|
|
150
|
+
sys.exit(1)
|
|
151
|
+
|
|
152
|
+
# --- Apply --config-dir override ---
|
|
153
|
+
if config_dir_override:
|
|
154
|
+
config_dir_str = config_dir_override
|
|
155
|
+
|
|
156
|
+
# --- Expand ~ ---
|
|
157
|
+
config_dir = Path(os.path.expanduser(config_dir_str)).resolve()
|
|
158
|
+
opencode_config_src = os.path.expanduser(opencode_config_src)
|
|
159
|
+
|
|
160
|
+
# --- Check out/ exists ---
|
|
161
|
+
if not out_dir.is_dir():
|
|
162
|
+
print("Error: out/ directory not found.", file=sys.stderr)
|
|
163
|
+
print("Run build.py first to produce the build output:", file=sys.stderr)
|
|
164
|
+
print(f" python3 {script_dir / 'build.py'}", file=sys.stderr)
|
|
165
|
+
sys.exit(1)
|
|
166
|
+
|
|
167
|
+
# --- Safety: refuse if out/ == CONFIG_DIR ---
|
|
168
|
+
try:
|
|
169
|
+
resolved_out = out_dir.resolve()
|
|
170
|
+
resolved_config = config_dir.resolve()
|
|
171
|
+
except Exception:
|
|
172
|
+
resolved_out = out_dir
|
|
173
|
+
resolved_config = config_dir
|
|
174
|
+
|
|
175
|
+
if resolved_out == resolved_config:
|
|
176
|
+
print("Error: out/ directory equals target CONFIG_DIR.", file=sys.stderr)
|
|
177
|
+
print("", file=sys.stderr)
|
|
178
|
+
print(f" out/: {resolved_out}", file=sys.stderr)
|
|
179
|
+
print(f" CONFIG_DIR: {resolved_config}", file=sys.stderr)
|
|
180
|
+
print("", file=sys.stderr)
|
|
181
|
+
print(
|
|
182
|
+
"Rsyncing out/ onto itself would be destructive. Check your profile.",
|
|
183
|
+
file=sys.stderr,
|
|
184
|
+
)
|
|
185
|
+
sys.exit(1)
|
|
186
|
+
|
|
187
|
+
# --- Rsync out/ to CONFIG_DIR ---
|
|
188
|
+
print(f"Deploying to: {config_dir}")
|
|
189
|
+
print(f"Profile: {profile} ({profile_file})")
|
|
190
|
+
print(f"Source: {out_dir}")
|
|
191
|
+
print()
|
|
192
|
+
|
|
193
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
|
|
195
|
+
rsync_cmd = [
|
|
196
|
+
"rsync",
|
|
197
|
+
"-a",
|
|
198
|
+
"--delete",
|
|
199
|
+
f"{out_dir}/",
|
|
200
|
+
f"{config_dir}/",
|
|
201
|
+
]
|
|
202
|
+
_ = subprocess.run(rsync_cmd, check=True)
|
|
203
|
+
print("Rsync complete.")
|
|
204
|
+
|
|
205
|
+
# --- Deploy AoE config ---
|
|
206
|
+
aoe_src = src_dir / "aoe-config.toml"
|
|
207
|
+
aoe_dest = Path.home() / ".config" / "aoe" / "config.toml"
|
|
208
|
+
if aoe_src.is_file():
|
|
209
|
+
if agent_vault:
|
|
210
|
+
aoe_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
211
|
+
content = aoe_src.read_text()
|
|
212
|
+
content = content.replace("{{AGENT_VAULT}}", agent_vault)
|
|
213
|
+
content = content.replace("{{OPENCODE_CONFIG_SRC}}", opencode_config_src)
|
|
214
|
+
_ = aoe_dest.write_text(content)
|
|
215
|
+
print(f"AoE config deployed to: {aoe_dest}")
|
|
216
|
+
else:
|
|
217
|
+
print(
|
|
218
|
+
"Warning: AGENT_VAULT not set — skipping AoE config deployment.",
|
|
219
|
+
file=sys.stderr,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# --- Summary ---
|
|
223
|
+
print()
|
|
224
|
+
print("Done.")
|
|
225
|
+
print()
|
|
226
|
+
print(f" Profile: {profile}")
|
|
227
|
+
print(f" Source: {out_dir}")
|
|
228
|
+
print(f" Target directory: {config_dir}")
|
|
229
|
+
print(f" OPENCODE_CONFIG_SRC: {opencode_config_src}")
|
|
230
|
+
print()
|
|
231
|
+
print("To use this config, ensure OPENCODE_CONFIG_SRC is set in your environment:")
|
|
232
|
+
print(f' export OPENCODE_CONFIG_SRC="{opencode_config_src}"')
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__":
|
|
236
|
+
main()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# lint.sh -- run the same checks as CI (.github/workflows/lint.yml)
|
|
3
|
+
# Exit codes: 0 = all passed, 1 = one or more checks failed
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
cd "$(git rev-parse --show-toplevel)"
|
|
7
|
+
|
|
8
|
+
failed=()
|
|
9
|
+
|
|
10
|
+
header() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; }
|
|
11
|
+
|
|
12
|
+
# -- shfmt -------------------------------------------------------------------
|
|
13
|
+
header "shfmt"
|
|
14
|
+
mapfile -t sh_files < <(find . -name '*.sh' -not -path '*/node_modules/*')
|
|
15
|
+
if [[ ${#sh_files[@]} -gt 0 ]]; then
|
|
16
|
+
if ! shfmt -d "${sh_files[@]}"; then
|
|
17
|
+
failed+=(shfmt)
|
|
18
|
+
fi
|
|
19
|
+
else
|
|
20
|
+
echo "(no .sh files found)"
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
# -- shellcheck ---------------------------------------------------------------
|
|
24
|
+
header "shellcheck"
|
|
25
|
+
if [[ ${#sh_files[@]} -gt 0 ]]; then
|
|
26
|
+
if ! shellcheck "${sh_files[@]}"; then
|
|
27
|
+
failed+=(shellcheck)
|
|
28
|
+
fi
|
|
29
|
+
else
|
|
30
|
+
echo "(no .sh files found)"
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# -- prettier -----------------------------------------------------------------
|
|
34
|
+
# Detect a way to run prettier: direct binary, then package-runner CLIs
|
|
35
|
+
prettier_cmd=()
|
|
36
|
+
if command -v prettier &>/dev/null; then
|
|
37
|
+
prettier_cmd=(prettier)
|
|
38
|
+
elif command -v bunx &>/dev/null; then
|
|
39
|
+
prettier_cmd=(bunx prettier)
|
|
40
|
+
elif command -v pnpx &>/dev/null; then
|
|
41
|
+
prettier_cmd=(pnpx prettier)
|
|
42
|
+
elif command -v npx &>/dev/null; then
|
|
43
|
+
prettier_cmd=(npx prettier)
|
|
44
|
+
elif command -v deno &>/dev/null; then
|
|
45
|
+
prettier_cmd=(deno run npm:prettier --)
|
|
46
|
+
else
|
|
47
|
+
prettier_cmd=()
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
header "prettier"
|
|
51
|
+
if [[ ${#prettier_cmd[@]} -eq 0 ]]; then
|
|
52
|
+
echo "SKIP: no prettier, bunx, pnpx, npx, or deno found"
|
|
53
|
+
failed+=(prettier)
|
|
54
|
+
elif ! "${prettier_cmd[@]}" --check .; then
|
|
55
|
+
failed+=(prettier)
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
# -- ruff format --------------------------------------------------------------
|
|
59
|
+
header "ruff format"
|
|
60
|
+
if ! uvx ruff format --check scripts/; then
|
|
61
|
+
failed+=(ruff-format)
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
# -- ruff check ---------------------------------------------------------------
|
|
65
|
+
header "ruff check"
|
|
66
|
+
if ! uvx ruff check scripts/; then
|
|
67
|
+
failed+=(ruff-check)
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
# -- basedpyright -------------------------------------------------------------
|
|
71
|
+
# Run from scripts/ so basedpyright finds pyproject.toml config
|
|
72
|
+
header "basedpyright"
|
|
73
|
+
if ! (cd scripts && uvx basedpyright .); then
|
|
74
|
+
failed+=(basedpyright)
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
# -- summary ------------------------------------------------------------------
|
|
78
|
+
echo
|
|
79
|
+
if [[ ${#failed[@]} -gt 0 ]]; then
|
|
80
|
+
printf '\033[1;31mFailed: %s\033[0m\n' "${failed[*]}"
|
|
81
|
+
exit 1
|
|
82
|
+
else
|
|
83
|
+
printf '\033[1;32mAll checks passed.\033[0m\n'
|
|
84
|
+
fi
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cubething-occonf"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Standalone installer for ada-x64/opencode-config"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "ada-x64" }]
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
cubething-occonf = "scripts.setup:main"
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
include = ["scripts/setup.py"]
|
|
18
|
+
|
|
19
|
+
[tool.ruff]
|
|
20
|
+
target-version = "py312"
|
|
21
|
+
|
|
22
|
+
[tool.ruff.lint]
|
|
23
|
+
select = ["E", "F", "W", "I", "UP", "B", "RUF"]
|
|
24
|
+
ignore = [
|
|
25
|
+
"E501", # line too long — formatter handles this
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.ruff.format]
|
|
29
|
+
quote-style = "double"
|
|
30
|
+
indent-style = "space"
|
|
31
|
+
|
|
32
|
+
[tool.basedpyright]
|
|
33
|
+
pythonVersion = "3.12"
|
|
34
|
+
typeCheckingMode = "standard"
|
|
35
|
+
include = ["scripts"]
|
|
36
|
+
ignore = ["**/__pycache__"]
|
|
37
|
+
|
|
38
|
+
# json.loads returns Any; these flow from it and can't be avoided without
|
|
39
|
+
# wrapping every call in a TypedDict — not worth it for a build script
|
|
40
|
+
reportAny = "none"
|
|
41
|
+
reportUnknownVariableType = "none"
|
|
42
|
+
reportUnknownParameterType = "none"
|
|
43
|
+
reportUnknownMemberType = "none"
|
|
44
|
+
reportUnknownArgumentType = "none"
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""setup.py — standalone installer for opencode-config
|
|
3
|
+
|
|
4
|
+
Bootstrapper entry point. Prompts for environment paths, downloads the
|
|
5
|
+
release tarball to a staging directory, runs build.py to produce stamped
|
|
6
|
+
output, then rsyncs out/ to ~/.config/opencode and writes shell env vars.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
# Recommended — via uv (no install required):
|
|
10
|
+
uvx cubething-occonf
|
|
11
|
+
|
|
12
|
+
# Or via pipx:
|
|
13
|
+
pipx run cubething-occonf
|
|
14
|
+
|
|
15
|
+
# Directly from a GitHub release (no PyPI needed):
|
|
16
|
+
uvx --from https://github.com/ada-x64/opencode-config/releases/latest/download/opencode-config.tar.gz cubething-occonf
|
|
17
|
+
|
|
18
|
+
# Pipe from curl (classic):
|
|
19
|
+
curl -fsSL https://github.com/ada-x64/opencode-config/releases/latest/download/setup.py | python3
|
|
20
|
+
|
|
21
|
+
# Or run the downloaded script directly:
|
|
22
|
+
python3 setup.py
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
import tarfile
|
|
30
|
+
import tempfile
|
|
31
|
+
import urllib.request
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import IO
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Config
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
CONFIG_DIR = Path.home() / ".config" / "opencode"
|
|
40
|
+
REPO = "ada-x64/opencode-config"
|
|
41
|
+
TARBALL_URL = (
|
|
42
|
+
f"https://github.com/{REPO}/releases/latest/download/opencode-config.tar.gz"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Color helpers
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def info(msg: str) -> None:
|
|
51
|
+
print(f"\033[1;32m==>\033[0m \033[1m{msg}\033[0m")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def warn(msg: str) -> None:
|
|
55
|
+
print(f"\033[1;33mWarning:\033[0m {msg}", file=sys.stderr)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Interactive prompts
|
|
60
|
+
# When piped via curl | python3, stdin is the script, not the terminal.
|
|
61
|
+
# Open /dev/tty explicitly so input() can interact with the user.
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def open_tty() -> IO[str] | None:
|
|
66
|
+
"""Return a file object for /dev/tty, or None if unavailable."""
|
|
67
|
+
try:
|
|
68
|
+
return open("/dev/tty")
|
|
69
|
+
except OSError:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def prompt(tty: IO[str], message: str) -> str:
|
|
74
|
+
"""Write prompt to stdout and read a line from tty."""
|
|
75
|
+
print(message, end="", flush=True)
|
|
76
|
+
return tty.readline().rstrip("\n")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def prompt_required(tty: IO[str], label: str, env_key: str) -> str:
|
|
80
|
+
"""Prompt for a required value, looping until non-empty. Honours env default."""
|
|
81
|
+
default = os.environ.get(env_key, "")
|
|
82
|
+
while True:
|
|
83
|
+
if default:
|
|
84
|
+
value = prompt(tty, f"{label} [{default}]: ")
|
|
85
|
+
else:
|
|
86
|
+
value = prompt(tty, f"{label} (required): ")
|
|
87
|
+
result = value or default
|
|
88
|
+
if result:
|
|
89
|
+
return result
|
|
90
|
+
warn(f"{env_key} is required.")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def prompt_optional(tty: IO[str], label: str) -> str:
|
|
94
|
+
"""Prompt for an optional value. Returns empty string if skipped."""
|
|
95
|
+
return prompt(tty, f"{label} (optional, Enter to skip): ")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
# Shell profile helpers
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def find_shell_profile() -> Path:
|
|
104
|
+
zdotdir = os.environ.get("ZDOTDIR", "")
|
|
105
|
+
if zdotdir:
|
|
106
|
+
return Path(zdotdir) / ".zshrc"
|
|
107
|
+
zshrc = Path.home() / ".zshrc"
|
|
108
|
+
if zshrc.exists():
|
|
109
|
+
return zshrc
|
|
110
|
+
return Path.home() / ".bashrc"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# Main
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def main() -> None:
|
|
119
|
+
print()
|
|
120
|
+
print(" cubething-occonf setup")
|
|
121
|
+
print(" =====================")
|
|
122
|
+
print()
|
|
123
|
+
|
|
124
|
+
# --- Open /dev/tty for interactive prompts ---
|
|
125
|
+
tty = open_tty()
|
|
126
|
+
if tty is None:
|
|
127
|
+
warn("No interactive terminal available.")
|
|
128
|
+
warn(
|
|
129
|
+
"Set AGENT_VAULT and AGENT_REPOS in your environment and re-run setup.py directly:"
|
|
130
|
+
)
|
|
131
|
+
warn(" AGENT_VAULT=~/obsidian/agent.obs python3 setup.py")
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
agent_vault = prompt_required(tty, "Where is your agent vault?", "AGENT_VAULT")
|
|
136
|
+
agent_repos = prompt_required(tty, "Where do you keep repos?", "AGENT_REPOS")
|
|
137
|
+
ntfy_topic = prompt_optional(tty, "ntfy.sh topic for push notifications")
|
|
138
|
+
finally:
|
|
139
|
+
tty.close()
|
|
140
|
+
|
|
141
|
+
print()
|
|
142
|
+
|
|
143
|
+
# --- Download tarball to staging directory ---
|
|
144
|
+
info("Downloading opencode-config...")
|
|
145
|
+
staging = Path(tempfile.mkdtemp(prefix="opencode-config-"))
|
|
146
|
+
try:
|
|
147
|
+
tarball_path = staging / "opencode-config.tar.gz"
|
|
148
|
+
_, _ = urllib.request.urlretrieve(TARBALL_URL, tarball_path)
|
|
149
|
+
|
|
150
|
+
info("Extracting...")
|
|
151
|
+
with tarfile.open(tarball_path, "r:gz") as tf:
|
|
152
|
+
tf.extractall(staging)
|
|
153
|
+
|
|
154
|
+
# The tarball may contain a top-level directory — find the repo root
|
|
155
|
+
# (look for scripts/build.py to identify it)
|
|
156
|
+
repo_root = staging
|
|
157
|
+
for candidate in staging.iterdir():
|
|
158
|
+
if candidate.is_dir() and (candidate / "scripts" / "build.py").is_file():
|
|
159
|
+
repo_root = candidate
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
# --- Set up environment for child processes ---
|
|
163
|
+
env = os.environ.copy()
|
|
164
|
+
env["OPENCODE_CONFIG_SRC"] = str(CONFIG_DIR)
|
|
165
|
+
env["AGENT_VAULT"] = agent_vault
|
|
166
|
+
env["AGENT_REPOS"] = agent_repos
|
|
167
|
+
if ntfy_topic:
|
|
168
|
+
env["NTFY_TOPIC"] = ntfy_topic
|
|
169
|
+
|
|
170
|
+
# --- Run build.py (generates build.json, copies src/ → out/, stamps everything) ---
|
|
171
|
+
info("Running build.py...")
|
|
172
|
+
build_py = repo_root / "scripts" / "build.py"
|
|
173
|
+
_ = subprocess.run(
|
|
174
|
+
[sys.executable, str(build_py), "--config-dir", str(CONFIG_DIR)],
|
|
175
|
+
check=True,
|
|
176
|
+
env=env,
|
|
177
|
+
cwd=str(repo_root),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# --- Run install.py (rsyncs out/ → CONFIG_DIR, deploys AoE config) ---
|
|
181
|
+
info("Running install.py...")
|
|
182
|
+
install_py = repo_root / "scripts" / "install.py"
|
|
183
|
+
_ = subprocess.run(
|
|
184
|
+
[sys.executable, str(install_py), "--config-dir", str(CONFIG_DIR)],
|
|
185
|
+
check=True,
|
|
186
|
+
env=env,
|
|
187
|
+
cwd=str(repo_root),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
finally:
|
|
191
|
+
# Clean up staging directory
|
|
192
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
193
|
+
|
|
194
|
+
# --- Write environment variables to shell profile ---
|
|
195
|
+
profile = find_shell_profile()
|
|
196
|
+
try:
|
|
197
|
+
existing = profile.read_text() if profile.exists() else ""
|
|
198
|
+
except OSError:
|
|
199
|
+
existing = ""
|
|
200
|
+
|
|
201
|
+
if "OPENCODE_CONFIG_SRC" in existing:
|
|
202
|
+
warn(
|
|
203
|
+
"Shell profile already contains OPENCODE_CONFIG_SRC — skipping env block."
|
|
204
|
+
+ f" Update {profile} manually if needed."
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
with profile.open("a") as fh:
|
|
208
|
+
_ = fh.write("\n# opencode-config\n")
|
|
209
|
+
_ = fh.write(f'export OPENCODE_CONFIG_SRC="{CONFIG_DIR}"\n')
|
|
210
|
+
_ = fh.write(f'export AGENT_VAULT="{agent_vault}"\n')
|
|
211
|
+
_ = fh.write(f'export AGENT_REPOS="{agent_repos}"\n')
|
|
212
|
+
if ntfy_topic:
|
|
213
|
+
_ = fh.write(f'export NTFY_TOPIC="{ntfy_topic}"\n')
|
|
214
|
+
info(f"Environment variables written to {profile}")
|
|
215
|
+
|
|
216
|
+
# --- Summary ---
|
|
217
|
+
print()
|
|
218
|
+
info("Setup complete!")
|
|
219
|
+
print()
|
|
220
|
+
print(f" Config: {CONFIG_DIR}")
|
|
221
|
+
print(" AoE: ~/.config/aoe/config.toml")
|
|
222
|
+
print(f" Vault: {agent_vault}")
|
|
223
|
+
print(f" Repos: {agent_repos}")
|
|
224
|
+
print()
|
|
225
|
+
print(f"Restart your shell or run: source ~/{profile.name}")
|
|
226
|
+
print()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
if __name__ == "__main__":
|
|
230
|
+
main()
|