aetherion 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.
Files changed (43) hide show
  1. aetherion-0.1.0/.gitignore +10 -0
  2. aetherion-0.1.0/.python-version +1 -0
  3. aetherion-0.1.0/LICENSE +21 -0
  4. aetherion-0.1.0/Makefile +18 -0
  5. aetherion-0.1.0/PKG-INFO +99 -0
  6. aetherion-0.1.0/README.md +91 -0
  7. aetherion-0.1.0/pyproject.toml +17 -0
  8. aetherion-0.1.0/src/aetherion/__init__.py +3 -0
  9. aetherion-0.1.0/src/aetherion/__main__.py +10 -0
  10. aetherion-0.1.0/src/aetherion/cli.py +410 -0
  11. aetherion-0.1.0/src/aetherion/data/Dockerfile +656 -0
  12. aetherion-0.1.0/src/aetherion/data/scripts/install-treesitter.lua +35 -0
  13. aetherion-0.1.0/src/aetherion/data/skeleton/etc/apt/apt.conf.d/99-aetherion-minimal +4 -0
  14. aetherion-0.1.0/src/aetherion/data/skeleton/etc/containers/containers.conf +5 -0
  15. aetherion-0.1.0/src/aetherion/data/skeleton/etc/containers/storage.conf +5 -0
  16. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.bashrc +37 -0
  17. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/.gitignore +1 -0
  18. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/README.md +216 -0
  19. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/ascii_header.txt +7 -0
  20. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/init.lua +54 -0
  21. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lazy-lock.json +35 -0
  22. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/commands/git.lua +14 -0
  23. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/options.lua +338 -0
  24. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/alpha.lua +247 -0
  25. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/catppuccin.lua +8 -0
  26. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/cinnamon.lua +14 -0
  27. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/debug.lua +185 -0
  28. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/git-utils.lua +109 -0
  29. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/lsp-config.lua +265 -0
  30. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/lualine.lua +28 -0
  31. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/none-ls.lua +50 -0
  32. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/nvim-cmp.lua +107 -0
  33. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/nvim-tree.lua +55 -0
  34. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/nvim-treesitter.lua +32 -0
  35. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/smear-cursor.lua +4 -0
  36. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/telescope.lua +110 -0
  37. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/vim-godot.lua +5 -0
  38. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/vim-visual-multi.lua +20 -0
  39. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/which-key.lua +49 -0
  40. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins.lua +1 -0
  41. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/stylua.toml +8 -0
  42. aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/starship.toml +210 -0
  43. aetherion-0.1.0/uv.lock +8 -0
