conduct-cli 0.5.3__tar.gz → 0.5.5__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.
Files changed (27) hide show
  1. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/PKG-INFO +1 -1
  2. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/pyproject.toml +1 -1
  3. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/api.py +2 -2
  4. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/guard.py +133 -38
  5. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/guardmcp.py +44 -19
  6. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/hook_template.py +22 -4
  7. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/main.py +102 -9
  8. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  9. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/README.md +0 -0
  10. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/setup.cfg +0 -0
  11. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/setup.py +0 -0
  12. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/__init__.py +0 -0
  13. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/hook_precompact_template.py +0 -0
  14. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/hook_session_start_template.py +0 -0
  15. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/hook_stop_template.py +0 -0
  16. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/mcp_server.py +0 -0
  17. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/memory.py +0 -0
  18. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli/paxel.py +0 -0
  19. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli.egg-info/SOURCES.txt +0 -0
  20. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  21. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  22. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli.egg-info/requires.txt +0 -0
  23. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/src/conduct_cli.egg-info/top_level.txt +0 -0
  24. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/tests/test_guard_policy.py +0 -0
  25. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/tests/test_guard_savings.py +0 -0
  26. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/tests/test_hook_syntax.py +0 -0
  27. {conduct_cli-0.5.3 → conduct_cli-0.5.5}/tests/test_switch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.5.3
3
+ Version: 0.5.5
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.5.3"
7
+ version = "0.5.5"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -31,7 +31,7 @@ def req(method: str, url: str, hdrs: dict, body=None, timeout: int = 30) -> dict
31
31
  detail = json.loads(raw).get("detail", raw)
32
32
  except Exception:
33
33
  detail = raw
34
- print(f"{RED}HTTP {e.code}: {detail}{RESET}")
34
+ print(f"{RED}HTTP {e.code}: {detail} [{url}]{RESET}")
35
35
  sys.exit(1)
36
36
  except (socket.timeout, TimeoutError):
37
37
  print(f"{RED}Request timed out: {url}{RESET}")
