conduct-cli 0.7.45__tar.gz → 0.7.48__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 (48) hide show
  1. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/PKG-INFO +1 -1
  2. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/pyproject.toml +1 -1
  3. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli/guard.py +47 -24
  4. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli.egg-info/PKG-INFO +1 -1
  5. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli.egg-info/SOURCES.txt +1 -0
  6. conduct_cli-0.7.48/tests/test_instruction_files.py +263 -0
  7. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/README.md +0 -0
  8. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/setup.cfg +0 -0
  9. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/setup.py +0 -0
  10. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli/__init__.py +0 -0
  11. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli/api.py +0 -0
  12. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli/guardmcp.py +0 -0
  13. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli/hooks/__init__.py +0 -0
  14. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli/hooks/base.py +0 -0
  15. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli/hooks/posttooluse.py +0 -0
  16. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli/hooks/precompact.py +0 -0
  17. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli/hooks/pretooluse.py +0 -0
  18. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli/hooks/session_parser.py +0 -0
  19. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli/hooks/session_report_push.py +0 -0
  20. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli/hooks/session_start.py +0 -0
  21. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli/hooks/stop.py +0 -0
  22. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli/log_util.py +0 -0
  23. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli/main.py +0 -0
  24. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli/mcp_server.py +0 -0
  25. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli/memory.py +0 -0
  26. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli/paxel.py +0 -0
  27. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli/tool_groups.py +0 -0
  28. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
  29. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli.egg-info/entry_points.txt +0 -0
  30. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli.egg-info/requires.txt +0 -0
  31. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/src/conduct_cli.egg-info/top_level.txt +0 -0
  32. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/tests/test_advisory_and_hook.py +0 -0
  33. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/tests/test_api_client.py +0 -0
  34. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/tests/test_bash_operator_signature.py +0 -0
  35. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/tests/test_blast_radius.py +0 -0
  36. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/tests/test_cmd_run.py +0 -0
  37. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/tests/test_command_word_matcher.py +0 -0
  38. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/tests/test_drain_daemon_health.py +0 -0
  39. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/tests/test_guard_policy.py +0 -0
  40. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/tests/test_guard_savings.py +0 -0
  41. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/tests/test_hook_dispatch.py +0 -0
  42. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/tests/test_journal_drain.py +0 -0
  43. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/tests/test_log_util.py +0 -0
  44. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/tests/test_proxy_coverage.py +0 -0
  45. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/tests/test_proxy_env.py +0 -0
  46. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/tests/test_signed_policy.py +0 -0
  47. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/tests/test_switch.py +0 -0
  48. {conduct_cli-0.7.45 → conduct_cli-0.7.48}/tests/test_token_lifecycle.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.7.45
3
+ Version: 0.7.48
4
4
  Summary: CLI for Conduct AI — secure, govern, 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.7.45"
7
+ version = "0.7.48"
8
8
  description = "CLI for Conduct AI — secure, govern, install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -474,31 +474,51 @@ def _patch_claude_desktop_proxy(api_url: str, agent_token: str) -> None:
474
474
 
475
475
 
476
476
  def _patch_copilot_mcp(agent_token: str, api_url: str) -> None:
477
- """Keep ~/.copilot/mcp-config.json in sync with current agent token.
478
-
479
- Copilot's mcp-config.json uses SSE + Bearer — it can't run a local stdio binary
480
- the way Claude Code / Cursor can, so we write the SSE entry with a fresh token.
481
- """
482
- cfg_path = Path.home() / ".copilot" / "mcp-config.json"
483
- if not cfg_path.exists():
484
- return
485
- try:
486
- cfg = json.loads(cfg_path.read_text())
487
- except (json.JSONDecodeError, OSError):
488
- cfg = {}
489
- mcp = cfg.setdefault("mcpServers", {})
490
- entry = {
477
+ """Keep ~/.copilot/mcp-config.json and any .mcp.json in cwd in sync with current agent token."""
478
+ import shutil
479
+ sse_entry = {
491
480
  "type": "sse",
492
481
  "url": f"{api_url}/guard/mcp",
493
482
  "headers": {"Authorization": f"Bearer {agent_token}"},
494
483
  "tools": ["**"],
495
484
  }
496
- if mcp.get("conduct-guard") == entry:
497
- print(f" {GRAY}conduct-guard MCP already registered in GitHub Copilot{RESET}")
498
- return
499
- mcp["conduct-guard"] = entry
500
- cfg_path.write_text(json.dumps(cfg, indent=2))
501
- print(f" {GREEN}conduct-guard MCP registered in GitHub Copilot (SSE){RESET}")
485
+ booster_entry = {"command": "booster", "args": ["serve"]} if shutil.which("booster") else None
486
+
487
+ # ~/.copilot/mcp-config.json (global Copilot config)
488
+ global_path = Path.home() / ".copilot" / "mcp-config.json"
489
+ if global_path.exists():
490
+ _write_mcp_file(global_path, "conduct-guard", sse_entry, booster_entry, "GitHub Copilot (global)")
491
+
492
+ # .mcp.json in cwd (project-level, picked up by VS Code Copilot)
493
+ local_path = Path.cwd() / ".mcp.json"
494
+ if local_path.exists():
495
+ _write_mcp_file(local_path, "conduct-guard", sse_entry, booster_entry, "GitHub Copilot (.mcp.json)")
496
+
497
+
498
+ def _write_mcp_file(
499
+ cfg_path: Path,
500
+ guard_key: str,
501
+ sse_entry: dict,
502
+ booster_entry: dict | None,
503
+ label: str,
504
+ ) -> None:
505
+ try:
506
+ cfg = json.loads(cfg_path.read_text())
507
+ except (json.JSONDecodeError, OSError):
508
+ cfg = {}
509
+ mcp = cfg.setdefault("mcpServers", {})
510
+ changed = False
511
+ if mcp.get(guard_key) != sse_entry:
512
+ mcp[guard_key] = sse_entry
513
+ changed = True
514
+ print(f" {GREEN}conduct-guard MCP registered in {label}{RESET}")
515
+ else:
516
+ print(f" {GRAY}conduct-guard MCP already registered in {label}{RESET}")
517
+ if booster_entry and mcp.get("agent-booster") != booster_entry:
518
+ mcp["agent-booster"] = booster_entry
519
+ changed = True
520
+ if changed:
521
+ cfg_path.write_text(json.dumps(cfg, indent=2))
502
522
 
503
523
 
504
524
  _GUARD_RULES_TEXT = (
@@ -563,10 +583,12 @@ def _patch_tool_instruction_files(agent_token: str, api_url: str, dry_run: bool
563
583
  GUARD_RULES = _GUARD_RULES_TEXT
564
584
 
565
585
  # Pull team instructions from API (non-fatal)
586
+ # Only write if admin has explicitly published (version != "v0")
566
587
  team_content = ""
567
588
  try:
568
589
  resp = _req("GET", f"{api_url}/team-os/instructions", token=agent_token)
569
- team_content = (resp.get("content") or "").strip()
590
+ if (resp.get("version") or "v0") != "v0":
591
+ team_content = (resp.get("content") or "").strip()
570
592
  except Exception:
571
593
  pass
572
594
 
@@ -1082,7 +1104,7 @@ def _detect_ai_tools() -> list[dict]:
1082
1104
  def _check_json_mcp(path: Path) -> bool:
1083
1105
  try:
1084
1106
  d = json.loads(path.read_text()) if path.exists() else {}
1085
- return "conduct" in d.get("mcpServers", {})
1107
+ return any("conduct" in k for k in d.get("mcpServers", {}))
1086
1108
  except Exception:
1087
1109
  return False
1088
1110
 
@@ -1189,7 +1211,7 @@ def _detect_ai_tools() -> list[dict]:
1189
1211
  vscode_settings = next((p for p in vscode_candidates if p.exists()), None)
1190
1212
  try:
1191
1213
  d = json.loads(vscode_settings.read_text()) if vscode_settings else {}
1192
- mcp_reg = "conduct" in d.get("mcp", {}).get("servers", {})
1214
+ mcp_reg = any("conduct" in k for k in d.get("mcp", {}).get("servers", {}))
1193
1215
  except Exception:
1194
1216
  mcp_reg = False
1195
1217
  # Also check ~/.copilot/mcp-config.json (SSE entry written by guard sync)
@@ -2319,7 +2341,7 @@ def _discover_config_agents() -> list[tuple]:
2319
2341
  return "conduct" in text and "mcp_servers" in text
2320
2342
  import json as _j
2321
2343
  d = _j.loads(text)
2322
- return "conduct" in d.get("mcpServers", {})
2344
+ return any("conduct" in k for k in d.get("mcpServers", {}))
2323
2345
  except Exception:
2324
2346
  return False
2325
2347
 
@@ -2345,6 +2367,7 @@ def _discover_config_agents() -> list[tuple]:
2345
2367
  ("codex", home / ".codex", home / ".codex" / "config.toml", "mcp"),
2346
2368
  ("cursor", home / ".cursor", home / ".cursor" / "mcp.json", "mcp"),
2347
2369
  ("windsurf", home / ".codeium" / "windsurf", home / ".codeium" / "windsurf" / "mcp_config.json", "mcp"),
2370
+ ("copilot", home / ".copilot", home / ".copilot" / "mcp-config.json", "mcp"),
2348
2371
  ]
2349
2372
  for name, check_dir, config_path, _ in TOOLS:
2350
2373
  if check_dir.exists():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.7.45
3
+ Version: 0.7.48
4
4
  Summary: CLI for Conduct AI — secure, govern, install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -36,6 +36,7 @@ tests/test_drain_daemon_health.py
36
36
  tests/test_guard_policy.py
37
37
  tests/test_guard_savings.py
38
38
  tests/test_hook_dispatch.py
39
+ tests/test_instruction_files.py
39
40
  tests/test_journal_drain.py
40
41
  tests/test_log_util.py
41
42
  tests/test_proxy_coverage.py
@@ -0,0 +1,263 @@
1
+ """
2
+ Tests for AI Rollout (epic #961) — CLI instruction file management.
3
+
4
+ Covers:
5
+ - _patch_tool_instruction_files: new file, idempotent re-sync, dry_run, existing content preserved
6
+ - _reset_tool_instruction_files: strips markers, preserves surrounding content
7
+ - _check_instructions_staleness: stale version, up-to-date, no local version
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ from io import BytesIO
14
+ from pathlib import Path
15
+ from unittest.mock import MagicMock, patch, call
16
+
17
+ import pytest
18
+
19
+ # ── ensure conduct_cli is importable ─────────────────────────────────────────
20
+ CONDUCT_CLI_SRC = Path(__file__).resolve().parent.parent / "src"
21
+ if str(CONDUCT_CLI_SRC) not in sys.path:
22
+ sys.path.insert(0, str(CONDUCT_CLI_SRC))
23
+
24
+ import conduct_cli.guard as guard_mod
25
+ from conduct_cli.guard import _patch_tool_instruction_files, _reset_tool_instruction_files
26
+
27
+ MARKER_START = "<!-- ConductGuard — managed by conduct guard sync -->"
28
+ MARKER_END = "<!-- /ConductGuard -->"
29
+
30
+ # Five target filenames relative to repo root
31
+ _TARGET_NAMES = [
32
+ "CLAUDE.md",
33
+ ".github/copilot-instructions.md",
34
+ "AGENTS.md",
35
+ ".cursorrules",
36
+ ".windsurfrules",
37
+ ]
38
+
39
+
40
+ def _make_api_response(content: str = "# team rules\n", version: str = "v1") -> MagicMock:
41
+ """Return a mock urllib response yielding the given JSON payload."""
42
+ payload = json.dumps({"content": content, "version": version}).encode()
43
+ mock_resp = MagicMock()
44
+ mock_resp.read.return_value = payload
45
+ mock_resp.__enter__ = lambda s: s
46
+ mock_resp.__exit__ = MagicMock(return_value=False)
47
+ return mock_resp
48
+
49
+
50
+ def _redirect_cwd_and_home(tmp_path: Path, monkeypatch) -> Path:
51
+ """
52
+ Point Path.cwd() and Path.home() at tmp_path sub-dirs.
53
+ Also create a fake .git so _patch_tool_instruction_files finds a repo_root.
54
+ Returns the repo root path.
55
+ """
56
+ repo = tmp_path / "repo"
57
+ repo.mkdir()
58
+ (repo / ".git").mkdir()
59
+
60
+ home = tmp_path / "home"
61
+ home.mkdir()
62
+
63
+ monkeypatch.chdir(repo)
64
+ monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
65
+ return repo
66
+
67
+
68
+ # ─────────────────────────────────────────────────────────────────────────────
69
+ # 1. New file created — all 5 targets get marker block
70
+ # ─────────────────────────────────────────────────────────────────────────────
71
+
72
+ def test_patch_creates_new_files_with_marker_block(tmp_path, monkeypatch, capsys):
73
+ repo = _redirect_cwd_and_home(tmp_path, monkeypatch)
74
+
75
+ mock_resp = _make_api_response("# team rules\n", "v1")
76
+ with patch("conduct_cli.guard.urllib.request.urlopen", return_value=mock_resp):
77
+ _patch_tool_instruction_files(
78
+ agent_token="tok-123",
79
+ api_url="https://api.conductai.ai",
80
+ dry_run=False,
81
+ )
82
+
83
+ for rel in _TARGET_NAMES:
84
+ p = repo / rel
85
+ assert p.exists(), f"Expected {rel} to be created"
86
+ text = p.read_text()
87
+ assert MARKER_START in text, f"{rel} missing marker start"
88
+ assert MARKER_END in text, f"{rel} missing marker end"
89
+
90
+
91
+ # ─────────────────────────────────────────────────────────────────────────────
92
+ # 2. Idempotent re-sync — marker block appears exactly once
93
+ # ─────────────────────────────────────────────────────────────────────────────
94
+
95
+ def test_patch_idempotent_resync(tmp_path, monkeypatch, capsys):
96
+ repo = _redirect_cwd_and_home(tmp_path, monkeypatch)
97
+
98
+ mock_resp = _make_api_response("# team rules\n", "v1")
99
+ with patch("conduct_cli.guard.urllib.request.urlopen", return_value=mock_resp):
100
+ _patch_tool_instruction_files("tok", "https://api.conductai.ai", dry_run=False)
101
+
102
+ mock_resp2 = _make_api_response("# team rules\n", "v1")
103
+ with patch("conduct_cli.guard.urllib.request.urlopen", return_value=mock_resp2):
104
+ _patch_tool_instruction_files("tok", "https://api.conductai.ai", dry_run=False)
105
+
106
+ for rel in _TARGET_NAMES:
107
+ p = repo / rel
108
+ text = p.read_text()
109
+ assert text.count(MARKER_START) == 1, f"{rel} should have exactly one marker block after two syncs"
110
+ assert text.count(MARKER_END) == 1, f"{rel} should have exactly one end marker"
111
+
112
+
113
+ # ─────────────────────────────────────────────────────────────────────────────
114
+ # 3. dry_run=True — no files written, something printed
115
+ # ─────────────────────────────────────────────────────────────────────────────
116
+
117
+ def test_patch_dry_run_does_not_write_files(tmp_path, monkeypatch, capsys):
118
+ repo = _redirect_cwd_and_home(tmp_path, monkeypatch)
119
+
120
+ mock_resp = _make_api_response("# team rules\n", "v1")
121
+ with patch("conduct_cli.guard.urllib.request.urlopen", return_value=mock_resp):
122
+ _patch_tool_instruction_files("tok", "https://api.conductai.ai", dry_run=True)
123
+
124
+ for rel in _TARGET_NAMES:
125
+ p = repo / rel
126
+ assert not p.exists(), f"{rel} should NOT be created in dry_run mode"
127
+
128
+ out = capsys.readouterr().out
129
+ assert out.strip(), "Expected at least some output in dry_run mode"
130
+ assert "[dry-run]" in out
131
+
132
+
133
+ # ─────────────────────────────────────────────────────────────────────────────
134
+ # 4. Existing developer content preserved
135
+ # ─────────────────────────────────────────────────────────────────────────────
136
+
137
+ def test_patch_preserves_existing_developer_content(tmp_path, monkeypatch, capsys):
138
+ repo = _redirect_cwd_and_home(tmp_path, monkeypatch)
139
+
140
+ claude_md = repo / "CLAUDE.md"
141
+ claude_md.write_text("# My rules\nDo X\n")
142
+
143
+ mock_resp = _make_api_response("# team rules\n", "v1")
144
+ with patch("conduct_cli.guard.urllib.request.urlopen", return_value=mock_resp):
145
+ _patch_tool_instruction_files("tok", "https://api.conductai.ai", dry_run=False)
146
+
147
+ text = claude_md.read_text()
148
+ assert "# My rules" in text, "Original developer content should be preserved"
149
+ assert "Do X" in text, "Original developer content should be preserved"
150
+ assert MARKER_START in text, "Marker block should be appended"
151
+ assert MARKER_END in text, "Marker block end should be present"
152
+
153
+
154
+ # ─────────────────────────────────────────────────────────────────────────────
155
+ # 5. _reset_tool_instruction_files — strips markers, preserves surrounding content
156
+ # ─────────────────────────────────────────────────────────────────────────────
157
+
158
+ def test_reset_strips_marker_block_and_preserves_content(tmp_path, monkeypatch, capsys):
159
+ repo = _redirect_cwd_and_home(tmp_path, monkeypatch)
160
+
161
+ # _reset uses a slightly different marker format (without the long comment)
162
+ # Match what _reset_tool_instruction_files actually searches for
163
+ RESET_MARKER_START = "<!-- ConductGuard -->"
164
+ RESET_MARKER_END = "<!-- /ConductGuard -->"
165
+ block = f"\n{RESET_MARKER_START}\n## Rules\nDo guard stuff.\n{RESET_MARKER_END}\n"
166
+
167
+ claude_md = repo / "CLAUDE.md"
168
+ claude_md.write_text(f"# Dev content\nKeep this.\n{block}\n# After block\n")
169
+
170
+ # _reset uses Path.cwd() directly for repo targets and Path.home() for home
171
+ _reset_tool_instruction_files()
172
+
173
+ text = claude_md.read_text()
174
+ assert RESET_MARKER_START not in text, "Marker start should be stripped"
175
+ assert RESET_MARKER_END not in text, "Marker end should be stripped"
176
+ assert "Keep this." in text, "Content before marker block must be preserved"
177
+
178
+
179
+ def test_reset_no_marker_prints_notice(tmp_path, monkeypatch, capsys):
180
+ repo = _redirect_cwd_and_home(tmp_path, monkeypatch)
181
+
182
+ claude_md = repo / "CLAUDE.md"
183
+ claude_md.write_text("# No guard block here\n")
184
+
185
+ _reset_tool_instruction_files()
186
+
187
+ out = capsys.readouterr().out
188
+ assert "No ConductGuard block" in out or "No ConductGuard blocks" in out
189
+
190
+
191
+ # ─────────────────────────────────────────────────────────────────────────────
192
+ # 6-8. _check_instructions_staleness
193
+ # ─────────────────────────────────────────────────────────────────────────────
194
+
195
+ # Re-import from session_start
196
+ SESSION_HOOK_SRC = CONDUCT_CLI_SRC
197
+ if str(SESSION_HOOK_SRC) not in sys.path:
198
+ sys.path.insert(0, str(SESSION_HOOK_SRC))
199
+
200
+ from conduct_cli.hooks.session_start import _check_instructions_staleness
201
+
202
+
203
+ def _make_version_response(version: str) -> MagicMock:
204
+ payload = json.dumps({"version": version}).encode()
205
+ mock_resp = MagicMock()
206
+ mock_resp.read.return_value = payload
207
+ mock_resp.__enter__ = lambda s: s
208
+ mock_resp.__exit__ = MagicMock(return_value=False)
209
+ return mock_resp
210
+
211
+
212
+ # 6. Stale: local v1, remote v2 → warning printed
213
+ def test_staleness_check_stale_prints_warning(capsys):
214
+ cfg = {
215
+ "api_url": "https://api.conductai.ai",
216
+ "agent_token": "tok-abc",
217
+ "instructions_version": "v1",
218
+ }
219
+ remote_resp = _make_version_response("v2")
220
+
221
+ # urllib.request is imported inside the function body as a local alias (_req).
222
+ # Patch the canonical module so the alias resolves to our mock.
223
+ with patch("conduct_cli.hooks.session_start.load_config", return_value=cfg), \
224
+ patch("urllib.request.urlopen", return_value=remote_resp):
225
+ _check_instructions_staleness()
226
+
227
+ out = capsys.readouterr().out
228
+ assert "v1" in out and "v2" in out, "Warning should mention both versions"
229
+ assert "conduct guard sync" in out, "Warning should suggest running sync"
230
+
231
+
232
+ # 7. Up to date: local v1, remote v1 → nothing printed
233
+ def test_staleness_check_up_to_date_no_output(capsys):
234
+ cfg = {
235
+ "api_url": "https://api.conductai.ai",
236
+ "agent_token": "tok-abc",
237
+ "instructions_version": "v1",
238
+ }
239
+ remote_resp = _make_version_response("v1")
240
+
241
+ with patch("conduct_cli.hooks.session_start.load_config", return_value=cfg), \
242
+ patch("urllib.request.urlopen", return_value=remote_resp):
243
+ _check_instructions_staleness()
244
+
245
+ out = capsys.readouterr().out
246
+ assert out.strip() == "", f"Expected no output when up-to-date, got: {repr(out)}"
247
+
248
+
249
+ # 8. No local version → returns silently without making HTTP call
250
+ def test_staleness_check_no_local_version_silent(capsys):
251
+ cfg = {
252
+ "api_url": "https://api.conductai.ai",
253
+ "agent_token": "tok-abc",
254
+ # no instructions_version key
255
+ }
256
+
257
+ with patch("conduct_cli.hooks.session_start.load_config", return_value=cfg), \
258
+ patch("urllib.request.urlopen") as mock_open:
259
+ _check_instructions_staleness()
260
+
261
+ mock_open.assert_not_called()
262
+ out = capsys.readouterr().out
263
+ assert out.strip() == "", "Expected no output when no local version"
File without changes
File without changes
File without changes