@@ -0,0 +1,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Sam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,18 @@
1
+ .PHONY: help build publish clean
2
+
3
+ .DEFAULT_GOAL := help
4
+
5
+ help: ## Show this help
6
+ @awk 'BEGIN {FS = ":.*## "; print "Available targets:\n"} \
7
+ /^[a-zA-Z_-]+:.*## / { printf " \033[36m%-10s\033[0m %s\n", $$1, $$2 }' \
8
+ $(MAKEFILE_LIST)
9
+
10
+ build: clean ## Build sdist + wheel into dist/
11
+ uv build
12
+
13
+ publish: build ## Upload dist/* to PyPI (requires UV_PUBLISH_TOKEN or ~/.pypirc)
14
+ uv publish
15
+
16
+ clean: ## Remove build artifacts (dist/, build/, *.egg-info, __pycache__)
17
+ rm -rf dist/ build/ src/*.egg-info
18
+ find . -type d -name __pycache__ -not -path './.venv/*' -exec rm -rf {} +
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: aetherion
3
+ Version: 0.1.0
4
+ Summary: Dev container launcher for AI coding agents
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.13
7
+ Description-Content-Type: text/markdown
8
+
9
+ # Aetherion
10
+
11
+ A containerized development environment for AI coding agents.
12
+
13
+ Ships a Debian dev container preloaded with the four major agent CLIs (Claude
14
+ Code, Cursor Agent, GitHub Copilot CLI, Gemini CLI), Neovim with LSP/DAP
15
+ support, podman-in-podman, and toolchains for Python, Node, Go, Rust, and
16
+ Ruby. The `aetherion` launcher mounts the current directory at the same path
17
+ inside the container and preserves per-agent login state across sessions.
18
+
19
+ ## Install
20
+
21
+ ```shell
22
+ uv tool install aetherion
23
+ ```
24
+
25
+ (or `pipx install aetherion`)
26
+
27
+ ## Quickstart
28
+
29
+ ```shell
30
+ aetherion --build-image # one-time: build localhost/aetherion:dev
31
+ aetherion # launch a shell in $PWD
32
+ ```
33
+
34
+ ## What's in the container
35
+
36
+ - **Languages & runtimes**: Python (system + uv), Node (via bun), Go, Rust, Ruby, C/C++ toolchain
37
+ - **Agent CLIs**: Claude Code, Cursor Agent, GitHub Copilot CLI, Gemini CLI
38
+ - **Editor**: Neovim with bundled LSPs (`pyright`, `gopls`, `rust-analyzer`, `lua-language-server`, `typescript-language-server`, `vim-language-server`) and DAPs (`debugpy`, `delve`, `codelldb`, `js-debug-adapter`)
39
+ - **CLI tools**: git, podman, tmux, starship, ripgrep, fd, fzf, jq, yq, posting, openssh-client
40
+
41
+ ## State preservation
42
+
43
+ The first time you log in to a bundled agent CLI, the launcher detects the new
44
+ config inside the container and copies it to `~/.aetherion/data/` on the host.
45
+ Subsequent launches bind-mount the saved config so you stay logged in.
46
+ `~/.aetherion/data/` is the only host directory the launcher writes to.
47
+
48
+ | agent | preserved paths |
49
+ | --- | --- |
50
+ | `claude` | `.claude/`, `.claude.json` |
51
+ | `cursor` | `.cursor/`, `.config/cursor/` |
52
+ | `copilot` | `.copilot/` |
53
+ | `gemini` | `.gemini` |
54
+
55
+ ## Flags
56
+
57
+ | flag | purpose |
58
+ | --- | --- |
59
+ | `--agents LIST` | Comma-separated subset of agents to expose (default: all). `--agents ''` for none. |
60
+ | `-e`, `--env NAME=VALUE` | Set a container environment variable. Repeatable. Quote at the shell for values with spaces: `--env 'NAME=has spaces'`. A bare `--env NAME` inherits from the host environment. |
61
+ | `--image REF` | Image ref to run, and to tag when building. Default: `localhost/aetherion:dev`. |
62
+ | `--build-image` | Build the image and exit. Does not launch the container. |
63
+ | `--build-dir PATH` | Build context directory. Defaults to the Dockerfile bundled with the launcher. |
64
+ | `--extract PATH` | Copy the bundled Dockerfile, skeleton/, and scripts/ to PATH and exit. |
65
+
66
+ `AETHERION_CONTAINER_RUNTIME=docker` overrides runtime auto-detection (podman is preferred when both are available).
67
+
68
+ ## Customizing the image
69
+
70
+ The launcher ships its own Dockerfile and skeleton tree inside the Python
71
+ package. To fork them:
72
+
73
+ ```shell
74
+ aetherion --extract ~/my-aetherion
75
+ $EDITOR ~/my-aetherion/Dockerfile
76
+ aetherion --build-image --build-dir ~/my-aetherion --image my:tag
77
+ aetherion --image my:tag
78
+ ```
79
+
80
+ ## Development
81
+
82
+ ```shell
83
+ git clone https://github.com/samintheshell/aetherion
84
+ cd aetherion
85
+ uv sync
86
+ uv run aetherion --help
87
+ ```
88
+
89
+ Build and publish the Python package with the included Makefile:
90
+
91
+ ```shell
92
+ make # show available targets
93
+ make build # produce sdist + wheel in dist/
94
+ make publish # upload dist/* to PyPI (UV_PUBLISH_TOKEN required)
95
+ ```
96
+
97
+ The container image itself has `uv` plus the standard CPython toolchain
98
+ installed, so you can also run `make publish` from inside an `aetherion`
99
+ shell if you prefer keeping credentials in the container.
@@ -0,0 +1,91 @@
1
+ # Aetherion
2
+
3
+ A containerized development environment for AI coding agents.
4
+
5
+ Ships a Debian dev container preloaded with the four major agent CLIs (Claude
6
+ Code, Cursor Agent, GitHub Copilot CLI, Gemini CLI), Neovim with LSP/DAP
7
+ support, podman-in-podman, and toolchains for Python, Node, Go, Rust, and
8
+ Ruby. The `aetherion` launcher mounts the current directory at the same path
9
+ inside the container and preserves per-agent login state across sessions.
10
+
11
+ ## Install
12
+
13
+ ```shell
14
+ uv tool install aetherion
15
+ ```
16
+
17
+ (or `pipx install aetherion`)
18
+
19
+ ## Quickstart
20
+
21
+ ```shell
22
+ aetherion --build-image # one-time: build localhost/aetherion:dev
23
+ aetherion # launch a shell in $PWD
24
+ ```
25
+
26
+ ## What's in the container
27
+
28
+ - **Languages & runtimes**: Python (system + uv), Node (via bun), Go, Rust, Ruby, C/C++ toolchain
29
+ - **Agent CLIs**: Claude Code, Cursor Agent, GitHub Copilot CLI, Gemini CLI
30
+ - **Editor**: Neovim with bundled LSPs (`pyright`, `gopls`, `rust-analyzer`, `lua-language-server`, `typescript-language-server`, `vim-language-server`) and DAPs (`debugpy`, `delve`, `codelldb`, `js-debug-adapter`)
31
+ - **CLI tools**: git, podman, tmux, starship, ripgrep, fd, fzf, jq, yq, posting, openssh-client
32
+
33
+ ## State preservation
34
+
35
+ The first time you log in to a bundled agent CLI, the launcher detects the new
36
+ config inside the container and copies it to `~/.aetherion/data/` on the host.
37
+ Subsequent launches bind-mount the saved config so you stay logged in.
38
+ `~/.aetherion/data/` is the only host directory the launcher writes to.
39
+
40
+ | agent | preserved paths |
41
+ | --- | --- |
42
+ | `claude` | `.claude/`, `.claude.json` |
43
+ | `cursor` | `.cursor/`, `.config/cursor/` |
44
+ | `copilot` | `.copilot/` |
45
+ | `gemini` | `.gemini` |
46
+
47
+ ## Flags
48
+
49
+ | flag | purpose |
50
+ | --- | --- |
51
+ | `--agents LIST` | Comma-separated subset of agents to expose (default: all). `--agents ''` for none. |
52
+ | `-e`, `--env NAME=VALUE` | Set a container environment variable. Repeatable. Quote at the shell for values with spaces: `--env 'NAME=has spaces'`. A bare `--env NAME` inherits from the host environment. |
53
+ | `--image REF` | Image ref to run, and to tag when building. Default: `localhost/aetherion:dev`. |
54
+ | `--build-image` | Build the image and exit. Does not launch the container. |
55
+ | `--build-dir PATH` | Build context directory. Defaults to the Dockerfile bundled with the launcher. |
56
+ | `--extract PATH` | Copy the bundled Dockerfile, skeleton/, and scripts/ to PATH and exit. |
57
+
58
+ `AETHERION_CONTAINER_RUNTIME=docker` overrides runtime auto-detection (podman is preferred when both are available).
59
+
60
+ ## Customizing the image
61
+
62
+ The launcher ships its own Dockerfile and skeleton tree inside the Python
63
+ package. To fork them:
64
+
65
+ ```shell
66
+ aetherion --extract ~/my-aetherion
67
+ $EDITOR ~/my-aetherion/Dockerfile
68
+ aetherion --build-image --build-dir ~/my-aetherion --image my:tag
69
+ aetherion --image my:tag
70
+ ```
71
+
72
+ ## Development
73
+
74
+ ```shell
75
+ git clone https://github.com/samintheshell/aetherion
76
+ cd aetherion
77
+ uv sync
78
+ uv run aetherion --help
79
+ ```
80
+
81
+ Build and publish the Python package with the included Makefile:
82
+
83
+ ```shell
84
+ make # show available targets
85
+ make build # produce sdist + wheel in dist/
86
+ make publish # upload dist/* to PyPI (UV_PUBLISH_TOKEN required)
87
+ ```
88
+
89
+ The container image itself has `uv` plus the standard CPython toolchain
90
+ installed, so you can also run `make publish` from inside an `aetherion`
91
+ shell if you prefer keeping credentials in the container.
@@ -0,0 +1,17 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "aetherion"
7
+ version = "0.1.0"
8
+ description = "Dev container launcher for AI coding agents"
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ dependencies = []
12
+
13
+ [project.scripts]
14
+ aetherion = "aetherion.cli:main"
15
+
16
+ [tool.hatch.build.targets.wheel]
17
+ packages = ["src/aetherion"]
@@ -0,0 +1,3 @@
1
+ """Aetherion: dev container launcher for AI coding agents."""
2
+
3
+ __version__ = "0.1.0"
@@ -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())
@@ -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())