@@ -50,7 +50,7 @@ def req_text(method: str, url: str, hdrs: dict, body_text: str, timeout: int = 3
50
50
  detail = json.loads(raw).get("detail", raw)
51
51
  except Exception:
52
52
  detail = raw
53
- print(f"{RED}HTTP {e.code}: {detail}{RESET}")
53
+ print(f"{RED}HTTP {e.code}: {detail} [{url}]{RESET}")
54
54
  sys.exit(1)
55
55
  except (socket.timeout, TimeoutError):
56
56
  print(f"{RED}Request timed out: {url}{RESET}")
@@ -159,6 +159,61 @@ def _install_session_hooks() -> None:
159
159
 
160
160
  # ── Guard config helpers ──────────────────────────────────────────────────────
161
161
 
162
+ _PERSONA_LABELS = {
163
+ "conservative": "Conservative — production-safe, default deny",
164
+ "standard": "Standard — engineering teams, balanced",
165
+ "developer": "Developer — local dev, audit-first",
166
+ }
167
+
168
+
169
+ def _ensure_persona(workspace_id: str, api_key: str, base_url: str) -> str:
170
+ """Prompt for persona if none is set yet. Saves choice to guard config and API.
171
+
172
+ Returns the active persona name. Skips prompt silently if already set.
173
+ """
174
+ cfg = _load_guard_config()
175
+ if cfg.get("persona"):
176
+ return cfg["persona"]
177
+
178
+ print(f"\n{BOLD}Choose a policy persona for your agents:{RESET}")
179
+ choices = list(_PERSONA_LABELS.keys())
180
+ for i, key in enumerate(choices, 1):
181
+ print(f" {i}. {_PERSONA_LABELS[key]}")
182
+
183
+ while True:
184
+ try:
185
+ raw = input(f"\nEnter 1-{len(choices)} [default: 2 — Standard]: ").strip()
186
+ if raw == "":
187
+ raw = "2"
188
+ idx = int(raw) - 1
189
+ if 0 <= idx < len(choices):
190
+ chosen = choices[idx]
191
+ break
192
+ print(f" Enter a number between 1 and {len(choices)}")
193
+ except (ValueError, EOFError):
194
+ chosen = "standard"
195
+ break
196
+
197
+ # Push to API
198
+ try:
199
+ _req(
200
+ "PATCH",
201
+ f"{base_url}/guard/config/persona",
202
+ body={"persona": chosen},
203
+ api_key=api_key,
204
+ )
205
+ except Exception:
206
+ pass # non-fatal — local config still records the choice
207
+
208
+ # Persist locally so we skip the prompt on subsequent syncs
209
+ cfg = _load_guard_config()
210
+ cfg["persona"] = chosen
211
+ _save_guard_config(cfg)
212
+
213
+ print(f" {GREEN}Persona set:{RESET} {chosen.capitalize()}")
214
+ return chosen
215
+
216
+
162
217
  def _load_guard_config() -> dict:
163
218
  if CONFIG_PATH.exists():
164
219
  return json.loads(CONFIG_PATH.read_text())
@@ -190,6 +245,17 @@ def _api_url(cfg: dict) -> str:
190
245
  # ── MCP registration ──────────────────────────────────────────────────────────
191
246
 
192
247
  # Tools that support mcpServers JSON — only write if the config file already exists
248
+ def _vscode_mcp_paths() -> list[tuple[Path, str]]:
249
+ """VS Code Copilot MCP config locations (macOS + Linux). Only if Copilot is installed."""
250
+ ext_dir = Path.home() / ".vscode" / "extensions"
251
+ if not ext_dir.exists() or not any(p.name.startswith("github.copilot") for p in ext_dir.iterdir() if p.is_dir()):
252
+ return []
253
+ candidates = [
254
+ Path.home() / "Library" / "Application Support" / "Code" / "User" / "mcp.json",
255
+ Path.home() / ".config" / "Code" / "User" / "mcp.json",
256
+ ]
257
+ return [(p, "VS Code Copilot") for p in candidates if p.parent.exists()]
258
+
193
259
  _MCP_TARGETS = [
194
260
  (Path.home() / ".claude" / "settings.json", "Claude Code"),
195
261
  (Path.home() / ".cursor" / "mcp.json", "Cursor"),
@@ -212,8 +278,9 @@ def _register_mcp(workspace_id: str, member_token: str, api_url: str) -> None:
212
278
  if shutil.which("booster"):
213
279
  servers["agent-booster"] = {"command": "booster", "args": ["serve"]}
214
280
 
281
+ targets = list(_MCP_TARGETS) + _vscode_mcp_paths()
215
282
  found_any = False
216
- for cfg_path, label in _MCP_TARGETS:
283
+ for cfg_path, label in targets:
217
284
  if not cfg_path.exists():
218
285
  continue
219
286
  found_any = True
@@ -327,7 +394,7 @@ def _req(method: str, url: str, body=None, token: str = None, api_key: str = Non
327
394
  detail = json.loads(raw).get("detail", raw)
328
395
  except Exception:
329
396
  detail = raw
330
- print(f"{RED}HTTP {e.code}: {detail}{RESET}")
397
+ print(f"{RED}HTTP {e.code}: {detail} [{url}]{RESET}")
331
398
  sys.exit(1)
332
399
  except Exception:
333
400
  print(f"{RED}Could not reach ConductAI API. Check your connection.{RESET}")
@@ -340,6 +407,13 @@ def _save_policy(policy: dict):
340
407
  POLICY_PATH.write_text(json.dumps(policy, indent=2))
341
408
 
342
409
 
410
+ def _load_policy() -> dict:
411
+ try:
412
+ return json.loads(POLICY_PATH.read_text())
413
+ except Exception:
414
+ return {"rules": []}
415
+
416
+
343
417
  # ── since-string parser ───────────────────────────────────────────────────────
344
418
 
345
419
  def _parse_since(since_str: str) -> str:
@@ -475,14 +549,8 @@ def cmd_guard_install(args):
475
549
  user_email = result.get("user_email") or ""
476
550
  clerk_user_id = result.get("clerk_user_id") or ""
477
551
 
478
- # Check if Security Loop module is installed for this workspace
479
- security_emit = False
480
- try:
481
- sec = _req("GET", f"{server}/secure/installed?workspace_id={workspace_id}", api_key=api_key)
482
- if sec.get("installed"):
483
- security_emit = True
484
- except Exception:
485
- pass
552
+ # Persona selection prompt once, skip if already chosen
553
+ _ensure_persona(workspace_id, api_key, server)
486
554
 
487
555
  # Persist guard config — include api_key so CLI commands can authenticate
488
556
  import time as _time
@@ -493,11 +561,8 @@ def cmd_guard_install(args):
493
561
  "clerk_user_id": clerk_user_id,
494
562
  "api_key": api_key,
495
563
  "api_url": server,
496
- "security_emit_enabled": security_emit,
497
564
  "last_synced_at": _time.time(),
498
565
  })
499
- if security_emit:
500
- print(f" {GREEN}Security Loop:{RESET} installed — classifier active")
501
566
 
502
567
  # Download policies
503
568
  try:
@@ -655,15 +720,19 @@ def _report_tools_to_server() -> None:
655
720
  "hook_registered": False,
656
721
  })
