agencycore-cli 0.1.0__py3-none-any.whl

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.
ac_cli/__init__.py ADDED
File without changes
ac_cli/client.py ADDED
@@ -0,0 +1,81 @@
1
+ """Authenticated httpx client for the AgencyCore API."""
2
+
3
+ import httpx
4
+ import typer
5
+
6
+ from ac_cli.config import load_config, save_config
7
+
8
+
9
+ def _refresh_access_token(cfg: dict) -> str:
10
+ """Use stored refresh_token to obtain a new access_token from Supabase.
11
+
12
+ Persists the new tokens to config. Returns the new access_token.
13
+ Raises typer.Exit on failure.
14
+ """
15
+ from supabase import create_client
16
+
17
+ supabase_url = cfg.get("supabase_url")
18
+ supabase_anon_key = cfg.get("supabase_anon_key")
19
+ refresh_token = cfg.get("refresh_token")
20
+
21
+ if not supabase_url or not supabase_anon_key or not refresh_token:
22
+ typer.echo("Session expired. Run `ac login` to re-authenticate.")
23
+ raise typer.Exit(code=1)
24
+
25
+ try:
26
+ sb = create_client(supabase_url, supabase_anon_key)
27
+ response = sb.auth.refresh_session(refresh_token)
28
+ except Exception as exc:
29
+ typer.echo(f"Token refresh failed: {exc}\nRun `ac login` to re-authenticate.")
30
+ raise typer.Exit(code=1) from exc
31
+
32
+ session = response.session
33
+ if not session:
34
+ typer.echo("Token refresh returned no session. Run `ac login` to re-authenticate.")
35
+ raise typer.Exit(code=1)
36
+
37
+ cfg["access_token"] = session.access_token
38
+ cfg["refresh_token"] = session.refresh_token
39
+ save_config(cfg)
40
+ return session.access_token
41
+
42
+
43
+ class _AuthClient(httpx.Client):
44
+ """httpx.Client that auto-refreshes expired Supabase tokens on 401."""
45
+
46
+ def __init__(self, cfg: dict, **kwargs: object) -> None:
47
+ self._cfg = cfg
48
+ super().__init__(**kwargs)
49
+
50
+ def send(self, request: httpx.Request, **kwargs: object) -> httpx.Response:
51
+ response = super().send(request, **kwargs)
52
+
53
+ if response.status_code == 401:
54
+ new_token = _refresh_access_token(self._cfg)
55
+ request.headers["Authorization"] = f"Bearer {new_token}"
56
+ self.headers["Authorization"] = f"Bearer {new_token}"
57
+ response = super().send(request, **kwargs)
58
+
59
+ return response
60
+
61
+
62
+ def get_api_client() -> httpx.Client:
63
+ """Return an httpx.Client with base URL and auth header from stored config.
64
+
65
+ The client auto-refreshes the Supabase access token on 401 responses.
66
+ Raises typer.Exit if not logged in.
67
+ """
68
+ cfg = load_config()
69
+ access_token = cfg.get("access_token")
70
+ api_url = cfg.get("api_url")
71
+
72
+ if not access_token or not api_url:
73
+ typer.echo("Not logged in. Run `ac login` first.")
74
+ raise typer.Exit(code=1)
75
+
76
+ return _AuthClient(
77
+ cfg=cfg,
78
+ base_url=api_url,
79
+ headers={"Authorization": f"Bearer {access_token}"},
80
+ timeout=30.0,
81
+ )
File without changes
@@ -0,0 +1,102 @@
1
+ """Authentication commands: login, logout, whoami."""
2
+
3
+ import httpx
4
+ import typer
5
+ from rich import print as rprint
6
+ from supabase import create_client
7
+
8
+ from ac_cli.client import get_api_client
9
+ from ac_cli.config import (
10
+ DEV_API_URL,
11
+ DEV_SUPABASE_ANON_KEY,
12
+ DEV_SUPABASE_URL,
13
+ STAGING_API_URL,
14
+ STAGING_SUPABASE_ANON_KEY,
15
+ STAGING_SUPABASE_URL,
16
+ clear_config,
17
+ save_config,
18
+ )
19
+
20
+ app = typer.Typer(help="Authentication commands")
21
+
22
+
23
+ @app.command()
24
+ def login(
25
+ email: str = typer.Option(None, help="Supabase account email"),
26
+ password: str = typer.Option(None, help="Account password"),
27
+ supabase_url: str = typer.Option(None, help="Supabase project URL"),
28
+ supabase_anon_key: str = typer.Option(None, help="Supabase anonymous/public key"),
29
+ api_url: str = typer.Option(None, help="AgencyCore API base URL"),
30
+ dev: bool = typer.Option(False, "--dev", help="Use local dev environment (localhost)"),
31
+ ) -> None:
32
+ """Sign in with email and password via Supabase.
33
+
34
+ By default, connects to the staging environment. Pass --dev to use local
35
+ dev services (localhost API + local Supabase).
36
+ """
37
+ if dev:
38
+ api_url = api_url or DEV_API_URL
39
+ supabase_url = supabase_url or DEV_SUPABASE_URL
40
+ supabase_anon_key = supabase_anon_key or DEV_SUPABASE_ANON_KEY
41
+ rprint("[dim]Using local dev environment[/dim]")
42
+ else:
43
+ api_url = api_url or STAGING_API_URL
44
+ supabase_url = supabase_url or STAGING_SUPABASE_URL
45
+ supabase_anon_key = supabase_anon_key or STAGING_SUPABASE_ANON_KEY
46
+
47
+ if not email:
48
+ email = typer.prompt("Email")
49
+ if not password:
50
+ password = typer.prompt("Password", hide_input=True)
51
+
52
+ try:
53
+ client = create_client(supabase_url, supabase_anon_key)
54
+ response = client.auth.sign_in_with_password(
55
+ {"email": email, "password": password}
56
+ )
57
+ except Exception as exc:
58
+ rprint(f"[red]Login failed:[/red] {exc}")
59
+ raise typer.Exit(code=1) from exc
60
+
61
+ session = response.session
62
+ if not session:
63
+ rprint("[red]Login failed: no session returned.[/red]")
64
+ raise typer.Exit(code=1)
65
+
66
+ save_config(
67
+ {
68
+ "api_url": api_url,
69
+ "supabase_url": supabase_url,
70
+ "supabase_anon_key": supabase_anon_key,
71
+ "access_token": session.access_token,
72
+ "refresh_token": session.refresh_token,
73
+ }
74
+ )
75
+ rprint(f"[green]Logged in as {email}[/green]")
76
+
77
+
78
+ @app.command()
79
+ def logout() -> None:
80
+ """Clear stored credentials."""
81
+ clear_config()
82
+ rprint("[green]Logged out.[/green]")
83
+
84
+
85
+ @app.command()
86
+ def whoami() -> None:
87
+ """Show the currently authenticated user."""
88
+ with get_api_client() as client:
89
+ try:
90
+ resp = client.get("/whoami")
91
+ resp.raise_for_status()
92
+ except httpx.HTTPStatusError as exc:
93
+ try:
94
+ detail = exc.response.json().get("detail", exc.response.text)
95
+ except Exception:
96
+ detail = exc.response.text
97
+ rprint(f"[red]Error {exc.response.status_code}:[/red] {detail}")
98
+ raise typer.Exit(code=1)
99
+ except httpx.HTTPError as exc:
100
+ rprint(f"[red]Connection error:[/red] {exc}")
101
+ raise typer.Exit(code=1)
102
+ rprint(resp.json())
@@ -0,0 +1,172 @@
1
+ """CRM commands: search, companies, people, deals, activities, dashboard, lists."""
2
+
3
+ import httpx
4
+ import typer
5
+ from rich import print as rprint
6
+
7
+ from ac_cli.client import get_api_client
8
+ from ac_cli.formatting import print_json, print_table
9
+
10
+ app = typer.Typer(help="CRM commands")
11
+
12
+ # -- Shared helpers -----------------------------------------------------------
13
+
14
+ _CRM = "/api/v1/crm"
15
+
16
+
17
+ @app.callback()
18
+ def crm_callback(
19
+ ctx: typer.Context,
20
+ json_output: bool = typer.Option(False, "--json", help="Output raw JSON"),
21
+ ) -> None:
22
+ ctx.ensure_object(dict)
23
+ ctx.obj["json"] = json_output
24
+
25
+
26
+ def _handle_error(exc: httpx.HTTPStatusError) -> None:
27
+ """Print API error detail and exit."""
28
+ try:
29
+ detail = exc.response.json().get("detail", exc.response.text)
30
+ except Exception:
31
+ detail = exc.response.text
32
+ rprint(f"[red]Error {exc.response.status_code}:[/red] {detail}")
33
+ raise typer.Exit(code=1)
34
+
35
+
36
+ def _api_request(method: str, path: str, **kwargs: object) -> httpx.Response:
37
+ """Make an authenticated API request with standard error handling."""
38
+ with get_api_client() as client:
39
+ try:
40
+ resp = getattr(client, method)(path, **kwargs)
41
+ resp.raise_for_status()
42
+ except httpx.HTTPStatusError as exc:
43
+ _handle_error(exc)
44
+ except httpx.HTTPError as exc:
45
+ rprint(f"[red]Connection error:[/red] {exc}")
46
+ raise typer.Exit(code=1)
47
+ return resp
48
+
49
+
50
+ def _build_body(**fields: object) -> dict:
51
+ """Build API request body from non-None fields."""
52
+ body: dict = {}
53
+ for key, value in fields.items():
54
+ if value is not None:
55
+ if key == "tags" and isinstance(value, str):
56
+ body[key] = [t.strip() for t in value.split(",")]
57
+ else:
58
+ body[key] = value
59
+ return body
60
+
61
+
62
+ # =============================================================================
63
+ # SEARCH
64
+ # =============================================================================
65
+
66
+
67
+ @app.command()
68
+ def search(
69
+ ctx: typer.Context,
70
+ query: str = typer.Argument(..., help="Search query"),
71
+ ) -> None:
72
+ """Search across companies, contacts, and deals."""
73
+ resp = _api_request("get", f"{_CRM}/search", params={"q": query})
74
+
75
+ data = resp.json()
76
+ if ctx.obj["json"]:
77
+ print_json(data)
78
+ return
79
+
80
+ companies = data.get("companies", [])
81
+ contacts = data.get("contacts", [])
82
+ deals = data.get("deals", [])
83
+
84
+ if companies:
85
+ print_table(
86
+ companies,
87
+ [("name", "Name"), ("industry", "Industry"), ("id", "ID")],
88
+ title="Companies",
89
+ )
90
+ if contacts:
91
+ print_table(
92
+ contacts,
93
+ [("full_name", "Name"), ("email", "Email"), ("id", "ID")],
94
+ title="Contacts",
95
+ )
96
+ if deals:
97
+ print_table(
98
+ deals,
99
+ [("name", "Name"), ("stage", "Stage"), ("id", "ID")],
100
+ title="Deals",
101
+ )
102
+
103
+ if not companies and not contacts and not deals:
104
+ rprint("[dim]No results found.[/dim]")
105
+
106
+
107
+ # =============================================================================
108
+ # DASHBOARD
109
+ # =============================================================================
110
+
111
+
112
+ @app.command("dashboard")
113
+ def dashboard(
114
+ ctx: typer.Context,
115
+ period: int = typer.Option(30, "--period", help="Period in days"),
116
+ ) -> None:
117
+ """Show CRM dashboard summary."""
118
+ resp = _api_request("get", f"{_CRM}/dashboard", params={"period_days": period})
119
+
120
+ data = resp.json()
121
+ if ctx.obj["json"]:
122
+ print_json(data)
123
+ return
124
+
125
+ pipeline = data.get("pipeline", {})
126
+ active = data.get("active_pipeline", {})
127
+ leads = data.get("leads", {})
128
+ messages = data.get("messages_sent", {})
129
+
130
+ rprint(f"\n[bold]CRM Dashboard[/bold] (last {data.get('period_days', period)} days)\n")
131
+
132
+ rprint("[bold]Pipeline[/bold]")
133
+ rprint(f" Total deals: {pipeline.get('total_deals', 0)}")
134
+ rprint(f" Total value: ${pipeline.get('total_value', 0):,.2f}")
135
+ stages = pipeline.get("deals_by_stage", {})
136
+ for stage_name, stats in stages.items():
137
+ rprint(f" {stage_name}: {stats.get('count', 0)} deals (${stats.get('value', 0):,.2f})")
138
+
139
+ rprint(f"\n[bold]Active Pipeline[/bold]")
140
+ rprint(f" Active deals: {active.get('active_deals_count', 0)}")
141
+ rprint(f" Total value: ${active.get('total_value', 0):,.2f}")
142
+ rprint(f" Adjusted value: ${active.get('adjusted_value', 0):,.2f}")
143
+
144
+ rprint(f"\n[bold]Leads[/bold]")
145
+ rprint(f" This period: {leads.get('current_period', 0)}")
146
+ rprint(f" Previous: {leads.get('previous_period', 0)}")
147
+ rprint(f" Change: {leads.get('change', 0):+d}")
148
+ rprint(f" Total: {leads.get('total', 0)}")
149
+
150
+ rprint(f"\n[bold]Messages Sent[/bold]")
151
+ rprint(f" This period: {messages.get('current_period', 0)}")
152
+ rprint(f" Previous: {messages.get('previous_period', 0)}")
153
+ rprint(f" Change: {messages.get('change', 0):+d}")
154
+
155
+
156
+ # -- Register sub-command groups from submodules ------------------------------
157
+
158
+ from ac_cli.commands.crm.companies import companies_app # noqa: E402
159
+ from ac_cli.commands.crm.people import people_app # noqa: E402
160
+ from ac_cli.commands.crm.deals import deals_app # noqa: E402
161
+ from ac_cli.commands.crm.activities import activities_app # noqa: E402
162
+ from ac_cli.commands.crm.lists import lists_app # noqa: E402
163
+ from ac_cli.commands.crm.communications import communications_app # noqa: E402
164
+ from ac_cli.commands.crm.imports import imports_app # noqa: E402
165
+
166
+ app.add_typer(companies_app, name="companies")
167
+ app.add_typer(people_app, name="people")
168
+ app.add_typer(deals_app, name="deals")
169
+ app.add_typer(activities_app, name="activities")
170
+ app.add_typer(lists_app, name="lists")
171
+ app.add_typer(communications_app, name="comms")
172
+ app.add_typer(imports_app, name="import")
@@ -0,0 +1,193 @@
1
+ """CRM activities commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+
7
+ import typer
8
+ from rich import print as rprint
9
+
10
+ from ac_cli.commands.crm import _CRM, _api_request, _build_body
11
+ from ac_cli.formatting import print_detail, print_json, print_table
12
+
13
+ activities_app = typer.Typer(help="Activity operations")
14
+
15
+
16
+ @activities_app.command("list")
17
+ def activities_list(
18
+ ctx: typer.Context,
19
+ deal_id: str | None = typer.Option(None, "--deal-id", help="Filter by deal"),
20
+ company_id: str | None = typer.Option(None, "--company-id", help="Filter by company"),
21
+ contact_id: str | None = typer.Option(None, "--contact-id", help="Filter by contact"),
22
+ activity_type: str | None = typer.Option(None, "--type", help="Filter by type"),
23
+ status: str | None = typer.Option(None, help="Filter by status"),
24
+ sort_by: str | None = typer.Option(None, "--sort-by", help="Sort field: due_date or created_at"),
25
+ limit: int = typer.Option(100, help="Max results"),
26
+ offset: int = typer.Option(0, help="Offset"),
27
+ ) -> None:
28
+ """List activities."""
29
+ params: dict = {"limit": limit, "offset": offset}
30
+ if deal_id:
31
+ params["deal_id"] = deal_id
32
+ if company_id:
33
+ params["company_id"] = company_id
34
+ if contact_id:
35
+ params["contact_id"] = contact_id
36
+ if activity_type:
37
+ params["type"] = activity_type
38
+ if status:
39
+ params["status"] = status
40
+ if sort_by:
41
+ params["sort_by"] = sort_by
42
+
43
+ resp = _api_request("get", f"{_CRM}/activities", params=params)
44
+
45
+ data = resp.json()
46
+ if ctx.obj["json"]:
47
+ print_json(data)
48
+ return
49
+
50
+ # API returns a flat list
51
+ items = data if isinstance(data, list) else data.get("data", [])
52
+ print_table(
53
+ items,
54
+ [
55
+ ("title", "Title"),
56
+ ("type", "Type"),
57
+ ("status", "Status"),
58
+ ("priority", "Priority"),
59
+ ("due_date", "Due Date"),
60
+ ("id", "ID"),
61
+ ],
62
+ title=f"Activities ({len(items)})",
63
+ )
64
+
65
+
66
+ @activities_app.command("get")
67
+ def activities_get(
68
+ ctx: typer.Context,
69
+ activity_id: str = typer.Argument(..., help="Activity ID"),
70
+ ) -> None:
71
+ """Get an activity by ID."""
72
+ resp = _api_request("get", f"{_CRM}/activities/{activity_id}")
73
+
74
+ data = resp.json()
75
+ if ctx.obj["json"]:
76
+ print_json(data)
77
+ return
78
+
79
+ print_detail(data, [
80
+ ("id", "ID"),
81
+ ("title", "Title"),
82
+ ("type", "Type"),
83
+ ("status", "Status"),
84
+ ("priority", "Priority"),
85
+ ("due_date", "Due Date"),
86
+ ("completed_at", "Completed At"),
87
+ ("description", "Description"),
88
+ ("company_id", "Company ID"),
89
+ ("contact_id", "Contact ID"),
90
+ ("deal_id", "Deal ID"),
91
+ ("assigned_to", "Assigned To"),
92
+ ("created_at", "Created"),
93
+ ("updated_at", "Updated"),
94
+ ])
95
+
96
+
97
+ @activities_app.command("create")
98
+ def activities_create(
99
+ ctx: typer.Context,
100
+ activity_type: str = typer.Option(..., "--type", help="Activity type (call, meeting, email, task, note)"),
101
+ title: str = typer.Option(..., help="Activity title"),
102
+ due_date: str | None = typer.Option(None, "--due-date", help="Due date (ISO format)"),
103
+ priority: str | None = typer.Option(None, help="Priority (low, medium, high, urgent)"),
104
+ deal_id: str | None = typer.Option(None, "--deal-id", help="Deal ID"),
105
+ company_id: str | None = typer.Option(None, "--company-id", help="Company ID"),
106
+ contact_id: str | None = typer.Option(None, "--contact-id", help="Contact ID"),
107
+ description: str | None = typer.Option(None, help="Description"),
108
+ ) -> None:
109
+ """Create a new activity."""
110
+ body = _build_body(
111
+ type=activity_type, title=title, due_date=due_date,
112
+ priority=priority, deal_id=deal_id, company_id=company_id,
113
+ contact_id=contact_id, description=description,
114
+ )
115
+
116
+ me = _api_request("get", "/whoami")
117
+ body["organization_id"] = me.json()["organization_id"]
118
+
119
+ resp = _api_request("post", f"{_CRM}/activities", json=body)
120
+
121
+ data = resp.json()
122
+ if ctx.obj["json"]:
123
+ print_json(data)
124
+ else:
125
+ rprint(f"[green]Created activity:[/green] {data['title']} ({data['id']})")
126
+
127
+
128
+ @activities_app.command("update")
129
+ def activities_update(
130
+ ctx: typer.Context,
131
+ activity_id: str = typer.Argument(..., help="Activity ID"),
132
+ title: str | None = typer.Option(None, help="Activity title"),
133
+ activity_type: str | None = typer.Option(None, "--type", help="Activity type (call, meeting, email, task, note)"),
134
+ status: str | None = typer.Option(None, help="Status (pending, in_progress, completed, cancelled)"),
135
+ priority: str | None = typer.Option(None, help="Priority (low, medium, high, urgent)"),
136
+ due_date: str | None = typer.Option(None, "--due-date", help="Due date (ISO format)"),
137
+ description: str | None = typer.Option(None, help="Description"),
138
+ deal_id: str | None = typer.Option(None, "--deal-id", help="Deal ID"),
139
+ company_id: str | None = typer.Option(None, "--company-id", help="Company ID"),
140
+ contact_id: str | None = typer.Option(None, "--contact-id", help="Contact ID"),
141
+ ) -> None:
142
+ """Update an existing activity."""
143
+ body = _build_body(
144
+ title=title, type=activity_type, status=status, priority=priority,
145
+ due_date=due_date, description=description, deal_id=deal_id,
146
+ company_id=company_id, contact_id=contact_id,
147
+ )
148
+
149
+ if not body:
150
+ rprint("[yellow]No fields to update.[/yellow]")
151
+ raise typer.Exit(code=1)
152
+
153
+ resp = _api_request("patch", f"{_CRM}/activities/{activity_id}", json=body)
154
+
155
+ data = resp.json()
156
+ if ctx.obj["json"]:
157
+ print_json(data)
158
+ else:
159
+ rprint(f"[green]Updated activity:[/green] {data['title']} ({data['id']})")
160
+
161
+
162
+ @activities_app.command("complete")
163
+ def activities_complete(
164
+ ctx: typer.Context,
165
+ activity_id: str = typer.Argument(..., help="Activity ID"),
166
+ ) -> None:
167
+ """Mark an activity as completed."""
168
+ now = datetime.now(timezone.utc).isoformat()
169
+ resp = _api_request(
170
+ "patch",
171
+ f"{_CRM}/activities/{activity_id}",
172
+ json={"status": "completed", "completed_at": now},
173
+ )
174
+
175
+ data = resp.json()
176
+ if ctx.obj["json"]:
177
+ print_json(data)
178
+ else:
179
+ rprint(f"[green]Completed activity:[/green] {data['title']} ({data['id']})")
180
+
181
+
182
+ @activities_app.command("delete")
183
+ def activities_delete(
184
+ activity_id: str = typer.Argument(..., help="Activity ID"),
185
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
186
+ ) -> None:
187
+ """Delete an activity."""
188
+ if not yes:
189
+ typer.confirm(f"Delete activity {activity_id}?", abort=True)
190
+
191
+ _api_request("delete", f"{_CRM}/activities/{activity_id}")
192
+
193
+ rprint(f"[green]Deleted activity {activity_id}[/green]")