mindyard 0.1.0__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 (59) hide show
  1. mindyard/__init__.py +4 -0
  2. mindyard/auth/__init__.py +21 -0
  3. mindyard/auth/backends.py +169 -0
  4. mindyard/auth/consent.py +76 -0
  5. mindyard/auth/gate.py +78 -0
  6. mindyard/auth/policy.py +46 -0
  7. mindyard/auth/tokens.py +96 -0
  8. mindyard/cli.py +132 -0
  9. mindyard/core/config.py +66 -0
  10. mindyard/core/errors.py +235 -0
  11. mindyard/core/git.py +86 -0
  12. mindyard/core/operations.py +525 -0
  13. mindyard/core/paths.py +57 -0
  14. mindyard/core/render.py +111 -0
  15. mindyard/core/search.py +129 -0
  16. mindyard/identity.py +52 -0
  17. mindyard/init.py +46 -0
  18. mindyard/lint/__init__.py +16 -0
  19. mindyard/lint/checks.py +220 -0
  20. mindyard/lint/markdown.py +104 -0
  21. mindyard/lint/runner.py +97 -0
  22. mindyard/mcp/__init__.py +7 -0
  23. mindyard/mcp/accesslog.py +33 -0
  24. mindyard/mcp/app.py +105 -0
  25. mindyard/mcp/middleware.py +64 -0
  26. mindyard/mcp/server.py +225 -0
  27. mindyard/mcp/telemetry.py +145 -0
  28. mindyard/schema/__init__.py +7 -0
  29. mindyard/schema/discovery.py +52 -0
  30. mindyard/schema/manifest.py +180 -0
  31. mindyard/settings.py +135 -0
  32. mindyard/static/icon-96.png +0 -0
  33. mindyard/static/icon.svg +1 -0
  34. mindyard/templates/blank/.gitignore +2 -0
  35. mindyard/templates/blank/index.md +3 -0
  36. mindyard/templates/blank/log.md +6 -0
  37. mindyard/templates/blank/raw/.gitkeep +0 -0
  38. mindyard/templates/blank/schema.md +37 -0
  39. mindyard/templates/blank/wiki/.gitkeep +0 -0
  40. mindyard/templates/blank/wiki.yaml +18 -0
  41. mindyard/templates/demo/.gitignore +2 -0
  42. mindyard/templates/demo/index.md +11 -0
  43. mindyard/templates/demo/log.md +6 -0
  44. mindyard/templates/demo/raw/2026-03-seed-swap-notes.md +5 -0
  45. mindyard/templates/demo/schema.md +20 -0
  46. mindyard/templates/demo/wiki/concepts/companion-planting.md +11 -0
  47. mindyard/templates/demo/wiki/events/spring-seed-swap.md +13 -0
  48. mindyard/templates/demo/wiki/plants/basil.md +13 -0
  49. mindyard/templates/demo/wiki/plants/tomato.md +14 -0
  50. mindyard/templates/demo/wiki.yaml +18 -0
  51. mindyard/tenancy/__init__.py +26 -0
  52. mindyard/tenancy/context.py +38 -0
  53. mindyard/tenancy/resolver.py +121 -0
  54. mindyard/tenancy/tenant.py +42 -0
  55. mindyard-0.1.0.dist-info/METADATA +124 -0
  56. mindyard-0.1.0.dist-info/RECORD +59 -0
  57. mindyard-0.1.0.dist-info/WHEEL +4 -0
  58. mindyard-0.1.0.dist-info/entry_points.txt +2 -0
  59. mindyard-0.1.0.dist-info/licenses/LICENSE +661 -0