657
722
 
658
- vscode_candidates = [
659
- home / "Library" / "Application Support" / "Code" / "User" / "settings.json",
660
- home / ".config" / "Code" / "User" / "settings.json",
661
- home / ".vscode" / "settings.json",
662
- ]
663
- vscode_settings = next((p for p in vscode_candidates if p.exists()), None)
664
- if vscode_settings:
723
+ vscode_ext_dir = home / ".vscode" / "extensions"
724
+ copilot_installed = vscode_ext_dir.exists() and any(
725
+ p.name.startswith("github.copilot") for p in vscode_ext_dir.iterdir() if p.is_dir()
726
+ )
727
+ if copilot_installed:
728
+ vscode_candidates = [
729
+ home / "Library" / "Application Support" / "Code" / "User" / "settings.json",
730
+ home / ".config" / "Code" / "User" / "settings.json",
731
+ home / ".vscode" / "settings.json",
732
+ ]
733
+ vscode_settings = next((p for p in vscode_candidates if p.exists()), None)
665
734
  try:
666
- d = json.loads(vscode_settings.read_text())
735
+ d = json.loads(vscode_settings.read_text()) if vscode_settings else {}
667
736
  mcp_reg = "conduct" in d.get("mcp", {}).get("servers", {})
668
737
  except Exception:
669
738
  mcp_reg = False
@@ -718,7 +787,11 @@ def cmd_guard_sync(args):
718
787
  api_key = cfg.get("api_key", "")
719
788
  base_url = _api_url(cfg)
720
789
 
721
- print(f"Syncing policy…")
790
+ # Persona selection — prompt once, skip if already chosen
791
+ _ensure_persona(workspace_id, api_key, base_url)
792
+
793
+ dry_run = getattr(args, "dry_run", False)
794
+ print(f"{'[dry-run] ' if dry_run else ''}Syncing policy…")
722
795
 
723
796
  try:
