conduct-cli 0.4.28__tar.gz → 0.4.30__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.28
3
+ Version: 0.4.30
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.28"
7
+ version = "0.4.30"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -832,6 +832,124 @@ def cmd_guard_join(args):
832
832
  )
833
833
 
834
834
 
835
+ def _report_tools_to_server() -> None:
836
+ """Detect AI coding tools on this machine and POST coverage to Guard API. Silent on failure."""
837
+ home = Path.home()
838
+
839
+ def _check_json_key(path: Path, *keys) -> bool:
840
+ try:
841
+ d = json.loads(path.read_text()) if path.exists() else {}
842
+ for k in keys:
843
+ d = d.get(k, {}) if isinstance(d, dict) else {}
844
+ return bool(d) and isinstance(d, dict) and len(d) > 0
845
+ except Exception:
846
+ return False
847
+
848
+ def _check_json_mcp(path: Path) -> bool:
849
+ try:
850
+ d = json.loads(path.read_text()) if path.exists() else {}
851
+ return "conduct" in d.get("mcpServers", {})
852
+ except Exception:
853
+ return False
854
+
855
+ def _check_json_hook(path: Path) -> bool:
856
+ try:
857
+ d = json.loads(path.read_text()) if path.exists() else {}
858
+ hooks = d.get("hooks", {})
859
+ pre = hooks.get("PreToolUse", [])
860
+ return any("conductguard" in str(h) or "conduct" in str(h).lower() for h in pre)
861
+ except Exception:
862
+ return False
863
+
864
+ def _check_toml_str(path: Path, needle: str) -> bool:
865
+ try:
866
+ return needle in (path.read_text() if path.exists() else "")
867
+ except Exception:
868
+ return False
869
+
870
+ tools = []
871
+
872
+ claude_dir = home / ".claude"
873
+ if claude_dir.exists():
874
+ settings = claude_dir / "settings.json"
875
+ tools.append({
876
+ "name": "claude-code",
877
+ "mcp_registered": _check_json_mcp(settings),
878
+ "hook_registered": _check_json_hook(settings),
879
+ })
880
+
881
+ codex_dir = home / ".codex"
882
+ if codex_dir.exists():
883
+ config = codex_dir / "config.toml"
884
+ tools.append({
885
+ "name": "codex",
886
+ "mcp_registered": _check_toml_str(config, "conduct-mcp"),
887
+ "hook_registered": _check_toml_str(config, "conductguard"),
888
+ })
889
+
890
+ cursor_dir = home / ".cursor"
891
+ if cursor_dir.exists():
892
+ tools.append({
893
+ "name": "cursor",
894
+ "mcp_registered": _check_json_mcp(cursor_dir / "mcp.json"),
895
+ "hook_registered": False,
896
+ })
897
+
898
+ windsurf_dir = home / ".codeium" / "windsurf"
899
+ if windsurf_dir.exists():
900
+ tools.append({
901
+ "name": "windsurf",
902
+ "mcp_registered": _check_json_mcp(windsurf_dir / "mcp_config.json"),
903
+ "hook_registered": False,
904
+ })
905
+
906
+ vscode_candidates = [
907
+ home / "Library" / "Application Support" / "Code" / "User" / "settings.json",
908
+ home / ".config" / "Code" / "User" / "settings.json",
909
+ home / ".vscode" / "settings.json",
910
+ ]
911
+ vscode_settings = next((p for p in vscode_candidates if p.exists()), None)
912
+ if vscode_settings:
913
+ try:
914
+ d = json.loads(vscode_settings.read_text())
915
+ mcp_reg = "conduct" in d.get("mcp", {}).get("servers", {})
916
+ except Exception:
917
+ mcp_reg = False
918
+ tools.append({
919
+ "name": "vscode",
920
+ "mcp_registered": mcp_reg,
921
+ "hook_registered": False,
922
+ })
923
+
924
+ if not tools:
925
+ return
926
+
927
+ try:
928
+ cfg = _load_guard_config()
929
+ base_url = _api_url(cfg)
930
+ email = cfg.get("user_email", "")
931
+ token = cfg.get("member_token", "")
932
+ api_key = cfg.get("api_key", "")
933
+
934
+ if not email:
935
+ return
936
+
937
+ payload = json.dumps({"email": email, "tools": tools}).encode()
938
+ auth = token or api_key
939
+ req = urllib.request.Request(
940
+ f"{base_url}/guard/developer-tools",
941
+ data=payload,
942
+ headers={
943
+ "Content-Type": "application/json",
944
+ "Authorization": f"Bearer {auth}",
945
+ },
946
+ method="POST",
947
+ )
948
+ urllib.request.urlopen(req, timeout=8)
949
+ except Exception:
950
+ pass # Never surface errors — this is background telemetry
951
+
952
+
835
953
  def cmd_guard_sync(args):
