chattermate-cli 0.2.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.
@@ -0,0 +1,19 @@
1
+ """
2
+ ChatterMate - CLI & MCP SDK
3
+ Copyright (C) 2024 ChatterMate
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU Affero General Public License as
7
+ published by the Free Software Foundation, either version 3 of the
8
+ License, or (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU Affero General Public License for more details.
14
+
15
+ You should have received a copy of the GNU Affero General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>
17
+ """
18
+
19
+ __version__ = "0.1.0"
@@ -0,0 +1,275 @@
1
+ """
2
+ ChatterMate - API Client
3
+ Copyright (C) 2024 ChatterMate
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU Affero General Public License as
7
+ published by the Free Software Foundation, either version 3 of the
8
+ License, or (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU Affero General Public License for more details.
14
+
15
+ You should have received a copy of the GNU Affero General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>
17
+ """
18
+
19
+ from typing import Any, Callable, Dict, List, Optional
20
+
21
+ import httpx
22
+
23
+ API_PREFIX = "/api/v1"
24
+ PAT_PREFIX = "cmat_"
25
+
26
+
27
+ class ChatterMateError(Exception):
28
+ """Raised when the API returns an error or is unreachable."""
29
+
30
+ def __init__(self, message: str, status_code: Optional[int] = None):
31
+ super().__init__(message)
32
+ self.status_code = status_code
33
+
34
+
35
+ class Client:
36
+ """
37
+ Thin HTTP wrapper around the ChatterMate API.
38
+
39
+ Supports two credentials:
40
+ * a Personal Access Token (``cmat_...``) — preferred for non-interactive use, and
41
+ * a JWT access token from ``login`` (auto-refreshed once on 401 using the refresh token).
42
+
43
+ Used by both the CLI commands and the MCP server.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ api_url: str,
49
+ token: Optional[str] = None,
50
+ refresh_token: Optional[str] = None,
51
+ on_tokens: Optional[Callable[[str, Optional[str]], None]] = None,
52
+ timeout: float = 30.0,
53
+ ):
54
+ self.api_url = api_url.rstrip("/")
55
+ self.token = token
56
+ self.refresh_token = refresh_token
57
+ self.on_tokens = on_tokens
58
+ self._http = httpx.Client(timeout=timeout)
59
+
60
+ # -- internals ---------------------------------------------------------
61
+
62
+ def _is_pat(self) -> bool:
63
+ return bool(self.token and self.token.startswith(PAT_PREFIX))
64
+
65
+ def _auth_headers(self) -> Dict[str, str]:
66
+ return {"Authorization": f"Bearer {self.token}"} if self.token else {}
67
+
68
+ def _refresh(self) -> bool:
69
+ if not self.refresh_token or self._is_pat():
70
+ return False
71
+ try:
72
+ resp = self._http.post(
73
+ f"{self.api_url}{API_PREFIX}/users/refresh",
74
+ cookies={"refresh_token": self.refresh_token},
75
+ )
76
+ except httpx.HTTPError:
77
+ return False
78
+ if resp.status_code != 200:
79
+ return False
80
+ data = resp.json()
81
+ self.token = data.get("access_token", self.token)
82
+ if data.get("refresh_token"):
83
+ self.refresh_token = data["refresh_token"]
84
+ if self.on_tokens:
85
+ self.on_tokens(self.token, self.refresh_token)
86
+ return True
87
+
88
+ @staticmethod
89
+ def _format_error(resp: httpx.Response) -> str:
90
+ try:
91
+ detail = resp.json().get("detail")
92
+ except Exception:
93
+ return resp.text or f"HTTP {resp.status_code}"
94
+ if isinstance(detail, list):
95
+ # FastAPI validation errors: list of {loc, msg, ...} — but tolerate plain strings.
96
+ parts = []
97
+ for item in detail:
98
+ if isinstance(item, dict):
99
+ loc = ".".join(str(p) for p in item.get("loc", []) if p not in ("body",))
100
+ msg = item.get("msg", "")
101
+ parts.append(f"{loc}: {msg}".strip(": "))
102
+ else:
103
+ parts.append(str(item))
104
+ return "; ".join(p for p in parts if p) or f"HTTP {resp.status_code}"
105
+ return str(detail) if detail else f"HTTP {resp.status_code}"
106
+
107
+ def request(
108
+ self,
109
+ method: str,
110
+ path: str,
111
+ *,
112
+ json: Any = None,
113
+ params: Optional[Dict[str, Any]] = None,
114
+ data: Optional[Dict[str, Any]] = None,
115
+ auth: bool = True,
116
+ _retry: bool = True,
117
+ ) -> Any:
118
+ url = f"{self.api_url}{API_PREFIX}{path}"
119
+ headers = self._auth_headers() if auth else {}
120
+ try:
121
+ resp = self._http.request(
122
+ method, url, json=json, params=params, data=data, headers=headers
123
+ )
124
+ except httpx.HTTPError as e:
125
+ raise ChatterMateError(f"Could not reach {self.api_url}: {e}") from e
126
+
127
+ if resp.status_code == 401 and auth and _retry and self._refresh():
128
+ return self.request(
129
+ method, path, json=json, params=params, data=data, auth=auth, _retry=False
130
+ )
131
+
132
+ if resp.status_code >= 400:
133
+ raise ChatterMateError(self._format_error(resp), resp.status_code)
134
+
135
+ if resp.status_code == 204 or not resp.content:
136
+ return None
137
+ ctype = resp.headers.get("content-type", "")
138
+ return resp.json() if "application/json" in ctype else resp.text
139
+
140
+ # -- auth --------------------------------------------------------------
141
+
142
+ def login(self, email: str, password: str) -> Dict[str, Any]:
143
+ data = self.request(
144
+ "POST", "/users/login",
145
+ data={"username": email, "password": password}, auth=False,
146
+ )
147
+ self.token = data.get("access_token")
148
+ self.refresh_token = data.get("refresh_token")
149
+ return data
150
+
151
+ def signup(
152
+ self, name: str, domain: str, admin_email: str, admin_name: str,
153
+ admin_password: str, timezone: str = "UTC",
154
+ ) -> Dict[str, Any]:
155
+ body = {
156
+ "name": name, "domain": domain, "timezone": timezone,
157
+ "admin_email": admin_email, "admin_name": admin_name,
158
+ "admin_password": admin_password,
159
+ }
160
+ data = self.request("POST", "/organizations", json=body, auth=False)
161
+ if isinstance(data, dict) and data.get("access_token"):
162
+ self.token = data.get("access_token")
163
+ self.refresh_token = data.get("refresh_token")
164
+ return data
165
+
166
+ def signup_request_otp(self, email: str) -> Dict[str, Any]:
167
+ """Enterprise signup step 1: request an email OTP (multi-org instances)."""
168
+ return self.request(
169
+ "POST", "/enterprise/signup/verify-email", json={"email": email}, auth=False
170
+ )
171
+
172
+ def signup_verify_otp(
173
+ self, email: str, otp: str, admin_name: str, admin_password: str,
174
+ organization_name: str, domain: str, colorcode: Optional[str] = None,
175
+ ) -> Dict[str, Any]:
176
+ """Enterprise signup step 2: verify OTP and create the org + admin."""
177
+ body = {
178
+ "email": email, "otp": otp, "name": admin_name, "password": admin_password,
179
+ "organization_name": organization_name, "domain": domain,
180
+ }
181
+ if colorcode:
182
+ body["colorcode"] = colorcode
183
+ data = self.request("POST", "/enterprise/signup/verify-otp", json=body, auth=False)
184
+ if isinstance(data, dict) and data.get("access_token"):
185
+ self.token = data.get("access_token")
186
+ self.refresh_token = data.get("refresh_token")
187
+ return data
188
+
189
+ def whoami(self) -> Dict[str, Any]:
190
+ # No dedicated GET /me endpoint exists; an empty PATCH is a safe no-op that
191
+ # returns the authenticated user.
192
+ return self.request("PATCH", "/users/me", json={})
193
+
194
+ # -- personal access tokens -------------------------------------------
195
+
196
+ def create_pat(
197
+ self, name: str, scopes: Optional[List[str]] = None,
198
+ expires_in_days: Optional[int] = None,
199
+ ) -> Dict[str, Any]:
200
+ body: Dict[str, Any] = {"name": name}
201
+ if scopes:
202
+ body["scopes"] = scopes
203
+ if expires_in_days is not None:
204
+ body["expires_in_days"] = expires_in_days
205
+ return self.request("POST", "/enterprise/tokens", json=body)
206
+
207
+ def list_pats(self) -> List[Dict[str, Any]]:
208
+ return self.request("GET", "/enterprise/tokens")
209
+
210
+ def revoke_pat(self, token_id: str) -> None:
211
+ return self.request("DELETE", f"/enterprise/tokens/{token_id}")
212
+
213
+ # -- agents ------------------------------------------------------------
214
+
215
+ def list_agents(self) -> Any:
216
+ return self.request("GET", "/agent/list")
217
+
218
+ def get_agent(self, agent_id: str) -> Any:
219
+ return self.request("GET", f"/agent/{agent_id}")
220
+
221
+ def create_agent(self, payload: Dict[str, Any]) -> Any:
222
+ return self.request("POST", "/agent", json=payload)
223
+
224
+ def update_agent(self, agent_id: str, payload: Dict[str, Any]) -> Any:
225
+ return self.request("PUT", f"/agent/{agent_id}", json=payload)
226
+
227
+ # -- workflows ---------------------------------------------------------
228
+
229
+ def get_workflow_for_agent(self, agent_id: str) -> Any:
230
+ return self.request("GET", f"/workflow/agent/{agent_id}")
231
+
232
+ def create_workflow(self, payload: Dict[str, Any]) -> Any:
233
+ return self.request("POST", "/workflow", json=payload)
234
+
235
+ def update_workflow(self, workflow_id: str, payload: Dict[str, Any]) -> Any:
236
+ return self.request("PUT", f"/workflow/{workflow_id}", json=payload)
237
+
238
+ def get_workflow_nodes(self, workflow_id: str) -> Any:
239
+ return self.request("GET", f"/workflow/{workflow_id}/nodes")
240
+
241
+ def update_workflow_nodes(self, workflow_id: str, payload: Dict[str, Any]) -> Any:
242
+ return self.request("PUT", f"/workflow/{workflow_id}/nodes", json=payload)
243
+
244
+ # -- knowledge ---------------------------------------------------------
245
+
246
+ def add_knowledge(
247
+ self, org_id: str, pdf_urls: Optional[List[str]] = None,
248
+ websites: Optional[List[str]] = None, agent_id: Optional[str] = None,
249
+ ) -> Any:
250
+ body: Dict[str, Any] = {
251
+ "org_id": org_id,
252
+ "pdf_urls": pdf_urls or [],
253
+ "websites": websites or [],
254
+ }
255
+ if agent_id:
256
+ body["agent_id"] = agent_id
257
+ return self.request("POST", "/knowledge/add/urls", json=body)
258
+
259
+ def list_knowledge_for_agent(self, agent_id: str) -> Any:
260
+ return self.request("GET", f"/knowledge/agent/{agent_id}")
261
+
262
+ def link_knowledge(self, knowledge_id: int, agent_id: str) -> Any:
263
+ return self.request(
264
+ "POST", "/knowledge/link",
265
+ params={"knowledge_id": knowledge_id, "agent_id": agent_id},
266
+ )
267
+
268
+ def unlink_knowledge(self, knowledge_id: int, agent_id: str) -> Any:
269
+ return self.request(
270
+ "DELETE", "/knowledge/unlink",
271
+ params={"knowledge_id": knowledge_id, "agent_id": agent_id},
272
+ )
273
+
274
+ def knowledge_queue_status(self, queue_id: int) -> Any:
275
+ return self.request("GET", f"/knowledge/queue/{queue_id}")
@@ -0,0 +1,17 @@
1
+ """
2
+ ChatterMate - CLI Commands
3
+ Copyright (C) 2024 ChatterMate
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU Affero General Public License as
7
+ published by the Free Software Foundation, either version 3 of the
8
+ License, or (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU Affero General Public License for more details.
14
+
15
+ You should have received a copy of the GNU Affero General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>
17
+ """
@@ -0,0 +1,122 @@
1
+ """
2
+ ChatterMate - CLI Agent Commands
3
+ Copyright (C) 2024 ChatterMate
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU Affero General Public License as
7
+ published by the Free Software Foundation, either version 3 of the
8
+ License, or (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU Affero General Public License for more details.
14
+
15
+ You should have received a copy of the GNU Affero General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>
17
+ """
18
+
19
+ from typing import List, Optional
20
+
21
+ import typer
22
+ from rich.table import Table
23
+
24
+ from .. import config
25
+ from ..context import console, get_client, output, print_error, run
26
+
27
+ agent_app = typer.Typer(no_args_is_help=True, help="Create and manage AI agents.")
28
+
29
+
30
+ @agent_app.command("list")
31
+ def list_agents(as_json: bool = typer.Option(False, "--json", help="Output raw JSON.")):
32
+ """List the organization's agents."""
33
+ client = get_client()
34
+ data = run(client.list_agents)
35
+
36
+ def render(rows):
37
+ table = Table(title="Agents")
38
+ for col in ("id", "name", "type", "active"):
39
+ table.add_column(col)
40
+ for r in rows or []:
41
+ table.add_row(
42
+ str(r.get("id")), r.get("display_name") or r.get("name", ""),
43
+ str(r.get("agent_type", "")), "yes" if r.get("is_active") else "no",
44
+ )
45
+ console.print(table)
46
+
47
+ output(data, as_json, render)
48
+
49
+
50
+ @agent_app.command("get")
51
+ def get_agent(
52
+ agent_id: str = typer.Argument(..., help="Agent id."),
53
+ as_json: bool = typer.Option(False, "--json", help="Output raw JSON."),
54
+ ):
55
+ """Show a single agent."""
56
+ client = get_client()
57
+ output(run(lambda: client.get_agent(agent_id)), as_json)
58
+
59
+
60
+ @agent_app.command("create")
61
+ def create_agent(
62
+ name: str = typer.Option(..., "--name", help="Agent name."),
63
+ agent_type: str = typer.Option(
64
+ "custom", "--type",
65
+ help="Agent type: customer_support, sales, tech_support, general, custom (case-insensitive).",
66
+ ),
67
+ instruction: List[str] = typer.Option(
68
+ [], "--instruction", "-i", help="An instruction line (repeatable)."
69
+ ),
70
+ description: Optional[str] = typer.Option(None, "--description"),
71
+ display_name: Optional[str] = typer.Option(None, "--display-name"),
72
+ as_json: bool = typer.Option(False, "--json", help="Output raw JSON."),
73
+ ):
74
+ """Create an agent. Organization is taken from your stored session."""
75
+ org_id = config.load_config().get("organization_id")
76
+ if not org_id:
77
+ print_error("No organization in session. Run 'chattermate login' or 'whoami' first.")
78
+ raise typer.Exit(code=1)
79
+ payload = {
80
+ "name": name,
81
+ # API enum values are lowercase (customer_support, sales, ...); normalize for convenience.
82
+ "agent_type": agent_type.lower(),
83
+ "instructions": list(instruction) or [f"You are {name}, a helpful assistant."],
84
+ "organization_id": org_id,
85
+ }
86
+ if description is not None:
87
+ payload["description"] = description
88
+ if display_name is not None:
89
+ payload["display_name"] = display_name
90
+ client = get_client()
91
+ data = run(lambda: client.create_agent(payload))
92
+ if not as_json:
93
+ console.print(f"[green]Created agent[/green] {data.get('id')} ({name})")
94
+ output(data, as_json)
95
+
96
+
97
+ @agent_app.command("update")
98
+ def update_agent(
99
+ agent_id: str = typer.Argument(..., help="Agent id."),
100
+ instruction: List[str] = typer.Option(
101
+ [], "--instruction", "-i", help="Replace instructions (repeatable)."
102
+ ),
103
+ display_name: Optional[str] = typer.Option(None, "--display-name"),
104
+ active: Optional[bool] = typer.Option(None, "--active/--inactive"),
105
+ as_json: bool = typer.Option(False, "--json", help="Output raw JSON."),
106
+ ):
107
+ """Update an agent's instructions, display name, or active state."""
108
+ payload: dict = {}
109
+ if instruction:
110
+ payload["instructions"] = list(instruction)
111
+ if display_name is not None:
112
+ payload["display_name"] = display_name
113
+ if active is not None:
114
+ payload["is_active"] = active
115
+ if not payload:
116
+ print_error("Nothing to update. Pass --instruction, --display-name or --active/--inactive.")
117
+ raise typer.Exit(code=1)
118
+ client = get_client()
119
+ data = run(lambda: client.update_agent(agent_id, payload))
120
+ if not as_json:
121
+ console.print(f"[green]Updated agent[/green] {agent_id}")
122
+ output(data, as_json)
@@ -0,0 +1,202 @@
1
+ """
2
+ ChatterMate - CLI Auth & Token Commands
3
+ Copyright (C) 2024 ChatterMate
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU Affero General Public License as
7
+ published by the Free Software Foundation, either version 3 of the
8
+ License, or (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU Affero General Public License for more details.
14
+
15
+ You should have received a copy of the GNU Affero General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>
17
+ """
18
+
19
+ import json
20
+ from typing import Optional
21
+
22
+ import typer
23
+ from rich.table import Table
24
+
25
+ from .. import config
26
+ from ..context import console, get_client, output, print_error, run
27
+
28
+ token_app = typer.Typer(no_args_is_help=True, help="Manage personal access tokens (PATs).")
29
+
30
+
31
+ def _store_session(client, user: dict) -> None:
32
+ config.update_config(
33
+ api_url=client.api_url,
34
+ access_token=client.token,
35
+ refresh_token=client.refresh_token,
36
+ user_id=str(user.get("id")) if user.get("id") else None,
37
+ organization_id=str(user.get("organization_id")) if user.get("organization_id") else None,
38
+ email=user.get("email"),
39
+ )
40
+
41
+
42
+ def login(
43
+ email: str = typer.Option(..., "--email", "-e", prompt=True, help="Account email."),
44
+ password: str = typer.Option(
45
+ ..., "--password", "-p", prompt=True, hide_input=True, help="Account password."
46
+ ),
47
+ as_json: bool = typer.Option(False, "--json", help="Output raw JSON."),
48
+ ):
49
+ """Log in and store JWT credentials in ~/.chattermate/config.json."""
50
+ client = get_client(require_auth=False)
51
+ data = run(lambda: client.login(email, password))
52
+ user = data.get("user", {}) if isinstance(data, dict) else {}
53
+ _store_session(client, user)
54
+ if as_json:
55
+ output(data, True)
56
+ else:
57
+ console.print(f"[green]Logged in[/green] as {user.get('email', email)}")
58
+ console.print("Tip: create a long-lived token for AI agents with 'chattermate token create'.")
59
+
60
+
61
+ def signup(
62
+ name: str = typer.Option(..., "--name", prompt=True, help="Organization name."),
63
+ domain: str = typer.Option(..., "--domain", prompt=True, help="Organization domain, e.g. acme.com."),
64
+ admin_email: str = typer.Option(..., "--admin-email", prompt=True, help="Admin email."),
65
+ admin_name: str = typer.Option(..., "--admin-name", prompt=True, help="Admin full name."),
66
+ admin_password: str = typer.Option(
67
+ ..., "--admin-password", prompt=True, hide_input=True, confirmation_prompt=True,
68
+ help="Admin password.",
69
+ ),
70
+ timezone: str = typer.Option("UTC", "--timezone", help="Organization timezone."),
71
+ enterprise: bool = typer.Option(
72
+ False, "--enterprise",
73
+ help="Use the enterprise OTP signup flow (multi-org instances).",
74
+ ),
75
+ otp: Optional[str] = typer.Option(
76
+ None, "--otp",
77
+ help="OTP for --enterprise signup. If omitted you'll be prompted (localhost test OTP is 123456).",
78
+ ),
79
+ as_json: bool = typer.Option(False, "--json", help="Output raw JSON."),
80
+ ):
81
+ """Create a new organization and admin user.
82
+
83
+ Default is community signup (single-org). Use --enterprise for the OTP-based
84
+ multi-org flow available on enterprise instances.
85
+ """
86
+ client = get_client(require_auth=False)
87
+
88
+ if enterprise:
89
+ run(lambda: client.signup_request_otp(admin_email))
90
+ if not otp:
91
+ otp = typer.prompt("Enter the OTP sent to your email (localhost test OTP: 123456)")
92
+ data = run(lambda: client.signup_verify_otp(
93
+ email=admin_email, otp=otp, admin_name=admin_name,
94
+ admin_password=admin_password, organization_name=name, domain=domain,
95
+ ))
96
+ user = data.get("user", {}) if isinstance(data, dict) else {}
97
+ if client.token:
98
+ _store_session(client, user or {"organization_id": None, "email": admin_email})
99
+ else:
100
+ data = run(lambda: client.signup(
101
+ name=name, domain=domain, admin_email=admin_email,
102
+ admin_name=admin_name, admin_password=admin_password, timezone=timezone,
103
+ ))
104
+ if isinstance(data, dict) and client.token:
105
+ _store_session(client, {
106
+ "id": None,
107
+ "organization_id": data.get("id"),
108
+ "email": admin_email,
109
+ })
110
+
111
+ if as_json:
112
+ output(data, True)
113
+ else:
114
+ console.print(f"[green]Organization created:[/green] {name} ({domain})")
115
+ if client.token:
116
+ console.print("You are now logged in.")
117
+
118
+
119
+ def logout():
120
+ """Remove stored credentials."""
121
+ config.clear_auth()
122
+ console.print("[green]Logged out.[/green] Stored credentials removed.")
123
+
124
+
125
+ def whoami(as_json: bool = typer.Option(False, "--json", help="Output raw JSON.")):
126
+ """Show the currently authenticated user."""
127
+ client = get_client()
128
+ data = run(client.whoami)
129
+
130
+ def render(d):
131
+ role = d.get("role") or {}
132
+ console.print(f"[bold]{d.get('full_name') or d.get('email')}[/bold]")
133
+ console.print(f" email: {d.get('email')}")
134
+ console.print(f" user id: {d.get('id')}")
135
+ console.print(f" organization: {d.get('organization_id')}")
136
+ console.print(f" role: {role.get('name', '-')}")
137
+
138
+ output(data, as_json, render)
139
+
140
+
141
+ @token_app.command("create")
142
+ def token_create(
143
+ name: str = typer.Argument(..., help="A label for the token, e.g. 'laptop-cli'."),
144
+ expires_in_days: Optional[int] = typer.Option(
145
+ None, "--expires-in-days", help="Days until expiry. Omit for a non-expiring token."
146
+ ),
147
+ as_json: bool = typer.Option(False, "--json", help="Output raw JSON."),
148
+ ):
149
+ """Create a Personal Access Token. The secret is shown ONCE."""
150
+ client = get_client()
151
+ data = run(lambda: client.create_pat(name=name, expires_in_days=expires_in_days))
152
+ if as_json:
153
+ output(data, True)
154
+ return
155
+ token = data.get("token", "")
156
+ console.print(f"[green]Created token[/green] '{name}'.")
157
+ console.print("[bold yellow]Store it now — it will not be shown again:[/bold yellow]")
158
+ console.print(f"\n {token}\n")
159
+ env = {"CHATTERMATE_TOKEN": token}
160
+ # Only include the API URL when it isn't the default hosted endpoint.
161
+ if client.api_url.rstrip("/") != config.DEFAULT_API_URL.rstrip("/"):
162
+ env["CHATTERMATE_API_URL"] = client.api_url
163
+ snippet = {
164
+ "mcpServers": {
165
+ "chattermate": {
166
+ "command": "uvx",
167
+ "args": ["--from", "chattermate-cli", "chattermate-mcp"],
168
+ "env": env,
169
+ }
170
+ }
171
+ }
172
+ console.print("MCP client config (paste into your AI agent):")
173
+ console.print(json.dumps(snippet, indent=2))
174
+
175
+
176
+ @token_app.command("list")
177
+ def token_list(as_json: bool = typer.Option(False, "--json", help="Output raw JSON.")):
178
+ """List your personal access tokens."""
179
+ client = get_client()
180
+ data = run(client.list_pats)
181
+
182
+ def render(rows):
183
+ table = Table(title="Personal Access Tokens")
184
+ for col in ("id", "name", "prefix", "expires_at", "last_used_at", "active"):
185
+ table.add_column(col)
186
+ for r in rows or []:
187
+ table.add_row(
188
+ str(r.get("id")), r.get("name", ""), r.get("token_prefix", ""),
189
+ str(r.get("expires_at") or "never"), str(r.get("last_used_at") or "-"),
190
+ "yes" if r.get("is_active") else "no",
191
+ )
192
+ console.print(table)
193
+
194
+ output(data, as_json, render)
195
+
196
+
197
+ @token_app.command("revoke")
198
+ def token_revoke(token_id: str = typer.Argument(..., help="The token id to revoke.")):
199
+ """Revoke (delete) a personal access token."""
200
+ client = get_client()
201
+ run(lambda: client.revoke_pat(token_id))
202
+ console.print(f"[green]Revoked[/green] token {token_id}")