fmind 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.
fmind-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: fmind
3
+ Version: 0.1.0
4
+ Summary: Read Médéric Hurier's (Fmind) portfolio from the terminal.
5
+ Keywords: portfolio,cli,fmind,resume,articles
6
+ Author: Médéric Hurier
7
+ Author-email: Médéric Hurier <contact@fmind.dev>
8
+ License-Expression: MIT
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Topic :: Utilities
13
+ Requires-Dist: typer>=0.15
14
+ Requires-Dist: rich>=13.9
15
+ Requires-Python: >=3.11
16
+ Project-URL: Homepage, https://www.fmind.dev/
17
+ Project-URL: Source, https://github.com/fmind/cli
18
+ Project-URL: Issues, https://github.com/fmind/cli/issues
19
+ Description-Content-Type: text/markdown
20
+
21
+ # fmind
22
+
23
+ Read [Médéric Hurier's (Fmind)](https://www.fmind.dev/) portfolio from the terminal.
24
+
25
+ ```bash
26
+ uvx fmind whoami
27
+ ```
28
+
29
+ ## Why it stays small
30
+
31
+ Every command renders a document the website already publishes — the portfolio at [`/api/profile`](https://www.fmind.dev/api/profile) and, for `fmind read`, the Markdown source of an article at `/articles/<slug>.md`. Those are the same sources behind the site, the Atom feed, `llms.txt` and the MCP server.
32
+
33
+ This package therefore carries no copy of the portfolio and needs **no release when the website changes**: new articles, a renewed certification or a new engagement appear on the next run.
34
+
35
+ Responses are cached under `${XDG_CACHE_HOME:-~/.cache}/fmind/` for one hour, matching the endpoints' own `Cache-Control`. Once a cache exists the CLI also works offline, falling back to the stale copy when the network is unavailable.
36
+
37
+ ## Commands
38
+
39
+ | Command | Shows |
40
+ | --------------------------- | ------------------------------------------------- |
41
+ | `fmind whoami` | Name, current mission, contact, availability |
42
+ | `fmind about` | The biography |
43
+ | `fmind skills` | Core expertise, laid out like a usage screen |
44
+ | `fmind work` | Engagements, current one first |
45
+ | `fmind community` | Ambassador and advisory roles |
46
+ | `fmind cert [--verify]` | Certifications, the PhD, specializations |
47
+ | `fmind papers` | The doctorate and the peer-reviewed publications |
48
+ | `fmind project [--top 6]` | Open-source repositories and video series |
49
+ | `fmind sites` | Interactive tools published alongside the writing |
50
+ | `fmind article [--limit 6]` | The most recent writing |
51
+ | `fmind search <query>` | Articles matching every term, newest first |
52
+ | `fmind read <slug> [--raw]` | One article, rendered in the terminal |
53
+ | `fmind hire` | What can be booked right now |
54
+
55
+ Global options: `--refresh` to bypass the cache, `--json` to emit the raw section, `--no-color` for pipes, `--version`.
56
+
57
+ ### Reading
58
+
59
+ `fmind search` matches titles, summaries, tags and slugs; terms are ANDed, so each extra word narrows the result. `--tag` restricts to one of the site's own tags.
60
+
61
+ ```bash
62
+ fmind search "agent security"
63
+ fmind search "" --tag MLOps --limit 3
64
+ ```
65
+
66
+ `fmind read` takes a slug, or enough of one to be unambiguous, and prints the article. Use `--raw` for the Markdown source.
67
+
68
+ ```bash
69
+ fmind read agentgateway # resolves to agentgateway-vs-litellm
70
+ fmind read mlops-adventure-continue --raw | glow -
71
+ ```
72
+
73
+ ### Piping
74
+
75
+ `--json` turns every command into a data source, so the portfolio composes with the rest of the shell.
76
+
77
+ ```bash
78
+ fmind --json article --limit 20 | jq -r '.[] | "\(.date[:10]) \(.title)"'
79
+ fmind --json search agent | jq -r '.[].url'
80
+ fmind --json cert --verify | jq -r '.[].title'
81
+ ```
82
+
83
+ ## Install
84
+
85
+ ```bash
86
+ uvx fmind whoami # zero install
87
+ uv tool install fmind # persistent
88
+ pipx install fmind # alternative
89
+ ```
90
+
91
+ Point it at another origin with `FMIND_PROFILE_URL` — useful against a local `mise run watch` server of the website:
92
+
93
+ ```bash
94
+ FMIND_PROFILE_URL=http://127.0.0.1:8080/api/profile fmind whoami
95
+ ```
96
+
97
+ Article reads follow the same origin, so one variable moves the whole CLI.
98
+
99
+ ## Development
100
+
101
+ ```bash
102
+ mise install # install the pinned toolchain
103
+ mise run install # sync the locked environment and install hooks
104
+ mise run format # ruff imports and format, dprint
105
+ mise run check # ruff, ty, actionlint, zizmor, gitleaks
106
+ mise run test # offline pytest with branch coverage
107
+ mise run smoke # exercise every command against the live site
108
+ mise run all # format, check, test, build — the gate CI runs
109
+ ```
110
+
111
+ ## License
112
+
113
+ [MIT](LICENSE) — © 2026 Médéric Hurier (Fmind).
fmind-0.1.0/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # fmind
2
+
3
+ Read [Médéric Hurier's (Fmind)](https://www.fmind.dev/) portfolio from the terminal.
4
+
5
+ ```bash
6
+ uvx fmind whoami
7
+ ```
8
+
9
+ ## Why it stays small
10
+
11
+ Every command renders a document the website already publishes — the portfolio at [`/api/profile`](https://www.fmind.dev/api/profile) and, for `fmind read`, the Markdown source of an article at `/articles/<slug>.md`. Those are the same sources behind the site, the Atom feed, `llms.txt` and the MCP server.
12
+
13
+ This package therefore carries no copy of the portfolio and needs **no release when the website changes**: new articles, a renewed certification or a new engagement appear on the next run.
14
+
15
+ Responses are cached under `${XDG_CACHE_HOME:-~/.cache}/fmind/` for one hour, matching the endpoints' own `Cache-Control`. Once a cache exists the CLI also works offline, falling back to the stale copy when the network is unavailable.
16
+
17
+ ## Commands
18
+
19
+ | Command | Shows |
20
+ | --------------------------- | ------------------------------------------------- |
21
+ | `fmind whoami` | Name, current mission, contact, availability |
22
+ | `fmind about` | The biography |
23
+ | `fmind skills` | Core expertise, laid out like a usage screen |
24
+ | `fmind work` | Engagements, current one first |
25
+ | `fmind community` | Ambassador and advisory roles |
26
+ | `fmind cert [--verify]` | Certifications, the PhD, specializations |
27
+ | `fmind papers` | The doctorate and the peer-reviewed publications |
28
+ | `fmind project [--top 6]` | Open-source repositories and video series |
29
+ | `fmind sites` | Interactive tools published alongside the writing |
30
+ | `fmind article [--limit 6]` | The most recent writing |
31
+ | `fmind search <query>` | Articles matching every term, newest first |
32
+ | `fmind read <slug> [--raw]` | One article, rendered in the terminal |
33
+ | `fmind hire` | What can be booked right now |
34
+
35
+ Global options: `--refresh` to bypass the cache, `--json` to emit the raw section, `--no-color` for pipes, `--version`.
36
+
37
+ ### Reading
38
+
39
+ `fmind search` matches titles, summaries, tags and slugs; terms are ANDed, so each extra word narrows the result. `--tag` restricts to one of the site's own tags.
40
+
41
+ ```bash
42
+ fmind search "agent security"
43
+ fmind search "" --tag MLOps --limit 3
44
+ ```
45
+
46
+ `fmind read` takes a slug, or enough of one to be unambiguous, and prints the article. Use `--raw` for the Markdown source.
47
+
48
+ ```bash
49
+ fmind read agentgateway # resolves to agentgateway-vs-litellm
50
+ fmind read mlops-adventure-continue --raw | glow -
51
+ ```
52
+
53
+ ### Piping
54
+
55
+ `--json` turns every command into a data source, so the portfolio composes with the rest of the shell.
56
+
57
+ ```bash
58
+ fmind --json article --limit 20 | jq -r '.[] | "\(.date[:10]) \(.title)"'
59
+ fmind --json search agent | jq -r '.[].url'
60
+ fmind --json cert --verify | jq -r '.[].title'
61
+ ```
62
+
63
+ ## Install
64
+
65
+ ```bash
66
+ uvx fmind whoami # zero install
67
+ uv tool install fmind # persistent
68
+ pipx install fmind # alternative
69
+ ```
70
+
71
+ Point it at another origin with `FMIND_PROFILE_URL` — useful against a local `mise run watch` server of the website:
72
+
73
+ ```bash
74
+ FMIND_PROFILE_URL=http://127.0.0.1:8080/api/profile fmind whoami
75
+ ```
76
+
77
+ Article reads follow the same origin, so one variable moves the whole CLI.
78
+
79
+ ## Development
80
+
81
+ ```bash
82
+ mise install # install the pinned toolchain
83
+ mise run install # sync the locked environment and install hooks
84
+ mise run format # ruff imports and format, dprint
85
+ mise run check # ruff, ty, actionlint, zizmor, gitleaks
86
+ mise run test # offline pytest with branch coverage
87
+ mise run smoke # exercise every command against the live site
88
+ mise run all # format, check, test, build — the gate CI runs
89
+ ```
90
+
91
+ ## License
92
+
93
+ [MIT](LICENSE) — © 2026 Médéric Hurier (Fmind).
@@ -0,0 +1,85 @@
1
+ [project]
2
+ name = "fmind"
3
+ version = "0.1.0"
4
+ description = "Read Médéric Hurier's (Fmind) portfolio from the terminal."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ keywords = [
9
+ "portfolio",
10
+ "cli",
11
+ "fmind",
12
+ "resume",
13
+ "articles",
14
+ ]
15
+ classifiers = [
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Topic :: Utilities",
20
+ ]
21
+ dependencies = [
22
+ "typer>=0.15",
23
+ "rich>=13.9",
24
+ ]
25
+
26
+ [[project.authors]]
27
+ name = "Médéric Hurier"
28
+ email = "contact@fmind.dev"
29
+
30
+ [project.urls]
31
+ Homepage = "https://www.fmind.dev/"
32
+ Source = "https://github.com/fmind/cli"
33
+ Issues = "https://github.com/fmind/cli/issues"
34
+
35
+ [project.scripts]
36
+ fmind = "fmind.cli:app"
37
+
38
+ [dependency-groups]
39
+ dev = [
40
+ "pytest>=8.3",
41
+ "pytest-cov>=6.0",
42
+ "ruff>=0.9",
43
+ "ty>=0.0.1a1",
44
+ ]
45
+
46
+ [build-system]
47
+ requires = ["uv_build>=0.12.10,<0.13"]
48
+ build-backend = "uv_build"
49
+
50
+ [tool.uv.build-backend]
51
+ module-name = "fmind"
52
+ module-root = "src"
53
+
54
+ [tool.ruff]
55
+ line-length = 120
56
+ target-version = "py311"
57
+
58
+ [tool.ruff.lint]
59
+ select = [
60
+ "E",
61
+ "F",
62
+ "I",
63
+ "N",
64
+ "UP",
65
+ "B",
66
+ "S",
67
+ "SIM",
68
+ "RUF",
69
+ ]
70
+
71
+ [tool.ruff.lint.per-file-ignores]
72
+ "tests/**" = ["S101"]
73
+
74
+ [tool.ruff.format]
75
+ docstring-code-format = true
76
+
77
+ [tool.pytest.ini_options]
78
+ addopts = "-ra --strict-markers --cov=fmind --cov-branch --cov-fail-under=85"
79
+ testpaths = ["tests"]
80
+
81
+ [tool.coverage.report]
82
+ exclude_also = [
83
+ "if __name__ == .__main__.:",
84
+ "raise NotImplementedError",
85
+ ]
@@ -0,0 +1,58 @@
1
+ # https://packaging.python.org/en/latest/specifications/pyproject-toml/
2
+ [project]
3
+ name = "fmind"
4
+ version = "0.1.0"
5
+ description = "Read Médéric Hurier's (Fmind) portfolio from the terminal."
6
+ readme = "README.md"
7
+ license = "MIT"
8
+ authors = [{ name = "Médéric Hurier", email = "contact@fmind.dev" }]
9
+ requires-python = ">=3.11"
10
+ keywords = ["portfolio", "cli", "fmind", "resume", "articles"]
11
+ classifiers = [
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "Programming Language :: Python :: 3 :: Only",
15
+ "Topic :: Utilities",
16
+ ]
17
+ dependencies = ["typer>=0.15", "rich>=13.9"]
18
+
19
+ [project.urls]
20
+ Homepage = "https://www.fmind.dev/"
21
+ Source = "https://github.com/fmind/cli"
22
+ Issues = "https://github.com/fmind/cli/issues"
23
+
24
+ [project.scripts]
25
+ fmind = "fmind.cli:app"
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "pytest>=8.3",
30
+ "pytest-cov>=6.0",
31
+ "ruff>=0.9",
32
+ "ty>=0.0.1a1",
33
+ ]
34
+
35
+ [build-system]
36
+ requires = ["uv_build>=0.12.10,<0.13"]
37
+ build-backend = "uv_build"
38
+
39
+ [tool.uv.build-backend]
40
+ module-name = "fmind"
41
+ module-root = "src"
42
+
43
+ [tool.ruff]
44
+ line-length = 120
45
+ target-version = "py311"
46
+ [tool.ruff.lint]
47
+ select = ["E", "F", "I", "N", "UP", "B", "S", "SIM", "RUF"]
48
+ [tool.ruff.lint.per-file-ignores]
49
+ "tests/**" = ["S101"] # assertions are the point of a test
50
+ [tool.ruff.format]
51
+ docstring-code-format = true
52
+
53
+ [tool.pytest.ini_options]
54
+ addopts = "-ra --strict-markers --cov=fmind --cov-branch --cov-fail-under=85"
55
+ testpaths = ["tests"]
56
+
57
+ [tool.coverage.report]
58
+ exclude_also = ["if __name__ == .__main__.:", "raise NotImplementedError"]
@@ -0,0 +1,9 @@
1
+ """Read Médéric Hurier's (Fmind) portfolio from the terminal.
2
+
3
+ Every command renders https://www.fmind.dev/api/profile, so this package carries
4
+ no copy of its own and needs no release when the website changes.
5
+ """
6
+
7
+ __all__ = ["__version__"]
8
+
9
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Allow `python -m fmind`."""
2
+
3
+ from fmind.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -0,0 +1,157 @@
1
+ """Fetch and cache the documents published by www.fmind.dev."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import time
9
+ import urllib.error
10
+ import urllib.request
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+ from typing import Any, TypeVar
14
+
15
+ from fmind import __version__
16
+
17
+ PROFILE_URL = os.environ.get("FMIND_PROFILE_URL", "https://www.fmind.dev/api/profile")
18
+ # The endpoints advertise `public, max-age=3600`; the on-disk copies honour the same window.
19
+ CACHE_TTL_SECONDS = 3600
20
+ TIMEOUT_SECONDS = 15.0
21
+ # Generous next to a ~75 KB profile and ~20 KB articles, but bounded: a hostile or
22
+ # misrouted origin must not be able to fill the cache directory.
23
+ MAX_BYTES = 8 * 1024 * 1024
24
+ USER_AGENT = f"fmind/{__version__} (+https://github.com/fmind/cli)"
25
+ # Slugs reach the URL and the cache path, so only the shape the site actually mints is accepted.
26
+ SLUG_PATTERN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*\Z")
27
+
28
+ T = TypeVar("T")
29
+
30
+
31
+ class FmindError(RuntimeError):
32
+ """A document could not be obtained, or was not the document that was asked for."""
33
+
34
+
35
+ def cache_path(*parts: str) -> Path:
36
+ """Return an on-disk cache location, honouring XDG_CACHE_HOME."""
37
+ root = os.environ.get("XDG_CACHE_HOME")
38
+ base = Path(root) if root else Path.home() / ".cache"
39
+ return base.joinpath("fmind", *parts)
40
+
41
+
42
+ def article_url(slug: str, *, profile_url: str = PROFILE_URL) -> str:
43
+ """Return the Markdown source URL of an article, on the same origin as the profile."""
44
+ if not SLUG_PATTERN.match(slug):
45
+ msg = f"not an article slug: {slug!r}"
46
+ raise FmindError(msg)
47
+ origin = profile_url.removesuffix("/api/profile").rstrip("/")
48
+ return f"{origin}/articles/{slug}.md"
49
+
50
+
51
+ def _read_cache(path: Path, ttl: int) -> bytes | None:
52
+ """Return the cached bytes while they are younger than `ttl`; `ttl < 0` accepts any age."""
53
+ try:
54
+ age = time.time() - path.stat().st_mtime
55
+ except OSError:
56
+ return None
57
+ if ttl >= 0 and age > ttl:
58
+ return None
59
+ try:
60
+ return path.read_bytes()
61
+ except OSError:
62
+ return None
63
+
64
+
65
+ def _write_cache(path: Path, payload: bytes) -> None:
66
+ """Persist a document, ignoring an unwritable cache directory."""
67
+ try:
68
+ path.parent.mkdir(parents=True, exist_ok=True)
69
+ tmp = path.with_suffix(path.suffix + ".tmp")
70
+ tmp.write_bytes(payload)
71
+ tmp.replace(path)
72
+ except OSError:
73
+ pass # a read-only cache must not break a working command
74
+
75
+
76
+ def _download(url: str, accept: str = "application/json") -> bytes:
77
+ """Fetch a document over HTTP(S) with an explicit timeout and size bound."""
78
+ if not url.startswith(("https://", "http://")):
79
+ msg = f"URL must be http(s): {url}"
80
+ raise FmindError(msg)
81
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": accept}) # noqa: S310
82
+ try:
83
+ with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response: # noqa: S310
84
+ payload = response.read(MAX_BYTES + 1)
85
+ except urllib.error.HTTPError as error:
86
+ msg = f"{url} returned HTTP {error.code}"
87
+ raise FmindError(msg) from error
88
+ except (urllib.error.URLError, TimeoutError, OSError) as error:
89
+ msg = f"could not reach {url}: {error}"
90
+ raise FmindError(msg) from error
91
+ if len(payload) > MAX_BYTES:
92
+ msg = f"{url} returned more than {MAX_BYTES} bytes"
93
+ raise FmindError(msg)
94
+ return payload
95
+
96
+
97
+ def _load(path: Path, url: str, accept: str, parse: Callable[[bytes], T], *, refresh: bool) -> T:
98
+ """Return a parsed document from the cache or the network.
99
+
100
+ A fresh cache wins unless `refresh`. When the network fails — or answers with
101
+ something that is not the expected document — a stale cache is still served, so
102
+ the CLI keeps working offline once it has run at least once. Unparseable cached
103
+ bytes are discarded rather than surfaced.
104
+ """
105
+ if not refresh:
106
+ cached = _read_cache(path, CACHE_TTL_SECONDS)
107
+ if cached is not None:
108
+ try:
109
+ return parse(cached)
110
+ except FmindError:
111
+ pass # a corrupt cache must not break a working command
112
+ try:
113
+ payload = _download(url, accept)
114
+ value = parse(payload)
115
+ except FmindError:
116
+ stale = _read_cache(path, -1)
117
+ if stale is not None:
118
+ try:
119
+ return parse(stale)
120
+ except FmindError:
121
+ pass
122
+ raise
123
+ _write_cache(path, payload)
124
+ return value
125
+
126
+
127
+ def _parse_profile(payload: bytes) -> dict[str, Any]:
128
+ """Decode the portfolio document, rejecting anything that is not one."""
129
+ try:
130
+ document = json.loads(payload)
131
+ except (json.JSONDecodeError, UnicodeDecodeError) as error:
132
+ raise FmindError("the profile endpoint did not return JSON") from error
133
+ if not isinstance(document, dict) or "metadata" not in document:
134
+ raise FmindError("the profile endpoint did not return a portfolio document")
135
+ return document
136
+
137
+
138
+ def _parse_markdown(payload: bytes) -> str:
139
+ """Decode an article, rejecting an HTML error page served with a 200."""
140
+ try:
141
+ text = payload.decode("utf-8")
142
+ except UnicodeDecodeError as error:
143
+ raise FmindError("the article was not UTF-8 text") from error
144
+ if not text.strip() or text.lstrip().startswith("<"):
145
+ raise FmindError("the article endpoint did not return Markdown")
146
+ return text
147
+
148
+
149
+ def load_profile(*, refresh: bool = False, url: str = PROFILE_URL) -> dict[str, Any]:
150
+ """Return the portfolio document behind every section command."""
151
+ return _load(cache_path("profile.json"), url, "application/json", _parse_profile, refresh=refresh)
152
+
153
+
154
+ def load_article(slug: str, *, refresh: bool = False, profile_url: str = PROFILE_URL) -> str:
155
+ """Return the Markdown source of one published article."""
156
+ url = article_url(slug, profile_url=profile_url)
157
+ return _load(cache_path("articles", f"{slug}.md"), url, "text/markdown", _parse_markdown, refresh=refresh)
@@ -0,0 +1,48 @@
1
+ """Select articles from the profile document: newest, matching, or named."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ Article = dict[str, Any]
8
+
9
+
10
+ def latest(articles: list[Article], *, limit: int) -> list[Article]:
11
+ """Return the most recently published articles, newest first."""
12
+ return sorted(articles, key=lambda a: a["date"], reverse=True)[: max(limit, 0)]
13
+
14
+
15
+ def _haystack(article: Article) -> str:
16
+ """Return the searchable text of one article, lower-cased."""
17
+ fields = [article["title"], article["description"], article["slug"], *article["tags"]]
18
+ return " ".join(fields).lower()
19
+
20
+
21
+ def search(articles: list[Article], query: str, *, tag: str | None = None, limit: int = 10) -> list[Article]:
22
+ """Return newest-first articles whose text contains every term of `query`.
23
+
24
+ Terms are ANDed so that adding a word narrows the result, and `tag` further
25
+ restricts to one of the site's own tags. Both comparisons are case-insensitive.
26
+ """
27
+ terms = query.lower().split()
28
+ wanted = tag.lower() if tag else None
29
+ found = [
30
+ article
31
+ for article in articles
32
+ if all(term in _haystack(article) for term in terms)
33
+ and (wanted is None or wanted in {t.lower() for t in article["tags"]})
34
+ ]
35
+ return latest(found, limit=limit)
36
+
37
+
38
+ def candidates(articles: list[Article], wanted: str) -> list[str]:
39
+ """Return the slugs `wanted` could mean: itself when exact, else every slug containing it."""
40
+ slugs = [article["slug"] for article in articles]
41
+ if wanted in slugs:
42
+ return [wanted]
43
+ return [slug for slug in slugs if wanted in slug]
44
+
45
+
46
+ def by_slug(articles: list[Article], slug: str) -> Article | None:
47
+ """Return the article index entry for `slug`, if the profile lists it."""
48
+ return next((article for article in articles if article["slug"] == slug), None)
@@ -0,0 +1,192 @@
1
+ """Command surface, mirroring the sections of www.fmind.dev."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Annotated, Any
8
+
9
+ import typer
10
+ from rich.console import RenderableType
11
+
12
+ from fmind import __version__, articles, render
13
+ from fmind.api import FmindError, load_article, load_profile
14
+
15
+ app = typer.Typer(
16
+ name="fmind",
17
+ help="Read Médéric Hurier's (Fmind) portfolio from the terminal. Every command renders the live "
18
+ "profile published at https://www.fmind.dev/api/profile.",
19
+ add_completion=False,
20
+ no_args_is_help=True,
21
+ rich_markup_mode=None,
22
+ )
23
+
24
+ _STATE: dict[str, Any] = {"refresh": False, "json": False, "color": True}
25
+
26
+
27
+ def _version(value: bool) -> None:
28
+ if value:
29
+ typer.echo(f"fmind {__version__}")
30
+ raise typer.Exit
31
+
32
+
33
+ @app.callback()
34
+ def main(
35
+ refresh: Annotated[bool, typer.Option("--refresh", help="Ignore the cached copy and fetch it again.")] = False,
36
+ as_json: Annotated[bool, typer.Option("--json", help="Print the raw section as JSON instead of prose.")] = False,
37
+ color: Annotated[bool, typer.Option("--color/--no-color", help="Force or suppress ANSI colour.")] = True,
38
+ _v: Annotated[bool, typer.Option("--version", callback=_version, is_eager=True, help="Show the version.")] = False,
39
+ ) -> None:
40
+ """Store the global options for the section commands."""
41
+ _STATE.update(refresh=refresh, json=as_json, color=color)
42
+
43
+
44
+ def _fail(message: str) -> typer.Exit:
45
+ """Report a clear one-line error on stderr and end the command."""
46
+ typer.secho(f"fmind: {message}", fg="red", err=True)
47
+ return typer.Exit(code=1)
48
+
49
+
50
+ def _doc() -> dict[str, Any]:
51
+ """Load the profile, reporting a clear error instead of a traceback."""
52
+ try:
53
+ return load_profile(refresh=bool(_STATE["refresh"]))
54
+ except FmindError as error:
55
+ raise _fail(str(error)) from error
56
+
57
+
58
+ def _emit(body: RenderableType, payload: Any, *, banner: dict[str, Any] | None = None) -> None:
59
+ """Print either the rendered section or its raw JSON."""
60
+ if _STATE["json"]:
61
+ json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
62
+ sys.stdout.write("\n")
63
+ return
64
+ out = render.console(color=bool(_STATE["color"]))
65
+ if banner is not None:
66
+ out.print(render.banner(banner))
67
+ out.print()
68
+ out.print(body)
69
+
70
+
71
+ @app.command()
72
+ def whoami() -> None:
73
+ """Name, current mission, contact and availability."""
74
+ doc = _doc()
75
+ _emit(render.whoami(doc), {"metadata": doc["metadata"], "services": doc["services"]}, banner=doc)
76
+
77
+
78
+ @app.command()
79
+ def about() -> None:
80
+ """The biography, as published on the site."""
81
+ doc = _doc()
82
+ _emit(render.about(doc), doc["biography"])
83
+
84
+
85
+ @app.command()
86
+ def skills() -> None:
87
+ """Core expertise, laid out like a usage screen."""
88
+ doc = _doc()
89
+ _emit(render.skills(doc), doc["expertise"])
90
+
91
+
92
+ @app.command()
93
+ def work() -> None:
94
+ """Engagements, current one first."""
95
+ doc = _doc()
96
+ _emit(render.work(doc), doc["experience"])
97
+
98
+
99
+ @app.command()
100
+ def community() -> None:
101
+ """Ambassador and advisory roles."""
102
+ doc = _doc()
103
+ _emit(render.community(doc), doc["leadership"])
104
+
105
+
106
+ @app.command()
107
+ def cert(
108
+ verify: Annotated[bool, typer.Option("--verify", help="List only credentials that are still active.")] = False,
109
+ ) -> None:
110
+ """Certifications, the PhD, and specializations."""
111
+ doc = _doc()
112
+ payload = [c for c in doc["certifications"] if c["active"]] if verify else doc["certifications"]
113
+ _emit(render.cert(doc, verify=verify), payload)
114
+
115
+
116
+ @app.command()
117
+ def papers() -> None:
118
+ """The doctorate and the peer-reviewed publications."""
119
+ doc = _doc()
120
+ _emit(render.papers(doc), {"thesis": doc["thesis"], "papers": doc["papers"]})
121
+
122
+
123
+ @app.command()
124
+ def project(
125
+ top: Annotated[int, typer.Option("--top", min=1, max=50, help="How many projects to show.")] = 6,
126
+ ) -> None:
127
+ """Open-source repositories and video series."""
128
+ doc = _doc()
129
+ _emit(render.project(doc, top=top), (doc["open_source"] + doc["youtube_series"])[:top])
130
+
131
+
132
+ @app.command()
133
+ def sites() -> None:
134
+ """Interactive tools published alongside the writing."""
135
+ doc = _doc()
136
+ _emit(render.sites(doc), doc["site_pages"])
137
+
138
+
139
+ @app.command()
140
+ def article(
141
+ limit: Annotated[int, typer.Option("--limit", min=1, max=200, help="How many articles to show.")] = 6,
142
+ ) -> None:
143
+ """The most recent writing."""
144
+ doc = _doc()
145
+ posts = articles.latest(doc["articles"], limit=limit)
146
+ footer = f"{len(doc['articles'])} published · {doc['metadata']['site_url']}/articles/"
147
+ _emit(render.articles(posts, footer=footer), posts)
148
+
149
+
150
+ @app.command()
151
+ def search(
152
+ query: Annotated[str, typer.Argument(help="Terms to match against titles, summaries, tags and slugs.")],
153
+ tag: Annotated[str | None, typer.Option("--tag", help="Restrict to one of the site's tags, such as Agent.")] = None,
154
+ limit: Annotated[int, typer.Option("--limit", min=1, max=200, help="How many matches to show.")] = 10,
155
+ ) -> None:
156
+ """Find articles by term, newest match first."""
157
+ doc = _doc()
158
+ found = articles.search(doc["articles"], query, tag=tag, limit=limit)
159
+ footer = f"{len(found)} shown · {len(doc['articles'])} published · fmind read <slug> opens one"
160
+ _emit(render.articles(found, footer=footer), found)
161
+
162
+
163
+ @app.command()
164
+ def read(
165
+ slug: Annotated[str, typer.Argument(help="Article slug, or enough of it to be unambiguous.")],
166
+ raw: Annotated[bool, typer.Option("--raw", help="Print the Markdown source instead of rendering it.")] = False,
167
+ ) -> None:
168
+ """Read one article in the terminal, from its published Markdown."""
169
+ doc = _doc()
170
+ matches = articles.candidates(doc["articles"], slug.strip().lower())
171
+ if not matches:
172
+ raise _fail(f"no article matches {slug!r} — try: fmind search {slug}")
173
+ if len(matches) > 1:
174
+ listed = "\n ".join(matches[:10])
175
+ raise _fail(f"{slug!r} matches {len(matches)} articles:\n {listed}")
176
+ resolved = matches[0]
177
+ try:
178
+ markdown = load_article(resolved, refresh=bool(_STATE["refresh"]))
179
+ except FmindError as error:
180
+ raise _fail(str(error)) from error
181
+ post = articles.by_slug(doc["articles"], resolved)
182
+ if raw and not _STATE["json"]:
183
+ sys.stdout.write(markdown)
184
+ return
185
+ _emit(render.article_body(markdown), {"slug": resolved, "markdown": markdown, **(post or {})})
186
+
187
+
188
+ @app.command()
189
+ def hire() -> None:
190
+ """What can be booked right now."""
191
+ doc = _doc()
192
+ _emit(render.hire(doc), doc["services"])
@@ -0,0 +1,272 @@
1
+ """Render sections of the portfolio document as terminal output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from rich.console import Console, Group, RenderableType
8
+ from rich.markdown import Markdown
9
+ from rich.padding import Padding
10
+ from rich.table import Table
11
+ from rich.text import Text
12
+
13
+ ACCENT = "bold #00ff41"
14
+ HI = "bold #ccffd9"
15
+ DIM = "#57a86e"
16
+ FLAG = "#7dffab"
17
+ ERR = "#ff7a5c"
18
+
19
+ WORDMARK = (
20
+ "█▀▀▀ █▄ ▄█ ▀█▀ █▄ █ █▀▀▄ ",
21
+ "█▄▄ █ ▀ █ █ █ █ █ █ █",
22
+ "█ █ █ █ █ ██ █ █",
23
+ "█ █ █ ▄█▄ █ █ █▄▄▀ ",
24
+ )
25
+
26
+ SKILL_FLAGS = {
27
+ "Agentic Orchestration": "--agents",
28
+ "Production MLOps": "--mlops",
29
+ "Security-First AI": "--security",
30
+ "Technical Strategy": "--strategy",
31
+ "Data Science & ML": "--data",
32
+ "Python Development": "--python",
33
+ }
34
+
35
+
36
+ def console(*, color: bool = True) -> Console:
37
+ """Build the output console; `color=False` yields plain text for pipes."""
38
+ return Console(no_color=not color, highlight=False, soft_wrap=False)
39
+
40
+
41
+ def _rows(pairs: list[tuple[str, RenderableType]], key_width: int = 11) -> Table:
42
+ """Lay out aligned key/value rows without visible borders."""
43
+ table = Table.grid(padding=(0, 2))
44
+ table.add_column(style=ACCENT, width=key_width, no_wrap=True)
45
+ table.add_column(overflow="fold")
46
+ for key, value in pairs:
47
+ table.add_row(key, value)
48
+ return table
49
+
50
+
51
+ def banner(doc: dict[str, Any]) -> RenderableType:
52
+ """The wordmark beside the name, role and headline."""
53
+ meta = doc["metadata"]
54
+ mark = Text("\n".join(WORDMARK), style=ACCENT)
55
+ identity = Group(
56
+ Text(meta["name"], style=HI),
57
+ Text(meta["job_title"], style="#00ff41"),
58
+ Text(meta["headline_primary"], style=DIM),
59
+ )
60
+ side = Table.grid(padding=(0, 3))
61
+ side.add_column(no_wrap=True)
62
+ side.add_column(overflow="fold")
63
+ side.add_row(mark, identity)
64
+ return side
65
+
66
+
67
+ def whoami(doc: dict[str, Any]) -> RenderableType:
68
+ """Identity, current mission, contact and availability."""
69
+ meta = doc["metadata"]
70
+ experience = doc.get("experience") or []
71
+ mission = Text("—", style=DIM)
72
+ if experience:
73
+ current = experience[0]
74
+ mission = Text(f"{current['company']} — {current['title']}", style="#74e492")
75
+ services = doc.get("services") or []
76
+ status = Text()
77
+ for index, service in enumerate(services):
78
+ open_now = service.get("badge_type") != "error"
79
+ if index:
80
+ status.append("\n")
81
+ status.append("● ", style="#00ff41" if open_now else ERR)
82
+ status.append(service["title"], style=HI)
83
+ status.append(f" — {service['badge']}", style=DIM)
84
+ return _rows(
85
+ [
86
+ ("Name", Text(f"{meta['name']} ({meta['alternate_name']})", style=HI)),
87
+ ("Role", Text(meta["job_title"], style="#74e492")),
88
+ ("Mission", mission),
89
+ ("Degree", Text(doc["thesis"]["institution_details"], style="#74e492")),
90
+ ("Contact", Text(meta["email"], style=FLAG)),
91
+ ("Website", Text(meta["site_url"], style=FLAG)),
92
+ ("Status", status),
93
+ ]
94
+ )
95
+
96
+
97
+ def about(doc: dict[str, Any]) -> RenderableType:
98
+ """The biography paragraphs."""
99
+ return Group(*(Padding(Text(p, style="#74e492"), (0, 0, 1, 0)) for p in doc["biography"]))
100
+
101
+
102
+ def skills(doc: dict[str, Any]) -> RenderableType:
103
+ """Expertise laid out like a usage screen, matching www.fmind.dev."""
104
+ flags = [SKILL_FLAGS.get(card["title"], "--" + card["title"].split()[0].lower()) for card in doc["expertise"]]
105
+ synopsis = Text("fmind", style=ACCENT)
106
+ synopsis.append(" skills ", style=HI)
107
+ synopsis.append(" ".join(f"[{flag}]" for flag in flags), style=FLAG)
108
+ table = Table.grid(padding=(0, 2))
109
+ table.add_column(style=FLAG, width=12, no_wrap=True)
110
+ table.add_column(overflow="fold")
111
+ for card, flag in zip(doc["expertise"], flags, strict=True):
112
+ body = Text(card["title"], style=HI)
113
+ body.append(f" — {card['description']}", style=DIM)
114
+ table.add_row(flag, body)
115
+ return Group(
116
+ Text("USAGE", style=DIM),
117
+ Padding(synopsis, (0, 0, 1, 2)),
118
+ Text("OPTIONS", style=DIM),
119
+ Padding(table, (0, 0, 0, 2)),
120
+ )
121
+
122
+
123
+ def work(doc: dict[str, Any]) -> RenderableType:
124
+ """Engagements, current one first."""
125
+ blocks: list[RenderableType] = []
126
+ for index, job in enumerate(doc["experience"]):
127
+ head = Text(job["company"].upper(), style=ACCENT)
128
+ if index == 0:
129
+ head.append(" · current", style=DIM)
130
+ body = Group(
131
+ head,
132
+ Text(job["title"], style=HI),
133
+ Text(job["description"], style=DIM),
134
+ Text(" ".join(job["tags"]), style=FLAG),
135
+ )
136
+ blocks.append(Padding(body, (0, 0, 1, 0)))
137
+ return Group(*blocks)
138
+
139
+
140
+ def community(doc: dict[str, Any]) -> RenderableType:
141
+ """Ambassador and advisory roles."""
142
+ return _rows(
143
+ [
144
+ (r["organization"][:11], Group(Text(r["role"], style=HI), Text(r["description"], style=DIM)))
145
+ for r in doc["leadership"]
146
+ ],
147
+ key_width=13,
148
+ )
149
+
150
+
151
+ def cert(doc: dict[str, Any], *, verify: bool = False) -> RenderableType:
152
+ """Credentials, plus specializations unless `verify` narrows to active ones."""
153
+ table = Table.grid(padding=(0, 2))
154
+ table.add_column(width=8, no_wrap=True)
155
+ table.add_column(overflow="fold")
156
+ for badge in doc["certifications"]:
157
+ active = bool(badge["active"])
158
+ if verify and not active:
159
+ continue
160
+ state = Text("active" if active else "expired", style="#00ff41" if active else DIM)
161
+ body = Text(badge["title"], style=HI if active else "#74e492")
162
+ body.append(f" — {badge['issuer']}", style=DIM)
163
+ table.add_row(state, body)
164
+ thesis = Text(doc["thesis"]["title"], style=HI)
165
+ thesis.append(f" — {doc['thesis']['institution_details']}", style=DIM)
166
+ parts: list[RenderableType] = [table, Padding(Group(Text("PhD", style=ACCENT), thesis), (1, 0, 0, 0))]
167
+ if not verify:
168
+ specs = Text("\n".join(f" {s['title']} — {s['issuer_details']}" for s in doc["specializations"]), style=DIM)
169
+ parts.append(Padding(Group(Text("SPECIALIZATIONS", style=DIM), specs), (1, 0, 0, 0)))
170
+ return Group(*parts)
171
+
172
+
173
+ def project(doc: dict[str, Any], *, top: int = 6) -> RenderableType:
174
+ """Open-source repositories and video series."""
175
+ items = [(p["title"], p["description"], p["href"]) for p in doc["open_source"]]
176
+ items += [(v["title"], v["description"], v["url"]) for v in doc["youtube_series"]]
177
+ blocks = [
178
+ Padding(Group(Text(title, style=HI), Text(description, style=DIM), Text(url, style=FLAG)), (0, 0, 1, 0))
179
+ for title, description, url in items[: max(top, 0)]
180
+ ]
181
+ return Group(*blocks)
182
+
183
+
184
+ def articles(posts: list[dict[str, Any]], *, footer: str = "") -> RenderableType:
185
+ """A list of articles, one block each, with `fmind read` slugs kept visible."""
186
+ blocks: list[RenderableType] = []
187
+ for post in posts:
188
+ head = Text(post["date"][:10], style=ACCENT)
189
+ head.append(f" {post['reading_minutes']} min", style=DIM)
190
+ head.append(f" {' '.join(post['tags'])}", style=FLAG)
191
+ body = Text("fmind read ", style=DIM)
192
+ body.append(post["slug"], style=FLAG)
193
+ blocks.append(Padding(Group(head, Text(post["title"], style=HI), body), (0, 0, 1, 0)))
194
+ if not blocks:
195
+ return Text("no article matches", style=DIM)
196
+ return Group(*blocks, Text(footer, style=DIM)) if footer else Group(*blocks)
197
+
198
+
199
+ def article_body(markdown: str) -> RenderableType:
200
+ """One article, rendered from its published Markdown source.
201
+
202
+ The source already opens with the title, summary, date, reading time, tags and
203
+ canonical URL, so nothing is prepended here.
204
+ """
205
+ return Markdown(markdown, code_theme="ansi_dark", hyperlinks=False)
206
+
207
+
208
+ def papers(doc: dict[str, Any]) -> RenderableType:
209
+ """The doctorate and the peer-reviewed record behind it."""
210
+ thesis = doc["thesis"]
211
+ head = Group(
212
+ Text("PhD", style=ACCENT),
213
+ Text(thesis["title"], style=HI),
214
+ Text(thesis["institution_details"], style="#74e492"),
215
+ Text(thesis["description"], style=DIM),
216
+ Text(thesis["url"], style=FLAG),
217
+ )
218
+ parts: list[RenderableType] = [Padding(head, (0, 0, 1, 0))]
219
+ for link in thesis.get("links") or []:
220
+ row = Text(" ", style=DIM)
221
+ row.append(link["label"], style="#74e492")
222
+ row.append(f" {link['url']}", style=FLAG)
223
+ parts.append(row)
224
+ if thesis.get("links"):
225
+ parts.append(Text())
226
+ parts.append(Text("PUBLICATIONS", style=ACCENT))
227
+ for paper in doc["papers"]:
228
+ body = Group(
229
+ Text(paper["title"], style=HI),
230
+ Text(paper["venue"], style=DIM),
231
+ Text(paper["url"], style=FLAG),
232
+ )
233
+ parts.append(Padding(body, (1, 0, 0, 0)))
234
+ return Group(*parts)
235
+
236
+
237
+ def sites(doc: dict[str, Any]) -> RenderableType:
238
+ """Interactive tools published alongside the writing."""
239
+ blocks = [
240
+ Padding(
241
+ Group(
242
+ Text(page["title"], style=HI),
243
+ Text(page["description"], style=DIM),
244
+ Text(f"for {page['audience']}", style="#74e492"),
245
+ Text(page["url"], style=FLAG),
246
+ ),
247
+ (0, 0, 1, 0),
248
+ )
249
+ for page in doc["site_pages"]
250
+ ]
251
+ return Group(*blocks) if blocks else Text("no interactive site published", style=DIM)
252
+
253
+
254
+ def hire(doc: dict[str, Any]) -> RenderableType:
255
+ """What can be booked right now."""
256
+ blocks: list[RenderableType] = []
257
+ for service in doc["services"]:
258
+ open_now = service.get("badge_type") != "error"
259
+ state = Text("● ", style="#00ff41" if open_now else ERR)
260
+ state.append(service["badge"], style="#00ff41" if open_now else ERR)
261
+ blocks.append(
262
+ Padding(
263
+ Group(
264
+ Text(service["title"], style=HI),
265
+ Text(service["description"], style=DIM),
266
+ state,
267
+ Text(service["cta_url"], style=FLAG),
268
+ ),
269
+ (0, 0, 1, 0),
270
+ )
271
+ )
272
+ return Group(*blocks)