conduct-cli 0.5.8__tar.gz → 0.5.9__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 (30) hide show
  1. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/PKG-INFO +1 -1
  2. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/pyproject.toml +1 -1
  3. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/src/conduct_cli/guardmcp.py +4 -0
  4. conduct_cli-0.5.9/src/conduct_cli/log_util.py +180 -0
  5. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/src/conduct_cli/main.py +4 -0
  6. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/src/conduct_cli/mcp_server.py +4 -0
  7. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  8. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/src/conduct_cli.egg-info/SOURCES.txt +2 -0
  9. conduct_cli-0.5.9/tests/test_log_util.py +123 -0
  10. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/README.md +0 -0
  11. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/setup.cfg +0 -0
  12. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/setup.py +0 -0
  13. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/src/conduct_cli/__init__.py +0 -0
  14. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/src/conduct_cli/api.py +0 -0
  15. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/src/conduct_cli/guard.py +0 -0
  16. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/src/conduct_cli/hook_precompact_template.py +0 -0
  17. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/src/conduct_cli/hook_session_start_template.py +0 -0
  18. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/src/conduct_cli/hook_stop_template.py +0 -0
  19. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/src/conduct_cli/hook_template.py +0 -0
  20. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/src/conduct_cli/memory.py +0 -0
  21. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/src/conduct_cli/paxel.py +0 -0
  22. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  23. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  24. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/src/conduct_cli.egg-info/requires.txt +0 -0
  25. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/src/conduct_cli.egg-info/top_level.txt +0 -0
  26. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/tests/test_bash_operator_signature.py +0 -0
  27. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/tests/test_guard_policy.py +0 -0
  28. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/tests/test_guard_savings.py +0 -0
  29. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/tests/test_hook_syntax.py +0 -0
  30. {conduct_cli-0.5.8 → conduct_cli-0.5.9}/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.8
3
+ Version: 0.5.9
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.8"
7
+ version = "0.5.9"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -467,5 +467,9 @@ def main() -> None:
467
467
  _err(msg_id, -32601, f"Method not found: {method}")
468
468
 
469
469
 
470
+ # ponytail: telemetry error interceptor (#718)
471
+ from .log_util import install_error_interceptor as _ei
472
+ main = _ei(main)
473
+
470
474
  if __name__ == "__main__":
471
475
  main()
