conduct-cli 0.4.40__tar.gz → 0.4.42__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.40
3
+ Version: 0.4.42
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.40"
7
+ version = "0.4.42"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -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
+ print(f" {GRAY}Guard not configured for this workspace — policies not synced{RESET}")
1391
+ except Exception as e:
1392
+ print(f" {YELLOW}⚠ Guard policy sync failed: {e}{RESET}")
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)
@@ -1443,6 +1612,14 @@ def main():
1443
1612
  run_p.add_argument("--input", action="append", metavar="key=value", help="Runtime input (repeatable)")
1444
1613
  run_p.add_argument("--max-turns", dest="max_turns", type=int, metavar="N", help="Max agentic turns (default: auto)")
1445
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")
1622
+
1446
1623
  # conduct guard
1447
1624
  guard_p, _guard_sub = _guard.register_guard_parser(sub)
1448
1625
 
@@ -1495,6 +1672,10 @@ def main():
1495
1672
  cmd_test(args)
1496
1673
  elif args.command == "run":
1497
1674
  cmd_run(args)
1675
+ elif args.command == "switch":
1676
+ cmd_switch(args)
1677
+ elif args.command == "whoami":
1678
+ cmd_whoami(args)
1498
1679
  elif args.command == "guard":
1499
1680
  _guard.dispatch_guard(args, guard_p)
1500
1681
  elif args.command == "mcp":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.4.40
3
+ Version: 0.4.42
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