conduct-cli 0.4.97__tar.gz → 0.4.98__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.4.97 → conduct_cli-0.4.98}/PKG-INFO +1 -1
  2. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/pyproject.toml +1 -1
  3. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/src/conduct_cli/hook_template.py +11 -2
  4. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/src/conduct_cli/main.py +59 -130
  5. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/src/conduct_cli/memory.py +22 -0
  6. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  7. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/README.md +0 -0
  8. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/setup.cfg +0 -0
  9. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/setup.py +0 -0
  10. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/src/conduct_cli/__init__.py +0 -0
  11. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/src/conduct_cli/api.py +0 -0
  12. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/src/conduct_cli/guard.py +0 -0
  13. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/src/conduct_cli/guardmcp.py +0 -0
  14. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/src/conduct_cli/hook_precompact_template.py +0 -0
  15. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/src/conduct_cli/hook_session_start_template.py +0 -0
  16. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/src/conduct_cli/hook_stop_template.py +0 -0
  17. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/src/conduct_cli/mcp_server.py +0 -0
  18. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/src/conduct_cli/paxel.py +0 -0
  19. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/src/conduct_cli.egg-info/SOURCES.txt +0 -0
  20. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  21. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  22. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/src/conduct_cli.egg-info/requires.txt +0 -0
  23. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/src/conduct_cli.egg-info/top_level.txt +0 -0
  24. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/tests/test_guard_policy.py +0 -0
  25. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/tests/test_guard_savings.py +0 -0
  26. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/tests/test_hook_syntax.py +0 -0
  27. {conduct_cli-0.4.97 → conduct_cli-0.4.98}/tests/test_switch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.4.97
3
+ Version: 0.4.98
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.97"
7
+ version = "0.4.98"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -588,12 +588,21 @@ def main():
588
588
  if data.get("hook_event_name") == "Stop" or data.get("stop_hook_active"):
589
589
  session_id = data.get("session_id", "")
590
590
  transcript_path = data.get("transcript_path")
591
- # Detect repo from CWD git remote
592
591
  repo = _detect_repo()
593
- from conduct_cli.memory import post_session_to_api
592
+ from conduct_cli.memory import post_session_to_api, mark_flushed
594
593
  post_session_to_api(session_id, transcript_path, repo)
594
+ mark_flushed()
595
595
  sys.exit(0)
596
596
 
597
+ # Periodic flush — fire at most once every 8 hours mid-session
598
+ from conduct_cli.memory import should_periodic_flush, mark_flushed, post_session_to_api
599
+ if should_periodic_flush():
600
+ session_id = data.get("session_id", "")
601
+ transcript_path = data.get("transcript_path")
602
+ repo = _detect_repo()
603
+ post_session_to_api(session_id, transcript_path, repo)
604
+ mark_flushed()
605
+
597
606
  # Policy version check (cached 60s) — auto-syncs if server version differs
598
607
  _maybe_sync_policy()
599
608
 
@@ -198,92 +198,36 @@ def _poll_run(server: str, workflow_id: str, run_id: str, hdrs: dict) -> bool:
198
198
 
199
199
  # ── Commands ──────────────────────────────────────────────────────────────────
200
200
 
