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.
@@ -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.
@@ -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
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()