autoverse 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ include README.md
2
+ include pyproject.toml
3
+ graft src/autoverse
4
+ graft tests
5
+ global-exclude *.py[cod] *.so .DS_Store
6
+ prune .venv
7
+ prune venv
8
+ prune deploy
9
+ prune doc
10
+ prune .pytest_cache
11
+ prune src/autoverse.egg-info
12
+ recursive-exclude src *.egg-info
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: autoverse
3
+ Version: 0.1.0
4
+ Summary: Command-line client for Autoverse literature search
5
+ Project-URL: Homepage, https://app.autoverse.nz
6
+ Project-URL: Documentation, https://app.autoverse.nz/#/docs/cli
7
+ Requires-Python: >=3.12
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: httpx<1,>=0.27
10
+ Requires-Dist: keyring<26,>=25
11
+ Requires-Dist: mcp<3,>=2
12
+ Requires-Dist: pydantic<3,>=2.10
13
+ Requires-Dist: typer<1,>=0.27
14
+ Requires-Dist: uvicorn[standard]<1,>=0.32
15
+ Provides-Extra: test
16
+ Requires-Dist: pytest<9,>=8.3; extra == "test"
17
+ Requires-Dist: pytest-asyncio<1,>=0.25; extra == "test"
18
+
19
+ # Autoverse
20
+
21
+ Command-line client for Autoverse literature search. The default API is `https://app.autoverse.nz`.
22
+
23
+ ```bash
24
+ pipx install autoverse
25
+ autoverse login
26
+ autoverse search "HER2-low breast cancer" --domain medicine --limit 5
27
+ autoverse resolve doi:10.4143/crt.2023.1138
28
+ autoverse usage
29
+ ```
30
+
31
+ Sign-in opens a browser for GitHub authorization. Headless or CI environments store a platform key:
32
+
33
+ ```bash
34
+ autoverse login --token sr_live_...
35
+ # or
36
+ export AUTOVERSE_API_KEY="sr_live_..."
37
+ ```
38
+
39
+ Coding assistants should use the hosted MCP at `https://app.autoverse.nz/mcp` with `Authorization: Bearer <platform key>`. Do not install `npx @autoverse/mcp-server`. Do not install `autoverse-cli` from PyPI — that name belongs to a different project.
40
+
41
+ Requires Python 3.12+.
@@ -0,0 +1,23 @@
1
+ # Autoverse
2
+
3
+ Command-line client for Autoverse literature search. The default API is `https://app.autoverse.nz`.
4
+
5
+ ```bash
6
+ pipx install autoverse
7
+ autoverse login
8
+ autoverse search "HER2-low breast cancer" --domain medicine --limit 5
9
+ autoverse resolve doi:10.4143/crt.2023.1138
10
+ autoverse usage
11
+ ```
12
+
13
+ Sign-in opens a browser for GitHub authorization. Headless or CI environments store a platform key:
14
+
15
+ ```bash
16
+ autoverse login --token sr_live_...
17
+ # or
18
+ export AUTOVERSE_API_KEY="sr_live_..."
19
+ ```
20
+
21
+ Coding assistants should use the hosted MCP at `https://app.autoverse.nz/mcp` with `Authorization: Bearer <platform key>`. Do not install `npx @autoverse/mcp-server`. Do not install `autoverse-cli` from PyPI — that name belongs to a different project.
22
+
23
+ Requires Python 3.12+.
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=75", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "autoverse"
7
+ version = "0.1.0"
8
+ description = "Command-line client for Autoverse literature search"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ dependencies = [
12
+ "httpx>=0.27,<1",
13
+ "keyring>=25,<26",
14
+ "mcp>=2,<3",
15
+ "pydantic>=2.10,<3",
16
+ "typer>=0.27,<1",
17
+ "uvicorn[standard]>=0.32,<1",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://app.autoverse.nz"
22
+ Documentation = "https://app.autoverse.nz/#/docs/cli"
23
+
24
+ [project.optional-dependencies]
25
+ test = [
26
+ "pytest>=8.3,<9",
27
+ "pytest-asyncio>=0.25,<1",
28
+ ]
29
+
30
+ [project.scripts]
31
+ autoverse = "autoverse.cli:main"
32
+ autoverse-mcp = "autoverse.mcp_server:main"
33
+
34
+ [tool.setuptools]
35
+ package-dir = {"" = "src"}
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
39
+ include = ["autoverse"]
40
+
41
+ [tool.pytest.ini_options]
42
+ pythonpath = ["src"]
43
+ testpaths = ["tests"]
44
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ DEFAULT_API_BASE = "https://app.autoverse.nz"
@@ -0,0 +1,4 @@
1
+ from autoverse.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import sys
5
+ from typing import Any
6
+
7
+ _REDACT_KEYS = frozenset(
8
+ {
9
+ "code",
10
+ "state",
11
+ "token",
12
+ "access_token",
13
+ "refresh_token",
14
+ "authorization",
15
+ "code_verifier",
16
+ "code_challenge",
17
+ "api_key",
18
+ "password",
19
+ "secret",
20
+ }
21
+ )
22
+
23
+
24
+ def configure_logging(*, quiet: bool = False) -> None:
25
+ """Send process logs to stderr only. stdout stays for tables and JSON."""
26
+
27
+ handler = logging.StreamHandler(sys.stderr)
28
+ handler.setFormatter(logging.Formatter("%(levelname)s %(name)s %(message)s"))
29
+ root = logging.getLogger("autoverse")
30
+ root.handlers.clear()
31
+ root.addHandler(handler)
32
+ root.setLevel(logging.WARNING if quiet else logging.INFO)
33
+ root.propagate = False
34
+
35
+
36
+ def event(name: str, **fields: Any) -> str:
37
+ parts = [name]
38
+ for key, value in fields.items():
39
+ if value is None:
40
+ continue
41
+ if key in _REDACT_KEYS:
42
+ continue
43
+ parts.append(f"{key}={value}")
44
+ return " ".join(parts)
@@ -0,0 +1,273 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Annotated, Any
6
+
7
+ import typer
8
+
9
+ from autoverse._log import configure_logging
10
+ from autoverse.client import AutoverseClient
11
+ from autoverse.credentials import CredentialStore
12
+ from autoverse.errors import AutoverseError, UsageError
13
+ from autoverse import login as login_flow
14
+ from autoverse.render import emit_error, emit_json, papers_table, response_data, usage_text
15
+
16
+ app = typer.Typer(no_args_is_help=True, add_completion=False)
17
+ mcp_app = typer.Typer(no_args_is_help=True, add_completion=False)
18
+ app.add_typer(mcp_app, name="mcp")
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class AppContext:
23
+ json_output: bool
24
+ api_base: str | None
25
+ api_key: str | None
26
+ profile: str
27
+ quiet: bool
28
+
29
+
30
+ def _client(ctx: typer.Context) -> AutoverseClient:
31
+ state: AppContext = ctx.obj
32
+ return AutoverseClient(
33
+ api_base=state.api_base,
34
+ api_key=state.api_key,
35
+ credentials=CredentialStore(profile=state.profile),
36
+ )
37
+
38
+
39
+ def _emit(ctx: typer.Context, payload: dict[str, Any], *, human: str) -> None:
40
+ data, request_id = response_data(payload) if isinstance(payload, dict) else (payload, None)
41
+ if ctx.obj.json_output:
42
+ emit_json(data, request_id=request_id)
43
+ return
44
+ typer.echo(human)
45
+
46
+
47
+ @app.callback()
48
+ def _root(
49
+ ctx: typer.Context,
50
+ json_output: Annotated[bool, typer.Option("--json", help="Write machine-readable JSON to stdout")] = False,
51
+ api_base: Annotated[str | None, typer.Option("--api-base", envvar="AUTOVERSE_API_BASE")] = None,
52
+ api_key: Annotated[str | None, typer.Option("--api-key", envvar="AUTOVERSE_API_KEY")] = None,
53
+ profile: Annotated[str, typer.Option("--profile")] = "default",
54
+ quiet: Annotated[bool, typer.Option("--quiet")] = False,
55
+ ) -> None:
56
+ configure_logging(quiet=quiet)
57
+ ctx.obj = AppContext(
58
+ json_output=json_output,
59
+ api_base=api_base,
60
+ api_key=api_key,
61
+ profile=profile,
62
+ quiet=quiet,
63
+ )
64
+
65
+
66
+ @app.command()
67
+ def login(
68
+ ctx: typer.Context,
69
+ no_browser: Annotated[bool, typer.Option("--no-browser")] = False,
70
+ token: Annotated[str | None, typer.Option("--token")] = None,
71
+ ) -> None:
72
+ client = _client(ctx)
73
+ try:
74
+ if token:
75
+ stored = client.login_with_api_key(token)
76
+ _emit(ctx, {"api_base": stored.api_base}, human="API key stored. You can search now.")
77
+ return
78
+ payload = login_flow.authorize(client, open_browser=not no_browser)
79
+ email = ((payload.get("user") or {}) or {}).get("email") or "signed in"
80
+ _emit(ctx, payload, human=f"Logged in as {email}.")
81
+ except AutoverseError as exc:
82
+ emit_error(exc, as_json=ctx.obj.json_output)
83
+ raise typer.Exit(exc.exit_code) from exc
84
+
85
+
86
+ @app.command()
87
+ def logout(
88
+ ctx: typer.Context,
89
+ all_sessions: Annotated[bool, typer.Option("--all")] = False,
90
+ ) -> None:
91
+ client = _client(ctx)
92
+ try:
93
+ client.logout(all_sessions=all_sessions)
94
+ _emit(ctx, {"status": "logged_out"}, human="Logged out.")
95
+ except AutoverseError as exc:
96
+ emit_error(exc, as_json=ctx.obj.json_output)
97
+ raise typer.Exit(exc.exit_code) from exc
98
+
99
+
100
+ @app.command()
101
+ def whoami(ctx: typer.Context) -> None:
102
+ client = _client(ctx)
103
+ try:
104
+ payload = client.whoami()
105
+ user = payload.get("user") or {}
106
+ usage = payload.get("usage") or {}
107
+ human = f"{user.get('email') or 'api-key'}\n{usage_text(usage)}"
108
+ _emit(ctx, payload, human=human)
109
+ except AutoverseError as exc:
110
+ emit_error(exc, as_json=ctx.obj.json_output)
111
+ raise typer.Exit(exc.exit_code) from exc
112
+
113
+
114
+ @app.command()
115
+ def search(
116
+ ctx: typer.Context,
117
+ query: str,
118
+ domain: Annotated[str, typer.Option("--domain")] = "auto",
119
+ mode: Annotated[str, typer.Option("--mode")] = "auto",
120
+ year_from: Annotated[int | None, typer.Option("--year-from")] = None,
121
+ year_to: Annotated[int | None, typer.Option("--year-to")] = None,
122
+ publication_type: Annotated[list[str] | None, typer.Option("--type")] = None,
123
+ limit: Annotated[int, typer.Option("--limit")] = 20,
124
+ cursor: Annotated[str | None, typer.Option("--cursor")] = None,
125
+ ) -> None:
126
+ if not 1 <= limit <= 50:
127
+ emit_error(UsageError("limit must be between 1 and 50"), as_json=ctx.obj.json_output)
128
+ raise typer.Exit(2)
129
+ client = _client(ctx)
130
+ try:
131
+ payload = client.search(
132
+ query,
133
+ domain=domain,
134
+ mode=mode,
135
+ year_from=year_from,
136
+ year_to=year_to,
137
+ publication_types=publication_type,
138
+ limit=limit,
139
+ cursor=cursor,
140
+ )
141
+ data, _ = response_data(payload)
142
+ items = data.get("items") if isinstance(data, dict) else []
143
+ _emit(ctx, payload, human=papers_table(items if isinstance(items, list) else []))
144
+ except AutoverseError as exc:
145
+ emit_error(exc, as_json=ctx.obj.json_output)
146
+ raise typer.Exit(exc.exit_code) from exc
147
+
148
+
149
+ @app.command()
150
+ def resolve(ctx: typer.Context, identifier: str) -> None:
151
+ client = _client(ctx)
152
+ try:
153
+ payload = client.resolve(identifier)
154
+ data, _ = response_data(payload)
155
+ title = data.get("title") if isinstance(data, dict) else identifier
156
+ _emit(ctx, payload, human=str(title))
157
+ except AutoverseError as exc:
158
+ emit_error(exc, as_json=ctx.obj.json_output)
159
+ raise typer.Exit(exc.exit_code) from exc
160
+
161
+
162
+ @app.command()
163
+ def batch(
164
+ ctx: typer.Context,
165
+ file: Annotated[Path, typer.Option("-f", "--file")],
166
+ ) -> None:
167
+ identifiers = [line.strip() for line in file.read_text(encoding="utf-8").splitlines() if line.strip()]
168
+ client = _client(ctx)
169
+ try:
170
+ payload = client.batch(identifiers)
171
+ items = payload.get("items") if isinstance(payload, dict) else []
172
+ human = "\n".join(
173
+ f"{item.get('identifier')} {((item.get('paper') or {}) or {}).get('title') or item.get('error')}"
174
+ for item in items
175
+ )
176
+ _emit(ctx, payload, human=human or "(no items)")
177
+ except AutoverseError as exc:
178
+ emit_error(exc, as_json=ctx.obj.json_output)
179
+ raise typer.Exit(exc.exit_code) from exc
180
+
181
+
182
+ @app.command()
183
+ def authors(ctx: typer.Context, query: str) -> None:
184
+ client = _client(ctx)
185
+ try:
186
+ payload = client.authors(query)
187
+ items = payload.get("items") if isinstance(payload, dict) else []
188
+ human = "\n".join(str(item.get("name") or item) for item in items)
189
+ _emit(ctx, payload, human=human or "(no authors)")
190
+ except AutoverseError as exc:
191
+ emit_error(exc, as_json=ctx.obj.json_output)
192
+ raise typer.Exit(exc.exit_code) from exc
193
+
194
+
195
+ @app.command()
196
+ def usage(ctx: typer.Context) -> None:
197
+ client = _client(ctx)
198
+ try:
199
+ payload = client.usage()
200
+ extra = ""
201
+ try:
202
+ events = client.usage_events(limit=10)
203
+ rows = events.get("items") or []
204
+ if rows:
205
+ extra = "\n" + "\n".join(
206
+ f"{item.get('created_at')} {item.get('price_code')} {item.get('outcome')}"
207
+ for item in rows
208
+ )
209
+ except AutoverseError:
210
+ extra = ""
211
+ _emit(ctx, payload, human=usage_text(payload) + extra)
212
+ except AutoverseError as exc:
213
+ emit_error(exc, as_json=ctx.obj.json_output)
214
+ raise typer.Exit(exc.exit_code) from exc
215
+
216
+
217
+ @app.command()
218
+ def api(
219
+ ctx: typer.Context,
220
+ path: str,
221
+ method: Annotated[str, typer.Option("-X")] = "GET",
222
+ field: Annotated[list[str] | None, typer.Option("-f")] = None,
223
+ ) -> None:
224
+ fields: dict[str, str] = {}
225
+ for item in field or []:
226
+ if "=" not in item:
227
+ emit_error(UsageError("use -f key=value"), as_json=ctx.obj.json_output)
228
+ raise typer.Exit(2)
229
+ key, value = item.split("=", 1)
230
+ fields[key] = value
231
+ client = _client(ctx)
232
+ try:
233
+ payload = client.raw(method, path, fields=fields or None)
234
+ _emit(ctx, payload if isinstance(payload, dict) else {"data": payload}, human=str(payload))
235
+ except AutoverseError as exc:
236
+ emit_error(exc, as_json=ctx.obj.json_output)
237
+ raise typer.Exit(exc.exit_code) from exc
238
+
239
+
240
+ @mcp_app.command("serve")
241
+ def mcp_serve(
242
+ host: Annotated[str, typer.Option("--host")] = "127.0.0.1",
243
+ port: Annotated[int, typer.Option("--port")] = 8091,
244
+ api_base: Annotated[str | None, typer.Option("--api-base")] = None,
245
+ toolset: Annotated[str, typer.Option("--toolset")] = "default",
246
+ ) -> None:
247
+ from autoverse.hosted_mcp import serve
248
+
249
+ try:
250
+ serve(host=host, port=port, api_base=api_base, toolset=toolset)
251
+ except UsageError as exc:
252
+ emit_error(exc, as_json=False)
253
+ raise typer.Exit(exc.exit_code) from exc
254
+
255
+
256
+ @mcp_app.command("install")
257
+ def mcp_install(
258
+ ctx: typer.Context,
259
+ host: str,
260
+ directory: Annotated[Path | None, typer.Option("--dir")] = None,
261
+ ) -> None:
262
+ from autoverse.mcp_server import write_host_config
263
+
264
+ try:
265
+ path = write_host_config(host, directory=directory)
266
+ _emit(ctx, {"path": str(path), "host": host}, human=f"Wrote {path}")
267
+ except AutoverseError as exc:
268
+ emit_error(exc, as_json=ctx.obj.json_output)
269
+ raise typer.Exit(exc.exit_code) from exc
270
+
271
+
272
+ def main() -> None:
273
+ app()