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.
- aetherion-0.1.0/.gitignore +10 -0
- aetherion-0.1.0/.python-version +1 -0
- aetherion-0.1.0/LICENSE +21 -0
- aetherion-0.1.0/Makefile +18 -0
- aetherion-0.1.0/PKG-INFO +99 -0
- aetherion-0.1.0/README.md +91 -0
- aetherion-0.1.0/pyproject.toml +17 -0
- aetherion-0.1.0/src/aetherion/__init__.py +3 -0
- aetherion-0.1.0/src/aetherion/__main__.py +10 -0
- aetherion-0.1.0/src/aetherion/cli.py +410 -0
- aetherion-0.1.0/src/aetherion/data/Dockerfile +656 -0
- aetherion-0.1.0/src/aetherion/data/scripts/install-treesitter.lua +35 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/etc/apt/apt.conf.d/99-aetherion-minimal +4 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/etc/containers/containers.conf +5 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/etc/containers/storage.conf +5 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.bashrc +37 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/.gitignore +1 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/README.md +216 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/ascii_header.txt +7 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/init.lua +54 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lazy-lock.json +35 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/commands/git.lua +14 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/options.lua +338 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/alpha.lua +247 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/catppuccin.lua +8 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/cinnamon.lua +14 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/debug.lua +185 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/git-utils.lua +109 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/lsp-config.lua +265 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/lualine.lua +28 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/none-ls.lua +50 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/nvim-cmp.lua +107 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/nvim-tree.lua +55 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/nvim-treesitter.lua +32 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/smear-cursor.lua +4 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/telescope.lua +110 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/vim-godot.lua +5 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/vim-visual-multi.lua +20 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins/which-key.lua +49 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/lua/plugins.lua +1 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/nvim/stylua.toml +8 -0
- aetherion-0.1.0/src/aetherion/data/skeleton/home/aetherion/.config/starship.toml +210 -0
- aetherion-0.1.0/uv.lock +8 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
aetherion-0.1.0/LICENSE
ADDED
|
@@ -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.
|
aetherion-0.1.0/Makefile
ADDED
|
@@ -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 {} +
|
aetherion-0.1.0/PKG-INFO
ADDED
|
@@ -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,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())
|