conduct-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.
conduct_cli/main.py ADDED
@@ -0,0 +1,1154 @@
1
+ import argparse
2
+ import json
3
+ import sys
4
+ import time
5
+ import urllib.parse
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+ from conduct_cli import api
11
+ from conduct_cli import guard as _guard
12
+
13
+ RESET = "\033[0m"
14
+ BOLD = "\033[1m"
15
+ GREEN = "\033[32m"
16
+ RED = "\033[31m"
17
+ BLUE = "\033[34m"
18
+ GRAY = "\033[90m"
19
+ CYAN = "\033[36m"
20
+ YELLOW = "\033[33m"
21
+
22
+ CONFIG_PATH = Path.home() / ".conduct" / "config.json"
23
+
24
+
25
+ # ── Config helpers ────────────────────────────────────────────────────────────
26
+
27
+ def _load_config() -> dict:
28
+ if CONFIG_PATH.exists():
29
+ return json.loads(CONFIG_PATH.read_text())
30
+ return {}
31
+
32
+
33
+ def _save_config(data: dict):
34
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
35
+ CONFIG_PATH.write_text(json.dumps(data, indent=2))
36
+
37
+
38
+ def _resolve(args, key: str, config_key=None):
39
+ """Return value from CLI args first, then config file."""
40
+ val = getattr(args, key.replace("-", "_"), None)
41
+ if val:
42
+ return val
43
+ cfg = _load_config()
44
+ return cfg.get(config_key or key)
45
+
46
+
47
+ def _require_auth(args):
48
+ """Return (server, workspace_id, api_key, token) — exit if not configured."""
49
+ server = _resolve(args, "server")
50
+ workspace = _resolve(args, "workspace")
51
+ api_key = _resolve(args, "api_key", "api_key")
52
+ token = _resolve(args, "token")
53
+
54
+ if not server:
55
+ print(f"{RED}No server set. Run: conduct login --server <url> --api-key <key>{RESET}")
56
+ sys.exit(1)
57
+ if not workspace:
58
+ print(f"{RED}No workspace set. Run: conduct login --workspace <id>{RESET}")
59
+ sys.exit(1)
60
+ if not api_key and not token:
61
+ print(f"{RED}No credentials. Run: conduct login --api-key <key>{RESET}")
62
+ sys.exit(1)
63
+
64
+ return server.rstrip("/"), workspace, api_key, token
65
+
66
+
67
+ # ── Stream helper ─────────────────────────────────────────────────────────────
68
+
69
+ def _stream_run(server: str, workflow_id: str, run_id: str, workspace_id: str, token=None, api_key=None) -> bool:
70
+ hdrs = api.headers(workspace_id, token, "application/json", api_key)
71
+ url = f"{server}/workflows/{workflow_id}/runs/{run_id}/stream"
72
+
73
+ for data in api.stream(url, hdrs):
74
+ kind = data.get("kind", "")
75
+ bid = data.get("block_id") or ""
76
+ payload = data.get("payload", data)
77
+ prefix = f"[{bid}] " if bid else ""
78
+
79
+ if kind == "block_started":
80
+ label = payload.get("label") or payload.get("type", "")
81
+ print(f"{BLUE} ▶ {prefix}{label}{RESET}")
82
+ elif kind == "block_completed":
83
+ summary = payload.get("summary") or json.dumps(payload, default=str)[:120]
84
+ print(f"{GREEN} ✓ {prefix}{summary}{RESET}")
85
+ elif kind == "block_failed":
86
+ err = payload.get("error", json.dumps(payload, default=str)[:200])
87
+ print(f"{RED} ✗ {prefix}{err}{RESET}")
88
+ elif kind == "brain_tool_call":
89
+ summary = payload.get("summary", payload.get("tool", ""))
90
+ print(f"{GRAY} · {summary}{RESET}")
91
+ elif kind == "run_completed":
92
+ print(f"{BOLD}{GREEN} ✓ done{RESET}")
93
+ elif kind == "run_failed":
94
+ err = payload.get("error", "")
95
+ print(f"{BOLD}{RED} ✗ failed: {err}{RESET}")
96
+ else:
97
+ print(f"{GRAY} {kind}: {json.dumps(payload, default=str)[:120]}{RESET}")
98
+
99
+ if kind in ("run_completed", "run_failed"):
100
+ return kind == "run_completed"
101
+
102
+ return False
103
+
104
+
105
+ def _poll_run(server: str, workflow_id: str, run_id: str, hdrs: dict) -> bool:
106
+ """Poll run status until terminal — fallback when SSE stream unavailable.
107
+
108
+ 'paused' is treated as pass: the run reached a human-approval step, which
109
+ is correct behaviour for approval-gated agents.
110
+ """
111
+ terminal = {"succeeded", "failed", "cancelled"}
112
+ for _ in range(360): # max 30 min — dependency installs can take 20-25 min
113
+ time.sleep(5)
114
+ try:
115
+ run = api.req("GET", f"{server}/runs/{run_id}", hdrs)
116
+ status = run.get("status", "")
117
+ print(f"{GRAY} status: {status}{RESET}", end="\r")
118
+ if status == "paused":
119
+ print(f"\n{GRAY} (paused — awaiting approval){RESET}")
120
+ return True
121
+ if status in terminal:
122
+ print()
123
+ return status == "succeeded"
124
+ except Exception:
125
+ pass
126
+ print(f"{RED} timed out waiting for run{RESET}")
127
+ return False
128
+
129
+
130
+ # ── Commands ──────────────────────────────────────────────────────────────────
131
+
132
+ def cmd_login(args):
133
+ server = args.server
134
+ api_key = args.api_key
135
+ workspace = args.workspace
136
+ token = args.token
137
+
138
+ if not server and not api_key and not workspace:
139
+ cfg = _load_config()
140
+ if cfg:
141
+ print(f"{BOLD}Current config ({CONFIG_PATH}):{RESET}")
142
+ print(f" server: {cfg.get('server', '—')}")
143
+ print(f" workspace: {cfg.get('workspace', '—')}")
144
+ print(f" api_key: {'set' if cfg.get('api_key') else '—'}")
145
+ else:
146
+ print("No config found. Run: conduct login --server <url> --api-key <key> --workspace <id>")
147
+ return
148
+
149
+ cfg = _load_config()
150
+ if server: cfg["server"] = server.rstrip("/")
151
+ if api_key: cfg["api_key"] = api_key
152
+ if workspace: cfg["workspace"] = workspace
153
+ if token: cfg["token"] = token
154
+
155
+ s = cfg["server"]
156
+ ak = cfg.get("api_key")
157
+ tok = cfg.get("token")
158
+
159
+ # Auto-discover workspace from API key if not provided
160
+ if ak and ak.startswith("cond_live_") and not cfg.get("workspace"):
161
+ try:
162
+ hdrs = {"X-Api-Key": ak, "Content-Type": "application/json"}
163
+ me = api.req("GET", f"{s}/me", hdrs)
164
+ cfg["workspace"] = me["workspace_id"]
165
+ print(f"{GREEN}✓ Workspace discovered:{RESET} {cfg['workspace']}")
166
+ except SystemExit:
167
+ print(f"{YELLOW}⚠ Could not auto-discover workspace. Pass --workspace <id> manually.{RESET}")
168
+
169
+ ws = cfg.get("workspace", "")
170
+ if ws and (ak or tok):
171
+ hdrs = api.headers(ws, tok, "application/json", ak)
172
+ try:
173
+ api.req("GET", f"{s}/workflows", hdrs)
174
+ print(f"{GREEN}✓ Connected to {s}{RESET}")
175
+ except SystemExit:
176
+ print(f"{RED}Could not connect — check your server URL, workspace ID, and API key.{RESET}")
177
+ sys.exit(1)
178
+
179
+ _save_config(cfg)
180
+ print(f"{GREEN}✓ Config saved to {CONFIG_PATH}{RESET}")
181
+
182
+
183
+ def cmd_agents(args):
184
+ server, workspace_id, api_key, token = _require_auth(args)
185
+ hdrs = api.headers(workspace_id, token, "application/json", api_key)
186
+
187
+ project_filter = getattr(args, "project", None)
188
+ url = f"{server}/workflows"
189
+ if project_filter:
190
+ # find project by name first
191
+ projects = api.req("GET", f"{server}/workspaces/{workspace_id}/projects", hdrs)
192
+ match = next((p for p in projects if p["name"].lower() == project_filter.lower()), None)
193
+ if not match:
194
+ print(f"{RED}Project '{project_filter}' not found.{RESET}")
195
+ sys.exit(1)
196
+ url += f"?project_id={match['id']}"
197
+
198
+ workflows = api.req("GET", url, hdrs)
199
+
200
+ if not workflows:
201
+ print("No agents found.")
202
+ return
203
+
204
+ # Fetch projects for name lookup
205
+ try:
206
+ projects = api.req("GET", f"{server}/workspaces/{workspace_id}/projects", hdrs)
207
+ proj_map = {str(p["id"]): p["name"] for p in projects}
208
+ except Exception:
209
+ proj_map = {}
210
+
211
+ print(f"\n{BOLD}{'Agent':<35} {'Project':<20} {'Playbook':<25} {'Last run':<12} {'ID'}{RESET}")
212
+ print("─" * 110)
213
+
214
+ for wf in workflows:
215
+ name = wf.get("name", "")[:34]
216
+ project = proj_map.get(str(wf.get("project_id", "")), "—")[:19]
217
+ slug = (wf.get("playbook_slug") or "—")[:24]
218
+ last_status = wf.get("last_run_status") or "—"
219
+ wf_id = str(wf.get("id", ""))
220
+
221
+ status_color = GREEN if last_status == "succeeded" else RED if last_status == "failed" else GRAY
222
+ print(f" {name:<35} {project:<20} {slug:<25} {status_color}{last_status:<12}{RESET} {GRAY}{wf_id}{RESET}")
223
+
224
+ print()
225
+
226
+
227
+ def cmd_test(args):
228
+ server, workspace_id, api_key, token = _require_auth(args)
229
+ hdrs = api.headers(workspace_id, token, "application/json", api_key)
230
+
231
+ agent_names = args.agents
232
+ run_all = getattr(args, "all", False)
233
+ project_filter = getattr(args, "project", None)
234
+ repo_override = getattr(args, "repo", None)
235
+ parallel = getattr(args, "parallel", False)
236
+
237
+ workflows = api.req("GET", f"{server}/workflows", hdrs)
238
+
239
+ if project_filter:
240
+ proj = _resolve_project(server, workspace_id, hdrs, project_filter)
241
+ proj_id = str(proj["id"])
242
+ workflows = [wf for wf in workflows if str(wf.get("project_id") or "") == proj_id]
243
+
244
+ if run_all:
245
+ targets = [wf for wf in workflows if wf.get("playbook_slug")]
246
+ if not targets:
247
+ print("No playbook-based agents found.")
248
+ return
249
+ else:
250
+ targets = []
251
+ for name in agent_names:
252
+ match = next((wf for wf in workflows if wf["name"].lower() == name.lower()), None)
253
+ if not match:
254
+ print(f"{RED}Agent '{name}' not found. Run 'conduct agents' to see available agents.{RESET}")
255
+ sys.exit(1)
256
+ if not match.get("playbook_slug"):
257
+ print(f"{YELLOW}⚠ '{name}' has no playbook_slug — no built-in test payload. Skipping.{RESET}")
258
+ continue
259
+ targets.append(match)
260
+
261
+ if not targets:
262
+ print("Nothing to test.")
263
+ return
264
+
265
+ proj_label = f" [{project_filter}]" if project_filter else ""
266
+ mode_label = f"{GRAY} --parallel{RESET}" if parallel else ""
267
+ print(f"\n{BOLD}▶ conduct test{proj_label} — {len(targets)} agent(s){RESET}{mode_label}\n")
268
+
269
+ pr_override = getattr(args, "pr", None)
270
+
271
+ def _build_payload(slug):
272
+ payload: dict = {}
273
+ if repo_override:
274
+ owner, repo = (repo_override.split("/", 1) + [""])[:2]
275
+ clone_url = f"https://github.com/{repo_override}.git"
276
+ payload.update({
277
+ "repo": repo_override,
278
+ "clone_url": clone_url,
279
+ "repo_owner": owner,
280
+ "repo_name": repo,
281
+ "repo_full_name": repo_override,
282
+ "repository": {
283
+ "full_name": repo_override,
284
+ "name": repo,
285
+ "owner": {"login": owner},
286
+ "clone_url": clone_url,
287
+ "default_branch": "main",
288
+ },
289
+ })
290
+ if pr_override:
291
+ pr = int(pr_override)
292
+ repo_path = repo_override or ""
293
+ payload.update({
294
+ "number": pr,
295
+ "pull_request": {
296
+ "number": pr,
297
+ "html_url": f"https://github.com/{repo_path}/pull/{pr}" if repo_path else "",
298
+ "diff_url": f"https://github.com/{repo_path}/pull/{pr}.diff" if repo_path else "",
299
+ "title": f"PR #{pr}",
300
+ "user": {"login": ""},
301
+ "base": {"ref": "main"},
302
+ "head": {"ref": ""},
303
+ },
304
+ })
305
+ return payload
306
+
307
+ if parallel:
308
+ _run_tests_parallel(server, workspace_id, api_key, token, hdrs, targets, _build_payload)
309
+ else:
310
+ _run_tests_serial(server, workspace_id, api_key, token, hdrs, targets, _build_payload)
311
+
312
+
313
+ def _run_tests_serial(server, workspace_id, api_key, token, hdrs, targets, build_payload):
314
+ results = []
315
+ for wf in targets:
316
+ name = wf["name"]
317
+ wf_id = str(wf["id"])
318
+ slug = wf.get("playbook_slug", "")
319
+
320
+ print(f"{CYAN}── {name}{RESET} {GRAY}({slug}){RESET}")
321
+ try:
322
+ run = api.req("POST", f"{server}/workflows/{wf_id}/trigger", hdrs, build_payload(slug))
323
+ except SystemExit:
324
+ results.append((name, False, None))
325
+ print()
326
+ continue
327
+
328
+ run_id = run.get("run_id")
329
+ print(f" {GRAY}run: {run_id}{RESET}")
330
+
331
+ try:
332
+ ok = _stream_run(server, wf_id, run_id, workspace_id, token, api_key)
333
+ except Exception:
334
+ ok = _poll_run(server, wf_id, run_id, hdrs)
335
+
336
+ results.append((name, ok, run_id))
337
+ print()
338
+
339
+ _print_results(results)
340
+
341
+
342
+ def _run_tests_parallel(server, workspace_id, api_key, token, hdrs, targets, build_payload):
343
+ """Fire all triggers immediately, then poll all runs concurrently."""
344
+ import threading
345
+
346
+ # Phase 1: fire all triggers at once
347
+ pending = [] # list of (name, run_id) or (name, None) on trigger failure
348
+ for wf in targets:
349
+ name = wf["name"]
350
+ wf_id = str(wf["id"])
351
+ slug = wf.get("playbook_slug", "")
352
+ print(f" {GRAY}→ triggering {name}{RESET}")
353
+ try:
354
+ run = api.req("POST", f"{server}/workflows/{wf_id}/trigger", hdrs, build_payload(slug))
355
+ run_id = run.get("run_id")
356
+ print(f" {GRAY}run: {run_id}{RESET}")
357
+ pending.append((name, wf_id, run_id))
358
+ except SystemExit:
359
+ pending.append((name, wf_id, None))
360
+
361
+ print(f"\n Polling {len(pending)} runs concurrently…\n")
362
+
363
+ results_lock = threading.Lock()
364
+ results: list = [None] * len(pending)
365
+
366
+ def _poll(idx, name, wf_id, run_id):
367
+ if run_id is None:
368
+ with results_lock:
369
+ results[idx] = (name, False, None)
370
+ return
371
+ ok = _poll_run(server, wf_id, run_id, hdrs)
372
+ with results_lock:
373
+ results[idx] = (name, ok, run_id)
374
+ icon = f"{GREEN}✓{RESET}" if ok else f"{RED}✗{RESET}"
375
+ print(f" {icon} {name}")
376
+
377
+ threads = [
378
+ threading.Thread(target=_poll, args=(i, name, wf_id, run_id), daemon=True)
379
+ for i, (name, wf_id, run_id) in enumerate(pending)
380
+ ]
381
+ for t in threads:
382
+ t.start()
383
+ for t in threads:
384
+ t.join()
385
+
386
+ _print_results(results)
387
+
388
+
389
+ def _print_results(results):
390
+ passed = sum(1 for _, ok, _ in results if ok)
391
+ failed = len(results) - passed
392
+
393
+ print(f"\n{BOLD}{'─' * 60}{RESET}")
394
+ print(f"{BOLD}Results:{RESET}")
395
+ for name, ok, run_id in results:
396
+ icon = f"{GREEN}✓{RESET}" if ok else f"{RED}✗{RESET}"
397
+ rid = f"{GRAY}{run_id[:8]}…{RESET}" if run_id else ""
398
+ print(f" {icon} {name:<40} {rid}")
399
+
400
+ print()
401
+ color = GREEN if failed == 0 else RED
402
+ print(f"{BOLD}{color}{passed}/{len(results)} passed{RESET}\n")
403
+
404
+ sys.exit(0 if failed == 0 else 1)
405
+
406
+
407
+ # ── Environment helpers ───────────────────────────────────────────────────────
408
+
409
+ def _list_environments(server: str, workspace_id: str, hdrs: dict) -> list:
410
+ return api.req("GET", f"{server}/environments", hdrs)
411
+
412
+
413
+ def _resolve_environment(server: str, workspace_id: str, hdrs: dict, name: str) -> dict:
414
+ envs = _list_environments(server, workspace_id, hdrs)
415
+ match = next((e for e in envs if e["name"].lower() == name.lower()), None)
416
+ if not match:
417
+ print(f"{RED}Environment '{name}' not found. Run 'conduct environments' to list environments.{RESET}")
418
+ sys.exit(1)
419
+ return match
420
+
421
+
422
+ # ── Environment commands ──────────────────────────────────────────────────────
423
+
424
+ def cmd_environments(args):
425
+ server, workspace_id, api_key, token = _require_auth(args)
426
+ hdrs = api.headers(workspace_id, token, "application/json", api_key)
427
+ envs = _list_environments(server, workspace_id, hdrs)
428
+
429
+ if not envs:
430
+ print("No environments found. Create one: conduct create environment <name>")
431
+ return
432
+
433
+ print(f"\n{BOLD}{'Environment':<30} {'ID'}{RESET}")
434
+ print("─" * 70)
435
+ for e in envs:
436
+ print(f" {e['name']:<30} {GRAY}{e['id']}{RESET}")
437
+ print()
438
+
439
+
440
+ def cmd_credentials(args):
441
+ server, workspace_id, api_key, token = _require_auth(args)
442
+ hdrs = api.headers(workspace_id, token, "application/json", api_key)
443
+ env = _resolve_environment(server, workspace_id, hdrs, args.environment)
444
+
445
+ rows = api.req("GET", f"{server}/credentials/env-vars/{env['id']}", hdrs)
446
+
447
+ if not rows:
448
+ print(f"No credentials in environment '{args.environment}'.")
449
+ print(f" Add one: conduct set credential --environment \"{args.environment}\" --key GITHUB_TOKEN --value <token>")
450
+ return
451
+
452
+ print(f"\n{BOLD}Credentials — {args.environment}{RESET}\n")
453
+ print(f"{BOLD}{'Key':<30} {'Value'}{RESET}")
454
+ print("─" * 55)
455
+ for row in rows:
456
+ key = row["key"]
457
+ val = row["value"]
458
+ masked = val[:4] + "***" if val and len(val) > 4 else "***"
459
+ print(f" {key:<30} {GRAY}{masked}{RESET}")
460
+ print()
461
+
462
+
463
+ def _do_set_credential(server, workspace_id, api_key, token, env_name, key, value):
464
+ hdrs = api.headers(workspace_id, token, "application/json", api_key)
465
+ env = _resolve_environment(server, workspace_id, hdrs, env_name)
466
+
467
+ existing = api.req("GET", f"{server}/credentials/env-vars/{env['id']}", hdrs)
468
+ merged = [{"key": r["key"], "value": r["value"]} for r in existing if r["key"] != key]
469
+ merged.append({"key": key, "value": value})
470
+
471
+ api.req("PUT", f"{server}/credentials/env-vars/{env['id']}", hdrs, merged)
472
+ masked = value[:4] + "***" if len(value) > 4 else "***"
473
+ print(f"{GREEN}✓ {key}{RESET} set in environment '{env_name}' {GRAY}({masked}){RESET}")
474
+
475
+
476
+ def _do_delete_credential(server, workspace_id, api_key, token, env_name, key, yes):
477
+ hdrs = api.headers(workspace_id, token, "application/json", api_key)
478
+ env = _resolve_environment(server, workspace_id, hdrs, env_name)
479
+
480
+ existing = api.req("GET", f"{server}/credentials/env-vars/{env['id']}", hdrs)
481
+ filtered = [r for r in existing if r["key"] != key]
482
+
483
+ if len(filtered) == len(existing):
484
+ print(f"{YELLOW}Key '{key}' not found in environment '{env_name}'.{RESET}")
485
+ sys.exit(1)
486
+
487
+ if not yes:
488
+ confirm = input(f"{YELLOW}Delete '{key}' from environment '{env_name}'? Type 'yes' to confirm: {RESET}").strip().lower()
489
+ if confirm != "yes":
490
+ print("Cancelled.")
491
+ return
492
+
493
+ api.req("PUT", f"{server}/credentials/env-vars/{env['id']}", hdrs, filtered)
494
+ print(f"{GREEN}✓ {key}{RESET} removed from environment '{env_name}'")
495
+
496
+
497
+ def cmd_set(args):
498
+ if args.set_command == "credential":
499
+ server, workspace_id, api_key, token = _require_auth(args)
500
+ _do_set_credential(server, workspace_id, api_key, token,
501
+ args.environment, args.key, args.value)
502
+ elif args.set_command == "environment":
503
+ server, workspace_id, api_key, token = _require_auth(args)
504
+ hdrs = api.headers(workspace_id, token, "application/json", api_key)
505
+
506
+ workflows = api.req("GET", f"{server}/workflows", hdrs)
507
+ wf = next((w for w in workflows if w["name"].lower() == args.agent.lower()), None)
508
+ if not wf:
509
+ print(f"{RED}Agent '{args.agent}' not found. Run 'conduct agents' to list agents.{RESET}")
510
+ sys.exit(1)
511
+
512
+ env = _resolve_environment(server, workspace_id, hdrs, args.environment)
513
+ api.req("PATCH", f"{server}/workflows/{wf['id']}/environment", hdrs, {"environment_id": env["id"]})
514
+ print(f"{GREEN}✓ Environment '{args.environment}' assigned to agent '{args.agent}'{RESET}")
515
+ else:
516
+ print(f"Usage: conduct set [credential|environment] ...")
517
+ sys.exit(1)
518
+
519
+
520
+ # ── Project commands ──────────────────────────────────────────────────────────
521
+
522
+ def _list_projects(server: str, workspace_id: str, hdrs: dict) -> list:
523
+ return api.req("GET", f"{server}/workspaces/{workspace_id}/projects", hdrs)
524
+
525
+
526
+ def _resolve_project(server: str, workspace_id: str, hdrs: dict, name: str) -> dict:
527
+ projects = _list_projects(server, workspace_id, hdrs)
528
+ match = next((p for p in projects if p["name"].lower() == name.lower()), None)
529
+ if not match:
530
+ print(f"{YELLOW}Project '{name}' not found — creating it…{RESET}")
531
+ match = api.req("POST", f"{server}/workspaces/{workspace_id}/projects", hdrs, {"name": name})
532
+ print(f" {GREEN}✓ Project created:{RESET} {match['name']} {GRAY}({match['id']}){RESET}")
533
+ return match
534
+
535
+
536
+ def cmd_projects(args):
537
+ server, workspace_id, api_key, token = _require_auth(args)
538
+ hdrs = api.headers(workspace_id, token, "application/json", api_key)
539
+ projects = _list_projects(server, workspace_id, hdrs)
540
+
541
+ if not projects:
542
+ print("No projects found. Create one: conduct create project <name>")
543
+ return
544
+
545
+ print(f"\n{BOLD}{'Project':<35} {'Agents':>6} {'ID'}{RESET}")
546
+ print("─" * 70)
547
+ for p in projects:
548
+ agents = p.get("agent_count", 0)
549
+ print(f" {p['name']:<35} {agents:>6} {GRAY}{p['id']}{RESET}")
550
+ print()
551
+
552
+
553
+ def cmd_create(args):
554
+ server, workspace_id, api_key, token = _require_auth(args)
555
+ hdrs = api.headers(workspace_id, token, "application/json", api_key)
556
+ parts = args.create_args
557
+
558
+ if parts and parts[0] == "environment":
559
+ name = " ".join(parts[1:]).strip()
560
+ if not name:
561
+ print(f"{RED}Usage: conduct create environment <name>{RESET}")
562
+ sys.exit(1)
563
+ result = api.req("POST", f"{server}/environments", hdrs, {"name": name})
564
+ print(f"{GREEN}✓ Environment created:{RESET} {result['name']} {GRAY}({result['id']}){RESET}")
565
+ else:
566
+ # conduct create [project] <name> — "project" keyword is optional
567
+ name = " ".join(parts[1:] if parts and parts[0] == "project" else parts).strip()
568
+ if not name:
569
+ print(f"{RED}Usage: conduct create [environment|project] <name>{RESET}")
570
+ sys.exit(1)
571
+ result = api.req("POST", f"{server}/workspaces/{workspace_id}/projects", hdrs, {"name": name})
572
+ print(f"{GREEN}✓ Project created:{RESET} {result['name']} {GRAY}({result['id']}){RESET}")
573
+
574
+
575
+ def cmd_delete(args):
576
+ server, workspace_id, api_key, token = _require_auth(args)
577
+ hdrs = api.headers(workspace_id, token, "application/json", api_key)
578
+ parts = args.delete_args
579
+
580
+ if parts and parts[0] == "environment":
581
+ name = " ".join(parts[1:]).strip()
582
+ if not name:
583
+ print(f"{RED}Usage: conduct delete environment <name>{RESET}")
584
+ sys.exit(1)
585
+ env = _resolve_environment(server, workspace_id, hdrs, name)
586
+ if not args.yes:
587
+ confirm = input(f"{YELLOW}Delete environment '{env['name']}'? Type 'yes' to confirm: {RESET}").strip().lower()
588
+ if confirm != "yes":
589
+ print("Cancelled.")
590
+ return
591
+ api.req("DELETE", f"{server}/environments/{env['id']}", hdrs)
592
+ print(f"{GREEN}✓ Environment '{env['name']}' deleted.{RESET}")
593
+
594
+ elif parts and parts[0] == "credential":
595
+ env_name = getattr(args, "environment", None)
596
+ key = getattr(args, "key", None)
597
+ if not env_name or not key:
598
+ print(f"{RED}Usage: conduct delete credential --environment <name> --key <KEY>{RESET}")
599
+ sys.exit(1)
600
+ _do_delete_credential(server, workspace_id, api_key, token, env_name, key, args.yes)
601
+
602
+ else:
603
+ # conduct delete [project] <name> [--yes] [--purge]
604
+ name = " ".join(parts[1:] if parts and parts[0] == "project" else parts).strip()
605
+ if not name:
606
+ print(f"{RED}Usage: conduct delete [environment|project|credential] <name>{RESET}")
607
+ sys.exit(1)
608
+ proj = _resolve_project(server, workspace_id, hdrs, name)
609
+ purge = getattr(args, "purge", False)
610
+ if purge:
611
+ print(f"{RED}{BOLD}⚠ PURGE mode — this will permanently delete ALL data for '{proj['name']}'{RESET}")
612
+ print(f"{RED} · All runs, events, and workflow versions{RESET}")
613
+ print(f"{RED} · Analytics and audit logs{RESET}")
614
+ print(f"{RED} · API keys and environments{RESET}")
615
+ print(f"{RED} This cannot be undone.{RESET}\n")
616
+ confirm = input(f"{YELLOW}Type the project name to confirm: {RESET}").strip()
617
+ if confirm != proj["name"]:
618
+ print("Cancelled — name did not match.")
619
+ return
620
+ elif not args.yes:
621
+ confirm = input(f"{YELLOW}Delete project '{proj['name']}' and all its agents? Type 'yes' to confirm: {RESET}").strip().lower()
622
+ if confirm != "yes":
623
+ print("Cancelled.")
624
+ return
625
+ url = f"{server}/workspaces/{workspace_id}/projects/{proj['id']}"
626
+ if purge:
627
+ url += "?purge=true"
628
+ api.req("DELETE", url, hdrs)
629
+ suffix = " (purged)" if purge else ""
630
+ print(f"{GREEN}✓ Project '{proj['name']}' deleted{suffix}.{RESET}")
631
+
632
+
633
+ # ── Playbook commands ─────────────────────────────────────────────────────────
634
+
635
+ def cmd_playbooks(args):
636
+ server, workspace_id, api_key, token = _require_auth(args)
637
+ hdrs = api.headers(workspace_id, token, "application/json", api_key)
638
+ slug = getattr(args, "slug", None)
639
+
640
+ if slug:
641
+ pb = api.req("GET", f"{server}/workflows/playbooks/{slug}", hdrs)
642
+ print(f"\n{BOLD}{pb['icon']} {pb['name']}{RESET}")
643
+ print(f" {pb['description']}")
644
+ tags = " ".join(pb.get("tags", []))
645
+ if tags:
646
+ print(f" {GRAY}{tags}{RESET}")
647
+ if pb.get("github_webhook"):
648
+ events = ", ".join(pb.get("github_events", []))
649
+ print(f" {GRAY}Trigger: GitHub webhook ({events}){RESET}")
650
+ print(f" {GRAY}Requires: --repo owner/repo{RESET}")
651
+ elif pb.get("requires_repo"):
652
+ print(f" {GRAY}Trigger: inbound webhook — POST your payload to the webhook URL{RESET}")
653
+ print(f" {GRAY}Requires: --repo owner/repo (agent clones this repo at runtime){RESET}")
654
+ inputs = pb.get("inputs", {})
655
+ if inputs:
656
+ print(f"\n{BOLD} Inputs:{RESET}")
657
+ for k, v in inputs.items():
658
+ default = v.get("default", "")
659
+ required = "" if default != "" else f" {RED}(required){RESET}"
660
+ desc = v.get("description", "")
661
+ print(f" {CYAN}--input {k}=<value>{RESET}{required} {GRAY}{desc}{RESET}")
662
+ print()
663
+ else:
664
+ pbs = api.req("GET", f"{server}/workflows/playbooks", hdrs)
665
+ if not pbs:
666
+ print("No playbooks available.")
667
+ return
668
+ print(f"\n{BOLD}{'Playbook':<30} {'Slug':<30} {'Tags'}{RESET}")
669
+ print("─" * 80)
670
+ for pb in pbs:
671
+ tags = ", ".join(pb.get("tags", []))[:25]
672
+ icon = pb.get("icon", "")
673
+ name = f"{icon} {pb['name']}"[:29]
674
+ print(f" {name:<30} {pb['slug']:<30} {GRAY}{tags}{RESET}")
675
+ print(f"\n Run {CYAN}conduct playbooks <slug>{RESET} for input details.\n")
676
+
677
+
678
+ # ── Install command ───────────────────────────────────────────────────────────
679
+
680
+ def cmd_install(args):
681
+ server, workspace_id, api_key, token = _require_auth(args)
682
+ hdrs = api.headers(workspace_id, token, "application/json", api_key)
683
+
684
+ slug = args.slug
685
+
686
+ # Fetch playbook to validate slug + get declared inputs
687
+ pb = api.req("GET", f"{server}/workflows/playbooks/{slug}", hdrs)
688
+ declared_inputs = pb.get("inputs", {})
689
+
690
+ # Require --repo for all playbooks
691
+ if not args.repo and pb.get("requires_repo"):
692
+ if pb.get("github_webhook"):
693
+ events = ", ".join(pb.get("github_events", []))
694
+ print(f"{RED}Error: --repo is required for this agent.{RESET}")
695
+ print(f" It listens for GitHub {events} events — Conduct must register a webhook on the target repo.")
696
+ else:
697
+ print(f"{RED}Error: --repo is required for this agent.{RESET}")
698
+ print(f" It clones and operates on a GitHub repository at runtime.")
699
+ print(f"\n Usage: conduct install {slug} --repo owner/repo\n")
700
+ sys.exit(1)
701
+
702
+ # Parse --input key=val pairs
703
+ raw_inputs: dict = {}
704
+ for pair in (args.input or []):
705
+ if "=" not in pair:
706
+ print(f"{RED}Bad --input format '{pair}'. Expected key=value.{RESET}")
707
+ sys.exit(1)
708
+ k, v = pair.split("=", 1)
709
+ raw_inputs[k.strip()] = v.strip()
710
+
711
+ # Check required inputs (no default and not supplied)
712
+ missing = [
713
+ k for k, v in declared_inputs.items()
714
+ if v.get("default", "__MISSING__") == "__MISSING__" and k not in raw_inputs
715
+ ]
716
+ if missing:
717
+ print(f"{RED}Missing required inputs: {', '.join(missing)}{RESET}")
718
+ print(f" Use: conduct install {slug} --input key=value ...")
719
+ sys.exit(1)
720
+
721
+ # Resolve project
722
+ project_id = None
723
+ if args.project:
724
+ proj = _resolve_project(server, workspace_id, hdrs, args.project)
725
+ project_id = proj["id"]
726
+
727
+ # Agent name — use friendly name, fall back to playbook API name
728
+ agent_name = args.name or _FRIENDLY_NAMES.get(slug) or pb["name"]
729
+
730
+ # Repo input — inject into inputs if playbook expects github_repo
731
+ if args.repo:
732
+ if "github_repo" in declared_inputs:
733
+ raw_inputs.setdefault("github_repo", args.repo)
734
+ if "repo" in declared_inputs:
735
+ raw_inputs.setdefault("repo", args.repo)
736
+
737
+ body: dict = {
738
+ "name": agent_name,
739
+ "template": slug,
740
+ "inputs": raw_inputs,
741
+ "graph": {"nodes": [], "edges": []},
742
+ }
743
+ if project_id:
744
+ body["project_id"] = project_id
745
+ if args.repo:
746
+ body["repo"] = args.repo
747
+
748
+ print(f"\n{BOLD}Installing {pb['icon']} {pb['name']}…{RESET}")
749
+ if project_id:
750
+ print(f" project: {args.project}")
751
+ print(f" agent: {agent_name}")
752
+ if raw_inputs:
753
+ for k, v in raw_inputs.items():
754
+ masked = v if "token" not in k.lower() and "secret" not in k.lower() else "***"
755
+ print(f" {k}: {masked}")
756
+ print()
757
+
758
+ result = api.req("POST", f"{server}/workflows", hdrs, body)
759
+
760
+ wf_id = result.get("id", "")
761
+ print(f"{GREEN}✓ Agent installed:{RESET} {result['name']} {GRAY}({wf_id}){RESET}")
762
+
763
+ webhook_error = result.get("webhook_error")
764
+ if webhook_error:
765
+ print(f"{YELLOW}⚠ Webhook:{RESET} {webhook_error}")
766
+ elif args.repo:
767
+ if pb.get("github_webhook"):
768
+ print(f"{GREEN}✓ GitHub webhook registered{RESET} on {args.repo}")
769
+ else:
770
+ print(f"{GREEN}✓ Target repo stored:{RESET} {args.repo}")
771
+
772
+ print(f"\n Run a test: {CYAN}conduct test \"{agent_name}\"{RESET}\n")
773
+
774
+
775
+ # ── Reset command ─────────────────────────────────────────────────────────────
776
+
777
+ def cmd_reset(args):
778
+ server, workspace_id, api_key, token = _require_auth(args)
779
+ hdrs = api.headers(workspace_id, token, "application/json", api_key)
780
+ proj = _resolve_project(server, workspace_id, hdrs, args.name)
781
+ project_id = proj["id"]
782
+
783
+ workflows = api.req("GET", f"{server}/workflows?project_id={project_id}", hdrs)
784
+ if not workflows:
785
+ print(f"{YELLOW}Project '{args.name}' has no agents — nothing to reset.{RESET}")
786
+ return
787
+
788
+ print(f"\n{BOLD}Reset project '{args.name}' — {len(workflows)} agent(s) will be deleted:{RESET}")
789
+ for wf in workflows:
790
+ print(f" {GRAY}· {wf['name']}{RESET}")
791
+
792
+ if not args.yes:
793
+ confirm = input(f"\n{YELLOW}Type 'yes' to confirm: {RESET}").strip().lower()
794
+ if confirm != "yes":
795
+ print("Cancelled.")
796
+ return
797
+
798
+ deleted = failed = 0
799
+ for wf in workflows:
800
+ try:
801
+ api.req("DELETE", f"{server}/workflows/{wf['id']}", hdrs)
802
+ print(f" {GREEN}✓ deleted:{RESET} {wf['name']}")
803
+ deleted += 1
804
+ except SystemExit:
805
+ print(f" {RED}✗ failed:{RESET} {wf['name']}")
806
+ failed += 1
807
+
808
+ print(f"\n{BOLD}{GREEN}{deleted} deleted{RESET}", end="")
809
+ if failed:
810
+ print(f" {RED}{failed} failed{RESET}", end="")
811
+ print()
812
+
813
+
814
+ # ── Install-all command ───────────────────────────────────────────────────────
815
+
816
+ # All known playbook slugs in install order
817
+ _ALL_SLUGS = [
818
+ "autopilot_quick",
819
+ "autopilot_full",
820
+ "autopilot_approved",
821
+ "pr_reviewer",
822
+ "ci_notify",
823
+ "incident_responder",
824
+ "dependency_updater",
825
+ "release_notes",
826
+ "issue_triage",
827
+ "copilot_reviewer",
828
+ "security_scanner",
829
+ "security_patch_updater",
830
+ "smoke_test",
831
+ ]
832
+
833
+ _FRIENDLY_NAMES = {
834
+ "autopilot_quick": "Autopilot Quick",
835
+ "autopilot_full": "Autopilot Full",
836
+ "autopilot_approved": "Autopilot + Approval",
837
+ "pr_reviewer": "PR Reviewer",
838
+ "ci_notify": "CI Failure Alert",
839
+ "incident_responder": "Incident Responder",
840
+ "dependency_updater": "Dependency Updater",
841
+ "release_notes": "Release Notes",
842
+ "issue_triage": "Issue Triage",
843
+ "copilot_reviewer": "Copilot / AI PR Reviewer",
844
+ "security_scanner": "Security Scanner",
845
+ "security_patch_updater": "Security Patch Updater",
846
+ "smoke_test": "Smoke Test",
847
+ }
848
+
849
+
850
+ def cmd_install_all(args):
851
+ server, workspace_id, api_key, token = _require_auth(args)
852
+ hdrs = api.headers(workspace_id, token, "application/json", api_key)
853
+
854
+ slugs = _ALL_SLUGS
855
+
856
+ print(f"\n{BOLD}▶ conduct install-all — {len(slugs)} playbooks → project '{args.project}'{RESET}")
857
+ if args.repo:
858
+ print(f" repo: {args.repo}")
859
+ print()
860
+
861
+ installed = []
862
+ failed = []
863
+
864
+ for slug in slugs:
865
+ # Build a minimal args-like namespace for cmd_install
866
+ class _A:
867
+ pass
868
+ a = _A()
869
+ a.slug = slug
870
+ a.project = args.project
871
+ a.repo = args.repo
872
+ a.name = None
873
+ a.input = args.input or []
874
+
875
+ # Patch server/workspace/auth into the namespace so _require_auth works
876
+ a.server = server
877
+ a.workspace = workspace_id
878
+ a.api_key = api_key
879
+ a.token = token
880
+
881
+ try:
882
+ cmd_install(a)
883
+ installed.append(slug)
884
+ except SystemExit:
885
+ failed.append(slug)
886
+
887
+ # Summary
888
+ print(f"\n{BOLD}{'─' * 50}{RESET}")
889
+ color = GREEN if not failed else RED
890
+ print(f"{BOLD}{color}{len(installed)}/{len(slugs)} installed{RESET}\n")
891
+
892
+ for s in installed:
893
+ print(f" {GREEN}✓{RESET} {s}")
894
+ for s in failed:
895
+ print(f" {RED}✗{RESET} {s}")
896
+ print()
897
+
898
+ if failed:
899
+ print(f"{RED}Some installs failed. Fix the issue, run 'conduct reset project {args.project}', then retry.{RESET}\n")
900
+ sys.exit(1)
901
+
902
+
903
+ def _build_state(issue: dict, repo_full_name: str) -> dict:
904
+ owner, repo = repo_full_name.split("/", 1)
905
+ trigger = {
906
+ "repo_owner": owner,
907
+ "repo_name": repo,
908
+ "repo_full_name": repo_full_name,
909
+ "issue_number": issue["number"],
910
+ "title": issue["title"],
911
+ "body": issue.get("body") or "",
912
+ "url": issue["url"],
913
+ "author": issue["author"],
914
+ "labels": issue["labels"],
915
+ "label_added": issue["labels"][0] if issue["labels"] else "",
916
+ "default_branch": "main",
917
+ "clone_url": issue["clone_url"],
918
+ }
919
+ return {"github_issue": trigger, "_trigger": trigger}
920
+
921
+
922
+ def cmd_run(args):
923
+ path = Path(args.yaml)
924
+ if not path.exists():
925
+ print(f"ERROR: file not found: {path}")
926
+ sys.exit(1)
927
+
928
+ raw_yaml = path.read_text()
929
+ cfg = yaml.safe_load(raw_yaml)
930
+ name = cfg.get("name", path.stem)
931
+ workflow_id = cfg.get("id")
932
+ server, workspace_id, api_key, token = _require_auth(args)
933
+ on_block = cfg.get("on") or {}
934
+ trigger_type = next(iter(on_block), None)
935
+ trigger_cfg = on_block.get(trigger_type, {})
936
+
937
+ json_h = api.headers(workspace_id, token, "application/json", api_key)
938
+ yaml_h = api.headers(workspace_id, token, "application/x-yaml", api_key)
939
+
940
+ print(f"\n{BOLD}▶ conduct run — {name}{RESET}")
941
+ print(f" server: {server}\n")
942
+
943
+ if not workflow_id:
944
+ workflow_id = api.find_or_create_workflow(server, name, json_h)
945
+ print(f" workflow: {workflow_id}")
946
+ print(f" pushing YAML… ", end="", flush=True)
947
+ api.req_text("PUT", f"{server}/workflows/{workflow_id}/yaml", yaml_h, raw_yaml)
948
+ print(f"{GREEN}ok{RESET}\n")
949
+
950
+ if trigger_type == "github_issue_labeled":
951
+ repo = trigger_cfg.get("repo_allowlist", "")
952
+ label = trigger_cfg.get("label", "")
953
+
954
+ print(f" Fetching issues from {repo} with label '{label}'…")
955
+ qs = urllib.parse.urlencode({"repo": repo, "label": label})
956
+ issues = api.req("GET", f"{server}/credentials/github/issues?{qs}", json_h)
957
+
958
+ if not issues:
959
+ print(f" No open issues found with label '{label}'.")
960
+ return
961
+
962
+ print(f" Found {len(issues)} issue(s)\n")
963
+
964
+ passed = failed = 0
965
+ for issue in issues:
966
+ print(f"{CYAN} ── Issue #{issue['number']}: {issue['title']}{RESET}")
967
+ state = _build_state(issue, repo)
968
+
969
+ max_turns = None
970
+ try:
971
+ pf = api.req("POST", f"{server}/workflows/{workflow_id}/preflight", json_h, {
972
+ "issue_title": issue["title"],
973
+ "issue_body": issue.get("body") or "",
974
+ })
975
+ suggested = pf.get("suggested_max_turns", 20)
976
+ if suggested > 20:
977
+ print(f"{GRAY} ⚠ estimated {suggested} turns — bumping max_turns{RESET}")
978
+ max_turns = suggested
979
+ except Exception:
980
+ pass
981
+
982
+ payload = {"triggered_by": f"cli:issue#{issue['number']}", "initial_state": state}
983
+ if max_turns:
984
+ payload["max_turns"] = max_turns
985
+ run = api.req("POST", f"{server}/workflows/{workflow_id}/runs", json_h, payload)
986
+ ok = _stream_run(server, workflow_id, run["id"], workspace_id, token, api_key)
987
+ passed += ok
988
+ failed += not ok
989
+ print()
990
+
991
+ print(f"{BOLD} Summary: {passed} passed, {failed} failed{RESET}\n")
992
+
993
+ else:
994
+ run = api.req("POST", f"{server}/workflows/{workflow_id}/runs", json_h, {
995
+ "triggered_by": "cli",
996
+ "initial_state": {},
997
+ })
998
+ _stream_run(server, workflow_id, run["id"], workspace_id, token)
999
+
1000
+
1001
+ # ── Entry point ───────────────────────────────────────────────────────────────
1002
+
1003
+ def main():
1004
+ parser = argparse.ArgumentParser(
1005
+ prog="conduct",
1006
+ description="Conduct AI — agent CLI",
1007
+ )
1008
+ # Global overrides (optional — config file is preferred)
1009
+ parser.add_argument("--server", help="API URL (default: from ~/.conduct/config.json)")
1010
+ parser.add_argument("--api-key", dest="api_key", help="CLI API key")
1011
+ parser.add_argument("--token", help=argparse.SUPPRESS)
1012
+ parser.add_argument("--workspace", help="Workspace ID")
1013
+
1014
+ sub = parser.add_subparsers(dest="command")
1015
+
1016
+ # conduct login
1017
+ login_p = sub.add_parser("login", help="Save connection config (~/.conduct/config.json)")
1018
+ login_p.add_argument("--server", help="API base URL e.g. https://api.conductai.ai")
1019
+ login_p.add_argument("--api-key", dest="api_key", help="CLI API key (set CLI_API_KEY on server)")
1020
+ login_p.add_argument("--workspace", help="Workspace ID (auto-discovered from API key if omitted)")
1021
+ login_p.add_argument("--token", help=argparse.SUPPRESS)
1022
+
1023
+ # conduct agents
1024
+ agents_p = sub.add_parser("agents", help="List all agents")
1025
+ agents_p.add_argument("--project", help="Filter by project name")
1026
+
1027
+ # conduct test
1028
+ test_p = sub.add_parser("test", help="Fire test trigger on one or more agents")
1029
+ test_p.add_argument("agents", nargs="*", metavar="agent_name", help="Agent name(s) to test")
1030
+ test_p.add_argument("--all", action="store_true", help="Test all playbook-based agents")
1031
+ test_p.add_argument("--parallel", action="store_true", help="Fire all triggers at once, poll concurrently (faster for many agents)")
1032
+ test_p.add_argument("--project", metavar="name", help="Limit to agents in this project")
1033
+ test_p.add_argument("--repo", metavar="owner/repo", help="Override repo in test payload (e.g. sseshachala/conductai-testbed-node)")
1034
+ test_p.add_argument("--pr", metavar="number", help="Inject a real PR number into the test payload (e.g. 246)")
1035
+
1036
+ # conduct environments
1037
+ sub.add_parser("environments", help="List all environments in the workspace")
1038
+
1039
+ # conduct credentials --environment <name>
1040
+ creds_p = sub.add_parser("credentials", help="List credentials in an environment")
1041
+ creds_p.add_argument("--environment", required=True, metavar="name", help="Environment name")
1042
+
1043
+ # conduct set credential|environment
1044
+ set_p = sub.add_parser("set", help="Set a credential or assign an environment to an agent")
1045
+ set_sub = set_p.add_subparsers(dest="set_command")
1046
+
1047
+ set_cred_p = set_sub.add_parser("credential", help="Set a credential in an environment")
1048
+ set_cred_p.add_argument("--environment", required=True, metavar="name", help="Environment name")
1049
+ set_cred_p.add_argument("--key", required=True, metavar="KEY", help="Env var name (e.g. GITHUB_TOKEN)")
1050
+ set_cred_p.add_argument("--value", required=True, metavar="VALUE", help="Credential value")
1051
+
1052
+ set_env_p = set_sub.add_parser("environment", help="Assign an environment to an agent")
1053
+ set_env_p.add_argument("--agent", required=True, metavar="name", help="Agent name (e.g. 'PR Reviewer')")
1054
+ set_env_p.add_argument("--environment", required=True, metavar="name", help="Environment name")
1055
+
1056
+ # conduct projects
1057
+ sub.add_parser("projects", help="List all projects in the workspace")
1058
+
1059
+ # conduct create [environment|project] <name>
1060
+ create_p = sub.add_parser("create", help="Create a project or environment")
1061
+ create_p.add_argument("create_args", nargs="+", metavar="[environment|project] name",
1062
+ help="Type (optional) and name — e.g. 'environment Production' or 'MyProject'")
1063
+
1064
+ # conduct playbooks [slug]
1065
+ pb_p = sub.add_parser("playbooks", help="List available playbooks or show detail for one")
1066
+ pb_p.add_argument("slug", nargs="?", help="Playbook slug for detail view")
1067
+
1068
+ # conduct install <slug>
1069
+ install_p = sub.add_parser("install", help="Install an agent from a playbook")
1070
+ install_p.add_argument("slug", help="Playbook slug (from 'conduct playbooks')")
1071
+ install_p.add_argument("--project", help="Project name to install into")
1072
+ install_p.add_argument("--name", help="Override agent name")
1073
+ install_p.add_argument("--repo", help="GitHub repo (owner/repo) for webhook-based playbooks")
1074
+ install_p.add_argument("--input", action="append", metavar="key=value",
1075
+ help="Playbook input value (repeatable, e.g. --input github_token=xxx)")
1076
+
1077
+ # conduct delete [environment|project|credential] <name>
1078
+ delete_p = sub.add_parser("delete", help="Delete a project, environment, or credential")
1079
+ delete_p.add_argument("delete_args", nargs="+", metavar="[environment|project|credential] name",
1080
+ help="Type (optional) and name, e.g. 'environment Production' or 'MyProject'")
1081
+ delete_p.add_argument("--environment", metavar="name", help="Environment name (for 'delete credential')")
1082
+ delete_p.add_argument("--key", metavar="KEY", help="Credential key (for 'delete credential')")
1083
+ delete_p.add_argument("--yes", action="store_true", help="Skip confirmation prompt")
1084
+ delete_p.add_argument("--purge", action="store_true", help="Also erase analytics, audit logs, API keys, and environments (irreversible)")
1085
+
1086
+ # conduct reset <name>
1087
+ reset_p = sub.add_parser("reset", help="Delete all agents in a project (clean slate)")
1088
+ reset_p.add_argument("name", help="Project name")
1089
+ reset_p.add_argument("--yes", action="store_true", help="Skip confirmation prompt")
1090
+
1091
+ # conduct install-all
1092
+ ia_p = sub.add_parser("install-all", help="Install all playbooks into a project")
1093
+ ia_p.add_argument("--project", help="Project name (uses default project if omitted)")
1094
+ ia_p.add_argument("--repo", help="GitHub repo (owner/repo)")
1095
+ ia_p.add_argument("--input", action="append", metavar="key=value",
1096
+ help="Input value applied to all playbooks (repeatable)")
1097
+
1098
+ # conduct run (existing)
1099
+ run_p = sub.add_parser("run", help="Run a workflow from a YAML file")
1100
+ run_p.add_argument("yaml", help="Path to workflow YAML")
1101
+
1102
+ # conduct guard
1103
+ guard_p, _guard_sub = _guard.register_guard_parser(sub)
1104
+
1105
+ args = parser.parse_args()
1106
+
1107
+ if args.command == "login":
1108
+ cmd_login(args)
1109
+ elif args.command == "agents":
1110
+ cmd_agents(args)
1111
+ elif args.command == "environments":
1112
+ cmd_environments(args)
1113
+ elif args.command == "credentials":
1114
+ cmd_credentials(args)
1115
+ elif args.command == "set":
1116
+ if not args.set_command:
1117
+ set_p.print_help()
1118
+ sys.exit(1)
1119
+ cmd_set(args)
1120
+ elif args.command == "projects":
1121
+ cmd_projects(args)
1122
+ elif args.command == "create":
1123
+ if getattr(args, "create_type", None) == "project":
1124
+ cmd_create(args)
1125
+ else:
1126
+ create_p.print_help()
1127
+ elif args.command == "playbooks":
1128
+ cmd_playbooks(args)
1129
+ elif args.command == "install":
1130
+ cmd_install(args)
1131
+ elif args.command == "delete":
1132
+ if getattr(args, "delete_type", None) == "project":
1133
+ cmd_delete(args)
1134
+ else:
1135
+ delete_p.print_help()
1136
+ elif args.command == "reset":
1137
+ cmd_reset(args)
1138
+ elif args.command == "install-all":
1139
+ cmd_install_all(args)
1140
+ elif args.command == "test":
1141
+ if not args.agents and not args.all:
1142
+ test_p.print_help()
1143
+ sys.exit(1)
1144
+ cmd_test(args)
1145
+ elif args.command == "run":
1146
+ cmd_run(args)
1147
+ elif args.command == "guard":
1148
+ _guard.dispatch_guard(args, guard_p)
1149
+ else:
1150
+ parser.print_help()
1151
+
1152
+
1153
+ if __name__ == "__main__":
1154
+ main()