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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.4.38
3
+ Version: 0.4.41
4
4
  Summary: CLI for Conduct AI — install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.4.38"
7
+ version = "0.4.41"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -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
- run = api.req("POST", f"{server}/workflows/{workflow_id}/runs", json_h, {
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", help="Agent name (e.g. 'security_autopilot_fix')")
1439
- run_p.add_argument("--project", metavar="name", help="Narrow to a specific project")
1440
- run_p.add_argument("--input", action="append", metavar="key=value", help="Runtime input (repeatable)")
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":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.4.38
3
+ Version: 0.4.41
4
4
  Summary: CLI for Conduct AI — install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -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