201
- def _write_claude_mcp_settings() -> bool:
202
- """Write conduct-mcp into ~/.claude/settings.json. Returns True if written."""
203
- settings_path = Path.home() / ".claude" / "settings.json"
204
- try:
205
- existing = json.loads(settings_path.read_text()) if settings_path.exists() else {}
206
- servers = existing.setdefault("mcpServers", {})
207
- if "conduct" in servers:
208
- return True # already registered
209
- servers["conduct"] = {"command": "conduct-mcp", "args": []}
210
- settings_path.parent.mkdir(parents=True, exist_ok=True)
211
- settings_path.write_text(json.dumps(existing, indent=2))
212
- return True
213
- except Exception:
214
- return False
215
-
216
-
217
- def _write_codex_mcp_config() -> bool:
218
- """Write conduct-mcp into ~/.codex/config.toml. Returns True if written."""
219
- codex_dir = Path.home() / ".codex"
220
- if not codex_dir.exists():
221
- return False
222
- config_path = codex_dir / "config.toml"
223
- try:
224
- content = config_path.read_text() if config_path.exists() else ""
225
- if "conduct-mcp" in content:
226
- return True
227
- mcp_block = '\n[mcp_servers.conduct]\ncommand = "conduct-mcp"\nargs = []\n'
228
- config_path.write_text(content + mcp_block)
229
- return True
230
- except Exception:
231
- return False
232
-
233
-
234
- def _write_cursor_mcp_config() -> bool:
235
- """Write conduct-mcp into ~/.cursor/mcp.json. Returns True if written."""
236
- cursor_dir = Path.home() / ".cursor"
237
- if not cursor_dir.exists():
201
+ def _write_mcp_config(path: Path, *, keys: tuple = ("mcpServers",), create: bool = False) -> bool:
202
+ """Write conduct-mcp entry into a JSON config file. Returns True if written or already present."""
203
+ if not create and not path.parent.exists():
238
204
  return False
239
- config_path = cursor_dir / "mcp.json"
240
205
  try:
241
- existing = json.loads(config_path.read_text()) if config_path.exists() else {}
242
- servers = existing.setdefault("mcpServers", {})
206
+ existing = json.loads(path.read_text()) if path.exists() else {}
207
+ node = existing
208
+ for k in keys[:-1]:
209
+ node = node.setdefault(k, {})
210
+ servers = node.setdefault(keys[-1], {})
243
211
  if "conduct" in servers:
244
212
  return True
245
213
  servers["conduct"] = {"command": "conduct-mcp", "args": []}
246
- config_path.write_text(json.dumps(existing, indent=2))
214
+ path.parent.mkdir(parents=True, exist_ok=True)
215
+ path.write_text(json.dumps(existing, indent=2))
247
216
  return True
248
217
  except Exception:
249
218
  return False
250
219
 
251
220
 
252
- def _write_windsurf_mcp_config() -> bool:
253
- """Write conduct-mcp into ~/.codeium/windsurf/mcp_config.json. Returns True if written."""
254
- config_path = Path.home() / ".codeium" / "windsurf" / "mcp_config.json"
221
+ def _write_codex_mcp_config() -> bool:
222
+ """Write conduct-mcp into ~/.codex/config.toml (TOML different format)."""
223
+ config_path = Path.home() / ".codex" / "config.toml"
255
224
  if not config_path.parent.exists():
256
225
  return False
257
226
  try:
258
- existing = json.loads(config_path.read_text()) if config_path.exists() else {}
259
- servers = existing.setdefault("mcpServers", {})
260
- if "conduct" in servers:
261
- return True
262
- servers["conduct"] = {"command": "conduct-mcp", "args": []}
263
- config_path.write_text(json.dumps(existing, indent=2))
264
- return True
265
- except Exception:
266
- return False
267
-
268
-
269
- def _write_vscode_mcp_config() -> bool:
270
- """Write conduct-mcp into VS Code settings.json (mcp.servers). Returns True if written."""
271
- # Check both standard locations
272
- candidates = [
273
- Path.home() / ".vscode" / "settings.json",
274
- Path.home() / "Library" / "Application Support" / "Code" / "User" / "settings.json",
275
- Path.home() / ".config" / "Code" / "User" / "settings.json",
276
- ]
277
- settings_path = next((p for p in candidates if p.exists()), None)
278
- if not settings_path:
279
- return False
280
- try:
281
- existing = json.loads(settings_path.read_text()) if settings_path.exists() else {}
282
- servers = existing.setdefault("mcp", {}).setdefault("servers", {})
283
- if "conduct" in servers:
227
+ content = config_path.read_text() if config_path.exists() else ""
228
+ if "conduct-mcp" in content:
284
229
  return True