@@ -0,0 +1,180 @@
1
+ """Shared log helper + error interceptor for conduct-cli (#718 Phase 1).
2
+
3
+ Stdlib only. No new dependencies. Failures here MUST NEVER block the user —
4
+ telemetry is best-effort. The POST runs in a daemon thread with a join
5
+ timeout so even a hung endpoint can't delay CLI exit by more than ~2s.
6
+
7
+ Public API:
8
+ log(level, message, **context) # print + (warn/err) post
9
+ install_error_interceptor(main_fn) # wrap top-level entry point
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+ import threading
17
+ import traceback
18
+ import urllib.request
19
+ from datetime import datetime, timezone
20
+ from functools import lru_cache
21
+ from pathlib import Path
22
+ from typing import Callable
23
+
24
+ PREFIX = "[conduct]"
25
+ TOOL = "conduct-cli"
26
+
27
+ _COLORS = {
28
+ "info": "\033[37m",
29
+ "ok": "\033[32m",
30
+ "warning": "\033[33m",
31
+ "error": "\033[31m",
32
+ }
33
+ _RESET = "\033[0m"
34
+
35
+ _API_BASE = os.environ.get("CONDUCT_API_URL", "https://api.conductai.ai").rstrip("/")
36
+ _CONFIG = Path.home() / ".conductguard" / "config.json"
37
+ _LOG_FILE = Path(
38
+ os.environ.get("CONDUCT_LOG_FILE")
39
+ or str(Path.home() / ".conductguard" / "logs" / "events.jsonl")
40
+ )
41
+ _POST_TIMEOUT_S = 2.0
42
+
43
+ _MAX_BYTES = 10 * 1024 * 1024 # 10MB
44
+ _BACKUPS = 3 # keep events.jsonl.1, .2, .3
45
+
46
+
47
+ def _rotate() -> None:
48
+ """events.jsonl → .1 → .2 → .3; oldest dropped. Silent on failure."""
49
+ base = str(_LOG_FILE)
50
+ try:
51
+ oldest = Path(f"{base}.{_BACKUPS}")
52
+ if oldest.exists():
53
+ oldest.unlink()
54
+ for i in range(_BACKUPS - 1, 0, -1):
55
+ src = Path(f"{base}.{i}")
56
+ if src.exists():
57
+ src.replace(Path(f"{base}.{i + 1}"))
58
+ if _LOG_FILE.exists():
59
+ _LOG_FILE.replace(Path(f"{base}.1"))
60
+ except Exception:
61
+ pass
62
+
63
+
64
+ def _write_local(event_type: str, message: str, tb: str | None, ctx: dict) -> None:
65
+ """Append one JSONL event to the local log file. Foreground, fast, silent on failure."""
66
+ try:
67
+ _LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
68
+ if _LOG_FILE.exists() and _LOG_FILE.stat().st_size >= _MAX_BYTES:
69
+ _rotate()
70
+ line = json.dumps({
71
+ "ts": datetime.now(timezone.utc).isoformat(),
72
+ "tool": TOOL,
73
+ "version": _version(),
74
+ "event_type": event_type,
75
+ "message": message,
76
+ "traceback": tb,
77
+ "run_id": os.environ.get("CONDUCT_RUN_ID"),
78
+ "session_id": os.environ.get("CONDUCT_SESSION_ID"),
79
+ "context": ctx or {},
80
+ })
81
+ with _LOG_FILE.open("a", encoding="utf-8") as f:
82
+ f.write(line + "\n")
83
+ except Exception:
84
+ pass # disk failure must never block the user
85
+
86
+
87
+ @lru_cache(maxsize=1)
88
+ def _read_creds() -> tuple[str | None, str | None]:
89
+ """(member_token, workspace_id) from ~/.conductguard/config.json, or (None, None)."""
90
+ try:
91
+ cfg = json.loads(_CONFIG.read_text())
92
+ return cfg.get("member_token"), cfg.get("workspace_id")
93
+ except Exception:
94
+ return None, None
95
+
96
+
97
+ @lru_cache(maxsize=1)
98
+ def _version() -> str:
99
+ try:
100
+ from importlib.metadata import version
101
+ return version("conduct-cli")
102
+ except Exception:
103
+ return "unknown"
104
+
105
+
106
+ def _post_event_sync(event_type: str, message: str, tb: str | None, ctx: dict) -> None:
107
+ token, workspace_id = _read_creds()
108
+ if not token:
109
+ return # no creds = no telemetry, silent
110
+ body = json.dumps({
111
+ "tool": TOOL,
112
+ "version": _version(),
113
+ "event_type": event_type,
114
+ "message": message[:4000],
115
+ "traceback": tb,
116
+ "run_id": os.environ.get("CONDUCT_RUN_ID"),
117
+ "session_id": os.environ.get("CONDUCT_SESSION_ID"),
118
+ "context": ctx or {},
119
+ }).encode("utf-8")
120
+ headers = {
121
+ "Authorization": f"Bearer {token}",
122
+ "Content-Type": "application/json",
123
+ }
124
+ if workspace_id:
125
+ headers["X-Workspace-Id"] = workspace_id
126
+ try:
127
+ req = urllib.request.Request(
128
+ f"{_API_BASE}/telemetry/events",
129
+ data=body,
130
+ headers=headers,
131
+ method="POST",
132
+ )
133
+ urllib.request.urlopen(req, timeout=_POST_TIMEOUT_S)
134
+ except Exception:
135
+ pass # ponytail: silent — telemetry must never block the user
136
+
137
+
138
+ def _post_event(event_type: str, message: str, tb: str | None, ctx: dict, *, wait: bool = False) -> None:
139
+ """Fire-and-forget by default; wait=True for the error interceptor so the
140
+ POST has a chance to land before sys.exit."""
141
+ t = threading.Thread(
142
+ target=_post_event_sync,
143
+ args=(event_type, message, tb, ctx),
144
+ daemon=True,
145
+ name="conduct-telemetry",
146
+ )
147
+ t.start()
148
+ if wait:
149
+ t.join(timeout=_POST_TIMEOUT_S)
150
+
151
+
152
+ def log(level: str, msg: str, **ctx) -> None:
153
+ """Print to stderr with a colored prefix; warn/error also persist locally
154
+ AND post to telemetry. Same event, three destinations, written once each."""
155
+ color = _COLORS.get(level, "")
156
+ sys.stderr.write(f"{color}{PREFIX} {msg}{_RESET}\n")
157
+ if level in ("warning", "error"):
158
+ _write_local(level, msg, None, ctx) # foreground, always
159
+ _post_event(level, msg, None, ctx) # background, best-effort
160
+
161
+
162
+ def install_error_interceptor(main_fn: Callable) -> Callable:
163
+ """Wrap a CLI entry point. Unhandled exception → telemetry POST → stderr → exit 1.
164
+
165
+ SystemExit / KeyboardInterrupt pass through unchanged.
166
+ """
167
+ def wrapped(*args, **kwargs):
168
+ try:
169
+ return main_fn(*args, **kwargs)
170
+ except (SystemExit, KeyboardInterrupt):
171
+ raise
172
+ except Exception as e:
173
+ tb = traceback.format_exc()
174
+ msg = f"{type(e).__name__}: {e}"
175
+ _write_local("error", msg, tb, {}) # foreground, ensures file row before exit
176
+ _post_event("error", msg, tb, {}, wait=True) # background, best-effort
177
+ sys.stderr.write(f"{_COLORS['error']}{PREFIX} {msg}{_RESET}\n{tb}\n")
178
+ sys.exit(1)
179
+ wrapped.__wrapped__ = main_fn
180
+ return wrapped
@@ -3205,5 +3205,9 @@ def main():
3205
3205
  parser.print_help()
3206
3206
 
3207
3207
 
3208
+ # ponytail: telemetry error interceptor (#718)
3209
+ from .log_util import install_error_interceptor as _ei
3210
+ main = _ei(main)
3211
+
3208
3212
  if __name__ == "__main__":
3209
3213
  main()
@@ -330,5 +330,9 @@ def main() -> None:
330
330
  _err(msg_id, -32601, f"Method not found: {method}")
331
331
 
332
332
 
333
+ # ponytail: telemetry error interceptor (#718)
334
+ from .log_util import install_error_interceptor as _ei
335
+ main = _ei(main)
336
+
333
337
  if __name__ == "__main__":
334
338
  main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.5.8
3
+ Version: 0.5.9
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
@@ -9,6 +9,7 @@ src/conduct_cli/hook_precompact_template.py
9
9
  src/conduct_cli/hook_session_start_template.py
10
10
  src/conduct_cli/hook_stop_template.py
11
11
  src/conduct_cli/hook_template.py
12
+ src/conduct_cli/log_util.py
12
13
  src/conduct_cli/main.py
13
14
  src/conduct_cli/mcp_server.py
14
15
  src/conduct_cli/memory.py
@@ -23,4 +24,5 @@ tests/test_bash_operator_signature.py
23
24
  tests/test_guard_policy.py
24
25
  tests/test_guard_savings.py
25
26
  tests/test_hook_syntax.py
27
+ tests/test_log_util.py
26
28
  tests/test_switch.py
@@ -0,0 +1,123 @@
1
+ """Tests for conduct_cli.log_util — error interceptor + post path (#718)."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+ from conduct_cli import log_util
7
+
8
+
9
+ def test_interceptor_catches_exception_and_posts(monkeypatch):
10
+ captured = {}
11
+
12
+ def _fake_post(event_type, message, tb, ctx, *, wait=False):
13
+ captured["event_type"] = event_type
14
+ captured["message"] = message
15
+ captured["tb"] = tb
16
+ captured["wait"] = wait
17
+
18
+ monkeypatch.setattr(log_util, "_post_event", _fake_post)
19
+
20
+ def _boom():
21
+ raise ValueError("kaboom")
22
+
23
+ wrapped = log_util.install_error_interceptor(_boom)
24
+ with pytest.raises(SystemExit) as ex:
25
+ wrapped()
26
+ assert ex.value.code == 1
27
+ assert captured["event_type"] == "error"
28
+ assert "ValueError" in captured["message"]
29
+ assert "kaboom" in captured["message"]
30
+ assert captured["tb"] is not None
31
+ assert captured["wait"] is True
32
+
33
+
34
+ def test_interceptor_passes_through_systemexit(monkeypatch):
35
+ monkeypatch.setattr(log_util, "_post_event", lambda *a, **k: None)
36
+
37
+ def _exit_clean():
38
+ raise SystemExit(0)
39
+
40
+ wrapped = log_util.install_error_interceptor(_exit_clean)
41
+ with pytest.raises(SystemExit) as ex:
42
+ wrapped()
43
+ assert ex.value.code == 0
44
+
45
+
46
+ def test_post_skipped_when_no_creds(monkeypatch, tmp_path):
47
+ """No ~/.conductguard/config.json → no POST attempted."""
48
+ monkeypatch.setattr(log_util, "_CONFIG", tmp_path / "missing.json")
49
+ log_util._read_creds.cache_clear()
50
+
51
+ called = {"urlopen": 0}
52
+ def _fake_urlopen(*a, **k):
53
+ called["urlopen"] += 1
54
+ monkeypatch.setattr(log_util.urllib.request, "urlopen", _fake_urlopen)
55
+
56
+ log_util._post_event_sync("error", "boom", None, {})
57
+ assert called["urlopen"] == 0
58
+
59
+
60
+ def test_local_file_gets_jsonl_line(monkeypatch, tmp_path):
61
+ """log() writes a JSONL line for warn/error to the local file."""
62
+ import json
63
+ log_file = tmp_path / "events.jsonl"
64
+ monkeypatch.setattr(log_util, "_LOG_FILE", log_file)
65
+ monkeypatch.setattr(log_util, "_post_event", lambda *a, **k: None)
66
+
67
+ log_util.log("error", "boom-on-disk", run="r1")
68
+
69
+ assert log_file.exists()
70
+ lines = log_file.read_text().strip().splitlines()
71
+ assert len(lines) == 1
72
+ row = json.loads(lines[0])
73
+ assert row["event_type"] == "error"
74
+ assert row["message"] == "boom-on-disk"
75
+ assert row["tool"] == log_util.TOOL
76
+ assert row["context"] == {"run": "r1"}
77
+
78
+
79
+ def test_local_file_rotates_when_oversize(monkeypatch, tmp_path):
80
+ """When the file passes _MAX_BYTES, current file rotates to .1 and a fresh file starts."""
81
+ from pathlib import Path
82
+ log_file = tmp_path / "events.jsonl"
83
+ monkeypatch.setattr(log_util, "_LOG_FILE", log_file)
84
+ monkeypatch.setattr(log_util, "_MAX_BYTES", 200) # tiny cap so the test rotates quickly
85
+ monkeypatch.setattr(log_util, "_post_event", lambda *a, **k: None)
86
+
87
+ # First write — creates the file
88
+ log_util.log("error", "first")
89
+ assert log_file.exists()
90
+
91
+ # Fill the file past the cap so the next write triggers rotation
92
+ log_file.write_text("x" * 300)
93
+
94
+ log_util.log("error", "after-rotation")
95
+
96
+ rotated = Path(str(log_file) + ".1")
97
+ assert rotated.exists(), "rotation should produce events.jsonl.1"
98
+ assert log_file.exists() and log_file.stat().st_size > 0
99
+ # Newest event lives in the current file, not the rotated one
100
+ assert "after-rotation" in log_file.read_text()
101
+
102
+
103
+ def test_local_file_persists_traceback_on_interceptor(monkeypatch, tmp_path):
104
+ """Unhandled exception → traceback lands in the local file."""
105
+ import json
106
+ log_file = tmp_path / "events.jsonl"
107
+ monkeypatch.setattr(log_util, "_LOG_FILE", log_file)
108
+ monkeypatch.setattr(log_util, "_post_event", lambda *a, **k: None)
109
+
110
+ def _boom():
111
+ raise RuntimeError("disk-trace")
112
+
113
+ wrapped = log_util.install_error_interceptor(_boom)
114
+ with pytest.raises(SystemExit):
115
+ wrapped()
116
+
117
+ rows = [json.loads(l) for l in log_file.read_text().splitlines()]
118
+ assert any(
119
+ r["event_type"] == "error"
120
+ and "RuntimeError" in r["message"]
121
+ and r["traceback"] and "disk-trace" in r["traceback"]
122
+ for r in rows
123
+ )
File without changes
File without changes
File without changes