hull-core 0.1.0b1__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.
Files changed (75) hide show
  1. hull_core/__init__.py +36 -0
  2. hull_core/auth/__init__.py +33 -0
  3. hull_core/auth/asgi.py +75 -0
  4. hull_core/auth/context.py +47 -0
  5. hull_core/auth/middleware.py +105 -0
  6. hull_core/auth/tokens.py +83 -0
  7. hull_core/auth/users.py +131 -0
  8. hull_core/cli.py +107 -0
  9. hull_core/config/__init__.py +39 -0
  10. hull_core/config/models.py +59 -0
  11. hull_core/config/settings.py +132 -0
  12. hull_core/crypto/__init__.py +5 -0
  13. hull_core/crypto/timing.py +16 -0
  14. hull_core/http/__init__.py +23 -0
  15. hull_core/http/ssrf.py +297 -0
  16. hull_core/lifecycle/__init__.py +5 -0
  17. hull_core/lifecycle/lock.py +278 -0
  18. hull_core/limits/__init__.py +5 -0
  19. hull_core/limits/limiter.py +34 -0
  20. hull_core/providers/__init__.py +5 -0
  21. hull_core/providers/openai_spec.py +147 -0
  22. hull_core/server/__init__.py +6 -0
  23. hull_core/server/app.py +78 -0
  24. hull_core/server/run.py +57 -0
  25. hull_core/storage/__init__.py +5 -0
  26. hull_core/storage/sqlite.py +69 -0
  27. hull_core-0.1.0b1.dist-info/METADATA +135 -0
  28. hull_core-0.1.0b1.dist-info/RECORD +75 -0
  29. hull_core-0.1.0b1.dist-info/WHEEL +4 -0
  30. hull_core-0.1.0b1.dist-info/entry_points.txt +3 -0
  31. hull_core-0.1.0b1.dist-info/licenses/LICENSE +201 -0
  32. hull_embedding_daemon/__init__.py +6 -0
  33. hull_embedding_daemon/__main__.py +53 -0
  34. hull_embedding_daemon/_extra.py +45 -0
  35. hull_embedding_daemon/api.py +82 -0
  36. hull_embedding_daemon/backends/__init__.py +0 -0
  37. hull_embedding_daemon/backends/gguf.py +17 -0
  38. hull_embedding_daemon/backends/onnx.py +17 -0
  39. hull_web/__init__.py +39 -0
  40. hull_web/_extra.py +51 -0
  41. hull_web/adapters/__init__.py +10 -0
  42. hull_web/adapters/mangadex.py +403 -0
  43. hull_web/browsers/__init__.py +19 -0
  44. hull_web/browsers/browserless.py +66 -0
  45. hull_web/browsers/cf_render.py +83 -0
  46. hull_web/browsers/interact.py +132 -0
  47. hull_web/browsers/invisible.py +76 -0
  48. hull_web/browsers/patchright.py +73 -0
  49. hull_web/browsers/protocol.py +27 -0
  50. hull_web/fingerprint.py +209 -0
  51. hull_web/http/__init__.py +13 -0
  52. hull_web/http/client.py +233 -0
  53. hull_web/http/url.py +137 -0
  54. hull_web/py.typed +0 -0
  55. hull_web/scraper/__init__.py +19 -0
  56. hull_web/scraper/agent.py +391 -0
  57. hull_web/scraper/base.py +31 -0
  58. hull_web/scraper/cache.py +117 -0
  59. hull_web/scraper/robots.py +110 -0
  60. hull_web/scraper/selector_inference.py +212 -0
  61. hull_web/scraper/state.py +37 -0
  62. hull_web/scraper/strategies/__init__.py +22 -0
  63. hull_web/scraper/strategies/api_direct.py +80 -0
  64. hull_web/scraper/strategies/basic_http.py +101 -0
  65. hull_web/scraper/strategies/captcha.py +295 -0
  66. hull_web/scraper/strategies/headless.py +106 -0
  67. hull_web/scraper/strategies/invisible_browser.py +32 -0
  68. hull_web/scraper/strategies/patchright_browser.py +194 -0
  69. hull_web/scraper/strategies/remote_render.py +49 -0
  70. hull_web/scraper/strategies/tls_spoof.py +97 -0
  71. hull_web/scraper/utils.py +190 -0
  72. hull_web/search/__init__.py +7 -0
  73. hull_web/search/client.py +294 -0
  74. hull_web/search/models.py +33 -0
  75. hull_web/search/runner.py +1448 -0
