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,9 @@
1
+ node_modules
2
+ package.json
3
+ bun.lock
4
+ *.bak
5
+ sandbox/
6
+ out/
7
+ __pycache__
8
+ .ruff_cache
9
+ build.json
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: cubething-occonf
3
+ Version: 0.1.0
4
+ Summary: Standalone installer for ada-x64/opencode-config
5
+ Author: ada-x64
6
+ License: MIT
7
+ Requires-Python: >=3.12
@@ -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()