costcrunch 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.
Files changed (33) hide show
  1. costcrunch-0.1.0/.github/workflows/ci.yml +19 -0
  2. costcrunch-0.1.0/.github/workflows/release.yml +38 -0
  3. costcrunch-0.1.0/.gitignore +8 -0
  4. costcrunch-0.1.0/LICENSE +21 -0
  5. costcrunch-0.1.0/PKG-INFO +156 -0
  6. costcrunch-0.1.0/README.md +128 -0
  7. costcrunch-0.1.0/pyproject.toml +57 -0
  8. costcrunch-0.1.0/src/costcrunch/__init__.py +7 -0
  9. costcrunch-0.1.0/src/costcrunch/__main__.py +3 -0
  10. costcrunch-0.1.0/src/costcrunch/_version.py +1 -0
  11. costcrunch-0.1.0/src/costcrunch/auth.py +207 -0
  12. costcrunch-0.1.0/src/costcrunch/cli.py +319 -0
  13. costcrunch-0.1.0/src/costcrunch/client.py +212 -0
  14. costcrunch-0.1.0/src/costcrunch/config.py +103 -0
  15. costcrunch-0.1.0/src/costcrunch/errors.py +45 -0
  16. costcrunch-0.1.0/src/costcrunch/mcp_server.py +112 -0
  17. costcrunch-0.1.0/src/costcrunch/ops/__init__.py +6 -0
  18. costcrunch-0.1.0/src/costcrunch/ops/_shape.py +51 -0
  19. costcrunch-0.1.0/src/costcrunch/ops/account.py +33 -0
  20. costcrunch-0.1.0/src/costcrunch/ops/documents.py +162 -0
  21. costcrunch-0.1.0/src/costcrunch/ops/materials.py +146 -0
  22. costcrunch-0.1.0/src/costcrunch/ops/projects.py +60 -0
  23. costcrunch-0.1.0/src/costcrunch/ops/search.py +40 -0
  24. costcrunch-0.1.0/src/costcrunch/ops/spend.py +26 -0
  25. costcrunch-0.1.0/src/costcrunch/ops/suppliers.py +83 -0
  26. costcrunch-0.1.0/src/costcrunch/output.py +177 -0
  27. costcrunch-0.1.0/src/costcrunch/registry.py +138 -0
  28. costcrunch-0.1.0/tests/__init__.py +0 -0
  29. costcrunch-0.1.0/tests/conftest.py +54 -0
  30. costcrunch-0.1.0/tests/test_api_keys.py +62 -0
  31. costcrunch-0.1.0/tests/test_client.py +111 -0
  32. costcrunch-0.1.0/tests/test_registry.py +50 -0
  33. costcrunch-0.1.0/tests/test_surfaces.py +101 -0
