recon-agent 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.
- recon_agent-0.1.0/.gitignore +12 -0
- recon_agent-0.1.0/PKG-INFO +153 -0
- recon_agent-0.1.0/README.md +119 -0
- recon_agent-0.1.0/pyproject.toml +56 -0
- recon_agent-0.1.0/recon_cli/__init__.py +0 -0
- recon_agent-0.1.0/recon_cli/auth.py +127 -0
- recon_agent-0.1.0/recon_cli/client.py +107 -0
- recon_agent-0.1.0/recon_cli/commands/__init__.py +0 -0
- recon_agent-0.1.0/recon_cli/commands/auth.py +100 -0
- recon_agent-0.1.0/recon_cli/commands/explain.py +101 -0
- recon_agent-0.1.0/recon_cli/commands/export.py +89 -0
- recon_agent-0.1.0/recon_cli/commands/history.py +112 -0
- recon_agent-0.1.0/recon_cli/commands/reconcile.py +129 -0
- recon_agent-0.1.0/recon_cli/config.py +35 -0
- recon_agent-0.1.0/recon_cli/main.py +37 -0
- recon_agent-0.1.0/recon_cli/output.py +38 -0
- recon_agent-0.1.0/web/README.md +36 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: recon-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI-powered reconciliation agent for finance.
|
|
5
|
+
Author-email: Abtimist <abhishek@abtimist.com>
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Requires-Dist: keyring>=25.0
|
|
9
|
+
Requires-Dist: pydantic>=2.0
|
|
10
|
+
Requires-Dist: rich>=13.7
|
|
11
|
+
Requires-Dist: typer>=0.12
|
|
12
|
+
Provides-Extra: backend
|
|
13
|
+
Requires-Dist: cryptography>=42.0; extra == 'backend'
|
|
14
|
+
Requires-Dist: faker>=20.0; extra == 'backend'
|
|
15
|
+
Requires-Dist: fastapi>=0.110; extra == 'backend'
|
|
16
|
+
Requires-Dist: google-genai>=0.2; extra == 'backend'
|
|
17
|
+
Requires-Dist: openai>=1.0; extra == 'backend'
|
|
18
|
+
Requires-Dist: openpyxl>=3.1; extra == 'backend'
|
|
19
|
+
Requires-Dist: pandas>=2.0; extra == 'backend'
|
|
20
|
+
Requires-Dist: psycopg2-binary>=2.9.9; extra == 'backend'
|
|
21
|
+
Requires-Dist: python-dateutil>=2.8; extra == 'backend'
|
|
22
|
+
Requires-Dist: python-dotenv>=1.0.0; extra == 'backend'
|
|
23
|
+
Requires-Dist: python-jose[cryptography]>=3.3; extra == 'backend'
|
|
24
|
+
Requires-Dist: python-multipart>=0.0.9; extra == 'backend'
|
|
25
|
+
Requires-Dist: rapidfuzz>=3.0; extra == 'backend'
|
|
26
|
+
Requires-Dist: reportlab>=4.0; extra == 'backend'
|
|
27
|
+
Requires-Dist: supabase>=2.0; extra == 'backend'
|
|
28
|
+
Requires-Dist: uvicorn>=0.29; extra == 'backend'
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# Recon Agent — Multi-Tenant SaaS
|
|
36
|
+
|
|
37
|
+
AI-powered financial reconciliation as a multi-tenant SaaS.
|
|
38
|
+
|
|
39
|
+
## Architecture
|
|
40
|
+
|
|
41
|
+
- **Frontend**: Next.js 16 + Clerk auth (App Router)
|
|
42
|
+
- **Backend API**: FastAPI (Python)
|
|
43
|
+
- **Core Engine**: Pure Python reconciliation logic (`core/`)
|
|
44
|
+
- **Database**: Supabase PostgreSQL with RLS
|
|
45
|
+
- **Storage**: Supabase Storage (CSV/XLSX files)
|
|
46
|
+
|
|
47
|
+
The architecture flows strictly as:
|
|
48
|
+
`Web → FastAPI API → Core Engine → Database`
|
|
49
|
+
|
|
50
|
+
> [!NOTE]
|
|
51
|
+
> The Command Line Interface (`recon`) is a fully featured CLI that consumes the FastAPI REST API using Personal Access Tokens (PATs).
|
|
52
|
+
|
|
53
|
+
## Quick Start
|
|
54
|
+
|
|
55
|
+
### 1. Backend (FastAPI)
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
cd /home/abhishek/PROJECTS/recon-agent
|
|
59
|
+
.venv/bin/python -m uvicorn api.main:app --host 0.0.0.0 --port 8000 --reload
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Health check: `curl http://localhost:8000/health`
|
|
63
|
+
|
|
64
|
+
### 2. Frontend (Next.js)
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
cd /home/abhishek/PROJECTS/recon-agent/web
|
|
68
|
+
npm run dev
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Command Line Interface (CLI)
|
|
72
|
+
|
|
73
|
+
Recon Agent includes a powerful CLI that acts as a first-class client to the FastAPI backend. It allows you to automate reconciliations, view history, export reports, and generate AI explanations straight from the terminal.
|
|
74
|
+
|
|
75
|
+
### Installation
|
|
76
|
+
|
|
77
|
+
Install locally via pip or uv:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pip install -e .
|
|
81
|
+
# or
|
|
82
|
+
uv pip install -e .
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
This makes the `recon` command globally available.
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
recon --help
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Usage
|
|
92
|
+
|
|
93
|
+
1. **Login**: Generate a Personal Access Token in the Web UI, then run:
|
|
94
|
+
```bash
|
|
95
|
+
recon login
|
|
96
|
+
```
|
|
97
|
+
2. **Reconcile**:
|
|
98
|
+
```bash
|
|
99
|
+
recon reconcile source.csv target.csv
|
|
100
|
+
```
|
|
101
|
+
3. **History**:
|
|
102
|
+
```bash
|
|
103
|
+
recon history
|
|
104
|
+
```
|
|
105
|
+
4. **Machine Readable output**:
|
|
106
|
+
```bash
|
|
107
|
+
recon history --json
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Documentation
|
|
111
|
+
|
|
112
|
+
- [Docs Directory](docs/)
|
|
113
|
+
- [Phase 1: Feature 1–7 Audit](docs/platform-integration-audit.md)
|
|
114
|
+
- [Phase 2: Platform Foundations](docs/phase-2-foundations-report.md)
|
|
115
|
+
- [Phase 3: API Authentication](docs/api-authentication.md)
|
|
116
|
+
- [Phase 4: CLI Documentation](docs/cli.md)
|
|
117
|
+
- [Phase 5: Tiers and Roles](docs/tiers-and-roles.md)
|
|
118
|
+
|
|
119
|
+
## Development
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
uv sync
|
|
123
|
+
```
|
|
124
|
+
Create `.env` (root) and `web/.env.local` with these values.
|
|
125
|
+
|
|
126
|
+
### Root `.env`
|
|
127
|
+
```
|
|
128
|
+
SUPABASE_URL=https://<project-ref>.supabase.co
|
|
129
|
+
SUPABASE_SERVICE_KEY=<service_role JWT> # NOT the publishable key!
|
|
130
|
+
CLERK_SECRET_KEY=sk_test_...
|
|
131
|
+
CLERK_ISSUER=https://<your-app>.clerk.accounts.dev
|
|
132
|
+
ENCRYPTION_KEY=<base64 32 bytes>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### `web/.env.local`
|
|
136
|
+
```
|
|
137
|
+
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Database Setup
|
|
141
|
+
|
|
142
|
+
Run the SQL in `api/migrations/001_initial_schema.sql` in the Supabase SQL editor.
|
|
143
|
+
|
|
144
|
+
This creates the tables and enables RLS policies.
|
|
145
|
+
|
|
146
|
+
## How multi-tenancy works
|
|
147
|
+
|
|
148
|
+
1. User signs in with Clerk (organizations enabled)
|
|
149
|
+
2. Clerk JWT is sent on every API request
|
|
150
|
+
3. Backend extracts `clerk_org_id` from JWT
|
|
151
|
+
4. Each DB query is scoped to that org — RLS enforces isolation as a backstop
|
|
152
|
+
|
|
153
|
+
See `api/migrations/001_initial_schema.sql` to `005_usage_quotas.sql` for the full schema.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Recon Agent — Multi-Tenant SaaS
|
|
2
|
+
|
|
3
|
+
AI-powered financial reconciliation as a multi-tenant SaaS.
|
|
4
|
+
|
|
5
|
+
## Architecture
|
|
6
|
+
|
|
7
|
+
- **Frontend**: Next.js 16 + Clerk auth (App Router)
|
|
8
|
+
- **Backend API**: FastAPI (Python)
|
|
9
|
+
- **Core Engine**: Pure Python reconciliation logic (`core/`)
|
|
10
|
+
- **Database**: Supabase PostgreSQL with RLS
|
|
11
|
+
- **Storage**: Supabase Storage (CSV/XLSX files)
|
|
12
|
+
|
|
13
|
+
The architecture flows strictly as:
|
|
14
|
+
`Web → FastAPI API → Core Engine → Database`
|
|
15
|
+
|
|
16
|
+
> [!NOTE]
|
|
17
|
+
> The Command Line Interface (`recon`) is a fully featured CLI that consumes the FastAPI REST API using Personal Access Tokens (PATs).
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
### 1. Backend (FastAPI)
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
cd /home/abhishek/PROJECTS/recon-agent
|
|
25
|
+
.venv/bin/python -m uvicorn api.main:app --host 0.0.0.0 --port 8000 --reload
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Health check: `curl http://localhost:8000/health`
|
|
29
|
+
|
|
30
|
+
### 2. Frontend (Next.js)
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
cd /home/abhishek/PROJECTS/recon-agent/web
|
|
34
|
+
npm run dev
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Command Line Interface (CLI)
|
|
38
|
+
|
|
39
|
+
Recon Agent includes a powerful CLI that acts as a first-class client to the FastAPI backend. It allows you to automate reconciliations, view history, export reports, and generate AI explanations straight from the terminal.
|
|
40
|
+
|
|
41
|
+
### Installation
|
|
42
|
+
|
|
43
|
+
Install locally via pip or uv:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install -e .
|
|
47
|
+
# or
|
|
48
|
+
uv pip install -e .
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
This makes the `recon` command globally available.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
recon --help
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Usage
|
|
58
|
+
|
|
59
|
+
1. **Login**: Generate a Personal Access Token in the Web UI, then run:
|
|
60
|
+
```bash
|
|
61
|
+
recon login
|
|
62
|
+
```
|
|
63
|
+
2. **Reconcile**:
|
|
64
|
+
```bash
|
|
65
|
+
recon reconcile source.csv target.csv
|
|
66
|
+
```
|
|
67
|
+
3. **History**:
|
|
68
|
+
```bash
|
|
69
|
+
recon history
|
|
70
|
+
```
|
|
71
|
+
4. **Machine Readable output**:
|
|
72
|
+
```bash
|
|
73
|
+
recon history --json
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Documentation
|
|
77
|
+
|
|
78
|
+
- [Docs Directory](docs/)
|
|
79
|
+
- [Phase 1: Feature 1–7 Audit](docs/platform-integration-audit.md)
|
|
80
|
+
- [Phase 2: Platform Foundations](docs/phase-2-foundations-report.md)
|
|
81
|
+
- [Phase 3: API Authentication](docs/api-authentication.md)
|
|
82
|
+
- [Phase 4: CLI Documentation](docs/cli.md)
|
|
83
|
+
- [Phase 5: Tiers and Roles](docs/tiers-and-roles.md)
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
uv sync
|
|
89
|
+
```
|
|
90
|
+
Create `.env` (root) and `web/.env.local` with these values.
|
|
91
|
+
|
|
92
|
+
### Root `.env`
|
|
93
|
+
```
|
|
94
|
+
SUPABASE_URL=https://<project-ref>.supabase.co
|
|
95
|
+
SUPABASE_SERVICE_KEY=<service_role JWT> # NOT the publishable key!
|
|
96
|
+
CLERK_SECRET_KEY=sk_test_...
|
|
97
|
+
CLERK_ISSUER=https://<your-app>.clerk.accounts.dev
|
|
98
|
+
ENCRYPTION_KEY=<base64 32 bytes>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### `web/.env.local`
|
|
102
|
+
```
|
|
103
|
+
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Database Setup
|
|
107
|
+
|
|
108
|
+
Run the SQL in `api/migrations/001_initial_schema.sql` in the Supabase SQL editor.
|
|
109
|
+
|
|
110
|
+
This creates the tables and enables RLS policies.
|
|
111
|
+
|
|
112
|
+
## How multi-tenancy works
|
|
113
|
+
|
|
114
|
+
1. User signs in with Clerk (organizations enabled)
|
|
115
|
+
2. Clerk JWT is sent on every API request
|
|
116
|
+
3. Backend extracts `clerk_org_id` from JWT
|
|
117
|
+
4. Each DB query is scoped to that org — RLS enforces isolation as a backstop
|
|
118
|
+
|
|
119
|
+
See `api/migrations/001_initial_schema.sql` to `005_usage_quotas.sql` for the full schema.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "recon-agent"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "AI-powered reconciliation agent for finance."
|
|
5
|
+
authors = [{name = "Abtimist", email = "abhishek@abtimist.com"}]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"httpx>=0.27",
|
|
10
|
+
"pydantic>=2.0",
|
|
11
|
+
"typer>=0.12",
|
|
12
|
+
"rich>=13.7",
|
|
13
|
+
"keyring>=25.0"
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
backend = [
|
|
18
|
+
"pandas>=2.0",
|
|
19
|
+
"rapidfuzz>=3.0",
|
|
20
|
+
"faker>=20.0",
|
|
21
|
+
"google-genai>=0.2",
|
|
22
|
+
"python-dateutil>=2.8",
|
|
23
|
+
"openai>=1.0",
|
|
24
|
+
"fastapi>=0.110",
|
|
25
|
+
"uvicorn>=0.29",
|
|
26
|
+
"supabase>=2.0",
|
|
27
|
+
"python-jose[cryptography]>=3.3",
|
|
28
|
+
"cryptography>=42.0",
|
|
29
|
+
"python-multipart>=0.0.9",
|
|
30
|
+
"openpyxl>=3.1",
|
|
31
|
+
"reportlab>=4.0",
|
|
32
|
+
"psycopg2-binary>=2.9.9",
|
|
33
|
+
"python-dotenv>=1.0.0",
|
|
34
|
+
]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=8.0",
|
|
37
|
+
"pytest-asyncio>=0.23",
|
|
38
|
+
"respx>=0.21"
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["hatchling"]
|
|
43
|
+
build-backend = "hatchling.build"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["recon_cli"]
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.sdist]
|
|
49
|
+
include = ["recon_cli", "README.md", "pyproject.toml"]
|
|
50
|
+
|
|
51
|
+
[project.scripts]
|
|
52
|
+
recon = "recon_cli.main:app"
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
testpaths = ["tests"]
|
|
56
|
+
pythonpath = ["."]
|
|
File without changes
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import keyring
|
|
4
|
+
import stat
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from recon_cli.config import CREDENTIALS_FILE, CONFIG_DIR
|
|
8
|
+
|
|
9
|
+
SERVICE_NAME = "recon-agent"
|
|
10
|
+
TOKEN_KEY = "pat"
|
|
11
|
+
|
|
12
|
+
def _is_keyring_available() -> bool:
|
|
13
|
+
try:
|
|
14
|
+
# Check if the active keyring is just the failing ChrootKeyring or similar empty ones
|
|
15
|
+
kr = keyring.get_keyring()
|
|
16
|
+
# Non-viable keyrings typically have "fail" in their name or class name, or it throws on get/set
|
|
17
|
+
if "fail" in str(kr).lower():
|
|
18
|
+
return False
|
|
19
|
+
return True
|
|
20
|
+
except Exception:
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
def _read_fallback_token() -> Optional[str]:
|
|
24
|
+
if not CREDENTIALS_FILE.exists():
|
|
25
|
+
return None
|
|
26
|
+
try:
|
|
27
|
+
# Ensure strict permissions before trusting it
|
|
28
|
+
st = os.stat(CREDENTIALS_FILE)
|
|
29
|
+
if st.st_mode & stat.S_IRWXO or st.st_mode & stat.S_IRWXG:
|
|
30
|
+
# File is accessible to group or others, which is insecure
|
|
31
|
+
print("Warning: ~/.recon/credentials.json has insecure permissions. It should be 600.")
|
|
32
|
+
|
|
33
|
+
with open(CREDENTIALS_FILE, "r") as f:
|
|
34
|
+
data = json.load(f)
|
|
35
|
+
return data.get(TOKEN_KEY)
|
|
36
|
+
except Exception:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
def _write_fallback_token(token: str):
|
|
40
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
|
|
42
|
+
# Touch the file securely if it doesn't exist
|
|
43
|
+
if not CREDENTIALS_FILE.exists():
|
|
44
|
+
open(CREDENTIALS_FILE, 'w').close()
|
|
45
|
+
|
|
46
|
+
# Force 600 permissions
|
|
47
|
+
os.chmod(CREDENTIALS_FILE, stat.S_IRUSR | stat.S_IWUSR)
|
|
48
|
+
|
|
49
|
+
data = {}
|
|
50
|
+
if CREDENTIALS_FILE.exists() and os.path.getsize(CREDENTIALS_FILE) > 0:
|
|
51
|
+
try:
|
|
52
|
+
with open(CREDENTIALS_FILE, "r") as f:
|
|
53
|
+
data = json.load(f)
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
data[TOKEN_KEY] = token
|
|
58
|
+
with open(CREDENTIALS_FILE, "w") as f:
|
|
59
|
+
json.dump(data, f)
|
|
60
|
+
|
|
61
|
+
def _delete_fallback_token():
|
|
62
|
+
if not CREDENTIALS_FILE.exists():
|
|
63
|
+
return
|
|
64
|
+
try:
|
|
65
|
+
with open(CREDENTIALS_FILE, "r") as f:
|
|
66
|
+
data = json.load(f)
|
|
67
|
+
if TOKEN_KEY in data:
|
|
68
|
+
del data[TOKEN_KEY]
|
|
69
|
+
with open(CREDENTIALS_FILE, "w") as f:
|
|
70
|
+
json.dump(data, f)
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
def get_token() -> Optional[str]:
|
|
75
|
+
"""
|
|
76
|
+
Get the Personal Access Token.
|
|
77
|
+
Checks environment variable first, then keyring, then fallback file.
|
|
78
|
+
"""
|
|
79
|
+
# 1. Environment Variable
|
|
80
|
+
env_token = os.environ.get("RECON_API_TOKEN")
|
|
81
|
+
if env_token:
|
|
82
|
+
return env_token
|
|
83
|
+
|
|
84
|
+
# 2. Keyring
|
|
85
|
+
if _is_keyring_available():
|
|
86
|
+
try:
|
|
87
|
+
token = keyring.get_password(SERVICE_NAME, "default_user")
|
|
88
|
+
if token:
|
|
89
|
+
return token
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
# 3. Fallback file
|
|
94
|
+
return _read_fallback_token()
|
|
95
|
+
|
|
96
|
+
def save_token(token: str) -> bool:
|
|
97
|
+
"""
|
|
98
|
+
Save the Personal Access Token.
|
|
99
|
+
Prefers keyring, falls back to secure file.
|
|
100
|
+
Returns True if keyring was used, False if fallback was used.
|
|
101
|
+
"""
|
|
102
|
+
if _is_keyring_available():
|
|
103
|
+
try:
|
|
104
|
+
keyring.set_password(SERVICE_NAME, "default_user", token)
|
|
105
|
+
# Make sure we clean up the fallback file if we successfully used keyring
|
|
106
|
+
_delete_fallback_token()
|
|
107
|
+
return True
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
# Fallback
|
|
112
|
+
_write_fallback_token(token)
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
def clear_token():
|
|
116
|
+
"""
|
|
117
|
+
Clear the saved Personal Access Token.
|
|
118
|
+
"""
|
|
119
|
+
if _is_keyring_available():
|
|
120
|
+
try:
|
|
121
|
+
keyring.delete_password(SERVICE_NAME, "default_user")
|
|
122
|
+
except keyring.errors.PasswordDeleteError:
|
|
123
|
+
pass
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
_delete_fallback_token()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
from typing import Optional, Any, Dict
|
|
3
|
+
from recon_cli.config import get_api_url
|
|
4
|
+
from recon_cli.auth import get_token
|
|
5
|
+
|
|
6
|
+
class ReconAPIError(Exception):
|
|
7
|
+
def __init__(self, message: str, status_code: Optional[int] = None, data: Any = None):
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
self.data = data
|
|
11
|
+
|
|
12
|
+
def _get_headers() -> Dict[str, str]:
|
|
13
|
+
token = get_token()
|
|
14
|
+
if not token:
|
|
15
|
+
raise ReconAPIError("Not authenticated. Please run 'recon login' first.")
|
|
16
|
+
return {
|
|
17
|
+
"Authorization": f"Bearer {token}"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
def _handle_response(response: httpx.Response) -> Any:
|
|
21
|
+
try:
|
|
22
|
+
response.raise_for_status()
|
|
23
|
+
if response.status_code == 204:
|
|
24
|
+
return None
|
|
25
|
+
return response.json()
|
|
26
|
+
except httpx.HTTPStatusError as e:
|
|
27
|
+
detail = "Unknown error"
|
|
28
|
+
try:
|
|
29
|
+
data = e.response.json()
|
|
30
|
+
if isinstance(data, dict) and "detail" in data:
|
|
31
|
+
detail = data["detail"]
|
|
32
|
+
except Exception:
|
|
33
|
+
detail = e.response.text
|
|
34
|
+
|
|
35
|
+
if e.response.status_code == 401:
|
|
36
|
+
raise ReconAPIError(
|
|
37
|
+
f"Authentication failed (401). Your token may be invalid or revoked. Detail: {detail}",
|
|
38
|
+
status_code=401
|
|
39
|
+
)
|
|
40
|
+
elif e.response.status_code == 403:
|
|
41
|
+
raise ReconAPIError(
|
|
42
|
+
f"Permission denied (403). You do not have access to this feature or role. Detail: {detail}",
|
|
43
|
+
status_code=403
|
|
44
|
+
)
|
|
45
|
+
elif e.response.status_code == 429:
|
|
46
|
+
raise ReconAPIError(
|
|
47
|
+
f"Quota Exceeded (429). Please upgrade your plan. Detail: {detail}",
|
|
48
|
+
status_code=429
|
|
49
|
+
)
|
|
50
|
+
elif e.response.status_code == 404:
|
|
51
|
+
raise ReconAPIError(
|
|
52
|
+
f"Not found (404). Detail: {detail}",
|
|
53
|
+
status_code=404
|
|
54
|
+
)
|
|
55
|
+
else:
|
|
56
|
+
raise ReconAPIError(
|
|
57
|
+
f"API Error ({e.response.status_code}): {detail}",
|
|
58
|
+
status_code=e.response.status_code
|
|
59
|
+
)
|
|
60
|
+
except httpx.RequestError as e:
|
|
61
|
+
raise ReconAPIError(f"Network error while connecting to API: {str(e)}")
|
|
62
|
+
|
|
63
|
+
def get_client() -> httpx.Client:
|
|
64
|
+
"""Returns an authenticated httpx client configured with the base URL."""
|
|
65
|
+
base_url = get_api_url()
|
|
66
|
+
try:
|
|
67
|
+
headers = _get_headers()
|
|
68
|
+
except ReconAPIError:
|
|
69
|
+
# If we just want a client without auth (e.g. for login check),
|
|
70
|
+
# we can handle that separately, but for now most commands need auth.
|
|
71
|
+
headers = {}
|
|
72
|
+
|
|
73
|
+
return httpx.Client(
|
|
74
|
+
base_url=base_url,
|
|
75
|
+
headers=headers,
|
|
76
|
+
timeout=30.0
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def api_get(endpoint: str, params: Optional[Dict] = None) -> Any:
|
|
80
|
+
base_url = get_api_url()
|
|
81
|
+
headers = _get_headers()
|
|
82
|
+
try:
|
|
83
|
+
with httpx.Client(base_url=base_url, headers=headers, timeout=30.0) as client:
|
|
84
|
+
response = client.get(endpoint, params=params)
|
|
85
|
+
return _handle_response(response)
|
|
86
|
+
except httpx.RequestError as e:
|
|
87
|
+
raise ReconAPIError(f"Network error while connecting to API: {str(e)}")
|
|
88
|
+
|
|
89
|
+
def api_post(endpoint: str, json: Optional[Dict] = None, data: Optional[Dict] = None, files: Optional[Dict] = None, timeout: float = 30.0) -> Any:
|
|
90
|
+
base_url = get_api_url()
|
|
91
|
+
headers = _get_headers()
|
|
92
|
+
try:
|
|
93
|
+
with httpx.Client(base_url=base_url, headers=headers, timeout=timeout) as client:
|
|
94
|
+
response = client.post(endpoint, json=json, data=data, files=files)
|
|
95
|
+
return _handle_response(response)
|
|
96
|
+
except httpx.RequestError as e:
|
|
97
|
+
raise ReconAPIError(f"Network error while connecting to API: {str(e)}")
|
|
98
|
+
|
|
99
|
+
def api_delete(endpoint: str) -> Any:
|
|
100
|
+
base_url = get_api_url()
|
|
101
|
+
headers = _get_headers()
|
|
102
|
+
try:
|
|
103
|
+
with httpx.Client(base_url=base_url, headers=headers, timeout=30.0) as client:
|
|
104
|
+
response = client.delete(endpoint)
|
|
105
|
+
return _handle_response(response)
|
|
106
|
+
except httpx.RequestError as e:
|
|
107
|
+
raise ReconAPIError(f"Network error while connecting to API: {str(e)}")
|
|
File without changes
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import httpx
|
|
3
|
+
from rich.prompt import Prompt
|
|
4
|
+
from recon_cli.auth import save_token, clear_token, get_token
|
|
5
|
+
from recon_cli.config import save_config, get_api_url
|
|
6
|
+
from recon_cli.client import ReconAPIError, api_get
|
|
7
|
+
from recon_cli.output import print_error, print_success, print_info, print_json
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(help="Authentication and session management.")
|
|
10
|
+
|
|
11
|
+
@app.command()
|
|
12
|
+
def login(
|
|
13
|
+
base_url: str = typer.Option(None, "--url", help="API Base URL"),
|
|
14
|
+
token: str = typer.Option(None, "--token", help="Personal Access Token")
|
|
15
|
+
):
|
|
16
|
+
"""
|
|
17
|
+
Log in to a Recon Agent instance using a Personal Access Token.
|
|
18
|
+
"""
|
|
19
|
+
if not base_url:
|
|
20
|
+
current_url = get_api_url()
|
|
21
|
+
base_url = Prompt.ask("API Base URL", default=current_url)
|
|
22
|
+
|
|
23
|
+
if not token:
|
|
24
|
+
token = Prompt.ask("Personal Access Token (ra_live_...)", password=True)
|
|
25
|
+
|
|
26
|
+
if not token.startswith("ra_live_"):
|
|
27
|
+
print_error("Invalid token format. It should start with 'ra_live_'")
|
|
28
|
+
raise typer.Exit(1)
|
|
29
|
+
|
|
30
|
+
# Save the config
|
|
31
|
+
save_config({"api_base_url": base_url.rstrip("/")})
|
|
32
|
+
|
|
33
|
+
# Save the token
|
|
34
|
+
used_keyring = save_token(token)
|
|
35
|
+
|
|
36
|
+
if used_keyring:
|
|
37
|
+
print_info("Token saved securely in OS keychain.")
|
|
38
|
+
else:
|
|
39
|
+
print_info("Token saved in ~/.recon/credentials.json (OS keychain unavailable).")
|
|
40
|
+
|
|
41
|
+
# Validate the token
|
|
42
|
+
try:
|
|
43
|
+
response = api_get("/auth/status")
|
|
44
|
+
print_success(f"Logged in successfully as {response.get('clerk_user_id', 'unknown')}")
|
|
45
|
+
except ReconAPIError as e:
|
|
46
|
+
print_error(f"Login failed: {e}")
|
|
47
|
+
clear_token()
|
|
48
|
+
raise typer.Exit(1)
|
|
49
|
+
except Exception as e:
|
|
50
|
+
print_error(f"Connection failed: {e}")
|
|
51
|
+
clear_token()
|
|
52
|
+
raise typer.Exit(1)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command()
|
|
56
|
+
def logout():
|
|
57
|
+
"""
|
|
58
|
+
Log out and clear the stored token.
|
|
59
|
+
"""
|
|
60
|
+
clear_token()
|
|
61
|
+
print_success("Logged out successfully.")
|
|
62
|
+
|
|
63
|
+
@app.command()
|
|
64
|
+
def whoami(
|
|
65
|
+
ctx: typer.Context,
|
|
66
|
+
):
|
|
67
|
+
"""
|
|
68
|
+
Check current authentication status.
|
|
69
|
+
"""
|
|
70
|
+
token = get_token()
|
|
71
|
+
if not token:
|
|
72
|
+
if ctx.obj.get("json", False):
|
|
73
|
+
print_json({"authenticated": False, "error": "No token found"})
|
|
74
|
+
else:
|
|
75
|
+
print_error("Not logged in. Run 'recon auth login' first.")
|
|
76
|
+
raise typer.Exit(1)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
data = api_get("/auth/status")
|
|
80
|
+
if ctx.obj.get("json", False):
|
|
81
|
+
print_json({"authenticated": True, "data": data})
|
|
82
|
+
else:
|
|
83
|
+
print_info(f"Authenticated as User ID: {data.get('clerk_user_id')}")
|
|
84
|
+
if data.get("org_id"):
|
|
85
|
+
print_info(f"Organization ID: {data.get('org_id')}")
|
|
86
|
+
print_info(f"Scopes: {', '.join(data.get('scopes', []))}")
|
|
87
|
+
if not data.get("is_pat"):
|
|
88
|
+
print_info("Note: Authenticated via Clerk JWT (browser token) instead of PAT.")
|
|
89
|
+
except ReconAPIError as e:
|
|
90
|
+
if ctx.obj.get("json", False):
|
|
91
|
+
print_json({"authenticated": False, "error": str(e)})
|
|
92
|
+
else:
|
|
93
|
+
print_error(f"Authentication check failed: {e}")
|
|
94
|
+
raise typer.Exit(1)
|
|
95
|
+
|
|
96
|
+
# Alias status to whoami
|
|
97
|
+
@app.command()
|
|
98
|
+
def status(ctx: typer.Context):
|
|
99
|
+
"""Alias for whoami."""
|
|
100
|
+
whoami(ctx)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from recon_cli.client import api_post, ReconAPIError
|
|
3
|
+
from recon_cli.output import print_error, print_json, print_markdown, console
|
|
4
|
+
|
|
5
|
+
app = typer.Typer(help="Generate an AI-powered CFO Explanation for a reconciliation result.")
|
|
6
|
+
|
|
7
|
+
@app.callback(invoke_without_command=True)
|
|
8
|
+
def explain(
|
|
9
|
+
ctx: typer.Context,
|
|
10
|
+
run_id: str = typer.Argument(..., help="Run ID to explain")
|
|
11
|
+
):
|
|
12
|
+
is_json = ctx.obj.get("json", False)
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from recon_cli.client import api_get
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
if not is_json:
|
|
19
|
+
console.print(f"Fetching run {run_id} details...")
|
|
20
|
+
|
|
21
|
+
run_data = api_get(f"/runs/{run_id}")
|
|
22
|
+
|
|
23
|
+
payload = {
|
|
24
|
+
"type": "single",
|
|
25
|
+
"result": run_data
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if not is_json:
|
|
29
|
+
with console.status("[bold cyan]Requesting CFO Explanation...[/bold cyan]") as status:
|
|
30
|
+
job_accepted = api_post("/explain/", json=payload, timeout=120.0)
|
|
31
|
+
job_id = job_accepted.get("job_id")
|
|
32
|
+
|
|
33
|
+
status.update("[bold cyan]Job queued. Waiting for worker to process...[/bold cyan]")
|
|
34
|
+
|
|
35
|
+
while True:
|
|
36
|
+
time.sleep(2)
|
|
37
|
+
job_status = api_get(f"/explain/{job_id}/status")
|
|
38
|
+
s = job_status.get("status")
|
|
39
|
+
|
|
40
|
+
if s == "processing":
|
|
41
|
+
status.update("[bold cyan]Worker is processing the job...[/bold cyan]")
|
|
42
|
+
elif s == "completed":
|
|
43
|
+
status.update("[bold cyan]Explanation generated![/bold cyan]")
|
|
44
|
+
data = job_status.get("response_data")
|
|
45
|
+
break
|
|
46
|
+
elif s == "failed":
|
|
47
|
+
raise ReconAPIError(500, job_status.get("error_message") or "Explain job failed")
|
|
48
|
+
else:
|
|
49
|
+
job_accepted = api_post("/explain/", json=payload, timeout=120.0)
|
|
50
|
+
job_id = job_accepted.get("job_id")
|
|
51
|
+
while True:
|
|
52
|
+
time.sleep(2)
|
|
53
|
+
job_status = api_get(f"/explain/{job_id}/status")
|
|
54
|
+
s = job_status.get("status")
|
|
55
|
+
if s == "completed":
|
|
56
|
+
data = job_status.get("response_data")
|
|
57
|
+
break
|
|
58
|
+
elif s == "failed":
|
|
59
|
+
raise ReconAPIError(500, job_status.get("error_message") or "Explain job failed")
|
|
60
|
+
|
|
61
|
+
if is_json:
|
|
62
|
+
print_json(data)
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
# Format the markdown output
|
|
66
|
+
md = f"""# {data.get('headline')}
|
|
67
|
+
|
|
68
|
+
**Status:** {data.get('status')}
|
|
69
|
+
|
|
70
|
+
## Summary
|
|
71
|
+
{data.get('summary')}
|
|
72
|
+
|
|
73
|
+
## Key Findings
|
|
74
|
+
"""
|
|
75
|
+
for finding in data.get('key_findings', []):
|
|
76
|
+
md += f"- {finding}\n"
|
|
77
|
+
|
|
78
|
+
md += f"\n## Financial Impact\n{data.get('financial_impact')}\n"
|
|
79
|
+
|
|
80
|
+
md += "\n## Attention Items\n"
|
|
81
|
+
for item in data.get('attention_items', []):
|
|
82
|
+
md += f"- {item}\n"
|
|
83
|
+
|
|
84
|
+
md += "\n## Recommended Actions\n"
|
|
85
|
+
for action in data.get('recommended_actions', []):
|
|
86
|
+
md += f"- {action}\n"
|
|
87
|
+
|
|
88
|
+
print_markdown(md)
|
|
89
|
+
|
|
90
|
+
except ReconAPIError as e:
|
|
91
|
+
if is_json:
|
|
92
|
+
print_json({"error": str(e)})
|
|
93
|
+
else:
|
|
94
|
+
print_error(str(e))
|
|
95
|
+
raise typer.Exit(1)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
if is_json:
|
|
98
|
+
print_json({"error": str(e)})
|
|
99
|
+
else:
|
|
100
|
+
print_error(f"Failed to generate explanation: {e}")
|
|
101
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import httpx
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from recon_cli.client import get_client, get_api_url, _get_headers
|
|
5
|
+
from recon_cli.output import print_error, print_success, print_json, console
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(help="Export reconciliation results to a file.")
|
|
8
|
+
|
|
9
|
+
@app.callback(invoke_without_command=True)
|
|
10
|
+
def export(
|
|
11
|
+
ctx: typer.Context,
|
|
12
|
+
run_id: str = typer.Argument(..., help="Run ID or Batch ID to export"),
|
|
13
|
+
format: str = typer.Option("excel", "--format", "-f", help="Format to export (excel or pdf)"),
|
|
14
|
+
out: Path = typer.Option(None, "--out", "-o", help="Output file path")
|
|
15
|
+
):
|
|
16
|
+
if format not in ["excel", "pdf"]:
|
|
17
|
+
print_error("Format must be 'excel' or 'pdf'.")
|
|
18
|
+
raise typer.Exit(1)
|
|
19
|
+
|
|
20
|
+
is_json = ctx.obj.get("json", False)
|
|
21
|
+
|
|
22
|
+
# First we need to know if it's a batch or single. We can just try one, then the other,
|
|
23
|
+
# or just assume it's single first. Let's try single.
|
|
24
|
+
|
|
25
|
+
base_url = get_api_url()
|
|
26
|
+
try:
|
|
27
|
+
headers = _get_headers()
|
|
28
|
+
except Exception as e:
|
|
29
|
+
if is_json:
|
|
30
|
+
print_json({"error": str(e)})
|
|
31
|
+
else:
|
|
32
|
+
print_error(str(e))
|
|
33
|
+
raise typer.Exit(1)
|
|
34
|
+
|
|
35
|
+
payload = {"run_id": run_id}
|
|
36
|
+
endpoint = f"{base_url}/export/single/{format}"
|
|
37
|
+
|
|
38
|
+
# Try single first
|
|
39
|
+
try:
|
|
40
|
+
with httpx.Client(headers=headers, timeout=60.0) as client:
|
|
41
|
+
if not is_json:
|
|
42
|
+
console.print(f"Requesting {format.upper()} export for run {run_id}...")
|
|
43
|
+
|
|
44
|
+
response = client.post(endpoint, json=payload)
|
|
45
|
+
|
|
46
|
+
if response.status_code == 404:
|
|
47
|
+
# Try batch
|
|
48
|
+
payload = {"batch_id": run_id}
|
|
49
|
+
endpoint = f"{base_url}/export/batch/{format}"
|
|
50
|
+
response = client.post(endpoint, json=payload)
|
|
51
|
+
|
|
52
|
+
response.raise_for_status()
|
|
53
|
+
|
|
54
|
+
# Determine output filename if not provided
|
|
55
|
+
if not out:
|
|
56
|
+
content_disp = response.headers.get("Content-Disposition", "")
|
|
57
|
+
if "filename=" in content_disp:
|
|
58
|
+
filename = content_disp.split("filename=")[-1].strip('"\'')
|
|
59
|
+
else:
|
|
60
|
+
ext = "xlsx" if format == "excel" else "pdf"
|
|
61
|
+
filename = f"recon_export_{run_id}.{ext}"
|
|
62
|
+
out = Path(filename)
|
|
63
|
+
|
|
64
|
+
with open(out, "wb") as f:
|
|
65
|
+
f.write(response.content)
|
|
66
|
+
|
|
67
|
+
if is_json:
|
|
68
|
+
print_json({"success": True, "file": str(out.absolute())})
|
|
69
|
+
else:
|
|
70
|
+
print_success(f"Export saved to [bold]{out}[/bold]")
|
|
71
|
+
|
|
72
|
+
except httpx.HTTPStatusError as e:
|
|
73
|
+
detail = "Unknown error"
|
|
74
|
+
try:
|
|
75
|
+
detail = e.response.json().get("detail", detail)
|
|
76
|
+
except:
|
|
77
|
+
pass
|
|
78
|
+
err_msg = f"API Error ({e.response.status_code}): {detail}"
|
|
79
|
+
if is_json:
|
|
80
|
+
print_json({"error": err_msg})
|
|
81
|
+
else:
|
|
82
|
+
print_error(err_msg)
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
if is_json:
|
|
86
|
+
print_json({"error": str(e)})
|
|
87
|
+
else:
|
|
88
|
+
print_error(f"Failed to export: {e}")
|
|
89
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from recon_cli.client import api_get, ReconAPIError
|
|
3
|
+
from recon_cli.output import print_error, print_json, print_table, print_panel, console
|
|
4
|
+
|
|
5
|
+
app = typer.Typer(help="View past reconciliation runs.")
|
|
6
|
+
|
|
7
|
+
@app.callback(invoke_without_command=True)
|
|
8
|
+
def main(
|
|
9
|
+
ctx: typer.Context,
|
|
10
|
+
run_id: str = typer.Argument(None, help="Specific Run ID to fetch details for"),
|
|
11
|
+
limit: int = typer.Option(20, help="Number of recent runs to show when listing")
|
|
12
|
+
):
|
|
13
|
+
if ctx.invoked_subcommand is not None:
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
is_json = ctx.obj.get("json", False)
|
|
17
|
+
|
|
18
|
+
if run_id:
|
|
19
|
+
# Fetch specific run
|
|
20
|
+
try:
|
|
21
|
+
# First try as a single run
|
|
22
|
+
try:
|
|
23
|
+
data = api_get(f"/runs/{run_id}")
|
|
24
|
+
except ReconAPIError as e:
|
|
25
|
+
if e.status_code == 404:
|
|
26
|
+
# Maybe it's a batch run?
|
|
27
|
+
data = api_get(f"/runs/batch/{run_id}")
|
|
28
|
+
else:
|
|
29
|
+
raise e
|
|
30
|
+
|
|
31
|
+
if is_json:
|
|
32
|
+
print_json(data)
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
# Formatting details
|
|
36
|
+
if "batch_id" in data and "files" not in data:
|
|
37
|
+
# Single run
|
|
38
|
+
print_panel(
|
|
39
|
+
f"ID: {data.get('id')}\n"
|
|
40
|
+
f"Status: {data.get('status')}\n"
|
|
41
|
+
f"Match Rate: {data.get('match_rate', 0)}%\n"
|
|
42
|
+
f"Exceptions: {data.get('exceptions_count', 0)}\n"
|
|
43
|
+
f"Completed: {data.get('completed_at')}",
|
|
44
|
+
title="Single Run Details"
|
|
45
|
+
)
|
|
46
|
+
else:
|
|
47
|
+
# Batch run
|
|
48
|
+
print_panel(
|
|
49
|
+
f"Batch ID: {data.get('batch_id')}\n"
|
|
50
|
+
f"Status: {data.get('status')}\n"
|
|
51
|
+
f"Total Match Rate: {data.get('overall_match_rate', 0)}%\n"
|
|
52
|
+
f"Total Exceptions: {data.get('total_exceptions', 0)}\n"
|
|
53
|
+
f"Files processed: {len(data.get('files', []))}",
|
|
54
|
+
title="Batch Run Details"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
except ReconAPIError as e:
|
|
58
|
+
if is_json:
|
|
59
|
+
print_json({"error": str(e)})
|
|
60
|
+
else:
|
|
61
|
+
print_error(str(e))
|
|
62
|
+
raise typer.Exit(1)
|
|
63
|
+
|
|
64
|
+
else:
|
|
65
|
+
# List runs
|
|
66
|
+
try:
|
|
67
|
+
data = api_get("/runs/", params={"limit": limit})
|
|
68
|
+
if is_json:
|
|
69
|
+
print_json(data)
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
if not data:
|
|
73
|
+
console.print("No history found.")
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
rows = []
|
|
77
|
+
for item in data:
|
|
78
|
+
run_type = item.get("type", "single")
|
|
79
|
+
run_ident = item.get("id") or item.get("batch_id")
|
|
80
|
+
completed = item.get("completed_at", "N/A")
|
|
81
|
+
if completed != "N/A":
|
|
82
|
+
completed = completed[:16].replace("T", " ")
|
|
83
|
+
|
|
84
|
+
if run_type == "single":
|
|
85
|
+
rate = f"{item.get('match_rate', 0)}%"
|
|
86
|
+
exc = str(item.get('exceptions_count', 0))
|
|
87
|
+
else:
|
|
88
|
+
rate = f"{item.get('overall_match_rate', 0)}%"
|
|
89
|
+
exc = str(item.get('total_exceptions', 0))
|
|
90
|
+
|
|
91
|
+
rows.append([
|
|
92
|
+
run_type.upper(),
|
|
93
|
+
run_ident[:8] + "...",
|
|
94
|
+
item.get("status", ""),
|
|
95
|
+
rate,
|
|
96
|
+
exc,
|
|
97
|
+
completed
|
|
98
|
+
])
|
|
99
|
+
|
|
100
|
+
print_table(
|
|
101
|
+
title=f"Recent Runs (Showing up to {limit})",
|
|
102
|
+
columns=["Type", "ID", "Status", "Match Rate", "Exceptions", "Completed"],
|
|
103
|
+
rows=rows
|
|
104
|
+
)
|
|
105
|
+
console.print("\nRun [cyan]recon history <id>[/cyan] to see details.")
|
|
106
|
+
|
|
107
|
+
except ReconAPIError as e:
|
|
108
|
+
if is_json:
|
|
109
|
+
print_json({"error": str(e)})
|
|
110
|
+
else:
|
|
111
|
+
print_error(str(e))
|
|
112
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from recon_cli.client import api_post, ReconAPIError
|
|
4
|
+
from recon_cli.output import print_error, print_success, print_info, print_json, print_table, console
|
|
5
|
+
|
|
6
|
+
app = typer.Typer(help="Execute reconciliation between source and target datasets.")
|
|
7
|
+
|
|
8
|
+
@app.command("run")
|
|
9
|
+
def run_reconcile(
|
|
10
|
+
ctx: typer.Context,
|
|
11
|
+
source: Path = typer.Argument(..., help="Path to the source CSV/XLSX file", exists=True, dir_okay=False),
|
|
12
|
+
target: Path = typer.Argument(..., help="Path to the target CSV/XLSX file", exists=True, dir_okay=False),
|
|
13
|
+
amount_tolerance: float = typer.Option(0.0, "--tolerance", "-t", help="Allowable difference in amounts"),
|
|
14
|
+
date_window: int = typer.Option(0, "--date-window", "-d", help="Allowable difference in days for dates"),
|
|
15
|
+
ai_provider: str = typer.Option("none", "--provider", help="AI provider for fuzzy matching (groq, openai, none)"),
|
|
16
|
+
mapping_id: str = typer.Option(None, "--mapping", "-m", help="Mapping preset ID to use"),
|
|
17
|
+
):
|
|
18
|
+
"""
|
|
19
|
+
Reconcile two files against each other.
|
|
20
|
+
"""
|
|
21
|
+
is_json = ctx.obj.get("json", False)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
with open(source, "rb") as s_file, open(target, "rb") as t_file:
|
|
25
|
+
files = {
|
|
26
|
+
"source_file": (source.name, s_file),
|
|
27
|
+
"target_file": (target.name, t_file),
|
|
28
|
+
}
|
|
29
|
+
data = {
|
|
30
|
+
"source_mapping_json": "{}",
|
|
31
|
+
"target_mapping_json": "{}",
|
|
32
|
+
"source_amount_mode": "single",
|
|
33
|
+
"target_amount_mode": "single",
|
|
34
|
+
"amount_tolerance": amount_tolerance,
|
|
35
|
+
"date_window_days": date_window
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
from recon_cli.client import api_get
|
|
39
|
+
import time
|
|
40
|
+
|
|
41
|
+
if not is_json:
|
|
42
|
+
with console.status("[bold cyan]Uploading and queueing reconciliation job...[/bold cyan]") as status:
|
|
43
|
+
job_accepted = api_post("/reconcile/", data=data, files=files, timeout=300.0)
|
|
44
|
+
run_id = job_accepted.get("run_id")
|
|
45
|
+
|
|
46
|
+
if not run_id:
|
|
47
|
+
raise ReconAPIError(500, "Failed to get run_id from API")
|
|
48
|
+
|
|
49
|
+
status.update("[bold cyan]Job queued. Waiting for worker to process...[/bold cyan]")
|
|
50
|
+
|
|
51
|
+
# Poll for completion
|
|
52
|
+
while True:
|
|
53
|
+
time.sleep(2)
|
|
54
|
+
job_status = api_get(f"/runs/{run_id}/status")
|
|
55
|
+
s = job_status.get("status")
|
|
56
|
+
|
|
57
|
+
if s == "processing":
|
|
58
|
+
status.update("[bold cyan]Worker is processing the job...[/bold cyan]")
|
|
59
|
+
elif s == "completed":
|
|
60
|
+
status.update("[bold cyan]Job completed! Fetching results...[/bold cyan]")
|
|
61
|
+
break
|
|
62
|
+
elif s == "failed":
|
|
63
|
+
raise ReconAPIError(500, job_status.get("error_message") or "Job failed")
|
|
64
|
+
|
|
65
|
+
result = api_get(f"/runs/{run_id}")
|
|
66
|
+
else:
|
|
67
|
+
job_accepted = api_post("/reconcile/", data=data, files=files, timeout=300.0)
|
|
68
|
+
run_id = job_accepted.get("run_id")
|
|
69
|
+
while True:
|
|
70
|
+
time.sleep(2)
|
|
71
|
+
job_status = api_get(f"/runs/{run_id}/status")
|
|
72
|
+
if job_status.get("status") in ("completed", "failed"):
|
|
73
|
+
if job_status.get("status") == "failed":
|
|
74
|
+
raise ReconAPIError(500, job_status.get("error_message") or "Job failed")
|
|
75
|
+
break
|
|
76
|
+
result = api_get(f"/runs/{run_id}")
|
|
77
|
+
|
|
78
|
+
except ReconAPIError as e:
|
|
79
|
+
if is_json:
|
|
80
|
+
print_json({"error": str(e), "status_code": e.status_code})
|
|
81
|
+
else:
|
|
82
|
+
print_error(str(e))
|
|
83
|
+
raise typer.Exit(1)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
if is_json:
|
|
86
|
+
print_json({"error": str(e)})
|
|
87
|
+
else:
|
|
88
|
+
print_error(f"Failed to upload and reconcile: {e}")
|
|
89
|
+
raise typer.Exit(1)
|
|
90
|
+
|
|
91
|
+
if is_json:
|
|
92
|
+
print_json(result)
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
# Render summary nicely
|
|
96
|
+
print_success(f"Reconciliation Run Completed (ID: {result.get('run_id', 'N/A')})")
|
|
97
|
+
|
|
98
|
+
summary = result.get("summary", {})
|
|
99
|
+
|
|
100
|
+
table = [
|
|
101
|
+
["Total Source Rows", str(result.get("total_source_rows", 0))],
|
|
102
|
+
["Matched Rows", str(result.get("total_matched", 0))],
|
|
103
|
+
["Match Rate", f"{result.get('match_rate', 0)}%"],
|
|
104
|
+
["Exceptions", str(result.get("exceptions_count", 0))],
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
print_table("Reconciliation Summary", ["Metric", "Value"], table)
|
|
108
|
+
|
|
109
|
+
matches = [
|
|
110
|
+
["Exact Matches", str(result.get("exact_matches", 0))],
|
|
111
|
+
["Fuzzy Matches", str(result.get("fuzzy_matches", 0))],
|
|
112
|
+
["AI Matches", str(result.get("ai_matches", 0))],
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
print_table("Match Breakdown", ["Type", "Count"], matches)
|
|
116
|
+
|
|
117
|
+
@app.callback(invoke_without_command=True)
|
|
118
|
+
def default_reconcile(
|
|
119
|
+
ctx: typer.Context,
|
|
120
|
+
source: Path = typer.Argument(..., help="Path to the source CSV/XLSX file", exists=True, dir_okay=False),
|
|
121
|
+
target: Path = typer.Argument(..., help="Path to the target CSV/XLSX file", exists=True, dir_okay=False),
|
|
122
|
+
amount_tolerance: float = typer.Option(0.0, "--tolerance", "-t", help="Allowable difference in amounts"),
|
|
123
|
+
date_window: int = typer.Option(0, "--date-window", "-d", help="Allowable difference in days for dates"),
|
|
124
|
+
ai_provider: str = typer.Option("none", "--provider", help="AI provider for fuzzy matching (groq, openai, none)"),
|
|
125
|
+
mapping_id: str = typer.Option(None, "--mapping", "-m", help="Mapping preset ID to use"),
|
|
126
|
+
):
|
|
127
|
+
if ctx.invoked_subcommand is None:
|
|
128
|
+
# Pass directly to run
|
|
129
|
+
run_reconcile(ctx, source, target, amount_tolerance, date_window, ai_provider, mapping_id)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
# The base configuration path
|
|
6
|
+
CONFIG_DIR = Path.home() / ".recon"
|
|
7
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
8
|
+
CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
|
|
9
|
+
|
|
10
|
+
DEFAULT_CONFIG = {
|
|
11
|
+
"api_base_url": "https://api.recon-agent.com"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
def load_config() -> dict:
|
|
15
|
+
if not CONFIG_FILE.exists():
|
|
16
|
+
return DEFAULT_CONFIG.copy()
|
|
17
|
+
try:
|
|
18
|
+
with open(CONFIG_FILE, "r") as f:
|
|
19
|
+
data = json.load(f)
|
|
20
|
+
# Merge with defaults
|
|
21
|
+
return {**DEFAULT_CONFIG, **data}
|
|
22
|
+
except Exception:
|
|
23
|
+
return DEFAULT_CONFIG.copy()
|
|
24
|
+
|
|
25
|
+
def save_config(config: dict):
|
|
26
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
with open(CONFIG_FILE, "w") as f:
|
|
28
|
+
json.dump(config, f, indent=2)
|
|
29
|
+
|
|
30
|
+
def get_api_url() -> str:
|
|
31
|
+
"""Return the API URL, allowing env var override."""
|
|
32
|
+
env_url = os.environ.get("RECON_API_URL")
|
|
33
|
+
if env_url:
|
|
34
|
+
return env_url.rstrip("/")
|
|
35
|
+
return load_config().get("api_base_url", DEFAULT_CONFIG["api_base_url"]).rstrip("/")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
app = typer.Typer(
|
|
4
|
+
name="recon",
|
|
5
|
+
help="Recon Agent CLI - Multi-tenant financial reconciliation from your terminal.",
|
|
6
|
+
no_args_is_help=True,
|
|
7
|
+
add_completion=False,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from recon_cli.commands import auth, reconcile, history, export, explain
|
|
11
|
+
|
|
12
|
+
app.add_typer(auth.app, name="auth")
|
|
13
|
+
app.add_typer(reconcile.app, name="reconcile")
|
|
14
|
+
app.add_typer(history.app, name="history")
|
|
15
|
+
app.add_typer(export.app, name="export")
|
|
16
|
+
app.add_typer(explain.app, name="explain")
|
|
17
|
+
|
|
18
|
+
# Add some top-level aliases for convenience
|
|
19
|
+
app.command(name="login")(auth.login)
|
|
20
|
+
app.command(name="logout")(auth.logout)
|
|
21
|
+
app.command(name="whoami")(auth.whoami)
|
|
22
|
+
|
|
23
|
+
@app.callback()
|
|
24
|
+
def main(
|
|
25
|
+
ctx: typer.Context,
|
|
26
|
+
json: bool = typer.Option(False, "--json", help="Output machine-readable JSON instead of human-readable text"),
|
|
27
|
+
debug: bool = typer.Option(False, "--debug", help="Enable debug mode with full tracebacks"),
|
|
28
|
+
):
|
|
29
|
+
"""
|
|
30
|
+
Global options for Recon Agent CLI.
|
|
31
|
+
"""
|
|
32
|
+
ctx.ensure_object(dict)
|
|
33
|
+
ctx.obj["json"] = json
|
|
34
|
+
ctx.obj["debug"] = debug
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
app()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
from rich import print as rprint
|
|
6
|
+
from rich.markdown import Markdown
|
|
7
|
+
|
|
8
|
+
# Global console instance
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
def print_error(msg: str):
|
|
12
|
+
console.print(f"[bold red]Error:[/bold red] {msg}")
|
|
13
|
+
|
|
14
|
+
def print_success(msg: str):
|
|
15
|
+
console.print(f"[bold green]Success:[/bold green] {msg}")
|
|
16
|
+
|
|
17
|
+
def print_info(msg: str):
|
|
18
|
+
console.print(f"[cyan]{msg}[/cyan]")
|
|
19
|
+
|
|
20
|
+
def print_json(data: dict | list):
|
|
21
|
+
"""Prints raw JSON to stdout. Bypasses rich formatting for standard out pipes."""
|
|
22
|
+
print(json.dumps(data, indent=2))
|
|
23
|
+
|
|
24
|
+
def print_table(title: str, columns: list[str], rows: list[list[str]]):
|
|
25
|
+
table = Table(title=title, show_header=True, header_style="bold magenta")
|
|
26
|
+
for col in columns:
|
|
27
|
+
table.add_column(col)
|
|
28
|
+
for row in rows:
|
|
29
|
+
table.add_row(*[str(item) if item is not None else "" for item in row])
|
|
30
|
+
console.print(table)
|
|
31
|
+
|
|
32
|
+
def print_panel(content: str, title: str = "", border_style: str = "blue"):
|
|
33
|
+
panel = Panel(content, title=title, border_style=border_style, expand=False)
|
|
34
|
+
console.print(panel)
|
|
35
|
+
|
|
36
|
+
def print_markdown(content: str):
|
|
37
|
+
md = Markdown(content)
|
|
38
|
+
console.print(md)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
|
2
|
+
|
|
3
|
+
## Getting Started
|
|
4
|
+
|
|
5
|
+
First, run the development server:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm run dev
|
|
9
|
+
# or
|
|
10
|
+
yarn dev
|
|
11
|
+
# or
|
|
12
|
+
pnpm dev
|
|
13
|
+
# or
|
|
14
|
+
bun dev
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
|
18
|
+
|
|
19
|
+
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
|
20
|
+
|
|
21
|
+
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
|
22
|
+
|
|
23
|
+
## Learn More
|
|
24
|
+
|
|
25
|
+
To learn more about Next.js, take a look at the following resources:
|
|
26
|
+
|
|
27
|
+
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
|
28
|
+
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
29
|
+
|
|
30
|
+
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
|
31
|
+
|
|
32
|
+
## Deploy on Vercel
|
|
33
|
+
|
|
34
|
+
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
35
|
+
|
|
36
|
+
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|