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.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .env
4
+ exception_report.csv
5
+ .streamlit/
6
+ .venv/
7
+ .pytest_cache/
8
+ web/node_modules/
9
+ web/.next/
10
+ web/build/
11
+ .vscode/
12
+ .idea/
@@ -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.