chaotic-cli 1.0.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,204 @@
1
+ Metadata-Version: 2.4
2
+ Name: chaotic-cli
3
+ Version: 1.0.0
4
+ Summary: CLI for Chaotic issue tracking system
5
+ Project-URL: Homepage, https://chaotic.sh
6
+ Project-URL: Repository, https://github.com/thethirdbearsolutions/chaotic.sh
7
+ Project-URL: Documentation, https://chaotic.sh/docs
8
+ Author: Third Bear Solutions
9
+ License-Expression: MIT
10
+ Keywords: chaotic,cli,issue-tracker,project-management
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Bug Tracking
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: click>=8.1.7
24
+ Requires-Dist: httpx>=0.26.0
25
+ Requires-Dist: rich>=13.7.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest-mock>=3.12.0; extra == 'dev'
28
+ Requires-Dist: pytest>=7.4.0; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # Chaotic CLI
32
+
33
+ Command-line interface for the Chaotic issue tracker.
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ cd cli
39
+ pip install -e .
40
+ ```
41
+
42
+ ## Configuration
43
+
44
+ Set the API URL (defaults to http://localhost:24267):
45
+ ```bash
46
+ chaotic config set-url https://your-api-server.com
47
+ ```
48
+
49
+ ## Authentication
50
+
51
+ ### Sign up / Login
52
+ ```bash
53
+ chaotic auth signup
54
+ chaotic auth login
55
+ ```
56
+
57
+ ### API Keys (for scripts/automation)
58
+ ```bash
59
+ chaotic auth keys list
60
+ chaotic auth keys create
61
+ chaotic auth keys revoke <key-id>
62
+ chaotic auth set-key ck_your_api_key_here
63
+ chaotic auth clear-key
64
+ ```
65
+
66
+ ### Check current user
67
+ ```bash
68
+ chaotic auth whoami
69
+ chaotic me # Shortcut for 'auth whoami'
70
+ ```
71
+
72
+ ## Status
73
+
74
+ Check current context (user, team, project):
75
+ ```bash
76
+ chaotic status
77
+ ```
78
+
79
+ ## Teams
80
+
81
+ ```bash
82
+ chaotic team list # List your teams
83
+ chaotic team use <team-id> # Set current team
84
+ chaotic team show # Show current team details
85
+ chaotic team create # Create a new team
86
+ chaotic team members # List team members
87
+ chaotic team invite # Invite a member
88
+ chaotic team accept-invite # Accept an invitation
89
+ ```
90
+
91
+ ## Projects
92
+
93
+ ```bash
94
+ chaotic project list # List projects in current team
95
+ chaotic project use <project-id> # Set current project
96
+ chaotic project show # Show current project details
97
+ chaotic project create # Create a new project
98
+ chaotic project update # Update current project
99
+ ```
100
+
101
+ ## Issues
102
+
103
+ ### Listing and viewing
104
+ ```bash
105
+ chaotic issue list # List issues in current project
106
+ chaotic issue list --status in_progress # Filter by status
107
+ chaotic issue list --priority high # Filter by priority
108
+ chaotic issue mine # List issues assigned to me
109
+ chaotic issue mine --status in_progress # Filter my issues by status
110
+ chaotic issue search "search term" # Search issues across the team
111
+ chaotic issue show CHT-123 # Show issue details
112
+ chaotic issue view CHT-123 # Alias for 'show'
113
+ chaotic issue open CHT-123 # Open issue in browser
114
+ ```
115
+
116
+ ### Creating issues
117
+ ```bash
118
+ chaotic issue create --title "Bug fix"
119
+ chaotic issue create --title "Bug fix" --project CHT # Specify project by key
120
+ chaotic issue create --title "Sub-task" --parent CHT-123 # Create sub-issue
121
+ chaotic issue create --title "Feature" --priority high --status todo
122
+ ```
123
+
124
+ ### Updating issues
125
+ ```bash
126
+ chaotic issue update CHT-123 --status done
127
+ chaotic issue update CHT-123 --priority urgent
128
+ chaotic issue update CHT-123 --assignee user-id
129
+ chaotic issue update CHT-123 --estimate 5
130
+ chaotic issue move CHT-123 in_progress # Quick status change
131
+ chaotic issue assign CHT-123 me # Assign to yourself
132
+ chaotic issue assign CHT-123 agent-bot # Assign to an agent by name/ID
133
+ chaotic issue assign CHT-123 # Unassign
134
+ ```
135
+
136
+ ### Comments
137
+ ```bash
138
+ chaotic issue comment CHT-123 "This is a comment"
139
+ ```
140
+
141
+ ### Sub-issues and Relations
142
+ ```bash
143
+ chaotic issue sub-issues CHT-123 # List sub-issues
144
+ chaotic issue relations CHT-123 # Show issue relations
145
+ chaotic issue block CHT-1 CHT-2 # CHT-1 blocks CHT-2
146
+ chaotic issue block CHT-1 CHT-2 --type duplicates # Mark as duplicate
147
+ chaotic issue block CHT-1 CHT-2 --type relates_to # Related issues
148
+ chaotic issue unblock CHT-1 CHT-2 # Remove relation
149
+ ```
150
+
151
+ ### Deleting
152
+ ```bash
153
+ chaotic issue delete CHT-123
154
+ ```
155
+
156
+ ## Sprints
157
+
158
+ ```bash
159
+ chaotic sprint list # List sprints in current project
160
+ chaotic sprint current # Get or create the current sprint
161
+ chaotic sprint close <id> # Close the current sprint
162
+ chaotic sprint delete <id> # Delete a sprint
163
+ ```
164
+
165
+ ## Labels
166
+
167
+ ```bash
168
+ chaotic label list # List labels in current team
169
+ chaotic label create # Create a new label
170
+ chaotic label delete <id> # Delete a label
171
+ ```
172
+
173
+ ## Documents
174
+
175
+ ```bash
176
+ chaotic doc list # List documents in current team
177
+ chaotic doc show <id> # Show document content
178
+ chaotic doc create # Create a new document
179
+ chaotic doc update <id> # Update a document
180
+ chaotic doc delete <id> # Delete a document
181
+ ```
182
+
183
+ ## Status Values
184
+
185
+ - `backlog` - Not yet planned
186
+ - `todo` - Planned for work
187
+ - `in_progress` - Currently being worked on
188
+ - `in_review` - Awaiting review
189
+ - `done` - Completed
190
+ - `canceled` - Canceled
191
+
192
+ ## Priority Values
193
+
194
+ - `no_priority` - No priority set
195
+ - `low` - Low priority
196
+ - `medium` - Medium priority
197
+ - `high` - High priority
198
+ - `urgent` - Urgent, needs immediate attention
199
+
200
+ ## Relation Types
201
+
202
+ - `blocks` - Issue blocks another issue
203
+ - `relates_to` - Issues are related
204
+ - `duplicates` - Issue is a duplicate of another
@@ -0,0 +1,9 @@
1
+ cli/__init__.py,sha256=aqZS0F5cTFRSdt3Hi0k5JhOXCA4-hBSAMxg3YPQbRDc,36
2
+ cli/client.py,sha256=CKWQJkumD_UFn_eYMXXVJjjszQhXt-mw38z9pghm__A,18163
3
+ cli/config.py,sha256=JNG0XpUV-XAacpRPNV3TeEADKcLcw_mRooVCaEys97E,15154
4
+ cli/main.py,sha256=KmSyGRe5A9n4Hq2JazZ4fsRMD5SDSpNFyTxzzA-cyic,125022
5
+ cli/system.py,sha256=8dzUTAxuru6KPi2VftAV_plnYWCLDPXvX2y40ea-DOw,37274
6
+ chaotic_cli-1.0.0.dist-info/METADATA,sha256=HuGrZ1ZrF3-G_pH8eocfd4P2PuWAZc6f3huKt_8dhfA,5880
7
+ chaotic_cli-1.0.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
8
+ chaotic_cli-1.0.0.dist-info/entry_points.txt,sha256=99B9jsPWPv3ajZ3oFUWf1RxH_U6pxaZTNsXuQ600mLk,41
9
+ chaotic_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ chaotic = cli.main:cli
cli/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ # Chaotic CLI
2
+ __version__ = "1.0.0"
cli/client.py ADDED
@@ -0,0 +1,432 @@
1
+ """API client for CLI."""
2
+ import httpx
3
+ from urllib.parse import quote, urlencode
4
+ from cli.config import get_api_url, get_token, get_api_key
5
+
6
+
7
+ class APIError(Exception):
8
+ """API error with message."""
9
+ pass
10
+
11
+
12
+ class Client:
13
+ """API client."""
14
+
15
+ def _headers(self) -> dict:
16
+ """Get request headers.
17
+
18
+ Reads config on each call to support profile switching.
19
+ """
20
+ headers = {"Content-Type": "application/json"}
21
+ # Prefer API key over JWT token
22
+ api_key = get_api_key()
23
+ if api_key:
24
+ headers["Authorization"] = f"Bearer {api_key}"
25
+ else:
26
+ token = get_token()
27
+ if token:
28
+ headers["Authorization"] = f"Bearer {token}"
29
+ return headers
30
+
31
+ def _request(self, method: str, path: str, data: dict = None) -> dict | None:
32
+ """Make API request."""
33
+ url = f"{get_api_url()}{path}"
34
+ with httpx.Client() as client:
35
+ response = client.request(
36
+ method,
37
+ url,
38
+ headers=self._headers(),
39
+ json=data if data is not None else None,
40
+ timeout=30.0,
41
+ )
42
+
43
+ if response.status_code == 204:
44
+ return None
45
+
46
+ result = response.json()
47
+
48
+ if not response.is_success:
49
+ detail = result.get("detail", "Unknown error")
50
+ # Format structured errors into actionable messages
51
+ if isinstance(detail, dict):
52
+ if "pending_rituals" in detail and "issue_id" in detail:
53
+ # Ticket ritual error - show first pending ritual with its prompt
54
+ issue_id = detail.get("issue_id", "")
55
+ pending = detail.get("pending_rituals", [])
56
+
57
+ if pending:
58
+ first = pending[0]
59
+ if isinstance(first, dict):
60
+ name = first.get("name", "unknown")
61
+ prompt = first.get("prompt", "")
62
+ prompt_line = f"\n {prompt}" if prompt else ""
63
+ msg = f"Pending ritual: {name}{prompt_line}"
64
+ else:
65
+ # Fallback for old format (just names)
66
+ name = first
67
+ msg = f"Pending ritual: {name}"
68
+ msg += f"\n\nAfter completing the ritual, run: chaotic ritual attest {name} --ticket {issue_id}"
69
+ else:
70
+ msg = "Ticket has pending rituals."
71
+
72
+ raise APIError(msg)
73
+ elif "pending_rituals" in detail:
74
+ raise APIError("Sprint is in limbo. Run `chaotic ritual list` to continue.")
75
+ elif "arrears_by" in detail:
76
+ raise APIError("Sprint is in arrears. Run `chaotic sprint close` to continue.")
77
+ else:
78
+ raise APIError(detail.get("message", str(detail)))
79
+ raise APIError(detail)
80
+
81
+ return result
82
+
83
+ # Auth
84
+ def signup(self, name: str, email: str, password: str) -> dict:
85
+ return self._request("POST", "/auth/signup", {"name": name, "email": email, "password": password})
86
+
87
+ def login(self, email: str, password: str) -> dict:
88
+ return self._request("POST", "/auth/login", {"email": email, "password": password})
89
+
90
+ def get_me(self) -> dict:
91
+ return self._request("GET", "/auth/me")
92
+
93
+ # API keys
94
+ def list_api_keys(self) -> list:
95
+ return self._request("GET", "/api-keys")
96
+
97
+ def create_api_key(self, name: str) -> dict:
98
+ return self._request("POST", "/api-keys", {"name": name})
99
+
100
+ def revoke_api_key(self, api_key_id: str):
101
+ return self._request("DELETE", f"/api-keys/{api_key_id}")
102
+
103
+ # Teams
104
+ def create_team(self, name: str, key: str, description: str = None) -> dict:
105
+ data = {"name": name, "key": key}
106
+ if description:
107
+ data["description"] = description
108
+ return self._request("POST", "/teams", data)
109
+
110
+ def get_teams(self) -> list:
111
+ return self._request("GET", "/teams")
112
+
113
+ def get_team(self, team_id: str) -> dict:
114
+ return self._request("GET", f"/teams/{team_id}")
115
+
116
+ def update_team(self, team_id: str, **kwargs) -> dict:
117
+ return self._request("PATCH", f"/teams/{team_id}", kwargs)
118
+
119
+ def delete_team(self, team_id: str):
120
+ return self._request("DELETE", f"/teams/{team_id}")
121
+
122
+ # Team members
123
+ def get_team_members(self, team_id: str) -> list:
124
+ return self._request("GET", f"/teams/{team_id}/members")
125
+
126
+ def remove_member(self, team_id: str, user_id: str):
127
+ return self._request("DELETE", f"/teams/{team_id}/members/{user_id}")
128
+
129
+ # Invitations
130
+ def invite_member(self, team_id: str, email: str, role: str = "member") -> dict:
131
+ return self._request("POST", f"/teams/{team_id}/invitations", {"email": email, "role": role})
132
+
133
+ def get_invitations(self, team_id: str) -> list:
134
+ return self._request("GET", f"/teams/{team_id}/invitations")
135
+
136
+ def accept_invitation(self, token: str) -> dict:
137
+ return self._request("POST", f"/teams/invitations/{token}/accept")
138
+
139
+ def delete_invitation(self, team_id: str, invitation_id: str):
140
+ return self._request("DELETE", f"/teams/{team_id}/invitations/{invitation_id}")
141
+
142
+ # Projects
143
+ def create_project(self, team_id: str, name: str, key: str, **kwargs) -> dict:
144
+ data = {"name": name, "key": key, **kwargs}
145
+ return self._request("POST", f"/projects?team_id={team_id}", data)
146
+
147
+ def get_projects(self, team_id: str) -> list:
148
+ return self._request("GET", f"/projects?team_id={team_id}")
149
+
150
+ def get_project(self, project_id: str) -> dict:
151
+ return self._request("GET", f"/projects/{project_id}")
152
+
153
+ def update_project(self, project_id: str, **kwargs) -> dict:
154
+ return self._request("PATCH", f"/projects/{project_id}", kwargs)
155
+
156
+ def delete_project(self, project_id: str):
157
+ return self._request("DELETE", f"/projects/{project_id}")
158
+
159
+ # Issues
160
+ def create_issue(self, project_id: str, title: str, **kwargs) -> dict:
161
+ data = {"title": title, **kwargs}
162
+ return self._request("POST", f"/issues?project_id={project_id}", data)
163
+
164
+ def get_issues(self, project_id: str = None, sprint_id: str = None, assignee_id: str = None, status: str = None, priority: str = None, limit: int = None, parent_id: str = None, sort_by: str = None, order: str = None, label: str = None, search: str = None) -> list:
165
+ params = {}
166
+ if project_id:
167
+ params["project_id"] = project_id
168
+ if sprint_id:
169
+ params["sprint_id"] = sprint_id
170
+ if assignee_id:
171
+ params["assignee_id"] = assignee_id
172
+ if limit:
173
+ params["limit"] = limit
174
+ if parent_id:
175
+ params["parent_id"] = parent_id
176
+ if sort_by:
177
+ params["sort_by"] = sort_by
178
+ if order:
179
+ params["order"] = order
180
+ if search:
181
+ params["search"] = search
182
+
183
+ # Build base query with urlencode for single params
184
+ query = urlencode(params) if params else ""
185
+
186
+ # Handle comma-separated status values (CHT-502)
187
+ if status:
188
+ status_values = [s.strip() for s in status.split(",")]
189
+ status_params = "&".join([f"status={quote(s)}" for s in status_values])
190
+ query = f"{query}&{status_params}" if query else status_params
191
+
192
+ # Handle comma-separated priority values
193
+ if priority:
194
+ priority_values = [p.strip() for p in priority.split(",")]
195
+ priority_params = "&".join([f"priority={quote(p)}" for p in priority_values])
196
+ query = f"{query}&{priority_params}" if query else priority_params
197
+
198
+ # Handle comma-separated label values (CHT-696)
199
+ if label:
200
+ label_values = [l.strip() for l in label.split(",")]
201
+ label_params = "&".join([f"label={quote(l)}" for l in label_values])
202
+ query = f"{query}&{label_params}" if query else label_params
203
+
204
+ return self._request("GET", f"/issues?{query}")
205
+
206
+ def get_issue(self, issue_id: str) -> dict:
207
+ return self._request("GET", f"/issues/{issue_id}")
208
+
209
+ def get_issue_by_identifier(self, identifier: str) -> dict:
210
+ return self._request("GET", f"/issues/identifier/{identifier}")
211
+
212
+ def search_issues(self, team_id: str, query: str, project_id: str = None) -> list:
213
+ url = f"/issues/search?team_id={team_id}&q={quote(query)}"
214
+ if project_id:
215
+ url += f"&project_id={project_id}"
216
+ return self._request("GET", url)
217
+
218
+ def update_issue(self, issue_id: str, **kwargs) -> dict:
219
+ return self._request("PATCH", f"/issues/{issue_id}", kwargs)
220
+
221
+ def delete_issue(self, issue_id: str):
222
+ return self._request("DELETE", f"/issues/{issue_id}")
223
+
224
+ # Sub-issues
225
+ def get_sub_issues(self, issue_id: str) -> list:
226
+ return self._request("GET", f"/issues/{issue_id}/sub-issues")
227
+
228
+ # Relations
229
+ def get_relations(self, issue_id: str) -> list:
230
+ return self._request("GET", f"/issues/{issue_id}/relations")
231
+
232
+ def create_relation(self, issue_id: str, related_issue_id: str, relation_type: str = "blocks") -> dict:
233
+ return self._request("POST", f"/issues/{issue_id}/relations", {
234
+ "related_issue_id": related_issue_id,
235
+ "relation_type": relation_type
236
+ })
237
+
238
+ def delete_relation(self, issue_id: str, relation_id: str):
239
+ return self._request("DELETE", f"/issues/{issue_id}/relations/{relation_id}")
240
+
241
+ # Comments
242
+ def create_comment(self, issue_id: str, content: str) -> dict:
243
+ return self._request("POST", f"/issues/{issue_id}/comments", {"content": content})
244
+
245
+ def get_comments(self, issue_id: str) -> list:
246
+ return self._request("GET", f"/issues/{issue_id}/comments")
247
+
248
+ def delete_comment(self, issue_id: str, comment_id: str):
249
+ return self._request("DELETE", f"/issues/{issue_id}/comments/{comment_id}")
250
+
251
+ # Sprints
252
+ def get_sprints(self, project_id: str, status: str = None) -> list:
253
+ url = f"/sprints?project_id={project_id}"
254
+ if status:
255
+ url += f"&sprint_status={status}"
256
+ return self._request("GET", url)
257
+
258
+ def get_sprint(self, sprint_id: str) -> dict:
259
+ return self._request("GET", f"/sprints/{sprint_id}")
260
+
261
+ def update_sprint(self, sprint_id: str, **kwargs) -> dict:
262
+ return self._request("PATCH", f"/sprints/{sprint_id}", kwargs)
263
+
264
+ def close_sprint(self, sprint_id: str) -> dict:
265
+ return self._request("POST", f"/sprints/{sprint_id}/close")
266
+
267
+ # Documents
268
+ def create_document(self, team_id: str, title: str, **kwargs) -> dict:
269
+ data = {"title": title, **kwargs}
270
+ return self._request("POST", f"/documents?team_id={team_id}", data)
271
+
272
+ def get_documents(self, team_id: str, project_id: str = None, sprint_id: str = None, search: str = None) -> list:
273
+ url = f"/documents?team_id={team_id}"
274
+ if project_id:
275
+ url += f"&project_id={project_id}"
276
+ if sprint_id:
277
+ url += f"&sprint_id={sprint_id}"
278
+ if search:
279
+ url += f"&search={search}"
280
+ return self._request("GET", url)
281
+
282
+ def get_document(self, document_id: str) -> dict:
283
+ return self._request("GET", f"/documents/{document_id}")
284
+
285
+ def update_document(self, document_id: str, **kwargs) -> dict:
286
+ return self._request("PATCH", f"/documents/{document_id}", kwargs)
287
+
288
+ def delete_document(self, document_id: str):
289
+ return self._request("DELETE", f"/documents/{document_id}")
290
+
291
+ # Document-Issue Links
292
+ def get_document_issues(self, document_id: str) -> list:
293
+ return self._request("GET", f"/documents/{document_id}/issues")
294
+
295
+ def link_document_to_issue(self, document_id: str, issue_id: str) -> dict:
296
+ return self._request("POST", f"/documents/{document_id}/issues/{issue_id}")
297
+
298
+ def unlink_document_from_issue(self, document_id: str, issue_id: str):
299
+ return self._request("DELETE", f"/documents/{document_id}/issues/{issue_id}")
300
+
301
+ def get_document_comments(self, document_id: str) -> list:
302
+ return self._request("GET", f"/documents/{document_id}/comments")
303
+
304
+ def create_document_comment(self, document_id: str, content: str) -> dict:
305
+ return self._request("POST", f"/documents/{document_id}/comments", {"content": content})
306
+
307
+ def get_issue_documents(self, issue_id: str) -> list:
308
+ return self._request("GET", f"/issues/{issue_id}/documents")
309
+
310
+ # Labels
311
+ def create_label(self, team_id: str, name: str, **kwargs) -> dict:
312
+ data = {"name": name, **kwargs}
313
+ return self._request("POST", f"/labels?team_id={team_id}", data)
314
+
315
+ def get_labels(self, team_id: str) -> list:
316
+ return self._request("GET", f"/labels?team_id={team_id}")
317
+
318
+ def update_label(self, label_id: str, **kwargs) -> dict:
319
+ return self._request("PATCH", f"/labels/{label_id}", kwargs)
320
+
321
+ def delete_label(self, label_id: str):
322
+ return self._request("DELETE", f"/labels/{label_id}")
323
+
324
+ def add_label_to_issue(self, issue_id: str, label_id: str) -> dict:
325
+ """Add a label to an issue."""
326
+ return self._request("POST", f"/issues/{issue_id}/labels", {"label_id": label_id})
327
+
328
+ def remove_label_from_issue(self, issue_id: str, label_id: str) -> dict:
329
+ """Remove a label from an issue."""
330
+ return self._request("DELETE", f"/issues/{issue_id}/labels/{label_id}")
331
+
332
+ # Rituals
333
+ def get_rituals(self, project_id: str) -> list:
334
+ return self._request("GET", f"/rituals?project_id={project_id}")
335
+
336
+ def get_limbo_status(self, project_id: str) -> dict:
337
+ return self._request("GET", f"/rituals/limbo?project_id={project_id}")
338
+
339
+ def force_clear_limbo(self, project_id: str) -> dict:
340
+ return self._request("POST", f"/rituals/force-clear-limbo?project_id={project_id}", {})
341
+
342
+ def create_ritual(self, project_id: str, name: str, prompt: str, **kwargs) -> dict:
343
+ data = {"name": name, "prompt": prompt, **kwargs}
344
+ return self._request("POST", f"/rituals?project_id={project_id}", data)
345
+
346
+ def update_ritual(self, ritual_id: str, **kwargs) -> dict:
347
+ """Update a ritual. Accepts prompt, name, approval_mode, trigger."""
348
+ data = {k: v for k, v in kwargs.items() if v is not None}
349
+ return self._request("PATCH", f"/rituals/{ritual_id}", data)
350
+
351
+ def delete_ritual(self, ritual_id: str) -> None:
352
+ """Delete (deactivate) a ritual."""
353
+ self._request("DELETE", f"/rituals/{ritual_id}")
354
+
355
+ def attest_ritual(self, ritual_id: str, project_id: str, note: str = None) -> dict:
356
+ data = {}
357
+ if note:
358
+ data["note"] = note
359
+ return self._request("POST", f"/rituals/{ritual_id}/attest?project_id={project_id}", data)
360
+
361
+ def approve_ritual(self, ritual_id: str, project_id: str) -> dict:
362
+ return self._request("POST", f"/rituals/{ritual_id}/approve?project_id={project_id}", {})
363
+
364
+ def complete_gate_ritual(self, ritual_id: str, project_id: str, note: str = None) -> dict:
365
+ data = {}
366
+ if note:
367
+ data["note"] = note
368
+ return self._request("POST", f"/rituals/{ritual_id}/complete?project_id={project_id}", data)
369
+
370
+ def get_pending_issue_rituals(self, issue_id: str) -> dict:
371
+ return self._request("GET", f"/rituals/issue/{issue_id}/pending")
372
+
373
+ def attest_ritual_for_issue(self, ritual_id: str, issue_id: str, note: str = None) -> dict:
374
+ data = {}
375
+ if note:
376
+ data["note"] = note
377
+ return self._request("POST", f"/rituals/{ritual_id}/attest-issue/{issue_id}", data)
378
+
379
+ def complete_gate_ritual_for_issue(self, ritual_id: str, issue_id: str, note: str = None) -> dict:
380
+ data = {}
381
+ if note:
382
+ data["note"] = note
383
+ return self._request("POST", f"/rituals/{ritual_id}/complete-issue/{issue_id}", data)
384
+
385
+ # Ritual Groups
386
+ def get_ritual_groups(self, project_id: str) -> list:
387
+ return self._request("GET", f"/rituals/groups?project_id={project_id}")
388
+
389
+ def get_ritual_group(self, group_id: str) -> dict:
390
+ return self._request("GET", f"/rituals/groups/{group_id}")
391
+
392
+ def create_ritual_group(self, project_id: str, name: str, selection_mode: str = "random_one") -> dict:
393
+ data = {"name": name, "selection_mode": selection_mode}
394
+ return self._request("POST", f"/rituals/groups?project_id={project_id}", data)
395
+
396
+ def update_ritual_group(self, group_id: str, **kwargs) -> dict:
397
+ data = {k: v for k, v in kwargs.items() if v is not None}
398
+ return self._request("PATCH", f"/rituals/groups/{group_id}", data)
399
+
400
+ def delete_ritual_group(self, group_id: str) -> None:
401
+ self._request("DELETE", f"/rituals/groups/{group_id}")
402
+
403
+ def get_current_sprint(self, project_id: str) -> dict:
404
+ return self._request("GET", f"/sprints/current?project_id={project_id}")
405
+
406
+ # Agents
407
+ def create_team_agent(self, team_id: str, name: str) -> dict:
408
+ """Create a team-scoped agent."""
409
+ return self._request("POST", f"/teams/{team_id}/agents", {"name": name})
410
+
411
+ def create_project_agent(self, project_id: str, name: str) -> dict:
412
+ """Create a project-scoped agent."""
413
+ return self._request("POST", f"/projects/{project_id}/agents", {"name": name})
414
+
415
+ def get_team_agents(self, team_id: str) -> list:
416
+ """List all agents for a team."""
417
+ return self._request("GET", f"/teams/{team_id}/agents")
418
+
419
+ def get_agent(self, agent_id: str) -> dict:
420
+ """Get agent by ID."""
421
+ return self._request("GET", f"/agents/{agent_id}")
422
+
423
+ def update_agent(self, agent_id: str, **kwargs) -> dict:
424
+ """Update an agent's name or avatar."""
425
+ return self._request("PATCH", f"/agents/{agent_id}", kwargs)
426
+
427
+ def delete_agent(self, agent_id: str):
428
+ """Delete an agent."""
429
+ return self._request("DELETE", f"/agents/{agent_id}")
430
+
431
+
432
+ client = Client()