conduct-cli 0.4.38__tar.gz → 0.4.41__tar.gz
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-0.4.38 → conduct_cli-0.4.41}/PKG-INFO +1 -1
- {conduct_cli-0.4.38 → conduct_cli-0.4.41}/pyproject.toml +1 -1
- {conduct_cli-0.4.38 → conduct_cli-0.4.41}/src/conduct_cli/guard.py +184 -0
- {conduct_cli-0.4.38 → conduct_cli-0.4.41}/src/conduct_cli/main.py +190 -5
- {conduct_cli-0.4.38 → conduct_cli-0.4.41}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.4.38 → conduct_cli-0.4.41}/src/conduct_cli.egg-info/SOURCES.txt +2 -1
- conduct_cli-0.4.41/tests/test_switch.py +215 -0
- {conduct_cli-0.4.38 → conduct_cli-0.4.41}/README.md +0 -0
- {conduct_cli-0.4.38 → conduct_cli-0.4.41}/setup.cfg +0 -0
- {conduct_cli-0.4.38 → conduct_cli-0.4.41}/setup.py +0 -0
- {conduct_cli-0.4.38 → conduct_cli-0.4.41}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.4.38 → conduct_cli-0.4.41}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.4.38 → conduct_cli-0.4.41}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.4.38 → conduct_cli-0.4.41}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.4.38 → conduct_cli-0.4.41}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.4.38 → conduct_cli-0.4.41}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.4.38 → conduct_cli-0.4.41}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.4.38 → conduct_cli-0.4.41}/src/conduct_cli.egg-info/top_level.txt +0 -0
|
@@ -446,6 +446,144 @@ if __name__ == "__main__":
|
|
|
446
446
|
main()
|
|
447
447
|
'''
|
|
448
448
|
|
|
449
|
+
_PRECOMPACT_HOOK_SCRIPT = '''\
|
|
450
|
+
#!/usr/bin/env python3
|
|
451
|
+
"""ConductGuard PreCompact hook — persists session context before compaction."""
|
|
452
|
+
import json
|
|
453
|
+
import os
|
|
454
|
+
import subprocess
|
|
455
|
+
import sys
|
|
456
|
+
from datetime import datetime, timezone
|
|
457
|
+
from pathlib import Path
|
|
458
|
+
|
|
459
|
+
GUARD_DIR = Path.home() / ".conductguard"
|
|
460
|
+
SNAPSHOT_PATH = GUARD_DIR / "session_snapshot.json"
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _git(cmd):
|
|
464
|
+
try:
|
|
465
|
+
return subprocess.check_output(
|
|
466
|
+
["git"] + cmd, stderr=subprocess.DEVNULL, text=True, timeout=3
|
|
467
|
+
).strip()
|
|
468
|
+
except Exception:
|
|
469
|
+
return ""
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _guard_status():
|
|
473
|
+
try:
|
|
474
|
+
out = subprocess.check_output(
|
|
475
|
+
["conductguard", "status", "--json"],
|
|
476
|
+
stderr=subprocess.DEVNULL, text=True, timeout=3,
|
|
477
|
+
)
|
|
478
|
+
return json.loads(out.strip())
|
|
479
|
+
except Exception:
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _memory_headline():
|
|
484
|
+
try:
|
|
485
|
+
root = Path.cwd()
|
|
486
|
+
mem_key = str(root).replace("/", "-").lstrip("-")
|
|
487
|
+
mem_path = Path.home() / ".claude" / "projects" / mem_key / "memory" / "MEMORY.md"
|
|
488
|
+
if mem_path.exists():
|
|
489
|
+
return "\\n".join(mem_path.read_text().splitlines()[:10])
|
|
490
|
+
except Exception:
|
|
491
|
+
pass
|
|
492
|
+
return ""
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def main():
|
|
496
|
+
try:
|
|
497
|
+
sys.stdin.read()
|
|
498
|
+
except Exception:
|
|
499
|
+
pass
|
|
500
|
+
|
|
501
|
+
try:
|
|
502
|
+
GUARD_DIR.mkdir(parents=True, exist_ok=True)
|
|
503
|
+
snapshot = {
|
|
504
|
+
"compacted_at": datetime.now(timezone.utc).isoformat(),
|
|
505
|
+
"tier1": {
|
|
506
|
+
"git_branch": _git(["branch", "--show-current"]),
|
|
507
|
+
"recent_commits": _git(["log", "--oneline", "-3"]),
|
|
508
|
+
"memory_headline": _memory_headline(),
|
|
509
|
+
},
|
|
510
|
+
"tier2": {"guard_status": _guard_status()},
|
|
511
|
+
"tier3": {"cwd": str(Path.cwd()), "python": sys.version.split()[0]},
|
|
512
|
+
}
|
|
513
|
+
tmp = GUARD_DIR / "session_snapshot.tmp"
|
|
514
|
+
tmp.write_text(json.dumps(snapshot, indent=2))
|
|
515
|
+
tmp.rename(SNAPSHOT_PATH)
|
|
516
|
+
except Exception:
|
|
517
|
+
pass
|
|
518
|
+
|
|
519
|
+
sys.exit(0)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
if __name__ == "__main__":
|
|
523
|
+
main()
|
|
524
|
+
'''
|
|
525
|
+
|
|
526
|
+
_SESSION_START_HOOK_SCRIPT = '''\
|
|
527
|
+
#!/usr/bin/env python3
|
|
528
|
+
"""ConductGuard SessionStart hook — prints context after compaction."""
|
|
529
|
+
import json
|
|
530
|
+
import sys
|
|
531
|
+
from datetime import datetime, timezone
|
|
532
|
+
from pathlib import Path
|
|
533
|
+
|
|
534
|
+
SNAPSHOT_PATH = Path.home() / ".conductguard" / "session_snapshot.json"
|
|
535
|
+
MAX_AGE_HOURS = 2
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def main():
|
|
539
|
+
try:
|
|
540
|
+
sys.stdin.read()
|
|
541
|
+
except Exception:
|
|
542
|
+
pass
|
|
543
|
+
|
|
544
|
+
if not SNAPSHOT_PATH.exists():
|
|
545
|
+
sys.exit(0)
|
|
546
|
+
|
|
547
|
+
try:
|
|
548
|
+
snapshot = json.loads(SNAPSHOT_PATH.read_text())
|
|
549
|
+
compacted_at = datetime.fromisoformat(snapshot.get("compacted_at", ""))
|
|
550
|
+
age_hours = (datetime.now(timezone.utc) - compacted_at).total_seconds() / 3600
|
|
551
|
+
if age_hours > MAX_AGE_HOURS:
|
|
552
|
+
sys.exit(0)
|
|
553
|
+
|
|
554
|
+
t1 = snapshot.get("tier1", {})
|
|
555
|
+
branch = t1.get("git_branch", "")
|
|
556
|
+
commits = t1.get("recent_commits", "")
|
|
557
|
+
headline = t1.get("memory_headline", "")
|
|
558
|
+
t2 = snapshot.get("tier2", {})
|
|
559
|
+
guard = t2.get("guard_status") or {}
|
|
560
|
+
|
|
561
|
+
lines = [f"## Session resumed (snapshot from {compacted_at.strftime(\'%Y-%m-%d %H:%M\')} UTC)"]
|
|
562
|
+
if branch:
|
|
563
|
+
last = commits.splitlines()[0] if commits else ""
|
|
564
|
+
lines.append(f"- Branch: {branch}" + (f" | Last: {last}" if last else ""))
|
|
565
|
+
budget = guard.get("budget_pct")
|
|
566
|
+
if budget is not None:
|
|
567
|
+
lines.append(f"- Guard: {budget}% budget used")
|
|
568
|
+
else:
|
|
569
|
+
lines.append("- Guard: state unavailable")
|
|
570
|
+
if headline:
|
|
571
|
+
lines.append(f"- Memory index:\\n {headline}")
|
|
572
|
+
else:
|
|
573
|
+
lines.append("- Memory index:\\n (none)")
|
|
574
|
+
|
|
575
|
+
print("\\n".join(lines))
|
|
576
|
+
except Exception:
|
|
577
|
+
pass
|
|
578
|
+
|
|
579
|
+
sys.exit(0)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
if __name__ == "__main__":
|
|
583
|
+
main()
|
|
584
|
+
'''
|
|
585
|
+
|
|
586
|
+
|
|
449
587
|
# ── Python interpreter selection ─────────────────────────────────────────────
|
|
450
588
|
|
|
451
589
|
def _best_python() -> str:
|
|
@@ -478,6 +616,42 @@ def _write_hook(path: Path) -> None:
|
|
|
478
616
|
) from exc
|
|
479
617
|
|
|
480
618
|
|
|
619
|
+
def _install_session_hooks() -> None:
|
|
620
|
+
"""Write PreCompact + SessionStart hook scripts and register them in ~/.claude/settings.json."""
|
|
621
|
+
python = _best_python()
|
|
622
|
+
|
|
623
|
+
precompact_path = GUARD_DIR / "guard-precompact.py"
|
|
624
|
+
session_start_path = GUARD_DIR / "guard-session-start.py"
|
|
625
|
+
|
|
626
|
+
precompact_path.write_text(_PRECOMPACT_HOOK_SCRIPT)
|
|
627
|
+
precompact_path.chmod(0o755)
|
|
628
|
+
session_start_path.write_text(_SESSION_START_HOOK_SCRIPT)
|
|
629
|
+
session_start_path.chmod(0o755)
|
|
630
|
+
|
|
631
|
+
claude_settings = Path.home() / ".claude" / "settings.json"
|
|
632
|
+
settings: dict = {}
|
|
633
|
+
if claude_settings.exists():
|
|
634
|
+
try:
|
|
635
|
+
settings = json.loads(claude_settings.read_text())
|
|
636
|
+
except Exception:
|
|
637
|
+
pass
|
|
638
|
+
|
|
639
|
+
hooks = settings.setdefault("hooks", {})
|
|
640
|
+
|
|
641
|
+
pre_cmd = f"{python} {precompact_path}"
|
|
642
|
+
compact_hooks = hooks.setdefault("PreCompact", [])
|
|
643
|
+
if not any(pre_cmd in str(e) for h in compact_hooks for e in h.get("hooks", [])):
|
|
644
|
+
compact_hooks.append({"hooks": [{"type": "command", "command": pre_cmd}]})
|
|
645
|
+
|
|
646
|
+
start_cmd = f"{python} {session_start_path}"
|
|
647
|
+
start_hooks = hooks.setdefault("SessionStart", [])
|
|
648
|
+
if not any(start_cmd in str(e) for h in start_hooks for e in h.get("hooks", [])):
|
|
649
|
+
start_hooks.append({"hooks": [{"type": "command", "command": start_cmd}]})
|
|
650
|
+
|
|
651
|
+
claude_settings.parent.mkdir(parents=True, exist_ok=True)
|
|
652
|
+
claude_settings.write_text(json.dumps(settings, indent=2) + "\n")
|
|
653
|
+
|
|
654
|
+
|
|
481
655
|
# ── Guard config helpers ──────────────────────────────────────────────────────
|
|
482
656
|
|
|
483
657
|
def _load_guard_config() -> dict:
|
|
@@ -815,6 +989,12 @@ def cmd_guard_install(args):
|
|
|
815
989
|
# Register MCP in all found AI tools — Cursor/Windsurf (advisory)
|
|
816
990
|
_register_mcp(workspace_id, member_token or "", server)
|
|
817
991
|
|
|
992
|
+
# Install session persistence hooks (PreCompact + SessionStart)
|
|
993
|
+
try:
|
|
994
|
+
_install_session_hooks()
|
|
995
|
+
except Exception:
|
|
996
|
+
pass
|
|
997
|
+
|
|
818
998
|
|
|
819
999
|
def cmd_guard_join(args):
|
|
820
1000
|
invite_code = args.invite_code
|
|
@@ -1020,6 +1200,10 @@ def cmd_guard_sync(args):
|
|
|
1020
1200
|
_install_codex_hook(hook_path)
|
|
1021
1201
|
cfg2 = _load_guard_config()
|
|
1022
1202
|
_register_mcp(workspace_id, cfg2.get("member_token", ""), base_url)
|
|
1203
|
+
try:
|
|
1204
|
+
_install_session_hooks()
|
|
1205
|
+
except Exception:
|
|
1206
|
+
pass
|
|
1023
1207
|
print(f" {GREEN}Hook script updated{RESET}")
|
|
1024
1208
|
|
|
1025
1209
|
# Capture savings from RTK and Agent Booster
|
|
@@ -1289,6 +1289,175 @@ def _build_state(issue: dict, repo_full_name: str) -> dict:
|
|
|
1289
1289
|
return {"github_issue": trigger, "_trigger": trigger}
|
|
1290
1290
|
|
|
1291
1291
|
|
|
1292
|
+
def _atomic_write(path: Path, data: dict) -> None:
|
|
1293
|
+
"""Write data to path atomically via a .tmp sibling."""
|
|
1294
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1295
|
+
tmp = path.with_suffix(".tmp")
|
|
1296
|
+
tmp.write_text(json.dumps(data, indent=2))
|
|
1297
|
+
os.replace(tmp, path)
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def cmd_switch(args):
|
|
1301
|
+
cfg = _load_config()
|
|
1302
|
+
server = cfg.get("server", "").rstrip("/")
|
|
1303
|
+
api_key = cfg.get("api_key", "")
|
|
1304
|
+
token = cfg.get("token", "")
|
|
1305
|
+
|
|
1306
|
+
if not server or (not api_key and not token):
|
|
1307
|
+
print(f"{RED}Not logged in. Run: conduct login --server <url> --api-key <key>{RESET}")
|
|
1308
|
+
sys.exit(1)
|
|
1309
|
+
|
|
1310
|
+
hdrs = {"Content-Type": "application/json"}
|
|
1311
|
+
if api_key:
|
|
1312
|
+
hdrs["X-Api-Key"] = api_key
|
|
1313
|
+
elif token:
|
|
1314
|
+
hdrs["Authorization"] = f"Bearer {token}"
|
|
1315
|
+
|
|
1316
|
+
workspaces = api.req("GET", f"{server}/projects", hdrs)
|
|
1317
|
+
|
|
1318
|
+
current_id = cfg.get("workspace", "")
|
|
1319
|
+
target = getattr(args, "workspace", None)
|
|
1320
|
+
|
|
1321
|
+
if not target:
|
|
1322
|
+
# List mode — print numbered list with current marked
|
|
1323
|
+
if not workspaces:
|
|
1324
|
+
print("No workspaces found.")
|
|
1325
|
+
return
|
|
1326
|
+
print(f"\n{BOLD}Workspaces:{RESET}")
|
|
1327
|
+
for i, ws in enumerate(workspaces, 1):
|
|
1328
|
+
marker = f"{GREEN}*{RESET}" if str(ws.get("id", "")) == str(current_id) else " "
|
|
1329
|
+
wid = str(ws.get("id", ""))
|
|
1330
|
+
print(f" {marker} {i}. {ws['name']:<35} {GRAY}{wid}{RESET}")
|
|
1331
|
+
print()
|
|
1332
|
+
return
|
|
1333
|
+
|
|
1334
|
+
# Match workspace: exact name (case-insensitive) first
|
|
1335
|
+
target_lower = target.lower()
|
|
1336
|
+
|
|
1337
|
+
exact = [ws for ws in workspaces if ws["name"].lower() == target_lower]
|
|
1338
|
+
if not exact:
|
|
1339
|
+
# Partial name match
|
|
1340
|
+
partial = [ws for ws in workspaces if target_lower in ws["name"].lower()]
|
|
1341
|
+
if not partial:
|
|
1342
|
+
# UUID prefix match
|
|
1343
|
+
partial = [ws for ws in workspaces if str(ws.get("id", "")).startswith(target)]
|
|
1344
|
+
candidates = partial
|
|
1345
|
+
else:
|
|
1346
|
+
candidates = exact
|
|
1347
|
+
|
|
1348
|
+
if len(candidates) > 1:
|
|
1349
|
+
print(f"{YELLOW}Ambiguous — multiple matches for '{target}':{RESET}")
|
|
1350
|
+
for ws in candidates:
|
|
1351
|
+
print(f" {ws['name']} {GRAY}({ws['id']}){RESET}")
|
|
1352
|
+
print("Be more specific.")
|
|
1353
|
+
sys.exit(1)
|
|
1354
|
+
|
|
1355
|
+
if not candidates:
|
|
1356
|
+
print(f"{RED}No workspace matching '{target}' found. Available:{RESET}")
|
|
1357
|
+
for ws in workspaces:
|
|
1358
|
+
print(f" {ws['name']} {GRAY}({ws['id']}){RESET}")
|
|
1359
|
+
sys.exit(1)
|
|
1360
|
+
|
|
1361
|
+
chosen = candidates[0]
|
|
1362
|
+
new_id = str(chosen["id"])
|
|
1363
|
+
new_name = chosen["name"]
|
|
1364
|
+
|
|
1365
|
+
# Update ~/.conduct/config.json atomically
|
|
1366
|
+
cfg["workspace"] = new_id
|
|
1367
|
+
_atomic_write(CONFIG_PATH, cfg)
|
|
1368
|
+
|
|
1369
|
+
# Update ~/.conductguard/config.json atomically if it exists
|
|
1370
|
+
guard_cfg_path = Path.home() / ".conductguard" / "config.json"
|
|
1371
|
+
if guard_cfg_path.exists():
|
|
1372
|
+
try:
|
|
1373
|
+
guard_cfg = json.loads(guard_cfg_path.read_text())
|
|
1374
|
+
guard_cfg["workspace_id"] = new_id
|
|
1375
|
+
_atomic_write(guard_cfg_path, guard_cfg)
|
|
1376
|
+
except Exception:
|
|
1377
|
+
pass
|
|
1378
|
+
|
|
1379
|
+
# Re-sync Guard policies for the new workspace
|
|
1380
|
+
try:
|
|
1381
|
+
policy = _guard._req(
|
|
1382
|
+
"GET",
|
|
1383
|
+
f"{server}/guard/policies/sync?workspace_id={new_id}",
|
|
1384
|
+
api_key=api_key,
|
|
1385
|
+
)
|
|
1386
|
+
_guard._save_policy(policy)
|
|
1387
|
+
rule_count = len(policy.get("rules", []))
|
|
1388
|
+
print(f" {GRAY}Guard policies synced: {rule_count} rule(s){RESET}")
|
|
1389
|
+
except SystemExit:
|
|
1390
|
+
pass # Guard not configured for this workspace — skip silently
|
|
1391
|
+
except Exception:
|
|
1392
|
+
pass
|
|
1393
|
+
|
|
1394
|
+
print(f"{GREEN}✓ Switched to \"{new_name}\" ({new_id[:8]}){RESET}")
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
def cmd_whoami(args):
|
|
1398
|
+
cfg = _load_config()
|
|
1399
|
+
|
|
1400
|
+
workspace_id = cfg.get("workspace", "")
|
|
1401
|
+
server = cfg.get("server", "—")
|
|
1402
|
+
api_key = cfg.get("api_key", "")
|
|
1403
|
+
|
|
1404
|
+
# Try to resolve workspace name from /projects
|
|
1405
|
+
workspace_name = ""
|
|
1406
|
+
if workspace_id and server != "—" and api_key:
|
|
1407
|
+
try:
|
|
1408
|
+
hdrs = {"Content-Type": "application/json", "X-Api-Key": api_key}
|
|
1409
|
+
projects = api.req("GET", f"{server.rstrip('/')}/projects", hdrs)
|
|
1410
|
+
match = next((p for p in projects if str(p.get("id", "")) == str(workspace_id)), None)
|
|
1411
|
+
if match:
|
|
1412
|
+
workspace_name = match["name"]
|
|
1413
|
+
except Exception:
|
|
1414
|
+
pass
|
|
1415
|
+
|
|
1416
|
+
ws_display = workspace_name if workspace_name else workspace_id
|
|
1417
|
+
ws_id_hint = f" ({workspace_id[:8]})" if workspace_id else ""
|
|
1418
|
+
api_key_display = (api_key[:12] + "… (set)") if api_key else "not set"
|
|
1419
|
+
|
|
1420
|
+
print(f"\n{BOLD}Workspace:{RESET} {ws_display}{ws_id_hint}")
|
|
1421
|
+
print(f"{BOLD}Server:{RESET} {server}")
|
|
1422
|
+
print(f"{BOLD}API key:{RESET} {api_key_display}")
|
|
1423
|
+
|
|
1424
|
+
# Guard section
|
|
1425
|
+
guard_cfg_path = Path.home() / ".conductguard" / "config.json"
|
|
1426
|
+
policy_path = Path.home() / ".conductguard" / "policy.json"
|
|
1427
|
+
hook_path = Path.home() / ".conductguard" / "hook.py"
|
|
1428
|
+
|
|
1429
|
+
if guard_cfg_path.exists():
|
|
1430
|
+
try:
|
|
1431
|
+
gcfg = json.loads(guard_cfg_path.read_text())
|
|
1432
|
+
user_email = gcfg.get("user_email", "")
|
|
1433
|
+
rule_count = 0
|
|
1434
|
+
if policy_path.exists():
|
|
1435
|
+
try:
|
|
1436
|
+
rule_count = len(json.loads(policy_path.read_text()).get("rules", []))
|
|
1437
|
+
except Exception:
|
|
1438
|
+
pass
|
|
1439
|
+
hook_status = "hook installed" if hook_path.exists() else "hook missing"
|
|
1440
|
+
email_part = f" | member: {user_email}" if user_email else ""
|
|
1441
|
+
print(f"{BOLD}Guard:{RESET} {GREEN}✓ {hook_status}{RESET} | policy: {rule_count} rules{email_part}")
|
|
1442
|
+
except Exception:
|
|
1443
|
+
print(f"{BOLD}Guard:{RESET} {YELLOW}config unreadable{RESET}")
|
|
1444
|
+
else:
|
|
1445
|
+
print(f"{BOLD}Guard:{RESET} not configured")
|
|
1446
|
+
|
|
1447
|
+
# Booster section
|
|
1448
|
+
booster_paths = [
|
|
1449
|
+
Path.home() / ".booster" / "config.json",
|
|
1450
|
+
Path.home() / ".agent-booster" / "config.json",
|
|
1451
|
+
]
|
|
1452
|
+
booster_found = any(p.exists() for p in booster_paths)
|
|
1453
|
+
if booster_found:
|
|
1454
|
+
print(f"{BOLD}Booster:{RESET} {GREEN}✓ configured{RESET}")
|
|
1455
|
+
else:
|
|
1456
|
+
print(f"{BOLD}Booster:{RESET} not configured")
|
|
1457
|
+
|
|
1458
|
+
print()
|
|
1459
|
+
|
|
1460
|
+
|
|
1292
1461
|
def cmd_run(args):
|
|
1293
1462
|
server, workspace_id, api_key, token = _require_auth(args)
|
|
1294
1463
|
json_h = api.headers(workspace_id, token, "application/json", api_key)
|
|
@@ -1327,10 +1496,13 @@ def cmd_run(args):
|
|
|
1327
1496
|
print(f" {GRAY}{k}={v}{RESET}")
|
|
1328
1497
|
print()
|
|
1329
1498
|
|
|
1330
|
-
|
|
1499
|
+
body: dict = {
|
|
1331
1500
|
"triggered_by": "cli",
|
|
1332
1501
|
"initial_state": {"__manual": True, "inputs": initial_state},
|
|
1333
|
-
}
|
|
1502
|
+
}
|
|
1503
|
+
if getattr(args, "max_turns", None):
|
|
1504
|
+
body["max_turns"] = args.max_turns
|
|
1505
|
+
run = api.req("POST", f"{server}/workflows/{workflow_id}/runs", json_h, body)
|
|
1334
1506
|
_stream_run(server, workflow_id, run["id"], workspace_id, token, api_key)
|
|
1335
1507
|
|
|
1336
1508
|
|
|
@@ -1435,9 +1607,18 @@ def main():
|
|
|
1435
1607
|
|
|
1436
1608
|
# conduct run (existing)
|
|
1437
1609
|
run_p = sub.add_parser("run", help="Run an installed agent by name")
|
|
1438
|
-
run_p.add_argument("agent",
|
|
1439
|
-
run_p.add_argument("--project",
|
|
1440
|
-
run_p.add_argument("--input",
|
|
1610
|
+
run_p.add_argument("agent", help="Agent name (e.g. 'security_autopilot_fix')")
|
|
1611
|
+
run_p.add_argument("--project", metavar="name", help="Narrow to a specific project")
|
|
1612
|
+
run_p.add_argument("--input", action="append", metavar="key=value", help="Runtime input (repeatable)")
|
|
1613
|
+
run_p.add_argument("--max-turns", dest="max_turns", type=int, metavar="N", help="Max agentic turns (default: auto)")
|
|
1614
|
+
|
|
1615
|
+
# conduct switch [workspace]
|
|
1616
|
+
switch_p = sub.add_parser("switch", help="Switch active workspace (or list workspaces)")
|
|
1617
|
+
switch_p.add_argument("workspace", nargs="?", metavar="name_or_id",
|
|
1618
|
+
help="Workspace name or UUID prefix to switch to (omit to list)")
|
|
1619
|
+
|
|
1620
|
+
# conduct whoami
|
|
1621
|
+
sub.add_parser("whoami", help="Show current workspace, server, API key, and Guard/Booster status")
|
|
1441
1622
|
|
|
1442
1623
|
# conduct guard
|
|
1443
1624
|
guard_p, _guard_sub = _guard.register_guard_parser(sub)
|
|
@@ -1491,6 +1672,10 @@ def main():
|
|
|
1491
1672
|
cmd_test(args)
|
|
1492
1673
|
elif args.command == "run":
|
|
1493
1674
|
cmd_run(args)
|
|
1675
|
+
elif args.command == "switch":
|
|
1676
|
+
cmd_switch(args)
|
|
1677
|
+
elif args.command == "whoami":
|
|
1678
|
+
cmd_whoami(args)
|
|
1494
1679
|
elif args.command == "guard":
|
|
1495
1680
|
_guard.dispatch_guard(args, guard_p)
|
|
1496
1681
|
elif args.command == "mcp":
|
|
@@ -12,4 +12,5 @@ src/conduct_cli.egg-info/SOURCES.txt
|
|
|
12
12
|
src/conduct_cli.egg-info/dependency_links.txt
|
|
13
13
|
src/conduct_cli.egg-info/entry_points.txt
|
|
14
14
|
src/conduct_cli.egg-info/requires.txt
|
|
15
|
-
src/conduct_cli.egg-info/top_level.txt
|
|
15
|
+
src/conduct_cli.egg-info/top_level.txt
|
|
16
|
+
tests/test_switch.py
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Tests for `conduct switch` and `conduct whoami` commands."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import types
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import MagicMock, patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Helpers
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
def _make_args(**kwargs):
|
|
17
|
+
ns = types.SimpleNamespace(**kwargs)
|
|
18
|
+
return ns
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _fake_workspaces():
|
|
22
|
+
return [
|
|
23
|
+
{"id": "ef0a7e36-0000-0000-0000-000000000001", "name": "Engineering", "owner_id": "u1", "workflow_count": 3},
|
|
24
|
+
{"id": "ab1b2c3d-0000-0000-0000-000000000002", "name": "Marketing", "owner_id": "u1", "workflow_count": 1},
|
|
25
|
+
{"id": "deadbeef-0000-0000-0000-000000000003", "name": "Eng Backup", "owner_id": "u1", "workflow_count": 0},
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# cmd_switch — list mode (no arg)
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
def test_switch_list_prints_workspaces(tmp_path, capsys):
|
|
34
|
+
"""conduct switch with no arg exits 0 and prints workspace list."""
|
|
35
|
+
from conduct_cli import main as m
|
|
36
|
+
|
|
37
|
+
config = {
|
|
38
|
+
"server": "https://api.conductai.ai",
|
|
39
|
+
"api_key": "cond_live_testkey",
|
|
40
|
+
"workspace": "ef0a7e36-0000-0000-0000-000000000001",
|
|
41
|
+
}
|
|
42
|
+
cfg_path = tmp_path / "config.json"
|
|
43
|
+
cfg_path.write_text(json.dumps(config))
|
|
44
|
+
|
|
45
|
+
args = _make_args(workspace=None)
|
|
46
|
+
|
|
47
|
+
with (
|
|
48
|
+
patch.object(m, "CONFIG_PATH", cfg_path),
|
|
49
|
+
patch.object(m.api, "req", return_value=_fake_workspaces()),
|
|
50
|
+
):
|
|
51
|
+
m.cmd_switch(args)
|
|
52
|
+
|
|
53
|
+
out = capsys.readouterr().out
|
|
54
|
+
assert "Engineering" in out
|
|
55
|
+
assert "Marketing" in out
|
|
56
|
+
assert "*" in out # current workspace marked
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# cmd_switch — exact match updates both config files
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def test_switch_exact_name_updates_configs(tmp_path, capsys):
|
|
64
|
+
"""conduct switch 'Marketing' updates ~/.conduct/config.json and guard config."""
|
|
65
|
+
from conduct_cli import main as m
|
|
66
|
+
from conduct_cli import guard as g
|
|
67
|
+
|
|
68
|
+
cfg_path = tmp_path / "conduct" / "config.json"
|
|
69
|
+
# Guard config lives at <home>/.conductguard/config.json; home is patched to tmp_path
|
|
70
|
+
guard_cfg_path = tmp_path / ".conductguard" / "config.json"
|
|
71
|
+
|
|
72
|
+
cfg_path.parent.mkdir(parents=True)
|
|
73
|
+
guard_cfg_path.parent.mkdir(parents=True)
|
|
74
|
+
|
|
75
|
+
cfg_path.write_text(json.dumps({
|
|
76
|
+
"server": "https://api.conductai.ai",
|
|
77
|
+
"api_key": "cond_live_testkey",
|
|
78
|
+
"workspace": "ef0a7e36-0000-0000-0000-000000000001",
|
|
79
|
+
}))
|
|
80
|
+
guard_cfg_path.write_text(json.dumps({
|
|
81
|
+
"workspace_id": "ef0a7e36-0000-0000-0000-000000000001",
|
|
82
|
+
"user_email": "dev@example.com",
|
|
83
|
+
}))
|
|
84
|
+
|
|
85
|
+
args = _make_args(workspace="Marketing")
|
|
86
|
+
|
|
87
|
+
fake_policy = {"version": "2", "rules": [{"rule_id": "r1", "action": "audit"}]}
|
|
88
|
+
|
|
89
|
+
with (
|
|
90
|
+
patch.object(m, "CONFIG_PATH", cfg_path),
|
|
91
|
+
patch("pathlib.Path.home", return_value=tmp_path),
|
|
92
|
+
patch.object(m.api, "req", return_value=_fake_workspaces()),
|
|
93
|
+
patch.object(g, "_req", return_value=fake_policy),
|
|
94
|
+
patch.object(g, "_save_policy") as mock_save_policy,
|
|
95
|
+
):
|
|
96
|
+
m.cmd_switch(args)
|
|
97
|
+
|
|
98
|
+
out = capsys.readouterr().out
|
|
99
|
+
assert "Marketing" in out
|
|
100
|
+
assert "ab1b2c3d" in out # first 8 chars of the new workspace id
|
|
101
|
+
|
|
102
|
+
updated_cfg = json.loads(cfg_path.read_text())
|
|
103
|
+
assert updated_cfg["workspace"] == "ab1b2c3d-0000-0000-0000-000000000002"
|
|
104
|
+
|
|
105
|
+
updated_guard = json.loads(guard_cfg_path.read_text())
|
|
106
|
+
assert updated_guard["workspace_id"] == "ab1b2c3d-0000-0000-0000-000000000002"
|
|
107
|
+
|
|
108
|
+
mock_save_policy.assert_called_once_with(fake_policy)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# cmd_switch — ambiguous partial match
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def test_switch_ambiguous_exits_1(tmp_path, capsys):
|
|
116
|
+
"""Partial match that hits multiple workspaces prints error and exits 1."""
|
|
117
|
+
from conduct_cli import main as m
|
|
118
|
+
|
|
119
|
+
cfg_path = tmp_path / "config.json"
|
|
120
|
+
cfg_path.write_text(json.dumps({
|
|
121
|
+
"server": "https://api.conductai.ai",
|
|
122
|
+
"api_key": "cond_live_testkey",
|
|
123
|
+
"workspace": "ef0a7e36-0000-0000-0000-000000000001",
|
|
124
|
+
}))
|
|
125
|
+
|
|
126
|
+
# "Eng" matches both "Engineering" and "Eng Backup"
|
|
127
|
+
args = _make_args(workspace="Eng")
|
|
128
|
+
|
|
129
|
+
with (
|
|
130
|
+
patch.object(m, "CONFIG_PATH", cfg_path),
|
|
131
|
+
patch.object(m.api, "req", return_value=_fake_workspaces()),
|
|
132
|
+
pytest.raises(SystemExit) as exc,
|
|
133
|
+
):
|
|
134
|
+
m.cmd_switch(args)
|
|
135
|
+
|
|
136
|
+
assert exc.value.code == 1
|
|
137
|
+
out = capsys.readouterr().out
|
|
138
|
+
assert "Ambiguous" in out or "ambiguous" in out.lower() or "more specific" in out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# cmd_switch — no match exits 1
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def test_switch_no_match_exits_1(tmp_path, capsys):
|
|
146
|
+
"""conduct switch 'Nonexistent' exits 1 and lists available workspaces."""
|
|
147
|
+
from conduct_cli import main as m
|
|
148
|
+
|
|
149
|
+
cfg_path = tmp_path / "config.json"
|
|
150
|
+
cfg_path.write_text(json.dumps({
|
|
151
|
+
"server": "https://api.conductai.ai",
|
|
152
|
+
"api_key": "cond_live_testkey",
|
|
153
|
+
"workspace": "ef0a7e36-0000-0000-0000-000000000001",
|
|
154
|
+
}))
|
|
155
|
+
|
|
156
|
+
args = _make_args(workspace="Nonexistent")
|
|
157
|
+
|
|
158
|
+
with (
|
|
159
|
+
patch.object(m, "CONFIG_PATH", cfg_path),
|
|
160
|
+
patch.object(m.api, "req", return_value=_fake_workspaces()),
|
|
161
|
+
pytest.raises(SystemExit) as exc,
|
|
162
|
+
):
|
|
163
|
+
m.cmd_switch(args)
|
|
164
|
+
|
|
165
|
+
assert exc.value.code == 1
|
|
166
|
+
out = capsys.readouterr().out
|
|
167
|
+
assert "Engineering" in out # shows available list
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# cmd_whoami — basic output
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def test_whoami_prints_all_sections(tmp_path, capsys):
|
|
175
|
+
"""conduct whoami prints workspace, server, api_key, Guard, and Booster lines."""
|
|
176
|
+
from conduct_cli import main as m
|
|
177
|
+
|
|
178
|
+
cfg_path = tmp_path / "conduct" / "config.json"
|
|
179
|
+
cfg_path.parent.mkdir(parents=True)
|
|
180
|
+
cfg_path.write_text(json.dumps({
|
|
181
|
+
"server": "https://api.conductai.ai",
|
|
182
|
+
"api_key": "cond_live_88a4longkeyxxx",
|
|
183
|
+
"workspace": "ef0a7e36-0000-0000-0000-000000000001",
|
|
184
|
+
}))
|
|
185
|
+
|
|
186
|
+
guard_dir = tmp_path / ".conductguard"
|
|
187
|
+
guard_dir.mkdir()
|
|
188
|
+
(guard_dir / "config.json").write_text(json.dumps({
|
|
189
|
+
"workspace_id": "ef0a7e36-0000-0000-0000-000000000001",
|
|
190
|
+
"user_email": "sudhi@b2bsphere.com",
|
|
191
|
+
}))
|
|
192
|
+
(guard_dir / "policy.json").write_text(json.dumps({
|
|
193
|
+
"version": "1",
|
|
194
|
+
"rules": [{"rule_id": "r1"}, {"rule_id": "r2"}, {"rule_id": "r3"}],
|
|
195
|
+
}))
|
|
196
|
+
# No hook.py — hook_status should say "hook missing"
|
|
197
|
+
|
|
198
|
+
args = _make_args()
|
|
199
|
+
|
|
200
|
+
def fake_home():
|
|
201
|
+
return tmp_path
|
|
202
|
+
|
|
203
|
+
with (
|
|
204
|
+
patch.object(m, "CONFIG_PATH", cfg_path),
|
|
205
|
+
patch("pathlib.Path.home", return_value=tmp_path),
|
|
206
|
+
patch.object(m.api, "req", return_value=_fake_workspaces()),
|
|
207
|
+
):
|
|
208
|
+
m.cmd_whoami(args)
|
|
209
|
+
|
|
210
|
+
out = capsys.readouterr().out
|
|
211
|
+
assert "https://api.conductai.ai" in out
|
|
212
|
+
assert "cond_live_88" in out # first 12 chars of the api_key
|
|
213
|
+
assert "sudhi@b2bsphere.com" in out
|
|
214
|
+
assert "3 rules" in out
|
|
215
|
+
assert "Booster" in out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|