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.
- costcrunch-0.1.0/.github/workflows/ci.yml +19 -0
- costcrunch-0.1.0/.github/workflows/release.yml +38 -0
- costcrunch-0.1.0/.gitignore +8 -0
- costcrunch-0.1.0/LICENSE +21 -0
- costcrunch-0.1.0/PKG-INFO +156 -0
- costcrunch-0.1.0/README.md +128 -0
- costcrunch-0.1.0/pyproject.toml +57 -0
- costcrunch-0.1.0/src/costcrunch/__init__.py +7 -0
- costcrunch-0.1.0/src/costcrunch/__main__.py +3 -0
- costcrunch-0.1.0/src/costcrunch/_version.py +1 -0
- costcrunch-0.1.0/src/costcrunch/auth.py +207 -0
- costcrunch-0.1.0/src/costcrunch/cli.py +319 -0
- costcrunch-0.1.0/src/costcrunch/client.py +212 -0
- costcrunch-0.1.0/src/costcrunch/config.py +103 -0
- costcrunch-0.1.0/src/costcrunch/errors.py +45 -0
- costcrunch-0.1.0/src/costcrunch/mcp_server.py +112 -0
- costcrunch-0.1.0/src/costcrunch/ops/__init__.py +6 -0
- costcrunch-0.1.0/src/costcrunch/ops/_shape.py +51 -0
- costcrunch-0.1.0/src/costcrunch/ops/account.py +33 -0
- costcrunch-0.1.0/src/costcrunch/ops/documents.py +162 -0
- costcrunch-0.1.0/src/costcrunch/ops/materials.py +146 -0
- costcrunch-0.1.0/src/costcrunch/ops/projects.py +60 -0
- costcrunch-0.1.0/src/costcrunch/ops/search.py +40 -0
- costcrunch-0.1.0/src/costcrunch/ops/spend.py +26 -0
- costcrunch-0.1.0/src/costcrunch/ops/suppliers.py +83 -0
- costcrunch-0.1.0/src/costcrunch/output.py +177 -0
- costcrunch-0.1.0/src/costcrunch/registry.py +138 -0
- costcrunch-0.1.0/tests/__init__.py +0 -0
- costcrunch-0.1.0/tests/conftest.py +54 -0
- costcrunch-0.1.0/tests/test_api_keys.py +62 -0
- costcrunch-0.1.0/tests/test_client.py +111 -0
- costcrunch-0.1.0/tests/test_registry.py +50 -0
- 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
|
costcrunch-0.1.0/LICENSE
ADDED
|
@@ -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 @@
|
|
|
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
|