create-ironclaws 1.0.0

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.
Files changed (80) hide show
  1. package/README.md +101 -0
  2. package/bin/create.js +394 -0
  3. package/package.json +33 -0
  4. package/template/.env.example +38 -0
  5. package/template/CLAUDE.md +104 -0
  6. package/template/agent-credentials.yaml +33 -0
  7. package/template/agents.yaml +22 -0
  8. package/template/container/Dockerfile +70 -0
  9. package/template/container/Dockerfile.argus +34 -0
  10. package/template/container/agent-runner/package-lock.json +1524 -0
  11. package/template/container/agent-runner/package.json +23 -0
  12. package/template/container/agent-runner/src/index.ts +630 -0
  13. package/template/container/agent-runner/src/ipc-mcp-stdio.ts +339 -0
  14. package/template/container/agent-runner/tsconfig.json +15 -0
  15. package/template/container/build-argus.sh +25 -0
  16. package/template/container/build.sh +23 -0
  17. package/template/container/skills/agent-browser/SKILL.md +159 -0
  18. package/template/container/skills/agent-status/SKILL.md +69 -0
  19. package/template/container/skills/capabilities/SKILL.md +100 -0
  20. package/template/container/skills/edit-agent/SKILL.md +93 -0
  21. package/template/container/skills/slack-formatting/SKILL.md +92 -0
  22. package/template/container/skills/status/SKILL.md +104 -0
  23. package/template/container/tools/elastic_query.py +161 -0
  24. package/template/container/tools/gdrive_tool.py +185 -0
  25. package/template/container/tools/jira_tool.py +433 -0
  26. package/template/container/tools/slack_history_tool.py +144 -0
  27. package/template/container/tools/youtube_tool.py +174 -0
  28. package/template/docker-compose.yml +54 -0
  29. package/template/docs/how-it-works.md +496 -0
  30. package/template/eslint.config.js +32 -0
  31. package/template/groups/forge/CLAUDE.md +107 -0
  32. package/template/package-lock.json +5278 -0
  33. package/template/package.json +52 -0
  34. package/template/scripts/github-app-token.py +58 -0
  35. package/template/scripts/register-expense-agent.sh +121 -0
  36. package/template/scripts/run-migrations.ts +105 -0
  37. package/template/scripts/setup-onecli-secrets.sh +252 -0
  38. package/template/setup-agents.sh +142 -0
  39. package/template/src/channels/index.ts +13 -0
  40. package/template/src/channels/registry.test.ts +42 -0
  41. package/template/src/channels/registry.ts +28 -0
  42. package/template/src/channels/slack.test.ts +859 -0
  43. package/template/src/channels/slack.ts +373 -0
  44. package/template/src/claw-skill.test.ts +45 -0
  45. package/template/src/config.ts +94 -0
  46. package/template/src/container-runner.test.ts +221 -0
  47. package/template/src/container-runner.ts +1029 -0
  48. package/template/src/container-runtime.test.ts +149 -0
  49. package/template/src/container-runtime.ts +124 -0
  50. package/template/src/db-migration.test.ts +67 -0
  51. package/template/src/db.test.ts +484 -0
  52. package/template/src/db.ts +837 -0
  53. package/template/src/env.ts +42 -0
  54. package/template/src/formatting.test.ts +294 -0
  55. package/template/src/github-token.ts +48 -0
  56. package/template/src/google-token.ts +75 -0
  57. package/template/src/group-folder.test.ts +43 -0
  58. package/template/src/group-folder.ts +44 -0
  59. package/template/src/group-queue.test.ts +484 -0
  60. package/template/src/group-queue.ts +363 -0
  61. package/template/src/http-server.ts +343 -0
  62. package/template/src/index.ts +960 -0
  63. package/template/src/ipc-auth.test.ts +679 -0
  64. package/template/src/ipc.ts +548 -0
  65. package/template/src/logger.ts +16 -0
  66. package/template/src/mount-security.ts +421 -0
  67. package/template/src/network-policy.ts +119 -0
  68. package/template/src/remote-control.test.ts +397 -0
  69. package/template/src/remote-control.ts +224 -0
  70. package/template/src/router.ts +52 -0
  71. package/template/src/routing.test.ts +170 -0
  72. package/template/src/sender-allowlist.test.ts +216 -0
  73. package/template/src/sender-allowlist.ts +128 -0
  74. package/template/src/task-scheduler.test.ts +129 -0
  75. package/template/src/task-scheduler.ts +290 -0
  76. package/template/src/timezone.test.ts +73 -0
  77. package/template/src/timezone.ts +37 -0
  78. package/template/src/types.ts +114 -0
  79. package/template/src/worktree.ts +206 -0
  80. package/template/tsconfig.json +20 -0