836
954
  cfg = _require_guard_config()
837
955
  workspace_id = cfg.get("workspace_id")
@@ -861,6 +979,12 @@ def cmd_guard_sync(args):
861
979
  # Capture savings from RTK and Agent Booster
862
980
  _report_savings(cfg, base_url, api_key)
863
981
 
982
+ # Report AI tool coverage
983
+ try:
984
+ _report_tools_to_server()
985
+ except Exception:
986
+ pass
987
+
864
988
  print(f"\n{BOLD}Policy refreshed ({rule_count} rule(s)).{RESET}")
865
989
 
866
990
 
@@ -217,7 +217,7 @@ def _write_codex_mcp_config() -> bool:
217
217
  try:
218
218
  content = config_path.read_text() if config_path.exists() else ""
219
219
  if "conduct-mcp" in content:
220
- return True # already registered
220
+ return True
221
221
  mcp_block = '\n[[mcp_servers]]\nname = "conduct"\ncommand = "conduct-mcp"\nargs = []\n'
222
222
  config_path.write_text(content + mcp_block)
223
223
  return True
@@ -225,8 +225,198 @@ def _write_codex_mcp_config() -> bool:
225
225
  return False
226
226
 
227
227
 
228
+ def _write_cursor_mcp_config() -> bool:
229
+ """Write conduct-mcp into ~/.cursor/mcp.json. Returns True if written."""
230
+ cursor_dir = Path.home() / ".cursor"
231
+ if not cursor_dir.exists():
232
+ return False
233
+ config_path = cursor_dir / "mcp.json"
234
+ try:
235
+ existing = json.loads(config_path.read_text()) if config_path.exists() else {}
236
+ servers = existing.setdefault("mcpServers", {})
237
+ if "conduct" in servers:
238
+ return True
239
+ servers["conduct"] = {"command": "conduct-mcp", "args": []}
240
+ config_path.write_text(json.dumps(existing, indent=2))
241
+ return True
242
+ except Exception:
243
+ return False
244
+
245
+
246
+ def _write_windsurf_mcp_config() -> bool:
247
+ """Write conduct-mcp into ~/.codeium/windsurf/mcp_config.json. Returns True if written."""
248
+ config_path = Path.home() / ".codeium" / "windsurf" / "mcp_config.json"
249
+ if not config_path.parent.exists():
250
+ return False
251
+ try:
252
+ existing = json.loads(config_path.read_text()) if config_path.exists() else {}
253
+ servers = existing.setdefault("mcpServers", {})
254
+ if "conduct" in servers:
255
+ return True
256
+ servers["conduct"] = {"command": "conduct-mcp", "args": []}
257
+ config_path.write_text(json.dumps(existing, indent=2))
258
+ return True
259
+ except Exception:
260
+ return False
261
+
262
+
263
+ def _write_vscode_mcp_config() -> bool:
264
+ """Write conduct-mcp into VS Code settings.json (mcp.servers). Returns True if written."""
265
+ # Check both standard locations
266
+ candidates = [
267
+ Path.home() / ".vscode" / "settings.json",
268
+ Path.home() / "Library" / "Application Support" / "Code" / "User" / "settings.json",
269
+ Path.home() / ".config" / "Code" / "User" / "settings.json",
270
+ ]
271
+ settings_path = next((p for p in candidates if p.exists()), None)
272
+ if not settings_path:
273
+ return False
274
+ try:
275
+ existing = json.loads(settings_path.read_text()) if settings_path.exists() else {}
276
+ servers = existing.setdefault("mcp", {}).setdefault("servers", {})
277
+ if "conduct" in servers:
278
+ return True
279
+ servers["conduct"] = {"command": "conduct-mcp", "args": []}
280
+ settings_path.write_text(json.dumps(existing, indent=2))
281
+ return True
282
+ except Exception:
283
+ return False
284
+
285
+
286
+ def _detect_ai_tools() -> list:
287
+ """
288
+ Detect which AI coding tools are installed and whether Guard/conduct-mcp is registered.
289
+ Returns list of {name, mcp_registered, hook_registered} for each detected tool.
290
+ Only includes tools whose config directory exists on this machine.
291
+ """
292
+ home = Path.home()
293
+ results = []
294
+
295
+ def _check_json_mcp(path: Path) -> bool:
296
+ try:
297
+ d = json.loads(path.read_text()) if path.exists() else {}
298
+ return "conduct" in d.get("mcpServers", {})
299
+ except Exception:
300
+ return False
301
+
302
+ def _check_json_hook(path: Path, hook_key: str = "hooks") -> bool:
303
+ try:
304
+ d = json.loads(path.read_text()) if path.exists() else {}
305
+ hooks = d.get(hook_key, {})
306
+ pre = hooks.get("PreToolUse", [])
307
+ return any("conductguard" in str(h) or "conduct" in str(h).lower() for h in pre)
308
+ except Exception:
309
+ return False
310
+
311
+ def _check_toml_str(path: Path, needle: str) -> bool:
312
+ try:
313
+ return needle in (path.read_text() if path.exists() else "")
314
+ except Exception:
315
+ return False
316
+
317
+ # Claude Code
318
+ claude_dir = home / ".claude"
319
+ if claude_dir.exists():
320
+ settings = claude_dir / "settings.json"
321
+ results.append({
322
+ "name": "claude-code",
323
+ "mcp_registered": _check_json_mcp(settings),
324
+ "hook_registered": _check_json_hook(settings),
325
+ })
326
+
327
+ # Codex
328
+ codex_dir = home / ".codex"
329
+ if codex_dir.exists():
330
+ config = codex_dir / "config.toml"
331
+ results.append({
332
+ "name": "codex",
333
+ "mcp_registered": _check_toml_str(config, "conduct-mcp"),
334
+ "hook_registered": _check_toml_str(config, "conductguard"),
335
+ })
336
+
337
+ # Cursor
338
+ cursor_dir = home / ".cursor"
339
+ if cursor_dir.exists():
340
+ results.append({
341
+ "name": "cursor",
342
+ "mcp_registered": _check_json_mcp(cursor_dir / "mcp.json"),
343
+ "hook_registered": False, # Cursor uses MCP only, no hook
344
+ })
345
+
346
+ # Windsurf
347
+ windsurf_dir = home / ".codeium" / "windsurf"
348
+ if windsurf_dir.exists():
349
+ results.append({
350
+ "name": "windsurf",
351
+ "mcp_registered": _check_json_mcp(windsurf_dir / "mcp_config.json"),
352
+ "hook_registered": False, # Windsurf uses MCP only
353
+ })
354
+
355
+ # VS Code (Copilot)
356
+ vscode_settings_candidates = [
357
+ home / "Library" / "Application Support" / "Code" / "User" / "settings.json",
358
+ home / ".config" / "Code" / "User" / "settings.json",
359
+ home / ".vscode" / "settings.json",
360
+ ]
361
+ vscode_settings = next((p for p in vscode_settings_candidates if p.exists()), None)
362
+ if vscode_settings:
363
+ try:
364
+ d = json.loads(vscode_settings.read_text())
365
+ mcp_reg = "conduct" in d.get("mcp", {}).get("servers", {})
366
+ except Exception:
367
+ mcp_reg = False
368
+ results.append({
369
+ "name": "vscode",
370
+ "mcp_registered": mcp_reg,
371
+ "hook_registered": False, # VS Code uses MCP only
372
+ })
373
+
374
+ return results
375
+
376
+
377
+ def _report_tool_coverage() -> None:
378
+ """Detect AI tools on this machine and POST coverage to Guard API. Silent on failure."""
379
+ try:
380
+ cfg = _load_config()
381
+ server = cfg.get("server", "").rstrip("/")
382
+ api_key = cfg.get("api_key", "")
383
+ token = cfg.get("token", "")
384
+ email = cfg.get("email", "")
385
+
386
+ # also check guard config for email/token
387
+ guard_cfg_path = Path.home() / ".conductguard" / "config.json"
388
+ if guard_cfg_path.exists():
389
+ gcfg = json.loads(guard_cfg_path.read_text())
390
+ if not email:
391
+ email = gcfg.get("user_email", "")
392
+ if not token:
393
+ token = gcfg.get("member_token", "")
394
+
395
+ if not server or not email:
396
+ return
397
+
398
+ tools = _detect_ai_tools()
399
+ if not tools:
400
+ return
401
+
402
+ payload = json.dumps({"email": email, "tools": tools}).encode()
403
+ auth = token or api_key
404
+ req = urllib.request.Request(
405
+ f"{server}/guard/developer-tools",
406
+ data=payload,
407
+ headers={
408
+ "Content-Type": "application/json",
409
+ "Authorization": f"Bearer {auth}",
410
+ },
411
+ method="POST",
412
+ )
413
+ urllib.request.urlopen(req, timeout=8)
414
+ except Exception:
415
+ pass # Never surface errors — this is background telemetry
416
+
417
+
228
418
  def cmd_mcp_install(args):