mindyard/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """mindyard: a personal LLM wiki served over MCP."""
3
+
4
+ __version__ = "0.1.0"
@@ -0,0 +1,21 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Pluggable authentication and access policies."""
3
+
4
+ from .backends import AuthBackend, BearerAuth, GoogleAuth, NoAuth, google_identity
5
+ from .policy import AccessPolicy, AllowAll, AllowAuthenticated, EmailAllowlist
6
+ from .tokens import TokenStore, TokenStoreError, add_token
7
+
8
+ __all__ = [
9
+ "AccessPolicy",
10
+ "AllowAll",
11
+ "AllowAuthenticated",
12
+ "AuthBackend",
13
+ "BearerAuth",
14
+ "EmailAllowlist",
15
+ "GoogleAuth",
16
+ "NoAuth",
17
+ "TokenStore",
18
+ "TokenStoreError",
19
+ "add_token",
20
+ "google_identity",
21
+ ]
@@ -0,0 +1,169 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Authentication backends: who is calling.
3
+
4
+ A backend has two jobs. It supplies the FastMCP auth provider that guards the
5
+ HTTP endpoint (or none), and it turns the verified access token into a neutral
6
+ :class:`~mindyard.identity.Identity`. Deciding whether that identity may come in is
7
+ the access policy's job, and which wikis it sees is the tenant resolver's.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Mapping
13
+ from typing import Any, Protocol
14
+
15
+ from fastmcp.server.auth import AccessToken, AuthProvider
16
+ from fastmcp.server.auth.providers.google import GoogleProvider
17
+
18
+ from mindyard.identity import Identity
19
+
20
+ from .consent import StableConsentMixin
21
+ from .gate import CallbackGateMixin
22
+ from .policy import AccessPolicy
23
+ from .tokens import BEARER_ISSUER, HashedTokenVerifier, TokenStore
24
+
25
+ GOOGLE_ISSUER = "https://accounts.google.com"
26
+ GOOGLE_SCOPES = ("openid", "email", "profile")
27
+
28
+
29
+ class AuthBackend(Protocol):
30
+ name: str
31
+
32
+ def provider(self) -> AuthProvider | None: ...
33
+
34
+ def identity(self, token: AccessToken | None) -> Identity | None: ...
35
+
36
+
37
+ class NoAuth:
38
+ """Local development only. The server refuses to listen beyond loopback."""
39
+
40
+ name = "none"
41
+
42
+ def provider(self) -> AuthProvider | None:
43
+ return None
44
+
45
+ def identity(self, token: AccessToken | None) -> Identity | None:
46
+ return None
47
+
48
+
49
+ class BearerAuth:
50
+ """Static bearer tokens, stored hashed; one named principal per token.
51
+
52
+ Suited to Claude Code, Claude Desktop (through a stdio bridge) and scripts.
53
+ Claude.ai custom connectors accept static headers only for some accounts,
54
+ so OAuth remains the route there.
55
+ """
56
+
57
+ name = "bearer"
58
+
59
+ def __init__(self, store: TokenStore) -> None:
60
+ self._verifier = HashedTokenVerifier(store)
61
+
62
+ def provider(self) -> AuthProvider | None:
63
+ return self._verifier
64
+
65
+ def identity(self, token: AccessToken | None) -> Identity | None:
66
+ if token is None:
67
+ return None
68
+ return Identity(issuer=BEARER_ISSUER, subject=token.client_id)
69
+
70
+
71
+ def _attested(flag: Any) -> bool:
72
+ """True only for an explicit affirmative flag, boolean or string.
73
+
74
+ Google's tokeninfo returns ``"true"``/``"false"`` strings and userinfo a JSON
75
+ boolean. ``"false"`` is a truthy Python string: a plain truth test would read
76
+ a refusal as a proof.
77
+ """
78
+ if isinstance(flag, bool):
79
+ return flag
80
+ if isinstance(flag, str):
81
+ return flag.strip().lower() == "true"
82
+ return False
83
+
84
+
85
+ def email_verified(claims: Mapping[str, Any]) -> bool:
86
+ """True only if every source present explicitly attests the email.
87
+
88
+ The flag has two homes: ``email_verified`` at the top level and
89
+ ``verified_email`` in the ``google_user_data`` profile, which is ``None``
90
+ when the userinfo fetch fails. No flag is not a verification, and any source
91
+ that disagrees is a refusal.
92
+ """
93
+ flags = [
94
+ source[key]
95
+ for source in (claims, claims.get("google_user_data"))
96
+ if isinstance(source, Mapping)
97
+ for key in ("email_verified", "verified_email")
98
+ if key in source
99
+ ]
100
+ return bool(flags) and all(_attested(flag) for flag in flags)
101
+
102
+
103
+ def google_identity(claims: Mapping[str, Any] | None) -> Identity | None:
104
+ """Identity from Google claims, or ``None`` without a subject."""
105
+ if not claims:
106
+ return None
107
+ subject = claims.get("sub")
108
+ if not isinstance(subject, str) or not subject:
109
+ return None
110
+ email = claims.get("email")
111
+ return Identity(
112
+ issuer=GOOGLE_ISSUER,
113
+ subject=subject,
114
+ email=email.strip().lower() if isinstance(email, str) and email.strip() else None,
115
+ email_verified=email_verified(claims),
116
+ claims=dict(claims),
117
+ )
118
+
119
+
120
+ class StableConsentGoogleProvider(StableConsentMixin, GoogleProvider):
121
+ """Google OAuth proxy whose consent page tolerates a second load."""
122
+
123
+
124
+ class GatedGoogleProvider(CallbackGateMixin, StableConsentGoogleProvider):
125
+ """The same proxy, refusing an identity the policy denies at the callback."""
126
+
127
+
128
+ class GoogleAuth:
129
+ """OAuth through Google, proxied by FastMCP so Claude.ai can connect.
130
+
131
+ The proxy persists client registrations and tokens under FastMCP's home
132
+ directory: losing it forces every client to sign in again. Given the access
133
+ policy, it also refuses a denied account at the callback, before the client
134
+ holds a token.
135
+ """
136
+
137
+ name = "google"
138
+
139
+ def __init__(
140
+ self,
141
+ client_id: str,
142
+ client_secret: str,
143
+ base_url: str,
144
+ redirect_path: str = "/auth/callback",
145
+ policy: AccessPolicy | None = None,
146
+ ) -> None:
147
+ self._client_id = client_id
148
+ self._client_secret = client_secret
149
+ self._base_url = base_url
150
+ self._redirect_path = redirect_path
151
+ self._policy = policy
152
+
153
+ def provider(self) -> AuthProvider | None:
154
+ options: dict[str, Any] = {
155
+ "client_id": self._client_id,
156
+ "client_secret": self._client_secret,
157
+ "base_url": self._base_url,
158
+ "redirect_path": self._redirect_path,
159
+ "required_scopes": list(GOOGLE_SCOPES),
160
+ }
161
+ policy = self._policy
162
+ if policy is None:
163
+ return StableConsentGoogleProvider(**options)
164
+ return GatedGoogleProvider(
165
+ admit=lambda token: policy.allows(self.identity(token)), **options
166
+ )
167
+
168
+ def identity(self, token: AccessToken | None) -> Identity | None:
169
+ return google_identity(token.claims if token is not None else None)
@@ -0,0 +1,76 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """A consent page whose CSRF token survives being loaded twice.
3
+
4
+ FastMCP's OAuth proxy issues a fresh CSRF token every time ``GET /consent`` is
5
+ served, and stores it on the transaction. Any second load of the same URL thus
6
+ invalidates the form the user is looking at. Second loads happen in practice:
7
+ some mobile browsers hand visited URLs to a scanning service, which fetches the
8
+ page within a second, so the user's "Approve" arrives with a stale token
9
+ ("Invalid or expired consent token").
10
+
11
+ This mixin keeps the token stable for the lifetime of the transaction: when the
12
+ transaction already holds an unexpired token, a reload is served that same
13
+ token. The double-submit check is untouched. A third party learning the token
14
+ gains nothing: the token is only accepted together with the browser's own
15
+ ``MCP_CONSENT_STATE`` cookie, which is ``SameSite=Lax`` and therefore never
16
+ attached to a cross-site POST.
17
+
18
+ It post-processes FastMCP's response instead of copying its private method, so
19
+ it depends on three internals only: the transaction store and its
20
+ ``csrf_token``/``csrf_expires_at`` fields, the cookie helpers, and the token
21
+ appearing verbatim in the rendered form. ``tests/auth/test_consent.py`` replays
22
+ the race end to end and fails if any of them changes.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import time
28
+ from typing import Any
29
+
30
+ from starlette.requests import Request
31
+ from starlette.responses import HTMLResponse, RedirectResponse
32
+
33
+ CONSENT_COOKIE = "MCP_CONSENT_STATE"
34
+
35
+
36
+ class StableConsentMixin:
37
+ """Mix into an ``OAuthProxy`` subclass, before it in the bases."""
38
+
39
+ async def _show_consent_page(self, request: Request) -> HTMLResponse | RedirectResponse:
40
+ proxy: Any = self
41
+ txn_id = request.query_params.get("txn_id")
42
+ previous: tuple[str, float] | None = None
43
+ if txn_id:
44
+ txn = await proxy._transaction_store.get(key=txn_id)
45
+ if txn is not None and txn.csrf_token and (txn.csrf_expires_at or 0) > time.time():
46
+ previous = (txn.csrf_token, float(txn.csrf_expires_at))
47
+
48
+ response: HTMLResponse | RedirectResponse = await super()._show_consent_page( # type: ignore[misc]
49
+ request
50
+ )
51
+ if previous is None or response.status_code != 200 or not txn_id:
52
+ return response
53
+ txn = await proxy._transaction_store.get(key=txn_id)
54
+ fresh = txn.csrf_token if txn is not None else None
55
+ body = bytes(response.body)
56
+ if not fresh or fresh == previous[0] or fresh.encode() not in body:
57
+ return response
58
+
59
+ token, expires_at = previous
60
+ txn.csrf_token, txn.csrf_expires_at = token, expires_at
61
+ ttl = max(1, int(expires_at - time.time()))
62
+ await proxy._transaction_store.put(key=txn_id, value=txn, ttl=ttl)
63
+
64
+ response.body = body.replace(fresh.encode(), token.encode())
65
+ response.headers["content-length"] = str(len(response.body))
66
+ cookie_name = proxy._cookie_name(CONSENT_COOKIE)
67
+ response.raw_headers = [
68
+ (k, v)
69
+ for k, v in response.raw_headers
70
+ if not (k == b"set-cookie" and v.startswith(cookie_name.encode() + b"="))
71
+ ]
72
+ tokens = [t for t in proxy._decode_list_cookie(request, CONSENT_COOKIE) if t != token]
73
+ proxy._set_list_cookie(
74
+ response, CONSENT_COOKIE, proxy._encode_list_cookie([*tokens, token]), max_age=ttl
75
+ )
76
+ return response
mindyard/auth/gate.py ADDED
@@ -0,0 +1,78 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Refuse an identity at the OAuth callback, not only at the first MCP request.
3
+
4
+ FastMCP's OAuth proxy hands an authorization code to any account the upstream
5
+ provider signs in. With Google that is any account on earth: the access policy
6
+ then refuses every MCP request, but the client already holds a token. Claude.ai
7
+ keeps such a token and the connector stays stuck until it is disconnected.
8
+
9
+ This mixin checks the identity as soon as Google's tokens are in hand. A refused
10
+ identity gets no code: its code is deleted and the client is redirected with
11
+ ``error=access_denied`` (RFC 6749, section 4.1.2.1), so it never stores a token.
12
+ The per-request gate stays the authority; this is the early, friendly refusal.
13
+
14
+ Like the consent mixin, it post-processes FastMCP's response instead of copying
15
+ its handler. It depends on the code store, the ``idp_tokens`` of a stored code
16
+ and the upstream token verifier; ``tests/auth/test_gate.py`` drives the whole
17
+ callback and fails if any of them changes.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Awaitable, Callable
23
+ from typing import Any
24
+ from urllib.parse import parse_qs, urlencode, urlparse
25
+
26
+ from fastmcp.server.auth import AccessToken
27
+ from starlette.requests import Request
28
+ from starlette.responses import HTMLResponse, RedirectResponse
29
+
30
+ Admit = Callable[[AccessToken | None], bool]
31
+
32
+ DENIED_DESCRIPTION = "This account is not allowed on this server."
33
+
34
+
35
+ class CallbackGateMixin:
36
+ """Mix into an ``OAuthProxy`` subclass, before it in the bases."""
37
+
38
+ def __init__(self, *args: Any, admit: Admit, **kwargs: Any) -> None:
39
+ super().__init__(*args, **kwargs)
40
+ self._admit = admit
41
+
42
+ async def _handle_idp_callback(self, request: Request) -> HTMLResponse | RedirectResponse:
43
+ proxy: Any = self
44
+ parent: Callable[[Request], Awaitable[HTMLResponse | RedirectResponse]]
45
+ parent = super()._handle_idp_callback # type: ignore[misc]
46
+ response = await parent(request)
47
+ if not isinstance(response, RedirectResponse):
48
+ return response
49
+ target = urlparse(response.headers["location"])
50
+ params = parse_qs(target.query)
51
+ code = params.get("code", [None])[0]
52
+ if not code:
53
+ return response
54
+ stored = await proxy._code_store.get(key=code)
55
+ if stored is not None and self._admit(await self._verify(stored.idp_tokens)):
56
+ return response
57
+
58
+ await proxy._code_store.delete(key=code)
59
+ denied = {"error": "access_denied", "error_description": DENIED_DESCRIPTION}
60
+ if "state" in params:
61
+ denied["state"] = params["state"][0]
62
+ kept = [(k, v) for k, vs in params.items() if k not in ("code", "state") for v in vs]
63
+ location = target._replace(query=urlencode([*kept, *denied.items()])).geturl()
64
+ refused = RedirectResponse(url=location, status_code=302)
65
+ refused.raw_headers += [(k, v) for k, v in response.raw_headers if k == b"set-cookie"]
66
+ return refused
67
+
68
+ async def _verify(self, idp_tokens: dict[str, Any]) -> AccessToken | None:
69
+ """The upstream verifier's verdict on the fresh tokens; failure denies."""
70
+ proxy: Any = self
71
+ access_token = idp_tokens.get("access_token")
72
+ if not isinstance(access_token, str) or not access_token:
73
+ return None
74
+ try:
75
+ verified: AccessToken | None = await proxy._token_validator.verify_token(access_token)
76
+ except Exception:
77
+ return None
78
+ return verified
@@ -0,0 +1,46 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Access policies: may this identity use the server at all.
3
+
4
+ Applied to every MCP request, the handshake included: the server instructions
5
+ and the tool list already describe the wikis, so an identity off the list must
6
+ not even learn what is behind the door.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Protocol
12
+
13
+ from mindyard.identity import Identity
14
+
15
+
16
+ class AccessPolicy(Protocol):
17
+ def allows(self, identity: Identity | None) -> bool: ...
18
+
19
+
20
+ class AllowAll:
21
+ """For backends whose credentials are explicit grants (bearer, no auth)."""
22
+
23
+ def allows(self, identity: Identity | None) -> bool:
24
+ return True
25
+
26
+
27
+ class AllowAuthenticated:
28
+ """Any verified identity; the tenant map then decides what it sees."""
29
+
30
+ def allows(self, identity: Identity | None) -> bool:
31
+ return identity is not None
32
+
33
+
34
+ class EmailAllowlist:
35
+ """A verified email on the list. An empty list denies everyone."""
36
+
37
+ def __init__(self, emails: frozenset[str]) -> None:
38
+ self._emails = frozenset(e.strip().lower() for e in emails if e.strip())
39
+
40
+ def allows(self, identity: Identity | None) -> bool:
41
+ return (
42
+ identity is not None
43
+ and identity.email_verified
44
+ and identity.email is not None
45
+ and identity.email in self._emails
46
+ )
@@ -0,0 +1,96 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Static bearer tokens, stored as SHA-256 digests in ``tokens.yaml``.
3
+
4
+ Tokens are 256-bit random strings, so a fast hash is enough: there is nothing
5
+ to brute-force, and the file never holds a usable secret. Lookup is by digest,
6
+ so comparing a guess costs the same whatever it matches.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import re
13
+ import secrets
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ import yaml
18
+ from fastmcp.server.auth import AccessToken, TokenVerifier
19
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
20
+
21
+ BEARER_ISSUER = "mindyard:bearer"
22
+ TOKEN_PREFIX = "myd_"
23
+ _NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,62}$")
24
+
25
+
26
+ class TokenStoreError(ValueError):
27
+ pass
28
+
29
+
30
+ def digest(token: str) -> str:
31
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
32
+
33
+
34
+ def new_token() -> str:
35
+ return TOKEN_PREFIX + secrets.token_urlsafe(32)
36
+
37
+
38
+ class _Entry(BaseModel):
39
+ model_config = ConfigDict(extra="forbid")
40
+ name: str
41
+ sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
42
+
43
+
44
+ class _File(BaseModel):
45
+ model_config = ConfigDict(extra="forbid")
46
+ tokens: list[_Entry] = []
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class TokenStore:
51
+ """Token digest to principal name. Loaded once, at startup."""
52
+
53
+ by_digest: dict[str, str]
54
+
55
+ @classmethod
56
+ def load(cls, path: Path) -> TokenStore:
57
+ if not path.is_file():
58
+ return cls({})
59
+ try:
60
+ parsed = _File.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")) or {})
61
+ except (yaml.YAMLError, ValidationError) as exc:
62
+ raise TokenStoreError(f"{path}: invalid ({exc})") from exc
63
+ by_digest: dict[str, str] = {}
64
+ for entry in parsed.tokens:
65
+ if entry.sha256 in by_digest:
66
+ raise TokenStoreError(f"{path}: duplicate token digest")
67
+ by_digest[entry.sha256] = entry.name
68
+ return cls(by_digest)
69
+
70
+
71
+ def add_token(path: Path, name: str) -> str:
72
+ """Mint a token for ``name``, store its digest, return the clear token once."""
73
+ if not _NAME_RE.match(name):
74
+ raise TokenStoreError(f"invalid token name {name!r}")
75
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) if path.is_file() else None
76
+ entries = list((data or {}).get("tokens", []))
77
+ if any(e.get("name") == name for e in entries):
78
+ raise TokenStoreError(f"a token named {name!r} already exists")
79
+ token = new_token()
80
+ entries.append({"name": name, "sha256": digest(token)})
81
+ path.parent.mkdir(parents=True, exist_ok=True)
82
+ path.write_text(yaml.safe_dump({"tokens": entries}, sort_keys=False), encoding="utf-8")
83
+ path.chmod(0o600)
84
+ return token
85
+
86
+
87
+ class HashedTokenVerifier(TokenVerifier):
88
+ def __init__(self, store: TokenStore) -> None:
89
+ super().__init__()
90
+ self._store = store
91
+
92
+ async def verify_token(self, token: str) -> AccessToken | None:
93
+ name = self._store.by_digest.get(digest(token))
94
+ if name is None:
95
+ return None
96
+ return AccessToken(token=token, client_id=name, scopes=[], claims={"sub": name})
mindyard/cli.py ADDED
@@ -0,0 +1,132 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """The ``mindyard`` command line: init, serve, lint, token."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import os
8
+ import sys
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+
12
+ from mindyard import __version__
13
+
14
+
15
+ def _cmd_init(args: argparse.Namespace) -> int:
16
+ from mindyard.init import InitError, init_vault
17
+
18
+ target = Path(args.directory)
19
+ label = args.label or target.name.replace("-", " ").capitalize()
20
+ try:
21
+ init_vault(target, label, "demo" if args.demo else "blank")
22
+ except InitError as exc:
23
+ print(f"mindyard init: {exc}", file=sys.stderr)
24
+ return 2
25
+ print(f"Vault created in {target}. Edit wiki.yaml and schema.md to make it yours.")
26
+ return 0
27
+
28
+
29
+ def _cmd_serve(args: argparse.Namespace) -> int:
30
+ from mindyard.mcp import run
31
+ from mindyard.settings import Settings, SettingsError
32
+ from mindyard.tenancy import TenantConfigError
33
+
34
+ env = dict(os.environ)
35
+ for flag, name in (
36
+ ("data_root", "MINDYARD_DATA_ROOT"),
37
+ ("host", "MINDYARD_HOST"),
38
+ ("port", "MINDYARD_PORT"),
39
+ ):
40
+ value = getattr(args, flag)
41
+ if value is not None:
42
+ env[name] = str(value)
43
+ try:
44
+ run(Settings.from_env(env))
45
+ except (SettingsError, TenantConfigError) as exc:
46
+ print(f"mindyard serve: {exc}", file=sys.stderr)
47
+ return 2
48
+ return 0
49
+
50
+
51
+ def _cmd_lint(args: argparse.Namespace) -> int:
52
+ from mindyard.core.config import CONFIG_FILE
53
+ from mindyard.lint import fix_table_pipes, lint_wiki, write_report
54
+ from mindyard.schema import ManifestError, Wiki, load_manifest
55
+
56
+ root = Path(args.vault).resolve()
57
+ if not (root / CONFIG_FILE).is_file():
58
+ print(f"mindyard lint: no {CONFIG_FILE} in {root}", file=sys.stderr)
59
+ return 2
60
+ try:
61
+ manifest = load_manifest(root)
62
+ except ManifestError as exc:
63
+ print(f"mindyard lint: {exc}", file=sys.stderr)
64
+ return 2
65
+ wiki = Wiki(manifest.to_config(root.name, root), manifest)
66
+ if args.fix:
67
+ for page, count in fix_table_pipes(wiki):
68
+ print(f"fixed {count} table pipe(s) in {page}")
69
+ findings = lint_wiki(wiki)
70
+ report = write_report(wiki, findings)
71
+ print(f"{findings.total} issue(s), {len(findings.items) - findings.total} notice(s): {report}")
72
+ return 1 if findings.total else 0
73
+
74
+
75
+ def _cmd_token(args: argparse.Namespace) -> int:
76
+ from mindyard.auth import TokenStoreError, add_token
77
+
78
+ data_root = args.data_root or os.environ.get("MINDYARD_DATA_ROOT")
79
+ if not data_root:
80
+ print("mindyard token: set MINDYARD_DATA_ROOT or pass --data-root", file=sys.stderr)
81
+ return 2
82
+ try:
83
+ token = add_token(Path(data_root) / "tokens.yaml", args.name)
84
+ except TokenStoreError as exc:
85
+ print(f"mindyard token: {exc}", file=sys.stderr)
86
+ return 2
87
+ print(token)
88
+ print(
89
+ "Shown once: only its digest is stored. Restart the server to load it.",
90
+ file=sys.stderr,
91
+ )
92
+ return 0
93
+
94
+
95
+ def build_parser() -> argparse.ArgumentParser:
96
+ parser = argparse.ArgumentParser(prog="mindyard", description=__doc__)
97
+ parser.add_argument("--version", action="version", version=f"mindyard {__version__}")
98
+ sub = parser.add_subparsers(dest="command", required=True)
99
+
100
+ p = sub.add_parser("init", help="create a vault")
101
+ p.add_argument("directory")
102
+ p.add_argument("--label", help="display name (default: from the directory name)")
103
+ p.add_argument("--demo", action="store_true", help="start from the demo garden vault")
104
+ p.set_defaults(func=_cmd_init)
105
+
106
+ p = sub.add_parser("serve", help="run the MCP server (configured by MINDYARD_* variables)")
107
+ p.add_argument("--data-root")
108
+ p.add_argument("--host")
109
+ p.add_argument("--port", type=int)
110
+ p.set_defaults(func=_cmd_serve)
111
+
112
+ p = sub.add_parser("lint", help="lint one vault and write its LINT_REPORT.md")
113
+ p.add_argument("vault")
114
+ p.add_argument("--fix", action="store_true", help="escape table-cell wikilink pipes first")
115
+ p.set_defaults(func=_cmd_lint)
116
+
117
+ p = sub.add_parser("token", help="mint a bearer token")
118
+ p.add_argument("action", choices=["new"])
119
+ p.add_argument("name", help="principal name, e.g. laptop")
120
+ p.add_argument("--data-root")
121
+ p.set_defaults(func=_cmd_token)
122
+ return parser
123
+
124
+
125
+ def main(argv: Sequence[str] | None = None) -> int:
126
+ args = build_parser().parse_args(argv)
127
+ code: int = args.func(args)
128
+ return code
129
+
130
+
131
+ if __name__ == "__main__":
132
+ raise SystemExit(main())
@@ -0,0 +1,66 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Resolved, immutable configuration of one wiki, as the core sees it.
3
+
4
+ The core never parses configuration files: ``mindyard.schema`` turns a vault's
5
+ ``wiki.yaml`` into a :class:`WikiConfig`, and the tenancy layer hands the core a
6
+ :class:`WikiSet` already scoped to the caller. Nothing in ``mindyard.core`` knows
7
+ where a wiki came from or whom it belongs to.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterator, Mapping
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from .errors import UnknownWikiError
17
+
18
+ CONFIG_FILE = "wiki.yaml"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class WikiConfig:
23
+ """Layout of a single wiki vault.
24
+
25
+ ``index_file``, ``log_file`` and ``schema_file`` are the only root files the
26
+ write path may touch. ``config_file`` (``wiki.yaml``) is deliberately not one
27
+ of them: the model must not be able to rewrite the rules that bind it.
28
+ """
29
+
30
+ id: str
31
+ root: Path
32
+ label: str
33
+ description: str = ""
34
+ wiki_dir: str = "wiki"
35
+ raw_dir: str = "raw"
36
+ index_file: str = "index.md"
37
+ log_file: str = "log.md"
38
+ schema_file: str = "schema.md"
39
+ lint_report_file: str = "LINT_REPORT.md"
40
+ log_sentinel: str = "<!-- log-end -->"
41
+
42
+
43
+ class WikiSet(Mapping[str, WikiConfig]):
44
+ """The wikis visible to one caller, keyed by id.
45
+
46
+ :meth:`get_wiki` is the single validation gate for a ``wiki`` argument. The
47
+ error lists only the ids of this set, so it never reveals another tenant's.
48
+ """
49
+
50
+ def __init__(self, wikis: Mapping[str, WikiConfig]) -> None:
51
+ self._wikis = dict(wikis)
52
+
53
+ def __getitem__(self, key: str) -> WikiConfig:
54
+ return self._wikis[key]
55
+
56
+ def __iter__(self) -> Iterator[str]:
57
+ return iter(self._wikis)
58
+
59
+ def __len__(self) -> int:
60
+ return len(self._wikis)
61
+
62
+ def get_wiki(self, wiki_id: str) -> WikiConfig:
63
+ try:
64
+ return self._wikis[wiki_id]
65
+ except KeyError:
66
+ raise UnknownWikiError(wiki_id, list(self._wikis)) from None