724
797
  policy = _req(
@@ -729,28 +802,49 @@ def cmd_guard_sync(args):
729
802
  except Exception as e:
730
803
  print(f"Guard sync skipped: {e}", file=sys.stderr)
731
804
  sys.exit(0)
805
+
806
+ rules = policy.get("rules", [])
807
+ rule_count = len(rules)
808
+
809
+ if dry_run:
810
+ current_policy = _load_policy()
811
+ current_rules = {r["rule_id"]: r for r in current_policy.get("rules", [])}
812
+ new_rules = {r["rule_id"]: r for r in rules}
813
+
814
+ added = [r for r in new_rules if r not in current_rules]
815
+ removed = [r for r in current_rules if r not in new_rules]
816
+ changed = [
817
+ r for r in new_rules
818
+ if r in current_rules and new_rules[r].get("action") != current_rules[r].get("action")
819
+ ]
820
+
821
+ print(f"\n {BOLD}Dry run — no files written{RESET}")
822
+ print(f" Rules in policy: {rule_count}")
823
+ if added:
824
+ print(f"\n {GREEN}+ Added ({len(added)}){RESET}")
825
+ for rid in added:
826
+ r = new_rules[rid]
827
+ print(f" {rid} [{r.get('action','?').upper()}]")
828
+ if removed:
829
+ print(f"\n {RED}- Removed ({len(removed)}){RESET}")
830
+ for rid in removed:
831
+ print(f" {rid}")
832
+ if changed:
833
+ print(f"\n ~ Changed action ({len(changed)})")
834
+ for rid in changed:
835
+ old_a = current_rules[rid].get("action", "?")
836
+ new_a = new_rules[rid].get("action", "?")
837
+ print(f" {rid} {old_a} → {new_a}")
838
+ if not added and not removed and not changed:
839
+ print(f" {GREEN}No changes{RESET}")
840
+ return
841
+
732
842
  _save_policy(policy)
733
- rule_count = len(policy.get("rules", []))
734
843
  print(f" {GREEN}Policy refreshed:{RESET} {rule_count} rule(s)")
735
844
 
736
845
  if getattr(args, "cursor", False):
737
846
  _write_cursorrules(policy)
738
847
 
739
- # Re-check Security Loop install status
740
- try:
741
- sec = _req("GET", f"{base_url}/secure/installed?workspace_id={workspace_id}", api_key=api_key)
742
- cfg["security_emit_enabled"] = bool(sec.get("installed"))
743
- _save_guard_config(cfg)
744
- if sec.get("installed"):
745
- print(f" {GREEN}Security Loop:{RESET} installed — classifier active")
746
- try:
747
- policies = _req("GET", f"{base_url}/secure/policies?workspace_id={workspace_id}", api_key=api_key)
748
- policy_count = len(policies) if isinstance(policies, list) else 0
749
- print(f" {GREEN}Security Loop policies:{RESET} {policy_count} rule(s) synced")
750
- except Exception:
751
- pass
752
- except Exception:
753
- pass
754
848
 
755
849
  # Refresh hook script + re-register in all tools
756
850
  hook_path = GUARD_DIR / "hook.py"
@@ -1198,6 +1292,7 @@ def register_guard_parser(sub):
1198
1292
  # conduct guard sync
1199
1293
  sync_p = guard_sub.add_parser("sync", help="Refresh policy and re-scan for AI tools")
1200
1294
  sync_p.add_argument("--cursor", action="store_true", help="Write active Guard policies to .cursorrules")
1295
+ sync_p.add_argument("--dry-run", action="store_true", help="Preview policy changes without writing anything")
1201
1296
 
1202
1297
  # conduct guard status
1203
1298
  guard_sub.add_parser("status", help="Show today's spend and violations")
@@ -139,12 +139,31 @@ def _load_config() -> dict:
139
139
  return {}
140
140
 
141
141
 
142
- _sync_lock = threading.Lock()
142
+ _DAEMON_URL = "http://127.0.0.1:7878"
143
+ _sync_lock = threading.Lock()
143
144
  _SYNC_INTERVAL = 300 # 5 minutes
144
145
 
145
146
 
147
+ def _daemon_alive() -> bool:
148
+ try:
149
+ urllib.request.urlopen(f"{_DAEMON_URL}/health", timeout=0.3)
150
+ return True
151
+ except Exception:
152
+ return False
153
+
154
+
146
155
  def _maybe_sync() -> None:
147
- """If config is stale (>5 min since last sync), run `conduct guard sync` in background."""
156
+ """Sync policy: daemon (instant) conduct guard sync (every 5 min). Never raises."""
157
+ try:
158
+ if _daemon_alive():
159
+ cfg = _load_config()
160
+ ws_id = cfg.get("workspace_id", "")
161
+ if ws_id:
162
+ with urllib.request.urlopen(f"{_DAEMON_URL}/policy?workspace_id={ws_id}", timeout=1) as r:
163
+ POLICY_PATH.write_text(r.read().decode())
164
+ return
165
+ except Exception:
166
+ pass # fall through to CLI sync
148
167
  cfg = _load_config()
149
168
  last_sync = cfg.get("last_synced_at", 0)
150
169
  if time.time() - last_sync < _SYNC_INTERVAL:
@@ -153,11 +172,7 @@ def _maybe_sync() -> None:
153
172
  return # another thread is already syncing
154
173
  def _run():
155
174
  try:
156
- subprocess.run(
157
- ["conduct", "guard", "sync"],
158
- timeout=15,
159
- capture_output=True,
160
- )
175
+ subprocess.run(["conduct", "guard", "sync"], timeout=15, capture_output=True)
161
176
  except Exception:
162
177
  pass
163
178
  finally:
@@ -192,12 +207,11 @@ def _post_audit_event(
192
207
  token: str,
193
208
  ai_tool: str,
194
209
  ) -> None:
195
- """Fire-and-forget: post a guard audit event to the Conduct API."""
210
+ """Fire-and-forget: queue event via daemon (batch) or direct API fallback."""
196
211
  if not workspace_id:
197
212
  return
198
- cfg = _load_config()
199
- api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
200
- payload = json.dumps({
213
+ cfg = _load_config()
214
+ event = {
201
215
  "workspace_id": workspace_id,
202
216
  "clerk_user_id": cfg.get("user_email", ""),
203
217
  "user_email": cfg.get("user_email", ""),
@@ -207,15 +221,26 @@ def _post_audit_event(
207
221
  "decision": decision,
208
222
  "rule_id": rule_id,
209
223
  "hook_session_id": _SESSION_ID,
210
- }).encode()
224
+ }
211
225
  try:
212
- req = urllib.request.Request(
213
- f"{api_url}/guard/events",
214
- data=payload,
215
- headers={
216
- "Content-Type": "application/json",
217
- "Authorization": f"Bearer {token}",
218
- },
226
+ # Fast path: daemon batches events, reducing API calls 30:1
227
+ if _daemon_alive():
228
+ body = json.dumps(event).encode()
229
+ req = urllib.request.Request(
230
+ f"{_DAEMON_URL}/event", data=body,
231
+ headers={"Content-Type": "application/json"}, method="POST",
232
+ )
233
+ urllib.request.urlopen(req, timeout=1)
234
+ return
235
+ except Exception:
236
+ pass
237
+ # Fallback: direct API post
238
+ try:
239
+ api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
240
+ body = json.dumps(event).encode()
241
+ req = urllib.request.Request(
242
+ f"{api_url}/guard/events", data=body,
243
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"},
219
244
  method="POST",
220
245
  )
221
246
  urllib.request.urlopen(req, timeout=5)
@@ -39,8 +39,19 @@ VERSION_CACHE_TTL = 60 # 1 minute — matches server poll window
39
39
  WARNED_RULES_PATH = GUARD_DIR / "warned_rules.json"
40
40
 
41
41
 
42
+ _DAEMON_URL = "http://127.0.0.1:7878"
43
+
44
+
45
+ def _daemon_alive() -> bool:
46
+ try:
47
+ with urllib.request.urlopen(f"{_DAEMON_URL}/health", timeout=0.3):
48
+ return True
49
+ except Exception:
50
+ return False
51
+
52
+
42
53
  def _maybe_sync_policy():
43
- """Check server policy version once per minute; re-download if stale. Never raises."""
54
+ """Sync policy from daemon (instant) or remote API (once per minute). Never raises."""
44
55
  try:
45
56
  cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
46
57
  workspace_id = cfg.get("workspace_id")
@@ -48,18 +59,25 @@ def _maybe_sync_policy():
48
59
  api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
49
60
  if not workspace_id:
50
61
  return
51
- # Check cache TTL
62
+
63
+ # Fast path: daemon is running — it keeps policy fresh via WebSocket push
64
+ if _daemon_alive():
65
+ url = f"{_DAEMON_URL}/policy?workspace_id={workspace_id}"
66
+ with urllib.request.urlopen(url, timeout=1) as resp:
67
+ remote = json.loads(resp.read())
68
+ POLICY_PATH.write_text(json.dumps(remote, indent=2))
69
+ return
70
+
71
+ # Slow path: no daemon — poll remote API once per minute
52
72
  if VERSION_CACHE_PATH.exists():
53
73
  cache = json.loads(VERSION_CACHE_PATH.read_text())
54
74
  if time.time() - cache.get("ts", 0) < VERSION_CACHE_TTL:
55
75
  return
56
- # Fetch current version from server
57
76
  url = f"{api_url}/guard/policies/sync?workspace_id={workspace_id}"
58
77
  req = urllib.request.Request(url, headers={"Authorization": f"Bearer {api_key}"} if api_key else {})
59
78
  with urllib.request.urlopen(req, timeout=2) as resp:
60
79
  remote = json.loads(resp.read())
61
80
  remote_version = remote.get("version", "")
62
- # Compare to local
63
81
  local_version = ""
64
82
  if POLICY_PATH.exists():
65
83
  local_version = json.loads(POLICY_PATH.read_text()).get("version", "")
@@ -302,16 +302,21 @@ def _detect_ai_tools() -> list:
302
302
  "hook_registered": False, # Windsurf uses MCP only
303
303
  })
304
304
 
305
- # VS Code (Copilot)
306
- vscode_settings_candidates = [
307
- home / "Library" / "Application Support" / "Code" / "User" / "settings.json",
308
- home / ".config" / "Code" / "User" / "settings.json",
309
- home / ".vscode" / "settings.json",
310
- ]
311
- vscode_settings = next((p for p in vscode_settings_candidates if p.exists()), None)
312
- if vscode_settings:
305
+ # VS Code (Copilot) — only report if Copilot extension is actually installed
306
+ vscode_ext_dir = home / ".vscode" / "extensions"
307
+ copilot_installed = vscode_ext_dir.exists() and any(
308
+ p.name.startswith("github.copilot") for p in vscode_ext_dir.iterdir()
309
+ if p.is_dir()
310
+ )
311
+ if copilot_installed:
312
+ vscode_settings_candidates = [
313
+ home / "Library" / "Application Support" / "Code" / "User" / "settings.json",
314
+ home / ".config" / "Code" / "User" / "settings.json",
315
+ home / ".vscode" / "settings.json",
316
+ ]
317
+ vscode_settings = next((p for p in vscode_settings_candidates if p.exists()), None)
313
318
  try:
314
- d = json.loads(vscode_settings.read_text())
319
+ d = json.loads(vscode_settings.read_text()) if vscode_settings else {}
315
320
  mcp_reg = "conduct" in d.get("mcp", {}).get("servers", {})
316
321
  except Exception:
317
322
  mcp_reg = False
@@ -409,6 +414,12 @@ def cmd_mcp_install(args):
409
414
  if uncovered:
410
415
  print(f"{YELLOW} Not covered: {', '.join(uncovered)} — run: conduct mcp install{RESET}")
411
416
 
417
+ # Push updated coverage to Guard so the dashboard reflects the new state immediately
418
+ try:
419
+ _report_tool_coverage()
420
+ except Exception:
421
+ pass
422
+
412
423
 
413
424
  def cmd_login(args):
414
425
  server = args.server
@@ -2847,6 +2858,77 @@ def _check_guard_setup(command: str) -> None:
2847
2858
  )