285
- servers["conduct"] = {"command": "conduct-mcp", "args": []}
286
- settings_path.write_text(json.dumps(existing, indent=2))
230
+ config_path.write_text(content + '\n[mcp_servers.conduct]\ncommand = "conduct-mcp"\nargs = []\n')
287
231
  return True
288
232
  except Exception:
289
233
  return False
@@ -427,30 +371,26 @@ def cmd_mcp_install(args):
427
371
  import shutil
428
372
  import subprocess
429
373
 
430
- registered = []
431
-
432
- # --- Claude Code ---
433
- # Write directly to ~/.claude/settings.json — `claude mcp add` without --global
434
- # writes to the project-level .claude/settings.json which _detect_ai_tools won't find.
435
- if _write_claude_mcp_settings():
436
- registered.append("Claude Code")
374
+ home = Path.home()
375
+ # claude mcp add --global writes to project-level settings; write directly instead.
376
+ _MCP_TARGETS = [
377
+ ("Claude Code", home / ".claude" / "settings.json", ("mcpServers",), True),
378
+ ("Cursor", home / ".cursor" / "mcp.json", ("mcpServers",), False),
379
+ ("Windsurf", home / ".codeium" / "windsurf" / "mcp_config.json", ("mcpServers",), False),
380
+ ("VS Code (Copilot)",next((p for p in [
381
+ home / ".vscode" / "settings.json",
382
+ home / "Library" / "Application Support" / "Code" / "User" / "settings.json",
383
+ home / ".config" / "Code" / "User" / "settings.json",
384
+ ] if p.exists()), None), ("mcp", "servers"), False),
385
+ ]
437
386
 
438
- # --- Codex CLI ---
387
+ registered = []
388
+ for label, path, keys, create in _MCP_TARGETS:
389
+ if path and _write_mcp_config(path, keys=keys, create=create):
390
+ registered.append(label)
439
391
  if _write_codex_mcp_config():
440
392
  registered.append("Codex")
441
393
 
442
- # --- Cursor ---
443
- if _write_cursor_mcp_config():
444
- registered.append("Cursor")
445
-
446
- # --- Windsurf ---
447
- if _write_windsurf_mcp_config():
448
- registered.append("Windsurf")
449
-
450
- # --- VS Code (Copilot) ---
451
- if _write_vscode_mcp_config():
452
- registered.append("VS Code (Copilot)")
453
-
454
394
  if registered:
455
395
  print(f"{GREEN}✓ conduct-mcp registered in: {', '.join(registered)}{RESET}")
456
396
  print(f"{GRAY} Restart your AI tools to pick up the new MCP server.{RESET}")
@@ -676,47 +616,38 @@ def cmd_test(args):
676
616
  })
677
617
  return payload
678
618
 
679
- if parallel:
680
- _run_tests_parallel(server, workspace_id, api_key, token, hdrs, targets, _build_payload)
681
- else:
682
- _run_tests_serial(server, workspace_id, api_key, token, hdrs, targets, _build_payload)
619
+ _run_tests(server, workspace_id, api_key, token, hdrs, targets, _build_payload, parallel=parallel)
683
620
 
684
621
 
685
- def _run_tests_serial(server, workspace_id, api_key, token, hdrs, targets, build_payload):
686
- results = []
687
- for wf in targets:
688
- name = wf["name"]
689
- wf_id = str(wf["id"])
690
- slug = wf.get("playbook_slug", "")
622
+ def _run_tests(server, workspace_id, api_key, token, hdrs, targets, build_payload, *, parallel: bool = False):
623
+ import threading
691
624
 