hull_core/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """hull-core: shared auth, model config, limiter, storage, and MCP server core."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("hull-core")
7
+ except PackageNotFoundError: # package not installed (e.g. running from source tree)
8
+ __version__ = "0.0.0+unknown"
9
+
10
+ from hull_core.auth.context import AuthContext, current_user
11
+ from hull_core.auth.middleware import Authenticator
12
+ from hull_core.auth.tokens import hash_token, verify_token
13
+ from hull_core.auth.users import User, UserLimits, UsersError, load_users
14
+ from hull_core.config.models import ModelCell, resolve_model_cells
15
+ from hull_core.config.settings import HullSettings, ServerSettings, load_settings
16
+ from hull_core.limits.limiter import SlidingWindowLimiter
17
+ from hull_core.storage.sqlite import HullDatabase
18
+
19
+ __all__ = [
20
+ "AuthContext",
21
+ "Authenticator",
22
+ "HullDatabase",
23
+ "HullSettings",
24
+ "ModelCell",
25
+ "ServerSettings",
26
+ "SlidingWindowLimiter",
27
+ "User",
28
+ "UserLimits",
29
+ "UsersError",
30
+ "current_user",
31
+ "hash_token",
32
+ "load_settings",
33
+ "load_users",
34
+ "resolve_model_cells",
35
+ "verify_token",
36
+ ]
@@ -0,0 +1,33 @@
1
+ """Token authentication: users.toml, three modes, ASGI middleware, identity context."""
2
+
3
+ from hull_core.auth.asgi import HullAuthMiddleware
4
+ from hull_core.auth.context import AuthContext, current_user, reset_current_user, set_current_user
5
+ from hull_core.auth.middleware import Authenticator, AuthOutcome
6
+ from hull_core.auth.tokens import TokenHashError, hash_token, verify_token
7
+ from hull_core.auth.users import (
8
+ User,
9
+ UserLimits,
10
+ UsersError,
11
+ ensure_path_allowed,
12
+ find_user_by_token,
13
+ load_users,
14
+ )
15
+
16
+ __all__ = [
17
+ "AuthContext",
18
+ "AuthOutcome",
19
+ "Authenticator",
20
+ "HullAuthMiddleware",
21
+ "TokenHashError",
22
+ "User",
23
+ "UserLimits",
24
+ "UsersError",
25
+ "current_user",
26
+ "ensure_path_allowed",
27
+ "find_user_by_token",
28
+ "hash_token",
29
+ "load_users",
30
+ "reset_current_user",
31
+ "set_current_user",
32
+ "verify_token",
33
+ ]
hull_core/auth/asgi.py ADDED
@@ -0,0 +1,75 @@
1
+ """Pure-ASGI middleware that authenticates the request and binds the identity.
2
+
3
+ Runs before the MCP endpoint (passed through FastMCP's ``http_app`` middleware
4
+ list) and publishes :class:`AuthContext` via the module contextvar so tools
5
+ can scope storage access per user. Rejections are JSON:
6
+ ``401`` (missing/invalid token), ``403`` (disabled user), ``429`` (over limit).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+
13
+ from hull_core.auth.context import set_current_user, reset_current_user
14
+ from hull_core.auth.middleware import Authenticator
15
+
16
+ _STATUS_TEXT = {
17
+ 401: "unauthorized",
18
+ 403: "forbidden",
19
+ 429: "rate limit exceeded",
20
+ 500: "server misconfiguration",
21
+ }
22
+
23
+
24
+ class HullAuthMiddleware:
25
+ """ASGI middleware: authenticate → bind contextvar → forward, or reject."""
26
+
27
+ def __init__(self, app, authenticator: Authenticator) -> None:
28
+ self.app = app
29
+ self.authenticator = authenticator
30
+
31
+ async def __call__(self, scope, receive, send) -> None: # noqa: ANN001
32
+ if scope["type"] != "http":
33
+ await self.app(scope, receive, send)
34
+ return
35
+
36
+ authz = None
37
+ for name, value in scope.get("headers", []):
38
+ if name == b"authorization":
39
+ authz = value.decode("latin-1")
40
+ break
41
+
42
+ outcome = self.authenticator.authenticate(authz)
43
+ if not outcome.ok:
44
+ await self._reject(send, outcome.status or 500, outcome.detail)
45
+ return
46
+
47
+ context = outcome.context
48
+ if context is None:
49
+ # ok=True always carries a context; fail closed rather than bind a
50
+ # null identity into the contextvar for the rest of the request.
51
+ await self._reject(send, 500, "authenticator returned no context")
52
+ return
53
+
54
+ token = set_current_user(context)
55
+ try:
56
+ await self.app(scope, receive, send)
57
+ finally:
58
+ reset_current_user(token)
59
+
60
+ async def _reject(self, send, status: int, detail: str) -> None: # noqa: ANN001
61
+ body = json.dumps({"error": _STATUS_TEXT.get(status, "error"), "detail": detail}).encode("utf-8")
62
+ headers = [
63
+ (b"content-type", b"application/json"),
64
+ (b"content-length", str(len(body)).encode()),
65
+ ]
66
+ if status == 401:
67
+ headers.append((b"www-authenticate", b"Bearer"))
68
+ await send(
69
+ {
70
+ "type": "http.response.start",
71
+ "status": status,
72
+ "headers": headers,
73
+ }
74
+ )
75
+ await send({"type": "http.response.body", "body": body})
@@ -0,0 +1,47 @@
1
+ """Request-scoped identity for MCP tools.
2
+
3
+ The pure-ASGI auth middleware resolves the caller before the MCP endpoint
4
+ runs and stores the resulting :class:`AuthContext` in a contextvar. Tools read
5
+ it with :func:`current_user` and scope every storage access by its namespace.
6
+ A pure-ASGI middleware (not Starlette's BaseHTTPMiddleware) is used so the
7
+ contextvar propagates into the endpoint task without anyio task-group copies.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from contextvars import ContextVar, Token
13
+ from dataclasses import dataclass, field
14
+
15
+ _CURRENT: ContextVar["AuthContext | None"] = ContextVar("hull_current_user", default=None)
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class AuthContext:
20
+ """Resolved caller identity attached to one HTTP request."""
21
+
22
+ uid: str
23
+ namespace: str
24
+ mode: str
25
+ allowed_roots: tuple[str, ...] = ()
26
+ rpm: int | None = None
27
+ disabled: bool = False
28
+ attributes: dict = field(default_factory=dict)
29
+
30
+ @staticmethod
31
+ def local() -> "AuthContext":
32
+ """Identity used in no-auth mode (and for in-process tool calls)."""
33
+ return AuthContext(uid="local", namespace="default", mode="no-auth")
34
+
35
+
36
+ def set_current_user(ctx: AuthContext) -> Token:
37
+ return _CURRENT.set(ctx)
38
+
39
+
40
+ def reset_current_user(token: Token) -> None:
41
+ _CURRENT.reset(token)
42
+
43
+
44
+ def current_user() -> AuthContext:
45
+ """The caller's identity; falls back to the local identity when unset."""
46
+ ctx = _CURRENT.get()
47
+ return ctx if ctx is not None else AuthContext.local()
@@ -0,0 +1,105 @@
1
+ """Token authentication: one mechanism, three modes (spec §4).
2
+
3
+ Mode is a config state, resolved by the host in ``config.toml``:
4
+
5
+ - ``no-auth`` — localhost-only; one shared namespace; no Authorization header.
6
+ - ``token`` — one shared token; still one shared namespace.
7
+ - ``multi`` — users.toml; token → user → namespace; 401 wrong token,
8
+ 403 disabled user, 429 over limit.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+ from hull_core.auth.context import AuthContext
16
+ from hull_core.auth.tokens import verify_token
17
+ from hull_core.auth.users import User, UsersError, find_user_by_token
18
+ from hull_core.config.settings import HullSettings
19
+ from hull_core.limits.limiter import SlidingWindowLimiter
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class AuthOutcome:
24
+ """Result of authenticating one request."""
25
+
26
+ context: AuthContext | None = None
27
+ status: int | None = None # None = proceed; 401/403 = reject
28
+ detail: str = ""
29
+
30
+ @property
31
+ def ok(self) -> bool:
32
+ return self.status is None and self.context is not None
33
+
34
+
35
+ class Authenticator:
36
+ """Resolves bearer tokens to AuthContexts according to the configured mode."""
37
+
38
+ def __init__(
39
+ self,
40
+ settings: HullSettings,
41
+ users: dict[str, User] | None = None,
42
+ limiter: SlidingWindowLimiter | None = None,
43
+ ) -> None:
44
+ self._settings = settings
45
+ self._mode = settings.server.auth
46
+ self._users = users
47
+ self._limiter = limiter if limiter is not None else SlidingWindowLimiter()
48
+
49
+ @property
50
+ def mode(self) -> str:
51
+ return self._mode
52
+
53
+ @property
54
+ def limiter(self) -> SlidingWindowLimiter:
55
+ return self._limiter
56
+
57
+ @staticmethod
58
+ def _bearer(authz: str | None) -> str | None:
59
+ if not authz:
60
+ return None
61
+ scheme, _, value = authz.partition(" ")
62
+ if scheme.lower() != "bearer" or not value.strip():
63
+ return None
64
+ return value.strip()
65
+
66
+ def authenticate(self, authz: str | None) -> AuthOutcome:
67
+ if self._mode == "no-auth":
68
+ return AuthOutcome(context=AuthContext.local())
69
+
70
+ token = self._bearer(authz)
71
+ if token is None:
72
+ return AuthOutcome(status=401, detail="missing or malformed Authorization header")
73
+
74
+ if self._mode == "token":
75
+ token_hash = self._settings.server.token_hash
76
+ if token_hash is None or not verify_token(token, token_hash):
77
+ return AuthOutcome(status=401, detail="invalid token")
78
+ return self._admit(
79
+ AuthContext(uid="shared", namespace="default", mode="token", rpm=self._settings.server.rpm)
80
+ )
81
+
82
+ if self._mode == "multi":
83
+ if self._users is None:
84
+ raise UsersError("multi mode requires the users table (load users.toml first)")
85
+ user = find_user_by_token(self._users, token)
86
+ if user is None:
87
+ return AuthOutcome(status=401, detail="invalid token")
88
+ if not user.enabled:
89
+ return AuthOutcome(status=403, detail=f"user {user.uid!r} is disabled")
90
+ return self._admit(
91
+ AuthContext(
92
+ uid=user.uid,
93
+ namespace=user.namespace,
94
+ mode="multi",
95
+ allowed_roots=user.allowed_roots,
96
+ rpm=user.limits.rpm,
97
+ )
98
+ )
99
+
100
+ return AuthOutcome(status=500, detail=f"unknown auth mode {self._mode!r}")
101
+
102
+ def _admit(self, ctx: AuthContext) -> AuthOutcome:
103
+ if not self._limiter.check(ctx.uid, ctx.rpm):
104
+ return AuthOutcome(status=429, detail="rate limit exceeded")
105
+ return AuthOutcome(context=ctx)
@@ -0,0 +1,83 @@
1
+ """Token hashing and verification (host-side credential material).
2
+
3
+ Tokens never travel to disk in plaintext: the host stores a scrypt hash in
4
+ ``users.toml`` / ``config.toml`` and verifies presented bearer tokens against
5
+ it. Format::
6
+
7
+ scrypt$<n>$<r>$<p>$<salt-hex>$<derived-key-hex>
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import hmac
14
+ import secrets
15
+
16
+ _SCRYPT_N = 2**15
17
+ _SCRYPT_R = 8
18
+ _SCRYPT_P = 1
19
+ _DKLEN = 32
20
+ _PREFIX = "scrypt"
21
+ # 128 * N * r = 32 MiB working memory; OpenSSL's default cap is lower on some
22
+ # builds, so grant an explicit 64 MiB ceiling.
23
+ _MAXMEM = 64 * 1024 * 1024
24
+
25
+
26
+ class TokenHashError(ValueError):
27
+ """Raised when an encoded token hash is malformed."""
28
+
29
+
30
+ def hash_token(token: str) -> str:
31
+ """Hash a bearer token into the storable ``scrypt$...`` encoding."""
32
+ salt = secrets.token_bytes(16)
33
+ dk = hashlib.scrypt(
34
+ token.encode("utf-8"),
35
+ salt=salt,
36
+ n=_SCRYPT_N,
37
+ r=_SCRYPT_R,
38
+ p=_SCRYPT_P,
39
+ dklen=_DKLEN,
40
+ maxmem=_MAXMEM,
41
+ )
42
+ return "$".join(
43
+ [
44
+ _PREFIX,
45
+ str(_SCRYPT_N),
46
+ str(_SCRYPT_R),
47
+ str(_SCRYPT_P),
48
+ salt.hex(),
49
+ dk.hex(),
50
+ ]
51
+ )
52
+
53
+
54
+ def verify_token(token: str, encoded: str) -> bool:
55
+ """Constant-time verify a presented token against an encoded hash."""
56
+ try:
57
+ prefix, n_s, r_s, p_s, salt_hex, dk_hex = encoded.split("$")
58
+ n, r, p = int(n_s), int(r_s), int(p_s)
59
+ salt = bytes.fromhex(salt_hex)
60
+ expected = bytes.fromhex(dk_hex)
61
+ except (ValueError, AttributeError) as exc:
62
+ raise TokenHashError(f"malformed token hash encoding: {encoded[:24]!r}") from exc
63
+ if prefix != _PREFIX or not salt or not expected:
64
+ raise TokenHashError(f"malformed token hash encoding: {encoded[:24]!r}")
65
+ actual = hashlib.scrypt(
66
+ token.encode("utf-8"),
67
+ salt=salt,
68
+ n=n,
69
+ r=r,
70
+ p=p,
71
+ dklen=len(expected),
72
+ maxmem=_MAXMEM,
73
+ )
74
+ return hmac.compare_digest(actual, expected)
75
+
76
+
77
+ def is_valid_encoding(encoded: str) -> bool:
78
+ """True when ``encoded`` parses as a scrypt token hash."""
79
+ try:
80
+ verify_token("", encoded)
81
+ except TokenHashError:
82
+ return False
83
+ return True
@@ -0,0 +1,131 @@
1
+ """users.toml — the multi-user table (auth mode 3).
2
+
3
+ Schema (spec §4)::
4
+
5
+ [users.<uid>]
6
+ token_hash = "scrypt$..." # mint with `hull token hash`
7
+ enabled = true
8
+ namespace = "<uid-or-shared>" # data isolation root
9
+ allowed_roots = ["/data/alice"] # filesystem roots tools may touch
10
+ [users.<uid>.limits]
11
+ rpm = 60 # optional per-minute request cap
12
+
13
+ Admin is host-side only: edit the file, restart the server. There is no admin
14
+ API and no self-service.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import tomllib
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Protocol
23
+
24
+ from hull_core.auth.tokens import is_valid_encoding, verify_token
25
+
26
+
27
+ class UsersError(ValueError):
28
+ """Raised when users.toml is missing, malformed, or semantically invalid."""
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class UserLimits:
33
+ rpm: int | None = None
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class User:
38
+ uid: str
39
+ token_hash: str
40
+ enabled: bool
41
+ namespace: str
42
+ allowed_roots: tuple[str, ...] = ()
43
+ limits: UserLimits = field(default_factory=UserLimits)
44
+
45
+
46
+ def load_users(path: Path) -> dict[str, User]:
47
+ """Parse and validate users.toml into a ``uid → User`` mapping."""
48
+ if not path.is_file():
49
+ raise UsersError(f"users file not found: {path}")
50
+ try:
51
+ raw = tomllib.loads(path.read_text(encoding="utf-8"))
52
+ except tomllib.TOMLDecodeError as exc:
53
+ raise UsersError(f"users file is not valid TOML: {path}: {exc}") from exc
54
+
55
+ table = raw.get("users")
56
+ if not isinstance(table, dict) or not table:
57
+ raise UsersError("users file must contain a non-empty [users] table")
58
+
59
+ users: dict[str, User] = {}
60
+ for uid, entry in table.items():
61
+ if not isinstance(entry, dict):
62
+ raise UsersError(f"user {uid!r}: expected a table")
63
+ token_hash = entry.get("token_hash")
64
+ if not isinstance(token_hash, str) or not is_valid_encoding(token_hash):
65
+ raise UsersError(
66
+ f"user {uid!r}: token_hash missing or not a scrypt encoding (mint one with `hull token hash`)"
67
+ )
68
+ namespace = entry.get("namespace")
69
+ if not isinstance(namespace, str) or not namespace:
70
+ raise UsersError(f"user {uid!r}: namespace must be a non-empty string")
71
+ enabled = entry.get("enabled", True)
72
+ if not isinstance(enabled, bool):
73
+ raise UsersError(f"user {uid!r}: enabled must be a boolean")
74
+ roots = entry.get("allowed_roots", [])
75
+ if not isinstance(roots, list) or not all(isinstance(r, str) for r in roots):
76
+ raise UsersError(f"user {uid!r}: allowed_roots must be a list of strings")
77
+ limits_raw = entry.get("limits", {})
78
+ if not isinstance(limits_raw, dict):
79
+ raise UsersError(f"user {uid!r}: limits must be a table")
80
+ rpm = limits_raw.get("rpm")
81
+ if rpm is not None and (not isinstance(rpm, int) or rpm < 1):
82
+ raise UsersError(f"user {uid!r}: limits.rpm must be a positive integer")
83
+ users[uid] = User(
84
+ uid=uid,
85
+ token_hash=token_hash,
86
+ enabled=enabled,
87
+ namespace=namespace,
88
+ allowed_roots=tuple(roots),
89
+ limits=UserLimits(rpm=rpm),
90
+ )
91
+ return users
92
+
93
+
94
+ def find_user_by_token(users: dict[str, User], token: str) -> User | None:
95
+ """Return the user whose token_hash verifies ``token``, else None."""
96
+ for user in users.values():
97
+ if verify_token(token, user.token_hash):
98
+ return user
99
+ return None
100
+
101
+
102
+ class PathScope(Protocol):
103
+ """Anything carrying a caller's filesystem roots.
104
+
105
+ Both the users.toml row (:class:`User`) and the per-request
106
+ :class:`~hull_core.auth.context.AuthContext` reach ``ensure_path_allowed``,
107
+ so the guard takes the shape both already have instead of one of them.
108
+ """
109
+
110
+ @property
111
+ def uid(self) -> str:
112
+ """Caller id used in the denial message."""
113
+
114
+ @property
115
+ def allowed_roots(self) -> tuple[str, ...]:
116
+ """Roots the caller may touch; empty grants nothing."""
117
+
118
+
119
+ def ensure_path_allowed(user: PathScope, path: Path) -> None:
120
+ """Enforce the user's allowed_roots (spec §4 Q6).
121
+
122
+ An empty allowed_roots list grants nothing. Roots are matched lexically
123
+ after resolving both sides, so symlinked escapes require the resolved
124
+ target to sit under a root too.
125
+ """
126
+ resolved = path.resolve()
127
+ for root in user.allowed_roots:
128
+ root_resolved = Path(root).resolve()
129
+ if resolved == root_resolved or root_resolved in resolved.parents:
130
+ return
131
+ raise PermissionError(f"path {resolved} is outside allowed roots for user {user.uid!r}")
hull_core/cli.py ADDED
@@ -0,0 +1,107 @@
1
+ """Host-side CLI: control plane + consumer.
2
+
3
+ Subcommands: ``server start``, ``token hash``, ``config init|path|show``,
4
+ ``db path``. With the server down, consumers are told to start it first —
5
+ there is no hidden local-core mode (spec §3).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+
13
+ from hull_core.auth.tokens import hash_token
14
+ from hull_core.config.settings import (
15
+ CONFIG_TEMPLATE,
16
+ default_config_dir,
17
+ load_settings,
18
+ write_default_config,
19
+ )
20
+ from hull_core.server.run import ServerConfigError, run_server
21
+
22
+
23
+ def _cmd_server_start(args: argparse.Namespace) -> int:
24
+ try:
25
+ run_server(host=args.host, port=args.port)
26
+ except ServerConfigError as exc:
27
+ print(f"error: {exc}", file=sys.stderr)
28
+ return 2
29
+ except KeyboardInterrupt:
30
+ pass
31
+ return 0
32
+
33
+
34
+ def _cmd_token_hash(args: argparse.Namespace) -> int:
35
+ print(hash_token(args.token))
36
+ return 0
37
+
38
+
39
+ def _cmd_config_init(args: argparse.Namespace) -> int:
40
+ try:
41
+ path = write_default_config(force=args.force)
42
+ except FileExistsError as exc:
43
+ print(f"error: {exc}", file=sys.stderr)
44
+ return 2
45
+ print(f"wrote {path}")
46
+ return 0
47
+
48
+
49
+ def _cmd_config_path(_args: argparse.Namespace) -> int:
50
+ print(default_config_dir() / "config.toml")
51
+ return 0
52
+
53
+
54
+ def _cmd_config_show(_args: argparse.Namespace) -> int:
55
+ path = default_config_dir() / "config.toml"
56
+ if path.is_file():
57
+ print(path.read_text(encoding="utf-8"), end="")
58
+ else:
59
+ print(CONFIG_TEMPLATE, end="")
60
+ return 0
61
+
62
+
63
+ def _cmd_db_path(_args: argparse.Namespace) -> int:
64
+ settings = load_settings()
65
+ print(settings.config_dir / "hull.db")
66
+ return 0
67
+
68
+
69
+ def build_parser() -> argparse.ArgumentParser:
70
+ parser = argparse.ArgumentParser(prog="hull", description="hull shared core control plane")
71
+ sub = parser.add_subparsers(dest="command", required=True)
72
+
73
+ server = sub.add_parser("server", help="server lifecycle")
74
+ server_sub = server.add_subparsers(dest="server_command", required=True)
75
+ start = server_sub.add_parser("start", help="start the HTTP MCP server (blocking)")
76
+ start.add_argument("--host", default=None, help="bind host (default from config)")
77
+ start.add_argument("--port", type=int, default=None, help="bind port (default from config)")
78
+ start.set_defaults(func=_cmd_server_start)
79
+
80
+ token = sub.add_parser("token", help="token utilities")
81
+ token_sub = token.add_subparsers(dest="token_command", required=True)
82
+ token_hash_p = token_sub.add_parser("hash", help="mint a scrypt token_hash from a plaintext token")
83
+ token_hash_p.add_argument("token", help="plaintext token (keep it out of shells that log history)")
84
+ token_hash_p.set_defaults(func=_cmd_token_hash)
85
+
86
+ config = sub.add_parser("config", help="instance config utilities")
87
+ config_sub = config.add_subparsers(dest="config_command", required=True)
88
+ init_p = config_sub.add_parser("init", help="write ~/.hull/config.toml from the template")
89
+ init_p.add_argument("--force", action="store_true", help="overwrite an existing config")
90
+ init_p.set_defaults(func=_cmd_config_init)
91
+ path_p = config_sub.add_parser("path", help="print the config file path")
92
+ path_p.set_defaults(func=_cmd_config_path)
93
+ show_p = config_sub.add_parser("show", help="print the effective config (template if absent)")
94
+ show_p.set_defaults(func=_cmd_config_show)
95
+
96
+ db = sub.add_parser("db", help="storage utilities")
97
+ db_sub = db.add_subparsers(dest="db_command", required=True)
98
+ db_path_p = db_sub.add_parser("path", help="print the SQLite database path")
99
+ db_path_p.set_defaults(func=_cmd_db_path)
100
+
101
+ return parser
102
+
103
+
104
+ def main(argv: list[str] | None = None) -> int:
105
+ parser = build_parser()
106
+ args = parser.parse_args(argv)
107
+ return int(args.func(args))
@@ -0,0 +1,39 @@
1
+ """Instance config: server mode + per-task model cells."""
2
+
3
+ from hull_core.config.models import (
4
+ DEFAULT_BASE_URL,
5
+ DEFAULT_MODELS,
6
+ TASKS,
7
+ ModelCell,
8
+ api_key_env,
9
+ model_cell_from_config,
10
+ resolve_model_cells,
11
+ )
12
+ from hull_core.config.settings import (
13
+ AUTH_MODES,
14
+ CONFIG_TEMPLATE,
15
+ ConfigError,
16
+ HullSettings,
17
+ ServerSettings,
18
+ default_config_dir,
19
+ load_settings,
20
+ write_default_config,
21
+ )
22
+
23
+ __all__ = [
24
+ "AUTH_MODES",
25
+ "CONFIG_TEMPLATE",
26
+ "DEFAULT_BASE_URL",
27
+ "DEFAULT_MODELS",
28
+ "TASKS",
29
+ "ConfigError",
30
+ "HullSettings",
31
+ "ModelCell",
32
+ "ServerSettings",
33
+ "api_key_env",
34
+ "default_config_dir",
35
+ "load_settings",
36
+ "model_cell_from_config",
37
+ "resolve_model_cells",
38
+ "write_default_config",
39
+ ]