@@ -0,0 +1,19 @@
1
+ name: ci
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ strategy:
11
+ matrix:
12
+ python: ["3.10", "3.12", "3.13"]
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: astral-sh/setup-uv@v6
16
+ - run: uv venv -p ${{ matrix.python }} && uv pip install -e '.[dev]'
17
+ - run: .venv/bin/ruff check src tests
18
+ - run: .venv/bin/pytest -q
19
+ - run: .venv/bin/cost --help >/dev/null
@@ -0,0 +1,38 @@
1
+ # Publishes to PyPI when a version tag is pushed: git tag v0.1.0 && git push origin v0.1.0
2
+ # Uses PyPI trusted publishing (no token stored anywhere): the pending publisher on pypi.org names
3
+ # this repo, this workflow file and the `pypi` environment.
4
+ name: release
5
+ on:
6
+ push:
7
+ tags: ["v*"]
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: astral-sh/setup-uv@v6
15
+ - run: uv venv && uv pip install -e '.[dev]'
16
+ - run: .venv/bin/ruff check src tests && .venv/bin/pytest -q
17
+ - name: Tag matches the package version
18
+ run: |
19
+ version=$(.venv/bin/python -c 'import costcrunch; print(costcrunch.__version__)')
20
+ test "v$version" = "$GITHUB_REF_NAME" || { echo "tag $GITHUB_REF_NAME != version $version"; exit 1; }
21
+ - run: uv build
22
+ - uses: actions/upload-artifact@v4
23
+ with:
24
+ name: dist
25
+ path: dist/
26
+
27
+ publish:
28
+ needs: build
29
+ runs-on: ubuntu-latest
30
+ environment: pypi
31
+ permissions:
32
+ id-token: write
33
+ steps:
34
+ - uses: actions/download-artifact@v4
35
+ with:
36
+ name: dist
37
+ path: dist/
38
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CostCrunch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.5
2
+ Name: costcrunch
3
+ Version: 0.1.0
4
+ Summary: CostCrunch from Python, your terminal, and AI assistants (MCP).
5
+ Project-URL: Homepage, https://costcrunch.ai
6
+ Project-URL: Documentation, https://support.costcrunch.ai
7
+ Author-email: CostCrunch <support@costcrunch.ai>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: cli,construction,costcrunch,invoices,mcp,procurement
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: httpx<1,>=0.27
21
+ Requires-Dist: mcp<3,>=2.3
22
+ Requires-Dist: rich>=13
23
+ Requires-Dist: typer>=0.12
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8; extra == 'dev'
26
+ Requires-Dist: ruff>=0.6; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # CostCrunch for Python, the terminal and AI assistants
30
+
31
+ One package, three ways in to your CostCrunch data:
32
+
33
+ | | |
34
+ |---|---|
35
+ | **CLI** | `cost documents list --status needs_attention` |
36
+ | **Python** | `CostCrunch().materials.history(material_id)` |
37
+ | **MCP** | Ask Claude, Cursor or any MCP client *"what did we pay for 5/8 sheetrock last month, and is that a good price?"* |
38
+
39
+ All three are generated from the same list of operations, so they always match. Everything is
40
+ read-only and scoped to your company: you see exactly what you see in the CostCrunch app.
41
+
42
+ ## Install
43
+
44
+ ```sh
45
+ uv tool install costcrunch # or: pipx install costcrunch
46
+ cost auth login
47
+ ```
48
+
49
+ This opens your browser to sign in with Google. For an email + password account, use
50
+ `cost auth login --email you@company.com`. Your session is stored in `~/.config/costcrunch/credentials.json`,
51
+ readable only by you. `cost auth logout` removes it.
52
+
53
+ Or use an **API key**: create one in CostCrunch under **Settings › Connections › API**, then
54
+ `cost auth login --api-key` (it asks for the key), or set `COSTCRUNCH_API_KEY`. A key belongs to
55
+ one company, can only read, and works until you revoke it on that page.
56
+
57
+ If you belong to more than one company: `cost account orgs`, then
58
+ `cost account use-org <id>` (or pass `--org <id>` to any command).
59
+
60
+ ## CLI
61
+
62
+ ```sh
63
+ cost search all "tri-state" # anything: documents, suppliers, materials...
64
+ cost documents list --type invoice --since 2026-10-01
65
+ cost documents get <document-id> # header + line items + price checks
66
+ cost suppliers list
67
+ cost suppliers materials <supplier-id> --search sheetrock
68
+ cost projects get <project-id> # spend by supplier, month, cost type
69
+ cost materials find "5/8 sheetrock" # by meaning, not just words
70
+ cost materials history <material-id> --since 2026-01-01
71
+ cost materials prices <material-id> # your history vs other suppliers vs online
72
+ cost spend monthly
73
+ ```
74
+
75
+ Add `--json` to any command for machine-readable output, or `--csv` on list commands for a
76
+ spreadsheet: `cost --csv suppliers list > suppliers.csv`. `cost <group> --help`
77
+ lists every command and option.
78
+
79
+ ## Python
80
+
81
+ ```python
82
+ from costcrunch import CostCrunch
83
+
84
+ cc = CostCrunch() # your `cost auth login`, or COSTCRUNCH_API_KEY
85
+ # cc = CostCrunch(api_key="cc_live_...") # or pass a key from Settings › API
86
+ for d in cc.documents.list(status="needs_attention")["items"]:
87
+ print(d["date"], d["supplier"], d["total"], d["url"])
88
+
89
+ sheetrock = cc.materials.find("5/8 sheetrock")["items"][0]
90
+ history = cc.materials.history(sheetrock["id"], since="2026-01-01")
91
+ print(history["summary"]["avg_price_per_unit"], history["summary"]["unit"])
92
+ ```
93
+
94
+ For scripts and CI, use an API key (`COSTCRUNCH_API_KEY`) instead of signing in.
95
+ Errors raise `costcrunch.ApiError` (with `.status`, `.code`, `.message`), `NotFound`,
96
+ `PermissionDenied` or `AuthError`.
97
+
98
+ ## AI assistants (MCP)
99
+
100
+ The quickest setup is in CostCrunch under **Settings › Connections › API**: create a key and copy
101
+ the ready-made command for Claude Code, Claude Desktop or Cursor. They run the server with
102
+ [uv](https://docs.astral.sh/uv/), so there's nothing to install first:
103
+
104
+ ```sh
105
+ claude mcp add costcrunch --env COSTCRUNCH_API_KEY=cc_live_... -- uvx --from costcrunch cost mcp
106
+ ```
107
+
108
+ If you've installed the CLI and signed in, it can print the setup for your own sign-in instead:
109
+
110
+ ```sh
111
+ cost mcp setup claude-code # prints: claude mcp add costcrunch -- /path/to/cost mcp
112
+ cost mcp setup claude-desktop # prints the JSON for claude_desktop_config.json
113
+ cost mcp setup cursor
114
+ ```
115
+
116
+ The server runs on your computer (`cost mcp`, over stdio), as you or as your API key. It exposes 20
117
+ read-only tools such as `search_all`, `documents_list`, `materials_find`, `materials_history`,
118
+ `materials_prices`, `projects_get` and `spend_monthly`, and every result carries a link back
119
+ into the CostCrunch app.
120
+
121
+ ## Security
122
+
123
+ * You sign in as yourself. The API decides which company's data you can see from your
124
+ membership; the `--org` option only chooses between companies you already belong to.
125
+ * This package can't change anything: there are no write operations in this version, and the
126
+ API refuses anything but reads from an API key.
127
+ * Only a hash of each API key is stored by CostCrunch; the key is shown once. Revoking it in
128
+ Settings › Connections › API stops it within a minute.
129
+ * Tokens are stored with `0600` permissions and refreshed automatically. The public Supabase
130
+ key embedded in the package is the same one the web app ships with.
131
+
132
+ ## Developing
133
+
134
+ ```sh
135
+ uv venv && uv pip install -e '.[dev]'
136
+ pytest && ruff check src tests
137
+ ```
138
+
139
+ Each operation lives in `src/costcrunch/ops/<group>.py`:
140
+
141
+ ```python
142
+ @op("suppliers", "get")
143
+ def get_supplier(api, supplier_id: Annotated[str, Arg("Supplier id", positional=True)]):
144
+ """Get one supplier: spend, branches and sales rep."""
145
+ return pick(api.get(f"/suppliers/{supplier_id}")["supplier"], {...})
146
+ ```
147
+
148
+ That one function becomes `cc.suppliers.get(...)`, `cost suppliers get <id>` and the MCP
149
+ tool `suppliers_get`. The docstring is the help text and tool description. Its first sentence
150
+ is the short summary, so write it for a customer. Results are reshaped with `pick` so internal
151
+ fields (storage paths, bounding boxes, flags) never leak out and the output doesn't change when
152
+ the web app's payloads do. `tests/test_registry.py` enforces the conventions.
153
+
154
+ Writes are supported by the registry (`@op(..., write=True)`). The CLI asks for confirmation
155
+ (or `--yes`), Python needs `confirm=True`, and the MCP tool requires a verbatim
156
+ `user_confirmation`. None are exposed yet.
@@ -0,0 +1,128 @@
1
+ # CostCrunch for Python, the terminal and AI assistants
2
+
3
+ One package, three ways in to your CostCrunch data:
4
+
5
+ | | |
6
+ |---|---|
7
+ | **CLI** | `cost documents list --status needs_attention` |
8
+ | **Python** | `CostCrunch().materials.history(material_id)` |
9
+ | **MCP** | Ask Claude, Cursor or any MCP client *"what did we pay for 5/8 sheetrock last month, and is that a good price?"* |
10
+
11
+ All three are generated from the same list of operations, so they always match. Everything is
12
+ read-only and scoped to your company: you see exactly what you see in the CostCrunch app.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ uv tool install costcrunch # or: pipx install costcrunch
18
+ cost auth login
19
+ ```
20
+
21
+ This opens your browser to sign in with Google. For an email + password account, use
22
+ `cost auth login --email you@company.com`. Your session is stored in `~/.config/costcrunch/credentials.json`,
23
+ readable only by you. `cost auth logout` removes it.
24
+
25
+ Or use an **API key**: create one in CostCrunch under **Settings › Connections › API**, then
26
+ `cost auth login --api-key` (it asks for the key), or set `COSTCRUNCH_API_KEY`. A key belongs to
27
+ one company, can only read, and works until you revoke it on that page.
28
+
29
+ If you belong to more than one company: `cost account orgs`, then
30
+ `cost account use-org <id>` (or pass `--org <id>` to any command).
31
+
32
+ ## CLI
33
+
34
+ ```sh
35
+ cost search all "tri-state" # anything: documents, suppliers, materials...
36
+ cost documents list --type invoice --since 2026-10-01
37
+ cost documents get <document-id> # header + line items + price checks
38
+ cost suppliers list
39
+ cost suppliers materials <supplier-id> --search sheetrock
40
+ cost projects get <project-id> # spend by supplier, month, cost type
41
+ cost materials find "5/8 sheetrock" # by meaning, not just words
42
+ cost materials history <material-id> --since 2026-01-01
43
+ cost materials prices <material-id> # your history vs other suppliers vs online
44
+ cost spend monthly
45
+ ```
46
+
47
+ Add `--json` to any command for machine-readable output, or `--csv` on list commands for a
48
+ spreadsheet: `cost --csv suppliers list > suppliers.csv`. `cost <group> --help`
49
+ lists every command and option.
50
+
51
+ ## Python
52
+
53
+ ```python
54
+ from costcrunch import CostCrunch
55
+
56
+ cc = CostCrunch() # your `cost auth login`, or COSTCRUNCH_API_KEY
57
+ # cc = CostCrunch(api_key="cc_live_...") # or pass a key from Settings › API
58
+ for d in cc.documents.list(status="needs_attention")["items"]:
59
+ print(d["date"], d["supplier"], d["total"], d["url"])
60
+
61
+ sheetrock = cc.materials.find("5/8 sheetrock")["items"][0]
62
+ history = cc.materials.history(sheetrock["id"], since="2026-01-01")
63
+ print(history["summary"]["avg_price_per_unit"], history["summary"]["unit"])
64
+ ```
65
+
66
+ For scripts and CI, use an API key (`COSTCRUNCH_API_KEY`) instead of signing in.
67
+ Errors raise `costcrunch.ApiError` (with `.status`, `.code`, `.message`), `NotFound`,
68
+ `PermissionDenied` or `AuthError`.
69
+
70
+ ## AI assistants (MCP)
71
+
72
+ The quickest setup is in CostCrunch under **Settings › Connections › API**: create a key and copy
73
+ the ready-made command for Claude Code, Claude Desktop or Cursor. They run the server with
74
+ [uv](https://docs.astral.sh/uv/), so there's nothing to install first:
75
+
76
+ ```sh
77
+ claude mcp add costcrunch --env COSTCRUNCH_API_KEY=cc_live_... -- uvx --from costcrunch cost mcp
78
+ ```
79
+
80
+ If you've installed the CLI and signed in, it can print the setup for your own sign-in instead:
81
+
82
+ ```sh
83
+ cost mcp setup claude-code # prints: claude mcp add costcrunch -- /path/to/cost mcp
84
+ cost mcp setup claude-desktop # prints the JSON for claude_desktop_config.json
85
+ cost mcp setup cursor
86
+ ```
87
+
88
+ The server runs on your computer (`cost mcp`, over stdio), as you or as your API key. It exposes 20
89
+ read-only tools such as `search_all`, `documents_list`, `materials_find`, `materials_history`,
90
+ `materials_prices`, `projects_get` and `spend_monthly`, and every result carries a link back
91
+ into the CostCrunch app.
92
+
93
+ ## Security
94
+
95
+ * You sign in as yourself. The API decides which company's data you can see from your
96
+ membership; the `--org` option only chooses between companies you already belong to.
97
+ * This package can't change anything: there are no write operations in this version, and the
98
+ API refuses anything but reads from an API key.
99
+ * Only a hash of each API key is stored by CostCrunch; the key is shown once. Revoking it in
100
+ Settings › Connections › API stops it within a minute.
101
+ * Tokens are stored with `0600` permissions and refreshed automatically. The public Supabase
102
+ key embedded in the package is the same one the web app ships with.
103
+
104
+ ## Developing
105
+
106
+ ```sh
107
+ uv venv && uv pip install -e '.[dev]'
108
+ pytest && ruff check src tests
109
+ ```
110
+
111
+ Each operation lives in `src/costcrunch/ops/<group>.py`:
112
+
113
+ ```python
114
+ @op("suppliers", "get")
115
+ def get_supplier(api, supplier_id: Annotated[str, Arg("Supplier id", positional=True)]):
116
+ """Get one supplier: spend, branches and sales rep."""
117
+ return pick(api.get(f"/suppliers/{supplier_id}")["supplier"], {...})
118
+ ```
119
+
120
+ That one function becomes `cc.suppliers.get(...)`, `cost suppliers get <id>` and the MCP
121
+ tool `suppliers_get`. The docstring is the help text and tool description. Its first sentence
122
+ is the short summary, so write it for a customer. Results are reshaped with `pick` so internal
123
+ fields (storage paths, bounding boxes, flags) never leak out and the output doesn't change when
124
+ the web app's payloads do. `tests/test_registry.py` enforces the conventions.
125
+
126
+ Writes are supported by the registry (`@op(..., write=True)`). The CLI asks for confirmation
127
+ (or `--yes`), Python needs `confirm=True`, and the MCP tool requires a verbatim
128
+ `user_confirmation`. None are exposed yet.
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "costcrunch"
7
+ description = "CostCrunch from Python, your terminal, and AI assistants (MCP)."
8
+ readme = "README.md"
9
+ requires-python = ">=3.10"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "CostCrunch", email = "support@costcrunch.ai" }]
13
+ dynamic = ["version"]
14
+ keywords = ["costcrunch", "construction", "invoices", "procurement", "mcp", "cli"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: End Users/Desktop",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ ]
25
+ dependencies = [
26
+ "httpx>=0.27,<1",
27
+ "typer>=0.12",
28
+ "rich>=13",
29
+ "mcp>=2.3,<3",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest>=8", "ruff>=0.6"]
34
+
35
+ [project.scripts]
36
+ cost = "costcrunch.cli:main"
37
+
38
+ [project.urls]
39
+ Homepage = "https://costcrunch.ai"
40
+ Documentation = "https://support.costcrunch.ai"
41
+
42
+ [tool.hatch.version]
43
+ path = "src/costcrunch/_version.py"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/costcrunch"]
47
+
48
+ [tool.ruff]
49
+ line-length = 110
50
+ target-version = "py310"
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
54
+
55
+ [tool.ruff.lint]
56
+ select = ["E", "F", "B", "I"]
57
+ ignore = ["B008"]
@@ -0,0 +1,7 @@
1
+ """CostCrunch: your construction purchasing data from Python, the terminal, or an AI assistant."""
2
+ from costcrunch._version import __version__
3
+ from costcrunch.client import CostCrunch
4
+ from costcrunch.errors import ApiError, AuthError, CostCrunchError, NotFound, PermissionDenied, UsageError
5
+
6
+ __all__ = ["CostCrunch", "ApiError", "AuthError", "CostCrunchError", "NotFound",
7
+ "PermissionDenied", "UsageError", "__version__"]
@@ -0,0 +1,3 @@
1
+ from costcrunch.cli import main
2
+
3
+ main()
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,207 @@
1
+ """Signing in to CostCrunch.
2
+
3
+ A CostCrunch API call is authenticated by the same Supabase session the web app uses: a
4
+ short-lived access token (Bearer) plus a refresh token. We keep both in the saved profile and
5
+ refresh the access token a few minutes before it expires.
6
+
7
+ Ways to get a session:
8
+
9
+ * password email + password (accounts created with a password)
10
+ * browser opens the CostCrunch sign-in in the browser (Google and other providers), and
11
+ catches the redirect on a localhost port (OAuth PKCE)
12
+ * api key a cc_live_ key from Settings › Connections › API (COSTCRUNCH_API_KEY, or saved by
13
+ `cost auth login --api-key`). Read-only, tied to one company, never expires
14
+ until revoked. What the MCP server setup on that page uses.
15
+ * token COSTCRUNCH_TOKEN, a raw access token, for CI and one-off scripts (no refresh)
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import hashlib
21
+ import http.server
22
+ import json
23
+ import os
24
+ import secrets
25
+ import socketserver
26
+ import threading
27
+ import time
28
+ import urllib.parse
29
+ import webbrowser
30
+ from typing import Callable, Optional
31
+
32
+ import httpx
33
+
34
+ from costcrunch import config
35
+ from costcrunch.errors import AuthError
36
+
37
+ REFRESH_MARGIN_S = 300
38
+ BROWSER_TIMEOUT_S = 300
39
+ # Supabase only redirects to allow-listed URLs (anything else lands on the web app instead), so
40
+ # browser sign-in listens on one fixed port: http://localhost:8976/** is on the allow list.
41
+ BROWSER_PORT = 8976
42
+
43
+
44
+ def _anon(profile: config.Profile) -> str:
45
+ return profile.anon_key or config.DEFAULT_AUTH_ANON_KEY
46
+
47
+
48
+ def _token_call(profile: config.Profile, grant_type: str, body: dict) -> dict:
49
+ try:
50
+ r = httpx.post(f"{profile.auth_url.rstrip('/')}/auth/v1/token",
51
+ params={"grant_type": grant_type},
52
+ headers={"apikey": _anon(profile)}, json=body, timeout=30)
53
+ except httpx.HTTPError as e:
54
+ raise AuthError(f"could not reach the CostCrunch sign-in service: {e}") from e
55
+ if r.status_code >= 400:
56
+ try:
57
+ j = r.json()
58
+ msg = j.get("error_description") or j.get("msg") or j.get("message") or r.text
59
+ except ValueError:
60
+ msg = r.text
61
+ raise AuthError(f"sign-in failed: {msg}")
62
+ return r.json()
63
+
64
+
65
+ def _apply_session(profile: config.Profile, s: dict) -> config.Profile:
66
+ profile.access_token = s["access_token"]
67
+ profile.refresh_token = s.get("refresh_token") or profile.refresh_token
68
+ profile.expires_at = time.time() + int(s.get("expires_in", 3600))
69
+ user = s.get("user") or {}
70
+ profile.email = user.get("email") or profile.email
71
+ return profile
72
+
73
+
74
+ def login_password(profile: config.Profile, email: str, password: str) -> config.Profile:
75
+ return _apply_session(profile, _token_call(profile, "password",
76
+ {"email": email, "password": password}))
77
+
78
+
79
+ def refresh(profile: config.Profile) -> config.Profile:
80
+ if not profile.refresh_token:
81
+ raise AuthError("this sign-in can't be refreshed; run `cost auth login` again")
82
+ return _apply_session(profile, _token_call(profile, "refresh_token",
83
+ {"refresh_token": profile.refresh_token}))
84
+
85
+
86
+ # --- browser sign-in (OAuth PKCE with a localhost redirect) --------------------
87
+
88
+ _DONE_PAGE = (b"<html><body style='font-family:system-ui;text-align:center;padding-top:4em'>"
89
+ b"<h2>You're signed in to CostCrunch.</h2><p>You can close this tab and go back "
90
+ b"to your terminal.</p></body></html>")
91
+
92
+
93
+ def login_browser(profile: config.Profile, provider: str = "google", port: int = BROWSER_PORT,
94
+ open_browser: Callable[[str], bool] = webbrowser.open,
95
+ on_url: Optional[Callable[[str], None]] = None) -> config.Profile:
96
+ """Open the provider's sign-in page and wait for Supabase to redirect back with a code.
97
+
98
+ The redirect URL (http://localhost:<port>/callback) must be on the Supabase project's
99
+ allowed redirect list."""
100
+ verifier = secrets.token_urlsafe(64)
101
+ challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
102
+ got: dict[str, str] = {}
103
+ ready = threading.Event()
104
+
105
+ class Handler(http.server.BaseHTTPRequestHandler):
106
+ def do_GET(self): # noqa: N802
107
+ q = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
108
+ if "code" in q or "error" in q:
109
+ got.update({k: v[0] for k, v in q.items()})
110
+ ready.set()
111
+ self.send_response(200)
112
+ self.send_header("Content-Type", "text/html")
113
+ self.end_headers()
114
+ self.wfile.write(_DONE_PAGE)
115
+
116
+ def log_message(self, *a): # silence the default stderr access log
117
+ pass
118
+
119
+ try:
120
+ srv = socketserver.TCPServer(("127.0.0.1", port), Handler)
121
+ except OSError as e:
122
+ raise AuthError(f"port {port} is busy, so the browser sign-in can't listen for the "
123
+ "redirect; sign in with --email instead") from e
124
+ with srv:
125
+ redirect = f"http://localhost:{srv.server_address[1]}/callback"
126
+ url = (f"{profile.auth_url.rstrip('/')}/auth/v1/authorize?" + urllib.parse.urlencode({
127
+ "provider": provider, "redirect_to": redirect,
128
+ "code_challenge": challenge, "code_challenge_method": "s256"}))
129
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
130
+ if on_url:
131
+ on_url(url)
132
+ open_browser(url)
133
+ finished = ready.wait(BROWSER_TIMEOUT_S)
134
+ srv.shutdown()
135
+ if not finished:
136
+ raise AuthError("timed out waiting for the browser sign-in")
137
+ if "error" in got:
138
+ raise AuthError(f"sign-in was not completed: {got.get('error_description') or got['error']}")
139
+ return _apply_session(profile, _token_call(profile, "pkce", {
140
+ "auth_code": got["code"], "code_verifier": verifier}))
141
+
142
+
143
+ # --- the token the client actually sends ---------------------------------------
144
+
145
+ def jwt_claims(token: str) -> dict:
146
+ """Unverified claims of an access token (the API verifies it; we only read sub/email/exp)."""
147
+ try:
148
+ payload = token.split(".")[1]
149
+ return json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
150
+ except Exception: # noqa: BLE001
151
+ return {}
152
+
153
+
154
+ API_KEY_PREFIX = "cc_live_"
155
+
156
+
157
+ def is_api_key(token: Optional[str]) -> bool:
158
+ return bool(token) and token.startswith(API_KEY_PREFIX)
159
+
160
+
161
+ class TokenSource:
162
+ """Hands out what goes in `Authorization: Bearer`: an API key, a fixed access token, or the
163
+ saved sign-in's access token (refreshed as needed).
164
+
165
+ Precedence: explicit api_key / token > COSTCRUNCH_API_KEY > COSTCRUNCH_TOKEN > saved profile
166
+ (its API key, else its session)."""
167
+
168
+ def __init__(self, token: Optional[str] = None, profile_name: Optional[str] = None,
169
+ api_key: Optional[str] = None):
170
+ self._lock = threading.Lock()
171
+ self._fixed = (api_key or token or os.environ.get(config.ENV_API_KEY)
172
+ or os.environ.get(config.ENV_TOKEN))
173
+ self._profile_name = profile_name
174
+ self._profile: Optional[config.Profile] = None if self._fixed else config.load(profile_name)
175
+ if self._profile is not None and self._profile.api_key:
176
+ self._fixed = self._profile.api_key
177
+
178
+ @property
179
+ def profile(self) -> Optional[config.Profile]:
180
+ return self._profile
181
+
182
+ @property
183
+ def uses_api_key(self) -> bool:
184
+ return is_api_key(self._fixed)
185
+
186
+ def token(self) -> str:
187
+ if self._fixed:
188
+ return self._fixed
189
+ with self._lock:
190
+ p = self._profile
191
+ if p is None or not p.access_token:
192
+ raise AuthError("not signed in: run `cost auth login` "
193
+ f"(or set {config.ENV_API_KEY})")
194
+ if time.time() > p.expires_at - REFRESH_MARGIN_S:
195
+ refresh(p)
196
+ config.save(p, self._profile_name)
197
+ return p.access_token
198
+
199
+ def force_refresh(self) -> bool:
200
+ """After a 401: try once to get a fresh token. False when there is nothing to refresh
201
+ (an API key or a fixed token)."""
202
+ if self._fixed or self._profile is None or not self._profile.refresh_token:
203
+ return False
204
+ with self._lock:
205
+ refresh(self._profile)
206
+ config.save(self._profile, self._profile_name)
207
+ return True