@@ -0,0 +1,185 @@
1
+ """
2
+ Standalone Google Drive tool for Argus agents.
3
+ Uses OAuth refresh token — no google-auth library needed, just requests.
4
+
5
+ Usage:
6
+ # List files in the standup folder
7
+ python3 gdrive_tool.py list
8
+
9
+ # List only files not yet in processed_transcripts.txt
10
+ python3 gdrive_tool.py list --new-only
11
+
12
+ # Read a Google Doc as plain text
13
+ python3 gdrive_tool.py read --file-id 1abc123...
14
+
15
+ # Mark a file as processed (appends ID to processed_transcripts.txt)
16
+ python3 gdrive_tool.py mark-processed --file-id 1abc123...
17
+
18
+ Environment variables (required):
19
+ GOOGLE_CLIENT_ID
20
+ GOOGLE_CLIENT_SECRET
21
+ GOOGLE_REFRESH_TOKEN
22
+ GOOGLE_DRIVE_STANDUP_FOLDER_ID
23
+
24
+ Optional:
25
+ GDRIVE_PROCESSED_FILE path to processed transcripts list
26
+ default: /workspace/group/processed_transcripts.txt
27
+ """
28
+
29
+ import argparse
30
+ import json
31
+ import os
32
+ import sys
33
+
34
+ try:
35
+ import requests
36
+ except ImportError:
37
+ print("ERROR: 'requests' is not installed.", file=sys.stderr)
38
+ sys.exit(1)
39
+
40
+ TOKEN_URL = "https://oauth2.googleapis.com/token"
41
+ DRIVE_API = "https://www.googleapis.com/drive/v3"
42
+
43
+ PROCESSED_FILE = os.environ.get(
44
+ "GDRIVE_PROCESSED_FILE", "/workspace/group/processed_transcripts.txt"
45
+ )
46
+
47
+
48
+ def get_access_token():
49
+ """Get Google access token.
50
+
51
+ Uses GOOGLE_ACCESS_TOKEN injected by the NanoClaw host (short-lived, ~1h TTL).
52
+ The OAuth refresh credentials stay on the host — never in the container.
53
+ """
54
+ token = os.environ.get("GOOGLE_ACCESS_TOKEN", "").strip()
55
+ if not token:
56
+ print(
57
+ "ERROR: GOOGLE_ACCESS_TOKEN must be set. "
58
+ "The NanoClaw host generates this token — check that Google OAuth "
59
+ "credentials (GOOGLE_CLIENT_ID/SECRET/REFRESH_TOKEN) are in the host .env.",
60
+ file=sys.stderr,
61
+ )
62
+ sys.exit(1)
63
+ return token
64
+
65
+
66
+ def get_folder_id():
67
+ folder_id = os.environ.get("GOOGLE_DRIVE_STANDUP_FOLDER_ID", "").strip()
68
+ if not folder_id:
69
+ print("ERROR: GOOGLE_DRIVE_STANDUP_FOLDER_ID must be set.", file=sys.stderr)
70
+ sys.exit(1)
71
+ return folder_id
72
+
73
+
74
+ def load_processed():
75
+ """Return set of already-processed file IDs."""
76
+ if not os.path.exists(PROCESSED_FILE):
77
+ return set()
78
+ with open(PROCESSED_FILE) as f:
79
+ return set(line.strip() for line in f if line.strip())
80
+
81
+
82
+ def mark_processed(file_id):
83
+ """Append a file ID to the processed list."""
84
+ processed = load_processed()
85
+ if file_id not in processed:
86
+ with open(PROCESSED_FILE, "a") as f:
87
+ f.write(file_id + "\n")
88
+ print(f"Marked {file_id} as processed.")
89
+ else:
90
+ print(f"{file_id} was already marked as processed.")
91
+
92
+
93
+ def cmd_list(args):
94
+ token = get_access_token()
95
+ folder_id = get_folder_id()
96
+
97
+ resp = requests.get(
98
+ f"{DRIVE_API}/files",
99
+ headers={"Authorization": f"Bearer {token}"},
100
+ params={
101
+ "q": f"'{folder_id}' in parents and trashed=false",
102
+ "fields": "files(id,name,createdTime,mimeType)",
103
+ "orderBy": "createdTime asc",
104
+ "pageSize": 1000,
105
+ "supportsAllDrives": True,
106
+ "includeItemsFromAllDrives": True,
107
+ },
108
+ timeout=30,
109
+ )
110
+ resp.raise_for_status()
111
+ files = resp.json().get("files", [])
112
+
113
+ if args.new_only:
114
+ processed = load_processed()
115
+ files = [f for f in files if f["id"] not in processed]
116
+
117
+ if not files:
118
+ print("No files found." if not args.new_only else "No new files to process.")
119
+ return
120
+
121
+ print(json.dumps(files, indent=2))
122
+
123
+
124
+ def cmd_read(args):
125
+ token = get_access_token()
126
+
127
+ # Try exporting as plain text (works for Google Docs)
128
+ resp = requests.get(
129
+ f"{DRIVE_API}/files/{args.file_id}/export",
130
+ headers={"Authorization": f"Bearer {token}"},
131
+ params={"mimeType": "text/plain"},
132
+ timeout=60,
133
+ )
134
+
135
+ if resp.status_code == 400:
136
+ # Not a Google Doc — try downloading directly
137
+ resp = requests.get(
138
+ f"{DRIVE_API}/files/{args.file_id}?alt=media",
139
+ headers={"Authorization": f"Bearer {token}"},
140
+ timeout=60,
141
+ )
142
+
143
+ resp.raise_for_status()
144
+ print(resp.text)
145
+
146
+
147
+ def cmd_mark_processed(args):
148
+ mark_processed(args.file_id)
149
+
150
+
151
+ def parse_args():
152
+ p = argparse.ArgumentParser(description="Google Drive tool for Argus agents")
153
+ sub = p.add_subparsers(dest="command")
154
+
155
+ l = sub.add_parser("list", help="List files in the standup folder")
156
+ l.add_argument("--new-only", action="store_true", help="Only show unprocessed files")
157
+
158
+ r = sub.add_parser("read", help="Read a Google Doc as plain text")
159
+ r.add_argument("--file-id", required=True)
160
+
161
+ m = sub.add_parser("mark-processed", help="Mark a file as processed")
162
+ m.add_argument("--file-id", required=True)
163
+
164
+ return p.parse_args()
165
+
166
+
167
+ def main():
168
+ args = parse_args()
169
+ if not args.command:
170
+ print("ERROR: specify a command. Use --help for usage.", file=sys.stderr)
171
+ sys.exit(1)
172
+ try:
173
+ {"list": cmd_list, "read": cmd_read, "mark-processed": cmd_mark_processed}[
174
+ args.command
175
+ ](args)
176
+ except requests.HTTPError as e:
177
+ print(f"ERROR: Google Drive API error: {e}\n{e.response.text}", file=sys.stderr)
178
+ sys.exit(1)
179
+ except Exception as e:
180
+ print(f"ERROR: {e}", file=sys.stderr)
181
+ sys.exit(1)
182
+
183
+
184
+ if __name__ == "__main__":
185
+ main()
@@ -0,0 +1,433 @@
1
+ """
2
+ Jira tool for Argus agents.
3
+
4
+ Usage:
5
+ # Get next ticket to work on (highest priority, oldest first)
6
+ python3 jira_tool.py next
7
+
8
+ # Get specific ticket
9
+ python3 jira_tool.py get --issue IAI-42
10
+
11
+ # Search tickets
12
+ python3 jira_tool.py search --status "To Do"
13
+ python3 jira_tool.py search --status "In Progress" --assignee "user@example.com"
14
+
15
+ # Create a ticket
16
+ python3 jira_tool.py create --summary "Bug in BobAIA" --description "..." --type Bug
17
+ python3 jira_tool.py create --summary "Fix login" --type Bug --epic IAI-10
18
+
19
+ # Create an epic
20
+ python3 jira_tool.py create-epic --summary "Salesforce Integration" --description "..."
21
+ python3 jira_tool.py create-epic --summary "Salesforce Integration" --start-date 2026-04-15 --due-date 2026-06-30
22
+
23
+ # Search epics
24
+ python3 jira_tool.py search-epics
25
+ python3 jira_tool.py search-epics --query "Salesforce"
26
+
27
+ # Update a ticket
28
+ python3 jira_tool.py update --issue IAI-42 --status "In Progress"
29
+ python3 jira_tool.py update --issue IAI-42 --description "Updated description"
30
+ python3 jira_tool.py update --issue IAI-42 --comment "Working on this now"
31
+ python3 jira_tool.py update --issue IAI-42 --assignee-email "user@yourcompany.com"
32
+
33
+ # Find Jira account ID by email
34
+ python3 jira_tool.py user --email "user@yourcompany.com"
35
+
36
+ Environment variables (required):
37
+ JIRA_BASE_URL e.g. https://yourcompany.atlassian.net
38
+ JIRA_EMAIL Atlassian account email
39
+ JIRA_API_TOKEN Atlassian API token
40
+ JIRA_PROJECT_KEY e.g. IAI
41
+ """
42
+
43
+ import argparse
44
+ import json
45
+ import os
46
+ import sys
47
+
48
+ try:
49
+ import requests
50
+ from requests.auth import HTTPBasicAuth
51
+ except ImportError:
52
+ print("ERROR: 'requests' is not installed. Run: pip3 install requests", file=sys.stderr)
53
+ sys.exit(1)
54
+
55
+
56
+ PRIORITY_ORDER = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3, "Lowest": 4}
57
+
58
+
59
+ def get_client():
60
+ base_url = os.environ.get("JIRA_BASE_URL", "").rstrip("/")
61
+ project = os.environ.get("JIRA_PROJECT_KEY", "")
62
+
63
+ if not base_url or not project:
64
+ print(
65
+ "ERROR: JIRA_BASE_URL and JIRA_PROJECT_KEY must be set.",
66
+ file=sys.stderr,
67
+ )
68
+ sys.exit(1)
69
+
70
+ email = os.environ.get("JIRA_EMAIL", "")
71
+ token = os.environ.get("JIRA_API_TOKEN", "")
72
+ # auth is optional — if absent, OneCLI HTTPS proxy injects Authorization header.
73
+ auth = HTTPBasicAuth(email, token) if email and token else None
74
+ return base_url, auth, project
75
+
76
+
77
+ def jira_get(base_url, auth, path, params=None):
78
+ resp = requests.get(
79
+ f"{base_url}/rest/api/3{path}",
80
+ auth=auth,
81
+ headers={"Accept": "application/json"},
82
+ params=params,
83
+ timeout=30,
84
+ )
85
+ resp.raise_for_status()
86
+ return resp.json()
87
+
88
+
89
+ def jira_search(base_url, auth, jql, max_results=50, fields=None):
90
+ """POST-based search — returns the issues list directly."""
91
+ if fields is None:
92
+ fields = ["summary", "status", "priority", "issuetype",
93
+ "assignee", "reporter", "description", "created"]
94
+ body = {"jql": jql, "maxResults": max_results, "fields": fields}
95
+ resp = requests.post(
96
+ f"{base_url}/rest/api/3/search/jql",
97
+ auth=auth,
98
+ headers={"Accept": "application/json", "Content-Type": "application/json"},
99
+ json=body,
100
+ timeout=30,
101
+ )
102
+ resp.raise_for_status()
103
+ return resp.json().get("issues", [])
104
+
105
+
106
+ def jira_post(base_url, auth, path, body):
107
+ resp = requests.post(
108
+ f"{base_url}/rest/api/3{path}",
109
+ auth=auth,
110
+ headers={"Accept": "application/json", "Content-Type": "application/json"},
111
+ json=body,
112
+ timeout=30,
113
+ )
114
+ resp.raise_for_status()
115
+ return resp.json()
116
+
117
+
118
+ def jira_put(base_url, auth, path, body):
119
+ resp = requests.put(
120
+ f"{base_url}/rest/api/3{path}",
121
+ auth=auth,
122
+ headers={"Accept": "application/json", "Content-Type": "application/json"},
123
+ json=body,
124
+ timeout=30,
125
+ )
126
+ resp.raise_for_status()
127
+ return resp
128
+
129
+
130
+ def jira_transition(base_url, auth, issue_key, status_name):
131
+ """Move an issue to a new status by name."""
132
+ transitions = jira_get(base_url, auth, f"/issue/{issue_key}/transitions")
133
+ match = next(
134
+ (t for t in transitions["transitions"] if t["name"].lower() == status_name.lower()),
135
+ None,
136
+ )
137
+ if not match:
138
+ available = [t["name"] for t in transitions["transitions"]]
139
+ print(
140
+ f"ERROR: Status '{status_name}' not found. Available: {available}",
141
+ file=sys.stderr,
142
+ )
143
+ sys.exit(1)
144
+ jira_post(base_url, auth, f"/issue/{issue_key}/transitions", {"transition": {"id": match["id"]}})
145
+
146
+
147
+ def format_issue(issue):
148
+ fields = issue.get("fields", {})
149
+ priority = (fields.get("priority") or {}).get("name", "None")
150
+ status = (fields.get("status") or {}).get("name", "?")
151
+ assignee = (fields.get("assignee") or {}).get("emailAddress", "unassigned")
152
+ reporter = (fields.get("reporter") or {}).get("emailAddress", "?")
153
+ issue_type = (fields.get("issuetype") or {}).get("name", "?")
154
+ summary = fields.get("summary", "?")
155
+ key = issue.get("key", "?")
156
+
157
+ # Extract plain text from description (Atlassian Document Format)
158
+ desc_raw = fields.get("description") or {}
159
+ description = extract_adf_text(desc_raw)
160
+
161
+ return {
162
+ "key": key,
163
+ "type": issue_type,
164
+ "status": status,
165
+ "priority": priority,
166
+ "summary": summary,
167
+ "description": description,
168
+ "reporter": reporter,
169
+ "assignee": assignee,
170
+ "url": f"{os.environ.get('JIRA_BASE_URL', '').rstrip('/')}/browse/{key}",
171
+ }
172
+
173
+
174
+ def extract_adf_text(node):
175
+ """Extract plain text from Atlassian Document Format (ADF) JSON."""
176
+ if not node:
177
+ return ""
178
+ if isinstance(node, str):
179
+ return node
180
+ if node.get("type") == "text":
181
+ return node.get("text", "")
182
+ parts = []
183
+ for child in node.get("content", []):
184
+ parts.append(extract_adf_text(child))
185
+ return " ".join(p for p in parts if p).strip()
186
+
187
+
188
+ def cmd_get(args):
189
+ base_url, auth, _ = get_client()
190
+ issue = jira_get(base_url, auth, f"/issue/{args.issue}")
191
+ print(json.dumps(format_issue(issue), indent=2))
192
+
193
+
194
+ def cmd_next(args):
195
+ base_url, auth, project = get_client()
196
+ jql = f'project = {project} AND status = "To Do" ORDER BY priority ASC, created ASC'
197
+ issues = jira_search(base_url, auth, jql, max_results=10)
198
+ if not issues:
199
+ print("No tickets in To Do.")
200
+ return
201
+ def sort_key(i):
202
+ p = (i.get("fields", {}).get("priority") or {}).get("name", "Low")
203
+ return (PRIORITY_ORDER.get(p, 99), i.get("fields", {}).get("created", ""))
204
+ issues.sort(key=sort_key)
205
+ print(json.dumps(format_issue(issues[0]), indent=2))
206
+
207
+
208
+ def cmd_search(args):
209
+ base_url, auth, project = get_client()
210
+ conditions = [f"project = {project}"]
211
+ if args.status:
212
+ conditions.append(f'status = "{args.status}"')
213
+ if args.assignee:
214
+ conditions.append(f'assignee = "{args.assignee}"')
215
+ jql = " AND ".join(conditions) + " ORDER BY priority ASC, created ASC"
216
+ issues = jira_search(base_url, auth, jql)
217
+ print(json.dumps([format_issue(i) for i in issues], indent=2))
218
+
219
+
220
+ def cmd_create(args):
221
+ base_url, auth, project = get_client()
222
+
223
+ # Build description in ADF format
224
+ description_adf = {
225
+ "type": "doc",
226
+ "version": 1,
227
+ "content": [
228
+ {
229
+ "type": "paragraph",
230
+ "content": [{"type": "text", "text": args.description or ""}],
231
+ }
232
+ ],
233
+ }
234
+
235
+ body = {
236
+ "fields": {
237
+ "project": {"key": project},
238
+ "summary": args.summary,
239
+ "description": description_adf,
240
+ "issuetype": {"name": args.type or "Task"},
241
+ }
242
+ }
243
+
244
+ # Set reporter by account ID if provided
245
+ if args.reporter_account_id:
246
+ body["fields"]["reporter"] = {"id": args.reporter_account_id}
247
+
248
+ # Link to parent epic if provided
249
+ if args.epic:
250
+ body["fields"]["parent"] = {"key": args.epic}
251
+
252
+ result = jira_post(base_url, auth, "/issue", body)
253
+ key = result.get("key")
254
+ url = f"{base_url}/browse/{key}"
255
+ print(json.dumps({"key": key, "url": url, "epic": args.epic or None}))
256
+
257
+
258
+ def cmd_create_epic(args):
259
+ base_url, auth, project = get_client()
260
+
261
+ description_adf = {
262
+ "type": "doc",
263
+ "version": 1,
264
+ "content": [
265
+ {
266
+ "type": "paragraph",
267
+ "content": [{"type": "text", "text": args.description or ""}],
268
+ }
269
+ ],
270
+ }
271
+
272
+ body = {
273
+ "fields": {
274
+ "project": {"key": project},
275
+ "summary": args.summary,
276
+ "description": description_adf,
277
+ "issuetype": {"name": "Epic"},
278
+ }
279
+ }
280
+
281
+ if args.reporter_account_id:
282
+ body["fields"]["reporter"] = {"id": args.reporter_account_id}
283
+
284
+ if args.start_date:
285
+ # customfield_10015 is the standard Epic start date in Jira Cloud
286
+ body["fields"]["customfield_10015"] = args.start_date
287
+
288
+ if args.due_date:
289
+ body["fields"]["duedate"] = args.due_date
290
+
291
+ result = jira_post(base_url, auth, "/issue", body)
292
+ key = result.get("key")
293
+ url = f"{base_url}/browse/{key}"
294
+ print(json.dumps({"key": key, "url": url, "type": "Epic"}))
295
+
296
+
297
+ def cmd_search_epics(args):
298
+ base_url, auth, project = get_client()
299
+ conditions = [f"project = {project}", "issuetype = Epic"]
300
+ if args.query:
301
+ # Escape quotes in query for JQL
302
+ safe_query = args.query.replace('"', '\\"')
303
+ conditions.append(f'summary ~ "{safe_query}"')
304
+ jql = " AND ".join(conditions) + " ORDER BY created DESC"
305
+ issues = jira_search(base_url, auth, jql)
306
+ print(json.dumps([format_issue(i) for i in issues], indent=2))
307
+
308
+
309
+ def cmd_update(args):
310
+ base_url, auth, _ = get_client()
311
+
312
+ if args.status:
313
+ jira_transition(base_url, auth, args.issue, args.status)
314
+ print(f"Moved {args.issue} to '{args.status}'")
315
+
316
+ if args.description:
317
+ description_adf = {
318
+ "type": "doc",
319
+ "version": 1,
320
+ "content": [
321
+ {
322
+ "type": "paragraph",
323
+ "content": [{"type": "text", "text": args.description}],
324
+ }
325
+ ],
326
+ }
327
+ jira_put(base_url, auth, f"/issue/{args.issue}", {"fields": {"description": description_adf}})
328
+ print(f"Updated description on {args.issue}")
329
+
330
+ if args.comment:
331
+ comment_adf = {
332
+ "type": "doc",
333
+ "version": 1,
334
+ "content": [
335
+ {
336
+ "type": "paragraph",
337
+ "content": [{"type": "text", "text": args.comment}],
338
+ }
339
+ ],
340
+ }
341
+ jira_post(base_url, auth, f"/issue/{args.issue}/comment", {"body": comment_adf})
342
+ print(f"Added comment to {args.issue}")
343
+
344
+ if args.assignee_email:
345
+ result = jira_get(base_url, auth, "/user/search", params={"query": args.assignee_email})
346
+ matches = [u for u in result if u.get("emailAddress", "").lower() == args.assignee_email.lower()]
347
+ if not matches:
348
+ print(f"ERROR: No Jira user found for email '{args.assignee_email}'", file=sys.stderr)
349
+ sys.exit(1)
350
+ account_id = matches[0]["accountId"]
351
+ jira_put(base_url, auth, f"/issue/{args.issue}", {"fields": {"assignee": {"id": account_id}}})
352
+ print(f"Assigned {args.issue} to {args.assignee_email}")
353
+
354
+
355
+ def cmd_user(args):
356
+ """Find Jira account ID by email — used to set reporter/assignee."""
357
+ base_url, auth, _ = get_client()
358
+ result = jira_get(base_url, auth, "/user/search", params={"query": args.email})
359
+ matches = [u for u in result if u.get("emailAddress", "").lower() == args.email.lower()]
360
+ if not matches:
361
+ print(f"ERROR: No Jira user found for email '{args.email}'", file=sys.stderr)
362
+ sys.exit(1)
363
+ user = matches[0]
364
+ print(json.dumps({
365
+ "account_id": user.get("accountId"),
366
+ "display_name": user.get("displayName"),
367
+ "email": user.get("emailAddress"),
368
+ }))
369
+
370
+
371
+ def parse_args():
372
+ p = argparse.ArgumentParser(description="Jira tool for Argus agents")
373
+ sub = p.add_subparsers(dest="command")
374
+
375
+ sub.add_parser("next", help="Get next To Do ticket by priority")
376
+
377
+ g = sub.add_parser("get", help="Get a specific ticket")
378
+ g.add_argument("--issue", required=True)
379
+
380
+ s = sub.add_parser("search", help="Search tickets")
381
+ s.add_argument("--status")
382
+ s.add_argument("--assignee")
383
+
384
+ c = sub.add_parser("create", help="Create a ticket")
385
+ c.add_argument("--summary", required=True)
386
+ c.add_argument("--description", default="")
387
+ c.add_argument("--type", default="Task", help="Bug, Task, Story, etc.")
388
+ c.add_argument("--reporter-account-id", dest="reporter_account_id", default=None)
389
+ c.add_argument("--epic", default=None, help="Parent epic key (e.g. IAI-10) to link this ticket under")
390
+
391
+ ce = sub.add_parser("create-epic", help="Create an epic")
392
+ ce.add_argument("--summary", required=True)
393
+ ce.add_argument("--description", default="")
394
+ ce.add_argument("--reporter-account-id", dest="reporter_account_id", default=None)
395
+ ce.add_argument("--start-date", dest="start_date", default=None, help="Start date (YYYY-MM-DD)")
396
+ ce.add_argument("--due-date", dest="due_date", default=None, help="Due date (YYYY-MM-DD)")
397
+
398
+ se = sub.add_parser("search-epics", help="Search epics in the project")
399
+ se.add_argument("--query", default=None, help="Search term to filter epic summaries")
400
+
401
+ u = sub.add_parser("update", help="Update a ticket")
402
+ u.add_argument("--issue", required=True)
403
+ u.add_argument("--status", help="Transition to this status")
404
+ u.add_argument("--description", help="Update description")
405
+ u.add_argument("--comment", help="Add a comment")
406
+ u.add_argument("--assignee-email", dest="assignee_email", help="Assign to this email address")
407
+
408
+ usr = sub.add_parser("user", help="Find Jira account ID by email")
409
+ usr.add_argument("--email", required=True)
410
+
411
+ return p.parse_args()
412
+
413
+
414
+ def main():
415
+ args = parse_args()
416
+ if not args.command:
417
+ print("ERROR: specify a command. Use --help for usage.", file=sys.stderr)
418
+ sys.exit(1)
419
+ try:
420
+ {"next": cmd_next, "get": cmd_get, "search": cmd_search,
421
+ "create": cmd_create, "create-epic": cmd_create_epic,
422
+ "search-epics": cmd_search_epics,
423
+ "update": cmd_update, "user": cmd_user}[args.command](args)
424
+ except requests.HTTPError as e:
425
+ print(f"ERROR: Jira API error: {e}\n{e.response.text}", file=sys.stderr)
426
+ sys.exit(1)
427
+ except Exception as e:
428
+ print(f"ERROR: {e}", file=sys.stderr)
429
+ sys.exit(1)
430
+
431
+
432
+ if __name__ == "__main__":
433
+ main()