conduct-cli 0.7.44__tar.gz → 0.7.46__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.
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/PKG-INFO +1 -1
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/pyproject.toml +1 -1
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli/guard.py +68 -13
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli.egg-info/PKG-INFO +1 -1
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli.egg-info/SOURCES.txt +1 -0
- conduct_cli-0.7.46/tests/test_instruction_files.py +263 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/README.md +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/setup.cfg +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/setup.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli/__init__.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli/api.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli/guardmcp.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli/hooks/__init__.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli/hooks/base.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli/hooks/posttooluse.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli/hooks/precompact.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli/hooks/pretooluse.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli/hooks/session_parser.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli/hooks/session_report_push.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli/hooks/session_start.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli/hooks/stop.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli/log_util.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli/main.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli/mcp_server.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli/memory.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli/paxel.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli/tool_groups.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli.egg-info/dependency_links.txt +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli.egg-info/entry_points.txt +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli.egg-info/requires.txt +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/src/conduct_cli.egg-info/top_level.txt +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/tests/test_advisory_and_hook.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/tests/test_api_client.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/tests/test_bash_operator_signature.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/tests/test_blast_radius.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/tests/test_cmd_run.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/tests/test_command_word_matcher.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/tests/test_drain_daemon_health.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/tests/test_guard_policy.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/tests/test_guard_savings.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/tests/test_hook_dispatch.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/tests/test_journal_drain.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/tests/test_log_util.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/tests/test_proxy_coverage.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/tests/test_proxy_env.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/tests/test_signed_policy.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/tests/test_switch.py +0 -0
- {conduct_cli-0.7.44 → conduct_cli-0.7.46}/tests/test_token_lifecycle.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "conduct-cli"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.46"
|
|
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" }
|
|
@@ -388,7 +388,7 @@ _MCP_TARGETS = [
|
|
|
388
388
|
]
|
|
389
389
|
|
|
390
390
|
|
|
391
|
-
def _register_mcp(workspace_id: str, agent_token: str, api_url: str) -> None:
|
|
391
|
+
def _register_mcp(workspace_id: str, agent_token: str, api_url: str, dry_run: bool = False) -> None:
|
|
392
392
|
"""Write conductguard + agent-booster MCP entries into every AI tool config found.
|
|
393
393
|
|
|
394
394
|
Credentials are NOT stored in the MCP config — the server reads them from
|
|
@@ -441,7 +441,7 @@ def _register_mcp(workspace_id: str, agent_token: str, api_url: str) -> None:
|
|
|
441
441
|
|
|
442
442
|
# Cursor global rules — write Guard policies as user rules so they apply across all projects.
|
|
443
443
|
_patch_cursor_global_rules()
|
|
444
|
-
_patch_tool_instruction_files(agent_token, api_url)
|
|
444
|
+
_patch_tool_instruction_files(agent_token, api_url, dry_run=dry_run)
|
|
445
445
|
|
|
446
446
|
|
|
447
447
|
def _patch_claude_desktop_proxy(api_url: str, agent_token: str) -> None:
|
|
@@ -542,7 +542,7 @@ def _patch_cursor_global_rules() -> None:
|
|
|
542
542
|
print(f" {GREEN}Cursor global rules updated with ConductGuard policy{RESET}")
|
|
543
543
|
|
|
544
544
|
|
|
545
|
-
def _patch_tool_instruction_files(agent_token: str, api_url: str) -> None:
|
|
545
|
+
def _patch_tool_instruction_files(agent_token: str, api_url: str, dry_run: bool = False) -> None:
|
|
546
546
|
"""Write ConductGuard policy block into instruction files for all AI tools.
|
|
547
547
|
|
|
548
548
|
Targets:
|
|
@@ -563,10 +563,12 @@ def _patch_tool_instruction_files(agent_token: str, api_url: str) -> None:
|
|
|
563
563
|
GUARD_RULES = _GUARD_RULES_TEXT
|
|
564
564
|
|
|
565
565
|
# Pull team instructions from API (non-fatal)
|
|
566
|
+
# Only write if admin has explicitly published (version != "v0")
|
|
566
567
|
team_content = ""
|
|
567
568
|
try:
|
|
568
569
|
resp = _req("GET", f"{api_url}/team-os/instructions", token=agent_token)
|
|
569
|
-
|
|
570
|
+
if (resp.get("version") or "v0") != "v0":
|
|
571
|
+
team_content = (resp.get("content") or "").strip()
|
|
570
572
|
except Exception:
|
|
571
573
|
pass
|
|
572
574
|
|
|
@@ -597,19 +599,64 @@ def _patch_tool_instruction_files(agent_token: str, api_url: str) -> None:
|
|
|
597
599
|
path = repo_path if (repo_path and repo_path.exists()) else global_path
|
|
598
600
|
if not path.exists() and repo_path:
|
|
599
601
|
path = repo_path
|
|
600
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
601
602
|
existing = path.read_text() if path.exists() else ""
|
|
602
603
|
if MARKER_START in existing:
|
|
603
604
|
updated = pattern.sub(guard_block, existing)
|
|
604
|
-
if
|
|
605
|
-
|
|
605
|
+
if dry_run:
|
|
606
|
+
if updated == existing:
|
|
607
|
+
print(f" [dry-run] No change: {path}")
|
|
608
|
+
else:
|
|
609
|
+
print(f" [dry-run] Would write: {path}")
|
|
606
610
|
else:
|
|
607
|
-
path.
|
|
608
|
-
|
|
611
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
612
|
+
if updated == existing:
|
|
613
|
+
print(f" {GRAY}{label} already up to date{RESET}")
|
|
614
|
+
else:
|
|
615
|
+
path.write_text(updated)
|
|
616
|
+
print(f" {GREEN}{label} updated{RESET}")
|
|
609
617
|
else:
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
618
|
+
new_content = (existing.rstrip() + ("\n\n" if existing.strip() else "") + guard_block + "\n").lstrip()
|
|
619
|
+
if dry_run:
|
|
620
|
+
if new_content == existing:
|
|
621
|
+
print(f" [dry-run] No change: {path}")
|
|
622
|
+
else:
|
|
623
|
+
print(f" [dry-run] Would write: {path}")
|
|
624
|
+
else:
|
|
625
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
626
|
+
path.write_text(new_content)
|
|
627
|
+
print(f" {GREEN}{label} — ConductGuard block added{RESET}")
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _reset_tool_instruction_files() -> None:
|
|
631
|
+
"""Remove ConductGuard blocks from all instruction files."""
|
|
632
|
+
import re as _re
|
|
633
|
+
MARKER_RE = _re.compile(
|
|
634
|
+
r"\n?<!-- ConductGuard -->\n.*?<!-- /ConductGuard -->\n?",
|
|
635
|
+
_re.DOTALL,
|
|
636
|
+
)
|
|
637
|
+
targets = [
|
|
638
|
+
Path.home() / "CLAUDE.md",
|
|
639
|
+
Path.cwd() / "CLAUDE.md",
|
|
640
|
+
Path.cwd() / ".github" / "copilot-instructions.md",
|
|
641
|
+
Path.cwd() / "AGENTS.md",
|
|
642
|
+
Path.cwd() / ".cursorrules",
|
|
643
|
+
Path.cwd() / ".windsurfrules",
|
|
644
|
+
]
|
|
645
|
+
removed = 0
|
|
646
|
+
for p in targets:
|
|
647
|
+
if not p.exists():
|
|
648
|
+
continue
|
|
649
|
+
original = p.read_text(encoding="utf-8")
|
|
650
|
+
cleaned = MARKER_RE.sub("", original).strip()
|
|
651
|
+
if cleaned != original.strip():
|
|
652
|
+
p.write_text(cleaned + "\n", encoding="utf-8")
|
|
653
|
+
print(f" {GREEN}Removed ConductGuard block: {p}{RESET}")
|
|
654
|
+
removed += 1
|
|
655
|
+
else:
|
|
656
|
+
print(f" {YELLOW}No ConductGuard block found: {p}{RESET}")
|
|
657
|
+
if removed == 0:
|
|
658
|
+
print(" No ConductGuard blocks found in any instruction files.")
|
|
659
|
+
|
|
613
660
|
|
|
614
661
|
def _install_codex_hook(hook_path: Path) -> None:
|
|
615
662
|
"""Register PreToolUse and PostToolUse hooks in ~/.codex/hooks.json."""
|
|
@@ -1319,6 +1366,12 @@ def cmd_guard_sync(args):
|
|
|
1319
1366
|
# Persona selection — prompt once, skip if already chosen
|
|
1320
1367
|
|
|
1321
1368
|
dry_run = getattr(args, "dry_run", False)
|
|
1369
|
+
|
|
1370
|
+
if getattr(args, "reset_instructions", False):
|
|
1371
|
+
print("Removing ConductGuard blocks from instruction files…")
|
|
1372
|
+
_reset_tool_instruction_files()
|
|
1373
|
+
return
|
|
1374
|
+
|
|
1322
1375
|
print(f"{'[dry-run] ' if dry_run else ''}Syncing policy…")
|
|
1323
1376
|
_check_and_upgrade_packages()
|
|
1324
1377
|
|
|
@@ -1463,7 +1516,7 @@ def cmd_guard_sync(args):
|
|
|
1463
1516
|
_install_claude_hook(hook_path)
|
|
1464
1517
|
_install_codex_hook(hook_path)
|
|
1465
1518
|
cfg2 = _load_guard_config()
|
|
1466
|
-
_register_mcp(workspace_id, cfg2.get("agent_token", ""), base_url)
|
|
1519
|
+
_register_mcp(workspace_id, cfg2.get("agent_token", ""), base_url, dry_run=dry_run)
|
|
1467
1520
|
try:
|
|
1468
1521
|
_install_session_hooks()
|
|
1469
1522
|
except Exception:
|
|
@@ -2176,6 +2229,8 @@ def register_guard_parser(sub):
|
|
|
2176
2229
|
sync_p = guard_sub.add_parser("sync", help="Refresh policy and re-scan for AI tools")
|
|
2177
2230
|
sync_p.add_argument("--cursor", action="store_true", help="Write active Guard policies to .cursorrules")
|
|
2178
2231
|
sync_p.add_argument("--dry-run", action="store_true", help="Preview policy changes without writing anything")
|
|
2232
|
+
sync_p.add_argument("--reset-instructions", action="store_true", dest="reset_instructions",
|
|
2233
|
+
help="Remove ConductGuard blocks from all instruction files")
|
|
2179
2234
|
sync_p.add_argument("--proxy-url", default=None,
|
|
2180
2235
|
help="Override the Guard proxy URL (default: https://api.conductai.ai/proxy/anthropic)")
|
|
2181
2236
|
sync_p.add_argument("--no-local-audit", action="store_true",
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|