2848
2859
 
2849
2860
 
2861
+
2862
+ def cmd_skill(args):
2863
+ """conduct skill list | install <slug> | uninstall <slug>"""
2864
+ skill_command = getattr(args, "skill_command", None)
2865
+ server, workspace, api_key, token = _require_auth(args)
2866
+
2867
+ headers = {"Content-Type": "application/json"}
2868
+ if api_key:
2869
+ headers["X-Api-Key"] = api_key
2870
+ elif token:
2871
+ headers["Authorization"] = f"Bearer {token}"
2872
+
2873
+ def _req(method, path, expect=None):
2874
+ url = f"{server}{path}"
2875
+ req = urllib.request.Request(url, headers=headers, method=method)
2876
+ try:
2877
+ with urllib.request.urlopen(req, timeout=10) as r:
2878
+ data = json.loads(r.read())
2879
+ return data
2880
+ except urllib.error.HTTPError as e:
2881
+ body = e.read().decode()
2882
+ try:
2883
+ msg = json.loads(body).get("detail", body)
2884
+ except Exception:
2885
+ msg = body
2886
+ print(f"{RED}Error {e.code}: {msg}{RESET}")
2887
+ sys.exit(1)
2888
+
2889
+ if skill_command == "list":
2890
+ data = _req("GET", f"/compliance/packs/available?workspace_id={workspace}")
2891
+ installed_data = _req("GET", f"/compliance/packs/installed?workspace_id={workspace}")
2892
+ installed = set(installed_data.get("installed", []))
2893
+
2894
+ packs = data.get("packs", [])
2895
+ if not packs:
2896
+ print("No skill packs available.")
2897
+ return
2898
+
2899
+ print(f"\n{'Slug':<20} {'Name':<22} {'Tier':<8} {'Rules':<7} Status")
2900
+ print("─" * 70)
2901
+ for p in packs:
2902
+ slug = p.get("slug", "")
2903
+ name = p.get("name", "")
2904
+ tier = p.get("tier", "")
2905
+ rules = len(p.get("rules", []))
2906
+ status = f"{GREEN}installed{RESET}" if slug in installed else f"{GRAY}available{RESET}"
2907
+ print(f"{slug:<20} {name:<22} {tier:<8} {rules:<7} {status}")
2908
+ print()
2909
+
2910
+ elif skill_command == "install":
2911
+ slug = getattr(args, "slug", None)
2912
+ if not slug:
2913
+ print(f"{RED}Usage: conduct skill install <slug>{RESET}")
2914
+ sys.exit(1)
2915
+ _req("POST", f"/compliance/packs/{slug}/install?workspace_id={workspace}")
2916
+ print(f"{GREEN}✓{RESET} Installed {BOLD}{slug}{RESET}")
2917
+ print(f" Run {BOLD}conduct guard sync{RESET} to push the updated policy to your hook.")
2918
+
2919
+ elif skill_command == "uninstall":
2920
+ slug = getattr(args, "slug", None)
2921
+ if not slug:
2922
+ print(f"{RED}Usage: conduct skill uninstall <slug>{RESET}")
2923
+ sys.exit(1)
2924
+ _req("DELETE", f"/compliance/packs/{slug}/uninstall?workspace_id={workspace}")
2925
+ print(f"{GREEN}✓{RESET} Uninstalled {BOLD}{slug}{RESET}")
2926
+ print(f" Run {BOLD}conduct guard sync{RESET} to push the updated policy to your hook.")
2927
+
2928
+ else:
2929
+ print("Usage: conduct skill <list|install|uninstall> [slug]")
2930
+
2931
+
2850
2932
  def main():
