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/__init__.py +0 -0
- conduct_cli/api.py +100 -0
- conduct_cli/guard.py +421 -0
- conduct_cli/main.py +1154 -0
- conduct_cli-0.2.0.dist-info/METADATA +108 -0
- conduct_cli-0.2.0.dist-info/RECORD +9 -0
- conduct_cli-0.2.0.dist-info/WHEEL +5 -0
- conduct_cli-0.2.0.dist-info/entry_points.txt +2 -0
- conduct_cli-0.2.0.dist-info/top_level.txt +1 -0
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()
|