692
- print(f"{CYAN}── {name}{RESET} {GRAY}({slug}){RESET}")
693
- try:
694
- run = api.req("POST", f"{server}/workflows/{wf_id}/trigger", hdrs, build_payload(slug))
695
- except SystemExit:
696
- results.append((name, False, None))
625
+ if not parallel:
626
+ results = []
627
+ for wf in targets:
628
+ name = wf["name"]
629
+ wf_id = str(wf["id"])
630
+ slug = wf.get("playbook_slug", "")
631
+ print(f"{CYAN}── {name}{RESET} {GRAY}({slug}){RESET}")
632
+ try:
633
+ run = api.req("POST", f"{server}/workflows/{wf_id}/trigger", hdrs, build_payload(slug))
634
+ except SystemExit:
635
+ results.append((name, False, None))
636
+ print()
637
+ continue
638
+ run_id = run.get("run_id")
639
+ print(f" {GRAY}run: {run_id}{RESET}")
640
+ try:
641
+ ok = _stream_run(server, wf_id, run_id, workspace_id, token, api_key)
642
+ except Exception:
643
+ ok = _poll_run(server, wf_id, run_id, hdrs)
644
+ results.append((name, ok, run_id))
697
645
  print()
698
- continue
699
-
700
- run_id = run.get("run_id")
701
- print(f" {GRAY}run: {run_id}{RESET}")
702
-
703
- try:
704
- ok = _stream_run(server, wf_id, run_id, workspace_id, token, api_key)
705
- except Exception:
706
- ok = _poll_run(server, wf_id, run_id, hdrs)
707
-
708
- results.append((name, ok, run_id))
709
- print()
710
-
711
- _print_results(results)
712
-
713
-
714
- def _run_tests_parallel(server, workspace_id, api_key, token, hdrs, targets, build_payload):
715
- """Fire all triggers immediately, then poll all runs concurrently."""
716
- import threading
646
+ _print_results(results)
647
+ return
717
648
 
718
- # Phase 1: fire all triggers at once
719
- pending = [] # list of (name, run_id) or (name, None) on trigger failure
649
+ # parallel: fire all triggers, then poll concurrently
650
+ pending = []
720
651
  for wf in targets:
721
652
  name = wf["name"]
722
653
  wf_id = str(wf["id"])
@@ -731,7 +662,6 @@ def _run_tests_parallel(server, workspace_id, api_key, token, hdrs, targets, bui
731
662
  pending.append((name, wf_id, None))
732
663
 
733
664
  print(f"\n Polling {len(pending)} runs concurrently…\n")
734
-
735
665
  results_lock = threading.Lock()
736
666
  results: list = [None] * len(pending)
737
667
 
@@ -754,7 +684,6 @@ def _run_tests_parallel(server, workspace_id, api_key, token, hdrs, targets, bui
754
684
  t.start()
755
685
  for t in threads:
756
686
  t.join()
757
-
758
687
  _print_results(results)
759
688
 
760
689
 
@@ -3,9 +3,31 @@ from __future__ import annotations
3
3
 
4
4
  import json
5
5
  import threading
6
+ import time
6
7
  import urllib.request
7
8
  from pathlib import Path
8
9
 
10
+ _FLUSH_INTERVAL = 8 * 3600 # 8 hours in seconds
11
+ _FLUSH_STAMP = Path.home() / ".conduct" / "last_memory_flush"
12
+
13
+
14
+ def should_periodic_flush() -> bool:
15
+ """True if 8+ hours have passed since the last periodic memory flush."""
16
+ try:
17
+ if not _FLUSH_STAMP.exists():
18
+ return True
19
+ return time.time() - float(_FLUSH_STAMP.read_text().strip()) >= _FLUSH_INTERVAL
20
+ except Exception:
21
+ return True
22
+
23
+
24
+ def mark_flushed() -> None:
25
+ try:
26
+ _FLUSH_STAMP.parent.mkdir(parents=True, exist_ok=True)
27
+ _FLUSH_STAMP.write_text(str(time.time()))
28
+ except Exception:
29
+ pass
30
+
9
31
 
10
32
  def _load_config():
11
33
  cfg_path = Path.home() / ".conduct" / "config.json"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.4.97
3
+ Version: 0.4.98
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