haiv-cli 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.
- haiv_cli-0.1.0/.gitignore +41 -0
- haiv_cli-0.1.0/LICENSE +21 -0
- haiv_cli-0.1.0/PKG-INFO +18 -0
- haiv_cli-0.1.0/README.md +0 -0
- haiv_cli-0.1.0/pyproject.toml +50 -0
- haiv_cli-0.1.0/src/haiv_cli/__init__.py +318 -0
- haiv_cli-0.1.0/src/haiv_cli/py.typed +0 -0
- haiv_cli-0.1.0/tests/test_command_sources.py +406 -0
- haiv_cli-0.1.0/tests/test_imports.py +34 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
develop-eggs/
|
|
9
|
+
dist/
|
|
10
|
+
downloads/
|
|
11
|
+
eggs/
|
|
12
|
+
.eggs/
|
|
13
|
+
lib/
|
|
14
|
+
lib64/
|
|
15
|
+
parts/
|
|
16
|
+
sdist/
|
|
17
|
+
var/
|
|
18
|
+
wheels/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
.installed.cfg
|
|
21
|
+
*.egg
|
|
22
|
+
|
|
23
|
+
# Virtual environments
|
|
24
|
+
.venv/
|
|
25
|
+
venv/
|
|
26
|
+
ENV/
|
|
27
|
+
|
|
28
|
+
# Testing
|
|
29
|
+
.pytest_cache/
|
|
30
|
+
.coverage
|
|
31
|
+
htmlcov/
|
|
32
|
+
|
|
33
|
+
# IDE
|
|
34
|
+
.idea/
|
|
35
|
+
.vscode/
|
|
36
|
+
*.swp
|
|
37
|
+
*.swo
|
|
38
|
+
|
|
39
|
+
# OS
|
|
40
|
+
.DS_Store
|
|
41
|
+
Thumbs.db
|
haiv_cli-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Casey Marquis
|
|
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.
|
haiv_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: haiv-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Seamless management of a collaborative AI team
|
|
5
|
+
Project-URL: Homepage, https://github.com/caseymarquis/haiv
|
|
6
|
+
Project-URL: Repository, https://github.com/caseymarquis/haiv
|
|
7
|
+
Author: Casey Marquis
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: haiv-core
|
haiv_cli-0.1.0/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "haiv-cli"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Seamless management of a collaborative AI team"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Casey Marquis" }
|
|
10
|
+
]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"haiv-core",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
hv = "haiv_cli:main"
|
|
26
|
+
|
|
27
|
+
[tool.uv.sources]
|
|
28
|
+
haiv-core = { workspace = true }
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/caseymarquis/haiv"
|
|
32
|
+
Repository = "https://github.com/caseymarquis/haiv"
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling"]
|
|
36
|
+
build-backend = "hatchling.build"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/haiv_cli"]
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
dev = [
|
|
43
|
+
"pytest>=9.0.2",
|
|
44
|
+
"pytest-xdist>=3.0",
|
|
45
|
+
"ty",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = ["tests"]
|
|
50
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""haiv: Seamless management of a collaborative AI team."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
import shlex
|
|
6
|
+
import sys
|
|
7
|
+
import traceback
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import ModuleType
|
|
12
|
+
from typing import cast
|
|
13
|
+
|
|
14
|
+
import haiv_core
|
|
15
|
+
import haiv_core.commands
|
|
16
|
+
|
|
17
|
+
from haiv._infrastructure import env
|
|
18
|
+
from haiv.paths import get_haiv_root, Paths
|
|
19
|
+
from haiv._infrastructure.routing import find_route, RouteMatch
|
|
20
|
+
from haiv._infrastructure.loader import load_command, load_commands_module
|
|
21
|
+
from haiv._infrastructure.args import build_ctx
|
|
22
|
+
from haiv._infrastructure.runner import run_command
|
|
23
|
+
from haiv._infrastructure.identity import detect_user, Identity
|
|
24
|
+
from haiv._infrastructure.resolvers import make_resolver
|
|
25
|
+
from haiv._infrastructure.haiv_hooks import configure_haiv_hooks
|
|
26
|
+
from haiv.util import module_to_folder
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
|
|
30
|
+
# Core package root (computed once at import)
|
|
31
|
+
_core_root = module_to_folder(haiv_core)
|
|
32
|
+
|
|
33
|
+
# Cached haiv_root lookup
|
|
34
|
+
_haiv_root: Path | None = None
|
|
35
|
+
_haiv_root_error: Exception | None = None
|
|
36
|
+
|
|
37
|
+
# Cached user detection
|
|
38
|
+
_user: Identity | None = None
|
|
39
|
+
_user_error: Exception | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _get_haiv_root_cached() -> Path:
|
|
43
|
+
"""Get haiv_root, caching the result (success or failure)."""
|
|
44
|
+
global _haiv_root, _haiv_root_error
|
|
45
|
+
|
|
46
|
+
if _haiv_root is None and _haiv_root_error is None:
|
|
47
|
+
try:
|
|
48
|
+
_haiv_root = get_haiv_root(cwd=Path.cwd())
|
|
49
|
+
except Exception as e:
|
|
50
|
+
_haiv_root_error = e
|
|
51
|
+
|
|
52
|
+
if _haiv_root_error is not None:
|
|
53
|
+
raise _haiv_root_error
|
|
54
|
+
|
|
55
|
+
return cast(Path, _haiv_root)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _detect_user_cached() -> Identity:
|
|
59
|
+
"""Detect user, caching the result (success or failure)."""
|
|
60
|
+
global _user, _user_error
|
|
61
|
+
|
|
62
|
+
if _user is None and _user_error is None:
|
|
63
|
+
try:
|
|
64
|
+
haiv_root = _get_haiv_root_cached()
|
|
65
|
+
paths = Paths(_called_from=None, _pkg_root=None, _haiv_root=haiv_root, _core_root=_core_root)
|
|
66
|
+
_user = detect_user(paths.users_dir)
|
|
67
|
+
if _user is None:
|
|
68
|
+
raise Exception(
|
|
69
|
+
"No user identity found.\n"
|
|
70
|
+
"Run 'hv users new --name <name>' to create one."
|
|
71
|
+
)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
_user_error = e
|
|
74
|
+
|
|
75
|
+
if _user_error is not None:
|
|
76
|
+
raise _user_error
|
|
77
|
+
|
|
78
|
+
return cast(Identity, _user)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class CommandSource:
|
|
83
|
+
"""Tracks a command source and whether it was checked."""
|
|
84
|
+
|
|
85
|
+
name: str
|
|
86
|
+
path: str
|
|
87
|
+
checked: bool
|
|
88
|
+
error: str | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _log_exception(exc: Exception) -> Path | None:
|
|
92
|
+
"""Log exception to XDG_STATE_HOME/haiv/logs/. Returns log path or None on failure."""
|
|
93
|
+
from datetime import datetime
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
state_home = os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state"))
|
|
97
|
+
log_dir = Path(state_home) / "haiv" / "logs"
|
|
98
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
|
|
100
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
101
|
+
log_file = log_dir / f"error-{timestamp}.log"
|
|
102
|
+
with open(log_file, "w") as f:
|
|
103
|
+
f.write(traceback.format_exc())
|
|
104
|
+
return log_file
|
|
105
|
+
except Exception:
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _handle_error(exc: Exception) -> None:
|
|
110
|
+
"""Handle an exception: print message, log traceback, exit."""
|
|
111
|
+
from haiv.errors import CommandError
|
|
112
|
+
|
|
113
|
+
log_path = _log_exception(exc)
|
|
114
|
+
|
|
115
|
+
if isinstance(exc, CommandError):
|
|
116
|
+
print(f"---\n{exc}", file=sys.stderr)
|
|
117
|
+
else:
|
|
118
|
+
print(f"---\nAn unexpected error occurred: {exc}", file=sys.stderr)
|
|
119
|
+
|
|
120
|
+
if log_path:
|
|
121
|
+
print(f"\n---\nDetails: {log_path}", file=sys.stderr)
|
|
122
|
+
else:
|
|
123
|
+
traceback.print_exc()
|
|
124
|
+
|
|
125
|
+
sys.exit(1)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _try_source(
|
|
129
|
+
command_string: str,
|
|
130
|
+
name: str,
|
|
131
|
+
path: str,
|
|
132
|
+
get_commands: Callable[[], ModuleType],
|
|
133
|
+
) -> tuple[RouteMatch | None, CommandSource]:
|
|
134
|
+
"""Try to find a command in a single source.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
command_string: The command to find
|
|
138
|
+
name: Source name for error reporting
|
|
139
|
+
path: Source path for error reporting
|
|
140
|
+
get_commands: Callable that returns the commands module
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
(route, source) - route is None if not found or source unavailable
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
commands = get_commands()
|
|
147
|
+
route = find_route(command_string, commands)
|
|
148
|
+
return route, CommandSource(name, path, checked=True)
|
|
149
|
+
except Exception as e:
|
|
150
|
+
return None, CommandSource(name, path, checked=False, error=str(e))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _get_project_commands() -> ModuleType:
|
|
154
|
+
"""Load haiv_project commands module."""
|
|
155
|
+
haiv_root = _get_haiv_root_cached()
|
|
156
|
+
os.environ[env.HV_ROOT] = str(haiv_root)
|
|
157
|
+
|
|
158
|
+
paths = Paths(_called_from=None, _pkg_root=None, _haiv_root=haiv_root, _core_root=_core_root)
|
|
159
|
+
return load_commands_module(paths.pkgs.project.commands_dir / "__init__.py")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _get_user_commands() -> ModuleType:
|
|
163
|
+
"""Load haiv_user commands module."""
|
|
164
|
+
haiv_root = _get_haiv_root_cached()
|
|
165
|
+
user = _detect_user_cached()
|
|
166
|
+
|
|
167
|
+
paths = Paths(_called_from=None, _pkg_root=None, _haiv_root=haiv_root, _user_name=user.name, _core_root=_core_root)
|
|
168
|
+
return load_commands_module(paths.pkgs.user.commands_dir / "__init__.py")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _find_command(
|
|
172
|
+
command_string: str,
|
|
173
|
+
) -> tuple[RouteMatch | None, Path | None, list[CommandSource]]:
|
|
174
|
+
"""Try to find a command across all sources.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
(route, haiv_root, sources) - route is None if not found
|
|
178
|
+
"""
|
|
179
|
+
sources: list[CommandSource] = []
|
|
180
|
+
|
|
181
|
+
# Try haiv_user first (highest precedence)
|
|
182
|
+
route, source = _try_source(
|
|
183
|
+
command_string,
|
|
184
|
+
"haiv_user",
|
|
185
|
+
"users/{user}/src/haiv_user/commands/",
|
|
186
|
+
_get_user_commands,
|
|
187
|
+
)
|
|
188
|
+
sources.append(source)
|
|
189
|
+
if route is not None:
|
|
190
|
+
return route, _haiv_root, sources
|
|
191
|
+
|
|
192
|
+
# Try haiv_project next
|
|
193
|
+
route, source = _try_source(
|
|
194
|
+
command_string,
|
|
195
|
+
"haiv_project",
|
|
196
|
+
"src/haiv_project/commands/",
|
|
197
|
+
_get_project_commands,
|
|
198
|
+
)
|
|
199
|
+
sources.append(source)
|
|
200
|
+
if route is not None:
|
|
201
|
+
return route, _haiv_root, sources
|
|
202
|
+
|
|
203
|
+
# Fall back to haiv_core (always available)
|
|
204
|
+
route, source = _try_source(
|
|
205
|
+
command_string,
|
|
206
|
+
"haiv_core",
|
|
207
|
+
"(installed)",
|
|
208
|
+
lambda: haiv_core.commands,
|
|
209
|
+
)
|
|
210
|
+
sources.append(source)
|
|
211
|
+
|
|
212
|
+
return route, _haiv_root, sources
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _print_not_found(command_string: str, sources: list[CommandSource]) -> None:
|
|
216
|
+
"""Print helpful error when command not found."""
|
|
217
|
+
print(f"Unknown command: {command_string}", file=sys.stderr)
|
|
218
|
+
|
|
219
|
+
checked = [s for s in sources if s.checked]
|
|
220
|
+
not_checked = [s for s in sources if not s.checked]
|
|
221
|
+
|
|
222
|
+
if checked:
|
|
223
|
+
print("\nChecked:", file=sys.stderr)
|
|
224
|
+
for s in checked:
|
|
225
|
+
print(f" ✓ {s.name} {s.path}", file=sys.stderr)
|
|
226
|
+
|
|
227
|
+
if not_checked:
|
|
228
|
+
print("\nCould not check:", file=sys.stderr)
|
|
229
|
+
for s in not_checked:
|
|
230
|
+
print(f" ✗ {s.name} {s.path}", file=sys.stderr)
|
|
231
|
+
if s.error:
|
|
232
|
+
print(f" {s.error}", file=sys.stderr)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def main():
|
|
236
|
+
"""Entry point for haiv CLI.
|
|
237
|
+
|
|
238
|
+
Load order (later takes precedence over earlier):
|
|
239
|
+
1. Core package (haiv_core) - always available
|
|
240
|
+
2. Project package (haiv_project) - if in haiv-managed repo
|
|
241
|
+
3. User package (haiv_user) - deferred until user identity exists
|
|
242
|
+
"""
|
|
243
|
+
cast(io.TextIOWrapper, sys.stdout).reconfigure(encoding="utf-8")
|
|
244
|
+
cast(io.TextIOWrapper, sys.stderr).reconfigure(encoding="utf-8")
|
|
245
|
+
|
|
246
|
+
# HV_PROG allows the wrapper script to pass its name (e.g., when using python -c)
|
|
247
|
+
prog = os.environ.get(env.HV_PROG) or Path(sys.argv[0]).name
|
|
248
|
+
args = sys.argv[1:]
|
|
249
|
+
|
|
250
|
+
if not args:
|
|
251
|
+
print(f"{prog} v{__version__}")
|
|
252
|
+
print(f"Usage: {prog} <command> [args...]")
|
|
253
|
+
print(f"Run '{prog} help' for available commands")
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
command_string = shlex.join(args)
|
|
257
|
+
|
|
258
|
+
route, haiv_root, sources = _find_command(command_string)
|
|
259
|
+
|
|
260
|
+
if route is None:
|
|
261
|
+
_print_not_found(command_string, sources)
|
|
262
|
+
sys.exit(1)
|
|
263
|
+
raise AssertionError("unreachable")
|
|
264
|
+
|
|
265
|
+
if route.file is None:
|
|
266
|
+
raise RuntimeError(
|
|
267
|
+
f"RouteMatch.file is None for '{command_string}'. "
|
|
268
|
+
"This indicates a bug in find_route() - it should return None "
|
|
269
|
+
"instead of a RouteMatch with file=None."
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
command = load_command(route.file)
|
|
274
|
+
haiv_username = _user.name if _user else None
|
|
275
|
+
|
|
276
|
+
# Build resolver callback from discovered resolvers
|
|
277
|
+
# Order: haiv_core, haiv_project, haiv_user (later overrides earlier)
|
|
278
|
+
pkg_roots: list[Path] = []
|
|
279
|
+
|
|
280
|
+
# haiv_core
|
|
281
|
+
pkg_roots.append(_core_root)
|
|
282
|
+
|
|
283
|
+
# haiv_project and haiv_user via Paths
|
|
284
|
+
paths = None
|
|
285
|
+
if haiv_root is not None:
|
|
286
|
+
paths = Paths(
|
|
287
|
+
_called_from=None,
|
|
288
|
+
_pkg_root=None,
|
|
289
|
+
_haiv_root=haiv_root,
|
|
290
|
+
_user_name=haiv_username,
|
|
291
|
+
_core_root=_core_root,
|
|
292
|
+
)
|
|
293
|
+
if paths.pkgs.project.root.exists():
|
|
294
|
+
pkg_roots.append(paths.pkgs.project.root)
|
|
295
|
+
if haiv_username is not None and paths.pkgs.user.root.exists():
|
|
296
|
+
pkg_roots.append(paths.pkgs.user.root)
|
|
297
|
+
|
|
298
|
+
resolve = make_resolver(pkg_roots, paths=paths, has_user=haiv_username is not None)
|
|
299
|
+
|
|
300
|
+
definition = command.define()
|
|
301
|
+
haiv_hook_registry = None
|
|
302
|
+
if definition.enable_haiv_hooks:
|
|
303
|
+
haiv_hook_registry = configure_haiv_hooks(pkg_roots)
|
|
304
|
+
|
|
305
|
+
ctx = build_ctx(
|
|
306
|
+
route, command,
|
|
307
|
+
haiv_root=haiv_root,
|
|
308
|
+
haiv_username=haiv_username,
|
|
309
|
+
resolve=resolve,
|
|
310
|
+
haiv_hook_registry=haiv_hook_registry,
|
|
311
|
+
)
|
|
312
|
+
run_command(command, ctx)
|
|
313
|
+
except Exception as exc:
|
|
314
|
+
_handle_error(exc)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
if __name__ == "__main__":
|
|
318
|
+
main()
|
|
File without changes
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Integration tests for multi-source command loading."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _reset_cli_cache():
|
|
11
|
+
"""Reset all cached lookups in haiv_cli."""
|
|
12
|
+
import haiv_cli
|
|
13
|
+
haiv_cli._haiv_root = None
|
|
14
|
+
haiv_cli._haiv_root_error = None
|
|
15
|
+
haiv_cli._user = None
|
|
16
|
+
haiv_cli._user_error = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def haiv_project(tmp_path, monkeypatch):
|
|
21
|
+
"""Create a minimal haiv project structure."""
|
|
22
|
+
# Create haiv root markers
|
|
23
|
+
(tmp_path / ".git").mkdir()
|
|
24
|
+
(tmp_path / "worktrees").mkdir()
|
|
25
|
+
|
|
26
|
+
# Create haiv_project commands
|
|
27
|
+
commands_dir = tmp_path / "src" / "haiv_project" / "commands"
|
|
28
|
+
commands_dir.mkdir(parents=True)
|
|
29
|
+
(commands_dir / "__init__.py").write_text("# haiv_project commands")
|
|
30
|
+
|
|
31
|
+
# Create users directory (empty)
|
|
32
|
+
(tmp_path / "users").mkdir()
|
|
33
|
+
|
|
34
|
+
# Set HV_ROOT and change to project dir
|
|
35
|
+
monkeypatch.setenv("HV_ROOT", str(tmp_path))
|
|
36
|
+
monkeypatch.chdir(tmp_path)
|
|
37
|
+
|
|
38
|
+
_reset_cli_cache()
|
|
39
|
+
|
|
40
|
+
return tmp_path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture
|
|
44
|
+
def haiv_project_with_user(haiv_project, monkeypatch):
|
|
45
|
+
"""Create a haiv project with a user that matches current env."""
|
|
46
|
+
# Create user directory with identity.toml
|
|
47
|
+
user_dir = haiv_project / "users" / "testuser"
|
|
48
|
+
user_dir.mkdir(parents=True)
|
|
49
|
+
|
|
50
|
+
# Get current system user for matching
|
|
51
|
+
system_user = os.environ.get("USER", "nobody")
|
|
52
|
+
(user_dir / "identity.toml").write_text(f'''\
|
|
53
|
+
[match]
|
|
54
|
+
system_user = ["{system_user}"]
|
|
55
|
+
''')
|
|
56
|
+
|
|
57
|
+
# Create haiv_user commands
|
|
58
|
+
commands_dir = user_dir / "src" / "haiv_user" / "commands"
|
|
59
|
+
commands_dir.mkdir(parents=True)
|
|
60
|
+
(commands_dir / "__init__.py").write_text("# haiv_user commands")
|
|
61
|
+
|
|
62
|
+
_reset_cli_cache()
|
|
63
|
+
|
|
64
|
+
return haiv_project
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestCommandSources:
|
|
68
|
+
"""Tests for command source resolution."""
|
|
69
|
+
|
|
70
|
+
def test_core_command_works(self, monkeypatch):
|
|
71
|
+
"""Commands from haiv_core are found."""
|
|
72
|
+
from haiv_cli import _find_command
|
|
73
|
+
|
|
74
|
+
_reset_cli_cache()
|
|
75
|
+
|
|
76
|
+
route, haiv_root, sources = _find_command("test_cmd")
|
|
77
|
+
|
|
78
|
+
assert route is not None
|
|
79
|
+
assert route.file is not None
|
|
80
|
+
assert route.file.name == "test_cmd.py"
|
|
81
|
+
# haiv_core should be in checked sources
|
|
82
|
+
core_sources = [s for s in sources if s.name == "haiv_core"]
|
|
83
|
+
assert len(core_sources) == 1
|
|
84
|
+
assert core_sources[0].checked is True
|
|
85
|
+
|
|
86
|
+
def test_project_command_takes_precedence(self, haiv_project):
|
|
87
|
+
"""haiv_project commands override haiv_core commands."""
|
|
88
|
+
from haiv_cli import _find_command
|
|
89
|
+
|
|
90
|
+
# Create a test_cmd in haiv_project that shadows haiv_core's
|
|
91
|
+
commands_dir = haiv_project / "src" / "haiv_project" / "commands"
|
|
92
|
+
(commands_dir / "test_cmd.py").write_text('''
|
|
93
|
+
from haiv import cmd
|
|
94
|
+
|
|
95
|
+
def define() -> cmd.Def:
|
|
96
|
+
return cmd.Def(description="Project test command")
|
|
97
|
+
|
|
98
|
+
def execute(ctx: cmd.Ctx) -> None:
|
|
99
|
+
print("Hello from haiv_project!")
|
|
100
|
+
''')
|
|
101
|
+
|
|
102
|
+
route, haiv_root, sources = _find_command("test_cmd")
|
|
103
|
+
|
|
104
|
+
assert route is not None
|
|
105
|
+
# Should come from haiv_project, not haiv_core
|
|
106
|
+
assert "haiv_project" in str(route.file)
|
|
107
|
+
assert haiv_root == haiv_project
|
|
108
|
+
|
|
109
|
+
def test_project_only_command(self, haiv_project):
|
|
110
|
+
"""Commands only in haiv_project are found."""
|
|
111
|
+
from haiv_cli import _find_command
|
|
112
|
+
|
|
113
|
+
# Create a command only in haiv_project
|
|
114
|
+
commands_dir = haiv_project / "src" / "haiv_project" / "commands"
|
|
115
|
+
(commands_dir / "project_only.py").write_text('''
|
|
116
|
+
from haiv import cmd
|
|
117
|
+
|
|
118
|
+
def define() -> cmd.Def:
|
|
119
|
+
return cmd.Def(description="Project-only command")
|
|
120
|
+
|
|
121
|
+
def execute(ctx: cmd.Ctx) -> None:
|
|
122
|
+
print("Only in project!")
|
|
123
|
+
''')
|
|
124
|
+
|
|
125
|
+
route, haiv_root, sources = _find_command("project_only")
|
|
126
|
+
|
|
127
|
+
assert route is not None
|
|
128
|
+
assert route.file is not None
|
|
129
|
+
assert route.file.name == "project_only.py"
|
|
130
|
+
|
|
131
|
+
def test_fallback_to_core_when_not_in_project(self, haiv_project):
|
|
132
|
+
"""Falls back to haiv_core when command not in haiv_project."""
|
|
133
|
+
from haiv_cli import _find_command
|
|
134
|
+
|
|
135
|
+
# haiv_project exists but doesn't have test_cmd
|
|
136
|
+
route, haiv_root, sources = _find_command("test_cmd")
|
|
137
|
+
|
|
138
|
+
assert route is not None
|
|
139
|
+
assert "haiv_core" in str(route.file)
|
|
140
|
+
|
|
141
|
+
def test_reports_unchecked_sources(self, tmp_path, monkeypatch):
|
|
142
|
+
"""Reports sources that couldn't be checked."""
|
|
143
|
+
from haiv_cli import _find_command
|
|
144
|
+
|
|
145
|
+
# Not in a haiv project
|
|
146
|
+
monkeypatch.chdir(tmp_path)
|
|
147
|
+
monkeypatch.delenv("HV_ROOT", raising=False)
|
|
148
|
+
|
|
149
|
+
_reset_cli_cache()
|
|
150
|
+
|
|
151
|
+
route, haiv_root, sources = _find_command("nonexistent")
|
|
152
|
+
|
|
153
|
+
assert route is None
|
|
154
|
+
# haiv_project should be unchecked
|
|
155
|
+
project_sources = [s for s in sources if s.name == "haiv_project"]
|
|
156
|
+
assert len(project_sources) == 1
|
|
157
|
+
assert project_sources[0].checked is False
|
|
158
|
+
assert project_sources[0].error is not None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TestUserCommandSources:
|
|
162
|
+
"""Tests for user command source resolution."""
|
|
163
|
+
|
|
164
|
+
def test_user_command_takes_precedence_over_project(self, haiv_project_with_user):
|
|
165
|
+
"""haiv_user commands override haiv_project commands."""
|
|
166
|
+
from haiv_cli import _find_command
|
|
167
|
+
|
|
168
|
+
# Create same command in both haiv_project and haiv_user
|
|
169
|
+
project_commands = haiv_project_with_user / "src" / "haiv_project" / "commands"
|
|
170
|
+
(project_commands / "shared_cmd.py").write_text('''
|
|
171
|
+
from haiv import cmd
|
|
172
|
+
|
|
173
|
+
def define() -> cmd.Def:
|
|
174
|
+
return cmd.Def(description="Project version")
|
|
175
|
+
|
|
176
|
+
def execute(ctx: cmd.Ctx) -> None:
|
|
177
|
+
print("Hello from haiv_project!")
|
|
178
|
+
''')
|
|
179
|
+
|
|
180
|
+
user_commands = haiv_project_with_user / "users" / "testuser" / "src" / "haiv_user" / "commands"
|
|
181
|
+
(user_commands / "shared_cmd.py").write_text('''
|
|
182
|
+
from haiv import cmd
|
|
183
|
+
|
|
184
|
+
def define() -> cmd.Def:
|
|
185
|
+
return cmd.Def(description="User version")
|
|
186
|
+
|
|
187
|
+
def execute(ctx: cmd.Ctx) -> None:
|
|
188
|
+
print("Hello from haiv_user!")
|
|
189
|
+
''')
|
|
190
|
+
|
|
191
|
+
route, haiv_root, sources = _find_command("shared_cmd")
|
|
192
|
+
|
|
193
|
+
assert route is not None
|
|
194
|
+
# Should come from haiv_user, not haiv_project
|
|
195
|
+
assert "haiv_user" in str(route.file)
|
|
196
|
+
|
|
197
|
+
def test_user_only_command(self, haiv_project_with_user):
|
|
198
|
+
"""Commands only in haiv_user are found."""
|
|
199
|
+
from haiv_cli import _find_command
|
|
200
|
+
|
|
201
|
+
user_commands = haiv_project_with_user / "users" / "testuser" / "src" / "haiv_user" / "commands"
|
|
202
|
+
(user_commands / "user_only.py").write_text('''
|
|
203
|
+
from haiv import cmd
|
|
204
|
+
|
|
205
|
+
def define() -> cmd.Def:
|
|
206
|
+
return cmd.Def(description="User-only command")
|
|
207
|
+
|
|
208
|
+
def execute(ctx: cmd.Ctx) -> None:
|
|
209
|
+
print("Only in user!")
|
|
210
|
+
''')
|
|
211
|
+
|
|
212
|
+
route, haiv_root, sources = _find_command("user_only")
|
|
213
|
+
|
|
214
|
+
assert route is not None
|
|
215
|
+
assert route.file is not None
|
|
216
|
+
assert route.file.name == "user_only.py"
|
|
217
|
+
|
|
218
|
+
def test_fallback_to_project_when_not_in_user(self, haiv_project_with_user):
|
|
219
|
+
"""Falls back to haiv_project when command not in haiv_user."""
|
|
220
|
+
from haiv_cli import _find_command
|
|
221
|
+
|
|
222
|
+
project_commands = haiv_project_with_user / "src" / "haiv_project" / "commands"
|
|
223
|
+
(project_commands / "project_cmd.py").write_text('''
|
|
224
|
+
from haiv import cmd
|
|
225
|
+
|
|
226
|
+
def define() -> cmd.Def:
|
|
227
|
+
return cmd.Def(description="Project command")
|
|
228
|
+
|
|
229
|
+
def execute(ctx: cmd.Ctx) -> None:
|
|
230
|
+
print("From project!")
|
|
231
|
+
''')
|
|
232
|
+
|
|
233
|
+
route, haiv_root, sources = _find_command("project_cmd")
|
|
234
|
+
|
|
235
|
+
assert route is not None
|
|
236
|
+
assert "haiv_project" in str(route.file)
|
|
237
|
+
|
|
238
|
+
def test_no_user_reports_unchecked(self, haiv_project):
|
|
239
|
+
"""Reports haiv_user as unchecked when no user identity found."""
|
|
240
|
+
from haiv_cli import _find_command
|
|
241
|
+
|
|
242
|
+
# haiv_project exists but no user
|
|
243
|
+
route, haiv_root, sources = _find_command("test_cmd")
|
|
244
|
+
|
|
245
|
+
# haiv_user should be unchecked
|
|
246
|
+
user_sources = [s for s in sources if s.name == "haiv_user"]
|
|
247
|
+
assert len(user_sources) == 1
|
|
248
|
+
assert user_sources[0].checked is False
|
|
249
|
+
assert user_sources[0].error is not None
|
|
250
|
+
assert "No user identity found" in user_sources[0].error
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class TestResolverIntegration:
|
|
254
|
+
"""Integration tests for resolver wiring in haiv_cli."""
|
|
255
|
+
|
|
256
|
+
def test_implicit_resolver_without_file_returns_raw_value(self, haiv_project_with_user):
|
|
257
|
+
"""Implicit resolver (_name_/) without resolver file returns raw string."""
|
|
258
|
+
from haiv_cli import _find_command, main
|
|
259
|
+
from haiv._infrastructure.loader import load_command
|
|
260
|
+
from haiv._infrastructure.args import build_ctx
|
|
261
|
+
from haiv._infrastructure.resolvers import make_resolver
|
|
262
|
+
|
|
263
|
+
# Create command with implicit param resolver
|
|
264
|
+
commands_dir = haiv_project_with_user / "src" / "haiv_project" / "commands" / "_name_"
|
|
265
|
+
commands_dir.mkdir(parents=True)
|
|
266
|
+
(commands_dir.parent / "__init__.py").touch()
|
|
267
|
+
(commands_dir / "greet.py").write_text('''
|
|
268
|
+
from haiv import cmd
|
|
269
|
+
|
|
270
|
+
def define() -> cmd.Def:
|
|
271
|
+
return cmd.Def(description="Greet by name")
|
|
272
|
+
|
|
273
|
+
def execute(ctx: cmd.Ctx) -> None:
|
|
274
|
+
name = ctx.args.get_one("name")
|
|
275
|
+
print(f"Hello, {name}!")
|
|
276
|
+
''')
|
|
277
|
+
|
|
278
|
+
route, haiv_root, sources = _find_command("alice greet")
|
|
279
|
+
|
|
280
|
+
assert route is not None
|
|
281
|
+
assert route.params["name"].value == "alice"
|
|
282
|
+
assert route.params["name"].resolver == "name"
|
|
283
|
+
|
|
284
|
+
# Build resolve callback - no resolver file exists
|
|
285
|
+
pkg_roots = [haiv_project_with_user / "src" / "haiv_project"]
|
|
286
|
+
resolve = make_resolver(pkg_roots, None, has_user=True)
|
|
287
|
+
|
|
288
|
+
# Implicit resolver should return raw value
|
|
289
|
+
from haiv._infrastructure.args import ResolveRequest
|
|
290
|
+
req = ResolveRequest(param="name", resolver="name", value="alice")
|
|
291
|
+
result = resolve(req)
|
|
292
|
+
|
|
293
|
+
assert result == "alice" # Raw value, no transformation
|
|
294
|
+
|
|
295
|
+
def test_explicit_resolver_without_file_raises_error(self, haiv_project_with_user):
|
|
296
|
+
"""Explicit resolver (_target_as_mind_/) without file raises UnknownResolverError."""
|
|
297
|
+
from haiv_cli import _find_command
|
|
298
|
+
from haiv._infrastructure.resolvers import make_resolver, UnknownResolverError
|
|
299
|
+
|
|
300
|
+
# Create command with explicit param resolver
|
|
301
|
+
commands_dir = haiv_project_with_user / "src" / "haiv_project" / "commands" / "_target_as_mind_"
|
|
302
|
+
commands_dir.mkdir(parents=True)
|
|
303
|
+
(commands_dir.parent / "__init__.py").touch()
|
|
304
|
+
(commands_dir / "send.py").write_text('''
|
|
305
|
+
from haiv import cmd
|
|
306
|
+
|
|
307
|
+
def define() -> cmd.Def:
|
|
308
|
+
return cmd.Def(description="Send to mind")
|
|
309
|
+
|
|
310
|
+
def execute(ctx: cmd.Ctx) -> None:
|
|
311
|
+
pass
|
|
312
|
+
''')
|
|
313
|
+
|
|
314
|
+
route, haiv_root, sources = _find_command("forge send")
|
|
315
|
+
|
|
316
|
+
assert route is not None
|
|
317
|
+
assert route.params["target"].resolver == "mind"
|
|
318
|
+
|
|
319
|
+
# Build resolve callback - no resolver file exists
|
|
320
|
+
pkg_roots = [haiv_project_with_user / "src" / "haiv_project"]
|
|
321
|
+
resolve = make_resolver(pkg_roots, None, has_user=True)
|
|
322
|
+
|
|
323
|
+
# Explicit resolver should raise error
|
|
324
|
+
from haiv._infrastructure.args import ResolveRequest
|
|
325
|
+
req = ResolveRequest(param="target", resolver="mind", value="forge")
|
|
326
|
+
|
|
327
|
+
with pytest.raises(UnknownResolverError) as exc_info:
|
|
328
|
+
resolve(req)
|
|
329
|
+
|
|
330
|
+
assert exc_info.value.resolver_name == "mind"
|
|
331
|
+
|
|
332
|
+
def test_resolver_file_is_discovered_and_used(self, haiv_project_with_user):
|
|
333
|
+
"""Resolver file in resolvers/ is discovered and used."""
|
|
334
|
+
from haiv._infrastructure.resolvers import make_resolver
|
|
335
|
+
|
|
336
|
+
# Create resolver file
|
|
337
|
+
resolvers_dir = haiv_project_with_user / "src" / "haiv_project" / "resolvers"
|
|
338
|
+
resolvers_dir.mkdir(parents=True)
|
|
339
|
+
(resolvers_dir / "mind.py").write_text('''
|
|
340
|
+
def resolve(value, ctx):
|
|
341
|
+
return f"Mind({value})"
|
|
342
|
+
''')
|
|
343
|
+
|
|
344
|
+
pkg_roots = [haiv_project_with_user / "src" / "haiv_project"]
|
|
345
|
+
resolve = make_resolver(pkg_roots, None, has_user=True)
|
|
346
|
+
|
|
347
|
+
from haiv._infrastructure.args import ResolveRequest
|
|
348
|
+
req = ResolveRequest(param="mind", resolver="mind", value="forge")
|
|
349
|
+
result = resolve(req)
|
|
350
|
+
|
|
351
|
+
assert result == "Mind(forge)"
|
|
352
|
+
|
|
353
|
+
def test_resolver_requires_user_when_found(self, haiv_project):
|
|
354
|
+
"""Resolver raises UserRequiredError when has_user=False."""
|
|
355
|
+
from haiv._infrastructure.resolvers import make_resolver, UserRequiredError
|
|
356
|
+
|
|
357
|
+
# Create resolver file
|
|
358
|
+
resolvers_dir = haiv_project / "src" / "haiv_project" / "resolvers"
|
|
359
|
+
resolvers_dir.mkdir(parents=True)
|
|
360
|
+
(resolvers_dir / "mind.py").write_text('''
|
|
361
|
+
def resolve(value, ctx):
|
|
362
|
+
return f"Mind({value})"
|
|
363
|
+
''')
|
|
364
|
+
|
|
365
|
+
pkg_roots = [haiv_project / "src" / "haiv_project"]
|
|
366
|
+
resolve = make_resolver(pkg_roots, None, has_user=False)
|
|
367
|
+
|
|
368
|
+
from haiv._infrastructure.args import ResolveRequest
|
|
369
|
+
req = ResolveRequest(param="mind", resolver="mind", value="forge")
|
|
370
|
+
|
|
371
|
+
with pytest.raises(UserRequiredError) as exc_info:
|
|
372
|
+
resolve(req)
|
|
373
|
+
|
|
374
|
+
assert exc_info.value.resolver_name == "mind"
|
|
375
|
+
|
|
376
|
+
def test_user_resolver_overrides_project_resolver(self, haiv_project_with_user):
|
|
377
|
+
"""User resolvers override project resolvers."""
|
|
378
|
+
from haiv._infrastructure.resolvers import make_resolver
|
|
379
|
+
|
|
380
|
+
# Create resolver in project
|
|
381
|
+
project_resolvers = haiv_project_with_user / "src" / "haiv_project" / "resolvers"
|
|
382
|
+
project_resolvers.mkdir(parents=True)
|
|
383
|
+
(project_resolvers / "mind.py").write_text('''
|
|
384
|
+
def resolve(value, ctx):
|
|
385
|
+
return f"ProjectMind({value})"
|
|
386
|
+
''')
|
|
387
|
+
|
|
388
|
+
# Create resolver in user (should win)
|
|
389
|
+
user_resolvers = haiv_project_with_user / "users" / "testuser" / "src" / "haiv_user" / "resolvers"
|
|
390
|
+
user_resolvers.mkdir(parents=True)
|
|
391
|
+
(user_resolvers / "mind.py").write_text('''
|
|
392
|
+
def resolve(value, ctx):
|
|
393
|
+
return f"UserMind({value})"
|
|
394
|
+
''')
|
|
395
|
+
|
|
396
|
+
pkg_roots = [
|
|
397
|
+
haiv_project_with_user / "src" / "haiv_project",
|
|
398
|
+
haiv_project_with_user / "users" / "testuser" / "src" / "haiv_user",
|
|
399
|
+
]
|
|
400
|
+
resolve = make_resolver(pkg_roots, None, has_user=True)
|
|
401
|
+
|
|
402
|
+
from haiv._infrastructure.args import ResolveRequest
|
|
403
|
+
req = ResolveRequest(param="mind", resolver="mind", value="forge")
|
|
404
|
+
result = resolve(req)
|
|
405
|
+
|
|
406
|
+
assert result == "UserMind(forge)" # User wins
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Test that dependencies are correctly wired."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_can_import_haiv_core():
|
|
5
|
+
"""Verify haiv-core is available as a dependency."""
|
|
6
|
+
from haiv_core import __version__
|
|
7
|
+
|
|
8
|
+
assert __version__ == "0.1.0"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_can_import_haiv_transitively():
|
|
12
|
+
"""Verify haiv is available transitively via haiv-core."""
|
|
13
|
+
from haiv import Container
|
|
14
|
+
|
|
15
|
+
assert Container is not None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_can_import_haiv_cli():
|
|
19
|
+
"""Verify haiv_cli itself imports."""
|
|
20
|
+
import haiv_cli
|
|
21
|
+
|
|
22
|
+
assert haiv_cli.__version__ == "0.1.0"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_main_runs(monkeypatch):
|
|
26
|
+
"""Verify main entry point runs without error."""
|
|
27
|
+
import sys
|
|
28
|
+
from haiv_cli import main
|
|
29
|
+
|
|
30
|
+
# Mock sys.argv to avoid picking up pytest args
|
|
31
|
+
monkeypatch.setattr(sys, "argv", ["hv"])
|
|
32
|
+
|
|
33
|
+
# Should not raise - prints usage and returns
|
|
34
|
+
main()
|