229
- """Register conduct-mcp in Claude Code and Codex CLI."""
419
+ """Register conduct-mcp in Claude Code, Codex, Cursor, Windsurf, and VS Code."""
230
420
  import shutil
231
421
  import subprocess
232
422
 
@@ -242,29 +432,48 @@ def cmd_mcp_install(args):
242
432
  if result.returncode == 0:
243
433
  registered.append("Claude Code")
244
434
  else:
245
- # claude mcp add is idempotent; also try writing settings.json directly as fallback
246
435
  _write_claude_mcp_settings()
247
436
  registered.append("Claude Code (settings.json)")
248
437
  except Exception:
249
438
  _write_claude_mcp_settings()
250
439
  registered.append("Claude Code (settings.json)")
251
440
  else:
252
- # claude CLI not found but .claude/ might exist (IDE extension)
253
- written = _write_claude_mcp_settings()
254
- if written:
441
+ if _write_claude_mcp_settings():
255
442
  registered.append("Claude Code (settings.json)")
256
443
 
257
444
  # --- Codex CLI ---
258
- written = _write_codex_mcp_config()
259
- if written:
445
+ if _write_codex_mcp_config():
260
446
  registered.append("Codex")
261
447
 
448
+ # --- Cursor ---
449
+ if _write_cursor_mcp_config():
450
+ registered.append("Cursor")
451
+
452
+ # --- Windsurf ---
453
+ if _write_windsurf_mcp_config():
454
+ registered.append("Windsurf")
455
+
456
+ # --- VS Code (Copilot) ---
457
+ if _write_vscode_mcp_config():
458
+ registered.append("VS Code (Copilot)")
459
+
262
460
  if registered:
263
461
  print(f"{GREEN}✓ conduct-mcp registered in: {', '.join(registered)}{RESET}")
264
- print(f"{GRAY} Restart Claude Code / Codex to pick up the new MCP server.{RESET}")
462
+ print(f"{GRAY} Restart your AI tools to pick up the new MCP server.{RESET}")
265
463
  else:
266
- print(f"{YELLOW}⚠ No supported AI tools detected. Install Claude Code or Codex first.{RESET}")
267
- print(f"{GRAY} Then re-run: conduct mcp install{RESET}")
464
+ print(f"{YELLOW}⚠ No supported AI tools detected on this machine.{RESET}")
465
+ print(f"{GRAY} Supported: Claude Code, Codex, Cursor, Windsurf, VS Code{RESET}")
466
+ print(f"{GRAY} After installing any of these, re-run: conduct mcp install{RESET}")
467
+
468
+ tools = _detect_ai_tools()
469
+ if tools:
470
+ print(f"{GRAY} Detected tools: {', '.join(t['name'] for t in tools)}{RESET}")
471
+ covered = [t['name'] for t in tools if t['mcp_registered']]
472
+ if covered:
473
+ print(f"{GREEN} MCP registered: {', '.join(covered)}{RESET}")
474
+ uncovered = [t['name'] for t in tools if not t['mcp_registered']]
475
+ if uncovered:
476
+ print(f"{YELLOW} Not covered: {', '.join(uncovered)} — run: conduct mcp install{RESET}")
268
477
 
269
478
 
270
479
  def cmd_login(args):
@@ -336,6 +545,12 @@ def cmd_login(args):
336
545
  except Exception:
337
546
  pass # Never block login on MCP registration errors
338
547
 
548
+ # Report tool coverage to Guard
549
+ try:
550
+ _report_tool_coverage()
551
+ except Exception:
552
+ pass
553
+
339
554
 
340
555
  def cmd_agents(args):
341
556
  server, workspace_id, api_key, token = _require_auth(args)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.4.28
3
+ Version: 0.4.30
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
File without changes
File without changes
File without changes