hexiel 0.2.9__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.
hexiel/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.9"
hexiel/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """`python -m hexiel` — same as the `hexiel` command (used by self-update restarts)."""
2
+
3
+ from .main import main
4
+
5
+ main()
hexiel/brand.py ADDED
@@ -0,0 +1,43 @@
1
+ """hexiel's logo: angel wings around an H under a halo. The image lives in
2
+ docs/logo.svg (and web/logo.svg); this is the terminal rendition."""
3
+
4
+ from rich.text import Text
5
+
6
+ LOGO = r"""
7
+ .-~~~~~-.
8
+ __.._ `-~~~~~-' _..__
9
+ \ ``--.._ ██ ██ _..--'' /
10
+ \ ``--.._ ``--._ ██ ██ _.--'' _..--'' /
11
+ `-._ ``--._ \ ███████ / _.--'' _.-'
12
+ `-.__ \ \ ██ ██ / / __.-'
13
+ `--.__\ \ ██ ██ / /__.--'
14
+ `-' `-'
15
+ """
16
+ LOGO_WIDTH = max(len(line) for line in LOGO.splitlines())
17
+ GOLD = "#d9b55a"
18
+ WING = ["#cfe6df", "#cfe6df", "#a8cfc4", "#93c2b5", "#7fb3a5", "#6aa294", "#5f9486", "#5f9486"]
19
+ HALO_COLS = range(22, 33)
20
+
21
+
22
+ def logo_text(tagline: str = "your codebase's guardian angel") -> Text:
23
+ """The coloured logo: gold halo + H, wings shaded light to sage."""
24
+ out = Text()
25
+ for row, line in enumerate(LOGO.strip("\n").splitlines()):
26
+ for col, ch in enumerate(line):
27
+ gold = ch == "█" or (row < 2 and col in HALO_COLS)
28
+ out.append(ch, style=f"bold {GOLD}" if gold else WING[min(row, len(WING) - 1)])
29
+ out.append("\n")
30
+ out.append(" " * 20 + "hexiel", style=f"bold {GOLD}")
31
+ out.append(f" · {tagline}", style="#8b919d")
32
+ return out
33
+
34
+
35
+ def small_logo_text() -> Text:
36
+ """One line for narrow terminals."""
37
+ out = Text()
38
+ out.append("~\\\\ ", style="#a8cfc4")
39
+ out.append("H", style=f"bold {GOLD}")
40
+ out.append(" //~ ", style="#a8cfc4")
41
+ out.append("hexiel", style=f"bold {GOLD}")
42
+ out.append(" · your codebase's guardian angel", style="#8b919d")
43
+ return out
hexiel/config.py ADDED
@@ -0,0 +1,194 @@
1
+ """Configuration loading and defaults for hexiel."""
2
+
3
+ import os
4
+ import tomllib
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+
9
+ def home_config_dir() -> Path:
10
+ return Path(os.environ.get("HEXIEL_HOME", Path.home() / ".config" / "hexiel"))
11
+
12
+
13
+ def home_data_dir() -> Path:
14
+ return Path(os.environ.get("HEXIEL_DATA", Path.home() / ".local" / "share" / "hexiel"))
15
+
16
+
17
+ def memory_dir() -> Path:
18
+ return home_data_dir() / "memory"
19
+
20
+
21
+ def skills_dirs() -> list[Path]:
22
+ dirs = [home_data_dir() / "skills"]
23
+ # compat with existing Claude Code skills so nothing has to be copied over
24
+ dirs.append(Path.home() / ".claude" / "skills")
25
+ dirs.append(Path.cwd() / ".hexiel" / "skills")
26
+ dirs.append(Path.cwd() / ".claude" / "skills")
27
+ return dirs
28
+
29
+
30
+ CONFIG_FILE = home_config_dir() / "config.toml"
31
+ OLLAMA_HOST = "http://localhost:11434"
32
+
33
+
34
+ def list_ollama_models(base_url: str = "") -> list[str]:
35
+ """Live model inventory from an Ollama server (local or cloud-backed)."""
36
+ import httpx
37
+ root = (base_url or OLLAMA_HOST).removesuffix("/v1").removesuffix("/")
38
+ try:
39
+ resp = httpx.get(f"{root}/api/tags", timeout=10)
40
+ return sorted(m["name"] for m in resp.json().get("models", []))
41
+ except (httpx.HTTPError, ValueError):
42
+ return []
43
+
44
+ DEFAULT_CONFIG = '''\
45
+ # hexiel configuration. Profiles live under [models.*]; pick one with /model NAME
46
+ # or `hexiel --model NAME`. "default" selects the boot model.
47
+
48
+ default = "glm-flash"
49
+
50
+ [settings]
51
+ auto_approve = false # when true, write tools and bash run without approval
52
+ compact_threshold = 0.82 # auto-compact when the prompt reaches this share of the context window
53
+ request_timeout = 900 # seconds per provider request
54
+ search_backend = "duckduckgo" # "duckduckgo" (no key) or "searxng"
55
+ searxng_url = "" # e.g. http://localhost:8888 when search_backend = "searxng"
56
+
57
+ [models.glm-flash]
58
+ provider = "openai" # "openai" = any OpenAI-compatible /chat/completions endpoint
59
+ base_url = "http://localhost:11434/v1"
60
+ model = "glm-5.3-flash:cloud"
61
+ context_window = 200000 # tokens before auto-compaction kicks in
62
+ max_output_tokens = 16384
63
+
64
+ [models.kimi]
65
+ provider = "openai"
66
+ base_url = "http://localhost:11434/v1"
67
+ model = "kimi-k2.7-code:cloud"
68
+ context_window = 262144
69
+ max_output_tokens = 16384
70
+
71
+ [models.anthropic]
72
+ provider = "anthropic"
73
+ model = "claude-opus-5-5"
74
+ api_key_env = "ANTHROPIC_API_KEY"
75
+ context_window = 200000
76
+ max_output_tokens = 16384
77
+
78
+ [models.anthropic-sonnet]
79
+ provider = "anthropic"
80
+ model = "claude-sonnet-5-5"
81
+ api_key_env = "ANTHROPIC_API_KEY"
82
+ context_window = 200000
83
+ max_output_tokens = 16384
84
+
85
+ # ---- more big-model presets (fill in the key env var / base_url you have) ----
86
+
87
+ [models.openai]
88
+ provider = "openai"
89
+ base_url = "https://api.openai.com/v1"
90
+ model = "gpt-5.2"
91
+ api_key_env = "OPENAI_API_KEY"
92
+ context_window = 400000
93
+ max_output_tokens = 16384
94
+
95
+ [models.gemini]
96
+ provider = "openai" # Gemini speaks the OpenAI-compatible protocol
97
+ base_url = "https://generativelanguage.googleapis.com/v1beta/openai"
98
+ model = "gemini-2.5-pro"
99
+ api_key_env = "GEMINI_API_KEY"
100
+ context_window = 1000000
101
+ max_output_tokens = 16384
102
+
103
+ [models.xai]
104
+ provider = "openai"
105
+ base_url = "https://api.x.ai/v1"
106
+ model = "grok-4"
107
+ api_key_env = "XAI_API_KEY"
108
+ context_window = 256000
109
+ max_output_tokens = 16384
110
+
111
+ [models.openrouter]
112
+ provider = "openai" # hundreds of models behind one key
113
+ base_url = "https://openrouter.ai/api/v1"
114
+ model = "anthropic/claude-opus-4.5"
115
+ api_key_env = "OPENROUTER_API_KEY"
116
+ context_window = 200000
117
+ max_output_tokens = 16384
118
+
119
+ [models.deepseek]
120
+ provider = "openai"
121
+ base_url = "https://api.deepseek.com/v1"
122
+ model = "deepseek-chat"
123
+ api_key_env = "DEEPSEEK_API_KEY"
124
+ context_window = 128000
125
+ max_output_tokens = 16384
126
+ '''
127
+
128
+
129
+ @dataclass
130
+ class ModelConfig:
131
+ name: str
132
+ provider: str
133
+ model: str
134
+ base_url: str = ""
135
+ api_key_env: str = ""
136
+ context_window: int = 200_000
137
+ max_output_tokens: int = 16_384
138
+ extra_body: dict = field(default_factory=dict)
139
+
140
+
141
+ @dataclass
142
+ class Settings:
143
+ auto_approve: bool = False
144
+ compact_threshold: float = 0.82
145
+ request_timeout: int = 900
146
+ search_backend: str = "duckduckgo"
147
+ searxng_url: str = ""
148
+
149
+
150
+ @dataclass
151
+ class Config:
152
+ default_model: str
153
+ models: dict[str, ModelConfig]
154
+ settings: Settings
155
+
156
+ def model(self, name: str | None = None) -> ModelConfig:
157
+ key = name or self.default_model
158
+ if key not in self.models:
159
+ raise KeyError(f"unknown model profile '{key}' (have: {', '.join(self.models)})")
160
+ return self.models[key]
161
+
162
+
163
+ def load_config(model_override: str | None = None) -> Config:
164
+ if not CONFIG_FILE.exists():
165
+ try:
166
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
167
+ CONFIG_FILE.write_text(DEFAULT_CONFIG)
168
+ except OSError:
169
+ pass
170
+
171
+ raw: dict = {}
172
+ if CONFIG_FILE.exists():
173
+ try:
174
+ raw = tomllib.loads(CONFIG_FILE.read_text())
175
+ except tomllib.TOMLDecodeError as exc:
176
+ raise SystemExit(f"bad config {CONFIG_FILE}: {exc}")
177
+
178
+ models: dict[str, ModelConfig] = {}
179
+ for name, body in raw.get("models", {}).items():
180
+ models[name] = ModelConfig(name=name, **body)
181
+
182
+ settings_raw = raw.get("settings", {})
183
+ settings = Settings(
184
+ auto_approve=bool(settings_raw.get("auto_approve", False)),
185
+ compact_threshold=float(settings_raw.get("compact_threshold", 0.82)),
186
+ request_timeout=int(settings_raw.get("request_timeout", 900)),
187
+ search_backend=str(settings_raw.get("search_backend", "duckduckgo")),
188
+ searxng_url=str(settings_raw.get("searxng_url", "")),
189
+ )
190
+
191
+ default_model = model_override or raw.get("default", next(iter(models), "default"))
192
+ if default_model not in models:
193
+ models.setdefault(default_model, ModelConfig(name=default_model, provider="openai"))
194
+ return Config(default_model=default_model, models=models, settings=settings)
hexiel/doctor.py ADDED
@@ -0,0 +1,168 @@
1
+ """hexiel doctor: preflight checks with copy-paste fixes, plus the first-run
2
+ wizard (same checks, guided). Run `hexiel doctor` any time."""
3
+
4
+ import os
5
+ import shutil
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from .config import CONFIG_FILE, load_config, home_data_dir
11
+ from .gitlab import load_token
12
+
13
+
14
+ def _check(label: str, ok: bool, detail: str = "", fix: str = "") -> bool:
15
+ mark = "\033[32m✓\033[0m" if sys.stdout.isatty() else "✓" if ok else "✗"
16
+ if not ok:
17
+ mark = "\033[31m✗\033[0m" if sys.stdout.isatty() else "✗"
18
+ line = f" {mark} {label}"
19
+ if detail:
20
+ line += f" — {detail}"
21
+ print(line)
22
+ if not ok and fix:
23
+ print(f" fix: {fix}")
24
+ return ok
25
+
26
+
27
+ def run_checks(cwd: str) -> dict:
28
+ """Run every check; returns {name: (ok, detail, fix)} and prints results."""
29
+ sys.path.insert(0, str(Path(__file__).parent))
30
+ results: dict[str, bool] = {}
31
+ print(f"hexiel {__version__} — doctor\n")
32
+
33
+ # config file
34
+ ok = CONFIG_FILE.exists()
35
+ results["config"] = _check(
36
+ "configuration", ok, str(CONFIG_FILE) if ok else "no config.toml yet",
37
+ fix="just run hexiel once — it writes a starter config")
38
+
39
+ cfg = None
40
+ try:
41
+ cfg = load_config()
42
+ results["config_parse"] = _check("config parses", True, ", ".join(cfg.models))
43
+ except SystemExit as exc:
44
+ results["config_parse"] = _check("config parses", False, str(exc))
45
+ return results
46
+
47
+ # python deps
48
+ for mod, needs in (("textual", "the TUI"), ("httpx", "all providers"),
49
+ ("aiohttp", "the web UI (hexiel serve)")):
50
+ try:
51
+ __import__(mod)
52
+ results[f"dep:{mod}"] = _check(f"python: {mod}", True, f"for {needs}")
53
+ except ImportError:
54
+ results[f"dep:{mod}"] = _check(
55
+ f"python: {mod}", False, f"needed for {needs}",
56
+ fix=f"pip install ({mod}) in your hexiel venv")
57
+
58
+ # provider reachability
59
+ default_name = cfg.default_model
60
+ model_cfg = cfg.models.get(default_name)
61
+ results["model"] = _check(
62
+ f"default model '{default_name}'", model_cfg is not None,
63
+ f"{model_cfg.model} @ {model_cfg.base_url or model_cfg.provider}" if model_cfg else "")
64
+ if model_cfg and model_cfg.base_url:
65
+ import httpx
66
+ try:
67
+ from .config import list_ollama_models
68
+ models = list_ollama_models(model_cfg.base_url)
69
+ reachable = bool(models)
70
+ wanted = model_cfg.model in models
71
+ results["provider"] = _check(
72
+ "provider endpoint", reachable, f"{len(models)} models seen, "
73
+ f"target '{model_cfg.model}' {'available' if wanted else 'NOT in inventory'}",
74
+ fix="" if wanted else
75
+ f"ollama pull {model_cfg.model} (or /model <another>)")
76
+ except Exception as exc: # noqa: BLE001
77
+ results["provider"] = _check("provider endpoint", False, repr(exc),
78
+ fix="start ollama serve (localhost:11434)")
79
+ elif model_cfg and model_cfg.api_key_env:
80
+ present = bool(os.environ.get(model_cfg.api_key_env))
81
+ results["provider"] = _check(
82
+ f"{model_cfg.provider} API key", present, model_cfg.api_key_env,
83
+ fix=f"export {model_cfg.api_key_env}=sk-… (or put it in shell profile)")
84
+
85
+ # toolchain
86
+ from .tooling import detect_toolchain
87
+ chain = detect_toolchain(cwd)
88
+ container = chain.container.binary if chain.container else None
89
+ results["container"] = _check(
90
+ "container runtime (docker/podman)", bool(container),
91
+ f"{container}{f' + compose' if (chain.container and chain.container.compose_command) else ''}"
92
+ if container else "none found — container tool disabled",
93
+ fix="sudo pacman -S docker && sudo systemctl enable --now docker")
94
+ results["test_runner"] = _check(
95
+ "test runner detection", bool(chain.test_runners),
96
+ ", ".join(r.label for r in chain.test_runners) or "none in this project")
97
+ results["git"] = _check("git", bool(shutil.which("git")),
98
+ "(needed for the /ship and /heal flows)",
99
+ fix="sudo pacman -S git")
100
+ pwsh = shutil.which("pwsh") or shutil.which("powershell.exe") or shutil.which("powershell")
101
+ if os.name == "nt":
102
+ results["powershell"] = _check(
103
+ "PowerShell", bool(pwsh), pwsh or "missing — the shell tools need it",
104
+ fix="winget install Microsoft.PowerShell")
105
+ else:
106
+ results["powershell"] = _check(
107
+ "powershell tool", bool(pwsh),
108
+ pwsh or "not installed — optional here, required on Windows hosts",
109
+ fix="yay -S powershell-bin (or winget install Microsoft.PowerShell)")
110
+
111
+ # gitlab token
112
+ token = load_token()
113
+ results["gitlab"] = _check(
114
+ "GitLab token", bool(token),
115
+ "set (proposes MRs with /ship)" if token else "not set — /ship /heal can only push, not open MRs",
116
+ fix="create one: https://gitlab.com/-/user_settings/personal_access_tokens (api scope),"
117
+ " then HEXIEL_GITLAB_TOKEN=… hexiel or /setup-gitlab <token>")
118
+
119
+ # memory + skills dirs
120
+ mem = home_data_dir() / "memory"
121
+ results["memory"] = _check("memory dir", mem.is_dir(), str(mem),
122
+ fix="created automatically on first run; check permissions")
123
+ from .skills import discover
124
+ skills = discover(cwd)
125
+ results["skills"] = _check("skills", len(skills) > 0,
126
+ ", ".join(s.name for s in skills[:8]) +
127
+ (" …" if len(skills) > 8 else "") or "none installed")
128
+
129
+ # incidents
130
+ from .heal import unhealed
131
+ incidents = unhealed(cwd)
132
+ results["incidents"] = _check("self-heal queue", not incidents,
133
+ "all clear" if not incidents
134
+ else f"{len(incidents)} unhealed — run /heal in a session")
135
+
136
+ return results
137
+
138
+
139
+ def wizard(cwd: str) -> None:
140
+ """First-run guided setup: config, provider pick, gitlab token (optional)."""
141
+ print("Welcome to hexiel — guided setup (all questions optional, Enter keeps defaults)\n")
142
+ if not CONFIG_FILE.exists():
143
+ load_config() # writes starter config
144
+ print(f" wrote {CONFIG_FILE}")
145
+ from .config import list_ollama_models
146
+ models = list_ollama_models()
147
+ if models:
148
+ names = ", ".join(models[:12]) + (" …" if len(models) > 12 else "")
149
+ print(f" Ollama reachable — models: {names}")
150
+ print(" config already defaults to ollama cloud models; /model NAME switches anytime")
151
+ else:
152
+ print(" Ollama not reachable on localhost:11434 — `ollama serve` + "
153
+ "`ollama pull <model>` enables it, or add a cloud profile to config.toml")
154
+ if not load_token():
155
+ print(" no GitLab token: /ship and /heal can push branches but will not open MRs.")
156
+ print(" add later with /setup-gitlab <token> (api scope PAT).")
157
+ print("\nDone. Start a session with `hexiel` (TUI), `hexiel --serve` (browser), or `hexiel -p \"…\"`.")
158
+
159
+
160
+ def doctor_main(cwd: str = ".") -> int:
161
+ results = run_checks(cwd)
162
+ failed = [k for k, ok in results.items() if not ok]
163
+ print()
164
+ if not failed:
165
+ print("all checks passed — hexiel is ready")
166
+ return 0
167
+ print(f"{len(failed)} check(s) need attention: {', '.join(failed)}")
168
+ return 1