ctxd 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.
ctxd-0.1.0/.gitignore ADDED
@@ -0,0 +1,173 @@
1
+ secrets/*
2
+ !secrets/.gitkeep
3
+ .docker_env
4
+ .env*
5
+ !.env_example
6
+ app_files/agent/*
7
+ # Byte-compiled / optimized / DLL files
8
+ __pycache__/
9
+ *.py[cod]
10
+ *$py.class
11
+ benchmarking/arxiv_*/*
12
+
13
+ scripts/data/*
14
+ # C extensions
15
+ *.so
16
+ # Distribution / packaging
17
+ .Python
18
+ build/
19
+ develop-eggs/
20
+ dist/
21
+ downloads/
22
+ eggs/
23
+ .eggs/
24
+ lib/
25
+ lib64/
26
+ parts/
27
+ sdist/
28
+ var/
29
+ wheels/
30
+ share/python-wheels/
31
+ *.egg-info/
32
+ .installed.cfg
33
+ *.egg
34
+ MANIFEST
35
+
36
+ # PyInstaller
37
+ # Usually these files are written by a python script from a template
38
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
39
+ *.manifest
40
+ *.spec
41
+
42
+ # Installer logs
43
+ pip-log.txt
44
+ pip-delete-this-directory.txt
45
+
46
+ # Unit test / coverage reports
47
+ htmlcov/
48
+ .tox/
49
+ .nox/
50
+ .coverage
51
+ .coverage.*
52
+ .cache
53
+ nosetests.xml
54
+ coverage.xml
55
+ *.cover
56
+ *.py,cover
57
+ .hypothesis/
58
+ .pytest_cache/
59
+ cover/
60
+
61
+ # Translations
62
+ *.mo
63
+ *.pot
64
+
65
+ # Django stuff:
66
+ *.log
67
+ local_settings.py
68
+ db.sqlite3
69
+ db.sqlite3-journal
70
+
71
+ # Flask stuff:
72
+ instance/
73
+ .webassets-cache
74
+
75
+ # Scrapy stuff:
76
+ .scrapy
77
+
78
+ # Sphinx documentation
79
+ docs/_build/
80
+
81
+ # PyBuilder
82
+ .pybuilder/
83
+ target/
84
+
85
+ # Jupyter Notebook
86
+ .ipynb_checkpoints
87
+
88
+ # IPython
89
+ profile_default/
90
+ ipython_config.py
91
+
92
+ # pyenv
93
+ # For a library or package, you might want to ignore these files since the code is
94
+ # intended to run in multiple environments; otherwise, check them in:
95
+ # .python-version
96
+
97
+ # pipenv
98
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
99
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
100
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
101
+ # install all needed dependencies.
102
+ #Pipfile.lock
103
+
104
+ # poetry
105
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
106
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
107
+ # commonly ignored for libraries.
108
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
109
+ #poetry.lock
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ #pdm.lock
114
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
115
+ # in version control.
116
+ # https://pdm.fming.dev/#use-with-ide
117
+ .pdm.toml
118
+
119
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
120
+ __pypackages__/
121
+
122
+ # Celery stuff
123
+ celerybeat-schedule
124
+ celerybeat.pid
125
+
126
+ # SageMath parsed files
127
+ *.sage.py
128
+
129
+ # Environments
130
+ .env
131
+ .venv
132
+ env/
133
+ venv/
134
+ ENV/
135
+ env.bak/
136
+ venv.bak/
137
+
138
+ # Spyder project settings
139
+ .spyderproject
140
+ .spyproject
141
+
142
+ # Rope project settings
143
+ .ropeproject
144
+
145
+ # mkdocs documentation
146
+ /site
147
+
148
+ # mypy
149
+ .mypy_cache/
150
+ .dmypy.json
151
+ dmypy.json
152
+
153
+ # Pyre type checker
154
+ .pyre/
155
+
156
+ # pytype static type analyzer
157
+ .pytype/
158
+
159
+ # Cython debug symbols
160
+ cython_debug/
161
+
162
+ # PyCharm
163
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
164
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
165
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
166
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
167
+ .idea/
168
+ .vscode/launch.json
169
+
170
+ # Agent debug artifacts
171
+ debug-artifacts/*
172
+ !debug-artifacts/.gitkeep
173
+ !debug-artifacts/README.md
ctxd-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: ctxd
3
+ Version: 0.1.0
4
+ Summary: Public Python SDK and CLI for the ctxd platform.
5
+ Requires-Python: <3.13,>=3.11
6
+ Requires-Dist: cryptography>=44.0.0
7
+ Requires-Dist: httpx>=0.28.1
8
+ Requires-Dist: keyring>=25.7.0
9
+ Requires-Dist: pydantic>=2.11.9
10
+ Description-Content-Type: text/markdown
11
+
12
+ # ctxd
13
+
14
+ Public Python SDK and CLI for the `ctxd` platform.
15
+
16
+ Install:
17
+
18
+ ```bash
19
+ pip install ctxd
20
+ ```
21
+
22
+ The SDK exposes sync and async clients:
23
+
24
+ - `Client`
25
+ - `AsyncClient`
26
+
27
+ Authentication:
28
+
29
+ 1. Run `ctxd login` with the CLI to store OAuth credentials in a secure local store
30
+ 2. The CLI starts a device-style login session through the backend auth API
31
+ 3. The browser flow continues on `https://app.ctxd.dev/cli-login`, so login can finish on a different machine than the terminal session
32
+ 4. The backend dynamically registers the CLI session with the MCP authorization server and completes the OAuth callback server-side
33
+ 5. The SDK keeps only non-secret metadata in `~/.ctxd/config.json`
34
+ 6. The SDK reads the stored access token and refreshes it automatically when needed
35
+ 7. Tokens are loaded from the OS keychain when available, otherwise from an encrypted local credential store unlocked with a passphrase entered interactively or provided through `CTXD_PASSPHRASE`
36
+
37
+ Base URL resolution order:
38
+
39
+ 1. `base_url=` passed to the client
40
+ 2. `CTXD_BASE_URL`
41
+ 3. `~/.ctxd/config.json`
42
+ 4. `https://mcp.ctxd.dev`
43
+
44
+ Auth API URL resolution order:
45
+
46
+ 1. `CTXD_AUTH_API_URL`
47
+ 2. `https://api.ctxd.dev`
48
+
49
+ Example:
50
+
51
+ ```python
52
+ from ctxd import Client
53
+
54
+ client = Client()
55
+
56
+ results = client.search("text:deployment application:slack")
57
+ profile = client.get_profile()
58
+ document = client.fetch_document("doc-123")
59
+ ```
60
+
61
+ Async example:
62
+
63
+ ```python
64
+ from ctxd import AsyncClient
65
+
66
+ async with AsyncClient() as client:
67
+ results = await client.search("text:deployment")
68
+ ```
ctxd-0.1.0/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # ctxd
2
+
3
+ Public Python SDK and CLI for the `ctxd` platform.
4
+
5
+ Install:
6
+
7
+ ```bash
8
+ pip install ctxd
9
+ ```
10
+
11
+ The SDK exposes sync and async clients:
12
+
13
+ - `Client`
14
+ - `AsyncClient`
15
+
16
+ Authentication:
17
+
18
+ 1. Run `ctxd login` with the CLI to store OAuth credentials in a secure local store
19
+ 2. The CLI starts a device-style login session through the backend auth API
20
+ 3. The browser flow continues on `https://app.ctxd.dev/cli-login`, so login can finish on a different machine than the terminal session
21
+ 4. The backend dynamically registers the CLI session with the MCP authorization server and completes the OAuth callback server-side
22
+ 5. The SDK keeps only non-secret metadata in `~/.ctxd/config.json`
23
+ 6. The SDK reads the stored access token and refreshes it automatically when needed
24
+ 7. Tokens are loaded from the OS keychain when available, otherwise from an encrypted local credential store unlocked with a passphrase entered interactively or provided through `CTXD_PASSPHRASE`
25
+
26
+ Base URL resolution order:
27
+
28
+ 1. `base_url=` passed to the client
29
+ 2. `CTXD_BASE_URL`
30
+ 3. `~/.ctxd/config.json`
31
+ 4. `https://mcp.ctxd.dev`
32
+
33
+ Auth API URL resolution order:
34
+
35
+ 1. `CTXD_AUTH_API_URL`
36
+ 2. `https://api.ctxd.dev`
37
+
38
+ Example:
39
+
40
+ ```python
41
+ from ctxd import Client
42
+
43
+ client = Client()
44
+
45
+ results = client.search("text:deployment application:slack")
46
+ profile = client.get_profile()
47
+ document = client.fetch_document("doc-123")
48
+ ```
49
+
50
+ Async example:
51
+
52
+ ```python
53
+ from ctxd import AsyncClient
54
+
55
+ async with AsyncClient() as client:
56
+ results = await client.search("text:deployment")
57
+ ```
@@ -0,0 +1,44 @@
1
+ """Public Python SDK for ctxd."""
2
+
3
+ from ctxd._metadata import SDK_NAME, SDK_VERSION, get_user_agent
4
+ from ctxd.async_client import AsyncClient
5
+ from ctxd.auth import ensure_valid_credentials, login_with_browser, logout
6
+ from ctxd.client import Client, CtxdClient
7
+ from ctxd.config import (
8
+ DEFAULT_BASE_URL,
9
+ clear_credentials,
10
+ get_config_path,
11
+ load_config,
12
+ load_credentials,
13
+ save_config,
14
+ save_credentials,
15
+ )
16
+ from ctxd.exceptions import CtxdAuthError, CtxdError, CtxdProtocolError
17
+ from ctxd.models import CredentialStore, DocumentResult, ProfileResult, SearchResult
18
+
19
+ __all__ = [
20
+ "AsyncClient",
21
+ "Client",
22
+ "CredentialStore",
23
+ "CtxdClient",
24
+ "CtxdAuthError",
25
+ "CtxdError",
26
+ "CtxdProtocolError",
27
+ "DEFAULT_BASE_URL",
28
+ "DocumentResult",
29
+ "ProfileResult",
30
+ "SDK_NAME",
31
+ "SearchResult",
32
+ "__version__",
33
+ "clear_credentials",
34
+ "ensure_valid_credentials",
35
+ "get_config_path",
36
+ "get_user_agent",
37
+ "load_credentials",
38
+ "login_with_browser",
39
+ "load_config",
40
+ "logout",
41
+ "save_config",
42
+ "save_credentials",
43
+ ]
44
+ __version__ = SDK_VERSION
@@ -0,0 +1,6 @@
1
+ SDK_NAME = "ctxd"
2
+ SDK_VERSION = "0.1.0"
3
+
4
+
5
+ def get_user_agent() -> str:
6
+ return f"{SDK_NAME}/{SDK_VERSION}"
@@ -0,0 +1,196 @@
1
+ import asyncio
2
+ import json
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from ctxd._metadata import get_user_agent
8
+ from ctxd.auth import ensure_valid_credentials, login_with_browser, logout
9
+ from ctxd.config import resolve_base_url
10
+ from ctxd.exceptions import CtxdAuthError, CtxdError, CtxdProtocolError
11
+ from ctxd.models import DocumentResult, ProfileResult, SearchResult
12
+
13
+
14
+ class AsyncClient:
15
+ """Async client for the public ctxd MCP endpoint."""
16
+
17
+ def __init__(
18
+ self,
19
+ *,
20
+ access_token: str | None = None,
21
+ base_url: str | None = None,
22
+ client_id: str | None = None,
23
+ timeout: float = 30.0,
24
+ ) -> None:
25
+ self._base_url = self._normalize_base_url(resolve_base_url(base_url))
26
+ self._access_token = access_token.strip() if access_token else None
27
+ self._client_id = client_id.strip() if client_id else None
28
+ self._timeout = timeout
29
+ self._client: httpx.AsyncClient | None = None
30
+
31
+ @property
32
+ def base_url(self) -> str:
33
+ return self._base_url
34
+
35
+ @property
36
+ def client_id(self) -> str | None:
37
+ return self._client_id
38
+
39
+ async def __aenter__(self) -> "AsyncClient":
40
+ self._client = httpx.AsyncClient(timeout=self._timeout)
41
+ return self
42
+
43
+ async def __aexit__(self, exc_type, exc, tb) -> None:
44
+ if self._client is not None:
45
+ await self._client.aclose()
46
+ self._client = None
47
+
48
+ async def search(self, query: str) -> SearchResult:
49
+ payload = await self.call_tool("search", {"query": query})
50
+ return SearchResult.model_validate(payload)
51
+
52
+ async def fetch_document(self, document_uid: str) -> DocumentResult:
53
+ payload = await self.call_tool("fetch_document", {"document_uid": document_uid})
54
+ return DocumentResult.model_validate(payload)
55
+
56
+ async def get_profile(self) -> ProfileResult:
57
+ payload = await self.call_tool("get_profile", {})
58
+ return ProfileResult.model_validate(payload)
59
+
60
+ async def login(self, *, open_browser: bool = True, timeout_seconds: float = 300.0):
61
+ return await _run_sync(
62
+ login_with_browser,
63
+ base_url=self._base_url,
64
+ timeout_seconds=timeout_seconds,
65
+ open_browser=open_browser,
66
+ )
67
+
68
+ def logout(self, *, keep_base_url: bool = True) -> None:
69
+ logout(keep_base_url=keep_base_url)
70
+
71
+ async def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
72
+ request_body = {
73
+ "jsonrpc": "2.0",
74
+ "method": "tools/call",
75
+ "params": {
76
+ "name": name,
77
+ "arguments": arguments,
78
+ },
79
+ "id": 1,
80
+ }
81
+ token = await self._resolve_access_token()
82
+ headers = {
83
+ "Authorization": f"Bearer {token}",
84
+ "Accept": "application/json, text/event-stream",
85
+ "User-Agent": get_user_agent(),
86
+ }
87
+
88
+ if self._client is not None:
89
+ response = await self._client.post(
90
+ self._base_url,
91
+ headers=headers,
92
+ json=request_body,
93
+ )
94
+ else:
95
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
96
+ response = await client.post(
97
+ self._base_url,
98
+ headers=headers,
99
+ json=request_body,
100
+ )
101
+
102
+ return self._parse_response(response)
103
+
104
+ async def _resolve_access_token(self) -> str:
105
+ if self._access_token:
106
+ return self._access_token
107
+
108
+ credentials = await _run_sync(
109
+ ensure_valid_credentials,
110
+ base_url=self._base_url,
111
+ client_id=self._client_id,
112
+ )
113
+ if not credentials.access_token:
114
+ raise CtxdAuthError("Missing access token. Run `ctxd login` first.")
115
+ return credentials.access_token
116
+
117
+ @staticmethod
118
+ def _normalize_base_url(base_url: str) -> str:
119
+ normalized = base_url.rstrip("/")
120
+ if normalized.endswith("/sse"):
121
+ normalized = normalized[: -len("/sse")]
122
+ if not normalized.endswith("/mcp"):
123
+ normalized = f"{normalized}/mcp"
124
+ return normalized
125
+
126
+ @staticmethod
127
+ def _parse_response(response: httpx.Response) -> dict[str, Any]:
128
+ if response.status_code >= 400:
129
+ message = f"ctxd MCP request failed with status {response.status_code}"
130
+ try:
131
+ error_payload = response.json()
132
+ except ValueError:
133
+ error_payload = response.text
134
+ raise CtxdError(
135
+ message, status_code=response.status_code, payload=error_payload
136
+ )
137
+
138
+ content_type = response.headers.get("content-type", "")
139
+ if "text/event-stream" in content_type:
140
+ return AsyncClient._parse_sse_payload(response.text)
141
+ if "application/json" in content_type:
142
+ return AsyncClient._parse_json_payload(response.json())
143
+
144
+ if response.text.startswith("event:") or response.text.startswith("data:"):
145
+ return AsyncClient._parse_sse_payload(response.text)
146
+
147
+ raise CtxdProtocolError(
148
+ f"Unsupported MCP response content type: {content_type or 'unknown'}"
149
+ )
150
+
151
+ @staticmethod
152
+ def _parse_sse_payload(raw_text: str) -> dict[str, Any]:
153
+ data_line = next(
154
+ (line for line in raw_text.splitlines() if line.startswith("data: ")),
155
+ None,
156
+ )
157
+ if data_line is None:
158
+ raise CtxdProtocolError("MCP SSE response did not contain a data line")
159
+
160
+ body = json.loads(data_line[len("data: ") :])
161
+ return AsyncClient._parse_json_payload(body)
162
+
163
+ @staticmethod
164
+ def _parse_json_payload(body: dict[str, Any]) -> dict[str, Any]:
165
+ if "error" in body:
166
+ raise CtxdError("MCP JSON-RPC error", payload=body["error"])
167
+
168
+ result = body.get("result")
169
+ if not isinstance(result, dict):
170
+ raise CtxdProtocolError("MCP response did not include a result object")
171
+
172
+ content = result.get("content")
173
+ if not isinstance(content, list) or not content:
174
+ raise CtxdProtocolError("MCP result content was missing or empty")
175
+
176
+ first_item = content[0]
177
+ if first_item.get("type") != "text":
178
+ raise CtxdProtocolError("MCP result content item was not text")
179
+
180
+ text = first_item.get("text")
181
+ if not isinstance(text, str):
182
+ raise CtxdProtocolError("MCP result text payload was not a string")
183
+
184
+ if result.get("isError"):
185
+ raise CtxdError(text, payload=result)
186
+
187
+ try:
188
+ return json.loads(text)
189
+ except json.JSONDecodeError as exc:
190
+ raise CtxdProtocolError(
191
+ "MCP result text payload was not valid JSON"
192
+ ) from exc
193
+
194
+
195
+ async def _run_sync(func, /, *args, **kwargs):
196
+ return await asyncio.to_thread(func, *args, **kwargs)