annotheca 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.
annotheca/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Annotheca: a local reference manager for research projects."""
2
+
3
+ __version__ = '0.1.0'
annotheca/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allows `python -m annotheca`."""
2
+
3
+ from annotheca import cli
4
+
5
+ cli.main()
annotheca/api.py ADDED
@@ -0,0 +1,194 @@
1
+ """HTTP API and static web interface."""
2
+
3
+ import mimetypes
4
+ import pathlib
5
+ from typing import Any
6
+ from urllib import parse
7
+
8
+ import fastapi
9
+ import httpx
10
+ import pydantic
11
+ from fastapi import responses
12
+ from fastapi import staticfiles
13
+
14
+ from annotheca import bundle
15
+ from annotheca import library as library_lib
16
+ from annotheca import models
17
+ from annotheca import sources
18
+
19
+ _STATIC_DIR = pathlib.Path(__file__).parent / 'static'
20
+
21
+ # Exception types mapped to an HTTP status and the error code the
22
+ # interface translates for the user.
23
+ _ERRORS = {
24
+ library_lib.NotFoundError: (404, 'not_found'),
25
+ library_lib.ConflictError: (409, 'name_taken'),
26
+ library_lib.InvalidPdfError: (422, 'not_pdf'),
27
+ bundle.InvalidBundleError: (422, 'not_bundle'),
28
+ }
29
+
30
+
31
+ class NameBody(pydantic.BaseModel):
32
+ name: models.Name
33
+
34
+
35
+ class AddPapersBody(pydantic.BaseModel):
36
+ """Either pasted references or an already known paper to file."""
37
+ text: str | None = None
38
+ paper_id: int | None = None
39
+
40
+
41
+ class NotesBody(pydantic.BaseModel):
42
+ notes: str
43
+
44
+
45
+ class AnnotationChanges(pydantic.BaseModel):
46
+ color: models.Color | None = None
47
+ geometry: list[Any] | None = None
48
+ body: str | None = None
49
+
50
+
51
+ def create_app(library: library_lib.Library,
52
+ client: httpx.Client | None = None) -> fastapi.FastAPI:
53
+ """Builds the web application.
54
+
55
+ Args:
56
+ library: Where everything is stored.
57
+ client: HTTP client for the scholarly sources; a default one is
58
+ created when omitted.
59
+ """
60
+ client = client or sources.make_client()
61
+ app = fastapi.FastAPI(title='Annotheca', docs_url=None, redoc_url=None)
62
+ api = fastapi.APIRouter(prefix='/api')
63
+
64
+ for error_type, (status, code) in _ERRORS.items():
65
+ app.add_exception_handler(error_type, _error_handler(status, code))
66
+
67
+ @app.exception_handler(sources.ResolveError)
68
+ def _resolve_error(request, error):
69
+ del request # Unused.
70
+ return responses.JSONResponse({'detail': str(error)}, status_code=422)
71
+
72
+ @api.get('/projects')
73
+ def list_projects():
74
+ return library.list_projects()
75
+
76
+ @api.post('/projects', status_code=201)
77
+ def create_project(body: NameBody):
78
+ return library.create_project(body.name)
79
+
80
+ @api.get('/projects/{project_id}')
81
+ def get_project(project_id: int):
82
+ return library.get_project(project_id)
83
+
84
+ @api.get('/projects/{project_id}/bibtex')
85
+ def export_bibtex(project_id: int):
86
+ name, content = library.export_bibtex(project_id)
87
+ return _download(content, f'{name}.bib', 'application/x-bibtex')
88
+
89
+ @api.get('/projects/{project_id}/bundle')
90
+ def export_bundle(project_id: int):
91
+ """The project with its notes, annotations and PDFs, to share."""
92
+ name, content = bundle.export_project(library, project_id)
93
+ return _download(content, f'{name}.annotheca', 'application/zip')
94
+
95
+ @api.post('/projects/import', status_code=201)
96
+ async def import_bundle(request: fastapi.Request):
97
+ return bundle.import_project(library, await request.body())
98
+
99
+ @api.post('/projects/{project_id}/folders', status_code=201)
100
+ def create_folder(project_id: int, body: NameBody):
101
+ return library.create_folder(project_id, body.name)
102
+
103
+ @api.patch('/projects/{project_id}', status_code=204)
104
+ def rename_project(project_id: int, body: NameBody):
105
+ library.rename('project', project_id, body.name)
106
+
107
+ @api.delete('/projects/{project_id}', status_code=204)
108
+ def delete_project(project_id: int):
109
+ library.delete('project', project_id)
110
+
111
+ @api.patch('/folders/{folder_id}', status_code=204)
112
+ def rename_folder(folder_id: int, body: NameBody):
113
+ library.rename('folder', folder_id, body.name)
114
+
115
+ @api.delete('/folders/{folder_id}', status_code=204)
116
+ def delete_folder(folder_id: int):
117
+ library.delete('folder', folder_id)
118
+
119
+ @api.post('/folders/{folder_id}/papers')
120
+ def add_papers(folder_id: int, body: AddPapersBody):
121
+ """Files pasted references, or a known paper, in a folder."""
122
+ if body.paper_id is not None:
123
+ paper_ids = [body.paper_id]
124
+ elif body.text and body.text.strip():
125
+ entries = sources.resolve(body.text, client)
126
+ paper_ids = list(dict.fromkeys(map(library.add_paper, entries)))
127
+ else:
128
+ raise sources.ResolveError('unresolvable')
129
+ added = library.file_papers(folder_id, paper_ids)
130
+ return {'paper_ids': paper_ids, 'added': added,
131
+ 'already_there': len(paper_ids) - added}
132
+
133
+ @api.put('/placements/{placement_id}/notes', status_code=204)
134
+ def set_notes(placement_id: int, body: NotesBody):
135
+ library.set_notes(placement_id, body.notes)
136
+
137
+ @api.delete('/placements/{placement_id}', status_code=204)
138
+ def remove_placement(placement_id: int):
139
+ library.remove_placement(placement_id)
140
+
141
+ @api.get('/papers/{paper_id}/pdf')
142
+ def get_pdf(paper_id: int):
143
+ path = library.pdf_path(paper_id)
144
+ if not path.exists():
145
+ raise library_lib.NotFoundError
146
+ return responses.FileResponse(path, media_type='application/pdf')
147
+
148
+ @api.put('/papers/{paper_id}/pdf', status_code=204)
149
+ async def upload_pdf(paper_id: int, request: fastapi.Request):
150
+ library.save_pdf(paper_id, await request.body())
151
+
152
+ @api.post('/papers/{paper_id}/pdf/fetch')
153
+ def fetch_pdf(paper_id: int):
154
+ """Looks for an open-access PDF; reports whether one was found."""
155
+ pdf = sources.find_pdf(library.get_paper(paper_id), client)
156
+ if pdf:
157
+ library.save_pdf(paper_id, pdf)
158
+ return {'found': pdf is not None}
159
+
160
+ @api.get('/placements/{placement_id}/annotations')
161
+ def list_annotations(placement_id: int):
162
+ return library.list_annotations(placement_id)
163
+
164
+ @api.post('/placements/{placement_id}/annotations', status_code=201)
165
+ def create_annotation(placement_id: int, body: models.Annotation):
166
+ return library.create_annotation(placement_id, body.model_dump())
167
+
168
+ @api.patch('/annotations/{annotation_id}')
169
+ def update_annotation(annotation_id: int, body: AnnotationChanges):
170
+ return library.update_annotation(
171
+ annotation_id, body.model_dump(exclude_none=True))
172
+
173
+ @api.delete('/annotations/{annotation_id}', status_code=204)
174
+ def delete_annotation(annotation_id: int):
175
+ library.delete_annotation(annotation_id)
176
+
177
+ app.include_router(api)
178
+ mimetypes.add_type('text/javascript', '.mjs')
179
+ app.mount('/', staticfiles.StaticFiles(directory=_STATIC_DIR, html=True))
180
+ return app
181
+
182
+
183
+ def _download(content: str | bytes, filename: str,
184
+ media_type: str) -> responses.Response:
185
+ disposition = f"attachment; filename*=UTF-8''{parse.quote(filename)}"
186
+ return responses.Response(content, media_type=media_type,
187
+ headers={'Content-Disposition': disposition})
188
+
189
+
190
+ def _error_handler(status: int, code: str):
191
+ def handle(request: fastapi.Request, error: Exception):
192
+ del request, error # Unused.
193
+ return responses.JSONResponse({'detail': code}, status_code=status)
194
+ return handle
annotheca/bibtex.py ADDED
@@ -0,0 +1,193 @@
1
+ """BibTeX entries: parsing, formatting, citation keys and identifiers."""
2
+
3
+ import dataclasses
4
+ import re
5
+ import unicodedata
6
+ from typing import Any
7
+
8
+ _DOI_RE = re.compile(r'\b(10\.\d{4,9}/[^\s"<>{}]+)', re.IGNORECASE)
9
+ _ARXIV_NEW_RE = re.compile(r'\b(\d{4}\.\d{4,5})(?:v\d+)?\b')
10
+ _ARXIV_OLD_RE = re.compile(
11
+ r'arxiv(?:\.org/(?:abs|pdf)/|:)\s*([a-z-]+(?:\.[a-z]{2})?/\d{7})',
12
+ re.IGNORECASE)
13
+ _ARXIV_DOI_PREFIX = '10.48550/arxiv.'
14
+ _ENTRY_RE = re.compile(r'@\s*([a-zA-Z]+)\s*\{')
15
+ _FIELD_NAME_RE = re.compile(r'\s*([^\s=,{}]+)\s*=\s*')
16
+ _BARE_VALUE_RE = re.compile(r'[^\s,#}]*')
17
+ _CONCATENATION_RE = re.compile(r'\s*#\s*')
18
+ _SEPARATOR_RE = re.compile(r'\s*,')
19
+ _IGNORED_TYPES = frozenset(('comment', 'preamble', 'string'))
20
+ _LATEX_ACCENT_RE = re.compile(
21
+ r'\\([`\'^"~]|c(?=[\s{]))\s*\{?([a-zA-Z])\}?')
22
+ _COMBINING_ACCENTS = {
23
+ '`': '\u0300', "'": '\u0301', '^': '\u0302', '~': '\u0303',
24
+ '"': '\u0308', 'c': '\u0327'}
25
+
26
+ # Fields printed first, in this order; the others follow alphabetically.
27
+ _FIELD_ORDER = ('author', 'title', 'journal', 'booktitle', 'year')
28
+ # Fields naming where a paper was published, by preference.
29
+ _VENUE_FIELDS = (
30
+ 'journal', 'booktitle', 'publisher', 'howpublished', 'archiveprefix')
31
+ _TITLE_STOPWORDS = frozenset(
32
+ ('a', 'an', 'the', 'on', 'of', 'for', 'in', 'to', 'and', 'with'))
33
+
34
+
35
+ @dataclasses.dataclass
36
+ class Entry:
37
+ """A single BibTeX entry.
38
+
39
+ Attributes:
40
+ entry_type: Lowercase entry type, e.g. 'article'.
41
+ citekey: The citation key.
42
+ fields: Field values keyed by lowercase field name.
43
+ """
44
+ entry_type: str
45
+ citekey: str
46
+ fields: dict[str, str]
47
+
48
+ @property
49
+ def doi(self) -> str | None:
50
+ """The normalized DOI, if the entry has one."""
51
+ return find_doi(self.fields.get('doi', ''))
52
+
53
+ @property
54
+ def arxiv_id(self) -> str | None:
55
+ """The arXiv identifier, from the DOI, eprint, URL or journal."""
56
+ doi = self.doi
57
+ if doi and doi.startswith(_ARXIV_DOI_PREFIX):
58
+ return doi.removeprefix(_ARXIV_DOI_PREFIX)
59
+ sources = ('eprint', 'url', 'journal', 'note')
60
+ return find_arxiv_id(
61
+ ' '.join(self.fields.get(name, '') for name in sources))
62
+
63
+ def summary(self) -> dict[str, Any]:
64
+ """The citation key, title, authors, year and venue, as plain text."""
65
+ venue = next((self.fields[name] for name in _VENUE_FIELDS
66
+ if name in self.fields), '')
67
+ return {
68
+ 'citekey': self.citekey,
69
+ 'title': plain(self.fields.get('title', self.citekey)),
70
+ 'authors': split_authors(self.fields.get('author', '')),
71
+ 'year': self.fields.get('year', ''),
72
+ 'venue': plain(venue),
73
+ }
74
+
75
+ @property
76
+ def title_key(self) -> str | None:
77
+ """The title as lowercase alphanumerics, used to spot duplicates."""
78
+ title = plain(self.fields.get('title', '')).lower()
79
+ return re.sub(r'[^a-z0-9]', '', title) or None
80
+
81
+
82
+ def find_doi(text: str) -> str | None:
83
+ """Returns the first DOI found in `text`, lowercased, or None."""
84
+ match = _DOI_RE.search(text)
85
+ return match.group(1).rstrip('.,;)]').lower() if match else None
86
+
87
+
88
+ def find_arxiv_id(text: str) -> str | None:
89
+ """Returns the first arXiv identifier found in `text`, or None."""
90
+ match = _ARXIV_NEW_RE.search(text) or _ARXIV_OLD_RE.search(text)
91
+ return match.group(1) if match else None
92
+
93
+
94
+ def parse(text: str) -> list[Entry]:
95
+ """Parses every entry of a BibTeX string.
96
+
97
+ Macros are kept verbatim rather than expanded, e.g. `month = jun` gives
98
+ 'jun'. Entries without a citation key are skipped.
99
+ """
100
+ entries = []
101
+ position = 0
102
+ while match := _ENTRY_RE.search(text, position):
103
+ end = _closing(text, match.end(), '}')
104
+ citekey, comma, body = text[match.end():end].partition(',')
105
+ entry_type = match.group(1).lower()
106
+ if comma and citekey.strip() and entry_type not in _IGNORED_TYPES:
107
+ entries.append(Entry(entry_type, citekey.strip(), _fields(body)))
108
+ position = end + 1
109
+ return entries
110
+
111
+
112
+ def format_entry(entry: Entry) -> str:
113
+ """Formats an entry as BibTeX source."""
114
+ rank = {name: i for i, name in enumerate(_FIELD_ORDER)}
115
+ names = sorted(entry.fields, key=lambda n: (rank.get(n, len(rank)), n))
116
+ lines = [f'@{entry.entry_type}{{{entry.citekey},']
117
+ lines += [f' {name} = {{{entry.fields[name]}}},' for name in names]
118
+ lines.append('}')
119
+ return '\n'.join(lines)
120
+
121
+
122
+ def make_citekey(fields: dict[str, str]) -> str:
123
+ """Builds a Google Scholar style key such as 'vaswani2017attention'."""
124
+ authors = split_authors(fields.get('author', ''))
125
+ last_name = ''
126
+ if authors:
127
+ first = authors[0]
128
+ last_name = first.split(',')[0] if ',' in first else first.split()[-1]
129
+ title_words = re.findall(
130
+ r'[a-z0-9]+', _ascii(plain(fields.get('title', ''))))
131
+ word = next((w for w in title_words if w not in _TITLE_STOPWORDS), '')
132
+ key = _ascii(last_name) + fields.get('year', '').strip() + word
133
+ return re.sub(r'[^a-z0-9]', '', key) or 'ref'
134
+
135
+
136
+ def split_authors(author_field: str) -> list[str]:
137
+ """Splits a BibTeX author field into plain author names."""
138
+ names = re.split(r'\s+and\s+', plain(author_field))
139
+ return [name.strip() for name in names if name.strip()]
140
+
141
+
142
+ def plain(text: str) -> str:
143
+ """Strips the LaTeX braces, accents and escapes common in fields."""
144
+ text = _LATEX_ACCENT_RE.sub(
145
+ lambda m: m.group(2) + _COMBINING_ACCENTS[m.group(1)], text)
146
+ text = unicodedata.normalize('NFC', re.sub(r'\\([&%$#_])', r'\1', text))
147
+ return ' '.join(text.replace('{', '').replace('}', '').split())
148
+
149
+
150
+ def _fields(text: str) -> dict[str, str]:
151
+ """Parses the `name = value` pairs of an entry body."""
152
+ fields = {}
153
+ position = 0
154
+ while match := _FIELD_NAME_RE.match(text, position):
155
+ parts = []
156
+ position = match.end()
157
+ while True:
158
+ if text.startswith(('{', '"'), position):
159
+ end = _closing(text, position + 1, '}' if text[position] == '{'
160
+ else '"')
161
+ parts.append(text[position + 1:end])
162
+ position = end + 1
163
+ else:
164
+ bare = _BARE_VALUE_RE.match(text, position)
165
+ parts.append(bare.group())
166
+ position = bare.end()
167
+ concatenation = _CONCATENATION_RE.match(text, position)
168
+ if not concatenation:
169
+ break
170
+ position = concatenation.end()
171
+ fields[match.group(1).lower()] = ' '.join(''.join(parts).split())
172
+ if separator := _SEPARATOR_RE.match(text, position):
173
+ position = separator.end()
174
+ return fields
175
+
176
+
177
+ def _closing(text: str, start: int, closer: str) -> int:
178
+ """Index of the first `closer` outside braces from `start`, or the end."""
179
+ depth = 0
180
+ for index in range(start, len(text)):
181
+ char = text[index]
182
+ if char == closer and depth == 0:
183
+ return index
184
+ if char == '{':
185
+ depth += 1
186
+ elif char == '}':
187
+ depth -= 1
188
+ return len(text)
189
+
190
+
191
+ def _ascii(text: str) -> str:
192
+ decomposed = unicodedata.normalize('NFKD', text)
193
+ return decomposed.encode('ascii', 'ignore').decode().lower()
annotheca/bundle.py ADDED
@@ -0,0 +1,152 @@
1
+ """Project bundles: a whole project in one file, to share it.
2
+
3
+ A bundle is a zip archive holding `project.json`, which describes the
4
+ folders, papers, notes and annotations, and the PDFs as `pdfs/<citekey>.pdf`.
5
+ """
6
+
7
+ import io
8
+ import itertools
9
+ import json
10
+ import zipfile
11
+ from typing import Any, Literal
12
+
13
+ import pydantic
14
+
15
+ from annotheca import bibtex
16
+ from annotheca import library as library_lib
17
+ from annotheca import models
18
+
19
+ _MANIFEST = 'project.json'
20
+
21
+
22
+ class InvalidBundleError(ValueError):
23
+ """The file is not an Annotheca project bundle."""
24
+
25
+
26
+ class _Paper(pydantic.BaseModel):
27
+ citekey: str
28
+ entry_type: str
29
+ fields: dict[str, str]
30
+
31
+
32
+ class _Placement(pydantic.BaseModel):
33
+ citekey: str
34
+ notes: str = ''
35
+ added_by: str = ''
36
+ annotations: list[models.Annotation] = []
37
+
38
+
39
+ class _Folder(pydantic.BaseModel):
40
+ name: models.Name
41
+ placements: list[_Placement]
42
+
43
+
44
+ class _Manifest(pydantic.BaseModel):
45
+ format: Literal['annotheca']
46
+ version: Literal[1]
47
+ name: models.Name
48
+ papers: list[_Paper]
49
+ folders: list[_Folder] = pydantic.Field(min_length=1)
50
+
51
+ @pydantic.model_validator(mode='after')
52
+ def _check_references(self) -> '_Manifest':
53
+ """Rejects bundles that could only be imported halfway."""
54
+ citekeys = {paper.citekey for paper in self.papers}
55
+ names = [folder.name for folder in self.folders]
56
+ if len(set(names)) < len(names) or any(
57
+ placement.citekey not in citekeys
58
+ for folder in self.folders for placement in folder.placements):
59
+ raise ValueError('Inconsistent bundle.')
60
+ return self
61
+
62
+
63
+ def export_project(library: library_lib.Library,
64
+ project_id: int) -> tuple[str, bytes]:
65
+ """Returns the name of a project and its bundle."""
66
+ project = library.get_project(project_id)
67
+ papers = {}
68
+ folders = []
69
+ for folder in project['folders']:
70
+ placements = []
71
+ for placement in folder['placements']:
72
+ paper_id = placement['paper_id']
73
+ if paper_id not in papers:
74
+ entry = library.get_paper(paper_id)
75
+ papers[paper_id] = _Paper(
76
+ citekey=entry.citekey, entry_type=entry.entry_type,
77
+ fields=entry.fields)
78
+ placements.append(_Placement(
79
+ citekey=papers[paper_id].citekey, notes=placement['notes'],
80
+ added_by=placement['added_by'],
81
+ annotations=library.list_annotations(placement['id'])))
82
+ folders.append(_Folder(name=folder['name'], placements=placements))
83
+ manifest = _Manifest(format='annotheca', version=1, name=project['name'],
84
+ papers=list(papers.values()), folders=folders)
85
+
86
+ buffer = io.BytesIO()
87
+ with zipfile.ZipFile(buffer, 'w') as archive:
88
+ archive.writestr(_MANIFEST, manifest.model_dump_json(indent=1),
89
+ compress_type=zipfile.ZIP_DEFLATED)
90
+ for paper_id, paper in papers.items():
91
+ if (path := library.pdf_path(paper_id)).exists():
92
+ archive.write(path, _pdf_name(paper.citekey))
93
+ return project['name'], buffer.getvalue()
94
+
95
+
96
+ def import_project(library: library_lib.Library,
97
+ data: bytes) -> dict[str, Any]:
98
+ """Creates a project from a bundle and returns its id and name.
99
+
100
+ Papers already in the library are reused, keeping their PDF. The project
101
+ name gets a number appended if it is already taken.
102
+
103
+ Raises:
104
+ InvalidBundleError: `data` is not a valid bundle.
105
+ """
106
+ try:
107
+ archive = zipfile.ZipFile(io.BytesIO(data))
108
+ manifest = _Manifest.model_validate(
109
+ json.loads(archive.read(_MANIFEST)))
110
+ except (zipfile.BadZipFile, KeyError, ValueError) as error:
111
+ raise InvalidBundleError from error
112
+
113
+ paper_ids = {}
114
+ for paper in manifest.papers:
115
+ paper_id = library.add_paper(
116
+ bibtex.Entry(paper.entry_type, paper.citekey, paper.fields))
117
+ paper_ids[paper.citekey] = paper_id
118
+ pdf_name = _pdf_name(paper.citekey)
119
+ if (pdf_name in archive.namelist()
120
+ and not library.pdf_path(paper_id).exists()):
121
+ library.save_pdf(paper_id, archive.read(pdf_name))
122
+
123
+ project = _create_project(library, manifest.name)
124
+ default_folder = library.get_project(project['id'])['folders'][0]
125
+ for index, folder in enumerate(manifest.folders):
126
+ if index == 0:
127
+ folder_id = default_folder['id']
128
+ library.rename('folder', folder_id, folder.name)
129
+ else:
130
+ folder_id = library.create_folder(project['id'], folder.name)['id']
131
+ for placement in folder.placements:
132
+ placement_id = library.file_paper(
133
+ folder_id, paper_ids[placement.citekey], placement.added_by)
134
+ library.set_notes(placement_id, placement.notes)
135
+ for annotation in placement.annotations:
136
+ library.create_annotation(placement_id, annotation.model_dump())
137
+ return project
138
+
139
+
140
+ def _create_project(library: library_lib.Library,
141
+ name: str) -> dict[str, Any]:
142
+ """Creates a project named `name`, or `name (2)`, `name (3)`..."""
143
+ for number in itertools.count(1):
144
+ try:
145
+ return library.create_project(
146
+ name if number == 1 else f'{name} ({number})')
147
+ except library_lib.ConflictError:
148
+ continue
149
+
150
+
151
+ def _pdf_name(citekey: str) -> str:
152
+ return f'pdfs/{citekey}.pdf'
annotheca/cli.py ADDED
@@ -0,0 +1,122 @@
1
+ """Command line entry point.
2
+
3
+ `annotheca` opens the interface; `annotheca mcp` serves the tools for AI
4
+ assistants; `annotheca connect` makes those tools available to Claude.
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ import os
10
+ import pathlib
11
+ import platform
12
+ import shutil
13
+ import socket
14
+ import subprocess
15
+ import sys
16
+ import threading
17
+ import webbrowser
18
+
19
+ import uvicorn
20
+
21
+ from annotheca import __version__
22
+ from annotheca import api
23
+ from annotheca import library
24
+ from annotheca import mcp_server
25
+
26
+
27
+ def main(argv: list[str] | None = None) -> None:
28
+ """Runs the requested command."""
29
+ parser = argparse.ArgumentParser(
30
+ prog='annotheca',
31
+ description='Manage the bibliography of your research projects.')
32
+ parser.add_argument('--data-dir', type=pathlib.Path,
33
+ help='where the library is stored (default: '
34
+ f'{_default_data_dir()})')
35
+ parser.add_argument('--port', type=int, default=8737,
36
+ help='local port of the interface (default: '
37
+ '%(default)s)')
38
+ parser.add_argument('--no-browser', action='store_true',
39
+ help='do not open the interface in a browser')
40
+ parser.add_argument('--version', action='version', version=__version__)
41
+ commands = parser.add_subparsers(dest='command', metavar='COMMAND')
42
+ commands.add_parser(
43
+ 'mcp', help='serve the tools for AI assistants over MCP (stdio)')
44
+ commands.add_parser(
45
+ 'connect', help='make the tools available to Claude Code and Claude '
46
+ 'Desktop, and show how to add them to other assistants')
47
+ args = parser.parse_args(argv)
48
+
49
+ data_dir = args.data_dir or _default_data_dir()
50
+ if args.command == 'mcp':
51
+ mcp_server.create_server(library.Library(data_dir)).run()
52
+ elif args.command == 'connect':
53
+ _connect(args.data_dir)
54
+ else:
55
+ _serve(data_dir, args.port, open_browser=not args.no_browser)
56
+
57
+
58
+ def _serve(data_dir: pathlib.Path, port: int, open_browser: bool) -> None:
59
+ url = f'http://127.0.0.1:{port}'
60
+ if _port_in_use(port):
61
+ print(f'Port {port} is busy: opening the running instance.'
62
+ ' Use --port if another program holds it.')
63
+ if open_browser:
64
+ webbrowser.open(url)
65
+ return
66
+ app = api.create_app(library.Library(data_dir))
67
+ print(f'Annotheca is running at {url} (library: {data_dir}).'
68
+ ' Press Ctrl+C to stop.')
69
+ if open_browser:
70
+ threading.Timer(1.0, webbrowser.open, [url]).start()
71
+ uvicorn.run(app, host='127.0.0.1', port=port, log_level='warning')
72
+
73
+
74
+ def _connect(data_dir: pathlib.Path | None) -> None:
75
+ """Registers `annotheca mcp` with the Claude applications found."""
76
+ data_args = ['--data-dir', str(data_dir.resolve())] if data_dir else []
77
+ command = [sys.executable, '-m', 'annotheca', *data_args, 'mcp']
78
+ server = {'command': command[0], 'args': command[1:]}
79
+
80
+ if claude := shutil.which('claude'):
81
+ subprocess.run([claude, 'mcp', 'remove', '--scope', 'user',
82
+ 'annotheca'], capture_output=True, check=False)
83
+ subprocess.run([claude, 'mcp', 'add', '--scope', 'user', 'annotheca',
84
+ '--', *command], capture_output=True, check=True)
85
+ print('Connected to Claude Code (all projects).')
86
+
87
+ config_path = _claude_desktop_config()
88
+ if config_path.parent.is_dir():
89
+ config = (json.loads(config_path.read_text())
90
+ if config_path.exists() else {})
91
+ config.setdefault('mcpServers', {})['annotheca'] = server
92
+ config_path.write_text(json.dumps(config, indent=2) + '\n')
93
+ print('Connected to Claude Desktop; restart it to load the tools.')
94
+
95
+ print('\nFor other assistants (Cursor, Codex, Gemini CLI...), add this '
96
+ 'MCP server to their configuration:\n'
97
+ + json.dumps({'mcpServers': {'annotheca': server}}, indent=2))
98
+
99
+
100
+ def _claude_desktop_config() -> pathlib.Path:
101
+ if platform.system() == 'Darwin':
102
+ root = pathlib.Path.home() / 'Library' / 'Application Support'
103
+ elif platform.system() == 'Windows':
104
+ root = pathlib.Path(os.environ['APPDATA'])
105
+ else:
106
+ root = pathlib.Path(os.environ.get('XDG_CONFIG_HOME')
107
+ or pathlib.Path.home() / '.config')
108
+ return root / 'Claude' / 'claude_desktop_config.json'
109
+
110
+
111
+ def _default_data_dir() -> pathlib.Path:
112
+ """$ANNOTHECA_HOME, else the XDG data directory."""
113
+ if home := os.environ.get('ANNOTHECA_HOME'):
114
+ return pathlib.Path(home)
115
+ xdg = os.environ.get('XDG_DATA_HOME')
116
+ return pathlib.Path(xdg or pathlib.Path.home() / '.local' / 'share',
117
+ 'annotheca')
118
+
119
+
120
+ def _port_in_use(port: int) -> bool:
121
+ with socket.socket() as probe:
122
+ return probe.connect_ex(('127.0.0.1', port)) == 0