2851
2933
  _auto_update()
2852
2934
 
@@ -2987,6 +3069,15 @@ def main():
2987
3069
  mcp_sub = mcp_p.add_subparsers(dest="mcp_command")
2988
3070
  mcp_sub.add_parser("install", help="Register conduct-mcp in Claude Code and Codex")
2989
3071
 
3072
+ # conduct skill
3073
+ skill_p = sub.add_parser("skill", help="Manage Guard skill packs")
3074
+ skill_sub = skill_p.add_subparsers(dest="skill_command")
3075
+ skill_sub.add_parser("list", help="List available and installed skill packs")
3076
+ skill_install_p = skill_sub.add_parser("install", help="Install a skill pack")
3077
+ skill_install_p.add_argument("slug", help="Pack slug, e.g. conduct-owasp")
3078
+ skill_uninstall_p = skill_sub.add_parser("uninstall", help="Uninstall a skill pack")
3079
+ skill_uninstall_p.add_argument("slug", help="Pack slug, e.g. conduct-owasp")
3080
+
2990
3081
  # conduct sync
2991
3082
  sub.add_parser("sync", help="Sync Guard policies (and Security Loop policies if installed)")
2992
3083
 
@@ -3080,6 +3171,8 @@ def main():
3080
3171
  cmd_mcp_install(args)
3081
3172
  else:
3082
3173
  mcp_p.print_help()
3174
+ elif args.command == "skill":
3175
+ cmd_skill(args)
3083
3176
  elif args.command == "sync":
3084
3177
  cmd_sync(args)
3085
3178
  elif args.command == "test-guard":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.5.3
3
+ Version: 0.5.5
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