aetherion 0.1.0__py3-none-any.whl

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.
Files changed (40) hide show
  1. aetherion/__init__.py +3 -0
  2. aetherion/__main__.py +10 -0
  3. aetherion/cli.py +410 -0
  4. aetherion/data/Dockerfile +656 -0
  5. aetherion/data/scripts/install-treesitter.lua +35 -0
  6. aetherion/data/skeleton/etc/apt/apt.conf.d/99-aetherion-minimal +4 -0
  7. aetherion/data/skeleton/etc/containers/containers.conf +5 -0
  8. aetherion/data/skeleton/etc/containers/storage.conf +5 -0
  9. aetherion/data/skeleton/home/aetherion/.bashrc +37 -0
  10. aetherion/data/skeleton/home/aetherion/.config/nvim/.gitignore +1 -0
  11. aetherion/data/skeleton/home/aetherion/.config/nvim/README.md +216 -0
  12. aetherion/data/skeleton/home/aetherion/.config/nvim/ascii_header.txt +7 -0
  13. aetherion/data/skeleton/home/aetherion/.config/nvim/init.lua +54 -0
  14. aetherion/data/skeleton/home/aetherion/.config/nvim/lazy-lock.json +35 -0
  15. aetherion/data/skeleton/home/aetherion/.config/nvim/lua/commands/git.lua +14 -0
  16. aetherion/data/skeleton/home/aetherion/.config/nvim/lua/options.lua +338 -0
  17. aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/alpha.lua +247 -0
  18. aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/catppuccin.lua +8 -0
  19. aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/cinnamon.lua +14 -0
  20. aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/debug.lua +185 -0
  21. aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/git-utils.lua +109 -0
  22. aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/lsp-config.lua +265 -0
  23. aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/lualine.lua +28 -0
  24. aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/none-ls.lua +50 -0
  25. aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/nvim-cmp.lua +107 -0
  26. aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/nvim-tree.lua +55 -0
  27. aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/nvim-treesitter.lua +32 -0
  28. aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/smear-cursor.lua +4 -0
  29. aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/telescope.lua +110 -0
  30. aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/vim-godot.lua +5 -0
  31. aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/vim-visual-multi.lua +20 -0
  32. aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/which-key.lua +49 -0
  33. aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins.lua +1 -0
  34. aetherion/data/skeleton/home/aetherion/.config/nvim/stylua.toml +8 -0
  35. aetherion/data/skeleton/home/aetherion/.config/starship.toml +210 -0
  36. aetherion-0.1.0.dist-info/METADATA +99 -0
  37. aetherion-0.1.0.dist-info/RECORD +40 -0
  38. aetherion-0.1.0.dist-info/WHEEL +4 -0
  39. aetherion-0.1.0.dist-info/entry_points.txt +2 -0
  40. aetherion-0.1.0.dist-info/licenses/LICENSE +21 -0
aetherion/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Aetherion: dev container launcher for AI coding agents."""
2
+
3
+ __version__ = "0.1.0"
aetherion/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Entry point for `python -m aetherion`. The console-script entry point in
2
+ pyproject.toml points at the same function, so both invocation paths share
3
+ a single implementation."""
4
+
5
+ import sys
6
+
7
+ from aetherion.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
aetherion/cli.py ADDED
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import os
6
+ import secrets
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ from pathlib import Path
12
+
13
+ DEFAULT_IMAGE = "localhost/aetherion:dev"
14
+ CONTAINER_HOME = "/home/aetherion"
15
+
16
+ # Files shipped alongside the launcher that together form the docker build
17
+ # context. Order is purely cosmetic (used in log output).
18
+ BUNDLED_ASSETS: tuple[str, ...] = ("Dockerfile", "skeleton", "scripts")
19
+
20
+ # Per-agent state we preserve on the host so that login or first-run setup
21
+ # done inside one container session survives into the next. Each tuple lists
22
+ # the paths (relative to CONTAINER_HOME, mirrored under the host data dir)
23
+ # owned by that agent — keep new paths grouped under the agent that owns
24
+ # them so `--agents <name>` slicing keeps working with no extra plumbing.
25
+ AGENT_PATHS: dict[str, tuple[str, ...]] = {
26
+ "claude": (".claude", ".claude.json"),
27
+ "cursor": (".cursor", ".config/cursor"),
28
+ "copilot": (".copilot",),
29
+ "gemini": (".gemini",),
30
+ }
31
+
32
+
33
+ def _detect_runtime() -> str:
34
+ override = os.environ.get("AETHERION_CONTAINER_RUNTIME")
35
+ if override:
36
+ return override
37
+ for candidate in ("podman", "docker"):
38
+ if shutil.which(candidate):
39
+ return candidate
40
+ sys.stderr.write(
41
+ "aetherion: container runtime detection failed "
42
+ "(tried podman and docker, neither found in PATH).\n"
43
+ "Set AETHERION_CONTAINER_RUNTIME to override.\n"
44
+ )
45
+ raise SystemExit(2)
46
+
47
+
48
+ # Container runtime: env var overrides; auto-detect prefers podman over docker.
49
+ CONTAINER_RUNTIME = _detect_runtime()
50
+ # Match against the basename so a full path (e.g. /usr/bin/docker) still works.
51
+ _RUNTIME_IS_DOCKER = Path(CONTAINER_RUNTIME).name == "docker"
52
+
53
+
54
+ def user_ns_args() -> list[str]:
55
+ # Podman and Docker diverge on user-namespace handling. Podman's
56
+ # `keep-id` maps the host UID/GID into the container so bind-mounted
57
+ # config stays owned by the caller. Docker has no `keep-id`; running as
58
+ # an explicit uid:gid achieves the equivalent ownership on the mounts.
59
+ if _RUNTIME_IS_DOCKER:
60
+ return ["--user", "1000:1000"]
61
+ return ["--userns=keep-id:uid=1000,gid=1000"]
62
+
63
+
64
+ def _bundled_assets_dir() -> Path:
65
+ # Dockerfile + skeleton/ + scripts/ ship inside the package itself, in a
66
+ # sibling data/ directory. This resolves to the same real path whether
67
+ # the launcher runs from a source checkout, an editable install, or a
68
+ # pip-installed wheel — no importlib.resources dance required, because
69
+ # we always need real filesystem paths anyway (docker build + shutil
70
+ # both want them).
71
+ return Path(__file__).resolve().parent / "data"
72
+
73
+
74
+ def _parse_args(argv: list[str]) -> argparse.Namespace:
75
+ parser = argparse.ArgumentParser(
76
+ prog="aetherion",
77
+ description="Launch the aetherion dev container.",
78
+ )
79
+ known = ", ".join(AGENT_PATHS)
80
+ parser.add_argument(
81
+ "--agents",
82
+ metavar="LIST",
83
+ type=lambda s: [a.strip() for a in s.split(",") if a.strip()],
84
+ default=list(AGENT_PATHS),
85
+ help=(
86
+ "Comma-separated subset of agent toolchains whose login/setup state "
87
+ "to expose into the container. Anything not listed is neither "
88
+ f"mounted in nor preserved on exit. Default: all. Known: {known}. "
89
+ "Pass an empty value (--agents '') to expose nothing."
90
+ ),
91
+ )
92
+ parser.add_argument(
93
+ "--image",
94
+ metavar="REF",
95
+ default=DEFAULT_IMAGE,
96
+ help=f"Container image to run (and to tag when building). Default: {DEFAULT_IMAGE}.",
97
+ )
98
+ parser.add_argument(
99
+ "--build-image",
100
+ action="store_true",
101
+ help=(
102
+ "Build the image and exit (does not launch the container). Uses "
103
+ "--build-dir as context if given, otherwise the Dockerfile + "
104
+ "skeleton bundled with this script. Chain with && to launch after."
105
+ ),
106
+ )
107
+ parser.add_argument(
108
+ "--build-dir",
109
+ metavar="PATH",
110
+ default=None,
111
+ help=(
112
+ "Directory to use as the build context. Must contain a Dockerfile. "
113
+ "Combine with --build-image to build from a customized copy "
114
+ "(see --extract)."
115
+ ),
116
+ )
117
+ parser.add_argument(
118
+ "--extract",
119
+ metavar="PATH",
120
+ default=None,
121
+ help=(
122
+ "Copy the bundled Dockerfile, skeleton/, and scripts/ into PATH "
123
+ "and exit without launching. Use this to customize the image: "
124
+ "edit, then `aetherion --build-image --build-dir PATH`."
125
+ ),
126
+ )
127
+ parser.add_argument(
128
+ "-e", "--env",
129
+ action="append",
130
+ default=[],
131
+ metavar="NAME=VALUE",
132
+ help=(
133
+ "Set an environment variable inside the container. Repeat for "
134
+ "multiple (e.g. -e ONE=1 --env TWO=2). Values may contain spaces "
135
+ "if quoted at the shell: --env 'NAME=has spaces'. A bare name "
136
+ "with no `=` inherits the value from the host environment."
137
+ ),
138
+ )
139
+ return parser.parse_args(argv)
140
+
141
+
142
+ def main(argv: list[str] | None = None) -> int:
143
+ # argv=None lets the console-script entry point (pyproject.toml) call
144
+ # main() with no args while keeping the parameter explicit for tests
145
+ # and for the `python -m aetherion` wrapper.
146
+ if argv is None:
147
+ argv = sys.argv[1:]
148
+ args = _parse_args(argv)
149
+
150
+ # --extract is terminal: it never launches the container.
151
+ if args.extract is not None:
152
+ return _extract_bundle(Path(args.extract).expanduser().resolve())
153
+
154
+ unknown = [a for a in args.agents if a not in AGENT_PATHS]
155
+ if unknown:
156
+ sys.stderr.write(
157
+ f"aetherion: unknown agent(s): {', '.join(unknown)}\n"
158
+ f"aetherion: known agents: {', '.join(AGENT_PATHS)}\n"
159
+ )
160
+ return 2
161
+
162
+ selected: list[str] = args.agents
163
+ if set(selected) != set(AGENT_PATHS):
164
+ scope = ", ".join(selected) if selected else "(none)"
165
+ sys.stderr.write(f"aetherion: agent scope limited to: {scope}\n")
166
+
167
+ image: str = args.image
168
+
169
+ # --build-image is terminal: it never launches the container, regardless
170
+ # of build success or failure. The build's exit code propagates as-is.
171
+ if args.build_image:
172
+ context = (
173
+ Path(args.build_dir).expanduser().resolve()
174
+ if args.build_dir is not None
175
+ else _bundled_assets_dir()
176
+ )
177
+ return _build_image(image, context)
178
+
179
+ if not _image_exists(image):
180
+ sys.stderr.write(
181
+ f"aetherion: image '{image}' is not present locally.\n"
182
+ "aetherion: this launcher does not pull images — build it locally:\n"
183
+ f" aetherion --build-image"
184
+ + (f" --image {image}" if image != DEFAULT_IMAGE else "")
185
+ + "\n"
186
+ "aetherion: or extract a copy of the build files first to customize:\n"
187
+ " aetherion --extract <path>\n"
188
+ )
189
+ return 1
190
+
191
+ home = Path.home()
192
+ pwd = Path.cwd()
193
+
194
+ data_dir = home / ".aetherion" / "data"
195
+ data_dir.mkdir(parents=True, exist_ok=True)
196
+
197
+ # Rewrite host home → container home so a host path of ~/foo lands at ~/foo
198
+ # inside the container too. Anything outside $HOME is mounted at its real
199
+ # path, since there's no portable home-relative form for it.
200
+ if pwd == home:
201
+ container_workdir = CONTAINER_HOME
202
+ elif home in pwd.parents:
203
+ container_workdir = f"{CONTAINER_HOME}/{pwd.relative_to(home)}"
204
+ else:
205
+ container_workdir = str(pwd)
206
+
207
+ mounts: list[str] = []
208
+ # (agent, rel) for each deferred path so the first-run notice and the
209
+ # post-exit "preserved <agent>" log can name the agent that owns it.
210
+ deferred: list[tuple[str, str]] = []
211
+
212
+ for agent in selected:
213
+ for rel in AGENT_PATHS[agent]:
214
+ host_path = data_dir / rel
215
+ container_path = f"{CONTAINER_HOME}/{rel}"
216
+ if host_path.exists():
217
+ mounts += ["-v", f"{host_path}:{container_path}:z"]
218
+ else:
219
+ deferred.append((agent, rel))
220
+
221
+ if deferred:
222
+ sys.stderr.write(
223
+ "aetherion: these agent paths are not yet preserved on the host;\n"
224
+ "any that get created during the session will be extracted on clean exit:\n"
225
+ )
226
+ last_agent: str | None = None
227
+ for agent, rel in deferred:
228
+ if agent != last_agent:
229
+ sys.stderr.write(f" [{agent}]\n")
230
+ last_agent = agent
231
+ sys.stderr.write(f" - {data_dir / rel}\n")
232
+ sys.stderr.write("aetherion: let the container exit cleanly, do not SIGKILL the launcher.\n\n")
233
+
234
+ # --cidfile instead of --rm: we need the container to outlive the shell so
235
+ # we can diff and `cp` config out before removing it.
236
+ instance_id = secrets.token_hex(4)
237
+ instance_name = f"aetherion-{instance_id}"
238
+
239
+ with tempfile.TemporaryDirectory(prefix="aetherion-cid-") as td:
240
+ cidfile = Path(td) / "cid"
241
+
242
+ # Passed through subprocess as separate argv entries, so values with
243
+ # spaces or shell-special characters are safe — no shell evaluation.
244
+ env_args: list[str] = []
245
+ for kv in args.env:
246
+ env_args += ["-e", kv]
247
+
248
+ run_argv = [
249
+ CONTAINER_RUNTIME, "run",
250
+ *user_ns_args(),
251
+ "--name", instance_name,
252
+ "--hostname", instance_id,
253
+ "--cidfile", str(cidfile),
254
+ *env_args,
255
+ "-v", f"{pwd}:{container_workdir}:z",
256
+ "-w", container_workdir,
257
+ *mounts,
258
+ "-it",
259
+ image,
260
+ ]
261
+
262
+ rc = subprocess.run(run_argv).returncode
263
+
264
+ if not cidfile.exists():
265
+ return rc
266
+
267
+ cid = cidfile.read_text().strip()
268
+
269
+ try:
270
+ if deferred:
271
+ preserve_agent_state(cid, deferred, data_dir)
272
+ finally:
273
+ subprocess.run(
274
+ [CONTAINER_RUNTIME, "rm", "-f", cid],
275
+ stdout=subprocess.DEVNULL,
276
+ stderr=subprocess.DEVNULL,
277
+ )
278
+
279
+ return rc
280
+
281
+
282
+ def _image_exists(image: str) -> bool:
283
+ return subprocess.run(
284
+ [CONTAINER_RUNTIME, "image", "inspect", image],
285
+ stdout=subprocess.DEVNULL,
286
+ stderr=subprocess.DEVNULL,
287
+ ).returncode == 0
288
+
289
+
290
+ def _build_image(image: str, context: Path) -> int:
291
+ if not context.is_dir():
292
+ sys.stderr.write(f"aetherion: build context does not exist: {context}\n")
293
+ return 1
294
+ if not (context / "Dockerfile").is_file():
295
+ sys.stderr.write(
296
+ f"aetherion: no Dockerfile found in build context: {context}\n"
297
+ "aetherion: run `aetherion --extract <path>` to populate one.\n"
298
+ )
299
+ return 1
300
+ sys.stderr.write(f"aetherion: building {image} from {context}\n")
301
+ return subprocess.run(
302
+ [CONTAINER_RUNTIME, "build", "-t", image, str(context)],
303
+ ).returncode
304
+
305
+
306
+ def _extract_bundle(dest: Path) -> int:
307
+ src = _bundled_assets_dir()
308
+ missing = [name for name in BUNDLED_ASSETS if not (src / name).exists()]
309
+ if missing:
310
+ sys.stderr.write(
311
+ f"aetherion: bundled asset(s) missing from {src}: {', '.join(missing)}\n"
312
+ "aetherion: this launcher must be run from a complete source tree.\n"
313
+ )
314
+ return 1
315
+
316
+ dest.mkdir(parents=True, exist_ok=True)
317
+ for name in BUNDLED_ASSETS:
318
+ s = src / name
319
+ d = dest / name
320
+ if s.is_dir():
321
+ # dirs_exist_ok overlays into an existing tree rather than failing,
322
+ # but it still only touches files that exist in the source — so any
323
+ # extra files the user added under dest/<name>/ stay put.
324
+ shutil.copytree(s, d, dirs_exist_ok=True)
325
+ else:
326
+ shutil.copy2(s, d)
327
+
328
+ sys.stderr.write(
329
+ f"aetherion: extracted {', '.join(BUNDLED_ASSETS)} to {dest}\n"
330
+ f"aetherion: build with: aetherion --build-image --build-dir {dest}\n"
331
+ )
332
+ return 0
333
+
334
+
335
+ def preserve_agent_state(cid: str, deferred: list[tuple[str, str]], data_dir: Path) -> None:
336
+ # Use `<runtime> diff` to find which deferred paths the container actually
337
+ # touched. Skipping `cp` on untouched paths avoids spurious errors and
338
+ # keeps the host clean of empty agent dirs from sessions where the user
339
+ # never logged in.
340
+ touched = _diff_paths(cid)
341
+ for agent, rel in deferred:
342
+ container_path = f"{CONTAINER_HOME}/{rel}"
343
+ if not _was_touched(container_path, touched):
344
+ continue
345
+ host_path = data_dir / rel
346
+ if extract(cid, container_path, host_path):
347
+ sys.stderr.write(f"aetherion: preserved {agent} state at {host_path}\n")
348
+
349
+
350
+ def _diff_paths(cid: str) -> set[str]:
351
+ """Return the set of container-fs paths reported as Added or Changed by
352
+ `<runtime> diff`. Deletes are ignored — nothing to extract."""
353
+ result = subprocess.run(
354
+ [CONTAINER_RUNTIME, "diff", cid],
355
+ capture_output=True,
356
+ text=True,
357
+ )
358
+ if result.returncode != 0:
359
+ return set()
360
+ paths: set[str] = set()
361
+ for line in result.stdout.splitlines():
362
+ kind, _, path = line.partition(" ")
363
+ if kind in ("A", "C") and path:
364
+ paths.add(path)
365
+ return paths
366
+
367
+
368
+ def _was_touched(target: str, touched: set[str]) -> bool:
369
+ if target in touched:
370
+ return True
371
+ prefix = target + "/"
372
+ return any(p.startswith(prefix) for p in touched)
373
+
374
+
375
+ def extract(cid: str, src_in_container: str, dst_on_host: Path) -> bool:
376
+ dst_on_host.parent.mkdir(parents=True, exist_ok=True)
377
+
378
+ # Stage to a sibling tmp path so the final move into place is an atomic
379
+ # rename on the same filesystem — no half-written config visible to a
380
+ # future run.
381
+ staging = dst_on_host.with_name(dst_on_host.name + ".tmp-extract")
382
+ _remove(staging)
383
+
384
+ cp = subprocess.run(
385
+ [CONTAINER_RUNTIME, "cp", f"{cid}:{src_in_container}", str(staging)],
386
+ stdout=subprocess.DEVNULL,
387
+ stderr=subprocess.PIPE,
388
+ )
389
+
390
+ if cp.returncode != 0:
391
+ _remove(staging)
392
+ return False
393
+
394
+ # Defensive: dst shouldn't exist (deferred = not on host at launch), but
395
+ # if a concurrent run raced us, clear it so os.replace can land cleanly
396
+ # even when staging is a directory.
397
+ _remove(dst_on_host)
398
+ os.replace(staging, dst_on_host)
399
+ return True
400
+
401
+
402
+ def _remove(path: Path) -> None:
403
+ if path.is_dir() and not path.is_symlink():
404
+ shutil.rmtree(path)
405
+ elif path.exists() or path.is_symlink():
406
+ path.unlink()
407
+
408
+
409
+ if __name__ == "__main__":
410
+ sys.exit(main())